// Imports import * as cp from 'child_process'; import {EventEmitter} from 'events'; import * as fs from 'fs'; import * as path from 'path'; import * as shell from 'shelljs'; import {SHORT_SHA_LEN} from '../../lib/common/constants'; import {BuildCreator} from '../../lib/preview-server/build-creator'; import {ChangedPrVisibilityEvent, CreatedBuildEvent} from '../../lib/preview-server/build-events'; import {PreviewServerError} from '../../lib/preview-server/preview-error'; import {expectToBePreviewServerError} from './helpers'; // Tests describe('BuildCreator', () => { const pr = 9; const sha = '9'.repeat(40); const shortSha = sha.substr(0, SHORT_SHA_LEN); const archive = 'snapshot.tar.gz'; const buildsDir = 'builds/dir'; const hiddenPrDir = path.join(buildsDir, `hidden--${pr}`); const publicPrDir = path.join(buildsDir, `${pr}`); const hiddenShaDir = path.join(hiddenPrDir, shortSha); const publicShaDir = path.join(publicPrDir, shortSha); let bc: BuildCreator; beforeEach(() => bc = new BuildCreator(buildsDir)); describe('constructor()', () => { it('should throw if \'buildsDir\' is missing or empty', () => { expect(() => new BuildCreator('')).toThrowError('Missing or empty required parameter \'buildsDir\'!'); }); it('should extend EventEmitter', () => { expect(bc).toEqual(jasmine.any(BuildCreator)); expect(bc).toEqual(jasmine.any(EventEmitter)); expect(Object.getPrototypeOf(bc)).toBe(BuildCreator.prototype); }); }); describe('create()', () => { let bcEmitSpy: jasmine.Spy; let bcExistsSpy: jasmine.Spy; let bcExtractArchiveSpy: jasmine.Spy; let bcUpdatePrVisibilitySpy: jasmine.Spy; let shellMkdirSpy: jasmine.Spy; let shellRmSpy: jasmine.Spy; beforeEach(() => { bcEmitSpy = spyOn(bc, 'emit'); bcExistsSpy = spyOn(bc as any, 'exists'); bcExtractArchiveSpy = spyOn(bc as any, 'extractArchive'); bcUpdatePrVisibilitySpy = spyOn(bc, 'updatePrVisibility'); shellMkdirSpy = spyOn(shell, 'mkdir'); shellRmSpy = spyOn(shell, 'rm'); }); [true, false].forEach(isPublic => { const prDir = isPublic ? publicPrDir : hiddenPrDir; const shaDir = isPublic ? publicShaDir : hiddenShaDir; it('should return a promise', done => { const promise = bc.create(pr, sha, archive, isPublic); promise.then(done); // Do not complete the test (and release the spies) synchronously // to avoid running the actual `extractArchive()`. expect(promise).toEqual(jasmine.any(Promise)); }); it('should update the PR\'s visibility first if necessary', done => { bcUpdatePrVisibilitySpy.and.callFake(() => expect(shellMkdirSpy).not.toHaveBeenCalled()); bc.create(pr, sha, archive, isPublic). then(() => { expect(bcUpdatePrVisibilitySpy).toHaveBeenCalledWith(pr, isPublic); expect(shellMkdirSpy).toHaveBeenCalled(); }). then(done); }); it('should create the build directory (and any parent directories)', done => { bc.create(pr, sha, archive, isPublic). then(() => expect(shellMkdirSpy).toHaveBeenCalledWith('-p', shaDir)). then(done); }); it('should extract the archive contents into the build directory', done => { bc.create(pr, sha, archive, isPublic). then(() => expect(bcExtractArchiveSpy).toHaveBeenCalledWith(archive, shaDir)). then(done); }); it('should emit a CreatedBuildEvent on success', done => { let emitted = false; bcEmitSpy.and.callFake((type: string, evt: CreatedBuildEvent) => { expect(type).toBe(CreatedBuildEvent.type); expect(evt).toEqual(jasmine.any(CreatedBuildEvent)); expect(evt.pr).toBe(+pr); expect(evt.sha).toBe(shortSha); expect(evt.isPublic).toBe(isPublic); emitted = true; }); bc.create(pr, sha, archive, isPublic). then(() => expect(emitted).toBe(true)). then(done); }); describe('on error', () => { let existsValues: {[dir: string]: boolean}; beforeEach(() => { existsValues = { [prDir]: false, [shaDir]: false, }; bcExistsSpy.and.callFake((dir: string) => existsValues[dir]); }); it('should abort and skip further operations if changing the PR\'s visibility fails', done => { const mockError = new PreviewServerError(543, 'Test'); bcUpdatePrVisibilitySpy.and.callFake(() => Promise.reject(mockError)); bc.create(pr, sha, archive, isPublic).catch(err => { expect(err).toBe(mockError); expect(bcExistsSpy).not.toHaveBeenCalled(); expect(shellMkdirSpy).not.toHaveBeenCalled(); expect(bcExtractArchiveSpy).not.toHaveBeenCalled(); expect(bcEmitSpy).not.toHaveBeenCalled(); done(); }); }); it('should abort and skip further operations if the build does already exist', done => { existsValues[shaDir] = true; bc.create(pr, sha, archive, isPublic).catch(err => { const publicOrNot = isPublic ? 'public' : 'non-public'; expectToBePreviewServerError(err, 409, `Request to overwrite existing ${publicOrNot} directory: ${shaDir}`); expect(shellMkdirSpy).not.toHaveBeenCalled(); expect(bcExtractArchiveSpy).not.toHaveBeenCalled(); expect(bcEmitSpy).not.toHaveBeenCalled(); done(); }); }); it('should detect existing build directory after visibility change', done => { bcUpdatePrVisibilitySpy.and.callFake(() => existsValues[prDir] = existsValues[shaDir] = true); expect(bcExistsSpy(prDir)).toBe(false); expect(bcExistsSpy(shaDir)).toBe(false); bc.create(pr, sha, archive, isPublic).catch(err => { const publicOrNot = isPublic ? 'public' : 'non-public'; expectToBePreviewServerError(err, 409, `Request to overwrite existing ${publicOrNot} directory: ${shaDir}`); expect(shellMkdirSpy).not.toHaveBeenCalled(); expect(bcExtractArchiveSpy).not.toHaveBeenCalled(); expect(bcEmitSpy).not.toHaveBeenCalled(); done(); }); }); it('should abort and skip further operations if it fails to create the directories', done => { shellMkdirSpy.and.throwError(''); bc.create(pr, sha, archive, isPublic).catch(() => { expect(shellMkdirSpy).toHaveBeenCalled(); expect(bcExtractArchiveSpy).not.toHaveBeenCalled(); expect(bcEmitSpy).not.toHaveBeenCalled(); done(); }); }); it('should abort and skip further operations if it fails to extract the archive', done => { bcExtractArchiveSpy.and.throwError(''); bc.create(pr, sha, archive, isPublic).catch(() => { expect(shellMkdirSpy).toHaveBeenCalled(); expect(bcExtractArchiveSpy).toHaveBeenCalled(); expect(bcEmitSpy).not.toHaveBeenCalled(); done(); }); }); it('should delete the PR directory (for new PR)', done => { bcExtractArchiveSpy.and.throwError(''); bc.create(pr, sha, archive, isPublic).catch(() => { expect(shellRmSpy).toHaveBeenCalledWith('-rf', prDir); done(); }); }); it('should delete the SHA directory (for existing PR)', done => { existsValues[prDir] = true; bcExtractArchiveSpy.and.throwError(''); bc.create(pr, sha, archive, isPublic).catch(() => { expect(shellRmSpy).toHaveBeenCalledWith('-rf', shaDir); done(); }); }); it('should reject with an PreviewServerError', done => { // tslint:disable-next-line: no-string-throw shellMkdirSpy.and.callFake(() => { throw 'Test'; }); bc.create(pr, sha, archive, isPublic).catch(err => { expectToBePreviewServerError(err, 500, `Error while creating preview at: ${shaDir}\nTest`); done(); }); }); it('should pass PreviewServerError instances unmodified', done => { shellMkdirSpy.and.callFake(() => { throw new PreviewServerError(543, 'Test'); }); bc.create(pr, sha, archive, isPublic).catch(err => { expectToBePreviewServerError(err, 543, 'Test'); done(); }); }); }); }); }); describe('updatePrVisibility()', () => { let bcEmitSpy: jasmine.Spy; let bcExistsSpy: jasmine.Spy; let bcListShasByDate: jasmine.Spy; let shellMvSpy: jasmine.Spy; beforeEach(() => { bcEmitSpy = spyOn(bc, 'emit'); bcExistsSpy = spyOn(bc as any, 'exists'); bcListShasByDate = spyOn(bc as any, 'listShasByDate'); shellMvSpy = spyOn(shell, 'mv'); bcExistsSpy.and.returnValues(Promise.resolve(true), Promise.resolve(false)); bcListShasByDate.and.returnValue([]); }); it('should return a promise', done => { const promise = bc.updatePrVisibility(pr, true); promise.then(done); // Do not complete the test (and release the spies) synchronously // to avoid running the actual `extractArchive()`. expect(promise).toEqual(jasmine.any(Promise)); }); [true, false].forEach(makePublic => { const oldPrDir = makePublic ? hiddenPrDir : publicPrDir; const newPrDir = makePublic ? publicPrDir : hiddenPrDir; it('should rename the directory', done => { bc.updatePrVisibility(pr, makePublic). then(() => expect(shellMvSpy).toHaveBeenCalledWith(oldPrDir, newPrDir)). then(done); }); describe('when the visibility is updated', () => { it('should resolve to true', done => { bc.updatePrVisibility(pr, makePublic). then(result => expect(result).toBe(true)). then(done); }); it('should rename the directory', done => { bc.updatePrVisibility(pr, makePublic). then(() => expect(shellMvSpy).toHaveBeenCalledWith(oldPrDir, newPrDir)). then(done); }); it('should emit a ChangedPrVisibilityEvent on success', done => { let emitted = false; bcEmitSpy.and.callFake((type: string, evt: ChangedPrVisibilityEvent) => { expect(type).toBe(ChangedPrVisibilityEvent.type); expect(evt).toEqual(jasmine.any(ChangedPrVisibilityEvent)); expect(evt.pr).toBe(+pr); expect(evt.shas).toEqual(jasmine.any(Array)); expect(evt.isPublic).toBe(makePublic); emitted = true; }); bc.updatePrVisibility(pr, makePublic). then(() => expect(emitted).toBe(true)). then(done); }); it('should include all shas in the emitted event', done => { const shas = ['foo', 'bar', 'baz']; let emitted = false; bcListShasByDate.and.callFake(() => Promise.resolve(shas)); bcEmitSpy.and.callFake((type: string, evt: ChangedPrVisibilityEvent) => { expect(bcListShasByDate).toHaveBeenCalledWith(newPrDir); expect(type).toBe(ChangedPrVisibilityEvent.type); expect(evt).toEqual(jasmine.any(ChangedPrVisibilityEvent)); expect(evt.pr).toBe(+pr); expect(evt.shas).toBe(shas); expect(evt.isPublic).toBe(makePublic); emitted = true; }); bc.updatePrVisibility(pr, makePublic). then(() => expect(emitted).toBe(true)). then(done); }); }); it('should do nothing if the visibility is already up-to-date', done => { bcExistsSpy.and.callFake((dir: string) => dir === newPrDir); bc.updatePrVisibility(pr, makePublic). then(result => { expect(result).toBe(false); expect(shellMvSpy).not.toHaveBeenCalled(); expect(bcListShasByDate).not.toHaveBeenCalled(); expect(bcEmitSpy).not.toHaveBeenCalled(); }). then(done); }); it('should do nothing if the PR directory does not exist', done => { bcExistsSpy.and.returnValue(false); bc.updatePrVisibility(pr, makePublic). then(result => { expect(result).toBe(false); expect(shellMvSpy).not.toHaveBeenCalled(); expect(bcListShasByDate).not.toHaveBeenCalled(); expect(bcEmitSpy).not.toHaveBeenCalled(); }). then(done); }); describe('on error', () => { it('should abort and skip further operations if both directories exist', done => { bcExistsSpy.and.returnValue(true); bc.updatePrVisibility(pr, makePublic).catch(err => { expectToBePreviewServerError(err, 409, `Request to move '${oldPrDir}' to existing directory '${newPrDir}'.`); expect(shellMvSpy).not.toHaveBeenCalled(); expect(bcListShasByDate).not.toHaveBeenCalled(); expect(bcEmitSpy).not.toHaveBeenCalled(); done(); }); }); it('should abort and skip further operations if it fails to rename the directory', done => { shellMvSpy.and.throwError(''); bc.updatePrVisibility(pr, makePublic).catch(() => { expect(shellMvSpy).toHaveBeenCalled(); expect(bcListShasByDate).not.toHaveBeenCalled(); expect(bcEmitSpy).not.toHaveBeenCalled(); done(); }); }); it('should abort and skip further operations if it fails to list the SHAs', done => { bcListShasByDate.and.throwError(''); bc.updatePrVisibility(pr, makePublic).catch(() => { expect(shellMvSpy).toHaveBeenCalled(); expect(bcListShasByDate).toHaveBeenCalled(); expect(bcEmitSpy).not.toHaveBeenCalled(); done(); }); }); it('should reject with an PreviewServerError', done => { // tslint:disable-next-line: no-string-throw shellMvSpy.and.callFake(() => { throw 'Test'; }); bc.updatePrVisibility(pr, makePublic).catch(err => { expectToBePreviewServerError(err, 500, `Error while making PR ${pr} ${makePublic ? 'public' : 'hidden'}.\nTest`); done(); }); }); it('should pass PreviewServerError instances unmodified', done => { shellMvSpy.and.callFake(() => { throw new PreviewServerError(543, 'Test'); }); bc.updatePrVisibility(pr, makePublic).catch(err => { expectToBePreviewServerError(err, 543, 'Test'); done(); }); }); }); }); }); // Protected methods describe('exists()', () => { let fsAccessSpy: jasmine.Spy; let fsAccessCbs: ((v?: any) => void)[]; beforeEach(() => { fsAccessCbs = []; fsAccessSpy = spyOn(fs, 'access').and.callFake((_: string, cb: (v?: any) => void) => fsAccessCbs.push(cb)); }); it('should return a promise', () => { expect((bc as any).exists('foo')).toEqual(jasmine.any(Promise)); }); it('should call \'fs.access()\' with the specified argument', () => { (bc as any).exists('foo'); expect(fsAccessSpy).toHaveBeenCalledWith('foo', jasmine.any(Function)); }); it('should resolve with \'true\' if \'fs.access()\' succeeds', done => { Promise. all([(bc as any).exists('foo'), (bc as any).exists('bar')]). then(results => expect(results).toEqual([true, true])). then(done); fsAccessCbs[0](); fsAccessCbs[1](null); }); it('should resolve with \'false\' if \'fs.access()\' errors', done => { Promise. all([(bc as any).exists('foo'), (bc as any).exists('bar')]). then(results => expect(results).toEqual([false, false])). then(done); fsAccessCbs[0]('Error'); fsAccessCbs[1](new Error()); }); }); describe('extractArchive()', () => { let consoleWarnSpy: jasmine.Spy; let shellChmodSpy: jasmine.Spy; let shellRmSpy: jasmine.Spy; let cpExecSpy: jasmine.Spy; let cpExecCbs: ((...args: any[]) => void)[]; beforeEach(() => { cpExecCbs = []; consoleWarnSpy = spyOn(console, 'warn'); shellChmodSpy = spyOn(shell, 'chmod'); shellRmSpy = spyOn(shell, 'rm'); cpExecSpy = spyOn(cp, 'exec').and.callFake((_: string, cb: (...args: any[]) => void) => cpExecCbs.push(cb)); }); it('should return a promise', () => { expect((bc as any).extractArchive('foo', 'bar')).toEqual(jasmine.any(Promise)); }); it('should "gunzip" and "untar" the input file into the output directory', () => { const cmd = 'tar --extract --gzip --directory "output/dir" --file "input/file"'; (bc as any).extractArchive('input/file', 'output/dir'); expect(cpExecSpy).toHaveBeenCalledWith(cmd, jasmine.any(Function)); }); it('should log (as a warning) any stderr output if extracting succeeded', done => { (bc as any).extractArchive('foo', 'bar'). then(() => expect(consoleWarnSpy) .toHaveBeenCalledWith(jasmine.any(String), 'BuildCreator: ', 'This is stderr')). then(done); cpExecCbs[0](null, 'This is stdout', 'This is stderr'); }); it('should make the build directory non-writable', done => { (bc as any).extractArchive('foo', 'bar'). then(() => expect(shellChmodSpy).toHaveBeenCalledWith('-R', 'a-w', 'bar')). then(done); cpExecCbs[0](); }); it('should delete the build artifact file on success', done => { (bc as any).extractArchive('input/file', 'output/dir'). then(() => expect(shellRmSpy).toHaveBeenCalledWith('-f', 'input/file')). then(done); cpExecCbs[0](); }); describe('on error', () => { it('should abort and skip further operations if it fails to extract the archive', done => { (bc as any).extractArchive('foo', 'bar').catch((err: any) => { expect(shellChmodSpy).not.toHaveBeenCalled(); expect(shellRmSpy).not.toHaveBeenCalled(); expect(err).toBe('Test'); done(); }); cpExecCbs[0]('Test'); }); it('should abort and skip further operations if it fails to make non-writable', done => { (bc as any).extractArchive('foo', 'bar').catch((err: any) => { expect(shellChmodSpy).toHaveBeenCalled(); expect(shellRmSpy).not.toHaveBeenCalled(); expect(err).toBe('Test'); done(); }); shellChmodSpy.and.callFake(() => { // tslint:disable-next-line: no-string-throw throw 'Test'; }); cpExecCbs[0](); }); it('should abort and reject if it fails to remove the build artifact file', done => { (bc as any).extractArchive('foo', 'bar').catch((err: any) => { expect(shellChmodSpy).toHaveBeenCalled(); expect(shellRmSpy).toHaveBeenCalled(); expect(err).toBe('Test'); done(); }); shellRmSpy.and.callFake(() => { // tslint:disable-next-line: no-string-throw throw 'Test'; }); cpExecCbs[0](); }); }); }); describe('listShasByDate()', () => { let shellLsSpy: jasmine.Spy; const lsResult = (name: string, mtimeMs: number, isDirectory = true) => ({ isDirectory: () => isDirectory, mtime: new Date(mtimeMs), name, }); beforeEach(() => { shellLsSpy = spyOn(shell, 'ls').and.returnValue([]); }); it('should return a promise', done => { const promise = (bc as any).listShasByDate('input/dir'); promise.then(done); // Do not complete the test (and release the spies) synchronously // to avoid running the actual `ls()`. expect(promise).toEqual(jasmine.any(Promise)); }); it('should `ls()` files with their metadata', done => { (bc as any).listShasByDate('input/dir'). then(() => expect(shellLsSpy).toHaveBeenCalledWith('-l', 'input/dir')). then(done); }); it('should reject if listing files fails', done => { shellLsSpy.and.callFake(() => Promise.reject('Test')); (bc as any).listShasByDate('input/dir').catch((err: string) => { expect(err).toBe('Test'); done(); }); }); it('should return the filenames', done => { shellLsSpy.and.callFake(() => Promise.resolve([ lsResult('foo', 100), lsResult('bar', 200), lsResult('baz', 300), ])); (bc as any).listShasByDate('input/dir'). then((shas: string[]) => expect(shas).toEqual(['foo', 'bar', 'baz'])). then(done); }); it('should sort by date', done => { shellLsSpy.and.callFake(() => Promise.resolve([ lsResult('foo', 300), lsResult('bar', 100), lsResult('baz', 200), ])); (bc as any).listShasByDate('input/dir'). then((shas: string[]) => expect(shas).toEqual(['bar', 'baz', 'foo'])). then(done); }); it('should not break with ShellJS\' custom `sort()` method', done => { const mockArray = [ lsResult('foo', 300), lsResult('bar', 100), lsResult('baz', 200), ]; mockArray.sort = jasmine.createSpy('sort'); shellLsSpy.and.callFake(() => Promise.resolve(mockArray)); (bc as any).listShasByDate('input/dir'). then((shas: string[]) => { expect(shas).toEqual(['bar', 'baz', 'foo']); expect(mockArray.sort).not.toHaveBeenCalled(); }). then(done); }); it('should only include directories', done => { shellLsSpy.and.callFake(() => Promise.resolve([ lsResult('foo', 100), lsResult('bar', 200, false), lsResult('baz', 300), ])); (bc as any).listShasByDate('input/dir'). then((shas: string[]) => expect(shas).toEqual(['foo', 'baz'])). then(done); }); }); });