Files
awesome-copilot/extensions/draw/server.test.mjs
T

557 lines
27 KiB
JavaScript

// Tests for the loopback server: who may talk to it, which files it serves, and its JSON API.
// Run `node --test` in the extension folder.
import assert from "node:assert/strict";
import http from "node:http";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { mergeOps, normalizeElement } from "./lib/model.mjs";
import { createDrawServer } from "./server.mjs";
import { Settings } from "./settings.mjs";
import { DrawingStore } from "./store.mjs";
const PANEL = "panel";
// Elements as the canvas sends them: complete, in the form the drawing stores.
const rect = (id, x = 0) => normalizeElement({ id, type: "rect", x, y: 0, w: 100, h: 60 });
// A server on a temp folder. When the test ends, it stops and the folder is removed.
async function setup(t) {
const dir = await mkdtemp(path.join(os.tmpdir(), "draw-server-test-"));
const store = new DrawingStore(path.join(dir, "drawings"));
await store.ready;
const settings = new Settings(path.join(dir, "settings", "settings.json"));
const ctx = { dir, store, settings, session: null };
ctx.server = createDrawServer({ store, settings, getSession: () => ctx.session });
await ctx.server.start();
ctx.doc = ctx.server.ensureDrawing(PANEL);
t.after(async () => {
ctx.server.close();
await store.flush().catch(() => {});
await rm(dir, { recursive: true, force: true, maxRetries: 3 });
});
return ctx;
}
// One request on its own connection, so no socket outlives the test.
function request(port, { method = "GET", url = "/", headers = {}, body } = {}) {
return new Promise((resolve, reject) => {
const req = http.request({ host: "127.0.0.1", port, method, path: url, headers, agent: false }, (res) => {
const chunks = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => {
const text = Buffer.concat(chunks).toString("utf8");
let json = null;
try {
json = JSON.parse(text);
} catch {
// Not JSON, for example the page itself.
}
resolve({ status: res.statusCode, headers: res.headers, text, json });
});
});
req.setTimeout(5000, () => req.destroy(new Error(`${method} ${url} timed out`)));
req.on("error", reject);
req.end(body);
});
}
const apiPath = (server, route, { instance = PANEL, token = server.token } = {}) =>
`${route}?${new URLSearchParams({ i: instance, t: token })}`;
const get = (server, url, headers) => request(server.port, { url, headers });
// The canvas sends its JSON as text/plain, so the tests do too.
const post = (server, route, body) =>
request(server.port, {
method: "POST",
url: apiPath(server, route),
headers: { "Content-Type": "text/plain;charset=utf-8" },
body: typeof body === "string" ? body : JSON.stringify(body),
});
// Opens a panel's live event stream. next(type) resolves with the next event of that type, and
// close() ends the stream the way a page that goes away does.
async function listen(t, server, clientId = "listener") {
const queue = [];
let wake = null;
const req = http.get({ host: "127.0.0.1", port: server.port, path: `${apiPath(server, "/api/events")}&c=${clientId}`, agent: false });
t.after(() => req.destroy());
const res = await new Promise((resolve, reject) => {
req.on("response", resolve);
req.on("error", reject);
});
assert.equal(res.statusCode, 200);
assert.match(res.headers["content-type"], /^text\/event-stream/);
let buffer = "";
res.setEncoding("utf8");
res.on("error", () => {});
res.on("data", (chunk) => {
buffer += chunk;
let end;
while ((end = buffer.indexOf("\n\n")) >= 0) {
const block = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
const type = /^event: (.*)$/m.exec(block)?.[1];
if (type) queue.push({ type, data: JSON.parse(/^data: (.*)$/m.exec(block)[1]) });
}
wake?.();
});
return {
close: () => req.destroy(),
async next(type, timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
for (;;) {
const i = queue.findIndex((e) => e.type === type);
if (i >= 0) return queue.splice(i, 1)[0].data;
const left = deadline - Date.now();
if (left <= 0) throw new Error(`No "${type}" event arrived.`);
await new Promise((resolve) => {
const timer = setTimeout(resolve, left);
wake = () => {
clearTimeout(timer);
resolve();
};
});
}
},
};
}
test("requests for any other host are refused", async (t) => {
const { server } = await setup(t);
const hosts = ["evil.example", `evil.example:${server.port}`, `127.0.0.1:${server.port + 1}`, `localhost.evil.example:${server.port}`];
for (const host of hosts) {
assert.equal((await get(server, "/", { Host: host })).status, 403, host);
assert.equal((await get(server, apiPath(server, "/api/state"), { Host: host })).status, 403, host);
}
assert.equal((await get(server, "/", { Host: `localhost:${server.port}` })).status, 200);
assert.equal((await get(server, "/")).status, 200);
});
test("the API needs the server's token and a panel id", async (t) => {
const { server } = await setup(t);
const noToken = await get(server, `/api/state?i=${PANEL}`);
assert.equal(noToken.status, 401);
assert.equal(noToken.json.error, "Missing or wrong token.");
assert.equal((await get(server, apiPath(server, "/api/state", { token: "x".repeat(server.token.length) }))).status, 401);
assert.equal((await get(server, apiPath(server, "/api/state", { token: server.token.slice(1) }))).status, 401);
assert.equal((await get(server, `/api/events?i=${PANEL}`)).status, 401);
assert.equal((await request(server.port, { method: "POST", url: `/api/ops?i=${PANEL}`, body: "{}" })).status, 401);
assert.equal((await get(server, `/api/state?t=${server.token}`)).status, 400);
assert.equal((await get(server, apiPath(server, "/api/state"))).status, 200);
});
test("the page is sent with a content security policy and the saved theme", async (t) => {
const { server, settings } = await setup(t);
await settings.update({ theme: "dark" });
const page = await get(server, `/?i=${PANEL}&t=${server.token}`);
assert.equal(page.status, 200);
assert.match(page.headers["content-type"], /^text\/html/);
assert.equal(page.headers["x-content-type-options"], "nosniff");
const csp = page.headers["content-security-policy"];
for (const rule of ["default-src 'self'", "script-src 'self'", "connect-src 'self'", "base-uri 'none'", "form-action 'none'"]) {
assert.ok(csp.includes(rule), rule);
}
assert.ok(page.text.includes('data-theme-mode="dark"'));
});
test("only the canvas's own files are served", async (t) => {
const { server } = await setup(t);
for (const url of ["/app.js", "/styles.css", "/lib/model.mjs"]) {
assert.equal((await get(server, url)).status, 200, url);
}
const hidden = ["/server.mjs", "/store.mjs", "/lib/../server.mjs", "/%2e%2e/server.mjs", "/lib/..%2fserver.mjs", "/public/app.js", "/lib/model.test.mjs", "/.state.json"];
for (const url of hidden) {
assert.equal((await get(server, url)).status, 404, url);
}
const head = await request(server.port, { method: "HEAD", url: "/" });
assert.equal(head.status, 200);
assert.equal(head.text, "");
assert.equal((await request(server.port, { method: "POST", url: "/" })).status, 405);
});
test("state returns the panel's drawing, the drawing list and the settings", async (t) => {
const { server, store, doc } = await setup(t);
const { status, json } = await get(server, apiPath(server, "/api/state"));
assert.equal(status, 200);
assert.equal(json.instanceId, PANEL);
assert.equal(json.drawing.id, doc.id);
assert.equal(json.drawing.saveError, null);
assert.deepEqual(json.drawings.map((d) => d.id), [doc.id]);
assert.equal(json.folder, store.dir);
assert.deepEqual(json.settings, { theme: "app" });
});
test("ops change the drawing, and a client that is behind gets the whole drawing back", async (t) => {
const { server, store, doc } = await setup(t);
const first = await post(server, "/api/ops", { drawingId: doc.id, baseRev: 0, ops: { upserts: [rect("a")] } });
assert.equal(first.status, 200);
assert.deepEqual(first.json, { rev: 1 });
const behind = await post(server, "/api/ops", { drawingId: doc.id, baseRev: 0, ops: { upserts: [rect("b", 200)] } });
assert.equal(behind.json.rev, 2);
assert.deepEqual(behind.json.drawing.elements.map((e) => e.id), ["a", "b"]);
assert.equal(store.get(doc.id).elements.length, 2);
assert.equal((await post(server, "/api/ops", { drawingId: "missing", ops: {} })).status, 404);
});
test("ops that would go past 5,000 elements are refused and change nothing", async (t) => {
const { server, store, doc } = await setup(t);
const upserts = Array.from({ length: 5001 }, (_, i) => rect(`r${i}`, i * 10));
const res = await post(server, "/api/ops", { drawingId: doc.id, ops: { upserts } });
assert.equal(res.status, 400);
assert.match(res.json.error, /up to 5,000 elements/);
assert.equal(store.get(doc.id).rev, 0);
assert.equal(store.get(doc.id).elements.length, 0);
});
test("a panel's selection can hold as many ids as a drawing has elements", async (t) => {
const { server, doc } = await setup(t);
const ids = Array.from({ length: 5010 }, (_, i) => `r${i}`);
const res = await post(server, "/api/selection", { drawingId: doc.id, ids: [42, ...ids] });
assert.equal(res.status, 200);
assert.deepEqual(server.selection(PANEL, doc.id), ids.slice(0, 5000));
assert.deepEqual(server.selection(PANEL, "another-drawing"), []);
});
test("showing the drawing already on screen keeps its selection, and showing another drops it", async (t) => {
const { server, store, doc } = await setup(t);
await post(server, "/api/selection", { drawingId: doc.id, ids: ["a"] });
server.showDrawing(PANEL, store.get(doc.id));
assert.deepEqual(server.selection(PANEL, doc.id), ["a"]);
server.showDrawing(PANEL, store.create("Other"));
server.showDrawing(PANEL, store.get(doc.id));
assert.deepEqual(server.selection(PANEL, doc.id), []);
});
test("a drawing the agent opens counts as shown once every page of the panel says so", async (t) => {
const { server, store, doc } = await setup(t);
const other = store.create("Other");
// With no page open, the next page to load shows the panel's drawing.
assert.equal(await server.openDrawing(PANEL, other), "shown");
assert.equal(server.ensureDrawing(PANEL).id, other.id);
const first = await listen(t, server, "page1");
const second = await listen(t, server, "page2");
const answer = (clientId, switchId) => post(server, "/api/switched", { clientId, switchId });
const soon = (promise) => Promise.race([promise, new Promise((resolve) => setTimeout(() => resolve("waiting"), 100))]);
const opened = async (drawing, timeoutMs) => {
const result = server.openDrawing(PANEL, drawing, timeoutMs);
const ev = await first.next("switch");
assert.equal(ev.drawing.id, drawing.id);
assert.equal((await second.next("switch")).switchId, ev.switchId);
return { result, switchId: ev.switchId };
};
let { result, switchId } = await opened(doc);
await answer("page1", switchId);
assert.equal(await soon(result), "waiting");
await answer("page2", switchId);
assert.equal(await result, "shown");
// A page that cannot take it moves the panel back, and says why it does.
({ result, switchId } = await opened(other));
assert.equal(server.ensureDrawing(PANEL).id, other.id);
await post(server, "/api/drawings", { clientId: "page1", action: "open", id: doc.id, switchId });
assert.equal(await result, "refused");
assert.equal(server.ensureDrawing(PANEL).id, doc.id);
// A page that goes away cannot answer, so it is not waited for.
({ result, switchId } = await opened(other));
await answer("page1", switchId);
second.close();
assert.equal(await result, "shown");
});
test("a drawing the agent opens is unconfirmed when the page does not answer in time", async (t) => {
const { server, store } = await setup(t);
const page = await listen(t, server, "page1");
const result = server.openDrawing(PANEL, store.create("Other"), 200);
const { switchId } = await page.next("switch");
assert.equal(await result, "unconfirmed");
// A late answer changes nothing.
assert.deepEqual((await post(server, "/api/switched", { clientId: "page1", switchId })).json, { ok: true });
});
test("a page's older change that arrives after a newer one is dropped", async (t) => {
const { server, store, doc } = await setup(t);
const send = (clientId, seq, ops) => post(server, "/api/ops", { clientId, drawingId: doc.id, baseRev: 0, seq, ops });
const x = () => store.get(doc.id).elements[0].x;
// The page closed while change 1 was still on its way, and its last change, 2, got there first.
assert.equal((await send("page", 2, { upserts: [rect("a", 200)] })).status, 200);
const late = await send("page", 1, { upserts: [rect("a", 100)] });
assert.equal(late.status, 200);
assert.equal(late.json.ignored, true);
assert.equal(late.json.drawing.elements[0].x, 200);
assert.equal(x(), 200);
assert.equal(store.get(doc.id).rev, 1);
// Each page counts on its own.
assert.equal((await send("other-page", 1, { upserts: [rect("a", 300)] })).json.ignored, undefined);
assert.equal(x(), 300);
// A change that is refused does not count, so it cannot hide an older one.
const tooMany = Array.from({ length: 5001 }, (_, i) => rect(`r${i}`));
assert.equal((await send("page", 4, { upserts: tooMany })).status, 400);
assert.equal((await send("page", 3, { upserts: [rect("a", 400)] })).json.ignored, undefined);
assert.equal(x(), 400);
});
test("a page gets the saved drawing back when saving tidied up what it sent", async (t) => {
const { server, doc } = await setup(t);
let rev = 0;
const send = async (ops) => {
const res = await post(server, "/api/ops", { drawingId: doc.id, baseRev: rev, ops });
assert.equal(res.status, 200);
rev = res.json.rev;
return res.json;
};
const byId = (res, id) => res.drawing.elements.find((e) => e.id === id);
// Saved just as sent, whatever order the keys are in, so nothing comes back.
assert.deepEqual(await send({ upserts: [rect("a"), rect("b", 200)] }), { rev: 1 });
const moved = Object.fromEntries(Object.entries({ ...rect("a"), x: 40 }).reverse());
assert.deepEqual(await send({ upserts: [moved] }), { rev: 2 });
const arrow = normalizeElement({ id: "ab", type: "arrow", from: "a", to: "b" });
assert.deepEqual(await send({ upserts: [arrow] }), { rev: 3 });
const wide = await send({ upserts: [{ ...rect("a"), w: 6000 }] });
assert.equal(wide.cleaned, true);
assert.equal(byId(wide, "a").w, 5000);
const pen = await send({ upserts: [{ id: "p", type: "pen", points: [[0, 0], [12.34, 5.67]], color: "gray", width: 2.5 }] });
assert.equal(pen.cleaned, true);
assert.deepEqual(byId(pen, "p").points, [[0, 0], [12.3, 5.7]]);
// Deleting a shape but not its arrow: the arrow goes too.
const orphan = await send({ deletes: ["b"] });
assert.equal(orphan.cleaned, true);
assert.deepEqual(orphan.drawing.elements.map((e) => e.id), ["a", "p"]);
// A page that is behind gets the whole drawing anyway, as someone else's change.
const behind = await post(server, "/api/ops", { drawingId: doc.id, baseRev: 0, ops: { upserts: [{ ...rect("c"), w: 6000 }] } });
assert.equal(byId(behind.json, "c").w, 5000);
assert.equal(behind.json.cleaned, undefined);
});
test("bad requests get a clear error", async (t) => {
const { server } = await setup(t);
for (const body of ["not json", "[1, 2]", "null"]) {
const res = await post(server, "/api/ops", body);
assert.equal(res.status, 400, body);
assert.equal(res.json.error, "Request body must be a JSON object.");
}
assert.equal((await post(server, "/api/unknown", {})).status, 404);
assert.equal((await get(server, apiPath(server, "/api/ops"))).status, 404);
});
test("settings only take known themes, and a failed write keeps the old theme", async (t) => {
const { server, settings } = await setup(t);
assert.equal((await post(server, "/api/settings", { theme: "neon" })).status, 400);
const saved = await post(server, "/api/settings", { theme: "light" });
assert.equal(saved.status, 200);
assert.deepEqual(saved.json.settings, { theme: "light" });
assert.equal(JSON.parse(await readFile(settings.file, "utf8")).theme, "light");
// A file where the settings folder should be makes the next write fail.
await rm(path.dirname(settings.file), { recursive: true });
await writeFile(path.dirname(settings.file), "");
assert.equal((await post(server, "/api/settings", { theme: "dark" })).status, 500);
assert.deepEqual(await settings.read(), { theme: "light" });
});
test("drawings can be made, renamed, copied and deleted", async (t) => {
const { server, store, doc } = await setup(t);
store.replace(doc.id, [rect("a")], "test");
const drawings = (body) => post(server, "/api/drawings", body);
const made = await drawings({ action: "new", name: "Plan" });
assert.equal(made.status, 200);
assert.equal(made.json.drawing.name, "Plan");
assert.equal(store.drawingForInstance(PANEL).id, made.json.drawing.id);
assert.equal((await drawings({ action: "rename", id: doc.id, name: " " })).status, 400);
assert.equal((await drawings({ action: "rename", id: doc.id, name: "Architecture" })).json.drawing.name, "Architecture");
const copy = await drawings({ action: "duplicate", id: doc.id });
assert.equal(copy.json.drawing.name, "Architecture copy");
assert.deepEqual(store.get(copy.json.drawing.id).elements, store.get(doc.id).elements);
const deleted = await drawings({ action: "delete", id: doc.id });
assert.equal(deleted.status, 200);
assert.equal(store.get(doc.id), null);
assert.ok(!deleted.json.drawings.some((d) => d.id === doc.id));
assert.equal((await drawings({ action: "open", id: doc.id })).status, 404);
assert.equal((await drawings({ action: "delete", id: doc.id })).status, 404);
assert.equal((await drawings({ action: "explode" })).status, 400);
});
test("exports are written to the drawings folder", async (t) => {
const { server, store, doc } = await setup(t);
const svg = '<svg xmlns="http://www.w3.org/2000/svg"/>';
const res = await post(server, "/api/export", { drawingId: doc.id, format: "svg", data: svg });
assert.equal(res.status, 200);
assert.equal(path.dirname(res.json.path), store.exportsDir);
assert.equal(await readFile(res.json.path, "utf8"), svg);
assert.equal((await post(server, "/api/export", { drawingId: doc.id, format: "png", data: "" })).status, 400);
assert.equal((await post(server, "/api/export", { drawingId: "missing", format: "svg", data: svg })).status, 404);
});
test("an export the agent asks for is only taken from the drawing it named", async (t) => {
const { server, doc } = await setup(t);
const svg = '<svg xmlns="http://www.w3.org/2000/svg"/>';
assert.equal(server.requestExport(PANEL, doc.id, "svg"), null);
const events = await listen(t, server);
const answer = (requestId, drawingId) => post(server, "/api/export", { requestId, drawingId, format: "svg", data: svg });
// The panel moved to another drawing before it drew this one.
const switched = assert.rejects(server.requestExport(PANEL, doc.id, "svg"), /switched to another drawing/);
const first = await events.next("export");
assert.equal(first.drawingId, doc.id);
assert.equal(first.format, "svg");
assert.deepEqual((await answer(first.requestId, "another-drawing")).json, { ok: true });
await switched;
const drawn = server.requestExport(PANEL, doc.id, "svg");
const second = await events.next("export");
await answer(second.requestId, doc.id);
assert.deepEqual(await drawn, { format: "svg", data: svg });
// An answer to a request that is already done is ignored.
assert.deepEqual((await answer(second.requestId, doc.id)).json, { ok: false });
});
test("reveal refuses paths outside the drawings folder", async (t) => {
const { server, store, dir } = await setup(t);
for (const target of [dir, store.dir, path.join(store.dir, "..", "settings"), os.homedir(), ""]) {
assert.equal((await post(server, "/api/reveal", { path: target })).status, 400, target);
}
});
test("ask needs a question and a connected session, then sends the drawing to Copilot", async (t) => {
const ctx = await setup(t);
const { server, doc } = ctx;
assert.equal((await post(server, "/api/ask", { drawingId: doc.id, text: " " })).status, 400);
assert.equal((await post(server, "/api/ask", { drawingId: doc.id, text: "Explain this" })).status, 503);
const sent = [];
ctx.session = { send: async (message) => sent.push(message) };
const res = await post(server, "/api/ask", { drawingId: doc.id, text: "Explain this", png: "iVBORw0KGgo=" });
assert.equal(res.status, 200);
assert.equal(sent.length, 1);
assert.equal(sent[0].displayPrompt, "Explain this");
assert.ok(sent[0].prompt.startsWith("Explain this"));
assert.ok(sent[0].prompt.includes(`instanceId "${PANEL}"`));
assert.deepEqual(sent[0].attachments.map((a) => a.mimeType), ["image/png"]);
ctx.session = {
send: async () => {
throw new Error("offline");
},
};
const failed = await post(server, "/api/ask", { drawingId: doc.id, text: "Explain this" });
assert.equal(failed.status, 502);
assert.match(failed.json.error, /offline/);
});
test("a question is only sent while its panel still shows the drawing it is about", async (t) => {
const ctx = await setup(t);
const { server, store, doc } = ctx;
const sent = [];
ctx.session = { send: async (message) => sent.push(message) };
const other = store.create("Other");
server.showDrawing(PANEL, other);
const stale = await post(server, "/api/ask", { drawingId: doc.id, text: "Explain this" });
assert.equal(stale.status, 409);
assert.match(stale.json.error, /was not sent/);
assert.equal(sent.length, 0);
assert.equal((await post(server, "/api/ask", { drawingId: other.id, text: "Explain this" })).status, 200);
assert.ok(sent[0].prompt.includes(`instanceId "${PANEL}" and drawingId "${other.id}"`));
});
test("ask sends only part of a big drawing's outline, and says how to read the rest", async (t) => {
const ctx = await setup(t);
const { server, store, doc } = ctx;
// The most a drawing can hold: 5,000 shapes with 4,000 character labels.
store.replace(doc.id, Array.from({ length: 5000 }, (_, i) => ({ ...rect(`r${i}`, i * 10), text: "x".repeat(4000) })), "test");
const sent = [];
ctx.session = { send: async (message) => sent.push(message) };
assert.equal((await post(server, "/api/ask", { drawingId: doc.id, text: "Explain this" })).status, 200);
const prompt = sent[0].prompt;
assert.ok(prompt.length < 20000, `the prompt has ${prompt.length} characters`);
assert.match(prompt, /Drawing ".*": 5000 shapes/);
assert.match(prompt, /The outline stops after \d+ of 5000 elements\. Call get_drawing with start \d+ to read the rest\./);
});
test("live updates reach the other panels, not the one that made the change", async (t) => {
const { server, doc } = await setup(t);
const events = await listen(t, server, "me");
await post(server, "/api/ops", { clientId: "me", drawingId: doc.id, ops: { upserts: [rect("a")] } });
await post(server, "/api/ops", { clientId: "someone-else", drawingId: doc.id, ops: { upserts: [rect("b", 200)] } });
const update = await events.next("ops");
assert.equal(update.rev, 2);
assert.equal(update.origin, "someone-else");
});
test("a change goes out as the ops that made it, even on the biggest drawing", async (t) => {
const { server, store, doc } = await setup(t);
// The most a drawing can hold: 5,000 shapes with 4,000 character labels, about 20 MB.
store.replace(doc.id, Array.from({ length: 5000 }, (_, i) => ({ ...rect(`r${i}`, i * 10), text: "x".repeat(4000) })), "test");
const events = await listen(t, server);
const check = async (change, want, maxSize = 10000) => {
const before = store.get(doc.id).elements;
await change();
const update = await events.next("ops");
const size = JSON.stringify(update).length;
assert.ok(size < maxSize, `the update has ${size} characters`);
assert.equal(update.drawingId, doc.id);
assert.equal(update.baseRev, update.rev - 1);
assert.equal(update.rev, store.get(doc.id).rev);
assert.deepEqual(update.ops, want);
// The ops turn the drawing as it was into the drawing as it is.
const after = update.ops ? mergeOps(before, update.ops) : before;
assert.deepEqual(after, store.get(doc.id).elements);
};
// From a page. The shape it sent past the size limit goes out as it was saved.
const wide = { ...rect("new", 50), w: 99999 };
await check(() => post(server, "/api/ops", { clientId: "page", drawingId: doc.id, ops: { upserts: [wide], deletes: ["r0"] } }), {
upserts: [{ ...wide, w: 5000 }],
deletes: ["r0"],
});
// From the agent: a new label, and the last shape moved to the back, which sends the order
// (5,000 ids, still far less than the drawing).
await check(
() => store.mutate(doc.id, (els) => [els.at(-1), ...els.slice(0, -1).map((e) => (e.id === "r7" ? { ...e, text: "seven" } : e))]),
{ upserts: [{ ...store.get(doc.id).elements.find((e) => e.id === "r7"), text: "seven" }], deletes: [], order: ["new", ...store.get(doc.id).elements.slice(0, -1).map((e) => e.id)] },
100000,
);
// A rename changes no element.
await check(() => store.rename(doc.id, "Renamed"), null);
});
test("a drawing that cannot be saved is reported to its panel until a save works", async (t) => {
const { server, store, doc } = await setup(t);
await store.flush();
const events = await listen(t, server);
// A folder where the drawing's file should be makes every write fail.
const file = store.filePath(doc.id);
await rm(file, { force: true });
await mkdir(file);
await writeFile(path.join(file, "keep"), "");
store.replace(doc.id, [rect("a")], "test");
await assert.rejects(store.flush(), { code: "save_failed" });
const failed = await events.next("save");
assert.equal(failed.drawingId, doc.id);
assert.ok(failed.error);
assert.equal((await get(server, apiPath(server, "/api/state"))).json.drawing.saveError, failed.error);
await rm(file, { recursive: true, force: true });
await store.flush();
assert.deepEqual(await events.next("save"), { drawingId: doc.id, error: null });
assert.equal((await get(server, apiPath(server, "/api/state"))).json.drawing.saveError, null);
assert.deepEqual(JSON.parse(await readFile(file, "utf8")).elements.map((e) => e.id), ["a"]);
});