mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-30 13:43:26 +08:00
2594 lines
121 KiB
JavaScript
2594 lines
121 KiB
JavaScript
/**
|
|
* Tests for scripts/lib/utils.js
|
|
*
|
|
* Run with: node tests/lib/utils.test.js
|
|
*/
|
|
|
|
const assert = require('assert');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const { spawnSync } = require('child_process');
|
|
|
|
// Import the module
|
|
const utils = require('../../scripts/lib/utils');
|
|
|
|
// Test helper
|
|
function test(name, fn) {
|
|
try {
|
|
fn();
|
|
console.log(` ✓ ${name}`);
|
|
return true;
|
|
} catch (err) {
|
|
console.log(` ✗ ${name}`);
|
|
console.log(` Error: ${err.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Test suite
|
|
function runTests() {
|
|
console.log('\n=== Testing utils.js ===\n');
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
// Platform detection tests
|
|
console.log('Platform Detection:');
|
|
|
|
if (test('isWindows/isMacOS/isLinux are booleans', () => {
|
|
assert.strictEqual(typeof utils.isWindows, 'boolean');
|
|
assert.strictEqual(typeof utils.isMacOS, 'boolean');
|
|
assert.strictEqual(typeof utils.isLinux, 'boolean');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('exactly one platform should be true', () => {
|
|
const platforms = [utils.isWindows, utils.isMacOS, utils.isLinux];
|
|
const trueCount = platforms.filter(p => p).length;
|
|
// Note: Could be 0 on other platforms like FreeBSD
|
|
assert.ok(trueCount <= 1, 'More than one platform is true');
|
|
})) passed++; else failed++;
|
|
|
|
// Directory functions tests
|
|
console.log('\nDirectory Functions:');
|
|
|
|
if (test('getHomeDir returns valid path', () => {
|
|
const home = utils.getHomeDir();
|
|
assert.strictEqual(typeof home, 'string');
|
|
assert.ok(home.length > 0, 'Home dir should not be empty');
|
|
assert.ok(fs.existsSync(home), 'Home dir should exist');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('getClaudeDir returns path under home', () => {
|
|
const claudeDir = utils.getClaudeDir();
|
|
const homeDir = utils.getHomeDir();
|
|
assert.ok(claudeDir.startsWith(homeDir), 'Claude dir should be under home');
|
|
assert.ok(claudeDir.includes('.claude'), 'Should contain .claude');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('getSessionsDir returns path under Claude dir', () => {
|
|
const sessionsDir = utils.getSessionsDir();
|
|
const claudeDir = utils.getClaudeDir();
|
|
assert.ok(sessionsDir.startsWith(claudeDir), 'Sessions should be under Claude dir');
|
|
assert.ok(sessionsDir.endsWith(path.join('.claude', 'session-data')) || sessionsDir.endsWith('/.claude/session-data'), 'Should use canonical session-data directory');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('getSessionSearchDirs includes canonical and legacy paths', () => {
|
|
const searchDirs = utils.getSessionSearchDirs();
|
|
assert.strictEqual(searchDirs[0], utils.getSessionsDir(), 'Canonical session dir should be searched first');
|
|
assert.strictEqual(searchDirs[1], utils.getLegacySessionsDir(), 'Legacy session dir should be searched second');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('getTempDir returns valid temp directory', () => {
|
|
const tempDir = utils.getTempDir();
|
|
assert.strictEqual(typeof tempDir, 'string');
|
|
assert.ok(tempDir.length > 0, 'Temp dir should not be empty');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('ensureDir creates directory', () => {
|
|
const testDir = path.join(utils.getTempDir(), `utils-test-${Date.now()}`);
|
|
try {
|
|
utils.ensureDir(testDir);
|
|
assert.ok(fs.existsSync(testDir), 'Directory should be created');
|
|
} finally {
|
|
fs.rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// Date/Time functions tests
|
|
console.log('\nDate/Time Functions:');
|
|
|
|
if (test('getDateString returns YYYY-MM-DD format', () => {
|
|
const date = utils.getDateString();
|
|
assert.ok(/^\d{4}-\d{2}-\d{2}$/.test(date), `Expected YYYY-MM-DD, got ${date}`);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('getTimeString returns HH:MM format', () => {
|
|
const time = utils.getTimeString();
|
|
assert.ok(/^\d{2}:\d{2}$/.test(time), `Expected HH:MM, got ${time}`);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('getDateTimeString returns full datetime format', () => {
|
|
const dt = utils.getDateTimeString();
|
|
assert.ok(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(dt), `Expected YYYY-MM-DD HH:MM:SS, got ${dt}`);
|
|
})) passed++; else failed++;
|
|
|
|
// Project name tests
|
|
console.log('\nProject Name Functions:');
|
|
|
|
if (test('getGitRepoName returns string or null', () => {
|
|
const repoName = utils.getGitRepoName();
|
|
assert.ok(repoName === null || typeof repoName === 'string');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('getProjectName returns non-empty string', () => {
|
|
const name = utils.getProjectName();
|
|
assert.ok(name && name.length > 0);
|
|
})) passed++; else failed++;
|
|
|
|
// sanitizeSessionId tests
|
|
console.log('\nsanitizeSessionId:');
|
|
|
|
if (test('sanitizeSessionId strips leading dots', () => {
|
|
assert.strictEqual(utils.sanitizeSessionId('.claude'), 'claude');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('sanitizeSessionId replaces dots and spaces', () => {
|
|
assert.strictEqual(utils.sanitizeSessionId('my.project'), 'my-project');
|
|
assert.strictEqual(utils.sanitizeSessionId('my project'), 'my-project');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('sanitizeSessionId replaces special chars and collapses runs', () => {
|
|
assert.strictEqual(utils.sanitizeSessionId('project@v2'), 'project-v2');
|
|
assert.strictEqual(utils.sanitizeSessionId('a...b'), 'a-b');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('sanitizeSessionId preserves valid chars', () => {
|
|
assert.strictEqual(utils.sanitizeSessionId('my-project_123'), 'my-project_123');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('sanitizeSessionId returns null for empty or punctuation-only values', () => {
|
|
assert.strictEqual(utils.sanitizeSessionId(''), null);
|
|
assert.strictEqual(utils.sanitizeSessionId(null), null);
|
|
assert.strictEqual(utils.sanitizeSessionId(undefined), null);
|
|
assert.strictEqual(utils.sanitizeSessionId('...'), null);
|
|
assert.strictEqual(utils.sanitizeSessionId('…'), null);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('sanitizeSessionId returns stable hashes for non-ASCII values', () => {
|
|
const chinese = utils.sanitizeSessionId('我的项目');
|
|
const cyrillic = utils.sanitizeSessionId('проект');
|
|
const emoji = utils.sanitizeSessionId('🚀🎉');
|
|
assert.ok(/^[a-f0-9]{8}$/.test(chinese), `Expected 8-char hash, got: ${chinese}`);
|
|
assert.ok(/^[a-f0-9]{8}$/.test(cyrillic), `Expected 8-char hash, got: ${cyrillic}`);
|
|
assert.ok(/^[a-f0-9]{8}$/.test(emoji), `Expected 8-char hash, got: ${emoji}`);
|
|
assert.notStrictEqual(chinese, cyrillic);
|
|
assert.notStrictEqual(chinese, emoji);
|
|
assert.strictEqual(utils.sanitizeSessionId('日本語プロジェクト'), utils.sanitizeSessionId('日本語プロジェクト'));
|
|
})) passed++; else failed++;
|
|
|
|
if (test('sanitizeSessionId disambiguates mixed-script names from pure ASCII', () => {
|
|
const mixed = utils.sanitizeSessionId('我的app');
|
|
const mixedTwo = utils.sanitizeSessionId('他的app');
|
|
const pure = utils.sanitizeSessionId('app');
|
|
assert.strictEqual(pure, 'app');
|
|
assert.ok(mixed.startsWith('app-'), `Expected mixed-script prefix, got: ${mixed}`);
|
|
assert.notStrictEqual(mixed, pure);
|
|
assert.notStrictEqual(mixed, mixedTwo);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('sanitizeSessionId is idempotent', () => {
|
|
for (const input of ['.claude', 'my.project', 'project@v2', 'a...b', 'my-project_123']) {
|
|
const once = utils.sanitizeSessionId(input);
|
|
const twice = utils.sanitizeSessionId(once);
|
|
assert.strictEqual(once, twice, `Expected idempotent result for ${input}`);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('sanitizeSessionId avoids Windows reserved device names', () => {
|
|
const con = utils.sanitizeSessionId('CON');
|
|
const aux = utils.sanitizeSessionId('aux');
|
|
assert.ok(con.startsWith('CON-'), `Expected CON to get a suffix, got: ${con}`);
|
|
assert.ok(aux.startsWith('aux-'), `Expected aux to get a suffix, got: ${aux}`);
|
|
assert.notStrictEqual(utils.sanitizeSessionId('COM1'), 'COM1');
|
|
})) passed++; else failed++;
|
|
|
|
// Session ID tests
|
|
console.log('\nSession ID Functions:');
|
|
|
|
if (test('getSessionIdShort falls back to sanitized project name', () => {
|
|
const original = process.env.CLAUDE_SESSION_ID;
|
|
delete process.env.CLAUDE_SESSION_ID;
|
|
try {
|
|
const shortId = utils.getSessionIdShort();
|
|
assert.strictEqual(shortId, utils.sanitizeSessionId(utils.getProjectName()));
|
|
} finally {
|
|
if (original !== undefined) process.env.CLAUDE_SESSION_ID = original;
|
|
else delete process.env.CLAUDE_SESSION_ID;
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('getSessionIdShort returns last 8 characters', () => {
|
|
const original = process.env.CLAUDE_SESSION_ID;
|
|
process.env.CLAUDE_SESSION_ID = 'test-session-abc12345';
|
|
try {
|
|
assert.strictEqual(utils.getSessionIdShort(), 'abc12345');
|
|
} finally {
|
|
if (original) process.env.CLAUDE_SESSION_ID = original;
|
|
else delete process.env.CLAUDE_SESSION_ID;
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('getSessionIdShort handles short session IDs', () => {
|
|
const original = process.env.CLAUDE_SESSION_ID;
|
|
process.env.CLAUDE_SESSION_ID = 'short';
|
|
try {
|
|
assert.strictEqual(utils.getSessionIdShort(), 'short');
|
|
} finally {
|
|
if (original) process.env.CLAUDE_SESSION_ID = original;
|
|
else delete process.env.CLAUDE_SESSION_ID;
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('getSessionIdShort sanitizes explicit fallback parameter', () => {
|
|
if (process.platform === 'win32') {
|
|
console.log(' (skipped — root CWD differs on Windows)');
|
|
return true;
|
|
}
|
|
|
|
const utilsPath = path.join(__dirname, '..', '..', 'scripts', 'lib', 'utils.js');
|
|
const script = `
|
|
const utils = require('${utilsPath.replace(/'/g, "\\'")}');
|
|
process.stdout.write(utils.getSessionIdShort('my.fallback'));
|
|
`;
|
|
const result = spawnSync('node', ['-e', script], {
|
|
encoding: 'utf8',
|
|
cwd: '/',
|
|
env: { ...process.env, CLAUDE_SESSION_ID: '' },
|
|
timeout: 10000
|
|
});
|
|
|
|
assert.strictEqual(result.status, 0, `Expected exit 0, got ${result.status}. stderr: ${result.stderr}`);
|
|
assert.strictEqual(result.stdout, 'my-fallback');
|
|
})) passed++; else failed++;
|
|
|
|
// File operations tests
|
|
console.log('\nFile Operations:');
|
|
|
|
if (test('readFile returns null for non-existent file', () => {
|
|
const content = utils.readFile('/non/existent/file/path.txt');
|
|
assert.strictEqual(content, null);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('writeFile and readFile work together', () => {
|
|
const testFile = path.join(utils.getTempDir(), `utils-test-${Date.now()}.txt`);
|
|
const testContent = 'Hello, World!';
|
|
try {
|
|
utils.writeFile(testFile, testContent);
|
|
const read = utils.readFile(testFile);
|
|
assert.strictEqual(read, testContent);
|
|
} finally {
|
|
fs.unlinkSync(testFile);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('appendFile adds content to file', () => {
|
|
const testFile = path.join(utils.getTempDir(), `utils-test-${Date.now()}.txt`);
|
|
try {
|
|
utils.writeFile(testFile, 'Line 1\n');
|
|
utils.appendFile(testFile, 'Line 2\n');
|
|
const content = utils.readFile(testFile);
|
|
assert.strictEqual(content, 'Line 1\nLine 2\n');
|
|
} finally {
|
|
fs.unlinkSync(testFile);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('replaceInFile replaces text', () => {
|
|
const testFile = path.join(utils.getTempDir(), `utils-test-${Date.now()}.txt`);
|
|
try {
|
|
utils.writeFile(testFile, 'Hello, World!');
|
|
utils.replaceInFile(testFile, /World/, 'Universe');
|
|
const content = utils.readFile(testFile);
|
|
assert.strictEqual(content, 'Hello, Universe!');
|
|
} finally {
|
|
fs.unlinkSync(testFile);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('countInFile counts occurrences', () => {
|
|
const testFile = path.join(utils.getTempDir(), `utils-test-${Date.now()}.txt`);
|
|
try {
|
|
utils.writeFile(testFile, 'foo bar foo baz foo');
|
|
const count = utils.countInFile(testFile, /foo/g);
|
|
assert.strictEqual(count, 3);
|
|
} finally {
|
|
fs.unlinkSync(testFile);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('grepFile finds matching lines', () => {
|
|
const testFile = path.join(utils.getTempDir(), `utils-test-${Date.now()}.txt`);
|
|
try {
|
|
utils.writeFile(testFile, 'line 1 foo\nline 2 bar\nline 3 foo');
|
|
const matches = utils.grepFile(testFile, /foo/);
|
|
assert.strictEqual(matches.length, 2);
|
|
assert.strictEqual(matches[0].lineNumber, 1);
|
|
assert.strictEqual(matches[1].lineNumber, 3);
|
|
} finally {
|
|
fs.unlinkSync(testFile);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// findFiles tests
|
|
console.log('\nfindFiles:');
|
|
|
|
if (test('findFiles returns empty for non-existent directory', () => {
|
|
const results = utils.findFiles('/non/existent/dir', '*.txt');
|
|
assert.strictEqual(results.length, 0);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('findFiles finds matching files', () => {
|
|
const testDir = path.join(utils.getTempDir(), `utils-test-${Date.now()}`);
|
|
try {
|
|
fs.mkdirSync(testDir);
|
|
fs.writeFileSync(path.join(testDir, 'test1.txt'), 'content');
|
|
fs.writeFileSync(path.join(testDir, 'test2.txt'), 'content');
|
|
fs.writeFileSync(path.join(testDir, 'test.md'), 'content');
|
|
|
|
const txtFiles = utils.findFiles(testDir, '*.txt');
|
|
assert.strictEqual(txtFiles.length, 2);
|
|
|
|
const mdFiles = utils.findFiles(testDir, '*.md');
|
|
assert.strictEqual(mdFiles.length, 1);
|
|
} finally {
|
|
fs.rmSync(testDir, { recursive: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// Edge case tests for defensive code
|
|
console.log('\nEdge Cases:');
|
|
|
|
if (test('findFiles returns empty for null/undefined dir', () => {
|
|
assert.deepStrictEqual(utils.findFiles(null, '*.txt'), []);
|
|
assert.deepStrictEqual(utils.findFiles(undefined, '*.txt'), []);
|
|
assert.deepStrictEqual(utils.findFiles('', '*.txt'), []);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('findFiles returns empty for null/undefined pattern', () => {
|
|
assert.deepStrictEqual(utils.findFiles('/tmp', null), []);
|
|
assert.deepStrictEqual(utils.findFiles('/tmp', undefined), []);
|
|
assert.deepStrictEqual(utils.findFiles('/tmp', ''), []);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('findFiles supports maxAge filter', () => {
|
|
const testDir = path.join(utils.getTempDir(), `utils-test-maxage-${Date.now()}`);
|
|
try {
|
|
fs.mkdirSync(testDir);
|
|
fs.writeFileSync(path.join(testDir, 'recent.txt'), 'content');
|
|
const results = utils.findFiles(testDir, '*.txt', { maxAge: 1 });
|
|
assert.strictEqual(results.length, 1);
|
|
assert.ok(results[0].path.endsWith('recent.txt'));
|
|
} finally {
|
|
fs.rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('findFiles supports recursive option', () => {
|
|
const testDir = path.join(utils.getTempDir(), `utils-test-recursive-${Date.now()}`);
|
|
const subDir = path.join(testDir, 'sub');
|
|
try {
|
|
fs.mkdirSync(subDir, { recursive: true });
|
|
fs.writeFileSync(path.join(testDir, 'top.txt'), 'content');
|
|
fs.writeFileSync(path.join(subDir, 'nested.txt'), 'content');
|
|
// Without recursive: only top level
|
|
const shallow = utils.findFiles(testDir, '*.txt', { recursive: false });
|
|
assert.strictEqual(shallow.length, 1);
|
|
// With recursive: finds nested too
|
|
const deep = utils.findFiles(testDir, '*.txt', { recursive: true });
|
|
assert.strictEqual(deep.length, 2);
|
|
} finally {
|
|
fs.rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('countInFile handles invalid regex pattern', () => {
|
|
const testFile = path.join(utils.getTempDir(), `utils-test-${Date.now()}.txt`);
|
|
try {
|
|
utils.writeFile(testFile, 'test content');
|
|
const count = utils.countInFile(testFile, '(unclosed');
|
|
assert.strictEqual(count, 0);
|
|
} finally {
|
|
fs.unlinkSync(testFile);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('countInFile handles non-string non-regex pattern', () => {
|
|
const testFile = path.join(utils.getTempDir(), `utils-test-${Date.now()}.txt`);
|
|
try {
|
|
utils.writeFile(testFile, 'test content');
|
|
const count = utils.countInFile(testFile, 42);
|
|
assert.strictEqual(count, 0);
|
|
} finally {
|
|
fs.unlinkSync(testFile);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('countInFile enforces global flag on RegExp', () => {
|
|
const testFile = path.join(utils.getTempDir(), `utils-test-${Date.now()}.txt`);
|
|
try {
|
|
utils.writeFile(testFile, 'foo bar foo baz foo');
|
|
// RegExp without global flag — countInFile should still count all
|
|
const count = utils.countInFile(testFile, /foo/);
|
|
assert.strictEqual(count, 3);
|
|
} finally {
|
|
fs.unlinkSync(testFile);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('grepFile handles invalid regex pattern', () => {
|
|
const testFile = path.join(utils.getTempDir(), `utils-test-${Date.now()}.txt`);
|
|
try {
|
|
utils.writeFile(testFile, 'test content');
|
|
const matches = utils.grepFile(testFile, '[invalid');
|
|
assert.deepStrictEqual(matches, []);
|
|
} finally {
|
|
fs.unlinkSync(testFile);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('replaceInFile returns false for non-existent file', () => {
|
|
const result = utils.replaceInFile('/non/existent/file.txt', 'foo', 'bar');
|
|
assert.strictEqual(result, false);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('countInFile returns 0 for non-existent file', () => {
|
|
const count = utils.countInFile('/non/existent/file.txt', /foo/g);
|
|
assert.strictEqual(count, 0);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('grepFile returns empty for non-existent file', () => {
|
|
const matches = utils.grepFile('/non/existent/file.txt', /foo/);
|
|
assert.deepStrictEqual(matches, []);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('commandExists rejects unsafe command names', () => {
|
|
assert.strictEqual(utils.commandExists('cmd; rm -rf'), false);
|
|
assert.strictEqual(utils.commandExists('$(whoami)'), false);
|
|
assert.strictEqual(utils.commandExists('cmd && echo hi'), false);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('ensureDir is idempotent', () => {
|
|
const testDir = path.join(utils.getTempDir(), `utils-test-idem-${Date.now()}`);
|
|
try {
|
|
const result1 = utils.ensureDir(testDir);
|
|
const result2 = utils.ensureDir(testDir);
|
|
assert.strictEqual(result1, testDir);
|
|
assert.strictEqual(result2, testDir);
|
|
assert.ok(fs.existsSync(testDir));
|
|
} finally {
|
|
fs.rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// System functions tests
|
|
console.log('\nSystem Functions:');
|
|
|
|
if (test('commandExists finds node', () => {
|
|
const exists = utils.commandExists('node');
|
|
assert.strictEqual(exists, true);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('commandExists returns false for fake command', () => {
|
|
const exists = utils.commandExists('nonexistent_command_12345');
|
|
assert.strictEqual(exists, false);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('runCommand executes simple command', () => {
|
|
const result = utils.runCommand('node --version');
|
|
assert.strictEqual(result.success, true);
|
|
assert.ok(result.output.startsWith('v'), 'Should start with v');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('runCommand handles failed command', () => {
|
|
const result = utils.runCommand('node --invalid-flag-12345');
|
|
assert.strictEqual(result.success, false);
|
|
})) passed++; else failed++;
|
|
|
|
// output() and log() tests
|
|
console.log('\noutput() and log():');
|
|
|
|
if (test('output() writes string to stdout', () => {
|
|
// Capture stdout by temporarily replacing console.log
|
|
let captured = null;
|
|
const origLog = console.log;
|
|
console.log = (v) => { captured = v; };
|
|
try {
|
|
utils.output('hello');
|
|
assert.strictEqual(captured, 'hello');
|
|
} finally {
|
|
console.log = origLog;
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('output() JSON-stringifies objects', () => {
|
|
let captured = null;
|
|
const origLog = console.log;
|
|
console.log = (v) => { captured = v; };
|
|
try {
|
|
utils.output({ key: 'value', num: 42 });
|
|
assert.strictEqual(captured, '{"key":"value","num":42}');
|
|
} finally {
|
|
console.log = origLog;
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('output() JSON-stringifies null (typeof null === "object")', () => {
|
|
let captured = null;
|
|
const origLog = console.log;
|
|
console.log = (v) => { captured = v; };
|
|
try {
|
|
utils.output(null);
|
|
// typeof null === 'object' in JS, so it goes through JSON.stringify
|
|
assert.strictEqual(captured, 'null');
|
|
} finally {
|
|
console.log = origLog;
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('output() handles arrays as objects', () => {
|
|
let captured = null;
|
|
const origLog = console.log;
|
|
console.log = (v) => { captured = v; };
|
|
try {
|
|
utils.output([1, 2, 3]);
|
|
assert.strictEqual(captured, '[1,2,3]');
|
|
} finally {
|
|
console.log = origLog;
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('log() writes to stderr', () => {
|
|
let captured = null;
|
|
const origError = console.error;
|
|
console.error = (v) => { captured = v; };
|
|
try {
|
|
utils.log('test message');
|
|
assert.strictEqual(captured, 'test message');
|
|
} finally {
|
|
console.error = origError;
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// isGitRepo() tests
|
|
console.log('\nisGitRepo():');
|
|
|
|
if (test('isGitRepo returns true in a git repo', () => {
|
|
// We're running from within the ECC repo, so this should be true
|
|
assert.strictEqual(utils.isGitRepo(), true);
|
|
})) passed++; else failed++;
|
|
|
|
// getGitModifiedFiles() tests
|
|
console.log('\ngetGitModifiedFiles():');
|
|
|
|
if (test('getGitModifiedFiles returns an array', () => {
|
|
const files = utils.getGitModifiedFiles();
|
|
assert.ok(Array.isArray(files));
|
|
})) passed++; else failed++;
|
|
|
|
if (test('getGitModifiedFiles filters by regex patterns', () => {
|
|
const files = utils.getGitModifiedFiles(['\\.NONEXISTENT_EXTENSION$']);
|
|
assert.ok(Array.isArray(files));
|
|
assert.strictEqual(files.length, 0);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('getGitModifiedFiles skips invalid patterns', () => {
|
|
// Mix of valid and invalid patterns — should not throw
|
|
const files = utils.getGitModifiedFiles(['(unclosed', '\\.js$', '[invalid']);
|
|
assert.ok(Array.isArray(files));
|
|
})) passed++; else failed++;
|
|
|
|
if (test('getGitModifiedFiles skips non-string patterns', () => {
|
|
const files = utils.getGitModifiedFiles([null, undefined, 42, '', '\\.js$']);
|
|
assert.ok(Array.isArray(files));
|
|
})) passed++; else failed++;
|
|
|
|
// getLearnedSkillsDir() test
|
|
console.log('\ngetLearnedSkillsDir():');
|
|
|
|
if (test('getLearnedSkillsDir returns path under Claude dir', () => {
|
|
const dir = utils.getLearnedSkillsDir();
|
|
assert.ok(dir.includes('.claude'));
|
|
assert.ok(dir.includes('skills'));
|
|
assert.ok(dir.includes('learned'));
|
|
})) passed++; else failed++;
|
|
|
|
// replaceInFile behavior tests
|
|
console.log('\nreplaceInFile (behavior):');
|
|
|
|
if (test('replaces first match when regex has no g flag', () => {
|
|
const testFile = path.join(utils.getTempDir(), `utils-test-${Date.now()}.txt`);
|
|
try {
|
|
utils.writeFile(testFile, 'foo bar foo baz foo');
|
|
utils.replaceInFile(testFile, /foo/, 'qux');
|
|
const content = utils.readFile(testFile);
|
|
// Without g flag, only first 'foo' should be replaced
|
|
assert.strictEqual(content, 'qux bar foo baz foo');
|
|
} finally {
|
|
fs.unlinkSync(testFile);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('replaces all matches when regex has g flag', () => {
|
|
const testFile = path.join(utils.getTempDir(), `utils-test-${Date.now()}.txt`);
|
|
try {
|
|
utils.writeFile(testFile, 'foo bar foo baz foo');
|
|
utils.replaceInFile(testFile, /foo/g, 'qux');
|
|
const content = utils.readFile(testFile);
|
|
assert.strictEqual(content, 'qux bar qux baz qux');
|
|
} finally {
|
|
fs.unlinkSync(testFile);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('replaces with string search (first occurrence)', () => {
|
|
const testFile = path.join(utils.getTempDir(), `utils-test-${Date.now()}.txt`);
|
|
try {
|
|
utils.writeFile(testFile, 'hello world hello');
|
|
utils.replaceInFile(testFile, 'hello', 'goodbye');
|
|
const content = utils.readFile(testFile);
|
|
// String.replace with string search only replaces first
|
|
assert.strictEqual(content, 'goodbye world hello');
|
|
} finally {
|
|
fs.unlinkSync(testFile);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('replaces all occurrences with string when options.all is true', () => {
|
|
const testFile = path.join(utils.getTempDir(), `utils-test-${Date.now()}.txt`);
|
|
try {
|
|
utils.writeFile(testFile, 'hello world hello again hello');
|
|
utils.replaceInFile(testFile, 'hello', 'goodbye', { all: true });
|
|
const content = utils.readFile(testFile);
|
|
assert.strictEqual(content, 'goodbye world goodbye again goodbye');
|
|
} finally {
|
|
fs.unlinkSync(testFile);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('options.all is ignored for regex patterns', () => {
|
|
const testFile = path.join(utils.getTempDir(), `utils-test-${Date.now()}.txt`);
|
|
try {
|
|
utils.writeFile(testFile, 'foo bar foo');
|
|
// all option should be ignored for regex; only g flag matters
|
|
utils.replaceInFile(testFile, /foo/, 'qux', { all: true });
|
|
const content = utils.readFile(testFile);
|
|
assert.strictEqual(content, 'qux bar foo', 'Regex without g should still replace first only');
|
|
} finally {
|
|
fs.unlinkSync(testFile);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('replaces with capture groups', () => {
|
|
const testFile = path.join(utils.getTempDir(), `utils-test-${Date.now()}.txt`);
|
|
try {
|
|
utils.writeFile(testFile, '**Last Updated:** 10:30');
|
|
utils.replaceInFile(testFile, /\*\*Last Updated:\*\*.*/, '**Last Updated:** 14:45');
|
|
const content = utils.readFile(testFile);
|
|
assert.strictEqual(content, '**Last Updated:** 14:45');
|
|
} finally {
|
|
fs.unlinkSync(testFile);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// writeFile edge cases
|
|
console.log('\nwriteFile (edge cases):');
|
|
|
|
if (test('writeFile overwrites existing content', () => {
|
|
const testFile = path.join(utils.getTempDir(), `utils-test-${Date.now()}.txt`);
|
|
try {
|
|
utils.writeFile(testFile, 'original');
|
|
utils.writeFile(testFile, 'replaced');
|
|
const content = utils.readFile(testFile);
|
|
assert.strictEqual(content, 'replaced');
|
|
} finally {
|
|
fs.unlinkSync(testFile);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('writeFile handles unicode content', () => {
|
|
const testFile = path.join(utils.getTempDir(), `utils-test-${Date.now()}.txt`);
|
|
try {
|
|
const unicode = '日本語テスト 🚀 émojis';
|
|
utils.writeFile(testFile, unicode);
|
|
const content = utils.readFile(testFile);
|
|
assert.strictEqual(content, unicode);
|
|
} finally {
|
|
fs.unlinkSync(testFile);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// findFiles with regex special characters in pattern
|
|
console.log('\nfindFiles (regex chars):');
|
|
|
|
if (test('findFiles handles regex special chars in pattern', () => {
|
|
const testDir = path.join(utils.getTempDir(), `utils-test-regex-${Date.now()}`);
|
|
try {
|
|
fs.mkdirSync(testDir);
|
|
// Create files with regex-special characters in names
|
|
fs.writeFileSync(path.join(testDir, 'file(1).txt'), 'content');
|
|
fs.writeFileSync(path.join(testDir, 'file+2.txt'), 'content');
|
|
fs.writeFileSync(path.join(testDir, 'file[3].txt'), 'content');
|
|
|
|
// These patterns should match literally, not as regex metacharacters
|
|
const parens = utils.findFiles(testDir, 'file(1).txt');
|
|
assert.strictEqual(parens.length, 1, 'Should match file(1).txt literally');
|
|
|
|
const plus = utils.findFiles(testDir, 'file+2.txt');
|
|
assert.strictEqual(plus.length, 1, 'Should match file+2.txt literally');
|
|
|
|
const brackets = utils.findFiles(testDir, 'file[3].txt');
|
|
assert.strictEqual(brackets.length, 1, 'Should match file[3].txt literally');
|
|
} finally {
|
|
fs.rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('findFiles wildcard still works with special chars', () => {
|
|
const testDir = path.join(utils.getTempDir(), `utils-test-glob-${Date.now()}`);
|
|
try {
|
|
fs.mkdirSync(testDir);
|
|
fs.writeFileSync(path.join(testDir, 'app(v2).js'), 'content');
|
|
fs.writeFileSync(path.join(testDir, 'app(v3).ts'), 'content');
|
|
|
|
const jsFiles = utils.findFiles(testDir, '*.js');
|
|
assert.strictEqual(jsFiles.length, 1);
|
|
assert.ok(jsFiles[0].path.endsWith('app(v2).js'));
|
|
} finally {
|
|
fs.rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// readStdinJson tests (via subprocess — safe hardcoded inputs)
|
|
// Use execFileSync with input option instead of shell echo|pipe for Windows compat
|
|
console.log('\nreadStdinJson():');
|
|
|
|
const stdinScript = 'const u=require("./scripts/lib/utils");u.readStdinJson({timeoutMs:2000}).then(d=>{process.stdout.write(JSON.stringify(d))})';
|
|
const stdinOpts = { encoding: 'utf8', cwd: path.join(__dirname, '..', '..'), timeout: 5000 };
|
|
|
|
if (test('readStdinJson parses valid JSON from stdin', () => {
|
|
const { execFileSync } = require('child_process');
|
|
const result = execFileSync('node', ['-e', stdinScript], { ...stdinOpts, input: '{"tool_input":{"command":"ls"}}' });
|
|
const parsed = JSON.parse(result);
|
|
assert.deepStrictEqual(parsed, { tool_input: { command: 'ls' } });
|
|
})) passed++; else failed++;
|
|
|
|
if (test('readStdinJson returns {} for invalid JSON', () => {
|
|
const { execFileSync } = require('child_process');
|
|
const result = execFileSync('node', ['-e', stdinScript], { ...stdinOpts, input: 'not json' });
|
|
assert.deepStrictEqual(JSON.parse(result), {});
|
|
})) passed++; else failed++;
|
|
|
|
if (test('readStdinJson returns {} for empty stdin', () => {
|
|
const { execFileSync } = require('child_process');
|
|
const result = execFileSync('node', ['-e', stdinScript], { ...stdinOpts, input: '' });
|
|
assert.deepStrictEqual(JSON.parse(result), {});
|
|
})) passed++; else failed++;
|
|
|
|
if (test('readStdinJson handles nested objects', () => {
|
|
const { execFileSync } = require('child_process');
|
|
const result = execFileSync('node', ['-e', stdinScript], { ...stdinOpts, input: '{"a":{"b":1},"c":[1,2]}' });
|
|
const parsed = JSON.parse(result);
|
|
assert.deepStrictEqual(parsed, { a: { b: 1 }, c: [1, 2] });
|
|
})) passed++; else failed++;
|
|
|
|
// grepFile with global regex (regression: g flag causes alternating matches)
|
|
console.log('\ngrepFile (global regex fix):');
|
|
|
|
if (test('grepFile with /g flag finds ALL matching lines (not alternating)', () => {
|
|
const testFile = path.join(utils.getTempDir(), `utils-test-grep-g-${Date.now()}.txt`);
|
|
try {
|
|
// 4 consecutive lines matching the same pattern
|
|
utils.writeFile(testFile, 'match-line\nmatch-line\nmatch-line\nmatch-line');
|
|
// Bug: without fix, /match/g would only find lines 1 and 3 (alternating)
|
|
const matches = utils.grepFile(testFile, /match/g);
|
|
assert.strictEqual(matches.length, 4, `Should find all 4 lines, found ${matches.length}`);
|
|
assert.strictEqual(matches[0].lineNumber, 1);
|
|
assert.strictEqual(matches[1].lineNumber, 2);
|
|
assert.strictEqual(matches[2].lineNumber, 3);
|
|
assert.strictEqual(matches[3].lineNumber, 4);
|
|
} finally {
|
|
fs.unlinkSync(testFile);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('grepFile preserves regex flags other than g (e.g. case-insensitive)', () => {
|
|
const testFile = path.join(utils.getTempDir(), `utils-test-grep-flags-${Date.now()}.txt`);
|
|
try {
|
|
utils.writeFile(testFile, 'FOO\nfoo\nFoO\nbar');
|
|
const matches = utils.grepFile(testFile, /foo/gi);
|
|
assert.strictEqual(matches.length, 3, `Should find 3 case-insensitive matches, found ${matches.length}`);
|
|
} finally {
|
|
fs.unlinkSync(testFile);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// commandExists edge cases
|
|
console.log('\ncommandExists Edge Cases:');
|
|
|
|
if (test('commandExists rejects empty string', () => {
|
|
assert.strictEqual(utils.commandExists(''), false, 'Empty string should not be a valid command');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('commandExists rejects command with spaces', () => {
|
|
assert.strictEqual(utils.commandExists('my command'), false, 'Commands with spaces should be rejected');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('commandExists rejects command with path separators', () => {
|
|
assert.strictEqual(utils.commandExists('/usr/bin/node'), false, 'Commands with / should be rejected');
|
|
assert.strictEqual(utils.commandExists('..\\cmd'), false, 'Commands with \\ should be rejected');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('commandExists rejects shell metacharacters', () => {
|
|
assert.strictEqual(utils.commandExists('cmd;ls'), false, 'Semicolons should be rejected');
|
|
assert.strictEqual(utils.commandExists('$(whoami)'), false, 'Subshell syntax should be rejected');
|
|
assert.strictEqual(utils.commandExists('cmd|cat'), false, 'Pipes should be rejected');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('commandExists allows dots and underscores', () => {
|
|
// These are valid chars per the regex check — the command might not exist
|
|
// but it shouldn't be rejected by the validator
|
|
const dotResult = utils.commandExists('definitely.not.a.real.tool.12345');
|
|
assert.strictEqual(typeof dotResult, 'boolean', 'Should return boolean, not throw');
|
|
})) passed++; else failed++;
|
|
|
|
// findFiles edge cases
|
|
console.log('\nfindFiles Edge Cases:');
|
|
|
|
if (test('findFiles with ? wildcard matches single character', () => {
|
|
const testDir = path.join(utils.getTempDir(), `ff-qmark-${Date.now()}`);
|
|
utils.ensureDir(testDir);
|
|
try {
|
|
fs.writeFileSync(path.join(testDir, 'a1.txt'), '');
|
|
fs.writeFileSync(path.join(testDir, 'b2.txt'), '');
|
|
fs.writeFileSync(path.join(testDir, 'abc.txt'), '');
|
|
|
|
const results = utils.findFiles(testDir, '??.txt');
|
|
const names = results.map(r => path.basename(r.path)).sort();
|
|
assert.deepStrictEqual(names, ['a1.txt', 'b2.txt'], 'Should match exactly 2-char basenames');
|
|
} finally {
|
|
fs.rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('findFiles sorts by mtime (newest first)', () => {
|
|
const testDir = path.join(utils.getTempDir(), `ff-sort-${Date.now()}`);
|
|
utils.ensureDir(testDir);
|
|
try {
|
|
const f1 = path.join(testDir, 'old.txt');
|
|
const f2 = path.join(testDir, 'new.txt');
|
|
fs.writeFileSync(f1, 'old');
|
|
// Set older mtime on first file
|
|
const past = new Date(Date.now() - 60000);
|
|
fs.utimesSync(f1, past, past);
|
|
fs.writeFileSync(f2, 'new');
|
|
|
|
const results = utils.findFiles(testDir, '*.txt');
|
|
assert.strictEqual(results.length, 2);
|
|
assert.ok(
|
|
path.basename(results[0].path) === 'new.txt',
|
|
`Newest file should be first, got ${path.basename(results[0].path)}`
|
|
);
|
|
} finally {
|
|
fs.rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('findFiles with maxAge filters old files', () => {
|
|
const testDir = path.join(utils.getTempDir(), `ff-age-${Date.now()}`);
|
|
utils.ensureDir(testDir);
|
|
try {
|
|
const recent = path.join(testDir, 'recent.txt');
|
|
const old = path.join(testDir, 'old.txt');
|
|
fs.writeFileSync(recent, 'new');
|
|
fs.writeFileSync(old, 'old');
|
|
// Set mtime to 30 days ago
|
|
const past = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
fs.utimesSync(old, past, past);
|
|
|
|
const results = utils.findFiles(testDir, '*.txt', { maxAge: 7 });
|
|
assert.strictEqual(results.length, 1, 'Should only return recent file');
|
|
assert.ok(results[0].path.includes('recent.txt'), 'Should return the recent file');
|
|
} finally {
|
|
fs.rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ensureDir edge cases
|
|
console.log('\nensureDir Edge Cases:');
|
|
|
|
if (test('ensureDir is safe for concurrent calls (EEXIST race)', () => {
|
|
const testDir = path.join(utils.getTempDir(), `ensure-race-${Date.now()}`, 'nested');
|
|
try {
|
|
// Call concurrently — both should succeed without throwing
|
|
const results = [utils.ensureDir(testDir), utils.ensureDir(testDir)];
|
|
assert.strictEqual(results[0], testDir);
|
|
assert.strictEqual(results[1], testDir);
|
|
assert.ok(fs.existsSync(testDir));
|
|
} finally {
|
|
fs.rmSync(path.dirname(testDir), { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('ensureDir returns the directory path', () => {
|
|
const testDir = path.join(utils.getTempDir(), `ensure-ret-${Date.now()}`);
|
|
try {
|
|
const result = utils.ensureDir(testDir);
|
|
assert.strictEqual(result, testDir, 'Should return the directory path');
|
|
} finally {
|
|
fs.rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// runCommand edge cases
|
|
console.log('\nrunCommand Edge Cases:');
|
|
|
|
if (test('runCommand returns trimmed output', () => {
|
|
// Windows echo includes quotes in output, use node to ensure consistent behavior
|
|
const result = utils.runCommand('node -e "process.stdout.write(\' hello \')"');
|
|
assert.strictEqual(result.success, true);
|
|
assert.strictEqual(result.output, 'hello', 'Should trim leading/trailing whitespace');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('runCommand captures stderr on failure', () => {
|
|
const result = utils.runCommand('node -e "process.exit(1)"');
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(typeof result.output === 'string', 'Output should be a string on failure');
|
|
})) passed++; else failed++;
|
|
|
|
// getGitModifiedFiles edge cases
|
|
console.log('\ngetGitModifiedFiles Edge Cases:');
|
|
|
|
if (test('getGitModifiedFiles returns array with empty patterns', () => {
|
|
const files = utils.getGitModifiedFiles([]);
|
|
assert.ok(Array.isArray(files), 'Should return array');
|
|
})) passed++; else failed++;
|
|
|
|
// replaceInFile edge cases
|
|
console.log('\nreplaceInFile Edge Cases:');
|
|
|
|
if (test('replaceInFile with regex capture groups works correctly', () => {
|
|
const testFile = path.join(utils.getTempDir(), `replace-capture-${Date.now()}.txt`);
|
|
try {
|
|
utils.writeFile(testFile, 'version: 1.0.0');
|
|
const result = utils.replaceInFile(testFile, /version: (\d+)\.(\d+)\.(\d+)/, 'version: $1.$2.99');
|
|
assert.strictEqual(result, true);
|
|
assert.strictEqual(utils.readFile(testFile), 'version: 1.0.99');
|
|
} finally {
|
|
fs.unlinkSync(testFile);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// readStdinJson (function API, not actual stdin — more thorough edge cases)
|
|
console.log('\nreadStdinJson Edge Cases:');
|
|
|
|
if (test('readStdinJson type check: returns a Promise', () => {
|
|
// readStdinJson returns a Promise regardless of stdin state
|
|
const result = utils.readStdinJson({ timeoutMs: 100 });
|
|
assert.ok(result instanceof Promise, 'Should return a Promise');
|
|
// Don't await — just verify it's a Promise type
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 28: readStdinJson maxSize truncation and edge cases ──
|
|
console.log('\nreadStdinJson maxSize truncation:');
|
|
|
|
if (test('readStdinJson maxSize stops accumulating after threshold (chunk-level guard)', () => {
|
|
if (process.platform === 'win32') {
|
|
console.log(' (skipped — stdin chunking behavior differs on Windows)');
|
|
return true;
|
|
}
|
|
const { execFileSync } = require('child_process');
|
|
// maxSize is a chunk-level guard: once data.length >= maxSize, no MORE chunks are added.
|
|
// A single small chunk that arrives when data.length < maxSize is added in full.
|
|
// To test multi-chunk behavior, we send >64KB (Node default highWaterMark=16KB)
|
|
// which should arrive in multiple chunks. With maxSize=100, only the first chunk(s)
|
|
// totaling under 100 bytes should be captured; subsequent chunks are dropped.
|
|
const script = 'const u=require("./scripts/lib/utils");u.readStdinJson({timeoutMs:2000,maxSize:100}).then(d=>{process.stdout.write(JSON.stringify(d))})';
|
|
// Generate 100KB of data (arrives in multiple chunks)
|
|
const bigInput = '{"k":"' + 'X'.repeat(100000) + '"}';
|
|
const result = execFileSync('node', ['-e', script], { ...stdinOpts, input: bigInput });
|
|
// Truncated mid-string → invalid JSON → resolves to {}
|
|
assert.deepStrictEqual(JSON.parse(result), {});
|
|
})) passed++; else failed++;
|
|
|
|
if (test('readStdinJson with maxSize large enough preserves valid JSON', () => {
|
|
const { execFileSync } = require('child_process');
|
|
const script = 'const u=require("./scripts/lib/utils");u.readStdinJson({timeoutMs:2000,maxSize:1024}).then(d=>{process.stdout.write(JSON.stringify(d))})';
|
|
const input = JSON.stringify({ key: 'value' });
|
|
const result = execFileSync('node', ['-e', script], { ...stdinOpts, input });
|
|
assert.deepStrictEqual(JSON.parse(result), { key: 'value' });
|
|
})) passed++; else failed++;
|
|
|
|
if (test('readStdinJson resolves {} for whitespace-only stdin', () => {
|
|
const { execFileSync } = require('child_process');
|
|
const result = execFileSync('node', ['-e', stdinScript], { ...stdinOpts, input: ' \n \t ' });
|
|
// data.trim() is empty → resolves {}
|
|
assert.deepStrictEqual(JSON.parse(result), {});
|
|
})) passed++; else failed++;
|
|
|
|
if (test('readStdinJson handles JSON with trailing whitespace/newlines', () => {
|
|
const { execFileSync } = require('child_process');
|
|
const result = execFileSync('node', ['-e', stdinScript], { ...stdinOpts, input: '{"a":1} \n\n' });
|
|
assert.deepStrictEqual(JSON.parse(result), { a: 1 });
|
|
})) passed++; else failed++;
|
|
|
|
if (test('readStdinJson handles JSON with BOM prefix (returns {})', () => {
|
|
const { execFileSync } = require('child_process');
|
|
// BOM (\uFEFF) before JSON makes it invalid for JSON.parse
|
|
const result = execFileSync('node', ['-e', stdinScript], { ...stdinOpts, input: '\uFEFF{"a":1}' });
|
|
// BOM prefix makes JSON.parse fail → resolve {}
|
|
assert.deepStrictEqual(JSON.parse(result), {});
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 31: ensureDir error propagation ──
|
|
console.log('\nensureDir Error Propagation (Round 31):');
|
|
|
|
if (test('ensureDir wraps non-EEXIST errors with descriptive message', () => {
|
|
// Attempting to create a dir under a file should fail with ENOTDIR, not EEXIST
|
|
const testFile = path.join(utils.getTempDir(), `ensure-err-${Date.now()}.txt`);
|
|
try {
|
|
fs.writeFileSync(testFile, 'blocking file');
|
|
const badPath = path.join(testFile, 'subdir');
|
|
assert.throws(
|
|
() => utils.ensureDir(badPath),
|
|
(err) => err.message.includes('Failed to create directory'),
|
|
'Should throw with descriptive "Failed to create directory" message'
|
|
);
|
|
} finally {
|
|
fs.unlinkSync(testFile);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('ensureDir error includes the directory path', () => {
|
|
const testFile = path.join(utils.getTempDir(), `ensure-err2-${Date.now()}.txt`);
|
|
try {
|
|
fs.writeFileSync(testFile, 'blocker');
|
|
const badPath = path.join(testFile, 'nested', 'dir');
|
|
try {
|
|
utils.ensureDir(badPath);
|
|
assert.fail('Should have thrown');
|
|
} catch (err) {
|
|
assert.ok(err.message.includes(badPath), 'Error should include the target path');
|
|
}
|
|
} finally {
|
|
fs.unlinkSync(testFile);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 31: runCommand stderr preference on failure ──
|
|
console.log('\nrunCommand failure output (Round 31):');
|
|
|
|
if (test('runCommand returns stderr content on failure when stderr exists', () => {
|
|
const result = utils.runCommand('node -e "process.stderr.write(\'custom error\'); process.exit(1)"');
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(result.output.includes('custom error'), 'Should include stderr output');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('runCommand returns error output on failed command', () => {
|
|
// Use an allowed prefix with a nonexistent subcommand to reach execSync
|
|
const result = utils.runCommand('git nonexistent-subcmd-xyz-12345');
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(result.output.length > 0, 'Should have some error output');
|
|
})) passed++; else failed++;
|
|
|
|
// ── runCommand security: allowlist and metacharacter blocking ──
|
|
console.log('\nrunCommand Security (allowlist + metacharacters):');
|
|
|
|
if (test('runCommand blocks disallowed command prefix', () => {
|
|
const result = utils.runCommand('rm -rf /');
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(result.output.includes('unrecognized command prefix'), 'Should mention blocked prefix');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('runCommand blocks curl command', () => {
|
|
const result = utils.runCommand('curl http://example.com');
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(result.output.includes('unrecognized command prefix'));
|
|
})) passed++; else failed++;
|
|
|
|
if (test('runCommand blocks bash command', () => {
|
|
const result = utils.runCommand('bash -c "echo hello"');
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(result.output.includes('unrecognized command prefix'));
|
|
})) passed++; else failed++;
|
|
|
|
if (test('runCommand blocks semicolon command chaining', () => {
|
|
const result = utils.runCommand('git status; echo pwned');
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(result.output.includes('metacharacters not allowed'), 'Should block semicolon chaining');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('runCommand blocks pipe command chaining', () => {
|
|
const result = utils.runCommand('git log | cat');
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(result.output.includes('metacharacters not allowed'), 'Should block pipe chaining');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('runCommand blocks ampersand command chaining', () => {
|
|
const result = utils.runCommand('git status && echo pwned');
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(result.output.includes('metacharacters not allowed'), 'Should block ampersand chaining');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('runCommand blocks dollar sign command substitution', () => {
|
|
const result = utils.runCommand('git log $(whoami)');
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(result.output.includes('metacharacters not allowed'), 'Should block $ substitution');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('runCommand blocks backtick command substitution', () => {
|
|
const result = utils.runCommand('git log `whoami`');
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(result.output.includes('metacharacters not allowed'), 'Should block backtick substitution');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('runCommand allows metacharacters inside double quotes', () => {
|
|
// Semicolon inside quotes should not trigger metacharacter blocking
|
|
const result = utils.runCommand('node -e "console.log(1);process.exit(0)"');
|
|
assert.strictEqual(result.success, true);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('runCommand allows metacharacters inside single quotes', () => {
|
|
const result = utils.runCommand("node -e 'process.exit(0);'");
|
|
assert.strictEqual(result.success, true);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('runCommand blocks unquoted metacharacters alongside quoted ones', () => {
|
|
// Semicolon inside quotes is safe, but && outside is not
|
|
const result = utils.runCommand('git log "safe;part" && echo pwned');
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(result.output.includes('metacharacters not allowed'));
|
|
})) passed++; else failed++;
|
|
|
|
if (test('runCommand blocks prefix without trailing space', () => {
|
|
// "gitconfig" starts with "git" but not "git " — must be blocked
|
|
const result = utils.runCommand('gitconfig --list');
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(result.output.includes('unrecognized command prefix'));
|
|
})) passed++; else failed++;
|
|
|
|
if (test('runCommand allows npx prefix', () => {
|
|
const result = utils.runCommand('npx --version');
|
|
assert.strictEqual(result.success, true);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('runCommand blocks newline command injection', () => {
|
|
const result = utils.runCommand('git status\necho pwned');
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(result.output.includes('metacharacters not allowed'), 'Should block newline injection');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('runCommand blocks $() inside double quotes (shell still evaluates)', () => {
|
|
// $() inside double quotes is still evaluated by the shell, so block $ everywhere
|
|
const result = utils.runCommand('node -e "$(whoami)"');
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(result.output.includes('metacharacters not allowed'), 'Should block $ inside quotes');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('runCommand blocks backtick inside double quotes (shell still evaluates)', () => {
|
|
const result = utils.runCommand('node -e "`whoami`"');
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(result.output.includes('metacharacters not allowed'), 'Should block backtick inside quotes');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('runCommand error message does not leak command string', () => {
|
|
const secret = 'rm secret_password_123';
|
|
const result = utils.runCommand(secret);
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(!result.output.includes('secret_password_123'), 'Should not leak command contents');
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 31: getGitModifiedFiles with empty patterns ──
|
|
console.log('\ngetGitModifiedFiles empty patterns (Round 31):');
|
|
|
|
if (test('getGitModifiedFiles with empty array returns all modified files', () => {
|
|
// With an empty patterns array, every file should match (no filter applied)
|
|
const withEmpty = utils.getGitModifiedFiles([]);
|
|
const withNone = utils.getGitModifiedFiles();
|
|
// Both should return the same list (no filtering)
|
|
assert.deepStrictEqual(withEmpty, withNone,
|
|
'Empty patterns array should behave same as no patterns');
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 33: readStdinJson error event handling ──
|
|
console.log('\nreadStdinJson error event (Round 33):');
|
|
|
|
if (test('readStdinJson resolves {} when stdin emits error (via broken pipe)', () => {
|
|
// Spawn a subprocess that reads from stdin, but close the pipe immediately
|
|
// to trigger an error or early-end condition
|
|
const { execFileSync } = require('child_process');
|
|
const script = 'const u=require("./scripts/lib/utils");u.readStdinJson({timeoutMs:2000}).then(d=>{process.stdout.write(JSON.stringify(d))})';
|
|
// Pipe stdin from /dev/null — this sends EOF immediately (no data)
|
|
const result = execFileSync('node', ['-e', script], {
|
|
encoding: 'utf8',
|
|
input: '', // empty stdin triggers 'end' with empty data
|
|
timeout: 5000,
|
|
cwd: path.join(__dirname, '..', '..'),
|
|
});
|
|
const parsed = JSON.parse(result);
|
|
assert.deepStrictEqual(parsed, {}, 'Should resolve to {} for empty stdin (end event path)');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('readStdinJson error handler is guarded by settled flag', () => {
|
|
// If 'end' fires first setting settled=true, then a late 'error' should be ignored
|
|
// We test this by verifying the code structure works: send valid JSON, the end event
|
|
// fires, settled=true, any late error is safely ignored
|
|
const { execFileSync } = require('child_process');
|
|
const script = 'const u=require("./scripts/lib/utils");u.readStdinJson({timeoutMs:2000}).then(d=>{process.stdout.write(JSON.stringify(d))})';
|
|
const result = execFileSync('node', ['-e', script], {
|
|
encoding: 'utf8',
|
|
input: '{"test":"settled-guard"}',
|
|
timeout: 5000,
|
|
cwd: path.join(__dirname, '..', '..'),
|
|
});
|
|
const parsed = JSON.parse(result);
|
|
assert.strictEqual(parsed.test, 'settled-guard', 'Should parse normally when end fires first');
|
|
})) passed++; else failed++;
|
|
|
|
// replaceInFile returns false when write fails (e.g., read-only file)
|
|
if (test('replaceInFile returns false on write failure (read-only file)', () => {
|
|
if (process.platform === 'win32' || process.getuid?.() === 0) {
|
|
console.log(' (skipped — chmod ineffective on Windows/root)');
|
|
return;
|
|
}
|
|
const testDir = path.join(utils.getTempDir(), `utils-test-readonly-${Date.now()}`);
|
|
fs.mkdirSync(testDir, { recursive: true });
|
|
const filePath = path.join(testDir, 'readonly.txt');
|
|
try {
|
|
fs.writeFileSync(filePath, 'hello world', 'utf8');
|
|
fs.chmodSync(filePath, 0o444);
|
|
const result = utils.replaceInFile(filePath, 'hello', 'goodbye');
|
|
assert.strictEqual(result, false, 'Should return false when file is read-only');
|
|
// Verify content unchanged
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
assert.strictEqual(content, 'hello world', 'Original content should be preserved');
|
|
} finally {
|
|
fs.chmodSync(filePath, 0o644);
|
|
fs.rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 69: getGitModifiedFiles with ALL invalid patterns ──
|
|
console.log('\ngetGitModifiedFiles all-invalid patterns (Round 69):');
|
|
|
|
if (test('getGitModifiedFiles with all-invalid patterns skips filtering (returns all files)', () => {
|
|
// When every pattern is invalid regex, compiled.length === 0 at line 386,
|
|
// so the filtering is skipped entirely and all modified files are returned.
|
|
// This differs from the mixed-valid test where at least one pattern compiles.
|
|
const allInvalid = utils.getGitModifiedFiles(['(unclosed', '[bad', '**invalid']);
|
|
const unfiltered = utils.getGitModifiedFiles();
|
|
// Both should return the same list — all-invalid patterns = no filtering
|
|
assert.deepStrictEqual(allInvalid, unfiltered,
|
|
'All-invalid patterns should return same result as no patterns (no filtering)');
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 71: findFiles recursive scan skips unreadable subdirectory ──
|
|
console.log('\nRound 71: findFiles (unreadable subdirectory in recursive scan):');
|
|
|
|
if (test('findFiles recursive scan skips unreadable subdirectory silently', () => {
|
|
if (process.platform === 'win32' || process.getuid?.() === 0) {
|
|
console.log(' (skipped — chmod ineffective on Windows/root)');
|
|
return;
|
|
}
|
|
const tmpDir = path.join(utils.getTempDir(), `ecc-findfiles-r71-${Date.now()}`);
|
|
const readableSubdir = path.join(tmpDir, 'readable');
|
|
const unreadableSubdir = path.join(tmpDir, 'unreadable');
|
|
fs.mkdirSync(readableSubdir, { recursive: true });
|
|
fs.mkdirSync(unreadableSubdir, { recursive: true });
|
|
|
|
// Create files in both subdirectories
|
|
fs.writeFileSync(path.join(readableSubdir, 'found.txt'), 'data');
|
|
fs.writeFileSync(path.join(unreadableSubdir, 'hidden.txt'), 'data');
|
|
|
|
// Make the subdirectory unreadable — readdirSync will throw EACCES
|
|
fs.chmodSync(unreadableSubdir, 0o000);
|
|
|
|
try {
|
|
const results = utils.findFiles(tmpDir, '*.txt', { recursive: true });
|
|
// Should find the readable file but silently skip the unreadable dir
|
|
assert.ok(results.length >= 1, 'Should find at least the readable file');
|
|
const paths = results.map(r => r.path);
|
|
assert.ok(paths.some(p => p.includes('found.txt')), 'Should find readable/found.txt');
|
|
assert.ok(!paths.some(p => p.includes('hidden.txt')), 'Should not find unreadable/hidden.txt');
|
|
} finally {
|
|
fs.chmodSync(unreadableSubdir, 0o755);
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 79: countInFile with valid string pattern ──
|
|
console.log('\nRound 79: countInFile (valid string pattern):');
|
|
|
|
if (test('countInFile counts occurrences using a plain string pattern', () => {
|
|
const testFile = path.join(utils.getTempDir(), `utils-test-count-str-${Date.now()}.txt`);
|
|
try {
|
|
utils.writeFile(testFile, 'apple banana apple cherry apple');
|
|
// Pass a plain string (not RegExp) — exercises typeof pattern === 'string'
|
|
// branch at utils.js:441-442 which creates new RegExp(pattern, 'g')
|
|
const count = utils.countInFile(testFile, 'apple');
|
|
assert.strictEqual(count, 3, 'String pattern should count all occurrences');
|
|
} finally {
|
|
fs.unlinkSync(testFile);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 79: grepFile with valid string pattern ──
|
|
console.log('\nRound 79: grepFile (valid string pattern):');
|
|
|
|
if (test('grepFile finds matching lines using a plain string pattern', () => {
|
|
const testFile = path.join(utils.getTempDir(), `utils-test-grep-str-${Date.now()}.txt`);
|
|
try {
|
|
utils.writeFile(testFile, 'line1 alpha\nline2 beta\nline3 alpha\nline4 gamma');
|
|
// Pass a plain string (not RegExp) — exercises the else branch
|
|
// at utils.js:468-469 which creates new RegExp(pattern)
|
|
const matches = utils.grepFile(testFile, 'alpha');
|
|
assert.strictEqual(matches.length, 2, 'String pattern should find 2 matching lines');
|
|
assert.strictEqual(matches[0].lineNumber, 1, 'First match at line 1');
|
|
assert.strictEqual(matches[1].lineNumber, 3, 'Second match at line 3');
|
|
assert.ok(matches[0].content.includes('alpha'), 'Content should include pattern');
|
|
} finally {
|
|
fs.unlinkSync(testFile);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 84: findFiles inner statSync catch (TOCTOU — broken symlink) ──
|
|
console.log('\nRound 84: findFiles (inner statSync catch — broken symlink):');
|
|
|
|
if (test('findFiles skips broken symlinks that match the pattern', () => {
|
|
// findFiles at utils.js:170-173: readdirSync returns entries including broken
|
|
// symlinks (entry.isFile() returns false for broken symlinks, but the test also
|
|
// verifies the overall robustness). On some systems, broken symlinks can be
|
|
// returned by readdirSync and pass through isFile() depending on the driver.
|
|
// More importantly: if statSync throws inside the inner loop, catch continues.
|
|
//
|
|
// To reliably trigger the statSync catch: create a real file, list it, then
|
|
// simulate the race. Since we can't truly race, we use a broken symlink which
|
|
// will at minimum verify the function doesn't crash on unusual dir entries.
|
|
const tmpDir = path.join(utils.getTempDir(), `ecc-r84-findfiles-toctou-${Date.now()}`);
|
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
|
|
// Create a real file and a broken symlink, both matching *.txt
|
|
const realFile = path.join(tmpDir, 'real.txt');
|
|
fs.writeFileSync(realFile, 'content');
|
|
const brokenLink = path.join(tmpDir, 'broken.txt');
|
|
fs.symlinkSync('/nonexistent/path/does/not/exist', brokenLink);
|
|
|
|
try {
|
|
const results = utils.findFiles(tmpDir, '*.txt');
|
|
// The real file should be found; the broken symlink should be skipped
|
|
const paths = results.map(r => r.path);
|
|
assert.ok(paths.some(p => p.includes('real.txt')), 'Should find the real file');
|
|
assert.ok(!paths.some(p => p.includes('broken.txt')),
|
|
'Should not include broken symlink in results');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 85: getSessionIdShort fallback parameter ──
|
|
console.log('\ngetSessionIdShort fallback (Round 85):');
|
|
|
|
if (test('getSessionIdShort uses fallback when getProjectName returns null (CWD at root)', () => {
|
|
if (process.platform === 'win32') {
|
|
console.log(' (skipped — root CWD differs on Windows)');
|
|
return;
|
|
}
|
|
// Spawn a subprocess at CWD=/ with CLAUDE_SESSION_ID empty.
|
|
// At /, git rev-parse --show-toplevel fails → getGitRepoName() = null.
|
|
// path.basename('/') = '' → '' || null = null → getProjectName() = null.
|
|
// So getSessionIdShort('my-custom-fallback') = null || 'my-custom-fallback'.
|
|
const utilsPath = path.join(__dirname, '..', '..', 'scripts', 'lib', 'utils.js');
|
|
const script = `
|
|
const utils = require('${utilsPath.replace(/'/g, "\\'")}');
|
|
process.stdout.write(utils.getSessionIdShort('my-custom-fallback'));
|
|
`;
|
|
const { spawnSync } = require('child_process');
|
|
const result = spawnSync('node', ['-e', script], {
|
|
encoding: 'utf8',
|
|
cwd: '/',
|
|
env: { ...process.env, CLAUDE_SESSION_ID: '' },
|
|
timeout: 10000
|
|
});
|
|
assert.strictEqual(result.status, 0, `Should exit 0, got status ${result.status}. stderr: ${result.stderr}`);
|
|
assert.strictEqual(result.stdout, 'my-custom-fallback',
|
|
`At CWD=/ with no session ID, should use the fallback parameter. Got: "${result.stdout}"`);
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 88: replaceInFile with empty replacement (deletion) ──
|
|
console.log('\nRound 88: replaceInFile with empty replacement string (deletion):');
|
|
if (test('replaceInFile with empty string replacement deletes matched text', () => {
|
|
const tmpDir = path.join(utils.getTempDir(), `ecc-r88-replace-empty-${Date.now()}`);
|
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
const tmpFile = path.join(tmpDir, 'delete-test.txt');
|
|
try {
|
|
fs.writeFileSync(tmpFile, 'hello REMOVE_ME world');
|
|
const result = utils.replaceInFile(tmpFile, 'REMOVE_ME ', '');
|
|
assert.strictEqual(result, true, 'Should return true on successful replacement');
|
|
const content = fs.readFileSync(tmpFile, 'utf8');
|
|
assert.strictEqual(content, 'hello world',
|
|
'Empty replacement should delete the matched text');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 88: countInFile with valid file but zero matches ──
|
|
console.log('\nRound 88: countInFile with existing file but non-matching pattern:');
|
|
if (test('countInFile returns 0 for valid file with no pattern matches', () => {
|
|
const tmpDir = path.join(utils.getTempDir(), `ecc-r88-count-zero-${Date.now()}`);
|
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
const tmpFile = path.join(tmpDir, 'no-match.txt');
|
|
try {
|
|
fs.writeFileSync(tmpFile, 'apple banana cherry');
|
|
const count = utils.countInFile(tmpFile, 'ZZZZNOTHERE');
|
|
assert.strictEqual(count, 0,
|
|
'Should return 0 when regex matches nothing in existing file');
|
|
const countRegex = utils.countInFile(tmpFile, /ZZZZNOTHERE/g);
|
|
assert.strictEqual(countRegex, 0,
|
|
'Should return 0 for RegExp with no matches in existing file');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 92: countInFile with object pattern type ──
|
|
console.log('\nRound 92: countInFile (non-string non-RegExp pattern):');
|
|
|
|
if (test('countInFile returns 0 for object pattern (neither string nor RegExp)', () => {
|
|
// utils.js line 443-444: The else branch returns 0 when pattern is
|
|
// not instanceof RegExp and typeof !== 'string'. An object like {invalid: true}
|
|
// triggers this early return without throwing.
|
|
const testFile = path.join(utils.getTempDir(), `utils-test-obj-pattern-${Date.now()}.txt`);
|
|
try {
|
|
utils.writeFile(testFile, 'some test content to match against');
|
|
const count = utils.countInFile(testFile, { invalid: 'object' });
|
|
assert.strictEqual(count, 0, 'Object pattern should return 0');
|
|
} finally {
|
|
try { fs.unlinkSync(testFile); } catch { /* best-effort */ }
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 93: countInFile with /pattern/i (g flag appended) ──
|
|
console.log('\nRound 93: countInFile (case-insensitive RegExp, g flag auto-appended):');
|
|
|
|
if (test('countInFile with /pattern/i appends g flag and counts case-insensitively', () => {
|
|
// utils.js line 440: pattern.flags = 'i', 'i'.includes('g') → false,
|
|
// so new RegExp(source, 'i' + 'g') → /pattern/ig
|
|
const testFile = path.join(utils.getTempDir(), `utils-test-ci-flag-${Date.now()}.txt`);
|
|
try {
|
|
utils.writeFile(testFile, 'Foo foo FOO fOo bar baz');
|
|
const count = utils.countInFile(testFile, /foo/i);
|
|
assert.strictEqual(count, 4,
|
|
'Case-insensitive regex with auto-appended g should match all 4 occurrences');
|
|
} finally {
|
|
try { fs.unlinkSync(testFile); } catch { /* best-effort */ }
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 93: countInFile with /pattern/gi (g flag already present) ──
|
|
console.log('\nRound 93: countInFile (case-insensitive RegExp, g flag preserved):');
|
|
|
|
if (test('countInFile with /pattern/gi preserves existing flags and counts correctly', () => {
|
|
// utils.js line 440: pattern.flags = 'gi', 'gi'.includes('g') → true,
|
|
// so new RegExp(source, 'gi') — flags preserved unchanged
|
|
const testFile = path.join(utils.getTempDir(), `utils-test-gi-flag-${Date.now()}.txt`);
|
|
try {
|
|
utils.writeFile(testFile, 'Foo foo FOO fOo bar baz');
|
|
const count = utils.countInFile(testFile, /foo/gi);
|
|
assert.strictEqual(count, 4,
|
|
'Case-insensitive regex with pre-existing g should match all 4 occurrences');
|
|
} finally {
|
|
try { fs.unlinkSync(testFile); } catch { /* best-effort */ }
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 95: countInFile with regex alternation (no g flag) ──
|
|
console.log('\nRound 95: countInFile (regex alternation without g flag):');
|
|
|
|
if (test('countInFile with /apple|banana/ (alternation, no g) counts all matches', () => {
|
|
const tmpDir = path.join(utils.getTempDir(), `ecc-r95-alternation-${Date.now()}`);
|
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
const testFile = path.join(tmpDir, 'alternation.txt');
|
|
try {
|
|
utils.writeFile(testFile, 'apple banana apple cherry banana apple');
|
|
// /apple|banana/ has alternation but no g flag — countInFile should auto-append g
|
|
const count = utils.countInFile(testFile, /apple|banana/);
|
|
assert.strictEqual(count, 5,
|
|
'Should find 3 apples + 2 bananas = 5 total (g flag auto-appended to alternation regex)');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 97: getSessionIdShort with whitespace-only CLAUDE_SESSION_ID ──
|
|
console.log('\nRound 97: getSessionIdShort (whitespace-only session ID):');
|
|
|
|
if (test('getSessionIdShort sanitizes whitespace-only CLAUDE_SESSION_ID to fallback', () => {
|
|
if (process.platform === 'win32') {
|
|
console.log(' (skipped — root CWD differs on Windows)');
|
|
return true;
|
|
}
|
|
|
|
const utilsPath = path.join(__dirname, '..', '..', 'scripts', 'lib', 'utils.js');
|
|
const script = `
|
|
const utils = require('${utilsPath.replace(/'/g, "\\'")}');
|
|
process.stdout.write(utils.getSessionIdShort('fallback'));
|
|
`;
|
|
const result = spawnSync('node', ['-e', script], {
|
|
encoding: 'utf8',
|
|
cwd: '/',
|
|
env: { ...process.env, CLAUDE_SESSION_ID: ' ' },
|
|
timeout: 10000
|
|
});
|
|
|
|
assert.strictEqual(result.status, 0, `Expected exit 0, got ${result.status}. stderr: ${result.stderr}`);
|
|
assert.strictEqual(result.stdout, 'fallback');
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 97: countInFile with same RegExp object called twice (lastIndex reuse) ──
|
|
console.log('\nRound 97: countInFile (RegExp lastIndex reuse validation):');
|
|
|
|
if (test('countInFile returns consistent count when same RegExp object is reused', () => {
|
|
// utils.js lines 438-440: Always creates a new RegExp to prevent lastIndex
|
|
// state bugs. Without this defense, a global regex's lastIndex would persist
|
|
// between calls, causing alternating match/miss behavior.
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r97-lastindex-'));
|
|
const testFile = path.join(tmpDir, 'test.txt');
|
|
try {
|
|
fs.writeFileSync(testFile, 'foo bar foo baz foo\nfoo again foo');
|
|
const sharedRegex = /foo/g;
|
|
// First call
|
|
const count1 = utils.countInFile(testFile, sharedRegex);
|
|
// Second call with SAME regex object — would fail without defensive new RegExp
|
|
const count2 = utils.countInFile(testFile, sharedRegex);
|
|
assert.strictEqual(count1, 5, 'First call should find 5 matches');
|
|
assert.strictEqual(count2, 5,
|
|
'Second call with same RegExp should also find 5 (lastIndex reset by defensive code)');
|
|
assert.strictEqual(count1, count2,
|
|
'Both calls must return identical counts (proves lastIndex is not shared)');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 98: findFiles with maxAge: -1 (negative boundary — excludes everything) ──
|
|
console.log('\nRound 98: findFiles (maxAge: -1 — negative boundary excludes all):');
|
|
|
|
if (test('findFiles with maxAge: -1 excludes all files (ageInDays always >= 0)', () => {
|
|
// utils.js line 176-178: `if (maxAge !== null) { ageInDays = ...; if (ageInDays <= maxAge) }`
|
|
// With maxAge: -1, the condition requires ageInDays <= -1. Since ageInDays =
|
|
// (Date.now() - mtimeMs) / 86400000 is always >= 0 for real files, nothing passes.
|
|
// This negative boundary deterministically excludes everything.
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r98-maxage-neg-'));
|
|
try {
|
|
fs.writeFileSync(path.join(tmpDir, 'fresh.txt'), 'created just now');
|
|
const results = utils.findFiles(tmpDir, '*.txt', { maxAge: -1 });
|
|
assert.strictEqual(results.length, 0,
|
|
'maxAge: -1 should exclude all files (ageInDays is always >= 0)');
|
|
// Contrast: maxAge: null (default) should include the file
|
|
const noMaxAge = utils.findFiles(tmpDir, '*.txt');
|
|
assert.strictEqual(noMaxAge.length, 1,
|
|
'No maxAge (null default) should include the file (proving it exists)');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 99: replaceInFile returns true even when pattern not found ──
|
|
console.log('\nRound 99: replaceInFile (no-match still returns true):');
|
|
|
|
if (test('replaceInFile returns true and rewrites file even when search does not match', () => {
|
|
// utils.js lines 405-417: replaceInFile reads content, calls content.replace(search, replace),
|
|
// and writes back the result. When the search pattern doesn't match anything,
|
|
// String.replace() returns the original string unchanged, but the function still
|
|
// writes it back to disk (changing mtime) and returns true. This means callers
|
|
// cannot distinguish "replacement made" from "no match found."
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r99-no-match-'));
|
|
const testFile = path.join(tmpDir, 'test.txt');
|
|
try {
|
|
fs.writeFileSync(testFile, 'hello world');
|
|
const result = utils.replaceInFile(testFile, 'NONEXISTENT_PATTERN', 'replacement');
|
|
assert.strictEqual(result, true,
|
|
'replaceInFile returns true even when pattern is not found (no match guard)');
|
|
const content = fs.readFileSync(testFile, 'utf8');
|
|
assert.strictEqual(content, 'hello world',
|
|
'Content should be unchanged since pattern did not match');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 99: grepFile with CR-only line endings (\r without \n) ──
|
|
console.log('\nRound 99: grepFile (CR-only line endings — classic Mac format):');
|
|
|
|
if (test('grepFile treats CR-only file as a single line (splits on \\n only)', () => {
|
|
// utils.js line 474: `content.split('\\n')` splits only on \\n (LF).
|
|
// A file using \\r (CR) line endings (classic Mac format) has no \\n characters,
|
|
// so split('\\n') returns the entire content as a single element array.
|
|
// This means grepFile reports everything on "line 1" regardless of \\r positions.
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r99-cr-only-'));
|
|
const testFile = path.join(tmpDir, 'cr-only.txt');
|
|
try {
|
|
// Write file with CR-only line endings (no LF)
|
|
fs.writeFileSync(testFile, 'alpha\rbeta\rgamma');
|
|
const matches = utils.grepFile(testFile, 'beta');
|
|
assert.strictEqual(matches.length, 1,
|
|
'Should find exactly 1 match (entire file is one "line")');
|
|
assert.strictEqual(matches[0].lineNumber, 1,
|
|
'Match should be reported on line 1 (no \\n splitting occurred)');
|
|
assert.ok(matches[0].content.includes('\r'),
|
|
'Content should contain \\r characters (unsplit)');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 100: findFiles with both maxAge AND recursive (interaction test) ──
|
|
console.log('\nRound 100: findFiles (maxAge + recursive combined — untested interaction):');
|
|
if (test('findFiles with maxAge AND recursive filters age across subdirectories', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r100-maxage-recur-'));
|
|
const subDir = path.join(tmpDir, 'nested');
|
|
try {
|
|
fs.mkdirSync(subDir);
|
|
// Create files: one in root, one in subdirectory
|
|
const rootFile = path.join(tmpDir, 'root.txt');
|
|
const nestedFile = path.join(subDir, 'nested.txt');
|
|
fs.writeFileSync(rootFile, 'root file');
|
|
fs.writeFileSync(nestedFile, 'nested file');
|
|
|
|
// maxAge: 1 with recursive: true — both files are fresh (ageInDays ≈ 0)
|
|
const results = utils.findFiles(tmpDir, '*.txt', { maxAge: 1, recursive: true });
|
|
assert.strictEqual(results.length, 2,
|
|
'Both root and nested files should match (fresh, maxAge: 1, recursive: true)');
|
|
|
|
// maxAge: -1 with recursive: true — no files should match (age always >= 0)
|
|
const noResults = utils.findFiles(tmpDir, '*.txt', { maxAge: -1, recursive: true });
|
|
assert.strictEqual(noResults.length, 0,
|
|
'maxAge: -1 should exclude all files even in subdirectories');
|
|
|
|
// maxAge: 1 with recursive: false — only root file
|
|
const rootOnly = utils.findFiles(tmpDir, '*.txt', { maxAge: 1, recursive: false });
|
|
assert.strictEqual(rootOnly.length, 1,
|
|
'recursive: false should only find root-level file');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 101: output() with circular reference object throws (no try/catch around JSON.stringify) ──
|
|
console.log('\nRound 101: output() (circular reference — JSON.stringify crash):');
|
|
if (test('output() throws TypeError on circular reference object (JSON.stringify has no try/catch)', () => {
|
|
const circular = { a: 1 };
|
|
circular.self = circular; // Creates circular reference
|
|
|
|
assert.throws(
|
|
() => utils.output(circular),
|
|
{ name: 'TypeError' },
|
|
'JSON.stringify of circular object should throw TypeError (no try/catch in output())'
|
|
);
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 103: countInFile with boolean false pattern (non-string non-RegExp) ──
|
|
console.log('\nRound 103: countInFile (boolean false — explicit type guard returns 0):');
|
|
if (test('countInFile returns 0 for boolean false pattern (else branch at line 443)', () => {
|
|
// utils.js lines 438-444: countInFile checks `instanceof RegExp` then `typeof === "string"`.
|
|
// Boolean `false` fails both checks and falls to the `else return 0` at line 443.
|
|
// This is the correct rejection path for non-string non-RegExp patterns, but was
|
|
// previously untested with boolean specifically (only null, undefined, object tested).
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r103-bool-pattern-'));
|
|
const testFile = path.join(tmpDir, 'test.txt');
|
|
try {
|
|
fs.writeFileSync(testFile, 'false is here\nfalse again\ntrue as well');
|
|
// Even though "false" appears in the content, boolean `false` is rejected by type guard
|
|
const count = utils.countInFile(testFile, false);
|
|
assert.strictEqual(count, 0,
|
|
'Boolean false should return 0 (typeof false === "boolean", not "string")');
|
|
// Contrast: string "false" should match normally
|
|
const stringCount = utils.countInFile(testFile, 'false');
|
|
assert.strictEqual(stringCount, 2,
|
|
'String "false" should match 2 times (proving content exists but type guard blocked boolean)');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 103: grepFile with numeric 0 pattern (implicit RegExp coercion) ──
|
|
console.log('\nRound 103: grepFile (numeric 0 — implicit toString via RegExp constructor):');
|
|
if (test('grepFile with numeric 0 implicitly coerces to /0/ via RegExp constructor', () => {
|
|
// utils.js line 468: grepFile's non-RegExp path does `regex = new RegExp(pattern)`.
|
|
// Unlike countInFile (which has explicit type guards), grepFile passes any value
|
|
// to the RegExp constructor, which calls toString() on it. So new RegExp(0)
|
|
// becomes /0/, and grepFile actually searches for lines containing "0".
|
|
// This contrasts with countInFile(file, 0) which returns 0 (type-rejected).
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r103-grep-numeric-'));
|
|
const testFile = path.join(tmpDir, 'test.txt');
|
|
try {
|
|
fs.writeFileSync(testFile, 'line with 0 zero\nno digit here\n100 bottles');
|
|
const matches = utils.grepFile(testFile, 0);
|
|
assert.strictEqual(matches.length, 2,
|
|
'grepFile(file, 0) should find 2 lines containing "0" (RegExp(0) → /0/)');
|
|
assert.strictEqual(matches[0].lineNumber, 1,
|
|
'First match on line 1 ("line with 0 zero")');
|
|
assert.strictEqual(matches[1].lineNumber, 3,
|
|
'Second match on line 3 ("100 bottles")');
|
|
// Contrast: countInFile with numeric 0 returns 0 (type-rejected)
|
|
const count = utils.countInFile(testFile, 0);
|
|
assert.strictEqual(count, 0,
|
|
'countInFile(file, 0) returns 0 — API inconsistency with grepFile');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 105: grepFile with sticky (y) flag — not stripped, causes stateful .test() ──
|
|
console.log('\nRound 105: grepFile (sticky y flag — not stripped like g, stateful .test() bug):');
|
|
|
|
if (test('grepFile with /pattern/y sticky flag misses lines due to lastIndex state', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r105-grep-sticky-'));
|
|
const testFile = path.join(tmpDir, 'test.txt');
|
|
try {
|
|
fs.writeFileSync(testFile, 'hello world\nhello again\nhello third');
|
|
// grepFile line 466: `pattern.flags.replace('g', '')` strips g but not y.
|
|
// With /hello/y (sticky), .test() advances lastIndex after each successful
|
|
// match. On the next line, .test() starts at lastIndex (not 0), so it fails
|
|
// unless the match happens at that exact position.
|
|
const stickyResults = utils.grepFile(testFile, /hello/y);
|
|
// Without the bug, all 3 lines should match. With sticky flag preserved,
|
|
// line 1 matches (lastIndex advances to 5), line 2 fails (no 'hello' at
|
|
// position 5 of "hello again"), line 3 also likely fails.
|
|
// The g-flag version (properly stripped) should find all 3:
|
|
const globalResults = utils.grepFile(testFile, /hello/g);
|
|
assert.strictEqual(globalResults.length, 3,
|
|
'g-flag regex should find all 3 lines (g is stripped, stateless)');
|
|
// Sticky flag causes fewer matches — demonstrating the bug
|
|
assert.ok(stickyResults.length < 3,
|
|
`Sticky y flag causes stateful .test() — found ${stickyResults.length}/3 lines ` +
|
|
'(y flag not stripped like g, so lastIndex advances between lines)');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 107: grepFile with ^$ pattern — empty line matching after split ──
|
|
console.log('\nRound 107: grepFile (empty line matching — ^$ on split lines, trailing \\n creates extra empty element):');
|
|
if (test('grepFile matches empty lines with ^$ pattern including trailing newline phantom line', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r107-grep-empty-'));
|
|
const testFile = path.join(tmpDir, 'test.txt');
|
|
try {
|
|
// 'line1\n\nline3\n\n'.split('\n') → ['line1','','line3','',''] (5 elements, 3 empty)
|
|
fs.writeFileSync(testFile, 'line1\n\nline3\n\n');
|
|
const results = utils.grepFile(testFile, /^$/);
|
|
assert.strictEqual(results.length, 3,
|
|
'Should match 3 empty lines: line 2, line 4, and trailing phantom line 5');
|
|
assert.strictEqual(results[0].lineNumber, 2, 'First empty line at position 2');
|
|
assert.strictEqual(results[1].lineNumber, 4, 'Second empty line at position 4');
|
|
assert.strictEqual(results[2].lineNumber, 5, 'Third empty line is the trailing phantom from split');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 107: replaceInFile where replacement re-introduces search pattern (single-pass) ──
|
|
console.log('\nRound 107: replaceInFile (replacement contains search pattern — String.replace is single-pass):');
|
|
if (test('replaceInFile does not re-scan replacement text (single-pass, no infinite loop)', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r107-replace-reintr-'));
|
|
const testFile = path.join(tmpDir, 'test.txt');
|
|
try {
|
|
fs.writeFileSync(testFile, 'foo bar baz');
|
|
// Replace "foo" with "foo extra foo" — should only replace the first occurrence
|
|
const result = utils.replaceInFile(testFile, 'foo', 'foo extra foo');
|
|
assert.strictEqual(result, true, 'replaceInFile should return true');
|
|
const content = utils.readFile(testFile);
|
|
assert.strictEqual(content, 'foo extra foo bar baz',
|
|
'Only the original "foo" is replaced — replacement text is not re-scanned');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 106: countInFile with named capture groups — match(g) ignores group details ──
|
|
console.log('\nRound 106: countInFile (named capture groups — String.match(g) returns full matches only):');
|
|
if (test('countInFile with named capture groups counts matches not groups', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r106-count-named-'));
|
|
const testFile = path.join(tmpDir, 'test.txt');
|
|
try {
|
|
fs.writeFileSync(testFile, 'foo bar baz\nfoo qux\nbar foo end');
|
|
// Named capture group — should still count 3 matches for "foo"
|
|
const count = utils.countInFile(testFile, /(?<word>foo)/);
|
|
assert.strictEqual(count, 3,
|
|
'Named capture group should not inflate count — match(g) returns full matches only');
|
|
// Compare with plain pattern
|
|
const plainCount = utils.countInFile(testFile, /foo/);
|
|
assert.strictEqual(plainCount, 3, 'Plain regex should also find 3 matches');
|
|
assert.strictEqual(count, plainCount,
|
|
'Named group pattern and plain pattern should return identical counts');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 106: grepFile with multiline (m) flag — preserved, unlike g which is stripped ──
|
|
console.log('\nRound 106: grepFile (multiline m flag — preserved in regex, unlike g which is stripped):');
|
|
if (test('grepFile preserves multiline (m) flag and anchors work on split lines', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r106-grep-multiline-'));
|
|
const testFile = path.join(tmpDir, 'test.txt');
|
|
try {
|
|
fs.writeFileSync(testFile, 'hello\nworld hello\nhello world');
|
|
// With m flag + anchors: ^hello$ should match only exact "hello" line
|
|
const mResults = utils.grepFile(testFile, /^hello$/m);
|
|
assert.strictEqual(mResults.length, 1,
|
|
'With m flag, ^hello$ should match only line 1 (exact "hello")');
|
|
assert.strictEqual(mResults[0].lineNumber, 1);
|
|
// Without m flag: same behavior since grepFile splits lines individually
|
|
const noMResults = utils.grepFile(testFile, /^hello$/);
|
|
assert.strictEqual(noMResults.length, 1,
|
|
'Without m flag, same result — grepFile splits lines so anchors are per-line already');
|
|
assert.strictEqual(mResults.length, noMResults.length,
|
|
'm flag is preserved but irrelevant — line splitting makes anchors per-line already');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 109: appendFile creating new file in non-existent directory (ensureDir + appendFileSync) ──
|
|
console.log('\nRound 109: appendFile (new file creation — ensureDir creates parent, appendFileSync creates file):');
|
|
if (test('appendFile creates parent directory and new file when neither exist', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r109-append-new-'));
|
|
const nestedPath = path.join(tmpDir, 'deep', 'nested', 'dir', 'newfile.txt');
|
|
try {
|
|
// Parent directory 'deep/nested/dir' does not exist yet
|
|
assert.ok(!fs.existsSync(path.join(tmpDir, 'deep')),
|
|
'Parent "deep" should not exist before appendFile');
|
|
utils.appendFile(nestedPath, 'first line\n');
|
|
assert.ok(fs.existsSync(nestedPath),
|
|
'File should be created by appendFile');
|
|
assert.strictEqual(utils.readFile(nestedPath), 'first line\n',
|
|
'Content should match what was appended');
|
|
// Append again to verify it adds to existing file
|
|
utils.appendFile(nestedPath, 'second line\n');
|
|
assert.strictEqual(utils.readFile(nestedPath), 'first line\nsecond line\n',
|
|
'Second append should add to existing file');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 108: grepFile with Unicode/emoji content — UTF-16 string matching on split lines ──
|
|
console.log('\nRound 108: grepFile (Unicode/emoji — regex matching on UTF-16 split lines):');
|
|
if (test('grepFile finds Unicode emoji patterns across lines', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r108-grep-unicode-'));
|
|
const testFile = path.join(tmpDir, 'test.txt');
|
|
try {
|
|
fs.writeFileSync(testFile, '🎉 celebration\nnormal line\n🎉 party\n日本語テスト');
|
|
const emojiResults = utils.grepFile(testFile, /🎉/);
|
|
assert.strictEqual(emojiResults.length, 2,
|
|
'Should find emoji on 2 lines (lines 1 and 3)');
|
|
assert.strictEqual(emojiResults[0].lineNumber, 1);
|
|
assert.strictEqual(emojiResults[1].lineNumber, 3);
|
|
const cjkResults = utils.grepFile(testFile, /日本語/);
|
|
assert.strictEqual(cjkResults.length, 1,
|
|
'Should find CJK characters on line 4');
|
|
assert.strictEqual(cjkResults[0].lineNumber, 4);
|
|
assert.ok(cjkResults[0].content.includes('日本語テスト'),
|
|
'Matched line should contain full CJK text');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 110: findFiles root directory unreadable — silent empty return (not throw) ──
|
|
console.log('\nRound 110: findFiles (root directory unreadable — EACCES on readdirSync caught silently):');
|
|
if (test('findFiles returns empty array when root directory exists but is unreadable', () => {
|
|
if (process.platform === 'win32' || process.getuid?.() === 0) {
|
|
console.log(' (skipped — chmod ineffective on Windows/root)');
|
|
return true;
|
|
}
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r110-unreadable-root-'));
|
|
const unreadableDir = path.join(tmpDir, 'no-read');
|
|
fs.mkdirSync(unreadableDir);
|
|
fs.writeFileSync(path.join(unreadableDir, 'secret.txt'), 'hidden');
|
|
try {
|
|
fs.chmodSync(unreadableDir, 0o000);
|
|
// Verify dir exists but is unreadable
|
|
assert.ok(fs.existsSync(unreadableDir), 'Directory should exist');
|
|
// findFiles should NOT throw — catch block at line 188 handles EACCES
|
|
const results = utils.findFiles(unreadableDir, '*');
|
|
assert.ok(Array.isArray(results), 'Should return an array');
|
|
assert.strictEqual(results.length, 0,
|
|
'Should return empty array when root dir is unreadable (not throw)');
|
|
// Also test with recursive flag
|
|
const recursiveResults = utils.findFiles(unreadableDir, '*', { recursive: true });
|
|
assert.strictEqual(recursiveResults.length, 0,
|
|
'Recursive search on unreadable root should also return empty array');
|
|
} finally {
|
|
// Restore permissions before cleanup
|
|
try { fs.chmodSync(unreadableDir, 0o755); } catch (_e) { /* ignore permission errors */ }
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 113: replaceInFile with zero-width regex — inserts between every character ──
|
|
console.log('\nRound 113: replaceInFile (zero-width regex /(?:)/g — matches every position):');
|
|
if (test('replaceInFile with zero-width regex /(?:)/g inserts replacement at every position', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r113-zero-width-'));
|
|
const testFile = path.join(tmpDir, 'test.txt');
|
|
try {
|
|
fs.writeFileSync(testFile, 'abc');
|
|
// /(?:)/g matches at every position boundary: before 'a', between 'a'-'b', etc.
|
|
// "abc".replace(/(?:)/g, 'X') → "XaXbXcX" (7 chars from 3)
|
|
const result = utils.replaceInFile(testFile, /(?:)/g, 'X');
|
|
assert.strictEqual(result, true, 'Should succeed (no error)');
|
|
const content = utils.readFile(testFile);
|
|
assert.strictEqual(content, 'XaXbXcX',
|
|
'Zero-width regex inserts at every position boundary');
|
|
|
|
// Also test with /^/gm (start of each line)
|
|
fs.writeFileSync(testFile, 'line1\nline2\nline3');
|
|
utils.replaceInFile(testFile, /^/gm, '> ');
|
|
const prefixed = utils.readFile(testFile);
|
|
assert.strictEqual(prefixed, '> line1\n> line2\n> line3',
|
|
'/^/gm inserts at start of each line');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 114: replaceInFile options.all is silently ignored for RegExp search ──
|
|
console.log('\nRound 114: replaceInFile (options.all silently ignored for RegExp search):');
|
|
if (test('replaceInFile ignores options.all when search is a RegExp — falls through to .replace()', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r114-all-regex-'));
|
|
const testFile = path.join(tmpDir, 'test.txt');
|
|
try {
|
|
// File with repeated pattern: "foo bar foo baz foo"
|
|
fs.writeFileSync(testFile, 'foo bar foo baz foo');
|
|
|
|
// With options.all=true and a non-global RegExp:
|
|
// Line 411: (options.all && typeof search === 'string') → false (RegExp !== string)
|
|
// Falls through to content.replace(regex, replace) — only replaces FIRST match
|
|
const result = utils.replaceInFile(testFile, /foo/, 'QUX', { all: true });
|
|
assert.strictEqual(result, true, 'Should succeed');
|
|
const content = utils.readFile(testFile);
|
|
assert.strictEqual(content, 'QUX bar foo baz foo',
|
|
'Non-global RegExp with options.all=true should still only replace FIRST match');
|
|
|
|
// Contrast: global RegExp replaces all regardless of options.all
|
|
fs.writeFileSync(testFile, 'foo bar foo baz foo');
|
|
utils.replaceInFile(testFile, /foo/g, 'QUX', { all: true });
|
|
const globalContent = utils.readFile(testFile);
|
|
assert.strictEqual(globalContent, 'QUX bar QUX baz QUX',
|
|
'Global RegExp replaces all matches (options.all irrelevant for RegExp)');
|
|
|
|
// String with options.all=true — uses replaceAll, replaces ALL occurrences
|
|
fs.writeFileSync(testFile, 'foo bar foo baz foo');
|
|
utils.replaceInFile(testFile, 'foo', 'QUX', { all: true });
|
|
const allContent = utils.readFile(testFile);
|
|
assert.strictEqual(allContent, 'QUX bar QUX baz QUX',
|
|
'String with options.all=true uses replaceAll — replaces ALL occurrences');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 114: output with object containing BigInt — JSON.stringify throws ──
|
|
console.log('\nRound 114: output (object containing BigInt — JSON.stringify throws):');
|
|
if (test('output throws TypeError when object contains BigInt values (JSON.stringify cannot serialize)', () => {
|
|
// Capture original console.log to prevent actual output during test
|
|
const originalLog = console.log;
|
|
|
|
try {
|
|
// Plain BigInt — typeof is 'bigint', not 'object', so goes to else branch
|
|
// console.log can handle BigInt directly (prints "42n")
|
|
let captured = null;
|
|
console.log = (val) => { captured = val; };
|
|
utils.output(BigInt(42));
|
|
// Node.js console.log prints BigInt as-is
|
|
assert.strictEqual(captured, BigInt(42), 'Plain BigInt goes to else branch, logged directly');
|
|
|
|
// Object containing BigInt — typeof is 'object', so JSON.stringify is called
|
|
// JSON.stringify(BigInt) throws: "Do not know how to serialize a BigInt"
|
|
console.log = originalLog; // restore before throw test
|
|
assert.throws(
|
|
() => utils.output({ value: BigInt(42) }),
|
|
(err) => err instanceof TypeError && /BigInt/.test(err.message),
|
|
'Object with BigInt should throw TypeError from JSON.stringify'
|
|
);
|
|
|
|
// Array containing BigInt — also typeof 'object'
|
|
assert.throws(
|
|
() => utils.output([BigInt(1), BigInt(2)]),
|
|
(err) => err instanceof TypeError && /BigInt/.test(err.message),
|
|
'Array with BigInt should also throw TypeError from JSON.stringify'
|
|
);
|
|
} finally {
|
|
console.log = originalLog;
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 115: countInFile with empty string pattern — matches at every position boundary ──
|
|
console.log('\nRound 115: countInFile (empty string pattern — matches at every zero-width position):');
|
|
if (test('countInFile with empty string pattern returns content.length + 1 (matches between every char)', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r115-empty-pattern-'));
|
|
const testFile = path.join(tmpDir, 'test.txt');
|
|
try {
|
|
// "hello" is 5 chars → 6 zero-width positions: |h|e|l|l|o|
|
|
fs.writeFileSync(testFile, 'hello');
|
|
const count = utils.countInFile(testFile, '');
|
|
assert.strictEqual(count, 6,
|
|
'Empty string pattern creates /(?:)/g which matches at 6 position boundaries in "hello"');
|
|
|
|
// Empty file → "" has 1 zero-width position (the empty string itself)
|
|
fs.writeFileSync(testFile, '');
|
|
const emptyCount = utils.countInFile(testFile, '');
|
|
assert.strictEqual(emptyCount, 1,
|
|
'Empty file still has 1 zero-width position boundary');
|
|
|
|
// Single char → 2 positions: |a|
|
|
fs.writeFileSync(testFile, 'a');
|
|
const singleCount = utils.countInFile(testFile, '');
|
|
assert.strictEqual(singleCount, 2,
|
|
'Single character file has 2 position boundaries');
|
|
|
|
// Newlines count as characters too
|
|
fs.writeFileSync(testFile, 'a\nb');
|
|
const newlineCount = utils.countInFile(testFile, '');
|
|
assert.strictEqual(newlineCount, 4,
|
|
'"a\\nb" is 3 chars → 4 position boundaries');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 117: grepFile with CRLF content — split('\n') leaves \r, anchored patterns fail ──
|
|
console.log('\nRound 117: grepFile (CRLF content — trailing \\r breaks anchored regex patterns):');
|
|
if (test('grepFile with CRLF content: unanchored patterns work but anchored $ fails due to trailing \\r', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r117-grep-crlf-'));
|
|
const testFile = path.join(tmpDir, 'test.txt');
|
|
try {
|
|
// Write CRLF content
|
|
fs.writeFileSync(testFile, 'hello\r\nworld\r\nfoo bar\r\n');
|
|
|
|
// Unanchored pattern works — 'hello' matches in 'hello\r'
|
|
const unanchored = utils.grepFile(testFile, 'hello');
|
|
assert.strictEqual(unanchored.length, 1, 'Unanchored pattern should find 1 match');
|
|
assert.strictEqual(unanchored[0].lineNumber, 1, 'Should be on line 1');
|
|
assert.ok(unanchored[0].content.endsWith('\r'),
|
|
'Line content should have trailing \\r from split("\\n") on CRLF');
|
|
|
|
// Anchored pattern /^hello$/ does NOT match 'hello\r' because $ is before \r
|
|
const anchored = utils.grepFile(testFile, /^hello$/);
|
|
assert.strictEqual(anchored.length, 0,
|
|
'Anchored /^hello$/ should NOT match "hello\\r" — $ fails before \\r');
|
|
|
|
// But /^hello\r?$/ or /^hello/ work
|
|
const withOptCr = utils.grepFile(testFile, /^hello\r?$/);
|
|
assert.strictEqual(withOptCr.length, 1,
|
|
'/^hello\\r?$/ matches "hello\\r" because \\r? consumes the trailing CR');
|
|
|
|
// Contrast: LF-only content works with anchored patterns
|
|
fs.writeFileSync(testFile, 'hello\nworld\nfoo bar\n');
|
|
const lfAnchored = utils.grepFile(testFile, /^hello$/);
|
|
assert.strictEqual(lfAnchored.length, 1,
|
|
'LF-only content: anchored /^hello$/ matches normally');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 116: replaceInFile with null/undefined replacement — JS coerces to string ──
|
|
console.log('\nRound 116: replaceInFile (null/undefined replacement — JS coerces to string "null"/"undefined"):');
|
|
if (test('replaceInFile with null replacement coerces to string "null" via String.replace ToString', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r116-null-replace-'));
|
|
const testFile = path.join(tmpDir, 'test.txt');
|
|
try {
|
|
// null replacement → String.replace coerces null to "null"
|
|
fs.writeFileSync(testFile, 'hello world');
|
|
const result = utils.replaceInFile(testFile, 'world', null);
|
|
assert.strictEqual(result, true, 'Should succeed');
|
|
const content = utils.readFile(testFile);
|
|
assert.strictEqual(content, 'hello null',
|
|
'null replacement is coerced to string "null" by String.replace');
|
|
|
|
// undefined replacement → coerced to "undefined"
|
|
fs.writeFileSync(testFile, 'hello world');
|
|
utils.replaceInFile(testFile, 'world', undefined);
|
|
const undefinedContent = utils.readFile(testFile);
|
|
assert.strictEqual(undefinedContent, 'hello undefined',
|
|
'undefined replacement is coerced to string "undefined" by String.replace');
|
|
|
|
// Contrast: empty string replacement works as expected
|
|
fs.writeFileSync(testFile, 'hello world');
|
|
utils.replaceInFile(testFile, 'world', '');
|
|
const emptyContent = utils.readFile(testFile);
|
|
assert.strictEqual(emptyContent, 'hello ',
|
|
'Empty string replacement correctly removes matched text');
|
|
|
|
// options.all with null replacement
|
|
fs.writeFileSync(testFile, 'foo bar foo baz foo');
|
|
utils.replaceInFile(testFile, 'foo', null, { all: true });
|
|
const allContent = utils.readFile(testFile);
|
|
assert.strictEqual(allContent, 'null bar null baz null',
|
|
'replaceAll also coerces null to "null" for every occurrence');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 116: ensureDir with null path — throws wrapped TypeError ──
|
|
console.log('\nRound 116: ensureDir (null path — fs.existsSync(null) throws TypeError):');
|
|
if (test('ensureDir with null path throws wrapped Error from TypeError (ERR_INVALID_ARG_TYPE)', () => {
|
|
// fs.existsSync(null) throws TypeError in modern Node.js
|
|
// Caught by ensureDir catch block, err.code !== 'EEXIST' → re-thrown as wrapped Error
|
|
assert.throws(
|
|
() => utils.ensureDir(null),
|
|
(err) => {
|
|
// Should be a wrapped Error (not raw TypeError)
|
|
assert.ok(err instanceof Error, 'Should throw an Error');
|
|
assert.ok(err.message.includes('Failed to create directory'),
|
|
'Error message should include "Failed to create directory"');
|
|
return true;
|
|
},
|
|
'ensureDir(null) should throw wrapped Error'
|
|
);
|
|
|
|
// undefined path — same behavior
|
|
assert.throws(
|
|
() => utils.ensureDir(undefined),
|
|
(err) => err instanceof Error && err.message.includes('Failed to create directory'),
|
|
'ensureDir(undefined) should also throw wrapped Error'
|
|
);
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 118: writeFile with non-string content — TypeError propagates (no try/catch) ──
|
|
console.log('\nRound 118: writeFile (non-string content — TypeError propagates uncaught):');
|
|
if (test('writeFile with null/number content throws TypeError because fs.writeFileSync rejects non-string data', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r118-writefile-type-'));
|
|
const testFile = path.join(tmpDir, 'test.txt');
|
|
try {
|
|
// null content → TypeError from fs.writeFileSync (data must be string/Buffer/etc.)
|
|
assert.throws(
|
|
() => utils.writeFile(testFile, null),
|
|
(err) => err instanceof TypeError,
|
|
'writeFile(path, null) should throw TypeError (no try/catch in writeFile)'
|
|
);
|
|
|
|
// undefined content → TypeError
|
|
assert.throws(
|
|
() => utils.writeFile(testFile, undefined),
|
|
(err) => err instanceof TypeError,
|
|
'writeFile(path, undefined) should throw TypeError'
|
|
);
|
|
|
|
// number content → TypeError (numbers not valid for fs.writeFileSync)
|
|
assert.throws(
|
|
() => utils.writeFile(testFile, 42),
|
|
(err) => err instanceof TypeError,
|
|
'writeFile(path, 42) should throw TypeError (number not a valid data type)'
|
|
);
|
|
|
|
// Contrast: string content works fine
|
|
utils.writeFile(testFile, 'valid string content');
|
|
assert.strictEqual(utils.readFile(testFile), 'valid string content',
|
|
'String content should write and read back correctly');
|
|
|
|
// Empty string is valid
|
|
utils.writeFile(testFile, '');
|
|
assert.strictEqual(utils.readFile(testFile), '',
|
|
'Empty string should write correctly');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 119: appendFile with non-string content — TypeError propagates (no try/catch) ──
|
|
console.log('\nRound 119: appendFile (non-string content — TypeError propagates like writeFile):');
|
|
if (test('appendFile with null/number content throws TypeError (no try/catch wrapper)', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r119-appendfile-type-'));
|
|
const testFile = path.join(tmpDir, 'test.txt');
|
|
try {
|
|
// Create file with initial content
|
|
fs.writeFileSync(testFile, 'initial');
|
|
|
|
// null content → TypeError from fs.appendFileSync
|
|
assert.throws(
|
|
() => utils.appendFile(testFile, null),
|
|
(err) => err instanceof TypeError,
|
|
'appendFile(path, null) should throw TypeError'
|
|
);
|
|
|
|
// undefined content → TypeError
|
|
assert.throws(
|
|
() => utils.appendFile(testFile, undefined),
|
|
(err) => err instanceof TypeError,
|
|
'appendFile(path, undefined) should throw TypeError'
|
|
);
|
|
|
|
// number content → TypeError
|
|
assert.throws(
|
|
() => utils.appendFile(testFile, 42),
|
|
(err) => err instanceof TypeError,
|
|
'appendFile(path, 42) should throw TypeError'
|
|
);
|
|
|
|
// Verify original content is unchanged after failed appends
|
|
assert.strictEqual(utils.readFile(testFile), 'initial',
|
|
'File content should be unchanged after failed appends');
|
|
|
|
// Contrast: string append works
|
|
utils.appendFile(testFile, ' appended');
|
|
assert.strictEqual(utils.readFile(testFile), 'initial appended',
|
|
'String append should work correctly');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 120: replaceInFile with empty string search — prepend vs insert-between-every-char ──
|
|
console.log('\nRound 120: replaceInFile (empty string search — replace vs replaceAll dramatic difference):');
|
|
if (test('replaceInFile with empty search: replace prepends at pos 0; replaceAll inserts between every char', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r120-empty-search-'));
|
|
const testFile = path.join(tmpDir, 'test.txt');
|
|
try {
|
|
// Without options.all: .replace('', 'X') prepends at position 0
|
|
fs.writeFileSync(testFile, 'hello');
|
|
utils.replaceInFile(testFile, '', 'X');
|
|
const prepended = utils.readFile(testFile);
|
|
assert.strictEqual(prepended, 'Xhello',
|
|
'replace("", "X") should prepend X at position 0 only');
|
|
|
|
// With options.all: .replaceAll('', 'X') inserts between every character
|
|
fs.writeFileSync(testFile, 'hello');
|
|
utils.replaceInFile(testFile, '', 'X', { all: true });
|
|
const insertedAll = utils.readFile(testFile);
|
|
assert.strictEqual(insertedAll, 'XhXeXlXlXoX',
|
|
'replaceAll("", "X") inserts X at every position boundary');
|
|
|
|
// Empty file + empty search
|
|
fs.writeFileSync(testFile, '');
|
|
utils.replaceInFile(testFile, '', 'X');
|
|
const emptyReplace = utils.readFile(testFile);
|
|
assert.strictEqual(emptyReplace, 'X',
|
|
'Empty content + empty search: single insertion at position 0');
|
|
|
|
// Empty file + empty search + all
|
|
fs.writeFileSync(testFile, '');
|
|
utils.replaceInFile(testFile, '', 'X', { all: true });
|
|
const emptyAll = utils.readFile(testFile);
|
|
assert.strictEqual(emptyAll, 'X',
|
|
'Empty content + replaceAll("", "X"): single position boundary → "X"');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 121: findFiles with ? glob pattern — single character wildcard ──
|
|
console.log('\nRound 121: findFiles (? glob pattern — converted to . regex for single char match):');
|
|
if (test('findFiles with ? glob matches single character only — test?.txt matches test1 but not test12', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r121-glob-question-'));
|
|
try {
|
|
// Create test files
|
|
fs.writeFileSync(path.join(tmpDir, 'test1.txt'), 'a');
|
|
fs.writeFileSync(path.join(tmpDir, 'testA.txt'), 'b');
|
|
fs.writeFileSync(path.join(tmpDir, 'test12.txt'), 'c');
|
|
fs.writeFileSync(path.join(tmpDir, 'test.txt'), 'd');
|
|
|
|
// ? matches exactly one character
|
|
const results = utils.findFiles(tmpDir, 'test?.txt');
|
|
const names = results.map(r => path.basename(r.path)).sort();
|
|
assert.ok(names.includes('test1.txt'), 'Should match test1.txt (? = single digit)');
|
|
assert.ok(names.includes('testA.txt'), 'Should match testA.txt (? = single letter)');
|
|
assert.ok(!names.includes('test12.txt'), 'Should NOT match test12.txt (12 is two chars)');
|
|
assert.ok(!names.includes('test.txt'), 'Should NOT match test.txt (no char for ?)');
|
|
|
|
// Multiple ? marks
|
|
fs.writeFileSync(path.join(tmpDir, 'ab.txt'), 'e');
|
|
fs.writeFileSync(path.join(tmpDir, 'abc.txt'), 'f');
|
|
const multiResults = utils.findFiles(tmpDir, '??.txt');
|
|
const multiNames = multiResults.map(r => path.basename(r.path));
|
|
assert.ok(multiNames.includes('ab.txt'), '?? should match 2-char filename');
|
|
assert.ok(!multiNames.includes('abc.txt'), '?? should NOT match 3-char filename');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 122: findFiles dot extension escaping — *.txt must not match filetxt ──
|
|
console.log('\nRound 122: findFiles (dot escaping — *.txt matches file.txt but not filetxt):');
|
|
if (test('findFiles escapes dots in glob pattern so *.txt only matches literal .txt extension', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r122-dot-escape-'));
|
|
try {
|
|
fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'a');
|
|
fs.writeFileSync(path.join(tmpDir, 'filetxt'), 'b');
|
|
fs.writeFileSync(path.join(tmpDir, 'file.txtx'), 'c');
|
|
fs.writeFileSync(path.join(tmpDir, 'notes.txt'), 'd');
|
|
|
|
const results = utils.findFiles(tmpDir, '*.txt');
|
|
const names = results.map(r => path.basename(r.path)).sort();
|
|
|
|
assert.ok(names.includes('file.txt'), 'Should match file.txt');
|
|
assert.ok(names.includes('notes.txt'), 'Should match notes.txt');
|
|
assert.ok(!names.includes('filetxt'),
|
|
'Should NOT match filetxt (dot is escaped to literal, not wildcard)');
|
|
assert.ok(!names.includes('file.txtx'),
|
|
'Should NOT match file.txtx ($ anchor requires exact end)');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 123: countInFile with overlapping patterns — match(g) is non-overlapping ──
|
|
console.log('\nRound 123: countInFile (overlapping patterns — String.match(/g/) is non-overlapping):');
|
|
if (test('countInFile counts non-overlapping matches only — "aaa" with /aa/g returns 1 not 2', () => {
|
|
// utils.js line 449: `content.match(regex)` with 'g' flag returns an array of
|
|
// non-overlapping matches. After matching "aa" starting at index 0, the engine
|
|
// advances to index 2, where only one "a" remains — no second match.
|
|
// This is standard JS regex behavior but can surprise users expecting overlap.
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r123-overlap-'));
|
|
const testFile = path.join(tmpDir, 'test.txt');
|
|
try {
|
|
// "aaa" — a human might count 2 occurrences of "aa" (at 0,1) but match(g) finds 1
|
|
fs.writeFileSync(testFile, 'aaa');
|
|
const count1 = utils.countInFile(testFile, 'aa');
|
|
assert.strictEqual(count1, 1,
|
|
'"aaa".match(/aa/g) returns ["aa"] — only 1 non-overlapping match');
|
|
|
|
// "aaaa" — 2 non-overlapping matches (at 0,2), not 3 overlapping (at 0,1,2)
|
|
fs.writeFileSync(testFile, 'aaaa');
|
|
const count2 = utils.countInFile(testFile, 'aa');
|
|
assert.strictEqual(count2, 2,
|
|
'"aaaa".match(/aa/g) returns ["aa","aa"] — 2 non-overlapping, not 3 overlapping');
|
|
|
|
// "abab" with /aba/g — only 1 match (at 0), not 2 (overlapping at 0,2)
|
|
fs.writeFileSync(testFile, 'ababab');
|
|
const count3 = utils.countInFile(testFile, 'aba');
|
|
assert.strictEqual(count3, 1,
|
|
'"ababab".match(/aba/g) returns 1 — after match at 0, next try starts at 3');
|
|
|
|
// RegExp object behaves the same
|
|
fs.writeFileSync(testFile, 'aaa');
|
|
const count4 = utils.countInFile(testFile, /aa/);
|
|
assert.strictEqual(count4, 1,
|
|
'RegExp /aa/ also gives 1 non-overlapping match on "aaa" (g flag auto-added)');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 123: replaceInFile with $& and $$ substitution tokens in replacement string ──
|
|
console.log('\nRound 123: replaceInFile ($& and $$ substitution tokens in replacement):');
|
|
if (test('replaceInFile replacement string interprets $& as matched text and $$ as literal $', () => {
|
|
// JS String.replace() interprets special patterns in the replacement string:
|
|
// $& → inserts the entire matched substring
|
|
// $$ → inserts a literal "$" character
|
|
// $' → inserts the portion after the matched substring
|
|
// $` → inserts the portion before the matched substring
|
|
// This is different from capture groups ($1, $2) already tested in Round 91.
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r123-dollar-'));
|
|
const testFile = path.join(tmpDir, 'test.txt');
|
|
try {
|
|
// $& — inserts the matched text itself
|
|
fs.writeFileSync(testFile, 'hello world');
|
|
utils.replaceInFile(testFile, 'world', '[$&]');
|
|
assert.strictEqual(utils.readFile(testFile), 'hello [world]',
|
|
'$& in replacement inserts the matched text "world" → "[world]"');
|
|
|
|
// $$ — inserts a literal $ sign
|
|
fs.writeFileSync(testFile, 'price is 100');
|
|
utils.replaceInFile(testFile, '100', '$$100');
|
|
assert.strictEqual(utils.readFile(testFile), 'price is $100',
|
|
'$$ becomes literal $ → "100" replaced with "$100"');
|
|
|
|
// $& with options.all — applies to each match
|
|
fs.writeFileSync(testFile, 'foo bar foo');
|
|
utils.replaceInFile(testFile, 'foo', '($&)', { all: true });
|
|
assert.strictEqual(utils.readFile(testFile), '(foo) bar (foo)',
|
|
'$& in replaceAll inserts each respective matched text');
|
|
|
|
// Combined $$ and $& in same replacement (3 $ + &)
|
|
fs.writeFileSync(testFile, 'item costs 50');
|
|
utils.replaceInFile(testFile, '50', '$$$&');
|
|
// In replacement string: $$ → "$" then $& → "50" so result is "$50"
|
|
assert.strictEqual(utils.readFile(testFile), 'item costs $50',
|
|
'$$$& (3 dollars + ampersand) means literal $ followed by matched text → "$50"');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 124: findFiles matches dotfiles (unlike shell glob where * excludes hidden files) ──
|
|
console.log('\nRound 124: findFiles (* glob matches dotfiles — unlike shell globbing):');
|
|
if (test('findFiles with * pattern matches dotfiles because .* regex includes hidden files', () => {
|
|
// In shell: `ls *` excludes .hidden files. In findFiles, `*` → `.*` regex which
|
|
// matches ANY filename including those starting with `.`. This is a behavioral
|
|
// difference from shell globbing that could surprise users.
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r124-dotfiles-'));
|
|
try {
|
|
// Create normal and hidden files
|
|
fs.writeFileSync(path.join(tmpDir, 'normal.txt'), 'visible');
|
|
fs.writeFileSync(path.join(tmpDir, '.hidden'), 'hidden');
|
|
fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'ignore');
|
|
fs.writeFileSync(path.join(tmpDir, 'README.md'), 'readme');
|
|
|
|
// * matches ALL files including dotfiles
|
|
const allResults = utils.findFiles(tmpDir, '*');
|
|
const names = allResults.map(r => path.basename(r.path)).sort();
|
|
assert.ok(names.includes('.hidden'),
|
|
'* should match .hidden (unlike shell glob)');
|
|
assert.ok(names.includes('.gitignore'),
|
|
'* should match .gitignore');
|
|
assert.ok(names.includes('normal.txt'),
|
|
'* should match normal.txt');
|
|
assert.strictEqual(names.length, 4,
|
|
'Should find all 4 files including 2 dotfiles');
|
|
|
|
// *.txt does NOT match dotfiles (because they don't end with .txt)
|
|
const txtResults = utils.findFiles(tmpDir, '*.txt');
|
|
assert.strictEqual(txtResults.length, 1,
|
|
'*.txt should only match normal.txt, not dotfiles');
|
|
|
|
// .* pattern specifically matches only dotfiles
|
|
const dotResults = utils.findFiles(tmpDir, '.*');
|
|
const dotNames = dotResults.map(r => path.basename(r.path)).sort();
|
|
assert.ok(dotNames.includes('.hidden'), '.* matches .hidden');
|
|
assert.ok(dotNames.includes('.gitignore'), '.* matches .gitignore');
|
|
assert.ok(!dotNames.includes('normal.txt'),
|
|
'.* should NOT match normal.txt (needs leading dot)');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 125: readFile with binary content — returns garbled UTF-8, not null ──
|
|
console.log('\nRound 125: readFile (binary/non-UTF8 content — garbled, not null):');
|
|
if (test('readFile with binary content returns garbled string (not null) because UTF-8 decode does not throw', () => {
|
|
// utils.js line 285: fs.readFileSync(filePath, 'utf8') — binary data gets UTF-8 decoded.
|
|
// Invalid byte sequences become U+FFFD replacement characters. The function does
|
|
// NOT return null for binary files (only returns null on ENOENT/permission errors).
|
|
// This means grepFile/countInFile would operate on corrupted content silently.
|
|
const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r125-binary-'));
|
|
const testFile = path.join(tmpDir, 'binary.dat');
|
|
try {
|
|
// Write raw binary data (invalid UTF-8 sequences)
|
|
const binaryData = Buffer.from([0x00, 0x80, 0xFF, 0xFE, 0x48, 0x65, 0x6C, 0x6C, 0x6F]);
|
|
fs.writeFileSync(testFile, binaryData);
|
|
|
|
const content = utils.readFile(testFile);
|
|
assert.ok(content !== null,
|
|
'readFile should NOT return null for binary files');
|
|
assert.ok(typeof content === 'string',
|
|
'readFile always returns a string (or null for missing files)');
|
|
// The string contains "Hello" (bytes 0x48-0x6F) somewhere in the garbled output
|
|
assert.ok(content.includes('Hello'),
|
|
'ASCII subset of binary data should survive UTF-8 decode');
|
|
// Content length may differ from byte length due to multi-byte replacement chars
|
|
assert.ok(content.length > 0, 'Non-empty content from binary file');
|
|
|
|
// grepFile on binary file — still works but on garbled content
|
|
const matches = utils.grepFile(testFile, 'Hello');
|
|
assert.strictEqual(matches.length, 1,
|
|
'grepFile finds "Hello" even in binary file (ASCII bytes survive)');
|
|
|
|
// Non-existent file — returns null (contrast with binary)
|
|
const missing = utils.readFile(path.join(tmpDir, 'no-such-file.txt'));
|
|
assert.strictEqual(missing, null,
|
|
'Missing file returns null (not garbled content)');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ── Round 125: output() with undefined, NaN, Infinity — non-object primitives logged directly ──
|
|
console.log('\nRound 125: output() (undefined/NaN/Infinity — typeof checks and JSON.stringify):');
|
|
if (test('output() handles undefined, NaN, Infinity as non-objects — logs directly', () => {
|
|
// utils.js line 273: `if (typeof data === 'object')` — undefined/NaN/Infinity are NOT objects.
|
|
// typeof undefined → "undefined", typeof NaN → "number", typeof Infinity → "number"
|
|
// All three bypass JSON.stringify and go to console.log(data) directly.
|
|
const origLog = console.log;
|
|
const logged = [];
|
|
console.log = (...args) => logged.push(args);
|
|
try {
|
|
// undefined — typeof "undefined", logged directly
|
|
utils.output(undefined);
|
|
assert.strictEqual(logged[0][0], undefined,
|
|
'output(undefined) logs undefined (not "undefined" string)');
|
|
|
|
// NaN — typeof "number", logged directly
|
|
utils.output(NaN);
|
|
assert.ok(Number.isNaN(logged[1][0]),
|
|
'output(NaN) logs NaN directly (typeof "number", not "object")');
|
|
|
|
// Infinity — typeof "number", logged directly
|
|
utils.output(Infinity);
|
|
assert.strictEqual(logged[2][0], Infinity,
|
|
'output(Infinity) logs Infinity directly');
|
|
|
|
// Object containing NaN — JSON.stringify converts NaN to null
|
|
utils.output({ value: NaN, count: Infinity });
|
|
const parsed = JSON.parse(logged[3][0]);
|
|
assert.strictEqual(parsed.value, null,
|
|
'JSON.stringify converts NaN to null inside objects');
|
|
assert.strictEqual(parsed.count, null,
|
|
'JSON.stringify converts Infinity to null inside objects');
|
|
} finally {
|
|
console.log = origLog;
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ─── stripAnsi ───
|
|
console.log('\nstripAnsi:');
|
|
|
|
if (test('strips SGR color codes (\\x1b[...m)', () => {
|
|
assert.strictEqual(utils.stripAnsi('\x1b[31mRed text\x1b[0m'), 'Red text');
|
|
assert.strictEqual(utils.stripAnsi('\x1b[1;36mBold cyan\x1b[0m'), 'Bold cyan');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('strips cursor movement sequences (\\x1b[H, \\x1b[2J, \\x1b[3J)', () => {
|
|
// These are the exact sequences reported in issue #642
|
|
assert.strictEqual(utils.stripAnsi('\x1b[H\x1b[2J\x1b[3JHello'), 'Hello');
|
|
assert.strictEqual(utils.stripAnsi('before\x1b[Hafter'), 'beforeafter');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('strips cursor position sequences (\\x1b[row;colH)', () => {
|
|
assert.strictEqual(utils.stripAnsi('\x1b[5;10Hplaced'), 'placed');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('strips erase line sequences (\\x1b[K, \\x1b[2K)', () => {
|
|
assert.strictEqual(utils.stripAnsi('line\x1b[Kend'), 'lineend');
|
|
assert.strictEqual(utils.stripAnsi('line\x1b[2Kend'), 'lineend');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('strips OSC sequences (window title, hyperlinks)', () => {
|
|
// OSC terminated by BEL (\x07)
|
|
assert.strictEqual(utils.stripAnsi('\x1b]0;My Title\x07content'), 'content');
|
|
// OSC terminated by ST (\x1b\\)
|
|
assert.strictEqual(utils.stripAnsi('\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\'), 'link');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('strips charset selection (\\x1b(B)', () => {
|
|
assert.strictEqual(utils.stripAnsi('\x1b(Bnormal'), 'normal');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('strips bare ESC + letter (\\x1bM reverse index)', () => {
|
|
assert.strictEqual(utils.stripAnsi('line\x1bMup'), 'lineup');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('handles mixed ANSI sequences in one string', () => {
|
|
const input = '\x1b[H\x1b[2J\x1b[1;36mSession\x1b[0m summary\x1b[K';
|
|
assert.strictEqual(utils.stripAnsi(input), 'Session summary');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('returns empty string for non-string input', () => {
|
|
assert.strictEqual(utils.stripAnsi(null), '');
|
|
assert.strictEqual(utils.stripAnsi(undefined), '');
|
|
assert.strictEqual(utils.stripAnsi(42), '');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('preserves string with no ANSI codes', () => {
|
|
assert.strictEqual(utils.stripAnsi('plain text'), 'plain text');
|
|
assert.strictEqual(utils.stripAnsi(''), '');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('handles CSI with question mark parameter (DEC private modes)', () => {
|
|
// e.g. \x1b[?25h (show cursor), \x1b[?25l (hide cursor)
|
|
assert.strictEqual(utils.stripAnsi('\x1b[?25hvisible\x1b[?25l'), 'visible');
|
|
})) passed++; else failed++;
|
|
|
|
// Summary
|
|
console.log('\n=== Test Results ===');
|
|
console.log(`Passed: ${passed}`);
|
|
console.log(`Failed: ${failed}`);
|
|
console.log(`Total: ${passed + failed}\n`);
|
|
|
|
process.exit(failed > 0 ? 1 : 0);
|
|
}
|
|
|
|
runTests();
|