From cb49f7022cfedae9647acad924e241418817561c Mon Sep 17 00:00:00 2001 From: 343dev <343dev@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:04:22 +0700 Subject: [PATCH 1/5] refactor(cli): add shared operation runtime Centralize bounded operation execution, resource-specific limits, outcome collection, and interruption-aware scheduling behind one runtime. --- packages/optimizt/lib/operation-runtime.js | 52 +++++++++ .../optimizt/tests/operation-runtime.test.js | 110 ++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 packages/optimizt/lib/operation-runtime.js create mode 100644 packages/optimizt/tests/operation-runtime.test.js diff --git a/packages/optimizt/lib/operation-runtime.js b/packages/optimizt/lib/operation-runtime.js new file mode 100644 index 0000000..a673730 --- /dev/null +++ b/packages/optimizt/lib/operation-runtime.js @@ -0,0 +1,52 @@ +import os from 'node:os'; + +import pLimit from 'p-limit'; + +import { OUTCOME_STATUS } from './outcome-status.js'; + +export function createOperationRuntime({ concurrency = os.availableParallelism(), detect, lifecycle, read, writer }) { + const operationLimit = pLimit(concurrency); + const resourceLimits = new Map([['guetzli', pLimit(1)]]); + + return { + async execute({ operations, policy, selectCodec, onCompleted = () => {} }) { + const pendingOutcomes = await Promise.all(operations.map((operation, planIndex) => operationLimit(async () => { + if (lifecycle.isInterrupted()) return; + try { + if (operation.skipReason) return recordOutcome(onCompleted, { output: operation.output, planIndex, status: OUTCOME_STATUS.SKIPPED }); + const input = await read(operation); + const metadata = await detect(operation, input); + const codec = selectCodec(metadata, operation); + const encode = async () => { + if (lifecycle.isInterrupted()) return; + return codec.encode(input, metadata, operation); + }; + const encoded = codec.resource && resourceLimits.has(codec.resource) + ? await resourceLimits.get(codec.resource)(encode) + : await encode(); + if (!encoded || lifecycle.isInterrupted()) return; + const decision = await policy.complete({ encoded, input, metadata, operation }); + if (decision.write) await writer.write(operation.output, encoded); + return recordOutcome(onCompleted, { + ...decision, output: operation.output, planIndex, + status: decision.write ? OUTCOME_STATUS.PROCESSED : OUTCOME_STATUS.SKIPPED, + }); + } catch (error) { + if (lifecycle.isInterrupted()) return; + return recordOutcome(onCompleted, { error, output: operation.output, planIndex, status: OUTCOME_STATUS.FAILED }); + } + }))); + const outcomes = pendingOutcomes.filter(Boolean); + return { + completed: outcomes.length, + failed: outcomes.filter(outcome => outcome.status === OUTCOME_STATUS.FAILED).length, + interrupted: lifecycle.isInterrupted(), outcomes, total: operations.length, + }; + }, + }; +} + +function recordOutcome(onCompleted, outcome) { + onCompleted(outcome); + return outcome; +} diff --git a/packages/optimizt/tests/operation-runtime.test.js b/packages/optimizt/tests/operation-runtime.test.js new file mode 100644 index 0000000..af97346 --- /dev/null +++ b/packages/optimizt/tests/operation-runtime.test.js @@ -0,0 +1,110 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { createOperationRuntime } from '../lib/operation-runtime.js'; + +function deferred() { + const { promise, resolve } = Promise.withResolvers(); + return { promise, resolve }; +} + +function operation(index, format = 'jpeg') { + return { input: `input-${index}`, output: `output-${index}`, detectedFormat: format }; +} + +const writer = { write: vi.fn(async () => {}) }; +const read = async operation_ => Buffer.from(operation_.input); +const detect = async operation_ => ({ format: operation_.detectedFormat }); +const policy = { + complete: ({ encoded }) => ({ bytesAfter: encoded.length, write: true }), +}; + +describe('Operation runtime', () => { + test('serializes detected JPEG Guetzli work regardless of the filename extension', async () => { + let active = 0; + let maximum = 0; + const gates = [deferred(), deferred()]; + let started = 0; + const runtime = createOperationRuntime({ concurrency: 2, detect, lifecycle: { isInterrupted: () => false }, read, writer }); + const execution = runtime.execute({ + operations: [operation(0), operation(1)], + policy, + selectCodec: () => ({ resource: 'guetzli', encode: async () => { + active += 1; + maximum = Math.max(maximum, active); + const gate = gates[started++]; + await gate.promise; + active -= 1; + return Buffer.from('jpeg'); + } }), + }); + await vi.waitFor(() => expect(started).toBe(1)); + gates[0].resolve(); + await vi.waitFor(() => expect(started).toBe(2)); + gates[1].resolve(); + await execution; + + expect(maximum).toBe(1); + }); + + test('does not read the entire plan before encoding begins', async () => { + let reads = 0; + let readsWhenFirstEncodeStarted; + const runtime = createOperationRuntime({ + concurrency: 2, + detect, + lifecycle: { isInterrupted: () => false }, + read: async (operation_) => { + reads += 1; + return Buffer.from(operation_.input); + }, + writer, + }); + await runtime.execute({ + operations: Array.from({ length: 20 }, (_, index) => operation(index, 'png')), + policy, + selectCodec: () => ({ encode: async () => { + readsWhenFirstEncodeStarted ??= reads; + return Buffer.from('png'); + } }), + }); + + expect(readsWhenFirstEncodeStarted).toBeLessThanOrEqual(2); + }); + + test('continues independent work after a failure and returns outcomes in plan order', async () => { + const runtime = createOperationRuntime({ concurrency: 2, detect, lifecycle: { isInterrupted: () => false }, read, writer }); + const result = await runtime.execute({ + operations: [operation(0, 'png'), operation(1, 'png')], + policy, + selectCodec: (_metadata, operation_) => ({ encode: async () => { + if (operation_.input === 'input-0') throw new Error('broken'); + return Buffer.from('ok'); + } }), + }); + + expect(result.outcomes.map(outcome => outcome.status)).toEqual(['failed', 'processed']); + expect(result.failed).toBe(1); + }); + + test('omits queued and abandoned work after interruption', async () => { + let isInterrupted = false; + const gate = deferred(); + const runtime = createOperationRuntime({ concurrency: 1, detect, lifecycle: { isInterrupted: () => isInterrupted }, read, writer }); + const execution = runtime.execute({ + operations: [operation(0), operation(1)], + policy, + selectCodec: () => ({ encode: async () => { + await gate.promise; + throw new Error('terminated'); + } }), + }); + isInterrupted = true; + gate.resolve(); + const result = await execution; + + expect(result.interrupted).toBe(true); + expect(result.outcomes).toEqual([]); + expect(result.completed).toBe(0); + expect(result.total).toBe(2); + }); +}); From 2b98d246a8426b32f551a09db28311114c182f46 Mon Sep 17 00:00:00 2001 From: 343dev <343dev@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:05:57 +0700 Subject: [PATCH 2/5] refactor(cli)!: isolate invocation execution Route optimization and conversion through the shared runtime, replace module-level options and lifecycle state with per-invocation dependencies, and consolidate progress and summary reporting. BREAKING CHANGE: operation execution now uses invocation-scoped runtime, lifecycle, and reporting contracts. --- packages/optimizt/cli.js | 50 ++- packages/optimizt/convert.js | 199 ++---------- packages/optimizt/index.js | 47 ++- packages/optimizt/lib/colorize.js | 6 +- packages/optimizt/lib/lifecycle.js | 100 +++--- packages/optimizt/lib/log.js | 54 ++-- packages/optimizt/lib/outcome-status.js | 3 - .../optimizt/lib/prepare-operation-plan.js | 25 +- packages/optimizt/lib/program-options.js | 14 - packages/optimizt/lib/reporter.js | 70 +++++ packages/optimizt/lib/show-total.js | 40 --- packages/optimizt/lib/stream-capabilities.js | 4 +- packages/optimizt/optimize.js | 285 ++++-------------- packages/optimizt/tests/interruption.test.js | 14 +- .../tests/invocation-isolation.test.js | 40 +++ packages/optimizt/tests/lifecycle.test.js | 152 +++------- packages/optimizt/tests/show-total.test.js | 66 ---- 17 files changed, 392 insertions(+), 777 deletions(-) delete mode 100644 packages/optimizt/lib/program-options.js create mode 100644 packages/optimizt/lib/reporter.js delete mode 100644 packages/optimizt/lib/show-total.js create mode 100644 packages/optimizt/tests/invocation-isolation.test.js delete mode 100644 packages/optimizt/tests/show-total.test.js diff --git a/packages/optimizt/cli.js b/packages/optimizt/cli.js index 5df9149..c4d4b9f 100755 --- a/packages/optimizt/cli.js +++ b/packages/optimizt/cli.js @@ -7,8 +7,7 @@ import { fileURLToPath } from 'node:url'; import { Command, CommanderError } from 'commander'; import optimizt from './index.js'; -import { finishLifecycle, installSignalHandlers } from './lib/lifecycle.js'; -import { setProgramOptions } from './lib/program-options.js'; +import { createLifecycle } from './lib/lifecycle.js'; const dirname = path.dirname(fileURLToPath(import.meta.url)); const packageJson = JSON.parse(await fs.readFile(path.join(dirname, 'package.json'), 'utf8')); @@ -27,47 +26,38 @@ program .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] [--] ') + .allowExcessArguments().usage('[options] [--] ') .version(packageJson.version, '-V, --version') .description(`${packageJson.description}.`) .exitOverride(); let exitCode = 0; -installSignalHandlers(); +const lifecycle = createLifecycle(); +const releaseSignals = lifecycle.install(); 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, + if (program.args.length === 0) program.outputHelp(); + else { + const parsed = program.opts(); + if (parsed.force && !parsed.avif && !parsed.webp) throw new Error('--force requires --avif or --webp'); + const options = Object.freeze({ + filePrefix: parsed.prefix || '', fileSuffix: parsed.suffix || '', isForced: Boolean(parsed.force), + isLossless: Boolean(parsed.lossless), isVerbose: Boolean(parsed.verbose), + shouldConvertToAvif: Boolean(parsed.avif), shouldConvertToWebp: Boolean(parsed.webp), + shouldUseColor: parsed.color !== false && !process.env.NO_COLOR, }); + const result = await optimizt({ configFilePath: parsed.config, inputPaths: program.args, lifecycle, options, outputDirectoryPath: parsed.output }); exitCode = result.failed > 0 ? 1 : 0; } } catch (error) { - if (error instanceof CommanderError && ['commander.helpDisplayed', 'commander.version'].includes(error.code)) { - exitCode = 0; - } else { + 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`); + process.stderr.write(`Error: ${debug && error.stack ? error.stack : error.message}\n`); if (debug) process.stderr.write(`Optimizt ${packageJson.version}; Node.js ${process.version}\n`); } +} finally { + releaseSignals(); } -process.exitCode = finishLifecycle() ?? exitCode; +process.exitCode = lifecycle.finish() ?? exitCode; diff --git a/packages/optimizt/convert.js b/packages/optimizt/convert.js index 640f4dc..26f6497 100644 --- a/packages/optimizt/convert.js +++ b/packages/optimizt/convert.js @@ -1,176 +1,43 @@ -import fs from 'node:fs'; -import os from 'node:os'; - -import pLimit from 'p-limit'; +import fs from 'node:fs/promises'; import { atomicWrite } from './lib/atomic-write.js'; import { calculateRatio } from './lib/calculate-ratio.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 sharp from './lib/sharp.js'; -import { - LOG_TYPES, - log, - logProgress, - logProgressVerbose, -} from './lib/log.js'; -import { OUTCOME_STATUS } from './lib/outcome-status.js'; +import { createOperationRuntime } from './lib/operation-runtime.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({ 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'})`); - - const progressBarTotal = operations.length; - const progressBarContainer = createProgressBarContainer(progressBarTotal); - const progressBar = progressBarContainer.create(progressBarTotal, 0); - - const totalSize = { before: 0, after: 0 }; - - const getConfig = format => config?.[format]?.[isLossless ? 'lossless' : 'lossy']; - - const avifConfig = getConfig('avif'); - const webpConfig = getConfig('webp'); - - const cpuCount = os.cpus().length; - const tasksSimultaneousLimit = pLimit(cpuCount); - - 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, 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, - skipReason, - format, - processFunction, -}) { - const outputFilePath = filePath.output; - - try { - if (skipReason) { - logProgressVerbose(getRelativePath(outputFilePath), { - description: `Output file already exists: ${outputFilePath}. Use --force to replace it.`, - progressBarContainer, - }); +import sharp from './lib/sharp.js'; - return { planIndex, status: OUTCOME_STATUS.SKIPPED }; +export async function convert({ operations, config, configPath, lifecycle, options, reporter }) { + const runtime = createOperationRuntime({ + detect: (_operation, input) => parseImageMetadata(input), + lifecycle, + read: operation => fs.readFile(operation.input), + writer: { write: atomicWrite }, + }); + return runtime.execute({ + onCompleted: reporter.operationCompleted, + operations, + policy: { + complete({ encoded, input }) { + return { after: encoded.length, before: input.length, ratio: calculateRatio(input.length, encoded.length), write: true }; + }, + }, + selectCodec(metadata, operation) { + if (!metadata.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(metadata.format)) throw new Error(`Unsupported image format: "${metadata.format}"`); + return { encode: input => encode(input, metadata, operation.format) }; + }, + }); + + async function encode(input, metadata, format) { + if (format === 'avif' && metadata.pages > 1) throw new Error('Unable to create an animated AVIF. Use WebP or provide a non-animated image.'); + const codecConfig = config?.[format]?.[options.isLossless ? 'lossless' : 'lossy'] || {}; + try { + const image = sharp(input, { animated: format === 'webp' && metadata.pages > 1 }).rotate(); + return await image[format](codecConfig).toBuffer(); + } catch (error) { + throw describeCodecFailure({ configPath, error, format, isLossless: options.isLossless, mode: 'convert' }); } - - const fileBuffer = await fs.promises.readFile(filePath.input); - const processedFileBuffer = await processFunction({ fileBuffer, config, configPath, isLossless }); - - await atomicWrite(outputFilePath, processedFileBuffer); - - const fileSize = fileBuffer.length; - const processedFileSize = processedFileBuffer.length; - - totalSize.before += fileSize; - totalSize.after += Math.min(fileSize, processedFileSize); - - const ratio = calculateRatio(fileSize, processedFileSize); - const before = formatBytes(fileSize); - const after = formatBytes(processedFileSize); - - logProgress(getRelativePath(outputFilePath), { - type: LOG_TYPES.SUCCESS, - description: `${before} → ${format} ${after}. Ratio: ${ratio}%`, - progressBarContainer, - }); - return { after: processedFileSize, before: fileSize, planIndex, status: OUTCOME_STATUS.PROCESSED }; - } catch (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, configPath, isLossless }) { - const imageMetadata = await parseImageMetadata(fileBuffer); - checkImageFormat(imageMetadata.format); - - const isAnimated = imageMetadata.pages > 1; - - if (isAnimated) { - throw new Error('Unable to create an animated AVIF. Use WebP or provide a non-animated image.'); // See: https://github.com/strukturag/libheif/issues/377 - } - - // Only the codec call is enriched, so detection and support errors keep speaking for themselves. - 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, configPath, isLossless }) { - const imageMetadata = await parseImageMetadata(fileBuffer); - checkImageFormat(imageMetadata.format); - - const isAnimated = imageMetadata.pages > 1; - - 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('Unable to read the image format. Check that the file is a valid, supported image.'); - } - - if (!SUPPORTED_FILE_TYPES.CONVERT.includes(imageFormat)) { - throw new Error(`Unsupported image format: "${imageFormat}"`); } } diff --git a/packages/optimizt/index.js b/packages/optimizt/index.js index 9137c3c..eeedbbe 100755 --- a/packages/optimizt/index.js +++ b/packages/optimizt/index.js @@ -4,30 +4,26 @@ 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 { createLog } from './lib/log.js'; import { prepareOperationPlan } from './lib/prepare-operation-plan.js'; -import { programOptions } from './lib/program-options.js'; +import { createReporter } from './lib/reporter.js'; -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, +export default async function optimizt({ inputPaths, outputDirectoryPath, configFilePath, lifecycle, options }) { + const { log } = createLog({ shouldUseColor: options.shouldUseColor }); + const formats = [...options.shouldConvertToAvif ? ['avif'] : [], ...options.shouldConvertToWebp ? ['webp'] : []]; + const mode = formats.length > 0 ? 'convert' : 'optimize'; + const plan = await prepareOperationPlan({ + extensions: SUPPORTED_FILE_TYPES[mode.toUpperCase()], + force: options.isForced, formats: formats.length > 0 ? formats : ['optimize'], inputPaths, outputDirectoryPath, - prefix: filePrefix, - suffix: fileSuffix, + prefix: options.filePrefix, + suffix: options.fileSuffix, }); - - if (operations.length === 0) { + if (plan.operations.length === 0) { log('No eligible images found'); - return { failed: 0 }; + return { failed: 0, interrupted: false }; } const configPath = await findConfigFilePath(configFilePath); @@ -37,12 +33,15 @@ export default async function optimizt({ inputPaths, outputDirectoryPath, config } 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.`); - } + const config = configData.default?.[mode]; + if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error(`Unable to use configuration file ${configPath}. Define "${mode}" as an object.`); - 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 }); + const reporter = createReporter({ isLossless: options.isLossless, isVerbose: options.isVerbose, mode, operations: plan.operations, shouldUseColor: options.shouldUseColor }); + for (const notice of plan.notices) reporter.notice(notice); + if (options.isLossless) log('Lossless mode may take a long time; JPEG uses Guetzli and is not strictly lossless'); + reporter.start(); + const execute = mode === 'convert' ? convert : optimize; + const result = await execute({ config, configPath, lifecycle, operations: plan.operations, options, reporter }); + reporter.finish(result); + return result; } diff --git a/packages/optimizt/lib/colorize.js b/packages/optimizt/lib/colorize.js index f7c1d90..85f1ae0 100644 --- a/packages/optimizt/lib/colorize.js +++ b/packages/optimizt/lib/colorize.js @@ -1,8 +1,12 @@ import { canUseColor } from './stream-capabilities.js'; export function colorize(...arguments_) { + return colorizeFor(undefined, ...arguments_); +} + +export function colorizeFor(shouldUseColor, ...arguments_) { const string_ = arguments_.join(' '); - const shouldColor = canUseColor(); + const shouldColor = canUseColor(shouldUseColor); const buildColor = (start, end) => `${shouldColor ? start : ''}${string_}${shouldColor ? end : ''}`; return { diff --git a/packages/optimizt/lib/lifecycle.js b/packages/optimizt/lib/lifecycle.js index 59a029c..e4cdf60 100644 --- a/packages/optimizt/lib/lifecycle.js +++ b/packages/optimizt/lib/lifecycle.js @@ -1,62 +1,60 @@ -const state = { - activeChildren: new Set(), - activeCancellations: 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 registerCancellable(cancel, completion) { - state.activeCancellations.add(cancel); - void completion.then( - () => state.activeCancellations.delete(cancel), - () => state.activeCancellations.delete(cancel), - ); - - if (isInterrupted()) void cancel(); -} - -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) return forceExit(signal); - for (const child of state.activeChildren) child.kill('SIGTERM'); - for (const cancel of state.activeCancellations) void cancel(); - // 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(() => forceExit(signal), 5000); - state.forceExitTimer.unref(); - }); +export function createLifecycle({ forceExit = defaultForceExit, shutdownMilliseconds = 5000 } = {}) { + const state = { + activeChildren: new Set(), + activeCancellations: new Set(), + forceExitTimer: undefined, + interruptCount: 0, + interruptedSignal: undefined, + listeners: new Map(), + }; + + function interrupt(signal) { + state.interruptCount += 1; + state.interruptedSignal ||= signal; + if (state.interruptCount > 1) return forceExit(signalExitCode(signal)); + for (const child of state.activeChildren) child.kill('SIGTERM'); + for (const cancel of state.activeCancellations) void cancel(); + state.forceExitTimer = setTimeout(() => forceExit(signalExitCode(signal)), shutdownMilliseconds); + state.forceExitTimer.unref(); } -} -export function isInterrupted() { - return Boolean(state.interruptedSignal); + return Object.freeze({ + finish() { + if (state.forceExitTimer) clearTimeout(state.forceExitTimer); + return state.interruptedSignal ? signalExitCode(state.interruptedSignal) : undefined; + }, + install() { + for (const signal of ['SIGINT', 'SIGTERM']) { + const listener = interrupt.bind(undefined, signal); + state.listeners.set(signal, listener); + process.on(signal, listener); + } + return release.bind(undefined, state); + }, + interrupt, + isInterrupted: () => Boolean(state.interruptedSignal), + registerCancellable(cancel, completion) { + state.activeCancellations.add(cancel); + void completion.finally(() => state.activeCancellations.delete(cancel)).catch(() => {}); + if (state.interruptedSignal) void cancel(); + }, + registerChild(child) { + state.activeChildren.add(child); + child.once('close', () => state.activeChildren.delete(child)); + if (state.interruptedSignal) child.kill('SIGTERM'); + }, + }); } -export function finishLifecycle() { +function release(state) { + for (const [signal, listener] of state.listeners) process.removeListener(signal, listener); + state.listeners.clear(); if (state.forceExitTimer) clearTimeout(state.forceExitTimer); - return state.interruptedSignal ? signalExitCode(state.interruptedSignal) : undefined; } -function forceExit(signal) { - // Forced shutdown must bypass synchronous exit hooks too: Sharp WASM's hook - // waits for libvips workers and can deadlock while a pipeline is still active. - // Normal completion keeps these hooks; only the emergency paths abandon cleanup. +function defaultForceExit(code) { process.removeAllListeners('exit'); - process.exit(signalExitCode(signal)); // eslint-disable-line n/no-process-exit + process.exit(code); // eslint-disable-line n/no-process-exit } function signalExitCode(signal) { diff --git a/packages/optimizt/lib/log.js b/packages/optimizt/lib/log.js index 2712888..7e6df78 100644 --- a/packages/optimizt/lib/log.js +++ b/packages/optimizt/lib/log.js @@ -1,8 +1,7 @@ import { EOL } from 'node:os'; import { format } from 'node:util'; -import { colorize } from './colorize.js'; -import { programOptions } from './program-options.js'; +import { colorizeFor } from './colorize.js'; import { canUseUnicode } from './stream-capabilities.js'; export const LOG_TYPES = { @@ -26,31 +25,34 @@ const symbols = { [LOG_TYPES.ERROR]: ['x', '✖'], }; -function formatLogMessage(title, { type = LOG_TYPES.INFO, description } = {}) { - if (!title) throw new Error('Title is required'); - return [ - colorize(symbols[type][canUseUnicode() ? 1 : 0])[colors[type]], - title, - ...description ? [EOL, ' ', colorize(description).dim] : [], - ]; -} - -export function log(title, { type, description } = {}) { - process.stderr.write(`${format(...formatLogMessage(title, { type, description }))}${EOL}`); -} - -export function logEmptyLine() { - process.stderr.write(EOL); -} +export function createLog({ shouldUseColor } = {}) { + function formatLogMessage(title, { type = LOG_TYPES.INFO, description } = {}) { + if (!title) throw new Error('Title is required'); + return [ + colorizeFor(shouldUseColor, symbols[type][canUseUnicode() ? 1 : 0])[colors[type]], + title, + ...description ? [EOL, ' ', colorizeFor(shouldUseColor, description).dim] : [], + ]; + } -export function logProgress(title, { type, description, progressBarContainer } = {}) { - if (progressBarContainer?.isRendering) { - progressBarContainer.log(`${formatLogMessage(title, { type, description }).join(' ')}${EOL}`); - return; + function log(title, { type, description } = {}) { + process.stderr.write(`${format(...formatLogMessage(title, { type, description }))}${EOL}`); } - log(title, { type, description }); -} -export function logProgressVerbose(title, { type, description, progressBarContainer } = {}) { - if (programOptions.isVerbose) logProgress(title, { type, description, progressBarContainer }); + return Object.freeze({ + log, + logEmptyLine() { + process.stderr.write(EOL); + }, + logProgress(title, { type, description, progressBarContainer } = {}) { + if (progressBarContainer?.isRendering) { + progressBarContainer.log(`${formatLogMessage(title, { type, description }).join(' ')}${EOL}`); + return; + } + log(title, { type, description }); + }, + }); } + +const defaultLog = createLog(); +export const { log, logEmptyLine, logProgress } = defaultLog; diff --git a/packages/optimizt/lib/outcome-status.js b/packages/optimizt/lib/outcome-status.js index 5cdff98..05112f0 100644 --- a/packages/optimizt/lib/outcome-status.js +++ b/packages/optimizt/lib/outcome-status.js @@ -2,7 +2,4 @@ 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/packages/optimizt/lib/prepare-operation-plan.js b/packages/optimizt/lib/prepare-operation-plan.js index 8ec102a..3dd2b9d 100644 --- a/packages/optimizt/lib/prepare-operation-plan.js +++ b/packages/optimizt/lib/prepare-operation-plan.js @@ -1,9 +1,6 @@ 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. @@ -32,13 +29,14 @@ export async function prepareOperationPlan({ // Operands are inspected in the given order so that deduplication keeps the first // spelling and reports the rest deterministically. + const notices = []; 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); + notices.push(duplicateNotice(operand, kept)); continue; } keptOperands.set(pathKey(operand.realPath), operand); @@ -61,7 +59,7 @@ export async function prepareOperationPlan({ for (const input of discovered) { const kept = uniqueInputs.get(pathKey(input.realPath)); if (kept) { - reportDuplicate(input, kept); + notices.push(duplicateNotice(input, kept)); continue; } uniqueInputs.set(pathKey(input.realPath), input); @@ -82,7 +80,7 @@ export async function prepareOperationPlan({ 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; + return { notices, operations }; } async function inspectOperand(inputPath, extensions) { @@ -196,15 +194,12 @@ async function canonicalizeThroughExistingAncestor(output) { // 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 duplicateNotice(dropped, kept) { + return { + droppedPath: dropped.givenPath ?? dropped.operandPath, + keptPath: kept.givenPath ?? kept.operandPath, + type: 'duplicate', + }; } function validateDirectoryOverlap(directories) { diff --git a/packages/optimizt/lib/program-options.js b/packages/optimizt/lib/program-options.js deleted file mode 100644 index 63ce870..0000000 --- a/packages/optimizt/lib/program-options.js +++ /dev/null @@ -1,14 +0,0 @@ -export const programOptions = { - shouldConvertToAvif: false, - shouldConvertToWebp: false, - isForced: false, - isLossless: false, - isVerbose: false, - filePrefix: '', - fileSuffix: '', -}; - -export function setProgramOptions(options) { - Object.assign(programOptions, options); - Object.freeze(programOptions); -} diff --git a/packages/optimizt/lib/reporter.js b/packages/optimizt/lib/reporter.js new file mode 100644 index 0000000..b1236a5 --- /dev/null +++ b/packages/optimizt/lib/reporter.js @@ -0,0 +1,70 @@ +import { calculateRatio } from './calculate-ratio.js'; +import { createProgressBarContainer } from './create-progress-bar-container.js'; +import { formatBytes } from './format-bytes.js'; +import { getPlural } from './get-plural.js'; +import { getRelativePath } from './get-relative-path.js'; +import { createLog, LOG_TYPES } from './log.js'; +import { OUTCOME_STATUS } from './outcome-status.js'; + +export function createReporter({ isLossless, isVerbose, mode, operations, shouldUseColor }) { + const { log, logEmptyLine, logProgress } = createLog({ shouldUseColor }); + const total = operations.length; + const inputCount = new Set(operations.map(operation => operation.input)).size; + const progressContainer = createProgressBarContainer(total); + const progress = progressContainer.create(total, 0); + + return Object.freeze({ + notice(notice) { + if (!isVerbose || notice.type !== 'duplicate') return; + const droppedPath = getRelativePath(notice.droppedPath); + const keptPath = getRelativePath(notice.keptPath); + log(droppedPath, { + description: droppedPath === keptPath + ? 'Already included. Skipped duplicate.' + : `Duplicate of "${keptPath}". Skipped.`, + }); + }, + operationCompleted(outcome) { + progress.increment(); + if (outcome.status === OUTCOME_STATUS.PROCESSED) { + const target = getRelativePath(outcome.output); + const description = mode === 'convert' + ? `${formatBytes(outcome.before)} → ${formatBytes(outcome.after)}. Ratio: ${outcome.ratio}%` + : `${formatBytes(outcome.before)} → ${formatBytes(outcome.after)}. Ratio: ${outcome.ratio}%`; + logProgress(target, { description, progressBarContainer: progressContainer, type: outcome.ratio > 0 ? LOG_TYPES.SUCCESS : LOG_TYPES.WARNING }); + } else if (outcome.status === OUTCOME_STATUS.SKIPPED && isVerbose) { + logProgress(getRelativePath(outcome.output), { description: 'Skipped', progressBarContainer: progressContainer }); + } + }, + start() { + const verb = mode === 'convert' ? 'Converting' : 'Optimizing'; + log(`${verb} ${inputCount} ${getPlural(inputCount, 'image', 'images')} (${isLossless ? 'lossless' : 'lossy'})...`); + }, + finish(result) { + progressContainer.update(); + progressContainer.stop(); + if (result.interrupted) { + logEmptyLine(); + log('Interrupted'); + log(`${result.completed} of ${result.total} operations completed`); + return; + } + renderSummary(result.outcomes, mode === 'convert', { log, logEmptyLine }); + }, + }); +} + +function renderSummary(outcomes, conversion, { log, logEmptyLine }) { + const processedOutcomes = outcomes.filter(outcome => outcome.status === OUTCOME_STATUS.PROCESSED); + const skipped = outcomes.filter(outcome => outcome.status === OUTCOME_STATUS.SKIPPED).length; + const failures = outcomes.filter(outcome => outcome.status === OUTCOME_STATUS.FAILED).toSorted((left, right) => left.planIndex - right.planIndex); + const summary = [[processedOutcomes.length, 'processed'], [skipped, 'skipped'], [failures.length, 'failed']] + .filter(([count]) => count > 0).map(([count, label]) => `${count} ${label}`).join(', '); + logEmptyLine(); + log(summary); + for (const failure of failures) log(getRelativePath(failure.output), { description: failure.error?.message || String(failure.error), type: LOG_TYPES.ERROR }); + if (processedOutcomes.length === 0) return; + const before = processedOutcomes.reduce((sum, outcome) => sum + outcome.before, 0); + const after = processedOutcomes.reduce((sum, outcome) => sum + outcome.after, 0); + log(conversion ? `${formatBytes(after)} created` : `${formatBytes(before - after)} saved (${calculateRatio(before, after)}%)`); +} diff --git a/packages/optimizt/lib/show-total.js b/packages/optimizt/lib/show-total.js deleted file mode 100644 index 3eb16a4..0000000 --- a/packages/optimizt/lib/show-total.js +++ /dev/null @@ -1,40 +0,0 @@ -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, 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(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/packages/optimizt/lib/stream-capabilities.js b/packages/optimizt/lib/stream-capabilities.js index d082780..2e83c11 100644 --- a/packages/optimizt/lib/stream-capabilities.js +++ b/packages/optimizt/lib/stream-capabilities.js @@ -1,5 +1,5 @@ -export function canUseColor() { - return canUseUnicode() && !process.env.NO_COLOR; +export function canUseColor(shouldUseColor = !process.env.NO_COLOR) { + return canUseUnicode() && shouldUseColor; } export function canUseUnicode() { diff --git a/packages/optimizt/optimize.js b/packages/optimizt/optimize.js index 01ca3b8..8b87f2b 100644 --- a/packages/optimizt/optimize.js +++ b/packages/optimizt/optimize.js @@ -1,248 +1,87 @@ import { spawn } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; +import fs from 'node:fs/promises'; import path from 'node:path'; -import pLimit from 'p-limit'; import { optimize as svgoOptimize } from 'svgo'; import { atomicWrite } from './lib/atomic-write.js'; -import { guetzliRunner, nodeExecutable } from './lib/guetzli.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 { createGifOperation } from './lib/gifsicle.js'; -import { getPlural } from './lib/get-plural.js'; -import { getRelativePath } from './lib/get-relative-path.js'; -import { isInterrupted, registerCancellable, registerChild } from './lib/lifecycle.js'; -import sharp from './lib/sharp.js'; -import { - LOG_TYPES, - log, - logProgress, - logProgressVerbose, -} from './lib/log.js'; +import { guetzliRunner, nodeExecutable } from './lib/guetzli.js'; +import { createOperationRuntime } from './lib/operation-runtime.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({ operations, config, configPath }) { - const { isLossless } = programOptions; - const filePaths = operations.map(operation => ({ input: operation.input, output: operation.output })); - - const filePathsCount = filePaths.length; - - if (filePathsCount <= 0) { - return; - } - - log(`Optimizing ${filePathsCount} ${getPlural(filePathsCount, 'image', 'images')} (${isLossless ? 'lossless' : 'lossy'})`); - - const progressBarContainer = createProgressBarContainer(filePathsCount); - const progressBar = progressBarContainer.create(filePathsCount, 0); - - const totalSize = { before: 0, after: 0 }; - - const cpuCount = os.cpus().length; - 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. - - const outcomes = await Promise.all( - filePaths.map((filePath, planIndex) => { - const extension = path.extname(filePath.input).toLowerCase(); - const isJpeg = extension === '.jpg' || extension === '.jpeg'; - - const limit = isJpeg && isLossless - ? guetzliTasksSimultaneousLimit - : tasksSimultaneousLimit; - - 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, 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, configPath, isLossless }); - - const fileSize = fileBuffer.length; - const processedFileSize = processedFileBuffer.length; - - totalSize.before += fileSize; - totalSize.after += Math.min(fileSize, processedFileSize); - - const ratio = calculateRatio(fileSize, processedFileSize); - - const isOptimized = ratio > 0; - const isChanged = !fileBuffer.equals(processedFileBuffer); - const isSvg = path.extname(filePath.input).toLowerCase() === '.svg'; - - if (!isOptimized && (!isChanged || !isSvg)) { - logProgressVerbose(getRelativePath(filePath.output), { - description: `${(isChanged ? 'File size increased' : 'Nothing changed')}. Skipped`, - progressBarContainer, - }); - - return { planIndex, status: OUTCOME_STATUS.SKIPPED }; - } - - await atomicWrite(filePath.output, processedFileBuffer); - - const before = formatBytes(fileSize); - const after = formatBytes(processedFileSize); - - logProgress(getRelativePath(filePath.output), { - type: isOptimized ? LOG_TYPES.SUCCESS : LOG_TYPES.WARNING, - description: `${before} → ${after}. Ratio: ${ratio}%`, - progressBarContainer, - }); - return { after: processedFileSize, before: fileSize, planIndex, status: OUTCOME_STATUS.PROCESSED }; - } catch (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, configPath, isLossless }) { - const imageMetadata = await parseImageMetadata(fileBuffer); - const format = imageMetadata.format; - - if (!format) { - throw new Error('Unable to read the image format. Check that the file is a valid, supported image.'); - } - - const processByFormat = PROCESS_BY_FORMAT.get(format); - if (!processByFormat) { - throw new Error(`Unsupported image format: "${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 - - if (!isLossless) { - return sharpImage - .jpeg(config?.jpeg?.lossy || {}) - .toBuffer(); - } - - const inputBuffer = await sharpImage - .toColorspace('srgb') // Replace colorspace (guetzli works only with sRGB) - .jpeg({ quality: 100, optimizeCoding: false }) // Applying maximum quality to minimize losses during image processing with sharp - .toBuffer(); - - const commandOptions = [ - guetzliRunner, - ...optionsToArguments({ - options: config?.jpeg?.lossless || {}, - }), - ]; +import sharp from './lib/sharp.js'; - return pipe({ - command: nodeExecutable, - commandOptions, - inputBuffer, +export async function optimize({ operations, config, configPath, lifecycle, options, reporter }) { + const runtime = createOperationRuntime({ + detect: (_operation, input) => parseImageMetadata(input), + lifecycle, + read: operation => fs.readFile(operation.input), + writer: { write: atomicWrite }, + }); + return runtime.execute({ + onCompleted: reporter.operationCompleted, + operations, + policy: { + complete({ encoded, input, metadata }) { + const ratio = calculateRatio(input.length, encoded.length); + const isChanged = !input.equals(encoded); + const isWrite = ratio > 0 || (metadata.format === 'svg' && isChanged); + return { before: input.length, after: isWrite ? encoded.length : input.length, ratio, write: isWrite }; // eslint-disable-line unicorn/prefer-minimal-ternary + }, + }, + selectCodec(metadata, operation) { + const codec = CODECS.get(metadata.format); + if (!metadata.format) throw new Error('Unable to read the image format. Check that the file is a valid, supported image.'); + if (!codec) throw new Error(`Unsupported image format: "${metadata.format}"`); + return { + resource: ['.jpg', '.jpeg'].includes(path.extname(operation.input).toLowerCase()) && options.isLossless ? 'guetzli' : undefined, + async encode(input) { + try { + return await codec({ config, input, isLossless: options.isLossless, lifecycle }); + } catch (error) { + throw describeCodecFailure({ configPath, error, format: metadata.format, isLossless: options.isLossless, mode: 'optimize' }); + } + }, + }; + }, }); } -function processPng({ fileBuffer, config, isLossless }) { - return sharp(fileBuffer) - .png(isLossless ? config?.png?.lossless : config?.png?.lossy || {}) - .toBuffer(); -} - -function processGif({ fileBuffer, config, isLossless }) { - const operation = createGifOperation( - fileBuffer, - (isLossless ? config?.gif?.lossless : config?.gif?.lossy) || {}, - ); - registerCancellable(operation.terminate, operation.promise); - return operation.promise; -} - -function processSvg({ fileBuffer, config }) { - return Buffer.from( - svgoOptimize( - fileBuffer, - config.svg, - ).data, - ); -} +const CODECS = new Map([ + ['gif', ({ config, input, isLossless, lifecycle }) => { + const operation = createGifOperation(input, (isLossless ? config?.gif?.lossless : config?.gif?.lossy) || {}); + lifecycle.registerCancellable(operation.terminate, operation.promise); + return operation.promise; + }], + ['jpeg', async ({ config, input, isLossless, lifecycle }) => { + const image = sharp(input).rotate(); + if (!isLossless) return image.jpeg(config?.jpeg?.lossy || {}).toBuffer(); + const prepared = await image.toColorspace('srgb').jpeg({ quality: 100, optimizeCoding: false }).toBuffer(); + return encodeWithGuetzli(prepared, config?.jpeg?.lossless || {}, lifecycle); + }], + ['png', ({ config, input, isLossless }) => sharp(input).png(isLossless ? config?.png?.lossless : config?.png?.lossy || {}).toBuffer()], + ['svg', ({ config, input }) => Buffer.from(svgoOptimize(input, config.svg).data)], +]); -function pipe({ command, commandOptions, inputBuffer }) { +function encodeWithGuetzli(input, options, lifecycle) { return new Promise((resolve, reject) => { - const process = spawn(command, commandOptions); - registerChild(process); - - process.stdin.write(inputBuffer); - process.stdin.end(); - - const stdoutChunks = []; - process.stdout.on('data', (chunk) => { - stdoutChunks.push(chunk); - }); - - process.on('error', (error) => { - reject(new Error(`Unable to optimize the image: ${error.message}`)); + const child = spawn(nodeExecutable, [guetzliRunner, ...optionsToArguments({ options })]); + lifecycle.registerChild(child); + child.stdin.end(input); + const stdout = []; + child.stdout.on('data', (chunk) => { + stdout.push(chunk); }); - - process.on('close', (code) => { + child.on('error', error => reject(new Error(`Unable to optimize the image: ${error.message}`))); + child.on('close', (code) => { if (code !== 0) { reject(new Error(`Unable to optimize the image. The encoder exited with code ${code}.`)); return; } - - const processedFileBuffer = Buffer.concat(stdoutChunks); - resolve(processedFileBuffer); + resolve(Buffer.concat(stdout)); }); }); } diff --git a/packages/optimizt/tests/interruption.test.js b/packages/optimizt/tests/interruption.test.js index 92b91ef..9bbb224 100644 --- a/packages/optimizt/tests/interruption.test.js +++ b/packages/optimizt/tests/interruption.test.js @@ -78,19 +78,17 @@ describe.skipIf(isWindows)('interruption', () => { expect(Date.now() - startedAt).toBeLessThan(5000); }, 30_000); - test('every operation is accounted for without being called skipped or failed', async () => { + test('reports completed work without assigning outcomes to abandoned operations', 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).toContain('Interrupted'); + expect(result.stderr).toMatch(new RegExp(String.raw`\d+ of ${IMAGE_COUNT} operations completed`)); + expect(outcomeCount(result.stderr, 'not started')).toBe(0); + expect(outcomeCount(result.stderr, 'failed')).toBe(0); + expect(result.stderr).not.toMatch(/\d+ (?:processed|skipped|failed)(?:,|\r?$)/m); expect(result.stderr).not.toContain('exited with code null'); }, 30_000); }); diff --git a/packages/optimizt/tests/invocation-isolation.test.js b/packages/optimizt/tests/invocation-isolation.test.js new file mode 100644 index 0000000..a649c44 --- /dev/null +++ b/packages/optimizt/tests/invocation-isolation.test.js @@ -0,0 +1,40 @@ +import { afterEach, expect, test, vi } from 'vitest'; + +import optimizt from '../index.js'; +import { createLifecycle } from '../lib/lifecycle.js'; +import { copyFixture, makeTemporaryDirectory, removeTemporaryDirectories } from './helpers/cli.js'; + +afterEach(() => { + vi.restoreAllMocks(); + return removeTemporaryDirectories(); +}); + +const baseOptions = Object.freeze({ + filePrefix: '', fileSuffix: '', isForced: false, isLossless: false, isVerbose: false, + shouldConvertToAvif: false, shouldConvertToWebp: false, shouldUseColor: true, +}); + +test('independent application invocations do not share options or lifecycle state', async () => { + const directory = await makeTemporaryDirectory(); + const first = await copyFixture(directory, 'png-not-optimized.png', 'first.png'); + const second = await copyFixture(directory, 'png-not-optimized.png', 'second.png'); + const originalNoColor = process.env.NO_COLOR; + const stderrWrite = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + const interruptedLifecycle = createLifecycle(); + interruptedLifecycle.interrupt('SIGINT'); + const freshLifecycle = createLifecycle(); + + const interrupted = await optimizt({ inputPaths: [first], lifecycle: interruptedLifecycle, options: baseOptions }); + const completed = await optimizt({ + inputPaths: [second], lifecycle: freshLifecycle, + options: Object.freeze({ ...baseOptions, isVerbose: true, shouldUseColor: false }), + }); + + expect(interrupted.interrupted).toBe(true); + expect(interrupted.completed).toBe(0); + expect(completed.interrupted).toBe(false); + expect(completed.completed).toBe(1); + expect(process.env.NO_COLOR).toBe(originalNoColor); + expect(stderrWrite).toHaveBeenCalled(); + interruptedLifecycle.finish(); +}); diff --git a/packages/optimizt/tests/lifecycle.test.js b/packages/optimizt/tests/lifecycle.test.js index c911c79..3cda8e1 100644 --- a/packages/optimizt/tests/lifecycle.test.js +++ b/packages/optimizt/tests/lifecycle.test.js @@ -1,36 +1,25 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; +import { createLifecycle } from '../lib/lifecycle.js'; 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 = []; +const releases = []; afterEach(() => { - for (const [signal, listener] of installedListeners) process.removeListener(signal, listener); - installedListeners.length = 0; + for (const release of releases) release(); + releases.length = 0; vi.useRealTimers(); 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]); - } - } - +function installedLifecycle(options) { + const lifecycle = createLifecycle(options); + releases.push(lifecycle.install()); return lifecycle; } -function createChildStub() { +function childStub() { return { kill: vi.fn(), once: vi.fn() }; } @@ -38,105 +27,52 @@ function pendingPromise() { return new Promise(() => {}); } -describe('shutdown coordinator', () => { - test.each(['SIGINT', 'SIGTERM'])('a second %s bypasses exit hooks before forcing exit', async (signal) => { - const lifecycle = await loadLifecycle(); - const removeListeners = vi.spyOn(process, 'removeAllListeners').mockReturnValue(process); - const exit = vi.spyOn(process, 'exit').mockImplementation(() => {}); - +describe('invocation lifecycle', () => { + test.each(['SIGINT', 'SIGTERM'])('a second %s forces immediate exit', (signal) => { + const forceExit = vi.fn(); + const lifecycle = installedLifecycle({ forceExit }); process.emit(signal); - expect(removeListeners).not.toHaveBeenCalled(); - expect(exit).not.toHaveBeenCalled(); process.emit(signal); - - expect(removeListeners).toHaveBeenCalledExactlyOnceWith('exit'); - expect(exit).toHaveBeenCalledExactlyOnceWith(interruptStatus[signal]); - expect(removeListeners.mock.invocationCallOrder[0]).toBeLessThan(exit.mock.invocationCallOrder[0]); - lifecycle.finishLifecycle(); + expect(forceExit).toHaveBeenCalledExactlyOnceWith(interruptStatus[signal]); + lifecycle.finish(); }); - test.each(['SIGINT', 'SIGTERM'])('the %s shutdown deadline bypasses exit hooks too', async (signal) => { + test('the shutdown deadline forces exit', () => { vi.useFakeTimers(); - const lifecycle = await loadLifecycle(); - const removeListeners = vi.spyOn(process, 'removeAllListeners').mockReturnValue(process); - const exit = vi.spyOn(process, 'exit').mockImplementation(() => {}); - - process.emit(signal); - vi.advanceTimersByTime(4999); - expect(exit).not.toHaveBeenCalled(); - expect(removeListeners).not.toHaveBeenCalled(); - vi.advanceTimersByTime(1); - - expect(removeListeners).toHaveBeenCalledExactlyOnceWith('exit'); - expect(exit).toHaveBeenCalledExactlyOnceWith(interruptStatus[signal]); - expect(removeListeners.mock.invocationCallOrder[0]).toBeLessThan(exit.mock.invocationCallOrder[0]); - lifecycle.finishLifecycle(); - }); - - 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(); - + const forceExit = vi.fn(); + const lifecycle = installedLifecycle({ forceExit }); process.emit('SIGINT'); - const lateChild = createChildStub(); - lifecycle.registerChild(lateChild); - - expect(lateChild.kill).toHaveBeenCalledWith('SIGTERM'); - expect(lifecycle.finishLifecycle()).toBe(interruptStatus.SIGINT); + vi.advanceTimersByTime(5000); + expect(forceExit).toHaveBeenCalledExactlyOnceWith(interruptStatus.SIGINT); + lifecycle.finish(); }); - test('an interrupt cancels active in-process operations', async () => { - const lifecycle = await loadLifecycle(); - const cancel = vi.fn(); - lifecycle.registerCancellable(cancel, pendingPromise()); - - process.emit('SIGINT'); - - expect(cancel).toHaveBeenCalledOnce(); - expect(lifecycle.finishLifecycle()).toBe(interruptStatus.SIGINT); - }); - - test('an operation registered after the interrupt is cancelled immediately', async () => { - const lifecycle = await loadLifecycle(); - const cancel = vi.fn(); - + test('interrupts active and late resources', () => { + const lifecycle = installedLifecycle(); + const active = childStub(); + const cancelActive = vi.fn(); + lifecycle.registerChild(active); + lifecycle.registerCancellable(cancelActive, pendingPromise()); process.emit('SIGTERM'); - lifecycle.registerCancellable(cancel, pendingPromise()); - - expect(cancel).toHaveBeenCalledOnce(); - expect(lifecycle.finishLifecycle()).toBe(interruptStatus.SIGTERM); + const late = childStub(); + const cancelLate = vi.fn(); + lifecycle.registerChild(late); + lifecycle.registerCancellable(cancelLate, pendingPromise()); + + expect(active.kill).toHaveBeenCalledWith('SIGTERM'); + expect(late.kill).toHaveBeenCalledWith('SIGTERM'); + expect(cancelActive).toHaveBeenCalledOnce(); + expect(cancelLate).toHaveBeenCalledOnce(); + expect(lifecycle.finish()).toBe(interruptStatus.SIGTERM); }); - test('a completed operation is no longer cancelled', async () => { - const lifecycle = await loadLifecycle(); - const cancel = vi.fn(); - lifecycle.registerCancellable(cancel, Promise.resolve()); - await Promise.resolve(); - - process.emit('SIGINT'); - - expect(cancel).not.toHaveBeenCalled(); - expect(lifecycle.finishLifecycle()).toBe(interruptStatus.SIGINT); + test('release removes only this invocation handlers and clears state ownership', () => { + const before = process.listenerCount('SIGINT'); + const lifecycle = createLifecycle(); + const release = lifecycle.install(); + expect(process.listenerCount('SIGINT')).toBe(before + 1); + release(); + expect(process.listenerCount('SIGINT')).toBe(before); + expect(lifecycle.isInterrupted()).toBe(false); }); }); diff --git a/packages/optimizt/tests/show-total.test.js b/packages/optimizt/tests/show-total.test.js deleted file mode 100644 index 8546882..0000000 --- a/packages/optimizt/tests/show-total.test.js +++ /dev/null @@ -1,66 +0,0 @@ -import { beforeEach, expect, test, vi } from 'vitest'; - -import { OUTCOME_STATUS } from '../lib/outcome-status.js'; -import { showTotal } from '../lib/show-total.js'; - -vi.mock('../lib/log.js', () => ({ - log: vi.fn(), - logEmptyLine: vi.fn(), -})); - -import { log } from '../lib/log.js'; - -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'); -}); - -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('summary reports operations left unstarted by interruption', () => { - showTotal(0, 0, [ - { status: OUTCOME_STATUS.UNSTARTED }, - { status: OUTCOME_STATUS.UNSTARTED }, - ]); - expect(log).toHaveBeenCalledWith('2 not started'); -}); - -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' })); -}); - -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' }); -}); - -test('conversion summary reports created bytes', () => { - showTotal(100, 60, [{ status: OUTCOME_STATUS.PROCESSED }], { conversion: true }); - expect(log).toHaveBeenCalledWith('60 Bytes created'); -}); From 11bcd180b66990afe707bf6bd4596b51ba025df7 Mon Sep 17 00:00:00 2001 From: 343dev <343dev@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:06:31 +0700 Subject: [PATCH 3/5] fix(guetzli): harden encoder execution Dispatch from detected image metadata, serialize Guetzli work independently of file extensions, preserve bounded stderr diagnostics, and distinguish spawn, stream, exit, and signal failures. --- packages/optimizt/lib/guetzli.js | 58 +++++++++++++++++++ packages/optimizt/optimize.js | 29 +--------- packages/optimizt/tests/formats.test.js | 12 ++++ packages/optimizt/tests/guetzli.test.js | 55 ++++++++++++++++++ .../optimizt/tests/process-contract.test.js | 15 +++++ 5 files changed, 143 insertions(+), 26 deletions(-) create mode 100644 packages/optimizt/tests/guetzli.test.js diff --git a/packages/optimizt/lib/guetzli.js b/packages/optimizt/lib/guetzli.js index 9fee860..62d1492 100644 --- a/packages/optimizt/lib/guetzli.js +++ b/packages/optimizt/lib/guetzli.js @@ -1,8 +1,66 @@ +import { spawn } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { optionsToArguments } from './options-to-arguments.js'; + +const DIAGNOSTIC_LIMIT = 64 * 1024; + export const nodeExecutable = process.execPath; export const guetzliRunner = path.resolve( path.dirname(fileURLToPath(import.meta.url)), '../vendor/guetzli/cli.js', ); + +export function encodeWithGuetzli(input, options, lifecycle, { + command = nodeExecutable, + runner = guetzliRunner, +} = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, [runner, ...optionsToArguments({ options })]); + lifecycle.registerChild(child); + const stdout = []; + let diagnostic = Buffer.alloc(0); + let isTruncated = false; + let isSettled = false; + let inputError; + + child.stdout.on('data', (chunk) => { + stdout.push(chunk); + }); + child.stderr.on('data', (chunk) => { + diagnostic = Buffer.concat([diagnostic, chunk]); + if (diagnostic.length > DIAGNOSTIC_LIMIT) { + diagnostic = diagnostic.subarray(diagnostic.length - DIAGNOSTIC_LIMIT); + isTruncated = true; + } + }); + child.once('error', error => rejectOnce(new Error(`Guetzli failed to start: ${error.message}`, { cause: error }))); + child.stdin.on('error', (error) => { + inputError = error; + }); + child.once('close', (code, signal) => { + if (isSettled) return; + if (code === 0 && !inputError) { + isSettled = true; + resolve(Buffer.concat(stdout)); + return; + } + if (code === 0) { + rejectOnce(new Error(`Could not send input to Guetzli: ${inputError.message}`, { cause: inputError })); + return; + } + const reason = diagnostic.toString('utf8').trim(); + const exit = signal ? `terminated by signal ${signal}` : `exited with code ${code}`; + const prefix = isTruncated ? '[stderr truncated] ' : ''; + rejectOnce(new Error(`${prefix}${reason || 'Guetzli failed'} (${exit})`)); + }); + child.stdin.end(input); + + function rejectOnce(error) { + if (isSettled) return; + isSettled = true; + reject(error); + } + }); +} diff --git a/packages/optimizt/optimize.js b/packages/optimizt/optimize.js index 8b87f2b..8193d2b 100644 --- a/packages/optimizt/optimize.js +++ b/packages/optimizt/optimize.js @@ -1,6 +1,4 @@ -import { spawn } from 'node:child_process'; import fs from 'node:fs/promises'; -import path from 'node:path'; import { optimize as svgoOptimize } from 'svgo'; @@ -8,9 +6,8 @@ import { atomicWrite } from './lib/atomic-write.js'; import { calculateRatio } from './lib/calculate-ratio.js'; import { describeCodecFailure } from './lib/describe-codec-failure.js'; import { createGifOperation } from './lib/gifsicle.js'; -import { guetzliRunner, nodeExecutable } from './lib/guetzli.js'; +import { encodeWithGuetzli } from './lib/guetzli.js'; import { createOperationRuntime } from './lib/operation-runtime.js'; -import { optionsToArguments } from './lib/options-to-arguments.js'; import { parseImageMetadata } from './lib/parse-image-metadata.js'; import sharp from './lib/sharp.js'; @@ -32,12 +29,12 @@ export async function optimize({ operations, config, configPath, lifecycle, opti return { before: input.length, after: isWrite ? encoded.length : input.length, ratio, write: isWrite }; // eslint-disable-line unicorn/prefer-minimal-ternary }, }, - selectCodec(metadata, operation) { + selectCodec(metadata) { const codec = CODECS.get(metadata.format); if (!metadata.format) throw new Error('Unable to read the image format. Check that the file is a valid, supported image.'); if (!codec) throw new Error(`Unsupported image format: "${metadata.format}"`); return { - resource: ['.jpg', '.jpeg'].includes(path.extname(operation.input).toLowerCase()) && options.isLossless ? 'guetzli' : undefined, + resource: metadata.format === 'jpeg' && options.isLossless ? 'guetzli' : undefined, async encode(input) { try { return await codec({ config, input, isLossless: options.isLossless, lifecycle }); @@ -65,23 +62,3 @@ const CODECS = new Map([ ['png', ({ config, input, isLossless }) => sharp(input).png(isLossless ? config?.png?.lossless : config?.png?.lossy || {}).toBuffer()], ['svg', ({ config, input }) => Buffer.from(svgoOptimize(input, config.svg).data)], ]); - -function encodeWithGuetzli(input, options, lifecycle) { - return new Promise((resolve, reject) => { - const child = spawn(nodeExecutable, [guetzliRunner, ...optionsToArguments({ options })]); - lifecycle.registerChild(child); - child.stdin.end(input); - const stdout = []; - child.stdout.on('data', (chunk) => { - stdout.push(chunk); - }); - child.on('error', error => reject(new Error(`Unable to optimize the image: ${error.message}`))); - child.on('close', (code) => { - if (code !== 0) { - reject(new Error(`Unable to optimize the image. The encoder exited with code ${code}.`)); - return; - } - resolve(Buffer.concat(stdout)); - }); - }); -} diff --git a/packages/optimizt/tests/formats.test.js b/packages/optimizt/tests/formats.test.js index 648ccbd..5db355d 100644 --- a/packages/optimizt/tests/formats.test.js +++ b/packages/optimizt/tests/formats.test.js @@ -36,6 +36,18 @@ describe('optimization by format', () => { await expect(fileSize(imagePath)).resolves.toBeLessThan(sizeBefore); }); + test('dispatches JPEG bytes under another eligible extension to Guetzli', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'jpeg-not-optimized.jpeg', 'renamed.png'); + const sizeBefore = await fileSize(imagePath); + + 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(OPTIMIZED_FORMATS)('optimizes %s in lossless mode', async (_name, fixture) => { const directory = await makeTemporaryDirectory(); const imagePath = await copyFixture(directory, fixture); diff --git a/packages/optimizt/tests/guetzli.test.js b/packages/optimizt/tests/guetzli.test.js new file mode 100644 index 0000000..20a5970 --- /dev/null +++ b/packages/optimizt/tests/guetzli.test.js @@ -0,0 +1,55 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, test } from 'vitest'; + +import { encodeWithGuetzli } from '../lib/guetzli.js'; + +const directories = []; +afterEach(async () => { + await Promise.all(directories.map(directory => fs.rm(directory, { force: true, recursive: true }))); + directories.length = 0; +}); + +const lifecycle = { registerChild() {} }; + +async function runner(source) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'optimizt-guetzli-')); + directories.push(directory); + const runnerPath = path.join(directory, 'runner.mjs'); + await fs.writeFile(runnerPath, source); + return runnerPath; +} + +describe('Guetzli adapter diagnostics', () => { + test('distinguishes non-zero exit and preserves stderr', async () => { + const runnerPath = await runner('process.stderr.write("precise reason"); process.exitCode = 7;'); + await expect(encodeWithGuetzli(Buffer.alloc(0), {}, lifecycle, { runner: runnerPath })) + .rejects.toThrow('precise reason (exited with code 7)'); + }); + + test('bounds verbose stderr and indicates truncation', async () => { + const runnerPath = await runner('process.stderr.write("x".repeat(70000)); process.exitCode = 1;'); + let failure; + try { + await encodeWithGuetzli(Buffer.alloc(0), {}, lifecycle, { runner: runnerPath }); + } catch (error) { + failure = error; + } + expect(failure.message).toContain('[stderr truncated]'); + expect(failure.message.length).toBeLessThan(66_000); + }); + + test('turns an early child exit while writing input into a codec failure', async () => { + const runnerPath = await runner('process.exit(1);'); + const encoding = encodeWithGuetzli(Buffer.alloc(100 * 1024 * 1024), {}, lifecycle, { runner: runnerPath }); + await expect(encoding).rejects.toThrow('Guetzli failed (exited with code 1)'); + }); + + test('distinguishes spawn failures', async () => { + const missingCommand = path.join(os.tmpdir(), 'missing-node'); + const encoding = encodeWithGuetzli(Buffer.alloc(0), {}, lifecycle, { command: missingCommand }); + await expect(encoding).rejects.toThrow('Guetzli failed to start:'); + }); +}); diff --git a/packages/optimizt/tests/process-contract.test.js b/packages/optimizt/tests/process-contract.test.js index 4a49def..dc0dacf 100644 --- a/packages/optimizt/tests/process-contract.test.js +++ b/packages/optimizt/tests/process-contract.test.js @@ -235,6 +235,21 @@ describe('configuration', () => { expect(result.stderr).not.toContain(' at '); }); + test('an invalid Guetzli option preserves the codec validation reason and context', async () => { + const directory = await makeTemporaryDirectory(); + const imagePath = await copyFixture(directory, 'jpeg-not-optimized.jpeg'); + const configPath = path.join(directory, 'config.cjs'); + await fs.writeFile(configPath, 'module.exports = { optimize: { jpeg: { lossless: { quality: 12 } } } };\n'); + + const result = await runCli(['--lossless', '--config', configPath, imagePath]); + + expect(result.code).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain(`Unable to optimize the JPEG image with the lossless profile and configuration file ${configPath}:`); + expect(result.stderr).toContain('quality must be an integer between 84 and 110'); + expect(result.stderr).toContain('exited with code 1'); + }); + test('an invalid conversion option names the target format', async () => { const directory = await makeTemporaryDirectory(); const imagePath = await copyFixture(directory, 'png-not-optimized.png'); From ca2bd9c8bab819844700b6ad786c0586a18eda13 Mon Sep 17 00:00:00 2001 From: 343dev <343dev@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:16:06 +0700 Subject: [PATCH 4/5] docs(optimizt): document runtime hardening --- packages/optimizt/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/optimizt/CHANGELOG.md b/packages/optimizt/CHANGELOG.md index fc6c430..a1aa88d 100644 --- a/packages/optimizt/CHANGELOG.md +++ b/packages/optimizt/CHANGELOG.md @@ -27,7 +27,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. +- Lossless JPEG scheduling now uses the detected image format instead of the file extension. Renamed JPEG files retain Guetzli's single-operation limit. - Interrupt handling now stops new work, terminates cancellable encoders, and reports conventional `SIGINT` and `SIGTERM` exit statuses on POSIX. +- After an interruption, the summary reports completed operations separately. It does not classify abandoned operations as skipped or failed. + +### Fixed + +- Guetzli errors now preserve up to 64 KiB of encoder diagnostics. Messages distinguish startup failures, write failures, exit codes, and termination signals. ## [13.0.0](https://github.com/343dev/optimizt/releases/tag/v13.0.0) - 2026-06-27 From 1f9409c4cf1b18be441cf5ee09e71ad432ae22ca Mon Sep 17 00:00:00 2001 From: 343dev <343dev@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:37:01 +0700 Subject: [PATCH 5/5] fix(ci): run workspace tests sequentially --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 14727a1..db53598 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build": "npm run build --workspace @343dev/optimizt", "check": "npm run build && npm run lint && npm test && npm run license-check", "lint": "npm run lint --workspaces --if-present", - "test": "npm test --workspaces --if-present", + "test": "npm test --workspace @343dev/optimizt && npm test --workspace @343dev/optimizt-gifsicle && npm test --workspace @343dev/optimizt-guetzli && npm test --workspace @343dev/optimizt-sharp", "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\"", "provenance:setup": "npm run setup-emsdk --workspace @343dev/optimizt-guetzli && EMSDK_INSTALL_DIR=$PWD/packages/optimizt-guetzli/.cache/emsdk npm run setup-emsdk --workspace @343dev/optimizt-gifsicle", "provenance:verify": "EMSDK_INSTALL_DIR=$PWD/packages/optimizt-guetzli/.cache/emsdk npm run verify:upstream --workspace @343dev/optimizt-guetzli && EMSDK_INSTALL_DIR=$PWD/packages/optimizt-guetzli/.cache/emsdk npm run verify:dist --workspace @343dev/optimizt-guetzli && EMSDK_INSTALL_DIR=$PWD/packages/optimizt-guetzli/.cache/emsdk npm run verify:upstream --workspace @343dev/optimizt-gifsicle && EMSDK_INSTALL_DIR=$PWD/packages/optimizt-guetzli/.cache/emsdk npm run verify:patch --workspace @343dev/optimizt-gifsicle && EMSDK_INSTALL_DIR=$PWD/packages/optimizt-guetzli/.cache/emsdk npm run verify:dist --workspace @343dev/optimizt-gifsicle && EMSDK_INSTALL_DIR=$PWD/packages/optimizt-guetzli/.cache/emsdk npm run verify:parity --workspace @343dev/optimizt-gifsicle"