mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
When the page's WebGPU device was lost, the canvas kept showing its last frame: edits still went through and rendered frames were right, but the editor never showed them and nothing said why (the status message about it is drawn in the canvas that can't present any more). The page now covers the canvas with a notice that offers Save Project (a download) and Reload. Reload first writes pending changes to browser storage, so the session snapshot brings the project and its unsaved changes back; if that write fails it says so and offers Reload Anyway. Without a kept session (?empty, memory storage) the notice says to save first. Test: apps/effectcraft-web/tests/page.mjs (Node, fake DOM). Co-Authored-By: Claude Opus 5.5 <[email protected]>
130 lines
4.3 KiB
JavaScript
130 lines
4.3 KiB
JavaScript
// Page-side helpers of the web app (js/host.js) under Node with a fake DOM, no browser or Wasm
|
|
// build needed:
|
|
//
|
|
// node apps/effectcraft-web/tests/page.mjs
|
|
//
|
|
// - Add Files… / Upload Folder…: a picked file that can't be read is listed with its error
|
|
// instead of failing the whole pick silently, and cancelling picks nothing (#226).
|
|
// - A lost WebGPU device: the page says so over the canvas and offers Save Project and Reload;
|
|
// the reload writes pending changes to browser storage first, and a failed write is reported
|
|
// instead of reloading (#225).
|
|
// Exits non-zero on failure.
|
|
import assert from "node:assert/strict";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
globalThis.self = globalThis;
|
|
|
|
class FakeElement {
|
|
constructor(tag) {
|
|
this.tag = tag;
|
|
this.children = [];
|
|
this.attributes = {};
|
|
this.textContent = "";
|
|
}
|
|
setAttribute(k, v) {
|
|
this.attributes[k] = v;
|
|
}
|
|
append(...c) {
|
|
this.children.push(...c);
|
|
}
|
|
click() {
|
|
this.onclick?.();
|
|
}
|
|
*all() {
|
|
yield this;
|
|
for (const c of this.children) yield* c.all();
|
|
}
|
|
}
|
|
let picker;
|
|
globalThis.document = {
|
|
body: new FakeElement("body"),
|
|
createElement(tag) {
|
|
const el = new FakeElement(tag);
|
|
if (tag === "input") {
|
|
picker = el;
|
|
el.click = () => {};
|
|
}
|
|
return el;
|
|
},
|
|
getElementById(id) {
|
|
return [...this.body.all()].find((e) => e.id === id) ?? null;
|
|
},
|
|
};
|
|
let reloads = 0;
|
|
globalThis.location = { reload: () => reloads++ };
|
|
|
|
const host = await import(pathToFileURL(join(here, "../js/host.js")));
|
|
|
|
// #226: one readable and one unreadable file.
|
|
const file = (name, read) => ({ name, webkitRelativePath: "", arrayBuffer: read });
|
|
const picked = host.browsePickFiles(false);
|
|
picker.files = [
|
|
file("ok.png", async () => new Uint8Array([1, 2, 3]).buffer),
|
|
file("red-corner.png", async () => {
|
|
throw new DOMException("Generated unreadable selected file", "NotReadableError");
|
|
}),
|
|
];
|
|
await picker.onchange();
|
|
const out = await picked;
|
|
assert.equal(out.length, 2);
|
|
assert.deepEqual([out[0].path, [...out[0].bytes]], ["ok.png", [1, 2, 3]]);
|
|
assert.deepEqual(out[1], { path: "red-corner.png", error: "Generated unreadable selected file" });
|
|
const cancelled = host.browsePickFiles(false);
|
|
picker.oncancel();
|
|
assert.deepEqual(await cancelled, [], "cancelling picks nothing");
|
|
|
|
// #225: the device-lost notice.
|
|
const calls = [];
|
|
let flushFails = false;
|
|
self.effectcraft = {
|
|
flush: async () => {
|
|
calls.push("flush");
|
|
if (flushFails) throw new Error("quota exceeded");
|
|
},
|
|
execute: async (command, params) => {
|
|
calls.push([command, params]);
|
|
if (command === "file.save") throw new Error("no path: use file.saveAs {path}");
|
|
},
|
|
};
|
|
const notice = () => document.getElementById("device-lost");
|
|
const buttons = () => [...notice().all()].filter((e) => e.tag === "button");
|
|
const text = () => [...notice().all()].find((e) => e.tag === "p").textContent;
|
|
|
|
host.showDeviceLost("GPU device lost (Destroyed): destroyed", true);
|
|
host.showDeviceLost("again", true);
|
|
assert.equal(document.body.children.length, 1, "one notice");
|
|
assert.equal([...notice().all()].find((e) => e.tag === "small").textContent, "GPU device lost (Destroyed): destroyed");
|
|
assert.match(text(), /Reload to continue/);
|
|
assert.deepEqual(
|
|
buttons().map((b) => b.textContent),
|
|
["Save Project", "Reload"],
|
|
);
|
|
// Save Project: Save, or Save As for an untitled project (a download).
|
|
await buttons()[0].onclick();
|
|
assert.deepEqual(calls.splice(0), [
|
|
["file.save", {}],
|
|
["file.saveAs", { path: "/Untitled Project.ecproj" }],
|
|
]);
|
|
// Reload writes pending changes first.
|
|
await buttons()[1].onclick();
|
|
assert.deepEqual(calls.splice(0), ["flush"]);
|
|
assert.equal(reloads, 1);
|
|
|
|
// A failed write is reported instead of reloading; Reload Anyway reloads.
|
|
document.body.children = [];
|
|
flushFails = true;
|
|
host.showDeviceLost("GPU device lost (Unknown): gone", false);
|
|
assert.match(text(), /isn't kept in browser storage/);
|
|
await buttons()[1].onclick();
|
|
await tick();
|
|
assert.equal(reloads, 1, "no reload after a failed write");
|
|
assert.match(text(), /quota exceeded/);
|
|
assert.equal(buttons()[1].textContent, "Reload Anyway");
|
|
buttons()[1].onclick();
|
|
assert.equal(reloads, 2);
|
|
|
|
console.log("page: ok");
|