From 251d64eb8831555e2cfc8a54c73701eadb8c4f06 Mon Sep 17 00:00:00 2001 From: myarmolinsky Date: Wed, 18 Sep 2024 12:51:06 -0400 Subject: [PATCH] Add `image-manager` tests Change-type: patch --- npm-shrinkwrap.json | 98 ++- package.json | 5 + src/utils/image-manager.ts | 46 +- tests/utils.ts | 5 + tests/utils/image-manager/fixtures/lorem.txt | 1 + tests/utils/image-manager/fixtures/lorem.zip | Bin 0 -> 195 bytes .../utils/image-manager/image-manager.spec.ts | 559 ++++++++++++++++++ 7 files changed, 676 insertions(+), 38 deletions(-) create mode 100644 tests/utils.ts create mode 100644 tests/utils/image-manager/fixtures/lorem.txt create mode 100644 tests/utils/image-manager/fixtures/lorem.zip create mode 100644 tests/utils/image-manager/image-manager.spec.ts diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 7e1cb2c2..55087c86 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -120,7 +120,9 @@ "@types/mime": "^3.0.4", "@types/mixpanel": "^2.14.3", "@types/mocha": "^10.0.7", + "@types/mock-fs": "^4.13.4", "@types/mock-require": "^2.0.1", + "@types/mockery": "^1.4.33", "@types/ndjson": "^2.0.1", "@types/node": "^20.0.0", "@types/node-cleanup": "^2.1.2", @@ -159,13 +161,16 @@ "jsonwebtoken": "^9.0.0", "klaw": "^4.1.0", "mocha": "^10.6.0", + "mock-fs": "^5.2.0", "mock-require": "^3.0.3", + "mockery": "^2.1.0", "nock": "^13.2.1", "oclif": "^4.14.0", "parse-link-header": "^2.0.0", "rewire": "^7.0.0", "simple-git": "^3.14.1", "sinon": "^18.0.0", + "string-to-stream": "^3.0.1", "ts-node": "^10.4.0", "typescript": "^5.6.2" }, @@ -3844,6 +3849,15 @@ "integrity": "sha512-GN8yJ1mNTcFcah/wKEFIJckJx9iJLoMSzWcfRRuxz/Jk+U6KQNnml+etbtxFK8lPjzOw3zp4Ha/kjSst9fsHYw==", "dev": true }, + "node_modules/@types/mock-fs": { + "version": "4.13.4", + "resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.4.tgz", + "integrity": "sha512-mXmM0o6lULPI8z3XNnQCpL0BGxPwx1Ul1wXYEPBGl4efShyxW2Rln0JOPEWGyZaYZMM6OVXM/15zUuFMY52ljg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/mock-require": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/mock-require/-/mock-require-2.0.1.tgz", @@ -3853,6 +3867,12 @@ "@types/node": "*" } }, + "node_modules/@types/mockery": { + "version": "1.4.33", + "resolved": "https://registry.npmjs.org/@types/mockery/-/mockery-1.4.33.tgz", + "integrity": "sha512-vpuuVxCnCEM0OakYNoyFs40mjJFJFJahBHyx0Z0Piysof+YwlDJzNO4V1weRvYySAmtAvlb0UHtxVO2IfTcykw==", + "dev": true + }, "node_modules/@types/mute-stream": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz", @@ -5454,6 +5474,15 @@ "node": ">=18" } }, + "node_modules/balena-device-init/node_modules/string-to-stream": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string-to-stream/-/string-to-stream-1.1.1.tgz", + "integrity": "sha512-QySF2+3Rwq0SdO3s7BAp4x+c3qsClpPQ6abAmb0DGViiSBAkT5kL6JT2iyzEVP+T1SmzHrQD1TwlP9QAHCc+Sw==", + "dependencies": { + "inherits": "^2.0.1", + "readable-stream": "^2.1.0" + } + }, "node_modules/balena-errors": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/balena-errors/-/balena-errors-4.9.0.tgz", @@ -5592,6 +5621,18 @@ "undici-types": "~5.26.4" } }, + "node_modules/balena-sdk/node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/balena-semver": { "version": "2.3.5", "resolved": "https://registry.npmjs.org/balena-semver/-/balena-semver-2.3.5.tgz", @@ -12370,14 +12411,15 @@ } }, "node_modules/mime": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", - "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "license": "MIT", "bin": { "mime": "cli.js" }, "engines": { - "node": ">=10.0.0" + "node": ">=4.0.0" } }, "node_modules/mime-db": { @@ -12597,6 +12639,15 @@ "node": ">=10" } }, + "node_modules/mock-fs": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz", + "integrity": "sha512-2dF2R6YMSZbpip1V1WHKGLNjr/k48uQClqMVb5H3MOvwc9qhYis3/IWbj02qIg/Y8MDXKFF4c5v0rxx2o6xTZw==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/mock-require": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/mock-require/-/mock-require-3.0.3.tgz", @@ -12628,6 +12679,12 @@ "node": ">=0.10.0" } }, + "node_modules/mockery": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mockery/-/mockery-2.1.0.tgz", + "integrity": "sha512-9VkOmxKlWXoDO/h1jDZaS4lH33aWfRiJiNT/tKj+8OGzrcFDLo8d0syGdbsc3Bc4GvRXPb+NMMvojotmuGJTvA==", + "dev": true + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -15471,6 +15528,15 @@ "string-to-stream": "^1.0.1" } }, + "node_modules/rindle/node_modules/string-to-stream": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string-to-stream/-/string-to-stream-1.1.1.tgz", + "integrity": "sha512-QySF2+3Rwq0SdO3s7BAp4x+c3qsClpPQ6abAmb0DGViiSBAkT5kL6JT2iyzEVP+T1SmzHrQD1TwlP9QAHCc+Sw==", + "dependencies": { + "inherits": "^2.0.1", + "readable-stream": "^2.1.0" + } + }, "node_modules/roarr": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", @@ -16342,12 +16408,26 @@ } }, "node_modules/string-to-stream": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string-to-stream/-/string-to-stream-1.1.1.tgz", - "integrity": "sha512-QySF2+3Rwq0SdO3s7BAp4x+c3qsClpPQ6abAmb0DGViiSBAkT5kL6JT2iyzEVP+T1SmzHrQD1TwlP9QAHCc+Sw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/string-to-stream/-/string-to-stream-3.0.1.tgz", + "integrity": "sha512-Hl092MV3USJuUCC6mfl9sPzGloA3K5VwdIeJjYIkXY/8K+mUvaeEabWJgArp+xXrsWxCajeT2pc4axbVhIZJyg==", + "dev": true, "dependencies": { - "inherits": "^2.0.1", - "readable-stream": "^2.1.0" + "readable-stream": "^3.4.0" + } + }, + "node_modules/string-to-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" } }, "node_modules/string-width": { diff --git a/package.json b/package.json index 4381108f..27e3bc55 100644 --- a/package.json +++ b/package.json @@ -141,7 +141,9 @@ "@types/mime": "^3.0.4", "@types/mixpanel": "^2.14.3", "@types/mocha": "^10.0.7", + "@types/mock-fs": "^4.13.4", "@types/mock-require": "^2.0.1", + "@types/mockery": "^1.4.33", "@types/ndjson": "^2.0.1", "@types/node": "^20.0.0", "@types/node-cleanup": "^2.1.2", @@ -180,13 +182,16 @@ "jsonwebtoken": "^9.0.0", "klaw": "^4.1.0", "mocha": "^10.6.0", + "mock-fs": "^5.2.0", "mock-require": "^3.0.3", + "mockery": "^2.1.0", "nock": "^13.2.1", "oclif": "^4.14.0", "parse-link-header": "^2.0.0", "rewire": "^7.0.0", "simple-git": "^3.14.1", "sinon": "^18.0.0", + "string-to-stream": "^3.0.1", "ts-node": "^10.4.0", "typescript": "^5.6.2" }, diff --git a/src/utils/image-manager.ts b/src/utils/image-manager.ts index 204bc6fd..fda0d384 100644 --- a/src/utils/image-manager.ts +++ b/src/utils/image-manager.ts @@ -23,8 +23,6 @@ const BALENAOS_VERSION_REGEX = /v?\d+\.\d+\.\d+(\.rev\d+)?((\-|\+).+)?/; /** * @summary Check if the string is a valid balenaOS version number - * @function - * @protected * @description Throws an error if the version is invalid * * @param {String} version - version number to validate @@ -38,17 +36,15 @@ const validateVersion = (version: string) => { /** * @summary Get file created date - * @function - * @protected * * @param {String} filePath - file path * @returns {Promise} date since creation * * @example - * utils.getFileCreatedDate('foo/bar').then (createdTime) -> + * getFileCreatedDate('foo/bar').then (createdTime) -> * console.log("The file was created in #{createdTime}") */ -const getFileCreatedDate = async (filePath: string) => { +export const getFileCreatedDate = async (filePath: string) => { const { promises: fs } = await import('fs'); const { ctime } = await fs.stat(filePath); return ctime; @@ -56,18 +52,16 @@ const getFileCreatedDate = async (filePath: string) => { /** * @summary Get path to image in cache - * @function - * @protected * * @param {String} deviceType - device type slug or alias * @param {String} version - the exact balenaOS version number * @returns {Promise} image path * * @example - * cache.getImagePath('raspberry-pi', '1.2.3').then (imagePath) -> + * getImagePath('raspberry-pi', '1.2.3').then (imagePath) -> * console.log(imagePath) */ -const getImagePath = async (deviceType: string, version?: string) => { +export const getImagePath = async (deviceType: string, version?: string) => { if (typeof version === 'string') { validateVersion(version); } @@ -83,8 +77,6 @@ const getImagePath = async (deviceType: string, version?: string) => { /** * @summary Determine if a device image is fresh - * @function - * @protected * * @description * If the device image does not exist, return false. @@ -94,17 +86,17 @@ const getImagePath = async (deviceType: string, version?: string) => { * @returns {Promise} is image fresh * * @example - * utils.isImageFresh('raspberry-pi', '1.2.3').then (isFresh) -> + * isImageFresh('raspberry-pi', '1.2.3').then (isFresh) -> * if isFresh * console.log('The Raspberry Pi image v1.2.3 is fresh!') */ -const isImageFresh = async (deviceType: string, version: string) => { +export const isImageFresh = async (deviceType: string, version: string) => { const imagePath = await getImagePath(deviceType, version); let createdDate; try { createdDate = await getFileCreatedDate(imagePath); } catch { - // Swallow errors from utils.getFileCreatedTime. + // Swallow errors from getFileCreatedTime. } if (createdDate == null) { return false; @@ -132,13 +124,11 @@ export const isESR = (version: string) => { /** * @summary Get the most recent compatible version - * @function - * @protected * * @param {String} deviceType - device type slug or alias * @param {String} versionOrRange - supports the same version options * as `balena.models.os.getMaxSatisfyingVersion`. - * See `manager.get` for the detailed explanation. + * See `getStream` for the detailed explanation. * @returns {Promise} the most recent compatible version. */ const resolveVersion = async (deviceType: string, versionOrRange: string) => { @@ -156,18 +146,16 @@ const resolveVersion = async (deviceType: string, versionOrRange: string) => { /** * @summary Get an image from the cache - * @function - * @protected * * @param {String} deviceType - device type slug or alias * @param {String} version - the exact balenaOS version number * @returns {Promise} image readable stream * * @example - * utils.getImage('raspberry-pi', '1.2.3').then (stream) -> + * getImage('raspberry-pi', '1.2.3').then (stream) -> * stream.pipe(fs.createWriteStream('foo/bar.img')) */ -const getImage = async (deviceType: string, version: string) => { +export const getImage = async (deviceType: string, version: string) => { const imagePath = await getImagePath(deviceType, version); const fs = await import('fs'); const stream = fs.createReadStream(imagePath) as ReturnType< @@ -182,18 +170,19 @@ const getImage = async (deviceType: string, version: string) => { /** * @summary Get a writable stream for an image in the cache - * @function - * @protected * * @param {String} deviceType - device type slug or alias * @param {String} version - the exact balenaOS version number * @returns {Promise Promise, removeCache: () => Promise }>} image writable stream * * @example - * utils.getImageWritableStream('raspberry-pi', '1.2.3').then (stream) -> + * getImageWritableStream('raspberry-pi', '1.2.3').then (stream) -> * fs.createReadStream('foo/bar').pipe(stream) */ -const getImageWritableStream = async (deviceType: string, version?: string) => { +export const getImageWritableStream = async ( + deviceType: string, + version?: string, +) => { const imagePath = await getImagePath(deviceType, version); // Ensure the cache directory exists, to prevent @@ -260,7 +249,6 @@ const doDownload = async (options: DownloadConfig) => { /** * @summary Get a device operating system image - * @function * @public * * @description @@ -286,12 +274,12 @@ const doDownload = async (options: DownloadConfig) => { * @returns {Promise} image readable stream * * @example - * manager.get('raspberry-pi', 'default').then (stream) -> + * getStream('raspberry-pi', 'default').then (stream) -> * stream.pipe(fs.createWriteStream('foo/bar.img')) */ export const getStream = async ( deviceType: string, - versionOrRange: string, + versionOrRange?: string, options: Omit = {}, ) => { if (versionOrRange == null) { diff --git a/tests/utils.ts b/tests/utils.ts new file mode 100644 index 00000000..a3e16531 --- /dev/null +++ b/tests/utils.ts @@ -0,0 +1,5 @@ +export async function delay(ms: number) { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/tests/utils/image-manager/fixtures/lorem.txt b/tests/utils/image-manager/fixtures/lorem.txt new file mode 100644 index 00000000..dc8344c1 --- /dev/null +++ b/tests/utils/image-manager/fixtures/lorem.txt @@ -0,0 +1 @@ +Lorem ipsum dolor sit amet diff --git a/tests/utils/image-manager/fixtures/lorem.zip b/tests/utils/image-manager/fixtures/lorem.zip new file mode 100644 index 0000000000000000000000000000000000000000..bc9e292943ff82fb338a13baeb8e5b2dd2c096a7 GIT binary patch literal 195 zcmWIWW@h1H0D;qSQtq8~wj9zxHVAVv$S~yO7p3Ovl~k03hHx@4>pi?1S_Z_W72FJr zEMFNJ7+6Gr3VlF|6fz5nOLG-c@_||uiZe?T5_3~axB|QxndF#pnJ)p<%)kINhG9t~ ch=pVuE5tT5n*zL9*+2>zfzTgF$ALHu0Q { + await promisify(rimraf)(await balena.settings.get('cacheDirectory')); +}; + +describe('image-manager', function () { + describe('.getStream()', () => { + describe('given the existing image', function () { + beforeEach(function () { + this.image = tmp.fileSync(); + fs.writeSync(this.image.fd, 'Cache image', 0, 'utf8'); + + this.cacheGetImagePathStub = stub(imageManager, 'getImagePath'); + return this.cacheGetImagePathStub.returns( + Promise.resolve(this.image.name), + ); + }); + + afterEach(function () { + this.cacheGetImagePathStub.restore(); + return this.image.removeCallback(); + }); + + describe('given the image is fresh', function () { + beforeEach(function () { + this.cacheIsImageFresh = stub(imageManager, 'isImageFresh'); + return this.cacheIsImageFresh.returns(Promise.resolve(true)); + }); + + afterEach(function () { + return this.cacheIsImageFresh.restore(); + }); + + it('should eventually become a readable stream of the cached image', function (done) { + this.timeout(5000); + + void imageManager.getStream('raspberry-pi').then(function (stream) { + let result = ''; + + stream.on('data', (chunk) => (result += chunk.toString())); + + return stream.on('end', function () { + expect(result).to.equal('Cache image'); + return done(); + }); + }); + }); + }); + + describe('given the image is not fresh', function () { + beforeEach(function () { + this.cacheIsImageFresh = stub(imageManager, 'isImageFresh'); + return this.cacheIsImageFresh.returns(Promise.resolve(false)); + }); + + afterEach(function () { + return this.cacheIsImageFresh.restore(); + }); + + describe('given a valid download endpoint', function () { + beforeEach(function () { + this.osDownloadStub = stub(balena.models.os, 'download'); + this.osDownloadStub.returns( + Promise.resolve(stringToStream('Download image')), + ); + }); + + afterEach(function () { + this.osDownloadStub.restore(); + }); + + it('should eventually become a readable stream of the download image and save a backup copy', function (done) { + void imageManager.getStream('raspberry-pi').then((stream) => { + let result = ''; + + stream.on('data', (chunk) => (result += chunk)); + + stream.on('end', async () => { + expect(result).to.equal('Download image'); + const contents = await fsAsync.readFile(this.image.name, { + encoding: 'utf8', + }); + expect(contents).to.equal('Download image'); + done(); + }); + }); + }); + + it('should be able to read from the stream after a slight delay', function (done) { + void imageManager.getStream('raspberry-pi').then(async (s) => { + await delay(200); + + const pass = new stream.PassThrough(); + s.pipe(pass); + + let result = ''; + + pass.on('data', (chunk) => (result += chunk)); + + pass.on('end', function () { + expect(result).to.equal('Download image'); + done(); + }); + }); + }); + }); + + describe('given a failing download', function () { + beforeEach(function () { + this.osDownloadStream = new stream.PassThrough(); + this.osDownloadStub = stub(balena.models.os, 'download'); + this.osDownloadStub.returns(Promise.resolve(this.osDownloadStream)); + }); + + afterEach(function () { + this.osDownloadStub.restore(); + }); + + it('should clean up the in progress cached stream if an error occurs', function (done) { + void imageManager.getStream('raspberry-pi').then((stream) => { + stream.on('data', () => { + // After the first chunk, error + return this.osDownloadStream.emit('error'); + }); + + stream.on('error', async () => { + const contents = await fsAsync + .stat(this.image.name + '.inprogress') + .then(function () { + throw new AssertionError( + 'Image cache should be deleted on failure', + ); + }) + .catch((err) => { + if (err.code !== 'ENOENT') { + throw err; + } + return fsAsync.readFile(this.image.name, { + encoding: 'utf8', + }); + }); + expect(contents).to.equal('Cache image'); + done(); + }); + + stringToStream('Download image').pipe(this.osDownloadStream); + }); + }); + }); + + describe('given a stream with the mime property', async function () { + beforeEach(function () { + this.osDownloadStub = stub(balena.models.os, 'download'); + const message = 'Lorem ipsum dolor sit amet'; + const mockResultStream = stringToStream(message) as ReturnType< + typeof stringToStream + > & { + mime?: string; + }; + mockResultStream.mime = 'application/zip'; + this.osDownloadStub.returns(Promise.resolve(mockResultStream)); + }); + + afterEach(function () { + this.osDownloadStub.restore(); + }); + + it('should preserve the property', () => + imageManager + .getStream('raspberry-pi') + .then((resultStream) => + expect(resultStream.mime).to.equal('application/zip'), + )); + }); + }); + }); + }); + + describe('.getImagePath()', () => { + describe('given a cache directory', function () { + beforeEach(function () { + this.balenaSettingsGetStub = stub(balena.settings, 'get'); + + this.balenaSettingsGetStub + .withArgs('cacheDirectory') + .returns( + Promise.resolve( + os.platform() === 'win32' + ? 'C:\\Users\\johndoe\\_balena\\cache' + : '/Users/johndoe/.balena/cache', + ), + ); + }); + + afterEach(function () { + this.balenaSettingsGetStub.restore(); + }); + + describe('given valid slugs', function () { + beforeEach(function () { + this.getDeviceTypeManifestBySlugStub = stub( + balena.models.config, + 'getDeviceTypeManifestBySlug', + ); + this.getDeviceTypeManifestBySlugStub.withArgs('raspberry-pi').returns( + Promise.resolve({ + yocto: { + fstype: 'resin-sdcard', + }, + }), + ); + + this.getDeviceTypeManifestBySlugStub.withArgs('intel-edison').returns( + Promise.resolve({ + yocto: { + fstype: 'zip', + }, + }), + ); + }); + + afterEach(function () { + this.getDeviceTypeManifestBySlugStub.restore(); + }); + + it('should eventually equal an absolute path', async () => { + await imageManager + .getImagePath('raspberry-pi', '1.2.3') + .then(function (imagePath) { + const isAbsolute = imagePath === resolve(imagePath); + expect(isAbsolute).to.be.true; + }); + }); + + it('should eventually equal the correct path', async function () { + const result = await imageManager.getImagePath( + 'raspberry-pi', + '1.2.3', + ); + expect(result).to.equal( + os.platform() === 'win32' + ? 'C:\\Users\\johndoe\\_balena\\cache\\raspberry-pi-v1.2.3.img' + : '/Users/johndoe/.balena/cache/raspberry-pi-v1.2.3.img', + ); + }); + + it('should use a zip extension for directory images', async () => { + const imagePath = await imageManager.getImagePath( + 'intel-edison', + '1.2.3', + ); + expect(extname(imagePath)).to.equal('.zip'); + }); + + it('given invalid version should be rejected', async function () { + const promise = imageManager.getImagePath('intel-edison', 'DOUGH'); + await expect(promise).to.be.eventually.rejectedWith( + 'Invalid version number', + ); + }); + }); + }); + }); + + describe('.isImageFresh()', () => { + describe('given the raspberry-pi manifest', function () { + beforeEach(function () { + this.getDeviceTypeManifestBySlugStub = stub( + balena.models.config, + 'getDeviceTypeManifestBySlug', + ); + this.getDeviceTypeManifestBySlugStub.returns( + Promise.resolve({ + yocto: { + fstype: 'balena-sdcard', + }, + }), + ); + }); + + afterEach(function () { + this.getDeviceTypeManifestBySlugStub.restore(); + }); + + describe('given the file does not exist', function () { + beforeEach(function () { + this.utilsGetFileCreatedDate = stub( + imageManager, + 'getFileCreatedDate', + ); + this.utilsGetFileCreatedDate.returns( + Promise.reject(new Error("ENOENT, stat 'raspberry-pi'")), + ); + }); + + afterEach(function () { + this.utilsGetFileCreatedDate.restore(); + }); + + it('should return false', async function () { + expect(await imageManager.isImageFresh('raspberry-pi', '1.2.3')).to.be + .false; + }); + }); + + describe('given a fixed created time', function () { + beforeEach(function () { + this.utilsGetFileCreatedDate = stub( + imageManager, + 'getFileCreatedDate', + ); + this.utilsGetFileCreatedDate.returns( + Promise.resolve(new Date('2014-01-01T00:00:00.000Z')), + ); + }); + + afterEach(function () { + this.utilsGetFileCreatedDate.restore(); + }); + + describe('given the file was created before the os last modified time', function () { + beforeEach(function () { + this.osGetLastModified = stub(balena.models.os, 'getLastModified'); + this.osGetLastModified.returns( + Promise.resolve(new Date('2014-02-01T00:00:00.000Z')), + ); + }); + + afterEach(function () { + this.osGetLastModified.restore(); + }); + + it('should return false', function () { + const promise = imageManager.isImageFresh('raspberry-pi', '1.2.3'); + return expect(promise).to.eventually.be.false; + }); + }); + + describe('given the file was created after the os last modified time', function () { + beforeEach(function () { + this.osGetLastModified = stub(balena.models.os, 'getLastModified'); + this.osGetLastModified.returns( + Promise.resolve(new Date('2013-01-01T00:00:00.000Z')), + ); + }); + + afterEach(function () { + this.osGetLastModified.restore(); + }); + + it('should return true', function () { + const promise = imageManager.isImageFresh('raspberry-pi', '1.2.3'); + return expect(promise).to.eventually.be.true; + }); + }); + + describe('given the file was created just at the os last modified time', function () { + beforeEach(function () { + this.osGetLastModified = stub(balena.models.os, 'getLastModified'); + this.osGetLastModified.returns( + Promise.resolve(new Date('2014-00-01T00:00:00.000Z')), + ); + }); + + afterEach(function () { + this.osGetLastModified.restore(); + }); + + it('should return false', function () { + const promise = imageManager.isImageFresh('raspberry-pi', '1.2.3'); + return expect(promise).to.eventually.be.false; + }); + }); + }); + }); + }); + + describe('.getImage()', () => { + describe('given an existing image', function () { + beforeEach(function () { + this.image = tmp.fileSync(); + fs.writeSync(this.image.fd, 'Lorem ipsum dolor sit amet', 0, 'utf8'); + + this.cacheGetImagePathStub = stub(imageManager, 'getImagePath'); + this.cacheGetImagePathStub.returns(Promise.resolve(this.image.name)); + }); + + afterEach(function (done) { + this.cacheGetImagePathStub.restore(); + fs.unlink(this.image.name, done); + }); + + it('should return a stream to the image', function (done) { + void imageManager + .getImage('lorem-ipsum', '1.2.3') + .then(function (stream) { + let result = ''; + + stream.on('data', (chunk) => (result += chunk)); + + stream.on('end', function () { + expect(result).to.equal('Lorem ipsum dolor sit amet'); + done(); + }); + }); + }); + + it('should contain the mime property', () => + imageManager + .getImage('lorem-ipsum', '1.2.3') + .then((stream) => + expect(stream.mime).to.equal('application/octet-stream'), + )); + }); + }); + + describe('.getImageWritableStream()', () => { + describe('given the valid image path', function () { + beforeEach(function () { + this.image = tmp.fileSync(); + this.cacheGetImagePathStub = stub(imageManager, 'getImagePath'); + this.cacheGetImagePathStub.returns(Promise.resolve(this.image.name)); + }); + + afterEach(function (done) { + this.cacheGetImagePathStub.restore(); + fs.unlink(this.image.name, done); + }); + + it('should return a writable stream', () => + imageManager + .getImageWritableStream('raspberry-pi', '1.2.3') + .then((stream) => + expect(stream).to.be.an.instanceof(WritableStream), + )); + + it('should allow writing to the stream', function (done) { + void imageManager + .getImageWritableStream('raspberry-pi', '1.2.3') + .then((stream) => { + const stringStream = stringToStream('Lorem ipsum dolor sit amet'); + stringStream.pipe(stream); + stream.on('finish', async () => { + await stream.persistCache(); + const contents = await fsAsync.readFile(this.image.name, { + encoding: 'utf8', + }); + expect(contents).to.equal('Lorem ipsum dolor sit amet'); + done(); + }); + }); + }); + }); + }); + + describe('.getFileCreatedDate()', function () { + describe('given the file exists', function () { + beforeEach(function () { + this.date = new Date(2014, 1, 1); + this.fsStatStub = stub(fs.promises, 'stat'); + this.fsStatStub + .withArgs('foo') + .returns(Promise.resolve({ ctime: this.date })); + }); + + afterEach(function () { + this.fsStatStub.restore(); + }); + + it('should eventually equal the created time in milliseconds', async function () { + const promise = imageManager.getFileCreatedDate('foo'); + await expect(promise).to.eventually.equal(this.date); + }); + }); + + describe('given the file does not exist', function () { + beforeEach(function () { + this.fsStatStub = stub(fs.promises, 'stat'); + this.fsStatStub + .withArgs('foo') + .returns(Promise.reject(new Error("ENOENT, stat 'foo'"))); + }); + + afterEach(function () { + this.fsStatStub.restore(); + }); + + it('should be rejected with an error', async function () { + const promise = imageManager.getFileCreatedDate('foo'); + await expect(promise).to.be.rejectedWith('ENOENT'); + }); + }); + }); + + describe('.clean()', function () { + describe('given the cache with saved images', function () { + beforeEach(async function () { + this.cacheDirectory = await balena.settings.get('cacheDirectory'); + mockFs({ + [this.cacheDirectory]: { + 'raspberry-pi': 'Raspberry Pi Image', + 'intel-edison': 'Intel Edison Image', + parallela: 'Parallela Image', + }, + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('should remove the cache directory completely', async function () { + const exists = await fsExistsAsync(this.cacheDirectory); + expect(exists).to.be.true; + await clean(); + const exists2 = await fsExistsAsync(this.cacheDirectory); + expect(exists2).to.be.false; + }); + }); + + describe('given no cache', function () { + beforeEach(async function () { + this.cacheDirectory = await balena.settings.get('cacheDirectory'); + mockFs({}); + }); + + afterEach(() => mockFs.restore()); + + it('should keep the cache directory removed', async function () { + const exists = await fsExistsAsync(this.cacheDirectory); + expect(exists).to.be.false; + await clean(); + const exists2 = await fsExistsAsync(this.cacheDirectory); + expect(exists2).to.be.false; + }); + }); + }); +});