/** * Importing the same picture twice has to work. * * A file input fires `change` only when the picker hands back something * different from what it is already holding. Leave the last file in it and the * second pick of that file is not a change: no event, no import, and nothing on * screen to say why. The path is ordinary — import a picture, roll a new camo * over it, want the picture back — and it failed in the way that is hardest to * report, by doing nothing at all. * * Emptying the input is the whole fix, and it has to happen on every path * through the handler, the failure one included: someone who picks a file that * will not decode, repairs it and picks it again is in exactly the same trap. * * Structural, because there is nothing to catch at runtime. The handler is * correct, the browser is behaving as specified, and the bug is an event that * never arrives. * * Run with `npm test`. */ import { test } from "node:test"; import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; const src = fileURLToPath(new URL("../src/", import.meta.url)); const editor = await readFile(`${src}camo/editor.ts`, "utf8"); /** Every file input the app builds. There is one; the rule is for all of them. */ function fileInputs(source) { return [ ...source.matchAll(/const (\w+) = el\("input", \{\s*\n\s*type: "file"/g), ].map((m) => m[1]); } /** The body of `.addEventListener("change", () => { ... })`. */ function changeHandler(source, name) { const opener = `${name}.addEventListener("change", () => {`; const start = source.indexOf(opener); assert.notEqual(start, -1, `${name} has no change handler`); // The handler closes at the indentation it opened on, which is two spaces // here; every block inside it closes deeper. const end = source.indexOf("\n });", start); assert.notEqual(end, -1, `${name}'s change handler has no end`); return source.slice(start, end); } test("the camo file input is emptied once its file is in hand", () => { const inputs = fileInputs(editor); assert.deepEqual( inputs, ["fileInput"], "the file input moved, was renamed, or gained a sibling this test does not know about", ); for (const name of inputs) { const handler = changeHandler(editor, name); assert.match( handler, new RegExp(`${name}\\.value = ""`), `${name} keeps its last file, so picking that same file again fires nothing`, ); } }); test("it is emptied in the handler itself, not in a callback", () => { const handler = changeHandler(editor, "fileInput"); // Nesting, read off the indentation prettier enforces. The handler's own // body sits at four spaces; anything inside img.onload or img.onerror sits // at six or deeper. Textual order cannot tell the two apart, because // img.onload is written above the img.src that runs it — a clear inside the // success callback still reads as "before the decode" and would leave the // failure path holding the file. assert.match( handler, /\n {4}fileInput\.value = "";/, 'fileInput.value = "" is nested inside a callback, so only some outcomes ' + "clear it — a file that failed to decode stays in the input and a " + "repaired copy of it will not reimport", ); // And the File has to be taken before the input is emptied, because emptying // it drops the FileList. const taken = handler.indexOf("fileInput.files?.[0]"); const cleared = handler.indexOf('fileInput.value = ""'); assert.ok( taken !== -1 && taken < cleared, "the file is read after the input is emptied, which reads nothing", ); });