@matware/e2e-runner 1.1.0 → 1.2.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.
Files changed (39) hide show
  1. package/.claude-plugin/plugin.json +9 -0
  2. package/.mcp.json +9 -0
  3. package/README.md +505 -279
  4. package/agents/test-analyzer.md +81 -0
  5. package/agents/test-creator.md +102 -0
  6. package/agents/test-improver.md +140 -0
  7. package/bin/cli.js +275 -7
  8. package/commands/create-test.md +50 -0
  9. package/commands/run.md +49 -0
  10. package/commands/verify-issue.md +63 -0
  11. package/package.json +11 -3
  12. package/skills/e2e-testing/SKILL.md +166 -0
  13. package/skills/e2e-testing/references/action-types.md +100 -0
  14. package/skills/e2e-testing/references/test-json-format.md +159 -0
  15. package/skills/e2e-testing/references/troubleshooting.md +182 -0
  16. package/src/actions.js +280 -17
  17. package/src/ai-generate.js +122 -11
  18. package/src/config.js +58 -0
  19. package/src/dashboard.js +173 -10
  20. package/src/db.js +232 -17
  21. package/src/index.js +9 -3
  22. package/src/learner-markdown.js +177 -0
  23. package/src/learner-neo4j.js +255 -0
  24. package/src/learner-sqlite.js +354 -0
  25. package/src/learner.js +413 -0
  26. package/src/mcp-tools.js +575 -16
  27. package/src/module-resolver.js +273 -0
  28. package/src/narrate.js +225 -0
  29. package/src/neo4j-pool.js +124 -0
  30. package/src/reporter.js +47 -2
  31. package/src/runner.js +180 -40
  32. package/src/verify.js +19 -5
  33. package/templates/build-dashboard.js +28 -0
  34. package/templates/dashboard/app.js +1152 -0
  35. package/templates/dashboard/styles.css +413 -0
  36. package/templates/dashboard/template.html +201 -0
  37. package/templates/dashboard.html +1091 -268
  38. package/templates/docker-compose-neo4j.yml +19 -0
  39. package/templates/e2e.config.js +3 -0
package/src/runner.js CHANGED
@@ -9,12 +9,24 @@ import fs from 'fs';
9
9
  import path from 'path';
10
10
  import { connectToPool, waitForPool, getPoolStatus } from './pool.js';
11
11
  import { executeAction } from './actions.js';
12
+ import { narrateAction } from './narrate.js';
12
13
  import { log, colors as C } from './logger.js';
14
+ import { resolveTestData } from './module-resolver.js';
13
15
 
14
16
  function sleep(ms) {
15
17
  return new Promise(resolve => setTimeout(resolve, ms));
16
18
  }
17
19
 
