diff --git a/apps/worker/src/__tests__/test-runner.test.ts b/apps/worker/src/__tests__/test-runner.test.ts index 8bd40cd..3522e70 100644 --- a/apps/worker/src/__tests__/test-runner.test.ts +++ b/apps/worker/src/__tests__/test-runner.test.ts @@ -2,7 +2,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' -import { detectTestRunner, detectPackageManager, detectMonorepo, runTests } from '../test-runner.js' +import { existsSync, readFileSync } from 'fs' +import { detectTestRunner, detectPackageManager, detectMonorepo, runTests, stripLocalUvSources } from '../test-runner.js' // Helper: create a temp directory with specific files function createTempDir(): string { @@ -244,6 +245,112 @@ describe('detectMonorepo', () => { }) }) +describe('stripLocalUvSources', () => { + let tempDir: string + + beforeEach(() => { + tempDir = createTempDir() + }) + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }) + }) + + it('strips path-based source entries from pyproject.toml', () => { + const pyprojectContent = `[project] +name = "test-project" +dependencies = [ + "openadapt-ml>=0.11.0", + "openadapt-consilium>=0.3.2", +] + +[tool.uv.sources] +openadapt-consilium = { git = "https://github.com/OpenAdaptAI/openadapt-consilium.git" } +openadapt-ml = { path = "../openadapt-ml", editable = true } + +[tool.hatch.build.targets.wheel] +packages = ["my_package"] +` + touchFile(tempDir, 'pyproject.toml', pyprojectContent) + touchFile(tempDir, 'uv.lock', 'some lock content') + + stripLocalUvSources(tempDir) + + const result = readFileSync(join(tempDir, 'pyproject.toml'), 'utf-8') + // Path-based source override should be removed + expect(result).not.toContain('path = "../openadapt-ml"') + // But the dependency itself in [project.dependencies] should remain + expect(result).toContain('openadapt-ml>=0.11.0') + // Git-based entry should be preserved + expect(result).toContain('openadapt-consilium') + expect(result).toContain('git = "https://github.com/') + // Other sections should be preserved + expect(result).toContain('[project]') + expect(result).toContain('[tool.hatch.build.targets.wheel]') + // uv.lock should be deleted + expect(existsSync(join(tempDir, 'uv.lock'))).toBe(false) + }) + + it('preserves pyproject.toml without [tool.uv.sources]', () => { + const pyprojectContent = `[project] +name = "test-project" +dependencies = ["requests>=2.28.0"] +` + touchFile(tempDir, 'pyproject.toml', pyprojectContent) + touchFile(tempDir, 'uv.lock', 'some lock content') + + stripLocalUvSources(tempDir) + + const result = readFileSync(join(tempDir, 'pyproject.toml'), 'utf-8') + expect(result).toBe(pyprojectContent) + // uv.lock should NOT be deleted when no changes were made + expect(existsSync(join(tempDir, 'uv.lock'))).toBe(true) + }) + + it('preserves pyproject.toml with only git sources', () => { + const pyprojectContent = `[project] +name = "test-project" + +[tool.uv.sources] +consilium = { git = "https://github.com/example/consilium.git" } +` + touchFile(tempDir, 'pyproject.toml', pyprojectContent) + touchFile(tempDir, 'uv.lock', 'some lock content') + + stripLocalUvSources(tempDir) + + const result = readFileSync(join(tempDir, 'pyproject.toml'), 'utf-8') + expect(result).toBe(pyprojectContent) + // uv.lock should NOT be deleted when no path sources were stripped + expect(existsSync(join(tempDir, 'uv.lock'))).toBe(true) + }) + + it('handles multiple path-based sources', () => { + const pyprojectContent = `[project] +name = "test-project" + +[tool.uv.sources] +pkg-a = { path = "../pkg-a", editable = true } +pkg-b = { git = "https://github.com/example/pkg-b.git" } +pkg-c = { path = "/absolute/path/to/pkg-c" } +` + touchFile(tempDir, 'pyproject.toml', pyprojectContent) + + stripLocalUvSources(tempDir) + + const result = readFileSync(join(tempDir, 'pyproject.toml'), 'utf-8') + expect(result).not.toContain('pkg-a') + expect(result).not.toContain('pkg-c') + expect(result).toContain('pkg-b') + expect(result).toContain('git = "https://github.com/') + }) + + it('does nothing when pyproject.toml does not exist', () => { + // Should not throw + expect(() => stripLocalUvSources(tempDir)).not.toThrow() + }) +}) + describe('runTests with real commands', () => { let tempDir: string diff --git a/apps/worker/src/test-runner.ts b/apps/worker/src/test-runner.ts index 4591503..e65f063 100644 --- a/apps/worker/src/test-runner.ts +++ b/apps/worker/src/test-runner.ts @@ -1,5 +1,5 @@ import { execSync } from 'child_process' -import { existsSync, readFileSync } from 'fs' +import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs' import { join } from 'path' import type { TestRunner, PackageManager, TestResults, TestFailure } from '@wright/shared' @@ -135,6 +135,77 @@ export function detectPackageManager(workDir: string): PackageManager { return 'none' } +/** + * Strip local path-based source overrides from pyproject.toml's [tool.uv.sources]. + * + * Many projects use `[tool.uv.sources]` to point dependencies at local sibling + * directories for development (e.g. `openadapt-ml = { path = "../openadapt-ml", editable = true }`). + * These paths don't exist on the worker, causing `uv sync` to fail immediately. + * + * This function removes any source entries that use `path = "..."` (local + * filesystem references) while preserving git/url sources. It also removes the + * stale `uv.lock` so uv regenerates it with PyPI-resolved versions. + * + * The packages themselves are still listed as regular dependencies (e.g. + * `openadapt-ml>=0.11.0`) and will resolve from PyPI without the source override. + */ +export function stripLocalUvSources(workDir: string): void { + const pyprojectPath = join(workDir, 'pyproject.toml') + if (!existsSync(pyprojectPath)) return + + const content = readFileSync(pyprojectPath, 'utf-8') + + // Check if there's a [tool.uv.sources] section with path-based entries + if (!content.includes('[tool.uv.sources]')) return + + const lines = content.split('\n') + const outputLines: string[] = [] + let inUvSources = false + let strippedAny = false + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const trimmed = line.trim() + + // Detect start of [tool.uv.sources] section + if (trimmed === '[tool.uv.sources]') { + inUvSources = true + outputLines.push(line) + continue + } + + // Detect start of any other section (ends [tool.uv.sources]) + if (inUvSources && trimmed.startsWith('[') && trimmed.endsWith(']')) { + inUvSources = false + } + + if (inUvSources) { + // Skip lines that contain `path =` (local filesystem source) + // These look like: `package-name = { path = "../some-dir", editable = true }` + if (/\bpath\s*=\s*"/.test(line)) { + strippedAny = true + console.log(`[test-runner] Stripped local uv source: ${trimmed}`) + continue + } + } + + outputLines.push(line) + } + + if (strippedAny) { + writeFileSync(pyprojectPath, outputLines.join('\n')) + console.log('[test-runner] Removed local path sources from pyproject.toml') + + // Delete uv.lock so uv regenerates it without the local sources. + // The old lock may contain entries tied to the local paths. + const lockPath = join(workDir, 'uv.lock') + if (existsSync(lockPath)) { + unlinkSync(lockPath) + console.log('[test-runner] Removed stale uv.lock (will regenerate)') + } + } +} + /** * Install dependencies using the detected package manager. */ @@ -154,6 +225,12 @@ export function installDependencies(workDir: string, pm: PackageManager): void { const cmd = commands[pm] if (!cmd) return + // For uv projects, strip local path sources from pyproject.toml that reference + // sibling directories (e.g. "../openadapt-ml") which won't exist on the worker. + if (pm === 'uv') { + stripLocalUvSources(workDir) + } + console.log(`[test-runner] Installing dependencies with ${pm}: ${cmd}`) try { execSync(cmd, { cwd: workDir, stdio: 'pipe', timeout: 300_000, env: getSafeEnv() })