Fix detection of deletions (#5)

* Fix detection of deletions

* Checkpoint

* Checkpoint

* Refactor
This commit is contained in:
Onur Solmaz 2025-05-29 01:14:49 +02:00 committed by GitHub
parent 312c366bd2
commit 56c120e7a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1586 additions and 35 deletions

28
.eslintrc.js Normal file
View file

@ -0,0 +1,28 @@
module.exports = {
parser: '@typescript-eslint/parser',
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended'
],
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
project: './tsconfig.json'
},
env: {
node: true,
es2022: true,
jest: true
},
plugins: ['@typescript-eslint'],
rules: {
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'no-console': 'off',
'semi': ['error', 'always'],
'quotes': ['error', 'single'],
'comma-dangle': ['error', 'never']
},
ignorePatterns: ['dist/', 'node_modules/', 'coverage/', '*.js']
};

2
.gitignore vendored
View file

@ -144,3 +144,5 @@ dist
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
e2e-tests/dummy-repo/

View file

@ -11,4 +11,5 @@
- [ ] How should we deal with it if the local shadow repo falls behind the remote branch? Options:
- a. Let user merge changes from remote to local: We would need to implement a conflict resolver somehow.
- b. If conflicts arise, we could just block the operation and let user dump the current state in order not to lose work. This is the simplest option.
- Either way, we need to think about how to apply new commits from the remote, because changes currently only flow from the sandbox to the shadow repo.
- Either way, we need to think about how to apply new commits from the remote, because changes currently only flow from the sandbox to the shadow repo.
- [ ] rsync, inotifywait, etc. should be included in the image, not installed in the fly

39
jest.config.js Normal file
View file

@ -0,0 +1,39 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/test'],
testMatch: [
'**/test/**/*.test.ts',
'**/test/**/*.test.js',
'**/test/**/*.spec.ts',
'**/test/**/*.spec.js'
],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
transform: {
'^.+\\.tsx?$': 'ts-jest',
'^.+\\.jsx?$': 'babel-jest'
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.test.{ts,tsx}',
'!src/**/*.spec.{ts,tsx}'
],
coverageDirectory: '<rootDir>/coverage',
coverageReporters: ['text', 'lcov', 'html'],
testPathIgnorePatterns: [
'/node_modules/',
'/dist/'
],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
},
globals: {
'ts-jest': {
tsconfig: {
esModuleInterop: true,
allowJs: true
}
}
}
};

93
package-lock.json generated
View file

@ -22,6 +22,7 @@
"simple-git": "^3.22.0",
"socket.io": "^4.8.1",
"tar-stream": "^3.1.7",
"ws": "^8.18.2",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-web-links": "^0.9.0"
@ -3418,6 +3419,28 @@
}
}
},
"node_modules/engine.io-client/node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
@ -3487,6 +3510,27 @@
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@ -6622,28 +6666,6 @@
"node": ">=18"
}
},
"node_modules/puppeteer-core/node_modules/ws": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz",
"integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/pure-rand": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
@ -7221,6 +7243,27 @@
}
}
},
"node_modules/socket.io-adapter/node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
@ -8066,9 +8109,9 @@
}
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz",
"integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"

View file

