@showrun/core 0.1.8 → 0.1.10-rc.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 (64) hide show
  1. package/dist/__tests__/dsl-validation.test.js +185 -0
  2. package/dist/__tests__/httpReplay.test.js +62 -0
  3. package/dist/__tests__/proxy.test.d.ts +2 -0
  4. package/dist/__tests__/proxy.test.d.ts.map +1 -0
  5. package/dist/__tests__/proxy.test.js +117 -0
  6. package/dist/__tests__/registry-client.test.d.ts +2 -0
  7. package/dist/__tests__/registry-client.test.d.ts.map +1 -0
  8. package/dist/__tests__/registry-client.test.js +228 -0
  9. package/dist/browserLauncher.d.ts +16 -0
  10. package/dist/browserLauncher.d.ts.map +1 -1
  11. package/dist/browserLauncher.js +46 -3
  12. package/dist/config.d.ts +3 -0
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +4 -0
  15. package/dist/dsl/builders.d.ts +22 -1
  16. package/dist/dsl/builders.d.ts.map +1 -1
  17. package/dist/dsl/builders.js +23 -0
  18. package/dist/dsl/interpreter.d.ts +5 -0
  19. package/dist/dsl/interpreter.d.ts.map +1 -1
  20. package/dist/dsl/interpreter.js +1 -0
  21. package/dist/dsl/stepHandlers.d.ts +3 -0
  22. package/dist/dsl/stepHandlers.d.ts.map +1 -1
  23. package/dist/dsl/stepHandlers.js +62 -2
  24. package/dist/dsl/types.d.ts +53 -1
  25. package/dist/dsl/types.d.ts.map +1 -1
  26. package/dist/dsl/validation.d.ts.map +1 -1
  27. package/dist/dsl/validation.js +90 -1
  28. package/dist/httpReplay.d.ts +5 -0
  29. package/dist/httpReplay.d.ts.map +1 -1
  30. package/dist/httpReplay.js +46 -2
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +4 -0
  34. package/dist/jsonPackValidator.js +1 -1
  35. package/dist/proxy/index.d.ts +4 -0
  36. package/dist/proxy/index.d.ts.map +1 -0
  37. package/dist/proxy/index.js +3 -0
  38. package/dist/proxy/oxylabs.d.ts +15 -0
  39. package/dist/proxy/oxylabs.d.ts.map +1 -0
  40. package/dist/proxy/oxylabs.js +34 -0
  41. package/dist/proxy/proxyService.d.ts +34 -0
  42. package/dist/proxy/proxyService.d.ts.map +1 -0
  43. package/dist/proxy/proxyService.js +69 -0
  44. package/dist/proxy/types.d.ts +59 -0
  45. package/dist/proxy/types.d.ts.map +1 -0
  46. package/dist/proxy/types.js +4 -0
  47. package/dist/registry/client.d.ts +34 -0
  48. package/dist/registry/client.d.ts.map +1 -0
  49. package/dist/registry/client.js +275 -0
  50. package/dist/registry/index.d.ts +4 -0
  51. package/dist/registry/index.d.ts.map +1 -0
  52. package/dist/registry/index.js +3 -0
  53. package/dist/registry/tokenStore.d.ts +13 -0
  54. package/dist/registry/tokenStore.d.ts.map +1 -0
  55. package/dist/registry/tokenStore.js +54 -0
  56. package/dist/registry/types.d.ts +120 -0
  57. package/dist/registry/types.d.ts.map +1 -0
  58. package/dist/registry/types.js +4 -0
  59. package/dist/runner.d.ts +9 -0
  60. package/dist/runner.d.ts.map +1 -1
  61. package/dist/runner.js +13 -4
  62. package/dist/types.d.ts +6 -0
  63. package/dist/types.d.ts.map +1 -1
  64. package/package.json +1 -1
@@ -201,3 +201,188 @@ describe('validateFlow — unknown params rejection', () => {
201
201
  expect(errors[0]).toContain('Unknown step type: custom_magic');
202
202
  });
203
203
  });
204
+ describe('validateFlow — dom_scrape step', () => {
205
+ const validDomScrape = {
206
+ id: 'scrape_results',
207
+ type: 'dom_scrape',
208
+ params: {
209
+ target: { kind: 'css', selector: '.search-result' },
210
+ collect: [
211
+ { key: 'title', target: { kind: 'css', selector: 'h3' } },
212
+ { key: 'url', target: { kind: 'css', selector: 'a' }, extract: 'attribute', attribute: 'href' },
213
+ { key: 'description', target: { kind: 'css', selector: '.snippet' } },
214
+ ],
215
+ skip_empty: true,
216
+ out: 'results',
217
+ },
218
+ };
219
+ it('accepts a valid dom_scrape step', () => {
220
+ const errors = [];
221
+ validateFlow([validDomScrape], errors);
222
+ expect(errors).toEqual([]);
223
+ });
224
+ it('accepts dom_scrape with legacy selector', () => {
225
+ const errors = [];
226
+ validateFlow([{
227
+ id: 'scrape_legacy',
228
+ type: 'dom_scrape',
229
+ params: {
230
+ selector: '.item',
231
+ collect: [{ key: 'name', target: { kind: 'css', selector: 'span' } }],
232
+ out: 'items',
233
+ },
234
+ }], errors);
235
+ expect(errors).toEqual([]);
236
+ });
237
+ it('errors when missing target and selector', () => {
238
+ const errors = [];
239
+ validateFlow([{
240
+ id: 'scrape_no_target',
241
+ type: 'dom_scrape',
242
+ params: {
243
+ collect: [{ key: 'name', target: { kind: 'css', selector: 'span' } }],
244
+ out: 'items',
245
+ },
246
+ }], errors);
247
+ expect(errors.some(e => e.includes('must have either "selector" or "target"'))).toBe(true);
248
+ });
249
+ it('errors when missing out', () => {
250
+ const errors = [];
251
+ validateFlow([{
252
+ id: 'scrape_no_out',
253
+ type: 'dom_scrape',
254
+ params: {
255
+ target: { kind: 'css', selector: '.item' },
256
+ collect: [{ key: 'name', target: { kind: 'css', selector: 'span' } }],
257
+ },
258
+ }], errors);
259
+ expect(errors.some(e => e.includes('non-empty string "out"'))).toBe(true);
260
+ });
261
+ it('errors when collect is empty', () => {
262
+ const errors = [];
263
+ validateFlow([{
264
+ id: 'scrape_empty_collect',
265
+ type: 'dom_scrape',
266
+ params: {
267
+ target: { kind: 'css', selector: '.item' },
268
+ collect: [],
269
+ out: 'items',
270
+ },
271
+ }], errors);
272
+ expect(errors.some(e => e.includes('non-empty "collect" array'))).toBe(true);
273
+ });
274
+ it('errors when collect is missing', () => {
275
+ const errors = [];
276
+ validateFlow([{
277
+ id: 'scrape_missing_collect',
278
+ type: 'dom_scrape',
279
+ params: {
280
+ target: { kind: 'css', selector: '.item' },
281
+ out: 'items',
282
+ },
283
+ }], errors);
284
+ expect(errors.some(e => e.includes('non-empty "collect" array'))).toBe(true);
285
+ });
286
+ it('errors on duplicate collect keys', () => {
287
+ const errors = [];
288
+ validateFlow([{
289
+ id: 'scrape_dup_keys',
290
+ type: 'dom_scrape',
291
+ params: {
292
+ target: { kind: 'css', selector: '.item' },
293
+ collect: [
294
+ { key: 'name', target: { kind: 'css', selector: 'h3' } },
295
+ { key: 'name', target: { kind: 'css', selector: 'h4' } },
296
+ ],
297
+ out: 'items',
298
+ },
299
+ }], errors);
300
+ expect(errors.some(e => e.includes('duplicate key "name"'))).toBe(true);
301
+ });
302
+ it('errors when collect field missing key', () => {
303
+ const errors = [];
304
+ validateFlow([{
305
+ id: 'scrape_no_key',
306
+ type: 'dom_scrape',
307
+ params: {
308
+ target: { kind: 'css', selector: '.item' },
309
+ collect: [{ target: { kind: 'css', selector: 'span' } }],
310
+ out: 'items',
311
+ },
312
+ }], errors);
313
+ expect(errors.some(e => e.includes('collect[0] must have a non-empty string "key"'))).toBe(true);
314
+ });
315
+ it('errors when collect field missing target', () => {
316
+ const errors = [];
317
+ validateFlow([{
318
+ id: 'scrape_no_field_target',
319
+ type: 'dom_scrape',
320
+ params: {
321
+ target: { kind: 'css', selector: '.item' },
322
+ collect: [{ key: 'name' }],
323
+ out: 'items',
324
+ },
325
+ }], errors);
326
+ expect(errors.some(e => e.includes('collect[0] must have a "target"'))).toBe(true);
327
+ });
328
+ it('errors when extract is "attribute" but attribute is missing', () => {
329
+ const errors = [];
330
+ validateFlow([{
331
+ id: 'scrape_attr_missing',
332
+ type: 'dom_scrape',
333
+ params: {
334
+ target: { kind: 'css', selector: '.item' },
335
+ collect: [
336
+ { key: 'url', target: { kind: 'css', selector: 'a' }, extract: 'attribute' },
337
+ ],
338
+ out: 'items',
339
+ },
340
+ }], errors);
341
+ expect(errors.some(e => e.includes('requires "attribute" when extract is "attribute"'))).toBe(true);
342
+ });
343
+ it('errors on invalid extract value', () => {
344
+ const errors = [];
345
+ validateFlow([{
346
+ id: 'scrape_bad_extract',
347
+ type: 'dom_scrape',
348
+ params: {
349
+ target: { kind: 'css', selector: '.item' },
350
+ collect: [
351
+ { key: 'val', target: { kind: 'css', selector: 'span' }, extract: 'innerText' },
352
+ ],
353
+ out: 'items',
354
+ },
355
+ }], errors);
356
+ expect(errors.some(e => e.includes('"extract" must be "text", "attribute", or "html"'))).toBe(true);
357
+ });
358
+ it('errors on unknown fields in collect entry', () => {
359
+ const errors = [];
360
+ validateFlow([{
361
+ id: 'scrape_unknown_field',
362
+ type: 'dom_scrape',
363
+ params: {
364
+ target: { kind: 'css', selector: '.item' },
365
+ collect: [
366
+ { key: 'name', target: { kind: 'css', selector: 'span' }, trim: true },
367
+ ],
368
+ out: 'items',
369
+ },
370
+ }], errors);
371
+ expect(errors.some(e => e.includes('unknown field(s): "trim"'))).toBe(true);
372
+ });
373
+ it('accepts dom_scrape with html extract', () => {
374
+ const errors = [];
375
+ validateFlow([{
376
+ id: 'scrape_html',
377
+ type: 'dom_scrape',
378
+ params: {
379
+ target: { kind: 'css', selector: '.item' },
380
+ collect: [
381
+ { key: 'content', target: { kind: 'css', selector: '.body' }, extract: 'html' },
382
+ ],
383
+ out: 'items',
384
+ },
385
+ }], errors);
386
+ expect(errors).toEqual([]);
387
+ });
388
+ });
@@ -97,6 +97,68 @@ describe('isFlowHttpCompatible', () => {
97
97
  ];
98
98
  expect(isFlowHttpCompatible(steps, snapshots)).toBe(false);
99
99
  });
