feat: export test run result (#220)
This commit is contained in:
@@ -106,11 +106,13 @@ app.use('/attachments', attachmentsDeleteRoute);
|
||||
app.use('/attachments', attachmentsDownloadRoute);
|
||||
|
||||
// "/runs"
|
||||
const runsDownloadRoute = require('./routes/runs/download')(sequelize);
|
||||
const runsIndexRoute = require('./routes/runs/index')(sequelize);
|
||||
const runsShowRoute = require('./routes/runs/show')(sequelize);
|
||||
const runsNewRoute = require('./routes/runs/new')(sequelize);
|
||||
const runsEditRoute = require('./routes/runs/edit')(sequelize);
|
||||
const runDeleteRoute = require('./routes/runs/delete')(sequelize);
|
||||
app.use('/runs', runsDownloadRoute);
|
||||
app.use('/runs', runsIndexRoute);
|
||||
app.use('/runs', runsShowRoute);
|
||||
app.use('/runs', runsNewRoute);
|
||||
|
||||
170
backend/package-lock.json
generated
170
backend/package-lock.json
generated
@@ -18,7 +18,8 @@
|
||||
"papaparse": "^5.5.2",
|
||||
"sequelize": "^6.37.5",
|
||||
"sequelize-cli": "^6.6.2",
|
||||
"sqlite3": "^5.1.7"
|
||||
"sqlite3": "^5.1.7",
|
||||
"xmlbuilder2": "^3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@gar/promisify": {
|
||||
@@ -219,6 +220,50 @@
|
||||
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
|
||||
"integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw=="
|
||||
},
|
||||
"node_modules/@oozcitak/dom": {
|
||||
"version": "1.15.10",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.10.tgz",
|
||||
"integrity": "sha512-0JT29/LaxVgRcGKvHmSrUTEvZ8BXvZhGl2LASRUgHqDTC1M5g1pLmVv56IYNyt3bG2CUjDkc67wnyZC14pbQrQ==",
|
||||
"dependencies": {
|
||||
"@oozcitak/infra": "1.0.8",
|
||||
"@oozcitak/url": "1.0.4",
|
||||
"@oozcitak/util": "8.3.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/infra": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.8.tgz",
|
||||
"integrity": "sha512-JRAUc9VR6IGHOL7OGF+yrvs0LO8SlqGnPAMqyzOuFZPSZSXI7Xf2O9+awQPSMXgIWGtgUf/dA6Hs6X6ySEaWTg==",
|
||||
"dependencies": {
|
||||
"@oozcitak/util": "8.3.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/url": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-1.0.4.tgz",
|
||||
"integrity": "sha512-kDcD8y+y3FCSOvnBI6HJgl00viO/nGbQoCINmQ0h98OhnGITrWR3bOGfwYCthgcrV8AnTJz8MzslTQbC3SOAmw==",
|
||||
"dependencies": {
|
||||
"@oozcitak/infra": "1.0.8",
|
||||
"@oozcitak/util": "8.3.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/util": {
|
||||
"version": "8.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.8.tgz",
|
||||
"integrity": "sha512-T8TbSnGsxo6TDBJx/Sgv/BlVJL3tshxZP7Aq5R1mSnM5OcHY2dQaxLMu2+E8u3gN0MLOzdjurqN4ZRVuzQycOQ==",
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@@ -396,6 +441,19 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse/node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@@ -1087,6 +1145,18 @@
|
||||
"resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
|
||||
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
@@ -1772,6 +1842,18 @@
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "3.14.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
|
||||
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsbn": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
|
||||
@@ -3436,6 +3518,20 @@
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
||||
},
|
||||
"node_modules/xmlbuilder2": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-3.1.1.tgz",
|
||||
"integrity": "sha512-WCSfbfZnQDdLQLiMdGUQpMxxckeQ4oZNMNhLVkcekTu7xhD4tuUDyAPoY8CwXvBYE6LwBHd6QW2WZXlOWr1vCw==",
|
||||
"dependencies": {
|
||||
"@oozcitak/dom": "1.15.10",
|
||||
"@oozcitak/infra": "1.0.8",
|
||||
"@oozcitak/util": "8.3.8",
|
||||
"js-yaml": "3.14.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
@@ -3637,6 +3733,38 @@
|
||||
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
|
||||
"integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw=="
|
||||
},
|
||||
"@oozcitak/dom": {
|
||||
"version": "1.15.10",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.10.tgz",
|
||||
"integrity": "sha512-0JT29/LaxVgRcGKvHmSrUTEvZ8BXvZhGl2LASRUgHqDTC1M5g1pLmVv56IYNyt3bG2CUjDkc67wnyZC14pbQrQ==",
|
||||
"requires": {
|
||||
"@oozcitak/infra": "1.0.8",
|
||||
"@oozcitak/url": "1.0.4",
|
||||
"@oozcitak/util": "8.3.8"
|
||||
}
|
||||
},
|
||||
"@oozcitak/infra": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.8.tgz",
|
||||
"integrity": "sha512-JRAUc9VR6IGHOL7OGF+yrvs0LO8SlqGnPAMqyzOuFZPSZSXI7Xf2O9+awQPSMXgIWGtgUf/dA6Hs6X6ySEaWTg==",
|
||||
"requires": {
|
||||
"@oozcitak/util": "8.3.8"
|
||||
}
|
||||
},
|
||||
"@oozcitak/url": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-1.0.4.tgz",
|
||||
"integrity": "sha512-kDcD8y+y3FCSOvnBI6HJgl00viO/nGbQoCINmQ0h98OhnGITrWR3bOGfwYCthgcrV8AnTJz8MzslTQbC3SOAmw==",
|
||||
"requires": {
|
||||
"@oozcitak/infra": "1.0.8",
|
||||
"@oozcitak/util": "8.3.8"
|
||||
}
|
||||
},
|
||||
"@oozcitak/util": {
|
||||
"version": "8.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.8.tgz",
|
||||
"integrity": "sha512-T8TbSnGsxo6TDBJx/Sgv/BlVJL3tshxZP7Aq5R1mSnM5OcHY2dQaxLMu2+E8u3gN0MLOzdjurqN4ZRVuzQycOQ=="
|
||||
},
|
||||
"@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@@ -3777,6 +3905,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"requires": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@@ -4323,6 +4466,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
|
||||
},
|
||||
"etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
@@ -4826,6 +4974,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"js-yaml": {
|
||||
"version": "3.14.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
|
||||
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
|
||||
"requires": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"jsbn": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
|
||||
@@ -6049,6 +6206,17 @@
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
||||
},
|
||||
"xmlbuilder2": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-3.1.1.tgz",
|
||||
"integrity": "sha512-WCSfbfZnQDdLQLiMdGUQpMxxckeQ4oZNMNhLVkcekTu7xhD4tuUDyAPoY8CwXvBYE6LwBHd6QW2WZXlOWr1vCw==",
|
||||
"requires": {
|
||||
"@oozcitak/dom": "1.15.10",
|
||||
"@oozcitak/infra": "1.0.8",
|
||||
"@oozcitak/util": "8.3.8",
|
||||
"js-yaml": "3.14.1"
|
||||
}
|
||||
},
|
||||
"xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"papaparse": "^5.5.2",
|
||||
"sequelize": "^6.37.5",
|
||||
"sequelize-cli": "^6.6.2",
|
||||
"sqlite3": "^5.1.7"
|
||||
"sqlite3": "^5.1.7",
|
||||
"xmlbuilder2": "^3.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,14 +30,20 @@ module.exports = function (sequelize) {
|
||||
return res.status(404).send('No cases found');
|
||||
}
|
||||
|
||||
const csv = Papa.unparse(cases, {
|
||||
quotes: true,
|
||||
skipEmptyLines: true,
|
||||
});
|
||||
if (type === 'json') {
|
||||
return res.json(cases);
|
||||
} else if (type === 'csv') {
|
||||
const csv = Papa.unparse(cases, {
|
||||
quotes: true,
|
||||
skipEmptyLines: true,
|
||||
});
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename=cases_folder_${folderId}.csv`);
|
||||
res.send(csv);
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename=cases_folder_${folderId}.csv`);
|
||||
return res.send(csv);
|
||||
}
|
||||
|
||||
return res.status(400).json({ error: 'Unsupported type. Use ?type=json or ?type=csv' });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Internal Server Error');
|
||||
|
||||
126
backend/routes/runs/download.js
Normal file
126
backend/routes/runs/download.js
Normal file
@@ -0,0 +1,126 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Papa = require('papaparse');
|
||||
const { create } = require('xmlbuilder2');
|
||||
|
||||
const defineRun = require('../../models/runs');
|
||||
const defineRunCase = require('../../models/runCases');
|
||||
const defineCase = require('../../models/cases');
|
||||
const defineFolder = require('../../models/folders');
|
||||
|
||||
module.exports = function (sequelize) {
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { verifySignedIn } = require('../../middleware/auth')(sequelize);
|
||||
const { verifyProjectVisibleFromRunId } = require('../../middleware/verifyVisible')(sequelize);
|
||||
|
||||
const Run = defineRun(sequelize, DataTypes);
|
||||
const RunCase = defineRunCase(sequelize, DataTypes);
|
||||
const Case = defineCase(sequelize, DataTypes);
|
||||
const Folder = defineFolder(sequelize, DataTypes);
|
||||
|
||||
RunCase.belongsTo(Case, { foreignKey: 'caseId' });
|
||||
|
||||
router.get('/download/:runId', verifySignedIn, verifyProjectVisibleFromRunId, async (req, res) => {
|
||||
const { runId } = req.params;
|
||||
const { type } = req.query;
|
||||
|
||||
if (!runId) {
|
||||
return res.status(400).json({ error: 'runId is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const run = await Run.findByPk(runId);
|
||||
if (!run) {
|
||||
return res.status(404).send('Run not found');
|
||||
}
|
||||
|
||||
const runCases = await RunCase.findAll({
|
||||
where: { runId },
|
||||
include: [{ model: Case }],
|
||||
});
|
||||
|
||||
if (type === 'xml') {
|
||||
// JUnit xml valid status
|
||||
const validStatuses = [1, 2, 4]; // 0: untested, 1: passed, 2 failed, 3: retest, 4: skipped
|
||||
const filteredRunCases = runCases.filter((rc) => validStatuses.includes(rc.status));
|
||||
|
||||
// group cases by folder
|
||||
const folderMap = new Map();
|
||||
for (const rc of filteredRunCases) {
|
||||
const folderId = rc.Case.folderId;
|
||||
if (!folderMap.has(folderId)) {
|
||||
folderMap.set(folderId, []);
|
||||
}
|
||||
folderMap.get(folderId).push(rc);
|
||||
}
|
||||
|
||||
// Construct JUnit xml
|
||||
const xml = create({ version: '1.0' });
|
||||
const root = xml.ele('testsuites');
|
||||
|
||||
for (const [folderId, cases] of folderMap.entries()) {
|
||||
let folderName = '';
|
||||
const folder = await Folder.findByPk(folderId);
|
||||
if (folder) {
|
||||
folderName = folder.name;
|
||||
}
|
||||
|
||||
const suite = root.ele('testsuite', {
|
||||
name: folderName,
|
||||
tests: cases.length,
|
||||
failures: cases.filter((c) => c.status === 2).length,
|
||||
skipped: cases.filter((c) => c.status === 4).length,
|
||||
});
|
||||
|
||||
for (const rc of cases) {
|
||||
const testCase = suite.ele('testcase', {
|
||||
name: rc.Case.title,
|
||||
classname: folderName,
|
||||
time: '0',
|
||||
});
|
||||
|
||||
if (rc.status === 2) {
|
||||
testCase.ele('failure', { message: 'Test failed' }).txt('Test case failed.');
|
||||
} else if (rc.status === 4) {
|
||||
testCase.ele('skipped', { message: 'skipped' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const xmlString = xml.end({ prettyPrint: true });
|
||||
|
||||
res.setHeader('Content-Type', 'application/xml');
|
||||
res.setHeader('Content-Disposition', `attachment; filename=run_${runId}.xml`);
|
||||
return res.send(xmlString);
|
||||
} else if (type === 'json') {
|
||||
return res.json(runCases);
|
||||
} else if (type === 'csv') {
|
||||
const records = runCases.map((rc) => ({
|
||||
id: rc.Case.id,
|
||||
title: rc.Case.title,
|
||||
state: rc.Case.state,
|
||||
priority: rc.Case.priority,
|
||||
type: rc.Case.type,
|
||||
automationStatus: rc.Case.automationStatus,
|
||||
status: rc.status,
|
||||
}));
|
||||
|
||||
const csv = Papa.unparse(records, {
|
||||
quotes: true,
|
||||
skipEmptyLines: true,
|
||||
});
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', `attachment; filename=run_${runId}.csv`);
|
||||
return res.send(csv);
|
||||
}
|
||||
|
||||
return res.status(400).json({ error: 'Unsupported type. Use ?type=xml or ?type=json or ?type=csv' });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Internal Server Error');
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -188,7 +188,7 @@
|
||||
"close": "Close",
|
||||
"are_you_sure": "Are you sure you want to delete test cases?",
|
||||
"new_test_case": "New Test Case",
|
||||
"download_csv": "Download CSV",
|
||||
"export": "Export",
|
||||
"status": "Status",
|
||||
"no_cases_found": "No test cases found",
|
||||
"case_title": "Test Case Title",
|
||||
@@ -254,6 +254,7 @@
|
||||
"updating": "Updating...",
|
||||
"update": "Update",
|
||||
"updated_test_run": "Updated test run",
|
||||
"export": "Export",
|
||||
"progress": "Progress",
|
||||
"refresh": "Refresh",
|
||||
"id": "ID",
|
||||
|
||||
@@ -189,7 +189,7 @@
|
||||
"close": "閉じる",
|
||||
"are_you_sure": "テストケースを削除してもよろしいですか?",
|
||||
"new_test_case": "新規テストケース",
|
||||
"download_csv": "CSVダウンロード",
|
||||
"export": "エクスポート",
|
||||
"status": "ステータス",
|
||||
"no_cases_found": "テストケースがありません",
|
||||
"case_title": "テストケースタイトル",
|
||||
@@ -255,6 +255,7 @@
|
||||
"updating": "更新中...",
|
||||
"update": "更新",
|
||||
"updated_test_run": "テストランを更新しました",
|
||||
"export": "エクスポート",
|
||||
"progress": "進捗",
|
||||
"refresh": "再読み込み",
|
||||
"id": "ID",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState, useEffect, useContext } from 'react';
|
||||
import TestCaseTable from './TestCaseTable';
|
||||
import CaseDialog from './CaseDialog';
|
||||
import { TokenContext } from '@/utils/TokenProvider';
|
||||
import { fetchCases, createCase, deleteCases, csvDownload } from '@/utils/caseControl';
|
||||
import { fetchCases, createCase, deleteCases, exportCases } from '@/utils/caseControl';
|
||||
import { CaseType, CasesMessages } from '@/types/case';
|
||||
import DeleteConfirmDialog from '@/components/DeleteConfirmDialog';
|
||||
import { PriorityMessages } from '@/types/priority';
|
||||
@@ -74,8 +74,8 @@ export default function CasesPane({ projectId, folderId, messages, priorityMessa
|
||||
}
|
||||
};
|
||||
|
||||
const onCsvDownload = async () => {
|
||||
await csvDownload(context.token.access_token, Number(folderId));
|
||||
const onExportCases = async (type: string) => {
|
||||
await exportCases(context.token.access_token, Number(folderId), type);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -87,7 +87,7 @@ export default function CasesPane({ projectId, folderId, messages, priorityMessa
|
||||
onCreateCase={() => setIsCaseDialogOpen(true)}
|
||||
onDeleteCase={onDeleteCase}
|
||||
onDeleteCases={onDeleteCases}
|
||||
onCsvDownload={onCsvDownload}
|
||||
onExportCases={onExportCases}
|
||||
messages={messages}
|
||||
priorityMessages={priorityMessages}
|
||||
locale={locale}
|
||||
|
||||
@@ -13,8 +13,9 @@ import {
|
||||
DropdownItem,
|
||||
Selection,
|
||||
SortDescriptor,
|
||||
ButtonGroup,
|
||||
} from '@heroui/react';
|
||||
import { Plus, MoreVertical, Trash, Download } from 'lucide-react';
|
||||
import { Plus, MoreVertical, Trash, Download, FileDown, ChevronDown, FileJson, FileSpreadsheet } from 'lucide-react';
|
||||
import { Link } from '@/src/i18n/routing';
|
||||
import { CaseType, CasesMessages } from '@/types/case';
|
||||
import { PriorityMessages } from '@/types/priority';
|
||||
@@ -28,7 +29,7 @@ type Props = {
|
||||
onCreateCase: () => void;
|
||||
onDeleteCase: (caseId: number) => void;
|
||||
onDeleteCases: (caseIds: number[]) => void;
|
||||
onCsvDownload: () => void;
|
||||
onExportCases: (type: string) => void;
|
||||
messages: CasesMessages;
|
||||
priorityMessages: PriorityMessages;
|
||||
locale: LocaleCodeType;
|
||||
@@ -41,7 +42,7 @@ export default function TestCaseTable({
|
||||
onCreateCase,
|
||||
onDeleteCase,
|
||||
onDeleteCases,
|
||||
onCsvDownload,
|
||||
onExportCases,
|
||||
messages,
|
||||
priorityMessages,
|
||||
locale,
|
||||
@@ -58,6 +59,7 @@ export default function TestCaseTable({
|
||||
column: 'id',
|
||||
direction: 'ascending',
|
||||
});
|
||||
const [exportType, setExportType] = useState(new Set(['json']));
|
||||
|
||||
const sortedItems = useMemo(() => {
|
||||
if (cases.length === 0) {
|
||||
@@ -133,6 +135,10 @@ export default function TestCaseTable({
|
||||
setSelectedKeys(new Set([]));
|
||||
};
|
||||
|
||||
const handleExportTypeChange = (keys: Selection) => {
|
||||
setExportType(new Set(Array.from(keys as Set<string>)));
|
||||
};
|
||||
|
||||
const classNames = useMemo(
|
||||
() => ({
|
||||
wrapper: ['max-w-3xl'],
|
||||
@@ -170,16 +176,38 @@ export default function TestCaseTable({
|
||||
{messages.delete}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
startContent={<Download size={16} />}
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
isDisabled={isDisabled}
|
||||
className="me-2"
|
||||
onPress={onCsvDownload}
|
||||
>
|
||||
{messages.downloadCsv}
|
||||
</Button>
|
||||
<ButtonGroup className="me-2">
|
||||
<Button
|
||||
startContent={<FileDown size={16} />}
|
||||
size="sm"
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => onExportCases(Array.from(exportType)[0])}
|
||||
>
|
||||
{messages.export} {exportType}
|
||||
</Button>
|
||||
<Dropdown placement="bottom-end">
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly size="sm">
|
||||
<ChevronDown size={16} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
disallowEmptySelection
|
||||
aria-label="Export options"
|
||||
className="max-w-[300px]"
|
||||
selectedKeys={exportType}
|
||||
selectionMode="single"
|
||||
onSelectionChange={handleExportTypeChange}
|
||||
>
|
||||
<DropdownItem key="json" startContent={<FileJson size={16} />}>
|
||||
json
|
||||
</DropdownItem>
|
||||
<DropdownItem key="csv" startContent={<FileSpreadsheet size={16} />}>
|
||||
csv
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</ButtonGroup>
|
||||
<Button
|
||||
startContent={<Plus size={16} />}
|
||||
size="sm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useContext, ChangeEvent, DragEvent } from 'react';
|
||||
import { Input, Textarea, Select, SelectItem, Button, Divider, Tooltip, addToast } from '@heroui/react';
|
||||
import { Input, Textarea, Select, SelectItem, Button, Divider, Tooltip, addToast, Badge } from '@heroui/react';
|
||||
import { useRouter } from '@/src/i18n/routing';
|
||||
import { Save, Plus, ArrowLeft, Circle } from 'lucide-react';
|
||||
import { priorities, testTypes, templates } from '@/config/selection';
|
||||
@@ -54,7 +54,7 @@ export default function CaseEditor({
|
||||
}: Props) {
|
||||
const tokenContext = useContext(TokenContext);
|
||||
const [testCase, setTestCase] = useState<CaseType>(defaultTestCase);
|
||||
const [isTitleInvalid, setIsTitleInvalid] = useState<boolean>(false);
|
||||
const [isTitleInvalid] = useState<boolean>(false);
|
||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||
const [plusCount, setPlusCount] = useState<number>(0);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
@@ -245,9 +245,12 @@ export default function CaseEditor({
|
||||
<h3 className="font-bold ms-2">{testCase.title}</h3>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{isDirty && <Circle size={8} color="#525252" fill="#525252" className="me-1" />}
|
||||
<Button
|
||||
startContent={<Save size={16} />}
|
||||
startContent={
|
||||
<Badge isInvisible={!isDirty} color="danger" size="sm" content="" shape="circle">
|
||||
<Save size={16} />
|
||||
</Badge>
|
||||
}
|
||||
size="sm"
|
||||
isDisabled={!tokenContext.isProjectDeveloper(Number(projectId))}
|
||||
color="primary"
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function Page({ params }: { params: { projectId: string; folderId
|
||||
close: t('close'),
|
||||
areYouSure: t('are_you_sure'),
|
||||
newTestCase: t('new_test_case'),
|
||||
downloadCsv: t('download_csv'),
|
||||
export: t('export'),
|
||||
status: t('status'),
|
||||
noCasesFound: t('no_cases_found'),
|
||||
caseTitle: t('case_title'),
|
||||
|
||||
@@ -3,9 +3,9 @@ import { useEffect, useState, useContext } from 'react';
|
||||
import { Button } from '@heroui/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import RunsTable from './RunsTable';
|
||||
import RunDialog from './RunDialog';
|
||||
import { fetchRuns, createRun, updateRun, deleteRun } from './runsControl';
|
||||
import { RunType, RunsMessages } from '@/types/run';
|
||||
import RunDialog from './RunDialog';
|
||||
import DeleteConfirmDialog from '@/components/DeleteConfirmDialog';
|
||||
import { TokenContext } from '@/utils/TokenProvider';
|
||||
import { LocaleCodeType } from '@/types/locale';
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import { useState, useEffect, useContext } from 'react';
|
||||
import { useRouter } from '@/src/i18n/routing';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
@@ -18,14 +16,23 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownItem,
|
||||
addToast,
|
||||
ButtonGroup,
|
||||
Badge,
|
||||
} from '@heroui/react';
|
||||
import { Save, Circle, ArrowLeft, Folder, ChevronDown, CopyPlus, CopyMinus, RotateCw } from 'lucide-react';
|
||||
import RunProgressChart from './RunPregressDonutChart';
|
||||
import TestCaseSelector from './TestCaseSelector';
|
||||
import { testRunStatus } from '@/config/selection';
|
||||
import { RunType, RunStatusCountType, RunMessages } from '@/types/run';
|
||||
import { CaseType } from '@/types/case';
|
||||
import { FolderType } from '@/types/folder';
|
||||
import {
|
||||
Save,
|
||||
ArrowLeft,
|
||||
Folder,
|
||||
ChevronDown,
|
||||
CopyPlus,
|
||||
CopyMinus,
|
||||
RotateCw,
|
||||
FileDown,
|
||||
FileSpreadsheet,
|
||||
FileCode,
|
||||
FileJson,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import {
|
||||
fetchRun,
|
||||
updateRun,
|
||||
@@ -33,10 +40,17 @@ import {
|
||||
fetchProjectCases,
|
||||
includeExcludeTestCases,
|
||||
changeStatus,
|
||||
exportRun,
|
||||
} from '../runsControl';
|
||||
import { fetchFolders } from '../../folders/foldersControl';
|
||||
import RunProgressChart from './RunPregressDonutChart';
|
||||
import TestCaseSelector from './TestCaseSelector';
|
||||
import { useRouter } from '@/src/i18n/routing';
|
||||
import { testRunStatus } from '@/config/selection';
|
||||
import { RunType, RunStatusCountType, RunMessages } from '@/types/run';
|
||||
import { CaseType } from '@/types/case';
|
||||
import { FolderType } from '@/types/folder';
|
||||
import { TokenContext } from '@/utils/TokenProvider';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useFormGuard } from '@/utils/formGuard';
|
||||
import { PriorityMessages } from '@/types/priority';
|
||||
import { RunStatusMessages, TestRunCaseStatusMessages } from '@/types/status';
|
||||
@@ -75,7 +89,7 @@ export default function RunEditor({
|
||||
locale,
|
||||
}: Props) {
|
||||
const tokenContext = useContext(TokenContext);
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { theme } = useTheme();
|
||||
const [testRun, setTestRun] = useState<RunType>(defaultTestRun);
|
||||
const [folders, setFolders] = useState<FolderType[]>([]);
|
||||
const [runStatusCounts, setRunStatusCounts] = useState<RunStatusCountType[]>([]);
|
||||
@@ -83,9 +97,10 @@ export default function RunEditor({
|
||||
const [selectedFolder, setSelectedFolder] = useState<FolderType | null>(null);
|
||||
const [testCases, setTestCases] = useState<CaseType[]>([]);
|
||||
const [filteredTestCases, setFilteredTestCases] = useState<CaseType[]>([]);
|
||||
const [isNameInvalid, setIsNameInvalid] = useState<boolean>(false);
|
||||
const [isNameInvalid] = useState<boolean>(false);
|
||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [exportType, setExportType] = useState(new Set(['xml']));
|
||||
const router = useRouter();
|
||||
useFormGuard(isDirty, messages.areYouSureLeave);
|
||||
|
||||
@@ -184,6 +199,10 @@ export default function RunEditor({
|
||||
const baseClass = '';
|
||||
const selectedClass = `${baseClass} bg-neutral-200 dark:bg-neutral-700`;
|
||||
|
||||
const handleExportTypeChange = (keys: Selection) => {
|
||||
setExportType(new Set(Array.from(keys as Set<string>)));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border-b-1 dark:border-neutral-700 w-full p-3 flex items-center justify-between">
|
||||
@@ -201,9 +220,47 @@ export default function RunEditor({
|
||||
<h3 className="font-bold ms-2">{testRun.name}</h3>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{isDirty && <Circle size={8} color="#525252" fill="#525252" className="me-1" />}
|
||||
<ButtonGroup className="me-2">
|
||||
<Button
|
||||
startContent={<FileDown size={16} />}
|
||||
size="sm"
|
||||
isDisabled={!tokenContext.isProjectReporter(Number(projectId))}
|
||||
onPress={() => exportRun(tokenContext.token.access_token, Number(testRun.id), Array.from(exportType)[0])}
|
||||
>
|
||||
{messages.export} {exportType}
|
||||
</Button>
|
||||
<Dropdown placement="bottom-end">
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly size="sm">
|
||||
<ChevronDown size={16} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
disallowEmptySelection
|
||||
aria-label="Export options"
|
||||
className="max-w-[300px]"
|
||||
selectedKeys={exportType}
|
||||
selectionMode="single"
|
||||
onSelectionChange={handleExportTypeChange}
|
||||
>
|
||||
<DropdownItem key="xml" startContent={<FileCode size={16} />}>
|
||||
xml
|
||||
</DropdownItem>
|
||||
<DropdownItem key="json" startContent={<FileJson size={16} />}>
|
||||
json
|
||||
</DropdownItem>
|
||||
<DropdownItem key="csv" startContent={<FileSpreadsheet size={16} />}>
|
||||
csv
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</ButtonGroup>
|
||||
<Button
|
||||
startContent={<Save size={16} />}
|
||||
startContent={
|
||||
<Badge isInvisible={!isDirty} color="danger" size="sm" content="" shape="circle">
|
||||
<Save size={16} />
|
||||
</Badge>
|
||||
}
|
||||
size="sm"
|
||||
isDisabled={!tokenContext.isProjectReporter(Number(projectId))}
|
||||
color="primary"
|
||||
|
||||
@@ -12,6 +12,7 @@ export default function Page({ params }: { params: { projectId: string; runId: s
|
||||
updating: t('updating'),
|
||||
update: t('update'),
|
||||
updatedTestRun: t('updated_test_run'),
|
||||
export: t('export'),
|
||||
progress: t('progress'),
|
||||
refresh: t('refresh'),
|
||||
id: t('id'),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LocaleCodeType } from '@/types/locale';
|
||||
import RunsPage from './RunsPage';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import RunsPage from './RunsPage';
|
||||
import { LocaleCodeType } from '@/types/locale';
|
||||
|
||||
export async function generateMetadata({ params: { locale } }: { params: { locale: LocaleCodeType } }) {
|
||||
const t = await getTranslations({ locale, namespace: 'Runs' });
|
||||
|
||||
@@ -129,6 +129,39 @@ async function deleteRun(jwt: string, runId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function exportRun(jwt: string, runId: number, type: string) {
|
||||
if (type !== 'xml' && type !== 'json' && type !== 'csv') {
|
||||
console.error('export type error. type:', type);
|
||||
return;
|
||||
}
|
||||
const url = `${apiServer}/runs/download/${runId}?type=${type}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const objectUrl = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = objectUrl;
|
||||
a.download = `run_${runId}.${type}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(objectUrl);
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching data:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRunCases(jwt: string, runId: number) {
|
||||
const url = `${apiServer}/runcases?runId=${runId}`;
|
||||
|
||||
@@ -310,6 +343,7 @@ export {
|
||||
createRun,
|
||||
updateRun,
|
||||
deleteRun,
|
||||
exportRun,
|
||||
fetchRunCases,
|
||||
changeStatus,
|
||||
includeExcludeTestCases,
|
||||
|
||||
@@ -70,7 +70,7 @@ type CasesMessages = {
|
||||
areYouSure: string;
|
||||
delete: string;
|
||||
newTestCase: string;
|
||||
downloadCsv: string;
|
||||
export: string;
|
||||
status: string;
|
||||
noCasesFound: string;
|
||||
caseTitle: string;
|
||||
|
||||
@@ -57,6 +57,7 @@ type RunMessages = {
|
||||
updating: string;
|
||||
update: string;
|
||||
updatedTestRun: string;
|
||||
export: string;
|
||||
progress: string;
|
||||
refresh: string;
|
||||
id: string;
|
||||
|
||||
@@ -132,8 +132,12 @@ async function deleteCases(jwt: string, deleteCaseIds: number[], projectId: numb
|
||||
}
|
||||
}
|
||||
|
||||
async function csvDownload(jwt: string, folderId: number) {
|
||||
const url = `${apiServer}/cases/download?folderId=${folderId}&type=csv`;
|
||||
async function exportCases(jwt: string, folderId: number, type: string) {
|
||||
if (type !== 'json' && type !== 'csv') {
|
||||
console.error('export type error. type:', type);
|
||||
return;
|
||||
}
|
||||
const url = `${apiServer}/cases/download?folderId=${folderId}&type=${type}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
@@ -151,7 +155,7 @@ async function csvDownload(jwt: string, folderId: number) {
|
||||
const objectUrl = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = objectUrl;
|
||||
a.download = `folder_${folderId}.csv`;
|
||||
a.download = `folder_${folderId}.${type}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
@@ -161,4 +165,4 @@ async function csvDownload(jwt: string, folderId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
export { fetchCase, fetchCases, updateCase, createCase, deleteCases, csvDownload };
|
||||
export { fetchCase, fetchCases, updateCase, createCase, deleteCases, exportCases };
|
||||
|
||||
Reference in New Issue
Block a user