@runfusion/fusion 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/dist/bin.js +26610 -20597
  2. package/dist/client/assets/AgentDetailView-BwJaLqZh.css +1 -0
  3. package/dist/client/assets/AgentDetailView-gy_5SUj2.js +18 -0
  4. package/dist/client/assets/AgentsView-BkB9FiMT.js +29 -0
  5. package/dist/client/assets/{AgentsView-DSGQWObq.css → AgentsView-CV3vm7Qk.css} +1 -1
  6. package/dist/client/assets/ChatView-B_-B8fqu.js +1 -0
  7. package/dist/client/assets/ChatView-DwJAd5G1.css +1 -0
  8. package/dist/client/assets/{DevServerView-C9lzHrcT.js → DevServerView-BkvtjZBa.js} +1 -1
  9. package/dist/client/assets/{DirectoryPicker-aVdFaV37.js → DirectoryPicker-BK-KbnhP.js} +1 -1
  10. package/dist/client/assets/{DocumentsView-DIpg3NSP.js → DocumentsView-BEg1CQAk.js} +1 -1
  11. package/dist/client/assets/{DocumentsView-BrhyOdeE.css → DocumentsView-gv4zG3aT.css} +1 -1
  12. package/dist/client/assets/EvalsView-Berf9bQm.js +1 -0
  13. package/dist/client/assets/EvalsView-CUNJ1TLc.css +1 -0
  14. package/dist/client/assets/{agentSkills-DDHJnrkn.css → ExperimentalAgentOnboardingModal-B-APN_lM.css} +1 -1
  15. package/dist/client/assets/ExperimentalAgentOnboardingModal-jcInE50G.js +499 -0
  16. package/dist/client/assets/InsightsView-B0J4mhzV.css +1 -0
  17. package/dist/client/assets/InsightsView-BX5bSF1J.js +11 -0
  18. package/dist/client/assets/{MemoryView-nXlTqebk.js → MemoryView-CKElJY_3.js} +2 -2
  19. package/dist/client/assets/NodesView-DLUOBLf6.js +14 -0
  20. package/dist/client/assets/NodesView-DT4pXowv.css +1 -0
  21. package/dist/client/assets/{PiExtensionsManager-Buopv-jb.js → PiExtensionsManager-COlJf0Kx.js} +2 -2
  22. package/dist/client/assets/PluginManager-CfW55BF4.js +1 -0
  23. package/dist/client/assets/PluginManager-DtRQXia5.css +1 -0
  24. package/dist/client/assets/{ResearchView-_BHXUv2j.js → ResearchView-B256Lr8I.js} +1 -1
  25. package/dist/client/assets/SettingsModal-BeA_nQtW.js +31 -0
  26. package/dist/client/assets/SettingsModal-DzsLquBu.css +1 -0
  27. package/dist/client/assets/{SettingsModal-C89Ikhfm.js → SettingsModal-yRqM4DV8.js} +1 -1
  28. package/dist/client/assets/SetupWizardModal-uUZk3TKT.js +1 -0
  29. package/dist/client/assets/{SkillsView-hDpTBdFT.js → SkillsView-CP8JX0P_.js} +1 -1
  30. package/dist/client/assets/TodoView-Cx9cVhq7.css +1 -0
  31. package/dist/client/assets/TodoView-DCRIkDZ-.js +6 -0
  32. package/dist/client/assets/createLucideIcon-BazL2hk5.js +21 -0
  33. package/dist/client/assets/dashboard-view-BkTMSZYn.css +1 -0
  34. package/dist/client/assets/dashboard-view-CyWN-d02.js +63 -0
  35. package/dist/client/assets/dashboard-view-lR7YYmSC.js +21 -0
  36. package/dist/client/assets/{folder-open-usZkXdq2.js → folder-open-DHjELt8-.js} +1 -1
  37. package/dist/client/assets/index-CQyVRLOb.js +692 -0
  38. package/dist/client/assets/index-CxA2Nn0_.css +1 -0
  39. package/dist/client/assets/projectDetection-G3XuxD2X.js +1 -0
  40. package/dist/client/assets/{star-BAT_ObKE.js → star-DYesq1AV.js} +1 -1
  41. package/dist/client/assets/{upload-BC2YKNEV.js → upload-DTWF3Db5.js} +1 -1
  42. package/dist/client/assets/{users-Dkd4rtrN.js → users--syrel4l.js} +1 -1
  43. package/dist/client/index.html +12 -20
  44. package/dist/client/theme-data.css +106 -0
  45. package/dist/client/version.json +1 -1
  46. package/dist/droid-cli/package.json +1 -1
  47. package/dist/extension.js +14287 -9568
  48. package/dist/pi-claude-cli/package.json +1 -1
  49. package/dist/plugins/fusion-plugin-cursor-runtime/bundled.js +218 -0
  50. package/dist/plugins/fusion-plugin-cursor-runtime/manifest.json +6 -0
  51. package/dist/plugins/fusion-plugin-cursor-runtime/package.json +11 -0
  52. package/dist/plugins/fusion-plugin-dependency-graph/manifest.json +1 -1
  53. package/dist/plugins/fusion-plugin-dependency-graph/package.json +6 -4
  54. package/dist/plugins/fusion-plugin-dependency-graph/src/DependencyGraph.css +58 -0
  55. package/dist/plugins/fusion-plugin-dependency-graph/src/DependencyGraph.tsx +301 -0
  56. package/dist/plugins/fusion-plugin-dependency-graph/src/GraphHighlight.css +27 -0
  57. package/dist/plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.css +157 -0
  58. package/dist/plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx +126 -0
  59. package/dist/plugins/fusion-plugin-dependency-graph/src/GraphToolbar.css +35 -0
  60. package/dist/plugins/fusion-plugin-dependency-graph/src/GraphToolbar.tsx +36 -0
  61. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraph.highlighting.test.tsx +112 -0
  62. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraph.persistence.test.tsx +115 -0
  63. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraph.test.tsx +128 -0
  64. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphTaskNode.drag.test.tsx +82 -0
  65. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphTaskNode.test.tsx +307 -0
  66. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphToolbar.test.tsx +60 -0
  67. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/edges.test.tsx +75 -0
  68. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/filtering.test.tsx +62 -0
  69. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/filters.test.ts +78 -0
  70. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/graphPositionStorage.test.ts +95 -0
  71. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/host-integration.test.ts +74 -0
  72. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/index.test.ts +58 -0
  73. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/interactions.test.tsx +121 -0
  74. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/layout.test.ts +70 -0
  75. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/persistence.test.tsx +89 -0
  76. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/useGraphData.test.ts +86 -0
  77. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/useGraphInteraction.test.ts +167 -0
  78. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/useGraphPositions.test.ts +66 -0
  79. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/useNodeDrag.test.ts +81 -0
  80. package/dist/plugins/fusion-plugin-dependency-graph/src/dashboard-interop.d.ts +35 -0
  81. package/dist/plugins/fusion-plugin-dependency-graph/src/dashboard-view.tsx +19 -0
  82. package/dist/plugins/fusion-plugin-dependency-graph/src/edges.tsx +70 -0
  83. package/dist/plugins/fusion-plugin-dependency-graph/src/filters.ts +8 -0
  84. package/dist/plugins/fusion-plugin-dependency-graph/src/hooks/__tests__/useDependencyChain.test.ts +53 -0
  85. package/dist/plugins/fusion-plugin-dependency-graph/src/hooks/useDependencyChain.ts +60 -0
  86. package/dist/plugins/fusion-plugin-dependency-graph/src/hooks/useGraphPositions.ts +45 -0
  87. package/dist/plugins/fusion-plugin-dependency-graph/src/hooks/useNodeDrag.ts +114 -0
  88. package/dist/plugins/fusion-plugin-dependency-graph/src/index.ts +1 -2
  89. package/dist/plugins/fusion-plugin-dependency-graph/src/layout.ts +91 -0
  90. package/dist/plugins/fusion-plugin-dependency-graph/src/styles/drag.css +15 -0
  91. package/dist/plugins/fusion-plugin-dependency-graph/src/types.ts +21 -0
  92. package/dist/plugins/fusion-plugin-dependency-graph/src/useGraphData.ts +17 -0
  93. package/dist/plugins/fusion-plugin-dependency-graph/src/useGraphInteraction.ts +292 -0
  94. package/dist/plugins/fusion-plugin-dependency-graph/src/utils/graphPositionStorage.ts +65 -0
  95. package/dist/plugins/fusion-plugin-droid-runtime/bundled.js +136680 -0
  96. package/dist/plugins/fusion-plugin-droid-runtime/manifest.json +13 -0
  97. package/dist/plugins/fusion-plugin-droid-runtime/mcp-schema-server.cjs +49 -0
  98. package/dist/plugins/fusion-plugin-droid-runtime/package.json +11 -0
  99. package/dist/plugins/fusion-plugin-hermes-runtime/package.json +1 -1
  100. package/dist/plugins/fusion-plugin-openclaw-runtime/bundled.js +93 -6
  101. package/dist/plugins/fusion-plugin-openclaw-runtime/mcp-schema-server.cjs +59 -0
  102. package/dist/plugins/fusion-plugin-openclaw-runtime/package.json +1 -1
  103. package/dist/plugins/fusion-plugin-paperclip-runtime/package.json +1 -1
  104. package/dist/plugins/fusion-plugin-reports/manifest.json +33 -0
  105. package/dist/plugins/fusion-plugin-reports/package.json +26 -0
  106. package/dist/plugins/fusion-plugin-reports/src/__tests__/manifest.test.ts +51 -0
  107. package/dist/plugins/fusion-plugin-reports/src/__tests__/review-panel.test.ts +166 -0
  108. package/dist/plugins/fusion-plugin-reports/src/__tests__/settings.test.ts +157 -0
  109. package/dist/plugins/fusion-plugin-reports/src/index.ts +41 -0
  110. package/dist/plugins/fusion-plugin-reports/src/review-panel.ts +294 -0
  111. package/dist/plugins/fusion-plugin-reports/src/review-types.ts +75 -0
  112. package/dist/plugins/fusion-plugin-reports/src/settings.ts +105 -0
  113. package/dist/plugins/fusion-plugin-roadmap/manifest.json +16 -0
  114. package/dist/plugins/fusion-plugin-roadmap/package.json +48 -0
  115. package/dist/plugins/fusion-plugin-roadmap/src/__tests__/api-client.test.ts +101 -0
  116. package/dist/plugins/fusion-plugin-roadmap/src/__tests__/index.test.ts +92 -0
  117. package/dist/plugins/fusion-plugin-roadmap/src/__tests__/roadmap-routes.test.ts +48 -0
  118. package/dist/plugins/fusion-plugin-roadmap/src/__tests__/roadmap-suggestions.test.ts +31 -0
  119. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.css +1299 -0
  120. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.tsx +2559 -0
  121. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/__tests__/RoadmapsView.test.tsx +1144 -0
  122. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/__tests__/useRoadmaps.test.ts +1756 -0
  123. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/api.ts +70 -0
  124. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/test-setup.ts +7 -0
  125. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/types.ts +1 -0
  126. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/useConfirm.ts +8 -0
  127. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/useRoadmaps.ts +1188 -0
  128. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/useViewportMode.ts +20 -0
  129. package/dist/plugins/fusion-plugin-roadmap/src/dashboard-view.tsx +6 -0
  130. package/dist/plugins/fusion-plugin-roadmap/src/index.ts +74 -0
  131. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-routes.ts +1 -0
  132. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-schema.ts +41 -0
  133. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-suggestions.d.ts +15 -0
  134. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-suggestions.ts +15 -0
  135. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts +283 -0
  136. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts.map +1 -0
  137. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.js +21 -0
  138. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.js.map +1 -0
  139. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.ts +310 -0
  140. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.d.ts +5 -0
  141. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.d.ts.map +1 -0
  142. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js +361 -0
  143. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js.map +1 -0
  144. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.ts +408 -0
  145. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.d.ts +68 -0
  146. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.d.ts.map +1 -0
  147. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js +300 -0
  148. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js.map +1 -0
  149. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.ts +381 -0
  150. package/dist/plugins/fusion-plugin-roadmap/src/server/index.d.ts +3 -0
  151. package/dist/plugins/fusion-plugin-roadmap/src/server/index.ts +1 -0
  152. package/dist/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-handoff.test.ts +445 -0
  153. package/dist/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-ordering.test.ts +334 -0
  154. package/dist/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts +1318 -0
  155. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-handoff.ts +163 -0
  156. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.d.ts +37 -0
  157. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.d.ts.map +1 -0
  158. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.js +188 -0
  159. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.js.map +1 -0
  160. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.ts +311 -0
  161. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.d.ts +299 -0
  162. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.d.ts.map +1 -0
  163. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.js +765 -0
  164. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.js.map +1 -0
  165. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.ts +1001 -0
  166. package/dist/plugins/fusion-plugin-whatsapp-chat/manifest.json +8 -0
  167. package/dist/plugins/fusion-plugin-whatsapp-chat/package.json +34 -0
  168. package/dist/plugins/fusion-plugin-whatsapp-chat/src/__tests__/auth-state.test.ts +99 -0
  169. package/dist/plugins/fusion-plugin-whatsapp-chat/src/__tests__/connection.test.ts +145 -0
  170. package/dist/plugins/fusion-plugin-whatsapp-chat/src/__tests__/index.test.ts +216 -0
  171. package/dist/plugins/fusion-plugin-whatsapp-chat/src/__tests__/reply.test.ts +52 -0
  172. package/dist/plugins/fusion-plugin-whatsapp-chat/src/auth-state.ts +89 -0
  173. package/dist/plugins/fusion-plugin-whatsapp-chat/src/connection.ts +253 -0
  174. package/dist/plugins/fusion-plugin-whatsapp-chat/src/index.ts +262 -0
  175. package/dist/plugins/fusion-plugin-whatsapp-chat/src/qrcode.d.ts +1 -0
  176. package/dist/plugins/fusion-plugin-whatsapp-chat/src/reply.ts +37 -0
  177. package/package.json +2 -2
  178. package/skill/fusion/SKILL.md +2 -2
  179. package/skill/fusion/references/engine-tools.md +3 -0
  180. package/skill/fusion/references/extension-tools.md +39 -0
  181. package/skill/fusion/references/fusion-capabilities.md +3 -0
  182. package/dist/client/assets/AgentDetailView-C1XceMgi.js +0 -18
  183. package/dist/client/assets/AgentDetailView-CeO_1MK7.css +0 -1
  184. package/dist/client/assets/AgentsView-Deh125ss.js +0 -527
  185. package/dist/client/assets/ChatView-7D_RQDqT.js +0 -1
  186. package/dist/client/assets/InsightsView-AWo5o_81.css +0 -1
  187. package/dist/client/assets/InsightsView-jKjEFAx_.js +0 -11
  188. package/dist/client/assets/NodesView-Di2SvOhg.js +0 -14
  189. package/dist/client/assets/NodesView-fXqDk9ur.css +0 -1
  190. package/dist/client/assets/PluginManager-B9-NbQ8f.js +0 -1
  191. package/dist/client/assets/PluginManager-C1DbPaar.css +0 -1
  192. package/dist/client/assets/RoadmapsView-DHWjUoc8.js +0 -6
  193. package/dist/client/assets/SettingsModal-DHitIpsa.css +0 -1
  194. package/dist/client/assets/SettingsModal-DR_yirvK.js +0 -31
  195. package/dist/client/assets/SetupWizardModal-BtDMY9pa.js +0 -1
  196. package/dist/client/assets/agentSkills-B-w5wFHh.js +0 -1
  197. package/dist/client/assets/index-Bc6ZdGMz.css +0 -1
  198. package/dist/client/assets/index-D__RMku8.js +0 -694
  199. package/dist/plugins/fusion-plugin-dependency-graph/src/DependencyGraphView.css +0 -141
  200. package/dist/plugins/fusion-plugin-dependency-graph/src/DependencyGraphView.tsx +0 -428
  201. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraphView.test.tsx +0 -261
  202. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/storage.test.ts +0 -41
  203. package/dist/plugins/fusion-plugin-dependency-graph/src/storage.ts +0 -22
  204. /package/dist/client/assets/{RoadmapsView-DdGlfuu-.css → dashboard-view-DdGlfuu-.css} +0 -0
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, renderHook } from "@testing-library/react";
3
+ import type { Task } from "@fusion/core";
4
+ import { filterGraphTasks } from "../filters";
5
+ import { useGraphData } from "../useGraphData";
6
+ import { DependencyGraph } from "../DependencyGraph";
7
+
8
+ function createTask(id: string, column: Task["column"], dependencies: string[] = [], status?: Task["status"]): Task {
9
+ return {
10
+ id,
11
+ description: id,
12
+ column,
13
+ status,
14
+ dependencies,
15
+ steps: [],
16
+ currentStep: 0,
17
+ log: [],
18
+ } as Task;
19
+ }
20
+
21
+ describe("dependency graph filtering", () => {
22
+ it("includes triage/todo/in-progress/in-review and excludes done/archived by column", () => {
23
+ const tasks = [
24
+ createTask("T", "triage", [], "done"),
25
+ createTask("TD", "todo", [], "done"),
26
+ createTask("P", "in-progress", [], "done"),
27
+ createTask("R", "in-review", [], "done"),
28
+ createTask("D", "done", [], "in-progress"),
29
+ createTask("A", "archived", [], "in-progress"),
30
+ ];
31
+
32
+ expect(filterGraphTasks(tasks).map((task) => task.id)).toEqual(["T", "TD", "P", "R"]);
33
+ });
34
+
35
+ it("keeps standalone tasks without dependencies as nodes", () => {
36
+ const tasks = [createTask("A", "todo")];
37
+ const { result } = renderHook(() => useGraphData(tasks));
38
+
39
+ expect(result.current.nodes.map((node) => node.task.id)).toEqual(["A"]);
40
+ expect(result.current.edges).toEqual([]);
41
+ });
42
+
43
+ it("keeps only edges to included dependency tasks for mixed-status dependencies", () => {
44
+ const filteredTasks = filterGraphTasks([
45
+ createTask("A", "in-progress", ["B", "DONE", "ARCH"]),
46
+ createTask("B", "todo"),
47
+ createTask("DONE", "done"),
48
+ createTask("ARCH", "archived"),
49
+ ]);
50
+
51
+ const { result } = renderHook(() => useGraphData(filteredTasks));
52
+ expect(result.current.edges).toEqual([{ source: "A", target: "B" }]);
53
+ });
54
+
55
+ it("renders empty state when all tasks are done/archived", () => {
56
+ const { container } = render(
57
+ <DependencyGraph tasks={[createTask("D", "done"), createTask("A", "archived")]} onOpenTaskDetail={() => {}} />,
58
+ );
59
+
60
+ expect(container.textContent).toContain("No active tasks");
61
+ });
62
+ });
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Task } from "@fusion/core";
3
+ import { EXCLUDED_COLUMNS, filterGraphTasks, INCLUDED_COLUMNS } from "../filters";
4
+
5
+ function createTask(id: string, column: Task["column"], dependencies: string[] = []): Task {
6
+ return {
7
+ id,
8
+ description: `Task ${id}`,
9
+ column,
10
+ dependencies,
11
+ steps: [],
12
+ currentStep: 0,
13
+ log: [],
14
+ } as Task;
15
+ }
16
+
17
+ describe("filterGraphTasks", () => {
18
+ it("returns empty for empty input", () => {
19
+ expect(filterGraphTasks([])).toEqual([]);
20
+ });
21
+
22
+ it("includes tasks from all included columns", () => {
23
+ const tasks = Array.from(INCLUDED_COLUMNS).map((column, index) => createTask(`FN-${index + 1}`, column));
24
+
25
+ expect(filterGraphTasks(tasks)).toEqual(tasks);
26
+ });
27
+
28
+ it("returns empty when only excluded columns are present", () => {
29
+ const tasks = Array.from(EXCLUDED_COLUMNS).map((column, index) => createTask(`FN-${index + 1}`, column));
30
+
31
+ expect(filterGraphTasks(tasks)).toEqual([]);
32
+ });
33
+
34
+ it("includes and excludes exact columns for mixed input", () => {
35
+ const tasks = [
36
+ createTask("FN-1", "triage"),
37
+ createTask("FN-2", "todo"),
38
+ createTask("FN-3", "in-progress"),
39
+ createTask("FN-4", "in-review"),
40
+ createTask("FN-5", "done"),
41
+ createTask("FN-6", "archived"),
42
+ ];
43
+
44
+ expect(filterGraphTasks(tasks).map((task) => task.id)).toEqual(["FN-1", "FN-2", "FN-3", "FN-4"]);
45
+ });
46
+
47
+ it.each([
48
+ ["triage", true],
49
+ ["todo", true],
50
+ ["in-progress", true],
51
+ ["in-review", true],
52
+ ["done", false],
53
+ ["archived", false],
54
+ ] as const)("column %s inclusion=%s", (column, included) => {
55
+ const task = createTask("FN-1", column);
56
+ const result = filterGraphTasks([task]);
57
+
58
+ expect(result.length > 0).toBe(included);
59
+ });
60
+
61
+ it("gracefully excludes tasks with invalid columns", () => {
62
+ const invalidTask = {
63
+ ...createTask("FN-invalid", "todo"),
64
+ column: undefined,
65
+ } as unknown as Task;
66
+
67
+ expect(filterGraphTasks([invalidTask])).toEqual([]);
68
+ });
69
+
70
+ it("preserves task object identity", () => {
71
+ const taskA = createTask("FN-1", "todo");
72
+ const taskB = createTask("FN-2", "in-review");
73
+ const result = filterGraphTasks([taskA, taskB]);
74
+
75
+ expect(result[0]).toBe(taskA);
76
+ expect(result[1]).toBe(taskB);
77
+ });
78
+ });
@@ -0,0 +1,95 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { clearPositions, loadPositions, mergePositions, savePositions } from "../utils/graphPositionStorage";
3
+
4
+ function createStorage() {
5
+ const store = new Map<string, string>();
6
+ return {
7
+ getItem: (key: string) => store.get(key) ?? null,
8
+ setItem: (key: string, value: string) => {
9
+ store.set(key, value);
10
+ },
11
+ removeItem: (key: string) => {
12
+ store.delete(key);
13
+ },
14
+ };
15
+ }
16
+
17
+ describe("graphPositionStorage", () => {
18
+ beforeEach(() => {
19
+ vi.unstubAllGlobals();
20
+ vi.stubGlobal("window", { localStorage: createStorage() });
21
+ });
22
+
23
+ it("loadPositions returns parsed positions from localStorage", () => {
24
+ window.localStorage.setItem("kb:p1:fusion-plugin-dependency-graph:positions", JSON.stringify({ a: { x: 1, y: 2 } }));
25
+ expect(loadPositions("p1")).toEqual({ a: { x: 1, y: 2 } });
26
+ });
27
+
28
+ it("loadPositions returns empty object when localStorage is empty", () => {
29
+ expect(loadPositions("p1")).toEqual({});
30
+ });
31
+
32
+ it("loadPositions returns empty object for invalid json", () => {
33
+ window.localStorage.setItem("kb:p1:fusion-plugin-dependency-graph:positions", "{oops");
34
+ expect(loadPositions("p1")).toEqual({});
35
+ });
36
+
37
+ it("loadPositions skips entries with invalid position shape", () => {
38
+ window.localStorage.setItem(
39
+ "kb:p1:fusion-plugin-dependency-graph:positions",
40
+ JSON.stringify({
41
+ good: { x: 1, y: 2 },
42
+ badX: { x: "1", y: 2 },
43
+ badY: { x: 1, y: null },
44
+ }),
45
+ );
46
+
47
+ expect(loadPositions("p1")).toEqual({ good: { x: 1, y: 2 } });
48
+ });
49
+
50
+ it("savePositions writes filtered positions json to scoped localStorage key", () => {
51
+ savePositions({ a: { x: 1, y: 2 }, b: { x: 3, y: 4 } }, new Set(["a"]), "p1");
52
+ expect(window.localStorage.getItem("kb:p1:fusion-plugin-dependency-graph:positions")).toBe(JSON.stringify({ a: { x: 1, y: 2 } }));
53
+ });
54
+
55
+ it("clearPositions removes scoped localStorage key", () => {
56
+ window.localStorage.setItem("kb:p1:fusion-plugin-dependency-graph:positions", JSON.stringify({ a: { x: 1, y: 2 } }));
57
+ clearPositions("p1");
58
+ expect(window.localStorage.getItem("kb:p1:fusion-plugin-dependency-graph:positions")).toBeNull();
59
+ });
60
+
61
+ it("mergePositions prefers saved for overlap and keeps auto-layout for new tasks", () => {
62
+ expect(
63
+ mergePositions(
64
+ { a: { x: 1, y: 1 }, b: { x: 2, y: 2 } },
65
+ { a: { x: 10, y: 10 } },
66
+ new Set(["a", "b"]),
67
+ ),
68
+ ).toEqual({ a: { x: 10, y: 10 }, b: { x: 2, y: 2 } });
69
+ });
70
+
71
+ it("mergePositions omits non-visible ids", () => {
72
+ expect(
73
+ mergePositions(
74
+ { a: { x: 1, y: 1 }, hidden: { x: 9, y: 9 } },
75
+ { hidden: { x: 10, y: 10 } },
76
+ new Set(["a"]),
77
+ ),
78
+ ).toEqual({ a: { x: 1, y: 1 } });
79
+ });
80
+
81
+ it("mergePositions returns auto-layout unchanged when saved is empty", () => {
82
+ expect(mergePositions({ a: { x: 1, y: 2 } }, {}, new Set(["a"]))).toEqual({ a: { x: 1, y: 2 } });
83
+ });
84
+
85
+ it("loadPositions returns empty object when localStorage.getItem is unavailable", () => {
86
+ vi.stubGlobal("window", { localStorage: {} });
87
+ expect(loadPositions("p1")).toEqual({});
88
+ });
89
+
90
+ it("savePositions and clearPositions are no-ops when localStorage methods are unavailable", () => {
91
+ vi.stubGlobal("window", { localStorage: {} });
92
+ expect(() => savePositions({ a: { x: 1, y: 2 } }, new Set(["a"]), "p1")).not.toThrow();
93
+ expect(() => clearPositions("p1")).not.toThrow();
94
+ });
95
+ });
@@ -0,0 +1,74 @@
1
+ import { createElement } from "react";
2
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
3
+ import { describe, expect, it, vi, afterEach } from "vitest";
4
+ import { definePlugin } from "@fusion/plugin-sdk";
5
+ import { validatePluginManifest } from "@fusion/core";
6
+ import plugin from "../index";
7
+ import { DependencyGraphDashboardView } from "../dashboard-view";
8
+ import { getPluginViewId } from "../../../../packages/dashboard/app/plugins/pluginViewRegistry";
9
+
10
+ vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
11
+ TaskCard: ({ task, onOpenDetail }: { task: { id: string }; onOpenDetail: (task: { id: string }) => void }) =>
12
+ createElement("button", { "data-testid": `task-${task.id}`, onClick: () => onOpenDetail(task) }, task.id),
13
+ }));
14
+
15
+ afterEach(() => {
16
+ cleanup();
17
+ });
18
+
19
+ describe("dependency graph plugin host integration contract", () => {
20
+ it("declares dashboard view manifest shape", () => {
21
+ expect(plugin.manifest.id).toBe("fusion-plugin-dependency-graph");
22
+ expect(plugin.dashboardViews).toEqual([
23
+ expect.objectContaining({
24
+ viewId: "graph",
25
+ label: "Graph",
26
+ componentPath: "./dashboard-view",
27
+ placement: "more",
28
+ }),
29
+ ]);
30
+ });
31
+
32
+ it("is valid for definePlugin + manifest validation", () => {
33
+ const defined = definePlugin(plugin);
34
+ const validation = validatePluginManifest(defined.manifest);
35
+
36
+ expect(validation.valid).toBe(true);
37
+ expect(validation.errors).toEqual([]);
38
+ });
39
+
40
+ it("produces loader-compatible pluginId/view entries", () => {
41
+ const entries = (plugin.dashboardViews ?? []).map((view) => ({ pluginId: plugin.manifest.id, view }));
42
+
43
+ expect(entries).toHaveLength(1);
44
+ expect(entries[0]).toEqual(
45
+ expect.objectContaining({
46
+ pluginId: "fusion-plugin-dependency-graph",
47
+ view: expect.objectContaining({ viewId: "graph" }),
48
+ }),
49
+ );
50
+ });
51
+
52
+ it("matches host registry lookup key format plugin:{pluginId}:{viewId}", () => {
53
+ const view = plugin.dashboardViews?.[0];
54
+ if (!view) throw new Error("missing dashboard view");
55
+
56
+ expect(getPluginViewId(plugin.manifest.id, view.viewId)).toBe("plugin:fusion-plugin-dependency-graph:graph");
57
+ });
58
+
59
+ it("uses host openTaskDetail context when rendered through dashboard view entrypoint", () => {
60
+ const openTaskDetail = vi.fn();
61
+ render(
62
+ createElement(DependencyGraphDashboardView, {
63
+ context: {
64
+ tasks: [{ id: "FN-HOST", description: "FN-HOST", column: "todo", dependencies: [], steps: [], currentStep: 0, log: [] }],
65
+ openTaskDetail,
66
+ } as never,
67
+ }),
68
+ );
69
+
70
+ fireEvent.click(screen.getByTestId("task-FN-HOST"));
71
+ expect(openTaskDetail).toHaveBeenCalledTimes(1);
72
+ expect(openTaskDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-HOST" }));
73
+ });
74
+ });
@@ -0,0 +1,58 @@
1
+ import { mkdtempSync } from "node:fs";
2
+ import { rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { PluginLoader, PluginStore } from "@fusion/core";
7
+ import { afterEach, describe, expect, it } from "vitest";
8
+ import plugin from "../index";
9
+
10
+ const testDirs: string[] = [];
11
+
12
+ afterEach(async () => {
13
+ await Promise.all(testDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
14
+ });
15
+
16
+ describe("dependency graph plugin index", () => {
17
+ it("exports node-importable plugin metadata", () => {
18
+ expect(plugin).toBeDefined();
19
+ expect(plugin.manifest.id).toBe("fusion-plugin-dependency-graph");
20
+ expect(plugin.dashboardViews?.[0]).toEqual(
21
+ expect.objectContaining({
22
+ viewId: "graph",
23
+ componentPath: "./dashboard-view",
24
+ }),
25
+ );
26
+ });
27
+
28
+ it("loads src/index.ts via Node dynamic import", async () => {
29
+ const moduleUrl = pathToFileURL(join(process.cwd(), "src/index.ts")).href;
30
+ const module = await import(moduleUrl);
31
+ expect(module.default?.manifest?.id).toBe("fusion-plugin-dependency-graph");
32
+ });
33
+
34
+ it("is loadable by PluginLoader without throwing", async () => {
35
+ const rootDir = mkdtempSync(join(tmpdir(), "fn-3737-plugin-loader-"));
36
+ testDirs.push(rootDir);
37
+
38
+ const pluginStore = new PluginStore(rootDir, { inMemoryDb: true, centralGlobalDir: rootDir });
39
+ await pluginStore.init();
40
+
41
+ const pluginPath = join(process.cwd(), "src/index.ts");
42
+ await pluginStore.registerPlugin({ manifest: plugin.manifest, path: pluginPath });
43
+
44
+ const loader = new PluginLoader({
45
+ pluginStore,
46
+ taskStore: { logActivity: async () => undefined } as never,
47
+ pluginDirs: [dirname(dirname(pluginPath))],
48
+ });
49
+
50
+ await loader.loadPlugin(plugin.manifest.id);
51
+
52
+ const loaded = loader.getPlugin(plugin.manifest.id);
53
+ expect(loaded?.state).toBe("started");
54
+ expect(loaded?.dashboardViews?.[0]).toEqual(
55
+ expect.objectContaining({ viewId: "graph", componentPath: "./dashboard-view" }),
56
+ );
57
+ });
58
+ });
@@ -0,0 +1,121 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { act, cleanup, fireEvent, render, renderHook, screen } from "@testing-library/react";
3
+ import type { Task } from "@fusion/core";
4
+ import { DependencyGraph } from "../DependencyGraph";
5
+ import { GraphTaskNode } from "../GraphTaskNode";
6
+ import { useGraphInteraction } from "../useGraphInteraction";
7
+
8
+ vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
9
+ TaskCard: ({ task, onOpenDetail }: { task: Task; onOpenDetail: (task: Task) => void }) => (
10
+ <button data-testid={`task-${task.id}`} onClick={() => onOpenDetail(task)}>
11
+ {task.id} {task.column === "in-progress" ? "Executing" : "Idle"}
12
+ </button>
13
+ ),
14
+ }));
15
+
16
+ function createTask(id: string, column: Task["column"] = "todo", dependencies: string[] = []): Task {
17
+ return {
18
+ id,
19
+ description: id,
20
+ column,
21
+ dependencies,
22
+ steps: [{ name: "one", status: "in-progress" }],
23
+ currentStep: 0,
24
+ status: column === "in-progress" ? "executing" : "queued",
25
+ log: [],
26
+ } as Task;
27
+ }
28
+
29
+ afterEach(() => {
30
+ cleanup();
31
+ });
32
+
33
+ describe("dependency graph interactions", () => {
34
+ it("supports pan and zoom via interaction hook", () => {
35
+ const { result } = renderHook(() => useGraphInteraction());
36
+
37
+ act(() => {
38
+ result.current.onPointerDown(1, { x: 10, y: 10 });
39
+ result.current.onPointerMove(1, { x: 110, y: 60 }, 800, 600);
40
+ result.current.zoomIn();
41
+ });
42
+
43
+ expect(result.current.pan).toEqual({ x: 100, y: 50 });
44
+ expect(result.current.zoom).toBeGreaterThan(1);
45
+ });
46
+
47
+ it("fit-to-graph computes bounds from actual node positions", () => {
48
+ const { result } = renderHook(() => useGraphInteraction());
49
+ const positions = new Map([
50
+ ["A", { x: 0, y: 0 }],
51
+ ["B", { x: 1000, y: 400 }],
52
+ ]);
53
+
54
+ act(() => {
55
+ result.current.fitToGraph(positions, 800, 600, { nodeWidth: 200, nodeHeight: 100, xGap: 40, yGap: 40 });
56
+ });
57
+
58
+ expect(result.current.zoom).toBeCloseTo(0.6, 3);
59
+ expect(result.current.pan.x).toBeCloseTo(40, 3);
60
+ expect(result.current.pan.y).toBeCloseTo(150, 3);
61
+ });
62
+
63
+ it("clicking a node opens task detail", () => {
64
+ const onOpenDetail = vi.fn();
65
+ render(<DependencyGraph tasks={[createTask("A", "in-progress")]} onOpenDetail={onOpenDetail} />);
66
+
67
+ fireEvent.click(screen.getByTestId("task-A"));
68
+ expect(onOpenDetail).toHaveBeenCalledTimes(1);
69
+ expect(onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "A" }));
70
+ expect(screen.getAllByText(/Executing/).length).toBeGreaterThan(0);
71
+ });
72
+
73
+ it("dragging a node updates its position", () => {
74
+ const onNodePositionChange = vi.fn();
75
+
76
+ render(
77
+ <GraphTaskNode
78
+ task={createTask("A")}
79
+ position={{ x: 0, y: 0 }}
80
+ scale={1}
81
+ isHighlighted={false}
82
+ isDimmed={false}
83
+ onNodePositionChange={onNodePositionChange}
84
+ onNodeDragStateChange={vi.fn()}
85
+ projectId="p1"
86
+ onOpenDetail={vi.fn()}
87
+ addToast={vi.fn()}
88
+ onUpdateTask={vi.fn()}
89
+ onArchiveTask={vi.fn()}
90
+ onUnarchiveTask={vi.fn()}
91
+ onDeleteTask={vi.fn()}
92
+ onRetryTask={vi.fn()}
93
+ onOpenDetailWithTab={vi.fn()}
94
+ onMoveTask={vi.fn()}
95
+ onOpenMission={vi.fn()}
96
+ taskStuckTimeoutMs={1_000}
97
+ lastFetchTimeMs={Date.now()}
98
+ workflowStepNameLookup={new Map()}
99
+ />,
100
+ );
101
+
102
+ const node = screen.getByTestId("graph-task-node-A");
103
+ fireEvent.pointerDown(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true });
104
+ fireEvent.pointerMove(node, { pointerId: 1, clientX: 25, clientY: 30, isPrimary: true });
105
+
106
+ expect(onNodePositionChange).toHaveBeenCalled();
107
+ });
108
+
109
+ it("highlights upstream/downstream chain on hover", () => {
110
+ render(
111
+ <DependencyGraph
112
+ tasks={[createTask("A"), createTask("B", "todo", ["A"]), createTask("C", "todo", ["B"]), createTask("D")]}
113
+ onOpenDetail={vi.fn()}
114
+ />,
115
+ );
116
+
117
+ fireEvent.mouseEnter(screen.getByTestId("graph-task-node-C"));
118
+ expect(screen.getByTestId("graph-task-node-A").className).toContain("graph-task-node--highlighted");
119
+ expect(screen.getByTestId("graph-task-node-D").className).toContain("graph-task-node--dimmed");
120
+ });
121
+ });
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { GraphData } from "../types";
3
+ import { computeAutoLayout } from "../layout";
4
+
5
+ function graph(nodeIds: string[], edges: Array<{ source: string; target: string }> = []): GraphData {
6
+ return {
7
+ nodes: nodeIds.map((id) => ({ task: { id } as never })),
8
+ edges,
9
+ };
10
+ }
11
+
12
+ describe("computeAutoLayout", () => {
13
+ it("returns empty map for empty graph", () => {
14
+ expect(computeAutoLayout({ nodes: [], edges: [] }).size).toBe(0);
15
+ });
16
+
17
+ it("positions single node", () => {
18
+ const positions = computeAutoLayout(graph(["A"]));
19
+ expect(positions.has("A")).toBe(true);
20
+ });
21
+
22
+ it("places linear chain in increasing depth", () => {
23
+ const positions = computeAutoLayout(graph(["A", "B", "C"], [
24
+ { source: "A", target: "B" },
25
+ { source: "B", target: "C" },
26
+ ]));
27
+
28
+ expect((positions.get("C")?.y ?? 0)).toBeLessThan(positions.get("B")?.y ?? 0);
29
+ expect((positions.get("B")?.y ?? 0)).toBeLessThan(positions.get("A")?.y ?? 0);
30
+ });
31
+
32
+ it("spreads wide layer horizontally", () => {
33
+ const positions = computeAutoLayout(graph(["A", "B", "C"]));
34
+ const xs = [positions.get("A")?.x, positions.get("B")?.x, positions.get("C")?.x].filter((x): x is number => x !== undefined);
35
+ expect(new Set(xs).size).toBe(3);
36
+ });
37
+
38
+ it("handles diamond dependencies", () => {
39
+ const positions = computeAutoLayout(graph(["A", "B", "C", "D"], [
40
+ { source: "A", target: "B" },
41
+ { source: "A", target: "C" },
42
+ { source: "B", target: "D" },
43
+ { source: "C", target: "D" },
44
+ ]));
45
+
46
+ expect((positions.get("D")?.y ?? 0)).toBeLessThan(positions.get("B")?.y ?? 0);
47
+ expect((positions.get("D")?.y ?? 0)).toBeLessThan(positions.get("C")?.y ?? 0);
48
+ expect((positions.get("B")?.y ?? 0)).toBeLessThan(positions.get("A")?.y ?? 0);
49
+ expect((positions.get("C")?.y ?? 0)).toBeLessThan(positions.get("A")?.y ?? 0);
50
+ });
51
+
52
+ it("handles cycles without crashing", () => {
53
+ const positions = computeAutoLayout(graph(["A", "B"], [
54
+ { source: "A", target: "B" },
55
+ { source: "B", target: "A" },
56
+ ]));
57
+
58
+ expect(positions.size).toBe(2);
59
+ });
60
+
61
+ it("respects custom spacing options", () => {
62
+ const positions = computeAutoLayout(graph(["A", "B"]), {
63
+ nodeWidth: 200,
64
+ nodeHeight: 120,
65
+ horizontalGap: 100,
66
+ verticalGap: 20,
67
+ });
68
+ expect(Math.abs((positions.get("A")?.x ?? 0) - (positions.get("B")?.x ?? 0))).toBe(300);
69
+ });
70
+ });
@@ -0,0 +1,89 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
3
+ import type { Task } from "@fusion/core";
4
+ import { DependencyGraph } from "../DependencyGraph";
5
+ import { loadPositions, savePositions } from "../utils/graphPositionStorage";
6
+
7
+ vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
8
+ TaskCard: ({ task }: { task: Task }) => <div>{task.id}</div>,
9
+ }));
10
+
11
+ function createStorage() {
12
+ const store = new Map<string, string>();
13
+ return {
14
+ getItem: (key: string) => store.get(key) ?? null,
15
+ setItem: (key: string, value: string) => {
16
+ store.set(key, value);
17
+ },
18
+ removeItem: (key: string) => {
19
+ store.delete(key);
20
+ },
21
+ };
22
+ }
23
+
24
+ function createTask(id: string, column: Task["column"] = "todo"): Task {
25
+ return { id, description: id, column, dependencies: [], steps: [], currentStep: 0, log: [] } as Task;
26
+ }
27
+
28
+ describe("dependency graph position persistence", () => {
29
+ beforeEach(() => {
30
+ Object.defineProperty(window, "localStorage", { value: createStorage(), configurable: true });
31
+ });
32
+
33
+ afterEach(() => {
34
+ cleanup();
35
+ vi.restoreAllMocks();
36
+ });
37
+
38
+ it("saves/restores project-scoped position shape", () => {
39
+ savePositions({ A: { x: 10, y: 20 }, B: { x: 30, y: 40 } }, new Set(["A", "B"]), "p1");
40
+
41
+ expect(window.localStorage.getItem("kb:p1:fusion-plugin-dependency-graph:positions")).toBe(
42
+ JSON.stringify({ A: { x: 10, y: 20 }, B: { x: 30, y: 40 } }),
43
+ );
44
+ expect(loadPositions("p1")).toEqual({ A: { x: 10, y: 20 }, B: { x: 30, y: 40 } });
45
+ });
46
+
47
+ it("keeps positions isolated across projects", () => {
48
+ savePositions({ A: { x: 1, y: 2 } }, new Set(["A"]), "p1");
49
+ savePositions({ A: { x: 99, y: 88 } }, new Set(["A"]), "p2");
50
+
51
+ expect(loadPositions("p1")).toEqual({ A: { x: 1, y: 2 } });
52
+ expect(loadPositions("p2")).toEqual({ A: { x: 99, y: 88 } });
53
+ });
54
+
55
+ it("falls back to auto-layout with corrupt storage and does not crash", () => {
56
+ window.localStorage.setItem("kb:p1:fusion-plugin-dependency-graph:positions", "{broken");
57
+
58
+ render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
59
+ expect(screen.getByTestId("graph-task-node-A")).toBeTruthy();
60
+ });
61
+
62
+ it("drag persistence only writes localStorage and performs no network writes", () => {
63
+ const fetchSpy = vi.spyOn(globalThis, "fetch");
64
+
65
+ render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
66
+ const node = screen.getByTestId("graph-task-node-A");
67
+ fireEvent.pointerDown(node, { pointerId: 1, isPrimary: true, clientX: 10, clientY: 10 });
68
+ fireEvent.pointerMove(node, { pointerId: 1, isPrimary: true, clientX: 30, clientY: 40 });
69
+ fireEvent.pointerUp(node, { pointerId: 1, isPrimary: true, clientX: 30, clientY: 40 });
70
+
71
+ expect(window.localStorage.getItem("kb:p1:fusion-plugin-dependency-graph:positions")).toContain('"A"');
72
+ expect(fetchSpy).not.toHaveBeenCalled();
73
+ });
74
+
75
+ it("clearing localStorage causes fresh auto-layout on remount", () => {
76
+ const { unmount } = render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
77
+ const node = screen.getByTestId("graph-task-node-A");
78
+ fireEvent.pointerDown(node, { pointerId: 1, isPrimary: true, clientX: 10, clientY: 10 });
79
+ fireEvent.pointerMove(node, { pointerId: 1, isPrimary: true, clientX: 40, clientY: 50 });
80
+ fireEvent.pointerUp(node, { pointerId: 1, isPrimary: true, clientX: 40, clientY: 50 });
81
+
82
+ window.localStorage.removeItem("kb:p1:fusion-plugin-dependency-graph:positions");
83
+ unmount();
84
+
85
+ render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
86
+ expect(window.localStorage.getItem("kb:p1:fusion-plugin-dependency-graph:positions")).toBeNull();
87
+ expect(screen.getByTestId("graph-task-node-A")).toBeTruthy();
88
+ });
89
+ });