@outputai/core 0.2.1-next.fd72d95.0 → 0.3.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.
@@ -1,5 +1,15 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { serializeError } from './utils.js';
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { safeFormatJSON, serializeError } from './utils.js';
3
+
4
+ const isPrettyStringifyCall = args => args.length >= 3 && args[2] === 2;
5
+
6
+ /** @param {number} targetBytes UTF-8 size of compact JSON.stringify( { a: "<xs>" } ) */
7
+ const objectWithCompactByteLength = targetBytes => {
8
+ const sample = { a: '' };
9
+ const overhead = Buffer.byteLength( JSON.stringify( sample ), 'utf8' );
10
+ const repeat = Math.max( 0, targetBytes - overhead );
11
+ return { a: 'x'.repeat( repeat ) };
12
+ };
3
13
 
4
14
  describe( 'tracing/utils', () => {
5
15
  it( 'serializeError unwraps causes and keeps message/stack', () => {
@@ -11,4 +21,126 @@ describe( 'tracing/utils', () => {
11
21
  expect( out.message ).toBe( 'inner' );
12
22
  expect( typeof out.stack ).toBe( 'string' );
13
23
  } );
24
+
25
+ describe( 'safeFormatJSON', () => {
26
+ it( 'formats small objects with indentation when under threshold', () => {
27
+ const content = { a: 1, b: [ 2, 3 ] };
28
+ const out = safeFormatJSON( content, 10_000 );
29
+
30
+ expect( out ).toContain( '\n' );
31
+ expect( out ).toMatch( /^\{\n/ );
32
+ expect( JSON.parse( out ) ).toEqual( content );
33
+ } );
34
+
35
+ it( 'formats small arrays with indentation when under threshold', () => {
36
+ const content = [ 1, { nested: true } ];
37
+ const out = safeFormatJSON( content, 10_000 );
38
+
39
+ expect( out ).toContain( '\n' );
40
+ expect( out.trimStart() ).toMatch( /^\[/ );
41
+ expect( JSON.parse( out ) ).toEqual( content );
42
+ } );
43
+
44
+ it( 'returns compact JSON when compact UTF-8 size is strictly greater than threshold', () => {
45
+ const content = objectWithCompactByteLength( 40 );
46
+ const compact = JSON.stringify( content );
47
+ expect( Buffer.byteLength( compact, 'utf8' ) ).toBe( 40 );
48
+
49
+ const out = safeFormatJSON( content, 39 );
50
+ expect( out ).toBe( compact );
51
+ expect( out ).not.toContain( '\n ' );
52
+ expect( JSON.parse( out ) ).toEqual( content );
53
+ } );
54
+
55
+ it( 'uses pretty JSON when compact UTF-8 size equals threshold', () => {
56
+ const content = objectWithCompactByteLength( 40 );
57
+ const compact = JSON.stringify( content );
58
+ expect( Buffer.byteLength( compact, 'utf8' ) ).toBe( 40 );
59
+
60
+ const out = safeFormatJSON( content, 40 );
61
+ expect( out ).not.toBe( compact );
62
+ expect( out ).toContain( '\n' );
63
+ expect( JSON.parse( out ) ).toEqual( content );
64
+ } );
65
+
66
+ it( 'uses UTF-8 byte length for threshold, not JavaScript string length', () => {
67
+ const content = { label: 'éclair' };
68
+ const compact = JSON.stringify( content );
69
+ expect( compact.length ).toBeLessThan( Buffer.byteLength( compact, 'utf8' ) );
70
+
71
+ const bytes = Buffer.byteLength( compact, 'utf8' );
72
+ const outCompact = safeFormatJSON( content, bytes - 1 );
73
+ expect( outCompact ).toBe( compact );
74
+
75
+ const outPretty = safeFormatJSON( content, bytes + 100 );
76
+ expect( outPretty ).toContain( '\n' );
77
+ expect( JSON.parse( outPretty ) ).toEqual( content );
78
+ } );
79
+
80
+ it( 'round-trips empty object and primitives for both branches', () => {
81
+ const tiny = {};
82
+ const pretty = safeFormatJSON( tiny, 100 );
83
+ expect( JSON.parse( pretty ) ).toEqual( tiny );
84
+
85
+ const forcedCompact = safeFormatJSON( tiny, 0 );
86
+ expect( JSON.parse( forcedCompact ) ).toEqual( tiny );
87
+ } );
88
+
89
+ it( 'returns compact JSON when pretty stringify throws Invalid string length', () => {
90
+ const content = { a: 1 };
91
+ const compact = JSON.stringify( content );
92
+ const origStringify = JSON.stringify.bind( JSON );
93
+
94
+ const spy = vi.spyOn( JSON, 'stringify' ).mockImplementation( ( ...args ) => {
95
+ if ( isPrettyStringifyCall( args ) ) {
96
+ throw new RangeError( 'Invalid string length' );
97
+ }
98
+ return origStringify( ...args );
99
+ } );
100
+
101
+ try {
102
+ const out = safeFormatJSON( content, 10_000 );
103
+ expect( out ).toBe( compact );
104
+ expect( JSON.parse( out ) ).toEqual( content );
105
+ } finally {
106
+ spy.mockRestore();
107
+ }
108
+ } );
109
+
110
+ it( 'rethrows RangeError when message is not Invalid string length', () => {
111
+ const content = { a: 1 };
112
+ const origStringify = JSON.stringify.bind( JSON );
113
+
114
+ const spy = vi.spyOn( JSON, 'stringify' ).mockImplementation( ( ...args ) => {
115
+ if ( isPrettyStringifyCall( args ) ) {
116
+ throw new RangeError( 'not the string length error' );
117
+ }
118
+ return origStringify( ...args );
119
+ } );
120
+
121
+ try {
122
+ expect( () => safeFormatJSON( content, 10_000 ) ).toThrow( RangeError );
123
+ } finally {
124
+ spy.mockRestore();
125
+ }
126
+ } );
127
+
128
+ it( 'rethrows non-RangeError from pretty stringify', () => {
129
+ const content = { a: 1 };
130
+ const origStringify = JSON.stringify.bind( JSON );
131
+
132
+ const spy = vi.spyOn( JSON, 'stringify' ).mockImplementation( ( ...args ) => {
133
+ if ( isPrettyStringifyCall( args ) ) {
134
+ throw new TypeError( 'cyclic structure' );
135
+ }
136
+ return origStringify( ...args );
137
+ } );
138
+
139
+ try {
140
+ expect( () => safeFormatJSON( content, 10_000 ) ).toThrow( TypeError );
141
+ } finally {
142
+ spy.mockRestore();
143
+ }
144
+ } );
145
+ } );
14
146
  } );
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Each possible action of a trace event
3
+ */
4
+ export const EventAction = {
5
+ START: 'start',
6
+ END: 'end',
7
+ ERROR: 'error',
8
+ ADD_ATTR: 'add_attr'
9
+ };
@@ -64,34 +64,34 @@ export const init = async () => {
64
64
  const serializeDetails = details => details instanceof Error ? serializeError( details ) : details;
65
65
 
66
66
  /**
67
- * Creates a new trace event phase and sends it to be written
67
+ * Emits an event action to the event bus.
68
68
  *
69
- * @param {string} phase - The phase
69
+ * @param {string} action - The action
70
70
  * @param {object} fields - All the trace fields
71
71
  * @returns {void}
72
72
  */
73
- export const addEventPhase = ( phase, { kind, name, id, parentId, details, executionContext } ) => {
73
+ export const addEventAction = ( action, { kind, name, id, parentId, details, executionContext } ) => {
74
74
  // Ignores internal steps in the actual trace files, ignore trace if the flag is true
75
75
  if ( kind !== ComponentType.INTERNAL_STEP && !executionContext.disableTrace ) {
76
76
  traceBus.emit( 'entry', {
77
77
  executionContext,
78
- entry: { kind, phase, name, id, parentId, timestamp: Date.now(), details: serializeDetails( details ) }
78
+ entry: { kind, action, name, id, parentId, timestamp: Date.now(), details: serializeDetails( details ) }
79
79
  } );
80
80
  }
81
81
  };
82
82
 
83
83
  /**
84
- * Adds an Event Phase, complementing the options with parentId and executionContext from the async storage.
84
+ * Attaches contextual information to an event action before calling the method to emit it to the bus.
85
85
  *
86
- * This function will have no effect if called from outside an Temporal Workflow/Activity environment,
87
- * so it is safe to be used on unit tests or any dependencies that might be used elsewhere
86
+ * This function has no effect if called outside a Temporal Workflow/Activity environment,
87
+ * so it is safe to use in unit tests or dependencies that might be used elsewhere.
88
88
  *
89
89
  * @param {object} options - The common trace configurations
90
90
  */
91
- export function addEventPhaseWithContext( phase, options ) {
91
+ export function addEventActionWithContext( action, options ) {
92
92
  const storeContent = Storage.load();
93
- if ( storeContent ) { // If there is no storageContext this was not called from an Temporal Environment
93
+ if ( storeContent ) { // If there is no storageContext this was not called from a Temporal environment
94
94
  const { parentId, executionContext } = storeContent;
95
- addEventPhase( phase, { ...options, parentId, executionContext } );
95
+ addEventAction( action, { ...options, parentId, executionContext } );
96
96
  }
97
97
  };
@@ -39,7 +39,7 @@ describe( 'tracing/trace_engine', () => {
39
39
  it( 'init() starts only enabled processors and attaches listeners', async () => {
40
40
  process.env.OUTPUT_TRACE_LOCAL_ON = '1';
41
41
  process.env.OUTPUT_TRACE_REMOTE_ON = '0';
42
- const { init, addEventPhase } = await loadTraceEngine();
42
+ const { init, addEventAction } = await loadTraceEngine();
43
43
 
44
44
  await init();
45
45
 
@@ -47,96 +47,96 @@ describe( 'tracing/trace_engine', () => {
47
47
  expect( s3InitMock ).not.toHaveBeenCalled();
48
48
 
49
49
  const executionContext = { disableTrace: false };
50
- addEventPhase( 'start', {
50
+ addEventAction( 'start', {
51
51
  kind: 'step', name: 'N', id: '1', parentId: 'p', details: { ok: true }, executionContext
52
52
  } );
53
53
  expect( localExecMock ).toHaveBeenCalledTimes( 1 );
54
54
  const payload = localExecMock.mock.calls[0][0];
55
55
  expect( payload.entry.name ).toBe( 'N' );
56
56
  expect( payload.entry.kind ).toBe( 'step' );
57
- expect( payload.entry.phase ).toBe( 'start' );
57
+ expect( payload.entry.action ).toBe( 'start' );
58
58
  expect( payload.entry.details ).toEqual( { ok: true } );
59
59
  expect( payload.executionContext ).toBe( executionContext );
60
60
  } );
61
61
 
62
- it( 'addEventPhase() emits an entry consumed by processors', async () => {
62
+ it( 'addEventAction() emits an entry consumed by processors', async () => {
63
63
  process.env.OUTPUT_TRACE_LOCAL_ON = 'on';
64
- const { init, addEventPhase } = await loadTraceEngine();
64
+ const { init, addEventAction } = await loadTraceEngine();
65
65
  await init();
66
66
 
67
- addEventPhase( 'end', {
67
+ addEventAction( 'end', {
68
68
  kind: 'workflow', name: 'W', id: '2', parentId: 'p2', details: 'done',
69
69
  executionContext: { disableTrace: false }
70
70
  } );
71
71
  expect( localExecMock ).toHaveBeenCalledTimes( 1 );
72
72
  const payload = localExecMock.mock.calls[0][0];
73
73
  expect( payload.entry.name ).toBe( 'W' );
74
- expect( payload.entry.phase ).toBe( 'end' );
74
+ expect( payload.entry.action ).toBe( 'end' );
75
75
  expect( payload.entry.details ).toBe( 'done' );
76
76
  } );
77
77
 
78
- it( 'addEventPhase() does not emit when executionContext.disableTrace is true', async () => {
78
+ it( 'addEventAction() does not emit when executionContext.disableTrace is true', async () => {
79
79
  process.env.OUTPUT_TRACE_LOCAL_ON = '1';
80
- const { init, addEventPhase } = await loadTraceEngine();
80
+ const { init, addEventAction } = await loadTraceEngine();
81
81
  await init();
82
82
 
83
- addEventPhase( 'start', {
83
+ addEventAction( 'start', {
84
84
  kind: 'step', name: 'X', id: '1', parentId: 'p', details: {},
85
85
  executionContext: { disableTrace: true }
86
86
  } );
87
87
  expect( localExecMock ).not.toHaveBeenCalled();
88
88
  } );
89
89
 
90
- it( 'addEventPhase() does not emit when kind is INTERNAL_STEP', async () => {
90
+ it( 'addEventAction() does not emit when kind is INTERNAL_STEP', async () => {
91
91
  process.env.OUTPUT_TRACE_LOCAL_ON = '1';
92
- const { init, addEventPhase } = await loadTraceEngine();
92
+ const { init, addEventAction } = await loadTraceEngine();
93
93
  await init();
94
94
 
95
- addEventPhase( 'start', {
95
+ addEventAction( 'start', {
96
96
  kind: 'internal_step', name: 'Internal', id: '1', parentId: 'p', details: {},
97
97
  executionContext: { disableTrace: false }
98
98
  } );
99
99
  expect( localExecMock ).not.toHaveBeenCalled();
100
100
  } );
101
101
 
102
- it( 'addEventPhaseWithContext() uses storage when available', async () => {
102
+ it( 'addEventActionWithContext() uses storage when available', async () => {
103
103
  process.env.OUTPUT_TRACE_LOCAL_ON = 'true';
104
104
  storageLoadMock.mockReturnValue( {
105
105
  parentId: 'ctx-p',
106
106
  executionContext: { runId: 'r1', disableTrace: false }
107
107
  } );
108
- const { init, addEventPhaseWithContext } = await loadTraceEngine();
108
+ const { init, addEventActionWithContext } = await loadTraceEngine();
109
109
  await init();
110
110
 
111
- addEventPhaseWithContext( 'tick', { kind: 'step', name: 'S', id: '3', details: 1 } );
111
+ addEventActionWithContext( 'tick', { kind: 'step', name: 'S', id: '3', details: 1 } );
112
112
  expect( localExecMock ).toHaveBeenCalledTimes( 1 );
113
113
  const payload = localExecMock.mock.calls[0][0];
114
114
  expect( payload.executionContext ).toEqual( { runId: 'r1', disableTrace: false } );
115
115
  expect( payload.entry.parentId ).toBe( 'ctx-p' );
116
116
  expect( payload.entry.name ).toBe( 'S' );
117
- expect( payload.entry.phase ).toBe( 'tick' );
117
+ expect( payload.entry.action ).toBe( 'tick' );
118
118
  } );
119
119
 
120
- it( 'addEventPhaseWithContext() does not emit when storage executionContext.disableTrace is true', async () => {
120
+ it( 'addEventActionWithContext() does not emit when storage executionContext.disableTrace is true', async () => {
121
121
  process.env.OUTPUT_TRACE_LOCAL_ON = '1';
122
122
  storageLoadMock.mockReturnValue( {
123
123
  parentId: 'ctx-p',
124
124
  executionContext: { runId: 'r1', disableTrace: true }
125
125
  } );
126
- const { init, addEventPhaseWithContext } = await loadTraceEngine();
126
+ const { init, addEventActionWithContext } = await loadTraceEngine();
127
127
  await init();
128
128
 
129
- addEventPhaseWithContext( 'tick', { kind: 'step', name: 'S', id: '3', details: 1 } );
129
+ addEventActionWithContext( 'tick', { kind: 'step', name: 'S', id: '3', details: 1 } );
130
130
  expect( localExecMock ).not.toHaveBeenCalled();
131
131
  } );
132
132
 
133
- it( 'addEventPhaseWithContext() is a no-op when storage is absent', async () => {
133
+ it( 'addEventActionWithContext() is a no-op when storage is absent', async () => {
134
134
  process.env.OUTPUT_TRACE_LOCAL_ON = '1';
135
135
  storageLoadMock.mockReturnValue( undefined );
136
- const { init, addEventPhaseWithContext } = await loadTraceEngine();
136
+ const { init, addEventActionWithContext } = await loadTraceEngine();
137
137
  await init();
138
138
 
139
- addEventPhaseWithContext( 'noop', { kind: 'step', name: 'X', id: '4', details: null } );
139
+ addEventActionWithContext( 'noop', { kind: 'step', name: 'X', id: '4', details: null } );
140
140
  expect( localExecMock ).not.toHaveBeenCalled();
141
141
  } );
142
142
 
@@ -26,7 +26,14 @@ const envVarSchema = z.object( {
26
26
  // Whether to send activity heartbeats (enabled by default)
27
27
  OUTPUT_ACTIVITY_HEARTBEAT_ENABLED: z.transform( v => v === undefined ? true : isStringboolTrue( v ) ),
28
28
  // Time to allow for hooks to flush before shutdown
29
- OUTPUT_PROCESS_FAILURE_SHUTDOWN_DELAY: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 3000 ) )
29
+ OUTPUT_PROCESS_FAILURE_SHUTDOWN_DELAY: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 3000 ) ),
30
+ // HTTP CONNECT proxy for Temporal gRPC connections (e.g. "proxy-host:8080").
31
+ // Must be a bare host:port — no scheme (Temporal's native HTTP CONNECT
32
+ // option is not a URL).
33
+ TEMPORAL_GRPC_PROXY: z.string().optional().refine(
34
+ v => !v || !v.includes( '://' ),
35
+ 'TEMPORAL_GRPC_PROXY must be host:port without a scheme (e.g. "proxy:8080", not "http://proxy:8080")'
36
+ )
30
37
  } );
31
38
 
32
39
  const { data: envVars, error } = envVarSchema.safeParse( process.env );
@@ -47,3 +54,4 @@ export const catalogId = envVars.OUTPUT_CATALOG_ID;
47
54
  export const activityHeartbeatIntervalMs = envVars.OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS;
48
55
  export const activityHeartbeatEnabled = envVars.OUTPUT_ACTIVITY_HEARTBEAT_ENABLED;
49
56
  export const processFailureShutdownDelay = envVars.OUTPUT_PROCESS_FAILURE_SHUTDOWN_DELAY;
57
+ export const grpcProxy = envVars.TEMPORAL_GRPC_PROXY;
@@ -9,6 +9,7 @@ import { initInterceptors } from './interceptors.js';
9
9
  import { createChildLogger } from '#logger';
10
10
  import { registerShutdown } from './shutdown.js';
11
11
  import { startCatalog } from './start_catalog.js';
12
+ import { bootstrapFetchProxy } from './proxy.js';
12
13
  import { messageBus } from '#bus';
13
14
  import './log_hooks.js';
14
15
  import { BusEventType } from '#consts';
@@ -24,6 +25,7 @@ const callerDir = process.argv[2];
24
25
  apiKey,
25
26
  namespace,
26
27
  taskQueue,
28
+ grpcProxy,
27
29
  maxConcurrentWorkflowTaskExecutions,
28
30
  maxConcurrentActivityTaskExecutions,
29
31
  maxCachedWorkflows,
@@ -41,6 +43,7 @@ const callerDir = process.argv[2];
41
43
  const activities = await loadActivities( callerDir, workflows );
42
44
 
43
45
  messageBus.emit( BusEventType.WORKER_BEFORE_START );
46
+ bootstrapFetchProxy();
44
47
 
45
48
  log.info( 'Creating worker entry point...' );
46
49
  const workflowsPath = createWorkflowsEntryPoint( workflows );
@@ -52,8 +55,11 @@ const callerDir = process.argv[2];
52
55
  const catalog = createCatalog( { workflows, activities } );
53
56
 
54
57
  log.info( 'Connecting Temporal...' );
55
- // Enable TLS when connecting to remote Temporal (API key present)
56
- const connection = await NativeConnection.connect( { address, tls: Boolean( apiKey ), apiKey } );
58
+ const proxy = grpcProxy ? { type: 'http-connect', targetHost: grpcProxy } : undefined;
59
+ if ( proxy ) {
60
+ log.info( 'Using gRPC proxy', { targetHost: grpcProxy } );
61
+ }
62
+ const connection = await NativeConnection.connect( { address, tls: Boolean( apiKey ), apiKey, proxy } );
57
63
 
58
64
  log.info( 'Creating worker...' );
59
65
  const worker = await Worker.create( {
@@ -16,6 +16,7 @@ const configValues = {
16
16
  namespace: 'default',
17
17
  taskQueue: 'test-queue',
18
18
  catalogId: 'test-catalog',
19
+ grpcProxy: undefined,
19
20
  maxConcurrentWorkflowTaskExecutions: 200,
20
21
  maxConcurrentActivityTaskExecutions: 40,
21
22
  maxCachedWorkflows: 1000,
@@ -52,6 +53,9 @@ vi.mock( './interceptors.js', () => ( { initInterceptors: initInterceptorsMock }
52
53
  const startCatalogMock = vi.fn().mockResolvedValue( undefined );
53
54
  vi.mock( './start_catalog.js', () => ( { startCatalog: startCatalogMock } ) );
54
55
 
56
+ const bootstrapFetchProxyMock = vi.fn();
57
+ vi.mock( './proxy.js', () => ( { bootstrapFetchProxy: bootstrapFetchProxyMock } ) );
58
+
55
59
  const registerShutdownMock = vi.fn();
56
60
  vi.mock( './shutdown.js', () => ( { registerShutdown: registerShutdownMock } ) );
57
61
 
@@ -101,10 +105,12 @@ describe( 'worker/index', () => {
101
105
  expect( createWorkflowsEntryPointMock ).toHaveBeenCalledWith( [] );
102
106
  expect( initTracing ).toHaveBeenCalled();
103
107
  expect( createCatalogMock ).toHaveBeenCalledWith( { workflows: [], activities: {} } );
108
+ expect( bootstrapFetchProxyMock ).toHaveBeenCalled();
104
109
  expect( NativeConnection.connect ).toHaveBeenCalledWith( {
105
110
  address: configValues.address,
106
111
  tls: false,
107
- apiKey: undefined
112
+ apiKey: undefined,
113
+ proxy: undefined
108
114
  } );
109
115
  expect( Worker.create ).toHaveBeenCalledWith( expect.objectContaining( {
110
116
  namespace: configValues.namespace,
@@ -0,0 +1,32 @@
1
+ import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
2
+ import { createChildLogger } from '#logger';
3
+
4
+ const log = createChildLogger( 'Proxy' );
5
+
6
+ /**
7
+ * Routes all `fetch()` calls (including those inside Temporal activities)
8
+ * through an HTTP/HTTPS proxy when standard proxy env vars are set
9
+ * (`HTTPS_PROXY`, `https_proxy`, `HTTP_PROXY`, `http_proxy`). No-op when
10
+ * none are set. Invalid URLs are logged and skipped so the worker keeps
11
+ * running.
12
+ *
13
+ * Call once at worker startup, before any network activity.
14
+ */
15
+ export const bootstrapFetchProxy = () => {
16
+ const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy ||
17
+ process.env.HTTP_PROXY || process.env.http_proxy;
18
+
19
+ if ( !proxyUrl ) {
20
+ return;
21
+ }
22
+
23
+ try {
24
+ new URL( proxyUrl );
25
+ } catch {
26
+ log.warn( 'Ignoring invalid proxy URL', { proxyUrl } );
27
+ return;
28
+ }
29
+
30
+ log.info( 'Routing fetch() through HTTP proxy', { proxyUrl } );
31
+ setGlobalDispatcher( new EnvHttpProxyAgent() );
32
+ };
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ const mockSetGlobalDispatcher = vi.fn();
4
+ const MockEnvHttpProxyAgent = vi.fn();
5
+
6
+ vi.mock( 'undici', () => ( {
7
+ EnvHttpProxyAgent: MockEnvHttpProxyAgent,
8
+ setGlobalDispatcher: mockSetGlobalDispatcher
9
+ } ) );
10
+
11
+ vi.mock( '#logger', () => ( {
12
+ createChildLogger: () => ( { info: vi.fn(), warn: vi.fn(), error: vi.fn() } )
13
+ } ) );
14
+
15
+ describe( 'worker/proxy', () => {
16
+ const originalEnv = { ...process.env };
17
+
18
+ beforeEach( () => {
19
+ vi.clearAllMocks();
20
+ delete process.env.HTTPS_PROXY;
21
+ delete process.env.https_proxy;
22
+ delete process.env.HTTP_PROXY;
23
+ delete process.env.http_proxy;
24
+ } );
25
+
26
+ afterEach( () => {
27
+ process.env = { ...originalEnv };
28
+ } );
29
+
30
+ it( 'does nothing when no proxy env vars are set', async () => {
31
+ const { bootstrapFetchProxy } = await import( './proxy.js' );
32
+ bootstrapFetchProxy();
33
+
34
+ expect( mockSetGlobalDispatcher ).not.toHaveBeenCalled();
35
+ } );
36
+
37
+ it( 'sets global dispatcher when HTTPS_PROXY is set', async () => {
38
+ process.env.HTTPS_PROXY = 'http://proxy:8080';
39
+ const { bootstrapFetchProxy } = await import( './proxy.js' );
40
+ bootstrapFetchProxy();
41
+
42
+ expect( MockEnvHttpProxyAgent ).toHaveBeenCalled();
43
+ expect( mockSetGlobalDispatcher ).toHaveBeenCalledTimes( 1 );
44
+ } );
45
+
46
+ it( 'sets global dispatcher when HTTP_PROXY is set', async () => {
47
+ process.env.HTTP_PROXY = 'http://proxy:8080';
48
+ const { bootstrapFetchProxy } = await import( './proxy.js' );
49
+ bootstrapFetchProxy();
50
+
51
+ expect( MockEnvHttpProxyAgent ).toHaveBeenCalled();
52
+ expect( mockSetGlobalDispatcher ).toHaveBeenCalledTimes( 1 );
53
+ } );
54
+
55
+ it( 'prefers HTTPS_PROXY over HTTP_PROXY for detection', async () => {
56
+ process.env.HTTPS_PROXY = 'http://secure-proxy:8080';
57
+ process.env.HTTP_PROXY = 'http://plain-proxy:8080';
58
+ const { bootstrapFetchProxy } = await import( './proxy.js' );
59
+ bootstrapFetchProxy();
60
+
61
+ expect( mockSetGlobalDispatcher ).toHaveBeenCalledTimes( 1 );
62
+ } );
63
+
64
+ it( 'does not set global dispatcher when proxy URL is malformed', async () => {
65
+ process.env.HTTPS_PROXY = 'not a url';
66
+ const { bootstrapFetchProxy } = await import( './proxy.js' );
67
+ bootstrapFetchProxy();
68
+
69
+ expect( mockSetGlobalDispatcher ).not.toHaveBeenCalled();
70
+ } );
71
+ } );