Add image-manager tests

Change-type: patch
This commit is contained in:
myarmolinsky 2024-09-18 12:51:06 -04:00
parent ff9bb52a20
commit 251d64eb88
7 changed files with 676 additions and 38 deletions

98
npm-shrinkwrap.json generated
View File

@ -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": {

View File

@ -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"
},

View File

@ -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>} 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<String>} 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<Boolean>} 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<String>} 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<fs.ReadStream>} 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<fs.WriteStream & { persistCache: () => Promise<void>, removeCache: () => Promise<void> }>} 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<NodeJS.ReadableStream>} 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<DownloadConfig, 'deviceType' | 'version'> = {},
) => {
if (versionOrRange == null) {

5
tests/utils.ts Normal file
View File

@ -0,0 +1,5 @@
export async function delay(ms: number) {
await new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

View File

@ -0,0 +1 @@
Lorem ipsum dolor sit amet

Binary file not shown.

View File

@ -0,0 +1,559 @@
import * as stream from 'stream';
import { AssertionError, expect } from 'chai';
import { stub } from 'sinon';
import * as tmp from 'tmp';
import { delay } from '../../utils';
import * as fs from 'fs';
import * as fsAsync from 'fs/promises';
import * as stringToStream from 'string-to-stream';
import { Writable as WritableStream } from 'stream';
import * as imageManager from '../../../build/utils/image-manager';
import { resolve, extname } from 'path';
import * as mockFs from 'mock-fs';
import * as rimraf from 'rimraf';
import { promisify } from 'util';
import * as os from 'os';
// Make sure we're all using literally the same instance of balena-sdk
// so we can mock out methods called by the real code
import { getBalenaSdk } from '../../../build/utils/lazy';
const balena = getBalenaSdk();
const fsExistsAsync = promisify(fs.exists);
const clean = async () => {
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;
});
});
});
});