Compare commits

...

3 Commits

Author SHA1 Message Date
midzelis
9935f75cc9 chore(ci): add unified test report PR comment
Change-Id: I1cee5c74dcff06215bf8f75b307a2d296a6a6964
2026-03-25 03:15:32 +00:00
midzelis
1d6131e490 chore(ci): deduplicate e2e server image cache with docker.yml
Change-Id: Idf104d87732b85b7402870195509752a6a6a6964
2026-03-24 18:17:37 +00:00
midzelis
10218fb900 feat: run e2e tests inside Docker compose network and in parallel
Change-Id: I04332d4f153b720316ab7b08c12f9a6e6a6a6964
2026-03-24 18:17:37 +00:00
19 changed files with 926 additions and 105 deletions

395
.github/scripts/write-test-summary.mjs vendored Normal file
View File

@@ -0,0 +1,395 @@
import { readFileSync, appendFileSync, readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import { parseArgs } from "node:util";
const { values } = parseArgs({
options: {
json: { type: "string" },
name: { type: "string" },
framework: { type: "string", default: "vitest" },
coverage: { type: "string" },
"pr-comment": { type: "boolean", default: false },
"artifacts-dir": { type: "string" },
},
});
function readJson(path) {
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch {
return undefined;
}
}
function formatDuration(milliseconds) {
if (milliseconds < 1000) {
return `${milliseconds}ms`;
}
const seconds = milliseconds / 1000;
if (seconds < 60) {
return `${seconds.toFixed(1)}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = (seconds % 60).toFixed(0);
return `${minutes}m ${remainingSeconds}s`;
}
function parseVitestResults(data) {
const startTime = data.startTime ?? 0;
const endTime = Math.max(
...(data.testResults ?? []).map((r) => r.endTime ?? 0),
startTime,
);
return {
total: data.numTotalTests ?? 0,
passed: data.numPassedTests ?? 0,
failed: data.numFailedTests ?? 0,
skipped: data.numPendingTests ?? 0,
flaky: 0,
duration: endTime - startTime,
success: data.success ?? false,
};
}
function parsePlaywrightResults(data) {
const stats = data.stats ?? {};
const passed = stats.expected ?? 0;
const failed = stats.unexpected ?? 0;
const flaky = stats.flaky ?? 0;
const skipped = stats.skipped ?? 0;
return {
total: passed + failed + flaky + skipped,
passed,
failed,
skipped,
flaky,
duration: stats.duration ?? 0,
success: failed === 0,
};
}
function parseCoverageSummary(data) {
const total = data.total ?? {};
const files = [];
for (const [filePath, entry] of Object.entries(data)) {
if (filePath === "total") {
continue;
}
files.push({
file: filePath.replace(/^.*?\/src\//, "src/"),
lines: entry.lines?.pct ?? 0,
branches: entry.branches?.pct ?? 0,
functions: entry.functions?.pct ?? 0,
statements: entry.statements?.pct ?? 0,
});
}
files.sort((a, b) => a.lines - b.lines);
return {
lines: total.lines?.pct ?? 0,
branches: total.branches?.pct ?? 0,
functions: total.functions?.pct ?? 0,
statements: total.statements?.pct ?? 0,
files,
};
}
function buildMarkdown(name, results, coverage) {
const statusIcon =
results.failed > 0
? "\u274c"
: results.flaky > 0
? "\u26a0\ufe0f"
: "\u2705";
const lines = [];
lines.push(`### ${statusIcon} ${name}`);
lines.push("");
lines.push("| Metric | Value |");
lines.push("|--------|-------|");
lines.push(`| Total | ${results.total} |`);
lines.push(`| Passed | ${results.passed} |`);
lines.push(`| Failed | ${results.failed} |`);
lines.push(`| Skipped | ${results.skipped} |`);
if (results.flaky > 0) {
lines.push(`| Flaky | ${results.flaky} |`);
}
lines.push(`| Duration | ${formatDuration(results.duration)} |`);
lines.push("");
if (coverage) {
lines.push("#### Coverage");
lines.push("");
lines.push("| Metric | Coverage |");
lines.push("|--------|----------|");
lines.push(`| Lines | ${coverage.lines}% |`);
lines.push(`| Branches | ${coverage.branches}% |`);
lines.push(`| Functions | ${coverage.functions}% |`);
lines.push(`| Statements | ${coverage.statements}% |`);
lines.push("");
if (coverage.files?.length > 0) {
lines.push("<details>");
lines.push(
`<summary>File coverage (${coverage.files.length} files)</summary>`,
);
lines.push("");
lines.push("| File | Lines | Branches | Functions |");
lines.push("|------|-------|----------|-----------|");
for (const file of coverage.files) {
lines.push(
`| ${file.file} | ${file.lines}% | ${file.branches}% | ${file.functions}% |`,
);
}
lines.push("");
lines.push("</details>");
lines.push("");
}
}
return lines.join("\n");
}
const ARTIFACT_CONFIGS = [
{
pattern: "report-server-unit",
name: "Server Unit Tests",
framework: "vitest",
testFile: "test-results.json",
coverageFile: "coverage/coverage-summary.json",
},
{
pattern: "report-web-unit",
name: "Web Unit Tests",
framework: "vitest",
testFile: "test-results.json",
coverageFile: "coverage/coverage-summary.json",
},
{
pattern: "report-server-medium",
name: "Server Medium Tests",
framework: "vitest",
testFile: "test-results-medium.json",
coverageFile: "coverage/coverage-summary.json",
},
{
pattern: "report-cli-unit",
name: "CLI Unit Tests",
framework: "vitest",
testFile: "test-results.json",
coverageFile: undefined,
},
{
pattern: "report-cli-unit-win",
name: "CLI Unit Tests (Windows)",
framework: "vitest",
testFile: "test-results.json",
coverageFile: undefined,
},
{
pattern: "report-e2e-server-cli-",
name: "E2E Server & CLI",
framework: "vitest",
testFile: "test-results.json",
coverageFile: undefined,
},
{
pattern: "report-e2e-server-maintenance-",
name: "E2E Server Maintenance",
framework: "vitest",
testFile: "test-results.json",
coverageFile: undefined,
},
{
pattern: "report-e2e-web-",
name: "E2E Web",
framework: "playwright",
testFile: "test-results.json",
coverageFile: undefined,
},
{
pattern: "report-e2e-web-ui-",
name: "E2E Web UI",
framework: "playwright",
testFile: "test-results.json",
coverageFile: undefined,
},
{
pattern: "report-e2e-web-maintenance-",
name: "E2E Web Maintenance",
framework: "playwright",
testFile: "test-results.json",
coverageFile: undefined,
},
];
function getStatusIcon(results) {
if (results.failed > 0) {
return "\u274c";
}
if (results.flaky > 0) {
return "\u26a0\ufe0f";
}
return "\u2705";
}
function discoverArtifacts(artifactsDir) {
const dirs = readdirSync(artifactsDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
const suites = [];
for (const dir of dirs) {
const config = ARTIFACT_CONFIGS.find((c) => dir.startsWith(c.pattern));
if (!config) {
continue;
}
const suffix = dir.slice(config.pattern.length);
const displayName = suffix ? `${config.name} (${suffix})` : config.name;
const testFilePath = join(artifactsDir, dir, config.testFile);
const testData = readJson(testFilePath);
if (!testData) {
continue;
}
const results =
config.framework === "playwright"
? parsePlaywrightResults(testData)
: parseVitestResults(testData);
let coverage;
if (config.coverageFile) {
const coveragePath = join(artifactsDir, dir, config.coverageFile);
const coverageData = readJson(coveragePath);
if (coverageData) {
coverage = parseCoverageSummary(coverageData);
}
}
suites.push({ name: displayName, results, coverage });
}
return suites;
}
function buildPrComment(suites) {
if (suites.length === 0) {
return "## Test Report\n\nNo test results found.\n";
}
const lines = [];
const totalFailed = suites.reduce((s, r) => s + r.results.failed, 0);
const totalFlaky = suites.reduce((s, r) => s + r.results.flaky, 0);
const overallIcon =
totalFailed > 0 ? "\u274c" : totalFlaky > 0 ? "\u26a0\ufe0f" : "\u2705";
lines.push(`## ${overallIcon} Test Report`);
lines.push("");
lines.push("| Suite | Tests | Passed | Failed | Skipped | Duration |");
lines.push("|-------|------:|-------:|-------:|--------:|---------:|");
for (const suite of suites) {
const { results } = suite;
const icon = getStatusIcon(results);
const flaky = results.flaky > 0 ? ` (${results.flaky} flaky)` : "";
lines.push(
`| ${icon} ${suite.name} | ${results.total} | ${results.passed} | ${results.failed}${flaky} | ${results.skipped} | ${formatDuration(results.duration)} |`,
);
}
lines.push("");
const suitesWithCoverage = suites.filter((s) => s.coverage);
if (suitesWithCoverage.length > 0) {
lines.push("### Coverage");
lines.push("");
lines.push("| Suite | Lines | Branches | Functions | Statements |");
lines.push("|-------|------:|---------:|----------:|-----------:|");
for (const suite of suitesWithCoverage) {
const c = suite.coverage;
lines.push(
`| ${suite.name} | ${c.lines}% | ${c.branches}% | ${c.functions}% | ${c.statements}% |`,
);
}
lines.push("");
const allFiles = suitesWithCoverage.flatMap(
(s) =>
s.coverage.files?.map((f) => ({
...f,
suite: s.name,
})) ?? [],
);
if (allFiles.length > 0) {
lines.push("<details>");
lines.push(`<summary>File coverage (${allFiles.length} files)</summary>`);
lines.push("");
for (const suite of suitesWithCoverage) {
if (!suite.coverage.files?.length) {
continue;
}
lines.push(`#### ${suite.name}`);
lines.push("");
lines.push("| File | Lines | Branches | Functions |");
lines.push("|------|------:|---------:|----------:|");
for (const file of suite.coverage.files) {
lines.push(
`| ${file.file} | ${file.lines}% | ${file.branches}% | ${file.functions}% |`,
);
}
lines.push("");
}
lines.push("</details>");
lines.push("");
}
}
return lines.join("\n");
}
if (values["pr-comment"]) {
const artifactsDir = values["artifacts-dir"];
if (!artifactsDir || !existsSync(artifactsDir)) {
console.error(`Artifacts directory not found: ${artifactsDir}`);
process.exit(1);
}
const suites = discoverArtifacts(artifactsDir);
const markdown = buildPrComment(suites);
process.stdout.write(markdown);
} else {
const summaryFile = process.env.GITHUB_STEP_SUMMARY;
if (!summaryFile) {
console.error("GITHUB_STEP_SUMMARY is not set");
process.exit(1);
}
const testData = readJson(values.json);
if (!testData) {
const fallback = `### \u26a0\ufe0f ${values.name}\n\nNo test results found at \`${values.json}\`\n\n`;
appendFileSync(summaryFile, fallback);
process.exit(0);
}
const results =
values.framework === "playwright"
? parsePlaywrightResults(testData)
: parseVitestResults(testData);
const coverageData = values.coverage ? readJson(values.coverage) : undefined;
const coverage = coverageData
? parseCoverageSummary(coverageData)
: undefined;
const markdown = buildMarkdown(values.name, results, coverage);
appendFileSync(summaryFile, markdown);
}

View File

@@ -94,8 +94,20 @@ jobs:
run: pnpm check
if: ${{ !cancelled() }}
- name: Run small tests & coverage
run: pnpm test
run: pnpm test --reporter=default --reporter=json --outputFile test-results.json --coverage --coverage.reporter=text --coverage.reporter=json-summary --coverage.reporter=json
if: ${{ !cancelled() }}
- name: Write test summary
if: always()
run: node ${{ github.workspace }}/.github/scripts/write-test-summary.mjs --json test-results.json --name "Server Unit Tests" --framework vitest --coverage coverage/coverage-summary.json
- name: Upload test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: report-server-unit
path: |
server/test-results.json
server/coverage/coverage-summary.json
retention-days: 1
cli-unit-tests:
name: Unit Test CLI
needs: pre-job
@@ -141,8 +153,18 @@ jobs:
run: pnpm check
if: ${{ !cancelled() }}
- name: Run unit tests & coverage
run: pnpm test
run: pnpm test --reporter=default --reporter=json --outputFile test-results.json
if: ${{ !cancelled() }}
- name: Write test summary
if: always()
run: node ${{ github.workspace }}/.github/scripts/write-test-summary.mjs --json test-results.json --name "CLI Unit Tests" --framework vitest
- name: Upload test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: report-cli-unit
path: cli/test-results.json
retention-days: 1
cli-unit-tests-win:
name: Unit Test CLI (Windows)
needs: pre-job
@@ -183,8 +205,18 @@ jobs:
run: pnpm check
if: ${{ !cancelled() }}
- name: Run unit tests & coverage
run: pnpm test
run: pnpm test --reporter=default --reporter=json --outputFile test-results.json
if: ${{ !cancelled() }}
- name: Write test summary
if: always()
run: node ${{ github.workspace }}/.github/scripts/write-test-summary.mjs --json test-results.json --name "CLI Unit Tests (Windows)" --framework vitest
- name: Upload test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: report-cli-unit-win
path: cli/test-results.json
retention-days: 1
web-lint:
name: Lint Web
needs: pre-job
@@ -268,8 +300,20 @@ jobs:
run: pnpm check:typescript
if: ${{ !cancelled() }}
- name: Run unit tests & coverage
run: pnpm test
run: pnpm test --reporter=default --reporter=json --outputFile test-results.json --coverage --coverage.reporter=text --coverage.reporter=json-summary --coverage.reporter=json
if: ${{ !cancelled() }}
- name: Write test summary
if: always()
run: node ${{ github.workspace }}/.github/scripts/write-test-summary.mjs --json test-results.json --name "Web Unit Tests" --framework vitest --coverage coverage/coverage-summary.json
- name: Upload test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: report-web-unit
path: |
web/test-results.json
web/coverage/coverage-summary.json
retention-days: 1
i18n-tests:
name: Test i18n
needs: pre-job
@@ -395,8 +439,20 @@ jobs:
- name: Run pnpm install
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
- name: Run medium tests
run: pnpm test:medium
run: pnpm test:medium --reporter=default --reporter=json --outputFile test-results-medium.json --coverage --coverage.reporter=text --coverage.reporter=json-summary --coverage.reporter=json
if: ${{ !cancelled() }}
- name: Write test summary
if: always()
run: node ${{ github.workspace }}/.github/scripts/write-test-summary.mjs --json test-results-medium.json --name "Server Medium Tests" --framework vitest --coverage coverage/coverage-summary.json
- name: Upload test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: report-server-medium
path: |
server/test-results-medium.json
server/coverage/coverage-summary.json
retention-days: 1
e2e-tests-server-cli:
name: End-to-End Tests (Server & CLI)
needs: pre-job
@@ -404,6 +460,7 @@ jobs:
runs-on: ${{ matrix.runner }}
permissions:
contents: read
actions: write
defaults:
run:
working-directory: ./e2e
@@ -416,58 +473,109 @@ jobs:
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }}
- name: Run setup web
run: pnpm install --frozen-lockfile && pnpm exec svelte-kit sync
working-directory: ./web
if: ${{ !cancelled() }}
- name: Run setup cli
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./cli
if: ${{ !cancelled() }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
if: ${{ !cancelled() }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Compute server cache key
run: |
BUILD_ARGS=$'DEVICE=cpu\n'
HASH=$(sha256sum <<< "${BUILD_ARGS}" | cut -d' ' -f1)
ARCH=$(echo "${{ runner.arch }}" | tr '[:upper:]' '[:lower:]')
[[ "$ARCH" == "x64" ]] && ARCH="amd64"
echo "SERVER_CACHE_KEY=linux-${ARCH}-${HASH}-main" >> $GITHUB_ENV
- name: Build Docker images from cache
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml --profile test build
- name: Start Docker Compose
run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
run: docker compose up -d --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
if: ${{ !cancelled() }}
- name: Run e2e tests (api & cli)
env:
VITEST_DISABLE_DOCKER_SETUP: true
run: pnpm test
run: docker compose --profile test run --rm e2e-runner pnpm test --reporter=default --reporter=json --outputFile playwright-report/test-results.json
if: ${{ !cancelled() }}
- name: Run e2e tests (maintenance)
env:
VITEST_DISABLE_DOCKER_SETUP: true
run: pnpm test:maintenance
- name: Write test summary
if: always()
run: node ${{ github.workspace }}/.github/scripts/write-test-summary.mjs --json playwright-report/test-results.json --name "E2E Server & CLI Tests (${{ matrix.runner }})" --framework vitest
- name: Upload test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: report-e2e-server-cli-${{ matrix.runner }}
path: e2e/playwright-report/test-results.json
retention-days: 1
- name: Capture Docker logs
if: always()
run: docker compose logs --no-color > docker-compose-logs.txt
- name: Archive Docker logs
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: always()
with:
name: e2e-server-docker-logs-${{ matrix.runner }}
path: e2e/docker-compose-logs.txt
e2e-tests-server-maintenance:
name: End-to-End Tests (Server Maintenance)
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true || fromJSON(needs.pre-job.outputs.should_run).server == true || fromJSON(needs.pre-job.outputs.should_run).cli == true }}
runs-on: ${{ matrix.runner }}
permissions:
contents: read
actions: write
defaults:
run:
working-directory: ./e2e
strategy:
matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Compute server cache key
run: |
BUILD_ARGS=$'DEVICE=cpu\n'
HASH=$(sha256sum <<< "${BUILD_ARGS}" | cut -d' ' -f1)
ARCH=$(echo "${{ runner.arch }}" | tr '[:upper:]' '[:lower:]')
[[ "$ARCH" == "x64" ]] && ARCH="amd64"
echo "SERVER_CACHE_KEY=linux-${ARCH}-${HASH}-main" >> $GITHUB_ENV
- name: Build Docker images from cache
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml --profile test build
- name: Start Docker Compose
run: docker compose up -d --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
if: ${{ !cancelled() }}
- name: Run e2e tests (maintenance)
run: docker compose --profile test run --rm e2e-runner pnpm test:maintenance --reporter=default --reporter=json --outputFile playwright-report/test-results.json
if: ${{ !cancelled() }}
- name: Write test summary
if: always()
run: node ${{ github.workspace }}/.github/scripts/write-test-summary.mjs --json playwright-report/test-results.json --name "E2E Server Maintenance Tests (${{ matrix.runner }})" --framework vitest
- name: Upload test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: report-e2e-server-maintenance-${{ matrix.runner }}
path: e2e/playwright-report/test-results.json
retention-days: 1
- name: Capture Docker logs
if: always()
run: docker compose logs --no-color > docker-compose-logs.txt
- name: Archive Docker logs
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: always()
with:
name: e2e-server-docker-logs-${{ matrix.runner }}
name: e2e-server-maintenance-docker-logs-${{ matrix.runner }}
path: e2e/docker-compose-logs.txt
e2e-tests-web:
name: End-to-End Tests (Web)
@@ -476,6 +584,7 @@ jobs:
runs-on: ${{ matrix.runner }}
permissions:
contents: read
actions: write
defaults:
run:
working-directory: ./e2e
@@ -488,61 +597,175 @@ jobs:
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
if: ${{ !cancelled() }}
- name: Install Playwright Browsers
run: pnpm exec playwright install chromium --only-shell
if: ${{ !cancelled() }}
- name: Docker build
run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Compute server cache key
run: |
BUILD_ARGS=$'DEVICE=cpu\n'
HASH=$(sha256sum <<< "${BUILD_ARGS}" | cut -d' ' -f1)
ARCH=$(echo "${{ runner.arch }}" | tr '[:upper:]' '[:lower:]')
[[ "$ARCH" == "x64" ]] && ARCH="amd64"
echo "SERVER_CACHE_KEY=linux-${ARCH}-${HASH}-main" >> $GITHUB_ENV
- name: Build Docker images from cache
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml --profile test build
- name: Start Docker Compose
run: docker compose up -d --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
if: ${{ !cancelled() }}
- name: Run e2e tests (web)
env:
PLAYWRIGHT_DISABLE_WEBSERVER: true
run: pnpm test:web
run: docker compose --profile test run --rm e2e-runner pnpm test:web
if: ${{ !cancelled() }}
- name: Write test summary (web)
if: always()
run: node ${{ github.workspace }}/.github/scripts/write-test-summary.mjs --json playwright-report/test-results.json --name "E2E Web Tests (${{ matrix.runner }})" --framework playwright
- name: Upload test results (web)
if: ${{ !cancelled() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: report-e2e-web-${{ matrix.runner }}
path: e2e/playwright-report/test-results.json
retention-days: 1
- name: Archive e2e test (web) results
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: success() || failure()
with:
name: e2e-web-test-results-${{ matrix.runner }}
path: e2e/playwright-report/
- name: Run ui tests (web)
env:
PLAYWRIGHT_DISABLE_WEBSERVER: true
run: pnpm test:web:ui
- name: Capture Docker logs
if: always()
run: docker compose logs --no-color > docker-compose-logs.txt
- name: Archive Docker logs
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: always()
with:
name: e2e-web-docker-logs-${{ matrix.runner }}
path: e2e/docker-compose-logs.txt
e2e-tests-web-ui:
name: End-to-End Tests (Web UI)
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true || fromJSON(needs.pre-job.outputs.should_run).web == true }}
runs-on: ${{ matrix.runner }}
permissions:
contents: read
actions: write
defaults:
run:
working-directory: ./e2e
strategy:
matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Compute server cache key
run: |
BUILD_ARGS=$'DEVICE=cpu\n'
HASH=$(sha256sum <<< "${BUILD_ARGS}" | cut -d' ' -f1)
ARCH=$(echo "${{ runner.arch }}" | tr '[:upper:]' '[:lower:]')
[[ "$ARCH" == "x64" ]] && ARCH="amd64"
echo "SERVER_CACHE_KEY=linux-${ARCH}-${HASH}-main" >> $GITHUB_ENV
- name: Build Docker images from cache
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml --profile test build
- name: Start Docker Compose
run: docker compose up -d --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
if: ${{ !cancelled() }}
- name: Run ui tests (web)
run: docker compose --profile test run --rm e2e-runner pnpm test:web:ui
if: ${{ !cancelled() }}
- name: Write test summary (ui)
if: always()
run: node ${{ github.workspace }}/.github/scripts/write-test-summary.mjs --json playwright-report/test-results.json --name "E2E Web UI Tests (${{ matrix.runner }})" --framework playwright
- name: Upload test results (ui)
if: ${{ !cancelled() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: report-e2e-web-ui-${{ matrix.runner }}
path: e2e/playwright-report/test-results.json
retention-days: 1
- name: Archive ui test (web) results
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: success() || failure()
with:
name: e2e-ui-test-results-${{ matrix.runner }}
path: e2e/playwright-report/
- name: Run maintenance tests
env:
PLAYWRIGHT_DISABLE_WEBSERVER: true
run: pnpm test:web:maintenance
- name: Capture Docker logs
if: always()
run: docker compose logs --no-color > docker-compose-logs.txt
- name: Archive Docker logs
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: always()
with:
name: e2e-web-ui-docker-logs-${{ matrix.runner }}
path: e2e/docker-compose-logs.txt
e2e-tests-web-maintenance:
name: End-to-End Tests (Web Maintenance)
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true || fromJSON(needs.pre-job.outputs.should_run).web == true }}
runs-on: ${{ matrix.runner }}
permissions:
contents: read
actions: write
defaults:
run:
working-directory: ./e2e
strategy:
matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Compute server cache key
run: |
BUILD_ARGS=$'DEVICE=cpu\n'
HASH=$(sha256sum <<< "${BUILD_ARGS}" | cut -d' ' -f1)
ARCH=$(echo "${{ runner.arch }}" | tr '[:upper:]' '[:lower:]')
[[ "$ARCH" == "x64" ]] && ARCH="amd64"
echo "SERVER_CACHE_KEY=linux-${ARCH}-${HASH}-main" >> $GITHUB_ENV
- name: Build Docker images from cache
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml --profile test build
- name: Start Docker Compose
run: docker compose up -d --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
if: ${{ !cancelled() }}
- name: Run maintenance tests
run: docker compose --profile test run --rm e2e-runner pnpm test:web:maintenance
if: ${{ !cancelled() }}
- name: Write test summary (maintenance)
if: always()
run: node ${{ github.workspace }}/.github/scripts/write-test-summary.mjs --json playwright-report/test-results.json --name "E2E Web Maintenance Tests (${{ matrix.runner }})" --framework playwright
- name: Upload test results (maintenance)
if: ${{ !cancelled() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: report-e2e-web-maintenance-${{ matrix.runner }}
path: e2e/playwright-report/test-results.json
retention-days: 1
- name: Archive maintenance tests (web) results
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: success() || failure()
@@ -552,16 +775,22 @@ jobs:
- name: Capture Docker logs
if: always()
run: docker compose logs --no-color > docker-compose-logs.txt
working-directory: ./e2e
- name: Archive Docker logs
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: always()
with:
name: e2e-web-docker-logs-${{ matrix.runner }}
name: e2e-web-maintenance-docker-logs-${{ matrix.runner }}
path: e2e/docker-compose-logs.txt
success-check-e2e:
name: End-to-End Tests Success
needs: [e2e-tests-server-cli, e2e-tests-web]
needs:
[
e2e-tests-server-cli,
e2e-tests-server-maintenance,
e2e-tests-web,
e2e-tests-web-ui,
e2e-tests-web-maintenance,
]
permissions: {}
runs-on: ubuntu-latest
if: always()
@@ -569,6 +798,39 @@ jobs:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
with:
needs: ${{ toJSON(needs) }}
test-report:
name: PR Test Report
if: github.event_name == 'pull_request' && always()
needs:
- server-unit-tests
- web-unit-tests
- server-medium-tests
- cli-unit-tests
- cli-unit-tests-win
- e2e-tests-server-cli
- e2e-tests-server-maintenance
- e2e-tests-web
- e2e-tests-web-ui
- e2e-tests-web-maintenance
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: 'report-*'
path: artifacts
- name: Generate unified report
run: node .github/scripts/write-test-summary.mjs --pr-comment --artifacts-dir artifacts > pr-comment.md
- name: Post PR comment
uses: marocchino/sticky-pull-request-comment@70d2764d1a7d5d9560b100cbea0077fc8f633987 # v3.0.2
with:
header: test-report
path: pr-comment.md
mobile-unit-tests:
name: Unit Test Mobile
needs: pre-job

View File

@@ -24,7 +24,7 @@ e2e-update:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
e2e-down:
docker compose -f ./e2e/docker-compose.yml down --remove-orphans
docker compose -f ./e2e/docker-compose.yml --profile test down --remove-orphans
prod:
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
@@ -101,10 +101,30 @@ check-web:
pnpm --filter immich-web run check:svelte
test-%:
pnpm --filter $(call map-package,$*) run test
test-e2e:
docker compose -f ./e2e/docker-compose.yml build
pnpm --filter immich-e2e run test
pnpm --filter immich-e2e run test:web
test-e2e: build-e2e test-e2e-server test-e2e-server-maintenance test-e2e-web test-e2e-web-ui test-e2e-web-maintenance
test-e2e-server:
docker compose -f ./e2e/docker-compose.yml up -d --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
docker compose -f ./e2e/docker-compose.yml --profile test run --rm e2e-runner pnpm test
test-e2e-server-maintenance:
docker compose -f ./e2e/docker-compose.yml up -d --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
docker compose -f ./e2e/docker-compose.yml --profile test run --rm e2e-runner pnpm test:maintenance
test-e2e-web:
docker compose -f ./e2e/docker-compose.yml up -d --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
docker compose -f ./e2e/docker-compose.yml --profile test run --rm e2e-runner pnpm test:web
test-e2e-web-ui:
docker compose -f ./e2e/docker-compose.yml up -d --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
docker compose -f ./e2e/docker-compose.yml --profile test run --rm e2e-runner pnpm test:web:ui
test-e2e-web-maintenance:
docker compose -f ./e2e/docker-compose.yml up -d --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
docker compose -f ./e2e/docker-compose.yml --profile test run --rm e2e-runner pnpm test:web:maintenance
build-e2e:
docker compose -f ./e2e/docker-compose.yml --profile test build
test-medium:
docker run \
--rm \

View File

@@ -71,6 +71,7 @@ const setup = async () => {
const redirectUris = [
'http://127.0.0.1:2285/auth/login',
'https://photos.immich.app/oauth/mobile-redirect',
...(process.env.EXTRA_REDIRECT_URIS?.split(',').filter(Boolean) ?? []),
];
const port = 2286;
const host = '0.0.0.0';

View File

@@ -1,6 +1,7 @@
{
"name": "@immich/e2e-auth-server",
"version": "0.1.0",
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
"type": "module",
"main": "auth-server.ts",
"scripts": {

40
e2e/Dockerfile.playwright Normal file
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

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/

16
e2e/docker-compose.ci.yml Normal file
View File

@@ -0,0 +1,16 @@
name: immich-e2e
services:
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}

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
@@ -15,8 +17,7 @@ services:
context: ../
dockerfile: server/Dockerfile
cache_from:
- type=registry,ref=ghcr.io/immich-app/immich-server-build-cache:linux-amd64-cc099f297acd18c924b35ece3245215b53d106eb2518e3af6415931d055746cd-main
- type=registry,ref=ghcr.io/immich-app/immich-server-build-cache:linux-arm64-cc099f297acd18c924b35ece3245215b53d106eb2518e3af6415931d055746cd-main
- type=registry,ref=ghcr.io/immich-app/immich-server-build-cache:${SERVER_CACHE_KEY:-linux-amd64-cc099f297acd18c924b35ece3245215b53d106eb2518e3af6415931d055746cd-main}
args:
- BUILD_ID=1234567890
- BUILD_IMAGE=e2e
@@ -65,3 +66,30 @@ 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
- ./blob-report:/app/e2e/blob-report
- /var/run/docker.sock:/var/run/docker.sock
depends_on:
immich-server:
condition: service_started
database:
condition: service_healthy
profiles:
- test

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;
@@ -19,7 +20,11 @@ const config: PlaywrightTestConfig = {
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 4 : 0,
reporter: 'html',
reporter: [
['html'],
['json', { outputFile: 'playwright-report/test-results.json' }],
...(process.env.CI ? [['blob', { outputDir: 'blob-report' }] as const] : []),
],
use: {
baseURL: playwriteBaseUrl,
trace: 'on-first-retry',
@@ -43,7 +48,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',

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';

View File

@@ -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 () => {

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',
]);

View File

@@ -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:'),

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');
});
});

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);
};

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();
},

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"]`,

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([