mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
test(dashboard): fail when game text is written into the pages
GameTextJs scans templates, scripts and the routes' strings for the game's zone, item and character names and currency labels, with an allowlist for legitimate uses and a self check of the scanner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -105,6 +105,9 @@ if(NODE_EXECUTABLE)
|
||||
# Which links the dashboard follows without a reload, and its in-place update rules (static/js/nav.js)
|
||||
add_test(NAME NavRulesJs COMMAND ${NODE_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/nav.test.mjs"
|
||||
"${PROJECT_SOURCE_DIR}/dDashboardServer/static/js/nav.js")
|
||||
# No game text (zone, item names, the game's currencies) written into the pages: it comes from the client's locale
|
||||
add_test(NAME GameTextJs COMMAND ${NODE_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/game-text.test.mjs"
|
||||
"${PROJECT_SOURCE_DIR}/dDashboardServer" "${CMAKE_CURRENT_SOURCE_DIR}/game-text-allowlist.json")
|
||||
# The Network page's diagram model: nodes and links from the traffic summary (static/js/network-graph.js)
|
||||
add_test(NAME NetworkGraphJs COMMAND ${NODE_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/network-graph.test.mjs"
|
||||
"${PROJECT_SOURCE_DIR}/dDashboardServer/static/js/network-graph.js")
|
||||
|
||||
1
tests/dWebTests/game-text-allowlist.json
Normal file
1
tests/dWebTests/game-text-allowlist.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
143
tests/dWebTests/game-text.test.mjs
Normal file
143
tests/dWebTests/game-text.test.mjs
Normal file
@@ -0,0 +1,143 @@
|
||||
// Game text is never written into the dashboard: zone, item and character names and the game's currency names come
|
||||
// from the client's locale in the viewer's language (routes/GameText.h; `game` and phrase()/zone_name() in templates,
|
||||
// GameText.* in static/js/common.js). This fails when one of the game's names is written into a template, a script or a
|
||||
// string in the routes' C++.
|
||||
//
|
||||
// Run by ctest: node game-text.test.mjs <dDashboardServer dir> <allowlist.json> [locale.xml]
|
||||
// With a client's locale.xml, every zone name in it is looked for too.
|
||||
//
|
||||
// A legitimate use (not game text shown to people) goes in game-text-allowlist.json as {file, text, why}, or gets a
|
||||
// "game-text: ok" comment on its line.
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
|
||||
const [root, allowPath, localePath] = process.argv.slice(2);
|
||||
|
||||
// Names the game uses, as its en_US locale has them (ZoneTable_*_DisplayDescription, Objects_*_name, mission senders)
|
||||
const NAMES = new Set([
|
||||
'Venture Explorer', 'Return to the Venture Explorer', 'Avant Gardens', 'Avant Gardens Survival', 'Block Yard', 'Avant Grove',
|
||||
'Nimbus Station', 'Pet Cove', 'Vertigo Loop Racetrack', 'Battle of Nimbus Station', 'Nimbus Rock', 'Nimbus Isle',
|
||||
'Gnarled Forest', 'Cannon Cove Shooting Gallery', 'Keelhaul Canyon Racetrack', 'Chantey Shanty', 'Forbidden Valley',
|
||||
'Forbidden Valley Dragon Battle', 'Dragonmaw Chasm Racetrack', 'Raven Bluff', 'Starbase 3001', 'Deep Freeze',
|
||||
'Robot City', 'Moonbase', 'Portabello', 'Port-a-bello', 'LEGO Club', 'Crux Prime', 'Nexus Tower', 'Ninjago Monastery',
|
||||
'Battle Against Frakjaw', 'Spider Queen Battle',
|
||||
'Thinking Hat', 'Nexus Jawbox', 'Rocket Nose Cone', 'Duke Exeter', 'Hael Storm', 'Vanda Darkflame', 'Numb Chuck',
|
||||
'Beck Strongheart',
|
||||
]);
|
||||
|
||||
// Every ZoneTable_<id>_DisplayDescription of a real locale.xml (en_US)
|
||||
if (localePath && existsSync(localePath)) {
|
||||
const xml = readFileSync(localePath, 'utf8');
|
||||
const phrase = /<phrase id="ZoneTable_\d+_DisplayDescription">([\s\S]*?)<\/phrase>/g;
|
||||
for (const [, body] of xml.matchAll(phrase)) {
|
||||
const en = /<translation locale="en_US">([^<]+)<\/translation>/.exec(body);
|
||||
if (en && en[1].trim().length >= 6) NAMES.add(en[1].trim());
|
||||
}
|
||||
}
|
||||
|
||||
// The game's currency and stat names written as labels (GameText.term / game.terms instead)
|
||||
const TERMS = [
|
||||
/\bU-[Ss]core\b/,
|
||||
/\bUniverse Score\b/,
|
||||
/(>|'|")(Coins|Reputation|Imagination)(<|'|")/,
|
||||
];
|
||||
|
||||
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const NAME_PATTERNS = [...NAMES].map((name) => ({ name, re: new RegExp('(?<![\\w-])' + escape(name) + '(?![\\w-])') }));
|
||||
|
||||
const allow = JSON.parse(readFileSync(allowPath, 'utf8'));
|
||||
const allowed = (file, text) => allow.some((a) => a.file === file && a.text === text);
|
||||
const used = new Set();
|
||||
|
||||
// The text of a line people could see: comments dropped
|
||||
function visible(line, kind) {
|
||||
const trimmed = line.trim();
|
||||
if (kind !== 'jinja2' && (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*'))) return '';
|
||||
let out = line.replace(/<!--.*?-->/g, '').replace(/\{#.*?#\}/g, '');
|
||||
// A trailing // comment (not in a URL)
|
||||
out = out.replace(/(^|[^:'"\\])\/\/.*$/, '$1');
|
||||
if (kind === 'cpp') {
|
||||
// Only the string literals of C++
|
||||
return [...out.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1]).join(' | ');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function* files(dir, exts) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* files(path, exts);
|
||||
else if (exts.some((e) => entry.name.endsWith(e)) && !entry.name.includes('.min.')) yield path;
|
||||
}
|
||||
}
|
||||
|
||||
// What a line is flagged for
|
||||
function found(line, kind) {
|
||||
if (line.includes('game-text: ok')) return [];
|
||||
const text = visible(line, kind);
|
||||
if (!text) return [];
|
||||
const names = [];
|
||||
for (const { name, re } of NAME_PATTERNS) if (re.test(text)) names.push(name);
|
||||
if (kind !== 'cpp') for (const re of TERMS) { const m = re.exec(text); if (m) names.push(m[0].replace(/^[>'"]|[<'"]$/g, '')); }
|
||||
return names;
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
|
||||
// The scanner itself: what it must catch and what it must leave
|
||||
const SELF = [
|
||||
['<p>Each world starts with its rent (Block Yard is free)</p>', 'jinja2', ['Block Yard']],
|
||||
["var labels = { title: 'U-score' };", 'js', ['U-score']],
|
||||
['<th>Coins</th>', 'jinja2', ['Coins']],
|
||||
['{ 4, "Opens LEGO Club" },', 'cpp', ['LEGO Club']],
|
||||
['// Checked in Avant Gardens', 'js', []],
|
||||
[' // Checked in Avant Gardens', 'cpp', []],
|
||||
['foo(); // like Nimbus Station', 'js', []],
|
||||
['<th>{{ game.terms.coins }}</th>', 'jinja2', []],
|
||||
['Where coins come from', 'jinja2', []],
|
||||
["'Nimbus Station' // game-text: ok", 'js', []],
|
||||
];
|
||||
for (const [line, kind, expected] of SELF) {
|
||||
const got = found(line, kind);
|
||||
if (JSON.stringify(got) !== JSON.stringify(expected)) {
|
||||
failures++;
|
||||
console.error(`scanner: ${JSON.stringify(line)} gave ${JSON.stringify(got)}, not ${JSON.stringify(expected)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const scanned = [
|
||||
...files(join(root, 'templates'), ['.jinja2']),
|
||||
...files(join(root, 'static', 'js'), ['.js']),
|
||||
...files(join(root, 'routes'), ['.cpp', '.h']),
|
||||
];
|
||||
for (const path of scanned) {
|
||||
const file = relative(root, path).replace(/\\/g, '/');
|
||||
const kind = path.endsWith('.jinja2') ? 'jinja2' : path.endsWith('.js') ? 'js' : 'cpp';
|
||||
readFileSync(path, 'utf8').split('\n').forEach((line, i) => {
|
||||
for (const name of found(line, kind)) {
|
||||
if (allowed(file, name)) { used.add(file + '\u0000' + name); continue; }
|
||||
failures++;
|
||||
console.error(`${file}:${i + 1}: "${name}" is game text written into the dashboard; look it up from the locale ` +
|
||||
`(GameText.h / game.terms / GameText.* in common.js), or allowlist it in game-text-allowlist.json`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Allowlist entries nothing needs any more
|
||||
for (const a of allow) {
|
||||
if (!used.has(a.file + '\u0000' + a.text)) {
|
||||
failures++;
|
||||
console.error(`game-text-allowlist.json: ${a.file} "${a.text}" is not used any more; remove it`);
|
||||
}
|
||||
}
|
||||
|
||||
if (scanned.length < 20) {
|
||||
failures++;
|
||||
console.error(`Only ${scanned.length} files found under ${root}`);
|
||||
}
|
||||
|
||||
if (failures) {
|
||||
console.error(`${failures} problem(s)`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`game text: ${scanned.length} files clean of ${NAMES.size} game names`);
|
||||
Reference in New Issue
Block a user