mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
test: Add Playwright smoke spec for Vite dev-server boot (#29539)
This commit is contained in:
parent
701f9a4627
commit
d4e9705749
25
.github/workflows/ci-pull-requests.yml
vendored
25
.github/workflows/ci-pull-requests.yml
vendored
|
|
@ -22,6 +22,7 @@ jobs:
|
||||||
ci: ${{ fromJSON(steps.ci-filter.outputs.results).ci == true }}
|
ci: ${{ fromJSON(steps.ci-filter.outputs.results).ci == true }}
|
||||||
unit: ${{ fromJSON(steps.ci-filter.outputs.results).unit == true }}
|
unit: ${{ fromJSON(steps.ci-filter.outputs.results).unit == true }}
|
||||||
e2e: ${{ fromJSON(steps.ci-filter.outputs.results).e2e == 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 }}
|
workflows: ${{ fromJSON(steps.ci-filter.outputs.results).workflows == true }}
|
||||||
workflow_scripts: ${{ fromJSON(steps.ci-filter.outputs.results)['workflow-scripts'] == true }}
|
workflow_scripts: ${{ fromJSON(steps.ci-filter.outputs.results)['workflow-scripts'] == true }}
|
||||||
db: ${{ fromJSON(steps.ci-filter.outputs.results).db == true }}
|
db: ${{ fromJSON(steps.ci-filter.outputs.results).db == true }}
|
||||||
|
|
@ -63,6 +64,16 @@ jobs:
|
||||||
.github/actions/load-n8n-docker/**
|
.github/actions/load-n8n-docker/**
|
||||||
packages/testing/playwright/**
|
packages/testing/playwright/**
|
||||||
packages/testing/containers/**
|
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/**
|
workflows: .github/**
|
||||||
workflow-scripts: .github/scripts/**
|
workflow-scripts: .github/scripts/**
|
||||||
performance:
|
performance:
|
||||||
|
|
@ -221,6 +232,19 @@ jobs:
|
||||||
upload-failure-artifacts: ${{ github.event.pull_request.head.repo.fork == true }}
|
upload-failure-artifacts: ${{ github.event.pull_request.head.repo.fork == true }}
|
||||||
secrets: inherit
|
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:
|
db-tests:
|
||||||
name: DB Tests
|
name: DB Tests
|
||||||
needs: install-and-build
|
needs: install-and-build
|
||||||
|
|
@ -296,6 +320,7 @@ jobs:
|
||||||
check-packaging,
|
check-packaging,
|
||||||
sqlite-sanity,
|
sqlite-sanity,
|
||||||
e2e,
|
e2e,
|
||||||
|
dev-server-smoke,
|
||||||
db-tests,
|
db-tests,
|
||||||
performance,
|
performance,
|
||||||
security-checks,
|
security-checks,
|
||||||
|
|
|
||||||
49
.github/workflows/test-dev-server-smoke-reusable.yml
vendored
Normal file
49
.github/workflows/test-dev-server-smoke-reusable.yml
vendored
Normal 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
|
||||||
|
|
@ -7,12 +7,18 @@ function globalTeardown() {
|
||||||
|
|
||||||
for (const port of ports) {
|
for (const port of ports) {
|
||||||
try {
|
try {
|
||||||
// Find process ID using the port
|
// `lsof -ti` returns one PID per line. Dev-mode n8n holds the port
|
||||||
const pid = execSync(`lsof -ti :${port}`, { encoding: 'utf-8' }).trim();
|
// 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) {
|
if (pids.length > 0) {
|
||||||
console.log(`- Killing process ${pid} on port ${port}`);
|
console.log(`- Killing process(es) ${pids.join(', ')} on port ${port}`);
|
||||||
execSync(`kill -9 ${pid}`);
|
execSync(`kill -9 ${pids.join(' ')}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// lsof returns non-zero exit code if no process is found
|
// lsof returns non-zero exit code if no process is found
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "N8N_BASE_URL=http://localhost:5678 N8N_EDITOR_URL=http://localhost:8080 RESET_E2E_DB=true playwright test --project=e2e",
|
"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:all": "playwright test",
|
||||||
"test:local": "N8N_BASE_URL=http://localhost:5680 RESET_E2E_DB=true playwright test --project=e2e",
|
"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",
|
"test:local:isolated": "node scripts/run-local-isolated.mjs",
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,15 @@ export function getProjects(): Project[] {
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
use: { baseURL: getFrontendUrl() },
|
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 {
|
} else {
|
||||||
for (const { name, config } of CONTAINER_CONFIGS) {
|
for (const { name, config } of CONTAINER_CONFIGS) {
|
||||||
projects.push(
|
projects.push(
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -54,4 +54,15 @@ cd(dir);
|
||||||
const cmd = normalizeCommand(run);
|
const cmd = normalizeCommand(run);
|
||||||
|
|
||||||
echo(chalk.cyan(`$ Running (dir: ${dir}) ${cmd} ${args.join(' ')}`));
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user