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/.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/.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 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..871e9bb 100644 --- a/README.md +++ b/README.md @@ -8,49 +8,53 @@ [![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 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. You can process individual files or entire directories, including their subdirectories. -## 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 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 -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. -- `--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. +## Command-line options -## Usage Examples +| 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 ```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,39 +64,52 @@ optimizt --avif --webp path/to/directory find . -iname \*.jpg -exec optimizt {} + ``` -## Differences Between Lossy and Lossless +## Lossy and lossless modes + +### Lossy (default) + +Optimizt uses lossy compression to reduce file size, with some loss of image quality. SVG settings are the same in both modes. + +### Lossless (`--lossless`) + +With the bundled settings: + +- **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. -### Lossy (Default) +Files may be larger than those produced by the lossy profile. -Provides the best balance between file size reduction and minimal visual quality loss. +## How files are written -### Lossless (`--lossless` flag) +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. -- **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. +Optimizt does not restore files it has already replaced if the run fails or stops. + +- 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, 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. ## 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. - -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. +For JPEG in the lossless profile, Optimizt uses [Guetzli](https://github.com/google/guetzli) instead of sharp for the final compression step. -## Troubleshooting +See [.optimiztrc.cjs](./.optimiztrc.cjs) for the default settings. For available options and accepted values, check the documentation for each image processor linked above. -### Errors like “spawn guetzli ENOENT”. +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. -Ensure 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). +> [!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. ## Development @@ -102,28 +119,28 @@ 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 +# pull the latest image docker pull 343dev/optimizt -# pull specific version +# pull a specific version docker pull 343dev/optimizt:9.0.2 ``` -### Manual Build +### 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 . ``` @@ -135,34 +152,34 @@ 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 -docker run --rm --volume $(pwd):/src 343dev/optimizt --webp ./image.png +# 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 ``` ## 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 -- [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 -Cute picture for the project was made 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. diff --git a/cli.js b/cli.js index 523d3ca..5df9149 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 ', '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') + .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}.`) + .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..074be35 100644 --- a/convert.js +++ b/convert.js @@ -1,46 +1,40 @@ 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; } - log(`Converting ${filePathsCount} ${getPlural(filePathsCount, 'image', 'images')} (${isLossless ? 'lossless' : 'lossy'})...`); + 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}'`, + description: `Output file already exists: ${outputFilePath}. Use --force to replace it.`, 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,52 +118,56 @@ 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); 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 } - 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) { 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/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..9137c3c 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(`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(`Unable to use configuration file ${configPath}. Define "${currentMode}" as an object.`); + } - 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..fd8a51d --- /dev/null +++ b/lib/describe-codec-failure.js @@ -0,0 +1,10 @@ +// 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'; + 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 e9ac381..03aaaea 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(`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(`Unable to use ${resolvedPath} as a configuration file. Provide the path to a CJS file.`); + 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(`Unable to inspect configuration file ${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..8ec102a --- /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 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); + } + + // 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(`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 + // 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(`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); + } +} + +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(`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); + } 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 included. Skipped duplicate.' + : `Duplicate of "${keptPath}". Skipped.`, + }); +} + +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('Unable to use overlapping input directories with --output. Remove the nested input or run the directories separately.'); + } + } +} + +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(`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(`Unable to process ${filePath}. Choose a file with one of these extensions: ${extensions.map(extension => `.${extension}`).join(', ')}.`); +} + +function isSupported(filePath, extensions) { + return extensions.includes(path.extname(filePath).toLowerCase().slice(1)); +} + +function assertContained(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) { + 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..62cf1b4 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; @@ -34,7 +39,7 @@ export async function optimize({ filePaths, config }) { 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); @@ -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) { - throw new Error('Unknown file format'); + if (!format) { + throw new Error('Unable to read the image format. Check that the file is a valid, supported image.'); } - 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(); @@ -236,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-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..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", @@ -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..bf92ad5 --- /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(`Unable to replace output 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('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 new file mode 100644 index 0000000..648ccbd --- /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('Unable to create an animated AVIF'); + 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..31dceca --- /dev/null +++ b/tests/helpers/cli.js @@ -0,0 +1,110 @@ +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 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; +} + +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..c82f5f4 --- /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(`Unable to process ${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(`Unable to process ${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}". Skipped.`); + }); + + 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 included. Skipped duplicate.'); + }); + + 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 directory 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(`Unable to replace output 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('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); + }); + + 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(`Unable to use output 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('Multiple inputs produce the same output file'); + 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('Multiple inputs produce the same output file'); + 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('Unable to use overlapping input directories 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('create a portable filename'); + 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..4a49def --- /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('Optimize images in place or create AVIF and WebP variants.'); + 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(`Configuration 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(`Unable to use ${directory} as a configuration file`); + 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(`Unable to load configuration file ${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(`Unable to use configuration file ${configPath}. Define "optimize" as an object.`); + }); + + 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(`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 '); + }); + + 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(`Unable to convert the image to AVIF with the lossless profile and configuration file ${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('Unable to read the image 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, + }, +});