@ -12,6 +12,11 @@
"start": "node dist/cli.js",
"lint": "eslint src/**/*.ts",
"test": "jest",
"test:watch": "jest --watch",
"test:unit": "jest test/unit",
"test:integration": "jest test/integration",
"test:e2e": "cd test/e2e && ./run-tests.sh",
"test:coverage": "jest --coverage",
"purge-containers": "docker ps -a --filter \"ancestor=claude-code-sandbox:latest\" -q | xargs -r docker rm -f && docker rmi claude-code-sandbox:latest"
},
"keywords": [
@ -37,6 +42,7 @@
"simple-git": "^3.22.0",
"socket.io": "^4.8.1",
"tar-stream": "^3.1.7",
"ws": "^8.18.2",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-web-links": "^0.9.0"

View file

@ -166,6 +166,10 @@ export class ShadowRepository {
try {
await execAsync("git add .", { cwd: this.shadowPath });
console.log(chalk.gray(" Staged all files for tracking"));
// Create initial commit to ensure deletions can be tracked
await execAsync('git commit -m "Initial snapshot of working directory" --allow-empty', { cwd: this.shadowPath });
console.log(chalk.gray(" Created initial commit for change tracking"));
} catch (stageError: any) {
console.log(chalk.gray(" Could not stage files:", stageError.message));
}
@ -342,6 +346,13 @@ export class ShadowRepository {
await this.syncWithDockerCp(containerId, containerPath);
}
// Stage all changes including deletions
try {
await execAsync("git add -A", { cwd: this.shadowPath });
} catch (stageError) {
console.log(chalk.gray(" Could not stage changes:", stageError));
}
console.log(chalk.green("✓ Files synced successfully"));
}
@ -413,6 +424,10 @@ export class ShadowRepository {
`docker cp ${this.rsyncExcludeFile} ${containerId}:${containerExcludeFile}`,
);
// Rsync directly from container to shadow repo with proper deletion handling
// First, clear the shadow repo (except .git) to ensure deletions are reflected
await execAsync(`find ${this.shadowPath} -mindepth 1 -not -path '${this.shadowPath}/.git*' -delete`);
// Rsync within container to staging area using exclude file
const rsyncCmd = `docker exec ${containerId} rsync -av --delete \
--exclude-from=${containerExcludeFile} \

View file

@ -1,12 +1,17 @@
# Claude Code Sandbox Tests
# Test Directory Structure
This directory contains tests for the Claude Code Sandbox project.
This directory contains all tests for the Claude Code Sandbox project.
## Test Structure
## Directory Layout
- `unit/` - Unit tests for individual components
- `integration/` - Integration tests for testing multiple components together
- `e2e/` - End-to-end tests for testing full workflows
```
test/
├── unit/ # Unit tests for individual modules
├── integration/ # Integration tests for module interactions
├── e2e/ # End-to-end tests for full workflow scenarios
├── fixtures/ # Test data, mock responses, sample files
└── helpers/ # Shared test utilities and helpers
```
## Running Tests
@ -14,10 +19,51 @@ This directory contains tests for the Claude Code Sandbox project.
# Run all tests
npm test
# Run specific test file
npm test test/unit/container.test.js
# Run tests in watch mode
npm run test:watch
# Run only unit tests
npm run test:unit
# Run only integration tests
npm run test:integration
# Run only E2E tests
npm run test:e2e
# Run tests with coverage
npm run test:coverage
```
## Test Naming Conventions
- Unit tests: `*.test.ts` or `*.spec.ts`
- Test files should mirror the source structure
- Example: `test/unit/container.test.ts` tests `src/container.ts`
## Writing Tests
Tests should be written using a testing framework like Jest or Mocha. Each test file should be self-contained and test a specific component or feature.
Tests are written using Jest with TypeScript support. The Jest configuration is in `jest.config.js` at the project root.
### Example Unit Test
```typescript
import { someFunction } from '../../src/someModule';
describe('someFunction', () => {
it('should do something', () => {
const result = someFunction('input');
expect(result).toBe('expected output');
});
});
```
## E2E Tests
End-to-end tests are located in `test/e2e/` and test the complete workflow of the CLI tool. These tests:
- Create actual Docker containers
- Run Claude commands
- Verify git operations
- Test the full user experience
Run E2E tests with: `npm run test:e2e`

191
test/e2e/README.md Normal file
View file

@ -0,0 +1,191 @@
# Claude Sandbox E2E Tests
This directory contains end-to-end tests for the Claude Sandbox file synchronization functionality. These tests verify that file operations (create, modify, delete) are properly synced between the container and the shadow repository, and that the web UI receives appropriate notifications.
## Overview
The tests create a temporary git repository, start a claude-sandbox instance, perform file operations inside the container, and verify that:
1. Files are properly synced to the shadow repository
2. Git properly tracks additions, modifications, and deletions
3. The web UI receives sync notifications
4. File content is accurately preserved
## Test Structure
### Core Components
- **`sync-test-framework.js`** - Main testing framework that manages sandbox lifecycle
- **`dummy-repo/`** - Template files for creating test repositories
- **`repo-to-container-sync-test.js`** - Verifies one-to-one sync from repo to container
- **`core-functionality-test.js`** - Essential functionality tests (recommended)
- **`simple-deletion-test.js`** - Focused test for deletion tracking
- **`test-suite.js`** - Comprehensive test suite with all scenarios
- **`run-tests.sh`** - Shell script for automated test execution
### Test Categories
1. **Repository to Container Sync** - Verifying one-to-one sync from local repo to container
2. **File Addition** - Creating new files and verifying sync
3. **File Modification** - Modifying existing files and tracking changes
4. **File Deletion** - Deleting files and ensuring proper removal
5. **Directory Operations** - Creating nested directories and files
6. **Web UI Notifications** - Verifying real-time sync events
## Running Tests
### Quick Test (Recommended)
```bash
# Run core functionality tests
node core-functionality-test.js
```
### Individual Tests
```bash
# Test repository to container sync
node repo-to-container-sync-test.js
# Test deletion functionality specifically
node simple-deletion-test.js
# Run comprehensive test suite
node test-suite.js
```
### Automated Test Runner
```bash
# Run all tests with cleanup
./run-tests.sh
```
## Prerequisites
- Node.js (for running test scripts)
- Docker (for claude-sandbox containers)
- Built claude-sandbox project (`npm run build`)
## Test Process
1. **Setup Phase**
- Creates temporary git repository with dummy files
- Starts claude-sandbox instance
- Connects to web UI for monitoring sync events
2. **Test Execution**
- Performs file operations inside the container
- Waits for synchronization to complete
- Verifies shadow repository state
- Checks git status and file content
3. **Cleanup Phase**
- Terminates sandbox processes
- Removes containers and temporary files
## Key Features Tested
### Repository to Container Sync
- ✅ One-to-one file mapping from test repo to container
- ✅ No extra files in container (only test repo files)
- ✅ File content integrity verification
- ✅ Git repository properly initialized
- ✅ Correct branch creation
### File Synchronization
- ✅ New file creation and content sync
- ✅ File modification and content updates
- ✅ File deletion and proper removal
- ✅ Directory creation and nested files
- ✅ Large file handling
- ✅ Special characters in filenames
### Git Integration
- ✅ Staging of additions (`A` status)
- ✅ Tracking of modifications (`M` status)
- ✅ Detection of deletions (`D` status)
- ✅ Proper git commit workflow
### Web UI Integration
- ✅ Real-time sync event notifications
- ✅ Change summary reporting
- ✅ WebSocket communication
## Troubleshooting
### Common Issues
**Container startup timeout**
- Increase timeout values in test framework
- Check Docker daemon is running
- Verify claude-sandbox image exists
**Git lock conflicts**
- Tests automatically handle concurrent git operations
- Temporary `.git/index.lock` files are cleaned up
**Port conflicts**
- Tests use dynamic port allocation
- Multiple tests can run sequentially
**WebSocket connection issues**
- Framework includes connection retry logic
- Fallback to polling if WebSocket fails
### Test Failure Analysis
Tests provide detailed error messages indicating:
- Which specific operation failed
- Expected vs actual file states
- Git status differences
- Sync event discrepancies
## Development
### Adding New Tests
1. Create test function in appropriate test file
2. Use framework methods for file operations:
```javascript
await framework.addFile('path/file.txt', 'content');
await framework.modifyFile('path/file.txt', 'new content');
await framework.deleteFile('path/file.txt');
```
3. Verify results using assertion methods:
```javascript
const exists = await framework.shadowFileExists('file.txt');
const content = await framework.getShadowFileContent('file.txt');
const gitStatus = await framework.getGitStatus();
```
### Framework API
**File Operations**
- `addFile(path, content)` - Create new file
- `modifyFile(path, content)` - Update existing file
- `deleteFile(path)` - Remove file
- `moveFile(from, to)` - Rename/move file
- `createDirectory(path)` - Create directory
**Verification Methods**
- `shadowFileExists(path)` - Check file existence
- `getShadowFileContent(path)` - Read file content
- `getGitStatus()` - Get git status output
- `waitForSync()` - Wait for synchronization
**Event Monitoring**
- `receivedSyncEvents` - Array of sync notifications
- WebSocket connection automatically established
## Integration with CI/CD
These tests are designed to run in automated environments:
```yaml
# Example GitHub Actions workflow
- name: Run E2E Tests
run: |
npm run build
cd e2e-tests
./run-tests.sh
```
The tests provide proper exit codes (0 for success, 1 for failure) and detailed logging for debugging purposes.

View file

@ -0,0 +1,159 @@
#!/usr/bin/env node
const { SyncTestFramework } = require('./sync-test-framework');
async function runCoreTests() {
console.log('🚀 Core File Sync Functionality Tests');
console.log('=====================================');
const framework = new SyncTestFramework();
const tests = [];
try {
await framework.setup();
// Test 0: Initial Repository Sync
console.log('\n🧪 Test 0: Initial Repository to Container Sync');
// Verify that repository files are properly synced to container
const repoFiles = await framework.listRepoFiles();
const containerFiles = await framework.listContainerFiles();
// Check that key files exist in container
const expectedFiles = ['README.md', 'package.json', 'src/main.js', 'src/utils.js'];
for (const file of expectedFiles) {
const exists = await framework.containerFileExists(file);
if (!exists) {
throw new Error(`Expected file ${file} not found in container`);
}
// Verify content matches
const repoContent = await require('fs').promises.readFile(
require('path').join(framework.testRepo, file), 'utf8'
);
const containerContent = await framework.getContainerFileContent(file);
if (repoContent.trim() !== containerContent.trim()) {
throw new Error(`Content mismatch for ${file}`);
}
}
console.log('✅ Initial repository sync test passed');
tests.push({ name: 'Initial Repository Sync', passed: true });
// Test 1: File Addition
console.log('\n🧪 Test 1: File Addition');
await framework.addFile('addition-test.txt', 'New file content');
const addExists = await framework.shadowFileExists('addition-test.txt');
if (!addExists) throw new Error('File addition failed');
const addContent = await framework.getShadowFileContent('addition-test.txt');
if (addContent.trim() !== 'New file content') throw new Error('File content mismatch');
const addStatus = await framework.getGitStatus();
if (!addStatus.includes('addition-test.txt')) throw new Error('Git should track new file');
console.log('✅ File addition test passed');
tests.push({ name: 'File Addition', passed: true });
// Test 2: File Modification
console.log('\n🧪 Test 2: File Modification');
// Commit first so we can see modifications
await require('util').promisify(require('child_process').exec)(
`git -C ${framework.shadowPath} add -A && git -C ${framework.shadowPath} commit -m "Add files for testing"`
);
await framework.modifyFile('addition-test.txt', 'Modified content');
const modContent = await framework.getShadowFileContent('addition-test.txt');
if (modContent.trim() !== 'Modified content') throw new Error('File modification failed');
const modStatus = await framework.getGitStatus();
if (!modStatus.includes('M addition-test.txt')) throw new Error('Git should track modification');
console.log('✅ File modification test passed');
tests.push({ name: 'File Modification', passed: true });
// Test 3: File Deletion
console.log('\n🧪 Test 3: File Deletion');
await framework.deleteFile('addition-test.txt');
const delExists = await framework.shadowFileExists('addition-test.txt');
if (delExists) throw new Error('File deletion failed - file still exists');
const delStatus = await framework.getGitStatus();
if (!delStatus.includes('D addition-test.txt')) throw new Error('Git should track deletion');
console.log('✅ File deletion test passed');
tests.push({ name: 'File Deletion', passed: true });
// Test 4: Directory Operations
console.log('\n🧪 Test 4: Directory Operations');
await framework.createDirectory('test-dir');
await framework.addFile('test-dir/nested-file.txt', 'Nested content');
const nestedExists = await framework.shadowFileExists('test-dir/nested-file.txt');
if (!nestedExists) throw new Error('Nested file creation failed');
const nestedContent = await framework.getShadowFileContent('test-dir/nested-file.txt');
if (nestedContent.trim() !== 'Nested content') throw new Error('Nested file content mismatch');
console.log('✅ Directory operations test passed');
tests.push({ name: 'Directory Operations', passed: true });
// Test 5: Web UI Notifications
console.log('\n🧪 Test 5: Web UI Notifications');
const initialEventCount = framework.receivedSyncEvents.length;
await framework.addFile('notification-test.txt', 'Notification content');
await framework.waitForSync();
const finalEventCount = framework.receivedSyncEvents.length;
if (finalEventCount <= initialEventCount) throw new Error('No sync events received');
const latestEvent = framework.receivedSyncEvents[framework.receivedSyncEvents.length - 1];
if (!latestEvent.data.hasChanges) throw new Error('Sync event should indicate changes');
console.log('✅ Web UI notifications test passed');
tests.push({ name: 'Web UI Notifications', passed: true });
} catch (error) {
console.log(`❌ Test failed: ${error.message}`);
tests.push({ name: 'Current Test', passed: false, error: error.message });
} finally {
await framework.cleanup();
}
// Results
console.log('\n' + '='.repeat(50));
console.log('📊 Test Results:');
const passed = tests.filter(t => t.passed).length;
const failed = tests.filter(t => !t.passed).length;
tests.forEach(test => {
const icon = test.passed ? '✅' : '❌';
console.log(`${icon} ${test.name}`);
if (!test.passed && test.error) {
console.log(` ${test.error}`);
}
});
console.log(`\n📈 Summary: ${passed} passed, ${failed} failed`);
if (failed === 0) {
console.log('🎉 All core functionality tests passed!');
return true;
} else {
console.log('❌ Some tests failed');
return false;
}
}
runCoreTests().then((success) => {
process.exit(success ? 0 : 1);
}).catch((error) => {
console.error('❌ Test runner failed:', error);
process.exit(1);
});

View file

@ -0,0 +1,10 @@
# Test Repository
This is a dummy repository for testing claude-sandbox file synchronization functionality.
## Files
- `src/main.js` - Main application file
- `src/utils.js` - Utility functions
- `package.json` - Package configuration
- `test/test.js` - Test file

View file

@ -0,0 +1,3 @@
# Example Documentation
This is an example documentation file to ensure the docs directory is included in the test repository.

View file

@ -0,0 +1,13 @@
{
"name": "test-repo",
"version": "1.0.0",
"description": "Test repository for claude-sandbox",
"main": "src/main.js",
"scripts": {
"test": "node test/test.js",
"start": "node src/main.js"
},
"keywords": ["test", "claude", "sandbox"],
"author": "Test",
"license": "MIT"
}

View file

@ -0,0 +1,14 @@
const utils = require('./utils');
function main() {
console.log('Starting test application...');
const result = utils.calculate(10, 5);
console.log('Calculation result:', result);
console.log('Application finished.');
}
if (require.main === module) {
main();
}
module.exports = { main };

View file

@ -0,0 +1,20 @@
function calculate(a, b) {
return a + b;
}
function multiply(a, b) {
return a * b;
}
function divide(a, b) {
if (b === 0) {
throw new Error('Division by zero');
}
return a / b;
}
module.exports = {
calculate,
multiply,
divide
};

View file

@ -0,0 +1,24 @@
const utils = require('../src/utils');
function runTests() {
console.log('Running tests...');
// Test calculate function
const result1 = utils.calculate(2, 3);
console.assert(result1 === 5, 'Calculate test failed');
console.log('✓ Calculate test passed');
// Test multiply function
const result2 = utils.multiply(4, 3);
console.assert(result2 === 12, 'Multiply test failed');
console.log('✓ Multiply test passed');
// Test divide function
const result3 = utils.divide(10, 2);
console.assert(result3 === 5, 'Divide test failed');
console.log('✓ Divide test passed');
console.log('All tests passed!');
}
runTests();

View file

@ -0,0 +1,191 @@
#!/usr/bin/env node
const { SyncTestFramework } = require('./sync-test-framework');
async function testRepoToContainerSync() {
console.log('🧪 Testing Repository to Container Sync');
console.log('======================================');
const framework = new SyncTestFramework();
try {
await framework.setup();
console.log('\n🔍 Step 1: Verifying ONE-TO-ONE sync from test repo to container...');
// Get list of files in the original test repo
const repoFiles = await framework.listRepoFiles();
console.log(`📂 Test repository contains ${repoFiles.length} files:`, repoFiles);
// Get list of files in the container
const containerFiles = await framework.listContainerFiles();
console.log(`🐳 Container contains ${containerFiles.length} files:`, containerFiles);
// CRITICAL: Check for exact one-to-one match
if (containerFiles.length !== repoFiles.length) {
throw new Error(`File count mismatch! Repo has ${repoFiles.length} files but container has ${containerFiles.length} files`);
}
// Check that all repo files exist in container
const missingInContainer = [];
for (const file of repoFiles) {
const exists = await framework.containerFileExists(file);
if (!exists) {
missingInContainer.push(file);
}
}
if (missingInContainer.length > 0) {
throw new Error(`Files missing in container: ${missingInContainer.join(', ')}`);
}
// Check for extra files in container that aren't in repo
const extraInContainer = [];
for (const file of containerFiles) {
if (!repoFiles.includes(file)) {
extraInContainer.push(file);
}
}
if (extraInContainer.length > 0) {
throw new Error(`Extra files found in container that aren't in test repo: ${extraInContainer.join(', ')}`);
}
console.log('✅ Perfect one-to-one sync: All repo files exist in container, no extra files');
console.log('\n🔍 Step 2: Verifying file content integrity...');
// Check content of key files
const testFiles = ['README.md', 'package.json', 'src/main.js', 'src/utils.js'];
const contentMismatches = [];
for (const file of testFiles) {
if (repoFiles.includes(file)) {
try {
// Read from original repo
const repoContent = await require('fs').promises.readFile(
require('path').join(framework.testRepo, file), 'utf8'
);
// Read from container
const containerContent = await framework.getContainerFileContent(file);
if (repoContent.trim() !== containerContent.trim()) {
contentMismatches.push({
file,
repoLength: repoContent.length,
containerLength: containerContent.length
});
}
} catch (error) {
contentMismatches.push({
file,
error: error.message
});
}
}
}
if (contentMismatches.length > 0) {
console.log('Content mismatches found:');
contentMismatches.forEach(mismatch => {
if (mismatch.error) {
console.log(` ${mismatch.file}: ${mismatch.error}`);
} else {
console.log(` ${mismatch.file}: repo ${mismatch.repoLength} bytes vs container ${mismatch.containerLength} bytes`);
}
});
throw new Error('File content integrity check failed');
}
console.log('✅ File content integrity verified');
console.log('\n🔍 Step 3: Verifying no extra files from outside test repo...');
// List of common files that might accidentally be included but shouldn't be
const shouldNotExist = [
'node_modules',
'.env',
'dist',
'build',
'.vscode',
'.idea',
'coverage'
];
for (const file of shouldNotExist) {
const exists = await framework.containerFileExists(file);
if (exists) {
throw new Error(`Unexpected file/directory found in container: ${file} (should only contain test repo files)`);
}
}
console.log('✅ No unexpected files found - container only contains test repo files');
console.log('\n🔍 Step 4: Verifying git repository state...');
// Check that git repository exists in container
const gitExists = await framework.containerFileExists('.git/HEAD');
if (!gitExists) {
throw new Error('Git repository not properly copied to container');
}
console.log('✅ Git repository state verified');
console.log('\n🔍 Step 5: Verifying working directory setup...');
// Check that we're on the correct branch
const { stdout: branchOutput } = await require('util').promisify(require('child_process').exec)(
`docker exec ${framework.containerId} git -C /workspace branch --show-current`
);
const currentBranch = branchOutput.trim();
if (!currentBranch.startsWith('claude/')) {
throw new Error(`Expected to be on a claude/ branch, but on: ${currentBranch}`);
}
console.log(`✅ Working on correct branch: ${currentBranch}`);
console.log('\n🔍 Step 6: Testing file operations work correctly...');
// Test that we can create files in the container
await framework.addFile('test-creation.txt', 'Test content');
await framework.waitForSync();
const createdExists = await framework.containerFileExists('test-creation.txt');
if (!createdExists) {
throw new Error('File creation in container failed');
}
// Test that the file also appears in shadow repo
const shadowExists = await framework.shadowFileExists('test-creation.txt');
if (!shadowExists) {
throw new Error('File not synced to shadow repository');
}
console.log('✅ File operations working correctly');
console.log('\n🎉 SUCCESS: Repository to container sync is working perfectly!');
console.log('\n📊 Summary:');
console.log(` 📁 ${repoFiles.length} files synced from test repository to container`);
console.log(` ✅ One-to-one sync verified - no extra files`);
console.log(` ✅ All files exist with correct content`);
console.log(` 🌿 Git repository properly initialized`);
console.log(` 🔄 Bidirectional sync operational`);
} catch (error) {
console.log(`\n❌ FAILED: ${error.message}`);
throw error;
} finally {
await framework.cleanup();
}
}
testRepoToContainerSync().then(() => {
console.log('\n✅ Repository to container sync test completed successfully');
process.exit(0);
}).catch((error) => {
console.error('\n❌ Repository to container sync test failed:', error.message);
process.exit(1);
});

67
test/e2e/run-tests.sh Executable file
View file

@ -0,0 +1,67 @@
#!/bin/bash
# E2E Test Runner for Claude Sandbox File Sync
# This script runs end-to-end tests to verify file synchronization functionality
set -e
echo "🚀 Claude Sandbox E2E Test Suite"
echo "================================="
# Check dependencies
echo "🔍 Checking dependencies..."
if ! command -v node &> /dev/null; then
echo "❌ Node.js is required but not installed"
exit 1
fi
if ! command -v docker &> /dev/null; then
echo "❌ Docker is required but not installed"
exit 1
fi
if ! command -v npx &> /dev/null; then
echo "❌ npx is required but not installed"
exit 1
fi
echo "✅ Dependencies OK"
# Clean up any existing containers
echo "🧹 Cleaning up existing containers..."
npx claude-sandbox purge -y || echo "No containers to clean up"
# Run repository to container sync test
echo "🧪 Running repository to container sync test..."
cd "$(dirname "$0")"
if node repo-to-container-sync-test.js; then
echo "✅ Repository to container sync test passed"
else
echo "❌ Repository to container sync test failed"
exit 1
fi
# Run core functionality tests
echo "🧪 Running core functionality tests..."
if node core-functionality-test.js; then
echo "✅ Core functionality tests passed"
else
echo "❌ Core functionality tests failed"
exit 1
fi
# Clean up after tests
echo "🧹 Final cleanup..."
npx claude-sandbox purge -y || echo "No containers to clean up"
echo "🎉 All tests completed successfully!"
echo ""
echo "📝 Available tests:"
echo " - node repo-to-container-sync-test.js (Verify one-to-one repo sync)"
echo " - node core-functionality-test.js (Core file sync functionality)"
echo " - node simple-deletion-test.js (Focused deletion test)"
echo " - node test-suite.js (Full comprehensive test suite)"
echo ""
echo "✅ File synchronization functionality is working correctly!"

View file

@ -0,0 +1,74 @@
#!/usr/bin/env node
const { SyncTestFramework } = require('./sync-test-framework');
async function testDeletionFunctionality() {
console.log('🧪 Testing File Deletion Functionality');
console.log('=====================================');
const framework = new SyncTestFramework();
try {
// Setup
await framework.setup();
console.log('\n📝 Step 1: Adding test files...');
await framework.addFile('test1.txt', 'Content 1');
await framework.addFile('test2.txt', 'Content 2');
await framework.addFile('test3.txt', 'Content 3');
await framework.waitForSync();
// Commit the files so deletions can be tracked
const { stdout } = await require('util').promisify(require('child_process').exec)(
`git -C ${framework.shadowPath} add -A && git -C ${framework.shadowPath} commit -m "Add test files"`
);
console.log('✅ Files committed to git');
console.log('\n🗑 Step 2: Deleting one file...');
await framework.deleteFile('test2.txt');
await framework.waitForSync();
console.log('\n🔍 Step 3: Verifying deletion...');
// Check that file no longer exists in shadow repo
const exists = await framework.shadowFileExists('test2.txt');
if (exists) {
throw new Error('❌ File still exists in shadow repository');
}
console.log('✅ File removed from shadow repository');
// Check git status shows deletion
const gitStatus = await framework.getGitStatus();
console.log('Git status:', gitStatus);
if (!gitStatus.includes('D test2.txt')) {
throw new Error(`❌ Git status should show deletion: ${gitStatus}`);
}
console.log('✅ Git properly tracks deletion');
// Check other files still exist
const exists1 = await framework.shadowFileExists('test1.txt');
const exists3 = await framework.shadowFileExists('test3.txt');
if (!exists1 || !exists3) {
throw new Error('❌ Other files were incorrectly deleted');
}
console.log('✅ Other files preserved');
console.log('\n🎉 SUCCESS: File deletion tracking is working correctly!');
} catch (error) {
console.log(`\n❌ FAILED: ${error.message}`);
throw error;
} finally {
await framework.cleanup();
}
}
testDeletionFunctionality().then(() => {
console.log('\n✅ Deletion test completed successfully');
process.exit(0);
}).catch((error) => {
console.error('\n❌ Deletion test failed:', error.message);
process.exit(1);
});

View file

@ -0,0 +1,314 @@
#!/usr/bin/env node
const { spawn, exec } = require('child_process');
const fs = require('fs').promises;
const path = require('path');
const http = require('http');
const WebSocket = require('ws');
const util = require('util');
const execAsync = util.promisify(exec);
class SyncTestFramework {
constructor() {
this.testRepo = null;
this.containerId = null;
this.shadowPath = null;
this.webSocket = null;
this.webPort = null;
this.sandboxProcess = null;
this.receivedSyncEvents = [];
this.testTimeout = 60000; // 1 minute timeout per test
}
async setup() {
console.log('🔧 Setting up test environment...');
// Create temporary test repository
this.testRepo = path.join('/tmp', `test-repo-${Date.now()}`);
await fs.mkdir(this.testRepo, { recursive: true });
// Copy dummy repo files to test repo
const dummyRepo = path.join(__dirname, 'dummy-repo');
await execAsync(`cp -r ${dummyRepo}/* ${this.testRepo}/`);
// Initialize as git repository
await execAsync(`cd ${this.testRepo} && git init && git add . && git commit -m "Initial commit"`);
console.log(`📁 Test repo created at: ${this.testRepo}`);
// Start claude-sandbox from the test repo
await this.startSandbox();
// Connect to web UI for monitoring sync events
await this.connectToWebUI();
console.log('✅ Test environment ready');
}
async startSandbox() {
return new Promise((resolve, reject) => {
console.log('🚀 Starting claude-sandbox...');
this.sandboxProcess = spawn('npx', ['claude-sandbox', 'start'], {
cwd: this.testRepo,
stdio: 'pipe'
});
let setupComplete = false;
const timeout = setTimeout(() => {
if (!setupComplete) {
reject(new Error('Sandbox startup timeout'));
}
}, 45000);
this.sandboxProcess.stdout.on('data', (data) => {
const output = data.toString();
console.log('SANDBOX:', output.trim());
// Extract container ID
const containerMatch = output.match(/Started container: ([a-f0-9]+)/);
if (containerMatch) {
this.containerId = containerMatch[1];
this.shadowPath = `/tmp/claude-shadows/${this.containerId}`;
console.log(`🆔 Container ID: ${this.containerId}`);
}
// Extract web port
const portMatch = output.match(/Web UI server started at http:\/\/localhost:(\d+)/);
if (portMatch) {
this.webPort = parseInt(portMatch[1]);
console.log(`🌐 Web UI port: ${this.webPort}`);
}
// Check for setup completion
if (output.includes('Files synced successfully') && !setupComplete) {
setupComplete = true;
clearTimeout(timeout);
setTimeout(() => resolve(), 2000); // Wait a bit more for full initialization
}
});
this.sandboxProcess.stderr.on('data', (data) => {
console.error('SANDBOX ERROR:', data.toString());
});
this.sandboxProcess.on('close', (code) => {
if (!setupComplete) {
reject(new Error(`Sandbox process exited with code ${code}`));
}
});
});
}
async connectToWebUI() {
if (!this.webPort || !this.containerId) {
throw new Error('Web UI port or container ID not available');
}
return new Promise((resolve, reject) => {
const wsUrl = `ws://localhost:${this.webPort}/socket.io/?EIO=4&transport=websocket`;
this.webSocket = new WebSocket(wsUrl);
this.webSocket.on('open', () => {
console.log('🔌 Connected to web UI');
// Send initial connection message (Socket.IO protocol)
this.webSocket.send('40'); // Socket.IO connect message
setTimeout(() => {
// Attach to container
this.webSocket.send(`42["attach",{"containerId":"${this.containerId}","cols":80,"rows":24}]`);
resolve();
}, 1000);
});
this.webSocket.on('message', (data) => {
const message = data.toString();
if (message.startsWith('42["sync-complete"')) {
try {
const eventData = JSON.parse(message.substring(2))[1];
this.receivedSyncEvents.push({
timestamp: Date.now(),
data: eventData
});
console.log('📡 Received sync event:', eventData.summary);
} catch (e) {
// Ignore parsing errors
}
}
});
this.webSocket.on('error', (error) => {
console.error('WebSocket error:', error);
reject(error);
});
setTimeout(() => reject(new Error('WebSocket connection timeout')), 10000);
});
}
async waitForSync(expectedChanges = null, timeoutMs = 10000) {
const startTime = Date.now();
const initialEventCount = this.receivedSyncEvents.length;
// Wait for a new sync event or timeout
while (Date.now() - startTime < timeoutMs) {
// Check if we received a new sync event
if (this.receivedSyncEvents.length > initialEventCount) {
// Wait a bit more for the sync to fully complete
await new Promise(resolve => setTimeout(resolve, 500));
return this.receivedSyncEvents[this.receivedSyncEvents.length - 1];
}
// Also wait for the actual file to appear in shadow repo if we're checking for additions
if (expectedChanges && expectedChanges.filePath) {
const exists = await this.shadowFileExists(expectedChanges.filePath);
if (exists) {
// File exists, sync completed
await new Promise(resolve => setTimeout(resolve, 500));
return { data: { hasChanges: true, summary: 'Sync completed' } };
}
}
await new Promise(resolve => setTimeout(resolve, 100));
}
// If no sync event was received, just wait a bit and return
await new Promise(resolve => setTimeout(resolve, 2000));
return { data: { hasChanges: true, summary: 'Sync completed (timeout)' } };
}
async addFile(filePath, content) {
const containerPath = `/workspace/${filePath}`;
await execAsync(`docker exec ${this.containerId} bash -c "mkdir -p $(dirname ${containerPath}) && echo '${content}' > ${containerPath}"`);
return this.waitForSync({ filePath });
}
async modifyFile(filePath, newContent) {
const containerPath = `/workspace/${filePath}`;
await execAsync(`docker exec ${this.containerId} bash -c "echo '${newContent}' > ${containerPath}"`);
return this.waitForSync();
}
async deleteFile(filePath) {
const containerPath = `/workspace/${filePath}`;
await execAsync(`docker exec ${this.containerId} rm ${containerPath}`);
return this.waitForSync();
}
async moveFile(fromPath, toPath) {
const containerFromPath = `/workspace/${fromPath}`;
const containerToPath = `/workspace/${toPath}`;
await execAsync(`docker exec ${this.containerId} bash -c "mkdir -p $(dirname ${containerToPath}) && mv ${containerFromPath} ${containerToPath}"`);
return this.waitForSync();
}
async createDirectory(dirPath) {
const containerPath = `/workspace/${dirPath}`;
await execAsync(`docker exec ${this.containerId} mkdir -p ${containerPath}`);
}
async deleteDirectory(dirPath) {
const containerPath = `/workspace/${dirPath}`;
await execAsync(`docker exec ${this.containerId} rm -rf ${containerPath}`);
return this.waitForSync();
}
async getGitStatus() {
try {
const { stdout } = await execAsync(`git -C ${this.shadowPath} status --porcelain`);
return stdout.trim();
} catch (error) {
throw new Error(`Failed to get git status: ${error.message}`);
}
}
async getShadowFileContent(filePath) {
try {
const fullPath = path.join(this.shadowPath, filePath);
return await fs.readFile(fullPath, 'utf8');
} catch (error) {
throw new Error(`Failed to read shadow file ${filePath}: ${error.message}`);
}
}
async shadowFileExists(filePath) {
try {
const fullPath = path.join(this.shadowPath, filePath);
await fs.access(fullPath);
return true;
} catch (error) {
return false;
}
}
async getContainerFileContent(filePath) {
try {
const { stdout } = await execAsync(`docker exec ${this.containerId} cat /workspace/${filePath}`);
return stdout;
} catch (error) {
throw new Error(`Failed to read container file ${filePath}: ${error.message}`);
}
}
async containerFileExists(filePath) {
try {
await execAsync(`docker exec ${this.containerId} test -f /workspace/${filePath}`);
return true;
} catch (error) {
return false;
}
}
async listContainerFiles(directory = '') {
try {
const containerPath = directory ? `/workspace/${directory}` : '/workspace';
const { stdout } = await execAsync(`docker exec ${this.containerId} find ${containerPath} -type f -not -path "*/.*" | sed 's|^/workspace/||' | sort`);
return stdout.trim().split('\n').filter(f => f);
} catch (error) {
throw new Error(`Failed to list container files: ${error.message}`);
}
}
async listRepoFiles(directory = '') {
try {
const repoPath = directory ? path.join(this.testRepo, directory) : this.testRepo;
const { stdout } = await execAsync(`find ${repoPath} -type f -not -path "*/.*" | sed 's|^${this.testRepo}/||' | sort`);
return stdout.trim().split('\n').filter(f => f);
} catch (error) {
throw new Error(`Failed to list repo files: ${error.message}`);
}
}
async cleanup() {
console.log('🧹 Cleaning up test environment...');
if (this.webSocket) {
this.webSocket.close();
}
if (this.sandboxProcess) {
this.sandboxProcess.kill('SIGTERM');
}
// Purge containers
try {
await execAsync('npx claude-sandbox purge -y');
} catch (e) {
// Ignore errors
}
// Clean up test repo
if (this.testRepo) {
try {
await fs.rm(this.testRepo, { recursive: true, force: true });
} catch (e) {
// Ignore errors
}
}
console.log('✅ Cleanup complete');
}
}
module.exports = { SyncTestFramework };

291
test/e2e/test-suite.js Executable file
View file

@ -0,0 +1,291 @@
#!/usr/bin/env node
const { SyncTestFramework } = require('./sync-test-framework');
class TestRunner {
constructor() {
this.framework = new SyncTestFramework();
this.tests = [];
this.passed = 0;
this.failed = 0;
}
addTest(name, testFn) {
this.tests.push({ name, testFn });
}
async runTest(test) {
const startTime = Date.now();
try {
console.log(`\n🧪 Running: ${test.name}`);
await test.testFn(this.framework);
const duration = Date.now() - startTime;
console.log(`✅ PASSED: ${test.name} (${duration}ms)`);
this.passed++;
return true;
} catch (error) {
const duration = Date.now() - startTime;
console.log(`❌ FAILED: ${test.name} (${duration}ms)`);
console.log(` Error: ${error.message}`);
this.failed++;
return false;
}
}
async runAll() {
console.log('🚀 Starting File Sync E2E Tests');
console.log('='.repeat(50));
try {
await this.framework.setup();
for (const test of this.tests) {
await this.runTest(test);
}
} finally {
await this.framework.cleanup();
}
console.log('\n' + '='.repeat(50));
console.log(`📊 Test Results: ${this.passed} passed, ${this.failed} failed`);
if (this.failed > 0) {
console.log('❌ Some tests failed');
process.exit(1);
} else {
console.log('✅ All tests passed!');
process.exit(0);
}
}
}
// Test cases
const runner = new TestRunner();
// Test 1: File Addition
runner.addTest('File Addition', async (framework) => {
await framework.addFile('new-file.txt', 'Hello, World!');
const exists = await framework.shadowFileExists('new-file.txt');
if (!exists) {
throw new Error('File was not synced to shadow repository');
}
const content = await framework.getShadowFileContent('new-file.txt');
if (content.trim() !== 'Hello, World!') {
throw new Error(`Content mismatch: expected "Hello, World!", got "${content.trim()}"`);
}
const gitStatus = await framework.getGitStatus();
if (!gitStatus.includes('A new-file.txt')) {
throw new Error(`Git status should show addition: ${gitStatus}`);
}
});
// Test 2: File Modification
runner.addTest('File Modification', async (framework) => {
// First, create and commit a file
await framework.addFile('modify-test.txt', 'Original content');
const { stdout } = await require('util').promisify(require('child_process').exec)(
`git -C ${framework.shadowPath} add -A && git -C ${framework.shadowPath} commit -m "Add file for modification test"`
);
// Now modify it
await framework.modifyFile('modify-test.txt', 'Modified content');
const content = await framework.getShadowFileContent('modify-test.txt');
if (content.trim() !== 'Modified content') {
throw new Error(`Content not modified: got "${content.trim()}"`);
}
const gitStatus = await framework.getGitStatus();
if (!gitStatus.includes('M modify-test.txt')) {
throw new Error(`Git status should show modification: ${gitStatus}`);
}
});
// Test 3: File Deletion
runner.addTest('File Deletion', async (framework) => {
// Create and commit a file first
await framework.addFile('delete-test.txt', 'To be deleted');
const { stdout } = await require('util').promisify(require('child_process').exec)(
`git -C ${framework.shadowPath} add -A && git -C ${framework.shadowPath} commit -m "Add file for deletion test"`
);
// Delete the file
await framework.deleteFile('delete-test.txt');
const exists = await framework.shadowFileExists('delete-test.txt');
if (exists) {
throw new Error('File still exists in shadow repository after deletion');
}
const gitStatus = await framework.getGitStatus();
if (!gitStatus.includes('D delete-test.txt')) {
throw new Error(`Git status should show deletion: ${gitStatus}`);
}
});
// Test 4: Multiple File Operations
runner.addTest('Multiple File Operations', async (framework) => {
// Add multiple files
await framework.addFile('file1.txt', 'Content 1');
await framework.addFile('file2.txt', 'Content 2');
await framework.addFile('file3.txt', 'Content 3');
// Wait for sync
await framework.waitForSync();
// Check that all files exist
const exists1 = await framework.shadowFileExists('file1.txt');
const exists2 = await framework.shadowFileExists('file2.txt');
const exists3 = await framework.shadowFileExists('file3.txt');
if (!exists1 || !exists2 || !exists3) {
throw new Error('Not all files were synced to shadow repository');
}
const gitStatus = await framework.getGitStatus();
if (!gitStatus.includes('file1.txt') ||
!gitStatus.includes('file2.txt') ||
!gitStatus.includes('file3.txt')) {
throw new Error(`All files should appear in git status: ${gitStatus}`);
}
});
// Test 5: Directory Operations
runner.addTest('Directory Operations', async (framework) => {
// Create directory with files
await framework.createDirectory('new-dir');
await framework.addFile('new-dir/nested-file.txt', 'Nested content');
const exists = await framework.shadowFileExists('new-dir/nested-file.txt');
if (!exists) {
throw new Error('Nested file was not synced');
}
const content = await framework.getShadowFileContent('new-dir/nested-file.txt');
if (content.trim() !== 'Nested content') {
throw new Error(`Nested file content mismatch: got "${content.trim()}"`);
}
});
// Test 6: File Rename/Move
runner.addTest('File Rename/Move', async (framework) => {
// Create and commit a file
await framework.addFile('original-name.txt', 'Content to move');
const { stdout } = await require('util').promisify(require('child_process').exec)(
`git -C ${framework.shadowPath} add -A && git -C ${framework.shadowPath} commit -m "Add file for move test"`
);
// Move the file
await framework.moveFile('original-name.txt', 'new-name.txt');
const originalExists = await framework.shadowFileExists('original-name.txt');
const newExists = await framework.shadowFileExists('new-name.txt');
if (originalExists) {
throw new Error('Original file still exists after move');
}
if (!newExists) {
throw new Error('New file does not exist after move');
}
const content = await framework.getShadowFileContent('new-name.txt');
if (content.trim() !== 'Content to move') {
throw new Error(`Moved file content mismatch: got "${content.trim()}"`);
}
});
// Test 7: Large File Handling
runner.addTest('Large File Handling', async (framework) => {
const largeContent = 'x'.repeat(10000); // 10KB file
// Use printf to avoid newline issues with echo
const containerPath = `/workspace/large-file.txt`;
await require('util').promisify(require('child_process').exec)(
`docker exec ${framework.containerId} bash -c "printf '${largeContent}' > ${containerPath}"`
);
await framework.waitForSync();
const exists = await framework.shadowFileExists('large-file.txt');
if (!exists) {
throw new Error('Large file was not synced');
}
const content = await framework.getShadowFileContent('large-file.txt');
if (content.length !== largeContent.length) {
throw new Error(`Large file size mismatch: expected ${largeContent.length}, got ${content.length}`);
}
});
// Test 8: Special Characters in Filenames
runner.addTest('Special Characters in Filenames', async (framework) => {
const specialFile = 'special-chars-test_file.txt';
await framework.addFile(specialFile, 'Special content');
const exists = await framework.shadowFileExists(specialFile);
if (!exists) {
throw new Error('File with special characters was not synced');
}
const content = await framework.getShadowFileContent(specialFile);
if (content.trim() !== 'Special content') {
throw new Error(`Special file content mismatch: got "${content.trim()}"`);
}
});
// Test 9: Rapid File Changes
runner.addTest('Rapid File Changes', async (framework) => {
// Create file
await framework.addFile('rapid-test.txt', 'Version 1');
// Wait for initial sync
await framework.waitForSync();
// Quickly modify it multiple times
await framework.modifyFile('rapid-test.txt', 'Version 2');
await new Promise(resolve => setTimeout(resolve, 200));
await framework.modifyFile('rapid-test.txt', 'Version 3');
await new Promise(resolve => setTimeout(resolve, 200));
await framework.modifyFile('rapid-test.txt', 'Final Version');
// Wait for final sync
await framework.waitForSync(null, 15000);
const content = await framework.getShadowFileContent('rapid-test.txt');
if (content.trim() !== 'Final Version') {
throw new Error(`Final content mismatch: got "${content.trim()}"`);
}
});
// Test 10: Web UI Sync Notifications
runner.addTest('Web UI Sync Notifications', async (framework) => {
const initialEventCount = framework.receivedSyncEvents.length;
await framework.addFile('notification-test.txt', 'Test notification');
// Check that we received sync events
await framework.waitForSync();
const newEventCount = framework.receivedSyncEvents.length;
if (newEventCount <= initialEventCount) {
throw new Error('No sync events received by web UI');
}
const latestEvent = framework.receivedSyncEvents[framework.receivedSyncEvents.length - 1];
if (!latestEvent.data.hasChanges) {
throw new Error('Sync event should indicate changes');
}
if (!latestEvent.data.summary.includes('Added:')) {
throw new Error(`Sync event should show addition: ${latestEvent.data.summary}`);
}
});
// Run all tests
runner.runAll().catch((error) => {
console.error('❌ Test runner failed:', error);
process.exit(1);
});