Merge remote-tracking branch 'origin/main' into feature/landlock

This commit is contained in:
Emrik Östling
2026-10-23 00:04:05 +02:00
62 changed files with 4116 additions and 1730 deletions
+1 -1
View File
@@ -1 +1 @@
1.4.2
1.4.4
+17
View File
@@ -74,6 +74,22 @@ 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: .
target: test
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
@@ -81,6 +97,7 @@ jobs:
DOCKER_BUILDKIT: 1
with:
context: .
target: release
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
annotations: ${{ steps.meta.outputs.annotations }}
+2 -2
View File
@@ -29,5 +29,5 @@ jobs:
- name: Install dependencies
run: bun install
- name: Run tests
run: bun test
- name: Run unit tests
run: bun run test
+21 -47
View File
@@ -1,49 +1,14 @@
FROM debian:testing-slim AS base
LABEL org.opencontainers.image.source="https://github.com/C4illin/ConvertX"
FROM oven/bun:1.4.4-slim AS base
WORKDIR /app
# install bun
RUN apt-get update && apt-get install -y \
ca-certificates \
curl \
unzip \
&& rm -rf /var/lib/apt/lists/*
# if architecture is arm64, use the arm64 version of bun
RUN ARCH=$(uname -m) && \
if [ "$ARCH" = "aarch64" ]; then \
curl -fsSL -o bun-linux-aarch64.zip https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-linux-aarch64.zip; \
else \
curl -fsSL -o bun-linux-x64-baseline.zip https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-linux-x64-baseline.zip; \
fi
RUN unzip -j bun-linux-*.zip -d /usr/local/bin && \
rm bun-linux-*.zip && \
chmod +x /usr/local/bin/bun
# install dependencies into temp directory
# this will cache them and speed up future builds
FROM base AS install
RUN mkdir -p /temp/dev
COPY package.json bun.lock /temp/dev/
RUN cd /temp/dev && bun install --frozen-lockfile
# install with --production (exclude devDependencies)
RUN mkdir -p /temp/prod
COPY package.json bun.lock /temp/prod/
RUN cd /temp/prod && bun install --frozen-lockfile --production
FROM base AS prerelease
WORKDIR /app
RUN apt-get update && apt-get install -y gcc libc6-dev --no-install-recommends && rm -rf /var/lib/apt/lists/*
COPY --from=install /temp/dev/node_modules node_modules
COPY package.json bun.lock /app/
RUN bun install --frozen-lockfile
COPY . .
# ENV NODE_ENV=production
RUN bun run build
RUN bun run build:bin
# copy production dependencies and source code into final image
FROM base AS release
FROM debian:testing-slim AS release
LABEL org.opencontainers.image.source="https://github.com/C4illin/ConvertX"
WORKDIR /app
# install additional dependencies
ENV PIPX_HOME=/opt/pipx
@@ -51,9 +16,12 @@ ENV PIPX_BIN_DIR=/usr/local/bin
RUN apt-get update && apt-get install -y \
assimp-utils \
ca-certificates \
calibre \
curl \
dasel \
dcraw \
djvulibre-bin \
dvisvgm \
ffmpeg \
fonts-liberation \
@@ -106,10 +74,9 @@ RUN ARCH=$(uname -m) && \
chmod +x /usr/local/bin/vtracer && \
rm /tmp/vtracer.tar.gz
COPY --from=install /temp/prod/node_modules node_modules
COPY --from=prerelease /app/public/ /app/public/
COPY --from=prerelease /app/dist /app/dist
COPY --from=prerelease /app/bin/landlock-runner /usr/local/bin/landlock-runner
COPY --from=base /app/public/ /app/public/
COPY --from=base /app/dist/convertx /app/convertx
COPY --from=base /app/bin/landlock-runner /usr/local/bin/landlock-runner
# COPY . .
RUN mkdir data
@@ -123,4 +90,11 @@ ENV QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox"
ENV SANDBOX_STRICT="true"
ENV NODE_ENV=production
ENTRYPOINT [ "/entrypoint.sh" ]
CMD [ "bun", "run", "dist/src/index.js" ]
CMD [ "/app/convertx" ]
# test stage that includes Bun for running tests if needed
FROM release AS test
COPY --from=base /usr/local/bin/bun /usr/local/bin/bun
# default target remains release
FROM release
+1 -2
View File
@@ -36,6 +36,7 @@ A self-hosted online file converter. Supports over a thousand different formats.
| [Calibre](https://calibre-ebook.com/) | E-books | 26 | 19 |
| [LibreOffice](https://www.libreoffice.org/) | Documents | 41 | 22 |
| [Dasel](https://github.com/TomWright/dasel) | Data Files | 5 | 4 |
| [DjVuLibre](https://djvu.sourceforge.net/) | Documents | 2 | 2 |
| [Pandoc](https://pandoc.org/) | Documents | 43 | 65 |
| [msgconvert](https://github.com/mvz/email-outlook-message-perl) | Outlook | 1 | 1 |
| VCF to CSV | Contacts | 1 | 1 |
@@ -162,8 +163,6 @@ Use [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/#summar
<img src="https://contrib.rocks/image?repo=C4illin/ConvertX" alt="Image with all contributors"/>
</a>
![Alt](https://repobeats.axiom.co/api/embed/dcdabd0564fcdcccbf5680c1bdc2efad54a3d4d9.svg "Repobeats analytics image")
## Star History
<a href="https://github.com/C4illin/ConvertX/stargazers">
+71 -69
View File
@@ -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.3",
"@types/node": "^24.19.0",
"eslint": "^10.11.0",
"eslint-plugin-better-tailwindcss": "^4.7.0",
@@ -48,6 +48,8 @@
"@cacheable/utils": ["@cacheable/utils@2.5.0", "", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA=="],
"@dual-bundle/import-meta-resolve": ["@dual-bundle/import-meta-resolve@4.2.1", "", {}, "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg=="],
"@elysiajs/html": ["@elysiajs/html@1.4.2", "", { "dependencies": { "@kitajs/html": "^4.1.0", "@kitajs/ts-html-plugin": "^4.0.1" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-Db7dmbkN7gptckMpU0/Fq9Qi3QuhQr/CH60A+8rs+RT+74NUC8sONs5nkfMm5oL+6kCUWCv19uUwOjBP7zsjYQ=="],
"@elysiajs/jwt": ["@elysiajs/jwt@1.4.2", "", { "dependencies": { "jose": "^6.0.11" }, "peerDependencies": { "elysia": ">= 1.4.27" } }, "sha512-gSPjF8KoL7cQIxL3kb+Ui9g5jpBdO4iUTe2+F52w66JVFct2K8cTLum8PEArxRZNg4PBrg100h6i1bJ2zfcqMg=="],
@@ -70,7 +72,7 @@
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
"@eslint/css-tree": ["@eslint/css-tree@4.1.1", "", { "dependencies": { "mdn-data": "2.35.0", "source-map-js": "^1.2.1" } }, "sha512-A+s89eP+yDAPkKjnIOfjlkGifg5J7GPn01nWx14UjBVWktfkxOU4975F9ZZ13xNxkGiblNcBaAqgKhnQrW6SWA=="],
"@eslint/css-tree": ["@eslint/css-tree@4.1.2", "", { "dependencies": { "mdn-data": "2.37.2", "source-map-js": "^1.2.1" } }, "sha512-rVcxLmwSkEL3aWS3vAgsTRrQHoYE1b0XcgDtKHDJ92XOkyUpcbS/g0tUJKhvCtlEhYzgKBWsgDd+Z/aPhd166w=="],
"@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="],
@@ -108,47 +110,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.154.0", "", { "os": "android", "cpu": "arm" }, "sha512-sDdEtDl7vKdCvf54j72RJbowE3jCmyRz/+oOB6IAwQxAHZ0eI+Dcj4hvVnm0b9Dy31plUkdmfds9C+/Zt895Tg=="],
"@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.154.0", "", { "os": "android", "cpu": "arm64" }, "sha512-UHrDPELzvWZoVkIDdSycj4bzcTMCJG3IbN++mSUgVBaUtNtWHjQ98U6Vz5bpF/1Ic3FYJyYhMr74huO5tVvpZw=="],
"@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.154.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mokYzajOSXxtSRDWCdHARGdntrKH+rZZ24Jzo8AtafyNzz0NARmpMm26Tc5B6qiGrr6UliWibHCOif1qRN2vaA=="],
"@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.154.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-WbtUBrclOVItWfLRSvs+Z/WLtXxJ2Ctv50PyuX894HzDN5/e0qzxVpTNPIVMzOerBhgKtFsSMcfxlYIAbvdVgg=="],
"@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.154.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YooHZSLawOe2fqm+lDdG8oAaQh2NYV/RMYTYzWRLy72stm2BgE9O/8Al6kFENjkmzyyBccDLLBirGxVtpzQfrw=="],
"@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.154.0", "", { "os": "linux", "cpu": "arm" }, "sha512-hKOwhZbrXdTwc4bGVfbLCXl9f2swl6zWdISocLQSNX9MgPmwHE3qGzOUkCrkuGpwdCVHPnkfOwsqTuYsrP5uNg=="],
"@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.154.0", "", { "os": "linux", "cpu": "arm" }, "sha512-tnjiooXEU013349O/yxKbF5Cusp1CfHWQ8kYR8tGeRzR/P4sOpQGQo21fsS71M0fMD6ESLq+lbvXFWi43PuKeQ=="],
"@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.154.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Cao2kj+8d7BT6XJU1gV9eVQjV+Cr+e8vQm3tddDRZiM2hHrMDxwy3j6t+ofaSl0idyuPPCZ4AlpmQoj8yOT1QA=="],
"@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.154.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-uiwA0V3q8llWA7/8r+a9BBjk4j5YMDmi6wfOsyQfGOJMQSLHEiZ3HI8ulea766ykUa9wJFWk9XWEl4uJrZLQqw=="],
"@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.154.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-LICfN0Xs4Zdko+bJy6eOAEQojDuIR26suF2JtOsMaXjgbCrpRSH7/NCvJhY13YvYBx12V6hnwrL8n0lk0rbNJQ=="],
"@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.154.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbbSYxLF8ssRbQ2qQPy+2EhGNwNH1bAoRdkk6cnWaKHqmCikpSIPtpXjFbCCtjTi0uojqOM3ghFQwQPbDQsU0Q=="],
"@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.154.0", "", { "os": "linux", "cpu": "none" }, "sha512-nSnRYYDLCcRVjSQzF3g6rKqCNYwo5o+aQgnn8BBXfe+ofCgMGlGqkfRyvXm6ptZvoxNkkWtLlqM8W7Xc3j6ZnQ=="],
"@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.154.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-BxBQoJKtzRQgZEKQIt6CDUm8RGKC5T8Qo4576yDa5/xgc10X3fBDItMbRabzLtbUBfTqKLl+8AVr/H6CF7W22g=="],
"@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.154.0", "", { "os": "linux", "cpu": "x64" }, "sha512-/whkust2vYy+GjRUVL5cWaJmEu6jkdFfo5mkxgckyeOX61eMwo4iBoiwzeJZeMWAdzb/XOtQyLApHhFht3sOWA=="],
"@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.154.0", "", { "os": "linux", "cpu": "x64" }, "sha512-EXURTyW9riw4B/kZnnE+58emAtw6+3VEszQfXUZkRqGaKIcmWDYkZtKB+xY/blHvuZ+Qy/fDclc31nmUCgtkSA=="],
"@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.154.0", "", { "os": "none", "cpu": "arm64" }, "sha512-ZRFooyH3qsR6vuR4jFGlG3XM36490SvvXtNaUahBK4zHpooMdTHNaOMtGGJI0EW38m5hi4rOco9kw7ZwCIp28w=="],
"@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.154.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-U0Vp+4lvwUa/PnOCJTZOjydj1rX2+VLHz4Y2vSFqlN8/7LTiURgD7z4uQ9yNv9O/NlU3mELBnPDlu7aaSYG1zQ=="],
"@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.154.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-Hc8QgsFY5eUUP25we8TISUruibG2cDt0H4IcPG9RKdabb6odkCzIqjV6Gk9beGWGuOwMVCr81H/Su1q363LCCg=="],
"@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.154.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hD7judkUMA/hZPoamvKADs6+an1smp8UTK0aMYRijkvi00K/1vl7vEtmyn2JyQ9S+vibtnfcNzxqTLGyJUZCRQ=="],
"@oxc-project/types": ["@oxc-project/types@0.150.0", "", {}, "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw=="],
"@oxc-project/types": ["@oxc-project/types@0.154.0", "", {}, "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA=="],
"@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA=="],
@@ -252,13 +254,13 @@
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.3", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "postcss": "^8.5.16", "tailwindcss": "4.3.3" } }, "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg=="],
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
"@tokenizer/inflate": ["@tokenizer/inflate@0.5.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.2" } }, "sha512-56U2O6fDLFtqokwWh597IdeUgtM7j+TT9tb1soybhgA6cy163gAcVmoQr8H4Ud/CkgCkViwcuLyqtn6wYqw59Q=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="],
"@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
"@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
@@ -266,39 +268,39 @@
"@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.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.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.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.71.2", "@typescript-eslint/type-utils": "8.71.2", "@typescript-eslint/utils": "8.71.2", "@typescript-eslint/visitor-keys": "8.71.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.71.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ=="],
"@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.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.71.2", "@typescript-eslint/types": "8.71.2", "@typescript-eslint/typescript-estree": "8.71.2", "@typescript-eslint/visitor-keys": "8.71.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw=="],
"@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.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.71.2", "@typescript-eslint/types": "^8.71.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw=="],
"@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.2", "", { "dependencies": { "@typescript-eslint/types": "8.71.2", "@typescript-eslint/visitor-keys": "8.71.2" } }, "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg=="],
"@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.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg=="],
"@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.2", "", { "dependencies": { "@typescript-eslint/types": "8.71.2", "@typescript-eslint/typescript-estree": "8.71.2", "@typescript-eslint/utils": "8.71.2", "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-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.70.1", "", {}, "sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.71.2", "", {}, "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw=="],
"@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.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.71.2", "@typescript-eslint/tsconfig-utils": "8.71.2", "@typescript-eslint/types": "8.71.2", "@typescript-eslint/visitor-keys": "8.71.2", "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-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA=="],
"@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.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.71.2", "@typescript-eslint/types": "8.71.2", "@typescript-eslint/typescript-estree": "8.71.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw=="],
"@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.2", "", { "dependencies": { "@typescript-eslint/types": "8.71.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg=="],
"@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=="],
"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=="],
@@ -308,7 +310,7 @@
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
"bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="],
"cacheable": ["cacheable@2.5.0", "", { "dependencies": { "@cacheable/memory": "^2.2.0", "@cacheable/utils": "^2.5.0", "hookified": "^1.15.0", "keyv": "^5.6.0", "qified": "^0.10.1" } }, "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g=="],
@@ -340,15 +342,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.13.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-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg=="],
"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.9.1", "", { "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.12.3", "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-WOuUOlc27Ib3tcYNt6qhRpvQUie5Y/pNTkwO779QGdfF1tu0k51avWtmvfPv++7/1oVjntoFHcUK4+5a6SPZvA=="],
"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=="],
@@ -380,7 +382,7 @@
"file-entry-cache": ["file-entry-cache@11.1.5", "", { "dependencies": { "flat-cache": "^6.1.23" } }, "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q=="],
"file-type": ["file-type@22.1.1", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0" } }, "sha512-sEB0oStmeLGtpmSAD88RDNuLsvhv4iC3u0lE25CUvfCULvj49xoQ3l3K525Vn+ZzsZ6DOLajQCV6RmhwwhqiJg=="],
"file-type": ["file-type@22.2.0", "", { "dependencies": { "@tokenizer/inflate": "^0.5.1", "strtok3": "^10.5.0", "token-types": "^6.1.2", "uint8array-extras": "^1.6.0" } }, "sha512-XmEVJsRpQMVYC+sWFW+FKAVo2P7/lPctDdWH4UQ1r6oxhdrTobnowdM3z+ZJ93a4J8D3yDpxX0bEvRuFev0vUQ=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -390,7 +392,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=="],
@@ -400,7 +402,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 +440,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.42.0", "", { "dependencies": { "formatly": "^0.11.4", "get-tsconfig": "4.14.3", "jiti": "^2.7.0", "oxc-parser": "^0.154.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": "^6.0.0", "yaml": "^2.9.1", "zod": "^4.6.5" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-lNypqHwi38RJQG8pVuItqzCDPMOfPbBExyFh8aM4yMJRHVU7t5OQAtEK4tDkaB3rLHetOcVMhRFAgv5Vn2CM0A=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
@@ -470,7 +472,7 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mdn-data": ["mdn-data@2.35.0", "", {}, "sha512-k+1+dEIm2z/BEfdvYzT/fIyAGeDBo/1AJNJI4ilFXJRYoxP/3Ds+GP8shAOIq8jYXY9N5nGB3Ag104NsagmLAA=="],
"mdn-data": ["mdn-data@2.37.2", "", {}, "sha512-rz9YOBGItdSbe7HcSArUbhiAv4SB/4DKRYg96CSFS2CcIYMbUvBtZlzA1NCbk2AdgbmYwMqwNblLpR441lLeiQ=="],
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
@@ -490,7 +492,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 +506,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.154.0", "", { "dependencies": { "@oxc-project/types": "^0.154.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.154.0", "@oxc-parser/binding-android-arm64": "0.154.0", "@oxc-parser/binding-darwin-arm64": "0.154.0", "@oxc-parser/binding-darwin-x64": "0.154.0", "@oxc-parser/binding-freebsd-x64": "0.154.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.154.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.154.0", "@oxc-parser/binding-linux-arm64-gnu": "0.154.0", "@oxc-parser/binding-linux-arm64-musl": "0.154.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.154.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.154.0", "@oxc-parser/binding-linux-riscv64-musl": "0.154.0", "@oxc-parser/binding-linux-s390x-gnu": "0.154.0", "@oxc-parser/binding-linux-x64-gnu": "0.154.0", "@oxc-parser/binding-linux-x64-musl": "0.154.0", "@oxc-parser/binding-openharmony-arm64": "0.154.0", "@oxc-parser/binding-win32-arm64-msvc": "0.154.0", "@oxc-parser/binding-win32-ia32-msvc": "0.154.0", "@oxc-parser/binding-win32-x64-msvc": "0.154.0" } }, "sha512-Gr/6fkjZwi937c4Tsz8y0iO4U0FLPkZI6JSVucLAIvQyqh379px7RS8ORIc0HOORbFjXZ6zyqWLl53qxhSeofw=="],
"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 +514,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 +526,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.31", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg=="],
"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=="],
@@ -536,7 +538,7 @@
"qified": ["qified@0.10.1", "", { "dependencies": { "hookified": "^2.1.1" } }, "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA=="],
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
"react": ["react@19.3.1", "", {}, "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw=="],
"read-package-json-fast": ["read-package-json-fast@6.0.0", "", { "dependencies": { "json-parse-even-better-errors": "^6.0.0", "npm-normalize-package-bin": "^6.0.0" } }, "sha512-PNaGjoCnw9DBA2Kl8D+8po957z778q/HOPuY2u3Bkw/JO3eC8MDx7jn/PgMtSgpcBbs+6UOjDbwReGpXmRvs0g=="],
@@ -550,11 +552,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=="],
"smol-toml": ["smol-toml@1.9.1", "", {}, "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ=="],
"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=="],
@@ -564,11 +566,11 @@
"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.6.2", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-BkdixFFUt7r7LsX6aGNPVyYMLjW+M1USLlKPNbZsrZEmLFh5tGqhdfckKo6xgsZIJqdDD0CZGkeCJJwNWQIKQA=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"synckit": ["synckit@0.11.13", "", { "dependencies": { "@pkgr/core": "^0.3.6" } }, "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg=="],
"synckit": ["synckit@0.12.3", "", { "dependencies": { "@dual-bundle/import-meta-resolve": "^4.2.1", "@pkgr/core": "^0.3.6" } }, "sha512-U+/AXT6LWsxnj2vz9HeUuPWBNgdWxjyIEjEPdbJARC9txmgPzo2VEdqnFo4hHaNyMPtycyVJrSaQYeQv/PoyyA=="],
"tailwind-csstree": ["tailwind-csstree@0.3.3", "", { "peerDependencies": { "@eslint/css": ">=1.0.0" }, "optionalPeers": ["@eslint/css"] }, "sha512-je9J5UYRsTJqAjYrIBMMlge8T/rreRd44pJxgG5Zx/zeo4kAC/liUKqzztRZrGlYRJLvIf2Cb1DVJMTXSzEShA=="],
@@ -600,11 +602,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.2", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.71.2", "@typescript-eslint/parser": "8.71.2", "@typescript-eslint/typescript-estree": "8.71.2", "@typescript-eslint/utils": "8.71.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw=="],
"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=="],
"unbash": ["unbash@6.0.0", "", {}, "sha512-vD5CvvFLVQDhXdNyNEPPa/tHPCK3HLySddOine+OZLDYIH1cMImGNgnw9+NkizToaPsFsQlwiyPGMiK+uwtYVw=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
@@ -634,25 +636,27 @@
"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.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="],
"@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=="],
"@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=="],
"@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.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
"bun-types/undici-types": ["undici-types@8.12.2", "", {}, "sha512-VoMeD2O0KdHhcn+oPqOrpivN+7aDOklVSwOLD7JNjbzV1CboAFek9IFOXnreoufTUtAsCsOK0iETiERPNR/fDA=="],
"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=="],
@@ -670,8 +674,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=="],
+1
View File
@@ -1,4 +1,5 @@
[test]
preload = ["./tests/preload.ts"]
coverage = true
coveragePathIgnorePatterns = [
"tests/converters/helpers/commonTests.ts"
+1
View File
@@ -2,6 +2,7 @@
services:
convertx:
pull_policy: build
build:
context: .
# dockerfile: Debian.Dockerfile
+2 -2
View File
@@ -12,7 +12,7 @@ if [ -z "$PUID" ] || [ "$PUID" -eq 0 ]; then
umask "$UMASK"
fi
if [ $# -eq 0 ]; then
set -- bun run dist/src/index.js
set -- /app/convertx
fi
exec "$@"
fi
@@ -45,7 +45,7 @@ fi
export HOME=/home/convertx
if [ $# -eq 0 ]; then
set -- bun run dist/src/index.js
set -- /app/convertx
fi
exec gosu convertx "$@"
+1 -1
View File
@@ -1,5 +1,5 @@
[tools]
bun = "1.4.2"
bun = "1.4.4"
[env]
JWT_SECRET = "aLongAndSecretStringUsedToSignTheJSONWebToken1234"
+9 -4
View File
@@ -1,21 +1,26 @@
{
"name": "convertx-frontend",
"version": "0.19.0",
"version": "0.20.0",
"scripts": {
"dev": "bun run --watch src/index.tsx",
"hot": "bun run --hot src/index.tsx",
"format": "npm-run-all 'format:*'",
"format:eslint": "eslint --fix .",
"format:prettier": "prettier --write .",
"build:css": "bun x @tailwindcss/cli -i ./src/main.css -o ./public/generated.css",
"build:js": "tsc",
"build:runner": "mkdir -p bin && gcc -O2 -Wall -Wextra src/native/landlock-runner.c -o bin/landlock-runner || true",
"build": "bun x @tailwindcss/cli -i ./src/main.css -o ./public/generated.css && bun run build:runner && bun run build:js",
"build:bin": "bun run build:css && bun run build:runner && bun build --compile --minify --sourcemap --bytecode --format=esm --define 'process.env.NODE_ENV=\"production\"' src/index.tsx --outfile dist/convertx",
"build:docker": "docker compose build",
"build": "bun run build: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 build --target test -t convertx:test . && docker run --rm -t -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",
@@ -36,7 +41,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.3",
"@types/node": "^24.19.0",
"eslint": "^10.11.0",
"eslint-plugin-better-tailwindcss": "^4.7.0",
+1 -2
View File
@@ -4,6 +4,5 @@
"lockFileMaintenance": {
"enabled": true,
"automerge": true
},
"ignoreDeps": ["bun-types", "@types/bun", "bun", "Bun"]
}
}
+39
View File
@@ -0,0 +1,39 @@
import { execFile as execFileOriginal } from "node:child_process";
import type { ExecFileFn } from "./types";
export const properties = {
from: {
document: ["djvu", "djv"],
},
to: {
document: ["pdf", "tiff"],
},
};
export function convert(
filePath: string,
_fileType: string,
convertTo: string,
targetPath: string,
_options?: unknown,
execFile: ExecFileFn = execFileOriginal,
): Promise<string> {
return new Promise((resolve, reject) => {
execFile("ddjvu", [`-format=${convertTo}`, filePath, targetPath], (error, stdout, stderr) => {
if (error) {
reject(`error: ${error}${stderr ? `\nstderr: ${stderr}` : ""}`);
return;
}
if (stdout) {
console.log(`stdout: ${stdout}`);
}
if (stderr) {
console.error(`stderr: ${stderr}`);
}
resolve("Done");
});
});
}
+18
View File
@@ -710,6 +710,24 @@ export async function convert(
let extraArgs: string[] = [];
let message = "Done";
const audioFormatsWithCover = ["m4a", "m4b", "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 = [
+5
View File
@@ -9,6 +9,7 @@ import { createSandboxedExec } from "../helpers/sandbox";
import { convert as convertassimp, properties as propertiesassimp } from "./assimp";
import { convert as convertCalibre, properties as propertiesCalibre } from "./calibre";
import { convert as convertDasel, properties as propertiesDasel } from "./dasel";
import { convert as convertDjvu, properties as propertiesDjvu } from "./djvu";
import { convert as convertDvisvgm, properties as propertiesDvisvgm } from "./dvisvgm";
import { convert as convertFFmpeg, properties as propertiesFFmpeg } from "./ffmpeg";
import {
@@ -95,6 +96,10 @@ const properties: Record<
properties: propertiesDasel,
converter: convertDasel,
},
djvu: {
properties: propertiesDjvu,
converter: convertDjvu,
},
libreoffice: {
properties: propertiesLibreOffice,
converter: convertLibreOffice,
+20 -26
View File
@@ -23,7 +23,7 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(stdout.split("\n")[0]);
console.log(`pandoc v${stdout.match(/pandoc ([\d.]+)/)?.[1] || "unknown"}`);
}
});
@@ -34,7 +34,7 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(stdout.split("\n")[0]);
console.log(`FFmpeg v${stdout.match(/version ([\w+.-]+)/)?.[1] || "unknown"}`);
}
});
@@ -45,7 +45,7 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(stdout.split("\n")[0]);
console.log(`Vips v${stdout.match(/vips-([\d.]+)/)?.[1] || "unknown"}`);
}
});
@@ -56,7 +56,7 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(stdout.split("\n")[0]?.replace("Version: ", ""));
console.log(`ImageMagick v${stdout.match(/ImageMagick ([\d.-]+)/)?.[1] || "unknown"}`);
}
});
@@ -67,7 +67,7 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(stdout.split("\n")[0]);
console.log(`GraphicsMagick v${stdout.match(/GraphicsMagick ([\d.]+)/)?.[1] || "unknown"}`);
}
});
@@ -78,7 +78,7 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(stdout.split("\n")[0]);
console.log(`Inkscape v${stdout.match(/Inkscape ([\d.]+)/)?.[1] || "unknown"}`);
}
});
@@ -89,7 +89,7 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(stdout.split("\n")[0]);
console.log(`djxl v${stdout.match(/(?:v)?([\d.]+)/)?.[1] || "unknown"}`);
}
});
@@ -100,7 +100,8 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(`dasel ${stdout.split("\n")[0]}`);
const ver = stdout.replace("dasel", "").trim().split(/\s+/)[0] || "unknown";
console.log(`dasel ${ver}`);
}
});
@@ -111,7 +112,7 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(stdout.split("\n")[0]);
console.log(`XeTeX v${stdout.match(/XeTeX ([\d.-]+)/)?.[1] || "unknown"}`);
}
});
@@ -122,7 +123,7 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(`resvg v${stdout.split("\n")[0]}`);
console.log(`resvg v${(stdout.split("\n")[0] ?? "").trim().replace(/^v/, "")}`);
}
});
@@ -133,8 +134,8 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
const firstLines = stdout.split("\n");
console.log(`assimp ${firstLines[5] || firstLines[0] || ""}`);
const match = stdout.match(/Version ([\d.]+)/i);
console.log(`assimp v${match?.[1] || "unknown"}`);
}
});
@@ -145,7 +146,7 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(stdout.split("\n")[0]);
console.log(`ebook-convert v${stdout.match(/calibre ([\d.]+)/)?.[1] || "unknown"}`);
}
});
@@ -167,7 +168,8 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(stdout.split("\n")[0]);
const ver = stdout.match(/potrace ([\d.]+)/)?.[1]?.replace(/\.$/, "") || "unknown";
console.log(`potrace v${ver}`);
}
});
@@ -178,7 +180,7 @@ if (process.env.NODE_ENV === "production") {
}
if (stdout) {
console.log(stdout.split("\n")[0]);
console.log(`LibreOffice v${stdout.match(/LibreOffice ([\d.]+)/)?.[1] || "unknown"}`);
}
});
@@ -204,18 +206,10 @@ 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}`);
}
});
exec("bun -v", (error, stdout) => {
if (error) {
console.error("Bun is not installed. wait what");
return;
}
if (stdout) {
console.log(`Bun v${stdout.split("\n")[0]}`);
}
});
console.log(`Bun v${Bun.version}`);
}
+26 -2
View File
@@ -4,7 +4,7 @@ import { Elysia } from "elysia";
import { rmSync } from "node:fs";
import db from "./db/db";
import { Jobs } from "./db/types";
import { AUTO_DELETE_EVERY_N_HOURS, outputDir, uploadsDir, WEBROOT } from "./helpers/env";
import { AUTO_DELETE_EVERY_N_HOURS, WEBROOT } from "./helpers/env";
import "./helpers/printVersions";
import { getLandlockRunnerPath, isSandboxAvailable } from "./helpers/sandbox";
import { chooseConverter } from "./pages/chooseConverter";
@@ -20,6 +20,9 @@ import { root } from "./pages/root";
import { upload } from "./pages/upload";
import { user } from "./pages/user";
export const uploadsDir = "./data/uploads/";
export const outputDir = "./data/output/";
// Fix for Elysia issue with Bun, (see https://github.com/oven-sh/bun/issues/12161)
process.getBuiltinModule = require;
@@ -79,6 +82,7 @@ if (isSandboxAvailable()) {
console.log("ℹ️ Landlock sandbox: NOT AVAILABLE (running unsandboxed)");
}
let clearJobsTimer: ReturnType<typeof setTimeout> | undefined;
const clearJobs = () => {
const jobs = db
.query("SELECT * FROM jobs WHERE date_created < ?")
@@ -100,9 +104,29 @@ const clearJobs = () => {
db.query("DELETE FROM jobs WHERE id = ?").run(job.id);
}
setTimeout(clearJobs, AUTO_DELETE_EVERY_N_HOURS * 60 * 60 * 1000);
clearJobsTimer = setTimeout(clearJobs, AUTO_DELETE_EVERY_N_HOURS * 60 * 60 * 1000);
};
if (AUTO_DELETE_EVERY_N_HOURS > 0) {
clearJobs();
}
let isShuttingDown = false;
const handleShutdown = async () => {
if (isShuttingDown) return;
isShuttingDown = true;
if (clearJobsTimer) {
clearTimeout(clearJobsTimer);
}
try {
await app.stop(true);
db.close();
process.exit(0);
} catch (error) {
console.error("Error during graceful shutdown:", error);
process.exit(1);
}
};
process.on("SIGINT", handleShutdown);
process.on("SIGTERM", handleShutdown);
+154 -137
View File
@@ -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 (
<div
class="rounded-sm border border-red-500/40 bg-red-500/10 px-3 py-2 text-red-300"
role="alert"
>
<p safe>{message}</p>
</div>
);
}
function LoginPage({ error, email = "" }: { error?: string; email?: string }) {
return (
<BaseHtml webroot={WEBROOT} title="ConvertX | Login">
<>
<Header
webroot={WEBROOT}
branding={BRANDING}
accountRegistration={ACCOUNT_REGISTRATION}
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
hideHistory={HIDE_HISTORY}
/>
<main
class={`
w-full flex-1 px-2
sm:px-4
`}
>
<article class="article">
<form method="post" class="flex flex-col gap-4">
{error ? <ErrorBanner message={error} /> : null}
<fieldset class="mb-4 flex flex-col gap-4">
<label class="flex flex-col gap-1">
Email
<input
type="email"
name="email"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Email"
autocomplete="email"
value={email}
autofocus
required
/>
</label>
<label class="flex flex-col gap-1">
Password
<input
type="password"
name="password"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Password"
autocomplete="current-password"
required
/>
</label>
</fieldset>
<div class="flex flex-row gap-4">
{ACCOUNT_REGISTRATION ? (
<a
href={`${WEBROOT}/register`}
role="button"
class="w-full btn-secondary text-center"
>
Register
</a>
) : null}
<input type="submit" value="Login" class="w-full btn-primary" />
</div>
</form>
</article>
</main>
</>
</BaseHtml>
);
}
function AccountPage({ email, error }: { email: string; error?: string }) {
return (
<BaseHtml webroot={WEBROOT} title="ConvertX | Account">
<>
<Header
webroot={WEBROOT}
branding={BRANDING}
accountRegistration={ACCOUNT_REGISTRATION}
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
hideHistory={HIDE_HISTORY}
loggedIn
/>
<main
class={`
w-full flex-1 px-2
sm:px-4
`}
>
<article class="article">
<form method="post" class="flex flex-col gap-4">
{error ? <ErrorBanner message={error} /> : null}
<fieldset class="mb-4 flex flex-col gap-4">
<label class="flex flex-col gap-1">
Email
<input
type="email"
name="email"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Email"
autocomplete="email"
value={email}
required
/>
</label>
<label class="flex flex-col gap-1">
Password (leave blank for unchanged)
<input
type="password"
name="newPassword"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Password"
autocomplete="new-password"
/>
</label>
<label class="flex flex-col gap-1">
Current Password
<input
type="password"
name="password"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Password"
autocomplete="current-password"
required
/>
</label>
</fieldset>
<div role="group">
<input type="submit" value="Update" class="w-full btn-primary" />
</div>
</form>
</article>
</main>
</>
</BaseHtml>
);
}
export const user = new Elysia()
.use(userService)
.get("/setup", ({ redirect }) => {
@@ -208,67 +352,7 @@ export const user = new Elysia()
auth.remove();
}
return (
<BaseHtml webroot={WEBROOT} title="ConvertX | Login">
<>
<Header
webroot={WEBROOT}
branding={BRANDING}
accountRegistration={ACCOUNT_REGISTRATION}
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
hideHistory={HIDE_HISTORY}
/>
<main
class={`
w-full flex-1 px-2
sm:px-4
`}
>
<article class="article">
<form method="post" class="flex flex-col gap-4">
<fieldset class="mb-4 flex flex-col gap-4">
<label class="flex flex-col gap-1">
Email
<input
type="email"
name="email"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Email"
autocomplete="email"
autofocus
required
/>
</label>
<label class="flex flex-col gap-1">
Password
<input
type="password"
name="password"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Password"
autocomplete="current-password"
required
/>
</label>
</fieldset>
<div class="flex flex-row gap-4">
{ACCOUNT_REGISTRATION ? (
<a
href={`${WEBROOT}/register`}
role="button"
class="w-full btn-secondary text-center"
>
Register
</a>
) : null}
<input type="submit" value="Login" class="w-full btn-primary" />
</div>
</form>
</article>
</main>
</>
</BaseHtml>
);
return <LoginPage />;
},
{ body: "signIn", cookie: "optionalSession" },
)
@@ -279,18 +363,14 @@ export const user = new Elysia()
if (!existingUser) {
set.status = 403;
return {
message: "Invalid credentials.",
};
return <LoginPage error="Invalid credentials." email={body.email} />;
}
const validPassword = await Bun.password.verify(body.password, existingUser.password);
if (!validPassword) {
set.status = 403;
return {
message: "Invalid credentials.",
};
return <LoginPage error="Invalid credentials." email={body.email} />;
}
const accessToken = await jwt.sign({
@@ -344,69 +424,7 @@ export const user = new Elysia()
return redirect(`${WEBROOT}/`, 302);
}
return (
<BaseHtml webroot={WEBROOT} title="ConvertX | Account">
<>
<Header
webroot={WEBROOT}
branding={BRANDING}
accountRegistration={ACCOUNT_REGISTRATION}
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
hideHistory={HIDE_HISTORY}
loggedIn
/>
<main
class={`
w-full flex-1 px-2
sm:px-4
`}
>
<article class="article">
<form method="post" class="flex flex-col gap-4">
<fieldset class="mb-4 flex flex-col gap-4">
<label class="flex flex-col gap-1">
Email
<input
type="email"
name="email"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Email"
autocomplete="email"
value={userData.email}
required
/>
</label>
<label class="flex flex-col gap-1">
Password (leave blank for unchanged)
<input
type="password"
name="newPassword"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Password"
autocomplete="new-password"
/>
</label>
<label class="flex flex-col gap-1">
Current Password
<input
type="password"
name="password"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Password"
autocomplete="current-password"
required
/>
</label>
</fieldset>
<div role="group">
<input type="submit" value="Update" class="w-full btn-primary" />
</div>
</form>
</article>
</main>
</>
</BaseHtml>
);
return <AccountPage email={userData.email} />;
},
{
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 <AccountPage email={emailValue} error="Invalid credentials." />;
}
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 <AccountPage email={emailValue} error="Email already in use." />;
}
fields.push("email");
values.push(body.email);
+22 -2
View File
@@ -1,7 +1,27 @@
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).toEqual([
{ cmd: "assimp", args: ["export", "in/scene.obj", "out/scene.glb", "-fglb2"] },
]);
});
+34 -2
View File
@@ -1,7 +1,39 @@
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([
// the extension is harmless, so only the file type triggers the rejection
["recipe file type", "in/news.txt", "recipe"],
["downloaded_recipe file type", "in/news.txt", "downloaded_recipe"],
["upper-case file type", "in/news.txt", "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);
});
+74 -30
View File
@@ -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<typeof captureConsole>;
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<ExecFileFn> = ["", [], () => {}];
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<ExecFileFn>;
return { stdin: { end: () => (stdinEnded = true) } } as unknown as ReturnType<ExecFileFn>;
};
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/);
});
});
+36
View File
@@ -0,0 +1,36 @@
import type { ExecFileException } from "node:child_process";
import { expect, test } from "bun:test";
import { convert, properties } from "../../src/converters/djvu";
import type { ExecFileFn } from "../../src/converters/types";
import { runCommonTests } from "./helpers/commonTests";
runCommonTests(convert);
test("advertises DjVu to PDF and TIFF conversions", () => {
expect(properties).toEqual({
from: { document: ["djvu", "djv"] },
to: { document: ["pdf", "tiff"] },
});
});
for (const format of ["pdf", "tiff"]) {
test(`calls ddjvu with the ${format} output format`, async () => {
let command = "";
let args: string[] = [];
const mockExecFile: ExecFileFn = (
cmd: string,
commandArgs: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
command = cmd;
args = commandArgs;
callback(null, "", "");
};
await expect(
convert("input.djvu", "djvu", format, `output.${format}`, undefined, mockExecFile),
).resolves.toBe("Done");
expect(command).toBe("ddjvu");
expect(args).toEqual([`-format=${format}`, "input.djvu", `output.${format}`]);
});
}
+26 -82
View File
@@ -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}`],
},
]);
});
+138 -106
View File
@@ -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,185 @@ 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<typeof captureConsole>;
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.each(["m4a", "m4b", "mp3", "flac", "ogg"])(
"keeps embedded cover art for %s output",
async (ext) => {
await convert("in.mp3", "mp3", ext, `out.${ext}`, undefined, mockExecFile);
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
expect(calls[0]).toEqual([
"-i",
"in.mp3",
"-map",
"0:a",
"-map",
"0:v:disp:attached_pic?",
"-c:v",
"copy",
"-disposition:v",
"attached_pic",
`out.${ext}`,
]);
},
);
await convert("in.mkv", "mkv", "h264.mp4", "out.mp4", undefined, mockExecFile);
test.each(["wav", "aac"])("drops video streams for %s output", async (ext) => {
await convert("in.mp3", "mp3", ext, `out.${ext}`, undefined, mockExecFile);
console.log = originalConsoleLog;
expect(calls[0]).toEqual(expect.arrayContaining(["-c:v", "libx264"]));
expect(loggedMessage).toBe("stdout: Fake stdout");
expect(calls[0]).toEqual(["-i", "in.mp3", "-vn", `out.${ext}`]);
});
test("uses libx265 for h265.mp4", async () => {
const originalConsoleLog = console.log;
test("places the cover art args after FFMPEG_OUTPUT_ARGS", async () => {
process.env.FFMPEG_OUTPUT_ARGS = "-b:a 192k";
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
await convert("in.wav", "wav", "mp3", "out.mp3", undefined, mockExecFile);
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");
expect(calls[0]?.slice(0, 5)).toEqual(["-i", "in.wav", "-b:a", "192k", "-map"]);
});
test("uses libx266 for h266.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", "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 +214,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 () => {
+7 -22
View File
@@ -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"] },
]);
});
+5
View File
@@ -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);
});
}
+108 -100
View File
@@ -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();
}
}
+75 -170
View File
@@ -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}`],
},
]);
});
+9 -2
View File
@@ -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"] }]);
});
+9 -2
View File
@@ -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"] }]);
});
+19 -70
View File
@@ -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("");
});
+86 -24
View File
@@ -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 source 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"]);
});
+364 -351
View File
@@ -1,393 +1,406 @@
// 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 { 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;
// Isolated test database: avoids mutation of ./data/mydb.sqlite
const dbPath = process.env.DB_PATH ?? join(testDbDir, "test.sqlite");
const dbDir = dirname(resolve(dbPath));
if (!existsSync(dbDir)) {
mkdirSync(dbDir, { recursive: true });
if (!process.env.CONVERTX_TEST_ROOT) {
// Without tests/preload.ts the db module would open ./data/mydb.sqlite and these tests
// would write their rows into it.
throw new Error("Converter tests must run via `bun test` so that tests/preload.ts is loaded.");
}
const testDb = new Database(dbPath, { create: true });
// dynamic imports ensure that the guard above runs before the db module is loaded
const { default: db } = await import("../../src/db/db");
const { chunks, getAllInputs, getAllTargets, getPossibleTargets, handleConvert, mainConverter } =
await import("../../src/converters/main");
const testRoot = mkdtempSync(join(tmpdir(), "convertx-main-test-"));
// Every row written by these tests uses this prefix, so cleanup never touches other rows.
const JOB_PREFIX = "main-test-";
type FileRow = { file_name: string; output_file_name: string; status: string };
function jobCookie(name?: string): Cookie<string | undefined> {
return { value: name === undefined ? undefined : `${JOB_PREFIX}${name}` } as Cookie<
string | undefined
>;
}
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[];
}
const vcard = (fullName: string) => `BEGIN:VCARD\nFN:${fullName}\nEND:VCARD\n`;
let caseCounter = 0;
let uploadsDir: string;
let outputDir: string;
let output: ReturnType<typeof captureConsole>;
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<string | undefined> {
return { value } as Cookie<string | undefined>;
}
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",
"djvu",
"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 () => {
// without a job id there is no job_id to look for, so look for the file name instead
const fileName = `${JOB_PREFIX}no-job.vcf`;
await writeFile(`${uploadsDir}${fileName}`, vcard("No Job"));
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([fileName], 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 = `<!DOCTYPE html>
<html>
<head><title>Test</title></head>
<body>Test Content</body>
</html>`;
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(db.query("SELECT * FROM file_names WHERE file_name = ?").all(fileName)).toEqual([]);
expect(await readFile(`${outputDir}${JOB_PREFIX}no-job.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",
]);
});
});
+41 -2
View File
@@ -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<typeof captureConsole>;
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([]);
});
+76 -47
View File
@@ -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<typeof captureConsole>;
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([]);
});
+31 -43
View File
@@ -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<ExecFileFn> = ["", [], () => {}];
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,40 @@ 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<ExecFileFn> = ["", [], () => {}];
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 reject if execFile returns an error", async () => {
mockExecFile = (cmd, args, callback) => callback(new Error("fail"), "", "");
await expect(
convert("input.md", "markdown", "html", "output.html", undefined, mockExecFile),
).rejects.toMatch(/error: Error: fail/);
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);
});
});
+28 -50
View File
@@ -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<typeof captureConsole>;
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"]);
});
+14 -2
View File
@@ -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],
},
]);
});
+9 -2
View File
@@ -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"] }]);
});
+153 -3
View File
@@ -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/);
});
});
+12 -53
View File
@@ -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"] },
]);
});
+108 -33
View File
@@ -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<typeof captureConsole>;
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<ExecFileFn> = ["", [], () => {}];
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<ExecFileFn> = ["", [], () => {}];
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([]);
});
});
+22 -2
View File
@@ -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");
});
+10 -7
View File
@@ -1,11 +1,15 @@
import { Database } from "bun:sqlite";
import { afterAll, afterEach, beforeEach, expect, test } from "bun:test";
import { existsSync, mkdirSync, unlinkSync } from "node:fs";
import { existsSync, unlinkSync } from "node:fs";
import { join } from "node:path";
// set environment variable to ensure the test database is used instead of production data
process.env.DB_PATH = "./data/test-isolated.sqlite";
const testRoot = process.env.CONVERTX_TEST_ROOT;
if (!testRoot) {
// Without tests/preload.ts the db module would open and migrate ./data/mydb.sqlite.
throw new Error("Database tests must run via `bun test` so that tests/preload.ts is loaded.");
}
// dynamic import ensures that db.ts is loaded after the env is set
// the dynamic import ensures that the guard above runs before the db module is loaded
let initializeDatabase: (db: Database) => void;
let defaultDb: Database | undefined;
await import("../../src/db/db").then((mod) => {
@@ -61,8 +65,7 @@ let testDbPath: string;
let testDb: Database;
beforeEach(() => {
mkdirSync("./data", { recursive: true });
testDbPath = `./data/test-db-${Date.now()}.sqlite`;
testDbPath = join(testRoot, `test-db-${Date.now()}.sqlite`);
testDb = new Database(testDbPath, { create: true });
// Now uses the real initialization logic from db.ts
initializeDatabase(testDb);
@@ -136,7 +139,7 @@ test("db initializes and creates tables on first run", () => {
test("db handles migration from version 0 to version 1", () => {
testDb.close();
const migrateDbPath = `./data/test-db-migrate-${Date.now()}.sqlite`;
const migrateDbPath = join(testRoot, `test-db-migrate-${Date.now()}.sqlite`);
const migrateDb = new Database(migrateDbPath, { create: true });
try {
-146
View File
@@ -1,146 +0,0 @@
import { afterEach, expect, mock, spyOn, test } from "bun:test";
import { exec } from "node:child_process";
import { readFile } from "node:fs";
// mocks have to be defined before importing the module under test,
// otherwise the real modules will be loaded first and the mocks won't take effect.
mock.module("node:child_process", () => ({
// The mock checks the environment variable MOCK_EXEC_ERROR at call-time so
// individual tests can trigger error paths by setting that env before
// importing the module under test.
exec: mock((cmd: string, cb: (error: Error | null, stdout: string) => void) => {
const shouldError = (process.env.MOCK_EXEC_ERROR || "")
.split(",")
.some((p) => p && cmd.includes(p));
if (shouldError) {
cb(new Error(`${cmd} not found`), "");
return;
}
// resvg, bun, heif-info, dasel, and the perl one-liner (msgconvert) print just
// the bare version number — the source code itself prepends the tool name as a label.
if (
cmd.startsWith("resvg") ||
cmd.startsWith("bun") ||
cmd.startsWith("heif-info") ||
cmd.startsWith("dasel") ||
cmd.startsWith("perl")
) {
cb(null, "1.0.0\n");
return;
}
// assimp's real output is multi-line; the source reads line index 5.
if (cmd.startsWith("assimp")) {
cb(null, "l1\nl2\nl3\nl4\nl5\nVersion 1.0.0 (GIT commit abc123)\n");
return;
}
// magick prefixes its version line with "Version: ".
if (cmd.startsWith("magick")) {
cb(null, "Version: ImageMagick v1.0.0\n");
return;
}
cb(null, `${cmd} v1.0.0\n`);
}),
}));
mock.module("node:fs", () => ({
readFile: mock(
(path: string, encoding: string, cb: (error: Error | null, data: string) => void) => {
cb(null, 'PRETTY_NAME="Ubuntu 22.04 LTS"\n');
},
),
}));
mock.module("../../package.json", () => ({
version: "1.0.0-test",
}));
// We import the module in each test after the spies and the desired NODE_ENV were set
// so that the top level execution is under control of each test
let consoleLogSpy: ReturnType<typeof spyOn>;
let consoleErrorSpy: ReturnType<typeof spyOn>;
afterEach(() => {
consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore();
delete process.env.NODE_ENV;
delete process.env.MOCK_EXEC_ERROR;
});
test("prints only the version and terminates itself immediately when NODE_ENV is not production", async () => {
consoleLogSpy = spyOn(console, "log");
consoleErrorSpy = spyOn(console, "error");
// import the module after the spies were set so that the initial console.log within the module
// gets recognized by the spy
await import("../../src/helpers/printVersions?test=" + Math.random());
// empty the queue to make sure all callbacks have been processed
await new Promise((resolve) => setImmediate(resolve));
expect(consoleLogSpy).toHaveBeenCalledWith("ConvertX v1.0.0-test");
expect(consoleLogSpy).toHaveBeenCalledTimes(1);
expect(readFile).not.toHaveBeenCalled();
});
test("prints system information and tool versions in production mode", async () => {
process.env.NODE_ENV = "production";
consoleLogSpy = spyOn(console, "log");
consoleErrorSpy = spyOn(console, "error");
// import module under test after setting NODE_ENV and activating the spies
await import("../../src/helpers/printVersions?test=" + Math.random());
await new Promise((resolve) => setImmediate(resolve));
expect(consoleLogSpy).toHaveBeenCalledWith("ConvertX v1.0.0-test");
expect(consoleLogSpy).toHaveBeenCalledWith("Ubuntu 22.04 LTS");
expect(readFile).toHaveBeenCalledTimes(1);
expect(exec).toHaveBeenCalledTimes(18); // Corresponds exactly to the number of exec calls in the source code
});
test("logs error paths when tools are missing", async () => {
process.env.NODE_ENV = "production";
// Trigger a few error paths ("perl" is the msgconvert version lookup)
process.env.MOCK_EXEC_ERROR = "pandoc,resvg,bun,perl";
consoleLogSpy = spyOn(console, "log");
consoleErrorSpy = spyOn(console, "error");
await import("../../src/helpers/printVersions?test=" + Math.random());
await new Promise((resolve) => setImmediate(resolve));
expect(consoleErrorSpy).toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalledWith("Pandoc is not installed.");
expect(consoleErrorSpy).toHaveBeenCalledWith("resvg is not installed");
expect(consoleErrorSpy).toHaveBeenCalledWith("Bun is not installed. wait what");
expect(consoleErrorSpy).toHaveBeenCalledWith(
"msgconvert (libemail-outlook-message-perl) is not installed",
);
});
test("processes output parsing correctly and logs no errors in the success case", async () => {
process.env.NODE_ENV = "production";
consoleLogSpy = spyOn(console, "log");
consoleErrorSpy = spyOn(console, "error");
// import module under test after setting NODE_ENV and activating the spies
await import("../../src/helpers/printVersions?test=" + Math.random());
await new Promise((resolve) => setImmediate(resolve));
// testing some specific outputs
expect(consoleLogSpy).toHaveBeenCalledWith("pandoc -v v1.0.0");
expect(consoleLogSpy).toHaveBeenCalledWith("ffmpeg -version v1.0.0");
expect(consoleLogSpy).toHaveBeenCalledWith("resvg v1.0.0");
expect(consoleLogSpy).toHaveBeenCalledWith("Bun v1.0.0");
expect(consoleLogSpy).toHaveBeenCalledWith("dasel 1.0.0");
expect(consoleLogSpy).toHaveBeenCalledWith("msgconvert v1.0.0");
// make sure that error paths have not been triggered
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
+55
View File
@@ -0,0 +1,55 @@
import { afterEach, beforeEach, expect, spyOn, test } from "bun:test";
let consoleLogSpy: ReturnType<typeof spyOn>;
let consoleErrorSpy: ReturnType<typeof spyOn>;
let originalNodeEnv: string | undefined;
beforeEach(() => {
originalNodeEnv = process.env.NODE_ENV;
});
afterEach(() => {
if (consoleLogSpy) consoleLogSpy.mockRestore();
if (consoleErrorSpy) consoleErrorSpy.mockRestore();
process.env.NODE_ENV = originalNodeEnv;
});
test("prints system information and tool versions in production mode without errors", async () => {
process.env.NODE_ENV = "production";
consoleLogSpy = spyOn(console, "log");
consoleErrorSpy = spyOn(console, "error");
await import("../../src/helpers/printVersions?test=" + Math.random());
const printVersionsContent = await Bun.file("src/helpers/printVersions.ts").text();
const expectedLogsCount = (printVersionsContent.match(/console\.log/g) || []).length;
// Poll every 50ms (up to 3 seconds) until all expected versions are logged
for (let i = 0; i < 60; i++) {
if (consoleLogSpy.mock.calls.length >= expectedLogsCount) break;
await new Promise((resolve) => setTimeout(resolve, 50));
}
expect(consoleErrorSpy).not.toHaveBeenCalled();
// We expect exactly one log per console.log statement in the file
expect(consoleLogSpy.mock.calls.length).toBe(expectedLogsCount);
// Verify the standard format "ToolName v1.2.3" for all tools (except ConvertX version and OS)
// ConvertX vX.X.X is the first call, OS is the second call.
// Actually, because callbacks are async, we should just check if it matches the pattern or is OS string
for (const call of consoleLogSpy.mock.calls) {
const msg = call[0];
// Ignore the OS string which doesn't follow the pattern
if (msg.includes("Linux") || msg.includes("Debian") || msg.includes("Ubuntu")) continue;
// Dasel on Debian Sid prints "development" instead of a numerical version
if (msg === "dasel development") continue;
// The pattern is "Name vVersion" where Version must start with a digit
// e.g., "FFmpeg v8.1.2-2+b3", "potrace v1.16"
expect(msg).toMatch(/^[a-zA-Z0-9_-]+\s+v\d+[a-zA-Z0-9+.-]*$/);
expect(msg).not.toMatch(/unknown/i);
}
}, 10000);
+49
View File
@@ -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('<optgroup label="vcf"><option value="csv,vcf">csv</option></optgroup>');
});
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("<optgroup");
// the placeholder of the select is still rendered
expect(body).toContain('<option selected disabled value="">');
});
test("returns a fragment, not a full page", async () => {
const body = await conversionsFor("vcf");
expect(body).toStartWith("<article");
expect(body).not.toContain("<html");
});
test("works without a session, as it only exposes the converter list", async () => {
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);
});
});
+174
View File
@@ -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("/");
});
});
+100
View File
@@ -0,0 +1,100 @@
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");
const response = await request("/delete", {
cookies: { auth: user.token, jobId: job },
json: { filename: `../../${other.user.id}/${other.job}/keep.txt` },
});
// the request passed the session and job checks, so the file name was really used;
// the sanitized name does not exist, which runs into the bug tracked below
expect(response.status).not.toBeOneOf([302, 401]);
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. This also
// happens for every sanitized traversal attempt. Remove `.failing` once deleteFile.tsx
// handles the missing file.
test.failing.each(["missing.txt", "../../1/1/keep.txt"])(
"answers a missing file (%s) without a server error or server paths",
async (filename) => {
const { user, job } = await userWithUploads();
const response = await request("/delete", {
cookies: { auth: user.token, jobId: job },
json: { filename },
});
expect(response.status).toBeLessThan(500);
expect(await response.text()).not.toContain(uploadsDir);
},
);
});
+160
View File
@@ -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);
});
});
+159 -81
View File
@@ -1,90 +1,168 @@
import { jwt } from "@elysiajs/jwt";
import { expect, test } from "bun:test";
import { Elysia } from "elysia";
import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
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 db from "../../src/db/db";
import { download } from "../../src/pages/download";
import { createJob, createUser, jobDirs, request } from "./helpers/app";
test("download route blocks symlinks and serves normal files", async () => {
const testUserId = 998;
const testUserEmail = `download-test-${Date.now()}@example.com`;
db.query("INSERT INTO users (id, email, password) VALUES (?, ?, ?)").run(
testUserId,
testUserEmail,
"hashed_pwd",
);
const res = db
.query(
"INSERT INTO jobs (user_id, date_created, status, num_files) VALUES (?, ?, ?, ?) RETURNING id",
)
.get(testUserId, new Date().toISOString(), "completed", 1) as { id: number };
const jobId = String(res.id);
const jobDir = `./data/output/${testUserId}/${jobId}`;
mkdirSync(jobDir, { recursive: true });
writeFileSync(`${jobDir}/normal.txt`, "safe content");
symlinkSync("/etc/passwd", `${jobDir}/symlink.txt`);
async function userWithOutputs(files: Record<string, string>) {
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 tokenApp = new Elysia()
.use(jwt({ name: "jwt", secret: process.env.JWT_SECRET ?? "secret", exp: "7d" }))
.get("/token", async ({ jwt }) => jwt.sign({ id: String(testUserId) }));
const tokenRes = await tokenApp.handle(new Request("http://localhost/token"));
const token = await tokenRes.text();
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 });
}
}
const app = new Elysia().use(download);
describe("GET /download/:userId/:jobId/:fileName", () => {
test("redirects to the login page without a session", async () => {
const { user, job } = await userWithOutputs({ "a.csv": "a" });
// 1. Downloading normal file must succeed (200)
const normalRes = await app.handle(
new Request(`http://localhost/download/${testUserId}/${jobId}/normal.txt`, {
headers: { cookie: `auth=${token}` },
}),
);
expect(normalRes.status).toBe(200);
expect(await normalRes.text()).toBe("safe content");
// 2. Downloading a symlink must be blocked (403)
const symlinkRes = await app.handle(
new Request(`http://localhost/download/${testUserId}/${jobId}/symlink.txt`, {
headers: { cookie: `auth=${token}` },
}),
);
expect(symlinkRes.status).toBe(403);
const body = (await symlinkRes.json()) as { message?: string };
expect(body.message).toBe("Access denied.");
// 3. Downloading non-existent file must return 404
const notFoundRes = await app.handle(
new Request(`http://localhost/download/${testUserId}/${jobId}/nonexistent.txt`, {
headers: { cookie: `auth=${token}` },
}),
);
expect(notFoundRes.status).toBe(404);
// 4. Archive tarball must exclude symlinks
const archiveRes = await app.handle(
new Request(`http://localhost/archive/${jobId}`, {
headers: { cookie: `auth=${token}` },
}),
);
expect(archiveRes.status).toBe(200);
const tarEntries: string[] = [];
await tar.t({
file: `${jobDir}/converted_files_${jobId}.tar`,
onReadEntry: (e) => {
tarEntries.push(e.path);
},
const response = await request(`/download/${user.id}/${job}/a.csv`, {
headers: { accept: "text/html" },
});
expect(tarEntries).toContain("./normal.txt");
expect(tarEntries).not.toContain("./symlink.txt");
} finally {
rmSync(`./data/output/${testUserId}`, { recursive: true, force: true });
db.query("DELETE FROM jobs WHERE id = ?").run(jobId);
db.query("DELETE FROM users WHERE id = ?").run(testUserId);
}
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");
});
});
+55
View File
@@ -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();
});
});
+18
View File
@@ -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([]);
});
});
+208
View File
@@ -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<Env>) {
mock.module("../../../src/helpers/env", () => ({ ...realEnv, ...overrides }));
}
export function resetEnv() {
mock.module("../../../src/helpers/env", () => realEnv);
}
// --- requests --------------------------------------------------------------------
type RequestOptions = {
method?: string;
cookies?: Record<string, string | number | undefined>;
headers?: Record<string, string>;
json?: unknown;
form?: Record<string, string>;
body?: RequestInit["body"];
};
export function request(path: string, options: RequestOptions = {}) {
const headers: Record<string, string> = { ...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<TestUser> {
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<Response>): Promise<Snapshot> {
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");
}
+111
View File
@@ -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("<td>3</td>");
expect(row).toContain('<td class="max-sm:hidden">2</td>');
expect(row).toContain("<td>pending</td>");
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: "<b>pending</b>",
files: [
{ file_name: '"><script>alert(1)</script>.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('"><script>');
expect(body).not.toContain("<b>pending</b>");
expect(body).toContain("&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;.vcf");
expect(body).toContain("&lt;b&gt;pending&lt;/b&gt;");
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test";
import { getAllInputs, getAllTargets } from "../../src/converters/main";
import { createUser, request } from "./helpers/app";
describe("GET /converters", () => {
test("redirects to the login page without a session", async () => {
const response = await request("/converters", { headers: { accept: "text/html" } });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/login");
});
test("lists every converter with the number of inputs and targets", async () => {
const user = await createUser();
const body = await (await request("/converters", { cookies: { auth: user.token } })).text();
for (const [converter, targets] of Object.entries(getAllTargets())) {
expect(body).toContain(
`<td>${converter}</td><td>Count: ${getAllInputs(converter).length}<ul>`,
);
expect(body).toContain(`Count: ${targets.length}<ul>`);
}
});
test("lists the inputs and targets of a converter", async () => {
const user = await createUser();
const body = await (await request("/converters", { cookies: { auth: user.token } })).text();
expect(body).toContain(
"<td>vcf</td><td>Count: 1<ul><li>vcf</li></ul></td><td>Count: 1<ul><li>csv</li></ul></td>",
);
});
});
+188 -1
View File
@@ -1,5 +1,10 @@
import { expect, test } from "bun:test";
import { afterEach, describe, expect, test } from "bun:test";
import { buildDownloadUrl } from "../../src/helpers/buildDownloadUrl";
import { createJob, createUser, request, resetEnv, setCookie, setEnv } from "./helpers/app";
afterEach(() => {
resetEnv();
});
test("encodes reserved characters in download filenames", () => {
expect(buildDownloadUrl("", "1/2/", "clip #1?.gif")).toBe("/download/1/2/clip%20%231%3F.gif");
@@ -10,3 +15,185 @@ test("preserves output path segments while encoding the filename", () => {
"/convertx/download/user/job/%E5%A0%B1%E5%91%8A%20100%25.pdf",
);
});
const done = (name: string) => ({
file_name: `${name}.vcf`,
output_file_name: `${name}.csv`,
status: "Done",
});
describe.each([
["GET", "/results"],
["POST", "/progress"],
])("%s %s/:jobId", (method, route) => {
test("requires a session", async () => {
const user = await createUser();
const job = createJob(user.id);
const response = await request(`${route}/${job}`, {
method,
headers: { accept: "application/json" },
});
expect(response.status).toBe(401);
});
test.each([
["another user's job", true],
["a job that does not exist", false],
])("answers 404 for %s", async (_, exists) => {
const user = await createUser();
const other = await createUser();
const job = exists ? createJob(other.id, { numFiles: 1, files: [done("secret")] }) : 999_999;
const response = await request(`${route}/${job}`, { method, cookies: { auth: user.token } });
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ message: "Job not found." });
});
test("lists the converted files with view and download links", async () => {
const user = await createUser();
const job = createJob(user.id, { numFiles: 2, files: [done("a"), done("my file #1")] });
const body = await (
await request(`${route}/${job}`, { method, cookies: { auth: user.token } })
).text();
expect(body).toContain(`href="/download/${user.id}/${job}/a.csv"`);
expect(body).toContain(`href="/download/${user.id}/${job}/my%20file%20%231.csv"`);
expect(body).toContain('download="my file #1.csv"');
});
test.each(["Failed, check logs", "File type not supported"])(
"shows no links for files with status '%s'",
async (status) => {
const user = await createUser();
const job = createJob(user.id, {
numFiles: 1,
files: [{ file_name: "a.xyz", output_file_name: "a.pdf", status }],
});
const body = await (
await request(`${route}/${job}`, { method, cookies: { auth: user.token } })
).text();
expect(body).toContain("Unavailable");
expect(body).toContain(`<td>${status}</td>`);
expect(body).not.toContain("/download/");
},
);
test("escapes file names and statuses", async () => {
const user = await createUser();
const name = '"><img src=x onerror=alert(1)>.csv';
const job = createJob(user.id, {
numFiles: 1,
files: [{ file_name: "x", output_file_name: name, status: "<b>Done</b>" }],
});
const body = await (
await request(`${route}/${job}`, { method, cookies: { auth: user.token } })
).text();
// neither the text nor the download attribute can be broken out of
expect(body).not.toContain('"><img');
expect(body).not.toContain("<b>Done</b>");
expect(body).toContain("&quot;&gt;&lt;img src=x onerror=alert(1)&gt;.csv");
expect(body).toContain("&lt;b&gt;Done&lt;/b&gt;");
});
test("disables delete and tar download while files are still converting", async () => {
const user = await createUser();
const job = createJob(user.id, { numFiles: 2, files: [done("a")] });
const body = await (
await request(`${route}/${job}`, { method, cookies: { auth: user.token } })
).text();
expect(body.match(/aria-busy="true"/g)).toHaveLength(2);
expect(body).toMatch(/<progress max="2"(?! value)/);
});
test("enables delete and tar download once every file is converted", async () => {
const user = await createUser();
const job = createJob(user.id, { numFiles: 2, files: [done("a"), done("b")] });
const body = await (
await request(`${route}/${job}`, { method, cookies: { auth: user.token } })
).text();
expect(body).not.toContain('aria-busy="true"');
expect(body).toContain('<progress max="2" value="2"');
expect(body).toContain(`action="/delete/${job}"`);
expect(body).toContain(`href="/archive/${job}"`);
});
test("prefixes all links with WEBROOT", async () => {
setEnv({ WEBROOT: "/convertx" });
const user = await createUser();
const job = createJob(user.id, { numFiles: 1, files: [done("a")] });
const body = await (
await request(`${route}/${job}`, { method, cookies: { auth: user.token } })
).text();
expect(body).toContain(`href="/convertx/download/${user.id}/${job}/a.csv"`);
expect(body).toContain(`action="/convertx/delete/${job}"`);
expect(body).toContain(`href="/convertx/archive/${job}"`);
});
test("keeps the session and the current job", async () => {
const user = await createUser();
const job = createJob(user.id);
const response = await request(`${route}/${job}`, {
method,
cookies: { auth: user.token, jobId: job },
});
expect(setCookie(response, "jobId")).toBeUndefined();
expect(setCookie(response, "auth")).toBeUndefined();
});
// no page sets job_id anymore (the job cookie is called jobId), but the handlers still
// clear it for browsers that kept it
test("clears the legacy job_id cookie", async () => {
const user = await createUser();
const job = createJob(user.id);
const response = await request(`${route}/${job}`, {
method,
cookies: { auth: user.token, job_id: job },
});
expect(setCookie(response, "job_id")).toContain("Max-Age=0");
});
});
describe("GET /results/:jobId", () => {
test("renders a full page with the results script", async () => {
const user = await createUser();
const job = createJob(user.id);
const body = await (await request(`/results/${job}`, { cookies: { auth: user.token } })).text();
expect(body).toStartWith("<!doctype html>");
expect(body).toContain("<title>ConvertX | Result</title>");
expect(body).toContain('<script src="/results.js" defer');
});
});
describe("POST /progress/:jobId", () => {
test("renders only the results fragment for polling", async () => {
const user = await createUser();
const job = createJob(user.id);
const body = await (
await request(`/progress/${job}`, { method: "POST", cookies: { auth: user.token } })
).text();
expect(body).toStartWith('<article class="article">');
expect(body).not.toContain("<html");
});
});
+159
View File
@@ -0,0 +1,159 @@
import { afterEach, describe, expect, test } from "bun:test";
import {
app,
createUser,
db,
request,
resetEnv,
setCookie,
setCookieValue,
setEnv,
} from "./helpers/app";
afterEach(() => {
resetEnv();
});
type JobRow = { id: number; user_id: string; date_created: string; status: string };
function jobsOf(userId: number | string) {
return db.query("SELECT * FROM jobs WHERE user_id = ? ORDER BY id").all(userId) as JobRow[];
}
async function userIdOf(response: Response) {
const payload = await app.decorator.jwt.verify(setCookieValue(response, "auth") ?? "");
return payload ? payload.id : undefined;
}
describe("GET / with accounts", () => {
test("redirects to the login page without a session", async () => {
const response = await request("/");
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/login");
});
test("redirects to the login page for an invalid session", async () => {
const response = await request("/", { cookies: { auth: "not-a-jwt" } });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/login");
});
test("clears the session of an account that no longer exists", async () => {
const user = await createUser();
db.query("DELETE FROM users WHERE id = ?").run(user.id);
const response = await request("/", { cookies: { auth: user.token } });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/login");
expect(setCookie(response, "auth")).toContain("Max-Age=0");
// no job is started for the deleted account
expect(jobsOf(user.id)).toEqual([]);
});
test("starts a new job and remembers it in the jobId cookie", async () => {
const user = await createUser();
const response = await request("/", { cookies: { auth: user.token } });
expect(response.status).toBe(200);
const jobs = jobsOf(user.id);
expect(jobs).toHaveLength(1);
expect(jobs[0]?.status).toBe("not started");
expect(Date.now() - Date.parse(jobs[0]?.date_created ?? "")).toBeLessThan(60_000);
expect(setCookieValue(response, "jobId")).toBe(String(jobs[0]?.id));
expect(setCookie(response, "jobId")).toContain("HttpOnly");
expect(setCookie(response, "jobId")).toContain("Secure");
expect(setCookie(response, "jobId")).toContain("SameSite=Strict");
expect(setCookie(response, "jobId")).toContain(`Max-Age=${24 * 60 * 60}`);
});
test("starts a fresh job on every visit", async () => {
const user = await createUser();
const first = await request("/", { cookies: { auth: user.token } });
const second = await request("/", { cookies: { auth: user.token } });
expect(jobsOf(user.id)).toHaveLength(2);
expect(Number(setCookieValue(second, "jobId"))).toBeGreaterThan(
Number(setCookieValue(first, "jobId")),
);
});
test("offers every target of every converter", async () => {
const user = await createUser();
const body = await (await request("/", { cookies: { auth: user.token } })).text();
expect(body).toContain('action="/convert"');
expect(body).toContain('<option value="csv,vcf">csv</option>');
expect(body).toContain('data-value="png,inkscape"');
});
test("links the account and logoff pages in the header", async () => {
const user = await createUser();
const body = await (await request("/", { cookies: { auth: user.token } })).text();
expect(body).toContain('href="/account"');
expect(body).toContain('href="/logoff"');
});
});
describe("GET / without accounts (ALLOW_UNAUTHENTICATED)", () => {
test("creates an anonymous session and a job for visitors without a session", async () => {
setEnv({ ALLOW_UNAUTHENTICATED: true });
const response = await request("/");
expect(response.status).toBe(200);
const userId = await userIdOf(response);
// anonymous ids start at 2^24 so they never collide with registered accounts
expect(Number(userId)).toBeGreaterThanOrEqual(2 ** 24);
expect(setCookie(response, "auth")).toContain(`Max-Age=${24 * 60 * 60}`);
expect(jobsOf(userId ?? "")).toHaveLength(1);
});
test("gives every anonymous visitor a different id", async () => {
setEnv({ ALLOW_UNAUTHENTICATED: true });
const first = await userIdOf(await request("/"));
const second = await userIdOf(await request("/"));
expect(first).not.toBe(second);
});
test("shares one anonymous id when UNAUTHENTICATED_USER_SHARING is enabled", async () => {
setEnv({ ALLOW_UNAUTHENTICATED: true, UNAUTHENTICATED_USER_SHARING: true });
expect(await userIdOf(await request("/"))).toBe("0");
expect(await userIdOf(await request("/"))).toBe("0");
});
test("hides the account and logoff links", async () => {
setEnv({ ALLOW_UNAUTHENTICATED: true });
const body = await (await request("/")).text();
expect(body).not.toContain('href="/account"');
expect(body).not.toContain('href="/logoff"');
});
// BUG: the ALLOW_UNAUTHENTICATED branch runs before the session check, so every visit
// replaces the existing session (even a registered account's) with a new anonymous id
// and the user loses access to their previous jobs. This regressed the fix for #114
// ("keep unauthenticated user logged in if allowed", bc4ad49) in 538c5b6. Remove
// `.failing` once root.tsx verifies an existing session first.
test.failing("keeps an existing session", async () => {
setEnv({ ALLOW_UNAUTHENTICATED: true });
const user = await createUser();
const response = await request("/", { cookies: { auth: user.token } });
expect(setCookie(response, "auth")).toBeUndefined();
expect(jobsOf(user.id)).toHaveLength(1);
});
});
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, test } from "bun:test";
import { existsSync, readdirSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { createJob, createUser, jobDirs, request } from "./helpers/app";
function uploadForm(...files: File[]) {
const form = new FormData();
for (const file of files) {
form.append("file", file);
}
return form;
}
describe("POST /upload", () => {
test("requires a session", async () => {
const response = await request("/upload", {
method: "POST",
headers: { accept: "application/json" },
body: uploadForm(new File(["x"], "a.txt")),
});
expect(response.status).toBe(401);
});
test("redirects home without a job", async () => {
const user = await createUser();
const response = await request("/upload", {
method: "POST",
cookies: { auth: user.token },
body: uploadForm(new File(["x"], "a.txt")),
});
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/");
});
test("refuses to upload into another user's job", async () => {
const user = await createUser();
const other = await createUser();
const otherJob = createJob(other.id);
const response = await request("/upload", {
method: "POST",
cookies: { auth: user.token, jobId: otherJob },
body: uploadForm(new File(["x"], "a.txt")),
});
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/");
expect(existsSync(jobDirs(other.id, otherJob).uploads)).toBe(false);
expect(existsSync(jobDirs(user.id, otherJob).uploads)).toBe(false);
});
test("stores a single file in the job's upload directory", async () => {
const user = await createUser();
const job = createJob(user.id);
const response = await request("/upload", {
method: "POST",
cookies: { auth: user.token, jobId: job },
body: uploadForm(new File(["hello world"], "notes.txt")),
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ message: "Files uploaded successfully." });
expect(await readFile(`${jobDirs(user.id, job).uploads}notes.txt`, "utf-8")).toBe(
"hello world",
);
});
test("stores multiple files at once", async () => {
const user = await createUser();
const job = createJob(user.id);
await request("/upload", {
method: "POST",
cookies: { auth: user.token, jobId: job },
body: uploadForm(new File(["1"], "one.txt"), new File(["2"], "two.txt")),
});
expect(readdirSync(jobDirs(user.id, job).uploads).sort()).toEqual(["one.txt", "two.txt"]);
});
test("sanitizes file names so uploads cannot escape the job directory", async () => {
const user = await createUser();
const job = createJob(user.id);
const { uploads } = jobDirs(user.id, job);
await request("/upload", {
method: "POST",
cookies: { auth: user.token, jobId: job },
body: uploadForm(new File(["evil"], "../../../evil.txt")),
});
expect(readdirSync(uploads)).toEqual(["......evil.txt"]);
expect(existsSync(resolve(uploads, "../../../evil.txt"))).toBe(false);
});
test("rejects a request without files", async () => {
const user = await createUser();
const job = createJob(user.id);
const response = await request("/upload", {
method: "POST",
cookies: { auth: user.token, jobId: job },
body: new FormData(),
});
expect(response.status).toBe(422);
});
});
+366
View File
@@ -0,0 +1,366 @@
import { afterEach, describe, expect, test } from "bun:test";
import type { User } from "../../src/db/types";
import {
app,
createUser,
db,
request,
resetEnv,
setCookie,
setCookieValue,
setEnv,
signToken,
} from "./helpers/app";
afterEach(() => {
resetEnv();
});
function findUser(email: string) {
return db.query("SELECT * FROM users WHERE email = ?").get(email) as User | null;
}
function findUserById(id: number) {
return db.query("SELECT * FROM users WHERE id = ?").get(id) as User | null;
}
const uniqueEmail = () => `${crypto.randomUUID()}@example.com`;
describe("GET /register", () => {
test("redirects to the login page while registration is closed", async () => {
setEnv({ ACCOUNT_REGISTRATION: false });
const response = await request("/register");
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/login");
});
test("shows the registration form when registration is open", async () => {
setEnv({ ACCOUNT_REGISTRATION: true });
const response = await request("/register");
expect(response.status).toBe(200);
expect(await response.text()).toContain('value="Register"');
});
});
describe("POST /register", () => {
test("does not create an account while registration is closed", async () => {
setEnv({ ACCOUNT_REGISTRATION: false });
const email = uniqueEmail();
const response = await request("/register", { form: { email, password: "secret" } });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/login");
expect(findUser(email)).toBeNull();
});
test("creates the account, logs it in and redirects home when registration is open", async () => {
setEnv({ ACCOUNT_REGISTRATION: true });
const email = uniqueEmail();
const response = await request("/register", { form: { email, password: "secret" } });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/");
const user = findUser(email);
expect(user).not.toBeNull();
expect(await Bun.password.verify("secret", user?.password ?? "")).toBe(true);
const token = setCookieValue(response, "auth") ?? "";
expect(await app.decorator.jwt.verify(token)).toMatchObject({ id: String(user?.id) });
expect(setCookie(response, "auth")).toContain("HttpOnly");
expect(setCookie(response, "auth")).toContain("Secure");
expect(setCookie(response, "auth")).toContain("SameSite=Strict");
expect(setCookie(response, "auth")).toContain(`Max-Age=${7 * 24 * 60 * 60}`);
});
test("omits the Secure cookie flag when plain HTTP is allowed", async () => {
setEnv({ ACCOUNT_REGISTRATION: true, HTTP_ALLOWED: true });
const response = await request("/register", {
form: { email: uniqueEmail(), password: "secret" },
});
expect(setCookie(response, "auth")).not.toContain("Secure");
});
test("rejects an email that is already registered", async () => {
setEnv({ ACCOUNT_REGISTRATION: true });
const existing = await createUser();
const response = await request("/register", {
form: { email: existing.email, password: "another password" },
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ message: "Email already in use." });
// the existing account keeps its password
expect(
await Bun.password.verify(existing.password, findUser(existing.email)?.password ?? ""),
).toBe(true);
});
test("rejects a request without a password", async () => {
setEnv({ ACCOUNT_REGISTRATION: true });
const response = await request("/register", { form: { email: uniqueEmail() } });
expect(response.status).toBe(422);
});
});
const expectErrorPage = async (response: Response, message: string) => {
expect(response.headers.get("content-type")).toContain("text/html");
const body = await response.text();
expect(body).toContain('role="alert"');
expect(body).toContain(message);
};
describe("GET /login", () => {
test("shows the login form without a register link while registration is closed", async () => {
setEnv({ ACCOUNT_REGISTRATION: false });
const body = await (await request("/login")).text();
expect(body).toContain('value="Login"');
expect(body).not.toContain('href="/register"');
});
test("links to the registration when it is open", async () => {
setEnv({ ACCOUNT_REGISTRATION: true });
const body = await (await request("/login")).text();
expect(body).toContain('href="/register"');
});
test("redirects users that are already logged in to the home page", async () => {
const user = await createUser();
const response = await request("/login", { cookies: { auth: user.token } });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/");
});
test("clears an invalid session and shows the login form", async () => {
const response = await request("/login", { cookies: { auth: "not-a-jwt" } });
expect(response.status).toBe(200);
expect(setCookie(response, "auth")).toContain("Max-Age=0");
});
});
describe("POST /login", () => {
test("logs in with valid credentials and redirects home", async () => {
const user = await createUser();
const response = await request("/login", {
form: { email: user.email, password: user.password },
});
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/");
const token = setCookieValue(response, "auth") ?? "";
expect(await app.decorator.jwt.verify(token)).toMatchObject({ id: String(user.id) });
expect(setCookie(response, "auth")).toContain("HttpOnly");
});
test.each([
["the password is wrong", "wrong password"],
["the password is empty", ""],
])("rejects the login when %s", async (_, password) => {
const user = await createUser();
const response = await request("/login", { form: { email: user.email, password } });
expect(response.status).toBe(403);
await expectErrorPage(response, "Invalid credentials.");
expect(setCookie(response, "auth")).toBeUndefined();
});
test("gives the same answer for unknown emails, so accounts cannot be enumerated", async () => {
const response = await request("/login", {
form: { email: uniqueEmail(), password: "whatever" },
});
expect(response.status).toBe(403);
await expectErrorPage(response, "Invalid credentials.");
});
test("keeps the entered email in the form after a failed login", async () => {
const user = await createUser();
const response = await request("/login", {
form: { email: user.email, password: "wrong password" },
});
expect(await response.text()).toContain(`value="${user.email}"`);
});
test("rejects a request without a password", async () => {
const user = await createUser();
const response = await request("/login", { form: { email: user.email } });
expect(response.status).toBe(422);
});
});
describe.each(["GET", "POST"])("%s /logoff", (method) => {
test("clears the session and redirects to the login page", async () => {
const user = await createUser();
const response = await request("/logoff", { method, cookies: { auth: user.token } });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/login");
expect(setCookie(response, "auth")).toContain("Max-Age=0");
});
test("redirects to the login page without a session", async () => {
const response = await request("/logoff", { method });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/login");
expect(setCookie(response, "auth")).toBeUndefined();
});
});
describe("GET /account", () => {
test("redirects to the login page without a session", async () => {
const response = await request("/account", { headers: { accept: "text/html" } });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/login");
});
test("shows the account form with the current email", async () => {
const user = await createUser();
const response = await request("/account", { cookies: { auth: user.token } });
expect(response.status).toBe(200);
expect(await response.text()).toContain(`value="${user.email}"`);
});
test("redirects home when the account no longer exists", async () => {
const response = await request("/account", { cookies: { auth: await signToken(999_999) } });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/");
});
});
describe("POST /account", () => {
const updateAccount = (
token: string,
form: { email?: string; newPassword?: string; password: string },
) =>
request("/account", {
cookies: { auth: token },
form: { email: "", newPassword: "", ...form },
});
test("rejects the update when the current password is wrong", async () => {
const user = await createUser();
const response = await updateAccount(user.token, {
email: uniqueEmail(),
password: "wrong password",
});
expect(response.status).toBe(403);
await expectErrorPage(response, "Invalid credentials.");
expect(findUserById(user.id)?.email).toBe(user.email);
});
test("changes the email", async () => {
const user = await createUser();
const newEmail = uniqueEmail();
const response = await updateAccount(user.token, { email: newEmail, password: user.password });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/");
expect(findUserById(user.id)?.email).toBe(newEmail);
});
test("allows submitting the unchanged email", async () => {
const user = await createUser();
const response = await updateAccount(user.token, {
email: user.email,
password: user.password,
});
expect(response.status).toBe(302);
expect(findUserById(user.id)?.email).toBe(user.email);
});
test("refuses an email that belongs to another account", async () => {
const user = await createUser();
const other = await createUser();
const response = await updateAccount(user.token, {
email: other.email,
password: user.password,
});
expect(response.status).toBe(409);
await expectErrorPage(response, "Email already in use.");
expect(findUserById(user.id)?.email).toBe(user.email);
});
test("changes the password", async () => {
const user = await createUser();
await updateAccount(user.token, { newPassword: "new password", password: user.password });
const login = await request("/login", {
form: { email: user.email, password: "new password" },
});
expect(login.status).toBe(302);
const oldLogin = await request("/login", {
form: { email: user.email, password: user.password },
});
expect(oldLogin.status).toBe(403);
});
test("keeps everything unchanged when email and new password are left blank", async () => {
const user = await createUser();
const before = findUserById(user.id);
const response = await updateAccount(user.token, { password: user.password });
expect(response.status).toBe(302);
expect(findUserById(user.id)).toEqual(before);
});
test("requires a session cookie", async () => {
const response = await request("/account", {
form: { email: "", newPassword: "", password: "secret" },
});
expect(response.status).toBe(422);
});
test("redirects to the login page for an invalid session", async () => {
const response = await updateAccount("not-a-jwt", { password: "secret" });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/login");
});
test("clears the session of an account that no longer exists", async () => {
const response = await updateAccount(await signToken(999_999), { password: "secret" });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/login");
expect(setCookie(response, "auth")).toContain("Max-Age=0");
});
});
+48
View File
@@ -0,0 +1,48 @@
// Loaded before every test file (see bunfig.toml).
import { afterAll, mock } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const testRoot = mkdtempSync(join(tmpdir(), "convertx-tests-"));
// Tests must never touch ./data/mydb.sqlite or a DB_PATH from the developer's shell.
process.env.DB_PATH = join(testRoot, "test.sqlite");
process.env.CONVERTX_TEST_ROOT = testRoot;
// Settings from the developer's shell or a .env file (which Bun loads automatically) must
// not change what the tests see, e.g. WEBROOT would move every page. src/helpers/env.ts
// reads them once at import; tests switch them with setEnv() from tests/pages/helpers/app.ts.
for (const name of [
"ACCOUNT_REGISTRATION",
"HTTP_ALLOWED",
"ALLOW_UNAUTHENTICATED",
"AUTO_DELETE_EVERY_N_HOURS",
"HIDE_HISTORY",
"BRANDING",
"WEBROOT",
"LANGUAGE",
"MAX_CONVERT_PROCESS",
"UNAUTHENTICATED_USER_SHARING",
]) {
delete process.env[name];
}
// TZ is read as TIMEZONE for the times on the history page. Deleting it would not help:
// Bun would fall back to the system timezone instead of bun test's UTC default (or keep
// the shell's zone). Assigning it switches the runtime timezone as well, so pin it.
process.env.TZ = "UTC";
// Several pages import uploadsDir/outputDir from src/index.tsx. Importing the real module
// would start the server, spawn every converter's --version and schedule the job cleanup,
// and its relative ./data paths would mix test files with real conversions. Provide the
// constants directly, pointing at the temp directory.
mock.module("../src/index", () => ({
uploadsDir: `${join(testRoot, "uploads")}/`,
outputDir: `${join(testRoot, "output")}/`,
}));
// a preload's afterAll runs once after all test files (and once per file with --isolate)
afterAll(() => {
rmSync(testRoot, { recursive: true, force: true });
});
+212
View File
@@ -0,0 +1,212 @@
import { describe, expect, test } from "bun:test";
import { randomUUID } from "node:crypto";
import { jwt } from "@elysiajs/jwt";
import { Elysia } from "elysia";
import { WEBROOT } from "../../src/helpers/env";
import { userService } from "../../src/services/user";
let protectedHandlerCalls = 0;
const app = new Elysia()
.use(userService)
.get(
"/me",
({ user }) => {
protectedHandlerCalls++;
return user;
},
{ auth: true },
)
.post("/me", ({ user }) => user, { auth: true })
.get("/public", () => "public")
.post("/sign-in", ({ body }) => body, { body: "signIn" })
.get("/session", ({ cookie: { auth, jobId } }) => ({ auth: auth.value, jobId: jobId.value }), {
cookie: "session",
});
const signer = app.decorator.jwt;
// tokens the service must reject
const foreignToken = await new Elysia()
.use(jwt({ name: "jwt", secret: randomUUID() }))
.decorator.jwt.sign({ id: "42" });
const expiredToken = await signer.sign({ id: "42", exp: Math.floor(Date.now() / 1000) - 60 });
// payload violates the { id: string } schema
const wrongSchemaToken = await signer.sign({ id: 42 as unknown as string });
function request(path: string, init: RequestInit & { cookie?: string } = {}) {
const { cookie, headers, ...rest } = init;
return app.handle(
new Request(`http://localhost${path}`, {
...rest,
headers: { ...(cookie ? { cookie } : {}), ...headers },
}),
);
}
const htmlPage = { accept: "text/html" };
const apiCall = { accept: "application/json" };
function expectAuthCookieCleared(response: Response) {
const setCookie = response.headers.get("set-cookie") ?? "";
expect(setCookie).toMatch(/^auth=;/);
expect(setCookie).toContain("Max-Age=0");
}
describe("auth macro with a valid token", () => {
test("resolves the token payload as user", async () => {
const token = await signer.sign({ id: "42" });
const response = await request("/me", { cookie: `auth=${token}` });
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ id: "42" });
});
test("issues tokens that expire after seven days", async () => {
const token = await signer.sign({ id: "42" });
const user = (await (await request("/me", { cookie: `auth=${token}` })).json()) as {
exp: number;
iat: number;
};
expect(user.exp - user.iat).toBe(7 * 24 * 60 * 60);
});
test("accepts an optional jobId cookie", async () => {
const token = await signer.sign({ id: "42" });
const response = await request("/me", { cookie: `auth=${token}; jobId=7` });
expect(response.status).toBe(200);
});
test("does not touch the auth cookie", async () => {
const token = await signer.sign({ id: "42" });
const response = await request("/me", { cookie: `auth=${token}` });
expect(response.headers.get("set-cookie")).toBeNull();
});
});
describe("auth macro without a token", () => {
test("redirects HTML page requests to the login page", async () => {
const response = await request("/me", { headers: htmlPage, redirect: "manual" });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe(`${WEBROOT}/login`);
expect(await response.json()).toEqual({ success: false, message: "Redirecting to login" });
});
test("redirects HEAD page requests to the login page", async () => {
const response = await request("/me", { method: "HEAD", headers: htmlPage });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe(`${WEBROOT}/login`);
});
test("returns 401 JSON for API requests", async () => {
const response = await request("/me", { headers: apiCall });
expect(response.status).toBe(401);
expect(response.headers.get("location")).toBeNull();
expect(await response.json()).toEqual({ success: false, message: "Unauthorized" });
});
test("returns 401 for non-GET requests even if they accept HTML", async () => {
const response = await request("/me", { method: "POST", headers: htmlPage });
expect(response.status).toBe(401);
});
test("treats an empty auth cookie as missing", async () => {
const response = await request("/me", { cookie: "auth=", headers: htmlPage });
expect(response.status).toBe(302);
// there is no session to clear
expect(response.headers.get("set-cookie")).toBeNull();
});
test("does not run the protected handler", async () => {
const callsBefore = protectedHandlerCalls;
await request("/me", { headers: apiCall });
await request("/me", { headers: htmlPage });
expect(protectedHandlerCalls).toBe(callsBefore);
});
});
describe("auth macro with an invalid token", () => {
test.each([
["a malformed token", "not-a-jwt"],
["a token signed with another secret", foreignToken],
["an expired token", expiredToken],
["a token whose payload does not match the schema", wrongSchemaToken],
])("rejects %s and clears the auth cookie", async (_, token) => {
const response = await request("/me", { cookie: `auth=${token}`, headers: apiCall });
expect(response.status).toBe(401);
expect(await response.json()).toEqual({ success: false, message: "Unauthorized" });
expectAuthCookieCleared(response);
});
test("redirects HTML page requests to login and clears the auth cookie", async () => {
const response = await request("/me", { cookie: "auth=not-a-jwt", headers: htmlPage });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe(`${WEBROOT}/login`);
expectAuthCookieCleared(response);
});
});
describe("routes without auth", () => {
test("are reachable without a token", async () => {
const response = await request("/public", { headers: htmlPage });
expect(response.status).toBe(200);
expect(await response.text()).toBe("public");
});
});
describe("models", () => {
const postJson = (body: unknown) =>
request("/sign-in", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
test("signIn accepts an email and a password", async () => {
const response = await postJson({ email: "user@example.com", password: "secret" });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ email: "user@example.com", password: "secret" });
});
test.each([
["the password is missing", { email: "user@example.com" }],
["the email is missing", { password: "secret" }],
["the email is not a string", { email: 42, password: "secret" }],
])("signIn rejects the body when %s", async (_, body) => {
const response = await postJson(body);
expect(response.status).toBe(422);
});
test("session requires an auth cookie", async () => {
const response = await request("/session", { cookie: "jobId=3" });
expect(response.status).toBe(422);
});
test("session accepts an auth cookie with an optional jobId", async () => {
const withJob = await request("/session", { cookie: "auth=token; jobId=3" });
const withoutJob = await request("/session", { cookie: "auth=token" });
expect(await withJob.json()).toEqual({ auth: "token", jobId: "3" });
expect(await withoutJob.json()).toEqual({ auth: "token" });
});
});