bauset
Version:
Simple npm Package builder
588 lines (491 loc) • 22.7 kB
JavaScript
#!/usr/bin/env node
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const config = require('../lib/config.js');
const { fileCopy, createPackage } = require('../lib/index.js');
// ─── helpers ─────────────────────────────────────────────────────────────────
function makeTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'bauset-test-'));
}
function rmTmpDir(dir) {
fs.rmSync(dir, { recursive: true, force: true });
}
const noop = () => {};
// ─── loadConfig ──────────────────────────────────────────────────────────────
describe('loadConfig', () => {
let tmp;
beforeEach(() => { tmp = makeTmpDir(); });
afterEach(() => { rmTmpDir(tmp); });
test('returns {} for a missing file', () => {
const r = config.loadConfig(path.join(tmp, 'nope.txt'));
assert.deepEqual(r, {});
});
test('parses key-value pairs', () => {
const f = path.join(tmp, 'cfg.txt');
fs.writeFileSync(f, 'name mypackage\nversion 1.2.3\n');
const r = config.loadConfig(f);
assert.equal(r.name, 'mypackage');
assert.equal(r.version, '1.2.3');
});
test('lowercases keys', () => {
const f = path.join(tmp, 'cfg.txt');
fs.writeFileSync(f, 'NAME mypackage\nVERSION 1.0.0\n');
const r = config.loadConfig(f);
assert.equal(r.name, 'mypackage');
assert.equal(r.version, '1.0.0');
});
test('strips # comment lines', () => {
const f = path.join(tmp, 'cfg.txt');
fs.writeFileSync(f, '# this is a comment\nname foo\n');
const r = config.loadConfig(f);
assert.equal(r.name, 'foo');
assert.equal(Object.keys(r).length, 1);
});
test('joins multi-word values with a space', () => {
const f = path.join(tmp, 'cfg.txt');
fs.writeFileSync(f, 'description Simple npm Package builder\n');
const r = config.loadConfig(f);
assert.equal(r.description, 'Simple npm Package builder');
});
test('ignores lines that have only a key and no value', () => {
const f = path.join(tmp, 'cfg.txt');
fs.writeFileSync(f, 'name\nversion 1.0.0\n');
const r = config.loadConfig(f);
assert.ok(!('name' in r));
assert.equal(r.version, '1.0.0');
});
test('handles blank lines gracefully', () => {
const f = path.join(tmp, 'cfg.txt');
fs.writeFileSync(f, '\n\nname foo\n\nversion 1.0.0\n\n');
const r = config.loadConfig(f);
assert.equal(r.name, 'foo');
assert.equal(r.version, '1.0.0');
});
test('handles CRLF line endings', () => {
const f = path.join(tmp, 'cfg.txt');
fs.writeFileSync(f, 'name foo\r\nversion 1.0.0\r\n');
const r = config.loadConfig(f);
assert.equal(r.name, 'foo');
assert.equal(r.version, '1.0.0');
});
test('parses a realistic PROJECT.txt', () => {
const f = path.join(tmp, 'PROJECT.txt');
fs.writeFileSync(f, [
'# Project configuration',
'name bauset',
'version 0.1.3',
'description Simple npm Package builder',
'author Robert Umbehant',
'license MIT',
'url https://github.com/wheresjames/bauset-js',
].join('\n') + '\n');
const r = config.loadConfig(f);
assert.equal(r.name, 'bauset');
assert.equal(r.version, '0.1.3');
assert.equal(r.license, 'MIT');
});
});
// ─── processFile ─────────────────────────────────────────────────────────────
describe('processFile', () => {
let tmp;
beforeEach(() => { tmp = makeTmpDir(); });
afterEach(() => { rmTmpDir(tmp); });
function run(input, cfg, removeComments = true) {
const fin = path.join(tmp, 'in.txt');
const fout = path.join(tmp, 'out.txt');
fs.writeFileSync(fin, input);
config.processFile(cfg, fin, fout, removeComments);
return fs.readFileSync(fout, 'utf8');
}
test('substitutes a single variable', () => {
const out = run('"name": "%NAME%"', { name: 'mypackage' });
assert.equal(out, '"name": "mypackage"');
});
test('substitutes multiple variables', () => {
const out = run('"name": "%NAME%"\n"version": "%VERSION%"',
{ name: 'mypackage', version: '1.0.0' });
assert.equal(out, '"name": "mypackage"\n"version": "1.0.0"');
});
test('substitutes all variables, not just the first', () => {
const out = run('%A% %B% %C%', { a: '1', b: '2', c: '3' });
assert.equal(out, '1 2 3');
});
test('leaves unknown variables intact', () => {
const out = run('"x": "%UNKNOWN%"', {});
assert.equal(out, '"x": "%UNKNOWN%"');
});
test('matches variable keys case-insensitively', () => {
const out = run('%Name%-%NAME%-%name%', { name: 'foo' });
assert.equal(out, 'foo-foo-foo');
});
test('substitutes variables whose replacement contains special characters', () => {
const out = run('%URL%', { url: 'https://example.com/path?q=1&r=2' });
assert.equal(out, 'https://example.com/path?q=1&r=2');
});
test('strips // comment lines', () => {
const out = run('// a comment\n"name": "foo"\n', {});
assert.ok(!out.includes('// a comment'));
assert.ok(out.includes('"name": "foo"'));
});
test('strips indented // comment lines', () => {
const out = run(' // indented comment\n"name": "foo"\n', {});
assert.ok(!out.includes('// indented comment'));
});
test('strips # comment lines', () => {
const out = run('# a comment\n"name": "foo"\n', {});
assert.ok(!out.includes('# a comment'));
assert.ok(out.includes('"name": "foo"'));
});
test('does not strip lines where // appears mid-content (e.g. URLs)', () => {
const out = run('"url": "https://example.com"\n', {});
assert.ok(out.includes('"url": "https://example.com"'));
});
test('does not strip lines where # appears mid-content (e.g. hex colors)', () => {
const out = run('"color": "#ff0000"\n', {});
assert.ok(out.includes('"color": "#ff0000"'));
});
test('removeComments=false skips stripping', () => {
const out = run('// comment\n# comment\n"x": 1\n', {}, false);
assert.ok(out.includes('// comment'));
assert.ok(out.includes('# comment'));
});
test('processes a template that resembles in.package.json', () => {
const template = JSON.stringify({
name: '%NAME%',
version: '%VERSION%',
description: '%DESCRIPTION%',
author: '%AUTHOR%',
license: '%LICENSE%',
}, null, 2);
const cfg = {
name: 'testpkg',
version: '1.2.3',
description: 'A test package',
author: 'Tester',
license: 'MIT',
};
const out = run(template, cfg);
const parsed = JSON.parse(out);
assert.equal(parsed.name, 'testpkg');
assert.equal(parsed.version, '1.2.3');
assert.equal(parsed.author, 'Tester');
});
});
// ─── parseParams ─────────────────────────────────────────────────────────────
describe('parseParams', () => {
const opts = [
['i', 'install', 'Install package'],
['g', 'global', 'Install globally'],
['s', 'source=', 'Source directory'],
['m', 'multi=+', 'Multi-value option'],
['v', 'version', 'Show version'],
];
function parse(argv, allowUnknown = false) {
return config.parseParams('prog [opts]', argv, opts, allowUnknown);
}
// Boolean flags
test('parses a short boolean flag', () => {
assert.equal(parse(['node', 'prog', '-i']).install, true);
});
test('parses a long boolean flag', () => {
assert.equal(parse(['node', 'prog', '--install']).install, true);
});
test('parses combined short boolean flags', () => {
const r = parse(['node', 'prog', '-ig']);
assert.equal(r.install, true);
assert.equal(r.global, true);
});
test('parses multiple separate short flags', () => {
const r = parse(['node', 'prog', '-i', '-g']);
assert.equal(r.install, true);
assert.equal(r.global, true);
});
// Options with values
test('parses a short option with a space-separated value', () => {
assert.equal(parse(['node', 'prog', '-s', '/tmp/src']).source, '/tmp/src');
});
test('parses a long option with a space-separated value', () => {
assert.equal(parse(['node', 'prog', '--source', '/tmp/src']).source, '/tmp/src');
});
test('parses a long option with = assignment', () => {
assert.equal(parse(['node', 'prog', '--source=/tmp/src']).source, '/tmp/src');
});
test('parses a short option with = assignment', () => {
assert.equal(parse(['node', 'prog', '-s=/tmp/src']).source, '/tmp/src');
});
test('parses flags and options mixed together', () => {
const r = parse(['node', 'prog', '-ig', '--source', '/tmp', 'extra']);
assert.equal(r.install, true);
assert.equal(r.global, true);
assert.equal(r.source, '/tmp');
assert.ok(r['*'].includes('extra'));
});
// Multi-value options
test('multi-value option collects multiple consecutive arguments', () => {
const r = parse(['node', 'prog', '-m', 'a', 'b', 'c']);
assert.deepEqual(r.multi, ['a', 'b', 'c']);
});
test('multi-value option stops at the next flag', () => {
const r = parse(['node', 'prog', '-m', 'a', 'b', '-i']);
assert.deepEqual(r.multi, ['a', 'b']);
assert.equal(r.install, true);
});
// Non-option arguments
test('non-option arguments accumulate in *', () => {
const r = parse(['node', 'prog', 'foo', 'bar', 'baz']);
assert.deepEqual(r['*'], ['node', 'prog', 'foo', 'bar', 'baz']);
});
// Unknown options
test('throws on unknown short option when allowUnknownOptions=false', () => {
assert.throws(() => parse(['node', 'prog', '-z'], false));
});
test('throws on unknown long option when allowUnknownOptions=false', () => {
assert.throws(() => parse(['node', 'prog', '--unknown'], false));
});
test('accepts unknown option when allowUnknownOptions=true', () => {
assert.doesNotThrow(() => parse(['node', 'prog', '--unknown'], true));
});
// Help
test('auto-adds -h / --help when neither is defined', () => {
const r = parse(['node', 'prog', '-h']);
assert.ok(typeof r.help === 'string');
assert.ok(r.help.length > 0);
});
test('help message lists all defined options', () => {
const r = parse(['node', 'prog', '--help']);
assert.ok(r.help.includes('install'));
assert.ok(r.help.includes('global'));
assert.ok(r.help.includes('source'));
assert.ok(r.help.includes('version'));
});
test('help message includes short flags', () => {
const r = parse(['node', 'prog', '--help']);
assert.ok(r.help.includes('-i'));
assert.ok(r.help.includes('-g'));
assert.ok(r.help.includes('-s'));
});
test('does not add help if -h is already defined by caller', () => {
const withH = [['h', 'halt', 'Stop everything'], ...opts];
const r = config.parseParams('prog', ['node', 'prog', '-h'], withH);
assert.equal(r.halt, true);
// help key should not be a formatted string since -h was consumed as --halt
assert.ok(typeof r.help !== 'string');
});
});
// ─── fileCopy ─────────────────────────────────────────────────────────────────
describe('fileCopy', () => {
let tmp;
beforeEach(() => { tmp = makeTmpDir(); });
afterEach(() => { rmTmpDir(tmp); });
test('copies a single file', () => {
const src = path.join(tmp, 'src.txt');
const dst = path.join(tmp, 'dst.txt');
fs.writeFileSync(src, 'hello world');
fileCopy(src, dst, {});
assert.equal(fs.readFileSync(dst, 'utf8'), 'hello world');
});
test('silently skips when source does not exist', () => {
assert.doesNotThrow(() => {
fileCopy(path.join(tmp, 'nope.txt'), path.join(tmp, 'out.txt'), {});
});
assert.ok(!fs.existsSync(path.join(tmp, 'out.txt')));
});
test('copies a directory recursively when recursive=true', () => {
const src = path.join(tmp, 'src');
const dst = path.join(tmp, 'dst');
fs.mkdirSync(src);
fs.mkdirSync(path.join(src, 'sub'));
fs.writeFileSync(path.join(src, 'a.txt'), 'a');
fs.writeFileSync(path.join(src, 'sub', 'b.txt'), 'b');
fileCopy(src, dst, { recursive: true, overwrite: true });
assert.equal(fs.readFileSync(path.join(dst, 'a.txt'), 'utf8'), 'a');
assert.equal(fs.readFileSync(path.join(dst, 'sub', 'b.txt'), 'utf8'), 'b');
});
test('does not recurse into subdirectories when recursive=false', () => {
const src = path.join(tmp, 'src');
const dst = path.join(tmp, 'dst');
fs.mkdirSync(src);
fs.mkdirSync(path.join(src, 'sub'));
fs.writeFileSync(path.join(src, 'a.txt'), 'a');
fs.writeFileSync(path.join(src, 'sub', 'b.txt'), 'b');
fileCopy(src, dst, { recursive: false, overwrite: true });
assert.ok(fs.existsSync(path.join(dst, 'a.txt')), 'top-level file should be copied');
assert.ok(!fs.existsSync(path.join(dst, 'sub')), 'subdirectory should not be copied');
});
test('preserves file content byte-for-byte', () => {
const src = path.join(tmp, 'src.bin');
const dst = path.join(tmp, 'dst.bin');
const data = Buffer.from([0x00, 0xff, 0x0a, 0x1b, 0xfe]);
fs.writeFileSync(src, data);
fileCopy(src, dst, {});
assert.ok(fs.readFileSync(dst).equals(data));
});
// fileCopy always deletes and recreates the destination directory before
// the copy loop runs, so overwrite=false has no effect on the top-level
// dst contents — they are always replaced. This test documents that
// (potentially surprising) behaviour.
test('replaces dst contents even when overwrite=false (dst is always wiped first)', () => {
const src = path.join(tmp, 'src');
const dst = path.join(tmp, 'dst');
fs.mkdirSync(src);
fs.mkdirSync(dst);
fs.writeFileSync(path.join(src, 'a.txt'), 'new');
fs.writeFileSync(path.join(dst, 'a.txt'), 'old');
fileCopy(src, dst, { recursive: true, overwrite: false });
assert.equal(fs.readFileSync(path.join(dst, 'a.txt'), 'utf8'), 'new');
});
test('overwrites an existing file when overwrite=true', () => {
const src = path.join(tmp, 'src');
const dst = path.join(tmp, 'dst');
fs.mkdirSync(src);
fs.mkdirSync(dst);
fs.writeFileSync(path.join(src, 'a.txt'), 'new');
fs.writeFileSync(path.join(dst, 'a.txt'), 'old');
fileCopy(src, dst, { recursive: true, overwrite: true });
assert.equal(fs.readFileSync(path.join(dst, 'a.txt'), 'utf8'), 'new');
});
test('replaces an existing destination directory', () => {
const src = path.join(tmp, 'src');
const dst = path.join(tmp, 'dst');
fs.mkdirSync(src);
fs.mkdirSync(dst);
// Put a stale file in dst that is not in src
fs.writeFileSync(path.join(dst, 'stale.txt'), 'stale');
fs.writeFileSync(path.join(src, 'fresh.txt'), 'fresh');
fileCopy(src, dst, { recursive: true, overwrite: true });
assert.ok(!fs.existsSync(path.join(dst, 'stale.txt')), 'stale file should be removed');
assert.ok(fs.existsSync(path.join(dst, 'fresh.txt')));
});
test('calls Log for each copied file when Log is provided', () => {
const src = path.join(tmp, 'src.txt');
const dst = path.join(tmp, 'dst.txt');
fs.writeFileSync(src, 'data');
const messages = [];
fileCopy(src, dst, { Log: (msg) => messages.push(msg) });
assert.ok(messages.some(m => m.includes('copied')));
});
});
// ─── createPackage ───────────────────────────────────────────────────────────
describe('createPackage', () => {
let tmp;
let savedCwd;
beforeEach(() => {
tmp = makeTmpDir();
savedCwd = process.cwd();
});
afterEach(() => {
// createPackage calls process.chdir — always restore
try { process.chdir(savedCwd); } catch (_) {}
rmTmpDir(tmp);
});
function makeProject(src, overrides = {}) {
fs.mkdirSync(src, { recursive: true });
fs.mkdirSync(path.join(src, 'var'));
fs.mkdirSync(path.join(src, 'lib'));
fs.mkdirSync(path.join(src, 'bin'));
// Use 'in' so that passing null explicitly means "omit this file"
const projectTxt = 'projectTxt' in overrides ? overrides.projectTxt : [
'name testpkg',
'version 0.0.1',
'description Test package',
'author Test Author',
'license MIT',
'url https://example.com',
'repo git+https://example.com/test.git',
].join('\n') + '\n';
if (projectTxt !== null)
fs.writeFileSync(path.join(src, 'PROJECT.txt'), projectTxt);
const pkgTemplate = 'pkgTemplate' in overrides ? overrides.pkgTemplate : JSON.stringify({
name: '%NAME%',
version: '%VERSION%',
description: '%DESCRIPTION%',
main: 'lib/index.js',
author: '%AUTHOR%',
license: '%LICENSE%',
}, null, 2);
if (pkgTemplate !== null)
fs.writeFileSync(path.join(src, 'var', 'in.package.json'), pkgTemplate);
fs.writeFileSync(path.join(src, 'lib', 'index.js'), "'use strict';\n");
fs.writeFileSync(path.join(src, 'README.md'), '# test\n');
fs.writeFileSync(path.join(src, 'LICENSE'), 'MIT\n');
}
test('rejects when PROJECT.txt is missing', async () => {
const src = path.join(tmp, 'src');
fs.mkdirSync(src);
await assert.rejects(() => createPackage(src, path.join(tmp, 'dst'), { Log: noop }));
});
test('rejects when PROJECT.txt has no valid entries', async () => {
const src = path.join(tmp, 'src');
fs.mkdirSync(src);
fs.writeFileSync(path.join(src, 'PROJECT.txt'), '# only a comment\n');
await assert.rejects(() => createPackage(src, path.join(tmp, 'dst'), { Log: noop }));
});
test('rejects when in.package.json template is missing', async () => {
const src = path.join(tmp, 'src');
makeProject(src, { pkgTemplate: null });
await assert.rejects(() => createPackage(src, path.join(tmp, 'dst'), { Log: noop }));
});
test('wipes and recreates dst when it already exists', async () => {
const src = path.join(tmp, 'src');
const dst = path.join(tmp, 'dst');
fs.mkdirSync(dst);
fs.writeFileSync(path.join(dst, 'sentinel.txt'), 'stale');
makeProject(src, { pkgTemplate: null }); // will reject after cleaning dst
await assert.rejects(() => createPackage(src, dst, { Log: noop }));
assert.ok(!fs.existsSync(path.join(dst, 'sentinel.txt')), 'dst should have been wiped');
});
test('writes PROJECT.json with __created__ timestamp', async () => {
const src = path.join(tmp, 'src');
const dst = path.join(tmp, 'dst');
makeProject(src);
await createPackage(src, dst, { Log: noop });
const json = JSON.parse(fs.readFileSync(path.join(dst, 'PROJECT.json'), 'utf8'));
assert.ok(typeof json.__created__ === 'number');
assert.ok(typeof json.__createdstr__ === 'string');
});
test('writes a valid package.json with substituted values', async () => {
const src = path.join(tmp, 'src');
const dst = path.join(tmp, 'dst');
makeProject(src);
await createPackage(src, dst, { Log: noop });
const pkg = JSON.parse(fs.readFileSync(path.join(dst, 'package.json'), 'utf8'));
assert.equal(pkg.name, 'testpkg');
assert.equal(pkg.version, '0.0.1');
assert.equal(pkg.license, 'MIT');
});
test('creates a versioned .tgz in ./pkg', async () => {
const src = path.join(tmp, 'src');
const dst = path.join(tmp, 'dst');
makeProject(src);
await createPackage(src, dst, { Log: noop });
assert.ok(fs.existsSync(path.join(src, 'pkg', 'testpkg-0.0.1.tgz')));
});
test('creates a -latest.tgz alias in ./pkg', async () => {
const src = path.join(tmp, 'src');
const dst = path.join(tmp, 'dst');
makeProject(src);
await createPackage(src, dst, { Log: noop });
assert.ok(fs.existsSync(path.join(src, 'pkg', 'testpkg-latest.tgz')));
});
test('copies source files into dst', async () => {
const src = path.join(tmp, 'src');
const dst = path.join(tmp, 'dst');
makeProject(src);
await createPackage(src, dst, { Log: noop });
assert.ok(fs.existsSync(path.join(dst, 'lib', 'index.js')));
assert.ok(fs.existsSync(path.join(dst, 'README.md')));
assert.ok(fs.existsSync(path.join(dst, 'LICENSE')));
});
test('returns a Promise', () => {
const src = path.join(tmp, 'src');
const dst = path.join(tmp, 'dst');
fs.mkdirSync(src);
const p = createPackage(src, dst, { Log: noop });
assert.ok(p instanceof Promise);
return p.catch(() => {}); // suppress unhandled rejection
});
});