diff --git a/.gitignore b/.gitignore index 872c5fa..ff18e6b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ coverage/ /playwright-screenshots/ /blob-report/ /playwright/.cache/ +.DS_Store diff --git a/README.md b/README.md index 7039184..2257dc3 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ There are many test case management tools available in the market, which can be Proprietary tools often come with modern, user-friendly interfaces but tend to be cloud-based, which may raise security concerns for some organizations. While some of them do offer on-premises options, these tend to be significantly more expensive. -There are also open-source tools, but many feature older user interfaces that involve frequent page reloads, which can hinder usability. +There are also open-source tools, but many feature older user interfaces that involve frequent full page reloads, which can hinder usability. With these challenges in mind, I set out to develop a modern, user-friendly, open-source test case management tool that anyone can use for free in a secure, self-hosted environment. diff --git a/backend/index.js b/backend/index.js index e05e2de..245d9b7 100644 --- a/backend/index.js +++ b/backend/index.js @@ -78,12 +78,14 @@ app.use('/folders', foldersEditRoute); app.use('/folders', foldersDeleteRoute); // "/cases" +const casesDownloadRoute = require('./routes/cases/download')(sequelize); const casesIndexRoute = require('./routes/cases/index')(sequelize); const casesIndexByProjectIdRoute = require('./routes/cases/indexByProjectId')(sequelize); const casesShowRoute = require('./routes/cases/show')(sequelize); const casesNewRoute = require('./routes/cases/new')(sequelize); const casesEditRoute = require('./routes/cases/edit')(sequelize); const casesDeleteRoute = require('./routes/cases/delete')(sequelize); +app.use('/cases', casesDownloadRoute); app.use('/cases', casesIndexRoute); app.use('/cases', casesIndexByProjectIdRoute); app.use('/cases', casesShowRoute); @@ -104,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); diff --git a/backend/package-lock.json b/backend/package-lock.json index 64215a5..0981383 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,9 +15,11 @@ "express-rate-limit": "^7.4.1", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", + "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": { @@ -218,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", @@ -395,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", @@ -1086,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", @@ -1771,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", @@ -2358,6 +2441,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-PZXg8UuAc4PcVwLosEEDYjPyfWnTEhOrUfdv+3Bx+NuAb+5NhDmXzg5fHWmdCh1mP5p7JAZfFr3IMQfcntNAdA==" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -3430,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", @@ -3631,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", @@ -3771,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", @@ -4317,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", @@ -4820,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", @@ -5265,6 +5428,11 @@ "aggregate-error": "^3.0.0" } }, + "papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-PZXg8UuAc4PcVwLosEEDYjPyfWnTEhOrUfdv+3Bx+NuAb+5NhDmXzg5fHWmdCh1mP5p7JAZfFr3IMQfcntNAdA==" + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -6038,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", diff --git a/backend/package.json b/backend/package.json index b374248..b2b5881 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,8 +18,10 @@ "express-rate-limit": "^7.4.1", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", + "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" } } diff --git a/backend/routes/cases/download.js b/backend/routes/cases/download.js new file mode 100644 index 0000000..147d9e7 --- /dev/null +++ b/backend/routes/cases/download.js @@ -0,0 +1,54 @@ +const express = require('express'); +const router = express.Router(); +const { DataTypes } = require('sequelize'); +const Papa = require('papaparse'); +const defineCase = require('../../models/cases'); + +module.exports = function (sequelize) { + const Case = defineCase(sequelize, DataTypes); + const { verifySignedIn } = require('../../middleware/auth')(sequelize); + const { verifyProjectVisibleFromFolderId } = require('../../middleware/verifyVisible')(sequelize); + + router.get('/download', verifySignedIn, verifyProjectVisibleFromFolderId, async (req, res) => { + const { folderId, type } = req.query; + + if (!folderId) { + return res.status(400).json({ error: 'folderId is required' }); + } + + if (!type) { + return res.status(400).json({ error: 'download type is required' }); + } + + try { + const cases = await Case.findAll({ + where: { folderId }, + raw: true, + }); + + if (cases.length === 0) { + return res.status(404).send('No cases found'); + } + + 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`); + 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'); + } + }); + + return router; +}; diff --git a/backend/routes/runs/download.js b/backend/routes/runs/download.js new file mode 100644 index 0000000..fa52597 --- /dev/null +++ b/backend/routes/runs/download.js @@ -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; +}; diff --git a/docs/docs/dev/backend.md b/docs/docs/dev/backend.md index 3b2c84f..d1a9f25 100644 --- a/docs/docs/dev/backend.md +++ b/docs/docs/dev/backend.md @@ -4,22 +4,6 @@ sidebar_position: 2 # Backend -## Environmental variables - -:::warning[Strongly Recommended] - -Although the system will work with default settings without setting environment variables, it is strongly recommended to set SECRET_KEY in production. - -::: - -Create `.env` File on `backend/` - -```.env -FRONTEND_ORIGIN=http://localhost:8000 -PORT=8001 -SECRET_KEY=your-secret-key -``` - ## Set up database ```bash diff --git a/docs/docs/dev/frontend.md b/docs/docs/dev/frontend.md index a0e50c2..007683f 100644 --- a/docs/docs/dev/frontend.md +++ b/docs/docs/dev/frontend.md @@ -4,20 +4,6 @@ sidebar_position: 3 # Frontend -## Environmental variables - -:::info - -Although the system will work with default settings without setting environment variables, but you can override the settings by setting environment variables. - -::: - -Create `.env` File on `frontend/` - -``` -NEXT_PUBLIC_BACKEND_ORIGIN=http://localhost:8001 -``` - ## Start frontend server with dev mode ```bash diff --git a/docs/docs/getstarted/environment.md b/docs/docs/getstarted/environment.md new file mode 100644 index 0000000..022cb74 --- /dev/null +++ b/docs/docs/getstarted/environment.md @@ -0,0 +1,31 @@ +--- +sidebar_position: 5 +--- + +# Override settings + +The system will work with default settings without setting environment variables, but you can override the settings by placing `.env` file. + +## setting frontend environment variables + +Create `.env` File on `frontend/` + +```.env title="frontend/.env" +NEXT_PUBLIC_BACKEND_ORIGIN=http://localhost:8001 +``` + +## setting backend environment variables + +Create `.env` File on `backend/` + +:::warning[Strongly Recommended] + +It is strongly recommended to set SECRET_KEY in production. + +::: + +```.env title="backend/.env" +FRONTEND_ORIGIN=http://localhost:8000 +PORT=8001 +SECRET_KEY=your-secret-key +``` diff --git a/docs/docs/index.md b/docs/docs/index.md index 0b0bc69..dd55866 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -4,4 +4,29 @@ sidebar_position: 1 # UnitTCMS -Open Source Test Case Management System +Open source test case management system designed for self-hosted use + +## Why UnitTCMS + +No matter the product, it is difficult to manage all testing with automated tests alone. Depending on the environment, Some portion of testing often involves manual test cases. In my team, we have been managing test cases using Excel. While Excel is a highly versatile tool, it is not specifically designed for test case management, which can make it inconvenient in certain scenarios. + +For example, + +- writing long test procedures can lead to oversized cells +- forced horizontal scrolling +- difficult to attach image files. +- the Excel file may become heavy +- Need to aggregate test cases and visualization +- Not ideal for simultaneous editing by multiple people. + +Due to these challenges, it becomes clear that a dedicated tool is necessary when managing a substantial number of manual tests. + +## Other tools + +There are many test case management tools available in the market, which can be categorized into proprietary and open-source solutions. + +Proprietary tools often come with modern, user-friendly interfaces but tend to be cloud-based, which may raise security concerns for some organizations. While some of them do offer on-premises options, these tend to be significantly more expensive. + +There are also open-source tools, but many feature older user interfaces that involve frequent full page reloads, which can hinder usability. + +With these challenges in mind, I set out to develop a modern, user-friendly, open-source test case management tool that anyone can use for free in a secure, self-hosted environment. diff --git a/docs/docs/why.md b/docs/docs/why.md deleted file mode 100644 index 658cd33..0000000 --- a/docs/docs/why.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -sidebar_position: 2 ---- - -# Why UnitTCMS - -No matter the product, it is difficult to manage all testing with automated tests alone. Depending on the environment, Some portion of testing often involves manual test cases. In my team, we have been managing test cases using Excel. While Excel is a highly versatile tool, it is not specifically designed for test case management, which can make it inconvenient in certain scenarios. - -For example, - -- writing long test procedures can lead to oversized cells -- forced horizontal scrolling -- difficult to attach image files. -- the Excel file may become heavy -- Need to aggregate test cases and visualization -- Not ideal for simultaneous editing by multiple people. - -Due to these challenges, it becomes clear that a dedicated tool is necessary when managing a substantial number of manual tests. - -## Other tools - -There are many test case management tools available in the market, which can be categorized into proprietary and open-source solutions. - -Proprietary tools often come with modern, user-friendly interfaces but tend to be cloud-based, which may raise security concerns for some organizations. While some of them do offer on-premises options, these tend to be significantly more expensive. - -There are also open-source tools, but many feature older user interfaces that involve frequent page reloads, which can hinder usability. - -With these challenges in mind, I set out to develop a modern, user-friendly, open-source test case management tool that anyone can use for free in a secure, self-hosted environment. diff --git a/frontend/components/ClientLink.tsx b/frontend/components/ClientLink.tsx new file mode 100644 index 0000000..83db74d --- /dev/null +++ b/frontend/components/ClientLink.tsx @@ -0,0 +1,10 @@ +'use client'; +import { forwardRef } from 'react'; +import { Link } from '@/src/i18n/routing'; +type Props = React.ComponentPropsWithoutRef; +const ClientLink = forwardRef(({ href, ...props }, ref) => ( + +)); +ClientLink.displayName = 'ClientLink'; + +export default ClientLink; diff --git a/frontend/messages/en.json b/frontend/messages/en.json index d0021d3..34af3c9 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -188,6 +188,7 @@ "close": "Close", "are_you_sure": "Are you sure you want to delete test cases?", "new_test_case": "New Test Case", + "export": "Export", "status": "Status", "no_cases_found": "No test cases found", "case_title": "Test Case Title", @@ -253,6 +254,7 @@ "updating": "Updating...", "update": "Update", "updated_test_run": "Updated test run", + "export": "Export", "progress": "Progress", "refresh": "Refresh", "id": "ID", diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index dabd405..ccd1632 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -189,6 +189,7 @@ "close": "閉じる", "are_you_sure": "テストケースを削除してもよろしいですか?", "new_test_case": "新規テストケース", + "export": "エクスポート", "status": "ステータス", "no_cases_found": "テストケースがありません", "case_title": "テストケースタイトル", @@ -254,6 +255,7 @@ "updating": "更新中...", "update": "更新", "updated_test_run": "テストランを更新しました", + "export": "エクスポート", "progress": "進捗", "refresh": "再読み込み", "id": "ID", diff --git a/frontend/src/app/[locale]/HeaderNavbarMenu.tsx b/frontend/src/app/[locale]/HeaderNavbarMenu.tsx index 40e7bb2..506ae15 100644 --- a/frontend/src/app/[locale]/HeaderNavbarMenu.tsx +++ b/frontend/src/app/[locale]/HeaderNavbarMenu.tsx @@ -94,19 +94,14 @@ export default function HeaderNavbarMenu({ messages, locale }: Props) { - 1.0.0-beta.11 + 1.0.0-beta.12 {commonLinks.map((link) => link.isExternal ? ( - } - > + {link.label} diff --git a/frontend/src/app/[locale]/PaneMainTitle.tsx b/frontend/src/app/[locale]/PaneMainTitle.tsx index c2865b8..ec1e178 100644 --- a/frontend/src/app/[locale]/PaneMainTitle.tsx +++ b/frontend/src/app/[locale]/PaneMainTitle.tsx @@ -1,9 +1,9 @@ -import { title, subtitle } from '@/components/primitives'; import { Button, Link as NextUiLink } from '@heroui/react'; import { MoveUpRight } from 'lucide-react'; -import { Link } from '@/src/i18n/routing'; import { useTranslations } from 'next-intl'; +import { title, subtitle } from '@/components/primitives'; import { LocaleCodeType } from '@/types/locale'; +import ClientLink from '@/components/ClientLink'; type Props = { locale: LocaleCodeType; @@ -34,35 +34,37 @@ export default function MainTitle({ locale }: Props) {

