import { describe, it, before, afterEach, beforeEach } from 'node:test';
import assert from 'assert';
import * as sinon from 'sinon';
import { FormData } from 'undici';
import {
RunPRJob,
CI_CRUMB_URL,
CI_PR_URL,
CI_V8_URL
} from '../../lib/ci/run_ci.js';
import PRChecker from '../../lib/pr_checker.js';
import TestCLI from '../fixtures/test_cli.js';
import { PRBuild } from '../../lib/ci/build-types/pr_build.js';
import { JobParser } from '../../lib/ci/ci_type_parser.js';
import PRData from '../../lib/pr_data.js';
describe('Jenkins', () => {
const owner = 'nodejs';
const repo = 'node-auto-test';
const prid = 123456;
const crumb = 'asdf1234';
before(() => {
sinon.stub(FormData.prototype, 'append').callsFake(function(key, value) {
assert.strictEqual(key, 'json');
const { parameter } = JSON.parse(value);
// Expected parameters are different for node-test-pull-request and
// node-test-commit-v8-linux, but we don't know which this FormData
// is for, so we make a guess.
const expectedParameters = parameter.some(({ name, _ }) => name === 'PR_ID')
? {
CERTIFY_SAFE: 'on',
COMMIT_SHA_CHECK: 'deadbeef',
TARGET_GITHUB_ORG: owner,
TARGET_REPO_NAME: repo,
PR_ID: prid,
REBASE_ONTO: '<pr base branch>',
DESCRIPTION_SETTER_DESCRIPTION: ''
}
: {
GITHUB_ORG: owner,
REPO_NAME: repo,
GIT_REMOTE_REF: `refs/pull/${prid}/head`,
COMMIT_SHA_CHECK: 'deadbeef'
};
for (const { name, value } of parameter) {
assert.strictEqual(value, expectedParameters[name]);
delete expectedParameters[name];
}
assert.strictEqual(Object.keys(expectedParameters).length, 0);
this._validated = true;
return FormData.prototype.append.wrappedMethod.bind(this)(key, value);
});
});
it('should fail if starting node-pull-request throws', async() => {
const cli = new TestCLI();
const request = {
fetch: sinon.stub().returns(Promise.resolve({ status: 400 })),
text: sinon.stub().throws(),
json: sinon.stub().withArgs(CI_CRUMB_URL)
.returns(Promise.resolve({ crumb }))
};
const jobRunner = new RunPRJob(cli, request, owner, repo, prid, true);
assert.strictEqual(await jobRunner.start(), false);
});
it('should return false if crumb fails', async() => {
const cli = new TestCLI();
const request = {
json: sinon.stub().throws()
};
const jobRunner = new RunPRJob(cli, request, owner, repo, prid, true);
assert.strictEqual(await jobRunner.start(), false);
});
it('should start node-pull-request', async() => {
const cli = new TestCLI();
const request = {
gql: sinon.stub().returns({
repository: {
pullRequest: {
labels: {
nodes: []
}
}
}
}),
fetch: sinon.stub()
.callsFake((url, { method, headers, body }) => {
assert.strictEqual(url, CI_PR_URL);
assert.strictEqual(method, 'POST');
assert.deepStrictEqual(headers, { 'Jenkins-Crumb': crumb });
assert.ok(body._validated);
return Promise.resolve({ status: 201 });
}),
json: sinon.stub().withArgs(CI_CRUMB_URL)
.returns(Promise.resolve({ crumb }))
};
const jobRunner = new RunPRJob(cli, request, owner, repo, prid, 'deadbeef');
assert.ok(await jobRunner.start());
});
it('should start node-test-commit-v8-linux', async() => {
const cli = new TestCLI();
const request = {
gql: sinon.stub().returns({
repository: {
pullRequest: {
labels: {
nodes: [{ name: 'v8 engine' }]
}
}
}
}),
fetch: sinon.stub()
.callsFake((url, { method, headers, body }) => {
assert.strictEqual(url, CI_PR_URL);
assert.strictEqual(method, 'POST');
assert.deepStrictEqual(headers, { 'Jenkins-Crumb': crumb });
assert.ok(body._validated);
return Promise.resolve({ status: 201 });
}).onSecondCall().callsFake((url, { method, headers, body }) => {
assert.strictEqual(url, CI_V8_URL);
assert.strictEqual(method, 'POST');
assert.deepStrictEqual(headers, { 'Jenkins-Crumb': crumb });
assert.ok(body._validated);
return Promise.resolve({ status: 201 });
}),
json: sinon.stub().withArgs(CI_CRUMB_URL)
.returns(Promise.resolve({ crumb }))
};
const jobRunner = new RunPRJob(cli, request, owner, repo, prid, 'deadbeef');
assert.ok(await jobRunner.start());
});
it('should return false if node-pull-request not started', async() => {
const cli = new TestCLI();
const request = {
fetch: sinon.stub()
.callsFake((url, { method, headers, body }) => {
assert.strictEqual(url, CI_PR_URL);
assert.strictEqual(method, 'POST');
assert.deepStrictEqual(headers, { 'Jenkins-Crumb': crumb });
assert.ok(body._validated);
return Promise.resolve({ status: 401 });
}),
json: sinon.stub().withArgs(CI_CRUMB_URL)
.returns(Promise.resolve({ crumb }))
};
const jobRunner = new RunPRJob(cli, request, owner, repo, prid, true);
assert.strictEqual(await jobRunner.start(), false);
});
describe('without --certify-safe flag', { concurrency: false }, () => {
afterEach(() => {
sinon.restore();
});
for (const certifySafe of [true, false]) {
it(`should return ${certifySafe} if PR checker reports it as ${
certifySafe ? '' : 'potentially un'
}safe`, async() => {
const cli = new TestCLI();
sinon.replace(PRChecker.prototype, 'getApprovedTipOfHead',
sinon.fake.returns(certifySafe && 'deadbeef'));
const request = {
gql: sinon.stub().returns({
repository: {
pullRequest: {
labels: {
nodes: []
}
}
}
}),
fetch: sinon.stub()
.callsFake((url, { method, headers, body }) => {
assert.strictEqual(url, CI_PR_URL);
assert.strictEqual(method, 'POST');
assert.deepStrictEqual(headers, { 'Jenkins-Crumb': crumb });
assert.ok(body._validated);
return Promise.resolve({ status: 201 });
}),
json: sinon.stub().withArgs(CI_CRUMB_URL)
.returns(Promise.resolve({ crumb }))
};
const jobRunner = new RunPRJob(cli, request, owner, repo, prid, false);
assert.strictEqual(await jobRunner.start(), certifySafe);
});
}
});
describe('--check-for-duplicates', { concurrency: false }, () => {
const jobid = 123456;
const jobURL = `https://ci.nodejs.org/job/node-test-pull-request/${jobid}/`;
const menuURL = `${jobURL}contextMenu`;
const duplicateRefusal = 'Refusing to start a potentially duplicate CI job. ';
const resumeHint = `Resume CI with: ncu-ci resume https://github.com/${owner}/${repo}/pull/${prid}`;
const manualHint = 'Check the existing CI run in Jenkins and rebase the PR if needed. ' +
`To start a new CI run manually: ncu-ci run https://github.com/${owner}/${repo}/pull/${prid}`;
beforeEach(() => {
sinon.replace(PRData.prototype, 'getComments', sinon.fake.resolves());
sinon.replace(PRData.prototype, 'getPR', sinon.fake.resolves());
sinon.replace(JobParser.prototype, 'parse',
sinon.fake.returns(new Map().set('PR', { jobid, link: jobURL })));
});
afterEach(() => {
sinon.restore();
});
const getParameters = (commitHash) =>
[
{
_class: 'hudson.model.BooleanParameterValue',
name: 'CERTIFY_SAFE',
value: true
},
{
_class: 'hudson.model.StringParameterValue',
name: 'COMMIT_SHA_CHECK',
value: commitHash
},
{
_class: 'hudson.model.StringParameterValue',
name: 'TARGET_GITHUB_ORG',
value: owner
},
{
_class: 'hudson.model.StringParameterValue',
name: 'TARGET_REPO_NAME',
value: repo
},
{
_class: 'hudson.model.StringParameterValue',
name: 'PR_ID',
value: prid
},
{
_class: 'hudson.model.StringParameterValue',
name: 'REBASE_ONTO',
value: '<pr base branch>'
},
{
_class: 'com.wangyin.parameter.WHideParameterValue',
name: 'DESCRIPTION_SETTER_DESCRIPTION',
value: ''
}
];
const mockJenkinsResponse = parameters => ({
_class: 'com.tikal.jenkins.plugins.multijob.MultiJobBuild',
actions: [
{ _class: 'hudson.model.CauseAction' },
{ _class: 'hudson.model.ParametersAction', parameters },
{ _class: 'hudson.model.ParametersAction', parameters },
{ _class: 'hudson.model.ParametersAction', parameters },
{},
{ _class: 'hudson.model.CauseAction' },
{},
{},
{},
{},
{ _class: 'hudson.plugins.git.util.BuildData' },
{},
{},
{},
{},
{ _class: 'hudson.model.ParametersAction', parameters },
{
_class: 'hudson.plugins.parameterizedtrigger.BuildInfoExporterAction'
},
{
_class: 'com.tikal.jenkins.plugins.multijob.MultiJobTestResults'
},
{},
{},
{},
{},
{},
{},
{},
{},
{
_class: 'org.jenkinsci.plugins.displayurlapi.actions.RunDisplayAction'
}
]
});
const createRequest = () => {
const request = {
fetch: sinon.stub().rejects(new Error('Unexpected fetch request')),
json: sinon.stub().rejects(new Error('Unexpected JSON request'))
};
request.json.withArgs(CI_CRUMB_URL).resolves({ crumb });
request.fetch.withArgs(CI_PR_URL).resolves({ status: 201 });
return request;
};
it('should return false if inferred commit already has CI', async() => {
const cli = new TestCLI();
sinon.replace(PRData.prototype, 'getReviews', sinon.fake.resolves());
sinon.replace(PRData.prototype, 'getCommits', sinon.fake.resolves());
sinon.replace(PRChecker.prototype, 'getApprovedTipOfHead',
sinon.fake.returns('deadbeef'));
sinon.replace(PRBuild.prototype, 'getBuildData',
sinon.fake.resolves(mockJenkinsResponse(getParameters('deadbeef'))));
const request = {
fetch: sinon.stub().resolves({ status: 201 }),
json: sinon.stub().withArgs(CI_CRUMB_URL).resolves({ crumb })
};
const jobRunner = new RunPRJob(cli, request, owner, repo, prid, undefined, true);
assert.strictEqual(await jobRunner.start(), false);
assert.strictEqual(request.fetch.callCount, 0);
});
const inProgress = 'The existing CI run is still in progress.';
const succeeded = 'CI has already succeeded for this commit.';
const checkJenkins = 'Check the existing CI run in Jenkins before retrying.';
for (const [state, reason] of [
[{ building: true, result: null }, inProgress],
[{ building: true, result: 'FAILURE' }, inProgress],
[{ building: false, result: null }, checkJenkins],
[{ building: false, result: 'SUCCESS' }, succeeded],
[{ building: false, result: 'UNSTABLE' }, checkJenkins],
[{ building: false, result: 'NOT_BUILT' }, checkJenkins],
[{ building: false, result: 'UNKNOWN' }, checkJenkins],
[{ building: false }, checkJenkins],
[{ result: 'FAILURE' }, checkJenkins]
]) {
it(`should reject duplicate CI without a resume lookup for ${
JSON.stringify(state)}`, async() => {
const cli = new TestCLI();
sinon.replace(PRBuild.prototype, 'getBuildData', sinon.fake.resolves({
...mockJenkinsResponse(getParameters('deadbeef')),
...state
}));
const request = createRequest();
const jobRunner = new RunPRJob(cli, request, owner, repo, prid, 'deadbeef', true);
assert.strictEqual(await jobRunner.start(), false);
sinon.assert.notCalled(request.fetch);
assert.deepStrictEqual(cli._calls.error, [[duplicateRefusal + reason]]);
});
}
for (const result of ['FAILURE', 'ABORTED']) {
for (const resumable of [true, false]) {
it(`should reject duplicate CI for a ${
result} job ${resumable ? 'with' : 'without'} a resume action`, async() => {
const cli = new TestCLI();
sinon.replace(PRBuild.prototype, 'getBuildData', sinon.fake.resolves({
...mockJenkinsResponse(getParameters('deadbeef')),
result,
building: false
}));
const request = createRequest();
const menuRequest = request.fetch.withArgs(menuURL).resolves({
status: 200,
json: async() => ({ items: resumable ? [{ url: 'resume' }] : [] })
});
const jobRunner = new RunPRJob(cli, request, owner, repo, prid, 'deadbeef', true);
assert.strictEqual(await jobRunner.start(), false);
sinon.assert.calledOnceWithExactly(menuRequest, menuURL, {
method: 'GET', redirect: 'error'
});
sinon.assert.notCalled(request.fetch.withArgs(CI_PR_URL));
assert.deepStrictEqual(cli._calls.error,
[[duplicateRefusal + (resumable ? resumeHint : manualHint)]]);
});
}
}
it('should suggest the resume-ci label for a resumable nodejs/node build', async() => {
const cli = new TestCLI();
const parameters = getParameters('deadbeef').map(parameter =>
parameter.name === 'TARGET_REPO_NAME' ? { ...parameter, value: 'node' } : parameter);
sinon.replace(PRBuild.prototype, 'getBuildData', sinon.fake.resolves({
...mockJenkinsResponse(parameters),
result: 'FAILURE',
building: false
}));
const request = createRequest();
request.fetch.withArgs(menuURL).resolves({
status: 200,
json: async() => ({ items: [{ url: 'resume' }] })
});
const jobRunner = new RunPRJob(cli, request, owner, 'node', prid, 'deadbeef', true);
assert.strictEqual(await jobRunner.start(), false);
sinon.assert.notCalled(request.fetch.withArgs(CI_PR_URL));
assert.deepStrictEqual(cli._calls.error, [[duplicateRefusal +
'Resume CI by adding the "resume-ci" label to the PR, or run: ' +
`ncu-ci resume https://github.com/nodejs/node/pull/${prid}`]]);
});
for (const resumable of [true, false]) {
it(`should reject duplicate CI when the resume ancestor ${
resumable ? 'has' : 'does not have'} a resume action`, async() => {
const cli = new TestCLI();
const ancestorJobid = jobid - 1;
const ancestorURL = `https://ci.nodejs.org/job/node-test-pull-request/${ancestorJobid}/`;
const data = {
...mockJenkinsResponse(getParameters('deadbeef')),
result: 'FAILURE',
building: false
};
const latestData = {
...data,
actions: [...data.actions, {
causes: [{
_class: 'com.tikal.jenkins.plugins.multijob.ResumeCause',
upstreamProject: 'node-test-pull-request',
upstreamBuild: ancestorJobid,
upstreamUrl: 'job/node-test-pull-request/'
}]
}]
};
const getBuildData = sinon.stub(PRBuild.prototype, 'getBuildData');
getBuildData.onFirstCall().resolves(latestData);
getBuildData.onSecondCall().resolves(data);
const request = createRequest();
request.fetch.withArgs(menuURL).resolves({
status: 200,
json: async() => ({ items: [] })
});
const ancestorMenu = request.fetch.withArgs(`${ancestorURL}contextMenu`).resolves({
status: 200,
json: async() => ({ items: resumable ? [{ url: 'resume' }] : [] })
});
const jobRunner = new RunPRJob(cli, request, owner, repo, prid, 'deadbeef', true);
assert.strictEqual(await jobRunner.start(), false);
sinon.assert.calledTwice(getBuildData);
assert.strictEqual(getBuildData.secondCall.thisValue.jobUrl, ancestorURL);
sinon.assert.calledOnce(ancestorMenu);
sinon.assert.notCalled(request.fetch.withArgs(CI_PR_URL));
assert.deepStrictEqual(cli._calls.error,
[[duplicateRefusal + (resumable ? resumeHint : manualHint)]]);
});
}
it('should reject duplicate CI when an ancestor cannot be queried', async() => {
const cli = new TestCLI();
const data = {
...mockJenkinsResponse(getParameters('deadbeef')),
result: 'FAILURE',
building: false
};
data.actions.push({
causes: [{
_class: 'com.tikal.jenkins.plugins.multijob.ResumeCause',
upstreamProject: 'node-test-pull-request',
upstreamBuild: jobid - 1,
upstreamUrl: 'job/node-test-pull-request/'
}]
});
const getBuildData = sinon.stub(PRBuild.prototype, 'getBuildData');
getBuildData.onFirstCall().resolves(data);
getBuildData.onSecondCall().rejects(new Error('Ancestor metadata unavailable'));
const request = createRequest();
request.fetch.withArgs(menuURL).resolves({
status: 200,
json: async() => ({ items: [] })
});
const jobRunner = new RunPRJob(cli, request, owner, repo, prid, 'deadbeef', true);
assert.strictEqual(await jobRunner.start(), false);
sinon.assert.notCalled(request.fetch.withArgs(CI_PR_URL));
assert.match(cli._calls.error[0][0], /Ancestor metadata unavailable/);
});
it('should reject a potential duplicate CI with conflicting approved commits', async() => {
const cli = new TestCLI();
const data = {
...mockJenkinsResponse(getParameters('different-commit')),
result: 'FAILURE',
building: false
};
data.actions.push({ parameters: getParameters('deadbeef') });
sinon.replace(PRBuild.prototype, 'getBuildData', sinon.fake.resolves(data));
const request = createRequest();
const jobRunner = new RunPRJob(cli, request, owner, repo, prid, 'deadbeef', true);
assert.strictEqual(await jobRunner.start(), false);
sinon.assert.notCalled(request.fetch);
assert.deepStrictEqual(cli._calls.error, [[duplicateRefusal + checkJenkins]]);
});
it('should reject duplicate CI when resume availability cannot be checked', async() => {
const cli = new TestCLI();
sinon.replace(PRBuild.prototype, 'getBuildData', sinon.fake.resolves({
...mockJenkinsResponse(getParameters('deadbeef')),
result: 'FAILURE',
building: false
}));
const request = createRequest();
request.fetch.withArgs(menuURL).resolves({ status: 403, statusText: 'Forbidden' });
const jobRunner = new RunPRJob(cli, request, owner, repo, prid, 'deadbeef', true);
assert.strictEqual(await jobRunner.start(), false);
sinon.assert.notCalled(request.fetch.withArgs(CI_PR_URL));
assert.match(cli._calls.error[0][0], /Could not check whether existing CI run/);
assert.ok(cli._calls.error[0][0].includes(jobURL));
assert.match(cli._calls.error[0][0], /403 Forbidden/);
assert.match(cli._calls.error[1][0], /Retry after checking Jenkins access/);
});
it('should not look up existing builds without the duplicate check flag', async() => {
const cli = new TestCLI();
const getBuildData = sinon.fake.rejects(new Error('Unexpected metadata lookup'));
sinon.replace(PRBuild.prototype, 'getBuildData', getBuildData);
const request = createRequest();
const jobRunner = new RunPRJob(cli, request, owner, repo, prid, 'deadbeef');
assert.strictEqual(await jobRunner.start(), true);
sinon.assert.notCalled(PRData.prototype.getComments);
sinon.assert.notCalled(getBuildData);
sinon.assert.calledOnceWithMatch(request.fetch, CI_PR_URL, { method: 'POST' });
});
it('should return true when last CI is on a different commit', async() => {
const cli = new TestCLI();
sinon.replace(PRBuild.prototype, 'getBuildData',
sinon.fake.resolves(mockJenkinsResponse(getParameters('123456789abcdef'))));
const request = {
gql: sinon.stub().returns({
repository: {
pullRequest: {
labels: {
nodes: []
}
}
}
}),
fetch: sinon.stub()
.callsFake((url, { method, headers, body }) => {
assert.strictEqual(url, CI_PR_URL);
assert.strictEqual(method, 'POST');
assert.deepStrictEqual(headers, { 'Jenkins-Crumb': crumb });
return Promise.resolve({ status: 201 });
}),
json: sinon.stub().withArgs(CI_CRUMB_URL).resolves({ crumb })
};
const jobRunner = new RunPRJob(cli, request, owner, repo, prid, 'deadbeef', true);
assert.strictEqual(await jobRunner.start(), true);
});
it('should start CI when the existing CI run cannot be queried', async() => {
const cli = new TestCLI();
const err = new SyntaxError('Unexpected token \'<\', "<!DOCTYPE " is not valid JSON');
sinon.replace(PRBuild.prototype, 'getBuildData', sinon.fake.rejects(err));
const request = {
gql: sinon.stub().returns({
repository: {
pullRequest: {
labels: {
nodes: []
}
}
}
}),
fetch: sinon.stub()
.callsFake((url, { method, headers, body }) => {
assert.strictEqual(url, CI_PR_URL);
assert.strictEqual(method, 'POST');
assert.deepStrictEqual(headers, { 'Jenkins-Crumb': crumb });
return Promise.resolve({ status: 201 });
}),
json: sinon.stub().withArgs(CI_CRUMB_URL).resolves({ crumb })
};
const jobRunner = new RunPRJob(cli, request, owner, repo, prid, 'deadbeef', true);
assert.strictEqual(await jobRunner.start(), true);
assert.strictEqual(request.fetch.callCount, 1);
});
});
});