Files
fusion/plugins/fusion-plugin-linear-import/src/__tests__/LinearImportView.test.tsx
gsxdsm dfb6e5270d FN-7443: add bundled Linear import plugin
Add a plugin-owned Linear importer that creates Fusion tasks from Linear issues.

- Add the fusion-plugin-linear-import package with settings, Linear GraphQL client, import routes, tools, and dashboard UI.
- Bundle and register the Linear import plugin in the CLI and dashboard plugin view registry.
- Document bundled plugin authoring details and cover duplicate detection, routes, tools, UI, and packaging with tests.

Files changed:
 .changeset/fn-7443-linear-import-plugin.md         |   7 +
 docs/PLUGIN_AUTHORING.md                           |   7 +
 docs/task-management.md                            |   2 +
 packages/cli/src/__tests__/bundle-output.test.ts   |  22 ++
 .../__tests__/bundled-plugin-install.test.ts       |  29 +++
 packages/cli/src/plugins/bundled-plugin-install.ts |   1 +
 .../cli/src/plugins/staged-bundled-plugin-ids.ts   |   1 +
 packages/cli/tsup.config.ts                        |   8 +
 .../__tests__/registerBundledPluginViews.test.tsx  |   9 +-
 .../app/plugins/registerBundledPluginViews.ts      |  18 ++
 .../app/types/plugin-dashboard-views.d.ts          |   9 +
 .../src/__tests__/routes-plugin-registry.test.ts   |   5 +
 .../runtime-plugin-alias-regression.test.ts        |  12 +
 packages/dashboard/src/registry-manifest.json      |  10 +
 packages/dashboard/vite.config.ts                  |   8 +
 packages/dashboard/vitest.config.ts                |   8 +
 plugins/fusion-plugin-linear-import/README.md      |  75 ++++++
 plugins/fusion-plugin-linear-import/manifest.json  |  48 ++++
 plugins/fusion-plugin-linear-import/package.json   |  38 +++
 .../scripts/copy-css.mjs                           |  11 +
 .../src/LinearImportView.css                       | 167 +++++++++++++
 .../src/LinearImportView.tsx                       | 263 +++++++++++++++++++++
 .../src/__tests__/LinearImportView.test.tsx        | 124 ++++++++++
 .../src/__tests__/import-linear.test.ts            |  78 ++++++
 .../src/__tests__/linear-client.test.ts            |  81 +++++++
 .../src/__tests__/routes.test.ts                   |  90 +++++++
 .../src/__tests__/tools.test.ts                    |  79 +++++++
 .../src/dashboard-interop.d.ts                     |  13 +
 .../src/dashboard-view.tsx                         |  10 +
 .../src/import-linear.ts                           | 154 ++++++++++++
 plugins/fusion-plugin-linear-import/src/index.ts   |  45 ++++
 .../src/linear-client.ts                           | 247 +++++++++++++++++++
 plugins/fusion-plugin-linear-import/src/routes.ts  | 149 ++++++++++++
 .../fusion-plugin-linear-import/src/settings.ts    |  68 ++++++
 plugins/fusion-plugin-linear-import/src/tools.ts   | 138 +++++++++++
 plugins/fusion-plugin-linear-import/tsconfig.json  |  14 ++
 .../fusion-plugin-linear-import/vitest.config.ts   |  40 ++++
 pnpm-lock.yaml                                     |  40 ++++
 pnpm-workspace.yaml                                |   1 +
 39 files changed, 2128 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-7443
Fusion-Task-Lineage: a016a9d4-84a4-4a0b-b9b6-b9a2886da49a
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-02 14:31:16 -07:00

125 lines
6.9 KiB
TypeScript

import "@testing-library/jest-dom/vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LinearImportView } from "../LinearImportView.js";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
}
const issue = {
id: "iss-1",
identifier: "ENG-1",
title: "Fix plugin import",
description: "Linear body",
url: "https://linear.app/acme/issue/ENG-1/fix-plugin-import",
state: { name: "Todo", type: "unstarted" },
team: { id: "team-1", key: "ENG", name: "Engineering" },
assignee: { id: "user-1", name: "Ada" },
labels: [],
};
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe("LinearImportView", () => {
it("renders missing-auth setup state", async () => {
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ ok: true, authenticated: false, configured: false })));
render(<LinearImportView context={{ projectId: "proj-1" } as any} />);
expect(await screen.findByText(/Add a Linear API key/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Browse issues/i })).toBeDisabled();
});
it("renders desktop filters and calls plugin route with projectId", async () => {
const fetch = vi.fn()
.mockResolvedValueOnce(jsonResponse({ ok: true, authenticated: true }))
.mockResolvedValueOnce(jsonResponse({ ok: true, issues: [issue], pageInfo: { hasNextPage: false, endCursor: null } }));
vi.stubGlobal("fetch", fetch);
render(<LinearImportView context={{ projectId: "proj-1" } as any} />);
await screen.findByText("Linear connected");
await userEvent.type(screen.getByLabelText(/Search/i), "plugin");
await userEvent.type(screen.getByLabelText(/Team key/i), "ENG");
await userEvent.click(screen.getByRole("button", { name: /Browse issues/i }));
await screen.findByText("Fix plugin import");
expect(fetch.mock.calls[0][0]).toBe("/api/plugins/fusion-plugin-linear-import/status?projectId=proj-1");
expect(fetch.mock.calls[1][0]).toBe("/api/plugins/fusion-plugin-linear-import/issues");
expect(JSON.parse(String(fetch.mock.calls[1][1].body))).toEqual(expect.objectContaining({ projectId: "proj-1", query: "plugin", teamKey: "ENG" }));
});
it("renders mobile/narrow controls without hidden focus traps", async () => {
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 390 });
fireEvent(window, new Event("resize"));
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ ok: true, authenticated: true })));
render(<LinearImportView context={{ projectId: "proj-1" } as any} />);
await screen.findByText("Linear connected");
expect(screen.getByLabelText(/Search/i)).toBeVisible();
expect(screen.getByLabelText(/Team key/i)).toBeVisible();
expect(screen.getByRole("button", { name: /Browse issues/i })).toBeVisible();
});
it("shows empty results", async () => {
vi.stubGlobal("fetch", vi.fn()
.mockResolvedValueOnce(jsonResponse({ ok: true, authenticated: true }))
.mockResolvedValueOnce(jsonResponse({ ok: true, issues: [], pageInfo: { hasNextPage: false, endCursor: null } })));
render(<LinearImportView context={{ projectId: "proj-1" } as any} />);
await screen.findByText("Linear connected");
await userEvent.click(screen.getByRole("button", { name: /Browse issues/i }));
expect(await screen.findByText(/No Linear issues matched/i)).toBeInTheDocument();
});
it("previews issue descriptions and imports one issue", async () => {
const fetch = vi.fn()
.mockResolvedValueOnce(jsonResponse({ ok: true, authenticated: true }))
.mockResolvedValueOnce(jsonResponse({ ok: true, issues: [issue], pageInfo: { hasNextPage: false, endCursor: null } }))
.mockResolvedValueOnce(jsonResponse({ ok: true, imported: true, duplicate: false, taskId: "FN-8" }, 201));
vi.stubGlobal("fetch", fetch);
render(<LinearImportView context={{ projectId: "proj-1" } as any} />);
await screen.findByText("Linear connected");
await userEvent.click(screen.getByRole("button", { name: /Browse issues/i }));
expect(await screen.findByText("Linear body")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /Import previewed issue/i }));
expect(await screen.findByText(/Imported ENG-1 as task FN-8/i)).toBeInTheDocument();
});
it("shows duplicate import response", async () => {
vi.stubGlobal("fetch", vi.fn()
.mockResolvedValueOnce(jsonResponse({ ok: true, authenticated: true }))
.mockResolvedValueOnce(jsonResponse({ ok: true, issues: [issue], pageInfo: { hasNextPage: false, endCursor: null } }))
.mockResolvedValueOnce(jsonResponse({ ok: true, imported: false, duplicate: true, taskId: "FN-2" })));
render(<LinearImportView context={{ projectId: "proj-1" } as any} />);
await screen.findByText("Linear connected");
await userEvent.click(screen.getByRole("button", { name: /Browse issues/i }));
await screen.findByText("Fix plugin import");
await userEvent.click(screen.getByRole("button", { name: /Import previewed issue/i }));
expect(await screen.findByText(/existing task FN-2/i)).toBeInTheDocument();
});
it("shows GraphQL error responses", async () => {
vi.stubGlobal("fetch", vi.fn()
.mockResolvedValueOnce(jsonResponse({ ok: true, authenticated: true }))
.mockResolvedValueOnce(jsonResponse({ ok: false, error: "Linear GraphQL error: broken", code: "graphql_error" }, 400)));
render(<LinearImportView context={{ projectId: "proj-1" } as any} />);
await screen.findByText("Linear connected");
await userEvent.click(screen.getByRole("button", { name: /Browse issues/i }));
expect(await screen.findByText(/Linear GraphQL error: broken/i)).toBeInTheDocument();
});
it("imports selected issues", async () => {
vi.stubGlobal("fetch", vi.fn()
.mockResolvedValueOnce(jsonResponse({ ok: true, authenticated: true }))
.mockResolvedValueOnce(jsonResponse({ ok: true, issues: [issue], pageInfo: { hasNextPage: false, endCursor: null } }))
.mockResolvedValueOnce(jsonResponse({ ok: true, imported: 1, duplicates: 0, results: [] })));
render(<LinearImportView context={{ projectId: "proj-1" } as any} />);
await screen.findByText("Linear connected");
await userEvent.click(screen.getByRole("button", { name: /Browse issues/i }));
await screen.findByText("Fix plugin import");
await userEvent.click(screen.getByLabelText(/Select ENG-1/i));
await waitFor(() => expect(screen.getByRole("button", { name: /Import selected/i })).toBeEnabled());
await userEvent.click(screen.getByRole("button", { name: /Import selected/i }));
expect(await screen.findByText(/1 imported, 0 duplicates/i)).toBeInTheDocument();
});
});