feat: v4.0.0 (#1016)

Co-authored-by: Denys Konovalov <kontakt@denyskon.de>
Co-authored-by: Mathieu COSYNS <64072917+Mathieu-COSYNS@users.noreply.github.com>
This commit is contained in:
Daniel Lautzenheiser
2024-01-03 15:14:09 -05:00
committed by GitHub
parent 682576ffc4
commit 799c7e6936
732 changed files with 48477 additions and 10886 deletions

View File

@ -0,0 +1,310 @@
const fs = require('fs-extra');
const fetch = require('node-fetch');
const path = require('path');
const { updateConfig } = require('../utils/config');
const { escapeRegExp } = require('../utils/regexp');
const {
getExpectationsFilename,
transformRecordedData: transformData,
getGitClient,
} = require('./common');
const { merge } = require('lodash');
const BITBUCKET_REPO_OWNER_SANITIZED_VALUE = 'owner';
const BITBUCKET_REPO_NAME_SANITIZED_VALUE = 'repo';
const BITBUCKET_REPO_TOKEN_SANITIZED_VALUE = 'fakeToken';
const FAKE_OWNER_USER = {
name: 'owner',
display_name: 'owner',
links: {
avatar: {
href: 'https://avatars1.githubusercontent.com/u/7892489?v=4',
},
},
nickname: 'owner',
};
async function getEnvs() {
const {
BITBUCKET_REPO_OWNER: owner,
BITBUCKET_REPO_NAME: repo,
BITBUCKET_OUATH_CONSUMER_KEY: consumerKey,
BITBUCKET_OUATH_CONSUMER_SECRET: consumerSecret,
} = process.env;
if (!owner || !repo || !consumerKey || !consumerSecret) {
throw new Error(
'Please set BITBUCKET_REPO_OWNER, BITBUCKET_REPO_NAME, BITBUCKET_OUATH_CONSUMER_KEY, BITBUCKET_OUATH_CONSUMER_SECRET environment variables',
);
}
const params = new URLSearchParams();
params.append('grant_type', 'client_credentials');
const { access_token: token } = await fetch(
`https://${consumerKey}:${consumerSecret}@bitbucket.org/site/oauth2/access_token`,
{ method: 'POST', body: params },
).then(r => r.json());
return { owner, repo, token };
}
const API_URL = 'https://api.bitbucket.org/2.0/';
function get(token, path) {
return fetch(`${API_URL}${path}`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
}).then(r => r.json());
}
function post(token, path, body) {
return fetch(`${API_URL}${path}`, {
method: 'POST',
...(body ? { body } : {}),
headers: {
'Content-Type': 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
Authorization: `Bearer ${token}`,
},
});
}
function del(token, path) {
return fetch(`${API_URL}${path}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
});
}
async function prepareTestBitBucketRepo({ lfs }) {
const { owner, repo, token } = await getEnvs();
// postfix a random string to avoid collisions
const postfix = Math.random().toString(32).slice(2);
const testRepoName = `${repo}-${Date.now()}-${postfix}`;
console.info('Creating repository', testRepoName, token);
const response = await post(
token,
`repositories/${owner}/${testRepoName}`,
JSON.stringify({ scm: 'git' }),
);
if (!response.ok) {
throw new Error(`Unable to create repository. ${response.statusText}`);
}
const tempDir = path.join('.temp', testRepoName);
await fs.remove(tempDir);
let git = getGitClient();
const repoUrl = `git@bitbucket.org:${owner}/${repo}.git`;
console.info('Cloning repository', repoUrl);
await git.clone(repoUrl, tempDir);
git = getGitClient(tempDir);
console.info('Pushing to new repository', testRepoName);
console.info('Updating remote...');
await git.removeRemote('origin');
await git.addRemote('origin', `git@bitbucket.org:${owner}/${testRepoName}`);
console.info('Pushing...');
await git.push(['-u', 'origin', 'main']);
console.info('Pushed to new repository', testRepoName);
if (lfs) {
console.info(`Enabling LFS for repo ${owner}/${repo}`);
await git.addConfig('commit.gpgsign', 'false');
await git.raw(['lfs', 'track', '*.png', '*.jpg']);
await git.add('.gitattributes');
await git.commit('chore: track images files under LFS');
await git.push('origin', 'main');
}
return { owner, repo: testRepoName, tempDir };
}
async function getUser() {
const { token } = await getEnvs();
const user = await get(token, 'user');
return { ...user, token, backendName: 'bitbucket' };
}
async function deleteRepositories({ owner, repo, tempDir }) {
const { token } = await getEnvs();
console.info('Deleting repository', `${owner}/${repo}`);
await fs.remove(tempDir);
await del(token, `repositories/${owner}/${repo}`);
}
async function resetOriginRepo({ owner, repo, tempDir }) {
console.info('Resetting origin repo:', `${owner}/${repo}`);
const { token } = await getEnvs();
const pullRequests = await get(token, `repositories/${owner}/${repo}/pullrequests`);
const ids = pullRequests.values.map(mr => mr.id);
console.info('Closing pull requests:', ids);
await Promise.all(
ids.map(id => post(token, `repositories/${owner}/${repo}/pullrequests/${id}/decline`)),
);
const branches = await get(token, `repositories/${owner}/${repo}/refs/branches`);
const toDelete = branches.values.filter(b => b.name !== 'main').map(b => b.name);
console.info('Deleting branches', toDelete);
await Promise.all(
toDelete.map(branch => del(token, `repositories/${owner}/${repo}/refs/branches/${branch}`)),
);
console.info('Resetting main');
const git = getGitClient(tempDir);
await git.push(['--force', 'origin', 'main']);
console.info('Done resetting origin repo:', `${owner}/${repo}`);
}
async function resetRepositories({ owner, repo, tempDir }) {
await resetOriginRepo({ owner, repo, tempDir });
}
async function setupBitBucket(options) {
const { lfs = false, ...rest } = options;
console.info('Running tests - live data will be used!');
const [user, repoData] = await Promise.all([getUser(), prepareTestBitBucketRepo({ lfs })]);
console.info('Updating config...');
await updateConfig(config => {
merge(config, rest, {
backend: {
repo: `${repoData.owner}/${repoData.repo}`,
},
});
});
return { ...repoData, user };
}
async function teardownBitBucket(taskData) {
await deleteRepositories(taskData);
return null;
}
async function setupBitBucketTest(taskData) {
await resetRepositories(taskData);
return null;
}
const sanitizeString = (str, { owner, repo, token, ownerName }) => {
let replaced = str
.replace(new RegExp(escapeRegExp(owner), 'g'), BITBUCKET_REPO_OWNER_SANITIZED_VALUE)
.replace(new RegExp(escapeRegExp(repo), 'g'), BITBUCKET_REPO_NAME_SANITIZED_VALUE)
.replace(new RegExp(escapeRegExp(token), 'g'), BITBUCKET_REPO_TOKEN_SANITIZED_VALUE)
.replace(
new RegExp('https://secure.gravatar.+?/u/.+?v=\\d', 'g'),
`${FAKE_OWNER_USER.links.avatar.href}`,
)
.replace(new RegExp(/\?token=.+?&/g), 'token=fakeToken&')
.replace(new RegExp(/&client=.+?&/g), 'client=fakeClient&');
if (ownerName) {
replaced = replaced.replace(
new RegExp(escapeRegExp(ownerName), 'g'),
FAKE_OWNER_USER.display_name,
);
}
return replaced;
};
const transformRecordedData = (expectation, toSanitize) => {
const requestBodySanitizer = httpRequest => {
let body;
if (httpRequest.body && httpRequest.body.type === 'JSON' && httpRequest.body.json) {
const bodyObject = JSON.parse(httpRequest.body.json);
if (bodyObject.encoding === 'base64') {
// sanitize encoded data
const decodedBody = Buffer.from(bodyObject.content, 'base64').toString('binary');
const sanitizedContent = sanitizeString(decodedBody, toSanitize);
const sanitizedEncodedContent = Buffer.from(sanitizedContent, 'binary').toString('base64');
bodyObject.content = sanitizedEncodedContent;
body = JSON.stringify(bodyObject);
} else {
body = httpRequest.body.json;
}
} else if (httpRequest.body && httpRequest.body.type === 'STRING' && httpRequest.body.string) {
body = httpRequest.body.string;
} else if (
httpRequest.body &&
httpRequest.body.type === 'BINARY' &&
httpRequest.body.base64Bytes
) {
body = {
encoding: 'base64',
content: httpRequest.body.base64Bytes,
contentType: httpRequest.body.contentType,
};
}
return body;
};
const responseBodySanitizer = (httpRequest, httpResponse) => {
let responseBody = null;
if (httpResponse.body && httpResponse.body.string) {
responseBody = httpResponse.body.string;
} else if (
httpResponse.body &&
httpResponse.body.type === 'BINARY' &&
httpResponse.body.base64Bytes
) {
responseBody = {
encoding: 'base64',
content: httpResponse.body.base64Bytes,
};
} else if (httpResponse.body) {
if (httpResponse.body && httpResponse.body.type === 'JSON' && httpResponse.body.json) {
responseBody = JSON.stringify(httpResponse.body.json);
} else {
responseBody = httpResponse.body;
}
}
// replace recorded user with fake one
if (
responseBody &&
httpRequest.path === '/2.0/user' &&
httpRequest.headers.host.includes('api.bitbucket.org')
) {
responseBody = JSON.stringify(FAKE_OWNER_USER);
}
return responseBody;
};
return transformData(expectation, requestBodySanitizer, responseBodySanitizer);
};
async function teardownBitBucketTest(taskData) {
await resetRepositories(taskData);
return null;
}
module.exports = {
setupBitBucket,
teardownBitBucket,
setupBitBucketTest,
teardownBitBucketTest,
};

80
cypress/plugins/common.js Normal file
View File

@ -0,0 +1,80 @@
const path = require('path');
const { default: simpleGit } = require('simple-git');
const GIT_SSH_COMMAND = 'ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no';
const GIT_SSL_NO_VERIFY = true;
const getExpectationsFilename = taskData => {
const { spec, testName } = taskData;
const basename = `${spec}__${testName}`;
const fixtures = path.join(__dirname, '..', 'fixtures');
const filename = path.join(fixtures, `${basename}.json`);
return filename;
};
const HEADERS_TO_IGNORE = [
'Date',
'X-RateLimit-Remaining',
'X-RateLimit-Reset',
'ETag',
'Last-Modified',
'X-GitHub-Request-Id',
'X-NF-Request-ID',
'X-Request-Id',
'X-Runtime',
'RateLimit-Limit',
'RateLimit-Observed',
'RateLimit-Remaining',
'RateLimit-Reset',
'RateLimit-ResetTime',
'GitLab-LB',
].map(h => h.toLowerCase());
const transformRecordedData = (expectation, requestBodySanitizer, responseBodySanitizer) => {
const { httpRequest, httpResponse } = expectation;
const responseHeaders = {
'Content-Type': 'application/json; charset=utf-8',
};
Object.keys(httpResponse.headers)
.filter(key => !HEADERS_TO_IGNORE.includes(key.toLowerCase()))
.forEach(key => {
responseHeaders[key] = httpResponse.headers[key][0];
});
let queryString;
if (httpRequest.queryStringParameters) {
const { queryStringParameters } = httpRequest;
queryString = Object.keys(queryStringParameters)
.map(key => `${key}=${queryStringParameters[key]}`)
.join('&');
}
const body = requestBodySanitizer(httpRequest);
const responseBody = responseBodySanitizer(httpRequest, httpResponse);
const cypressRouteOptions = {
body,
method: httpRequest.method,
url: queryString ? `${httpRequest.path}?${queryString}` : httpRequest.path,
headers: responseHeaders,
response: responseBody,
status: httpResponse.statusCode,
};
return cypressRouteOptions;
};
function getGitClient(repoDir) {
const git = simpleGit(repoDir).env({ ...process.env, GIT_SSH_COMMAND, GIT_SSL_NO_VERIFY });
return git;
}
module.exports = {
getExpectationsFilename,
transformRecordedData,
getGitClient,
};

View File

@ -0,0 +1,284 @@
const fetch = require('node-fetch');
const {
transformRecordedData: transformGitHub,
setupGitHub,
teardownGitHub,
setupGitHubTest,
teardownGitHubTest,
} = require('./github');
const {
transformRecordedData: transformGitLab,
setupGitLab,
teardownGitLab,
setupGitLabTest,
teardownGitLabTest,
} = require('./gitlab');
const { getGitClient } = require('./common');
function getEnvs() {
const {
NETLIFY_API_TOKEN: netlifyApiToken,
GITHUB_REPO_TOKEN: githubToken,
GITLAB_REPO_TOKEN: gitlabToken,
NETLIFY_INSTALLATION_ID: installationId,
} = process.env;
if (!netlifyApiToken) {
throw new Error(
'Please set NETLIFY_API_TOKEN, GITHUB_REPO_TOKEN, GITLAB_REPO_TOKEN, NETLIFY_INSTALLATION_ID environment variables',
);
}
return { netlifyApiToken, githubToken, gitlabToken, installationId };
}
const apiRoot = 'https://api.netlify.com/api/v1/';
async function get(netlifyApiToken, path) {
const response = await fetch(`${apiRoot}${path}`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${netlifyApiToken}`,
},
}).then(res => res.json());
return response;
}
async function post(netlifyApiToken, path, payload) {
const response = await fetch(`${apiRoot}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${netlifyApiToken}`,
},
...(payload ? { body: JSON.stringify(payload) } : {}),
}).then(res => res.json());
return response;
}
async function del(netlifyApiToken, path) {
const response = await fetch(`${apiRoot}${path}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${netlifyApiToken}`,
},
}).then(res => res.text());
return response;
}
async function createSite(netlifyApiToken, payload) {
return post(netlifyApiToken, 'sites', payload);
}
async function enableIdentity(netlifyApiToken, siteId) {
return post(netlifyApiToken, `sites/${siteId}/identity`, {});
}
async function enableGitGateway(netlifyApiToken, siteId, provider, token, repo) {
return post(netlifyApiToken, `sites/${siteId}/services/git/instances`, {
[provider]: {
repo,
access_token: token,
},
});
}
async function enableLargeMedia(netlifyApiToken, siteId) {
return post(netlifyApiToken, `sites/${siteId}/services/large-media/instances`, {});
}
async function waitForDeploys(netlifyApiToken, siteId) {
for (let i = 0; i < 10; i++) {
const deploys = await get(netlifyApiToken, `sites/${siteId}/deploys`);
if (deploys.some(deploy => deploy.state === 'ready')) {
console.info('Deploy finished for site:', siteId);
return;
}
console.info('Waiting on deploy of site:', siteId);
await new Promise(resolve => setTimeout(resolve, 30 * 1000));
}
console.info('Timed out waiting on deploy of site:', siteId);
}
async function createUser(netlifyApiToken, siteUrl, email, password) {
const response = await fetch(`${siteUrl}/.netlify/functions/create-user`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${netlifyApiToken}`,
},
body: JSON.stringify({ email, password }),
});
if (response.ok) {
console.info('User created successfully');
} else {
throw new Error('Failed to create user');
}
}
const netlifySiteURL = 'https://fake-site-url.netlify.com/';
const email = 'static@p-m.si';
const password = '12345678';
const backendName = 'git-gateway';
const methods = {
github: {
setup: setupGitHub,
teardown: teardownGitHub,
setupTest: setupGitHubTest,
teardownTest: teardownGitHubTest,
transformData: transformGitHub,
createSite: (netlifyApiToken, result) => {
const { installationId } = getEnvs();
return createSite(netlifyApiToken, {
repo: {
provider: 'github',
installation_id: installationId,
repo: `${result.owner}/${result.repo}`,
},
});
},
token: () => getEnvs().githubToken,
},
gitlab: {
setup: setupGitLab,
teardown: teardownGitLab,
setupTest: setupGitLabTest,
teardownTest: teardownGitLabTest,
transformData: transformGitLab,
createSite: async (netlifyApiToken, result) => {
const { id, public_key } = await post(netlifyApiToken, 'deploy_keys');
const { gitlabToken } = getEnvs();
const project = `${result.owner}/${result.repo}`;
await fetch(`https://gitlab.com/api/v4/projects/${encodeURIComponent(project)}/deploy_keys`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${gitlabToken}`,
},
body: JSON.stringify({ title: 'Netlify Deploy Key', key: public_key, can_push: false }),
}).then(res => res.json());
const site = await createSite(netlifyApiToken, {
account_slug: result.owner,
repo: {
provider: 'gitlab',
repo: `${result.owner}/${result.repo}`,
deploy_key_id: id,
},
});
await fetch(`https://gitlab.com/api/v4/projects/${encodeURIComponent(project)}/hooks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${gitlabToken}`,
},
body: JSON.stringify({
url: 'https://api.netlify.com/hooks/gitlab',
push_events: true,
merge_requests_events: true,
enable_ssl_verification: true,
}),
}).then(res => res.json());
return site;
},
token: () => getEnvs().gitlabToken,
},
};
async function setupGitGateway(options) {
const { provider, ...rest } = options;
const result = await methods[provider].setup(rest);
const { netlifyApiToken } = getEnvs();
console.info(`Creating Netlify Site for provider: ${provider}`);
let site_id, ssl_url;
try {
({ site_id, ssl_url } = await methods[provider].createSite(netlifyApiToken, result));
} catch (e) {
console.error(e);
throw e;
}
console.info('Enabling identity for site:', site_id);
await enableIdentity(netlifyApiToken, site_id);
console.info('Enabling git gateway for site:', site_id);
const token = methods[provider].token();
await enableGitGateway(
netlifyApiToken,
site_id,
provider,
token,
`${result.owner}/${result.repo}`,
);
console.info('Enabling large media for site:', site_id);
await enableLargeMedia(netlifyApiToken, site_id);
const git = getGitClient(result.tempDir);
await git.raw([
'config',
'-f',
'.lfsconfig',
'lfs.url',
`https://${site_id}.netlify.com/.netlify/large-media`,
]);
await git.addConfig('commit.gpgsign', 'false');
await git.add('.lfsconfig');
await git.commit('add .lfsconfig');
await git.push('origin', 'main');
await waitForDeploys(netlifyApiToken, site_id);
console.info('Creating user for site:', site_id, 'with email:', email);
try {
await createUser(netlifyApiToken, ssl_url, email, password);
} catch (e) {
console.error(e);
}
return {
...result,
user: {
...result.user,
backendName,
netlifySiteURL: ssl_url,
email,
password,
},
site_id,
ssl_url,
provider,
};
}
async function teardownGitGateway(taskData) {
const { netlifyApiToken } = getEnvs();
const { site_id } = taskData;
console.info('Deleting Netlify site:', site_id);
await del(netlifyApiToken, `sites/${site_id}`);
return methods[taskData.provider].teardown(taskData);
}
async function setupGitGatewayTest(taskData) {
return methods[taskData.provider].setupTest(taskData);
}
async function teardownGitGatewayTest(taskData) {
return methods[taskData.provider].teardownTest(taskData, {});
}
module.exports = {
setupGitGateway,
teardownGitGateway,
setupGitGatewayTest,
teardownGitGatewayTest,
};

