diff --git a/package.json b/package.json index 3a02413a4..fc3577b91 100644 --- a/package.json +++ b/package.json @@ -37,10 +37,13 @@ "@types/node": "^22.15.29", "ava": "^6.2.0", "cross-env": "^10.1.0", + "esmock": "^2.7.3", "execa": "^9.5.2", "js-yaml": "^4.1.1", "jsdom": "^26.1.0", + "memfs": "^4.57.1", "playwright": "^1.52.0", + "sinon": "^21.0.3", "tap-xunit": "^2.4.1" } } diff --git a/packages/cli/_tests/commands/create.test.js b/packages/cli/_tests/commands/create.test.js new file mode 100644 index 000000000..ce53d2e8a --- /dev/null +++ b/packages/cli/_tests/commands/create.test.js @@ -0,0 +1,309 @@ +import esmock from 'esmock' +import path from 'node:path' +import sinon from 'sinon' +import test from 'ava' +import { Volume, createFsFromVolume } from 'memfs' + +test.beforeEach((t) => { + // Create sinon sandbox for mocking + t.context.sandbox = sinon.createSandbox() + + // Create in-memory file system + t.context.vol = new Volume() + t.context.fs = createFsFromVolume(t.context.vol) + + // Setup mock directory structure + t.context.vol.fromJSON({ + '/starter-template/content/_data/config.yaml': 'title: Starter Template', + '/starter-template/package.json': JSON.stringify({ name: 'starter-template' }) + }) + + t.context.projectRoot = '/new-project' +}) + +test.afterEach.always((t) => { + // Restore all mocks + t.context.sandbox.restore() + + // Clear in-memory file system + t.context.vol.reset() +}) + +test('create command should initialize starter and install quire', async (t) => { + const { sandbox, fs } = t.context + + // Mock quire library + const mockQuire = { + initStarter: sandbox.stub().callsFake(async (starter, projectPath) => { + // Simulate creating project directory + fs.mkdirSync(projectPath, { recursive: true }) + fs.writeFileSync(`${projectPath}/package.json`, JSON.stringify({ name: 'new-project' })) + return '1.0.0' + }), + installInProject: sandbox.stub().resolves() + } + + // Mock config + const mockConfig = { + get: sandbox.stub().returns('default-starter') + } + + // Use esmock to replace imports + const CreateCommand = await esmock('../../src/commands/create.js', { + '#src/lib/quire/index.js': { + quire: mockQuire + }, + 'fs-extra': fs + }) + + const command = new CreateCommand() + command.config = mockConfig + command.name = sandbox.stub().returns('new') + + // Run action + await command.action('/new-project', 'starter-template', {}) + + t.true(mockQuire.initStarter.called, 'initStarter should be called') + t.true(mockQuire.initStarter.calledWith('starter-template', '/new-project'), 'initStarter should be called with correct arguments') + t.true(mockQuire.installInProject.called, 'installInProject should be called') + t.true(mockQuire.installInProject.calledWith('/new-project', '1.0.0'), 'installInProject should be called with project path and version') +}) + +test('create command should use default starter from config when not provided', async (t) => { + const { sandbox, fs } = t.context + + // Mock quire library + const mockQuire = { + initStarter: sandbox.stub().callsFake(async (starter, projectPath) => { + fs.mkdirSync(projectPath, { recursive: true }) + fs.writeFileSync(`${projectPath}/package.json`, JSON.stringify({ name: 'new-project' })) + return '1.0.0' + }), + installInProject: sandbox.stub().resolves() + } + + // Mock config + const mockConfig = { + get: sandbox.stub().returns('https://github.com/thegetty/quire-starter-default') + } + + // Use esmock to replace imports + const CreateCommand = await esmock('../../src/commands/create.js', { + '#src/lib/quire/index.js': { + quire: mockQuire + }, + 'fs-extra': fs + }) + + const command = new CreateCommand() + command.config = mockConfig + command.name = sandbox.stub().returns('new') + + // Run action without starter argument + await command.action('/new-project', null, {}) + + t.true(mockConfig.get.calledWith('projectTemplate'), 'config.get should be called for projectTemplate') + t.true(mockQuire.initStarter.calledWith('https://github.com/thegetty/quire-starter-default', '/new-project'), 'initStarter should use default starter from config') +}) + +test('create command should pass quire-version option to installInProject', async (t) => { + const { sandbox, fs } = t.context + + // Mock quire library + const mockQuire = { + initStarter: sandbox.stub().callsFake(async (starter, projectPath) => { + fs.mkdirSync(projectPath, { recursive: true }) + fs.writeFileSync(`${projectPath}/package.json`, JSON.stringify({ name: 'new-project' })) + return '1.0.0-rc.10' + }), + installInProject: sandbox.stub().resolves() + } + + // Mock config + const mockConfig = { + get: sandbox.stub().returns('default-starter') + } + + // Use esmock to replace imports + const CreateCommand = await esmock('../../src/commands/create.js', { + '#src/lib/quire/index.js': { + quire: mockQuire + }, + 'fs-extra': fs + }) + + const command = new CreateCommand() + command.config = mockConfig + command.name = sandbox.stub().returns('new') + + const options = { quireVersion: '1.0.0-rc.10' } + + // Run action with quire-version option + await command.action('/new-project', 'starter-template', options) + + t.true(mockQuire.installInProject.called, 'installInProject should be called') + const installCall = mockQuire.installInProject.getCall(0) + t.true(installCall.args[2] === options, 'installInProject should receive options object') +}) + +test('create command should handle initStarter errors gracefully', async (t) => { + const { sandbox, fs } = t.context + + const error = new Error('Failed to initialize starter template') + + // Mock quire library + const mockQuire = { + initStarter: sandbox.stub().rejects(error), + installInProject: sandbox.stub().resolves() + } + + // Mock config + const mockConfig = { + get: sandbox.stub().returns('default-starter') + } + + // Add removeSync to fs if it doesn't exist (memfs compatibility) + if (!fs.removeSync) { + fs.removeSync = sandbox.stub().callsFake((path) => { + if (fs.existsSync(path)) { + fs.rmSync(path, { recursive: true, force: true }) + } + }) + } + + // Use esmock to replace imports + const CreateCommand = await esmock('../../src/commands/create.js', { + '#src/lib/quire/index.js': { + quire: mockQuire + }, + 'fs-extra': fs + }) + + const command = new CreateCommand() + command.config = mockConfig + command.name = sandbox.stub().returns('new') + + // Run action - should handle error without throwing + await command.action('/new-project', 'starter-template', {}) + + t.true(mockQuire.initStarter.called, 'initStarter should be called') + t.false(mockQuire.installInProject.called, 'installInProject should not be called when initStarter fails') + t.true(fs.removeSync.called || !fs.existsSync('/new-project'), 'project directory should be removed on error') +}) + +test('create command should pass through debug option', async (t) => { + const { sandbox, fs } = t.context + + // Mock quire library + const mockQuire = { + initStarter: sandbox.stub().callsFake(async (starter, projectPath) => { + fs.mkdirSync(projectPath, { recursive: true }) + fs.writeFileSync(`${projectPath}/package.json`, JSON.stringify({ name: 'new-project' })) + return '1.0.0' + }), + installInProject: sandbox.stub().resolves() + } + + // Mock config + const mockConfig = { + get: sandbox.stub().returns('default-starter') + } + + // Use esmock to replace imports + const CreateCommand = await esmock('../../src/commands/create.js', { + '#src/lib/quire/index.js': { quire: mockQuire }, + 'fs-extra': fs + }) + + const command = new CreateCommand() + command.config = mockConfig + command.name = sandbox.stub().returns('new') + + // Run action with debug option + await command.action('/new-project', 'starter-template', { debug: true }) + + t.true(mockQuire.initStarter.called, 'initStarter should be called') +}) + +test('create command should pass quire-path option to methods', async (t) => { + const { sandbox, fs } = t.context + + // Mock quire library + const mockQuire = { + initStarter: sandbox.stub().callsFake(async (starter, projectPath) => { + fs.mkdirSync(projectPath, { recursive: true }) + fs.writeFileSync(`${projectPath}/package.json`, JSON.stringify({ name: 'new-project' })) + return '1.0.0' + }), + installInProject: sandbox.stub().resolves() + } + + // Mock config + const mockConfig = { + get: sandbox.stub().returns('default-starter') + } + + // Use esmock to replace imports + const CreateCommand = await esmock('../../src/commands/create.js', { + '#src/lib/quire/index.js': { + quire: mockQuire + }, + 'fs-extra': fs + }) + + const command = new CreateCommand() + command.config = mockConfig + command.name = sandbox.stub().returns('new') + + const options = { quirePath: '/custom/path/to/quire-11ty' } + + // Run action with quire-path option + await command.action('/new-project', 'starter-template', options) + + const initCall = mockQuire.initStarter.getCall(0) + const installCall = mockQuire.installInProject.getCall(0) + + t.true(initCall.args[2] === options, 'initStarter should receive options with quire-path') + t.true(installCall.args[2] === options, 'installInProject should receive options with quire-path') +}) + +test('create command should remove temp dir and package artifacts', async (t) => { + const { sandbox, fs, projectRoot, vol } = t.context + + // Setup project directory + fs.mkdirSync(projectRoot) + + // Mock fs (adding copySync) and git (with mocked chainable stubs) + // NB: Passing `vol` here as fs because memfs only provides the cpSync there + const { quire } = await esmock('../../src/lib/quire/index.js', { + 'fs-extra': vol, + '#lib/git/index.js': { + add: () => { + return { + commit: sandbox.stub() + } + }, + cwd: () => { + return { + rm: () => { + return { + catch: sandbox.stub() + } + } + } + } + }, + 'execa': { + // Mock `npm pack` behvaior of creating a file in .temp + execaCommand: sandbox.stub().withArgs(/npm pack/).callsFake(() => { + fs.writeFileSync(path.join(projectRoot, '.temp', 'thegetty-quire-11ty-1.0.0.tgz'), '') + }) + } + }) + + await quire.installInProject(projectRoot, '1.0.0') + + // Check that neither the temp dir nor the tarball exist + t.false(fs.existsSync(path.join(projectRoot, '.temp'))) + t.false(fs.existsSync(path.join(projectRoot, 'thegetty-quire-11ty-1.0.0.tgz'))) +}) diff --git a/packages/cli/package.json b/packages/cli/package.json index a596d6cc4..c924964f3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -38,7 +38,7 @@ "docs": "jsdoc2md --configure jsdoc.json --files src/**/*.js > docs/cli.md", "lint": "eslint src --ext .js", "lint:fix": "npm run lint -- --fix", - "test": "echo \"No test specified\" && exit 0" + "test": "ava ./_tests/**" }, "imports": { "#root/*": "./*", diff --git a/packages/cli/src/lib/quire/index.js b/packages/cli/src/lib/quire/index.js index aa778d9e4..de152361e 100644 --- a/packages/cli/src/lib/quire/index.js +++ b/packages/cli/src/lib/quire/index.js @@ -179,16 +179,19 @@ async function installInProject(projectPath, quireVersion, options = {}) { // Copy if passed a path and it exists, otherwise attempt to download the tarball for this pathspec if (fs.existsSync(quirePath)) { - fs.copySync(quirePath, tempDir) + fs.cpSync(quirePath, tempDir, {recursive: true}) } else { - await execaCommand(`npm pack ${ options.debug ? '--debug' : '--quiet' } ${quire11tyPackage}`) + await execaCommand(`npm pack ${ options.debug ? '--debug' : '--quiet' } --pack-destination ${tempDir} ${quire11tyPackage}`) // Extract only the package dir from the tar bar and strip it from the extracted path - await execaCommand(`tar -xzf thegetty-quire-11ty-${quireVersion}.tgz -C ${tempDir} --strip-components=1 package/`) + const tarballPath = path.join(tempDir, `thegetty-quire-11ty-${quireVersion}.tgz`) + await execaCommand(`tar -xzf ${tarballPath} -C ${tempDir} --strip-components=1 package/`) + + fs.rmSync(tarballPath) } // Copy `.temp` to projectPath - fs.copySync(tempDir, projectPath) + fs.cpSync(tempDir, projectPath, {recursive: true}) console.debug('[CLI:quire] installing dev dependencies into quire project') /** @@ -201,7 +204,7 @@ async function installInProject(projectPath, quireVersion, options = {}) { await execaCommand('npm install --save-dev', { cwd: projectPath }) } catch(error) { console.warn(`[CLI:error]`, error) - fs.removeSync(projectPath) + fs.rmSync(projectPath, {recursive: true}) return } @@ -218,7 +221,7 @@ async function installInProject(projectPath, quireVersion, options = {}) { await git.add(eleventyFilesToCommit).commit('Adds `@thegetty/quire-11ty` files') // remove temporary 11ty install directory - fs.removeSync(path.join(projectPath, temp11tyDirectory)) + fs.rmSync(path.join(projectPath, temp11tyDirectory), {recursive: true}) } /**