feat: run e2e tests inside Docker compose network and in parallel

Change-Id: I04332d4f153b720316ab7b08c12f9a6e6a6a6964
This commit is contained in:
midzelis
2026-03-22 18:55:26 +00:00
parent c975fe5bc7
commit 10218fb900
18 changed files with 351 additions and 96 deletions
+40
View File
@@ -0,0 +1,40 @@
FROM node:22-bookworm-slim
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@10.30.3 --activate
RUN apt-get update && apt-get install -y --no-install-recommends \
docker.io \
unzip \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml .pnpmfile.cjs ./
COPY open-api/typescript-sdk/package.json open-api/typescript-sdk/
COPY cli/package.json cli/
COPY web/package.json web/
COPY e2e/package.json e2e/
COPY e2e-auth-server/package.json e2e-auth-server/
RUN pnpm install --frozen-lockfile
COPY open-api/typescript-sdk/ open-api/typescript-sdk/
RUN pnpm --filter @immich/sdk build
COPY cli/ cli/
RUN pnpm --filter @immich/cli build && ln -s /app/cli/bin/immich /app/cli/node_modules/.bin/immich
COPY web/svelte.config.js web/vite.config.ts web/tsconfig.json web/
COPY web/src/ web/src/
COPY web/static/ web/static/
RUN pnpm --filter immich-web exec svelte-kit sync
COPY e2e/ e2e/
COPY e2e-auth-server/ e2e-auth-server/
RUN pnpm --filter immich-e2e exec playwright install --with-deps chromium
WORKDIR /app/e2e
+23
View File
@@ -0,0 +1,23 @@
.vscode/
.github/
.git/
*.log
*.tmp
*.temp
**/node_modules/
**/.pnpm-store/
**/dist/
**/coverage/
**/build/
design/
docker/
docs/
fastlane/
machine-learning/
misc/
mobile/
server/
plugins/
i18n/
+23
View File
@@ -0,0 +1,23 @@
name: immich-e2e
services:
immich-server:
build:
cache_from:
- type=gha,scope=e2e-server-${RUNNER_ARCH:-X64}
cache_to:
- type=gha,mode=max,scope=e2e-server-${RUNNER_ARCH:-X64}
e2e-auth-server:
build:
cache_from:
- type=gha,scope=e2e-auth-${RUNNER_ARCH:-X64}
cache_to:
- type=gha,mode=max,scope=e2e-auth-${RUNNER_ARCH:-X64}
e2e-runner:
build:
cache_from:
- type=gha,scope=e2e-runner-${RUNNER_ARCH:-X64}
cache_to:
- type=gha,mode=max,scope=e2e-runner-${RUNNER_ARCH:-X64}
+28
View File
@@ -5,6 +5,8 @@ services:
container_name: immich-e2e-auth-server
build:
context: ../e2e-auth-server
environment:
EXTRA_REDIRECT_URIS: http://immich-server:2285/auth/login
ports:
- 2286:2286
@@ -65,3 +67,29 @@ services:
timeout: 5s
retries: 30
start_period: 10s
e2e-runner:
container_name: immich-e2e-runner
build:
context: ../
dockerfile: e2e/Dockerfile.playwright
network_mode: 'service:immich-server'
shm_size: 1gb
environment:
PLAYWRIGHT_DB_HOST: database
PLAYWRIGHT_DB_PORT: '5432'
PLAYWRIGHT_DISABLE_WEBSERVER: 'true'
PLAYWRIGHT_AUTH_SERVER_URL: http://e2e-auth-server:2286
VITEST_DISABLE_DOCKER_SETUP: 'true'
CI: '${CI:-}'
volumes:
- ./test-assets:/app/e2e/test-assets
- ./playwright-report:/app/e2e/playwright-report
- /var/run/docker.sock:/var/run/docker.sock
depends_on:
immich-server:
condition: service_started
database:
condition: service_healthy
profiles:
- test
+2 -1
View File
@@ -7,6 +7,7 @@ dotenv.config({ quiet: true, path: resolve(import.meta.dirname, '.env') });
export const playwrightHost = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1';
export const playwrightDbHost = process.env.PLAYWRIGHT_DB_HOST ?? '127.0.0.1';
export const playwrightDbPort = process.env.PLAYWRIGHT_DB_PORT ?? '5435';
export const playwriteBaseUrl = process.env.PLAYWRIGHT_BASE_URL ?? `http://${playwrightHost}:2285`;
export const playwriteSlowMo = Number.parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0');
export const playwrightDisableWebserver = process.env.PLAYWRIGHT_DISABLE_WEBSERVER;
@@ -43,7 +44,7 @@ const config: PlaywrightTestConfig = {
use: { ...devices['Desktop Chrome'] },
testDir: './src/ui/specs',
fullyParallel: true,
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
workers: process.env.CI ? 3 : Math.min(10, Math.max(1, Math.round(cpus().length * 0.75) - 1)),
},
{
name: 'maintenance',
+1 -1
View File
@@ -15,7 +15,7 @@ import { beforeAll, describe, expect, it } from 'vitest';
const authServer = {
internal: 'http://e2e-auth-server:2286',
external: 'http://127.0.0.1:2286',
external: process.env.PLAYWRIGHT_AUTH_SERVER_URL ?? 'http://127.0.0.1:2286',
};
const mobileOverrideRedirectUri = 'https://photos.immich.app/oauth/mobile-redirect';
@@ -106,7 +106,7 @@ describe('/shared-links', () => {
const resp = await request(shareUrl).get(`/${linkWithAssets.key}`);
expect(resp.status).toBe(200);
expect(resp.header['content-type']).toContain('text/html');
expect(resp.text).toContain(`<meta property="og:image" content="http://127.0.0.1:2285`);
expect(resp.text).toContain(`<meta property="og:image" content="${baseUrl}`);
});
it('should fall back to my.immich.app og:image meta tag for shared asset if Host header is not present', async () => {
+4 -4
View File
@@ -1,6 +1,6 @@
import { Permission } from '@immich/sdk';
import { stat } from 'node:fs/promises';
import { app, immichCli, utils } from 'src/utils';
import { app, baseUrl, immichCli, utils } from 'src/utils';
import { beforeEach, describe, expect, it } from 'vitest';
describe(`immich login`, () => {
@@ -33,7 +33,7 @@ describe(`immich login`, () => {
const key = await utils.createApiKey(admin.accessToken, [Permission.All]);
const { stdout, stderr, exitCode } = await immichCli(['login', app, `${key.secret}`]);
expect(stdout.split('\n')).toEqual([
'Logging in to http://127.0.0.1:2285/api',
`Logging in to ${baseUrl}/api`,
'Logged in as admin@immich.cloud',
'Wrote auth info to /tmp/immich/auth.yml',
]);
@@ -50,8 +50,8 @@ describe(`immich login`, () => {
const key = await utils.createApiKey(admin.accessToken, [Permission.All]);
const { stdout, stderr, exitCode } = await immichCli(['login', app.replaceAll('/api', ''), `${key.secret}`]);
expect(stdout.split('\n')).toEqual([
'Logging in to http://127.0.0.1:2285',
'Discovered API at http://127.0.0.1:2285/api',
`Logging in to ${baseUrl}`,
`Discovered API at ${baseUrl}/api`,
'Logged in as admin@immich.cloud',
'Wrote auth info to /tmp/immich/auth.yml',
]);
@@ -1,4 +1,4 @@
import { immichCli, utils } from 'src/utils';
import { baseUrl, immichCli, utils } from 'src/utils';
import { beforeAll, describe, expect, it } from 'vitest';
describe(`immich server-info`, () => {
@@ -12,7 +12,7 @@ describe(`immich server-info`, () => {
const { stderr, stdout, exitCode } = await immichCli(['server-info']);
expect(stdout.split('\n')).toEqual([
expect.stringContaining('Server Info (via admin@immich.cloud'),
' Url: http://127.0.0.1:2285/api',
` Url: ${baseUrl}/api`,
expect.stringContaining('Version:'),
' Formats:',
expect.stringContaining('Images:'),
+20 -4
View File
@@ -1,5 +1,7 @@
import { LoginResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { lookup } from 'node:dns/promises';
import { playwrightHost } from 'playwright.config';
import { utils } from 'src/utils';
test.describe('Websocket', () => {
@@ -12,14 +14,28 @@ test.describe('Websocket', () => {
});
test('connects using ipv4', async ({ page, context }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto('http://127.0.0.1:2285/');
const { address: ipv4 } = await lookup(playwrightHost, 4);
await utils.setAuthCookies(context, admin.accessToken, ipv4);
await page.goto(`http://${ipv4}:2285/`);
await expect(page.locator('#sidebar')).toContainText('Server Online');
});
test('connects using ipv6', async ({ page, context }) => {
await utils.setAuthCookies(context, admin.accessToken, '[::1]');
await page.goto('http://[::1]:2285/');
let ipv6: string;
if (playwrightHost === '127.0.0.1') {
ipv6 = '::1';
} else {
try {
const { address } = await lookup(playwrightHost, 6);
ipv6 = address;
} catch {
test.skip(true, 'No IPv6 address available');
return;
}
}
const ipv6Url = `http://[${ipv6}]:2285/`;
await utils.setAuthCookies(context, admin.accessToken, undefined, ipv6Url);
await page.goto(ipv6Url);
await expect(page.locator('#sidebar')).toContainText('Server Online');
});
});
+11 -1
View File
@@ -1,4 +1,4 @@
import { BrowserContext } from '@playwright/test';
import { BrowserContext, expect, Page } from '@playwright/test';
import { playwrightHost } from 'playwright.config';
export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserId: string) => {
@@ -283,3 +283,13 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
});
});
};
export const waitForServiceWorker = async (page: Page) => {
await expect
.poll(() => page.context().serviceWorkers().length, {
message:
'Service worker not registered. Ensure the origin is a secure context (localhost or use --unsafely-treat-insecure-origin-as-secure flag).',
timeout: 10_000,
})
.toBeGreaterThan(0);
};
+2
View File
@@ -1,5 +1,6 @@
import type { AssetResponseDto } from '@immich/sdk';
import { expect, Page } from '@playwright/test';
import { waitForServiceWorker } from 'src/ui/mock-network/base-network';
function getAssetIdFromUrl(url: URL): string | null {
const pathMatch = url.pathname.match(/\/memory\/photos\/([^/]+)/);
@@ -15,6 +16,7 @@ export const memoryViewerUtils = {
},
async waitForMemoryLoad(page: Page) {
await waitForServiceWorker(page);
await expect(this.locator(page)).toBeVisible();
await expect(page.locator('#memory-viewer img').first()).toBeVisible();
},
+3
View File
@@ -1,6 +1,7 @@
import { BrowserContext, expect, Page } from '@playwright/test';
import { DateTime } from 'luxon';
import { TimelineAssetConfig } from 'src/ui/generators/timeline';
import { waitForServiceWorker } from 'src/ui/mock-network/base-network';
export const sleep = (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -143,6 +144,7 @@ export const timelineUtils = {
return page.locator('#asset-grid');
},
async waitForTimelineLoad(page: Page) {
await waitForServiceWorker(page);
await expect(timelineUtils.locator(page)).toBeInViewport();
await expect.poll(() => thumbnailUtils.locator(page).count()).toBeGreaterThan(0);
},
@@ -163,6 +165,7 @@ export const assetViewerUtils = {
return page.locator('#immich-asset-viewer');
},
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
await waitForServiceWorker(page);
await page
.locator(
`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`,
+9 -10
View File
@@ -71,7 +71,7 @@ import { io, type Socket } from 'socket.io-client';
import { loginDto, signupDto } from 'src/fixtures';
import { makeRandomImage } from 'src/generators';
import request from 'supertest';
import { playwrightDbHost, playwrightHost, playwriteBaseUrl } from '../playwright.config';
import { playwrightDbHost, playwrightDbPort, playwrightHost, playwriteBaseUrl } from '../playwright.config';
export type { Emitter } from '@socket.io/component-emitter';
@@ -81,7 +81,7 @@ type WaitOptions = { event: EventType; id?: string; total?: number; timeout?: nu
type AdminSetupOptions = { onboarding?: boolean };
type FileData = { bytes?: Buffer; filename: string };
const dbUrl = `postgres://postgres:postgres@${playwrightDbHost}:5435/immich`;
const dbUrl = `postgres://postgres:postgres@${playwrightDbHost}:${playwrightDbPort}/immich`;
export const baseUrl = playwriteBaseUrl;
export const shareUrl = `${baseUrl}/share`;
export const app = `${baseUrl}/api`;
@@ -522,13 +522,13 @@ export const utils = {
queueCommand: async (accessToken: string, name: QueueName, queueCommandDto: QueueCommandDto) =>
runQueueCommandLegacy({ name, queueCommandDto }, { headers: asBearerAuth(accessToken) }),
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = playwrightHost) =>
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = playwrightHost, url?: string) => {
const origin = url ? { url } : { domain, path: '/' };
await context.addCookies([
{
name: 'immich_access_token',
value: accessToken,
domain,
path: '/',
...origin,
expires: 2_058_028_213,
httpOnly: true,
secure: false,
@@ -537,8 +537,7 @@ export const utils = {
{
name: 'immich_auth_type',
value: 'password',
domain,
path: '/',
...origin,
expires: 2_058_028_213,
httpOnly: true,
secure: false,
@@ -547,14 +546,14 @@ export const utils = {
{
name: 'immich_is_authenticated',
value: 'true',
domain,
path: '/',
...origin,
expires: 2_058_028_213,
httpOnly: false,
secure: false,
sameSite: 'Lax',
},
]),
]);
},
setMaintenanceAuthCookie: async (context: BrowserContext, token: string, domain = '127.0.0.1') =>
await context.addCookies([