Files
Will Miao a19c1056dd fix(save-image): resolve %date% at save time and never overwrite counter-less files
The frontend serializeValue hook resolved %date:...% at queue time, baking
the queue timestamp into the serialized prompt. Prompts queued within the
same second (or re-run from history) shared one filename and silently
overwrote each other when add_counter_to_filename was off. %date% now
passes through to the backend, which formats it with datetime.now() at
save time; the date: check also runs before the '.' split so formats with
dots (e.g. %date:yyyy.M.d%) are not mistaken for %Node.Widget% patterns.

As a safety net, counter-less saves now fall back to an incrementing
suffix when the target file already exists instead of overwriting it.
2026-10-22 06:55:58 +08:00

92 lines
2.7 KiB
JavaScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const { APP_MODULE, UTILS_MODULE, TARGET_MODULE } = vi.hoisted(() => ({
APP_MODULE: new URL("../../../scripts/app.js", import.meta.url).pathname,
UTILS_MODULE: new URL("../../../web/comfyui/utils.js", import.meta.url).pathname,
TARGET_MODULE: new URL(
"../../../web/comfyui/save_image_extra_output.js",
import.meta.url
).pathname,
}));
vi.mock(APP_MODULE, () => ({
app: {
graph: {},
registerExtension: vi.fn(),
},
}));
vi.mock(UTILS_MODULE, () => ({
chainCallback: (proto, name, fn) => {
const existing = proto[name];
proto[name] = function (...args) {
existing?.apply(this, args);
fn.apply(this, args);
};
},
getAllGraphNodes: vi.fn(() => []),
getWidgetByName: (node, name) => node.widgets?.find((w) => w.name === name),
}));
describe("SaveImageExtraOutput filename_prefix serialization", () => {
let app;
let utils;
let extension;
beforeEach(async () => {
vi.resetModules();
await import(TARGET_MODULE);
({ app } = await import(APP_MODULE));
utils = await import(UTILS_MODULE);
extension = app.registerExtension.mock.calls[0][0];
});
async function createSaveNode(prefixValue) {
const nodeType = { prototype: {} };
await extension.beforeRegisterNodeDef(nodeType, {
name: "Save Image (LoraManager)",
});
const node = {
widgets: [{ name: "filename_prefix", value: prefixValue }],
};
nodeType.prototype.onNodeCreated.call(node);
return node.widgets[0];
}
it("passes %date:...% through unchanged so the backend resolves it at save time", async () => {
const widget = await createSaveNode("MOSS/%date:yy-MM-dd_hh-mm-ss%");
expect(widget.serializeValue()).toBe("MOSS/%date:yy-MM-dd_hh-mm-ss%");
});
it("passes date formats containing dots through without treating them as node placeholders", async () => {
const widget = await createSaveNode("MOSS/%date:yyyy.M.d%");
expect(widget.serializeValue()).toBe("MOSS/%date:yyyy.M.d%");
});
it("still resolves %NodeTitle.WidgetName% placeholders from the graph", async () => {
utils.getAllGraphNodes.mockReturnValue([
{
node: {
title: "KSampler",
properties: {},
widgets: [{ name: "seed", value: 12345 }],
},
},
]);
const widget = await createSaveNode("gen_%KSampler.seed%_%date:yyyy%");
expect(widget.serializeValue()).toBe("gen_12345_%date:yyyy%");
});
it("leaves backend-only patterns like %seed% untouched", async () => {
const widget = await createSaveNode("out_%seed%");
vi.spyOn(console, "warn").mockImplementation(() => {});
expect(widget.serializeValue()).toBe("out_%seed%");
});
});