From 37954170b5c54b7bf02d4138e97cc181d017bd3f Mon Sep 17 00:00:00 2001 From: chiboy948 Date: Sat, 27 Jun 2026 11:01:23 +0000 Subject: [PATCH] feat(playground): refactor dependency update automation for Smart Contract Playground - Add dependency-update.service.ts with automated update logic - Add dependencies.routes.ts and validation schemas for update endpoints - Register new dependency routes in index.ts - Add DependencyUpdatePanel component to playground UI - Add useDependencyUpdates hook for frontend state management - Add unit tests for service, routes, component, and hook --- backend/src/routes/dependencies.routes.ts | 37 +++ .../routes/dependencies.validation.schemas.ts | 22 ++ backend/src/routes/index.ts | 2 + .../src/services/dependency-update.service.ts | 176 +++++++++++++ .../tests/dependency-update.routes.test.ts | 108 ++++++++ .../tests/dependency-update.service.test.ts | 105 ++++++++ frontend/src/app/playground/page.tsx | 19 ++ .../playground/DependencyUpdatePanel.tsx | 238 ++++++++++++++++++ .../__tests__/DependencyUpdatePanel.test.tsx | 145 +++++++++++ .../__tests__/useDependencyUpdates.test.ts | 136 ++++++++++ frontend/src/hooks/useDependencyUpdates.ts | 95 +++++++ 11 files changed, 1083 insertions(+) create mode 100644 backend/src/routes/dependencies.routes.ts create mode 100644 backend/src/routes/dependencies.validation.schemas.ts create mode 100644 backend/src/services/dependency-update.service.ts create mode 100644 backend/tests/dependency-update.routes.test.ts create mode 100644 backend/tests/dependency-update.service.test.ts create mode 100644 frontend/src/components/playground/DependencyUpdatePanel.tsx create mode 100644 frontend/src/components/playground/__tests__/DependencyUpdatePanel.test.tsx create mode 100644 frontend/src/hooks/__tests__/useDependencyUpdates.test.ts create mode 100644 frontend/src/hooks/useDependencyUpdates.ts diff --git a/backend/src/routes/dependencies.routes.ts b/backend/src/routes/dependencies.routes.ts new file mode 100644 index 00000000..4343d5f5 --- /dev/null +++ b/backend/src/routes/dependencies.routes.ts @@ -0,0 +1,37 @@ +import { Router } from 'express'; +import { checkDependencies, updateDependencies } from '../services/dependency-update.service.js'; +import { validateRequest } from '../utils/validation.js'; +import logger from '../utils/logger.js'; +import { dependencyCheckSchema, dependencyUpdateSchema } from './dependencies.validation.schemas.js'; + +const router = Router(); + +/** + * POST /dependencies/check + * Parse a Cargo.toml and return outdated dependency information. + */ +router.post('/check', validateRequest(dependencyCheckSchema), async (req, res) => { + try { + const result = await checkDependencies(req.body.cargoToml); + res.json({ status: 'success', ...result }); + } catch (error) { + logger.error('Error checking dependencies', error); + res.status(500).json({ status: 'error', message: 'Unable to check dependencies' }); + } +}); + +/** + * POST /dependencies/update + * Apply selected dependency updates and return a suggested Cargo.toml. + */ +router.post('/update', validateRequest(dependencyUpdateSchema), async (req, res) => { + try { + const result = await updateDependencies(req.body.cargoToml, req.body.dependencies); + res.json({ status: 'success', ...result }); + } catch (error) { + logger.error('Error updating dependencies', error); + res.status(500).json({ status: 'error', message: 'Unable to update dependencies' }); + } +}); + +export default router; diff --git a/backend/src/routes/dependencies.validation.schemas.ts b/backend/src/routes/dependencies.validation.schemas.ts new file mode 100644 index 00000000..ae127e9d --- /dev/null +++ b/backend/src/routes/dependencies.validation.schemas.ts @@ -0,0 +1,22 @@ +// @ts-nocheck +import { z } from 'zod'; + +export const dependencyCheckSchema = z.object({ + cargoToml: z + .string() + .min(1, { message: 'Cargo.toml content is required.' }) + .max(50_000, { message: 'Cargo.toml must not exceed 50,000 characters.' }), +}); + +export const dependencyUpdateSchema = z.object({ + cargoToml: z + .string() + .min(1, { message: 'Cargo.toml content is required.' }) + .max(50_000, { message: 'Cargo.toml must not exceed 50,000 characters.' }), + dependencies: z + .array(z.string().min(1).max(128), { + invalid_type_error: 'Dependencies must be an array of strings.', + }) + .min(1, { message: 'At least one dependency name is required.' }) + .max(100, { message: 'Cannot update more than 100 dependencies at once.' }), +}); diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 7a1160e2..ab71d27d 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -19,6 +19,7 @@ import studentsRouter from './students.js'; import notificationRouter from '../notifications/notification.routes.js'; import notificationPreferencesRouter from '../notifications/preferences.routes.js'; import metricsRouter from './metrics.routes.js'; +import dependenciesRouter from './dependencies.routes.js'; import webhooksRouter from './webhooks.js'; @@ -43,5 +44,6 @@ router.use('/export', exportRouter); router.use('/webhooks', webhooksRouter); router.use('/user', userRouter); router.use('/metrics', metricsRouter); +router.use('/dependencies', dependenciesRouter); export default router; diff --git a/backend/src/services/dependency-update.service.ts b/backend/src/services/dependency-update.service.ts new file mode 100644 index 00000000..ae473b94 --- /dev/null +++ b/backend/src/services/dependency-update.service.ts @@ -0,0 +1,176 @@ +import logger from '../utils/logger.js'; + +export interface CargoTomlDependency { + name: string; + currentVersion: string; + latestVersion: string; + isOutdated: boolean; + updateType: 'major' | 'minor' | 'patch' | 'none'; + releaseNotes?: string; +} + +export interface DependencyCheckResult { + dependencies: CargoTomlDependency[]; + outdatedCount: number; + checkedAt: string; + cargoTomlHash: string; +} + +export interface DependencyUpdateResult { + updated: string[]; + failed: string[]; + suggestedCargoToml: string; +} + +// Simulated Soroban/Stellar Rust dependency registry (latest known versions) +const REGISTRY: Record = { + 'soroban-sdk': '22.0.7', + 'soroban-auth': '22.0.7', + 'stellar-xdr': '22.1.0', + 'num-integer': '0.1.46', + 'num-traits': '0.2.19', + 'serde': '1.0.219', + 'serde_json': '1.0.140', + 'base64': '0.22.1', + 'hex': '0.4.3', + 'sha2': '0.10.9', + 'hmac': '0.12.1', + 'ed25519-dalek': '2.1.1', +}; + +const RELEASE_NOTES: Record = { + 'soroban-sdk': 'Protocol 22 support, improved storage APIs, and security patches.', + 'stellar-xdr': 'Updated XDR definitions for Stellar Protocol 22.', + 'soroban-auth': 'Improved authorization framework compatibility with Protocol 22.', + 'serde': 'Performance improvements and new derive macro features.', +}; + +function compareVersions(a: string, b: string): 'major' | 'minor' | 'patch' | 'none' { + const [aMaj, aMin, aPat] = a.split('.').map(Number); + const [bMaj, bMin, bPat] = b.split('.').map(Number); + if (bMaj > aMaj) return 'major'; + if (bMin > aMin) return 'minor'; + if (bPat > aPat) return 'patch'; + return 'none'; +} + +function simpleHash(input: string): string { + let hash = 0; + for (let i = 0; i < input.length; i++) { + hash = (hash * 31 + input.charCodeAt(i)) >>> 0; + } + return hash.toString(16).padStart(8, '0'); +} + +/** + * Parse dependency lines from a Cargo.toml content string. + * Handles both `name = "version"` and `name = { version = "version", ... }` formats. + */ +export function parseCargoTomlDependencies(cargoToml: string): Array<{ name: string; version: string }> { + const deps: Array<{ name: string; version: string }> = []; + const inDepSection = /^\[dependencies\]/m.test(cargoToml); + if (!inDepSection) return deps; + + const sectionMatch = cargoToml.match(/\[dependencies\]([\s\S]*?)(?=\n\[|$)/); + if (!sectionMatch) return deps; + + const section = sectionMatch[1]; + const lines = section.split('\n'); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + // Simple: name = "version" + const simpleMatch = trimmed.match(/^([\w-]+)\s*=\s*"([^"]+)"/); + if (simpleMatch) { + deps.push({ name: simpleMatch[1], version: simpleMatch[2] }); + continue; + } + + // Inline table: name = { version = "...", ... } + const tableMatch = trimmed.match(/^([\w-]+)\s*=\s*\{[^}]*version\s*=\s*"([^"]+)"/); + if (tableMatch) { + deps.push({ name: tableMatch[1], version: tableMatch[2] }); + } + } + + return deps; +} + +export async function checkDependencies(cargoToml: string): Promise { + const parsed = parseCargoTomlDependencies(cargoToml); + const cargoTomlHash = simpleHash(cargoToml); + + const dependencies: CargoTomlDependency[] = parsed.map(({ name, version }) => { + const latestVersion = REGISTRY[name] ?? version; + const updateType = compareVersions(version, latestVersion); + return { + name, + currentVersion: version, + latestVersion, + isOutdated: updateType !== 'none', + updateType, + ...(RELEASE_NOTES[name] ? { releaseNotes: RELEASE_NOTES[name] } : {}), + }; + }); + + const outdatedCount = dependencies.filter((d) => d.isOutdated).length; + + logger.info('Dependency check completed', { + cargoTomlHash, + totalDeps: dependencies.length, + outdatedCount, + }); + + return { + dependencies, + outdatedCount, + checkedAt: new Date().toISOString(), + cargoTomlHash, + }; +} + +export async function updateDependencies( + cargoToml: string, + dependenciesToUpdate: string[] +): Promise { + const parsed = parseCargoTomlDependencies(cargoToml); + const updated: string[] = []; + const failed: string[] = []; + let updatedCargoToml = cargoToml; + + for (const depName of dependenciesToUpdate) { + const dep = parsed.find((d) => d.name === depName); + if (!dep) { + failed.push(depName); + continue; + } + const latestVersion = REGISTRY[dep.name]; + if (!latestVersion) { + failed.push(depName); + continue; + } + const escapedName = depName.replace(/[-]/g, '\\-'); + const escapedVersion = dep.version.replace(/\./g, '\\.'); + // Replace simple: name = "version" + updatedCargoToml = updatedCargoToml.replace( + new RegExp(`(${escapedName}\\s*=\\s*)"${escapedVersion}"`, 'g'), + `$1"${latestVersion}"` + ); + // Replace inline table: name = { version = "version", ... } + updatedCargoToml = updatedCargoToml.replace( + new RegExp(`(${escapedName}\\s*=\\s*\\{[^}]*version\\s*=\\s*)"${escapedVersion}"`, 'g'), + `$1"${latestVersion}"` + ); + updated.push(depName); + } + + logger.info('Dependency update completed', { + requested: dependenciesToUpdate.length, + updated: updated.length, + failed: failed.length, + }); + + return { updated, failed, suggestedCargoToml: updatedCargoToml }; +} diff --git a/backend/tests/dependency-update.routes.test.ts b/backend/tests/dependency-update.routes.test.ts new file mode 100644 index 00000000..df4e6229 --- /dev/null +++ b/backend/tests/dependency-update.routes.test.ts @@ -0,0 +1,108 @@ +import request from 'supertest'; +import { app } from '../src/index.js'; + +const VALID_CARGO_TOML = `[package] +name = "test-contract" +version = "0.1.0" +edition = "2021" + +[dependencies] +soroban-sdk = "21.7.6" +soroban-auth = "21.0.0" +`; + +describe('Dependencies API - POST /dependencies/check', () => { + it('returns dependency check results for valid Cargo.toml', async () => { + const res = await request(app) + .post('/api/v1/dependencies/check') + .send({ cargoToml: VALID_CARGO_TOML }) + .expect(200); + + expect(res.body.status).toBe('success'); + expect(Array.isArray(res.body.dependencies)).toBe(true); + expect(typeof res.body.outdatedCount).toBe('number'); + expect(res.body.checkedAt).toBeDefined(); + expect(res.body.cargoTomlHash).toBeDefined(); + }); + + it('identifies soroban-sdk as outdated', async () => { + const res = await request(app) + .post('/api/v1/dependencies/check') + .send({ cargoToml: VALID_CARGO_TOML }) + .expect(200); + + const sdk = res.body.dependencies.find((d: { name: string }) => d.name === 'soroban-sdk'); + expect(sdk).toBeDefined(); + expect(sdk.isOutdated).toBe(true); + expect(sdk.latestVersion).toBe('22.0.7'); + }); + + it('rejects missing cargoToml with 400', async () => { + const res = await request(app) + .post('/api/v1/dependencies/check') + .send({}) + .expect(400); + + expect(res.body).toHaveProperty('error', 'Validation failed'); + }); + + it('rejects cargoToml exceeding max length with 400', async () => { + const res = await request(app) + .post('/api/v1/dependencies/check') + .send({ cargoToml: 'x'.repeat(50_001) }) + .expect(400); + + expect(res.body).toHaveProperty('error', 'Validation failed'); + }); +}); + +describe('Dependencies API - POST /dependencies/update', () => { + it('applies update and returns suggestedCargoToml', async () => { + const res = await request(app) + .post('/api/v1/dependencies/update') + .send({ cargoToml: VALID_CARGO_TOML, dependencies: ['soroban-sdk'] }) + .expect(200); + + expect(res.body.status).toBe('success'); + expect(res.body.updated).toContain('soroban-sdk'); + expect(typeof res.body.suggestedCargoToml).toBe('string'); + expect(res.body.suggestedCargoToml).toContain('"22.0.7"'); + }); + + it('reports failure for non-existent dependencies', async () => { + const res = await request(app) + .post('/api/v1/dependencies/update') + .send({ cargoToml: VALID_CARGO_TOML, dependencies: ['nonexistent-crate'] }) + .expect(200); + + expect(res.body.failed).toContain('nonexistent-crate'); + expect(res.body.updated).not.toContain('nonexistent-crate'); + }); + + it('rejects missing dependencies array with 400', async () => { + const res = await request(app) + .post('/api/v1/dependencies/update') + .send({ cargoToml: VALID_CARGO_TOML }) + .expect(400); + + expect(res.body).toHaveProperty('error', 'Validation failed'); + }); + + it('rejects empty dependencies array with 400', async () => { + const res = await request(app) + .post('/api/v1/dependencies/update') + .send({ cargoToml: VALID_CARGO_TOML, dependencies: [] }) + .expect(400); + + expect(res.body).toHaveProperty('error', 'Validation failed'); + }); + + it('rejects missing cargoToml with 400', async () => { + const res = await request(app) + .post('/api/v1/dependencies/update') + .send({ dependencies: ['soroban-sdk'] }) + .expect(400); + + expect(res.body).toHaveProperty('error', 'Validation failed'); + }); +}); diff --git a/backend/tests/dependency-update.service.test.ts b/backend/tests/dependency-update.service.test.ts new file mode 100644 index 00000000..c5b073f6 --- /dev/null +++ b/backend/tests/dependency-update.service.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from '@jest/globals'; +import { + parseCargoTomlDependencies, + checkDependencies, + updateDependencies, +} from '../src/services/dependency-update.service.js'; + +const SAMPLE_TOML = `[package] +name = "test-contract" +version = "0.1.0" +edition = "2021" + +[dependencies] +soroban-sdk = "21.7.6" +soroban-auth = "21.0.0" +stellar-xdr = "21.2.0" +num-integer = "0.1.44" +serde = { version = "1.0.100", features = ["derive"] } +unknown-crate = "1.0.0" +`; + +describe('parseCargoTomlDependencies', () => { + it('parses simple version strings', () => { + const deps = parseCargoTomlDependencies(SAMPLE_TOML); + const names = deps.map((d) => d.name); + expect(names).toContain('soroban-sdk'); + expect(names).toContain('num-integer'); + expect(names).toContain('unknown-crate'); + }); + + it('parses inline table version', () => { + const deps = parseCargoTomlDependencies(SAMPLE_TOML); + const serde = deps.find((d) => d.name === 'serde'); + expect(serde).toBeDefined(); + expect(serde?.version).toBe('1.0.100'); + }); + + it('returns empty array when no [dependencies] section', () => { + const result = parseCargoTomlDependencies('[package]\nname = "x"\nversion = "0.1.0"\n'); + expect(result).toEqual([]); + }); +}); + +describe('checkDependencies', () => { + it('identifies outdated soroban-sdk', async () => { + const result = await checkDependencies(SAMPLE_TOML); + const sdk = result.dependencies.find((d) => d.name === 'soroban-sdk'); + expect(sdk?.isOutdated).toBe(true); + expect(sdk?.currentVersion).toBe('21.7.6'); + expect(sdk?.latestVersion).toBe('22.0.7'); + expect(sdk?.updateType).toBe('major'); + }); + + it('marks unknown-crate as up-to-date (not in registry)', async () => { + const result = await checkDependencies(SAMPLE_TOML); + const unknown = result.dependencies.find((d) => d.name === 'unknown-crate'); + expect(unknown?.isOutdated).toBe(false); + expect(unknown?.updateType).toBe('none'); + }); + + it('includes releaseNotes for known deps', async () => { + const result = await checkDependencies(SAMPLE_TOML); + const sdk = result.dependencies.find((d) => d.name === 'soroban-sdk'); + expect(typeof sdk?.releaseNotes).toBe('string'); + }); + + it('returns outdatedCount and metadata', async () => { + const result = await checkDependencies(SAMPLE_TOML); + expect(result.outdatedCount).toBeGreaterThan(0); + expect(result.checkedAt).toBeDefined(); + expect(result.cargoTomlHash).toBeDefined(); + }); + + it('returns 0 outdated when versions are already latest', async () => { + const latest = '[dependencies]\nsoroban-sdk = "22.0.7"\n'; + const result = await checkDependencies(latest); + expect(result.outdatedCount).toBe(0); + }); +}); + +describe('updateDependencies', () => { + it('updates a known dependency to latest version', async () => { + const result = await updateDependencies(SAMPLE_TOML, ['soroban-sdk']); + expect(result.updated).toContain('soroban-sdk'); + expect(result.failed).not.toContain('soroban-sdk'); + expect(result.suggestedCargoToml).toContain('"22.0.7"'); + }); + + it('reports failure for dependency not in registry', async () => { + const result = await updateDependencies(SAMPLE_TOML, ['does-not-exist']); + expect(result.failed).toContain('does-not-exist'); + expect(result.updated).not.toContain('does-not-exist'); + }); + + it('handles multiple updates in one call', async () => { + const result = await updateDependencies(SAMPLE_TOML, ['soroban-sdk', 'soroban-auth', 'stellar-xdr']); + expect(result.updated.length).toBeGreaterThanOrEqual(2); + }); + + it('returns suggestedCargoToml as a non-empty string', async () => { + const result = await updateDependencies(SAMPLE_TOML, ['soroban-sdk']); + expect(typeof result.suggestedCargoToml).toBe('string'); + expect(result.suggestedCargoToml.length).toBeGreaterThan(0); + }); +}); diff --git a/frontend/src/app/playground/page.tsx b/frontend/src/app/playground/page.tsx index 187ba7cf..e48aecb5 100644 --- a/frontend/src/app/playground/page.tsx +++ b/frontend/src/app/playground/page.tsx @@ -20,6 +20,23 @@ import { DatabaseManager } from '@/lib/storage/DatabaseManager'; import { SyncManager } from '@/lib/storage/SyncManager'; import { FilePresenceManager } from '@/lib/explorer/FilePresence'; import { Settings, X } from 'lucide-react'; +import { DependencyUpdatePanel } from '@/components/playground/DependencyUpdatePanel'; + +const DEFAULT_CARGO_TOML = `[package] +name = "soroban-contract" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "21.7.6" +soroban-auth = "21.0.0" +stellar-xdr = "21.2.0" +num-integer = "0.1.44" +num-traits = "0.2.17" +`; const INITIAL_TREE: FileTreeNode[] = [ { @@ -379,6 +396,8 @@ export default function PlaygroundPage() { + +

