[cli] New vc promote command (#9984)

~This PR is blocked by https://github.com/vercel/api/pull/19508.~

Linear: https://linear.app/vercel/issue/VCCLI-262/cli-new-command-to-promote-deployment
This commit is contained in:
Chris Barber
2023-05-24 12:22:11 -05:00
committed by GitHub
parent c7bcea4081
commit 4bd70d4b6e
23 changed files with 1088 additions and 9 deletions

View File

@@ -24,6 +24,7 @@ export const help = () => `
ls | list [app] Lists deployments
login [email] Logs into your account or creates a new one
logout Logs out of your account
promote [url|id] Promote an existing deployment to current
pull [path] Pull your Project Settings from the cloud
redeploy [url|id] Rebuild and deploy a previous deployment.
rollback [url|id] Quickly revert back to a previous deployment

View File

@@ -26,6 +26,7 @@ export default new Map([
['ls', 'list'],
['project', 'project'],
['projects', 'project'],
['promote', 'promote'],
['pull', 'pull'],
['redeploy', 'redeploy'],
['remove', 'remove'],

View File

@@ -0,0 +1,121 @@
import chalk from 'chalk';
import type Client from '../../util/client';
import getArgs from '../../util/get-args';
import getProjectByCwdOrLink from '../../util/projects/get-project-by-cwd-or-link';
import { getPkgName } from '../../util/pkg-name';
import handleError from '../../util/handle-error';
import { isErrnoException } from '@vercel/error-utils';
import logo from '../../util/output/logo';
import ms from 'ms';
import requestPromote from './request-promote';
import promoteStatus from './status';
const help = () => {
console.log(`
${chalk.bold(`${logo} ${getPkgName()} promote`)} [deployment id/url]
Promote an existing deployment to current.
${chalk.dim('Options:')}
-h, --help Output usage information
-A ${chalk.bold.underline('FILE')}, --local-config=${chalk.bold.underline(
'FILE'
)} Path to the local ${'`vercel.json`'} file
-Q ${chalk.bold.underline('DIR')}, --global-config=${chalk.bold.underline(
'DIR'
)} Path to the global ${'`.vercel`'} directory
-d, --debug Debug mode [off]
--no-color No color mode [off]
-t ${chalk.bold.underline('TOKEN')}, --token=${chalk.bold.underline(
'TOKEN'
)} Login token
--timeout=${chalk.bold.underline(
'TIME'
)} Time to wait for promotion completion [3m]
-y, --yes Skip questions when setting up new project using default scope and settings
${chalk.dim('Examples:')}
${chalk.gray('')} Show the status of any current pending promotions
${chalk.cyan(`$ ${getPkgName()} promote`)}
${chalk.cyan(`$ ${getPkgName()} promote status`)}
${chalk.cyan(`$ ${getPkgName()} promote status <project>`)}
${chalk.cyan(`$ ${getPkgName()} promote status --timeout 30s`)}
${chalk.gray('')} Promote a deployment using id or url
${chalk.cyan(`$ ${getPkgName()} promote <deployment id/url>`)}
`);
};
/**
* `vc promote` command
* @param {Client} client
* @returns {Promise<number>} Resolves an exit code; 0 on success
*/
export default async (client: Client): Promise<number> => {
let argv;
try {
argv = getArgs(client.argv.slice(2), {
'--timeout': String,
'--yes': Boolean,
'-y': '--yes',
});
} catch (err) {
handleError(err);
return 1;
}
if (argv['--help'] || argv._[0] === 'help') {
help();
return 2;
}
// validate the timeout
let timeout = argv['--timeout'];
if (timeout && ms(timeout) === undefined) {
client.output.error(`Invalid timeout "${timeout}"`);
return 1;
}
const actionOrDeployId = argv._[1] || 'status';
try {
if (actionOrDeployId === 'status') {
const project = await getProjectByCwdOrLink({
autoConfirm: Boolean(argv['--yes']),
client,
commandName: 'promote',
cwd: argv['--cwd'] || process.cwd(),
projectNameOrId: argv._[2],
});
return await promoteStatus({
client,
project,
timeout,
});
}
return await requestPromote({
client,
deployId: actionOrDeployId,
timeout,
});
} catch (err) {
if (isErrnoException(err)) {
if (err.code === 'ERR_CANCELED') {
return 0;
}
if (err.code === 'ERR_INVALID_CWD' || err.code === 'ERR_LINK_PROJECT') {
// do not show the message
return 1;
}
}
client.output.prettyError(err);
return 1;
}
};

View File

@@ -0,0 +1,58 @@
import chalk from 'chalk';
import type Client from '../../util/client';
import { getCommandName } from '../../util/pkg-name';
import getProjectByDeployment from '../../util/projects/get-project-by-deployment';
import ms from 'ms';
import promoteStatus from './status';
/**
* Requests a promotion and waits for it complete.
* @param {Client} client - The Vercel client instance
* @param {string} deployId - The deployment name or id to promote
* @param {Project} project - Project info instance
* @param {string} [timeout] - Time to poll for succeeded/failed state
* @returns {Promise<number>} Resolves an exit code; 0 on success
*/
export default async function requestPromote({
client,
deployId,
timeout,
}: {
client: Client;
deployId: string;
timeout?: string;
}): Promise<number> {
const { output } = client;
const { contextName, deployment, project } = await getProjectByDeployment({
client,
deployId,
output: client.output,
});
// request the promotion
await client.fetch(`/v9/projects/${project.id}/promote/${deployment.id}`, {
body: {}, // required
json: false,
method: 'POST',
});
if (timeout !== undefined && ms(timeout) === 0) {
output.log(
`Successfully requested promote of ${chalk.bold(project.name)} to ${
deployment.url
} (${deployment.id})`
);
output.log(`To check promote status, run ${getCommandName('promote')}.`);
return 0;
}
// check the status
return await promoteStatus({
client,
contextName,
deployment,
project,
timeout,
});
}

View File

@@ -0,0 +1,265 @@
import chalk from 'chalk';
import type {
LastAliasRequest,
Deployment,
PaginationOptions,
Project,
} from '@vercel-internals/types';
import type Client from '../../util/client';
import elapsed from '../../util/output/elapsed';
import formatDate from '../../util/format-date';
import getDeployment from '../../util/get-deployment';
import { getPkgName } from '../../util/pkg-name';
import getScope from '../../util/get-scope';
import ms from 'ms';
import sleep from '../../util/sleep';
import getProjectByNameOrId from '../../util/projects/get-project-by-id-or-name';
import { ProjectNotFound } from '../../util/errors-ts';
import renderAliasStatus from '../../util/alias/render-alias-status';
interface DeploymentAlias {
alias: {
alias: string;
deploymentId: string;
};
id: string;
status: 'completed' | 'in-progress' | 'pending' | 'failed';
}
interface AliasesResponse {
aliases: DeploymentAlias[];
pagination: PaginationOptions;
}
/**
* Continuously checks a deployment status until it has succeeded, failed, or
* taken longer than the timeout (default 3 minutes).
*
* @param {Client} client - The Vercel client instance
* @param {string} [contextName] - The scope name; if not specified, it will be
* extracted from the `client`
* @param {Deployment} [deployment] - Info about the deployment which is used
* to display different output following a promotion request
* @param {Project} project - Project info instance
* @param {string} [timeout] - Milliseconds to poll for succeeded/failed state
* @returns {Promise<number>} Resolves an exit code; 0 on success
*/
export default async function promoteStatus({
client,
contextName,
deployment,
project,
timeout = '3m',
}: {
client: Client;
contextName?: string;
deployment?: Deployment;
project: Project;
timeout?: string;
}): Promise<number> {
const { output } = client;
const recentThreshold = Date.now() - ms('3m');
const promoteTimeout = Date.now() + ms(timeout);
let counter = 0;
let spinnerMessage = deployment
? 'Promote in progress'
: `Checking promotion status of ${project.name}`;
if (!contextName) {
({ contextName } = await getScope(client));
}
try {
output.spinner(`${spinnerMessage}`);
// continuously loop until the promotion has explicitly succeeded, failed,
// or timed out
for (;;) {
const projectCheck = await getProjectByNameOrId(
client,
project.id,
project.accountId,
true
);
if (projectCheck instanceof ProjectNotFound) {
throw projectCheck;
}
const {
jobStatus,
requestedAt,
toDeploymentId,
type,
}: Partial<LastAliasRequest> = projectCheck.lastAliasRequest ?? {};
if (
!jobStatus ||
(jobStatus !== 'in-progress' && jobStatus !== 'pending')
) {
output.stopSpinner();
output.log(`${spinnerMessage}`);
}
if (
!jobStatus ||
!requestedAt ||
!toDeploymentId ||
requestedAt < recentThreshold
) {
output.log('No deployment promotion in progress');
return 0;
}
if (jobStatus === 'skipped' && type === 'promote') {
output.log('Promote deployment was skipped');
return 0;
}
if (jobStatus === 'succeeded') {
return await renderJobSucceeded({
client,
contextName,
performingPromote: !!deployment,
requestedAt,
project,
toDeploymentId,
});
}
if (jobStatus === 'failed') {
return await renderJobFailed({
client,
contextName,
deployment,
project,
toDeploymentId,
});
}
// lastly, if we're not pending/in-progress, then we don't know what
// the status is, so bail
if (jobStatus !== 'pending' && jobStatus !== 'in-progress') {
output.log(`Unknown promote deployment status "${jobStatus}"`);
return 1;
}
// check if we have been running for too long
if (requestedAt < recentThreshold || Date.now() >= promoteTimeout) {
output.log(
`The promotion exceeded its deadline - rerun ${chalk.bold(
`${getPkgName()} promote ${toDeploymentId}`
)} to try again`
);
return 1;
}
// if we've done our first poll and not rolling back, then print the
// requested at date/time
if (counter++ === 0 && !deployment) {
spinnerMessage += ` requested at ${formatDate(requestedAt)}`;
}
output.spinner(`${spinnerMessage}`);
await sleep(250);
}
} finally {
output.stopSpinner();
}
}
async function renderJobFailed({
client,
contextName,
deployment,
project,
toDeploymentId,
}: {
client: Client;
contextName: string;
deployment?: Deployment;
project: Project;
toDeploymentId: string;
}) {
const { output } = client;
try {
const name = (
deployment || (await getDeployment(client, contextName, toDeploymentId))
)?.url;
output.error(
`Failed to remap all aliases to the requested deployment ${name} (${toDeploymentId})`
);
} catch (e) {
output.error(
`Failed to remap all aliases to the requested deployment ${toDeploymentId}`
);
}
// aliases are paginated, so continuously loop until all of them have been
// fetched
let nextTimestamp;
for (;;) {
let url = `/v9/projects/${project.id}/promote/aliases?failedOnly=true&limit=20`;
if (nextTimestamp) {
url += `&until=${nextTimestamp}`;
}
const { aliases, pagination } = await client.fetch<AliasesResponse>(url);
for (const { alias, status } of aliases) {
output.log(
` ${renderAliasStatus(status).padEnd(11)} ${alias.alias} (${
alias.deploymentId
})`
);
}
if (pagination?.next) {
nextTimestamp = pagination.next;
} else {
break;
}
}
return 1;
}
async function renderJobSucceeded({
client,
contextName,
performingPromote,
project,
requestedAt,
toDeploymentId,
}: {
client: Client;
contextName: string;
performingPromote: boolean;
project: Project;
requestedAt: number;
toDeploymentId: string;
}) {
const { output } = client;
// attempt to get the new deployment url
let deploymentInfo = '';
try {
const deployment = await getDeployment(client, contextName, toDeploymentId);
deploymentInfo = `${chalk.bold(deployment.url)} (${toDeploymentId})`;
} catch (err: any) {
output.debug(
`Failed to get deployment url for ${toDeploymentId}: ${
err?.toString() || err
}`
);
deploymentInfo = chalk.bold(toDeploymentId);
}
const duration = performingPromote ? elapsed(Date.now() - requestedAt) : '';
output.log(
`Success! ${chalk.bold(
project.name
)} was promoted to ${deploymentInfo} ${duration}`
);
return 0;
}

View File

@@ -12,9 +12,7 @@ import validatePaths from '../util/validate-paths';
const help = () => {
console.log(`
${chalk.bold(
`${logo} ${getPkgName()} rollback`
)} [deploymentId|deploymentName]
${chalk.bold(`${logo} ${getPkgName()} rollback`)} [deployment id/url]
Quickly revert back to a previous deployment.

View File

@@ -571,6 +571,9 @@ const main = async () => {
case 'project':
func = require('./commands/project').default;
break;
case 'promote':
func = require('./commands/promote').default;
break;
case 'pull':
func = require('./commands/pull').default;
break;

View File

@@ -0,0 +1,58 @@
import type Client from '../client';
import { ProjectNotFound } from '../errors-ts';
import { ensureLink } from '../link/ensure-link';
import validatePaths from '../validate-paths';
import getProjectByNameOrId from './get-project-by-id-or-name';
import type { Project } from '@vercel-internals/types';
export default async function getProjectByCwdOrLink({
autoConfirm,
client,
commandName,
cwd,
projectNameOrId,
}: {
autoConfirm?: boolean;
client: Client;
commandName: string;
cwd: string;
projectNameOrId?: string;
}): Promise<Project> {
if (projectNameOrId) {
const project = await getProjectByNameOrId(client, projectNameOrId);
if (project instanceof ProjectNotFound) {
throw project;
}
return project;
}
const pathValidation = await validatePaths(client, [cwd]);
if (!pathValidation.valid) {
if (pathValidation.exitCode) {
const err: NodeJS.ErrnoException = new Error(
'Invalid current working directory'
);
err.code = 'ERR_INVALID_CWD';
throw err;
}
const err: NodeJS.ErrnoException = new Error('Canceled');
err.code = 'ERR_CANCELED';
throw err;
}
// ensure the current directory is a linked project
const linkedProject = await ensureLink(
commandName,
client,
pathValidation.path,
{ autoConfirm }
);
if (typeof linkedProject === 'number') {
const err: NodeJS.ErrnoException = new Error('Link project error');
err.code = 'ERR_LINK_PROJECT';
throw err;
}
return linkedProject.project;
}

View File

@@ -0,0 +1,104 @@
import chalk from 'chalk';
import type Client from '../client';
import type { Deployment, Project, Team } from '@vercel-internals/types';
import getDeployment from '../get-deployment';
import getProjectByNameOrId from './get-project-by-id-or-name';
import getScope from '../get-scope';
import getTeamById from '../teams/get-team-by-id';
import { isValidName } from '../is-valid-name';
import { Output } from '../output';
import { ProjectNotFound } from '../errors-ts';
export default async function getProjectByDeployment({
client,
deployId,
output,
}: {
client: Client;
deployId: string;
output?: Output;
}): Promise<{
contextName: string;
deployment: Deployment;
project: Project;
}> {
const { config } = client;
const { contextName } = await getScope(client);
if (!isValidName(deployId)) {
throw new Error(
`The provided argument "${deployId}" is not a valid deployment`
);
}
let deployment: Deployment;
let team: Team | undefined;
try {
output?.spinner(
`Fetching deployment "${deployId}" in ${chalk.bold(contextName)}`
);
const [teamResult, deploymentResult] = await Promise.allSettled([
config.currentTeam ? getTeamById(client, config.currentTeam) : undefined,
getDeployment(client, contextName, deployId),
]);
if (teamResult.status === 'rejected') {
throw new Error(
`Failed to retrieve team information: ${teamResult.reason}`
);
}
if (deploymentResult.status === 'rejected') {
throw new Error(deploymentResult.reason);
}
team = teamResult.value;
deployment = deploymentResult.value;
// re-render the spinner text
output?.log(
`Fetching deployment "${deployId}" in ${chalk.bold(contextName)}`
);
if (deployment.team?.id) {
if (!team || deployment.team.id !== team.id) {
const err: NodeJS.ErrnoException = new Error(
team
? `Deployment doesn't belong to current team ${chalk.bold(
contextName
)}`
: `Deployment belongs to a different team`
);
err.code = 'ERR_INVALID_TEAM';
throw err;
}
}
if (team) {
const err: NodeJS.ErrnoException = new Error(
`Deployment doesn't belong to current team ${chalk.bold(contextName)}`
);
err.code = 'ERR_INVALID_TEAM';
throw err;
}
if (!deployment.projectId) {
throw new Error('Deployment is not associated to a project');
}
const project = await getProjectByNameOrId(client, deployment.projectId);
if (project instanceof ProjectNotFound) {
throw project;
}
return {
contextName,
deployment,
project,
};
} finally {
output?.stopSpinner();
}
}

View File

@@ -5,11 +5,13 @@ import { isAPIError, ProjectNotFound } from '../errors-ts';
export default async function getProjectByNameOrId(
client: Client,
projectNameOrId: string,
accountId?: string
accountId?: string,
includeRollbackInfo?: boolean
) {
try {
const qs = includeRollbackInfo ? '?rollbackInfo=true' : '';
const project = await client.fetch<Project>(
`/v8/projects/${encodeURIComponent(projectNameOrId)}`,
`/v9/projects/${encodeURIComponent(projectNameOrId)}${qs}`,
{ accountId }
);
return project;

View File

@@ -11,7 +11,7 @@ import formatDate from '../format-date';
import getDeployment from '../get-deployment';
import getScope from '../get-scope';
import ms from 'ms';
import renderAliasStatus from './render-alias-status';
import renderAliasStatus from '../alias/render-alias-status';
import sleep from '../sleep';
interface RollbackAlias {

View File

@@ -0,0 +1,3 @@
.next
yarn.lock
!.vercel

View File

@@ -0,0 +1,4 @@
{
"orgId": "team_dummy",
"projectId": "vercel-promote"
}

View File

@@ -0,0 +1,12 @@
{
"scripts": {
"build": "next build",
"dev": "next",
"now-build": "next build"
},
"dependencies": {
"next": "13.4.3",
"react": "latest",
"react-dom": "latest"
}
}

View File

@@ -0,0 +1,11 @@
import { withRouter } from 'next/router';
function Index({ router }) {
const data = {
pathname: router.pathname,
query: router.query,
};
return <div>{JSON.stringify(data)}</div>;
}
export default withRouter(Index);

View File

@@ -0,0 +1,10 @@
{
"version": 2,
"name": "vercel-promote",
"routes": [
{
"src": "/(.*)",
"dest": "/index?route-param=b"
}
]
}

View File

@@ -3,6 +3,7 @@ import chance from 'chance';
import { client } from './client';
import { Build, Deployment, User } from '@vercel-internals/types';
import type { Request, Response } from 'express';
import { defaultProject } from './project';
let deployments = new Map<string, Deployment>();
let deploymentBuilds = new Map<Deployment, Build[]>();
@@ -16,6 +17,7 @@ export function useDeployment({
creator,
state = 'READY',
createdAt,
project = defaultProject,
}: {
creator: Pick<User, 'id' | 'email' | 'name' | 'username'>;
state?:
@@ -26,6 +28,7 @@ export function useDeployment({
| 'READY'
| 'CANCELED';
createdAt?: number;
project: any; // FIX ME: Use `Project` once PR #9956 is merged
}) {
setupDeploymentEndpoints();
@@ -53,6 +56,7 @@ export function useDeployment({
name,
ownerId: creator.id,
plan: 'hobby',
projectId: project.id,
public: false,
ready: createdAt + 30000,
readyState: state,

View File

@@ -128,6 +128,7 @@ export const defaultProject: Project = {
url: 'a-project-name-rjtr4pz3f.vercel.app',
},
],
lastAliasRequest: null,
lastRollbackTarget: null,
alias: [
{
@@ -205,10 +206,10 @@ export function useProject(
project: Partial<Project> = defaultProject,
projectEnvs: ProjectEnvVariable[] = envs
) {
client.scenario.get(`/v8/projects/${project.name}`, (_req, res) => {
client.scenario.get(`/:version/projects/${project.name}`, (_req, res) => {
res.json(project);
});
client.scenario.get(`/v8/projects/${project.id}`, (_req, res) => {
client.scenario.get(`/:version/projects/${project.id}`, (_req, res) => {
res.json(project);
});
client.scenario.patch(`/:version/projects/${project.id}`, (req, res) => {

View File

@@ -0,0 +1,380 @@
import chalk from 'chalk';
import { client } from '../../mocks/client';
import { defaultProject, useProject } from '../../mocks/project';
import { Request, Response } from 'express';
import promote from '../../../src/commands/promote';
import { LastAliasRequest } from '@vercel-internals/types';
import { setupUnitFixture } from '../../helpers/setup-unit-fixture';
import { useDeployment } from '../../mocks/deployment';
import { useTeams } from '../../mocks/team';
import { useUser } from '../../mocks/user';
import sleep from '../../../src/util/sleep';
jest.setTimeout(60000);
describe('promote', () => {
it('should error if cwd is invalid', async () => {
client.setArgv('promote', '--cwd', __filename);
const exitCodePromise = promote(client);
await expect(client.stderr).toOutput(
'Error: Support for single file deployments has been removed.'
);
await expect(exitCodePromise).resolves.toEqual(1);
});
it('should error if timeout is invalid', async () => {
const { cwd } = initPromoteTest();
client.setArgv('promote', '--yes', '--cwd', cwd, '--timeout', 'foo');
const exitCodePromise = promote(client);
await expect(client.stderr).toOutput('Error: Invalid timeout "foo"');
await expect(exitCodePromise).resolves.toEqual(1);
});
it('should error if invalid deployment ID', async () => {
const { cwd } = initPromoteTest();
client.setArgv('promote', '????', '--yes', '--cwd', cwd);
const exitCodePromise = promote(client);
await expect(client.stderr).toOutput(
'Error: The provided argument "????" is not a valid deployment'
);
await expect(exitCodePromise).resolves.toEqual(1);
});
it('should error if deployment not found', async () => {
const { cwd } = initPromoteTest();
client.setArgv('promote', 'foo', '--yes', '--cwd', cwd);
const exitCodePromise = promote(client);
await expect(client.stderr).toOutput('Fetching deployment "foo" in ');
await expect(client.stderr).toOutput(
'Error: Error: Can\'t find the deployment "foo" under the context'
);
await expect(exitCodePromise).resolves.toEqual(1);
});
it('should show status when not promoting', async () => {
const { cwd } = initPromoteTest();
client.setArgv('promote', '--yes', '--cwd', cwd);
const exitCodePromise = promote(client);
await expect(client.stderr).toOutput(
'Checking promotion status of vercel-promote'
);
await expect(client.stderr).toOutput('No deployment promotion in progress');
await expect(exitCodePromise).resolves.toEqual(0);
});
it('should promote by deployment id', async () => {
const { cwd, previousDeployment } = initPromoteTest();
client.setArgv('promote', previousDeployment.id, '--yes', '--cwd', cwd);
const exitCodePromise = promote(client);
await expect(client.stderr).toOutput(
`Fetching deployment "${previousDeployment.id}" in ${previousDeployment.creator?.username}`
);
await expect(client.stderr).toOutput('Promote in progress');
await expect(client.stderr).toOutput(
`Success! ${chalk.bold('vercel-promote')} was promoted to ${
previousDeployment.url
} (${previousDeployment.id})`
);
await expect(exitCodePromise).resolves.toEqual(0);
});
it('should promote by deployment url', async () => {
const { cwd, previousDeployment } = initPromoteTest();
client.setArgv('promote', previousDeployment.url, '--yes', '--cwd', cwd);
const exitCodePromise = promote(client);
await expect(client.stderr).toOutput(
`Fetching deployment "${previousDeployment.url}" in ${previousDeployment.creator?.username}`
);
await expect(client.stderr).toOutput('Promote in progress');
await expect(client.stderr).toOutput(
`Success! ${chalk.bold('vercel-promote')} was promoted to ${
previousDeployment.url
} (${previousDeployment.id})`
);
await expect(exitCodePromise).resolves.toEqual(0);
});
it('should get status while promoting', async () => {
const { cwd, previousDeployment, project } = initPromoteTest({
promotePollCount: 10,
});
// start the promote
client.setArgv('promote', previousDeployment.id, '--yes', '--cwd', cwd);
promote(client);
// need to wait for the promote request to be accepted
await sleep(300);
// get the status
client.setArgv('promote', '--yes', '--cwd', cwd);
const exitCodePromise = promote(client);
await expect(client.stderr).toOutput(
`Checking promotion status of ${project.name}`
);
await expect(client.stderr).toOutput(
`Success! ${chalk.bold('vercel-promote')} was promoted to ${
previousDeployment.url
} (${previousDeployment.id})`
);
await expect(exitCodePromise).resolves.toEqual(0);
});
it('should error if promote request fails', async () => {
const { cwd, previousDeployment } = initPromoteTest({
promotePollCount: 10,
promoteStatusCode: 500,
});
client.setArgv('promote', previousDeployment.id, '--yes', '--cwd', cwd);
const exitCodePromise = promote(client);
await expect(client.stderr).toOutput(
`Fetching deployment "${previousDeployment.id}" in ${previousDeployment.creator?.username}`
);
// we need to wait a super long time because fetch will return on 500
await expect(client.stderr).toOutput('Response Error (500)', 20000);
await expect(exitCodePromise).resolves.toEqual(1);
});
it('should error if promote fails (no aliases)', async () => {
const { cwd, previousDeployment } = initPromoteTest({
promoteJobStatus: 'failed',
});
client.setArgv('promote', previousDeployment.id, '--yes', '--cwd', cwd);
const exitCodePromise = promote(client);
await expect(client.stderr).toOutput(
`Fetching deployment "${previousDeployment.id}" in ${previousDeployment.creator?.username}`
);
await expect(client.stderr).toOutput('Promote in progress');
await expect(client.stderr).toOutput(
`Error: Failed to remap all aliases to the requested deployment ${previousDeployment.url} (${previousDeployment.id})`
);
await expect(exitCodePromise).resolves.toEqual(1);
});
it('should error if promote fails (with aliases)', async () => {
const { cwd, previousDeployment } = initPromoteTest({
promoteAliases: [
{
alias: { alias: 'foo', deploymentId: 'foo_123' },
status: 'completed',
},
{
alias: { alias: 'bar', deploymentId: 'bar_123' },
status: 'failed',
},
],
promoteJobStatus: 'failed',
});
client.setArgv('promote', previousDeployment.id, '--yes', '--cwd', cwd);
const exitCodePromise = promote(client);
await expect(client.stderr).toOutput(
`Fetching deployment "${previousDeployment.id}" in ${previousDeployment.creator?.username}`
);
await expect(client.stderr).toOutput('Promote in progress');
await expect(client.stderr).toOutput(
`Error: Failed to remap all aliases to the requested deployment ${previousDeployment.url} (${previousDeployment.id})`
);
await expect(client.stderr).toOutput(
` ${chalk.green('completed')} foo (foo_123)`
);
await expect(client.stderr).toOutput(
` ${chalk.red('failed')} bar (bar_123)`
);
await expect(exitCodePromise).resolves.toEqual(1);
});
it('should error if deployment times out', async () => {
const { cwd, previousDeployment } = initPromoteTest({
promotePollCount: 10,
});
client.setArgv(
'promote',
previousDeployment.id,
'--yes',
'--cwd',
cwd,
'--timeout',
'1'
);
const exitCodePromise = promote(client);
await expect(client.stderr).toOutput(
`Fetching deployment "${previousDeployment.id}" in ${previousDeployment.creator?.username}`
);
await expect(client.stderr).toOutput('Promote in progress');
await expect(client.stderr).toOutput(
`The promotion exceeded its deadline - rerun ${chalk.bold(
`vercel promote ${previousDeployment.id}`
)} to try again`,
10000
);
await expect(exitCodePromise).resolves.toEqual(1);
});
it('should immediately exit after requesting promote', async () => {
const { cwd, previousDeployment } = initPromoteTest();
client.setArgv(
'promote',
previousDeployment.id,
'--yes',
'--cwd',
cwd,
'--timeout',
'0'
);
const exitCodePromise = promote(client);
await expect(client.stderr).toOutput(
`Fetching deployment "${previousDeployment.id}" in ${previousDeployment.creator?.username}`
);
await expect(client.stderr).toOutput(
`Successfully requested promote of ${chalk.bold('vercel-promote')} to ${
previousDeployment.url
} (${previousDeployment.id})`
);
await expect(exitCodePromise).resolves.toEqual(0);
});
it('should error if deployment belongs to different team', async () => {
const { cwd, previousDeployment } = initPromoteTest();
previousDeployment.team = {
id: 'abc',
name: 'abc',
slug: 'abc',
};
client.setArgv('promote', previousDeployment.id, '--yes', '--cwd', cwd);
const exitCodePromise = promote(client);
await expect(client.stderr).toOutput(
`Fetching deployment "${previousDeployment.id}" in ${previousDeployment.creator?.username}`
);
await expect(client.stderr).toOutput(
'Error: Deployment belongs to a different team'
);
await expect(exitCodePromise).resolves.toEqual(1);
});
});
type DeploymentAlias = {
alias: {
alias: string;
deploymentId: string;
};
status: string;
};
function initPromoteTest({
promoteAliases = [],
promoteJobStatus = 'succeeded',
promotePollCount = 2,
promoteStatusCode,
}: {
promoteAliases?: DeploymentAlias[];
promoteJobStatus?: LastAliasRequest['jobStatus'];
promotePollCount?: number;
promoteStatusCode?: number;
} = {}) {
const cwd = setupUnitFixture('commands/promote/simple-next-site');
const user = useUser();
useTeams('team_dummy');
const { project } = useProject({
...defaultProject,
id: 'vercel-promote',
name: 'vercel-promote',
});
const currentDeployment = useDeployment({ creator: user, project });
const previousDeployment = useDeployment({ creator: user, project });
let pollCounter = 0;
let lastAliasRequest: LastAliasRequest | null = null;
client.scenario.post(
'/:version/projects/:project/promote/:id',
(req: Request, res: Response) => {
if (promoteStatusCode === 500) {
res.statusCode = 500;
res.end('Server error');
return;
}
const { id } = req.params;
if (previousDeployment.id !== id) {
res.statusCode = 404;
res.json({
error: { code: 'not_found', message: 'Deployment not found', id },
});
return;
}
lastAliasRequest = {
fromDeploymentId: currentDeployment.id,
jobStatus: 'in-progress',
requestedAt: Date.now(),
toDeploymentId: id,
type: 'promote',
};
Object.defineProperty(project, 'lastAliasRequest', {
get(): LastAliasRequest | null {
if (
lastAliasRequest &&
promotePollCount !== undefined &&
pollCounter++ > promotePollCount
) {
lastAliasRequest.jobStatus = promoteJobStatus;
}
return lastAliasRequest;
},
set(value: LastAliasRequest | null) {
lastAliasRequest = value;
},
});
res.statusCode = 201;
res.end();
}
);
client.scenario.get(
'/:version/projects/:project/promote/aliases',
(req, res) => {
res.json({
aliases: promoteAliases,
pagination: null,
});
}
);
return {
cwd,
project,
currentDeployment,
previousDeployment,
};
}

View File

@@ -317,6 +317,8 @@ function initRollbackTest({
const currentDeployment = useDeployment({ creator: user });
const previousDeployment = useDeployment({ creator: user });
let pollCounter = 0;
let lastRollbackTarget: RollbackTarget | null = null;
client.scenario.post(
@@ -343,6 +345,23 @@ function initRollbackTest({
requestedAt: Date.now(),
toDeploymentId: id,
};
Object.defineProperty(project, 'lastRollbackTarget', {
get(): RollbackTarget | null {
if (
lastRollbackTarget &&
rollbackPollCount !== undefined &&
pollCounter++ > rollbackPollCount
) {
lastRollbackTarget.jobStatus = rollbackJobStatus;
}
return lastRollbackTarget;
},
set(value: RollbackTarget | null) {
lastRollbackTarget = value;
},
});
res.statusCode = 201;
res.end();
}