100
+ it('returns false when a navigate step has a dynamic template URL', () => {
101
+ const steps = [
102
+ { id: 'nav1', type: 'navigate', params: { url: 'https://example.com/items?batch={{inputs.batch | urlencode}}' } },
103
+ { id: 'find1', type: 'network_find', params: { where: { urlIncludes: '/api/' }, saveAs: 'reqId' } },
104
+ {
105
+ id: 'replay1', type: 'network_replay',
106
+ params: { requestId: '{{vars.reqId}}', auth: 'browser_context', out: 'data', response: { as: 'json' } },
107
+ },
108
+ ];
109
+ const snapshots = makeSnapshotFile(['replay1']);
110
+ expect(isFlowHttpCompatible(steps, snapshots)).toBe(false);
111
+ });
112
+ it('returns false when a fill step has a dynamic template value', () => {
113
+ const steps = [
114
+ { id: 'nav1', type: 'navigate', params: { url: 'https://example.com' } },
115
+ { id: 'fill1', type: 'fill', params: { target: { kind: 'css', selector: '#search' }, value: '{{inputs.query}}' } },
116
+ {
117
+ id: 'replay1', type: 'network_replay',
118
+ params: { requestId: '{{vars.reqId}}', auth: 'browser_context', out: 'data', response: { as: 'json' } },
119
+ },
120
+ ];
121
+ const snapshots = makeSnapshotFile(['replay1']);
122
+ expect(isFlowHttpCompatible(steps, snapshots)).toBe(false);
123
+ });
124
+ it('returns false when a click step has a dynamic template target', () => {
125
+ const steps = [
126
+ { id: 'nav1', type: 'navigate', params: { url: 'https://example.com' } },
127
+ { id: 'click1', type: 'click', params: { target: { kind: 'text', text: '{{inputs.category}}' } } },
128
+ {
129
+ id: 'replay1', type: 'network_replay',
130
+ params: { requestId: '{{vars.reqId}}', auth: 'browser_context', out: 'data', response: { as: 'json' } },
131
+ },
132
+ ];
133
+ const snapshots = makeSnapshotFile(['replay1']);
134
+ expect(isFlowHttpCompatible(steps, snapshots)).toBe(false);
135
+ });
136
+ it('allows static navigate step (no templates) in HTTP mode', () => {
137
+ const steps = [
138
+ { id: 'nav1', type: 'navigate', params: { url: 'https://example.com/page' } },
139
+ {
140
+ id: 'replay1', type: 'network_replay',
141
+ params: { requestId: '{{vars.reqId}}', auth: 'browser_context', out: 'data', response: { as: 'json' } },
142
+ },
143
+ ];
144
+ const snapshots = makeSnapshotFile(['replay1']);
145
+ expect(isFlowHttpCompatible(steps, snapshots)).toBe(true);
146
+ });
147
+ it('templates in network_replay params do NOT block HTTP mode (they are resolved at replay time)', () => {
148
+ const steps = [
149
+ { id: 'nav1', type: 'navigate', params: { url: 'https://example.com' } },
150
+ {
151
+ id: 'replay1', type: 'network_replay',
152
+ params: {
153
+ requestId: '{{vars.reqId}}', auth: 'browser_context', out: 'data',
154
+ response: { as: 'json' },
155
+ overrides: { bodyReplace: [{ find: 'W24', replace: '{{inputs.batch}}' }] },
156
+ },
157
+ },
158
+ ];
159
+ const snapshots = makeSnapshotFile(['replay1']);
160
+ expect(isFlowHttpCompatible(steps, snapshots)).toBe(true);
161
+ });
100
162
  it('allows sleep, set_var, and network_extract in HTTP mode', () => {
101
163
  const steps = [
102
164
  {
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=proxy.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proxy.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/proxy.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { OxylabsProvider } from '../proxy/oxylabs.js';
3
+ import { resolveProxy, registerProxyProvider, getProxyProvider, listProxyProviders, } from '../proxy/proxyService.js';
4
+ describe('OxylabsProvider', () => {
5
+ const provider = new OxylabsProvider();
6
+ const creds = { username: 'testuser', password: 'testpass' };
7
+ it('generates correct random mode username', () => {
8
+ const config = { enabled: true, mode: 'random' };
9
+ const result = provider.resolve(config, creds);
10
+ expect(result.server).toBe('http://pr.oxylabs.io:7777');
11
+ expect(result.username).toBe('customer-testuser');
12
+ expect(result.password).toBe('testpass');
13
+ });
14
+ it('generates correct session mode username with sessid and sesstime', () => {
15
+ const config = { enabled: true, mode: 'session', sessionDurationMinutes: 15 };
16
+ const result = provider.resolve(config, creds);
17
+ expect(result.server).toBe('http://pr.oxylabs.io:7777');
18
+ expect(result.username).toMatch(/^customer-testuser-sessid-[a-f0-9]+-sesstime-15$/);
19
+ expect(result.password).toBe('testpass');
20
+ });
21
+ it('defaults to session mode when mode is not specified', () => {
22
+ const config = { enabled: true };
23
+ const result = provider.resolve(config, creds);
24
+ expect(result.username).toContain('-sessid-');
25
+ expect(result.username).toContain('-sesstime-10'); // default 10 min
26
+ });
27
+ it('includes country code when specified', () => {
28
+ const config = { enabled: true, mode: 'random', country: 'US' };
29
+ const result = provider.resolve(config, creds);
30
+ expect(result.username).toBe('customer-testuser-cc-US');
31
+ });
32
+ it('uppercases country code', () => {
33
+ const config = { enabled: true, mode: 'random', country: 'gb' };
34
+ const result = provider.resolve(config, creds);
35
+ expect(result.username).toBe('customer-testuser-cc-GB');
36
+ });
37
+ it('includes country before session params in session mode', () => {
38
+ const config = { enabled: true, mode: 'session', country: 'DE' };
39
+ const result = provider.resolve(config, creds);
40
+ expect(result.username).toMatch(/^customer-testuser-cc-DE-sessid-[a-f0-9]+-sesstime-10$/);
41
+ });
42
+ it('reports required credential keys', () => {
43
+ expect(provider.requiredCredentialKeys()).toEqual(['USERNAME', 'PASSWORD']);
44
+ });
45
+ });
46
+ describe('resolveProxy', () => {
47
+ const originalEnv = { ...process.env };
48
+ beforeEach(() => {
49
+ // Clean proxy env vars before each test
50
+ delete process.env.SHOWRUN_PROXY_USERNAME;
51
+ delete process.env.SHOWRUN_PROXY_PASSWORD;
52
+ delete process.env.SHOWRUN_PROXY_PROVIDER;
53
+ });
54
+ afterEach(() => {
55
+ // Restore original env
56
+ process.env = { ...originalEnv };
57
+ });
58
+ it('returns null for undefined config', () => {
59
+ expect(resolveProxy(undefined)).toBeNull();
60
+ });
61
+ it('returns null for disabled config', () => {
62
+ expect(resolveProxy({ enabled: false })).toBeNull();
63
+ });
64
+ it('returns null and warns when env vars are missing', () => {
65
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
66
+ const result = resolveProxy({ enabled: true });
67
+ expect(result).toBeNull();
68
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('credentials not configured'));
69
+ warnSpy.mockRestore();
70
+ });
71
+ it('resolves proxy when env vars are set', () => {
72
+ process.env.SHOWRUN_PROXY_USERNAME = 'myuser';
73
+ process.env.SHOWRUN_PROXY_PASSWORD = 'mypass';
74
+ const result = resolveProxy({ enabled: true, mode: 'random' });
75
+ expect(result).not.toBeNull();
76
+ expect(result.server).toBe('http://pr.oxylabs.io:7777');
77
+ expect(result.username).toBe('customer-myuser');
78
+ expect(result.password).toBe('mypass');
79
+ });
80
+ it('reads provider from env var', () => {
81
+ process.env.SHOWRUN_PROXY_USERNAME = 'user';
82
+ process.env.SHOWRUN_PROXY_PASSWORD = 'pass';
83
+ process.env.SHOWRUN_PROXY_PROVIDER = 'oxylabs';
84
+ const result = resolveProxy({ enabled: true, mode: 'random' });
85
+ expect(result).not.toBeNull();
86
+ });
87
+ it('throws for unknown provider', () => {
88
+ process.env.SHOWRUN_PROXY_USERNAME = 'user';
89
+ process.env.SHOWRUN_PROXY_PASSWORD = 'pass';
90
+ expect(() => resolveProxy({ enabled: true, provider: 'nonexistent' })).toThrow('Unknown proxy provider "nonexistent"');
91
+ });
92
+ });
93
+ describe('provider registry', () => {
94
+ it('lists default providers', () => {
95
+ const names = listProxyProviders();
96
+ expect(names).toContain('oxylabs');
97
+ });
98
+ it('gets a provider by name', () => {
99
+ const provider = getProxyProvider('oxylabs');
100
+ expect(provider).toBeDefined();
101
+ expect(provider.name).toBe('oxylabs');
102
+ });
103
+ it('registers a custom provider', () => {
104
+ const custom = {
105
+ name: 'custom-test',
106
+ resolve(_config, creds) {
107
+ return { server: 'http://custom:1234', username: creds.username, password: creds.password };
108
+ },
109
+ requiredCredentialKeys() {
110
+ return ['USERNAME', 'PASSWORD'];
111
+ },
112
+ };
113
+ registerProxyProvider(custom);
114
+ expect(getProxyProvider('custom-test')).toBe(custom);
115
+ expect(listProxyProviders()).toContain('custom-test');
116
+ });
117
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=registry-client.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry-client.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/registry-client.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,228 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { mkdirSync, rmSync, statSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { tmpdir, platform } from 'os';
5
+ import { randomBytes } from 'crypto';
6
+ // ── Token store tests ─────────────────────────────────────────────────────
7
+ describe('tokenStore', () => {
8
+ let tempDir;
9
+ let originalEnv;
10
+ beforeEach(() => {
11
+ tempDir = join(tmpdir(), `showrun-test-${randomBytes(6).toString('hex')}`);
12
+ mkdirSync(tempDir, { recursive: true });
13
+ originalEnv = {
14
+ XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
15
+ APPDATA: process.env.APPDATA,
16
+ HOME: process.env.HOME,
17
+ };
18
+ // Point config dir to temp
19
+ process.env.XDG_CONFIG_HOME = tempDir;
20
+ });
21
+ afterEach(() => {
22
+ // Restore env
23
+ for (const [key, value] of Object.entries(originalEnv)) {
24
+ if (value === undefined) {
25
+ delete process.env[key];
26
+ }
27
+ else {
28
+ process.env[key] = value;
29
+ }
30
+ }
31
+ // Clean up temp dir
32
+ try {
33
+ rmSync(tempDir, { recursive: true, force: true });
34
+ }
35
+ catch {
36
+ // Ignore
37
+ }
38
+ vi.restoreAllMocks();
39
+ });
40
+ it('saveTokens creates auth.json and loadTokens reads it', async () => {
41
+ const { saveTokens, loadTokens } = await import('../registry/tokenStore.js');
42
+ const auth = {
43
+ accessToken: 'at-123',
44
+ refreshToken: 'rt-456',
45
+ user: { id: '1', username: 'testuser', email: 'test@test.com' },
46
+ registryUrl: 'https://registry.example.com',
47
+ savedAt: new Date().toISOString(),
48
+ };
49
+ saveTokens(auth);
50
+ const loaded = loadTokens();
51
+ expect(loaded).not.toBeNull();
52
+ expect(loaded.accessToken).toBe('at-123');
53
+ expect(loaded.refreshToken).toBe('rt-456');
54
+ expect(loaded.user.username).toBe('testuser');
55
+ expect(loaded.registryUrl).toBe('https://registry.example.com');
56
+ });
57
+ it('clearTokens removes auth.json', async () => {
58
+ const { saveTokens, clearTokens, loadTokens } = await import('../registry/tokenStore.js');
59
+ saveTokens({
60
+ accessToken: 'at',
61
+ refreshToken: 'rt',
62
+ user: { id: '1', username: 'u', email: 'e@e.com' },
63
+ registryUrl: 'https://r.com',
64
+ savedAt: new Date().toISOString(),
65
+ });
66
+ expect(loadTokens()).not.toBeNull();
67
+ clearTokens();
68
+ expect(loadTokens()).toBeNull();
69
+ });
70
+ it('loadTokens returns null when no file exists', async () => {
71
+ const { loadTokens } = await import('../registry/tokenStore.js');
72
+ expect(loadTokens()).toBeNull();
73
+ });
74
+ it('sets 0o600 permissions on Unix', async () => {
75
+ if (platform() === 'win32')
76
+ return; // Skip on Windows
77
+ const { saveTokens } = await import('../registry/tokenStore.js');
78
+ saveTokens({
79
+ accessToken: 'at',
80
+ refreshToken: 'rt',
81
+ user: { id: '1', username: 'u', email: 'e@e.com' },
82
+ registryUrl: 'https://r.com',
83
+ savedAt: new Date().toISOString(),
84
+ });
85
+ const authPath = join(tempDir, 'showrun', 'auth.json');
86
+ const stats = statSync(authPath);
87
+ // 0o600 = owner read+write only
88
+ expect(stats.mode & 0o777).toBe(0o600);
89
+ });
90
+ });
91
+ // ── RegistryClient tests ──────────────────────────────────────────────────
92
+ describe('RegistryClient', () => {
93
+ let originalEnv;
94
+ beforeEach(() => {
95
+ originalEnv = {
96
+ SHOWRUN_REGISTRY_URL: process.env.SHOWRUN_REGISTRY_URL,
97
+ };
98
+ });
99
+ afterEach(() => {
100
+ for (const [key, value] of Object.entries(originalEnv)) {
101
+ if (value === undefined) {
102
+ delete process.env[key];
103
+ }
104
+ else {
105
+ process.env[key] = value;
106
+ }
107
+ }
108
+ vi.restoreAllMocks();
109
+ });
110
+ it('throws RegistryError when registry URL is not configured', async () => {
111
+ delete process.env.SHOWRUN_REGISTRY_URL;
112
+ const { RegistryClient, RegistryError } = await import('../registry/client.js');
113
+ expect(() => new RegistryClient()).toThrow(RegistryError);
114
+ expect(() => new RegistryClient()).toThrow('Registry not configured');
115
+ });
116
+ it('constructs with explicit URL', async () => {
117
+ const { RegistryClient } = await import('../registry/client.js');
118
+ const client = new RegistryClient('https://registry.example.com');
119
+ expect(client).toBeDefined();
120
+ });
121
+ it('constructs with env var', async () => {
122
+ process.env.SHOWRUN_REGISTRY_URL = 'https://registry.example.com';
123
+ const { RegistryClient } = await import('../registry/client.js');
124
+ const client = new RegistryClient();
125
+ expect(client).toBeDefined();
126
+ });
127
+ it('strips trailing slash from URL', async () => {
128
+ const { RegistryClient } = await import('../registry/client.js');
129
+ // The URL gets stripped internally - verify it works by checking search constructs properly
130
+ const client = new RegistryClient('https://registry.example.com///');
131
+ expect(client).toBeDefined();
132
+ });
133
+ it('startDeviceLogin requests device code', async () => {
134
+ const { RegistryClient } = await import('../registry/client.js');
135
+ const mockDevice = {
136
+ deviceCode: 'dc-123',
137
+ userCode: 'ABCD-1234',
138
+ verificationUri: 'https://registry.example.com/device',
139
+ expiresIn: 900,
140
+ interval: 5,
141
+ };
142
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify(mockDevice), {
143
+ status: 200,
144
+ headers: { 'Content-Type': 'application/json' },
145
+ }));
146
+ const client = new RegistryClient('https://registry.example.com');
147
+ const result = await client.startDeviceLogin();
148
+ expect(result.userCode).toBe('ABCD-1234');
149
+ expect(result.deviceCode).toBe('dc-123');
150
+ expect(fetchSpy).toHaveBeenCalledOnce();
151
+ expect(fetchSpy.mock.calls[0][0]).toBe('https://registry.example.com/api/auth/device');
152
+ });
153
+ it('pollDeviceLogin returns pending when user has not approved', async () => {
154
+ const { RegistryClient } = await import('../registry/client.js');
155
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ error: 'authorization_pending' }), {
156
+ status: 428,
157
+ headers: { 'Content-Type': 'application/json' },
158
+ }));
159
+ const client = new RegistryClient('https://registry.example.com');
160
+ const result = await client.pollDeviceLogin('dc-123');
161
+ expect(result.status).toBe('pending');
162
+ });
163
+ it('pollDeviceLogin stores tokens on success', async () => {
164
+ const { RegistryClient } = await import('../registry/client.js');
165
+ const { loadTokens, clearTokens } = await import('../registry/tokenStore.js');
166
+ const mockTokenResponse = {
167
+ accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTl9.test',
168
+ refreshToken: 'rt-mock',
169
+ user: { id: '1', username: 'mockuser', email: 'mock@test.com', displayName: 'Mock User' },
170
+ };
171
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify(mockTokenResponse), {
172
+ status: 200,
173
+ headers: { 'Content-Type': 'application/json' },
174
+ }));
175
+ const client = new RegistryClient('https://registry.example.com');
176
+ const result = await client.pollDeviceLogin('dc-123');
177
+ expect(result.status).toBe('complete');
178
+ if (result.status === 'complete') {
179
+ expect(result.user.username).toBe('mockuser');
180
+ }
181
+ // Verify tokens were stored
182
+ const stored = loadTokens();
183
+ expect(stored).not.toBeNull();
184
+ expect(stored.accessToken).toBe(mockTokenResponse.accessToken);
185
+ // Clean up
186
+ clearTokens();
187
+ });
188
+ it('searchPacks sends correct query params', async () => {
189
+ const { RegistryClient } = await import('../registry/client.js');
190
+ const mockResponse = {
191
+ data: [],
192
+ total: 0,
193
+ page: 1,
194
+ limit: 20,
195
+ totalPages: 0,
196
+ };
197
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify(mockResponse), {
198
+ status: 200,
199
+ headers: { 'Content-Type': 'application/json' },
200
+ }));
201
+ const client = new RegistryClient('https://registry.example.com');
202
+ await client.searchPacks({ q: 'test', page: 2, limit: 10 });
203
+ expect(fetchSpy).toHaveBeenCalledOnce();
204
+ const url = fetchSpy.mock.calls[0][0];
205
+ expect(url).toContain('/api/packs?');
206
+ expect(url).toContain('q=test');
207
+ expect(url).toContain('page=2');
208
+ expect(url).toContain('limit=10');
209
+ });
210
+ it('handles 4xx/5xx errors as RegistryError', async () => {
211
+ const { RegistryClient, RegistryError } = await import('../registry/client.js');
212
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ message: 'Not Found' }), {
213
+ status: 404,
214
+ headers: { 'Content-Type': 'application/json' },
215
+ }));
216
+ const client = new RegistryClient('https://registry.example.com');
217
+ await expect(client.searchPacks({ q: 'missing' })).rejects.toThrow(RegistryError);
218
+ await expect(
219
+ // Re-mock for second call
220
+ (async () => {
221
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ message: 'Internal error' }), {
222
+ status: 500,
223
+ headers: { 'Content-Type': 'application/json' },
224
+ }));
225
+ return client.searchPacks({ q: 'fail' });
226
+ })()).rejects.toThrow(RegistryError);
227
+ });
228
+ });
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { type Browser, type BrowserContext, type Page } from 'playwright';
8
8
  import type { BrowserEngine, BrowserSettings, BrowserPersistence } from './types.js';
