testdriverai 7.2.25 → 7.2.27

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.
@@ -0,0 +1,263 @@
1
+ /**
2
+ * GitHub Comment Formatter for TestDriver Test Results
3
+ *
4
+ * This module creates beautiful, formatted GitHub comments with test results,
5
+ * dashcam replays, screenshots, exceptions, and more.
6
+ */
7
+
8
+ const { Octokit } = require('@octokit/rest');
9
+
10
+ /**
11
+ * Format test results into a beautiful GitHub comment markdown
12
+ * @param {Object} testRunData - Test run data from Vitest plugin
13
+ * @returns {string} Formatted markdown comment
14
+ */
15
+ function formatGitHubComment(testRunData) {
16
+ const { testCases, startTime, endTime, totalDuration } = testRunData;
17
+
18
+ const passed = testCases.filter(tc => tc.result?.state === 'pass').length;
19
+ const failed = testCases.filter(tc => tc.result?.state === 'fail').length;
20
+ const skipped = testCases.filter(tc => tc.result?.state === 'skip').length;
21
+
22
+ const statusEmoji = failed > 0 ? '❌' : '✅';
23
+ const statusText = failed > 0 ? 'Failed' : 'Passed';
24
+
25
+ let comment = `## 🎯 TestDriver Test Results\n\n`;
26
+ comment += `**Status:** ${statusEmoji} ${statusText} • `;
27
+ comment += `${passed} passed`;
28
+ if (failed > 0) comment += `, ${failed} failed`;
29
+ if (skipped > 0) comment += `, ${skipped} skipped`;
30
+ comment += `\n`;
31
+ comment += `**Duration:** ${(totalDuration / 1000).toFixed(1)}s\n\n`;
32
+
33
+ // Group test cases by file
34
+ const testsByFile = {};
35
+ for (const testCase of testCases) {
36
+ const file = testCase.file || 'unknown';
37
+ if (!testsByFile[file]) {
38
+ testsByFile[file] = [];
39
+ }
40
+ testsByFile[file].push(testCase);
41
+ }
42
+
43
+ comment += `### Test Cases\n\n`;
44
+
45
+ for (const [file, tests] of Object.entries(testsByFile)) {
46
+ comment += `#### 📁 ${file}\n\n`;
47
+
48
+ for (const testCase of tests) {
49
+ const emoji = testCase.result?.state === 'pass' ? '✅' :
50
+ testCase.result?.state === 'fail' ? '❌' : '⏭️';
51
+ const testName = testCase.name || 'Unnamed test';
52
+
53
+ comment += `${emoji} **${testName}**\n\n`;
54
+
55
+ // Duration
56
+ if (testCase.result?.duration) {
57
+ comment += `- ⏱️ **Duration:** ${(testCase.result.duration / 1000).toFixed(2)}s\n`;
58
+ }
59
+
60
+ // Dashcam replay
61
+ if (testCase.recordingData?.dashcamUrl) {
62
+ comment += `- 🎬 **Dashcam Replay:** [View recording](${testCase.recordingData.dashcamUrl})\n`;
63
+ }
64
+
65
+ // Sandbox info
66
+ if (testCase.sandbox?.sandboxId) {
67
+ comment += `- 🖥️ **Sandbox:** \`${testCase.sandbox.sandboxId}\`\n`;
68
+ }
69
+
70
+ // Exception/error details
71
+ if (testCase.result?.errors && testCase.result.errors.length > 0) {
72
+ comment += `\n<details>\n<summary>❌ Error Details</summary>\n\n`;
73
+ comment += `\`\`\`\n`;
74
+ for (const error of testCase.result.errors) {
75
+ comment += `${error.message || error}\n`;
76
+ if (error.stack) {
77
+ comment += `${error.stack}\n`;
78
+ }
79
+ }
80
+ comment += `\`\`\`\n\n`;
81
+ comment += `</details>\n\n`;
82
+ }
83
+
84
+ // Screenshots
85
+ if (testCase.recordingData?.screenshots && testCase.recordingData.screenshots.length > 0) {
86
+ comment += `\n<details>\n<summary>📸 Screenshots (${testCase.recordingData.screenshots.length})</summary>\n\n`;
87
+ for (const screenshot of testCase.recordingData.screenshots) {
88
+ if (screenshot.url) {
89
+ comment += `![Screenshot](${screenshot.url})\n\n`;
90
+ }
91
+ }
92
+ comment += `</details>\n\n`;
93
+ }
94
+
95
+ comment += `\n`;
96
+ }
97
+ }
98
+
99
+ comment += `---\n\n`;
100
+ comment += `*Powered by [TestDriver.ai](https://testdriver.ai) • `;
101
+ comment += `Generated at ${new Date(endTime).toISOString()}*\n`;
102
+
103
+ return comment;
104
+ }
105
+
106
+ /**
107
+ * Get GitHub context from environment variables
108
+ * Supports multiple CI/CD environments:
109
+ * - GitHub Actions: GITHUB_TOKEN, GITHUB_REPOSITORY, github.event.pull_request.number
110
+ * - Manual: TD_GITHUB_TOKEN, TD_GITHUB_REPO, TD_GITHUB_PR
111
+ * @returns {Object|null} GitHub context or null if not available
112
+ */
113
+ function getGitHubContext() {
114
+ // Try TestDriver-specific env vars first (for manual control)
115
+ let token = process.env.TD_GITHUB_TOKEN || process.env.GITHUB_TOKEN;
116
+ let repo = process.env.TD_GITHUB_REPO || process.env.GITHUB_REPOSITORY;
117
+ let prNumber = process.env.TD_GITHUB_PR ||
118
+ process.env.GITHUB_PR_NUMBER ||
119
+ process.env.PR_NUMBER;
120
+
121
+ // GitHub Actions: try to extract PR number from event path
122
+ if (!prNumber && process.env.GITHUB_EVENT_PATH) {
123
+ try {
124
+ const fs = require('fs');
125
+ const eventData = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'));
126
+ prNumber = eventData.pull_request?.number;
127
+ } catch (err) {
128
+ // Ignore errors reading event file
129
+ }
130
+ }
131
+
132
+ // GitHub Actions: try to extract from GITHUB_REF (refs/pull/123/merge)
133
+ if (!prNumber && process.env.GITHUB_REF) {
134
+ const match = process.env.GITHUB_REF.match(/refs\/pull\/(\d+)\/(merge|head)/);
135
+ if (match) {
136
+ prNumber = match[1];
137
+ }
138
+ }
139
+
140
+ if (!token || !repo || !prNumber) {
141
+ return null;
142
+ }
143
+
144
+ const [owner, repoName] = repo.split('/');
145
+ if (!owner || !repoName) {
146
+ return null;
147
+ }
148
+
149
+ return {
150
+ token,
151
+ owner,
152
+ repo: repoName,
153
+ prNumber: parseInt(prNumber, 10),
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Check if GitHub integration is properly configured
159
+ * @returns {Object} Status object with configured flag and message
160
+ */
161
+ function checkGitHubIntegration() {
162
+ const context = getGitHubContext();
163
+
164
+ if (!context) {
165
+ const missing = [];
166
+ if (!process.env.TD_GITHUB_TOKEN && !process.env.GITHUB_TOKEN) {
167
+ missing.push('GITHUB_TOKEN or TD_GITHUB_TOKEN');
168
+ }
169
+ if (!process.env.TD_GITHUB_REPO && !process.env.GITHUB_REPOSITORY) {
170
+ missing.push('GITHUB_REPOSITORY or TD_GITHUB_REPO');
171
+ }
172
+ if (!process.env.TD_GITHUB_PR && !process.env.GITHUB_PR_NUMBER && !process.env.GITHUB_REF) {
173
+ missing.push('TD_GITHUB_PR or PR number detection');
174
+ }
175
+
176
+ return {
177
+ configured: false,
178
+ message: `GitHub integration not configured. Missing: ${missing.join(', ')}`,
179
+ context: null,
180
+ };
181
+ }
182
+
183
+ return {
184
+ configured: true,
185
+ message: `GitHub integration configured for ${context.owner}/${context.repo}#${context.prNumber}`,
186
+ context,
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Post a comment to a GitHub PR
192
+ * @param {Object} testRunData - Test run data from Vitest plugin
193
+ * @returns {Promise<Object>} GitHub API response
194
+ */
195
+ async function postGitHubComment(testRunData) {
196
+ const context = getGitHubContext();
197
+
198
+ if (!context) {
199
+ console.log('ℹ️ Skipping GitHub comment: GitHub context not configured');
200
+ return null;
201
+ }
202
+
203
+ // Check if comments are explicitly disabled
204
+ if (process.env.TD_GITHUB_COMMENTS === 'false') {
205
+ console.log('ℹ️ Skipping GitHub comment: TD_GITHUB_COMMENTS=false');
206
+ return null;
207
+ }
208
+
209
+ try {
210
+ const octokit = new Octokit({ auth: context.token });
211
+ const commentBody = formatGitHubComment(testRunData);
212
+
213
+ // Check if we already posted a comment
214
+ const { data: comments } = await octokit.issues.listComments({
215
+ owner: context.owner,
216
+ repo: context.repo,
217
+ issue_number: context.prNumber,
218
+ });
219
+
220
+ const botComment = comments.find(c =>
221
+ c.body?.includes('🎯 TestDriver Test Results') &&
222
+ c.user?.login === (process.env.GITHUB_ACTOR || 'github-actions[bot]')
223
+ );
224
+
225
+ let response;
226
+ if (botComment) {
227
+ // Update existing comment
228
+ response = await octokit.issues.updateComment({
229
+ owner: context.owner,
230
+ repo: context.repo,
231
+ comment_id: botComment.id,
232
+ body: commentBody,
233
+ });
234
+ console.log(`✅ Updated GitHub comment: ${response.data.html_url}`);
235
+ } else {
236
+ // Create new comment
237
+ response = await octokit.issues.createComment({
238
+ owner: context.owner,
239
+ repo: context.repo,
240
+ issue_number: context.prNumber,
241
+ body: commentBody,
242
+ });
243
+ console.log(`✅ Posted GitHub comment: ${response.data.html_url}`);
244
+ }
245
+
246
+ return response.data;
247
+ } catch (error) {
248
+ console.error('❌ Failed to post GitHub comment:', error.message);
249
+ if (error.status === 401) {
250
+ console.error(' Check that your GITHUB_TOKEN has the correct permissions');
251
+ } else if (error.status === 404) {
252
+ console.error(' Check that the repository and PR number are correct');
253
+ }
254
+ return null;
255
+ }
256
+ }
257
+
258
+ module.exports = {
259
+ formatGitHubComment,
260
+ postGitHubComment,
261
+ getGitHubContext,
262
+ checkGitHubIntegration,
263
+ };
@@ -0,0 +1,424 @@
1
+ /**
2
+ * GitHub Comment Generator for TestDriver Test Results
3
+ *
4
+ * Creates beautifully formatted GitHub comments with:
5
+ * - Test results summary
6
+ * - Dashcam GIF replays
7
+ * - Exception details
8
+ * - Links to test runs
9
+ */
10
+
11
+ import { Octokit } from '@octokit/rest';
12
+
13
+ /**
14
+ * Format test duration in human-readable format
15
+ * @param {number} ms - Duration in milliseconds
16
+ * @returns {string} Formatted duration
17
+ */
18
+ function formatDuration(ms) {
19
+ if (ms < 1000) return `${ms}ms`;
20
+ if (ms < 60000) return `${(ms / 1000).toFixed(2)}s`;
21
+ return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
22
+ }
23
+
24
+ /**
25
+ * Get status emoji for test result
26
+ * @param {string} status - Test status (passed, failed, skipped)
27
+ * @returns {string} Emoji
28
+ */
29
+ function getStatusEmoji(status) {
30
+ switch (status) {
31
+ case 'passed': return '✅';
32
+ case 'failed': return '❌';
33
+ case 'skipped': return '⏭️';
34
+ case 'cancelled': return '🚫';
35
+ default: return '❓';
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Generate markdown for test results table
41
+ * @param {Array} testCases - Array of test case objects
42
+ * @param {string} testRunUrl - Base URL for test run
43
+ * @returns {string} Markdown table
44
+ */
45
+ function generateTestResultsTable(testCases, testRunUrl) {
46
+ if (!testCases || testCases.length === 0) {
47
+ return '_No test cases recorded_';
48
+ }
49
+
50
+ let table = '| Status | Test | File | Duration | Replay |\n';
51
+ table += '|--------|------|------|----------|--------|\n';
52
+
53
+ for (const test of testCases) {
54
+ const status = getStatusEmoji(test.status);
55
+ const name = test.testName || 'Unknown';
56
+ const file = test.testFile || 'unknown';
57
+ const duration = formatDuration(test.duration || 0);
58
+
59
+ // Use test run context URL instead of direct replay URL
60
+ let replay = '-';
61
+ if (test.replayUrl) {
62
+ const linkUrl = (test.id && testRunUrl) ? `${testRunUrl}/${test.id}` : test.replayUrl;
63
+
64
+ // Extract replay ID and generate GIF URL
65
+ const replayId = extractReplayId(test.replayUrl);
66
+ if (replayId) {
67
+ const gifUrl = getReplayGifUrl(test.replayUrl, replayId);
68
+ // Embed GIF with link using HTML for width control
69
+ replay = `<a href="${linkUrl}"><img src="${gifUrl}" width="250" alt="${name}" /></a>`;
70
+ } else {
71
+ // Fallback to text link if no GIF available
72
+ replay = `[🎥 View](${linkUrl})`;
73
+ }
74
+ } else if (test.id && testRunUrl) {
75
+ // Link without replay URL
76
+ replay = `[🎥 View](${testRunUrl}/${test.id})`;
77
+ }
78
+
79
+ table += `| ${status} | ${name} | \`${file}\` | ${duration} | ${replay} |\n`;
80
+ }
81
+
82
+ return table;
83
+ }
84
+
85
+ /**
86
+ * Generate markdown for exceptions/errors
87
+ * @param {Array} testCases - Array of test case objects with errors
88
+ * @param {string} testRunUrl - Base URL for test run
89
+ * @returns {string} Markdown with error details
90
+ */
91
+ function generateExceptionsSection(testCases, testRunUrl) {
92
+ const failedTests = testCases.filter(t => t.status === 'failed' && t.errorMessage);
93
+
94
+ if (failedTests.length === 0) {
95
+ return '';
96
+ }
97
+
98
+ let section = '\n## 🔴 Failures\n\n';
99
+
100
+ for (const test of failedTests) {
101
+ section += `### ${test.testName}\n\n`;
102
+ section += `**File:** \`${test.testFile}\`\n\n`;
103
+
104
+ // Use test run context URL instead of direct replay URL
105
+ if (test.id && testRunUrl) {
106
+ section += `**📹 [Watch Replay](${testRunUrl}/${test.id})**\n\n`;
107
+ } else if (test.replayUrl) {
108
+ section += `**📹 [Watch Replay](${test.replayUrl})**\n\n`;
109
+ }
110
+
111
+ section += '```\n';
112
+ section += test.errorMessage || 'Unknown error';
113
+ section += '\n```\n\n';
114
+
115
+ if (test.errorStack) {
116
+ section += '<details>\n';
117
+ section += '<summary>Stack Trace</summary>\n\n';
118
+ section += '```\n';
119
+ section += test.errorStack;
120
+ section += '\n```\n';
121
+ section += '</details>\n\n';
122
+ }
123
+ }
124
+
125
+ return section;
126
+ }
127
+
128
+ /**
129
+ * Generate markdown for dashcam replays section
130
+ * @param {Array} testCases - Array of test case objects
131
+ * @param {string} testRunUrl - Base URL for test run
132
+ * @returns {string} Markdown with replay embeds
133
+ */
134
+ function generateReplaySection(testCases, testRunUrl) {
135
+ const testsWithReplays = testCases.filter(t => t.replayUrl);
136
+
137
+ if (testsWithReplays.length === 0) {
138
+ return '';
139
+ }
140
+
141
+ let section = '\n## 🎥 Dashcam Replays\n\n';
142
+
143
+ for (const test of testsWithReplays) {
144
+ section += `### ${test.testName}\n\n`;
145
+
146
+ // Determine the link URL - prefer test run context
147
+ let linkUrl = test.replayUrl;
148
+ if (test.id && testRunUrl) {
149
+ linkUrl = `${testRunUrl}/${test.id}`;
150
+ }
151
+
152
+ // Extract replay ID from URL for GIF embed
153
+ const replayId = extractReplayId(test.replayUrl);
154
+ if (replayId) {
155
+ const gifUrl = getReplayGifUrl(test.replayUrl, replayId);
156
+ section += `[![${test.testName}](${gifUrl})](${linkUrl})\n\n`;
157
+ section += `[🎬 View Full Replay](${linkUrl})\n\n`;
158
+ } else {
159
+ section += `[🎬 View Replay](${linkUrl})\n\n`;
160
+ }
161
+ }
162
+
163
+ return section;
164
+ }
165
+
166
+ /**
167
+ * Extract replay ID from dashcam URL
168
+ * @param {string} url - Dashcam replay URL
169
+ * @returns {string|null} Replay ID or null
170
+ */
171
+ function extractReplayId(url) {
172
+ if (!url) return null;
173
+
174
+ // Match pattern: /replay/{id} or /replay/{id}?params
175
+ const match = url.match(/\/replay\/([^?/#]+)/);
176
+ return match ? match[1] : null;
177
+ }
178
+
179
+ /**
180
+ * Extract share key from dashcam URL
181
+ * @param {string} url - Dashcam replay URL
182
+ * @returns {string|null} Share key or null
183
+ */
184
+ function extractShareKey(url) {
185
+ if (!url) return null;
186
+
187
+ // Match pattern: ?share=KEY or &share=KEY
188
+ const match = url.match(/[?&]share=([^&#]+)/);
189
+ return match ? match[1] : null;
190
+ }
191
+
192
+ /**
193
+ * Get GIF URL for replay
194
+ * @param {string} replayUrl - Full replay URL
195
+ * @param {string} replayId - Replay ID
196
+ * @returns {string} GIF URL
197
+ */
198
+ function getReplayGifUrl(replayUrl, replayId) {
199
+ // Determine the API base URL based on the replay URL
200
+ let apiBaseUrl;
201
+
202
+ if (replayUrl.includes('app.dashcam.io')) {
203
+ // Production dashcam uses Heroku API
204
+ apiBaseUrl = 'https://testdriverai-v6-c96fc597be11.herokuapp.com';
205
+ } else if (replayUrl.includes('console.testdriver.ai')) {
206
+ // TestDriver console
207
+ apiBaseUrl = 'https://testdriver-api.onrender.com';
208
+ } else if (replayUrl.includes('localhost')) {
209
+ // Local development
210
+ apiBaseUrl = 'http://localhost:1337';
211
+ } else {
212
+ // Default: try to extract base URL from replay URL
213
+ const urlObj = new URL(replayUrl);
214
+ apiBaseUrl = `${urlObj.protocol}//${urlObj.host}`;
215
+ }
216
+
217
+ // Extract share key if present
218
+ const shareKey = extractShareKey(replayUrl);
219
+
220
+ // Build GIF URL with shareKey parameter
221
+ let gifUrl = `${apiBaseUrl}/replay/${replayId}/gif`;
222
+ if (shareKey) {
223
+ gifUrl += `?shareKey=${shareKey}`;
224
+ }
225
+
226
+ return gifUrl;
227
+ }
228
+
229
+ /**
230
+ * Generate complete GitHub comment markdown
231
+ * @param {Object} testRunData - Test run data
232
+ * @param {Array} testCases - Array of test case objects
233
+ * @returns {string} Complete markdown comment
234
+ */
235
+ export function generateGitHubComment(testRunData, testCases = []) {
236
+ const {
237
+ runId,
238
+ status,
239
+ totalTests = 0,
240
+ passedTests = 0,
241
+ failedTests = 0,
242
+ skippedTests = 0,
243
+ duration = 0,
244
+ testRunUrl,
245
+ platform = 'unknown',
246
+ branch = 'unknown',
247
+ commit = 'unknown',
248
+ } = testRunData;
249
+
250
+ // Header with overall status
251
+ const statusEmoji = getStatusEmoji(status);
252
+ const statusColor = status === 'passed' ? '🟢' : status === 'failed' ? '🔴' : '🟡';
253
+
254
+ let comment = `# ${statusColor} TestDriver Test Results\n\n`;
255
+
256
+ // Compact summary line
257
+ comment += `**Status:** ${statusEmoji} ${status.toUpperCase()}`;
258
+ comment += ` • **Duration:** ${formatDuration(duration)}`;
259
+ comment += ` • ${passedTests} passed`;
260
+ if (failedTests > 0) comment += `, ${failedTests} failed`;
261
+ if (skippedTests > 0) comment += `, ${skippedTests} skipped`;
262
+ comment += `\n\n`;
263
+
264
+ // Test results table (now includes embedded GIFs)
265
+ comment += '## 📝 Test Results\n\n';
266
+ comment += generateTestResultsTable(testCases, testRunUrl);
267
+
268
+ // Link to full test run (below table)
269
+ if (testRunUrl) {
270
+ comment += `\n[📋 View Full Test Run](${testRunUrl})\n`;
271
+ }
272
+
273
+ // Exceptions section (only if there are failures)
274
+ comment += generateExceptionsSection(testCases, testRunUrl);
275
+
276
+ // Footer
277
+ comment += '\n---\n';
278
+ comment += `<sub>Generated by [TestDriver](https://testdriver.ai) • Run ID: \`${runId}\`</sub>\n`;
279
+
280
+ return comment;
281
+ }
282
+
283
+ /**
284
+ * Post comment to GitHub PR or commit
285
+ * @param {Object} options - Options
286
+ * @param {string} options.token - GitHub token
287
+ * @param {string} options.owner - Repository owner
288
+ * @param {string} options.repo - Repository name
289
+ * @param {number} [options.prNumber] - Pull request number (if commenting on PR)
290
+ * @param {string} [options.commitSha] - Commit SHA (if commenting on commit)
291
+ * @param {string} options.body - Comment body (markdown)
292
+ * @returns {Promise<Object>} GitHub API response
293
+ */
294
+ export async function postGitHubComment(options) {
295
+ const { token, owner, repo, prNumber, commitSha, body } = options;
296
+
297
+ if (!token) {
298
+ throw new Error('GitHub token is required');
299
+ }
300
+
301
+ if (!owner || !repo) {
302
+ throw new Error('Repository owner and name are required');
303
+ }
304
+
305
+ if (!prNumber && !commitSha) {
306
+ throw new Error('Either prNumber or commitSha must be provided');
307
+ }
308
+
309
+ const octokit = new Octokit({ auth: token });
310
+
311
+ if (prNumber) {
312
+ // Comment on PR
313
+ const response = await octokit.rest.issues.createComment({
314
+ owner,
315
+ repo,
316
+ issue_number: prNumber,
317
+ body,
318
+ });
319
+ return response.data;
320
+ } else if (commitSha) {
321
+ // Comment on commit
322
+ const response = await octokit.rest.repos.createCommitComment({
323
+ owner,
324
+ repo,
325
+ commit_sha: commitSha,
326
+ body,
327
+ });
328
+ return response.data;
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Update existing GitHub comment
334
+ * @param {Object} options - Options
335
+ * @param {string} options.token - GitHub token
336
+ * @param {string} options.owner - Repository owner
337
+ * @param {string} options.repo - Repository name
338
+ * @param {number} options.commentId - Comment ID to update
339
+ * @param {string} options.body - Updated comment body (markdown)
340
+ * @returns {Promise<Object>} GitHub API response
341
+ */
342
+ export async function updateGitHubComment(options) {
343
+ const { token, owner, repo, commentId, body } = options;
344
+
345
+ if (!token || !owner || !repo || !commentId) {
346
+ throw new Error('Token, owner, repo, and commentId are required');
347
+ }
348
+
349
+ const octokit = new Octokit({ auth: token });
350
+
351
+ const response = await octokit.rest.issues.updateComment({
352
+ owner,
353
+ repo,
354
+ comment_id: commentId,
355
+ body,
356
+ });
357
+
358
+ return response.data;
359
+ }
360
+
361
+ /**
362
+ * Find existing TestDriver comment on PR
363
+ * @param {Object} options - Options
364
+ * @param {string} options.token - GitHub token
365
+ * @param {string} options.owner - Repository owner
366
+ * @param {string} options.repo - Repository name
367
+ * @param {number} options.prNumber - Pull request number
368
+ * @returns {Promise<Object|null>} Existing comment or null
369
+ */
370
+ export async function findExistingComment(options) {
371
+ const { token, owner, repo, prNumber } = options;
372
+
373
+ if (!token || !owner || !repo || !prNumber) {
374
+ return null;
375
+ }
376
+
377
+ const octokit = new Octokit({ auth: token });
378
+
379
+ const comments = await octokit.rest.issues.listComments({
380
+ owner,
381
+ repo,
382
+ issue_number: prNumber,
383
+ });
384
+
385
+ // Find comment with TestDriver signature
386
+ const existingComment = comments.data.find(comment =>
387
+ comment.body && comment.body.includes('Generated by [TestDriver]')
388
+ );
389
+
390
+ return existingComment || null;
391
+ }
392
+
393
+ /**
394
+ * Post or update GitHub comment with test results
395
+ * Always keeps the comment at the end of the thread by deleting and recreating
396
+ * @param {Object} testRunData - Test run data
397
+ * @param {Array} testCases - Array of test case objects
398
+ * @param {Object} githubOptions - GitHub API options
399
+ * @returns {Promise<Object>} GitHub API response
400
+ */
401
+ export async function postOrUpdateTestResults(testRunData, testCases, githubOptions) {
402
+ const commentBody = generateGitHubComment(testRunData, testCases);
403
+
404
+ // Try to find and delete existing comment to keep it at the end
405
+ if (githubOptions.prNumber) {
406
+ const existingComment = await findExistingComment(githubOptions);
407
+
408
+ if (existingComment) {
409
+ // Delete the old comment
410
+ const octokit = new Octokit({ auth: githubOptions.token });
411
+ await octokit.rest.issues.deleteComment({
412
+ owner: githubOptions.owner,
413
+ repo: githubOptions.repo,
414
+ comment_id: existingComment.id,
415
+ });
416
+ }
417
+ }
418
+
419
+ // Always create a new comment (will be at the end of the thread)
420
+ return await postGitHubComment({
421
+ ...githubOptions,
422
+ body: commentBody,
423
+ });
424
+ }
@@ -234,6 +234,21 @@ export function TestDriver(context, options = {}) {
234
234
  testdriver.__vitestContext = context.task;
235
235
  testDriverInstances.set(context.task, testdriver);
236
236
 
237
+ // Set platform metadata early so the reporter can show the correct OS from the start
238
+ if (!context.task.meta) {
239
+ context.task.meta = {};
240
+ }
241
+ const platform = mergedOptions.os || 'linux';
242
+ const absolutePath = context.task.file?.filepath || context.task.file?.name || 'unknown';
243
+ const projectRoot = process.cwd();
244
+ const testFile = absolutePath !== 'unknown'
245
+ ? path.relative(projectRoot, absolutePath)
246
+ : absolutePath;
247
+
248
+ context.task.meta.platform = platform;
249
+ context.task.meta.testFile = testFile;
250
+ context.task.meta.testOrder = 0;
251
+
237
252
  // Auto-connect if enabled (default: true)
238
253
  const autoConnect = config.autoConnect !== undefined ? config.autoConnect : true;
239
254
  const debugConsoleSpy = process.env.TD_DEBUG_CONSOLE_SPY === 'true';