@qe-mcp/server-ena 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/launch.json +11 -0
- package/.gitlab-ci.yml +88 -0
- package/README.md +232 -0
- package/hooks/ena-privacy-gate.mjs +29 -0
- package/package.json +27 -0
- package/qeviz-init.js +11 -0
- package/qeviz-widget.html +50 -0
- package/qeviz.umd.js +3511 -0
- package/server.js +1234 -0
- package/skill/ena-analysis/SKILL.md +82 -0
- package/test-mcp.mjs +189 -0
- package/test-render.mjs +93 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ena-analysis
|
|
3
|
+
description: >
|
|
4
|
+
Run Epistemic Network Analysis (ENA) on tabular coded data using the
|
|
5
|
+
@qe-mcp/server-ena connector. Use whenever the user references tabular data
|
|
6
|
+
with binary presence/absence codes and asks for analysis, exploration,
|
|
7
|
+
co-occurrence patterns, or group comparison — especially discourse data,
|
|
8
|
+
behavioral coding, learning analytics, or clinical observation data.
|
|
9
|
+
Includes a privacy gate for sensitive datasets: raw data can stay entirely
|
|
10
|
+
on the user's machine.
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# ENA Analysis
|
|
14
|
+
|
|
15
|
+
Epistemic Network Analysis models patterns of co-occurrence between coded
|
|
16
|
+
events (discourse moves, behaviors, features) within conversations, projecting
|
|
17
|
+
each unit of analysis into a low-dimensional space where distances are
|
|
18
|
+
interpretable. It reveals structural differences that summary statistics and
|
|
19
|
+
regression miss.
|
|
20
|
+
|
|
21
|
+
This skill drives the **ena** MCP connector (`@qe-mcp/server-ena`). All model
|
|
22
|
+
computation runs locally on the user's machine via WebAssembly — only results
|
|
23
|
+
(node positions, plots, summary statistics) are returned to Claude.
|
|
24
|
+
|
|
25
|
+
## Privacy gate — always ask first
|
|
26
|
+
|
|
27
|
+
Before calling any ENA tool on new data, ask:
|
|
28
|
+
|
|
29
|
+
> "Is this data sensitive or confidential? If so, give me the file path
|
|
30
|
+
> instead of pasting the data — raw data stays on your machine when you use
|
|
31
|
+
> a file path."
|
|
32
|
+
|
|
33
|
+
If the data is sensitive (or the user hasn't answered yet):
|
|
34
|
+
|
|
35
|
+
1. Call `ena_profile` with only `data_path`. It returns column names, unique
|
|
36
|
+
counts, and binary detection — **no raw row data is ever transmitted**.
|
|
37
|
+
2. Use the profile to determine parameters (units, conversations, codes).
|
|
38
|
+
3. Call `ena_fit` / `ena_compare_groups` with `data_path`. Only model results
|
|
39
|
+
reach the conversation.
|
|
40
|
+
|
|
41
|
+
Note: unit labels (values from the unit column, e.g. participant IDs) appear
|
|
42
|
+
in model results. If those are themselves sensitive, warn the user before
|
|
43
|
+
fitting.
|
|
44
|
+
|
|
45
|
+
If the data is not sensitive, or is toy/demo data, proceed with `ena_inspect`
|
|
46
|
+
directly. Prefer `data_path` over `data_csv` in all cases; use `data_csv`
|
|
47
|
+
only for small pasted samples.
|
|
48
|
+
|
|
49
|
+
## When ENA fits the data
|
|
50
|
+
|
|
51
|
+
- Rows are utterances, events, or observations
|
|
52
|
+
- Columns include binary (0/1) presence/absence codes
|
|
53
|
+
- A column identifies who produced each row (unit of analysis)
|
|
54
|
+
- A column groups rows into episodes or conversations
|
|
55
|
+
- Optionally: two or more conditions/groups to compare
|
|
56
|
+
|
|
57
|
+
## Workflow
|
|
58
|
+
|
|
59
|
+
1. **Triage** — `ena_inspect` (or `ena_profile` for sensitive data). Returns
|
|
60
|
+
`ena_recommended`, suggested codes/units/conversations, and a pre-filled
|
|
61
|
+
`next_step` call. If `ena_recommended` is true, propose ENA as the primary
|
|
62
|
+
analysis and briefly explain what it will reveal.
|
|
63
|
+
2. **Fit** — `ena_fit` for a single model, or `ena_compare_groups` when two
|
|
64
|
+
groups exist. Both return a `model_id` and an inline network plot.
|
|
65
|
+
Do not generate your own visualization — the plot comes back as an image.
|
|
66
|
+
3. **Explore** — `ena_plot` with the cached `model_id` renders additional
|
|
67
|
+
views without refitting:
|
|
68
|
+
- `all` — all units
|
|
69
|
+
- `group` — one group's mean network (`group_value`)
|
|
70
|
+
- `comparison` — two group networks overlaid (`group1`, `group2`)
|
|
71
|
+
- `subtraction` — difference network (`group1`, `group2`)
|
|
72
|
+
- `unit` — a single unit's network (`unit_label`)
|
|
73
|
+
4. **Interpret** — strong connections indicate codes that co-occur within
|
|
74
|
+
the stanza window; in subtraction plots, edge color/direction shows which
|
|
75
|
+
group each connection favors. Node positions are fixed across views of the
|
|
76
|
+
same model, so plots are directly comparable.
|
|
77
|
+
|
|
78
|
+
Each fit/plot response also includes `interactive_plot` — a local HTML file
|
|
79
|
+
path with the interactive visualization the user can open in a browser.
|
|
80
|
+
|
|
81
|
+
Repeated calls with identical parameters return the cached model instantly,
|
|
82
|
+
so iterating on views is cheap.
|
package/test-mcp.mjs
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* test-mcp.mjs — drive the MCP server over newline-delimited JSON-RPC.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* node test-mcp.mjs
|
|
6
|
+
* node test-mcp.mjs /path/to/data.csv UnitCol ConvoCol Code1 Code2 ...
|
|
7
|
+
*
|
|
8
|
+
* PNG images are saved to /tmp/ena-test-N.png and opened automatically.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn } from 'child_process';
|
|
12
|
+
import { writeFileSync } from 'fs';
|
|
13
|
+
import { createInterface } from 'readline';
|
|
14
|
+
import { fileURLToPath } from 'url';
|
|
15
|
+
import { dirname, join } from 'path';
|
|
16
|
+
|
|
17
|
+
const DIR = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
19
|
+
// ── configure ─────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
const DATA_PATH = process.argv[2] ?? null;
|
|
22
|
+
// UNIT_COL/CONVO_COL accept a single string or comma-separated list for compound columns
|
|
23
|
+
const UNIT_COL = process.argv[3] ?? 'ENA_UNIT';
|
|
24
|
+
const CONVO_COL = process.argv[4] ?? 'Condition';
|
|
25
|
+
const CODES = process.argv.slice(5).length
|
|
26
|
+
? process.argv.slice(5)
|
|
27
|
+
: ['CodeA', 'CodeB', 'CodeC'];
|
|
28
|
+
|
|
29
|
+
// For rs.data.new.csv group comparison:
|
|
30
|
+
// units=['UserName','Condition'] conversations='GameDay' group_col='Condition'
|
|
31
|
+
// group1='FirstGame' group2='SecondGame'
|
|
32
|
+
const GROUP_COL = DATA_PATH ? 'Condition' : null;
|
|
33
|
+
const GROUP1 = DATA_PATH ? 'FirstGame' : null;
|
|
34
|
+
const GROUP2 = DATA_PATH ? 'SecondGame' : null;
|
|
35
|
+
|
|
36
|
+
// Compound units: each UserName×Condition pair is a separate unit
|
|
37
|
+
const UNIT_COLS = DATA_PATH ? ['UserName', 'Condition'] : UNIT_COL;
|
|
38
|
+
const CONVO_COLS = DATA_PATH ? 'GameDay' : CONVO_COL;
|
|
39
|
+
|
|
40
|
+
const INLINE_CSV = `ENA_UNIT,Condition,CodeA,CodeB,CodeC
|
|
41
|
+
u1,A,1,0,1
|
|
42
|
+
u1,A,0,1,1
|
|
43
|
+
u2,A,1,1,0
|
|
44
|
+
u2,A,0,0,1
|
|
45
|
+
u3,B,1,1,0
|
|
46
|
+
u3,B,1,0,1
|
|
47
|
+
u4,B,0,1,1
|
|
48
|
+
u4,B,1,1,0`;
|
|
49
|
+
|
|
50
|
+
// ── transport (newline-delimited JSON, as used by SDK v1.4) ───────────────────
|
|
51
|
+
|
|
52
|
+
const proc = spawn('node', [join(DIR, 'server.js')], {
|
|
53
|
+
stdio: ['pipe', 'pipe', 'inherit'],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
let _seq = 0;
|
|
57
|
+
const _pending = new Map();
|
|
58
|
+
|
|
59
|
+
const rl = createInterface({ input: proc.stdout, crlfDelay: Infinity });
|
|
60
|
+
rl.on('line', line => {
|
|
61
|
+
if (!line.trim()) return;
|
|
62
|
+
let msg;
|
|
63
|
+
try { msg = JSON.parse(line); } catch { return; }
|
|
64
|
+
const cb = _pending.get(msg.id);
|
|
65
|
+
if (cb) { _pending.delete(msg.id); cb(msg); }
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
proc.on('close', () => {
|
|
69
|
+
for (const cb of _pending.values()) cb({ error: { message: 'server closed' } });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
function rpc(method, params) {
|
|
73
|
+
const id = ++_seq;
|
|
74
|
+
return new Promise(resolve => {
|
|
75
|
+
_pending.set(id, resolve);
|
|
76
|
+
proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── result handler ────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
let imgCount = 0;
|
|
83
|
+
|
|
84
|
+
function handleResult(label, msg) {
|
|
85
|
+
if (msg.error) {
|
|
86
|
+
console.log(`[${label}] ✗ ${msg.error.message ?? JSON.stringify(msg.error)}`);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const content = msg.result?.content ?? [];
|
|
90
|
+
let parsed = null;
|
|
91
|
+
for (const block of content) {
|
|
92
|
+
if (block.type === 'image') {
|
|
93
|
+
const path = `/tmp/ena-test-${++imgCount}.png`;
|
|
94
|
+
writeFileSync(path, Buffer.from(block.data, 'base64'));
|
|
95
|
+
console.log(`[${label}] ✓ image → ${path}`);
|
|
96
|
+
}
|
|
97
|
+
if (block.type === 'text') {
|
|
98
|
+
try { parsed = JSON.parse(block.text); } catch { parsed = block.text; }
|
|
99
|
+
if (parsed?._render_error) console.log(` render error: ${parsed._render_error}`);
|
|
100
|
+
if (parsed?.interactive_plot) console.log(` html → ${parsed.interactive_plot}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return parsed;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── test sequence ─────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
async function run() {
|
|
109
|
+
// Handshake
|
|
110
|
+
const initMsg = await rpc('initialize', {
|
|
111
|
+
protocolVersion: '2024-11-05',
|
|
112
|
+
capabilities: {},
|
|
113
|
+
clientInfo: { name: 'test-mcp', version: '1.0' },
|
|
114
|
+
});
|
|
115
|
+
if (initMsg.error) throw new Error('initialize failed: ' + initMsg.error.message);
|
|
116
|
+
// Required: send initialized notification (no response)
|
|
117
|
+
proc.stdin.write(JSON.stringify({ jsonrpc:'2.0', method:'notifications/initialized' }) + '\n');
|
|
118
|
+
|
|
119
|
+
// List tools
|
|
120
|
+
const listMsg = await rpc('tools/list', {});
|
|
121
|
+
const tools = listMsg.result?.tools ?? [];
|
|
122
|
+
console.log(`tools: ${tools.map(t => t.name).join(', ')}\n`);
|
|
123
|
+
|
|
124
|
+
const data = DATA_PATH ? { data_path: DATA_PATH } : { data_csv: INLINE_CSV };
|
|
125
|
+
|
|
126
|
+
// ena_profile (privacy-safe metadata only — only works with data_path)
|
|
127
|
+
if (DATA_PATH) {
|
|
128
|
+
const profile = handleResult('ena_profile',
|
|
129
|
+
await rpc('tools/call', { name: 'ena_profile', arguments: { data_path: DATA_PATH } }));
|
|
130
|
+
if (profile) {
|
|
131
|
+
console.log(` ${profile.privacy_note}`);
|
|
132
|
+
console.log(` binary cols: ${profile.binary_columns?.join(', ')}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ena_inspect
|
|
137
|
+
const inspect = handleResult('ena_inspect',
|
|
138
|
+
await rpc('tools/call', { name: 'ena_inspect', arguments: data }));
|
|
139
|
+
if (inspect) {
|
|
140
|
+
console.log(` recommended=${inspect.ena_recommended} codes: ${inspect.suggested_codes?.join(', ')}`);
|
|
141
|
+
if (inspect.privacy_note) console.log(` ${inspect.privacy_note}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ena_fit
|
|
145
|
+
const fitArgs = {
|
|
146
|
+
...data,
|
|
147
|
+
units: UNIT_COLS, conversations: CONVO_COLS, codes: CODES,
|
|
148
|
+
window_size: 4, binary: true, dims: 2, rotation: 'svd',
|
|
149
|
+
...(GROUP_COL ? { group_col: GROUP_COL } : {}),
|
|
150
|
+
};
|
|
151
|
+
const fit = handleResult('ena_fit',
|
|
152
|
+
await rpc('tools/call', { name: 'ena_fit', arguments: fitArgs }));
|
|
153
|
+
if (!fit) { proc.kill(); return; }
|
|
154
|
+
console.log(` model_id=${fit.model_id} units=${fit.n_units} connections=${fit.n_connections}`);
|
|
155
|
+
|
|
156
|
+
const modelId = fit.model_id;
|
|
157
|
+
|
|
158
|
+
// ena_plot — all units
|
|
159
|
+
handleResult('ena_plot(all)',
|
|
160
|
+
await rpc('tools/call', { name: 'ena_plot', arguments: { model_id: modelId, type: 'all' } }));
|
|
161
|
+
|
|
162
|
+
// ena_plot — first unit
|
|
163
|
+
const firstUnit = fit.units?.[0];
|
|
164
|
+
if (firstUnit) {
|
|
165
|
+
handleResult(`ena_plot(unit=${firstUnit})`,
|
|
166
|
+
await rpc('tools/call', { name: 'ena_plot', arguments: { model_id: modelId, type: 'unit', unit_label: firstUnit } }));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ena_compare_groups + subtraction (if GROUP_COL is set)
|
|
170
|
+
if (GROUP_COL && GROUP1 && GROUP2) {
|
|
171
|
+
const cmpArgs = { ...fitArgs, rotation: undefined, group_col: GROUP_COL, group1_value: GROUP1, group2_value: GROUP2 };
|
|
172
|
+
delete cmpArgs.rotation;
|
|
173
|
+
const cmp = handleResult('ena_compare_groups',
|
|
174
|
+
await rpc('tools/call', { name: 'ena_compare_groups', arguments: cmpArgs }));
|
|
175
|
+
if (cmp) {
|
|
176
|
+
console.log(` model_id=${cmp.model_id} g1=${JSON.stringify(cmp.group1_mean)} g2=${JSON.stringify(cmp.group2_mean)}`);
|
|
177
|
+
handleResult('ena_plot(subtraction)',
|
|
178
|
+
await rpc('tools/call', { name: 'ena_plot', arguments: { model_id: cmp.model_id, type: 'subtraction', group1: GROUP1, group2: GROUP2 } }));
|
|
179
|
+
handleResult('ena_plot(comparison)',
|
|
180
|
+
await rpc('tools/call', { name: 'ena_plot', arguments: { model_id: cmp.model_id, type: 'comparison', group1: GROUP1, group2: GROUP2 } }));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
proc.stdin.end();
|
|
185
|
+
console.log(`\ndone — ${imgCount} image(s) saved`);
|
|
186
|
+
if (imgCount) console.log('open: open /tmp/ena-test-*.png');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
run().catch(err => { console.error(err.message); proc.kill(); });
|
package/test-render.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke test: load WASM, fit a model, render to PNG.
|
|
3
|
+
* Run with: node test-render.mjs
|
|
4
|
+
*/
|
|
5
|
+
import loadENA from '@qe-libs/rena-wasm';
|
|
6
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
7
|
+
import { createRequire } from 'module';
|
|
8
|
+
import { JSDOM } from 'jsdom';
|
|
9
|
+
import { Resvg } from '@resvg/resvg-js';
|
|
10
|
+
|
|
11
|
+
const _require = createRequire(import.meta.url);
|
|
12
|
+
const QEVIZ_BUNDLE = _require.resolve('@qe-libs/qeviz');
|
|
13
|
+
const qevizJS = () => readFileSync(QEVIZ_BUNDLE, 'utf8');
|
|
14
|
+
const SVG_SIZE = 600;
|
|
15
|
+
|
|
16
|
+
// Minimal synthetic dataset
|
|
17
|
+
const rows = [
|
|
18
|
+
{ unit: 'A', convo: 'c1', X: '1', Y: '0', Z: '1' },
|
|
19
|
+
{ unit: 'A', convo: 'c1', X: '0', Y: '1', Z: '1' },
|
|
20
|
+
{ unit: 'B', convo: 'c1', X: '1', Y: '1', Z: '0' },
|
|
21
|
+
{ unit: 'B', convo: 'c1', X: '0', Y: '0', Z: '1' },
|
|
22
|
+
{ unit: 'C', convo: 'c2', X: '1', Y: '1', Z: '0' },
|
|
23
|
+
{ unit: 'C', convo: 'c2', X: '1', Y: '0', Z: '1' },
|
|
24
|
+
];
|
|
25
|
+
const codes = ['X', 'Y', 'Z'];
|
|
26
|
+
|
|
27
|
+
console.log('1. Loading WASM...');
|
|
28
|
+
const ena = await loadENA();
|
|
29
|
+
console.log(' OK');
|
|
30
|
+
|
|
31
|
+
console.log('2. Fitting model...');
|
|
32
|
+
const model = ena.fit(rows, {
|
|
33
|
+
codes,
|
|
34
|
+
units: ['unit'],
|
|
35
|
+
conversations: ['convo'],
|
|
36
|
+
window: 4,
|
|
37
|
+
binary: true,
|
|
38
|
+
dims: 2,
|
|
39
|
+
rotation: 'svd',
|
|
40
|
+
});
|
|
41
|
+
console.log(` OK — ${model.nUnits} units, ${model.nConnections} connections`);
|
|
42
|
+
console.log(` centroids type: ${model.centroids?.constructor?.name}`);
|
|
43
|
+
console.log(` positions type: ${model.positions?.constructor?.name}`);
|
|
44
|
+
console.log(` networks type: ${model.networks?.constructor?.name}`);
|
|
45
|
+
|
|
46
|
+
console.log('3. Building vizData...');
|
|
47
|
+
const edgeColName = (n) => n.replace(' & ', '.');
|
|
48
|
+
const modelData = {
|
|
49
|
+
directed: false, updated: Date.now(),
|
|
50
|
+
id_col: 'ENA_UNIT', node_id_col: 'code', x_col: 'x', y_col: 'y',
|
|
51
|
+
nodes: {
|
|
52
|
+
data: codes.map((code, i) => ({ code, x: model.positions[i*2], y: model.positions[i*2+1] })),
|
|
53
|
+
types: { code: 'character', x: 'numeric', y: 'numeric' },
|
|
54
|
+
},
|
|
55
|
+
edges: {
|
|
56
|
+
data: model.unitLabels.map((lbl, i) => {
|
|
57
|
+
const row = { ENA_UNIT: lbl };
|
|
58
|
+
model.connectionNames.forEach((n, j) => { row[edgeColName(n)] = model.networks[i * model.nConnections + j]; });
|
|
59
|
+
return row;
|
|
60
|
+
}),
|
|
61
|
+
types: Object.fromEntries([['ENA_UNIT','character'], ...model.connectionNames.map(n => [edgeColName(n),'numeric'])]),
|
|
62
|
+
},
|
|
63
|
+
points: {
|
|
64
|
+
data: model.unitLabels.map((lbl, i) => ({ ENA_UNIT: lbl, x: model.centroids[i*2], y: model.centroids[i*2+1] })),
|
|
65
|
+
types: { ENA_UNIT: 'character', x: 'numeric', y: 'numeric' },
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
console.log(' OK');
|
|
69
|
+
|
|
70
|
+
console.log('4. Rendering SVG via jsdom...');
|
|
71
|
+
const dom = new JSDOM(
|
|
72
|
+
`<!DOCTYPE html><body><qe-visual id="vis"><qe-graph id="g" width="${SVG_SIZE}" height="${SVG_SIZE}" label-nodes="on"><qe-points label="auto"></qe-points></qe-graph></qe-visual></body>`,
|
|
73
|
+
{ runScripts: 'dangerously', pretendToBeVisual: true }
|
|
74
|
+
);
|
|
75
|
+
const { window } = dom;
|
|
76
|
+
window.Element.prototype.getBoundingClientRect = () =>
|
|
77
|
+
({ x:0, y:0, width:SVG_SIZE, height:SVG_SIZE, top:0, left:0, right:SVG_SIZE, bottom:SVG_SIZE });
|
|
78
|
+
window.requestAnimationFrame = cb => setTimeout(cb, 0);
|
|
79
|
+
window.console = { log: ()=>{}, info: ()=>{}, warn: ()=>{}, error: ()=>{}, debug: ()=>{} };
|
|
80
|
+
const s = window.document.createElement('script');
|
|
81
|
+
s.textContent = qevizJS();
|
|
82
|
+
window.document.head.appendChild(s);
|
|
83
|
+
window.document.getElementById('vis').setModelData(modelData);
|
|
84
|
+
await new Promise(r => setTimeout(r, 300));
|
|
85
|
+
const svg = window.document.getElementById('g')?.svg_element?.outerHTML ?? null;
|
|
86
|
+
if (!svg) { console.error(' FAIL — svg_element is null'); process.exit(1); }
|
|
87
|
+
console.log(` OK — SVG ${svg.length} bytes`);
|
|
88
|
+
|
|
89
|
+
console.log('5. Rasterizing to PNG via resvg...');
|
|
90
|
+
const png = new Resvg(svg, { width: SVG_SIZE, height: SVG_SIZE }).render().asPng();
|
|
91
|
+
writeFileSync('/tmp/ena-smoke.png', png);
|
|
92
|
+
console.log(` OK — PNG ${png.length} bytes → /tmp/ena-smoke.png`);
|
|
93
|
+
console.log('\nAll steps passed. Open /tmp/ena-smoke.png to check the visual.');
|