@sweny-ai/core 0.1.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 (93) hide show
  1. package/dist/__tests__/claude.test.d.ts +1 -0
  2. package/dist/__tests__/claude.test.js +328 -0
  3. package/dist/__tests__/executor.test.d.ts +1 -0
  4. package/dist/__tests__/executor.test.js +296 -0
  5. package/dist/__tests__/integration/datadog.integration.test.d.ts +1 -0
  6. package/dist/__tests__/integration/datadog.integration.test.js +23 -0
  7. package/dist/__tests__/integration/e2e-workflow.integration.test.d.ts +1 -0
  8. package/dist/__tests__/integration/e2e-workflow.integration.test.js +75 -0
  9. package/dist/__tests__/integration/github.integration.test.d.ts +1 -0
  10. package/dist/__tests__/integration/github.integration.test.js +37 -0
  11. package/dist/__tests__/integration/harness.d.ts +24 -0
  12. package/dist/__tests__/integration/harness.js +34 -0
  13. package/dist/__tests__/integration/linear.integration.test.d.ts +1 -0
  14. package/dist/__tests__/integration/linear.integration.test.js +15 -0
  15. package/dist/__tests__/integration/sentry.integration.test.d.ts +1 -0
  16. package/dist/__tests__/integration/sentry.integration.test.js +20 -0
  17. package/dist/__tests__/integration/slack.integration.test.d.ts +1 -0
  18. package/dist/__tests__/integration/slack.integration.test.js +22 -0
  19. package/dist/__tests__/schema.test.d.ts +1 -0
  20. package/dist/__tests__/schema.test.js +239 -0
  21. package/dist/__tests__/skills-index.test.d.ts +1 -0
  22. package/dist/__tests__/skills-index.test.js +122 -0
  23. package/dist/__tests__/skills.test.d.ts +1 -0
  24. package/dist/__tests__/skills.test.js +296 -0
  25. package/dist/__tests__/studio.test.d.ts +1 -0
  26. package/dist/__tests__/studio.test.js +172 -0
  27. package/dist/__tests__/testing.test.d.ts +1 -0
  28. package/dist/__tests__/testing.test.js +224 -0
  29. package/dist/browser.d.ts +17 -0
  30. package/dist/browser.js +22 -0
  31. package/dist/claude.d.ts +48 -0
  32. package/dist/claude.js +293 -0
  33. package/dist/cli/check.d.ts +11 -0
  34. package/dist/cli/check.js +237 -0
  35. package/dist/cli/config-file.d.ts +12 -0
  36. package/dist/cli/config-file.js +208 -0
  37. package/dist/cli/config.d.ts +77 -0
  38. package/dist/cli/config.js +565 -0
  39. package/dist/cli/main.d.ts +10 -0
  40. package/dist/cli/main.js +744 -0
  41. package/dist/cli/output.d.ts +26 -0
  42. package/dist/cli/output.js +357 -0
  43. package/dist/cli/renderer.d.ts +33 -0
  44. package/dist/cli/renderer.js +423 -0
  45. package/dist/cli/renderer.test.d.ts +1 -0
  46. package/dist/cli/renderer.test.js +302 -0
  47. package/dist/cli/setup.d.ts +11 -0
  48. package/dist/cli/setup.js +310 -0
  49. package/dist/executor.d.ts +29 -0
  50. package/dist/executor.js +173 -0
  51. package/dist/executor.test.d.ts +1 -0
  52. package/dist/executor.test.js +314 -0
  53. package/dist/index.d.ts +37 -0
  54. package/dist/index.js +36 -0
  55. package/dist/mcp.d.ts +11 -0
  56. package/dist/mcp.js +183 -0
  57. package/dist/mcp.test.d.ts +1 -0
  58. package/dist/mcp.test.js +334 -0
  59. package/dist/schema.d.ts +318 -0
  60. package/dist/schema.js +207 -0
  61. package/dist/skills/betterstack.d.ts +7 -0
  62. package/dist/skills/betterstack.js +114 -0
  63. package/dist/skills/datadog.d.ts +7 -0
  64. package/dist/skills/datadog.js +107 -0
  65. package/dist/skills/github.d.ts +8 -0
  66. package/dist/skills/github.js +155 -0
  67. package/dist/skills/index.d.ts +68 -0
  68. package/dist/skills/index.js +134 -0
  69. package/dist/skills/linear.d.ts +7 -0
  70. package/dist/skills/linear.js +89 -0
  71. package/dist/skills/notification.d.ts +11 -0
  72. package/dist/skills/notification.js +142 -0
  73. package/dist/skills/sentry.d.ts +7 -0
  74. package/dist/skills/sentry.js +105 -0
  75. package/dist/skills/slack.d.ts +8 -0
  76. package/dist/skills/slack.js +115 -0
  77. package/dist/studio.d.ts +124 -0
  78. package/dist/studio.js +174 -0
  79. package/dist/testing.d.ts +88 -0
  80. package/dist/testing.js +253 -0
  81. package/dist/types.d.ts +144 -0
  82. package/dist/types.js +11 -0
  83. package/dist/workflow-builder.d.ts +45 -0
  84. package/dist/workflow-builder.js +120 -0
  85. package/dist/workflow-builder.test.d.ts +1 -0
  86. package/dist/workflow-builder.test.js +117 -0
  87. package/dist/workflows/implement.d.ts +11 -0
  88. package/dist/workflows/implement.js +83 -0
  89. package/dist/workflows/index.d.ts +2 -0
  90. package/dist/workflows/index.js +2 -0
  91. package/dist/workflows/triage.d.ts +18 -0
  92. package/dist/workflows/triage.js +108 -0
  93. package/package.json +83 -0
