Files
repomix-mirror/tests/shared/errorHandle.test.ts
Kazuki Yamada e5f7a1f311 fix(shared): Address PR review feedback
- shared/errorHandle: recognize duck-typed OperationCancelledError from
  worker boundaries in isRepomixError (it extends RepomixError but the
  name was missing from the structured-clone fallback comparison).
  Add a regression test for the worker-boundary case.

Test improvements per coderabbit / claude review:
- cliReport: assert skill-directory + relative path on the same log line.
- processConcurrency: restore process.versions.bun by removing the property
  when it didn't originally exist, instead of leaving it defined-as-undefined.
- logger: drop the no-op `process.env.REPOMIX_LOG_LEVEL = undefined` (it
  coerces to the string "undefined" and is overwritten by the next delete).
- unifiedWorker: replace the tautological cache test with one that proves
  cache uniqueness via onWorkerTermination cleanup count; add a test for
  task-based inference overriding workerData (bundled-env reuse).
- calculateMetricsWorker: new direct test for the default export's items
  vs. single-mode dispatch — unifiedWorker mocks this module so the branch
  was otherwise untested.
- packRemoteRepositoryTool: hard-code the expected output path instead of
  expect.any(String) to catch arg-swap regressions.
- memoryUtils: tighten getMemoryStats assertions with sanity bounds
  (heapUsed <= heapTotal, rss > 0, heapUsagePercent <= 100) so a
  unit-conversion regression (bytes vs MB) would fail the test.
2026-04-26 22:20:42 +09:00

259 lines
9.3 KiB
TypeScript

