From 5370fbc44da8ff4e817cea2751d0f0db33b2c899 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Sun, 26 Apr 2026 14:25:55 +0100 Subject: [PATCH] test service-specific framework command passthrough Co-authored-by: Jordan Craw Co-authored-by: Jordan Ashley Craw <27454899+rageycomma@users.noreply.github.com> --- components/framework/index.js | 25 ++-- src/render-help.js | 4 + test/unit/components/framework/index.test.js | 125 +++++++++++++++++++ test/unit/src/components-service.test.js | 69 ++++++++++ test/unit/src/index.test.js | 117 +++++++++++++++++ test/unit/src/render-help.test.js | 27 ++++ test/unit/src/validate-options.test.js | 6 + 7 files changed, 363 insertions(+), 10 deletions(-) create mode 100644 test/unit/src/index.test.js create mode 100644 test/unit/src/render-help.test.js diff --git a/components/framework/index.js b/components/framework/index.js index bb2c71e..c0e46f1 100644 --- a/components/framework/index.js +++ b/components/framework/index.js @@ -15,6 +15,19 @@ const MINIMAL_FRAMEWORK_VERSION = '3.7.7'; const doesSatisfyRequiredFrameworkVersion = (version) => semver.gte(version, MINIMAL_FRAMEWORK_VERSION); +const formatCliParam = (key, value) => { + if (value === true) { + // Support flags like `--verbose` + return [`--${key}`]; + } + if (key.length === 1) { + // To handle shorthand notation like + // deploy -f function + return [`-${key}`, value]; + } + return [`--${key}=${value}`]; +}; + class ServerlessFramework { /** * @param {string} id @@ -51,16 +64,8 @@ class ServerlessFramework { const cliparams = Object.entries(options) .filter(([key]) => key !== 'stage') .flatMap(([key, value]) => { - if (value === true) { - // Support flags like `--verbose` - return `--${key}`; - } - if (key.length === 1) { - // To handle shorthand notation like - // deploy -f function - return [`-${key}`, value]; - } - return `--${key}=${value}`; + const values = Array.isArray(value) ? value : [value]; + return values.flatMap((singleValue) => formatCliParam(key, singleValue)); }); const args = [...command.split(':'), ...cliparams]; return await this.exec('serverless', args, true); diff --git a/src/render-help.js b/src/render-help.js index 7fb04e3..1365f41 100644 --- a/src/render-help.js +++ b/src/render-help.js @@ -52,6 +52,10 @@ module.exports = async () => { output.writeText(colors.gray('or the shortcut:')); output.writeText('serverless : '); output.writeText(); + output.writeText(colors.gray('Examples')); + output.writeText('serverless deploy function --service=service-a --function=handler'); + output.writeText('serverless service-a:invoke:local --function=handler'); + output.writeText(); output.writeText(colors.gray('Global options')); output.writeText(formatLine('--verbose', 'Enable verbose logs')); output.writeText(formatLine('--stage', 'Stage of the service')); diff --git a/test/unit/components/framework/index.test.js b/test/unit/components/framework/index.test.js index 7a8c4b0..8e695a9 100644 --- a/test/unit/components/framework/index.test.js +++ b/test/unit/components/framework/index.test.js @@ -374,6 +374,131 @@ describe('test/unit/components/framework/index.test.js', () => { expect(spawnStub.getCall(0).args[2].cwd).to.equal('custom-path'); }); + it('passes documented nested Framework commands through to Serverless CLI', async () => { + const cases = [ + { + command: 'deploy:function', + options: { function: 'handler' }, + expectedArgs: ['deploy', 'function', '--function=handler', '--stage', 'dev'], + }, + { + command: 'deploy:list', + options: { 'region': 'us-east-1', 'aws-profile': 'dev-profile' }, + expectedArgs: [ + 'deploy', + 'list', + '--region=us-east-1', + '--aws-profile=dev-profile', + '--stage', + 'dev', + ], + }, + { + command: 'deploy:list:functions', + options: {}, + expectedArgs: ['deploy', 'list', 'functions', '--stage', 'dev'], + }, + { + command: 'rollback:function', + options: { 'function': 'handler', 'function-version': '23' }, + expectedArgs: [ + 'rollback', + 'function', + '--function=handler', + '--function-version=23', + '--stage', + 'dev', + ], + }, + { + command: 'invoke', + options: { function: 'handler', data: '{"ok":true}', raw: true }, + expectedArgs: [ + 'invoke', + '--function=handler', + '--data={"ok":true}', + '--raw', + '--stage', + 'dev', + ], + }, + { + command: 'invoke:local', + options: { function: 'handler', path: 'event.json' }, + expectedArgs: [ + 'invoke', + 'local', + '--function=handler', + '--path=event.json', + '--stage', + 'dev', + ], + }, + ]; + + for (const testCase of cases) { + const spawnStub = sinon.stub().returns({ + on: (arg, cb) => { + if (arg === 'close') cb(0); + }, + kill: () => {}, + }); + const FrameworkComponent = proxyquire('../../../../components/framework/index.js', { + 'cross-spawn': spawnStub, + }); + + const context = await getContext(); + const component = new FrameworkComponent('some-id', context, { path: 'path' }); + context.state.detectedFrameworkVersion = '9.9.9'; + + await component.command(testCase.command, testCase.options); + + expect(spawnStub).to.be.calledOnce; + expect(spawnStub.getCall(0).args[0]).to.equal('serverless'); + expect(spawnStub.getCall(0).args[1]).to.deep.equal(testCase.expectedArgs); + expect(spawnStub.getCall(0).args[2].cwd).to.equal('path'); + expect(spawnStub.getCall(0).args[2].stdio).to.equal('inherit'); + } + }); + + it('preserves repeated passthrough options', async () => { + const spawnStub = sinon.stub().returns({ + on: (arg, cb) => { + if (arg === 'close') cb(0); + }, + kill: () => {}, + }); + + const FrameworkComponent = proxyquire('../../../../components/framework/index.js', { + 'cross-spawn': spawnStub, + }); + + const context = await getContext(); + const component = new FrameworkComponent('some-id', context, { path: 'path' }); + context.state.detectedFrameworkVersion = '9.9.9'; + + await component.command('invoke:local', { + function: 'handler', + env: ['VAR1=value1', 'VAR2=value2'], + e: ['SHORT1=value1', 'SHORT2=value2'], + }); + + expect(spawnStub).to.be.calledOnce; + expect(spawnStub.getCall(0).args[1]).to.deep.equal([ + 'invoke', + 'local', + '--function=handler', + '--env=VAR1=value1', + '--env=VAR2=value2', + '-e', + 'SHORT1=value1', + '-e', + 'SHORT2=value2', + '--stage', + 'dev', + ]); + }); + it('correctly ignores `stage` from options to not duplicate it when executing command', async () => { const spawnStub = sinon.stub().returns({ on: (arg, cb) => { diff --git a/test/unit/src/components-service.test.js b/test/unit/src/components-service.test.js index d42de89..743f038 100644 --- a/test/unit/src/components-service.test.js +++ b/test/unit/src/components-service.test.js @@ -837,6 +837,75 @@ describe('test/unit/src/components-service.test.js', () => { ).to.eventually.be.rejected.and.have.property('code', 'COMPONENT_COMMAND_NOT_FOUND'); }); + it('passes nested commands through for Serverless Framework services', async () => { + const command = sinon.stub().resolves(); + + class FakeServerlessFramework { + async command(commandName, options) { + return command(commandName, options); + } + } + + const loadComponent = sinon.stub().resolves(new FakeServerlessFramework()); + const ComponentsServiceWithStubbedLoad = proxyquire('../../../src/ComponentsService', { + './load': { loadComponent }, + '../components/framework': FakeServerlessFramework, + }); + const context = new Context({ + root: process.cwd(), + stage: 'dev', + disableIO: true, + configuration: {}, + }); + await context.init(); + + const localComponentsService = new ComponentsServiceWithStubbedLoad( + context, + { + services: { + api: { + path: 'api', + }, + }, + }, + {} + ); + await localComponentsService.init(); + + const cases = [ + { + command: 'deploy:function', + options: { function: 'handler' }, + }, + { + command: 'deploy:list:functions', + options: {}, + }, + { + command: 'rollback:function', + options: { 'function': 'handler', 'function-version': '23' }, + }, + { + command: 'invoke:local', + options: { function: 'handler' }, + }, + ]; + + for (const testCase of cases) { + await localComponentsService.invokeComponentCommand( + 'api', + testCase.command, + testCase.options + ); + } + + expect(command).to.have.callCount(cases.length); + cases.forEach((testCase, index) => { + expect(command.getCall(index).args).to.deep.equal([testCase.command, testCase.options]); + }); + expect(context.componentCommandsOutcomes.api).to.equal('success'); + }); + it('supports default commands implemented on component prototypes', async () => { const deploy = sinon.stub().resolves(); diff --git a/test/unit/src/index.test.js b/test/unit/src/index.test.js new file mode 100644 index 0000000..50fa5dc --- /dev/null +++ b/test/unit/src/index.test.js @@ -0,0 +1,117 @@ +'use strict'; + +const chai = require('chai'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); + +const expect = chai.expect; + +describe('test/unit/src/index.test.js', () => { + afterEach(() => { + sinon.restore(); + }); + + const loadRunComponents = (componentsServiceInstances, validateOptions = sinon.stub()) => { + class FakeContext { + constructor(config) { + this.root = config.root; + this.stage = config.stage; + this.output = { + log: sinon.stub(), + }; + this.componentCommandsOutcomes = {}; + } + + async init() { + return undefined; + } + + shutdown() { + return undefined; + } + } + + const ComponentsService = sinon.stub(); + componentsServiceInstances.forEach((instance, index) => { + ComponentsService.onCall(index).returns(instance); + }); + + delete require.cache[require.resolve('../../../src/index.js')]; + const { runComponents } = proxyquire('../../../src', { + './render-help': sinon.stub().resolves(), + './Context': FakeContext, + './ComponentsService': ComponentsService, + './handle-error': sinon.stub(), + './configuration/resolve-variables': sinon.stub().resolves(), + './configuration/resolve-path': sinon.stub().resolves('serverless-compose.yml'), + './configuration/read': sinon.stub().resolves({ + services: { + api: { + path: 'api', + }, + }, + }), + './configuration/validate': { + validateConfiguration: sinon.stub(), + }, + './validate-options': validateOptions, + './utils/serverless-utils/log-reporters/node': sinon.stub(), + }); + + return { runComponents, validateOptions }; + }; + + it('preserves nested shortcut service commands', async () => { + const componentsServiceInstance = { + init: sinon.stub().resolves(), + invokeComponentCommand: sinon.stub().resolves(), + invokeGlobalCommand: sinon.stub().resolves(), + allComponents: {}, + }; + const { runComponents, validateOptions } = loadRunComponents([componentsServiceInstance]); + const processExit = sinon.stub(process, 'exit'); + sinon.stub(process, 'getMaxListeners').returns(10); + sinon.stub(process, 'setMaxListeners'); + + await runComponents(['api:deploy:function', '--function', 'handler']); + + expect(validateOptions).to.have.been.calledOnceWithExactly( + sinon.match({ function: 'handler' }), + 'deploy:function' + ); + expect(componentsServiceInstance.invokeComponentCommand).to.have.been.calledOnceWithExactly( + 'api', + 'deploy:function', + sinon.match({ function: 'handler' }) + ); + expect(componentsServiceInstance.invokeGlobalCommand.called).to.equal(false); + expect(processExit).to.have.been.calledOnceWithExactly(0); + }); + + it('preserves nested commands when using --service', async () => { + const componentsServiceInstance = { + init: sinon.stub().resolves(), + invokeComponentCommand: sinon.stub().resolves(), + invokeGlobalCommand: sinon.stub().resolves(), + allComponents: {}, + }; + const { runComponents, validateOptions } = loadRunComponents([componentsServiceInstance]); + const processExit = sinon.stub(process, 'exit'); + sinon.stub(process, 'getMaxListeners').returns(10); + sinon.stub(process, 'setMaxListeners'); + + await runComponents(['invoke', 'local', '--service', 'api', '--function', 'handler']); + + expect(validateOptions).to.have.been.calledOnceWithExactly( + sinon.match({ function: 'handler' }), + 'invoke:local' + ); + expect(componentsServiceInstance.invokeComponentCommand).to.have.been.calledOnceWithExactly( + 'api', + 'invoke:local', + sinon.match({ function: 'handler' }) + ); + expect(componentsServiceInstance.invokeGlobalCommand.called).to.equal(false); + expect(processExit).to.have.been.calledOnceWithExactly(0); + }); +}); diff --git a/test/unit/src/render-help.test.js b/test/unit/src/render-help.test.js new file mode 100644 index 0000000..fd43892 --- /dev/null +++ b/test/unit/src/render-help.test.js @@ -0,0 +1,27 @@ +'use strict'; + +const chai = require('chai'); +const proxyquire = require('proxyquire'); + +const expect = chai.expect; + +describe('test/unit/src/render-help.test.js', () => { + it('shows nested service-specific command examples', async () => { + const lines = []; + + class FakeOutput { + writeText(message = '') { + lines.push(message); + } + } + + const renderHelp = proxyquire('../../../src/render-help', { + './cli/Output': FakeOutput, + }); + + await renderHelp(); + + expect(lines).to.include('serverless deploy function --service=service-a --function=handler'); + expect(lines).to.include('serverless service-a:invoke:local --function=handler'); + }); +}); diff --git a/test/unit/src/validate-options.test.js b/test/unit/src/validate-options.test.js index 30ad6c8..96c7d27 100644 --- a/test/unit/src/validate-options.test.js +++ b/test/unit/src/validate-options.test.js @@ -19,4 +19,10 @@ describe('test/unit/src/validate-options.test.js', () => { it('accepts custom options for non-native Compose commands', () => { validateOptions({ package: '../something' }, 'invoke'); }); + + it('accepts Framework options for nested passthrough commands', () => { + validateOptions({ function: 'handler' }, 'deploy:function'); + validateOptions({ function: 'handler' }, 'invoke:local'); + validateOptions({ 'function': 'handler', 'function-version': '23' }, 'rollback:function'); + }); });