Files
vercel/packages/cli/test/dev/utils.js
Nathan Rajlich 3e7bcb2073 [cli] Remove a bunch of isCanary checks (#9806)
We no longer publish versions that include `-canary` in the package.json `version` field, so these are dead code. Just doing a little bit of cleanup.
2023-04-14 20:35:56 +00:00

618 lines
15 KiB
JavaScript

const fs = require('fs-extra');
const { join, resolve } = require('path');
const _execa = require('execa');
const fetch = require('node-fetch');
const retry = require('async-retry');
const { satisfies } = require('semver');
const stripAnsi = require('strip-ansi');
const {
fetchCachedToken,
} = require('../../../../test/lib/deployment/now-deploy');
const { spawnSync, execFileSync } = require('child_process');
jest.setTimeout(10 * 60 * 1000);
const isCI = !!process.env.CI;
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
let port = 3000;
const binaryPath = resolve(__dirname, `../../scripts/start.js`);
const fixture = name => join('test', 'dev', 'fixtures', name);
const fixtureAbsolute = name => join(__dirname, 'fixtures', name);
const exampleAbsolute = name =>
join(__dirname, '..', '..', '..', '..', 'examples', name);
let processCounter = 0;
const processList = new Map();
function execa(...args) {
const procId = ++processCounter;
const child = _execa(...args);
processList.set(procId, child);
child.on('close', () => processList.delete(procId));
return child;
}
function fetchWithRetry(url, opts = {}) {
return retry(
async () => {
const res = await fetch(url, opts);
if (res.status !== opts.status) {
const text = await res.text();
throw new Error(
`Failed to fetch ${url} with status ${res.status} (expected ${opts.status}):\n\n${text}\n\n`
);
}
return res;
},
{
retries: opts.retries ?? 3,
factor: 1,
}
);
}
function createResolver() {
let resolver;
let rejector;
const p = new Promise((resolve, reject) => {
resolver = resolve;
rejector = reject;
});
p.resolve = resolver;
p.reject = rejector;
return p;
}
function formatOutput({ stderr, stdout }) {
return `Received:\n"${stderr}"\n"${stdout}"`;
}
function printOutput(fixture, stdout, stderr) {
const lines = (
`\nOutput for "${fixture}"\n` +
`\n----- stdout -----\n` +
stdout +
`\n----- stderr -----\n` +
stderr
).split('\n');
const getPrefix = nr => {
return nr === 0 ? '╭' : nr === lines.length - 1 ? '╰' : '│';
};
console.log(
lines.map((line, index) => ` ${getPrefix(index)} ${line}`).join('\n')
);
}
function shouldSkip(name, versions) {
if (!satisfies(process.version, versions)) {
console.log(`Skipping "${name}" because it requires "${versions}".`);
return true;
}
return false;
}
function validateResponseHeaders(res, podId) {
if (res.status < 500) {
expect(res.headers.get('server')).toEqual('Vercel');
expect(res.headers.get('cache-control').length > 0).toBeTruthy();
expect(res.headers.get('x-vercel-id')).toBeTruthy();
if (podId) {
expect(
res.headers.get('x-vercel-id').includes(`::${podId}-`)
).toBeTruthy();
}
}
}
async function exec(directory, args = []) {
const token = await fetchCachedToken();
return execa(
binaryPath,
[
'dev',
directory,
'-t',
token,
...(process.env.VERCEL_TEAM_ID
? ['--scope', process.env.VERCEL_TEAM_ID]
: []),
...args,
],
{
reject: false,
shell: true,
env: { __VERCEL_SKIP_DEV_CMD: 1 },
}
);
}
async function runNpmInstall(fixturePath) {
if (await fs.pathExists(join(fixturePath, 'package.json'))) {
await execa('yarn', ['install'], {
cwd: fixturePath,
shell: true,
stdio: 'inherit',
});
}
}
async function testPath(
isDev,
origin,
status,
path,
expectedText,
expectedHeaders = {},
fetchOpts = {}
) {
const opts = {
retries: isCI ? 5 : 0,
...fetchOpts,
redirect: 'manual-dont-change',
status,
};
const url = `${origin}${path}`;
const res = await fetchWithRetry(url, opts);
const msg = `Testing response from ${fetchOpts.method || 'GET'} ${url}`;
console.log(msg);
expect(res.status).toBe(status);
validateResponseHeaders(res);
if (typeof expectedText === 'string') {
const actualText = await res.text();
expect(actualText.trim()).toBe(expectedText.trim());
} else if (typeof expectedText === 'function') {
const actualText = await res.text();
await expectedText(actualText, res, isDev);
} else if (expectedText instanceof RegExp) {
const actualText = await res.text();
expectedText.lastIndex = 0; // reset since we test twice
expect(actualText).toMatch(expectedText);
}
if (expectedHeaders) {
Object.entries(expectedHeaders).forEach(([key, expectedValue]) => {
let actualValue = res.headers.get(key);
if (key.toLowerCase() === 'location' && actualValue === '//') {
// HACK: `node-fetch` has strange behavior for location header so fix it
// with `manual-dont-change` opt and convert double slash to single.
// See https://github.com/node-fetch/node-fetch/issues/417#issuecomment-587233352
actualValue = '/';
}
expect(actualValue).toBe(expectedValue);
});
}
}
async function testFixture(directory, opts = {}, args = []) {
await runNpmInstall(directory);
const token = await fetchCachedToken();
const dev = execa(
binaryPath,
[
'dev',
directory,
'-t',
token,
...(process.env.VERCEL_TEAM_ID
? ['--scope', process.env.VERCEL_TEAM_ID]
: []),
'-l',
String(port),
...args,
],
{
reject: false,
shell: true,
stdio: 'pipe',
...opts,
env: { ...opts.env, __VERCEL_SKIP_DEV_CMD: 1 },
}
);
let stdout = '';
let stderr = '';
const readyResolver = createResolver();
const exitResolver = createResolver();
dev.stdout.setEncoding('utf8');
dev.stderr.setEncoding('utf8');
dev.stdout.on('data', data => {
stdout += data;
});
dev.stderr.on('data', data => {
stderr += data;
if (stripAnsi(stderr).includes('Ready! Available at')) {
readyResolver.resolve();
}
});
let printedOutput = false;
let devTimer = null;
dev.on('exit', code => {
devTimer = setTimeout(async () => {
const pids = Object.keys(await ps(dev.pid)).join(', ');
console.error(
`Test ${directory} exited with code ${code}, but has timed out closing stdio\n` +
(pids
? `Hanging child processes: ${pids}`
: `${dev.pid} already exited`)
);
}, 5000);
});
dev.on('close', () => {
clearTimeout(devTimer);
if (!printedOutput) {
printOutput(directory, stdout, stderr);
printedOutput = true;
}
exitResolver.resolve();
readyResolver.resolve();
});
dev.on('error', () => {
if (!printedOutput) {
printOutput(directory, stdout, stderr);
printedOutput = true;
}
exitResolver.resolve();
readyResolver.resolve();
});
dev._kill = dev.kill;
dev.kill = async () => {
// kill the entire process tree for the child as some tests will spawn
// child processes that either become defunct or assigned a new parent
// process
await nukeProcessTree(dev.pid);
await exitResolver;
return {
stdout,
stderr,
};
};
return {
dev,
port,
readyResolver,
};
}
function testFixtureStdio(
directory,
fn,
{
expectedCode = 0,
skipDeploy,
isExample,
projectSettings,
readyTimeout = 0,
} = {}
) {
return async () => {
const nodeMajor = Number(process.versions.node.split('.')[0]);
if (isExample && nodeMajor < 12) {
console.log(`Skipping ${directory} on Node ${process.version}`);
return;
}
const cwd = isExample
? exampleAbsolute(directory)
: fixtureAbsolute(directory);
const token = await fetchCachedToken();
let deploymentUrl;
// Deploy fixture and link project
if (!skipDeploy) {
const projectJsonPath = join(cwd, '.vercel', 'project.json');
await fs.remove(projectJsonPath);
const gitignore = join(cwd, '.gitignore');
const hasGitignore = await fs.pathExists(gitignore);
try {
// Run `vc link`
const linkResult = await execa(
binaryPath,
[
'-t',
token,
...(process.env.VERCEL_TEAM_ID
? ['--scope', process.env.VERCEL_TEAM_ID]
: []),
'link',
'--yes',
],
{ cwd, stdio: 'pipe', reject: false }
);
console.log({
stderr: linkResult.stderr,
stdout: linkResult.stdout,
});
expect(linkResult.exitCode).toBe(0);
// Patch the project with any non-default properties
if (projectSettings) {
const { projectId } = await fs.readJson(projectJsonPath);
const res = await fetchWithRetry(
`https://api.vercel.com/v2/projects/${projectId}${
process.env.VERCEL_TEAM_ID
? `?teamId=${process.env.VERCEL_TEAM_ID}`
: ''
}`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(projectSettings),
retries: isCI ? 3 : 0,
status: 200,
}
);
expect(res.status).toBe(200);
}
// Run `vc deploy`
let deployResult = await execa(
binaryPath,
[
'-t',
token,
...(process.env.VERCEL_TEAM_ID
? ['--scope', process.env.VERCEL_TEAM_ID]
: []),
'deploy',
...(process.env.VERCEL_CLI_VERSION
? [
'--build-env',
`VERCEL_CLI_VERSION=${process.env.VERCEL_CLI_VERSION}`,
]
: []),
'--public',
'--debug',
],
{ cwd, stdio: 'pipe', reject: false }
);
console.log({
exitCode: deployResult.exitCode,
stdout: deployResult.stdout,
stderr: deployResult.stderr,
});
expect(deployResult.exitCode).toBe(expectedCode);
if (expectedCode === 0) {
deploymentUrl = new URL(deployResult.stdout).host;
}
} finally {
if (!hasGitignore) {
await fs.remove(gitignore);
}
}
}
// Start dev
let dev;
await runNpmInstall(cwd);
let stdout = '';
let stderr = '';
const readyResolver = createResolver();
const exitResolver = createResolver();
// By default, tests will wait 6 minutes for the dev server to be ready and
// perform the tests, however a `readyTimeout` can be used to reduce the
// wait time if the dev server is expected to fail to start or hang
let readyTimer = null;
if (readyTimeout > 0) {
readyTimer = setTimeout(() => {
readyResolver.reject(
new Error('Dev server timed out while waiting to be ready')
);
}, readyTimeout);
}
try {
let printedOutput = false;
const env = skipDeploy
? { ...process.env, __VERCEL_SKIP_DEV_CMD: 1 }
: process.env;
dev = execa(
binaryPath,
[
'dev',
'-l',
port,
'-t',
token,
...(process.env.VERCEL_TEAM_ID
? ['--scope', process.env.VERCEL_TEAM_ID]
: []),
'--debug',
],
{
cwd,
env,
}
);
dev.stdout.setEncoding('utf8');
dev.stderr.setEncoding('utf8');
dev.stdout.pipe(process.stdout);
dev.stderr.pipe(process.stderr);
dev.stdout.on('data', data => {
stdout += data;
});
dev.stderr.on('data', async data => {
stderr += data;
if (stripAnsi(data).includes('Ready! Available at')) {
clearTimeout(readyTimer);
readyResolver.resolve();
}
if (stderr.includes(`Requested port ${port} is already in use`)) {
await nukeProcessTree(dev.pid);
throw new Error(
`Failed for "${directory}" with port ${port} with stderr "${stderr}".`
);
}
if (stderr.includes('Command failed')) {
await nukeProcessTree(dev.pid);
throw new Error(`Failed for "${directory}" with stderr "${stderr}".`);
}
});
dev.on('close', () => {
if (!printedOutput) {
printOutput(directory, stdout, stderr);
printedOutput = true;
}
exitResolver.resolve();
});
dev.on('error', () => {
if (!printedOutput) {
printOutput(directory, stdout, stderr);
printedOutput = true;
}
exitResolver.resolve();
});
await readyResolver;
const helperTestPath = async (...args) => {
if (!skipDeploy) {
await testPath(false, `https://${deploymentUrl}`, ...args);
}
await testPath(true, `http://localhost:${port}`, ...args);
};
await fn(helperTestPath, port);
} finally {
await nukeProcessTree(dev.pid);
await exitResolver;
}
};
}
async function ps(parentPid, pids = {}) {
const cmd =
process.platform === 'darwin'
? ['pgrep', '-P', parentPid]
: ['ps', '-o', 'pid', '--no-headers', '--ppid', parentPid];
try {
const buf = execFileSync(cmd[0], cmd.slice(1), {
encoding: 'utf-8',
});
for (let pid of buf.match(/\d+/g)) {
pid = parseInt(pid);
const recurse = Object.prototype.hasOwnProperty.call(pids, pid);
pids[parentPid].push(pid);
pids[pid] = [];
if (recurse) {
await ps(pid, pids);
}
}
} catch (e) {
console.log(`Failed to get processes: ${e.toString()}`);
}
return pids;
}
async function nukePID(pid, signal = 'SIGTERM', retries = 10) {
if (retries === 0) {
console.log(`pid ${pid} won't die, giving up`);
return;
}
// kill the process
try {
process.kill(pid, signal);
} catch (e) {
// process does not exist
console.log(`pid ${pid} is not running`);
return;
}
await sleep(250);
try {
// check if killed
process.kill(pid, 0);
} catch (e) {
console.log(`pid ${pid} is not running`);
return;
}
console.log(`pid ${pid} didn't exit, sending SIGKILL (retries ${retries})`);
await nukePID(pid, 'SIGKILL', retries - 1);
}
async function nukeProcessTree(pid, signal) {
if (process.platform === 'win32') {
spawnSync('taskkill', ['/pid', pid, '/T', '/F'], { stdio: 'inherit' });
return;
}
const pids = await ps(pid, {
[pid]: [],
});
console.log(`Nuking pids: ${Object.keys(pids).join(', ')}`);
await Promise.all(Object.keys(pids).map(pid => nukePID(pid, signal)));
}
beforeEach(() => {
port = ++port;
});
afterEach(async () => {
await Promise.all(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Array.from(processList).map(async ([_procId, proc]) => {
console.log(`killing process ${proc.pid} "${proc.spawnargs.join(' ')}"`);
try {
await nukeProcessTree(proc.pid);
} catch (err) {
// Was already killed
if (err.code !== 'ESRCH') {
console.error('Failed to kill process', proc.pid, err);
}
}
})
);
});
module.exports = {
sleep,
testPath,
testFixture,
testFixtureStdio,
exec,
formatOutput,
shouldSkip,
fixture,
fetch,
fetchWithRetry,
validateResponseHeaders,
};