493 lines
19 KiB
JavaScript
493 lines
19 KiB
JavaScript
/**
|
|
* Native fs Wrapper Tests
|
|
*
|
|
* Validates that tools/cli/lib/fs.js provides the same API surface
|
|
* as fs-extra but backed entirely by native node:fs. Exercises every
|
|
* exported method the CLI codebase relies on.
|
|
*
|
|
* Usage: node test/test-fs-wrapper.js
|
|
* Exit codes: 0 = all tests pass, 1 = test failures
|
|
*/
|
|
|
|
const nativeFs = require('node:fs');
|
|
const path = require('node:path');
|
|
const fs = require('../tools/cli/lib/fs');
|
|
|
|
// ANSI color codes
|
|
const colors = {
|
|
reset: '\u001B[0m',
|
|
green: '\u001B[32m',
|
|
red: '\u001B[31m',
|
|
yellow: '\u001B[33m',
|
|
cyan: '\u001B[36m',
|
|
dim: '\u001B[2m',
|
|
};
|
|
|
|
let totalTests = 0;
|
|
let passedTests = 0;
|
|
const failures = [];
|
|
|
|
function test(name, fn) {
|
|
totalTests++;
|
|
try {
|
|
fn();
|
|
passedTests++;
|
|
console.log(` ${colors.green}\u2713${colors.reset} ${name}`);
|
|
} catch (error) {
|
|
console.log(` ${colors.red}\u2717${colors.reset} ${name} ${colors.red}${error.message}${colors.reset}`);
|
|
failures.push({ name, message: error.message });
|
|
}
|
|
}
|
|
|
|
async function asyncTest(name, fn) {
|
|
totalTests++;
|
|
try {
|
|
await fn();
|
|
passedTests++;
|
|
console.log(` ${colors.green}\u2713${colors.reset} ${name}`);
|
|
} catch (error) {
|
|
console.log(` ${colors.red}\u2717${colors.reset} ${name} ${colors.red}${error.message}${colors.reset}`);
|
|
failures.push({ name, message: error.message });
|
|
}
|
|
}
|
|
|
|
function assert(condition, message) {
|
|
if (!condition) throw new Error(message);
|
|
}
|
|
|
|
function assertEqual(actual, expected, message) {
|
|
if (actual !== expected) {
|
|
throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
}
|
|
}
|
|
|
|
// ── Test fixtures ───────────────────────────────────────────────────────────
|
|
|
|
const TMP = path.join(__dirname, '.tmp-fs-wrapper-test');
|
|
|
|
function setup() {
|
|
nativeFs.rmSync(TMP, { recursive: true, force: true });
|
|
nativeFs.mkdirSync(TMP, { recursive: true });
|
|
}
|
|
|
|
function teardown() {
|
|
nativeFs.rmSync(TMP, { recursive: true, force: true });
|
|
}
|
|
|
|
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
|
|
async function runTests() {
|
|
console.log(`${colors.cyan}========================================`);
|
|
console.log('Native fs Wrapper Tests');
|
|
console.log(`========================================${colors.reset}\n`);
|
|
|
|
setup();
|
|
|
|
// ── Re-exported native members ──────────────────────────────────────────
|
|
|
|
console.log(`${colors.yellow}Re-exported native fs members${colors.reset}`);
|
|
|
|
test('exports fs.constants', () => {
|
|
assert(fs.constants !== undefined, 'fs.constants is undefined');
|
|
assert(typeof fs.constants.F_OK === 'number', 'fs.constants.F_OK is not a number');
|
|
});
|
|
|
|
test('exports fs.existsSync', () => {
|
|
assert(typeof fs.existsSync === 'function', 'fs.existsSync is not a function');
|
|
assert(fs.existsSync(__dirname), 'existsSync returns false for existing dir');
|
|
assert(!fs.existsSync(path.join(TMP, 'nonexistent')), 'existsSync returns true for missing path');
|
|
});
|
|
|
|
test('exports fs.readFileSync', () => {
|
|
const content = fs.readFileSync(__filename, 'utf8');
|
|
assert(content.includes('Native fs Wrapper Tests'), 'readFileSync did not return expected content');
|
|
});
|
|
|
|
test('exports fs.writeFileSync', () => {
|
|
const p = path.join(TMP, 'write-sync.txt');
|
|
fs.writeFileSync(p, 'hello sync');
|
|
assertEqual(nativeFs.readFileSync(p, 'utf8'), 'hello sync', 'writeFileSync content mismatch');
|
|
});
|
|
|
|
test('exports fs.mkdirSync', () => {
|
|
const p = path.join(TMP, 'mkdir-sync');
|
|
fs.mkdirSync(p);
|
|
assert(nativeFs.statSync(p).isDirectory(), 'mkdirSync did not create directory');
|
|
});
|
|
|
|
test('exports fs.readdirSync', () => {
|
|
const entries = fs.readdirSync(TMP);
|
|
assert(Array.isArray(entries), 'readdirSync did not return array');
|
|
});
|
|
|
|
test('exports fs.statSync', () => {
|
|
const stat = fs.statSync(__dirname);
|
|
assert(stat.isDirectory(), 'statSync did not return directory stat');
|
|
});
|
|
|
|
test('exports fs.copyFileSync', () => {
|
|
const src = path.join(TMP, 'copy-src.txt');
|
|
const dest = path.join(TMP, 'copy-dest.txt');
|
|
nativeFs.writeFileSync(src, 'copy me');
|
|
fs.copyFileSync(src, dest);
|
|
assertEqual(nativeFs.readFileSync(dest, 'utf8'), 'copy me', 'copyFileSync content mismatch');
|
|
});
|
|
|
|
test('exports fs.accessSync', () => {
|
|
// Should not throw for existing file
|
|
fs.accessSync(__filename);
|
|
let threw = false;
|
|
try {
|
|
fs.accessSync(path.join(TMP, 'nonexistent'));
|
|
} catch {
|
|
threw = true;
|
|
}
|
|
assert(threw, 'accessSync did not throw for missing file');
|
|
});
|
|
|
|
test('exports fs.createReadStream', () => {
|
|
assert(typeof fs.createReadStream === 'function', 'createReadStream is not a function');
|
|
});
|
|
|
|
console.log('');
|
|
|
|
// ── Async promise-based methods ─────────────────────────────────────────
|
|
|
|
console.log(`${colors.yellow}Async promise-based methods${colors.reset}`);
|
|
|
|
await asyncTest('readFile returns promise with content', async () => {
|
|
const content = await fs.readFile(__filename, 'utf8');
|
|
assert(content.includes('Native fs Wrapper Tests'), 'readFile did not return expected content');
|
|
});
|
|
|
|
await asyncTest('writeFile writes content asynchronously', async () => {
|
|
const p = path.join(TMP, 'write-async.txt');
|
|
await fs.writeFile(p, 'hello async');
|
|
assertEqual(nativeFs.readFileSync(p, 'utf8'), 'hello async', 'writeFile content mismatch');
|
|
});
|
|
|
|
await asyncTest('readdir returns directory entries', async () => {
|
|
const dir = path.join(TMP, 'readdir-test');
|
|
nativeFs.mkdirSync(dir, { recursive: true });
|
|
nativeFs.writeFileSync(path.join(dir, 'a.txt'), 'a');
|
|
const entries = await fs.readdir(dir);
|
|
assert(Array.isArray(entries), 'readdir did not return array');
|
|
assert(entries.length > 0, 'readdir returned empty array for non-empty dir');
|
|
});
|
|
|
|
await asyncTest('readdir with withFileTypes returns Dirent objects', async () => {
|
|
const dir = path.join(TMP, 'dirent-test');
|
|
nativeFs.mkdirSync(dir, { recursive: true });
|
|
nativeFs.writeFileSync(path.join(dir, 'file.txt'), 'content');
|
|
nativeFs.mkdirSync(path.join(dir, 'subdir'));
|
|
|
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
assert(Array.isArray(entries), 'should return array');
|
|
|
|
const fileEntry = entries.find((e) => e.name === 'file.txt');
|
|
const dirEntry = entries.find((e) => e.name === 'subdir');
|
|
|
|
assert(fileEntry && typeof fileEntry.isFile === 'function', 'entry should have isFile method');
|
|
assert(dirEntry && typeof dirEntry.isDirectory === 'function', 'entry should have isDirectory method');
|
|
assert(fileEntry.isFile(), 'file entry should return true for isFile()');
|
|
assert(dirEntry.isDirectory(), 'dir entry should return true for isDirectory()');
|
|
});
|
|
|
|
await asyncTest('stat returns file stats', async () => {
|
|
const stat = await fs.stat(__dirname);
|
|
assert(stat.isDirectory(), 'stat did not return directory stat');
|
|
});
|
|
|
|
await asyncTest('access resolves for existing file', async () => {
|
|
await fs.access(__filename); // should not throw
|
|
});
|
|
|
|
await asyncTest('access rejects for missing file', async () => {
|
|
let threw = false;
|
|
try {
|
|
await fs.access(path.join(TMP, 'nonexistent'));
|
|
} catch {
|
|
threw = true;
|
|
}
|
|
assert(threw, 'access did not reject for missing file');
|
|
});
|
|
|
|
await asyncTest('rename moves a file', async () => {
|
|
const src = path.join(TMP, 'rename-src.txt');
|
|
const dest = path.join(TMP, 'rename-dest.txt');
|
|
nativeFs.writeFileSync(src, 'rename me');
|
|
await fs.rename(src, dest);
|
|
assert(!nativeFs.existsSync(src), 'rename did not remove source');
|
|
assertEqual(nativeFs.readFileSync(dest, 'utf8'), 'rename me', 'rename content mismatch');
|
|
});
|
|
|
|
await asyncTest('realpath resolves path', async () => {
|
|
const resolved = await fs.realpath(__dirname);
|
|
assert(typeof resolved === 'string', 'realpath did not return string');
|
|
assert(resolved.length > 0, 'realpath returned empty string');
|
|
});
|
|
|
|
console.log('');
|
|
|
|
// ── fs-extra compatible methods ─────────────────────────────────────────
|
|
|
|
console.log(`${colors.yellow}fs-extra compatible methods${colors.reset}`);
|
|
|
|
await asyncTest('ensureDir creates nested directories', async () => {
|
|
const p = path.join(TMP, 'ensure', 'deep', 'nested');
|
|
await fs.ensureDir(p);
|
|
assert(nativeFs.statSync(p).isDirectory(), 'ensureDir did not create nested dirs');
|
|
});
|
|
|
|
await asyncTest('ensureDir is idempotent on existing directory', async () => {
|
|
const p = path.join(TMP, 'ensure', 'deep', 'nested');
|
|
await fs.ensureDir(p); // should not throw
|
|
assert(nativeFs.statSync(p).isDirectory(), 'ensureDir failed on existing dir');
|
|
});
|
|
|
|
await asyncTest('pathExists returns true for existing path', async () => {
|
|
assertEqual(await fs.pathExists(__filename), true, 'pathExists returned false for existing file');
|
|
});
|
|
|
|
await asyncTest('pathExists returns false for missing path', async () => {
|
|
assertEqual(await fs.pathExists(path.join(TMP, 'nonexistent')), false, 'pathExists returned true for missing path');
|
|
});
|
|
|
|
test('pathExistsSync returns true for existing path', () => {
|
|
assertEqual(fs.pathExistsSync(__filename), true, 'pathExistsSync returned false for existing file');
|
|
});
|
|
|
|
test('pathExistsSync returns false for missing path', () => {
|
|
assertEqual(fs.pathExistsSync(path.join(TMP, 'nonexistent')), false, 'pathExistsSync returned true for missing path');
|
|
});
|
|
|
|
await asyncTest('copy copies a single file', async () => {
|
|
const src = path.join(TMP, 'copy-file-src.txt');
|
|
const dest = path.join(TMP, 'copy-file-dest.txt');
|
|
nativeFs.writeFileSync(src, 'copy file');
|
|
await fs.copy(src, dest);
|
|
assertEqual(nativeFs.readFileSync(dest, 'utf8'), 'copy file', 'copy file content mismatch');
|
|
});
|
|
|
|
await asyncTest('copy creates parent directories for dest', async () => {
|
|
const src = path.join(TMP, 'copy-mkdir-src.txt');
|
|
nativeFs.writeFileSync(src, 'copy mkdir');
|
|
const dest = path.join(TMP, 'copy-deep', 'nested', 'dest.txt');
|
|
await fs.copy(src, dest);
|
|
assertEqual(nativeFs.readFileSync(dest, 'utf8'), 'copy mkdir', 'copy with mkdir content mismatch');
|
|
});
|
|
|
|
await asyncTest('copy copies a directory recursively', async () => {
|
|
const srcDir = path.join(TMP, 'copy-dir-src');
|
|
nativeFs.mkdirSync(path.join(srcDir, 'sub'), { recursive: true });
|
|
nativeFs.writeFileSync(path.join(srcDir, 'a.txt'), 'file a');
|
|
nativeFs.writeFileSync(path.join(srcDir, 'sub', 'b.txt'), 'file b');
|
|
|
|
const destDir = path.join(TMP, 'copy-dir-dest');
|
|
await fs.copy(srcDir, destDir);
|
|
|
|
assertEqual(nativeFs.readFileSync(path.join(destDir, 'a.txt'), 'utf8'), 'file a', 'copy dir: top-level file mismatch');
|
|
assertEqual(nativeFs.readFileSync(path.join(destDir, 'sub', 'b.txt'), 'utf8'), 'file b', 'copy dir: nested file mismatch');
|
|
});
|
|
|
|
await asyncTest('copy respects overwrite: false for files', async () => {
|
|
const src = path.join(TMP, 'overwrite-src.txt');
|
|
const dest = path.join(TMP, 'overwrite-dest.txt');
|
|
nativeFs.writeFileSync(src, 'new content');
|
|
nativeFs.writeFileSync(dest, 'original content');
|
|
await fs.copy(src, dest, { overwrite: false });
|
|
assertEqual(nativeFs.readFileSync(dest, 'utf8'), 'original content', 'copy overwrote file when overwrite: false');
|
|
});
|
|
|
|
await asyncTest('copy respects overwrite: false for directories', async () => {
|
|
const srcDir = path.join(TMP, 'ow-dir-src');
|
|
nativeFs.mkdirSync(srcDir, { recursive: true });
|
|
nativeFs.writeFileSync(path.join(srcDir, 'file.txt'), 'new');
|
|
|
|
const destDir = path.join(TMP, 'ow-dir-dest');
|
|
nativeFs.mkdirSync(destDir, { recursive: true });
|
|
nativeFs.writeFileSync(path.join(destDir, 'file.txt'), 'original');
|
|
|
|
await fs.copy(srcDir, destDir, { overwrite: false });
|
|
assertEqual(nativeFs.readFileSync(path.join(destDir, 'file.txt'), 'utf8'), 'original', 'copy dir overwrote file when overwrite: false');
|
|
});
|
|
|
|
await asyncTest('copy respects filter option for files', async () => {
|
|
const srcDir = path.join(TMP, 'filter-src');
|
|
nativeFs.mkdirSync(srcDir, { recursive: true });
|
|
nativeFs.writeFileSync(path.join(srcDir, 'keep.txt'), 'keep me');
|
|
nativeFs.writeFileSync(path.join(srcDir, 'skip.log'), 'skip me');
|
|
|
|
const destDir = path.join(TMP, 'filter-dest');
|
|
await fs.copy(srcDir, destDir, {
|
|
filter: (src) => !src.endsWith('.log'),
|
|
});
|
|
|
|
assert(nativeFs.existsSync(path.join(destDir, 'keep.txt')), 'filter: kept file is missing');
|
|
assert(!nativeFs.existsSync(path.join(destDir, 'skip.log')), 'filter: skipped file was copied');
|
|
});
|
|
|
|
await asyncTest('copy respects filter option for directories', async () => {
|
|
const srcDir = path.join(TMP, 'filter-dir-src');
|
|
nativeFs.mkdirSync(path.join(srcDir, 'include'), { recursive: true });
|
|
nativeFs.mkdirSync(path.join(srcDir, 'node_modules'), { recursive: true });
|
|
nativeFs.writeFileSync(path.join(srcDir, 'include', 'a.txt'), 'included');
|
|
nativeFs.writeFileSync(path.join(srcDir, 'node_modules', 'b.txt'), 'excluded');
|
|
|
|
const destDir = path.join(TMP, 'filter-dir-dest');
|
|
await fs.copy(srcDir, destDir, {
|
|
filter: (src) => !src.includes('node_modules'),
|
|
});
|
|
|
|
assert(nativeFs.existsSync(path.join(destDir, 'include', 'a.txt')), 'filter: included dir file is missing');
|
|
assert(!nativeFs.existsSync(path.join(destDir, 'node_modules')), 'filter: excluded dir was copied');
|
|
});
|
|
|
|
await asyncTest('copy filter skips top-level src when filter returns false', async () => {
|
|
const src = path.join(TMP, 'filter-skip-src.txt');
|
|
const dest = path.join(TMP, 'filter-skip-dest.txt');
|
|
nativeFs.writeFileSync(src, 'should not be copied');
|
|
await fs.copy(src, dest, {
|
|
filter: () => false,
|
|
});
|
|
assert(!nativeFs.existsSync(dest), 'filter: file was copied despite filter returning false');
|
|
});
|
|
|
|
await asyncTest('remove deletes a file', async () => {
|
|
const p = path.join(TMP, 'remove-file.txt');
|
|
nativeFs.writeFileSync(p, 'delete me');
|
|
await fs.remove(p);
|
|
assert(!nativeFs.existsSync(p), 'remove did not delete file');
|
|
});
|
|
|
|
await asyncTest('remove deletes a directory recursively', async () => {
|
|
const dir = path.join(TMP, 'remove-dir');
|
|
nativeFs.mkdirSync(path.join(dir, 'sub'), { recursive: true });
|
|
nativeFs.writeFileSync(path.join(dir, 'sub', 'file.txt'), 'nested');
|
|
await fs.remove(dir);
|
|
assert(!nativeFs.existsSync(dir), 'remove did not delete directory');
|
|
});
|
|
|
|
await asyncTest('remove does not throw for missing path', async () => {
|
|
await fs.remove(path.join(TMP, 'nonexistent-remove-target'));
|
|
// should not throw — force: true
|
|
});
|
|
|
|
await asyncTest('move renames a file', async () => {
|
|
const src = path.join(TMP, 'move-src.txt');
|
|
const dest = path.join(TMP, 'move-dest.txt');
|
|
nativeFs.writeFileSync(src, 'move me');
|
|
await fs.move(src, dest);
|
|
assert(!nativeFs.existsSync(src), 'move did not remove source');
|
|
assertEqual(nativeFs.readFileSync(dest, 'utf8'), 'move me', 'move content mismatch');
|
|
});
|
|
|
|
await asyncTest('move renames a directory', async () => {
|
|
const srcDir = path.join(TMP, 'move-dir-src');
|
|
nativeFs.mkdirSync(srcDir, { recursive: true });
|
|
nativeFs.writeFileSync(path.join(srcDir, 'file.txt'), 'dir move');
|
|
|
|
const destDir = path.join(TMP, 'move-dir-dest');
|
|
await fs.move(srcDir, destDir);
|
|
assert(!nativeFs.existsSync(srcDir), 'move did not remove source dir');
|
|
assertEqual(nativeFs.readFileSync(path.join(destDir, 'file.txt'), 'utf8'), 'dir move', 'move dir content mismatch');
|
|
});
|
|
|
|
test('readJsonSync parses JSON file', () => {
|
|
const p = path.join(TMP, 'test.json');
|
|
nativeFs.writeFileSync(p, JSON.stringify({ key: 'value', num: 42 }));
|
|
const result = fs.readJsonSync(p);
|
|
assertEqual(result.key, 'value', 'readJsonSync key mismatch');
|
|
assertEqual(result.num, 42, 'readJsonSync num mismatch');
|
|
});
|
|
|
|
test('readJsonSync throws on invalid JSON', () => {
|
|
const p = path.join(TMP, 'bad.json');
|
|
nativeFs.writeFileSync(p, '{ invalid json }');
|
|
let threw = false;
|
|
let errorMessage = '';
|
|
try {
|
|
fs.readJsonSync(p);
|
|
} catch (error) {
|
|
threw = true;
|
|
errorMessage = error.message;
|
|
}
|
|
assert(threw, 'readJsonSync did not throw on invalid JSON');
|
|
assert(errorMessage.includes(p), 'readJsonSync error did not include file path');
|
|
});
|
|
|
|
test('readJsonSync strips UTF-8 BOM', () => {
|
|
const p = path.join(TMP, 'bom.json');
|
|
nativeFs.writeFileSync(p, '\uFEFF{"bom": true}');
|
|
const result = fs.readJsonSync(p);
|
|
assertEqual(result.bom, true, 'readJsonSync failed to parse BOM-prefixed JSON');
|
|
});
|
|
|
|
console.log('');
|
|
|
|
// ── Bulk copy stress test ───────────────────────────────────────────────
|
|
|
|
console.log(`${colors.yellow}Bulk copy determinism${colors.reset}`);
|
|
|
|
await asyncTest('copy preserves all files in a large directory tree', async () => {
|
|
// Create a tree with 200+ files to verify no silent loss
|
|
const srcDir = path.join(TMP, 'bulk-src');
|
|
const fileCount = 250;
|
|
|
|
for (let i = 0; i < fileCount; i++) {
|
|
const subDir = path.join(srcDir, `dir-${String(Math.floor(i / 10)).padStart(2, '0')}`);
|
|
nativeFs.mkdirSync(subDir, { recursive: true });
|
|
nativeFs.writeFileSync(path.join(subDir, `file-${i}.txt`), `content-${i}`);
|
|
}
|
|
|
|
const destDir = path.join(TMP, 'bulk-dest');
|
|
await fs.copy(srcDir, destDir);
|
|
|
|
// Count all files in destination
|
|
let destCount = 0;
|
|
const countFiles = (dir) => {
|
|
const entries = nativeFs.readdirSync(dir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory()) {
|
|
countFiles(path.join(dir, entry.name));
|
|
} else {
|
|
destCount++;
|
|
}
|
|
}
|
|
};
|
|
countFiles(destDir);
|
|
|
|
assertEqual(destCount, fileCount, `bulk copy lost files: expected ${fileCount}, got ${destCount}`);
|
|
});
|
|
|
|
console.log('');
|
|
|
|
// ── Cleanup ─────────────────────────────────────────────────────────────
|
|
|
|
teardown();
|
|
|
|
// ── Summary ─────────────────────────────────────────────────────────────
|
|
console.log(`${colors.cyan}========================================`);
|
|
console.log('Test Results:');
|
|
console.log(` Total: ${totalTests}`);
|
|
console.log(` Passed: ${colors.green}${passedTests}${colors.reset}`);
|
|
console.log(` Failed: ${colors.red}${totalTests - passedTests}${colors.reset}`);
|
|
console.log(`========================================${colors.reset}\n`);
|
|
|
|
if (failures.length === 0) {
|
|
console.log(`${colors.green}\u2728 All fs wrapper tests passed!${colors.reset}\n`);
|
|
process.exit(0);
|
|
} else {
|
|
console.log(`${colors.red}\u274C Some fs wrapper tests failed${colors.reset}\n`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Run tests
|
|
runTests().catch((error) => {
|
|
teardown();
|
|
console.error(`${colors.red}Test runner failed:${colors.reset}`, error.message);
|
|
console.error(error.stack);
|
|
process.exit(1);
|
|
});
|