From fa46626a94a480f1734d119b927ea60d1b288634 Mon Sep 17 00:00:00 2001 From: 343dev <343dev@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:17:45 +0700 Subject: [PATCH 1/8] chore: configure the repository development environment --- .devcontainer/devcontainer.json | 9 +++++++++ .devcontainer/docker-compose.yml | 13 +++++++++++++ .devcontainer/on-create.sh | 13 +++++++++++++ .devcontainer/post-create.sh | 5 +++++ .gitignore | 1 - .npmrc | 4 ++++ 6 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/docker-compose.yml create mode 100755 .devcontainer/on-create.sh create mode 100755 .devcontainer/post-create.sh create mode 100644 .npmrc diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..2931324 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,9 @@ +{ + "name": "optimizt", + "workspaceFolder": "/workspace", + "dockerComposeFile": "./docker-compose.yml", + "service": "app", + "remoteUser": "root", + "onCreateCommand": ".devcontainer/on-create.sh", + "postCreateCommand": ".devcontainer/post-create.sh" +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..6fe9415 --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,13 @@ +name: optimizt + +services: + app: + image: node:24.18.0-trixie-slim + command: sleep infinity + environment: + - SSH_AUTH_SOCK=/ssh-auth.sock + volumes: + - ../:/workspace:cached + - ~/.gitconfig:/root/.gitconfig:ro + - ~/.config/git/ignore:/root/.config/git/ignore:ro + - /run/host-services/ssh-auth.sock:/ssh-auth.sock diff --git a/.devcontainer/on-create.sh b/.devcontainer/on-create.sh new file mode 100755 index 0000000..e9a5b7e --- /dev/null +++ b/.devcontainer/on-create.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -euo pipefail + +export DEBIAN_FRONTEND=noninteractive + +apt update +apt install --yes --no-install-recommends \ + ca-certificates \ + openssh-client \ + git + +rm -rf /var/lib/apt/lists/* diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100755 index 0000000..4d05eb3 --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -euo pipefail + +npm ci --no-audit diff --git a/.gitignore b/.gitignore index 9e7e583..b509c88 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ -.devcontainer/ coverage/ node_modules/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..e80a184 --- /dev/null +++ b/.npmrc @@ -0,0 +1,4 @@ +allow-git=none +fund=false +git-tag-version=false +min-release-age=3 From e0fd39a658881f21f0c699718ea07bbd7766aa04 Mon Sep 17 00:00:00 2001 From: 343dev <343dev@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:17:48 +0700 Subject: [PATCH 2/8] feat(cli)!: make image processing automation-safe Validate and plan filesystem operations before loading configuration, preserve independent successful work after runtime failures, write outputs atomically, and expose reliable process statuses and stream separation. Align local and CI validation and document the new process, filesystem, and integration contracts. BREAKING CHANGE: processing diagnostics now use stderr, invalid operands and runtime failures return non-zero status, unsafe output mappings are rejected, and generated names are no longer sanitized. --- .github/workflows/ci-nodejs.yml | 3 + CHANGELOG.md | 109 ++-- MIGRATION.md | 61 ++ README.md | 20 +- cli.js | 94 ++-- convert.js | 140 ++--- docs/github.md | 196 ++----- index.js | 73 ++- lib/atomic-write.js | 26 + lib/check-path-accessibility.js | 10 - lib/colorize.js | 6 +- lib/create-progress-bar-container.js | 26 +- lib/describe-codec-failure.js | 7 + lib/find-config-file-path.js | 42 +- lib/lifecycle.js | 45 ++ lib/log.js | 42 +- lib/outcome-status.js | 8 + lib/prepare-file-paths.js | 93 --- lib/prepare-operation-plan.js | 260 +++++++++ lib/prepare-output-directory-path.js | 24 - lib/show-total.js | 43 +- lib/stream-capabilities.js | 7 + optimize.js | 98 ++-- package-lock.json | 20 +- package.json | 10 +- tests/cli.test.js | 593 -------------------- tests/create-progress-bar-container.test.js | 52 ++ tests/filesystem-safety.test.js | 193 +++++++ tests/formats.test.js | 115 ++++ tests/helpers/cli.js | 107 ++++ tests/helpers/platform.js | 5 + tests/interruption.test.js | 96 ++++ tests/lifecycle.test.js | 69 +++ tests/log.test.js | 101 +--- tests/operation-planning.test.js | 322 +++++++++++ tests/prepare-file-paths.test.js | 71 --- tests/prepare-output-path.test.js | 42 -- tests/process-contract.test.js | 276 +++++++++ tests/show-total.test.js | 67 ++- vitest.config.js | 11 + 40 files changed, 2176 insertions(+), 1407 deletions(-) create mode 100644 lib/atomic-write.js delete mode 100644 lib/check-path-accessibility.js create mode 100644 lib/describe-codec-failure.js create mode 100644 lib/lifecycle.js create mode 100644 lib/outcome-status.js delete mode 100644 lib/prepare-file-paths.js create mode 100644 lib/prepare-operation-plan.js delete mode 100644 lib/prepare-output-directory-path.js create mode 100644 lib/stream-capabilities.js delete mode 100644 tests/cli.test.js create mode 100644 tests/create-progress-bar-container.test.js create mode 100644 tests/filesystem-safety.test.js create mode 100644 tests/formats.test.js create mode 100644 tests/helpers/cli.js create mode 100644 tests/helpers/platform.js create mode 100644 tests/interruption.test.js create mode 100644 tests/lifecycle.test.js create mode 100644 tests/operation-planning.test.js delete mode 100644 tests/prepare-file-paths.test.js delete mode 100644 tests/prepare-output-path.test.js create mode 100644 tests/process-contract.test.js create mode 100644 vitest.config.js diff --git a/.github/workflows/ci-nodejs.yml b/.github/workflows/ci-nodejs.yml index 6a58fec..a0d8290 100644 --- a/.github/workflows/ci-nodejs.yml +++ b/.github/workflows/ci-nodejs.yml @@ -20,6 +20,9 @@ jobs: macos-15-intel, # x64 macos-15, # arm64 ] + include: + - os: windows-latest + node-version: 22.22.1 steps: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 diff --git a/CHANGELOG.md b/CHANGELOG.md index 713050a..253da3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,28 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [13.0.0] - 2026-06-27 +## Unreleased + +### Added + +- Added `--no-color` to disable colored output and `--debug` to include stack traces and version details in errors. + +### Changed + +- **BREAKING:** Processing now exits with status `1` when argument validation, filesystem preflight, configuration loading, or any image operation fails. A valid run with no eligible images still exits `0`. +- **BREAKING:** Status, progress, warnings, errors, and summaries now use `stderr`. `stdout` is reserved for help and version output and remains empty during image processing. +- **BREAKING:** Missing or inaccessible explicit operands and explicitly supplied files with unsupported extensions now fail instead of being silently ignored. Unsupported files found while traversing directories remain ignored. +- **BREAKING:** `--force` is rejected unless `--avif` or `--webp` is selected. +- **BREAKING:** Unsafe prefixes, suffixes, and generated filenames are rejected instead of being silently sanitized. +- **BREAKING:** A custom CJS configuration must define an object for the selected mode. It continues to replace, rather than merge with, the bundled configuration for that mode. +- Optimizt now validates the complete input and output request before loading configuration or changing images. Unsafe mappings, output collisions, incomplete traversal, and unsafe replacement targets fail before processing starts. +- Image writes now atomically replace complete targets, preserving existing permissions and, when permitted, ownership. Multiply hard-linked targets are not replaced. +- Independent operations continue after a runtime failure and keep their successful outputs, while the invocation still exits with status `1`. +- Redirected output no longer contains animated progress or terminal decoration. Progress totals count generated outputs, and summaries distinguish optimization savings from bytes created by conversion. +- Equivalent operands and inputs are processed once. Symbolic links retain their documented placement and replacement behavior. +- Interrupt handling now stops new work, terminates cancellable encoders, and reports conventional `SIGINT` and `SIGTERM` exit statuses on POSIX. + +## [13.0.0](https://github.com/343dev/optimizt/releases/tag/v13.0.0) - 2026-06-27 ### Changed @@ -19,13 +40,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Removed the `gcompat` package from the Docker image build. -## [12.1.1] - 2026-01-02 +## [12.1.1](https://github.com/343dev/optimizt/releases/tag/v12.1.1) - 2026-01-02 ### Changed - Updated `@343dev/gifsicle` and `@343dev/guetzli` packages, which now allow Optimizt to work on Alpine Linux without installing additional packages. Previously, the `gcompat` package was required as the `guetzli` and `gifsicle` binaries were built for glibc-based systems, which is absent on Alpine Linux. -## [12.1.0] - 2025-12-24 +## [12.1.0](https://github.com/343dev/optimizt/releases/tag/v12.1.0) - 2025-12-24 ### Added @@ -35,7 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Updated log output to display output filenames instead of input filenames. -## [12.0.0] - 2025-12-23 +## [12.0.0](https://github.com/343dev/optimizt/releases/tag/v12.0.0) - 2025-12-23 ### Added @@ -47,7 +68,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Update SVGO config in default [.optimiztrc.cjs](.optimiztrc.cjs) to migrate from v3 to v4 (see [migration guide](./MIGRATION.md)). - Update all dependencies to latest versions with a 7-day cooling period and fix versions to prevent security vulnerabilities during installations. -## [11.0.0] - 2025-05-04 +## [11.0.0](https://github.com/343dev/optimizt/releases/tag/v11.0.0) - 2025-05-04 ### Added @@ -69,7 +90,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - This means `--lossy=N` will behave differently than in previous versions and may compress less than expected. - For behavior similar to previous versions, use `--lossy=N --gamma=1`. -## [10.0.0] - 2024-10-28 +## 10.0.0 - 2024-10-28 ### Changed @@ -81,13 +102,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Removed the "webpGif" section from [.optimiztrc.cjs](.optimiztrc.cjs). -## [9.1.1] - 2024-10-22 +## 9.1.1 - 2024-10-22 ### Changed - Replaced [imagemin/guetzli-bin](https://github.com/imagemin/guetzli-bin) with [343dev/guetzli](https://github.com/343dev/guetzli). -## [9.1.0] - 2024-10-16 +## 9.1.0 - 2024-10-16 ### Changed @@ -97,19 +118,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 For this reason I decided to disable parallel optimization of JPEG files in Lossless mode. Now, it will take more time but will have less impact on the OS performance. -## [9.0.2] - 2024-10-08 +## 9.0.2 - 2024-10-08 ### Fixed - Fixed Guetzli install. -## [9.0.1] - 2024-10-08 +## 9.0.1 - 2024-10-08 ### Fixed - Fixed installing on Windows. -## [9.0.0] - 2024-10-03 +## 9.0.0 - 2024-10-03 ### Added @@ -126,7 +147,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed the progress indicator in conversion mode. It now correctly shows the total number of items. - Fixed output to the user directory. The original folder structure is now preserved (I hope so!). -## [8.0.0] - 2024-08-05 +## 8.0.0 - 2024-08-05 ### Added @@ -141,52 +162,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Removed convert ratio check for AVIF and WebP. -## [7.0.0] - 2024-02-02 +## 7.0.0 - 2024-02-02 ### Changed - SVGO config updated. - Minimum Node.js version is set to 18.17. -## [6.0.0] - 2023-08-27 +## 6.0.0 - 2023-08-27 ### Removed - Drop support for Node.js 14. -## [5.0.1] - 2023-06-04 +## 5.0.1 - 2023-06-04 ### Fixed - Fixed an [issue](https://github.com/funbox/optimizt/issues/63) with an incorrect path to the configuration file on Windows systems. -## [5.0.0] - 2023-05-19 +## 5.0.0 - 2023-05-19 ### Removed - Removed `removeOffCanvasPaths` plugin from SVGO config due to known bugs: [svg/svgo#1732](https://github.com/svg/svgo/issues/1732), [svg/svgo#1646](https://github.com/svg/svgo/issues/1646). - Removed import of `removeUnknownsAndDefaults` plugin from the default config to make it easy to redefine the config on the user side. -## [4.1.1] - 2023-02-27 +## 4.1.1 - 2023-02-27 ### Fixed - Fixed installation and included the configuration file to the package. -## [4.1.0] - 2023-01-27 +## 4.1.0 - 2023-01-27 ### Added - Added `--config` flag, which allows specifying path to file with custom settings. -## [4.0.0] - 2022-05-27 +## 4.0.0 - 2022-05-27 ### Changed - This package is now pure ESM. - In lossy mode, JPEG files are now processed by sharp module. -## [3.1.2] - 2022-05-06 +## 3.1.2 - 2022-05-06 ### Added @@ -196,37 +217,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed total saved size calculation. -## [3.1.1] - 2022-04-08 +## 3.1.1 - 2022-04-08 ### Removed - Removed logging unsupported symbols in CI environment. -## [3.1.0] - 2022-02-04 +## 3.1.0 - 2022-02-04 ### Added - Added `--output` flag, which allows output to be written to provided directory. -## [3.0.0] - 2022-01-31 +## 3.0.0 - 2022-01-31 ### Removed - Removed [pngquant-bin](https://github.com/imagemin/pngquant-bin) due to license issues. -## [2.7.5] - 2021-09-23 +## 2.7.5 - 2021-09-23 ### Fixed - Fixed "Cannot find module" error that occurred if Optimizt was installed using Yarn. -## [2.7.4] - 2021-09-22 +## 2.7.4 - 2021-09-22 ### Fixed - Fixed SVG processing. -## [2.7.3] - 2021-09-14 +## 2.7.3 - 2021-09-14 ### Changed @@ -235,13 +256,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - - -## [2.7.2] - 2021-06-21 +## 2.7.2 - 2021-06-21 ### Fixed - Sometimes Optimizt could return empty JPEG files. Now it's fixed. -## [2.7.1] - 2021-06-18 +## 2.7.1 - 2021-06-18 ### Added @@ -251,25 +272,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Disabled cursor hiding in progress bar. -## [2.7.0] - 2021-06-16 +## 2.7.0 - 2021-06-16 ### Added - Added display of summary. -## [2.6.1] - 2021-06-11 +## 2.6.1 - 2021-06-11 ### Fixed - Fixed "Only YUV color space input jpeg is supported" error. Now Optimizt may set sRGB color space for images before processing them. -## [2.6.0] - 2021-05-28 +## 2.6.0 - 2021-05-28 ### Changed - Spinner was replaced with a progress bar. -## [2.5.0] - 2021-04-08 +## 2.5.0 - 2021-04-08 ### Added @@ -280,32 +301,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Enabled use of [LZW compression](https://github.com/kohler/gifsicle/commit/0fd160b506ab0c4bce9f6852b494dc2b4ac9733f) to optimize GIF files in lossy mode. -## [2.4.2] - 2021-02-19 +## 2.4.2 - 2021-02-19 ### Fixed - Fixed ratio logging. -## [2.4.1] - 2021-02-16 +## 2.4.1 - 2021-02-16 ### Added - Added Troubleshooting section to [README.md](README.md). - Added error handler for `jpegoptim` child process. -## [2.4.0] - 2021-01-27 +## 2.4.0 - 2021-01-27 ### Added - Added AVIF support. -## [2.3.1] - 2021-01-25 +## 2.3.1 - 2021-01-25 ### Fixed - Fixed GIF to WebP conversion. -## [2.3.0] - 2021-01-25 +## 2.3.0 - 2021-01-25 ### Added @@ -319,20 +340,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed a bug with JPEG processing (jpegoptim could crash with some files). -## [2.2.0] - 2020-10-28 +## 2.2.0 - 2020-10-28 ### Changed - Updated dependencies. - Unpinned dependencies versions to major range. -## [2.1.0] - 2020-10-27 +## 2.1.0 - 2020-10-27 ### Added - Added disabling coloring output when no TTY found. -## [2.0.1] - 2020-10-12 +## 2.0.1 - 2020-10-12 ### Changed @@ -340,20 +361,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Optimized [prepareFilePaths](lib/prepareFilePaths.js) function. - Updated images used in README. -## [2.0.0] - 2020-10-07 +## 2.0.0 - 2020-10-07 ### Changed - Prepared the package for publishing on GitHub. - Updated some deps, fixed small linter errors, added LICENSE. -## [1.0.1] - 2020-06-09 +## 1.0.1 - 2020-06-09 ### Changed - Removed useless files from the bundled package. -## [1.0.0] - 2020-05-18 +## 1.0.0 - 2020-05-18 ### Added @@ -366,7 +387,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - First major version! -## [0.1.0] - 2020-04-06 +## 0.1.0 - 2020-04-06 ### Added diff --git a/MIGRATION.md b/MIGRATION.md index 3c25c62..3b10e6f 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,66 @@ # Migration +## 13.0.0 → Unreleased + +Optimizt now treats the exit status and files on disk as the command result. Review scripts and custom configuration for the changes below. + +### Use the exit status, not log text + +Optimizt exits `0` after successful processing or a valid no-op, and `1` when argument validation, filesystem checks, configuration loading, or any image operation fails. Independent operations continue after a runtime failure and keep their successful outputs, but the overall command still exits `1`. + +On POSIX, handled `SIGINT` and `SIGTERM` exit with `130` and `143`. Windows interruptions exit non-zero without guaranteeing those codes. + +### Redirect human output from `stderr` + +Status, progress, warnings, errors, and summaries now go to `stderr`. `stdout` is reserved for help and version output and stays empty during image processing. + +```shell +optimizt ./images 2> optimizt.log +``` + +Do not parse summary wording; use the exit status. Redirected logs contain no animated progress or ANSI decoration. Use `--no-color`, `NO_COLOR`, or `TERM=dumb` when decoration must also be disabled on a terminal. + +### Filter explicit file lists + +Missing or inaccessible operands and explicitly named files with unsupported extensions now fail before processing. Unsupported files discovered inside a directory are still ignored. + +When passing files generated by another command, filter deleted and unsupported paths and preserve argument boundaries: + +```shell +git diff --name-only -z --diff-filter=d HEAD~1 -- '*.png' '*.jpg' '*.jpeg' '*.gif' '*.svg' \ + | xargs -0 -r optimizt -- +``` + +### Update affected options and configuration + +- Use `--force` only with `--avif` or `--webp`; it is rejected during ordinary optimization. +- Ensure `--prefix` and `--suffix` contain no path separators or NUL and produce portable filenames. Invalid names are rejected rather than rewritten. +- Ensure a custom `.optimiztrc.cjs` defines an object for the selected `optimize` or `convert` mode. The custom section replaces the bundled section; it is not merged with defaults. + +```js +module.exports = { + optimize: { + // Complete custom optimization settings + }, + convert: { + // Complete custom conversion settings + }, +}; +``` + +Discovered `.optimiztrc.cjs` files remain executable code and run with the current user's permissions. + +### Review output mappings and linked files + +Optimizt validates all outputs before processing and rejects collisions, paths escaping the selected output root, overlapping directory operands used with `--output`, and targets that cannot be replaced safely. + +Writes now atomically replace complete files and preserve existing permissions and, when permitted, ownership. This changes linked-file behavior: + +- A target with multiple hard links is not replaced. Break the link or write to a different target with `--output`. +- Optimizing an explicit symbolic link replaces its real target while preserving the link. +- Converting an explicit symbolic link writes variants next to the link path. +- Directory symbolic links are not followed during recursive traversal. + ## 12.1.1 → 13.0.0 Node.js version must be 22.22.1 or higher. diff --git a/README.md b/README.md index 3bc017e..fab2f9b 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,19 @@ Provides the best balance between file size reduction and minimal visual quality - **PNG/JPEG/GIF**: Maximizes image quality at the expense of larger file sizes. - **SVG**: Settings are identical in both modes. +## How Files Are Written + +Every image is written to a temporary file in the destination directory, synchronized to disk, and then renamed over the target. An interrupted or failed run therefore leaves the original file untouched instead of half-written, and a reader never sees a partial image. + +Alongside that: + +- The existing permission mode of a replaced file is preserved, and its ownership is preserved when the operating system permits it. +- A file with more than one hard link is never replaced, because renaming over it would break the shared inode. Creating a new output from such a source is allowed. +- Optimizing a symbolic link replaces the file it points to and keeps the link itself; converting one writes the new variant next to the link. + +> [!NOTE] +> Atomic replacement protects against partially written files. It does not guarantee that the directory entry itself survives sudden power loss, and it does not preserve file timestamps. + ## Configuration Image processing leverages: @@ -85,7 +98,10 @@ Image processing leverages: Default settings are defined in [.optimiztrc.cjs](./.optimiztrc.cjs), which includes all supported parameters. Disable any parameter by setting it to `false`. -When using `--config path/to/.optimiztrc.cjs`, the specified configuration file will be used. If no `--config` is provided, Optimizt searches recursively from the current directory upward for `.optimiztrc.cjs`. If none is found, defaults are applied. +When using `--config path/to/.optimiztrc.cjs`, the specified configuration file replaces the bundled settings for the selected mode. If no `--config` is provided, Optimizt searches recursively from the current directory upward for `.optimiztrc.cjs`. If none is found, defaults are applied. + +> [!WARNING] +> `.optimiztrc.cjs` is executable code. Auto-discovered and explicitly selected configuration runs with your user permissions; use Optimizt only in repositories you trust. ## Troubleshooting @@ -139,7 +155,7 @@ docker build --tag 343dev/optimizt https://github.com/343dev/optimizt.git ```bash # mount current directory to /src in the container -docker run --rm --volume $(pwd):/src 343dev/optimizt --webp ./image.png +docker run --rm --user "$(id -u):$(id -g)" --volume "$(pwd):/src" 343dev/optimizt --webp ./image.png ``` ## Integrations diff --git a/cli.js b/cli.js index 523d3ca..8236102 100755 --- a/cli.js +++ b/cli.js @@ -4,54 +4,70 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { program } from 'commander'; +import { Command, CommanderError } from 'commander'; import optimizt from './index.js'; +import { finishLifecycle, installSignalHandlers } from './lib/lifecycle.js'; import { setProgramOptions } from './lib/program-options.js'; const dirname = path.dirname(fileURLToPath(import.meta.url)); const packageJson = JSON.parse(await fs.readFile(path.join(dirname, 'package.json'), 'utf8')); +const program = new Command(); program - .option('--avif', 'create AVIF and exit') - .option('--webp', 'create WebP and exit') - .option('-f, --force', 'force create AVIF and WebP') - .option('-l, --lossless', 'perform lossless optimizations') - .option('-v, --verbose', 'be verbose') - .option('-c, --config ', 'use this configuration, overriding default config options if present') - .option('-o, --output ', 'write output to directory') - .option('-p, --prefix ', 'add prefix to optimized file names') - .option('-s, --suffix ', 'add suffix to optimized file names'); - -program + .name('optimizt') + .option('--avif', 'create AVIF variants') + .option('--webp', 'create WebP variants') + .option('-f, --force', 'replace existing conversion targets') + .option('-l, --lossless', 'use the lossless processing profile') + .option('-v, --verbose', 'show skipped and deduplicated work') + .option('-c, --config ', 'replace defaults with this executable CJS configuration') + .option('-o, --output ', 'write under an existing output directory') + .option('-p, --prefix ', 'add a prefix to output file names') + .option('-s, --suffix ', 'add a suffix to output file names') + .option('--no-color', 'disable color output') + .option('--debug', 'include stack traces and version details in errors') .allowExcessArguments() - .usage('[options] ') + .usage('[options] [--] ') .version(packageJson.version, '-V, --version') - .description(packageJson.description) - .parse(process.argv); - -if (program.args.length === 0) { - program.help(); -} else { - const { avif, webp, force, lossless, verbose, config, output, prefix, suffix } = program.opts(); + .description(`${packageJson.description}. Optimizes in place by default; --avif and --webp create variants.`) + .exitOverride(); - setProgramOptions({ - shouldConvertToAvif: Boolean(avif), - shouldConvertToWebp: Boolean(webp), - isForced: Boolean(force), - isLossless: Boolean(lossless), - isVerbose: Boolean(verbose), - filePrefix: prefix || '', - fileSuffix: suffix || '', - }); - - optimizt({ - inputPaths: program.args, - outputDirectoryPath: output, - configFilePath: config, - }); +let exitCode = 0; +installSignalHandlers(); +try { + program.parse(process.argv); + if (program.args.length === 0) { + program.outputHelp(); + } else { + const options = program.opts(); + if (options.force && !options.avif && !options.webp) throw new Error('--force requires --avif or --webp'); + setProgramOptions({ + filePrefix: options.prefix || '', + fileSuffix: options.suffix || '', + isForced: Boolean(options.force), + isLossless: Boolean(options.lossless), + isVerbose: Boolean(options.verbose), + shouldConvertToAvif: Boolean(options.avif), + shouldConvertToWebp: Boolean(options.webp), + }); + if (options.color === false) process.env.NO_COLOR = '1'; + const result = await optimizt({ + configFilePath: options.config, + inputPaths: program.args, + outputDirectoryPath: options.output, + }); + exitCode = result.failed > 0 ? 1 : 0; + } +} catch (error) { + if (error instanceof CommanderError && ['commander.helpDisplayed', 'commander.version'].includes(error.code)) { + exitCode = 0; + } else { + exitCode = 1; + const debug = program.opts().debug; + const message = debug && error.stack ? error.stack : error.message; + process.stderr.write(`Error: ${message}\n`); + if (debug) process.stderr.write(`Optimizt ${packageJson.version}; Node.js ${process.version}\n`); + } } - -process.on('unhandledRejection', (error) => { - console.error(error); -}); +process.exitCode = finishLifecycle() ?? exitCode; diff --git a/convert.js b/convert.js index 4b1dcc4..1f57621 100644 --- a/convert.js +++ b/convert.js @@ -1,36 +1,32 @@ import fs from 'node:fs'; import os from 'node:os'; -import path from 'node:path'; import pLimit from 'p-limit'; import sharp from 'sharp'; +import { atomicWrite } from './lib/atomic-write.js'; import { calculateRatio } from './lib/calculate-ratio.js'; -import { checkPathAccessibility } from './lib/check-path-accessibility.js'; import { createProgressBarContainer } from './lib/create-progress-bar-container.js'; import { SUPPORTED_FILE_TYPES } from './lib/constants.js'; +import { describeCodecFailure } from './lib/describe-codec-failure.js'; import { formatBytes } from './lib/format-bytes.js'; import { getPlural } from './lib/get-plural.js'; import { getRelativePath } from './lib/get-relative-path.js'; +import { isInterrupted } from './lib/lifecycle.js'; import { LOG_TYPES, log, logProgress, logProgressVerbose, } from './lib/log.js'; +import { OUTCOME_STATUS } from './lib/outcome-status.js'; import { parseImageMetadata } from './lib/parse-image-metadata.js'; import { programOptions } from './lib/program-options.js'; import { showTotal } from './lib/show-total.js'; -export async function convert({ filePaths, config }) { - const { - isForced, - isLossless, - shouldConvertToAvif, - shouldConvertToWebp, - } = programOptions; - - const filePathsCount = filePaths.length; +export async function convert({ operations, config, configPath }) { + const { isLossless } = programOptions; + const filePathsCount = new Set(operations.map(operation => operation.input)).size; if (!filePathsCount) { return; @@ -38,9 +34,7 @@ export async function convert({ filePaths, config }) { log(`Converting ${filePathsCount} ${getPlural(filePathsCount, 'image', 'images')} (${isLossless ? 'lossless' : 'lossy'})...`); - const progressBarTotal = shouldConvertToAvif && shouldConvertToWebp - ? filePathsCount * 2 - : filePathsCount; + const progressBarTotal = operations.length; const progressBarContainer = createProgressBarContainer(progressBarTotal); const progressBar = progressBarContainer.create(progressBarTotal, 0); @@ -54,80 +48,60 @@ export async function convert({ filePaths, config }) { const cpuCount = os.cpus().length; const tasksSimultaneousLimit = pLimit(cpuCount); - const tasks = []; - for (const filePath of filePaths) { - if (shouldConvertToAvif) { - tasks.push( - tasksSimultaneousLimit( - () => processFile({ - filePath, - config: avifConfig || {}, - progressBarContainer, - progressBar, - totalSize, - isForced, - format: 'AVIF', - processFunction: processAvif, - }), - ), - ); - } - - if (shouldConvertToWebp) { - tasks.push( - tasksSimultaneousLimit( - () => processFile({ - filePath, - config: webpConfig || {}, - progressBarContainer, - progressBar, - totalSize, - isForced, - format: 'WebP', - processFunction: processWebp, - }), - ), - ); - } - } - await Promise.all(tasks); + const outcomes = await Promise.all(operations.map((operation, planIndex) => tasksSimultaneousLimit(() => { + if (isInterrupted()) return { planIndex, status: OUTCOME_STATUS.UNSTARTED }; + const isAvif = operation.format === 'avif'; + return processFile({ + config: (isAvif ? avifConfig : webpConfig) || {}, + configPath, + filePath: { input: operation.input, output: operation.output }, + format: isAvif ? 'AVIF' : 'WebP', + isLossless, + processFunction: isAvif ? processAvif : processWebp, + progressBar, + progressBarContainer, + planIndex, + skipReason: operation.skipReason, + totalSize, + }); + }))); progressBarContainer.update(); // Prevent logs lost. See: https://github.com/npkgz/cli-progress/issues/145#issuecomment-1859594159 progressBarContainer.stop(); - showTotal(totalSize.before, totalSize.after); + showTotal(totalSize.before, totalSize.after, outcomes, { conversion: true }); + return { failed: outcomes.filter(outcome => outcome.status === OUTCOME_STATUS.FAILED).length, outcomes }; } async function processFile({ filePath, config, + configPath, + isLossless, progressBarContainer, progressBar, + planIndex, totalSize, - isForced, + skipReason, format, processFunction, }) { - const { dir, name } = path.parse(filePath.output); - const outputFilePath = path.join(dir, `${name}.${format.toLowerCase()}`); + const outputFilePath = filePath.output; try { - const isAccessible = await checkPathAccessibility(outputFilePath); - - if (!isForced && isAccessible) { + if (skipReason) { logProgressVerbose(getRelativePath(outputFilePath), { description: `File already exists, '${outputFilePath}'`, progressBarContainer, }); - return; + return { planIndex, status: OUTCOME_STATUS.SKIPPED }; } const fileBuffer = await fs.promises.readFile(filePath.input); - const processedFileBuffer = await processFunction({ fileBuffer, config }); + const processedFileBuffer = await processFunction({ fileBuffer, config, configPath, isLossless }); - await fs.promises.mkdir(path.dirname(outputFilePath), { recursive: true }); - await fs.promises.writeFile(outputFilePath, processedFileBuffer); + await atomicWrite(outputFilePath, processedFileBuffer); const fileSize = fileBuffer.length; const processedFileSize = processedFileBuffer.length; @@ -144,22 +118,17 @@ async function processFile({ description: `${before} → ${format} ${after}. Ratio: ${ratio}%`, progressBarContainer, }); + return { after: processedFileSize, before: fileSize, planIndex, status: OUTCOME_STATUS.PROCESSED }; } catch (error) { - if (error.message) { - logProgress(getRelativePath(outputFilePath), { - type: LOG_TYPES.ERROR, - description: (error.message || '').trim(), - progressBarContainer, - }); - } else { - progressBarContainer.log(error); - } + // Work abandoned during shutdown is left undone by the interruption, not failed. + if (isInterrupted()) return { planIndex, status: OUTCOME_STATUS.UNSTARTED }; + return { error, output: outputFilePath, planIndex, status: OUTCOME_STATUS.FAILED }; } finally { progressBar.increment(); } } -async function processAvif({ fileBuffer, config }) { +async function processAvif({ fileBuffer, config, configPath, isLossless }) { const imageMetadata = await parseImageMetadata(fileBuffer); checkImageFormat(imageMetadata.format); @@ -169,22 +138,31 @@ async function processAvif({ fileBuffer, config }) { throw new Error('Animated AVIF is not supported'); // See: https://github.com/strukturag/libheif/issues/377 } - return sharp(fileBuffer) - .rotate() // Rotate image using information from EXIF Orientation tag - .avif(config) - .toBuffer(); + // Only the codec call is enriched, so detection and support errors keep speaking for themselves. + try { + return await sharp(fileBuffer) + .rotate() // Rotate image using information from EXIF Orientation tag + .avif(config) + .toBuffer(); + } catch (error) { + throw describeCodecFailure({ configPath, error, format: 'avif', isLossless, mode: 'convert' }); + } } -async function processWebp({ fileBuffer, config }) { +async function processWebp({ fileBuffer, config, configPath, isLossless }) { const imageMetadata = await parseImageMetadata(fileBuffer); checkImageFormat(imageMetadata.format); const isAnimated = imageMetadata.pages > 1; - return sharp(fileBuffer, { animated: isAnimated }) - .rotate() // Rotate image using information from EXIF Orientation tag - .webp(config) - .toBuffer(); + try { + return await sharp(fileBuffer, { animated: isAnimated }) + .rotate() // Rotate image using information from EXIF Orientation tag + .webp(config) + .toBuffer(); + } catch (error) { + throw describeCodecFailure({ configPath, error, format: 'webp', isLossless, mode: 'convert' }); + } } function checkImageFormat(imageFormat) { diff --git a/docs/github.md b/docs/github.md index aca410d..bd6595f 100644 --- a/docs/github.md +++ b/docs/github.md @@ -1,168 +1,82 @@ -# GitHub Actions: Integrating Optimizt with “Workflow” +# GitHub Actions integration -## Example of creating AVIF and WebP versions after a push to the main branch +Use a supported Node.js release and pass filenames as distinct arguments. Do not assemble changed paths into a shell string: spaces, wildcard characters, and leading hyphens would be reinterpreted by the shell. -This workflow watches for JPEG and PNG files when you push commits to the main branch. If it finds such files, it will add AVIF and WebP versions and commit the changes. +The following workflow optimizes changed images and creates WebP variants. `tj-actions/changed-files` exposes newline-delimited paths, which the step reads without word splitting. -In the `.github/workflows` directory of your repository, create a file called `optimizt-push.yml` with the following content: - -```yml -name: Create AVIF & WebP - -on: - # Runs on "push" event for the "main" branch but only if there are changes to JPEG and PNG files. - push: - branches: - - main - paths: - - "**.jpe?g" - - "**.png" - - # Allows manual workflow trigger from the Actions tab - workflow_dispatch: - -jobs: - convert: - runs-on: ubuntu-latest - - steps: - # Install Node.js to avoid EACCESS errors during package installation - - uses: actions/setup-node@v2 - with: - node-version: 18.17.0 - - - name: Install Optimizt - run: npm install --global @343dev/optimizt - - - uses: actions/checkout@v2 - with: - persist-credentials: false # Use personal access token instead of GITHUB_TOKEN - fetch-depth: 0 # Download all commits (default is just the latest) - - - name: Run Optimizt - run: optimizt --verbose --force --avif --webp . - - - name: Commit changes - run: | - git add -A - git config --local user.email "actions@github.com" - git config --local user.name "github-actions[bot]" - git diff --quiet && git diff --staged --quiet \ - || git commit -am "Create WebP & AVIF versions" - - - name: Push changes - uses: ad-m/github-push-action@master - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - branch: ${{ github.ref }} -``` - -## Example of optimizing images in a Pull Request - -This workflow checks for images in a pull request. If it finds images, it optimizes them and also adds AVIF and WebP versions with a new commit. - -In the `.github/workflows` directory of your repository, create a file called `optimizt-pr.yml` with the following content: - -```yml +```yaml name: Optimize images on: pull_request: - # Runs when you open a new PR or push new commits to the PR's head branch types: [opened, synchronize] paths: - - '**.jpe?g' + - '**.jpg' + - '**.jpeg' - '**.png' - '**.gif' - '**.svg' - # Allows manual workflow trigger from the Actions tab - workflow_dispatch: +permissions: + contents: write jobs: - optimize-and-convert: + optimize: runs-on: ubuntu-latest - env: - IMAGES_PATHS: '' - IMAGES_PATHS_AVIF: '' - steps: - # Check out the repository so your job can access it - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: - fetch-depth: 0 # Get all commits - ref: ${{ github.head_ref }} # Checkout to the source branch of the PR - - - name: Setup Git config - run: | - git config --local user.name "github-actions[bot]" - git config --local user.email "actions@github.com" - - # Get the list of changed files - - id: changed_files - uses: jitterbit/get-changed-files@v1 - - - name: Set images paths variables - run: | # Process only added and modified file paths - for path in ${{ steps.changed_files.outputs.added_modified }}; do - if printf '%s\n' "${path}" | grep -P '(gif|jpe?g|png|svg)$'; then - IMAGES_PATHS="${IMAGES_PATHS} ${path}" - fi - - if printf '%s\n' "${path}" | grep -P '(jpe?g|png)$'; then - IMAGES_PATHS_AVIF="${IMAGES_PATHS_AVIF} ${path}" - fi - done - echo "IMAGES_PATHS=`echo ${IMAGES_PATHS} | awk '{$1=$1};1'`" >> $GITHUB_ENV - echo "IMAGES_PATHS_AVIF=`echo ${IMAGES_PATHS_AVIF} | awk '{$1=$1};1'`" >> $GITHUB_ENV + ref: ${{ github.head_ref }} + fetch-depth: 0 - # Install Node.js to avoid EACCESS errors during package installation - - uses: actions/setup-node@v2 - if: env.IMAGES_PATHS != '' || env.IMAGES_PATHS_AVIF != '' + - uses: actions/setup-node@v4 with: - node-version: '18' - - - name: Install Optimizt via npm - if: env.IMAGES_PATHS != '' || env.IMAGES_PATHS_AVIF != '' - run: npm install --global @343dev/optimizt + node-version: '22.22.1' - - name: Optimize images - if: env.IMAGES_PATHS != '' - run: optimizt --verbose ${{ env.IMAGES_PATHS }} + - run: npm install --global @343dev/optimizt - - name: Commit optimized images - if: env.IMAGES_PATHS != '' + - id: changed + uses: tj-actions/changed-files@v47 + with: + files: | + **.jpg + **.jpeg + **.png + **.gif + **.svg + separator: ${{ '\n' }} + + - name: Optimize changed images + if: steps.changed.outputs.any_changed == 'true' + env: + CHANGED_FILES: ${{ steps.changed.outputs.all_changed_files }} + shell: bash run: | - git add -A - git diff --quiet && git diff --staged --quiet \ - || git commit -m "Optimize images" - - - name: Create WebP - if: env.IMAGES_PATHS != '' - run: optimizt --webp ${{ env.IMAGES_PATHS }} - - - name: Commit WebP versions - if: env.IMAGES_PATHS != '' + mapfile -t images <<< "$CHANGED_FILES" + optimizt -- "${images[@]}" + + - name: Create WebP variants + if: steps.changed.outputs.any_changed == 'true' + env: + CHANGED_FILES: ${{ steps.changed.outputs.all_changed_files }} + shell: bash run: | - git add -A - git diff --quiet && git diff --staged --quiet \ - || git commit -m "Create WebP versions" - - - name: Create AVIF - if: env.IMAGES_PATHS_AVIF != '' - run: optimizt --avif ${{ env.IMAGES_PATHS_AVIF }} + mapfile -t images <<< "$CHANGED_FILES" + raster=() + for image in "${images[@]}"; do + case "$image" in + *.jpg|*.jpeg|*.png|*.gif) raster+=("$image") ;; + esac + done + ((${#raster[@]} == 0)) || optimizt --webp -- "${raster[@]}" - - name: Commit AVIF versions - if: env.IMAGES_PATHS_AVIF != '' + - name: Commit results run: | - git add -A - git diff --quiet && git diff --staged --quiet \ - || git commit -m "Create AVIF versions" - - - name: Push changes - if: env.IMAGES_PATHS != '' || env.IMAGES_PATHS_AVIF != '' - uses: ad-m/github-push-action@master - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - branch: ${{ github.head_ref }} + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add --all + git diff --cached --quiet || git commit -m 'chore(images): optimize images' + git push ``` + +Pin third-party actions to full commit SHAs in security-sensitive repositories. The major-version tags above keep the example readable but are mutable. diff --git a/index.js b/index.js index 5631248..d00fd50 100755 --- a/index.js +++ b/index.js @@ -2,56 +2,47 @@ import { pathToFileURL } from 'node:url'; import { convert } from './convert.js'; import { optimize } from './optimize.js'; - import { SUPPORTED_FILE_TYPES } from './lib/constants.js'; import { findConfigFilePath } from './lib/find-config-file-path.js'; import { log } from './lib/log.js'; -import { prepareFilePaths } from './lib/prepare-file-paths.js'; -import { prepareOutputDirectoryPath } from './lib/prepare-output-directory-path.js'; +import { prepareOperationPlan } from './lib/prepare-operation-plan.js'; import { programOptions } from './lib/program-options.js'; -const MODE_NAME = { - CONVERT: 'convert', - OPTIMIZE: 'optimize', -}; - -export default async function optimizt({ - inputPaths, - outputDirectoryPath, - configFilePath, -}) { - const { - isLossless, - shouldConvertToAvif, - shouldConvertToWebp, - } = programOptions; - - const shouldConvert = shouldConvertToAvif || shouldConvertToWebp; - - const currentMode = shouldConvert - ? MODE_NAME.CONVERT - : MODE_NAME.OPTIMIZE; - - const foundConfigFilePath = pathToFileURL(await findConfigFilePath(configFilePath)); - const configData = await import(foundConfigFilePath); - const config = configData.default[currentMode.toLowerCase()]; - - const filePaths = await prepareFilePaths({ - inputPaths, - outputDirectoryPath: await prepareOutputDirectoryPath(outputDirectoryPath), +export default async function optimizt({ inputPaths, outputDirectoryPath, configFilePath }) { + const { isForced, isLossless, filePrefix, fileSuffix, shouldConvertToAvif, shouldConvertToWebp } = programOptions; + const formats = [ + ...shouldConvertToAvif ? ['avif'] : [], + ...shouldConvertToWebp ? ['webp'] : [], + ]; + const currentMode = formats.length > 0 ? 'convert' : 'optimize'; + const operations = await prepareOperationPlan({ extensions: SUPPORTED_FILE_TYPES[currentMode.toUpperCase()], + force: isForced, + formats: formats.length > 0 ? formats : ['optimize'], + inputPaths, + outputDirectoryPath, + prefix: filePrefix, + suffix: fileSuffix, }); - if (isLossless) { - log('Lossless optimization may take a long time'); + if (operations.length === 0) { + log('No eligible images found'); + return { failed: 0 }; } - const processFunction = shouldConvert - ? convert - : optimize; + const configPath = await findConfigFilePath(configFilePath); + let configData; + try { + configData = await import(`${pathToFileURL(configPath).href}?loaded=${Date.now()}`); + } catch (error) { + throw new Error(`Could not load configuration ${configPath}: ${error.message}`, { cause: error }); + } + const config = configData.default?.[currentMode]; + if (!config || typeof config !== 'object' || Array.isArray(config)) { + throw new Error(`Configuration ${configPath} must define an object-valued "${currentMode}" section`); + } - await processFunction({ - filePaths, - config, - }); + if (isLossless) log('Lossless mode may take a long time; JPEG uses Guetzli and is not strictly lossless'); + const processOperations = currentMode === 'convert' ? convert : optimize; + return processOperations({ config, configPath, operations }); } diff --git a/lib/atomic-write.js b/lib/atomic-write.js new file mode 100644 index 0000000..3d620fc --- /dev/null +++ b/lib/atomic-write.js @@ -0,0 +1,26 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import writeFileAtomic from 'write-file-atomic'; + +export async function atomicWrite(filePath, data) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + let stat; + try { + stat = await fs.stat(filePath); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + + const options = { + fsync: true, + mode: stat?.mode, + chown: stat ? { gid: stat.gid, uid: stat.uid } : undefined, + }; + try { + await writeFileAtomic(filePath, data, options); + } catch (error) { + if (!options.chown || !['EACCES', 'EPERM'].includes(error.code)) throw error; + await writeFileAtomic(filePath, data, { fsync: true, mode: options.mode }); + } +} diff --git a/lib/check-path-accessibility.js b/lib/check-path-accessibility.js deleted file mode 100644 index f4788f7..0000000 --- a/lib/check-path-accessibility.js +++ /dev/null @@ -1,10 +0,0 @@ -import { access } from 'node:fs/promises'; - -export async function checkPathAccessibility(filePath) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} diff --git a/lib/colorize.js b/lib/colorize.js index 68552bd..f7c1d90 100644 --- a/lib/colorize.js +++ b/lib/colorize.js @@ -1,7 +1,9 @@ +import { canUseColor } from './stream-capabilities.js'; + export function colorize(...arguments_) { const string_ = arguments_.join(' '); - const isTTY = Boolean(process.stdout.isTTY); - const buildColor = (start, end) => `${isTTY ? start : ''}${string_}${isTTY ? end : ''}`; + const shouldColor = canUseColor(); + const buildColor = (start, end) => `${shouldColor ? start : ''}${string_}${shouldColor ? end : ''}`; return { dim: buildColor('\u{1B}[2m', '\u{1B}[22m'), diff --git a/lib/create-progress-bar-container.js b/lib/create-progress-bar-container.js index 6d92bad..d3afce9 100644 --- a/lib/create-progress-bar-container.js +++ b/lib/create-progress-bar-container.js @@ -1,10 +1,32 @@ import CliProgress from 'cli-progress'; import { getPlural } from './get-plural.js'; +import { canUseUnicode } from './stream-capabilities.js'; + +const silentProgressBar = Object.freeze({ + increment() {}, + update() {}, +}); + +// Animated progress belongs to a decorating terminal only. Elsewhere every call is +// accepted and rendered nowhere, so callers do not branch on stream capability. +const silentProgressBarContainer = Object.freeze({ + create: () => silentProgressBar, + isRendering: false, + log() {}, + stop() {}, + update() {}, +}); export function createProgressBarContainer(totalCount) { - return new CliProgress.MultiBar({ - format: '{bar} {percentage}% | Processed {value} of {total} ' + getPlural(totalCount, 'image', 'images'), + if (!canUseUnicode()) return silentProgressBarContainer; + + const container = new CliProgress.MultiBar({ + format: '{bar} {percentage}% | Processed {value} of {total} ' + getPlural(totalCount, 'operation', 'operations'), clearOnComplete: true, + stream: process.stderr, }, CliProgress.Presets.shades_classic); + container.isRendering = true; + + return container; } diff --git a/lib/describe-codec-failure.js b/lib/describe-codec-failure.js new file mode 100644 index 0000000..0814f6f --- /dev/null +++ b/lib/describe-codec-failure.js @@ -0,0 +1,7 @@ +// Codec libraries own their option schemas, so their messages arrive without any +// Optimizt context. This states which mode, format, and configuration produced the +// attempt and keeps the library's own reason intact. +export function describeCodecFailure({ error, mode, format, isLossless, configPath }) { + const profile = isLossless ? 'lossless' : 'lossy'; + return new Error(`${mode} ${format} (${profile}) using ${configPath}: ${error.message}`, { cause: error }); +} diff --git a/lib/find-config-file-path.js b/lib/find-config-file-path.js index e9ac381..ebca383 100644 --- a/lib/find-config-file-path.js +++ b/lib/find-config-file-path.js @@ -1,9 +1,8 @@ -import fs from 'node:fs'; +import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { DEFAULT_CONFIG_FILENAME } from './constants.js'; -import { logErrorAndExit } from './log.js'; const defaultDirectoryPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const defaultConfigPath = path.join(defaultDirectoryPath, DEFAULT_CONFIG_FILENAME); @@ -11,44 +10,29 @@ const defaultConfigPath = path.join(defaultDirectoryPath, DEFAULT_CONFIG_FILENAM export async function findConfigFilePath(providedConfigPath) { if (providedConfigPath) { const resolvedPath = path.resolve(providedConfigPath); - + let stat; try { - const stat = await fs.promises.stat(resolvedPath); - - if (!stat.isFile()) { - logErrorAndExit('Config path must point to a file'); - } - - return resolvedPath; - } catch { - logErrorAndExit(`Config file not exists: ${resolvedPath}`); + stat = await fs.stat(resolvedPath); + } catch (error) { + if (error.code === 'ENOENT') throw new Error(`Config file does not exist: ${resolvedPath}`, { cause: error }); + throw new Error(`Cannot inspect configuration file ${resolvedPath}: ${error.message}`, { cause: error }); } + if (!stat.isFile()) throw new Error(`Config path does not refer to a file: ${resolvedPath}`); + return resolvedPath; } let currentDirectoryPath = path.resolve(process.cwd()); - while (true) { const currentConfigPath = path.join(currentDirectoryPath, DEFAULT_CONFIG_FILENAME); - try { - const stat = await fs.promises.stat(currentConfigPath); - - if (stat.isFile()) { - return currentConfigPath; - } - } catch { - // File not found, continue searching + const stat = await fs.stat(currentConfigPath); + if (stat.isFile()) return currentConfigPath; + } catch (error) { + if (error.code !== 'ENOENT') throw new Error(`Cannot inspect configuration ${currentConfigPath}: ${error.message}`, { cause: error }); } - const parentDirectoryPath = path.dirname(currentDirectoryPath); - - if (parentDirectoryPath === currentDirectoryPath) { - // Reached the root of the file system - break; - } - + if (parentDirectoryPath === currentDirectoryPath) break; currentDirectoryPath = parentDirectoryPath; } - return defaultConfigPath; } diff --git a/lib/lifecycle.js b/lib/lifecycle.js new file mode 100644 index 0000000..f0630ca --- /dev/null +++ b/lib/lifecycle.js @@ -0,0 +1,45 @@ +const state = { + activeChildren: new Set(), + forceExitTimer: undefined, + interruptCount: 0, + interruptedSignal: undefined, +}; + +export function registerChild(child) { + state.activeChildren.add(child); + child.once('close', () => state.activeChildren.delete(child)); + + // An operation already in flight can reach its external encoder after the interrupt + // arrived. No encoder may keep running once shutdown started, whenever it was spawned. + if (isInterrupted()) child.kill('SIGTERM'); +} + +export function installSignalHandlers() { + for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => { + state.interruptCount += 1; + state.interruptedSignal ||= signal; + // A second interrupt is the emergency escape hatch and must not wait for cleanup. + if (state.interruptCount > 1) process.exit(signalExitCode(signal)); // eslint-disable-line n/no-process-exit + for (const child of state.activeChildren) child.kill('SIGTERM'); + // Sharp offers no cancellation for a running pipeline, so shutdown is bounded + // instead: whatever is still native-bound loses the process after five seconds. + state.forceExitTimer = setTimeout(() => process.exit(signalExitCode(signal)), 5000); // eslint-disable-line n/no-process-exit + state.forceExitTimer.unref(); + }); + } +} + +export function isInterrupted() { + return Boolean(state.interruptedSignal); +} + +export function finishLifecycle() { + if (state.forceExitTimer) clearTimeout(state.forceExitTimer); + return state.interruptedSignal ? signalExitCode(state.interruptedSignal) : undefined; +} + +function signalExitCode(signal) { + if (process.platform === 'win32') return 1; + return signal === 'SIGINT' ? 130 : 143; +} diff --git a/lib/log.js b/lib/log.js index 2bbf3d5..2712888 100644 --- a/lib/log.js +++ b/lib/log.js @@ -1,7 +1,9 @@ import { EOL } from 'node:os'; +import { format } from 'node:util'; import { colorize } from './colorize.js'; import { programOptions } from './program-options.js'; +import { canUseUnicode } from './stream-capabilities.js'; export const LOG_TYPES = { INFO: 'info', @@ -19,56 +21,36 @@ const colors = { const symbols = { [LOG_TYPES.INFO]: ['i', 'ℹ'], - [LOG_TYPES.SUCCESS]: ['√', '✔'], - [LOG_TYPES.WARNING]: ['‼', '⚠'], - [LOG_TYPES.ERROR]: ['×', '✖'], + [LOG_TYPES.SUCCESS]: ['v', '✔'], + [LOG_TYPES.WARNING]: ['!', '⚠'], + [LOG_TYPES.ERROR]: ['x', '✖'], }; -const isUnicodeSupported = process.platform !== 'win32' || process.env.TERM === 'xterm-256color'; -const symbolIndex = isUnicodeSupported ? 1 : 0; - function formatLogMessage(title, { type = LOG_TYPES.INFO, description } = {}) { - if (!title) { - throw new Error('Title is required'); - } - - /* - We use an array to create the message so that we can conveniently test - the content of `console.log` in tests - */ + if (!title) throw new Error('Title is required'); return [ - colorize(symbols[type][symbolIndex])[colors[type]], + colorize(symbols[type][canUseUnicode() ? 1 : 0])[colors[type]], title, ...description ? [EOL, ' ', colorize(description).dim] : [], ]; } export function log(title, { type, description } = {}) { - console.log(...formatLogMessage(title, { type, description })); -} - -export function logErrorAndExit(title) { - log(title, { type: LOG_TYPES.ERROR }); - process.exit(1); // eslint-disable-line n/no-process-exit + process.stderr.write(`${format(...formatLogMessage(title, { type, description }))}${EOL}`); } export function logEmptyLine() { - console.log(); + process.stderr.write(EOL); } export function logProgress(title, { type, description, progressBarContainer } = {}) { - if (process.stdout.isTTY && progressBarContainer) { - const message = `${formatLogMessage(title, { type, description }).join(' ')}${EOL}`; - progressBarContainer.log(message); - + if (progressBarContainer?.isRendering) { + progressBarContainer.log(`${formatLogMessage(title, { type, description }).join(' ')}${EOL}`); return; } - log(title, { type, description }); } export function logProgressVerbose(title, { type, description, progressBarContainer } = {}) { - if (programOptions.isVerbose) { - logProgress(title, { type, description, progressBarContainer }); - } + if (programOptions.isVerbose) logProgress(title, { type, description, progressBarContainer }); } diff --git a/lib/outcome-status.js b/lib/outcome-status.js new file mode 100644 index 0000000..5cdff98 --- /dev/null +++ b/lib/outcome-status.js @@ -0,0 +1,8 @@ +export const OUTCOME_STATUS = Object.freeze({ + FAILED: 'failed', + PROCESSED: 'processed', + SKIPPED: 'skipped', + // An operation an interruption left undone, whether it never started or was + // abandoned in flight. Either way the work still has to be repeated. + UNSTARTED: 'unstarted', +}); diff --git a/lib/prepare-file-paths.js b/lib/prepare-file-paths.js deleted file mode 100644 index 7693abe..0000000 --- a/lib/prepare-file-paths.js +++ /dev/null @@ -1,93 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; - -import { fdir } from 'fdir'; - -import { programOptions } from './program-options.js'; - -function sanitizeFilenamePart(part) { - // Remove characters that are forbidden in filenames across platforms - return part.replaceAll(/[<>:"|?*\\/]/g, ''); -} - -export async function prepareFilePaths({ - inputPaths, - outputDirectoryPath, - extensions, -}) { - const files = new Set(); - const directories = new Set(); - const inputPathsSet = new Set(inputPaths); - - await Promise.all( - [...inputPathsSet].map(async (currentPath) => { - try { - const stat = await fs.promises.stat(currentPath); - const resolvedPath = path.resolve(currentPath); - - if (stat.isDirectory()) { - directories.add(resolvedPath); - } else if (stat.isFile() && checkFileType(resolvedPath, extensions)) { - files.add(resolvedPath); - } - } catch { - // ¯\_(ツ)_/¯ - } - }), - ); - - const crawler = new fdir() - .withFullPaths() - .withDirs() - .filter(currentPath => checkFileType(currentPath, extensions)); - - const crawledPaths = await Promise.all( - [...directories].map(currentPath => crawler.crawl(currentPath).withPromise()), - ); - - for (const crawledPath of crawledPaths.flat()) { - files.add(crawledPath); - } - - const hasDirectories = directories.size > 0; - - const result = [...files].map((filePath) => { - let outputPath = filePath; - - if (outputDirectoryPath) { - if (hasDirectories) { - for (const directory of directories) { - if (!filePath.startsWith(directory)) { - continue; - } - - outputPath = path.join(outputDirectoryPath, filePath.slice(directory.length)); - break; - } - } else { - outputPath = path.join(outputDirectoryPath, filePath.slice(path.dirname(filePath).length)); - } - } - - // Apply prefix and suffix to the basename - const { dir, base } = path.parse(outputPath); - const { name, ext } = path.parse(base); - const sanitizedPrefix = sanitizeFilenamePart(programOptions.filePrefix); - const sanitizedSuffix = sanitizeFilenamePart(programOptions.fileSuffix); - const newName = sanitizedPrefix + name + sanitizedSuffix; - const newBase = newName + ext; - outputPath = path.join(dir, newBase); - - return { - input: filePath, - output: outputPath, - }; - }); - - return result; -} - -function checkFileType(filePath, extensions) { - const extension = path.extname(filePath).toLowerCase().slice(1); - return extensions.includes(extension); -} diff --git a/lib/prepare-operation-plan.js b/lib/prepare-operation-plan.js new file mode 100644 index 0000000..38304c3 --- /dev/null +++ b/lib/prepare-operation-plan.js @@ -0,0 +1,260 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { getRelativePath } from './get-relative-path.js'; +import { logProgressVerbose } from './log.js'; + +const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i; +const INVALID_GENERATED_CHARACTERS = /[<>:"|?*]/; +// A path that does not exist reports ENOENT, or ENOTDIR when one of its ancestors is a file. +const MISSING_PATH_CODES = new Set(['ENOENT', 'ENOTDIR']); + +export async function prepareOperationPlan({ + inputPaths, + outputDirectoryPath, + extensions, + formats, + force, + prefix, + suffix, +}) { + validateNamePart(prefix, 'Prefix'); + validateNamePart(suffix, 'Suffix'); + + let outputRoot; + if (outputDirectoryPath) { + const requestedOutputRoot = path.resolve(outputDirectoryPath); + const outputStat = await statExplicitPath(requestedOutputRoot, 'Output path'); + if (!outputStat.isDirectory()) throw new Error(`Output path is not a directory: ${requestedOutputRoot}`); + await fs.access(requestedOutputRoot, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK); + outputRoot = await fs.realpath(requestedOutputRoot); + } + + // Operands are inspected in the given order so that deduplication keeps the first + // spelling and reports the rest deterministically. + const operands = []; + const keptOperands = new Map(); + for (const inputPath of inputPaths) { + const operand = await inspectOperand(inputPath, extensions); + const kept = keptOperands.get(pathKey(operand.realPath)); + if (kept) { + reportDuplicate(operand, kept); + continue; + } + keptOperands.set(pathKey(operand.realPath), operand); + operands.push(operand); + } + + const directories = operands.filter(operand => operand.kind === 'directory'); + if (outputRoot) validateDirectoryOverlap(directories); + + const discovered = []; + for (const operand of operands) { + if (operand.kind === 'file') { + discovered.push(operand); + } else { + discovered.push(...await walkDirectory(operand, extensions)); + } + } + + const uniqueInputs = new Map(); + for (const input of discovered) { + const kept = uniqueInputs.get(pathKey(input.realPath)); + if (kept) { + reportDuplicate(input, kept); + continue; + } + uniqueInputs.set(pathKey(input.realPath), input); + } + + const operations = []; + for (const input of uniqueInputs.values()) { + const placement = calculatePlacement(input, outputRoot); + for (const format of formats) { + const extension = format === 'optimize' ? path.extname(placement) : `.${format}`; + const basename = `${prefix}${path.basename(placement, path.extname(placement))}${suffix}${extension}`; + if (format !== 'optimize' || outputRoot || prefix || suffix) validateGeneratedBasename(basename); + let output = path.join(path.dirname(placement), basename); + if (format === 'optimize' && !outputRoot && !prefix && !suffix) output = input.realPath; + operations.push({ format, input: input.realPath, output: path.resolve(output) }); + } + } + + operations.sort((left, right) => left.input.localeCompare(right.input) || left.format.localeCompare(right.format) || left.output.localeCompare(right.output)); + await validateOutputs(operations, { force, outputRoot }); + return operations; +} + +async function inspectOperand(inputPath, extensions) { + const operandPath = path.resolve(inputPath); + let lstat; + try { + lstat = await fs.lstat(operandPath); + } catch (error) { + throw new Error(`Input does not exist or is inaccessible: ${operandPath}: ${error.message}`, { cause: error }); + } + + if (lstat.isSymbolicLink()) { + const realPath = await fs.realpath(operandPath); + const stat = await fs.stat(realPath); + if (!stat.isFile()) throw new Error(`Explicit symbolic link does not refer to a file: ${operandPath}`); + assertSupported(operandPath, extensions); + return { givenPath: inputPath, kind: 'file', operandPath, realPath, root: undefined }; + } + if (lstat.isFile()) { + assertSupported(operandPath, extensions); + return { givenPath: inputPath, kind: 'file', operandPath, realPath: await fs.realpath(operandPath), root: undefined }; + } + if (lstat.isDirectory()) return { givenPath: inputPath, kind: 'directory', operandPath, realPath: await fs.realpath(operandPath) }; + throw new Error(`Input is not a file or directory: ${operandPath}`); +} + +async function walkDirectory(root, extensions) { + const files = []; + async function walk(current) { + let entries; + try { + entries = await fs.readdir(current, { withFileTypes: true }); + } catch (error) { + throw new Error(`Cannot traverse directory ${current}: ${error.message}`, { cause: error }); + } + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const entryPath = path.join(current, entry.name); + if (entry.isDirectory()) await walk(entryPath); + else if (entry.isFile() && isSupported(entryPath, extensions)) { + files.push({ kind: 'file', operandPath: entryPath, realPath: await fs.realpath(entryPath), root }); + } + } + } + await walk(root.operandPath); + return files; +} + +function calculatePlacement(input, outputRoot) { + if (!outputRoot) return input.operandPath; + if (!input.root) return path.join(outputRoot, path.basename(input.operandPath)); + return path.join(outputRoot, path.relative(input.root.operandPath, input.operandPath)); +} + +async function validateOutputs(operations, { force, outputRoot }) { + const outputs = new Map(); + for (const operation of operations) { + if (outputRoot) assertContained(outputRoot, operation.output); + let identity = operation.output; + let targetStat; + try { + const lstat = await fs.lstat(operation.output); + if (lstat.isSymbolicLink()) { + if (operation.format !== 'optimize' && !force) { + operation.skipReason = 'File already exists'; + } else { + identity = await fs.realpath(operation.output); + if (outputRoot) assertContained(outputRoot, identity); + operation.output = identity; + targetStat = await fs.stat(identity); + } + } else targetStat = lstat; + if (!operation.skipReason && !targetStat?.isFile()) throw new Error(`Output target is not a regular file: ${operation.output}`); + if (!operation.skipReason && targetStat.nlink > 1) throw new Error(`Refusing to replace multiply hard-linked file: ${operation.output}`); + if (operation.format !== 'optimize' && !force) operation.skipReason = 'File already exists'; + } catch (error) { + // The target cannot exist yet, either because nothing is there or because an + // ancestor is not a directory. Both are diagnosed by walking up to what exists. + if (!MISSING_PATH_CODES.has(error.code)) throw error; + const canonicalOutput = await canonicalizeThroughExistingAncestor(operation.output); + if (outputRoot) assertContained(outputRoot, canonicalOutput); + operation.output = canonicalOutput; + identity = canonicalOutput; + } + const key = pathKey(identity); + const previous = outputs.get(key); + if (previous) throw new Error(`Output collision: ${previous.output} and ${operation.output}`); + outputs.set(key, operation); + } +} + +async function canonicalizeThroughExistingAncestor(output) { + let current = path.dirname(output); + const missingSegments = [path.basename(output)]; + while (true) { + try { + const stat = await fs.stat(current); + if (!stat.isDirectory()) throw new Error(`Output parent is not a directory: ${current}`); + await fs.access(current, fs.constants.W_OK | fs.constants.X_OK); + const realAncestor = await fs.realpath(current); + return path.join(realAncestor, ...missingSegments); + } catch (error) { + if (!MISSING_PATH_CODES.has(error.code)) throw error; + missingSegments.unshift(path.basename(current)); + const parent = path.dirname(current); + if (parent === current) throw error; + current = parent; + } + } +} + +// Omitted work must be explainable, so deduplication is visible in verbose output. Two +// operands can also reach one file through the same path, as overlapping roots do. +function reportDuplicate(dropped, kept) { + // Explicit operands are named as the user spelled them; discovered files have only a path. + const droppedPath = getRelativePath(dropped.givenPath ?? dropped.operandPath); + const keptPath = getRelativePath(kept.givenPath ?? kept.operandPath); + logProgressVerbose(droppedPath, { + description: droppedPath === keptPath + ? 'Already planned once. Not planned again' + : `Duplicate of '${keptPath}'. Not planned again`, + }); +} + +function validateDirectoryOverlap(directories) { + for (let index = 0; index < directories.length; index += 1) { + for (let other = index + 1; other < directories.length; other += 1) { + const left = directories[index].realPath; + const right = directories[other].realPath; + if (isContained(left, right) || isContained(right, left)) throw new Error('Overlapping directory operands cannot be used with --output'); + } + } +} + +function validateNamePart(value, label) { + if (value.includes('\0') || value.includes('/') || value.includes('\\')) throw new Error(`${label} must not contain path separators or NUL`); +} + +function validateGeneratedBasename(basename) { + const stem = basename.replace(/\..*$/, ''); + const hasControlCharacter = [...basename].some(character => character.codePointAt(0) < 32); + if (!basename || basename === '.' || basename === '..' || basename.endsWith('.') || basename.endsWith(' ') || hasControlCharacter || INVALID_GENERATED_CHARACTERS.test(basename) || WINDOWS_RESERVED_NAME.test(stem)) { + throw new Error(`Generated filename is not portable: ${basename}`); + } +} + +function assertSupported(filePath, extensions) { + if (!isSupported(filePath, extensions)) throw new Error(`Unsupported explicit file: ${filePath}`); +} + +function isSupported(filePath, extensions) { + return extensions.includes(path.extname(filePath).toLowerCase().slice(1)); +} + +function assertContained(root, target) { + if (!isContained(root, target)) throw new Error(`Output escapes permitted root: ${target}`); +} + +function isContained(root, target) { + const relative = path.relative(root, target); + return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); +} + +function pathKey(value) { + const normalized = path.normalize(value); + return process.platform === 'linux' ? normalized : normalized.toLowerCase(); +} + +async function statExplicitPath(value, label) { + try { + return await fs.stat(value); + } catch (error) { + throw new Error(`${label} does not exist or is inaccessible: ${value}: ${error.message}`, { cause: error }); + } +} diff --git a/lib/prepare-output-directory-path.js b/lib/prepare-output-directory-path.js deleted file mode 100644 index bb9ab0a..0000000 --- a/lib/prepare-output-directory-path.js +++ /dev/null @@ -1,24 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; - -import { logErrorAndExit } from './log.js'; - -export async function prepareOutputDirectoryPath(outputDirectoryPath) { - if (!outputDirectoryPath) { - return ''; - } - - const resolvedPath = path.resolve(outputDirectoryPath); - - try { - const stat = await fs.promises.stat(resolvedPath); - - if (!stat.isDirectory()) { - logErrorAndExit('Output path must be a directory'); - } - } catch { - logErrorAndExit('Output path does not exist'); - } - - return resolvedPath; -} diff --git a/lib/show-total.js b/lib/show-total.js index 07084d6..3eb16a4 100644 --- a/lib/show-total.js +++ b/lib/show-total.js @@ -1,15 +1,40 @@ import { calculateRatio } from './calculate-ratio.js'; import { formatBytes } from './format-bytes.js'; +import { getRelativePath } from './get-relative-path.js'; import { log, logEmptyLine } from './log.js'; +import { OUTCOME_STATUS } from './outcome-status.js'; -export function showTotal(before, after) { - const ratio = calculateRatio(before, after); - const saved = formatBytes(before - after); - +export function showTotal(before, after, outcomes, { conversion = false } = {}) { + const processed = outcomes.filter(outcome => outcome.status === OUTCOME_STATUS.PROCESSED).length; + const skipped = outcomes.filter(outcome => outcome.status === OUTCOME_STATUS.SKIPPED).length; + const unstarted = outcomes.filter(outcome => outcome.status === OUTCOME_STATUS.UNSTARTED).length; + const failures = outcomes + .filter(outcome => outcome.status === OUTCOME_STATUS.FAILED) + .toSorted((left, right) => left.planIndex - right.planIndex); + // Only outcomes that occurred are named. Every operation holds one of these statuses, + // and showTotal runs only for a non-empty plan, so at least one count is always left. + const summary = [ + [processed, 'processed'], + [skipped, 'skipped'], + [failures.length, 'failed'], + [unstarted, 'not started'], + ] + .filter(([count]) => count > 0) + .map(([count, label]) => `${count} ${label}`) + .join(', '); logEmptyLine(); - log( - ratio > 0 - ? `Yay! You saved ${saved} (${ratio}%)` - : 'Done!', - ); + log(summary); + for (const failure of failures) { + log(failure.output ? getRelativePath(failure.output) : 'Operation failed', { + description: failure.error?.message || String(failure.error), + type: 'error', + }); + } + if (processed === 0) return; + if (conversion) { + log(`${formatBytes(after)} created`); + return; + } + const ratio = calculateRatio(before, after); + log(`${formatBytes(before - after)} saved (${ratio}%)`); } diff --git a/lib/stream-capabilities.js b/lib/stream-capabilities.js new file mode 100644 index 0000000..d082780 --- /dev/null +++ b/lib/stream-capabilities.js @@ -0,0 +1,7 @@ +export function canUseColor() { + return canUseUnicode() && !process.env.NO_COLOR; +} + +export function canUseUnicode() { + return Boolean(process.stderr.isTTY) && process.env.TERM !== 'dumb'; +} diff --git a/optimize.js b/optimize.js index 411768c..6b80aee 100644 --- a/optimize.js +++ b/optimize.js @@ -9,11 +9,14 @@ import pLimit from 'p-limit'; import sharp from 'sharp'; import { optimize as svgoOptimize } from 'svgo'; +import { atomicWrite } from './lib/atomic-write.js'; import { calculateRatio } from './lib/calculate-ratio.js'; import { createProgressBarContainer } from './lib/create-progress-bar-container.js'; +import { describeCodecFailure } from './lib/describe-codec-failure.js'; import { formatBytes } from './lib/format-bytes.js'; import { getPlural } from './lib/get-plural.js'; import { getRelativePath } from './lib/get-relative-path.js'; +import { isInterrupted, registerChild } from './lib/lifecycle.js'; import { LOG_TYPES, log, @@ -21,12 +24,14 @@ import { logProgressVerbose, } from './lib/log.js'; import { optionsToArguments } from './lib/options-to-arguments.js'; +import { OUTCOME_STATUS } from './lib/outcome-status.js'; import { parseImageMetadata } from './lib/parse-image-metadata.js'; import { programOptions } from './lib/program-options.js'; import { showTotal } from './lib/show-total.js'; -export async function optimize({ filePaths, config }) { +export async function optimize({ operations, config, configPath }) { const { isLossless } = programOptions; + const filePaths = operations.map(operation => ({ input: operation.input, output: operation.output })); const filePathsCount = filePaths.length; @@ -45,8 +50,8 @@ export async function optimize({ filePaths, config }) { const tasksSimultaneousLimit = pLimit(cpuCount); const guetzliTasksSimultaneousLimit = pLimit(1); // Guetzli uses a large amount of memory and a significant amount of CPU time. To reduce system load, we only allow one instance of guetzli to run at the same time. - await Promise.all( - filePaths.map((filePath) => { + const outcomes = await Promise.all( + filePaths.map((filePath, planIndex) => { const extension = path.extname(filePath.input).toLowerCase(); const isJpeg = extension === '.jpg' || extension === '.jpeg'; @@ -54,34 +59,41 @@ export async function optimize({ filePaths, config }) { ? guetzliTasksSimultaneousLimit : tasksSimultaneousLimit; - return limit(() => processFile({ - filePath, - config, - progressBarContainer, - progressBar, - totalSize, - isLossless, - })); + return limit(() => isInterrupted() + ? { planIndex, status: OUTCOME_STATUS.UNSTARTED } + : processFile({ + filePath, + config, + configPath, + progressBarContainer, + progressBar, + totalSize, + isLossless, + planIndex, + })); }), ); progressBarContainer.update(); // Prevent logs lost. See: https://github.com/npkgz/cli-progress/issues/145#issuecomment-1859594159 progressBarContainer.stop(); - showTotal(totalSize.before, totalSize.after); + showTotal(totalSize.before, totalSize.after, outcomes); + return { failed: outcomes.filter(outcome => outcome.status === OUTCOME_STATUS.FAILED).length, outcomes }; } async function processFile({ filePath, config, + configPath, progressBarContainer, progressBar, totalSize, isLossless, + planIndex, }) { try { const fileBuffer = await fs.promises.readFile(filePath.input); - const processedFileBuffer = await processFileByFormat({ fileBuffer, config, isLossless }); + const processedFileBuffer = await processFileByFormat({ fileBuffer, config, configPath, isLossless }); const fileSize = fileBuffer.length; const processedFileSize = processedFileBuffer.length; @@ -101,11 +113,10 @@ async function processFile({ progressBarContainer, }); - return; + return { planIndex, status: OUTCOME_STATUS.SKIPPED }; } - await fs.promises.mkdir(path.dirname(filePath.output), { recursive: true }); - await fs.promises.writeFile(filePath.output, processedFileBuffer); + await atomicWrite(filePath.output, processedFileBuffer); const before = formatBytes(fileSize); const after = formatBytes(processedFileSize); @@ -115,51 +126,45 @@ async function processFile({ description: `${before} → ${after}. Ratio: ${ratio}%`, progressBarContainer, }); + return { after: processedFileSize, before: fileSize, planIndex, status: OUTCOME_STATUS.PROCESSED }; } catch (error) { - if (error.message) { - logProgress(getRelativePath(filePath.output), { - type: LOG_TYPES.ERROR, - description: (error.message || '').trim(), - progressBarContainer, - }); - } else { - progressBarContainer.log(error); - } + // Work abandoned during shutdown is left undone by the interruption, not failed. + if (isInterrupted()) return { planIndex, status: OUTCOME_STATUS.UNSTARTED }; + return { error, output: filePath.output, planIndex, status: OUTCOME_STATUS.FAILED }; } finally { progressBar.increment(); } } -async function processFileByFormat({ fileBuffer, config, isLossless }) { +async function processFileByFormat({ fileBuffer, config, configPath, isLossless }) { const imageMetadata = await parseImageMetadata(fileBuffer); + const format = imageMetadata.format; - if (!imageMetadata.format) { + if (!format) { throw new Error('Unknown file format'); } - switch (imageMetadata.format) { - case 'jpeg': { - return processJpeg({ fileBuffer, config, isLossless }); - } - - case 'png': { - return processPng({ fileBuffer, config, isLossless }); - } - - case 'gif': { - return processGif({ fileBuffer, config, isLossless }); - } - - case 'svg': { - return processSvg({ fileBuffer, config }); - } + const processByFormat = PROCESS_BY_FORMAT.get(format); + if (!processByFormat) { + throw new Error(`Unsupported image format: "${format}"`); + } - default: { - throw new Error(`Unsupported image format: "${imageMetadata.format}"`); - } + // Only the codec call is enriched, so filesystem and detection errors keep speaking + // for themselves. + try { + return await processByFormat({ fileBuffer, config, isLossless }); + } catch (error) { + throw describeCodecFailure({ configPath, error, format, isLossless, mode: 'optimize' }); } } +const PROCESS_BY_FORMAT = new Map([ + ['gif', processGif], + ['jpeg', processJpeg], + ['png', processPng], + ['svg', processSvg], +]); + async function processJpeg({ fileBuffer, config, isLossless }) { const sharpImage = sharp(fileBuffer) .rotate(); // Rotate image using information from EXIF Orientation tag @@ -226,6 +231,7 @@ function processSvg({ fileBuffer, config }) { function pipe({ command, commandOptions, inputBuffer }) { return new Promise((resolve, reject) => { const process = spawn(command, commandOptions); + registerChild(process); process.stdin.write(inputBuffer); process.stdin.end(); diff --git a/package-lock.json b/package-lock.json index 5557f3e..62bbcfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,10 +13,10 @@ "@343dev/guetzli": "1.3.0", "cli-progress": "3.12.0", "commander": "15.0.0", - "fdir": "6.5.0", "p-limit": "7.3.0", "sharp": "0.35.2", - "svgo": "4.0.1" + "svgo": "4.0.1", + "write-file-atomic": "7.0.1" }, "bin": { "optimizt": "cli.js" @@ -2515,6 +2515,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -3596,7 +3597,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -3886,7 +3887,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -4456,6 +4456,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/write-file-atomic": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz", + "integrity": "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==", + "license": "ISC", + "dependencies": { + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", diff --git a/package.json b/package.json index dacf985..2c5d99b 100644 --- a/package.json +++ b/package.json @@ -37,9 +37,9 @@ ], "scripts": { "license-check": "npx license-compliance@3.0.1 --report detailed --allow '0BSD;Apache-2.0;Artistic-2.0;BlueOak-1.0.0;BSD-2-Clause;BSD-3-Clause;BSD-4-Clause;CC0-1.0;CC-BY-4.0;ISC;LGPL-2.1-or-later;LGPL-3.0-or-later;MIT;MPL-1.1;MPL-2.0;Python-2.0;Unlicense;W3C;W3C-20150513;X11;Zlib' --exclude 'spdx-exceptions;@343dev/gifsicle'", - "lint": "eslint --ignore-pattern 'coverage/*' ./", - "test": "vitest run --testTimeout 60000", - "test-coverage": "vitest run --testTimeout 60000 --coverage", + "lint": "eslint ./", + "test": "vitest run", + "test-coverage": "vitest run --coverage", "enable-git-hooks": "git config core.hooksPath .githooks" }, "dependencies": { @@ -47,10 +47,10 @@ "@343dev/guetzli": "1.3.0", "cli-progress": "3.12.0", "commander": "15.0.0", - "fdir": "6.5.0", "p-limit": "7.3.0", "sharp": "0.35.2", - "svgo": "4.0.1" + "svgo": "4.0.1", + "write-file-atomic": "7.0.1" }, "devDependencies": { "@343dev/eslint-config": "5.0.0", diff --git a/tests/cli.test.js b/tests/cli.test.js deleted file mode 100644 index ffb3be9..0000000 --- a/tests/cli.test.js +++ /dev/null @@ -1,593 +0,0 @@ -import { - afterEach, beforeEach, describe, expect, test, -} from 'vitest'; - -import { execSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { calculateRatio } from '../lib/calculate-ratio.js'; - -const dirname = path.dirname(fileURLToPath(import.meta.url)); - -const cliPath = path.resolve('cli.js'); -const images = path.resolve(dirname, 'images'); - -let temporary; -let workDirectory; - -/* eslint-disable unicorn/no-top-level-assignment-in-function -- shared fixtures assigned in setup, read by tests and helpers */ -beforeEach(() => { - temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'optimizt-test-')); - workDirectory = `${temporary}${path.sep}`; - copyRecursive(images, temporary); -}); -/* eslint-enable unicorn/no-top-level-assignment-in-function */ - -afterEach(() => { - if (temporary) { - fs.rmSync(temporary, { recursive: true }); - } -}); - -describe('CLI', () => { - describe('Optimization', () => { - describe('Lossy', () => { - test('SVG should be optimized', () => { - const file = 'svg-not-optimized.svg'; - const stdout = runCliWithParameters(`${workDirectory}${file}`); - - expectFileRatio({ - stdout, file, maxRatio: 85, minRatio: 80, - }); - }); - - test('JPEG/JPG should be optimized', () => { - const file = 'jpeg-not-optimized.jpeg'; - const stdout = runCliWithParameters(`${workDirectory}${file}`); - - expectFileRatio({ - stdout, file, maxRatio: 55, minRatio: 50, - }); - }); - - test('PNG should be optimized', () => { - const file = 'png-not-optimized.png'; - const stdout = runCliWithParameters(`${workDirectory}${file}`); - - expectFileRatio({ - stdout, file, maxRatio: 80, minRatio: 75, - }); - }); - - test('GIF should be optimized', () => { - const file = 'gif-not-optimized.gif'; - const stdout = runCliWithParameters(`${workDirectory}${file}`); - - expectFileRatio({ - stdout, file, maxRatio: 45, minRatio: 35, - }); - }); - - test('Files should not be optimized if ratio <= 0', () => { - const stdout = runCliWithParameters(`${workDirectory}svg-optimized.svg ${workDirectory}jpeg-one-pixel.jpg`); - - expectStringContains(stdout, 'Optimizing 2 images (lossy)...'); - expectStringContains(stdout, 'Done!'); - expectFileNotModified('svg-optimized.svg'); - expectFileNotModified('jpeg-one-pixel.jpg'); - }); - - test('Files in provided directory should be optimized', () => { - const stdout = runCliWithParameters(workDirectory); - - expectStringContains(stdout, 'Optimizing 8 images (lossy)...'); - expectTotalRatio({ maxRatio: 60, minRatio: 55, stdout }); - }); - }); - - describe('Lossless (--lossless)', () => { - test('JPEG/JPG should be optimized', () => { - const file = 'jpeg-not-optimized.jpeg'; - const stdout = runCliWithParameters(`--lossless ${workDirectory}${file}`); - - expectFileRatio({ - stdout, file, maxRatio: 55, minRatio: 45, - }); - }); - - test('PNG should be optimized', () => { - const file = 'png-not-optimized.png'; - const stdout = runCliWithParameters(`--lossless ${workDirectory}${file}`); - - expectFileRatio({ - stdout, file, maxRatio: 25, minRatio: 20, - }); - }); - - test('GIF should be optimized', () => { - const file = 'gif-not-optimized.gif'; - const stdout = runCliWithParameters(`--lossless ${workDirectory}${file}`); - - expectFileRatio({ - stdout, file, maxRatio: 10, minRatio: 5, - }); - }); - - test('Files should not be optimized if ratio <= 0', () => { - const stdout = runCliWithParameters(`--lossless ${workDirectory}jpeg-one-pixel.jpg`); - - expectStringContains(stdout, 'Optimizing 1 image (lossless)...'); - expectStringContains(stdout, 'Done!'); - expectFileNotModified('jpeg-one-pixel.jpg'); - }); - - test('Files in provided directory should be optimized', () => { - const stdout = runCliWithParameters(`--lossless ${workDirectory}`); - - expectStringContains(stdout, 'Optimizing 8 images (lossless)...'); - expectTotalRatio({ maxRatio: 25, minRatio: 15, stdout }); - }); - }); - }); - - describe('Converting to AVIF (--avif)', () => { - describe('Lossy', () => { - test('JPEG should be converted', () => { - const file = 'jpeg-not-optimized.jpeg'; - const stdout = runCliWithParameters(`--avif ${workDirectory}${file}`); - - expectFileRatio({ - stdout, file, maxRatio: 85, minRatio: 75, outputExt: 'avif', - }); - expectFileNotModified(file); - }); - - test('PNG should be converted', () => { - const file = 'png-not-optimized.png'; - const stdout = runCliWithParameters(`--avif ${workDirectory}${file}`); - - expectFileRatio({ - stdout, file, maxRatio: 94, minRatio: 89, outputExt: 'avif', - }); - expectFileNotModified(file); - }); - - test('GIF should not be converted', () => { - const file = 'gif-not-optimized.gif'; - const stdout = runCliWithParameters(`--avif ${workDirectory}${file}`); - - expectStringContains(stdout, 'Animated AVIF is not supported'); - expectFileNotModified(file); - expectFileNotExists('gif-not-optimized.avif'); - }); - - test('Files in provided directory should be converted', () => { - const fileBasename = 'png-not-optimized'; - const stdout = runCliWithParameters(`--avif ${workDirectory}`); - const stdoutRatio = grepTotalRatio(stdout); - - expectStringContains(stdout, 'Converting 6 images (lossy)...'); - expectRatio(stdoutRatio, 80, 85); - expectFileNotModified(`${fileBasename}.png`); - expectFileExists(`${fileBasename}.avif`); - }); - }); - - describe('Lossless (--lossless)', () => { - // TODO: In lossless mode JPEG file size is always smaller than AVIF - // test('JPEG should be converted', () => {}); - - test('PNG should be converted', () => { - const file = 'png-not-optimized.png'; - const stdout = runCliWithParameters(`--avif --lossless ${workDirectory}${file}`); - - expectFileRatio({ - stdout, file, maxRatio: 30, minRatio: 20, outputExt: 'avif', - }); - expectFileNotModified(file); - }); - - test('GIF should not be converted', () => { - const file = 'gif-not-optimized.gif'; - const stdout = runCliWithParameters(`--avif --lossless ${workDirectory}${file}`); - - expectStringContains(stdout, 'Animated AVIF is not supported'); - expectFileNotModified(file); - expectFileNotExists('gif-not-optimized.avif'); - }); - - test('Files in provided directory should be converted (except GIF)', () => { - const fileBasename = 'png-not-optimized'; - const stdout = runCliWithParameters(`--avif --lossless ${workDirectory}`); - const stdoutRatio = grepTotalRatio(stdout); - - expectStringContains(stdout, 'Converting 6 images (lossless)...'); - expectRatio(stdoutRatio, 15, 20); - expectFileNotModified(`${fileBasename}.png`); - expectFileExists(`${fileBasename}.avif`); - - expectStringContains(stdout, 'Animated AVIF is not supported'); - expectFileNotExists('gif-not-optimized.avif'); - }); - }); - }); - - describe('Converting to WebP (--webp)', () => { - describe('Lossy', () => { - test('JPEG should be converted', () => { - const file = 'jpeg-not-optimized.jpeg'; - const stdout = runCliWithParameters(`--webp ${workDirectory}${file}`); - - expectFileRatio({ - stdout, file, maxRatio: 75, minRatio: 70, outputExt: 'webp', - }); - expectFileNotModified(file); - }); - - test('PNG should be converted', () => { - const file = 'png-not-optimized.png'; - const stdout = runCliWithParameters(`--webp ${workDirectory}${file}`); - - expectFileRatio({ - stdout, file, maxRatio: 85, minRatio: 80, outputExt: 'webp', - }); - expectFileNotModified(file); - }); - - test('GIF should be converted', () => { - const file = 'gif-not-optimized.gif'; - const stdout = runCliWithParameters(`--webp ${workDirectory}${file}`); - - expectFileRatio({ - stdout, file, maxRatio: 30, minRatio: 25, outputExt: 'webp', - }); - expectFileNotModified(file); - }); - - test('Files in provided directory should be converted', () => { - const fileBasename = 'png-not-optimized'; - const stdout = runCliWithParameters(`--webp ${workDirectory}`); - const stdoutRatio = grepTotalRatio(stdout); - - expectStringContains(stdout, 'Converting 6 images (lossy)...'); - expectRatio(stdoutRatio, 55, 60); - expectFileNotModified(`${fileBasename}.png`); - expectFileExists(`${fileBasename}.webp`); - }); - }); - - describe('Lossless (--lossless)', () => { - test('JPEG should be converted', () => { - const file = 'jpeg-one-pixel.jpg'; - const stdout = runCliWithParameters(`--webp --lossless ${workDirectory}${file}`); - - expectFileRatio({ - stdout, file, maxRatio: 85, minRatio: 80, outputExt: 'webp', - }); - expectFileNotModified(file); - }); - - test('PNG should be converted', () => { - const file = 'png-not-optimized.png'; - const stdout = runCliWithParameters(`--webp --lossless ${workDirectory}${file}`); - - expectFileRatio({ - stdout, file, maxRatio: 50, minRatio: 45, outputExt: 'webp', - }); - expectFileNotModified(file); - }); - - test('GIF should be converted', () => { - const file = 'gif-not-optimized.gif'; - const stdout = runCliWithParameters(`--webp --lossless ${workDirectory}${file}`); - - expectFileRatio({ - stdout, file, maxRatio: 25, minRatio: 20, outputExt: 'webp', - }); - expectFileNotModified(file); - }); - - test('Files in provided directory should be converted', () => { - const fileBasename = 'png-not-optimized'; - const stdout = runCliWithParameters(`--webp --lossless ${workDirectory}`); - const stdoutRatio = grepTotalRatio(stdout); - - expectStringContains(stdout, 'Converting 6 images (lossless)...'); - expectRatio(stdoutRatio, 30, 35); - expectFileNotModified(`${fileBasename}.png`); - expectFileExists(`${fileBasename}.webp`); - }); - }); - }); - - describe('Converting to AVIF and WebP at the same time (--avif --webp)', () => { - describe('Lossy', () => { - test('AVIF and WebP should be created', () => { - const fileBasename = 'png-not-optimized'; - const stdout = runCliWithParameters(`--avif --webp ${workDirectory}${fileBasename}.png`); - const stdoutRatio = grepTotalRatio(stdout); - - expectStringContains(stdout, 'Converting 1 image (lossy)...'); - expectStringContains(stdout, path.join(temporary, `${fileBasename}.avif`)); - expectStringContains(stdout, path.join(temporary, `${fileBasename}.webp`)); - expectRatio(stdoutRatio, 85, 90); - expectFileNotModified(`${fileBasename}.png`); - expectFileExists(`${fileBasename}.avif`); - expectFileExists(`${fileBasename}.webp`); - }); - }); - - describe('Lossless (--lossless)', () => { - test('AVIF and WebP should be created', () => { - const fileBasename = 'png-not-optimized'; - const stdout = runCliWithParameters(`--avif --webp --lossless ${workDirectory}${fileBasename}.png`); - const stdoutRatio = grepTotalRatio(stdout); - - expectStringContains(stdout, 'Converting 1 image (lossless)...'); - expectStringContains(stdout, path.join(temporary, `${fileBasename}.avif`)); - expectStringContains(stdout, path.join(temporary, `${fileBasename}.webp`)); - expectRatio(stdoutRatio, 35, 40); - expectFileNotModified(`${fileBasename}.png`); - expectFileExists(`${fileBasename}.avif`); - expectFileExists(`${fileBasename}.webp`); - }); - }); - }); - - describe('Force rewrite AVIF or WebP (--force)', () => { - test('Should not be overwritten', () => { - const fileBasename = 'png-not-optimized'; - const parameters = `--verbose --avif --webp ${workDirectory}${fileBasename}.png`; - - runCliWithParameters(parameters); - const stdout = runCliWithParameters(parameters); - - expectStringContains(stdout, `File already exists, '${workDirectory}${fileBasename}.avif'`); - expectStringContains(stdout, `File already exists, '${workDirectory}${fileBasename}.webp'`); - }); - - test('Should be overwritten', () => { - const fileBasename = 'png-not-optimized'; - const parameters = `--avif --webp --force ${workDirectory}${fileBasename}.png`; - - runCliWithParameters(parameters); - const stdout = runCliWithParameters(parameters); - - expectStringNotContains(stdout, `File already exists, '${workDirectory}${fileBasename}.avif'`); - expectStringNotContains(stdout, `File already exists, '${workDirectory}${fileBasename}.webp'`); - }); - }); - - describe('Output to provided directory (--output)', () => { - let outputDirectory; - - beforeEach(() => { - outputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'optimizt-test-')); - }); - - afterEach(() => { - if (outputDirectory) { - fs.rmSync(outputDirectory, { recursive: true }); - } - }); - - describe('Optimization', () => { - test('Should output one file', () => { - const fileName = 'png-not-optimized.png'; - - runCliWithParameters(`--output ${outputDirectory} ${workDirectory}${fileName}`); - expect(fs.existsSync(path.join(outputDirectory, fileName))).toBeTruthy(); - }); - - test('Should output list of files', () => { - runCliWithParameters(`--output ${outputDirectory} ${workDirectory}*.jpg ${workDirectory}*.jpeg`); - expect(fs.existsSync(path.join(outputDirectory, 'jpeg-low-quality.jpg'))).toBeTruthy(); - expect(fs.existsSync(path.join(outputDirectory, 'jpeg-not-optimized.jpeg'))).toBeTruthy(); - }); - }); - - describe('Converting', () => { - test('Should output one file', () => { - const fileBasename = 'png-not-optimized'; - - runCliWithParameters(`--avif --output ${outputDirectory} ${workDirectory}${fileBasename}.png`); - expect(fs.existsSync(path.join(outputDirectory, `${fileBasename}.avif`))).toBeTruthy(); - }); - - test('Should output list of files', () => { - runCliWithParameters(`--avif --output ${outputDirectory} ${workDirectory}*.jpg ${workDirectory}*.jpeg`); - expect(fs.existsSync(path.join(outputDirectory, 'jpeg-low-quality.avif'))).toBeTruthy(); - expect(fs.existsSync(path.join(outputDirectory, 'jpeg-not-optimized.avif'))).toBeTruthy(); - }); - }); - }); - - describe('Verbose mode (--verbose)', () => { - test('Should be verbose', () => { - const stdout = runCliWithParameters(`--verbose ${workDirectory}svg-optimized.svg`); - expectStringContains(stdout, 'Nothing changed. Skipped'); - }); - - test('Should not be verbose', () => { - const stdout = runCliWithParameters(`${workDirectory}svg-optimized.svg`); - expectStringNotContains(stdout, 'Nothing changed. Skipped'); - }); - }); - - describe('Prefix and Suffix (--prefix --suffix)', () => { - let outputDirectory; - - beforeEach(() => { - outputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'optimizt-test-')); - }); - - afterEach(() => { - if (outputDirectory) { - fs.rmSync(outputDirectory, { recursive: true }); - } - }); - - test('Should add prefix and suffix to optimized filenames', () => { - const fileName = 'png-not-optimized.png'; - const expectedOutput = 'prepng-not-optimizedsuf.png'; - - runCliWithParameters(`--prefix pre --suffix suf --output ${outputDirectory} ${workDirectory}${fileName}`); - expect(fs.existsSync(path.join(outputDirectory, expectedOutput))).toBeTruthy(); - }); - - test('Should add prefix and suffix to converted filenames', () => { - const fileBasename = 'png-not-optimized'; - const expectedAvif = 'prepng-not-optimizedsuf.avif'; - const expectedWebp = 'prepng-not-optimizedsuf.webp'; - - runCliWithParameters(`--avif --webp --prefix pre --suffix suf --output ${outputDirectory} ${workDirectory}${fileBasename}.png`); - expect(fs.existsSync(path.join(outputDirectory, expectedAvif))).toBeTruthy(); - expect(fs.existsSync(path.join(outputDirectory, expectedWebp))).toBeTruthy(); - }); - - test('Should sanitize forbidden characters in prefix and suffix', () => { - const fileName = 'png-not-optimized.png'; - const expectedOutput = 'unsafepng-not-optimizedunsafe.png'; - - runCliWithParameters(`--prefix "" --suffix "" --output ${outputDirectory} ${workDirectory}${fileName}`); - expect(fs.existsSync(path.join(outputDirectory, expectedOutput))).toBeTruthy(); - }); - }); - - describe('Help (--help)', () => { - const helpString = `\ -Usage: cli [options] - -CLI image optimization tool - -Options: - --avif create AVIF and exit - --webp create WebP and exit - -f, --force force create AVIF and WebP - -l, --lossless perform lossless optimizations - -v, --verbose be verbose - -c, --config use this configuration, overriding default config options - if present - -o, --output write output to directory - -p, --prefix add prefix to optimized file names - -s, --suffix add suffix to optimized file names - -V, --version output the version number - -h, --help display help for command -`; - - test('Should be printed', () => { - const stdout = runCliWithParameters('--help'); - expect(stdout).toBe(helpString); - }); - - test('Should be printed if no CLI params provided', () => { - const stdout = runCliWithParameters(''); - expect(stdout).toBe(helpString); - }); - }); -}); - -function copyRecursive(from, to) { - if (!fs.existsSync(to)) { - fs.mkdirSync(to); - } - - const items = fs.readdirSync(from, { withFileTypes: true }); - - for (const item of items) { - const fromPath = path.join(from, item.name); - const toPath = path.join(to, item.name); - - if (item.isDirectory()) { - copyRecursive(fromPath, toPath); - } else { - fs.copyFileSync(fromPath, toPath); - } - } -} - -function calculateDirectorySize(directoryPath) { - let totalSize = 0; - - const items = fs.readdirSync(directoryPath, { withFileTypes: true }); - - for (const item of items) { - const itemPath = path.join(directoryPath, item.name); - - if (item.isDirectory()) { - calculateDirectorySize(itemPath); - continue; - } - - totalSize += fs.statSync(itemPath).size; - } - - return totalSize; -} - -function runCliWithParameters(parameters) { - return execSync(`node "${cliPath}" ${parameters}`).toString(); -} - -function grepTotalRatio(string) { - const [, ratio] = /You\ssaved\s.+\((\d{1,3})%\)/.exec(string); - return Number(ratio); -} - -function expectStringContains(string, containing) { - expect(string).toEqual(expect.stringContaining(containing)); -} - -function expectStringNotContains(string, containing) { - expect(string).toEqual(expect.not.stringContaining(containing)); -} - -function expectRatio(current, min, max) { - expect(current).toBeGreaterThanOrEqual(min); - expect(current).toBeLessThanOrEqual(max); -} - -function expectFileRatio({ file, maxRatio, minRatio, stdout, outputExt }) { - const fileBasename = path.basename(file, path.extname(file)); - const outputFile = outputExt ? `${fileBasename}.${outputExt}` : file; - - const sizeBefore = fs.statSync(path.join(images, file)).size; - const sizeAfter = fs.statSync(path.join(temporary, outputFile)).size; - - const calculatedRatio = calculateRatio(sizeBefore, sizeAfter); - const stdoutRatio = grepTotalRatio(stdout); - - expectStringContains(stdout, path.join(temporary, outputFile)); - expect(stdoutRatio).toBe(calculatedRatio); - expectRatio(stdoutRatio, minRatio, maxRatio); -} - -function expectTotalRatio({ maxRatio, minRatio, stdout }) { - const sizeBefore = calculateDirectorySize(images); - const sizeAfter = calculateDirectorySize(temporary); - const calculatedRatio = calculateRatio(sizeBefore, sizeAfter); - const stdoutRatio = grepTotalRatio(stdout); - - expect(stdoutRatio).toBe(calculatedRatio); - expectRatio(stdoutRatio, minRatio, maxRatio); -} - -function expectFileNotModified(fileName) { - const origImageBuffer = fs.readFileSync(path.join(images, fileName)); - const temporaryImageBuffer = fs.readFileSync(path.join(temporary, fileName)); - - expect(temporaryImageBuffer.equals(origImageBuffer)).toBe(true); -} - -function expectFileExists(fileName) { - const isFileExists = fs.existsSync(path.join(temporary, fileName)); - expect(isFileExists).toBe(true); -} - -function expectFileNotExists(fileName) { - const isFileExists = fs.existsSync(path.join(temporary, fileName)); - expect(isFileExists).toBe(false); -} diff --git a/tests/create-progress-bar-container.test.js b/tests/create-progress-bar-container.test.js new file mode 100644 index 0000000..2092155 --- /dev/null +++ b/tests/create-progress-bar-container.test.js @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { createProgressBarContainer } from '../lib/create-progress-bar-container.js'; + +const originalIsTTY = process.stderr.isTTY; + +afterEach(() => { + process.stderr.isTTY = originalIsTTY; + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +function prepareStderr({ isTTY, term }) { + process.stderr.isTTY = isTTY; + vi.stubEnv('TERM', term); + return vi.spyOn(process.stderr, 'write').mockImplementation(() => true); +} + +describe('progress bar container', () => { + test.each([ + ['stderr is not a terminal', { isTTY: false, term: 'xterm-256color' }], + ['the terminal is dumb', { isTTY: true, term: 'dumb' }], + ])('renders nothing when %s', (_name, capabilities) => { + const stderrSpy = prepareStderr(capabilities); + + const container = createProgressBarContainer(3); + const progressBar = container.create(3, 0); + progressBar.increment(); + container.log('inline message'); + container.update(); + container.stop(); + + expect(container.isRendering).toBe(false); + expect(stderrSpy).not.toHaveBeenCalled(); + }); + + test('renders the requested operation total on a capable terminal', () => { + const stderrSpy = prepareStderr({ isTTY: true, term: 'xterm-256color' }); + + const container = createProgressBarContainer(3); + try { + const progressBar = container.create(3, 0); + progressBar.increment(); + container.update(); + + expect(container.isRendering).toBe(true); + expect(stderrSpy.mock.calls.flat().join('')).toContain('Processed 1 of 3 operations'); + } finally { + container.stop(); + } + }); +}); diff --git a/tests/filesystem-safety.test.js b/tests/filesystem-safety.test.js new file mode 100644 index 0000000..3fb041b --- /dev/null +++ b/tests/filesystem-safety.test.js @@ -0,0 +1,193 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { afterEach, describe, expect, test } from 'vitest'; + +import { + copyFixture, + fileSize, + findTemporaryWriteLeftovers, + isPrivileged, + isWindows, + makeTemporaryDirectory, + removeTemporaryDirectories, + runCli, + summaryLine, +} from './helpers/cli.js'; + +afterEach(removeTemporaryDirectories); + +describe('atomic replacement', () => { + test('an existing target is replaced in place with its mode preserved', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png', 'image with spaces.png'); + const sizeBefore = await fileSize(imagePath); + await fs.chmod(imagePath, 0o640); + + const result = await runCli([imagePath]); + + expect(result.code).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain(summaryLine('1 processed')); + const after = await fs.stat(imagePath); + expect(after.size).toBeLessThan(sizeBefore); + expect(after.mode & 0o777).toBe(isWindows ? after.mode & 0o777 : 0o640); + await expect(findTemporaryWriteLeftovers(directory)).resolves.toEqual([]); + }); + + test('conversion creates the selected variants without modifying the source', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png'); + const before = await fs.readFile(imagePath); + + const result = await runCli(['--avif', '--webp', imagePath]); + + expect(result.code).toBe(0); + const source = await fs.readFile(imagePath); + expect(source.equals(before)).toBe(true); + expect(result.stderr).toContain(summaryLine('2 processed')); + await expect(fileSize(path.join(directory, 'png-not-optimized.avif'))).resolves.toBeGreaterThan(0); + await expect(fileSize(path.join(directory, 'png-not-optimized.webp'))).resolves.toBeGreaterThan(0); + await expect(findTemporaryWriteLeftovers(directory)).resolves.toEqual([]); + }); + + test('an existing conversion target is kept unless force is selected', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png'); + const target = path.join(directory, 'png-not-optimized.webp'); + await fs.writeFile(target, 'existing'); + + const skipped = await runCli(['--verbose', '--webp', imagePath]); + expect(skipped.code).toBe(0); + expect(skipped.stderr).toContain(summaryLine('1 skipped')); + await expect(fs.readFile(target, 'utf8')).resolves.toBe('existing'); + + const forced = await runCli(['--force', '--webp', imagePath]); + expect(forced.code).toBe(0); + await expect(fs.readFile(target, 'utf8')).resolves.not.toBe('existing'); + }); + + test.skipIf(isWindows || isPrivileged)('a target survives a failing write and leaves no temporary file', async () => { + const directory = await makeTemporaryDirectory(); + const readOnly = path.join(directory, 'read-only'); + const imagePath = await copyFixture(readOnly, 'png-not-optimized.png'); + const before = await fs.readFile(imagePath); + await fs.chmod(readOnly, 0o555); + + try { + const result = await runCli([imagePath]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('1 failed'); + const current = await fs.readFile(imagePath); + expect(current.equals(before)).toBe(true); + await expect(findTemporaryWriteLeftovers(readOnly)).resolves.toEqual([]); + } finally { + await fs.chmod(readOnly, 0o755); + } + }); + + test.skipIf(isWindows)('a multiply hard-linked target is not replaced', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png'); + const linkPath = path.join(directory, 'hard-link.png'); + await fs.link(imagePath, linkPath); + const before = await fs.readFile(imagePath); + + const result = await runCli([imagePath]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain(`Refusing to replace multiply hard-linked file: ${imagePath}`); + const current = await fs.readFile(imagePath); + expect(current.equals(before)).toBe(true); + }); + + test.skipIf(isWindows)('a distinct output may be created from a hard-linked source', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png'); + await fs.link(imagePath, path.join(directory, 'hard-link.png')); + + const result = await runCli(['--webp', imagePath]); + + expect(result.code).toBe(0); + await expect(fileSize(path.join(directory, 'png-not-optimized.webp'))).resolves.toBeGreaterThan(0); + }); +}); + +describe.skipIf(isWindows)('symbolic links', () => { + test('optimizing an explicit symlink replaces the real target and keeps the link', async () => { + const directory = await makeTemporaryDirectory(); + const realPath = await copyFixture(directory, 'png-not-optimized.png', 'real.png'); + const linkPath = path.join(directory, 'alias.png'); + await fs.symlink('real.png', linkPath); + const sizeBefore = await fileSize(realPath); + + const result = await runCli([linkPath]); + + expect(result.code).toBe(0); + const linkStat = await fs.lstat(linkPath); + expect(linkStat.isSymbolicLink()).toBe(true); + await expect(fileSize(realPath)).resolves.toBeLessThan(sizeBefore); + await expect(findTemporaryWriteLeftovers(directory)).resolves.toEqual([]); + }); + + test('converting an explicit symlink places the variant beside the operand', async () => { + const directory = await makeTemporaryDirectory(); + await copyFixture(directory, 'png-not-optimized.png', path.join('elsewhere', 'real.png')); + const linkPath = path.join(directory, 'alias.png'); + await fs.symlink(path.join('elsewhere', 'real.png'), linkPath); + + const result = await runCli(['--webp', linkPath]); + + expect(result.code).toBe(0); + await expect(fileSize(path.join(directory, 'alias.webp'))).resolves.toBeGreaterThan(0); + await expect(fs.stat(path.join(directory, 'elsewhere', 'real.webp'))).rejects.toThrow(); + }); + + test('recursive traversal does not follow symlinked directories', async () => { + const directory = await makeTemporaryDirectory(); + await copyFixture(directory, 'png-not-optimized.png', path.join('real', 'picture.png')); + await fs.symlink(path.join(directory, 'real'), path.join(directory, 'linked')); + + const result = await runCli([directory]); + + expect(result.code).toBe(0); + expect(result.stderr).toContain('Optimizing 1 image'); + expect(result.stderr).toContain(summaryLine('1 processed')); + }); + + test('an existing conversion-output symlink is skipped without force', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png'); + const realTarget = path.join(directory, 'real.webp'); + await fs.writeFile(realTarget, 'existing'); + await fs.symlink('real.webp', path.join(directory, 'png-not-optimized.webp')); + + const skipped = await runCli(['--verbose', '--webp', imagePath]); + expect(skipped.code).toBe(0); + expect(skipped.stderr).toContain(summaryLine('1 skipped')); + await expect(fs.readFile(realTarget, 'utf8')).resolves.toBe('existing'); + + const forced = await runCli(['--force', '--webp', imagePath]); + expect(forced.code).toBe(0); + await expect(fs.readFile(realTarget, 'utf8')).resolves.not.toBe('existing'); + }); + + test('forced replacement may not follow a symlink out of the output root', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png'); + const output = path.join(directory, 'output'); + const outside = path.join(directory, 'outside'); + await fs.mkdir(output); + await fs.mkdir(outside); + const escapeTarget = path.join(outside, 'escaped.webp'); + await fs.writeFile(escapeTarget, 'outside the root'); + await fs.symlink(escapeTarget, path.join(output, 'png-not-optimized.webp')); + + const result = await runCli(['--force', '--webp', '--output', output, imagePath]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('Output escapes permitted root'); + await expect(fs.readFile(escapeTarget, 'utf8')).resolves.toBe('outside the root'); + }); +}); diff --git a/tests/formats.test.js b/tests/formats.test.js new file mode 100644 index 0000000..9a75953 --- /dev/null +++ b/tests/formats.test.js @@ -0,0 +1,115 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { afterEach, describe, expect, test } from 'vitest'; + +import { + copyFixture, + fileSize, + makeTemporaryDirectory, + removeTemporaryDirectories, + runCli, + summaryLine, +} from './helpers/cli.js'; + +afterEach(removeTemporaryDirectories); + +// Each supported format reaches a different codec, so every codec is exercised with the +// bundled configuration. Compression numbers are not asserted, only that real work happened. +const OPTIMIZED_FORMATS = [ + ['GIF through gifsicle', 'gif-not-optimized.gif'], + ['JPEG through sharp', 'jpeg-not-optimized.jpeg'], + ['PNG through sharp', 'png-not-optimized.png'], + ['SVG through svgo', 'svg-not-optimized.svg'], +]; + +describe('optimization by format', () => { + test.each(OPTIMIZED_FORMATS)('optimizes %s', async (_name, fixture) => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, fixture); + const sizeBefore = await fileSize(imagePath); + + const result = await runCli([imagePath]); + + expect(result.code).toBe(0); + expect(result.stderr).toContain(summaryLine('1 processed')); + await expect(fileSize(imagePath)).resolves.toBeLessThan(sizeBefore); + }); + + test.each(OPTIMIZED_FORMATS)('optimizes %s in lossless mode', async (_name, fixture) => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, fixture); + const sizeBefore = await fileSize(imagePath); + + // Lossless JPEG runs Guetzli, which is deliberately slow. + const result = await runCli(['--lossless', imagePath]); + + expect(result.code).toBe(0); + expect(result.stderr).toContain(summaryLine('1 processed')); + await expect(fileSize(imagePath)).resolves.toBeLessThan(sizeBefore); + }, 60_000); + + test.each([ + ['an already optimized SVG', 'svg-optimized.svg'], + ['a JPEG that cannot be improved', 'jpeg-one-pixel.jpg'], + ])('skips %s without rewriting it', async (_name, fixture) => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, fixture); + const before = await fs.readFile(imagePath); + + const result = await runCli([imagePath]); + + expect(result.code).toBe(0); + expect(result.stderr).toContain(summaryLine('1 skipped')); + const after = await fs.readFile(imagePath); + expect(after.equals(before)).toBe(true); + }); +}); + +describe('conversion by format', () => { + test.each([ + ['GIF', 'gif-not-optimized.gif'], + ['JPEG', 'jpeg-not-optimized.jpeg'], + ['PNG', 'png-not-optimized.png'], + ])('converts %s to WebP', async (_name, fixture) => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, fixture); + const before = await fs.readFile(imagePath); + + const result = await runCli(['--webp', imagePath]); + + expect(result.code).toBe(0); + expect(result.stderr).toContain(summaryLine('1 processed')); + const variant = path.join(directory, `${path.basename(fixture, path.extname(fixture))}.webp`); + await expect(fileSize(variant)).resolves.toBeGreaterThan(0); + const source = await fs.readFile(imagePath); + expect(source.equals(before)).toBe(true); + }); + + test.each([ + ['JPEG', 'jpeg-not-optimized.jpeg'], + ['PNG', 'png-not-optimized.png'], + ])('converts %s to AVIF', async (_name, fixture) => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, fixture); + + const result = await runCli(['--avif', imagePath]); + + expect(result.code).toBe(0); + expect(result.stderr).toContain(summaryLine('1 processed')); + const variant = path.join(directory, `${path.basename(fixture, path.extname(fixture))}.avif`); + await expect(fileSize(variant)).resolves.toBeGreaterThan(0); + }); + + test('reports that an animated GIF cannot become AVIF', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'gif-not-optimized.gif'); + + const result = await runCli(['--avif', imagePath]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain(summaryLine('1 failed')); + expect(result.stderr).toContain('Animated AVIF is not supported'); + await expect(fs.stat(path.join(directory, 'gif-not-optimized.avif'))).rejects.toThrow(); + }); +}); diff --git a/tests/helpers/cli.js b/tests/helpers/cli.js new file mode 100644 index 0000000..80f8233 --- /dev/null +++ b/tests/helpers/cli.js @@ -0,0 +1,107 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os, { EOL } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Every behaviour test drives the real CLI process with an argv array against real +// temporary directories, so nothing in a test path can be reinterpreted by a shell. +const dirname = path.dirname(fileURLToPath(import.meta.url)); +const cliPath = path.resolve(dirname, '..', '..', 'cli.js'); +const fixturesPath = path.resolve(dirname, '..', 'images'); +const temporaryDirectories = []; + +// Re-exported so a process-level test file needs only this harness. +export { hasCaseSensitivePaths, isPrivileged, isWindows } from './platform.js'; + +export async function makeTemporaryDirectory() { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'optimizt-test-')); + temporaryDirectories.push(directory); + return directory; +} + +export async function removeTemporaryDirectories() { + await Promise.all(temporaryDirectories.map(directory => fs.rm(directory, { force: true, recursive: true }))); + temporaryDirectories.length = 0; +} + +export async function copyFixture(directory, fixtureName, outputName = fixtureName) { + const target = path.join(directory, outputName); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.copyFile(path.join(fixturesPath, fixtureName), target); + return target; +} + +// The whole summary line, so an assertion also states which outcomes were absent. +export function summaryLine(counts) { + return `i ${counts}${EOL}`; +} + +// Outcome counts are readable individually because absent outcomes are not printed. +export function outcomeCount(stderr, label) { + const match = new RegExp(String.raw`(\d+) ${label}`).exec(stderr); + return match ? Number(match[1]) : 0; +} + +export async function fileSize(filePath) { + const stat = await fs.stat(filePath); + return stat.size; +} + +export async function findTemporaryWriteLeftovers(directory) { + const names = await fs.readdir(directory); + return names.filter(name => /\.\d{4,}$/.test(name) || name.includes('.optimizt')); +} + +export function startCli(argumentsList, { environment = {} } = {}) { + const child = spawn(process.execPath, [cliPath, ...argumentsList], { + env: { ...process.env, ...environment }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + const stderrWatchers = new Set(); + + child.stdout.setEncoding('utf8').on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.setEncoding('utf8').on('data', (chunk) => { + stderr += chunk; + for (const watcher of stderrWatchers) watcher(); + }); + + const finished = new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', (code, signal) => resolve({ code, signal, stderr, stdout })); + }); + + return { + child, + finished, + // Signal tests must act while work is in flight, so they wait for evidence of + // progress instead of sleeping for an arbitrary duration. + async whenStderrIncludes(text) { + const appeared = new Promise((resolve) => { + const check = () => { + if (!stderr.includes(text)) return; + stderrWatchers.delete(check); + resolve(true); + }; + stderrWatchers.add(check); + check(); + }); + const exited = (async () => { + await finished; + return false; + })(); + + if (await Promise.race([appeared, exited])) return; + throw new Error(`CLI exited before stderr included ${JSON.stringify(text)}: ${stderr}`); + }, + }; +} + +export function runCli(argumentsList, options) { + return startCli(argumentsList, options).finished; +} diff --git a/tests/helpers/platform.js b/tests/helpers/platform.js new file mode 100644 index 0000000..92a9c10 --- /dev/null +++ b/tests/helpers/platform.js @@ -0,0 +1,5 @@ +// Platform conditions used to skip tests whose semantics a platform cannot offer. +export const isWindows = process.platform === 'win32'; +export const isPrivileged = process.getuid?.() === 0; +// Path comparison is case-sensitive on Linux and conservatively case-insensitive elsewhere. +export const hasCaseSensitivePaths = process.platform === 'linux'; diff --git a/tests/interruption.test.js b/tests/interruption.test.js new file mode 100644 index 0000000..92b91ef --- /dev/null +++ b/tests/interruption.test.js @@ -0,0 +1,96 @@ +import os from 'node:os'; + +import { afterEach, describe, expect, test } from 'vitest'; + +import { + copyFixture, + fileSize, + findTemporaryWriteLeftovers, + isWindows, + makeTemporaryDirectory, + outcomeCount, + removeTemporaryDirectories, + startCli, +} from './helpers/cli.js'; + +// Interruption must arrive while work is still queued, so the plan holds several times +// more operations than the runtime can process at once, and the signal is sent only +// after the first operation has reported a result. +const IMAGE_COUNT = os.cpus().length * 4; + +afterEach(removeTemporaryDirectories); + +async function startInterruptibleRun() { + const directory = await makeTemporaryDirectory(); + const images = []; + for (let index = 0; index < IMAGE_COUNT; index += 1) { + images.push(await copyFixture(directory, 'png-not-optimized.png', `image-${index}.png`)); + } + + const run = startCli([directory]); + await run.whenStderrIncludes('Ratio:'); + + return { directory, images, run }; +} + +// POSIX statuses and signal delivery are Unix semantics; Windows offers no equivalent. +describe.skipIf(isWindows)('interruption', () => { + test('SIGINT exits 130 within the bounded shutdown and leaves every image readable', async () => { + const { directory, images, run } = await startInterruptibleRun(); + const sizesBefore = await Promise.all(images.map(image => fileSize(image))); + + run.child.kill('SIGINT'); + const interruptedAt = Date.now(); + const result = await run.finished; + + expect(result.code).toBe(130); + expect(result.stdout).toBe(''); + expect(Date.now() - interruptedAt).toBeLessThan(5000); + await expect(findTemporaryWriteLeftovers(directory)).resolves.toEqual([]); + + const sizesAfter = await Promise.all(images.map(image => fileSize(image))); + for (const [index, size] of sizesAfter.entries()) { + expect(size).toBeGreaterThan(0); + expect(size).toBeLessThanOrEqual(sizesBefore[index]); + } + }, 30_000); + + test('SIGTERM exits 143', async () => { + const { run } = await startInterruptibleRun(); + + run.child.kill('SIGTERM'); + const result = await run.finished; + + expect(result.code).toBe(143); + }, 30_000); + + test('a second interrupt exits immediately', async () => { + const { run } = await startInterruptibleRun(); + + run.child.kill('SIGINT'); + const startedAt = Date.now(); + await new Promise(resolve => setTimeout(resolve, 100)); + run.child.kill('SIGINT'); + const result = await run.finished; + + expect(result.code).toBe(130); + // The bounded shutdown allows five seconds; the escape hatch must not wait for it. + expect(Date.now() - startedAt).toBeLessThan(5000); + }, 30_000); + + test('every operation is accounted for without being called skipped or failed', async () => { + const { run } = await startInterruptibleRun(); + + run.child.kill('SIGINT'); + const result = await run.finished; + + const processed = outcomeCount(result.stderr, 'processed'); + const skipped = outcomeCount(result.stderr, 'skipped'); + const failed = outcomeCount(result.stderr, 'failed'); + const unstarted = outcomeCount(result.stderr, 'not started'); + expect(processed + skipped + failed + unstarted).toBe(IMAGE_COUNT); + expect(unstarted).toBeGreaterThan(0); + expect(failed).toBe(0); + expect(result.stderr).not.toContain('exited with code null'); + }, 30_000); +}); diff --git a/tests/lifecycle.test.js b/tests/lifecycle.test.js new file mode 100644 index 0000000..fa19c95 --- /dev/null +++ b/tests/lifecycle.test.js @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { isWindows } from './helpers/platform.js'; + +// Signal delivery is covered at the process level; these tests pin the resource rules +// the coordinator applies while a real signal is being handled. +const interruptStatus = { SIGINT: isWindows ? 1 : 130, SIGTERM: isWindows ? 1 : 143 }; +const installedListeners = []; + +afterEach(() => { + for (const [signal, listener] of installedListeners) process.removeListener(signal, listener); + installedListeners.length = 0; + vi.restoreAllMocks(); +}); + +// The coordinator owns module state, so every test works with a freshly loaded copy. +async function loadLifecycle() { + vi.resetModules(); + const lifecycle = await import('../lib/lifecycle.js'); + const known = new Set(['SIGINT', 'SIGTERM'].flatMap(signal => process.listeners(signal))); + + lifecycle.installSignalHandlers(); + for (const signal of ['SIGINT', 'SIGTERM']) { + for (const listener of process.listeners(signal)) { + if (!known.has(listener)) installedListeners.push([signal, listener]); + } + } + + return lifecycle; +} + +function createChildStub() { + return { kill: vi.fn(), once: vi.fn() }; +} + +describe('shutdown coordinator', () => { + test('nothing is interrupted and no status is forced without a signal', async () => { + const lifecycle = await loadLifecycle(); + const child = createChildStub(); + lifecycle.registerChild(child); + + expect(lifecycle.isInterrupted()).toBe(false); + expect(lifecycle.finishLifecycle()).toBeUndefined(); + expect(child.kill).not.toHaveBeenCalled(); + }); + + test.each(['SIGINT', 'SIGTERM'])('%s terminates active external encoders and forces its status', async (signal) => { + const lifecycle = await loadLifecycle(); + const child = createChildStub(); + lifecycle.registerChild(child); + + process.emit(signal); + + expect(child.kill).toHaveBeenCalledWith('SIGTERM'); + expect(lifecycle.isInterrupted()).toBe(true); + expect(lifecycle.finishLifecycle()).toBe(interruptStatus[signal]); + }); + + test('an encoder started after the interrupt is terminated as soon as it is registered', async () => { + const lifecycle = await loadLifecycle(); + + process.emit('SIGINT'); + const lateChild = createChildStub(); + lifecycle.registerChild(lateChild); + + expect(lateChild.kill).toHaveBeenCalledWith('SIGTERM'); + expect(lifecycle.finishLifecycle()).toBe(interruptStatus.SIGINT); + }); +}); diff --git a/tests/log.test.js b/tests/log.test.js index 6599cb5..1ef8676 100644 --- a/tests/log.test.js +++ b/tests/log.test.js @@ -1,90 +1,19 @@ -import { - describe, expect, test, vi, -} from 'vitest'; +import { EOL } from 'node:os'; -import { colorize } from '../lib/colorize.js'; -import { LOG_TYPES, log } from '../lib/log.js'; - -const colors = { - info: 'blue', - success: 'green', - warning: 'yellow', - error: 'red', -}; -const symbols = { - info: ['i', 'ℹ'], - success: ['√', '✔'], - warning: ['‼', '⚠'], - error: ['×', '✖'], -}; -const symbolIndex = Number(process.platform !== 'win32' || process.env.TERM === 'xterm-256color'); - -test('Default log type is “info”', () => { - expectLog({ - symbol: symbols.info[symbolIndex], - title: 'default', - }); -}); - -test('Description logged', () => { - expectLog({ - description: 'Simple description', - symbol: symbols.info[symbolIndex], - title: 'Hello!', - }); -}); - -describe('Titles and symbols', () => { - test('Logged “info” with symbol', () => { - expectLog({ - symbol: symbols.info[symbolIndex], - title: 'info', - type: LOG_TYPES.INFO, - }); - }); +import { expect, test, vi } from 'vitest'; - test('Logged “success” with symbol', () => { - expectLog({ - symbol: symbols.success[symbolIndex], - title: 'success', - type: LOG_TYPES.SUCCESS, - }); - }); - - test('Logged “warning” with symbol', () => { - expectLog({ - symbol: symbols.warning[symbolIndex], - title: 'warning', - type: LOG_TYPES.WARNING, - }); - }); +import { LOG_TYPES, log } from '../lib/log.js'; - test('Logged “error” with symbol', () => { - expectLog({ - symbol: symbols.error[symbolIndex], - title: 'error', - type: LOG_TYPES.ERROR, - }); - }); +test.each([ + [undefined, 'i'], + [LOG_TYPES.INFO, 'i'], + [LOG_TYPES.SUCCESS, 'v'], + [LOG_TYPES.WARNING, '!'], + [LOG_TYPES.ERROR, 'x'], +])('logs %s messages to stderr', (type, symbol) => { + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + log('Title', { description: 'Description', type }); + + expect(stderrSpy).toHaveBeenCalledWith(`${symbol} Title ${EOL} Description${EOL}`); + stderrSpy.mockRestore(); }); - -function expectLog({ - description, - symbol, - title, - type, -}) { - const symbolColored = colorize(symbol)[colors[(type || LOG_TYPES.INFO)]]; - const descriptionColored = description - ? colorize(description).dim - : undefined; - - const consoleSpy = vi.spyOn(console, 'log'); - log(title, { type, description }); - - expect(consoleSpy.mock.calls[0][0]).toBe(symbolColored); - expect(consoleSpy.mock.calls[0][1]).toBe(title); - expect(consoleSpy.mock.calls[0][4]).toBe(descriptionColored); - - consoleSpy.mockRestore(); -} diff --git a/tests/operation-planning.test.js b/tests/operation-planning.test.js new file mode 100644 index 0000000..c0adf05 --- /dev/null +++ b/tests/operation-planning.test.js @@ -0,0 +1,322 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { afterEach, describe, expect, test } from 'vitest'; + +import { + copyFixture, + fileSize, + hasCaseSensitivePaths, + isPrivileged, + isWindows, + makeTemporaryDirectory, + removeTemporaryDirectories, + runCli, + summaryLine, +} from './helpers/cli.js'; + +afterEach(removeTemporaryDirectories); + +describe('eligibility', () => { + test('an explicit file with an unsupported extension fails', async () => { + const directory = await makeTemporaryDirectory(); + const notAnImage = path.join(directory, 'notes.txt'); + await fs.writeFile(notAnImage, 'text'); + + const result = await runCli([notAnImage]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain(`Unsupported explicit file: ${notAnImage}`); + }); + + test('unsupported files discovered inside a directory are ignored', async () => { + const directory = await makeTemporaryDirectory(); + await copyFixture(directory, 'png-not-optimized.png'); + await fs.writeFile(path.join(directory, 'notes.txt'), 'text'); + + const result = await runCli([directory]); + + expect(result.code).toBe(0); + expect(result.stderr).toContain('Optimizing 1 image'); + expect(result.stderr).toContain(summaryLine('1 processed')); + }); + + test('an explicit file that cannot be converted fails before processing', async () => { + const directory = await makeTemporaryDirectory(); + const svgPath = await copyFixture(directory, 'svg-not-optimized.svg'); + + const result = await runCli(['--webp', svgPath]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain(`Unsupported explicit file: ${svgPath}`); + }); + + test.skipIf(isWindows || isPrivileged)('an incomplete directory traversal fails before any image changes', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png'); + const deniedPath = path.join(directory, 'denied'); + await fs.mkdir(deniedPath); + await copyFixture(deniedPath, 'png-not-optimized.png', 'hidden.png'); + await fs.chmod(deniedPath, 0o000); + const sizeBefore = await fileSize(imagePath); + + try { + const result = await runCli([directory]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain(`Cannot traverse directory ${deniedPath}`); + await expect(fileSize(imagePath)).resolves.toBe(sizeBefore); + } finally { + await fs.chmod(deniedPath, 0o755); + } + }); +}); + +describe('operand normalization', () => { + test('equivalent operands are processed once and reported in verbose output', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png'); + // Joined without path.join, which would normalize the detour away before the CLI sees it. + const spelledThroughParent = [directory, 'nested', '..', 'png-not-optimized.png'].join(path.sep); + await fs.mkdir(path.join(directory, 'nested')); + + const result = await runCli(['--verbose', imagePath, spelledThroughParent]); + + expect(result.code).toBe(0); + expect(result.stderr).toContain('Optimizing 1 image'); + expect(result.stderr).toContain(summaryLine('1 processed')); + expect(result.stderr).toContain(`Duplicate of '${imagePath}'`); + }); + + test('deduplication stays quiet without verbose output', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png'); + + const result = await runCli([imagePath, imagePath]); + + expect(result.code).toBe(0); + expect(result.stderr).toContain('Optimizing 1 image'); + expect(result.stderr).not.toContain('Duplicate of'); + }); + + test('overlapping directory operands are deduplicated when processing in place', async () => { + const directory = await makeTemporaryDirectory(); + const nested = path.join(directory, 'nested'); + await fs.mkdir(nested); + await copyFixture(directory, 'png-not-optimized.png', 'top.png'); + await copyFixture(nested, 'png-not-optimized.png', 'deep.png'); + + const result = await runCli(['--verbose', directory, nested]); + + expect(result.code).toBe(0); + expect(result.stderr).toContain('Optimizing 2 images'); + expect(result.stderr).toContain(summaryLine('2 processed')); + expect(result.stderr).toContain('Already planned once'); + }); + + test('an output root accepts the same directory spelled two ways', async () => { + const directory = await makeTemporaryDirectory(); + const input = path.join(directory, 'input'); + const output = path.join(directory, 'output'); + await fs.mkdir(output); + await copyFixture(input, 'png-not-optimized.png', 'picture.png'); + + const result = await runCli(['--output', output, input, path.join(input, '.')]); + + expect(result.code).toBe(0); + expect(result.stderr).toContain(summaryLine('1 processed')); + await expect(exists(path.join(output, 'picture.png'))).resolves.toBe(true); + }); +}); + +describe('output mapping', () => { + test('nested directory contents are mapped under an existing output root', async () => { + const directory = await makeTemporaryDirectory(); + const input = path.join(directory, 'input'); + const output = path.join(directory, 'output'); + await fs.mkdir(output); + await copyFixture(input, 'png-not-optimized.png', path.join('nested', 'picture.png')); + + const result = await runCli(['--output', output, input]); + + expect(result.code).toBe(0); + expect(await exists(path.join(output, 'nested', 'picture.png'))).toBe(true); + }); + + test('several directory operands map directly into one output root', async () => { + const directory = await makeTemporaryDirectory(); + const output = path.join(directory, 'output'); + await fs.mkdir(output); + await copyFixture(directory, 'png-not-optimized.png', path.join('first', 'one.png')); + await copyFixture(directory, 'png-not-optimized.png', path.join('second', 'two.png')); + + const result = await runCli(['--output', output, path.join(directory, 'first'), path.join(directory, 'second')]); + + expect(result.code).toBe(0); + expect(await exists(path.join(output, 'one.png'))).toBe(true); + expect(await exists(path.join(output, 'two.png'))).toBe(true); + }); + + test('a missing output root fails preflight instead of being created', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png'); + const output = path.join(directory, 'output'); + + const result = await runCli(['--output', output, imagePath]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain(`Output path does not exist or is inaccessible: ${output}`); + expect(await exists(output)).toBe(false); + }); + + test('a target that is not a regular file fails before encoding', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png'); + const output = path.join(directory, 'output'); + const blockedTarget = path.join(output, 'png-not-optimized.webp'); + await fs.mkdir(blockedTarget, { recursive: true }); + + const result = await runCli(['--webp', '--output', output, imagePath]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain(`Output target is not a regular file: ${blockedTarget}`); + await expect(fs.readdir(blockedTarget)).resolves.toEqual([]); + }); + + test('an output whose existing parent is not a directory fails preflight', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png', path.join('input', 'nested', 'picture.png')); + const output = path.join(directory, 'output'); + await fs.mkdir(output); + await fs.writeFile(path.join(output, 'nested'), 'occupied by a file'); + + const result = await runCli(['--output', output, path.join(directory, 'input')]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('Output parent is not a directory'); + await expect(fs.readFile(path.join(output, 'nested'), 'utf8')).resolves.toBe('occupied by a file'); + await expect(fileSize(imagePath)).resolves.toBeGreaterThan(0); + }); + + test('an output root that is not a directory fails preflight', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png'); + const outputPath = path.join(directory, 'output.txt'); + await fs.writeFile(outputPath, 'not a directory'); + + const result = await runCli(['--output', outputPath, imagePath]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain(`Output path is not a directory: ${outputPath}`); + }); +}); + +describe('collisions', () => { + test('two inputs mapping to one output are rejected before any write', async () => { + const directory = await makeTemporaryDirectory(); + const output = path.join(directory, 'output'); + await fs.mkdir(output); + await copyFixture(directory, 'png-not-optimized.png', path.join('first', 'same.png')); + await copyFixture(directory, 'png-not-optimized.png', path.join('second', 'same.png')); + + const result = await runCli(['--output', output, path.join(directory, 'first'), path.join(directory, 'second')]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('Output collision'); + expect(await fs.readdir(output)).toEqual([]); + }); + + test.runIf(hasCaseSensitivePaths)('case variants are distinct targets on a case-sensitive filesystem', async () => { + const { arguments_, output } = await prepareCaseVariants(); + + const result = await runCli(arguments_); + + expect(result.code).toBe(0); + await expect(fs.readdir(output)).resolves.toEqual(['Picture.png', 'picture.png']); + }); + + test.skipIf(hasCaseSensitivePaths)('case variants collide where path comparison ignores case', async () => { + const { arguments_, output } = await prepareCaseVariants(); + + const result = await runCli(arguments_); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('Output collision'); + await expect(fs.readdir(output)).resolves.toEqual([]); + }); + + test('overlapping directory operands are rejected with an output root', async () => { + const directory = await makeTemporaryDirectory(); + const output = path.join(directory, 'output'); + const input = path.join(directory, 'input'); + await fs.mkdir(output); + await copyFixture(input, 'png-not-optimized.png', path.join('nested', 'picture.png')); + + const result = await runCli(['--output', output, input, path.join(input, 'nested')]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('Overlapping directory operands cannot be used with --output'); + expect(await fs.readdir(output)).toEqual([]); + }); +}); + +describe('generated names', () => { + test('an invalid generated name is rejected rather than sanitized', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png'); + const output = path.join(directory, 'output'); + await fs.mkdir(output); + + const result = await runCli(['--prefix', '', '--output', output, imagePath]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('not portable'); + expect(await fs.readdir(output)).toEqual([]); + }); + + test.each([ + ['--prefix', 'Prefix'], + ['--suffix', 'Suffix'], + ])('%s containing a path separator is rejected', async (option, label) => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png'); + + const result = await runCli([option, `nested${path.sep}part`, imagePath]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain(`${label} must not contain path separators or NUL`); + }); + + // Every basename this project rejects as non-portable is also unusable on Windows, + // so only a POSIX filesystem can hold such an existing input. + test.skipIf(isWindows)('an existing name that is not portable stays usable for in-place optimization', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png', 'question?.png'); + const sizeBefore = await fileSize(imagePath); + + const result = await runCli([imagePath]); + + expect(result.code).toBe(0); + await expect(fileSize(imagePath)).resolves.toBeLessThan(sizeBefore); + }); +}); + +// Two inputs whose outputs differ only in case: distinct on a case-sensitive filesystem, +// the same target where path comparison ignores case. +async function prepareCaseVariants() { + const directory = await makeTemporaryDirectory(); + const output = path.join(directory, 'output'); + await fs.mkdir(output); + await copyFixture(directory, 'png-not-optimized.png', path.join('first', 'Picture.png')); + await copyFixture(directory, 'png-not-optimized.png', path.join('second', 'picture.png')); + return { arguments_: ['--output', output, path.join(directory, 'first'), path.join(directory, 'second')], output }; +} + +async function exists(targetPath) { + try { + await fs.stat(targetPath); + return true; + } catch { + return false; + } +} diff --git a/tests/prepare-file-paths.test.js b/tests/prepare-file-paths.test.js deleted file mode 100644 index ed8eec2..0000000 --- a/tests/prepare-file-paths.test.js +++ /dev/null @@ -1,71 +0,0 @@ -import { expect, test } from 'vitest'; - -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { getRelativePath } from '../lib/get-relative-path.js'; -import { prepareFilePaths } from '../lib/prepare-file-paths.js'; - -const dirname = path.dirname(fileURLToPath(import.meta.url)); - -const DEFAULT_IMAGE_PATH = resolvePath(['images']); -const DEFAULT_EXTENSIONS = ['gif', 'jpeg', 'jpg', 'png', 'svg']; - -test('Non-existent file paths are ignored', async () => { - const inputPaths = await generateInputPaths({ - inputPaths: [ - resolvePath(['not+exists']), - resolvePath(['not+exists.svg']), - ], - }); - - expect(inputPaths).toStrictEqual([]); -}); - -test('Files from subdirectories are processed', async () => { - const inputPaths = await generateInputPaths(); - - expect(inputPaths).toEqual( - expect.arrayContaining([ - expect.stringMatching(/file-in-subdirectory.jpg$/), - ]), - ); -}); - -test('Files are filtered by extension', async () => { - const inputPaths = await generateInputPaths({ extensions: ['gif', 'jpeg', 'png', 'svg'] }); - - expect(inputPaths).toEqual( - expect.arrayContaining([ - expect.stringMatching(/\.gif$/), - expect.stringMatching(/\.png$/), - expect.stringMatching(/\.svg$/), - ]), - ); - - expect(inputPaths).not.toEqual( - expect.arrayContaining([ - expect.stringMatching(/\.jpg$/), - ]), - ); -}); - -test('Only relative file paths are generated', async () => { - const inputPaths = await generateInputPaths(); - const absolutePathPattern = new RegExp(`^${dirname}`); - - expect(inputPaths).not.toEqual( - expect.arrayContaining([ - expect.stringMatching(absolutePathPattern), - ]), - ); -}); - -function resolvePath(segments) { - return path.resolve(dirname, ...segments); -} - -async function generateInputPaths({ inputPaths = [DEFAULT_IMAGE_PATH], extensions = DEFAULT_EXTENSIONS } = {}) { - const result = await prepareFilePaths({ inputPaths, extensions }); - return result.map(item => getRelativePath(item.input)); -} diff --git a/tests/prepare-output-path.test.js b/tests/prepare-output-path.test.js deleted file mode 100644 index 273b14a..0000000 --- a/tests/prepare-output-path.test.js +++ /dev/null @@ -1,42 +0,0 @@ -import { expect, test, vi } from 'vitest'; - -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { prepareOutputDirectoryPath } from '../lib/prepare-output-directory-path.js'; - -const dirname = path.dirname(fileURLToPath(import.meta.url)); - -test('Exit if the path does not exist', async () => { - const processExitMock = vi.spyOn(process, 'exit').mockImplementation((exitCode) => { - throw new Error(`Process exit with status code: ${exitCode}`); - }); - - const consoleSpy = vi.spyOn(console, 'log'); - - await expect(() => prepareOutputDirectoryPath('not+exists')).rejects.toThrow(); - expect(processExitMock).toHaveBeenCalledWith(1); - expect(consoleSpy.mock.calls[0][1]).toBe('Output path does not exist'); - - consoleSpy.mockRestore(); - processExitMock.mockRestore(); -}); - -test('Exit if specified path to file instead of directory', async () => { - const processExitMock = vi.spyOn(process, 'exit').mockImplementation((exitCode) => { - throw new Error(`Process exit with status code: ${exitCode}`); - }); - - const consoleSpy = vi.spyOn(console, 'log'); - - await expect(() => prepareOutputDirectoryPath(path.resolve(dirname, 'images', 'svg-not-optimized.svg'))).rejects.toThrow(); - expect(processExitMock).toHaveBeenCalledWith(1); - expect(consoleSpy.mock.calls[0][1]).toBe('Output path must be a directory'); - - consoleSpy.mockRestore(); - processExitMock.mockRestore(); -}); - -test('Full path is generated', async () => { - expect(await prepareOutputDirectoryPath('tests/images')).toBe(path.resolve(dirname, 'images')); -}); diff --git a/tests/process-contract.test.js b/tests/process-contract.test.js new file mode 100644 index 0000000..dd59c8e --- /dev/null +++ b/tests/process-contract.test.js @@ -0,0 +1,276 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { afterEach, describe, expect, test } from 'vitest'; + +import { + copyFixture, + fileSize, + makeTemporaryDirectory, + removeTemporaryDirectories, + runCli, + summaryLine, +} from './helpers/cli.js'; + +afterEach(removeTemporaryDirectories); + +describe('process contract', () => { + test('bare invocation prints help to stdout and succeeds', async () => { + const result = await runCli([]); + + expect(result.code).toBe(0); + expect(result.stdout).toContain('Usage:'); + expect(result.stderr).toBe(''); + }); + + test('help takes precedence over unrelated invalid arguments', async () => { + const result = await runCli(['--help', '--unknown', '/missing']); + + expect(result.code).toBe(0); + expect(result.stdout).toContain('Optimizes in place by default'); + expect(result.stderr).toBe(''); + }); + + test('the version is printed to stdout', async () => { + const result = await runCli(['--version']); + + expect(result.code).toBe(0); + expect(result.stdout).toMatch(/^\d+\.\d+\.\d+\n?$/); + expect(result.stderr).toBe(''); + }); + + test('an unknown option fails without stdout output', async () => { + const result = await runCli(['--nonsense', 'image.png']); + + expect(result.code).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('unknown option \'--nonsense\''); + }); + + test('missing explicit operand fails without stdout output', async () => { + const result = await runCli(['/definitely/missing/image.png']); + + expect(result.code).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('does not exist'); + }); + + test('successful processing uses stderr and leaves stdout empty', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png', 'image with spaces.png'); + + const result = await runCli([imagePath]); + + expect(result.code).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('Optimizing 1 image'); + expect(result.stderr).not.toContain('\u{1B}['); + }); + + test('an operand spelled with shell metacharacters is passed through as written', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png', 'name; $(echo x) & \'quoted\'.png'); + const sizeBefore = await fileSize(imagePath); + + const result = await runCli([imagePath]); + + expect(result.code).toBe(0); + expect(result.stderr).toContain(summaryLine('1 processed')); + await expect(fileSize(imagePath)).resolves.toBeLessThan(sizeBefore); + }); + + test('a leading-hyphen operand after -- is treated as a path', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png', '-leading-hyphen.png'); + const sizeBefore = await fileSize(imagePath); + + const result = await runCli(['--', imagePath]); + + expect(result.code).toBe(0); + await expect(fileSize(imagePath)).resolves.toBeLessThan(sizeBefore); + }); + + test('redirected output contains no progress animation', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png'); + + const result = await runCli(['--avif', '--webp', imagePath]); + + expect(result.code).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain(summaryLine('2 processed')); + expect(result.stderr).not.toMatch(/Processed \d+ of \d+/); + expect(result.stderr).not.toMatch(/[░▒█]/); + expect(result.stderr).not.toContain('\u{1B}['); + }); + + test('a dumb terminal gets ASCII diagnostics without progress or color', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png'); + + const result = await runCli([imagePath], { environment: { TERM: 'dumb' } }); + + expect(result.code).toBe(0); + expect(result.stderr).toContain('i Optimizing 1 image'); + expect(result.stderr).not.toMatch(/Processed \d+ of \d+/); + expect(result.stderr).not.toMatch(/[░▒█]/); + expect(result.stderr).not.toContain('\u{1B}['); + }); + + test('a corrupt image makes the invocation fail while independent work succeeds', async () => { + const directory = await makeTemporaryDirectory(); + const validPath = await copyFixture(directory, 'png-not-optimized.png', 'valid.png'); + const corruptPath = path.join(directory, 'corrupt.png'); + await fs.writeFile(corruptPath, 'not an image'); + const validBefore = await fileSize(validPath); + + const result = await runCli([corruptPath, validPath]); + + expect(result.code).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('1 failed'); + expect(result.stderr).not.toContain('Done!'); + await expect(fileSize(validPath)).resolves.toBeLessThan(validBefore); + }); + + test('an empty directory is a successful no-op', async () => { + const directory = await makeTemporaryDirectory(); + const result = await runCli([directory]); + + expect(result.code).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('No eligible images found'); + }); + + test('force is rejected outside conversion mode', async () => { + const result = await runCli(['--force', '/unused']); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('--force requires --avif or --webp'); + }); +}); + +describe('configuration', () => { + test('a missing explicit configuration is reported precisely', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png'); + const configPath = path.join(directory, 'missing.cjs'); + + const result = await runCli(['--config', configPath, imagePath]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain(`Config file does not exist: ${configPath}`); + expect(result.stderr).not.toContain('Configuration file is invalid'); + }); + + test('a non-file explicit configuration is reported precisely', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png'); + + const result = await runCli(['--config', directory, imagePath]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain(`Config path does not refer to a file: ${directory}`); + expect(result.stderr).not.toContain('Configuration file is invalid'); + }); + + test('a configuration that fails to load names its path and reason', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png'); + const configPath = path.join(directory, 'throws.cjs'); + await fs.writeFile(configPath, 'throw new Error("broken configuration");\n'); + + const result = await runCli(['--config', configPath, imagePath]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain(`Could not load configuration ${configPath}`); + expect(result.stderr).toContain('broken configuration'); + expect(result.stderr).not.toContain(' at '); + }); + + test.each([ + ['null', 'module.exports = { optimize: null };\n'], + ['an array', 'module.exports = { optimize: [] };\n'], + ['absent', 'module.exports = { convert: {} };\n'], + ])('a selected section that is %s is rejected', async (_name, source) => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png'); + const configPath = path.join(directory, 'config.cjs'); + await fs.writeFile(configPath, source); + + const result = await runCli(['--config', configPath, imagePath]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain(`Configuration ${configPath} must define an object-valued "optimize" section`); + }); + + test('a custom configuration replaces the bundled defaults for the selected mode', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png'); + const configPath = path.join(directory, 'config.cjs'); + await fs.writeFile(configPath, 'module.exports = { optimize: { png: { lossy: { colors: 4, palette: true } } } };\n'); + const bundled = await runCli([await copyFixture(directory, 'png-not-optimized.png', 'bundled.png')]); + const bundledSize = await fileSize(path.join(directory, 'bundled.png')); + + const result = await runCli(['--config', configPath, imagePath]); + + expect(bundled.code).toBe(0); + expect(result.code).toBe(0); + await expect(fileSize(imagePath)).resolves.toBeLessThan(bundledSize); + }); + + test('an invalid codec option names the mode, format, and configuration behind it', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png'); + const configPath = path.join(directory, 'config.cjs'); + await fs.writeFile(configPath, 'module.exports = { optimize: { png: { lossy: { compressionLevel: 42 } } } };\n'); + + const result = await runCli(['--config', configPath, imagePath]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain(summaryLine('1 failed')); + expect(result.stderr).toContain(`optimize png (lossy) using ${configPath}:`); + // The codec keeps ownership of the option schema, so its own reason survives. + expect(result.stderr).toContain('compressionLevel'); + expect(result.stderr).not.toContain(' at '); + }); + + test('an invalid conversion option names the target format', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png'); + const configPath = path.join(directory, 'config.cjs'); + await fs.writeFile(configPath, 'module.exports = { convert: { avif: { lossless: { quality: 500 } } } };\n'); + + const result = await runCli(['--lossless', '--config', configPath, '--avif', imagePath]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain(`convert avif (lossless) using ${configPath}:`); + expect(result.stderr).toContain('quality'); + }); + + test('a failure that is not a codec option keeps its own words', async () => { + const directory = await makeTemporaryDirectory(); + const notAnImage = path.join(directory, 'broken.png'); + await fs.writeFile(notAnImage, 'not an image'); + + const result = await runCli([notAnImage]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('Unknown file format'); + expect(result.stderr).not.toContain('using '); + }); + + test('debug mode adds a stack trace and version details', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'png-not-optimized.png'); + const configPath = path.join(directory, 'throws.cjs'); + await fs.writeFile(configPath, 'throw new Error("broken configuration");\n'); + + const result = await runCli(['--debug', '--config', configPath, imagePath]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain(' at '); + expect(result.stderr).toContain(`Node.js ${process.version}`); + expect(result.stderr).not.toContain('PATH'); + }); +}); diff --git a/tests/show-total.test.js b/tests/show-total.test.js index bb7bd88..8546882 100644 --- a/tests/show-total.test.js +++ b/tests/show-total.test.js @@ -1,25 +1,66 @@ -import { expect, test, vi } from 'vitest'; +import { beforeEach, expect, test, vi } from 'vitest'; +import { OUTCOME_STATUS } from '../lib/outcome-status.js'; import { showTotal } from '../lib/show-total.js'; -test('Savings size and compression ratio are displayed', () => { - const fileSize = 1_048_576; +vi.mock('../lib/log.js', () => ({ + log: vi.fn(), + logEmptyLine: vi.fn(), +})); - const consoleSpy = vi.spyOn(console, 'log'); +import { log } from '../lib/log.js'; - showTotal(fileSize, fileSize / 2); - expect(consoleSpy.mock.calls[1][1]).toBe('Yay! You saved 512 KB (50%)'); +beforeEach(() => { + log.mockClear(); +}); + +test('optimization summary reports savings for written operations', () => { + showTotal(100, 60, [{ status: OUTCOME_STATUS.PROCESSED }, { status: OUTCOME_STATUS.SKIPPED }]); + expect(log).toHaveBeenCalledWith('1 processed, 1 skipped'); + expect(log).toHaveBeenCalledWith('40 Bytes saved (40%)'); +}); + +test('summary omits size when no operation was processed', () => { + showTotal(0, 0, [{ status: OUTCOME_STATUS.FAILED }]); + expect(log).toHaveBeenCalledWith('1 failed'); +}); - consoleSpy.mockRestore(); +test('summary names only the outcomes that occurred', () => { + showTotal(100, 60, [ + { after: 60, before: 100, status: OUTCOME_STATUS.PROCESSED }, + { status: OUTCOME_STATUS.PROCESSED }, + ]); + expect(log).toHaveBeenCalledWith('2 processed'); }); -test('Savings size and compression ratio are not displayed', () => { - const fileSize = 1_048_576; +test('summary reports operations left unstarted by interruption', () => { + showTotal(0, 0, [ + { status: OUTCOME_STATUS.UNSTARTED }, + { status: OUTCOME_STATUS.UNSTARTED }, + ]); + expect(log).toHaveBeenCalledWith('2 not started'); +}); - const consoleSpy = vi.spyOn(console, 'log'); +test('summary keeps work left undone by interruption out of failures', () => { + showTotal(100, 60, [ + { after: 60, before: 100, status: OUTCOME_STATUS.PROCESSED }, + { status: OUTCOME_STATUS.UNSTARTED }, + { status: OUTCOME_STATUS.UNSTARTED }, + ]); + expect(log).toHaveBeenCalledWith('1 processed, 2 not started'); + expect(log).not.toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ type: 'error' })); +}); - showTotal(fileSize, fileSize * 2); - expect(consoleSpy.mock.calls[1][1]).toBe('Done!'); +test('failure details are reported once in deterministic plan order', () => { + showTotal(0, 0, [ + { error: new Error('second'), output: '/tmp/second.png', planIndex: 1, status: OUTCOME_STATUS.FAILED }, + { error: new Error('first'), output: '/tmp/first.png', planIndex: 0, status: OUTCOME_STATUS.FAILED }, + ]); + expect(log).toHaveBeenNthCalledWith(2, '/tmp/first.png', { description: 'first', type: 'error' }); + expect(log).toHaveBeenNthCalledWith(3, '/tmp/second.png', { description: 'second', type: 'error' }); +}); - consoleSpy.mockRestore(); +test('conversion summary reports created bytes', () => { + showTotal(100, 60, [{ status: OUTCOME_STATUS.PROCESSED }], { conversion: true }); + expect(log).toHaveBeenCalledWith('60 Bytes created'); }); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..c8415e8 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // Every test of this project lives in tests/. Scoping the run keeps other checkouts + // of the repository, which tooling may mount anywhere below the root, out of it. + include: ['tests/**/*.test.js'], + // Image processing is real work: Guetzli alone needs several seconds per image. + testTimeout: 60_000, + }, +}); From a319dd6af35b9b90a1f05f7f74919aafc72a5676 Mon Sep 17 00:00:00 2001 From: 343dev <343dev@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:33:16 +0700 Subject: [PATCH 3/8] fix(cli): clarify help and error messages --- cli.js | 4 ++-- convert.js | 8 ++++---- index.js | 4 ++-- lib/describe-codec-failure.js | 5 ++++- lib/find-config-file-path.js | 8 ++++---- lib/prepare-operation-plan.js | 24 ++++++++++++------------ optimize.js | 8 ++++---- package.json | 2 +- tests/filesystem-safety.test.js | 4 ++-- tests/formats.test.js | 2 +- tests/operation-planning.test.js | 24 ++++++++++++------------ tests/process-contract.test.js | 16 ++++++++-------- 12 files changed, 56 insertions(+), 53 deletions(-) diff --git a/cli.js b/cli.js index 8236102..5df9149 100755 --- a/cli.js +++ b/cli.js @@ -21,7 +21,7 @@ program .option('-f, --force', 'replace existing conversion targets') .option('-l, --lossless', 'use the lossless processing profile') .option('-v, --verbose', 'show skipped and deduplicated work') - .option('-c, --config ', 'replace defaults with this executable CJS configuration') + .option('-c, --config ', 'use this CJS configuration file instead of the defaults') .option('-o, --output ', 'write under an existing output directory') .option('-p, --prefix ', 'add a prefix to output file names') .option('-s, --suffix ', 'add a suffix to output file names') @@ -30,7 +30,7 @@ program .allowExcessArguments() .usage('[options] [--] ') .version(packageJson.version, '-V, --version') - .description(`${packageJson.description}. Optimizes in place by default; --avif and --webp create variants.`) + .description(`${packageJson.description}.`) .exitOverride(); let exitCode = 0; diff --git a/convert.js b/convert.js index 1f57621..074be35 100644 --- a/convert.js +++ b/convert.js @@ -32,7 +32,7 @@ export async function convert({ operations, config, configPath }) { return; } - log(`Converting ${filePathsCount} ${getPlural(filePathsCount, 'image', 'images')} (${isLossless ? 'lossless' : 'lossy'})...`); + log(`Converting ${filePathsCount} ${getPlural(filePathsCount, 'image', 'images')} (${isLossless ? 'lossless' : 'lossy'})`); const progressBarTotal = operations.length; const progressBarContainer = createProgressBarContainer(progressBarTotal); @@ -91,7 +91,7 @@ async function processFile({ try { if (skipReason) { logProgressVerbose(getRelativePath(outputFilePath), { - description: `File already exists, '${outputFilePath}'`, + description: `Output file already exists: ${outputFilePath}. Use --force to replace it.`, progressBarContainer, }); @@ -135,7 +135,7 @@ async function processAvif({ fileBuffer, config, configPath, isLossless }) { const isAnimated = imageMetadata.pages > 1; if (isAnimated) { - throw new Error('Animated AVIF is not supported'); // See: https://github.com/strukturag/libheif/issues/377 + throw new Error('Unable to create an animated AVIF. Use WebP or provide a non-animated image.'); // See: https://github.com/strukturag/libheif/issues/377 } // Only the codec call is enriched, so detection and support errors keep speaking for themselves. @@ -167,7 +167,7 @@ async function processWebp({ fileBuffer, config, configPath, isLossless }) { function checkImageFormat(imageFormat) { if (!imageFormat) { - throw new Error('Unknown file format'); + throw new Error('Unable to read the image format. Check that the file is a valid, supported image.'); } if (!SUPPORTED_FILE_TYPES.CONVERT.includes(imageFormat)) { diff --git a/index.js b/index.js index d00fd50..9137c3c 100755 --- a/index.js +++ b/index.js @@ -35,11 +35,11 @@ export default async function optimizt({ inputPaths, outputDirectoryPath, config try { configData = await import(`${pathToFileURL(configPath).href}?loaded=${Date.now()}`); } catch (error) { - throw new Error(`Could not load configuration ${configPath}: ${error.message}`, { cause: error }); + throw new Error(`Unable to load configuration file ${configPath}: ${error.message}`, { cause: error }); } const config = configData.default?.[currentMode]; if (!config || typeof config !== 'object' || Array.isArray(config)) { - throw new Error(`Configuration ${configPath} must define an object-valued "${currentMode}" section`); + throw new Error(`Unable to use configuration file ${configPath}. Define "${currentMode}" as an object.`); } if (isLossless) log('Lossless mode may take a long time; JPEG uses Guetzli and is not strictly lossless'); diff --git a/lib/describe-codec-failure.js b/lib/describe-codec-failure.js index 0814f6f..fd8a51d 100644 --- a/lib/describe-codec-failure.js +++ b/lib/describe-codec-failure.js @@ -3,5 +3,8 @@ // attempt and keeps the library's own reason intact. export function describeCodecFailure({ error, mode, format, isLossless, configPath }) { const profile = isLossless ? 'lossless' : 'lossy'; - return new Error(`${mode} ${format} (${profile}) using ${configPath}: ${error.message}`, { cause: error }); + const action = mode === 'convert' + ? `convert the image to ${format.toUpperCase()}` + : `optimize the ${format.toUpperCase()} image`; + return new Error(`Unable to ${action} with the ${profile} profile and configuration file ${configPath}: ${error.message}`, { cause: error }); } diff --git a/lib/find-config-file-path.js b/lib/find-config-file-path.js index ebca383..03aaaea 100644 --- a/lib/find-config-file-path.js +++ b/lib/find-config-file-path.js @@ -14,10 +14,10 @@ export async function findConfigFilePath(providedConfigPath) { try { stat = await fs.stat(resolvedPath); } catch (error) { - if (error.code === 'ENOENT') throw new Error(`Config file does not exist: ${resolvedPath}`, { cause: error }); - throw new Error(`Cannot inspect configuration file ${resolvedPath}: ${error.message}`, { cause: error }); + if (error.code === 'ENOENT') throw new Error(`Configuration file does not exist: ${resolvedPath}. Provide the path to an existing CJS file.`, { cause: error }); + throw new Error(`Unable to inspect configuration file ${resolvedPath}: ${error.message}`, { cause: error }); } - if (!stat.isFile()) throw new Error(`Config path does not refer to a file: ${resolvedPath}`); + if (!stat.isFile()) throw new Error(`Unable to use ${resolvedPath} as a configuration file. Provide the path to a CJS file.`); return resolvedPath; } @@ -28,7 +28,7 @@ export async function findConfigFilePath(providedConfigPath) { const stat = await fs.stat(currentConfigPath); if (stat.isFile()) return currentConfigPath; } catch (error) { - if (error.code !== 'ENOENT') throw new Error(`Cannot inspect configuration ${currentConfigPath}: ${error.message}`, { cause: error }); + if (error.code !== 'ENOENT') throw new Error(`Unable to inspect configuration file ${currentConfigPath}: ${error.message}`, { cause: error }); } const parentDirectoryPath = path.dirname(currentDirectoryPath); if (parentDirectoryPath === currentDirectoryPath) break; diff --git a/lib/prepare-operation-plan.js b/lib/prepare-operation-plan.js index 38304c3..8ec102a 100644 --- a/lib/prepare-operation-plan.js +++ b/lib/prepare-operation-plan.js @@ -24,8 +24,8 @@ export async function prepareOperationPlan({ let outputRoot; if (outputDirectoryPath) { const requestedOutputRoot = path.resolve(outputDirectoryPath); - const outputStat = await statExplicitPath(requestedOutputRoot, 'Output path'); - if (!outputStat.isDirectory()) throw new Error(`Output path is not a directory: ${requestedOutputRoot}`); + const outputStat = await statExplicitPath(requestedOutputRoot, 'Output directory'); + if (!outputStat.isDirectory()) throw new Error(`Unable to use output directory ${requestedOutputRoot}. The path is not a directory.`); await fs.access(requestedOutputRoot, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK); outputRoot = await fs.realpath(requestedOutputRoot); } @@ -155,8 +155,8 @@ async function validateOutputs(operations, { force, outputRoot }) { targetStat = await fs.stat(identity); } } else targetStat = lstat; - if (!operation.skipReason && !targetStat?.isFile()) throw new Error(`Output target is not a regular file: ${operation.output}`); - if (!operation.skipReason && targetStat.nlink > 1) throw new Error(`Refusing to replace multiply hard-linked file: ${operation.output}`); + if (!operation.skipReason && !targetStat?.isFile()) throw new Error(`Unable to replace output file ${operation.output}. Remove it or choose a different --output directory because it is not a regular file.`); + if (!operation.skipReason && targetStat.nlink > 1) throw new Error(`Unable to replace output file ${operation.output}. Remove its additional hard links or choose a different --output directory.`); if (operation.format !== 'optimize' && !force) operation.skipReason = 'File already exists'; } catch (error) { // The target cannot exist yet, either because nothing is there or because an @@ -169,7 +169,7 @@ async function validateOutputs(operations, { force, outputRoot }) { } const key = pathKey(identity); const previous = outputs.get(key); - if (previous) throw new Error(`Output collision: ${previous.output} and ${operation.output}`); + if (previous) throw new Error(`Multiple inputs produce the same output file: ${previous.output} and ${operation.output}. Rename an input or use a different --prefix, --suffix, or --output directory.`); outputs.set(key, operation); } } @@ -180,7 +180,7 @@ async function canonicalizeThroughExistingAncestor(output) { while (true) { try { const stat = await fs.stat(current); - if (!stat.isDirectory()) throw new Error(`Output parent is not a directory: ${current}`); + if (!stat.isDirectory()) throw new Error(`Unable to create an output file under ${current}. Remove the conflicting file or choose a different --output directory.`); await fs.access(current, fs.constants.W_OK | fs.constants.X_OK); const realAncestor = await fs.realpath(current); return path.join(realAncestor, ...missingSegments); @@ -202,8 +202,8 @@ function reportDuplicate(dropped, kept) { const keptPath = getRelativePath(kept.givenPath ?? kept.operandPath); logProgressVerbose(droppedPath, { description: droppedPath === keptPath - ? 'Already planned once. Not planned again' - : `Duplicate of '${keptPath}'. Not planned again`, + ? 'Already included. Skipped duplicate.' + : `Duplicate of "${keptPath}". Skipped.`, }); } @@ -212,7 +212,7 @@ function validateDirectoryOverlap(directories) { for (let other = index + 1; other < directories.length; other += 1) { const left = directories[index].realPath; const right = directories[other].realPath; - if (isContained(left, right) || isContained(right, left)) throw new Error('Overlapping directory operands cannot be used with --output'); + if (isContained(left, right) || isContained(right, left)) throw new Error('Unable to use overlapping input directories with --output. Remove the nested input or run the directories separately.'); } } } @@ -225,12 +225,12 @@ function validateGeneratedBasename(basename) { const stem = basename.replace(/\..*$/, ''); const hasControlCharacter = [...basename].some(character => character.codePointAt(0) < 32); if (!basename || basename === '.' || basename === '..' || basename.endsWith('.') || basename.endsWith(' ') || hasControlCharacter || INVALID_GENERATED_CHARACTERS.test(basename) || WINDOWS_RESERVED_NAME.test(stem)) { - throw new Error(`Generated filename is not portable: ${basename}`); + throw new Error(`Unable to use generated filename "${basename}". Rename the input or change --prefix and --suffix to create a portable filename.`); } } function assertSupported(filePath, extensions) { - if (!isSupported(filePath, extensions)) throw new Error(`Unsupported explicit file: ${filePath}`); + if (!isSupported(filePath, extensions)) throw new Error(`Unable to process ${filePath}. Choose a file with one of these extensions: ${extensions.map(extension => `.${extension}`).join(', ')}.`); } function isSupported(filePath, extensions) { @@ -238,7 +238,7 @@ function isSupported(filePath, extensions) { } function assertContained(root, target) { - if (!isContained(root, target)) throw new Error(`Output escapes permitted root: ${target}`); + if (!isContained(root, target)) throw new Error(`Unable to write outside the output directory: ${target}. Remove the escaping symbolic link or choose a different --output directory.`); } function isContained(root, target) { diff --git a/optimize.js b/optimize.js index 6b80aee..62cf1b4 100644 --- a/optimize.js +++ b/optimize.js @@ -39,7 +39,7 @@ export async function optimize({ operations, config, configPath }) { return; } - log(`Optimizing ${filePathsCount} ${getPlural(filePathsCount, 'image', 'images')} (${isLossless ? 'lossless' : 'lossy'})...`); + log(`Optimizing ${filePathsCount} ${getPlural(filePathsCount, 'image', 'images')} (${isLossless ? 'lossless' : 'lossy'})`); const progressBarContainer = createProgressBarContainer(filePathsCount); const progressBar = progressBarContainer.create(filePathsCount, 0); @@ -141,7 +141,7 @@ async function processFileByFormat({ fileBuffer, config, configPath, isLossless const format = imageMetadata.format; if (!format) { - throw new Error('Unknown file format'); + throw new Error('Unable to read the image format. Check that the file is a valid, supported image.'); } const processByFormat = PROCESS_BY_FORMAT.get(format); @@ -242,12 +242,12 @@ function pipe({ command, commandOptions, inputBuffer }) { }); process.on('error', (error) => { - reject(new Error(`Error processing image: ${error.message}`)); + reject(new Error(`Unable to optimize the image: ${error.message}`)); }); process.on('close', (code) => { if (code !== 0) { - reject(new Error(`Image optimization process exited with code ${code}`)); + reject(new Error(`Unable to optimize the image. The encoder exited with code ${code}.`)); return; } diff --git a/package.json b/package.json index 2c5d99b..436aa1f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@343dev/optimizt", "version": "13.0.0", - "description": "CLI image optimization tool", + "description": "Optimize images in place or create AVIF and WebP variants", "keywords": [ "svg", "png", diff --git a/tests/filesystem-safety.test.js b/tests/filesystem-safety.test.js index 3fb041b..bf92ad5 100644 --- a/tests/filesystem-safety.test.js +++ b/tests/filesystem-safety.test.js @@ -97,7 +97,7 @@ describe('atomic replacement', () => { const result = await runCli([imagePath]); expect(result.code).toBe(1); - expect(result.stderr).toContain(`Refusing to replace multiply hard-linked file: ${imagePath}`); + expect(result.stderr).toContain(`Unable to replace output file ${imagePath}`); const current = await fs.readFile(imagePath); expect(current.equals(before)).toBe(true); }); @@ -187,7 +187,7 @@ describe.skipIf(isWindows)('symbolic links', () => { const result = await runCli(['--force', '--webp', '--output', output, imagePath]); expect(result.code).toBe(1); - expect(result.stderr).toContain('Output escapes permitted root'); + expect(result.stderr).toContain('Unable to write outside the output directory'); await expect(fs.readFile(escapeTarget, 'utf8')).resolves.toBe('outside the root'); }); }); diff --git a/tests/formats.test.js b/tests/formats.test.js index 9a75953..648ccbd 100644 --- a/tests/formats.test.js +++ b/tests/formats.test.js @@ -109,7 +109,7 @@ describe('conversion by format', () => { expect(result.code).toBe(1); expect(result.stderr).toContain(summaryLine('1 failed')); - expect(result.stderr).toContain('Animated AVIF is not supported'); + expect(result.stderr).toContain('Unable to create an animated AVIF'); await expect(fs.stat(path.join(directory, 'gif-not-optimized.avif'))).rejects.toThrow(); }); }); diff --git a/tests/operation-planning.test.js b/tests/operation-planning.test.js index c0adf05..c82f5f4 100644 --- a/tests/operation-planning.test.js +++ b/tests/operation-planning.test.js @@ -26,7 +26,7 @@ describe('eligibility', () => { const result = await runCli([notAnImage]); expect(result.code).toBe(1); - expect(result.stderr).toContain(`Unsupported explicit file: ${notAnImage}`); + expect(result.stderr).toContain(`Unable to process ${notAnImage}`); }); test('unsupported files discovered inside a directory are ignored', async () => { @@ -48,7 +48,7 @@ describe('eligibility', () => { const result = await runCli(['--webp', svgPath]); expect(result.code).toBe(1); - expect(result.stderr).toContain(`Unsupported explicit file: ${svgPath}`); + expect(result.stderr).toContain(`Unable to process ${svgPath}`); }); test.skipIf(isWindows || isPrivileged)('an incomplete directory traversal fails before any image changes', async () => { @@ -85,7 +85,7 @@ describe('operand normalization', () => { expect(result.code).toBe(0); expect(result.stderr).toContain('Optimizing 1 image'); expect(result.stderr).toContain(summaryLine('1 processed')); - expect(result.stderr).toContain(`Duplicate of '${imagePath}'`); + expect(result.stderr).toContain(`Duplicate of "${imagePath}". Skipped.`); }); test('deduplication stays quiet without verbose output', async () => { @@ -111,7 +111,7 @@ describe('operand normalization', () => { expect(result.code).toBe(0); expect(result.stderr).toContain('Optimizing 2 images'); expect(result.stderr).toContain(summaryLine('2 processed')); - expect(result.stderr).toContain('Already planned once'); + expect(result.stderr).toContain('Already included. Skipped duplicate.'); }); test('an output root accepts the same directory spelled two ways', async () => { @@ -165,7 +165,7 @@ describe('output mapping', () => { const result = await runCli(['--output', output, imagePath]); expect(result.code).toBe(1); - expect(result.stderr).toContain(`Output path does not exist or is inaccessible: ${output}`); + expect(result.stderr).toContain(`Output directory does not exist or is inaccessible: ${output}`); expect(await exists(output)).toBe(false); }); @@ -179,7 +179,7 @@ describe('output mapping', () => { const result = await runCli(['--webp', '--output', output, imagePath]); expect(result.code).toBe(1); - expect(result.stderr).toContain(`Output target is not a regular file: ${blockedTarget}`); + expect(result.stderr).toContain(`Unable to replace output file ${blockedTarget}`); await expect(fs.readdir(blockedTarget)).resolves.toEqual([]); }); @@ -193,7 +193,7 @@ describe('output mapping', () => { const result = await runCli(['--output', output, path.join(directory, 'input')]); expect(result.code).toBe(1); - expect(result.stderr).toContain('Output parent is not a directory'); + expect(result.stderr).toContain('Remove the conflicting file or choose a different --output directory.'); await expect(fs.readFile(path.join(output, 'nested'), 'utf8')).resolves.toBe('occupied by a file'); await expect(fileSize(imagePath)).resolves.toBeGreaterThan(0); }); @@ -207,7 +207,7 @@ describe('output mapping', () => { const result = await runCli(['--output', outputPath, imagePath]); expect(result.code).toBe(1); - expect(result.stderr).toContain(`Output path is not a directory: ${outputPath}`); + expect(result.stderr).toContain(`Unable to use output directory ${outputPath}`); }); }); @@ -222,7 +222,7 @@ describe('collisions', () => { const result = await runCli(['--output', output, path.join(directory, 'first'), path.join(directory, 'second')]); expect(result.code).toBe(1); - expect(result.stderr).toContain('Output collision'); + expect(result.stderr).toContain('Multiple inputs produce the same output file'); expect(await fs.readdir(output)).toEqual([]); }); @@ -241,7 +241,7 @@ describe('collisions', () => { const result = await runCli(arguments_); expect(result.code).toBe(1); - expect(result.stderr).toContain('Output collision'); + expect(result.stderr).toContain('Multiple inputs produce the same output file'); await expect(fs.readdir(output)).resolves.toEqual([]); }); @@ -255,7 +255,7 @@ describe('collisions', () => { const result = await runCli(['--output', output, input, path.join(input, 'nested')]); expect(result.code).toBe(1); - expect(result.stderr).toContain('Overlapping directory operands cannot be used with --output'); + expect(result.stderr).toContain('Unable to use overlapping input directories with --output'); expect(await fs.readdir(output)).toEqual([]); }); }); @@ -270,7 +270,7 @@ describe('generated names', () => { const result = await runCli(['--prefix', '', '--output', output, imagePath]); expect(result.code).toBe(1); - expect(result.stderr).toContain('not portable'); + expect(result.stderr).toContain('create a portable filename'); expect(await fs.readdir(output)).toEqual([]); }); diff --git a/tests/process-contract.test.js b/tests/process-contract.test.js index dd59c8e..4a49def 100644 --- a/tests/process-contract.test.js +++ b/tests/process-contract.test.js @@ -27,7 +27,7 @@ describe('process contract', () => { const result = await runCli(['--help', '--unknown', '/missing']); expect(result.code).toBe(0); - expect(result.stdout).toContain('Optimizes in place by default'); + expect(result.stdout).toContain('Optimize images in place or create AVIF and WebP variants.'); expect(result.stderr).toBe(''); }); @@ -159,7 +159,7 @@ describe('configuration', () => { const result = await runCli(['--config', configPath, imagePath]); expect(result.code).toBe(1); - expect(result.stderr).toContain(`Config file does not exist: ${configPath}`); + expect(result.stderr).toContain(`Configuration file does not exist: ${configPath}`); expect(result.stderr).not.toContain('Configuration file is invalid'); }); @@ -170,7 +170,7 @@ describe('configuration', () => { const result = await runCli(['--config', directory, imagePath]); expect(result.code).toBe(1); - expect(result.stderr).toContain(`Config path does not refer to a file: ${directory}`); + expect(result.stderr).toContain(`Unable to use ${directory} as a configuration file`); expect(result.stderr).not.toContain('Configuration file is invalid'); }); @@ -183,7 +183,7 @@ describe('configuration', () => { const result = await runCli(['--config', configPath, imagePath]); expect(result.code).toBe(1); - expect(result.stderr).toContain(`Could not load configuration ${configPath}`); + expect(result.stderr).toContain(`Unable to load configuration file ${configPath}`); expect(result.stderr).toContain('broken configuration'); expect(result.stderr).not.toContain(' at '); }); @@ -201,7 +201,7 @@ describe('configuration', () => { const result = await runCli(['--config', configPath, imagePath]); expect(result.code).toBe(1); - expect(result.stderr).toContain(`Configuration ${configPath} must define an object-valued "optimize" section`); + expect(result.stderr).toContain(`Unable to use configuration file ${configPath}. Define "optimize" as an object.`); }); test('a custom configuration replaces the bundled defaults for the selected mode', async () => { @@ -229,7 +229,7 @@ describe('configuration', () => { expect(result.code).toBe(1); expect(result.stderr).toContain(summaryLine('1 failed')); - expect(result.stderr).toContain(`optimize png (lossy) using ${configPath}:`); + expect(result.stderr).toContain(`Unable to optimize the PNG image with the lossy profile and configuration file ${configPath}:`); // The codec keeps ownership of the option schema, so its own reason survives. expect(result.stderr).toContain('compressionLevel'); expect(result.stderr).not.toContain(' at '); @@ -244,7 +244,7 @@ describe('configuration', () => { const result = await runCli(['--lossless', '--config', configPath, '--avif', imagePath]); expect(result.code).toBe(1); - expect(result.stderr).toContain(`convert avif (lossless) using ${configPath}:`); + expect(result.stderr).toContain(`Unable to convert the image to AVIF with the lossless profile and configuration file ${configPath}:`); expect(result.stderr).toContain('quality'); }); @@ -256,7 +256,7 @@ describe('configuration', () => { const result = await runCli([notAnImage]); expect(result.code).toBe(1); - expect(result.stderr).toContain('Unknown file format'); + expect(result.stderr).toContain('Unable to read the image format'); expect(result.stderr).not.toContain('using '); }); From 946eb6d21bcc626f1c3b7ea8947f48ef7cbd7b19 Mon Sep 17 00:00:00 2001 From: 343dev <343dev@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:33:18 +0700 Subject: [PATCH 4/8] docs(readme): clarify usage and adopt a personal voice --- README.md | 95 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 50 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index fab2f9b..5cc57a6 100644 --- a/README.md +++ b/README.md @@ -10,47 +10,49 @@ **Optimizt** is a command-line tool that helps prepare images for the web. -It can compress PNG, JPEG, GIF, and SVG lossy or lossless, and create AVIF and WebP versions for raster images. +It compresses PNG, JPEG, GIF, and SVG files and creates AVIF and WebP versions of raster images. -## Rationale +## Why Optimizt? -As frontend developers, we have to care about pictures: compress PNG and JPEG, remove useless parts of SVG, create AVIF and WebP for modern browsers, and so on. One day, we got tired of using a bunch of apps for that, and created one tool that does everything we want. +I built Optimizt because preparing images for the web meant switching between several tools. I wanted one command to compress images, clean up SVGs, and create AVIF and WebP versions. ## Usage -Install: +Install Optimizt: ```sh npm install -g @343dev/optimizt ``` -Optimize! +Optimize an image in place: ```sh optimizt path/to/picture.jpg ``` -## Command Line Flags +This command can replace the original file. To keep it, use `--output` with a separate, existing directory. + +## Command-line options - `--avif` — create AVIF versions of images. - `--webp` — create WebP versions of images. -- `-f, --force` — recreate AVIF and WebP versions even if they already exist. -- `-l, --lossless` — optimize losslessly instead of lossily. -- `-v, --verbose` — show detailed output (e.g. skipped files). -- `-c, --config` — use a custom configuration file instead of the default. -- `-o, --output` — write results to the specified directory. -- `-p, --prefix` — add prefix to optimized file names. -- `-s, --suffix` — add suffix to optimized file names. -- `-V, --version` — display the tool version. -- `-h, --help` — show help message. - -## Usage Examples +- `-f, --force` — replace existing AVIF and WebP versions. +- `-l, --lossless` — use the lossless profile; JPEG compression is still lossy. +- `-v, --verbose` — show detailed output, including skipped files. +- `-c, --config ` — use a custom configuration file instead of the bundled defaults. +- `-o, --output ` — write results to an existing directory. +- `-p, --prefix ` — add a prefix to output file names. +- `-s, --suffix ` — add a suffix to output file names. +- `-V, --version` — show the version. +- `-h, --help` — show help. + +## Examples ```bash # optimize a single image optimizt path/to/picture.jpg -# optimize multiple images losslessly +# optimize multiple images with the lossless profile optimizt --lossless path/to/picture.jpg path/to/another/picture.png # recursively create AVIF and WebP versions for all images in a directory @@ -60,54 +62,57 @@ optimizt --avif --webp path/to/directory find . -iname \*.jpg -exec optimizt {} + ``` -## Differences Between Lossy and Lossless +## Lossy and lossless modes + +### Lossy (default) -### Lossy (Default) +Uses lossy compression to reduce file size, with some loss of image quality. SVG settings are the same in both modes. -Provides the best balance between file size reduction and minimal visual quality loss. +### Lossless (`--lossless`) -### Lossless (`--lossless` flag) +With the bundled settings: -- **AVIF/WebP**: Uses lossless compression. -- **PNG/JPEG/GIF**: Maximizes image quality at the expense of larger file sizes. -- **SVG**: Settings are identical in both modes. +- **AVIF/WebP/PNG/GIF**: Uses lossless compression. +- **JPEG**: Uses [Guetzli](https://github.com/google/guetzli) for higher-quality lossy compression. Despite the profile name, JPEG compression is not lossless, and repeated optimization may reduce quality. +- **SVG**: Uses the same settings as the default mode. -## How Files Are Written +Files may be larger than those produced by the lossy profile. -Every image is written to a temporary file in the destination directory, synchronized to disk, and then renamed over the target. An interrupted or failed run therefore leaves the original file untouched instead of half-written, and a reader never sees a partial image. +## How files are written -Alongside that: +Optimizt writes each result to a temporary file in the destination directory and synchronizes it to disk. It then replaces the destination file in one step, so other programs see either the old file or the complete new file, never a partially written image. -- The existing permission mode of a replaced file is preserved, and its ownership is preserved when the operating system permits it. -- A file with more than one hard link is never replaced, because renaming over it would break the shared inode. Creating a new output from such a source is allowed. -- Optimizing a symbolic link replaces the file it points to and keeps the link itself; converting one writes the new variant next to the link. +Files already replaced are not restored if the run fails or stops. + +- When replacing a file, Optimizt keeps its permissions and, where the operating system allows, its owner. +- Optimizt does not replace files with multiple hard links, because the other links would still point to the old file. Use a separate output path instead. +- When optimizing a symbolic link in place, Optimizt replaces the file it points to and keeps the link itself. When converting an image through a symbolic link, it writes the AVIF or WebP version next to the link unless you choose a different output directory. > [!NOTE] > Atomic replacement protects against partially written files. It does not guarantee that the directory entry itself survives sudden power loss, and it does not preserve file timestamps. ## Configuration -Image processing leverages: +Optimizt uses: - [sharp](https://github.com/lovell/sharp) for [JPEG](https://sharp.pixelplumbing.com/api-output#jpeg), [PNG](https://sharp.pixelplumbing.com/api-output#png), [WebP](https://sharp.pixelplumbing.com/api-output#webp), and [AVIF](https://sharp.pixelplumbing.com/api-output#avif). - [svgo](https://github.com/svg/svgo) for SVG. - [gifsicle](https://github.com/kohler/gifsicle) for GIF. -> [!NOTE] -> In Lossless mode for JPEG, [Guetzli](https://github.com/google/guetzli) is used. Repeated optimization may degrade visual quality. +For JPEG in the lossless profile, Optimizt uses [Guetzli](https://github.com/google/guetzli) instead of sharp for the final compression step. -Default settings are defined in [.optimiztrc.cjs](./.optimiztrc.cjs), which includes all supported parameters. Disable any parameter by setting it to `false`. +See [.optimiztrc.cjs](./.optimiztrc.cjs) for the default settings. For available options and accepted values, check the documentation for each image processor linked above. -When using `--config path/to/.optimiztrc.cjs`, the specified configuration file replaces the bundled settings for the selected mode. If no `--config` is provided, Optimizt searches recursively from the current directory upward for `.optimiztrc.cjs`. If none is found, defaults are applied. +Use `--config path/to/.optimiztrc.cjs` to load your own settings instead of the bundled configuration. Without `--config`, Optimizt looks for `.optimiztrc.cjs` in the current directory, then in each parent directory, and uses the first file it finds. If it finds none, it uses the bundled defaults. > [!WARNING] > `.optimiztrc.cjs` is executable code. Auto-discovered and explicitly selected configuration runs with your user permissions; use Optimizt only in repositories you trust. ## Troubleshooting -### Errors like “spawn guetzli ENOENT”. +### Errors like “spawn guetzli ENOENT” -Ensure the [ignore-scripts](https://docs.npmjs.com/cli/v6/using-npm/config#ignore-scripts) npm option is disabled. +Make sure the [ignore-scripts](https://docs.npmjs.com/cli/v6/using-npm/config#ignore-scripts) npm option is disabled. Details: [funbox/optimizt/issues/9](https://github.com/funbox/optimizt/issues/9). ## Development @@ -118,11 +123,11 @@ After cloning the repository, enable Git hooks once: npm run enable-git-hooks ``` -This configures Git to use the versioned hooks from the [.githooks](./.githooks) directory. +This tells Git to use the hooks stored in the [.githooks](./.githooks) directory. ## Docker -### Pre-Built Image +### Pre-built image ```bash # pull latest @@ -132,7 +137,7 @@ docker pull 343dev/optimizt docker pull 343dev/optimizt:9.0.2 ``` -### Manual Build +### Build the image ```bash # clone repository @@ -151,7 +156,7 @@ Alternatively: docker build --tag 343dev/optimizt https://github.com/343dev/optimizt.git ``` -### Run Container +### Run the container ```bash # mount current directory to /src in the container @@ -160,12 +165,12 @@ docker run --rm --user "$(id -u):$(id -g)" --volume "$(pwd):/src" 343dev/optimiz ## Integrations -Optimizt works seamlessly with: +Use these guides to run Optimizt from your editor or GitHub Actions: - [JetBrains IDEs](./docs/jetbrains.md) - [Visual Studio Code](./docs/vscode.md) - [Sublime Text 3](./docs/sublime-text.md) -- [GitHub Actions Workflow](./docs/github.md) +- [GitHub Actions](./docs/github.md) ## Articles @@ -174,7 +179,7 @@ Optimizt works seamlessly with: ## Credits -Cute picture for the project was made by [Igor Garybaldi](http://pandabanda.com/). +The logo was created by [Igor Garybaldi](http://pandabanda.com/). ## Other projects From edad09e2d096994658d57cbc5be52f11ec6dfe53 Mon Sep 17 00:00:00 2001 From: 343dev <343dev@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:34:21 +0700 Subject: [PATCH 5/8] docs(readme): simplify wording and remove decorative formatting --- README.md | 63 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 5cc57a6..72eb6c4 100644 --- a/README.md +++ b/README.md @@ -34,17 +34,19 @@ This command can replace the original file. To keep it, use `--output` with a se ## Command-line options -- `--avif` — create AVIF versions of images. -- `--webp` — create WebP versions of images. -- `-f, --force` — replace existing AVIF and WebP versions. -- `-l, --lossless` — use the lossless profile; JPEG compression is still lossy. -- `-v, --verbose` — show detailed output, including skipped files. -- `-c, --config ` — use a custom configuration file instead of the bundled defaults. -- `-o, --output ` — write results to an existing directory. -- `-p, --prefix ` — add a prefix to output file names. -- `-s, --suffix ` — add a suffix to output file names. -- `-V, --version` — show the version. -- `-h, --help` — show help. +| Option | Description | +| --- | --- | +| `--avif` | Create AVIF versions of images. | +| `--webp` | Create WebP versions of images. | +| `-f, --force` | Replace existing AVIF and WebP versions. | +| `-l, --lossless` | Use the lossless profile. JPEG compression is still lossy. | +| `-v, --verbose` | Show detailed output, including skipped files. | +| `-c, --config ` | Use a custom configuration file instead of the bundled defaults. | +| `-o, --output ` | Write results to an existing directory. | +| `-p, --prefix ` | Add a prefix to output file names. | +| `-s, --suffix ` | Add a suffix to output file names. | +| `-V, --version` | Show the version. | +| `-h, --help` | Show help. | ## Examples @@ -66,7 +68,7 @@ find . -iname \*.jpg -exec optimizt {} + ### Lossy (default) -Uses lossy compression to reduce file size, with some loss of image quality. SVG settings are the same in both modes. +Optimizt uses lossy compression to reduce file size, with some loss of image quality. SVG settings are the same in both modes. ### Lossless (`--lossless`) @@ -82,11 +84,12 @@ Files may be larger than those produced by the lossy profile. Optimizt writes each result to a temporary file in the destination directory and synchronizes it to disk. It then replaces the destination file in one step, so other programs see either the old file or the complete new file, never a partially written image. -Files already replaced are not restored if the run fails or stops. +Optimizt does not restore files it has already replaced if the run fails or stops. -- When replacing a file, Optimizt keeps its permissions and, where the operating system allows, its owner. +- When replacing a file, Optimizt keeps its permissions. It also keeps the file's owner if the operating system allows it. - Optimizt does not replace files with multiple hard links, because the other links would still point to the old file. Use a separate output path instead. -- When optimizing a symbolic link in place, Optimizt replaces the file it points to and keeps the link itself. When converting an image through a symbolic link, it writes the AVIF or WebP version next to the link unless you choose a different output directory. +- When optimizing a symbolic link in place, Optimizt replaces the file it points to and keeps the link itself. +- When converting an image through a symbolic link, Optimizt writes the AVIF or WebP version next to the link. Use `--output` to write it to a different directory. > [!NOTE] > Atomic replacement protects against partially written files. It does not guarantee that the directory entry itself survives sudden power loss, and it does not preserve file timestamps. @@ -103,14 +106,14 @@ For JPEG in the lossless profile, Optimizt uses [Guetzli](https://github.com/goo See [.optimiztrc.cjs](./.optimiztrc.cjs) for the default settings. For available options and accepted values, check the documentation for each image processor linked above. -Use `--config path/to/.optimiztrc.cjs` to load your own settings instead of the bundled configuration. Without `--config`, Optimizt looks for `.optimiztrc.cjs` in the current directory, then in each parent directory, and uses the first file it finds. If it finds none, it uses the bundled defaults. +Use `--config path/to/.optimiztrc.cjs` to load your own settings instead of the bundled configuration. Without `--config`, Optimizt looks for `.optimiztrc.cjs` in the current directory, then in each parent directory. It uses the first file it finds, or the bundled defaults if it finds none. > [!WARNING] -> `.optimiztrc.cjs` is executable code. Auto-discovered and explicitly selected configuration runs with your user permissions; use Optimizt only in repositories you trust. +> `.optimiztrc.cjs` is executable code and runs with your permissions. This applies whether Optimizt finds the file automatically or you select it with `--config`. Use Optimizt only in repositories you trust. ## Troubleshooting -### Errors like “spawn guetzli ENOENT” +### Errors like `spawn guetzli ENOENT` Make sure the [ignore-scripts](https://docs.npmjs.com/cli/v6/using-npm/config#ignore-scripts) npm option is disabled. Details: [funbox/optimizt/issues/9](https://github.com/funbox/optimizt/issues/9). @@ -130,21 +133,21 @@ This tells Git to use the hooks stored in the [.githooks](./.githooks) directory ### Pre-built image ```bash -# pull latest +# pull the latest image docker pull 343dev/optimizt -# pull specific version +# pull a specific version docker pull 343dev/optimizt:9.0.2 ``` ### Build the image ```bash -# clone repository +# clone the repository git clone https://github.com/343dev/optimizt.git cd optimizt -# build image +# build the image docker build --tag 343dev/optimizt . ``` @@ -159,7 +162,7 @@ docker build --tag 343dev/optimizt https://github.com/343dev/optimizt.git ### Run the container ```bash -# mount current directory to /src in the container +# mount the current directory at /src in the container docker run --rm --user "$(id -u):$(id -g)" --volume "$(pwd):/src" 343dev/optimizt --webp ./image.png ``` @@ -174,16 +177,16 @@ Use these guides to run Optimizt from your editor or GitHub Actions: ## Articles -- [anuwong.com](https://anuwong.com/blog/2023-08-21-save-tons-of-gbs-with-optimizt/) — Compress files before uploading, save tons of GBs. 🇹🇭 -- [Linux Format, Issue 277 (July 2021)](https://www.linuxformat.com/archives?issue=277#:~:text=Kitchen%20Tales%2C%20zFRAG%2C-,Optimizt,-and%20SingleFileZ.) — Optimizt is ideal for reducing the disk footprint of images without any reduction in quality. +- [anuwong.com](https://anuwong.com/blog/2023-08-21-save-tons-of-gbs-with-optimizt/). An article in Thai about compressing files before uploading them. +- [Linux Format, Issue 277 (July 2021)](https://www.linuxformat.com/archives?issue=277#:~:text=Kitchen%20Tales%2C%20zFRAG%2C-,Optimizt,-and%20SingleFileZ.). An article about reducing image file sizes with Optimizt. ## Credits -The logo was created by [Igor Garybaldi](http://pandabanda.com/). +[Igor Garybaldi](http://pandabanda.com/) created the logo. ## Other projects -- 📦 [harold](https://github.com/343dev/harold) — CLI tool that compares frontend project bundle sizes between snapshots -- 🐳 [jailbot](https://github.com/343dev/jailbot) — Docker container wrapper with automatic filesystem path mounting -- 📝 [markdown-lint](https://github.com/343dev/markdown-lint) — Markdown code style linter based on Prettier, Remark, and Typograf -- 🔤 [languagetool-node](https://github.com/343dev/languagetool-node) — CLI spell and grammar checker powered by LanguageTool +- [harold](https://github.com/343dev/harold) compares frontend project bundle sizes between snapshots from the command line. +- [jailbot](https://github.com/343dev/jailbot) wraps Docker containers and automatically mounts filesystem paths. +- [markdown-lint](https://github.com/343dev/markdown-lint) checks Markdown style with Prettier, Remark, and Typograf. +- [languagetool-node](https://github.com/343dev/languagetool-node) checks spelling and grammar from the command line with LanguageTool. From 000a201e9bcba52fc654be2f0a32faee3c0bb772 Mon Sep 17 00:00:00 2001 From: 343dev <343dev@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:39:24 +0700 Subject: [PATCH 6/8] docs(readme): remove troubleshooting section --- README.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/README.md b/README.md index 72eb6c4..efd398b 100644 --- a/README.md +++ b/README.md @@ -111,13 +111,6 @@ Use `--config path/to/.optimiztrc.cjs` to load your own settings instead of the > [!WARNING] > `.optimiztrc.cjs` is executable code and runs with your permissions. This applies whether Optimizt finds the file automatically or you select it with `--config`. Use Optimizt only in repositories you trust. -## Troubleshooting - -### Errors like `spawn guetzli ENOENT` - -Make sure the [ignore-scripts](https://docs.npmjs.com/cli/v6/using-npm/config#ignore-scripts) npm option is disabled. -Details: [funbox/optimizt/issues/9](https://github.com/funbox/optimizt/issues/9). - ## Development After cloning the repository, enable Git hooks once: From c7a78e893c5a10d31abb32a423d7bf99053de40b Mon Sep 17 00:00:00 2001 From: 343dev <343dev@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:43:49 +0700 Subject: [PATCH 7/8] docs(readme): describe image processing beyond web use --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index efd398b..871e9bb 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,13 @@ [![npm](https://img.shields.io/npm/v/@343dev/optimizt.svg)](https://www.npmjs.com/package/@343dev/optimizt) [![Docker](https://img.shields.io/docker/v/343dev/optimizt?label=Docker)](https://hub.docker.com/r/343dev/optimizt) -**Optimizt** is a command-line tool that helps prepare images for the web. +Optimizt is a command-line tool for compressing images and converting them to AVIF and WebP. -It compresses PNG, JPEG, GIF, and SVG files and creates AVIF and WebP versions of raster images. +It compresses PNG, JPEG, GIF, and SVG files. You can process individual files or entire directories, including their subdirectories. ## Why Optimizt? -I built Optimizt because preparing images for the web meant switching between several tools. I wanted one command to compress images, clean up SVGs, and create AVIF and WebP versions. +I built Optimizt while working on images for the web. I kept switching between tools to compress images and convert formats. I wanted one command-line tool for both. ## Usage From 5ee1816c493b8c87051d69d8c6456490b7a3536d Mon Sep 17 00:00:00 2001 From: 343dev <343dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:17:34 +0700 Subject: [PATCH 8/8] test: canonicalize temporary directory paths --- tests/helpers/cli.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/helpers/cli.js b/tests/helpers/cli.js index 80f8233..31dceca 100644 --- a/tests/helpers/cli.js +++ b/tests/helpers/cli.js @@ -15,7 +15,10 @@ const temporaryDirectories = []; export { hasCaseSensitivePaths, isPrivileged, isWindows } from './platform.js'; export async function makeTemporaryDirectory() { - const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'optimizt-test-')); + const createdDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'optimizt-test-')); + // macOS commonly exposes /tmp and /var through symbolic links. Use the + // canonical spelling because operation planning canonicalizes filesystem paths. + const directory = await fs.realpath(createdDirectory); temporaryDirectories.push(directory); return directory; }