UNPKG

bauset

Version:
588 lines (491 loc) 22.7 kB
#!/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 }); });