{t('integrate_and_manage')}

- - - + - - - + - - - +
); diff --git a/frontend/src/app/[locale]/account/AccountPage.tsx b/frontend/src/app/[locale]/account/AccountPage.tsx index dd9a580..06e7d35 100644 --- a/frontend/src/app/[locale]/account/AccountPage.tsx +++ b/frontend/src/app/[locale]/account/AccountPage.tsx @@ -86,11 +86,16 @@ export default function AccountPage({ messages, locale }: Props) { ) : ( <> {messages.notOwnAnyProjects} - - - + )} diff --git a/frontend/src/app/[locale]/account/authPage.tsx b/frontend/src/app/[locale]/account/authPage.tsx index 6ff9cec..34a8df5 100644 --- a/frontend/src/app/[locale]/account/authPage.tsx +++ b/frontend/src/app/[locale]/account/authPage.tsx @@ -97,11 +97,16 @@ export default function AuthPage({ isSignup, messages, locale }: Props) {

{messages.title}

- - - +
diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx index c0ea790..26558cf 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx @@ -1,11 +1,11 @@ 'use client'; import { useState, useEffect, useContext } from 'react'; -import { TokenContext } from '@/utils/TokenProvider'; import TestCaseTable from './TestCaseTable'; -import { fetchCases, createCase, deleteCases } from '@/utils/caseControl'; +import CaseDialog from './CaseDialog'; +import { TokenContext } from '@/utils/TokenProvider'; +import { fetchCases, createCase, deleteCases, exportCases } from '@/utils/caseControl'; import { CaseType, CasesMessages } from '@/types/case'; import DeleteConfirmDialog from '@/components/DeleteConfirmDialog'; -import CaseDialog from './CaseDialog'; import { PriorityMessages } from '@/types/priority'; import { LocaleCodeType } from '@/types/locale'; @@ -74,6 +74,10 @@ export default function CasesPane({ projectId, folderId, messages, priorityMessa } }; + const onExportCases = async (type: string) => { + await exportCases(context.token.access_token, Number(folderId), type); + }; + return ( <> setIsCaseDialogOpen(true)} onDeleteCase={onDeleteCase} onDeleteCases={onDeleteCases} + onExportCases={onExportCases} messages={messages} priorityMessages={priorityMessages} locale={locale} diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseTable.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseTable.tsx index e22040e..cd5242e 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseTable.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseTable.tsx @@ -13,9 +13,10 @@ import { DropdownItem, Selection, SortDescriptor, + ButtonGroup, } from '@heroui/react'; -import { Link, NextUiLinkClasses } from '@/src/i18n/routing'; -import { Plus, MoreVertical, Trash } 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'; import TestCasePriority from '@/components/TestCasePriority'; @@ -28,6 +29,7 @@ type Props = { onCreateCase: () => void; onDeleteCase: (caseId: number) => void; onDeleteCases: (caseIds: number[]) => void; + onExportCases: (type: string) => void; messages: CasesMessages; priorityMessages: PriorityMessages; locale: LocaleCodeType; @@ -40,6 +42,7 @@ export default function TestCaseTable({ onCreateCase, onDeleteCase, onDeleteCases, + onExportCases, messages, priorityMessages, locale, @@ -56,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) { @@ -82,14 +86,15 @@ export default function TestCaseTable({ return {cellValue as number}; case 'title': return ( - ); case 'priority': @@ -130,6 +135,10 @@ export default function TestCaseTable({ setSelectedKeys(new Set([])); }; + const handleExportTypeChange = (keys: Selection) => { + setExportType(new Set(Array.from(keys as Set))); + }; + const classNames = useMemo( () => ({ wrapper: ['max-w-3xl'], @@ -153,6 +162,7 @@ export default function TestCaseTable({ <>

{messages.testCaseList}

+
{((selectedKeys !== 'all' && selectedKeys.size > 0) || selectedKeys === 'all') && ( + + + + + + }> + json + + }> + csv + + + +
- {isDirty && } + + + + + + }> + xml + + }> + json + + }> + csv + + + +