@cloudflare/sandbox 0.3.6 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/.turbo/turbo-build.log +44 -0
  2. package/CHANGELOG.md +6 -8
  3. package/Dockerfile +88 -18
  4. package/README.md +89 -824
  5. package/dist/{chunk-JTKON2SH.js → chunk-BCJ7SF3Q.js} +9 -5
  6. package/dist/chunk-BCJ7SF3Q.js.map +1 -0
  7. package/dist/chunk-BFVUNTP4.js +104 -0
  8. package/dist/chunk-BFVUNTP4.js.map +1 -0
  9. package/dist/{chunk-NNGBXDMY.js → chunk-EKSWCBCA.js} +3 -6
  10. package/dist/chunk-EKSWCBCA.js.map +1 -0
  11. package/dist/chunk-HGF554LH.js +2236 -0
  12. package/dist/chunk-HGF554LH.js.map +1 -0
  13. package/dist/{chunk-6UAWTJ5S.js → chunk-Z532A7QC.js} +13 -20
  14. package/dist/{chunk-6UAWTJ5S.js.map → chunk-Z532A7QC.js.map} +1 -1
  15. package/dist/file-stream.d.ts +16 -38
  16. package/dist/file-stream.js +1 -2
  17. package/dist/index.d.ts +6 -5
  18. package/dist/index.js +35 -39
  19. package/dist/index.js.map +1 -1
  20. package/dist/interpreter.d.ts +3 -3
  21. package/dist/interpreter.js +2 -2
  22. package/dist/request-handler.d.ts +4 -3
  23. package/dist/request-handler.js +4 -7
  24. package/dist/sandbox-D9K2ypln.d.ts +583 -0
  25. package/dist/sandbox.d.ts +3 -3
  26. package/dist/sandbox.js +4 -7
  27. package/dist/security.d.ts +4 -3
  28. package/dist/security.js +3 -3
  29. package/dist/sse-parser.js +1 -1
  30. package/package.json +11 -5
  31. package/src/clients/base-client.ts +280 -0
  32. package/src/clients/command-client.ts +115 -0
  33. package/src/clients/file-client.ts +269 -0
  34. package/src/clients/git-client.ts +92 -0
  35. package/src/clients/index.ts +63 -0
  36. package/src/{interpreter-client.ts → clients/interpreter-client.ts} +148 -171
  37. package/src/clients/port-client.ts +105 -0
  38. package/src/clients/process-client.ts +177 -0
  39. package/src/clients/sandbox-client.ts +41 -0
  40. package/src/clients/types.ts +84 -0
  41. package/src/clients/utility-client.ts +94 -0
  42. package/src/errors/adapter.ts +180 -0
  43. package/src/errors/classes.ts +469 -0
  44. package/src/errors/index.ts +105 -0
  45. package/src/file-stream.ts +119 -117
  46. package/src/index.ts +81 -69
  47. package/src/interpreter.ts +17 -8
  48. package/src/request-handler.ts +69 -43
  49. package/src/sandbox.ts +694 -533
  50. package/src/security.ts +14 -23
  51. package/src/sse-parser.ts +4 -8
  52. package/startup.sh +3 -0
  53. package/tests/base-client.test.ts +328 -0
  54. package/tests/command-client.test.ts +407 -0
  55. package/tests/file-client.test.ts +643 -0
  56. package/tests/file-stream.test.ts +306 -0
  57. package/tests/git-client.test.ts +328 -0
  58. package/tests/port-client.test.ts +301 -0
  59. package/tests/process-client.test.ts +658 -0
  60. package/tests/sandbox.test.ts +465 -0
  61. package/tests/sse-parser.test.ts +290 -0
  62. package/tests/utility-client.test.ts +266 -0
  63. package/tests/wrangler.jsonc +35 -0
  64. package/tsconfig.json +9 -1
  65. package/vitest.config.ts +31 -0
  66. package/container_src/bun.lock +0 -76
  67. package/container_src/circuit-breaker.ts +0 -121
  68. package/container_src/control-process.ts +0 -784
  69. package/container_src/handler/exec.ts +0 -185
  70. package/container_src/handler/file.ts +0 -457
  71. package/container_src/handler/git.ts +0 -130
  72. package/container_src/handler/ports.ts +0 -314
  73. package/container_src/handler/process.ts +0 -568
  74. package/container_src/handler/session.ts +0 -92
  75. package/container_src/index.ts +0 -601
  76. package/container_src/interpreter-service.ts +0 -276
  77. package/container_src/isolation.ts +0 -1213
  78. package/container_src/mime-processor.ts +0 -255
  79. package/container_src/package.json +0 -18
  80. package/container_src/runtime/executors/javascript/node_executor.ts +0 -123
  81. package/container_src/runtime/executors/python/ipython_executor.py +0 -338
  82. package/container_src/runtime/executors/typescript/ts_executor.ts +0 -138
  83. package/container_src/runtime/process-pool.ts +0 -464
  84. package/container_src/shell-escape.ts +0 -42
  85. package/container_src/startup.sh +0 -11
  86. package/container_src/types.ts +0 -131
  87. package/dist/chunk-32UDXUPC.js +0 -671
  88. package/dist/chunk-32UDXUPC.js.map +0 -1
  89. package/dist/chunk-5DILEXGY.js +0 -85
  90. package/dist/chunk-5DILEXGY.js.map +0 -1
  91. package/dist/chunk-D3U63BZP.js +0 -240
  92. package/dist/chunk-D3U63BZP.js.map +0 -1
  93. package/dist/chunk-FXYPFGOZ.js +0 -129
  94. package/dist/chunk-FXYPFGOZ.js.map +0 -1
  95. package/dist/chunk-JTKON2SH.js.map +0 -1
  96. package/dist/chunk-NNGBXDMY.js.map +0 -1
  97. package/dist/chunk-SQLJNZ3K.js +0 -674
  98. package/dist/chunk-SQLJNZ3K.js.map +0 -1
  99. package/dist/chunk-W7TVRPBG.js +0 -108
  100. package/dist/chunk-W7TVRPBG.js.map +0 -1
  101. package/dist/client-B3RUab0s.d.ts +0 -225
  102. package/dist/client.d.ts +0 -4
  103. package/dist/client.js +0 -7
  104. package/dist/client.js.map +0 -1
  105. package/dist/errors.d.ts +0 -95
  106. package/dist/errors.js +0 -27
  107. package/dist/errors.js.map +0 -1
  108. package/dist/interpreter-client.d.ts +0 -4
  109. package/dist/interpreter-client.js +0 -9
  110. package/dist/interpreter-client.js.map +0 -1
  111. package/dist/interpreter-types.d.ts +0 -259
  112. package/dist/interpreter-types.js +0 -9
  113. package/dist/interpreter-types.js.map +0 -1
  114. package/dist/types.d.ts +0 -453
  115. package/dist/types.js +0 -45
  116. package/dist/types.js.map +0 -1
  117. package/src/client.ts +0 -1048
  118. package/src/errors.ts +0 -219
  119. package/src/interpreter-types.ts +0 -390
  120. package/src/types.ts +0 -571
