@goharvest/simforge 0.5.0 → 0.5.2

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.
@@ -1,6 +1,6 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import * as baml from "./baml.js";
3
- import { Simforge, SimforgeError } from "./client";
3
+ import { getCurrentSpan, Simforge } from "./client";
4
4
  // Mock fetch globally
5
5
  globalThis.fetch = vi.fn();
6
6
  // Mock BAML execution
@@ -16,9 +16,30 @@ describe("Simforge Client", () => {
16
16
  const client = new Simforge({ apiKey: "test-key" });
17
17
  expect(client).toBeInstanceOf(Simforge);
18
18
  });
19
- it("should create client even with empty apiKey (validation happens at call time)", () => {
19
+ it("should auto-disable tracing with empty apiKey and log warning", () => {
20
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
20
21
  const client = new Simforge({ apiKey: "" });
21
22
  expect(client).toBeInstanceOf(Simforge);
23
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("apiKey is empty"));
24
+ // Should behave as disabled — withSpan returns the original function
25
+ const fn = client.withSpan("test-key", () => "result");
26
+ expect(fn()).toBe("result");
27
+ warnSpy.mockRestore();
28
+ });
29
+ it("should auto-disable tracing with whitespace apiKey", () => {
30
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
31
+ const client = new Simforge({ apiKey: " " });
32
+ const fn = client.withSpan("test-key", () => "result");
33
+ expect(fn()).toBe("result");
34
+ warnSpy.mockRestore();
35
+ });
36
+ it("should not warn when explicitly disabled with empty apiKey", () => {
37
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
38
+ const client = new Simforge({ apiKey: "", enabled: false });
39
+ expect(warnSpy).not.toHaveBeenCalled();
40
+ const fn = client.withSpan("test-key", () => "result");
41
+ expect(fn()).toBe("result");
42
+ warnSpy.mockRestore();
22
43
  });
23
44
  it("should use custom serviceUrl", () => {
24
45
  const client = new Simforge({
@@ -41,14 +62,7 @@ describe("Simforge Client", () => {
41
62
  });
42
63
  expect(client).toBeInstanceOf(Simforge);
43
64
  });
44
- it("should accept executeLocally flag", () => {
45
- const client = new Simforge({
46
- apiKey: "test-key",
47
- executeLocally: false,
48
- });
49
- expect(client).toBeInstanceOf(Simforge);
50
- });
51
- describe("call method - local execution", () => {
65
+ describe("call method", () => {
52
66
  beforeEach(() => {
53
67
  // Reset BAML mock
54
68
  vi.mocked(baml.runFunctionWithBaml).mockReset();
@@ -118,7 +132,6 @@ function ExtractName(text: string) -> Name {
118
132
  });
119
133
  const client = new Simforge({
120
134
  apiKey: "test-key",
121
- executeLocally: true,
122
135
  envVars: { OPENAI_API_KEY: "test-openai-key" },
123
136
  });
124
137
  const result = await client.call("ExtractName", {
@@ -151,7 +164,6 @@ function ExtractName(text: string) -> Name {
151
164
  vi.mocked(baml.runFunctionWithBaml).mockRejectedValueOnce(new Error("Invalid argument: Expected type String, got Number(12345)"));
152
165
  const client = new Simforge({
153
166
  apiKey: "test-key",
154
- executeLocally: true,
155
167
  });
156
168
  await expect(
157
169
  // biome-ignore lint/suspicious/noExplicitAny: Testing wrong type handling
@@ -175,7 +187,6 @@ function ExtractName(text: string) -> Name {
175
187
  vi.mocked(baml.runFunctionWithBaml).mockRejectedValueOnce(new Error("Missing required parameter: text"));
176
188
  const client = new Simforge({
177
189
  apiKey: "test-key",
178
- executeLocally: true,
179
190
  });
180
191
  await expect(client.call("ExtractName", {})).rejects.toThrow("Missing required parameter");
181
192
  });
@@ -232,7 +243,6 @@ function ExtractPerson(text: string) -> Person {}
232
243
  });
233
244
  const client = new Simforge({
234
245
  apiKey: "test-key",
235
- executeLocally: true,
236
246
  });
237
247
  const result = (await client.call("ExtractPerson", {
238
248
  text: "John Doe, 30, john@example.com, 123 Main St, New York, 10001",
@@ -262,7 +272,6 @@ function ExtractPerson(text: string) -> Person {}
262
272
  });
263
273
  const client = new Simforge({
264
274
  apiKey: "test-key",
265
- executeLocally: true,
266
275
  });
267
276
  await expect(client.call("ExtractName", { text: "test" })).rejects.toThrow("has no prompt");
268
277
  });
@@ -295,7 +304,6 @@ function ExtractPerson(text: string) -> Person {}
295
304
  });
296
305
  const client = new Simforge({
297
306
  apiKey: "test-key",
298
- executeLocally: true,
299
307
  envVars: {
300
308
  OPENAI_API_KEY: "test-key",
301
309
  },
@@ -309,50 +317,6 @@ function ExtractPerson(text: string) -> Person {}
309
317
  });
310
318
  });
311
319
  });
312
- describe("call method - server-side execution", () => {
313
- it("should make successful API call", async () => {
314
- const mockResponse = "success";
315
- globalThis.fetch.mockResolvedValueOnce({
316
- ok: true,
317
- json: async () => ({ result: mockResponse }),
318
- });
319
- const client = new Simforge({
320
- apiKey: "test-key",
321
- executeLocally: false,
322
- });
323
- const result = await client.call("testMethod", { input: "test" });
324
- expect(result).toEqual(mockResponse);
325
- expect(globalThis.fetch).toHaveBeenCalledWith(expect.stringContaining("/api/sdk/call"), expect.objectContaining({
326
- method: "POST",
327
- headers: expect.objectContaining({
328
- Authorization: "Bearer test-key",
329
- "Content-Type": "application/json",
330
- }),
331
- }));
332
- });
333
- it("should handle API errors", async () => {
334
- ;
335
- globalThis.fetch.mockResolvedValueOnce({
336
- ok: false,
337
- status: 400,
338
- json: async () => ({ error: "Bad request" }),
339
- });
340
- const client = new Simforge({
341
- apiKey: "test-key",
342
- executeLocally: false,
343
- });
344
- await expect(client.call("testMethod", {})).rejects.toThrow(SimforgeError);
345
- });
346
- it("should handle network errors", async () => {
347
- ;
348
- globalThis.fetch.mockRejectedValueOnce(new Error("Network error"));
349
- const client = new Simforge({
350
- apiKey: "test-key",
351
- executeLocally: false,
352
- });
353
- await expect(client.call("testMethod", {})).rejects.toThrow(SimforgeError);
354
- });
355
- });
356
320
  describe("trace creation with source field", () => {
357
321
  it("should include source='typescript-sdk' by default when creating traces", async () => {
358
322
  const mockFetch = vi.mocked(fetch);
@@ -387,7 +351,6 @@ function ExtractPerson(text: string) -> Person {}
387
351
  });
388
352
  const client = new Simforge({
389
353
  apiKey: "test-key",
390
- executeLocally: true,
391
354
  });
392
355
  await client.call("testMethod", {});
393
356
  // Verify trace creation was called with source field
@@ -440,7 +403,6 @@ function ExtractPerson(text: string) -> Person {}
440
403
  });
441
404
  const client = new Simforge({
442
405
  apiKey: "test-key",
443
- executeLocally: true,
444
406
  });
445
407
  await client.call("testMethod", {});
446
408
  // Verify trace creation includes both source and rawCollector
@@ -912,6 +874,264 @@ function ExtractPerson(text: string) -> Person {}
912
874
  expect(traceBody.rawSpan.span_data.output).toBe(8);
913
875
  });