426
cypress/plugins/github.js Normal file
View File

@ -0,0 +1,426 @@
const { Octokit } = require('@octokit/rest');
const fs = require('fs-extra');
const path = require('path');
const {
getExpectationsFilename,
transformRecordedData: transformData,
getGitClient,
} = require('./common');
const { updateConfig } = require('../utils/config');
const { escapeRegExp } = require('../utils/regexp');
const { merge } = require('lodash');
const GITHUB_REPO_OWNER_SANITIZED_VALUE = 'owner';
const GITHUB_REPO_NAME_SANITIZED_VALUE = 'repo';
const GITHUB_REPO_TOKEN_SANITIZED_VALUE = 'fakeToken';
const GITHUB_OPEN_AUTHORING_OWNER_SANITIZED_VALUE = 'forkOwner';
const GITHUB_OPEN_AUTHORING_TOKEN_SANITIZED_VALUE = 'fakeForkToken';
const FAKE_OWNER_USER = {
login: 'owner',
id: 1,
avatar_url: 'https://avatars1.githubusercontent.com/u/7892489?v=4',
name: 'owner',
};
const FAKE_FORK_OWNER_USER = {
login: 'forkOwner',
id: 2,
avatar_url: 'https://avatars1.githubusercontent.com/u/9919?s=200&v=4',
name: 'forkOwner',
};
function getGitHubClient(token) {
const client = new Octokit({
auth: `token ${token}`,
baseUrl: 'https://api.github.com',
});
return client;
}
function getEnvs() {
const {
GITHUB_REPO_OWNER: owner,
GITHUB_REPO_NAME: repo,
GITHUB_REPO_TOKEN: token,
GITHUB_OPEN_AUTHORING_OWNER: forkOwner,
GITHUB_OPEN_AUTHORING_TOKEN: forkToken,
} = process.env;
if (!owner || !repo || !token || !forkOwner || !forkToken) {
throw new Error(
'Please set GITHUB_REPO_OWNER, GITHUB_REPO_NAME, GITHUB_REPO_TOKEN, GITHUB_OPEN_AUTHORING_OWNER, GITHUB_OPEN_AUTHORING_TOKEN environment variables',
);
}
return { owner, repo, token, forkOwner, forkToken };
}
async function prepareTestGitHubRepo() {
const { owner, repo, token } = getEnvs();
// postfix a random string to avoid collisions
const postfix = Math.random().toString(32).slice(2);
const testRepoName = `${repo}-${Date.now()}-${postfix}`;
const client = getGitHubClient(token);
console.info('Creating repository', testRepoName);
await client.repos.createForAuthenticatedUser({
name: testRepoName,
});
const tempDir = path.join('.temp', testRepoName);
await fs.remove(tempDir);
let git = getGitClient();
const repoUrl = `git@github.com:${owner}/${repo}.git`;
console.info('Cloning repository', repoUrl);
await git.clone(repoUrl, tempDir);
git = getGitClient(tempDir);
console.info('Pushing to new repository', testRepoName);
await git.removeRemote('origin');
await git.addRemote(
'origin',
`https://${token}:x-oauth-basic@github.com/${owner}/${testRepoName}`,
);
await git.push(['-u', 'origin', 'main']);
return { owner, repo: testRepoName, tempDir };
}
async function getAuthenticatedUser(token) {
const client = getGitHubClient(token);
const { data: user } = await client.users.getAuthenticated();
return { ...user, token, backendName: 'github' };
}
async function getUser() {
const { token } = getEnvs();
return getAuthenticatedUser(token);
}
async function getForkUser() {
const { forkToken } = getEnvs();
return getAuthenticatedUser(forkToken);
}
async function deleteRepositories({ owner, repo, tempDir }) {
const { forkOwner, token, forkToken } = getEnvs();
const errorHandler = e => {
if (e.status !== 404) {
throw e;
}
};
console.info('Deleting repository', `${owner}/${repo}`);
await fs.remove(tempDir);
let client = getGitHubClient(token);
await client.repos
.delete({
owner,
repo,
})
.catch(errorHandler);
console.info('Deleting forked repository', `${forkOwner}/${repo}`);
client = getGitHubClient(forkToken);
await client.repos
.delete({
owner: forkOwner,
repo,
})
.catch(errorHandler);
}
async function batchRequests(items, batchSize, func) {
while (items.length > 0) {
const batch = items.splice(0, batchSize);
await Promise.all(batch.map(func));
await new Promise(resolve => setTimeout(resolve, 2500));
}
}
async function resetOriginRepo({ owner, repo, tempDir }) {
console.info('Resetting origin repo:', `${owner}/${repo}`);
const { token } = getEnvs();
const client = getGitHubClient(token);
const { data: prs } = await client.pulls.list({
repo,
owner,
state: 'open',
});
const numbers = prs.map(pr => pr.number);
console.info('Closing prs:', numbers);
await batchRequests(numbers, 10, async pull_number => {
await client.pulls.update({
owner,
repo,
pull_number,
state: 'closed',
});
});
const { data: branches } = await client.repos.listBranches({ owner, repo });
const refs = branches.filter(b => b.name !== 'main').map(b => `heads/${b.name}`);
console.info('Deleting refs', refs);
await batchRequests(refs, 10, async ref => {
await client.git.deleteRef({
owner,
repo,
ref,
});
});
console.info('Resetting main');
const git = getGitClient(tempDir);
await git.push(['--force', 'origin', 'main']);
console.info('Done resetting origin repo:', `${owner}/${repo}`);
}
async function resetForkedRepo({ repo }) {
const { forkToken, forkOwner } = getEnvs();
const client = getGitHubClient(forkToken);
const { data: repos } = await client.repos.list();
if (repos.some(r => r.name === repo)) {
console.info('Resetting forked repo:', `${forkOwner}/${repo}`);
const { data: branches } = await client.repos.listBranches({ owner: forkOwner, repo });
const refs = branches.filter(b => b.name !== 'main').map(b => `heads/${b.name}`);
console.info('Deleting refs', refs);
await Promise.all(
refs.map(ref =>
client.git.deleteRef({
owner: forkOwner,
repo,
ref,
}),
),
);
console.info('Done resetting forked repo:', `${forkOwner}/${repo}`);
}
}
async function resetRepositories({ owner, repo, tempDir }) {
await resetOriginRepo({ owner, repo, tempDir });
await resetForkedRepo({ repo });
}
async function setupGitHub(options) {
console.info('Running tests - live data will be used!');
const [user, forkUser, repoData] = await Promise.all([
getUser(),
getForkUser(),
prepareTestGitHubRepo(),
]);
await updateConfig(config => {
merge(config, options, {
backend: {
repo: `${repoData.owner}/${repoData.repo}`,
},
});
});
return { ...repoData, user, forkUser };
}
async function teardownGitHub(taskData) {
await deleteRepositories(taskData);
return null;
}
async function setupGitHubTest(taskData) {
await resetRepositories(taskData);
return null;
}
const sanitizeString = (
str,
{ owner, repo, token, forkOwner, forkToken, ownerName, forkOwnerName },
) => {
let replaced = str
.replace(new RegExp(escapeRegExp(forkOwner), 'g'), GITHUB_OPEN_AUTHORING_OWNER_SANITIZED_VALUE)
.replace(new RegExp(escapeRegExp(forkToken), 'g'), GITHUB_OPEN_AUTHORING_TOKEN_SANITIZED_VALUE)
.replace(new RegExp(escapeRegExp(owner), 'g'), GITHUB_REPO_OWNER_SANITIZED_VALUE)
.replace(new RegExp(escapeRegExp(repo), 'g'), GITHUB_REPO_NAME_SANITIZED_VALUE)
.replace(new RegExp(escapeRegExp(token), 'g'), GITHUB_REPO_TOKEN_SANITIZED_VALUE)
.replace(
new RegExp('https://avatars\\d+\\.githubusercontent\\.com/u/\\d+?\\?v=\\d', 'g'),
`${FAKE_OWNER_USER.avatar_url}`,
);
if (ownerName) {
replaced = replaced.replace(new RegExp(escapeRegExp(ownerName), 'g'), FAKE_OWNER_USER.name);
}
if (forkOwnerName) {
replaced = replaced.replace(
new RegExp(escapeRegExp(forkOwnerName), 'g'),
FAKE_FORK_OWNER_USER.name,
);
}
return replaced;
};
const transformRecordedData = (expectation, toSanitize) => {
const requestBodySanitizer = httpRequest => {
let body;
if (httpRequest.body && httpRequest.body.type === 'JSON' && httpRequest.body.json) {
const bodyObject =
typeof httpRequest.body.json === 'string'
? JSON.parse(httpRequest.body.json)
: httpRequest.body.json;
if (bodyObject.encoding === 'base64') {
// sanitize encoded data
const decodedBody = Buffer.from(bodyObject.content, 'base64').toString('binary');
const sanitizedContent = sanitizeString(decodedBody, toSanitize);
const sanitizedEncodedContent = Buffer.from(sanitizedContent, 'binary').toString('base64');
bodyObject.content = sanitizedEncodedContent;
body = JSON.stringify(bodyObject);
} else {
body = JSON.stringify(bodyObject);
}
} else if (httpRequest.body && httpRequest.body.type === 'STRING' && httpRequest.body.string) {
body = httpRequest.body.string;
} else if (httpRequest.body) {
const str =
typeof httpRequest.body !== 'string' ? JSON.stringify(httpRequest.body) : httpRequest.body;
body = sanitizeString(str, toSanitize);
}
return body;
};
const responseBodySanitizer = (httpRequest, httpResponse) => {
let responseBody = null;
if (httpResponse.body && httpResponse.body.string) {
responseBody = httpResponse.body.string;
} else if (
httpResponse.body &&
httpResponse.body.type === 'BINARY' &&
httpResponse.body.base64Bytes
) {
responseBody = {
encoding: 'base64',
content: httpResponse.body.base64Bytes,
};
} else if (httpResponse.body && httpResponse.body.json) {
responseBody = JSON.stringify(httpResponse.body.json);
} else {
responseBody =
typeof httpResponse.body === 'string'
? httpResponse.body
: httpResponse.body && JSON.stringify(httpResponse.body);
}
// replace recorded user with fake one
if (
responseBody &&
httpRequest.path === '/user' &&
httpRequest.headers.host.includes('api.github.com')
) {
const parsed = JSON.parse(responseBody);
if (parsed.login === toSanitize.forkOwner) {
responseBody = JSON.stringify(FAKE_FORK_OWNER_USER);
} else {
responseBody = JSON.stringify(FAKE_OWNER_USER);
}
}
return responseBody;
};
const cypressRouteOptions = transformData(
expectation,
requestBodySanitizer,
responseBodySanitizer,
);
return cypressRouteOptions;
};
const defaultOptions = {
transformRecordedData,
};
async function teardownGitHubTest(taskData, { transformRecordedData } = defaultOptions) {
await resetRepositories(taskData);
return null;
}
async function seedGitHubRepo(taskData) {
const { owner, token } = getEnvs();
const client = getGitHubClient(token);
const repo = taskData.repo;
try {
console.info('Getting main branch');
const { data: main } = await client.repos.getBranch({
owner,
repo,
branch: 'main',
});
const prCount = 120;
const prs = new Array(prCount).fill(0).map((v, i) => i);
const batchSize = 5;
await batchRequests(prs, batchSize, async i => {
const branch = `seed_branch_${i}`;
console.info(`Creating branch ${branch}`);
await client.git.createRef({
owner,
repo,
ref: `refs/heads/${branch}`,
sha: main.commit.sha,
});
const path = `seed/file_${i}`;
console.info(`Creating file ${path}`);
await client.repos.createOrUpdateFile({
owner,
repo,
branch,
content: Buffer.from(`Seed File ${i}`).toString('base64'),
message: `Create seed file ${i}`,
path,
});
const title = `Non CMS Pull Request ${i}`;
console.info(`Creating PR ${title}`);
await client.pulls.create({
owner,
repo,
base: 'main',
head: branch,
title,
});
});
} catch (e) {
console.error(e);
throw e;
}
return null;
}
module.exports = {
transformRecordedData,
setupGitHub,
teardownGitHub,
setupGitHubTest,
teardownGitHubTest,
seedGitHubRepo,
};

