qase-report 1.0.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.
@@ -0,0 +1,117 @@
1
+ import { readFileSync, existsSync, readdirSync } from 'fs';
2
+ import { join, resolve } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ // Strip UTF-8 BOM that some tools (e.g. .NET reporters) prepend to JSON files
5
+ const stripBom = (s) => s.replace(/^\uFEFF/, '');
6
+ /**
7
+ * Escape JSON string for safe embedding in HTML.
8
+ * Prevents XSS by replacing characters that could break out of script context.
9
+ *
10
+ * @param json - JSON string to escape
11
+ * @returns XSS-safe JSON string
12
+ */
13
+ export function escapeJsonForHtml(json) {
14
+ return json
15
+ .replace(/</g, '\\u003c') // Prevent tag injection
16
+ .replace(/>/g, '\\u003e') // Consistency
17
+ .replace(/\//g, '\\/'); // Prevent </script> breaking
18
+ }
19
+ /**
20
+ * Inject data into HTML as window global variable.
21
+ *
22
+ * @param html - HTML template string
23
+ * @param dataKey - Global variable name (e.g., "__QASE_RUN_DATA__")
24
+ * @param data - Data to embed
25
+ * @returns Modified HTML with injected data
26
+ */
27
+ export function injectData(html, dataKey, data) {
28
+ const json = JSON.stringify(data);
29
+ const escaped = escapeJsonForHtml(json);
30
+ const script = `<script>window.${dataKey}=${escaped};</script>`;
31
+ // Inject before closing </head> tag
32
+ return html.replace('</head>', `${script}\n</head>`);
33
+ }
34
+ /**
35
+ * Load report data from filesystem.
36
+ *
37
+ * @param reportPath - Path to report directory
38
+ * @returns Report data with run and results
39
+ * @throws If run.json is missing or invalid
40
+ */
41
+ export function loadReportData(reportPath) {
42
+ const runJsonPath = join(reportPath, 'run.json');
43
+ // Read run.json
44
+ if (!existsSync(runJsonPath)) {
45
+ throw new Error(`run.json not found at ${runJsonPath}`);
46
+ }
47
+ const run = JSON.parse(stripBom(readFileSync(runJsonPath, 'utf-8')));
48
+ // Read all *.json files from results/ directory
49
+ const resultsDir = join(reportPath, 'results');
50
+ const results = [];
51
+ if (existsSync(resultsDir)) {
52
+ const resultFiles = readdirSync(resultsDir).filter((file) => file.endsWith('.json'));
53
+ for (const file of resultFiles) {
54
+ const resultPath = join(resultsDir, file);
55
+ const resultData = JSON.parse(stripBom(readFileSync(resultPath, 'utf-8')));
56
+ results.push(resultData);
57
+ }
58
+ }
59
+ return { run, results };
60
+ }
61
+ /**
62
+ * Load history data from filesystem.
63
+ * Returns null if file doesn't exist (graceful handling).
64
+ *
65
+ * @param reportPath - Path to report directory
66
+ * @param historyPath - Optional custom history file path
67
+ * @returns History data or null if not available
68
+ */
69
+ export function loadHistoryData(reportPath, historyPath) {
70
+ // Default path: reportPath/qase-report-history.json
71
+ const path = historyPath || join(reportPath, 'qase-report-history.json');
72
+ if (!existsSync(path)) {
73
+ return null;
74
+ }
75
+ try {
76
+ return JSON.parse(stripBom(readFileSync(path, 'utf-8')));
77
+ }
78
+ catch (error) {
79
+ console.warn(`Warning: Failed to load history from ${path}:`, error instanceof Error ? error.message : error);
80
+ return null;
81
+ }
82
+ }
83
+ /**
84
+ * Generate self-contained HTML report with embedded data.
85
+ *
86
+ * @param options - Generation options
87
+ * @returns HTML string with embedded report data
88
+ * @throws If template or report data cannot be loaded
89
+ */
90
+ export function generateHtmlReport(options) {
91
+ const { reportPath, historyPath, templatePath } = options;
92
+ // Resolve report path
93
+ const resolvedReportPath = resolve(reportPath);
94
+ // Default template path: ../../../dist/index.html relative to this file
95
+ const defaultTemplatePath = fileURLToPath(new URL('../../../dist/index.html', import.meta.url));
96
+ const resolvedTemplatePath = templatePath
97
+ ? resolve(templatePath)
98
+ : defaultTemplatePath;
99
+ // Load template HTML
100
+ if (!existsSync(resolvedTemplatePath)) {
101
+ throw new Error(`HTML template not found at ${resolvedTemplatePath}`);
102
+ }
103
+ let html = readFileSync(resolvedTemplatePath, 'utf-8');
104
+ // Load report data
105
+ const { run, results } = loadReportData(resolvedReportPath);
106
+ // Load history (optional)
107
+ const history = loadHistoryData(resolvedReportPath, historyPath);
108
+ // Inject data into HTML
109
+ html = injectData(html, '__QASE_RUN_DATA__', run);
110
+ html = injectData(html, '__QASE_RESULTS_DATA__', results);
111
+ if (history !== null) {
112
+ html = injectData(html, '__QASE_HISTORY_DATA__', history);
113
+ }
114
+ // Set static mode flag
115
+ html = injectData(html, '__QASE_STATIC_MODE__', true);
116
+ return html;
117
+ }
@@ -0,0 +1,150 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
2
+ import { dirname } from 'path';
3
+ /**
4
+ * Maximum number of runs to store in history file.
5
+ * Per requirements HIST-04.
6
+ */
7
+ const MAX_HISTORY_RUNS = 30;
8
+ /**
9
+ * Default filename for history file.
10
+ */
11
+ export const DEFAULT_HISTORY_FILENAME = 'qase-report-history.json';
12
+ /**
13
+ * Loads history data from file.
14
+ * Returns null if file doesn't exist (not an error).
15
+ * Returns null and logs warning if JSON parse fails (corrupted file).
16
+ *
17
+ * @param historyPath - Path to history file
18
+ * @returns Parsed history data or null
19
+ */
20
+ export function loadHistory(historyPath) {
21
+ try {
22
+ if (!existsSync(historyPath)) {
23
+ return null;
24
+ }
25
+ const data = readFileSync(historyPath, 'utf-8').replace(/^\uFEFF/, '');
26
+ const parsed = JSON.parse(data);
27
+ return parsed;
28
+ }
29
+ catch (error) {
30
+ console.warn(`Warning: Failed to load history from ${historyPath}:`, error instanceof Error ? error.message : error);
31
+ return null;
32
+ }
33
+ }
34
+ /**
35
+ * Saves history data to file.
36
+ * Creates parent directory if needed.
37
+ *
38
+ * @param historyPath - Path to history file
39
+ * @param history - History data to save
40
+ */
41
+ export function saveHistory(historyPath, history) {
42
+ const dir = dirname(historyPath);
43
+ // Create parent directory if needed
44
+ if (!existsSync(dir)) {
45
+ mkdirSync(dir, { recursive: true });
46
+ }
47
+ writeFileSync(historyPath, JSON.stringify(history, null, 2), 'utf-8');
48
+ }
49
+ /**
50
+ * Adds current run to history file.
51
+ * Creates new history if file doesn't exist.
52
+ * Enforces MAX_HISTORY_RUNS limit by removing oldest runs.
53
+ * Skips if run already exists (idempotent).
54
+ *
55
+ * @param options - Configuration object
56
+ * @param options.historyPath - Path to history file
57
+ * @param options.run - Current run metadata
58
+ * @param options.results - Array of test results
59
+ */
60
+ export function addRunToHistory(options) {
61
+ const { historyPath, run, results } = options;
62
+ // Load existing history or create new
63
+ let history = loadHistory(historyPath);
64
+ if (!history) {
65
+ history = {
66
+ schema_version: '1.0.0',
67
+ runs: [],
68
+ tests: [],
69
+ };
70
+ }
71
+ // Generate runId from timestamp
72
+ const runId = run.execution.start_time
73
+ ? new Date(run.execution.start_time).getTime().toString()
74
+ : Date.now().toString();
75
+ // Check for duplicate and skip if exists
76
+ if (history.runs.some((r) => r.run_id === runId)) {
77
+ return;
78
+ }
79
+ // Create HistoricalRun object
80
+ const historicalRun = {
81
+ run_id: runId,
82
+ title: run.title ?? null,
83
+ environment: run.environment ?? null,
84
+ start_time: run.execution.start_time
85
+ ? new Date(run.execution.start_time).getTime()
86
+ : Date.now(),
87
+ end_time: run.execution.end_time
88
+ ? new Date(run.execution.end_time).getTime()
89
+ : Date.now(),
90
+ duration: run.execution.duration,
91
+ stats: {
92
+ total: run.stats.total,
93
+ passed: run.stats.passed,
94
+ failed: run.stats.failed,
95
+ skipped: run.stats.skipped,
96
+ blocked: run.stats.blocked,
97
+ invalid: run.stats.invalid,
98
+ muted: run.stats.muted,
99
+ },
100
+ };
101
+ // Add run to history
102
+ history.runs.push(historicalRun);
103
+ // Update per-test history
104
+ for (const testResult of results) {
105
+ const signature = testResult.signature;
106
+ if (!signature)
107
+ continue;
108
+ // Find or create test entry
109
+ let testEntry = history.tests.find((t) => t.signature === signature);
110
+ if (!testEntry) {
111
+ testEntry = {
112
+ signature,
113
+ title: testResult.title,
114
+ runs: [],
115
+ };
116
+ history.tests.push(testEntry);
117
+ }
118
+ // Extract first line of error message for flakiness detection
119
+ let errorMessage = null;
120
+ if (testResult.execution.stacktrace) {
121
+ const firstLine = testResult.execution.stacktrace.split('\n')[0];
122
+ errorMessage = firstLine?.trim() || null;
123
+ }
124
+ // Add run data for this test
125
+ const testRunData = {
126
+ run_id: runId,
127
+ status: testResult.execution.status,
128
+ duration: testResult.execution.duration,
129
+ start_time: testResult.execution.start_time
130
+ ? new Date(testResult.execution.start_time).getTime()
131
+ : Date.now(),
132
+ error_message: errorMessage,
133
+ };
134
+ testEntry.runs.push(testRunData);
135
+ }
136
+ // Enforce MAX_HISTORY_RUNS limit
137
+ while (history.runs.length > MAX_HISTORY_RUNS) {
138
+ const oldestRun = history.runs.shift();
139
+ if (oldestRun) {
140
+ // Remove run data from per-test history
141
+ for (const testEntry of history.tests) {
142
+ testEntry.runs = testEntry.runs.filter((r) => r.run_id !== oldestRun.run_id);
143
+ }
144
+ // Remove tests with no remaining run data (orphaned tests)
145
+ history.tests = history.tests.filter((t) => t.runs.length > 0);
146
+ }
147
+ }
148
+ // Save updated history
149
+ saveHistory(historyPath, history);
150
+ }
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ import { program } from 'commander';
3
+ import { readFileSync } from 'fs';
4
+ import { fileURLToPath } from 'url';
5
+ import { dirname, join } from 'path';
6
+ import { registerOpenCommand } from './commands/open.js';
7
+ import { registerGenerateCommand } from './commands/generate.js';
8
+ // Get version from package.json dynamically
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = dirname(__filename);
11
+ const packageJsonPath = join(__dirname, '..', '..', 'package.json');
12
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
13
+ program
14
+ .name('qase-report')
15
+ .description('Visualize Qase test reports in an interactive UI')
16
+ .version(packageJson.version, '-v, --version', 'Output the current version')
17
+ .showHelpAfterError();
18
+ // Register commands
19
+ registerOpenCommand(program);
20
+ registerGenerateCommand(program);
21
+ program.parse(process.argv);
22
+ // Show help if no command provided
23
+ if (!process.argv.slice(2).length) {
24
+ program.outputHelp();
25
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Qase API client for interacting with Qase TMS API.
3
+ * Handles run creation, result submission, and run completion.
4
+ */
5
+ /**
6
+ * Custom error class for Qase API errors
7
+ */
8
+ export class QaseApiError extends Error {
9
+ statusCode;
10
+ qaseMessage;
11
+ constructor(message, statusCode, qaseMessage) {
12
+ super(message);
13
+ this.statusCode = statusCode;
14
+ this.qaseMessage = qaseMessage;
15
+ this.name = 'QaseApiError';
16
+ }
17
+ }
18
+ /**
19
+ * Format Date object as "YYYY-MM-DD HH:mm:ss" in UTC
20
+ */
21
+ function formatDateTime(date) {
22
+ const year = date.getUTCFullYear();
23
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0');
24
+ const day = String(date.getUTCDate()).padStart(2, '0');
25
+ const hours = String(date.getUTCHours()).padStart(2, '0');
26
+ const minutes = String(date.getUTCMinutes()).padStart(2, '0');
27
+ const seconds = String(date.getUTCSeconds()).padStart(2, '0');
28
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
29
+ }
30
+ /**
31
+ * Create a new test run in Qase TMS
32
+ * @returns Run ID
33
+ */
34
+ export async function createQaseRun(options) {
35
+ const { apiToken, projectCode, title, startTime } = options;
36
+ const url = `https://api.qase.io/v1/run/${projectCode}`;
37
+ const runStartTime = startTime
38
+ ? formatDateTime(new Date(startTime * 1000))
39
+ : formatDateTime(new Date());
40
+ try {
41
+ const response = await fetch(url, {
42
+ method: 'POST',
43
+ headers: {
44
+ Token: apiToken,
45
+ 'Content-Type': 'application/json',
46
+ },
47
+ body: JSON.stringify({
48
+ title,
49
+ is_autotest: true,
50
+ start_time: runStartTime,
51
+ }),
52
+ });
53
+ const responseText = await response.text();
54
+ let data;
55
+ try {
56
+ data = JSON.parse(responseText);
57
+ }
58
+ catch {
59
+ data = { raw: responseText };
60
+ }
61
+ if (!response.ok) {
62
+ const errorMessage = data.errorMessage || data.message || response.statusText;
63
+ if (response.status === 401) {
64
+ throw new QaseApiError('Invalid API token', 401, String(errorMessage));
65
+ }
66
+ else if (response.status === 404) {
67
+ throw new QaseApiError(`Project not found: ${projectCode}`, 404, String(errorMessage));
68
+ }
69
+ else {
70
+ throw new QaseApiError(`Qase API error: ${response.statusText}`, response.status, String(errorMessage));
71
+ }
72
+ }
73
+ if (!data.status || !data.result?.id) {
74
+ throw new QaseApiError('Invalid response from Qase API', response.status, 'Missing run ID in response');
75
+ }
76
+ return data.result.id;
77
+ }
78
+ catch (err) {
79
+ if (err instanceof QaseApiError) {
80
+ throw err;
81
+ }
82
+ // Network or other error
83
+ const message = err instanceof Error ? err.message : 'Unknown error';
84
+ throw new QaseApiError(`Network error: ${message}`, 0, message);
85
+ }
86
+ }
87
+ /**
88
+ * Send test results to a Qase run
89
+ */
90
+ export async function sendQaseResults(options) {
91
+ const { apiToken, projectCode, runId, results } = options;
92
+ const url = `https://api.qase.io/v2/${projectCode}/run/${runId}/results`;
93
+ try {
94
+ const response = await fetch(url, {
95
+ method: 'POST',
96
+ headers: {
97
+ Token: apiToken,
98
+ 'Content-Type': 'application/json',
99
+ },
100
+ body: JSON.stringify({ results }),
101
+ });
102
+ if (!response.ok) {
103
+ const responseText = await response.text();
104
+ let data;
105
+ try {
106
+ data = JSON.parse(responseText);
107
+ }
108
+ catch {
109
+ data = { raw: responseText };
110
+ }
111
+ const errorMessage = data.errorMessage || data.message || response.statusText;
112
+ if (response.status === 401) {
113
+ throw new QaseApiError('Invalid API token', 401, String(errorMessage));
114
+ }
115
+ else if (response.status === 404) {
116
+ throw new QaseApiError(`Run not found: ${runId}`, 404, String(errorMessage));
117
+ }
118
+ else {
119
+ throw new QaseApiError(`Qase API error: ${response.statusText}`, response.status, String(errorMessage));
120
+ }
121
+ }
122
+ }
123
+ catch (err) {
124
+ if (err instanceof QaseApiError) {
125
+ throw err;
126
+ }
127
+ // Network or other error
128
+ const message = err instanceof Error ? err.message : 'Unknown error';
129
+ throw new QaseApiError(`Network error: ${message}`, 0, message);
130
+ }
131
+ }
132
+ /**
133
+ * Mark a test run as complete in Qase TMS
134
+ */
135
+ export async function completeQaseRun(options) {
136
+ const { apiToken, projectCode, runId } = options;
137
+ const url = `https://api.qase.io/v1/run/${projectCode}/${runId}/complete`;
138
+ try {
139
+ const response = await fetch(url, {
140
+ method: 'POST',
141
+ headers: {
142
+ Token: apiToken,
143
+ 'Content-Type': 'application/json',
144
+ },
145
+ });
146
+ if (!response.ok) {
147
+ const data = await response.json();
148
+ const errorMessage = data.errorMessage || data.message || response.statusText;
149
+ if (response.status === 401) {
150
+ throw new QaseApiError('Invalid API token', 401, errorMessage);
151
+ }
152
+ else if (response.status === 404) {
153
+ throw new QaseApiError(`Run not found: ${runId}`, 404, errorMessage);
154
+ }
155
+ else {
156
+ throw new QaseApiError(`Qase API error: ${response.statusText}`, response.status, errorMessage);
157
+ }
158
+ }
159
+ }
160
+ catch (err) {
161
+ if (err instanceof QaseApiError) {
162
+ throw err;
163
+ }
164
+ // Network or other error
165
+ const message = err instanceof Error ? err.message : 'Unknown error';
166
+ throw new QaseApiError(`Network error: ${message}`, 0, message);
167
+ }
168
+ }
169
+ /**
170
+ * Upload attachments to Qase TMS and return a map of attachment id → hash
171
+ *
172
+ * Batching: max 20 files per request, max 128 MB per request
173
+ * Skips files larger than 32 MB
174
+ */
175
+ export async function uploadAttachments(options) {
176
+ const { apiToken, projectCode, files } = options;
177
+ const url = `https://api.qase.io/v1/attachment/${projectCode}`;
178
+ const idToHash = new Map();
179
+ const MAX_FILES_PER_BATCH = 20;
180
+ const MAX_BATCH_SIZE = 128 * 1024 * 1024; // 128 MB
181
+ const MAX_FILE_SIZE = 32 * 1024 * 1024; // 32 MB
182
+ // Build batches respecting file count and size limits
183
+ const batches = [];
184
+ let currentBatch = [];
185
+ let currentBatchSize = 0;
186
+ for (const file of files) {
187
+ const { readFileSync, statSync } = await import('fs');
188
+ let fileSize;
189
+ try {
190
+ fileSize = statSync(file.path).size;
191
+ }
192
+ catch {
193
+ console.warn(`Skipping attachment ${file.name}: cannot stat file`);
194
+ continue;
195
+ }
196
+ if (fileSize > MAX_FILE_SIZE) {
197
+ console.warn(`Skipping attachment ${file.name}: file size ${(fileSize / 1024 / 1024).toFixed(1)} MB exceeds 32 MB limit`);
198
+ continue;
199
+ }
200
+ if (currentBatch.length >= MAX_FILES_PER_BATCH ||
201
+ currentBatchSize + fileSize > MAX_BATCH_SIZE) {
202
+ if (currentBatch.length > 0) {
203
+ batches.push(currentBatch);
204
+ }
205
+ currentBatch = [];
206
+ currentBatchSize = 0;
207
+ }
208
+ currentBatch.push(file);
209
+ currentBatchSize += fileSize;
210
+ }
211
+ if (currentBatch.length > 0) {
212
+ batches.push(currentBatch);
213
+ }
214
+ // Upload each batch
215
+ for (const batch of batches) {
216
+ const { readFileSync } = await import('fs');
217
+ const formData = new FormData();
218
+ for (const file of batch) {
219
+ const fileBuffer = readFileSync(file.path);
220
+ const blob = new Blob([fileBuffer], { type: file.mimeType });
221
+ formData.append('file[]', blob, file.name);
222
+ }
223
+ try {
224
+ const response = await fetch(url, {
225
+ method: 'POST',
226
+ headers: {
227
+ Token: apiToken,
228
+ },
229
+ body: formData,
230
+ });
231
+ const responseText = await response.text();
232
+ let data;
233
+ try {
234
+ data = JSON.parse(responseText);
235
+ }
236
+ catch {
237
+ data = { raw: responseText };
238
+ }
239
+ if (!response.ok) {
240
+ const errorMessage = data.errorMessage || data.message || response.statusText;
241
+ console.warn(`Warning: Failed to upload attachment batch: ${errorMessage}`);
242
+ continue;
243
+ }
244
+ // Map uploaded files back to their IDs by matching filenames
245
+ const resultItems = data.result;
246
+ if (Array.isArray(resultItems)) {
247
+ // Build a filename→id lookup from current batch
248
+ const nameToIds = new Map();
249
+ for (const file of batch) {
250
+ const ids = nameToIds.get(file.name) || [];
251
+ ids.push(file.id);
252
+ nameToIds.set(file.name, ids);
253
+ }
254
+ for (const item of resultItems) {
255
+ const ids = nameToIds.get(item.filename);
256
+ if (ids && ids.length > 0) {
257
+ const id = ids.shift();
258
+ idToHash.set(id, item.hash);
259
+ }
260
+ }
261
+ }
262
+ }
263
+ catch (err) {
264
+ const message = err instanceof Error ? err.message : 'Unknown error';
265
+ console.warn(`Warning: Failed to upload attachment batch: ${message}`);
266
+ }
267
+ }
268
+ return idToHash;
269
+ }
270
+ /**
271
+ * Build URL to view a run in Qase TMS web interface
272
+ */
273
+ export function buildRunUrl(projectCode, runId) {
274
+ return `https://app.qase.io/run/${projectCode}/dashboard/${runId}`;
275
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Result transformer - converts internal QaseTestResult format to Qase API v2 ResultCreate format
3
+ */
4
+ /**
5
+ * Map internal test status to Qase API status
6
+ */
7
+ function mapStatus(status) {
8
+ switch (status) {
9
+ case 'passed':
10
+ return 'passed';
11
+ case 'failed':
12
+ return 'failed';
13
+ case 'skipped':
14
+ return 'skipped';
15
+ case 'broken':
16
+ return 'failed'; // Qase API has no 'broken' status
17
+ case 'blocked':
18
+ return 'blocked';
19
+ case 'invalid':
20
+ return 'invalid';
21
+ case 'muted':
22
+ return 'passed'; // Muted tests are treated as passed
23
+ default:
24
+ return 'failed'; // Safe fallback
25
+ }
26
+ }
27
+ /**
28
+ * Map internal step status to Qase API step status
29
+ */
30
+ function mapStepStatus(status) {
31
+ switch (status) {
32
+ case 'passed':
33
+ return 'passed';
34
+ case 'failed':
35
+ return 'failed';
36
+ case 'blocked':
37
+ return 'blocked';
38
+ case 'skipped':
39
+ return 'skipped';
40
+ case 'broken':
41
+ return 'failed'; // Qase API has no 'broken' status
42
+ default:
43
+ return 'failed'; // Safe fallback
44
+ }
45
+ }
46
+ /**
47
+ * Resolve attachment hashes from attachment map
48
+ */
49
+ function resolveAttachmentHashes(attachments, attachmentMap) {
50
+ if (!attachmentMap || attachments.length === 0)
51
+ return [];
52
+ const hashes = [];
53
+ for (const att of attachments) {
54
+ const hash = attachmentMap.get(att.id);
55
+ if (hash)
56
+ hashes.push(hash);
57
+ }
58
+ return hashes;
59
+ }
60
+ /**
61
+ * Transform a single step to Qase API v2 format (recursive)
62
+ */
63
+ function transformStep(step, attachmentMap) {
64
+ const resultStep = {
65
+ step_type: 'text',
66
+ data: {
67
+ action: step.data.action || step.step_type,
68
+ expected_result: step.data.expected_result !== null && step.data.expected_result !== undefined
69
+ ? step.data.expected_result
70
+ : undefined,
71
+ },
72
+ execution: {
73
+ status: mapStepStatus(step.execution.status),
74
+ attachments: resolveAttachmentHashes(step.execution.attachments, attachmentMap),
75
+ },
76
+ };
77
+ // Add nested steps if they exist
78
+ if (step.steps && step.steps.length > 0) {
79
+ resultStep.steps = step.steps.map((s) => transformStep(s, attachmentMap));
80
+ }
81
+ return resultStep;
82
+ }
83
+ /**
84
+ * Transform a single test result to Qase API v2 format
85
+ */
86
+ export function transformResult(result, attachmentMap) {
87
+ return {
88
+ title: result.title,
89
+ execution: {
90
+ status: mapStatus(result.execution.status),
91
+ start_time: result.execution.start_time,
92
+ end_time: result.execution.end_time ??
93
+ result.execution.start_time + result.execution.duration,
94
+ duration: result.execution.duration,
95
+ stacktrace: result.execution.stacktrace !== null
96
+ ? result.execution.stacktrace
97
+ : undefined,
98
+ thread: result.execution.thread !== null ? result.execution.thread : undefined,
99
+ },
100
+ testops_ids: result.testops_ids !== null && result.testops_ids !== undefined
101
+ ? result.testops_ids
102
+ : undefined,
103
+ attachments: resolveAttachmentHashes(result.attachments, attachmentMap),
104
+ steps: result.steps.map(s => transformStep(s, attachmentMap)),
105
+ params: result.params,
106
+ param_groups: result.param_groups,
107
+ relations: result.relations ?? {},
108
+ message: result.message !== null ? result.message : undefined,
109
+ fields: Object.fromEntries(Object.entries(result.fields).filter(([, v]) => v !== null)),
110
+ defect: false, // No defect tracking in report format
111
+ signature: result.signature,
112
+ };
113
+ }
114
+ /**
115
+ * Transform multiple test results to Qase API v2 format
116
+ */
117
+ export function transformResults(results, attachmentMap) {
118
+ return results.map(r => transformResult(r, attachmentMap));
119
+ }