@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
@@ -0,0 +1,159 @@
1
+ import { describe, expect, test, mock, beforeEach } from "bun:test";
2
+ import { Sprinkle, Type } from "../index.js";
3
+
4
+ // Mock @inquirer/prompts
5
+ const mockSelect = mock();
6
+ const mockInput = mock();
7
+
8
+ mock.module("@inquirer/prompts", () => ({
9
+ select: mockSelect,
10
+ input: mockInput,
11
+ }));
12
+
13
+ describe("FillInStruct", () => {
14
+ let sprinkle: Sprinkle<any>;
15
+
16
+ beforeEach(() => {
17
+ const schema = Type.Object({ placeholder: Type.String() });
18
+ sprinkle = new Sprinkle(schema, "/tmp/test");
19
+ mockSelect.mockClear();
20
+ mockInput.mockClear();
21
+ });
22
+
23
+ test("fills a simple string field", async () => {
24
+ mockInput.mockResolvedValueOnce("hello");
25
+
26
+ const result = await sprinkle.FillInStruct(Type.String());
27
+ expect(result).toBe("hello");
28
+ });
29
+
30
+ test("fills a string with title as prompt", async () => {
31
+ mockInput.mockResolvedValueOnce("world");
32
+
33
+ await sprinkle.FillInStruct(Type.String({ title: "Enter name" }));
34
+ expect(mockInput.mock.calls[0][0].message).toBe("Enter name");
35
+ });
36
+
37
+ test("fills a bigint field", async () => {
38
+ mockInput.mockResolvedValueOnce("42");
39
+
40
+ const result = await sprinkle.FillInStruct(Type.BigInt());
41
+ expect(result).toBe(42n);
42
+ });
43
+
44
+ test("fills a literal field without prompting", async () => {
45
+ const result = await sprinkle.FillInStruct(Type.Literal("fixed"));
46
+ expect(result).toBe("fixed");
47
+ expect(mockInput).not.toHaveBeenCalled();
48
+ expect(mockSelect).not.toHaveBeenCalled();
49
+ });
50
+
51
+ test("fills an object with multiple fields", async () => {
52
+ const schema = Type.Object({
53
+ name: Type.String(),
54
+ age: Type.BigInt(),
55
+ });
56
+
57
+ mockInput
58
+ .mockResolvedValueOnce("Alice")
59
+ .mockResolvedValueOnce("30");
60
+
61
+ const result = await sprinkle.FillInStruct(schema);
62
+ expect(result).toEqual({ name: "Alice", age: 30n });
63
+ });
64
+
65
+ test("fills a union type by selecting variant then filling", async () => {
66
+ const schema = Type.Union([
67
+ Type.Object({
68
+ type: Type.Literal("a"),
69
+ value: Type.String(),
70
+ }),
71
+ Type.Object({
72
+ type: Type.Literal("b"),
73
+ count: Type.BigInt(),
74
+ }),
75
+ ]);
76
+
77
+ // Select first variant (the Object with type "a")
78
+ mockSelect.mockImplementationOnce(async (opts: any) => {
79
+ return opts.choices[0].value;
80
+ });
81
+ mockInput.mockResolvedValueOnce("test-value");
82
+
83
+ const result = await sprinkle.FillInStruct(schema);
84
+ expect(result).toEqual({ type: "a", value: "test-value" });
85
+ });
86
+
87
+ test("fills an array with items", async () => {
88
+ const schema = Type.Array(Type.String());
89
+
90
+ mockInput.mockResolvedValueOnce("first");
91
+ mockSelect.mockResolvedValueOnce(true); // add another
92
+ mockInput.mockResolvedValueOnce("second");
93
+ mockSelect.mockResolvedValueOnce(false); // stop
94
+
95
+ const result = await sprinkle.FillInStruct(schema);
96
+ expect(result).toEqual(["first", "second"]);
97
+ });
98
+
99
+ test("fills an array with single item", async () => {
100
+ const schema = Type.Array(Type.String());
101
+
102
+ mockInput.mockResolvedValueOnce("only");
103
+ mockSelect.mockResolvedValueOnce(false); // stop
104
+
105
+ const result = await sprinkle.FillInStruct(schema);
106
+ expect(result).toEqual(["only"]);
107
+ });
108
+
109
+ test("uses default value for string", async () => {
110
+ mockInput.mockResolvedValueOnce("used-default");
111
+
112
+ await sprinkle.FillInStruct(
113
+ Type.String(),
114
+ "my-default" as any,
115
+ );
116
+
117
+ expect(mockInput.mock.calls[0][0].default).toBe("my-default");
118
+ });
119
+
120
+ test("uses default value for bigint", async () => {
121
+ mockInput.mockResolvedValueOnce("99");
122
+
123
+ await sprinkle.FillInStruct(Type.BigInt(), 99n as any);
124
+
125
+ expect(mockInput.mock.calls[0][0].default).toBe("99");
126
+ });
127
+
128
+ test("throws for unsupported types", async () => {
129
+ expect(
130
+ sprinkle.FillInStruct(Type.Boolean() as any),
131
+ ).rejects.toThrow("Unable to fill in struct");
132
+ });
133
+
134
+ test("fills nested objects", async () => {
135
+ const schema = Type.Object({
136
+ outer: Type.Object({
137
+ inner: Type.String(),
138
+ }),
139
+ });
140
+
141
+ mockInput.mockResolvedValueOnce("deep-value");
142
+
143
+ const result = await sprinkle.FillInStruct(schema);
144
+ expect(result).toEqual({ outer: { inner: "deep-value" } });
145
+ });
146
+
147
+ test("remembers last string input as default", async () => {
148
+ mockInput
149
+ .mockResolvedValueOnce("first-input")
150
+ .mockResolvedValueOnce("second-input");
151
+
152
+ await sprinkle.FillInStruct(Type.String());
153
+ expect(sprinkle.defaults["string"]).toBe("first-input");
154
+
155
+ await sprinkle.FillInStruct(Type.String());
156
+ // Second call should have the first input as default
157
+ expect(mockInput.mock.calls[1][0].default).toBe("first-input");
158
+ });
159
+ });
@@ -0,0 +1,215 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Value } from "@sinclair/typebox/value";
3
+ import {
4
+ NetworkSchema,
5
+ ProviderSettingsSchema,
6
+ WalletSettingsSchema,
7
+ MultisigScriptModule,
8
+ } from "../index.js";
9
+
10
+ describe("Built-in Schemas", () => {
11
+ describe("NetworkSchema", () => {
12
+ test("accepts mainnet", () => {
13
+ expect(Value.Check(NetworkSchema, "mainnet")).toBe(true);
14
+ });
15
+
16
+ test("accepts preview", () => {
17
+ expect(Value.Check(NetworkSchema, "preview")).toBe(true);
18
+ });
19
+
20
+ test("accepts preprod", () => {
21
+ expect(Value.Check(NetworkSchema, "preprod")).toBe(true);
22
+ });
23
+
24
+ test("rejects invalid network", () => {
25
+ expect(Value.Check(NetworkSchema, "testnet")).toBe(false);
26
+ });
27
+
28
+ test("rejects non-string", () => {
29
+ expect(Value.Check(NetworkSchema, 42)).toBe(false);
30
+ });
31
+ });
32
+
33
+ describe("ProviderSettingsSchema", () => {
34
+ test("accepts blockfrost settings", () => {
35
+ expect(
36
+ Value.Check(ProviderSettingsSchema, {
37
+ type: "blockfrost",
38
+ projectId: "abc123",
39
+ }),
40
+ ).toBe(true);
41
+ });
42
+
43
+ test("accepts maestro settings", () => {
44
+ expect(
45
+ Value.Check(ProviderSettingsSchema, {
46
+ type: "maestro",
47
+ apiKey: "key123",
48
+ }),
49
+ ).toBe(true);
50
+ });
51
+
52
+ test("rejects empty projectId for blockfrost", () => {
53
+ expect(
54
+ Value.Check(ProviderSettingsSchema, {
55
+ type: "blockfrost",
56
+ projectId: "",
57
+ }),
58
+ ).toBe(false);
59
+ });
60
+
61
+ test("rejects empty apiKey for maestro", () => {
62
+ expect(
63
+ Value.Check(ProviderSettingsSchema, {
64
+ type: "maestro",
65
+ apiKey: "",
66
+ }),
67
+ ).toBe(false);
68
+ });
69
+
70
+ test("rejects unknown provider type", () => {
71
+ expect(
72
+ Value.Check(ProviderSettingsSchema, {
73
+ type: "unknown",
74
+ key: "abc",
75
+ }),
76
+ ).toBe(false);
77
+ });
78
+ });
79
+
80
+ describe("WalletSettingsSchema", () => {
81
+ test("accepts hot wallet settings", () => {
82
+ expect(
83
+ Value.Check(WalletSettingsSchema, {
84
+ type: "hot",
85
+ privateKey: "deadbeef",
86
+ }),
87
+ ).toBe(true);
88
+ });
89
+
90
+ test("accepts cold wallet settings", () => {
91
+ expect(
92
+ Value.Check(WalletSettingsSchema, {
93
+ type: "cold",
94
+ address: "addr1qtest",
95
+ }),
96
+ ).toBe(true);
97
+ });
98
+
99
+ test("rejects empty privateKey for hot wallet", () => {
100
+ expect(
101
+ Value.Check(WalletSettingsSchema, {
102
+ type: "hot",
103
+ privateKey: "",
104
+ }),
105
+ ).toBe(false);
106
+ });
107
+
108
+ test("rejects empty address for cold wallet", () => {
109
+ expect(
110
+ Value.Check(WalletSettingsSchema, {
111
+ type: "cold",
112
+ address: "",
113
+ }),
114
+ ).toBe(false);
115
+ });
116
+ });
117
+
118
+ describe("MultisigScript", () => {
119
+ const MultisigScript = MultisigScriptModule.Import("MultisigScript");
120
+
121
+ test("accepts Signature variant", () => {
122
+ expect(
123
+ Value.Check(MultisigScript, {
124
+ Signature: { key_hash: "abc123" },
125
+ }),
126
+ ).toBe(true);
127
+ });
128
+
129
+ test("accepts AllOf variant with nested scripts", () => {
130
+ expect(
131
+ Value.Check(MultisigScript, {
132
+ AllOf: {
133
+ scripts: [
134
+ { Signature: { key_hash: "abc" } },
135
+ { Signature: { key_hash: "def" } },
136
+ ],
137
+ },
138
+ }),
139
+ ).toBe(true);
140
+ });
141
+
142
+ test("accepts AnyOf variant", () => {
143
+ expect(
144
+ Value.Check(MultisigScript, {
145
+ AnyOf: {
146
+ scripts: [{ Signature: { key_hash: "abc" } }],
147
+ },
148
+ }),
149
+ ).toBe(true);
150
+ });
151
+
152
+ test("accepts AtLeast variant", () => {
153
+ expect(
154
+ Value.Check(MultisigScript, {
155
+ AtLeast: {
156
+ required: 2n,
157
+ scripts: [
158
+ { Signature: { key_hash: "a" } },
159
+ { Signature: { key_hash: "b" } },
160
+ { Signature: { key_hash: "c" } },
161
+ ],
162
+ },
163
+ }),
164
+ ).toBe(true);
165
+ });
166
+
167
+ test("accepts Before variant", () => {
168
+ expect(
169
+ Value.Check(MultisigScript, {
170
+ Before: { time: 1000n },
171
+ }),
172
+ ).toBe(true);
173
+ });
174
+
175
+ test("accepts After variant", () => {
176
+ expect(
177
+ Value.Check(MultisigScript, {
178
+ After: { time: 2000n },
179
+ }),
180
+ ).toBe(true);
181
+ });
182
+
183
+ test("accepts Script variant", () => {
184
+ expect(
185
+ Value.Check(MultisigScript, {
186
+ Script: { script_hash: "hash123" },
187
+ }),
188
+ ).toBe(true);
189
+ });
190
+
191
+ test("accepts deeply nested scripts", () => {
192
+ expect(
193
+ Value.Check(MultisigScript, {
194
+ AllOf: {
195
+ scripts: [
196
+ {
197
+ AnyOf: {
198
+ scripts: [
199
+ { Signature: { key_hash: "nested" } },
200
+ {
201
+ AtLeast: {
202
+ required: 1n,
203
+ scripts: [{ Signature: { key_hash: "deep" } }],
204
+ },
205
+ },
206
+ ],
207
+ },
208
+ },
209
+ ],
210
+ },
211
+ }),
212
+ ).toBe(true);
213
+ });
214
+ });
215
+ });
@@ -0,0 +1,181 @@
1
+ import { describe, expect, test, 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
+ describe("Settings Persistence", () => {
9
+ let tmpDir: string;
10
+
11
+ beforeEach(() => {
12
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sprinkles-test-"));
13
+ });
14
+
15
+ afterEach(() => {
16
+ fs.rmSync(tmpDir, { recursive: true, force: true });
17
+ });
18
+
19
+ test("SettingsPath returns correct path", () => {
20
+ const result = Sprinkle.SettingsPath("/some/path");
21
+ expect(result).toBe(`/some/path${path.sep}settings.json`);
22
+ });
23
+
24
+ test("saveSettings creates directory if missing", () => {
25
+ const nestedDir = path.join(tmpDir, "nested", "deep");
26
+ const schema = Type.Object({ name: Type.String() });
27
+ const sprinkle = withProfile(new Sprinkle(schema, nestedDir));
28
+ sprinkle.settings = { name: "test" } as any;
29
+
30
+ sprinkle.saveSettings();
31
+
32
+ expect(fs.existsSync(path.join(nestedDir, "profiles", "test.json"))).toBe(
33
+ true,
34
+ );
35
+ });
36
+
37
+ test("saveSettings writes valid JSON with profile format", () => {
38
+ const schema = Type.Object({ name: Type.String() });
39
+ const sprinkle = withProfile(new Sprinkle(schema, tmpDir));
40
+ sprinkle.settings = { name: "hello" } as any;
41
+
42
+ sprinkle.saveSettings();
43
+
44
+ const content = fs.readFileSync(
45
+ path.join(tmpDir, "profiles", "test.json"),
46
+ "utf-8",
47
+ );
48
+ const parsed = JSON.parse(content);
49
+ expect(parsed.settings.name).toBe("hello");
50
+ expect(parsed.defaults).toEqual({});
51
+ expect(parsed.meta.name).toBe("Test");
52
+ });
53
+
54
+ test("saveSettings serializes BigInt values", () => {
55
+ const schema = Type.Object({ amount: Type.BigInt() });
56
+ const sprinkle = withProfile(new Sprinkle(schema, tmpDir));
57
+ sprinkle.settings = { amount: 42n } as any;
58
+
59
+ sprinkle.saveSettings();
60
+
61
+ const content = fs.readFileSync(
62
+ path.join(tmpDir, "profiles", "test.json"),
63
+ "utf-8",
64
+ );
65
+ const raw = JSON.parse(content);
66
+ expect(raw.settings.amount).toBe("42n");
67
+ });
68
+
69
+ test("saveSettings and loadProfile round-trip with BigInt", async () => {
70
+ const schema = Type.Object({ amount: Type.BigInt() });
71
+ const sprinkle = withProfile(new Sprinkle(schema, tmpDir));
72
+ sprinkle.settings = { amount: 99n } as any;
73
+ sprinkle.saveSettings();
74
+
75
+ const sprinkle2 = new Sprinkle(schema, tmpDir);
76
+ await sprinkle2.loadProfile("test");
77
+
78
+ expect(sprinkle2.settings).toEqual({ amount: 99n });
79
+ });
80
+
81
+ test("saveSettings preserves defaults", () => {
82
+ const schema = Type.Object({ name: Type.String() });
83
+ const sprinkle = withProfile(new Sprinkle(schema, tmpDir));
84
+ sprinkle.settings = { name: "test" } as any;
85
+ sprinkle.defaults = { string: "last-input" };
86
+
87
+ sprinkle.saveSettings();
88
+
89
+ const content = fs.readFileSync(
90
+ path.join(tmpDir, "profiles", "test.json"),
91
+ "utf-8",
92
+ );
93
+ const parsed = JSON.parse(content);
94
+ expect(parsed.defaults.string).toBe("last-input");
95
+ });
96
+
97
+ test("loadProfile restores defaults", async () => {
98
+ const schema = Type.Object({ name: Type.String() });
99
+ const sprinkle = withProfile(new Sprinkle(schema, tmpDir));
100
+ sprinkle.settings = { name: "test" } as any;
101
+ sprinkle.defaults = { string: "remembered" };
102
+ sprinkle.saveSettings();
103
+
104
+ const sprinkle2 = new Sprinkle(schema, tmpDir);
105
+ await sprinkle2.loadProfile("test");
106
+
107
+ expect(sprinkle2.defaults).toEqual({ string: "remembered" });
108
+ });
109
+
110
+ test("round-trip with nested objects", async () => {
111
+ const schema = Type.Object({
112
+ user: Type.Object({
113
+ name: Type.String(),
114
+ score: Type.BigInt(),
115
+ }),
116
+ });
117
+ const sprinkle = withProfile(new Sprinkle(schema, tmpDir));
118
+ sprinkle.settings = { user: { name: "alice", score: 100n } } as any;
119
+ sprinkle.saveSettings();
120
+
121
+ const sprinkle2 = new Sprinkle(schema, tmpDir);
122
+ await sprinkle2.loadProfile("test");
123
+
124
+ expect(sprinkle2.settings).toEqual({
125
+ user: { name: "alice", score: 100n },
126
+ });
127
+ });
128
+
129
+ test("scanProfiles returns profile entries", () => {
130
+ const schema = Type.Object({ name: Type.String() });
131
+ const sprinkle = withProfile(new Sprinkle(schema, tmpDir), "alice");
132
+ sprinkle.profileMeta.name = "Alice";
133
+ sprinkle.settings = { name: "test" } as any;
134
+ sprinkle.saveSettings();
135
+
136
+ const profiles = sprinkle.scanProfiles();
137
+ expect(profiles.length).toBe(1);
138
+ expect(profiles[0]!.id).toBe("alice");
139
+ expect(profiles[0]!.meta.name).toBe("Alice");
140
+ });
141
+
142
+ test("sanitizeProfileId handles various inputs", () => {
143
+ expect(Sprinkle.sanitizeProfileId("Mainnet Alice")).toBe("mainnet-alice");
144
+ expect(Sprinkle.sanitizeProfileId("test--profile")).toBe("test-profile");
145
+ expect(Sprinkle.sanitizeProfileId("Hello World!@#$")).toBe("hello-world");
146
+ expect(Sprinkle.sanitizeProfileId(" ")).toBe("profile");
147
+ expect(Sprinkle.sanitizeProfileId("simple")).toBe("simple");
148
+ });
149
+
150
+ test("migration converts legacy settings.json to profile", async () => {
151
+ const schema = Type.Object({ name: Type.String() });
152
+
153
+ // Write legacy settings.json
154
+ fs.mkdirSync(tmpDir, { recursive: true });
155
+ fs.writeFileSync(
156
+ path.join(tmpDir, "settings.json"),
157
+ JSON.stringify({
158
+ settings: { name: "legacy" },
159
+ defaults: { string: "old" },
160
+ }),
161
+ );
162
+
163
+ // Create sprinkle and trigger migration
164
+ const sprinkle = new Sprinkle(schema, tmpDir);
165
+ await (sprinkle as any).migrateIfNeeded();
166
+
167
+ // Check profile was created
168
+ expect(fs.existsSync(path.join(tmpDir, "profiles", "default.json"))).toBe(
169
+ true,
170
+ );
171
+ // Check legacy file was backed up
172
+ expect(fs.existsSync(path.join(tmpDir, "settings.json.bak"))).toBe(true);
173
+ expect(fs.existsSync(path.join(tmpDir, "settings.json"))).toBe(false);
174
+
175
+ // Load the migrated profile
176
+ await sprinkle.loadProfile("default");
177
+ expect(sprinkle.settings).toEqual({ name: "legacy" });
178
+ expect(sprinkle.defaults).toEqual({ string: "old" });
179
+ expect(sprinkle.profileMeta.name).toBe("Default");
180
+ });
181
+ });
@@ -0,0 +1,123 @@
1
+ import { describe, expect, test, mock, beforeEach } from "bun:test";
2
+ import { Sprinkle, Type, type IMenu } from "../index.js";
3
+ import { withProfile } from "./test-helpers.js";
4
+
5
+ const mockSelect = mock();
6
+ const mockInput = mock();
7
+
8
+ mock.module("@inquirer/prompts", () => ({
9
+ select: mockSelect,
10
+ input: mockInput,
11
+ }));
12
+
13
+ describe("showMenu", () => {
14
+ let sprinkle: Sprinkle<any>;
15
+
16
+ beforeEach(() => {
17
+ const schema = Type.Object({ name: Type.String() });
18
+ sprinkle = withProfile(new Sprinkle(schema, "/tmp/test"));
19
+ sprinkle.settings = { name: "test" } as any;
20
+ mockSelect.mockClear();
21
+ mockInput.mockClear();
22
+ });
23
+
24
+ test("exits when Exit is selected on main menu", async () => {
25
+ mockSelect.mockResolvedValueOnce(-1);
26
+
27
+ const menu: IMenu<any> = {
28
+ title: "Test Menu",
29
+ items: [
30
+ {
31
+ title: "Action 1",
32
+ action: async () => {},
33
+ },
34
+ ],
35
+ };
36
+
37
+ await sprinkle.showMenu(menu);
38
+ });
39
+
40
+ test("executes action and re-shows menu", async () => {
41
+ const actionFn = mock(async () => {});
42
+
43
+ mockSelect.mockResolvedValueOnce(0).mockResolvedValueOnce(-1);
44
+
45
+ const menu: IMenu<any> = {
46
+ title: "Test Menu",
47
+ items: [
48
+ {
49
+ title: "My Action",
50
+ action: actionFn,
51
+ },
52
+ ],
53
+ };
54
+
55
+ await sprinkle.showMenu(menu);
56
+ expect(actionFn).toHaveBeenCalledTimes(1);
57
+ });
58
+
59
+ test("main menu includes Settings & Profiles submenu and Exit", async () => {
60
+ mockSelect.mockResolvedValueOnce(-1);
61
+
62
+ const menu: IMenu<any> = {
63
+ title: "Test",
64
+ items: [{ title: "Action", action: async () => {} }],
65
+ };
66
+
67
+ await sprinkle.showMenu(menu);
68
+
69
+ const choices = mockSelect.mock.calls[0][0].choices;
70
+ const names = choices.map((c: any) => c.name);
71
+ expect(names).toContain("Settings & Profiles");
72
+ expect(names).toContain("Exit");
73
+ expect(names).not.toContain("Switch profile");
74
+ expect(names).not.toContain("Manage profiles");
75
+ expect(names).not.toContain("Edit settings");
76
+ });
77
+
78
+ test("submenu includes Back instead of Exit", async () => {
79
+ mockSelect
80
+ .mockResolvedValueOnce(0)
81
+ .mockResolvedValueOnce(-1)
82
+ .mockResolvedValueOnce(-1);
83
+
84
+ const menu: IMenu<any> = {
85
+ title: "Main",
86
+ items: [
87
+ {
88
+ title: "Sub",
89
+ items: [{ title: "Sub Action", action: async () => {} }],
90
+ },
91
+ ],
92
+ };
93
+
94
+ await sprinkle.showMenu(menu);
95
+
96
+ const subChoices = mockSelect.mock.calls[1][0].choices;
97
+ const subNames = subChoices.map((c: any) => c.name);
98
+ expect(subNames).toContain("Back");
99
+ expect(subNames).not.toContain("Exit");
100
+ });
101
+
102
+ test("action returning sprinkle instance saves settings", async () => {
103
+ sprinkle.settings = { name: "original" } as any;
104
+
105
+ mockSelect.mockResolvedValueOnce(0).mockResolvedValueOnce(-1);
106
+
107
+ const menu: IMenu<any> = {
108
+ title: "Test",
109
+ items: [
110
+ {
111
+ title: "Update",
112
+ action: async (s: Sprinkle<any>) => {
113
+ s.settings = { name: "updated" } as any;
114
+ return s;
115
+ },
116
+ },
117
+ ],
118
+ };
119
+
120
+ await sprinkle.showMenu(menu);
121
+ expect(sprinkle.settings).toEqual({ name: "updated" });
122
+ });
123
+ });
@@ -0,0 +1,14 @@
1
+ import { Sprinkle } from "../index.js";
2
+
3
+ export function withProfile<S>(
4
+ sprinkle: Sprinkle<S>,
5
+ id: string = "test",
6
+ ): Sprinkle<S> {
7
+ sprinkle.profileId = id;
8
+ sprinkle.profileMeta = {
9
+ name: "Test",
10
+ createdAt: new Date().toISOString(),
11
+ updatedAt: new Date().toISOString(),
12
+ };
13
+ return sprinkle;
14
+ }