275
cypress/plugins/gitlab.js Normal file
View File

@ -0,0 +1,275 @@
const { Gitlab } = require('gitlab');
const fs = require('fs-extra');
const path = require('path');
const { updateConfig } = require('../utils/config');
const { escapeRegExp } = require('../utils/regexp');
const {
getExpectationsFilename,
transformRecordedData: transformData,
getGitClient,
} = require('./common');
const { merge } = require('lodash');
const GITLAB_REPO_OWNER_SANITIZED_VALUE = 'owner';
const GITLAB_REPO_NAME_SANITIZED_VALUE = 'repo';
const GITLAB_REPO_TOKEN_SANITIZED_VALUE = 'fakeToken';
const FAKE_OWNER_USER = {
id: 1,
name: 'owner',
username: 'owner',
avatar_url: 'https://avatars1.githubusercontent.com/u/7892489?v=4',
email: 'owner@email.com',
login: 'owner',
};
function getGitLabClient(token) {
const client = new Gitlab({
token,
});
return client;
}
function getEnvs() {
const {
GITLAB_REPO_OWNER: owner,
GITLAB_REPO_NAME: repo,
GITLAB_REPO_TOKEN: token,
} = process.env;
if (!owner || !repo || !token) {
throw new Error(
'Please set GITLAB_REPO_OWNER, GITLAB_REPO_NAME, GITLAB_REPO_TOKEN environment variables',
);
}
return { owner, repo, token };
}
async function prepareTestGitLabRepo() {
const { owner, repo, token } = getEnvs();
// postfix a random string to avoid collisions
const postfix = Math.random().toString(32).slice(2);
const testRepoName = `${repo}-${Date.now()}-${postfix}`;
const client = getGitLabClient(token);
console.info('Creating repository', testRepoName);
await client.Projects.create({
name: testRepoName,
lfs_enabled: false,
});
const tempDir = path.join('.temp', testRepoName);
await fs.remove(tempDir);
let git = getGitClient();
const repoUrl = `git@gitlab.com:${owner}/${repo}.git`;
console.info('Cloning repository', repoUrl);
await git.clone(repoUrl, tempDir);
git = getGitClient(tempDir);
console.info('Pushing to new repository', testRepoName);
await git.removeRemote('origin');
await git.addRemote('origin', `https://oauth2:${token}@gitlab.com/${owner}/${testRepoName}`);
await git.push(['-u', 'origin', 'main']);
await client.ProtectedBranches.unprotect(`${owner}/${testRepoName}`, 'main');
return { owner, repo: testRepoName, tempDir };
}
async function getAuthenticatedUser(token) {
const client = getGitLabClient(token);
const user = await client.Users.current();
return { ...user, token, backendName: 'gitlab' };
}
async function getUser() {
const { token } = getEnvs();
return getAuthenticatedUser(token);
}
async function deleteRepositories({ owner, repo, tempDir }) {
const { token } = getEnvs();
const errorHandler = e => {
if (e.status !== 404) {
throw e;
}
};
console.info('Deleting repository', `${owner}/${repo}`);
await fs.remove(tempDir);
const client = getGitLabClient(token);
await client.Projects.remove(`${owner}/${repo}`).catch(errorHandler);
}
async function resetOriginRepo({ owner, repo, tempDir }) {
console.info('Resetting origin repo:', `${owner}/${repo}`);
const { token } = getEnvs();
const client = getGitLabClient(token);
const projectId = `${owner}/${repo}`;
const mergeRequests = await client.MergeRequests.all({
projectId,
state: 'opened',
});
const ids = mergeRequests.map(mr => mr.iid);
console.info('Closing merge requests:', ids);
await Promise.all(
ids.map(id => client.MergeRequests.edit(projectId, id, { state_event: 'close' })),
);
const branches = await client.Branches.all(projectId);
const toDelete = branches.filter(b => b.name !== 'main').map(b => b.name);
console.info('Deleting branches', toDelete);
await Promise.all(toDelete.map(branch => client.Branches.remove(projectId, branch)));
console.info('Resetting main');
const git = getGitClient(tempDir);
await git.push(['--force', 'origin', 'main']);
console.info('Done resetting origin repo:', `${owner}/${repo}`);
}
async function resetRepositories({ owner, repo, tempDir }) {
await resetOriginRepo({ owner, repo, tempDir });
}
async function setupGitLab(options) {
console.info('Running tests - live data will be used!');
const [user, repoData] = await Promise.all([getUser(), prepareTestGitLabRepo()]);
await updateConfig(config => {
merge(config, options, {
backend: {
repo: `${repoData.owner}/${repoData.repo}`,
},
});
});
return { ...repoData, user };
}
async function teardownGitLab(taskData) {
await deleteRepositories(taskData);
return null;
}
async function setupGitLabTest(taskData) {
await resetRepositories(taskData);
return null;
}
const sanitizeString = (str, { owner, repo, token, ownerName }) => {
let replaced = str
.replace(new RegExp(escapeRegExp(owner), 'g'), GITLAB_REPO_OWNER_SANITIZED_VALUE)
.replace(new RegExp(escapeRegExp(repo), 'g'), GITLAB_REPO_NAME_SANITIZED_VALUE)
.replace(new RegExp(escapeRegExp(token), 'g'), GITLAB_REPO_TOKEN_SANITIZED_VALUE)
.replace(
new RegExp('https://secure.gravatar.+?/u/.+?v=\\d', 'g'),
`${FAKE_OWNER_USER.avatar_url}`,
);
if (ownerName) {
replaced = replaced.replace(new RegExp(escapeRegExp(ownerName), 'g'), FAKE_OWNER_USER.name);
}
return replaced;
};
const transformRecordedData = (expectation, toSanitize) => {
const requestBodySanitizer = httpRequest => {
let body;
if (httpRequest.body && httpRequest.body.type === 'JSON' && httpRequest.body.json) {
const bodyObject =
typeof httpRequest.body.json === 'string'
? JSON.parse(httpRequest.body.json)
: httpRequest.body.json;
if (bodyObject.encoding === 'base64') {
// sanitize encoded data
const decodedBody = Buffer.from(bodyObject.content, 'base64').toString('binary');
const sanitizedContent = sanitizeString(decodedBody, toSanitize);
const sanitizedEncodedContent = Buffer.from(sanitizedContent, 'binary').toString('base64');
bodyObject.content = sanitizedEncodedContent;
body = JSON.stringify(bodyObject);
} else {
body = JSON.stringify(bodyObject);
}
} else if (httpRequest.body && httpRequest.body.type === 'STRING' && httpRequest.body.string) {
body = sanitizeString(httpRequest.body.string, toSanitize);
} else if (httpRequest.body) {
const str =
typeof httpRequest.body !== 'string' ? JSON.stringify(httpRequest.body) : httpRequest.body;
body = sanitizeString(str, toSanitize);
}
return body;
};
const responseBodySanitizer = (httpRequest, httpResponse) => {
let responseBody = null;
if (httpResponse.body && httpResponse.body.string) {
responseBody = httpResponse.body.string;
} else if (
httpResponse.body &&
httpResponse.body.type === 'BINARY' &&
httpResponse.body.base64Bytes
) {
responseBody = {
encoding: 'base64',
content: httpResponse.body.base64Bytes,
};
} else if (httpResponse.body && httpResponse.body.json) {
responseBody = JSON.stringify(httpResponse.body.json);
} else {
responseBody =
typeof httpResponse.body === 'string'
? httpResponse.body
: httpResponse.body && JSON.stringify(httpResponse.body);
}
// replace recorded user with fake one
if (
responseBody &&
httpRequest.path === '/api/v4/user' &&
httpRequest.headers.host.includes('gitlab.com')
) {
responseBody = JSON.stringify(FAKE_OWNER_USER);
}
return responseBody;
};
const cypressRouteOptions = transformData(
expectation,
requestBodySanitizer,
responseBodySanitizer,
);
return cypressRouteOptions;
};
const defaultOptions = {
transformRecordedData,
};
async function teardownGitLabTest(taskData, { transformRecordedData } = defaultOptions) {
await resetRepositories(taskData);
return null;
}
module.exports = {
transformRecordedData,
setupGitLab,
teardownGitLab,
setupGitLabTest,
teardownGitLabTest,
};

