diff --git a/lib/classes/config-schema-handler/resolve-ajv-validate.js b/lib/classes/config-schema-handler/resolve-ajv-validate.js index ab2c8e346..d84022d24 100644 --- a/lib/classes/config-schema-handler/resolve-ajv-validate.js +++ b/lib/classes/config-schema-handler/resolve-ajv-validate.js @@ -7,7 +7,7 @@ const os = require('os'); const standaloneCode = require('ajv/dist/standalone').default; const { log } = require('../../utils/serverless-utils/log'); const fsp = require('fs').promises; -const resolveTmpdir = require('process-utils/tmpdir'); +const resolveTmpdir = require('../../utils/resolve-process-tmp-dir'); const safeMoveFile = require('../../utils/fs/safe-move-file'); const requireFromString = require('require-from-string'); const deepSortObjectByKey = require('../../utils/deep-sort-object-by-key'); diff --git a/lib/cli/run-compose.js b/lib/cli/run-compose.js index 3413ae828..78cd97517 100644 --- a/lib/cli/run-compose.js +++ b/lib/cli/run-compose.js @@ -90,8 +90,8 @@ module.exports = async () => { // Add progress bar if (shouldInstallCompose) { - const getCliProgressFooter = require('cli-progress-footer'); - const cliProgressFooter = getCliProgressFooter(); + const createProgressFooter = require('../utils/progress-footer'); + const cliProgressFooter = createProgressFooter(); cliProgressFooter.shouldAddProgressAnimationPrefix = true; cliProgressFooter.progressAnimationPrefixFrames = cliProgressFooter.progressAnimationPrefixFrames.map((frame) => `\x1b[91m${frame}\x1b[39m`); diff --git a/lib/utils/log-deprecation.js b/lib/utils/log-deprecation.js index 3b8a68c21..a2fbc2b45 100644 --- a/lib/utils/log-deprecation.js +++ b/lib/utils/log-deprecation.js @@ -4,7 +4,7 @@ const path = require('path'); const fse = require('fs-extra'); const fsp = require('fs').promises; const weakMemoizee = require('memoizee/weak'); -const resolveTmpdir = require('process-utils/tmpdir'); +const resolveTmpdir = require('./resolve-process-tmp-dir'); const ServerlessError = require('../serverless-error'); const safeMoveFile = require('./fs/safe-move-file'); const { style, log } = require('./serverless-utils/log'); diff --git a/lib/utils/progress-footer.js b/lib/utils/progress-footer.js new file mode 100644 index 000000000..e8ca29c01 --- /dev/null +++ b/lib/utils/progress-footer.js @@ -0,0 +1,272 @@ +'use strict'; + +const { stripVTControlCharacters } = require('node:util'); + +const moveUp = '\x1b[1A'; +const clearLine = '\x1b[2K'; +const emptyLinesPattern = /^[\n\r\u2028\u2029]{2}$/u; + +const defaultFrames = + process.platform === 'win32' + ? ['┤', '┘', '┴', '└', '├', '┌', '┬', '┐'] + : ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; +const defaultAnimationIntervalMs = process.platform === 'win32' ? 100 : 80; + +const splitRows = (rows) => { + if (Array.isArray(rows)) return rows.map(String); + if (!rows) return []; + return String(rows).split(/[\n\r]/u); +}; + +const stringifyChunk = (chunk) => { + if (typeof chunk === 'string') return chunk; + if (chunk instanceof Uint8Array) return Buffer.from(chunk).toString(); + return String(chunk); +}; + +const getVisibleLength = (value) => [...stripVTControlCharacters(value)].length; + +const areEmptyLines = (value) => emptyLinesPattern.test(value); + +class ProgressFooter { + constructor(options = {}) { + if (!options || typeof options !== 'object') options = {}; + const { stdout = process.stdout, stderr = process.stderr } = options; + + this.stdout = stdout; + this.stderr = stderr; + this.rawRows = []; + this.renderedLineCount = 0; + this.hasLeadingProgressSpacer = false; + this.hasTerminatedPartialLine = false; + this.lastOutLineLength = 0; + this.lastOutCharacters = ''; + this.frameIndex = 0; + this.animationIntervalId = null; + this.isActive = false; + this.originalStdoutWrite = this.stdout.write; + this.originalStderrWrite = this.stderr.write; + this._shouldAddProgressAnimationPrefix = false; + this._progressAnimationPrefixFrames = defaultFrames; + this.installPassiveObserver(); + } + + get progressAnimationPrefixFrames() { + return this._progressAnimationPrefixFrames; + } + + set progressAnimationPrefixFrames(frames) { + const normalizedFrames = Array.from(frames, String); + if (normalizedFrames.length < 2) { + throw new TypeError('Expected at least two animation frames'); + } + this._progressAnimationPrefixFrames = normalizedFrames; + this.frameIndex = 0; + this.repaint(); + } + + get shouldAddProgressAnimationPrefix() { + return this._shouldAddProgressAnimationPrefix; + } + + set shouldAddProgressAnimationPrefix(value) { + this._shouldAddProgressAnimationPrefix = Boolean(value); + if (this.isActive) { + if (this._shouldAddProgressAnimationPrefix) { + this.startAnimation(); + } else { + this.stopAnimation(); + } + } + this.repaint(); + } + + updateProgress(rows) { + this.rawRows = splitRows(rows); + + if (!this.rawRows.length) { + this.clearRenderedProgress(); + this.deactivate(); + return; + } + + this.activate(); + this.repaint(); + } + + startAnimation() { + if (this.animationIntervalId) return; + + this.animationIntervalId = setInterval(() => { + if (!this.rawRows.length) return; + this.frameIndex = (this.frameIndex + 1) % this.progressAnimationPrefixFrames.length; + this.repaint(); + }, defaultAnimationIntervalMs); + + if (this.animationIntervalId.unref) { + this.animationIntervalId.unref(); + } + } + + stopAnimation() { + clearInterval(this.animationIntervalId); + this.animationIntervalId = null; + } + + activate() { + if (this.isActive) return; + + this.restoreOriginalWrites(); + + this.stdout.write = (chunk, encoding, callback) => + this.writeAroundProgress(this.writeOriginalStdout.bind(this), chunk, encoding, callback); + + this.stderr.write = (chunk, encoding, callback) => + this.writeAroundProgress(this.writeOriginalStdout.bind(this), chunk, encoding, callback); + + this.isActive = true; + if (this.shouldAddProgressAnimationPrefix) this.startAnimation(); + } + + deactivate() { + if (!this.isActive) return; + + this.restoreOriginalWrites(); + this.stopAnimation(); + this.isActive = false; + this.installPassiveObserver(); + } + + installPassiveObserver() { + this.stdout.write = (chunk, ...args) => { + const result = this.originalStdoutWrite.call(this.stdout, chunk, ...args); + this.updateLastOutLineLength(stringifyChunk(chunk)); + return result; + }; + + this.stderr.write = (chunk, ...args) => { + const result = this.originalStderrWrite.call(this.stderr, chunk, ...args); + this.updateLastOutLineLength(stringifyChunk(chunk)); + return result; + }; + } + + restoreOriginalWrites() { + this.stdout.write = this.originalStdoutWrite; + this.stderr.write = this.originalStderrWrite; + } + + writeOriginalStdout(chunk, ...args) { + return this.originalStdoutWrite.call(this.stdout, chunk, ...args); + } + + writeAroundProgress(write, chunk, encoding, callback) { + if (typeof encoding === 'function') { + callback = encoding; + encoding = undefined; + } + + this.clearRenderedProgress(); + + let result; + if (encoding === undefined) { + result = callback ? write(chunk, callback) : write(chunk); + } else { + result = write(chunk, encoding, callback); + } + + this.updateLastOutLineLength(stringifyChunk(chunk)); + this.writeProgress(); + return result; + } + + updateLastOutLineLength(content) { + const strippedContent = stripVTControlCharacters(content); + const trailingContent = strippedContent.slice(-50); + + if (trailingContent.length === 1) { + this.lastOutCharacters = `${this.lastOutCharacters.slice(-1)}${trailingContent}`; + } else if (trailingContent.length > 1) { + this.lastOutCharacters = trailingContent.slice(-2); + } + + const lines = strippedContent.split(/[\n\r]/u); + const lastLine = lines[lines.length - 1]; + + if (lines.length === 1) { + this.lastOutLineLength += [...lastLine].length; + } else { + this.lastOutLineLength = [...lastLine].length; + } + } + + repaint() { + if (!this.rawRows.length || !this.isActive) return; + this.clearRenderedProgress(); + this.writeProgress(); + } + + getRows() { + if (!this.shouldAddProgressAnimationPrefix) return this.rawRows; + + const prefix = `${this.progressAnimationPrefixFrames[this.frameIndex]} `; + const padding = ' '.repeat(getVisibleLength(prefix)); + + return this.rawRows.map((row) => { + if (!row) return row; + return `${prefix}${row.split('\n').join(`\n${padding}`)}`; + }); + } + + writeProgress() { + const rows = this.getRows(); + if (!rows.length) return; + + this.hasTerminatedPartialLine = Boolean(this.lastOutLineLength); + this.hasLeadingProgressSpacer = !areEmptyLines(this.lastOutCharacters); + + if (this.hasTerminatedPartialLine) { + this.writeOriginalStdout('\n'); + } + + if (this.hasLeadingProgressSpacer) { + this.writeOriginalStdout('\n'); + } + + for (const row of rows) { + this.writeOriginalStdout(`${row}\n`); + } + + this.renderedLineCount = rows.reduce((count, row) => count + this.getLineCount(row), 0); + } + + clearRenderedProgress() { + if (!this.renderedLineCount) return; + + const lineCountToClear = this.renderedLineCount + (this.hasLeadingProgressSpacer ? 1 : 0); + + for (let index = 0; index < lineCountToClear; index += 1) { + this.writeOriginalStdout(`${moveUp}${clearLine}`); + } + + this.renderedLineCount = 0; + this.hasLeadingProgressSpacer = false; + + if (this.hasTerminatedPartialLine) { + const columns = this.stdout.columns || 80; + const column = this.lastOutLineLength % columns || columns; + this.writeOriginalStdout(`${moveUp}\x1b[${column}C`); + } + + this.hasTerminatedPartialLine = false; + } + + getLineCount(row) { + const columns = this.stdout.columns || 80; + return String(row) + .split(/[\n\r]/u) + .reduce((count, line) => count + (Math.ceil(getVisibleLength(line) / columns) || 1), 0); + } +} + +module.exports = (options) => new ProgressFooter(options); diff --git a/lib/utils/resolve-process-tmp-dir.js b/lib/utils/resolve-process-tmp-dir.js new file mode 100644 index 000000000..2ce329109 --- /dev/null +++ b/lib/utils/resolve-process-tmp-dir.js @@ -0,0 +1,34 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const fsp = fs.promises; +const os = require('os'); +const path = require('path'); + +const tmpDirPrefix = 'node-process-'; + +let processTmpDirPromise; + +module.exports = async () => { + if (!processTmpDirPromise) { + processTmpDirPromise = fsp + .mkdtemp(path.join(os.tmpdir(), `${tmpDirPrefix}${crypto.randomBytes(2).toString('hex')}-`)) + .then((tmpDir) => { + process.once('exit', () => { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // Best-effort cleanup during process exit. + } + }); + return tmpDir; + }) + .catch((error) => { + processTmpDirPromise = undefined; + throw error; + }); + } + + return processTmpDirPromise; +}; diff --git a/lib/utils/serverless-utils/lib/log-reporters/node/progress-reporter.js b/lib/utils/serverless-utils/lib/log-reporters/node/progress-reporter.js index 05292965e..84614533f 100644 --- a/lib/utils/serverless-utils/lib/log-reporters/node/progress-reporter.js +++ b/lib/utils/serverless-utils/lib/log-reporters/node/progress-reporter.js @@ -1,13 +1,13 @@ 'use strict'; -const getCliProgressFooter = require('cli-progress-footer'); +const createProgressFooter = require('../../../../progress-footer'); const { emitter } = require('../../log/get-progress-reporter'); const { progress, log } = require('../../../log'); const joinTextTokens = require('../../log/join-text-tokens'); const style = require('./style'); module.exports = ({ logLevelIndex }) => { - const cliProgressFooter = getCliProgressFooter(); + const cliProgressFooter = createProgressFooter(); cliProgressFooter.shouldAddProgressAnimationPrefix = true; cliProgressFooter.progressAnimationPrefixFrames = cliProgressFooter.progressAnimationPrefixFrames.map((frame) => style.noticeSymbol(frame)); diff --git a/package.json b/package.json index ce5572a5d..aa85678fd 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ "ajv-formats": "^2.1.1", "aws-sdk": "^2.1693.0", "cachedir": "^2.3.0", - "cli-progress-footer": "^2.3.2", "content-disposition": "^0.5.4", "cross-spawn": "^7.0.6", "d": "^1.0.1", @@ -53,7 +52,6 @@ "filesize": "^10.0.7", "fs-extra": "^11.3.4", "get-stdin": "^8.0.0", - "graceful-fs": "^4.2.11", "https-proxy-agent": "^9.0.0", "js-yaml": "^4.1.0", "json-cycle": "^1.5.0", @@ -65,7 +63,6 @@ "module-alias": "^2.2.3", "object-hash": "^3.0.0", "open": "^8.4.2", - "process-utils": "^4.0.0", "punycode": "^2.3.1", "require-from-string": "^2.0.2", "semver": "^7.5.3", diff --git a/scripts/serverless.js b/scripts/serverless.js index a2368c733..f72ed2dab 100755 --- a/scripts/serverless.js +++ b/scripts/serverless.js @@ -4,10 +4,6 @@ Error.stackTraceLimit = Infinity; -// global graceful-fs patch -// https://github.com/isaacs/node-graceful-fs#global-patching -require('graceful-fs').gracefulify(require('fs')); - // Setup log writing require('../lib/utils/serverless-utils/log-reporters/node'); const { log, progress } = require('../lib/utils/serverless-utils/log'); diff --git a/test/lib/resolve-env.js b/test/lib/resolve-env.js index e26eab8ec..2d4f245a3 100644 --- a/test/lib/resolve-env.js +++ b/test/lib/resolve-env.js @@ -1,6 +1,6 @@ 'use strict'; -const createEnv = require('process-utils/create-env'); +const { createEnv } = require('../utils/process'); module.exports = (options = {}) => { if (!options) options = {}; diff --git a/test/lib/run-serverless.js b/test/lib/run-serverless.js index 33d24d498..f76b076e5 100644 --- a/test/lib/run-serverless.js +++ b/test/lib/run-serverless.js @@ -8,9 +8,7 @@ const { realpathSync } = require('fs'); const { writeJson } = require('fs-extra'); const path = require('path'); const os = require('os'); -const overrideEnv = require('process-utils/override-env'); -const overrideCwd = require('process-utils/override-cwd'); -const overrideArgv = require('process-utils/override-argv'); +const { overrideEnv, overrideCwd, overrideArgv } = require('../utils/process'); const sinon = require('sinon'); const resolveEnv = require('./resolve-env'); const observeOutput = require('./observe-output'); diff --git a/test/unit/lib/aws/config.test.js b/test/unit/lib/aws/config.test.js index 4d552849c..ff828185e 100644 --- a/test/unit/lib/aws/config.test.js +++ b/test/unit/lib/aws/config.test.js @@ -2,7 +2,7 @@ const chai = require('chai'); const proxyquire = require('proxyquire'); -const overrideEnv = require('process-utils/override-env'); +const { overrideEnv } = require('../../../utils/process'); const { expect } = chai; diff --git a/test/unit/lib/aws/credentials.test.js b/test/unit/lib/aws/credentials.test.js index 98054e87f..be93d063c 100644 --- a/test/unit/lib/aws/credentials.test.js +++ b/test/unit/lib/aws/credentials.test.js @@ -4,7 +4,7 @@ const chai = require('chai'); const path = require('path'); const proxyquire = require('proxyquire'); const sinon = require('sinon'); -const overrideEnv = require('process-utils/override-env'); +const { overrideEnv } = require('../../../utils/process'); const { expect } = chai; diff --git a/test/unit/lib/aws/has-local-credentials.test.js b/test/unit/lib/aws/has-local-credentials.test.js index f57a4b365..5e7d0db60 100644 --- a/test/unit/lib/aws/has-local-credentials.test.js +++ b/test/unit/lib/aws/has-local-credentials.test.js @@ -1,7 +1,7 @@ 'use strict'; const chai = require('chai'); -const overrideEnv = require('process-utils/override-env'); +const { overrideEnv } = require('../../../utils/process'); const requireUncached = require('../../../utils/require-uncached'); const path = require('path'); const fse = require('fs-extra'); diff --git a/test/unit/lib/aws/request.test.js b/test/unit/lib/aws/request.test.js index 5bb489a1d..c6080537a 100644 --- a/test/unit/lib/aws/request.test.js +++ b/test/unit/lib/aws/request.test.js @@ -3,7 +3,7 @@ const sinon = require('sinon'); const chai = require('chai'); const proxyquire = require('proxyquire'); -const overrideEnv = require('process-utils/override-env'); +const { overrideEnv } = require('../../../utils/process'); const expect = chai.expect; diff --git a/test/unit/lib/classes/plugin-manager.test.js b/test/unit/lib/classes/plugin-manager.test.js index f194f5faf..b8c0f591a 100644 --- a/test/unit/lib/classes/plugin-manager.test.js +++ b/test/unit/lib/classes/plugin-manager.test.js @@ -1,8 +1,7 @@ 'use strict'; const chai = require('chai'); -const overrideEnv = require('process-utils/override-env'); -const overrideArgv = require('process-utils/override-argv'); +const { overrideEnv, overrideArgv } = require('../../../utils/process'); const runServerless = require('../../../utils/run-serverless'); const fixtures = require('../../../fixtures/programmatic'); const Serverless = require('../../../../lib/serverless'); diff --git a/test/unit/lib/cli/conditionally-load-dotenv.test.js b/test/unit/lib/cli/conditionally-load-dotenv.test.js index 6d88429b8..71d4f85e6 100644 --- a/test/unit/lib/cli/conditionally-load-dotenv.test.js +++ b/test/unit/lib/cli/conditionally-load-dotenv.test.js @@ -1,7 +1,7 @@ 'use strict'; const path = require('path'); -const overrideEnv = require('process-utils/override-env'); +const { overrideEnv } = require('../../../utils/process'); const fsp = require('fs').promises; const conditionallyLoadDotenv = require('../../../../lib/cli/conditionally-load-dotenv'); diff --git a/test/unit/lib/cli/ensure-supported-command.test.js b/test/unit/lib/cli/ensure-supported-command.test.js index 86fbbd91a..b8f00eb9f 100644 --- a/test/unit/lib/cli/ensure-supported-command.test.js +++ b/test/unit/lib/cli/ensure-supported-command.test.js @@ -1,7 +1,7 @@ 'use strict'; const { expect } = require('chai'); -const overrideArgv = require('process-utils/override-argv'); +const { overrideArgv } = require('../../../utils/process'); const ServerlessError = require('../../../../lib/serverless-error'); const { triggeredDeprecations } = require('../../../../lib/utils/log-deprecation'); const ensureSupportedCommand = require('../../../../lib/cli/ensure-supported-command'); diff --git a/test/unit/lib/cli/load-dotenv.test.js b/test/unit/lib/cli/load-dotenv.test.js index f0991ee2d..bf5ce0d8c 100644 --- a/test/unit/lib/cli/load-dotenv.test.js +++ b/test/unit/lib/cli/load-dotenv.test.js @@ -2,7 +2,7 @@ const path = require('path'); const sinon = require('sinon'); -const overrideEnv = require('process-utils/override-env'); +const { overrideEnv } = require('../../../utils/process'); const fsp = require('fs').promises; const loadEnv = require('../../../../lib/cli/load-dotenv'); const dotenv = require('dotenv'); diff --git a/test/unit/lib/cli/render-help/index.test.js b/test/unit/lib/cli/render-help/index.test.js index 4888316bd..dac6641b5 100644 --- a/test/unit/lib/cli/render-help/index.test.js +++ b/test/unit/lib/cli/render-help/index.test.js @@ -1,7 +1,7 @@ 'use strict'; const { expect } = require('chai'); -const overrideArgv = require('process-utils/override-argv'); +const { overrideArgv } = require('../../../../utils/process'); const resolveInput = require('../../../../../lib/cli/resolve-input'); const resolveFinalCommandsSchema = require('../../../../../lib/cli/commands-schema/resolve-final'); const renderHelp = require('../../../../../lib/cli/render-help'); diff --git a/test/unit/lib/cli/resolve-configuration-path.test.js b/test/unit/lib/cli/resolve-configuration-path.test.js index c702fb5c4..81bfc6549 100644 --- a/test/unit/lib/cli/resolve-configuration-path.test.js +++ b/test/unit/lib/cli/resolve-configuration-path.test.js @@ -7,8 +7,7 @@ const { expect } = chai; const path = require('path'); const fsp = require('fs').promises; const fse = require('fs-extra'); -const overrideArgv = require('process-utils/override-argv'); -const overrideEnv = require('process-utils/override-env'); +const { overrideArgv, overrideEnv } = require('../../../utils/process'); const requireUncached = require('../../../utils/require-uncached'); const resolveServerlessConfigPath = require('../../../../lib/cli/resolve-configuration-path'); const resolveInput = require('../../../../lib/cli/resolve-input'); diff --git a/test/unit/lib/cli/resolve-input.test.js b/test/unit/lib/cli/resolve-input.test.js index a95f730fc..231e7f31d 100644 --- a/test/unit/lib/cli/resolve-input.test.js +++ b/test/unit/lib/cli/resolve-input.test.js @@ -1,7 +1,7 @@ 'use strict'; const { expect } = require('chai'); -const overrideArgv = require('process-utils/override-argv'); +const { overrideArgv } = require('../../../utils/process'); const resolveInput = require('../../../../lib/cli/resolve-input'); const commandsSchema = require('../../../../lib/cli/commands-schema'); const resolveFinalCommandsSchema = require('../../../../lib/cli/commands-schema/resolve-final'); diff --git a/test/unit/lib/cli/run-compose.test.js b/test/unit/lib/cli/run-compose.test.js index 322b6cc65..1d1e2b23b 100644 --- a/test/unit/lib/cli/run-compose.test.js +++ b/test/unit/lib/cli/run-compose.test.js @@ -4,9 +4,7 @@ const path = require('path'); const fse = require('fs-extra'); const proxyquire = require('proxyquire'); const sinon = require('sinon'); -const overrideEnv = require('process-utils/override-env'); -const overrideCwd = require('process-utils/override-cwd'); -const overrideStdoutWrite = require('process-utils/override-stdout-write'); +const { overrideEnv, overrideCwd, overrideStdoutWrite } = require('../../../utils/process'); const { expect } = require('chai'); const provisionTmpDir = require('../../../lib/provision-tmp-dir'); @@ -33,7 +31,7 @@ const loadRunCompose = ({ spawnStub, inquirerStub, progressFooterFactoryStub, fs return proxyquire.noCallThru().load(modulePath, { '../utils/spawn': spawnStub, '../utils/serverless-utils/inquirer': inquirerStub, - 'cli-progress-footer': progressFooterFactoryStub, + '../utils/progress-footer': progressFooterFactoryStub, ...(fsStub ? { fs: fsStub } : {}), }); }; diff --git a/test/unit/lib/cli/triage/index.test.js b/test/unit/lib/cli/triage/index.test.js index 02adb8614..f8600230b 100644 --- a/test/unit/lib/cli/triage/index.test.js +++ b/test/unit/lib/cli/triage/index.test.js @@ -2,9 +2,7 @@ const { expect } = require('chai'); const fs = require('fs'); -const overrideCwd = require('process-utils/override-cwd'); -const overrideEnv = require('process-utils/override-env'); -const overrideArgv = require('process-utils/override-argv'); +const { overrideCwd, overrideEnv, overrideArgv } = require('../../../../utils/process'); const path = require('path'); const triage = require('../../../../../lib/cli/triage'); diff --git a/test/unit/lib/configuration/variables/eventually-report-resolution-errors.test.js b/test/unit/lib/configuration/variables/eventually-report-resolution-errors.test.js index 101fd5fb3..94df61877 100644 --- a/test/unit/lib/configuration/variables/eventually-report-resolution-errors.test.js +++ b/test/unit/lib/configuration/variables/eventually-report-resolution-errors.test.js @@ -2,7 +2,7 @@ const { expect } = require('chai'); -const overrideArgv = require('process-utils/override-argv'); +const { overrideArgv } = require('../../../../utils/process'); const ServerlessError = require('../../../../../lib/serverless-error'); const resolveCliInput = require('../../../../../lib/cli/resolve-input'); const resolveMeta = require('../../../../../lib/configuration/variables/resolve-meta'); diff --git a/test/unit/lib/plugins/aws/invoke-local/index.test.js b/test/unit/lib/plugins/aws/invoke-local/index.test.js index 72f099317..750b43854 100644 --- a/test/unit/lib/plugins/aws/invoke-local/index.test.js +++ b/test/unit/lib/plugins/aws/invoke-local/index.test.js @@ -11,7 +11,7 @@ const EventEmitter = require('events'); const fse = require('fs-extra'); const log = require('log').get('serverless:test'); const proxyquire = require('proxyquire'); -const overrideEnv = require('process-utils/override-env'); +const { overrideEnv } = require('../../../../../utils/process'); const AwsProvider = require('../../../../../../lib/plugins/aws/provider'); const Serverless = require('../../../../../../lib/serverless'); const CLI = require('../../../../../../lib/classes/cli'); diff --git a/test/unit/lib/plugins/aws/provider.test.js b/test/unit/lib/plugins/aws/provider.test.js index bc8258916..e288b4ef3 100644 --- a/test/unit/lib/plugins/aws/provider.test.js +++ b/test/unit/lib/plugins/aws/provider.test.js @@ -6,7 +6,7 @@ const fs = require('fs-extra'); const os = require('os'); const proxyquire = require('proxyquire'); const sinon = require('sinon'); -const overrideEnv = require('process-utils/override-env'); +const { overrideEnv } = require('../../../../utils/process'); const AwsProvider = require('../../../../../lib/plugins/aws/provider'); const Serverless = require('../../../../../lib/serverless'); diff --git a/test/unit/lib/plugins/aws/utils/credentials.test.js b/test/unit/lib/plugins/aws/utils/credentials.test.js index 82c8e7173..ab5420def 100644 --- a/test/unit/lib/plugins/aws/utils/credentials.test.js +++ b/test/unit/lib/plugins/aws/utils/credentials.test.js @@ -4,7 +4,7 @@ const expect = require('chai').expect; const os = require('os'); const path = require('path'); const { outputFile, lstat, remove: rmDir } = require('fs-extra'); -const overrideEnv = require('process-utils/override-env'); +const { overrideEnv } = require('../../../../../utils/process'); const credentials = require('../../../../../../lib/plugins/aws/utils/credentials'); describe('#credentials', () => { diff --git a/test/unit/lib/plugins/invoke.test.js b/test/unit/lib/plugins/invoke.test.js index 923ae2074..47de5025f 100644 --- a/test/unit/lib/plugins/invoke.test.js +++ b/test/unit/lib/plugins/invoke.test.js @@ -1,7 +1,7 @@ 'use strict'; const chai = require('chai'); -const overrideEnv = require('process-utils/override-env'); +const { overrideEnv } = require('../../../utils/process'); const Invoke = require('../../../../lib/plugins/invoke'); const Serverless = require('../../../../lib/serverless'); diff --git a/test/unit/lib/utils/get-framework-id.test.js b/test/unit/lib/utils/get-framework-id.test.js index f5d9ef93f..0d0814ece 100644 --- a/test/unit/lib/utils/get-framework-id.test.js +++ b/test/unit/lib/utils/get-framework-id.test.js @@ -7,8 +7,7 @@ const fse = require('fs-extra'); const { expect } = require('chai'); const requireUncached = require('../../../utils/require-uncached'); const sinon = require('sinon'); -const overrideEnv = require('process-utils/override-env'); -const overrideCwd = require('process-utils/override-cwd'); +const { overrideEnv, overrideCwd } = require('../../../utils/process'); const withIsolatedHome = async (name, callback) => { const homeDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `${name}-home-`)); diff --git a/test/unit/lib/utils/log-deprecation.test.js b/test/unit/lib/utils/log-deprecation.test.js index 310849742..7dafa412b 100644 --- a/test/unit/lib/utils/log-deprecation.test.js +++ b/test/unit/lib/utils/log-deprecation.test.js @@ -3,7 +3,7 @@ const fsp = require('fs').promises; const sandbox = require('sinon'); const expect = require('chai').expect; -const overrideEnv = require('process-utils/override-env'); +const { overrideEnv } = require('../../../utils/process'); const ServerlessError = require('../../../../lib/serverless-error'); describe('test/unit/lib/utils/logDeprecation.test.js', () => { diff --git a/test/unit/lib/utils/process.test.js b/test/unit/lib/utils/process.test.js new file mode 100644 index 000000000..6f1d1eda4 --- /dev/null +++ b/test/unit/lib/utils/process.test.js @@ -0,0 +1,234 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { expect } = require('chai'); + +const { + createEnv, + overrideArgv, + overrideCwd, + overrideEnv, + overrideStdoutWrite, +} = require('../../../utils/process'); + +describe('test/unit/lib/utils/process.test.js', () => { + let originalEnv; + let originalArgv; + let originalStdoutWrite; + let originalCwd; + let tmpDir; + + beforeEach(() => { + originalEnv = process.env; + originalArgv = process.argv; + originalStdoutWrite = process.stdout.write; + originalCwd = process.cwd(); + tmpDir = null; + }); + + afterEach(() => { + process.env = originalEnv; + process.argv = originalArgv; + process.stdout.write = originalStdoutWrite; + process.chdir(originalCwd); + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('creates env from whitelist and variables', () => { + process.env = { KEEP: 'yes', SKIP: 'no' }; + + const env = createEnv({ + whitelist: ['KEEP', 'MISSING'], + variables: { ADDED: '42' }, + }); + + expect(env.KEEP).to.equal('yes'); + expect(env.ADDED).to.equal('42'); + expect(env.SKIP).to.equal(undefined); + expect(env.MISSING).to.equal(undefined); + }); + + it('preserves unsafe env keys as own properties', () => { + const variables = Object.create(null); + variables.__proto__ = 'proto-value'; + variables.toString = 'string-value'; + + const env = createEnv({ variables }); + + expect(Object.prototype.hasOwnProperty.call(env, '__proto__')).to.equal(true); + expect(Object.prototype.hasOwnProperty.call(env, 'toString')).to.equal(true); + expect(env.__proto__).to.equal('proto-value'); + expect(env.toString).to.equal('string-value'); + }); + + it('matches upstream coercion errors for Symbol env values', () => { + expect(() => createEnv({ variables: { FOO: Symbol('foo') } })).to.throw(TypeError); + }); + + it('copies current env when asCopy is true', () => { + process.env = { COPIED: 'yes' }; + + const env = createEnv({ asCopy: true }); + + expect(env.COPIED).to.equal('yes'); + }); + + it('returns manual env restore handles', () => { + const { originalEnv: handleOriginalEnv, restoreEnv } = overrideEnv({ + variables: { FOO: 'bar' }, + }); + + expect(handleOriginalEnv).to.equal(originalEnv); + expect(process.env).to.not.equal(originalEnv); + expect(process.env.FOO).to.equal('bar'); + + restoreEnv(); + + expect(process.env).to.equal(originalEnv); + }); + + it('restores env after callback resolves', async () => { + const result = await overrideEnv({ variables: { FOO: 'bar' } }, async (callbackOriginalEnv) => { + expect(callbackOriginalEnv).to.equal(originalEnv); + expect(process.env.FOO).to.equal('bar'); + return 'result'; + }); + + expect(result).to.equal('result'); + expect(process.env).to.equal(originalEnv); + }); + + it('restores env after callback rejects', async () => { + const error = new Error('failure'); + + await expect( + overrideEnv({ variables: { FOO: 'bar' } }, async () => { + throw error; + }) + ).to.be.rejectedWith(error); + + expect(process.env).to.equal(originalEnv); + }); + + it('restores env after callback throws', () => { + const error = new Error('failure'); + + expect(() => + overrideEnv({ variables: { FOO: 'bar' } }, () => { + throw error; + }) + ).to.throw(error); + + expect(process.env).to.equal(originalEnv); + }); + + it('returns manual cwd restore handles', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'local-process-cwd-')); + const expectedCwd = fs.realpathSync(tmpDir); + + const { originalCwd: handleOriginalCwd, restoreCwd } = overrideCwd(tmpDir); + + expect(handleOriginalCwd).to.equal(originalCwd); + expect(process.cwd()).to.equal(expectedCwd); + + restoreCwd(); + + expect(process.cwd()).to.equal(originalCwd); + }); + + it('restores cwd after callback resolves', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'local-process-cwd-')); + const expectedCwd = fs.realpathSync(tmpDir); + + const result = await overrideCwd(tmpDir, async (callbackOriginalCwd) => { + expect(callbackOriginalCwd).to.equal(originalCwd); + expect(process.cwd()).to.equal(expectedCwd); + return 'result'; + }); + + expect(result).to.equal('result'); + expect(process.cwd()).to.equal(originalCwd); + }); + + it('returns manual argv restore handles', () => { + process.argv = ['node', 'script.js', 'old']; + const argvBeforeOverride = process.argv; + + const { originalArgv: handleOriginalArgv, restoreArgv } = overrideArgv({ + sliceAt: 2, + args: ['deploy', 42], + }); + + expect(handleOriginalArgv).to.equal(argvBeforeOverride); + expect(process.argv).to.deep.equal(['node', 'script.js', 'deploy', '42']); + + restoreArgv(); + + expect(process.argv).to.equal(argvBeforeOverride); + }); + + it('restores argv after callback resolves', async () => { + process.argv = ['node', 'script.js', 'old']; + const argvBeforeOverride = process.argv; + + const result = await overrideArgv({ args: ['deploy'] }, async (callbackOriginalArgv) => { + expect(callbackOriginalArgv).to.equal(argvBeforeOverride); + expect(process.argv).to.deep.equal(['node', 'deploy']); + return 'result'; + }); + + expect(result).to.equal('result'); + expect(process.argv).to.equal(argvBeforeOverride); + }); + + it('rejects invalid argv sliceAt values', () => { + expect(() => overrideArgv({ sliceAt: -1 })).to.throw(TypeError); + expect(() => overrideArgv({ sliceAt: 1.5 })).to.throw(TypeError); + }); + + it('returns manual stdout.write restore handles', () => { + let output = ''; + + const { + originalWrite, + originalStdoutWrite: handleOriginalStdoutWrite, + restoreStdoutWrite, + } = overrideStdoutWrite((chunk) => { + output += String(chunk); + return 'handled'; + }); + + expect(originalWrite).to.equal(originalStdoutWrite); + expect(handleOriginalStdoutWrite).to.be.a('function'); + expect(process.stdout.write('hello')).to.equal('handled'); + expect(output).to.equal('hello'); + + restoreStdoutWrite(); + + expect(process.stdout.write).to.equal(originalStdoutWrite); + }); + + it('restores stdout.write after callback resolves', async () => { + let output = ''; + + const result = await overrideStdoutWrite( + (chunk) => { + output += String(chunk); + return true; + }, + async (handleOriginalStdoutWrite, handleOriginalWrite) => { + expect(handleOriginalStdoutWrite).to.be.a('function'); + expect(handleOriginalWrite).to.equal(originalStdoutWrite); + expect(process.stdout.write('hello')).to.equal(true); + return 'result'; + } + ); + + expect(result).to.equal('result'); + expect(output).to.equal('hello'); + expect(process.stdout.write).to.equal(originalStdoutWrite); + }); +}); diff --git a/test/unit/lib/utils/progress-footer.test.js b/test/unit/lib/utils/progress-footer.test.js new file mode 100644 index 000000000..4f266f1e2 --- /dev/null +++ b/test/unit/lib/utils/progress-footer.test.js @@ -0,0 +1,345 @@ +'use strict'; + +const childProcess = require('child_process'); + +const sinon = require('sinon'); +const { expect } = require('chai'); + +const modulePath = '../../../../lib/utils/progress-footer'; +const moveUp = '\x1b[1A'; +const clearLine = '\x1b[2K'; +const clearProgressLine = `${moveUp}${clearLine}`; + +const stringifyChunk = (chunk) => { + if (typeof chunk === 'string') return chunk; + if (chunk instanceof Uint8Array) return Buffer.from(chunk).toString(); + return String(chunk); +}; + +const loadProgressFooter = (platform = process.platform) => { + const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + delete require.cache[require.resolve(modulePath)]; + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform, + }); + + try { + return require(modulePath); + } finally { + Object.defineProperty(process, 'platform', originalPlatformDescriptor); + } +}; + +const createMockStream = (options = {}) => { + const writes = []; + const stream = { + columns: options.columns || 80, + write(chunk, encoding, callback) { + writes.push(chunk); + if (typeof encoding === 'function') encoding(); + if (callback) callback(); + return true; + }, + }; + + return { + stream, + get output() { + return writes.map(stringifyChunk).join(''); + }, + get chunks() { + return writes.map(stringifyChunk); + }, + clear() { + writes.length = 0; + }, + }; +}; + +const createFooter = (options = {}) => { + const stdout = createMockStream({ columns: options.columns }); + const stderr = createMockStream({ columns: options.columns }); + const createProgressFooter = loadProgressFooter(options.platform); + const footer = createProgressFooter({ stdout: stdout.stream, stderr: stderr.stream }); + + return { footer, stdout, stderr }; +}; + +describe('test/unit/lib/utils/progress-footer.test.js', () => { + afterEach(() => { + delete require.cache[require.resolve(modulePath)]; + sinon.restore(); + }); + + it('writes array progress rows', () => { + const { footer, stdout } = createFooter(); + + footer.updateProgress(['first', 'second']); + + expect(stdout.output).to.equal('\nfirst\nsecond\n'); + }); + + it('splits string progress rows', () => { + const { footer, stdout } = createFooter(); + + footer.updateProgress('first\nsecond'); + + expect(stdout.output).to.equal('\nfirst\nsecond\n'); + }); + + it('adds animation prefix when enabled', () => { + const clock = sinon.useFakeTimers(); + const { footer, stdout } = createFooter(); + const interval = process.platform === 'win32' ? 100 : 80; + + footer.progressAnimationPrefixFrames = ['a', 'b']; + footer.shouldAddProgressAnimationPrefix = true; + footer.updateProgress(['row']); + + expect(stdout.output).to.equal('\na row\n'); + + stdout.clear(); + clock.tick(interval); + + expect(stdout.output).to.equal(`${clearProgressLine}${clearProgressLine}\nb row\n`); + footer.updateProgress(); + }); + + it('uses the platform default animation interval', () => { + const intervals = []; + sinon.stub(global, 'setInterval').callsFake((callback, interval) => { + intervals.push(interval); + return { unref: sinon.stub() }; + }); + sinon.stub(global, 'clearInterval'); + + for (const [platform, expectedInterval] of [ + ['linux', 80], + ['win32', 100], + ]) { + const stdout = createMockStream(); + const stderr = createMockStream(); + const createProgressFooter = loadProgressFooter(platform); + const footer = createProgressFooter({ stdout: stdout.stream, stderr: stderr.stream }); + + footer.shouldAddProgressAnimationPrefix = true; + footer.updateProgress('row'); + footer.updateProgress(); + + expect(intervals.pop()).to.equal(expectedInterval); + delete require.cache[require.resolve(modulePath)]; + } + }); + + it('restores process.platform descriptor after platform-specific loading', () => { + const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + + loadProgressFooter('win32'); + + expect(Object.getOwnPropertyDescriptor(process, 'platform')).to.deep.equal( + originalPlatformDescriptor + ); + }); + + it('accepts null options like the upstream footer', () => { + const originalStdoutWrite = process.stdout.write; + const originalStderrWrite = process.stderr.write; + process.stdout.write = () => true; + process.stderr.write = () => true; + + try { + expect(() => loadProgressFooter()(null)).to.not.throw(); + } finally { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + } + }); + + it('clears rendered rows when progress is cleared', () => { + const { footer, stdout } = createFooter(); + footer.updateProgress(['row']); + stdout.clear(); + + footer.updateProgress(); + + expect(stdout.output).to.equal(`${clearProgressLine}${clearProgressLine}`); + }); + + it('clears and redraws progress around stdout writes without recursion', () => { + const { footer, stdout } = createFooter(); + footer.updateProgress('progress'); + stdout.clear(); + + stdout.stream.write('log\n'); + + expect(stdout.output).to.equal(`${clearProgressLine}${clearProgressLine}log\n\nprogress\n`); + }); + + it('redirects stderr writes through stdout while active', () => { + const { footer, stdout, stderr } = createFooter(); + footer.updateProgress('progress'); + stdout.clear(); + stderr.clear(); + + stderr.stream.write('warn\n'); + + expect(stderr.output).to.equal(''); + expect(stdout.output).to.equal(`${clearProgressLine}${clearProgressLine}warn\n\nprogress\n`); + }); + + it('tracks partial stdout writes before first progress update', () => { + const { footer, stdout } = createFooter(); + + stdout.stream.write('partial'); + footer.updateProgress('progress'); + + expect(stdout.output).to.equal('partial\n\nprogress\n'); + }); + + it('tracks partial stderr writes before first progress update', () => { + const { footer, stdout, stderr } = createFooter(); + + stderr.stream.write('partial'); + footer.updateProgress('progress'); + + expect(stderr.output).to.equal('partial'); + expect(stdout.output).to.equal('\n\nprogress\n'); + }); + + it('continues tracking partial stdout writes after progress is cleared', () => { + const { footer, stdout } = createFooter(); + footer.updateProgress('progress'); + footer.updateProgress(); + stdout.clear(); + + stdout.stream.write('later'); + footer.updateProgress('progress'); + + expect(stdout.output).to.equal('later\n\nprogress\n'); + }); + + it('continues tracking partial stderr writes after progress is cleared', () => { + const { footer, stdout, stderr } = createFooter(); + footer.updateProgress('progress'); + footer.updateProgress(); + stdout.clear(); + stderr.clear(); + + stderr.stream.write('later'); + footer.updateProgress('progress'); + + expect(stderr.output).to.equal('later'); + expect(stdout.output).to.equal('\n\nprogress\n'); + }); + + it('preserves the upstream leading spacer on first clean progress render', () => { + const { footer, stdout } = createFooter(); + + footer.updateProgress('progress'); + + expect(stdout.output).to.equal('\nprogress\n'); + }); + + it('keeps a blank spacer after partial stdout writes', () => { + const { footer, stdout } = createFooter(); + stdout.stream.write('partial'); + stdout.clear(); + + footer.updateProgress('progress'); + + expect(stdout.output).to.equal('\n\nprogress\n'); + }); + + it('keeps a blank spacer after newline-terminated stdout writes', () => { + const { footer, stdout } = createFooter(); + stdout.stream.write('line\n'); + stdout.clear(); + + footer.updateProgress('progress'); + + expect(stdout.output).to.equal('\nprogress\n'); + }); + + it('does not add an extra spacer after an already empty line', () => { + const { footer, stdout } = createFooter(); + stdout.stream.write('\n\n'); + stdout.clear(); + + footer.updateProgress('progress'); + + expect(stdout.output).to.equal('progress\n'); + }); + + it('preserves split stdout writes without inserting logical newlines', () => { + const { footer, stdout } = createFooter(); + footer.updateProgress('progress'); + stdout.clear(); + + stdout.stream.write('he'); + stdout.stream.write('llo\n'); + + expect(stdout.chunks).to.include('he'); + expect(stdout.chunks).to.include('llo\n'); + expect(stdout.chunks).to.not.include('he\n'); + expect(stdout.chunks).to.not.include('\nllo\n'); + }); + + it('counts multiline and wrapped rows when clearing', () => { + const { footer, stdout } = createFooter({ columns: 3 }); + footer.updateProgress(['abcdef', 'x\ny']); + stdout.clear(); + + footer.updateProgress(); + + expect(stdout.output).to.equal(clearProgressLine.repeat(5)); + }); + + it('supports write callbacks, Buffer chunks, and Uint8Array chunks', () => { + const { footer, stdout } = createFooter(); + let callbackCalled = false; + footer.updateProgress('progress'); + stdout.clear(); + + stdout.stream.write(Buffer.from('buffer\n'), () => { + callbackCalled = true; + }); + stdout.stream.write(new Uint8Array(Buffer.from('bytes\n'))); + + expect(callbackCalled).to.equal(true); + expect(stdout.output).to.include('buffer\n'); + expect(stdout.output).to.include('bytes\n'); + }); + + it('does not patch child_process APIs', () => { + const originalMethods = { + exec: childProcess.exec, + execFile: childProcess.execFile, + fork: childProcess.fork, + spawn: childProcess.spawn, + }; + const { footer } = createFooter(); + + footer.updateProgress('progress'); + footer.updateProgress(); + + expect(childProcess.exec).to.equal(originalMethods.exec); + expect(childProcess.execFile).to.equal(originalMethods.execFile); + expect(childProcess.fork).to.equal(originalMethods.fork); + expect(childProcess.spawn).to.equal(originalMethods.spawn); + }); + + it('does not discard stdin, hide the cursor, or install stdin SIGINT forwarding', () => { + const stdinDataListeners = process.stdin.listeners('data'); + const stdinSigintListeners = process.stdin.listeners('SIGINT'); + const { footer, stdout } = createFooter(); + + footer.updateProgress('progress'); + footer.updateProgress(); + + expect(process.stdin.listeners('data')).to.deep.equal(stdinDataListeners); + expect(process.stdin.listeners('SIGINT')).to.deep.equal(stdinSigintListeners); + expect(stdout.output).to.not.include('\x1b[?25l'); + expect(stdout.output).to.not.include('\x1b[?25h'); + }); +}); diff --git a/test/unit/lib/utils/resolve-process-tmp-dir.test.js b/test/unit/lib/utils/resolve-process-tmp-dir.test.js new file mode 100644 index 000000000..cf7d5f906 --- /dev/null +++ b/test/unit/lib/utils/resolve-process-tmp-dir.test.js @@ -0,0 +1,120 @@ +'use strict'; + +const fsp = require('fs').promises; +const os = require('os'); +const path = require('path'); +const proxyquire = require('proxyquire').noCallThru().noPreserveCache(); +const requireUncached = require('../../../utils/require-uncached'); +const sinon = require('sinon'); + +const { expect } = require('chai'); + +describe('test/unit/lib/utils/resolve-process-tmp-dir.test.js', () => { + afterEach(() => { + sinon.restore(); + }); + + it('creates and reuses a process temp directory', async () => { + const resolveProcessTmpDir = requireUncached(() => + require('../../../../lib/utils/resolve-process-tmp-dir') + ); + + const firstTmpDir = await resolveProcessTmpDir(); + const secondTmpDir = await resolveProcessTmpDir(); + + expect(secondTmpDir).to.equal(firstTmpDir); + expect(path.basename(firstTmpDir)).to.match(/^node-process-[0-9a-f]{4}-/); + expect((await fsp.stat(firstTmpDir)).isDirectory()).to.equal(true); + + await fsp.rm(firstTmpDir, { recursive: true, force: true }); + }); + + it('retries after a failed temp directory creation', async () => { + const error = new Error('temporary failure'); + const tmpDir = path.join(os.tmpdir(), 'node-process-abcd-retry'); + + const fsStub = { + promises: { + mkdtemp: sinon.stub(), + }, + rmSync: sinon.stub(), + }; + fsStub.promises.mkdtemp.onFirstCall().rejects(error); + fsStub.promises.mkdtemp.onSecondCall().resolves(tmpDir); + sinon.stub(process, 'once').returns(process); + + const resolveProcessTmpDir = proxyquire('../../../../lib/utils/resolve-process-tmp-dir', { + fs: fsStub, + crypto: { + randomBytes: () => Buffer.from('abcd', 'hex'), + }, + }); + + await expect(resolveProcessTmpDir()).to.be.rejectedWith(error); + expect(await resolveProcessTmpDir()).to.equal(tmpDir); + expect(fsStub.promises.mkdtemp).to.have.been.calledTwice; + }); + + it('memoizes the resolved temp dir for subsequent calls', async () => { + const tmpDir = path.join(os.tmpdir(), 'node-process-abcd-memoized'); + const fsStub = { + promises: { mkdtemp: sinon.stub().resolves(tmpDir) }, + rmSync: sinon.stub(), + }; + sinon.stub(process, 'once').returns(process); + + const resolveProcessTmpDir = proxyquire('../../../../lib/utils/resolve-process-tmp-dir', { + fs: fsStub, + crypto: { randomBytes: () => Buffer.from('abcd', 'hex') }, + }); + + expect(await resolveProcessTmpDir()).to.equal(tmpDir); + expect(await resolveProcessTmpDir()).to.equal(tmpDir); + expect(fsStub.promises.mkdtemp).to.have.been.calledOnce; + }); + + it('registers an exit cleanup for the resolved temp dir', async () => { + const tmpDir = path.join(os.tmpdir(), 'node-process-abcd-cleanup'); + const fsStub = { + promises: { mkdtemp: sinon.stub().resolves(tmpDir) }, + rmSync: sinon.stub(), + }; + const onceStub = sinon.stub(process, 'once').returns(process); + + const resolveProcessTmpDir = proxyquire('../../../../lib/utils/resolve-process-tmp-dir', { + fs: fsStub, + crypto: { randomBytes: () => Buffer.from('abcd', 'hex') }, + }); + + await resolveProcessTmpDir(); + + expect(onceStub).to.have.been.calledOnceWithExactly('exit', sinon.match.func); + onceStub.firstCall.args[1](); + expect(fsStub.rmSync).to.have.been.calledOnceWithExactly(tmpDir, { + recursive: true, + force: true, + }); + }); + + it('swallows rmSync errors during exit cleanup', async () => { + const tmpDir = path.join(os.tmpdir(), 'node-process-abcd-cleanup-error'); + const fsStub = { + promises: { mkdtemp: sinon.stub().resolves(tmpDir) }, + rmSync: sinon.stub().throws(new Error('cleanup failed')), + }; + const onceStub = sinon.stub(process, 'once').returns(process); + + const resolveProcessTmpDir = proxyquire('../../../../lib/utils/resolve-process-tmp-dir', { + fs: fsStub, + crypto: { randomBytes: () => Buffer.from('abcd', 'hex') }, + }); + + await resolveProcessTmpDir(); + + expect(() => onceStub.firstCall.args[1]()).to.not.throw(); + expect(fsStub.rmSync).to.have.been.calledOnceWithExactly(tmpDir, { + recursive: true, + force: true, + }); + }); +}); diff --git a/test/unit/lib/utils/serverless-utils/config.test.js b/test/unit/lib/utils/serverless-utils/config.test.js index 028011209..5a008b74e 100644 --- a/test/unit/lib/utils/serverless-utils/config.test.js +++ b/test/unit/lib/utils/serverless-utils/config.test.js @@ -7,8 +7,7 @@ const fse = require('fs-extra'); const { expect } = require('chai'); const requireUncached = require('../../../../utils/require-uncached'); const sinon = require('sinon'); -const overrideEnv = require('process-utils/override-env'); -const overrideCwd = require('process-utils/override-cwd'); +const { overrideEnv, overrideCwd } = require('../../../../utils/process'); const loadConfigModule = () => requireUncached(() => require('../../../../../lib/utils/serverless-utils/config')); diff --git a/test/unit/lib/utils/serverless-utils/lib/log-reporters/node/progress-reporter.test.js b/test/unit/lib/utils/serverless-utils/lib/log-reporters/node/progress-reporter.test.js new file mode 100644 index 000000000..4b6fa005e --- /dev/null +++ b/test/unit/lib/utils/serverless-utils/lib/log-reporters/node/progress-reporter.test.js @@ -0,0 +1,106 @@ +'use strict'; + +const chai = require('chai'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); + +const expect = chai.expect; + +describe('test/unit/lib/utils/serverless-utils/lib/log-reporters/node/progress-reporter.test.js', () => { + afterEach(() => { + sinon.restore(); + }); + + const loadProgressReporter = () => { + const handlers = new Map(); + const cliProgressFooter = { + shouldAddProgressAnimationPrefix: false, + progressAnimationPrefixFrames: ['.', 'o'], + updateProgress: sinon.stub(), + }; + const progress = {}; + const log = { info: sinon.stub() }; + const joinTextTokens = sinon.stub().returns('deploying\n'); + const style = { + aside: sinon.stub().callsFake((value) => value), + noticeSymbol: sinon.stub().callsFake((value) => value), + }; + + const load = proxyquire + .noCallThru() + .load( + '../../../../../../../../lib/utils/serverless-utils/lib/log-reporters/node/progress-reporter', + { + '../../../../progress-footer': sinon.stub().returns(cliProgressFooter), + '../../log/get-progress-reporter': { + emitter: { + on: sinon.stub().callsFake((eventName, handler) => { + handlers.set(eventName, handler); + }), + }, + }, + '../../../log': { progress, log }, + '../../log/join-text-tokens': joinTextTokens, + './style': style, + } + ); + + return { load, handlers, cliProgressFooter, progress, log, joinTextTokens, style }; + }; + + it('logs main events once and clears the progress footer', () => { + const { load, handlers, cliProgressFooter, progress, log, joinTextTokens, style } = + loadProgressReporter(); + const setIntervalStub = sinon.stub(global, 'setInterval').returns(123); + const clearIntervalStub = sinon.stub(global, 'clearInterval'); + sinon.stub(Date, 'now').returns(1000); + + load({ logLevelIndex: 2 }); + handlers.get('update')({ + namespace: 'serverless', + name: 'main', + levelIndex: 2, + textTokens: ['deploying'], + options: { isMainEvent: true }, + }); + handlers.get('update')({ + namespace: 'serverless', + name: 'main', + levelIndex: 2, + textTokens: ['deploying'], + options: { isMainEvent: true }, + }); + progress.clear(); + + expect(joinTextTokens).to.have.been.calledTwice; + expect(joinTextTokens.firstCall).to.have.been.calledWithExactly([['deploying']]); + expect(log.info).to.have.been.calledOnceWithExactly('deploying'); + expect(setIntervalStub).to.have.been.calledOnce; + expect(style.aside).to.have.been.calledWithExactly('(0s)'); + expect(cliProgressFooter.updateProgress.firstCall).to.have.been.calledWithExactly([ + 'deploying (0s)', + ]); + expect(clearIntervalStub).to.have.been.calledOnceWithExactly(123); + expect(cliProgressFooter.updateProgress.lastCall).to.have.been.calledWithExactly(); + }); + + it('tracks and removes sub-progress items', () => { + const { load, handlers, cliProgressFooter, joinTextTokens } = loadProgressReporter(); + + load({ logLevelIndex: 2 }); + handlers.get('update')({ + namespace: 'serverless:plugin:aws', + name: 'deploy', + levelIndex: 2, + textTokens: ['deploying'], + options: null, + }); + handlers.get('remove')({ namespace: 'serverless:plugin:aws', name: 'deploy' }); + + expect(joinTextTokens).to.have.been.calledOnceWithExactly([['deploying']]); + expect(cliProgressFooter.updateProgress.firstCall).to.have.been.calledWithExactly([ + 'deploying', + ]); + expect(cliProgressFooter.updateProgress.secondCall).to.have.been.calledWithExactly([]); + }); +}); diff --git a/test/unit/scripts/serverless-signals.test.js b/test/unit/scripts/serverless-signals.test.js index 298786ef5..bf338602d 100644 --- a/test/unit/scripts/serverless-signals.test.js +++ b/test/unit/scripts/serverless-signals.test.js @@ -82,7 +82,6 @@ describe('test/unit/scripts/serverless-signals.test.js', () => { delete require.cache[require.resolve(modulePath)]; proxyquire.noCallThru().load(modulePath, { - 'graceful-fs': { gracefulify: sinon.stub() }, '../lib/utils/serverless-utils/log-reporters/node': {}, '../lib/utils/serverless-utils/log': { log, progress }, '../lib/cli/handle-error': stubs.handleError, diff --git a/test/utils/process.js b/test/utils/process.js new file mode 100644 index 000000000..96cd8dbcb --- /dev/null +++ b/test/utils/process.js @@ -0,0 +1,165 @@ +'use strict'; + +const path = require('path'); + +const runWithRestore = (callback, callbackArgs, restore) => { + let result; + + try { + result = callback(...callbackArgs); + } catch (error) { + restore(); + throw error; + } + + if (result && typeof result.then === 'function') { + return Promise.resolve(result).finally(restore); + } + + restore(); + return result; +}; + +const createEnv = (options = {}) => { + if (!options || typeof options !== 'object') options = {}; + + if (options.asCopy && options.whitelist) { + throw new Error('Either `asCopy` or `whitelist` option is expected but not both'); + } + + const env = new Proxy( + {}, + { + set(target, key, value) { + Object.defineProperty(target, key, { + configurable: true, + enumerable: true, + value: `${value}`, + writable: true, + }); + return true; + }, + defineProperty(target, key, descriptor) { + Object.defineProperty(target, key, { + configurable: true, + enumerable: true, + value: `${descriptor.value}`, + writable: true, + }); + return true; + }, + } + ); + + if (options.asCopy) { + for (const [name, value] of Object.entries(process.env)) { + env[name] = value; + } + } + + if (options.whitelist) { + for (const name of options.whitelist) { + if (Object.prototype.hasOwnProperty.call(process.env, name)) { + env[name] = process.env[name]; + } + } + } + + if (options.variables) { + for (const [name, value] of Object.entries(options.variables)) { + env[name] = value; + } + } + + return env; +}; + +const overrideEnv = (options = {}, callback = null) => { + if (typeof options === 'function') { + callback = options; + options = {}; + } + + const originalEnv = process.env; + process.env = createEnv(options); + + const restoreEnv = () => { + process.env = originalEnv; + }; + + if (!callback) return { originalEnv, restoreEnv }; + return runWithRestore(callback, [originalEnv], restoreEnv); +}; + +const overrideCwd = (counterpart, callback = null) => { + const originalCwd = process.cwd(); + process.chdir(path.resolve(String(counterpart))); + + const restoreCwd = () => { + process.chdir(originalCwd); + }; + + if (!callback) return { originalCwd, restoreCwd }; + return runWithRestore(callback, [originalCwd], restoreCwd); +}; + +const overrideArgv = (options = {}, callback = null) => { + if (typeof options === 'function') { + callback = options; + options = {}; + } + + const originalArgv = process.argv; + const sliceAt = options.sliceAt == null ? 1 : Number(options.sliceAt); + if (!Number.isInteger(sliceAt) || sliceAt < 0) { + throw new TypeError('`sliceAt` expected to be a non-negative integer'); + } + const argv = process.argv.slice(0, sliceAt); + + if (options.args) { + argv.push(...Array.from(options.args, String)); + } + + process.argv = argv; + + const restoreArgv = () => { + process.argv = originalArgv; + }; + + if (!callback) return { originalArgv, restoreArgv }; + return runWithRestore(callback, [originalArgv], restoreArgv); +}; + +const overrideStreamWrite = (stream, restoreKey, customWrite, callback = null) => { + const originalWrite = stream.write; + const originalStdWrite = originalWrite.bind(stream); + + stream.write = function write(data, encoding, cb) { + return customWrite.call(this, data, originalStdWrite, encoding, cb); + }; + + const restore = () => { + stream.write = originalWrite; + }; + + if (!callback) { + return { + originalWrite, + [restoreKey.replace('restore', 'original')]: originalStdWrite, + [restoreKey]: restore, + }; + } + + return runWithRestore(callback, [originalStdWrite, originalWrite], restore); +}; + +const overrideStdoutWrite = (customWrite, callback = null) => + overrideStreamWrite(process.stdout, 'restoreStdoutWrite', customWrite, callback); + +module.exports = { + createEnv, + overrideArgv, + overrideCwd, + overrideEnv, + overrideStdoutWrite, +};