ci: Docker CI changes (#24962)

This commit is contained in:
Declan Carroll 2026-01-28 11:41:33 +00:00 committed by GitHub
parent e7c5c17402
commit ea83ed49a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1038 additions and 989 deletions

File diff suppressed because it is too large Load diff

View file

@ -13,15 +13,15 @@ env:
DOCKER_IMAGE: ghcr.io/n8n-io/n8n:pr-${{ github.event.pull_request.number }}-${{ github.run_id }}
jobs:
# Build Docker image once and push to GHCR for all shards to pull
# Only runs for internal PRs (not community/fork PRs)
build-docker:
name: 'Build Docker Image'
prepare:
name: 'Prepare E2E'
if: ${{ !github.event.pull_request.head.repo.fork }}
runs-on: blacksmith-4vcpu-ubuntu-2204
permissions:
packages: write
contents: read
outputs:
matrix: ${{ steps.generate-matrix.outputs.matrix }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -29,14 +29,6 @@ jobs:
ref: ${{ inputs.branch || github.ref }}
fetch-depth: 1
- name: Setup Environment
uses: ./.github/actions/setup-nodejs
with:
build-command: 'pnpm build:docker'
enable-docker-cache: true
env:
INCLUDE_TEST_CONTROLLER: 'true'
- name: Login to GHCR
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
@ -44,23 +36,26 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Tag and push to GHCR
run: |
# Push n8n image
docker tag n8nio/n8n:local ${{ env.DOCKER_IMAGE }}
docker push ${{ env.DOCKER_IMAGE }}
- name: Build and push to GHCR
uses: ./.github/actions/setup-nodejs
with:
build-command: 'pnpm build:docker'
enable-docker-cache: true
env:
INCLUDE_TEST_CONTROLLER: 'true'
IMAGE_BASE_NAME: ghcr.io/n8n-io/n8n
IMAGE_TAG: pr-${{ github.event.pull_request.number }}-${{ github.run_id }}
RUNNERS_IMAGE_BASE_NAME: ghcr.io/n8n-io/runners
# Push runners image (tests derive it from n8n image tag)
RUNNERS_IMAGE="${{ env.DOCKER_IMAGE }}"
RUNNERS_IMAGE="${RUNNERS_IMAGE/\/n8n:/\/runners:}"
docker tag n8nio/runners:local "$RUNNERS_IMAGE"
docker push "$RUNNERS_IMAGE"
- name: Generate shard matrix
id: generate-matrix
run: echo "matrix=$(node packages/testing/playwright/scripts/distribute-tests.mjs --matrix 16 --orchestrate)" >> "$GITHUB_OUTPUT"
# Multi-main: postgres + redis + caddy + 2 mains + 1 worker
# Only runs for internal PRs (not community/fork PRs)
# Pulls pre-built Docker image from GHCR
multi-main-e2e:
needs: [build-docker]
needs: [prepare]
name: 'Multi-Main: E2E'
if: ${{ !github.event.pull_request.head.repo.fork }}
uses: ./.github/workflows/test-e2e-reusable.yml
@ -69,25 +64,11 @@ jobs:
test-mode: docker-pull
docker-image: ghcr.io/n8n-io/n8n:pr-${{ github.event.pull_request.number }}-${{ github.run_id }}
test-command: pnpm --filter=n8n-playwright test:container:multi-main:e2e
shards: 15
shards: 16
runner: blacksmith-2vcpu-ubuntu-2204
workers: '1'
use-custom-orchestration: true
secrets: inherit
multi-main-isolated:
needs: [build-docker]
name: 'Multi-Main: Isolated'
if: ${{ !github.event.pull_request.head.repo.fork }}
uses: ./.github/workflows/test-e2e-reusable.yml
with:
branch: ${{ inputs.branch }}
test-mode: docker-pull
docker-image: ghcr.io/n8n-io/n8n:pr-${{ github.event.pull_request.number }}-${{ github.run_id }}
test-command: pnpm --filter=n8n-playwright test:container:multi-main:isolated
shards: 1
runner: blacksmith-2vcpu-ubuntu-2204
workers: '1'
pre-generated-matrix: ${{ needs.prepare.outputs.matrix }}
secrets: inherit
# Community PR tests: Local mode with SQLite (no container building, no secrets required)
@ -105,23 +86,10 @@ jobs:
workers: '1'
upload-failure-artifacts: true
community-isolated:
name: 'Community: Isolated'
if: ${{ github.event.pull_request.head.repo.fork }}
uses: ./.github/workflows/test-e2e-reusable.yml
with:
branch: ${{ inputs.branch }}
test-mode: local
test-command: pnpm --filter=n8n-playwright test:local:isolated
shards: 1
runner: ubuntu-latest
workers: '1'
upload-failure-artifacts: true
# Cleanup ephemeral Docker image from GHCR after tests complete
cleanup-docker:
name: 'Cleanup Docker Image'
needs: [multi-main-e2e, multi-main-isolated]
needs: [prepare, multi-main-e2e]
if: ${{ !failure() && !cancelled() && !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-slim
permissions:

View file

@ -52,6 +52,11 @@ on:
required: false
default: 'LRxcNt'
type: string
pre-generated-matrix:
description: 'Pre-generated shard matrix JSON (skips matrix job if provided)'
required: false
default: ''
type: string
secrets:
CURRENTS_RECORD_KEY:
@ -87,6 +92,7 @@ env:
jobs:
matrix:
if: ${{ inputs.pre-generated-matrix == '' }}
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-2vcpu-ubuntu-2204' }}
outputs:
matrix: ${{ steps.generate.outputs.matrix }}
@ -107,12 +113,13 @@ jobs:
test:
needs: matrix
if: ${{ !cancelled() }}
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || inputs.runner }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.matrix.outputs.matrix) }}
include: ${{ fromJSON(inputs.pre-generated-matrix || needs.matrix.outputs.matrix) }}
name: Shard ${{ matrix.shard }}
steps:

View file