178
cypress/plugins/index.ts Normal file
View File

@ -0,0 +1,178 @@
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
import 'dotenv/config';
import { addMatchImageSnapshotPlugin } from '@simonsmith/cypress-image-snapshot/plugin';
import merge from 'lodash/merge';
// const { setupGitHub, teardownGitHub, setupGitHubTest, teardownGitHubTest, seedGitHubRepo } = require("./github");
// const { setupGitGateway, teardownGitGateway, setupGitGatewayTest, teardownGitGatewayTest } = require("./gitGateway");
// const { setupGitLab, teardownGitLab, setupGitLabTest, teardownGitLabTest } = require("./gitlab");
// const { setupBitBucket, teardownBitBucket, setupBitBucketTest, teardownBitBucketTest } = require("./bitbucket");
// const { setupProxy, teardownProxy, setupProxyTest, teardownProxyTest } = require("./proxy");
import { setupTestBackend } from './testBackend';
import { copyBackendFiles, switchVersion, updateConfig } from '../utils/config';
import type { Config } from '@staticcms/core/interface';
import type {
SeedRepoProps,
SetupBackendProps,
SetupBackendResponse,
SetupBackendTestProps,
TeardownBackendProps,
TeardownBackendTestProps,
} from '../interface';
export default async (on: Cypress.PluginEvents) => {
// `on` is used to hook into various events Cypress emits
on('task', {
async setupBackend({ backend, options }: SetupBackendProps): Promise<SetupBackendResponse> {
console.info('Preparing environment for backend', backend);
await copyBackendFiles(backend);
let result = null;
switch (backend) {
// case "github":
// result = await setupGitHub(options);
// break;
// case "git-gateway":
// result = await setupGitGateway(options);
// break;
// case "gitlab":
// result = await setupGitLab(options);
// break;
// case "bitbucket":
// result = await setupBitBucket(options);
// break;
// case "proxy":
// result = await setupProxy(options);
// break;
case 'test':
result = await setupTestBackend(options);
break;
}
return result;
},
async teardownBackend({ backend }: TeardownBackendProps): Promise<null> {
console.info('Tearing down backend', backend);
switch (
backend
// case "github":
// await teardownGitHub(taskData);
// break;
// case "git-gateway":
// await teardownGitGateway(taskData);
// break;
// case "gitlab":
// await teardownGitLab(taskData);
// break;
// case "bitbucket":
// await teardownBitBucket(taskData);
// break;
// case "proxy":
// await teardownProxy(taskData);
// break;
) {
}
console.info('Restoring defaults');
await copyBackendFiles('test');
return null;
},
async setupBackendTest({ backend, testName }: SetupBackendTestProps): Promise<null> {
console.info(`Setting up single test '${testName}' for backend`, backend);
switch (
backend
// case "github":
// await setupGitHubTest(taskData);
// break;
// case "git-gateway":
// await setupGitGatewayTest(taskData);
// break;
// case "gitlab":
// await setupGitLabTest(taskData);
// break;
// case "bitbucket":
// await setupBitBucketTest(taskData);
// break;
// case "proxy":
// await setupProxyTest(taskData);
// break;
) {
}
return null;
},
async teardownBackendTest({ backend, testName }: TeardownBackendTestProps): Promise<null> {
console.info(`Tearing down single test '${testName}' for backend`, backend);
switch (
backend
// case "github":
// await teardownGitHubTest(taskData);
// break;
// case "git-gateway":
// await teardownGitGatewayTest(taskData);
// break;
// case "gitlab":
// await teardownGitLabTest(taskData);
// break;
// case "bitbucket":
// await teardownBitBucketTest(taskData);
// break;
// case "proxy":
// await teardownProxyTest(taskData);
// break;
) {
}
return null;
},
async seedRepo({ backend }: SeedRepoProps): Promise<null> {
console.info(`Seeding repository for backend`, backend);
switch (
backend
// case "github":
// await seedGitHubRepo(taskData);
// break;
) {
}
return null;
},
async switchToVersion(taskData) {
const { version } = taskData;
console.info(`Switching CMS to version '${version}'`);
await switchVersion(version);
return null;
},
async updateConfig(config: Partial<Config>) {
await updateConfig(current => {
merge(current, config);
});
return null;
},
});
addMatchImageSnapshotPlugin(on);
};