20
+ /** Simple glob matching with * wildcards for exclude patterns. */
21
+ function matchesExclude(filename, excludePatterns) {
22
+ if (!excludePatterns?.length) return false;
23
+ const name = filename.replace('.json', '');
24
+ return excludePatterns.some(pattern => {
25
+ const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
26
+ return regex.test(name) || regex.test(filename);
27
+ });
28
+ }
29
+
18
30
  function timeDiff(start, end) {
19
31
  const ms = new Date(end) - new Date(start);
20
32
  return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
@@ -44,7 +56,13 @@ function normalizeTestData(data) {
44
56
  if (Array.isArray(data)) {
45
57
  return { tests: data, hooks: {} };
46
58
  }
47
- return { tests: data.tests || [], hooks: data.hooks || {} };
59
+ // Support hooks nested under "hooks" key or directly at root level
60
+ const hooks = data.hooks || {};
61
+ if (!hooks.beforeAll && data.beforeAll) hooks.beforeAll = data.beforeAll;
62
+ if (!hooks.afterAll && data.afterAll) hooks.afterAll = data.afterAll;
63
+ if (!hooks.beforeEach && data.beforeEach) hooks.beforeEach = data.beforeEach;
64
+ if (!hooks.afterEach && data.afterEach) hooks.afterEach = data.afterEach;
65
+ return { tests: data.tests || [], hooks };
48
66
  }
49
67
 
50
68
  /** Waits until the pool has capacity before connecting */
@@ -70,6 +88,7 @@ async function waitForSlot(poolUrl, pollIntervalMs = 2000, maxWaitMs = 60000) {
70
88
  /** Runs a single test end-to-end */
71
89
  export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
72
90
  let browser = null;
91
+ let context = null;
73
92
  let page = null;
74
93
 
75
94
  const result = {
@@ -80,12 +99,16 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
80
99
  error: null,
81
100
  consoleLogs: [],
82
101
  networkErrors: [],
102
+ networkLogs: [],
83
103
  };
104
+ const pendingBodies = [];
84
105
 
85
106
  try {
86
107
  await waitForSlot(config.poolUrl);
87
108
  browser = await connectToPool(config.poolUrl, config.connectRetries, config.connectRetryDelay);
88
- page = await browser.newPage();
109
+ // Use incognito context for cookie isolation between concurrent tests
110
+ context = await browser.createBrowserContext();
111
+ page = await context.newPage();
89
112
  await page.setViewport(config.viewport);
90
113
 
91
114
  page.on('console', (msg) => {
@@ -95,6 +118,37 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
95
118
  result.networkErrors.push({ url: req.url(), error: req.failure()?.errorText });
96
119
  });
97
120
 
121
+ const requestTimings = new Map();
122
+ page.on('request', (req) => {
123
+ const rt = req.resourceType();
124
+ if (rt === 'xhr' || rt === 'fetch') requestTimings.set(req, Date.now());
125
+ });
126
+ page.on('response', (resp) => {
127
+ const req = resp.request();
128
+ const startMs = requestTimings.get(req);
129
+ if (startMs !== undefined) {
130
+ requestTimings.delete(req);
131
+ const entry = {
132
+ url: req.url(),
133
+ method: req.method(),
134
+ status: resp.status(),
135
+ statusText: resp.statusText(),
136
+ duration: Date.now() - startMs,
137
+ requestHeaders: req.headers(),
138
+ requestBody: null,
139
+ responseHeaders: resp.headers(),
140
+ responseBody: null,
141
+ };
142
+ try { entry.requestBody = req.postData() || null; } catch { /* */ }
143
+ result.networkLogs.push(entry);
144
+ // Read response body async — collect promise for later flush
145
+ const bodyPromise = resp.text().then(body => {
146
+ entry.responseBody = body && body.length > 51200 ? body.slice(0, 51200) + '\n...[truncated]' : body;
147
+ }).catch(() => { /* response may be unavailable */ });
148
+ pendingBodies.push(bodyPromise);
149
+ }
150
+ });
151
+
98
152
  // Run beforeEach hook
99
153
  if (hooks.beforeEach?.length) {
100
154
  await executeHookActions(page, hooks.beforeEach, config);
@@ -102,30 +156,77 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
102
156
 
103
157
  for (let i = 0; i < test.actions.length; i++) {
104
158
  const action = test.actions[i];
105
- const actionStart = Date.now();
106
- try {
107
- const actionResult = await executeAction(page, action, config);
108
- const actionDuration = Date.now() - actionStart;
109
- result.actions.push({
110
- ...action,
111
- success: true,
112
- duration: actionDuration,
113
- result: actionResult,
114
- });
115
- progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: true, duration: actionDuration, screenshotPath: actionResult?.screenshot || null });
116
- } catch (error) {
117
- const actionDuration = Date.now() - actionStart;
118
- result.actions.push({
119
- ...action,
120
- success: false,
121
- duration: actionDuration,
122
- error: error.message,
123
- });
124
- progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: false, duration: actionDuration, error: error.message });
125
- throw error;
159
+ const maxActionRetries = action.retries ?? config.actionRetries ?? 0;
160
+ const actionRetryDelay = config.actionRetryDelay ?? 500;
161
+ let lastError = null;
162
+
163
+ for (let attempt = 0; attempt <= maxActionRetries; attempt++) {
164
+ const actionStart = Date.now();
165
+ try {
166
+ let actionResult;
167
+ if (action.type === 'assert_no_network_errors') {
168
+ // Handled inline — needs access to result.networkErrors
169
+ if (result.networkErrors.length > 0) {
170
+ const summary = result.networkErrors.map(e => `${e.url} (${e.error})`).join(', ');
171
+ throw new Error(`assert_no_network_errors failed: ${result.networkErrors.length} error(s): ${summary}`);
172
+ }
173
+ actionResult = null;
174
+ } else {
175
+ actionResult = await executeAction(page, action, config);
176
+ }
177
+ const actionDuration = Date.now() - actionStart;
178
+ const actionEntry = {
179
+ ...action,
180
+ success: true,
181
+ duration: actionDuration,
182
+ result: actionResult,
183
+ };
184
+ if (attempt > 0) actionEntry.actionRetries = attempt;
185
+ actionEntry.narrative = narrateAction(action, actionEntry);
186
+ result.actions.push(actionEntry);
187
+ progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: true, duration: actionDuration, narrative: actionEntry.narrative, screenshotPath: actionResult?.screenshot || null });
188
+ lastError = null;
189
+ break;
190
+ } catch (error) {
191
+ lastError = error;
192
+ if (attempt < maxActionRetries) {
193
+ log('🔄', `${C.dim}Action ${action.type} retry ${attempt + 1}/${maxActionRetries} (${error.message})${C.reset}`);
194
+ await sleep(actionRetryDelay);
195
+ continue;
196
+ }
197
+ const actionDuration = Date.now() - actionStart;
198
+ const failedEntry = {
199
+ ...action,
200
+ success: false,
201
+ duration: actionDuration,
202
+ error: error.message,
203
+ };
204
+ if (maxActionRetries > 0) failedEntry.actionRetries = attempt;
205
+ failedEntry.narrative = narrateAction(action, failedEntry);
206
+ result.actions.push(failedEntry);
207
+ progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: false, duration: actionDuration, narrative: failedEntry.narrative, error: error.message });
208
+ throw error;
209
+ }
126
210
  }
