diff --git a/.github/actions/setup-nodejs/safe-chain.config.json b/.github/actions/setup-nodejs/safe-chain.config.json index 42bda8079046..893acf1aa83e 100644 --- a/.github/actions/setup-nodejs/safe-chain.config.json +++ b/.github/actions/setup-nodejs/safe-chain.config.json @@ -3,6 +3,13 @@ "minimumPackageAgeExclusions": [ "@n8n/*", "@n8n_io/*", + "endform", + "endform-darwin-arm64", + "endform-darwin-x86", + "endform-linux-arm64", + "endform-linux-x86", + "endform-win32-arm64", + "endform-win32-x86", "n8n", "n8n-containers", "n8n-core", diff --git a/.github/workflows/ci-pull-requests.yml b/.github/workflows/ci-pull-requests.yml index 9735a6da94f2..991957218af9 100644 --- a/.github/workflows/ci-pull-requests.yml +++ b/.github/workflows/ci-pull-requests.yml @@ -11,7 +11,7 @@ concurrency: jobs: install-and-build: name: Install & Build - runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }} + runs-on: ${{ (github.repository == 'endformdev/n8n' || vars.RUNNER_PROVIDER == 'github') && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }} env: NODE_OPTIONS: '--max-old-space-size=7168' CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} @@ -19,11 +19,11 @@ jobs: QA_METRICS_WEBHOOK_USER: ${{ secrets.QA_METRICS_WEBHOOK_USER }} QA_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_METRICS_WEBHOOK_PASSWORD }} outputs: - ci: ${{ fromJSON(steps.ci-filter.outputs.results).ci == true }} + ci: ${{ github.repository == 'n8n-io/n8n' && fromJSON(steps.ci-filter.outputs.results).ci == true }} runtime: ${{ fromJSON(steps.ci-filter.outputs.results).runtime == true }} - unit: ${{ fromJSON(steps.ci-filter.outputs.results).unit == true }} + unit: ${{ github.repository == 'n8n-io/n8n' && fromJSON(steps.ci-filter.outputs.results).unit == true }} e2e: ${{ fromJSON(steps.ci-filter.outputs.results).e2e == true }} - dev_server_smoke: ${{ fromJSON(steps.ci-filter.outputs.results)['dev-server-smoke'] == true }} + dev_server_smoke: ${{ github.repository == 'n8n-io/n8n' && fromJSON(steps.ci-filter.outputs.results)['dev-server-smoke'] == true }} workflows: ${{ fromJSON(steps.ci-filter.outputs.results).workflows == true }} workflow_scripts: ${{ fromJSON(steps.ci-filter.outputs.results)['workflow-scripts'] == true }} db: ${{ fromJSON(steps.ci-filter.outputs.results).db == true }} @@ -253,9 +253,8 @@ jobs: run: | pnpm -r pack --dry-run - # Seeds the SHA-keyed Docker image cache once so that downstream e2e jobs - # (each of which invokes prepare-docker internally) short-circuit to a - # cache hit instead of racing to rebuild. + # Seeds the SHA-keyed Docker image cache once for internal container-based E2E jobs. + # The Endform fork uses a local SQLite backend and does not consume this image. prepare-docker: name: Prepare Docker needs: install-and-build @@ -270,6 +269,7 @@ jobs: uses: ./.github/workflows/prepare-docker-reusable.yml with: branch: ${{ needs.install-and-build.outputs.commit_sha }} + runner: ubuntu-latest secrets: inherit # Internal-only 1-spec fail-fast sanity check on sqlite. @@ -293,13 +293,41 @@ jobs: artifact-prefix: sanity secrets: inherit + # Gives the Endform fork an early signal while the full impacted suite is still running. + endform-smoke: + name: 'Endform: Fast Signal' + needs: install-and-build + if: >- + github.repository == 'endformdev/n8n' && + (needs.install-and-build.outputs.runtime == 'true' + || needs.install-and-build.outputs.e2e == 'true' + || contains(github.event.pull_request.labels.*.name, 'force-e2e')) && + needs.install-and-build.outputs.skip_tests != 'true' && + github.event_name != 'merge_group' + uses: ./.github/workflows/test-e2e-reusable.yml + with: + branch: ${{ needs.install-and-build.outputs.commit_sha }} + test-mode: local + test-command: >- + pnpm --filter=n8n-playwright exec endform test --organization-id=2G1ZCj7X --project=e2e + tests/e2e/building-blocks/workflow-entry-points.spec.ts + tests/e2e/credentials/crud.spec.ts + test-runner: endform + endform-concurrency: '4' + timeout-minutes: 30 + artifact-prefix: endform-smoke + secrets: inherit + # Full e2e run. Internal PRs run multi-main (postgres + redis + caddy + 2 mains + 1 worker). # Fork PRs run sqlite-only and skip @licensed tests (no enterprise license secrets on forks). e2e: name: E2E needs: [install-and-build, prepare-docker] if: >- - needs.prepare-docker.result == 'success' && + always() && + (needs.prepare-docker.result == 'success' + || github.repository == 'endformdev/n8n' + || github.event.pull_request.head.repo.fork == true) && (needs.install-and-build.outputs.runtime == 'true' || needs.install-and-build.outputs.e2e == 'true' || contains(github.event.pull_request.labels.*.name, 'force-e2e')) && @@ -308,9 +336,13 @@ jobs: uses: ./.github/workflows/test-e2e-reusable.yml with: branch: ${{ needs.install-and-build.outputs.commit_sha }} - test-mode: docker-artifact - test-command: ${{ github.event.pull_request.head.repo.fork == true && 'pnpm --filter=n8n-playwright test:container:sqlite:e2e --grep-invert=@licensed' || 'pnpm --filter=n8n-playwright test:container:multi-main:e2e' }} + test-mode: ${{ (github.repository == 'endformdev/n8n' || github.event.pull_request.head.repo.fork == true) && 'local' || 'docker-artifact' }} + test-command: ${{ (github.repository == 'endformdev/n8n' || github.event.pull_request.head.repo.fork == true) && 'pnpm --filter=n8n-playwright exec endform test --organization-id=2G1ZCj7X --project=e2e' || 'pnpm --filter=n8n-playwright exec endform test --organization-id=2G1ZCj7X --project=multi-main:e2e' }} + test-runner: endform + endform-concurrency: ${{ (github.repository == 'endformdev/n8n' || github.event.pull_request.head.repo.fork == true) && '2' || '1' }} + endform-exclusive-specs: ${{ (github.repository == 'endformdev/n8n' || github.event.pull_request.head.repo.fork == true) && '(app-config/env-feature-flags|api/webhook-isolate-skip|credentials/global|dynamic-credentials/form-trigger-submit-gate-client|mcp/mcp-(oauth|service)|settings/environments/variables|settings/workers/workers|workflows/editor/tags)\.spec\.ts$' || '' }} workers: '1' + timeout-minutes: ${{ (github.repository == 'endformdev/n8n' || github.event.pull_request.head.repo.fork == true) && 90 || 30 }} pre-generated-matrix: ${{ needs.install-and-build.outputs.matrix }} artifact-prefix: e2e secrets: inherit @@ -363,7 +395,7 @@ jobs: security-checks: name: Security Checks needs: install-and-build - if: needs.install-and-build.outputs.workflows == 'true' + if: needs.install-and-build.outputs.workflows == 'true' && github.repository == 'n8n-io/n8n' uses: ./.github/workflows/sec-ci-reusable.yml with: ref: ${{ needs.install-and-build.outputs.commit_sha }} diff --git a/.github/workflows/test-e2e-reusable.yml b/.github/workflows/test-e2e-reusable.yml index c3af91ba6b33..dda62393d14d 100644 --- a/.github/workflows/test-e2e-reusable.yml +++ b/.github/workflows/test-e2e-reusable.yml @@ -17,6 +17,21 @@ on: required: false default: 'pnpm --filter=n8n-playwright test:local' type: string + test-runner: + description: 'Test runner: playwright or endform' + required: false + default: 'playwright' + type: string + endform-concurrency: + description: 'Maximum Endform tests sharing one backend' + required: false + default: '1' + type: string + endform-exclusive-specs: + description: 'Regex matching specs that Endform must run sequentially after the parallel lane' + required: false + default: '' + type: string workers: description: 'Number of parallel workers' required: false @@ -73,7 +88,7 @@ env: jobs: test: - runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || inputs.runner }} + runs-on: ${{ (github.repository == 'endformdev/n8n' || vars.RUNNER_PROVIDER == 'github') && 'ubuntu-latest' || inputs.runner }} timeout-minutes: ${{ inputs.timeout-minutes }} permissions: contents: read @@ -98,6 +113,7 @@ jobs: QA_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_METRICS_WEBHOOK_PASSWORD }} - name: Install Browsers + if: ${{ inputs.test-runner != 'endform' }} run: pnpm turbo run install-browsers --filter=n8n-playwright - name: Load n8n image from cache @@ -107,7 +123,7 @@ jobs: build-variant: ${{ inputs.build-variant }} - name: Pre-pull Test Container Images - if: ${{ !contains(inputs.test-command, 'test:local') }} + if: ${{ inputs.test-mode == 'docker-artifact' }} run: pnpm --filter n8n-containers exec tsx pull-test-images.ts ${{ matrix.images }} || true - name: Filter specs to previous-attempt failures @@ -149,6 +165,44 @@ jobs: - name: Run Tests if: steps.shard-args.outputs.skip != 'true' run: | + if [ "$TEST_RUNNER" = 'endform' ]; then + if [ -n "$ENDFORM_EXCLUSIVE_SPECS" ]; then + if [[ "$SHARD_ARGS" == --shard=* ]]; then + echo "Endform exclusive specs require a pre-generated spec matrix." + exit 1 + fi + + read -r -a shard_args <<< "$SHARD_ARGS" + parallel_args=() + exclusive_args=() + for arg in "${shard_args[@]}"; do + if [[ "$arg" =~ $ENDFORM_EXCLUSIVE_SPECS ]]; then + exclusive_args+=("$arg") + else + parallel_args+=("$arg") + fi + done + + if (( ${#parallel_args[@]} > 0 )); then + echo "Running ${#parallel_args[@]} spec files with Endform concurrency $ENDFORM_CONCURRENCY." + # shellcheck disable=SC2086 + $TEST_COMMAND "${parallel_args[@]}" + fi + + if (( ${#exclusive_args[@]} > 0 )); then + echo "Running ${#exclusive_args[@]} shared-state spec files sequentially." + ENDFORM_CONCURRENCY=1 + # shellcheck disable=SC2086 + $TEST_COMMAND "${exclusive_args[@]}" + fi + exit + fi + + # shellcheck disable=SC2086 + $TEST_COMMAND $SHARD_ARGS + exit + fi + # --pass-with-no-tests makes Playwright exit 0 when the selected specs # resolve to zero runnable tests. Covers the impact-map vs current-suite # skew window: a spec the committed coverage map points at may have been @@ -161,10 +215,14 @@ jobs: env: # Protect args from template injections TEST_COMMAND: ${{ inputs.test-command }} + TEST_RUNNER: ${{ inputs.test-runner }} + ENDFORM_CONCURRENCY: ${{ inputs.endform-concurrency }} + ENDFORM_EXCLUSIVE_SPECS: ${{ inputs.endform-exclusive-specs }} # Uses pre-distributed specs if orchestration enabled, otherwise falls back to Playwright sharding WORKERS: ${{ env.PLAYWRIGHT_WORKERS }} SHARD_ARGS: ${{ steps.shard-args.outputs.args }} # Args for actual test command runner + ENDFORM_API_KEY: ${{ inputs.test-runner == 'endform' && secrets.ENDFORM_API_KEY || '' }} CURRENTS_RECORD_KEY: ${{ secrets.CURRENTS_RECORD_KEY }} QA_METRICS_WEBHOOK_URL: ${{ secrets.QA_METRICS_WEBHOOK_URL }} QA_METRICS_WEBHOOK_USER: ${{ secrets.QA_METRICS_WEBHOOK_USER }} @@ -173,6 +231,12 @@ jobs: N8N_LICENSE_CERT: ${{ secrets.N8N_LICENSE_CERT }} N8N_ENCRYPTION_KEY: ${{ secrets.N8N_ENCRYPTION_KEY }} N8N_TEST_ENV: ${{ inputs.n8n-env }} + # Endform runners are remote and cannot access the job's Docker socket. For the fork's + # SQLite suite, use Playwright's existing webServer and Endform's automatic proxying. + N8N_BASE_URL: ${{ inputs.test-runner == 'endform' && github.repository == 'endformdev/n8n' && 'http://localhost:5680' || '' }} + E2E_TESTS: ${{ inputs.test-runner == 'endform' && github.repository == 'endformdev/n8n' && 'true' || '' }} + E2E_REMOTE_RUNNER: ${{ inputs.test-runner == 'endform' && 'true' || '' }} + RESET_E2E_DB: ${{ inputs.test-runner == 'endform' && github.repository == 'endformdev/n8n' && 'true' || '' }} - name: Upload Shard Artifacts if: always() diff --git a/packages/testing/playwright/endform.config.ts b/packages/testing/playwright/endform.config.ts new file mode 100644 index 000000000000..78f102b12460 --- /dev/null +++ b/packages/testing/playwright/endform.config.ts @@ -0,0 +1,10 @@ +/* eslint-disable import-x/no-default-export */ +import { defineEndformConfig } from 'endform'; + +export default defineEndformConfig({ + additionalFiles: ['workflows/**/*', 'fixtures/**/*', 'expectations/**/*', 'tests/**/*'], + concurrentTestLimits: [ + { scope: 'within-suite-run', limit: Number(process.env.ENDFORM_CONCURRENCY ?? 1) }, + ], + environmentVariables: ['E2E_REMOTE_RUNNER', 'N8N_BASE_URL', 'RESET_E2E_DB'], +}); diff --git a/packages/testing/playwright/global-setup.ts b/packages/testing/playwright/global-setup.ts index ad7c167d2455..d3541570e458 100644 --- a/packages/testing/playwright/global-setup.ts +++ b/packages/testing/playwright/global-setup.ts @@ -1,4 +1,5 @@ import { request } from '@playwright/test'; +import { setTimeout as wait } from 'node:timers/promises'; import { ApiHelpers } from './services/api-helper'; import { getBackendUrl } from './utils/url-helper'; @@ -20,14 +21,28 @@ async function globalSetup() { } console.log(`🔄 Resetting database for ${n8nBaseUrl}...`); - // Quick hack till we find out a better health check for the database reset command! - await new Promise((resolve) => setTimeout(resolve, 3000)); // Create standalone API request context const requestContext = await request.newContext({ baseURL: n8nBaseUrl, }); try { + const readinessDeadline = Date.now() + 30_000; + let e2eApiReady = false; + while (Date.now() < readinessDeadline) { + const response = await requestContext.get('/rest/e2e/env-feature-flags'); + // The editor fallback returns HTML with 200 before REST controllers are mounted. + if (response.ok() && response.headers()['content-type']?.includes('application/json')) { + e2eApiReady = true; + break; + } + await wait(500); + } + + if (!e2eApiReady) { + throw new Error('Timed out waiting for E2E API routes'); + } + const api = new ApiHelpers(requestContext); await api.resetDatabase(); console.log('✅ Database reset completed successfully'); diff --git a/packages/testing/playwright/package.json b/packages/testing/playwright/package.json index ee52b7454505..ebcd619be727 100644 --- a/packages/testing/playwright/package.json +++ b/packages/testing/playwright/package.json @@ -69,6 +69,7 @@ "@types/jsonwebtoken": "catalog:", "@types/lodash": "catalog:", "autocannon": "^8.0.0", + "endform": "0.75.3", "eslint-plugin-playwright": "catalog:e2e", "flatted": "catalog:", "generate-schema": "2.6.0", diff --git a/packages/testing/playwright/playwright-projects.ts b/packages/testing/playwright/playwright-projects.ts index 0880acbeeb8d..7c2dd159b1d0 100644 --- a/packages/testing/playwright/playwright-projects.ts +++ b/packages/testing/playwright/playwright-projects.ts @@ -219,7 +219,13 @@ export function getProjects(): Project[] { testDir: './tests/e2e', grepInvert: ALLOW_CONTAINER_ONLY ? undefined : CONTAINER_ONLY, fullyParallel: true, - use: { baseURL: getFrontendUrl() }, + timeout: process.env.E2E_REMOTE_RUNNER === 'true' ? 120_000 : undefined, + use: { + baseURL: getFrontendUrl(), + ...(process.env.E2E_REMOTE_RUNNER === 'true' + ? { actionTimeout: 30_000, navigationTimeout: 30_000 } + : {}), + }, }); projects.push({ name: 'dev-server-smoke', diff --git a/packages/testing/playwright/playwright.config.ts b/packages/testing/playwright/playwright.config.ts index 6ea83bfb1301..b6a7d800ddea 100644 --- a/packages/testing/playwright/playwright.config.ts +++ b/packages/testing/playwright/playwright.config.ts @@ -53,8 +53,8 @@ const SKIP_WEB_SERVER = process.env.PLAYWRIGHT_SKIP_WEBSERVER === 'true'; if (BACKEND_URL && !SKIP_WEB_SERVER) { webServer.push({ - command: 'cd .. && pnpm start', - url: `${BACKEND_URL}/favicon.ico`, + command: 'pnpm --filter=n8n start', + url: `${BACKEND_URL}/rest/e2e/env-feature-flags`, timeout: 30000, reuseExistingServer: IS_DEV ? false : true, env: { diff --git a/packages/testing/playwright/tests/e2e/workflows/editor/execution/logs.spec.ts b/packages/testing/playwright/tests/e2e/workflows/editor/execution/logs.spec.ts index 8ee54534a5dd..4e8da4de87b2 100644 --- a/packages/testing/playwright/tests/e2e/workflows/editor/execution/logs.spec.ts +++ b/packages/testing/playwright/tests/e2e/workflows/editor/execution/logs.spec.ts @@ -16,7 +16,7 @@ const NODES = { }; test.describe( - 'Logs', + 'Logs @capability:proxy', { annotation: [{ type: 'owner', description: 'Catalysts' }], }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0735bd26a50..3492ffe8be53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7106,6 +7106,9 @@ importers: autocannon: specifier: ^8.0.0 version: 8.0.0 + endform: + specifier: 0.75.3 + version: 0.75.3 eslint-plugin-playwright: specifier: catalog:e2e version: 2.2.2(eslint@9.29.0(jiti@2.6.1)(supports-color@8.1.1)) @@ -16502,6 +16505,40 @@ packages: end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} + endform-darwin-arm64@0.75.3: + resolution: {integrity: sha512-7CTjEjrimK47CZI5Tki+1UdDPya2KBfwR1vSBkNC/IbGFu23B9eoKpzNWq4rw6d66HagP9OCheLkwXbQ5pyHHw==} + cpu: [arm64] + os: [darwin] + + endform-darwin-x86@0.75.3: + resolution: {integrity: sha512-SbY2EIZkAtR7F7aEuWIkfqc2C7mx+qO9Jn24ecEgZF5yPl6EqNSmo3Xs9JAoUwYji4ha2LvHMUnA5zHEI07JdQ==} + cpu: [x64] + os: [darwin] + + endform-linux-arm64@0.75.3: + resolution: {integrity: sha512-akW3fFq+wC3S0/aLKzFNaWf3b0gUdop8ItVGxh9JZzDGrOGqsl4/CxzBoO5UhHQsB+BeELYpcx7SXwGMKUs8Rw==} + cpu: [arm64] + os: [linux] + + endform-linux-x86@0.75.3: + resolution: {integrity: sha512-guzjS/xY6RSA8BnjavYlPOksfE4nffTUQhkVVv+OokWyoYjwLpL/eF7aTOO0sG4EZ/6YC5rAHq0LIQ4sMs+5ng==} + cpu: [x64] + os: [linux] + + endform-win32-arm64@0.75.3: + resolution: {integrity: sha512-dbP6ok4yfDIH6ctrXXvkzuush3LWIFx1JkZdgzbhpkEQ8icfBAWqC/wnS/nEiWkSi0dGz7IGXNG+GU3Vx89ZRA==} + cpu: [arm64] + os: [win32] + + endform-win32-x86@0.75.3: + resolution: {integrity: sha512-pygvw6ifZFDH4lvlDFD1nz+5naPPdONEqfnnUFD9NBu0qiYpRV9nzkZQRSkLwEoMxJQOA3xQOUGQq9eSWzYOyQ==} + cpu: [x64] + os: [win32] + + endform@0.75.3: + resolution: {integrity: sha512-cc3YQdQiMHFS0RFQDtVflj1Cedae2rmuY/mJtwByF2GFxyMqSuPdK4GgYX7RBIdWhCSKb7IbN9Ef8pfLxjebbQ==} + hasBin: true + enhanced-resolve@5.18.2: resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==} engines: {node: '>=10.13.0'} @@ -36359,6 +36396,33 @@ snapshots: dependencies: once: 1.4.0 + endform-darwin-arm64@0.75.3: + optional: true + + endform-darwin-x86@0.75.3: + optional: true + + endform-linux-arm64@0.75.3: + optional: true + + endform-linux-x86@0.75.3: + optional: true + + endform-win32-arm64@0.75.3: + optional: true + + endform-win32-x86@0.75.3: + optional: true + + endform@0.75.3: + optionalDependencies: + endform-darwin-arm64: 0.75.3 + endform-darwin-x86: 0.75.3 + endform-linux-arm64: 0.75.3 + endform-linux-x86: 0.75.3 + endform-win32-arm64: 0.75.3 + endform-win32-x86: 0.75.3 + enhanced-resolve@5.18.2: dependencies: graceful-fs: 4.2.11 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5e61b8ba4dce..3b2e071281a1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -34,6 +34,9 @@ minimumReleaseAgeExclude: - '@ai-sdk/anthropic' # Security patch (Aikido) newer than the minimumReleaseAge window; allow it in. - '@xmldom/xmldom' + # Endform releases need immediate validation against the PR E2E suite. + - endform + - 'endform-*' packages: - packages/*