@sundaeswap/sprinkles 0.1.1

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 (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +260 -0
  3. package/dist/cjs/Sprinkle/__tests__/bigint-reviver.test.js +40 -0
  4. package/dist/cjs/Sprinkle/__tests__/bigint-reviver.test.js.map +1 -0
  5. package/dist/cjs/Sprinkle/__tests__/encryption.test.js +267 -0
  6. package/dist/cjs/Sprinkle/__tests__/encryption.test.js.map +1 -0
  7. package/dist/cjs/Sprinkle/__tests__/enhancements.test.js +147 -0
  8. package/dist/cjs/Sprinkle/__tests__/enhancements.test.js.map +1 -0
  9. package/dist/cjs/Sprinkle/__tests__/extract-message.test.js +60 -0
  10. package/dist/cjs/Sprinkle/__tests__/extract-message.test.js.map +1 -0
  11. package/dist/cjs/Sprinkle/__tests__/fill-in-struct.test.js +131 -0
  12. package/dist/cjs/Sprinkle/__tests__/fill-in-struct.test.js.map +1 -0
  13. package/dist/cjs/Sprinkle/__tests__/schemas.test.js +184 -0
  14. package/dist/cjs/Sprinkle/__tests__/schemas.test.js.map +1 -0
  15. package/dist/cjs/Sprinkle/__tests__/settings-persistence.test.js +199 -0
  16. package/dist/cjs/Sprinkle/__tests__/settings-persistence.test.js.map +1 -0
  17. package/dist/cjs/Sprinkle/__tests__/show-menu.test.js +108 -0
  18. package/dist/cjs/Sprinkle/__tests__/show-menu.test.js.map +1 -0
  19. package/dist/cjs/Sprinkle/__tests__/test-helpers.js +16 -0
  20. package/dist/cjs/Sprinkle/__tests__/test-helpers.js.map +1 -0
  21. package/dist/cjs/Sprinkle/__tests__/tx-dialog.test.js +271 -0
  22. package/dist/cjs/Sprinkle/__tests__/tx-dialog.test.js.map +1 -0
  23. package/dist/cjs/Sprinkle/index.js +954 -0
  24. package/dist/cjs/Sprinkle/index.js.map +1 -0
  25. package/dist/cjs/index.js +17 -0
  26. package/dist/cjs/index.js.map +1 -0
  27. package/dist/cjs/package.json +1 -0
  28. package/dist/esm/Sprinkle/__tests__/bigint-reviver.test.js +38 -0
  29. package/dist/esm/Sprinkle/__tests__/bigint-reviver.test.js.map +1 -0
  30. package/dist/esm/Sprinkle/__tests__/encryption.test.js +264 -0
  31. package/dist/esm/Sprinkle/__tests__/encryption.test.js.map +1 -0
  32. package/dist/esm/Sprinkle/__tests__/enhancements.test.js +145 -0
  33. package/dist/esm/Sprinkle/__tests__/enhancements.test.js.map +1 -0
  34. package/dist/esm/Sprinkle/__tests__/extract-message.test.js +58 -0
  35. package/dist/esm/Sprinkle/__tests__/extract-message.test.js.map +1 -0
  36. package/dist/esm/Sprinkle/__tests__/fill-in-struct.test.js +130 -0
  37. package/dist/esm/Sprinkle/__tests__/fill-in-struct.test.js.map +1 -0
  38. package/dist/esm/Sprinkle/__tests__/schemas.test.js +182 -0
  39. package/dist/esm/Sprinkle/__tests__/schemas.test.js.map +1 -0
  40. package/dist/esm/Sprinkle/__tests__/settings-persistence.test.js +196 -0
  41. package/dist/esm/Sprinkle/__tests__/settings-persistence.test.js.map +1 -0
  42. package/dist/esm/Sprinkle/__tests__/show-menu.test.js +106 -0
  43. package/dist/esm/Sprinkle/__tests__/show-menu.test.js.map +1 -0
  44. package/dist/esm/Sprinkle/__tests__/test-helpers.js +10 -0
  45. package/dist/esm/Sprinkle/__tests__/test-helpers.js.map +1 -0
  46. package/dist/esm/Sprinkle/__tests__/tx-dialog.test.js +269 -0
  47. package/dist/esm/Sprinkle/__tests__/tx-dialog.test.js.map +1 -0
  48. package/dist/esm/Sprinkle/index.js +928 -0
  49. package/dist/esm/Sprinkle/index.js.map +1 -0
  50. package/dist/esm/index.js +2 -0
  51. package/dist/esm/index.js.map +1 -0
  52. package/dist/types/Sprinkle/index.d.ts +205 -0
  53. package/dist/types/Sprinkle/index.d.ts.map +1 -0
  54. package/dist/types/index.d.ts +2 -0
  55. package/dist/types/index.d.ts.map +1 -0
  56. package/dist/types/tsconfig.build.tsbuildinfo +1 -0
  57. package/package.json +85 -0
  58. package/src/Sprinkle/__tests__/bigint-reviver.test.ts +49 -0
  59. package/src/Sprinkle/__tests__/encryption.test.ts +266 -0
  60. package/src/Sprinkle/__tests__/enhancements.test.ts +154 -0
  61. package/src/Sprinkle/__tests__/extract-message.test.ts +60 -0
  62. package/src/Sprinkle/__tests__/fill-in-struct.test.ts +159 -0
  63. package/src/Sprinkle/__tests__/schemas.test.ts +215 -0
  64. package/src/Sprinkle/__tests__/settings-persistence.test.ts +181 -0
  65. package/src/Sprinkle/__tests__/show-menu.test.ts +123 -0
  66. package/src/Sprinkle/__tests__/test-helpers.ts +14 -0
  67. package/src/Sprinkle/__tests__/tx-dialog.test.ts +293 -0
  68. package/src/Sprinkle/index.ts +1215 -0
  69. package/src/index.ts +1 -0
package/package.json ADDED
@@ -0,0 +1,85 @@
1
+ {
2
+ "name": "@sundaeswap/sprinkles",
3
+ "version": "0.1.1",
4
+ "description": "A TypeScript library for building interactive CLI menus and TUI applications with TypeBox schema validation",
5
+ "type": "module",
6
+ "main": "./dist/cjs/index.js",
7
+ "module": "./dist/esm/index.js",
8
+ "types": "./dist/types/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "require": "./dist/cjs/index.js",
12
+ "import": "./dist/esm/index.js",
13
+ "types": "./dist/types/index.d.ts"
14
+ }
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "src",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "engineStrict": true,
26
+ "engines": {
27
+ "node": "^22"
28
+ },
29
+ "scripts": {
30
+ "clean": "rm -rf ./dist",
31
+ "build:esm": "cross-env BABEL_ENV=esmUnbundled babel src --extensions '.ts,.tsx' --out-dir './dist/esm' --source-maps",
32
+ "build:cjs": "cross-env BABEL_ENV=cjs babel src --extensions '.ts,.tsx' --out-dir 'dist/cjs' --source-maps",
33
+ "types": "tsc --project ./tsconfig.build.json",
34
+ "build": "bun clean && bun types && bun build:esm && bun build:cjs && bun set-cjs",
35
+ "watch": "bun clean && bun run build && bun build:esm --watch",
36
+ "set-cjs": "echo '{ \"type\": \"commonjs\" }' > ./dist/cjs/package.json",
37
+ "test": "bun test src/",
38
+ "prepublishOnly": "bun run build",
39
+ "changeset": "changeset",
40
+ "version": "changeset version",
41
+ "release": "bun run build && changeset publish"
42
+ },
43
+ "keywords": [
44
+ "cli",
45
+ "tui",
46
+ "terminal",
47
+ "interactive",
48
+ "menu",
49
+ "typebox",
50
+ "inquirer",
51
+ "cardano"
52
+ ],
53
+ "author": "SundaeSwap Labs",
54
+ "license": "MIT",
55
+ "repository": {
56
+ "type": "git",
57
+ "url": "git+https://github.com/SundaeSwap-finance/sprinkles.git"
58
+ },
59
+ "homepage": "https://github.com/SundaeSwap-finance/sprinkles#readme",
60
+ "bugs": {
61
+ "url": "https://github.com/SundaeSwap-finance/sprinkles/issues"
62
+ },
63
+ "peerDependencies": {
64
+ "typescript": "^5.8.3",
65
+ "@blaze-cardano/sdk": "^0.2.33",
66
+ "@blaze-cardano/query": "^0.5.0"
67
+ },
68
+ "devDependencies": {
69
+ "@changesets/cli": "^2.27.1",
70
+ "@babel/cli": "^7.27.2",
71
+ "@babel/core": "^7.27.4",
72
+ "@babel/plugin-proposal-class-properties": "^7.18.6",
73
+ "@babel/preset-env": "^7.27.2",
74
+ "@babel/preset-typescript": "^7.27.1",
75
+ "@types/bun": "latest",
76
+ "@types/node": "^20.0.0",
77
+ "cross-env": "^7.0.3",
78
+ "typescript": "^5.8.3"
79
+ },
80
+ "dependencies": {
81
+ "@inquirer/prompts": "^7.5.3",
82
+ "@sinclair/typebox": "^0.34.41",
83
+ "clipboardy": "^5.2.1"
84
+ }
85
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Sprinkle } from "../index.js";
3
+
4
+ describe("bigIntReviver", () => {
5
+ test("converts string ending with n to BigInt", () => {
6
+ expect(Sprinkle.bigIntReviver("key", "42n")).toBe(42n);
7
+ });
8
+
9
+ test("converts zero bigint string", () => {
10
+ expect(Sprinkle.bigIntReviver("key", "0n")).toBe(0n);
11
+ });
12
+
13
+ test("converts large bigint string", () => {
14
+ expect(Sprinkle.bigIntReviver("key", "99999999999999999999n")).toBe(
15
+ 99999999999999999999n,
16
+ );
17
+ });
18
+
19
+ test("does not convert regular strings", () => {
20
+ expect(Sprinkle.bigIntReviver("key", "hello")).toBe("hello");
21
+ });
22
+
23
+ test("does not convert strings with n in the middle", () => {
24
+ expect(Sprinkle.bigIntReviver("key", "12n34")).toBe("12n34");
25
+ });
26
+
27
+ test("does not convert non-numeric n-suffixed strings", () => {
28
+ expect(Sprinkle.bigIntReviver("key", "abcn")).toBe("abcn");
29
+ });
30
+
31
+ test("passes through numbers unchanged", () => {
32
+ expect(Sprinkle.bigIntReviver("key", 42)).toBe(42);
33
+ });
34
+
35
+ test("passes through booleans unchanged", () => {
36
+ expect(Sprinkle.bigIntReviver("key", true)).toBe(true);
37
+ });
38
+
39
+ test("passes through null unchanged", () => {
40
+ expect(Sprinkle.bigIntReviver("key", null)).toBe(null);
41
+ });
42
+
43
+ test("works with JSON.parse", () => {
44
+ const json = '{"amount": "100n", "name": "test"}';
45
+ const parsed = JSON.parse(json, Sprinkle.bigIntReviver);
46
+ expect(parsed.amount).toBe(100n);
47
+ expect(parsed.name).toBe("test");
48
+ });
49
+ });
@@ -0,0 +1,266 @@
1
+ import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test";
2
+ import { Sprinkle, Type } from "../index.js";
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ import * as os from "os";
6
+ import { withProfile } from "./test-helpers.js";
7
+
8
+ const mockSelect = mock();
9
+ const mockInput = mock();
10
+ const mockPassword = mock();
11
+
12
+ mock.module("@inquirer/prompts", () => ({
13
+ select: mockSelect,
14
+ input: mockInput,
15
+ password: mockPassword,
16
+ }));
17
+
18
+ describe("Encryption & Sensitive Fields", () => {
19
+ let tmpDir: string;
20
+
21
+ beforeEach(() => {
22
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sprinkles-enc-test-"));
23
+ mockSelect.mockClear();
24
+ mockInput.mockClear();
25
+ mockPassword.mockClear();
26
+ });
27
+
28
+ afterEach(() => {
29
+ fs.rmSync(tmpDir, { recursive: true, force: true });
30
+ });
31
+
32
+ describe("sensitive field prompts", () => {
33
+ test("uses password() for sensitive string fields", async () => {
34
+ const schema = Type.Object({
35
+ secret: Type.String({ sensitive: true, title: "Enter secret" }),
36
+ });
37
+ const sprinkle = new Sprinkle(schema, tmpDir);
38
+
39
+ mockPassword.mockResolvedValueOnce("my-secret");
40
+
41
+ const result = await sprinkle.FillInStruct(schema);
42
+ expect(result).toEqual({ secret: "my-secret" });
43
+ expect(mockPassword).toHaveBeenCalledTimes(1);
44
+ expect(mockPassword.mock.calls[0][0].message).toBe("Enter secret");
45
+ expect(mockInput).not.toHaveBeenCalled();
46
+ });
47
+
48
+ test("uses input() for non-sensitive string fields", async () => {
49
+ const schema = Type.Object({
50
+ name: Type.String({ title: "Enter name" }),
51
+ });
52
+ const sprinkle = new Sprinkle(schema, tmpDir);
53
+
54
+ mockInput.mockResolvedValueOnce("visible");
55
+
56
+ const result = await sprinkle.FillInStruct(schema);
57
+ expect(result).toEqual({ name: "visible" });
58
+ expect(mockInput).toHaveBeenCalledTimes(1);
59
+ expect(mockPassword).not.toHaveBeenCalled();
60
+ });
61
+
62
+ test("does not remember sensitive field as default", async () => {
63
+ const schema = Type.String({ sensitive: true });
64
+ const sprinkle = new Sprinkle(Type.Object({ p: Type.String() }), tmpDir);
65
+
66
+ mockPassword.mockResolvedValueOnce("secret-val");
67
+
68
+ await sprinkle.FillInStruct(schema);
69
+ expect(sprinkle.defaults["string"]).toBeUndefined();
70
+ });
71
+ });
72
+
73
+ describe("encryption on save/load", () => {
74
+ test("encrypts sensitive fields when saving with encryption configured", () => {
75
+ const schema = Type.Object({
76
+ name: Type.String(),
77
+ secret: Type.String({ sensitive: true }),
78
+ });
79
+ const sprinkle = withProfile(
80
+ new Sprinkle(schema, tmpDir, {
81
+ encryption: {
82
+ encrypt: (plain) => `ENC:${plain}`,
83
+ decrypt: async (cipher) => cipher.replace("ENC:", ""),
84
+ },
85
+ }),
86
+ );
87
+ sprinkle.settings = { name: "visible", secret: "hidden" } as any;
88
+
89
+ sprinkle.saveSettings();
90
+
91
+ const content = fs.readFileSync(
92
+ path.join(tmpDir, "profiles", "test.json"),
93
+ "utf-8",
94
+ );
95
+ const parsed = JSON.parse(content);
96
+ expect(parsed.settings.name).toBe("visible");
97
+ expect(parsed.settings.secret).toBe("ENC:hidden");
98
+ });
99
+
100
+ test("decrypts sensitive fields when loading with encryption configured", async () => {
101
+ const schema = Type.Object({
102
+ name: Type.String(),
103
+ secret: Type.String({ sensitive: true }),
104
+ });
105
+
106
+ // First save with encryption
107
+ const sprinkle1 = withProfile(
108
+ new Sprinkle(schema, tmpDir, {
109
+ encryption: {
110
+ encrypt: (plain) => `ENC:${plain}`,
111
+ decrypt: async (cipher) => cipher.replace("ENC:", ""),
112
+ },
113
+ }),
114
+ );
115
+ sprinkle1.settings = { name: "visible", secret: "hidden" } as any;
116
+ sprinkle1.saveSettings();
117
+
118
+ // Then load with same encryption
119
+ const sprinkle2 = new Sprinkle(schema, tmpDir, {
120
+ encryption: {
121
+ encrypt: (plain) => `ENC:${plain}`,
122
+ decrypt: async (cipher) => cipher.replace("ENC:", ""),
123
+ },
124
+ });
125
+ await sprinkle2.loadProfile("test");
126
+
127
+ expect(sprinkle2.settings).toEqual({ name: "visible", secret: "hidden" });
128
+ });
129
+
130
+ test("does not encrypt when no encryption configured", () => {
131
+ const schema = Type.Object({
132
+ secret: Type.String({ sensitive: true }),
133
+ });
134
+ const sprinkle = withProfile(new Sprinkle(schema, tmpDir));
135
+ sprinkle.settings = { secret: "plain-value" } as any;
136
+
137
+ sprinkle.saveSettings();
138
+
139
+ const content = fs.readFileSync(
140
+ path.join(tmpDir, "profiles", "test.json"),
141
+ "utf-8",
142
+ );
143
+ const parsed = JSON.parse(content);
144
+ expect(parsed.settings.secret).toBe("plain-value");
145
+ });
146
+
147
+ test("encrypts nested sensitive fields", () => {
148
+ const schema = Type.Object({
149
+ wallet: Type.Object({
150
+ key: Type.String({ sensitive: true }),
151
+ address: Type.String(),
152
+ }),
153
+ });
154
+ const sprinkle = withProfile(
155
+ new Sprinkle(schema, tmpDir, {
156
+ encryption: {
157
+ encrypt: (plain) => `ENC:${plain}`,
158
+ decrypt: async (cipher) => cipher.replace("ENC:", ""),
159
+ },
160
+ }),
161
+ );
162
+ sprinkle.settings = {
163
+ wallet: { key: "secret-key", address: "addr1..." },
164
+ } as any;
165
+
166
+ sprinkle.saveSettings();
167
+
168
+ const content = fs.readFileSync(
169
+ path.join(tmpDir, "profiles", "test.json"),
170
+ "utf-8",
171
+ );
172
+ const parsed = JSON.parse(content);
173
+ expect(parsed.settings.wallet.key).toBe("ENC:secret-key");
174
+ expect(parsed.settings.wallet.address).toBe("addr1...");
175
+ });
176
+
177
+ test("handles sensitive fields in union variants", () => {
178
+ const schema = Type.Union([
179
+ Type.Object({
180
+ type: Type.Literal("hot"),
181
+ privateKey: Type.String({ sensitive: true }),
182
+ }),
183
+ Type.Object({
184
+ type: Type.Literal("cold"),
185
+ address: Type.String(),
186
+ }),
187
+ ]);
188
+ const outerSchema = Type.Object({ wallet: schema });
189
+ const sprinkle = withProfile(
190
+ new Sprinkle(outerSchema, tmpDir, {
191
+ encryption: {
192
+ encrypt: (plain) => `ENC:${plain}`,
193
+ decrypt: async (cipher) => cipher.replace("ENC:", ""),
194
+ },
195
+ }),
196
+ );
197
+ sprinkle.settings = {
198
+ wallet: { type: "hot", privateKey: "my-key" },
199
+ } as any;
200
+
201
+ sprinkle.saveSettings();
202
+
203
+ const content = fs.readFileSync(
204
+ path.join(tmpDir, "profiles", "test.json"),
205
+ "utf-8",
206
+ );
207
+ const parsed = JSON.parse(content);
208
+ expect(parsed.settings.wallet.privateKey).toBe("ENC:my-key");
209
+ });
210
+
211
+ test("round-trip with encryption preserves all data", async () => {
212
+ const schema = Type.Object({
213
+ network: Type.String(),
214
+ secret: Type.String({ sensitive: true }),
215
+ count: Type.BigInt(),
216
+ });
217
+ const opts = {
218
+ encryption: {
219
+ encrypt: (plain: string) => Buffer.from(plain).toString("base64"),
220
+ decrypt: async (cipher: string) =>
221
+ Buffer.from(cipher, "base64").toString(),
222
+ },
223
+ };
224
+ const s1 = withProfile(new Sprinkle(schema, tmpDir, opts));
225
+ s1.settings = {
226
+ network: "mainnet",
227
+ secret: "top-secret",
228
+ count: 42n,
229
+ } as any;
230
+ s1.saveSettings();
231
+
232
+ const s2 = new Sprinkle(schema, tmpDir, opts);
233
+ await s2.loadProfile("test");
234
+
235
+ expect(s2.settings).toEqual({
236
+ network: "mainnet",
237
+ secret: "top-secret",
238
+ count: 42n,
239
+ });
240
+ });
241
+ });
242
+
243
+ describe("Sprinkle.New with options", () => {
244
+ test("passes options through to instance and migrates legacy", async () => {
245
+ const schema = Type.Object({ name: Type.String() });
246
+
247
+ // Write legacy settings file so New triggers migration
248
+ fs.mkdirSync(tmpDir, { recursive: true });
249
+ fs.writeFileSync(
250
+ path.join(tmpDir, "settings.json"),
251
+ JSON.stringify({ settings: { name: "test" }, defaults: {} }),
252
+ );
253
+
254
+ const sprinkle = await Sprinkle.New(schema, tmpDir, {
255
+ encryption: {
256
+ encrypt: (p) => p,
257
+ decrypt: async (c) => c,
258
+ },
259
+ });
260
+
261
+ expect(sprinkle.options.encryption).toBeDefined();
262
+ expect(sprinkle.profileId).toBe("default");
263
+ expect(sprinkle.settings).toEqual({ name: "test" });
264
+ });
265
+ });
266
+ });
@@ -0,0 +1,154 @@
1
+ import { describe, expect, test, mock, beforeEach } from "bun:test";
2
+ import { Sprinkle, Type, type IMenu } from "../index.js";
3
+
4
+ const mockSelect = mock();
5
+ const mockInput = mock();
6
+ const mockPassword = mock();
7
+ const mockSearch = mock();
8
+
9
+ mock.module("@inquirer/prompts", () => ({
10
+ select: mockSelect,
11
+ input: mockInput,
12
+ password: mockPassword,
13
+ search: mockSearch,
14
+ }));
15
+
16
+ describe("beforeShow hook (2.2)", () => {
17
+ let sprinkle: Sprinkle<any>;
18
+
19
+ beforeEach(() => {
20
+ const schema = Type.Object({ name: Type.String() });
21
+ sprinkle = new Sprinkle(schema, "/tmp/test");
22
+ sprinkle.settings = { name: "test" } as any;
23
+ mockSelect.mockClear();
24
+ });
25
+
26
+ test("calls beforeShow before rendering menu", async () => {
27
+ const callOrder: string[] = [];
28
+
29
+ const beforeShowFn = mock(async () => {
30
+ callOrder.push("beforeShow");
31
+ });
32
+
33
+ mockSelect.mockImplementation(async () => {
34
+ callOrder.push("select");
35
+ return -1; // exit
36
+ });
37
+
38
+ const menu: IMenu<any> = {
39
+ title: "Test",
40
+ beforeShow: beforeShowFn,
41
+ items: [{ title: "Action", action: async () => {} }],
42
+ };
43
+
44
+ await sprinkle.showMenu(menu);
45
+
46
+ expect(beforeShowFn).toHaveBeenCalledTimes(1);
47
+ expect(callOrder[0]).toBe("beforeShow");
48
+ expect(callOrder[1]).toBe("select");
49
+ });
50
+
51
+ test("passes sprinkle instance to beforeShow", async () => {
52
+ let receivedSprinkle: any;
53
+
54
+ mockSelect.mockResolvedValueOnce(-1);
55
+
56
+ const menu: IMenu<any> = {
57
+ title: "Test",
58
+ beforeShow: async (s) => {
59
+ receivedSprinkle = s;
60
+ },
61
+ items: [{ title: "Action", action: async () => {} }],
62
+ };
63
+
64
+ await sprinkle.showMenu(menu);
65
+ expect(receivedSprinkle).toBe(sprinkle);
66
+ });
67
+
68
+ test("menu works without beforeShow", async () => {
69
+ mockSelect.mockResolvedValueOnce(-1);
70
+
71
+ const menu: IMenu<any> = {
72
+ title: "Test",
73
+ items: [{ title: "Action", action: async () => {} }],
74
+ };
75
+
76
+ await sprinkle.showMenu(menu);
77
+ // Should complete without error
78
+ });
79
+ });
80
+
81
+ describe("SearchSelect (2.3)", () => {
82
+ beforeEach(() => {
83
+ mockSearch.mockClear();
84
+ });
85
+
86
+ test("delegates to search prompt", async () => {
87
+ mockSearch.mockResolvedValueOnce("selected-value");
88
+
89
+ const result = await Sprinkle.SearchSelect({
90
+ message: "Pick one",
91
+ source: () => [
92
+ { name: "Option A", value: "a" },
93
+ { name: "Option B", value: "b" },
94
+ ],
95
+ });
96
+
97
+ expect(result).toBe("selected-value");
98
+ expect(mockSearch).toHaveBeenCalledTimes(1);
99
+ expect(mockSearch.mock.calls[0][0].message).toBe("Pick one");
100
+ });
101
+
102
+ test("passes source function through", async () => {
103
+ const sourceFn = mock(() => [{ name: "X", value: "x" }]);
104
+ mockSearch.mockResolvedValueOnce("x");
105
+
106
+ await Sprinkle.SearchSelect({
107
+ message: "Search",
108
+ source: sourceFn,
109
+ });
110
+
111
+ expect(mockSearch.mock.calls[0][0].source).toBe(sourceFn);
112
+ });
113
+ });
114
+
115
+ describe("Optional type support (2.4)", () => {
116
+ let sprinkle: Sprinkle<any>;
117
+
118
+ beforeEach(() => {
119
+ const schema = Type.Object({ placeholder: Type.String() });
120
+ sprinkle = new Sprinkle(schema, "/tmp/test");
121
+ mockSelect.mockClear();
122
+ mockInput.mockClear();
123
+ });
124
+
125
+ test("skips optional field when user selects Skip", async () => {
126
+ const schema = Type.Object({
127
+ name: Type.String(),
128
+ nickname: Type.Optional(Type.String()),
129
+ });
130
+
131
+ mockInput.mockResolvedValueOnce("Alice"); // name
132
+ mockSelect.mockResolvedValueOnce(false); // skip nickname
133
+
134
+ const result = await sprinkle.FillInStruct(schema);
135
+ expect(result.name).toBe("Alice");
136
+ expect(result.nickname).toBeUndefined();
137
+ });
138
+
139
+ test("fills optional field when user selects Yes", async () => {
140
+ const schema = Type.Object({
141
+ name: Type.String(),
142
+ nickname: Type.Optional(Type.String()),
143
+ });
144
+
145
+ mockInput
146
+ .mockResolvedValueOnce("Alice") // name
147
+ .mockResolvedValueOnce("Ali"); // nickname
148
+ mockSelect.mockResolvedValueOnce(true); // fill nickname
149
+
150
+ const result = await sprinkle.FillInStruct(schema);
151
+ expect(result.name).toBe("Alice");
152
+ expect(result.nickname).toBe("Ali");
153
+ });
154
+ });
@@ -0,0 +1,60 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Sprinkle, Type } from "../index.js";
3
+
4
+ describe("ExtractMessage", () => {
5
+ test("returns title when present", () => {
6
+ const schema = Type.String({ title: "My Title" });
7
+ expect(Sprinkle.ExtractMessage(schema, "fallback")).toBe("My Title");
8
+ });
9
+
10
+ test("returns description when title is absent", () => {
11
+ const schema = Type.String({ description: "My Description" });
12
+ expect(Sprinkle.ExtractMessage(schema, "fallback")).toBe("My Description");
13
+ });
14
+
15
+ test("prefers title over description", () => {
16
+ const schema = Type.String({
17
+ title: "My Title",
18
+ description: "My Description",
19
+ });
20
+ expect(Sprinkle.ExtractMessage(schema, "fallback")).toBe("My Title");
21
+ });
22
+
23
+ test("returns literal const value as string", () => {
24
+ const schema = Type.Literal("hello");
25
+ expect(Sprinkle.ExtractMessage(schema, "fallback")).toBe("hello");
26
+ });
27
+
28
+ test("returns literal number const as string", () => {
29
+ const schema = Type.Literal(42);
30
+ expect(Sprinkle.ExtractMessage(schema, "fallback")).toBe("42");
31
+ });
32
+
33
+ test("returns type field message for objects with a type field", () => {
34
+ const schema = Type.Object({
35
+ type: Type.Literal("blockfrost"),
36
+ projectId: Type.String(),
37
+ });
38
+ expect(Sprinkle.ExtractMessage(schema, "fallback")).toBe("blockfrost");
39
+ });
40
+
41
+ test("returns single field name for single-field objects", () => {
42
+ const schema = Type.Object({
43
+ Signature: Type.Object({ key_hash: Type.String() }),
44
+ });
45
+ expect(Sprinkle.ExtractMessage(schema, "fallback")).toBe("Signature");
46
+ });
47
+
48
+ test("returns default for multi-field objects without type field", () => {
49
+ const schema = Type.Object({
50
+ name: Type.String(),
51
+ age: Type.BigInt(),
52
+ });
53
+ expect(Sprinkle.ExtractMessage(schema, "fallback")).toBe("fallback");
54
+ });
55
+
56
+ test("returns default for plain string without title/description", () => {
57
+ const schema = Type.String();
58
+ expect(Sprinkle.ExtractMessage(schema, "fallback")).toBe("fallback");
59
+ });
60
+ });