mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
75cd28435c
Point launcher, fused build, docs, and gitignore at the relocated Switch port tree.
607 lines
24 KiB
JavaScript
607 lines
24 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { createHash } from 'node:crypto';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { spawnSync } from 'node:child_process';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const root = path.resolve(__dirname, '..');
|
|
|
|
function read(rel) {
|
|
return fs.readFileSync(path.join(root, rel), 'utf8');
|
|
}
|
|
|
|
function sha256Hex(buf) {
|
|
return createHash('sha256').update(buf).digest('hex');
|
|
}
|
|
|
|
/** JS mirror of src/update/SwitchOta.lua — contract under test. */
|
|
function createSwitchOta() {
|
|
const OTA_RE = /^gen1recomp-(\d+\.\d+\.\d+)-switch\.zip$/;
|
|
const CHECK_TIMEOUT_SEC = 6;
|
|
const GAME_NRO_NAME = 'gen1recomp-game.nro';
|
|
const LAUNCHER_NRO_NAME = 'gen1recomp.nro';
|
|
const SAVE_DIR_NAME = 'pokemon-love2d';
|
|
const INSTALL_DIR = 'switch/gen1recomp';
|
|
|
|
function parseSemver(s) {
|
|
if (typeof s !== 'string') return null;
|
|
const m = s.match(/^v?(\d+)\.(\d+)\.(\d+)$/);
|
|
if (!m) return null;
|
|
return { major: +m[1], minor: +m[2], patch: +m[3] };
|
|
}
|
|
|
|
function compareSemver(a, b) {
|
|
const pa = parseSemver(a);
|
|
const pb = parseSemver(b);
|
|
if (!pa && !pb) return 0;
|
|
if (!pa) return -1;
|
|
if (!pb) return 1;
|
|
for (const f of ['major', 'minor', 'patch']) {
|
|
if (pa[f] < pb[f]) return -1;
|
|
if (pa[f] > pb[f]) return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function isOtaAssetName(name) {
|
|
return typeof name === 'string' && OTA_RE.test(name);
|
|
}
|
|
|
|
function findJsonObjectStart(jsonText, pos) {
|
|
if (!jsonText || pos < 1) return null;
|
|
let depth = 0;
|
|
for (let p = pos; p >= 1; p--) {
|
|
const c = jsonText[p - 1];
|
|
if (c === '}') depth += 1;
|
|
else if (c === '{') {
|
|
if (depth === 0) return p;
|
|
depth -= 1;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function findJsonObjectEnd(jsonText, objectStart) {
|
|
if (!jsonText || objectStart < 1) return null;
|
|
if (jsonText[objectStart - 1] !== '{') return null;
|
|
let depth = 1;
|
|
for (let p = objectStart + 1; p <= jsonText.length; p++) {
|
|
const c = jsonText[p - 1];
|
|
if (c === '{') depth += 1;
|
|
else if (c === '}') {
|
|
depth -= 1;
|
|
if (depth === 0) return p + 1;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseRelease(jsonText) {
|
|
if (!jsonText) return { ok: false, reason: 'empty_json' };
|
|
const tagM = jsonText.match(/"tag_name"\s*:\s*"([^"]+)"/);
|
|
if (!tagM) return { ok: false, reason: 'missing_tag' };
|
|
const tag = tagM[1];
|
|
const versionM = tag.match(/^v?(\d+\.\d+\.\d+)$/);
|
|
if (!versionM) return { ok: false, reason: 'bad_tag' };
|
|
const version = versionM[1];
|
|
|
|
let cursor = 0;
|
|
while (true) {
|
|
const nameKeyPos = jsonText.indexOf('"name"', cursor);
|
|
if (nameKeyPos === -1) break;
|
|
const tail = jsonText.slice(nameKeyPos);
|
|
const nameM = tail.match(/"name"\s*:\s*"([^"]+)"/);
|
|
const name = nameM?.[1];
|
|
if (name && isOtaAssetName(name)) {
|
|
const assetStart = findJsonObjectStart(jsonText, nameKeyPos + 1);
|
|
const assetEnd = assetStart ? findJsonObjectEnd(jsonText, assetStart) : null;
|
|
if (assetStart && assetEnd && assetEnd > nameKeyPos + 1) {
|
|
const assetBlock = jsonText.slice(assetStart - 1, assetEnd - 1);
|
|
const urlM = assetBlock.match(/"browser_download_url"\s*:\s*"([^"]+)"/);
|
|
const downloadUrl = urlM?.[1];
|
|
if (downloadUrl) {
|
|
return {
|
|
ok: true,
|
|
tag,
|
|
version,
|
|
assetName: name,
|
|
downloadUrl,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
cursor = nameKeyPos + 6;
|
|
}
|
|
return { ok: false, reason: 'missing_ota_asset' };
|
|
}
|
|
|
|
function decideUpdate(installed, release) {
|
|
if (!parseSemver(installed)) return { status: 'error', reason: 'bad_installed_version' };
|
|
if (!release?.ok || !release.version) return { status: 'error', reason: 'bad_release' };
|
|
if (compareSemver(release.version, installed) <= 0) {
|
|
return { status: 'uptodate', version: installed };
|
|
}
|
|
return {
|
|
status: 'available',
|
|
version: release.version,
|
|
assetName: release.assetName,
|
|
downloadUrl: release.downloadUrl,
|
|
};
|
|
}
|
|
|
|
function parseSums(text) {
|
|
const sums = {};
|
|
if (typeof text !== 'string') return sums;
|
|
for (const line of text.split(/\r?\n/)) {
|
|
const m = line.match(/^([0-9a-fA-F]+)\s+\*?\.?\/?(.*?)\s*$/);
|
|
if (m) sums[m[2]] = m[1].toLowerCase();
|
|
}
|
|
return sums;
|
|
}
|
|
|
|
function verifySha256(assetName, actualHex, sums) {
|
|
if (!assetName) return { ok: false, reason: 'bad_asset_name' };
|
|
if (!sums || typeof sums !== 'object') return { ok: false, reason: 'missing_sums' };
|
|
const expected = sums[assetName];
|
|
if (!expected) return { ok: false, reason: 'sum_not_found' };
|
|
if (!actualHex) return { ok: false, reason: 'missing_actual_hash' };
|
|
if (actualHex.toLowerCase() !== expected.toLowerCase()) {
|
|
return { ok: false, reason: 'hash_mismatch' };
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
function planAtomicApply(installDir, verifiedTempPath) {
|
|
installDir = installDir || INSTALL_DIR;
|
|
const gameNro = `${installDir}/${GAME_NRO_NAME}`;
|
|
const launcherNro = `${installDir}/${LAUNCHER_NRO_NAME}`;
|
|
const partPath = `${gameNro}.part`;
|
|
const launcherPart = `${launcherNro}.part`;
|
|
return {
|
|
steps: [
|
|
{ op: 'copy_to_part', from: verifiedTempPath, to: partPath },
|
|
{ op: 'rename', from: partPath, to: gameNro },
|
|
{ op: 'copy_to_part', from: 'launcher', to: launcherPart },
|
|
{ op: 'rename', from: launcherPart, to: launcherNro },
|
|
{ op: 'env_set_next_load', target: gameNro },
|
|
],
|
|
preserve: [`${installDir}/${SAVE_DIR_NAME}`],
|
|
forbidden: [`delete:${installDir}/${SAVE_DIR_NAME}`, `write_direct:${gameNro}`],
|
|
};
|
|
}
|
|
|
|
function offlinePolicy(elapsedSec, events = {}) {
|
|
if (events.userSkip) {
|
|
return { action: 'play_installed', reason: 'user_skip', message: 'update skipped' };
|
|
}
|
|
if (events.apiError || events.networkOk === false) {
|
|
return {
|
|
action: 'play_installed',
|
|
reason: 'offline_or_error',
|
|
message: 'offline or update check failed — play installed version',
|
|
};
|
|
}
|
|
if (typeof elapsedSec === 'number' && elapsedSec >= CHECK_TIMEOUT_SEC) {
|
|
return {
|
|
action: 'play_installed',
|
|
reason: 'timeout',
|
|
message: `update check timed out after ${CHECK_TIMEOUT_SEC}s`,
|
|
};
|
|
}
|
|
return { action: 'keep_checking', reason: 'in_flight' };
|
|
}
|
|
|
|
return {
|
|
CHECK_TIMEOUT_SEC,
|
|
GAME_NRO_NAME,
|
|
LAUNCHER_NRO_NAME,
|
|
SAVE_DIR_NAME,
|
|
compareSemver,
|
|
isOtaAssetName,
|
|
parseRelease,
|
|
decideUpdate,
|
|
parseSums,
|
|
verifySha256,
|
|
planAtomicApply,
|
|
offlinePolicy,
|
|
};
|
|
}
|
|
|
|
const M = createSwitchOta();
|
|
|
|
const GITHUB_UPLOADER =
|
|
'"login":"github-actions[bot]",' +
|
|
'"id":41898282,' +
|
|
'"node_id":"MDM6Qm90NDE4OTgyODI=",' +
|
|
'"avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4",' +
|
|
'"gravatar_id":"",' +
|
|
'"url":"https://api.github.com/users/github-actions%5Bbot%5D",' +
|
|
'"html_url":"https://github.com/apps/github-actions",' +
|
|
'"followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers",' +
|
|
'"following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}",' +
|
|
'"gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}",' +
|
|
'"starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}",' +
|
|
'"subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions",' +
|
|
'"organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs",' +
|
|
'"repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos",' +
|
|
'"events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}",' +
|
|
'"received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events",' +
|
|
'"type":"Bot",' +
|
|
'"user_view_type":"public",' +
|
|
'"site_admin":false';
|
|
|
|
function buildGithubReleaseJson() {
|
|
return (
|
|
'{' +
|
|
'"tag_name":"v0.1.70",' +
|
|
'"name":"0.1.70",' +
|
|
'"assets":[' +
|
|
'{' +
|
|
'"url":"https://api.github.com/repos/bryanthaboi/gen1recomp/releases/assets/502823880",' +
|
|
'"id":502823880,' +
|
|
'"name":"gen1recomp-0.1.70-switch.zip",' +
|
|
'"label":"",' +
|
|
'"uploader":{' +
|
|
GITHUB_UPLOADER +
|
|
'},' +
|
|
'"content_type":"application/zip",' +
|
|
'"state":"uploaded",' +
|
|
'"size":9000573,' +
|
|
'"browser_download_url":"https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-switch.zip"' +
|
|
'}' +
|
|
']' +
|
|
'}'
|
|
);
|
|
}
|
|
|
|
const sampleRelease = M.parseRelease(
|
|
JSON.stringify({
|
|
tag_name: 'v1.5.0',
|
|
assets: [
|
|
{
|
|
name: 'gen1recomp-1.5.0-switch.zip',
|
|
browser_download_url: 'https://example/switch.zip',
|
|
},
|
|
{ name: 'sha256sums.txt', browser_download_url: 'https://example/sums' },
|
|
],
|
|
})
|
|
);
|
|
|
|
test('AC-001: Launcher nativo verifica release no GitHub @spec:AC-001', () => {
|
|
assert.equal(M.compareSemver('1.2.0', '1.1.0'), 1);
|
|
assert.equal(M.compareSemver('1.1.0', '1.1.0'), 0);
|
|
assert.equal(M.compareSemver('1.0.0', '1.1.0'), -1);
|
|
|
|
assert.equal(sampleRelease.ok, true);
|
|
assert.equal(sampleRelease.version, '1.5.0');
|
|
assert.equal(sampleRelease.assetName, 'gen1recomp-1.5.0-switch.zip');
|
|
|
|
assert.equal(M.decideUpdate('1.4.0', sampleRelease).status, 'available');
|
|
assert.equal(M.decideUpdate('1.5.0', sampleRelease).status, 'uptodate');
|
|
assert.equal(M.decideUpdate('1.6.0', sampleRelease).status, 'uptodate');
|
|
|
|
const missing = M.parseRelease(JSON.stringify({ tag_name: 'v1.5.0', assets: [] }));
|
|
assert.equal(missing.ok, false);
|
|
assert.equal(missing.reason, 'missing_ota_asset');
|
|
|
|
const githubRelease = M.parseRelease(buildGithubReleaseJson());
|
|
assert.equal(githubRelease.ok, true);
|
|
assert.equal(githubRelease.version, '0.1.70');
|
|
assert.equal(githubRelease.assetName, 'gen1recomp-0.1.70-switch.zip');
|
|
assert.equal(
|
|
githubRelease.downloadUrl,
|
|
'https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-switch.zip'
|
|
);
|
|
assert.equal(M.decideUpdate('0.1.69', githubRelease).status, 'available');
|
|
});
|
|
|
|
test('AC-002: Download com verificação SHA-256 @spec:AC-002', () => {
|
|
const name = 'gen1recomp-1.5.0-switch.zip';
|
|
const payload = Buffer.from('switch-sd-payload');
|
|
const hex = sha256Hex(payload);
|
|
const sums = M.parseSums(`${hex} ${name}\n`);
|
|
|
|
assert.equal(M.verifySha256(name, hex, sums).ok, true);
|
|
assert.equal(M.verifySha256(name, 'deadbeef', sums).reason, 'hash_mismatch');
|
|
// Invisible-error guard: download without a sum MUST NOT verify.
|
|
assert.equal(M.verifySha256(name, hex, {}).ok, false);
|
|
assert.equal(M.verifySha256(name, hex, {}).reason, 'sum_not_found');
|
|
assert.equal(M.verifySha256(name, '', sums).reason, 'missing_actual_hash');
|
|
});
|
|
|
|
test('AC-003: Aplicação atômica e handoff para o jogo @spec:AC-003', () => {
|
|
const plan = M.planAtomicApply('switch/gen1recomp', '/tmp/verified.zip');
|
|
assert.ok(plan.steps.some((s) => s.op === 'copy_to_part'));
|
|
assert.ok(plan.steps.some((s) => s.op === 'rename'));
|
|
assert.ok(plan.steps.some((s) => s.op === 'env_set_next_load'));
|
|
assert.ok(plan.steps.filter((s) => s.op === 'rename').length >= 2, 'renames game + launcher');
|
|
assert.ok(plan.preserve.some((p) => p.endsWith('pokemon-love2d')));
|
|
assert.ok(plan.forbidden.some((f) => f.startsWith('delete:') && f.includes('pokemon-love2d')));
|
|
assert.ok(plan.forbidden.some((f) => f.startsWith('write_direct:')));
|
|
const renameGame = plan.steps.find((s) => s.op === 'rename' && s.to.endsWith('gen1recomp-game.nro'));
|
|
assert.ok(renameGame);
|
|
assert.ok(renameGame.from.endsWith('.part'));
|
|
const renameLauncher = plan.steps.find((s) => s.op === 'rename' && s.to.endsWith('gen1recomp.nro'));
|
|
assert.ok(renameLauncher, 'launcher NRO replaced so NACP version stays in sync');
|
|
});
|
|
|
|
test('AC-004: Offline ou falha de rede não trava o jogo @spec:AC-004', () => {
|
|
assert.equal(M.CHECK_TIMEOUT_SEC, 6);
|
|
assert.equal(M.offlinePolicy(1, { userSkip: true }).action, 'play_installed');
|
|
assert.equal(M.offlinePolicy(1, { networkOk: false }).action, 'play_installed');
|
|
assert.equal(M.offlinePolicy(6, { networkOk: true }).action, 'play_installed');
|
|
assert.equal(M.offlinePolicy(6, { networkOk: true }).reason, 'timeout');
|
|
assert.equal(M.offlinePolicy(2, { networkOk: true }).action, 'keep_checking');
|
|
});
|
|
|
|
test('AC-005: Documentação Switch descreve o launcher OTA @spec:AC-005', () => {
|
|
const install = read('docs/switch-install.md');
|
|
const build = read('docs/switch-build.md');
|
|
const updater = read('docs/updater.md');
|
|
|
|
for (const [name, text] of [
|
|
['switch-install', install],
|
|
['switch-build', build],
|
|
['updater', updater],
|
|
]) {
|
|
assert.match(text, /native OTA launcher|launcher nativo/i, `${name} mentions native OTA launcher`);
|
|
}
|
|
|
|
assert.match(install, /manual|zip|microSD/i, 'manual zip fallback still documented');
|
|
assert.match(install, /gen1recomp-\*-switch\.zip|switch\.zip/i, 'unified Switch zip documented');
|
|
assert.doesNotMatch(install, /switch-ota\.zip/, 'legacy separate OTA zip must not be documented');
|
|
assert.match(install, /Sphaira|forwarder/i, 'Sphaira shortcut version note documented');
|
|
assert.doesNotMatch(
|
|
install,
|
|
/Switch OTA uses `src\/update\/Check\.lua`/i,
|
|
'must not claim Switch OTA uses LÖVE Check.lua'
|
|
);
|
|
assert.match(updater, /Nintendo Switch/i);
|
|
assert.match(updater, /networkValidated|disabled|desligado|off/i);
|
|
});
|
|
|
|
test('AC-006: LOVE no NX continua sem rede de updater @spec:AC-006', () => {
|
|
const platform = read('src/core/Platform.lua');
|
|
assert.match(
|
|
platform,
|
|
/networkValidated\s*=\s*not nx/,
|
|
'Platform.networkValidated must stay false on NX'
|
|
);
|
|
assert.doesNotMatch(platform, /networkValidated\s*=\s*true/);
|
|
|
|
const boot = read('src/update/Boot.lua');
|
|
assert.match(boot, /networkValidated/, 'Boot still gates on networkValidated');
|
|
|
|
assert.ok(read('src/update/Check.lua').length > 0);
|
|
assert.ok(read('src/update/check_worker.lua').length > 0);
|
|
assert.ok(read('src/core/HostShell.lua').length > 0);
|
|
assert.match(read('src/import/RomImporter.lua'), /updaterAllowed|networkValidated|Check/);
|
|
assert.ok(read('src/import/LauncherView.lua').length > 0);
|
|
|
|
const switchOta = read('src/update/SwitchOta.lua');
|
|
assert.doesNotMatch(switchOta, /networkValidated\s*=\s*true/);
|
|
assert.match(switchOta, /never runs this path|remains gated off on NX/i);
|
|
});
|
|
|
|
test('AC-007: Funções puras do protocolo OTA têm testes host-side @spec:AC-007', () => {
|
|
assert.ok(M.isOtaAssetName('gen1recomp-1.2.3-switch.zip'));
|
|
assert.equal(M.isOtaAssetName('gen1recomp-1.2.3-switch-ota.zip'), false);
|
|
assert.equal(M.isOtaAssetName('gen1recomp-1.2.3.love'), false);
|
|
assert.equal(M.compareSemver('2.0.0', '1.9.9'), 1);
|
|
assert.equal(M.compareSemver('1.0.0', '1.0.0'), 0);
|
|
assert.equal(M.compareSemver('0.9.0', '1.0.0'), -1);
|
|
|
|
const rel = M.parseRelease(
|
|
JSON.stringify({
|
|
tag_name: 'v9.9.9',
|
|
assets: [
|
|
{
|
|
name: 'gen1recomp-9.9.9-switch.zip',
|
|
browser_download_url: 'https://example/x',
|
|
},
|
|
],
|
|
})
|
|
);
|
|
assert.equal(rel.version, '9.9.9');
|
|
|
|
const sums = M.parseSums('deadbeef unexpected-name.zip\n');
|
|
assert.equal(
|
|
M.verifySha256('gen1recomp-9.9.9-switch.zip', 'deadbeef', sums).ok,
|
|
false,
|
|
'unexpected filename in sums must not verify OTA asset'
|
|
);
|
|
assert.equal(M.verifySha256('gen1recomp-9.9.9-switch.zip', 'anything', {}).ok, false);
|
|
|
|
const lua = read('src/update/SwitchOta.lua');
|
|
assert.ok(fs.existsSync(path.join(root, 'src/update/SwitchOta.lua')));
|
|
for (const fn of [
|
|
'compareSemver',
|
|
'parseRelease',
|
|
'decideUpdate',
|
|
'parseSums',
|
|
'verifySha256',
|
|
'planAtomicApply',
|
|
'offlinePolicy',
|
|
]) {
|
|
assert.match(lua, new RegExp(`function SwitchOta\\.${fn}`), `Lua exports ${fn}`);
|
|
}
|
|
assert.match(lua, /CHECK_TIMEOUT_SEC\s*=\s*6/);
|
|
assert.match(lua, /switch%.zip|%-switch%.zip/);
|
|
assert.doesNotMatch(lua, /switch%-ota%.zip|switch-ota\.zip/);
|
|
});
|
|
|
|
test('AC-008: Gates de regressão anti-“erro invisível” @spec:AC-008', () => {
|
|
const platform = read('src/core/Platform.lua');
|
|
assert.match(platform, /networkValidated\s*=\s*not nx\s+and\s+not uwp/);
|
|
|
|
for (const rel of ['docs/switch-install.md', 'docs/switch-build.md', 'docs/updater.md']) {
|
|
const text = read(rel);
|
|
assert.match(text, /native OTA launcher|launcher nativo/i, rel);
|
|
assert.match(
|
|
text,
|
|
/LÖVE self-updater|LOVE self-updater|self-updater LÖVE|updater LÖVE|Check\.lua/i,
|
|
`${rel} contrasts with LÖVE updater`
|
|
);
|
|
}
|
|
|
|
const manifest = read('scripts/switch/ota_launcher.manifest');
|
|
assert.match(manifest, /^OTA_ENABLED=1$/m);
|
|
assert.match(manifest, /ENTRY_NRO=switch\/gen1recomp\/gen1recomp\.nro/);
|
|
assert.match(manifest, /GAME_NRO=switch\/gen1recomp\/gen1recomp-game\.nro/);
|
|
assert.match(manifest, /OTA_ASSET_GLOB=gen1recomp-\*-switch\.zip/);
|
|
assert.doesNotMatch(manifest, /switch-ota\.zip/);
|
|
assert.match(manifest, /REQUIRE_SHA256SUMS=1/);
|
|
});
|
|
|
|
test('AC-009: Protocolo C host-testável espelha SwitchOta.lua @spec:AC-009', () => {
|
|
const proto = read('ports/switch/ota-launcher/src/ota_protocol.c');
|
|
const header = read('ports/switch/ota-launcher/include/ota_protocol.h');
|
|
assert.match(header, /OTA_CHECK_TIMEOUT_SEC\s+6/);
|
|
assert.match(header, /ota_compare_semver/);
|
|
assert.match(header, /ota_parse_release/);
|
|
assert.match(header, /ota_verify_sha256/);
|
|
assert.match(header, /ota_plan_atomic_apply/);
|
|
assert.match(header, /ota_offline_policy/);
|
|
assert.match(proto, /sum_not_found/);
|
|
assert.match(proto, /env_set_next_load/);
|
|
assert.match(header, /pokemon-love2d/);
|
|
assert.match(proto, /OTA_SAVE_DIR_NAME/);
|
|
|
|
const lua = read('src/update/SwitchOta.lua');
|
|
assert.match(lua, /CHECK_TIMEOUT_SEC\s*=\s*6/);
|
|
|
|
// Compile + run C host tests (gcc)
|
|
const r = spawnSync(
|
|
'make',
|
|
['-C', path.join(root, 'ports/switch/ota-launcher'), 'host-test'],
|
|
{ encoding: 'utf8' }
|
|
);
|
|
assert.equal(r.status, 0, `host-test failed:\n${r.stdout}\n${r.stderr}`);
|
|
assert.match(r.stdout, /all ota_protocol host tests passed/);
|
|
});
|
|
|
|
test('AC-010: Fonte do launcher e Makefile DEVKITPRO existem @spec:AC-010', () => {
|
|
for (const rel of [
|
|
'ports/switch/ota-launcher/Makefile',
|
|
'ports/switch/ota-launcher/README.md',
|
|
'ports/switch/ota-launcher/src/main.c',
|
|
'ports/switch/ota-launcher/src/ota_net.c',
|
|
'ports/switch/ota-launcher/src/ota_fs.c',
|
|
'scripts/switch/build_ota_launcher.sh',
|
|
'docs/switch-build.md',
|
|
]) {
|
|
assert.ok(fs.existsSync(path.join(root, rel)), `missing ${rel}`);
|
|
}
|
|
const main = read('ports/switch/ota-launcher/src/main.c');
|
|
assert.match(main, /envSetNextLoad|ota_fs_handoff_to_game/);
|
|
assert.match(main, /gen1recomp-game\.nro|OTA_GAME_NRO_NAME/);
|
|
assert.match(main, /Quiet by default|stays quiet|LÖVE self-updater stays off|self-updater stays off/i);
|
|
assert.match(main, /ota_ui_prompt_update|ota_ui_show_progress/);
|
|
assert.match(main, /ota_ui_alert_error/);
|
|
assert.match(main, /Step 1\/3: Downloading/);
|
|
assert.doesNotMatch(main, /\u2026/, 'OTA UI strings must be ASCII (no Unicode ellipsis)');
|
|
assert.doesNotMatch(main, /ota_ui_alert\([^)]*Install failed/, 'generic install alert removed');
|
|
assert.doesNotMatch(main, /consoleInit/, 'no terminal console UI');
|
|
assert.ok(fs.existsSync(path.join(root, 'ports/switch/ota-launcher/src/ota_ui.c')));
|
|
const otaUi = read('ports/switch/ota-launcher/src/ota_ui.c');
|
|
assert.match(otaUi, /COL_RAIL_R|rail|FFD600|255,\s*214/);
|
|
assert.match(otaUi, /logo\.rgba/);
|
|
assert.doesNotMatch(otaUi, /stb_image/);
|
|
assert.ok(
|
|
fs.existsSync(path.join(root, 'ports/switch/assets/logo.rgba')),
|
|
'pre-baked OTA logo asset'
|
|
);
|
|
assert.ok(
|
|
fs.existsSync(path.join(root, 'scripts/switch/bake_ota_logo.py')),
|
|
'logo bake script for regenerating logo.rgba'
|
|
);
|
|
assert.match(otaUi, /ota_ui_sanitize_ascii/);
|
|
assert.match(otaUi, /draw_text_wrapped_centered/);
|
|
assert.match(otaUi, /ota_ui_alert_error/);
|
|
assert.match(read('ports/switch/ota-launcher/src/ota_net.c'), /ota_net_init/);
|
|
assert.match(read('ports/switch/ota-launcher/src/main.c'), /ota_net_init/);
|
|
assert.match(read('ports/switch/ota-launcher/src/ota_net.c'), /CURLOPT_SSL_VERIFYPEER,\s*1L/);
|
|
assert.match(read('ports/switch/ota-launcher/src/ota_net.c'), /romfs:\/cacert\.pem/);
|
|
assert.doesNotMatch(read('ports/switch/ota-launcher/src/ota_net.c'), /CURLOPT_SSL_VERIFYPEER,\s*0L/);
|
|
assert.match(read('ports/switch/ota-launcher/Makefile'), /^ROMFS\s*:=/m);
|
|
assert.match(read('ports/switch/ota-launcher/Makefile'), /cacert\.pem/);
|
|
assert.match(read('ports/switch/ota-launcher/Makefile'), /logo\.rgba/);
|
|
|
|
const mk = read('ports/switch/ota-launcher/Makefile');
|
|
assert.match(mk, /libnx\/switch_rules|DEVKITPRO/);
|
|
assert.match(mk, /-lcurl/);
|
|
assert.match(mk, /-lzzip/);
|
|
assert.match(mk, /ports\/switch\/assets\/icon\.jpg|assets\/icon\.jpg/, 'uses project Switch icon');
|
|
|
|
assert.ok(fs.existsSync(path.join(root, 'ports/switch/ota-launcher/src/ota_unzip.c')));
|
|
assert.match(read('ports/switch/ota-launcher/src/ota_unzip.c'), /zzip\/zzip\.h/);
|
|
assert.match(read('ports/switch/ota-launcher/src/main.c'), /ota_unzip_extract_file/);
|
|
assert.match(read('ports/switch/ota-launcher/src/main.c'), /GAME_MEMBER_IN_ZIP|switch\/gen1recomp\//);
|
|
assert.match(read('ports/switch/ota-launcher/src/main.c'), /LAUNCHER_MEMBER_IN_ZIP|ota_fs_stage_launcher_bootstrap/);
|
|
assert.match(read('ports/switch/ota-launcher/src/main.c'), /ota_fs_stage_launcher_bootstrap\([\s\S]*\) != 0/);
|
|
assert.match(read('ports/switch/ota-launcher/src/main.c'), /return 2.*bootstrap|bootstrap.*return 2/i);
|
|
assert.match(read('ports/switch/ota-launcher/src/ota_fs.c'), /ota_fs_atomic_replace_nro/);
|
|
assert.match(read('ports/switch/ota-launcher/src/ota_fs.c'), /remove\(dest\)/);
|
|
assert.doesNotMatch(read('ports/switch/ota-launcher/src/ota_fs.c'), /#ifdef _WIN32[\s\S]*remove\(dest\)/);
|
|
assert.doesNotMatch(read('scripts/switch/install_devkitpro_deps.sh'), /switch-minizip/);
|
|
assert.match(read('scripts/switch/install_devkitpro_deps.sh'), /switch-zziplib/);
|
|
assert.match(read('scripts/switch/install_devkitpro_deps.sh'), /switch-dev/);
|
|
|
|
const buildDoc = read('docs/switch-build.md');
|
|
assert.match(buildDoc, /ports\/switch\/ota-launcher|build_ota_launcher/);
|
|
assert.match(buildDoc, /native packages.*or.*Docker|native or Docker/i);
|
|
assert.match(buildDoc, /--fused|install_devkitpro_deps/);
|
|
assert.match(buildDoc, /Requires DEVKITPRO|DEVKITPRO is[\s\S]*required/i);
|
|
assert.doesNotMatch(buildDoc, /switch-ota\.zip/);
|
|
assert.doesNotMatch(buildDoc, /--ota\b/);
|
|
|
|
const readme = read('ports/switch/ota-launcher/README.md');
|
|
assert.match(readme, /DEVKITPRO|devkitPro/i);
|
|
assert.match(readme, /Docker|devkita64/i);
|
|
assert.match(readme, /Quiet|quiet|silen/i);
|
|
});
|
|
|
|
test('AC-011: Empacotamento dual-NRO e selftest @spec:AC-011', () => {
|
|
const pack = read('scripts/switch/pack_sd_zip.sh');
|
|
assert.match(pack, /LAUNCHER_NRO/);
|
|
assert.match(pack, /gen1recomp-game\.nro/);
|
|
assert.match(pack, /version\.txt/);
|
|
|
|
assert.equal(
|
|
fs.existsSync(path.join(root, 'scripts/switch/pack_ota_zip.sh')),
|
|
false,
|
|
'separate pack_ota_zip.sh removed — unified *-switch.zip'
|
|
);
|
|
|
|
const buildSwitch = read('scripts/build_switch.sh');
|
|
assert.doesNotMatch(buildSwitch, /--ota\b/);
|
|
assert.doesNotMatch(buildSwitch, /pack_ota_zip\.sh/);
|
|
assert.match(buildSwitch, /build_ota_launcher\.sh/);
|
|
assert.match(buildSwitch, /dual-NRO SD zip|OTA download asset/i);
|
|
|
|
const buildOtaLauncher = read('scripts/switch/build_ota_launcher.sh');
|
|
assert.match(buildOtaLauncher, /ota_launcher_deps_ready/);
|
|
assert.match(buildOtaLauncher, /fail_missing_devkitpro/);
|
|
assert.match(buildSwitch, /preflight_fused_build/);
|
|
|
|
const selftest = read('scripts/switch/selftest_build_switch.sh');
|
|
assert.match(selftest, /dual-NRO|gen1recomp-game\.nro/);
|
|
assert.match(selftest, /ota_launcher\.manifest/);
|
|
assert.match(selftest, /OTA uses the same SD zip|same SD zip|legacy OTA-only/i);
|
|
assert.match(selftest, /ota_ui|framebuffer|no prompt when up to date/i);
|
|
|
|
const manifest = read('scripts/switch/ota_launcher.manifest');
|
|
assert.match(manifest, /^OTA_ENABLED=1$/m);
|
|
assert.match(manifest, /OTA_ASSET_GLOB=gen1recomp-\*-switch\.zip/);
|
|
|
|
// Run the dual-NRO portion via full offline selftest (includes legacy + OTA)
|
|
const r = spawnSync('bash', [path.join(root, 'scripts/switch/selftest_build_switch.sh')], {
|
|
encoding: 'utf8',
|
|
});
|
|
assert.equal(r.status, 0, `selftest failed:\n${r.stdout}\n${r.stderr}`);
|
|
assert.match(r.stdout, /dual-NRO OTA layout/);
|
|
});
|