#!/usr/bin/env node // Run one scenario script in a browser that is signed in to an n8n instance. // // node run_scenario.mjs --out --script [--live] [--auto-confirm] // [--timeout-min 10] [--viewport 1600x1000] // // N8N_BASE_URL selects the instance. Writes to : // steps.json the steps, checks and captures with their screenshots (updated while it runs) // step-NN.png ... one screenshot for each step, check and capture // captures/.png screens for the visual check, with .aria.yml // live.png the screen every 2.5 seconds, for the live page (with --live) // screen.webm the video, with screen-poster.png // console.json browser errors and failed requests to the instance // result.json {status: passed|failed|error, error, threads, prompts, startedAt, finishedAt, ...} // Prints "thread " for each thread that the scenario opens, so run.py can read its events. import { randomUUID } from 'node:crypto'; import { copyFileSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { parseArgs } from 'node:util'; import { baseUrl, launchBrowser, selectors, setting, signIn, } from '../../testmax-n8n-ai/scripts/n8n_browser.mjs'; import { StepFailed, createKit } from './kit.mjs'; const { values: args } = parseArgs({ options: { out: { type: 'string' }, script: { type: 'string' }, live: { type: 'boolean', default: false }, 'auto-confirm': { type: 'boolean', default: false }, 'timeout-min': { type: 'string', default: '10' }, viewport: { type: 'string', default: '1600x1000' }, }, }); const out = resolve(args.out); mkdirSync(out, { recursive: true }); const [width, height] = args.viewport.split('x').map(Number); const size = { width, height }; const LIVE_SHOT_MS = 2500; const emit = (line) => console.log(line.replace(/\s+/g, ' ').slice(0, 300)); /** * Sign in through the REST API, which is about 1.3 seconds faster than the sign-in page. The auth cookie * is bound to the browser-id header of the login, so the page gets the same id in localStorage. * Falls back to the sign-in page. */ async function signedInState(browser) { const login = await browser.newContext(); try { const browserId = randomUUID(); const res = await login.request.post(`${baseUrl}/rest/login`, { headers: { 'browser-id': browserId }, data: { emailOrLdapLoginId: setting('N8N_EVAL_EMAIL', 'nathan@n8n.io'), password: setting('N8N_EVAL_PASSWORD', 'PlaywrightTest123'), }, }); if (res.ok()) { const state = await login.storageState(); state.origins = [ { origin: new URL(baseUrl).origin, localStorage: [{ name: 'n8n-browserId', value: browserId }], }, ]; return state; } await signIn(await login.newPage()); return await login.storageState(); } finally { await login.close(); } } selectors.setTestIdAttribute('data-test-id'); const browser = await launchBrowser(); const storageState = await signedInState(browser); // The video starts after the sign-in, at the first page of the scenario. const videoDir = mkdtempSync(join(tmpdir(), 'feature-test-video-')); const context = await browser.newContext({ viewport: size, storageState, recordVideo: { dir: videoDir, size }, }); const page = await context.newPage(); const origin = new URL(baseUrl).origin; const consoleErrors = []; const requestErrors = []; page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push({ text: m.text().slice(0, 1000), url: m.location()?.url }); }); page.on('pageerror', (e) => consoleErrors.push({ text: `Uncaught: ${e.message}`.slice(0, 1000) })); page.on('response', (r) => { if (r.status() >= 400 && r.url().startsWith(origin)) { requestErrors.push({ status: r.status(), method: r.request().method(), url: r.url().slice(origin.length), }); } }); page.on('requestfailed', (r) => { const failure = r.failure()?.errorText ?? ''; // Closed event streams and aborted navigations are normal. if (r.url().startsWith(origin) && !/ERR_ABORTED|NS_BINDING_ABORTED/.test(failure)) { requestErrors.push({ status: 0, method: r.method(), url: r.url().slice(origin.length), error: failure, }); } }); let shooting = false; async function liveShot() { if (shooting) return; shooting = true; const partial = join(out, 'live.tmp.png'); try { await page.screenshot({ path: partial }); renameSync(partial, join(out, 'live.png')); } catch { // The page can be in the middle of a navigation. The next interval tries again. } finally { shooting = false; } } const liveTimer = args.live ? setInterval(liveShot, LIVE_SHOT_MS) : null; const { t, threads, prompts, steps, stop } = createKit({ page, out, baseUrl, autoConfirm: args['auto-confirm'], emit, }); const result = { status: 'passed', startedAt: new Date().toISOString(), baseUrl }; const timeoutMs = Number(args['timeout-min']) * 60_000; let timer; try { const scenario = (await import(pathToFileURL(resolve(args.script)).href)).default; if (typeof scenario !== 'function') throw new Error('The script must export a default async function (t).'); await Promise.race([ scenario(t), new Promise((_, reject) => { timer = setTimeout(() => { // The race does not end the scenario function. stop() makes its next step throw. stop(); reject(new Error(`The scenario did not finish in ${args['timeout-min']} minutes.`)); }, timeoutMs); }), ]); if (steps.some((s) => s.status === 'failed')) result.status = 'failed'; } catch (error) { result.status = error instanceof StepFailed || steps.length ? 'failed' : 'error'; result.error = String(error?.message ?? error) .replace(/\u001b\[[0-9;]*m/g, '') .slice(0, 2000); if (!(error instanceof StepFailed)) emit(`error: ${result.error.split('\n')[0]}`); } finally { clearTimeout(timer); } stop(); clearInterval(liveTimer); for (const s of steps.filter((x) => x.status === 'running')) { s.status = 'failed'; s.error = 'The scenario stopped during this step.'; } writeFileSync(join(out, 'steps.json'), JSON.stringify(steps, null, 1)); await page.waitForTimeout(1500); if (args.live) await liveShot(); await page.screenshot({ path: join(out, 'screen-poster.png') }).catch(() => {}); const video = page.video(); await context.close(); if (video) copyFileSync(await video.path(), join(out, 'screen.webm')); await browser.close(); rmSync(videoDir, { recursive: true, force: true }); Object.assign(result, { finishedAt: new Date().toISOString(), threads, prompts, consoleErrors: consoleErrors.length, requestErrors: requestErrors.length, }); writeFileSync(join(out, 'console.json'), JSON.stringify({ consoleErrors, requestErrors }, null, 1)); writeFileSync(join(out, 'result.json'), JSON.stringify(result, null, 2)); emit(`result ${result.status}`); // A scenario that timed out can still wait on its own timers. The results are written, so end here. process.exit(0);