mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
ci: Docker CI changes (#24962)
This commit is contained in:
parent
e7c5c17402
commit
ea83ed49a0
28 changed files with 1038 additions and 989 deletions
1219
.github/test-metrics/playwright.json
vendored
1219
.github/test-metrics/playwright.json
vendored
File diff suppressed because it is too large
Load diff
74
.github/workflows/test-e2e-ci-reusable.yml
vendored
74
.github/workflows/test-e2e-ci-reusable.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
9
.github/workflows/test-e2e-reusable.yml
vendored
9
.github/workflows/test-e2e-reusable.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() };
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue