Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
309 changes: 309 additions & 0 deletions packages/cli/_tests/commands/create.test.js
Original file line number Diff line number Diff line change
@@ -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')))
})
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/*": "./*",
Expand Down
15 changes: 9 additions & 6 deletions packages/cli/src/lib/quire/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
/**
Expand All @@ -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
}

Expand All @@ -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})
}

/**
Expand Down