diff --git a/src/main/kotlin/ai/opencode/ide/jetbrains/OpenCodeService.kt b/src/main/kotlin/ai/opencode/ide/jetbrains/OpenCodeService.kt index 51d7328..e383111 100644 --- a/src/main/kotlin/ai/opencode/ide/jetbrains/OpenCodeService.kt +++ b/src/main/kotlin/ai/opencode/ide/jetbrains/OpenCodeService.kt @@ -106,6 +106,12 @@ class OpenCodeService(private val project: Project) : Disposable { ApplicationManager.getApplication().invokeLater(it) } + internal var pasteDispatch: (() -> Unit) -> Unit = { task -> + AppExecutorUtil.getAppScheduledExecutorService().schedule({ + ApplicationManager.getApplication().invokeLater { task() } + }, 100L, TimeUnit.MILLISECONDS) + } + // ==================== Public API ==================== fun focusOrCreateTerminal(interactive: Boolean = false) { @@ -322,14 +328,12 @@ class OpenCodeService(private val project: Project) : Disposable { try { val files = diffs.mapNotNull { diff -> PathUtil.resolveProjectPath(project, diff.file)?.let { - val ioFile = java.io.File(it) - // If file is deleted, we must refresh the parent directory to detect deletion - if (!ioFile.exists()) ioFile.parentFile else ioFile + java.io.File(it).takeIf { it.exists() } } } if (files.isNotEmpty()) { - logger.info("[OpenCode] Forcing VFS refresh for ${files.size} paths: ${files.map { it.absolutePath }}") - com.intellij.openapi.vfs.LocalFileSystem.getInstance().refreshIoFiles(files, false, false, null) + logger.info("[OpenCode] Forcing VFS refresh for ${files.size} paths") + com.intellij.openapi.vfs.LocalFileSystem.getInstance().refreshIoFiles(files, true, false, null) } } catch (e: Exception) { logger.debug("[OpenCode] VFS refresh skipped: ${e.message}") @@ -815,15 +819,13 @@ class OpenCodeService(private val project: Project) : Disposable { } return } - AppExecutorUtil.getAppScheduledExecutorService().schedule({ - ApplicationManager.getApplication().invokeLater { - if (!project.isDisposed) { - if (!pasteToTerminal(t)) { - schedulePasteAttempt(t, l - 1, d) - } + pasteDispatch { + if (!project.isDisposed) { + if (!pasteToTerminal(t)) { + schedulePasteAttempt(t, l - 1, d) } - } - }, d, TimeUnit.MILLISECONDS) + } + } } private fun extractPartMessageInfo(p: JsonElement): PartMessageInfo? { if (!p.isJsonObject) return null; val o = p.asJsonObject; val mId = o.get("messageID")?.asString; val sId = o.get("sessionID")?.asString; return if (mId != null && sId != null) PartMessageInfo(sId, mId) else null } diff --git a/src/test/kotlin/ai/opencode/ide/jetbrains/actions/SendSelectionToTerminalActionTest.kt b/src/test/kotlin/ai/opencode/ide/jetbrains/actions/SendSelectionToTerminalActionTest.kt index 1c816df..f171e0d 100644 --- a/src/test/kotlin/ai/opencode/ide/jetbrains/actions/SendSelectionToTerminalActionTest.kt +++ b/src/test/kotlin/ai/opencode/ide/jetbrains/actions/SendSelectionToTerminalActionTest.kt @@ -9,9 +9,9 @@ import ai.opencode.ide.jetbrains.session.SessionManager import com.intellij.openapi.actionSystem.CommonDataKeys import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.components.service -import com.intellij.testFramework.PlatformTestUtil import com.intellij.testFramework.MapDataContext import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean class SendSelectionToTerminalActionTest : BasePlatformTestCase() { @@ -68,19 +68,17 @@ class SendSelectionToTerminalActionTest : BasePlatformTestCase() { val event = AnActionEvent.createFromDataContext("place", null, dataContext) - // 3. Perform Action + // 3. Override paste dispatch to run synchronously (no thread hops) + service.pasteDispatch = { task -> task() } + + // 4. Perform Action action.actionPerformed(event) - // 4. Verify - // Wait for server to receive request (async) - val start = System.currentTimeMillis() - while (server?.receivedPrompts?.isEmpty() == true && System.currentTimeMillis() - start < 2000) { - PlatformTestUtil.dispatchAllEventsInIdeEventQueue() - Thread.sleep(50) - } + // 5. Verify + val latch = server?.promptLatch ?: return + assertTrue("Server should have received a prompt", latch.await(5, TimeUnit.SECONDS)) val prompts = server?.receivedPrompts ?: emptyList() - assertFalse("Server should have received a prompt", prompts.isEmpty()) val lastPrompt = prompts.last() println("Received prompt JSON: $lastPrompt") diff --git a/src/test/kotlin/ai/opencode/ide/jetbrains/integration/FakeOpenCodeServer.kt b/src/test/kotlin/ai/opencode/ide/jetbrains/integration/FakeOpenCodeServer.kt index b2164bd..e614802 100644 --- a/src/test/kotlin/ai/opencode/ide/jetbrains/integration/FakeOpenCodeServer.kt +++ b/src/test/kotlin/ai/opencode/ide/jetbrains/integration/FakeOpenCodeServer.kt @@ -5,6 +5,7 @@ import java.io.OutputStream import java.net.InetSocketAddress import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors class FakeOpenCodeServer(val port: Int) { @@ -13,6 +14,7 @@ class FakeOpenCodeServer(val port: Int) { private val diffResponses = ConcurrentHashMap() private val diffDelays = ConcurrentHashMap() val receivedPrompts = CopyOnWriteArrayList() + val promptLatch = CountDownLatch(1) val activePort: Int get() = server.address.port @@ -32,6 +34,7 @@ class FakeOpenCodeServer(val port: Int) { println(" [FakeServer] POST /tui/append-prompt: $body") // Keep raw JSON for assertions in tests. receivedPrompts.add(body) + promptLatch.countDown() val resp = "{}".toByteArray() ex.sendResponseHeaders(200, resp.size.toLong()) diff --git a/src/test/kotlin/ai/opencode/ide/jetbrains/integration/OpenCodeLogicTest.kt b/src/test/kotlin/ai/opencode/ide/jetbrains/integration/OpenCodeLogicTest.kt index 2a09a13..46da1c2 100644 --- a/src/test/kotlin/ai/opencode/ide/jetbrains/integration/OpenCodeLogicTest.kt +++ b/src/test/kotlin/ai/opencode/ide/jetbrains/integration/OpenCodeLogicTest.kt @@ -7,28 +7,20 @@ import ai.opencode.ide.jetbrains.diff.DiffViewerService import ai.opencode.ide.jetbrains.session.SessionManager import com.intellij.history.Label import com.intellij.openapi.project.Project -import java.lang.reflect.Proxy +import com.intellij.testFramework.fixtures.BasePlatformTestCase import java.util.concurrent.CopyOnWriteArrayList -import org.junit.After -import org.junit.Before -import org.junit.Test /** * Logic Unit Test using Fake Server. * Simplified suite covering core Diff scenarios. */ -class OpenCodeLogicTest { - - @Before - fun setUp() { +class OpenCodeLogicTest : BasePlatformTestCase() { + + override fun setUp() { + super.setUp() OpenCodeService.DEBOUNCE_MS = 0L } - - @After - fun tearDown() { - } - - @Test + fun testDiffScenarios() { println("=== Starting OpenCode Logic Tests (Diff Isolation) ===") @@ -38,12 +30,14 @@ class OpenCodeLogicTest { val port = server.activePort // Setup Test Runner (Mock IDE) - val runner = TestRunner(port) + val runner = TestRunner(project, port) val r = runner val s = server + val baseDir = project.basePath!! try { + java.io.File(baseDir).mkdirs() // Wait for connection to establish Thread.sleep(500) @@ -127,8 +121,7 @@ class OpenCodeLogicTest { Thread.sleep(100) // 2. Simulate AI writing a new file to disk - val tempDir = System.getProperty("java.io.tmpdir") - val newFile = java.io.File(tempDir, "new.kt") + val newFile = java.io.File(baseDir, "new.kt") newFile.writeText("fun new() {}") r.sessionManager.simulateFileCreation("new.kt") // Signal VFS creation @@ -196,7 +189,7 @@ class OpenCodeLogicTest { r.resetState() // 1. Prepare file - val toDelete = java.io.File(tempDir, "to_delete.kt") + val toDelete = java.io.File(baseDir, "to_delete.kt") toDelete.writeText("delete me") // 2. Start Turn @@ -266,7 +259,7 @@ class OpenCodeLogicTest { s.broadcast("""{"type":"session.status","properties":{"sessionID":"s1","status":{"type":"busy"}}}""") Thread.sleep(100) - val file1 = java.io.File(tempDir, "1.md") + val file1 = java.io.File(baseDir, "1.md") file1.writeText("created content") r.sessionManager.simulateFileCreation("1.md") s.broadcast("""{"type":"file.edited","properties":{"file":"1.md"}}""") @@ -320,36 +313,19 @@ class OpenCodeLogicTest { } } -class TestRunner(val serverPort: Int) { - val mockProject: Project +class TestRunner(val project: Project, val serverPort: Int) { val mockDiffViewer: MockDiffViewerService val sessionManager: TestSessionManager val openCodeService: OpenCodeService init { - // Setup Mocks - val tempDir = System.getProperty("java.io.tmpdir") - mockProject = Proxy.newProxyInstance( - Project::class.java.classLoader, - arrayOf(Project::class.java) - ) { _, method, _ -> - when (method.name) { - "getBasePath" -> tempDir - "isDisposed" -> false - "toString" -> "MockProject" - "hashCode" -> 12345 - "equals" -> false - else -> null - } - } as Project - - mockDiffViewer = MockDiffViewerService(mockProject) - sessionManager = TestSessionManager(mockProject) + mockDiffViewer = MockDiffViewerService(project) + sessionManager = TestSessionManager(project) // Initialize Service with REAL ApiClient pointing to Fake Server val apiClient = OpenCodeApiClient("127.0.0.1", serverPort) - openCodeService = OpenCodeService(mockProject) + openCodeService = OpenCodeService(project) openCodeService.setTestDeps(sessionManager, mockDiffViewer, apiClient) // Mock UI execution diff --git a/src/test/kotlin/ai/opencode/ide/jetbrains/integration/RealProcessIntegrationTest.kt b/src/test/kotlin/ai/opencode/ide/jetbrains/integration/RealProcessIntegrationTest.kt index 48bea3d..8e15bd8 100644 --- a/src/test/kotlin/ai/opencode/ide/jetbrains/integration/RealProcessIntegrationTest.kt +++ b/src/test/kotlin/ai/opencode/ide/jetbrains/integration/RealProcessIntegrationTest.kt @@ -22,8 +22,10 @@ class RealProcessIntegrationTest : BasePlatformTestCase() { port = PortFinder.findAvailablePort() // Find opencode executable + val isWin = System.getProperty("os.name", "").lowercase().contains("windows") + val candidates = if (isWin) listOf("opencode.cmd", "opencode.bat", "opencode.exe", "opencode") else listOf("opencode") val path = System.getenv("PATH").split(File.pathSeparator) - .map { File(it, "opencode") } + .flatMap { dir -> candidates.map { File(dir, it) } } .firstOrNull { it.exists() && it.canExecute() } ?.absolutePath ?: throw IllegalStateException("opencode binary not found in PATH")