@@ -0,0 +1,465 @@
1
+ import type { DurableObjectState } from '@cloudflare/workers-types';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { Sandbox } from '../src/sandbox';
4
+
5
+ // Mock dependencies before imports
6
+ vi.mock('./interpreter', () => ({
7
+ CodeInterpreter: vi.fn().mockImplementation(() => ({})),
8
+ }));
9
+
10
+ vi.mock('@cloudflare/containers', () => ({
11
+ Container: class Container {
12
+ ctx: any;
13
+ env: any;
14
+ constructor(ctx: any, env: any) {
15
+ this.ctx = ctx;
16
+ this.env = env;
17
+ }
18
+ },
19
+ getContainer: vi.fn(),
20
+ }));
21
+
22
+ describe('Sandbox - Automatic Session Management', () => {
23
+ let sandbox: Sandbox;
24
+ let mockCtx: Partial<DurableObjectState>;
25
+ let mockEnv: any;
26
+
27
+ beforeEach(async () => {
28
+ vi.clearAllMocks();
29
+
30
+ // Mock DurableObjectState
31
+ mockCtx = {
32
+ storage: {
33
+ get: vi.fn().mockResolvedValue(null),
34
+ put: vi.fn().mockResolvedValue(undefined),
35
+ delete: vi.fn().mockResolvedValue(undefined),
36
+ list: vi.fn().mockResolvedValue(new Map()),
37
+ } as any,
38
+ blockConcurrencyWhile: vi.fn((fn: () => Promise<void>) => fn()),
39
+ id: {
40
+ toString: () => 'test-sandbox-id',
41
+ equals: vi.fn(),
42
+ name: 'test-sandbox',
43
+ } as any,
44
+ };
45
+
46
+ mockEnv = {};
47
+
48
+ // Create Sandbox instance - SandboxClient is created internally
49
+ sandbox = new Sandbox(mockCtx as DurableObjectState, mockEnv);
50
+
51
+ // Wait for blockConcurrencyWhile to complete
52
+ await vi.waitFor(() => {
53
+ expect(mockCtx.blockConcurrencyWhile).toHaveBeenCalled();
54
+ });
55
+
56
+ // Now spy on the client methods that we need for testing
57
+ vi.spyOn(sandbox.client.utils, 'createSession').mockResolvedValue({
58
+ success: true,
59
+ id: 'sandbox-default',
60
+ message: 'Created',
61
+ } as any);
62
+
63
+ vi.spyOn(sandbox.client.commands, 'execute').mockResolvedValue({
64
+ success: true,
65
+ stdout: '',
66
+ stderr: '',
67
+ exitCode: 0,
68
+ command: '',
69
+ timestamp: new Date().toISOString(),
70
+ } as any);
71
+
72
+ vi.spyOn(sandbox.client.files, 'writeFile').mockResolvedValue({
73
+ success: true,
74
+ path: '/test.txt',
75
+ timestamp: new Date().toISOString(),
76
+ } as any);
77
+ });
78
+
79
+ afterEach(() => {
80
+ vi.restoreAllMocks();
81
+ });
82
+
83
+ describe('default session management', () => {
84
+ it('should create default session on first operation', async () => {
85
+ vi.mocked(sandbox.client.commands.execute).mockResolvedValueOnce({
86
+ success: true,
87
+ stdout: 'test output',
88
+ stderr: '',
89
+ exitCode: 0,
90
+ command: 'echo test',
91
+ timestamp: new Date().toISOString(),
92
+ } as any);
93
+
94
+ await sandbox.exec('echo test');
95
+
96
+ expect(sandbox.client.utils.createSession).toHaveBeenCalledTimes(1);
97
+ expect(sandbox.client.utils.createSession).toHaveBeenCalledWith({
98
+ id: expect.stringMatching(/^sandbox-/),
99
+ env: {},
100
+ cwd: '/workspace',
101
+ });
102
+
103
+ expect(sandbox.client.commands.execute).toHaveBeenCalledWith(
104
+ 'echo test',
105
+ expect.stringMatching(/^sandbox-/)
106
+ );
107
+ });
108
+
109
+ it('should reuse default session across multiple operations', async () => {
110
+ await sandbox.exec('echo test1');
111
+ await sandbox.writeFile('/test.txt', 'content');
112
+ await sandbox.exec('echo test2');
113
+
114
+ expect(sandbox.client.utils.createSession).toHaveBeenCalledTimes(1);
115
+
116
+ const firstSessionId = vi.mocked(sandbox.client.commands.execute).mock.calls[0][1];
117
+ const fileSessionId = vi.mocked(sandbox.client.files.writeFile).mock.calls[0][2];
118
+ const secondSessionId = vi.mocked(sandbox.client.commands.execute).mock.calls[1][1];
119
+
120
+ expect(firstSessionId).toBe(fileSessionId);
121
+ expect(firstSessionId).toBe(secondSessionId);
122
+ });
123
+
124
+ it('should use default session for process management', async () => {
125
+ vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({
126
+ success: true,
127
+ processId: 'proc-1',
128
+ pid: 1234,
129
+ command: 'sleep 10',
130
+ timestamp: new Date().toISOString(),
131
+ } as any);
132
+
133
+ vi.spyOn(sandbox.client.processes, 'listProcesses').mockResolvedValue({
134
+ success: true,
135
+ processes: [{
136
+ id: 'proc-1',
137
+ pid: 1234,
138
+ command: 'sleep 10',
139
+ status: 'running',
140
+ startTime: new Date().toISOString(),
141
+ }],
142
+ timestamp: new Date().toISOString(),
143
+ } as any);
144
+
145
+ const process = await sandbox.startProcess('sleep 10');
146
+ const processes = await sandbox.listProcesses();
147
+
148
+ expect(sandbox.client.utils.createSession).toHaveBeenCalledTimes(1);
149
+
150
+ // startProcess uses sessionId (to start process in that session)
151
+ const startSessionId = vi.mocked(sandbox.client.processes.startProcess).mock.calls[0][1];
152
+ expect(startSessionId).toMatch(/^sandbox-/);
153
+
154
+ // listProcesses is sandbox-scoped - no sessionId parameter
155
+ const listProcessesCall = vi.mocked(sandbox.client.processes.listProcesses).mock.calls[0];
156
+ expect(listProcessesCall).toEqual([]);
157
+
158
+ // Verify the started process appears in the list
159
+ expect(process.id).toBe('proc-1');
160
+ expect(processes).toHaveLength(1);
161
+ expect(processes[0].id).toBe('proc-1');
162
+ });
163
+
164
+ it('should use default session for git operations', async () => {
165
+ vi.spyOn(sandbox.client.git, 'checkout').mockResolvedValue({
166
+ success: true,
167
+ stdout: 'Cloned successfully',
168
+ stderr: '',
169
+ branch: 'main',
170
+ targetDir: '/workspace/repo',
171
+ timestamp: new Date().toISOString(),
172
+ } as any);
173
+
174
+ await sandbox.gitCheckout('https://github.com/test/repo.git', { branch: 'main' });
175
+
176
+ expect(sandbox.client.utils.createSession).toHaveBeenCalledTimes(1);
177
+ expect(sandbox.client.git.checkout).toHaveBeenCalledWith(
178
+ 'https://github.com/test/repo.git',
179
+ expect.stringMatching(/^sandbox-/),
180
+ { branch: 'main', targetDir: undefined }
181
+ );
182
+ });
183
+
184
+ it('should initialize session with sandbox name when available', async () => {
185
+ await sandbox.setSandboxName('my-sandbox');
186
+
187
+ await sandbox.exec('pwd');
188
+
189
+ expect(sandbox.client.utils.createSession).toHaveBeenCalledWith({
190
+ id: 'sandbox-my-sandbox',
191
+ env: {},
192
+ cwd: '/workspace',
193
+ });
194
+ });
195
+ });
196
+
197
+ describe('explicit session creation', () => {
198
+ it('should create isolated execution session', async () => {
199
+ vi.mocked(sandbox.client.utils.createSession).mockResolvedValueOnce({
200
+ success: true,
201
+ id: 'custom-session-123',
202
+ message: 'Created',
203
+ } as any);
204
+
205
+ const session = await sandbox.createSession({
206
+ id: 'custom-session-123',
207
+ env: { NODE_ENV: 'test' },
208
+ cwd: '/test',
209
+ });
210
+
211
+ expect(sandbox.client.utils.createSession).toHaveBeenCalledWith({
212
+ id: 'custom-session-123',
213
+ env: { NODE_ENV: 'test' },
214
+ cwd: '/test',
215
+ });
216
+
217
+ expect(session.id).toBe('custom-session-123');
218
+ expect(session.exec).toBeInstanceOf(Function);
219
+ expect(session.startProcess).toBeInstanceOf(Function);
220
+ expect(session.writeFile).toBeInstanceOf(Function);
221
+ expect(session.gitCheckout).toBeInstanceOf(Function);
222
+ });
223
+
224
+ it('should execute operations in specific session context', async () => {
225
+ vi.mocked(sandbox.client.utils.createSession).mockResolvedValueOnce({
226
+ success: true,
227
+ id: 'isolated-session',
228
+ message: 'Created',
229
+ } as any);
230
+
231
+ const session = await sandbox.createSession({ id: 'isolated-session' });
232
+
233
+ await session.exec('echo test');
234
+
235
+ expect(sandbox.client.commands.execute).toHaveBeenCalledWith(
236
+ 'echo test',
237
+ 'isolated-session'
238
+ );
239
+ });
240
+
241
+ it('should isolate multiple explicit sessions', async () => {
242
+ vi.mocked(sandbox.client.utils.createSession)
243
+ .mockResolvedValueOnce({ success: true, id: 'session-1', message: 'Created' } as any)
244
+ .mockResolvedValueOnce({ success: true, id: 'session-2', message: 'Created' } as any);
245
+
246
+ const session1 = await sandbox.createSession({ id: 'session-1' });
247
+ const session2 = await sandbox.createSession({ id: 'session-2' });
248
+
249
+ await session1.exec('echo build');
250
+ await session2.exec('echo test');
251
+
252
+ const session1Id = vi.mocked(sandbox.client.commands.execute).mock.calls[0][1];
253
+ const session2Id = vi.mocked(sandbox.client.commands.execute).mock.calls[1][1];
254
+
255
+ expect(session1Id).toBe('session-1');
256
+ expect(session2Id).toBe('session-2');
257
+ expect(session1Id).not.toBe(session2Id);
258
+ });
259
+
260
+ it('should not interfere with default session', async () => {
261
+ vi.mocked(sandbox.client.utils.createSession)
262
+ .mockResolvedValueOnce({ success: true, id: 'sandbox-default', message: 'Created' } as any)
263
+ .mockResolvedValueOnce({ success: true, id: 'explicit-session', message: 'Created' } as any);
264
+
265
+ await sandbox.exec('echo default');
266
+
267
+ const explicitSession = await sandbox.createSession({ id: 'explicit-session' });
268
+ await explicitSession.exec('echo explicit');
269
+
270
+ await sandbox.exec('echo default-again');
271
+
272
+ const defaultSessionId1 = vi.mocked(sandbox.client.commands.execute).mock.calls[0][1];
273
+ const explicitSessionId = vi.mocked(sandbox.client.commands.execute).mock.calls[1][1];
274
+ const defaultSessionId2 = vi.mocked(sandbox.client.commands.execute).mock.calls[2][1];
275
+
276
+ expect(defaultSessionId1).toBe('sandbox-default');
277
+ expect(explicitSessionId).toBe('explicit-session');
278
+ expect(defaultSessionId2).toBe('sandbox-default');
279
+ expect(defaultSessionId1).toBe(defaultSessionId2);
280
+ expect(explicitSessionId).not.toBe(defaultSessionId1);
281
+ });
282
+
283
+ it('should generate session ID if not provided', async () => {
284
+ vi.mocked(sandbox.client.utils.createSession).mockResolvedValueOnce({
285
+ success: true,
286
+ id: 'session-generated-123',
287
+ message: 'Created',
288
+ } as any);
289
+
290
+ await sandbox.createSession();
291
+
292
+ expect(sandbox.client.utils.createSession).toHaveBeenCalledWith({
293
+ id: expect.stringMatching(/^session-/),
294
+ env: undefined,
295
+ cwd: undefined,
296
+ });
297
+ });
298
+ });
299
+
300
+ describe('ExecutionSession operations', () => {
301
+ let session: any;
302
+
303
+ beforeEach(async () => {
304
+ vi.mocked(sandbox.client.utils.createSession).mockResolvedValueOnce({
305
+ success: true,
306
+ id: 'test-session',
307
+ message: 'Created',
308
+ } as any);
309
+
310
+ session = await sandbox.createSession({ id: 'test-session' });
311
+ });
312
+
313
+ it('should execute command with session context', async () => {
314
+ await session.exec('pwd');
315
+ expect(sandbox.client.commands.execute).toHaveBeenCalledWith('pwd', 'test-session');
316
+ });
317
+
318
+ it('should start process with session context', async () => {
319
+ vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({
320
+ success: true,
321
+ process: {
322
+ id: 'proc-1',
323
+ pid: 1234,
324
+ command: 'sleep 10',
325
+ status: 'running',
326
+ startTime: new Date().toISOString(),
327
+ },
328
+ } as any);
329
+
330
+ await session.startProcess('sleep 10');
331
+
332
+ expect(sandbox.client.processes.startProcess).toHaveBeenCalledWith(
333
+ 'sleep 10',
334
+ 'test-session',
335
+ { processId: undefined }
336
+ );
337
+ });
338
+
339
+ it('should write file with session context', async () => {
340
+ vi.spyOn(sandbox.client.files, 'writeFile').mockResolvedValue({
341
+ success: true,
342
+ path: '/test.txt',
343
+ timestamp: new Date().toISOString(),
344
+ } as any);
345
+
346
+ await session.writeFile('/test.txt', 'content');
347
+
348
+ expect(sandbox.client.files.writeFile).toHaveBeenCalledWith(
349
+ '/test.txt',
350
+ 'content',
351
+ 'test-session',
352
+ { encoding: undefined }
353
+ );
354
+ });
355
+
356
+ it('should perform git checkout with session context', async () => {
357
+ vi.spyOn(sandbox.client.git, 'checkout').mockResolvedValue({
358
+ success: true,
359
+ stdout: 'Cloned',
360
+ stderr: '',
361
+ branch: 'main',
362
+ targetDir: '/workspace/repo',
363
+ timestamp: new Date().toISOString(),
364
+ } as any);
365
+
366
+ await session.gitCheckout('https://github.com/test/repo.git');
367
+
368
+ expect(sandbox.client.git.checkout).toHaveBeenCalledWith(
369
+ 'https://github.com/test/repo.git',
370
+ 'test-session',
371
+ { branch: undefined, targetDir: undefined }
372
+ );
373
+ });
374
+ });
375
+
376
+ describe('edge cases and error handling', () => {
377
+ it('should handle session creation errors gracefully', async () => {
378
+ vi.mocked(sandbox.client.utils.createSession).mockRejectedValueOnce(
379
+ new Error('Session creation failed')
380
+ );
381
+
382
+ await expect(sandbox.exec('echo test')).rejects.toThrow('Session creation failed');
383
+ });
384
+
385
+ it('should initialize with empty environment when not set', async () => {
386
+ await sandbox.exec('pwd');
387
+
388
+ expect(sandbox.client.utils.createSession).toHaveBeenCalledWith({
389
+ id: expect.any(String),
390
+ env: {},
391
+ cwd: '/workspace',
392
+ });
393
+ });
394
+
395
+ it('should use updated environment after setEnvVars', async () => {
396
+ await sandbox.setEnvVars({ NODE_ENV: 'production', DEBUG: 'true' });
397
+
398
+ await sandbox.exec('env');
399
+
400
+ expect(sandbox.client.utils.createSession).toHaveBeenCalledWith({
401
+ id: expect.any(String),
402
+ env: { NODE_ENV: 'production', DEBUG: 'true' },
403
+ cwd: '/workspace',
404
+ });
405
+ });
406
+ });
407
+
408
+ describe('port exposure - workers.dev detection', () => {
409
+ beforeEach(async () => {
410
+ await sandbox.setSandboxName('test-sandbox');
411
+ vi.spyOn(sandbox.client.ports, 'exposePort').mockResolvedValue({
412
+ success: true,
413
+ port: 8080,
414
+ name: 'test-service',
415
+ exposedAt: new Date().toISOString(),
416
+ } as any);
417
+ });
418
+
419
+ it('should reject workers.dev domains with CustomDomainRequiredError', async () => {
420
+ const hostnames = [
421
+ 'my-worker.workers.dev',
422
+ 'my-worker.my-account.workers.dev'
423
+ ];
424
+
425
+ for (const hostname of hostnames) {
426
+ try {
427
+ await sandbox.exposePort(8080, { name: 'test', hostname });
428
+ // Should not reach here
429
+ expect.fail('Should have thrown CustomDomainRequiredError');
430
+ } catch (error: any) {
431
+ expect(error.name).toBe('CustomDomainRequiredError');
432
+ expect(error.code).toBe('CUSTOM_DOMAIN_REQUIRED');
433
+ expect(error.message).toContain('workers.dev');
434
+ expect(error.message).toContain('custom domain');
435
+ }
436
+ }
437
+
438
+ // Verify client method was never called
439
+ expect(sandbox.client.ports.exposePort).not.toHaveBeenCalled();
440
+ });
441
+
442
+ it('should accept custom domains and subdomains', async () => {
443
+ const testCases = [
444
+ { hostname: 'example.com', description: 'apex domain' },
445
+ { hostname: 'sandbox.example.com', description: 'subdomain' }
446
+ ];
447
+
448
+ for (const { hostname } of testCases) {
449
+ const result = await sandbox.exposePort(8080, { name: 'test', hostname });
450
+ expect(result.url).toContain(hostname);
451
+ expect(result.port).toBe(8080);
452
+ }
453
+ });
454
+
455
+ it('should accept localhost for local development', async () => {
456
+ const result = await sandbox.exposePort(8080, {
457
+ name: 'test',
458
+ hostname: 'localhost:8787'
459
+ });
460
+
461
+ expect(result.url).toContain('localhost');
462
+ expect(sandbox.client.ports.exposePort).toHaveBeenCalled();
463
+ });
464
+ });
465
+ });