diff --git a/.github/workflows/ci-pull-requests.yml b/.github/workflows/ci-pull-requests.yml index b9c1fc996d7..db03e8edac2 100644 --- a/.github/workflows/ci-pull-requests.yml +++ b/.github/workflows/ci-pull-requests.yml @@ -22,6 +22,7 @@ jobs: ci: ${{ fromJSON(steps.ci-filter.outputs.results).ci == true }} unit: ${{ 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 }} 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 }} @@ -63,6 +64,16 @@ jobs: .github/actions/load-n8n-docker/** packages/testing/playwright/** packages/testing/containers/** + dev-server-smoke: + packages/frontend/editor-ui/vite.config.mts + pnpm-workspace.yaml + packages/@n8n/*/package.json + packages/testing/playwright/tests/dev-server-smoke/** + packages/testing/playwright/playwright.config.ts + packages/testing/playwright/playwright-projects.ts + packages/testing/playwright/package.json + .github/workflows/test-dev-server-smoke-reusable.yml + .github/workflows/ci-pull-requests.yml workflows: .github/** workflow-scripts: .github/scripts/** performance: @@ -221,6 +232,19 @@ jobs: upload-failure-artifacts: ${{ github.event.pull_request.head.repo.fork == true }} secrets: inherit + # Boots the editor-ui against the Vite dev server and fails on any console + # or page error during load. Catches regressions in dev-mode module + # resolution (missing Vite alias, broken workspace package interop) that + # the production-bundle e2e job bundles around. + dev-server-smoke: + name: Dev-server boot smoke + needs: install-and-build + if: needs.install-and-build.outputs.dev_server_smoke == 'true' && github.event_name != 'merge_group' + uses: ./.github/workflows/test-dev-server-smoke-reusable.yml + with: + ref: ${{ needs.install-and-build.outputs.commit_sha }} + secrets: inherit + db-tests: name: DB Tests needs: install-and-build @@ -296,6 +320,7 @@ jobs: check-packaging, sqlite-sanity, e2e, + dev-server-smoke, db-tests, performance, security-checks, diff --git a/.github/workflows/test-dev-server-smoke-reusable.yml b/.github/workflows/test-dev-server-smoke-reusable.yml new file mode 100644 index 00000000000..fbe1b40d9f3 --- /dev/null +++ b/.github/workflows/test-dev-server-smoke-reusable.yml @@ -0,0 +1,49 @@ +name: 'Test: Dev-server boot smoke' + +on: + workflow_call: + inputs: + ref: + description: 'Git ref to test' + required: true + type: string + +env: + NODE_OPTIONS: '--max-old-space-size=6144' + PLAYWRIGHT_BROWSERS_PATH: packages/testing/playwright/.playwright-browsers + +jobs: + smoke: + name: Dev-server smoke + runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }} + timeout-minutes: 10 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + ref: ${{ inputs.ref }} + + - name: Setup and Build + uses: ./.github/actions/setup-nodejs + + - name: Install Browsers + run: pnpm turbo run install-browsers --filter=n8n-playwright + + - name: Run dev-server smoke spec + # Run from repo root so PLAYWRIGHT_BROWSERS_PATH (relative) resolves + # correctly. cd-ing into the playwright package double-nests it. + run: pnpm --filter=n8n-playwright test:dev-server-smoke --reporter=list + + - name: Upload Failure Artifacts + if: ${{ failure() }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: dev-server-smoke-report + path: | + packages/testing/playwright/test-results/ + packages/testing/playwright/playwright-report/ + retention-days: 7 diff --git a/packages/testing/playwright/global-teardown.ts b/packages/testing/playwright/global-teardown.ts index 26da3204223..18e0b37a401 100644 --- a/packages/testing/playwright/global-teardown.ts +++ b/packages/testing/playwright/global-teardown.ts @@ -7,12 +7,18 @@ function globalTeardown() { for (const port of ports) { try { - // Find process ID using the port - const pid = execSync(`lsof -ti :${port}`, { encoding: 'utf-8' }).trim(); + // `lsof -ti` returns one PID per line. Dev-mode n8n holds the port + // from multiple PIDs (parent + worker), so split and space-join + // before passing to `kill` — otherwise the second PID lands on its + // own shell line and gets executed as a command. + const pids = execSync(`lsof -ti :${port}`, { encoding: 'utf-8' }) + .trim() + .split('\n') + .filter(Boolean); - if (pid) { - console.log(`- Killing process ${pid} on port ${port}`); - execSync(`kill -9 ${pid}`); + if (pids.length > 0) { + console.log(`- Killing process(es) ${pids.join(', ')} on port ${port}`); + execSync(`kill -9 ${pids.join(' ')}`); } } catch (error) { // lsof returns non-zero exit code if no process is found diff --git a/packages/testing/playwright/package.json b/packages/testing/playwright/package.json index 7b574483ce9..ccb837c5fac 100644 --- a/packages/testing/playwright/package.json +++ b/packages/testing/playwright/package.json @@ -3,6 +3,7 @@ "private": true, "scripts": { "dev": "N8N_BASE_URL=http://localhost:5678 N8N_EDITOR_URL=http://localhost:8080 RESET_E2E_DB=true playwright test --project=e2e", + "test:dev-server-smoke": "N8N_BASE_URL=http://localhost:5678 N8N_EDITOR_URL=http://localhost:8080 RESET_E2E_DB=true playwright test --project=dev-server-smoke", "test:all": "playwright test", "test:local": "N8N_BASE_URL=http://localhost:5680 RESET_E2E_DB=true playwright test --project=e2e", "test:local:isolated": "node scripts/run-local-isolated.mjs", diff --git a/packages/testing/playwright/playwright-projects.ts b/packages/testing/playwright/playwright-projects.ts index a09c88c05cf..bcf2f59f0cb 100644 --- a/packages/testing/playwright/playwright-projects.ts +++ b/packages/testing/playwright/playwright-projects.ts @@ -172,6 +172,15 @@ export function getProjects(): Project[] { fullyParallel: true, use: { baseURL: getFrontendUrl() }, }); + projects.push({ + name: 'dev-server-smoke', + testDir: './tests/dev-server-smoke', + fullyParallel: false, + // Vite dev cold-start can take 15-25s on the first navigation while it + // pre-bundles deps. Subsequent navigations are sub-second. + timeout: 90_000, + use: { baseURL: getFrontendUrl(), navigationTimeout: 30_000 }, + }); } else { for (const { name, config } of CONTAINER_CONFIGS) { projects.push( diff --git a/packages/testing/playwright/tests/dev-server-smoke/dev-server-boots.spec.ts b/packages/testing/playwright/tests/dev-server-smoke/dev-server-boots.spec.ts new file mode 100644 index 00000000000..b2f5bc52611 --- /dev/null +++ b/packages/testing/playwright/tests/dev-server-smoke/dev-server-boots.spec.ts @@ -0,0 +1,112 @@ +import type { ConsoleMessage, Page } from '@playwright/test'; + +import { test } from '../../fixtures/base'; + +/** + * Smoke tests that catch app-wide regressions where the editor-ui fails to boot — + * for example a workspace package whose import is silently broken in dev mode + * (missing Vite alias, stale dist-only re-export, CJS interop failure, etc.). + * + * These tests visit a small set of representative routes and fail the suite if + * any error-level console message or uncaught page error is observed during the + * load. They are intentionally light — UI behaviour is covered elsewhere. + * + * Must run against the Vite dev server (`N8N_EDITOR_URL` set), which is what the + * `test:dev-server-smoke` script wires up. + */ + +const BENIGN_PATTERNS: Array<{ messageRe: RegExp; reason: string }> = [ + { + messageRe: /\[vite\] (server connection lost|connecting\.\.\.)/, + reason: 'HMR transport noise', + }, +]; + +const isBenign = (text: string) => BENIGN_PATTERNS.some((p) => p.messageRe.test(text)); + +const navigateAndAssertNoErrors = async ( + page: Page, + label: string, + navigate: () => Promise, +) => { + const consoleErrors: string[] = []; + const pageErrors: string[] = []; + + const onConsole = (message: ConsoleMessage) => { + if (message.type() !== 'error') return; + if (isBenign(message.text())) return; + consoleErrors.push(`${message.text()} (at ${message.location().url ?? ''})`); + }; + const onPageError = (error: Error) => { + const firstFrame = error.stack?.split('\n')[1]?.trim() ?? ''; + pageErrors.push(`${error.name}: ${error.message}\n ${firstFrame}`); + }; + + page.on('console', onConsole); + page.on('pageerror', onPageError); + + // When dev-mode module resolution is broken, the JS app never bootstraps — + // so entry points like `fromHome()` time out waiting for the post-redirect + // URL. That timeout would mask the real cause (the SyntaxError captured + // below). Capture any navigation failure and surface page/console errors + // as the primary diagnostic when both happened. + let navigationError: Error | undefined; + try { + await navigate(); + // `load` (and not `networkidle`) is the project convention; entry points + // already wait for landing-zone elements, so by the time `load` fires all + // initial module evaluation has completed and any SyntaxError-on-import + // has surfaced to either pageerror or console.error. + await page.waitForLoadState('load', { timeout: 15_000 }); + } catch (error) { + navigationError = error instanceof Error ? error : new Error(String(error)); + } finally { + page.off('console', onConsole); + page.off('pageerror', onPageError); + } + + if (pageErrors.length > 0 || consoleErrors.length > 0) { + const sections = [ + pageErrors.length > 0 && `Uncaught page errors:\n ${pageErrors.join('\n ')}`, + consoleErrors.length > 0 && `Error-level console messages:\n ${consoleErrors.join('\n ')}`, + navigationError && + `Navigation also failed (likely a downstream effect): ${navigationError.message.split('\n')[0]}`, + ].filter(Boolean); + throw new Error(`[${label}] dev-server boot failed.\n\n${sections.join('\n\n')}`); + } + + if (navigationError) throw navigationError; +}; + +test.describe( + 'Dev-server boot smoke', + { + annotation: [ + { + type: 'description', + description: + 'Boots representative routes against the Vite dev server and fails on any error-level console message or uncaught page error during load.', + }, + ], + }, + () => { + test('home page boots cleanly', async ({ n8n }) => { + await navigateAndAssertNoErrors(n8n.page, 'home', async () => { + await n8n.start.fromHome(); + }); + }); + + test('blank canvas boots cleanly', async ({ n8n }) => { + await navigateAndAssertNoErrors(n8n.page, 'blank-canvas', async () => { + await n8n.start.fromBlankCanvas(); + }); + }); + + test('credentials page boots cleanly', async ({ n8n }) => { + await navigateAndAssertNoErrors(n8n.page, 'credentials', async () => { + await n8n.start.fromHome(); + await n8n.navigate.toCredentials(); + }); + }); + }, +); diff --git a/scripts/os-normalize.mjs b/scripts/os-normalize.mjs index 0e26366cca8..8f03175262b 100644 --- a/scripts/os-normalize.mjs +++ b/scripts/os-normalize.mjs @@ -54,4 +54,15 @@ cd(dir); const cmd = normalizeCommand(run); echo(chalk.cyan(`$ Running (dir: ${dir}) ${cmd} ${args.join(' ')}`)); -await $({ stdio: 'inherit' })`${cmd} ${args}`; +try { + await $({ stdio: 'inherit' })`${cmd} ${args}`; +} catch (err) { + // Forward signal-based exits silently (e.g. Playwright's globalTeardown + // SIGKILLs the n8n process, or the user Ctrl-C's `pnpm start`). Without + // this, zx logs an unhandled `_ProcessOutput` traceback as the parent + // is already shutting us down — pure noise. + if (err?.signal || err?.exitCode === 137 || err?.exitCode === 143) { + process.exit(err.exitCode ?? 0); + } + throw err; +}