@zenfs/core
Version:
A filesystem, anywhere
280 lines (232 loc) • 8.71 kB
JavaScript
// NOTE: Not compiled, use erasable TS only
import { execSync } from 'node:child_process';
import { existsSync, globSync, mkdirSync, rmSync } from 'node:fs';
import { join, parse, basename } from 'node:path';
import { parseArgs, styleText } from 'node:util';
const { values: options, positionals } = parseArgs({
options: {
// Output
help: { short: 'h', type: 'boolean', default: false },
verbose: { short: 'v', type: 'boolean', default: false },
quiet: { short: 'q', type: 'boolean', default: false },
log: { short: 'l', type: 'string', default: '' },
'file-names': { short: 'N', type: 'boolean', default: false },
ci: { short: 'C', type: 'boolean', default: false },
debug: { short: 'd', type: 'boolean', default: false },
// Test behavior
test: { short: 't', type: 'string' },
force: { short: 'f', type: 'boolean', default: false },
auto: { short: 'a', type: 'boolean', default: false },
build: { short: 'b', type: 'boolean', default: false },
common: { short: 'c', type: 'boolean', default: false },
inspect: { short: 'I', type: 'boolean', default: false },
skip: { short: 's', type: 'string', multiple: true, default: [] },
'exit-on-fail': { short: 'e', type: 'boolean' },
runs: { short: 'r', type: 'string' },
// Coverage and performance
coverage: { type: 'string', default: 'tests/.coverage' },
preserve: { short: 'p', type: 'boolean' },
report: { type: 'boolean', default: false },
clean: { type: 'boolean', default: false },
profile: { type: 'boolean', default: false },
},
allowPositionals: true,
});
function debug(...args) {
if (options.debug) console.debug(styleText('dim', '[debug]'), ...args.map(a => (typeof a === 'string' ? styleText('dim', a) : a)));
}
if (options.help) {
console.log(`zenfs-test [...options] <...paths>
Paths: The setup files to run tests on
Behavior:
-a, --auto Automatically detect setup files
-b, --build Run the npm build script prior to running tests
-c, --common Also run tests not specific to any backend
-e, --exit-on-fail If any tests suites fail, exit immediately
-r, --runs <n> Run tests n times and print average result
-t, --test <glob> Which FS test suite(s) to run
-f, --force Whether to use --test-force-exit
-I, --inspect Use the inspector for debugging
-s, --skip <pattern> Skip tests with names matching the given pattern. Can be specified multiple times.
-d, --debug Output debug messages from the test runner
Output:
-h, --help Outputs this help message
-v, --verbose Output verbose messages
-q, --quiet Don't output normal messages
-l, --logs <level> Change the default log level for test output. Level can be a number or string
-N, --file-names Use full file paths for tests from setup files instead of the base name
-C, --ci Continuous integration (CI) mode. This interacts with the Github
Checks API for better test status. Requires @octokit/action
Coverage:
--coverage <dir> Override the default coverage data directory
-p, --preserve Do not delete or report coverage data
--report ONLY report coverage
--clean ONLY clean up coverage directory
--profile Record performance profiles`);
process.exit();
}
if (options.quiet && options.verbose) {
console.error('ERROR: Can not specify --verbose and --quiet');
process.exit(1);
}
process.env.NODE_V8_COVERAGE = options.coverage;
process.env.ZENFS_LOG_LEVEL = options.log;
if (options.verbose) process.env.VERBOSE = '1';
if (options.clean) {
rmSync(options.coverage, { recursive: true, force: true });
process.exit();
}
function report() {
try {
execSync('npx c8 report --reporter=text', { stdio: 'inherit' });
} catch (e) {
console.error('Failed to generate coverage report!');
console.error(e);
} finally {
rmSync(options.coverage, { recursive: true });
}
}
if (options.report) {
report();
process.exit();
}
let ci;
if (options.ci) {
if (options.runs) {
console.error('Cannot use --ci with --runs');
process.exit(1);
}
ci = await import('./ci.js');
}
options.verbose && options.force && console.debug('Forcing tests to exit (--test-force-exit)');
if (options.build) {
!options.quiet && process.stdout.write('Building... ');
try {
execSync('npm run build');
console.log('done.');
} catch {
console.warn('failed, continuing without it.');
}
}
if (!existsSync(join(import.meta.dirname, '../dist'))) {
console.error('ERROR: Missing build. If you are using an installed package, please submit a bug report.');
process.exit(1);
}
if (options.auto) {
let sum = 0;
for (const pattern of ['**/tests/setup/*.ts', '**/tests/setup-*.ts']) {
const files = await globSync(pattern).filter(f => !f.includes('node_modules'));
sum += files.length;
positionals.push(...files);
}
!options.quiet && console.log(`Auto-detected ${sum} test setup files`);
}
if (!options.preserve) rmSync(options.coverage, { force: true, recursive: true });
mkdirSync(options.coverage, { recursive: true });
/**
* Generate the command used to run the tests
* @param {string} profileName
* @param {...string} rest
* @returns {string}
*/
function makeCommand(profileName, ...rest) {
const command = [
'tsx --trace-deprecation',
options.inspect ? '--inspect' : '',
'--test --experimental-test-coverage',
options.force ? '--test-force-exit' : '',
options.skip.length ? `--test-skip-pattern='${options.skip.join('|').replaceAll("'", "\\'")}'` : '',
!options.profile ? '' : `--cpu-prof --cpu-prof-dir=.profiles --cpu-prof-name=${profileName}.cpuprofile --cpu-prof-interval=500`,
...rest,
]
.filter(v => v)
.join(' ');
if (!options.quiet) debug('command:', command);
return command;
}
/**
* @param {number} ms
*/
function duration(ms) {
ms = Math.round(ms);
let unit = 'ms';
if (ms > 5000) {
ms /= 1000;
unit = 's';
}
return ms + ' ' + unit;
}
const nRuns = Number.isSafeInteger(parseInt(options.runs)) ? parseInt(options.runs) : 1;
/**
* @typedef {object} RunTestOptions
* @property {string} name
* @property {string[]} args
* @property {string} [statusName]
* @property {() => boolean} [shouldSkip]
*/
/**
* @param {RunTestOptions} config
*/
async function runTests(config) {
const statusName = config.statusName || config.name;
const command = makeCommand(config.name, ...config.args);
let totalTime = 0;
for (let i = 0; i < nRuns; i++) {
const start = performance.now();
if (options.ci) await ci.startCheck(statusName);
const time = () => styleText('dim', `(${duration(Math.round(performance.now() - start))})`);
let identText = options.verbose ? `: ${statusName}` : '';
if (nRuns != 1) identText += ` [${i + 1}/${nRuns}]`;
if (!options.quiet) {
if (options.verbose) console.log('Running tests:', config.name);
else process.stdout.write(`Running tests: ${config.name}... `);
}
if (config.shouldSkip?.()) {
if (!options.quiet) console.log(`${styleText('yellow', 'skipped')}${identText} ${time()}`);
if (options.ci) await ci.completeCheck(statusName, 'skipped');
return;
}
try {
execSync(command, {
stdio: ['ignore', options.verbose ? 'inherit' : 'ignore', 'inherit'],
});
if (!options.quiet) console.log(`${styleText('green', 'passed')}${identText} ${time()}`);
if (options.ci) await ci.completeCheck(statusName, 'success');
totalTime += performance.now() - start;
} catch {
console.error(`${styleText(['red', 'bold'], 'failed')}${identText} ${time()}`);
if (options.ci) await ci.completeCheck(statusName, 'failure');
process.exitCode = 1;
if (options['exit-on-fail']) process.exit();
return;
}
}
if (nRuns != 1) {
console.log('Average', config.name, 'time:', styleText('blueBright', duration(totalTime / nRuns)));
}
}
if (options.common) {
await runTests({
name: 'common',
args: [`'tests/*.test.ts'`, `'tests/**/!(fs)/*.test.ts'`],
statusName: 'Common tests',
});
}
const testsGlob = join(import.meta.dirname, `../tests/fs/${options.test || '*'}.test.ts`);
for (const setupFile of positionals) {
if (!existsSync(setupFile)) {
!options.quiet && console.warn('Skipping tests for non-existent setup file:', setupFile);
continue;
}
process.env.SETUP = setupFile;
const name = options['file-names'] && !options.ci ? setupFile : parse(setupFile).name;
await runTests({
name,
args: [`'${testsGlob.replaceAll("'", "\\'")}'`, process.env.CMD],
shouldSkip() {
return basename(setupFile).startsWith('_');
},
});
}
if (!options.preserve) report();