mirror of
https://github.com/github/awesome-copilot.git
synced 2026-10-13 07:14:24 +00:00
247 lines
12 KiB
JavaScript
247 lines
12 KiB
JavaScript
// Tests for the agent actions, run against a real store on a temp folder.
|
|
// Run `node --test` in the extension folder.
|
|
import assert from "node:assert/strict";
|
|
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 { makeActions } from "./actions.mjs";
|
|
import { MAX_PLACEHOLDERS } from "./lib/layout.mjs";
|
|
import { DrawingStore } from "./store.mjs";
|
|
|
|
class CanvasError extends Error {
|
|
constructor(code, message) {
|
|
super(message);
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
// The actions for one panel, with a stand-in for the server that has no canvas open.
|
|
async function setup(t) {
|
|
const dir = await mkdtemp(path.join(os.tmpdir(), "draw-actions-test-"));
|
|
const store = new DrawingStore(dir);
|
|
await store.ready;
|
|
const doc = store.create("Plan");
|
|
await store.flush();
|
|
const server = { ensureDrawing: () => store.get(doc.id), hasClient: () => false, selection: () => [] };
|
|
const actions = makeActions({ runtime: async () => ({ store, server }), CanvasError });
|
|
const run = (name, input) => actions.find((a) => a.name === name).handler({ instanceId: "panel", input });
|
|
t.after(async () => {
|
|
await store.flush().catch(() => {});
|
|
await rm(dir, { recursive: true, force: true, maxRetries: 3 });
|
|
});
|
|
return { store, doc, run, server };
|
|
}
|
|
|
|
test("a change the agent makes is on disk when the action returns", async (t) => {
|
|
const { store, doc, run } = await setup(t);
|
|
const result = await run("add_elements", { nodes: [{ id: "api", label: "API" }] });
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.warning, undefined);
|
|
const saved = JSON.parse(await readFile(store.filePath(doc.id), "utf8"));
|
|
assert.deepEqual(saved.elements.map((e) => e.id), ["api"]);
|
|
});
|
|
|
|
test("a change that cannot be saved is reported by the action that made it", async (t) => {
|
|
const { store, doc, run } = await setup(t);
|
|
// A folder where the drawing's file should be makes the write fail.
|
|
const file = store.filePath(doc.id);
|
|
await rm(file);
|
|
await mkdir(file);
|
|
await writeFile(path.join(file, "keep"), "");
|
|
|
|
const result = await run("add_elements", { nodes: [{ id: "api", label: "API" }] });
|
|
assert.equal(result.ok, true);
|
|
assert.match(result.warning, /could not be saved to disk/);
|
|
assert.ok(result.warning.includes(store.saveError(doc.id)));
|
|
await rm(file, { recursive: true });
|
|
});
|
|
|
|
test("a shape that grows to fit a longer label keeps its center, even at the size limit", async (t) => {
|
|
const { store, doc, run } = await setup(t);
|
|
store.replace(doc.id, [{ id: "a", type: "rect", x: 0, y: 1000, w: 20, h: 100 }], "test");
|
|
await run("update_elements", { updates: [{ id: "a", label: "ab ".repeat(1300), size: "xl" }] });
|
|
const [a] = store.get(doc.id).elements;
|
|
assert.equal(a.h, 5000);
|
|
assert.equal(a.y + a.h / 2, 1050);
|
|
});
|
|
|
|
test("get_drawing reads the biggest drawing in parts of limited size", async (t) => {
|
|
const { store, doc, run, server } = await setup(t);
|
|
// The most a drawing can hold: 5,000 shapes with 4,000 character labels.
|
|
const label = "x".repeat(4000);
|
|
const all = Array.from({ length: 5000 }, (_, i) => `r${i}`);
|
|
store.replace(doc.id, all.map((id, i) => ({ id, type: "rect", x: (i % 100) * 200, y: Math.floor(i / 100) * 100, text: label })), "test");
|
|
const listed = (result) => [...result.outline.matchAll(/^- (r\d+) /gm)].map((m) => m[1]);
|
|
|
|
const seen = [];
|
|
let start = 0;
|
|
for (;;) {
|
|
const result = await run("get_drawing", { start });
|
|
const size = JSON.stringify(result).length;
|
|
assert.ok(size < 40000, `a part has ${size} characters`);
|
|
seen.push(...listed(result));
|
|
if (result.nextStart === undefined) break;
|
|
assert.equal(result.nextStart, seen.length);
|
|
start = result.nextStart;
|
|
}
|
|
assert.deepEqual(seen, all);
|
|
|
|
// Chosen elements come with their JSON, where labels are not cut.
|
|
const chosen = await run("get_drawing", { ids: ["r7", "gone"], includeElements: true });
|
|
assert.deepEqual(chosen.elements.map((e) => [e.id, e.text.length]), [["r7", 4000]]);
|
|
assert.deepEqual(chosen.missingIds, ["gone"]);
|
|
assert.equal(chosen.nextStart, undefined);
|
|
|
|
// A selection too big to list is counted, and selectedOnly reads it in parts too.
|
|
server.selection = () => all;
|
|
const counted = await run("get_drawing", {});
|
|
assert.equal(counted.selectedIds, undefined);
|
|
assert.equal(counted.selectedCount, 5000);
|
|
assert.ok(JSON.stringify(counted).length < 40000);
|
|
server.selection = () => ["r3", "r1"];
|
|
const selected = await run("get_drawing", { selectedOnly: true });
|
|
assert.deepEqual(selected.selectedIds, ["r3", "r1"]);
|
|
assert.deepEqual(listed(selected), ["r1", "r3"]);
|
|
});
|
|
|
|
test("an action that names another drawing than the one shown changes nothing", async (t) => {
|
|
const { store, doc, run } = await setup(t);
|
|
const nodes = [{ id: "api", label: "API" }];
|
|
await assert.rejects(run("add_elements", { drawingId: "old-plan", nodes }), (err) => {
|
|
assert.equal(err.code, "drawing_changed");
|
|
assert.ok(err.message.includes(`id ${doc.id}`));
|
|
return true;
|
|
});
|
|
assert.equal(store.get(doc.id).elements.length, 0);
|
|
assert.equal((await run("add_elements", { drawingId: doc.id, nodes })).ok, true);
|
|
assert.equal(store.get(doc.id).elements.length, 1);
|
|
});
|
|
|
|
test("a name more than one drawing has opens nothing and lists their ids", async (t) => {
|
|
const { store, run, server } = await setup(t);
|
|
const shown = [];
|
|
server.openDrawing = async (instanceId, d) => {
|
|
shown.push(d.id);
|
|
return "shown";
|
|
};
|
|
assert.equal(store.create("Plan").id, "plan-2");
|
|
for (const name of ["Plan", "PLAN", "plan!"]) {
|
|
await assert.rejects(run("open_drawing", { name }), (err) => {
|
|
assert.ok(err instanceof CanvasError);
|
|
assert.equal(err.code, "ambiguous_name");
|
|
assert.match(err.message, /plan \("Plan"\), plan-2 \("Plan"\)/);
|
|
return true;
|
|
});
|
|
}
|
|
assert.deepEqual(shown, []);
|
|
assert.equal(store.list().length, 2);
|
|
|
|
// An id always picks one, and the result names the others.
|
|
const second = await run("open_drawing", { name: "plan-2" });
|
|
assert.equal(second.drawing.id, "plan-2");
|
|
assert.match(second.message, /Other drawings have this name too: plan\./);
|
|
assert.equal((await run("open_drawing", { name: "plan" })).drawing.id, "plan");
|
|
assert.deepEqual(shown, ["plan-2", "plan"]);
|
|
});
|
|
|
|
test("open_drawing says when the canvas could not take the drawing, or has not said it did", async (t) => {
|
|
const { store, run, server } = await setup(t);
|
|
let outcome = "refused";
|
|
server.openDrawing = async () => outcome;
|
|
await assert.rejects(run("open_drawing", { name: "Other" }), (err) => {
|
|
assert.equal(err.code, "unsaved_changes");
|
|
assert.match(err.message, /^The canvas stayed on "Plan" \(id plan\): it has changes that could not be saved yet, and opening "Other" would lose them\./);
|
|
assert.match(err.message, / "Other" was created, so opening it again finds it\.$/);
|
|
return true;
|
|
});
|
|
|
|
outcome = "unconfirmed";
|
|
const result = await run("open_drawing", { name: "Other" });
|
|
assert.equal(result.created, false);
|
|
assert.match(result.message, /^Opened "Other"\. The canvas has not said yet that it shows it, so pass drawingId "other" to the next actions/);
|
|
assert.equal(store.list().length, 2);
|
|
});
|
|
|
|
test("every action on the shown drawing can name the drawing it means", () => {
|
|
const actions = makeActions({ runtime: async () => ({}), CanvasError });
|
|
const unpinned = actions.filter((a) => !a.inputSchema.properties.drawingId).map((a) => a.name);
|
|
assert.deepEqual(unpinned.sort(), ["list_drawings", "open_drawing", "set_theme"]);
|
|
});
|
|
|
|
test("a layout that would not fit on the canvas changes nothing and says why", async (t) => {
|
|
const { store, doc, run } = await setup(t);
|
|
// Long arrow labels spread the layers apart, so this chain would reach past x = 10,000,000.
|
|
const label = "x".repeat(4000);
|
|
const nodes = Array.from({ length: 500 }, (_, i) => ({ id: `n${i}`, label: `Step ${i}` }));
|
|
const edges = nodes.slice(1).map((n, i) => ({ from: `n${i}`, to: n.id, label }));
|
|
const tooBig = /The layout does not fit: "n\d+" would be at \d+,-?\d+, but positions only go from -1,000,000 to 1,000,000\./;
|
|
for (const name of ["set_diagram", "add_elements"]) {
|
|
await assert.rejects(run(name, { nodes, edges }), (err) => {
|
|
assert.equal(err.code, "invalid_diagram");
|
|
assert.match(err.message, tooBig);
|
|
return true;
|
|
});
|
|
}
|
|
assert.deepEqual(store.get(doc.id).elements, []);
|
|
|
|
// The same diagram, drawn by hand, cannot be laid out either.
|
|
store.replace(doc.id, [
|
|
...nodes.map((n, i) => ({ id: n.id, type: "rect", x: (i % 25) * 200, y: Math.floor(i / 25) * 100, text: n.label })),
|
|
...edges.map((e, i) => ({ id: `e${i}`, type: "arrow", from: e.from, to: e.to, text: e.label })),
|
|
], "test");
|
|
const before = store.get(doc.id);
|
|
await assert.rejects(run("layout", {}), (err) => {
|
|
assert.equal(err.code, "layout_too_big");
|
|
assert.match(err.message, /^Nothing was changed\./);
|
|
assert.match(err.message, tooBig);
|
|
return true;
|
|
});
|
|
assert.equal(store.get(doc.id).rev, before.rev);
|
|
|
|
// With short labels it fits, and no two shapes end up in the same spot.
|
|
store.replace(doc.id, before.elements.map((e) => (e.type === "arrow" ? { ...e, text: "next" } : e)), "test");
|
|
assert.equal((await run("layout", {})).ok, true);
|
|
const spots = new Set(store.get(doc.id).elements.filter((e) => e.type === "rect").map((e) => `${e.x},${e.y}`));
|
|
assert.equal(spots.size, 500);
|
|
});
|
|
|
|
test("a layout with too many long arrows changes nothing and says why", async (t) => {
|
|
const { store, doc, run } = await setup(t);
|
|
// A chain of 400 steps, with 300 arrows that jump from near its start to near its end. Each
|
|
// one passes hundreds of layers, and needs a placeholder on every one of them.
|
|
const nodes = Array.from({ length: 400 }, (_, i) => ({ id: `n${i}`, label: `Step ${i}` }));
|
|
const edges = nodes.slice(1).map((n, i) => ({ from: `n${i}`, to: n.id }));
|
|
let needed = 0;
|
|
for (let k = 0; k < 300; k++) {
|
|
const from = k % 20;
|
|
const to = 399 - Math.floor(k / 20);
|
|
edges.push({ from: `n${from}`, to: `n${to}` });
|
|
needed += to - from - 1;
|
|
}
|
|
assert.ok(needed > MAX_PLACEHOLDERS);
|
|
const count = (n) => n.toLocaleString("en-US");
|
|
const tooBig = new RegExp(`The layout is too big to work out: .* this diagram needs ${count(needed)}, more than the ${count(MAX_PLACEHOLDERS)} automatic layout handles\\.`);
|
|
for (const name of ["set_diagram", "add_elements"]) {
|
|
await assert.rejects(run(name, { nodes, edges }), (err) => {
|
|
assert.equal(err.code, "invalid_diagram");
|
|
assert.match(err.message, tooBig);
|
|
return true;
|
|
});
|
|
}
|
|
assert.deepEqual(store.get(doc.id).elements, []);
|
|
|
|
store.replace(doc.id, [
|
|
...nodes.map((n, i) => ({ id: n.id, type: "rect", x: (i % 20) * 200, y: Math.floor(i / 20) * 100, text: n.label })),
|
|
...edges.map((e, i) => ({ id: `e${i}`, type: "arrow", from: e.from, to: e.to })),
|
|
], "test");
|
|
const before = store.get(doc.id);
|
|
await assert.rejects(run("layout", {}), (err) => {
|
|
assert.equal(err.code, "layout_too_big");
|
|
assert.match(err.message, /^Nothing was changed\. The layout is too big to work out:/);
|
|
assert.match(err.message, tooBig);
|
|
return true;
|
|
});
|
|
assert.equal(store.get(doc.id).rev, before.rev);
|
|
});
|