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.
- package/LICENSE +201 -0
- package/README.md +282 -0
- package/dist/cli/commands/generate.js +69 -0
- package/dist/cli/commands/open.js +95 -0
- package/dist/cli/generators/html-generator.js +117 -0
- package/dist/cli/history.js +150 -0
- package/dist/cli/index.js +25 -0
- package/dist/cli/qase-api.js +275 -0
- package/dist/cli/qase-transform.js +119 -0
- package/dist/cli/server.js +459 -0
- package/dist/index.html +431 -0
- package/dist/schemas/Attachment.schema.js +43 -0
- package/dist/schemas/QaseHistory.schema.js +147 -0
- package/dist/schemas/QaseRun.schema.js +146 -0
- package/dist/schemas/QaseTestResult.schema.js +122 -0
- package/dist/schemas/Step.schema.js +87 -0
- package/package.json +92 -0
|
@@ -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
|
+
}
|