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.
- package/.github/workflows/test-with-comments.yml +73 -0
- package/docs/GITHUB_COMMENTS.md +330 -0
- package/docs/GITHUB_COMMENTS_ANNOUNCEMENT.md +167 -0
- package/docs/QUICK-START-GITHUB-COMMENTS.md +84 -0
- package/docs/TEST-GITHUB-COMMENTS.md +129 -0
- package/docs/github-integration-setup.md +266 -0
- package/examples/github-actions.yml +68 -0
- package/examples/github-comment-demo.test.mjs +42 -0
- package/interfaces/vitest-plugin.mjs +100 -0
- package/lib/github-comment-formatter.js +263 -0
- package/lib/github-comment.mjs +424 -0
- package/lib/vitest/hooks.mjs +15 -0
- package/package.json +2 -1
|
@@ -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 += `\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 += `[](${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
|
+
}
|
package/lib/vitest/hooks.mjs
CHANGED
|
@@ -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';
|