balena-cli/tests/utils/device/live.spec.ts

376 lines
11 KiB
TypeScript
Raw Normal View History

/**
* @license
* Copyright 2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { expect } from 'chai';
import * as chokidar from 'chokidar';
import { promises as fs } from 'fs';
import * as _ from 'lodash';
import * as path from 'path';
import { promisify } from 'util';
import { LivepushManager } from '../../../lib/utils/device/live';
import { resetDockerignoreCache } from '../../docker-build';
import { setupDockerignoreTestData } from '../../projects';
const delay = promisify(setTimeout);
const FS_WATCH_DURATION_MS = 500;
const repoPath = path.normalize(path.join(__dirname, '..', '..', '..'));
const projectsPath = path.join(repoPath, 'tests', 'test-data', 'projects');
interface ByService<T> {
[serviceName: string]: T;
}
class MockLivepushManager extends LivepushManager {
public constructor() {
super({
buildContext: '',
composition: { version: '2.1', services: {} },
buildTasks: [],
docker: {} as import('dockerode'),
api: {} as import('../../../lib/utils/device/api').DeviceAPI,
logger: {} as import('../../../lib/utils/logger'),
imageIds: {},
deployOpts:
{} as import('../../../lib/utils/device/deploy').DeviceDeployOptions,
});
}
public testSetupFilesystemWatcher(
serviceName: string,
rootContext: string,
serviceContext: string,
changedPathHandler: (serviceName: string, changedPath: string) => void,
dockerignoreByService: ByService<import('@balena/dockerignore').Ignore>,
multiDockerignore: boolean,
): import('chokidar').FSWatcher {
return super.setupFilesystemWatcher(
serviceName,
rootContext,
serviceContext,
changedPathHandler,
dockerignoreByService,
multiDockerignore,
);
}
}
// "describeSS" stands for "describe Skip Standalone"
const describeSS =
process.env.BALENA_CLI_TEST_TYPE === 'standalone' ? describe.skip : describe;
describeSS('LivepushManager::setupFilesystemWatcher', function () {
const manager = new MockLivepushManager();
async function createMonitors(
projectPath: string,
composition: import('@balena/compose/dist/parse').Composition,
multiDockerignore: boolean,
changedPathHandler: (serviceName: string, changedPath: string) => void,
): Promise<ByService<chokidar.FSWatcher>> {
const { getServiceDirsFromComposition } = await import(
'../../../build/utils/compose_ts'
);
const { getDockerignoreByService } = await import(
'../../../build/utils/ignore'
);
const rootContext = path.resolve(projectPath);
const monitors: ByService<chokidar.FSWatcher> = {};
const serviceDirsByService = await getServiceDirsFromComposition(
projectPath,
composition,
);
const dockerignoreByService = await getDockerignoreByService(
projectPath,
multiDockerignore,
serviceDirsByService,
);
for (const serviceName of Object.keys(composition.services)) {
const service = composition.services[serviceName];
const serviceContext = path.resolve(rootContext, service.build!.context);
const monitor = manager.testSetupFilesystemWatcher(
serviceName,
rootContext,
serviceContext,
changedPathHandler,
dockerignoreByService,
multiDockerignore,
);
monitors[serviceName] = monitor;
await new Promise((resolve, reject) => {
monitor.on('error', reject);
monitor.on('ready', resolve);
});
}
return monitors;
}
this.beforeAll(async () => {
await setupDockerignoreTestData();
});
this.afterAll(async () => {
await setupDockerignoreTestData({ cleanup: true });
});
this.beforeEach(() => {
resetDockerignoreCache();
});
describe('for project no-docker-compose/basic', function () {
const projectPath = path.join(projectsPath, 'no-docker-compose', 'basic');
const composition = {
version: '2.1',
services: {
main: { build: { context: '.' } },
},
};
it('should trigger change events for paths that are not ignored', async () => {
const changedPaths: ByService<string[]> = { main: [] };
const multiDockerignore = true;
const monitors = await createMonitors(
projectPath,
composition,
multiDockerignore,
(serviceName: string, changedPath: string) => {
changedPaths[serviceName].push(changedPath);
},
);
await Promise.all([
touch(path.join(projectPath, 'Dockerfile')),
touch(path.join(projectPath, 'src', 'start.sh')),
touch(path.join(projectPath, 'src', 'windows-crlf.sh')),
]);
// wait a bit so that filesystem modifications are notified
await delay(FS_WATCH_DURATION_MS);
await Promise.all(
Object.values(monitors).map((monitor) => monitor.close()),
);
expect(changedPaths['main']).to.have.members([
'Dockerfile',
path.join('src', 'start.sh'),
path.join('src', 'windows-crlf.sh'),
]);
});
});
describe('for project no-docker-compose/dockerignore1', function () {
const projectPath = path.join(
projectsPath,
'no-docker-compose',
'dockerignore1',
);
const composition = {
version: '2.1',
services: {
main: { build: { context: '.' } },
},
};
it('should trigger change events for paths that are not ignored', async () => {
const changedPaths: ByService<string[]> = { main: [] };
const multiDockerignore = true;
const monitors = await createMonitors(
projectPath,
composition,
multiDockerignore,
(serviceName: string, changedPath: string) => {
changedPaths[serviceName].push(changedPath);
},
);
await Promise.all([
touch(path.join(projectPath, 'a.txt')),
touch(path.join(projectPath, 'b.txt')),
touch(path.join(projectPath, 'vendor', '.git', 'vendor-git-contents')),
touch(path.join(projectPath, 'src', 'src-a.txt')),
touch(path.join(projectPath, 'src', 'src-b.txt')),
]);
// wait a bit so that filesystem modifications are notified
await delay(FS_WATCH_DURATION_MS);
await Promise.all(
Object.values(monitors).map((monitor) => monitor.close()),
);
expect(changedPaths['main']).to.have.members([
'a.txt',
path.join('src', 'src-a.txt'),
path.join('vendor', '.git', 'vendor-git-contents'),
]);
});
});
describe('for project no-docker-compose/dockerignore2', function () {
const projectPath = path.join(
projectsPath,
'no-docker-compose',
'dockerignore2',
);
const composition = {
version: '2.1',
services: {
main: { build: { context: '.' } },
},
};
it('should trigger change events for paths that are not ignored', async () => {
const changedPaths: ByService<string[]> = { main: [] };
const multiDockerignore = true;
const monitors = await createMonitors(
projectPath,
composition,
multiDockerignore,
(serviceName: string, changedPath: string) => {
changedPaths[serviceName].push(changedPath);
},
);
await Promise.all([
touch(path.join(projectPath, 'a.txt')),
touch(path.join(projectPath, 'b.txt')),
touch(path.join(projectPath, 'lib', 'src-a.txt')),
touch(path.join(projectPath, 'lib', 'src-b.txt')),
touch(path.join(projectPath, 'src', 'src-a.txt')),
touch(path.join(projectPath, 'src', 'src-b.txt')),
touch(path.join(projectPath, 'symlink-a.txt')),
touch(path.join(projectPath, 'symlink-b.txt')),
]);
// wait a bit so that filesystem modifications are notified
await delay(FS_WATCH_DURATION_MS);
await Promise.all(
Object.values(monitors).map((monitor) => monitor.close()),
);
// chokidar appears to treat symbolic links differently on different
// platforms like Linux and macOS. On Linux only, change events are
// reported for symlinks when the target file they point to is changed.
// We tolerate this difference in this test case.
const expectedNoSymlink = [
'b.txt',
path.join('lib', 'src-b.txt'),
path.join('src', 'src-b.txt'),
];
const expectedWithSymlink = [...expectedNoSymlink, 'symlink-a.txt'];
expect(changedPaths['main']).to.include.members(expectedNoSymlink);
expect(expectedWithSymlink).to.include.members(changedPaths['main']);
});
});
describe('for project docker-compose/basic', function () {
const projectPath = path.join(projectsPath, 'docker-compose', 'basic');
const composition = {
version: '2.1',
services: {
service1: { build: { context: 'service1' } },
service2: { build: { context: 'service2' } },
},
};
it('should trigger change events for paths that are not ignored (docker-compose)', async () => {
const changedPaths: ByService<string[]> = {
service1: [],
service2: [],
};
const multiDockerignore = false;
const monitors = await createMonitors(
projectPath,
composition,
multiDockerignore,
(serviceName: string, changedPath: string) => {
changedPaths[serviceName].push(changedPath);
},
);
await Promise.all([
touch(path.join(projectPath, 'service1', 'test-ignore.txt')),
touch(path.join(projectPath, 'service1', 'file1.sh')),
touch(path.join(projectPath, 'service2', 'src', 'file1.sh')),
touch(path.join(projectPath, 'service2', 'file2-crlf.sh')),
]);
// wait a bit so that filesystem modifications are notified
await delay(FS_WATCH_DURATION_MS);
await Promise.all(
Object.values(monitors).map((monitor) => monitor.close()),
);
expect(changedPaths['service1']).to.have.members(['file1.sh']);
expect(changedPaths['service2']).to.have.members([
path.join('src', 'file1.sh'),
'file2-crlf.sh',
]);
});
it('should trigger change events for paths that are not ignored (docker-compose, multi-dockerignore)', async () => {
const changedPaths: ByService<string[]> = {
service1: [],
service2: [],
};
const multiDockerignore = true;
const monitors = await createMonitors(
projectPath,
composition,
multiDockerignore,
(serviceName: string, changedPath: string) => {
changedPaths[serviceName].push(changedPath);
},
);
await Promise.all([
touch(path.join(projectPath, 'service1', 'test-ignore.txt')),
touch(path.join(projectPath, 'service1', 'file1.sh')),
touch(path.join(projectPath, 'service2', 'src', 'file1.sh')),
touch(path.join(projectPath, 'service2', 'file2-crlf.sh')),
]);
// wait a bit so that filesystem modifications are notified
await delay(FS_WATCH_DURATION_MS);
await Promise.all(
Object.values(monitors).map((monitor) => monitor.close()),
);
expect(changedPaths['service1']).to.have.members([
'file1.sh',
'test-ignore.txt',
]);
expect(changedPaths['service2']).to.have.members(['file2-crlf.sh']);
});
});
});
async function touch(filePath: string) {
const time = new Date();
return fs.utimes(filePath, time, time);
}