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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 15 additions & 10 deletions components/framework/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions src/render-help.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ module.exports = async () => {
output.writeText(colors.gray('or the shortcut:'));
output.writeText('serverless <service-name>:<command> <options>');
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'));
Expand Down
125 changes: 125 additions & 0 deletions test/unit/components/framework/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
69 changes: 69 additions & 0 deletions test/unit/src/components-service.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
117 changes: 117 additions & 0 deletions test/unit/src/index.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading