Skip to content

Commit 5e2bb0f

Browse files
committed
test(pdf-server): add interact tool unit tests
New describe("interact tool") block with 7 tests covering: - enqueue -> poll_pdf_commands roundtrip - missing-arg error paths for navigate, fill_form, add_annotations - command queue isolation across distinct viewUUIDs - fill_form passthrough when viewFieldNames not registered - (skip) unknown-UUID poll (LONG_POLL_TIMEOUT_MS not exported, can't bypass the 30s wait without changing server.ts) Surprises found: - interact never validates viewUUID exists; enqueues to any string - batch-mode early-exits on first error, silently dropping later commands
1 parent 8be53a2 commit 5e2bb0f

1 file changed

Lines changed: 177 additions & 0 deletions

File tree

examples/pdf-server/server.test.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -785,3 +785,180 @@ describe("file watching", () => {
785785
},
786786
);
787787
});
788+
789+
describe("interact tool", () => {
790+
// Helper: connected server+client pair with interact enabled.
791+
// Command queues are MODULE-LEVEL (shared across server instances), so each
792+
// test uses a distinct viewUUID to avoid cross-test interference.
793+
async function connect() {
794+
const server = createServer({ enableInteract: true });
795+
const client = new Client({ name: "t", version: "1" });
796+
const [ct, st] = InMemoryTransport.createLinkedPair();
797+
await Promise.all([server.connect(st), client.connect(ct)]);
798+
return { server, client };
799+
}
800+
801+
// Helper: poll with an outer deadline so a failing test doesn't hang for the
802+
// full 30s long-poll. Safe ONLY when a command is already enqueued — poll
803+
// then returns after the 200ms batch window.
804+
async function poll(client: Client, uuid: string, timeoutMs = 2000) {
805+
const result = await Promise.race([
806+
client.callTool({
807+
name: "poll_pdf_commands",
808+
arguments: { viewUUID: uuid },
809+
}),
810+
new Promise<never>((_, reject) =>
811+
setTimeout(() => reject(new Error("poll timeout")), timeoutMs),
812+
),
813+
]);
814+
return (
815+
(result as { structuredContent?: { commands?: unknown[] } })
816+
.structuredContent?.commands ?? []
817+
) as Array<Record<string, unknown>>;
818+
}
819+
820+
function firstText(r: Awaited<ReturnType<Client["callTool"]>>): string {
821+
return (r.content as Array<{ type: string; text: string }>)[0].text;
822+
}
823+
824+
it("enqueue → poll roundtrip delivers the command", async () => {
825+
const { server, client } = await connect();
826+
const uuid = "test-interact-roundtrip";
827+
828+
const r = await client.callTool({
829+
name: "interact",
830+
arguments: { viewUUID: uuid, action: "navigate", page: 5 },
831+
});
832+
expect(r.isError).toBeFalsy();
833+
expect(firstText(r)).toContain("Queued");
834+
expect(firstText(r)).toContain("page 5");
835+
836+
// Core mechanism: the viewer polls for what the model enqueued.
837+
const cmds = await poll(client, uuid);
838+
expect(cmds).toHaveLength(1);
839+
expect(cmds[0].type).toBe("navigate");
840+
expect(cmds[0].page).toBe(5);
841+
842+
await client.close();
843+
await server.close();
844+
});
845+
846+
it("navigate without `page` returns isError with a helpful message", async () => {
847+
const { server, client } = await connect();
848+
849+
const r = await client.callTool({
850+
name: "interact",
851+
arguments: { viewUUID: "test-err-nav", action: "navigate" },
852+
});
853+
expect(r.isError).toBe(true);
854+
expect(firstText(r)).toContain("navigate");
855+
expect(firstText(r)).toContain("page");
856+
857+
await client.close();
858+
await server.close();
859+
});
860+
861+
it("fill_form without `fields` returns isError with a helpful message", async () => {
862+
const { server, client } = await connect();
863+
864+
const r = await client.callTool({
865+
name: "interact",
866+
arguments: { viewUUID: "test-err-fill", action: "fill_form" },
867+
});
868+
expect(r.isError).toBe(true);
869+
expect(firstText(r)).toContain("fill_form");
870+
expect(firstText(r)).toContain("fields");
871+
872+
await client.close();
873+
await server.close();
874+
});
875+
876+
it("add_annotations without `annotations` returns isError with a helpful message", async () => {
877+
const { server, client } = await connect();
878+
879+
const r = await client.callTool({
880+
name: "interact",
881+
arguments: { viewUUID: "test-err-ann", action: "add_annotations" },
882+
});
883+
expect(r.isError).toBe(true);
884+
expect(firstText(r)).toContain("add_annotations");
885+
expect(firstText(r)).toContain("annotations");
886+
887+
await client.close();
888+
await server.close();
889+
});
890+
891+
it("isolates command queues across distinct viewUUIDs", async () => {
892+
const { server, client } = await connect();
893+
const uuidA = "test-isolate-A";
894+
const uuidB = "test-isolate-B";
895+
896+
await client.callTool({
897+
name: "interact",
898+
arguments: { viewUUID: uuidA, action: "navigate", page: 3 },
899+
});
900+
await client.callTool({
901+
name: "interact",
902+
arguments: { viewUUID: uuidB, action: "search", query: "quantum" },
903+
});
904+
905+
const cmdsA = await poll(client, uuidA);
906+
expect(cmdsA).toHaveLength(1);
907+
expect(cmdsA[0].type).toBe("navigate");
908+
expect(cmdsA[0].page).toBe(3);
909+
910+
const cmdsB = await poll(client, uuidB);
911+
expect(cmdsB).toHaveLength(1);
912+
expect(cmdsB[0].type).toBe("search");
913+
expect(cmdsB[0].query).toBe("quantum");
914+
915+
await client.close();
916+
await server.close();
917+
});
918+
919+
// SKIPPED: the unknown-UUID path enters the long-poll branch and blocks for
920+
// the full LONG_POLL_TIMEOUT_MS (30s, module-local const, not configurable).
921+
// The handler does dequeue [] at the end, so the return value IS
922+
// {commands: []} — but there's no fast path to reach it without waiting.
923+
// See the `stopFileWatch prevents further commands` test above for indirect
924+
// coverage of the same blocking behaviour.
925+
it.skip("poll with unknown viewUUID returns {commands: []} after long-poll", () => {});
926+
927+
it("fill_form passes all fields through when viewFieldNames is not registered", async () => {
928+
const { server, client } = await connect();
929+
// Fresh UUID never seen by display_pdf → viewFieldNames.get(uuid) is
930+
// undefined → the known-fields guard (`knownFields && !knownFields.has()`)
931+
// is falsy for every field → everything is enqueued.
932+
const uuid = "test-fillform-passthrough";
933+
934+
const r = await client.callTool({
935+
name: "interact",
936+
arguments: {
937+
viewUUID: uuid,
938+
action: "fill_form",
939+
fields: [
940+
{ name: "anything", value: "goes" },
941+
{ name: "unchecked", value: true },
942+
],
943+
},
944+
});
945+
expect(r.isError).toBeFalsy();
946+
expect(firstText(r)).toContain("Filled 2 field(s)");
947+
// No rejection complaint — the registry has no entry for this UUID
948+
expect(firstText(r)).not.toContain("Unknown");
949+
950+
const cmds = await poll(client, uuid);
951+
expect(cmds).toHaveLength(1);
952+
expect(cmds[0].type).toBe("fill_form");
953+
const fields = cmds[0].fields as Array<{ name: string; value: unknown }>;
954+
expect(fields).toHaveLength(2);
955+
expect(fields.map((f) => f.name).sort()).toEqual(["anything", "unchecked"]);
956+
957+
// Note: the "registered → reject unknown" branch needs viewFieldNames
958+
// populated, which only happens inside display_pdf (requires a real PDF).
959+
// That map isn't exported, so the rejection path is covered by e2e only.
960+
961+
await client.close();
962+
await server.close();
963+
});
964+
});

0 commit comments

Comments
 (0)