538 lines
33 KiB
JavaScript
Executable File
538 lines
33 KiB
JavaScript
Executable File
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import * as fs from 'node:fs/promises';
|
|
import { execFile } from 'node:child_process';
|
|
import { promisify } from 'node:util';
|
|
import { realpath } from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { createHash } from 'node:crypto';
|
|
import { packageGame, readRelease, SOURCE, verifyPackage } from '../scripts/package.mjs';
|
|
import { backupWorld, restoreWorld, verifyBackup } from '../scripts/backup.mjs';
|
|
import { absolute, atomicDirectory, disjoint, exists, inventory, MANIFEST, relativeName, withLauncherLock } from '../scripts/files.mjs';
|
|
import { assertEngineVersion, coordinates, engineExecutable, environmentSettings, fixtureSettings, graphicsSettings, parseEngineVersion, prepareCourse, runtimeHome, snapshotBeforePlay, validateEngine } from '../scripts/launcher.mjs';
|
|
import { writeWorld } from '../scripts/run.mjs';
|
|
import { isTerrainSetting, terrainSettings } from '../scripts/terrain.mjs';
|
|
|
|
const exec = promisify(execFile);
|
|
const cli = path.join(SOURCE, 'scripts/run.mjs');
|
|
const binary = Buffer.from([0, 255, 254, 128, 10, 13, 0, 42]);
|
|
|
|
async function temporary(t) {
|
|
// macOS /var is normally a symlink; resolve the OS-owned temp root before
|
|
// testing the deliberately symlink-rejecting filesystem API.
|
|
const root = await fs.mkdtemp(path.join(await promisify(realpath)(os.tmpdir()), 'hearth-tooling-'));
|
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
return root;
|
|
}
|
|
|
|
async function sourceTree(root) {
|
|
const source = path.join(root, 'source');
|
|
await fs.mkdir(path.join(source, 'mods/hearth'), { recursive: true });
|
|
await fs.mkdir(path.join(source, 'tests/fixtures'), { recursive: true });
|
|
for (const filename of ['game.conf', 'minetest.conf', 'settingtypes.txt', 'LICENSE.txt', 'release.json', 'tests/fixtures/scenarios.json']) {
|
|
await fs.copyFile(path.join(SOURCE, filename), path.join(source, filename));
|
|
}
|
|
for (const name of ['init', 'model', 'runtime', 'content', 'encounters']) {
|
|
await fs.writeFile(path.join(source, 'mods/hearth', name + '.lua'), '-- fixture #abcdef\n');
|
|
}
|
|
await fs.writeFile(path.join(source, 'mods/hearth/mod.conf'), 'name = hearth\n');
|
|
await fs.writeFile(path.join(source, 'secret.env'), 'not part of payload\n');
|
|
await fs.writeFile(path.join(source, 'mods/hearth/data.png'), binary);
|
|
await fs.mkdir(path.join(source, 'mods/hearth/textures'));
|
|
await fs.writeFile(path.join(source, 'mods/hearth/textures/stale.png'), binary);
|
|
return source;
|
|
}
|
|
|
|
async function worldTree(root) {
|
|
const world = path.join(root, 'old-world');
|
|
await fs.mkdir(path.join(world, 'players/empty'), { recursive: true });
|
|
await fs.mkdir(path.join(world, 'copy'));
|
|
await fs.writeFile(path.join(world, 'world.mt'), 'gameid = last_hearth\nbackend = sqlite3\n');
|
|
await fs.writeFile(path.join(world, 'map.sqlite'), binary);
|
|
await fs.writeFile(path.join(world, 'players/player.bin'), binary);
|
|
return world;
|
|
}
|
|
|
|
function options(root) { return { offline: true, runtime: path.join(root, 'runtime') }; }
|
|
|
|
test('package twice: identical full inventory and manifest hashes, original assets, no engine', async t => {
|
|
const root = await temporary(t), source = await sourceTree(root);
|
|
const a = path.join(root, 'a'), b = path.join(root, 'b');
|
|
await packageGame(a, { source });
|
|
await packageGame(b, { source });
|
|
assert.deepEqual(await inventory(a), await inventory(b));
|
|
const hash = data => createHash('sha256').update(data).digest('hex');
|
|
assert.equal(hash(await fs.readFile(path.join(a, MANIFEST))), hash(await fs.readFile(path.join(b, MANIFEST))));
|
|
const manifest = await verifyPackage(a);
|
|
assert.ok(manifest.entries.some(entry => entry.path === 'release.json'));
|
|
for (const absent of ['scripts', 'tests', '.git', 'README.md', 'secret.env', 'mods/hearth/textures/stale.png']) assert.equal(await exists(path.join(a, absent)), false);
|
|
assert.deepEqual(await fs.readFile(path.join(a, 'mods/hearth/data.png')), binary);
|
|
assert.deepEqual((await fs.readFile(path.join(a, 'mods/hearth/textures/hearth_abcdef.png'))).subarray(0, 8), Buffer.from([137,80,78,71,13,10,26,10]));
|
|
assert.match(await fs.readFile(path.join(a, 'mods/hearth/models/hearth_person.obj'), 'utf8'), /^# Original Last Hearth/);
|
|
assert.equal((await readRelease(a)).engine.version, '5.18.0');
|
|
assert.equal((await readRelease(a)).version, '0.5.0-m4');
|
|
});
|
|
|
|
test('optional runtime fixture contract is copied verbatim', async t => {
|
|
const root = await temporary(t), source = await sourceTree(root);
|
|
const bytes = Buffer.from('{"owned_by":"runtime"}\n');
|
|
await fs.writeFile(path.join(source, 'tests/fixtures/config.json'), bytes);
|
|
await fs.writeFile(path.join(source, 'tests/fixtures/first-session.json'), bytes);
|
|
const destination = path.join(root, 'game');
|
|
await packageGame(destination, { source });
|
|
assert.deepEqual(await fs.readFile(path.join(destination, 'fixtures/config.json')), bytes);
|
|
assert.deepEqual(await fs.readFile(path.join(destination, 'fixtures/first-session.json')), bytes);
|
|
});
|
|
|
|
test('package refuses existing/unmanaged output without changing it', async t => {
|
|
const root = await temporary(t), source = await sourceTree(root);
|
|
const destination = path.join(root, 'shared');
|
|
await fs.mkdir(destination);
|
|
await fs.writeFile(path.join(destination, 'keep'), binary);
|
|
const before = await inventory(destination);
|
|
await assert.rejects(packageGame(destination, { source }), /overwrite/);
|
|
assert.deepEqual(await inventory(destination), before);
|
|
await assert.rejects(packageGame(path.join(source, 'mods/output'), { source }), /overlap/);
|
|
});
|
|
|
|
test('atomic build failure leaves no destination, staging directory or guard', async t => {
|
|
const root = await temporary(t), destination = path.join(root, 'new');
|
|
await assert.rejects(atomicDirectory(destination, async stage => {
|
|
await fs.writeFile(path.join(stage, 'partial'), binary);
|
|
throw new Error('injected failure');
|
|
}), /injected/);
|
|
assert.deepEqual(await fs.readdir(root), []);
|
|
});
|
|
|
|
test('package manifest rejects missing, extra, corrupted files and extra empty dirs', async t => {
|
|
const root = await temporary(t), source = await sourceTree(root);
|
|
for (const kind of ['missing', 'extra', 'corrupt', 'directory']) {
|
|
const destination = path.join(root, kind);
|
|
await packageGame(destination, { source });
|
|
if (kind === 'missing') await fs.unlink(path.join(destination, 'game.conf'));
|
|
if (kind === 'extra') await fs.writeFile(path.join(destination, 'extra'), binary);
|
|
if (kind === 'corrupt') await fs.appendFile(path.join(destination, 'game.conf'), 'bad');
|
|
if (kind === 'directory') await fs.mkdir(path.join(destination, 'empty-extra'));
|
|
await assert.rejects(verifyPackage(destination), /manifest mismatch/);
|
|
}
|
|
});
|
|
|
|
test('offline full backup/restore preserves binary bytes, directories and original world', async t => {
|
|
const root = await temporary(t), world = await worldTree(root);
|
|
const before = await inventory(world), backup = path.join(root, 'snapshot'), restored = path.join(root, 'restored');
|
|
await backupWorld(world, backup, options(root));
|
|
await verifyBackup(backup);
|
|
await restoreWorld(backup, restored, options(root));
|
|
assert.deepEqual(await inventory(world), before);
|
|
assert.deepEqual(await inventory(restored), before);
|
|
assert.deepEqual(await fs.readFile(path.join(restored, 'map.sqlite')), binary);
|
|
assert.equal(await exists(path.join(restored, MANIFEST)), false);
|
|
await assert.rejects(restoreWorld(backup, world, options(root)), /new world/);
|
|
await assert.rejects(backupWorld(world, backup, options(root)), /overwrite/);
|
|
assert.deepEqual(await inventory(world), before);
|
|
});
|
|
|
|
test('restore rejects corruption, missing/extra files, empty dirs and unsafe manifest paths atomically', async t => {
|
|
const root = await temporary(t), world = await worldTree(root), before = await inventory(world);
|
|
for (const kind of ['corrupt', 'missing', 'extra', 'directory', 'traversal']) {
|
|
const backup = path.join(root, 'backup-' + kind), restored = path.join(root, 'restore-' + kind);
|
|
await backupWorld(world, backup, options(root));
|
|
if (kind === 'corrupt') await fs.writeFile(path.join(backup, 'world/map.sqlite'), Buffer.from('broken'));
|
|
if (kind === 'missing') await fs.unlink(path.join(backup, 'world/map.sqlite'));
|
|
if (kind === 'extra') await fs.writeFile(path.join(backup, 'extra'), binary);
|
|
if (kind === 'directory') await fs.mkdir(path.join(backup, 'unexpected'));
|
|
if (kind === 'traversal') {
|
|
const filename = path.join(backup, MANIFEST), manifest = JSON.parse(await fs.readFile(filename, 'utf8'));
|
|
manifest.entries[0].path = '../escape';
|
|
await fs.writeFile(filename, JSON.stringify(manifest));
|
|
}
|
|
await assert.rejects(restoreWorld(backup, restored, options(root)));
|
|
assert.equal(await exists(restored), false);
|
|
assert.equal(await exists(restored + '.hearth-write-lock'), false);
|
|
}
|
|
assert.deepEqual(await inventory(world), before);
|
|
});
|
|
|
|
test('offline confirmation and own launcher lock required; active lease permits internal snapshot', async t => {
|
|
const root = await temporary(t), world = await worldTree(root), opt = options(root);
|
|
await assert.rejects(backupWorld(world, path.join(root, 'no-confirmation'), { runtime: opt.runtime }), /offline/);
|
|
await withLauncherLock(opt.runtime, async lease => {
|
|
await assert.rejects(backupWorld(world, path.join(root, 'blocked'), opt), /Launcher lock/);
|
|
await assert.rejects(backupWorld(world, path.join(root, 'forged'), { ...opt, lease: { runtime: opt.runtime } }), /Launcher lock/);
|
|
await backupWorld(world, path.join(root, 'internal'), { ...opt, lease });
|
|
});
|
|
assert.equal(await exists(path.join(opt.runtime, 'launcher.lock')), false);
|
|
});
|
|
|
|
test('world lock files and SQLite sidecars fail closed, env_meta remains normal data', async t => {
|
|
const root = await temporary(t), world = await worldTree(root);
|
|
await fs.writeFile(path.join(world, 'env_meta.txt'), 'normal engine metadata');
|
|
for (const name of ['world.lock', '.lock', 'world.mt.lock', 'map.sqlite-wal', 'players.sqlite-shm', 'auth.sqlite-journal']) {
|
|
await fs.writeFile(path.join(world, name), '');
|
|
await assert.rejects(backupWorld(world, path.join(root, 'blocked'), options(root)), /lock or sidecar/);
|
|
await fs.unlink(path.join(world, name));
|
|
}
|
|
await backupWorld(world, path.join(root, 'allowed'), options(root));
|
|
});
|
|
|
|
test('every managed play snapshots existing world without deleting prior snapshots or world data', async t => {
|
|
const root = await temporary(t), world = await worldTree(root), runtime = path.join(root, 'runtime');
|
|
await fs.mkdir(path.join(runtime, 'worlds'), { recursive: true });
|
|
const managed = path.join(runtime, 'worlds/last_hearth');
|
|
await fs.rename(world, managed);
|
|
const original = await inventory(managed);
|
|
await withLauncherLock(runtime, async lease => {
|
|
const first = await snapshotBeforePlay(runtime, lease);
|
|
await fs.appendFile(path.join(managed, 'world.mt'), 'some_new_setting = true\n');
|
|
const second = await snapshotBeforePlay(runtime, lease);
|
|
assert.notEqual(first, second);
|
|
assert.deepEqual(await inventory(path.join(first, 'world')), original);
|
|
assert.deepEqual(await inventory(path.join(second, 'world')), await inventory(managed));
|
|
assert.equal((await fs.readdir(path.join(runtime, 'backups'))).length, 2);
|
|
await verifyBackup(first);
|
|
await verifyBackup(second);
|
|
});
|
|
await restoreWorld((await fs.readdir(path.join(runtime, 'backups'))).map(name => path.join(runtime, 'backups', name)).sort()[0],
|
|
path.join(root, 'restored-before-migration'), options(root));
|
|
assert.deepEqual(await inventory(path.join(root, 'restored-before-migration')), original);
|
|
});
|
|
|
|
test('new play world is not snapshotted; nonempty unmanaged world fails closed', async t => {
|
|
const root = await temporary(t), runtime = path.join(root, 'runtime');
|
|
await withLauncherLock(runtime, async lease => {
|
|
assert.equal(await snapshotBeforePlay(runtime, lease), undefined);
|
|
assert.equal(await exists(path.join(runtime, 'backups')), false);
|
|
await fs.mkdir(path.join(runtime, 'worlds/last_hearth'), { recursive: true });
|
|
await fs.writeFile(path.join(runtime, 'worlds/last_hearth/keep'), binary);
|
|
await assert.rejects(snapshotBeforePlay(runtime, lease), /unmanaged/);
|
|
assert.deepEqual(await fs.readFile(path.join(runtime, 'worlds/last_hearth/keep')), binary);
|
|
});
|
|
});
|
|
|
|
test('symlink/junction input, parent and destination rejected', async t => {
|
|
const root = await temporary(t), source = await sourceTree(root), world = await worldTree(root);
|
|
const outside = path.join(root, 'outside');
|
|
await fs.mkdir(outside);
|
|
const link = path.join(root, 'linked');
|
|
// Windows junctions do not require developer mode or administrator privileges.
|
|
await fs.symlink(outside, link, process.platform === 'win32' ? 'junction' : 'dir');
|
|
await assert.rejects(packageGame(path.join(link, 'game'), { source }), /link|junction/);
|
|
await assert.rejects(packageGame(link, { source }), /link|junction/);
|
|
await fs.symlink(outside, path.join(source, 'mods/hearth/escape'), process.platform === 'win32' ? 'junction' : 'dir');
|
|
await assert.rejects(packageGame(path.join(root, 'game'), { source }), /link|junction/);
|
|
await fs.symlink(outside, path.join(world, 'escape'), process.platform === 'win32' ? 'junction' : 'dir');
|
|
await assert.rejects(backupWorld(world, path.join(root, 'snapshot'), options(root)), /link|junction/);
|
|
assert.deepEqual(await fs.readdir(outside), []);
|
|
});
|
|
|
|
test('absolute paths, portable names and overlap validation reject traversal', () => {
|
|
for (const name of ['../escape', '/absolute', 'a\\b', 'a/../b', 'a//b', 'C:ads', 'CON.txt', 'x.']) assert.throws(() => relativeName(name));
|
|
assert.throws(() => absolute('relative'));
|
|
assert.throws(() => absolute(path.join(os.tmpdir(), 'a') + path.sep + '..' + path.sep + 'b'));
|
|
assert.throws(() => disjoint(os.tmpdir(), path.join(os.tmpdir(), 'world')));
|
|
});
|
|
|
|
test('runtime defaults preserve legacy Windows world path and support HEARTH_HOME on all platforms', () => {
|
|
const home = path.resolve(os.tmpdir(), 'person'), local = path.join(home, 'Local');
|
|
const legacy = runtimeHome({ LOCALAPPDATA: local }, 'win32', home);
|
|
assert.equal(legacy, path.join(local, 'last-hearth-runtime'));
|
|
assert.equal(engineExecutable({ LOCALAPPDATA: local }, 'win32'), path.join(legacy, 'luanti-5.18.0-win64/bin/luanti.exe'));
|
|
for (const platform of ['win32', 'linux', 'darwin']) {
|
|
assert.equal(runtimeHome({ HEARTH_HOME: home }, platform), home);
|
|
assert.equal(engineExecutable({ HEARTH_ENGINE: process.execPath }, platform, home), process.execPath);
|
|
}
|
|
assert.match(runtimeHome({}, 'darwin', home), /Application Support/);
|
|
assert.equal(runtimeHome({ XDG_DATA_HOME: local }, 'linux', home), path.join(local, 'last-hearth-runtime'));
|
|
assert.throws(() => engineExecutable({}, 'linux', home), /HEARTH_ENGINE/);
|
|
assert.throws(() => engineExecutable({ HEARTH_ENGINE: 'relative' }, 'linux', home), /absolute/);
|
|
});
|
|
|
|
test('engine version parsing is exact and actual non-engine executable fails closed', async () => {
|
|
assert.equal(parseEngineVersion('Luanti 5.18.0 (Windows)\nUsing LuaJIT\n'), '5.18.0');
|
|
assert.equal(parseEngineVersion('Luanti 5.19.0\n'), '5.19.0');
|
|
assert.equal(assertEngineVersion('Luanti 5.18.0 (Windows)\n'), '5.18.0');
|
|
for (const version of ['5.17.0', '5.19.0', '5.18.1']) assert.throws(() => assertEngineVersion(`Luanti ${version}\n`), /version mismatch/);
|
|
for (const text of ['Luanti 5.18.0-dev', 'Minetest 5.18.0', 'prefix Luanti 5.18.0', 'Luanti 5.18.00x', 'v24.0.0']) assert.throws(() => parseEngineVersion(text));
|
|
await assert.rejects(validateEngine(process.execPath), /Unrecognized Luanti/);
|
|
});
|
|
|
|
test('fixture contract: first-session real clock, summer/winter fixed UTC/location/weather, production rejects', async () => {
|
|
assert.deepEqual(await fixtureSettings('first-session', 'local'), ['fixed_map_seed = 731104']);
|
|
for (const [name, utc, weather] of [['summer', 1782043200, 'clear'], ['winter', 1797854400, 'rain']]) {
|
|
const settings = await fixtureSettings(name, 'staging');
|
|
for (const expected of [`hearth_preview_date = ${utc}`, `hearth_preview_weather = ${weather}`, 'hearth_latitude = 45', 'hearth_longitude = 0']) assert.ok(settings.includes(expected));
|
|
}
|
|
await assert.rejects(fixtureSettings('summer', 'production'), /Production/);
|
|
await assert.rejects(fixtureSettings('__proto__', 'local'), /Unknown/);
|
|
assert.deepEqual(environmentSettings(), ['hearth_environment = production']);
|
|
assert.deepEqual(environmentSettings('staging', ['hearth_launcher_mode = smoke']), ['hearth_environment = staging', 'hearth_launcher_mode = smoke']);
|
|
for (const line of ['hearth_preview_date = 1', 'hearth_preview_weather = clear', 'hearth_launcher_mode = smoke', 'hearth_smoke_token = token']) assert.throws(() => environmentSettings('production', [line]), /Production/);
|
|
assert.throws(() => environmentSettings('local', ['a = 1\nb = 2']), /Invalid/);
|
|
assert.throws(() => coordinates({ HEARTH_LATITUDE: '45\nfoo=bar', HEARTH_LONGITUDE: '0' }));
|
|
});
|
|
|
|
test('release and npm commands retain M0-M3 tooling entry points', async () => {
|
|
const pkg = JSON.parse(await fs.readFile(path.join(SOURCE, 'package.json'), 'utf8'));
|
|
assert.equal(pkg.version, '0.5.0-m4');
|
|
assert.equal((await readRelease()).version, pkg.version);
|
|
assert.equal(pkg.scripts.course, 'node scripts/run.mjs course');
|
|
assert.equal(pkg.scripts['test:course'], 'node scripts/run.mjs test:course');
|
|
assert.equal(pkg.scripts['test:seeds'], 'node scripts/run.mjs test:seeds');
|
|
assert.equal(pkg.scripts.test, 'node --test tests/*.test.mjs');
|
|
assert.equal(pkg.scripts.play, 'node scripts/run.mjs play');
|
|
assert.equal(pkg.scripts['test:recovery'], 'node scripts/recovery.mjs');
|
|
});
|
|
|
|
test('course creates only fresh stage/course with matching exclusive UUID marker and summer fixture', async t => {
|
|
const root = await temporary(t), world = await worldTree(root);
|
|
const original = await inventory(world);
|
|
const stage = path.join(root, 'session'), secondStage = path.join(root, 'second-session');
|
|
await fs.mkdir(stage); await fs.mkdir(secondStage);
|
|
const first = await prepareCourse(stage);
|
|
const second = await prepareCourse(secondStage, { environment: 'staging' });
|
|
assert.equal(first.world, path.join(stage, 'course'));
|
|
assert.equal(path.basename(second.world), 'course');
|
|
assert.match(first.nonce, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
|
assert.notEqual(first.nonce, second.nonce);
|
|
assert.deepEqual(first.settings, [...await fixtureSettings('summer', 'local'),
|
|
'hearth_m1_course = true', `hearth_m1_course_nonce = ${first.nonce}`]);
|
|
assert.deepEqual(environmentSettings('local', first.settings).filter(line => /^hearth_environment/.test(line)), ['hearth_environment = local']);
|
|
for (const entry of [first, second]) {
|
|
assert.deepEqual(await fs.readdir(entry.world), ['.hearth-m1-course-nonce']);
|
|
assert.equal(await fs.readFile(path.join(entry.world, '.hearth-m1-course-nonce'), 'utf8'), entry.nonce + '\n');
|
|
assert.ok(entry.settings.includes('hearth_preview_date = 1782043200'));
|
|
assert.ok(entry.settings.includes('hearth_preview_weather = clear'));
|
|
assert.ok(entry.settings.includes('hearth_latitude = 45'));
|
|
assert.ok(entry.settings.includes('hearth_longitude = 0'));
|
|
}
|
|
const before = await inventory(stage);
|
|
await assert.rejects(prepareCourse(stage), /overwrite/);
|
|
assert.deepEqual(await inventory(stage), before);
|
|
assert.deepEqual(await inventory(world), original);
|
|
assert.equal(await exists(path.join(root, 'backups')), false);
|
|
});
|
|
|
|
test('course refuses existing empty/unmanaged worlds, linked stages, and production without mutation', async t => {
|
|
const root = await temporary(t), stage = path.join(root, 'session');
|
|
await fs.mkdir(path.join(stage, 'course'), { recursive: true });
|
|
await assert.rejects(prepareCourse(stage), /overwrite/);
|
|
await fs.writeFile(path.join(stage, 'course/keep'), binary);
|
|
const before = await inventory(stage);
|
|
await assert.rejects(prepareCourse(stage), /overwrite/);
|
|
for (const environment of ['production', 'invalid']) await assert.rejects(prepareCourse(stage, { environment }), /local\/staging/);
|
|
assert.deepEqual(await inventory(stage), before);
|
|
const link = path.join(root, 'linked-stage');
|
|
await fs.symlink(stage, link, process.platform === 'win32' ? 'junction' : 'dir');
|
|
await assert.rejects(prepareCourse(link), /link|junction/);
|
|
assert.deepEqual(await inventory(stage), before);
|
|
});
|
|
|
|
test('production rejects presence of all course, recovery, debug and launcher settings, including false/empty', () => {
|
|
for (const key of ['hearth_m1_course', 'hearth_m1_course_nonce', 'hearth_m1_course_snag',
|
|
'hearth_m1_course_future', 'hearth_recovery_phase', 'hearth_recovery_token', 'hearth_recovery_gameid',
|
|
'hearth_debug', 'hearth_debug_future', 'hearth_course_probe_token', 'hearth_launcher_mode', 'hearth_smoke_token']) {
|
|
for (const value of ['', 'false', '0', 'test']) {
|
|
const line = ` ${key} = ${value}`;
|
|
assert.throws(() => environmentSettings('production', [line]), /Production/, key);
|
|
assert.deepEqual(environmentSettings('staging', [line]), ['hearth_environment = staging', line]);
|
|
}
|
|
}
|
|
assert.deepEqual(environmentSettings('production', ['hearth_latitude = 45', 'hearth_longitude = 0']),
|
|
['hearth_environment = production', 'hearth_latitude = 45', 'hearth_longitude = 0']);
|
|
});
|
|
|
|
test('CLI package/verify work with absent engine and invalid runtime env; backup/restore offline CLI', async t => {
|
|
const root = await temporary(t), destination = path.join(root, 'game');
|
|
const env = { ...process.env, HEARTH_ENGINE: 'not-an-absolute-engine', HEARTH_HOME: 'not-a-runtime' };
|
|
const packaged = await exec(process.execPath, [cli, 'package', destination], { env, timeout: 90_000 });
|
|
assert.match(packaged.stdout, /PACKAGE_PASS/);
|
|
const verified = await exec(process.execPath, [cli, 'verify', destination], { env, timeout: 30_000 });
|
|
assert.match(verified.stdout, /PACKAGE_VERIFY_PASS/);
|
|
const world = await worldTree(root), backup = path.join(root, 'backup'), restored = path.join(root, 'restored');
|
|
env.HEARTH_HOME = path.join(root, 'runtime');
|
|
await exec(process.execPath, [cli, 'backup', world, backup, '--offline'], { env, timeout: 30_000 });
|
|
await exec(process.execPath, [cli, 'restore', backup, restored, '--offline'], { env, timeout: 30_000 });
|
|
assert.deepEqual(await inventory(world), await inventory(restored));
|
|
await assert.rejects(exec(process.execPath, [cli, 'backup', world, path.join(root, 'unconfirmed')], { env, timeout: 10_000 }), /OFFLINE/);
|
|
// Version check happens before creating any runtime data or touching the world.
|
|
env.HEARTH_ENGINE = process.execPath;
|
|
const before = await inventory(world);
|
|
await assert.rejects(exec(process.execPath, [cli, 'play'], { env, timeout: 10_000 }), /Unrecognized Luanti/);
|
|
for (const command of ['course', 'test:course']) {
|
|
await assert.rejects(exec(process.execPath, [cli, command], {
|
|
env: { ...env, HEARTH_FIXTURE: 'winter', HEARTH_LATITUDE: 'invalid' }, timeout: 10_000,
|
|
}), /Unrecognized Luanti/); // Course ignores inherited fixture/coordinates, but never engine pinning.
|
|
}
|
|
assert.deepEqual(await inventory(world), before);
|
|
assert.deepEqual(await fs.readdir(env.HEARTH_HOME), []);
|
|
});
|
|
|
|
test('new-world terrain preset is pure, explicit, bounded and matches packaged game defaults', async () => {
|
|
const first = terrainSettings(true);
|
|
assert.deepEqual(terrainSettings(false), []);
|
|
assert.throws(() => terrainSettings('true'), /boolean/);
|
|
assert.ok(first.includes('mapgen_limit = 4096'));
|
|
assert.ok(first.includes('mgv7_spflags = mountains,ridges,nofloatlands,caverns'));
|
|
assert.ok(first.includes('mgv7_np_terrain_base = 12, 45, (384, 384, 384), 82341, 5, 0.6, 2.0'));
|
|
assert.ok(first.includes('mgv7_np_mount_height = 250, 100, (700, 700, 700), 72449, 3, 0.6, 2.0'));
|
|
const defaults = (await fs.readFile(path.join(SOURCE, 'minetest.conf'), 'utf8'))
|
|
.split(/\r?\n/).filter(isTerrainSetting).sort();
|
|
assert.deepEqual(first.filter(line => !line.startsWith('fixed_map_seed')).sort(), defaults);
|
|
assert.equal(new Set(first.map(line => line.split('=')[0].trim())).size, first.length);
|
|
first[0] = 'mg_name = flat'; first.push('untrusted = true');
|
|
assert.equal(terrainSettings(true)[0], 'mg_name = v7');
|
|
for (const line of ['mg_name = v7', ' mgv7_np_terrain_base = 1', 'mg_biome_np_heat = 1',
|
|
'fixed_map_seed = 42', 'mapgen_limit = 512', 'water_level = 1']) assert.ok(isTerrainSetting(line), line);
|
|
assert.equal(isTerrainSetting('hearth_latitude = 45'), false);
|
|
});
|
|
|
|
test('world creation writes terrain once; subsequent ephemeral configs never retune or reseed saved worlds', async t => {
|
|
const root = await temporary(t), world = path.join(root, 'world'), config = path.join(root, 'first.conf');
|
|
await writeWorld(world, config, ['hearth_latitude = 45', 'hearth_longitude = 0'],
|
|
['fixed_map_seed = 42'], 'local');
|
|
const first = await fs.readFile(config, 'utf8');
|
|
for (const line of terrainSettings(true)) assert.ok(first.includes(line + '\n'), line);
|
|
assert.ok(first.indexOf('fixed_map_seed = 42') > first.indexOf('fixed_map_seed = 731104'));
|
|
const worldFile = await fs.readFile(path.join(world, 'world.mt'));
|
|
// An old world may have an entirely different mapgen, seed and boundary.
|
|
const saved = Buffer.from('mg_name = valleys\nseed = 99\nmapgen_limit = 512\n[end_of_params]\n');
|
|
await fs.writeFile(path.join(world, 'map_meta.txt'), saved);
|
|
const before = await inventory(world);
|
|
const secondConfig = path.join(root, 'second.conf');
|
|
await writeWorld(world, secondConfig, ['hearth_latitude = 45'], [
|
|
...await fixtureSettings('first-session', 'local'), 'mg_name = flat',
|
|
'mgv7_np_mountain = 1', 'water_level = 30', 'mapgen_limit = 4096',
|
|
], 'local');
|
|
const second = await fs.readFile(secondConfig, 'utf8');
|
|
assert.deepEqual(second.split(/\r?\n/).filter(isTerrainSetting), []);
|
|
assert.match(second, /hearth_latitude = 45/);
|
|
assert.match(second, /secure.enable_security = true/);
|
|
assert.deepEqual(await fs.readFile(path.join(world, 'world.mt')), worldFile);
|
|
assert.deepEqual(await fs.readFile(path.join(world, 'map_meta.txt')), saved);
|
|
assert.deepEqual(await inventory(world), before);
|
|
await assert.rejects(writeWorld(world, secondConfig, [], [], 'local'), /EEXIST/);
|
|
assert.deepEqual(await inventory(world), before);
|
|
});
|
|
|
|
test('world.mt without map metadata and orphan map metadata each suppress creation overrides', async t => {
|
|
const root = await temporary(t);
|
|
for (const name of ['world.mt', 'map_meta.txt']) {
|
|
const world = path.join(root, name === 'world.mt' ? 'legacy' : 'orphan');
|
|
await fs.mkdir(world);
|
|
const bytes = Buffer.from(name === 'world.mt' ? 'gameid = last_hearth\n' : 'seed = 123\n[end_of_params]\n');
|
|
await fs.writeFile(path.join(world, name), bytes);
|
|
const config = path.join(root, path.basename(world) + '.conf');
|
|
await writeWorld(world, config, [], ['fixed_map_seed = 731104'], 'local');
|
|
assert.deepEqual((await fs.readFile(config, 'utf8')).split(/\r?\n/).filter(isTerrainSetting), []);
|
|
assert.deepEqual(await fs.readFile(path.join(world, name)), bytes);
|
|
}
|
|
});
|
|
|
|
test('graphics preset defaults to 512 nodes with bounded full-detail send/generate distances', () => {
|
|
const expected = distance => [
|
|
`viewing_range = ${distance}`, `max_block_send_distance = ${Math.ceil(distance / 16) + 2}`,
|
|
`max_block_generate_distance = ${Math.ceil(distance / 16) + 2}`,
|
|
'client_mesh_chunk = 2', 'fog_start = 0.65',
|
|
];
|
|
assert.deepEqual(graphicsSettings({}), expected(512));
|
|
for (let distance = 128; distance <= 1024; distance++) {
|
|
const settings = graphicsSettings({ HEARTH_VIEW_DISTANCE: String(distance) });
|
|
assert.deepEqual(settings, expected(distance));
|
|
assert.ok(Number(settings[1].split('=')[1]) <= 66);
|
|
assert.equal(new Set(settings.map(line => line.split('=')[0].trim())).size, settings.length);
|
|
assert.deepEqual(settings.filter(isTerrainSetting), []);
|
|
assert.ok(settings.every(line => !/active|activation|culling|occlusion|simulation/.test(line)));
|
|
}
|
|
const changed = graphicsSettings({});
|
|
changed[0] = 'viewing_range = 1'; changed.push('untrusted = true');
|
|
assert.deepEqual(graphicsSettings({}), expected(512));
|
|
});
|
|
|
|
const invalidViewDistances = ['', '127', '1025', '4000', '0', '-512', '+512', '0512',
|
|
'512.0', '5e2', '0x200', 'NaN', 'Infinity', ' 512', '512 ', '512\n', '512\r',
|
|
'512\nactive_block_range = 100', '512\r\nsecure.enable_security = false',
|
|
'512; enable_occlusion_culling = false', '512', '9'.repeat(1000), null, 512, true, {}];
|
|
|
|
test('graphics rejects malformed, injected and out-of-range values before any world/config write', async t => {
|
|
const root = await temporary(t), existing = await worldTree(root);
|
|
const before = await inventory(existing);
|
|
for (const value of invalidViewDistances) {
|
|
const env = { HEARTH_VIEW_DISTANCE: value };
|
|
assert.throws(() => graphicsSettings(env), /HEARTH_VIEW_DISTANCE.*128.*1024/);
|
|
for (const world of [path.join(root, 'absent'), existing]) {
|
|
await assert.rejects(writeWorld(world, path.join(root, 'invalid.conf'), [], [], 'local', 'last_hearth', env),
|
|
/HEARTH_VIEW_DISTANCE.*128.*1024/);
|
|
}
|
|
assert.equal(await exists(path.join(root, 'absent')), false);
|
|
assert.equal(await exists(path.join(root, 'invalid.conf')), false);
|
|
assert.deepEqual(await inventory(existing), before);
|
|
}
|
|
});
|
|
|
|
test('play/course/test configs include unique graphics keys for new and existing worlds without mapgen rewrite', async t => {
|
|
const root = await temporary(t);
|
|
for (const mode of ['play', 'course', 'test']) {
|
|
for (const distance of [512, 768, 1024]) {
|
|
for (const existing of [false, true]) {
|
|
const directory = path.join(root, `${mode}-${distance}-${existing}`);
|
|
await fs.mkdir(directory);
|
|
const world = path.join(directory, 'world'), config = path.join(directory, `${mode}.conf`);
|
|
if (existing) {
|
|
await fs.mkdir(world);
|
|
await fs.writeFile(path.join(world, 'world.mt'), 'gameid = last_hearth\nbackend = sqlite3\n');
|
|
await fs.writeFile(path.join(world, 'map_meta.txt'), 'mg_name = valleys\nseed = 99\nmapgen_limit = 512\n[end_of_params]\n');
|
|
await fs.writeFile(path.join(world, 'map.sqlite'), binary);
|
|
}
|
|
const before = existing ? await inventory(world) : undefined;
|
|
const env = distance === 512 ? {} : { HEARTH_VIEW_DISTANCE: String(distance) };
|
|
const extra = mode === 'course' ? [...await fixtureSettings('summer', 'local'), 'hearth_m1_course = true']
|
|
: mode === 'test' ? ['hearth_launcher_mode = check', 'max_users = 0'] : [];
|
|
await writeWorld(world, config, [], extra, mode === 'test' ? 'staging' : 'local', 'last_hearth', env);
|
|
const lines = (await fs.readFile(config, 'utf8')).split(/\r?\n/);
|
|
for (const setting of graphicsSettings(env)) {
|
|
assert.ok(lines.includes(setting), setting);
|
|
const key = setting.split('=')[0].trim();
|
|
assert.equal(lines.filter(line => line.split('=')[0].trim() === key).length, 1, key);
|
|
}
|
|
assert.ok(lines.includes('secure.enable_security = true'));
|
|
if (existing) {
|
|
assert.deepEqual(lines.filter(isTerrainSetting), []);
|
|
assert.deepEqual(await inventory(world), before);
|
|
} else {
|
|
for (const setting of terrainSettings(true)) assert.ok(lines.includes(setting), setting);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test('managed graphics overrides cannot duplicate preset keys or bypass the environment bounds', async t => {
|
|
const root = await temporary(t), world = path.join(root, 'world'), config = path.join(root, 'play.conf');
|
|
for (const line of graphicsSettings({})) {
|
|
for (const location of [false, true]) {
|
|
await assert.rejects(writeWorld(world, config, location ? [` ${line}`] : [],
|
|
location ? [] : [` ${line}`], 'local', 'last_hearth', {}), /launcher-managed/);
|
|
assert.equal(await exists(world), false);
|
|
assert.equal(await exists(config), false);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('CLI validates graphics before any engine check or runtime/world writes in every launch mode', async t => {
|
|
const root = await temporary(t), runtime = path.join(root, 'runtime');
|
|
const env = { ...process.env, HEARTH_HOME: runtime, HEARTH_ENGINE: process.execPath,
|
|
HEARTH_VIEW_DISTANCE: '512\nsecure.enable_security = false' };
|
|
for (const mode of ['play', 'course', 'test:course', 'check', 'smoke', 'test', 'test:seeds']) {
|
|
await assert.rejects(exec(process.execPath, [cli, mode], { env, timeout: 10_000 }), error => {
|
|
assert.equal(error.code, 1);
|
|
assert.match(error.stderr, /HEARTH_VIEW_DISTANCE.*128.*1024/);
|
|
assert.doesNotMatch(error.stderr, /Unrecognized Luanti/);
|
|
return true;
|
|
});
|
|
assert.equal(await exists(runtime), false);
|
|
}
|
|
});
|
|
|
|
// Node-only tooling regression suite: never opens a GUI or the original world.
|