diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b72aa2d53..2eada627e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,7 @@ jobs: outputs: cloud_agent: ${{ steps.filter.outputs.cloud_agent }} webhook_agent: ${{ steps.filter.outputs.webhook_agent }} + app_builder: ${{ steps.filter.outputs.app_builder }} steps: - uses: actions/checkout@v4 with: @@ -30,6 +31,8 @@ jobs: - 'cloud-agent/**' webhook_agent: - 'cloudflare-webhook-agent-ingest/**' + app_builder: + - 'cloudflare-app-builder/**' test: runs-on: ubuntu-24.04-8core @@ -250,6 +253,37 @@ jobs: - name: Run webhook-agent-ingest tests run: pnpm --filter cloudflare-webhook-agent-ingest test + app-builder: + needs: changes + if: needs.changes.outputs.app_builder == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + lfs: true + ref: ${{ github.head_ref }} + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: latest + run_install: false + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck (app-builder) + run: pnpm --filter app-builder typecheck + + - name: Run app-builder tests + run: pnpm --filter app-builder test + cloudflare: strategy: fail-fast: false @@ -259,8 +293,6 @@ jobs: filter: kilo-deploy-builder - name: deploy-dispatcher filter: deploy-dispatcher - - name: app-builder - filter: app-builder - name: code-review filter: kilo-code-review-worker name: cloudflare-${{ matrix.name }} diff --git a/cloudflare-app-builder/.pnpm-rebuild.txt b/cloudflare-app-builder/.pnpm-rebuild.txt new file mode 100644 index 000000000..ad3876596 --- /dev/null +++ b/cloudflare-app-builder/.pnpm-rebuild.txt @@ -0,0 +1 @@ +better-sqlite3 diff --git a/cloudflare-app-builder/package.json b/cloudflare-app-builder/package.json index 5521b86c6..0b0ad39a9 100644 --- a/cloudflare-app-builder/package.json +++ b/cloudflare-app-builder/package.json @@ -8,7 +8,9 @@ "deploy": "wrangler deploy", "cf-typegen": "wrangler types --env-interface CloudflareEnv worker-configuration.d.ts", "typecheck": "tsc --noEmit", - "test:git": "tsx src/_integration_tests/test-git-integration.ts" + "test": "vitest run", + "test:watch": "vitest", + "test:git:all": "./src/_integration_tests/run-all-tests.sh" }, "dependencies": { "@ashishkumar472/cf-git": "1.0.5", @@ -18,13 +20,16 @@ "zod": "^4.1.12" }, "devDependencies": { - "@types/jsonwebtoken": "^9.0.9", "@cloudflare/containers": "^0.0.30", "@cloudflare/sandbox": "0.6.7", "@cloudflare/workers-types": "^4.20251014.0", + "@types/better-sqlite3": "^7.6.13", + "@types/jsonwebtoken": "^9.0.9", "@types/node": "^22.10.1", + "better-sqlite3": "^12.6.0", "tsx": "^4.7.0", "typescript": "^5.9.3", + "vitest": "^2.1.9", "wrangler": "4.45.3" } } diff --git a/cloudflare-app-builder/src/_integration_tests/run-all-tests.sh b/cloudflare-app-builder/src/_integration_tests/run-all-tests.sh new file mode 100755 index 000000000..8db88f249 --- /dev/null +++ b/cloudflare-app-builder/src/_integration_tests/run-all-tests.sh @@ -0,0 +1,78 @@ +#!/bin/bash + +# Run all App Builder integration tests +# Usage: ./run-all-tests.sh +# +# Prerequisites: +# - App builder running at http://localhost:8790 +# - AUTH_TOKEN environment variable set (or uses default) + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/../.." + +# Default values +export APP_BUILDER_URL="${APP_BUILDER_URL:-http://localhost:8790}" +export AUTH_TOKEN="${AUTH_TOKEN:-dev-token-change-this-in-production}" + +echo "========================================" +echo "App Builder Integration Tests" +echo "========================================" +echo "URL: $APP_BUILDER_URL" +echo "Running from: $(pwd)" +echo "========================================" +echo "" + +TOTAL_PASSED=0 +TOTAL_FAILED=0 +FAILED_TESTS=() + +run_test() { + local test_name="$1" + local test_file="$2" + + echo "" + echo "========================================" + echo "Running: $test_name" + echo "========================================" + echo "" + + if pnpm exec tsx "$test_file"; then + TOTAL_PASSED=$((TOTAL_PASSED + 1)) + else + TOTAL_FAILED=$((TOTAL_FAILED + 1)) + FAILED_TESTS+=("$test_name") + fi +} + +# Run all test suites +run_test "Basic Git Integration" "src/_integration_tests/test-git-integration.ts" +run_test "Authentication Edge Cases" "src/_integration_tests/test-auth-edge-cases.ts" +run_test "Init Edge Cases" "src/_integration_tests/test-init-edge-cases.ts" +run_test "Files API" "src/_integration_tests/test-files-api.ts" +run_test "Delete Endpoint" "src/_integration_tests/test-delete.ts" +run_test "Advanced Git Operations" "src/_integration_tests/test-git-advanced.ts" + +# Final summary +echo "" +echo "========================================" +echo "FINAL SUMMARY" +echo "========================================" +echo "Test suites passed: $TOTAL_PASSED" +echo "Test suites failed: $TOTAL_FAILED" + +if [ $TOTAL_FAILED -gt 0 ]; then + echo "" + echo "Failed test suites:" + for test in "${FAILED_TESTS[@]}"; do + echo " - $test" + done + echo "" + echo "❌ SOME TEST SUITES FAILED" + exit 1 +else + echo "" + echo "πŸŽ‰ ALL TEST SUITES PASSED!" + exit 0 +fi diff --git a/cloudflare-app-builder/src/_integration_tests/test-auth-edge-cases.ts b/cloudflare-app-builder/src/_integration_tests/test-auth-edge-cases.ts new file mode 100644 index 000000000..10e5debe9 --- /dev/null +++ b/cloudflare-app-builder/src/_integration_tests/test-auth-edge-cases.ts @@ -0,0 +1,449 @@ +#!/usr/bin/env npx ts-node + +/** + * Integration Tests for Authentication Edge Cases + * + * Tests authentication failures and token validation scenarios. + * + * Prerequisites: + * - App builder running at http://localhost:8790 + * - Set AUTH_TOKEN environment variable + * + * Usage: + * cd cloudflare-app-builder + * AUTH_TOKEN=dev-token-change-this-in-production npx ts-node src/_integration_tests/test-auth-edge-cases.ts + */ + +import { execSync } from 'child_process'; +import { mkdtempSync, rmSync, mkdirSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +// --- Configuration --- +const APP_BUILDER_URL = process.env.APP_BUILDER_URL || 'http://localhost:8790'; +const AUTH_TOKEN = process.env.AUTH_TOKEN || 'dev-token-change-this-in-production'; + +// --- Types --- +type TokenPermission = 'full' | 'ro'; + +type TokenResponse = { + success: true; + token: string; + expires_at: string; + permission: TokenPermission; +}; + +type InitSuccessResponse = { + success: true; + app_id: string; + git_url: string; +}; + +type ErrorResponse = { + error: string; + message?: string; +}; + +// --- Helper Functions --- + +function log(message: string, data?: unknown) { + console.log(`[TEST] ${message}`, data ? JSON.stringify(data, null, 2) : ''); +} + +function logError(message: string, error?: unknown) { + console.error(`[ERROR] ${message}`, error); +} + +function logSuccess(message: string) { + console.log(`[βœ“] ${message}`); +} + +function logFailure(message: string) { + console.error(`[βœ—] ${message}`); +} + +async function initProject(projectId: string): Promise { + const endpoint = `${APP_BUILDER_URL}/apps/${encodeURIComponent(projectId)}/init`; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new Error(`Failed to init project: ${response.status} - ${errorText}`); + } + + return response.json(); +} + +async function generateGitToken( + appId: string, + permission: TokenPermission +): Promise { + const endpoint = `${APP_BUILDER_URL}/apps/${encodeURIComponent(appId)}/token`; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + body: JSON.stringify({ permission }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new Error(`Failed to generate token: ${response.status} - ${errorText}`); + } + + return response.json(); +} + +function buildGitUrlWithToken(gitUrl: string, token: string, username = 'x-access-token'): string { + const url = new URL(gitUrl); + url.username = username; + url.password = token; + return url.toString(); +} + +function runGitCommand(dir: string, command: string): { success: boolean; output: string } { + const fullCommand = `cd "${dir}" && ${command} 2>&1`; + + try { + const output = execSync(fullCommand, { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + return { success: true, output }; + } catch (error: unknown) { + const execError = error as { stderr?: string; stdout?: string; message?: string }; + return { + success: false, + output: execError.stderr || execError.stdout || execError.message || 'Command failed', + }; + } +} + +async function deleteProject(projectId: string): Promise { + const endpoint = `${APP_BUILDER_URL}/apps/${encodeURIComponent(projectId)}`; + + await fetch(endpoint, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + }); +} + +// --- Test Cases --- + +async function testMissingAuthHeader() { + log('\n=== Test: Missing Authorization Header ==='); + + // Use a valid-length app ID (20+ characters) so route matches + const testAppId = 'test-missing-auth-header-check'; + const response = await fetch(`${APP_BUILDER_URL}/apps/${testAppId}/init`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (response.status === 401) { + const body = (await response.json()) as ErrorResponse; + if (body.error === 'authentication_required') { + logSuccess('Missing auth header returns 401 with authentication_required error'); + return true; + } + } + + logFailure(`Expected 401, got ${response.status}`); + return false; +} + +async function testInvalidBearerToken() { + log('\n=== Test: Invalid Bearer Token ==='); + + // Use a valid-length app ID (20+ characters) so route matches + const testAppId = 'test-invalid-bearer-token-check'; + const response = await fetch(`${APP_BUILDER_URL}/apps/${testAppId}/init`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer invalid-token-12345', + }, + }); + + if (response.status === 401) { + const body = (await response.json()) as ErrorResponse; + if (body.error === 'invalid_token') { + logSuccess('Invalid bearer token returns 401 with invalid_token error'); + return true; + } + } + + logFailure(`Expected 401, got ${response.status}`); + return false; +} + +async function testMalformedBearerToken() { + log('\n=== Test: Malformed Bearer Header ==='); + + // Use a valid-length app ID (20+ characters) so route matches + const testAppId = 'test-malformed-bearer-header'; + const response = await fetch(`${APP_BUILDER_URL}/apps/${testAppId}/init`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'NotBearer some-token', + }, + }); + + if (response.status === 401) { + logSuccess('Malformed bearer header returns 401'); + return true; + } + + logFailure(`Expected 401, got ${response.status}`); + return false; +} + +async function testTokenForWrongRepository(tempDir: string) { + log('\n=== Test: Token for Wrong Repository ==='); + + const testId1 = `test-auth-repo1-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const testId2 = `test-auth-repo2-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + try { + // Initialize two separate projects + const initResult1 = await initProject(testId1); + const initResult2 = await initProject(testId2); + + // Get token for repo1 + const token1 = await generateGitToken(testId1, 'full'); + + // Try to use token1 to access repo2 + const cloneDir = join(tempDir, 'wrong-repo-clone'); + mkdirSync(cloneDir, { recursive: true }); + + const wrongUrl = buildGitUrlWithToken(initResult2.git_url, token1.token); + const result = runGitCommand(tempDir, `git clone "${wrongUrl}" wrong-repo-clone`); + + if ( + !result.success && + (result.output.includes('401') || result.output.includes('Unauthorized')) + ) { + logSuccess('Token for wrong repository is rejected'); + return true; + } + + logFailure('Expected clone with wrong repo token to fail'); + return false; + } finally { + // Cleanup + await deleteProject(testId1); + await deleteProject(testId2); + } +} + +async function testInvalidGitUsername(tempDir: string) { + log('\n=== Test: Invalid Git Username (not x-access-token) ==='); + + const testId = `test-auth-username-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + try { + const initResult = await initProject(testId); + const tokenResult = await generateGitToken(testId, 'full'); + + // Use wrong username instead of "x-access-token" + const cloneDir = join(tempDir, 'wrong-username-clone'); + mkdirSync(cloneDir, { recursive: true }); + + const wrongUsernameUrl = buildGitUrlWithToken( + initResult.git_url, + tokenResult.token, + 'wrong-username' + ); + const result = runGitCommand(tempDir, `git clone "${wrongUsernameUrl}" wrong-username-clone`); + + if ( + !result.success && + (result.output.includes('401') || result.output.includes('Invalid credentials')) + ) { + logSuccess('Invalid git username is rejected'); + return true; + } + + logFailure('Expected clone with wrong username to fail'); + return false; + } finally { + await deleteProject(testId); + } +} + +async function testTokenForNonExistentRepo() { + log('\n=== Test: Generate Token for Non-Existent Repository ==='); + + const nonExistentId = `non-existent-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const endpoint = `${APP_BUILDER_URL}/apps/${encodeURIComponent(nonExistentId)}/token`; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + body: JSON.stringify({ permission: 'full' }), + }); + + if (response.status === 404) { + const body = (await response.json()) as ErrorResponse; + if (body.error === 'not_found') { + logSuccess('Token generation for non-existent repo returns 404'); + return true; + } + } + + logFailure(`Expected 404, got ${response.status}`); + return false; +} + +async function testInvalidTokenPermission() { + log('\n=== Test: Invalid Token Permission Value ==='); + + const testId = `test-auth-perm-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + try { + await initProject(testId); + + const endpoint = `${APP_BUILDER_URL}/apps/${encodeURIComponent(testId)}/token`; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + body: JSON.stringify({ permission: 'invalid-permission' }), + }); + + if (response.status === 400) { + const body = (await response.json()) as ErrorResponse; + if (body.error === 'invalid_parameter') { + logSuccess('Invalid permission value returns 400'); + return true; + } + } + + logFailure(`Expected 400, got ${response.status}`); + return false; + } finally { + await deleteProject(testId); + } +} + +async function testCloneNonExistentRepository(tempDir: string) { + log('\n=== Test: Clone Non-Existent Repository ==='); + + // Generate a valid-looking but non-existent app ID + const nonExistentId = `non-existent-repo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const gitUrl = `${APP_BUILDER_URL}/apps/${nonExistentId}.git`; + + // We need some token to attempt the clone (the git protocol will validate) + const cloneDir = join(tempDir, 'non-existent-clone'); + mkdirSync(cloneDir, { recursive: true }); + + // Use x-access-token with a dummy token + const url = new URL(gitUrl); + url.username = 'x-access-token'; + url.password = 'dummy-token'; + + const result = runGitCommand(tempDir, `git clone "${url.toString()}" non-existent-clone`); + + // Git reports authentication failures as "Authentication failed" or the server's error message + if ( + !result.success && + (result.output.includes('404') || + result.output.includes('not found') || + result.output.includes('401') || + result.output.includes('Unauthorized') || + result.output.includes('Authentication failed')) + ) { + logSuccess('Clone of non-existent repository fails appropriately'); + return true; + } + + logFailure('Expected clone of non-existent repo to fail'); + return false; +} + +// --- Main Test Runner --- + +async function runTests() { + const tempDir = mkdtempSync(join(tmpdir(), 'app-builder-auth-test-')); + let passed = 0; + let failed = 0; + + log('Starting authentication edge case tests', { + tempDir, + appBuilderUrl: APP_BUILDER_URL, + }); + + try { + const tests = [ + () => testMissingAuthHeader(), + () => testInvalidBearerToken(), + () => testMalformedBearerToken(), + () => testTokenForWrongRepository(tempDir), + () => testInvalidGitUsername(tempDir), + () => testTokenForNonExistentRepo(), + () => testInvalidTokenPermission(), + () => testCloneNonExistentRepository(tempDir), + ]; + + for (const test of tests) { + try { + const result = await test(); + if (result) { + passed++; + } else { + failed++; + } + } catch (error) { + logError('Test threw an exception', error); + failed++; + } + } + + console.log('\n' + '='.repeat(50)); + if (failed === 0) { + console.log(`πŸŽ‰ ALL TESTS PASSED! (${passed}/${passed + failed})`); + } else { + console.log(`❌ SOME TESTS FAILED (${passed} passed, ${failed} failed)`); + } + console.log('='.repeat(50)); + + if (failed > 0) { + process.exit(1); + } + } finally { + log('\nCleaning up temp directory...'); + try { + rmSync(tempDir, { recursive: true, force: true }); + log('Cleanup complete'); + } catch (e) { + logError('Failed to cleanup temp directory', e); + } + } +} + +runTests().catch(error => { + logError('Unhandled error', error); + process.exit(1); +}); diff --git a/cloudflare-app-builder/src/_integration_tests/test-delete.ts b/cloudflare-app-builder/src/_integration_tests/test-delete.ts new file mode 100644 index 000000000..94270cd8d --- /dev/null +++ b/cloudflare-app-builder/src/_integration_tests/test-delete.ts @@ -0,0 +1,523 @@ +#!/usr/bin/env npx ts-node + +/** + * Integration Tests for Delete Endpoint + * + * Tests project deletion and verification that operations fail afterwards. + * + * Prerequisites: + * - App builder running at http://localhost:8790 + * - Set AUTH_TOKEN environment variable + * + * Usage: + * cd cloudflare-app-builder + * AUTH_TOKEN=dev-token-change-this-in-production npx ts-node src/_integration_tests/test-delete.ts + */ + +import { execSync } from 'child_process'; +import { mkdtempSync, rmSync, mkdirSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +// --- Configuration --- +const APP_BUILDER_URL = process.env.APP_BUILDER_URL || 'http://localhost:8790'; +const AUTH_TOKEN = process.env.AUTH_TOKEN || 'dev-token-change-this-in-production'; + +// --- Types --- +type TokenResponse = { + success: true; + token: string; + expires_at: string; + permission: 'full' | 'ro'; +}; + +type InitSuccessResponse = { + success: true; + app_id: string; + git_url: string; +}; + +type DeleteResponse = { + success?: boolean; + error?: string; + message?: string; +}; + +type InitResponse = { + success: true; + app_id: string; + git_url: string; +}; + +type ErrorResponse = { + error: string; + message?: string; +}; + +// --- Helper Functions --- + +function log(message: string, data?: unknown) { + console.log(`[TEST] ${message}`, data ? JSON.stringify(data, null, 2) : ''); +} + +function logError(message: string, error?: unknown) { + console.error(`[ERROR] ${message}`, error); +} + +function logSuccess(message: string) { + console.log(`[βœ“] ${message}`); +} + +function logFailure(message: string) { + console.error(`[βœ—] ${message}`); +} + +async function initProject(projectId: string): Promise { + const endpoint = `${APP_BUILDER_URL}/apps/${encodeURIComponent(projectId)}/init`; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new Error(`Failed to init project: ${response.status} - ${errorText}`); + } + + return response.json(); +} + +async function generateGitToken(appId: string, permission: 'full' | 'ro'): Promise { + const endpoint = `${APP_BUILDER_URL}/apps/${encodeURIComponent(appId)}/token`; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + body: JSON.stringify({ permission }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new Error(`Failed to generate token: ${response.status} - ${errorText}`); + } + + return response.json(); +} + +async function deleteProjectRaw( + projectId: string +): Promise<{ status: number; body: DeleteResponse }> { + const endpoint = `${APP_BUILDER_URL}/apps/${encodeURIComponent(projectId)}`; + + const response = await fetch(endpoint, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + }); + + const body = (await response.json()) as DeleteResponse; + return { status: response.status, body }; +} + +function buildGitUrlWithToken(gitUrl: string, token: string): string { + const url = new URL(gitUrl); + url.username = 'x-access-token'; + url.password = token; + return url.toString(); +} + +function runGitCommand(dir: string, command: string): { success: boolean; output: string } { + const fullCommand = `cd "${dir}" && ${command} 2>&1`; + + try { + const output = execSync(fullCommand, { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + return { success: true, output }; + } catch (error: unknown) { + const execError = error as { stderr?: string; stdout?: string; message?: string }; + return { + success: false, + output: execError.stderr || execError.stdout || execError.message || 'Command failed', + }; + } +} + +// --- Test Cases --- + +async function testBasicDelete() { + log('\n=== Test: Basic Project Deletion ==='); + + const testId = `test-delete-basic-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + // Initialize project + await initProject(testId); + + // Delete project + const result = await deleteProjectRaw(testId); + + if (result.status === 200 && result.body.success === true) { + logSuccess('Delete returns 200 with success: true'); + return true; + } + + logFailure(`Expected 200 with success, got ${result.status} - ${JSON.stringify(result.body)}`); + return false; +} + +async function testDeleteWithoutAuth() { + log('\n=== Test: Delete Without Authorization ==='); + + const testId = `test-delete-noauth-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + try { + await initProject(testId); + + const endpoint = `${APP_BUILDER_URL}/apps/${encodeURIComponent(testId)}`; + + const response = await fetch(endpoint, { + method: 'DELETE', + // No Authorization header + }); + + if (response.status === 401) { + logSuccess('Delete without auth returns 401'); + return true; + } + + logFailure(`Expected 401, got ${response.status}`); + return false; + } finally { + // Cleanup - delete with auth + await deleteProjectRaw(testId); + } +} + +async function testDeleteWithInvalidAuth() { + log('\n=== Test: Delete With Invalid Auth Token ==='); + + const testId = `test-delete-badauth-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + try { + await initProject(testId); + + const endpoint = `${APP_BUILDER_URL}/apps/${encodeURIComponent(testId)}`; + + const response = await fetch(endpoint, { + method: 'DELETE', + headers: { + Authorization: 'Bearer invalid-token-xyz', + }, + }); + + if (response.status === 401) { + logSuccess('Delete with invalid auth returns 401'); + return true; + } + + logFailure(`Expected 401, got ${response.status}`); + return false; + } finally { + await deleteProjectRaw(testId); + } +} + +async function testCloneFailsAfterDelete(tempDir: string) { + log('\n=== Test: Clone Fails After Delete ==='); + + const testId = `test-delete-clone-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + // Initialize and get token + const initResult = await initProject(testId); + const tokenResult = await generateGitToken(testId, 'full'); + const gitUrl = buildGitUrlWithToken(initResult.git_url, tokenResult.token); + + // Verify clone works before delete + const cloneDir1 = join(tempDir, 'clone-before-delete'); + mkdirSync(cloneDir1, { recursive: true }); + + const beforeResult = runGitCommand(tempDir, `git clone "${gitUrl}" clone-before-delete`); + if (!beforeResult.success) { + logFailure('Clone failed even before delete'); + await deleteProjectRaw(testId); + return false; + } + + // Delete the project + await deleteProjectRaw(testId); + + // Try to clone again - should fail + const cloneDir2 = join(tempDir, 'clone-after-delete'); + mkdirSync(cloneDir2, { recursive: true }); + + const afterResult = runGitCommand(tempDir, `git clone "${gitUrl}" clone-after-delete`); + + // Git reports authentication failures as "Authentication failed" or the server's error message + if ( + !afterResult.success && + (afterResult.output.includes('404') || + afterResult.output.includes('not found') || + afterResult.output.includes('401') || + afterResult.output.includes('Unauthorized') || + afterResult.output.includes('Authentication failed')) + ) { + logSuccess('Clone fails after project deletion'); + return true; + } + + logFailure('Clone should fail after deletion but it succeeded or failed with unexpected error'); + return false; +} + +async function testTokenGenerationFailsAfterDelete() { + log('\n=== Test: Token Generation Fails After Delete ==='); + + const testId = `test-delete-token-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + // Initialize project + await initProject(testId); + + // Verify token generation works + const token1 = await generateGitToken(testId, 'full').catch(() => null); + if (!token1) { + logFailure('Token generation failed even before delete'); + await deleteProjectRaw(testId); + return false; + } + + // Delete project + await deleteProjectRaw(testId); + + // Try to generate token - should fail with 404 + const endpoint = `${APP_BUILDER_URL}/apps/${encodeURIComponent(testId)}/token`; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + body: JSON.stringify({ permission: 'full' }), + }); + + if (response.status === 404) { + const body = (await response.json()) as ErrorResponse; + if (body.error === 'not_found') { + logSuccess('Token generation returns 404 after project deletion'); + return true; + } + } + + logFailure(`Expected 404 not_found, got ${response.status}`); + return false; +} + +async function testFilesApiFailsAfterDelete() { + log('\n=== Test: Files API Fails After Delete ==='); + + const testId = `test-delete-files-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + // Initialize project + await initProject(testId); + + // Verify tree works before delete + const treeBefore = await fetch( + `${APP_BUILDER_URL}/apps/${encodeURIComponent(testId)}/tree/HEAD`, + { + headers: { Authorization: `Bearer ${AUTH_TOKEN}` }, + } + ); + + if (treeBefore.status !== 200) { + logFailure('Tree endpoint failed even before delete'); + await deleteProjectRaw(testId); + return false; + } + + // Delete project + await deleteProjectRaw(testId); + + // Try tree endpoint - should fail + const treeAfter = await fetch(`${APP_BUILDER_URL}/apps/${encodeURIComponent(testId)}/tree/HEAD`, { + headers: { Authorization: `Bearer ${AUTH_TOKEN}` }, + }); + + if (treeAfter.status === 404) { + logSuccess('Tree endpoint returns 404 after project deletion'); + return true; + } + + logFailure(`Expected 404, got ${treeAfter.status}`); + return false; +} + +async function testReInitAfterDelete() { + log('\n=== Test: Re-Initialize After Delete ==='); + + const testId = `test-delete-reinit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + // Initialize, delete, then re-initialize + await initProject(testId); + await deleteProjectRaw(testId); + + // Re-initialize should work + const endpoint = `${APP_BUILDER_URL}/apps/${encodeURIComponent(testId)}/init`; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + }); + + if (response.status === 201) { + const body = (await response.json()) as InitResponse; + if (body.success === true && body.app_id === testId) { + logSuccess('Re-initialization after delete succeeds with 201'); + await deleteProjectRaw(testId); // Cleanup + return true; + } + } + + logFailure(`Expected 201 on re-init, got ${response.status}`); + await deleteProjectRaw(testId); // Cleanup attempt + return false; +} + +async function testDeleteNonExistentProject() { + log('\n=== Test: Delete Non-Existent Project ==='); + + const nonExistentId = `non-existent-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const result = await deleteProjectRaw(nonExistentId); + + // Deleting non-existent project should either succeed (idempotent) or return 404 + // The current implementation appears to return 200 (idempotent delete) + if (result.status === 200 || result.status === 404) { + logSuccess(`Delete non-existent project returns ${result.status} (acceptable)`); + return true; + } + + logFailure(`Unexpected status ${result.status} for delete of non-existent project`); + return false; +} + +async function testPushFailsAfterDelete(tempDir: string) { + log('\n=== Test: Push Fails After Delete ==='); + + const testId = `test-delete-push-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + // Initialize, clone, and prepare for push + const initResult = await initProject(testId); + const tokenResult = await generateGitToken(testId, 'full'); + const gitUrl = buildGitUrlWithToken(initResult.git_url, tokenResult.token); + + const cloneDir = join(tempDir, 'clone-for-push'); + mkdirSync(cloneDir, { recursive: true }); + + runGitCommand(tempDir, `git clone "${gitUrl}" clone-for-push`); + runGitCommand(cloneDir, 'git config user.email "test@example.com"'); + runGitCommand(cloneDir, 'git config user.name "Test User"'); + runGitCommand(cloneDir, 'echo "test" > new-file.txt'); + runGitCommand(cloneDir, 'git add new-file.txt'); + runGitCommand(cloneDir, 'git commit -m "Test commit"'); + + // Delete the project + await deleteProjectRaw(testId); + + // Try to push - should fail + const pushResult = runGitCommand(cloneDir, 'git push origin main'); + + // Git reports authentication failures as "Authentication failed" or the server's error message + if ( + !pushResult.success && + (pushResult.output.includes('404') || + pushResult.output.includes('401') || + pushResult.output.includes('rejected') || + pushResult.output.includes('not found') || + pushResult.output.includes('Unauthorized') || + pushResult.output.includes('Authentication failed')) + ) { + logSuccess('Push fails after project deletion'); + return true; + } + + logFailure('Push should fail after deletion'); + return false; +} + +// --- Main Test Runner --- + +async function runTests() { + const tempDir = mkdtempSync(join(tmpdir(), 'app-builder-delete-test-')); + let passed = 0; + let failed = 0; + + log('Starting delete endpoint tests', { + tempDir, + appBuilderUrl: APP_BUILDER_URL, + }); + + try { + const tests: Array<() => Promise> = [ + testBasicDelete, + testDeleteWithoutAuth, + testDeleteWithInvalidAuth, + () => testCloneFailsAfterDelete(tempDir), + testTokenGenerationFailsAfterDelete, + testFilesApiFailsAfterDelete, + testReInitAfterDelete, + testDeleteNonExistentProject, + () => testPushFailsAfterDelete(tempDir), + ]; + + for (const test of tests) { + try { + const result = await test(); + if (result) { + passed++; + } else { + failed++; + } + } catch (error) { + logError('Test threw an exception', error); + failed++; + } + } + + console.log('\n' + '='.repeat(50)); + if (failed === 0) { + console.log(`πŸŽ‰ ALL TESTS PASSED! (${passed}/${passed + failed})`); + } else { + console.log(`❌ SOME TESTS FAILED (${passed} passed, ${failed} failed)`); + } + console.log('='.repeat(50)); + + if (failed > 0) { + process.exit(1); + } + } finally { + log('\nCleaning up temp directory...'); + try { + rmSync(tempDir, { recursive: true, force: true }); + log('Cleanup complete'); + } catch (e) { + logError('Failed to cleanup temp directory', e); + } + } +} + +runTests().catch(error => { + logError('Unhandled error', error); + process.exit(1); +}); diff --git a/cloudflare-app-builder/src/_integration_tests/test-files-api.ts b/cloudflare-app-builder/src/_integration_tests/test-files-api.ts new file mode 100644 index 000000000..87b08fc28 --- /dev/null +++ b/cloudflare-app-builder/src/_integration_tests/test-files-api.ts @@ -0,0 +1,569 @@ +#!/usr/bin/env npx ts-node + +/** + * Integration Tests for Files API (Tree & Blob Endpoints) + * + * Tests the file browsing API endpoints: + * - GET /apps/{id}/tree/{ref} - List directory contents + * - GET /apps/{id}/blob/{ref}/{path} - Get file content + * + * Prerequisites: + * - App builder running at http://localhost:8790 + * - Set AUTH_TOKEN environment variable + * + * Usage: + * cd cloudflare-app-builder + * AUTH_TOKEN=dev-token-change-this-in-production npx ts-node src/_integration_tests/test-files-api.ts + */ + +import { execSync } from 'child_process'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +// --- Configuration --- +const APP_BUILDER_URL = process.env.APP_BUILDER_URL || 'http://localhost:8790'; +const AUTH_TOKEN = process.env.AUTH_TOKEN || 'dev-token-change-this-in-production'; + +// --- Types --- +type TreeEntry = { + name: string; + type: 'blob' | 'tree'; + oid: string; + mode: string; +}; + +type GetTreeResponse = { + entries: TreeEntry[]; + path: string; + ref: string; + commitSha: string; +}; + +type GetBlobResponse = { + content: string; + encoding: 'utf-8' | 'base64'; + size: number; + path: string; + sha: string; +}; + +type TokenResponse = { + success: true; + token: string; + expires_at: string; + permission: 'full' | 'ro'; +}; + +type InitSuccessResponse = { + success: true; + app_id: string; + git_url: string; +}; + +// --- Helper Functions --- + +function log(message: string, data?: unknown) { + console.log(`[TEST] ${message}`, data ? JSON.stringify(data, null, 2) : ''); +} + +function logError(message: string, error?: unknown) { + console.error(`[ERROR] ${message}`, error); +} + +function logSuccess(message: string) { + console.log(`[βœ“] ${message}`); +} + +function logFailure(message: string) { + console.error(`[βœ—] ${message}`); +} + +async function initProject(projectId: string): Promise { + const endpoint = `${APP_BUILDER_URL}/apps/${encodeURIComponent(projectId)}/init`; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new Error(`Failed to init project: ${response.status} - ${errorText}`); + } + + return response.json(); +} + +async function generateGitToken(appId: string, permission: 'full' | 'ro'): Promise { + const endpoint = `${APP_BUILDER_URL}/apps/${encodeURIComponent(appId)}/token`; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + body: JSON.stringify({ permission }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new Error(`Failed to generate token: ${response.status} - ${errorText}`); + } + + return response.json(); +} + +async function deleteProject(projectId: string): Promise { + const endpoint = `${APP_BUILDER_URL}/apps/${encodeURIComponent(projectId)}`; + + await fetch(endpoint, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + }); +} + +function buildGitUrlWithToken(gitUrl: string, token: string): string { + const url = new URL(gitUrl); + url.username = 'x-access-token'; + url.password = token; + return url.toString(); +} + +function runGitCommand(dir: string, command: string): string { + const fullCommand = `cd "${dir}" && ${command}`; + return execSync(fullCommand, { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); +} + +async function getTree( + appId: string, + ref: string, + path?: string +): Promise<{ status: number; body: GetTreeResponse | { error: string; message: string } }> { + let endpoint = `${APP_BUILDER_URL}/apps/${encodeURIComponent(appId)}/tree/${encodeURIComponent(ref)}`; + if (path) { + endpoint += `?path=${encodeURIComponent(path)}`; + } + + const response = await fetch(endpoint, { + headers: { + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + }); + + return { status: response.status, body: await response.json() }; +} + +async function getBlob( + appId: string, + ref: string, + path: string +): Promise<{ status: number; body: GetBlobResponse | { error: string; message: string } }> { + const endpoint = `${APP_BUILDER_URL}/apps/${encodeURIComponent(appId)}/blob/${encodeURIComponent(ref)}/${path}`; + + const response = await fetch(endpoint, { + headers: { + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + }); + + return { status: response.status, body: await response.json() }; +} + +// --- Test Cases --- + +async function testGetTreeAtHead(testId: string) { + log('\n=== Test: Get Tree at HEAD ==='); + + const result = await getTree(testId, 'HEAD'); + + if (result.status === 200) { + const body = result.body as GetTreeResponse; + if (body.entries && Array.isArray(body.entries) && body.ref === 'HEAD' && body.commitSha) { + logSuccess( + `Get tree at HEAD returns entries (${body.entries.length} items), ref, and commitSha` + ); + return true; + } + } + + logFailure( + `Expected 200 with tree entries, got ${result.status} - ${JSON.stringify(result.body)}` + ); + return false; +} + +async function testGetTreeAtMain(testId: string) { + log('\n=== Test: Get Tree at main Branch ==='); + + const result = await getTree(testId, 'main'); + + if (result.status === 200) { + const body = result.body as GetTreeResponse; + if (body.entries && body.ref === 'main') { + logSuccess('Get tree at main branch succeeds'); + return true; + } + } + + logFailure(`Expected 200, got ${result.status}`); + return false; +} + +async function testGetTreeWithSubdirectoryPath(testId: string) { + log('\n=== Test: Get Tree with Subdirectory Path ==='); + + // First, get root tree to find a subdirectory + const rootResult = await getTree(testId, 'HEAD'); + + if (rootResult.status !== 200) { + logFailure('Could not get root tree'); + return false; + } + + const rootBody = rootResult.body as GetTreeResponse; + const subdir = rootBody.entries.find(e => e.type === 'tree'); + + if (!subdir) { + log('No subdirectory found in template, skipping subdir test'); + logSuccess('(Skipped - no subdirectories in template)'); + return true; + } + + const subdirResult = await getTree(testId, 'HEAD', subdir.name); + + if (subdirResult.status === 200) { + const body = subdirResult.body as GetTreeResponse; + if (body.path === subdir.name) { + logSuccess(`Get tree for subdirectory "${subdir.name}" succeeds`); + return true; + } + } + + logFailure(`Expected 200 for subdir, got ${subdirResult.status}`); + return false; +} + +async function testGetTreeForNonExistentRef(testId: string) { + log('\n=== Test: Get Tree for Non-Existent Ref ==='); + + const result = await getTree(testId, 'non-existent-branch-xyz'); + + if (result.status === 404) { + logSuccess('Get tree for non-existent ref returns 404'); + return true; + } + + logFailure(`Expected 404, got ${result.status}`); + return false; +} + +async function testGetTreeForNonExistentPath(testId: string) { + log('\n=== Test: Get Tree for Non-Existent Path ==='); + + const result = await getTree(testId, 'HEAD', 'non/existent/path'); + + if (result.status === 404) { + logSuccess('Get tree for non-existent path returns 404'); + return true; + } + + logFailure(`Expected 404, got ${result.status}`); + return false; +} + +async function testGetBlobForTextFile(testId: string) { + log('\n=== Test: Get Blob for Text File ==='); + + // First, find a text file in the tree + const treeResult = await getTree(testId, 'HEAD'); + + if (treeResult.status !== 200) { + logFailure('Could not get tree'); + return false; + } + + const body = treeResult.body as GetTreeResponse; + const textFile = body.entries.find( + e => + e.type === 'blob' && + (e.name.endsWith('.json') || + e.name.endsWith('.ts') || + e.name.endsWith('.js') || + e.name.endsWith('.md')) + ); + + if (!textFile) { + log('No text file found in root, looking for package.json'); + // Most node templates have package.json + const blobResult = await getBlob(testId, 'HEAD', 'package.json'); + if (blobResult.status === 200) { + const blobBody = blobResult.body as GetBlobResponse; + if (blobBody.encoding === 'utf-8' && blobBody.content && blobBody.size > 0) { + logSuccess('Get blob for package.json returns utf-8 encoded content'); + return true; + } + } + logFailure('Could not find or read any text file'); + return false; + } + + const blobResult = await getBlob(testId, 'HEAD', textFile.name); + + if (blobResult.status === 200) { + const blobBody = blobResult.body as GetBlobResponse; + if ( + blobBody.encoding === 'utf-8' && + blobBody.content && + blobBody.size > 0 && + blobBody.path === textFile.name + ) { + logSuccess(`Get blob for "${textFile.name}" returns utf-8 content`); + return true; + } + } + + logFailure(`Expected 200 with blob content, got ${blobResult.status}`); + return false; +} + +async function testGetBlobForNonExistentFile(testId: string) { + log('\n=== Test: Get Blob for Non-Existent File ==='); + + const result = await getBlob(testId, 'HEAD', 'non-existent-file.xyz'); + + if (result.status === 404) { + logSuccess('Get blob for non-existent file returns 404'); + return true; + } + + logFailure(`Expected 404, got ${result.status}`); + return false; +} + +async function testGetBlobAtSpecificCommit(testId: string, tempDir: string) { + log('\n=== Test: Get Blob at Specific Commit SHA ==='); + + // Clone, add file, push, get commit SHA + const tokenResult = await generateGitToken(testId, 'full'); + const initResult = await initProject(`${testId}-commit`).catch(() => null); + + if (!initResult) { + // Project already exists from earlier init, get the git_url differently + logFailure('Could not get project for commit test'); + return false; + } + + try { + const cloneDir = join(tempDir, 'commit-test-clone'); + mkdirSync(cloneDir, { recursive: true }); + + const tokenResult2 = await generateGitToken(`${testId}-commit`, 'full'); + const gitUrl = buildGitUrlWithToken(initResult.git_url, tokenResult2.token); + + runGitCommand(tempDir, `git clone "${gitUrl}" commit-test-clone`); + + // Get the initial commit SHA + const sha = runGitCommand(cloneDir, 'git rev-parse HEAD').trim(); + log(`Initial commit SHA: ${sha}`); + + // Get tree at that specific SHA + const result = await getTree(`${testId}-commit`, sha); + + if (result.status === 200) { + const body = result.body as GetTreeResponse; + if (body.commitSha === sha) { + logSuccess('Get tree at specific commit SHA succeeds'); + return true; + } + } + + logFailure(`Expected 200 with matching SHA, got ${result.status}`); + return false; + } finally { + await deleteProject(`${testId}-commit`); + } +} + +async function testFilesApiWithoutAuth(testId: string) { + log('\n=== Test: Files API Without Authorization ==='); + + const endpoint = `${APP_BUILDER_URL}/apps/${encodeURIComponent(testId)}/tree/HEAD`; + + const response = await fetch(endpoint, { + // No Authorization header + }); + + if (response.status === 401) { + logSuccess('Files API without auth returns 401'); + return true; + } + + logFailure(`Expected 401, got ${response.status}`); + return false; +} + +async function testTreeEntryTypes(testId: string) { + log('\n=== Test: Tree Entry Types (blob vs tree) ==='); + + const result = await getTree(testId, 'HEAD'); + + if (result.status !== 200) { + logFailure('Could not get tree'); + return false; + } + + const body = result.body as GetTreeResponse; + + // Verify entries have correct type values + const validTypes = body.entries.every(e => e.type === 'blob' || e.type === 'tree'); + const hasRequiredFields = body.entries.every(e => e.name && e.oid && e.mode); + + if (validTypes && hasRequiredFields) { + const blobs = body.entries.filter(e => e.type === 'blob').length; + const trees = body.entries.filter(e => e.type === 'tree').length; + logSuccess(`Tree entries have correct types (${blobs} blobs, ${trees} trees)`); + return true; + } + + logFailure('Tree entries have invalid or missing fields'); + return false; +} + +async function testGetBlobNestedPath(testId: string) { + log('\n=== Test: Get Blob with Nested Path ==='); + + // Find a file in a subdirectory + const rootResult = await getTree(testId, 'HEAD'); + + if (rootResult.status !== 200) { + logFailure('Could not get root tree'); + return false; + } + + const rootBody = rootResult.body as GetTreeResponse; + const subdir = rootBody.entries.find(e => e.type === 'tree'); + + if (!subdir) { + log('No subdirectory found, skipping nested path test'); + logSuccess('(Skipped - no subdirectories in template)'); + return true; + } + + // Get contents of subdirectory + const subdirResult = await getTree(testId, 'HEAD', subdir.name); + + if (subdirResult.status !== 200) { + logFailure('Could not get subdirectory tree'); + return false; + } + + const subdirBody = subdirResult.body as GetTreeResponse; + const nestedFile = subdirBody.entries.find(e => e.type === 'blob'); + + if (!nestedFile) { + log('No file found in subdirectory, skipping'); + logSuccess('(Skipped - no files in subdirectory)'); + return true; + } + + const nestedPath = `${subdir.name}/${nestedFile.name}`; + const blobResult = await getBlob(testId, 'HEAD', nestedPath); + + if (blobResult.status === 200) { + const blobBody = blobResult.body as GetBlobResponse; + if (blobBody.path === nestedPath) { + logSuccess(`Get blob for nested path "${nestedPath}" succeeds`); + return true; + } + } + + logFailure(`Expected 200 for nested blob, got ${blobResult.status}`); + return false; +} + +// --- Main Test Runner --- + +async function runTests() { + const testId = `test-files-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const tempDir = mkdtempSync(join(tmpdir(), 'app-builder-files-test-')); + let passed = 0; + let failed = 0; + + log('Starting files API tests', { + testId, + tempDir, + appBuilderUrl: APP_BUILDER_URL, + }); + + try { + // Initialize a project for testing + log('Initializing test project...'); + await initProject(testId); + logSuccess(`Test project initialized: ${testId}`); + + const tests: Array<() => Promise> = [ + () => testGetTreeAtHead(testId), + () => testGetTreeAtMain(testId), + () => testGetTreeWithSubdirectoryPath(testId), + () => testGetTreeForNonExistentRef(testId), + () => testGetTreeForNonExistentPath(testId), + () => testGetBlobForTextFile(testId), + () => testGetBlobForNonExistentFile(testId), + () => testGetBlobAtSpecificCommit(testId, tempDir), + () => testFilesApiWithoutAuth(testId), + () => testTreeEntryTypes(testId), + () => testGetBlobNestedPath(testId), + ]; + + for (const test of tests) { + try { + const result = await test(); + if (result) { + passed++; + } else { + failed++; + } + } catch (error) { + logError('Test threw an exception', error); + failed++; + } + } + + console.log('\n' + '='.repeat(50)); + if (failed === 0) { + console.log(`πŸŽ‰ ALL TESTS PASSED! (${passed}/${passed + failed})`); + } else { + console.log(`❌ SOME TESTS FAILED (${passed} passed, ${failed} failed)`); + } + console.log('='.repeat(50)); + + if (failed > 0) { + process.exit(1); + } + } finally { + // Cleanup + log('\nCleaning up...'); + await deleteProject(testId); + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch (e) { + logError('Failed to cleanup temp directory', e); + } + log('Cleanup complete'); + } +} + +runTests().catch(error => { + logError('Unhandled error', error); + process.exit(1); +}); diff --git a/cloudflare-app-builder/src/_integration_tests/test-git-advanced.ts b/cloudflare-app-builder/src/_integration_tests/test-git-advanced.ts new file mode 100644 index 000000000..1e8e73bbd --- /dev/null +++ b/cloudflare-app-builder/src/_integration_tests/test-git-advanced.ts @@ -0,0 +1,716 @@ +#!/usr/bin/env npx ts-node + +/** + * Advanced Git Integration Tests + * + * Tests additional git scenarios not covered by the basic integration test: + * - Multiple commits and fetch + * - Branch operations + * - Binary file handling + * - Large file handling + * - Concurrent operations + * + * Prerequisites: + * - App builder running at http://localhost:8790 + * - Set AUTH_TOKEN environment variable + * + * Usage: + * cd cloudflare-app-builder + * AUTH_TOKEN=dev-token-change-this-in-production npx ts-node src/_integration_tests/test-git-advanced.ts + */ + +import { execSync } from 'child_process'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +// --- Configuration --- +const APP_BUILDER_URL = process.env.APP_BUILDER_URL || 'http://localhost:8790'; +const AUTH_TOKEN = process.env.AUTH_TOKEN || 'dev-token-change-this-in-production'; + +// --- Types --- +type TokenPermission = 'full' | 'ro'; + +type InitSuccessResponse = { + success: true; + app_id: string; + git_url: string; +}; + +type TokenResponse = { + success: true; + token: string; + expires_at: string; + permission: TokenPermission; +}; + +// --- Helper Functions --- + +function log(message: string, data?: unknown) { + console.log(`[TEST] ${message}`, data ? JSON.stringify(data, null, 2) : ''); +} + +function logError(message: string, error?: unknown) { + console.error(`[ERROR] ${message}`, error); +} + +function logSuccess(message: string) { + console.log(`[βœ“] ${message}`); +} + +function logFailure(message: string) { + console.error(`[βœ—] ${message}`); +} + +async function initProject(projectId: string): Promise { + const endpoint = `${APP_BUILDER_URL}/apps/${encodeURIComponent(projectId)}/init`; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new Error(`Failed to init project: ${response.status} - ${errorText}`); + } + + return response.json(); +} + +async function generateGitToken( + appId: string, + permission: TokenPermission +): Promise { + const endpoint = `${APP_BUILDER_URL}/apps/${encodeURIComponent(appId)}/token`; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + body: JSON.stringify({ permission }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new Error(`Failed to generate token: ${response.status} - ${errorText}`); + } + + return response.json(); +} + +async function deleteProject(projectId: string): Promise { + const endpoint = `${APP_BUILDER_URL}/apps/${encodeURIComponent(projectId)}`; + + await fetch(endpoint, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + }); +} + +function buildGitUrlWithToken(gitUrl: string, token: string): string { + const url = new URL(gitUrl); + url.username = 'x-access-token'; + url.password = token; + return url.toString(); +} + +function runGitCommand(dir: string, command: string): { success: boolean; output: string } { + const fullCommand = `cd "${dir}" && ${command} 2>&1`; + + try { + const output = execSync(fullCommand, { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + return { success: true, output }; + } catch (error: unknown) { + const execError = error as { stderr?: string; stdout?: string; message?: string }; + return { + success: false, + output: execError.stderr || execError.stdout || execError.message || 'Command failed', + }; + } +} + +function runGitCommandOrThrow(dir: string, command: string): string { + const result = runGitCommand(dir, command); + if (!result.success) { + throw new Error(`Git command failed: ${command}\nOutput: ${result.output}`); + } + return result.output; +} + +// --- Test Cases --- + +async function testMultipleCommitsThenFetch(tempDir: string) { + log('\n=== Test: Multiple Commits and Git Fetch ==='); + + const testId = `test-git-multi-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + try { + const initResult = await initProject(testId); + const tokenResult = await generateGitToken(testId, 'full'); + const gitUrl = buildGitUrlWithToken(initResult.git_url, tokenResult.token); + + // Clone 1 - user A + const cloneDirA = join(tempDir, 'clone-a'); + mkdirSync(cloneDirA, { recursive: true }); + runGitCommandOrThrow(tempDir, `git clone "${gitUrl}" clone-a`); + runGitCommandOrThrow(cloneDirA, 'git config user.email "a@example.com"'); + runGitCommandOrThrow(cloneDirA, 'git config user.name "User A"'); + + // Clone 2 - user B (simulated) + const cloneDirB = join(tempDir, 'clone-b'); + mkdirSync(cloneDirB, { recursive: true }); + const tokenB = await generateGitToken(testId, 'full'); + const gitUrlB = buildGitUrlWithToken(initResult.git_url, tokenB.token); + runGitCommandOrThrow(tempDir, `git clone "${gitUrlB}" clone-b`); + runGitCommandOrThrow(cloneDirB, 'git config user.email "b@example.com"'); + runGitCommandOrThrow(cloneDirB, 'git config user.name "User B"'); + + // User A makes commit 1 + writeFileSync(join(cloneDirA, 'file-from-a.txt'), 'Content from A'); + runGitCommandOrThrow(cloneDirA, 'git add file-from-a.txt'); + runGitCommandOrThrow(cloneDirA, 'git commit -m "Commit from A"'); + + // Get fresh token for push (original may be getting close to expiry) + const tokenAPush = await generateGitToken(testId, 'full'); + runGitCommandOrThrow( + cloneDirA, + `git remote set-url origin "${buildGitUrlWithToken(initResult.git_url, tokenAPush.token)}"` + ); + runGitCommandOrThrow(cloneDirA, 'git push origin main'); + + // User B fetches A's changes + const tokenBFetch = await generateGitToken(testId, 'full'); + runGitCommandOrThrow( + cloneDirB, + `git remote set-url origin "${buildGitUrlWithToken(initResult.git_url, tokenBFetch.token)}"` + ); + runGitCommandOrThrow(cloneDirB, 'git fetch origin'); + runGitCommandOrThrow(cloneDirB, 'git merge origin/main --no-edit'); + + // Verify B has A's file + if (existsSync(join(cloneDirB, 'file-from-a.txt'))) { + const content = readFileSync(join(cloneDirB, 'file-from-a.txt'), 'utf-8'); + if (content === 'Content from A') { + logSuccess('Git fetch and merge works correctly'); + return true; + } + } + + logFailure('Fetched content does not match'); + return false; + } finally { + await deleteProject(testId); + } +} + +async function testModifyExistingFile(tempDir: string) { + log('\n=== Test: Modify Existing File and Push ==='); + + const testId = `test-git-modify-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + try { + const initResult = await initProject(testId); + const tokenResult = await generateGitToken(testId, 'full'); + const gitUrl = buildGitUrlWithToken(initResult.git_url, tokenResult.token); + + const cloneDir = join(tempDir, 'clone-modify'); + mkdirSync(cloneDir, { recursive: true }); + runGitCommandOrThrow(tempDir, `git clone "${gitUrl}" clone-modify`); + runGitCommandOrThrow(cloneDir, 'git config user.email "test@example.com"'); + runGitCommandOrThrow(cloneDir, 'git config user.name "Test User"'); + + // Check if package.json exists (from template) + const packageJsonPath = join(cloneDir, 'package.json'); + if (!existsSync(packageJsonPath)) { + log('No package.json in template, creating new file to modify'); + writeFileSync(packageJsonPath, '{"name": "test"}'); + runGitCommandOrThrow(cloneDir, 'git add package.json'); + runGitCommandOrThrow(cloneDir, 'git commit -m "Add package.json"'); + const token1 = await generateGitToken(testId, 'full'); + runGitCommandOrThrow( + cloneDir, + `git remote set-url origin "${buildGitUrlWithToken(initResult.git_url, token1.token)}"` + ); + runGitCommandOrThrow(cloneDir, 'git push origin main'); + } + + // Modify package.json + const originalContent = readFileSync(packageJsonPath, 'utf-8'); + const modifiedContent = originalContent.replace(/"name":\s*"[^"]*"/, '"name": "modified-name"'); + writeFileSync(packageJsonPath, modifiedContent); + + runGitCommandOrThrow(cloneDir, 'git add package.json'); + runGitCommandOrThrow(cloneDir, 'git commit -m "Modify package.json"'); + + const token2 = await generateGitToken(testId, 'full'); + runGitCommandOrThrow( + cloneDir, + `git remote set-url origin "${buildGitUrlWithToken(initResult.git_url, token2.token)}"` + ); + runGitCommandOrThrow(cloneDir, 'git push origin main'); + + // Clone again and verify + const cloneDir2 = join(tempDir, 'clone-verify-modify'); + mkdirSync(cloneDir2, { recursive: true }); + const token3 = await generateGitToken(testId, 'full'); + runGitCommandOrThrow( + tempDir, + `git clone "${buildGitUrlWithToken(initResult.git_url, token3.token)}" clone-verify-modify` + ); + + const verifyContent = readFileSync(join(cloneDir2, 'package.json'), 'utf-8'); + if (verifyContent.includes('modified-name')) { + logSuccess('Modify existing file and push works'); + return true; + } + + logFailure('Modified content not found in fresh clone'); + return false; + } finally { + await deleteProject(testId); + } +} + +async function testDeleteFileAndPush(tempDir: string) { + log('\n=== Test: Delete File and Push ==='); + + const testId = `test-git-delete-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + try { + const initResult = await initProject(testId); + const tokenResult = await generateGitToken(testId, 'full'); + const gitUrl = buildGitUrlWithToken(initResult.git_url, tokenResult.token); + + const cloneDir = join(tempDir, 'clone-delete-file'); + mkdirSync(cloneDir, { recursive: true }); + runGitCommandOrThrow(tempDir, `git clone "${gitUrl}" clone-delete-file`); + runGitCommandOrThrow(cloneDir, 'git config user.email "test@example.com"'); + runGitCommandOrThrow(cloneDir, 'git config user.name "Test User"'); + + // Add a file first + writeFileSync(join(cloneDir, 'to-delete.txt'), 'This file will be deleted'); + runGitCommandOrThrow(cloneDir, 'git add to-delete.txt'); + runGitCommandOrThrow(cloneDir, 'git commit -m "Add file to delete"'); + const token1 = await generateGitToken(testId, 'full'); + runGitCommandOrThrow( + cloneDir, + `git remote set-url origin "${buildGitUrlWithToken(initResult.git_url, token1.token)}"` + ); + runGitCommandOrThrow(cloneDir, 'git push origin main'); + + // Now delete the file + runGitCommandOrThrow(cloneDir, 'git rm to-delete.txt'); + runGitCommandOrThrow(cloneDir, 'git commit -m "Delete file"'); + const token2 = await generateGitToken(testId, 'full'); + runGitCommandOrThrow( + cloneDir, + `git remote set-url origin "${buildGitUrlWithToken(initResult.git_url, token2.token)}"` + ); + runGitCommandOrThrow(cloneDir, 'git push origin main'); + + // Clone again and verify file is gone + const cloneDir2 = join(tempDir, 'clone-verify-delete'); + mkdirSync(cloneDir2, { recursive: true }); + const token3 = await generateGitToken(testId, 'full'); + runGitCommandOrThrow( + tempDir, + `git clone "${buildGitUrlWithToken(initResult.git_url, token3.token)}" clone-verify-delete` + ); + + if (!existsSync(join(cloneDir2, 'to-delete.txt'))) { + logSuccess('Delete file and push works'); + return true; + } + + logFailure('Deleted file still exists in fresh clone'); + return false; + } finally { + await deleteProject(testId); + } +} + +async function testGitLog(tempDir: string) { + log('\n=== Test: Git Log Shows Commit History ==='); + + const testId = `test-git-log-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + try { + const initResult = await initProject(testId); + const tokenResult = await generateGitToken(testId, 'full'); + const gitUrl = buildGitUrlWithToken(initResult.git_url, tokenResult.token); + + const cloneDir = join(tempDir, 'clone-log'); + mkdirSync(cloneDir, { recursive: true }); + runGitCommandOrThrow(tempDir, `git clone "${gitUrl}" clone-log`); + runGitCommandOrThrow(cloneDir, 'git config user.email "test@example.com"'); + runGitCommandOrThrow(cloneDir, 'git config user.name "Test User"'); + + // Make multiple commits + writeFileSync(join(cloneDir, 'commit1.txt'), 'First'); + runGitCommandOrThrow(cloneDir, 'git add commit1.txt'); + runGitCommandOrThrow(cloneDir, 'git commit -m "First commit"'); + + writeFileSync(join(cloneDir, 'commit2.txt'), 'Second'); + runGitCommandOrThrow(cloneDir, 'git add commit2.txt'); + runGitCommandOrThrow(cloneDir, 'git commit -m "Second commit"'); + + const token1 = await generateGitToken(testId, 'full'); + runGitCommandOrThrow( + cloneDir, + `git remote set-url origin "${buildGitUrlWithToken(initResult.git_url, token1.token)}"` + ); + runGitCommandOrThrow(cloneDir, 'git push origin main'); + + // Clone again and check git log + const cloneDir2 = join(tempDir, 'clone-verify-log'); + mkdirSync(cloneDir2, { recursive: true }); + const token2 = await generateGitToken(testId, 'full'); + runGitCommandOrThrow( + tempDir, + `git clone "${buildGitUrlWithToken(initResult.git_url, token2.token)}" clone-verify-log` + ); + + const logOutput = runGitCommandOrThrow(cloneDir2, 'git log --oneline'); + + if (logOutput.includes('First commit') && logOutput.includes('Second commit')) { + logSuccess('Git log shows full commit history'); + return true; + } + + logFailure(`Git log missing commits: ${logOutput}`); + return false; + } finally { + await deleteProject(testId); + } +} + +async function testNestedDirectoryCreation(tempDir: string) { + log('\n=== Test: Create Nested Directory Structure ==='); + + const testId = `test-git-nested-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + try { + const initResult = await initProject(testId); + const tokenResult = await generateGitToken(testId, 'full'); + const gitUrl = buildGitUrlWithToken(initResult.git_url, tokenResult.token); + + const cloneDir = join(tempDir, 'clone-nested'); + mkdirSync(cloneDir, { recursive: true }); + runGitCommandOrThrow(tempDir, `git clone "${gitUrl}" clone-nested`); + runGitCommandOrThrow(cloneDir, 'git config user.email "test@example.com"'); + runGitCommandOrThrow(cloneDir, 'git config user.name "Test User"'); + + // Create nested structure + const nestedPath = join(cloneDir, 'deep', 'nested', 'path'); + mkdirSync(nestedPath, { recursive: true }); + writeFileSync(join(nestedPath, 'deep-file.txt'), 'Deep content'); + + runGitCommandOrThrow(cloneDir, 'git add .'); + runGitCommandOrThrow(cloneDir, 'git commit -m "Add nested directories"'); + const token1 = await generateGitToken(testId, 'full'); + runGitCommandOrThrow( + cloneDir, + `git remote set-url origin "${buildGitUrlWithToken(initResult.git_url, token1.token)}"` + ); + runGitCommandOrThrow(cloneDir, 'git push origin main'); + + // Clone and verify + const cloneDir2 = join(tempDir, 'clone-verify-nested'); + mkdirSync(cloneDir2, { recursive: true }); + const token2 = await generateGitToken(testId, 'full'); + runGitCommandOrThrow( + tempDir, + `git clone "${buildGitUrlWithToken(initResult.git_url, token2.token)}" clone-verify-nested` + ); + + const deepFile = join(cloneDir2, 'deep', 'nested', 'path', 'deep-file.txt'); + if (existsSync(deepFile)) { + const content = readFileSync(deepFile, 'utf-8'); + if (content === 'Deep content') { + logSuccess('Nested directory creation works'); + return true; + } + } + + logFailure('Nested file not found or content mismatch'); + return false; + } finally { + await deleteProject(testId); + } +} + +async function testBinaryFileHandling(tempDir: string) { + log('\n=== Test: Binary File Handling ==='); + + const testId = `test-git-binary-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + try { + const initResult = await initProject(testId); + const tokenResult = await generateGitToken(testId, 'full'); + const gitUrl = buildGitUrlWithToken(initResult.git_url, tokenResult.token); + + const cloneDir = join(tempDir, 'clone-binary'); + mkdirSync(cloneDir, { recursive: true }); + runGitCommandOrThrow(tempDir, `git clone "${gitUrl}" clone-binary`); + runGitCommandOrThrow(cloneDir, 'git config user.email "test@example.com"'); + runGitCommandOrThrow(cloneDir, 'git config user.name "Test User"'); + + // Create a small binary file (PNG header + some data) + const binaryData = Buffer.from([ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, // PNG signature + 0x00, + 0x00, + 0x00, + 0x0d, // IHDR length + 0x49, + 0x48, + 0x44, + 0x52, // IHDR type + 0x00, + 0x00, + 0x00, + 0x01, // width: 1 + 0x00, + 0x00, + 0x00, + 0x01, // height: 1 + 0x08, + 0x02, // bit depth: 8, color type: 2 (RGB) + 0x00, + 0x00, + 0x00, // compression, filter, interlace + 0xff, + 0xff, + 0xff, + 0xff, // CRC (placeholder) + ]); + + writeFileSync(join(cloneDir, 'test-image.png'), binaryData); + runGitCommandOrThrow(cloneDir, 'git add test-image.png'); + runGitCommandOrThrow(cloneDir, 'git commit -m "Add binary file"'); + const token1 = await generateGitToken(testId, 'full'); + runGitCommandOrThrow( + cloneDir, + `git remote set-url origin "${buildGitUrlWithToken(initResult.git_url, token1.token)}"` + ); + runGitCommandOrThrow(cloneDir, 'git push origin main'); + + // Clone and verify + const cloneDir2 = join(tempDir, 'clone-verify-binary'); + mkdirSync(cloneDir2, { recursive: true }); + const token2 = await generateGitToken(testId, 'full'); + runGitCommandOrThrow( + tempDir, + `git clone "${buildGitUrlWithToken(initResult.git_url, token2.token)}" clone-verify-binary` + ); + + const clonedBinary = readFileSync(join(cloneDir2, 'test-image.png')); + if (clonedBinary.equals(binaryData)) { + logSuccess('Binary file handling works correctly'); + return true; + } + + logFailure('Binary file content mismatch'); + return false; + } finally { + await deleteProject(testId); + } +} + +async function testEmptyCommit(tempDir: string) { + log('\n=== Test: Empty Commit (amend without changes) ==='); + + const testId = `test-git-empty-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + try { + const initResult = await initProject(testId); + const tokenResult = await generateGitToken(testId, 'full'); + const gitUrl = buildGitUrlWithToken(initResult.git_url, tokenResult.token); + + const cloneDir = join(tempDir, 'clone-empty'); + mkdirSync(cloneDir, { recursive: true }); + runGitCommandOrThrow(tempDir, `git clone "${gitUrl}" clone-empty`); + runGitCommandOrThrow(cloneDir, 'git config user.email "test@example.com"'); + runGitCommandOrThrow(cloneDir, 'git config user.name "Test User"'); + + // Make a commit, then try empty commit with --allow-empty + writeFileSync(join(cloneDir, 'file.txt'), 'Content'); + runGitCommandOrThrow(cloneDir, 'git add file.txt'); + runGitCommandOrThrow(cloneDir, 'git commit -m "Add file"'); + runGitCommandOrThrow(cloneDir, 'git commit --allow-empty -m "Empty commit"'); + + const token1 = await generateGitToken(testId, 'full'); + runGitCommandOrThrow( + cloneDir, + `git remote set-url origin "${buildGitUrlWithToken(initResult.git_url, token1.token)}"` + ); + runGitCommandOrThrow(cloneDir, 'git push origin main'); + + // Clone and verify both commits exist + const cloneDir2 = join(tempDir, 'clone-verify-empty'); + mkdirSync(cloneDir2, { recursive: true }); + const token2 = await generateGitToken(testId, 'full'); + runGitCommandOrThrow( + tempDir, + `git clone "${buildGitUrlWithToken(initResult.git_url, token2.token)}" clone-verify-empty` + ); + + const logOutput = runGitCommandOrThrow(cloneDir2, 'git log --oneline'); + if (logOutput.includes('Empty commit') && logOutput.includes('Add file')) { + logSuccess('Empty commit support works'); + return true; + } + + logFailure('Empty commit not in history'); + return false; + } finally { + await deleteProject(testId); + } +} + +async function testSpecialCharactersInFilename(tempDir: string) { + log('\n=== Test: Special Characters in Filename ==='); + + const testId = `test-git-special-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + try { + const initResult = await initProject(testId); + const tokenResult = await generateGitToken(testId, 'full'); + const gitUrl = buildGitUrlWithToken(initResult.git_url, tokenResult.token); + + const cloneDir = join(tempDir, 'clone-special'); + mkdirSync(cloneDir, { recursive: true }); + runGitCommandOrThrow(tempDir, `git clone "${gitUrl}" clone-special`); + runGitCommandOrThrow(cloneDir, 'git config user.email "test@example.com"'); + runGitCommandOrThrow(cloneDir, 'git config user.name "Test User"'); + + // Test files with special (but valid) characters + const specialNames = [ + 'file with spaces.txt', + 'file-with-dashes.txt', + 'file_with_underscores.txt', + 'file.multiple.dots.txt', + ]; + + for (const name of specialNames) { + writeFileSync(join(cloneDir, name), `Content of ${name}`); + } + + runGitCommandOrThrow(cloneDir, 'git add .'); + runGitCommandOrThrow(cloneDir, 'git commit -m "Add special filename files"'); + const token1 = await generateGitToken(testId, 'full'); + runGitCommandOrThrow( + cloneDir, + `git remote set-url origin "${buildGitUrlWithToken(initResult.git_url, token1.token)}"` + ); + runGitCommandOrThrow(cloneDir, 'git push origin main'); + + // Clone and verify + const cloneDir2 = join(tempDir, 'clone-verify-special'); + mkdirSync(cloneDir2, { recursive: true }); + const token2 = await generateGitToken(testId, 'full'); + runGitCommandOrThrow( + tempDir, + `git clone "${buildGitUrlWithToken(initResult.git_url, token2.token)}" clone-verify-special` + ); + + let allFound = true; + for (const name of specialNames) { + if (!existsSync(join(cloneDir2, name))) { + logFailure(`File not found: ${name}`); + allFound = false; + } + } + + if (allFound) { + logSuccess('Special characters in filenames work'); + return true; + } + return false; + } finally { + await deleteProject(testId); + } +} + +// --- Main Test Runner --- + +async function runTests() { + const tempDir = mkdtempSync(join(tmpdir(), 'app-builder-git-adv-test-')); + let passed = 0; + let failed = 0; + + log('Starting advanced git integration tests', { + tempDir, + appBuilderUrl: APP_BUILDER_URL, + }); + + try { + const tests: Array<() => Promise> = [ + () => testMultipleCommitsThenFetch(tempDir), + () => testModifyExistingFile(tempDir), + () => testDeleteFileAndPush(tempDir), + () => testGitLog(tempDir), + () => testNestedDirectoryCreation(tempDir), + () => testBinaryFileHandling(tempDir), + () => testEmptyCommit(tempDir), + () => testSpecialCharactersInFilename(tempDir), + ]; + + for (const test of tests) { + try { + const result = await test(); + if (result) { + passed++; + } else { + failed++; + } + } catch (error) { + logError('Test threw an exception', error); + failed++; + } + } + + console.log('\n' + '='.repeat(50)); + if (failed === 0) { + console.log(`πŸŽ‰ ALL TESTS PASSED! (${passed}/${passed + failed})`); + } else { + console.log(`❌ SOME TESTS FAILED (${passed} passed, ${failed} failed)`); + } + console.log('='.repeat(50)); + + if (failed > 0) { + process.exit(1); + } + } finally { + log('\nCleaning up temp directory...'); + try { + rmSync(tempDir, { recursive: true, force: true }); + log('Cleanup complete'); + } catch (e) { + logError('Failed to cleanup temp directory', e); + } + } +} + +runTests().catch(error => { + logError('Unhandled error', error); + process.exit(1); +}); diff --git a/cloudflare-app-builder/src/_integration_tests/test-init-edge-cases.ts b/cloudflare-app-builder/src/_integration_tests/test-init-edge-cases.ts new file mode 100644 index 000000000..ba9526b9b --- /dev/null +++ b/cloudflare-app-builder/src/_integration_tests/test-init-edge-cases.ts @@ -0,0 +1,371 @@ +#!/usr/bin/env npx ts-node + +/** + * Integration Tests for Init Endpoint Edge Cases + * + * Tests project initialization scenarios including templates and idempotency. + * + * Prerequisites: + * - App builder running at http://localhost:8790 + * - Set AUTH_TOKEN environment variable + * + * Usage: + * cd cloudflare-app-builder + * AUTH_TOKEN=dev-token-change-this-in-production npx ts-node src/_integration_tests/test-init-edge-cases.ts + */ + +// --- Configuration --- +const APP_BUILDER_URL = process.env.APP_BUILDER_URL || 'http://localhost:8790'; +const AUTH_TOKEN = process.env.AUTH_TOKEN || 'dev-token-change-this-in-production'; + +// --- Types --- +type InitSuccessResponse = { + success: true; + app_id: string; + git_url: string; +}; + +type InitErrorResponse = { + success: false; + error: string; + message: string; + git_url?: string; +}; + +type InitResponse = InitSuccessResponse | InitErrorResponse; + +type ErrorResponse = { + error: string; + message?: string; +}; + +// --- Helper Functions --- + +function log(message: string, data?: unknown) { + console.log(`[TEST] ${message}`, data ? JSON.stringify(data, null, 2) : ''); +} + +function logError(message: string, error?: unknown) { + console.error(`[ERROR] ${message}`, error); +} + +function logSuccess(message: string) { + console.log(`[βœ“] ${message}`); +} + +function logFailure(message: string) { + console.error(`[βœ—] ${message}`); +} + +async function initProjectRaw( + projectId: string, + body?: Record +): Promise<{ status: number; body: InitResponse }> { + const endpoint = `${APP_BUILDER_URL}/apps/${encodeURIComponent(projectId)}/init`; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + body: body ? JSON.stringify(body) : undefined, + }); + + const responseBody = (await response.json()) as InitResponse; + return { status: response.status, body: responseBody }; +} + +async function deleteProject(projectId: string): Promise { + const endpoint = `${APP_BUILDER_URL}/apps/${encodeURIComponent(projectId)}`; + + await fetch(endpoint, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + }); +} + +// --- Test Cases --- + +async function testBasicInit() { + log('\n=== Test: Basic Project Initialization ==='); + + const testId = `test-init-basic-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + try { + const result = await initProjectRaw(testId); + + if (result.status === 201 && result.body.success === true) { + const successBody = result.body as InitSuccessResponse; + if (successBody.app_id === testId && successBody.git_url.includes(testId)) { + logSuccess('Basic init returns 201 with correct app_id and git_url'); + return true; + } + } + + logFailure(`Unexpected response: ${result.status} - ${JSON.stringify(result.body)}`); + return false; + } finally { + await deleteProject(testId); + } +} + +async function testDoubleInit() { + log('\n=== Test: Double Initialization (409 Conflict) ==='); + + const testId = `test-init-double-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + try { + // First init should succeed + const firstResult = await initProjectRaw(testId); + if (firstResult.status !== 201) { + logFailure(`First init failed: ${firstResult.status}`); + return false; + } + + // Second init should fail with 409 + const secondResult = await initProjectRaw(testId); + + if (secondResult.status === 409 && secondResult.body.success === false) { + const errorBody = secondResult.body as InitErrorResponse; + if (errorBody.error === 'repository_exists' && errorBody.git_url) { + logSuccess('Double init returns 409 with repository_exists error and git_url'); + return true; + } + } + + logFailure(`Expected 409, got ${secondResult.status} - ${JSON.stringify(secondResult.body)}`); + return false; + } finally { + await deleteProject(testId); + } +} + +async function testInitWithDefaultTemplate() { + log('\n=== Test: Init with Default Template (empty body) ==='); + + const testId = `test-init-default-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + try { + // Init without specifying template (should use default) + const result = await initProjectRaw(testId); + + if (result.status === 201 && result.body.success === true) { + logSuccess('Init with empty body uses default template and succeeds'); + return true; + } + + logFailure(`Expected 201, got ${result.status}`); + return false; + } finally { + await deleteProject(testId); + } +} + +async function testInitWithExplicitTemplate() { + log('\n=== Test: Init with Explicit Template ==='); + + const testId = `test-init-template-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + try { + // Init with explicit template name + const result = await initProjectRaw(testId, { template: 'nextjs-starter' }); + + if (result.status === 201 && result.body.success === true) { + logSuccess('Init with explicit template succeeds'); + return true; + } + + logFailure(`Expected 201, got ${result.status}`); + return false; + } finally { + await deleteProject(testId); + } +} + +async function testInitWithNonExistentTemplate() { + log('\n=== Test: Init with Non-Existent Template (500) ==='); + + const testId = `test-init-bad-template-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + try { + const result = await initProjectRaw(testId, { template: 'non-existent-template-xyz' }); + + if (result.status === 500 && result.body.success === false) { + const errorBody = result.body as InitErrorResponse; + if (errorBody.error === 'template_not_found') { + logSuccess('Init with non-existent template returns 500 template_not_found'); + return true; + } + } + + logFailure( + `Expected 500 template_not_found, got ${result.status} - ${JSON.stringify(result.body)}` + ); + return false; + } finally { + await deleteProject(testId); + } +} + +async function testInitWithInvalidTemplateName() { + log('\n=== Test: Init with Invalid Template Name (path traversal attempt) ==='); + + const testId = `test-init-invalid-name-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + try { + // Attempt path traversal in template name + const result = await initProjectRaw(testId, { template: '../../../etc/passwd' }); + + if (result.status === 400 && result.body.success === false) { + const errorBody = result.body as InitErrorResponse; + if (errorBody.error === 'invalid_request') { + logSuccess('Init with path traversal template name returns 400'); + return true; + } + } + + logFailure( + `Expected 400 invalid_request, got ${result.status} - ${JSON.stringify(result.body)}` + ); + return false; + } finally { + await deleteProject(testId); + } +} + +async function testInitWithInvalidJson() { + log('\n=== Test: Init with Invalid JSON Body ==='); + + const testId = `test-init-invalid-json-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const endpoint = `${APP_BUILDER_URL}/apps/${encodeURIComponent(testId)}/init`; + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + body: 'not valid json {{{', + }); + + if (response.status === 400) { + const body = (await response.json()) as ErrorResponse; + if (body.error === 'invalid_request') { + logSuccess('Init with invalid JSON returns 400'); + return true; + } + } + + logFailure(`Expected 400, got ${response.status}`); + return false; + } finally { + await deleteProject(testId); + } +} + +async function testInitWithSpecialCharactersInId() { + log('\n=== Test: Init with Special Characters in App ID ==='); + + // Test various special characters - most should work since pattern is [a-z0-9_-] + const validTestId = `test-init_special-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + try { + const result = await initProjectRaw(validTestId); + + if (result.status === 201 && result.body.success === true) { + logSuccess('Init with valid special chars (underscore, hyphen) succeeds'); + return true; + } + + logFailure(`Expected 201, got ${result.status}`); + return false; + } finally { + await deleteProject(validTestId); + } +} + +async function testInitResponseContainsCorrectGitUrl() { + log('\n=== Test: Init Response Contains Correct Git URL Format ==='); + + const testId = `test-init-giturl-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + try { + const result = await initProjectRaw(testId); + + if (result.status === 201 && result.body.success === true) { + const successBody = result.body as InitSuccessResponse; + const expectedUrlPattern = new RegExp(`https://[^/]+/apps/${testId}\\.git$`); + + if (expectedUrlPattern.test(successBody.git_url)) { + logSuccess('Git URL follows expected format: https://hostname/apps/{id}.git'); + return true; + } + + logFailure(`Git URL format unexpected: ${successBody.git_url}`); + return false; + } + + logFailure(`Expected 201, got ${result.status}`); + return false; + } finally { + await deleteProject(testId); + } +} + +// --- Main Test Runner --- + +async function runTests() { + let passed = 0; + let failed = 0; + + log('Starting init edge case tests', { + appBuilderUrl: APP_BUILDER_URL, + }); + + const tests = [ + testBasicInit, + testDoubleInit, + testInitWithDefaultTemplate, + testInitWithExplicitTemplate, + testInitWithNonExistentTemplate, + testInitWithInvalidTemplateName, + testInitWithInvalidJson, + testInitWithSpecialCharactersInId, + testInitResponseContainsCorrectGitUrl, + ]; + + for (const test of tests) { + try { + const result = await test(); + if (result) { + passed++; + } else { + failed++; + } + } catch (error) { + logError(`Test ${test.name} threw an exception`, error); + failed++; + } + } + + console.log('\n' + '='.repeat(50)); + if (failed === 0) { + console.log(`πŸŽ‰ ALL TESTS PASSED! (${passed}/${passed + failed})`); + } else { + console.log(`❌ SOME TESTS FAILED (${passed} passed, ${failed} failed)`); + } + console.log('='.repeat(50)); + + if (failed > 0) { + process.exit(1); + } +} + +runTests().catch(error => { + logError('Unhandled error', error); + process.exit(1); +}); diff --git a/cloudflare-app-builder/src/api-schemas.ts b/cloudflare-app-builder/src/api-schemas.ts index 2c66f6e62..d6769ef16 100644 --- a/cloudflare-app-builder/src/api-schemas.ts +++ b/cloudflare-app-builder/src/api-schemas.ts @@ -143,3 +143,41 @@ export const ApiErrorResponseSchema = z.object({ }); export type ApiErrorResponse = z.infer; + +// ============================================ +// Tree Endpoint Schemas +// GET /apps/{app_id}/tree/{ref} +// ============================================ + +export const TreeEntrySchema = z.object({ + name: z.string(), + type: z.enum(['blob', 'tree']), + oid: z.string(), + mode: z.string(), +}); + +export type TreeEntry = z.infer; + +export const GetTreeResponseSchema = z.object({ + entries: z.array(TreeEntrySchema), + path: z.string(), + ref: z.string(), + commitSha: z.string(), +}); + +export type GetTreeResponse = z.infer; + +// ============================================ +// Blob Endpoint Schemas +// GET /apps/{app_id}/blob/{ref}/{path} +// ============================================ + +export const GetBlobResponseSchema = z.object({ + content: z.string(), + encoding: z.enum(['utf-8', 'base64']), + size: z.number(), + path: z.string(), + sha: z.string(), // blob SHA +}); + +export type GetBlobResponse = z.infer; diff --git a/cloudflare-app-builder/src/git-repository-do.ts b/cloudflare-app-builder/src/git-repository-do.ts index d57a0d6a8..e524e3d98 100644 --- a/cloudflare-app-builder/src/git-repository-do.ts +++ b/cloudflare-app-builder/src/git-repository-do.ts @@ -1,6 +1,6 @@ /** - * Git Repository Agent - * Stores git repositories in SQLite and provides export functionality + * Git Repository Durable Object + * Thin RPC wrapper around GitVersionControl for durable persistence * Uses RPC for communication with workers */ @@ -35,7 +35,7 @@ export class GitRepositoryDO extends DurableObject { private _initialized = false; /** - * Initialize the git filesystem (internal method) + * Get or create the GitVersionControl instance */ private async initializeFS(): Promise { if (this.fs) return; @@ -263,4 +263,144 @@ export class GitRepositoryDO extends DurableObject { logger.info('Repository deleted successfully'); }); } + + /** + * Get directory tree contents at a specific path (RPC method) + * @param ref - Branch name (e.g., "main", "HEAD") or commit SHA + * @param path - Optional path to a subdirectory (defaults to root) + * @returns Array of tree entries with name, type, oid, and mode + */ + async getTree( + ref: string, + path?: string + ): Promise<{ + entries: Array<{ name: string; type: 'blob' | 'tree'; oid: string; mode: string }>; + commitSha: string; + }> { + return withLogTags({ source: 'GitRepositoryDO' }, async () => { + if (!this.fs) { + await this.initializeFS(); + } + + if (!this._initialized || !this.fs) { + throw new Error('Repository not initialized'); + } + + // Resolve the ref to a commit SHA + const commitSha = await git.resolveRef({ fs: this.fs, dir: '/', ref }); + + // Read the commit to get the tree SHA + const { commit } = await git.readCommit({ fs: this.fs, dir: '/', oid: commitSha }); + let treeSha = commit.tree; + + // If a path is specified, navigate to that subtree + if (path && path.length > 0) { + const pathParts = path.split('/').filter(p => p.length > 0); + for (const part of pathParts) { + const { tree } = await git.readTree({ fs: this.fs, dir: '/', oid: treeSha }); + const entry = tree.find((e: { path: string }) => e.path === part); + if (!entry || entry.type !== 'tree') { + throw new Error(`Path not found: ${path}`); + } + treeSha = entry.oid; + } + } + + // Read the tree + const { tree } = await git.readTree({ fs: this.fs, dir: '/', oid: treeSha }); + + const entries = tree.map( + (entry: { path: string; type: string; oid: string; mode: string }) => ({ + name: entry.path, + type: entry.type as 'blob' | 'tree', + oid: entry.oid, + mode: entry.mode, + }) + ); + + // Return commit SHA (not tree SHA) - more useful for consumers to know which commit was resolved + return { entries, commitSha }; + }); + } + + /** + * Get blob (file) contents at a specific path (RPC method) + * @param ref - Branch name (e.g., "main", "HEAD") or commit SHA + * @param path - Path to the file + * @returns File content (utf-8 or base64 encoded), encoding type, size, and blob SHA + */ + async getBlob( + ref: string, + path: string + ): Promise<{ + content: string; + encoding: 'utf-8' | 'base64'; + size: number; + sha: string; + }> { + return withLogTags({ source: 'GitRepositoryDO' }, async () => { + if (!this.fs) { + await this.initializeFS(); + } + + if (!this._initialized || !this.fs) { + throw new Error('Repository not initialized'); + } + + // Resolve the ref to a commit SHA + const commitSha = await git.resolveRef({ fs: this.fs, dir: '/', ref }); + + // Read the commit to get the tree SHA + const { commit } = await git.readCommit({ fs: this.fs, dir: '/', oid: commitSha }); + let treeSha = commit.tree; + + // Navigate to the file's parent directory + const pathParts = path.split('/').filter(p => p.length > 0); + const fileName = pathParts.pop(); + if (!fileName) { + throw new Error('Invalid path: no filename specified'); + } + + for (const part of pathParts) { + const { tree } = await git.readTree({ fs: this.fs, dir: '/', oid: treeSha }); + const entry = tree.find((e: { path: string }) => e.path === part); + if (!entry || entry.type !== 'tree') { + throw new Error(`Path not found: ${path}`); + } + treeSha = entry.oid; + } + + // Find the file in the tree + const { tree } = await git.readTree({ fs: this.fs, dir: '/', oid: treeSha }); + const fileEntry = tree.find((e: { path: string }) => e.path === fileName); + if (!fileEntry || fileEntry.type !== 'blob') { + throw new Error(`File not found: ${path}`); + } + + // Read the blob + const { blob } = await git.readBlob({ fs: this.fs, dir: '/', oid: fileEntry.oid }); + + // Detect binary content by checking for null bytes + const isBinary = blob.some((byte: number) => byte === 0); + + if (isBinary) { + // Base64 encode binary content + return { + content: Buffer.from(blob).toString('base64'), + encoding: 'base64', + size: blob.length, + sha: fileEntry.oid, + }; + } else { + // UTF-8 decode text content + const textDecoder = new TextDecoder('utf-8'); + return { + content: textDecoder.decode(blob), + encoding: 'utf-8', + size: blob.length, + sha: fileEntry.oid, + }; + } + }); + } } diff --git a/cloudflare-app-builder/src/git/fs-adapter.test.ts b/cloudflare-app-builder/src/git/fs-adapter.test.ts new file mode 100644 index 000000000..2d3e450c5 --- /dev/null +++ b/cloudflare-app-builder/src/git/fs-adapter.test.ts @@ -0,0 +1,448 @@ +/** + * Unit tests for SqliteFS filesystem adapter + */ + +import { describe, it, expect, beforeEach, afterAll } from 'vitest'; +import { SqliteFS } from './fs-adapter'; +import { createTestSqlExecutor, closeAllDatabases } from './test-utils'; +import { MAX_OBJECT_SIZE } from './constants'; + +// Clean up all database connections after all tests +afterAll(() => { + closeAllDatabases(); +}); + +describe('SqliteFS', () => { + let fs: SqliteFS; + + beforeEach(() => { + const sql = createTestSqlExecutor(); + fs = new SqliteFS(sql); + fs.init(); + }); + + describe('init', () => { + it('creates git_objects table and root directory', () => { + // init() was called in beforeEach + // Verify root directory exists + expect(fs.promises).toBe(fs); + }); + + it('sets promises property to self for isomorphic-git compatibility', () => { + expect(fs.promises).toBe(fs); + expect(fs.promises.readFile).toBe(fs.readFile); + }); + }); + + describe('writeFile', () => { + it('writes string content', async () => { + await fs.writeFile('test.txt', 'Hello World'); + const content = await fs.readFile('test.txt', { encoding: 'utf8' }); + expect(content).toBe('Hello World'); + }); + + it('writes binary content', async () => { + const data = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" + await fs.writeFile('binary.bin', data); + const content = await fs.readFile('binary.bin'); + expect(content).toBeInstanceOf(Uint8Array); + expect(Array.from(content as Uint8Array)).toEqual([72, 101, 108, 108, 111]); + }); + + it('normalizes paths with leading slashes', async () => { + await fs.writeFile('/leading-slash.txt', 'content'); + const content = await fs.readFile('leading-slash.txt', { encoding: 'utf8' }); + expect(content).toBe('content'); + }); + + it('throws error for empty path', async () => { + await expect(fs.writeFile('', 'content')).rejects.toThrow('Cannot write to root'); + }); + + it('throws EISDIR when writing to directory path', async () => { + await fs.mkdir('mydir'); + try { + await fs.writeFile('mydir', 'content'); + expect.fail('Should have thrown'); + } catch (err) { + expect((err as NodeJS.ErrnoException).code).toBe('EISDIR'); + } + }); + + it('throws error when file exceeds MAX_OBJECT_SIZE', async () => { + const largeContent = new Uint8Array(MAX_OBJECT_SIZE + 1); + await expect(fs.writeFile('large.bin', largeContent)).rejects.toThrow('File too large'); + }); + + it('provides helpful error for oversized git packfiles', async () => { + const largeContent = new Uint8Array(MAX_OBJECT_SIZE + 1); + await expect(fs.writeFile('.git/objects/pack/pack-abc.pack', largeContent)).rejects.toThrow( + 'Git packfile too large' + ); + }); + + it('creates parent directories automatically', async () => { + await fs.writeFile('a/b/c/file.txt', 'nested'); + const content = await fs.readFile('a/b/c/file.txt', { encoding: 'utf8' }); + expect(content).toBe('nested'); + + // Verify parent directories exist + const stat = await fs.stat('a/b/c'); + expect(stat.type).toBe('dir'); + }); + + it('handles empty content', async () => { + await fs.writeFile('empty.txt', ''); + const content = await fs.readFile('empty.txt', { encoding: 'utf8' }); + expect(content).toBe(''); + }); + }); + + describe('readFile', () => { + it('returns Uint8Array by default', async () => { + await fs.writeFile('test.txt', 'Hello'); + const content = await fs.readFile('test.txt'); + expect(content).toBeInstanceOf(Uint8Array); + }); + + it('returns string with utf8 encoding', async () => { + await fs.writeFile('test.txt', 'Hello'); + const content = await fs.readFile('test.txt', { encoding: 'utf8' }); + expect(typeof content).toBe('string'); + expect(content).toBe('Hello'); + }); + + it('throws ENOENT for non-existent file', async () => { + try { + await fs.readFile('nonexistent.txt'); + expect.fail('Should have thrown'); + } catch (err) { + expect((err as NodeJS.ErrnoException).code).toBe('ENOENT'); + expect((err as NodeJS.ErrnoException).path).toBe('nonexistent.txt'); + } + }); + + it('throws EISDIR when reading a directory', async () => { + await fs.mkdir('mydir'); + try { + await fs.readFile('mydir'); + expect.fail('Should have thrown'); + } catch (err) { + expect((err as NodeJS.ErrnoException).code).toBe('EISDIR'); + } + }); + + it('handles binary data with null bytes', async () => { + const data = new Uint8Array([0, 1, 2, 0, 3, 4, 0]); + await fs.writeFile('binary.bin', data); + const content = await fs.readFile('binary.bin'); + expect(Array.from(content as Uint8Array)).toEqual([0, 1, 2, 0, 3, 4, 0]); + }); + }); + + describe('unlink', () => { + it('deletes a file', async () => { + await fs.writeFile('to-delete.txt', 'content'); + await fs.unlink('to-delete.txt'); + + try { + await fs.readFile('to-delete.txt'); + expect.fail('Should have thrown'); + } catch (err) { + expect((err as NodeJS.ErrnoException).code).toBe('ENOENT'); + } + }); + + it('throws ENOENT for non-existent file', async () => { + try { + await fs.unlink('nonexistent.txt'); + expect.fail('Should have thrown'); + } catch (err) { + expect((err as NodeJS.ErrnoException).code).toBe('ENOENT'); + } + }); + + it('throws EPERM when unlinking a directory', async () => { + await fs.mkdir('mydir'); + try { + await fs.unlink('mydir'); + expect.fail('Should have thrown'); + } catch (err) { + expect((err as NodeJS.ErrnoException).code).toBe('EPERM'); + } + }); + }); + + describe('mkdir', () => { + it('creates a directory', async () => { + await fs.mkdir('newdir'); + const stat = await fs.stat('newdir'); + expect(stat.type).toBe('dir'); + }); + + it('is idempotent for existing directory', async () => { + await fs.mkdir('mydir'); + await fs.mkdir('mydir'); // Should not throw + const stat = await fs.stat('mydir'); + expect(stat.type).toBe('dir'); + }); + + it('throws ENOENT when parent does not exist', async () => { + try { + await fs.mkdir('nonexistent/child'); + expect.fail('Should have thrown'); + } catch (err) { + expect((err as NodeJS.ErrnoException).code).toBe('ENOENT'); + } + }); + + it('throws EEXIST when path is a file', async () => { + await fs.writeFile('file.txt', 'content'); + try { + await fs.mkdir('file.txt'); + expect.fail('Should have thrown'); + } catch (err) { + expect((err as NodeJS.ErrnoException).code).toBe('EEXIST'); + } + }); + + it('does nothing for root path', async () => { + await fs.mkdir(''); // Should not throw + await fs.mkdir('/'); // Should not throw + }); + }); + + describe('rmdir', () => { + it('removes an empty directory', async () => { + await fs.mkdir('emptydir'); + await fs.rmdir('emptydir'); + + try { + await fs.stat('emptydir'); + expect.fail('Should have thrown'); + } catch (err) { + expect((err as NodeJS.ErrnoException).code).toBe('ENOENT'); + } + }); + + it('throws ENOENT for non-existent directory', async () => { + try { + await fs.rmdir('nonexistent'); + expect.fail('Should have thrown'); + } catch (err) { + expect((err as NodeJS.ErrnoException).code).toBe('ENOENT'); + } + }); + + it('throws ENOTDIR for file path', async () => { + await fs.writeFile('file.txt', 'content'); + try { + await fs.rmdir('file.txt'); + expect.fail('Should have thrown'); + } catch (err) { + expect((err as NodeJS.ErrnoException).code).toBe('ENOTDIR'); + } + }); + + it('throws ENOTEMPTY for non-empty directory', async () => { + await fs.writeFile('dir/file.txt', 'content'); + try { + await fs.rmdir('dir'); + expect.fail('Should have thrown'); + } catch (err) { + expect((err as NodeJS.ErrnoException).code).toBe('ENOTEMPTY'); + } + }); + + it('throws error when removing root', async () => { + await expect(fs.rmdir('')).rejects.toThrow('Cannot remove root directory'); + }); + }); + + describe('readdir', () => { + it('lists directory contents', async () => { + await fs.writeFile('dir/file1.txt', 'content1'); + await fs.writeFile('dir/file2.txt', 'content2'); + await fs.writeFile('dir/subdir/nested.txt', 'nested'); + + const entries = await fs.readdir('dir'); + expect(entries.sort()).toEqual(['file1.txt', 'file2.txt', 'subdir'].sort()); + }); + + it('returns empty array for empty directory', async () => { + await fs.mkdir('emptydir'); + const entries = await fs.readdir('emptydir'); + expect(entries).toEqual([]); + }); + + it('throws ENOENT for non-existent directory', async () => { + try { + await fs.readdir('nonexistent'); + expect.fail('Should have thrown'); + } catch (err) { + expect((err as NodeJS.ErrnoException).code).toBe('ENOENT'); + } + }); + + it('throws ENOENT for file path', async () => { + await fs.writeFile('file.txt', 'content'); + try { + await fs.readdir('file.txt'); + expect.fail('Should have thrown'); + } catch (err) { + expect((err as NodeJS.ErrnoException).code).toBe('ENOENT'); + } + }); + + it('handles root directory', async () => { + await fs.writeFile('root-file.txt', 'content'); + await fs.mkdir('root-dir'); + + const entries = await fs.readdir(''); + expect(entries).toContain('root-file.txt'); + expect(entries).toContain('root-dir'); + }); + }); + + describe('stat', () => { + it('returns file stats', async () => { + await fs.writeFile('file.txt', 'Hello World'); + const stat = await fs.stat('file.txt'); + + expect(stat.type).toBe('file'); + expect(stat.mode).toBe(0o100644); + expect(stat.size).toBeGreaterThan(0); + expect(stat.mtimeMs).toBeGreaterThan(0); + expect(stat.isFile()).toBe(true); + expect(stat.isDirectory()).toBe(false); + expect(stat.isSymbolicLink()).toBe(false); + }); + + it('returns directory stats', async () => { + await fs.mkdir('mydir'); + const stat = await fs.stat('mydir'); + + expect(stat.type).toBe('dir'); + expect(stat.mode).toBe(0o040755); + expect(stat.size).toBe(0); + expect(stat.isFile()).toBe(false); + expect(stat.isDirectory()).toBe(true); + }); + + it('throws ENOENT for non-existent path', async () => { + try { + await fs.stat('nonexistent'); + expect.fail('Should have thrown'); + } catch (err) { + expect((err as NodeJS.ErrnoException).code).toBe('ENOENT'); + } + }); + + it('approximates file size from base64 encoding', async () => { + const content = 'A'.repeat(100); + await fs.writeFile('sized.txt', content); + const stat = await fs.stat('sized.txt'); + + // Size should be approximately the original content length + expect(stat.size).toBeGreaterThan(50); + expect(stat.size).toBeLessThan(150); + }); + }); + + describe('lstat', () => { + it('delegates to stat', async () => { + await fs.writeFile('file.txt', 'content'); + const stat = await fs.stat('file.txt'); + const lstat = await fs.lstat('file.txt'); + + expect(lstat.type).toBe(stat.type); + expect(lstat.mode).toBe(stat.mode); + }); + }); + + describe('symlink and readlink', () => { + it('symlink writes target as file content', async () => { + await fs.symlink('/target/path', 'link'); + const content = await fs.readFile('link', { encoding: 'utf8' }); + expect(content).toBe('/target/path'); + }); + + it('readlink returns file content as string', async () => { + await fs.writeFile('link', '/target/path'); + const target = await fs.readlink('link'); + expect(target).toBe('/target/path'); + }); + }); + + describe('exists', () => { + it('returns true for existing file', async () => { + await fs.writeFile('file.txt', 'content'); + const exists = await fs.exists('file.txt'); + expect(exists).toBe(true); + }); + + it('returns true for existing directory', async () => { + await fs.mkdir('mydir'); + const exists = await fs.exists('mydir'); + expect(exists).toBe(true); + }); + + it('returns false for non-existent path', async () => { + const exists = await fs.exists('nonexistent'); + expect(exists).toBe(false); + }); + }); + + describe('write', () => { + it('is an alias for writeFile', async () => { + await fs.write('via-write.txt', 'content'); + const content = await fs.readFile('via-write.txt', { encoding: 'utf8' }); + expect(content).toBe('content'); + }); + }); + + describe('getStorageStats', () => { + it('returns zero stats for empty filesystem', () => { + const stats = fs.getStorageStats(); + expect(stats.totalObjects).toBe(0); + expect(stats.totalBytes).toBe(0); + expect(stats.largestObject).toBeNull(); + }); + + it('tracks object count and sizes', async () => { + await fs.writeFile('small.txt', 'small'); + await fs.writeFile('large.txt', 'A'.repeat(1000)); + + const stats = fs.getStorageStats(); + expect(stats.totalObjects).toBe(2); + expect(stats.totalBytes).toBeGreaterThan(0); + expect(stats.largestObject?.path).toBe('large.txt'); + }); + }); + + describe('exportGitObjects', () => { + it('exports only .git/ prefixed files', async () => { + await fs.writeFile('.git/config', '[core]'); + await fs.writeFile('.git/objects/ab/cdef', 'blob'); + await fs.writeFile('src/index.ts', 'code'); + + const objects = fs.exportGitObjects(); + + expect(objects.length).toBe(2); + expect(objects.every(o => o.path.startsWith('.git/'))).toBe(true); + expect(objects.find(o => o.path === 'src/index.ts')).toBeUndefined(); + }); + + it('returns binary data for each object', async () => { + await fs.writeFile('.git/HEAD', 'ref: refs/heads/main'); + const objects = fs.exportGitObjects(); + + expect(objects[0].data).toBeInstanceOf(Uint8Array); + }); + + it('returns empty array when no git objects', () => { + const objects = fs.exportGitObjects(); + expect(objects).toEqual([]); + }); + }); +}); diff --git a/cloudflare-app-builder/src/git/fs-adapter.ts b/cloudflare-app-builder/src/git/fs-adapter.ts index e75518530..fdd9817f1 100644 --- a/cloudflare-app-builder/src/git/fs-adapter.ts +++ b/cloudflare-app-builder/src/git/fs-adapter.ts @@ -339,9 +339,15 @@ export class SqliteFS { void this.sql`DELETE FROM git_objects WHERE path = ${normalized}`; } - async stat( - path: string - ): Promise<{ type: 'file' | 'dir'; mode: number; size: number; mtimeMs: number }> { + async stat(path: string): Promise<{ + type: 'file' | 'dir'; + mode: number; + size: number; + mtimeMs: number; + isFile: () => boolean; + isDirectory: () => boolean; + isSymbolicLink: () => boolean; + }> { const normalized = path.replace(/^\/+/, ''); const result = this.sql<{ data: string; diff --git a/cloudflare-app-builder/src/git/git-clone-service.test.ts b/cloudflare-app-builder/src/git/git-clone-service.test.ts new file mode 100644 index 000000000..80a856336 --- /dev/null +++ b/cloudflare-app-builder/src/git/git-clone-service.test.ts @@ -0,0 +1,316 @@ +/** + * Unit tests for GitCloneService + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { GitCloneService } from './git-clone-service'; +import { MemFS } from './memfs'; +import git from '@ashishkumar472/cf-git'; + +describe('GitCloneService', () => { + describe('buildRepository', () => { + it('creates empty repo when no git objects provided', async () => { + const fs = await GitCloneService.buildRepository({ + gitObjects: [], + }); + + expect(fs).toBeInstanceOf(MemFS); + + // Should have .git directory + const stat = await fs.stat('.git'); + expect(stat.type).toBe('dir'); + }); + + it('builds repository from exported git objects', async () => { + // First create a repo with some commits + const sourceFs = new MemFS(); + await git.init({ fs: sourceFs, dir: '/', defaultBranch: 'main' }); + await sourceFs.writeFile('test.txt', 'content'); + await git.add({ fs: sourceFs, dir: '/', filepath: 'test.txt' }); + await git.commit({ + fs: sourceFs, + dir: '/', + message: 'Initial commit', + author: { name: 'Test', email: 'test@example.com', timestamp: Date.now() / 1000 }, + }); + + // Export git objects + const gitObjects: Array<{ path: string; data: Uint8Array }> = []; + const files = (sourceFs as unknown as { files: Map }).files; + for (const [path, data] of files.entries()) { + if (path.startsWith('.git/')) { + gitObjects.push({ path, data }); + } + } + + // Build new repository from exported objects + const targetFs = await GitCloneService.buildRepository({ gitObjects }); + + // Verify HEAD exists + const head = await git.resolveRef({ fs: targetFs, dir: '/', ref: 'HEAD' }); + expect(head).toBeTruthy(); + expect(head.length).toBe(40); + }); + + it('throws error on invalid git objects', async () => { + // This tests error handling - providing corrupt data + const invalidObjects = [{ path: '.git/invalid', data: new Uint8Array([255, 255, 255]) }]; + + // Should still return a MemFS (init succeeds, import may have issues but shouldn't throw) + const fs = await GitCloneService.buildRepository({ gitObjects: invalidObjects }); + expect(fs).toBeInstanceOf(MemFS); + }); + }); + + describe('handleInfoRefs', () => { + let fs: MemFS; + + beforeEach(async () => { + fs = new MemFS(); + await git.init({ fs, dir: '/', defaultBranch: 'main' }); + }); + + it('returns service header and refs for empty repo', async () => { + // Create a commit so we have a ref + await fs.writeFile('test.txt', 'content'); + await git.add({ fs, dir: '/', filepath: 'test.txt' }); + await git.commit({ + fs, + dir: '/', + message: 'Initial commit', + author: { name: 'Test', email: 'test@example.com', timestamp: Date.now() / 1000 }, + }); + + const response = await GitCloneService.handleInfoRefs(fs); + + expect(response).toContain('# service=git-upload-pack'); + expect(response).toContain('HEAD'); + expect(response).toContain('refs/heads/main'); + }); + + it('includes git capabilities', async () => { + await fs.writeFile('test.txt', 'content'); + await git.add({ fs, dir: '/', filepath: 'test.txt' }); + await git.commit({ + fs, + dir: '/', + message: 'Initial commit', + author: { name: 'Test', email: 'test@example.com', timestamp: Date.now() / 1000 }, + }); + + const response = await GitCloneService.handleInfoRefs(fs); + + expect(response).toContain('side-band-64k'); + expect(response).toContain('thin-pack'); + expect(response).toContain('ofs-delta'); + }); + + it('includes branch refs', async () => { + await fs.writeFile('test.txt', 'content'); + await git.add({ fs, dir: '/', filepath: 'test.txt' }); + const oid = await git.commit({ + fs, + dir: '/', + message: 'Initial commit', + author: { name: 'Test', email: 'test@example.com', timestamp: Date.now() / 1000 }, + }); + + // Create additional branch + await git.writeRef({ fs, dir: '/', ref: 'refs/heads/feature', value: oid }); + + const response = await GitCloneService.handleInfoRefs(fs); + + expect(response).toContain('refs/heads/main'); + expect(response).toContain('refs/heads/feature'); + }); + + it('ends with flush packet', async () => { + await fs.writeFile('test.txt', 'content'); + await git.add({ fs, dir: '/', filepath: 'test.txt' }); + await git.commit({ + fs, + dir: '/', + message: 'Initial commit', + author: { name: 'Test', email: 'test@example.com', timestamp: Date.now() / 1000 }, + }); + + const response = await GitCloneService.handleInfoRefs(fs); + + expect(response.endsWith('0000')).toBe(true); + }); + }); + + describe('handleUploadPack', () => { + let fs: MemFS; + + beforeEach(async () => { + fs = new MemFS(); + await git.init({ fs, dir: '/', defaultBranch: 'main' }); + }); + + it('returns packfile for repository with commits', async () => { + await fs.writeFile('test.txt', 'content'); + await git.add({ fs, dir: '/', filepath: 'test.txt' }); + await git.commit({ + fs, + dir: '/', + message: 'Initial commit', + author: { name: 'Test', email: 'test@example.com', timestamp: Date.now() / 1000 }, + }); + + const packfile = await GitCloneService.handleUploadPack(fs); + + expect(packfile).toBeInstanceOf(Uint8Array); + expect(packfile.length).toBeGreaterThan(0); + }); + + it('includes NAK packet at start', async () => { + await fs.writeFile('test.txt', 'content'); + await git.add({ fs, dir: '/', filepath: 'test.txt' }); + await git.commit({ + fs, + dir: '/', + message: 'Initial commit', + author: { name: 'Test', email: 'test@example.com', timestamp: Date.now() / 1000 }, + }); + + const packfile = await GitCloneService.handleUploadPack(fs); + + // NAK packet is "0008NAK\n" + const decoder = new TextDecoder(); + const start = decoder.decode(packfile.slice(0, 8)); + expect(start).toBe('0008NAK\n'); + }); + + it('includes objects from all branches', async () => { + // Create first commit on main + await fs.writeFile('main.txt', 'main content'); + await git.add({ fs, dir: '/', filepath: 'main.txt' }); + const mainOid = await git.commit({ + fs, + dir: '/', + message: 'Main commit', + author: { name: 'Test', email: 'test@example.com', timestamp: Date.now() / 1000 }, + }); + + // Create feature branch with another commit + await git.writeRef({ fs, dir: '/', ref: 'refs/heads/feature', value: mainOid }); + await fs.writeFile('feature.txt', 'feature content'); + await git.add({ fs, dir: '/', filepath: 'feature.txt' }); + await git.commit({ + fs, + dir: '/', + message: 'Feature commit', + author: { name: 'Test', email: 'test@example.com', timestamp: Date.now() / 1000 }, + }); + + const packfile = await GitCloneService.handleUploadPack(fs); + + // Should have objects from both branches + expect(packfile.length).toBeGreaterThan(100); + }); + + it('ends with flush packet', async () => { + await fs.writeFile('test.txt', 'content'); + await git.add({ fs, dir: '/', filepath: 'test.txt' }); + await git.commit({ + fs, + dir: '/', + message: 'Initial commit', + author: { name: 'Test', email: 'test@example.com', timestamp: Date.now() / 1000 }, + }); + + const packfile = await GitCloneService.handleUploadPack(fs); + + // Should end with flush packet "0000" + const decoder = new TextDecoder(); + const end = decoder.decode(packfile.slice(-4)); + expect(end).toBe('0000'); + }); + }); + + describe('formatPacketLine', () => { + it('formats packet line with correct length prefix', () => { + // Access private method via class prototype + const formatPacketLine = ( + GitCloneService as unknown as { + formatPacketLine: (data: string) => string; + } + ).formatPacketLine; + + const result = formatPacketLine('test data\n'); + // length = "test data\n" (10) + 4 = 14 = 0x0e + expect(result).toBe('000etest data\n'); + }); + + it('handles empty string', () => { + const formatPacketLine = ( + GitCloneService as unknown as { + formatPacketLine: (data: string) => string; + } + ).formatPacketLine; + + const result = formatPacketLine(''); + // length = 0 + 4 = 4 = 0x0004 + expect(result).toBe('0004'); + }); + }); + + describe('wrapInSideband', () => { + it('wraps packfile in sideband-64k format', () => { + const wrapInSideband = ( + GitCloneService as unknown as { + wrapInSideband: (packfile: Uint8Array) => Uint8Array; + } + ).wrapInSideband; + + const packfile = new Uint8Array([1, 2, 3, 4, 5]); + const result = wrapInSideband(packfile); + + // Should have length header (4 bytes) + band byte (1) + data + expect(result.length).toBeGreaterThan(packfile.length); + + // First 4 bytes are hex length + const decoder = new TextDecoder(); + const lengthHex = decoder.decode(result.slice(0, 4)); + expect(lengthHex).toMatch(/^[0-9a-f]{4}$/); + + // Band byte should be 0x01 (packfile data) + expect(result[4]).toBe(0x01); + + // Should end with flush packet + const end = decoder.decode(result.slice(-4)); + expect(end).toBe('0000'); + }); + + it('handles large packfiles with chunking', () => { + const wrapInSideband = ( + GitCloneService as unknown as { + wrapInSideband: (packfile: Uint8Array) => Uint8Array; + } + ).wrapInSideband; + + // Create packfile larger than 65515 bytes (CHUNK_SIZE) + const largePackfile = new Uint8Array(70000); + const result = wrapInSideband(largePackfile); + + // Should be split into multiple chunks + expect(result.length).toBeGreaterThan(largePackfile.length); + }); + + it('handles empty packfile', () => { + const wrapInSideband = ( + GitCloneService as unknown as { + wrapInSideband: (packfile: Uint8Array) => Uint8Array; + } + ).wrapInSideband; + + const emptyPackfile = new Uint8Array(0); + const result = wrapInSideband(emptyPackfile); + + // Should just be flush packet + const decoder = new TextDecoder(); + expect(decoder.decode(result)).toBe('0000'); + }); + }); +}); diff --git a/cloudflare-app-builder/src/git/git-receive-pack-service.test.ts b/cloudflare-app-builder/src/git/git-receive-pack-service.test.ts new file mode 100644 index 000000000..deeafdeb6 --- /dev/null +++ b/cloudflare-app-builder/src/git/git-receive-pack-service.test.ts @@ -0,0 +1,297 @@ +/** + * Unit tests for GitReceivePackService + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { GitReceivePackService } from './git-receive-pack-service'; +import { MemFS } from './memfs'; +import git from '@ashishkumar472/cf-git'; + +describe('GitReceivePackService', () => { + let fs: MemFS; + + beforeEach(async () => { + fs = new MemFS(); + await git.init({ fs, dir: '/', defaultBranch: 'main' }); + }); + + describe('parsePktLines', () => { + it('parses a single ref update command', () => { + const encoder = new TextEncoder(); + // Format: length (4 hex chars) + old-oid (40 chars) + space + new-oid (40 chars) + space + ref-name + newline + const oldOid = '0000000000000000000000000000000000000000'; + const newOid = 'abc1234567890123456789012345678901234567'; + const refName = 'refs/heads/main'; + const command = `${oldOid} ${newOid} ${refName}\n`; + const length = (command.length + 4).toString(16).padStart(4, '0'); + const pktLine = length + command + '0000'; // flush packet + + const data = encoder.encode(pktLine); + const result = GitReceivePackService.parsePktLines(data); + + expect(result.commands.length).toBe(1); + expect(result.commands[0].oldOid).toBe(oldOid); + expect(result.commands[0].newOid).toBe(newOid); + expect(result.commands[0].refName).toBe(refName); + }); + + it('parses command with capabilities (NUL byte)', () => { + const encoder = new TextEncoder(); + const oldOid = '0000000000000000000000000000000000000000'; + const newOid = 'abc1234567890123456789012345678901234567'; + const refName = 'refs/heads/main'; + const capabilities = 'report-status side-band-64k'; + const command = `${oldOid} ${newOid} ${refName}\0${capabilities}\n`; + const length = (command.length + 4).toString(16).padStart(4, '0'); + const pktLine = length + command + '0000'; + + const data = encoder.encode(pktLine); + const result = GitReceivePackService.parsePktLines(data); + + expect(result.commands.length).toBe(1); + expect(result.commands[0].refName).toBe(refName); + }); + + it('parses multiple ref update commands', () => { + const encoder = new TextEncoder(); + const oldOid = '0000000000000000000000000000000000000000'; + const newOid1 = 'abc1234567890123456789012345678901234567'; + const newOid2 = 'def1234567890123456789012345678901234567'; + + const cmd1 = `${oldOid} ${newOid1} refs/heads/main\n`; + const cmd2 = `${oldOid} ${newOid2} refs/heads/feature\n`; + + const pkt1 = (cmd1.length + 4).toString(16).padStart(4, '0') + cmd1; + const pkt2 = (cmd2.length + 4).toString(16).padStart(4, '0') + cmd2; + const pktLines = pkt1 + pkt2 + '0000'; + + const data = encoder.encode(pktLines); + const result = GitReceivePackService.parsePktLines(data); + + expect(result.commands.length).toBe(2); + expect(result.commands[0].refName).toBe('refs/heads/main'); + expect(result.commands[1].refName).toBe('refs/heads/feature'); + }); + + it('returns packfile start offset after flush packet', () => { + const encoder = new TextEncoder(); + const oldOid = '0000000000000000000000000000000000000000'; + const newOid = 'abc1234567890123456789012345678901234567'; + const command = `${oldOid} ${newOid} refs/heads/main\n`; + const length = (command.length + 4).toString(16).padStart(4, '0'); + + // Commands + flush + pack data + const packData = 'PACK...binary...'; + const pktLines = length + command + '0000' + packData; + + const data = encoder.encode(pktLines); + const result = GitReceivePackService.parsePktLines(data); + + expect(result.packfileStart).toBeGreaterThan(0); + // Packfile starts after flush packet (0000) + const expectedStart = (length + command + '0000').length; + expect(result.packfileStart).toBe(expectedStart); + }); + + it('ignores invalid ref update commands', () => { + const encoder = new TextEncoder(); + // Invalid: OID too short + const invalidCmd = 'shortoid shortoid refs/heads/main\n'; + const length = (invalidCmd.length + 4).toString(16).padStart(4, '0'); + const pktLine = length + invalidCmd + '0000'; + + const data = encoder.encode(pktLine); + const result = GitReceivePackService.parsePktLines(data); + + expect(result.commands.length).toBe(0); + }); + + it('handles empty input', () => { + const data = new Uint8Array(0); + const result = GitReceivePackService.parsePktLines(data); + + expect(result.commands.length).toBe(0); + expect(result.packfileStart).toBe(0); + }); + + it('handles flush packet only', () => { + const encoder = new TextEncoder(); + const data = encoder.encode('0000'); + const result = GitReceivePackService.parsePktLines(data); + + expect(result.commands.length).toBe(0); + expect(result.packfileStart).toBe(4); + }); + }); + + describe('handleInfoRefs', () => { + it('returns receive-pack service header for empty repo', async () => { + const response = await GitReceivePackService.handleInfoRefs(fs); + + expect(response).toContain('# service=git-receive-pack'); + expect(response).toContain('0000000000000000000000000000000000000000'); + expect(response).toContain('capabilities^{}'); + expect(response).toContain('report-status'); + }); + + it('returns refs for repository with commits', async () => { + // Create a commit + await fs.writeFile('test.txt', 'content'); + await git.add({ fs, dir: '/', filepath: 'test.txt' }); + const oid = await git.commit({ + fs, + dir: '/', + message: 'Test commit', + author: { name: 'Test', email: 'test@example.com', timestamp: Date.now() / 1000 }, + }); + + const response = await GitReceivePackService.handleInfoRefs(fs); + + expect(response).toContain('# service=git-receive-pack'); + expect(response).toContain(oid); + expect(response).toContain('refs/heads/main'); + }); + + it('includes required capabilities', async () => { + const response = await GitReceivePackService.handleInfoRefs(fs); + + expect(response).toContain('report-status'); + expect(response).toContain('delete-refs'); + expect(response).toContain('side-band-64k'); + expect(response).toContain('ofs-delta'); + }); + }); + + describe('handleReceivePack', () => { + it('returns success for valid ref update without packfile', async () => { + // Create initial commit + await fs.writeFile('test.txt', 'content'); + await git.add({ fs, dir: '/', filepath: 'test.txt' }); + const oid = await git.commit({ + fs, + dir: '/', + message: 'Initial', + author: { name: 'Test', email: 'test@example.com', timestamp: Date.now() / 1000 }, + }); + + const encoder = new TextEncoder(); + const zeroOid = '0000000000000000000000000000000000000000'; + const command = `${zeroOid} ${oid} refs/heads/feature\n`; + const length = (command.length + 4).toString(16).padStart(4, '0'); + const requestData = encoder.encode(length + command + '0000'); + + const { result } = await GitReceivePackService.handleReceivePack(fs, requestData); + + expect(result.success).toBe(true); + expect(result.refUpdates.length).toBe(1); + expect(result.errors.length).toBe(0); + }); + + it('returns error for oversized packfile', async () => { + const encoder = new TextEncoder(); + const zeroOid = '0000000000000000000000000000000000000000'; + const newOid = 'abc1234567890123456789012345678901234567'; + const command = `${zeroOid} ${newOid} refs/heads/main\n`; + const length = (command.length + 4).toString(16).padStart(4, '0'); + + // Create oversized packfile (> MAX_OBJECT_SIZE) + const packHeader = 'PACK'; + const oversizedData = 'x'.repeat(900 * 1024); // > 850KB limit + const requestData = encoder.encode(length + command + '0000' + packHeader + oversizedData); + + const { result } = await GitReceivePackService.handleReceivePack(fs, requestData); + + expect(result.success).toBe(false); + expect(result.errors.some(e => e.includes('Packfile too large'))).toBe(true); + }); + + it('returns response with unpack status', async () => { + await fs.writeFile('test.txt', 'content'); + await git.add({ fs, dir: '/', filepath: 'test.txt' }); + const oid = await git.commit({ + fs, + dir: '/', + message: 'Initial', + author: { name: 'Test', email: 'test@example.com', timestamp: Date.now() / 1000 }, + }); + + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const zeroOid = '0000000000000000000000000000000000000000'; + const command = `${zeroOid} ${oid} refs/heads/feature\n`; + const length = (command.length + 4).toString(16).padStart(4, '0'); + const requestData = encoder.encode(length + command + '0000'); + + const { response } = await GitReceivePackService.handleReceivePack(fs, requestData); + + const responseText = decoder.decode(response); + expect(responseText).toContain('unpack ok'); + }); + + it('handles delete ref command', async () => { + // Create initial commit and branch + await fs.writeFile('test.txt', 'content'); + await git.add({ fs, dir: '/', filepath: 'test.txt' }); + const oid = await git.commit({ + fs, + dir: '/', + message: 'Initial', + author: { name: 'Test', email: 'test@example.com', timestamp: Date.now() / 1000 }, + }); + + // Create a feature branch + await git.writeRef({ fs, dir: '/', ref: 'refs/heads/feature', value: oid }); + + const encoder = new TextEncoder(); + const zeroOid = '0000000000000000000000000000000000000000'; + // Delete by setting new oid to zero + const command = `${oid} ${zeroOid} refs/heads/feature\n`; + const length = (command.length + 4).toString(16).padStart(4, '0'); + const requestData = encoder.encode(length + command + '0000'); + + const { result } = await GitReceivePackService.handleReceivePack(fs, requestData); + + // Note: delete may fail if the ref doesn't exist in expected format + // The important thing is that the command is recognized as a delete + expect(result.refUpdates[0].newOid).toBe(zeroOid); + }); + }); + + describe('exportGitObjects', () => { + it('exports git objects from MemFS', async () => { + await fs.writeFile('.git/config', '[core]'); + await fs.writeFile('.git/HEAD', 'ref: refs/heads/main'); + await fs.writeFile('src/index.ts', 'code'); // non-git file + + const objects = GitReceivePackService.exportGitObjects(fs); + + expect(objects.length).toBe(2); + expect(objects.every(o => o.path.startsWith('.git/'))).toBe(true); + expect(objects.some(o => o.path === '.git/config')).toBe(true); + expect(objects.some(o => o.path === '.git/HEAD')).toBe(true); + }); + + it('returns empty array when no git objects', async () => { + const emptyFs = new MemFS(); + const objects = GitReceivePackService.exportGitObjects(emptyFs); + + expect(objects).toEqual([]); + }); + }); + + describe('packet formatting', () => { + it('formatPacketLine creates correct format', () => { + // Access private method via class prototype for testing + const formatPacketLine = ( + GitReceivePackService as unknown as { + formatPacketLine: (data: string) => string; + } + ).formatPacketLine; + + const result = formatPacketLine('ok refs/heads/main\n'); + // length = content (20) + 4 = 24 = 0x18 + expect(result.slice(0, 4)).toMatch(/^[0-9a-f]{4}$/); + expect(result).toContain('ok refs/heads/main'); + }); + }); +}); diff --git a/cloudflare-app-builder/src/git/git.test.ts b/cloudflare-app-builder/src/git/git.test.ts new file mode 100644 index 000000000..7fdd5a38a --- /dev/null +++ b/cloudflare-app-builder/src/git/git.test.ts @@ -0,0 +1,525 @@ +/** + * Unit tests for GitVersionControl + * + * Note: Some tests are skipped as they involve complex isomorphic-git operations + * that may hang in the test environment due to async filesystem interactions. + */ + +import { describe, it, expect, beforeEach, afterAll } from 'vitest'; +import { GitVersionControl } from './git'; +import { createTestSqlExecutor, closeAllDatabases } from './test-utils'; + +// Clean up all database connections after all tests +afterAll(() => { + closeAllDatabases(); +}); + +describe('GitVersionControl', () => { + let git: GitVersionControl; + + beforeEach(() => { + const sql = createTestSqlExecutor(); + git = new GitVersionControl(sql, { name: 'Test User', email: 'test@example.com' }); + }); + + describe('init', () => { + it('initializes a new git repository', async () => { + await git.init(); + const initialized = await git.isInitialized(); + expect(initialized).toBe(true); + }); + + it('handles re-initialization gracefully', async () => { + await git.init(); + await git.init(); // Should not throw + const initialized = await git.isInitialized(); + expect(initialized).toBe(true); + }); + }); + + describe('isInitialized', () => { + it('returns false for uninitialized repository', async () => { + const initialized = await git.isInitialized(); + expect(initialized).toBe(false); + }); + + it('returns true after initialization', async () => { + await git.init(); + const initialized = await git.isInitialized(); + expect(initialized).toBe(true); + }); + }); + + describe('commit', () => { + beforeEach(async () => { + await git.init(); + }); + + it('creates a commit with files', async () => { + const oid = await git.commit( + [{ filePath: 'test.txt', fileContents: 'Hello World' }], + 'Initial commit' + ); + expect(oid).toBeTruthy(); + expect(typeof oid).toBe('string'); + expect(oid?.length).toBe(40); // SHA-1 hash + }); + + it('creates multiple commits', async () => { + const oid1 = await git.commit( + [{ filePath: 'file1.txt', fileContents: 'First file' }], + 'First commit' + ); + const oid2 = await git.commit( + [{ filePath: 'file2.txt', fileContents: 'Second file' }], + 'Second commit' + ); + + expect(oid1).toBeTruthy(); + expect(oid2).toBeTruthy(); + expect(oid1).not.toBe(oid2); + }); + }); + + describe('stage', () => { + beforeEach(async () => { + await git.init(); + }); + + it('stages files without committing', async () => { + await git.stage([{ filePath: 'test.txt', fileContents: 'staged content' }]); + + // Verify file is written to filesystem + const content = await git.fs.readFile('test.txt', { encoding: 'utf8' }); + expect(content).toBe('staged content'); + }); + + it('handles empty file list', async () => { + await expect(git.stage([])).resolves.toBeUndefined(); + }); + + it('normalizes paths with leading slashes', async () => { + await git.stage([{ filePath: '/leading-slash.txt', fileContents: 'content' }]); + const content = await git.fs.readFile('leading-slash.txt', { encoding: 'utf8' }); + expect(content).toBe('content'); + }); + }); + + describe('log', () => { + beforeEach(async () => { + await git.init(); + }); + + it('returns empty array for repository with no commits', async () => { + const logs = await git.log(); + expect(logs).toEqual([]); + }); + + it('returns commit history', async () => { + await git.commit([{ filePath: 'test.txt', fileContents: 'content' }], 'Test commit'); + + const logs = await git.log(); + expect(logs.length).toBe(1); + expect(logs[0].message.trim()).toBe('Test commit'); + expect(logs[0].author).toContain('Test User'); + expect(logs[0].oid).toBeTruthy(); + }); + }); + + describe('getHead', () => { + beforeEach(async () => { + await git.init(); + }); + + it('returns null for repository with no commits', async () => { + const head = await git.getHead(); + expect(head).toBeNull(); + }); + + it('returns HEAD commit oid', async () => { + const commitOid = await git.commit( + [{ filePath: 'test.txt', fileContents: 'content' }], + 'Test commit' + ); + + const head = await git.getHead(); + expect(head).toBe(commitOid); + }); + }); + + describe('show', () => { + beforeEach(async () => { + await git.init(); + }); + + it('returns commit info for initial commit', async () => { + const oid = await git.commit( + [ + { filePath: 'file1.txt', fileContents: 'content1' }, + { filePath: 'file2.txt', fileContents: 'content2' }, + ], + 'Initial commit' + ); + + const result = await git.show(oid!); + expect(result.oid).toBe(oid); + expect(result.message.trim()).toBe('Initial commit'); + expect(result.files).toBe(2); + expect(result.fileList).toContain('file1.txt'); + expect(result.fileList).toContain('file2.txt'); + }); + }); + + describe('getAllFilesFromHead', () => { + beforeEach(async () => { + await git.init(); + }); + + it('returns empty array for repository with no commits', async () => { + const files = await git.getAllFilesFromHead(); + expect(files).toEqual([]); + }); + + it('returns all files from HEAD', async () => { + await git.commit( + [ + { filePath: 'file1.txt', fileContents: 'content1' }, + { filePath: 'dir/file2.txt', fileContents: 'content2' }, + ], + 'Add files' + ); + + const files = await git.getAllFilesFromHead(); + expect(files.length).toBe(2); + + const paths = files.map(f => f.filePath); + expect(paths).toContain('file1.txt'); + expect(paths).toContain('dir/file2.txt'); + }); + }); + + describe('createInitialCommit', () => { + it('creates initial commit with binary files', async () => { + const encoder = new TextEncoder(); + const files = { + 'readme.md': encoder.encode('# Project'), + 'src/index.ts': encoder.encode('export const x = 1;'), + }; + + const oid = await git.createInitialCommit(files); + expect(oid).toBeTruthy(); + + const allFiles = await git.getAllFilesFromHead(); + const paths = allFiles.map(f => f.filePath); + expect(paths).toContain('readme.md'); + expect(paths).toContain('src/index.ts'); + }); + }); + + describe('getStorageStats', () => { + beforeEach(async () => { + await git.init(); + }); + + it('returns stats after commits', async () => { + await git.commit( + [ + { filePath: 'small.txt', fileContents: 'small' }, + { filePath: 'large.txt', fileContents: 'a'.repeat(1000) }, + ], + 'Add files' + ); + + const stats = git.getStorageStats(); + expect(stats.totalObjects).toBeGreaterThan(0); + expect(stats.totalBytes).toBeGreaterThan(0); + }); + }); + + describe('getTree', () => { + beforeEach(async () => { + await git.init(); + }); + + it('returns tree entries at root', async () => { + await git.commit( + [ + { filePath: 'file.txt', fileContents: 'content' }, + { filePath: 'dir/nested.txt', fileContents: 'nested content' }, + ], + 'Add files' + ); + + const result = await git.getTree('HEAD'); + expect(result.entries.length).toBe(2); + + const fileEntry = result.entries.find(e => e.name === 'file.txt'); + expect(fileEntry?.type).toBe('blob'); + + const dirEntry = result.entries.find(e => e.name === 'dir'); + expect(dirEntry?.type).toBe('tree'); + }); + + it('throws for non-existent path', async () => { + await git.commit([{ filePath: 'file.txt', fileContents: 'content' }], 'Add file'); + + await expect(git.getTree('HEAD', 'nonexistent')).rejects.toThrow('Path not found'); + }); + }); + + describe('getBlob', () => { + beforeEach(async () => { + await git.init(); + }); + + it('returns blob content', async () => { + await git.commit([{ filePath: 'test.txt', fileContents: 'Hello World' }], 'Add file'); + + const result = await git.getBlob('HEAD', 'test.txt'); + const content = new TextDecoder().decode(result.content); + expect(content).toBe('Hello World'); + expect(result.size).toBe(11); + }); + + it('throws for non-existent file', async () => { + await git.commit([{ filePath: 'file.txt', fileContents: 'content' }], 'Add file'); + + await expect(git.getBlob('HEAD', 'nonexistent.txt')).rejects.toThrow('File not found'); + }); + + it('throws when path is empty', async () => { + await git.commit([{ filePath: 'file.txt', fileContents: 'content' }], 'Add file'); + + await expect(git.getBlob('HEAD', '')).rejects.toThrow('Path is required'); + }); + }); + + describe('exportGitObjects', () => { + it('exports git objects after commit', async () => { + await git.init(); + await git.commit( + [ + { filePath: 'readme.md', fileContents: '# Project' }, + { filePath: 'src/index.ts', fileContents: 'export const x = 1;' }, + ], + 'Initial commit' + ); + + const objects = git.exportGitObjects(); + expect(objects.length).toBeGreaterThan(0); + + // All exported objects should be under .git/ + expect(objects.every(o => o.path.startsWith('.git/'))).toBe(true); + }); + }); + + // Note: reset() tests are skipped because git.checkout() can hang in test environment + // due to async filesystem interactions with isomorphic-git + describe.skip('reset', () => { + beforeEach(async () => { + await git.init(); + }); + + it('resets HEAD to a specific commit', async () => { + const oid1 = await git.commit( + [{ filePath: 'file1.txt', fileContents: 'first' }], + 'First commit' + ); + await git.commit([{ filePath: 'file2.txt', fileContents: 'second' }], 'Second commit'); + + const result = await git.reset(oid1!); + + expect(result.ref).toBe(oid1); + const head = await git.getHead(); + expect(head).toBe(oid1); + }); + + it('performs hard reset by default', async () => { + const oid1 = await git.commit( + [{ filePath: 'file1.txt', fileContents: 'first' }], + 'First commit' + ); + await git.commit([{ filePath: 'file2.txt', fileContents: 'second' }], 'Second commit'); + + const result = await git.reset(oid1!); + + expect(result.filesReset).toBeGreaterThan(0); + }); + + it('resets using branch name', async () => { + await git.commit([{ filePath: 'file.txt', fileContents: 'content' }], 'Initial commit'); + + const result = await git.reset('main'); + + expect(result.ref).toBeTruthy(); + expect(result.filesReset).toBeGreaterThan(0); + }); + }); + + describe('show with includeDiff', () => { + beforeEach(async () => { + await git.init(); + }); + + it('returns diffs when includeDiff is true', async () => { + await git.commit([{ filePath: 'file.txt', fileContents: 'original' }], 'First commit'); + const oid2 = await git.commit( + [{ filePath: 'file.txt', fileContents: 'modified' }], + 'Second commit' + ); + + const result = await git.show(oid2!, { includeDiff: true }); + + expect(result.diffs).toBeDefined(); + expect(result.diffs?.length).toBeGreaterThan(0); + expect(result.diffs?.[0].path).toBe('file.txt'); + expect(result.diffs?.[0].diff).toContain('original'); + expect(result.diffs?.[0].diff).toContain('modified'); + }); + + it('does not include diffs when includeDiff is false', async () => { + await git.commit([{ filePath: 'file.txt', fileContents: 'original' }], 'First commit'); + const oid2 = await git.commit( + [{ filePath: 'file.txt', fileContents: 'modified' }], + 'Second commit' + ); + + const result = await git.show(oid2!, { includeDiff: false }); + + expect(result.diffs).toBeUndefined(); + }); + + it('shows diff for added files', async () => { + await git.commit([{ filePath: 'file1.txt', fileContents: 'first' }], 'First commit'); + const oid2 = await git.commit( + [{ filePath: 'file2.txt', fileContents: 'second file' }], + 'Second commit' + ); + + const result = await git.show(oid2!, { includeDiff: true }); + + expect(result.diffs).toBeDefined(); + const newFileDiff = result.diffs?.find(d => d.path === 'file2.txt'); + expect(newFileDiff).toBeDefined(); + expect(newFileDiff?.diff).toContain('second file'); + }); + }); + + describe('importGitObjects', () => { + it('imports git objects into a new repository', async () => { + // Create source repo + const sql1 = createTestSqlExecutor(); + const sourceGit = new GitVersionControl(sql1, { name: 'Test', email: 'test@example.com' }); + await sourceGit.init(); + await sourceGit.commit([{ filePath: 'test.txt', fileContents: 'content' }], 'Initial commit'); + + // Export objects + const objects = sourceGit.exportGitObjects(); + + // Import into target repo + await git.importGitObjects(objects); + + // Verify import + const initialized = await git.isInitialized(); + expect(initialized).toBe(true); + }); + + it('initializes repo if not already initialized', async () => { + const initialized = await git.isInitialized(); + expect(initialized).toBe(false); + + await git.importGitObjects([]); + + const afterImport = await git.isInitialized(); + expect(afterImport).toBe(true); + }); + }); + + describe('commit edge cases', () => { + beforeEach(async () => { + await git.init(); + }); + + it('generates auto-checkpoint message when message is omitted', async () => { + const oid = await git.commit([{ filePath: 'file.txt', fileContents: 'content' }]); + + const logs = await git.log(); + expect(logs[0].message).toContain('Auto-checkpoint'); + }); + + it('creates commit with staged files', async () => { + await git.stage([{ filePath: 'staged.txt', fileContents: 'staged content' }]); + const oid = await git.commit([], 'Commit staged files'); + + // Commit can still succeed if there are staged changes + expect(oid).toBeTruthy(); + }); + }); + + describe('getTree edge cases', () => { + beforeEach(async () => { + await git.init(); + }); + + it('returns tree entries at subdirectory path', async () => { + await git.commit( + [ + { filePath: 'src/index.ts', fileContents: 'index' }, + { filePath: 'src/utils/helper.ts', fileContents: 'helper' }, + ], + 'Add files' + ); + + const result = await git.getTree('HEAD', 'src'); + + expect(result.entries.length).toBe(2); + expect(result.entries.some(e => e.name === 'index.ts')).toBe(true); + expect(result.entries.some(e => e.name === 'utils')).toBe(true); + }); + + it('throws when path is a file not directory', async () => { + await git.commit([{ filePath: 'file.txt', fileContents: 'content' }], 'Add file'); + + await expect(git.getTree('HEAD', 'file.txt')).rejects.toThrow('Path is not a directory'); + }); + }); + + describe('getBlob edge cases', () => { + beforeEach(async () => { + await git.init(); + }); + + it('returns blob for nested file', async () => { + await git.commit( + [{ filePath: 'src/deep/nested/file.txt', fileContents: 'deep content' }], + 'Add file' + ); + + const result = await git.getBlob('HEAD', 'src/deep/nested/file.txt'); + const content = new TextDecoder().decode(result.content); + expect(content).toBe('deep content'); + }); + + it('throws when trying to get directory as blob', async () => { + await git.commit([{ filePath: 'src/index.ts', fileContents: 'content' }], 'Add file'); + + await expect(git.getBlob('HEAD', 'src')).rejects.toThrow('directory, not a file'); + }); + }); + + // Note: setOnFilesChangedCallback test with reset is skipped because git.checkout() hangs + describe.skip('setOnFilesChangedCallback', () => { + it('calls callback after reset', async () => { + await git.init(); + let callbackCalled = false; + git.setOnFilesChangedCallback(() => { + callbackCalled = true; + }); + + await git.commit([{ filePath: 'file.txt', fileContents: 'content' }], 'Initial commit'); + await git.reset('main'); + + expect(callbackCalled).toBe(true); + }); + }); +}); diff --git a/cloudflare-app-builder/src/git/git.ts b/cloudflare-app-builder/src/git/git.ts index 33eff488f..c0f759ff1 100644 --- a/cloudflare-app-builder/src/git/git.ts +++ b/cloudflare-app-builder/src/git/git.ts @@ -5,7 +5,15 @@ import git from '@ashishkumar472/cf-git'; import { SqliteFS } from './fs-adapter'; import * as Diff from 'diff'; -import type { CommitInfo, FileDiff, GitShowResult, SqlExecutor } from '../types'; +import type { + CommitInfo, + FileDiff, + GitShowResult, + SqlExecutor, + TreeEntry, + TreeResult, + BlobResult, +} from '../types'; /** * Represents a file snapshot with path and contents @@ -357,4 +365,189 @@ export class GitVersionControl { } { return this.fs.getStorageStats(); } + + /** + * Check if the repository has been initialized (has a .git directory) + */ + async isInitialized(): Promise { + try { + await this.fs.stat('.git'); + return true; + } catch { + return false; + } + } + + /** + * Create initial commit with the provided files + * @param files - Record of path to content (Uint8Array) + */ + async createInitialCommit(files: Record): Promise { + // Ensure repo is initialized + await this.init(); + + console.log(`[Git] Creating initial commit with ${Object.keys(files).length} files`); + + // Write files and stage them + for (const [path, content] of Object.entries(files)) { + const normalizedPath = this.normalizePath(path); + await this.fs.writeFile(normalizedPath, content); + await git.add({ ...this.gitConfig, filepath: normalizedPath }); + } + + // Commit + const oid = await git.commit({ + ...this.gitConfig, + message: 'Initial commit', + author: { + name: this.author.name, + email: this.author.email, + timestamp: Math.floor(Date.now() / 1000), + }, + }); + + console.log(`[Git] Initial commit created: ${oid}`); + return oid; + } + + /** + * Export all git objects for cloning + * Returns raw binary data for each object + */ + exportGitObjects(): Array<{ path: string; data: Uint8Array }> { + return this.fs.exportGitObjects(); + } + + /** + * Import git objects (e.g., from a push operation) + * Writes all objects to the filesystem + */ + async importGitObjects(objects: Array<{ path: string; data: Uint8Array }>): Promise { + // Ensure repo is initialized + const initialized = await this.isInitialized(); + if (!initialized) { + await this.init(); + } + + console.log(`[Git] Importing ${objects.length} git objects`); + + for (const obj of objects) { + await this.fs.writeFile(obj.path, obj.data); + } + + console.log('[Git] Git objects imported successfully'); + } + + /** + * Get directory tree contents at a specific ref and path + * @param ref - Branch name (e.g., "main", "HEAD") or commit SHA + * @param path - Optional path to a subdirectory (defaults to root) + */ + async getTree(ref: string, path?: string): Promise { + // Resolve ref to commit OID + const commitOid = await git.resolveRef({ ...this.gitConfig, ref }); + + // Read commit to get root tree OID + const commit = await git.readCommit({ ...this.gitConfig, oid: commitOid }); + let treeOid = commit.commit.tree; + + // Navigate to subdirectory if path is provided + const normalizedPath = path?.replace(/^\/+|\/+$/g, '') || ''; + if (normalizedPath) { + const pathParts = normalizedPath.split('/'); + + for (const part of pathParts) { + const tree = await git.readTree({ ...this.gitConfig, oid: treeOid }); + const entry = tree.tree.find((e: { path: string }) => e.path === part); + + if (!entry) { + throw new Error(`Path not found: ${normalizedPath}`); + } + + if (entry.type !== 'tree') { + throw new Error(`Path is not a directory: ${normalizedPath}`); + } + + treeOid = entry.oid; + } + } + + // Read tree entries at current path + const tree = await git.readTree({ ...this.gitConfig, oid: treeOid }); + + const entries: TreeEntry[] = tree.tree.map( + (entry: { path: string; type: string; oid: string; mode: string }) => ({ + name: entry.path, + type: entry.type as 'blob' | 'tree', + oid: entry.oid, + mode: entry.mode, + }) + ); + + return { entries, sha: commitOid }; + } + + /** + * Get blob (file) contents at a specific ref and path + * @param ref - Branch name (e.g., "main", "HEAD") or commit SHA + * @param path - Path to the file + */ + async getBlob(ref: string, path: string): Promise { + // Resolve ref to commit OID + const commitOid = await git.resolveRef({ ...this.gitConfig, ref }); + + // Read commit to get root tree OID + const commit = await git.readCommit({ ...this.gitConfig, oid: commitOid }); + let treeOid = commit.commit.tree; + + // Navigate to file + const normalizedPath = path.replace(/^\/+|\/+$/g, ''); + if (!normalizedPath) { + throw new Error('Path is required for blob'); + } + + const pathParts = normalizedPath.split('/'); + const fileName = pathParts.pop(); + + if (!fileName) { + throw new Error('Invalid path'); + } + + // Navigate to parent directory + for (const part of pathParts) { + const tree = await git.readTree({ ...this.gitConfig, oid: treeOid }); + const entry = tree.tree.find((e: { path: string }) => e.path === part); + + if (!entry) { + throw new Error(`Path not found: ${normalizedPath}`); + } + + if (entry.type !== 'tree') { + throw new Error(`Path is not a directory: ${part}`); + } + + treeOid = entry.oid; + } + + // Find the blob entry + const tree = await git.readTree({ ...this.gitConfig, oid: treeOid }); + const blobEntry = tree.tree.find((e: { path: string }) => e.path === fileName); + + if (!blobEntry) { + throw new Error(`File not found: ${normalizedPath}`); + } + + if (blobEntry.type !== 'blob') { + throw new Error(`Path is a directory, not a file: ${normalizedPath}`); + } + + // Read blob content + const blob = await git.readBlob({ ...this.gitConfig, oid: blobEntry.oid }); + + return { + content: blob.blob, + size: blob.blob.length, + sha: blobEntry.oid, + }; + } } diff --git a/cloudflare-app-builder/src/git/index.ts b/cloudflare-app-builder/src/git/index.ts index fe8e4ffef..88fcdf070 100644 --- a/cloudflare-app-builder/src/git/index.ts +++ b/cloudflare-app-builder/src/git/index.ts @@ -15,4 +15,7 @@ export type { CommitInfo, FileDiff, GitShowResult, + TreeEntry, + TreeResult, + BlobResult, } from '../types'; diff --git a/cloudflare-app-builder/src/git/memfs.test.ts b/cloudflare-app-builder/src/git/memfs.test.ts new file mode 100644 index 000000000..3d1dfff90 --- /dev/null +++ b/cloudflare-app-builder/src/git/memfs.test.ts @@ -0,0 +1,322 @@ +/** + * Unit tests for MemFS in-memory filesystem + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { MemFS } from './memfs'; + +describe('MemFS', () => { + let fs: MemFS; + + beforeEach(() => { + fs = new MemFS(); + }); + + describe('constructor', () => { + it('sets promises property to self for isomorphic-git compatibility', () => { + expect((fs as unknown as { promises: MemFS }).promises).toBe(fs); + }); + }); + + describe('writeFile', () => { + it('writes string content', async () => { + await fs.writeFile('test.txt', 'Hello World'); + const content = await fs.readFile('test.txt', { encoding: 'utf8' }); + expect(content).toBe('Hello World'); + }); + + it('writes binary content', async () => { + const data = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" + await fs.writeFile('binary.bin', data); + const content = await fs.readFile('binary.bin'); + expect(content).toBeInstanceOf(Uint8Array); + expect(Array.from(content as Uint8Array)).toEqual([72, 101, 108, 108, 111]); + }); + + it('normalizes paths with leading slashes', async () => { + await fs.writeFile('/leading-slash.txt', 'content'); + const content = await fs.readFile('leading-slash.txt', { encoding: 'utf8' }); + expect(content).toBe('content'); + }); + + it('overwrites existing file', async () => { + await fs.writeFile('file.txt', 'original'); + await fs.writeFile('file.txt', 'updated'); + const content = await fs.readFile('file.txt', { encoding: 'utf8' }); + expect(content).toBe('updated'); + }); + + it('handles nested paths', async () => { + await fs.writeFile('a/b/c/file.txt', 'nested'); + const content = await fs.readFile('a/b/c/file.txt', { encoding: 'utf8' }); + expect(content).toBe('nested'); + }); + }); + + describe('readFile', () => { + it('returns Uint8Array by default', async () => { + await fs.writeFile('test.txt', 'Hello'); + const content = await fs.readFile('test.txt'); + expect(content).toBeInstanceOf(Uint8Array); + }); + + it('returns string with utf8 encoding', async () => { + await fs.writeFile('test.txt', 'Hello'); + const content = await fs.readFile('test.txt', { encoding: 'utf8' }); + expect(typeof content).toBe('string'); + expect(content).toBe('Hello'); + }); + + it('throws ENOENT for non-existent file', async () => { + try { + await fs.readFile('nonexistent.txt'); + expect.fail('Should have thrown'); + } catch (err) { + expect((err as NodeJS.ErrnoException).code).toBe('ENOENT'); + } + }); + + it('handles paths with leading slashes', async () => { + await fs.writeFile('file.txt', 'content'); + const content = await fs.readFile('/file.txt', { encoding: 'utf8' }); + expect(content).toBe('content'); + }); + }); + + describe('readdir', () => { + it('lists directory contents', async () => { + await fs.writeFile('dir/file1.txt', 'content1'); + await fs.writeFile('dir/file2.txt', 'content2'); + await fs.writeFile('dir/subdir/nested.txt', 'nested'); + + const entries = await fs.readdir('dir'); + expect(entries.sort()).toEqual(['file1.txt', 'file2.txt', 'subdir'].sort()); + }); + + it('handles root directory with slash', async () => { + await fs.writeFile('root-file.txt', 'content'); + + const entries = await fs.readdir('/'); + expect(entries).toContain('root-file.txt'); + }); + + it('handles root directory with empty string', async () => { + await fs.writeFile('root-file.txt', 'content'); + + const entries = await fs.readdir(''); + expect(entries).toContain('root-file.txt'); + }); + + it('returns empty array for directory with no children', async () => { + await fs.writeFile('other/file.txt', 'content'); + + // 'emptydir' has no files - readdir should return empty + const entries = await fs.readdir('emptydir'); + expect(entries).toEqual([]); + }); + + it('normalizes paths with leading slashes', async () => { + await fs.writeFile('mydir/file.txt', 'content'); + + const entries = await fs.readdir('/mydir'); + expect(entries).toContain('file.txt'); + }); + }); + + describe('stat', () => { + it('returns file stats', async () => { + await fs.writeFile('file.txt', 'Hello World'); + const stat = await fs.stat('file.txt'); + + expect(stat.type).toBe('file'); + expect(stat.mode).toBe(0o100644); + expect(stat.size).toBe(11); + expect(stat.isFile()).toBe(true); + expect(stat.isDirectory()).toBe(false); + expect(stat.isSymbolicLink()).toBe(false); + }); + + it('returns directory stats', async () => { + await fs.writeFile('mydir/file.txt', 'content'); + const stat = await fs.stat('mydir'); + + expect(stat.type).toBe('dir'); + expect(stat.mode).toBe(0o040755); + expect(stat.size).toBe(0); + expect(stat.isFile()).toBe(false); + expect(stat.isDirectory()).toBe(true); + }); + + it('throws ENOENT for non-existent path', async () => { + try { + await fs.stat('nonexistent'); + expect.fail('Should have thrown'); + } catch (err) { + expect((err as NodeJS.ErrnoException).code).toBe('ENOENT'); + } + }); + + it('normalizes paths with leading slashes', async () => { + await fs.writeFile('file.txt', 'content'); + const stat = await fs.stat('/file.txt'); + expect(stat.type).toBe('file'); + }); + + it('includes timestamp properties', async () => { + await fs.writeFile('file.txt', 'content'); + const stat = await fs.stat('file.txt'); + + expect(stat.mtimeMs).toBeGreaterThan(0); + expect(stat.ctimeMs).toBeGreaterThan(0); + expect(stat.ctime).toBeInstanceOf(Date); + expect(stat.mtime).toBeInstanceOf(Date); + }); + }); + + describe('lstat', () => { + it('delegates to stat', async () => { + await fs.writeFile('file.txt', 'content'); + const stat = await fs.stat('file.txt'); + const lstat = await fs.lstat('file.txt'); + + expect(lstat.type).toBe(stat.type); + expect(lstat.mode).toBe(stat.mode); + expect(lstat.size).toBe(stat.size); + }); + }); + + describe('mkdir', () => { + it('is a no-op (directories are implicit)', async () => { + await fs.mkdir('somedir'); + // No error should occur, directories are implicit in MemFS + }); + + it('accepts options parameter', async () => { + await fs.mkdir('somedir', { recursive: true }); + // No error should occur + }); + }); + + describe('rmdir', () => { + it('is a no-op', async () => { + await fs.writeFile('dir/file.txt', 'content'); + await fs.rmdir('dir'); + // No error should occur - rmdir is no-op in MemFS + }); + }); + + describe('rename', () => { + it('renames a file', async () => { + await fs.writeFile('old.txt', 'content'); + await fs.rename('old.txt', 'new.txt'); + + const content = await fs.readFile('new.txt', { encoding: 'utf8' }); + expect(content).toBe('content'); + + // Old file should no longer exist + try { + await fs.readFile('old.txt'); + expect.fail('Should have thrown'); + } catch (err) { + expect((err as NodeJS.ErrnoException).code).toBe('ENOENT'); + } + }); + + it('normalizes paths with leading slashes', async () => { + await fs.writeFile('file.txt', 'content'); + await fs.rename('/file.txt', '/renamed.txt'); + + const content = await fs.readFile('renamed.txt', { encoding: 'utf8' }); + expect(content).toBe('content'); + }); + + it('does nothing if source does not exist', async () => { + await fs.rename('nonexistent.txt', 'new.txt'); + // No error, just no-op + }); + }); + + describe('chmod', () => { + it('is a no-op', async () => { + await fs.writeFile('file.txt', 'content'); + await fs.chmod('file.txt', 0o755); + // No error should occur - chmod is no-op in MemFS + }); + }); + + describe('readlink', () => { + it('throws error (symlinks not supported)', async () => { + await expect(fs.readlink('anypath')).rejects.toThrow('Symbolic links not supported'); + }); + }); + + describe('symlink', () => { + it('throws error (symlinks not supported)', async () => { + await expect(fs.symlink('/target', '/link')).rejects.toThrow('Symbolic links not supported'); + }); + }); + + describe('unlink', () => { + it('deletes a file', async () => { + await fs.writeFile('to-delete.txt', 'content'); + await fs.unlink('to-delete.txt'); + + try { + await fs.readFile('to-delete.txt'); + expect.fail('Should have thrown'); + } catch (err) { + expect((err as NodeJS.ErrnoException).code).toBe('ENOENT'); + } + }); + + it('normalizes paths with leading slashes', async () => { + await fs.writeFile('file.txt', 'content'); + await fs.unlink('/file.txt'); + + try { + await fs.readFile('file.txt'); + expect.fail('Should have thrown'); + } catch (err) { + expect((err as NodeJS.ErrnoException).code).toBe('ENOENT'); + } + }); + + it('does nothing for non-existent file', async () => { + await fs.unlink('nonexistent.txt'); + // No error - just deletes from map (no-op if not present) + }); + }); + + describe('edge cases', () => { + it('handles empty file content', async () => { + await fs.writeFile('empty.txt', ''); + const content = await fs.readFile('empty.txt', { encoding: 'utf8' }); + expect(content).toBe(''); + }); + + it('handles binary data with null bytes', async () => { + const data = new Uint8Array([0, 1, 2, 0, 3, 4, 0]); + await fs.writeFile('binary.bin', data); + const content = await fs.readFile('binary.bin'); + expect(Array.from(content as Uint8Array)).toEqual([0, 1, 2, 0, 3, 4, 0]); + }); + + it('handles deeply nested paths', async () => { + const path = 'a/b/c/d/e/f/g/h/file.txt'; + await fs.writeFile(path, 'deep'); + const content = await fs.readFile(path, { encoding: 'utf8' }); + expect(content).toBe('deep'); + + // Intermediate directories should be detected + const stat = await fs.stat('a/b/c'); + expect(stat.type).toBe('dir'); + }); + + it('handles unicode content', async () => { + const unicode = 'δ½ ε₯½δΈ–η•Œ 🌍 Ω…Ψ±Ψ­Ψ¨Ψ§'; + await fs.writeFile('unicode.txt', unicode); + const content = await fs.readFile('unicode.txt', { encoding: 'utf8' }); + expect(content).toBe(unicode); + }); + }); +}); diff --git a/cloudflare-app-builder/src/git/test-utils.ts b/cloudflare-app-builder/src/git/test-utils.ts new file mode 100644 index 000000000..1709374c5 --- /dev/null +++ b/cloudflare-app-builder/src/git/test-utils.ts @@ -0,0 +1,72 @@ +/** + * Test utilities for GitVersionControl unit tests + * + * Provides a SqlExecutor implementation backed by better-sqlite3 + * for realistic SQLite testing in Node.js. + */ + +import Database, { Database as DatabaseType } from 'better-sqlite3'; +import type { SqlExecutor } from '../types'; + +type SqlExecutorWithClose = SqlExecutor & { close: () => void }; + +// Track all databases for cleanup +const databases: DatabaseType[] = []; + +/** + * Creates an in-memory SQLite database and returns a SqlExecutor + * compatible with SqliteFS and GitVersionControl. + */ +export function createTestSqlExecutor(): SqlExecutorWithClose { + const db = new Database(':memory:'); + databases.push(db); + + const executor = (( + query: TemplateStringsArray, + ...values: (string | number | boolean | null)[] + ): T[] => { + // Reconstruct the SQL query with placeholders + let sql = ''; + for (let i = 0; i < query.length; i++) { + sql += query[i]; + if (i < values.length) { + sql += '?'; + } + } + + // Handle statements without return values (CREATE, INSERT, UPDATE, DELETE) + const trimmedSql = sql.trim().toUpperCase(); + if ( + trimmedSql.startsWith('CREATE') || + trimmedSql.startsWith('INSERT') || + trimmedSql.startsWith('UPDATE') || + trimmedSql.startsWith('DELETE') + ) { + db.prepare(sql).run(...values); + return [] as T[]; + } + + // SELECT statements return rows + return db.prepare(sql).all(...values) as T[]; + }) as SqlExecutorWithClose; + + executor.close = () => { + db.close(); + }; + + return executor; +} + +/** + * Close all databases opened during tests + */ +export function closeAllDatabases(): void { + for (const db of databases) { + try { + db.close(); + } catch { + // Ignore - might already be closed + } + } + databases.length = 0; +} diff --git a/cloudflare-app-builder/src/handlers/files.ts b/cloudflare-app-builder/src/handlers/files.ts new file mode 100644 index 000000000..e75d2a9e4 --- /dev/null +++ b/cloudflare-app-builder/src/handlers/files.ts @@ -0,0 +1,133 @@ +/** + * File tree and blob endpoint handlers + * GET /apps/{app_id}/tree/{ref} - Get directory contents + * GET /apps/{app_id}/blob/{ref}/{path} - Get file content + */ + +import { verifyBearerToken } from '../utils/auth'; +import { logger } from '../utils/logger'; +import type { Env } from '../types'; + +/** + * Handle GET /apps/{app_id}/tree/{ref} request + * + * Lists directory contents at the specified path for a given ref. + * @param request - The incoming request + * @param env - Environment bindings + * @param appId - The application/repository ID + * @param ref - Branch name (e.g., "main", "HEAD") or commit SHA + */ +export async function handleGetTree( + request: Request, + env: Env, + appId: string, + ref: string +): Promise { + const authResult = verifyBearerToken(request, env); + if (!authResult.isAuthenticated) { + if (!authResult.errorResponse) { + return new Response('Unauthorized', { status: 401 }); + } + return authResult.errorResponse; + } + + const url = new URL(request.url); + const path = url.searchParams.get('path') || ''; + + const id = env.GIT_REPOSITORY.idFromName(appId); + const stub = env.GIT_REPOSITORY.get(id); + + try { + const result = await stub.getTree(ref, path); + return new Response( + JSON.stringify({ + entries: result.entries, + path, + ref, + commitSha: result.commitSha, + }), + { + headers: { 'Content-Type': 'application/json' }, + } + ); + } catch (error) { + logger.error('Failed to get tree', { + appId, + ref, + path, + error: error instanceof Error ? error.message : String(error), + }); + return new Response( + JSON.stringify({ + error: 'not_found', + message: error instanceof Error ? error.message : 'Path not found', + }), + { + status: 404, + headers: { 'Content-Type': 'application/json' }, + } + ); + } +} + +/** + * Handle GET /apps/{app_id}/blob/{ref}/{path} request + * + * Returns the content of a file at the specified path for a given ref. + * @param request - The incoming request + * @param env - Environment bindings + * @param appId - The application/repository ID + * @param ref - Branch name (e.g., "main", "HEAD") or commit SHA + * @param path - Path to the file within the repository + */ +export async function handleGetBlob( + request: Request, + env: Env, + appId: string, + ref: string, + path: string +): Promise { + const authResult = verifyBearerToken(request, env); + if (!authResult.isAuthenticated) { + if (!authResult.errorResponse) { + return new Response('Unauthorized', { status: 401 }); + } + return authResult.errorResponse; + } + + const id = env.GIT_REPOSITORY.idFromName(appId); + const stub = env.GIT_REPOSITORY.get(id); + + try { + const result = await stub.getBlob(ref, path); + return new Response( + JSON.stringify({ + content: result.content, + encoding: result.encoding, + size: result.size, + path, + sha: result.sha, + }), + { + headers: { 'Content-Type': 'application/json' }, + } + ); + } catch (error) { + logger.error('Failed to get blob', { + appId, + ref, + path, + error: error instanceof Error ? error.message : String(error), + }); + return new Response( + JSON.stringify({ + error: 'not_found', + message: error instanceof Error ? error.message : 'File not found', + }), + { + status: 404, + headers: { 'Content-Type': 'application/json' }, + } + ); + } +} diff --git a/cloudflare-app-builder/src/handlers/git-protocol.ts b/cloudflare-app-builder/src/handlers/git-protocol.ts index e07c89cae..193cf6ab2 100644 --- a/cloudflare-app-builder/src/handlers/git-protocol.ts +++ b/cloudflare-app-builder/src/handlers/git-protocol.ts @@ -18,10 +18,11 @@ export type { Env } from '../types'; /** * Git protocol route patterns + * Note: App ID pattern requires 20+ characters to match the main router in index.ts */ -const GIT_INFO_REFS_PATTERN = /^\/apps\/([a-z0-9_-]+)\.git\/info\/refs$/; -const GIT_UPLOAD_PACK_PATTERN = /^\/apps\/([a-z0-9_-]+)\.git\/git-upload-pack$/; -const GIT_RECEIVE_PACK_PATTERN = /^\/apps\/([a-z0-9_-]+)\.git\/git-receive-pack$/; +const GIT_INFO_REFS_PATTERN = /^\/apps\/([a-z0-9_-]{20,})\.git\/info\/refs$/; +const GIT_UPLOAD_PACK_PATTERN = /^\/apps\/([a-z0-9_-]{20,})\.git\/git-upload-pack$/; +const GIT_RECEIVE_PACK_PATTERN = /^\/apps\/([a-z0-9_-]{20,})\.git\/git-receive-pack$/; /** * Check if request is a Git protocol request @@ -75,14 +76,25 @@ async function handleInfoRefs( // Check if repository is initialized (RPC call) const isInitialized = await repoStub.isInitialized(); - // For receive-pack on empty repo, we need to advertise capabilities - // For upload-pack on empty repo, return empty advertisement + // For upload-pack or receive-pack on non-initialized repo, return 404 + // Repositories must be explicitly initialized via the /init endpoint + // This prevents auto-initialization after deletion if (!isInitialized) { - if (isReceivePack) { - // For receive-pack, initialize the repo first and return empty refs with capabilities (RPC call) - await repoStub.initialize(); + return new Response('Repository not found', { status: 404 }); + } + + // Get git objects from DO (RPC call) + const gitObjects = await repoStub.exportGitObjects(); - // Return empty repo advertisement for receive-pack + // Convert base64 data back to Uint8Array + const processedObjects = gitObjects.map(obj => ({ + path: obj.path, + data: Uint8Array.from(atob(obj.data), c => c.charCodeAt(0)), + })); + + if (processedObjects.length === 0) { + if (isReceivePack) { + // Return empty repo advertisement for receive-pack (allows initial push) const capabilities = 'report-status report-status-v2 delete-refs side-band-64k quiet atomic ofs-delta agent=git/isomorphic-git'; const zeroOid = '0000000000000000000000000000000000000000'; @@ -98,30 +110,17 @@ async function handleInfoRefs( }, }); } else { - return new Response('Repository not found', { status: 404 }); + // Return empty advertisement for repos with no commits (upload-pack) + return new Response('001e# service=git-upload-pack\n0000', { + status: 200, + headers: { + 'Content-Type': 'application/x-git-upload-pack-advertisement', + 'Cache-Control': 'no-cache', + }, + }); } } - // Get git objects from DO (RPC call) - const gitObjects = await repoStub.exportGitObjects(); - - // Convert base64 data back to Uint8Array - const processedObjects = gitObjects.map(obj => ({ - path: obj.path, - data: Uint8Array.from(atob(obj.data), c => c.charCodeAt(0)), - })); - - if (processedObjects.length === 0 && !isReceivePack) { - // Return empty advertisement for repos with no commits (upload-pack only) - return new Response('001e# service=git-upload-pack\n0000', { - status: 200, - headers: { - 'Content-Type': 'application/x-git-upload-pack-advertisement', - 'Cache-Control': 'no-cache', - }, - }); - } - // Build repository in worker const repoFS = await GitCloneService.buildRepository({ gitObjects: processedObjects, @@ -224,12 +223,13 @@ async function handleReceivePack( // Check if repository is initialized (RPC call) const isInitialized = await repoStub.isInitialized(); - // For push to new repo, initialize first (RPC call) + // Repositories must be explicitly initialized via the /init endpoint + // This prevents auto-initialization after deletion if (!isInitialized) { - await repoStub.initialize(); + return new Response('Repository not found', { status: 404 }); } - // Get existing git objects from DO (may be empty for new repo) (RPC call) + // Get existing git objects from DO (RPC call) const existingObjects = await repoStub.exportGitObjects(); // Convert base64 data back to Uint8Array diff --git a/cloudflare-app-builder/src/index.ts b/cloudflare-app-builder/src/index.ts index 86b1762a7..ac51195d6 100644 --- a/cloudflare-app-builder/src/index.ts +++ b/cloudflare-app-builder/src/index.ts @@ -13,6 +13,7 @@ import { handleStreamBuildLogs, handleTriggerBuild, } from './handlers/preview'; +import { handleGetTree, handleGetBlob } from './handlers/files'; import { logger, withLogTags } from './utils/logger'; // Export Durable Objects @@ -28,6 +29,8 @@ const PREVIEW_STATUS_PATTERN = new RegExp(`^/apps/(${APP_ID_PATTERN_STR})/previe const BUILD_TRIGGER_PATTERN = new RegExp(`^/apps/(${APP_ID_PATTERN_STR})/build$`); const BUILD_LOGS_PATTERN = new RegExp(`^/apps/(${APP_ID_PATTERN_STR})/build/logs$`); const DELETE_PATTERN = new RegExp(`^/apps/(${APP_ID_PATTERN_STR})$`); +const TREE_PATTERN = new RegExp(`^/apps/(${APP_ID_PATTERN_STR})/tree/([^/]+)$`); +const BLOB_PATTERN = new RegExp(`^/apps/(${APP_ID_PATTERN_STR})/blob/([^/]+)/(.+)$`); // Dev Mode let previewAppId: string | null = null; @@ -134,6 +137,21 @@ export default { return handleDelete(request, env, deleteMatch[1]); } + // Handle tree requests (GET /apps/{app_id}/tree/{ref}) + const treeMatch = pathname.match(TREE_PATTERN); + if (treeMatch && request.method === 'GET') { + const decodedRef = decodeURIComponent(treeMatch[2]); + return handleGetTree(request, env, treeMatch[1], decodedRef); + } + + // Handle blob requests (GET /apps/{app_id}/blob/{ref}/{path}) + const blobMatch = pathname.match(BLOB_PATTERN); + if (blobMatch && request.method === 'GET') { + const decodedRef = decodeURIComponent(blobMatch[2]); + const decodedPath = decodeURIComponent(blobMatch[3]); + return handleGetBlob(request, env, blobMatch[1], decodedRef, decodedPath); + } + // Handle git protocol requests if (isGitProtocolRequest(pathname)) { return handleGitProtocolRequest(request, env, ctx); diff --git a/cloudflare-app-builder/src/types.ts b/cloudflare-app-builder/src/types.ts index 8672985e2..b8f42e303 100644 --- a/cloudflare-app-builder/src/types.ts +++ b/cloudflare-app-builder/src/types.ts @@ -85,6 +85,25 @@ export interface GitShowResult { diffs?: FileDiff[]; } +export interface TreeEntry { + name: string; + type: 'blob' | 'tree'; + oid: string; + mode: string; +} + +export interface TreeResult { + entries: TreeEntry[]; + sha: string; +} + +/** Internal git blob result - raw binary content. The DO/API layer converts this to string + encoding. */ +export interface BlobResult { + content: Uint8Array; + size: number; + sha: string; +} + // ============================================ // Filesystem Error Types (Node.js-compatible for isomorphic-git) // ============================================ diff --git a/cloudflare-app-builder/vitest.config.ts b/cloudflare-app-builder/vitest.config.ts new file mode 100644 index 000000000..743858c06 --- /dev/null +++ b/cloudflare-app-builder/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts'], + testTimeout: 30000, + hookTimeout: 5000, + sequence: { + concurrent: false, + }, + reporters: ['verbose'], + }, +}); diff --git a/jest.config.ts b/jest.config.ts index 020a3f477..9c9586812 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -33,6 +33,7 @@ const config: Config = { '/cloud-agent/', '/cloudflare-webhook-agent-ingest/', '/cloudflare-session-ingest/', + '/cloudflare-app-builder/', ], transformIgnorePatterns: [ 'node_modules/.pnpm/(?!(@octokit|universal-user-agent|before-after-hook|bottleneck))', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f77222ba..46ca08906 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -192,7 +192,7 @@ importers: version: 1.11.19 drizzle-orm: specifier: ^0.44.7 - version: 0.44.7(@cloudflare/workers-types@4.20260130.0)(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(bun-types@1.3.8)(kysely@0.28.10)(pg@8.16.3(pg-native@3.5.2))(postgres@3.4.7) + version: 0.44.7(@cloudflare/workers-types@4.20260130.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.6.2)(bun-types@1.3.8)(kysely@0.28.10)(pg@8.16.3(pg-native@3.5.2))(postgres@3.4.7) event-source-polyfill: specifier: ^1.0.31 version: 1.0.31 @@ -560,18 +560,27 @@ importers: '@cloudflare/workers-types': specifier: ^4.20251014.0 version: 4.20251014.0 + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 '@types/jsonwebtoken': specifier: ^9.0.9 version: 9.0.10 '@types/node': specifier: ^22.10.1 version: 22.19.1 + better-sqlite3: + specifier: ^12.6.0 + version: 12.6.2 tsx: specifier: ^4.7.0 version: 4.20.6 typescript: specifier: ^5.9.3 version: 5.9.3 + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@22.19.1)(@vitest/ui@2.1.9)(lightningcss@1.30.2)(terser@5.44.0) wrangler: specifier: 4.45.3 version: 4.45.3(@cloudflare/workers-types@4.20251014.0) @@ -736,7 +745,7 @@ importers: version: 9.0.10 jest: specifier: ^30.2.0 - version: 30.2.0(@types/node@24.10.0)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@swc/core@1.12.5)(@types/node@24.10.0)(typescript@5.9.3)) + version: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@swc/core@1.12.5)(@types/node@22.19.1)(typescript@5.9.3)) typescript: specifier: ^5.3.3 version: 5.9.3 @@ -5989,6 +5998,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -7053,6 +7065,10 @@ packages: resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} engines: {node: '>=12.0.0'} + better-sqlite3@12.6.2: + resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + big.js@5.2.2: resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==} @@ -7254,6 +7270,9 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chromatic@13.3.3: resolution: {integrity: sha512-89w0hiFzIRqLbwGSkqSQzhbpuqaWpXYZuevSIF+570Wb+T/apeAkp3px8nMJcFw+zEdqw/i6soofkJtfirET1Q==} hasBin: true @@ -8031,6 +8050,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + endent@2.1.0: resolution: {integrity: sha512-r8VyPX7XL8U01Xgnb1CjZ3XV+z90cXIJ9JPE/R9SEC9vpw2P6CfsRPJmp20DppC5N7ZAMCmjYkJIa744Iyg96w==} @@ -8436,6 +8458,10 @@ packages: resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} engines: {node: '>= 0.8.0'} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expand-tilde@1.2.2: resolution: {integrity: sha512-rtmc+cjLZqnu9dSYosX9EWmSJhTwpACgJQTfj4hgg2JjOD/6SIQalZrt4a3aQeh++oNxkazcaxrhPUj6+g5G/Q==} engines: {node: '>=0.10.0'} @@ -8667,6 +8693,9 @@ packages: fromentries@1.3.2: resolution: {integrity: sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-exists-sync@0.1.0: resolution: {integrity: sha512-cR/vflFyPZtrN6b38ZyWxpWdhlXrzZEBawlpBQMq7033xVY7/kg0GDMBK5jg8lDYQckdJ5x/YC88lM3C7VMsLg==} engines: {node: '>=0.10.0'} @@ -8758,6 +8787,9 @@ packages: get-tsconfig@4.12.0: resolution: {integrity: sha512-LScr2aNr2FbjAjZh2C6X6BxRx1/x+aTDExct/xyq2XKbYOiG5c0aK7pMsSuyc0brz3ibr/lbQiHD9jzt4lccJw==} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -10358,6 +10390,9 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -10421,6 +10456,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -10474,6 +10512,10 @@ packages: no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + node-abi@3.87.0: + resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==} + engines: {node: '>=10'} + node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} @@ -11012,6 +11054,11 @@ packages: preact@10.27.2: resolution: {integrity: sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + hasBin: true + precinct@12.2.0: resolution: {integrity: sha512-NFBMuwIfaJ4SocE9YXPU/n4AcNSoFMVFjP72nvl3cx69j/ke61/hPOWFREVxLkFhhEGnA8ZuVfTqJBa+PK3b5w==} engines: {node: '>=18'} @@ -11153,6 +11200,9 @@ packages: public-encrypt@4.0.3: resolution: {integrity: sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==} + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + punycode@1.4.1: resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==} @@ -12043,6 +12093,13 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + tar-stream@3.1.7: resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} @@ -12259,6 +12316,9 @@ packages: tty-browserify@0.0.1: resolution: {integrity: sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -15642,42 +15702,6 @@ snapshots: - supports-color - ts-node - '@jest/core@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@swc/core@1.12.5)(@types/node@24.10.0)(typescript@5.9.3))': - dependencies: - '@jest/console': 30.2.0 - '@jest/pattern': 30.0.1 - '@jest/reporters': 30.2.0 - '@jest/test-result': 30.2.0 - '@jest/transform': 30.2.0 - '@jest/types': 30.2.0 - '@types/node': 22.19.1 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 4.3.1 - exit-x: 0.2.2 - graceful-fs: 4.2.11 - jest-changed-files: 30.2.0 - jest-config: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@swc/core@1.12.5)(@types/node@24.10.0)(typescript@5.9.3)) - jest-haste-map: 30.2.0 - jest-message-util: 30.2.0 - jest-regex-util: 30.0.1 - jest-resolve: 30.2.0 - jest-resolve-dependencies: 30.2.0 - jest-runner: 30.2.0 - jest-runtime: 30.2.0 - jest-snapshot: 30.2.0 - jest-util: 30.2.0 - jest-validate: 30.2.0 - jest-watcher: 30.2.0 - micromatch: 4.0.8 - pretty-format: 30.2.0 - slash: 3.0.0 - transitivePeerDependencies: - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - '@jest/create-cache-key-function@30.0.5': dependencies: '@jest/types': 30.0.5 @@ -18836,6 +18860,10 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 22.19.1 + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -19129,10 +19157,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.46.3(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.46.3 '@typescript-eslint/type-utils': 8.46.3(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/utils': 8.46.3(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) @@ -20123,6 +20151,11 @@ snapshots: dependencies: open: 8.4.2 + better-sqlite3@12.6.2: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + big.js@5.2.2: {} binary-extensions@2.3.0: {} @@ -20130,7 +20163,6 @@ snapshots: bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 - optional: true birpc@0.2.14: {} @@ -20361,6 +20393,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + chownr@1.1.4: {} + chromatic@13.3.3(@chromatic-com/playwright@0.12.8(@playwright/test@1.57.0)(@swc/core@1.12.5)(@testing-library/dom@10.4.1)(@types/react@19.2.2)(esbuild@0.25.12)(prettier@3.7.4)(typescript@5.9.3)(vite@6.3.5(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))): optionalDependencies: '@chromatic-com/playwright': 0.12.8(@playwright/test@1.57.0)(@swc/core@1.12.5)(@testing-library/dom@10.4.1)(@types/react@19.2.2)(esbuild@0.25.12)(prettier@3.7.4)(typescript@5.9.3)(vite@6.3.5(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) @@ -21001,11 +21035,13 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260130.0)(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(bun-types@1.3.8)(kysely@0.28.10)(pg@8.16.3(pg-native@3.5.2))(postgres@3.4.7): + drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260130.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.6.2)(bun-types@1.3.8)(kysely@0.28.10)(pg@8.16.3(pg-native@3.5.2))(postgres@3.4.7): optionalDependencies: '@cloudflare/workers-types': 4.20260130.0 '@opentelemetry/api': 1.9.0 + '@types/better-sqlite3': 7.6.13 '@types/pg': 8.15.6 + better-sqlite3: 12.6.2 bun-types: 1.3.8 kysely: 0.28.10 pg: 8.16.3(pg-native@3.5.2) @@ -21055,6 +21091,10 @@ snapshots: encodeurl@2.0.0: optional: true + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + endent@2.1.0: dependencies: dedent: 0.7.0 @@ -21422,8 +21462,8 @@ snapshots: '@typescript-eslint/parser': 8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1(jiti@2.6.1)) @@ -21446,7 +21486,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -21457,18 +21497,18 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -21483,7 +21523,7 @@ snapshots: eslint: 9.39.1(jiti@2.6.1) eslint-compat-utils: 0.5.1(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -21494,7 +21534,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -21829,6 +21869,8 @@ snapshots: exit@0.1.2: {} + expand-template@2.0.3: {} + expand-tilde@1.2.2: dependencies: os-homedir: 1.0.2 @@ -21959,8 +22001,7 @@ snapshots: dependencies: flat-cache: 4.0.1 - file-uri-to-path@1.0.0: - optional: true + file-uri-to-path@1.0.0: {} filesize@10.1.6: {} @@ -22121,6 +22162,8 @@ snapshots: fromentries@1.3.2: {} + fs-constants@1.0.0: {} + fs-exists-sync@0.1.0: {} fs-extra@10.1.0: @@ -22207,6 +22250,8 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + github-from-package@0.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -22997,25 +23042,6 @@ snapshots: - supports-color - ts-node - jest-cli@30.2.0(@types/node@24.10.0)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@swc/core@1.12.5)(@types/node@24.10.0)(typescript@5.9.3)): - dependencies: - '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@swc/core@1.12.5)(@types/node@24.10.0)(typescript@5.9.3)) - '@jest/test-result': 30.2.0 - '@jest/types': 30.2.0 - chalk: 4.1.2 - exit-x: 0.2.2 - import-local: 3.2.0 - jest-config: 30.2.0(@types/node@24.10.0)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@swc/core@1.12.5)(@types/node@24.10.0)(typescript@5.9.3)) - jest-util: 30.2.0 - jest-validate: 30.2.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jest-config@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@swc/core@1.12.5)(@types/node@22.19.1)(typescript@5.9.3)): dependencies: '@babel/core': 7.28.5 @@ -23146,74 +23172,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@swc/core@1.12.5)(@types/node@24.10.0)(typescript@5.9.3)): - dependencies: - '@babel/core': 7.28.5 - '@jest/get-type': 30.1.0 - '@jest/pattern': 30.0.1 - '@jest/test-sequencer': 30.2.0 - '@jest/types': 30.2.0 - babel-jest: 30.2.0(@babel/core@7.28.5) - chalk: 4.1.2 - ci-info: 4.3.1 - deepmerge: 4.3.1 - glob: 10.5.0 - graceful-fs: 4.2.11 - jest-circus: 30.2.0 - jest-docblock: 30.2.0 - jest-environment-node: 30.2.0 - jest-regex-util: 30.0.1 - jest-resolve: 30.2.0 - jest-runner: 30.2.0 - jest-util: 30.2.0 - jest-validate: 30.2.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 30.2.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 22.19.1 - esbuild-register: 3.6.0(esbuild@0.27.0) - ts-node: 10.9.2(@swc/core@1.12.5)(@types/node@24.10.0)(typescript@5.9.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-config@30.2.0(@types/node@24.10.0)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@swc/core@1.12.5)(@types/node@24.10.0)(typescript@5.9.3)): - dependencies: - '@babel/core': 7.28.5 - '@jest/get-type': 30.1.0 - '@jest/pattern': 30.0.1 - '@jest/test-sequencer': 30.2.0 - '@jest/types': 30.2.0 - babel-jest: 30.2.0(@babel/core@7.28.5) - chalk: 4.1.2 - ci-info: 4.3.1 - deepmerge: 4.3.1 - glob: 10.5.0 - graceful-fs: 4.2.11 - jest-circus: 30.2.0 - jest-docblock: 30.2.0 - jest-environment-node: 30.2.0 - jest-regex-util: 30.0.1 - jest-resolve: 30.2.0 - jest-runner: 30.2.0 - jest-util: 30.2.0 - jest-validate: 30.2.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 30.2.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 24.10.0 - esbuild-register: 3.6.0(esbuild@0.27.0) - ts-node: 10.9.2(@swc/core@1.12.5)(@types/node@24.10.0)(typescript@5.9.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-diff@29.7.0: dependencies: chalk: 4.1.2 @@ -23795,19 +23753,6 @@ snapshots: - supports-color - ts-node - jest@30.2.0(@types/node@24.10.0)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@swc/core@1.12.5)(@types/node@24.10.0)(typescript@5.9.3)): - dependencies: - '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@swc/core@1.12.5)(@types/node@24.10.0)(typescript@5.9.3)) - '@jest/types': 30.2.0 - import-local: 3.2.0 - jest-cli: 30.2.0(@types/node@24.10.0)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@swc/core@1.12.5)(@types/node@24.10.0)(typescript@5.9.3)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jiti@2.5.1: {} jiti@2.6.1: {} @@ -24821,6 +24766,8 @@ snapshots: minipass@7.1.2: {} + mkdirp-classic@0.5.3: {} + mkdirp@1.0.4: {} mlly@1.8.0: @@ -24875,6 +24822,8 @@ snapshots: nanoid@3.3.11: {} + napi-build-utils@2.0.0: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -24929,6 +24878,10 @@ snapshots: lower-case: 2.0.2 tslib: 2.8.1 + node-abi@3.87.0: + dependencies: + semver: 7.7.3 + node-abort-controller@3.1.1: {} node-fetch@2.7.0: @@ -25525,6 +25478,21 @@ snapshots: preact@10.27.2: {} + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.87.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + precinct@12.2.0: dependencies: '@dependents/detective-less': 5.0.1 @@ -25634,6 +25602,11 @@ snapshots: randombytes: 2.1.0 safe-buffer: 5.2.1 + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@1.4.1: {} punycode@2.3.1: {} @@ -26798,6 +26771,21 @@ snapshots: tapable@2.3.0: {} + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + tar-stream@3.1.7: dependencies: b4a: 1.7.3 @@ -26994,27 +26982,6 @@ snapshots: '@swc/core': 1.12.5 optional: true - ts-node@10.9.2(@swc/core@1.12.5)(@types/node@24.10.0)(typescript@5.9.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.12 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 24.10.0 - acorn: 8.15.0 - acorn-walk: 8.3.4 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 5.9.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - optionalDependencies: - '@swc/core': 1.12.5 - optional: true - tsconfig-paths-webpack-plugin@4.2.0: dependencies: chalk: 4.1.2 @@ -27046,6 +27013,10 @@ snapshots: tty-browserify@0.0.1: {} + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + tw-animate-css@1.4.0: {} type-check@0.4.0: @@ -27132,7 +27103,7 @@ snapshots: typescript-eslint@8.46.3(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.46.3(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.46.3(typescript@5.9.3) '@typescript-eslint/utils': 8.46.3(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a004bc987..d62dd66a0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -42,6 +42,7 @@ minimumReleaseAgeExclude: onlyBuiltDependencies: - '@swc/core' - '@tailwindcss/oxide' + - better-sqlite3 - core-js-pure - es5-ext - esbuild