diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 636a332110..c3ede44661 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -135,38 +135,6 @@ jobs: run: npm run test:cov if: ${{ !cancelled() }} - cli-e2e-tests: - name: CLI (e2e) - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./cli - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - submodules: "recursive" - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Run setup typescript-sdk - run: npm ci && npm run build - working-directory: ./open-api/typescript-sdk - - - name: Run npm install (cli) - run: npm ci - - - name: Run npm install (server) - run: npm ci && npm run build - working-directory: ./server - - - name: Run e2e tests - run: npm run test:e2e - web-unit-tests: name: Web runs-on: ubuntu-latest @@ -205,8 +173,8 @@ jobs: run: npm run test:cov if: ${{ !cancelled() }} - web-e2e-tests: - name: Web (e2e) + e2e-tests: + name: End-to-End Tests runs-on: ubuntu-latest defaults: run: @@ -215,11 +183,22 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + submodules: "recursive" + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 - name: Run setup typescript-sdk run: npm ci && npm run build working-directory: ./open-api/typescript-sdk + - name: Run setup cli + run: npm ci && npm run build + working-directory: ./cli + - name: Install dependencies run: npm ci @@ -229,33 +208,12 @@ jobs: - name: Docker build run: docker compose build - - name: Run e2e tests + - name: Run e2e tests (api & cli) + run: npm run test + + - name: Run e2e tests (web) run: npx playwright test - api-e2e-tests: - name: API (e2e) - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./e2e - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Run setup typescript-sdk - run: npm ci && npm run build - working-directory: ./open-api/typescript-sdk - - - name: Install dependencies - run: npm ci - - - name: Docker build - run: docker compose build - - - name: Run e2e tests - run: npm run test:api - mobile-unit-tests: name: Mobile runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index e28232bef8..b455e2656b 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ server-e2e-api: .PHONY: e2e e2e: - docker compose -f ./docker/docker-compose.e2e.yml up --build -V --remove-orphans + docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans prod: docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans diff --git a/cli/.eslintrc.cjs b/cli/.eslintrc.cjs index 0906129bc1..33ee3bd1e8 100644 --- a/cli/.eslintrc.cjs +++ b/cli/.eslintrc.cjs @@ -21,11 +21,6 @@ module.exports = { 'unicorn/prefer-module': 'off', curly: 2, 'prettier/prettier': 0, - 'unicorn/prevent-abbreviations': [ - 'error', - { - ignore: ['\\.e2e-spec$', /^ignore/i], - }, - ], + 'unicorn/prevent-abbreviations': 'error', }, }; diff --git a/cli/.npmignore b/cli/.npmignore index 1d0d005a94..42809f8e80 100644 --- a/cli/.npmignore +++ b/cli/.npmignore @@ -1,5 +1,4 @@ **/*.spec.js -test/** upload/** .editorconfig .eslintignore diff --git a/cli/package-lock.json b/cli/package-lock.json index b1bdab8b7a..2b7bc18623 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -29,7 +29,6 @@ "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^51.0.0", "glob": "^10.3.1", - "immich": "file:../server", "mock-fs": "^5.2.0", "prettier": "^3.2.5", "prettier-plugin-organize-imports": "^3.2.4", @@ -64,7 +63,7 @@ "../server": { "name": "immich", "version": "1.94.1", - "dev": true, + "extraneous": true, "license": "GNU Affero General Public License version 3", "dependencies": { "@babel/runtime": "^7.22.11", @@ -3168,10 +3167,6 @@ "node": ">= 4" } }, - "node_modules/immich": { - "resolved": "../server", - "link": true - }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -7794,99 +7789,6 @@ "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", "dev": true }, - "immich": { - "version": "file:../server", - "requires": { - "@babel/runtime": "^7.22.11", - "@immich/cli": "^2.0.7", - "@nestjs/bullmq": "^10.0.1", - "@nestjs/cli": "^10.1.16", - "@nestjs/common": "^10.2.2", - "@nestjs/config": "^3.0.0", - "@nestjs/core": "^10.2.2", - "@nestjs/platform-express": "^10.2.2", - "@nestjs/platform-socket.io": "^10.2.2", - "@nestjs/schedule": "^4.0.0", - "@nestjs/schematics": "^10.0.2", - "@nestjs/swagger": "^7.1.8", - "@nestjs/testing": "^10.2.2", - "@nestjs/typeorm": "^10.0.0", - "@nestjs/websockets": "^10.2.2", - "@socket.io/postgres-adapter": "^0.3.1", - "@testcontainers/postgresql": "^10.2.1", - "@types/archiver": "^6.0.0", - "@types/async-lock": "^1.4.2", - "@types/bcrypt": "^5.0.0", - "@types/cookie-parser": "^1.4.3", - "@types/express": "^4.17.17", - "@types/fluent-ffmpeg": "^2.1.21", - "@types/imagemin": "^8.0.1", - "@types/jest": "29.5.12", - "@types/jest-when": "^3.5.2", - "@types/lodash": "^4.14.197", - "@types/mock-fs": "^4.13.1", - "@types/multer": "^1.4.7", - "@types/node": "^20.5.7", - "@types/picomatch": "^2.3.3", - "@types/sharp": "^0.31.1", - "@types/supertest": "^6.0.0", - "@types/ua-parser-js": "^0.7.36", - "@typescript-eslint/eslint-plugin": "^7.0.0", - "@typescript-eslint/parser": "^7.0.0", - "archiver": "^6.0.0", - "async-lock": "^1.4.0", - "bcrypt": "^5.1.1", - "bullmq": "^4.8.0", - "chokidar": "^3.5.3", - "class-transformer": "^0.5.1", - "class-validator": "^0.14.0", - "cookie-parser": "^1.4.6", - "dotenv": "^16.3.1", - "eslint": "^8.56.0", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-prettier": "^5.1.3", - "eslint-plugin-unicorn": "^51.0.0", - "exiftool-vendored": "~24.4.0", - "exiftool-vendored.pl": "12.73", - "fluent-ffmpeg": "^2.1.2", - "geo-tz": "^8.0.0", - "glob": "^10.3.3", - "handlebars": "^4.7.8", - "i18n-iso-countries": "^7.6.0", - "ioredis": "^5.3.2", - "jest": "^29.6.4", - "jest-when": "^3.6.0", - "joi": "^17.10.0", - "lodash": "^4.17.21", - "luxon": "^3.4.2", - "mock-fs": "^5.2.0", - "nest-commander": "^3.11.1", - "node-addon-api": "^7.0.0", - "openid-client": "^5.4.3", - "pg": "^8.11.3", - "picomatch": "^4.0.0", - "prettier": "^3.0.2", - "prettier-plugin-organize-imports": "^3.2.3", - "reflect-metadata": "^0.1.13", - "rimraf": "^5.0.1", - "rxjs": "^7.8.1", - "sanitize-filename": "^1.6.3", - "sharp": "^0.33.0", - "source-map-support": "^0.5.21", - "sql-formatter": "^15.0.0", - "supertest": "^6.3.3", - "testcontainers": "^10.2.1", - "thumbhash": "^0.1.1", - "ts-jest": "^29.1.1", - "ts-loader": "^9.4.4", - "ts-node": "^10.9.1", - "tsconfig-paths": "^4.2.0", - "typeorm": "^0.3.17", - "typescript": "^5.3.3", - "ua-parser-js": "^1.0.35", - "utimes": "^5.2.1" - } - }, "import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", diff --git a/cli/package.json b/cli/package.json index 6afc559516..221c13f1a4 100644 --- a/cli/package.json +++ b/cli/package.json @@ -30,7 +30,6 @@ "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^51.0.0", "glob": "^10.3.1", - "immich": "file:../server", "mock-fs": "^5.2.0", "prettier": "^3.2.5", "prettier-plugin-organize-imports": "^3.2.4", @@ -41,15 +40,14 @@ }, "scripts": { "build": "vite build", - "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0", + "lint": "eslint \"src/**/*.ts\" --max-warnings 0", "lint:fix": "npm run lint -- --fix", "prepack": "npm run build", "test": "vitest", "test:cov": "vitest --coverage", "format": "prettier --check .", "format:fix": "prettier --write .", - "check": "tsc --noEmit", - "test:e2e": "vitest --config test/e2e/vitest.config.ts" + "check": "tsc --noEmit" }, "repository": { "type": "git", diff --git a/cli/src/index.ts b/cli/src/index.ts index 35edc1fdf2..d663f4b5f1 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -15,7 +15,7 @@ const program = new Command() .version(version) .description('Command line interface for Immich') .addOption( - new Option('-d, --config-directory', 'Configuration directory where auth.yml will be stored') + new Option('-d, --config-directory ', 'Configuration directory where auth.yml will be stored') .env('IMMICH_CONFIG_DIR') .default(defaultConfigDirectory), ); @@ -60,10 +60,10 @@ program program .command('login-key') .description('Login using an API key') - .argument('[instanceUrl]') - .argument('[apiKey]') - .action(async (paths, options) => { - await new LoginCommand(program.opts()).run(paths, options); + .argument('url') + .argument('key') + .action(async (url, key) => { + await new LoginCommand(program.opts()).run(url, key); }); program diff --git a/cli/src/services/session.service.spec.ts b/cli/src/services/session.service.spec.ts index 56967fa812..c217ab4e6a 100644 --- a/cli/src/services/session.service.spec.ts +++ b/cli/src/services/session.service.spec.ts @@ -1,17 +1,41 @@ import fs from 'node:fs'; +import path from 'node:path'; import yaml from 'yaml'; -import { - TEST_AUTH_FILE, - TEST_CONFIG_DIR, - TEST_IMMICH_API_KEY, - TEST_IMMICH_INSTANCE_URL, - createTestAuthFile, - deleteAuthFile, - readTestAuthFile, - spyOnConsole, -} from '../../test/cli-test-utils'; import { SessionService } from './session.service'; +const TEST_CONFIG_DIR = '/tmp/immich/'; +const TEST_AUTH_FILE = path.join(TEST_CONFIG_DIR, 'auth.yml'); +const TEST_IMMICH_INSTANCE_URL = 'https://test/api'; +const TEST_IMMICH_API_KEY = 'pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg'; + +const spyOnConsole = () => vi.spyOn(console, 'log').mockImplementation(() => {}); + +const createTestAuthFile = async (contents: string) => { + if (!fs.existsSync(TEST_CONFIG_DIR)) { + // Create config folder if it doesn't exist + const created = await fs.promises.mkdir(TEST_CONFIG_DIR, { recursive: true }); + if (!created) { + throw new Error(`Failed to create config folder ${TEST_CONFIG_DIR}`); + } + } + + fs.writeFileSync(TEST_AUTH_FILE, contents); +}; + +const readTestAuthFile = async (): Promise => { + return await fs.promises.readFile(TEST_AUTH_FILE, 'utf8'); +}; + +const deleteAuthFile = () => { + try { + fs.unlinkSync(TEST_AUTH_FILE); + } catch (error: any) { + if (error.code !== 'ENOENT') { + throw error; + } + } +}; + const mocks = vi.hoisted(() => { return { getMyUserInfo: vi.fn(() => Promise.resolve({ email: 'admin@example.com' })), diff --git a/cli/test/cli-test-utils.ts b/cli/test/cli-test-utils.ts deleted file mode 100644 index cc3e29d27d..0000000000 --- a/cli/test/cli-test-utils.ts +++ /dev/null @@ -1,52 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { ImmichApi } from 'src/services/api.service'; - -export const TEST_CONFIG_DIR = '/tmp/immich/'; -export const TEST_AUTH_FILE = path.join(TEST_CONFIG_DIR, 'auth.yml'); -export const TEST_IMMICH_INSTANCE_URL = 'https://test/api'; -export const TEST_IMMICH_API_KEY = 'pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg'; - -export const CLI_BASE_OPTIONS = { configDirectory: TEST_CONFIG_DIR }; - -export const setup = async () => { - const api = new ImmichApi(process.env.IMMICH_INSTANCE_URL as string, ''); - await api.signUpAdmin({ email: 'cli@immich.app', password: 'password', name: 'Administrator' }); - const admin = await api.login({ email: 'cli@immich.app', password: 'password' }); - const apiKey = await api.createApiKey( - { name: 'CLI Test' }, - { headers: { Authorization: `Bearer ${admin.accessToken}` } }, - ); - - api.setApiKey(apiKey.secret); - - return api; -}; - -export const spyOnConsole = () => vi.spyOn(console, 'log').mockImplementation(() => {}); - -export const createTestAuthFile = async (contents: string) => { - if (!fs.existsSync(TEST_CONFIG_DIR)) { - // Create config folder if it doesn't exist - const created = await fs.promises.mkdir(TEST_CONFIG_DIR, { recursive: true }); - if (!created) { - throw new Error(`Failed to create config folder ${TEST_CONFIG_DIR}`); - } - } - - fs.writeFileSync(TEST_AUTH_FILE, contents); -}; - -export const readTestAuthFile = async (): Promise => { - return await fs.promises.readFile(TEST_AUTH_FILE, 'utf8'); -}; - -export const deleteAuthFile = () => { - try { - fs.unlinkSync(TEST_AUTH_FILE); - } catch (error: any) { - if (error.code !== 'ENOENT') { - throw error; - } - } -}; diff --git a/cli/test/e2e/login-key.e2e-spec.ts b/cli/test/e2e/login-key.e2e-spec.ts deleted file mode 100644 index 679d510002..0000000000 --- a/cli/test/e2e/login-key.e2e-spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { restoreTempFolder, testApp } from '@test-utils'; -import { readFile, stat } from 'node:fs/promises'; -import { CLI_BASE_OPTIONS, TEST_AUTH_FILE, deleteAuthFile, setup, spyOnConsole } from 'test/cli-test-utils'; -import yaml from 'yaml'; -import { LoginCommand } from '../../src/commands/login.command'; - -describe(`login-key (e2e)`, () => { - let apiKey: string; - let instanceUrl: string; - - spyOnConsole(); - - beforeAll(async () => { - await testApp.create(); - if (process.env.IMMICH_INSTANCE_URL) { - instanceUrl = process.env.IMMICH_INSTANCE_URL; - } else { - throw new Error('IMMICH_INSTANCE_URL environment variable not set'); - } - }); - - afterAll(async () => { - await testApp.teardown(); - await restoreTempFolder(); - deleteAuthFile(); - }); - - beforeEach(async () => { - await testApp.reset(); - await restoreTempFolder(); - - const api = await setup(); - apiKey = api.apiKey; - - deleteAuthFile(); - }); - - it('should error when providing an invalid API key', async () => { - await expect(new LoginCommand(CLI_BASE_OPTIONS).run(instanceUrl, 'invalid')).rejects.toThrow( - `Failed to connect to server ${instanceUrl}: Error: 401`, - ); - }); - - it('should log in when providing the correct API key', async () => { - await new LoginCommand(CLI_BASE_OPTIONS).run(instanceUrl, apiKey); - }); - - it('should create an auth file when logging in', async () => { - await new LoginCommand(CLI_BASE_OPTIONS).run(instanceUrl, apiKey); - - const data: string = await readFile(TEST_AUTH_FILE, 'utf8'); - const parsedConfig = yaml.parse(data); - - expect(parsedConfig).toEqual(expect.objectContaining({ instanceUrl, apiKey })); - }); - - it('should create an auth file with chmod 600', async () => { - await new LoginCommand(CLI_BASE_OPTIONS).run(instanceUrl, apiKey); - - const stats = await stat(TEST_AUTH_FILE); - const mode = (stats.mode & 0o777).toString(8); - - expect(mode).toEqual('600'); - }); -}); diff --git a/cli/test/e2e/server-info.e2e-spec.ts b/cli/test/e2e/server-info.e2e-spec.ts deleted file mode 100644 index c0b10813a6..0000000000 --- a/cli/test/e2e/server-info.e2e-spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { restoreTempFolder, testApp } from '@test-utils'; -import { CLI_BASE_OPTIONS, setup, spyOnConsole } from 'test/cli-test-utils'; -import { ServerInfoCommand } from '../../src/commands/server-info.command'; - -describe(`server-info (e2e)`, () => { - const consoleSpy = spyOnConsole(); - - beforeAll(async () => { - await testApp.create(); - }); - - afterAll(async () => { - await testApp.teardown(); - await restoreTempFolder(); - }); - - beforeEach(async () => { - await testApp.reset(); - await restoreTempFolder(); - const api = await setup(); - process.env.IMMICH_API_KEY = api.apiKey; - }); - - it('should show server version', async () => { - await new ServerInfoCommand(CLI_BASE_OPTIONS).run(); - - expect(consoleSpy.mock.calls).toEqual([ - [expect.stringMatching(new RegExp('Server Version: \\d+.\\d+.\\d+'))], - [expect.stringMatching('Image Types: .*')], - [expect.stringMatching('Video Types: .*')], - ['Statistics:\n Images: 0\n Videos: 0\n Total: 0'], - ]); - }); -}); diff --git a/cli/test/e2e/setup.ts b/cli/test/e2e/setup.ts deleted file mode 100644 index f51976aa7a..0000000000 --- a/cli/test/e2e/setup.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { PostgreSqlContainer } from '@testcontainers/postgresql'; -import { access } from 'node:fs/promises'; -import path from 'node:path'; - -export const directoryExists = (directory: string) => - access(directory) - .then(() => true) - .catch(() => false); - -export default async () => { - let IMMICH_TEST_ASSET_PATH: string = ''; - - if (process.env.IMMICH_TEST_ASSET_PATH === undefined) { - IMMICH_TEST_ASSET_PATH = path.normalize(`${__dirname}/../../../server/test/assets/`); - process.env.IMMICH_TEST_ASSET_PATH = IMMICH_TEST_ASSET_PATH; - } else { - IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH; - } - - if (!(await directoryExists(`${IMMICH_TEST_ASSET_PATH}/albums`))) { - throw new Error( - `Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${IMMICH_TEST_ASSET_PATH} before testing`, - ); - } - - if (process.env.DB_HOSTNAME === undefined) { - // DB hostname not set which likely means we're not running e2e through docker compose. Start a local postgres container. - const pg = await new PostgreSqlContainer('tensorchord/pgvecto-rs:pg14-v0.2.0') - .withExposedPorts(5432) - .withDatabase('immich') - .withUsername('postgres') - .withPassword('postgres') - .withReuse() - .start(); - - process.env.DB_URL = pg.getConnectionUri(); - } - - process.env.NODE_ENV = 'development'; - process.env.IMMICH_CONFIG_FILE = path.normalize(`${__dirname}/../../../server/e2e/jobs/immich-e2e-config.json`); - process.env.TZ = 'Z'; -}; diff --git a/cli/test/e2e/upload.e2e-spec.ts b/cli/test/e2e/upload.e2e-spec.ts deleted file mode 100644 index 4c4bf10739..0000000000 --- a/cli/test/e2e/upload.e2e-spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { IMMICH_TEST_ASSET_PATH, restoreTempFolder, testApp } from '@test-utils'; -import { ImmichApi } from 'src/services/api.service'; -import { CLI_BASE_OPTIONS, setup, spyOnConsole } from 'test/cli-test-utils'; -import { UploadCommand } from '../../src/commands/upload.command'; - -describe(`upload (e2e)`, () => { - let api: ImmichApi; - - spyOnConsole(); - - beforeAll(async () => { - await testApp.create(); - }); - - afterAll(async () => { - await testApp.teardown(); - await restoreTempFolder(); - }); - - beforeEach(async () => { - await testApp.reset(); - await restoreTempFolder(); - api = await setup(); - process.env.IMMICH_API_KEY = api.apiKey; - }); - - it('should upload a folder recursively', async () => { - await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { recursive: true }); - const assets = await api.getAllAssets(); - expect(assets.length).toBeGreaterThan(4); - }); - - it('should not create a new album', async () => { - await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { recursive: true }); - const albums = await api.getAllAlbums(); - expect(albums.length).toEqual(0); - }); - - it('should create album from folder name', async () => { - await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { - recursive: true, - album: true, - }); - - const albums = await api.getAllAlbums(); - expect(albums.length).toEqual(1); - const natureAlbum = albums[0]; - expect(natureAlbum.albumName).toEqual('nature'); - }); - - it('should add existing assets to album', async () => { - await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { - recursive: true, - }); - - // upload again, but this time add to album - await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { - recursive: true, - album: true, - }); - - const albums = await api.getAllAlbums(); - expect(albums.length).toEqual(1); - const natureAlbum = albums[0]; - expect(natureAlbum.albumName).toEqual('nature'); - }); - - it('should upload to the specified album name', async () => { - await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { - recursive: true, - albumName: 'testAlbum', - }); - - const albums = await api.getAllAlbums(); - expect(albums.length).toEqual(1); - const testAlbum = albums[0]; - expect(testAlbum.albumName).toEqual('testAlbum'); - }); -}); diff --git a/cli/test/e2e/vitest.config.ts b/cli/test/e2e/vitest.config.ts deleted file mode 100644 index 1657938765..0000000000 --- a/cli/test/e2e/vitest.config.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - resolve: { - alias: { - '@test-utils': new URL('../../../server/dist/test-utils/utils.js', import.meta.url).pathname, - }, - }, - test: { - include: ['**/*.e2e-spec.ts'], - globals: true, - globalSetup: 'test/e2e/setup.ts', - pool: 'forks', - poolOptions: { - forks: { - maxForks: 1, - minForks: 1, - }, - }, - testTimeout: 10_000, - }, -}); diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 798d8502f1..d8804445d2 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -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 diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 588d0feed4..5e0ffb0e2b 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -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 diff --git a/e2e/package.json b/e2e/package.json index 82bb17c7a8..ebd5b9aeae 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -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", diff --git a/e2e/src/api/specs/auth.e2e-spec.ts b/e2e/src/api/specs/auth.e2e-spec.ts index adc5df37fd..20eb6a2760 100644 --- a/e2e/src/api/specs/auth.e2e-spec.ts +++ b/e2e/src/api/specs/auth.e2e-spec.ts @@ -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}`) diff --git a/e2e/src/cli/specs/login.e2e-spec.ts b/e2e/src/cli/specs/login.e2e-spec.ts new file mode 100644 index 0000000000..ef811a8678 --- /dev/null +++ b/e2e/src/cli/specs/login.e2e-spec.ts @@ -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'); + }); +}); diff --git a/e2e/src/cli/specs/server-info.e2e-spec.ts b/e2e/src/cli/specs/server-info.e2e-spec.ts new file mode 100644 index 0000000000..e9e89befd1 --- /dev/null +++ b/e2e/src/cli/specs/server-info.e2e-spec.ts @@ -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); + }); +}); diff --git a/e2e/src/cli/specs/upload.e2e-spec.ts b/e2e/src/cli/specs/upload.e2e-spec.ts new file mode 100644 index 0000000000..b736bed93c --- /dev/null +++ b/e2e/src/cli/specs/upload.e2e-spec.ts @@ -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'); + }); + }); +}); diff --git a/e2e/src/cli/specs/version.e2e-spec.ts b/e2e/src/cli/specs/version.e2e-spec.ts new file mode 100644 index 0000000000..e94ccf214f --- /dev/null +++ b/e2e/src/cli/specs/version.e2e-spec.ts @@ -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); + }); + }); +}); diff --git a/e2e/src/api/setup.ts b/e2e/src/setup.ts similarity index 87% rename from e2e/src/api/setup.ts rename to e2e/src/setup.ts index 3006e8776e..b560a2bbb1 100644 --- a/e2e/src/api/setup.ts +++ b/e2e/src/setup.ts @@ -2,7 +2,7 @@ import { spawn, exec } from 'child_process'; export default async () => { let _resolve: () => unknown; - const promise = new Promise((resolve, reject) => (_resolve = resolve)); + const promise = new Promise((resolve) => (_resolve = resolve)); const child = spawn('docker', ['compose', 'up'], { stdio: 'pipe' }); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index d2e359fde0..7480d32b63 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -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((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 = { diff --git a/e2e/src/web/specs/auth.e2e-spec.ts b/e2e/src/web/specs/auth.e2e-spec.ts index cc1e0791d8..63935d5b42 100644 --- a/e2e/src/web/specs/auth.e2e-spec.ts +++ b/e2e/src/web/specs/auth.e2e-spec.ts @@ -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(); }); diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts index 8632d47735..72c126a899 100644 --- a/e2e/vitest.config.ts +++ b/e2e/vitest.config.ts @@ -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,