feat: export test run result (#220)

This commit is contained in:
kimatata
2025-05-06 20:31:47 +09:00
committed by GitHub
parent c2ef8d4c7b
commit 0faa5f0be2
19 changed files with 488 additions and 55 deletions

View File

@@ -106,11 +106,13 @@ app.use('/attachments', attachmentsDeleteRoute);
app.use('/attachments', attachmentsDownloadRoute); app.use('/attachments', attachmentsDownloadRoute);
// "/runs" // "/runs"
const runsDownloadRoute = require('./routes/runs/download')(sequelize);
const runsIndexRoute = require('./routes/runs/index')(sequelize); const runsIndexRoute = require('./routes/runs/index')(sequelize);
const runsShowRoute = require('./routes/runs/show')(sequelize); const runsShowRoute = require('./routes/runs/show')(sequelize);
const runsNewRoute = require('./routes/runs/new')(sequelize); const runsNewRoute = require('./routes/runs/new')(sequelize);
const runsEditRoute = require('./routes/runs/edit')(sequelize); const runsEditRoute = require('./routes/runs/edit')(sequelize);
const runDeleteRoute = require('./routes/runs/delete')(sequelize); const runDeleteRoute = require('./routes/runs/delete')(sequelize);
app.use('/runs', runsDownloadRoute);
app.use('/runs', runsIndexRoute); app.use('/runs', runsIndexRoute);
app.use('/runs', runsShowRoute); app.use('/runs', runsShowRoute);
app.use('/runs', runsNewRoute); app.use('/runs', runsNewRoute);

View File

@@ -18,7 +18,8 @@
"papaparse": "^5.5.2", "papaparse": "^5.5.2",
"sequelize": "^6.37.5", "sequelize": "^6.37.5",
"sequelize-cli": "^6.6.2", "sequelize-cli": "^6.6.2",
"sqlite3": "^5.1.7" "sqlite3": "^5.1.7",
"xmlbuilder2": "^3.1.1"
} }
}, },
"node_modules/@gar/promisify": { "node_modules/@gar/promisify": {
@@ -219,6 +220,50 @@
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
"integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==" "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": { "node_modules/@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -396,6 +441,19 @@
"node": ">= 6" "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": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "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", "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==" "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": { "node_modules/etag": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "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": "^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": { "node_modules/jsbn": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", "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", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" "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": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "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", "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
"integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==" "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": { "@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "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": { "array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "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": { "etag": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "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": { "jsbn": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", "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", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" "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": { "xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@@ -21,6 +21,7 @@
"papaparse": "^5.5.2", "papaparse": "^5.5.2",
"sequelize": "^6.37.5", "sequelize": "^6.37.5",
"sequelize-cli": "^6.6.2", "sequelize-cli": "^6.6.2",
"sqlite3": "^5.1.7" "sqlite3": "^5.1.7",
"xmlbuilder2": "^3.1.1"
} }
} }

View File

@@ -30,6 +30,9 @@ module.exports = function (sequelize) {
return res.status(404).send('No cases found'); return res.status(404).send('No cases found');
} }
if (type === 'json') {
return res.json(cases);
} else if (type === 'csv') {
const csv = Papa.unparse(cases, { const csv = Papa.unparse(cases, {
quotes: true, quotes: true,
skipEmptyLines: true, skipEmptyLines: true,
@@ -37,7 +40,10 @@ module.exports = function (sequelize) {
res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename=cases_folder_${folderId}.csv`); res.setHeader('Content-Disposition', `attachment; filename=cases_folder_${folderId}.csv`);
res.send(csv); return res.send(csv);
}
return res.status(400).json({ error: 'Unsupported type. Use ?type=json or ?type=csv' });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res.status(500).send('Internal Server Error'); res.status(500).send('Internal Server Error');

View 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;
};

View File

@@ -188,7 +188,7 @@
"close": "Close", "close": "Close",
"are_you_sure": "Are you sure you want to delete test cases?", "are_you_sure": "Are you sure you want to delete test cases?",
"new_test_case": "New Test Case", "new_test_case": "New Test Case",
"download_csv": "Download CSV", "export": "Export",
"status": "Status", "status": "Status",
"no_cases_found": "No test cases found", "no_cases_found": "No test cases found",
"case_title": "Test Case Title", "case_title": "Test Case Title",
@@ -254,6 +254,7 @@
"updating": "Updating...", "updating": "Updating...",
"update": "Update", "update": "Update",
"updated_test_run": "Updated test run", "updated_test_run": "Updated test run",
"export": "Export",
"progress": "Progress", "progress": "Progress",
"refresh": "Refresh", "refresh": "Refresh",
"id": "ID", "id": "ID",

View File

@@ -189,7 +189,7 @@
"close": "閉じる", "close": "閉じる",
"are_you_sure": "テストケースを削除してもよろしいですか?", "are_you_sure": "テストケースを削除してもよろしいですか?",
"new_test_case": "新規テストケース", "new_test_case": "新規テストケース",
"download_csv": "CSVダウンロード", "export": "エクスポート",
"status": "ステータス", "status": "ステータス",
"no_cases_found": "テストケースがありません", "no_cases_found": "テストケースがありません",
"case_title": "テストケースタイトル", "case_title": "テストケースタイトル",
@@ -255,6 +255,7 @@
"updating": "更新中...", "updating": "更新中...",
"update": "更新", "update": "更新",
"updated_test_run": "テストランを更新しました", "updated_test_run": "テストランを更新しました",
"export": "エクスポート",
"progress": "進捗", "progress": "進捗",
"refresh": "再読み込み", "refresh": "再読み込み",
"id": "ID", "id": "ID",

View File

@@ -3,7 +3,7 @@ import { useState, useEffect, useContext } from 'react';
import TestCaseTable from './TestCaseTable'; import TestCaseTable from './TestCaseTable';
import CaseDialog from './CaseDialog'; import CaseDialog from './CaseDialog';
import { TokenContext } from '@/utils/TokenProvider'; 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 { CaseType, CasesMessages } from '@/types/case';
import DeleteConfirmDialog from '@/components/DeleteConfirmDialog'; import DeleteConfirmDialog from '@/components/DeleteConfirmDialog';
import { PriorityMessages } from '@/types/priority'; import { PriorityMessages } from '@/types/priority';
@@ -74,8 +74,8 @@ export default function CasesPane({ projectId, folderId, messages, priorityMessa
} }
}; };
const onCsvDownload = async () => { const onExportCases = async (type: string) => {
await csvDownload(context.token.access_token, Number(folderId)); await exportCases(context.token.access_token, Number(folderId), type);
}; };
return ( return (
@@ -87,7 +87,7 @@ export default function CasesPane({ projectId, folderId, messages, priorityMessa
onCreateCase={() => setIsCaseDialogOpen(true)} onCreateCase={() => setIsCaseDialogOpen(true)}
onDeleteCase={onDeleteCase} onDeleteCase={onDeleteCase}
onDeleteCases={onDeleteCases} onDeleteCases={onDeleteCases}
onCsvDownload={onCsvDownload} onExportCases={onExportCases}
messages={messages} messages={messages}
priorityMessages={priorityMessages} priorityMessages={priorityMessages}
locale={locale} locale={locale}

View File

@@ -13,8 +13,9 @@ import {
DropdownItem, DropdownItem,
Selection, Selection,
SortDescriptor, SortDescriptor,
ButtonGroup,
} from '@heroui/react'; } 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 { Link } from '@/src/i18n/routing';
import { CaseType, CasesMessages } from '@/types/case'; import { CaseType, CasesMessages } from '@/types/case';
import { PriorityMessages } from '@/types/priority'; import { PriorityMessages } from '@/types/priority';
@@ -28,7 +29,7 @@ type Props = {
onCreateCase: () => void; onCreateCase: () => void;
onDeleteCase: (caseId: number) => void; onDeleteCase: (caseId: number) => void;
onDeleteCases: (caseIds: number[]) => void; onDeleteCases: (caseIds: number[]) => void;
onCsvDownload: () => void; onExportCases: (type: string) => void;
messages: CasesMessages; messages: CasesMessages;
priorityMessages: PriorityMessages; priorityMessages: PriorityMessages;
locale: LocaleCodeType; locale: LocaleCodeType;
@@ -41,7 +42,7 @@ export default function TestCaseTable({
onCreateCase, onCreateCase,
onDeleteCase, onDeleteCase,
onDeleteCases, onDeleteCases,
onCsvDownload, onExportCases,
messages, messages,
priorityMessages, priorityMessages,
locale, locale,
@@ -58,6 +59,7 @@ export default function TestCaseTable({
column: 'id', column: 'id',
direction: 'ascending', direction: 'ascending',
}); });
const [exportType, setExportType] = useState(new Set(['json']));
const sortedItems = useMemo(() => { const sortedItems = useMemo(() => {
if (cases.length === 0) { if (cases.length === 0) {
@@ -133,6 +135,10 @@ export default function TestCaseTable({
setSelectedKeys(new Set([])); setSelectedKeys(new Set([]));
}; };
const handleExportTypeChange = (keys: Selection) => {
setExportType(new Set(Array.from(keys as Set<string>)));
};
const classNames = useMemo( const classNames = useMemo(
() => ({ () => ({
wrapper: ['max-w-3xl'], wrapper: ['max-w-3xl'],
@@ -170,16 +176,38 @@ export default function TestCaseTable({
{messages.delete} {messages.delete}
</Button> </Button>
)} )}
<ButtonGroup className="me-2">
<Button <Button
startContent={<Download size={16} />} startContent={<FileDown size={16} />}
size="sm" size="sm"
variant="bordered"
isDisabled={isDisabled} isDisabled={isDisabled}
className="me-2" onPress={() => onExportCases(Array.from(exportType)[0])}
onPress={onCsvDownload}
> >
{messages.downloadCsv} {messages.export} {exportType}
</Button> </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 <Button
startContent={<Plus size={16} />} startContent={<Plus size={16} />}
size="sm" size="sm"

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect, useContext, ChangeEvent, DragEvent } from 'react'; 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 { useRouter } from '@/src/i18n/routing';
import { Save, Plus, ArrowLeft, Circle } from 'lucide-react'; import { Save, Plus, ArrowLeft, Circle } from 'lucide-react';
import { priorities, testTypes, templates } from '@/config/selection'; import { priorities, testTypes, templates } from '@/config/selection';
@@ -54,7 +54,7 @@ export default function CaseEditor({
}: Props) { }: Props) {
const tokenContext = useContext(TokenContext); const tokenContext = useContext(TokenContext);
const [testCase, setTestCase] = useState<CaseType>(defaultTestCase); const [testCase, setTestCase] = useState<CaseType>(defaultTestCase);
const [isTitleInvalid, setIsTitleInvalid] = useState<boolean>(false); const [isTitleInvalid] = useState<boolean>(false);
const [isUpdating, setIsUpdating] = useState<boolean>(false); const [isUpdating, setIsUpdating] = useState<boolean>(false);
const [plusCount, setPlusCount] = useState<number>(0); const [plusCount, setPlusCount] = useState<number>(0);
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
@@ -245,9 +245,12 @@ export default function CaseEditor({
<h3 className="font-bold ms-2">{testCase.title}</h3> <h3 className="font-bold ms-2">{testCase.title}</h3>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
{isDirty && <Circle size={8} color="#525252" fill="#525252" className="me-1" />}
<Button <Button
startContent={<Save size={16} />} startContent={
<Badge isInvisible={!isDirty} color="danger" size="sm" content="" shape="circle">
<Save size={16} />
</Badge>
}
size="sm" size="sm"
isDisabled={!tokenContext.isProjectDeveloper(Number(projectId))} isDisabled={!tokenContext.isProjectDeveloper(Number(projectId))}
color="primary" color="primary"

View File

@@ -25,7 +25,7 @@ export default function Page({ params }: { params: { projectId: string; folderId
close: t('close'), close: t('close'),
areYouSure: t('are_you_sure'), areYouSure: t('are_you_sure'),
newTestCase: t('new_test_case'), newTestCase: t('new_test_case'),
downloadCsv: t('download_csv'), export: t('export'),
status: t('status'), status: t('status'),
noCasesFound: t('no_cases_found'), noCasesFound: t('no_cases_found'),
caseTitle: t('case_title'), caseTitle: t('case_title'),

View File

@@ -3,9 +3,9 @@ import { useEffect, useState, useContext } from 'react';
import { Button } from '@heroui/react'; import { Button } from '@heroui/react';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import RunsTable from './RunsTable'; import RunsTable from './RunsTable';
import RunDialog from './RunDialog';
import { fetchRuns, createRun, updateRun, deleteRun } from './runsControl'; import { fetchRuns, createRun, updateRun, deleteRun } from './runsControl';
import { RunType, RunsMessages } from '@/types/run'; import { RunType, RunsMessages } from '@/types/run';
import RunDialog from './RunDialog';
import DeleteConfirmDialog from '@/components/DeleteConfirmDialog'; import DeleteConfirmDialog from '@/components/DeleteConfirmDialog';
import { TokenContext } from '@/utils/TokenProvider'; import { TokenContext } from '@/utils/TokenProvider';
import { LocaleCodeType } from '@/types/locale'; import { LocaleCodeType } from '@/types/locale';

View File

@@ -1,7 +1,5 @@
'use client'; 'use client';
import React from 'react';
import { useState, useEffect, useContext } from 'react'; import { useState, useEffect, useContext } from 'react';
import { useRouter } from '@/src/i18n/routing';
import { import {
Button, Button,
Input, Input,
@@ -18,14 +16,23 @@ import {
DropdownMenu, DropdownMenu,
DropdownItem, DropdownItem,
addToast, addToast,
ButtonGroup,
Badge,
} from '@heroui/react'; } from '@heroui/react';
import { Save, Circle, ArrowLeft, Folder, ChevronDown, CopyPlus, CopyMinus, RotateCw } from 'lucide-react'; import {
import RunProgressChart from './RunPregressDonutChart'; Save,
import TestCaseSelector from './TestCaseSelector'; ArrowLeft,
import { testRunStatus } from '@/config/selection'; Folder,
import { RunType, RunStatusCountType, RunMessages } from '@/types/run'; ChevronDown,
import { CaseType } from '@/types/case'; CopyPlus,
import { FolderType } from '@/types/folder'; CopyMinus,
RotateCw,
FileDown,
FileSpreadsheet,
FileCode,
FileJson,
} from 'lucide-react';
import { useTheme } from 'next-themes';
import { import {
fetchRun, fetchRun,
updateRun, updateRun,
@@ -33,10 +40,17 @@ import {
fetchProjectCases, fetchProjectCases,
includeExcludeTestCases, includeExcludeTestCases,
changeStatus, changeStatus,
exportRun,
} from '../runsControl'; } from '../runsControl';
import { fetchFolders } from '../../folders/foldersControl'; 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 { TokenContext } from '@/utils/TokenProvider';
import { useTheme } from 'next-themes';
import { useFormGuard } from '@/utils/formGuard'; import { useFormGuard } from '@/utils/formGuard';
import { PriorityMessages } from '@/types/priority'; import { PriorityMessages } from '@/types/priority';
import { RunStatusMessages, TestRunCaseStatusMessages } from '@/types/status'; import { RunStatusMessages, TestRunCaseStatusMessages } from '@/types/status';
@@ -75,7 +89,7 @@ export default function RunEditor({
locale, locale,
}: Props) { }: Props) {
const tokenContext = useContext(TokenContext); const tokenContext = useContext(TokenContext);
const { theme, setTheme } = useTheme(); const { theme } = useTheme();
const [testRun, setTestRun] = useState<RunType>(defaultTestRun); const [testRun, setTestRun] = useState<RunType>(defaultTestRun);
const [folders, setFolders] = useState<FolderType[]>([]); const [folders, setFolders] = useState<FolderType[]>([]);
const [runStatusCounts, setRunStatusCounts] = useState<RunStatusCountType[]>([]); const [runStatusCounts, setRunStatusCounts] = useState<RunStatusCountType[]>([]);
@@ -83,9 +97,10 @@ export default function RunEditor({
const [selectedFolder, setSelectedFolder] = useState<FolderType | null>(null); const [selectedFolder, setSelectedFolder] = useState<FolderType | null>(null);
const [testCases, setTestCases] = useState<CaseType[]>([]); const [testCases, setTestCases] = useState<CaseType[]>([]);
const [filteredTestCases, setFilteredTestCases] = 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 [isUpdating, setIsUpdating] = useState<boolean>(false);
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
const [exportType, setExportType] = useState(new Set(['xml']));
const router = useRouter(); const router = useRouter();
useFormGuard(isDirty, messages.areYouSureLeave); useFormGuard(isDirty, messages.areYouSureLeave);
@@ -184,6 +199,10 @@ export default function RunEditor({
const baseClass = ''; const baseClass = '';
const selectedClass = `${baseClass} bg-neutral-200 dark:bg-neutral-700`; const selectedClass = `${baseClass} bg-neutral-200 dark:bg-neutral-700`;
const handleExportTypeChange = (keys: Selection) => {
setExportType(new Set(Array.from(keys as Set<string>)));
};
return ( return (
<> <>
<div className="border-b-1 dark:border-neutral-700 w-full p-3 flex items-center justify-between"> <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> <h3 className="font-bold ms-2">{testRun.name}</h3>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
{isDirty && <Circle size={8} color="#525252" fill="#525252" className="me-1" />} <ButtonGroup className="me-2">
<Button <Button
startContent={<Save size={16} />} 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={
<Badge isInvisible={!isDirty} color="danger" size="sm" content="" shape="circle">
<Save size={16} />
</Badge>
}
size="sm" size="sm"
isDisabled={!tokenContext.isProjectReporter(Number(projectId))} isDisabled={!tokenContext.isProjectReporter(Number(projectId))}
color="primary" color="primary"

View File

@@ -12,6 +12,7 @@ export default function Page({ params }: { params: { projectId: string; runId: s
updating: t('updating'), updating: t('updating'),
update: t('update'), update: t('update'),
updatedTestRun: t('updated_test_run'), updatedTestRun: t('updated_test_run'),
export: t('export'),
progress: t('progress'), progress: t('progress'),
refresh: t('refresh'), refresh: t('refresh'),
id: t('id'), id: t('id'),

View File

@@ -1,7 +1,7 @@
import { LocaleCodeType } from '@/types/locale';
import RunsPage from './RunsPage';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import RunsPage from './RunsPage';
import { LocaleCodeType } from '@/types/locale';
export async function generateMetadata({ params: { locale } }: { params: { locale: LocaleCodeType } }) { export async function generateMetadata({ params: { locale } }: { params: { locale: LocaleCodeType } }) {
const t = await getTranslations({ locale, namespace: 'Runs' }); const t = await getTranslations({ locale, namespace: 'Runs' });

View File

@@ -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) { async function fetchRunCases(jwt: string, runId: number) {
const url = `${apiServer}/runcases?runId=${runId}`; const url = `${apiServer}/runcases?runId=${runId}`;
@@ -310,6 +343,7 @@ export {
createRun, createRun,
updateRun, updateRun,
deleteRun, deleteRun,
exportRun,
fetchRunCases, fetchRunCases,
changeStatus, changeStatus,
includeExcludeTestCases, includeExcludeTestCases,

View File

@@ -70,7 +70,7 @@ type CasesMessages = {
areYouSure: string; areYouSure: string;
delete: string; delete: string;
newTestCase: string; newTestCase: string;
downloadCsv: string; export: string;
status: string; status: string;
noCasesFound: string; noCasesFound: string;
caseTitle: string; caseTitle: string;

View File

@@ -57,6 +57,7 @@ type RunMessages = {
updating: string; updating: string;
update: string; update: string;
updatedTestRun: string; updatedTestRun: string;
export: string;
progress: string; progress: string;
refresh: string; refresh: string;
id: string; id: string;

View File

@@ -132,8 +132,12 @@ async function deleteCases(jwt: string, deleteCaseIds: number[], projectId: numb
} }
} }
async function csvDownload(jwt: string, folderId: number) { async function exportCases(jwt: string, folderId: number, type: string) {
const url = `${apiServer}/cases/download?folderId=${folderId}&type=csv`; if (type !== 'json' && type !== 'csv') {
console.error('export type error. type:', type);
return;
}
const url = `${apiServer}/cases/download?folderId=${folderId}&type=${type}`;
try { try {
const response = await fetch(url, { const response = await fetch(url, {
@@ -151,7 +155,7 @@ async function csvDownload(jwt: string, folderId: number) {
const objectUrl = window.URL.createObjectURL(blob); const objectUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = objectUrl; a.href = objectUrl;
a.download = `folder_${folderId}.csv`; a.download = `folder_${folderId}.${type}`;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
a.remove(); 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 };