9
+ import type { ResolvedProxy } from './proxy/types.js';
9
10
  /**
10
11
  * Browser session returned by launchBrowser
11
12
  */
@@ -34,6 +35,10 @@ export interface BrowserSession {
34
35
  * User data directory path (if persistence is enabled)
35
36
  */
36
37
  userDataDir?: string;
38
+ /**
39
+ * Resolved proxy used for this session (if any)
40
+ */
41
+ proxy?: ResolvedProxy;
37
42
  /**
38
43
  * Close the browser session
39
44
  */
@@ -64,6 +69,17 @@ export interface LaunchBrowserConfig {
64
69
  * When set, bypasses persistence resolution (sessionId/packPath).
65
70
  */
66
71
  userDataDir?: string;
72
+ /**
73
+ * Resolved proxy to route traffic through.
74
+ * Passed directly to Playwright/Camoufox launch options.
75
+ */
76
+ proxy?: ResolvedProxy;
77
+ /**
78
+ * Chrome DevTools Protocol URL to connect to an existing browser.
79
+ * When set, connects via CDP instead of launching a new browser.
80
+ * Chromium-only (Camoufox/Firefox does not support CDP).
81
+ */
82
+ cdpUrl?: string;
67
83
  }
68
84
  /**
69
85
  * Launches a browser with the specified configuration
@@ -1 +1 @@
1
- {"version":3,"file":"browserLauncher.d.ts","sourceRoot":"","sources":["../src/browserLauncher.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAY,KAAK,OAAO,EAAE,KAAK,cAAc,EAAE,KAAK,IAAI,EAAE,MAAM,YAAY,CAAC;AAGpF,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAGrF;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B;;OAEG;IACH,OAAO,EAAE,cAAc,CAAC;IACxB;;OAEG;IACH,IAAI,EAAE,IAAI,CAAC;IACX;;OAEG;IACH,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;IACxB;;OAEG;IACH,MAAM,EAAE,aAAa,CAAC;IACtB;;OAEG;IACH,WAAW,EAAE,kBAAkB,CAAC;IAChC;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;OAEG;IACH,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC;;OAEG;IACH,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC;;OAEG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;OAEG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;OAEG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;GAKG;AACH,wBAAsB,aAAa,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,cAAc,CAAC,CA0CxF;AA6JD;;GAEG;AACH,wBAAsB,wBAAwB,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,OAAO,CAAC,CAgBtF"}
1
+ {"version":3,"file":"browserLauncher.d.ts","sourceRoot":"","sources":["../src/browserLauncher.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAY,KAAK,OAAO,EAAE,KAAK,cAAc,EAAE,KAAK,IAAI,EAAE,MAAM,YAAY,CAAC;AAGpF,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AACrF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B;;OAEG;IACH,OAAO,EAAE,cAAc,CAAC;IACxB;;OAEG;IACH,IAAI,EAAE,IAAI,CAAC;IACX;;OAEG;IACH,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;IACxB;;OAEG;IACH,MAAM,EAAE,aAAa,CAAC;IACtB;;OAEG;IACH,WAAW,EAAE,kBAAkB,CAAC;IAChC;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;OAEG;IACH,KAAK,CAAC,EAAE,aAAa,CAAC;IACtB;;OAEG;IACH,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC;;OAEG;IACH,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC;;OAEG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;OAEG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;OAEG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,KAAK,CAAC,EAAE,aAAa,CAAC;IACtB;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;GAKG;AACH,wBAAsB,aAAa,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,cAAc,CAAC,CAiDxF;AAyMD;;GAEG;AACH,wBAAsB,wBAAwB,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,OAAO,CAAC,CAgBtF"}