Merge pull request #223 from kimatata/develop

This commit is contained in:
kimatata
2025-05-06 20:58:22 +09:00
committed by GitHub
31 changed files with 708 additions and 145 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ coverage/
/playwright-screenshots/
/blob-report/
/playwright/.cache/
.DS_Store

View File

@@ -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.

View File

@@ -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);

View File

@@ -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",

View File

@@ -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"
}
}

View File

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

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

@@ -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

View File

@@ -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

View File

@@ -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
```

View File

@@ -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.

View File

@@ -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.

View File

@@ -0,0 +1,10 @@
'use client';
import { forwardRef } from 'react';
import { Link } from '@/src/i18n/routing';
type Props = React.ComponentPropsWithoutRef<typeof Link>;
const ClientLink = forwardRef<HTMLAnchorElement, Props>(({ href, ...props }, ref) => (
<Link ref={ref} href={href} {...props} />
));
ClientLink.displayName = 'ClientLink';
export default ClientLink;

View File

@@ -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",

View File

@@ -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",

View File

@@ -94,19 +94,14 @@ export default function HeaderNavbarMenu({ messages, locale }: Props) {
<NavbarItem className="hidden md:block">
<Chip size="sm" variant="flat">
<Link className="data-[active=true]:text-primary data-[active=true]:font-medium" href="/" locale={locale}>
1.0.0-beta.11
1.0.0-beta.12
</Link>
</Chip>
</NavbarItem>
{commonLinks.map((link) =>
link.isExternal ? (
<NavbarItem key={link.uid} className="hidden md:block">
<NextUiLink
isExternal
href={link.href}
showAnchorIcon
anchorIcon={<MoveUpRight size={12} className="ms-1" />}
>
<NextUiLink isExternal href={link.href} showAnchorIcon>
{link.label}
</NextUiLink>
</NavbarItem>

View File

@@ -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) {
<h4 className={subtitle({ class: 'mt-4' })}>{t('integrate_and_manage')}</h4>
<div className="mt-5">
<Link href={`/projects/`} locale={locale}>
<Button color="primary" radius="full" className="px-0">
<Button as={ClientLink} href={`/projects/`} locale={locale} color="primary" radius="full" className="px-0">
{t('demo')}
</Button>
</Link>
<NextUiLink isExternal href="https://kimatata.github.io/unittcms/docs/getstarted/selfhost" aria-label="docs">
<Button
showAnchorIcon
as={NextUiLink}
isExternal
href="https://kimatata.github.io/unittcms/docs/getstarted/selfhost"
aria-label="docs"
color="primary"
variant="bordered"
radius="full"
className="ms-2"
endContent={<MoveUpRight size={12} />}
>
{t('get_started')}
</Button>
</NextUiLink>
<NextUiLink size="sm" isExternal href="https://github.com/kimatata/unittcms" aria-label="Github">
<Button
showAnchorIcon
as={NextUiLink}
isExternal
href="https://github.com/kimatata/unittcms"
aria-label="Github"
color="primary"
variant="bordered"
radius="full"
className="ms-2"
endContent={<MoveUpRight size={12} />}
>
GitHub
</Button>
</NextUiLink>
</div>
</div>
);

View File

@@ -86,11 +86,16 @@ export default function AccountPage({ messages, locale }: Props) {
) : (
<>
<span className="text-default-500 me-2">{messages.notOwnAnyProjects}</span>
<Link href={`/projects/`} locale={locale} className={NextUiLinkClasses}>
<Button variant="flat" size="sm" endContent={<ArrowRight size={12} />}>
<Button
as={Link}
href={`/projects/`}
locale={locale}
variant="flat"
size="sm"
endContent={<ArrowRight size={12} />}
>
{messages.findProjects}
</Button>
</Link>
</>
)}
</div>

View File

@@ -97,11 +97,16 @@ export default function AuthPage({ isSignup, messages, locale }: Props) {
<Card className="w-[480px] mt-16">
<CardHeader className="px-4 pt-4 pb-0 flex justify-between">
<h4 className="font-bold text-large">{messages.title}</h4>
<Link href={isSignup ? '/account/signin' : '/account/signup'} locale={locale}>
<Button color="primary" variant="light" endContent={<ChevronRight size={16} />}>
<Button
as={Link}
href={isSignup ? '/account/signin' : '/account/signup'}
locale={locale}
color="primary"
variant="light"
endContent={<ChevronRight size={16} />}
>
{messages.linkTitle}
</Button>
</Link>
</CardHeader>
<CardBody className="overflow-visible px-4 pt-0 pb-4">
<form>

View File

@@ -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 (
<>
<TestCaseTable
@@ -83,6 +87,7 @@ export default function CasesPane({ projectId, folderId, messages, priorityMessa
onCreateCase={() => setIsCaseDialogOpen(true)}
onDeleteCase={onDeleteCase}
onDeleteCases={onDeleteCases}
onExportCases={onExportCases}
messages={messages}
priorityMessages={priorityMessages}
locale={locale}

View File

@@ -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 <span>{cellValue as number}</span>;
case 'title':
return (
<Button size="sm" variant="light" className="data-[hover=true]:bg-transparent">
<Link
<Button
size="sm"
as={Link}
href={`/projects/${projectId}/folders/${testCase.folderId}/cases/${testCase.id}`}
locale={locale}
className={NextUiLinkClasses}
variant="light"
className="data-[hover=true]:bg-transparent"
>
{cellValue as string}
</Link>
</Button>
);
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<string>)));
};
const classNames = useMemo(
() => ({
wrapper: ['max-w-3xl'],
@@ -153,6 +162,7 @@ export default function TestCaseTable({
<>
<div className="border-b-1 dark:border-neutral-700 w-full p-3 flex items-center justify-between">
<h3 className="font-bold">{messages.testCaseList}</h3>
<div>
{((selectedKeys !== 'all' && selectedKeys.size > 0) || selectedKeys === 'all') && (
<Button
@@ -166,6 +176,38 @@ export default function TestCaseTable({
{messages.delete}
</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"

View File

@@ -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"

View File

@@ -1,7 +1,7 @@
import { PriorityMessages } from '@/types/priority';
import CasesPane from './CasesPane';
import { getTranslations } from 'next-intl/server';
import { useTranslations } from 'next-intl';
import CasesPane from './CasesPane';
import { PriorityMessages } from '@/types/priority';
import { LocaleCodeType } from '@/types/locale';
export async function generateMetadata({ params: { locale } }: { params: { locale: LocaleCodeType } }) {
@@ -25,6 +25,7 @@ export default function Page({ params }: { params: { projectId: string; folderId
close: t('close'),
areYouSure: t('are_you_sure'),
newTestCase: t('new_test_case'),
export: t('export'),
status: t('status'),
noCasesFound: t('no_cases_found'),
caseTitle: t('case_title'),

View File

@@ -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';

View File

@@ -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={<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"
isDisabled={!tokenContext.isProjectReporter(Number(projectId))}
color="primary"

View File

@@ -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'),

View File

@@ -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' });

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

View File

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

View File

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

View File

@@ -132,4 +132,37 @@ async function deleteCases(jwt: string, deleteCaseIds: number[], projectId: numb
}
}
export { fetchCase, fetchCases, updateCase, createCase, deleteCases };
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, {
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 = `folder_${folderId}.${type}`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(objectUrl);
} catch (error: any) {
console.error('Error fetching data:', error.message);
}
}
export { fetchCase, fetchCases, updateCase, createCase, deleteCases, exportCases };