127
211
  }
128
212
 
213
+ // Fail the test if failOnNetworkError is enabled and network errors occurred
214
+ if (config.failOnNetworkError && result.networkErrors.length > 0) {
215
+ const summary = result.networkErrors.map(e => `${e.url} (${e.error})`).join(', ');
216
+ throw new Error(`Network errors detected (failOnNetworkError=true): ${result.networkErrors.length} error(s): ${summary}`);
217
+ }
218
+
219
+ // Auto-capture verification screenshot if test has "expect"
220
+ if (test.expect && page) {
221
+ result.expect = test.expect;
222
+ try {
223
+ const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
224
+ const verifyPath = path.join(config.screenshotsDir, `verify-${safeName}-${Date.now()}.png`);
225
+ await page.screenshot({ path: verifyPath, fullPage: true });
226
+ result.verificationScreenshot = verifyPath;
227
+ } catch { /* page may be dead */ }
228
+ }
229
+
129
230
  // Run afterEach hook (success path)
130
231
  if (hooks.afterEach?.length) {
131
232
  await executeHookActions(page, hooks.afterEach, config);
@@ -148,10 +249,17 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
148
249
  } catch { /* page may be dead */ }
149
250
  }
150
251
  } finally {
252
+ // Flush pending response body reads before disconnecting
253
+ if (pendingBodies.length > 0) {
254
+ try { await Promise.allSettled(pendingBodies); } catch { /* */ }
255
+ }
151
256
  result.endTime = new Date().toISOString();
152
257
  if (page) {
153
258
  try { result.finalUrl = page.url(); } catch { /* */ }
154
259
  }
260
+ if (context) {
261
+ try { await context.close(); } catch { /* */ }
262
+ }
155
263
  if (browser) {
156
264
  try { browser.disconnect(); } catch { /* */ }
157
265
  }
@@ -166,6 +274,12 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
166
274
 
167
275
  // Run beforeAll hook
168
276
  if (hooks.beforeAll?.length) {
277
+ const stateActions = hooks.beforeAll.filter(a =>
278
+ ['evaluate', 'goto', 'navigate', 'clear_cookies', 'type', 'click', 'select'].includes(a.type)
279
+ );
280
+ if (stateActions.length > 0) {
281
+ log('⚠️', `${C.yellow}beforeAll runs on a separate browser — state from ${stateActions.map(a => a.type).join(', ')} will NOT carry over to tests. Use beforeEach instead.${C.reset}`);
282
+ }
169
283
  log('🪝', `${C.dim}Running beforeAll hook...${C.reset}`);
170
284
  let browser = null;
171
285
  try {
@@ -186,11 +300,20 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
186
300
  const _runId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
187
301
  const _proj = config.projectName || null;
188
302
  const _cwd = config._cwd || null;
189
- const _progress = (data) => config.onProgress && config.onProgress({ ...data, runId: _runId, project: _proj, cwd: _cwd });
303
+ const _triggeredBy = config.triggeredBy || 'unknown';
304
+ const _progress = (data) => config.onProgress && config.onProgress({ ...data, runId: _runId, project: _proj, cwd: _cwd, triggeredBy: _triggeredBy });
305
+
306
+ // Split serial and parallel tests
307
+ const parallelTests = tests.filter(t => !t.serial);
308
+ const serialTests = tests.filter(t => t.serial);
309
+ if (serialTests.length > 0) {
310
+ log('🔒', `${C.dim}${serialTests.length} serial test(s) will run after parallel batch${C.reset}`);
311
+ }
312
+
190
313
  _progress({ event: 'run:start', total: tests.length, concurrency, timestamp: new Date().toISOString() });
191
314
 
192
315
  const results = [];
193
- const queue = [...tests];
316
+ const queue = [...parallelTests];
194
317
  let activeCount = 0;
195
318
 
196
319
  const worker = async () => {
@@ -198,7 +321,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
198
321
  const test = queue.shift();
199
322
  activeCount++;
200
323
  log('▶▶▶', `${C.cyan}${test.name}${C.reset} ${C.dim}(${activeCount} active)${C.reset}`);
201
- _progress({ event: 'test:start', name: test.name, activeCount, queueRemaining: queue.length });
324
+ _progress({ event: 'test:start', name: test.name, serial: test.serial || false, activeCount, queueRemaining: queue.length });
202
325
 
203
326
  const maxAttempts = (test.retries ?? config.retries ?? 0) + 1;
204
327
  const testTimeout = test.timeout ?? config.testTimeout ?? 60000;
@@ -211,7 +334,8 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
211
334
  });
212
335
 
213
336
  try {
214
- result = await Promise.race([runTest(test, config, hooks, _progress), timeoutPromise]);
337
+ const testHooks = test._suiteHooks ? mergeHooks(config.hooks, test._suiteHooks) : hooks;
338
+ result = await Promise.race([runTest(test, config, testHooks, _progress), timeoutPromise]);
215
339
  } catch (error) {
216
340
  result = {
217
341
  name: test.name,
@@ -222,6 +346,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
222
346
  error: error.message,
223
347
  consoleLogs: [],
224
348
  networkErrors: [],
349
+ networkLogs: [],
225
350
  };
226
351
  }
227
352
 
@@ -238,7 +363,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
238
363
  activeCount--;
239
364
 
240
365
  const screenshots = result.actions.filter(a => a.result?.screenshot).map(a => a.result.screenshot);
241
- _progress({ event: 'test:complete', name: test.name, success: result.success, duration: timeDiff(result.startTime, result.endTime), error: result.error, consoleLogs: result.consoleLogs, networkErrors: result.networkErrors, errorScreenshot: result.errorScreenshot, screenshots });
366
+ _progress({ event: 'test:complete', name: test.name, success: result.success, duration: timeDiff(result.startTime, result.endTime), error: result.error, consoleLogs: result.consoleLogs, networkErrors: result.networkErrors, networkLogs: result.networkLogs, errorScreenshot: result.errorScreenshot, screenshots });
242
367
 
243
368
  if (result.success) {
244
369
  const flaky = result.attempt > 1 ? ` ${C.yellow}(flaky, passed on attempt ${result.attempt}/${result.maxAttempts})${C.reset}` : '';
@@ -259,11 +384,18 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
259
384
  };
260
385
 
261
386
  const workers = [];
262
- for (let i = 0; i < Math.min(concurrency, tests.length); i++) {
387
+ for (let i = 0; i < Math.min(concurrency, parallelTests.length); i++) {
263
388
  workers.push(worker());
264
389
  }
265
390
  await Promise.all(workers);
266
391
 
392
+ // Run serial tests one at a time
393
+ if (serialTests.length > 0) {
394
+ log('🔒', `${C.dim}Running ${serialTests.length} serial test(s)...${C.reset}`);
395
+ queue.push(...serialTests);
396
+ await worker();
397
+ }
398
+
267
399
  // Run afterAll hook
268
400
  if (hooks.afterAll?.length) {
269
401
  log('🪝', `${C.dim}Running afterAll hook...${C.reset}`);
@@ -291,16 +423,17 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
291
423
  }
292
424
 
293
425
  /** Loads tests from a JSON file — returns { tests, hooks } */
294
- export function loadTestFile(filePath) {
426
+ export function loadTestFile(filePath, modulesDir) {
295
427
  if (!fs.existsSync(filePath)) {
296
428
  throw new Error(`File not found: ${filePath}`);
297
429
  }
298
430
  const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
299
- return normalizeTestData(data);
431
+ const normalized = normalizeTestData(data);
432
+ return modulesDir ? resolveTestData(normalized, modulesDir) : normalized;
300
433
  }
301
434
 
302
435
  /** Loads a test suite by name — returns { tests, hooks } */
303
- export function loadTestSuite(suiteName, testsDir) {
436
+ export function loadTestSuite(suiteName, testsDir, modulesDir) {
304
437
  // Match with or without numeric prefix (e.g. "agents" matches "03-agents.json")
305
438
  const files = fs.readdirSync(testsDir).filter(f => f.endsWith('.json'));
306
439
  const exact = files.find(f => f === `${suiteName}.json`);
@@ -312,29 +445,36 @@ export function loadTestSuite(suiteName, testsDir) {
312
445
  }
313
446
 
314
447
  const data = JSON.parse(fs.readFileSync(path.join(testsDir, match), 'utf-8'));
315
- return normalizeTestData(data);
448
+ const normalized = normalizeTestData(data);
449
+ return modulesDir ? resolveTestData(normalized, modulesDir) : normalized;
316
450
  }
317
451
 
318
452
  /** Loads all test suites from the tests directory — returns { tests, hooks } */
319
- export function loadAllSuites(testsDir) {
453
+ export function loadAllSuites(testsDir, modulesDir, exclude = []) {
320
454
  if (!fs.existsSync(testsDir)) {
321
455
  throw new Error(`Tests directory not found: ${testsDir}`);
322
456
  }
323
457
 
324
- const files = fs.readdirSync(testsDir).filter(f => f.endsWith('.json')).sort();
458
+ const files = fs.readdirSync(testsDir).filter(f => f.endsWith('.json')).sort()
459
+ .filter(f => !matchesExclude(f, exclude));
325
460
  let allTests = [];
326
- let mergedHooks = {};
327
461
 
328
462
  for (const file of files) {
329
463
  const data = JSON.parse(fs.readFileSync(path.join(testsDir, file), 'utf-8'));
330
- const { tests, hooks } = normalizeTestData(data);
464
+ let { tests, hooks } = normalizeTestData(data);
465
+ // Resolve modules per-suite before concatenating
466
+ if (modulesDir) {
467
+ ({ tests, hooks } = resolveTestData({ tests, hooks }, modulesDir));
468
+ }
469
+ // Tag each test with its own suite's hooks so they're preserved
470
+ for (const t of tests) {
471
+ t._suiteHooks = hooks;
472
+ }
331
473
  allTests = allTests.concat(tests);
332
- // Last suite's hooks win for each non-empty key
333
- mergedHooks = mergeHooks(mergedHooks, hooks);
334
474
  log('📋', `${C.cyan}${file}${C.reset} (${tests.length} tests)`);
335
475
  }
336
476
 
337
- return { tests: allTests, hooks: mergedHooks };
477
+ return { tests: allTests, hooks: {} };
338
478
  }
339
479
 
340
480
  /** Lists all available test suites */
package/src/verify.js CHANGED
@@ -25,7 +25,7 @@ export async function verifyIssue(url, config) {
25
25
  const issue = fetchIssue(url);
26
26
 
27
27
  // 2. Generate tests via Claude API
28
- const { tests, suiteName } = await generateTests(issue, config);
28
+ const { tests, suiteName } = await generateTests(issue, config, config.testType || 'e2e');
29
29
 
30
30
  // 3. Save tests to a temp file (underscore prefix for cleanup identification)
31
31
  const testFile = path.join(config.testsDir, `_verify-${suiteName}.json`);
@@ -35,19 +35,33 @@ export async function verifyIssue(url, config) {
35
35
  fs.writeFileSync(testFile, JSON.stringify(tests, null, 2));
36
36
 
37
37
  try {
38
- // 4. Wait for pool and run
38
+ // 4. Build hooks (inject auth if provided)
39
+ const hooks = {};
40
+ if (config.authToken) {
41
+ const esc = s => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
42
+ const storageKey = esc(config.authStorageKey || 'accessToken');
43
+ const token = esc(config.authToken);
44
+ hooks.beforeEach = [
45
+ { type: 'goto', value: '/' },
46
+ { type: 'evaluate', value: `localStorage.setItem('${storageKey}', '${token}')` },
47
+ { type: 'goto', value: '/' },
48
+ { type: 'wait', value: '1000' },
49
+ ];
50
+ }
51
+
52
+ // 5. Wait for pool and run
39
53
  await waitForPool(config.poolUrl);
40
- const results = await runTestsParallel(tests, config, {});
54
+ const results = await runTestsParallel(tests, config, hooks);
41
55
  const report = generateReport(results);
42
56
  saveReport(report, config.screenshotsDir, config);
43
57
  persistRun(report, config, suiteName);
44
58
 
45
- // 5. Interpret results
59
+ // 6. Interpret results
46
60
  const bugConfirmed = report.summary.failed > 0;
47
61
 
48
62
  return { issue, report, bugConfirmed, tests, suiteName };
49
63
  } finally {
50
- // 6. Clean up temp file
64
+ // 7. Clean up temp file
51
65
  try { fs.unlinkSync(testFile); } catch { /* already gone */ }
52
66
  }
53
67
  }
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Build script — concatenates dashboard/template.html + dashboard/styles.css + dashboard/app.js
4
+ * into a single dashboard.html file.
5
+ *
6
+ * Usage: node templates/build-dashboard.js
7
+ */
8
+
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import { fileURLToPath } from 'url';
12
+
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+ const dashDir = path.join(__dirname, 'dashboard');
15
+
16
+ const template = fs.readFileSync(path.join(dashDir, 'template.html'), 'utf-8');
17
+ const styles = fs.readFileSync(path.join(dashDir, 'styles.css'), 'utf-8');
18
+ const script = fs.readFileSync(path.join(dashDir, 'app.js'), 'utf-8');
19
+
20
+ const output = template
21
+ .replace('/* __STYLES__ */', () => styles)
22
+ .replace('/* __SCRIPT__ */', () => script);
23
+
24
+ const outPath = path.join(__dirname, 'dashboard.html');
25
+ fs.writeFileSync(outPath, output);
26
+
27
+ const lines = output.split('\n').length;
28
+ console.log(`Built ${outPath} (${lines} lines)`);