@cloudflare/sandbox 0.4.18 → 0.5.2
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.
- package/.turbo/turbo-build.log +17 -9
- package/CHANGELOG.md +64 -0
- package/Dockerfile +5 -1
- package/LICENSE +176 -0
- package/README.md +1 -1
- package/dist/dist-gVyG2H2h.js +612 -0
- package/dist/dist-gVyG2H2h.js.map +1 -0
- package/dist/index.d.ts +94 -1834
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +489 -678
- package/dist/index.js.map +1 -1
- package/dist/openai/index.d.ts +67 -0
- package/dist/openai/index.d.ts.map +1 -0
- package/dist/openai/index.js +362 -0
- package/dist/openai/index.js.map +1 -0
- package/dist/sandbox-B3vJ541e.d.ts +1729 -0
- package/dist/sandbox-B3vJ541e.d.ts.map +1 -0
- package/package.json +16 -2
- package/src/clients/base-client.ts +107 -46
- package/src/index.ts +19 -2
- package/src/openai/index.ts +465 -0
- package/src/request-handler.ts +2 -1
- package/src/sandbox.ts +684 -62
- package/src/storage-mount/credential-detection.ts +41 -0
- package/src/storage-mount/errors.ts +51 -0
- package/src/storage-mount/index.ts +17 -0
- package/src/storage-mount/provider-detection.ts +93 -0
- package/src/storage-mount/types.ts +17 -0
- package/src/version.ts +1 -1
- package/tests/base-client.test.ts +218 -0
- package/tests/get-sandbox.test.ts +24 -1
- package/tests/git-client.test.ts +7 -39
- package/tests/openai-shell-editor.test.ts +434 -0
- package/tests/port-client.test.ts +25 -35
- package/tests/process-client.test.ts +73 -107
- package/tests/sandbox.test.ts +128 -1
- package/tests/storage-mount/credential-detection.test.ts +119 -0
- package/tests/storage-mount/provider-detection.test.ts +77 -0
- package/tsconfig.json +2 -2
- package/tsdown.config.ts +3 -2
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { BucketCredentials, MountBucketOptions } from '@repo/shared';
|
|
2
|
+
import { MissingCredentialsError } from './errors';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Detect credentials for bucket mounting from environment variables
|
|
6
|
+
* Priority order:
|
|
7
|
+
* 1. Explicit options.credentials
|
|
8
|
+
* 2. Standard AWS env vars: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
|
|
9
|
+
* 3. Error: no credentials found
|
|
10
|
+
*
|
|
11
|
+
* @param options - Mount options
|
|
12
|
+
* @param envVars - Environment variables
|
|
13
|
+
* @returns Detected credentials
|
|
14
|
+
* @throws MissingCredentialsError if no credentials found
|
|
15
|
+
*/
|
|
16
|
+
export function detectCredentials(
|
|
17
|
+
options: MountBucketOptions,
|
|
18
|
+
envVars: Record<string, string | undefined>
|
|
19
|
+
): BucketCredentials {
|
|
20
|
+
// Priority 1: Explicit credentials in options
|
|
21
|
+
if (options.credentials) {
|
|
22
|
+
return options.credentials;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Priority 2: Standard AWS env vars
|
|
26
|
+
const awsAccessKeyId = envVars.AWS_ACCESS_KEY_ID;
|
|
27
|
+
const awsSecretAccessKey = envVars.AWS_SECRET_ACCESS_KEY;
|
|
28
|
+
|
|
29
|
+
if (awsAccessKeyId && awsSecretAccessKey) {
|
|
30
|
+
return {
|
|
31
|
+
accessKeyId: awsAccessKeyId,
|
|
32
|
+
secretAccessKey: awsSecretAccessKey
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// No credentials found - throw error with helpful message
|
|
37
|
+
throw new MissingCredentialsError(
|
|
38
|
+
`No credentials found. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY ` +
|
|
39
|
+
`environment variables, or pass explicit credentials in options.`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bucket mounting error classes
|
|
3
|
+
*
|
|
4
|
+
* These are SDK-side validation errors that follow the same pattern as SecurityError.
|
|
5
|
+
* They are thrown before any container interaction occurs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ErrorCode } from '@repo/shared/errors';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Base error for bucket mounting operations
|
|
12
|
+
*/
|
|
13
|
+
export class BucketMountError extends Error {
|
|
14
|
+
public readonly code: ErrorCode;
|
|
15
|
+
|
|
16
|
+
constructor(message: string, code: ErrorCode = ErrorCode.BUCKET_MOUNT_ERROR) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'BucketMountError';
|
|
19
|
+
this.code = code;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Thrown when S3FS mount command fails
|
|
25
|
+
*/
|
|
26
|
+
export class S3FSMountError extends BucketMountError {
|
|
27
|
+
constructor(message: string) {
|
|
28
|
+
super(message, ErrorCode.S3FS_MOUNT_ERROR);
|
|
29
|
+
this.name = 'S3FSMountError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Thrown when no credentials found in environment
|
|
35
|
+
*/
|
|
36
|
+
export class MissingCredentialsError extends BucketMountError {
|
|
37
|
+
constructor(message: string) {
|
|
38
|
+
super(message, ErrorCode.MISSING_CREDENTIALS);
|
|
39
|
+
this.name = 'MissingCredentialsError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Thrown when bucket name, mount path, or options are invalid
|
|
45
|
+
*/
|
|
46
|
+
export class InvalidMountConfigError extends BucketMountError {
|
|
47
|
+
constructor(message: string) {
|
|
48
|
+
super(message, ErrorCode.INVALID_MOUNT_CONFIG);
|
|
49
|
+
this.name = 'InvalidMountConfigError';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bucket mounting functionality
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { detectCredentials } from './credential-detection';
|
|
6
|
+
export {
|
|
7
|
+
BucketMountError,
|
|
8
|
+
InvalidMountConfigError,
|
|
9
|
+
MissingCredentialsError,
|
|
10
|
+
S3FSMountError
|
|
11
|
+
} from './errors';
|
|
12
|
+
export {
|
|
13
|
+
detectProviderFromUrl,
|
|
14
|
+
getProviderFlags,
|
|
15
|
+
resolveS3fsOptions
|
|
16
|
+
} from './provider-detection';
|
|
17
|
+
export type { MountInfo } from './types';
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider detection and s3fs flag configuration
|
|
3
|
+
*
|
|
4
|
+
* Based on s3fs-fuse documentation:
|
|
5
|
+
* https://github.com/s3fs-fuse/s3fs-fuse/wiki/Non-Amazon-S3
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { BucketProvider } from '@repo/shared';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Detect provider from endpoint URL using pattern matching
|
|
12
|
+
*/
|
|
13
|
+
export function detectProviderFromUrl(endpoint: string): BucketProvider | null {
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(endpoint);
|
|
16
|
+
const hostname = url.hostname.toLowerCase();
|
|
17
|
+
|
|
18
|
+
if (hostname.endsWith('.r2.cloudflarestorage.com')) {
|
|
19
|
+
return 'r2';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Match AWS S3: *.amazonaws.com or s3.amazonaws.com
|
|
23
|
+
if (
|
|
24
|
+
hostname.endsWith('.amazonaws.com') ||
|
|
25
|
+
hostname === 's3.amazonaws.com'
|
|
26
|
+
) {
|
|
27
|
+
return 's3';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (hostname === 'storage.googleapis.com') {
|
|
31
|
+
return 'gcs';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return null;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get s3fs flags for a given provider
|
|
42
|
+
*
|
|
43
|
+
* Based on s3fs-fuse wiki recommendations:
|
|
44
|
+
* https://github.com/s3fs-fuse/s3fs-fuse/wiki/Non-Amazon-S3
|
|
45
|
+
*/
|
|
46
|
+
export function getProviderFlags(provider: BucketProvider | null): string[] {
|
|
47
|
+
if (!provider) {
|
|
48
|
+
return ['use_path_request_style'];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
switch (provider) {
|
|
52
|
+
case 'r2':
|
|
53
|
+
return ['nomixupload'];
|
|
54
|
+
|
|
55
|
+
case 's3':
|
|
56
|
+
return [];
|
|
57
|
+
|
|
58
|
+
case 'gcs':
|
|
59
|
+
return [];
|
|
60
|
+
|
|
61
|
+
default:
|
|
62
|
+
return ['use_path_request_style'];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve s3fs options by combining provider defaults with user overrides
|
|
68
|
+
*/
|
|
69
|
+
export function resolveS3fsOptions(
|
|
70
|
+
provider: BucketProvider | null,
|
|
71
|
+
userOptions?: string[]
|
|
72
|
+
): string[] {
|
|
73
|
+
const providerFlags = getProviderFlags(provider);
|
|
74
|
+
|
|
75
|
+
if (!userOptions || userOptions.length === 0) {
|
|
76
|
+
return providerFlags;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Merge provider flags with user options
|
|
80
|
+
// User options take precedence (come last in the array)
|
|
81
|
+
const allFlags = [...providerFlags, ...userOptions];
|
|
82
|
+
|
|
83
|
+
// Deduplicate flags (keep last occurrence)
|
|
84
|
+
const flagMap = new Map<string, string>();
|
|
85
|
+
|
|
86
|
+
for (const flag of allFlags) {
|
|
87
|
+
// Split on '=' to get the flag name
|
|
88
|
+
const [flagName] = flag.split('=');
|
|
89
|
+
flagMap.set(flagName, flag);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return Array.from(flagMap.values());
|
|
93
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal bucket mounting types
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { BucketProvider } from '@repo/shared';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Internal tracking information for active mounts
|
|
9
|
+
*/
|
|
10
|
+
export interface MountInfo {
|
|
11
|
+
bucket: string;
|
|
12
|
+
mountPath: string;
|
|
13
|
+
endpoint: string;
|
|
14
|
+
provider: BucketProvider | null;
|
|
15
|
+
passwordFilePath: string;
|
|
16
|
+
mounted: boolean;
|
|
17
|
+
}
|
package/src/version.ts
CHANGED
|
@@ -361,4 +361,222 @@ describe('BaseHttpClient', () => {
|
|
|
361
361
|
}
|
|
362
362
|
});
|
|
363
363
|
});
|
|
364
|
+
|
|
365
|
+
describe('container startup retry logic', () => {
|
|
366
|
+
it('should retry 503 errors with "no container instance available"', async () => {
|
|
367
|
+
vi.useFakeTimers();
|
|
368
|
+
|
|
369
|
+
mockFetch
|
|
370
|
+
.mockResolvedValueOnce(
|
|
371
|
+
new Response('Error: There is no container instance available', {
|
|
372
|
+
status: 503
|
|
373
|
+
})
|
|
374
|
+
)
|
|
375
|
+
.mockResolvedValueOnce(
|
|
376
|
+
new Response(JSON.stringify({ success: true, data: 'recovered' }), {
|
|
377
|
+
status: 200
|
|
378
|
+
})
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
const promise = client.testRequest<TestDataResponse>('/api/test');
|
|
382
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
383
|
+
const result = await promise;
|
|
384
|
+
|
|
385
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
386
|
+
expect(result.success).toBe(true);
|
|
387
|
+
expect(result.data).toBe('recovered');
|
|
388
|
+
|
|
389
|
+
vi.useRealTimers();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('should retry 500 errors with "container port not found"', async () => {
|
|
393
|
+
vi.useFakeTimers();
|
|
394
|
+
|
|
395
|
+
mockFetch
|
|
396
|
+
.mockResolvedValueOnce(
|
|
397
|
+
new Response('Connection refused: container port not found', {
|
|
398
|
+
status: 500
|
|
399
|
+
})
|
|
400
|
+
)
|
|
401
|
+
.mockResolvedValueOnce(
|
|
402
|
+
new Response(JSON.stringify({ success: true }), { status: 200 })
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
const promise = client.testRequest('/api/test');
|
|
406
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
407
|
+
const result = await promise;
|
|
408
|
+
|
|
409
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
410
|
+
expect(result.success).toBe(true);
|
|
411
|
+
|
|
412
|
+
vi.useRealTimers();
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('should retry 500 errors with "the container is not listening"', async () => {
|
|
416
|
+
vi.useFakeTimers();
|
|
417
|
+
|
|
418
|
+
mockFetch
|
|
419
|
+
.mockResolvedValueOnce(
|
|
420
|
+
new Response('Error: the container is not listening on port 3000', {
|
|
421
|
+
status: 500
|
|
422
|
+
})
|
|
423
|
+
)
|
|
424
|
+
.mockResolvedValueOnce(
|
|
425
|
+
new Response(JSON.stringify({ success: true }), { status: 200 })
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
const promise = client.testRequest('/api/test');
|
|
429
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
430
|
+
const result = await promise;
|
|
431
|
+
|
|
432
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
433
|
+
expect(result.success).toBe(true);
|
|
434
|
+
|
|
435
|
+
vi.useRealTimers();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('should NOT retry 500 errors with "no such image"', async () => {
|
|
439
|
+
mockFetch.mockResolvedValueOnce(
|
|
440
|
+
new Response('Error: no such image: my-container:latest', {
|
|
441
|
+
status: 500
|
|
442
|
+
})
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
await expect(client.testRequest('/api/test')).rejects.toThrow();
|
|
446
|
+
expect(mockFetch).toHaveBeenCalledTimes(1); // No retry
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('should NOT retry 500 errors with "container already exists"', async () => {
|
|
450
|
+
mockFetch.mockResolvedValueOnce(
|
|
451
|
+
new Response('Error: container already exists', { status: 500 })
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
await expect(client.testRequest('/api/test')).rejects.toThrow();
|
|
455
|
+
expect(mockFetch).toHaveBeenCalledTimes(1); // No retry
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('should NOT retry 500 errors with unknown patterns', async () => {
|
|
459
|
+
mockFetch.mockResolvedValueOnce(
|
|
460
|
+
new Response('Internal server error: database connection failed', {
|
|
461
|
+
status: 500
|
|
462
|
+
})
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
await expect(client.testRequest('/api/test')).rejects.toThrow();
|
|
466
|
+
expect(mockFetch).toHaveBeenCalledTimes(1); // Fail-safe: don't retry
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('should NOT retry 404 or other non-500/503 errors', async () => {
|
|
470
|
+
mockFetch.mockResolvedValueOnce(
|
|
471
|
+
new Response('Not found', { status: 404 })
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
await expect(client.testRequest('/api/test')).rejects.toThrow();
|
|
475
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('should respect MIN_TIME_FOR_RETRY_MS and stop retrying', async () => {
|
|
479
|
+
vi.useFakeTimers();
|
|
480
|
+
|
|
481
|
+
// Mock responses that would trigger retry
|
|
482
|
+
mockFetch.mockResolvedValue(
|
|
483
|
+
new Response('No container instance available', { status: 503 })
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
const promise = client.testRequest('/api/test');
|
|
487
|
+
|
|
488
|
+
// Fast-forward past retry budget (120s)
|
|
489
|
+
await vi.advanceTimersByTimeAsync(125_000);
|
|
490
|
+
|
|
491
|
+
// Should eventually give up and throw the 503 error
|
|
492
|
+
await expect(promise).rejects.toThrow();
|
|
493
|
+
|
|
494
|
+
vi.useRealTimers();
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('should use exponential backoff: 3s, 6s, 12s, 24s, 30s', async () => {
|
|
498
|
+
vi.useFakeTimers();
|
|
499
|
+
const delays: number[] = [];
|
|
500
|
+
let callCount = 0;
|
|
501
|
+
|
|
502
|
+
mockFetch.mockImplementation(async () => {
|
|
503
|
+
delays.push(Date.now());
|
|
504
|
+
callCount++;
|
|
505
|
+
// After 5 attempts, return success to avoid timeout
|
|
506
|
+
if (callCount >= 5) {
|
|
507
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
508
|
+
status: 200
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
return new Response('No container instance available', { status: 503 });
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
const promise = client.testRequest('/api/test');
|
|
515
|
+
|
|
516
|
+
// Advance time to allow all retries
|
|
517
|
+
await vi.advanceTimersByTimeAsync(80_000);
|
|
518
|
+
|
|
519
|
+
await promise;
|
|
520
|
+
|
|
521
|
+
// Check delays between attempts (approximately)
|
|
522
|
+
// Attempt 1 at 0ms, Attempt 2 at ~3000ms, Attempt 3 at ~9000ms, etc.
|
|
523
|
+
expect(delays.length).toBeGreaterThanOrEqual(4);
|
|
524
|
+
|
|
525
|
+
vi.useRealTimers();
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('should retry multiple transient errors in sequence', async () => {
|
|
529
|
+
vi.useFakeTimers();
|
|
530
|
+
|
|
531
|
+
mockFetch
|
|
532
|
+
.mockResolvedValueOnce(
|
|
533
|
+
new Response('No container instance available', { status: 503 })
|
|
534
|
+
)
|
|
535
|
+
.mockResolvedValueOnce(
|
|
536
|
+
new Response('Container port not found', { status: 500 })
|
|
537
|
+
)
|
|
538
|
+
.mockResolvedValueOnce(
|
|
539
|
+
new Response('The container is not listening', { status: 500 })
|
|
540
|
+
)
|
|
541
|
+
.mockResolvedValueOnce(
|
|
542
|
+
new Response(JSON.stringify({ success: true }), { status: 200 })
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
const promise = client.testRequest('/api/test');
|
|
546
|
+
|
|
547
|
+
// Advance time to allow all retries (3s + 6s + 12s = 21s)
|
|
548
|
+
await vi.advanceTimersByTimeAsync(25_000);
|
|
549
|
+
|
|
550
|
+
const result = await promise;
|
|
551
|
+
|
|
552
|
+
expect(mockFetch).toHaveBeenCalledTimes(4);
|
|
553
|
+
expect(result.success).toBe(true);
|
|
554
|
+
|
|
555
|
+
vi.useRealTimers();
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it('should handle case-insensitive error matching', async () => {
|
|
559
|
+
vi.useFakeTimers();
|
|
560
|
+
|
|
561
|
+
mockFetch
|
|
562
|
+
.mockResolvedValueOnce(
|
|
563
|
+
new Response('ERROR: CONTAINER PORT NOT FOUND', { status: 500 })
|
|
564
|
+
)
|
|
565
|
+
.mockResolvedValueOnce(
|
|
566
|
+
new Response(JSON.stringify({ success: true }), { status: 200 })
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
const promise = client.testRequest('/api/test');
|
|
570
|
+
|
|
571
|
+
// Advance time for first retry (3s)
|
|
572
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
573
|
+
|
|
574
|
+
const result = await promise;
|
|
575
|
+
|
|
576
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
577
|
+
expect(result.success).toBe(true);
|
|
578
|
+
|
|
579
|
+
vi.useRealTimers();
|
|
580
|
+
});
|
|
581
|
+
});
|
|
364
582
|
});
|
|
@@ -44,7 +44,10 @@ describe('getSandbox', () => {
|
|
|
44
44
|
const sandbox = getSandbox(mockNamespace, 'test-sandbox');
|
|
45
45
|
|
|
46
46
|
expect(sandbox).toBeDefined();
|
|
47
|
-
expect(sandbox.setSandboxName).toHaveBeenCalledWith(
|
|
47
|
+
expect(sandbox.setSandboxName).toHaveBeenCalledWith(
|
|
48
|
+
'test-sandbox',
|
|
49
|
+
undefined
|
|
50
|
+
);
|
|
48
51
|
});
|
|
49
52
|
|
|
50
53
|
it('should apply sleepAfter option when provided as string', () => {
|
|
@@ -146,4 +149,24 @@ describe('getSandbox', () => {
|
|
|
146
149
|
expect(sandbox.setBaseUrl).toHaveBeenCalledWith('https://example.com');
|
|
147
150
|
expect(sandbox.setKeepAlive).toHaveBeenCalledWith(true);
|
|
148
151
|
});
|
|
152
|
+
|
|
153
|
+
it('should preserve sandbox ID case by default', () => {
|
|
154
|
+
const mockNamespace = {} as any;
|
|
155
|
+
getSandbox(mockNamespace, 'MyProject-ABC123');
|
|
156
|
+
|
|
157
|
+
expect(mockGetContainer).toHaveBeenCalledWith(
|
|
158
|
+
mockNamespace,
|
|
159
|
+
'MyProject-ABC123'
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should normalize sandbox ID to lowercase when normalizeId option is true', () => {
|
|
164
|
+
const mockNamespace = {} as any;
|
|
165
|
+
getSandbox(mockNamespace, 'MyProject-ABC123', { normalizeId: true });
|
|
166
|
+
|
|
167
|
+
expect(mockGetContainer).toHaveBeenCalledWith(
|
|
168
|
+
mockNamespace,
|
|
169
|
+
'myproject-abc123'
|
|
170
|
+
);
|
|
171
|
+
});
|
|
149
172
|
});
|
package/tests/git-client.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import type { GitCheckoutResult } from '@repo/shared';
|
|
1
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import type { GitCheckoutResponse } from '../src/clients';
|
|
3
3
|
import { GitClient } from '../src/clients/git-client';
|
|
4
4
|
import {
|
|
5
5
|
GitAuthenticationError,
|
|
@@ -35,12 +35,8 @@ describe('GitClient', () => {
|
|
|
35
35
|
|
|
36
36
|
describe('repository cloning', () => {
|
|
37
37
|
it('should clone public repositories successfully', async () => {
|
|
38
|
-
const mockResponse:
|
|
38
|
+
const mockResponse: GitCheckoutResult = {
|
|
39
39
|
success: true,
|
|
40
|
-
stdout:
|
|
41
|
-
"Cloning into 'react'...\nReceiving objects: 100% (1284/1284), done.",
|
|
42
|
-
stderr: '',
|
|
43
|
-
exitCode: 0,
|
|
44
40
|
repoUrl: 'https://github.com/facebook/react.git',
|
|
45
41
|
branch: 'main',
|
|
46
42
|
targetDir: 'react',
|
|
@@ -59,15 +55,11 @@ describe('GitClient', () => {
|
|
|
59
55
|
expect(result.success).toBe(true);
|
|
60
56
|
expect(result.repoUrl).toBe('https://github.com/facebook/react.git');
|
|
61
57
|
expect(result.branch).toBe('main');
|
|
62
|
-
expect(result.exitCode).toBe(0);
|
|
63
58
|
});
|
|
64
59
|
|
|
65
60
|
it('should clone repositories to specific branches', async () => {
|
|
66
|
-
const mockResponse:
|
|
61
|
+
const mockResponse: GitCheckoutResult = {
|
|
67
62
|
success: true,
|
|
68
|
-
stdout: "Cloning into 'project'...\nSwitching to branch 'development'",
|
|
69
|
-
stderr: '',
|
|
70
|
-
exitCode: 0,
|
|
71
63
|
repoUrl: 'https://github.com/company/project.git',
|
|
72
64
|
branch: 'development',
|
|
73
65
|
targetDir: 'project',
|
|
@@ -89,11 +81,8 @@ describe('GitClient', () => {
|
|
|
89
81
|
});
|
|
90
82
|
|
|
91
83
|
it('should clone repositories to custom directories', async () => {
|
|
92
|
-
const mockResponse:
|
|
84
|
+
const mockResponse: GitCheckoutResult = {
|
|
93
85
|
success: true,
|
|
94
|
-
stdout: "Cloning into 'workspace/my-app'...\nDone.",
|
|
95
|
-
stderr: '',
|
|
96
|
-
exitCode: 0,
|
|
97
86
|
repoUrl: 'https://github.com/user/my-app.git',
|
|
98
87
|
branch: 'main',
|
|
99
88
|
targetDir: 'workspace/my-app',
|
|
@@ -115,12 +104,8 @@ describe('GitClient', () => {
|
|
|
115
104
|
});
|
|
116
105
|
|
|
117
106
|
it('should handle large repository clones with warnings', async () => {
|
|
118
|
-
const mockResponse:
|
|
107
|
+
const mockResponse: GitCheckoutResult = {
|
|
119
108
|
success: true,
|
|
120
|
-
stdout:
|
|
121
|
-
"Cloning into 'linux'...\nReceiving objects: 100% (8125432/8125432), 2.34 GiB, done.",
|
|
122
|
-
stderr: 'warning: filtering not recognized by server',
|
|
123
|
-
exitCode: 0,
|
|
124
109
|
repoUrl: 'https://github.com/torvalds/linux.git',
|
|
125
110
|
branch: 'master',
|
|
126
111
|
targetDir: 'linux',
|
|
@@ -137,15 +122,11 @@ describe('GitClient', () => {
|
|
|
137
122
|
);
|
|
138
123
|
|
|
139
124
|
expect(result.success).toBe(true);
|
|
140
|
-
expect(result.stderr).toContain('warning:');
|
|
141
125
|
});
|
|
142
126
|
|
|
143
127
|
it('should handle SSH repository URLs', async () => {
|
|
144
|
-
const mockResponse:
|
|
128
|
+
const mockResponse: GitCheckoutResult = {
|
|
145
129
|
success: true,
|
|
146
|
-
stdout: "Cloning into 'private-project'...\nDone.",
|
|
147
|
-
stderr: '',
|
|
148
|
-
exitCode: 0,
|
|
149
130
|
repoUrl: 'git@github.com:company/private-project.git',
|
|
150
131
|
branch: 'main',
|
|
151
132
|
targetDir: 'private-project',
|
|
@@ -175,8 +156,6 @@ describe('GitClient', () => {
|
|
|
175
156
|
JSON.stringify({
|
|
176
157
|
success: true,
|
|
177
158
|
stdout: `Cloning into '${repoName}'...\nDone.`,
|
|
178
|
-
stderr: '',
|
|
179
|
-
exitCode: 0,
|
|
180
159
|
repoUrl: body.repoUrl,
|
|
181
160
|
branch: body.branch || 'main',
|
|
182
161
|
targetDir: body.targetDir || repoName,
|
|
@@ -320,11 +299,8 @@ describe('GitClient', () => {
|
|
|
320
299
|
});
|
|
321
300
|
|
|
322
301
|
it('should handle partial clone failures', async () => {
|
|
323
|
-
const mockResponse:
|
|
302
|
+
const mockResponse: GitCheckoutResult = {
|
|
324
303
|
success: false,
|
|
325
|
-
stdout: "Cloning into 'repo'...\nReceiving objects: 45% (450/1000)",
|
|
326
|
-
stderr: 'error: RPC failed\nfatal: early EOF',
|
|
327
|
-
exitCode: 128,
|
|
328
304
|
repoUrl: 'https://github.com/problematic/repo.git',
|
|
329
305
|
branch: 'main',
|
|
330
306
|
targetDir: 'repo',
|
|
@@ -341,8 +317,6 @@ describe('GitClient', () => {
|
|
|
341
317
|
);
|
|
342
318
|
|
|
343
319
|
expect(result.success).toBe(false);
|
|
344
|
-
expect(result.exitCode).toBe(128);
|
|
345
|
-
expect(result.stderr).toContain('RPC failed');
|
|
346
320
|
});
|
|
347
321
|
});
|
|
348
322
|
|
|
@@ -434,9 +408,6 @@ describe('GitClient', () => {
|
|
|
434
408
|
new Response(
|
|
435
409
|
JSON.stringify({
|
|
436
410
|
success: true,
|
|
437
|
-
stdout: "Cloning into 'private-repo'...\nDone.",
|
|
438
|
-
stderr: '',
|
|
439
|
-
exitCode: 0,
|
|
440
411
|
repoUrl:
|
|
441
412
|
'https://oauth2:ghp_token123@github.com/user/private-repo.git',
|
|
442
413
|
branch: 'main',
|
|
@@ -463,9 +434,6 @@ describe('GitClient', () => {
|
|
|
463
434
|
new Response(
|
|
464
435
|
JSON.stringify({
|
|
465
436
|
success: true,
|
|
466
|
-
stdout: "Cloning into 'react'...\nDone.",
|
|
467
|
-
stderr: '',
|
|
468
|
-
exitCode: 0,
|
|
469
437
|
repoUrl: 'https://github.com/facebook/react.git',
|
|
470
438
|
branch: 'main',
|
|
471
439
|
targetDir: '/workspace/react',
|