app, core: save/load a result, save/load a project, generate every scene
Player: Save to bin (whatever the viewer shows, if it has a real file identity) and Save frame (canvas-capture the video on screen, upload through ComfyUI's own /upload/image, no new route needed). Both add without navigating the viewer -- an upload is a real round trip, and the result is added to the bin, not shown as a stale finished pick. Save/Load Project File: a real download route with Content-Disposition, and an import route that never trusts an id in the file -- an import always lands as its own project, never overwrites one already here. Generate All: every scene in order, waiting for each to finish before starting the next -- not a montage (v5 has no render/stitch stage), N separate generations each landing on its own scene. Both project-switching (open/newProject/importProject) and the earlier run-reattach path shared a bug class this session kept finding: state captured at one moment, applied at a later async moment with no check that nothing had moved on in between. Closed it properly this time -- a generation counter every switch bumps and checks, including start() (the page's own handlers are live before it resolves) and undo/redo (also "a different project is open now"), and scheduleSave() captures the object an edit actually touched instead of re-reading a variable that can point somewhere else by the time its debounce timer fires. Four adversarial rounds against this range, each fixed before landing.
This commit is contained in:
+95
-8
@@ -9,9 +9,9 @@ import { mountAll } from "./shell/panels.js";
|
||||
import { settle } from "./shell/mounts.js";
|
||||
import { all as allValues } from "./shell/values.js";
|
||||
import { composer } from "./composer/composer.js";
|
||||
import { createRun, viewUrl, DONE } from "./shell/run.js";
|
||||
import { createRun, viewUrl, DONE, FAILED, CANCELLED } from "./shell/run.js";
|
||||
import { clientId, connect, queuedFor, finishedFor } from "./shell/client.js";
|
||||
import { wire } from "./shell/session.js";
|
||||
import { wire, waitForTerminal } from "./shell/session.js";
|
||||
import { check, load, describe, search } from "./shell/pipeline.js";
|
||||
import { open as openPipeline } from "./shell/pipeline_window.js";
|
||||
import { createPrompts } from "./shell/prompt.js";
|
||||
@@ -113,13 +113,85 @@ async function start() {
|
||||
// Before the page, because the properties column is composed with it in.
|
||||
inspector = createInspector({ project, onRename: (name) => project.rename(name) });
|
||||
|
||||
// "Load Project File..." trigger. One input, kept and reused rather than
|
||||
// built fresh per click -- a fresh one would need its `change` listener
|
||||
// rewired every time, which is a listener leaked on every click but the last.
|
||||
const loadProjectFile = document.createElement("input");
|
||||
loadProjectFile.type = "file";
|
||||
loadProjectFile.accept = "application/json,.json";
|
||||
loadProjectFile.hidden = true;
|
||||
document.body.appendChild(loadProjectFile);
|
||||
loadProjectFile.addEventListener("change", async () => {
|
||||
const file = loadProjectFile.files[0];
|
||||
loadProjectFile.value = ""; // the same file picked twice must still change
|
||||
if (!file) return;
|
||||
try {
|
||||
await project.importProject(JSON.parse(await file.text()));
|
||||
} catch (err) {
|
||||
page.transport.say(`Could not load that project file: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// What Generate does for whichever scene is selected right now -- shared
|
||||
// with "Generate All" below, so the two never disagree about how a run gets
|
||||
// attributed to its scene.
|
||||
const generateCurrentScene = () => {
|
||||
ranFor = project.selectedId;
|
||||
ranForProject = project.project ? project.project.id : null;
|
||||
return session.generate();
|
||||
};
|
||||
|
||||
// Every scene, one at a time, waiting for each to actually finish before
|
||||
// starting the next. Not a montage -- v5 has no render/stitch stage -- this
|
||||
// is N separate generations, each landing on its own scene, the same as
|
||||
// pressing Generate N times without having to sit at the keyboard for each.
|
||||
let batchCancelled = false;
|
||||
async function generateAll() {
|
||||
const scenes = [...project.scenes];
|
||||
if (!scenes.length) return;
|
||||
generateAllBtn.setDisabled(true);
|
||||
batchCancelled = false;
|
||||
page.transport.hold(`Generating scene 1 of ${scenes.length}…`);
|
||||
const failed = [];
|
||||
try {
|
||||
for (let i = 0; i < scenes.length; i++) {
|
||||
if (batchCancelled) break;
|
||||
const scene = scenes[i];
|
||||
// A scene removed mid-batch by whoever is not at the keyboard right
|
||||
// now is not a scene left to generate.
|
||||
if (!project.scenes.some((s) => s.id === scene.id)) continue;
|
||||
project.select(scene.id);
|
||||
showScene(project.selected);
|
||||
page.transport.say(`Generating scene ${i + 1} of ${scenes.length}…`);
|
||||
const waiting = waitForTerminal(run);
|
||||
const queued = await generateCurrentScene();
|
||||
if (!queued) {
|
||||
// Refused before it ever reached run.start() -- nothing will ever
|
||||
// transition the run, so nothing will ever resolve `waiting` either.
|
||||
waiting.cancel();
|
||||
failed.push(scene);
|
||||
continue;
|
||||
}
|
||||
const phase = await waiting;
|
||||
if (phase === CANCELLED) { batchCancelled = true; break; }
|
||||
if (phase === FAILED) failed.push(scene);
|
||||
}
|
||||
} finally {
|
||||
generateAllBtn.setDisabled(false);
|
||||
page.transport.release(run.state);
|
||||
}
|
||||
if (batchCancelled) page.transport.say("Generate All was cancelled.");
|
||||
else if (failed.length) page.transport.say(`${failed.length} of ${scenes.length} scenes did not generate.`);
|
||||
}
|
||||
const generateAllBtn = composer.button.sm({
|
||||
label: "Generate All", tone: "ghost",
|
||||
onClick: () => generateAll(),
|
||||
});
|
||||
|
||||
const page = build(root, {
|
||||
inspector,
|
||||
onGenerate: () => {
|
||||
ranFor = project.selectedId;
|
||||
ranForProject = project.project ? project.project.id : null;
|
||||
session.generate();
|
||||
},
|
||||
onGenerate: generateCurrentScene,
|
||||
generateAll: generateAllBtn,
|
||||
onCancel: () => run.cancel(),
|
||||
onConstructor: () => page.constructor.open(),
|
||||
// The window asks whether a run is in flight, because the restart that
|
||||
@@ -129,7 +201,8 @@ async function start() {
|
||||
onLog: () => openLog(),
|
||||
// Opening one puts it in the Preview, which is where somebody hunting for a
|
||||
// file wants it -- the same place a result from the bin goes.
|
||||
onTemp: () => openTemp({ onOpen: (item) => page.viewer.setSource(item.url, item.kind) }),
|
||||
onTemp: () => openTemp({ onOpen: (item) => page.viewer.setSource(
|
||||
item.url, item.kind, item.file ? { ...item.file, type: "temp" } : null) }),
|
||||
// What the Edit menu offers, and what the keyboard reaches. One list, so a
|
||||
// menu item and its shortcut cannot drift apart.
|
||||
edits: {
|
||||
@@ -156,6 +229,19 @@ async function start() {
|
||||
const name = await asked.result;
|
||||
if (name === null) return; // cancelled
|
||||
await project.newProject(name);
|
||||
} else if (id === "save-file") {
|
||||
const url = project.downloadUrl();
|
||||
if (!url) return;
|
||||
// Navigation, not a fetch: the server's own Content-Disposition is
|
||||
// what makes this a save-as instead of a page full of JSON.
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
} else if (id === "load-file") {
|
||||
loadProjectFile.click();
|
||||
} else if (id !== (project.project || {}).id) {
|
||||
await project.open(id);
|
||||
} else {
|
||||
@@ -331,6 +417,7 @@ async function start() {
|
||||
|
||||
window.FunPack = {
|
||||
manifest, values: allValues, failed, hidden, run, bin: page.bin, project, wheel,
|
||||
viewer: page.viewer,
|
||||
prompts: () => (prompts ? prompts.overrides() : {}),
|
||||
mounted: mounted.map((m) => m.id),
|
||||
};
|
||||
|
||||
@@ -304,9 +304,15 @@ define("ruler", "default", ({ marks = [], total = 1, label } = {}) => {
|
||||
define("viewer", "media", ({ src, kind = "image", empty = "Nothing yet", onError } = {}) => {
|
||||
const stage = el("div", { cls: "cx-viewer-stage" });
|
||||
let current = null;
|
||||
let currentKind = kind;
|
||||
let currentFile = null;
|
||||
let mediaEl = null;
|
||||
|
||||
function show(next, nextKind = kind) {
|
||||
function show(next, nextKind = kind, file = null) {
|
||||
current = next || null;
|
||||
currentKind = nextKind;
|
||||
currentFile = current ? file : null;
|
||||
mediaEl = null;
|
||||
stage.replaceChildren();
|
||||
if (!current) {
|
||||
stage.append(el("p", { cls: "cx-list-empty", text: empty }));
|
||||
@@ -315,10 +321,16 @@ define("viewer", "media", ({ src, kind = "image", empty = "Nothing yet", onError
|
||||
const media = nextKind === "video"
|
||||
? el("video", { cls: "cx-viewer-media", attrs: { src: current, controls: "", loop: "", playsinline: "" } })
|
||||
: el("img", { cls: "cx-viewer-media", attrs: { src: current, alt: "" } });
|
||||
mediaEl = media;
|
||||
// A failed load is reported rather than left as a broken icon: the file is
|
||||
// produced by a run, so it not arriving is a fault worth naming.
|
||||
media.addEventListener("error", () => {
|
||||
stage.replaceChildren(el("p", { cls: "cx-list-empty", text: "This result could not be loaded." }));
|
||||
mediaEl = null;
|
||||
// Nothing is actually showing any more -- `file` saying otherwise is
|
||||
// what let "Save to bin" silently bin a dead reference from this exact
|
||||
// state, the one place the panel itself says there is nothing here.
|
||||
currentFile = null;
|
||||
if (onError) onError(current);
|
||||
});
|
||||
stage.append(media);
|
||||
@@ -329,8 +341,26 @@ define("viewer", "media", ({ src, kind = "image", empty = "Nothing yet", onError
|
||||
return {
|
||||
node,
|
||||
get value() { return current; },
|
||||
get kind() { return currentKind; },
|
||||
// The file this came from -- {filename, subfolder, type} -- when the
|
||||
// caller had one to give. Null for anything shown by URL alone, which is
|
||||
// not enough to name a file back to the server with.
|
||||
get file() { return currentFile; },
|
||||
setValue: (next) => show(next, kind),
|
||||
setSource: (next, nextKind) => show(next, nextKind || kind),
|
||||
setSource: (next, nextKind, file) => show(next, nextKind || kind, file),
|
||||
/**
|
||||
* The video frame on screen right now, as a PNG blob -- null if nothing is
|
||||
* playing or there is no frame yet to draw (a video that has not loaded
|
||||
* its first frame has no dimensions to capture at).
|
||||
*/
|
||||
captureFrame() {
|
||||
if (currentKind !== "video" || !mediaEl || !mediaEl.videoWidth) return Promise.resolve(null);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = mediaEl.videoWidth;
|
||||
canvas.height = mediaEl.videoHeight;
|
||||
canvas.getContext("2d").drawImage(mediaEl, 0, 0);
|
||||
return new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
|
||||
},
|
||||
destroy: () => node.remove(),
|
||||
};
|
||||
});
|
||||
|
||||
+7
-2
@@ -132,8 +132,13 @@ export function createBin({ onOpen, view = recallView(), persist = true,
|
||||
* anything new arrives -- so it must be silent when there is nothing new. It
|
||||
* was not, once: redrawing on each message walked the selection back to the
|
||||
* newest result the moment the user clicked an older one.
|
||||
*
|
||||
* `open: false` is for a save action, not a run: something ADDED to the bin
|
||||
* a while after it was asked for (an upload's own round trip) must not then
|
||||
* yank the viewer back to it -- the user may have moved on to something else
|
||||
* in the meantime, and a save was never a request to look at what got saved.
|
||||
*/
|
||||
function absorb(files = []) {
|
||||
function absorb(files = [], { open: shouldOpen = true } = {}) {
|
||||
const added = [];
|
||||
for (const file of files || []) {
|
||||
const id = keyOf(file);
|
||||
@@ -151,7 +156,7 @@ export function createBin({ onOpen, view = recallView(), persist = true,
|
||||
// its own result is the whole point; an older selection is not preserved
|
||||
// through it, because nobody generates in order to keep looking at the last
|
||||
// one.
|
||||
open(added[added.length - 1].id);
|
||||
if (shouldOpen) open(added[added.length - 1].id);
|
||||
return added;
|
||||
}
|
||||
|
||||
|
||||
+74
-3
@@ -38,15 +38,81 @@ export function build(root, handlers = {}) {
|
||||
// Left: what a project is made of, which for now is what it has produced. The
|
||||
// bin owns the choice of what the viewer shows -- one place decides, so a
|
||||
// click on an older result and a run finishing cannot both be right at once.
|
||||
const bin = createBin({ onOpen: (item) => viewer.setSource(item.url, item.kind) });
|
||||
const bin = createBin({
|
||||
onOpen: (item) => viewer.setSource(item.url, item.kind, item.file),
|
||||
});
|
||||
const assets = composer.panel.zone({
|
||||
title: "Assets",
|
||||
actions: [bin.control],
|
||||
body: bin.host,
|
||||
});
|
||||
|
||||
// ComfyUI's own route, not FunPack's -- ordinary image upload already exists
|
||||
// and there is nothing about a captured frame that needs its own.
|
||||
async function uploadFrame(blob) {
|
||||
const form = new FormData();
|
||||
form.append("image", blob, `frame-${Date.now()}.png`);
|
||||
form.append("type", "input");
|
||||
const res = await fetch("/upload/image", { method: "POST", body: form });
|
||||
if (!res.ok) throw new Error(`the upload was refused (${res.status})`);
|
||||
const body = await res.json();
|
||||
return { filename: body.name, subfolder: body.subfolder || "", type: body.type || "input" };
|
||||
}
|
||||
|
||||
// `transport.say` is the Timeline's own line, overwritten by the next
|
||||
// progress message a run in flight sends -- often inside a second, which
|
||||
// silently erased a save's own confirmation or error whenever anything was
|
||||
// generating. This one is the Preview zone's, and nothing else writes to it.
|
||||
const previewStatus = composer.text.sm({ text: "" });
|
||||
let previewStatusTimer = null;
|
||||
function sayPreview(text, ms = 3000) {
|
||||
previewStatus.setText(text);
|
||||
if (previewStatusTimer) clearTimeout(previewStatusTimer);
|
||||
previewStatusTimer = setTimeout(() => previewStatus.setText(""), ms);
|
||||
// Not a reason to keep a process alive: under a test runner, a page built
|
||||
// and abandoned mid-test (a new one built for the next test, this one's
|
||||
// DOM never explicitly torn down) left a timer that fired minutes later,
|
||||
// reaching for a `document` the test harness had already removed.
|
||||
if (previewStatusTimer && typeof previewStatusTimer.unref === "function") previewStatusTimer.unref();
|
||||
}
|
||||
|
||||
// What's shown here came from somewhere already server-side (a run's own
|
||||
// result, or an already-open bin/temp item) OR is a video the viewer can
|
||||
// still draw a frame from -- either way, "keep this" should not mean a
|
||||
// download and a re-upload by hand.
|
||||
const saveToBin = composer.iconButton.sm({
|
||||
icon: "➕", label: "Save to bin",
|
||||
onClick: () => {
|
||||
if (!viewer.file) { sayPreview("Nothing here has a file to save."); return; }
|
||||
// Already showing, so nothing needs to move -- but a save is a save,
|
||||
// never a navigation, even when it happens to name what's on screen.
|
||||
const added = bin.absorb([viewer.file], { open: false });
|
||||
sayPreview(added.length ? "Saved to the bin." : "Already in the bin.");
|
||||
},
|
||||
});
|
||||
const saveFrame = composer.iconButton.sm({
|
||||
icon: "⛶", label: "Save this frame",
|
||||
onClick: async () => {
|
||||
const blob = await viewer.captureFrame();
|
||||
if (!blob) { sayPreview("Play a video first -- there is no frame to save."); return; }
|
||||
try {
|
||||
// Not `open: true`: the upload is a real round trip, and by the time
|
||||
// it lands the viewer may be showing something else entirely -- a
|
||||
// save must not yank it back to what it saved.
|
||||
const added = bin.absorb([await uploadFrame(blob)], { open: false });
|
||||
sayPreview(added.length ? "Frame saved to the bin." : "Already in the bin.");
|
||||
} catch (err) {
|
||||
sayPreview(`Could not save the frame: ${err.message}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
// v4's preview head carries nothing but the name of the zone: what is being
|
||||
// looked at is not something you act on.
|
||||
const preview = composer.panel.zone({ title: "Preview", body: viewer, flush: true });
|
||||
// looked at is not something you act on -- except now it is, the two
|
||||
// actions above, which only ever touch what is ALREADY on screen.
|
||||
const preview = composer.panel.zone({
|
||||
title: "Preview", body: viewer, flush: true,
|
||||
actions: [saveToBin, saveFrame], status: [previewStatus],
|
||||
});
|
||||
|
||||
// The timeline: what the project IS, and where a run is started from.
|
||||
//
|
||||
@@ -101,6 +167,11 @@ export function build(root, handlers = {}) {
|
||||
title: "Timeline",
|
||||
actions: [
|
||||
...transport.actions,
|
||||
// A ready-made widget, not a callback: the loop it runs needs the run,
|
||||
// the session and the project all at once, and none of those exist yet
|
||||
// at the point build() is called -- boot.js is the one place all three
|
||||
// do, so boot.js builds the button too.
|
||||
...(handlers.generateAll ? [handlers.generateAll] : []),
|
||||
regionToggle("Assets", () => Boolean(ws && ws.isOpen("left")),
|
||||
() => ws && ws.toggle("left"), { overlaid: true }),
|
||||
regionToggle("Properties", () => Boolean(ws && ws.isOpen("right")),
|
||||
|
||||
@@ -76,6 +76,11 @@ export function createMenubar({ workspace, onPipeline, onProject, projects = ()
|
||||
|
||||
const file = menu("File", () => [
|
||||
{ id: "new", label: "New project…" },
|
||||
{ separator: true },
|
||||
// Moving a project between machines -- a git checkout does not carry
|
||||
// ~/Documents/ComfyUI/user/default/funpack_projects with it.
|
||||
{ id: "save-file", label: "Save Project File…", hint: "⬇" },
|
||||
{ id: "load-file", label: "Load Project File…" },
|
||||
...(projects().length ? [{ separator: true }] : []),
|
||||
// A tick rather than leaving the current one out: which project is open is
|
||||
// a fact about the app, and a menu that hides it makes you press one to find out.
|
||||
|
||||
+88
-8
@@ -76,6 +76,12 @@ export function createProject({ onChange, onError, onOpen } = {}) {
|
||||
let timer = null;
|
||||
let saving = null; // the PUT in flight, so a queued one waits for it
|
||||
let dirty = false;
|
||||
// Bumped by every open/newProject/importProject, BEFORE their own await --
|
||||
// so a switch that starts and then loses a race (two imports, or an import
|
||||
// overlapping an open()) can tell, once its fetch finally answers, that a
|
||||
// NEWER switch already won and abandon its own result instead of clobbering
|
||||
// whatever the user is actually looking at now.
|
||||
let generation = 0;
|
||||
|
||||
const changed = () => { if (onChange) onChange(); };
|
||||
|
||||
@@ -94,6 +100,12 @@ export function createProject({ onChange, onError, onOpen } = {}) {
|
||||
if (!project || !from.length) return false;
|
||||
to.push(copy());
|
||||
const was = from.pop();
|
||||
// Same reason open/newProject/importProject/start bump it: an undo/redo
|
||||
// is also "a different project is open now" (the comment on opened() two
|
||||
// lines below already says so), and an in-flight switch that started
|
||||
// before this must not still be allowed to land on top of it once its
|
||||
// own fetch catches up.
|
||||
generation += 1;
|
||||
project = was.project;
|
||||
selected = was.selected;
|
||||
// The scene that was selected may be gone -- stepping FORWARD into a state
|
||||
@@ -113,24 +125,39 @@ export function createProject({ onChange, onError, onOpen } = {}) {
|
||||
const forget = () => { past = []; future = []; };
|
||||
|
||||
function scheduleSave() {
|
||||
// The object THIS edit actually touched, captured now -- not read back
|
||||
// later from `project`, which can point somewhere else entirely by the
|
||||
// time this timer fires (a project switch started and finished in
|
||||
// between). Without this, an edit made in the gap between a switch
|
||||
// starting and completing was silently lost: mutated on the right object,
|
||||
// then saved -- once the timer finally fired -- against whatever project
|
||||
// had since replaced it.
|
||||
//
|
||||
// ponytail: `dirty`/`timer`/`saving` stay single, shared flags rather than
|
||||
// one per target. A second edit landing on a DIFFERENT project before this
|
||||
// one's timer fires still cancels it (real, narrower race: an edit during
|
||||
// a switch's gap, then another edit to the new project, both inside one
|
||||
// debounce window). Upgrade to a per-target queue if that ever shows up
|
||||
// outside a deliberately adversarial test.
|
||||
const target = project;
|
||||
dirty = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(flush, SAVE_AFTER);
|
||||
timer = setTimeout(() => flush(target), SAVE_AFTER);
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
async function flush(target = project) {
|
||||
if (timer) { clearTimeout(timer); timer = null; }
|
||||
if (!project || !dirty) return;
|
||||
if (!target || !dirty) return;
|
||||
// One PUT at a time. Two overlapping writes of a whole project can land in
|
||||
// either order, and the loser is a version of the project the user has
|
||||
// already moved past.
|
||||
if (saving) { await saving.catch(() => {}); if (!dirty) return; }
|
||||
dirty = false;
|
||||
const body = { ...project };
|
||||
const body = { ...target };
|
||||
saving = put(body).then((saved) => {
|
||||
// Only the fields the server owns are taken back: replacing the whole
|
||||
// project would overwrite whatever was typed while the PUT was in flight.
|
||||
if (project && saved && project.id === saved.id) project.updated_at = saved.updated_at;
|
||||
if (target && saved && target.id === saved.id) target.updated_at = saved.updated_at;
|
||||
}).catch((err) => {
|
||||
dirty = true; // it is still unsaved, so say so
|
||||
if (onError) onError(err);
|
||||
@@ -150,9 +177,19 @@ export function createProject({ onChange, onError, onOpen } = {}) {
|
||||
get recent() { return recent; },
|
||||
|
||||
async start() {
|
||||
// Guarded like open/newProject/importProject, and for the same reason:
|
||||
// the page's own handlers (the File menu, the hidden file input) are
|
||||
// wired up by build() well before this is awaited, and both of start()'s
|
||||
// own network calls are real gaps a user's own click can finish inside
|
||||
// of. Losing this race used to mean start() caught up later and
|
||||
// silently reverted a switch the user had already made, with no error.
|
||||
const mine = ++generation;
|
||||
const found = await list();
|
||||
if (mine !== generation) return project;
|
||||
recent = found;
|
||||
project = found.length ? await read(found[0].id) : await create("Untitled");
|
||||
const opened_ = found.length ? await read(found[0].id) : await create("Untitled");
|
||||
if (mine !== generation) return project;
|
||||
project = opened_;
|
||||
if (!found.length) recent = [{ id: project.id, name: project.name }];
|
||||
selected = (project.scenes || [])[0]?.id ?? null;
|
||||
forget(); // a project just opened has no past
|
||||
@@ -161,8 +198,14 @@ export function createProject({ onChange, onError, onOpen } = {}) {
|
||||
},
|
||||
|
||||
async open(id) {
|
||||
const mine = ++generation;
|
||||
await flush();
|
||||
project = await read(id);
|
||||
const found = await read(id);
|
||||
// A newer switch already won this race (another open/new/import started
|
||||
// after this one and has already landed) -- applying this one now would
|
||||
// silently pull the user back to a project they already left.
|
||||
if (mine !== generation) return project;
|
||||
project = found;
|
||||
selected = (project.scenes || [])[0]?.id ?? null;
|
||||
forget(); // a project just opened has no past
|
||||
opened();
|
||||
@@ -170,8 +213,18 @@ export function createProject({ onChange, onError, onOpen } = {}) {
|
||||
},
|
||||
|
||||
async newProject(name) {
|
||||
const mine = ++generation;
|
||||
await flush();
|
||||
project = await create(name);
|
||||
const made = await create(name);
|
||||
if (mine !== generation) {
|
||||
// Superseded by a newer switch -- this one already created a real
|
||||
// project on the server that nothing will ever show. Best-effort:
|
||||
// a failed cleanup leaves an orphan, which is the pre-existing
|
||||
// (worse) behaviour, not a new one.
|
||||
remove(made.id).catch(() => {});
|
||||
return project;
|
||||
}
|
||||
project = made;
|
||||
recent = [{ id: project.id, name: project.name }, ...recent];
|
||||
selected = (project.scenes || [])[0]?.id ?? null;
|
||||
forget(); // a project just opened has no past
|
||||
@@ -179,6 +232,33 @@ export function createProject({ onChange, onError, onOpen } = {}) {
|
||||
return project;
|
||||
},
|
||||
|
||||
/** Where "Save Project File" downloads FROM, for the current project. */
|
||||
downloadUrl() { return project ? `${BASE}/${encodeURIComponent(project.id)}/download` : null; },
|
||||
|
||||
/**
|
||||
* A project file from disk, as its own new project -- moving a project
|
||||
* between machines, not restoring one in place. The file's own id is
|
||||
* never kept (the server strips it too, but a project already open in
|
||||
* THIS browser with that id must not be what a stale double-check relies
|
||||
* on): imported always means "another one", even from the very checkout
|
||||
* it came from.
|
||||
*/
|
||||
async importProject(data) {
|
||||
const mine = ++generation;
|
||||
await flush();
|
||||
const imported = await json("POST", `${BASE}/import`, data);
|
||||
if (mine !== generation) {
|
||||
remove(imported.id).catch(() => {}); // superseded -- see newProject()
|
||||
return project;
|
||||
}
|
||||
project = imported;
|
||||
recent = [{ id: project.id, name: project.name }, ...recent];
|
||||
selected = (project.scenes || [])[0]?.id ?? null;
|
||||
forget();
|
||||
opened();
|
||||
return project;
|
||||
},
|
||||
|
||||
select(id) {
|
||||
if (!sceneAt(id) || id === selected) return;
|
||||
selected = id;
|
||||
|
||||
@@ -8,6 +8,41 @@
|
||||
// live during a round trip, and a socket listening before anyone knew which run
|
||||
// was ours.
|
||||
|
||||
import { DONE, FAILED, CANCELLED } from "./run.js";
|
||||
|
||||
const TERMINAL = new Set([DONE, FAILED, CANCELLED]);
|
||||
|
||||
/**
|
||||
* Resolves with the phase a run ends at -- done, failed, or cancelled.
|
||||
*
|
||||
* Subscribe FIRST, before whatever starts the run: subscribing after risks
|
||||
* missing the very transition being waited for. subscribe() delivers the
|
||||
* CURRENT state immediately, which is not a new transition and is not what
|
||||
* this is waiting for -- if a page reload adopts a run that already finished,
|
||||
* that first delivery would resolve this before the caller's own "start"
|
||||
* step has even run. Only a state delivered AFTER this call counts.
|
||||
*
|
||||
* The returned promise carries its own `.cancel()`: subscribing before the
|
||||
* caller knows whether a run will actually start is the whole point (see
|
||||
* above), but a start attempt that gets refused before it ever reaches
|
||||
* run.start() -- an incomplete pipeline, a queue that says no -- never
|
||||
* transitions the run at all, so nothing would ever resolve this and the
|
||||
* subscription would sit on `run` forever. Call `.cancel()` when the attempt
|
||||
* this was waiting on turns out never to have started.
|
||||
*/
|
||||
export function waitForTerminal(run) {
|
||||
let unsubscribe = () => {};
|
||||
const promise = new Promise((resolve) => {
|
||||
let first = true;
|
||||
unsubscribe = run.subscribe((state) => {
|
||||
if (first) { first = false; return; }
|
||||
if (TERMINAL.has(state.phase)) { unsubscribe(); resolve(state.phase); }
|
||||
});
|
||||
});
|
||||
promise.cancel = () => unsubscribe();
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* onGenerate: ask what to queue, then queue it, once at a time.
|
||||
*
|
||||
|
||||
@@ -99,6 +99,30 @@ test("the newest of a batch is the one shown", () => {
|
||||
assert.deepEqual(b.items.map((i) => i.label), ["three.png", "two.png", "one.png"]);
|
||||
});
|
||||
|
||||
test("open: false adds a file to the bin without moving what the viewer shows", () => {
|
||||
// A save action's own round trip (an upload) can finish well after the user
|
||||
// has moved on to looking at something else -- absorb()'s normal auto-open
|
||||
// exists for a RUN's own result appearing, not for that. `open: false` is
|
||||
// what a save uses instead.
|
||||
const b = bin();
|
||||
b.absorb([file("already-open.png")]); // the normal path: this IS opened
|
||||
assert.equal(b.opened.length, 1);
|
||||
|
||||
b.absorb([file("saved-later.png")], { open: false });
|
||||
|
||||
assert.equal(b.items.length, 2, "the file was not added to the bin at all");
|
||||
assert.equal(b.opened.length, 1, "opened a second time despite open: false");
|
||||
});
|
||||
|
||||
test("open: false still dedupes -- a file already in the bin is not added twice", () => {
|
||||
const b = bin();
|
||||
b.absorb([file("a.png")]);
|
||||
const added = b.absorb([file("a.png")], { open: false });
|
||||
|
||||
assert.equal(added.length, 0);
|
||||
assert.equal(b.items.length, 1);
|
||||
});
|
||||
|
||||
test("a video is known as one, and never becomes a <video> in the bin", () => {
|
||||
// v4 put live <video> elements in the bin and Chrome's six-per-origin
|
||||
// connection pool wedged the whole API behind them: the app stopped answering
|
||||
|
||||
@@ -56,6 +56,126 @@ test("picking an older result in the bin changes what the viewer shows", () => {
|
||||
assert.match(p.viewer.node.querySelector("img").getAttribute("src"), /first\.png/);
|
||||
});
|
||||
|
||||
test("what a run produced can be saved to the bin from wherever it is shown", () => {
|
||||
// Opened from the temp browser, or anywhere else that hands the viewer a
|
||||
// real file identity -- "keep this" should not mean a download and a
|
||||
// re-upload by hand.
|
||||
const p = page();
|
||||
p.viewer.setSource("/view?filename=stray.png&subfolder=&type=temp", "image",
|
||||
{ filename: "stray.png", subfolder: "", type: "temp" });
|
||||
|
||||
const saveToBin = p.preview.node.querySelector('button[aria-label="Save to bin"]');
|
||||
assert.ok(saveToBin, "no Save to bin action in the Preview head");
|
||||
saveToBin.click();
|
||||
|
||||
assert.equal(p.bin.items.length, 1);
|
||||
assert.equal(p.bin.items[0].file.filename, "stray.png");
|
||||
});
|
||||
|
||||
test("saving something already in the bin says so instead of duplicating it", () => {
|
||||
const p = page();
|
||||
p.bin.absorb([file("a.png")]); // also becomes what the viewer shows
|
||||
|
||||
const saveToBin = p.preview.node.querySelector('button[aria-label="Save to bin"]');
|
||||
saveToBin.click();
|
||||
|
||||
assert.equal(p.bin.items.length, 1, "the same file was saved twice");
|
||||
});
|
||||
|
||||
test("saving with nothing shown does not throw, and says why", () => {
|
||||
const p = page();
|
||||
const saveToBin = p.preview.node.querySelector('button[aria-label="Save to bin"]');
|
||||
assert.doesNotThrow(() => saveToBin.click());
|
||||
assert.equal(p.bin.items.length, 0);
|
||||
});
|
||||
|
||||
test("saving a frame with no video playing says so rather than uploading nothing", async () => {
|
||||
// jsdom has no real video decoder, so a genuine capture can only be proven
|
||||
// in a real browser (see the Playwright spec) -- this is the guard that
|
||||
// keeps a silent no-op from reaching the upload at all.
|
||||
const p = page();
|
||||
const saveFrame = p.preview.node.querySelector('button[aria-label="Save this frame"]');
|
||||
assert.ok(saveFrame, "no Save frame action in the Preview head");
|
||||
|
||||
let asked = false;
|
||||
globalThis.fetch = async () => { asked = true; return { ok: true, json: async () => ({}) }; };
|
||||
saveFrame.click();
|
||||
await new Promise((r) => setTimeout(r, 0)); // let the click's own async handler settle
|
||||
|
||||
assert.equal(asked, false, "uploaded with no frame to upload");
|
||||
assert.equal(p.bin.items.length, 0);
|
||||
});
|
||||
|
||||
test("a frame that dedupes to something already in the bin says so, not that it saved", async () => {
|
||||
// saveToBin already checked bin.absorb()'s own dedup result; saveFrame did
|
||||
// not, so a second capture that happened to upload to the same identity
|
||||
// (two rapid clicks, a server that overwrites rather than renames on
|
||||
// collision) claimed success while adding nothing.
|
||||
const p = page();
|
||||
p.viewer.captureFrame = async () => new Blob(["x"], { type: "image/png" });
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true, json: async () => ({ name: "captured.png", subfolder: "", type: "input" }),
|
||||
});
|
||||
|
||||
const saveFrame = p.preview.node.querySelector('button[aria-label="Save this frame"]');
|
||||
saveFrame.click();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
assert.equal(p.bin.items.length, 1);
|
||||
assert.match(p.preview.node.querySelector(".cx-panel-status").textContent, /Frame saved/);
|
||||
|
||||
saveFrame.click(); // resolves to the exact same identity again
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
assert.equal(p.bin.items.length, 1, "the same capture was added twice");
|
||||
assert.match(p.preview.node.querySelector(".cx-panel-status").textContent, /Already in the bin/);
|
||||
});
|
||||
|
||||
test("a frame upload that resolves after the user moved on does not yank the viewer back", async () => {
|
||||
// The upload is a real round trip -- by the time it lands the user may
|
||||
// already be looking at something else. A save must add to the bin without
|
||||
// moving what is on screen, whenever it finishes.
|
||||
const p = page();
|
||||
p.bin.absorb([file("first.png")]); // the viewer shows this
|
||||
|
||||
// jsdom cannot decode video; stand in for a real captured frame (the real
|
||||
// capture itself is proven in the Playwright spec, against a real video).
|
||||
p.viewer.captureFrame = async () => new Blob(["x"], { type: "image/png" });
|
||||
|
||||
let resolveUpload;
|
||||
globalThis.fetch = () => new Promise((resolve) => { resolveUpload = resolve; });
|
||||
|
||||
p.preview.node.querySelector('button[aria-label="Save this frame"]').click();
|
||||
// Let captureFrame()'s own await, then the call into fetch(), actually
|
||||
// happen -- the upload is now in flight, waiting on resolveUpload.
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
assert.equal(typeof resolveUpload, "function", "the upload was never started");
|
||||
|
||||
// The user moves on before the upload resolves.
|
||||
p.bin.absorb([file("second.png")]);
|
||||
assert.match(p.viewer.node.querySelector("img").getAttribute("src"), /second\.png/);
|
||||
|
||||
resolveUpload({ ok: true, json: async () => ({ name: "captured.png", subfolder: "", type: "input" }) });
|
||||
await Promise.resolve(); await Promise.resolve(); await Promise.resolve();
|
||||
|
||||
assert.equal(p.bin.items.length, 3, "the captured frame was never saved");
|
||||
assert.match(p.viewer.node.querySelector("img").getAttribute("src"), /second\.png/,
|
||||
"a save that finished late pulled the viewer back to what it saved");
|
||||
});
|
||||
|
||||
test("a result that failed to load is not something Save to bin can still save", () => {
|
||||
// The viewer's own error state says "nothing is showing here" -- `file`
|
||||
// disagreeing with that let a dead reference be saved as if it were real.
|
||||
const p = page();
|
||||
p.bin.absorb([file("gone.png")]);
|
||||
p.viewer.node.querySelector("img").dispatchEvent(new window.Event("error"));
|
||||
assert.match(p.viewer.node.textContent, /could not be loaded/);
|
||||
assert.equal(p.viewer.file, null, "the viewer still claims a file is showing after it failed to load");
|
||||
|
||||
const saveToBin = p.preview.node.querySelector('button[aria-label="Save to bin"]');
|
||||
saveToBin.click();
|
||||
|
||||
assert.equal(p.bin.items.length, 1, "the failed file was saved again, over the guard");
|
||||
});
|
||||
|
||||
test("a control sits in the head of the zone it acts on", () => {
|
||||
// The arrangement that makes this an editor rather than a dashboard: the bin's
|
||||
// view control is in the Assets head, and Generate is in the head of the zone
|
||||
|
||||
@@ -153,6 +153,243 @@ test("a result for a project that is open again goes through the normal live pat
|
||||
|
||||
await p.setResultFor(id, sceneId, "/view?filename=b.png");
|
||||
assert.equal(p.scenes[0].result, "/view?filename=b.png");
|
||||
await p.flush(); // setResultFor's same-project branch schedules a save like
|
||||
// any other edit -- left pending, its real debounce timer
|
||||
// outlives this test and can fire mid a LATER one.
|
||||
});
|
||||
|
||||
test("downloadUrl names the current project, and nothing when there is none", async () => {
|
||||
const p = createProject({});
|
||||
assert.equal(p.downloadUrl(), null, "a download link before anything is open");
|
||||
|
||||
server(); // default project id abcdef012345
|
||||
await p.start();
|
||||
assert.equal(p.downloadUrl(), "/funpack/api/projects/abcdef012345/download");
|
||||
});
|
||||
|
||||
test("importing a project file opens whatever the server hands back, as its own project", async () => {
|
||||
// The server strips the id (a file imported twice, or on the machine it
|
||||
// came from, must not overwrite anything already here) -- this only has to
|
||||
// send what the file held and open what comes back.
|
||||
const posted = [];
|
||||
globalThis.fetch = async (path, opts = {}) => {
|
||||
if (String(path).endsWith("/projects/import")) {
|
||||
posted.push(JSON.parse(opts.body));
|
||||
return { ok: true, status: 200, json: async () => ({
|
||||
id: "newid000001", name: "Imported", scenes: [{ id: "s1", text: "", result: null }],
|
||||
video: {}, updated_at: 5,
|
||||
}) };
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({ projects: [] }) };
|
||||
};
|
||||
|
||||
const p = createProject({});
|
||||
const result = await p.importProject({ id: "someoldid0001", name: "Imported", scenes: [] });
|
||||
|
||||
assert.equal(p.project.id, "newid000001");
|
||||
assert.equal(p.project.name, "Imported");
|
||||
assert.equal(p.selectedId, "s1", "an imported project with scenes has nothing selected");
|
||||
assert.deepEqual(p.recent.map((r) => r.id), ["newid000001"]);
|
||||
assert.equal(posted[0].id, "someoldid0001", "the client's job is to send the file, not clean it");
|
||||
assert.equal(result.id, "newid000001");
|
||||
assert.equal(p.canUndo, false, "an import just opened has no past to step into");
|
||||
});
|
||||
|
||||
test("a project open when an import happens is not what flush() saves afterward", async () => {
|
||||
// flush() is called before the import switches `project` -- if it ran
|
||||
// AFTER, whatever debounced edit was pending on the OLD project would be
|
||||
// saved against the NEW one's id instead.
|
||||
const { sent } = server();
|
||||
const p = createProject({});
|
||||
await p.start();
|
||||
p.rename("About to leave"); // scheduled, not yet sent
|
||||
|
||||
globalThis.fetch = async (path, opts = {}) => {
|
||||
if (String(path).endsWith("/projects/import")) {
|
||||
return { ok: true, status: 200, json: async () => ({
|
||||
id: "newid000002", name: "Imported", scenes: [], video: {}, updated_at: 5 }) };
|
||||
}
|
||||
const body = opts.body ? JSON.parse(opts.body) : null;
|
||||
if (opts.method === "PUT") sent.push(body);
|
||||
return { ok: true, status: 200, json: async () => (body ? { ...body, updated_at: 2 } : {}) };
|
||||
};
|
||||
|
||||
await p.importProject({ name: "Imported", scenes: [] });
|
||||
|
||||
assert.equal(sent.length, 1, "the pending rename was never saved, or was saved against the wrong project");
|
||||
assert.equal(sent[0].id, "abcdef012345", "flushed against the OLD project, which is what flush() is for");
|
||||
assert.equal(p.project.id, "newid000002");
|
||||
});
|
||||
|
||||
test("an edit made WHILE an import is in flight is still saved, against the old project", async () => {
|
||||
// The gap this closes: importProject()'s OWN initial flush() finds nothing
|
||||
// dirty (the edit hasn't happened yet), then the edit lands DURING the
|
||||
// import's network round trip, on the project still open at that moment.
|
||||
// scheduleSave() has to remember which object that was -- the debounce
|
||||
// timer fires only after the import has already switched `project` out
|
||||
// from under it.
|
||||
const { sent } = server();
|
||||
const p = createProject({});
|
||||
await p.start(); // project abcdef012345
|
||||
|
||||
let resolveImport;
|
||||
globalThis.fetch = async (path, opts = {}) => {
|
||||
if (String(path).endsWith("/projects/import")) {
|
||||
return new Promise((resolve) => {
|
||||
resolveImport = () => resolve({ ok: true, status: 200, json: async () => (
|
||||
{ id: "newid000003", name: "Imported", scenes: [], video: {}, updated_at: 5 }) });
|
||||
});
|
||||
}
|
||||
const body = opts.body ? JSON.parse(opts.body) : null;
|
||||
if (opts.method === "PUT") sent.push(body);
|
||||
return { ok: true, status: 200, json: async () => (body ? { ...body, updated_at: 2 } : {}) };
|
||||
};
|
||||
|
||||
const importing = p.importProject({ name: "Imported", scenes: [] });
|
||||
await Promise.resolve(); await Promise.resolve(); await Promise.resolve();
|
||||
assert.equal(p.project.id, "abcdef012345", "the import switched before the edit -- this test proves nothing");
|
||||
|
||||
p.rename("Typed while importing"); // scheduled against the still-current OLD project
|
||||
|
||||
resolveImport();
|
||||
await importing;
|
||||
assert.equal(p.project.id, "newid000003", "the import did not complete");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 700)); // past the real debounce window
|
||||
|
||||
assert.equal(sent.length, 1, "the edit made during the import never reached the server");
|
||||
assert.equal(sent[0].id, "abcdef012345", "saved against the OLD project, not the one now open");
|
||||
assert.equal(sent[0].name, "Typed while importing");
|
||||
});
|
||||
|
||||
test("two imports racing land on whichever was clicked last, not whichever answered last", async () => {
|
||||
const p = createProject({});
|
||||
await p.start();
|
||||
|
||||
const pending = {};
|
||||
globalThis.fetch = async (path, opts = {}) => {
|
||||
if (String(path).endsWith("/projects/import")) {
|
||||
const body = JSON.parse(opts.body);
|
||||
return new Promise((resolve) => {
|
||||
pending[body.name] = () => resolve({ ok: true, status: 200, json: async () => (
|
||||
{ id: `id-${body.name}`, name: body.name, scenes: [], video: {}, updated_at: 5 }) });
|
||||
});
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({ projects: [] }) };
|
||||
};
|
||||
|
||||
const first = p.importProject({ name: "A", scenes: [] }); // clicked first
|
||||
await Promise.resolve();
|
||||
const second = p.importProject({ name: "B", scenes: [] }); // clicked second, while A is still in flight
|
||||
await Promise.resolve();
|
||||
|
||||
// A answers LAST, after B -- a real, ordinary network race, not a
|
||||
// contrived one.
|
||||
pending.B();
|
||||
await second;
|
||||
assert.equal(p.project.name, "B");
|
||||
|
||||
pending.A();
|
||||
await first;
|
||||
|
||||
assert.equal(p.project.name, "B", "the earlier, superseded import overwrote the one clicked last");
|
||||
assert.equal(p.project.id, "id-B");
|
||||
});
|
||||
|
||||
test("a superseded import deletes its own orphaned project instead of leaving it behind", async () => {
|
||||
const deleted = [];
|
||||
const pending = {};
|
||||
globalThis.fetch = async (path, opts = {}) => {
|
||||
if (String(path).endsWith("/projects/import")) {
|
||||
const body = JSON.parse(opts.body);
|
||||
return new Promise((resolve) => {
|
||||
pending[body.name] = () => resolve({ ok: true, status: 200, json: async () => (
|
||||
{ id: `id-${body.name}`, name: body.name, scenes: [], video: {}, updated_at: 5 }) });
|
||||
});
|
||||
}
|
||||
if (opts.method === "DELETE") { deleted.push(path); return { ok: true, status: 200, json: async () => ({}) }; }
|
||||
return { ok: true, status: 200, json: async () => ({ projects: [] }) };
|
||||
};
|
||||
|
||||
const p = createProject({});
|
||||
await p.start();
|
||||
const first = p.importProject({ name: "A", scenes: [] });
|
||||
await Promise.resolve();
|
||||
const second = p.importProject({ name: "B", scenes: [] });
|
||||
await Promise.resolve();
|
||||
|
||||
pending.B(); await second;
|
||||
pending.A(); await first;
|
||||
|
||||
assert.deepEqual(deleted, ["/funpack/api/projects/id-A"], "the superseded project was never cleaned up");
|
||||
});
|
||||
|
||||
test("start() losing a race to a switch the user already made does not revert it", async () => {
|
||||
// build() wires the File menu (and so onProject -> newProject/open/import)
|
||||
// before start() is ever awaited -- start()'s own network calls are real
|
||||
// gaps a user's click can finish inside of.
|
||||
let resolveList;
|
||||
globalThis.fetch = async (path, opts = {}) => {
|
||||
if (path === "/funpack/api/projects" && opts.method === "GET") {
|
||||
return new Promise((resolve) => { resolveList = () => resolve(
|
||||
{ ok: true, status: 200, json: async () => ({ projects: [] }) }); });
|
||||
}
|
||||
if (opts.method === "POST" && path.endsWith("/projects")) {
|
||||
const body = JSON.parse(opts.body);
|
||||
return { ok: true, status: 200, json: async () => (
|
||||
{ id: "usercreated0", name: body.name, scenes: [{ id: "s1" }], video: {}, updated_at: 2 }) };
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}) };
|
||||
};
|
||||
|
||||
const p = createProject({});
|
||||
const starting = p.start(); // in flight -- list() has not answered yet
|
||||
await Promise.resolve();
|
||||
|
||||
await p.newProject("MyNewThing"); // the user's own action completes first
|
||||
assert.equal(p.project.name, "MyNewThing");
|
||||
|
||||
resolveList(); // start()'s list() finally answers
|
||||
await starting;
|
||||
|
||||
assert.equal(p.project.name, "MyNewThing", "start() caught up and silently reverted the switch");
|
||||
});
|
||||
|
||||
test("an undo during an in-flight open supersedes it too, not just another switch", async () => {
|
||||
const store = {
|
||||
a: { id: "aaaaaaaaaaaa", name: "A", scenes: [{ id: "s1", text: "", result: null }], updated_at: 1 },
|
||||
b: { id: "bbbbbbbbbbbb", name: "B", scenes: [{ id: "s1", text: "", result: null }], updated_at: 1 },
|
||||
};
|
||||
let resolveOpenB;
|
||||
globalThis.fetch = async (path, opts = {}) => {
|
||||
if (path === "/funpack/api/projects" && opts.method === "GET") {
|
||||
return { ok: true, status: 200, json: async () => ({ projects: [{ id: store.a.id }] }) };
|
||||
}
|
||||
if (String(path).endsWith(store.b.id) && opts.method === "GET") {
|
||||
return new Promise((resolve) => { resolveOpenB = () => resolve(
|
||||
{ ok: true, status: 200, json: async () => store.b }); });
|
||||
}
|
||||
if (opts.method === "PUT") return { ok: true, status: 200, json: async () => JSON.parse(opts.body) };
|
||||
return { ok: true, status: 200, json: async () => store.a };
|
||||
};
|
||||
|
||||
const p = createProject({});
|
||||
await p.start(); // project A
|
||||
p.setText(p.selectedId, "edited"); // undo history now has the original
|
||||
|
||||
const opening = p.open(store.b.id); // in flight
|
||||
await new Promise((r) => setTimeout(r, 0)); // past open()'s own flush() first
|
||||
|
||||
p.undo(); // restores A's original text, DURING the open
|
||||
assert.equal(p.project.id, store.a.id);
|
||||
assert.equal(p.scenes[0].text, "");
|
||||
|
||||
resolveOpenB();
|
||||
await opening;
|
||||
|
||||
// The open lost the race it was already in when the undo happened -- it
|
||||
// must not land on top of the undo a moment later.
|
||||
assert.equal(p.project.id, store.a.id, "the in-flight open overwrote the undo");
|
||||
});
|
||||
|
||||
test("renaming a project moves the name in the File menu too", async () => {
|
||||
|
||||
@@ -9,11 +9,11 @@ import assert from "node:assert/strict";
|
||||
|
||||
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
|
||||
|
||||
let createGenerator, reattach, wire, createTransport;
|
||||
let createGenerator, reattach, wire, waitForTerminal, createTransport;
|
||||
test.before(async () => {
|
||||
setupDom();
|
||||
await import("../../composer/composer.js");
|
||||
({ createGenerator, reattach, wire } = await import("../session.js"));
|
||||
({ createGenerator, reattach, wire, waitForTerminal } = await import("../session.js"));
|
||||
({ createTransport } = await import("../transport.js"));
|
||||
});
|
||||
test.after(() => teardownDom());
|
||||
@@ -515,3 +515,52 @@ test("what is typed on the main window is sent with the run", async () => {
|
||||
|
||||
assert.deepEqual(asked[0].inputs, { positive: { text: "a cat on a roof" } });
|
||||
});
|
||||
|
||||
// --- waitForTerminal ---------------------------------------------------------
|
||||
|
||||
test("waitForTerminal resolves with the phase a run actually ends at", async () => {
|
||||
const run = fakeRun();
|
||||
const waiting = waitForTerminal(run);
|
||||
run.state = { ...run.state, phase: "running" };
|
||||
run.announce();
|
||||
run.state = { ...run.state, phase: "done" };
|
||||
run.announce();
|
||||
assert.equal(await waiting, "done");
|
||||
});
|
||||
|
||||
test("the snapshot delivered at subscribe time is not mistaken for a transition", async () => {
|
||||
// A page reload adopting a run that already finished delivers ITS finished
|
||||
// state the moment subscribe() is called -- if that counted, this would
|
||||
// resolve before the caller's own "start" step had even run.
|
||||
const run = fakeRun("done");
|
||||
let resolved = false;
|
||||
waitForTerminal(run).then(() => { resolved = true; });
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
assert.equal(resolved, false, "resolved from the snapshot, not a real transition");
|
||||
|
||||
run.announce(); // the same "done" state, delivered again -- now a real one
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
assert.equal(resolved, true);
|
||||
});
|
||||
|
||||
test("cancel() unsubscribes a waiter whose run never actually started", () => {
|
||||
// A generation refused before it ever reaches run.start() (an incomplete
|
||||
// pipeline, a queue that says no) never transitions the run -- nothing
|
||||
// would ever resolve this promise, and the subscription would sit on the
|
||||
// run forever without a way out.
|
||||
const run = fakeRun();
|
||||
assert.equal(run.subscribers.size, 0);
|
||||
const waiting = waitForTerminal(run);
|
||||
assert.equal(run.subscribers.size, 1);
|
||||
waiting.cancel();
|
||||
assert.equal(run.subscribers.size, 0, "cancel() did not unsubscribe");
|
||||
});
|
||||
|
||||
test("cancel() after the promise already resolved is harmless", async () => {
|
||||
const run = fakeRun();
|
||||
const waiting = waitForTerminal(run);
|
||||
run.state = { ...run.state, phase: "done" };
|
||||
run.announce();
|
||||
assert.equal(await waiting, "done");
|
||||
assert.doesNotThrow(() => waiting.cancel());
|
||||
});
|
||||
|
||||
@@ -102,9 +102,13 @@ test("a run is started from the head of the zone it fills, and reported there",
|
||||
await expect(page.locator(".cx-action-bar")).toHaveCount(0);
|
||||
|
||||
const head = page.locator(".cx-panel", { hasText: "Timeline" }).first().locator(".cx-panel-head");
|
||||
await expect(head.getByRole("button", { name: "Generate" })).toBeVisible();
|
||||
// Exact: "Generate All" also matches an inexact "Generate" search now.
|
||||
await expect(head.getByRole("button", { name: "Generate", exact: true })).toBeVisible();
|
||||
|
||||
const [bar] = await boxes(page, ".cx-panel-status");
|
||||
// Scoped to the zone with the Generate button: the Preview zone has its own
|
||||
// .cx-panel-status now too (a save confirmation), and an unscoped selector
|
||||
// stopped naming a unique element the day that arrived.
|
||||
const [bar] = await boxes(page, ".cx-panel:has(.cx-btn-primary) .cx-panel-status");
|
||||
const [generate] = await boxes(page, ".cx-panel-head .cx-btn-primary");
|
||||
expect(bar.x, "what the run is doing is not at the far end of its own head")
|
||||
.toBeGreaterThan(generate.r);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// "Generate All": every scene in order, waiting for each before starting the
|
||||
// next. The dev server has no /prompt, so every attempt is refused before it
|
||||
// ever reaches the queue -- which is exactly the path this proves does not
|
||||
// hang or leak: the loop must still walk every scene and finish cleanly.
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
async function app(page) {
|
||||
await page.goto("/funpack/");
|
||||
await page.waitForFunction(() => window.FunPack !== undefined);
|
||||
}
|
||||
|
||||
test("Generate All walks every scene and lands the selection on one of them", async ({ page }) => {
|
||||
await app(page);
|
||||
const ids = await page.evaluate(() => {
|
||||
const { project } = window.FunPack;
|
||||
while (project.scenes.length < 3) project.addScene();
|
||||
return project.scenes.map((s) => s.id);
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Generate All" }).click();
|
||||
|
||||
// The batch actually finishes -- the button comes back, rather than
|
||||
// staying disabled on a hung loop (every scene here is refused before it
|
||||
// reaches the queue, since the dev server has no /prompt).
|
||||
await expect.poll(() => page.getByRole("button", { name: "Generate All" }).isEnabled(),
|
||||
{ timeout: 15000 }).toBe(true);
|
||||
|
||||
const finalSelected = await page.evaluate(() => window.FunPack.project.selectedId);
|
||||
expect(ids).toContain(finalSelected);
|
||||
});
|
||||
|
||||
test("Generate All says how many scenes did not generate, and re-enables both buttons", async ({ page }) => {
|
||||
await app(page);
|
||||
await page.evaluate(() => {
|
||||
const { project } = window.FunPack;
|
||||
while (project.scenes.length < 2) project.addScene();
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Generate All" }).click();
|
||||
|
||||
await expect.poll(() =>
|
||||
page.locator(".cx-panel-status", { hasText: /did not generate/i }).count(), { timeout: 15000 },
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
await expect(page.getByRole("button", { name: "Generate All" })).toBeEnabled();
|
||||
await expect(page.getByRole("button", { name: "Generate", exact: true })).toBeEnabled();
|
||||
});
|
||||
|
||||
test("a second click while a batch is already running does not start an overlapping one", async ({ page }) => {
|
||||
await app(page);
|
||||
await page.evaluate(() => {
|
||||
const { project } = window.FunPack;
|
||||
while (project.scenes.length < 3) project.addScene();
|
||||
});
|
||||
|
||||
const btn = page.getByRole("button", { name: "Generate All" });
|
||||
await btn.click();
|
||||
await expect(btn).toBeDisabled();
|
||||
await btn.click({ force: true }); // a disabled button ignores this; proves it, not a real click path
|
||||
|
||||
await expect.poll(() => btn.isEnabled(), { timeout: 15000 }).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// Saving a video frame, where it needs a real <video> element and a real
|
||||
// canvas -- jsdom has neither. The dev server has no ComfyUI behind it (no
|
||||
// /upload/image, same as no /view elsewhere), so the upload itself is
|
||||
// intercepted; what is under test is that a real browser can actually draw
|
||||
// the frame on screen into a real PNG blob and post it.
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// A two-frame, 32x32 red mp4 -- ffmpeg's own smallest sane output, small
|
||||
// enough to inline. Real enough for Chromium to decode and report a real
|
||||
// videoWidth/videoHeight, which is the one thing that matters here.
|
||||
const TINY_MP4 = "data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAMybW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAA+gAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAlx0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAABAAAAAAAAA+gAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAACAAAAAgAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAPoAAAAAAABAAAAAAHUbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAABAAAAAQABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABf21pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAT9zdGJsAAAAv3N0c2QAAAAAAAAAAQAAAK9hdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAACAAIABIAAAASAAAAAAAAAABFUxhdmM2Mi4yOC4xMDEgbGlieDI2NAAAAAAAAAAAAAAAGP//AAAANWF2Y0MBZAAK/+EAGGdkAAqs2UlsBEAAAAMAQAAAAwEDxIllgAEABmjr48siwP34+AAAAAAQcGFzcAAAAAEAAAABAAAAFGJ0cnQAAAAAAAAW6AAAAAAAAAAYc3R0cwAAAAAAAAABAAAAAgAAIAAAAAAUc3RzcwAAAAAAAAABAAAAAQAAABxzdHNjAAAAAAAAAAEAAAABAAAAAgAAAAEAAAAcc3RzegAAAAAAAAAAAAAAAgAAAtAAAAANAAAAFHN0Y28AAAAAAAAAAQAAA2IAAABidWR0YQAAAFptZXRhAAAAAAAAACFoZGxyAAAAAAAAAABtZGlyYXBwbAAAAAAAAAAAAAAAAC1pbHN0AAAAJal0b28AAAAdZGF0YQAAAAEAAAAATGF2ZjYyLjEyLjEwMQAAAAhmcmVlAAAC5W1kYXQAAAKtBgX//6ncRem95tlIt5Ys2CDZI+7veDI2NCAtIGNvcmUgMTY1IHIzMjIyIGIzNTYwNWEgLSBILjI2NC9NUEVHLTQgQVZDIGNvZGVjIC0gQ29weWxlZnQgMjAwMy0yMDI1IC0gaHR0cDovL3d3dy52aWRlb2xhbi5vcmcveDI2NC5odG1sIC0gb3B0aW9uczogY2FiYWM9MSByZWY9MyBkZWJsb2NrPTE6MDowIGFuYWx5c2U9MHgzOjB4MTEzIG1lPWhleCBzdWJtZT03IHBzeT0xIHBzeV9yZD0xLjAwOjAuMDAgbWl4ZWRfcmVmPTEgbWVfcmFuZ2U9MTYgY2hyb21hX21lPTEgdHJlbGxpcz0xIDh4OGRjdD0xIGNxbT0wIGRlYWR6b25lPTIxLDExIGZhc3RfcHNraXA9MSBjaHJvbWFfcXBfb2Zmc2V0PS0yIHRocmVhZHM9MSBsb29rYWhlYWRfdGhyZWFkcz0xIHNsaWNlZF90aHJlYWRzPTAgbnI9MCBkZWNpbWF0ZT0xIGludGVybGFjZWQ9MCBibHVyYXlfY29tcGF0PTAgY29uc3RyYWluZWRfaW50cmE9MCBiZnJhbWVzPTMgYl9weXJhbWlkPTIgYl9hZGFwdD0xIGJfYmlhcz0wIGRpcmVjdD0xIHdlaWdodGI9MSBvcGVuX2dvcD0wIHdlaWdodHA9MiBrZXlpbnQ9MjUwIGtleWludF9taW49MiBzY2VuZWN1dD00MCBpbnRyYV9yZWZyZXNoPTAgcmNfbG9va2FoZWFkPTQwIHJjPWNyZiBtYnRyZWU9MSBjcmY9MjMuMCBxY29tcD0wLjYwIHFwbWluPTAgcXBtYXg9NjkgcXBzdGVwPTQgaXBfcmF0aW89MS40MCBhcT0xOjEuMDAAgAAAABtliIQAFP/+7Np+BTY91+fFNDe1lLW3K4+Aln0AAAAJQZohbEEv/rXA";
|
||||
|
||||
async function app(page) {
|
||||
await page.goto("/funpack/");
|
||||
await page.waitForFunction(() => window.FunPack !== undefined);
|
||||
}
|
||||
|
||||
test("a real frame from a real video is captured and posted as an upload", async ({ page }) => {
|
||||
let posted = null;
|
||||
await page.route("**/upload/image", (route) => {
|
||||
posted = route.request().postDataBuffer();
|
||||
return route.fulfill({ json: { name: "captured.png", subfolder: "", type: "input" } });
|
||||
});
|
||||
|
||||
await app(page);
|
||||
await page.evaluate((src) => {
|
||||
window.FunPack.viewer.setSource(src, "video", { filename: "clip.mp4", subfolder: "", type: "output" });
|
||||
}, TINY_MP4);
|
||||
|
||||
// A real decode, not a stub: nothing here is faked once the <video> exists.
|
||||
await expect.poll(() => page.evaluate(() =>
|
||||
document.querySelector(".cx-viewer-media")?.videoWidth || 0)).toBeGreaterThan(0);
|
||||
|
||||
await page.getByRole("button", { name: "Save this frame" }).click();
|
||||
|
||||
await expect.poll(() => posted !== null).toBe(true);
|
||||
expect(posted.length, "no image data reached the upload").toBeGreaterThan(200);
|
||||
|
||||
await expect.poll(() => page.evaluate(() => window.FunPack.bin.items.length)).toBe(1);
|
||||
await expect.poll(() => page.evaluate(() => window.FunPack.bin.items[0].file.filename))
|
||||
.toBe("captured.png");
|
||||
});
|
||||
|
||||
test("saving a frame from a still image does nothing -- there is no frame to draw", async ({ page }) => {
|
||||
// The dev server has no /view, so this 404s and the viewer shows its own
|
||||
// "could not be loaded" state rather than an <img> -- irrelevant here: what
|
||||
// matters is that NOTHING playable is on screen, image or otherwise.
|
||||
await app(page);
|
||||
await page.evaluate(() => {
|
||||
window.FunPack.bin.absorb([{ filename: "a.png", subfolder: "", type: "output" }]);
|
||||
});
|
||||
|
||||
let posted = false;
|
||||
await page.route("**/upload/image", (route) => { posted = true; return route.fulfill({ json: {} }); });
|
||||
|
||||
await page.getByRole("button", { name: "Save this frame" }).click();
|
||||
await page.waitForTimeout(200); // long enough for a wrongly-fired upload to have started
|
||||
|
||||
expect(posted, "uploaded something with no video on screen").toBe(false);
|
||||
expect(await page.evaluate(() => window.FunPack.bin.items.length)).toBe(1); // still just the image
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// Moving a project between machines: Save Project File downloads the real
|
||||
// server route, Load Project File reads a real file back in. jsdom covers the
|
||||
// store's own downloadUrl()/importProject() logic against a fake fetch; what
|
||||
// only a browser can prove is that the menu items actually trigger a real
|
||||
// download and a real file picker, against the real project routes.
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
async function app(page) {
|
||||
await page.goto("/funpack/");
|
||||
await page.waitForFunction(() => window.FunPack !== undefined);
|
||||
}
|
||||
|
||||
test("Save Project File downloads the current project as real JSON", async ({ page }) => {
|
||||
await app(page);
|
||||
const name = `Downloadable ${Date.now()}`;
|
||||
await page.evaluate((n) => window.FunPack.project.newProject(n), name);
|
||||
|
||||
await page.getByRole("button", { name: "File" }).click();
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page.getByRole("menuitem", { name: "Save Project File…" }).click(),
|
||||
]);
|
||||
|
||||
expect(download.suggestedFilename()).toMatch(/\.funpack_project\.json$/);
|
||||
const body = JSON.parse(readFileSync(await download.path(), "utf-8"));
|
||||
|
||||
expect(body.name).toBe(name);
|
||||
expect(Array.isArray(body.scenes)).toBe(true);
|
||||
});
|
||||
|
||||
test("Load Project File opens the file as a new project, not the one it came from", async ({ page }) => {
|
||||
await app(page);
|
||||
const original = await page.evaluate(async (n) => {
|
||||
await window.FunPack.project.newProject(n);
|
||||
window.FunPack.project.setVideo("width", 777);
|
||||
await window.FunPack.project.flush();
|
||||
return window.FunPack.project.project.id;
|
||||
}, `Original ${Date.now()}`);
|
||||
|
||||
const fileContent = await page.evaluate(async () => {
|
||||
const r = await fetch(window.FunPack.project.downloadUrl());
|
||||
return r.text();
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "File" }).click();
|
||||
const [chooser] = await Promise.all([
|
||||
page.waitForEvent("filechooser"),
|
||||
page.getByRole("menuitem", { name: "Load Project File…" }).click(),
|
||||
]);
|
||||
await chooser.setFiles({
|
||||
name: "project.funpack_project.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(fileContent),
|
||||
});
|
||||
|
||||
await expect.poll(() => page.evaluate(() => window.FunPack.project.project.video.width)).toBe(777);
|
||||
const imported = await page.evaluate(() => window.FunPack.project.project.id);
|
||||
expect(imported).not.toBe(original);
|
||||
|
||||
// The original is untouched by the import -- a shared id would have
|
||||
// overwritten it instead of landing as a project of its own.
|
||||
const stillThere = await page.evaluate((id) => fetch(`/funpack/api/projects/${id}`).then((r) => r.status), original);
|
||||
expect(stillThere).toBe(200);
|
||||
});
|
||||
|
||||
test("a file that is not a project is refused, not silently opened", async ({ page }) => {
|
||||
await app(page);
|
||||
const before = await page.evaluate(() => window.FunPack.project.project.id);
|
||||
|
||||
await page.getByRole("button", { name: "File" }).click();
|
||||
const [chooser] = await Promise.all([
|
||||
page.waitForEvent("filechooser"),
|
||||
page.getByRole("menuitem", { name: "Load Project File…" }).click(),
|
||||
]);
|
||||
await chooser.setFiles({
|
||||
name: "not-a-project.json", mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify({ hello: "world" })),
|
||||
});
|
||||
|
||||
await expect(page.locator(".cx-panel-status", { hasText: /could not load/i })).toBeVisible();
|
||||
expect(await page.evaluate(() => window.FunPack.project.project.id)).toBe(before);
|
||||
});
|
||||
@@ -110,11 +110,12 @@ test("a project can be made and switched to from the File menu", async ({ page }
|
||||
const made = await page.evaluate(() => window.FunPack.project.project.id);
|
||||
|
||||
// And away again. By position, not by name: every other project in the store
|
||||
// may well be called Untitled.
|
||||
// may well be called Untitled. Three fixed items ahead of the project list --
|
||||
// New project, Save Project File, Load Project File -- then the newest first.
|
||||
await page.getByRole("button", { name: "File" }).click();
|
||||
const items = page.getByRole("menuitem");
|
||||
await expect(items.nth(1)).toHaveText(new RegExp(name)); // the one just made, ticked
|
||||
await items.nth(2).click();
|
||||
await expect(items.nth(3)).toHaveText(new RegExp(name)); // the one just made, ticked
|
||||
await items.nth(4).click();
|
||||
|
||||
await expect.poll(() => page.evaluate(() => window.FunPack.project.project.id)).not.toBe(made);
|
||||
});
|
||||
|
||||
@@ -118,8 +118,14 @@ test("the transport reports a refusal beside Generate", async ({ page }) => {
|
||||
// The dev server has no ComfyUI behind it, so the pipeline genuinely cannot
|
||||
// run -- and that is the case worth seeing: the reason appears where the run
|
||||
// is started, not in a console nobody has open.
|
||||
await page.locator('.cx-panel-head button:has-text("Generate")').click();
|
||||
const bar = page.locator(".cx-panel-status").first();
|
||||
// The real Generate button specifically: "Generate All" also matches a
|
||||
// plain text search for "Generate", and .cx-btn-primary is what actually
|
||||
// distinguishes the one this test means.
|
||||
await page.locator(".cx-panel-head button.cx-btn-primary").click();
|
||||
// Scoped to the Timeline zone specifically: the Preview zone has its own
|
||||
// .cx-panel-status now too (a save confirmation), and .first() stopped
|
||||
// being safe to assume once there was more than one on the page.
|
||||
const bar = page.locator(".cx-zone:has(button.cx-btn-primary) .cx-panel-status");
|
||||
await expect(bar).not.toHaveText(/^Ready/);
|
||||
// Whatever the reason is, it names the slot it is about. This used to read
|
||||
// "there is no node called X installed", because the dev server had no node
|
||||
|
||||
@@ -584,6 +584,41 @@ def register(routes, prefix=None):
|
||||
return web.json_response({"problems": ["no such project"]}, status=404)
|
||||
return web.json_response({"deleted": True})
|
||||
|
||||
@routes.post(P + "/api/projects/import")
|
||||
async def _projects_import(req):
|
||||
body = await _body(req)
|
||||
if body is None or "scenes" not in body:
|
||||
return web.json_response(
|
||||
{"problems": ["that does not look like a project file"]}, status=400)
|
||||
# Never the id in the file: a project imported twice, or imported on
|
||||
# the machine it came from, must land as its OWN project -- an id that
|
||||
# happens to match one already here would overwrite it on save.
|
||||
body = {**body, "id": None}
|
||||
# from_dict() itself is lenient by design -- everything it cannot make
|
||||
# sense of degrades to a safe default rather than raising, matching
|
||||
# every other project load. Only save()'s own disk I/O can actually
|
||||
# fail here, and that is not the file's fault: calling it "invalid"
|
||||
# sends someone to inspect their JSON when the real problem is on this
|
||||
# end (disk full, permissions).
|
||||
try:
|
||||
saved = projects.save(projects.Project.from_dict(body))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.broke("project import", exc, doing="saving the imported project")
|
||||
return web.json_response(
|
||||
{"problems": [f"could not save the imported project: {exc}"]}, status=500)
|
||||
return web.json_response(saved.to_dict())
|
||||
|
||||
@routes.get(P + "/api/projects/{pid}/download")
|
||||
async def _projects_download(req):
|
||||
found = projects.get(req.match_info["pid"])
|
||||
if found is None:
|
||||
return web.json_response({"problems": ["no such project"]}, status=404)
|
||||
safe = "".join(c if c.isalnum() or c in "._- " else "_" for c in found.name).strip()[:64]
|
||||
return web.json_response(
|
||||
found.to_dict(),
|
||||
headers={"Content-Disposition":
|
||||
f'attachment; filename="{safe or found.id}.funpack_project.json"'})
|
||||
|
||||
@routes.get(P + "/api/log/funpack")
|
||||
async def _log(req):
|
||||
level = req.query.get("level") or None
|
||||
|
||||
@@ -231,6 +231,97 @@ def test_a_path_shaped_id_is_refused_not_served(server):
|
||||
assert status in (404, 400), pid
|
||||
|
||||
|
||||
# --- moving a project between machines ----------------------------------------
|
||||
|
||||
def test_a_downloaded_project_imports_back_as_a_new_one(server):
|
||||
"""The round trip the feature exists for: save it, move the file
|
||||
somewhere else, load it there."""
|
||||
_, made = _request(server, "POST", "/funpack/api/projects", {"name": "Portable"})
|
||||
made["scenes"].append({"text": "a second scene"})
|
||||
_request(server, "PUT", f"/funpack/api/projects/{made['id']}", made)
|
||||
|
||||
status, downloaded = _request(server, "GET", f"/funpack/api/projects/{made['id']}/download")
|
||||
assert status == 200
|
||||
assert downloaded["name"] == "Portable"
|
||||
assert len(downloaded["scenes"]) == 2
|
||||
|
||||
status, imported = _request(server, "POST", "/funpack/api/projects/import", downloaded)
|
||||
assert status == 200
|
||||
assert imported["name"] == "Portable"
|
||||
assert [s["text"] for s in imported["scenes"]] == ["", "a second scene"]
|
||||
|
||||
status, listed = _request(server, "GET", "/funpack/api/projects")
|
||||
assert sorted(p["id"] for p in listed["projects"]) == sorted([made["id"], imported["id"]]), \
|
||||
"the import did not land as a project of its own"
|
||||
|
||||
|
||||
def test_importing_a_project_never_reuses_its_old_id(server):
|
||||
"""Importing the SAME file twice -- or importing it back on the machine it
|
||||
came from -- must not silently overwrite whatever already has that id."""
|
||||
_, made = _request(server, "POST", "/funpack/api/projects", {"name": "Original"})
|
||||
status, downloaded = _request(server, "GET", f"/funpack/api/projects/{made['id']}/download")
|
||||
assert downloaded["id"] == made["id"]
|
||||
|
||||
status, imported = _request(server, "POST", "/funpack/api/projects/import", downloaded)
|
||||
assert status == 200
|
||||
assert imported["id"] != made["id"]
|
||||
|
||||
# The original is untouched -- an id collision would have overwritten it.
|
||||
_, original = _request(server, "GET", f"/funpack/api/projects/{made['id']}")
|
||||
assert original["name"] == "Original"
|
||||
|
||||
|
||||
def test_an_id_offered_in_the_body_does_not_win_either(server):
|
||||
"""Not just the downloaded shape -- ANY id in an imported body is a
|
||||
liability, including a made-up one that could collide with something
|
||||
imported later."""
|
||||
status, imported = _request(server, "POST", "/funpack/api/projects/import",
|
||||
{"id": "aaaaaaaaaaaa", "name": "Sneaky", "scenes": [{"text": ""}]})
|
||||
assert status == 200
|
||||
assert imported["id"] != "aaaaaaaaaaaa"
|
||||
|
||||
|
||||
def test_something_that_does_not_look_like_a_project_is_refused(server):
|
||||
for body in ({"name": "not a project"}, {}):
|
||||
status, resp = _request(server, "POST", "/funpack/api/projects/import", body)
|
||||
assert status == 400, body
|
||||
assert "problems" in resp
|
||||
|
||||
|
||||
def test_a_scenes_field_of_the_wrong_shape_degrades_to_empty_rather_than_erroring(server):
|
||||
"""`scenes` present but not a list is the same "never trust a field" rule
|
||||
every other project load already follows (Project.from_dict), not a
|
||||
special case this route invents -- an import is not the one place a
|
||||
malformed file gets to crash instead of degrading."""
|
||||
status, imported = _request(server, "POST", "/funpack/api/projects/import",
|
||||
{"name": "Odd", "scenes": "not a list"})
|
||||
assert status == 200, imported
|
||||
assert imported["scenes"] == []
|
||||
|
||||
|
||||
def test_downloading_a_project_that_does_not_exist_is_a_404(server):
|
||||
status, _ = _request(server, "GET", "/funpack/api/projects/0123456789ab/download")
|
||||
assert status == 404
|
||||
|
||||
|
||||
def test_a_disk_failure_during_import_is_not_blamed_on_the_file(server, monkeypatch):
|
||||
"""from_dict() is lenient by design -- it cannot actually raise on a
|
||||
well-formed body. The only thing that can fail here is save()'s own I/O,
|
||||
which is not the file's fault and should not be reported as though it
|
||||
were."""
|
||||
from core import projects
|
||||
|
||||
def broken_save(_project):
|
||||
raise OSError("No space left on device")
|
||||
monkeypatch.setattr(projects, "save", broken_save)
|
||||
|
||||
status, resp = _request(server, "POST", "/funpack/api/projects/import",
|
||||
{"name": "Fine", "scenes": [{"text": ""}]})
|
||||
assert status == 500, resp
|
||||
assert "No space left on device" in resp["problems"][0]
|
||||
assert "invalid" not in resp["problems"][0].lower()
|
||||
|
||||
|
||||
# --- what the project is generated at ----------------------------------------
|
||||
|
||||
def test_video_settings_survive_a_round_trip(store):
|
||||
|
||||
Reference in New Issue
Block a user