diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 8286609..60a133a 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -1,21 +1,37 @@ name: PR checks -# Per-package parallel pipeline: each codec is built, tested, and benchmarked -# independently. The matrix mirrors what CircleCI was doing — every entry -# runs concurrently, any failure fails the workflow. +# Pipeline: builds run as a per-package parallel matrix (wasm compiles are +# the slow part), then a single test job runs the whole vitest workspace +# against the built dists, and CodSpeed benches the packages the PR touched. +# +# Toolchain bumps (emsdk image tag below, root package.json/yarn.lock, this +# workflow itself) force the FULL pipeline including a full bench sweep. +# Expect such a PR to also need: +# 1. tools/dist-size/baseline.json regenerated from that PR's own CI +# artifacts: gh run download --pattern 'dist-*' -d tmp/ then +# node tools/dist-size/check.js --update --artifacts tmp/ (commit diff) +# 2. possibly regenerated lossy decode goldens (openjpeg .91, charls .81, +# openjphjs corpus SHAs) IF the lossless tests still pass — see +# tools/fixture-verification/README.md before touching any golden. # # CodSpeed's first-class GHA integration replaces the manual codspeed-bench -# job from CircleCI: `CodSpeedHQ/action@v3` installs valgrind, sets up +# job from CircleCI: `CodSpeedHQ/action` installs valgrind, sets up # instrumentation, and uploads results in one step. on: pull_request: push: - # Run on every branch including main so each merge to main seeds a - # fresh CodSpeed baseline. Without a main run, PR comments stay stuck - # on "Congrats! CodSpeed is installed" with no before/after deltas. + # Run pushes on main only: each merge seeds a fresh CodSpeed baseline + # (without a main run, PR comments stay stuck on "Congrats! CodSpeed + # is installed" with no before/after deltas). PR branches are already + # covered by the pull_request event — running push on them too meant + # every commit was built/tested/benched TWICE and uploaded two + # CodSpeed measurements per commit, each on a randomly allocated + # runner CPU (GitHub mixes Intel 8370C and AMD EPYC 7763 hardware), + # which is a prime source of "Different runtime environments detected" + # warnings on comparisons. branches: - - "**" + - main # workflow_dispatch lets CodSpeed trigger a backtest run from the # dashboard (to seed initial perf data after the repo is connected). workflow_dispatch: @@ -32,13 +48,20 @@ permissions: jobs: detect-changes: - # Build the matrix dynamically from the set of packages that actually - # changed since main. Mirrors CircleCI's --since main skip logic. On a - # PR where every README was touched (this branch) the list is the full - # 8 packages; on a docs-only PR the build matrix is empty. + # Decide what this run needs to do. Two outputs: + # packages — what to build (and therefore what the test job can rely + # on). dicom-codec's integration tests decode through EVERY sibling + # codec's dist, and the in-test CI guards fail loudly when a dist is + # missing (no more silently-skipped suites), so any package change + # builds the full set. Docs-only changes still skip everything. + # bench — only the packages that actually changed. CodSpeed benches + # run under valgrind (the slowest job in the workflow), and a PR + # only needs deltas for what it touched; main re-benches everything + # to keep full baselines. runs-on: ubuntu-latest outputs: packages: ${{ steps.list.outputs.packages }} + bench: ${{ steps.list.outputs.bench }} any: ${{ steps.list.outputs.any }} steps: - uses: actions/checkout@v4 @@ -52,6 +75,7 @@ jobs: run: | set -e ALL=(charls libjpeg-turbo-8bit libjpeg-turbo-12bit openjpeg openjphjs little-endian big-endian dicom-codec) + ALL_JSON=$(printf '%s\n' "${ALL[@]}" | jq -R . | jq -s -c .) # Baseline runs: on a manual dispatch or any commit landing on # main, build/test/bench every package. The "diff vs main" trick @@ -59,15 +83,46 @@ jobs: # `git diff origin/main..HEAD` is empty and would skip CodSpeed # entirely, leaving the dashboard with no baseline data. if [ "$EVENT_NAME" = "workflow_dispatch" ] || [ "$REF" = "refs/heads/main" ]; then - json=$(printf '%s\n' "${ALL[@]}" | jq -R . | jq -s -c .) echo "Baseline run ($EVENT_NAME on $REF): forcing all packages" - echo "packages=$json" >> "$GITHUB_OUTPUT" + echo "packages=$ALL_JSON" >> "$GITHUB_OUTPUT" + echo "bench=$ALL_JSON" >> "$GITHUB_OUTPUT" echo "any=true" >> "$GITHUB_OUTPUT" exit 0 fi git fetch --no-tags --depth=50 origin main || true BASE=$(git merge-base origin/main HEAD || echo "origin/main") + + # Toolchain files affect how EVERY package is built, tested or + # measured. A change here (e.g. an emsdk bump in this workflow, or + # a vitest upgrade in the root lockfile) must run the full + # pipeline with a full bench sweep — previously such PRs matched + # no package path and skipped CI entirely. tools/ entries count + # too: browser-smoke drives the browser-smoke job, and the test + # suites import reference derivations from + # tools/fixture-verification/gen/ — a change to either must not + # land with CI skipped. + TOOLCHAIN_PATHS=( + ".github/workflows/" + "package.json" + "yarn.lock" + "vitest.workspace.mjs" + "babel.config.json" + "lerna.json" + "tools/dist-size/" + "tools/browser-smoke/" + "tools/fixture-verification/" + ) + for p in "${TOOLCHAIN_PATHS[@]}"; do + if ! git diff --quiet "$BASE"..HEAD -- "$p"; then + echo "Toolchain change detected in $p: forcing all packages (build/test/bench)" + echo "packages=$ALL_JSON" >> "$GITHUB_OUTPUT" + echo "bench=$ALL_JSON" >> "$GITHUB_OUTPUT" + echo "any=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + done + changed=() for pkg in "${ALL[@]}"; do if ! git diff --quiet "$BASE"..HEAD -- "packages/$pkg/"; then @@ -77,11 +132,13 @@ jobs: if [ ${#changed[@]} -eq 0 ]; then echo "No packages changed since $BASE." echo 'packages=[]' >> "$GITHUB_OUTPUT" + echo 'bench=[]' >> "$GITHUB_OUTPUT" echo "any=false" >> "$GITHUB_OUTPUT" else - json=$(printf '%s\n' "${changed[@]}" | jq -R . | jq -s -c .) - echo "Changed packages: $json" - echo "packages=$json" >> "$GITHUB_OUTPUT" + bench_json=$(printf '%s\n' "${changed[@]}" | jq -R . | jq -s -c .) + echo "Changed: $bench_json — building all packages, benching changed only" + echo "packages=$ALL_JSON" >> "$GITHUB_OUTPUT" + echo "bench=$bench_json" >> "$GITHUB_OUTPUT" echo "any=true" >> "$GITHUB_OUTPUT" fi @@ -107,8 +164,15 @@ jobs: apt-get clean -y rm -rf /var/lib/apt/lists/* - uses: actions/checkout@v4 + # Shallow checkout: builds only need the working tree (full history + # is only required by detect-changes for the merge-base diff). + - uses: actions/setup-node@v4 + # The emsdk image bundles node 20.18.0, which fails vite 7's engine + # check (needs >=20.19) during yarn install. Put node 22 on PATH for + # yarn/webpack; emscripten is unaffected — emcc invokes the node + # binary pinned in its own .emscripten config, not the one on PATH. with: - fetch-depth: 0 + node-version: '22' - name: Allow git to operate on the workspace # The container runs as root but the workspace is owned by the # checkout action's user, which makes git complain about @@ -121,13 +185,16 @@ jobs: else echo "No extern/ submodule for ${{ matrix.package }}; skipping." fi - - name: Restore yarn cache + - name: Restore node_modules cache + id: modules-cache uses: actions/cache@v4 with: - path: ~/.cache/yarn - key: yarn-${{ hashFiles('yarn.lock') }} - restore-keys: yarn- + path: | + node_modules + packages/*/node_modules + key: modules-build-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('yarn.lock') }} - name: Install dependencies + if: steps.modules-cache.outputs.cache-hit != 'true' run: yarn install --frozen-lockfile - name: Build run: cd "packages/${{ matrix.package }}" && yarn run build:ci @@ -142,12 +209,13 @@ jobs: retention-days: 7 test: + # One job for the whole vitest workspace: per-package test jobs spent far + # more on runner setup (checkout, node, artifact download, yarn install) + # than on the few seconds of vitest itself, and dicom-codec's suite needs + # every sibling dist anyway. CI=true (set by GitHub) arms the in-test + # guards: a missing dist FAILS its suite instead of silently skipping. needs: [detect-changes, build] if: needs.detect-changes.outputs.any == 'true' - strategy: - fail-fast: false - matrix: - package: ${{ fromJson(needs.detect-changes.outputs.packages) }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -173,20 +241,50 @@ jobs: cp -r "$d"/* "packages/$pkg/dist/" 2>/dev/null || true done ls packages/*/dist 2>/dev/null | head - - name: Restore yarn cache + - name: Restore node_modules cache + id: modules-cache uses: actions/cache@v4 with: - path: ~/.cache/yarn - key: yarn-${{ hashFiles('yarn.lock') }} - restore-keys: yarn- + path: | + node_modules + packages/*/node_modules + key: modules-node22-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('yarn.lock') }} - name: Install dependencies + if: steps.modules-cache.outputs.cache-hit != 'true' run: yarn install --frozen-lockfile - name: Test - run: cd "packages/${{ matrix.package }}" && yarn run test:ci + run: npx vitest run - codspeed-bench: + dist-size: + # Binary-size regression gate: compares every shipped dist artifact + # (js/wasm/mem, raw and gzip) against the ground truth committed in + # tools/dist-size/baseline.json and fails if anything grows beyond + # max(1%, 1 KiB). Intentional size changes update the baseline in the + # same PR (node tools/dist-size/check.js --update) so growth is always + # a visible, reviewed diff. + needs: [detect-changes, build] + if: needs.detect-changes.outputs.any == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Download all built dists + uses: actions/download-artifact@v4 + with: + pattern: dist-* + path: tmp/ + - name: Check dist sizes against baseline + run: node tools/dist-size/check.js --artifacts tmp + + browser-smoke: + # Decodes every build variant in headless Chromium and hash-compares + # against the RAW references — catches emscripten glue regressions that + # only manifest in browsers (wasm URL resolution, MIME/streaming + # compile, fetch loading), which is where emsdk bumps break first. + # Advisory while it beds in; promote to blocking once proven stable. needs: [detect-changes, build] if: needs.detect-changes.outputs.any == 'true' + continue-on-error: true + timeout-minutes: 15 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -208,14 +306,96 @@ jobs: shopt -s dotglob nullglob cp -r "$d"/* "packages/$pkg/dist/" 2>/dev/null || true done - - name: Restore yarn cache + - name: Restore node_modules cache + id: modules-cache uses: actions/cache@v4 with: - path: ~/.cache/yarn - key: yarn-${{ hashFiles('yarn.lock') }} - restore-keys: yarn- + path: | + node_modules + packages/*/node_modules + key: modules-node22-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('yarn.lock') }} - name: Install dependencies + if: steps.modules-cache.outputs.cache-hit != 'true' run: yarn install --frozen-lockfile + - name: Cache Playwright chromium + id: pw-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-chromium-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + - name: Install chromium + if: steps.pw-cache.outputs.cache-hit != 'true' + run: npx playwright-core install chromium + - name: Browser smoke decode + run: node tools/browser-smoke/run.js + + codspeed-bench: + needs: [detect-changes, build] + if: needs.detect-changes.outputs.any == 'true' + # Pin the OS (not ubuntu-latest) so PR runs are measured in the same + # runtime environment as the baseline seeded on main. A drifting + # ubuntu-latest image changes the valgrind/toolchain fingerprint between + # baseline and PR, which makes CodSpeed report "Different runtime + # environments detected" and refuse to trust the comparison. The baseline + # on main must be re-seeded after changing this so both sides match. + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Download all built dists + uses: actions/download-artifact@v4 + with: + pattern: dist-* + path: tmp/ + - name: Replay dists into packages//dist + run: | + set -e + for d in tmp/dist-*; do + [ -d "$d" ] || continue + pkg=$(basename "$d" | sed 's/^dist-//') + mkdir -p "packages/$pkg/dist" + shopt -s dotglob nullglob + cp -r "$d"/* "packages/$pkg/dist/" 2>/dev/null || true + done + - name: Restore node_modules cache + id: modules-cache + uses: actions/cache@v4 + with: + path: | + node_modules + packages/*/node_modules + key: modules-node22-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('yarn.lock') }} + - name: Install dependencies + if: steps.modules-cache.outputs.cache-hit != 'true' + run: yarn install --frozen-lockfile + - name: Log CPU info + # GitHub standard runners are randomly assigned different physical + # CPUs (e.g. Intel Xeon 8370C vs AMD EPYC 7763) with different cache + # sizes and ISA extensions; glibc dispatches different code paths on + # each, so even simulation-mode instruction counts shift between + # them. CodSpeed flags such comparisons as "different runtime + # environments". Logging the CPU makes those warnings diagnosable + # at a glance (recommendation from + # https://codspeed.io/blog/unrelated-benchmark-regression). + run: lscpu | grep -E "Model name|Cache|Flags" | head -5 || true + - name: Compute bench scope + # Translate the changed-package directory names into lerna --scope + # flags so PRs only bench what they touched. Baseline runs (main / + # workflow_dispatch) get the full list from detect-changes, which + # makes this a no-op filter there. + id: scope + env: + BENCH: ${{ needs.detect-changes.outputs.bench }} + run: | + flags="" + for pkg in $(echo "$BENCH" | jq -r '.[]'); do + name=$(node -p "require('./packages/$pkg/package.json').name") + flags="$flags --scope $name" + done + echo "Bench scope flags:$flags" + echo "flags=$flags" >> "$GITHUB_OUTPUT" - name: Run CodSpeed benchmarks # CodSpeedHQ/action@v4 sets up CPU simulation (Cachegrind-based # instruction counting on a modeled CPU + cache hierarchy), runs @@ -224,19 +404,111 @@ jobs: # Authenticates via GitHub OIDC (id-token: write above) so no # CODSPEED_TOKEN secret is needed. # - # mode: simulation (vs walltime) — we chose simulation because: + # mode: simulation — the blocking regression gate: # - deterministic: <1% run-to-run drift (verified across 3 # runs of identical source) - # - free CI minutes (walltime needs CodSpeed macro-runners) + # - free CI minutes (runs on GitHub-hosted runners) # - regression-detection signal is strong even though the # headline numbers are MODELED instruction-time, not real # wall-clock (JS-loop benches inflate 30-100x vs production # V8 due to no JIT under Cachegrind; wasm decode kernels # inflate ~5-15x; pure native ~1x) + # The codspeed-walltime job below complements this with real + # wall-clock measurements on CodSpeed macro runners. # See BENCHMARKING.md at the repo root for the full measurement # model, how to read the cold/warm bench split, and what the # CodSpeed dashboard warnings mean. - uses: CodSpeedHQ/action@v4 + # Pinned to a full version (not the floating @v4) so the + # instrumentation environment stays identical between the main + # baseline and PR runs. + # Note: vitest 3's hard-coded 60s worker-RPC timer counts real + # seconds while valgrind slows the process ~60x, so large suites + # structurally trip "Timeout calling onTaskUpdate" AFTER their + # benches complete. The vitest configs set + # dangerouslyIgnoreUnhandledErrors when CODSPEED_RUNNER_MODE is + # "simulation" to keep that exit-code noise from failing the job + # (config-level because yarn 1 mangles `--`-forwarded CLI flags). + uses: CodSpeedHQ/action@v4.18.2 with: mode: simulation - run: yarn run bench + run: yarn lerna run bench --parallel --stream ${{ steps.scope.outputs.flags }} + + codspeed-walltime: + needs: [detect-changes, build] + # Gated behind a repository variable: a `runs-on: codspeed-macro` job + # queues forever (up to 24h) when macro runners aren't provisioned for + # the org, and GitHub's job timeout only covers execution, not queue + # time. Enable macro runners for the org on app.codspeed.io first + # (the repo is public — also make sure the runner group allows public + # repositories), then: gh variable set CODSPEED_MACRO_ENABLED --body true + if: needs.detect-changes.outputs.any == 'true' && vars.CODSPEED_MACRO_ENABLED == 'true' + # Advisory instrument: real wall-clock numbers (V8 JIT active, real + # cache/branch behavior) that complement the simulation gate above — + # simulation catches small algorithmic slips deterministically, + # walltime keeps the numbers honest on real hardware and covers the + # pure-JS packages where the no-JIT simulation model is furthest from + # production. Failures here must not block the PR while this beds in. + continue-on-error: true + timeout-minutes: 30 + # CodSpeed-managed 16-core ARM64 bare-metal machine, tuned for + # low-noise walltime measurement. Requires the CodSpeed GitHub app on + # an organization account. NOTE: ARM64 — anything cached must be + # arch-qualified (see the cache key below). + runs-on: codspeed-macro + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Download all built dists + uses: actions/download-artifact@v4 + with: + pattern: dist-* + path: tmp/ + - name: Replay dists into packages//dist + run: | + set -e + for d in tmp/dist-*; do + [ -d "$d" ] || continue + pkg=$(basename "$d" | sed 's/^dist-//') + mkdir -p "packages/$pkg/dist" + shopt -s dotglob nullglob + cp -r "$d"/* "packages/$pkg/dist/" 2>/dev/null || true + done + - name: Restore node_modules cache + id: modules-cache + uses: actions/cache@v4 + with: + path: | + node_modules + packages/*/node_modules + # runner.arch matters: this job runs on ARM64 while every other + # job is x64; sharing a key would restore x64 native binaries + # (esbuild/rollup) and break vitest. + key: modules-node22-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('yarn.lock') }} + - name: Install dependencies + if: steps.modules-cache.outputs.cache-hit != 'true' + run: yarn install --frozen-lockfile + - name: Log CPU info + run: lscpu | grep -E "Model name|Cache|Flags" | head -5 || true + - name: Compute bench scope + id: scope + env: + BENCH: ${{ needs.detect-changes.outputs.bench }} + run: | + flags="" + for pkg in $(echo "$BENCH" | jq -r '.[]'); do + name=$(node -p "require('./packages/$pkg/package.json').name") + flags="$flags --scope $name" + done + echo "Bench scope flags:$flags" + echo "flags=$flags" >> "$GITHUB_OUTPUT" + - name: Run CodSpeed benchmarks (walltime) + # Walltime measures actual elapsed time, so parallel benchmark + # processes would contend for cores and add noise — run packages + # sequentially (--concurrency 1), unlike the simulation job where + # instruction counting is immune to contention. + uses: CodSpeedHQ/action@v4.18.2 + with: + mode: walltime + run: yarn lerna run bench --concurrency 1 --stream ${{ steps.scope.outputs.flags }} diff --git a/BENCHMARKING.md b/BENCHMARKING.md index 802388a..94792eb 100644 --- a/BENCHMARKING.md +++ b/BENCHMARKING.md @@ -5,8 +5,9 @@ what the numbers mean, why they don't match real wall-clock time, and how to read warnings from the CodSpeed dashboard. Bench files live under `packages/*/bench/*.bench.js` and are driven by -`vitest bench` + `@codspeed/vitest-plugin@^4`. The full pipeline is in -`.github/workflows/pr-checks.yml` (job: `codspeed-bench`). +`vitest bench` + `@codspeed/vitest-plugin@^5`. The full pipeline is in +`.github/workflows/pr-checks.yml` (jobs: `codspeed-bench` and +`codspeed-walltime`). ## TL;DR @@ -21,10 +22,10 @@ Bench files live under `packages/*/bench/*.bench.js` and are driven by inflates 30–100× vs production V8; for wasm decode kernels it's ~5–15×; for pure compute it's roughly 1×. -## Why simulation, not walltime +## Two instruments: simulation is the gate, walltime is the reality check CodSpeed has two instruments: `simulation` (Cachegrind) and `walltime` -(real CPU, statistical sampling on macro-runners). +(real CPU, statistical sampling on macro-runners). We run **both**: | | Simulation | Walltime | |---|---|---| @@ -36,9 +37,22 @@ CodSpeed has two instruments: `simulation` (Cachegrind) and `walltime` | Honest about JIT tier-up | No (depends on tier at bench time) | Yes | | Good for | Regression detection on a PR | Absolute production-like timing | -We chose simulation because regression detection is the primary goal — -catching "this PR slowed openjpeg by 5%" matters more than knowing the -exact ms a user's browser will take. +Simulation is the **blocking gate** because regression detection is the +primary goal — catching "this PR slowed openjpeg by 5%" matters more than +knowing the exact ms a user's browser will take, and simulation's <1% +determinism flags small algorithmic slips that walltime noise would hide. + +Walltime runs as a **second, advisory instrument** (`codspeed-walltime` +job, `continue-on-error: true`) on CodSpeed macro runners — 16-core ARM64 +bare-metal machines. It covers simulation's two blind spots: real-time +effects (branch prediction, actual caches) that instruction counting +models away, and the pure-JS packages (`little-endian`/`big-endian`) +where the no-JIT simulation model is furthest from production V8. +Walltime benches run packages sequentially (`--concurrency 1`) because +parallel processes contend for cores and add noise; simulation is immune +to contention so it keeps `--parallel`. Note the macro runners are ARM64: +walltime numbers are real milliseconds, but on different silicon than +most x86 production traffic. ## How the numbers get inflated diff --git a/package.json b/package.json index 9b21500..45bac7d 100644 --- a/package.json +++ b/package.json @@ -5,17 +5,19 @@ "packages/*" ], "devDependencies": { - "@codspeed/vitest-plugin": "^4.0.1", - "@vitest/coverage-v8": "^2.1.8", + "@codspeed/vitest-plugin": "^5.7.1", + "@vitest/coverage-v8": "^3.2.4", "dotenv": "^14.1.0", "lerna": "^8.0.0", - "vitest": "^2.1.8" + "vitest": "^3.2.4", + "playwright-core": "^1.49.0" }, "engines": { "node": ">=18" }, "scripts": { "build:all": "lerna run build:ci --parallel --stream", + "test": "vitest run", "test:all": "lerna run test:ci --parallel --stream", "bench": "lerna run bench --parallel --stream", "bench:ci": "lerna run bench --parallel --stream", diff --git a/packages/big-endian/bench/decode.bench.js b/packages/big-endian/bench/decode.bench.js index 2ce4ec6..152d3ae 100644 --- a/packages/big-endian/bench/decode.bench.js +++ b/packages/big-endian/bench/decode.bench.js @@ -20,8 +20,14 @@ describe("big-endian decode (byte-swap)", () => { decode({ bitsAllocated: 16, pixelRepresentation: 1 }, data16) }) + // The 16-bit benches above do real per-pixel swap work and are left + // unbatched. The 8-bit path is a bare property assignment, so a single + // call is swamped by fixed harness overhead and cache-model variation + // across runner CPUs; batch x100 so the body is measurable. const data8 = makeBuffer(SIZE_512x512) - bench("8-bit passthrough, 512x512", () => { - decode({ bitsAllocated: 8 }, data8) + bench("8-bit passthrough, 512x512 x100", () => { + for (let i = 0; i < 100; i++) { + decode({ bitsAllocated: 8 }, data8) + } }) }) diff --git a/packages/big-endian/vitest.config.mjs b/packages/big-endian/vitest.config.mjs index 70ce595..649a3ec 100644 --- a/packages/big-endian/vitest.config.mjs +++ b/packages/big-endian/vitest.config.mjs @@ -4,6 +4,18 @@ import codspeedPlugin from "@codspeed/vitest-plugin" export default defineConfig({ plugins: [codspeedPlugin()], test: { + // Under the CodSpeed simulation instrument the entire process runs ~60x + // slower under valgrind while vitest's hard-coded 60s worker-RPC timer + // counts real seconds, so large bench suites structurally hit "Timeout + // calling onTaskUpdate" AFTER their benches complete and upload. Ignore + // that exit-code noise in simulation only; walltime and test runs stay + // strict. + // (CODSPEED_ENV is set whenever the CodSpeed runner is active; the + // mode string is "instrumentation" on older runners and "simulation" + // on newer ones, so match anything except walltime.) + dangerouslyIgnoreUnhandledErrors: + process.env.CODSPEED_ENV !== undefined && + process.env.CODSPEED_RUNNER_MODE !== "walltime", name: "big-endian", include: ["test/**/*.test.js"], benchmark: { diff --git a/packages/charls/bench/decode.bench.js b/packages/charls/bench/decode.bench.js index d0f888c..372b4b4 100644 --- a/packages/charls/bench/decode.bench.js +++ b/packages/charls/bench/decode.bench.js @@ -59,7 +59,9 @@ let coldEnc let warmEnc if (!skip) { const factory = (await import(distPath)).default ?? (await import(distPath)) - codec = await factory() + // Silence wasm stdout/stderr so vitest's console interception never runs + // inside a measured bench body (see openjphjs bench for details). + codec = await factory({ print: () => {}, printErr: () => {} }) // Cold instances: one per fixture, constructed but never decoded. // The bench body will be the first decode call on this instance. @@ -86,14 +88,21 @@ if (!skip) { } describe.skipIf(skip)("charls JPEG-LS (wasm)", () => { - bench("instantiate+destroy JpegLSDecoder", () => { - const d = new codec.JpegLSDecoder() - d.delete() + // Batched x50: a single instantiate+destroy is ~60 µs, where fixed harness + // overhead and cache-model variation across runner CPUs dominate; the loop + // puts the body in the ms range so the codec work is the signal. + bench("instantiate+destroy JpegLSDecoder x50", () => { + for (let i = 0; i < 50; i++) { + const d = new codec.JpegLSDecoder() + d.delete() + } }) - bench("instantiate+destroy JpegLSEncoder", () => { - const e = new codec.JpegLSEncoder() - e.delete() + bench("instantiate+destroy JpegLSEncoder x50", () => { + for (let i = 0; i < 50; i++) { + const e = new codec.JpegLSEncoder() + e.delete() + } }) bench("decode CT1.JLS (.80 lossless, 512x512x16bit) — cold", () => { diff --git a/packages/charls/test/decode.test.js b/packages/charls/test/decode.test.js index 9817e0d..aa275ff 100644 --- a/packages/charls/test/decode.test.js +++ b/packages/charls/test/decode.test.js @@ -8,14 +8,24 @@ const distDir = resolve(__dirname, "../dist") const fixturesDir = resolve(__dirname, "fixtures") const ct1Encoded = readFileSync(resolve(fixturesDir, "CT1.JLS")) +// CT1.RAW is the lossless reference for CT1.JLS. The same slice ships as +// openjpeg's CT1.RAW and openjphjs's CT1.RAW; charls, openjpeg and openjph +// all decode their CT1 fixtures to these exact bytes, so the three codecs +// cross-validate each other. +const ct1Raw = readFileSync(resolve(fixturesDir, "CT1.RAW")) const ct2Encoded = readFileSync(resolve(fixturesDir, "CT2.JLS")) const ct2Raw = readFileSync(resolve(fixturesDir, "CT2.RAW")) // CT-512x512-near-lossless.JLS is a real .81 (JPEG-LS Lossy / Near-Lossless) // payload extracted from a Cornerstone3D test DICOM. Decoding exercises the // same charls codec but verifies the near-lossless code path (NEAR > 0). +// Near-lossless decoding is deterministic, so the decoder's own output is +// pinned as a regression golden (identical across all three build variants). const ctNearLossless = readFileSync( resolve(fixturesDir, "CT-512x512-near-lossless.JLS") ) +const ctNearLosslessRaw = readFileSync( + resolve(fixturesDir, "CT-512x512-near-lossless.RAW") +) async function loadModule(modulePath) { const mod = await import(modulePath) @@ -37,11 +47,17 @@ describe.each(buildVariants)("charls JPEG-LS decode — $name", ({ path, dist }) if (isBuilt) codec = await loadModule(path) }) + // In CI a missing dist means the build/artifact pipeline broke; fail loudly + // instead of letting every skipIf() below silently skip the suite. + it.runIf(process.env.CI)("dist is present in CI", () => { + expect(isBuilt, `${dist} missing — build artifact was not replayed`).toBe(true) + }) + it.skipIf(!isBuilt)("exposes a version string", () => { expect(typeof codec.getVersion()).toBe("string") }) - it.skipIf(!isBuilt)("decodes CT1.JLS to a 512x512 16-bit monochrome frame", () => { + it.skipIf(!isBuilt)("decodes CT1.JLS to bytes matching CT1.RAW (lossless)", () => { const decoder = new codec.JpegLSDecoder() decoder.getEncodedBuffer(ct1Encoded.length).set(ct1Encoded) decoder.decode() @@ -53,7 +69,8 @@ describe.each(buildVariants)("charls JPEG-LS decode — $name", ({ path, dist }) expect(frameInfo.componentCount).toBe(1) const decoded = decoder.getDecodedBuffer() - expect(decoded.length).toBe(512 * 512 * 2) + expect(decoded.length).toBe(ct1Raw.length) + expect(Buffer.from(decoded).equals(ct1Raw)).toBe(true) decoder.delete() }) @@ -71,7 +88,7 @@ describe.each(buildVariants)("charls JPEG-LS decode — $name", ({ path, dist }) }) it.skipIf(!isBuilt)( - "decodes a near-lossless CT JLS (transfer syntax .81)", + "decodes a near-lossless CT JLS (transfer syntax .81) matching the pinned golden output", () => { const decoder = new codec.JpegLSDecoder() decoder.getEncodedBuffer(ctNearLossless.length).set(ctNearLossless) @@ -84,7 +101,8 @@ describe.each(buildVariants)("charls JPEG-LS decode — $name", ({ path, dist }) expect(frameInfo.componentCount).toBe(1) const decoded = decoder.getDecodedBuffer() - expect(decoded.length).toBe(512 * 512 * 2) + expect(decoded.length).toBe(ctNearLosslessRaw.length) + expect(Buffer.from(decoded).equals(ctNearLosslessRaw)).toBe(true) decoder.delete() } ) @@ -114,8 +132,11 @@ describe.each(encoderVariants)( encoder.setNearLossless(0) encoder.encode() const encoded = encoder.getEncodedBuffer() - expect(encoded.length).toBeGreaterThan(0) - expect(encoded.length).toBeLessThan(ct2Raw.length) + // Compression-quality regression bounds. Measured 115504 bytes on + // 2026-07-07 (emsdk 3.1.74): the ceiling catches an encoder that + // regresses compression ratio; the floor catches silent truncation. + expect(encoded.length).toBeGreaterThan(115504 * 0.5) + expect(encoded.length).toBeLessThan(115504 * 1.10) const decoder = new codec.JpegLSDecoder() decoder.getEncodedBuffer(encoded.length).set(encoded) diff --git a/packages/charls/test/fixtures/CT-512x512-near-lossless.RAW b/packages/charls/test/fixtures/CT-512x512-near-lossless.RAW new file mode 100644 index 0000000..f71d752 Binary files /dev/null and b/packages/charls/test/fixtures/CT-512x512-near-lossless.RAW differ diff --git a/packages/charls/test/fixtures/CT1.RAW b/packages/charls/test/fixtures/CT1.RAW new file mode 100644 index 0000000..92cca6a Binary files /dev/null and b/packages/charls/test/fixtures/CT1.RAW differ diff --git a/packages/charls/test/fixtures/CT2-gray16u.jls b/packages/charls/test/fixtures/CT2-gray16u.jls new file mode 100644 index 0000000..31e5c05 Binary files /dev/null and b/packages/charls/test/fixtures/CT2-gray16u.jls differ diff --git a/packages/charls/test/fixtures/CT2-gray8.jls b/packages/charls/test/fixtures/CT2-gray8.jls new file mode 100644 index 0000000..d21cfc8 Binary files /dev/null and b/packages/charls/test/fixtures/CT2-gray8.jls differ diff --git a/packages/charls/test/fixtures/US1-color-ilv-sample.jls b/packages/charls/test/fixtures/US1-color-ilv-sample.jls new file mode 100644 index 0000000..d1ae830 Binary files /dev/null and b/packages/charls/test/fixtures/US1-color-ilv-sample.jls differ diff --git a/packages/charls/test/heap-stability.test.js b/packages/charls/test/heap-stability.test.js new file mode 100644 index 0000000..6245a8f --- /dev/null +++ b/packages/charls/test/heap-stability.test.js @@ -0,0 +1,87 @@ +import { beforeAll, describe, expect, it } from "vitest" +import { existsSync, readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { dirname, resolve } from "node:path" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const distDir = resolve(__dirname, "../dist") +const fixturesDir = resolve(__dirname, "fixtures") + +const isBuilt = existsSync(resolve(distDir, "charlswasm.js")) + +async function loadModule(path) { + const mod = await import(path) + const factory = mod.default ?? mod + return await factory() +} + +// Emscripten heaps grow but never shrink, so a native-side leak shows up as +// monotonic HEAP8 growth across repeated use — invisible to single-decode +// tests, an OOM after thousands of frames in a viewer. This repo has a +// history of exactly that class of bug (error-path instance leaks, encoder +// handle leaks). +// +// Loop sizing matters: the arena starts at 50 MiB (INITIAL_MEMORY) with +// ALLOW_MEMORY_GROWTH, so a leak only becomes visible once the leaked +// instances exceed the arena's free slack. Measured: 100 leaked decoder +// instances grow HEAP8 by ~22 MiB — comfortably detectable — while 100 +// correct decode/delete cycles leave capacity byte-identical. (Verified by +// deleting the delete() call: the assertion fails.) +describe("charls wasm heap stability", { timeout: 120000 }, () => { + let codec + const encoded = readFileSync(resolve(fixturesDir, "CT1.JLS")) + const raw = readFileSync(resolve(fixturesDir, "CT2.RAW")) + + beforeAll(async () => { + if (isBuilt) codec = await loadModule("../dist/charlswasm.js") + }) + + it.skipIf(!isBuilt)("repeated decode/delete cycles do not grow the heap", () => { + const decodeOnce = () => { + const decoder = new codec.JpegLSDecoder() + decoder.getEncodedBuffer(encoded.length).set(encoded) + decoder.decode() + decoder.delete() + } + for (let i = 0; i < 10; i++) decodeOnce() + const settled = codec.HEAP8.length + for (let i = 0; i < 100; i++) decodeOnce() + expect(codec.HEAP8.length).toBe(settled) + }) + + it.skipIf(!isBuilt)("repeated encode/delete cycles do not grow the heap", () => { + const encodeOnce = () => { + const encoder = new codec.JpegLSEncoder() + encoder.getDecodedBuffer({ width: 512, height: 512, bitsPerSample: 16, componentCount: 1 }).set(raw) + encoder.setNearLossless(0) + encoder.encode() + encoder.delete() + } + for (let i = 0; i < 10; i++) encodeOnce() + const settled = codec.HEAP8.length + for (let i = 0; i < 60; i++) encodeOnce() + expect(codec.HEAP8.length).toBe(settled) + }) + + it.skipIf(!isBuilt)("repeated failing decodes do not grow the heap", () => { + // Garbage (not truncated-after-valid-header) input: fails fast at + // header parse instead of spending seconds in sample recovery, so the + // loop can be large enough for a leak to exceed the arena slack. + const garbage = new Uint8Array(64) + for (let i = 0; i < garbage.length; i++) garbage[i] = (i * 37 + 11) % 256 + const failOnce = () => { + const decoder = new codec.JpegLSDecoder() + decoder.getEncodedBuffer(garbage.length).set(garbage) + try { + decoder.decode() + } catch { + // expected for malformed input + } + decoder.delete() + } + for (let i = 0; i < 10; i++) failOnce() + const settled = codec.HEAP8.length + for (let i = 0; i < 100; i++) failOnce() + expect(codec.HEAP8.length).toBe(settled) + }) +}) diff --git a/packages/charls/test/matrix.test.js b/packages/charls/test/matrix.test.js new file mode 100644 index 0000000..a09972f --- /dev/null +++ b/packages/charls/test/matrix.test.js @@ -0,0 +1,126 @@ +import { beforeAll, describe, expect, it } from "vitest" +import { createHash } from "node:crypto" +import { existsSync, readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { dirname, resolve } from "node:path" +import { gray8FromCT2, gray16uFromCT2 } from "../../../tools/fixture-verification/gen/derive.mjs" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const distDir = resolve(__dirname, "../dist") +const fixturesDir = resolve(__dirname, "fixtures") + +const isBuilt = existsSync(resolve(distDir, "charlsjs.js")) + +async function loadModule(path) { + const mod = await import(path) + const factory = mod.default ?? mod + return await factory() +} + +const asBuffer = (ta) => Buffer.from(ta.buffer, ta.byteOffset, ta.byteLength) + +// Color and bit-depth coverage beyond the 16-bit signed CT profile that +// decode.test.js pins. +// +// Fixture provenance (tools/fixture-verification/gen/generate-fixtures.mjs): +// - US1-color-ilv-sample.jls: US1.RAW (640x480 interleaved RGB ultrasound +// frame shipped in the openjpeg package) encoded losslessly with +// interleave mode 2 (sample). Lossless => the reference is the source +// itself. Independently verified byte-identical by pylibjpeg-libjpeg +// (Thomas Richter's libjpeg — a JPEG-LS implementation independent of +// CharLS) on 2026-07-07. +// - CT2-gray8.jls / CT2-gray16u.jls: deterministic transforms of CT2.RAW +// (see derive.mjs), encoded losslessly; tests re-derive the reference. +// - SC1.JLS: shipped 12-bit fixture; decoded output verified byte-exact by +// pylibjpeg-libjpeg and cross-codec against openjphjs/openjpeg SC1 +// encodings, pinned by SHA-256. +describe("charls JPEG-LS decode matrix — color and bit depths", () => { + let codec + const us1 = readFileSync(resolve(__dirname, "../../openjpeg/test/fixtures/raw/US1.RAW")) + const ct2 = readFileSync(resolve(fixturesDir, "CT2.RAW")) + + beforeAll(async () => { + if (isBuilt) codec = await loadModule("../dist/charlsjs.js") + }) + + it.runIf(process.env.CI)("dist is present in CI", () => { + expect(isBuilt, "charlsjs.js missing — build artifact was not replayed").toBe(true) + }) + + const decode = (file) => { + const encoded = readFileSync(resolve(fixturesDir, file)) + const decoder = new codec.JpegLSDecoder() + decoder.getEncodedBuffer(encoded.length).set(encoded) + decoder.decode() + const frameInfo = decoder.getFrameInfo() + const out = Buffer.from(decoder.getDecodedBuffer()) + decoder.delete() + return { frameInfo, out } + } + + it.skipIf(!isBuilt)("decodes 3-component interleaved color losslessly (ILV=sample)", () => { + const { frameInfo, out } = decode("US1-color-ilv-sample.jls") + expect(frameInfo.componentCount).toBe(3) + expect(frameInfo.width).toBe(640) + expect(frameInfo.height).toBe(480) + expect(frameInfo.bitsPerSample).toBe(8) + expect(out.equals(us1)).toBe(true) + }) + + it.skipIf(!isBuilt)("decodes 8-bit grayscale losslessly", () => { + const { frameInfo, out } = decode("CT2-gray8.jls") + expect(frameInfo.bitsPerSample).toBe(8) + expect(out.equals(asBuffer(gray8FromCT2(ct2)))).toBe(true) + }) + + it.skipIf(!isBuilt)("decodes 16-bit unsigned losslessly", () => { + const { frameInfo, out } = decode("CT2-gray16u.jls") + expect(frameInfo.bitsPerSample).toBe(16) + expect(out.equals(asBuffer(gray16uFromCT2(ct2)))).toBe(true) + }) + + it.skipIf(!isBuilt)("near-lossless encode honors the spec error bound (maxAbsDiff <= NEAR)", () => { + // T.87 guarantees every reconstructed sample is within NEAR of the + // source — an exact spec bound, not a heuristic tolerance. + for (const near of [1, 2, 3]) { + const encoder = new codec.JpegLSEncoder() + encoder.getDecodedBuffer({ width: 512, height: 512, bitsPerSample: 16, componentCount: 1 }).set(ct2) + encoder.setNearLossless(near) + encoder.encode() + const encoded = Buffer.from(encoder.getEncodedBuffer()) + encoder.delete() + + const decoder = new codec.JpegLSDecoder() + decoder.getEncodedBuffer(encoded.length).set(encoded) + decoder.decode() + const out = Buffer.from(decoder.getDecodedBuffer()) + decoder.delete() + + const src16 = new Int16Array(ct2.buffer, ct2.byteOffset, ct2.length / 2) + const out16 = new Int16Array(out.buffer, out.byteOffset, out.length / 2) + let maxAbsDiff = 0 + for (let i = 0; i < src16.length; i++) { + const diff = Math.abs(out16[i] - src16[i]) + if (diff > maxAbsDiff) maxAbsDiff = diff + } + expect(maxAbsDiff).toBeLessThanOrEqual(near) + expect(maxAbsDiff).toBeGreaterThan(0) // actually lossy, not accidentally lossless + } + }) + + it.skipIf(!isBuilt)("decodes the shipped 12-bit SC1 fixture to the pinned pixels", () => { + const { frameInfo, out } = decode("SC1.JLS") + expect(frameInfo.width).toBe(2048) + expect(frameInfo.height).toBe(2487) + expect(frameInfo.bitsPerSample).toBe(12) + expect(frameInfo.componentCount).toBe(1) + // Golden pinned by hash (a 10 MB RAW is not worth committing). Verified + // three ways (2026-07-07): pylibjpeg-libjpeg (independent JPEG-LS + // implementation) agrees on all 5,093,376 samples, and the same image's + // HTJ2K (SC1.j2c) and J2K (SC1.j2k) encodings decode to these exact + // bytes in openjphjs and openjpeg — three codecs, one pixel truth. + expect(createHash("sha256").update(out).digest("hex")).toBe( + "1c8e43cef2a3b25b5304c3dd1732e64c2f44d05d342387ea8e15ce01ec793c32" + ) + }) +}) diff --git a/packages/charls/vitest.config.mjs b/packages/charls/vitest.config.mjs index 50bd901..0cdf45b 100644 --- a/packages/charls/vitest.config.mjs +++ b/packages/charls/vitest.config.mjs @@ -4,6 +4,18 @@ import codspeedPlugin from "@codspeed/vitest-plugin" export default defineConfig({ plugins: [codspeedPlugin()], test: { + // Under the CodSpeed simulation instrument the entire process runs ~60x + // slower under valgrind while vitest's hard-coded 60s worker-RPC timer + // counts real seconds, so large bench suites structurally hit "Timeout + // calling onTaskUpdate" AFTER their benches complete and upload. Ignore + // that exit-code noise in simulation only; walltime and test runs stay + // strict. + // (CODSPEED_ENV is set whenever the CodSpeed runner is active; the + // mode string is "instrumentation" on older runners and "simulation" + // on newer ones, so match anything except walltime.) + dangerouslyIgnoreUnhandledErrors: + process.env.CODSPEED_ENV !== undefined && + process.env.CODSPEED_RUNNER_MODE !== "walltime", name: "charls", include: ["test/**/*.test.js"], benchmark: { diff --git a/packages/dicom-codec/bench/dispatch.bench.js b/packages/dicom-codec/bench/dispatch.bench.js index 3e368fa..03ae8d3 100644 --- a/packages/dicom-codec/bench/dispatch.bench.js +++ b/packages/dicom-codec/bench/dispatch.bench.js @@ -105,3 +105,20 @@ describe.skipIf(skip)("dicom-codec dispatch", () => { }) } }) + +describe.skipIf(skip)("dicom-codec encode/transcode dispatch", () => { + const ct1Raw = skip ? null : read("openjpeg/test/fixtures/raw/CT1.RAW") + const ct1Jls = skip ? null : read("charls/test/fixtures/CT1.JLS") + + bench("encode to JPEG-LS Lossless (.80)", async () => { + await dicomCodec.encode(new Uint8Array(ct1Raw), ctSigned512, "1.2.840.10008.1.2.4.80") + }) + + bench("encode to JPEG 2000 Lossless (.90)", async () => { + await dicomCodec.encode(new Uint8Array(ct1Raw), ctSigned512, "1.2.840.10008.1.2.4.90") + }) + + bench("transcode JPEG-LS -> J2K (.80 -> .90)", async () => { + await dicomCodec.transcode(ct1Jls, ctSigned512, "1.2.840.10008.1.2.4.80", "1.2.840.10008.1.2.4.90") + }) +}) diff --git a/packages/dicom-codec/test/color-and-depth.test.js b/packages/dicom-codec/test/color-and-depth.test.js new file mode 100644 index 0000000..00d9c0d --- /dev/null +++ b/packages/dicom-codec/test/color-and-depth.test.js @@ -0,0 +1,75 @@ +import { beforeAll, describe, expect, it } from "vitest" +import { existsSync, readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { dirname, resolve } from "node:path" +import { gray8FromCT2 } from "../../../tools/fixture-verification/gen/derive.mjs" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const packagesRoot = resolve(__dirname, "../..") + +const REQUIRED = [ + "libjpeg-turbo-8bit/dist/libjpegturbojs.js", + "charls/dist/charlsjs.js", +] +const ALL_BUILT = REQUIRED.every((p) => existsSync(resolve(packagesRoot, p))) + +function frameBytes(imageFrame) { + return Buffer.from(imageFrame.buffer, imageFrame.byteOffset ?? 0, imageFrame.byteLength) +} + +it.runIf(process.env.CI)("sibling codec dists are present in CI (color/depth suite)", () => { + expect(ALL_BUILT, "codec dists missing — artifacts not replayed").toBe(true) +}) + +// Color and non-16-bit paths through the dispatcher. Every reference is +// either a DCMTK-verified golden (color JPEG), the lossless source itself +// (RLE, JLS), or a deterministic derivation (derive.mjs). +describe.skipIf(!ALL_BUILT)("dicom-codec color and bit-depth dispatch", () => { + let dicomCodec + const us1 = readFileSync(resolve(packagesRoot, "openjpeg/test/fixtures/raw/US1.RAW")) + + beforeAll(async () => { + const mod = await import("../src/index.js") + dicomCodec = mod.default ?? mod + }) + + it("decodes a color 4:2:0 JPEG (.50, samplesPerPixel 3) to the DCMTK-verified pixels", async () => { + const jpegBytes = readFileSync( + resolve(packagesRoot, "libjpeg-turbo-8bit/test/fixtures/jpeg/US1-color-420.jpg") + ) + const golden = readFileSync( + resolve(packagesRoot, "libjpeg-turbo-8bit/test/fixtures/raw/US1-color-420.raw") + ) + const result = await dicomCodec.decode( + jpegBytes, + { rows: 480, columns: 640, bitsAllocated: 8, samplesPerPixel: 3, pixelRepresentation: 0, signed: false }, + "1.2.840.10008.1.2.4.50" + ) + expect(result.imageFrame.byteLength).toBe(640 * 480 * 3) + expect(frameBytes(result.imageFrame).equals(golden)).toBe(true) + }) + + it("decodes color RLE (3 segments) interleaved when planarConfiguration is 0", async () => { + const rleBytes = readFileSync( + resolve(packagesRoot, "dicom-codec/test/fixtures/rle/US1-color.rle") + ) + const result = await dicomCodec.decode( + new Uint8Array(rleBytes), + { rows: 480, columns: 640, bitsAllocated: 8, samplesPerPixel: 3, planarConfiguration: 0 }, + "1.2.840.10008.1.2.5" + ) + expect(frameBytes(result.imageFrame).equals(us1)).toBe(true) + }) + + it("decodes an 8-bit JPEG-LS (.80) through the dispatcher losslessly", async () => { + const jls = readFileSync(resolve(packagesRoot, "charls/test/fixtures/CT2-gray8.jls")) + const ct2 = readFileSync(resolve(packagesRoot, "charls/test/fixtures/CT2.RAW")) + const expected = gray8FromCT2(ct2) + const result = await dicomCodec.decode( + jls, + { rows: 512, columns: 512, bitsAllocated: 8, samplesPerPixel: 1, pixelRepresentation: 0, signed: false }, + "1.2.840.10008.1.2.4.80" + ) + expect(frameBytes(result.imageFrame).equals(Buffer.from(expected.buffer, 0, expected.byteLength))).toBe(true) + }) +}) diff --git a/packages/dicom-codec/test/dispatch.test.js b/packages/dicom-codec/test/dispatch.test.js index 8f58634..1169a57 100644 --- a/packages/dicom-codec/test/dispatch.test.js +++ b/packages/dicom-codec/test/dispatch.test.js @@ -37,6 +37,15 @@ const SUPPORTED_UIDS = [ "1.2.840.10008.1.2.5", ] +// In CI a missing sibling dist means the build/artifact pipeline broke; fail +// loudly instead of letting describe.skipIf() silently skip the whole suite. +it.runIf(process.env.CI)("required sibling builds are present in CI", () => { + const missing = REQUIRED_BUILDS.filter( + (p) => !existsSync(resolve(packagesRoot, p)) + ) + expect(missing, `missing dists: ${missing.join(", ")}`).toEqual([]) +}) + describe.skipIf(!ALL_BUILT)("dicom-codec dispatcher", () => { let dicomCodec diff --git a/packages/dicom-codec/test/fixtures/raw/CT-512x512.raw b/packages/dicom-codec/test/fixtures/raw/CT-512x512.raw new file mode 100644 index 0000000..7b44b87 Binary files /dev/null and b/packages/dicom-codec/test/fixtures/raw/CT-512x512.raw differ diff --git a/packages/dicom-codec/test/fixtures/rle/US1-color.rle b/packages/dicom-codec/test/fixtures/rle/US1-color.rle new file mode 100644 index 0000000..343e959 Binary files /dev/null and b/packages/dicom-codec/test/fixtures/rle/US1-color.rle differ diff --git a/packages/dicom-codec/test/integration.test.js b/packages/dicom-codec/test/integration.test.js index b931898..8f21b36 100644 --- a/packages/dicom-codec/test/integration.test.js +++ b/packages/dicom-codec/test/integration.test.js @@ -22,6 +22,22 @@ const OPENJPH_BUILT = existsSync( const ALL_BUILT = LIBJPEG_8BIT_BUILT && CHARLS_BUILT && OPENJPEG_BUILT && OPENJPH_BUILT +// Byte view over a decoded imageFrame regardless of its typed-array flavor +// (Uint8Array, Uint16Array, Int16Array, ...). +function frameBytes(imageFrame) { + return Buffer.from( + imageFrame.buffer, + imageFrame.byteOffset ?? 0, + imageFrame.byteLength + ) +} + +// In CI a missing sibling dist means the build/artifact pipeline broke; fail +// loudly instead of letting describe.skipIf() silently skip the whole suite. +it.runIf(process.env.CI)("sibling codec dists are present in CI", () => { + expect(ALL_BUILT, "one or more codec dists missing — artifacts not replayed").toBe(true) +}) + describe.skipIf(!ALL_BUILT)("dicom-codec integration", () => { let dicomCodec @@ -37,8 +53,11 @@ describe.skipIf(!ALL_BUILT)("dicom-codec integration", () => { "libjpeg-turbo-8bit/test/fixtures/jpeg/jpeg400jfif.jpg" ) ) + const jpegRaw = readFileSync( + resolve(packagesRoot, "libjpeg-turbo-8bit/test/fixtures/raw/jpeg400jfif.raw") + ) - it("decodes through the dispatcher", async () => { + it("decodes through the dispatcher to the exact reference pixels", async () => { const imageInfo = { rows: 800, columns: 600, @@ -55,6 +74,7 @@ describe.skipIf(!ALL_BUILT)("dicom-codec integration", () => { ) expect(result.imageFrame.byteLength).toBe(600 * 800) + expect(frameBytes(result.imageFrame).equals(jpegRaw)).toBe(true) expect(result.imageInfo.width).toBe(600) expect(result.imageInfo.height).toBe(800) expect(typeof result.processInfo.duration).toBe("number") @@ -65,8 +85,11 @@ describe.skipIf(!ALL_BUILT)("dicom-codec integration", () => { const jlsBytes = readFileSync( resolve(packagesRoot, "charls/test/fixtures/CT1.JLS") ) + const jlsRaw = readFileSync( + resolve(packagesRoot, "charls/test/fixtures/CT1.RAW") + ) - it("decodes through the dispatcher", async () => { + it("decodes through the dispatcher to the exact reference pixels", async () => { const imageInfo = { rows: 512, columns: 512, @@ -83,6 +106,7 @@ describe.skipIf(!ALL_BUILT)("dicom-codec integration", () => { ) expect(result.imageFrame.byteLength).toBe(512 * 512 * 2) + expect(frameBytes(result.imageFrame).equals(jlsRaw)).toBe(true) expect(result.imageInfo.width).toBe(512) expect(result.imageInfo.height).toBe(512) }) @@ -92,8 +116,11 @@ describe.skipIf(!ALL_BUILT)("dicom-codec integration", () => { const j2kBytes = readFileSync( resolve(packagesRoot, "openjpeg/test/fixtures/j2k/CT1.j2k") ) + const j2kRaw = readFileSync( + resolve(packagesRoot, "openjpeg/test/fixtures/raw/CT1.RAW") + ) - it("decodes through the dispatcher", async () => { + it("decodes through the dispatcher to the exact reference pixels", async () => { const imageInfo = { rows: 512, columns: 512, @@ -110,6 +137,7 @@ describe.skipIf(!ALL_BUILT)("dicom-codec integration", () => { ) expect(result.imageFrame.byteLength).toBe(512 * 512 * 2) + expect(frameBytes(result.imageFrame).equals(j2kRaw)).toBe(true) expect(result.imageInfo.width).toBe(512) expect(result.imageInfo.height).toBe(512) }) @@ -119,8 +147,11 @@ describe.skipIf(!ALL_BUILT)("dicom-codec integration", () => { const j2cBytes = readFileSync( resolve(packagesRoot, "openjphjs/test/fixtures/j2c/CT1.j2c") ) + const j2cRaw = readFileSync( + resolve(packagesRoot, "openjphjs/test/fixtures/raw/CT1.RAW") + ) - it("decodes through the dispatcher", async () => { + it("decodes through the dispatcher to the exact reference pixels", async () => { const imageInfo = { rows: 512, columns: 512, @@ -137,6 +168,7 @@ describe.skipIf(!ALL_BUILT)("dicom-codec integration", () => { ) expect(result.imageFrame.byteLength).toBe(512 * 512 * 2) + expect(frameBytes(result.imageFrame).equals(j2cRaw)).toBe(true) expect(result.imageInfo.width).toBe(512) expect(result.imageInfo.height).toBe(512) }) @@ -145,7 +177,10 @@ describe.skipIf(!ALL_BUILT)("dicom-codec integration", () => { describe("JPEG Lossless (1.2.840.10008.1.2.4.57 / .70)", () => { // These go through dicom-codec's internal jpegLosslessCodec // (jpeg-lossless-decoder-js, pure JS — no separate wasm package). Both - // fixtures decode the same 512x512x16 CT slice. + // fixtures encode the same 512x512x16 CT slice; the reference + // fixtures/raw/CT-512x512.raw was cross-validated three ways: the RLE + // decoder, the Process 14 path of jpeg-lossless-decoder-js and DCMTK's + // dcmdjpeg all produce these exact bytes. const jpllProcess14 = readFileSync( resolve( packagesRoot, @@ -158,6 +193,9 @@ describe.skipIf(!ALL_BUILT)("dicom-codec integration", () => { "dicom-codec/test/fixtures/jpeg-lossless/CT-512x512-process14-sv1.jpll" ) ) + const ctRaw = readFileSync( + resolve(packagesRoot, "dicom-codec/test/fixtures/raw/CT-512x512.raw") + ) const ctImageInfo = { rows: 512, @@ -168,32 +206,59 @@ describe.skipIf(!ALL_BUILT)("dicom-codec integration", () => { signed: true, } - it("decodes Process 14 through the dispatcher (.57)", async () => { + it("decodes Process 14 through the dispatcher (.57) to the exact reference pixels", async () => { const result = await dicomCodec.decode( jpllProcess14, ctImageInfo, "1.2.840.10008.1.2.4.57" ) - expect(result.imageFrame.byteLength).toBe(512 * 512 * 2) + expect(result.imageFrame.byteLength).toBe(ctRaw.length) + expect(frameBytes(result.imageFrame).equals(ctRaw)).toBe(true) }) - it("decodes Process 14 SV1 through the dispatcher (.70)", async () => { + // KNOWN UPSTREAM BUG (jpeg-lossless-decoder-js): the SV1 path decodes + // the final pixel of this fixture as 0 instead of -2000. DCMTK's + // dcmdjpeg confirms the fixture itself is correct (its decode matches + // CT-512x512.raw exactly, last pixel included), so the defect is in the + // JS decoder. The test below pins today's behavior: every sample except + // the last matches the reference. When the upstream bug is fixed, the + // paired `it.fails` test starts passing and vitest will flag it — then + // fold these two tests into a single exact comparison. + it("decodes Process 14 SV1 through the dispatcher (.70) — all but the last pixel match", async () => { const result = await dicomCodec.decode( jpllProcess14Sv1, ctImageInfo, "1.2.840.10008.1.2.4.70" ) - expect(result.imageFrame.byteLength).toBe(512 * 512 * 2) + expect(result.imageFrame.byteLength).toBe(ctRaw.length) + + const actual = frameBytes(result.imageFrame) + // Everything up to the final 16-bit sample must match exactly. + expect(actual.subarray(0, ctRaw.length - 2).equals(ctRaw.subarray(0, ctRaw.length - 2))).toBe(true) + }) + + it.fails("decodes Process 14 SV1 (.70) to the exact reference pixels (known upstream last-pixel bug)", async () => { + const result = await dicomCodec.decode( + jpllProcess14Sv1, + ctImageInfo, + "1.2.840.10008.1.2.4.70" + ) + expect(frameBytes(result.imageFrame).equals(ctRaw)).toBe(true) }) }) describe("RLE Lossless (1.2.840.10008.1.2.5)", () => { - // Routed to dicom-codec's internal rleLossless.js (pure JS). + // Routed to dicom-codec's internal rleLossless.js (pure JS). The RAW + // reference is cross-validated: the JPEG Lossless Process 14 decode of + // the same slice and DCMTK both produce these exact bytes. const rleBytes = readFileSync( resolve(packagesRoot, "dicom-codec/test/fixtures/rle/CT-512x512.rle") ) + const ctRaw = readFileSync( + resolve(packagesRoot, "dicom-codec/test/fixtures/raw/CT-512x512.raw") + ) - it("decodes through the dispatcher", async () => { + it("decodes through the dispatcher to the exact reference pixels", async () => { const imageInfo = { rows: 512, columns: 512, @@ -209,7 +274,8 @@ describe.skipIf(!ALL_BUILT)("dicom-codec integration", () => { "1.2.840.10008.1.2.5" ) - expect(result.imageFrame.byteLength).toBe(512 * 512 * 2) + expect(result.imageFrame.byteLength).toBe(ctRaw.length) + expect(frameBytes(result.imageFrame).equals(ctRaw)).toBe(true) }) }) }) diff --git a/packages/dicom-codec/test/transcode-and-pixeldata.test.js b/packages/dicom-codec/test/transcode-and-pixeldata.test.js new file mode 100644 index 0000000..e2fef74 --- /dev/null +++ b/packages/dicom-codec/test/transcode-and-pixeldata.test.js @@ -0,0 +1,77 @@ +import { beforeAll, describe, expect, it } from "vitest" +import { existsSync, readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { dirname, resolve } from "node:path" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const packagesRoot = resolve(__dirname, "../..") + +const REQUIRED = [ + "charls/dist/charlsjs.js", +] +const ALL_BUILT = REQUIRED.every((p) => existsSync(resolve(packagesRoot, p))) + +function frameBytes(imageFrame) { + return Buffer.from(imageFrame.buffer, imageFrame.byteOffset ?? 0, imageFrame.byteLength) +} + +it.runIf(process.env.CI)("sibling codec dists are present in CI (transcode suite)", () => { + expect(ALL_BUILT, "codec dists missing — artifacts not replayed").toBe(true) +}) + +describe.skipIf(!ALL_BUILT)("dicom-codec encode", () => { + let dicomCodec + const ct1Raw = readFileSync(resolve(packagesRoot, "openjpeg/test/fixtures/raw/CT1.RAW")) + const ctImageInfo = { + rows: 512, + columns: 512, + bitsAllocated: 16, + samplesPerPixel: 1, + pixelRepresentation: 1, + signed: true, + } + + beforeAll(async () => { + const mod = await import("../src/index.js") + dicomCodec = mod.default ?? mod + }) + + it("encode() to JPEG-LS Lossless (.80) round-trips byte-exact", async () => { + const encoded = await dicomCodec.encode(new Uint8Array(ct1Raw), ctImageInfo, "1.2.840.10008.1.2.4.80") + const decoded = await dicomCodec.decode(encoded.imageFrame, ctImageInfo, "1.2.840.10008.1.2.4.80") + expect(frameBytes(decoded.imageFrame).equals(ct1Raw)).toBe(true) + }) +}) + +describe.skipIf(!ALL_BUILT)("dicom-codec getPixelData typed-array contract", () => { + let dicomCodec + const ct1Jls = readFileSync(resolve(packagesRoot, "charls/test/fixtures/CT1.JLS")) + + beforeAll(async () => { + const mod = await import("../src/index.js") + dicomCodec = mod.default ?? mod + }) + + // Pins the constructor returned per (bitsAllocated, pixelRepresentation) + // for the JPEG-LS codec path — the contract plan 008 hardens. + it.each([ + { bitsAllocated: 16, pixelRepresentation: 1, ctor: "Int16Array" }, + { bitsAllocated: 16, pixelRepresentation: 0, ctor: "Uint16Array" }, + ])( + "returns $ctor for bitsAllocated=$bitsAllocated pixelRepresentation=$pixelRepresentation", + async ({ bitsAllocated, pixelRepresentation, ctor }) => { + const imageInfo = { + rows: 512, + columns: 512, + bitsAllocated, + samplesPerPixel: 1, + pixelRepresentation, + signed: pixelRepresentation === 1, + } + const decoded = await dicomCodec.decode(ct1Jls, imageInfo, "1.2.840.10008.1.2.4.80") + const pixelData = dicomCodec.getPixelData(decoded.imageFrame, imageInfo, "1.2.840.10008.1.2.4.80") + expect(pixelData.constructor.name).toBe(ctor) + expect(pixelData.length).toBe(512 * 512) + } + ) +}) diff --git a/packages/dicom-codec/vitest.config.mjs b/packages/dicom-codec/vitest.config.mjs index 2939469..f71bed6 100644 --- a/packages/dicom-codec/vitest.config.mjs +++ b/packages/dicom-codec/vitest.config.mjs @@ -4,6 +4,18 @@ import codspeedPlugin from "@codspeed/vitest-plugin" export default defineConfig({ plugins: [codspeedPlugin()], test: { + // Under the CodSpeed simulation instrument the entire process runs ~60x + // slower under valgrind while vitest's hard-coded 60s worker-RPC timer + // counts real seconds, so large bench suites structurally hit "Timeout + // calling onTaskUpdate" AFTER their benches complete and upload. Ignore + // that exit-code noise in simulation only; walltime and test runs stay + // strict. + // (CODSPEED_ENV is set whenever the CodSpeed runner is active; the + // mode string is "instrumentation" on older runners and "simulation" + // on newer ones, so match anything except walltime.) + dangerouslyIgnoreUnhandledErrors: + process.env.CODSPEED_ENV !== undefined && + process.env.CODSPEED_RUNNER_MODE !== "walltime", name: "dicom-codec", include: ["test/**/*.test.js"], benchmark: { diff --git a/packages/libjpeg-turbo-12bit/test/fixtures/raw/CT-512x512-12bit.raw b/packages/libjpeg-turbo-12bit/test/fixtures/raw/CT-512x512-12bit.raw new file mode 100644 index 0000000..9724244 Binary files /dev/null and b/packages/libjpeg-turbo-12bit/test/fixtures/raw/CT-512x512-12bit.raw differ diff --git a/packages/libjpeg-turbo-8bit/bench/decode.bench.js b/packages/libjpeg-turbo-8bit/bench/decode.bench.js index 93d6ffe..09df187 100644 --- a/packages/libjpeg-turbo-8bit/bench/decode.bench.js +++ b/packages/libjpeg-turbo-8bit/bench/decode.bench.js @@ -57,7 +57,9 @@ let coldEnc let warmEnc if (!skip) { const factory = (await import(distPath)).default ?? (await import(distPath)) - codec = await factory() + // Silence wasm stdout/stderr so vitest's console interception never runs + // inside a measured bench body (see openjphjs bench for details). + codec = await factory({ print: () => {}, printErr: () => {} }) // Cold instances: just construct, never decode/encode at module load. // The bench body will be the first call into the decoder/encoder. @@ -81,14 +83,21 @@ if (!skip) { } describe.skipIf(skip)("libjpeg-turbo-8bit (wasm)", () => { - bench("instantiate+destroy JPEGDecoder", () => { - const d = new codec.JPEGDecoder() - d.delete() + // Batched x50: a single instantiate+destroy is ~60 µs, where fixed harness + // overhead and cache-model variation across runner CPUs dominate; the loop + // puts the body in the ms range so the codec work is the signal. + bench("instantiate+destroy JPEGDecoder x50", () => { + for (let i = 0; i < 50; i++) { + const d = new codec.JPEGDecoder() + d.delete() + } }) - bench("instantiate+destroy JPEGEncoder", () => { - const e = new codec.JPEGEncoder() - e.delete() + bench("instantiate+destroy JPEGEncoder x50", () => { + for (let i = 0; i < 50; i++) { + const e = new codec.JPEGEncoder() + e.delete() + } }) bench("decode jpeg400jfif.jpg (600x800x8bit) — cold", () => { diff --git a/packages/libjpeg-turbo-8bit/test/decode.test.js b/packages/libjpeg-turbo-8bit/test/decode.test.js index 5212d90..315e483 100644 --- a/packages/libjpeg-turbo-8bit/test/decode.test.js +++ b/packages/libjpeg-turbo-8bit/test/decode.test.js @@ -1,13 +1,26 @@ import { beforeAll, describe, expect, it } from "vitest" -import { readFileSync } from "node:fs" +import { existsSync, readFileSync } from "node:fs" import { fileURLToPath } from "node:url" import { dirname, resolve } from "node:path" const __dirname = dirname(fileURLToPath(import.meta.url)) +const distDir = resolve(__dirname, "../dist") const fixturesDir = resolve(__dirname, "fixtures") const jpeg400 = readFileSync(resolve(fixturesDir, "jpeg/jpeg400jfif.jpg")) const jpeg400Raw = readFileSync(resolve(fixturesDir, "raw/jpeg400jfif.raw")) +// Progressive (SOF2) variant of the same image — a distinct libjpeg decode +// module (jdcoefct/progressive refinement) not exercised by the baseline +// fixture. Golden verified bit-identical against Pillow's independently +// built libjpeg on 2026-07-07; asm.js and wasm variants agree byte-exact. +const jpegProgressive = readFileSync(resolve(fixturesDir, "jpeg/jpeg400jfif-new.jpg")) +const jpegProgressiveRaw = readFileSync(resolve(fixturesDir, "raw/jpeg400jfif-new.raw")) +// Color 4:2:0 YCbCr baseline JPEG encoded from the US1 RGB ultrasound frame +// (tools/fixture-verification/gen/generate-fixtures.mjs). Decoded golden +// verified bit-identical against DCMTK dcmdjpeg (0 of 921600 bytes differ, +// 2026-07-07) — pins the YCbCr->RGB conversion + chroma upsampling path. +const jpegColor = readFileSync(resolve(fixturesDir, "jpeg/US1-color-420.jpg")) +const jpegColorRaw = readFileSync(resolve(fixturesDir, "raw/US1-color-420.raw")) async function loadModule(modulePath) { const mod = await import(modulePath) @@ -16,18 +29,25 @@ async function loadModule(modulePath) { } const buildVariants = [ - { name: "asm.js (libjpegturbojs)", path: "../dist/libjpegturbojs.js" }, - { name: "wasm (libjpegturbowasm)", path: "../dist/libjpegturbowasm.js" }, + { name: "asm.js (libjpegturbojs)", path: "../dist/libjpegturbojs.js", dist: "libjpegturbojs.js" }, + { name: "wasm (libjpegturbowasm)", path: "../dist/libjpegturbowasm.js", dist: "libjpegturbowasm.js" }, ] -describe.each(buildVariants)("libjpeg-turbo-8bit decode — $name", ({ path }) => { +describe.each(buildVariants)("libjpeg-turbo-8bit decode — $name", ({ path, dist }) => { + const isBuilt = existsSync(resolve(distDir, dist)) let codec beforeAll(async () => { - codec = await loadModule(path) + if (isBuilt) codec = await loadModule(path) }) - it("decodes the jpeg400 grayscale fixture", () => { + // In CI a missing dist means the build/artifact pipeline broke; fail loudly + // instead of letting every skipIf() below silently skip the suite. + it.runIf(process.env.CI)("dist is present in CI", () => { + expect(isBuilt, `${dist} missing — build artifact was not replayed`).toBe(true) + }) + + it.skipIf(!isBuilt)("decodes the jpeg400 grayscale fixture to bytes matching the RAW reference", () => { const decoder = new codec.JPEGDecoder() const encodedBuffer = decoder.getEncodedBuffer(jpeg400.length) encodedBuffer.set(jpeg400) @@ -41,13 +61,48 @@ describe.each(buildVariants)("libjpeg-turbo-8bit decode — $name", ({ path }) = expect(frameInfo.componentCount).toBe(1) const decoded = decoder.getDecodedBuffer() - expect(decoded.length).toBe(600 * 800) expect(decoded.length).toBe(jpeg400Raw.length) + // Baseline JPEG decoding is deterministic: the decoded pixels must match + // the RAW reference exactly (identical across asm.js and wasm variants). + expect(Buffer.from(decoded).equals(jpeg400Raw)).toBe(true) + + decoder.delete() + }) + + it.skipIf(!isBuilt)("decodes a progressive (SOF2) JPEG to bytes matching the RAW reference", () => { + const decoder = new codec.JPEGDecoder() + decoder.getEncodedBuffer(jpegProgressive.length).set(jpegProgressive) + decoder.decode() + + const frameInfo = decoder.getFrameInfo() + expect(frameInfo.width).toBe(600) + expect(frameInfo.height).toBe(800) + expect(frameInfo.componentCount).toBe(1) + + const decoded = decoder.getDecodedBuffer() + expect(Buffer.from(decoded).equals(jpegProgressiveRaw)).toBe(true) + + decoder.delete() + }) + + it.skipIf(!isBuilt)("decodes a color 4:2:0 YCbCr JPEG to interleaved RGB matching the DCMTK-verified reference", () => { + const decoder = new codec.JPEGDecoder() + decoder.getEncodedBuffer(jpegColor.length).set(jpegColor) + decoder.decode() + + const frameInfo = decoder.getFrameInfo() + expect(frameInfo.width).toBe(640) + expect(frameInfo.height).toBe(480) + expect(frameInfo.componentCount).toBe(3) + + const decoded = decoder.getDecodedBuffer() + expect(decoded.length).toBe(640 * 480 * 3) + expect(Buffer.from(decoded).equals(jpegColorRaw)).toBe(true) decoder.delete() }) - it("throws or marks error on truncated input", () => { + it.skipIf(!isBuilt)("throws or marks error on truncated input", () => { const truncated = jpeg400.subarray(0, Math.floor(jpeg400.length / 2)) const decoder = new codec.JPEGDecoder() const encodedBuffer = decoder.getEncodedBuffer(truncated.length) @@ -61,14 +116,15 @@ describe.each(buildVariants)("libjpeg-turbo-8bit decode — $name", ({ path }) = describe.each(buildVariants)( "libjpeg-turbo-8bit encode + round-trip — $name", - ({ path }) => { + ({ path, dist }) => { + const isBuilt = existsSync(resolve(distDir, dist)) let codec beforeAll(async () => { - codec = await loadModule(path) + if (isBuilt) codec = await loadModule(path) }) - it("encodes raw → JPEG and decodes back to the same dimensions", () => { + it.skipIf(!isBuilt)("encodes raw → JPEG and decodes back to the same dimensions", () => { const frameInfo = { width: 600, height: 800, @@ -82,8 +138,10 @@ describe.each(buildVariants)( encoder.encode() const encoded = encoder.getEncodedBuffer() - expect(encoded.length).toBeGreaterThan(0) - expect(encoded.length).toBeLessThan(jpeg400Raw.length) + // Measured 63975 bytes at the default quality (95) on 2026-07-07; + // ceiling = compression regression, floor = silent truncation. + expect(encoded.length).toBeGreaterThan(63975 * 0.5) + expect(encoded.length).toBeLessThan(63975 * 1.10) const decoder = new codec.JPEGDecoder() const inBuffer = decoder.getEncodedBuffer(encoded.length) @@ -99,6 +157,30 @@ describe.each(buildVariants)( const roundTripDecoded = decoder.getDecodedBuffer() expect(roundTripDecoded.length).toBe(jpeg400Raw.length) + // Baseline JPEG is lossy, so byte equality is not expected — but the + // error must stay small. Measured on this fixture: maxAbsDiff 6, + // meanAbsDiff 0.26. Bound it so a broken DCT/quantization path (which + // produces structurally wrong pixels, not slightly-off ones) fails. + let maxAbsDiff = 0 + let totalAbsDiff = 0 + for (let i = 0; i < jpeg400Raw.length; i++) { + const diff = Math.abs(roundTripDecoded[i] - jpeg400Raw[i]) + if (diff > maxAbsDiff) maxAbsDiff = diff + totalAbsDiff += diff + } + expect(maxAbsDiff).toBeLessThanOrEqual(10) + expect(totalAbsDiff / jpeg400Raw.length).toBeLessThanOrEqual(1) + + // PSNR floor: measured 53.6 dB at default quality on 2026-07-07. A + // broken DCT/quantization path lands tens of dB below this. + let sumSq = 0 + for (let i = 0; i < jpeg400Raw.length; i++) { + const diff = roundTripDecoded[i] - jpeg400Raw[i] + sumSq += diff * diff + } + const psnr = 10 * Math.log10((255 * 255) / (sumSq / jpeg400Raw.length)) + expect(psnr).toBeGreaterThan(48) + decoder.delete() encoder.delete() }) diff --git a/packages/libjpeg-turbo-8bit/test/fixtures/jpeg/US1-color-420.jpg b/packages/libjpeg-turbo-8bit/test/fixtures/jpeg/US1-color-420.jpg new file mode 100644 index 0000000..ea90210 Binary files /dev/null and b/packages/libjpeg-turbo-8bit/test/fixtures/jpeg/US1-color-420.jpg differ diff --git a/packages/libjpeg-turbo-8bit/test/fixtures/raw/US1-color-420.raw b/packages/libjpeg-turbo-8bit/test/fixtures/raw/US1-color-420.raw new file mode 100644 index 0000000..56584d3 Binary files /dev/null and b/packages/libjpeg-turbo-8bit/test/fixtures/raw/US1-color-420.raw differ diff --git a/packages/libjpeg-turbo-8bit/test/fixtures/raw/jpeg400jfif-new.raw b/packages/libjpeg-turbo-8bit/test/fixtures/raw/jpeg400jfif-new.raw new file mode 100644 index 0000000..6c262a9 Binary files /dev/null and b/packages/libjpeg-turbo-8bit/test/fixtures/raw/jpeg400jfif-new.raw differ diff --git a/packages/libjpeg-turbo-8bit/test/heap-stability.test.js b/packages/libjpeg-turbo-8bit/test/heap-stability.test.js new file mode 100644 index 0000000..d370309 --- /dev/null +++ b/packages/libjpeg-turbo-8bit/test/heap-stability.test.js @@ -0,0 +1,85 @@ +import { beforeAll, describe, expect, it } from "vitest" +import { existsSync, readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { dirname, resolve } from "node:path" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const distDir = resolve(__dirname, "../dist") +const fixturesDir = resolve(__dirname, "fixtures") + +const isBuilt = existsSync(resolve(distDir, "libjpegturbowasm.js")) + +async function loadModule(path) { + const mod = await import(path) + const factory = mod.default ?? mod + return await factory() +} + +// Emscripten heaps grow but never shrink, so a native-side leak shows up as +// monotonic HEAP8 growth across repeated use — invisible to single-decode +// tests, an OOM after thousands of frames in a viewer. This repo has a +// history of exactly that class of bug (error-path instance leaks, encoder +// handle leaks). +// +// Loop sizing matters: the arena starts at 50 MiB (INITIAL_MEMORY) with +// ALLOW_MEMORY_GROWTH, so a leak only becomes visible once the leaked +// instances exceed the arena's free slack; 100+ instance-sized leaks +// comfortably exceed it (see the charls twin of this file for the +// calibration measurements). +describe("libjpeg-turbo-8bit wasm heap stability", { timeout: 120000 }, () => { + let codec + const encoded = readFileSync(resolve(fixturesDir, "jpeg/jpeg400jfif.jpg")) + const raw = readFileSync(resolve(fixturesDir, "raw/jpeg400jfif.raw")) + + beforeAll(async () => { + if (isBuilt) codec = await loadModule("../dist/libjpegturbowasm.js") + }) + + it.skipIf(!isBuilt)("repeated decode/delete cycles do not grow the heap", () => { + const decodeOnce = () => { + const decoder = new codec.JPEGDecoder() + decoder.getEncodedBuffer(encoded.length).set(encoded) + decoder.decode() + decoder.delete() + } + for (let i = 0; i < 10; i++) decodeOnce() + const settled = codec.HEAP8.length + for (let i = 0; i < 100; i++) decodeOnce() + expect(codec.HEAP8.length).toBe(settled) + }) + + it.skipIf(!isBuilt)("repeated encode/delete cycles do not grow the heap", () => { + const encodeOnce = () => { + const encoder = new codec.JPEGEncoder() + encoder.getDecodedBuffer({ width: 600, height: 800, bitsPerSample: 8, componentCount: 1, isSigned: false }).set(raw) + encoder.encode() + encoder.delete() + } + for (let i = 0; i < 10; i++) encodeOnce() + const settled = codec.HEAP8.length + for (let i = 0; i < 60; i++) encodeOnce() + expect(codec.HEAP8.length).toBe(settled) + }) + + it.skipIf(!isBuilt)("repeated failing decodes do not grow the heap", () => { + // Garbage (not truncated-after-valid-header) input: fails fast at + // header parse instead of spending seconds in sample recovery, so the + // loop can be large enough for a leak to exceed the arena slack. + const garbage = new Uint8Array(64) + for (let i = 0; i < garbage.length; i++) garbage[i] = (i * 37 + 11) % 256 + const failOnce = () => { + const decoder = new codec.JPEGDecoder() + decoder.getEncodedBuffer(garbage.length).set(garbage) + try { + decoder.decode() + } catch { + // expected for malformed input + } + decoder.delete() + } + for (let i = 0; i < 10; i++) failOnce() + const settled = codec.HEAP8.length + for (let i = 0; i < 100; i++) failOnce() + expect(codec.HEAP8.length).toBe(settled) + }) +}) diff --git a/packages/libjpeg-turbo-8bit/vitest.config.mjs b/packages/libjpeg-turbo-8bit/vitest.config.mjs index 0250bc5..541f079 100644 --- a/packages/libjpeg-turbo-8bit/vitest.config.mjs +++ b/packages/libjpeg-turbo-8bit/vitest.config.mjs @@ -4,6 +4,18 @@ import codspeedPlugin from "@codspeed/vitest-plugin" export default defineConfig({ plugins: [codspeedPlugin()], test: { + // Under the CodSpeed simulation instrument the entire process runs ~60x + // slower under valgrind while vitest's hard-coded 60s worker-RPC timer + // counts real seconds, so large bench suites structurally hit "Timeout + // calling onTaskUpdate" AFTER their benches complete and upload. Ignore + // that exit-code noise in simulation only; walltime and test runs stay + // strict. + // (CODSPEED_ENV is set whenever the CodSpeed runner is active; the + // mode string is "instrumentation" on older runners and "simulation" + // on newer ones, so match anything except walltime.) + dangerouslyIgnoreUnhandledErrors: + process.env.CODSPEED_ENV !== undefined && + process.env.CODSPEED_RUNNER_MODE !== "walltime", name: "libjpeg-turbo-8bit", include: ["test/**/*.test.js"], benchmark: { diff --git a/packages/little-endian/bench/decode.bench.js b/packages/little-endian/bench/decode.bench.js index c4b919f..7e25920 100644 --- a/packages/little-endian/bench/decode.bench.js +++ b/packages/little-endian/bench/decode.bench.js @@ -3,6 +3,12 @@ import decode from "../src/index.js" const SIZE_512x512 = 512 * 512 +// decode() only creates typed-array views (no per-pixel work), so a single +// call is sub-microsecond and the measurement is dominated by fixed harness +// overhead and cache-model variation across runner CPUs. Batch x100 so the +// body is in the ms range and the decode path is the signal. +const ITERS = 100 + function makeBuffer(byteLen) { const data = new Uint8Array(byteLen) for (let i = 0; i < byteLen; i++) data[i] = (i * 37) & 0xff @@ -12,21 +18,29 @@ function makeBuffer(byteLen) { describe("little-endian decode", () => { const data16 = makeBuffer(SIZE_512x512 * 2) - bench("16-bit unsigned, 512x512", () => { - decode({ bitsAllocated: 16, pixelRepresentation: 0 }, data16) + bench("16-bit unsigned, 512x512 x100", () => { + for (let i = 0; i < ITERS; i++) { + decode({ bitsAllocated: 16, pixelRepresentation: 0 }, data16) + } }) - bench("16-bit signed, 512x512", () => { - decode({ bitsAllocated: 16, pixelRepresentation: 1 }, data16) + bench("16-bit signed, 512x512 x100", () => { + for (let i = 0; i < ITERS; i++) { + decode({ bitsAllocated: 16, pixelRepresentation: 1 }, data16) + } }) const data8 = makeBuffer(SIZE_512x512) - bench("8-bit passthrough, 512x512", () => { - decode({ bitsAllocated: 8 }, data8) + bench("8-bit passthrough, 512x512 x100", () => { + for (let i = 0; i < ITERS; i++) { + decode({ bitsAllocated: 8 }, data8) + } }) const data32 = makeBuffer(SIZE_512x512 * 4) - bench("32-bit float, 512x512", () => { - decode({ bitsAllocated: 32 }, data32) + bench("32-bit float, 512x512 x100", () => { + for (let i = 0; i < ITERS; i++) { + decode({ bitsAllocated: 32 }, data32) + } }) }) diff --git a/packages/little-endian/vitest.config.mjs b/packages/little-endian/vitest.config.mjs index 5357d03..5037255 100644 --- a/packages/little-endian/vitest.config.mjs +++ b/packages/little-endian/vitest.config.mjs @@ -4,6 +4,18 @@ import codspeedPlugin from "@codspeed/vitest-plugin" export default defineConfig({ plugins: [codspeedPlugin()], test: { + // Under the CodSpeed simulation instrument the entire process runs ~60x + // slower under valgrind while vitest's hard-coded 60s worker-RPC timer + // counts real seconds, so large bench suites structurally hit "Timeout + // calling onTaskUpdate" AFTER their benches complete and upload. Ignore + // that exit-code noise in simulation only; walltime and test runs stay + // strict. + // (CODSPEED_ENV is set whenever the CodSpeed runner is active; the + // mode string is "instrumentation" on older runners and "simulation" + // on newer ones, so match anything except walltime.) + dangerouslyIgnoreUnhandledErrors: + process.env.CODSPEED_ENV !== undefined && + process.env.CODSPEED_RUNNER_MODE !== "walltime", name: "little-endian", include: ["test/**/*.test.js"], benchmark: { diff --git a/packages/openjpeg/bench/decode.bench.js b/packages/openjpeg/bench/decode.bench.js index 101168d..e2bda70 100644 --- a/packages/openjpeg/bench/decode.bench.js +++ b/packages/openjpeg/bench/decode.bench.js @@ -56,7 +56,9 @@ let coldEnc let warmEnc if (!skip) { const factory = (await import(distPath)).default ?? (await import(distPath)) - codec = await factory() + // Silence wasm stdout/stderr so vitest's console interception never runs + // inside a measured bench body (see openjphjs bench for details). + codec = await factory({ print: () => {}, printErr: () => {} }) // Cold instances: one per fixture, constructed but never decoded. coldDecCT1 = new codec.J2KDecoder() @@ -80,14 +82,21 @@ if (!skip) { } describe.skipIf(skip)("openjpeg J2K (wasm)", () => { - bench("instantiate+destroy J2KDecoder", () => { - const d = new codec.J2KDecoder() - d.delete() + // Batched x50: a single instantiate+destroy is ~60 µs, where fixed harness + // overhead and cache-model variation across runner CPUs dominate; the loop + // puts the body in the ms range so the codec work is the signal. + bench("instantiate+destroy J2KDecoder x50", () => { + for (let i = 0; i < 50; i++) { + const d = new codec.J2KDecoder() + d.delete() + } }) - bench("instantiate+destroy J2KEncoder", () => { - const e = new codec.J2KEncoder() - e.delete() + bench("instantiate+destroy J2KEncoder x50", () => { + for (let i = 0; i < 50; i++) { + const e = new codec.J2KEncoder() + e.delete() + } }) bench("decode CT1.j2k (.90 lossless 5-3, 512x512x16bit) — cold", () => { diff --git a/packages/openjpeg/test/api.test.js b/packages/openjpeg/test/api.test.js new file mode 100644 index 0000000..d77fc42 --- /dev/null +++ b/packages/openjpeg/test/api.test.js @@ -0,0 +1,196 @@ +import { beforeAll, describe, expect, it } from "vitest" +import { createHash } from "node:crypto" +import { existsSync, readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { dirname, resolve } from "node:path" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const distDir = resolve(__dirname, "../dist") +const fixturesDir = resolve(__dirname, "fixtures") + +const isBuilt = existsSync(resolve(distDir, "openjpegwasm.js")) + +async function loadModule(path) { + const mod = await import(path) + const factory = mod.default ?? mod + return await factory() +} + +const ct1 = () => readFileSync(resolve(fixturesDir, "j2k/CT1.j2k")) +const ct1Raw = () => readFileSync(resolve(fixturesDir, "raw/CT1.RAW")) +const lossy = () => readFileSync(resolve(fixturesDir, "j2k/CT-512x512-lossy.j2k")) + +// Exercises the embind surface beyond decode(): header introspection, +// sub-resolution (progressive) decode, and encoder setters. Literals are +// derived from CT1's known encode parameters, not blind snapshots: CT1 was +// encoded with 5 decompositions, single layer, LRCP progression, 64x64 +// code blocks, grayscale. +describe("openjpeg J2K API surface", () => { + let codec + + beforeAll(async () => { + if (isBuilt) codec = await loadModule("../dist/openjpegwasm.js") + }) + + it.runIf(process.env.CI)("dist is present in CI", () => { + expect(isBuilt, "openjpegwasm.js missing — build artifact was not replayed").toBe(true) + }) + + it.skipIf(!isBuilt)("exposes coding parameters through the getters after decode()", () => { + const encoded = ct1() + const decoder = new codec.J2KDecoder() + decoder.getEncodedBuffer(encoded.length).set(encoded) + decoder.decode() + + expect(decoder.getNumDecompositions()).toBe(5) + expect(decoder.getIsReversible()).toBe(true) + expect(decoder.getProgressionOrder()).toBe(0) // LRCP + expect(decoder.getNumLayers()).toBe(1) + expect(decoder.getColorSpace()).toBe(2) // OPJ_CLRSPC_GRAY + expect(decoder.getBlockDimensions()).toEqual({ width: 64, height: 64 }) + expect(decoder.getImageOffset()).toEqual({ x: 0, y: 0 }) + expect(decoder.calculateSizeAtDecompositionLevel(0)).toEqual({ width: 512, height: 512 }) + expect(decoder.calculateSizeAtDecompositionLevel(1)).toEqual({ width: 256, height: 256 }) + expect(decoder.calculateSizeAtDecompositionLevel(2)).toEqual({ width: 128, height: 128 }) + + decoder.delete() + }) + + // KNOWN BUG (found 2026-07-07 while adding this suite): readHeader() does + // not populate frameInfo or the coding-parameter state — every getter + // returns zeros (and, for some fixtures, uninitialized values such as + // getNumDecompositions() === 4294965296). Consumers must call decode() + // before trusting any getter. When readHeader() is fixed to parse the + // header, this test starts passing and vitest will flag it — then convert + // it into a plain it(). + it.fails.skipIf(!isBuilt)("readHeader() alone populates frameInfo (known broken)", () => { + const encoded = ct1() + const decoder = new codec.J2KDecoder() + decoder.getEncodedBuffer(encoded.length).set(encoded) + decoder.readHeader() + const frameInfo = decoder.getFrameInfo() + decoder.delete() + expect(frameInfo.width).toBe(512) + }) + + // KNOWN BUG: the .91 fixture uses the irreversible 9-7 wavelet, but + // getIsReversible() reports true after decoding it. Flips to failing + // (= fixed) when the wiring is corrected. + it.fails.skipIf(!isBuilt)("getIsReversible() is false for the lossy 9-7 stream (known broken)", () => { + const encoded = lossy() + const decoder = new codec.J2KDecoder() + decoder.getEncodedBuffer(encoded.length).set(encoded) + decoder.decode() + const reversible = decoder.getIsReversible() + decoder.delete() + expect(reversible).toBe(false) + }) + + it.skipIf(!isBuilt)("decodeSubResolution() produces the level-1 and level-2 images", () => { + const encoded = ct1() + const decoder = new codec.J2KDecoder() + decoder.getEncodedBuffer(encoded.length).set(encoded) + + decoder.decodeSubResolution(1, 0) + const sub1 = Buffer.from(decoder.getDecodedBuffer()) + expect(sub1.length).toBe(256 * 256 * 2) + // Regression pin. Cross-validated: openjphjs decodeSubResolution(1) on + // CT1.j2c produces the byte-identical buffer (the 5/3 LL band is + // mathematically shared between J2K and HTJ2K of the same source). + expect(createHash("sha256").update(sub1).digest("hex")).toBe( + "b6c934cad65758b2c90b5b7e2bea6ca2cd96574b547bb6720f1ca405e791abee" + ) + + decoder.decodeSubResolution(2, 0) + const sub2 = Buffer.from(decoder.getDecodedBuffer()) + expect(sub2.length).toBe(128 * 128 * 2) + + decoder.delete() + }) + + it.skipIf(!isBuilt)("setProgressionOrder() round-trips through the encoded stream", () => { + const raw = ct1Raw() + const frameInfo = { width: 512, height: 512, bitsPerSample: 16, componentCount: 1, isSigned: true } + const encoder = new codec.J2KEncoder() + encoder.getDecodedBuffer(frameInfo).set(raw) + encoder.setProgressionOrder(2) // RPCL + encoder.encode() + const encoded = Buffer.from(encoder.getEncodedBuffer()) + encoder.delete() + + const decoder = new codec.J2KDecoder() + decoder.getEncodedBuffer(encoded.length).set(encoded) + decoder.decode() + expect(decoder.getProgressionOrder()).toBe(2) + // structural change only: pixels still lossless + expect(Buffer.from(decoder.getDecodedBuffer()).equals(raw)).toBe(true) + decoder.delete() + }) + + // KNOWN DEAD SETTERS (found 2026-07-07): the encoder stores but never + // applies blockDimensions_, tileSize_, tileOffset_, precincts_ and + // downSample_ — encode() never copies them into opj_cparameters + // (no cblockw_init / cp_tdx / prcw_init wiring). Only imageOffset, + // progressionOrder, decompositions, quality/ratios are live. This + // sentinel flips when setBlockDimensions gets wired; wire and test the + // other four with it. + it.fails.skipIf(!isBuilt)("setBlockDimensions() round-trips through the encoded stream (known dead setter)", () => { + const raw = ct1Raw() + const frameInfo = { width: 512, height: 512, bitsPerSample: 16, componentCount: 1, isSigned: true } + const encoder = new codec.J2KEncoder() + encoder.getDecodedBuffer(frameInfo).set(raw) + encoder.setBlockDimensions({ width: 32, height: 32 }) + encoder.encode() + const encoded = Buffer.from(encoder.getEncodedBuffer()) + encoder.delete() + + const decoder = new codec.J2KDecoder() + decoder.getEncodedBuffer(encoded.length).set(encoded) + decoder.decode() + expect(decoder.getBlockDimensions()).toEqual({ width: 32, height: 32 }) + expect(Buffer.from(decoder.getDecodedBuffer()).equals(raw)).toBe(true) + decoder.delete() + }) + + it.skipIf(!isBuilt)("setCompressionRatio() produces a lossy stream with bounded distortion", () => { + const raw = ct1Raw() + const frameInfo = { width: 512, height: 512, bitsPerSample: 16, componentCount: 1, isSigned: true } + + const losslessEncoder = new codec.J2KEncoder() + losslessEncoder.getDecodedBuffer(frameInfo).set(raw) + losslessEncoder.encode() + const losslessSize = losslessEncoder.getEncodedBuffer().length + losslessEncoder.delete() + + const encoder = new codec.J2KEncoder() + encoder.getDecodedBuffer(frameInfo).set(raw) + encoder.setQuality(false, 1) // lossy (irreversible 9-7), 1 layer + encoder.setCompressionRatio(0, 10) // layer 0 at 10:1 + encoder.encode() + const encoded = Buffer.from(encoder.getEncodedBuffer()) + encoder.delete() + + expect(encoded.length).toBeGreaterThan(0) + expect(encoded.length).toBeLessThan(losslessSize) + + const decoder = new codec.J2KDecoder() + decoder.getEncodedBuffer(encoded.length).set(encoded) + decoder.decode() + const out = Buffer.from(decoder.getDecodedBuffer()) + decoder.delete() + + // lossy: not byte-equal, but tightly bounded distortion (PSNR floor) + expect(out.equals(raw)).toBe(false) + const a = new Int16Array(raw.buffer, raw.byteOffset, raw.length / 2) + const b = new Int16Array(out.buffer, out.byteOffset, out.length / 2) + let sumSq = 0 + for (let i = 0; i < a.length; i++) { + const d = a[i] - b[i] + sumSq += d * d + } + const mse = sumSq / a.length + const psnr = 10 * Math.log10((65535 * 65535) / mse) + // measured ~... generous floor: a broken quantization path lands far below + expect(psnr).toBeGreaterThan(50) + }) +}) diff --git a/packages/openjpeg/test/corpus.test.js b/packages/openjpeg/test/corpus.test.js new file mode 100644 index 0000000..5dee069 --- /dev/null +++ b/packages/openjpeg/test/corpus.test.js @@ -0,0 +1,89 @@ +import { beforeAll, describe, expect, it } from "vitest" +import { existsSync, readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { dirname, resolve } from "node:path" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const distDir = resolve(__dirname, "../dist") +const fixturesDir = resolve(__dirname, "fixtures") + +const isBuilt = existsSync(resolve(distDir, "openjpegwasm.js")) + +async function loadModule(path) { + const mod = await import(path) + const factory = mod.default ?? mod + return await factory() +} + +// Every shipped .j2k fixture with a committed RAW reference, previously +// unused by any test. Verified byte-exact on activation (2026-07-07); the +// RAWs are the references these lossless codestreams were produced from. +// Together they pin the decoder across bit depths (8/10/12/15/16), +// signedness, dimensions, and — for US1/VL* — 3-component color, which no +// other openjpeg test touches. Variant agreement (asm.js/wasm/decode-only) +// is covered for CT1/CT2 in decode.test.js; this corpus runs on the wasm +// variant to keep runtime sane. +const corpus = [ + { file: "MG1.j2k", width: 3064, height: 4664, bps: 12, comp: 1, signed: false }, + { file: "MR1.j2k", width: 512, height: 512, bps: 16, comp: 1, signed: true }, + { file: "MR2.j2k", width: 1024, height: 1024, bps: 12, comp: 1, signed: false }, + { file: "MR3.j2k", width: 512, height: 512, bps: 16, comp: 1, signed: true }, + { file: "MR4.j2k", width: 512, height: 512, bps: 12, comp: 1, signed: false }, + { file: "NM1.j2k", width: 256, height: 1024, bps: 16, comp: 1, signed: true }, + { file: "RG1.j2k", width: 1841, height: 1955, bps: 15, comp: 1, signed: false }, + { file: "RG2.j2k", width: 1760, height: 2140, bps: 10, comp: 1, signed: false }, + { file: "RG3.j2k", width: 1760, height: 1760, bps: 10, comp: 1, signed: false }, + { file: "SC1.j2k", width: 2048, height: 2487, bps: 12, comp: 1, signed: false }, + { file: "XA1.j2k", width: 1024, height: 1024, bps: 10, comp: 1, signed: false }, + { file: "US1.j2k", width: 640, height: 480, bps: 8, comp: 3, signed: false }, + { file: "VL1.j2k", width: 756, height: 486, bps: 8, comp: 3, signed: false }, + { file: "VL4.j2k", width: 2226, height: 1868, bps: 8, comp: 3, signed: false }, + { file: "VL6.j2k", width: 756, height: 486, bps: 8, comp: 3, signed: false }, +] + +describe("openjpeg J2K decode corpus (bit depths, signedness, color)", () => { + let codec + + beforeAll(async () => { + if (isBuilt) codec = await loadModule("../dist/openjpegwasm.js") + }) + + it.runIf(process.env.CI)("dist is present in CI", () => { + expect(isBuilt, "openjpegwasm.js missing — build artifact was not replayed").toBe(true) + }) + + it.skipIf(!isBuilt).each(corpus)( + "decodes $file ($width x $height, $bps-bit, $comp comp) byte-exact vs RAW", + ({ file, width, height, bps, comp, signed }) => { + const encoded = readFileSync(resolve(fixturesDir, "j2k", file)) + const raw = readFileSync(resolve(fixturesDir, "raw", file.replace(".j2k", ".RAW"))) + + const decoder = new codec.J2KDecoder() + decoder.getEncodedBuffer(encoded.length).set(encoded) + decoder.decode() + + const frameInfo = decoder.getFrameInfo() + expect(frameInfo.width).toBe(width) + expect(frameInfo.height).toBe(height) + expect(frameInfo.bitsPerSample).toBe(bps) + expect(frameInfo.componentCount).toBe(comp) + expect(frameInfo.isSigned).toBe(signed) + + const decoded = decoder.getDecodedBuffer() + expect(decoded.length).toBe(raw.length) + expect(Buffer.from(decoded).equals(raw)).toBe(true) + + decoder.delete() + } + ) + + it.skipIf(!isBuilt)("decodes the 0-decomposition CT1 variant to the same pixels as CT1", () => { + const encoded = readFileSync(resolve(fixturesDir, "j2k/CT1-0decomp.j2k")) + const raw = readFileSync(resolve(fixturesDir, "raw/CT1.RAW")) + const decoder = new codec.J2KDecoder() + decoder.getEncodedBuffer(encoded.length).set(encoded) + decoder.decode() + expect(Buffer.from(decoder.getDecodedBuffer()).equals(raw)).toBe(true) + decoder.delete() + }) +}) diff --git a/packages/openjpeg/test/decode.test.js b/packages/openjpeg/test/decode.test.js index 220ff0d..80a4af8 100644 --- a/packages/openjpeg/test/decode.test.js +++ b/packages/openjpeg/test/decode.test.js @@ -13,8 +13,12 @@ const ct2Encoded = readFileSync(resolve(fixturesDir, "j2k/CT2.j2k")) const ct2Raw = readFileSync(resolve(fixturesDir, "raw/CT2.RAW")) // CT-512x512-lossy.j2k is a real .91 (JPEG 2000 Lossy) payload extracted from // a Cornerstone3D test DICOM. Uses an irreversible 9-7 wavelet, so byte -// equality with any RAW is not expected. +// equality with the original source pixels is not expected — but decoding is +// deterministic, so we pin the decoder's own output as a regression golden +// (CT-512x512-lossy.raw, generated by this codec and identical across the +// asm.js/wasm/decode-only variants). const ctLossy = readFileSync(resolve(fixturesDir, "j2k/CT-512x512-lossy.j2k")) +const ctLossyRaw = readFileSync(resolve(fixturesDir, "raw/CT-512x512-lossy.raw")) async function loadModule(modulePath) { const mod = await import(modulePath) @@ -36,6 +40,12 @@ describe.each(buildVariants)("openjpeg J2K decode — $name", ({ path, dist }) = if (isBuilt) codec = await loadModule(path) }) + // In CI a missing dist means the build/artifact pipeline broke; fail loudly + // instead of letting every skipIf() below silently skip the suite. + it.runIf(process.env.CI)("dist is present in CI", () => { + expect(isBuilt, `${dist} missing — build artifact was not replayed`).toBe(true) + }) + it.skipIf(!isBuilt)( "decodes CT1.j2k to a 512x512 16-bit monochrome frame", () => { @@ -69,7 +79,7 @@ describe.each(buildVariants)("openjpeg J2K decode — $name", ({ path, dist }) = }) it.skipIf(!isBuilt)( - "decodes a lossy 9-7 J2K (transfer syntax .91) to a 512x512 16-bit frame", + "decodes a lossy 9-7 J2K (transfer syntax .91) matching the pinned golden output", () => { const decoder = new codec.J2KDecoder() decoder.getEncodedBuffer(ctLossy.length).set(ctLossy) @@ -82,12 +92,42 @@ describe.each(buildVariants)("openjpeg J2K decode — $name", ({ path, dist }) = expect(frameInfo.componentCount).toBe(1) const decoded = decoder.getDecodedBuffer() - expect(decoded.length).toBe(512 * 512 * 2) + expect(decoded.length).toBe(ctLossyRaw.length) + expect(Buffer.from(decoded).equals(ctLossyRaw)).toBe(true) decoder.delete() } ) }) +describe.each(buildVariants)("openjpeg J2K decode robustness — $name", ({ path, dist }) => { + const isBuilt = existsSync(resolve(distDir, dist)) + let codec + + beforeAll(async () => { + if (isBuilt) codec = await loadModule(path) + }) + + it.skipIf(!isBuilt)("does not crash the process on a malformed/garbage buffer", () => { + const decoder = new codec.J2KDecoder() + const garbage = new Uint8Array(64) + for (let i = 0; i < garbage.length; i++) garbage[i] = (i * 37 + 11) % 256 + decoder.getEncodedBuffer(garbage.length).set(garbage) + + // Malformed input must either throw or return without corrupting the + // process (e.g. via an out-of-bounds heap write). Reaching this + // expectation at all is the meaningful assertion here. + expect(() => { + try { + decoder.decode() + } catch (e) { + // throwing is an acceptable, expected outcome for malformed input + } + }).not.toThrow() + + decoder.delete() + }) +}) + const encoderVariants = buildVariants.filter((v) => !v.name.includes("decode-only")) describe.each(encoderVariants)( @@ -114,7 +154,10 @@ describe.each(encoderVariants)( encoder.getDecodedBuffer(frameInfo).set(ct1Raw) encoder.encode() const encoded = encoder.getEncodedBuffer() - expect(encoded.length).toBeGreaterThan(0) + // Measured 174404 bytes on 2026-07-07 (emsdk 3.1.74); ceiling = + // compression regression, floor = silent truncation. + expect(encoded.length).toBeGreaterThan(174404 * 0.5) + expect(encoded.length).toBeLessThan(174404 * 1.10) const decoder = new codec.J2KDecoder() decoder.getEncodedBuffer(encoded.length).set(encoded) diff --git a/packages/openjpeg/test/fixtures/raw/CT-512x512-lossy.raw b/packages/openjpeg/test/fixtures/raw/CT-512x512-lossy.raw new file mode 100644 index 0000000..5fd199d Binary files /dev/null and b/packages/openjpeg/test/fixtures/raw/CT-512x512-lossy.raw differ diff --git a/packages/openjpeg/test/heap-stability.test.js b/packages/openjpeg/test/heap-stability.test.js new file mode 100644 index 0000000..7f3b6c2 --- /dev/null +++ b/packages/openjpeg/test/heap-stability.test.js @@ -0,0 +1,71 @@ +import { beforeAll, describe, expect, it } from "vitest" +import { existsSync, readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { dirname, resolve } from "node:path" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const distDir = resolve(__dirname, "../dist") +const fixturesDir = resolve(__dirname, "fixtures") + +const isBuilt = existsSync(resolve(distDir, "openjpegwasm.js")) + +async function loadModule(path) { + const mod = await import(path) + const factory = mod.default ?? mod + return await factory() +} + +// Emscripten heaps grow but never shrink, so a native-side leak shows up as +// monotonic HEAP8 growth across repeated use — invisible to single-decode +// tests, an OOM after thousands of frames in a viewer. This repo has a +// history of exactly that class of bug (error-path instance leaks, encoder +// handle leaks). +// +// Loop sizing matters: the arena starts at 50 MiB (INITIAL_MEMORY) with +// ALLOW_MEMORY_GROWTH, so a leak only becomes visible once the leaked +// instances exceed the arena's free slack; 100+ instance-sized leaks +// comfortably exceed it (see the charls twin of this file for the +// calibration measurements). +describe("openjpeg wasm heap stability", { timeout: 120000 }, () => { + let codec + const encoded = readFileSync(resolve(fixturesDir, "j2k/CT1.j2k")) + + beforeAll(async () => { + if (isBuilt) codec = await loadModule("../dist/openjpegwasm.js") + }) + + it.skipIf(!isBuilt)("repeated decode/delete cycles do not grow the heap", () => { + const decodeOnce = () => { + const decoder = new codec.J2KDecoder() + decoder.getEncodedBuffer(encoded.length).set(encoded) + decoder.decode() + decoder.delete() + } + for (let i = 0; i < 10; i++) decodeOnce() + const settled = codec.HEAP8.length + for (let i = 0; i < 100; i++) decodeOnce() + expect(codec.HEAP8.length).toBe(settled) + }) + + it.skipIf(!isBuilt)("repeated failing decodes do not grow the heap", () => { + // Garbage (not truncated-after-valid-header) input: fails fast at + // header parse instead of spending seconds in sample recovery, so the + // loop can be large enough for a leak to exceed the arena slack. + const garbage = new Uint8Array(64) + for (let i = 0; i < garbage.length; i++) garbage[i] = (i * 37 + 11) % 256 + const failOnce = () => { + const decoder = new codec.J2KDecoder() + decoder.getEncodedBuffer(garbage.length).set(garbage) + try { + decoder.decode() + } catch { + // expected for malformed input + } + decoder.delete() + } + for (let i = 0; i < 10; i++) failOnce() + const settled = codec.HEAP8.length + for (let i = 0; i < 100; i++) failOnce() + expect(codec.HEAP8.length).toBe(settled) + }) +}) diff --git a/packages/openjpeg/vitest.config.mjs b/packages/openjpeg/vitest.config.mjs index c253d42..2913463 100644 --- a/packages/openjpeg/vitest.config.mjs +++ b/packages/openjpeg/vitest.config.mjs @@ -4,6 +4,18 @@ import codspeedPlugin from "@codspeed/vitest-plugin" export default defineConfig({ plugins: [codspeedPlugin()], test: { + // Under the CodSpeed simulation instrument the entire process runs ~60x + // slower under valgrind while vitest's hard-coded 60s worker-RPC timer + // counts real seconds, so large bench suites structurally hit "Timeout + // calling onTaskUpdate" AFTER their benches complete and upload. Ignore + // that exit-code noise in simulation only; walltime and test runs stay + // strict. + // (CODSPEED_ENV is set whenever the CodSpeed runner is active; the + // mode string is "instrumentation" on older runners and "simulation" + // on newer ones, so match anything except walltime.) + dangerouslyIgnoreUnhandledErrors: + process.env.CODSPEED_ENV !== undefined && + process.env.CODSPEED_RUNNER_MODE !== "walltime", name: "openjpeg", include: ["test/**/*.test.js"], benchmark: { diff --git a/packages/openjphjs/bench/decode.bench.js b/packages/openjphjs/bench/decode.bench.js index 2c65bfb..c20471e 100644 --- a/packages/openjphjs/bench/decode.bench.js +++ b/packages/openjphjs/bench/decode.bench.js @@ -59,7 +59,11 @@ let coldEnc let warmEnc if (!skip) { const factory = (await import(distPath)).default ?? (await import(distPath)) - codec = await factory() + // Silence wasm stdout/stderr: HTJ2KDecoder's constructor prints a banner, + // and vitest's console interception (stack-trace task attribution + + // source-map mapping) would otherwise run inside the measured bench body, + // swamping the microsecond-scale instantiate+destroy benches. + codec = await factory({ print: () => {}, printErr: () => {} }) // Cold instances: one per fixture, constructed but never decoded. coldDecCT1 = new codec.HTJ2KDecoder() @@ -82,14 +86,23 @@ if (!skip) { } describe.skipIf(skip)("openjphjs HTJ2K (wasm)", () => { - bench("instantiate+destroy HTJ2KDecoder", () => { - const d = new codec.HTJ2KDecoder() - d.delete() + // Lifecycle benches are batched: a single instantiate+destroy is ~60 µs, + // small enough that fixed harness overhead (bench-wrapper frames, task + // attribution) and the simulated cache model's CPU-to-CPU variation + // dominate the number. 50 iterations puts the body in the ms range where + // the codec work is the signal. + bench("instantiate+destroy HTJ2KDecoder x50", () => { + for (let i = 0; i < 50; i++) { + const d = new codec.HTJ2KDecoder() + d.delete() + } }) - bench("instantiate+destroy HTJ2KEncoder", () => { - const e = new codec.HTJ2KEncoder() - e.delete() + bench("instantiate+destroy HTJ2KEncoder x50", () => { + for (let i = 0; i < 50; i++) { + const e = new codec.HTJ2KEncoder() + e.delete() + } }) bench("decode CT1.j2c (.201 lossless, 512x512x16bit) — cold", () => { diff --git a/packages/openjphjs/test/api.test.js b/packages/openjphjs/test/api.test.js new file mode 100644 index 0000000..8330856 --- /dev/null +++ b/packages/openjphjs/test/api.test.js @@ -0,0 +1,71 @@ +import { beforeAll, describe, expect, it } from "vitest" +import { createHash } from "node:crypto" +import { existsSync, readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { dirname, resolve } from "node:path" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const distDir = resolve(__dirname, "../dist") +const fixturesDir = resolve(__dirname, "fixtures") + +const isBuilt = existsSync(resolve(distDir, "openjphjs.js")) + +async function loadModule(path) { + const mod = await import(path) + const factory = mod.default ?? mod + return await factory() +} + +// Header introspection and progressive (sub-resolution) decode — the +// viewer's progressive-loading path, previously untested. +describe("openjphjs HTJ2K API surface", () => { + let codec + + beforeAll(async () => { + if (isBuilt) codec = await loadModule("../dist/openjphjs.js") + }) + + it.runIf(process.env.CI)("dist is present in CI", () => { + expect(isBuilt, "openjphjs.js missing — build artifact was not replayed").toBe(true) + }) + + it.skipIf(!isBuilt)("readHeader() populates frameInfo and decomposition info without decoding", () => { + const encoded = readFileSync(resolve(fixturesDir, "j2c/CT1.j2c")) + const decoder = new codec.HTJ2KDecoder() + decoder.getEncodedBuffer(encoded.length).set(encoded) + decoder.readHeader() + + expect(decoder.getFrameInfo()).toEqual({ + width: 512, + height: 512, + bitsPerSample: 16, + componentCount: 1, + isSigned: true, + isUsingColorTransform: false, + }) + expect(decoder.getNumDecompositions()).toBe(5) + expect(decoder.calculateSizeAtDecompositionLevel(0)).toEqual({ width: 512, height: 512 }) + expect(decoder.calculateSizeAtDecompositionLevel(1)).toEqual({ width: 256, height: 256 }) + expect(decoder.calculateSizeAtDecompositionLevel(2)).toEqual({ width: 128, height: 128 }) + + decoder.delete() + }) + + it.skipIf(!isBuilt)("decodeSubResolution(1) produces the level-1 image", () => { + const encoded = readFileSync(resolve(fixturesDir, "j2c/CT1.j2c")) + const decoder = new codec.HTJ2KDecoder() + decoder.getEncodedBuffer(encoded.length).set(encoded) + decoder.decodeSubResolution(1) + const sub1 = Buffer.from(decoder.getDecodedBuffer()) + decoder.delete() + + expect(sub1.length).toBe(256 * 256 * 2) + // Cross-codec validated pin: openjpeg's decodeSubResolution(1, 0) on + // CT1.j2k produces this byte-identical buffer — the reversible 5/3 + // LL band at each resolution level is mathematically shared between + // J2K and HTJ2K encodes of the same source. + expect(createHash("sha256").update(sub1).digest("hex")).toBe( + "b6c934cad65758b2c90b5b7e2bea6ca2cd96574b547bb6720f1ca405e791abee" + ) + }) +}) diff --git a/packages/openjphjs/test/corpus.test.js b/packages/openjphjs/test/corpus.test.js new file mode 100644 index 0000000..ceacf27 --- /dev/null +++ b/packages/openjphjs/test/corpus.test.js @@ -0,0 +1,70 @@ +import { beforeAll, describe, expect, it } from "vitest" +import { existsSync, readFileSync } from "node:fs" +import { createHash } from "node:crypto" +import { fileURLToPath } from "node:url" +import { dirname, resolve } from "node:path" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const distDir = resolve(__dirname, "../dist") +const fixturesDir = resolve(__dirname, "fixtures") + +const modulePath = "../dist/openjphjs.js" +const isBuilt = existsSync(resolve(distDir, "openjphjs.js")) + +async function loadModule(path) { + const mod = await import(path) + const factory = mod.default ?? mod + return await factory() +} + +// Every .j2c fixture in the repo, decoded and pinned by the SHA-256 of the +// decoded pixel buffer. CT1/CT2 are additionally byte-verified against RAW +// references in decode.test.js; the CT1/CT2 pixels are cross-validated +// bit-for-bit against the charls and openjpeg codecs (all three decode the +// same slices to identical bytes). The rest pin current decoder output as +// regression goldens across a range of modalities, sizes and aspect ratios. +// Hashes keep the repo small — a mismatch means the decoded pixels changed; +// regenerate deliberately if a decoder upgrade legitimately changes output. +const corpus = [ + { file: "CT1.j2c", width: 512, height: 512, sha256: "1add6ede29758c6f0c68f01749ddc6c907e68a312be4eb9da8489e376e0bbd34" }, + { file: "CT2.j2c", width: 512, height: 512, sha256: "ddaf7fb6a05bf7ac8b2b29e29cca3204e426179cce2888eeff3a270c1927d73d" }, + { file: "MG1.j2c", width: 3064, height: 4774, sha256: "d4b670f1deccc165f72316858b746556acad6636ec0a381cd82b2bd23810a31c" }, + { file: "MR1.j2c", width: 512, height: 512, sha256: "2541a628cb676972b37008a4fe6b5cce3df9866df62a77086bdffbe422064632" }, + { file: "MR2.j2c", width: 1024, height: 1024, sha256: "7d1a676f3c012d0ca9d4fb9069c5dcca2b0bac014173dba48f0e32b9b49198b3" }, + { file: "MR3.j2c", width: 512, height: 512, sha256: "9d32a2a63e3980d08130da4606abab010d6de943e9d504deb80ccb910fe5aa45" }, + { file: "MR4.j2c", width: 512, height: 512, sha256: "9c7574cb23eef7f99481e94764d3efe4025db704be97cc18a944c0db2dfdb3d1" }, + { file: "NM1.j2c", width: 256, height: 1024, sha256: "a6e9d32143339d3f5748b5520aa4e6c6ffb3550b6f71fdf17bdb2ebb44bc2611" }, + { file: "RG1.j2c", width: 1841, height: 1955, sha256: "26721b2112d94887b0feae345f1b7c1c8148e1710eaf27283d8c3e650682d252" }, + { file: "RG2.j2c", width: 1760, height: 2140, sha256: "9ed5d9818c250bb81ff9093a6c4d5c6032df281fce82b9a288de65c74348301e" }, + { file: "RG3.j2c", width: 1760, height: 1760, sha256: "85480a0287e37795bc96799747a69af475f3bf0c35203fac1010fc6e100821a7" }, + { file: "SC1.j2c", width: 2048, height: 2487, sha256: "1c8e43cef2a3b25b5304c3dd1732e64c2f44d05d342387ea8e15ce01ec793c32" }, + { file: "XA1.j2c", width: 1024, height: 1024, sha256: "797b3375a2d1f94ccac04c657b5b5d90d9b4051f76508c867f2dea465d1a7f3b" }, +] + +describe("openjphjs HTJ2K decode corpus", () => { + let codec + + beforeAll(async () => { + if (isBuilt) codec = await loadModule(modulePath) + }) + + it.skipIf(!isBuilt).each(corpus)( + "decodes $file to pixels matching its pinned SHA-256", + ({ file, width, height, sha256 }) => { + const encoded = readFileSync(resolve(fixturesDir, "j2c", file)) + const decoder = new codec.HTJ2KDecoder() + decoder.getEncodedBuffer(encoded.length).set(encoded) + decoder.decode() + + const frameInfo = decoder.getFrameInfo() + expect(frameInfo.width).toBe(width) + expect(frameInfo.height).toBe(height) + + const decoded = decoder.getDecodedBuffer() + const actual = createHash("sha256").update(decoded).digest("hex") + expect(actual).toBe(sha256) + + decoder.delete() + } + ) +}) diff --git a/packages/openjphjs/test/decode.test.js b/packages/openjphjs/test/decode.test.js index 8a9da43..f37f93d 100644 --- a/packages/openjphjs/test/decode.test.js +++ b/packages/openjphjs/test/decode.test.js @@ -28,6 +28,12 @@ describe("openjphjs HTJ2K decode", () => { if (isBuilt) codec = await loadModule(modulePath) }) + // In CI a missing dist means the build/artifact pipeline broke; fail loudly + // instead of letting every skipIf() below silently skip the suite. + it.runIf(process.env.CI)("dist is present in CI", () => { + expect(isBuilt, "openjphjs.js missing — build artifact was not replayed").toBe(true) + }) + it.skipIf(!isBuilt)( "decodes CT1.j2c to a 512x512 16-bit monochrome frame matching CT1.RAW", () => { @@ -83,7 +89,10 @@ describe("openjphjs HTJ2K encode + round-trip", () => { encoder.getDecodedBuffer(frameInfo).set(ct1Raw) encoder.encode() const encoded = encoder.getEncodedBuffer() - expect(encoded.length).toBeGreaterThan(0) + // Measured 185183 bytes on 2026-07-07 (emsdk 3.1.74); ceiling = + // compression regression, floor = silent truncation. + expect(encoded.length).toBeGreaterThan(185183 * 0.5) + expect(encoded.length).toBeLessThan(185183 * 1.10) const decoder = new codec.HTJ2KDecoder() decoder.getEncodedBuffer(encoded.length).set(encoded) diff --git a/packages/openjphjs/test/fixtures/j2c/CT2-gray12.j2c b/packages/openjphjs/test/fixtures/j2c/CT2-gray12.j2c new file mode 100644 index 0000000..d8fa3f5 Binary files /dev/null and b/packages/openjphjs/test/fixtures/j2c/CT2-gray12.j2c differ diff --git a/packages/openjphjs/test/fixtures/j2c/CT2-gray8.j2c b/packages/openjphjs/test/fixtures/j2c/CT2-gray8.j2c new file mode 100644 index 0000000..e781bad Binary files /dev/null and b/packages/openjphjs/test/fixtures/j2c/CT2-gray8.j2c differ diff --git a/packages/openjphjs/test/fixtures/j2c/US1-color-ct.j2c b/packages/openjphjs/test/fixtures/j2c/US1-color-ct.j2c new file mode 100644 index 0000000..3800212 Binary files /dev/null and b/packages/openjphjs/test/fixtures/j2c/US1-color-ct.j2c differ diff --git a/packages/openjphjs/test/fixtures/j2c/US1-color-nct.j2c b/packages/openjphjs/test/fixtures/j2c/US1-color-nct.j2c new file mode 100644 index 0000000..b1a422f Binary files /dev/null and b/packages/openjphjs/test/fixtures/j2c/US1-color-nct.j2c differ diff --git a/packages/openjphjs/test/heap-stability.test.js b/packages/openjphjs/test/heap-stability.test.js new file mode 100644 index 0000000..0380324 --- /dev/null +++ b/packages/openjphjs/test/heap-stability.test.js @@ -0,0 +1,85 @@ +import { beforeAll, describe, expect, it } from "vitest" +import { existsSync, readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { dirname, resolve } from "node:path" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const distDir = resolve(__dirname, "../dist") +const fixturesDir = resolve(__dirname, "fixtures") + +const isBuilt = existsSync(resolve(distDir, "openjphjs.js")) + +async function loadModule(path) { + const mod = await import(path) + const factory = mod.default ?? mod + return await factory() +} + +// Emscripten heaps grow but never shrink, so a native-side leak shows up as +// monotonic HEAP8 growth across repeated use — invisible to single-decode +// tests, an OOM after thousands of frames in a viewer. This repo has a +// history of exactly that class of bug (error-path instance leaks, encoder +// handle leaks). +// +// Loop sizing matters: the arena starts at 50 MiB (INITIAL_MEMORY) with +// ALLOW_MEMORY_GROWTH, so a leak only becomes visible once the leaked +// instances exceed the arena's free slack; 100+ instance-sized leaks +// comfortably exceed it (see the charls twin of this file for the +// calibration measurements). +describe("openjphjs wasm heap stability", { timeout: 120000 }, () => { + let codec + const encoded = readFileSync(resolve(fixturesDir, "j2c/CT1.j2c")) + const raw = readFileSync(resolve(fixturesDir, "raw/CT1.RAW")) + + beforeAll(async () => { + if (isBuilt) codec = await loadModule("../dist/openjphjs.js") + }) + + it.skipIf(!isBuilt)("repeated decode/delete cycles do not grow the heap", () => { + const decodeOnce = () => { + const decoder = new codec.HTJ2KDecoder() + decoder.getEncodedBuffer(encoded.length).set(encoded) + decoder.decode() + decoder.delete() + } + for (let i = 0; i < 10; i++) decodeOnce() + const settled = codec.HEAP8.length + for (let i = 0; i < 100; i++) decodeOnce() + expect(codec.HEAP8.length).toBe(settled) + }) + + it.skipIf(!isBuilt)("repeated encode/delete cycles do not grow the heap", () => { + const encodeOnce = () => { + const encoder = new codec.HTJ2KEncoder() + encoder.getDecodedBuffer({ width: 512, height: 512, bitsPerSample: 16, componentCount: 1, isSigned: true, isUsingColorTransform: false }).set(raw) + encoder.encode() + encoder.delete() + } + for (let i = 0; i < 10; i++) encodeOnce() + const settled = codec.HEAP8.length + for (let i = 0; i < 60; i++) encodeOnce() + expect(codec.HEAP8.length).toBe(settled) + }) + + it.skipIf(!isBuilt)("repeated failing decodes do not grow the heap", () => { + // Garbage (not truncated-after-valid-header) input: fails fast at + // header parse instead of spending seconds in sample recovery, so the + // loop can be large enough for a leak to exceed the arena slack. + const garbage = new Uint8Array(64) + for (let i = 0; i < garbage.length; i++) garbage[i] = (i * 37 + 11) % 256 + const failOnce = () => { + const decoder = new codec.HTJ2KDecoder() + decoder.getEncodedBuffer(garbage.length).set(garbage) + try { + decoder.decode() + } catch { + // expected for malformed input + } + decoder.delete() + } + for (let i = 0; i < 10; i++) failOnce() + const settled = codec.HEAP8.length + for (let i = 0; i < 100; i++) failOnce() + expect(codec.HEAP8.length).toBe(settled) + }) +}) diff --git a/packages/openjphjs/test/matrix.test.js b/packages/openjphjs/test/matrix.test.js new file mode 100644 index 0000000..1ade60c --- /dev/null +++ b/packages/openjphjs/test/matrix.test.js @@ -0,0 +1,79 @@ +import { beforeAll, describe, expect, it } from "vitest" +import { existsSync, readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { dirname, resolve } from "node:path" +import { gray8FromCT2, gray12FromCT2 } from "../../../tools/fixture-verification/gen/derive.mjs" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const distDir = resolve(__dirname, "../dist") +const fixturesDir = resolve(__dirname, "fixtures") + +const isBuilt = existsSync(resolve(distDir, "openjphjs.js")) + +async function loadModule(path) { + const mod = await import(path) + const factory = mod.default ?? mod + return await factory() +} + +const asBuffer = (ta) => Buffer.from(ta.buffer, ta.byteOffset, ta.byteLength) + +// Color and bit-depth coverage beyond the 16-bit CT corpus. +// +// Fixture provenance (tools/fixture-verification/gen/generate-fixtures.mjs): +// all four are lossless HTJ2K encodes of committed sources (US1.RAW RGB +// frame; deterministic CT2.RAW transforms from derive.mjs), so each test's +// reference is re-derived from the source — a decoder regression on these +// paths breaks byte equality. +// +// The color pair covers both isUsingColorTransform settings — the RCT path +// was flagged "not been tested yet" in HTJ2KDecoder.hpp. +describe("openjphjs HTJ2K decode matrix — color and bit depths", () => { + let codec + const us1 = readFileSync(resolve(__dirname, "../../openjpeg/test/fixtures/raw/US1.RAW")) + const ct2 = readFileSync(resolve(__dirname, "../../charls/test/fixtures/CT2.RAW")) + + beforeAll(async () => { + if (isBuilt) codec = await loadModule("../dist/openjphjs.js") + }) + + it.runIf(process.env.CI)("dist is present in CI", () => { + expect(isBuilt, "openjphjs.js missing — build artifact was not replayed").toBe(true) + }) + + const decode = (file) => { + const encoded = readFileSync(resolve(fixturesDir, "j2c", file)) + const decoder = new codec.HTJ2KDecoder() + decoder.getEncodedBuffer(encoded.length).set(encoded) + decoder.decode() + const frameInfo = decoder.getFrameInfo() + const out = Buffer.from(decoder.getDecodedBuffer()) + decoder.delete() + return { frameInfo, out } + } + + it.skipIf(!isBuilt)("decodes 3-component color losslessly (no color transform)", () => { + const { frameInfo, out } = decode("US1-color-nct.j2c") + expect(frameInfo.componentCount).toBe(3) + expect(frameInfo.bitsPerSample).toBe(8) + expect(out.equals(us1)).toBe(true) + }) + + it.skipIf(!isBuilt)("decodes 3-component color losslessly (reversible color transform)", () => { + const { frameInfo, out } = decode("US1-color-ct.j2c") + expect(frameInfo.componentCount).toBe(3) + expect(out.equals(us1)).toBe(true) + }) + + it.skipIf(!isBuilt)("decodes 8-bit grayscale losslessly", () => { + const { frameInfo, out } = decode("CT2-gray8.j2c") + expect(frameInfo.bitsPerSample).toBe(8) + expect(out.equals(asBuffer(gray8FromCT2(ct2)))).toBe(true) + }) + + it.skipIf(!isBuilt)("decodes 12-bit grayscale losslessly", () => { + const { frameInfo, out } = decode("CT2-gray12.j2c") + expect(frameInfo.bitsPerSample).toBe(12) + expect(out.equals(asBuffer(gray12FromCT2(ct2)))).toBe(true) + }) +}) diff --git a/packages/openjphjs/vitest.config.mjs b/packages/openjphjs/vitest.config.mjs index 7262e3f..cc3d2b5 100644 --- a/packages/openjphjs/vitest.config.mjs +++ b/packages/openjphjs/vitest.config.mjs @@ -4,6 +4,18 @@ import codspeedPlugin from "@codspeed/vitest-plugin" export default defineConfig({ plugins: [codspeedPlugin()], test: { + // Under the CodSpeed simulation instrument the entire process runs ~60x + // slower under valgrind while vitest's hard-coded 60s worker-RPC timer + // counts real seconds, so large bench suites structurally hit "Timeout + // calling onTaskUpdate" AFTER their benches complete and upload. Ignore + // that exit-code noise in simulation only; walltime and test runs stay + // strict. + // (CODSPEED_ENV is set whenever the CodSpeed runner is active; the + // mode string is "instrumentation" on older runners and "simulation" + // on newer ones, so match anything except walltime.) + dangerouslyIgnoreUnhandledErrors: + process.env.CODSPEED_ENV !== undefined && + process.env.CODSPEED_RUNNER_MODE !== "walltime", name: "openjphjs", include: ["test/**/*.test.js"], benchmark: { diff --git a/tools/browser-smoke/blank.html b/tools/browser-smoke/blank.html new file mode 100644 index 0000000..45692a6 --- /dev/null +++ b/tools/browser-smoke/blank.html @@ -0,0 +1 @@ +codec smoke diff --git a/tools/browser-smoke/run.js b/tools/browser-smoke/run.js new file mode 100644 index 0000000..1a15175 --- /dev/null +++ b/tools/browser-smoke/run.js @@ -0,0 +1,130 @@ +#!/usr/bin/env node +// Browser smoke-decode: loads every wasm/asm.js build variant of every +// codec in headless Chromium, decodes its reference fixture IN THE PAGE, +// and compares the SHA-256 of the decoded pixels against the RAW reference. +// +// This is the coverage node tests cannot give: emscripten glue differences +// that only manifest in browsers (wasm URL resolution/locateFile, fetch vs +// filesystem loading, streaming-compile MIME fallbacks) — exactly what +// changes across emsdk versions. +// +// Usage: node tools/browser-smoke/run.js +// Requires: built dists, npx playwright install chromium (cached in CI). +"use strict"; + +const http = require("http"); +const path = require("path"); +const fs = require("fs"); +const crypto = require("crypto"); +const { chromium } = require("playwright-core"); + +const repoRoot = path.resolve(__dirname, "../.."); + +// [package, dist module, decoder class, encoded fixture, reference raw] +// Every entry decodes CT1/jpeg400 and must hash-match its committed RAW. +const VARIANTS = [ + ["charls", "charlsjs.js", "JpegLSDecoder", "charls/test/fixtures/CT1.JLS", "charls/test/fixtures/CT1.RAW"], + ["charls", "charlswasm.js", "JpegLSDecoder", "charls/test/fixtures/CT1.JLS", "charls/test/fixtures/CT1.RAW"], + ["charls", "charlswasm_decode.js", "JpegLSDecoder", "charls/test/fixtures/CT1.JLS", "charls/test/fixtures/CT1.RAW"], + ["openjpeg", "openjpegjs.js", "J2KDecoder", "openjpeg/test/fixtures/j2k/CT1.j2k", "openjpeg/test/fixtures/raw/CT1.RAW"], + ["openjpeg", "openjpegwasm.js", "J2KDecoder", "openjpeg/test/fixtures/j2k/CT1.j2k", "openjpeg/test/fixtures/raw/CT1.RAW"], + ["openjpeg", "openjpegwasm_decode.js", "J2KDecoder", "openjpeg/test/fixtures/j2k/CT1.j2k", "openjpeg/test/fixtures/raw/CT1.RAW"], + ["openjphjs", "openjphjs.js", "HTJ2KDecoder", "openjphjs/test/fixtures/j2c/CT1.j2c", "openjphjs/test/fixtures/raw/CT1.RAW"], + ["libjpeg-turbo-8bit", "libjpegturbojs.js", "JPEGDecoder", "libjpeg-turbo-8bit/test/fixtures/jpeg/jpeg400jfif.jpg", "libjpeg-turbo-8bit/test/fixtures/raw/jpeg400jfif.raw"], + ["libjpeg-turbo-8bit", "libjpegturbowasm.js", "JPEGDecoder", "libjpeg-turbo-8bit/test/fixtures/jpeg/jpeg400jfif.jpg", "libjpeg-turbo-8bit/test/fixtures/raw/jpeg400jfif.raw"], +]; + +const MIME = { + ".js": "text/javascript", + ".mjs": "text/javascript", + ".wasm": "application/wasm", // correct MIME so streaming compilation runs + ".html": "text/html", + ".mem": "application/octet-stream", +}; + +function startServer() { + const server = http.createServer((req, res) => { + const urlPath = decodeURIComponent(new URL(req.url, "http://x").pathname); + const filePath = path.join(repoRoot, urlPath); + if (!filePath.startsWith(repoRoot) || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + res.writeHead(404); + res.end("not found"); + return; + } + res.writeHead(200, { "Content-Type": MIME[path.extname(filePath)] ?? "application/octet-stream" }); + fs.createReadStream(filePath).pipe(res); + }); + return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(server))); +} + +// Runs inside the page. The dists are MODULARIZE=1 UMD outputs: loading via +// a classic