Setting the file. One moment.
Run Scenario · N8n:testmax Assistant UI · n8n-io/n8n · Skills Docs
ContentsBack to the top of the page scripts/run_scenario.mjs
scripts/ run_scenario.mjs
JavaScript · 203 lines · 7 KB
14
// result.json {status: passed|failed|error, error, threads, prompts, startedAt, finishedAt, ...}
15 // Prints "thread <id>" for each thread that the scenario opens, so run.py can read its events.
16 import { randomUUID } from 'node:crypto' ;
17 import { copyFileSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs' ;
18 import { tmpdir } from 'node:os' ;
19 import { join, resolve } from 'node:path' ;
20 import { pathToFileURL } from 'node:url' ;
21 import { parseArgs } from 'node:util' ;
22
23 import {
24 baseUrl,
25 launchBrowser,
26 selectors,
27 setting,
28 signIn,
29 } from '../../testmax-n8n-ai/scripts/n8n_browser.mjs' ;
30 import { StepFailed, createKit } from './kit.mjs' ;
31
32 const { values : args } = parseArgs ({
33 options: {
34 out: { type: 'string' },
35 script: { type: 'string' },
36 live: { type: 'boolean' , default: false },
37 'auto-confirm' : { type: 'boolean' , default: false },
38 'timeout-min' : { type: 'string' , default: '10' },
39 viewport: { type: 'string' , default: '1600x1000' },
40 },
41 });
42 const out = resolve (args.out);
43 mkdirSync (out, { recursive: true });
44 const [ width , height ] = args.viewport. split ( 'x' ). map (Number);
45 const size = { width, height };
46 const LIVE_SHOT_MS = 2500 ;
47 const emit = ( line ) => console. log (line. replace ( / \s + / g , ' ' ). slice ( 0 , 300 ));
48
49 /**
50 * Sign in through the REST API, which is about 1.3 seconds faster than the sign-in page. The auth cookie
51 * is bound to the browser-id header of the login, so the page gets the same id in localStorage.
52 * Falls back to the sign-in page.
53 */
54 async function signedInState ( browser ) {
55 const login = await browser. newContext ();
56 try {
57 const browserId = randomUUID ();
58 const res = await login.request. post ( `${ baseUrl }/rest/login` , {
59 headers: { 'browser-id' : browserId },
60 data: {
61 emailOrLdapLoginId: setting ( 'N8N_EVAL_EMAIL' , 'nathan@n8n.io' ),
62 password: setting ( 'N8N_EVAL_PASSWORD' , 'PlaywrightTest123' ),
63 },
64 });
65 if (res. ok ()) {
66 const state = await login. storageState ();
67 state.origins = [
68 {
69 origin: new URL (baseUrl).origin,
70 localStorage: [{ name: 'n8n-browserId' , value: browserId }],
71 },
72 ];
73 return state;
74 }
75 await signIn ( await login. newPage ());
76 return await login. storageState ();
77 } finally {
78 await login. close ();
79 }
80 }
81
82 selectors. setTestIdAttribute ( 'data-test-id' );
83 const browser = await launchBrowser ();
84 const storageState = await signedInState (browser);
85
86 // The video starts after the sign-in, at the first page of the scenario.
87 const videoDir = mkdtempSync ( join ( tmpdir (), 'feature-test-video-' ));
88 const context = await browser. newContext ({
89 viewport: size,
90 storageState,
91 recordVideo: { dir: videoDir, size },
92 });
93 const page = await context. newPage ();
94
95 const origin = new URL (baseUrl).origin;
96 const consoleErrors = [];
97 const requestErrors = [];
98 page. on ( 'console' , ( m ) => {
99 if (m. type () === 'error' )
100 consoleErrors. push ({ text: m. text (). slice ( 0 , 1000 ), url: m. location ()?.url });
101 });
102 page. on ( 'pageerror' , ( e ) => consoleErrors. push ({ text: `Uncaught: ${ e . message }` . slice ( 0 , 1000 ) }));
103 page. on ( 'response' , ( r ) => {
104 if (r. status () >= 400 && r. url (). startsWith (origin)) {
105 requestErrors. push ({
106 status: r. status (),
107 method: r. request (). method (),
108 url: r. url (). slice (origin. length ),
109 });
110 }
111 });
112 page. on ( 'requestfailed' , ( r ) => {
113 const failure = r. failure ()?.errorText ?? '' ;
114 // Closed event streams and aborted navigations are normal.
115 if (r. url (). startsWith (origin) && ! /ERR_ABORTED | NS_BINDING_ABORTED/ . test (failure)) {
116 requestErrors. push ({
117 status: 0 ,
118 method: r. method (),
119 url: r. url (). slice (origin. length ),
120 error: failure,
121 });
122 }
123 });
124
125 let shooting = false ;
126 async function liveShot () {
127 if (shooting) return ;
128 shooting = true ;
129 const partial = join (out, 'live.tmp.png' );
130 try {
131 await page. screenshot ({ path: partial });
132 renameSync (partial, join (out, 'live.png' ));
133 } catch {
134 // The page can be in the middle of a navigation. The next interval tries again.
135 } finally {
136 shooting = false ;
137 }
138 }
139 const liveTimer = args.live ? setInterval (liveShot, LIVE_SHOT_MS ) : null ;
140
141 const { t , threads , prompts , steps , stop } = createKit ({
142 page,
143 out,
144 baseUrl,
145 autoConfirm: args[ 'auto-confirm' ],
146 emit,
147 });
148 const result = { status: 'passed' , startedAt: new Date (). toISOString (), baseUrl };
149 const timeoutMs = Number (args[ 'timeout-min' ]) * 60_000 ;
150 let timer;
151 try {
152 const scenario = ( await import ( pathToFileURL ( resolve (args.script)).href)).default;
153 if ( typeof scenario !== 'function' )
154 throw new Error ( 'The script must export a default async function (t).' );
155 await Promise . race ([
156 scenario (t),
157 new Promise (( _ , reject ) => {
158 timer = setTimeout (() => {
159 // The race does not end the scenario function. stop() makes its next step throw.
160 stop ();
161 reject ( new Error ( `The scenario did not finish in ${ args [ 'timeout-min' ] } minutes.` ));
162 }, timeoutMs);
163 }),
164 ]);
165 if (steps. some (( s ) => s.status === 'failed' )) result.status = 'failed' ;
166 } catch (error) {
167 result.status = error instanceof StepFailed || steps. length ? 'failed' : 'error' ;
168 result.error = String (error?.message ?? error)
169 . replace ( / \u001b \[ [0-9;] * m/ g , '' )
170 . slice ( 0 , 2000 );
171 if ( ! (error instanceof StepFailed )) emit ( `error: ${ result . error . split ( ' \n ' )[ 0 ] }` );
172 } finally {
173 clearTimeout (timer);
174 }
175
176 stop ();
177 clearInterval (liveTimer);
178 for ( const s of steps. filter (( x ) => x.status === 'running' )) {
179 s.status = 'failed' ;
180 s.error = 'The scenario stopped during this step.' ;
181 }
182 writeFileSync ( join (out, 'steps.json' ), JSON . stringify (steps, null , 1 ));
183 await page. waitForTimeout ( 1500 );
184 if (args.live) await liveShot ();
185 await page. screenshot ({ path: join (out, 'screen-poster.png' ) }). catch (() => {});
186 const video = page. video ();
187 await context. close ();
188 if (video) copyFileSync ( await video. path (), join (out, 'screen.webm' ));
189 await browser. close ();
190 rmSync (videoDir, { recursive: true , force: true });
191
192 Object. assign (result, {
193 finishedAt: new Date (). toISOString (),
194 threads,
195 prompts,
196 consoleErrors: consoleErrors. length ,
197 requestErrors: requestErrors. length ,
198 });
199 writeFileSync ( join (out, 'console.json' ), JSON . stringify ({ consoleErrors, requestErrors }, null , 1 ));
200 writeFileSync ( join (out, 'result.json' ), JSON . stringify (result, null , 2 ));
201 emit ( `result ${ result . status }` );
202 // A scenario that timed out can still wait on its own timers. The results are written, so end here.
203 process. exit ( 0 );