mirror of
https://github.com/github/awesome-copilot.git
synced 2026-10-13 15:24:25 +00:00
557 lines
27 KiB
JavaScript
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"]);
|
|
});
|