@showrun/core 0.1.0 → 0.1.1-b

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 (56) hide show
  1. package/dist/__tests__/config.test.d.ts +2 -0
  2. package/dist/__tests__/config.test.d.ts.map +1 -0
  3. package/dist/__tests__/config.test.js +164 -0
  4. package/dist/__tests__/httpReplay.test.d.ts +2 -0
  5. package/dist/__tests__/httpReplay.test.d.ts.map +1 -0
  6. package/dist/__tests__/httpReplay.test.js +306 -0
  7. package/dist/__tests__/requestSnapshot.test.d.ts +2 -0
  8. package/dist/__tests__/requestSnapshot.test.d.ts.map +1 -0
  9. package/dist/__tests__/requestSnapshot.test.js +323 -0
  10. package/dist/browserLauncher.d.ts.map +1 -1
  11. package/dist/browserLauncher.js +7 -1
  12. package/dist/config.d.ts +96 -0
  13. package/dist/config.d.ts.map +1 -0
  14. package/dist/config.js +268 -0
  15. package/dist/dsl/interpreter.d.ts +9 -0
  16. package/dist/dsl/interpreter.d.ts.map +1 -1
  17. package/dist/dsl/interpreter.js +24 -5
  18. package/dist/dsl/stepHandlers.d.ts +7 -0
  19. package/dist/dsl/stepHandlers.d.ts.map +1 -1
  20. package/dist/dsl/stepHandlers.js +141 -5
  21. package/dist/dsl/templating.d.ts.map +1 -1
  22. package/dist/dsl/templating.js +11 -0
  23. package/dist/dsl/types.d.ts +16 -4
  24. package/dist/dsl/types.d.ts.map +1 -1
  25. package/dist/dsl/validation.d.ts.map +1 -1
  26. package/dist/dsl/validation.js +29 -14
  27. package/dist/httpReplay.d.ts +43 -0
  28. package/dist/httpReplay.d.ts.map +1 -0
  29. package/dist/httpReplay.js +102 -0
  30. package/dist/index.d.ts +4 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +5 -0
  33. package/dist/jsonPackValidator.d.ts.map +1 -1
  34. package/dist/jsonPackValidator.js +12 -3
  35. package/dist/loader.d.ts.map +1 -1
  36. package/dist/loader.js +4 -0
  37. package/dist/networkCapture.d.ts +10 -4
  38. package/dist/networkCapture.d.ts.map +1 -1
  39. package/dist/networkCapture.js +20 -12
  40. package/dist/requestSnapshot.d.ts +91 -0
  41. package/dist/requestSnapshot.d.ts.map +1 -0
  42. package/dist/requestSnapshot.js +200 -0
  43. package/dist/runner.d.ts.map +1 -1
  44. package/dist/runner.js +209 -13
  45. package/dist/storage/index.d.ts +3 -0
  46. package/dist/storage/index.d.ts.map +1 -0
  47. package/dist/storage/index.js +2 -0
  48. package/dist/storage/keys.d.ts +12 -0
  49. package/dist/storage/keys.d.ts.map +1 -0
  50. package/dist/storage/keys.js +39 -0
  51. package/dist/storage/types.d.ts +112 -0
  52. package/dist/storage/types.d.ts.map +1 -0
  53. package/dist/storage/types.js +7 -0
  54. package/dist/types.d.ts +5 -0
  55. package/dist/types.d.ts.map +1 -1
  56. package/package.json +2 -2
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=config.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/config.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,164 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdirSync, rmSync, existsSync } from 'fs';
3
+ import { join, resolve } from 'path';
4
+ import { tmpdir } from 'os';
5
+ import { randomBytes } from 'crypto';
6
+ import { deepMerge, discoverConfigDirs, loadConfig, applyConfigToEnv, resolveFilePath, } from '../config.js';
7
+ /** Create a unique temporary directory for each test */
8
+ function makeTempDir() {
9
+ const dir = join(tmpdir(), `showrun-test-${randomBytes(6).toString('hex')}`);
10
+ mkdirSync(dir, { recursive: true });
11
+ return dir;
12
+ }
13
+ describe('deepMerge', () => {
14
+ it('merges flat objects', () => {
15
+ const base = { a: 1, b: 2 };
16
+ const override = { b: 3, c: 4 };
17
+ expect(deepMerge(base, override)).toEqual({ a: 1, b: 3, c: 4 });
18
+ });
19
+ it('recursively merges nested objects', () => {
20
+ const base = { llm: { provider: 'openai', openai: { apiKey: 'k1' } } };
21
+ const override = { llm: { provider: 'anthropic', anthropic: { apiKey: 'k2' } } };
22
+ const result = deepMerge(base, override);
23
+ expect(result).toEqual({
24
+ llm: {
25
+ provider: 'anthropic',
26
+ openai: { apiKey: 'k1' },
27
+ anthropic: { apiKey: 'k2' },
28
+ },
29
+ });
30
+ });
31
+ it('skips null and undefined values in override', () => {
32
+ const base = { a: 1, b: 2 };
33
+ const override = { a: null, b: undefined, c: 3 };
34
+ expect(deepMerge(base, override)).toEqual({ a: 1, b: 2, c: 3 });
35
+ });
36
+ it('replaces arrays instead of merging them', () => {
37
+ const base = { items: [1, 2, 3] };
38
+ const override = { items: [4, 5] };
39
+ expect(deepMerge(base, override)).toEqual({ items: [4, 5] });
40
+ });
41
+ it('replaces primitives', () => {
42
+ const base = { x: 'old' };
43
+ const override = { x: 'new' };
44
+ expect(deepMerge(base, override)).toEqual({ x: 'new' });
45
+ });
46
+ });
47
+ describe('discoverConfigDirs', () => {
48
+ it('returns an array of strings', () => {
49
+ const dirs = discoverConfigDirs();
50
+ expect(Array.isArray(dirs)).toBe(true);
51
+ expect(dirs.length).toBeGreaterThan(0);
52
+ for (const d of dirs) {
53
+ expect(typeof d).toBe('string');
54
+ }
55
+ });
56
+ it('includes cwd/.showrun as the last entry', () => {
57
+ const dirs = discoverConfigDirs();
58
+ const last = dirs[dirs.length - 1];
59
+ expect(last).toBe(join(process.cwd(), '.showrun'));
60
+ });
61
+ it('respects XDG_CONFIG_HOME when set (non-Windows)', () => {
62
+ if (process.platform === 'win32')
63
+ return; // skip on Windows
64
+ const original = process.env.XDG_CONFIG_HOME;
65
+ const customXdg = '/tmp/custom-xdg-config';
66
+ process.env.XDG_CONFIG_HOME = customXdg;
67
+ try {
68
+ const dirs = discoverConfigDirs();
69
+ expect(dirs[0]).toBe(join(customXdg, 'showrun'));
70
+ }
71
+ finally {
72
+ if (original === undefined)
73
+ delete process.env.XDG_CONFIG_HOME;
74
+ else
75
+ process.env.XDG_CONFIG_HOME = original;
76
+ }
77
+ });
78
+ });
79
+ describe('applyConfigToEnv', () => {
80
+ const testEnvVars = [
81
+ 'LLM_PROVIDER',
82
+ 'ANTHROPIC_API_KEY',
83
+ 'OPENAI_API_KEY',
84
+ 'MAX_BROWSER_ROUNDS',
85
+ ];
86
+ const saved = {};
87
+ beforeEach(() => {
88
+ for (const v of testEnvVars) {
89
+ saved[v] = process.env[v];
90
+ delete process.env[v];
91
+ }
92
+ });
93
+ afterEach(() => {
94
+ for (const v of testEnvVars) {
95
+ if (saved[v] === undefined)
96
+ delete process.env[v];
97
+ else
98
+ process.env[v] = saved[v];
99
+ }
100
+ });
101
+ it('sets env vars from config when not already present', () => {
102
+ const config = {
103
+ llm: { provider: 'anthropic', anthropic: { apiKey: 'sk-test' } },
104
+ };
105
+ applyConfigToEnv(config);
106
+ expect(process.env.LLM_PROVIDER).toBe('anthropic');
107
+ expect(process.env.ANTHROPIC_API_KEY).toBe('sk-test');
108
+ });
109
+ it('does not overwrite existing env vars', () => {
110
+ process.env.ANTHROPIC_API_KEY = 'existing-key';
111
+ const config = {
112
+ llm: { anthropic: { apiKey: 'should-not-replace' } },
113
+ };
114
+ applyConfigToEnv(config);
115
+ expect(process.env.ANTHROPIC_API_KEY).toBe('existing-key');
116
+ });
117
+ it('converts numbers to strings', () => {
118
+ const config = {
119
+ agent: { maxBrowserRounds: 5 },
120
+ };
121
+ applyConfigToEnv(config);
122
+ expect(process.env.MAX_BROWSER_ROUNDS).toBe('5');
123
+ });
124
+ });
125
+ describe('loadConfig', () => {
126
+ let tempDir;
127
+ beforeEach(() => {
128
+ tempDir = makeTempDir();
129
+ });
130
+ afterEach(() => {
131
+ rmSync(tempDir, { recursive: true, force: true });
132
+ });
133
+ it('returns empty config and loadedFiles when no config files exist', () => {
134
+ // loadConfig uses discoverConfigDirs which won't include tempDir
135
+ // but this validates the basic structure
136
+ const result = loadConfig();
137
+ expect(result.config).toBeDefined();
138
+ expect(Array.isArray(result.loadedFiles)).toBe(true);
139
+ expect(Array.isArray(result.searchedDirs)).toBe(true);
140
+ });
141
+ });
142
+ describe('resolveFilePath', () => {
143
+ let tempDir;
144
+ beforeEach(() => {
145
+ tempDir = makeTempDir();
146
+ });
147
+ afterEach(() => {
148
+ rmSync(tempDir, { recursive: true, force: true });
149
+ });
150
+ it('returns null when file is not found', () => {
151
+ const result = resolveFilePath(`nonexistent-${randomBytes(8).toString('hex')}.txt`);
152
+ expect(result).toBeNull();
153
+ });
154
+ it('finds a file in cwd', () => {
155
+ // This tests the cwd fallback — the EXPLORATION_AGENT_SYSTEM_PROMPT.md
156
+ // should be found if it exists in the repo root
157
+ const promptFile = 'EXPLORATION_AGENT_SYSTEM_PROMPT.md';
158
+ const cwdPath = resolve(process.cwd(), promptFile);
159
+ if (existsSync(cwdPath)) {
160
+ const result = resolveFilePath(promptFile);
161
+ expect(result).toBeTruthy();
162
+ }
163
+ });
164
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=httpReplay.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"httpReplay.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/httpReplay.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,306 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { isFlowHttpCompatible, replayFromSnapshot } from '../httpReplay.js';
3
+ function makeSnapshotFile(stepIds) {
4
+ const snapshots = {};
5
+ for (const id of stepIds) {
6
+ snapshots[id] = {
7
+ stepId: id,
8
+ capturedAt: Date.now(),
9
+ ttl: null,
10
+ request: {
11
+ method: 'GET',
12
+ url: 'https://api.example.com/data',
13
+ headers: { 'content-type': 'application/json' },
14
+ body: null,
15
+ },
16
+ responseValidation: {
17
+ expectedStatus: 200,
18
+ expectedContentType: 'application/json',
19
+ expectedKeys: [],
20
+ },
21
+ sensitiveHeaders: [],
22
+ };
23
+ }
24
+ return { version: 1, snapshots };
25
+ }
26
+ // ---------------------------------------------------------------------------
27
+ // isFlowHttpCompatible
28
+ // ---------------------------------------------------------------------------
29
+ describe('isFlowHttpCompatible', () => {
30
+ it('returns true when all network_replay steps have snapshots and no DOM extraction', () => {
31
+ const steps = [
32
+ { id: 'nav1', type: 'navigate', params: { url: 'https://example.com' } },
33
+ { id: 'find1', type: 'network_find', params: { where: { urlIncludes: '/api/' }, saveAs: 'reqId' } },
34
+ {
35
+ id: 'replay1', type: 'network_replay',
36
+ params: { requestId: '{{vars.reqId}}', auth: 'browser_context', out: 'data', response: { as: 'json' } },
37
+ },
38
+ { id: 'var1', type: 'set_var', params: { name: 'x', value: 'y' } },
39
+ ];
40
+ const snapshots = makeSnapshotFile(['replay1']);
41
+ expect(isFlowHttpCompatible(steps, snapshots)).toBe(true);
42
+ });
43
+ it('returns false when snapshots is null', () => {
44
+ const steps = [
45
+ {
46
+ id: 'replay1', type: 'network_replay',
47
+ params: { requestId: '{{vars.reqId}}', auth: 'browser_context', out: 'data', response: { as: 'json' } },
48
+ },
49
+ ];
50
+ expect(isFlowHttpCompatible(steps, null)).toBe(false);
51
+ });
52
+ it('returns false when a replay step has no snapshot', () => {
53
+ const steps = [
54
+ {
55
+ id: 'replay1', type: 'network_replay',
56
+ params: { requestId: '{{vars.reqId}}', auth: 'browser_context', out: 'data', response: { as: 'json' } },
57
+ },
58
+ {
59
+ id: 'replay2', type: 'network_replay',
60
+ params: { requestId: '{{vars.reqId2}}', auth: 'browser_context', out: 'data2', response: { as: 'json' } },
61
+ },
62
+ ];
63
+ const snapshots = makeSnapshotFile(['replay1']); // Missing replay2
64
+ expect(isFlowHttpCompatible(steps, snapshots)).toBe(false);
65
+ });
66
+ it('returns false when flow contains extract_text step', () => {
67
+ const steps = [
68
+ {
69
+ id: 'replay1', type: 'network_replay',
70
+ params: { requestId: '{{vars.reqId}}', auth: 'browser_context', out: 'data', response: { as: 'json' } },
71
+ },
72
+ {
73
+ id: 'extract1', type: 'extract_text',
74
+ params: { target: { kind: 'css', selector: '.title' }, out: 'title' },
75
+ },
76
+ ];
77
+ const snapshots = makeSnapshotFile(['replay1']);
78
+ expect(isFlowHttpCompatible(steps, snapshots)).toBe(false);
79
+ });
80
+ it('returns false when flow has no network_replay steps', () => {
81
+ const steps = [
82
+ { id: 'nav1', type: 'navigate', params: { url: 'https://example.com' } },
83
+ { id: 'var1', type: 'set_var', params: { name: 'x', value: 'y' } },
84
+ ];
85
+ const snapshots = makeSnapshotFile([]);
86
+ expect(isFlowHttpCompatible(steps, snapshots)).toBe(false);
87
+ });
88
+ it('returns false when a snapshot is stale (TTL expired)', () => {
89
+ const snapshots = makeSnapshotFile(['replay1']);
90
+ snapshots.snapshots['replay1'].capturedAt = Date.now() - 120_000;
91
+ snapshots.snapshots['replay1'].ttl = 60_000;
92
+ const steps = [
93
+ {
94
+ id: 'replay1', type: 'network_replay',
95
+ params: { requestId: '{{vars.reqId}}', auth: 'browser_context', out: 'data', response: { as: 'json' } },
96
+ },
97
+ ];
98
+ expect(isFlowHttpCompatible(steps, snapshots)).toBe(false);
99
+ });
100
+ it('allows sleep, set_var, and network_extract in HTTP mode', () => {
101
+ const steps = [
102
+ {
103
+ id: 'replay1', type: 'network_replay',
104
+ params: { requestId: '{{vars.reqId}}', auth: 'browser_context', out: 'data', response: { as: 'json' } },
105
+ },
106
+ { id: 'sleep1', type: 'sleep', params: { durationMs: 100 } },
107
+ { id: 'var1', type: 'set_var', params: { name: 'x', value: 'y' } },
108
+ {
109
+ id: 'extract1', type: 'network_extract',
110
+ params: { fromVar: 'data', as: 'json', path: 'results', out: 'items' },
111
+ },
112
+ ];
113
+ const snapshots = makeSnapshotFile(['replay1']);
114
+ expect(isFlowHttpCompatible(steps, snapshots)).toBe(true);
115
+ });
116
+ });
117
+ // ---------------------------------------------------------------------------
118
+ // replayFromSnapshot
119
+ // ---------------------------------------------------------------------------
120
+ describe('replayFromSnapshot', () => {
121
+ const originalFetch = globalThis.fetch;
122
+ afterEach(() => {
123
+ globalThis.fetch = originalFetch;
124
+ });
125
+ it('makes a GET request from snapshot data', async () => {
126
+ const mockResponse = {
127
+ status: 200,
128
+ text: async () => '{"results":[]}',
129
+ headers: new Headers({ 'content-type': 'application/json' }),
130
+ };
131
+ globalThis.fetch = vi.fn().mockResolvedValue(mockResponse);
132
+ const snapshot = {
133
+ stepId: 'test',
134
+ capturedAt: Date.now(),
135
+ ttl: null,
136
+ request: {
137
+ method: 'GET',
138
+ url: 'https://api.example.com/items',
139
+ headers: { accept: 'application/json' },
140
+ body: null,
141
+ },
142
+ responseValidation: {
143
+ expectedStatus: 200,
144
+ expectedContentType: 'application/json',
145
+ expectedKeys: [],
146
+ },
147
+ sensitiveHeaders: [],
148
+ };
149
+ const result = await replayFromSnapshot(snapshot, {}, {});
150
+ expect(globalThis.fetch).toHaveBeenCalledWith('https://api.example.com/items', expect.objectContaining({ method: 'GET' }));
151
+ expect(result.status).toBe(200);
152
+ expect(result.body).toBe('{"results":[]}');
153
+ });
154
+ it('makes a POST request with body', async () => {
155
+ const mockResponse = {
156
+ status: 200,
157
+ text: async () => '{"ok":true}',
158
+ headers: new Headers({ 'content-type': 'application/json' }),
159
+ };
160
+ globalThis.fetch = vi.fn().mockResolvedValue(mockResponse);
161
+ const snapshot = {
162
+ stepId: 'test',
163
+ capturedAt: Date.now(),
164
+ ttl: null,
165
+ request: {
166
+ method: 'POST',
167
+ url: 'https://api.example.com/search',
168
+ headers: { 'content-type': 'application/json' },
169
+ body: '{"query":"test"}',
170
+ },
171
+ responseValidation: {
172
+ expectedStatus: 200,
173
+ expectedContentType: 'application/json',
174
+ expectedKeys: [],
175
+ },
176
+ sensitiveHeaders: [],
177
+ };
178
+ await replayFromSnapshot(snapshot, {}, {});
179
+ expect(globalThis.fetch).toHaveBeenCalledWith('https://api.example.com/search', expect.objectContaining({
180
+ method: 'POST',
181
+ body: '{"query":"test"}',
182
+ }));
183
+ });
184
+ it('applies overrides from inputs before request', async () => {
185
+ const mockResponse = {
186
+ status: 200,
187
+ text: async () => '{"data":[]}',
188
+ headers: new Headers({ 'content-type': 'application/json' }),
189
+ };
190
+ globalThis.fetch = vi.fn().mockResolvedValue(mockResponse);
191
+ const snapshot = {
192
+ stepId: 'test',
193
+ capturedAt: Date.now(),
194
+ ttl: null,
195
+ request: {
196
+ method: 'POST',
197
+ url: 'https://api.example.com/search',
198
+ headers: { 'content-type': 'application/json' },
199
+ body: '{"batch":"W24"}',
200
+ },
201
+ overrides: {
202
+ bodyReplace: [{ find: 'W24', replace: '{{inputs.batch}}' }],
203
+ },
204
+ responseValidation: {
205
+ expectedStatus: 200,
206
+ expectedContentType: 'application/json',
207
+ expectedKeys: [],
208
+ },
209
+ sensitiveHeaders: [],
210
+ };
211
+ await replayFromSnapshot(snapshot, { batch: 'S25' }, {});
212
+ expect(globalThis.fetch).toHaveBeenCalledWith('https://api.example.com/search', expect.objectContaining({
213
+ body: '{"batch":"S25"}',
214
+ }));
215
+ });
216
+ it('does not send body for GET requests', async () => {
217
+ const mockResponse = {
218
+ status: 200,
219
+ text: async () => '{}',
220
+ headers: new Headers({}),
221
+ };
222
+ globalThis.fetch = vi.fn().mockResolvedValue(mockResponse);
223
+ const snapshot = {
224
+ stepId: 'test',
225
+ capturedAt: Date.now(),
226
+ ttl: null,
227
+ request: {
228
+ method: 'GET',
229
+ url: 'https://api.example.com/items',
230
+ headers: {},
231
+ body: 'should-be-ignored',
232
+ },
233
+ responseValidation: {
234
+ expectedStatus: 200,
235
+ expectedContentType: '',
236
+ expectedKeys: [],
237
+ },
238
+ sensitiveHeaders: [],
239
+ };
240
+ await replayFromSnapshot(snapshot, {}, {});
241
+ const fetchCall = globalThis.fetch.mock.calls[0][1];
242
+ expect(fetchCall.body).toBeUndefined();
243
+ });
244
+ it('preserves sensitive headers in the request (needed for auth)', async () => {
245
+ const mockResponse = {
246
+ status: 200,
247
+ text: async () => '{}',
248
+ headers: new Headers({}),
249
+ };
250
+ globalThis.fetch = vi.fn().mockResolvedValue(mockResponse);
251
+ const snapshot = {
252
+ stepId: 'test',
253
+ capturedAt: Date.now(),
254
+ ttl: null,
255
+ request: {
256
+ method: 'GET',
257
+ url: 'https://api.example.com/items',
258
+ headers: {
259
+ accept: 'application/json',
260
+ cookie: 'session=abc123',
261
+ Authorization: 'Bearer token',
262
+ },
263
+ body: null,
264
+ },
265
+ responseValidation: {
266
+ expectedStatus: 200,
267
+ expectedContentType: '',
268
+ expectedKeys: [],
269
+ },
270
+ sensitiveHeaders: ['cookie', 'Authorization'],
271
+ };
272
+ await replayFromSnapshot(snapshot, {}, {});
273
+ const fetchCall = globalThis.fetch.mock.calls[0][1];
274
+ expect(fetchCall.headers).toHaveProperty('accept', 'application/json');
275
+ expect(fetchCall.headers).toHaveProperty('cookie', 'session=abc123');
276
+ expect(fetchCall.headers).toHaveProperty('Authorization', 'Bearer token');
277
+ });
278
+ it('throws timeout error when fetch takes too long', async () => {
279
+ // Mock a fetch that never resolves
280
+ globalThis.fetch = vi.fn().mockImplementation((_url, options) => new Promise((_resolve, reject) => {
281
+ options.signal?.addEventListener('abort', () => {
282
+ const err = new Error('The operation was aborted');
283
+ err.name = 'AbortError';
284
+ reject(err);
285
+ });
286
+ }));
287
+ const snapshot = {
288
+ stepId: 'test',
289
+ capturedAt: Date.now(),
290
+ ttl: null,
291
+ request: {
292
+ method: 'GET',
293
+ url: 'https://api.example.com/items',
294
+ headers: {},
295
+ body: null,
296
+ },
297
+ responseValidation: {
298
+ expectedStatus: 200,
299
+ expectedContentType: '',
300
+ expectedKeys: [],
301
+ },
302
+ sensitiveHeaders: [],
303
+ };
304
+ await expect(replayFromSnapshot(snapshot, {}, {}, { timeoutMs: 50 })).rejects.toThrow('HTTP replay timed out');
305
+ });
306
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=requestSnapshot.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"requestSnapshot.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/requestSnapshot.test.ts"],"names":[],"mappings":""}