feat: enhance build process and testing workflow

- Updated package.json scripts to include separate unit and integration tests before building.
- Added a new script `test-build-test` to run tests before and after the build process.
- Introduced a new local CI/CD script for streamlined local development and testing.
- Refactored PlayerTab component to normalize skills data structure and added tooltips for Space Marine and Power Armour abilities.
- Added named export for Tooltip in DeathwatchRoller for reuse in other components.
This commit is contained in:
2025-08-16 15:14:28 +02:00
parent b6320ceff6
commit 671865d100
12 changed files with 2462 additions and 35 deletions
+1246
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -252,7 +252,8 @@ router.post('/', async (req, res) => {
router.put('/:name', requireSession, async (req, res) => {
try {
const { name } = req.params;
const updateData = req.body;
// Ensure updateData is an object to avoid runtime TypeErrors when fields are missing
const updateData = req.body || {};
// Check if player exists
const existingPlayer = playerHelpers.getByName(name);
@@ -270,9 +271,9 @@ router.put('/:name', requireSession, async (req, res) => {
// Merge the data properly
const mergedData = {
rollerInfo: { ...existingPlayer.rollerInfo, ...updateData.rollerInfo },
shopInfo: { ...existingPlayer.shopInfo, ...updateData.shopInfo },
tabInfo: { ...existingPlayer.tabInfo, ...updateData.tabInfo },
rollerInfo: { ...existingPlayer.rollerInfo, ...(updateData.rollerInfo || {}) },
shopInfo: { ...existingPlayer.shopInfo, ...(updateData.shopInfo || {}) },
tabInfo: { ...existingPlayer.tabInfo, ...(updateData.tabInfo || {}) },
// Never carry forward plaintext pw into validation; keep pw empty and use pwHash
pw: '',
pwHash: updateData.pwHash || existingPlayer.pwHash
View File
+59 -13
View File
@@ -25,14 +25,38 @@ app.use(cors());
// API Routes (before static files)
console.log('Registering API routes...');
app.use('/api/players', playerRoutes);
console.log('Players routes registered');
app.use('/api/sessions', sessionRoutes);
console.log('Sessions routes registered');
app.use('/api/shop', shopRoutes);
console.log('Shop routes registered');
app.use('/api/rules', rulesRoutes);
console.log('Rules routes registered');
try {
console.log('Registering /api/players');
app.use('/api/players', playerRoutes);
console.log('Players routes registered');
} catch (e) {
console.error('Error mounting /api/players:', e && e.stack ? e.stack : e);
throw e;
}
try {
console.log('Registering /api/sessions');
app.use('/api/sessions', sessionRoutes);
console.log('Sessions routes registered');
} catch (e) {
console.error('Error mounting /api/sessions:', e && e.stack ? e.stack : e);
throw e;
}
try {
console.log('Registering /api/shop');
app.use('/api/shop', shopRoutes);
console.log('Shop routes registered');
} catch (e) {
console.error('Error mounting /api/shop:', e && e.stack ? e.stack : e);
throw e;
}
try {
console.log('Registering /api/rules');
app.use('/api/rules', rulesRoutes);
console.log('Rules routes registered');
} catch (e) {
console.error('Error mounting /api/rules:', e && e.stack ? e.stack : e);
throw e;
}
// Serve static files from build directory (React app)
const buildDir = path.join(__dirname, '..', 'build');
@@ -66,16 +90,38 @@ process.on('SIGTERM', () => {
process.exit(0);
});
// Root route for friendly message
app.get('/', (req, res) => {
res.send('Deathwatch Roller API is running with SQLite. Use /api/players for player data.');
});
// If a React build exists, serve it at root and fallback to index.html for client-side routing
const indexHtml = path.join(buildDir, 'index.html');
if (fs.existsSync(indexHtml)) {
app.get('/', (req, res) => res.sendFile(indexHtml));
// Use routes
// Catch-all for client-side routes (must be after API routes)
// Middleware fallback for client-side routing: serve index.html for non-API and non-avatar paths.
// Use a plain middleware (no path) to avoid route string parsing by path-to-regexp.
app.use((req, res, next) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/avatars/')) return next();
return res.sendFile(indexHtml);
});
} else {
// Root route for friendly message when no build is present
app.get('/', (req, res) => {
res.send('Deathwatch Roller API is running with SQLite. Use /api/players for player data.');
});
}
// Use routes (instrument mounts to debug invalid route patterns)
console.log('Mounting route: /api/shop');
app.use('/api/shop', shopRoutes);
console.log('Mounted /api/shop');
console.log('Mounting route: /api/players');
app.use('/api/players', playerRoutes);
console.log('Mounted /api/players');
console.log('Mounting route: /api/sessions');
app.use('/api/sessions', sessionRoutes);
console.log('Mounted /api/sessions');
console.log('Mounting route: /api/rules');
app.use('/api/rules', rulesRoutes);
console.log('Mounted /api/rules');
// Start Server
app.listen(PORT, '0.0.0.0', () => {
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1013 -3
View File
File diff suppressed because it is too large Load Diff
+9 -5
View File
@@ -20,17 +20,21 @@
"scripts": {
"start": "npm test && react-scripts start",
"dev": "npm test && react-scripts start",
"build": "npm test && react-scripts build",
"test": "jest --runInBand --passWithNoTests",
"build": "npm run test:unit && npm run test:integration && react-scripts build && npm run test:unit",
"test-build-test": "npm run test:unit && npm run test:integration && npm run build && npm run test:unit",
"test": "jest --runInBand --passWithNoTests",
"test:unit": "jest --runInBand --passWithNoTests --testPathPattern=src/tests",
"test:integration": "npm run pm2:start && sleep 1 && jest --runInBand --passWithNoTests --testPathPattern=tests && npm run pm2:stop",
"deploy:build": "npm run build && npm run pm2:start && npm run pm2:reload",
"eject": "react-scripts eject",
"serve": "npm test && serve -s build",
"extract:armoury": "node scripts/extract-armoury.js \"$PWD/Deathwatch-Core-Rulebook.pdf\" public/deathwatch-armoury.json",
"server": "npm test && cd database && node server.js",
"server:notest": "cd database && node server.js",
"db:sqlite": "cd database && ./switch-db.sh sqlite",
"pm2:start": "cd database && pm2 start pm2.config.js --env production --update-env",
"pm2:stop": "pm2 stop deathwatch-server || true",
"pm2:reload": "cd database && pm2 reload pm2.config.js --env production --update-env",
"pm2:start": "cd database && pm2 start pm2.config.js --env production --update-env",
"pm2:stop": "pm2 stop deathwatch-server || true",
"pm2:reload": "cd database && pm2 reload pm2.config.js --env production --update-env",
"prestart": "",
"preserver": ""
},
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
# Local CI/CD helper script
# Usage:
# ./scripts/local-ci.sh # run install, unit tests, integration tests, build
# ./scripts/local-ci.sh --deploy # run above then reload pm2 (calls npm run pm2:reload)
echo "[local-ci] Starting local CI run"
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT_DIR"
echo "[local-ci] Running npm ci to ensure deterministic install"
if npm ci; then
echo "[local-ci] npm ci succeeded"
else
echo "[local-ci] npm ci failed — falling back to npm install (local dev mode)"
npm install
fi
echo "[local-ci] Running unit tests"
npm run test:unit --silent
echo "[local-ci] Running integration tests (this will start/stop PM2 as configured)"
# test:integration starts pm2, runs tests, and stops pm2
npm run test:integration --silent
echo "[local-ci] Building production bundle"
npm run build --silent
if [ "${1:-}" = "--deploy" ]; then
echo "[local-ci] Deploy flag detected: reloading PM2"
npm run pm2:reload || (echo "[local-ci] npm run pm2:reload failed, attempting pm2 reload directly" && pm2 reload database/pm2.config.js || true)
fi
echo "[local-ci] Finished successfully"
+3
View File
@@ -1122,3 +1122,6 @@ if (process.env.NODE_ENV === 'development') {
}
export default DeathwatchRoller;
// Named export for reuse in other components
export { Tooltip };
+91 -10
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import axios from 'axios';
import logger, { debug, info, warn, logApiCall, logApiError, logUserAction } from '../utils/logger';
import { Tooltip } from './DeathwatchRoller';
const STORAGE_SHOP_AUTHED = 'dw:shop:authedPlayer';
const STORAGE_SHOP_PLAYERS = 'dw:shop:players:v1';
@@ -29,6 +30,8 @@ const CHARACTERISTICS = [
{ key: 'fel', label: 'Fellowship (Fel)' },
]
// Updated default skills to Space Marine skills
const SKILLS = [
'Acrobatics (Ag)', 'Awareness (Per)', 'Charm (Fel)', 'Climb (S)', 'Command (Fel)',
@@ -67,7 +70,7 @@ function PlayerTab({
const [pastEvent, setPastEvent] = useState('');
const [personalDemeanour, setPersonalDemeanour] = useState('');
const [characteristics, setCharacteristics] = useState({});
const [skills, setSkills] = useState([]);
const [skills, setSkills] = useState({});
const [weapons, setWeapons] = useState([]);
const [armour, setArmour] = useState({});
const [talents, setTalents] = useState('');
@@ -89,6 +92,37 @@ function PlayerTab({
// Debug state
const [showLogs, setShowLogs] = useState(false);
const [logs, setLogs] = useState([]);
// Ability descriptions (used as hover tooltips)
const SPACE_MARINE_ABILITIES = [
{ name: 'Secondary Heart', desc: 'Once per long rest, when reduced to 0 Wounds you remain at 1 Wound instead of falling unconscious (stunned 1 round).' },
{ name: "Larraman's Organ", desc: 'When stabilised or treated with basic medicae, regain an extra 1–3 Wounds (GM roll) or gain +10 to recovery checks.' },
{ name: 'Catalepsean Node', desc: 'Ignore penalties from lack of sleep for ~48 hours and reduce exhaustion effects; can grant allies advantage on vigilance checks during extended watches.' },
{ name: 'Preomnor', desc: 'Strong bonus to toxin-resistance tests (GM: auto-success or +20) and halve duration/effect of ingested poisons.' },
{ name: 'Omophagea', desc: 'On consuming a biological sample (GM permission), gain a one-time clue or +10 to lore/identification checks related to that subject.' },
{ name: 'Multi-lung', desc: 'Ignore breathing penalties in toxic/low-oxygen environments and avoid short-term inhalation damage.' },
{ name: 'Sus-an Membrane', desc: 'Can enter suspended animation to avoid bleeding out when gravely wounded; buys time for evacuation/treatment.' },
{ name: 'Oolitic Kidney', desc: 'Reduce toxin damage/effects by half once per exposure or gain advantage on resisting chemical effects.' },
{ name: 'Neuroglottis', desc: 'Automatic/strong bonus to checks identifying substances by taste; +10 to Tracking vs a target you have tasted.' },
{ name: 'Mucranoid', desc: 'Small resistance to disease and contaminants; +10 on disease-resistance and recovery rolls.' },
{ name: "Betcher's Gland", desc: 'One-use close toxin/venom attack per rest or apply a minor toxin effect on a successful melee strike (GM adjudication).' },
{ name: 'Progenoid Glands', desc: 'Store genetic material for Chapter preservation; may enable long-term resurrection rituals (GM process).' },
{ name: 'Melanochrome', desc: '+10 or advantage to perception in varied lighting and camouflage detection.' },
{ name: 'Occulobe', desc: 'Ignore low-light penalties; see in darkness to short range.' },
{ name: "Lyman's Ear", desc: 'Advantage on hearing/perception checks and ability to detect faint sounds.' }
];
const POWER_ARMOUR_ABILITIES = [
{ name: 'Servo-Augmented Musculature', desc: '+20 Strength while wearing Power Armour.' },
{ name: 'Auto-senses', desc: 'Dark sight; immune to Photon Flash and Stun Grenades; Called Shots are Half Actions; +10 to sight and hearing Awareness Tests (stacks with Heightened Senses).' },
{ name: 'Built-in Vox Link', desc: 'Integrated communications — no external vox gear required.' },
{ name: 'Built-in Magboots', desc: 'Grip in zero-G or slippery conditions; avoid slipping/falling penalties.' },
{ name: 'Nutrient Recycling', desc: 'Operate up to two weeks without resupply.' },
{ name: 'Recoil Suppression', desc: 'May fire Basic weapons one-handed without penalty while in Power Armour.' },
{ name: 'Black Carapace / Size: Hulking', desc: 'Large frame, but Black Carapace prevents enemies gaining a size-based bonus to hit you while in Power Armour.' },
{ name: 'Poor Manual Dexterity', desc: 'Delicate tasks suffer a −10 penalty unless using Space Marine-specific tools.' },
{ name: 'Osmotic Gill Life Sustainer', desc: 'Extended underwater/contaminated atmosphere operation support.' }
];
// Initialize GM state is now handled in App.js
@@ -97,6 +131,10 @@ function PlayerTab({
if (showLogs) setLogs(logger.getLogs({ limit: 200 }));
}, [showLogs]);
// GM helpers (define early so hooks using them can call safely)
const isGMLoggedIn = useCallback(() => authedPlayer === 'gm', [authedPlayer]);
const isGMOrShopAuthed = useCallback(() => isGMLoggedIn() || !!authedPlayer, [isGMLoggedIn, authedPlayer]);
// Remove localStorage cache for players except after backend fetch
const currentPlayer = useMemo(() => {
@@ -129,7 +167,7 @@ function PlayerTab({
setPastEvent('');
setPersonalDemeanour('');
setCharacteristics({});
setSkills([]);
setSkills({});
setWeapons([]);
setArmour({});
setTalents('');
@@ -162,7 +200,7 @@ function PlayerTab({
setPastEvent(tabInfo.pastEvent || '');
setPersonalDemeanour(tabInfo.personalDemeanour || '');
setCharacteristics(tabInfo.characteristics || {});
setSkills(tabInfo.skills || []);
setSkills(normalizeIncomingSkills(tabInfo.skills || []));
setWeapons(tabInfo.weapons || []);
setArmour(tabInfo.armour || {});
setPicture(tabInfo.picture || tabInfo.avatar || '');
@@ -337,7 +375,7 @@ function PlayerTab({
pastEvent: updatedPlayer.pastEvent,
personalDemeanour: updatedPlayer.personalDemeanour,
characteristics: updatedPlayer.characteristics,
skills: updatedPlayer.skills,
skills: serializeSkillsForSave(updatedPlayer.skills),
weapons: updatedPlayer.weapons,
armour: updatedPlayer.armour,
talents: updatedPlayer.talents,
@@ -432,9 +470,7 @@ function PlayerTab({
// Define shopAuthed for use in the component
const shopAuthed = getShopAuthed();
// Stable callbacks so hooks depending on them don't change each render
const isGMLoggedIn = useCallback(() => authedPlayer === 'gm', [authedPlayer]);
const isGMOrShopAuthed = useCallback(() => isGMLoggedIn() || !!authedPlayer, [isGMLoggedIn, authedPlayer]);
// Stable callbacks defined earlier
// GM session is now managed in App.js
@@ -695,6 +731,33 @@ function PlayerTab({
return headers;
}
// Skills normalization helpers
function normalizeIncomingSkills(raw) {
// Backend stores skills as array of names; frontend expects an object map
if (Array.isArray(raw)) {
const obj = {};
for (const s of raw) {
if (!s) continue;
obj[s] = { trained: true, plus10: false, plus20: false };
}
return obj;
}
if (raw && typeof raw === 'object') return raw;
return {};
}
function serializeSkillsForSave(skillsObj) {
// Convert frontend skills object back to an array of skill names for backend
if (Array.isArray(skillsObj)) return skillsObj.filter(Boolean);
if (!skillsObj || typeof skillsObj !== 'object') return [];
const out = [];
for (const [k, v] of Object.entries(skillsObj)) {
if (!k) continue;
if (v && (v.trained || v.plus10 || v.plus20)) out.push(k);
}
return out;
}
function refreshLogs() { setLogs(logger.getLogs({ limit: 200 })); }
function clearLogs() { logger.clearLogs(); setLogs([]); }
@@ -901,11 +964,29 @@ function PlayerTab({
</div>
</div>
{/* Space Marine Abilities */}
{/* Space Marine Abilities - hover tooltips */}
<div className="bg-white/5 rounded-xl p-3 border border-white/10">
<div className="font-semibold mb-2">Space Marine Abilities</div>
<div className="text-xs opacity-80">
Secondary Heart, Larraman’s Organ, Catalepsean Node, Preomnor, Omophagea, Multi-lung, Sus-an Membrane, Oolitic Kidney, Neuroglottis, Mucranoid, Betcher’s Gland, Progenoid Glands, Melanochrome, Occulobe, Lyman’s Ear
<div className="text-xs opacity-80 grid grid-cols-2 md:grid-cols-3 gap-2">
{SPACE_MARINE_ABILITIES.map(a => (
<div key={a.name} className="p-2 bg-white/3 rounded flex items-center justify-between">
<span className="mr-2">{a.name}</span>
<Tooltip text={a.desc}><span className="inline-block w-5 h-5 text-center text-black bg-white rounded-full text-xs leading-5">?</span></Tooltip>
</div>
))}
</div>
</div>
{/* Power Armour Abilities - hover tooltips */}
<div className="bg-white/5 rounded-xl p-3 border border-white/10 mt-4">
<div className="font-semibold mb-2">Power Armour Abilities (standard)</div>
<div className="text-xs opacity-80 grid grid-cols-2 md:grid-cols-3 gap-2">
{POWER_ARMOUR_ABILITIES.map(a => (
<div key={a.name} className="p-2 bg-white/3 rounded flex items-center justify-between">
<span className="mr-2">{a.name}</span>
<Tooltip text={a.desc}><span className="inline-block w-5 h-5 text-center text-black bg-white rounded-full text-xs leading-5">?</span></Tooltip>
</div>
))}
</div>
</div>