Files
paseo/plugin-examples/linear/linear.test.ts
T
Mohamed BoudraandGitHub 23853a1515 Add managed local plugin lifecycle (#3222)
* feat(plugins): add local plugin surfaces and RPCs

Keep plugin execution scoped to its daemon while allowing one local TypeScript entry point to contribute validated RPCs and native client surfaces. Paseo owns host selection, navigation chrome, and per-installation query caches.

* feat(plugins): add Linear attachment source

* fix(nix): refresh npm dependency hash

* test(server): exclude internal sessions from Hub counts

* test(server): distinguish Hub execution sessions

* feat(plugins): make local plugins explicitly opt-in

Keep plugin loading dormant until configuration enables it, and collect unsupported examples under one directory.

* refactor(plugins): establish host boundaries

* fix(nix): refresh plugin dependency hash

* fix(plugins): complete packaging and startup rollback

* feat(plugins): add managed local lifecycle

Make trusted local plugins recoverable and authorable through daemon-owned lifecycle operations, host settings, and a capability-gated CLI. Reloads fully stop the previous plugin before starting new code, while generated projects provide strict TSX checking without changing runtime dependency ownership.

* fix(nix): refresh plugin dependency hash
2026-08-14 17:12:49 +02:00

136 lines
4.3 KiB
TypeScript

import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import { describe, expect, it } from "vitest";
import { createLinearIssueSearch } from "./linear";
const issue = {
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
description: "Let extensions attach external context.",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
priorityLabel: "High",
state: { name: "In Progress" },
assignee: { name: "Mohamed" },
project: { name: "Paseo" },
labels: { nodes: [{ name: "Feature" }] },
};
interface CapturedRequest {
authorization: string | undefined;
body: { query: string; variables: Record<string, unknown> };
}
async function readRequest(request: IncomingMessage): Promise<CapturedRequest> {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk));
return {
authorization: request.headers.authorization,
body: JSON.parse(Buffer.concat(chunks).toString("utf8")) as CapturedRequest["body"],
};
}
async function withLinearServer<T>(
respond: (request: CapturedRequest, response: ServerResponse) => void,
run: (endpoint: string) => Promise<T>,
): Promise<T> {
const server = createServer((request, response) => {
void readRequest(request).then((captured) => respond(captured, response));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as AddressInfo;
try {
return await run(`http://127.0.0.1:${address.port}/graphql`);
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
}
function sendJson(response: ServerResponse, body: unknown): void {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify(body));
}
describe("Linear issue search", () => {
it("resolves an exact issue identifier and formats its agent snapshot", async () => {
const result = await withLinearServer(
(request, response) => {
expect(request.authorization).toBe("lin_api_test");
expect(request.body.variables).toEqual({ id: "ENG-123" });
sendJson(response, { data: { issue } });
},
async (endpoint) => {
const linear = createLinearIssueSearch({ apiKey: "lin_api_test", endpoint });
return linear.search("eng-123");
},
);
expect(result).toEqual({
items: [
{
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
subtitle: "In Progress · Mohamed",
url: issue.url,
resourceType: "issue",
text: [
"Linear issue ENG-123: Plugin attachments",
`URL: ${issue.url}`,
"Status: In Progress",
"Priority: High",
"Assignee: Mohamed",
"Project: Paseo",
"Labels: Feature",
"",
"Let extensions attach external context.",
].join("\n"),
},
],
});
});
it("searches issue titles through a case-insensitive filter", async () => {
const result = await withLinearServer(
(request, response) => {
expect(request.body.variables).toEqual({
filter: { title: { containsIgnoreCase: "plugin" } },
});
sendJson(response, { data: { issues: { nodes: [issue] } } });
},
async (endpoint) => {
const linear = createLinearIssueSearch({ apiKey: "lin_api_test", endpoint });
return linear.search(" plugin ");
},
);
expect(result.items.map((item) => item.identifier)).toEqual(["ENG-123"]);
});
it("surfaces GraphQL errors even when HTTP succeeds", async () => {
await expect(
withLinearServer(
(_request, response) => sendJson(response, { errors: [{ message: "Token expired" }] }),
async (endpoint) => {
const linear = createLinearIssueSearch({ apiKey: "lin_api_test", endpoint });
return linear.search("ENG-123");
},
),
).rejects.toThrow("Token expired");
});
it("requires a daemon-side API key", () => {
expect(() => createLinearIssueSearch({ apiKey: "" })).toThrow(
"Set LINEAR_API_KEY in the daemon environment",
);
});
});