105
cypress/plugins/proxy.js Normal file
View File

@ -0,0 +1,105 @@
const fs = require('fs-extra');
const path = require('path');
const { spawn } = require('child_process');
const { merge } = require('lodash');
const { updateConfig } = require('../utils/config');
const { getGitClient } = require('./common');
const initRepo = async dir => {
await fs.remove(dir);
await fs.mkdirp(dir);
const git = getGitClient(dir);
await git.init({ '--initial-branch': 'main' });
await git.addConfig('user.email', 'cms-cypress-test@netlify.com');
await git.addConfig('user.name', 'cms-cypress-test');
const readme = 'README.md';
await fs.writeFile(path.join(dir, readme), '');
await git.add(readme);
await git.commit('initial commit', readme, { '--no-verify': true, '--no-gpg-sign': true });
};
const startServer = async (repoDir, mode) => {
const tsNode = path.join(__dirname, '..', '..', 'node_modules', '.bin', 'ts-node');
const serverDir = path.join(__dirname, '..', '..', 'packages', 'static-server');
const distIndex = path.join(serverDir, 'dist', 'index.js');
const tsIndex = path.join(serverDir, 'src', 'index.ts');
const port = 8082;
const env = {
...process.env,
GIT_REPO_DIRECTORY: path.resolve(repoDir),
PORT: port,
MODE: mode,
};
console.info(`Starting proxy server on port '${port}' with mode ${mode}`);
if (await fs.pathExists(distIndex)) {
serverProcess = spawn('node', [distIndex], { env, cwd: serverDir });
} else {
serverProcess = spawn(tsNode, ['--files', tsIndex], { env, cwd: serverDir });
}
return new Promise((resolve, reject) => {
serverProcess.stdout.on('data', data => {
const message = data.toString().trim();
console.info(`server:stdout: ${message}`);
if (message.includes('Static CMS Proxy Server listening on port')) {
resolve(serverProcess);
}
});
serverProcess.stderr.on('data', data => {
console.error(`server:stderr: ${data.toString().trim()}`);
reject(data.toString());
});
});
};
let serverProcess;
async function setupProxy(options) {
const postfix = Math.random().toString(32).slice(2);
const testRepoName = `proxy-test-repo-${Date.now()}-${postfix}`;
const tempDir = path.join('.temp', testRepoName);
const { mode, ...rest } = options;
await updateConfig(config => {
merge(config, rest);
});
return { tempDir, mode };
}
async function teardownProxy(taskData) {
if (serverProcess) {
serverProcess.kill();
}
await fs.remove(taskData.tempDir);
return null;
}
async function setupProxyTest(taskData) {
await initRepo(taskData.tempDir);
serverProcess = await startServer(taskData.tempDir, taskData.mode);
return null;
}
async function teardownProxyTest(taskData) {
if (serverProcess) {
serverProcess.kill();
}
await fs.remove(taskData.tempDir);
return null;
}
module.exports = {
setupProxy,
teardownProxy,
setupProxyTest,
teardownProxyTest,
};

View File

@ -0,0 +1,13 @@
import merge from 'lodash/merge';
import { updateConfig } from '../utils/config';
import type { Config } from '@staticcms/core/interface';
import type { SetupBackendResponse } from '../interface';
export async function setupTestBackend(options: Partial<Config>): Promise<SetupBackendResponse> {
await updateConfig(current => {
merge(current, options);
});
return null;
}