diff --git a/packages/cli/src/command.js b/packages/cli/src/command.js index 0f1d4bb74d..0033811252 100644 --- a/packages/cli/src/command.js +++ b/packages/cli/src/command.js @@ -1,4 +1,4 @@ - +import consola from 'consola' import minimist from 'minimist' import { name, version } from '../package.json' import { loadNuxtConfig, forceExit } from './utils' @@ -45,6 +45,10 @@ export default class NuxtCommand { const runResolve = Promise.resolve(this.cmd.run(this)) + if (this.argv.lock) { + runResolve.then(() => this.releaseLock()) + } + if (this.argv['force-exit']) { const forceExitByUser = this.isUserSuppliedArg('force-exit') runResolve.then(() => forceExit(this.cmd.name, forceExitByUser ? false : forceExitTimeout)) @@ -71,7 +75,7 @@ export default class NuxtCommand { async getNuxtConfig(extraOptions) { const config = await loadNuxtConfig(this.argv) - const options = Object.assign(config, extraOptions || {}) + const options = Object.assign(config, extraOptions) for (const name of Object.keys(this.cmd.options)) { this.cmd.options[name].prepare && this.cmd.options[name].prepare(this, options, this.argv) @@ -99,6 +103,26 @@ export default class NuxtCommand { return new Generator(nuxt, builder) } + async setLock(lockRelease) { + if (lockRelease) { + if (this._lockRelease) { + consola.warn(`A previous unreleased lock was found, this shouldn't happen and is probably an error in 'nuxt ${this.cmd.name}' command. The lock will be removed but be aware of potential strange results`) + + await this.releaseLock() + this._lockRelease = lockRelease + } else { + this._lockRelease = lockRelease + } + } + } + + async releaseLock() { + if (this._lockRelease) { + await this._lockRelease() + this._lockRelease = undefined + } + } + isUserSuppliedArg(option) { return this._argv.includes(`--${option}`) || this._argv.includes(`--no-${option}`) } diff --git a/packages/cli/src/commands/build.js b/packages/cli/src/commands/build.js index 48fa34f9b6..1787da6397 100644 --- a/packages/cli/src/commands/build.js +++ b/packages/cli/src/commands/build.js @@ -1,4 +1,5 @@ -import { common } from '../options' +import { common, locking } from '../options' +import { createLock } from '../utils' export default { name: 'build', @@ -6,6 +7,7 @@ export default { usage: 'build ', options: { ...common, + ...locking, analyze: { alias: 'a', type: 'boolean', @@ -62,6 +64,14 @@ export default { const config = await cmd.getNuxtConfig({ dev: false }) const nuxt = await cmd.getNuxt(config) + if (cmd.argv.lock) { + await cmd.setLock(await createLock({ + id: 'build', + dir: nuxt.options.buildDir, + root: config.rootDir + })) + } + if (nuxt.options.mode !== 'spa' || cmd.argv.generate === false) { // Build only const builder = await cmd.getBuilder(nuxt) diff --git a/packages/cli/src/commands/generate.js b/packages/cli/src/commands/generate.js index 52c5cbf18c..5945a42986 100644 --- a/packages/cli/src/commands/generate.js +++ b/packages/cli/src/commands/generate.js @@ -1,5 +1,5 @@ -import { common } from '../options' -import { normalizeArg } from '../utils' +import { common, locking } from '../options' +import { normalizeArg, createLock } from '../utils' export default { name: 'generate', @@ -7,6 +7,7 @@ export default { usage: 'generate ', options: { ...common, + ...locking, build: { type: 'boolean', default: true, @@ -44,6 +45,25 @@ export default { config.build.analyze = false const nuxt = await cmd.getNuxt(config) + + if (cmd.argv.lock) { + await cmd.setLock(await createLock({ + id: 'build', + dir: nuxt.options.buildDir, + root: config.rootDir + })) + + nuxt.hook('build:done', async () => { + await cmd.releaseLock() + + await cmd.setLock(await createLock({ + id: 'generate', + dir: nuxt.options.generate.dir, + root: config.rootDir + })) + }) + } + const generator = await cmd.getGenerator(nuxt) await generator.generate({ diff --git a/packages/cli/src/options/index.js b/packages/cli/src/options/index.js index 347fa2c72a..21ddcaeca2 100644 --- a/packages/cli/src/options/index.js +++ b/packages/cli/src/options/index.js @@ -1,2 +1,3 @@ export { default as common } from './common' export { default as server } from './server' +export { default as locking } from './locking' diff --git a/packages/cli/src/options/locking.js b/packages/cli/src/options/locking.js new file mode 100644 index 0000000000..28035510ae --- /dev/null +++ b/packages/cli/src/options/locking.js @@ -0,0 +1,7 @@ +export default { + lock: { + type: 'boolean', + default: true, + description: 'Do not set a lock on the project when building' + } +} diff --git a/packages/cli/src/utils/index.js b/packages/cli/src/utils/index.js index 7050125164..b2f22d13b6 100644 --- a/packages/cli/src/utils/index.js +++ b/packages/cli/src/utils/index.js @@ -5,6 +5,7 @@ import esm from 'esm' import exit from 'exit' import defaultsDeep from 'lodash/defaultsDeep' import { defaultNuxtConfigFile, getDefaultNuxtConfig } from '@nuxt/config' +import { lock } from '@nuxt/utils' import chalk from 'chalk' import prettyBytes from 'pretty-bytes' import env from 'std-env' @@ -158,3 +159,9 @@ ${chalk.bold('DeprecationWarning: Starting with Nuxt version 3 this will be a fa exit(0) } } + +// An immediate export throws an error when mocking with jest +// TypeError: Cannot set property createLock of # which has only a getter +export function createLock(...args) { + return lock(...args) +} diff --git a/packages/cli/test/unit/build.test.js b/packages/cli/test/unit/build.test.js index 946fa2fb11..4e510a6c88 100644 --- a/packages/cli/test/unit/build.test.js +++ b/packages/cli/test/unit/build.test.js @@ -1,4 +1,4 @@ -import * as utils from '../../src/utils/' +import * as utils from '../../src/utils' import { mockGetNuxt, mockGetBuilder, mockGetGenerator, NuxtCommand } from '../utils' describe('build', () => { @@ -8,6 +8,7 @@ describe('build', () => { build = await import('../../src/commands/build').then(m => m.default) jest.spyOn(process, 'exit').mockImplementation(code => code) jest.spyOn(utils, 'forceExit').mockImplementation(() => {}) + jest.spyOn(utils, 'createLock').mockImplementation(() => () => {}) }) afterEach(() => jest.resetAllMocks()) @@ -37,7 +38,7 @@ describe('build', () => { analyze: false } }) - const generate = mockGetGenerator(Promise.resolve()) + const generate = mockGetGenerator() await NuxtCommand.from(build).run() @@ -106,4 +107,36 @@ describe('build', () => { expect(utils.forceExit).not.toHaveBeenCalled() }) + + test('build locks project by default', async () => { + mockGetNuxt({ + mode: 'universal' + }) + mockGetBuilder(Promise.resolve()) + + const releaseLock = jest.fn(() => Promise.resolve()) + const createLock = jest.fn(() => releaseLock) + jest.spyOn(utils, 'createLock').mockImplementation(createLock) + + const cmd = NuxtCommand.from(build, ['build', '.']) + await cmd.run() + + expect(createLock).toHaveBeenCalledTimes(1) + expect(releaseLock).toHaveBeenCalledTimes(1) + }) + + test('build can disable locking', async () => { + mockGetNuxt({ + mode: 'universal' + }) + mockGetBuilder(Promise.resolve()) + + const createLock = jest.fn(() => Promise.resolve()) + jest.spyOn(utils, 'createLock').mockImplementationOnce(() => createLock) + + const cmd = NuxtCommand.from(build, ['build', '.', '--no-lock']) + await cmd.run() + + expect(createLock).not.toHaveBeenCalled() + }) }) diff --git a/packages/cli/test/unit/command.test.js b/packages/cli/test/unit/command.test.js index 5aa7e821e5..b3bf84b17d 100644 --- a/packages/cli/test/unit/command.test.js +++ b/packages/cli/test/unit/command.test.js @@ -128,4 +128,29 @@ describe('cli/command', () => { expect(process.stdout.write).toHaveBeenCalled() process.stdout.write.mockRestore() }) + + test('can set and release lock', () => { + const release = jest.fn(() => Promise.resolve()) + const cmd = new Command() + + cmd.setLock(release) + cmd.releaseLock() + + expect(release).toHaveBeenCalledTimes(1) + }) + + test('logs warning when lock already exists and removes old lock', () => { + const release = jest.fn(() => Promise.resolve()) + const cmd = new Command() + + cmd.setLock(release) + cmd.setLock(release) + + expect(consola.warn).toHaveBeenCalledTimes(1) + expect(consola.warn).toHaveBeenCalledWith(expect.stringMatching('A previous unreleased lock was found')) + expect(release).toHaveBeenCalledTimes(1) + + cmd.releaseLock() + expect(release).toHaveBeenCalledTimes(2) + }) }) diff --git a/packages/cli/test/unit/generate.test.js b/packages/cli/test/unit/generate.test.js index ceef94b8df..35c0dcedce 100644 --- a/packages/cli/test/unit/generate.test.js +++ b/packages/cli/test/unit/generate.test.js @@ -1,4 +1,4 @@ -import * as utils from '../../src/utils/' +import * as utils from '../../src/utils' import { mockGetNuxt, mockGetGenerator, NuxtCommand } from '../utils' describe('generate', () => { @@ -8,6 +8,7 @@ describe('generate', () => { generate = await import('../../src/commands/generate').then(m => m.default) jest.spyOn(process, 'exit').mockImplementation(code => code) jest.spyOn(utils, 'forceExit').mockImplementation(() => {}) + jest.spyOn(utils, 'createLock').mockImplementation(() => () => {}) }) afterEach(() => jest.resetAllMocks()) @@ -18,7 +19,7 @@ describe('generate', () => { test('builds by default', async () => { mockGetNuxt() - const generator = mockGetGenerator(Promise.resolve()) + const generator = mockGetGenerator() await NuxtCommand.from(generate).run() @@ -28,7 +29,7 @@ describe('generate', () => { test('doesnt build with no-build', async () => { mockGetNuxt() - const generator = mockGetGenerator(Promise.resolve()) + const generator = mockGetGenerator() await NuxtCommand.run(generate, ['generate', '.', '--no-build']) @@ -38,7 +39,7 @@ describe('generate', () => { test('build with devtools', async () => { mockGetNuxt() - const generator = mockGetGenerator(Promise.resolve()) + const generator = mockGetGenerator() const cmd = NuxtCommand.from(generate, ['generate', '.', '--devtools']) @@ -53,20 +54,7 @@ describe('generate', () => { test('generate with modern mode', async () => { mockGetNuxt() - mockGetGenerator(Promise.resolve()) - - const cmd = NuxtCommand.from(generate, ['generate', '.', '--m']) - - const options = await cmd.getNuxtConfig() - - await cmd.run() - - expect(options.modern).toBe('client') - }) - - test('generate with modern mode', async () => { - mockGetNuxt() - mockGetGenerator(Promise.resolve()) + mockGetGenerator() const cmd = NuxtCommand.from(generate, ['generate', '.', '--m']) @@ -79,7 +67,7 @@ describe('generate', () => { test('generate force-exits by default', async () => { mockGetNuxt() - mockGetGenerator(Promise.resolve()) + mockGetGenerator() const cmd = NuxtCommand.from(generate, ['generate', '.']) await cmd.run() @@ -90,7 +78,7 @@ describe('generate', () => { test('generate can set force exit explicitly', async () => { mockGetNuxt() - mockGetGenerator(Promise.resolve()) + mockGetGenerator() const cmd = NuxtCommand.from(generate, ['generate', '.', '--force-exit']) await cmd.run() @@ -101,11 +89,45 @@ describe('generate', () => { test('generate can disable force exit explicitly', async () => { mockGetNuxt() - mockGetGenerator(Promise.resolve()) + mockGetGenerator() const cmd = NuxtCommand.from(generate, ['generate', '.', '--no-force-exit']) await cmd.run() expect(utils.forceExit).not.toHaveBeenCalled() }) + + test('generate locks project by default twice', async () => { + const releaseLock = jest.fn(() => Promise.resolve()) + const createLock = jest.fn(() => releaseLock) + jest.spyOn(utils, 'createLock').mockImplementation(createLock) + + let buildDone + mockGetNuxt({ generate: {} }, { + hook: (hookName, fn) => (buildDone = fn) + }) + + mockGetGenerator(async () => { + await buildDone() + }) + + const cmd = NuxtCommand.from(generate, ['generate', '.']) + await cmd.run() + + expect(createLock).toHaveBeenCalledTimes(2) + expect(releaseLock).toHaveBeenCalledTimes(2) + }) + + test('generate can disable locking', async () => { + mockGetNuxt() + mockGetGenerator() + + const createLock = jest.fn(() => Promise.resolve()) + jest.spyOn(utils, 'createLock').mockImplementationOnce(() => createLock) + + const cmd = NuxtCommand.from(generate, ['generate', '.', '--no-lock']) + await cmd.run() + + expect(createLock).not.toHaveBeenCalled() + }) }) diff --git a/packages/cli/test/unit/utils-minimalcli.test.js b/packages/cli/test/unit/utils-minimalcli.test.js new file mode 100644 index 0000000000..59029b1074 --- /dev/null +++ b/packages/cli/test/unit/utils-minimalcli.test.js @@ -0,0 +1,31 @@ +import { consola } from '../utils' +import * as utils from '../../src/utils' + +jest.mock('std-env', () => ({ + test: false, + minimalCLI: true +})) + +describe('cli/utils', () => { + afterEach(() => jest.resetAllMocks()) + + test('showBanner prints only listeners', () => { + const listeners = [ + { url: 'first' }, + { url: 'second' } + ] + + utils.showBanner({ + options: { + cli: {} + }, + server: { + listeners + } + }) + + expect(consola.info).toHaveBeenCalledTimes(2) + expect(consola.info).toHaveBeenCalledWith(`Listening on: ${listeners[0].url}`) + expect(consola.info).toHaveBeenCalledWith(`Listening on: ${listeners[1].url}`) + }) +}) diff --git a/packages/cli/test/unit/utils.test.js b/packages/cli/test/unit/utils.test.js index 2e5476727d..e01add99db 100644 --- a/packages/cli/test/unit/utils.test.js +++ b/packages/cli/test/unit/utils.test.js @@ -3,6 +3,11 @@ import { consola } from '../utils' import * as utils from '../../src/utils' import * as fmt from '../../src/utils/formatting' +jest.mock('std-env', () => ({ + test: false, + minimalCLI: false +})) + describe('cli/utils', () => { afterEach(() => jest.resetAllMocks()) @@ -113,4 +118,67 @@ describe('cli/utils', () => { test('indent custom char', () => { expect(fmt.indent(4, '-')).toBe('----') }) + + test('showBanner prints full-info box', () => { + const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => {}) + const successBox = jest.fn().mockImplementation((m, t) => t + m) + jest.spyOn(fmt, 'successBox').mockImplementation(successBox) + + const badgeMessages = [ 'badgeMessage' ] + const listeners = [ + { url: 'first' }, + { url: 'second' } + ] + + utils.showBanner({ + options: { + cli: { + badgeMessages + } + }, + server: { + listeners + } + }) + + expect(successBox).toHaveBeenCalledTimes(1) + expect(stdout).toHaveBeenCalledTimes(1) + expect(stdout).toHaveBeenCalledWith(expect.stringMatching('Nuxt.js')) + expect(stdout).toHaveBeenCalledWith(expect.stringMatching(`Listening on: ${listeners[0].url}`)) + expect(stdout).toHaveBeenCalledWith(expect.stringMatching(`Listening on: ${listeners[1].url}`)) + expect(stdout).toHaveBeenCalledWith(expect.stringMatching('badgeMessage')) + stdout.mockRestore() + }) + + test('forceExit exits after timeout', () => { + jest.useFakeTimers() + const exit = jest.spyOn(process, 'exit').mockImplementation(() => {}) + const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => {}) + + utils.forceExit('test', 1) + expect(exit).not.toHaveBeenCalled() + jest.runAllTimers() + + expect(stderr).toHaveBeenCalledTimes(1) + expect(stderr).toHaveBeenCalledWith(expect.stringMatching('Nuxt.js will now force exit')) + expect(exit).toHaveBeenCalledTimes(1) + + stderr.mockRestore() + exit.mockRestore() + jest.useRealTimers() + }) + + test('forceExit exits immediately without timeout', () => { + jest.useFakeTimers() + const exit = jest.spyOn(process, 'exit').mockImplementation(() => {}) + const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => {}) + + utils.forceExit('test', false) + expect(stderr).not.toHaveBeenCalledWith() + expect(exit).toHaveBeenCalledTimes(1) + + stderr.mockRestore() + exit.mockRestore() + jest.useRealTimers() + }) }) diff --git a/packages/cli/test/utils/mocking.js b/packages/cli/test/utils/mocking.js index 05836e299d..1f2f29b9f2 100644 --- a/packages/cli/test/utils/mocking.js +++ b/packages/cli/test/utils/mocking.js @@ -28,13 +28,9 @@ export const mockGetNuxt = (options = {}, implementation) => { } export const mockGetBuilder = (ret) => { - const build = jest.fn().mockImplementationOnce(() => { - return ret - }) + const build = jest.fn().mockImplementationOnce(() => ret) - Command.prototype.getBuilder = jest.fn().mockImplementationOnce(() => { - return { build } - }) + Command.prototype.getBuilder = jest.fn().mockImplementationOnce(() => ({ build })) return build } @@ -42,14 +38,10 @@ export const mockGetBuilder = (ret) => { export const mockGetGenerator = (ret) => { const generate = jest.fn() if (ret) { - generate.mockImplementationOnce(() => { - return ret - }) + generate.mockImplementationOnce(ret) } - Command.prototype.getGenerator = jest.fn().mockImplementationOnce(() => { - return { generate } - }) + Command.prototype.getGenerator = jest.fn().mockImplementationOnce(() => ({ generate })) return generate } diff --git a/packages/core/test/module.test.js b/packages/core/test/module.test.js index b69ce96004..2ac630e72a 100644 --- a/packages/core/test/module.test.js +++ b/packages/core/test/module.test.js @@ -5,7 +5,8 @@ import { chainFn } from '@nuxt/utils' import ModuleContainer from '../src/module' jest.mock('fs', () => ({ - existsSync: Boolean + existsSync: Boolean, + closeSync: Boolean })) jest.mock('hash-sum', () => src => `hash(${src})`) diff --git a/packages/utils/package.json b/packages/utils/package.json index 0a1d47c6b7..0ee495896f 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -9,7 +9,10 @@ "main": "dist/utils.js", "dependencies": { "consola": "^2.5.6", - "serialize-javascript": "^1.6.1" + "hash-sum": "^1.0.2", + "proper-lockfile": "^3.2.0", + "serialize-javascript": "^1.6.1", + "signal-exit": "^3.0.2" }, "publishConfig": { "access": "public" diff --git a/packages/utils/src/index.js b/packages/utils/src/index.js index 64028fd3ac..5c94687802 100644 --- a/packages/utils/src/index.js +++ b/packages/utils/src/index.js @@ -1,5 +1,6 @@ export * from './context' export * from './lang' +export * from './locking' export * from './resolve' export * from './route' export * from './serialize' diff --git a/packages/utils/src/locking.js b/packages/utils/src/locking.js new file mode 100644 index 0000000000..903baa48e1 --- /dev/null +++ b/packages/utils/src/locking.js @@ -0,0 +1,65 @@ +import path from 'path' +import consola from 'consola' +import hash from 'hash-sum' +import fs from 'fs-extra' +import properlock from 'proper-lockfile' +import onExit from 'signal-exit' + +export const lockPaths = new Set() + +export const defaultLockOptions = { + stale: 15000, + onCompromised: err => consola.fatal(err) +} + +export function getLockOptions(options) { + return Object.assign({}, defaultLockOptions, options) +} + +export function createLockPath({ id = 'nuxt', dir, root }) { + const sum = hash(`${root}-${dir}`) + + return path.resolve(root, 'node_modules/.cache/nuxt', `${id}-lock-${sum}`) +} + +export async function getLockPath(config) { + const lockPath = createLockPath(config) + + // the lock is created for the lockPath as ${lockPath}.lock + // so the (temporary) lockPath needs to exist + await fs.ensureDir(lockPath) + + return lockPath +} + +export async function lock({ id, dir, root, options }) { + const lockPath = await getLockPath({ id, dir, root }) + + const locked = await properlock.check(lockPath) + if (locked) { + consola.fatal(`A lock with id '${id}' already exists on ${dir}`) + } + + const release = await properlock.lock(lockPath, options) + + if (!release) { + consola.warn(`Unable to get a lock with id '${id}' on ${dir} (but will continue)`) + } + + if (!lockPaths.size) { + // make sure to always cleanup our temporate lockPaths + onExit(() => { + for (const lockPath of lockPaths) { + fs.removeSync(lockPath) + } + }) + } + + lockPaths.add(lockPath) + + return async function lockRelease() { + await release() + await fs.remove(lockPath) + lockPaths.delete(lockPath) + } +} diff --git a/packages/utils/test/index.test.js b/packages/utils/test/index.test.js index 0ad3710d59..33c7988753 100644 --- a/packages/utils/test/index.test.js +++ b/packages/utils/test/index.test.js @@ -1,6 +1,7 @@ import * as Util from '../src' import * as context from '../src/context' import * as lang from '../src/lang' +import * as locking from '../src/locking' import * as resolve from '../src/resolve' import * as route from '../src/route' import * as serialize from '../src/serialize' @@ -12,6 +13,7 @@ describe('util: entry', () => { expect(Util).toEqual({ ...context, ...lang, + ...locking, ...resolve, ...route, ...serialize, diff --git a/packages/utils/test/locking.test.js b/packages/utils/test/locking.test.js new file mode 100644 index 0000000000..5687890830 --- /dev/null +++ b/packages/utils/test/locking.test.js @@ -0,0 +1,146 @@ +import consola from 'consola' +import fs from 'fs-extra' +import properlock from 'proper-lockfile' +import onExit from 'signal-exit' +import { lockPaths, defaultLockOptions, getLockOptions, createLockPath, getLockPath, lock } from '../src/locking' + +jest.mock('fs-extra') +jest.mock('proper-lockfile') +jest.mock('signal-exit') + +describe('util: locking', () => { + const lockConfig = { + id: 'id', + dir: 'dist', + root: '/project-root' + } + + beforeEach(() => jest.resetAllMocks()) + beforeEach(() => lockPaths.clear()) + + test('onCompromised lock is fatal error by default', () => { + defaultLockOptions.onCompromised() + expect(consola.fatal).toHaveBeenCalledTimes(1) + }) + + test('can override default options', () => { + const options = getLockOptions({ onCompromised: err => consola.warn(err) }) + options.onCompromised() + + expect(consola.warn).toHaveBeenCalledTimes(1) + }) + + test('createLockPath creates the same lockPath for identical locks', () => { + const path1 = createLockPath(lockConfig) + const path2 = createLockPath(Object.assign({}, lockConfig)) + expect(path1).toBe(path2) + }) + + test('createLockPath creates unique lockPaths for different ids', () => { + const path1 = createLockPath(lockConfig) + const path2 = createLockPath(Object.assign({}, lockConfig, { id: 'id2' })) + expect(path1).not.toBe(path2) + }) + + test('createLockPath creates unique lockPaths for different dirs', () => { + const path1 = createLockPath(lockConfig) + const path2 = createLockPath(Object.assign({}, lockConfig, { dir: 'dir2' })) + expect(path1).not.toBe(path2) + }) + + test('createLockPath creates unique lockPaths for different roots', () => { + const path1 = createLockPath(lockConfig) + const path2 = createLockPath(Object.assign({}, lockConfig, { root: '/project-root2' })) + expect(path1).not.toBe(path2) + }) + + test('getLockPath creates lockPath when it doesnt exists', () => { + getLockPath(lockConfig) + + expect(fs.ensureDir).toHaveBeenCalledTimes(1) + }) + + test('lock creates a lock and returns a release fn', async () => { + properlock.lock.mockImplementationOnce(() => true) + + const fn = await lock(lockConfig) + + expect(properlock.check).toHaveBeenCalledTimes(1) + expect(properlock.lock).toHaveBeenCalledTimes(1) + expect(fs.ensureDir).toHaveBeenCalledTimes(1) + expect(fn).toEqual(expect.any(Function)) + expect(consola.error).not.toHaveBeenCalled() + expect(consola.fatal).not.toHaveBeenCalled() + expect(consola.warn).not.toHaveBeenCalled() + }) + + test('lock throws error when lock already exists', async () => { + properlock.check.mockImplementationOnce(() => true) + + await lock(lockConfig) + expect(properlock.check).toHaveBeenCalledTimes(1) + expect(consola.fatal).toHaveBeenCalledTimes(1) + expect(consola.fatal).toHaveBeenCalledWith(`A lock with id '${lockConfig.id}' already exists on ${lockConfig.dir}`) + }) + + test('lock logs warning when it couldnt get a lock', async () => { + properlock.lock.mockImplementationOnce(() => false) + + await lock(lockConfig) + expect(properlock.lock).toHaveBeenCalledTimes(1) + expect(consola.warn).toHaveBeenCalledTimes(1) + expect(consola.warn).toHaveBeenCalledWith(`Unable to get a lock with id '${lockConfig.id}' on ${lockConfig.dir} (but will continue)`) + }) + + test('lock returns a release method for unlocking both lockfile as lockPath', async () => { + const release = jest.fn() + properlock.lock.mockImplementationOnce(() => release) + + const fn = await lock(lockConfig) + await fn() + + expect(release).toHaveBeenCalledTimes(1) + expect(fs.remove).toHaveBeenCalledTimes(1) + }) + + test('lock release also cleansup onExit set', async () => { + const release = jest.fn() + properlock.lock.mockImplementationOnce(() => release) + + const fn = await lock(lockConfig) + expect(lockPaths.size).toBe(1) + + await fn() + expect(lockPaths.size).toBe(0) + }) + + test('lock sets exit listener once to remove lockPaths', async () => { + properlock.lock.mockImplementationOnce(() => true) + + await lock(lockConfig) + await lock(lockConfig) + + expect(onExit).toHaveBeenCalledTimes(1) + }) + + test('exit listener removes all lockPaths when called', async () => { + let callback + onExit.mockImplementationOnce(cb => (callback = cb)) + + const lockConfig2 = Object.assign({}, lockConfig, { id: 'id2' }) + + const path1 = createLockPath(lockConfig) + const path2 = createLockPath(lockConfig2) + + await lock(lockConfig) + await lock(lockConfig2) + + expect(onExit).toHaveBeenCalledTimes(1) + expect(lockPaths.size).toBe(2) + expect(callback).toBeDefined() + callback() + + expect(fs.removeSync).toHaveBeenCalledWith(path1) + expect(fs.removeSync).toHaveBeenCalledWith(path2) + }) +}) diff --git a/yarn.lock b/yarn.lock index 969e958b4c..7223085edc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8594,6 +8594,15 @@ promzard@^0.3.0: dependencies: read "1" +proper-lockfile@^3.2.0: + version "3.2.0" + resolved "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-3.2.0.tgz#89ca420eea1d55d38ca552578851460067bcda66" + integrity sha512-iMghHHXv2bsxl6NchhEaFck8tvX3F9cknEEh1SUpguUOBjN7PAAW9BLzmbc1g/mCD1gY3EE2EABBHPJfFdHFmA== + dependencies: + graceful-fs "^4.1.11" + retry "^0.12.0" + signal-exit "^3.0.2" + proto-list@~1.2.1: version "1.2.4" resolved "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" @@ -9288,6 +9297,11 @@ retry@^0.10.0: resolved "https://registry.npmjs.org/retry/-/retry-0.10.1.tgz#e76388d217992c252750241d3d3956fed98d8ff4" integrity sha1-52OI0heZLCUnUCQdPTlW/tmNj/Q= +retry@^0.12.0: + version "0.12.0" + resolved "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" + integrity sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs= + rgb-regex@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/rgb-regex/-/rgb-regex-1.0.1.tgz#c0e0d6882df0e23be254a475e8edd41915feaeb1"