Setting the file. One moment.
Capture Canvas · N8n:testmax N8n AI · n8n-io/n8n · Skills Docs
ContentsBack to the top of the page (opens in a new tab)
scripts/ capture_canvas.mjs
JavaScript · 162 lines · 6 KB
12
13 const runDir = resolve (process.argv[ 2 ] ?? '.' );
14
15 // The status icons that CanvasNodeStatusIcons.vue draws on a node. n8n computes them from the node
16 // type description, so they are the verdict of n8n itself on each node.
17 const ISSUE_MARKERS = [ 'node-issues' , 'node-setup-required' , 'node-not-installed' , 'node-restricted' ];
18
19 /**
20 * The status icon of each node, with the issues that the editor computed for it. The tooltips
21 * are teleported and show after a delay, so the issues come from the workflow document store.
22 */
23 async function readIssues ( page , workflowId ) {
24 return await page. evaluate (
25 ({ markers , workflowId }) => {
26 const icons = {};
27 for ( const el of document. querySelectorAll (
28 markers. map (( m ) => `[data-node-name] [data-test-id="${ m }"]` ). join ( ',' ),
29 )) {
30 icons[el. closest ( '[data-node-name]' ).dataset.nodeName] = el.dataset.testId;
31 }
32 const pinia = document. querySelector ( '#app' )?.__vue_app__?.config.globalProperties.$pinia;
33 const key = [ ... (pinia?._s. keys () ?? [])]. find (( k ) =>
34 k. startsWith ( `workflowDocuments/${ workflowId }@` ),
35 );
36 const storeNodes = key ? (pinia._s. get (key).allNodes ?? []) : [];
37 const nodes = storeNodes
38 . filter (( n ) => icons[n.name] || (n.issues && Object. keys (n.issues). length ))
39 . map (( n ) => ({
40 name: n.name,
41 type: n.type,
42 marker: icons[n.name] ?? null ,
43 issues: n.issues ?? {},
44 }));
45 for ( const [ name , marker ] of Object. entries (icons)) {
46 if ( ! nodes. some (( n ) => n.name === name)) nodes. push ({ name, marker, issues: {} });
47 }
48 return { markers, store: Boolean (key), nodes };
49 },
50 { markers: ISSUE_MARKERS , workflowId },
51 );
52 }
53
54 const runs = readdirSync (runDir, { withFileTypes: true })
55 . filter (( d ) => d. isDirectory () && existsSync ( join (runDir, d.name, 'run.json' )))
56 . map (( d ) => ({
57 dir: join (runDir, d.name),
58 meta: JSON . parse ( readFileSync ( join (runDir, d.name, 'run.json' ), 'utf8' )),
59 }))
60 . filter (( r ) => r.meta.workflowId);
61
62 if (runs. length === 0 ) {
63 console. log ( 'No run has a workflowId. Nothing to capture.' );
64 process. exit ( 0 );
65 }
66
67 const browser = await launchBrowser ();
68 const VIEWPORT = { width: 1800 , height: 1000 };
69 const context = await browser. newContext ({ viewport: VIEWPORT , deviceScaleFactor: 2 });
70 const page = await context. newPage ();
71 await signIn (page);
72
73 for ( const { dir , meta } of runs) {
74 const id = meta.workflowId;
75 const res = await page.request. get ( `${ baseUrl }/rest/workflows/${ id }` , {
76 headers: {
77 'browser-id' : await page. evaluate (() => localStorage. getItem ( 'n8n-browserId' ) ?? '' ),
78 },
79 });
80 if (res. ok ())
81 writeFileSync ( join (dir, 'workflow.json' ), JSON . stringify (( await res. json ()).data, null , 1 ));
82 else console. log ( `${ id }: workflow fetch failed with ${ res . status () }` );
83
84 await page. goto ( `${ baseUrl }/workflow/${ id }` );
85 try {
86 await page. locator ( '[data-test-id="canvas-node"]' ). first (). waitFor ({ timeout: 30_000 });
87 } catch {
88 console. log ( `${ id }: no canvas nodes, skipped` );
89 continue ;
90 }
91 // Expanding one group re-renders the others, so repeat until no collapsed group is left.
92 for ( let round = 0 ; round < 5 ; round ++ ) {
93 const clicked = await page. evaluate (() => {
94 const buttons = [ ... document. querySelectorAll ( 'button[aria-label="Expand"]' )];
95 buttons. forEach (( b ) => b. click ());
96 return buttons. length ;
97 });
98 if (clicked === 0 ) break ;
99 await page. waitForTimeout ( 400 );
100 }
101 // The layout moves after groups expand. Fit twice so the second fit sees the final size.
102 for ( let i = 0 ; i < 2 ; i ++ ) {
103 await page. waitForTimeout ( 1000 );
104 await page
105 . locator ( '[data-test-id="zoom-to-fit"]' )
106 . click ({ timeout: 5_000 })
107 . catch (() => {});
108 }
109 await page. waitForTimeout ( 800 );
110
111 const box = await page. evaluate (() => {
112 const rects = [ ... document. querySelectorAll ( '.vue-flow__node' )]. map (( n ) =>
113 n. getBoundingClientRect (),
114 );
115 if (rects. length === 0 ) return null ;
116 // Labels, subtitles and expanded groups overflow their node element, so use the descendants too.
117 // Only count what draws: hit areas and hover toolbars are empty boxes around the node.
118 const draws = ( el ) =>
119 el instanceof SVGSVGElement ||
120 el instanceof HTMLImageElement ||
121 [ ... el.childNodes]. some (( c ) => c.nodeType === Node. TEXT_NODE && c.textContent. trim ());
122 const visible = ( el ) => {
123 for ( let e = el; e && ! e.classList. contains ( 'vue-flow__node' ); e = e.parentElement) {
124 const style = getComputedStyle (e);
125 if (
126 style.visibility === 'hidden' ||
127 style.display === 'none' ||
128 Number (style.opacity) === 0
129 )
130 return false ;
131 }
132 return true ;
133 };
134 const all = rects. concat (
135 [ ... document. querySelectorAll ( '.vue-flow__node *' )]
136 . filter (( el ) => draws (el) && visible (el))
137 . map (( n ) => n. getBoundingClientRect ())
138 . filter (( r ) => r.width > 0 && r.height > 0 ),
139 );
140 const left = Math. min ( ... all. map (( r ) => r.left));
141 const top = Math. min ( ... all. map (( r ) => r.top));
142 const right = Math. max ( ... all. map (( r ) => r.right));
143 const bottom = Math. max ( ... all. map (( r ) => r.bottom));
144 return { left, top, right, bottom };
145 });
146 const pad = 40 ;
147 const clip = box
148 ? {
149 x: Math. max ( 0 , box.left - pad),
150 y: Math. max ( 0 , box.top - pad),
151 width: Math. min ( VIEWPORT .width, box.right + pad) - Math. max ( 0 , box.left - pad),
152 height: Math. min ( VIEWPORT .height, box.bottom + pad) - Math. max ( 0 , box.top - pad),
153 }
154 : undefined ;
155 await page. screenshot ({ path: join (dir, 'canvas.png' ), clip });
156 console. log ( `${ id }: canvas.png written` );
157 const issues = await readIssues (page, id);
158 writeFileSync ( join (dir, 'canvas-issues.json' ), JSON . stringify (issues, null , 1 ));
159 console. log ( `${ id }: canvas-issues.json written (${ issues . nodes . length } nodes with a status icon)` );
160 }
161
162 await browser. close ();