mirror of
https://github.com/penpot/penpot.git
synced 2026-11-07 02:20:36 +00:00
The backend rejects a save after the call that made the changes has returned, and the workspace then undoes them. A plugin, or the MCP agent, went on working with shapes that were no longer there and never learned why. The rollback event now names the files that lost commits and the pages and page shapes their changes touched, and plugins get it as the new `contentrollback` event (`ContentRollback` type) through a subscription to the store stream, so no rollback is dropped by debouncing. The MCP plugin keeps the rollbacks it is told about and sends them with its next task response, failed or not. The server collects them per tool call and puts a warning first in that tool's response, naming what was affected; the instructions tell the agent to read the pages again and redo what is missing.
62 lines
1.9 KiB
TypeScript
62 lines
1.9 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import { RollbackNotices } from "./RollbackNotices";
|
|
|
|
const rollback = (id: string, shapes: string[] = [`shape-${id}`]) => ({
|
|
fileIds: ["file-1"],
|
|
pageIds: [`page-${id}`],
|
|
shapeIds: shapes,
|
|
});
|
|
|
|
test("collects only the rollbacks recorded while its function runs", async () => {
|
|
const notices = new RollbackNotices();
|
|
notices.record([rollback("outside")]);
|
|
|
|
const [value, rollbacks] = await notices.collect(async () => {
|
|
notices.record([rollback("a")]);
|
|
await Promise.resolve();
|
|
notices.record([rollback("b")]);
|
|
return 42;
|
|
});
|
|
|
|
assert.equal(value, 42);
|
|
assert.deepEqual(rollbacks, [rollback("a"), rollback("b")]);
|
|
});
|
|
|
|
test("keeps the rollbacks of overlapping executions apart", async () => {
|
|
const notices = new RollbackNotices();
|
|
let release!: () => void;
|
|
const gate = new Promise<void>((resolve) => (release = resolve));
|
|
|
|
const first = notices.collect(async () => {
|
|
await gate;
|
|
notices.record([rollback("a")]);
|
|
});
|
|
const second = notices.collect(async () => {
|
|
notices.record([rollback("b")]);
|
|
release();
|
|
});
|
|
|
|
const [[, a], [, b]] = await Promise.all([first, second]);
|
|
assert.deepEqual(a, [rollback("a")]);
|
|
assert.deepEqual(b, [rollback("b")]);
|
|
});
|
|
|
|
test("names each affected file, page and shape once", () => {
|
|
const text = RollbackNotices.describe([rollback("a", ["s1", "s2"]), rollback("b", ["s2"])]);
|
|
|
|
assert.match(text, /\(2 times\)/);
|
|
assert.match(text, /Files: file-1\n/);
|
|
assert.match(text, /Pages: page-a, page-b\n/);
|
|
assert.match(text, /Shapes: s1, s2$/);
|
|
});
|
|
|
|
test("lists at most 50 shapes", () => {
|
|
const shapes = Array.from({ length: 53 }, (_, i) => `s${i}`);
|
|
|
|
const text = RollbackNotices.describe([rollback("a", shapes)]);
|
|
|
|
assert.match(text, /s49 \(and 3 more\)$/);
|
|
assert.doesNotMatch(text, /s50/);
|
|
});
|