mirror of
https://github.com/textcortex/claude-code-sandbox.git
synced 2025-07-07 13:25:10 +00:00
Fix detection of deletions (#5)
* Fix detection of deletions * Checkpoint * Checkpoint * Refactor
This commit is contained in:
parent
312c366bd2
commit
56c120e7a8
21 changed files with 1586 additions and 35 deletions
28
.eslintrc.js
Normal file
28
.eslintrc.js
Normal 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
2
.gitignore
vendored
|
@ -144,3 +144,5 @@ dist
|
|||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
e2e-tests/dummy-repo/
|
3
TODO.md
3
TODO.md
|
@ -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
39
jest.config.js
Normal 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
93
package-lock.json
generated
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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} \
|
||||
|
|
|
@ -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
191
test/e2e/README.md
Normal 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.
|
159
test/e2e/core-functionality-test.js
Executable file
159
test/e2e/core-functionality-test.js
Executable 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);
|
||||
});
|
10
test/e2e/dummy-repo/README.md
Normal file
10
test/e2e/dummy-repo/README.md
Normal 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
|
3
test/e2e/dummy-repo/docs/example.md
Normal file
3
test/e2e/dummy-repo/docs/example.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# Example Documentation
|
||||
|
||||
This is an example documentation file to ensure the docs directory is included in the test repository.
|
13
test/e2e/dummy-repo/package.json
Normal file
13
test/e2e/dummy-repo/package.json
Normal 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"
|
||||
}
|
14
test/e2e/dummy-repo/src/main.js
Normal file
14
test/e2e/dummy-repo/src/main.js
Normal 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 };
|
20
test/e2e/dummy-repo/src/utils.js
Normal file
20
test/e2e/dummy-repo/src/utils.js
Normal 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
|
||||
};
|
24
test/e2e/dummy-repo/test/test.js
Normal file
24
test/e2e/dummy-repo/test/test.js
Normal 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();
|
191
test/e2e/repo-to-container-sync-test.js
Executable file
191
test/e2e/repo-to-container-sync-test.js
Executable 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
67
test/e2e/run-tests.sh
Executable 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!"
|
74
test/e2e/simple-deletion-test.js
Executable file
74
test/e2e/simple-deletion-test.js
Executable 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);
|
||||
});
|
314
test/e2e/sync-test-framework.js
Normal file
314
test/e2e/sync-test-framework.js
Normal 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
291
test/e2e/test-suite.js
Executable 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);
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue