@matware/e2e-runner 1.0.2 → 1.1.0

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/src/db.js ADDED
@@ -0,0 +1,366 @@
1
+ /**
2
+ * SQLite database module for cross-project dashboard data.
3
+ *
4
+ * DB location: ~/.e2e-runner/dashboard.db
5
+ * Uses WAL mode for concurrent CLI + dashboard access.
6
+ * All writes are wrapped in try/catch — never crashes the runner.
7
+ */
8
+
9
+ import path from 'path';
10
+ import os from 'os';
11
+ import fs from 'fs';
12
+ import crypto from 'crypto';
13
+ import Database from 'better-sqlite3';
14
+
15
+ const DB_DIR = path.join(os.homedir(), '.e2e-runner');
16
+ const DB_PATH = path.join(DB_DIR, 'dashboard.db');
17
+
18
+ let db = null;
19
+
20
+ /** Returns the singleton database connection, creating it + running migrations if needed. */
21
+ export function getDb() {
22
+ if (db) return db;
23
+
24
+ if (!fs.existsSync(DB_DIR)) {
25
+ fs.mkdirSync(DB_DIR, { recursive: true });
26
+ }
27
+
28
+ db = new Database(DB_PATH);
29
+ db.pragma('journal_mode = WAL');
30
+ db.pragma('foreign_keys = ON');
31
+
32
+ migrate(db);
33
+ return db;
34
+ }
35
+
36
+ function migrate(db) {
37
+ db.exec(`
38
+ CREATE TABLE IF NOT EXISTS projects (
39
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
40
+ cwd TEXT NOT NULL UNIQUE,
41
+ name TEXT NOT NULL,
42
+ screenshots_dir TEXT,
43
+ created_at TEXT DEFAULT (datetime('now')),
44
+ updated_at TEXT DEFAULT (datetime('now'))
45
+ );
46
+
47
+ CREATE TABLE IF NOT EXISTS runs (
48
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
49
+ project_id INTEGER NOT NULL REFERENCES projects(id),
50
+ run_id TEXT NOT NULL,
51
+ total INTEGER DEFAULT 0,
52
+ passed INTEGER DEFAULT 0,
53
+ failed INTEGER DEFAULT 0,
54
+ pass_rate TEXT,
55
+ duration TEXT,
56
+ generated_at TEXT NOT NULL,
57
+ suite_name TEXT,
58
+ UNIQUE(project_id, run_id)
59
+ );
60
+
61
+ CREATE TABLE IF NOT EXISTS test_results (
62
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
63
+ run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
64
+ name TEXT NOT NULL,
65
+ success INTEGER DEFAULT 0,
66
+ error TEXT,
67
+ start_time TEXT,
68
+ end_time TEXT,
69
+ duration_ms INTEGER,
70
+ attempt INTEGER DEFAULT 1,
71
+ max_attempts INTEGER DEFAULT 1,
72
+ error_screenshot TEXT,
73
+ console_logs TEXT,
74
+ network_errors TEXT
75
+ );
76
+
77
+ CREATE INDEX IF NOT EXISTS idx_runs_project ON runs(project_id);
78
+ CREATE INDEX IF NOT EXISTS idx_runs_generated ON runs(generated_at);
79
+ CREATE INDEX IF NOT EXISTS idx_test_results_run ON test_results(run_id);
80
+ `);
81
+
82
+ // Add screenshots_dir column if upgrading from older schema
83
+ try {
84
+ db.prepare('SELECT screenshots_dir FROM projects LIMIT 0').run();
85
+ } catch {
86
+ db.exec('ALTER TABLE projects ADD COLUMN screenshots_dir TEXT');
87
+ }
88
+
89
+ // Add tests_dir column if upgrading from older schema
90
+ try {
91
+ db.prepare('SELECT tests_dir FROM projects LIMIT 0').run();
92
+ } catch {
93
+ db.exec('ALTER TABLE projects ADD COLUMN tests_dir TEXT');
94
+ }
95
+
96
+ // Add screenshots column if upgrading from older schema
97
+ try {
98
+ db.prepare('SELECT screenshots FROM test_results LIMIT 0').run();
99
+ } catch {
100
+ db.exec('ALTER TABLE test_results ADD COLUMN screenshots TEXT');
101
+ }
102
+
103
+ // Screenshot hashes table
104
+ db.exec(`
105
+ CREATE TABLE IF NOT EXISTS screenshot_hashes (
106
+ hash TEXT PRIMARY KEY,
107
+ file_path TEXT NOT NULL,
108
+ project_id INTEGER REFERENCES projects(id),
109
+ run_id INTEGER REFERENCES runs(id) ON DELETE CASCADE,
110
+ created_at TEXT DEFAULT (datetime('now'))
111
+ );
112
+ CREATE INDEX IF NOT EXISTS idx_ss_path ON screenshot_hashes(file_path);
113
+ `);
114
+ }
115
+
116
+ /** Upsert a project row. Returns the project id. */
117
+ export function ensureProject(cwd, name, screenshotsDir, testsDir) {
118
+ const d = getDb();
119
+
120
+ const existing = d.prepare('SELECT id FROM projects WHERE cwd = ?').get(cwd);
121
+ if (existing) {
122
+ d.prepare('UPDATE projects SET name = ?, screenshots_dir = COALESCE(?, screenshots_dir), tests_dir = COALESCE(?, tests_dir), updated_at = datetime(\'now\') WHERE id = ?').run(name, screenshotsDir || null, testsDir || null, existing.id);
123
+ return existing.id;
124
+ }
125
+
126
+ const info = d.prepare('INSERT INTO projects (cwd, name, screenshots_dir, tests_dir) VALUES (?, ?, ?, ?)').run(cwd, name, screenshotsDir || null, testsDir || null);
127
+ return info.lastInsertRowid;
128
+ }
129
+
130
+ /** Get a project's screenshots directory. */
131
+ export function getProjectScreenshotsDir(projectId) {
132
+ const d = getDb();
133
+ const row = d.prepare('SELECT screenshots_dir, cwd FROM projects WHERE id = ?').get(projectId);
134
+ if (!row) return null;
135
+ return row.screenshots_dir || path.join(row.cwd, 'e2e', 'screenshots');
136
+ }
137
+
138
+ /** Get a project's cwd. */
139
+ export function getProjectCwd(projectId) {
140
+ const d = getDb();
141
+ const row = d.prepare('SELECT cwd FROM projects WHERE id = ?').get(projectId);
142
+ return row ? row.cwd : null;
143
+ }
144
+
145
+ /** Get a project's tests directory. */
146
+ export function getProjectTestsDir(projectId) {
147
+ const d = getDb();
148
+ const row = d.prepare('SELECT tests_dir, cwd FROM projects WHERE id = ?').get(projectId);
149
+ if (!row) return null;
150
+ return row.tests_dir || path.join(row.cwd, 'e2e', 'tests');
151
+ }
152
+
153
+ /** Compute an 8-char hex hash from a file path (deterministic, matches client-side Web Crypto). */
154
+ export function computeScreenshotHash(filePath) {
155
+ return crypto.createHash('sha256').update(filePath).digest('hex').slice(0, 8);
156
+ }
157
+
158
+ /** Register a screenshot hash. INSERT OR IGNORE avoids duplicates. */
159
+ export function registerScreenshotHash(hash, filePath, projectId, runDbId) {
160
+ const d = getDb();
161
+ d.prepare('INSERT OR IGNORE INTO screenshot_hashes (hash, file_path, project_id, run_id) VALUES (?, ?, ?, ?)').run(hash, filePath, projectId || null, runDbId || null);
162
+ }
163
+
164
+ /** Look up a screenshot by hash. Strips optional "ss:" prefix. Returns { hash, file_path, project_id } or null. */
165
+ export function lookupScreenshotHash(rawHash) {
166
+ const d = getDb();
167
+ const hash = rawHash.replace(/^ss:/, '');
168
+ return d.prepare('SELECT hash, file_path, project_id FROM screenshot_hashes WHERE hash = ?').get(hash) || null;
169
+ }
170
+
171
+ /** Batch lookup: given an array of file paths, returns { [path]: hash } map. */
172
+ export function getScreenshotHashes(filePaths) {
173
+ if (!filePaths || filePaths.length === 0) return {};
174
+ const d = getDb();
175
+ const stmt = d.prepare('SELECT hash, file_path FROM screenshot_hashes WHERE file_path = ?');
176
+ const result = {};
177
+ for (const fp of filePaths) {
178
+ const row = stmt.get(fp);
179
+ if (row) result[fp] = row.hash;
180
+ }
181
+ return result;
182
+ }
183
+
184
+ /** Save a run + its test results in a single transaction. Returns the run's DB id. */
185
+ export function saveRun(projectId, report, runId, suiteName) {
186
+ const d = getDb();
187
+ const { summary, results, generatedAt } = report;
188
+
189
+ const insertRun = d.prepare(`
190
+ INSERT INTO runs (project_id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name)
191
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
192
+ `);
193
+
194
+ const insertTest = d.prepare(`
195
+ INSERT INTO test_results (run_id, name, success, error, start_time, end_time, duration_ms, attempt, max_attempts, error_screenshot, console_logs, network_errors, screenshots)
196
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
197
+ `);
198
+
199
+ const insertHash = d.prepare('INSERT OR IGNORE INTO screenshot_hashes (hash, file_path, project_id, run_id) VALUES (?, ?, ?, ?)');
200
+
201
+ const tx = d.transaction(() => {
202
+ const runInfo = insertRun.run(
203
+ projectId,
204
+ runId,
205
+ summary.total,
206
+ summary.passed,
207
+ summary.failed,
208
+ summary.passRate,
209
+ summary.duration,
210
+ generatedAt,
211
+ suiteName || null,
212
+ );
213
+ const runDbId = runInfo.lastInsertRowid;
214
+
215
+ for (const r of results) {
216
+ const durationMs = (r.endTime && r.startTime)
217
+ ? new Date(r.endTime) - new Date(r.startTime)
218
+ : null;
219
+
220
+ // Collect screenshot paths from actions
221
+ const screenshots = (r.actions || [])
222
+ .filter(a => a.type === 'screenshot' && a.result?.screenshot)
223
+ .map(a => a.result.screenshot);
224
+
225
+ insertTest.run(
226
+ runDbId,
227
+ r.name,
228
+ r.success ? 1 : 0,
229
+ r.error || null,
230
+ r.startTime || null,
231
+ r.endTime || null,
232
+ durationMs,
233
+ r.attempt || 1,
234
+ r.maxAttempts || 1,
235
+ r.errorScreenshot || null,
236
+ r.consoleLogs ? JSON.stringify(r.consoleLogs) : null,
237
+ r.networkErrors ? JSON.stringify(r.networkErrors) : null,
238
+ screenshots.length ? JSON.stringify(screenshots) : null,
239
+ );
240
+
241
+ // Register screenshot hashes
242
+ for (const ssPath of screenshots) {
243
+ insertHash.run(computeScreenshotHash(ssPath), ssPath, projectId, runDbId);
244
+ }
245
+ if (r.errorScreenshot) {
246
+ insertHash.run(computeScreenshotHash(r.errorScreenshot), r.errorScreenshot, projectId, runDbId);
247
+ }
248
+ }
249
+
250
+ return runDbId;
251
+ });
252
+
253
+ return tx();
254
+ }
255
+
256
+ /** List all projects with aggregated stats. */
257
+ export function listProjects() {
258
+ const d = getDb();
259
+ return d.prepare(`
260
+ SELECT
261
+ p.id, p.cwd, p.name, p.screenshots_dir, p.tests_dir, p.created_at, p.updated_at,
262
+ COUNT(r.id) AS run_count,
263
+ MAX(r.generated_at) AS last_run_at,
264
+ (SELECT r2.pass_rate FROM runs r2 WHERE r2.project_id = p.id ORDER BY r2.generated_at DESC LIMIT 1) AS last_pass_rate
265
+ FROM projects p
266
+ LEFT JOIN runs r ON r.project_id = p.id
267
+ GROUP BY p.id
268
+ ORDER BY p.updated_at DESC
269
+ `).all();
270
+ }
271
+
272
+ /** Paginated runs for a project. */
273
+ export function getProjectRuns(projectId, limit = 50, offset = 0) {
274
+ const d = getDb();
275
+ return d.prepare(`
276
+ SELECT id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name
277
+ FROM runs
278
+ WHERE project_id = ?
279
+ ORDER BY generated_at DESC
280
+ LIMIT ? OFFSET ?
281
+ `).all(projectId, limit, offset);
282
+ }
283
+
284
+ /** Full run detail with test results (reconstructed in report shape). */
285
+ export function getRunDetail(runDbId) {
286
+ const d = getDb();
287
+
288
+ const run = d.prepare('SELECT * FROM runs WHERE id = ?').get(runDbId);
289
+ if (!run) return null;
290
+
291
+ const tests = d.prepare('SELECT * FROM test_results WHERE run_id = ? ORDER BY id').all(runDbId);
292
+
293
+ // Collect all screenshot paths for batch hash lookup
294
+ const allPaths = [];
295
+ for (const t of tests) {
296
+ const ss = t.screenshots ? JSON.parse(t.screenshots) : [];
297
+ allPaths.push(...ss);
298
+ if (t.error_screenshot) allPaths.push(t.error_screenshot);
299
+ }
300
+ const hashMap = getScreenshotHashes(allPaths);
301
+
302
+ return {
303
+ runId: run.run_id,
304
+ summary: {
305
+ total: run.total,
306
+ passed: run.passed,
307
+ failed: run.failed,
308
+ passRate: run.pass_rate,
309
+ duration: run.duration,
310
+ },
311
+ generatedAt: run.generated_at,
312
+ suiteName: run.suite_name,
313
+ results: tests.map(t => {
314
+ const screenshots = t.screenshots ? JSON.parse(t.screenshots) : [];
315
+ const testPaths = [...screenshots];
316
+ if (t.error_screenshot) testPaths.push(t.error_screenshot);
317
+ const screenshotHashes = {};
318
+ for (const p of testPaths) {
319
+ if (hashMap[p]) screenshotHashes[p] = hashMap[p];
320
+ }
321
+ return {
322
+ name: t.name,
323
+ success: !!t.success,
324
+ error: t.error,
325
+ startTime: t.start_time,
326
+ endTime: t.end_time,
327
+ durationMs: t.duration_ms,
328
+ attempt: t.attempt,
329
+ maxAttempts: t.max_attempts,
330
+ errorScreenshot: t.error_screenshot,
331
+ screenshots,
332
+ consoleLogs: t.console_logs ? JSON.parse(t.console_logs) : [],
333
+ networkErrors: t.network_errors ? JSON.parse(t.network_errors) : [],
334
+ screenshotHashes,
335
+ };
336
+ }),
337
+ };
338
+ }
339
+
340
+ /** All runs across all projects (with project name), paginated. */
341
+ export function getAllRuns(limit = 50, offset = 0) {
342
+ const d = getDb();
343
+ return d.prepare(`
344
+ SELECT r.id, r.run_id, r.total, r.passed, r.failed, r.pass_rate, r.duration,
345
+ r.generated_at, r.suite_name, p.name AS project_name, p.id AS project_id
346
+ FROM runs r
347
+ JOIN projects p ON p.id = r.project_id
348
+ ORDER BY r.generated_at DESC
349
+ LIMIT ? OFFSET ?
350
+ `).all(limit, offset);
351
+ }
352
+
353
+ /** Returns total run count (used for change detection). */
354
+ export function getRunCount() {
355
+ const d = getDb();
356
+ const row = d.prepare('SELECT COUNT(*) AS cnt FROM runs').get();
357
+ return row.cnt;
358
+ }
359
+
360
+ /** Close the database connection. */
361
+ export function closeDb() {
362
+ if (db) {
363
+ db.close();
364
+ db = null;
365
+ }
366
+ }
package/src/index.js CHANGED
@@ -11,7 +11,11 @@ export { loadConfig } from './config.js';
11
11
  export { waitForPool, connectToPool, startPool, stopPool, restartPool, getPoolStatus } from './pool.js';
12
12
  export { executeAction } from './actions.js';
13
13
  export { runTest, runTestsParallel, loadTestFile, loadTestSuite, loadAllSuites, listSuites } from './runner.js';
14
- export { generateReport, generateJUnitXML, saveReport, printReport } from './reporter.js';
14
+ export { generateReport, generateJUnitXML, saveReport, printReport, saveHistory, loadHistory, loadHistoryRun } from './reporter.js';
15
+ export { startDashboard, stopDashboard } from './dashboard.js';
16
+ export { fetchIssue, parseIssueUrl, detectProvider, checkCliAuth } from './issues.js';
17
+ export { buildPrompt, generateTests, hasApiKey } from './ai-generate.js';
18
+ export { verifyIssue } from './verify.js';
15
19
 
16
20
  import { loadConfig } from './config.js';
17
21
  import { waitForPool } from './pool.js';
package/src/issues.js ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Issue Provider Drivers — GitHub and GitLab
3
+ *
4
+ * Fetches issue details from GitHub or GitLab using their respective CLI tools
5
+ * (gh / glab). All external commands use execFileSync to prevent shell injection.
6
+ */
7
+
8
+ import { execFileSync } from 'child_process';
9
+
10
+ // ── URL Parsing ───────────────────────────────────────────────────────────────
11
+
12
+ const GITHUB_RE = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/;
13
+ const GITLAB_RE = /^https?:\/\/([^/]+)\/((?:[^/]+\/)*[^/]+)\/-\/issues\/(\d+)/;
14
+
15
+ /**
16
+ * Detects the issue provider from a URL.
17
+ * @param {string} url
18
+ * @returns {'github' | 'gitlab'}
19
+ */
20
+ export function detectProvider(url) {
21
+ if (GITHUB_RE.test(url)) return 'github';
22
+ if (GITLAB_RE.test(url)) return 'gitlab';
23
+ throw new Error(`Unsupported issue URL: ${url}. Expected a GitHub or GitLab issue URL.`);
24
+ }
25
+
26
+ /**
27
+ * Parses an issue URL into its components.
28
+ * @param {string} url
29
+ * @returns {{ provider: string, owner: string, repo: string, fullPath: string, number: number }}
30
+ */
31
+ export function parseIssueUrl(url) {
32
+ const ghMatch = url.match(GITHUB_RE);
33
+ if (ghMatch) {
34
+ return {
35
+ provider: 'github',
36
+ owner: ghMatch[1],
37
+ repo: ghMatch[2],
38
+ fullPath: `${ghMatch[1]}/${ghMatch[2]}`,
39
+ number: parseInt(ghMatch[3], 10),
40
+ };
41
+ }
42
+
43
+ const glMatch = url.match(GITLAB_RE);
44
+ if (glMatch) {
45
+ return {
46
+ provider: 'gitlab',
47
+ host: glMatch[1],
48
+ owner: glMatch[2].split('/').slice(0, -1).join('/'),
49
+ repo: glMatch[2].split('/').pop(),
50
+ fullPath: glMatch[2],
51
+ number: parseInt(glMatch[3], 10),
52
+ };
53
+ }
54
+
55
+ throw new Error(`Cannot parse issue URL: ${url}`);
56
+ }
57
+
58
+ // ── Auth Check ────────────────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * Checks if the CLI tool for the given provider is authenticated.
62
+ * @param {'github' | 'gitlab'} provider
63
+ * @returns {{ authenticated: boolean, error?: string }}
64
+ */
65
+ export function checkCliAuth(provider) {
66
+ try {
67
+ if (provider === 'github') {
68
+ execFileSync('gh', ['auth', 'token'], { stdio: 'pipe', timeout: 10000 });
69
+ return { authenticated: true };
70
+ } else if (provider === 'gitlab') {
71
+ execFileSync('glab', ['auth', 'status'], { stdio: 'pipe', timeout: 10000 });
72
+ return { authenticated: true };
73
+ }
74
+ return { authenticated: false, error: `Unknown provider: ${provider}` };
75
+ } catch (err) {
76
+ const cmd = provider === 'github' ? 'gh auth login' : 'glab auth login';
77
+ return { authenticated: false, error: `Not authenticated. Run: ${cmd}` };
78
+ }
79
+ }
80
+
81
+ // ── Fetch Issue ───────────────────────────────────────────────────────────────
82
+
83
+ /**
84
+ * Fetches an issue from GitHub using the gh CLI.
85
+ * @param {{ fullPath: string, number: number }} parsed
86
+ * @returns {object} Normalized issue object
87
+ */
88
+ function fetchGitHubIssue(parsed) {
89
+ const output = execFileSync('gh', [
90
+ 'api',
91
+ `repos/${parsed.fullPath}/issues/${parsed.number}`,
92
+ ], { stdio: 'pipe', timeout: 30000, encoding: 'utf-8' });
93
+
94
+ const data = JSON.parse(output);
95
+
96
+ return {
97
+ title: data.title,
98
+ body: data.body || '',
99
+ labels: (data.labels || []).map(l => typeof l === 'string' ? l : l.name),
100
+ url: data.html_url,
101
+ provider: 'github',
102
+ state: data.state,
103
+ number: data.number,
104
+ repo: parsed.fullPath,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Fetches an issue from GitLab using the glab CLI.
110
+ * @param {{ fullPath: string, number: number }} parsed
111
+ * @returns {object} Normalized issue object
112
+ */
113
+ function fetchGitLabIssue(parsed) {
114
+ const projectPath = encodeURIComponent(parsed.fullPath);
115
+ const output = execFileSync('glab', [
116
+ 'api',
117
+ `projects/${projectPath}/issues/${parsed.number}`,
118
+ ], { stdio: 'pipe', timeout: 30000, encoding: 'utf-8' });
119
+
120
+ const data = JSON.parse(output);
121
+
122
+ return {
123
+ title: data.title,
124
+ body: data.description || '',
125
+ labels: data.labels || [],
126
+ url: data.web_url,
127
+ provider: 'gitlab',
128
+ state: data.state,
129
+ number: data.iid,
130
+ repo: parsed.fullPath,
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Fetches and normalizes an issue from its URL.
136
+ * @param {string} url - GitHub or GitLab issue URL
137
+ * @returns {{ title: string, body: string, labels: string[], url: string, provider: string, state: string, number: number, repo: string }}
138
+ */
139
+ export function fetchIssue(url) {
140
+ const parsed = parseIssueUrl(url);
141
+
142
+ const auth = checkCliAuth(parsed.provider);
143
+ if (!auth.authenticated) {
144
+ throw new Error(auth.error);
145
+ }
146
+
147
+ if (parsed.provider === 'github') {
148
+ return fetchGitHubIssue(parsed);
149
+ } else {
150
+ return fetchGitLabIssue(parsed);
151
+ }
152
+ }