test(cli): e2e testing (#5101)

* Allow building and installing cli

* feat: add format fix

* docs: remove cli folder

* feat: use immich scoped package

* feat: rewrite cli readme

* docs: add info on running without building

* cleanup

* chore: remove import functionality from cli

* feat: add logout to cli

* docs: add todo for file format from server

* docs: add compilation step to cli

* fix: success message spacing

* feat: can create albums

* fix: add check step to cli

* fix: typos

* feat: pull file formats from server

* chore: use crawl service from server

* chore: fix lint

* docs: add cli documentation

* chore: rename ignore pattern

* chore: add version number to cli

* feat: use sdk

* fix: cleanup

* feat: album name on windows

* chore: remove skipped asset field

* feat: add more info to server-info command

* chore: cleanup

* wip

* chore: remove unneeded packages

* e2e test can start

* git ignore for geocode in cli

* add cli e2e to github actions

* can do e2e tests in the cli

* simplify e2e test

* cleanup

* set matrix strategy in workflow

* run npm ci in server

* choose different working directory

* check out submodules too

* increase test timeout

* set node version

* cli docker e2e tests

* fix cli docker file

* run cli e2e in correct folder

* set docker context

* correct docker build

* remove cli from dockerignore

* chore: fix docs links

* feat: add cli v2 milestone

* fix: set correct cli date

* remove submodule

* chore: add npmignore

* chore(cli): push to npm

* fix: server e2e

* run npm ci in server

* remove state from e2e

* run npm ci in server

* reshuffle docker compose files

* use new e2e composes in makefile

* increase test timeout to 10 minutes

* make github actions run makefile e2e tests

* cleanup github test names

* assert on server version

* chore: split cli e2e tests into one file per command

* chore: set cli release working dir

* chore: add repo url to npmjs

* chore: bump node setup to v4

* chore: normalize the github url

* check e2e code in lint

* fix lint

* test key login flow

* feat: allow configurable config dir

* fix session service tests

* create missing dir

* cleanup

* bump cli version to 2.0.4

* remove form-data

* feat: allow single files as argument

* add version option

* bump dependencies

* fix lint

* wip use axios as upload

* version bump

* cApiTALiZaTiON

* don't touch package lock

* wip: don't use job queues

* don't use make for cli e2e

* fix server e2e

* chore: remove old gha step

* add npm ci to server

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Jonathan Jogenfors
2023-12-19 03:29:26 +01:00
committed by GitHub
parent baed16dab6
commit 4e9b96ff1a
50 changed files with 2486 additions and 149 deletions

View File

@ -1,10 +1,9 @@
import { ImmichApi } from '../api/client';
import path from 'node:path';
import { SessionService } from '../services/session.service';
import { LoginError } from '../cores/errors/login-error';
import { exit } from 'node:process';
import os from 'os';
import { ServerVersionResponseDto, UserResponseDto } from 'src/api/open-api';
import { BaseOptionsDto } from 'src/cores/dto/base-options-dto';
export abstract class BaseCommand {
protected sessionService!: SessionService;
@ -12,14 +11,11 @@ export abstract class BaseCommand {
protected user!: UserResponseDto;
protected serverVersion!: ServerVersionResponseDto;
protected configDir;
protected authPath;
constructor() {
const userHomeDir = os.homedir();
this.configDir = path.join(userHomeDir, '.config/immich/');
this.sessionService = new SessionService(this.configDir);
this.authPath = path.join(this.configDir, 'auth.yml');
constructor(options: BaseOptionsDto) {
if (!options.config) {
throw new Error('Config directory is required');
}
this.sessionService = new SessionService(options.config);
}
public async connect(): Promise<void> {

View File

@ -2,7 +2,7 @@ import { Asset } from '../cores/models/asset';
import { CrawlService } from '../services';
import { UploadOptionsDto } from '../cores/dto/upload-options-dto';
import { CrawlOptionsDto } from '../cores/dto/crawl-options-dto';
import fs from 'node:fs';
import cliProgress from 'cli-progress';
import byteSize from 'byte-size';
import { BaseCommand } from '../cli/base-command';
@ -15,8 +15,6 @@ export default class Upload extends BaseCommand {
public async run(paths: string[], options: UploadOptionsDto): Promise<void> {
await this.connect();
const deviceId = 'CLI';
const formatResponse = await this.immichApi.serverInfoApi.getSupportedMediaTypes();
const crawlService = new CrawlService(formatResponse.data.image, formatResponse.data.video);
@ -25,14 +23,26 @@ export default class Upload extends BaseCommand {
crawlOptions.recursive = options.recursive;
crawlOptions.exclusionPatterns = options.exclusionPatterns;
const files: string[] = [];
for (const pathArgument of paths) {
const fileStat = await fs.promises.lstat(pathArgument);
if (fileStat.isFile()) {
files.push(pathArgument);
}
}
const crawledFiles: string[] = await crawlService.crawl(crawlOptions);
crawledFiles.push(...files);
if (crawledFiles.length === 0) {
console.log('No assets found, exiting');
return;
}
const assetsToUpload = crawledFiles.map((path) => new Asset(path, deviceId));
const assetsToUpload = crawledFiles.map((path) => new Asset(path));
const uploadProgress = new cliProgress.SingleBar(
{

37
cli/src/constants.ts Normal file
View File

@ -0,0 +1,37 @@
import pkg from '../package.json';
export interface ICLIVersion {
major: number;
minor: number;
patch: number;
}
export class CLIVersion implements ICLIVersion {
constructor(
public readonly major: number,
public readonly minor: number,
public readonly patch: number,
) {}
toString() {
return `${this.major}.${this.minor}.${this.patch}`;
}
toJSON() {
const { major, minor, patch } = this;
return { major, minor, patch };
}
static fromString(version: string): CLIVersion {
const regex = /(?:v)?(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)/i;
const matchResult = version.match(regex);
if (matchResult) {
const [, major, minor, patch] = matchResult.map(Number);
return new CLIVersion(major, minor, patch);
} else {
throw new Error(`Invalid version format: ${version}`);
}
}
}
export const cliVersion = CLIVersion.fromString(pkg.version);

View File

@ -0,0 +1,3 @@
export class BaseOptionsDto {
config?: string;
}

View File

@ -1,9 +1,8 @@
export class UploadOptionsDto {
recursive = false;
exclusionPatterns!: string[];
dryRun = false;
skipHash = false;
delete = false;
readOnly = true;
album = false;
recursive? = false;
exclusionPatterns?: string[] = [];
dryRun? = false;
skipHash? = false;
delete? = false;
album? = false;
}

View File

@ -2,10 +2,8 @@ export class LoginError extends Error {
constructor(message: string) {
super(message);
// assign the error class name in your custom error (as a shortcut)
this.name = this.constructor.name;
// capturing the stack trace keeps the reference to your error class
Error.captureStackTrace(this, this.constructor);
}
}

View File

@ -17,9 +17,8 @@ export class Asset {
fileSize!: number;
albumName?: string;
constructor(path: string, deviceId: string) {
constructor(path: string) {
this.path = path;
this.deviceId = deviceId;
}
async process() {
@ -45,12 +44,11 @@ export class Asset {
if (!this.deviceAssetId) throw new Error('Device asset id not set');
if (!this.fileCreatedAt) throw new Error('File created at not set');
if (!this.fileModifiedAt) throw new Error('File modified at not set');
if (!this.deviceId) throw new Error('Device id not set');
const data: any = {
assetData: this.assetData as any,
deviceAssetId: this.deviceAssetId,
deviceId: this.deviceId,
deviceId: 'CLI',
fileCreatedAt: this.fileCreatedAt,
fileModifiedAt: this.fileModifiedAt,
isFavorite: String(false),

View File

@ -1,13 +1,23 @@
#! /usr/bin/env node
import { program, Option } from 'commander';
import { Option, Command } from 'commander';
import Upload from './commands/upload';
import ServerInfo from './commands/server-info';
import LoginKey from './commands/login/key';
import Logout from './commands/logout';
import { version } from '../package.json';
program.name('immich').description('Immich command line interface').version(version);
import path from 'node:path';
import os from 'os';
const userHomeDir = os.homedir();
const configDir = path.join(userHomeDir, '.config/immich/');
const program = new Command()
.name('immich')
.version(version)
.description('Command line interface for Immich')
.addOption(new Option('-d, --config', 'Configuration directory').env('IMMICH_CONFIG_DIR').default(configDir));
program
.command('upload')
@ -30,14 +40,14 @@ program
.argument('[paths...]', 'One or more paths to assets to be uploaded')
.action(async (paths, options) => {
options.exclusionPatterns = options.ignore;
await new Upload().run(paths, options);
await new Upload(program.opts()).run(paths, options);
});
program
.command('server-info')
.description('Display server information')
.action(async () => {
await new ServerInfo().run();
await new ServerInfo(program.opts()).run();
});
program
@ -46,14 +56,14 @@ program
.argument('[instanceUrl]')
.argument('[apiKey]')
.action(async (paths, options) => {
await new LoginKey().run(paths, options);
await new LoginKey(program.opts()).run(paths, options);
});
program
.command('logout')
.description('Remove stored credentials')
.action(async () => {
await new Logout().run();
await new Logout(program.opts()).run();
});
program.parse(process.argv);

View File

@ -1,8 +1,17 @@
import { SessionService } from './session.service';
import mockfs from 'mock-fs';
import fs from 'node:fs';
import yaml from 'yaml';
import { LoginError } from '../cores/errors/login-error';
import {
TEST_AUTH_FILE,
TEST_CONFIG_DIR,
TEST_IMMICH_API_KEY,
TEST_IMMICH_INSTANCE_URL,
createTestAuthFile,
deleteAuthFile,
readTestAuthFile,
spyOnConsole,
} from '../../test/cli-test-utils';
const mockPingServer = jest.fn(() => Promise.resolve({ data: { res: 'pong' } }));
const mockUserInfo = jest.fn(() => Promise.resolve({ data: { email: 'admin@example.com' } }));
@ -22,74 +31,85 @@ jest.mock('../api/open-api', () => {
describe('SessionService', () => {
let sessionService: SessionService;
let consoleSpy: jest.SpyInstance;
beforeAll(() => {
// Write a dummy output before mock-fs to prevent some annoying errors
console.log();
consoleSpy = spyOnConsole();
});
beforeEach(() => {
const configDir = '/config';
sessionService = new SessionService(configDir);
deleteAuthFile();
sessionService = new SessionService(TEST_CONFIG_DIR);
});
afterEach(() => {
deleteAuthFile();
});
it('should connect to immich', async () => {
mockfs({
'/config/auth.yml': 'apiKey: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\ninstanceUrl: https://test/api',
});
await createTestAuthFile(
JSON.stringify({
apiKey: TEST_IMMICH_API_KEY,
instanceUrl: TEST_IMMICH_INSTANCE_URL,
}),
);
await sessionService.connect();
expect(mockPingServer).toHaveBeenCalledTimes(1);
});
it('should error if no auth file exists', async () => {
mockfs();
await sessionService.connect().catch((error) => {
expect(error.message).toEqual('No auth file exist. Please login first');
});
});
it('should error if auth file is missing instance URl', async () => {
mockfs({
'/config/auth.yml': 'foo: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\napiKey: https://test/api',
});
await createTestAuthFile(
JSON.stringify({
apiKey: TEST_IMMICH_API_KEY,
}),
);
await sessionService.connect().catch((error) => {
expect(error).toBeInstanceOf(LoginError);
expect(error.message).toEqual('Instance URL missing in auth config file /config/auth.yml');
expect(error.message).toEqual(`Instance URL missing in auth config file ${TEST_AUTH_FILE}`);
});
});
it('should error if auth file is missing api key', async () => {
mockfs({
'/config/auth.yml': 'instanceUrl: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\nbar: https://test/api',
});
await sessionService.connect().catch((error) => {
expect(error).toBeInstanceOf(LoginError);
expect(error.message).toEqual('API key missing in auth config file /config/auth.yml');
});
await createTestAuthFile(
JSON.stringify({
instanceUrl: TEST_IMMICH_INSTANCE_URL,
}),
);
await expect(sessionService.connect()).rejects.toThrow(
new LoginError(`API key missing in auth config file ${TEST_AUTH_FILE}`),
);
});
it.skip('should create auth file when logged in', async () => {
mockfs();
it('should create auth file when logged in', async () => {
await sessionService.keyLogin(TEST_IMMICH_INSTANCE_URL, TEST_IMMICH_API_KEY);
await sessionService.keyLogin('https://test/api', 'pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg');
const data: string = await fs.promises.readFile('/config/auth.yml', 'utf8');
const data: string = await readTestAuthFile();
const authConfig = yaml.parse(data);
expect(authConfig.instanceUrl).toBe('https://test/api');
expect(authConfig.apiKey).toBe('pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg');
expect(authConfig.instanceUrl).toBe(TEST_IMMICH_INSTANCE_URL);
expect(authConfig.apiKey).toBe(TEST_IMMICH_API_KEY);
});
it('should delete auth file when logging out', async () => {
mockfs({
'/config/auth.yml': 'apiKey: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\ninstanceUrl: https://test/api',
});
await createTestAuthFile(
JSON.stringify({
apiKey: TEST_IMMICH_API_KEY,
instanceUrl: TEST_IMMICH_INSTANCE_URL,
}),
);
await sessionService.logout();
await fs.promises.access('/auth.yml', fs.constants.F_OK).catch((error) => {
await fs.promises.access(TEST_AUTH_FILE, fs.constants.F_OK).catch((error) => {
expect(error.message).toContain('ENOENT');
});
});
afterEach(() => {
mockfs.restore();
expect(consoleSpy.mock.calls).toEqual([[`Removed auth file ${TEST_AUTH_FILE}`]]);
});
});

View File

@ -5,33 +5,39 @@ import { ImmichApi } from '../api/client';
import { LoginError } from '../cores/errors/login-error';
export class SessionService {
readonly configDir: string;
readonly configDir!: string;
readonly authPath!: string;
private api!: ImmichApi;
constructor(configDir: string) {
this.configDir = configDir;
this.authPath = path.join(this.configDir, 'auth.yml');
this.authPath = path.join(configDir, '/auth.yml');
}
public async connect(): Promise<ImmichApi> {
await fs.promises.access(this.authPath, fs.constants.F_OK).catch((error) => {
if (error.code === 'ENOENT') {
throw new LoginError('No auth file exist. Please login first');
let instanceUrl = process.env.IMMICH_INSTANCE_URL;
let apiKey = process.env.IMMICH_API_KEY;
if (!instanceUrl || !apiKey) {
await fs.promises.access(this.authPath, fs.constants.F_OK).catch((error) => {
if (error.code === 'ENOENT') {
throw new LoginError('No auth file exist. Please login first');
}
});
const data: string = await fs.promises.readFile(this.authPath, 'utf8');
const parsedConfig = yaml.parse(data);
instanceUrl = parsedConfig.instanceUrl;
apiKey = parsedConfig.apiKey;
if (!instanceUrl) {
throw new LoginError(`Instance URL missing in auth config file ${this.authPath}`);
}
});
const data: string = await fs.promises.readFile(this.authPath, 'utf8');
const parsedConfig = yaml.parse(data);
const instanceUrl: string = parsedConfig.instanceUrl;
const apiKey: string = parsedConfig.apiKey;
if (!instanceUrl) {
throw new LoginError('Instance URL missing in auth config file ' + this.authPath);
}
if (!apiKey) {
throw new LoginError('API key missing in auth config file ' + this.authPath);
if (!apiKey) {
throw new LoginError(`API key missing in auth config file ${this.authPath}`);
}
}
this.api = new ImmichApi(instanceUrl, apiKey);
@ -59,10 +65,6 @@ export class SessionService {
}
}
if (!fs.existsSync(this.configDir)) {
console.error('waah');
}
fs.writeFileSync(this.authPath, yaml.stringify({ instanceUrl, apiKey }));
console.log('Wrote auth info to ' + this.authPath);
@ -82,7 +84,7 @@ export class SessionService {
});
if (pingResponse.res !== 'pong') {
throw new Error('Unexpected ping reply');
throw new Error(`Could not parse response. Is Immich listening on ${this.api.apiConfiguration.instanceUrl}?`);
}
}
}