import * as v from 'valibot';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
handleError,
OperationCancelledError,
RepomixConfigValidationError,
RepomixError,
rethrowValidationErrorIfSchemaError,
} from '../../src/shared/errorHandle.js';
import { logger, repomixLogLevels } from '../../src/shared/logger.js';
describe('rethrowValidationErrorIfSchemaError', () => {
it('rethrows ValiError as RepomixConfigValidationError with formatted message', () => {
const schema = v.object({ foo: v.string() });
let caught: unknown;
try {
v.parse(schema, { foo: 42 });
} catch (error) {
caught = error;
}
expect(() => rethrowValidationErrorIfSchemaError(caught, 'Invalid config')).toThrow(RepomixConfigValidationError);
try {
rethrowValidationErrorIfSchemaError(caught, 'Invalid config');
} catch (error) {
expect(error).toBeInstanceOf(RepomixConfigValidationError);
expect((error as Error).message).toContain('Invalid config');
// Valibot path segments are { key } objects — the helper should unwrap them.
expect((error as Error).message).toContain('[foo]');
}
});
it('rethrows ZodError-shaped duck-typed errors as RepomixConfigValidationError', () => {
const zodLike = {
name: 'ZodError',
message: 'Validation failed',
issues: [{ path: ['output', 'style'], message: 'Invalid enum value' }],
};
expect(() => rethrowValidationErrorIfSchemaError(zodLike, 'Invalid cli arguments')).toThrow(
RepomixConfigValidationError,
);
try {
rethrowValidationErrorIfSchemaError(zodLike, 'Invalid cli arguments');
} catch (error) {
expect((error as Error).message).toContain('[output.style]');
expect((error as Error).message).toContain('Invalid enum value');
}
});
it('handles serialized ValiError across worker boundaries (no instanceof Error)', () => {
// Simulate a structured-clone copy — no Error prototype, plain object.
const workerError = {
name: 'ValiError',
message: 'Invalid type',
issues: [{ path: [{ key: 'input' }, { key: 'maxFileSize' }], message: 'Invalid type' }],
};
expect(() => rethrowValidationErrorIfSchemaError(workerError, 'Invalid config')).toThrow(
RepomixConfigValidationError,
);
});
it('does nothing for non-schema errors', () => {
expect(() => rethrowValidationErrorIfSchemaError(new Error('boom'), 'msg')).not.toThrow();
expect(() => rethrowValidationErrorIfSchemaError(null, 'msg')).not.toThrow();
expect(() => rethrowValidationErrorIfSchemaError('string', 'msg')).not.toThrow();
expect(() => rethrowValidationErrorIfSchemaError({ name: 'Other', issues: [] }, 'msg')).not.toThrow();
});
it('filters out empty path segments so the joined path stays clean', () => {
// A malformed path item (object without `key`) should drop out instead of
// producing a double-dot like `[output..style]`.
const malformed = {
name: 'ValiError',
message: 'Invalid type',
issues: [
{
path: [{ key: 'output' }, { type: 'object' }, { key: 'style' }],
message: 'Invalid type',
},
],
};
try {
rethrowValidationErrorIfSchemaError(malformed, 'Invalid config');
expect.fail('Expected RepomixConfigValidationError to be thrown');
} catch (error) {
expect((error as Error).message).toContain('[output.style]');
expect((error as Error).message).not.toContain('..');
}
});
it('omits the bracketed path when a schema issue has no path', () => {
// Root-level issues carry no path; the formatted message should read as
// `message` rather than `[] message`.
const rootIssue = {
name: 'ValiError',
message: 'Root-level failure',
issues: [{ path: [] as unknown[], message: 'Expected object' }],
};
try {
rethrowValidationErrorIfSchemaError(rootIssue, 'Invalid config');
expect.fail('Expected RepomixConfigValidationError to be thrown');
} catch (error) {
const msg = (error as Error).message;
expect(msg).toContain('Expected object');
expect(msg).not.toContain('[]');
}
});
});
describe('error classes', () => {
it('RepomixError sets name and preserves cause', () => {
const cause = new Error('underlying');
const err = new RepomixError('boom', { cause });
expect(err.name).toBe('RepomixError');
expect(err.message).toBe('boom');
expect(err.cause).toBe(cause);
});
it('RepomixConfigValidationError extends RepomixError', () => {
const err = new RepomixConfigValidationError('invalid');
expect(err).toBeInstanceOf(RepomixError);
expect(err.name).toBe('RepomixConfigValidationError');
});
it('OperationCancelledError uses default message', () => {
const err = new OperationCancelledError();
expect(err).toBeInstanceOf(RepomixError);
expect(err.name).toBe('OperationCancelledError');
expect(err.message).toBe('Operation cancelled');
});
});
describe('handleError', () => {
let errorSpy: ReturnType<typeof vi.spyOn>;
let noteSpy: ReturnType<typeof vi.spyOn>;
let infoSpy: ReturnType<typeof vi.spyOn>;
let debugSpy: ReturnType<typeof vi.spyOn>;
const originalLevel = logger.getLogLevel();
beforeEach(() => {
errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => {});
noteSpy = vi.spyOn(logger, 'note').mockImplementation(() => {});
vi.spyOn(logger, 'log').mockImplementation(() => {});
infoSpy = vi.spyOn(logger, 'info').mockImplementation(() => {});
debugSpy = vi.spyOn(logger, 'debug').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
logger.setLogLevel(originalLevel);
});
it('handles RepomixError with verbose hint at non-debug level', () => {
logger.setLogLevel(repomixLogLevels.INFO);
const err = new RepomixError('config invalid');
handleError(err);
expect(errorSpy).toHaveBeenCalledWith('✖ config invalid');
expect(noteSpy).toHaveBeenCalledWith(expect.stringContaining('--verbose'));
expect(infoSpy).toHaveBeenCalledWith('Need help?');
});
it('handles RepomixError without verbose hint at debug level', () => {
logger.setLogLevel(repomixLogLevels.DEBUG);
const err = new RepomixError('config invalid');
handleError(err);
expect(errorSpy).toHaveBeenCalledWith('✖ config invalid');
// Verbose hint is suppressed at DEBUG level
expect(noteSpy).not.toHaveBeenCalledWith(expect.stringContaining('--verbose'));
});
it('logs cause when RepomixError has one', () => {
logger.setLogLevel(repomixLogLevels.DEBUG);
const cause = new Error('root cause');
const err = new RepomixError('outer', { cause });
handleError(err);
expect(debugSpy).toHaveBeenCalledWith('Caused by:', cause);
});
it('handles unexpected (non-Repomix) Error with stack trace', () => {
logger.setLogLevel(repomixLogLevels.INFO);
const err = new Error('something broke');
handleError(err);
expect(errorSpy).toHaveBeenCalledWith('✖ Unexpected error: something broke');
expect(noteSpy).toHaveBeenCalledWith('Stack trace:', err.stack);
expect(noteSpy).toHaveBeenCalledWith(expect.stringContaining('--verbose'));
});
it('handles duck-typed Error from worker boundary', () => {
logger.setLogLevel(repomixLogLevels.INFO);
// Plain object that quacks like an Error (e.g. structured-clone copy)
const workerError = { name: 'TypeError', message: 'invalid arg', stack: 'stack here' };
handleError(workerError);
expect(errorSpy).toHaveBeenCalledWith('✖ Unexpected error: invalid arg');
});
it('handles duck-typed RepomixError from worker boundary', () => {
logger.setLogLevel(repomixLogLevels.INFO);
const workerError = { name: 'RepomixError', message: 'pack failed' };
handleError(workerError);
expect(errorSpy).toHaveBeenCalledWith('✖ pack failed');
});
it('handles duck-typed RepomixConfigValidationError from worker boundary', () => {
logger.setLogLevel(repomixLogLevels.INFO);
const workerError = { name: 'RepomixConfigValidationError', message: 'bad config' };
handleError(workerError);
expect(errorSpy).toHaveBeenCalledWith('✖ bad config');
});
it('handles duck-typed OperationCancelledError from worker boundary', () => {
// OperationCancelledError extends RepomixError. Without the name being
// listed in isRepomixError's duck-typed comparison, a structured-clone
// copy from a worker would fall into the "Unexpected error" branch
// and surface a noisy stack trace for what is actually a user cancel.
logger.setLogLevel(repomixLogLevels.INFO);
const workerError = { name: 'OperationCancelledError', message: 'Operation cancelled' };
handleError(workerError);
expect(errorSpy).toHaveBeenCalledWith('✖ Operation cancelled');
expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining('Unexpected error'));
});
it('handles unknown non-Error values via inspect()', () => {
logger.setLogLevel(repomixLogLevels.INFO);
handleError('a thrown string');
expect(errorSpy).toHaveBeenCalledWith('✖ An unknown error occurred');
expect(noteSpy).toHaveBeenCalledWith('Error details:', expect.stringContaining('a thrown string'));
});
it('handles null as unknown error', () => {
logger.setLogLevel(repomixLogLevels.INFO);
handleError(null);
expect(errorSpy).toHaveBeenCalledWith('✖ An unknown error occurred');
});
});