@@ -0,0 +1,296 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { github } from "../skills/github.js";
3
+ import { linear } from "../skills/linear.js";
4
+ import { slack } from "../skills/slack.js";
5
+ import { sentry } from "../skills/sentry.js";
6
+ import { datadog } from "../skills/datadog.js";
7
+ import { notification } from "../skills/notification.js";
8
+ // ─── Helpers ─────────────────────────────────────────────────────
9
+ const mockCtx = (config = {}) => ({
10
+ config,
11
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
12
+ });
13
+ let fetchMock;
14
+ beforeEach(() => {
15
+ fetchMock = vi.fn();
16
+ vi.stubGlobal("fetch", fetchMock);
17
+ });
18
+ afterEach(() => {
19
+ vi.restoreAllMocks();
20
+ });
21
+ function mockResponse(body, ok = true, status = 200) {
22
+ return {
23
+ ok,
24
+ status,
25
+ json: async () => body,
26
+ text: async () => JSON.stringify(body),
27
+ };
28
+ }
29
+ function findTool(skillTools, name) {
30
+ const tool = skillTools.find((t) => t.name === name);
31
+ if (!tool)
32
+ throw new Error(`Tool ${name} not found`);
33
+ return tool;
34
+ }
35
+ // ─── GitHub skill tests ──────────────────────────────────────────
36
+ describe("github skill", () => {
37
+ const ctx = () => mockCtx({ GITHUB_TOKEN: "ghp_test" });
38
+ it("search_code sends correct request", async () => {
39
+ fetchMock.mockResolvedValueOnce(mockResponse({ items: [] }));
40
+ const tool = findTool(github.tools, "github_search_code");
41
+ await tool.handler({ query: "foo", repo: "owner/repo" }, ctx());
42
+ expect(fetchMock).toHaveBeenCalledOnce();
43
+ const [url, opts] = fetchMock.mock.calls[0];
44
+ expect(url).toContain("/search/code");
45
+ expect(url).toContain("repo%3Aowner%2Frepo");
46
+ expect(opts.headers.Authorization).toBe("token ghp_test");
47
+ expect(opts.signal).toBeDefined(); // timeout signal
48
+ });
49
+ it("get_issue sends correct request", async () => {
50
+ fetchMock.mockResolvedValueOnce(mockResponse({ id: 1, title: "Bug" }));
51
+ const tool = findTool(github.tools, "github_get_issue");
52
+ const result = await tool.handler({ repo: "owner/repo", number: 42 }, ctx());
53
+ expect(fetchMock.mock.calls[0][0]).toContain("/repos/owner/repo/issues/42");
54
+ expect(result.title).toBe("Bug");
55
+ });
56
+ it("create_issue sends POST with body", async () => {
57
+ fetchMock.mockResolvedValueOnce(mockResponse({ number: 1 }));
58
+ const tool = findTool(github.tools, "github_create_issue");
59
+ await tool.handler({ repo: "o/r", title: "New Issue", body: "Details", labels: ["bug"] }, ctx());
60
+ const [, opts] = fetchMock.mock.calls[0];
61
+ expect(opts.method).toBe("POST");
62
+ const body = JSON.parse(opts.body);
63
+ expect(body.title).toBe("New Issue");
64
+ expect(body.labels).toContain("bug");
65
+ });
66
+ it("create_pr sends POST with correct fields", async () => {
67
+ fetchMock.mockResolvedValueOnce(mockResponse({ number: 10 }));
68
+ const tool = findTool(github.tools, "github_create_pr");
69
+ await tool.handler({ repo: "o/r", title: "PR", head: "feature", base: "develop" }, ctx());
70
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
71
+ expect(body.head).toBe("feature");
72
+ expect(body.base).toBe("develop");
73
+ });
74
+ it("get_file decodes base64 content", async () => {
75
+ const encoded = Buffer.from("hello world").toString("base64");
76
+ fetchMock.mockResolvedValueOnce(mockResponse({ content: encoded, encoding: "base64" }));
77
+ const tool = findTool(github.tools, "github_get_file");
78
+ const result = await tool.handler({ repo: "o/r", path: "README.md" }, ctx());
79
+ expect(result.decoded_content).toBe("hello world");
80
+ });
81
+ it("throws on HTTP error", async () => {
82
+ fetchMock.mockResolvedValueOnce(mockResponse("Not Found", false, 404));
83
+ const tool = findTool(github.tools, "github_search_code");
84
+ await expect(tool.handler({ query: "x", repo: "o/r" }, ctx())).rejects.toThrow("[GitHub]");
85
+ });
86
+ });
87
+ // ─── Linear skill tests ─────────────────────────────────────────
88
+ describe("linear skill", () => {
89
+ const ctx = () => mockCtx({ LINEAR_API_KEY: "lin_test" });
90
+ it("create_issue sends correct GraphQL mutation", async () => {
91
+ fetchMock.mockResolvedValueOnce(mockResponse({ data: { issueCreate: { success: true, issue: { id: "1" } } } }));
92
+ const tool = findTool(linear.tools, "linear_create_issue");
93
+ await tool.handler({ teamId: "team1", title: "Bug" }, ctx());
94
+ const [url, opts] = fetchMock.mock.calls[0];
95
+ expect(url).toBe("https://api.linear.app/graphql");
96
+ expect(opts.headers.Authorization).toBe("lin_test");
97
+ const body = JSON.parse(opts.body);
98
+ expect(body.query).toContain("issueCreate");
99
+ });
100
+ it("search_issues sends correct query", async () => {
101
+ fetchMock.mockResolvedValueOnce(mockResponse({ data: { searchIssues: { nodes: [] } } }));
102
+ const tool = findTool(linear.tools, "linear_search_issues");
103
+ await tool.handler({ query: "memory leak", limit: 5 }, ctx());
104
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
105
+ expect(body.variables.query).toBe("memory leak");
106
+ expect(body.variables.first).toBe(5);
107
+ });
108
+ it("update_issue sends correct mutation", async () => {
109
+ fetchMock.mockResolvedValueOnce(mockResponse({ data: { issueUpdate: { success: true } } }));
110
+ const tool = findTool(linear.tools, "linear_update_issue");
111
+ await tool.handler({ issueId: "id1", title: "Updated" }, ctx());
112
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
113
+ expect(body.variables.id).toBe("id1");
114
+ expect(body.variables.input.title).toBe("Updated");
115
+ });
116
+ it("throws on GraphQL errors", async () => {
117
+ fetchMock.mockResolvedValueOnce(mockResponse({ errors: [{ message: "Not found" }] }));
118
+ const tool = findTool(linear.tools, "linear_search_issues");
119
+ await expect(tool.handler({ query: "x" }, ctx())).rejects.toThrow("[Linear] GraphQL error");
120
+ });
121
+ it("throws on HTTP error", async () => {
122
+ fetchMock.mockResolvedValueOnce(mockResponse("Unauthorized", false, 401));
123
+ const tool = findTool(linear.tools, "linear_create_issue");
124
+ await expect(tool.handler({ teamId: "t", title: "x" }, ctx())).rejects.toThrow("[Linear]");
125
+ });
126
+ });
127
+ // ─── Slack skill tests ──────────────────────────────────────────
128
+ describe("slack skill", () => {
129
+ it("sends via webhook when only webhook is configured", async () => {
130
+ fetchMock.mockResolvedValueOnce(mockResponse("ok", true, 200));
131
+ const tool = findTool(slack.tools, "slack_send_message");
132
+ const ctx = mockCtx({ SLACK_WEBHOOK_URL: "https://hooks.slack.com/test" });
133
+ await tool.handler({ text: "Hello" }, ctx);
134
+ expect(fetchMock.mock.calls[0][0]).toBe("https://hooks.slack.com/test");
135
+ });
136
+ it("sends via bot token when bot token and channel are provided", async () => {
137
+ fetchMock.mockResolvedValueOnce(mockResponse({ ok: true, ts: "123" }));
138
+ const tool = findTool(slack.tools, "slack_send_message");
139
+ const ctx = mockCtx({ SLACK_BOT_TOKEN: "xoxb-test" });
140
+ const result = await tool.handler({ text: "Hello", channel: "C123" }, ctx);
141
+ expect(fetchMock.mock.calls[0][0]).toBe("https://slack.com/api/chat.postMessage");
142
+ expect(result.ok).toBe(true);
143
+ });
144
+ it("throws when Slack API returns ok:false", async () => {
145
+ fetchMock.mockResolvedValueOnce(mockResponse({ ok: false, error: "channel_not_found" }));
146
+ const tool = findTool(slack.tools, "slack_send_message");
147
+ const ctx = mockCtx({ SLACK_BOT_TOKEN: "xoxb-test" });
148
+ await expect(tool.handler({ text: "Hello", channel: "C123" }, ctx)).rejects.toThrow("channel_not_found");
149
+ });
150
+ it("throws when no credentials configured", async () => {
151
+ const tool = findTool(slack.tools, "slack_send_message");
152
+ const ctx = mockCtx({});
153
+ await expect(tool.handler({ text: "Hello" }, ctx)).rejects.toThrow("No Slack credentials");
154
+ });
155
+ it("thread_reply requires bot token", async () => {
156
+ const tool = findTool(slack.tools, "slack_send_thread_reply");
157
+ const ctx = mockCtx({});
158
+ await expect(tool.handler({ channel: "C1", thread_ts: "123", text: "Reply" }, ctx)).rejects.toThrow("SLACK_BOT_TOKEN");
159
+ });
160
+ it("thread_reply validates response", async () => {
161
+ fetchMock.mockResolvedValueOnce(mockResponse({ ok: false, error: "thread_not_found" }));
162
+ const tool = findTool(slack.tools, "slack_send_thread_reply");
163
+ const ctx = mockCtx({ SLACK_BOT_TOKEN: "xoxb-test" });
164
+ await expect(tool.handler({ channel: "C1", thread_ts: "123", text: "Reply" }, ctx)).rejects.toThrow("thread_not_found");
165
+ });
166
+ });
167
+ // ─── Sentry skill tests ─────────────────────────────────────────
168
+ describe("sentry skill", () => {
169
+ const ctx = () => mockCtx({ SENTRY_AUTH_TOKEN: "sntrys_test", SENTRY_ORG: "my-org" });
170
+ it("list_issues sends correct request", async () => {
171
+ fetchMock.mockResolvedValueOnce(mockResponse([]));
172
+ const tool = findTool(sentry.tools, "sentry_list_issues");
173
+ await tool.handler({ project: "my-project" }, ctx());
174
+ const [url, opts] = fetchMock.mock.calls[0];
175
+ expect(url).toContain("/projects/my-org/my-project/issues/");
176
+ expect(opts.headers.Authorization).toBe("Bearer sntrys_test");
177
+ });
178
+ it("get_issue sends correct request", async () => {
179
+ fetchMock.mockResolvedValueOnce(mockResponse({ id: "123" }));
180
+ const tool = findTool(sentry.tools, "sentry_get_issue");
181
+ await tool.handler({ issueId: "123" }, ctx());
182
+ expect(fetchMock.mock.calls[0][0]).toContain("/issues/123/");
183
+ });
184
+ it("uses SENTRY_BASE_URL when configured", async () => {
185
+ fetchMock.mockResolvedValueOnce(mockResponse([]));
186
+ const tool = findTool(sentry.tools, "sentry_list_issues");
187
+ const customCtx = mockCtx({
188
+ SENTRY_AUTH_TOKEN: "x",
189
+ SENTRY_ORG: "org",
190
+ SENTRY_BASE_URL: "https://sentry.example.com",
191
+ });
192
+ await tool.handler({ project: "p" }, customCtx);
193
+ expect(fetchMock.mock.calls[0][0]).toContain("https://sentry.example.com");
194
+ });
195
+ it("throws on HTTP error", async () => {
196
+ fetchMock.mockResolvedValueOnce(mockResponse("Forbidden", false, 403));
197
+ const tool = findTool(sentry.tools, "sentry_list_issues");
198
+ await expect(tool.handler({ project: "p" }, ctx())).rejects.toThrow("[Sentry]");
199
+ });
200
+ });
201
+ // ─── Datadog skill tests ────────────────────────────────────────
202
+ describe("datadog skill", () => {
203
+ const ctx = () => mockCtx({ DD_API_KEY: "dd_key", DD_APP_KEY: "dd_app" });
204
+ it("search_logs sends correct POST to v2 API", async () => {
205
+ fetchMock.mockResolvedValueOnce(mockResponse({ data: [] }));
206
+ const tool = findTool(datadog.tools, "datadog_search_logs");
207
+ await tool.handler({ query: "service:web status:error" }, ctx());
208
+ const [url, opts] = fetchMock.mock.calls[0];
209
+ expect(url).toContain("/api/v2/logs/events/search");
210
+ expect(opts.method).toBe("POST");
211
+ expect(opts.headers["DD-API-KEY"]).toBe("dd_key");
212
+ expect(opts.headers["DD-APPLICATION-KEY"]).toBe("dd_app");
213
+ });
214
+ it("query_metrics sends correct GET to v1 API", async () => {
215
+ fetchMock.mockResolvedValueOnce(mockResponse({ series: [] }));
216
+ const tool = findTool(datadog.tools, "datadog_query_metrics");
217
+ await tool.handler({ query: "avg:system.cpu.user{*}", from: 1000, to: 2000 }, ctx());
218
+ const url = fetchMock.mock.calls[0][0];
219
+ expect(url).toContain("/api/v1/query");
220
+ expect(url).toContain("from=1000");
221
+ expect(url).toContain("to=2000");
222
+ });
223
+ it("list_monitors sends correct GET", async () => {
224
+ fetchMock.mockResolvedValueOnce(mockResponse([]));
225
+ const tool = findTool(datadog.tools, "datadog_list_monitors");
226
+ await tool.handler({ name: "CPU" }, ctx());
227
+ const url = fetchMock.mock.calls[0][0];
228
+ expect(url).toContain("/api/v1/monitor");
229
+ expect(url).toContain("name=CPU");
230
+ });
231
+ it("uses DD_SITE for custom domain", async () => {
232
+ fetchMock.mockResolvedValueOnce(mockResponse([]));
233
+ const tool = findTool(datadog.tools, "datadog_list_monitors");
234
+ const euCtx = mockCtx({ DD_API_KEY: "k", DD_APP_KEY: "a", DD_SITE: "datadoghq.eu" });
235
+ await tool.handler({}, euCtx);
236
+ expect(fetchMock.mock.calls[0][0]).toContain("api.datadoghq.eu");
237
+ });
238
+ it("throws on HTTP error", async () => {
239
+ fetchMock.mockResolvedValueOnce(mockResponse("Unauthorized", false, 401));
240
+ const tool = findTool(datadog.tools, "datadog_search_logs");
241
+ await expect(tool.handler({ query: "x" }, ctx())).rejects.toThrow("[Datadog]");
242
+ });
243
+ });
244
+ // ─── Notification skill tests ───────────────────────────────────
245
+ describe("notification skill", () => {
246
+ it("notify_webhook sends POST to configured URL", async () => {
247
+ fetchMock.mockResolvedValueOnce(mockResponse("ok", true, 200));
248
+ const tool = findTool(notification.tools, "notify_webhook");
249
+ const ctx = mockCtx({ NOTIFICATION_WEBHOOK_URL: "https://example.com/hook" });
250
+ await tool.handler({ payload: { event: "test" } }, ctx);
251
+ expect(fetchMock.mock.calls[0][0]).toBe("https://example.com/hook");
252
+ });
253
+ it("notify_webhook allows URL override", async () => {
254
+ fetchMock.mockResolvedValueOnce(mockResponse("ok", true, 200));
255
+ const tool = findTool(notification.tools, "notify_webhook");
256
+ const ctx = mockCtx({});
257
+ await tool.handler({ url: "https://custom.com/hook", payload: { x: 1 } }, ctx);
258
+ expect(fetchMock.mock.calls[0][0]).toBe("https://custom.com/hook");
259
+ });
260
+ it("notify_webhook throws without URL", async () => {
261
+ const tool = findTool(notification.tools, "notify_webhook");
262
+ await expect(tool.handler({ payload: {} }, mockCtx({}))).rejects.toThrow("No webhook URL");
263
+ });
264
+ it("notify_discord sends to Discord webhook", async () => {
265
+ fetchMock.mockResolvedValueOnce(mockResponse(null, true, 204));
266
+ const tool = findTool(notification.tools, "notify_discord");
267
+ const ctx = mockCtx({ DISCORD_WEBHOOK_URL: "https://discord.com/api/webhooks/test" });
268
+ await tool.handler({ content: "Hello Discord" }, ctx);
269
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
270
+ expect(body.content).toBe("Hello Discord");
271
+ });
272
+ it("notify_discord throws without URL", async () => {
273
+ const tool = findTool(notification.tools, "notify_discord");
274
+ await expect(tool.handler({ content: "x" }, mockCtx({}))).rejects.toThrow("DISCORD_WEBHOOK_URL");
275
+ });
276
+ it("notify_teams sends MessageCard", async () => {
277
+ fetchMock.mockResolvedValueOnce(mockResponse("1", true, 200));
278
+ const tool = findTool(notification.tools, "notify_teams");
279
+ const ctx = mockCtx({ TEAMS_WEBHOOK_URL: "https://outlook.webhook.office.com/test" });
280
+ await tool.handler({ text: "Alert!", title: "SWEny" }, ctx);
281
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
282
+ expect(body["@type"]).toBe("MessageCard");
283
+ expect(body.title).toBe("SWEny");
284
+ });
285
+ it("notify_teams throws without URL", async () => {
286
+ const tool = findTool(notification.tools, "notify_teams");
287
+ await expect(tool.handler({ text: "x" }, mockCtx({}))).rejects.toThrow("TEAMS_WEBHOOK_URL");
288
+ });
289
+ it("all notification fetches have timeout signal", async () => {
290
+ fetchMock.mockResolvedValueOnce(mockResponse("ok", true, 200));
291
+ const tool = findTool(notification.tools, "notify_webhook");
292
+ const ctx = mockCtx({ NOTIFICATION_WEBHOOK_URL: "https://example.com" });
293
+ await tool.handler({ payload: {} }, ctx);
294
+ expect(fetchMock.mock.calls[0][1].signal).toBeDefined();
295
+ });
296
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,172 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { workflowToFlow, flowToWorkflow, applyExecutionEvent, exportAsTypescript, getSkillCatalog } from "../studio.js";
3
+ const testWorkflow = {
4
+ id: "test-studio",
5
+ name: "Studio Test",
6
+ description: "A→B, A→C",
7
+ entry: "a",
8
+ nodes: {
9
+ a: { name: "Node A", instruction: "Do A", skills: ["github"] },
10
+ b: { name: "Node B", instruction: "Do B", skills: [] },
11
+ c: { name: "Node C", instruction: "Do C", skills: ["slack"] },
12
+ },
13
+ edges: [
14
+ { from: "a", to: "b" },
15
+ { from: "a", to: "c", when: "needs notification" },
16
+ ],
17
+ };
18
+ describe("workflowToFlow", () => {
19
+ it("converts nodes to FlowNodes", () => {
20
+ const { nodes } = workflowToFlow(testWorkflow);
21
+ expect(nodes).toHaveLength(3);
22
+ const nodeA = nodes.find((n) => n.id === "a");
23
+ expect(nodeA?.type).toBe("skillNode");
24
+ expect(nodeA?.data.isEntry).toBe(true);
25
+ expect(nodeA?.data.nodeId).toBe("a");
26
+ expect(nodeA?.data.node.name).toBe("Node A");
27
+ });
28
+ it("marks terminal nodes", () => {
29
+ const { nodes } = workflowToFlow(testWorkflow);
30
+ const nodeB = nodes.find((n) => n.id === "b");
31
+ const nodeC = nodes.find((n) => n.id === "c");
32
+ expect(nodeB?.data.isTerminal).toBe(true);
33
+ expect(nodeC?.data.isTerminal).toBe(true);
34
+ // a is not terminal (it has outgoing edges)
35
+ const nodeA = nodes.find((n) => n.id === "a");
36
+ expect(nodeA?.data.isTerminal).toBe(false);
37
+ });
38
+ it("converts edges to FlowEdges", () => {
39
+ const { edges } = workflowToFlow(testWorkflow);
40
+ expect(edges).toHaveLength(2);
41
+ const unconditional = edges.find((e) => e.source === "a" && e.target === "b");
42
+ expect(unconditional?.data.isConditional).toBe(false);
43
+ expect(unconditional?.data.when).toBeUndefined();
44
+ const conditional = edges.find((e) => e.source === "a" && e.target === "c");
45
+ expect(conditional?.data.isConditional).toBe(true);
46
+ expect(conditional?.data.when).toBe("needs notification");
47
+ });
48
+ it("resolves skill metadata from catalog", () => {
49
+ const { nodes } = workflowToFlow(testWorkflow);
50
+ const nodeA = nodes.find((n) => n.id === "a");
51
+ expect(nodeA?.data.skills).toHaveLength(1);
52
+ expect(nodeA?.data.skills[0].id).toBe("github");
53
+ expect(nodeA?.data.skills[0].name).toBe("GitHub");
54
+ expect(nodeA?.data.skills[0].toolCount).toBeGreaterThan(0);
55
+ });
56
+ it("handles unknown skills gracefully", () => {
57
+ const { nodes } = workflowToFlow(testWorkflow, []); // empty catalog
58
+ const nodeA = nodes.find((n) => n.id === "a");
59
+ expect(nodeA?.data.skills[0].name).toBe("github"); // falls back to ID
60
+ expect(nodeA?.data.skills[0].toolCount).toBe(0);
61
+ });
62
+ it("initializes all nodes as pending", () => {
63
+ const { nodes } = workflowToFlow(testWorkflow);
64
+ for (const node of nodes) {
65
+ expect(node.data.exec.status).toBe("pending");
66
+ }
67
+ });
68
+ });
69
+ describe("flowToWorkflow", () => {
70
+ it("round-trips correctly", () => {
71
+ const { nodes, edges } = workflowToFlow(testWorkflow);
72
+ const rebuilt = flowToWorkflow({
73
+ id: testWorkflow.id,
74
+ name: testWorkflow.name,
75
+ description: testWorkflow.description,
76
+ entry: testWorkflow.entry,
77
+ }, nodes, edges);
78
+ expect(rebuilt.id).toBe(testWorkflow.id);
79
+ expect(rebuilt.entry).toBe(testWorkflow.entry);
80
+ expect(Object.keys(rebuilt.nodes)).toHaveLength(3);
81
+ expect(rebuilt.edges).toHaveLength(2);
82
+ expect(rebuilt.edges.find((e) => e.from === "a" && e.to === "c")?.when).toBe("needs notification");
83
+ });
84
+ it("omits when field for unconditional edges", () => {
85
+ const { nodes, edges } = workflowToFlow(testWorkflow);
86
+ const rebuilt = flowToWorkflow({ id: "x", name: "X", description: "", entry: "a" }, nodes, edges);
87
+ const unconditional = rebuilt.edges.find((e) => e.from === "a" && e.to === "b");
88
+ expect(unconditional).not.toHaveProperty("when");
89
+ });
90
+ });
91
+ describe("applyExecutionEvent", () => {
92
+ function makeNodeDataMap() {
93
+ const { nodes } = workflowToFlow(testWorkflow);
94
+ return new Map(nodes.map((n) => [n.id, n.data]));
95
+ }
96
+ it("resets all nodes on workflow:start", () => {
97
+ const map = makeNodeDataMap();
98
+ map.get("a").exec = { status: "success" };
99
+ applyExecutionEvent({ type: "workflow:start", workflow: "test" }, map);
100
+ expect(map.get("a").exec.status).toBe("pending");
101
+ });
102
+ it("marks node as running on node:enter", () => {
103
+ const map = makeNodeDataMap();
104
+ applyExecutionEvent({ type: "node:enter", node: "a", instruction: "Do A" }, map);
105
+ expect(map.get("a").exec.status).toBe("running");
106
+ });
107
+ it("tracks current tool on tool:call", () => {
108
+ const map = makeNodeDataMap();
109
+ map.get("a").exec = { status: "running" };
110
+ applyExecutionEvent({ type: "tool:call", node: "a", tool: "github_search_code", input: {} }, map);
111
+ expect(map.get("a").exec.currentTool).toBe("github_search_code");
112
+ });
113
+ it("clears current tool on tool:result", () => {
114
+ const map = makeNodeDataMap();
115
+ map.get("a").exec = { status: "running", currentTool: "github_search_code" };
116
+ applyExecutionEvent({ type: "tool:result", node: "a", tool: "github_search_code", output: {} }, map);
117
+ expect(map.get("a").exec.currentTool).toBeUndefined();
118
+ });
119
+ it("marks node as success on node:exit with success", () => {
120
+ const map = makeNodeDataMap();
121
+ const result = { status: "success", data: {}, toolCalls: [] };
122
+ applyExecutionEvent({ type: "node:exit", node: "a", result }, map);
123
+ expect(map.get("a").exec.status).toBe("success");
124
+ expect(map.get("a").exec.result).toBe(result);
125
+ });
126
+ it("marks node as failed on node:exit with failure", () => {
127
+ const map = makeNodeDataMap();
128
+ const result = { status: "failed", data: { error: "oops" }, toolCalls: [] };
129
+ applyExecutionEvent({ type: "node:exit", node: "a", result }, map);
130
+ expect(map.get("a").exec.status).toBe("failed");
131
+ });
132
+ it("ignores events for unknown nodes", () => {
133
+ const map = makeNodeDataMap();
134
+ // Should not throw
135
+ applyExecutionEvent({ type: "node:enter", node: "ghost", instruction: "" }, map);
136
+ expect(map.has("ghost")).toBe(false);
137
+ });
138
+ });
139
+ describe("exportAsTypescript", () => {
140
+ it("generates valid TypeScript", () => {
141
+ const ts = exportAsTypescript(testWorkflow);
142
+ expect(ts).toContain("import type { Workflow }");
143
+ expect(ts).toContain("test_studio");
144
+ expect(ts).toContain('"Studio Test"');
145
+ });
146
+ it("sanitizes id for variable name", () => {
147
+ const wf = { ...testWorkflow, id: "my-special_workflow.v2" };
148
+ const ts = exportAsTypescript(wf);
149
+ expect(ts).toContain("my_special_workflow_v2");
150
+ });
151
+ });
152
+ describe("getSkillCatalog", () => {
153
+ it("returns builtin skills", () => {
154
+ const catalog = getSkillCatalog();
155
+ expect(catalog.length).toBeGreaterThan(0);
156
+ const github = catalog.find((s) => s.id === "github");
157
+ expect(github).toBeDefined();
158
+ expect(github.tools.length).toBeGreaterThan(0);
159
+ });
160
+ it("includes extra skills", () => {
161
+ const extra = {
162
+ id: "custom",
163
+ name: "Custom",
164
+ description: "A custom skill",
165
+ category: "general",
166
+ config: {},
167
+ tools: [{ name: "custom_tool", description: "Does custom", input_schema: {}, handler: async () => { } }],
168
+ };
169
+ const catalog = getSkillCatalog([extra]);
170
+ expect(catalog.find((s) => s.id === "custom")).toBeDefined();
171
+ });
172
+ });
@@ -0,0 +1 @@
1
+ export {};