From 8d4899ca15bfad6dfceaa9cb90ab3ee6811b7b6b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:02:22 +0200 Subject: [PATCH 01/25] chore(deps): lock file maintenance (#661) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- bun.lock | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/bun.lock b/bun.lock index 5d2260dc..2b596bf7 100644 --- a/bun.lock +++ b/bun.lock @@ -298,7 +298,7 @@ "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], - "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], + "ansi-regex": ["ansi-regex@6.4.0", "", {}, "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw=="], "ansi-styles": ["ansi-styles@7.0.0", "", {}, "sha512-kKvt3m4uwzqL0wlkPd09CmljPJGOZZ4D0fP65sqFSvPkMRKhNi+74MgIJ5QxE6SxqB4t4KyUFGg8+n5zjo6hew=="], @@ -602,7 +602,7 @@ "typescript-eslint": ["typescript-eslint@8.70.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.70.1", "@typescript-eslint/parser": "8.70.1", "@typescript-eslint/typescript-estree": "8.70.1", "@typescript-eslint/utils": "8.70.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA=="], - "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + "uint8array-extras": ["uint8array-extras@1.6.0", "", {}, "sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q=="], "unbash": ["unbash@4.0.11", "", {}, "sha512-FoSOKV7NEofQSkAefMVHam4ZPKYMxjAydxiV72UFEDNV/YofxjGfiZ2A9pZjdL/lRJzTjcu4PABo1JYJX8N5iQ=="], @@ -638,9 +638,9 @@ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], @@ -670,8 +670,6 @@ "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], - "bun-types/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], From 49d1db8dd06134f4423d5c1a447cce9c920156bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emrik=20=C3=96stling?= Date: Wed, 30 Sep 2026 22:36:25 +0200 Subject: [PATCH 02/25] chore: allow bun in renovate again (#662) --- renovate.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/renovate.json b/renovate.json index 6eb40ebf..3418a449 100644 --- a/renovate.json +++ b/renovate.json @@ -4,6 +4,5 @@ "lockFileMaintenance": { "enabled": true, "automerge": true - }, - "ignoreDeps": ["bun-types", "@types/bun", "bun", "Bun"] + } } From 23697c57f33d1248b017bc0f95c53d64a7834863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emrik=20=C3=96stling?= Date: Sun, 4 Oct 2026 20:47:19 +0200 Subject: [PATCH 03/25] chore: Remove stale Repobeats analytics image --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 458bcddc..5a31d39f 100644 --- a/README.md +++ b/README.md @@ -162,8 +162,6 @@ Use [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/#summar Image with all contributors -![Alt](https://repobeats.axiom.co/api/embed/dcdabd0564fcdcccbf5680c1bdc2efad54a3d4d9.svg "Repobeats analytics image") - ## Star History From 87ba195fd52d559b1c5e0e979dc00b57bba5abd2 Mon Sep 17 00:00:00 2001 From: Orban Gergo Date: Sun, 4 Oct 2026 22:52:53 +0300 Subject: [PATCH 04/25] fix(ffmpeg): handle embedded cover art in audio conversions (#607) --- src/converters/ffmpeg.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/converters/ffmpeg.ts b/src/converters/ffmpeg.ts index 0a118356..6182cdf5 100644 --- a/src/converters/ffmpeg.ts +++ b/src/converters/ffmpeg.ts @@ -721,6 +721,24 @@ export async function convert( let extraArgs: string[] = []; let message = "Done"; + const audioFormatsWithCover = ["m4a", "mp3", "flac", "ogg"]; + const audioFormatsNoCover = ["wav", "aac"]; + + if (audioFormatsWithCover.includes(convertTo)) { + extraArgs.push( + "-map", + "0:a", + "-map", + "0:v:disp:attached_pic?", + "-c:v", + "copy", + "-disposition:v", + "attached_pic", + ); + } else if (audioFormatsNoCover.includes(convertTo)) { + extraArgs.push("-vn"); + } + if (convertTo === "ico") { // Make sure image is 256x256 or smaller extraArgs = [ From 7fa8c9819e23596c21e2336304b856b9e0953990 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:41:15 +0200 Subject: [PATCH 05/25] chore(deps): lock file maintenance (#666) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- bun.lock | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/bun.lock b/bun.lock index 2b596bf7..3a9baf45 100644 --- a/bun.lock +++ b/bun.lock @@ -266,29 +266,29 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/node": ["@types/node@24.19.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew=="], + "@types/node": ["@types/node@24.19.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ=="], "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.70.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.70.1", "@typescript-eslint/type-utils": "8.70.1", "@typescript-eslint/utils": "8.70.1", "@typescript-eslint/visitor-keys": "8.70.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.70.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.71.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.71.0", "@typescript-eslint/type-utils": "8.71.0", "@typescript-eslint/utils": "8.71.0", "@typescript-eslint/visitor-keys": "8.71.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.71.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.70.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.70.1", "@typescript-eslint/types": "8.70.1", "@typescript-eslint/typescript-estree": "8.70.1", "@typescript-eslint/visitor-keys": "8.70.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.71.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.71.0", "@typescript-eslint/types": "8.71.0", "@typescript-eslint/typescript-estree": "8.71.0", "@typescript-eslint/visitor-keys": "8.71.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.70.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.70.1", "@typescript-eslint/types": "^8.70.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.71.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.71.0", "@typescript-eslint/types": "^8.71.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.70.1", "", { "dependencies": { "@typescript-eslint/types": "8.70.1", "@typescript-eslint/visitor-keys": "8.70.1" } }, "sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.71.0", "", { "dependencies": { "@typescript-eslint/types": "8.71.0", "@typescript-eslint/visitor-keys": "8.71.0" } }, "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.70.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.71.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.70.1", "", { "dependencies": { "@typescript-eslint/types": "8.70.1", "@typescript-eslint/typescript-estree": "8.70.1", "@typescript-eslint/utils": "8.70.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.71.0", "", { "dependencies": { "@typescript-eslint/types": "8.71.0", "@typescript-eslint/typescript-estree": "8.71.0", "@typescript-eslint/utils": "8.71.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.70.1", "", {}, "sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.71.0", "", {}, "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.70.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.70.1", "@typescript-eslint/tsconfig-utils": "8.70.1", "@typescript-eslint/types": "8.70.1", "@typescript-eslint/visitor-keys": "8.70.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.71.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.71.0", "@typescript-eslint/tsconfig-utils": "8.71.0", "@typescript-eslint/types": "8.71.0", "@typescript-eslint/visitor-keys": "8.71.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.70.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.70.1", "@typescript-eslint/types": "8.70.1", "@typescript-eslint/typescript-estree": "8.70.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.71.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.71.0", "@typescript-eslint/types": "8.71.0", "@typescript-eslint/typescript-estree": "8.71.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.70.1", "", { "dependencies": { "@typescript-eslint/types": "8.70.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.71.0", "", { "dependencies": { "@typescript-eslint/types": "8.71.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A=="], "@valibot/to-json-schema": ["@valibot/to-json-schema@1.8.0", "", { "peerDependencies": { "valibot": "^1.5.0" } }, "sha512-a0M+uwCuQZEPAo65NYkFSJ14O5c213KoSZmPdoCfuFvUhfULi4T2Z6Tpjv6lTVMW9RoLm74RpRAjNEa43KcN+g=="], @@ -340,15 +340,15 @@ "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - "enhanced-resolve": ["enhanced-resolve@5.25.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w=="], + "enhanced-resolve": ["enhanced-resolve@5.26.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@10.11.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ=="], + "eslint": ["eslint@10.12.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA=="], - "eslint-plugin-better-tailwindcss": ["eslint-plugin-better-tailwindcss@4.7.0", "", { "dependencies": { "@eslint/css-tree": "^4.0.4", "@valibot/to-json-schema": "^1.7.1", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "synckit": "^0.11.13", "tailwind-csstree": "^0.3.3", "tsconfig-paths-webpack-plugin": "^4.2.0", "valibot": "^1.4.2" }, "peerDependencies": { "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", "oxlint": "^1.35.0", "tailwindcss": "^3.3.0 || ^4.1.17" }, "optionalPeers": ["eslint", "oxlint"] }, "sha512-lrdlVW4pzLPj/zX5HRqMhKPesGijDfRhnSRJMlNcsrCyJHsndmHCLKTiRNa1eREUZ6G3D3QjdLc+G4tlfGrmkw=="], + "eslint-plugin-better-tailwindcss": ["eslint-plugin-better-tailwindcss@4.8.0", "", { "dependencies": { "@eslint/css-tree": "^4.0.4", "@valibot/to-json-schema": "^1.7.1", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "synckit": "^0.11.13", "tailwind-csstree": "^0.3.3", "tsconfig-paths-webpack-plugin": "^4.2.0", "valibot": "^1.4.2" }, "peerDependencies": { "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", "oxlint": "^1.35.0", "tailwindcss": "^3.3.0 || ^4.1.17" }, "optionalPeers": ["eslint", "oxlint"] }, "sha512-E3eiSJg8iq/FOZFejyT364s3M/GVdlnkr1bNizSRRueAZXKzHwZnC84TZkOJvezRdWM6ZigJ/g4hLbOafwXfcA=="], "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], @@ -400,7 +400,7 @@ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - "globals": ["globals@17.12.0", "", {}, "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA=="], + "globals": ["globals@17.13.0", "", {}, "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -438,7 +438,7 @@ "keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="], - "knip": ["knip@6.38.0", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.7.0", "get-tsconfig": "4.14.3", "jiti": "^2.7.0", "oxc-parser": "^0.150.0", "oxc-resolver": "11.24.2", "picomatch": "^4.0.7", "smol-toml": "^1.8.0", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.17", "unbash": "^4.0.11", "yaml": "^2.9.1", "zod": "^4.4.3" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-umytiMCaZf02MV9SOO1FEDgbDf6h20VZk3ruJE0kh52kkGjYZFRUAXHJWYoLhvhoHGINO/7GXVJmQ2vxTBvl6A=="], + "knip": ["knip@6.39.0", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.7.0", "get-tsconfig": "4.14.3", "jiti": "^2.7.0", "oxc-parser": "^0.150.0", "oxc-resolver": "11.24.2", "picomatch": "^4.0.7", "smol-toml": "^1.8.0", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.17", "unbash": "^4.0.11", "yaml": "^2.9.1", "zod": "^4.4.3" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-Y7g6Ve1ATMox1u1v+gx3z9LTlvzVLTJISm/LVn9TgBU4qc7OoC63AWGO8qCFPVDDdaqlYdXkDDvvO1METBfTRw=="], "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], @@ -550,11 +550,11 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "shell-quote": ["shell-quote@1.10.0", "", {}, "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="], + "shell-quote": ["shell-quote@1.12.0", "", {}, "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q=="], "smol-toml": ["smol-toml@1.9.0", "", {}, "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "string-width": ["string-width@8.3.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ=="], @@ -600,11 +600,11 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "typescript-eslint": ["typescript-eslint@8.70.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.70.1", "@typescript-eslint/parser": "8.70.1", "@typescript-eslint/typescript-estree": "8.70.1", "@typescript-eslint/utils": "8.70.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA=="], + "typescript-eslint": ["typescript-eslint@8.71.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.71.0", "@typescript-eslint/parser": "8.71.0", "@typescript-eslint/typescript-estree": "8.71.0", "@typescript-eslint/utils": "8.71.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw=="], "uint8array-extras": ["uint8array-extras@1.6.0", "", {}, "sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q=="], - "unbash": ["unbash@4.0.11", "", {}, "sha512-FoSOKV7NEofQSkAefMVHam4ZPKYMxjAydxiV72UFEDNV/YofxjGfiZ2A9pZjdL/lRJzTjcu4PABo1JYJX8N5iQ=="], + "unbash": ["unbash@4.0.12", "", {}, "sha512-PnKvRX+Fbxl/usVlTr/31tXIOVAH+8OdC9qaxUQcvurwEnoc3Vk/ipi2Ce8IjMxzK9byfSGdsUBjqqJGc1o5lw=="], "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], @@ -650,9 +650,9 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.10", "", {}, "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.12", "", {}, "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg=="], - "bun-types/@types/node": ["@types/node@26.6.3", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg=="], + "bun-types/@types/node": ["@types/node@26.6.4", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="], "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], From 36cee01dde29e8998f804a23a1408394ec1fd8c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emrik=20=C3=96stling?= Date: Tue, 6 Oct 2026 17:45:45 +0200 Subject: [PATCH 06/25] 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> --- .github/workflows/docker-publish.yml | 15 +++ .github/workflows/run-bun-test.yml | 4 +- compose.yaml | 2 + package.json | 5 +- src/helpers/printVersions.ts | 35 +++--- tests/helpers/printVersions.test.ts | 146 ------------------------ tests/integration/printVersions.test.ts | 55 +++++++++ 7 files changed, 97 insertions(+), 165 deletions(-) delete mode 100644 tests/helpers/printVersions.test.ts create mode 100644 tests/integration/printVersions.test.ts diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index e56d7c1a..9158d73d 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 dd120706..debd5283 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 1bb0d99c..89c7bb66 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 0ad46b20..43a37f05 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 c17ef9f3..57b5bf0a 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 28da1174..00000000 --- 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 00000000..37729f59 --- /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); From 3f69a7acb24f0e3d390c774b86123107378f4c76 Mon Sep 17 00:00:00 2001 From: Justin Shetty Date: Tue, 6 Oct 2026 08:48:47 -0700 Subject: [PATCH 07/25] feat(auth): prettier login failure page (#665) --- src/pages/user.tsx | 291 ++++++++++++++++++++++++--------------------- 1 file changed, 154 insertions(+), 137 deletions(-) diff --git a/src/pages/user.tsx b/src/pages/user.tsx index fe9d7308..29a24483 100644 --- a/src/pages/user.tsx +++ b/src/pages/user.tsx @@ -6,10 +6,10 @@ import { User } from "../db/types"; import { ACCOUNT_REGISTRATION, ALLOW_UNAUTHENTICATED, + BRANDING, HIDE_HISTORY, HTTP_ALLOWED, WEBROOT, - BRANDING, } from "../helpers/env"; import { userService } from "../services/user"; @@ -17,6 +17,150 @@ export { userService } from "../services/user"; export let FIRST_RUN = db.query("SELECT * FROM users").get() === null || false; +function ErrorBanner({ message }: { message: string }) { + return ( + + ); +} + +function LoginPage({ error, email = "" }: { error?: string; email?: string }) { + return ( + + <> +
+
+ +
+ + + ); +} + +function AccountPage({ email, error }: { email: string; error?: string }) { + return ( + + <> +
+
+
+
+ {error ? : null} +
+ + + +
+
+ +
+ +
+
+ + + ); +} + export const user = new Elysia() .use(userService) .get("/setup", ({ redirect }) => { @@ -208,67 +352,7 @@ export const user = new Elysia() auth.remove(); } - return ( - - <> -
-
-
-
-
- - -
-
- {ACCOUNT_REGISTRATION ? ( - - Register - - ) : null} - -
-
-
-
- - - ); + return ; }, { body: "signIn", cookie: "optionalSession" }, ) @@ -279,18 +363,14 @@ export const user = new Elysia() if (!existingUser) { set.status = 403; - return { - message: "Invalid credentials.", - }; + return ; } const validPassword = await Bun.password.verify(body.password, existingUser.password); if (!validPassword) { set.status = 403; - return { - message: "Invalid credentials.", - }; + return ; } const accessToken = await jwt.sign({ @@ -344,69 +424,7 @@ export const user = new Elysia() return redirect(`${WEBROOT}/`, 302); } - return ( - - <> -
-
-
-
-
- - - -
-
- -
-
-
-
- - - ); + return ; }, { auth: true, @@ -433,25 +451,24 @@ export const user = new Elysia() } const validPassword = await Bun.password.verify(body.password, existingUser.password); + const emailValue = body.email || existingUser.email; if (!validPassword) { set.status = 403; - return { - message: "Invalid credentials.", - }; + return ; } const fields = []; const values = []; if (body.email) { - const existingUser = await db + const existingEmailUser = await db .query("SELECT id FROM users WHERE email = ?") .as(User) .get(body.email); - if (existingUser && existingUser.id.toString() !== user.id) { + if (existingEmailUser && existingEmailUser.id.toString() !== user.id) { set.status = 409; - return { message: "Email already in use." }; + return ; } fields.push("email"); values.push(body.email); From 70675b9910d6827bbdb7f27e7bd96f23237e3a4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emrik=20=C3=96stling?= Date: Tue, 6 Oct 2026 18:03:03 +0200 Subject: [PATCH 08/25] chore: pin bun types to bun version --- bun.lock | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index 3a9baf45..cd09fd86 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,7 @@ "@kitajs/ts-html-plugin": "^4.1.4", "@tailwindcss/cli": "^4.3.3", "@tailwindcss/postcss": "^4.3.3", - "@types/bun": "latest", + "@types/bun": "1.4.2", "@types/node": "^24.19.0", "eslint": "^10.11.0", "eslint-plugin-better-tailwindcss": "^4.7.0", diff --git a/package.json b/package.json index 43a37f05..c88b2a2d 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@kitajs/ts-html-plugin": "^4.1.4", "@tailwindcss/cli": "^4.3.3", "@tailwindcss/postcss": "^4.3.3", - "@types/bun": "latest", + "@types/bun": "1.4.2", "@types/node": "^24.19.0", "eslint": "^10.11.0", "eslint-plugin-better-tailwindcss": "^4.7.0", From 6a2bc871359d1cb76c27aee32ba109b7228a5b2d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:44:26 +0200 Subject: [PATCH 09/25] chore(deps): lock file maintenance (#671) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- bun.lock | 96 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/bun.lock b/bun.lock index cd09fd86..33c98b59 100644 --- a/bun.lock +++ b/bun.lock @@ -108,47 +108,47 @@ "@kitajs/ts-html-plugin": ["@kitajs/ts-html-plugin@4.1.4", "", { "dependencies": { "chalk": "^5.6.2", "tslib": "^2.8.1", "yargs": "^18.0.0" }, "peerDependencies": { "@kitajs/html": "^4.2.10", "typescript": "^5.9.3" }, "bin": { "xss-scan": "dist/cli.js", "ts-html-plugin": "dist/cli.js" } }, "sha512-xK5mNrhnIy73kJFKx5yVGChJyWFRGmIaE0sjlVxVYllk5dyaEYVCrIh1N8AfnseEHka8gAqzPGW95HlkhDvnJA=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA=="], - "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.150.0", "", { "os": "android", "cpu": "arm" }, "sha512-oQef2Zu4Prz1KLKznz3HqZzU9uVoA5PMoDZuuLmqms7hKmKSAPzlaMnLllJq3t+rgKmfJJ2siPrpZfFrW06btw=="], + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.153.0", "", { "os": "android", "cpu": "arm" }, "sha512-BcWu25dMwMjHpaDCEidmpAi7rGg1qZoihJeUUH9bD55pVZ0z4Wa5CQgqSTo0R41H81oozisQKW3WqvAN2m77IQ=="], - "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.150.0", "", { "os": "android", "cpu": "arm64" }, "sha512-B6ofpoFiAUwIZ0MJ2IgHPvZK8FAtL4qzSZRwOkAKIfxEobE1mQC8nDdiFZj7pUaJiVUVCsfkMsBJKedzO8rZvA=="], + "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.153.0", "", { "os": "android", "cpu": "arm64" }, "sha512-9l5rdp2DhRqVriBHsNpu5cEf+hizZ4B58rt93HzyQEBUgZiC6qe2oFpaAAnXGCxHGnQExI1s7S2G0QhFacUZ6w=="], - "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.150.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-J+9IHKzx/bSz1JetOfD4zKXSK9sOm4/a7+0qomJODcTL6JsRXGeV/ZdkAPSIDydfFmWzYCraMlQEosSZEyBYkQ=="], + "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.153.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EcpsyRXHhENDpekpG4QNaSZj2F/BAz4Gh4o89zZwNss86R8KyRSQ6sXbAj74Xyeb4z3j/yBZZ6joQZhraIWyfw=="], - "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.150.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-v6IPfcAcSYrBWXV5Tce1DmxDMXLhEYpIIWiRFPJopgCVscZdLaV4MRQOUxvL3usQlw+yrwYOjBFB9IwBx+jUiQ=="], + "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.153.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-hlj80HUQy07/2lYp6rmxH+3u+H2ik3rjLR52CUdPApxmZCNxASDUf/+ykTzAHS/fehftmVMATKvPaWmuLHuasg=="], - "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.150.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-AoR/4jD02HET0KO0yNT4nbTE+XJUxiO9jB1X/ycEjUE3WrIJ3IjV/Vf+gskhWLcqqeXfvNnkDtBR7epllAm88A=="], + "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.153.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-fWRcv2pMSh1qtez46MOu8vuUJ5fiaQZPHSphbWulbL7SHBMKfRgZl2PzjXZYQJbq7NEXyXbgIuIuUm+/Rmkv8w=="], - "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.150.0", "", { "os": "linux", "cpu": "arm" }, "sha512-A/hycCFjLUrCmLoL5O/vBp40ohlaXO1Ta3v5hqYZxicYFs129wZmgZJggcW4mYGYbZebQLhup+N8JjeQl8qwyw=="], + "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.153.0", "", { "os": "linux", "cpu": "arm" }, "sha512-paZeUK1GXShl/bOJQET1/8AQxayFeuN4SIWAkgTRxE7ZQwv9uYSB2n0648smygP22N7jcI3zxAm0Z+ALNiIKxg=="], - "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.150.0", "", { "os": "linux", "cpu": "arm" }, "sha512-pbqahg1Pkz7J4RKmXm30s/iQu99hv64ayXCu8P4p75HcEH5azOdR4eGqiB6lFGkFR3mMpzaYsSKLkS8oJbjmeQ=="], + "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.153.0", "", { "os": "linux", "cpu": "arm" }, "sha512-qFPp5NwMf4K1C0O+DCVGmcbOaYyYAq98Rvv9p4VSfb777iUwlJCqMUxf6QG9N5f5ls4TEX6LitOZHi+6GL6rYQ=="], - "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.150.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-HV11aRbBQGwqv8bEo+K/6qr88uB4fEe1mhXncMhlVCrj+WpBSraxrUzHaPVmeQRU6x6x8v/3DuCbbo93rpp+fw=="], + "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.153.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-oufEhZCX7saeo27Logra8cvBR1jQ/yw2zUI2hHmWw3qA/XYqEW31FOvLQBv7DWN32NjVdlcaP11WBBZTJo4k9g=="], - "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.150.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-k6pVkJqALwtEuP4zukVhGRhdIy4+ofChUIUUsAHHyqDZlNsknmel3JjbggrJiWGaes6h/dIcWmEXsKW4SmK9oQ=="], + "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.153.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zXYzuAqng85h6+LI3iRm0zeMLX1/K2z41ZAMWxVRgt0D10VvZUaV5q+TEB46KDhoKBpdexoTLC6QoB/EiP2oqQ=="], - "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.150.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tEFg39mw/rHO5n8GK2DR4ZMFfsPQZtnQIPbsIpjoQRomJc/2tRfsCSmCT6gNit+NZGoeJG5aH9ATDH5bf0WnoQ=="], + "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.153.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-J7+NLwLEzdkKM9cKyPHH/AjXXstWOs98KYgLrPPnO4N9MO8EZRBfPNnBcS3S67uMOBkRpUmCl2FhRnWdQ5CbBA=="], - "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.150.0", "", { "os": "linux", "cpu": "none" }, "sha512-0JO7IFkoek6HqV589Menx3hJySNXi6quRhO4tq2P7kpEHKEXoDATnoPVUpn5B7gDH2KLkdo751N0uIrGJFCTCw=="], + "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.153.0", "", { "os": "linux", "cpu": "none" }, "sha512-sD0K11GQTLWylln54nPMMTtgmBbQrR5It/vMpZpy/fogiw8O2AvIE+9LuWUXddrWk9zvFIHGRcSgFn4IHOfoYg=="], - "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.150.0", "", { "os": "linux", "cpu": "none" }, "sha512-7XPzREnyAS5wHU9aB+49Uaqgoo1gVCklHc3DRlc3fJy2tXD/eAJFN1QFz/crj+Ulup5P1+axNREF+/RGaB/IRA=="], + "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.153.0", "", { "os": "linux", "cpu": "none" }, "sha512-7L66uql2BhvSz51LwfExjOdn6et/ydTiQxl23VApdKzJhTc3TczA1Qt6VR/UcvlQrL2ZbytIJb447vh7p5wmUw=="], - "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.150.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-7n7ZxcbRWDFaTdyj8M8p0O9OfzoMKm8O6syBAIgcSutbOALq0Bb9G52Me+XH0anMLZV+NgXc726gqgG2q39vcQ=="], + "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.153.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hXV3T7mNyrPR19rbPYkglkQJtoM/UNo/xwbZeeqdAxEg8PXgb4LXXuxPpGgI4bzRRCWwLUxdDSS58xIyC2u1TA=="], - "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.150.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Vx0GSA9ZCRTUiczoEQnIIyXkMvtEY8uc6IiwLQtMe5ci2sXPqjrry0Ek5fsPP1k2kCzkML3ow9UOOEgnEDi7Bw=="], + "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.153.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Da8Vqs1xdtoDYtEjmozD7wGgJeLBQkMbR0WUBECOSxodyX5DXy8ySlvJWbukyO2Q0+TM9EK+4LT6tbq0jCg4hQ=="], - "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.150.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ii4/9m3viDLssMnfXU7/Pni/3nYplApuGayceX4qsVGaqqQJCx3tHIH9M/HWIeSty5i8LjS21zPYT6lVIkwLxw=="], + "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.153.0", "", { "os": "linux", "cpu": "x64" }, "sha512-zGAPxNTIIw6X5WyTRscIFE/oDjpdNtrrl32ing6NOXdGjzRucadnxBilexLdeYQ9YxixF93GujpXyQ0jw7ddSg=="], - "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.150.0", "", { "os": "none", "cpu": "arm64" }, "sha512-ZtoxX5rez36ZWkwtiEhjkCPnlTPoQ2I7lIL0IYZ1yjxMYohbvoOjBmUIZzrf9W/HouONq0omIfTeCWEY+R380w=="], + "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.153.0", "", { "os": "none", "cpu": "arm64" }, "sha512-GoHzpqP5XyPIp9DHokoEdCp+K4UPl/h1neFj1uXnUB3pJYyOQ7NOhFVOttNUgCGcj2eywAvu5cRivTOSq4S10g=="], - "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.150.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-VqeRb5JX/bKYBrff7TUDvJyKY0914UzuCkuXIGxJS4FUmvMNfqkCbc7Sq90iMz9DlmAtlggwaBYQ9eg3h3ltiw=="], + "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.153.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-xGcy12tO9k7zFbeyQzEuilfWfQpyRq23/fLPfN3wjSWuWv58k46fQT1hsO1KFNykZuVGW9xSJE/z592TmAoSDA=="], - "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.150.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-EPqJfeZ4Pgg2BJSsCV8GozxV0FDltRzpVtGVa1r2noJu1iu+opOw3gtBWXwIo9odCT/MdFfyHxdy0/CRqs3OqA=="], + "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.153.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-o1ykUjWncvXX6N5a4KebgLjtD8zRui+f1llMhSGoSC9oP2KOqapRpdwmZ3NcNG0axFouwVKmhGBjd+IhA8BksA=="], - "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.150.0", "", { "os": "win32", "cpu": "x64" }, "sha512-n5YMzbqwPQqozbkrexi0lNZrUz5cnPHalYpFWe6Dau7AmKIWNo+y24IwzDuZEQtEdUHuKhXmMMih/MPjOI+CMw=="], + "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.153.0", "", { "os": "win32", "cpu": "x64" }, "sha512-gxQz7G96Up136fd+2zWrP+o6FYGs9NO4Dyv55IoeJFJUDwLFHcLYY11hhA5wQKs0HoM57Q8esQ3nEVhnCdsjAQ=="], - "@oxc-project/types": ["@oxc-project/types@0.150.0", "", {}, "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw=="], + "@oxc-project/types": ["@oxc-project/types@0.153.0", "", {}, "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw=="], "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA=="], @@ -266,33 +266,33 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/node": ["@types/node@24.19.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ=="], + "@types/node": ["@types/node@24.19.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ=="], "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.71.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.71.0", "@typescript-eslint/type-utils": "8.71.0", "@typescript-eslint/utils": "8.71.0", "@typescript-eslint/visitor-keys": "8.71.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.71.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.71.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.71.1", "@typescript-eslint/type-utils": "8.71.1", "@typescript-eslint/utils": "8.71.1", "@typescript-eslint/visitor-keys": "8.71.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.71.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.71.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.71.0", "@typescript-eslint/types": "8.71.0", "@typescript-eslint/typescript-estree": "8.71.0", "@typescript-eslint/visitor-keys": "8.71.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.71.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.71.1", "@typescript-eslint/types": "8.71.1", "@typescript-eslint/typescript-estree": "8.71.1", "@typescript-eslint/visitor-keys": "8.71.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.71.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.71.0", "@typescript-eslint/types": "^8.71.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.71.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.71.1", "@typescript-eslint/types": "^8.71.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.71.0", "", { "dependencies": { "@typescript-eslint/types": "8.71.0", "@typescript-eslint/visitor-keys": "8.71.0" } }, "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.71.1", "", { "dependencies": { "@typescript-eslint/types": "8.71.1", "@typescript-eslint/visitor-keys": "8.71.1" } }, "sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.71.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.71.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.71.0", "", { "dependencies": { "@typescript-eslint/types": "8.71.0", "@typescript-eslint/typescript-estree": "8.71.0", "@typescript-eslint/utils": "8.71.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.71.1", "", { "dependencies": { "@typescript-eslint/types": "8.71.1", "@typescript-eslint/typescript-estree": "8.71.1", "@typescript-eslint/utils": "8.71.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.71.0", "", {}, "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.71.1", "", {}, "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.71.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.71.0", "@typescript-eslint/tsconfig-utils": "8.71.0", "@typescript-eslint/types": "8.71.0", "@typescript-eslint/visitor-keys": "8.71.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.71.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.71.1", "@typescript-eslint/tsconfig-utils": "8.71.1", "@typescript-eslint/types": "8.71.1", "@typescript-eslint/visitor-keys": "8.71.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.71.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.71.0", "@typescript-eslint/types": "8.71.0", "@typescript-eslint/typescript-estree": "8.71.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.71.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.71.1", "@typescript-eslint/types": "8.71.1", "@typescript-eslint/typescript-estree": "8.71.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.71.0", "", { "dependencies": { "@typescript-eslint/types": "8.71.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.71.1", "", { "dependencies": { "@typescript-eslint/types": "8.71.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w=="], "@valibot/to-json-schema": ["@valibot/to-json-schema@1.8.0", "", { "peerDependencies": { "valibot": "^1.5.0" } }, "sha512-a0M+uwCuQZEPAo65NYkFSJ14O5c213KoSZmPdoCfuFvUhfULi4T2Z6Tpjv6lTVMW9RoLm74RpRAjNEa43KcN+g=="], - "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + "acorn": ["acorn@8.19.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -348,7 +348,7 @@ "eslint": ["eslint@10.12.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA=="], - "eslint-plugin-better-tailwindcss": ["eslint-plugin-better-tailwindcss@4.8.0", "", { "dependencies": { "@eslint/css-tree": "^4.0.4", "@valibot/to-json-schema": "^1.7.1", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "synckit": "^0.11.13", "tailwind-csstree": "^0.3.3", "tsconfig-paths-webpack-plugin": "^4.2.0", "valibot": "^1.4.2" }, "peerDependencies": { "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", "oxlint": "^1.35.0", "tailwindcss": "^3.3.0 || ^4.1.17" }, "optionalPeers": ["eslint", "oxlint"] }, "sha512-E3eiSJg8iq/FOZFejyT364s3M/GVdlnkr1bNizSRRueAZXKzHwZnC84TZkOJvezRdWM6ZigJ/g4hLbOafwXfcA=="], + "eslint-plugin-better-tailwindcss": ["eslint-plugin-better-tailwindcss@4.9.0", "", { "dependencies": { "@eslint/css-tree": "^4.0.4", "@valibot/to-json-schema": "^1.7.1", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "synckit": "^0.11.13", "tailwind-csstree": "^0.3.3", "tsconfig-paths-webpack-plugin": "^4.2.0", "valibot": "^1.4.2" }, "peerDependencies": { "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", "oxlint": "^1.35.0", "tailwindcss": "^3.3.0 || ^4.1.17" }, "optionalPeers": ["eslint", "oxlint"] }, "sha512-L8z5Wpdzji/qYS4Dz27LZAKVk9ZQ3yTvUbvRtyFhefu6g6T7ZnRqyWkNBiHDD7Pm1vHJW/KcV8oo3ehdoNZ1JA=="], "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], @@ -390,7 +390,7 @@ "flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="], - "formatly": ["formatly@0.7.1", "", { "dependencies": { "fd-package-json": "^2.0.0", "package-manager-detector": "^1.8.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-ESuCEJE0SGgabl9q+zABI8LhJDmF+8nhpuqDzuM48HfJ9QTD5o6PqFJDFZY7w6f8xsh5lixuj5nJsQNvYOWvKQ=="], + "formatly": ["formatly@0.11.4", "", { "dependencies": { "fd-package-json": "^2.0.0", "package-manager-detector": "^1.8.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-TeCC10lP8KCgoX+Fe5lryILIT0/Dy+nP0OSVnsuB5CwXoLuOdqxCYlPFF1inR8S/zTy1ec7TjhS2TeRKWzzImA=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], @@ -438,7 +438,7 @@ "keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="], - "knip": ["knip@6.39.0", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.7.0", "get-tsconfig": "4.14.3", "jiti": "^2.7.0", "oxc-parser": "^0.150.0", "oxc-resolver": "11.24.2", "picomatch": "^4.0.7", "smol-toml": "^1.8.0", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.17", "unbash": "^4.0.11", "yaml": "^2.9.1", "zod": "^4.4.3" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-Y7g6Ve1ATMox1u1v+gx3z9LTlvzVLTJISm/LVn9TgBU4qc7OoC63AWGO8qCFPVDDdaqlYdXkDDvvO1METBfTRw=="], + "knip": ["knip@6.41.0", "", { "dependencies": { "formatly": "^0.11.4", "get-tsconfig": "4.14.3", "jiti": "^2.7.0", "oxc-parser": "^0.153.0", "oxc-resolver": "11.24.2", "picomatch": "^4.0.7", "smol-toml": "^1.9.0", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.17", "unbash": "^5.0.0", "yaml": "^2.9.1", "zod": "^4.6.5" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-DrhQlIuV6xScd5Q+WTF1znd1w41QzxFGCtAHXCajsH/ndGMotHoNYbdXbuFtzW3mIOmniHtZ7zCtvnQkUmJtQQ=="], "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], @@ -490,7 +490,7 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], + "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], @@ -504,7 +504,7 @@ "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], - "oxc-parser": ["oxc-parser@0.150.0", "", { "dependencies": { "@oxc-project/types": "^0.150.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.150.0", "@oxc-parser/binding-android-arm64": "0.150.0", "@oxc-parser/binding-darwin-arm64": "0.150.0", "@oxc-parser/binding-darwin-x64": "0.150.0", "@oxc-parser/binding-freebsd-x64": "0.150.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.150.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.150.0", "@oxc-parser/binding-linux-arm64-gnu": "0.150.0", "@oxc-parser/binding-linux-arm64-musl": "0.150.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.150.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.150.0", "@oxc-parser/binding-linux-riscv64-musl": "0.150.0", "@oxc-parser/binding-linux-s390x-gnu": "0.150.0", "@oxc-parser/binding-linux-x64-gnu": "0.150.0", "@oxc-parser/binding-linux-x64-musl": "0.150.0", "@oxc-parser/binding-openharmony-arm64": "0.150.0", "@oxc-parser/binding-win32-arm64-msvc": "0.150.0", "@oxc-parser/binding-win32-ia32-msvc": "0.150.0", "@oxc-parser/binding-win32-x64-msvc": "0.150.0" } }, "sha512-zwajKw1GUa57IOdafIS7/yOObGF2NihwIfZfs5yJTRprMSkiwhWOc/k+n8bwnUPbHr2/XsjfBO8KyTPaW3VpWg=="], + "oxc-parser": ["oxc-parser@0.153.0", "", { "dependencies": { "@oxc-project/types": "^0.153.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.153.0", "@oxc-parser/binding-android-arm64": "0.153.0", "@oxc-parser/binding-darwin-arm64": "0.153.0", "@oxc-parser/binding-darwin-x64": "0.153.0", "@oxc-parser/binding-freebsd-x64": "0.153.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.153.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.153.0", "@oxc-parser/binding-linux-arm64-gnu": "0.153.0", "@oxc-parser/binding-linux-arm64-musl": "0.153.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.153.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.153.0", "@oxc-parser/binding-linux-riscv64-musl": "0.153.0", "@oxc-parser/binding-linux-s390x-gnu": "0.153.0", "@oxc-parser/binding-linux-x64-gnu": "0.153.0", "@oxc-parser/binding-linux-x64-musl": "0.153.0", "@oxc-parser/binding-openharmony-arm64": "0.153.0", "@oxc-parser/binding-win32-arm64-msvc": "0.153.0", "@oxc-parser/binding-win32-ia32-msvc": "0.153.0", "@oxc-parser/binding-win32-x64-msvc": "0.153.0" } }, "sha512-cEnpyyV1OJRXv++SFNw0z8i+2o/cr88rVLaOYJafELDoHM5yHvCAbwGRLvxuany6SNLcbblSMUyZaGzH+quSYg=="], "oxc-resolver": ["oxc-resolver@11.24.2", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.24.2", "@oxc-resolver/binding-android-arm64": "11.24.2", "@oxc-resolver/binding-darwin-arm64": "11.24.2", "@oxc-resolver/binding-darwin-x64": "11.24.2", "@oxc-resolver/binding-freebsd-x64": "11.24.2", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2", "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2", "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2", "@oxc-resolver/binding-linux-arm64-musl": "11.24.2", "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2", "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-musl": "11.24.2", "@oxc-resolver/binding-openharmony-arm64": "11.24.2", "@oxc-resolver/binding-wasm32-wasi": "11.24.2", "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2", "@oxc-resolver/binding-win32-x64-msvc": "11.24.2" } }, "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw=="], @@ -512,7 +512,7 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], - "package-manager-detector": ["package-manager-detector@1.8.0", "", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="], + "package-manager-detector": ["package-manager-detector@1.9.0", "", {}, "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], @@ -524,11 +524,11 @@ "pidtree": ["pidtree@1.0.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-avfAvjB9Dd0wdj3rjJX//yS+G79OO0KrS5pJHFJENjYGX6N4SMgEDBBI/yFy0lloOYSaC6XQxzpOAMPfSYFV/Q=="], - "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], + "postcss": ["postcss@8.5.29", "", { "dependencies": { "nanoid": "^3.3.19", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - "prettier": ["prettier@3.9.9", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg=="], + "prettier": ["prettier@3.9.10", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA=="], "prism-react-renderer": ["prism-react-renderer@2.4.1", "", { "dependencies": { "@types/prismjs": "^1.26.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": ">=16.0.0" } }, "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig=="], @@ -552,7 +552,7 @@ "shell-quote": ["shell-quote@1.12.0", "", {}, "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q=="], - "smol-toml": ["smol-toml@1.9.0", "", {}, "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ=="], + "smol-toml": ["smol-toml@1.9.1", "", {}, "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ=="], "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], @@ -564,7 +564,7 @@ "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], - "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], + "strtok3": ["strtok3@10.4.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-zzNQzrgQUNHyFFDzGCkzgWPgxiBcfwqg+/RNK9cZHKRH95KuCKxqXBXe+jC/1Xq+MRLAe/DkizyPe3kR37QV3w=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -600,11 +600,11 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "typescript-eslint": ["typescript-eslint@8.71.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.71.0", "@typescript-eslint/parser": "8.71.0", "@typescript-eslint/typescript-estree": "8.71.0", "@typescript-eslint/utils": "8.71.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw=="], + "typescript-eslint": ["typescript-eslint@8.71.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.71.1", "@typescript-eslint/parser": "8.71.1", "@typescript-eslint/typescript-estree": "8.71.1", "@typescript-eslint/utils": "8.71.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ=="], "uint8array-extras": ["uint8array-extras@1.6.0", "", {}, "sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q=="], - "unbash": ["unbash@4.0.12", "", {}, "sha512-PnKvRX+Fbxl/usVlTr/31tXIOVAH+8OdC9qaxUQcvurwEnoc3Vk/ipi2Ce8IjMxzK9byfSGdsUBjqqJGc1o5lw=="], + "unbash": ["unbash@5.0.0", "", {}, "sha512-RYNm/ISFtflWpIcXTPHHLJZ0mqzInav+L9ke9PcL+hHsSf9+Fd/syPb614Mlqs/O3AYgjnvxDOFl0a+zVqi1/w=="], "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], @@ -634,7 +634,7 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], + "zod": ["zod@4.6.6", "", {}, "sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -644,7 +644,7 @@ "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA=="], "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="], @@ -652,7 +652,7 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.12", "", {}, "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg=="], - "bun-types/@types/node": ["@types/node@26.6.4", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="], + "bun-types/@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="], "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], From 411ffabd8c2f68c3aadfef2d734af6f7e935ebd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Krzeslak?= Date: Fri, 2 Oct 2026 14:20:22 +0200 Subject: [PATCH 10/25] test: improve converter tests --- tests/converters/assimp.test.ts | 22 +- tests/converters/calibre.test.ts | 35 +- tests/converters/dasel.test.ts | 104 +++- tests/converters/dvisvgm.test.ts | 108 +--- tests/converters/ffmpeg.test.ts | 227 ++++---- tests/converters/graphicsmagick.test.ts | 29 +- tests/converters/helpers/commonTests.ts | 5 + tests/converters/helpers/converters.ts | 208 +++---- tests/converters/imagemagick.test.ts | 245 +++----- tests/converters/inkscape.test.ts | 11 +- tests/converters/libheif.test.ts | 11 +- tests/converters/libjxl.test.ts | 89 +-- tests/converters/libreoffice.test.ts | 110 +++- tests/converters/main.test.ts | 712 ++++++++++++------------ tests/converters/markitdown.test.ts | 43 +- tests/converters/msgconvert.test.ts | 123 ++-- tests/converters/pandoc.test.ts | 76 ++- tests/converters/pdftops.test.ts | 78 +-- tests/converters/potrace.test.ts | 16 +- tests/converters/resvg.test.ts | 11 +- tests/converters/vcf.test.ts | 156 +++++- tests/converters/vips.test.ts | 65 +-- tests/converters/vtracer.test.ts | 141 +++-- tests/converters/xelatex.test.ts | 24 +- 24 files changed, 1445 insertions(+), 1204 deletions(-) diff --git a/tests/converters/assimp.test.ts b/tests/converters/assimp.test.ts index ea3479bf..6face8cb 100644 --- a/tests/converters/assimp.test.ts +++ b/tests/converters/assimp.test.ts @@ -1,7 +1,25 @@ -import { test } from "bun:test"; +import { expect, test } from "bun:test"; import { convert } from "../../src/converters/assimp"; import { runCommonTests } from "./helpers/commonTests"; +import { createMockExecFile } from "./helpers/converters"; runCommonTests(convert); -test.skip("dummy - required to trigger test detection", () => {}); +test("invokes assimp export with the target format id", async () => { + const { execFile, calls } = createMockExecFile(); + + await convert("in/scene.obj", "obj", "stl", "out/scene.stl", undefined, execFile); + + expect(calls).toEqual([ + { cmd: "assimp", args: ["export", "in/scene.obj", "out/scene.stl", "-fstl"] }, + ]); +}); + +test("passes format ids that are not file extensions through unchanged", async () => { + // e.g. glb2 is written to a .glb file, but assimp still needs -fglb2 + const { execFile, calls } = createMockExecFile(); + + await convert("in/scene.obj", "obj", "glb2", "out/scene.glb", undefined, execFile); + + expect(calls[0]?.args).toEqual(["export", "in/scene.obj", "out/scene.glb", "-fglb2"]); +}); diff --git a/tests/converters/calibre.test.ts b/tests/converters/calibre.test.ts index 773017dd..6d47baf3 100644 --- a/tests/converters/calibre.test.ts +++ b/tests/converters/calibre.test.ts @@ -1,7 +1,38 @@ -import { test } from "bun:test"; +import { expect, test } from "bun:test"; import { convert } from "../../src/converters/calibre"; import { runCommonTests } from "./helpers/commonTests"; +import { createMockExecFile } from "./helpers/converters"; runCommonTests(convert); -test.skip("dummy - required to trigger test detection", () => {}); +test("invokes ebook-convert with input and target path", async () => { + const { execFile, calls } = createMockExecFile(); + + await convert("in/book.epub", "epub", "mobi", "out/book.mobi", undefined, execFile); + + expect(calls).toEqual([{ cmd: "ebook-convert", args: ["in/book.epub", "out/book.mobi"] }]); +}); + +test.each([ + ["recipe file type", "in/news.recipe", "recipe"], + ["downloaded_recipe file type", "in/news.downloaded_recipe", "downloaded_recipe"], + ["upper-case file type", "in/news.RECIPE", "RECIPE"], + ["recipe extension with another file type", "in/news.recipe", "txt"], + ["upper-case downloaded_recipe extension", "in/news.DOWNLOADED_RECIPE", "txt"], +])("rejects recipe input (%s) without invoking ebook-convert", async (_, filePath, fileType) => { + const { execFile, calls } = createMockExecFile(); + + await expect( + convert(filePath, fileType, "epub", "out/news.epub", undefined, execFile), + ).rejects.toThrow("Recipe files are not supported"); + expect(calls).toHaveLength(0); +}); + +test("accepts files without an extension", async () => { + const { execFile, calls } = createMockExecFile(); + + await expect( + convert("in/README", "txt", "epub", "out/README.epub", undefined, execFile), + ).resolves.toBe("Done"); + expect(calls).toHaveLength(1); +}); diff --git a/tests/converters/dasel.test.ts b/tests/converters/dasel.test.ts index 9e5a86a8..bb0c780c 100644 --- a/tests/converters/dasel.test.ts +++ b/tests/converters/dasel.test.ts @@ -2,25 +2,11 @@ import fs from "fs"; import { beforeEach, afterEach, expect, test, describe } from "bun:test"; import { buildDaselArgs, convert } from "../../src/converters/dasel"; import type { ExecFileFn } from "../../src/converters/types"; +import { captureConsole } from "./helpers/converters"; const originalWriteFile = fs.writeFile; -describe("convert", () => { - let mockExecFile: ExecFileFn; - - beforeEach(() => { - // mock fs.writeFile - // @ts-expect-error: property __promisify__ is missing - fs.writeFile = (path, data, cb) => cb(null); - // mock execFile - mockExecFile = (cmd, args, callback) => callback(null, "output-data", ""); - }); - - afterEach(() => { - // reset fs.writeFile - fs.writeFile = originalWriteFile; - }); - +describe("buildDaselArgs", () => { test("should build dasel v3 arguments", () => { expect(buildDaselArgs("input.yaml", "yaml", "json")).toEqual([ "--var", @@ -31,6 +17,47 @@ describe("convert", () => { ]); }); + test.each([ + ["toml", "yaml"], + ["json", "toml"], + ["xml", "json"], + ["csv", "yaml"], + ])("should read %s and write %s", (fileType, convertTo) => { + expect(buildDaselArgs(`in/data.${fileType}`, fileType, convertTo)).toEqual([ + "--var", + `data=${fileType}:file:in/data.${fileType}`, + "--out", + convertTo, + "$data", + ]); + }); +}); + +describe("convert", () => { + let mockExecFile: ExecFileFn; + let writes: { path: unknown; data: unknown }[]; + let output: ReturnType; + + beforeEach(() => { + writes = []; + output = captureConsole(); + // mock fs.writeFile + // @ts-expect-error: property __promisify__ is missing + fs.writeFile = (path, data, cb) => { + writes.push({ path, data }); + // @ts-expect-error: could not be callable with null + cb(null); + }; + // mock execFile + mockExecFile = (cmd, args, callback) => callback(null, "output-data", ""); + }); + + afterEach(() => { + // reset fs.writeFile + fs.writeFile = originalWriteFile; + output.restore(); + }); + test("should call dasel with correct arguments and write output", async () => { let calledArgs: Parameters = ["", [], () => {}]; mockExecFile = (cmd, args, callback) => { @@ -38,16 +65,6 @@ describe("convert", () => { callback(null, "output-data", ""); }; - let writeFileCalled = false; - // @ts-expect-error: property __promisify__ is missing - fs.writeFile = (path, data, cb) => { - writeFileCalled = true; - expect(path).toBe("output.json"); - expect(data).toBe("output-data"); - // @ts-expect-error: could not be callable with null - cb(null); - }; - const result = await convert( "input.yaml", "yaml", @@ -59,7 +76,7 @@ describe("convert", () => { expect(calledArgs[0]).toBe("dasel"); expect(calledArgs[1]).toEqual(["--var", "data=yaml:file:input.yaml", "--out", "json", "$data"]); - expect(writeFileCalled).toBe(true); + expect(writes).toEqual([{ path: "output.json", data: "output-data" }]); expect(result).toBe("Done"); }); @@ -67,7 +84,7 @@ describe("convert", () => { let stdinEnded = false; mockExecFile = (cmd, args, callback) => { callback(null, "output-data", ""); - return { stdin: { end: () => (stdinEnded = true) } } as ReturnType; + return { stdin: { end: () => (stdinEnded = true) } } as unknown as ReturnType; }; await convert("input.yaml", "yaml", "json", "output.json", undefined, mockExecFile); @@ -75,11 +92,38 @@ describe("convert", () => { expect(stdinEnded).toBe(true); }); - test("should reject if execFile returns an error", async () => { + test("should not fail when execFile returns no child process", async () => { + mockExecFile = (cmd, args, callback) => { + callback(null, "output-data", ""); + return undefined; + }; + + await expect( + convert("input.yaml", "yaml", "json", "output.json", undefined, mockExecFile), + ).resolves.toBe("Done"); + }); + + test("should log stderr and still write the output", async () => { + mockExecFile = (cmd, args, callback) => callback(null, "output-data", "deprecation warning"); + + await convert("input.yaml", "yaml", "json", "output.json", undefined, mockExecFile); + + expect(output.errors).toEqual(["stderr: deprecation warning"]); + expect(writes).toHaveLength(1); + }); + + test("should not log stdout, since it is the converted document", async () => { + await convert("input.yaml", "yaml", "json", "output.json", undefined, mockExecFile); + + expect(output.logs).toEqual([]); + }); + + test("should reject and not write anything if execFile returns an error", async () => { mockExecFile = (cmd, args, callback) => callback(new Error("fail"), "", ""); await expect( convert("input.yaml", "yaml", "json", "output.json", undefined, mockExecFile), ).rejects.toMatch(/error: Error: fail/); + expect(writes).toEqual([]); }); test("should reject if writeFile fails", async () => { @@ -89,6 +133,6 @@ describe("convert", () => { convert("input.yaml", "yaml", "json", "output.json", undefined, (cmd, args, cb) => cb(null, "output-data", ""), ), - ).rejects.toMatch(/Failed to write output/); + ).rejects.toMatch(/Failed to write output: Error: write fail/); }); }); diff --git a/tests/converters/dvisvgm.test.ts b/tests/converters/dvisvgm.test.ts index 27238017..3808664a 100644 --- a/tests/converters/dvisvgm.test.ts +++ b/tests/converters/dvisvgm.test.ts @@ -1,91 +1,35 @@ -import type { ExecFileException } from "node:child_process"; -import { beforeEach, expect, test } from "bun:test"; +import { expect, test } from "bun:test"; import { convert } from "../../src/converters/dvisvgm"; -import { ExecFileFn } from "../../src/converters/types"; import { runCommonTests } from "./helpers/commonTests"; - -let calls: string[][] = []; - -beforeEach(() => { - calls = []; -}); +import { createMockExecFile } from "./helpers/converters"; runCommonTests(convert); -test("convert respects eps filetype", async () => { - const originalConsoleLog = console.log; +test.each([ + { fileType: "dvi", convertTo: "svg", flags: [] }, + { fileType: "xdv", convertTo: "svg", flags: [] }, + { fileType: "eps", convertTo: "svg", flags: ["--eps"] }, + { fileType: "pdf", convertTo: "svg", flags: ["--pdf"] }, + { fileType: "dvi", convertTo: "svgz", flags: ["-z"] }, + { fileType: "eps", convertTo: "svgz", flags: ["--eps", "-z"] }, + { fileType: "pdf", convertTo: "svgz", flags: ["--pdf", "-z"] }, +])("converts $fileType to $convertTo", async ({ fileType, convertTo, flags }) => { + const { execFile, calls } = createMockExecFile(); - let loggedMessage = ""; - console.log = (msg) => { - loggedMessage = msg; - }; - - const mockExecFile: ExecFileFn = ( - _cmd: string, - _args: string[], - callback: (err: ExecFileException | null, stdout: string, stderr: string) => void, - ) => { - calls.push(_args); - callback(null, "Fake stdout", ""); - }; - - const result = await convert("input.eps", "eps", "stl", "output.stl", undefined, mockExecFile); - - console.log = originalConsoleLog; + const result = await convert( + `in/figure.${fileType}`, + fileType, + convertTo, + `out/figure.${convertTo}`, + undefined, + execFile, + ); expect(result).toBe("Done"); - expect(calls[0]).toEqual(expect.arrayContaining(["--eps", "input.eps", "output.stl"])); - expect(loggedMessage).toBe("stdout: Fake stdout"); -}); - -test("convert respects pdf filetype", async () => { - const originalConsoleLog = console.log; - - let loggedMessage = ""; - console.log = (msg) => { - loggedMessage = msg; - }; - - const mockExecFile: ExecFileFn = ( - _cmd: string, - _args: string[], - callback: (err: ExecFileException | null, stdout: string, stderr: string) => void, - ) => { - calls.push(_args); - callback(null, "Fake stdout", ""); - }; - - const result = await convert("input.pdf", "pdf", "stl", "output.stl", undefined, mockExecFile); - - console.log = originalConsoleLog; - - expect(result).toBe("Done"); - expect(calls[0]).toEqual(expect.arrayContaining(["--pdf", "input.pdf", "output.stl"])); - expect(loggedMessage).toBe("stdout: Fake stdout"); -}); - -test("convert respects svgz conversion target type", async () => { - const originalConsoleLog = console.log; - - let loggedMessage = ""; - console.log = (msg) => { - loggedMessage = msg; - }; - - const mockExecFile: ExecFileFn = ( - _cmd: string, - _args: string[], - callback: (err: ExecFileException | null, stdout: string, stderr: string) => void, - ) => { - calls.push(_args); - callback(null, "Fake stdout", ""); - }; - - const result = await convert("input.obj", "eps", "svgz", "output.svgz", undefined, mockExecFile); - - console.log = originalConsoleLog; - - expect(result).toBe("Done"); - expect(calls[0]).toEqual(expect.arrayContaining(["-z", "input.obj", "output.svgz"])); - expect(loggedMessage).toBe("stdout: Fake stdout"); + expect(calls).toEqual([ + { + cmd: "dvisvgm", + args: [...flags, `in/figure.${fileType}`, "-o", `out/figure.${convertTo}`], + }, + ]); }); diff --git a/tests/converters/ffmpeg.test.ts b/tests/converters/ffmpeg.test.ts index bd845c31..17a84343 100644 --- a/tests/converters/ffmpeg.test.ts +++ b/tests/converters/ffmpeg.test.ts @@ -1,16 +1,19 @@ -import { beforeEach, expect, test } from "bun:test"; +import { afterAll, afterEach, beforeEach, expect, test } from "bun:test"; import { convert } from "../../src/converters/ffmpeg"; import type { ExecFileOptions } from "node:child_process"; +import { captureConsole } from "./helpers/converters"; let calls: string[][] = []; +let commands: string[] = []; let lastOptions: ExecFileOptions | undefined; function mockExecFile( - _cmd: string, + cmd: string, args: string[], options: ExecFileOptions, callback: (err: Error | null, stdout: string, stderr: string) => void, ) { + commands.push(cmd); calls.push(args); lastOptions = options; if (args.includes("fail.mov")) { @@ -20,155 +23,150 @@ function mockExecFile( } } +// Both variables are read on every call and may be set in the developer's shell +// (mise.toml sets FFMPEG_OUTPUT_ARGS), so isolate them and restore them afterwards. +const originalEnv = { + FFMPEG_ARGS: process.env.FFMPEG_ARGS, + FFMPEG_OUTPUT_ARGS: process.env.FFMPEG_OUTPUT_ARGS, +}; + +let output: ReturnType; + beforeEach(() => { calls = []; + commands = []; lastOptions = undefined; delete process.env.FFMPEG_ARGS; + delete process.env.FFMPEG_OUTPUT_ARGS; + output = captureConsole(); }); +afterEach(() => { + output.restore(); +}); + +afterAll(() => { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +}); + +const ICO_FILTER = "scale='min(256,iw)':min'(256,ih)':force_original_aspect_ratio=decrease"; + test("converts a normal file", async () => { - const originalConsoleLog = console.log; - - let loggedMessage = ""; - console.log = (msg) => { - loggedMessage = msg; - }; - const result = await convert("in.mp4", "mp4", "avi", "out.avi", undefined, mockExecFile); - console.log = originalConsoleLog; - expect(result).toBe("Done"); - expect(calls[0]).toEqual(expect.arrayContaining(["-i", "in.mp4", "out.avi"])); - expect(loggedMessage).toBe("stdout: Fake stdout"); + expect(commands).toEqual(["ffmpeg"]); + expect(calls).toEqual([["-i", "in.mp4", "out.avi"]]); + expect(output.logs).toEqual(["stdout: Fake stdout"]); }); test("adds resize for ico output", async () => { - const originalConsoleLog = console.log; - - let loggedMessage = ""; - console.log = (msg) => { - loggedMessage = msg; - }; - const result = await convert("in.png", "png", "ico", "out.ico", undefined, mockExecFile); - console.log = originalConsoleLog; - expect(result).toBe("Done: resized to 256x256"); - expect(calls[0]).toEqual( - expect.arrayContaining(["-filter:v", expect.stringContaining("scale=")]), - ); - expect(loggedMessage).toBe("stdout: Fake stdout"); + expect(calls[0]).toEqual(["-i", "in.png", "-filter:v", ICO_FILTER, "out.ico"]); }); -test("uses libaom-av1 for av1.mp4", async () => { - const originalConsoleLog = console.log; +test.each([ + ["av1.mp4", "libaom-av1"], + ["av1.mkv", "libaom-av1"], + ["h264.mp4", "libx264"], + ["h264.mkv", "libx264"], + ["h265.mp4", "libx265"], + ["h265.mkv", "libx265"], + ["h266.mp4", "libx266"], + ["h266.mkv", "libx266"], +])("uses the right video codec for %s", async (convertTo, codec) => { + const result = await convert("in.mkv", "mkv", convertTo, "out.video", undefined, mockExecFile); - let loggedMessage = ""; - console.log = (msg) => { - loggedMessage = msg; - }; - - await convert("in.mkv", "mkv", "av1.mp4", "out.mp4", undefined, mockExecFile); - - console.log = originalConsoleLog; - - expect(calls[0]).toEqual(expect.arrayContaining(["-c:v", "libaom-av1"])); - expect(loggedMessage).toBe("stdout: Fake stdout"); + expect(result).toBe("Done"); + expect(calls[0]).toEqual(["-i", "in.mkv", "-c:v", codec, "out.video"]); }); -test("uses libx264 for h264.mp4", async () => { - const originalConsoleLog = console.log; +test("does not force a codec for an unknown codec prefix", async () => { + await convert("in.mkv", "mkv", "vp9.webm", "out.webm", undefined, mockExecFile); - let loggedMessage = ""; - console.log = (msg) => { - loggedMessage = msg; - }; - - await convert("in.mkv", "mkv", "h264.mp4", "out.mp4", undefined, mockExecFile); - - console.log = originalConsoleLog; - - expect(calls[0]).toEqual(expect.arrayContaining(["-c:v", "libx264"])); - expect(loggedMessage).toBe("stdout: Fake stdout"); -}); - -test("uses libx265 for h265.mp4", async () => { - const originalConsoleLog = console.log; - - let loggedMessage = ""; - console.log = (msg) => { - loggedMessage = msg; - }; - - await convert("in.mkv", "mkv", "h265.mp4", "out.mp4", undefined, mockExecFile); - - console.log = originalConsoleLog; - - expect(calls[0]).toEqual(expect.arrayContaining(["-c:v", "libx265"])); - expect(loggedMessage).toBe("stdout: Fake stdout"); -}); - -test("uses libx266 for h266.mp4", async () => { - const originalConsoleLog = console.log; - - let loggedMessage = ""; - console.log = (msg) => { - loggedMessage = msg; - }; - - await convert("in.mkv", "mkv", "h266.mp4", "out.mp4", undefined, mockExecFile); - - console.log = originalConsoleLog; - - expect(calls[0]).toEqual(expect.arrayContaining(["-c:v", "libx266"])); - expect(loggedMessage).toBe("stdout: Fake stdout"); + expect(calls[0]).toEqual(["-i", "in.mkv", "out.webm"]); }); test("respects FFMPEG_ARGS", async () => { process.env.FFMPEG_ARGS = "-hide_banner -y"; - const originalConsoleLog = console.log; + await convert("input.mov", "mov", "mp4", "output.mp4", undefined, mockExecFile); - let loggedMessage = ""; - console.log = (msg) => { - loggedMessage = msg; - }; + expect(calls[0]).toEqual(["-hide_banner", "-y", "-i", "input.mov", "output.mp4"]); +}); + +test("places FFMPEG_OUTPUT_ARGS after the input", async () => { + process.env.FFMPEG_OUTPUT_ARGS = "-preset veryfast -threads 2"; await convert("input.mov", "mov", "mp4", "output.mp4", undefined, mockExecFile); - console.log = originalConsoleLog; + expect(calls[0]).toEqual([ + "-i", + "input.mov", + "-preset", + "veryfast", + "-threads", + "2", + "output.mp4", + ]); +}); - expect(calls[0]?.slice(0, 2)).toEqual(["-hide_banner", "-y"]); - expect(loggedMessage).toBe("stdout: Fake stdout"); +test("orders global args, input, output args, codec args and target", async () => { + process.env.FFMPEG_ARGS = "-hwaccel vaapi"; + process.env.FFMPEG_OUTPUT_ARGS = "-preset veryfast"; + + await convert("in.mkv", "mkv", "h265.mp4", "out.mp4", undefined, mockExecFile); + + expect(calls[0]).toEqual([ + "-hwaccel", + "vaapi", + "-i", + "in.mkv", + "-preset", + "veryfast", + "-c:v", + "libx265", + "out.mp4", + ]); +}); + +test("splits FFMPEG_ARGS on any whitespace", async () => { + process.env.FFMPEG_ARGS = "-hide_banner\t-y -nostdin"; + + await convert("input.mov", "mov", "mp4", "output.mp4", undefined, mockExecFile); + + expect(calls[0]?.slice(0, 3)).toEqual(["-hide_banner", "-y", "-nostdin"]); +}); + +// BUG: split(/\s+/) keeps empty strings for leading/trailing whitespace +// (" -y ".split(/\s+/) is ["", "-y", ""]), so ffmpeg receives "" as an argument +// and fails. Remove `.failing` once src/converters/ffmpeg.ts drops empty entries. +test.failing("ignores leading and trailing whitespace in FFMPEG_ARGS", async () => { + process.env.FFMPEG_ARGS = " -y "; + process.env.FFMPEG_OUTPUT_ARGS = " -preset veryfast "; + + await convert("input.mov", "mov", "mp4", "output.mp4", undefined, mockExecFile); + + expect(calls[0]).toEqual(["-y", "-i", "input.mov", "-preset", "veryfast", "output.mp4"]); }); test("fails on exec error", async () => { - const originalConsoleError = console.error; + await expect( + convert("fail.mov", "mov", "mp4", "output.mp4", undefined, mockExecFile), + ).rejects.toBe("error: Error: mock failure"); - let loggedMessage = ""; - console.error = (msg) => { - loggedMessage = msg; - }; - - expect(convert("fail.mov", "mov", "mp4", "output.mp4", undefined, mockExecFile)).rejects.toThrow( - "mock failure", - ); - - console.error = originalConsoleError; - - expect(loggedMessage).toBe("stderr: Fake stderr: fail"); + expect(output.errors).toEqual(["stderr: Fake stderr: fail"]); }); test("logs stderr when execFile returns only stderr and no error", async () => { - const originalConsoleError = console.error; - - let loggedMessage = ""; - console.error = (msg) => { - loggedMessage = msg; - }; - // Mock execFile to call back with no error, no stdout, but with stderr const mockExecFileStderrOnly = ( _cmd: string, @@ -181,9 +179,8 @@ test("logs stderr when execFile returns only stderr and no error", async () => { await convert("input.mov", "mov", "mp4", "output.mp4", undefined, mockExecFileStderrOnly); - console.error = originalConsoleError; - - expect(loggedMessage).toBe("stderr: Only stderr output"); + expect(output.errors).toEqual(["stderr: Only stderr output"]); + expect(output.logs).toEqual([]); }); test("passes a maxBuffer above the 1 MB default so long conversions don't overflow stderr (#565)", async () => { diff --git a/tests/converters/graphicsmagick.test.ts b/tests/converters/graphicsmagick.test.ts index 69080057..7a7b9284 100644 --- a/tests/converters/graphicsmagick.test.ts +++ b/tests/converters/graphicsmagick.test.ts @@ -1,32 +1,17 @@ -import { beforeEach, expect, test } from "bun:test"; -import type { ExecFileException } from "node:child_process"; +import { expect, test } from "bun:test"; import { convert } from "../../src/converters/graphicsmagick"; -import { ExecFileFn } from "../../src/converters/types"; import { runCommonTests } from "./helpers/commonTests"; - -let calls: string[][] = []; - -beforeEach(() => { - calls = []; -}); +import { createMockExecFile } from "./helpers/converters"; runCommonTests(convert); test("convert applies EXIF auto-orient", async () => { - let command = ""; - const mockExecFile: ExecFileFn = ( - cmd: string, - args: string[], - callback: (err: ExecFileException | null, stdout: string, stderr: string) => void, - ) => { - command = cmd; - calls.push(args); - callback(null, "", ""); - }; + const { execFile, calls } = createMockExecFile(); - const result = await convert("input.jpg", "jpg", "pdf", "output.pdf", undefined, mockExecFile); + const result = await convert("input.jpg", "jpg", "pdf", "output.pdf", undefined, execFile); expect(result).toBe("Done"); - expect(command).toBe("gm"); - expect(calls[0]).toEqual(["convert", "input.jpg", "-auto-orient", "output.pdf"]); + expect(calls).toEqual([ + { cmd: "gm", args: ["convert", "input.jpg", "-auto-orient", "output.pdf"] }, + ]); }); diff --git a/tests/converters/helpers/commonTests.ts b/tests/converters/helpers/commonTests.ts index 64483b35..9c9872e7 100644 --- a/tests/converters/helpers/commonTests.ts +++ b/tests/converters/helpers/commonTests.ts @@ -2,6 +2,7 @@ import { test } from "bun:test"; import { ConvertFnWithExecFile } from "../../../src/converters/types"; import { runConvertFailTest, + runConvertLogsNothingWithoutOutput, runConvertLogsStderror, runConvertLogsStderrorAndStdout, runConvertSuccessTest, @@ -23,4 +24,8 @@ export function runCommonTests(convert: ConvertFnWithExecFile) { test("convert logs both stderr and stdout when present", async () => { await runConvertLogsStderrorAndStdout(convert); }); + + test("convert logs nothing when stdout and stderr are empty", async () => { + await runConvertLogsNothingWithoutOutput(convert); + }); } diff --git a/tests/converters/helpers/converters.ts b/tests/converters/helpers/converters.ts index 912f6e2c..a7269313 100644 --- a/tests/converters/helpers/converters.ts +++ b/tests/converters/helpers/converters.ts @@ -1,121 +1,129 @@ import { expect } from "bun:test"; -import type { ExecFileException } from "node:child_process"; -import { ConvertFnWithExecFile, ExecFileFn } from "../../../src/converters/types"; +import type { ConvertFnWithExecFile, ExecFileFn } from "../../../src/converters/types"; + +type ExecFileCall = { cmd: string; args: string[] }; + +/** + * Creates an execFile replacement that records every invocation and answers with the + * given error/stdout/stderr, so converter tests never spawn real binaries. + */ +export function createMockExecFile({ + error = null, + stdout = "", + stderr = "", +}: { error?: Error | null; stdout?: string; stderr?: string } = {}) { + const calls: ExecFileCall[] = []; + const execFile: ExecFileFn = (cmd, args, callback) => { + calls.push({ cmd, args }); + callback(error, stdout, stderr); + }; + return { execFile, calls }; +} + +/** + * Redirects console.log/error/warn into arrays so tests can assert on them without + * printing to the terminal. Always call restore() afterwards (e.g. in afterEach). + */ +export function captureConsole() { + const original = { log: console.log, error: console.error, warn: console.warn }; + const logs: string[] = []; + const errors: string[] = []; + const warnings: string[] = []; + + const sink = + (target: string[]): Console["log"] => + (...data) => { + target.push(data.map(String).join(" ")); + }; + + console.log = sink(logs); + console.error = sink(errors); + console.warn = sink(warnings); + + return { + logs, + errors, + warnings, + restore: () => { + console.log = original.log; + console.error = original.error; + console.warn = original.warn; + }, + }; +} export async function runConvertSuccessTest(convertFn: ConvertFnWithExecFile) { - const originalConsoleLog = console.log; + const output = captureConsole(); + const { execFile, calls } = createMockExecFile({ stdout: "Fake stdout" }); - let loggedMessage = ""; - console.log = (msg) => { - loggedMessage = msg; - }; + try { + const result = await convertFn("input.obj", "obj", "stl", "output.stl", undefined, execFile); - const mockExecFile: ExecFileFn = ( - _cmd: string, - _args: string[], - callback: (err: ExecFileException | null, stdout: string, stderr: string) => void, - ) => { - callback(null, "Fake stdout", ""); - }; - - const result = await convertFn("input.obj", "obj", "stl", "output.stl", undefined, mockExecFile); - - console.log = originalConsoleLog; - - expect(result).toBe("Done"); - expect(loggedMessage).toBe("stdout: Fake stdout"); + expect(result).toBe("Done"); + expect(calls).toHaveLength(1); + expect(output.logs).toEqual(["stdout: Fake stdout"]); + expect(output.errors).toEqual([]); + } finally { + output.restore(); + } } export async function runConvertFailTest(convertFn: ConvertFnWithExecFile) { - const mockExecFile: ExecFileFn = ( - _cmd: string, - _args: string[], - callback: (err: ExecFileException | null, stdout: string, stderr: string) => void, - ) => { - callback(new Error("Test error"), "", ""); - }; + const failures: [error: unknown, expected: RegExp][] = [ + [new Error("Test error"), /error: Error: Test error/], + // non-standard error object lacking a 'message' property + [{ notMessage: true }, /error:/i], + // non-object error (e.g., a string) + ["string error", /error:/i], + ]; - expect( - convertFn("input.obj", "obj", "stl", "output.stl", undefined, mockExecFile), - ).rejects.toMatch(/error: Error: Test error/); + for (const [error, expected] of failures) { + const { execFile } = createMockExecFile({ error: error as Error }); - // Test with error object lacking 'message' property - const mockExecFileNoMessage: ExecFileFn = ( - _cmd: string, - _args: string[], - callback: (err: ExecFileException | null, stdout: string, stderr: string) => void, - ) => { - // Simulate a non-standard error object - callback({ notMessage: true } as unknown as ExecFileException, "", ""); - }; - - expect( - convertFn("input.obj", "obj", "stl", "output.stl", undefined, mockExecFileNoMessage), - ).rejects.toMatch(/error:/i); - - // Test with a non-object error (e.g., a string) - const mockExecFileStringError: ExecFileFn = ( - _cmd: string, - _args: string[], - callback: (err: ExecFileException | null, stdout: string, stderr: string) => void, - ) => { - callback("string error" as unknown as ExecFileException, "", ""); - }; - - expect( - convertFn("input.obj", "obj", "stl", "output.stl", undefined, mockExecFileStringError), - ).rejects.toMatch(/error:/i); + await expect( + convertFn("input.obj", "obj", "stl", "output.stl", undefined, execFile), + ).rejects.toMatch(expected); + } } export async function runConvertLogsStderror(convertFn: ConvertFnWithExecFile) { - const originalConsoleError = console.error; + const output = captureConsole(); + const { execFile } = createMockExecFile({ stderr: "Fake stderr" }); - let loggedMessage = ""; - console.error = (msg) => { - loggedMessage = msg; - }; + try { + await convertFn("file.obj", "obj", "stl", "out.stl", undefined, execFile); - const mockExecFile = ( - _cmd: string, - _args: string[], - callback: (err: Error | null, stdout: string, stderr: string) => void, - ) => { - callback(null, "", "Fake stderr"); - }; - - await convertFn("file.obj", "obj", "stl", "out.stl", undefined, mockExecFile); - - console.error = originalConsoleError; - - expect(loggedMessage).toBe("stderr: Fake stderr"); + expect(output.errors).toEqual(["stderr: Fake stderr"]); + expect(output.logs).toEqual([]); + } finally { + output.restore(); + } } export async function runConvertLogsStderrorAndStdout(convertFn: ConvertFnWithExecFile) { - const originalConsoleError = console.error; - const originalConsoleLog = console.log; + const output = captureConsole(); + const { execFile } = createMockExecFile({ stdout: "Fake stdout", stderr: "Fake stderr" }); - let loggedError = ""; - let loggedMessage = ""; - console.error = (msg) => { - loggedError = msg; - }; - console.log = (msg) => { - loggedMessage = msg; - }; + try { + await convertFn("file.obj", "obj", "stl", "out.stl", undefined, execFile); - const mockExecFile = ( - _cmd: string, - _args: string[], - callback: (err: Error | null, stdout: string, stderr: string) => void, - ) => { - callback(null, "Fake stdout", "Fake stderr"); - }; - - await convertFn("file.obj", "obj", "stl", "out.stl", undefined, mockExecFile); - - console.error = originalConsoleError; - console.log = originalConsoleLog; - - expect(loggedError).toBe("stderr: Fake stderr"); - expect(loggedMessage).toBe("stdout: Fake stdout"); + expect(output.errors).toEqual(["stderr: Fake stderr"]); + expect(output.logs).toEqual(["stdout: Fake stdout"]); + } finally { + output.restore(); + } +} + +export async function runConvertLogsNothingWithoutOutput(convertFn: ConvertFnWithExecFile) { + const output = captureConsole(); + const { execFile } = createMockExecFile(); + + try { + await convertFn("file.obj", "obj", "stl", "out.stl", undefined, execFile); + + expect(output.logs).toEqual([]); + expect(output.errors).toEqual([]); + } finally { + output.restore(); + } } diff --git a/tests/converters/imagemagick.test.ts b/tests/converters/imagemagick.test.ts index f6940459..2f176364 100644 --- a/tests/converters/imagemagick.test.ts +++ b/tests/converters/imagemagick.test.ts @@ -1,181 +1,86 @@ -import { beforeEach, expect, test } from "bun:test"; -import type { ExecFileException } from "node:child_process"; +import { expect, test } from "bun:test"; import { convert } from "../../src/converters/imagemagick"; -import { ExecFileFn } from "../../src/converters/types"; import { runCommonTests } from "./helpers/commonTests"; - -let calls: string[][] = []; - -beforeEach(() => { - calls = []; -}); +import { createMockExecFile } from "./helpers/converters"; runCommonTests(convert); -test("convert respects ico conversion target type", async () => { - const originalConsoleLog = console.log; +const ICO_ARGS = ["-define", "icon:auto-resize=256,128,64,48,32,16", "-background", "none"]; - let loggedMessage = ""; - console.log = (msg) => { - loggedMessage = msg; - }; +test.each([ + { + name: "applies only EXIF auto-orient for a plain conversion", + fileType: "jpg", + convertTo: "png", + inputArgs: [], + outputArgs: ["-auto-orient"], + }, + { + name: "does not rasterize svg at high density unless the target is ico", + fileType: "svg", + convertTo: "png", + inputArgs: [], + outputArgs: ["-auto-orient"], + }, + { + name: "auto-resizes ico output with a transparent background", + fileType: "eps", + convertTo: "ico", + inputArgs: [], + outputArgs: [...ICO_ARGS, "-auto-orient"], + }, + { + name: "rasterizes svg at high density for ico output", + fileType: "svg", + convertTo: "ico", + inputArgs: ["-background", "none", "-density", "512"], + outputArgs: [...ICO_ARGS, "-auto-orient"], + }, + { + name: "rasterizes pdf input at 300 dpi", + fileType: "pdf", + convertTo: "png", + inputArgs: ["-density", "300"], + outputArgs: ["-auto-orient"], + }, + { + name: "combines pdf density with ico options", + fileType: "pdf", + convertTo: "ico", + inputArgs: ["-density", "300"], + outputArgs: [...ICO_ARGS, "-auto-orient"], + }, + { + name: "disables the emf delegate and flattens onto white", + fileType: "emf", + convertTo: "png", + inputArgs: ["-define", "emf:delegate=false", "-density", "300"], + outputArgs: ["-background", "white", "-alpha", "remove", "-auto-orient"], + }, + { + name: "combines emf handling with ico options", + fileType: "emf", + convertTo: "ico", + inputArgs: ["-define", "emf:delegate=false", "-density", "300"], + outputArgs: [...ICO_ARGS, "-background", "white", "-alpha", "remove", "-auto-orient"], + }, +])("$name ($fileType -> $convertTo)", async ({ fileType, convertTo, inputArgs, outputArgs }) => { + const { execFile, calls } = createMockExecFile(); - const mockExecFile: ExecFileFn = ( - _cmd: string, - _args: string[], - callback: (err: ExecFileException | null, stdout: string, stderr: string) => void, - ) => { - calls.push(_args); - callback(null, "Fake stdout", ""); - }; - - const result = await convert("input.obj", "eps", "ico", "output.ico", undefined, mockExecFile); - - console.log = originalConsoleLog; - - expect(result).toBe("Done"); - expect(calls[0]).toEqual( - expect.arrayContaining([ - "-define", - "icon:auto-resize=256,128,64,48,32,16", - "-background", - "none", - "input.obj", - "output.ico", - ]), + const result = await convert( + `in/image.${fileType}`, + fileType, + convertTo, + `out/image.${convertTo}`, + undefined, + execFile, ); - expect(loggedMessage).toBe("stdout: Fake stdout"); -}); - -test("convert respects ico conversion target type with svg as input filetype", async () => { - const originalConsoleLog = console.log; - - let loggedMessage = ""; - console.log = (msg) => { - loggedMessage = msg; - }; - - const mockExecFile: ExecFileFn = ( - _cmd: string, - _args: string[], - callback: (err: ExecFileException | null, stdout: string, stderr: string) => void, - ) => { - calls.push(_args); - callback(null, "Fake stdout", ""); - }; - - const result = await convert("input.svg", "svg", "ico", "output.ico", undefined, mockExecFile); - - console.log = originalConsoleLog; expect(result).toBe("Done"); - expect(calls[0]).toEqual( - expect.arrayContaining([ - "-define", - "icon:auto-resize=256,128,64,48,32,16", - "-background", - "none", - "-density", - "512", - "input.svg", - "output.ico", - ]), - ); - expect(loggedMessage).toBe("stdout: Fake stdout"); -}); - -test("convert respects ico conversion target type with emf as input filetype", async () => { - const originalConsoleLog = console.log; - - let loggedMessage = ""; - console.log = (msg) => { - loggedMessage = msg; - }; - - const mockExecFile: ExecFileFn = ( - _cmd: string, - _args: string[], - callback: (err: ExecFileException | null, stdout: string, stderr: string) => void, - ) => { - calls.push(_args); - callback(null, "Fake stdout", ""); - }; - - const result = await convert("input.emf", "emf", "ico", "output.ico", undefined, mockExecFile); - - console.log = originalConsoleLog; - - expect(result).toBe("Done"); - expect(calls[0]).toEqual( - expect.arrayContaining([ - "-define", - "icon:auto-resize=256,128,64,48,32,16", - "-background", - "none", - "emf:delegate=false", - "-density", - "300", - "white", - "-alpha", - "remove", - "input.emf", - "output.ico", - ]), - ); - expect(loggedMessage).toBe("stdout: Fake stdout"); -}); - -test("convert respects emf as input filetype", async () => { - const originalConsoleLog = console.log; - - let loggedMessage = ""; - console.log = (msg) => { - loggedMessage = msg; - }; - - const mockExecFile: ExecFileFn = ( - _cmd: string, - _args: string[], - callback: (err: ExecFileException | null, stdout: string, stderr: string) => void, - ) => { - calls.push(_args); - callback(null, "Fake stdout", ""); - }; - - const result = await convert("input.emf", "emf", "obj", "output.obj", undefined, mockExecFile); - - console.log = originalConsoleLog; - - expect(result).toBe("Done"); - expect(calls[0]).toEqual( - expect.arrayContaining([ - "-define", - "emf:delegate=false", - "-density", - "300", - "-background", - "white", - "-alpha", - "remove", - "input.emf", - "output.obj", - ]), - ); - expect(loggedMessage).toBe("stdout: Fake stdout"); -}); - -test("convert applies EXIF auto-orient", async () => { - const mockExecFile: ExecFileFn = ( - _cmd: string, - _args: string[], - callback: (err: ExecFileException | null, stdout: string, stderr: string) => void, - ) => { - calls.push(_args); - callback(null, "", ""); - }; - - const result = await convert("input.jpg", "jpg", "png", "output.png", undefined, mockExecFile); - - expect(result).toBe("Done"); - expect(calls[0]).toEqual(expect.arrayContaining(["input.jpg", "-auto-orient", "output.png"])); + expect(calls).toEqual([ + { + cmd: "magick", + args: [...inputArgs, `in/image.${fileType}`, ...outputArgs, `out/image.${convertTo}`], + }, + ]); }); diff --git a/tests/converters/inkscape.test.ts b/tests/converters/inkscape.test.ts index a75ea3b4..b0697461 100644 --- a/tests/converters/inkscape.test.ts +++ b/tests/converters/inkscape.test.ts @@ -1,7 +1,14 @@ -import { test } from "bun:test"; +import { expect, test } from "bun:test"; import { convert } from "../../src/converters/inkscape"; import { runCommonTests } from "./helpers/commonTests"; +import { createMockExecFile } from "./helpers/converters"; runCommonTests(convert); -test.skip("dummy - required to trigger test detection", () => {}); +test("invokes inkscape with input and -o target path", async () => { + const { execFile, calls } = createMockExecFile(); + + await convert("in/drawing.svg", "svg", "png", "out/drawing.png", undefined, execFile); + + expect(calls).toEqual([{ cmd: "inkscape", args: ["in/drawing.svg", "-o", "out/drawing.png"] }]); +}); diff --git a/tests/converters/libheif.test.ts b/tests/converters/libheif.test.ts index 48d8154f..4001f301 100644 --- a/tests/converters/libheif.test.ts +++ b/tests/converters/libheif.test.ts @@ -1,7 +1,14 @@ -import { test } from "bun:test"; +import { expect, test } from "bun:test"; import { convert } from "../../src/converters/libheif"; import { runCommonTests } from "./helpers/commonTests"; +import { createMockExecFile } from "./helpers/converters"; runCommonTests(convert); -test.skip("dummy - required to trigger test detection", () => {}); +test("invokes heif-convert with input and target path", async () => { + const { execFile, calls } = createMockExecFile(); + + await convert("in/photo.heic", "heic", "jpeg", "out/photo.jpg", undefined, execFile); + + expect(calls).toEqual([{ cmd: "heif-convert", args: ["in/photo.heic", "out/photo.jpg"] }]); +}); diff --git a/tests/converters/libjxl.test.ts b/tests/converters/libjxl.test.ts index c1a27af1..c5415384 100644 --- a/tests/converters/libjxl.test.ts +++ b/tests/converters/libjxl.test.ts @@ -1,91 +1,40 @@ -import { beforeEach, expect, test } from "bun:test"; -import type { ExecFileException } from "node:child_process"; +import { expect, test } from "bun:test"; import { convert } from "../../src/converters/libjxl"; -import { ExecFileFn } from "../../src/converters/types"; import { runCommonTests } from "./helpers/commonTests"; - -let command: string = ""; - -beforeEach(() => { - command = ""; -}); +import { createMockExecFile } from "./helpers/converters"; runCommonTests(convert); test("convert uses djxl with input filetype being jxl", async () => { - const originalConsoleLog = console.log; + const { execFile, calls } = createMockExecFile(); - let loggedMessage = ""; - console.log = (msg) => { - loggedMessage = msg; - }; - - const mockExecFile: ExecFileFn = ( - _cmd: string, - _args: string[], - callback: (err: ExecFileException | null, stdout: string, stderr: string) => void, - ) => { - command = _cmd; - callback(null, "Fake stdout", ""); - }; - - const result = await convert("input.jxl", "jxl", "png", "output.png", undefined, mockExecFile); - - console.log = originalConsoleLog; + const result = await convert("input.jxl", "jxl", "png", "output.png", undefined, execFile); expect(result).toBe("Done"); - expect(command).toEqual("djxl"); - expect(loggedMessage).toBe("stdout: Fake stdout"); + expect(calls).toEqual([{ cmd: "djxl", args: ["input.jxl", "output.png"] }]); }); test("convert uses cjxl with output filetype being jxl", async () => { - const originalConsoleLog = console.log; + const { execFile, calls } = createMockExecFile(); - let loggedMessage = ""; - console.log = (msg) => { - loggedMessage = msg; - }; - - const mockExecFile: ExecFileFn = ( - _cmd: string, - _args: string[], - callback: (err: ExecFileException | null, stdout: string, stderr: string) => void, - ) => { - command = _cmd; - callback(null, "Fake stdout", ""); - }; - - const result = await convert("input.png", "png", "jxl", "output.jxl", undefined, mockExecFile); - - console.log = originalConsoleLog; + const result = await convert("input.png", "png", "jxl", "output.jxl", undefined, execFile); expect(result).toBe("Done"); - expect(command).toEqual("cjxl"); - expect(loggedMessage).toBe("stdout: Fake stdout"); + expect(calls).toEqual([{ cmd: "cjxl", args: ["input.png", "output.jxl"] }]); +}); + +test("convert prefers cjxl when both input and output filetype are jxl", async () => { + const { execFile, calls } = createMockExecFile(); + + await convert("input.jxl", "jxl", "jxl", "output.jxl", undefined, execFile); + + expect(calls[0]?.cmd).toBe("cjxl"); }); test("convert uses empty string as command with neither input nor output filetype being jxl", async () => { - const originalConsoleLog = console.log; + const { execFile, calls } = createMockExecFile(); - let loggedMessage = ""; - console.log = (msg) => { - loggedMessage = msg; - }; + await convert("input.png", "png", "jpg", "output.jpg", undefined, execFile); - const mockExecFile: ExecFileFn = ( - _cmd: string, - _args: string[], - callback: (err: ExecFileException | null, stdout: string, stderr: string) => void, - ) => { - command = _cmd; - callback(null, "Fake stdout", ""); - }; - - const result = await convert("input.png", "png", "jpg", "output.jpg", undefined, mockExecFile); - - console.log = originalConsoleLog; - - expect(result).toBe("Done"); - expect(command).toEqual(""); - expect(loggedMessage).toBe("stdout: Fake stdout"); + expect(calls[0]?.cmd).toBe(""); }); diff --git a/tests/converters/libreoffice.test.ts b/tests/converters/libreoffice.test.ts index c08e791f..b6304509 100644 --- a/tests/converters/libreoffice.test.ts +++ b/tests/converters/libreoffice.test.ts @@ -73,7 +73,7 @@ test("invokes soffice with --headless and outdir derived from targetPath", async ]); }); -test("uses only outFilter when input has no filter (e.g., pdf -> txt)", async () => { +test("uses writer_pdf_import and a text outFilter for pdf -> txt", async () => { await convert("in.pdf", "pdf", "txt", "out/out.txt", undefined, mockExecFile); const { args } = requireDefined(calls[0], "Expected at least one execFile call"); @@ -89,18 +89,12 @@ test("uses only outFilter when input has no filter (e.g., pdf -> txt)", async () ]); }); -test("uses only infilter when convertTo has no out filter (e.g., docx -> pdf)", async () => { +test("uses no filters at all when converting to pdf (e.g., docx -> pdf)", async () => { await convert("in.docx", "docx", "pdf", "out/out.pdf", undefined, mockExecFile); const { args } = requireDefined(calls[0], "Expected at least one execFile call"); - // If docx has an infilter, it should be present expect(args).toEqual(["--headless", "--convert-to", "pdf", "--outdir", "out", "in.docx"]); - - const i = args.indexOf("--convert-to"); - expect(i).toBeGreaterThanOrEqual(0); - expect(args[i + 1]).toBe("pdf"); - expect(args.slice(-2)).toEqual(["out", "in.docx"]); }); test("does not force an infilter for wps (Microsoft Works, not MS Word 97)", async () => { @@ -203,26 +197,94 @@ test("logs both stdout and stderr when both are present", async () => { test("logs stderr on exec error as well", async () => { behavior = { kind: "error", message: "boom", stderr: "EPIPE" }; - expect(convert("in.txt", "txt", "docx", "out/out.docx", undefined, mockExecFile)).rejects.toMatch( - /error: Error: boom/, - ); + await expect( + convert("in.txt", "txt", "docx", "out/out.docx", undefined, mockExecFile), + ).rejects.toMatch(/error: Error: boom/); // The callback still provided stderr; your implementation logs it before settling expect(errors).toContain("stderr: EPIPE"); }); -// --- calc filter branch (test-only exports) --------------------------------- -test("getFilters returns calc mapping when present", () => { - // temporarily add entries to calc mapping - filters.calc["testfoo"] = "TestFooFilter"; - filters.calc["testbar"] = "TestBarFilter"; +// --- spreadsheet (calc) conversions ------------------------------------------ +test.each([ + { + fileType: "csv", + convertTo: "xlsx", + infilter: "Text - txt - csv (StarCalc)", + convertToArg: "xlsx:Calc MS Excel 2007 XML", + }, + { + fileType: "ods", + convertTo: "xlsx", + infilter: "calc8", + convertToArg: "xlsx:Calc MS Excel 2007 XML", + }, + { + fileType: "xlsx", + convertTo: "ods", + infilter: "Calc MS Excel 2007 XML", + convertToArg: "ods:calc8", + }, + { + fileType: "xls", + convertTo: "xlsm", + infilter: "MS Excel 97", + convertToArg: "xlsm:Calc MS Excel 2007 XML VBA", + }, +])( + "uses calc filters for $fileType -> $convertTo", + async ({ fileType, convertTo, infilter, convertToArg }) => { + await convert( + `in.${fileType}`, + fileType, + convertTo, + `out/out.${convertTo}`, + undefined, + mockExecFile, + ); - try { - const res = getFilters("testfoo", "testbar"); - expect(res).toEqual(["TestFooFilter", "TestBarFilter"]); - } finally { - // cleanup - delete filters.calc["testfoo"]; - delete filters.calc["testbar"]; - } + const { args } = requireDefined(calls[0], "Expected at least one execFile call"); + + expect(args).toEqual([ + "--headless", + `--infilter=${infilter}`, + "--convert-to", + convertToArg, + "--outdir", + "out", + `in.${fileType}`, + ]); + }, +); + +test("uses no filters for a spreadsheet converted to pdf", async () => { + await convert("in.xlsx", "xlsx", "pdf", "out/out.pdf", undefined, mockExecFile); + + const { args } = requireDefined(calls[0], "Expected at least one execFile call"); + + expect(args).toEqual(["--headless", "--convert-to", "pdf", "--outdir", "out", "in.xlsx"]); +}); + +// --- getFilters (test-only export) ------------------------------------------- +test("getFilters returns text filters when both formats are text formats", () => { + expect(getFilters("doc", "odt")).toEqual(["MS Word 97", "writer8"]); +}); + +test("getFilters falls back to calc filters when the target is not a text format", () => { + expect(getFilters("xls", "csv")).toEqual(["MS Excel 97", "Text - txt - csv (StarCalc)"]); +}); + +test.each([ + ["docx", "xlsx"], + ["xlsx", "docx"], + ["unknown", "odt"], + ["docx", "unknown"], +])("getFilters returns no filters for unrelated formats (%s -> %s)", (fileType, convertTo) => { + expect(getFilters(fileType, convertTo)).toEqual([null, null]); +}); + +test("getFilters keeps wps deliberately unmapped instead of falling through to calc", () => { + expect("wps" in filters.text).toBe(true); + expect(filters.text.wps).toBeNull(); + expect(getFilters("wps", "docx")).toEqual([null, "MS Word 2007 XML"]); }); diff --git a/tests/converters/main.test.ts b/tests/converters/main.test.ts index 2638878b..11199a09 100644 --- a/tests/converters/main.test.ts +++ b/tests/converters/main.test.ts @@ -1,393 +1,407 @@ -// Set isolated DB path before import to protect production data +import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { mkdirSync, rmSync, existsSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; - -const testDbDir = join(tmpdir(), "converter-test-db"); -rmSync(testDbDir, { recursive: true, force: true }); -mkdirSync(testDbDir, { recursive: true }); -process.env.DB_PATH = join(testDbDir, "test.sqlite"); - -import { test, expect, afterAll, afterEach } from "bun:test"; +import { join } from "node:path"; import type { Cookie } from "elysia"; -import { writeFile, mkdir, rm, readFile } from "fs/promises"; -import { Database } from "bun:sqlite"; +import { properties as libreofficeProperties } from "../../src/converters/libreoffice"; +import { + chunks, + getAllInputs, + getAllTargets, + getPossibleTargets, + handleConvert, + mainConverter, +} from "../../src/converters/main"; +// tests/preload.ts points DB_PATH at a temporary database, so production data is never used +import db from "../../src/db/db"; +import { captureConsole } from "./helpers/converters"; -// dynamic import ensures that the module is loaded after the environment variable is set -const converterModule = await import("../../src/converters/main"); -const { getPossibleTargets, getAllTargets, getAllInputs, handleConvert, mainConverter, chunks } = - converterModule; +const testRoot = mkdtempSync(join(tmpdir(), "convertx-main-test-")); -// Isolated test database: avoids mutation of ./data/mydb.sqlite -const dbPath = process.env.DB_PATH ?? join(testDbDir, "test.sqlite"); -const dbDir = dirname(resolve(dbPath)); +// Every row written by these tests uses this prefix, so cleanup never touches other rows. +const JOB_PREFIX = "main-test-"; -if (!existsSync(dbDir)) { - mkdirSync(dbDir, { recursive: true }); +type FileRow = { file_name: string; output_file_name: string; status: string }; + +function jobCookie(name?: string): Cookie { + return { value: name === undefined ? undefined : `${JOB_PREFIX}${name}` } as Cookie< + string | undefined + >; } -const testDb = new Database(dbPath, { create: true }); +function rowsFor(name: string, orderBy: "id" | "file_name" = "id"): FileRow[] { + return db + .query( + `SELECT file_name, output_file_name, status FROM file_names WHERE job_id = ? ORDER BY ${orderBy}`, + ) + .all(`${JOB_PREFIX}${name}`) as FileRow[]; +} + +function countAllRows(): number { + return (db.query("SELECT COUNT(*) AS count FROM file_names").get() as { count: number }).count; +} + +const vcard = (fullName: string) => `BEGIN:VCARD\nFN:${fullName}\nEND:VCARD\n`; + +let caseCounter = 0; +let uploadsDir: string; +let outputDir: string; +let output: ReturnType; + +beforeEach(() => { + // handleConvert concatenates dir and file name, so both need a trailing slash + const caseDir = join(testRoot, `case-${caseCounter++}`); + uploadsDir = `${join(caseDir, "uploads")}/`; + outputDir = `${join(caseDir, "output")}/`; + mkdirSync(uploadsDir, { recursive: true }); + mkdirSync(outputDir, { recursive: true }); + output = captureConsole(); +}); -// cleans up the table before each test for real isolation (even with parallel tests) afterEach(() => { - try { - testDb.query("DELETE FROM file_names"); - } catch (err) { - if (err instanceof Error) { - // ignore only the expected error for missing tables; rethrow real issues - if (!err.message.includes("no such table")) { - throw err; - } - } - } + output.restore(); + db.query("DELETE FROM file_names WHERE job_id LIKE ?").run(`${JOB_PREFIX}%`); }); -// closes the DB and removes the temp directory after all tests finish +// removes the temp directory with the uploads and outputs after all tests finish afterAll(() => { - testDb.close(); - rmSync(testDbDir, { recursive: true, force: true }); + rmSync(testRoot, { recursive: true, force: true }); }); -// Mock factory for jobId Cookie to avoid repeated `as Cookie` casts -function createMockJobId(value: string): Cookie { - return { value } as Cookie; -} +describe("converter registry", () => { + test("getPossibleTargets lists the targets of every converter for an input type", () => { + expect(getPossibleTargets("vcf")).toEqual({ vcf: ["csv"] }); + }); -test("getPossibleTargets, getAllTargets and getAllInputs include vcf/csv mapping", () => { - const possible = getPossibleTargets("vcf"); - // should have an entry for the vcf converter - expect(Object.keys(possible).length).toBeGreaterThan(0); - // getAllTargets should include 'vcf' converter target csv - const allTargets = getAllTargets(); - // Be defensive: allTargets.vcf may be undefined in some builds - expect(allTargets).toHaveProperty("vcf"); - expect(Array.isArray(allTargets.vcf)).toBe(true); - expect((allTargets.vcf ?? []).includes("csv")).toBe(true); + test("getPossibleTargets normalizes the input type", () => { + expect(getPossibleTargets("VCF")).toEqual({ vcf: ["csv"] }); + expect(Object.keys(getPossibleTargets("jpeg")).length).toBeGreaterThan(0); + expect(getPossibleTargets("JPG")).toEqual(getPossibleTargets("jpeg")); + expect(getPossibleTargets("htm")).toEqual(getPossibleTargets("html")); + }); - const allInputs = getAllInputs("vcf"); - expect(allInputs.includes("vcf")).toBe(true); + test("getPossibleTargets returns an empty object for unknown input types", () => { + expect(getPossibleTargets("xyz123")).toEqual({}); + }); + + test("getAllTargets has an entry for every registered converter", () => { + expect(Object.keys(getAllTargets()).sort()).toEqual([ + "assimp", + "calibre", + "dasel", + "dvisvgm", + "ffmpeg", + "graphicsmagick", + "imagemagick", + "inkscape", + "libheif", + "libjxl", + "libreoffice", + "markitDown", + "msgconvert", + "pandoc", + "pdftops", + "potrace", + "resvg", + "vcf", + "vips", + "vtracer", + "xelatex", + ]); + }); + + test("getAllTargets merges the targets of all categories of a converter", () => { + const allTargets = getAllTargets(); + + expect(allTargets.vcf).toEqual(["csv"]); + expect(allTargets.libjxl).toEqual([ + "apng", + "exr", + "jpeg", + "pam", + "pfm", + "pgm", + "pgx", + "png", + "ppm", + "jxl", + ]); + }); + + test("getAllInputs merges the inputs of all categories of a converter", () => { + expect(getAllInputs("vcf")).toEqual(["vcf"]); + expect(getAllInputs("libjxl")).toEqual([ + "jxl", + "apng", + "exr", + "gif", + "jpeg", + "pam", + "pfm", + "pgm", + "pgx", + "png", + "ppm", + ]); + }); + + test("getAllInputs returns an empty list for unknown converters", () => { + expect(getAllInputs("doesnotexist")).toEqual([]); + }); + + // BUG: building allTargets/allInputs stores the first category's array by + // reference and then pushes the other categories into it. That mutates the + // converters' own properties, so e.g. LibreOffice offers docx -> xlsx and + // libjxl offers jxl -> jxl. Remove `.failing` once main.ts copies the arrays. + test.failing("listing all targets and inputs does not leak formats across categories", () => { + expect(getPossibleTargets("docx").libreoffice).not.toContain("xlsx"); + expect(getPossibleTargets("jxl").libjxl).not.toContain("jxl"); + expect(libreofficeProperties.from.text).not.toContain("xlsx"); + expect(libreofficeProperties.to.text).not.toContain("xlsm"); + }); }); -test("handleConvert uses vcf converter to transform .vcf to .csv and records DB entry", async () => { - const uploadsDir = "./data/uploads/test-main/"; - const outputDir = "./data/output/test-main/"; - await mkdir(uploadsDir, { recursive: true }); - await mkdir(outputDir, { recursive: true }); +describe("chunks", () => { + test("chunks with size 0 returns entire array as single chunk", () => { + expect(chunks([1, 2, 3, 4, 5], 0)).toEqual([[1, 2, 3, 4, 5]]); + }); - const fileName = "contact.vcf"; - const inputPath = `${uploadsDir}${fileName}`; - const sampleVcf = `BEGIN:VCARD -FN:John Doe -N:Doe;John;;; -TEL;TYPE=CELL:123456789 -EMAIL:john@example.com -ORG:Example Inc; -END:VCARD -`; - await writeFile(inputPath, sampleVcf, "utf-8"); + test("chunks with negative size returns entire array as single chunk", () => { + expect(chunks(["a", "b", "c"], -1)).toEqual([["a", "b", "c"]]); + }); - const jobId = createMockJobId("4242"); + test("chunks with size larger than array returns single chunk", () => { + expect(chunks([1, 2], 10)).toEqual([[1, 2]]); + }); - await handleConvert([fileName], uploadsDir, outputDir, "csv", "vcf", jobId); + test("chunks with exact division returns equal-sized chunks", () => { + expect(chunks([1, 2, 3, 4, 5, 6], 2)).toEqual([ + [1, 2], + [3, 4], + [5, 6], + ]); + }); - const outPath = `${outputDir}contact.csv`; - const out = await readFile(outPath, "utf-8"); + test("chunks puts the remainder into a smaller last chunk", () => { + expect(chunks([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); + }); - // CSV should contain headers and the name - expect(out.includes("Full Name")).toBe(true); - expect(out.includes("John Doe")).toBe(true); - - // cleanup - await rm(inputPath); - await rm(outPath); + test("chunks of an empty array is empty", () => { + expect(chunks([], 3)).toEqual([]); + }); }); -test("handleConvert with unsupported format does not throw", async () => { - const uploadsDir = "./data/uploads/test-unsupported/"; - const outputDir = "./data/output/test-unsupported/"; - await mkdir(uploadsDir, { recursive: true }); - await mkdir(outputDir, { recursive: true }); +describe("mainConverter", () => { + test("returns 'File type not supported' for unsupported combination", async () => { + expect(await mainConverter("test.xyz", "xyz", "abc", "out.abc")).toBe( + "File type not supported", + ); + expect(output.logs).toContain("No available converter supports converting from xyz to abc."); + }); - // Create a dummy file with unsupported extension - const fileName = "dummy.xyz123"; - const inputPath = `${uploadsDir}${fileName}`; - await writeFile(inputPath, "dummy content", "utf-8"); + test("returns 'File type not supported' for an unknown converter name", async () => { + const inputPath = `${uploadsDir}contact.vcf`; + await writeFile(inputPath, vcard("Jane Roe")); - // Try to convert unsupported format - const jobId = createMockJobId("unsupported-test"); - // This should not throw, just log that no converter is available - await expect( - handleConvert([fileName], uploadsDir, outputDir, "pdf", "xyz123", jobId), - ).resolves.toBeUndefined(); + expect( + await mainConverter(inputPath, "vcf", "csv", `${outputDir}contact.csv`, {}, "doesnotexist"), + ).toBe("File type not supported"); + }); - await rm(inputPath); -}); + test("auto-discovers converter when not specified", async () => { + const inputPath = `${uploadsDir}test.vcf`; + const outPath = `${outputDir}test.csv`; + await writeFile(inputPath, vcard("Discover Test")); -test("handleConvert with multiple files processes them", async () => { - const uploadsDir = "./data/uploads/test-multi/"; - const outputDir = "./data/output/test-multi/"; - await mkdir(uploadsDir, { recursive: true }); - await mkdir(outputDir, { recursive: true }); + expect(await mainConverter(inputPath, "vcf", "csv", outPath)).toBe("Done"); + expect(await readFile(outPath, "utf-8")).toContain("Discover Test"); + }); - // Create multiple vcf files - const files = ["contact1.vcf", "contact2.vcf", "contact3.vcf"]; - const baseVcf = `BEGIN:VCARD -FN:Test Contact -N:Contact;Test;;; -END:VCARD -`; + test("normalizes the input type before discovering a converter", async () => { + const inputPath = `${uploadsDir}test.VCF`; + const outPath = `${outputDir}test.csv`; + await writeFile(inputPath, vcard("Upper Case")); - for (const fileName of files) { - await writeFile(`${uploadsDir}${fileName}`, baseVcf, "utf-8"); - } + expect(await mainConverter(inputPath, "VCF", "csv", outPath)).toBe("Done"); + }); - const jobId = createMockJobId("multi-test"); - await handleConvert(files, uploadsDir, outputDir, "csv", "vcf", jobId); + test("returns 'Failed, check logs' when converter throws", async () => { + expect( + await mainConverter(`${uploadsDir}missing.vcf`, "vcf", "csv", "out.csv", undefined, "vcf"), + ).toBe("Failed, check logs"); + expect(output.errors.join("\n")).toContain("using vcf"); + }); - // Verify all output files were created - for (const fileName of files) { - const outputFileName = fileName.replace(".vcf", ".csv"); - const outPath = `${outputDir}${outputFileName}`; - expect(await readFile(outPath, "utf-8")).toBeTruthy(); - await rm(outPath); - } - - // Cleanup - for (const fileName of files) { - await rm(`${uploadsDir}${fileName}`); - } -}); - -test("handleConvert with explicit converter skips discovery", async () => { - const uploadsDir = "./data/uploads/test-explicit/"; - const outputDir = "./data/output/test-explicit/"; - await mkdir(uploadsDir, { recursive: true }); - await mkdir(outputDir, { recursive: true }); - - const fileName = "test.vcf"; - const inputPath = `${uploadsDir}${fileName}`; - const sampleVcf = `BEGIN:VCARD -FN:Explicit Test -N:Test;Explicit;;; -END:VCARD -`; - await writeFile(inputPath, sampleVcf, "utf-8"); - - // Use explicit vcf converter (avoids discovery loop) - const jobId = createMockJobId("explicit-test"); - await handleConvert([fileName], uploadsDir, outputDir, "csv", "vcf", jobId); - - const outPath = `${outputDir}test.csv`; - expect(await readFile(outPath, "utf-8")).toBeTruthy(); - - await rm(inputPath); - await rm(outPath); -}); - -test("handleConvert with dvisvgm discovers converter from category keys", async () => { - const uploadsDir = "./data/uploads/test-dvisvgm/"; - const outputDir = "./data/output/test-dvisvgm/"; - await mkdir(uploadsDir, { recursive: true }); - await mkdir(outputDir, { recursive: true }); - - // Create a simple DVI-like file (dvisvgm would normally handle .dvi files) - // For testing we'll use a latex file and ask for svg output, which should fail gracefully - const fileName = "test.tex"; - const inputPath = `${uploadsDir}${fileName}`; - await writeFile( - inputPath, - "\\documentclass{article}\\begin{document}test\\end{document}", - "utf-8", + test.each([ + ["msgconvert", "mail.eml", "eml", "msg"], + ["calibre", "news.recipe", "recipe", "epub"], + ])( + "returns 'Failed, check logs' when %s refuses the conversion", + async (converterName, fileName, fileType, convertTo) => { + expect( + await mainConverter( + `${uploadsDir}${fileName}`, + fileType, + convertTo, + `${outputDir}out.${convertTo}`, + {}, + converterName, + ), + ).toBe("Failed, check logs"); + expect(output.errors.join("\n")).toContain(`using ${converterName}`); + }, ); - // This tests that converter discovery iterates through all converters - // and tries to find one matching tex -> svg - const jobId = createMockJobId("dvi-test"); - await handleConvert([fileName], uploadsDir, outputDir, "svg", "tex", jobId); + // BUG: discovery loops with `for (converterName in properties)` and only breaks + // the inner loop, so the variable always ends on the last registered converter. + // Remove `.failing` once mainConverter stops at the first match. + test.failing("logs the name of the auto-discovered converter", async () => { + const inputPath = `${uploadsDir}named.vcf`; + await writeFile(inputPath, vcard("Named")); - await rm(inputPath); + await mainConverter(inputPath, "vcf", "csv", `${outputDir}named.csv`); + + expect(output.logs.join("\n")).toContain("successfully using vcf"); + }); + + // BUG: same root cause - later matches overwrite earlier ones, so emf -> png + // uses ImageMagick even though Inkscape is registered first on purpose + // ("Prioritize Inkscape for EMF files"). The input does not exist, so whichever + // converter is picked fails fast; only the logged converter name matters. + test.failing("auto-discovery prefers the first registered converter", async () => { + await mainConverter(`${uploadsDir}missing.emf`, "emf", "png", `${outputDir}missing.png`); + + expect(output.errors.join("\n")).toContain("using inkscape"); + }); }); -test("handleConvert processes multiple files with vcf converter across categories", async () => { - const uploadsDir = "./data/uploads/test-vcf-multi/"; - const outputDir = "./data/output/test-vcf-multi/"; - await mkdir(uploadsDir, { recursive: true }); - await mkdir(outputDir, { recursive: true }); +describe("handleConvert", () => { + test("converts every file and records one row per file", async () => { + const files = ["a.vcf", "b.vcf", "c.vcf"]; + for (const fileName of files) { + await writeFile(`${uploadsDir}${fileName}`, vcard(`Contact ${fileName}`)); + } - const baseVcf = `BEGIN:VCARD -FN:Multi Test -N:Test;Multi;;; -TEL:9999 -EMAIL:multi@test.com -END:VCARD -`; + await handleConvert(files, uploadsDir, outputDir, "csv", "vcf", jobCookie("rows")); - // Create 2 vcf files to test looping through fileNames - const files = ["a.vcf", "b.vcf"]; - for (const f of files) { - await writeFile(`${uploadsDir}${f}`, baseVcf, "utf-8"); - } + expect(rowsFor("rows", "file_name")).toEqual([ + { file_name: "a.vcf", output_file_name: "a.csv", status: "Done" }, + { file_name: "b.vcf", output_file_name: "b.csv", status: "Done" }, + { file_name: "c.vcf", output_file_name: "c.csv", status: "Done" }, + ]); + for (const fileName of files) { + const csv = await readFile(`${outputDir}${fileName.replace(".vcf", ".csv")}`, "utf-8"); + expect(csv).toBe(`Full Name\n"Contact ${fileName}"`); + } + }); - // Explicit converter to hit the properties access code path - const jobId = createMockJobId("vcf-multi-test"); - await handleConvert(files, uploadsDir, outputDir, "csv", "vcf", jobId); + test("converts but does not record rows when the job id cookie has no value", async () => { + await writeFile(`${uploadsDir}contact.vcf`, vcard("No Job")); + const rowsBefore = countAllRows(); - for (const f of files) { - const csvName = f.replace(".vcf", ".csv"); - expect(await readFile(`${outputDir}${csvName}`, "utf-8")).toBeTruthy(); - await rm(`${outputDir}${csvName}`); - } + await handleConvert(["contact.vcf"], uploadsDir, outputDir, "csv", "vcf", jobCookie()); - for (const f of files) { - await rm(`${uploadsDir}${f}`); - } -}); - -test("chunks with size 0 returns entire array as single chunk", () => { - const arr = [1, 2, 3, 4, 5]; - const result = chunks(arr, 0); - expect(result).toEqual([[1, 2, 3, 4, 5]]); -}); - -test("chunks with negative size returns entire array as single chunk", () => { - const arr = ["a", "b", "c"]; - const result = chunks(arr, -1); - expect(result).toEqual([["a", "b", "c"]]); -}); - -test("chunks with size larger than array returns single chunk", () => { - const arr = [1, 2]; - const result = chunks(arr, 10); - expect(result).toEqual([[1, 2]]); -}); - -test("chunks with exact division returns equal-sized chunks", () => { - const arr = [1, 2, 3, 4, 5, 6]; - const result = chunks(arr, 2); - expect(result).toEqual([ - [1, 2], - [3, 4], - [5, 6], - ]); -}); - -test("mainConverter returns 'File type not supported' for unsupported combination", async () => { - const result = await mainConverter("test.xyz", "xyz", "abc", "out.abc"); - expect(result).toBe("File type not supported"); -}); - -test("mainConverter auto-discovers converter when not specified", async () => { - const uploadsDir = "./data/uploads/test-discover/"; - const outputDir = "./data/output/test-discover/"; - await mkdir(uploadsDir, { recursive: true }); - await mkdir(outputDir, { recursive: true }); - - const fileName = "test.vcf"; - const inputPath = `${uploadsDir}${fileName}`; - const outPath = `${outputDir}test.csv`; - const sampleVcf = `BEGIN:VCARD -FN:Discover Test -N:Test;Discover;;; -END:VCARD -`; - await writeFile(inputPath, sampleVcf, "utf-8"); - - // Call mainConverter without explicit converterName to trigger discovery - const result = await mainConverter(inputPath, "vcf", "csv", outPath, undefined, undefined); - expect(result).toBe("Done"); - expect(await readFile(outPath, "utf-8")).toBeTruthy(); - - await rm(inputPath); - await rm(outPath); -}); - -test("mainConverter returns 'Failed, check logs' when converter throws", async () => { - // Try with a file that might cause issues (non-existent input) - // This should trigger the catch block - const result = await mainConverter( - "/nonexistent/path.vcf", - "vcf", - "csv", - "out.csv", - undefined, - "vcf", - ); - expect(result).toBe("Failed, check logs"); -}); - -test("handleConvert with normalization covers fileTypeOrig variations", async () => { - const uploadsDir = "./data/uploads/test-normalize/"; - const outputDir = "./data/output/test-normalize/"; - await mkdir(uploadsDir, { recursive: true }); - await mkdir(outputDir, { recursive: true }); - - // Use a file extension that gets normalized (e.g., .htm -> .html) - const fileName = "index.htm"; - const inputPath = `${uploadsDir}${fileName}`; - const sampleHtml = ` - -Test -Test Content -`; - await writeFile(inputPath, sampleHtml, "utf-8"); - - // htm normalizes to html; libreoffice can handle html -> pdf - const jobId = createMockJobId("normalize-test"); - await handleConvert([fileName], uploadsDir, outputDir, "pdf", "htm", jobId); - - await rm(inputPath); - // PDF output may or may not exist depending on soffice availability, so we don't check it -}); - -test("handleConvert with explicit converter processes VCF to CSV", async () => { - const uploadsDir = "./data/uploads/test-main-extra/"; - const outputDir = "./data/output/test-main-extra/"; - await mkdir(uploadsDir, { recursive: true }); - await mkdir(outputDir, { recursive: true }); - - const fileName = "contact.vcf"; - const inputPath = `${uploadsDir}${fileName}`; - const outPath = `${outputDir}contact.csv`; - const sampleVcf = `BEGIN:VCARD -FN:Jane Roe -N:Roe;Jane;;; -TEL;TYPE=CELL:555 -EMAIL:jane@example.com -END:VCARD -`; - await writeFile(inputPath, sampleVcf, "utf-8"); - - // Call handleConvert with an explicit converter name ("vcf") to transform VCF to CSV - const jobId = createMockJobId("discovery-test"); - await handleConvert([fileName], uploadsDir, outputDir, "csv", "vcf", jobId); - - const out = await readFile(outPath, "utf-8"); - expect(out.includes("Jane Roe")).toBe(true); - - await rm(inputPath); - await rm(outPath); -}); - -test("handleConvert handles files without extension by appending output extension", async () => { - const uploadsDir = "./data/uploads/test-main-noext/"; - const outputDir = "./data/output/test-main-noext/"; - await mkdir(uploadsDir, { recursive: true }); - await mkdir(outputDir, { recursive: true }); - - const fileName = "noextfile"; // no extension - const inputPath = `${uploadsDir}${fileName}`; - const sampleVcf = `BEGIN:VCARD -FN:No Ext -N:Ext;No;;; -END:VCARD -`; - await writeFile(inputPath, sampleVcf, "utf-8"); - - const outPath = `${outputDir}${fileName}.csv`; - // Call handleConvert with explicit vcf converter (no extension on input) - const jobId = createMockJobId("noext-test"); - await handleConvert([fileName], uploadsDir, outputDir, "csv", "vcf", jobId); - - await rm(inputPath); - await rm(outPath); + expect(countAllRows()).toBe(rowsBefore); + expect(await readFile(`${outputDir}contact.csv`, "utf-8")).toContain("No Job"); + }); + + test("replaces only the last occurrence of the extension", async () => { + await writeFile(`${uploadsDir}vcf.backup.vcf`, vcard("Backup")); + + await handleConvert(["vcf.backup.vcf"], uploadsDir, outputDir, "csv", "vcf", jobCookie("ext")); + + expect(rowsFor("ext")).toEqual([ + { file_name: "vcf.backup.vcf", output_file_name: "vcf.backup.csv", status: "Done" }, + ]); + expect(await readFile(`${outputDir}vcf.backup.csv`, "utf-8")).toContain("Backup"); + }); + + test("keeps the base name of files with an upper-case extension", async () => { + await writeFile(`${uploadsDir}CONTACT.VCF`, vcard("Upper")); + + await handleConvert(["CONTACT.VCF"], uploadsDir, outputDir, "csv", "vcf", jobCookie("upper")); + + expect(rowsFor("upper")).toEqual([ + { file_name: "CONTACT.VCF", output_file_name: "CONTACT.csv", status: "Done" }, + ]); + }); + + test("handles files without extension by appending output extension", async () => { + await writeFile(`${uploadsDir}noextfile`, vcard("No Ext")); + + await handleConvert(["noextfile"], uploadsDir, outputDir, "csv", "vcf", jobCookie("noext")); + + expect(rowsFor("noext")).toEqual([ + { file_name: "noextfile", output_file_name: "noextfile.csv", status: "Done" }, + ]); + expect(await readFile(`${outputDir}noextfile.csv`, "utf-8")).toContain("No Ext"); + }); + + test("resolves and records the status when no converter supports the conversion", async () => { + await writeFile(`${uploadsDir}dummy.xyz123`, "dummy content"); + + await expect( + handleConvert(["dummy.xyz123"], uploadsDir, outputDir, "pdf", "xyz123", jobCookie("none")), + ).resolves.toBeUndefined(); + + expect(rowsFor("none")).toEqual([ + { + file_name: "dummy.xyz123", + output_file_name: "dummy.pdf", + status: "File type not supported", + }, + ]); + }); + + test("rejects when the output directory is missing, so the caller can log it", async () => { + await writeFile(`${uploadsDir}contact.vcf`, vcard("Lost")); + const missingDir = `${join(testRoot, "does-not-exist")}/`; + + await expect( + handleConvert(["contact.vcf"], uploadsDir, missingDir, "csv", "vcf", jobCookie("lost")), + ).rejects.toThrow(/ENOENT/); + expect(rowsFor("lost")).toEqual([]); + }); + + test.each([ + ["jpeg", "photo.jpg"], + ["markdown_strict", "photo.md"], + ["glb2", "photo.glb"], + ["stlb", "photo.stl"], + ])("names the output for target %s with its canonical extension", async (convertTo, expected) => { + // the conversion itself is unsupported, but the output name is still derived and recorded + await handleConvert(["photo.JPG"], uploadsDir, outputDir, convertTo, "none", jobCookie("name")); + + expect(rowsFor("name")).toEqual([ + { file_name: "photo.JPG", output_file_name: expected, status: "File type not supported" }, + ]); + }); + + test("records numbered sibling outputs (e.g. one file per page) in numeric order", async () => { + // converters such as ImageMagick write doc-1.csv, doc-2.csv, ... instead of doc.csv + for (const fileName of [ + "doc-1.csv", + "doc-2.csv", + "doc-10.csv", + // none of these belong to doc.csv + "doc-.csv", + "doc-a.csv", + "doc-1.txt", + "mydoc-1.csv", + "other-1.csv", + ]) { + await writeFile(`${outputDir}${fileName}`, ""); + } + await writeFile(`${uploadsDir}doc.vcf`, vcard("Pages")); + + await handleConvert(["doc.vcf"], uploadsDir, outputDir, "csv", "vcf", jobCookie("pages")); + + expect(rowsFor("pages").map((row) => row.output_file_name)).toEqual([ + "doc-1.csv", + "doc-2.csv", + "doc-10.csv", + "doc.csv", + ]); + }); }); diff --git a/tests/converters/markitdown.test.ts b/tests/converters/markitdown.test.ts index 109909fe..3c05ac02 100644 --- a/tests/converters/markitdown.test.ts +++ b/tests/converters/markitdown.test.ts @@ -1,7 +1,46 @@ -import { test } from "bun:test"; +import { afterEach, beforeEach, expect, test } from "bun:test"; import { convert } from "../../src/converters/markitdown"; import { runCommonTests } from "./helpers/commonTests"; +import { captureConsole, createMockExecFile } from "./helpers/converters"; runCommonTests(convert); -test.skip("dummy - required to trigger test detection", () => {}); +let output: ReturnType; + +beforeEach(() => { + output = captureConsole(); +}); + +afterEach(() => { + output.restore(); +}); + +test("invokes markitdown with input and -o target path", async () => { + const { execFile, calls } = createMockExecFile(); + + await convert("in/report.docx", "docx", "md", "out/report.md", undefined, execFile); + + expect(calls).toEqual([{ cmd: "markitdown", args: ["in/report.docx", "-o", "out/report.md"] }]); +}); + +test("prefixes rejections with the converter name", async () => { + const { execFile } = createMockExecFile({ error: new Error("boom") }); + + await expect( + convert("in/report.docx", "docx", "md", "out/report.md", undefined, execFile), + ).rejects.toBe("markitdown error: Error: boom"); +}); + +test("does not log output of a failed run", async () => { + const { execFile } = createMockExecFile({ + error: new Error("boom"), + stdout: "partial", + stderr: "trace", + }); + + await expect( + convert("in/report.docx", "docx", "md", "out/report.md", undefined, execFile), + ).rejects.toMatch(/boom/); + expect(output.logs).toEqual([]); + expect(output.errors).toEqual([]); +}); diff --git a/tests/converters/msgconvert.test.ts b/tests/converters/msgconvert.test.ts index 2a6631bf..02e64085 100644 --- a/tests/converters/msgconvert.test.ts +++ b/tests/converters/msgconvert.test.ts @@ -1,61 +1,90 @@ -import { expect, test } from "bun:test"; -import type { ExecFileException } from "node:child_process"; +import { afterEach, beforeEach, expect, test } from "bun:test"; import { convert } from "../../src/converters/msgconvert"; -import { ExecFileFn } from "../../src/converters/types"; +import { captureConsole, createMockExecFile } from "./helpers/converters"; -test("convert rejects conversion if input filetype is not msg and output type is not eml", async () => { - const mockExecFile: ExecFileFn = ( - _cmd: string, - _args: string[], - callback: (err: ExecFileException | null, stdout: string, stderr: string) => void, - ) => { - callback(null, "Fake stdout", ""); - }; +let output: ReturnType; - const expectedError = new Error( - "Unsupported conversion from obj to stl. Only MSG to EML conversion is currently supported.", - ); - - expect(convert("input.obj", "obj", "stl", "output.stl", undefined, mockExecFile)).rejects.toEqual( - expectedError, +beforeEach(() => { + output = captureConsole(); +}); + +afterEach(() => { + output.restore(); +}); + +test("convert invokes msgconvert with --outfile and resolves with the target path", async () => { + const { execFile, calls } = createMockExecFile(); + + const result = await convert("in/mail.msg", "msg", "eml", "out/mail.eml", undefined, execFile); + + expect(result).toBe("out/mail.eml"); + expect(calls).toEqual([ + { cmd: "msgconvert", args: ["--outfile", "out/mail.eml", "in/mail.msg"] }, + ]); +}); + +test.each([ + ["obj", "stl"], + ["msg", "pdf"], + ["eml", "eml"], + ["eml", "msg"], +])("convert rejects %s to %s without invoking msgconvert", async (fileType, convertTo) => { + const { execFile, calls } = createMockExecFile(); + + await expect( + convert(`input.${fileType}`, fileType, convertTo, `output.${convertTo}`, undefined, execFile), + ).rejects.toThrow( + `Unsupported conversion from ${fileType} to ${convertTo}. Only MSG to EML conversion is currently supported.`, ); + expect(calls).toHaveLength(0); }); test("convert rejects conversion on error", async () => { - const mockExecFile: ExecFileFn = ( - _cmd: string, - _args: string[], - callback: (err: ExecFileException | null, stdout: string, stderr: string) => void, - ) => { - callback(new Error("Test error"), "", ""); - }; + const { execFile } = createMockExecFile({ error: new Error("Test error") }); - const expectedError = new Error("msgconvert failed: Test error"); - - expect(convert("input.msg", "msg", "eml", "output.eml", undefined, mockExecFile)).rejects.toEqual( - expectedError, - ); + await expect( + convert("input.msg", "msg", "eml", "output.eml", undefined, execFile), + ).rejects.toThrow("msgconvert failed: Test error"); }); test("convert logs stderr as warning", async () => { - const originalConsoleWarn = console.warn; + const { execFile } = createMockExecFile({ stderr: "Fake stderr" }); - let loggedMessage = ""; - console.warn = (msg) => { - loggedMessage = msg; - }; + await convert("file.msg", "msg", "eml", "out.eml", undefined, execFile); - const mockExecFile = ( - _cmd: string, - _args: string[], - callback: (err: Error | null, stdout: string, stderr: string) => void, - ) => { - callback(null, "", "Fake stderr"); - }; - - await convert("file.msg", "msg", "eml", "out.eml", undefined, mockExecFile); - - console.error = originalConsoleWarn; - - expect(loggedMessage).toBe("msgconvert stderr: Fake stderr"); + expect(output.warnings).toEqual(["msgconvert stderr: Fake stderr"]); +}); + +test("convert redacts absolute paths in logged stderr", async () => { + const { execFile } = createMockExecFile({ + stderr: "cannot read /home/user/uploads/secret.msg: bad header", + }); + + await convert("file.msg", "msg", "eml", "out.eml", undefined, execFile); + + expect(output.warnings).toEqual(["msgconvert stderr: cannot read [REDACTED_PATH] bad header"]); +}); + +test("convert truncates logged stderr longer than 200 characters", async () => { + const { execFile } = createMockExecFile({ stderr: "x".repeat(201) }); + + await convert("file.msg", "msg", "eml", "out.eml", undefined, execFile); + + expect(output.warnings).toEqual([`msgconvert stderr: ${"x".repeat(200)}...`]); +}); + +test("convert does not truncate stderr of exactly 200 characters", async () => { + const { execFile } = createMockExecFile({ stderr: "x".repeat(200) }); + + await convert("file.msg", "msg", "eml", "out.eml", undefined, execFile); + + expect(output.warnings).toEqual([`msgconvert stderr: ${"x".repeat(200)}`]); +}); + +test("convert does not warn when stderr is empty", async () => { + const { execFile } = createMockExecFile({ stdout: "converted" }); + + await convert("file.msg", "msg", "eml", "out.eml", undefined, execFile); + + expect(output.warnings).toEqual([]); }); diff --git a/tests/converters/pandoc.test.ts b/tests/converters/pandoc.test.ts index c910d6c0..d70642fd 100644 --- a/tests/converters/pandoc.test.ts +++ b/tests/converters/pandoc.test.ts @@ -1,20 +1,13 @@ -import { beforeEach, expect, test, describe } from "bun:test"; +import { expect, test, describe } from "bun:test"; import { convert } from "../../src/converters/pandoc"; -import type { ExecFileFn } from "../../src/converters/types"; +import { runCommonTests } from "./helpers/commonTests"; +import { createMockExecFile } from "./helpers/converters"; + +runCommonTests(convert); describe("convert", () => { - let mockExecFile: ExecFileFn; - - beforeEach(() => { - mockExecFile = (cmd, args, callback) => callback(null, "output-data", ""); - }); - test("should call pandoc with correct arguments (normal)", async () => { - let calledArgs: Parameters = ["", [], () => {}]; - mockExecFile = (cmd, args, callback) => { - calledArgs = [cmd, args, callback]; - callback(null, "output-data", ""); - }; + const { execFile, calls } = createMockExecFile(); const result = await convert( "input.md", @@ -22,45 +15,48 @@ describe("convert", () => { "html", "output.html", undefined, - mockExecFile, + execFile, ); - expect(calledArgs[0]).toBe("pandoc"); - expect(calledArgs[1]).toEqual([ - "input.md", - "-f", - "markdown", - "-t", - "html", - "-o", - "output.html", + expect(calls).toEqual([ + { + cmd: "pandoc", + args: ["input.md", "-f", "markdown", "-t", "html", "-o", "output.html"], + }, ]); expect(result).toBe("Done"); }); - test("should add xelatex argument for pdf/latex", async () => { - let calledArgs: Parameters = ["", [], () => {}]; - mockExecFile = (cmd, args, callback) => { - calledArgs = [cmd, args, callback]; - callback(null, "output-data", ""); - }; + test.each(["pdf", "latex"])("should use the xelatex pdf engine for %s", async (convertTo) => { + const { execFile, calls } = createMockExecFile(); - await convert("input.md", "markdown", "pdf", "output.pdf", undefined, mockExecFile); + await convert("input.md", "markdown", convertTo, `output.${convertTo}`, undefined, execFile); - expect(calledArgs[1][0]).toBe("--pdf-engine=xelatex"); - expect(calledArgs[1]).toContain("input.md"); - expect(calledArgs[1]).toContain("-f"); - expect(calledArgs[1]).toContain("markdown"); - expect(calledArgs[1]).toContain("-t"); - expect(calledArgs[1]).toContain("pdf"); - expect(calledArgs[1]).toContain("-o"); - expect(calledArgs[1]).toContain("output.pdf"); + expect(calls[0]?.args).toEqual([ + "--pdf-engine=xelatex", + "input.md", + "-f", + "markdown", + "-t", + convertTo, + "-o", + `output.${convertTo}`, + ]); + }); + + test("should not set a pdf engine for other targets", async () => { + const { execFile, calls } = createMockExecFile(); + + await convert("input.md", "markdown", "docx", "output.docx", undefined, execFile); + + expect(calls[0]?.args.some((arg) => arg.startsWith("--pdf-engine"))).toBe(false); }); test("should reject if execFile returns an error", async () => { - mockExecFile = (cmd, args, callback) => callback(new Error("fail"), "", ""); + const { execFile } = createMockExecFile({ error: new Error("fail") }); + await expect( - convert("input.md", "markdown", "html", "output.html", undefined, mockExecFile), + convert("input.md", "markdown", "html", "output.html", undefined, execFile), ).rejects.toMatch(/error: Error: fail/); }); }); diff --git a/tests/converters/pdftops.test.ts b/tests/converters/pdftops.test.ts index ca6b525e..b3f94765 100644 --- a/tests/converters/pdftops.test.ts +++ b/tests/converters/pdftops.test.ts @@ -1,77 +1,55 @@ -import { beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, test } from "bun:test"; import { convert } from "../../src/converters/pdftops"; import { runCommonTests } from "./helpers/commonTests"; +import { captureConsole, createMockExecFile } from "./helpers/converters"; runCommonTests(convert); -let calls: string[][] = []; - -function mockExecFile( - _cmd: string, - args: string[], - callback: (err: Error | null, stdout: string, stderr: string) => void, -) { - calls.push(args); - if (args.includes("fail.pdf")) { - callback(new Error("mock failure"), "", "Fake stderr: fail"); - } else { - callback(null, "Fake stdout", ""); - } -} +let output: ReturnType; beforeEach(() => { - calls = []; + output = captureConsole(); +}); + +afterEach(() => { + output.restore(); }); test("converts a normal file to ps", async () => { - const originalConsoleLog = console.log; + const { execFile, calls } = createMockExecFile({ stdout: "Fake stdout" }); - let loggedMessage = ""; - console.log = (msg) => { - loggedMessage = msg; - }; - - const result = await convert("in.pdf", "pdf", "ps", "out.ps", undefined, mockExecFile); - - console.log = originalConsoleLog; + const result = await convert("in.pdf", "pdf", "ps", "out.ps", undefined, execFile); expect(result).toBe("Done"); - expect(calls[0]).toEqual(["in.pdf", "out.ps"]); - expect(loggedMessage).toBe("stdout: Fake stdout"); + expect(calls).toEqual([{ cmd: "pdftops", args: ["in.pdf", "out.ps"] }]); + expect(output.logs).toEqual(["stdout: Fake stdout"]); }); test("adds -eps flag for eps output", async () => { - const result = await convert("in.pdf", "pdf", "eps", "out.eps", undefined, mockExecFile); + const { execFile, calls } = createMockExecFile(); + + const result = await convert("in.pdf", "pdf", "eps", "out.eps", undefined, execFile); expect(result).toBe("Done"); - expect(calls[0]).toEqual(["-eps", "in.pdf", "out.eps"]); + expect(calls).toEqual([{ cmd: "pdftops", args: ["-eps", "in.pdf", "out.eps"] }]); }); -test("fails on exec error", async () => { - expect(convert("fail.pdf", "pdf", "ps", "output.ps", undefined, mockExecFile)).rejects.toMatch( - /error: Error: mock failure/, +test("fails on exec error without logging the output of the failed run", async () => { + const { execFile } = createMockExecFile({ + error: new Error("mock failure"), + stderr: "Fake stderr: fail", + }); + + await expect(convert("fail.pdf", "pdf", "ps", "output.ps", undefined, execFile)).rejects.toBe( + "error: Error: mock failure", ); + expect(output.errors).toEqual([]); }); test("logs stderr when execFile returns only stderr and no error", async () => { - const originalConsoleError = console.error; + const { execFile } = createMockExecFile({ stderr: "Only stderr output" }); - let loggedMessage = ""; - console.error = (msg) => { - loggedMessage = msg; - }; + await convert("input.pdf", "pdf", "ps", "output.ps", undefined, execFile); - const mockExecFileStderrOnly = ( - _cmd: string, - _args: string[], - callback: (err: Error | null, stdout: string, stderr: string) => void, - ) => { - callback(null, "", "Only stderr output"); - }; - - await convert("input.pdf", "pdf", "ps", "output.ps", undefined, mockExecFileStderrOnly); - - console.error = originalConsoleError; - - expect(loggedMessage).toBe("stderr: Only stderr output"); + expect(output.errors).toEqual(["stderr: Only stderr output"]); }); diff --git a/tests/converters/potrace.test.ts b/tests/converters/potrace.test.ts index 90dd86bd..cf1ebf65 100644 --- a/tests/converters/potrace.test.ts +++ b/tests/converters/potrace.test.ts @@ -1,7 +1,19 @@ -import { test } from "bun:test"; +import { expect, test } from "bun:test"; import { convert } from "../../src/converters/potrace"; import { runCommonTests } from "./helpers/commonTests"; +import { createMockExecFile } from "./helpers/converters"; runCommonTests(convert); -test.skip("dummy - required to trigger test detection", () => {}); +test.each(["svg", "pdf", "eps", "dxf"])("selects the %s backend via -b", async (convertTo) => { + const { execFile, calls } = createMockExecFile(); + + await convert("in/bitmap.pbm", "pbm", convertTo, `out/bitmap.${convertTo}`, undefined, execFile); + + expect(calls).toEqual([ + { + cmd: "potrace", + args: ["in/bitmap.pbm", "-o", `out/bitmap.${convertTo}`, "-b", convertTo], + }, + ]); +}); diff --git a/tests/converters/resvg.test.ts b/tests/converters/resvg.test.ts index 88b3aba3..ad06a263 100644 --- a/tests/converters/resvg.test.ts +++ b/tests/converters/resvg.test.ts @@ -1,7 +1,14 @@ -import { test } from "bun:test"; +import { expect, test } from "bun:test"; import { convert } from "../../src/converters/resvg"; import { runCommonTests } from "./helpers/commonTests"; +import { createMockExecFile } from "./helpers/converters"; runCommonTests(convert); -test.skip("dummy - required to trigger test detection", () => {}); +test("invokes resvg with input and target path", async () => { + const { execFile, calls } = createMockExecFile(); + + await convert("in/icon.svg", "svg", "png", "out/icon.png", undefined, execFile); + + expect(calls).toEqual([{ cmd: "resvg", args: ["in/icon.svg", "out/icon.png"] }]); +}); diff --git a/tests/converters/vcf.test.ts b/tests/converters/vcf.test.ts index 181dd712..06f89535 100644 --- a/tests/converters/vcf.test.ts +++ b/tests/converters/vcf.test.ts @@ -1,4 +1,8 @@ -import { expect, test, describe } from "bun:test"; +import { afterAll, beforeAll, expect, test, describe } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { convert, parseVCF, toCSV } from "../../src/converters/vcf"; describe("parseVCF", () => { @@ -60,6 +64,83 @@ END:VCARD`; }, ]); }); + + test("should handle CRLF line endings", () => { + const vcfData = "BEGIN:VCARD\r\nFN:John Doe\r\nTEL:123\r\nEND:VCARD\r\n"; + + expect(parseVCF(vcfData)).toEqual([{ "Full Name": "John Doe", Phone: "123" }]); + }); + + test("should keep colons that are part of the value", () => { + const vcfData = `BEGIN:VCARD +FN:Dr: Who +END:VCARD`; + + expect(parseVCF(vcfData)).toEqual([{ "Full Name": "Dr: Who" }]); + }); + + test("should default the first name to an empty string when N has only a last name", () => { + const vcfData = `BEGIN:VCARD +N:Doe +END:VCARD`; + + expect(parseVCF(vcfData)).toEqual([{ "Last Name": "Doe", "First Name": "" }]); + }); + + test("should only use the organization name, not its units", () => { + const vcfData = `BEGIN:VCARD +ORG:Example Corp;Marketing;Berlin +END:VCARD`; + + expect(parseVCF(vcfData)).toEqual([{ Organization: "Example Corp" }]); + }); + + test("should not mistake properties starting with N for the name", () => { + const vcfData = `BEGIN:VCARD +FN:John Doe +NOTE:likes cats +NICKNAME:Johnny +END:VCARD`; + + expect(parseVCF(vcfData)).toEqual([{ "Full Name": "John Doe" }]); + }); + + test("should skip lines without a colon", () => { + const vcfData = `BEGIN:VCARD +FN:John Doe +this line is garbage +END:VCARD`; + + expect(parseVCF(vcfData)).toEqual([{ "Full Name": "John Doe" }]); + }); + + test("should ignore text outside of BEGIN/END blocks", () => { + const vcfData = `exported by some tool +FN:Not A Contact +BEGIN:VCARD +FN:John Doe +END:VCARD +FN:Also Not A Contact`; + + expect(parseVCF(vcfData)).toEqual([{ "Full Name": "John Doe" }]); + }); + + test("should drop cards that contain no known properties", () => { + const vcfData = `BEGIN:VCARD +END:VCARD +BEGIN:VCARD +VERSION:3.0 +URL:https://example.com +END:VCARD +BEGIN:VCARDEND:VCARD`; + + expect(parseVCF(vcfData)).toEqual([]); + }); + + test("should return an empty list for input without cards", () => { + expect(parseVCF("")).toEqual([]); + expect(parseVCF("just some text")).toEqual([]); + }); }); describe("toCSV", () => { @@ -85,6 +166,30 @@ describe("toCSV", () => { expect(result).toBe('Full Name\n"John ""Johnny"" Doe"'); }); + test("should keep commas and newlines inside quoted values", () => { + const contacts = [{ "Full Name": "Doe, John", Organization: "Line 1\nLine 2" }]; + + expect(toCSV(contacts)).toBe('Full Name,Organization\n"Doe, John","Line 1\nLine 2"'); + }); + + test("should write an empty cell for values missing in later contacts", () => { + const contacts = [ + { "Full Name": "John Doe", Email: "john@example.com" }, + { "Full Name": "Jane Smith" }, + ]; + + expect(toCSV(contacts)).toBe('Full Name,Email\n"John Doe","john@example.com"\n"Jane Smith",""'); + }); + + // BUG: the header is built from the first contact only, so fields that only + // later contacts have are silently dropped from the export. Remove `.failing` + // once toCSV builds the header from all contacts. + test.failing("should not drop fields that only later contacts have", () => { + const contacts = [{ "Full Name": "John Doe" }, { "Full Name": "Jane Smith", Email: "j@x.com" }]; + + expect(toCSV(contacts)).toContain("j@x.com"); + }); + test("should handle empty data", () => { const result = toCSV([]); expect(result).toBe(""); @@ -92,7 +197,52 @@ describe("toCSV", () => { }); describe("convert", () => { - test("should be a function", () => { - expect(typeof convert).toBe("function"); + let dir: string; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "convertx-vcf-")); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + test("should read the vcf file and write the contacts as csv", async () => { + const inputPath = join(dir, "contacts.vcf"); + const targetPath = join(dir, "contacts.csv"); + await writeFile( + inputPath, + `BEGIN:VCARD +FN:John Doe +EMAIL:john@example.com +END:VCARD +BEGIN:VCARD +FN:Jane Smith +EMAIL:jane@example.com +END:VCARD +`, + ); + + const result = await convert(inputPath, "vcf", "csv", targetPath); + + expect(result).toBe("Done"); + expect(await readFile(targetPath, "utf-8")).toBe( + 'Full Name,Email\n"John Doe","john@example.com"\n"Jane Smith","jane@example.com"', + ); + }); + + test("should write an empty file when the input has no contacts", async () => { + const inputPath = join(dir, "empty.vcf"); + const targetPath = join(dir, "empty.csv"); + await writeFile(inputPath, ""); + + await expect(convert(inputPath, "vcf", "csv", targetPath)).resolves.toBe("Done"); + expect(await readFile(targetPath, "utf-8")).toBe(""); + }); + + test("should reject when the input file does not exist", async () => { + await expect( + convert(join(dir, "missing.vcf"), "vcf", "csv", join(dir, "missing.csv")), + ).rejects.toThrow(/ENOENT/); }); }); diff --git a/tests/converters/vips.test.ts b/tests/converters/vips.test.ts index 9f944492..6c264cf8 100644 --- a/tests/converters/vips.test.ts +++ b/tests/converters/vips.test.ts @@ -1,65 +1,24 @@ -import { beforeEach, expect, test } from "bun:test"; -import type { ExecFileException } from "node:child_process"; -import { ExecFileFn } from "../../src/converters/types"; +import { expect, test } from "bun:test"; import { convert } from "../../src/converters/vips"; import { runCommonTests } from "./helpers/commonTests"; - -let calls: string[][] = []; - -beforeEach(() => { - calls = []; -}); +import { createMockExecFile } from "./helpers/converters"; runCommonTests(convert); -test("convert uses action pdfload with filetype being pdf", async () => { - const originalConsoleLog = console.log; +test("uses the pdfload action for pdf input", async () => { + const { execFile, calls } = createMockExecFile(); - let loggedMessage = ""; - console.log = (msg) => { - loggedMessage = msg; - }; + await convert("in/doc.pdf", "pdf", "png", "out/doc.png", undefined, execFile); - const mockExecFile: ExecFileFn = ( - _cmd: string, - _args: string[], - callback: (err: ExecFileException | null, stdout: string, stderr: string) => void, - ) => { - calls.push(_args); - callback(null, "Fake stdout", ""); - }; - - const result = await convert("input.pdf", "pdf", "obj", "output.obj", undefined, mockExecFile); - - console.log = originalConsoleLog; - - expect(result).toBe("Done"); - expect(calls[0]).toEqual(expect.arrayContaining(["pdfload"])); - expect(loggedMessage).toBe("stdout: Fake stdout"); + expect(calls).toEqual([{ cmd: "vips", args: ["pdfload", "in/doc.pdf", "out/doc.png"] }]); }); -test("convert uses action copy with filetype being anything but pdf", async () => { - const originalConsoleLog = console.log; +test.each(["jpeg", "png", "svg", "tiff"])("uses the copy action for %s input", async (fileType) => { + const { execFile, calls } = createMockExecFile(); - let loggedMessage = ""; - console.log = (msg) => { - loggedMessage = msg; - }; + await convert(`in/image.${fileType}`, fileType, "webp", "out/image.webp", undefined, execFile); - const mockExecFile: ExecFileFn = ( - _cmd: string, - _args: string[], - callback: (err: ExecFileException | null, stdout: string, stderr: string) => void, - ) => { - calls.push(_args); - callback(null, "Fake stdout", ""); - }; - - const result = await convert("input.jpg", "jpg", "obj", "output.obj", undefined, mockExecFile); - - console.log = originalConsoleLog; - - expect(result).toBe("Done"); - expect(calls[0]).toEqual(expect.arrayContaining(["copy"])); - expect(loggedMessage).toBe("stdout: Fake stdout"); + expect(calls).toEqual([ + { cmd: "vips", args: ["copy", `in/image.${fileType}`, "out/image.webp"] }, + ]); }); diff --git a/tests/converters/vtracer.test.ts b/tests/converters/vtracer.test.ts index 365748e3..f78718ad 100644 --- a/tests/converters/vtracer.test.ts +++ b/tests/converters/vtracer.test.ts @@ -1,58 +1,133 @@ -import { beforeEach, expect, test, describe } from "bun:test"; +import { afterEach, beforeEach, expect, test, describe } from "bun:test"; import { convert } from "../../src/converters/vtracer"; -import type { ExecFileFn } from "../../src/converters/types"; +import { captureConsole, createMockExecFile } from "./helpers/converters"; describe("convert", () => { - let mockExecFile: ExecFileFn; + let output: ReturnType; beforeEach(() => { - mockExecFile = (cmd, args, callback) => callback(null, "output-data", ""); + output = captureConsole(); + }); + + afterEach(() => { + output.restore(); }); test("should call vtracer with correct arguments (minimal)", async () => { - let calledArgs: Parameters = ["", [], () => {}]; - mockExecFile = (cmd, args, callback) => { - calledArgs = [cmd, args, callback]; - callback(null, "output-data", ""); - }; + const { execFile, calls } = createMockExecFile(); - const result = await convert("input.png", "png", "svg", "output.svg", undefined, mockExecFile); + const result = await convert("input.png", "png", "svg", "output.svg", undefined, execFile); - expect(calledArgs[0]).toBe("vtracer"); - expect(calledArgs[1]).toEqual(["--input", "input.png", "--output", "output.svg"]); + expect(calls).toEqual([ + { cmd: "vtracer", args: ["--input", "input.png", "--output", "output.svg"] }, + ]); expect(result).toBe("Done"); }); - test("should add options as arguments", async () => { - let calledArgs: Parameters = ["", [], () => {}]; - mockExecFile = (cmd, args, callback) => { - calledArgs = [cmd, args, callback]; - callback(null, "output-data", ""); - }; + test("should pass every supported option as a flag in a fixed order", async () => { + const { execFile, calls } = createMockExecFile(); + // deliberately not in the converter's internal order const options = { + path_precision: 8, + splice_threshold: 45, + max_iterations: 10, + length_threshold: 4, + corner_threshold: 60, + layer_difference: 16, + color_precision: 6, + filter_speckle: 4, + mode: "spline", + hierarchical: "stacked", colormode: "color", - hierarchical: "true", - filter_speckle: 5, - path_precision: 0.8, }; - await convert("input.png", "png", "svg", "output.svg", options, mockExecFile); + await convert("input.png", "png", "svg", "output.svg", options, execFile); - expect(calledArgs[1]).toContain("--colormode"); - expect(calledArgs[1]).toContain("color"); - expect(calledArgs[1]).toContain("--hierarchical"); - expect(calledArgs[1]).toContain("true"); - expect(calledArgs[1]).toContain("--filter_speckle"); - expect(calledArgs[1]).toContain("5"); - expect(calledArgs[1]).toContain("--path_precision"); - expect(calledArgs[1]).toContain("0.8"); + expect(calls[0]?.args).toEqual([ + "--input", + "input.png", + "--output", + "output.svg", + "--colormode", + "color", + "--hierarchical", + "stacked", + "--mode", + "spline", + "--filter_speckle", + "4", + "--color_precision", + "6", + "--layer_difference", + "16", + "--corner_threshold", + "60", + "--length_threshold", + "4", + "--max_iterations", + "10", + "--splice_threshold", + "45", + "--path_precision", + "8", + ]); }); + test("should keep falsy option values such as 0", async () => { + const { execFile, calls } = createMockExecFile(); + + await convert("input.png", "png", "svg", "output.svg", { filter_speckle: 0 }, execFile); + + expect(calls[0]?.args.slice(4)).toEqual(["--filter_speckle", "0"]); + }); + + test("should ignore unknown and undefined options", async () => { + const { execFile, calls } = createMockExecFile(); + + const options = { mode: undefined, preset: "photo", "--output": "/etc/passwd" }; + await convert("input.png", "png", "svg", "output.svg", options, execFile); + + expect(calls[0]?.args).toEqual(["--input", "input.png", "--output", "output.svg"]); + }); + + test.each([null, "colormode=color", 42])( + "should ignore non-object options (%p)", + async (options) => { + const { execFile, calls } = createMockExecFile(); + + await convert("input.png", "png", "svg", "output.svg", options, execFile); + + expect(calls[0]?.args).toEqual(["--input", "input.png", "--output", "output.svg"]); + }, + ); + test("should reject if execFile returns an error", async () => { - mockExecFile = (cmd, args, callback) => callback(new Error("fail"), "", "stderr output"); + const { execFile } = createMockExecFile({ + error: new Error("fail"), + stderr: "stderr output", + }); + await expect( - convert("input.png", "png", "svg", "output.svg", undefined, mockExecFile), - ).rejects.toMatch(/error: Error: fail\nstderr: stderr output/); + convert("input.png", "png", "svg", "output.svg", undefined, execFile), + ).rejects.toBe("error: Error: fail\nstderr: stderr output"); + }); + + test("should omit the stderr suffix from the rejection when stderr is empty", async () => { + const { execFile } = createMockExecFile({ error: new Error("fail") }); + + await expect( + convert("input.png", "png", "svg", "output.svg", undefined, execFile), + ).rejects.toBe("error: Error: fail"); + }); + + test("should log stdout and stderr of a successful run", async () => { + const { execFile } = createMockExecFile({ stdout: "traced", stderr: "warning" }); + + await convert("input.png", "png", "svg", "output.svg", undefined, execFile); + + // vtracer reports progress on stderr, so it is logged as regular output + expect(output.logs).toEqual(["stdout: traced", "stderr: warning"]); + expect(output.errors).toEqual([]); }); }); diff --git a/tests/converters/xelatex.test.ts b/tests/converters/xelatex.test.ts index 5e5f0b0d..5e540664 100644 --- a/tests/converters/xelatex.test.ts +++ b/tests/converters/xelatex.test.ts @@ -1,7 +1,27 @@ -import { test } from "bun:test"; +import { expect, test } from "bun:test"; import { convert } from "../../src/converters/xelatex"; import { runCommonTests } from "./helpers/commonTests"; +import { createMockExecFile } from "./helpers/converters"; runCommonTests(convert); -test.skip("dummy - required to trigger test detection", () => {}); +test("invokes latexmk with xelatex in non-interactive mode", async () => { + const { execFile, calls } = createMockExecFile(); + + await convert("in/paper.tex", "latex", "pdf", "out/job/paper.pdf", undefined, execFile); + + expect(calls).toEqual([ + { + cmd: "latexmk", + args: ["-xelatex", "-interaction=nonstopmode", "-output-directory=out/job", "in/paper.tex"], + }, + ]); +}); + +test("strips a leading './' from the output directory", async () => { + const { execFile, calls } = createMockExecFile(); + + await convert("in/paper.tex", "latex", "pdf", "./data/output/paper.pdf", undefined, execFile); + + expect(calls[0]?.args).toContain("-output-directory=data/output"); +}); From 1248009e3061c0d3e0b72aa57ff3fe5bbfdaf6ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Krzeslak?= Date: Fri, 2 Oct 2026 14:20:43 +0200 Subject: [PATCH 11/25] test: add tests for pages --- bunfig.toml | 1 + tests/db/db.test.ts | 38 +-- tests/pages/chooseConverter.test.ts | 49 ++++ tests/pages/convert.test.ts | 174 ++++++++++++++ tests/pages/deleteFile.test.ts | 93 ++++++++ tests/pages/deleteJob.test.ts | 160 +++++++++++++ tests/pages/download.test.ts | 168 +++++++++++++ tests/pages/firstRun.test.ts | 55 +++++ tests/pages/healthcheck.test.ts | 18 ++ tests/pages/helpers/app.ts | 208 +++++++++++++++++ tests/pages/history.test.ts | 111 +++++++++ tests/pages/listConverters.test.ts | 35 +++ tests/pages/results.test.ts | 175 +++++++++++++- tests/pages/root.test.ts | 156 +++++++++++++ tests/pages/upload.test.ts | 113 +++++++++ tests/pages/user.test.ts | 350 ++++++++++++++++++++++++++++ tests/preload.ts | 25 ++ tests/services/user.test.ts | 212 +++++++++++++++++ 18 files changed, 2104 insertions(+), 37 deletions(-) create mode 100644 tests/pages/chooseConverter.test.ts create mode 100644 tests/pages/convert.test.ts create mode 100644 tests/pages/deleteFile.test.ts create mode 100644 tests/pages/deleteJob.test.ts create mode 100644 tests/pages/download.test.ts create mode 100644 tests/pages/firstRun.test.ts create mode 100644 tests/pages/healthcheck.test.ts create mode 100644 tests/pages/helpers/app.ts create mode 100644 tests/pages/history.test.ts create mode 100644 tests/pages/listConverters.test.ts create mode 100644 tests/pages/root.test.ts create mode 100644 tests/pages/upload.test.ts create mode 100644 tests/pages/user.test.ts create mode 100644 tests/preload.ts create mode 100644 tests/services/user.test.ts diff --git a/bunfig.toml b/bunfig.toml index 082bcacd..d073e330 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,4 +1,5 @@ [test] +preload = ["./tests/preload.ts"] coverage = true coveragePathIgnorePatterns = [ "tests/converters/helpers/commonTests.ts" diff --git a/tests/db/db.test.ts b/tests/db/db.test.ts index 49ec9420..29a5f903 100644 --- a/tests/db/db.test.ts +++ b/tests/db/db.test.ts @@ -1,11 +1,8 @@ -import { test, expect, beforeEach, afterEach, afterAll } from "bun:test"; +import { test, expect, beforeEach, afterEach } from "bun:test"; import { Database } from "bun:sqlite"; import { unlinkSync, existsSync, mkdirSync } from "node:fs"; -// set environment variable to ensure the test database is used instead of production data -process.env.DB_PATH = "./data/test-isolated.sqlite"; - -// dynamic import ensures that db.ts is loaded after the env is set +// tests/preload.ts points DB_PATH at a temporary database, so production data is never used let initializeDatabase: (db: Database) => void; let defaultDb: Database | undefined; await import("../../src/db/db").then((mod) => { @@ -97,37 +94,6 @@ afterEach(() => { } }); -afterAll(() => { - // Close the module-level default database before cleanup to prevent file lock errors - if (defaultDb) { - defaultDb.close(); - } - // Cleanup of the isolated test database after the test run - if (existsSync("./data/test-isolated.sqlite")) { - unlinkSync("./data/test-isolated.sqlite"); - } - if (existsSync("./data/test-isolated.sqlite-wal")) { - try { - unlinkSync("./data/test-isolated.sqlite-wal"); - } catch (err) { - // WAL file cleanup error - log but don't fail test - if (err instanceof Error && err.message.includes("ENOENT")) { - // File already gone, which is fine - } - } - } - if (existsSync("./data/test-isolated.sqlite-shm")) { - try { - unlinkSync("./data/test-isolated.sqlite-shm"); - } catch (err) { - // SHM file cleanup error - log but don't fail test - if (err instanceof Error && err.message.includes("ENOENT")) { - // File already gone, which is fine - } - } - } -}); - test("db initializes and creates tables on first run", () => { const tables = queryAllTables(testDb); expect(tables.length).toBeGreaterThanOrEqual(3); diff --git a/tests/pages/chooseConverter.test.ts b/tests/pages/chooseConverter.test.ts new file mode 100644 index 00000000..3042febf --- /dev/null +++ b/tests/pages/chooseConverter.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { request } from "./helpers/app"; + +async function conversionsFor(fileType: string) { + return (await request("/conversions", { json: { fileType } })).text(); +} + +describe("POST /conversions", () => { + test("lists the targets of every converter that accepts the file type", async () => { + const body = await conversionsFor("vcf"); + + expect(body).toContain('data-converter="vcf"'); + expect(body).toContain('data-value="csv,vcf"'); + expect(body).toContain(''); + }); + + test("normalizes the file type", async () => { + expect(await conversionsFor("VCF")).toBe(await conversionsFor("vcf")); + expect(await conversionsFor("jpg")).toBe(await conversionsFor("jpeg")); + }); + + test("offers no converters for an unknown file type", async () => { + const body = await conversionsFor("xyz123"); + + expect(body).not.toContain("convert_to_group"); + expect(body).not.toContain("'); + }); + + test("returns a fragment, not a full page", async () => { + const body = await conversionsFor("vcf"); + + expect(body).toStartWith(" { + const response = await request("/conversions", { json: { fileType: "vcf" } }); + + expect(response.status).toBe(200); + }); + + test("rejects a request without a file type", async () => { + const response = await request("/conversions", { json: {} }); + + expect(response.status).toBe(422); + }); +}); diff --git a/tests/pages/convert.test.ts b/tests/pages/convert.test.ts new file mode 100644 index 00000000..44756a6f --- /dev/null +++ b/tests/pages/convert.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; +import { createJob, createUser, db, jobDirs, request, waitFor } from "./helpers/app"; + +type JobRow = { status: string; num_files: number }; +type FileRow = { file_name: string; output_file_name: string; status: string }; + +function jobRow(jobId: number) { + return db.query("SELECT status, num_files FROM jobs WHERE id = ?").get(jobId) as JobRow; +} + +function fileRows(jobId: number) { + return db + .query( + "SELECT file_name, output_file_name, status FROM file_names WHERE job_id = ? ORDER BY file_name", + ) + .all(jobId) as FileRow[]; +} + +async function userWithJob() { + const user = await createUser(); + const job = createJob(user.id); + return { user, job, dirs: jobDirs(user.id, job) }; +} + +function convertRequest( + cookies: { auth: string; jobId?: number }, + form: { convert_to: string; file_names: string }, +) { + return request("/convert", { cookies, form }); +} + +describe("POST /convert", () => { + test("requires a session", async () => { + const response = await request("/convert", { + headers: { accept: "application/json" }, + form: { convert_to: "csv,vcf", file_names: "[]" }, + }); + + expect(response.status).toBe(401); + }); + + test("redirects home without a job", async () => { + const { user } = await userWithJob(); + + const response = await convertRequest( + { auth: user.token }, + { convert_to: "csv,vcf", file_names: '["a.vcf"]' }, + ); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/"); + }); + + test("refuses to convert another user's job", async () => { + const { user } = await userWithJob(); + const other = await userWithJob(); + + const response = await convertRequest( + { auth: user.token, jobId: other.job }, + { convert_to: "csv,vcf", file_names: '["a.vcf"]' }, + ); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/"); + expect(jobRow(other.job).status).toBe("not started"); + }); + + test.each([ + ["no converter is given", "csv"], + ["the target contains a path separator", "a/b,vcf"], + ["the target contains a backslash", "a\\b,vcf"], + ["the target points to a parent directory", "..,vcf"], + ])("redirects home without converting when %s", async (_, convertTo) => { + const { user, job } = await userWithJob(); + + const response = await convertRequest( + { auth: user.token, jobId: job }, + { convert_to: convertTo, file_names: '["a.vcf"]' }, + ); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/"); + expect(jobRow(job).status).toBe("not started"); + }); + + test("redirects home without converting when no files are given", async () => { + const { user, job } = await userWithJob(); + + const response = await convertRequest( + { auth: user.token, jobId: job }, + { convert_to: "csv,vcf", file_names: "[]" }, + ); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/"); + expect(jobRow(job).status).toBe("not started"); + }); + + test("converts the uploaded files in the background and shows the results", async () => { + const { user, job, dirs } = await userWithJob(); + mkdirSync(dirs.uploads, { recursive: true }); + await writeFile(`${dirs.uploads}a.vcf`, "BEGIN:VCARD\nFN:Alice\nEND:VCARD\n"); + await writeFile(`${dirs.uploads}b.vcf`, "BEGIN:VCARD\nFN:Bob\nEND:VCARD\n"); + + const response = await convertRequest( + { auth: user.token, jobId: job }, + { convert_to: "csv,vcf", file_names: '["a.vcf","b.vcf"]' }, + ); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe(`/results/${job}`); + expect(jobRow(job).num_files).toBe(2); + + await waitFor(() => jobRow(job).status === "completed"); + expect(fileRows(job)).toEqual([ + { file_name: "a.vcf", output_file_name: "a.csv", status: "Done" }, + { file_name: "b.vcf", output_file_name: "b.csv", status: "Done" }, + ]); + expect(await readFile(`${dirs.output}a.csv`, "utf-8")).toBe('Full Name\n"Alice"'); + }); + + test("normalizes the target type before converting", async () => { + const { user, job } = await userWithJob(); + + await convertRequest( + { auth: user.token, jobId: job }, + { convert_to: "JPG,doesnotexist", file_names: '["photo.png"]' }, + ); + + await waitFor(() => jobRow(job).status === "completed"); + // jpg is normalized to jpeg, whose files get the .jpg extension + expect(fileRows(job)).toEqual([ + { file_name: "photo.png", output_file_name: "photo.jpg", status: "File type not supported" }, + ]); + }); + + test("sanitizes file names so conversions cannot read outside the job directory", async () => { + const { user, job } = await userWithJob(); + + await convertRequest( + { auth: user.token, jobId: job }, + { convert_to: "csv,vcf", file_names: '["../../secret.vcf"]' }, + ); + + await waitFor(() => jobRow(job).status === "completed"); + expect(fileRows(job)).toEqual([ + { + file_name: "....secret.vcf", + output_file_name: "....secret.csv", + status: "Failed, check logs", + }, + ]); + }); + + // BUG: file_names is parsed and indexed before it is validated, so a value that is not a + // JSON array ends in a 500 instead of the redirect used for every other invalid input. + // Remove `.failing` once convert.tsx validates the parsed value first. + test.failing.each([ + ["is not JSON", "not json"], + ["is a JSON string", '"a.vcf"'], + ])("redirects home when file_names %s", async (_, fileNames) => { + const { user, job } = await userWithJob(); + + const response = await convertRequest( + { auth: user.token, jobId: job }, + { convert_to: "csv,vcf", file_names: fileNames }, + ); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/"); + }); +}); diff --git a/tests/pages/deleteFile.test.ts b/tests/pages/deleteFile.test.ts new file mode 100644 index 00000000..93c6dbe8 --- /dev/null +++ b/tests/pages/deleteFile.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { createJob, createUser, jobDirs, request, uploadsDir } from "./helpers/app"; + +async function userWithUploads(...fileNames: string[]) { + const user = await createUser(); + const job = createJob(user.id); + const dirs = jobDirs(user.id, job); + mkdirSync(dirs.uploads, { recursive: true }); + for (const fileName of fileNames) { + writeFileSync(`${dirs.uploads}${fileName}`, fileName); + } + return { user, job, dirs }; +} + +describe("POST /delete", () => { + test("requires a session", async () => { + const response = await request("/delete", { + headers: { accept: "application/json" }, + json: { filename: "a.txt" }, + }); + + expect(response.status).toBe(401); + }); + + test("redirects home without a job", async () => { + const { user, dirs } = await userWithUploads("a.txt"); + + const response = await request("/delete", { + cookies: { auth: user.token }, + json: { filename: "a.txt" }, + }); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/"); + expect(existsSync(`${dirs.uploads}a.txt`)).toBe(true); + }); + + test("refuses to delete from another user's job", async () => { + const { user } = await userWithUploads(); + const other = await userWithUploads("a.txt"); + + const response = await request("/delete", { + cookies: { auth: user.token, jobId: other.job }, + json: { filename: "a.txt" }, + }); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/"); + expect(existsSync(`${other.dirs.uploads}a.txt`)).toBe(true); + }); + + test("deletes only the given uploaded file", async () => { + const { user, job, dirs } = await userWithUploads("a.txt", "b.txt"); + + const response = await request("/delete", { + cookies: { auth: user.token, jobId: job }, + json: { filename: "a.txt" }, + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ message: "File deleted successfully." }); + expect(existsSync(`${dirs.uploads}a.txt`)).toBe(false); + expect(existsSync(`${dirs.uploads}b.txt`)).toBe(true); + }); + + test("sanitizes the file name so files outside the job cannot be deleted", async () => { + const { user, job } = await userWithUploads(); + const other = await userWithUploads("keep.txt"); + + await request("/delete", { + cookies: { auth: user.token, jobId: job }, + json: { filename: `../../${other.user.id}/${other.job}/keep.txt` }, + }); + + expect(existsSync(`${other.dirs.uploads}keep.txt`)).toBe(true); + }); + + // BUG: unlink errors are not handled, so deleting a file that does not exist answers + // with a 500 whose body contains the absolute upload path on the server. Remove + // `.failing` once deleteFile.tsx handles the missing file. + test.failing("answers a missing file without a server error or server paths", async () => { + const { user, job } = await userWithUploads(); + + const response = await request("/delete", { + cookies: { auth: user.token, jobId: job }, + json: { filename: "missing.txt" }, + }); + + expect(response.status).toBeLessThan(500); + expect(await response.text()).not.toContain(uploadsDir); + }); +}); diff --git a/tests/pages/deleteJob.test.ts b/tests/pages/deleteJob.test.ts new file mode 100644 index 00000000..1ef31dbf --- /dev/null +++ b/tests/pages/deleteJob.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { createJob, createUser, db, jobDirs, request } from "./helpers/app"; + +async function userWithJobs(count = 1) { + const user = await createUser(); + const jobs = Array.from({ length: count }, () => { + const job = createJob(user.id, { numFiles: 1 }); + const dirs = jobDirs(user.id, job); + mkdirSync(dirs.uploads, { recursive: true }); + mkdirSync(dirs.output, { recursive: true }); + writeFileSync(`${dirs.uploads}in.vcf`, "in"); + writeFileSync(`${dirs.output}out.csv`, "out"); + return job; + }); + return { user, jobs }; +} + +function jobExists(jobId: number | string) { + return db.query("SELECT id FROM jobs WHERE id = ?").get(jobId) !== null; +} + +function filesExist(userId: number, jobId: number) { + const dirs = jobDirs(userId, jobId); + return existsSync(dirs.uploads) || existsSync(dirs.output); +} + +describe("POST /delete/:jobId", () => { + test("requires a session", async () => { + const { user, jobs } = await userWithJobs(); + + const response = await request(`/delete/${jobs[0]}`, { + method: "POST", + headers: { accept: "application/json" }, + }); + + expect(response.status).toBe(401); + expect(jobExists(jobs[0] ?? 0)).toBe(true); + expect(filesExist(user.id, jobs[0] ?? 0)).toBe(true); + }); + + test("deletes the job with its uploaded and converted files", async () => { + const { user, jobs } = await userWithJobs(); + const job = jobs[0] ?? 0; + + const response = await request(`/delete/${job}`, { + method: "POST", + cookies: { auth: user.token }, + }); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/history"); + expect(jobExists(job)).toBe(false); + expect(filesExist(user.id, job)).toBe(false); + }); + + test("refuses to delete another user's job", async () => { + const user = await createUser(); + const other = await userWithJobs(); + const job = other.jobs[0] ?? 0; + + const response = await request(`/delete/${job}`, { + method: "POST", + cookies: { auth: user.token }, + }); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/results"); + expect(jobExists(job)).toBe(true); + expect(filesExist(other.user.id, job)).toBe(true); + }); + + test("cannot be triggered by a GET request (CSRF protection)", async () => { + const { user, jobs } = await userWithJobs(); + const job = jobs[0] ?? 0; + + const response = await request(`/delete/${job}`, { cookies: { auth: user.token } }); + + expect(response.status).toBe(404); + expect(jobExists(job)).toBe(true); + }); +}); + +describe("POST /delete-multiple", () => { + const deleteMultiple = (token: string, jobIds: unknown) => + request("/delete-multiple", { cookies: { auth: token }, json: { jobIds } }); + + test("requires a session", async () => { + const { jobs } = await userWithJobs(); + + const response = await request("/delete-multiple", { + headers: { accept: "application/json" }, + json: { jobIds: jobs.map(String) }, + }); + + expect(response.status).toBe(401); + expect(jobExists(jobs[0] ?? 0)).toBe(true); + }); + + test("deletes all given jobs with their files", async () => { + const { user, jobs } = await userWithJobs(2); + + const response = await deleteMultiple(user.token, jobs.map(String)); + + expect(await response.json()).toEqual({ + success: true, + deleted: 2, + failed: 0, + details: { success: jobs.map(String), failed: [] }, + }); + for (const job of jobs) { + expect(jobExists(job)).toBe(false); + expect(filesExist(user.id, job)).toBe(false); + } + }); + + test("reports jobs that do not exist or belong to another user", async () => { + const { user, jobs } = await userWithJobs(); + const other = await userWithJobs(); + const foreign = String(other.jobs[0]); + + const response = await deleteMultiple(user.token, [String(jobs[0]), foreign, "999999"]); + + expect(await response.json()).toEqual({ + success: false, + deleted: 1, + failed: 2, + details: { + success: [String(jobs[0])], + failed: [ + { jobId: foreign, error: "Job not found or unauthorized" }, + { jobId: "999999", error: "Job not found or unauthorized" }, + ], + }, + }); + expect(jobExists(foreign)).toBe(true); + expect(filesExist(other.user.id, Number(foreign))).toBe(true); + }); + + test("rejects an empty list", async () => { + const user = await createUser(); + + const response = await deleteMultiple(user.token, []); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ success: false, message: "Invalid job IDs provided" }); + }); + + test.each([ + ["more than 100 job ids", Array.from({ length: 101 }, (_, i) => String(i))], + ["job ids that are not strings", [1, 2]], + ["a value that is not a list", "1"], + ])("rejects %s", async (_, jobIds) => { + const user = await createUser(); + + const response = await deleteMultiple(user.token, jobIds); + + expect(response.status).toBe(422); + }); +}); diff --git a/tests/pages/download.test.ts b/tests/pages/download.test.ts new file mode 100644 index 00000000..9827b8ec --- /dev/null +++ b/tests/pages/download.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as tar from "tar"; +import { createJob, createUser, jobDirs, request } from "./helpers/app"; + +async function userWithOutputs(files: Record) { + const user = await createUser(); + const job = createJob(user.id); + const dirs = jobDirs(user.id, job); + mkdirSync(dirs.output, { recursive: true }); + for (const [name, content] of Object.entries(files)) { + writeFileSync(`${dirs.output}${name}`, content); + } + return { user, job, dirs }; +} + +async function tarEntries(response: Response) { + const dir = mkdtempSync(join(tmpdir(), "convertx-archive-")); + try { + const file = join(dir, "archive.tar"); + writeFileSync(file, Buffer.from(await response.arrayBuffer())); + const entries: string[] = []; + await tar.list({ file, onReadEntry: (entry) => entries.push(entry.path) }); + return entries.sort(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +describe("GET /download/:userId/:jobId/:fileName", () => { + test("redirects to the login page without a session", async () => { + const { user, job } = await userWithOutputs({ "a.csv": "a" }); + + const response = await request(`/download/${user.id}/${job}/a.csv`, { + headers: { accept: "text/html" }, + }); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/login"); + }); + + test("serves a converted file", async () => { + const { user, job } = await userWithOutputs({ "a.csv": "converted" }); + + const response = await request(`/download/${user.id}/${job}/a.csv`, { + cookies: { auth: user.token }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toStartWith("text/csv"); + expect(await response.text()).toBe("converted"); + }); + + test("serves file names with encoded spaces and hashes", async () => { + const { user, job } = await userWithOutputs({ "my file #1.csv": "spaced" }); + + const response = await request(`/download/${user.id}/${job}/my%20file%20%231.csv`, { + cookies: { auth: user.token }, + }); + + expect(await response.text()).toBe("spaced"); + }); + + test("answers 404 for a file that does not exist", async () => { + const { user, job } = await userWithOutputs({}); + + const response = await request(`/download/${user.id}/${job}/missing.csv`, { + cookies: { auth: user.token }, + }); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ message: "Converted file not found." }); + }); + + test("refuses files of another user's job", async () => { + const user = await createUser(); + const other = await userWithOutputs({ "secret.csv": "secret" }); + + const response = await request(`/download/${other.user.id}/${other.job}/secret.csv`, { + cookies: { auth: user.token }, + }); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/results"); + }); + + test("ignores the user id in the URL and only serves the caller's own files", async () => { + const { user, job } = await userWithOutputs({ "a.csv": "mine" }); + const other = await createUser(); + mkdirSync(jobDirs(other.id, job).output, { recursive: true }); + writeFileSync(`${jobDirs(other.id, job).output}a.csv`, "theirs"); + + const response = await request(`/download/${other.id}/${job}/a.csv`, { + cookies: { auth: user.token }, + }); + + expect(await response.text()).toBe("mine"); + }); + + test("sanitizes the file name so files outside the job cannot be read", async () => { + const { user, job } = await userWithOutputs({}); + const other = await userWithOutputs({ "secret.csv": "secret" }); + + const traversal = encodeURIComponent(`../../${other.user.id}/${other.job}/secret.csv`); + const response = await request(`/download/${user.id}/${job}/${traversal}`, { + cookies: { auth: user.token }, + }); + + expect(response.status).toBe(404); + }); + + // BUG: Elysia already decodes path parameters and download.tsx decodes them a second + // time, so file names containing "%" fail with a 500 "URI error" (and a literal "%20" + // would be turned into a space). The results page links to such files. Remove + // `.failing` once the second decodeURIComponent is dropped. + test.failing("serves file names containing a percent sign", async () => { + const { user, job } = await userWithOutputs({ "100%.csv": "percent" }); + + const response = await request(`/download/${user.id}/${job}/100%25.csv`, { + cookies: { auth: user.token }, + }); + + expect(response.status).toBe(200); + expect(await response.text()).toBe("percent"); + }); +}); + +describe("GET /archive/:jobId", () => { + test("redirects to the login page without a session", async () => { + const { job } = await userWithOutputs({ "a.csv": "a" }); + + const response = await request(`/archive/${job}`, { headers: { accept: "text/html" } }); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/login"); + }); + + test("packs all converted files of the job into a tar archive", async () => { + const { user, job } = await userWithOutputs({ "a.csv": "a", "b.csv": "b" }); + + const response = await request(`/archive/${job}`, { cookies: { auth: user.token } }); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/x-tar"); + expect(await tarEntries(response)).toEqual(["./", "./a.csv", "./b.csv"]); + }); + + test("does not include earlier archives in a new archive", async () => { + const { user, job } = await userWithOutputs({ "a.csv": "a" }); + + await request(`/archive/${job}`, { cookies: { auth: user.token } }); + const response = await request(`/archive/${job}`, { cookies: { auth: user.token } }); + + expect(await tarEntries(response)).toEqual(["./", "./a.csv"]); + }); + + test("refuses another user's job", async () => { + const user = await createUser(); + const other = await userWithOutputs({ "secret.csv": "secret" }); + + const response = await request(`/archive/${other.job}`, { cookies: { auth: user.token } }); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/results"); + }); +}); diff --git a/tests/pages/firstRun.test.ts b/tests/pages/firstRun.test.ts new file mode 100644 index 00000000..4665b366 --- /dev/null +++ b/tests/pages/firstRun.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import type { User } from "../../src/db/types"; +import { db, FIRST_ACCOUNT, firstRun as recordedFirstRun, SECOND_ACCOUNT } from "./helpers/app"; + +// helpers/app.ts walks through the first run when it loads the pages (see the comment +// there); these tests check the recorded responses. +if (!recordedFirstRun) { + throw new Error("The database already had accounts when helpers/app.ts loaded the pages"); +} +const firstRun = recordedFirstRun; + +function findUser(email: string) { + return db.query("SELECT * FROM users WHERE email = ?").get(email) as User | null; +} + +describe("first run", () => { + test("shows the account setup form", () => { + expect(firstRun.setup.status).toBe(200); + expect(firstRun.setup.body).toContain("Welcome to ConvertX!"); + expect(firstRun.setup.body).toContain('action="/register"'); + }); + + test("sends visitors of the login page to the setup", () => { + expect(firstRun.login.status).toBe(302); + expect(firstRun.login.location).toBe("/setup"); + }); + + test("sends visitors of the home page to the setup", () => { + expect(firstRun.home.status).toBe(302); + expect(firstRun.home.location).toBe("/setup"); + }); + + test("registers the first account although registration is closed", async () => { + expect(firstRun.register.status).toBe(302); + expect(firstRun.register.location).toBe("/"); + expect(firstRun.register.cookies.some((cookie) => cookie.startsWith("auth="))).toBe(true); + + const user = findUser(FIRST_ACCOUNT.email); + expect(user).not.toBeNull(); + // the password is stored as a hash, never in plain text + expect(user?.password).not.toBe(FIRST_ACCOUNT.password); + expect(await Bun.password.verify(FIRST_ACCOUNT.password, user?.password ?? "")).toBe(true); + }); + + test("closes the setup once the first account exists", () => { + expect(firstRun.setupAfterwards.status).toBe(302); + expect(firstRun.setupAfterwards.location).toBe("/login"); + }); + + test("closes the registration once the first account exists", () => { + expect(firstRun.registerAfterwards.status).toBe(302); + expect(firstRun.registerAfterwards.location).toBe("/login"); + expect(findUser(SECOND_ACCOUNT.email)).toBeNull(); + }); +}); diff --git a/tests/pages/healthcheck.test.ts b/tests/pages/healthcheck.test.ts new file mode 100644 index 00000000..807a17ce --- /dev/null +++ b/tests/pages/healthcheck.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test"; +import { request } from "./helpers/app"; + +describe("GET /healthcheck", () => { + test("reports ok without a session", async () => { + const response = await request("/healthcheck"); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ status: "ok" }); + }); + + test("ignores an invalid session", async () => { + const response = await request("/healthcheck", { cookies: { auth: "not-a-jwt" } }); + + expect(response.status).toBe(200); + expect(response.headers.getSetCookie()).toEqual([]); + }); +}); diff --git a/tests/pages/helpers/app.ts b/tests/pages/helpers/app.ts new file mode 100644 index 00000000..ae889483 --- /dev/null +++ b/tests/pages/helpers/app.ts @@ -0,0 +1,208 @@ +import { mock } from "bun:test"; +import { html } from "@elysiajs/html"; +import { Elysia } from "elysia"; +import * as realEnvModule from "../../../src/helpers/env"; + +if (!process.env.CONVERTX_TEST_ROOT) { + // Without tests/preload.ts the pages would import src/index.tsx (starting the server) + // and write into ./data next to real conversions. + throw new Error("Page tests must run via `bun test` so that tests/preload.ts is loaded."); +} + +// dynamic imports ensure that the guard above runs before any page module is loaded +const { default: db } = await import("../../../src/db/db"); +const { uploadsDir, outputDir } = await import("../../../src/index"); +const userPage = await import("../../../src/pages/user"); +const { root } = await import("../../../src/pages/root"); +const { upload } = await import("../../../src/pages/upload"); +const { history } = await import("../../../src/pages/history"); +const { convert } = await import("../../../src/pages/convert"); +const { download } = await import("../../../src/pages/download"); +const { deleteJob } = await import("../../../src/pages/deleteJob"); +const { results } = await import("../../../src/pages/results"); +const { deleteFile } = await import("../../../src/pages/deleteFile"); +const { listConverters } = await import("../../../src/pages/listConverters"); +const { chooseConverter } = await import("../../../src/pages/chooseConverter"); +const { healthcheck } = await import("../../../src/pages/healthcheck"); + +export { db, uploadsDir }; + +// Same composition as src/index.tsx, without the server, static files and job cleanup. +export const app = new Elysia({ prefix: realEnvModule.WEBROOT }) + .use(html()) + .use(userPage.user) + .use(root) + .use(upload) + .use(history) + .use(convert) + .use(download) + .use(deleteJob) + .use(results) + .use(deleteFile) + .use(listConverters) + .use(chooseConverter) + .use(healthcheck); + +// --- environment flags --------------------------------------------------------- +// src/helpers/env.ts reads process.env once at import, so flags are switched by +// re-mocking the module; importers see the new values through their live bindings. +type Env = typeof realEnvModule; +const realEnv: Env = { ...realEnvModule }; + +export function setEnv(overrides: Partial) { + mock.module("../../../src/helpers/env", () => ({ ...realEnv, ...overrides })); +} + +export function resetEnv() { + mock.module("../../../src/helpers/env", () => realEnv); +} + +// --- requests -------------------------------------------------------------------- +type RequestOptions = { + method?: string; + cookies?: Record; + headers?: Record; + json?: unknown; + form?: Record; + body?: RequestInit["body"]; +}; + +export function request(path: string, options: RequestOptions = {}) { + const headers: Record = { ...options.headers }; + let body = options.body; + + const cookie = Object.entries(options.cookies ?? {}) + .filter(([, value]) => value !== undefined) + .map(([name, value]) => `${name}=${value}`) + .join("; "); + if (cookie) headers.cookie = cookie; + + if (options.json !== undefined) { + headers["content-type"] = "application/json"; + body = JSON.stringify(options.json); + } else if (options.form) { + headers["content-type"] = "application/x-www-form-urlencoded"; + body = new URLSearchParams(options.form).toString(); + } + + return app.handle( + new Request(`http://localhost${path}`, { + method: options.method ?? (body === undefined ? "GET" : "POST"), + headers, + body, + }), + ); +} + +/** Returns the Set-Cookie header for one cookie, or undefined if it was not set. */ +export function setCookie(response: Response, name: string) { + return response.headers.getSetCookie().find((cookie) => cookie.startsWith(`${name}=`)); +} + +/** Extracts the value of a cookie set by the response. */ +export function setCookieValue(response: Response, name: string) { + return setCookie(response, name) + ?.split(";")[0] + ?.slice(name.length + 1); +} + +// --- users and jobs -------------------------------------------------------------- +let userCounter = 0; + +export type TestUser = { id: number; email: string; password: string; token: string }; + +export async function signToken(id: number | string) { + return app.decorator.jwt.sign({ id: String(id) }); +} + +/** Creates a user directly in the database, bypassing the registration rules. */ +export async function createUser(password = "correct horse battery staple"): Promise { + const email = `user${++userCounter}-${crypto.randomUUID()}@example.com`; + const { id } = db + .query("INSERT INTO users (email, password) VALUES (?, ?) RETURNING id") + .get(email, await Bun.password.hash(password)) as { id: number }; + + return { id, email, password, token: await signToken(id) }; +} + +type FileRow = { file_name: string; output_file_name: string; status: string }; + +export function createJob( + userId: number | string, + { numFiles = 0, status = "not started", files = [] as FileRow[] } = {}, +) { + const { id } = db + .query( + "INSERT INTO jobs (user_id, date_created, num_files, status) VALUES (?, ?, ?, ?) RETURNING id", + ) + .get(userId, new Date().toISOString(), numFiles, status) as { id: number }; + + for (const file of files) { + db.query( + "INSERT INTO file_names (job_id, file_name, output_file_name, status) VALUES (?, ?, ?, ?)", + ).run(id, file.file_name, file.output_file_name, file.status); + } + + return id; +} + +export function jobDirs(userId: number | string, jobId: number | string) { + return { + uploads: `${uploadsDir}${userId}/${jobId}/`, + output: `${outputDir}${userId}/${jobId}/`, + }; +} + +/** Polls until the condition holds, e.g. for conversions that run in the background. */ +export async function waitFor(condition: () => boolean, timeoutMs = 5000) { + const start = Date.now(); + while (!condition()) { + if (Date.now() - start > timeoutMs) { + throw new Error(`Condition not met within ${timeoutMs} ms`); + } + await Bun.sleep(10); + } +} + +// --- first run ------------------------------------------------------------------- +// FIRST_RUN is computed once when src/pages/user.tsx is loaded and only flips back when +// the first account is registered. Until then almost every page redirects to /setup. +// Walk through the setup once, like a fresh installation, and keep the responses for +// tests/pages/firstRun.test.ts; every other test then starts from a configured instance. +type Snapshot = { status: number; location: string | null; cookies: string[]; body: string }; + +async function snapshot(response: Promise): Promise { + const { status, headers } = await response; + return { + status, + location: headers.get("location"), + cookies: headers.getSetCookie(), + body: await (await response).text(), + }; +} + +export const FIRST_ACCOUNT = { email: "first@example.com", password: crypto.randomUUID() }; +export const SECOND_ACCOUNT = { email: "second@example.com", password: crypto.randomUUID() }; + +async function walkThroughFirstRun() { + // the first account must not depend on open registration + setEnv({ ACCOUNT_REGISTRATION: false }); + try { + return { + setup: await snapshot(request("/setup")), + login: await snapshot(request("/login")), + home: await snapshot(request("/")), + register: await snapshot(request("/register", { form: FIRST_ACCOUNT })), + setupAfterwards: await snapshot(request("/setup")), + registerAfterwards: await snapshot(request("/register", { form: SECOND_ACCOUNT })), + }; + } finally { + resetEnv(); + } +} + +export const firstRun = userPage.FIRST_RUN ? await walkThroughFirstRun() : undefined; + +if (userPage.FIRST_RUN) { + throw new Error("Registering the first account did not complete the first run"); +} diff --git a/tests/pages/history.test.ts b/tests/pages/history.test.ts new file mode 100644 index 00000000..7e04fdd3 --- /dev/null +++ b/tests/pages/history.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createJob, createUser, request, resetEnv, setEnv } from "./helpers/app"; + +afterEach(() => { + resetEnv(); +}); + +const file = (name: string, status = "Done") => ({ + file_name: `${name}.vcf`, + output_file_name: `${name}.csv`, + status, +}); + +async function historyOf(token: string) { + return (await request("/history", { cookies: { auth: token } })).text(); +} + +describe("GET /history", () => { + test("redirects to the login page without a session", async () => { + const response = await request("/history", { headers: { accept: "text/html" } }); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/login"); + }); + + test("redirects home when the history is hidden", async () => { + setEnv({ HIDE_HISTORY: true }); + const user = await createUser(); + + const response = await request("/history", { cookies: { auth: user.token } }); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/"); + }); + + test("lists the user's jobs, newest first", async () => { + const user = await createUser(); + const older = createJob(user.id, { numFiles: 1, files: [file("old")] }); + const newer = createJob(user.id, { numFiles: 1, files: [file("new")] }); + + const body = await historyOf(user.token); + + expect(body.indexOf(`id="job-row-${newer}"`)).toBeGreaterThan(-1); + expect(body.indexOf(`id="job-row-${older}"`)).toBeGreaterThan( + body.indexOf(`id="job-row-${newer}"`), + ); + expect(body).toContain(`href="/results/${newer}"`); + expect(body).toContain(`action="/delete/${newer}"`); + }); + + test("shows progress and file details of a job", async () => { + const user = await createUser(); + const job = createJob(user.id, { + numFiles: 3, + status: "pending", + files: [file("a"), file("b", "Failed, check logs")], + }); + + const body = await historyOf(user.token); + const row = body.slice( + body.indexOf(`id="job-row-${job}"`), + body.indexOf(`id="details-${job}"`), + ); + const details = body.slice(body.indexOf(`id="details-${job}"`)); + + expect(row).toContain("3"); + expect(row).toContain('2'); + expect(row).toContain("pending"); + expect(details).toContain('title="a.vcf"'); + expect(details).toContain('title="b.csv"'); + }); + + test("hides jobs without files", async () => { + const user = await createUser(); + const empty = createJob(user.id, { numFiles: 0 }); + + const body = await historyOf(user.token); + + expect(body).not.toContain(`id="job-row-${empty}"`); + }); + + test("does not show other users' jobs", async () => { + const user = await createUser(); + const other = await createUser(); + const foreign = createJob(other.id, { numFiles: 1, files: [file("foreign")] }); + + const body = await historyOf(user.token); + + expect(body).not.toContain(`id="job-row-${foreign}"`); + expect(body).not.toContain("foreign.vcf"); + }); + + test("escapes file names and statuses", async () => { + const user = await createUser(); + createJob(user.id, { + numFiles: 1, + status: "pending", + files: [ + { file_name: '">.vcf', output_file_name: "x.csv", status: "Done" }, + ], + }); + + const body = await historyOf(user.token); + + // neither the text nor the title attribute can be broken out of + expect(body).not.toContain('">