refactor: cli e2e (#7211)

This commit is contained in:
Jason Rasmussen
2024-02-19 17:25:57 -05:00
committed by GitHub
parent 870d517ce3
commit 947bcf2d68
26 changed files with 442 additions and 500 deletions
+4 -3
View File
@@ -13,6 +13,7 @@ x-server-build: &server-common
- DB_PASSWORD=postgres
- DB_DATABASE_NAME=immich
- REDIS_HOSTNAME=redis
- IMMICH_MACHINE_LEARNING_ENABLED=false
volumes:
- upload:/usr/src/app/upload
depends_on:
@@ -26,9 +27,9 @@ services:
ports:
- 2283:3001
immich-microservices:
command: [ "./start.sh", "microservices" ]
<<: *server-common
# immich-microservices:
# command: [ "./start.sh", "microservices" ]
# <<: *server-common
redis:
image: redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5
+42
View File
@@ -9,6 +9,7 @@
"version": "1.0.0",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@immich/cli": "file:../cli",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.41.2",
"@types/node": "^20.11.17",
@@ -21,6 +22,43 @@
"vitest": "^1.3.0"
}
},
"../cli": {
"version": "2.0.8",
"dev": true,
"license": "GNU Affero General Public License version 3",
"bin": {
"immich": "dist/index.js"
},
"devDependencies": {
"@immich/sdk": "file:../open-api/typescript-sdk",
"@testcontainers/postgresql": "^10.7.1",
"@types/byte-size": "^8.1.0",
"@types/cli-progress": "^3.11.0",
"@types/mock-fs": "^4.13.1",
"@types/node": "^20.3.1",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"@vitest/coverage-v8": "^1.2.2",
"byte-size": "^8.1.1",
"cli-progress": "^3.12.0",
"commander": "^12.0.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^51.0.0",
"glob": "^10.3.1",
"mock-fs": "^5.2.0",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^3.2.4",
"typescript": "^5.3.3",
"vite": "^5.0.12",
"vitest": "^1.2.2",
"yaml": "^2.3.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.92.1",
@@ -471,6 +509,10 @@
"node": ">=12"
}
},
"node_modules/@immich/cli": {
"resolved": "../cli",
"link": true
},
"node_modules/@immich/sdk": {
"resolved": "../open-api/typescript-sdk",
"link": true
+3 -1
View File
@@ -5,13 +5,15 @@
"main": "index.js",
"type": "module",
"scripts": {
"test": "vitest --config vitest.config.ts",
"test:web": "npx playwright test",
"test:api": "vitest"
"start:web": "npx playwright test --ui"
},
"keywords": [],
"author": "",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@immich/cli": "file:../cli",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.41.2",
"@types/node": "^20.11.17",
+7 -7
View File
@@ -11,15 +11,15 @@ import {
loginResponseDto,
signupResponseDto,
} from 'src/responses';
import { app, asAuthHeader, dbUtils } from 'src/utils';
import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
const { name, email, password } = signupDto.admin;
describe(`Registration`, () => {
describe(`/auth/admin-sign-up`, () => {
beforeAll(() => {
dbUtils.setup();
apiUtils.setup();
});
beforeEach(async () => {
@@ -96,7 +96,7 @@ describe(`Registration`, () => {
});
});
describe('Auth', () => {
describe('/auth/*', () => {
let admin: LoginResponseDto;
beforeEach(async () => {
@@ -177,7 +177,7 @@ describe('Auth', () => {
}
await expect(
getAuthDevices({ headers: asAuthHeader(admin.accessToken) })
getAuthDevices({ headers: asBearerAuth(admin.accessToken) })
).resolves.toHaveLength(6);
const { status } = await request(app)
@@ -186,7 +186,7 @@ describe('Auth', () => {
expect(status).toBe(204);
await expect(
getAuthDevices({ headers: asAuthHeader(admin.accessToken) })
getAuthDevices({ headers: asBearerAuth(admin.accessToken) })
).resolves.toHaveLength(1);
});
@@ -202,7 +202,7 @@ describe('Auth', () => {
it('should logout a device', async () => {
const [device] = await getAuthDevices({
headers: asAuthHeader(admin.accessToken),
headers: asBearerAuth(admin.accessToken),
});
const { status } = await request(app)
.delete(`/auth/devices/${device.id}`)
+58
View File
@@ -0,0 +1,58 @@
import { stat } from 'node:fs/promises';
import { apiUtils, app, dbUtils, immichCli } from 'src/utils';
import { beforeEach, beforeAll, describe, expect, it } from 'vitest';
describe(`immich login-key`, () => {
beforeAll(() => {
apiUtils.setup();
});
beforeEach(async () => {
await dbUtils.reset();
});
it('should require a url', async () => {
const { stderr, exitCode } = await immichCli(['login-key']);
expect(stderr).toBe("error: missing required argument 'url'");
expect(exitCode).toBe(1);
});
it('should require a key', async () => {
const { stderr, exitCode } = await immichCli(['login-key', app]);
expect(stderr).toBe("error: missing required argument 'key'");
expect(exitCode).toBe(1);
});
it('should require a valid key', async () => {
const { stderr, exitCode } = await immichCli([
'login-key',
app,
'immich-is-so-cool',
]);
expect(stderr).toContain(
'Failed to connect to server http://127.0.0.1:2283/api: Error: 401'
);
expect(exitCode).toBe(1);
});
it('should login', async () => {
const admin = await apiUtils.adminSetup();
const key = await apiUtils.createApiKey(admin.accessToken);
const { stdout, stderr, exitCode } = await immichCli([
'login-key',
app,
`${key.secret}`,
]);
expect(stdout.split('\n')).toEqual([
'Logging in...',
'Logged in as admin@immich.cloud',
'Wrote auth info to /tmp/immich/auth.yml',
]);
expect(stderr).toBe('');
expect(exitCode).toBe(0);
const stats = await stat('/tmp/immich/auth.yml');
const mode = (stats.mode & 0o777).toString(8);
expect(mode).toEqual('600');
});
});
+28
View File
@@ -0,0 +1,28 @@
import { apiUtils, cliUtils, dbUtils, immichCli } from 'src/utils';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
describe(`immich server-info`, () => {
beforeAll(() => {
apiUtils.setup();
});
beforeEach(async () => {
await dbUtils.reset();
await cliUtils.login();
});
it('should return the server info', async () => {
const { stderr, stdout, exitCode } = await immichCli(['server-info']);
expect(stdout.split('\n')).toEqual([
expect.stringContaining('Server Version:'),
expect.stringContaining('Image Types:'),
expect.stringContaining('Video Types:'),
'Statistics:',
' Images: 0',
' Videos: 0',
' Total: 0',
]);
expect(stderr).toBe('');
expect(exitCode).toBe(0);
});
});
+127
View File
@@ -0,0 +1,127 @@
import { getAllAlbums, getAllAssets } from '@immich/sdk';
import {
apiUtils,
asKeyAuth,
cliUtils,
dbUtils,
immichCli,
testAssetDir,
} from 'src/utils';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
describe(`immich upload`, () => {
let key: string;
beforeAll(() => {
apiUtils.setup();
});
beforeEach(async () => {
await dbUtils.reset();
key = await cliUtils.login();
});
describe('immich upload --recursive', () => {
it('should upload a folder recursively', async () => {
const { stderr, stdout, exitCode } = await immichCli([
'upload',
`${testAssetDir}/albums/nature/`,
'--recursive',
]);
expect(stderr).toBe('');
expect(stdout.split('\n')).toEqual([
expect.stringContaining('Successfully uploaded 9 assets'),
]);
expect(exitCode).toBe(0);
const assets = await getAllAssets({}, { headers: asKeyAuth(key) });
expect(assets.length).toBe(9);
});
});
describe('immich upload --recursive --album', () => {
it('should create albums from folder names', async () => {
const { stderr, stdout, exitCode } = await immichCli([
'upload',
`${testAssetDir}/albums/nature/`,
'--recursive',
'--album',
]);
expect(stdout.split('\n')).toEqual([
expect.stringContaining('Successfully uploaded 9 assets'),
]);
expect(stderr).toBe('');
expect(exitCode).toBe(0);
const assets = await getAllAssets({}, { headers: asKeyAuth(key) });
expect(assets.length).toBe(9);
const albums = await getAllAlbums({}, { headers: asKeyAuth(key) });
expect(albums.length).toBe(1);
expect(albums[0].albumName).toBe('nature');
});
it('should add existing assets to albums', async () => {
const response1 = await immichCli([
'upload',
`${testAssetDir}/albums/nature/`,
'--recursive',
]);
expect(response1.stdout.split('\n')).toEqual([
expect.stringContaining('Successfully uploaded 9 assets'),
]);
expect(response1.stderr).toBe('');
expect(response1.exitCode).toBe(0);
const assets1 = await getAllAssets({}, { headers: asKeyAuth(key) });
expect(assets1.length).toBe(9);
const albums1 = await getAllAlbums({}, { headers: asKeyAuth(key) });
expect(albums1.length).toBe(0);
const response2 = await immichCli([
'upload',
`${testAssetDir}/albums/nature/`,
'--recursive',
'--album',
]);
expect(response2.stdout.split('\n')).toEqual([
expect.stringContaining(
'All assets were already uploaded, nothing to do.'
),
]);
expect(response2.stderr).toBe('');
expect(response2.exitCode).toBe(0);
const assets2 = await getAllAssets({}, { headers: asKeyAuth(key) });
expect(assets2.length).toBe(9);
const albums2 = await getAllAlbums({}, { headers: asKeyAuth(key) });
expect(albums2.length).toBe(1);
expect(albums2[0].albumName).toBe('nature');
});
});
describe('immich upload --recursive --album-name=e2e', () => {
it('should create a named album', async () => {
const { stderr, stdout, exitCode } = await immichCli([
'upload',
`${testAssetDir}/albums/nature/`,
'--recursive',
'--album-name=e2e',
]);
expect(stdout.split('\n')).toEqual([
expect.stringContaining('Successfully uploaded 9 assets'),
]);
expect(stderr).toBe('');
expect(exitCode).toBe(0);
const assets = await getAllAssets({}, { headers: asKeyAuth(key) });
expect(assets.length).toBe(9);
const albums = await getAllAlbums({}, { headers: asKeyAuth(key) });
expect(albums.length).toBe(1);
expect(albums[0].albumName).toBe('e2e');
});
});
});
+29
View File
@@ -0,0 +1,29 @@
import { readFileSync } from 'node:fs';
import { apiUtils, immichCli } from 'src/utils';
import { beforeAll, describe, expect, it } from 'vitest';
const pkg = JSON.parse(readFileSync('../cli/package.json', 'utf8'));
describe(`immich --version`, () => {
beforeAll(() => {
apiUtils.setup();
});
describe('immich --version', () => {
it('should print the cli version', async () => {
const { stdout, stderr, exitCode } = await immichCli(['--version']);
expect(stdout).toEqual(pkg.version);
expect(stderr).toEqual('');
expect(exitCode).toBe(0);
});
});
describe('immich -V', () => {
it('should print the cli version', async () => {
const { stdout, stderr, exitCode } = await immichCli(['-V']);
expect(stdout).toEqual(pkg.version);
expect(stderr).toEqual('');
expect(exitCode).toBe(0);
});
});
});
+1 -1
View File
@@ -2,7 +2,7 @@ import { spawn, exec } from 'child_process';
export default async () => {
let _resolve: () => unknown;
const promise = new Promise<void>((resolve, reject) => (_resolve = resolve));
const promise = new Promise<void>((resolve) => (_resolve = resolve));
const child = spawn('docker', ['compose', 'up'], { stdio: 'pipe' });
+76 -7
View File
@@ -1,29 +1,44 @@
import {
LoginResponseDto,
createApiKey,
defaults,
login,
setAdminOnboarding,
signUpAdmin,
} from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
import { spawn } from 'child_process';
import { access } from 'node:fs/promises';
import path from 'node:path';
import pg from 'pg';
import { loginDto, signupDto } from 'src/fixtures';
export const app = 'http://127.0.0.1:2283/api';
defaults.baseUrl = app;
const directoryExists = (directory: string) =>
access(directory)
.then(() => true)
.catch(() => false);
// TODO move test assets into e2e/assets
export const testAssetDir = path.resolve(`./../server/test/assets/`);
if (!(await directoryExists(`${testAssetDir}/albums`))) {
throw new Error(
`Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${testAssetDir} before testing`
);
}
const setBaseUrl = () => (defaults.baseUrl = app);
export const asAuthHeader = (accessToken: string) => ({
export const asBearerAuth = (accessToken: string) => ({
Authorization: `Bearer ${accessToken}`,
});
export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
let client: pg.Client | null = null;
export const dbUtils = {
setup: () => {
setBaseUrl();
},
reset: async () => {
try {
if (!client) {
@@ -33,7 +48,14 @@ export const dbUtils = {
await client.connect();
}
for (const table of ['user_token', 'users', 'system_metadata']) {
for (const table of [
'albums',
'assets',
'api_keys',
'user_token',
'users',
'system_metadata',
]) {
await client.query(`DELETE FROM ${table} CASCADE;`);
}
} catch (error) {
@@ -53,14 +75,61 @@ export const dbUtils = {
}
},
};
export interface CliResponse {
stdout: string;
stderr: string;
exitCode: number | null;
}
export const immichCli = async (args: string[]) => {
let _resolve: (value: CliResponse) => void;
const deferred = new Promise<CliResponse>((resolve) => (_resolve = resolve));
const _args = ['node_modules/.bin/immich', '-d', '/tmp/immich/', ...args];
const child = spawn('node', _args, {
stdio: 'pipe',
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => (stdout += data.toString()));
child.stderr.on('data', (data) => (stderr += data.toString()));
child.on('exit', (exitCode) => {
_resolve({
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode,
});
});
return deferred;
};
export const apiUtils = {
setup: () => {
setBaseUrl();
},
adminSetup: async () => {
await signUpAdmin({ signUpDto: signupDto.admin });
const response = await login({ loginCredentialDto: loginDto.admin });
await setAdminOnboarding({ headers: asAuthHeader(response.accessToken) });
await setAdminOnboarding({ headers: asBearerAuth(response.accessToken) });
return response;
},
createApiKey: (accessToken: string) => {
return createApiKey(
{ apiKeyCreateDto: { name: 'e2e' } },
{ headers: asBearerAuth(accessToken) }
);
},
};
export const cliUtils = {
login: async () => {
const admin = await apiUtils.adminSetup();
const key = await apiUtils.createApiKey(admin.accessToken);
await immichCli(['login-key', app, `${key.secret}`]);
return key.secret;
},
};
export const webUtils = {
+4
View File
@@ -2,6 +2,10 @@ import { test, expect } from '@playwright/test';
import { apiUtils, dbUtils, webUtils } from 'src/utils';
test.describe('Registration', () => {
test.beforeAll(() => {
apiUtils.setup();
});
test.beforeEach(async () => {
await dbUtils.reset();
});
+2 -2
View File
@@ -2,8 +2,8 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/api/specs/*.e2e-spec.ts'],
globalSetup: ['src/api/setup.ts'],
include: ['src/{api,cli}/specs/*.e2e-spec.ts'],
globalSetup: ['src/setup.ts'],
poolOptions: {
threads: {
singleThread: true,