Files
Quer3qandClaude Opus 5.5 8b4215acab Update check and toast, PS5 update through elfldr, takeover by any newly loaded copy
At every start the app and the PS5 page ask GitHub's releases/latest and show a green toast
(bottom corner, "v{v} is available", Update / Later) only after a successful check found an
installable update: a newer vX.Y.Z tag, this platform's exact asset uploaded and not empty,
and on the PS5 elfldr answering (GET /api/elfldr). Any failure is silent; no setting,
nothing stored (Later hides it for the session).
- Desktop: Update opens the platform's release archive in the browser (update_target,
  open_update, shared open_url; CSP allows api.github.com). No new dependencies.
- PS5: POST /api/self_update asks elfldr for GET /?uri=<release .elf?args=serve&pipe=0>;
  the server keeps serving and the page waits one minute in all for a new instance.
- Takeover: a newly loaded Forge of any version closes an idle running one and takes the
  port: quit {"if_idle": true} for copies with session handover, GET /api/jobs then quit
  for v0.1.1 and older. Running jobs always block ("Can't close Dump Forge - some jobs are
  still running.", 11 console languages); "already running" is gone.
- Self copy saves hold a cross-process flock, so two copies starting together keep the
  newest; trace downloads count as busy; admission waits are bounded to 1 s.
- Locales (11), AGENTS.md, ps5/README.md, README.md, DESIGN.md; tests in serve.rs, api.rs,
  self_copy.rs and app/scripts/check-update.mjs. Reviewed with Codex (3 rounds, clean).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:44:34 +03:00

172 lines
8.8 KiB
JavaScript

// Checks src/update.ts's decisions (tag, version order, the release file, failures as silence)
// with a fake fetch, then http-client.ts's selfUpdate with a fake server and clock, with a
// plain `node scripts/check-update.mjs` (type stripping, no network).
import assert from "node:assert/strict";
const U = await import("../src/update.ts");
// ---- tags: `vX.Y.Z` only ----
assert.equal(U.tagVersion("v1.2.3"), "1.2.3");
assert.equal(U.tagVersion("v0.10.0"), "0.10.0");
for (const bad of ["1.2.3", "v1.2", "v1.2.3.4", "v1.2.3-pre1", "v1.2.3+b", " v1.2.3", "v1.2.3\n", "V1.2.3", "v1.x.3", "v1234567890.0.0", "", null, 1, undefined])
assert.equal(U.tagVersion(bad), null, String(bad));
console.log("tags: vX.Y.Z only (no prerelease, at most 9 digits a part)");
// ---- order: by number, a prerelease before its release ----
const newer = [
["0.1.2", "0.1.1"],
["0.2.0", "0.1.9"],
["1.0.0", "0.99.99"],
["0.1.10", "0.1.9"],
["0.1.2", "0.1.2-pre4"],
["0.1.2", "0.1.2-rc.1+build5"],
["0.1.3", "0.1.2+build"],
];
for (const [l, c] of newer) assert.equal(U.isNewer(l, c), true, `${l} > ${c}`);
const notNewer = [
["0.1.1", "0.1.1"],
["0.1.0", "0.1.1"],
["0.1.9", "0.1.10"],
["0.1.1", "0.1.2-pre4"],
["0.1.2", "0.1.2+build"],
["0.1.2", "garbage"],
["0.1.2", ""],
["x", "0.1.1"],
];
for (const [l, c] of notNewer) assert.equal(U.isNewer(l, c), false, `${l} !> ${c}`);
console.log("order: numeric, a current prerelease is older than its release, junk is never newer");
// ---- the release file ----
const ps5 = { version: "0.1.1", asset: "ps5.elf" };
const mac = { version: "0.1.1", asset: "macos-universal.zip" };
const rel = (tag, names) => ({ tag_name: tag, assets: names.map((name) => ({ name, state: "uploaded", size: 1 })) });
const all = (v) => ["ps5.elf", "macos-universal.zip", "windows-x86-64.zip", "linux-x86-64.tar.gz"].map((a) => U.assetName(v, a));
assert.equal(U.assetName("0.2.0", "ps5.elf"), "ps5-dump-forge-0.2.0-ps5.elf");
assert.equal(U.offer(rel("v0.2.0", all("0.2.0")), ps5), "0.2.0");
assert.equal(U.offer(rel("v0.2.0", all("0.2.0")), mac), "0.2.0");
assert.equal(U.offer(rel("v0.2.0", ["ps5-dump-forge-0.2.0-macos-universal.zip"]), ps5), null, "no elf");
assert.equal(U.offer(rel("v0.2.0", all("0.1.9")), ps5), null, "another version's files");
assert.equal(U.offer(rel("v0.2.0", ["PS5-DUMP-FORGE-0.2.0-PS5.ELF"]), ps5), null, "exact name");
assert.equal(U.offer(rel("v0.2.0", []), ps5), null, "no files yet");
assert.equal(U.offer({ tag_name: "v0.2.0" }, ps5), null, "no assets field");
assert.equal(U.offer({ tag_name: "v0.2.0", assets: [null, 1, {}] }, ps5), null, "junk assets");
const elf = "ps5-dump-forge-0.2.0-ps5.elf";
assert.equal(U.offer({ tag_name: "v0.2.0", assets: [{ name: elf, state: "uploaded", size: 1 }] }, ps5), "0.2.0");
assert.equal(U.offer({ tag_name: "v0.2.0", assets: [{ name: elf, state: "starter", size: 1 }] }, ps5), null, "upload unfinished");
assert.equal(U.offer({ tag_name: "v0.2.0", assets: [{ name: elf, state: "uploaded", size: 0 }] }, ps5), null, "empty file");
assert.equal(U.offer({ tag_name: "v0.2.0", assets: [{ name: elf }] }, ps5), null, "no state or size");
assert.equal(U.offer(rel("v0.1.1", all("0.1.1")), ps5), null, "same version");
assert.equal(U.offer(rel("v0.1.0", all("0.1.0")), ps5), null, "older");
assert.equal(U.offer(rel("v0.2.0-pre1", all("0.2.0-pre1")), ps5), null, "prerelease tag");
assert.equal(U.offer(null, ps5), null);
assert.equal(U.offer("x", ps5), null);
assert.equal(U.offer(rel("v0.1.1", all("0.1.1")), { version: "0.1.1-pre2", asset: "ps5.elf" }), "0.1.1");
console.log("offer: a newer tag whose assets hold this platform's exact file name");
// ---- checkUpdate: the request, and every failure is null ----
const reply = (body, status = 200) => async (url, init) => {
assert.equal(url, "https://api.github.com/repos/quer3q/ps5-dump-forge/releases/latest");
assert.deepEqual(Object.keys(init).sort(), ["cache", "credentials", "signal"], "no headers, no body");
assert.equal(init.cache, "no-store");
assert.equal(init.credentials, "omit");
return new Response(typeof body === "string" ? body : JSON.stringify(body), { status });
};
assert.equal(await U.checkUpdate(ps5, reply(rel("v0.2.0", all("0.2.0")))), "0.2.0");
assert.equal(await U.checkUpdate(ps5, reply(rel("v0.2.0", all("0.2.0")), 403)), null, "rate limited");
assert.equal(await U.checkUpdate(ps5, reply(rel("v0.2.0", all("0.2.0")), 404)), null, "no release");
assert.equal(await U.checkUpdate(ps5, reply("<html>")), null, "not JSON");
assert.equal(await U.checkUpdate(ps5, async () => { throw new TypeError("offline"); }), null, "offline");
const realSetTimeout = globalThis.setTimeout;
globalThis.setTimeout = (f, ms, ...rest) => realSetTimeout(f, ms === U.CHECK_TIMEOUT_MS ? 5 : ms, ...rest);
const hang = async (_, init) =>
new Promise((_ok, no) => init.signal.addEventListener("abort", () => no(new DOMException("aborted", "AbortError"))));
assert.equal(await U.checkUpdate(ps5, hang), null, "no answer within the timeout");
const slowBody = async (_, init) =>
new Response(
new ReadableStream({
start(c) {
c.enqueue(new TextEncoder().encode("{"));
init.signal.addEventListener("abort", () => c.error(new DOMException("aborted", "AbortError")));
},
}),
{ status: 200 },
);
assert.equal(await U.checkUpdate(ps5, slowBody), null, "a body that never ends");
globalThis.setTimeout = realSetTimeout;
console.log("checkUpdate: GET without cookies or cache; HTTP errors, junk, offline and timeouts are null");
// ---- http-client's selfUpdate: a fake server and clock ----
const c = await import("../src/http-client.ts");
function server({ updateStatus = 200, updateError, startAfter = 3, dropCall = false, downFor = 0, sessionMs = 0 }) {
const d = { now: 0, calls: [], sessions: 0 };
const json = (v, status = 200) => new Response(JSON.stringify(v), { status });
globalThis.fetch = async (path, init) => {
if (path === "/api/session") {
d.sessions++;
d.now += sessionMs; // a slow answer
if (d.sessions === 1) return json({ instance: "old" });
if (d.sessions <= 1 + downFor) throw new TypeError("connection refused");
return json({ instance: d.now >= startAfter * 1000 ? "new" : "old" });
}
assert.equal(path, "/api/self_update");
assert.equal(init.method, "POST");
d.calls.push(JSON.parse(init.body));
if (dropCall) throw new TypeError("connection reset");
return json(updateStatus === 200 ? {} : { error: updateError }, updateStatus);
};
// The 1 s poll sleeps advance the clock at once; request timeouts are real (and cleared).
globalThis.setTimeout = (f, ms, ...rest) => {
if (ms !== 1000) return realSetTimeout(f, ms, ...rest);
d.now += ms;
return realSetTimeout(f, 0, ...rest);
};
Date.now = () => d.now;
return d;
}
const realNow = Date.now;
try {
let d = server({});
await c.selfUpdate("0.2.0");
assert.deepEqual(d.calls, [{ version: "0.2.0" }]);
assert.equal(d.now, 3000, "polled every second until the new instance answered");
console.log("selfUpdate: POST {version}, then the session until its instance changes");
d = server({ dropCall: true, downFor: 2 });
await c.selfUpdate("0.2.0");
assert.equal(d.calls.length, 1);
console.log("selfUpdate: a dropped reply and a server that is down for a while are waited out");
d = server({ updateStatus: 503, updateError: "elfldr isn't running on port 9021: load it, then try again" });
await assert.rejects(c.selfUpdate("0.2.0"), (e) => e === "elfldr isn't running on port 9021: load it, then try again");
assert.equal(d.now, 0, "no waiting after a refusal");
console.log("selfUpdate: the server's refusal is the error, as is");
d = server({ startAfter: 1e9 });
await assert.rejects(c.selfUpdate("0.2.0"), (e) => e === U.UPDATE_TIMEOUT);
assert.equal(d.now, c.UPDATE_WAIT_MS);
console.log(`selfUpdate: no new instance -> UPDATE_TIMEOUT after ${c.UPDATE_WAIT_MS / 1000} s`);
// Slow session answers count against the same minute, the first one included: a new
// instance seen only after it is still a timeout.
d = server({ startAfter: 61, sessionMs: 2999 });
await assert.rejects(c.selfUpdate("0.2.0"), (e) => e === U.UPDATE_TIMEOUT);
assert.ok(d.now <= c.UPDATE_WAIT_MS + 2999, `gave up at ${d.now} ms`);
console.log("selfUpdate: the minute runs from the click, slow answers included");
// The first answer comes after the minute: nothing is asked.
d = server({ sessionMs: 61000 });
await assert.rejects(c.selfUpdate("0.2.0"), (e) => e === U.UPDATE_TIMEOUT);
assert.equal(d.calls.length, 0, "no POST once expired");
console.log("selfUpdate: an expired minute never sends the update");
globalThis.fetch = async () => {
throw new TypeError("connection refused");
};
await assert.rejects(c.selfUpdate("0.2.0"), (e) => e === "unreachable");
console.log("selfUpdate: an unreachable server is refused before anything is asked");
} finally {
globalThis.setTimeout = realSetTimeout;
Date.now = realNow;
}