@showrun/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 (74) hide show
  1. package/LICENSE +21 -0
  2. package/dist/__tests__/dsl-validation.test.d.ts +2 -0
  3. package/dist/__tests__/dsl-validation.test.d.ts.map +1 -0
  4. package/dist/__tests__/dsl-validation.test.js +203 -0
  5. package/dist/__tests__/pack-versioning.test.d.ts +2 -0
  6. package/dist/__tests__/pack-versioning.test.d.ts.map +1 -0
  7. package/dist/__tests__/pack-versioning.test.js +165 -0
  8. package/dist/__tests__/validator.test.d.ts +2 -0
  9. package/dist/__tests__/validator.test.d.ts.map +1 -0
  10. package/dist/__tests__/validator.test.js +149 -0
  11. package/dist/authResilience.d.ts +146 -0
  12. package/dist/authResilience.d.ts.map +1 -0
  13. package/dist/authResilience.js +378 -0
  14. package/dist/browserLauncher.d.ts +74 -0
  15. package/dist/browserLauncher.d.ts.map +1 -0
  16. package/dist/browserLauncher.js +159 -0
  17. package/dist/browserPersistence.d.ts +49 -0
  18. package/dist/browserPersistence.d.ts.map +1 -0
  19. package/dist/browserPersistence.js +143 -0
  20. package/dist/context.d.ts +10 -0
  21. package/dist/context.d.ts.map +1 -0
  22. package/dist/context.js +30 -0
  23. package/dist/dsl/builders.d.ts +340 -0
  24. package/dist/dsl/builders.d.ts.map +1 -0
  25. package/dist/dsl/builders.js +416 -0
  26. package/dist/dsl/conditions.d.ts +33 -0
  27. package/dist/dsl/conditions.d.ts.map +1 -0
  28. package/dist/dsl/conditions.js +169 -0
  29. package/dist/dsl/interpreter.d.ts +24 -0
  30. package/dist/dsl/interpreter.d.ts.map +1 -0
  31. package/dist/dsl/interpreter.js +491 -0
  32. package/dist/dsl/stepHandlers.d.ts +32 -0
  33. package/dist/dsl/stepHandlers.d.ts.map +1 -0
  34. package/dist/dsl/stepHandlers.js +787 -0
  35. package/dist/dsl/target.d.ts +28 -0
  36. package/dist/dsl/target.d.ts.map +1 -0
  37. package/dist/dsl/target.js +110 -0
  38. package/dist/dsl/templating.d.ts +21 -0
  39. package/dist/dsl/templating.d.ts.map +1 -0
  40. package/dist/dsl/templating.js +73 -0
  41. package/dist/dsl/types.d.ts +695 -0
  42. package/dist/dsl/types.d.ts.map +1 -0
  43. package/dist/dsl/types.js +7 -0
  44. package/dist/dsl/validation.d.ts +15 -0
  45. package/dist/dsl/validation.d.ts.map +1 -0
  46. package/dist/dsl/validation.js +974 -0
  47. package/dist/index.d.ts +20 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +20 -0
  50. package/dist/jsonPackValidator.d.ts +11 -0
  51. package/dist/jsonPackValidator.d.ts.map +1 -0
  52. package/dist/jsonPackValidator.js +61 -0
  53. package/dist/loader.d.ts +35 -0
  54. package/dist/loader.d.ts.map +1 -0
  55. package/dist/loader.js +107 -0
  56. package/dist/networkCapture.d.ts +107 -0
  57. package/dist/networkCapture.d.ts.map +1 -0
  58. package/dist/networkCapture.js +390 -0
  59. package/dist/packUtils.d.ts +36 -0
  60. package/dist/packUtils.d.ts.map +1 -0
  61. package/dist/packUtils.js +97 -0
  62. package/dist/packVersioning.d.ts +25 -0
  63. package/dist/packVersioning.d.ts.map +1 -0
  64. package/dist/packVersioning.js +137 -0
  65. package/dist/runner.d.ts +62 -0
  66. package/dist/runner.d.ts.map +1 -0
  67. package/dist/runner.js +170 -0
  68. package/dist/types.d.ts +336 -0
  69. package/dist/types.d.ts.map +1 -0
  70. package/dist/types.js +1 -0
  71. package/dist/validator.d.ts +20 -0
  72. package/dist/validator.d.ts.map +1 -0
  73. package/dist/validator.js +68 -0
  74. package/package.json +49 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Emrah Yalaz, Anil Seyrek, Mahmut Karaca
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=dsl-validation.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dsl-validation.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/dsl-validation.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,203 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { validateFlow, ValidationError } from '../dsl/validation.js';
3
+ describe('validateFlow — collect-all errors', () => {
4
+ it('collects multiple errors from a single step', () => {
5
+ const errors = [];
6
+ // Step with no id, no type, no params
7
+ validateFlow([{}], errors);
8
+ expect(errors.length).toBeGreaterThanOrEqual(3);
9
+ expect(errors.some((e) => e.includes('must have a non-empty string "id"'))).toBe(true);
10
+ expect(errors.some((e) => e.includes('must have a non-empty string "type"'))).toBe(true);
11
+ expect(errors.some((e) => e.includes('must have a "params" object'))).toBe(true);
12
+ });
13
+ it('collects errors from multiple bad steps', () => {
14
+ const errors = [];
15
+ validateFlow([
16
+ { id: 'nav_1', type: 'navigate', params: {} }, // missing url
17
+ { id: 'click_1', type: 'click', params: { first: 'yes' } }, // missing target + bad first
18
+ ], errors);
19
+ expect(errors.length).toBeGreaterThanOrEqual(3);
20
+ // Errors from step 0
21
+ expect(errors.some((e) => e.includes('Step 0') && e.includes('Navigate'))).toBe(true);
22
+ // Errors from step 1
23
+ expect(errors.some((e) => e.includes('Step 1') && e.includes('Click step must have either'))).toBe(true);
24
+ expect(errors.some((e) => e.includes('Step 1') && e.includes('"first" must be a boolean'))).toBe(true);
25
+ });
26
+ it('reports nested target errors alongside param errors', () => {
27
+ const errors = [];
28
+ validateFlow([
29
+ {
30
+ id: 'click_bad',
31
+ type: 'click',
32
+ params: {
33
+ target: { kind: 'role', role: 'INVALID' }, // bad role
34
+ first: 'not-bool', // bad type
35
+ },
36
+ },
37
+ ], errors);
38
+ expect(errors.length).toBeGreaterThanOrEqual(2);
39
+ expect(errors.some((e) => e.includes('Role target must have a valid role'))).toBe(true);
40
+ expect(errors.some((e) => e.includes('"first" must be a boolean'))).toBe(true);
41
+ });
42
+ it('reports duplicate ID alongside field-level errors', () => {
43
+ const errors = [];
44
+ validateFlow([
45
+ { id: 'dup', type: 'navigate', params: { url: 'https://example.com' } },
46
+ { id: 'dup', type: 'click', params: {} }, // duplicate ID + missing target
47
+ ], errors);
48
+ expect(errors.some((e) => e.includes('Duplicate step ID'))).toBe(true);
49
+ expect(errors.some((e) => e.includes('Click step must have either'))).toBe(true);
50
+ });
51
+ it('backwards compat: throws on first error when collectedErrors is omitted', () => {
52
+ expect(() => validateFlow([
53
+ { id: 'nav_1', type: 'navigate', params: {} }, // missing url
54
+ { id: 'click_1', type: 'click', params: {} }, // missing target
55
+ ])).toThrow(ValidationError);
56
+ // Should throw only one error (the first)
57
+ try {
58
+ validateFlow([
59
+ { id: 'nav_1', type: 'navigate', params: {} },
60
+ { id: 'click_1', type: 'click', params: {} },
61
+ ]);
62
+ }
63
+ catch (e) {
64
+ expect(e).toBeInstanceOf(ValidationError);
65
+ // Message should reference step 0 only
66
+ expect(e.message).toContain('Step 0');
67
+ }
68
+ });
69
+ it('collects no errors for a valid flow', () => {
70
+ const errors = [];
71
+ validateFlow([
72
+ { id: 'nav_1', type: 'navigate', params: { url: 'https://example.com' } },
73
+ { id: 'click_1', type: 'click', params: { selector: '#btn' } },
74
+ ], errors);
75
+ expect(errors).toEqual([]);
76
+ });
77
+ it('error prefix includes step index, id, and type', () => {
78
+ const errors = [];
79
+ validateFlow([{ id: 'my_step', type: 'navigate', params: {} }], errors);
80
+ expect(errors.length).toBe(1);
81
+ expect(errors[0]).toMatch(/^Step 0 \(id="my_step", type="navigate"\):/);
82
+ });
83
+ it('uses "?" for unknown id and type', () => {
84
+ const errors = [];
85
+ validateFlow([{ params: { url: 'https://example.com' } }], errors);
86
+ expect(errors.some((e) => e.includes('id="?"'))).toBe(true);
87
+ expect(errors.some((e) => e.includes('type="?"'))).toBe(true);
88
+ });
89
+ it('handles non-array input with collectedErrors', () => {
90
+ const errors = [];
91
+ validateFlow('not-an-array', errors);
92
+ expect(errors).toEqual(['Flow must be an array of steps']);
93
+ });
94
+ it('handles non-array input without collectedErrors (throws)', () => {
95
+ expect(() => validateFlow('not-an-array')).toThrow('Flow must be an array of steps');
96
+ });
97
+ it('handles step that is not an object', () => {
98
+ const errors = [];
99
+ validateFlow([null, 'string-step', 42], errors);
100
+ expect(errors.length).toBe(3);
101
+ expect(errors[0]).toContain('Step 0');
102
+ expect(errors[1]).toContain('Step 1');
103
+ expect(errors[2]).toContain('Step 2');
104
+ });
105
+ });
106
+ describe('validateFlow — unknown params rejection', () => {
107
+ it('rejects unknown params like "extract" and "expression" on extract_text', () => {
108
+ const errors = [];
109
+ validateFlow([
110
+ {
111
+ id: 'bad_extract',
112
+ type: 'extract_text',
113
+ params: {
114
+ selector: '.price',
115
+ out: 'price',
116
+ extract: 'eval',
117
+ expression: 'parseFloat(text)',
118
+ },
119
+ },
120
+ ], errors);
121
+ expect(errors.length).toBe(1);
122
+ expect(errors[0]).toContain('Unknown param(s) "extract", "expression"');
123
+ expect(errors[0]).toContain('"extract_text" step');
124
+ expect(errors[0]).toContain('Allowed params:');
125
+ });
126
+ it('provides eval hint when unknown param name looks eval-like', () => {
127
+ const errors = [];
128
+ validateFlow([
129
+ {
130
+ id: 'eval_step',
131
+ type: 'extract_text',
132
+ params: {
133
+ selector: '.data',
134
+ out: 'result',
135
+ eval: 'some expression',
136
+ },
137
+ },
138
+ ], errors);
139
+ expect(errors.length).toBe(1);
140
+ expect(errors[0]).toContain('network_extract step with a JMESPath');
141
+ });
142
+ it('does not provide eval hint for non-eval-like unknown params', () => {
143
+ const errors = [];
144
+ validateFlow([
145
+ {
146
+ id: 'typo_step',
147
+ type: 'click',
148
+ params: {
149
+ selector: '#btn',
150
+ frist: true, // typo of "first"
151
+ },
152
+ },
153
+ ], errors);
154
+ expect(errors.length).toBe(1);
155
+ expect(errors[0]).toContain('Unknown param(s) "frist"');
156
+ expect(errors[0]).not.toContain('network_extract');
157
+ });
158
+ it('detects eval() in string param values', () => {
159
+ const errors = [];
160
+ validateFlow([
161
+ {
162
+ id: 'eval_value',
163
+ type: 'fill',
164
+ params: {
165
+ selector: '#input',
166
+ value: 'eval(document.cookie)',
167
+ },
168
+ },
169
+ ], errors);
170
+ expect(errors.some((e) => e.includes('contains eval()'))).toBe(true);
171
+ expect(errors.some((e) => e.includes('network_extract'))).toBe(true);
172
+ });
173
+ it('allows valid params without errors', () => {
174
+ const errors = [];
175
+ validateFlow([
176
+ {
177
+ id: 'valid_extract',
178
+ type: 'extract_text',
179
+ params: {
180
+ selector: '.price',
181
+ out: 'price',
182
+ first: true,
183
+ trim: true,
184
+ default: 'N/A',
185
+ },
186
+ },
187
+ ], errors);
188
+ expect(errors).toEqual([]);
189
+ });
190
+ it('does not reject unknown params for unknown step types', () => {
191
+ const errors = [];
192
+ validateFlow([
193
+ {
194
+ id: 'custom_step',
195
+ type: 'custom_magic',
196
+ params: { anything: 'goes' },
197
+ },
198
+ ], errors);
199
+ // Should only have the "Unknown step type" error, not unknown params
200
+ expect(errors.length).toBe(1);
201
+ expect(errors[0]).toContain('Unknown step type: custom_magic');
202
+ });
203
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=pack-versioning.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pack-versioning.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/pack-versioning.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,165 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { tmpdir } from 'os';
5
+ import { saveVersion, listVersions, restoreVersion, getVersionFiles } from '../packVersioning.js';
6
+ function createTempPack(opts) {
7
+ const dir = join(tmpdir(), `test-pack-${Date.now()}-${Math.random().toString(36).slice(2)}`);
8
+ mkdirSync(dir, { recursive: true });
9
+ const taskpack = {
10
+ id: 'test-pack',
11
+ name: 'Test Pack',
12
+ version: opts?.version || '1.0.0',
13
+ kind: 'json-dsl',
14
+ };
15
+ writeFileSync(join(dir, 'taskpack.json'), JSON.stringify(taskpack, null, 2));
16
+ const flow = {
17
+ inputs: {},
18
+ collectibles: [],
19
+ flow: [{ id: 'step1', type: 'navigate', params: { url: 'https://example.com' } }],
20
+ };
21
+ writeFileSync(join(dir, 'flow.json'), JSON.stringify(flow, null, 2));
22
+ return dir;
23
+ }
24
+ describe('Pack Versioning', () => {
25
+ let packDir;
26
+ beforeEach(() => {
27
+ packDir = createTempPack();
28
+ });
29
+ afterEach(() => {
30
+ try {
31
+ rmSync(packDir, { recursive: true, force: true });
32
+ }
33
+ catch { /* ignore */ }
34
+ });
35
+ it('saves a version and creates versioned files', () => {
36
+ const v = saveVersion(packDir, { source: 'cli', label: 'Initial' });
37
+ expect(v.number).toBe(1);
38
+ expect(v.version).toBe('1.0.0');
39
+ expect(v.source).toBe('cli');
40
+ expect(v.label).toBe('Initial');
41
+ expect(v.timestamp).toBeTruthy();
42
+ expect(existsSync(join(packDir, '.versions', '1.flow.json'))).toBe(true);
43
+ expect(existsSync(join(packDir, '.versions', '1.taskpack.json'))).toBe(true);
44
+ expect(existsSync(join(packDir, '.versions', 'manifest.json'))).toBe(true);
45
+ });
46
+ it('reads version field from taskpack.json metadata', () => {
47
+ const v = saveVersion(packDir, { source: 'dashboard' });
48
+ expect(v.version).toBe('1.0.0');
49
+ // Update version and save again
50
+ const taskpackPath = join(packDir, 'taskpack.json');
51
+ const taskpack = JSON.parse(readFileSync(taskpackPath, 'utf-8'));
52
+ taskpack.version = '2.0.0';
53
+ writeFileSync(taskpackPath, JSON.stringify(taskpack, null, 2));
54
+ const v2 = saveVersion(packDir, { source: 'cli' });
55
+ expect(v2.version).toBe('2.0.0');
56
+ expect(v2.number).toBe(2);
57
+ });
58
+ it('increments version numbers', () => {
59
+ const v1 = saveVersion(packDir, { source: 'cli' });
60
+ const v2 = saveVersion(packDir, { source: 'cli' });
61
+ const v3 = saveVersion(packDir, { source: 'cli' });
62
+ expect(v1.number).toBe(1);
63
+ expect(v2.number).toBe(2);
64
+ expect(v3.number).toBe(3);
65
+ });
66
+ it('lists versions', () => {
67
+ saveVersion(packDir, { source: 'cli', label: 'First' });
68
+ saveVersion(packDir, { source: 'dashboard', label: 'Second' });
69
+ const versions = listVersions(packDir);
70
+ expect(versions).toHaveLength(2);
71
+ expect(versions[0].label).toBe('First');
72
+ expect(versions[1].label).toBe('Second');
73
+ });
74
+ it('returns empty array when no versions exist', () => {
75
+ const versions = listVersions(packDir);
76
+ expect(versions).toEqual([]);
77
+ });
78
+ it('restores a version', () => {
79
+ // Save version 1 with original flow
80
+ saveVersion(packDir, { source: 'cli', label: 'Original' });
81
+ // Modify flow
82
+ const flowPath = join(packDir, 'flow.json');
83
+ const modified = {
84
+ inputs: {},
85
+ collectibles: [],
86
+ flow: [{ id: 'modified', type: 'navigate', params: { url: 'https://modified.com' } }],
87
+ };
88
+ writeFileSync(flowPath, JSON.stringify(modified, null, 2));
89
+ // Restore version 1
90
+ restoreVersion(packDir, 1);
91
+ // Root flow should match version 1
92
+ const restored = JSON.parse(readFileSync(flowPath, 'utf-8'));
93
+ expect(restored.flow[0].id).toBe('step1');
94
+ expect(restored.flow[0].params.url).toBe('https://example.com');
95
+ });
96
+ it('auto-saves current state before restoring', () => {
97
+ saveVersion(packDir, { source: 'cli', label: 'Original' });
98
+ // Modify flow
99
+ const flowPath = join(packDir, 'flow.json');
100
+ writeFileSync(flowPath, JSON.stringify({ flow: [{ id: 'new', type: 'navigate', params: { url: 'https://new.com' } }] }));
101
+ restoreVersion(packDir, 1);
102
+ // Should have 3 versions: original, auto-save, (version 1 was already there)
103
+ const versions = listVersions(packDir);
104
+ expect(versions).toHaveLength(2);
105
+ const autoSave = versions.find((v) => v.label?.includes('Auto-saved before restoring'));
106
+ expect(autoSave).toBeTruthy();
107
+ });
108
+ it('throws when restoring nonexistent version', () => {
109
+ expect(() => restoreVersion(packDir, 999)).toThrow('Version 999 not found');
110
+ });
111
+ it('getVersionFiles reads versioned files without restoring', () => {
112
+ saveVersion(packDir, { source: 'cli' });
113
+ // Modify current flow
114
+ const flowPath = join(packDir, 'flow.json');
115
+ writeFileSync(flowPath, JSON.stringify({ flow: [{ id: 'changed', type: 'navigate', params: { url: 'https://changed.com' } }] }));
116
+ // getVersionFiles should return the original
117
+ const files = getVersionFiles(packDir, 1);
118
+ expect(files.flow.flow[0].id).toBe('step1');
119
+ // Current flow should still be changed
120
+ const current = JSON.parse(readFileSync(flowPath, 'utf-8'));
121
+ expect(current.flow[0].id).toBe('changed');
122
+ });
123
+ it('throws when getting files for nonexistent version', () => {
124
+ expect(() => getVersionFiles(packDir, 999)).toThrow('Version 999 not found');
125
+ });
126
+ it('handles missing taskpack.json in old version gracefully', () => {
127
+ saveVersion(packDir, { source: 'cli' });
128
+ // Delete the versioned taskpack.json
129
+ const vTaskpack = join(packDir, '.versions', '1.taskpack.json');
130
+ rmSync(vTaskpack);
131
+ // getVersionFiles should still work with null taskpack
132
+ const files = getVersionFiles(packDir, 1);
133
+ expect(files.flow).toBeTruthy();
134
+ expect(files.taskpack).toBeNull();
135
+ });
136
+ it('prunes oldest versions when exceeding maxVersions', () => {
137
+ // Set a low maxVersions
138
+ const manifestPath = join(packDir, '.versions', 'manifest.json');
139
+ mkdirSync(join(packDir, '.versions'), { recursive: true });
140
+ writeFileSync(manifestPath, JSON.stringify({ versions: [], maxVersions: 3 }));
141
+ // Save 5 versions
142
+ for (let i = 0; i < 5; i++) {
143
+ saveVersion(packDir, { source: 'cli', label: `v${i}` });
144
+ }
145
+ const versions = listVersions(packDir);
146
+ expect(versions).toHaveLength(3);
147
+ // Oldest should have been pruned — remaining should be versions 3, 4, 5
148
+ expect(versions[0].number).toBe(3);
149
+ expect(versions[1].number).toBe(4);
150
+ expect(versions[2].number).toBe(5);
151
+ // Pruned versioned files should be deleted
152
+ expect(existsSync(join(packDir, '.versions', '1.flow.json'))).toBe(false);
153
+ expect(existsSync(join(packDir, '.versions', '2.flow.json'))).toBe(false);
154
+ });
155
+ it('saves conversationId when provided', () => {
156
+ const v = saveVersion(packDir, {
157
+ source: 'agent',
158
+ conversationId: 'conv-abc123',
159
+ label: 'Agent save',
160
+ });
161
+ expect(v.conversationId).toBe('conv-abc123');
162
+ const versions = listVersions(packDir);
163
+ expect(versions[0].conversationId).toBe('conv-abc123');
164
+ });
165
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=validator.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validator.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/validator.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,149 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { InputValidator } from '../validator.js';
3
+ describe('InputValidator', () => {
4
+ describe('validate', () => {
5
+ it('passes for valid inputs with all required fields', () => {
6
+ const schema = { name: { type: 'string', required: true } };
7
+ expect(() => InputValidator.validate({ name: 'John' }, schema)).not.toThrow();
8
+ });
9
+ it('passes for valid inputs with optional fields omitted', () => {
10
+ const schema = {
11
+ name: { type: 'string', required: true },
12
+ age: { type: 'number', required: false },
13
+ };
14
+ expect(() => InputValidator.validate({ name: 'John' }, schema)).not.toThrow();
15
+ });
16
+ it('passes for valid inputs with optional fields provided', () => {
17
+ const schema = {
18
+ name: { type: 'string', required: true },
19
+ age: { type: 'number', required: false },
20
+ };
21
+ expect(() => InputValidator.validate({ name: 'John', age: 30 }, schema)).not.toThrow();
22
+ });
23
+ it('fails for missing required fields', () => {
24
+ const schema = { name: { type: 'string', required: true } };
25
+ expect(() => InputValidator.validate({}, schema)).toThrow(/Missing required field: name/);
26
+ });
27
+ it('fails for multiple missing required fields', () => {
28
+ const schema = {
29
+ name: { type: 'string', required: true },
30
+ email: { type: 'string', required: true },
31
+ };
32
+ expect(() => InputValidator.validate({}, schema)).toThrow(/Missing required field/);
33
+ });
34
+ it('fails for wrong type - string expected', () => {
35
+ const schema = { name: { type: 'string', required: true } };
36
+ expect(() => InputValidator.validate({ name: 123 }, schema)).toThrow(/must be a string/);
37
+ });
38
+ it('fails for wrong type - number expected', () => {
39
+ const schema = { age: { type: 'number', required: true } };
40
+ expect(() => InputValidator.validate({ age: 'thirty' }, schema)).toThrow(/must be a number/);
41
+ });
42
+ it('fails for wrong type - boolean expected', () => {
43
+ const schema = { enabled: { type: 'boolean', required: true } };
44
+ expect(() => InputValidator.validate({ enabled: 'true' }, schema)).toThrow(/must be a boolean/);
45
+ });
46
+ it('fails for unknown fields', () => {
47
+ const schema = { name: { type: 'string', required: true } };
48
+ expect(() => InputValidator.validate({ name: 'John', extra: 'field' }, schema)).toThrow(/Unknown field: extra/);
49
+ });
50
+ it('validates number type correctly', () => {
51
+ const schema = { count: { type: 'number', required: true } };
52
+ expect(() => InputValidator.validate({ count: 42 }, schema)).not.toThrow();
53
+ expect(() => InputValidator.validate({ count: 3.14 }, schema)).not.toThrow();
54
+ });
55
+ it('validates boolean type correctly', () => {
56
+ const schema = { active: { type: 'boolean', required: true } };
57
+ expect(() => InputValidator.validate({ active: true }, schema)).not.toThrow();
58
+ expect(() => InputValidator.validate({ active: false }, schema)).not.toThrow();
59
+ });
60
+ it('handles empty schema', () => {
61
+ const schema = {};
62
+ expect(() => InputValidator.validate({}, schema)).not.toThrow();
63
+ });
64
+ it('fails for empty inputs with unknown field on empty schema', () => {
65
+ const schema = {};
66
+ expect(() => InputValidator.validate({ foo: 'bar' }, schema)).toThrow(/Unknown field: foo/);
67
+ });
68
+ });
69
+ describe('applyDefaults', () => {
70
+ it('applies defaults for missing fields', () => {
71
+ const schema = { count: { type: 'number', default: 10 } };
72
+ expect(InputValidator.applyDefaults({}, schema)).toEqual({ count: 10 });
73
+ });
74
+ it('does not override provided values', () => {
75
+ const schema = { count: { type: 'number', default: 10 } };
76
+ expect(InputValidator.applyDefaults({ count: 5 }, schema)).toEqual({ count: 5 });
77
+ });
78
+ it('applies string defaults', () => {
79
+ const schema = { name: { type: 'string', default: 'Anonymous' } };
80
+ expect(InputValidator.applyDefaults({}, schema)).toEqual({ name: 'Anonymous' });
81
+ });
82
+ it('applies boolean defaults', () => {
83
+ const schema = { enabled: { type: 'boolean', default: true } };
84
+ expect(InputValidator.applyDefaults({}, schema)).toEqual({ enabled: true });
85
+ });
86
+ it('applies false boolean default', () => {
87
+ const schema = { enabled: { type: 'boolean', default: false } };
88
+ expect(InputValidator.applyDefaults({}, schema)).toEqual({ enabled: false });
89
+ });
90
+ it('does not apply default when value is explicitly false', () => {
91
+ const schema = { enabled: { type: 'boolean', default: true } };
92
+ expect(InputValidator.applyDefaults({ enabled: false }, schema)).toEqual({ enabled: false });
93
+ });
94
+ it('does not apply default when value is explicitly 0', () => {
95
+ const schema = { count: { type: 'number', default: 10 } };
96
+ expect(InputValidator.applyDefaults({ count: 0 }, schema)).toEqual({ count: 0 });
97
+ });
98
+ it('does not apply default when value is explicitly empty string', () => {
99
+ const schema = { name: { type: 'string', default: 'default' } };
100
+ expect(InputValidator.applyDefaults({ name: '' }, schema)).toEqual({ name: '' });
101
+ });
102
+ it('applies multiple defaults', () => {
103
+ const schema = {
104
+ name: { type: 'string', default: 'User' },
105
+ count: { type: 'number', default: 1 },
106
+ active: { type: 'boolean', default: true },
107
+ };
108
+ expect(InputValidator.applyDefaults({}, schema)).toEqual({
109
+ name: 'User',
110
+ count: 1,
111
+ active: true,
112
+ });
113
+ });
114
+ it('applies only missing defaults', () => {
115
+ const schema = {
116
+ name: { type: 'string', default: 'User' },
117
+ count: { type: 'number', default: 1 },
118
+ active: { type: 'boolean', default: true },
119
+ };
120
+ expect(InputValidator.applyDefaults({ name: 'Custom' }, schema)).toEqual({
121
+ name: 'Custom',
122
+ count: 1,
123
+ active: true,
124
+ });
125
+ });
126
+ it('does not add fields without defaults', () => {
127
+ const schema = {
128
+ name: { type: 'string', required: true },
129
+ count: { type: 'number', default: 10 },
130
+ };
131
+ expect(InputValidator.applyDefaults({}, schema)).toEqual({ count: 10 });
132
+ });
133
+ it('preserves existing fields not in schema', () => {
134
+ const schema = { count: { type: 'number', default: 10 } };
135
+ expect(InputValidator.applyDefaults({ extra: 'value' }, schema)).toEqual({
136
+ extra: 'value',
137
+ count: 10,
138
+ });
139
+ });
140
+ it('returns new object without modifying input', () => {
141
+ const schema = { count: { type: 'number', default: 10 } };
142
+ const original = { name: 'test' };
143
+ const result = InputValidator.applyDefaults(original, schema);
144
+ expect(result).not.toBe(original);
145
+ expect(original).toEqual({ name: 'test' });
146
+ expect(result).toEqual({ name: 'test', count: 10 });
147
+ });
148
+ });
149
+ });