Laboratory Notes diff --git a/frontend/src/components/playground/DependencyUpdatePanel.tsx b/frontend/src/components/playground/DependencyUpdatePanel.tsx new file mode 100644 index 00000000..37b3de7e --- /dev/null +++ b/frontend/src/components/playground/DependencyUpdatePanel.tsx @@ -0,0 +1,238 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { useDependencyUpdates, type DependencyInfo } from '@/hooks/useDependencyUpdates'; + +const BADGE_STYLES: Record = { + major: 'bg-red-500/20 text-red-400 border-red-500/30', + minor: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', + patch: 'bg-green-500/20 text-green-400 border-green-500/30', + none: 'bg-zinc-700/50 text-zinc-400 border-zinc-600/30', +}; + +interface DependencyUpdatePanelProps { + cargoToml: string; + onCargoTomlUpdate?: (updated: string) => void; +} + +export function DependencyUpdatePanel({ cargoToml, onCargoTomlUpdate }: DependencyUpdatePanelProps) { + const { checkResult, updateResult, isChecking, isUpdating, error, checkDependencies, applyUpdates, reset } = + useDependencyUpdates(); + const [selected, setSelected] = useState>(new Set()); + const [copied, setCopied] = useState(false); + + const handleCheck = useCallback(() => { + setSelected(new Set()); + reset(); + checkDependencies(cargoToml); + }, [cargoToml, checkDependencies, reset]); + + const toggleDep = useCallback((name: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(name)) { + next.delete(name); + } else { + next.add(name); + } + return next; + }); + }, []); + + const toggleAll = useCallback(() => { + if (!checkResult) return; + const outdated = checkResult.dependencies.filter((d) => d.isOutdated).map((d) => d.name); + setSelected((prev) => (prev.size === outdated.length ? new Set() : new Set(outdated))); + }, [checkResult]); + + const handleUpdate = useCallback(async () => { + if (!selected.size) return; + await applyUpdates(cargoToml, Array.from(selected)); + }, [cargoToml, selected, applyUpdates]); + + const handleCopy = useCallback(async () => { + if (!updateResult?.suggestedCargoToml) return; + await navigator.clipboard.writeText(updateResult.suggestedCargoToml); + onCargoTomlUpdate?.(updateResult.suggestedCargoToml); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [updateResult, onCargoTomlUpdate]); + + const outdatedDeps = checkResult?.dependencies.filter((d) => d.isOutdated) ?? []; + const upToDateDeps = checkResult?.dependencies.filter((d) => !d.isOutdated) ?? []; + + return ( +
+
+
+

+ Dependency Updater +

+

Check and update your Cargo.toml dependencies

+
+ +
+ + {error && ( +
+ {error} +
+ )} + + {checkResult && !updateResult && ( + <> +
+ + {checkResult.dependencies.length} deps scanned + + + 0 ? 'text-yellow-400 font-bold' : 'text-green-400 font-bold'}> + {checkResult.outdatedCount} + {' '} + outdated + +
+ + {outdatedDeps.length > 0 && ( + <> +
+ + Outdated + + +
+
    + {outdatedDeps.map((dep) => ( + + ))} +
+ + + )} + + {upToDateDeps.length > 0 && ( +
+ + Up-to-date ({upToDateDeps.length}) + +
    + {upToDateDeps.map((dep) => ( +
  • + {dep.name} + {dep.currentVersion} +
  • + ))} +
+
+ )} + + {outdatedDeps.length === 0 && ( +

+ ✓ All dependencies are up-to-date +

+ )} + + )} + + {updateResult && ( +
+
+ + {updateResult.updated.length} updated + + {updateResult.failed.length > 0 && ( + + {updateResult.failed.length} failed + + )} +
+ {updateResult.updated.length > 0 && ( +
    + {updateResult.updated.map((name) => ( +
  • + {name} +
  • + ))} +
+ )} + + +
+ )} +
+ ); +} + +function DepRow({ + dep, + selected, + onToggle, +}: { + dep: DependencyInfo; + selected: boolean; + onToggle: (name: string) => void; +}) { + return ( +
  • + +
  • + ); +} diff --git a/frontend/src/components/playground/__tests__/DependencyUpdatePanel.test.tsx b/frontend/src/components/playground/__tests__/DependencyUpdatePanel.test.tsx new file mode 100644 index 00000000..a0e1d214 --- /dev/null +++ b/frontend/src/components/playground/__tests__/DependencyUpdatePanel.test.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DependencyUpdatePanel } from '../DependencyUpdatePanel'; + +const SAMPLE_TOML = '[dependencies]\nsoroban-sdk = "21.7.6"\n'; + +const CHECK_RESPONSE = { + status: 'success', + dependencies: [ + { + name: 'soroban-sdk', + currentVersion: '21.7.6', + latestVersion: '22.0.7', + isOutdated: true, + updateType: 'major', + releaseNotes: 'Protocol 22 support.', + }, + { + name: 'num-integer', + currentVersion: '0.1.46', + latestVersion: '0.1.46', + isOutdated: false, + updateType: 'none', + }, + ], + outdatedCount: 1, + checkedAt: '2026-06-27T10:00:00.000Z', + cargoTomlHash: 'abc123', +}; + +const UPDATE_RESPONSE = { + status: 'success', + updated: ['soroban-sdk'], + failed: [], + suggestedCargoToml: '[dependencies]\nsoroban-sdk = "22.0.7"\n', +}; + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('DependencyUpdatePanel', () => { + it('renders the panel heading', () => { + render(); + expect(screen.getByText(/Dependency Updater/i)).toBeDefined(); + }); + + it('renders the Check button', () => { + render(); + expect(screen.getByRole('button', { name: /check dependencies/i })).toBeDefined(); + }); + + it('shows outdated dependency after check', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => CHECK_RESPONSE, + } as Response); + + render(); + fireEvent.click(screen.getByRole('button', { name: /check dependencies/i })); + + await waitFor(() => { + expect(screen.getByText('soroban-sdk')).toBeDefined(); + }); + expect(screen.getByText('22.0.7')).toBeDefined(); + }); + + it('shows up-to-date summary count', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => CHECK_RESPONSE, + } as Response); + + render(); + fireEvent.click(screen.getByRole('button', { name: /check dependencies/i })); + + await waitFor(() => { + expect(screen.getByText(/Up-to-date \(1\)/i)).toBeDefined(); + }); + }); + + it('shows error message when check fails', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + json: async () => ({ status: 'error', message: 'Server error' }), + } as Response); + + render(); + fireEvent.click(screen.getByRole('button', { name: /check dependencies/i })); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeDefined(); + expect(screen.getByText('Server error')).toBeDefined(); + }); + }); + + it('shows updated confirmation after applying updates', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce({ ok: true, json: async () => CHECK_RESPONSE } as Response) + .mockResolvedValueOnce({ ok: true, json: async () => UPDATE_RESPONSE } as Response); + + render(); + fireEvent.click(screen.getByRole('button', { name: /check dependencies/i })); + + await waitFor(() => screen.getByText('soroban-sdk')); + + // Select the dep and apply + const checkbox = screen.getByRole('checkbox', { name: /select soroban-sdk/i }); + fireEvent.click(checkbox); + fireEvent.click(screen.getByRole('button', { name: /apply.*update/i })); + + await waitFor(() => { + expect(screen.getByText(/1 updated/i)).toBeDefined(); + }); + }); + + it('calls onCargoTomlUpdate after copying updated Cargo.toml', async () => { + const onUpdate = vi.fn(); + vi.mocked(fetch) + .mockResolvedValueOnce({ ok: true, json: async () => CHECK_RESPONSE } as Response) + .mockResolvedValueOnce({ ok: true, json: async () => UPDATE_RESPONSE } as Response); + + render(); + fireEvent.click(screen.getByRole('button', { name: /check dependencies/i })); + + await waitFor(() => screen.getByText('soroban-sdk')); + + const checkbox = screen.getByRole('checkbox', { name: /select soroban-sdk/i }); + fireEvent.click(checkbox); + fireEvent.click(screen.getByRole('button', { name: /apply.*update/i })); + + await waitFor(() => screen.getByRole('button', { name: /copy updated cargo/i })); + fireEvent.click(screen.getByRole('button', { name: /copy updated cargo/i })); + + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledWith(UPDATE_RESPONSE.suggestedCargoToml); + }); + }); +}); diff --git a/frontend/src/hooks/__tests__/useDependencyUpdates.test.ts b/frontend/src/hooks/__tests__/useDependencyUpdates.test.ts new file mode 100644 index 00000000..00140b44 --- /dev/null +++ b/frontend/src/hooks/__tests__/useDependencyUpdates.test.ts @@ -0,0 +1,136 @@ +import { renderHook, act } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useDependencyUpdates } from '../../hooks/useDependencyUpdates'; + +const SAMPLE_TOML = '[dependencies]\nsoroban-sdk = "21.7.6"\n'; + +const CHECK_RESPONSE = { + status: 'success', + dependencies: [ + { + name: 'soroban-sdk', + currentVersion: '21.7.6', + latestVersion: '22.0.7', + isOutdated: true, + updateType: 'major', + releaseNotes: 'Protocol 22 support.', + }, + ], + outdatedCount: 1, + checkedAt: '2026-06-27T10:00:00.000Z', + cargoTomlHash: 'abc123', +}; + +const UPDATE_RESPONSE = { + status: 'success', + updated: ['soroban-sdk'], + failed: [], + suggestedCargoToml: '[dependencies]\nsoroban-sdk = "22.0.7"\n', +}; + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('useDependencyUpdates', () => { + it('initialises with null results and no loading state', () => { + const { result } = renderHook(() => useDependencyUpdates()); + expect(result.current.checkResult).toBeNull(); + expect(result.current.updateResult).toBeNull(); + expect(result.current.isChecking).toBe(false); + expect(result.current.isUpdating).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('sets checkResult on successful checkDependencies call', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => CHECK_RESPONSE, + } as Response); + + const { result } = renderHook(() => useDependencyUpdates()); + + await act(async () => { + await result.current.checkDependencies(SAMPLE_TOML); + }); + + expect(result.current.checkResult).toMatchObject({ + outdatedCount: 1, + cargoTomlHash: 'abc123', + }); + expect(result.current.isChecking).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('sets error on failed checkDependencies call', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + json: async () => ({ status: 'error', message: 'Server error' }), + } as Response); + + const { result } = renderHook(() => useDependencyUpdates()); + + await act(async () => { + await result.current.checkDependencies(SAMPLE_TOML); + }); + + expect(result.current.checkResult).toBeNull(); + expect(result.current.error).toBe('Server error'); + }); + + it('sets error on network failure', async () => { + vi.mocked(fetch).mockRejectedValueOnce(new Error('Network failure')); + + const { result } = renderHook(() => useDependencyUpdates()); + + await act(async () => { + await result.current.checkDependencies(SAMPLE_TOML); + }); + + expect(result.current.error).toBe('Network failure'); + }); + + it('sets updateResult on successful applyUpdates call', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => UPDATE_RESPONSE, + } as Response); + + const { result } = renderHook(() => useDependencyUpdates()); + + await act(async () => { + await result.current.applyUpdates(SAMPLE_TOML, ['soroban-sdk']); + }); + + expect(result.current.updateResult).toMatchObject({ + updated: ['soroban-sdk'], + failed: [], + }); + expect(result.current.isUpdating).toBe(false); + }); + + it('clears state on reset', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => CHECK_RESPONSE, + } as Response); + + const { result } = renderHook(() => useDependencyUpdates()); + + await act(async () => { + await result.current.checkDependencies(SAMPLE_TOML); + }); + + act(() => { + result.current.reset(); + }); + + expect(result.current.checkResult).toBeNull(); + expect(result.current.updateResult).toBeNull(); + expect(result.current.error).toBeNull(); + }); +}); diff --git a/frontend/src/hooks/useDependencyUpdates.ts b/frontend/src/hooks/useDependencyUpdates.ts new file mode 100644 index 00000000..5c5a863d --- /dev/null +++ b/frontend/src/hooks/useDependencyUpdates.ts @@ -0,0 +1,95 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import { API_BASE_URL } from '@/lib/api-config'; + +export interface DependencyInfo { + name: string; + currentVersion: string; + latestVersion: string; + isOutdated: boolean; + updateType: 'major' | 'minor' | 'patch' | 'none'; + releaseNotes?: string; +} + +export interface DependencyCheckResult { + dependencies: DependencyInfo[]; + outdatedCount: number; + checkedAt: string; + cargoTomlHash: string; +} + +export interface DependencyUpdateResult { + updated: string[]; + failed: string[]; + suggestedCargoToml: string; +} + +interface UseDependencyUpdatesReturn { + checkResult: DependencyCheckResult | null; + updateResult: DependencyUpdateResult | null; + isChecking: boolean; + isUpdating: boolean; + error: string | null; + checkDependencies: (cargoToml: string) => Promise; + applyUpdates: (cargoToml: string, dependencies: string[]) => Promise; + reset: () => void; +} + +export function useDependencyUpdates(): UseDependencyUpdatesReturn { + const [checkResult, setCheckResult] = useState(null); + const [updateResult, setUpdateResult] = useState(null); + const [isChecking, setIsChecking] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [error, setError] = useState(null); + + const checkDependencies = useCallback(async (cargoToml: string) => { + setIsChecking(true); + setError(null); + try { + const res = await fetch(`${API_BASE_URL}/dependencies/check`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cargoToml }), + }); + const data = await res.json(); + if (!res.ok || data.status === 'error') { + throw new Error(data.message ?? 'Failed to check dependencies'); + } + setCheckResult(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setIsChecking(false); + } + }, []); + + const applyUpdates = useCallback(async (cargoToml: string, dependencies: string[]) => { + setIsUpdating(true); + setError(null); + try { + const res = await fetch(`${API_BASE_URL}/dependencies/update`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cargoToml, dependencies }), + }); + const data = await res.json(); + if (!res.ok || data.status === 'error') { + throw new Error(data.message ?? 'Failed to update dependencies'); + } + setUpdateResult(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setIsUpdating(false); + } + }, []); + + const reset = useCallback(() => { + setCheckResult(null); + setUpdateResult(null); + setError(null); + }, []); + + return { checkResult, updateResult, isChecking, isUpdating, error, checkDependencies, applyUpdates, reset }; +}