branch-remover
Version:
A small application for quickly removing unnecessary branches from GitHub.
884 lines (726 loc) • 24.6 kB
text/typescript
import { expect } from 'chai';
import fs from 'fs';
import streams from 'memory-streams';
import readline from 'readline';
import sinon from 'sinon';
import { Writable } from 'stream';
import winston from 'winston';
import { BranchRemoverOptionsBuilder } from '../src/BranchRemoverOptionsBuilder';
import {
Branch,
BranchRemoveCancelationReason,
BranchRemoverContext,
BranchRemoverOptionsIgnoreFunction,
BranchRemoverOptionsRemoveFunction,
Logger,
} from '../src/Core';
import { branchInfoFormatter } from '../src/Formatters';
describe('BranchRemoverOptionsBuilder', () => {
let context: BranchRemoverContext = null;
let writer: Writable = null;
const parseLog = <T = any>(): Array<T> => {
const jsonString = '[' + writer.toString().trim().replace(/[\r\n]+/g, ',') + ']';
return JSON.parse(jsonString);
};
afterEach((): void => {
writer = new streams.WritableStream();
const logger = new Logger({
levels: Logger.defaultLevels,
level: 'debug',
transports: [
new winston.transports.Stream({
stream: writer,
}),
],
});
context = {
logger,
};
});
describe('ignore', (): void => {
it('the ignore property should be a function', (): void => {
const builder = new BranchRemoverOptionsBuilder();
builder.ignore('test');
const options = builder.build();
expect(typeof options.ignore)
.to.be.equal('function');
});
it('the ignore function should return TRUE', async(): Promise<void> => {
const builder = new BranchRemoverOptionsBuilder();
builder.ignore('^(master|prod|release)$');
const options = builder.build();
const ignore = options.ignore as BranchRemoverOptionsIgnoreFunction;
expect(
await ignore({
context,
branchName: 'master',
})
).to.be.true;
expect(
await ignore({
context,
branchName: 'release',
})
).to.be.true;
});
it('the ignore function should return FALSE', async(): Promise<void> => {
const builder = new BranchRemoverOptionsBuilder();
builder.ignore('^(master|prod|release)$');
const options = builder.build();
const ignore = options.ignore as BranchRemoverOptionsIgnoreFunction;
expect(
await ignore({
context,
branchName: 'issue-123',
})
).to.be.false;
expect(
await ignore({
context,
branchName: 'master-prod',
})
).to.be.false;
expect(
await ignore({
context,
branchName: 'prod555',
})
).to.be.false;
expect(
await ignore({
context,
branchName: 'master-2',
})
).to.be.false;
});
it('should return FALSE when the ignore property is not specified', async(): Promise<void> => {
const builder = new BranchRemoverOptionsBuilder();
const options = builder.build();
const ignore = options.ignore as BranchRemoverOptionsIgnoreFunction;
expect(
await ignore({
context,
branchName: 'issue-123',
})
).to.be.false;
});
});
describe('remove', (): void => {
it('the remove property should be a function', (): void => {
const builder = new BranchRemoverOptionsBuilder();
const options = builder.build();
expect(typeof options.remove)
.to.be.equal('function');
});
it('should return TRUE for the merged branch', async(): Promise<void> => {
const builder = new BranchRemoverOptionsBuilder();
builder.quiet();
const options = builder.build();
const remove = options.remove as BranchRemoverOptionsRemoveFunction;
expect(
await remove({
context,
branch: {
merged: true,
name: 'issue-100',
updatedDate: new Date(),
mergedDate: new Date(),
hasUncommittedChanges: false,
},
})
).to.be.true;
});
it('should return FALSE for the unmerged branch', async(): Promise<void> => {
const builder = new BranchRemoverOptionsBuilder();
const options = builder.build();
const remove = options.remove as BranchRemoverOptionsRemoveFunction;
expect(
await remove({
context,
branch: {
merged: false,
name: 'issue-100',
updatedDate: new Date(),
hasUncommittedChanges: true,
},
})
).to.be.equal(BranchRemoveCancelationReason.UpdateDateOutOfRange);
});
describe('merged', (): void => {
it('should return TRUE for the merged branch when merge date less specified', async(): Promise<void> => {
const builder = new BranchRemoverOptionsBuilder();
builder.quiet();
builder.merged(new Date(2021, 7, 8, 12, 0, 0));
const options = builder.build();
const remove = options.remove as BranchRemoverOptionsRemoveFunction;
expect(
await remove({
context,
branch: {
merged: true,
name: 'issue-100',
updatedDate: new Date(),
mergedDate: new Date(2021, 7, 8, 11, 59, 59),
hasUncommittedChanges: true,
},
})
).to.be.true;
});
it('should return TRUE for the merged branch when merge date equal to the specified', async(): Promise<void> => {
const mergedDate = new Date(2021, 7, 8, 12, 0, 0);
const builder = new BranchRemoverOptionsBuilder();
builder.quiet();
builder.merged(mergedDate);
const options = builder.build();
const remove = options.remove as BranchRemoverOptionsRemoveFunction;
expect(
await remove({
context,
branch: {
merged: true,
name: 'issue-100',
updatedDate: new Date(),
mergedDate: mergedDate,
hasUncommittedChanges: true,
},
})
).to.be.true;
});
it('should return MergeDateOutOfRange for the merged branch when merge date greater than specified', async(): Promise<void> => {
const builder = new BranchRemoverOptionsBuilder();
builder.quiet();
builder.merged(new Date(2021, 7, 8, 12, 0, 0));
const options = builder.build();
const remove = options.remove as BranchRemoverOptionsRemoveFunction;
expect(
await remove({
context,
branch: {
merged: true,
name: 'issue-100',
updatedDate: new Date(),
mergedDate: new Date(2021, 7, 8, 12, 0, 1),
hasUncommittedChanges: true,
},
})
).to.be.equal(BranchRemoveCancelationReason.MergeDateOutOfRange);
});
});
describe('stale', (): void => {
it('should return TRUE for the unmerged branch when update date less specified', async(): Promise<void> => {
const builder = new BranchRemoverOptionsBuilder();
builder.quiet();
builder.stale(new Date(2021, 7, 8, 12, 0, 0));
const options = builder.build();
const remove = options.remove as BranchRemoverOptionsRemoveFunction;
expect(
await remove({
context,
branch: {
merged: false,
name: 'issue-100',
updatedDate: new Date(2021, 7, 8, 11, 59, 59),
hasUncommittedChanges: true,
},
})
).to.be.true;
});
it('should return TRUE for the unmerged branch when update date equal to the specified', async(): Promise<void> => {
const updatedDate = new Date(2021, 7, 8, 12, 0, 0);
const builder = new BranchRemoverOptionsBuilder();
builder.quiet();
builder.stale(updatedDate);
const options = builder.build();
const remove = options.remove as BranchRemoverOptionsRemoveFunction;
expect(
await remove({
context,
branch: {
merged: false,
hasUncommittedChanges: true,
name: 'issue-100',
updatedDate,
},
})
).to.be.true;
});
it('should return UpdateDateOutOfRange for the unmerged branch when update date greater than specified', async(): Promise<void> => {
const builder = new BranchRemoverOptionsBuilder();
builder.quiet();
builder.stale(new Date(2021, 7, 8, 12, 0, 0));
const options = builder.build();
const remove = options.remove as BranchRemoverOptionsRemoveFunction;
expect(
await remove({
context,
branch: {
merged: false,
name: 'issue-100',
updatedDate: new Date(2021, 7, 8, 12, 0, 1),
hasUncommittedChanges: true,
},
})
).to.be.equal(BranchRemoveCancelationReason.UpdateDateOutOfRange);
});
});
describe('quiet', (): void => {
afterEach((): void => {
sinon.restore();
});
it('should show a confirmation and be TRUE when the answer is "yes"', async(): Promise<void> => {
const builder = new BranchRemoverOptionsBuilder();
const options = builder.build();
const remove = options.remove as BranchRemoverOptionsRemoveFunction;
const readlineStub = {
question: sinon.stub().callsFake(
(query, callback) => {
callback('yes');
}
),
close: sinon.stub(),
};
sinon.stub(
readline,
'createInterface'
).returns(<any>readlineStub);
const result = await remove({
context,
branch: {
merged: true,
name: 'issue-100',
updatedDate: new Date(),
mergedDate: new Date(),
hasUncommittedChanges: false,
},
});
sinon.assert.calledWith(
readlineStub.question,
'Do you want to remove merged branch "issue-100"? Merged 0 seconds ago [y/N] ',
sinon.match.func
);
sinon.assert.calledOnce(readlineStub.close);
expect(result).to.be.true;
});
it('should show a confirmation and be CanceledByUser when the answer is "no"', async(): Promise<void> => {
const builder = new BranchRemoverOptionsBuilder();
const options = builder.build();
const remove = options.remove as BranchRemoverOptionsRemoveFunction;
const branch: Branch = {
merged: true,
name: 'issue-100',
// null - to avoid problems with different representation of dates
updatedDate: null,
mergedDate: null,
hasUncommittedChanges: false,
};
const readlineStub = {
question: sinon.stub().callsFake(
(query, callback) => {
callback('no');
}
),
close: sinon.stub(),
};
sinon.stub(
readline,
'createInterface'
).returns(<any>readlineStub);
const result = await remove({
context,
branch,
});
sinon.assert.calledWith(
readlineStub.question,
'Do you want to remove merged branch "issue-100"? Merged 0 seconds ago [y/N] ',
sinon.match.func
);
sinon.assert.calledOnce(readlineStub.close);
expect(parseLog())
.to.be.deep.equal([
{
branch,
level: 'debug',
message:'Can remove merged "{branch.name}", because no limit on merge date.',
},
{
branch,
level: 'debug',
message: 'Removing branch "{branch.name}" canceled by user.',
}
]);
expect(result).to.be.equal(BranchRemoveCancelationReason.CanceledByUser);
});
});
describe('yes', (): void => {
afterEach((): void => {
sinon.restore();
});
it('should show a confirmation and be TRUE when the answer is empty', async(): Promise<void> => {
const builder = new BranchRemoverOptionsBuilder();
builder.yes();
const options = builder.build();
const remove = options.remove as BranchRemoverOptionsRemoveFunction;
const readlineStub = {
question: sinon.stub().callsFake(
(query, callback) => {
callback('');
}
),
close: sinon.stub(),
};
sinon.stub(
readline,
'createInterface'
).returns(<any>readlineStub);
const result = await remove({
context,
branch: {
merged: true,
name: 'issue-100',
updatedDate: new Date(),
mergedDate: new Date(),
hasUncommittedChanges: false,
},
});
sinon.assert.calledWith(
readlineStub.question,
'Do you want to remove merged branch "issue-100"? Merged 0 seconds ago [Y/n] ',
sinon.match.func
);
sinon.assert.calledOnce(readlineStub.close);
expect(result).to.be.true;
});
it('should show a confirmation and be CanceledByUser when the answer is empty', async(): Promise<void> => {
const builder = new BranchRemoverOptionsBuilder();
const options = builder.build();
const remove = options.remove as BranchRemoverOptionsRemoveFunction;
const branch: Branch = {
merged: true,
name: 'issue-100',
// null - to avoid problems with different representation of dates
updatedDate: null,
mergedDate: null,
hasUncommittedChanges: false,
};
const readlineStub = {
question: sinon.stub().callsFake(
(query, callback) => {
callback('no');
}
),
close: sinon.stub(),
};
sinon.stub(
readline,
'createInterface'
).returns(<any>readlineStub);
const result = await remove({
context,
branch,
});
sinon.assert.calledWith(
readlineStub.question,
'Do you want to remove merged branch "issue-100"? Merged 0 seconds ago [y/N] ',
sinon.match.func
);
sinon.assert.calledOnce(readlineStub.close);
expect(parseLog())
.to.be.deep.equal([
{
branch,
level: 'debug',
message:'Can remove merged "{branch.name}", because no limit on merge date.',
},
{
branch,
level: 'debug',
message: 'Removing branch "{branch.name}" canceled by user.',
}
]);
expect(result).to.be.equal(BranchRemoveCancelationReason.CanceledByUser);
});
});
describe('details', (): void => {
afterEach((): void => {
sinon.restore();
});
it('should display details for unmerged branch', async(): Promise<void> => {
const builder = new BranchRemoverOptionsBuilder();
builder.details();
const options = builder.build();
const remove = options.remove as BranchRemoverOptionsRemoveFunction;
sinon.stub(console, 'log');
const branch = {
merged: false,
name: 'issue-100',
updatedDate: new Date(),
mergedDate: new Date(),
hasUncommittedChanges: false,
};
await remove({
context,
branch,
});
sinon.assert.calledWith(
<any>console.log,
branchInfoFormatter(branch)
);
});
it('should not display details for unmerged branch', async(): Promise<void> => {
const builder = new BranchRemoverOptionsBuilder();
const options = builder.build();
const remove = options.remove as BranchRemoverOptionsRemoveFunction;
sinon.stub(console, 'log');
const branch = {
merged: false,
name: 'issue-100',
updatedDate: new Date(),
mergedDate: new Date(),
hasUncommittedChanges: false,
};
await remove({
context,
branch,
});
sinon.assert.neverCalledWith(
<any>console.log,
branchInfoFormatter(branch)
);
});
});
describe('beforeRemove', (): void => {
const branch = {
merged: false,
name: 'issue-100',
updatedDate: new Date(),
mergedDate: new Date(),
hasUncommittedChanges: false,
};
it('should be true with default implementation', async(): Promise<void> => {
const builder = new BranchRemoverOptionsBuilder();
const options = builder.build();
const result = await options.beforeRemove({
branch,
context
});
expect(result).to.be.true;
});
it('should output command with branch name, and the result should be true', async(): Promise<void> => {
const command = 'echo {branch}';
const commandToExec = `echo ${branch.name}`;
const builder = new BranchRemoverOptionsBuilder();
const options = builder
.beforeRemove(command)
.build();
const result = await options.beforeRemove({
branch,
context
});
const [
commandPreparation,
commandResult,
] = parseLog();
expect(commandPreparation.source).to.be.equal(command);
expect(commandPreparation.parsed).to.be.equal(commandToExec);
expect(commandResult.command).to.be.equal(commandToExec);
expect(commandResult.stdout).to.be.equal(branch.name);
expect(commandResult.stderr).to.be.empty;
expect(result).to.be.true;
});
it('should escape special characters', async(): Promise<void> => {
const command = 'echo "\\{branch} {branch} \\{branch\\} {branch\\}"';
const commandToExec = `echo "{branch} ${branch.name} {branch} {branch}"`;
const builder = new BranchRemoverOptionsBuilder();
const options = builder
.beforeRemove(command)
.build();
await options.beforeRemove({
branch,
context
});
const [
commandPreparation,
commandResult,
] = parseLog();
expect(commandPreparation.source).to.be.equal(command);
expect(commandPreparation.parsed).to.be.equal(commandToExec);
expect(commandResult.command).to.be.equal(commandToExec);
expect(commandResult.stdout).to.be.equal(`{branch} ${branch.name} {branch} {branch}`);
expect(commandResult.stderr).to.be.empty;
});
it('should be thrown an exception', (): void => {
const command = 'exit 1';
const builder = new BranchRemoverOptionsBuilder();
const options = builder
.beforeRemove(command)
.build();
expect(
async(): Promise<void> => {
await options.beforeRemove({
branch,
context
});
}
).to.be.throw;
});
});
describe('afterRemove', (): void => {
const branch = {
merged: false,
name: 'issue-100',
updatedDate: new Date(),
mergedDate: new Date(),
hasUncommittedChanges: false,
};
it('should output command with branch name', async(): Promise<void> => {
const command = 'echo {branch}';
const commandToExec = `echo ${branch.name}`;
const builder = new BranchRemoverOptionsBuilder();
const options = builder
.afterRemove(command)
.build();
await options.afterRemove({
branch,
context
});
const [
commandPreparation,
commandResult,
] = parseLog();
expect(commandPreparation.source).to.be.equal(command);
expect(commandPreparation.parsed).to.be.equal(commandToExec);
expect(commandResult.command).to.be.equal(commandToExec);
expect(commandResult.stdout).to.be.equal(branch.name);
expect(commandResult.stderr).to.be.empty;
});
it('should be thrown an exception', (): void => {
const command = 'exit 1';
const builder = new BranchRemoverOptionsBuilder();
const options = builder
.afterRemove(command)
.build();
expect(
async(): Promise<void> => {
await options.afterRemove({
branch,
context
});
}
).to.be.throw;
});
});
describe('cachePath', (): void => {
afterEach((): void => {
sinon.restore();
});
it('should have a default path for the cache file', (): void => {
const builder = new BranchRemoverOptionsBuilder();
const options = builder.build();
sinon.stub(fs, 'existsSync');
options.cache.provider.load();
sinon.assert.calledWith(
<any>fs.existsSync,
BranchRemoverOptionsBuilder.DEFAULT_CACHE_PATH
);
});
it('should have custom path to the cache file', (): void => {
const cachePath = './custom.cache';
const builder = new BranchRemoverOptionsBuilder();
const options = builder
.cachePath(cachePath)
.build();
sinon.stub(fs, 'existsSync');
options.cache.provider.load();
sinon.assert.calledWith(
<any>fs.existsSync,
cachePath
);
});
it('should throw an exception if an empty path is specified', (): void => {
expect(
(): void => {
const builder = new BranchRemoverOptionsBuilder();
builder.cachePath('');
}
).to.be.throw('Parameter "path" cannot be null or empty.');
});
});
describe('cacheTimeout', (): void => {
afterEach((): void => {
sinon.restore();
});
it('should be 0 by default', (): void => {
const builder = new BranchRemoverOptionsBuilder();
const options = builder.build();
expect(options.cache.timeout).to.be.equal(0);
});
it('should have the specified value', (): void => {
const builder = new BranchRemoverOptionsBuilder();
const options = builder
.cacheTimeout(300)
.build();
expect(options.cache.timeout).to.be.equal(300);
});
});
describe('debug', (): void => {
it('should contain debug information', async(): Promise<void> => {
const logger = new Logger({
levels: Logger.defaultLevels,
level: 'debug',
transports: [
new winston.transports.Stream({
stream: writer,
}),
],
});
context = {
logger,
};
const options = new BranchRemoverOptionsBuilder()
.debug()
.quiet()
.build();
const remove = options.remove as BranchRemoverOptionsRemoveFunction;
await remove({
context,
branch: {
merged: true,
name: 'issue-100',
updatedDate: new Date(),
hasUncommittedChanges: false,
},
});
expect(writer.toString()).to.be.contain('"level":"debug"');
});
});
it('should not contain debug information', async(): Promise<void> => {
const logger = new Logger({
levels: Logger.defaultLevels,
level: 'info',
transports: [
new winston.transports.Stream({
stream: writer,
}),
],
});
context = {
logger,
};
const options = new BranchRemoverOptionsBuilder()
.debug()
.quiet()
.build();
const remove = options.remove as BranchRemoverOptionsRemoveFunction;
await remove({
context,
branch: {
merged: true,
name: 'issue-100',
updatedDate: new Date(),
hasUncommittedChanges: false,
},
});
expect(writer.toString()).to.be.not.contain('"level":"debug"');
});
});
});