@ -81,7 +81,8 @@ await member2Page.navigate.toCredentials();
| Pattern | Why | Use Instead |
|---------|-----|-------------|
| `test.describe.serial` | Creates test dependencies | Parallel tests with isolated setup |
| `@db:reset` tag | Deprecated - CI issues | `test.use()` with unique capability |
| Fresh DB per file | Tests need isolated container | `test.use({ capability: { env: { TEST_ISOLATION: 'name' } } })` |
| Fresh DB per test | Tests modify shared state | `@db:reset` tag on describe (container-only, combined with `test.use()`) |
| `n8n.api.signin()` | Session bleeding | `n8n.start.withUser()` |
| `Date.now()` for IDs | Race conditions | `nanoid()` |
| `waitForTimeout()` | Flaky | `waitForResponse()`, `toBeVisible()` |
@ -182,13 +183,23 @@ Use `test.use()` at file top-level with unique capability config:
import { test, expect } from '../fixtures/base';
// Must be top-level, not inside describe block
test.use({ capability: { env: { _ISOLATION: 'my-isolated-tests' } } });
test.use({ capability: { env: { TEST_ISOLATION: 'my-isolated-tests' } } });
test('test with clean state', async ({ n8n }) => {
// Fresh container with reset database
});
```
For per-test database reset (when tests modify shared state like MFA), add `@db:reset` to the describe. **Note:** `@db:reset` is container-only - these tests won't run locally.
```typescript
test.use({ capability: { env: { TEST_ISOLATION: 'my-stateful-tests' } } });
test.describe('My stateful tests @db:reset', () => {
// Each test gets a fresh database reset (container-only)
});
```
## Data Setup
Use API helpers for fast, reliable test data setup. Reserve UI interactions for testing UI behavior:

View file

@ -66,24 +66,53 @@ test('enterprise feature @licensed', ...) // Requires enterprise licen
| `@licensed` | Enterprise license features | Tests for features behind license flags at startup |
| `@cloud:X` | Resource constraints (trial, enterprise) | Performance tests with memory/CPU limits |
| `@chaostest` | Chaos engineering tests | Tests that intentionally break things |
| `@db:reset` | ⚠️ Deprecated - use `test.use()` pattern | Legacy isolation pattern |
| `@auth:X` | Authentication role (owner, admin, member, none) | Tests requiring specific user role |
| `@db:reset` | Reset database before each test (container-only) | Tests that need fresh DB state per test (e.g., MFA tests) |
### Worker Isolation (Fresh Database)
If tests need a clean database state, use `test.use()` at the top level of the file with a unique capability config instead of the deprecated `@db:reset` tag:
Tests that need their own isolated database should use `test.use()` with a unique capability config. This gives the test file its own container with a fresh database:
```typescript
// my-isolated-tests.spec.ts
import { test, expect } from '../fixtures/base';
// Must be top-level, not inside describe block
test.use({ capability: { env: { _ISOLATION: 'my-isolated-tests' } } });
// Unique value breaks worker cache → fresh container with clean DB
test.use({ capability: { env: { TEST_ISOLATION: 'my-test-name' } } });
test('test with clean state', async ({ n8n }) => {
// Fresh container with reset database
test.describe('My isolated tests', () => {
test.describe.configure({ mode: 'serial' }); // If tests depend on each other's data
test('test with clean state', async ({ n8n }) => {
// Fresh container with reset database
});
});
```
> **Deprecated:** `@db:reset` tag causes CI issues (separate workers, sequential execution). Use `test.use()` pattern above instead.
**How it works:** The `capability` option is scoped to the worker level. When you pass a unique value via `test.use()`, Playwright creates a new worker with a fresh container. Each container starts with a clean database automatically.
### Per-Test Database Reset (@db:reset)
If tests within the same file need a fresh database before **each test** (not just the file), add `@db:reset` to the describe block. **Note:** This tag is container-only - tests with `@db:reset` won't run in local mode.
```typescript
// my-stateful-tests.spec.ts
import { test, expect } from '../fixtures/base';
test.use({ capability: { env: { TEST_ISOLATION: 'my-stateful-tests' } } });
test.describe('My stateful tests @db:reset', () => {
test('test 1', async ({ n8n }) => {
// Fresh database (reset before this test)
});
test('test 2', async ({ n8n }) => {
// Fresh database again (reset before this test too)
});
});
```
**When to use `@db:reset`:** When tests modify shared state that would break subsequent tests (e.g., enabling MFA, creating users, changing settings). Since resetting the database would affect all parallel tests in local mode, these tests are excluded from local runs and only execute in container mode where each worker has its own isolated database.
### Enterprise Features (@licensed)
Use the `@licensed` tag for tests that require enterprise features which are **only available when the license is present at startup**. This differs from features that can be enabled/disabled at runtime.

View file

@ -2,24 +2,19 @@
"name": "n8n-playwright",
"private": true,
"scripts": {
"dev": "N8N_BASE_URL=http://localhost:5678 N8N_EDITOR_URL=http://localhost:8080 RESET_E2E_DB=true playwright test --project=e2e --project=e2e:isolated",
"dev": "N8N_BASE_URL=http://localhost:5678 N8N_EDITOR_URL=http://localhost:8080 RESET_E2E_DB=true playwright test --project=e2e",
"test:all": "playwright test",
"test:local": "N8N_BASE_URL=http://localhost:5680 RESET_E2E_DB=true playwright test --project=e2e --project=e2e:isolated",
"test:local:e2e-only": "N8N_BASE_URL=http://localhost:5680 RESET_E2E_DB=true playwright test --project=e2e",
"test:local:isolated": "N8N_BASE_URL=http://localhost:5680 RESET_E2E_DB=true playwright test --project=e2e:isolated",
"test:local": "N8N_BASE_URL=http://localhost:5680 RESET_E2E_DB=true playwright test --project=e2e",
"test:e2e": "playwright test --project=*e2e*",
"test:performance": "playwright test --project=performance",
"test:infrastructure": "playwright test --project='*:infrastructure'",
"test:container:sqlite": "playwright test --project='sqlite:*'",
"test:container:sqlite:e2e": "playwright test --project='sqlite:e2e'",
"test:container:sqlite:isolated": "playwright test --project='sqlite:e2e:isolated'",
"test:container:postgres": "playwright test --project='postgres:*'",
"test:container:queue": "playwright test --project='queue:*'",
"test:container:queue:e2e-only": "playwright test --project='queue:e2e'",
"test:container:queue:isolated": "playwright test --project='queue:e2e:isolated'",
"test:container:queue:e2e": "playwright test --project='queue:e2e'",
"test:container:multi-main": "playwright test --project='multi-main:*'",
"test:container:multi-main:e2e": "playwright test --project='multi-main:e2e'",
"test:container:multi-main:isolated": "playwright test --project='multi-main:e2e:isolated'",
"test:container:trial": "playwright test --project='trial:*'",
"test:workflows:setup": "tsx ./tests/cli-workflows/setup-workflow-tests.ts",
"test:workflows": "playwright test --project=cli-workflows",

View file

@ -13,19 +13,11 @@ import { getBackendUrl, getFrontendUrl } from './utils/url-helper';
// - @capability:X - add-on features (email, proxy, source-control, etc.)
// - @mode:X - infrastructure modes (postgres, queue, multi-main)
// - @licensed - enterprise license features (log streaming, SSO, etc.)
// - @db:reset - tests needing per-test database reset (requires isolated containers)
const CONTAINER_ONLY = new RegExp(
`@capability:(${CONTAINER_ONLY_CAPABILITIES.join('|')})|@mode:(${CONTAINER_ONLY_MODES.join('|')})|@${LICENSED_TAG}`,
`@capability:(${CONTAINER_ONLY_CAPABILITIES.join('|')})|@mode:(${CONTAINER_ONLY_MODES.join('|')})|@${LICENSED_TAG}|@db:reset`,
);
// Tags that need serial execution
// These tests will be run AFTER the first run of the E2E tests
// In local run they are a "dependency" which means they will be skipped if earlier tests fail, not ideal but needed for isolation
const SERIAL_EXECUTION = /@db:reset/;
// Routes tests to isolated worker without triggering automatic database resets in fixtures
// Use when tests need worker isolation but have intentional state dependencies (e.g., serial tests sharing data)
const ISOLATED_ONLY = /@isolated/;
const CONTAINER_CONFIGS: Array<{ name: string; config: N8NConfig }> = [
{ name: 'sqlite', config: {} },
{ name: 'postgres', config: { postgres: true } },
@ -41,43 +33,23 @@ export function getProjects(): Project[] {
const projects: Project[] = [];
if (isLocal) {
projects.push(
{
name: 'e2e',
testDir: './tests/e2e',
grepInvert: new RegExp(
[CONTAINER_ONLY.source, SERIAL_EXECUTION.source, ISOLATED_ONLY.source].join('|'),
),
fullyParallel: true,
use: { baseURL: getFrontendUrl() },
},
{
name: 'e2e:isolated',
testDir: './tests/e2e',
grep: new RegExp([SERIAL_EXECUTION.source, ISOLATED_ONLY.source].join('|')),
workers: 1,
use: { baseURL: getFrontendUrl() },
},
);
projects.push({
name: 'e2e',
testDir: './tests/e2e',
grepInvert: CONTAINER_ONLY,
fullyParallel: true,
use: { baseURL: getFrontendUrl() },
});
} else {
for (const { name, config } of CONTAINER_CONFIGS) {
const grepInvertPatterns = [SERIAL_EXECUTION.source, ISOLATED_ONLY.source];
projects.push(
{
name: `${name}:e2e`,
testDir: './tests/e2e',
grepInvert: new RegExp(grepInvertPatterns.join('|')),
timeout: name === 'sqlite' ? 60000 : 180000, // 60 seconds for sqlite container test, 180 for other modes to allow startup
fullyParallel: true,
use: { containerConfig: config },
},
{
name: `${name}:e2e:isolated`,
testDir: './tests/e2e',
grep: new RegExp([SERIAL_EXECUTION.source, ISOLATED_ONLY.source].join('|')),
workers: 1,
use: { containerConfig: config },
},
{
name: `${name}:infrastructure`,
testDir: './tests/infrastructure',

View file

@ -90,7 +90,7 @@ If you see "No coverage files found":
2. Run tests with coverage enabled: `BUILD_WITH_COVERAGE=true pnpm test:container:sqlite`
3. Check that coverage files exist in `.nyc_output/{projectName}/` directories
- For local mode: `.nyc_output/e2e/`
- For container mode: `.nyc_output/sqlite:e2e/`, `.nyc_output/sqlite:e2e:isolated/`, etc.
- For container mode: `.nyc_output/sqlite:e2e/`, `.nyc_output/postgres:e2e/`, etc.
### Low Coverage Percentage
@ -162,7 +162,7 @@ packages/testing/playwright/
├── .nyc_output/ # Raw coverage data (per project)
│ ├── e2e/ # Local mode coverage
│ ├── sqlite:e2e/ # Container mode coverage
│ ├── sqlite:e2e:isolated/
│ ├── postgres:e2e/ # Other container modes
│ └── out.json # Merged coverage data
├── nyc.config.ts # NYC configuration
└── scripts/

View file

@ -30,7 +30,7 @@ const METRICS_PATH = path.join(ROOT_DIR, '.github/test-metrics/playwright.json')
const PLAYWRIGHT_DIR = path.resolve(__dirname, '..');
const DEFAULT_DURATION = 60000; // 1 minute default (accounts for container startup)
const E2E_PROJECT = 'multi-main:e2e';
const CONTAINER_STARTUP_TIME = 20000; // 20 seconds per container type
const CONTAINER_STARTUP_TIME = 22500; // 22.5 seconds average (heavier stacks with extra services take longer)
const MAX_GROUP_DURATION = 5 * 60 * 1000; // 5 minutes - split groups larger than this
const args = process.argv.slice(2);
@ -128,26 +128,23 @@ function distributeCapabilityAware(numShards) {
}
}
// Calculate effective durations for capability groups
// When grouped, only first test pays container startup - rest reuse worker
// Group capability specs together - container startup is per-shard overhead, not per-spec
// Currents avgDuration = actual test time, container startup is separate
// If a group exceeds MAX_GROUP_DURATION, split it into smaller sub-groups
/** @type {Array<{type: string, capability: string, specs: string[], reportedDuration: number, effectiveDuration: number, startupSavings: number, subGroup?: number}>} */
/** @type {Array<{type: string, capability: string, specs: string[], duration: number, subGroup?: number}>} */
const capabilityItems = [];
for (const [capability, specs] of capabilityGroups.entries()) {
// Sort specs by duration (largest first) for better splitting
specs.sort((a, b) => b.duration - a.duration);
const reportedDuration = specs.reduce((sum, s) => sum + s.duration, 0);
// Calculate effective duration: each spec's actual time (minus startup) + one startup
const actualTestTime = reportedDuration - specs.length * CONTAINER_STARTUP_TIME;
const effectiveDuration = actualTestTime + CONTAINER_STARTUP_TIME;
const totalDuration = specs.reduce((sum, s) => sum + s.duration, 0);
// Check if we need to split this group
if (effectiveDuration > MAX_GROUP_DURATION && specs.length > 1) {
if (totalDuration > MAX_GROUP_DURATION && specs.length > 1) {
// Calculate how many sub-groups we need
const numSubGroups = Math.ceil(effectiveDuration / MAX_GROUP_DURATION);
const targetPerSubGroup = effectiveDuration / numSubGroups;
const numSubGroups = Math.ceil(totalDuration / MAX_GROUP_DURATION);
const targetPerSubGroup = totalDuration / numSubGroups;
// Greedy split: fill each sub-group up to target
let currentSubGroup = 0;
@ -156,33 +153,27 @@ function distributeCapabilityAware(numShards) {
const subGroups = [[]];
for (const spec of specs) {
const specActualTime = spec.duration - CONTAINER_STARTUP_TIME;
// Start new sub-group if current is full (unless it's empty)
if (currentTotal + specActualTime > targetPerSubGroup && subGroups[currentSubGroup].length > 0) {
if (currentTotal + spec.duration > targetPerSubGroup && subGroups[currentSubGroup].length > 0) {
currentSubGroup++;
subGroups[currentSubGroup] = [];
currentTotal = 0;
}
subGroups[currentSubGroup].push(spec);
currentTotal += specActualTime;
currentTotal += spec.duration;
}
// Create items for each sub-group
for (let i = 0; i < subGroups.length; i++) {
const subGroupSpecs = subGroups[i];
const subReported = subGroupSpecs.reduce((sum, s) => sum + s.duration, 0);
const subActual = subReported - subGroupSpecs.length * CONTAINER_STARTUP_TIME;
const subEffective = subActual + CONTAINER_STARTUP_TIME; // Each sub-group pays one startup
const subDuration = subGroupSpecs.reduce((sum, s) => sum + s.duration, 0);
capabilityItems.push({
type: 'capability',
capability,
specs: subGroupSpecs.map((s) => s.path),
reportedDuration: subReported,
effectiveDuration: subEffective,
startupSavings: (subGroupSpecs.length - 1) * CONTAINER_STARTUP_TIME,
duration: subDuration,
subGroup: i + 1,
});
}
@ -192,89 +183,96 @@ function distributeCapabilityAware(numShards) {
type: 'capability',
capability,
specs: specs.map((s) => s.path),
reportedDuration,
effectiveDuration,
startupSavings: (specs.length - 1) * CONTAINER_STARTUP_TIME,
duration: totalDuration,
});
}
}
// Standard specs keep their reported duration (no grouping benefit)
// Standard specs - each is its own item
const standardItems = standardSpecs.map((spec) => ({
type: 'standard',
capability: null,
specs: [spec.path],
reportedDuration: spec.duration,
effectiveDuration: spec.duration,
startupSavings: 0,
duration: spec.duration,
}));
// Combine and sort by effective duration (largest first for greedy packing)
const allItems = [...capabilityItems, ...standardItems].sort(
(a, b) => b.effectiveDuration - a.effectiveDuration,
);
// Combine and sort by duration (largest first for greedy packing)
const allItems = [...capabilityItems, ...standardItems].sort((a, b) => b.duration - a.duration);
// Calculate totals for reporting
const totalEffective = allItems.reduce((sum, item) => sum + item.effectiveDuration, 0);
const totalReported = allItems.reduce((sum, item) => sum + item.reportedDuration, 0);
const totalSavings = capabilityItems.reduce((sum, item) => sum + item.startupSavings, 0);
const targetPerShard = totalEffective / numShards;
const totalTestTime = allItems.reduce((sum, item) => sum + item.duration, 0);
const targetPerShard = totalTestTime / numShards;
console.error('\n📦 Capability-Aware Distribution:');
console.error(` Reported total: ${(totalReported / 60000).toFixed(1)} min`);
console.error(` Effective total: ${(totalEffective / 60000).toFixed(1)} min (after grouping)`);
console.error(` Worker reuse savings: ${(totalSavings / 1000).toFixed(0)}s`);
console.error(` Total test time: ${(totalTestTime / 60000).toFixed(1)} min`);
console.error(` Target per shard: ${(targetPerShard / 60000).toFixed(1)} min\n`);
// Report capability groups
console.error(' Capability groups (treated as atomic units):');
console.error(' Capability groups:');
for (const item of capabilityItems) {
const reported = (item.reportedDuration / 60000).toFixed(1);
const effective = (item.effectiveDuration / 60000).toFixed(1);
const saved = (item.startupSavings / 1000).toFixed(0);
console.error(` ${item.capability}: ${item.specs.length} specs, ${reported} min → ${effective} min (saved ${saved}s)`);
const mins = (item.duration / 60000).toFixed(1);
const subGroupLabel = item.subGroup ? ` (part ${item.subGroup})` : '';
console.error(` ${item.capability}${subGroupLabel}: ${item.specs.length} specs, ${mins} min`);
}
console.error(` Standard specs: ${standardItems.length} specs\n`);
// Initialize buckets
/** @type {Array<{specs: string[]; total: number; capabilities: Set<string>}>} */
/** @type {Array<{specs: string[]; testTime: number; capabilities: Set<string>; hasStandardSpecs: boolean}>} */
const buckets = Array.from({ length: numShards }, () => ({
specs: [],
total: 0,
testTime: 0,
capabilities: new Set(),
hasStandardSpecs: false,
}));
// Greedy bin-packing: assign each item to the lightest bucket
for (const item of allItems) {
const lightest = buckets.reduce((min, b) => (b.total < min.total ? b : min));
const lightest = buckets.reduce((min, b) => (b.testTime < min.testTime ? b : min));
// Add all specs from this item to the bucket
for (const specPath of item.specs) {
lightest.specs.push(specPath);
}
lightest.total += item.effectiveDuration;
lightest.testTime += item.duration;
if (item.capability) {
lightest.capabilities.add(item.capability);
} else {
lightest.hasStandardSpecs = true;
}
}
// Report container optimization
const simpleContainers = capabilityItems.reduce((sum, item) => {
// Estimate: with simple distribution, specs spread across ~70% of shards
// Calculate container starts per shard: unique capabilities + 1 for default if has standard specs
const containerStartsPerShard = buckets.map((b) => b.capabilities.size + (b.hasStandardSpecs ? 1 : 0));
const totalContainerStarts = containerStartsPerShard.reduce((sum, c) => sum + c, 0);
const totalContainerOverhead = totalContainerStarts * CONTAINER_STARTUP_TIME;
// Estimate simple distribution: capabilities spread across ~70% of shards + default container per shard
const simpleCapabilityContainers = capabilityItems.reduce((sum, item) => {
return sum + Math.min(numShards, Math.ceil(item.specs.length * 0.7));
}, 0);
const simpleDefaultContainers = numShards; // Every shard would have standard specs
const simpleContainers = simpleCapabilityContainers + simpleDefaultContainers;
const containersSaved = simpleContainers - totalContainerStarts;
const optimizedContainers = capabilityItems.reduce((sum, item) => {
// Count actual shards with this capability
return sum + buckets.filter((b) => b.capabilities.has(item.capability)).length;
}, 0);
console.error(' Container optimization:');
console.error(' Container starts per shard:');
for (let i = 0; i < buckets.length; i++) {
const caps = buckets[i].capabilities.size;
const def = buckets[i].hasStandardSpecs ? 1 : 0;
const containerCount = caps + def;
const overhead = containerCount * CONTAINER_STARTUP_TIME;
const totalTime = buckets[i].testTime + overhead;
const details = [
...[...buckets[i].capabilities],
...(buckets[i].hasStandardSpecs ? ['default'] : []),
].join(', ');
console.error(` Shard ${i + 1}: ${containerCount} containers (${details}) → +${(overhead / 1000).toFixed(0)}s overhead`);
}
console.error(`\n Container optimization:`);
console.error(` Simple distribution: ~${simpleContainers} container starts`);
console.error(` Capability-aware: ${optimizedContainers} container starts`);
console.error(` Containers saved: ~${simpleContainers - optimizedContainers}`);
console.error(` Total time saved: ~${((totalSavings + (simpleContainers - optimizedContainers) * CONTAINER_STARTUP_TIME) / 1000).toFixed(0)}s\n`);
console.error(` Capability-aware: ${totalContainerStarts} container starts`);
console.error(` Containers saved: ~${containersSaved}`);
console.error(` Time saved: ~${((containersSaved * CONTAINER_STARTUP_TIME) / 1000).toFixed(0)}s\n`);
return buckets;
}
@ -296,13 +294,20 @@ if (matrixMode) {
if (orchestrate && buckets) {
console.error('\n📊 Shard Distribution:');
let maxShardTime = 0;
for (let i = 0; i < buckets.length; i++) {
const mins = (buckets[i].total / 60000).toFixed(1);
const containerCount = buckets[i].capabilities.size + (buckets[i].hasStandardSpecs ? 1 : 0);
const overhead = containerCount * CONTAINER_STARTUP_TIME;
const totalTime = buckets[i].testTime + overhead;
maxShardTime = Math.max(maxShardTime, totalTime);
const testMins = (buckets[i].testTime / 60000).toFixed(1);
const totalMins = (totalTime / 60000).toFixed(1);
const caps = buckets[i].capabilities.size > 0 ? ` [${[...buckets[i].capabilities].join(', ')}]` : '';
console.error(` Shard ${i + 1}: ${buckets[i].specs.length} specs, ~${mins} min${caps}`);
console.error(` Shard ${i + 1}: ${buckets[i].specs.length} specs, ${testMins} min test + ${(overhead / 1000).toFixed(0)}s startup = ${totalMins} min${caps}`);
}
const totalMins = (buckets.reduce((sum, b) => sum + b.total, 0) / 60000).toFixed(1);
console.error(` Total: ${totalMins} min across ${shards} shards\n`);
const totalTestMins = (buckets.reduce((sum, b) => sum + b.testTime, 0) / 60000).toFixed(1);
console.error(`\n Total test time: ${totalTestMins} min`);
console.error(` Expected wall-clock: ~${(maxShardTime / 60000).toFixed(1)} min (longest shard)\n`);
}
console.log(JSON.stringify(matrix));

View file

@ -28,16 +28,16 @@ if (!PROJECT_ID) {
process.exit(1);
}
async function fetchAllTests(apiKey) {
const tests = [];
async function fetchSpecPerformance(apiKey) {
const specs = [];
let page = 0;
let hasMore = true;
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
while (hasMore && page < 200) {
const url = new URL(`${CURRENTS_API}/tests/${PROJECT_ID}`);
while (hasMore && page < 100) {
const url = new URL(`${CURRENTS_API}/spec-files/${PROJECT_ID}`);
url.searchParams.set('limit', '50');
url.searchParams.set('page', page.toString());
url.searchParams.set('date_start', sevenDaysAgo.toISOString());
@ -50,35 +50,16 @@ async function fetchAllTests(apiKey) {
if (!res.ok) throw new Error(`API error: ${res.status}`);
const data = await res.json();
tests.push(...data.data.list);
// Filter to e2e specs only
const e2eSpecs = data.data.list.filter((s) => s.spec.startsWith('tests/e2e/'));
specs.push(...e2eSpecs);
hasMore = data.data.nextPage !== false;
page++;
process.stdout.write(`\rFetched ${tests.length} tests...`);
process.stdout.write(`\rFetched ${specs.length} e2e specs...`);
}
console.log();
return tests;
}
function aggregateBySpec(tests) {
const specs = {};
for (const test of tests) {
// Only include e2e tests
if (!test.spec.startsWith('tests/e2e/')) continue;
if (!specs[test.spec]) {
specs[test.spec] = { totalDuration: 0, testCount: 0, totalFlaky: 0, executions: 0 };
}
const s = specs[test.spec];
s.totalDuration += test.metrics.avgDurationMs;
s.testCount++;
s.totalFlaky += test.metrics.flaky;
s.executions += test.metrics.executions;
}
return specs;
}
@ -107,8 +88,7 @@ async function main() {
}
const validSpecs = getPlaywrightSpecs();
const tests = await fetchAllTests(apiKey);
const aggregated = aggregateBySpec(tests);
const specMetrics = await fetchSpecPerformance(apiKey);
const output = {
updatedAt: new Date().toISOString(),
@ -118,17 +98,17 @@ async function main() {
};
const staleSpecs = [];
for (const [spec, data] of Object.entries(aggregated)) {
for (const item of specMetrics) {
// Skip specs not in Playwright (deleted/isolated)
if (validSpecs && !validSpecs.has(spec)) {
staleSpecs.push(spec);
if (validSpecs && !validSpecs.has(item.spec)) {
staleSpecs.push(item.spec);
continue;
}
const duration = data.totalDuration < 1000 ? DEFAULT_DURATION : Math.round(data.totalDuration);
output.specs[spec] = {
const duration = item.metrics.avgDuration < 1000 ? DEFAULT_DURATION : Math.round(item.metrics.avgDuration);
output.specs[item.spec] = {
avgDuration: duration,
testCount: data.testCount,
flakyRate: data.executions > 0 ? Math.round((data.totalFlaky / data.executions) * 10000) / 10000 : 0,
testCount: item.metrics.suiteSize,
flakyRate: Math.round(item.metrics.flakeRate * 10000) / 10000,
};
}

View file

@ -17,13 +17,9 @@ const NYC_CONFIG = path.join(__dirname, '..', 'nyc.config.ts');
const COVERAGE_PROJECT_PATTERNS = [
'e2e', // Local mode project
'sqlite:e2e', // Container mode projects
'sqlite:e2e:isolated',
'postgres:e2e',
'postgres:e2e:isolated',
'queue:e2e',
'queue:e2e:isolated',
'multi-main:e2e',
'multi-main:e2e:isolated',
];
/**

View file

@ -73,14 +73,14 @@ export class ApiHelpers {
// ===== MAIN SETUP METHODS =====
/**
* Setup test environment based on test tags (recommended approach)
* Setup test environment based on test tags
* @param tags - Array of test tags (e.g., ['@db:reset', '@auth:owner'])
* @param memberIndex - Which member to use (if auth role is 'member')
*
* Examples:
* - ['@db:reset'] = reset DB, manual signin required
* - ['@db:reset', '@auth:owner'] = reset DB + signin as owner
* - ['@auth:admin'] = signin as admin (no reset)
* - ['@auth:none'] = no signin (unauthenticated)
*/
async setupFromTags(tags: string[], memberIndex: number = 0): Promise<LoginResponseData | null> {
const shouldReset = this.shouldResetDatabase(tags);
@ -103,6 +103,14 @@ export class ApiHelpers {
return null;
}
/**
* Check if database should be reset based on tags
*/
private shouldResetDatabase(tags: string[]): boolean {
const lowerTags = tags.map((tag) => tag.toLowerCase());
return lowerTags.includes(DB_TAGS.RESET.toLowerCase());
}
/**
* Setup test environment based on desired state (programmatic approach)
* @param state - 'fresh': new container, 'reset': reset DB + signin, 'signin-only': just signin
@ -464,14 +472,9 @@ export class ApiHelpers {
// ===== TAG PARSING METHODS =====
private shouldResetDatabase(tags: string[]): boolean {
const lowerTags = tags.map((tag) => tag.toLowerCase());
return lowerTags.includes(DB_TAGS.RESET.toLowerCase());
}
/**
* Get the role from the tags
* @param tags - Array of test tags (e.g., ['@db:reset', '@auth:owner'])
* @param tags - Array of test tags (e.g., ['@auth:owner'])
* @returns The role from the tags, or 'owner' if no role is found
*/
getRoleFromTags(tags: string[]): UserRole | null {

View file

@ -75,7 +75,7 @@ test.describe('Settings @capability:proxy', () => {
// Anthropic: save settings
await page.providerModal.getConfirmButton().click();
await expect(page.providerModal.getRoot()).not.toBeVisible();
await expect(page.providerModal.getRoot()).toBeHidden();
// Open OpenAI settings
await page.getProviderActionToggle('OpenAI').click();
@ -85,7 +85,7 @@ test.describe('Settings @capability:proxy', () => {
await expect(page.providerModal.getEnabledToggle()).toBeChecked();
await page.providerModal.getEnabledToggle().click();
await page.providerModal.getConfirmButton().click();
await expect(page.providerModal.getRoot()).not.toBeVisible();
await expect(page.providerModal.getRoot()).toBeHidden();
await n8n.page.close();
@ -106,7 +106,7 @@ test.describe('Settings @capability:proxy', () => {
await chatPage.getModelSelectorButton().click();
await expect(chatPage.getVisiblePopoverMenuItem('Anthropic')).toBeVisible();
await expect(chatPage.getVisiblePopoverMenuItem('OpenAI')).not.toBeVisible();
await expect(chatPage.getVisiblePopoverMenuItem('OpenAI')).toBeHidden();
await chatPage.getVisiblePopoverMenuItem('Anthropic').hover({ force: true });
const anthropicModels = chatPage.getVisiblePopoverMenuItem(/^Claude/);

View file

@ -21,7 +21,7 @@ test.describe('Tools usage @capability:proxy', () => {
await page.toolsModal.getToolSwitch('Jina AI', 'Web Search').click();
await page.toolsModal.getConfirmButton().click();
await expect(page.toolsModal.getRoot()).not.toBeVisible();
await expect(page.toolsModal.getRoot()).toBeHidden();
await expect(page.getToolsButton()).toHaveText('1 Tool');
await page.getChatInput().fill('What is n8n?');
await page.getSendButton().click();

View file

@ -3,6 +3,8 @@ import basePlanData from '../../../fixtures/plan-data-trial.json';
import type { n8nPage } from '../../../pages/n8nPage';
import type { TestRequirements } from '../../../Types';
test.use({ capability: { env: { TEST_ISOLATION: 'cloud' } } });
const fiveDaysFromNow = new Date(Date.now() + 5 * 24 * 60 * 60 * 1000);
const planData = { ...basePlanData, expirationDate: fiveDaysFromNow.toJSON() };

View file

@ -1,10 +1,11 @@
import { test, expect } from '../../../fixtures/base';
test.describe('Global credentials @isolated', () => {
test.use({ capability: { env: { TEST_ISOLATION: 'global-credentials' } } });
test.describe('Global credentials', () => {
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ api }) => {
await api.resetDatabase();
await api.enableFeature('sharing');
});
@ -140,7 +141,7 @@ test.describe('Global credentials @isolated', () => {
n8n.credentials.cards
.getCredential('Global HTTP Header Cred')
.getByTestId('credential-global-badge'),
).not.toBeVisible();
).toBeHidden();
});
test('member should not see credential after global sharing removed', async ({ n8n }) => {
@ -150,6 +151,6 @@ test.describe('Global credentials @isolated', () => {
await n8n.navigate.toCredentials();
// Verify credential is no longer visible to member
await expect(n8n.credentials.cards.getCredential('Global HTTP Header Cred')).not.toBeVisible();
await expect(n8n.credentials.cards.getCredential('Global HTTP Header Cred')).toBeHidden();
});
});

View file

@ -0,0 +1,147 @@
import {
INSTANCE_ADMIN_CREDENTIALS,
INSTANCE_MEMBER_CREDENTIALS,
INSTANCE_OWNER_CREDENTIALS,
} from '../../../config/test-users';
import { test, expect } from '../../../fixtures/base';
test.use({ capability: { env: { TEST_ISOLATION: 'projects-move-resources' } } });
test.describe('Projects - Moving Resources @db:reset', () => {
test.describe.configure({ mode: 'serial' });
test.beforeEach(async ({ n8n }) => {
await n8n.goHome();
// Enable features required for project workflows and moving resources
await n8n.api.enableFeature('sharing');
await n8n.api.enableFeature('folders');
await n8n.api.enableFeature('advancedPermissions');
await n8n.api.enableFeature('projectRole:admin');
await n8n.api.enableFeature('projectRole:editor');
await n8n.api.setMaxTeamProjectsQuota(-1);
// Create workflow + credential in Home/Personal project
await n8n.api.workflows.createWorkflow({
name: 'Workflow in Home project',
nodes: [],
connections: {},
active: false,
});
await n8n.api.credentials.createCredential({
name: 'Credential in Home project',
type: 'notionApi',
data: { apiKey: '1234567890' },
});
// Create Project 1 with resources
const project1 = await n8n.api.projects.createProject('Project 1');
await n8n.api.workflows.createInProject(project1.id, {
name: 'Workflow in Project 1',
});
await n8n.api.credentials.createCredential({
name: 'Credential in Project 1',
type: 'notionApi',
data: { apiKey: '1234567890' },
projectId: project1.id,
});
// Create Project 2 with resources
const project2 = await n8n.api.projects.createProject('Project 2');
await n8n.api.workflows.createInProject(project2.id, {
name: 'Workflow in Project 2',
});
await n8n.api.credentials.createCredential({
name: 'Credential in Project 2',
type: 'notionApi',
data: { apiKey: '1234567890' },
projectId: project2.id,
});
// Navigate to home to load sidebar with new projects
await n8n.goHome();
});
test('should move the workflow to expected projects @auth:owner', async ({ n8n }) => {
// Move workflow from Personal to Project 2
await n8n.sideBar.clickPersonalMenuItem();
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(1);
await n8n.workflowComposer.moveToProject('Workflow in Home project', 'Project 2');
// Verify Personal has 0 workflows
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(0);
// Move workflow from Project 1 to Project 2
await n8n.sideBar.clickProjectMenuItem('Project 1');
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(1);
await n8n.workflowComposer.moveToProject('Workflow in Project 1', 'Project 2');
// Move workflow from Project 2 to member user
await n8n.sideBar.clickProjectMenuItem('Project 2');
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(3);
await n8n.workflowComposer.moveToProject(
'Workflow in Home project',
INSTANCE_MEMBER_CREDENTIALS[0].email,
null,
);
// Verify Project 2 has 2 workflows remaining
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(2);
});
test('should move the credential to expected projects @auth:owner', async ({ n8n }) => {
// Move credential from Project 1 to Project 2
await n8n.sideBar.clickProjectMenuItem('Project 1');
await n8n.sideBar.clickCredentialsLink();
await expect(n8n.credentials.cards.getCredentials()).toHaveCount(1);
const credentialCard1 = n8n.credentials.cards.getCredential('Credential in Project 1');
await n8n.credentials.cards.openCardActions(credentialCard1);
await n8n.credentials.cards.getCardAction('move').click();
await expect(n8n.resourceMoveModal.getMoveCredentialButton()).toBeDisabled();
await n8n.resourceMoveModal.getProjectSelectCredential().locator('input').click();
await expect(n8n.page.getByRole('option')).toHaveCount(6);
await n8n.resourceMoveModal.selectProjectOption('Project 2');
await n8n.resourceMoveModal.clickMoveCredentialButton();
await expect(n8n.credentials.cards.getCredentials()).toHaveCount(0);
// Move credential from Project 2 to admin user
await n8n.sideBar.clickProjectMenuItem('Project 2');
await n8n.sideBar.clickCredentialsLink();
await expect(n8n.credentials.cards.getCredentials()).toHaveCount(2);
const credentialCard2 = n8n.credentials.cards.getCredential('Credential in Project 1');
await n8n.credentials.cards.openCardActions(credentialCard2);
await n8n.credentials.cards.getCardAction('move').click();
await expect(n8n.resourceMoveModal.getMoveCredentialButton()).toBeDisabled();
await n8n.resourceMoveModal.getProjectSelectCredential().locator('input').click();
await expect(n8n.page.getByRole('option')).toHaveCount(6);
await n8n.resourceMoveModal.selectProjectOption(INSTANCE_ADMIN_CREDENTIALS.email);
await n8n.resourceMoveModal.clickMoveCredentialButton();
await expect(n8n.credentials.cards.getCredentials()).toHaveCount(1);
// Move credential from admin user (Home) back to owner user
await n8n.sideBar.clickHomeMenuItem();
await n8n.navigate.toCredentials();
await expect(n8n.credentials.cards.getCredentials()).toHaveCount(3);
const credentialCard3 = n8n.credentials.cards.getCredential('Credential in Project 1');
await n8n.credentials.cards.openCardActions(credentialCard3);
await n8n.credentials.cards.getCardAction('move').click();
await expect(n8n.resourceMoveModal.getMoveCredentialButton()).toBeDisabled();
await n8n.resourceMoveModal.getProjectSelectCredential().locator('input').click();
await expect(n8n.page.getByRole('option')).toHaveCount(6);
await n8n.resourceMoveModal.selectProjectOption(INSTANCE_OWNER_CREDENTIALS.email);
await n8n.resourceMoveModal.clickMoveCredentialButton();
// Verify final state: 3 credentials total, 2 with Personal badge
await expect(n8n.credentials.cards.getCredentials()).toHaveCount(3);
await expect(
n8n.credentials.cards.getCredentials().filter({ hasText: 'Personal' }),
).toHaveCount(2);
});
});

View file

@ -1,19 +1,17 @@
import { nanoid } from 'nanoid';
import {
INSTANCE_ADMIN_CREDENTIALS,
INSTANCE_MEMBER_CREDENTIALS,
INSTANCE_OWNER_CREDENTIALS,
} from '../../../config/test-users';
import { INSTANCE_MEMBER_CREDENTIALS } from '../../../config/test-users';
import { test, expect } from '../../../fixtures/base';
test.use({ capability: { env: { TEST_ISOLATION: 'projects' } } });
const MANUAL_TRIGGER_NODE_NAME = 'Manual Trigger';
const EXECUTE_WORKFLOW_NODE_NAME = 'Execute Sub-workflow';
const NOTION_NODE_NAME = 'Notion';
const EDIT_FIELDS_SET_NODE_NAME = 'Edit Fields (Set)';
const NOTION_API_KEY = 'abc123Playwright';
test.describe('Projects', () => {
test.describe('Projects @db:reset', () => {
test.describe.configure({ mode: 'serial' });
test.beforeEach(async ({ n8n }) => {
@ -27,7 +25,7 @@ test.describe('Projects', () => {
await n8n.api.setMaxTeamProjectsQuota(-1);
});
test.describe('when starting from scratch @db:reset', () => {
test.describe('when starting from scratch', () => {
test('should not show project add button and projects to a member if not invited to any project @auth:member', async ({
n8n,
}) => {
@ -36,25 +34,7 @@ test.describe('Projects', () => {
await expect(n8n.sideBar.getProjectMenuItems()).toHaveCount(0);
});
test('should filter credentials by project ID when creating new workflow or hard reloading an opened workflow', async ({
n8n,
}) => {
const { projectName, projectId } = await n8n.projectComposer.createProject();
await n8n.projectComposer.addCredentialToProject(
projectName,
'Notion API',
'apiKey',
NOTION_API_KEY,
);
const credentials = await n8n.api.credentials.getCredentialsByProject(projectId);
expect(credentials).toHaveLength(1);
const { projectId: project2Id } = await n8n.projectComposer.createProject();
const credentials2 = await n8n.api.credentials.getCredentialsByProject(project2Id);
expect(credentials2).toHaveLength(0);
});
// This test needs empty credentials list - must run before tests that create credentials
test('should allow changing an inaccessible credential when the workflow was moved to a team project @auth:owner', async ({
n8n,
}) => {
@ -128,6 +108,25 @@ test.describe('Projects', () => {
await expect(n8n.ndv.getCredentialSelectInput()).toBeEnabled();
});
test('should filter credentials by project ID when creating new workflow or hard reloading an opened workflow', async ({
n8n,
}) => {
const { projectName, projectId } = await n8n.projectComposer.createProject();
await n8n.projectComposer.addCredentialToProject(
projectName,
'Notion API',
'apiKey',
NOTION_API_KEY,
);
const credentials = await n8n.api.credentials.getCredentialsByProject(projectId);
expect(credentials).toHaveLength(1);
const { projectId: project2Id } = await n8n.projectComposer.createProject();
const credentials2 = await n8n.api.credentials.getCredentialsByProject(project2Id);
expect(credentials2).toHaveLength(0);
});
test('should create sub-workflow and credential in the sub-workflow in the same project @auth:owner', async ({
n8n,
}) => {
@ -266,134 +265,4 @@ test.describe('Projects', () => {
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(0);
});
});
test.describe('when moving resources between projects @db:reset', () => {
test.describe.configure({ mode: 'serial' });
test.beforeEach(async ({ n8n }) => {
// Create workflow + credential in Home/Personal project
await n8n.api.workflows.createWorkflow({
name: 'Workflow in Home project',
nodes: [],
connections: {},
active: false,
});
await n8n.api.credentials.createCredential({
name: 'Credential in Home project',
type: 'notionApi',
data: { apiKey: '1234567890' },
});
// Create Project 1 with resources
const project1 = await n8n.api.projects.createProject('Project 1');
await n8n.api.workflows.createInProject(project1.id, {
name: 'Workflow in Project 1',
});
await n8n.api.credentials.createCredential({
name: 'Credential in Project 1',
type: 'notionApi',
data: { apiKey: '1234567890' },
projectId: project1.id,
});
// Create Project 2 with resources
const project2 = await n8n.api.projects.createProject('Project 2');
await n8n.api.workflows.createInProject(project2.id, {
name: 'Workflow in Project 2',
});
await n8n.api.credentials.createCredential({
name: 'Credential in Project 2',
type: 'notionApi',
data: { apiKey: '1234567890' },
projectId: project2.id,
});
// Navigate to home to load sidebar with new projects
await n8n.goHome();
});
test('should move the workflow to expected projects @auth:owner', async ({ n8n }) => {
// Move workflow from Personal to Project 2
await n8n.sideBar.clickPersonalMenuItem();
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(1);
await n8n.workflowComposer.moveToProject('Workflow in Home project', 'Project 2');
// Verify Personal has 0 workflows
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(0);
// Move workflow from Project 1 to Project 2
await n8n.sideBar.clickProjectMenuItem('Project 1');
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(1);
await n8n.workflowComposer.moveToProject('Workflow in Project 1', 'Project 2');
// Move workflow from Project 2 to member user
await n8n.sideBar.clickProjectMenuItem('Project 2');
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(3);
await n8n.workflowComposer.moveToProject(
'Workflow in Home project',
INSTANCE_MEMBER_CREDENTIALS[0].email,
null,
);
// Verify Project 2 has 2 workflows remaining
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(2);
});
test('should move the credential to expected projects @auth:owner', async ({ n8n }) => {
// Move credential from Project 1 to Project 2
await n8n.sideBar.clickProjectMenuItem('Project 1');
await n8n.sideBar.clickCredentialsLink();
await expect(n8n.credentials.cards.getCredentials()).toHaveCount(1);
const credentialCard1 = n8n.credentials.cards.getCredential('Credential in Project 1');
await n8n.credentials.cards.openCardActions(credentialCard1);
await n8n.credentials.cards.getCardAction('move').click();
await expect(n8n.resourceMoveModal.getMoveCredentialButton()).toBeDisabled();
await n8n.resourceMoveModal.getProjectSelectCredential().locator('input').click();
await expect(n8n.page.getByRole('option')).toHaveCount(6);
await n8n.resourceMoveModal.selectProjectOption('Project 2');
await n8n.resourceMoveModal.clickMoveCredentialButton();
await expect(n8n.credentials.cards.getCredentials()).toHaveCount(0);
// Move credential from Project 2 to admin user
await n8n.sideBar.clickProjectMenuItem('Project 2');
await n8n.sideBar.clickCredentialsLink();
await expect(n8n.credentials.cards.getCredentials()).toHaveCount(2);
const credentialCard2 = n8n.credentials.cards.getCredential('Credential in Project 1');
await n8n.credentials.cards.openCardActions(credentialCard2);
await n8n.credentials.cards.getCardAction('move').click();
await expect(n8n.resourceMoveModal.getMoveCredentialButton()).toBeDisabled();
await n8n.resourceMoveModal.getProjectSelectCredential().locator('input').click();
await expect(n8n.page.getByRole('option')).toHaveCount(6);
await n8n.resourceMoveModal.selectProjectOption(INSTANCE_ADMIN_CREDENTIALS.email);
await n8n.resourceMoveModal.clickMoveCredentialButton();
await expect(n8n.credentials.cards.getCredentials()).toHaveCount(1);
// Move credential from admin user (Home) back to owner user
await n8n.sideBar.clickHomeMenuItem();
await n8n.navigate.toCredentials();
await expect(n8n.credentials.cards.getCredentials()).toHaveCount(3);
const credentialCard3 = n8n.credentials.cards.getCredential('Credential in Project 1');
await n8n.credentials.cards.openCardActions(credentialCard3);
await n8n.credentials.cards.getCardAction('move').click();
await expect(n8n.resourceMoveModal.getMoveCredentialButton()).toBeDisabled();
await n8n.resourceMoveModal.getProjectSelectCredential().locator('input').click();
await expect(n8n.page.getByRole('option')).toHaveCount(6);
await n8n.resourceMoveModal.selectProjectOption(INSTANCE_OWNER_CREDENTIALS.email);
await n8n.resourceMoveModal.clickMoveCredentialButton();
// Verify final state: 3 credentials total, 2 with Personal badge
await expect(n8n.credentials.cards.getCredentials()).toHaveCount(3);
await expect(
n8n.credentials.cards.getCredentials().filter({ hasText: 'Personal' }),
).toHaveCount(2);
});
});
});

View file

@ -4,6 +4,8 @@ import { test, expect } from '../../../fixtures/base';
import type { TestRequirements } from '../../../Types';
import { resolveFromRoot } from '../../../utils/path-helper';
test.use({ capability: { env: { TEST_ISOLATION: 'template-setup-experiment' } } });
const TEMPLATE_HOSTNAME = 'custom.template.host';
const TEMPLATE_HOST = `https://${TEMPLATE_HOSTNAME}/api`;
const TEMPLATE_ID = 1205;

View file

@ -121,7 +121,7 @@ test.describe('Source Control Settings @capability:source-control @fixme', () =>
// check that source control is disconnected
await n8n.navigate.toHome();
await expect(n8n.sideBar.getSourceControlConnectedIndicator()).not.toBeVisible();
await expect(n8n.sideBar.getSourceControlConnectedIndicator()).toBeHidden();
// Reconnect
await n8n.navigate.toEnvironments();

View file

@ -3,6 +3,8 @@ import { authenticator } from 'otplib';
import { INSTANCE_OWNER_CREDENTIALS } from '../../../../config/test-users';
import { test, expect } from '../../../../fixtures/base';
test.use({ capability: { env: { TEST_ISOLATION: 'two-factor-auth' } } });
const TEST_DATA = {
NEW_EMAIL: 'newemail@test.com',
NEW_FIRST_NAME: 'newFirstName',
@ -17,6 +19,8 @@ const { email, password, mfaSecret, mfaRecoveryCodes } = INSTANCE_OWNER_CREDENTI
const RECOVERY_CODE = mfaRecoveryCodes![0];
test.describe('Two-factor authentication @auth:none @db:reset', () => {
test.describe.configure({ mode: 'serial' });
test('Should be able to login with MFA code', async ({ n8n }) => {
await n8n.mfaComposer.enableMfa(email, password, mfaSecret!);
await n8n.sideBar.signOutFromWorkflows();

View file

@ -138,7 +138,7 @@ test.describe('Workflow Diff Demo', () => {
);
// Wait for the diff view to appear - the waiting message should disappear
await expect(n8n.page.getByText('Waiting for workflow data...')).not.toBeVisible();
await expect(n8n.page.getByText('Waiting for workflow data...')).toBeHidden();
// The workflow name should appear in the header
await expect(
@ -170,7 +170,7 @@ test.describe('Workflow Diff Demo', () => {
);
// Wait for the diff view to appear
await expect(n8n.page.getByText('Waiting for workflow data...')).not.toBeVisible();
await expect(n8n.page.getByText('Waiting for workflow data...')).toBeHidden();
// The workflow name from newWorkflow should appear
await expect(n8n.page.getByRole('heading', { name: 'Test Workflow - After' })).toBeVisible();
@ -197,7 +197,7 @@ test.describe('Workflow Diff Demo', () => {
);
// Wait for the diff view to appear
await expect(n8n.page.getByText('Waiting for workflow data...')).not.toBeVisible();
await expect(n8n.page.getByText('Waiting for workflow data...')).toBeHidden();
// The workflow name from oldWorkflow should appear
await expect(n8n.page.getByRole('heading', { name: 'Test Workflow - Before' })).toBeVisible();
@ -226,7 +226,7 @@ test.describe('Workflow Diff Demo', () => {
);
// Wait for the diff view to appear
await expect(n8n.page.getByText('Waiting for workflow data...')).not.toBeVisible();
await expect(n8n.page.getByText('Waiting for workflow data...')).toBeHidden();
// The diff view should render (tidyUp affects node positioning but view should still render)
await expect(

View file

@ -62,7 +62,7 @@ test.describe('Logs', () => {
await n8n.canvas.logsPanel.getClearExecutionButton().click();
await expect(n8n.canvas.logsPanel.getLogEntries()).toHaveCount(0);
await expect(n8n.canvas.getNodeIssuesByName(NODES.CODE1)).not.toBeVisible();
await expect(n8n.canvas.getNodeIssuesByName(NODES.CODE1)).toBeHidden();
});
test('should allow to trigger partial execution', async ({ n8n, setupRequirements }) => {

View file

@ -2,6 +2,8 @@ import { INSTANCE_MEMBER_CREDENTIALS } from '../../../../config/test-users';
import { test, expect } from '../../../../fixtures/base';
import type { n8nPage } from '../../../../pages/n8nPage';
test.use({ capability: { env: { TEST_ISOLATION: 'viewer-permissions' } } });
const MEMBER_EMAIL = INSTANCE_MEMBER_CREDENTIALS[0].email;
// Helper to set up a project with a workflow and sign in as member with specified role
@ -36,7 +38,7 @@ async function setupProjectWithWorkflowAndSignInAsMember({
await expect(n8n.canvas.getLoadingMask()).not.toBeAttached();
}
test.describe('Workflow Viewer Permissions @isolated', () => {
test.describe('Workflow Viewer Permissions', () => {
test.describe.configure({ mode: 'serial' });
let readOnlyRole: { slug: string };

View file

@ -87,7 +87,7 @@ test.describe('Workflow Archive @fixme', () => {
await expect(n8n.canvas.getArchivedTag()).toBeVisible();
await expect(n8n.canvas.getNodeCreatorPlusButton()).not.toBeAttached();
await expect(n8n.canvas.getPublishedIndicator()).not.toBeVisible();
await expect(n8n.canvas.getPublishedIndicator()).toBeHidden();
await expect(n8n.workflowSettingsModal.getWorkflowMenu()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
@ -129,7 +129,7 @@ test.describe('Workflow Archive @fixme', () => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.waitForSaveWorkflowCompleted();
await expect(n8n.canvas.getPublishedIndicator()).not.toBeVisible();
await expect(n8n.canvas.getPublishedIndicator()).toBeHidden();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await expect(n8n.workflowSettingsModal.getUnpublishMenuItem()).not.toBeAttached();
@ -150,7 +150,7 @@ test.describe('Workflow Archive @fixme', () => {
await n8n.workflowSettingsModal.confirmUnpublishModal();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.canvas.getPublishedIndicator()).not.toBeVisible();
await expect(n8n.canvas.getPublishedIndicator()).toBeHidden();
});
// Flaky in multi-main mode
@ -172,8 +172,8 @@ test.describe('Workflow Archive @fixme', () => {
await goToWorkflow(n8n, workflowId);
await expect(n8n.canvas.getArchivedTag()).toBeVisible();
await expect(n8n.canvas.getPublishedIndicator()).not.toBeVisible();
await expect(n8n.canvas.getPublishButton()).not.toBeVisible();
await expect(n8n.canvas.getPublishedIndicator()).toBeHidden();
await expect(n8n.canvas.getPublishButton()).toBeHidden();
await expect(n8n.workflowSettingsModal.getWorkflowMenu()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();

View file

@ -21,7 +21,7 @@ test.describe('Workflow Publish', () => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.waitForSaveWorkflowCompleted();
await expect(n8n.canvas.getPublishedIndicator()).not.toBeVisible();
await expect(n8n.canvas.getPublishedIndicator()).toBeHidden();
await n8n.canvas.publishWorkflow();

View file

@ -4,6 +4,8 @@ import { test, expect } from '../../../../fixtures/base';
import type { TestRequirements } from '../../../../Types';
import { resolveFromRoot } from '../../../../utils/path-helper';
test.use({ capability: { env: { TEST_ISOLATION: 'template-credentials-setup' } } });
const TEMPLATE_HOST = 'https://api.n8n.io/api/';
const TEMPLATE_ID = 1205;
const TEMPLATE_WITHOUT_CREDS_ID = 1344;

View file

@ -157,14 +157,12 @@ async function main() {
await checkPrerequisites();
// Build n8n Docker image
const n8nBuildTime = await buildDockerImage({
name: 'n8n',
dockerfilePath: config.n8n.dockerfilePath,
fullImageName: config.n8n.fullImageName,
});
// Build runners Docker image
const runnersBuildTime = await buildDockerImage({
name: 'runners',
dockerfilePath: config.runners.dockerfilePath,
@ -215,7 +213,14 @@ async function checkPrerequisites() {
async function buildDockerImage({ name, dockerfilePath, fullImageName }) {
const startTime = Date.now();
const containerEngine = await getContainerEngine();
// Push directly if image name contains a registry (e.g., ghcr.io/...)
// This avoids the slow --load step (export/import tarball) when pushing to a registry
const shouldPush = fullImageName.includes('/') && fullImageName.split('/').length > 2;
echo(chalk.yellow(`INFO: Building ${name} Docker image using ${containerEngine}...`));
if (shouldPush) {
echo(chalk.yellow(`INFO: Registry detected - pushing directly to ${fullImageName}`));
}
try {
if (containerEngine === 'podman') {
@ -229,12 +234,16 @@ async function buildDockerImage({ name, dockerfilePath, fullImageName }) {
} else {
// Use docker buildx build to leverage Blacksmith's layer caching when running in CI.
// The setup-docker-builder action creates a buildx builder with sticky disk cache.
// In CI, push directly to registry to avoid slow --load (export/import tarball).
// Locally, use --load to make image available in local daemon.
const outputFlag = shouldPush ? '--push' : '--load';
const { stdout } = await $`docker buildx build \
--platform ${platform} \
--build-arg TARGETPLATFORM=${platform} \
-t ${fullImageName} \
-f ${dockerfilePath} \
--load \
--provenance=false \
${outputFlag} \
${config.buildContext}`;
echo(stdout);
}