test: Add Playwright smoke spec for Vite dev-server boot (#29539)

This commit is contained in:
Csaba Tuncsik 2026-05-06 10:49:25 +02:00 committed by GitHub
parent 701f9a4627
commit d4e9705749
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 219 additions and 6 deletions

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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",

View File

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

View File

@ -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<void>,
) => {
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 ?? '<unknown>'})`);
};
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();
});
});
},
);

View File

@ -54,4 +54,15 @@ cd(dir);
const cmd = normalizeCommand(run);
echo(chalk.cyan(`$ Running (dir: ${dir}) ${cmd} ${args.join(' ')}`));
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;
}