feat(CLI Onboarding): Add telemetry for interactive flow

This commit is contained in:
Piotr Grzesik 2021-06-11 12:25:02 +02:00
parent f5c1c47fa2
commit 0eba2dcdfe
10 changed files with 508 additions and 197 deletions

View File

@ -77,8 +77,8 @@ const getProviders = memoizee(
}
);
const awsAccessKeyIdInput = async () =>
(
const awsAccessKeyIdInput = async ({ stepHistory }) => {
const accessKeyId = (
await inquirer.prompt({
message: 'AWS Access Key Id:',
type: 'input',
@ -89,9 +89,12 @@ const awsAccessKeyIdInput = async () =>
},
})
).accessKeyId.trim();
stepHistory.set('accessKeyId', '_user_provided_');
return accessKeyId;
};
const awsSecretAccessKeyInput = async () =>
(
const awsSecretAccessKeyInput = async ({ stepHistory }) => {
const secretAccessKey = (
await inquirer.prompt({
message: 'AWS Secret Access Key:',
type: 'input',
@ -105,8 +108,11 @@ const awsSecretAccessKeyInput = async () =>
},
})
).secretAccessKey.trim();
stepHistory.set('secretAccessKey', '_user_provided_');
return secretAccessKey;
};
const credentialsSetupChoice = async (providers) => {
const credentialsSetupChoice = async (context, providers) => {
let credentialsSetupChoices = [];
let message = 'No AWS credentials found, what credentials do you want to use?';
@ -142,7 +148,7 @@ const credentialsSetupChoice = async (providers) => {
{ name: 'Skip', value: CREDENTIALS_SETUP_CHOICE.SKIP }
);
return (
const result = (
await inquirer.prompt({
message,
type: 'list',
@ -150,6 +156,12 @@ const credentialsSetupChoice = async (providers) => {
choices: credentialsSetupChoices,
})
).credentialsSetupChoice;
context.stepHistory.set(
'credentialsSetupChoice',
result.startsWith('_') ? result : '_user_provided_'
);
return result;
};
const steps = {
@ -158,15 +170,16 @@ const steps = {
http://slss.io/aws-creds-setup\n`),
ensureAwsAccount: async () => {
ensureAwsAccount: async ({ stepHistory }) => {
if (await confirm('Do you have an AWS account?', { name: 'hasAwsAccount' })) return;
openBrowser('https://portal.aws.amazon.com/billing/signup');
await inquirer.prompt({
message: 'Press Enter to continue after creating an AWS account',
name: 'createAwsAccountPrompt',
});
stepHistory.set('createAwsAccountPrompt', true);
},
ensureAwsCredentials: async ({ options, configuration }) => {
ensureAwsCredentials: async ({ options, configuration, stepHistory }) => {
const region = options.region || configuration.provider.region || 'us-east-1';
openBrowser(
`https://console.aws.amazon.com/iam/home?region=${region}#/users$new?step=final&accessKey&userNames=serverless&permissionType=policies&policies=arn:aws:iam::aws:policy%2FAdministratorAccess`
@ -175,10 +188,11 @@ const steps = {
message: 'Press Enter to continue after creating an AWS user with access keys',
name: 'generateAwsCredsPrompt',
});
stepHistory.set('generateAwsCredsPrompt', true);
},
inputAwsCredentials: async () => {
const accessKeyId = await awsAccessKeyIdInput();
const secretAccessKey = await awsSecretAccessKeyInput();
inputAwsCredentials: async (context) => {
const accessKeyId = await awsAccessKeyIdInput(context);
const secretAccessKey = await awsSecretAccessKeyInput(context);
await awsCredentials.saveFileProfiles(new Map([['default', { accessKeyId, secretAccessKey }]]));
process.stdout.write(
`\n${chalk.green(
@ -188,7 +202,7 @@ const steps = {
)}\n`
);
},
handleProviderCreation: async ({ configuration: { org: orgName } }) => {
handleProviderCreation: async ({ configuration: { org: orgName }, stepHistory }) => {
const providersUrl = `https://app.serverless.com/${orgName}/settings/providers?source=cli&providerId=new&provider=aws`;
openBrowser(chalk.bold.white(providersUrl));
process.stdout.write(
@ -210,7 +224,10 @@ const steps = {
name: 'skipProviderSetup',
});
inquirerPrompt.then(() => resolve(null));
inquirerPrompt.then(() => {
stepHistory.set('skipProviderSetup', true);
resolve(null);
});
}, timeoutDuration);
onEvent = (event) => {
@ -307,10 +324,17 @@ module.exports = {
_.get(configuration, 'provider') !== 'aws' &&
_.get(configuration, 'provider.name') !== 'aws'
) {
context.inapplicabilityReasonCode = 'NON_AWS_PROVIDER';
return false;
}
if (new AWS.S3().config.credentials) {
context.inapplicabilityReasonCode = 'LOCAL_CREDENTIALS_CONFIGURED';
return false;
}
if ((await awsCredentials.resolveFileProfiles()).size) {
context.inapplicabilityReasonCode = 'LOCAL_CREDENTIAL_PROFILES_CONFIGURED';
return false;
}
if (new AWS.S3().config.credentials) return false;
if ((await awsCredentials.resolveFileProfiles()).size) return false;
const orgName = configuration.org;
if (orgName && isAuthenticated()) {
@ -326,7 +350,10 @@ module.exports = {
}
const hasDefaultProvider = providers.some((provider) => provider.isDefault);
if (hasDefaultProvider) return false;
if (hasDefaultProvider) {
context.inapplicabilityReasonCode = 'DEFAULT_PROVIDER_CONFIGURED';
return false;
}
// For situation where it is invoked for already existing service
// We need to check if service already has a linked provider
@ -335,6 +362,7 @@ module.exports = {
!history.has('service') &&
(await doesServiceInstanceHaveLinkedProvider({ configuration, options }))
) {
context.inapplicabilityReasonCode = 'LINKED_PROVIDER_CONFIGURED';
return false;
}
}
@ -357,7 +385,7 @@ module.exports = {
throw err;
}
}
const credentialsSetupChoiceAnswer = await credentialsSetupChoice(providers);
const credentialsSetupChoiceAnswer = await credentialsSetupChoice(context, providers);
if (credentialsSetupChoiceAnswer === CREDENTIALS_SETUP_CHOICE.CREATE_PROVIDER) {
try {
@ -386,9 +414,9 @@ module.exports = {
steps.writeOnSetupSkip();
return;
} else if (credentialsSetupChoiceAnswer === CREDENTIALS_SETUP_CHOICE.LOCAL) {
await steps.ensureAwsAccount();
await steps.ensureAwsAccount(context);
await steps.ensureAwsCredentials(context);
await steps.inputAwsCredentials();
await steps.inputAwsCredentials(context);
return;
}
@ -406,4 +434,12 @@ module.exports = {
}
},
steps,
configuredQuestions: [
'credentialsSetupChoice',
'createAwsAccountPrompt',
'generateAwsCredsPrompt',
'accessKeyId',
'secretAccessKey',
'skipProviderSetup',
],
};

View File

@ -100,8 +100,10 @@ const configurePlugin = (serverless, originalStdWrite) => {
};
module.exports = {
async isApplicable({ configuration, serviceDir, history, options }) {
async isApplicable(context) {
const { configuration, serviceDir, history, options } = context;
if (!serviceDir) {
context.inapplicabilityReasonCode = 'NOT_IN_SERVICE_DIRECTORY';
return false;
}
@ -109,6 +111,7 @@ module.exports = {
_.get(configuration, 'provider') !== 'aws' &&
_.get(configuration, 'provider.name') !== 'aws'
) {
context.inapplicabilityReasonCode = 'NON_AWS_PROVIDER';
return false;
}
@ -127,11 +130,17 @@ module.exports = {
// We want to proceed if local credentials are available
if (new AWS.Config().credentials) return true;
context.inapplicabilityReasonCode = 'NO_CREDENTIALS_CONFIGURED';
return false;
},
async run({ configuration, configurationFilename, serviceDir }) {
async run({ configuration, configurationFilename, serviceDir, stepHistory }) {
const serviceName = configuration.service;
if (!(await confirm('Do you want to deploy your project?', { name: 'shouldDeploy' }))) {
const shouldDeploy = await confirm('Do you want to deploy your project?', {
name: 'shouldDeploy',
});
stepHistory.set('shouldDeploy', shouldDeploy);
if (!shouldDeploy) {
printMessage({
serviceName,
hasBeenDeployed: false,
@ -174,4 +183,5 @@ module.exports = {
dashboardPlugin: serverless.pluginManager.dashboardPlugin,
});
},
configuredQuestions: ['shouldDeploy'],
};

View File

@ -1,6 +1,7 @@
'use strict';
const inquirer = require('@serverless/utils/inquirer');
const { StepHistory } = require('@serverless/utils/telemetry');
const steps = {
service: require('./service'),
@ -12,14 +13,32 @@ const steps = {
module.exports = async (context) => {
context = { ...context, inquirer, history: new Map() };
const stepsDetails = new Map();
for (const [stepName, step] of Object.entries(steps)) {
delete context.stepHistory;
delete context.inapplicabilityReasonCode;
const stepData = await step.isApplicable(context);
stepsDetails.set(stepName, {
isApplicable: Boolean(stepData),
inapplicabilityReasonCode: context.inapplicabilityReasonCode,
timestamp: Date.now(),
configuredQuestions: step.configuredQuestions,
});
if (stepData) {
process.stdout.write('\n');
context.stepHistory = [];
context.stepHistory = new StepHistory();
context.history.set(stepName, context.stepHistory);
await step.run(context, stepData);
}
}
const commandUsage = Array.from(stepsDetails.entries()).map(([step, stepDetails]) => {
const stepHistory = context.history.get(step);
return {
name: step,
...stepDetails,
history: stepHistory ? stepHistory.toJSON() : [],
};
});
return { commandUsage, configuration: context.configuration };
};

View File

@ -32,8 +32,8 @@ const initializeProjectChoices = [
{ name: 'Other', value: 'other' },
];
const projectTypeChoice = async () =>
(
const projectTypeChoice = async (stepHistory) => {
const projectType = (
await inquirer.prompt({
message: 'What do you want to make?',
type: 'list',
@ -43,14 +43,18 @@ const projectTypeChoice = async () =>
})
).projectType;
stepHistory.set('projectType', projectType);
return projectType;
};
const INVALID_PROJECT_NAME_MESSAGE =
'Project name is not valid.\n' +
' - It should only contain alphanumeric and hyphens.\n' +
' - It should start with an alphabetic character.\n' +
" - Shouldn't exceed 128 characters";
const projectNameInput = async (workingDir, projectType) =>
(
const projectNameInput = async (workingDir, projectType, stepHistory) => {
const projectName = (
await inquirer.prompt({
message: 'What do you want to call this project?',
type: 'input',
@ -72,7 +76,11 @@ const projectNameInput = async (workingDir, projectType) =>
})
).projectName.trim();
const resolveProjectNameInput = async (options, workingDir, projectType = null) => {
stepHistory.set('projectName', '_user_provided_');
return projectName;
};
const resolveProjectNameInput = async ({ options, workingDir, projectType, stepHistory }) => {
if (options.name) {
if (!isValidServiceName(options.name)) {
throw new ServerlessError(INVALID_PROJECT_NAME_MESSAGE, 'INVALID_PROJECT_NAME');
@ -96,11 +104,12 @@ const resolveProjectNameInput = async (options, workingDir, projectType = null)
return options.name;
}
return projectNameInput(workingDir, projectType);
return projectNameInput(workingDir, projectType, stepHistory);
};
module.exports = {
isApplicable({ options, serviceDir }) {
isApplicable(context) {
const { options, serviceDir } = context;
const notApplicableOptions = new Set(['name', 'template-path', 'template', 'template-url']);
if (serviceDir && Object.keys(options).some((key) => notApplicableOptions.has(key))) {
throw new ServerlessError(
@ -113,7 +122,11 @@ module.exports = {
);
}
return !serviceDir;
const inServiceDir = Boolean(serviceDir);
if (inServiceDir) {
context.inapplicabilityReasonCode = 'IN_SERVICE_DIRECTORY';
}
return !inServiceDir;
},
async run(context) {
const workingDir = context.cwd || process.cwd();
@ -132,7 +145,11 @@ module.exports = {
let projectDir;
let projectName;
if (context.options['template-path']) {
projectName = await resolveProjectNameInput(context.options, workingDir);
projectName = await resolveProjectNameInput({
options: context.options,
workingDir,
stepHistory: context.stepHistory,
});
projectDir = join(workingDir, projectName);
await createFromLocalTemplate({
templatePath: context.options['template-path'],
@ -140,7 +157,11 @@ module.exports = {
projectName,
});
} else if (context.options['template-url']) {
projectName = await resolveProjectNameInput(context.options, workingDir);
projectName = await resolveProjectNameInput({
options: context.options,
workingDir,
stepHistory: context.stepHistory,
});
projectDir = join(workingDir, projectName);
const templateUrl = context.options['template-url'];
process.stdout.write(`\nDownloading template from provided url: ${templateUrl}...\n`);
@ -159,7 +180,7 @@ module.exports = {
if (context.options.template) {
projectType = context.options.template;
} else {
projectType = await projectTypeChoice();
projectType = await projectTypeChoice(context.stepHistory);
if (projectType === 'other') {
process.stdout.write(
'\nRun “serverless create --help” to view available templates and create a new project ' +
@ -168,7 +189,12 @@ module.exports = {
return;
}
}
projectName = await resolveProjectNameInput(context.options, workingDir, projectType);
projectName = await resolveProjectNameInput({
options: context.options,
workingDir,
projectType,
stepHistory: context.stepHistory,
});
projectDir = join(workingDir, projectName);
const templateUrl = `https://github.com/serverless/examples/tree/master/${projectType}`;
process.stdout.write(`\nDownloading "${projectType}" template...\n`);
@ -238,4 +264,5 @@ module.exports = {
context.configuration = await readConfiguration(configurationPath);
await resolveVariables(context);
},
configuredQuestions: ['projectType', 'projectName'],
};

View File

@ -123,6 +123,7 @@ module.exports = async ({
serviceDir,
configuration,
serverless,
commandUsage,
}) => {
let commandDurationMs;
@ -268,5 +269,9 @@ module.exports = async ({
payload.dashboard.orgUid = serverless && serverless.service.orgUid;
}
if (commandUsage) {
payload.commandUsage = commandUsage;
}
return payload;
};

View File

@ -403,12 +403,13 @@ const processSpanPromise = (async () => {
'INTERACTIVE_SETUP_IN_NON_TTY'
);
}
await require('../lib/cli/interactive-setup')({
configuration,
serviceDir,
configurationFilename,
options,
});
const { commandUsage, configuration: configurationFromInteractive } =
await require('../lib/cli/interactive-setup')({
configuration,
serviceDir,
configurationFilename,
options,
});
hasTelemetryBeenReported = true;
if (!isTelemetryDisabled) {
await storeTelemetryLocally(
@ -417,7 +418,8 @@ const processSpanPromise = (async () => {
options,
commandSchema,
serviceDir,
configuration,
configuration: configurationFromInteractive,
commandUsage,
})
);
await sendTelemetry({ serverlessExecutionSpan: processSpanPromise });

View File

@ -7,6 +7,7 @@ const overrideEnv = require('process-utils/override-env');
const overrideStdoutWrite = require('process-utils/override-stdout-write');
const requireUncached = require('ncjsm/require-uncached');
const chalk = require('chalk');
const { StepHistory } = require('@serverless/utils/telemetry');
const { expect } = chai;
@ -53,17 +54,21 @@ describe('test/unit/lib/cli/interactive-setup/aws-credentials.test.js', () => {
sinon.restore();
});
it('Should be ineffective, when not at service path', async () =>
expect(await step.isApplicable({})).to.equal(false));
it('Should be ineffective, when not at service path', async () => {
const context = {};
expect(await step.isApplicable(context)).to.equal(false);
expect(context.inapplicabilityReasonCode).to.equal('NON_AWS_PROVIDER');
});
it('Should be ineffective, when not at AWS service', async () =>
expect(
await step.isApplicable({
serviceDir: process.cwd(),
configuration: {},
configurationFilename: 'serverless.yml',
})
).to.equal(false));
it('Should be ineffective, when not at AWS service', async () => {
const context = {
serviceDir: process.cwd(),
configuration: {},
configurationFilename: 'serverless.yml',
};
expect(await step.isApplicable(context)).to.equal(false);
expect(context.inapplicabilityReasonCode).to.equal('NON_AWS_PROVIDER');
});
it('Should be ineffective, when user has default provider set', async () => {
const internalMockedSdk = {
@ -92,13 +97,13 @@ describe('test/unit/lib/cli/interactive-setup/aws-credentials.test.js', () => {
'@serverless/dashboard-plugin/lib/isAuthenticated': () => true,
});
expect(
await mockedStep.isApplicable({
serviceDir: process.cwd(),
configuration: { provider: { name: 'aws' }, org: 'someorg' },
configurationFilename: 'serverless.yml',
})
).to.be.false;
const context = {
serviceDir: process.cwd(),
configuration: { provider: { name: 'aws' }, org: 'someorg' },
configurationFilename: 'serverless.yml',
};
expect(await mockedStep.isApplicable(context)).to.be.false;
expect(context.inapplicabilityReasonCode).to.equal('DEFAULT_PROVIDER_CONFIGURED');
});
it('Should be ineffective, when existing service already has a provider set', async () => {
@ -131,20 +136,20 @@ describe('test/unit/lib/cli/interactive-setup/aws-credentials.test.js', () => {
},
});
expect(
await mockedStep.isApplicable({
history: new Set(),
serviceDir: process.cwd(),
configuration: {
provider: { name: 'aws' },
org: 'someorg',
app: 'someapp',
service: 'service',
},
options: {},
configurationFilename: 'serverless.yml',
})
).to.be.false;
const context = {
history: new Set(),
serviceDir: process.cwd(),
configuration: {
provider: { name: 'aws' },
org: 'someorg',
app: 'someapp',
service: 'service',
},
options: {},
configurationFilename: 'serverless.yml',
};
expect(await mockedStep.isApplicable(context)).to.be.false;
expect(context.inapplicabilityReasonCode).to.equal('LINKED_PROVIDER_CONFIGURED');
});
it('Should be effective, when existing service instance does not have a provider set', async () => {
@ -239,18 +244,23 @@ describe('test/unit/lib/cli/interactive-setup/aws-credentials.test.js', () => {
list: { credentialsSetupChoice: '_skip_' },
});
const context = {
serviceDir: process.cwd(),
configuration: { provider: { name: 'aws' } },
configurationFilename: 'serverless.yml',
stepHistory: new StepHistory(),
options: {},
};
let stdoutData = '';
await overrideStdoutWrite(
(data) => (stdoutData += data),
async () =>
await step.run({
serviceDir: process.cwd(),
configuration: { provider: { name: 'aws' }, org: 'someorg' },
configurationFilename: 'serverless.yml',
})
async () => await step.run(context)
);
expect(stdoutData).to.include('You can setup your AWS account later');
expect(context.stepHistory.valuesMap()).to.deep.equal(
new Map([['credentialsSetupChoice', '_skip_']])
);
});
describe('In environment credentials', () => {
@ -323,13 +333,27 @@ describe('test/unit/lib/cli/interactive-setup/aws-credentials.test.js', () => {
secretAccessKey,
},
});
await step.run({ configuration: { provider: {} }, options: {} });
const context = {
configuration: { provider: {} },
options: {},
stepHistory: new StepHistory(),
};
await step.run(context);
expect(openBrowserUrls.length).to.equal(2);
expect(openBrowserUrls[0].includes('signup')).to.be.true;
expect(openBrowserUrls[1].includes('console.aws.amazon.com')).to.be.true;
resolveFileProfiles().then((profiles) => {
expect(profiles).to.deep.equal(new Map([['default', { accessKeyId, secretAccessKey }]]));
});
expect(context.stepHistory.valuesMap()).to.deep.equal(
new Map([
['credentialsSetupChoice', '_local_'],
['createAwsAccountPrompt', true],
['generateAwsCredsPrompt', true],
['accessKeyId', '_user_provided_'],
['secretAccessKey', '_user_provided_'],
])
);
});
it('Should setup credentials for users having an AWS account', async () => {
@ -338,9 +362,22 @@ describe('test/unit/lib/cli/interactive-setup/aws-credentials.test.js', () => {
confirm: { hasAwsAccount: true },
input: { generateAwsCredsPrompt: '', accessKeyId, secretAccessKey },
});
await step.run({ configuration: { provider: {} }, options: {} });
const context = {
configuration: { provider: {} },
options: {},
stepHistory: new StepHistory(),
};
await step.run(context);
expect(openBrowserUrls.length).to.equal(1);
expect(openBrowserUrls[0].includes('console.aws.amazon.com')).to.be.true;
expect(context.stepHistory.valuesMap()).to.deep.equal(
new Map([
['credentialsSetupChoice', '_local_'],
['generateAwsCredsPrompt', true],
['accessKeyId', '_user_provided_'],
['secretAccessKey', '_user_provided_'],
])
);
return resolveFileProfiles().then((profiles) => {
expect(profiles).to.deep.equal(new Map([['default', { accessKeyId, secretAccessKey }]]));
});
@ -352,12 +389,21 @@ describe('test/unit/lib/cli/interactive-setup/aws-credentials.test.js', () => {
confirm: { hasAwsAccount: true },
input: { generateAwsCredsPrompt: '', accessKeyId: 'foo', secretAccessKey },
});
await expect(
step.run({
configuration: { provider: {} },
options: {},
})
).to.eventually.be.rejected.and.have.property('code', 'INVALID_ANSWER');
const context = {
configuration: { provider: {} },
options: {},
stepHistory: new StepHistory(),
};
await expect(step.run(context)).to.eventually.be.rejected.and.have.property(
'code',
'INVALID_ANSWER'
);
expect(context.stepHistory.valuesMap()).to.deep.equal(
new Map([
['credentialsSetupChoice', '_local_'],
['generateAwsCredsPrompt', true],
])
);
});
it('Should not accept invalid secret access key', async () => {
@ -366,12 +412,22 @@ describe('test/unit/lib/cli/interactive-setup/aws-credentials.test.js', () => {
confirm: { hasAwsAccount: true },
input: { generateAwsCredsPrompt: '', accessKeyId, secretAccessKey: 'foo' },
});
await expect(
step.run({
configuration: { provider: {} },
options: {},
})
).to.eventually.be.rejected.and.have.property('code', 'INVALID_ANSWER');
const context = {
configuration: { provider: {} },
options: {},
stepHistory: new StepHistory(),
};
await expect(step.run(context)).to.eventually.be.rejected.and.have.property(
'code',
'INVALID_ANSWER'
);
expect(context.stepHistory.valuesMap()).to.deep.equal(
new Map([
['credentialsSetupChoice', '_local_'],
['generateAwsCredsPrompt', true],
['accessKeyId', '_user_provided_'],
])
);
});
});
@ -418,6 +474,7 @@ describe('test/unit/lib/cli/interactive-setup/aws-credentials.test.js', () => {
},
options: {},
configurationFilename: 'serverless.yml',
stepHistory: new StepHistory(),
};
await overrideStdoutWrite(
(data) => (stdoutData += data),
@ -432,6 +489,9 @@ describe('test/unit/lib/cli/interactive-setup/aws-credentials.test.js', () => {
);
expect(mockedDisconnect).to.have.been.called;
expect(mockedCreateProviderLink).not.to.have.been.called;
expect(context.stepHistory.valuesMap()).to.deep.equal(
new Map([['credentialsSetupChoice', '_create_provider_']])
);
});
it('Should correctly setup with newly created provider when previous providers exist', async () => {
@ -490,6 +550,7 @@ describe('test/unit/lib/cli/interactive-setup/aws-credentials.test.js', () => {
},
options: {},
configurationFilename: 'serverless.yml',
stepHistory: new StepHistory(),
};
let stdoutData = '';
await overrideStdoutWrite(
@ -510,6 +571,9 @@ describe('test/unit/lib/cli/interactive-setup/aws-credentials.test.js', () => {
'appName|someapp|serviceName|someservice|stage|dev|region|us-east-1',
providerUid
);
expect(context.stepHistory.valuesMap()).to.deep.equal(
new Map([['credentialsSetupChoice', '_create_provider_']])
);
});
it('Should emit warning when dashboard unavailable when connecting to it', async () => {
@ -533,20 +597,21 @@ describe('test/unit/lib/cli/interactive-setup/aws-credentials.test.js', () => {
list: { credentialsSetupChoice: '_create_provider_' },
});
const context = {
serviceDir: process.cwd(),
configuration: {
service: 'someservice',
provider: { name: 'aws' },
org: 'someorg',
app: 'someapp',
},
configurationFilename: 'serverless.yml',
stepHistory: new StepHistory(),
};
let stdoutData = '';
await overrideStdoutWrite(
(data) => (stdoutData += data),
async () =>
await mockedStep.run({
serviceDir: process.cwd(),
configuration: {
service: 'someservice',
provider: { name: 'aws' },
org: 'someorg',
app: 'someapp',
},
configurationFilename: 'serverless.yml',
})
async () => await mockedStep.run(context)
);
expect(stdoutData).to.include('Dashboard service is currently unavailable');
@ -555,6 +620,9 @@ describe('test/unit/lib/cli/interactive-setup/aws-credentials.test.js', () => {
'https://app.serverless.com/someorg/settings/providers?source=cli&providerId=new&provider=aws'
)
);
expect(context.stepHistory.valuesMap()).to.deep.equal(
new Map([['credentialsSetupChoice', '_create_provider_']])
);
});
it('Should correctly setup with existing provider', async () => {
@ -599,6 +667,7 @@ describe('test/unit/lib/cli/interactive-setup/aws-credentials.test.js', () => {
},
options: {},
configurationFilename: 'serverless.yml',
stepHistory: new StepHistory(),
};
let stdoutData = '';
await overrideStdoutWrite(
@ -613,6 +682,9 @@ describe('test/unit/lib/cli/interactive-setup/aws-credentials.test.js', () => {
'provideruid'
);
expect(stdoutData).to.include('Selected provider was successfully linked');
expect(context.stepHistory.valuesMap()).to.deep.equal(
new Map([['credentialsSetupChoice', '_user_provided_']])
);
});
it('Should emit a warning when dashboard is not available and link cannot be created', async () => {
@ -651,21 +723,22 @@ describe('test/unit/lib/cli/interactive-setup/aws-credentials.test.js', () => {
list: { credentialsSetupChoice: providerUid },
});
const context = {
serviceDir: process.cwd(),
configuration: {
service: 'someservice',
provider: { name: 'aws' },
org: 'someorg',
app: 'someapp',
},
options: {},
stepHistory: new StepHistory(),
configurationFilename: 'serverless.yml',
};
let stdoutData = '';
await overrideStdoutWrite(
(data) => (stdoutData += data),
async () =>
await mockedStep.run({
serviceDir: process.cwd(),
configuration: {
service: 'someservice',
provider: { name: 'aws' },
org: 'someorg',
app: 'someapp',
},
options: {},
configurationFilename: 'serverless.yml',
})
async () => await mockedStep.run(context)
);
expect(stdoutData).to.include(
@ -678,6 +751,10 @@ describe('test/unit/lib/cli/interactive-setup/aws-credentials.test.js', () => {
'appName|someapp|serviceName|someservice|stage|dev|region|us-east-1',
'provideruid'
);
expect(context.stepHistory.valuesMap()).to.deep.equal(
new Map([['credentialsSetupChoice', '_user_provided_']])
);
});
it('Should emit a warning when dashboard is not available when fetching providers', async () => {

View File

@ -8,6 +8,7 @@ const overrideEnv = require('process-utils/override-env');
const step = require('../../../../../lib/cli/interactive-setup/deploy');
const proxyquire = require('proxyquire');
const overrideStdoutWrite = require('process-utils/override-stdout-write');
const { StepHistory } = require('@serverless/utils/telemetry');
const { expect } = chai;
@ -17,18 +18,24 @@ chai.use(require('sinon-chai'));
const inquirer = require('@serverless/utils/inquirer');
describe('test/unit/lib/cli/interactive-setup/deploy.test.js', () => {
it('Should be not applied, when not at service path', async () =>
expect(await step.isApplicable({ options: {} })).to.equal(false));
it('Should be not applied, when not at service path', async () => {
const context = {
options: {},
};
expect(await step.isApplicable(context)).to.equal(false);
expect(context.inapplicabilityReasonCode).to.equal('NOT_IN_SERVICE_DIRECTORY');
});
it('Should be not applied, when service is not configured with AWS provider', async () =>
expect(
await step.isApplicable({
configuration: { provider: { name: 'notaws' } },
serviceDir: '/foo',
options: {},
history: new Map(),
})
).to.equal(false));
it('Should be not applied, when service is not configured with AWS provider', async () => {
const context = {
configuration: { provider: { name: 'notaws' } },
serviceDir: '/foo',
options: {},
history: new Map([['service', []]]),
};
expect(await step.isApplicable(context)).to.equal(false);
expect(context.inapplicabilityReasonCode).to.equal('NON_AWS_PROVIDER');
});
it('Should be applied, if awsCredentials step was not executed which means user already had credentials', async () =>
expect(
@ -80,22 +87,24 @@ describe('test/unit/lib/cli/interactive-setup/deploy.test.js', () => {
confirm: { shouldDeploy: false },
});
const context = {
serviceDir: process.cwd(),
configuration: {
service: 'someservice',
provider: { name: 'aws' },
},
configurationFilename: 'serverless.yml',
stepHistory: new StepHistory(),
};
let stdoutData = '';
await overrideStdoutWrite(
(data) => (stdoutData += data),
async () =>
await step.run({
serviceDir: process.cwd(),
configuration: {
service: 'someservice',
provider: { name: 'aws' },
},
configurationFilename: 'serverless.yml',
})
async () => await step.run(context)
);
expect(stdoutData).to.include('Your project is ready for deployment');
expect(stdoutData).to.include(`Run ${chalk.bold('serverless')} in the project directory`);
expect(context.stepHistory.valuesMap()).to.deep.equal(new Map([['shouldDeploy', false]]));
});
it('should correctly handle skipping deployment for service not configured with dashboard', async () => {
@ -103,24 +112,26 @@ describe('test/unit/lib/cli/interactive-setup/deploy.test.js', () => {
confirm: { shouldDeploy: false },
});
const context = {
serviceDir: process.cwd(),
configuration: {
service: 'someservice',
provider: { name: 'aws' },
org: 'someorg',
app: 'someapp',
},
configurationFilename: 'serverless.yml',
stepHistory: new StepHistory(),
};
let stdoutData = '';
await overrideStdoutWrite(
(data) => (stdoutData += data),
async () =>
await step.run({
serviceDir: process.cwd(),
configuration: {
service: 'someservice',
provider: { name: 'aws' },
org: 'someorg',
app: 'someapp',
},
configurationFilename: 'serverless.yml',
})
async () => await step.run(context)
);
expect(stdoutData).to.include('Your project is ready for deployment');
expect(stdoutData).to.include('Invoke your functions and view logs in the dashboard');
expect(context.stepHistory.valuesMap()).to.deep.equal(new Map([['shouldDeploy', false]]));
});
it('should correctly handle deployment for service configured with dashboard', async () => {
@ -156,26 +167,29 @@ describe('test/unit/lib/cli/interactive-setup/deploy.test.js', () => {
confirm: { shouldDeploy: true },
});
const context = {
serviceDir: process.cwd(),
configuration: {
service: 'someservice',
provider: { name: 'aws' },
org: 'someorg',
app: 'someapp',
},
configurationFilename: 'serverless.yml',
stepHistory: new StepHistory(),
};
let stdoutData = '';
await overrideStdoutWrite(
(data) => (stdoutData += data),
async () =>
await mockedStep.run({
serviceDir: process.cwd(),
configuration: {
service: 'someservice',
provider: { name: 'aws' },
org: 'someorg',
app: 'someapp',
},
configurationFilename: 'serverless.yml',
})
async () => await mockedStep.run(context)
);
expect(stdoutData).to.include('Your project is live and available');
expect(stdoutData).to.include(
`Open ${chalk.bold('https://app.serverless-dev.com/path/to/dashboard')}`
);
expect(context.stepHistory.valuesMap()).to.deep.equal(new Map([['shouldDeploy', true]]));
});
it('should correctly handle deployment for service not configured with dashboard', async () => {
@ -207,22 +221,24 @@ describe('test/unit/lib/cli/interactive-setup/deploy.test.js', () => {
confirm: { shouldDeploy: true },
});
const context = {
serviceDir: process.cwd(),
configuration: {
service: 'someservice',
provider: { name: 'aws' },
},
configurationFilename: 'serverless.yml',
stepHistory: new StepHistory(),
};
let stdoutData = '';
await overrideStdoutWrite(
(data) => (stdoutData += data),
async () =>
await mockedStep.run({
serviceDir: process.cwd(),
configuration: {
service: 'someservice',
provider: { name: 'aws' },
},
configurationFilename: 'serverless.yml',
})
async () => await mockedStep.run(context)
);
expect(stdoutData).to.include('Your project is live and available');
expect(stdoutData).to.include(`Run ${chalk.bold('serverless')}`);
expect(context.stepHistory.valuesMap()).to.deep.equal(new Map([['shouldDeploy', true]]));
});
});
});

View File

@ -8,6 +8,7 @@ const step = require('../../../../../lib/cli/interactive-setup/service');
const proxyquire = require('proxyquire');
const overrideStdoutWrite = require('process-utils/override-stdout-write');
const ServerlessError = require('../../../../../lib/serverless-error');
const { StepHistory } = require('@serverless/utils/telemetry');
const templatesPath = path.resolve(__dirname, '../../../../../lib/plugins/create/templates');
@ -23,12 +24,24 @@ const confirmEmptyWorkingDir = async () =>
expect(await fsp.readdir(process.cwd())).to.deep.equal([]);
describe('test/unit/lib/cli/interactive-setup/service.test.js', () => {
afterEach(() => sinon.restore());
afterEach(() => {
sinon.restore();
});
it('Should be not applied, when at service path', () =>
expect(step.isApplicable({ serviceDir: '/foo', options: {} })).to.equal(false));
it('Should be applied, when not at service path', () =>
expect(step.isApplicable({ options: {} })).to.equal(true));
it('Should be not applied, when at service path', () => {
const context = {
serviceDir: '/foo',
options: {},
};
expect(step.isApplicable(context)).to.equal(false);
expect(context.inapplicabilityReasonCode).to.equal('IN_SERVICE_DIRECTORY');
});
it('Should be applied, when not at service path', () => {
const context = { options: {} };
expect(step.isApplicable(context)).to.equal(true);
expect(context.inapplicabilityReasonCode).to.be.undefined;
});
it('Should result in an error when at service path with `template-path` options provided', () => {
expect(() =>
@ -56,7 +69,9 @@ describe('test/unit/lib/cli/interactive-setup/service.test.js', () => {
configureInquirerStub(inquirer, {
list: { projectType: 'other' },
});
await step.run({ options: {} });
const context = { options: {}, stepHistory: new StepHistory() };
await step.run(context);
expect(context.stepHistory.valuesMap()).to.deep.equal(new Map([['projectType', 'other']]));
return confirmEmptyWorkingDir();
});
@ -84,7 +99,8 @@ describe('test/unit/lib/cli/interactive-setup/service.test.js', () => {
list: { projectType: 'aws-nodejs' },
input: { projectName: 'test-project' },
});
await mockedStep.run({ options: {} });
const context = { options: {}, stepHistory: new StepHistory() };
await mockedStep.run(context);
const stats = await fsp.lstat('test-project/serverless.yml');
expect(stats.isFile()).to.be.true;
expect(downloadTemplateFromRepoStub).to.have.been.calledWith(
@ -92,6 +108,12 @@ describe('test/unit/lib/cli/interactive-setup/service.test.js', () => {
'aws-nodejs',
'test-project'
);
expect(context.stepHistory.valuesMap()).to.deep.equal(
new Map([
['projectType', 'aws-nodejs'],
['projectName', '_user_provided_'],
])
);
});
it('Should remove `serverless.template.yml` if its a part of the template', async () => {
@ -118,7 +140,8 @@ describe('test/unit/lib/cli/interactive-setup/service.test.js', () => {
list: { projectType: 'aws-nodejs' },
input: { projectName: 'test-project-template' },
});
await mockedStep.run({ options: {} });
const context = { options: {}, stepHistory: new StepHistory() };
await mockedStep.run(context);
const stats = await fsp.lstat('test-project-template/serverless.yml');
expect(stats.isFile()).to.be.true;
expect(downloadTemplateFromRepoStub).to.have.been.calledWith(
@ -129,6 +152,13 @@ describe('test/unit/lib/cli/interactive-setup/service.test.js', () => {
await expect(
fsp.lstat('test-proejct-template/serverless.template.yml')
).to.eventually.be.rejected.and.have.property('code', 'ENOENT');
expect(context.stepHistory.valuesMap()).to.deep.equal(
new Map([
['projectType', 'aws-nodejs'],
['projectName', '_user_provided_'],
])
);
});
it('Should run `npm install` if `package.json` present', async () => {
@ -157,7 +187,8 @@ describe('test/unit/lib/cli/interactive-setup/service.test.js', () => {
list: { projectType: 'aws-nodejs' },
input: { projectName: 'test-project-package-json' },
});
await mockedStep.run({ options: {} });
const context = { options: {}, stepHistory: new StepHistory() };
await mockedStep.run(context);
const stats = await fsp.lstat('test-project-package-json/serverless.yml');
expect(stats.isFile()).to.be.true;
expect(downloadTemplateFromRepoStub).to.have.been.calledWith(
@ -168,6 +199,13 @@ describe('test/unit/lib/cli/interactive-setup/service.test.js', () => {
expect(spawnStub).to.have.been.calledWith('npm', ['install'], {
cwd: path.join(process.cwd(), 'test-project-package-json'),
});
expect(context.stepHistory.valuesMap()).to.deep.equal(
new Map([
['projectType', 'aws-nodejs'],
['projectName', '_user_provided_'],
])
);
});
it('Should emit warning if npm installation not found', async () => {
@ -196,15 +234,23 @@ describe('test/unit/lib/cli/interactive-setup/service.test.js', () => {
input: { projectName: 'test-project-missing-npm' },
});
const context = { options: {}, stepHistory: new StepHistory() };
let stdoutData = '';
await overrideStdoutWrite(
(data) => (stdoutData += data),
async () => mockedStep.run({ options: {} })
async () => mockedStep.run(context)
);
const stats = await fsp.lstat('test-project-missing-npm/serverless.yml');
expect(stats.isFile()).to.be.true;
expect(stdoutData).to.include('Cannot install dependencies');
expect(context.stepHistory.valuesMap()).to.deep.equal(
new Map([
['projectType', 'aws-nodejs'],
['projectName', '_user_provided_'],
])
);
});
it('Should emit warning if npm installation not found', async () => {
@ -233,19 +279,35 @@ describe('test/unit/lib/cli/interactive-setup/service.test.js', () => {
input: { projectName: 'test-project-failed-install' },
});
await expect(mockedStep.run({ options: {} })).to.be.eventually.rejected.and.have.property(
const context = { options: {}, stepHistory: new StepHistory() };
await expect(mockedStep.run(context)).to.be.eventually.rejected.and.have.property(
'code',
'DEPENDENCIES_INSTALL_FAILED'
);
expect(context.stepHistory.valuesMap()).to.deep.equal(
new Map([
['projectType', 'aws-nodejs'],
['projectName', '_user_provided_'],
])
);
});
it('Should create project at not existing directory from a provided `template-path`', async () => {
configureInquirerStub(inquirer, {
input: { projectName: 'test-project-from-local-template' },
});
await step.run({ options: { 'template-path': path.join(templatesPath, 'aws-nodejs') } });
const context = {
options: { 'template-path': path.join(templatesPath, 'aws-nodejs') },
stepHistory: new StepHistory(),
};
await step.run(context);
const stats = await fsp.lstat('test-project-from-local-template/serverless.yml');
expect(stats.isFile()).to.be.true;
expect(context.stepHistory.valuesMap()).to.deep.equal(
new Map([['projectName', '_user_provided_']])
);
});
it('Should create project at not existing directory with provided `name`', async () => {
@ -268,9 +330,16 @@ describe('test/unit/lib/cli/interactive-setup/service.test.js', () => {
configureInquirerStub(inquirer, {
list: { projectType: 'aws-nodejs' },
});
await mockedStep.run({ options: { name: 'test-project-from-cli-option' } });
const context = {
options: { name: 'test-project-from-cli-option' },
stepHistory: new StepHistory(),
};
await mockedStep.run(context);
const stats = await fsp.lstat('test-project-from-cli-option/serverless.yml');
expect(stats.isFile()).to.be.true;
expect(context.stepHistory.valuesMap()).to.deep.equal(
new Map([['projectType', 'aws-nodejs']])
);
});
it('Should create project at not existing directory with provided template', async () => {
@ -294,7 +363,8 @@ describe('test/unit/lib/cli/interactive-setup/service.test.js', () => {
configureInquirerStub(inquirer, {
input: { projectName: 'test-project-from-provided-template' },
});
await mockedStep.run({ options: { template: 'test-template' } });
const context = { options: { template: 'test-template' }, stepHistory: new StepHistory() };
await mockedStep.run(context);
const stats = await fsp.lstat('test-project-from-provided-template/serverless.yml');
expect(stats.isFile()).to.be.true;
expect(downloadTemplateFromRepoStub).to.have.been.calledWith(
@ -302,6 +372,10 @@ describe('test/unit/lib/cli/interactive-setup/service.test.js', () => {
'test-template',
'test-project-from-provided-template'
);
expect(context.stepHistory.valuesMap()).to.deep.equal(
new Map([['projectName', '_user_provided_']])
);
});
it('Should create project at not existing directory with provided `template-url`', async () => {
@ -327,7 +401,11 @@ describe('test/unit/lib/cli/interactive-setup/service.test.js', () => {
configureInquirerStub(inquirer, {
input: { projectName: 'test-project-from-provided-template-url' },
});
await mockedStep.run({ options: { 'template-url': providedTemplateUrl } });
const context = {
options: { 'template-url': providedTemplateUrl },
stepHistory: new StepHistory(),
};
await mockedStep.run(context);
const stats = await fsp.lstat('test-project-from-provided-template-url/serverless.yml');
expect(stats.isFile()).to.be.true;
expect(downloadTemplateFromRepoStub).to.have.been.calledWith(
@ -335,6 +413,10 @@ describe('test/unit/lib/cli/interactive-setup/service.test.js', () => {
null,
'test-project-from-provided-template-url'
);
expect(context.stepHistory.valuesMap()).to.deep.equal(
new Map([['projectName', '_user_provided_']])
);
});
it('Should throw an error when template cannot be downloaded', async () => {
@ -349,10 +431,18 @@ describe('test/unit/lib/cli/interactive-setup/service.test.js', () => {
list: { projectType: 'aws-nodejs' },
input: { projectName: 'test-error-during-download' },
});
await expect(mockedStep.run({ options: {} })).to.be.eventually.rejected.and.have.property(
const context = { options: {}, stepHistory: new StepHistory() };
await expect(mockedStep.run(context)).to.be.eventually.rejected.and.have.property(
'code',
'TEMPLATE_DOWNLOAD_FAILED'
);
expect(context.stepHistory.valuesMap()).to.deep.equal(
new Map([
['projectType', 'aws-nodejs'],
['projectName', '_user_provided_'],
])
);
});
it('Should throw an error when provided template cannot be found', async () => {
@ -364,9 +454,14 @@ describe('test/unit/lib/cli/interactive-setup/service.test.js', () => {
configureInquirerStub(inquirer, {
input: { projectName: 'test-error-during-download' },
});
await expect(
mockedStep.run({ options: { template: 'test-template' } })
).to.be.eventually.rejected.and.have.property('code', 'INVALID_TEMPLATE');
const context = { options: { template: 'test-template' }, stepHistory: new StepHistory() };
await expect(mockedStep.run(context)).to.be.eventually.rejected.and.have.property(
'code',
'INVALID_TEMPLATE'
);
expect(context.stepHistory.valuesMap()).to.deep.equal(
new Map([['projectName', '_user_provided_']])
);
});
it('Should throw an error when template provided with url cannot be found', async () => {
@ -380,9 +475,18 @@ describe('test/unit/lib/cli/interactive-setup/service.test.js', () => {
configureInquirerStub(inquirer, {
input: { projectName: 'test-error-during-download-custom-template' },
});
await expect(
mockedStep.run({ options: { 'template-url': 'test-template-url' } })
).to.be.eventually.rejected.and.have.property('code', 'INVALID_TEMPLATE_URL');
const context = {
options: { 'template-url': 'test-template-url' },
stepHistory: new StepHistory(),
};
await expect(mockedStep.run(context)).to.be.eventually.rejected.and.have.property(
'code',
'INVALID_TEMPLATE_URL'
);
expect(context.stepHistory.valuesMap()).to.deep.equal(
new Map([['projectName', '_user_provided_']])
);
});
});
@ -394,10 +498,13 @@ describe('test/unit/lib/cli/interactive-setup/service.test.js', () => {
await fsp.mkdir('existing');
await expect(step.run({ options: {} })).to.eventually.be.rejected.and.have.property(
const context = { options: {}, stepHistory: new StepHistory() };
await expect(step.run(context)).to.eventually.be.rejected.and.have.property(
'code',
'INVALID_ANSWER'
);
expect(context.stepHistory.valuesMap()).to.deep.equal(new Map([['projectType', 'aws-nodejs']]));
});
it('Should not allow project creation in a directory in which already service is configured when `name` flag provided', async () => {
@ -407,9 +514,13 @@ describe('test/unit/lib/cli/interactive-setup/service.test.js', () => {
await fsp.mkdir('anotherexisting');
await expect(
step.run({ options: { name: 'anotherexisting' } })
).to.eventually.be.rejected.and.have.property('code', 'TARGET_FOLDER_ALREADY_EXISTS');
const context = { options: { name: 'anotherexisting' }, stepHistory: new StepHistory() };
await expect(step.run(context)).to.eventually.be.rejected.and.have.property(
'code',
'TARGET_FOLDER_ALREADY_EXISTS'
);
expect(context.stepHistory.valuesMap()).to.deep.equal(new Map([['projectType', 'aws-nodejs']]));
});
it('Should not allow project creation using an invalid project name', async () => {
@ -417,19 +528,26 @@ describe('test/unit/lib/cli/interactive-setup/service.test.js', () => {
list: { projectType: 'aws-nodejs' },
input: { projectName: 'elo grzegżółka' },
});
await expect(step.run({ options: {} })).to.eventually.be.rejected.and.have.property(
const context = { options: {}, stepHistory: new StepHistory() };
await expect(step.run(context)).to.eventually.be.rejected.and.have.property(
'code',
'INVALID_ANSWER'
);
expect(context.stepHistory.valuesMap()).to.deep.equal(new Map([['projectType', 'aws-nodejs']]));
});
it('Should not allow project creation using an invalid project name when `name` flag provided', async () => {
configureInquirerStub(inquirer, {
list: { projectType: 'aws-nodejs' },
});
await expect(
step.run({ options: { name: 'elo grzegżółka' } })
).to.eventually.be.rejected.and.have.property('code', 'INVALID_PROJECT_NAME');
const context = { options: { name: 'elo grzegżółka' }, stepHistory: new StepHistory() };
await expect(step.run(context)).to.eventually.be.rejected.and.have.property(
'code',
'INVALID_PROJECT_NAME'
);
expect(context.stepHistory.valuesMap()).to.deep.equal(new Map([['projectType', 'aws-nodejs']]));
});
it('Should not allow project creation if multiple template-related options are provided', async () => {

View File

@ -6,6 +6,7 @@ const fs = require('fs');
const os = require('os');
const overrideEnv = require('process-utils/override-env');
const overrideCwd = require('process-utils/override-cwd');
const sinon = require('sinon');
const resolveLocalServerless = require('../../../../../lib/cli/resolve-local-serverless-path');
const commandsSchema = require('../../../../../lib/cli/commands-schema');