|
1 | 1 | /** |
2 | 2 | * Workspace Deletion Workflow Tests (Issue #288) |
3 | | - * |
4 | | - * Tests the fix for GitHub issue #288 where `sandbox.exec()` fails with |
5 | | - * "Unknown Error, TODO" after `/workspace` is removed or replaced with symlink. |
6 | | - * |
7 | | - * These tests verify: |
8 | | - * 1. Symlinks work correctly in non-/workspace directories |
9 | | - * 2. Session continues working after /workspace is deleted |
10 | | - * 3. Session continues working after /workspace is replaced with symlink |
11 | | - * 4. Session can recreate /workspace after deletion |
12 | | - * 5. New sessions can be created even when /workspace doesn't exist |
| 3 | +
|
13 | 4 | * |
14 | 5 | * @see https://github.com/cloudflare/sandbox-sdk/issues/288 |
15 | 6 | */ |
@@ -381,7 +372,212 @@ describe('Workspace Deletion Workflow (Issue #288)', () => { |
381 | 372 | }, 90000); |
382 | 373 |
|
383 | 374 | /** |
384 | | - * Test 7: Multiple operations after workspace manipulation |
| 375 | + * Test 7: New session creation after /workspace is deleted (Core bug from Issue #288) |
| 376 | + * |
| 377 | + * This is the PRIMARY test for issue #288. The bug was that creating a NEW session |
| 378 | + * after /workspace was deleted would fail with "Unknown Error, TODO". |
| 379 | + * |
| 380 | + * The fix makes session initialization fall back to "/" if /workspace doesn't exist. |
| 381 | + * |
| 382 | + * IMPORTANT: This test actually deletes /workspace and creates a new session. |
| 383 | + * It restores /workspace at the end to avoid breaking other tests. |
| 384 | + */ |
| 385 | + test('should create new session successfully after /workspace is deleted (issue #288 core bug)', async () => { |
| 386 | + const sandbox = await getSharedSandbox(); |
| 387 | + |
| 388 | + // Use a dedicated session for the setup/cleanup operations |
| 389 | + const setupSession = createUniqueSession(); |
| 390 | + const setupHeaders = sandbox.createHeaders(setupSession); |
| 391 | + |
| 392 | + // Step 1: Verify /workspace exists (or create it if needed) |
| 393 | + const ensureWorkspaceResponse = await fetch(`${workerUrl}/api/execute`, { |
| 394 | + method: 'POST', |
| 395 | + headers: setupHeaders, |
| 396 | + body: JSON.stringify({ |
| 397 | + command: 'mkdir -p /workspace && ls -la /workspace' |
| 398 | + }) |
| 399 | + }); |
| 400 | + expect(ensureWorkspaceResponse.status).toBe(200); |
| 401 | + |
| 402 | + // Step 2: Delete /workspace entirely |
| 403 | + const deleteResponse = await fetch(`${workerUrl}/api/execute`, { |
| 404 | + method: 'POST', |
| 405 | + headers: setupHeaders, |
| 406 | + body: JSON.stringify({ |
| 407 | + command: 'rm -rf /workspace' |
| 408 | + }) |
| 409 | + }); |
| 410 | + expect(deleteResponse.status).toBe(200); |
| 411 | + const deleteData = (await deleteResponse.json()) as ExecResult; |
| 412 | + expect(deleteData.success).toBe(true); |
| 413 | + |
| 414 | + // Step 3: Verify /workspace is gone |
| 415 | + const verifyGoneResponse = await fetch(`${workerUrl}/api/execute`, { |
| 416 | + method: 'POST', |
| 417 | + headers: setupHeaders, |
| 418 | + body: JSON.stringify({ |
| 419 | + command: |
| 420 | + '[ ! -d /workspace ] && echo "workspace deleted" || echo "workspace exists"' |
| 421 | + }) |
| 422 | + }); |
| 423 | + expect(verifyGoneResponse.status).toBe(200); |
| 424 | + const verifyGoneData = (await verifyGoneResponse.json()) as ExecResult; |
| 425 | + expect(verifyGoneData.stdout?.trim()).toBe('workspace deleted'); |
| 426 | + |
| 427 | + // Step 4: Create a BRAND NEW session - this is where the bug occurred |
| 428 | + // Before the fix, this would fail with "Unknown Error, TODO" |
| 429 | + const newSession = `new-after-delete-${Date.now()}-${Math.random().toString(36).slice(2)}`; |
| 430 | + const newHeaders = sandbox.createHeaders(newSession); |
| 431 | + |
| 432 | + const newSessionResponse = await fetch(`${workerUrl}/api/execute`, { |
| 433 | + method: 'POST', |
| 434 | + headers: newHeaders, |
| 435 | + body: JSON.stringify({ |
| 436 | + command: 'echo "new session after workspace deletion works"' |
| 437 | + }) |
| 438 | + }); |
| 439 | + |
| 440 | + expect(newSessionResponse.status).toBe(200); |
| 441 | + const newSessionData = (await newSessionResponse.json()) as ExecResult; |
| 442 | + expect(newSessionData.success).toBe(true); |
| 443 | + expect(newSessionData.stdout?.trim()).toBe( |
| 444 | + 'new session after workspace deletion works' |
| 445 | + ); |
| 446 | + |
| 447 | + // Verify NO "Unknown Error" or "TODO" in stderr |
| 448 | + expect(newSessionData.stderr?.toLowerCase() || '').not.toContain( |
| 449 | + 'unknown error' |
| 450 | + ); |
| 451 | + expect(newSessionData.stderr?.toLowerCase() || '').not.toContain('todo'); |
| 452 | + |
| 453 | + // Step 5: Verify the new session fell back to "/" as working directory |
| 454 | + const pwdResponse = await fetch(`${workerUrl}/api/execute`, { |
| 455 | + method: 'POST', |
| 456 | + headers: newHeaders, |
| 457 | + body: JSON.stringify({ |
| 458 | + command: 'pwd' |
| 459 | + }) |
| 460 | + }); |
| 461 | + expect(pwdResponse.status).toBe(200); |
| 462 | + const pwdData = (await pwdResponse.json()) as ExecResult; |
| 463 | + expect(pwdData.success).toBe(true); |
| 464 | + // Session should have fallen back to / since /workspace didn't exist |
| 465 | + expect(pwdData.stdout?.trim()).toBe('/'); |
| 466 | + |
| 467 | + // Step 6: Restore /workspace for other tests |
| 468 | + const restoreResponse = await fetch(`${workerUrl}/api/execute`, { |
| 469 | + method: 'POST', |
| 470 | + headers: setupHeaders, |
| 471 | + body: JSON.stringify({ |
| 472 | + command: 'mkdir -p /workspace && echo "workspace restored"' |
| 473 | + }) |
| 474 | + }); |
| 475 | + expect(restoreResponse.status).toBe(200); |
| 476 | + const restoreData = (await restoreResponse.json()) as ExecResult; |
| 477 | + expect(restoreData.success).toBe(true); |
| 478 | + }, 120000); |
| 479 | + |
| 480 | + /** |
| 481 | + * Test 8: New session creation after /workspace is replaced with symlink (Issue #288 variant) |
| 482 | + * |
| 483 | + * Tests the symlink variant of issue #288 where /workspace is replaced with |
| 484 | + * a symlink to another directory. |
| 485 | + */ |
| 486 | + test('should create new session successfully after /workspace is replaced with symlink', async () => { |
| 487 | + const sandbox = await getSharedSandbox(); |
| 488 | + |
| 489 | + // Use a dedicated session for setup/cleanup |
| 490 | + const setupSession = createUniqueSession(); |
| 491 | + const setupHeaders = sandbox.createHeaders(setupSession); |
| 492 | + const backupDir = `/tmp/workspace-backup-${Date.now()}`; |
| 493 | + |
| 494 | + // Step 1: Create backup directory and ensure /workspace exists |
| 495 | + const setupResponse = await fetch(`${workerUrl}/api/execute`, { |
| 496 | + method: 'POST', |
| 497 | + headers: setupHeaders, |
| 498 | + body: JSON.stringify({ |
| 499 | + command: `mkdir -p ${backupDir} /workspace` |
| 500 | + }) |
| 501 | + }); |
| 502 | + expect(setupResponse.status).toBe(200); |
| 503 | + |
| 504 | + // Step 2: Replace /workspace with a symlink |
| 505 | + const symlinkResponse = await fetch(`${workerUrl}/api/execute`, { |
| 506 | + method: 'POST', |
| 507 | + headers: setupHeaders, |
| 508 | + body: JSON.stringify({ |
| 509 | + command: `rm -rf /workspace && ln -sf ${backupDir} /workspace` |
| 510 | + }) |
| 511 | + }); |
| 512 | + expect(symlinkResponse.status).toBe(200); |
| 513 | + const symlinkData = (await symlinkResponse.json()) as ExecResult; |
| 514 | + expect(symlinkData.success).toBe(true); |
| 515 | + |
| 516 | + // Step 3: Verify /workspace is now a symlink |
| 517 | + const verifySymlinkResponse = await fetch(`${workerUrl}/api/execute`, { |
| 518 | + method: 'POST', |
| 519 | + headers: setupHeaders, |
| 520 | + body: JSON.stringify({ |
| 521 | + command: '[ -L /workspace ] && echo "is symlink" || echo "not symlink"' |
| 522 | + }) |
| 523 | + }); |
| 524 | + expect(verifySymlinkResponse.status).toBe(200); |
| 525 | + const verifySymlinkData = |
| 526 | + (await verifySymlinkResponse.json()) as ExecResult; |
| 527 | + expect(verifySymlinkData.stdout?.trim()).toBe('is symlink'); |
| 528 | + |
| 529 | + // Step 4: Create a NEW session - should work with the symlink |
| 530 | + const newSession = `new-after-symlink-${Date.now()}-${Math.random().toString(36).slice(2)}`; |
| 531 | + const newHeaders = sandbox.createHeaders(newSession); |
| 532 | + |
| 533 | + const newSessionResponse = await fetch(`${workerUrl}/api/execute`, { |
| 534 | + method: 'POST', |
| 535 | + headers: newHeaders, |
| 536 | + body: JSON.stringify({ |
| 537 | + command: 'echo "new session with symlink workspace works"' |
| 538 | + }) |
| 539 | + }); |
| 540 | + |
| 541 | + expect(newSessionResponse.status).toBe(200); |
| 542 | + const newSessionData = (await newSessionResponse.json()) as ExecResult; |
| 543 | + expect(newSessionData.success).toBe(true); |
| 544 | + expect(newSessionData.stdout?.trim()).toBe( |
| 545 | + 'new session with symlink workspace works' |
| 546 | + ); |
| 547 | + |
| 548 | + // Verify NO "Unknown Error" or "TODO" in stderr |
| 549 | + expect(newSessionData.stderr?.toLowerCase() || '').not.toContain( |
| 550 | + 'unknown error' |
| 551 | + ); |
| 552 | + expect(newSessionData.stderr?.toLowerCase() || '').not.toContain('todo'); |
| 553 | + |
| 554 | + // Step 5: Verify the session's cwd is through the symlink (resolves to backup dir) |
| 555 | + const pwdResponse = await fetch(`${workerUrl}/api/execute`, { |
| 556 | + method: 'POST', |
| 557 | + headers: newHeaders, |
| 558 | + body: JSON.stringify({ |
| 559 | + command: 'pwd -P' // -P shows physical path, resolving symlinks |
| 560 | + }) |
| 561 | + }); |
| 562 | + expect(pwdResponse.status).toBe(200); |
| 563 | + const pwdData = (await pwdResponse.json()) as ExecResult; |
| 564 | + expect(pwdData.success).toBe(true); |
| 565 | + // The physical path should be the backup directory |
| 566 | + expect(pwdData.stdout?.trim()).toBe(backupDir); |
| 567 | + |
| 568 | + // Step 6: Cleanup - restore /workspace as a real directory |
| 569 | + const cleanupResponse = await fetch(`${workerUrl}/api/execute`, { |
| 570 | + method: 'POST', |
| 571 | + headers: setupHeaders, |
| 572 | + body: JSON.stringify({ |
| 573 | + command: `rm -f /workspace && mkdir -p /workspace && rm -rf ${backupDir}` |
| 574 | + }) |
| 575 | + }); |
| 576 | + expect(cleanupResponse.status).toBe(200); |
| 577 | + }, 120000); |
| 578 | + |
| 579 | + /** |
| 580 | + * Test 9: Multiple operations after workspace manipulation |
385 | 581 | * |
386 | 582 | * Tests a realistic workflow where the workspace is manipulated |
387 | 583 | * and then multiple subsequent operations are performed. |
@@ -472,4 +668,65 @@ describe('Workspace Deletion Workflow (Issue #288)', () => { |
472 | 668 | body: JSON.stringify({ command: `rm -rf ${testWorkspace} ${backupDir}` }) |
473 | 669 | }); |
474 | 670 | }, 90000); |
| 671 | + |
| 672 | + /** |
| 673 | + * Exact reproduction from Issue #288 |
| 674 | + * |
| 675 | + * @see https://github.com/cloudflare/sandbox-sdk/issues/288 |
| 676 | + */ |
| 677 | + test('issue #288 exact minimal reproduction', async () => { |
| 678 | + const sandbox = await getSharedSandbox(); |
| 679 | + |
| 680 | + // Use a single session for the entire test (matches the bug report scenario) |
| 681 | + const sessionId = `issue-288-repro-${Date.now()}`; |
| 682 | + const headers = sandbox.createHeaders(sessionId); |
| 683 | + |
| 684 | + // 1. Get a sandbox instance (done via shared sandbox) |
| 685 | + |
| 686 | + // 2. Verify baseline works |
| 687 | + const baselineResponse = await fetch(`${workerUrl}/api/execute`, { |
| 688 | + method: 'POST', |
| 689 | + headers, |
| 690 | + body: JSON.stringify({ command: 'echo "baseline works"' }) |
| 691 | + }); |
| 692 | + expect(baselineResponse.status).toBe(200); |
| 693 | + const baselineData = (await baselineResponse.json()) as ExecResult; |
| 694 | + expect(baselineData.success).toBe(true); |
| 695 | + expect(baselineData.stdout?.trim()).toBe('baseline works'); |
| 696 | + |
| 697 | + // 3. Remove /workspace |
| 698 | + const removeResponse = await fetch(`${workerUrl}/api/execute`, { |
| 699 | + method: 'POST', |
| 700 | + headers, |
| 701 | + body: JSON.stringify({ command: 'rm -rf /workspace' }) |
| 702 | + }); |
| 703 | + expect(removeResponse.status).toBe(200); |
| 704 | + const removeData = (await removeResponse.json()) as ExecResult; |
| 705 | + expect(removeData.success).toBe(true); |
| 706 | + |
| 707 | + // 4. Try ANY subsequent exec() call |
| 708 | + const afterRemovalResponse = await fetch(`${workerUrl}/api/execute`, { |
| 709 | + method: 'POST', |
| 710 | + headers, |
| 711 | + body: JSON.stringify({ command: 'echo "after removal"' }) |
| 712 | + }); |
| 713 | + expect(afterRemovalResponse.status).toBe(200); |
| 714 | + const afterRemovalData = (await afterRemovalResponse.json()) as ExecResult; |
| 715 | + |
| 716 | + expect(afterRemovalData.success).toBe(true); |
| 717 | + expect(afterRemovalData.stdout?.trim()).toBe('after removal'); |
| 718 | + |
| 719 | + // Verify no "Unknown Error, TODO" anywhere |
| 720 | + expect(afterRemovalData.stderr?.toLowerCase() || '').not.toContain( |
| 721 | + 'unknown error' |
| 722 | + ); |
| 723 | + expect(afterRemovalData.stderr?.toLowerCase() || '').not.toContain('todo'); |
| 724 | + |
| 725 | + // Cleanup: restore /workspace for other tests |
| 726 | + await fetch(`${workerUrl}/api/execute`, { |
| 727 | + method: 'POST', |
| 728 | + headers, |
| 729 | + body: JSON.stringify({ command: 'mkdir -p /workspace' }) |
| 730 | + }); |
| 731 | + }, 90000); |
475 | 732 | }); |
0 commit comments