914
876
  });
877
+ describe("enabled option", () => {
878
+ it("should default enabled to true", () => {
879
+ const mockFetch = vi.mocked(fetch);
880
+ mockFetch.mockResolvedValueOnce({
881
+ ok: true,
882
+ status: 200,
883
+ json: async () => ({ traceId: "enabled-default-trace" }),
884
+ });
885
+ const client = new Simforge({ apiKey: "test-key" });
886
+ const fn = async () => "result";
887
+ const wrappedFn = client.withSpan("test-service", fn);
888
+ // If enabled defaults to true, the wrapped function should send spans
889
+ // We can verify by checking that it's not the same reference as the original
890
+ expect(wrappedFn).not.toBe(fn);
891
+ });
892
+ it("should return unwrapped function when enabled is false", () => {
893
+ const client = new Simforge({ apiKey: "test-key", enabled: false });
894
+ const fn = async () => "result";
895
+ const wrappedFn = client.withSpan("test-service", fn);
896
+ // When disabled, withSpan should return the original function
897
+ expect(wrappedFn).toBe(fn);
898
+ });
899
+ it("should not send spans when enabled is false", async () => {
900
+ const mockFetch = vi.mocked(fetch);
901
+ mockFetch.mockClear();
902
+ const client = new Simforge({ apiKey: "test-key", enabled: false });
903
+ const fn = async (x) => x * 2;
904
+ const wrappedFn = client.withSpan("test-service", fn);
905
+ const result = await wrappedFn(5);
906
+ expect(result).toBe(10);
907
+ // Wait for any potential background traces
908
+ await new Promise((resolve) => setTimeout(resolve, 50));
909
+ // No fetch calls should have been made
910
+ const spanCalls = mockFetch.mock.calls.filter((call) => call[0].toString().includes("/sdk/externalSpans"));
911
+ expect(spanCalls.length).toBe(0);
912
+ });
913
+ it("should return unwrapped function via getFunction when enabled is false", () => {
914
+ const client = new Simforge({ apiKey: "test-key", enabled: false });
915
+ const func = client.getFunction("my-function");
916
+ const fn = async () => "result";
917
+ const wrappedFn = func.withSpan(fn);
918
+ // When disabled, should return the original function
919
+ expect(wrappedFn).toBe(fn);
920
+ });
921
+ it("should return unwrapped function with options when enabled is false", () => {
922
+ const client = new Simforge({ apiKey: "test-key", enabled: false });
923
+ const fn = async () => "result";
924
+ const wrappedFn = client.withSpan("test-service", { type: "function" }, fn);
925
+ expect(wrappedFn).toBe(fn);
926
+ });
927
+ });
928
+ describe("metadata", () => {
929
+ it("metadata is included in span_data when provided", async () => {
930
+ const mockFetch = vi.mocked(fetch);
931
+ mockFetch.mockResolvedValue({
932
+ ok: true,
933
+ status: 200,
934
+ json: async () => ({ traceId: "metadata-trace" }),
935
+ });
936
+ const client = new Simforge({ apiKey: "test-key" });
937
+ async function processOrder(id) {
938
+ return { id };
939
+ }
940
+ const wrapped = client.withSpan("my-service", {
941
+ type: "function",
942
+ metadata: { user_id: "u-123", region: "us-east" },
943
+ }, processOrder);
944
+ await wrapped("123");
945
+ await new Promise((resolve) => setTimeout(resolve, 10));
946
+ const spanCall = mockFetch.mock.calls.find((c) => String(c[0]).includes("/sdk/externalSpans"));
947
+ expect(spanCall).toBeDefined();
948
+ const traceBody = JSON.parse(spanCall[1]?.body ?? "{}");
949
+ const spanData = traceBody.rawSpan
950
+ .span_data;
951
+ expect(spanData.metadata).toEqual({ user_id: "u-123", region: "us-east" });
952
+ });
953
+ it("metadata is omitted from span_data when not provided", async () => {
954
+ const mockFetch = vi.mocked(fetch);
955
+ mockFetch.mockResolvedValue({
956
+ ok: true,
957
+ status: 200,
958
+ json: async () => ({ traceId: "no-metadata-trace" }),
959
+ });
960
+ const client = new Simforge({ apiKey: "test-key" });
961
+ async function myFn() {
962
+ return "result";
963
+ }
964
+ const wrapped = client.withSpan("my-service", myFn);
965
+ await wrapped();
966
+ await new Promise((resolve) => setTimeout(resolve, 10));
967
+ const spanCall = mockFetch.mock.calls.find((c) => String(c[0]).includes("/sdk/externalSpans"));
968
+ expect(spanCall).toBeDefined();
969
+ const traceBody = JSON.parse(spanCall[1]?.body ?? "{}");
970
+ const spanData = traceBody.rawSpan
971
+ .span_data;
972
+ expect(spanData.metadata).toBeUndefined();
973
+ });
974
+ it("metadata works with getFunction fluent API", async () => {
975
+ const mockFetch = vi.mocked(fetch);
976
+ mockFetch.mockResolvedValue({
977
+ ok: true,
978
+ status: 200,
979
+ json: async () => ({ traceId: "fluent-metadata-trace" }),
980
+ });
981
+ const client = new Simforge({ apiKey: "test-key" });
982
+ const service = client.getFunction("order-processing");
983
+ async function processOrder(id) {
984
+ return { id };
985
+ }
986
+ const wrapped = service.withSpan({
987
+ type: "function",
988
+ metadata: { env: "staging", version: "1.2.3" },
989
+ }, processOrder);
990
+ await wrapped("123");
991
+ await new Promise((resolve) => setTimeout(resolve, 10));
992
+ const spanCall = mockFetch.mock.calls.find((c) => String(c[0]).includes("/sdk/externalSpans"));
993
+ expect(spanCall).toBeDefined();
994
+ const traceBody = JSON.parse(spanCall[1]?.body ?? "{}");
995
+ const spanData = traceBody.rawSpan
996
+ .span_data;
997
+ expect(spanData.metadata).toEqual({ env: "staging", version: "1.2.3" });
998
+ });
999
+ });
1000
+ describe("runtime metadata (getCurrentSpan)", () => {
1001
+ it("sets metadata at runtime from inside a span", async () => {
1002
+ const mockFetch = vi.mocked(fetch);
1003
+ mockFetch.mockResolvedValue({
1004
+ ok: true,
1005
+ status: 200,
1006
+ json: async () => ({ traceId: "runtime-meta-trace" }),
1007
+ });
1008
+ const client = new Simforge({ apiKey: "test-key" });
1009
+ async function processOrder(id) {
1010
+ getCurrentSpan()?.setMetadata({
1011
+ request_id: "req-789",
1012
+ user_id: "u-456",
1013
+ });
1014
+ return { id };
1015
+ }
1016
+ const wrapped = client.withSpan("my-service", { type: "function" }, processOrder);
1017
+ await wrapped("123");
1018
+ await new Promise((resolve) => setTimeout(resolve, 10));
1019
+ const spanCall = mockFetch.mock.calls.find((c) => String(c[0]).includes("/sdk/externalSpans"));
1020
+ expect(spanCall).toBeDefined();
1021
+ const traceBody = JSON.parse(spanCall[1]?.body ?? "{}");
1022
+ const spanData = traceBody.rawSpan
1023
+ .span_data;
1024
+ expect(spanData.metadata).toEqual({
1025
+ request_id: "req-789",
1026
+ user_id: "u-456",
1027
+ });
1028
+ });
1029
+ it("merges runtime metadata with definition-time metadata (runtime wins)", async () => {
1030
+ const mockFetch = vi.mocked(fetch);
1031
+ mockFetch.mockResolvedValue({
1032
+ ok: true,
1033
+ status: 200,
1034
+ json: async () => ({ traceId: "merge-meta-trace" }),
1035
+ });
1036
+ const client = new Simforge({ apiKey: "test-key" });
1037
+ async function processOrder(id) {
1038
+ getCurrentSpan()?.setMetadata({
1039
+ region: "eu-west",
1040
+ request_id: "req-789",
1041
+ });
1042
+ return { id };
1043
+ }
1044
+ const wrapped = client.withSpan("my-service", { type: "function", metadata: { user_id: "u-123", region: "us-east" } }, processOrder);
1045
+ await wrapped("123");
1046
+ await new Promise((resolve) => setTimeout(resolve, 10));
1047
+ const spanCall = mockFetch.mock.calls.find((c) => String(c[0]).includes("/sdk/externalSpans"));
1048
+ expect(spanCall).toBeDefined();
1049
+ const traceBody = JSON.parse(spanCall[1]?.body ?? "{}");
1050
+ const spanData = traceBody.rawSpan
1051
+ .span_data;
1052
+ expect(spanData.metadata).toEqual({
1053
+ user_id: "u-123",
1054
+ region: "eu-west",
1055
+ request_id: "req-789",
1056
+ });
1057
+ });
1058
+ it("getCurrentSpan returns null outside of a span", () => {
1059
+ expect(getCurrentSpan()).toBeNull();
1060
+ });
1061
+ it("works with getFunction fluent API", async () => {
1062
+ const mockFetch = vi.mocked(fetch);
1063
+ mockFetch.mockResolvedValue({
1064
+ ok: true,
1065
+ status: 200,
1066
+ json: async () => ({ traceId: "fluent-runtime-meta" }),
1067
+ });
1068
+ const client = new Simforge({ apiKey: "test-key" });
1069
+ const service = client.getFunction("order-processing");
1070
+ async function processOrder(id) {
1071
+ getCurrentSpan()?.setMetadata({ computed_at: "2024-01-01" });
1072
+ return { id };
1073
+ }
1074
+ const wrapped = service.withSpan({ type: "function" }, processOrder);
1075
+ await wrapped("123");
1076
+ await new Promise((resolve) => setTimeout(resolve, 10));
1077
+ const spanCall = mockFetch.mock.calls.find((c) => String(c[0]).includes("/sdk/externalSpans"));
1078
+ expect(spanCall).toBeDefined();
1079
+ const traceBody = JSON.parse(spanCall[1]?.body ?? "{}");
1080
+ const spanData = traceBody.rawSpan
1081
+ .span_data;
1082
+ expect(spanData.metadata).toEqual({ computed_at: "2024-01-01" });
1083
+ });
1084
+ });
1085
+ describe("error resilience", () => {
1086
+ it("should not crash the host app when span sending throws synchronously", async () => {
1087
+ const mockFetch = vi.mocked(fetch);
1088
+ // Make fetch throw synchronously (simulating serialization failure)
1089
+ mockFetch.mockImplementation(() => {
1090
+ throw new Error("Serialization error");
1091
+ });
1092
+ const client = new Simforge({ apiKey: "test-key" });
1093
+ const fn = async (x) => x * 2;
1094
+ const wrappedFn = client.withSpan("resilient-service", fn);
1095
+ // Function should still return normally
1096
+ const result = await wrappedFn(5);
1097
+ expect(result).toBe(10);
1098
+ });
1099
+ it("should propagate user errors even when SDK has internal errors", async () => {
1100
+ const mockFetch = vi.mocked(fetch);
1101
+ mockFetch.mockImplementation(() => {
1102
+ throw new Error("SDK internal error");
1103
+ });
1104
+ const client = new Simforge({ apiKey: "test-key" });
1105
+ const failingFn = async () => {
1106
+ throw new Error("User error");
1107
+ };
1108
+ const wrappedFn = client.withSpan("error-service", failingFn);
1109
+ await expect(wrappedFn()).rejects.toThrow("User error");
1110
+ });
1111
+ it("should not crash with sync functions when span sending throws", () => {
1112
+ const mockFetch = vi.mocked(fetch);
1113
+ mockFetch.mockImplementation(() => {
1114
+ throw new Error("Serialization error");
1115
+ });
1116
+ const client = new Simforge({ apiKey: "test-key" });
1117
+ const fn = (a, b) => a + b;
1118
+ const wrappedFn = client.withSpan("sync-resilient", fn);
1119
+ const result = wrappedFn(5, 3);
1120
+ expect(result).toBe(8);
1121
+ });
1122
+ it("should propagate user errors from sync functions even when SDK has internal errors", () => {
1123
+ const mockFetch = vi.mocked(fetch);
1124
+ mockFetch.mockImplementation(() => {
1125
+ throw new Error("SDK internal error");
1126
+ });
1127
+ const client = new Simforge({ apiKey: "test-key" });
1128
+ const failingFn = () => {
1129
+ throw new Error("Sync user error");
1130
+ };
1131
+ const wrappedFn = client.withSpan("sync-error-service", failingFn);
1132
+ expect(() => wrappedFn()).toThrow("Sync user error");
1133
+ });
1134
+ });
915
1135
  describe("getFunction", () => {
916
1136
  it("should return a SimforgeFunction instance", () => {
917
1137
  const client = new Simforge({ apiKey: "test-key" });