|
| 1 | +package com.sourcegraph.cody.util |
| 2 | + |
| 3 | +import com.intellij.ide.lightEdit.LightEdit |
| 4 | +import com.intellij.openapi.actionSystem.ActionManager |
| 5 | +import com.intellij.openapi.actionSystem.AnActionEvent |
| 6 | +import com.intellij.openapi.actionSystem.DataContext |
| 7 | +import com.intellij.openapi.application.ApplicationManager |
| 8 | +import com.intellij.openapi.command.WriteCommandAction |
| 9 | +import com.intellij.openapi.diagnostic.Logger |
| 10 | +import com.intellij.openapi.editor.Editor |
| 11 | +import com.intellij.openapi.editor.ex.EditorEx |
| 12 | +import com.intellij.openapi.fileEditor.FileDocumentManager |
| 13 | +import com.intellij.openapi.project.DumbService |
| 14 | +import com.intellij.openapi.project.Project |
| 15 | +import com.intellij.testFramework.EditorTestUtil |
| 16 | +import com.intellij.testFramework.PlatformTestUtil |
| 17 | +import com.intellij.testFramework.fixtures.BasePlatformTestCase |
| 18 | +import com.intellij.testFramework.runInEdtAndWait |
| 19 | +import com.intellij.util.messages.Topic |
| 20 | +import com.sourcegraph.cody.agent.CodyAgentService |
| 21 | +import com.sourcegraph.cody.config.CodyPersistentAccountsHost |
| 22 | +import com.sourcegraph.cody.config.SourcegraphServerPath |
| 23 | +import com.sourcegraph.cody.edit.CodyInlineEditActionNotifier |
| 24 | +import com.sourcegraph.cody.edit.FixupService |
| 25 | +import com.sourcegraph.cody.edit.sessions.FixupSession |
| 26 | +import com.sourcegraph.config.ConfigUtil |
| 27 | +import java.io.File |
| 28 | +import java.nio.file.Paths |
| 29 | +import java.util.concurrent.CompletableFuture |
| 30 | +import java.util.concurrent.TimeUnit |
| 31 | +import java.util.regex.Pattern |
| 32 | + |
| 33 | +open class CodyIntegrationTextFixture : BasePlatformTestCase() { |
| 34 | + private val logger = Logger.getInstance(CodyIntegrationTextFixture::class.java) |
| 35 | + |
| 36 | + override fun setUp() { |
| 37 | + super.setUp() |
| 38 | + configureFixture() |
| 39 | + checkInitialConditions() |
| 40 | + myProject = project |
| 41 | + } |
| 42 | + |
| 43 | + override fun tearDown() { |
| 44 | + try { |
| 45 | + assertNotNull(project) |
| 46 | + FixupService.getInstance(project).getActiveSession()?.apply { |
| 47 | + try { |
| 48 | + dispose() |
| 49 | + } catch (x: Exception) { |
| 50 | + logger.warn("Error shutting down session", x) |
| 51 | + } |
| 52 | + } |
| 53 | + } finally { |
| 54 | + super.tearDown() |
| 55 | + } |
| 56 | + } |
| 57 | + |
| 58 | + private fun configureFixture() { |
| 59 | + // If you don't specify this system property with this setting when running the tests, |
| 60 | + // the tests will fail, because IntelliJ will run them from the EDT, which can't block. |
| 61 | + // Setting this property invokes the tests from an executor pool thread, which lets us |
| 62 | + // block/wait on potentially long-running operations during the integration test. |
| 63 | + val policy = System.getProperty("idea.test.execution.policy") |
| 64 | + assertTrue(policy == "com.sourcegraph.cody.test.NonEdtIdeaTestExecutionPolicy") |
| 65 | + |
| 66 | + // This is wherever src/integrationTest/resources is on the box running the tests. |
| 67 | + val testResourcesDir = File(System.getProperty("test.resources.dir")) |
| 68 | + assertTrue(testResourcesDir.exists()) |
| 69 | + |
| 70 | + // During test runs this is set by IntelliJ to a private temp folder. |
| 71 | + // We pass it to the Agent during initialization. |
| 72 | + val workspaceRootUri = ConfigUtil.getWorkspaceRootPath(project) |
| 73 | + |
| 74 | + // We copy the test resources there manually, bypassing Gradle, which is picky. |
| 75 | + val testDataPath = Paths.get(workspaceRootUri.toString(), "src/").toFile() |
| 76 | + testResourcesDir.copyRecursively(testDataPath, overwrite = true) |
| 77 | + |
| 78 | + // This useful setting lets us tell the fixture to look where we copied them. |
| 79 | + myFixture.testDataPath = testDataPath.path |
| 80 | + |
| 81 | + // The file we pass to configureByFile must be relative to testDataPath. |
| 82 | + val projectFile = "testProjects/documentCode/src/main/java/Foo.java" |
| 83 | + val sourcePath = Paths.get(testDataPath.path, projectFile).toString() |
| 84 | + assertTrue(File(sourcePath).exists()) |
| 85 | + myFixture.configureByFile(projectFile) |
| 86 | + |
| 87 | + initCredentialsAndAgent() |
| 88 | + initCaretPosition() |
| 89 | + } |
| 90 | + |
| 91 | + // Ideally we should call this method only once per recording session, but since we need a |
| 92 | + // `project` to be present it is currently hard to do with Junit 4. |
| 93 | + // Methods there are mostly idempotent though, so calling again for every test case should not |
| 94 | + // change anything. |
| 95 | + private fun initCredentialsAndAgent() { |
| 96 | + val credentials = TestingCredentials.dotcom |
| 97 | + CodyPersistentAccountsHost(project) |
| 98 | + .addAccount( |
| 99 | + SourcegraphServerPath.from(credentials.serverEndpoint, ""), |
| 100 | + login = "test_user", |
| 101 | + displayName = "Test User", |
| 102 | + token = credentials.token ?: credentials.redactedToken, |
| 103 | + id = "random-unique-testing-id-1337") |
| 104 | + |
| 105 | + assertNotNull( |
| 106 | + "Unable to start agent in a timely fashion!", |
| 107 | + CodyAgentService.getInstance(project) |
| 108 | + .startAgent(project) |
| 109 | + .completeOnTimeout(null, ASYNC_WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS) |
| 110 | + .get()) |
| 111 | + } |
| 112 | + |
| 113 | + private fun checkInitialConditions() { |
| 114 | + val project = myFixture.project |
| 115 | + |
| 116 | + // Check if the project is in dumb mode |
| 117 | + val isDumbMode = DumbService.getInstance(project).isDumb |
| 118 | + assertFalse("Project should not be in dumb mode", isDumbMode) |
| 119 | + |
| 120 | + // Check if the project is in LightEdit mode |
| 121 | + val isLightEditMode = LightEdit.owns(project) |
| 122 | + assertFalse("Project should not be in LightEdit mode", isLightEditMode) |
| 123 | + |
| 124 | + // Check the initial state of the action's presentation |
| 125 | + val action = ActionManager.getInstance().getAction("cody.documentCodeAction") |
| 126 | + val event = |
| 127 | + AnActionEvent.createFromAnAction(action, null, "", createEditorContext(myFixture.editor)) |
| 128 | + action.update(event) |
| 129 | + val presentation = event.presentation |
| 130 | + assertTrue("Action should be enabled", presentation.isEnabled) |
| 131 | + assertTrue("Action should be visible", presentation.isVisible) |
| 132 | + } |
| 133 | + |
| 134 | + private fun createEditorContext(editor: Editor): DataContext { |
| 135 | + return (editor as? EditorEx)?.dataContext ?: DataContext.EMPTY_CONTEXT |
| 136 | + } |
| 137 | + |
| 138 | + // This provides a crude mechanism for specifying the caret position in the test file. |
| 139 | + private fun initCaretPosition() { |
| 140 | + runInEdtAndWait { |
| 141 | + val virtualFile = myFixture.file.virtualFile |
| 142 | + val document = FileDocumentManager.getInstance().getDocument(virtualFile)!! |
| 143 | + val caretToken = "[[caret]]" |
| 144 | + val caretIndex = document.text.indexOf(caretToken) |
| 145 | + |
| 146 | + if (caretIndex != -1) { // Remove caret token from doc |
| 147 | + WriteCommandAction.runWriteCommandAction(project) { |
| 148 | + document.deleteString(caretIndex, caretIndex + caretToken.length) |
| 149 | + } |
| 150 | + // Place the caret at the position where the token was found. |
| 151 | + myFixture.editor.caretModel.moveToOffset(caretIndex) |
| 152 | + // myFixture.editor.selectionModel.setSelection(caretIndex, caretIndex) |
| 153 | + } else { |
| 154 | + initSelectionRange() |
| 155 | + } |
| 156 | + } |
| 157 | + } |
| 158 | + |
| 159 | + // Provides a mechanism to specify the selection range via [[start]] and [[end]]. |
| 160 | + // The tokens are removed and the range is selected, notifying the Agent. |
| 161 | + private fun initSelectionRange() { |
| 162 | + runInEdtAndWait { |
| 163 | + val virtualFile = myFixture.file.virtualFile |
| 164 | + val document = FileDocumentManager.getInstance().getDocument(virtualFile)!! |
| 165 | + val startToken = "[[start]]" |
| 166 | + val endToken = "[[end]]" |
| 167 | + val start = document.text.indexOf(startToken) |
| 168 | + val end = document.text.indexOf(endToken) |
| 169 | + // Remove the tokens from the document. |
| 170 | + if (start != -1 && end != -1) { |
| 171 | + ApplicationManager.getApplication().runWriteAction { |
| 172 | + document.deleteString(start, start + startToken.length) |
| 173 | + document.deleteString(end, end + endToken.length) |
| 174 | + } |
| 175 | + myFixture.editor.selectionModel.setSelection(start, end) |
| 176 | + } else { |
| 177 | + logger.warn("No caret or selection range specified in test file.") |
| 178 | + } |
| 179 | + } |
| 180 | + } |
| 181 | + |
| 182 | + private fun triggerAction(actionId: String) { |
| 183 | + runInEdtAndWait { |
| 184 | + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() |
| 185 | + EditorTestUtil.executeAction(myFixture.editor, actionId) |
| 186 | + } |
| 187 | + } |
| 188 | + |
| 189 | + protected fun activeSession(): FixupSession { |
| 190 | + assertActiveSession() |
| 191 | + return FixupService.getInstance(project).getActiveSession()!! |
| 192 | + } |
| 193 | + |
| 194 | + protected fun assertNoInlayShown() { |
| 195 | + runInEdtAndWait { |
| 196 | + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() |
| 197 | + assertFalse( |
| 198 | + "Lens group inlay should NOT be displayed", |
| 199 | + myFixture.editor.inlayModel.hasBlockElements()) |
| 200 | + } |
| 201 | + } |
| 202 | + |
| 203 | + protected fun assertInlayIsShown() { |
| 204 | + runInEdtAndWait { |
| 205 | + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() |
| 206 | + assertTrue( |
| 207 | + "Lens group inlay should be displayed", myFixture.editor.inlayModel.hasBlockElements()) |
| 208 | + } |
| 209 | + } |
| 210 | + |
| 211 | + protected fun assertNoActiveSession() { |
| 212 | + assertNull( |
| 213 | + "NO active session was expected", FixupService.getInstance(project).getActiveSession()) |
| 214 | + } |
| 215 | + |
| 216 | + protected fun assertActiveSession() { |
| 217 | + assertNotNull( |
| 218 | + "Active session was expected", FixupService.getInstance(project).getActiveSession()) |
| 219 | + } |
| 220 | + |
| 221 | + protected fun runAndWaitForNotifications( |
| 222 | + actionId: String, |
| 223 | + vararg topic: Topic<CodyInlineEditActionNotifier> |
| 224 | + ) { |
| 225 | + val futures = topic.associateWith { subscribeToTopic(it) } |
| 226 | + triggerAction(actionId) |
| 227 | + futures.forEach { (t, f) -> |
| 228 | + try { |
| 229 | + f.get() |
| 230 | + } catch (e: Exception) { |
| 231 | + assertTrue( |
| 232 | + "Error while awaiting ${t.displayName} notification: ${e.localizedMessage}", false) |
| 233 | + } |
| 234 | + } |
| 235 | + } |
| 236 | + |
| 237 | + // Returns a future that completes when the topic is published. |
| 238 | + private fun subscribeToTopic( |
| 239 | + topic: Topic<CodyInlineEditActionNotifier>, |
| 240 | + ): CompletableFuture<Void> { |
| 241 | + val future = CompletableFuture<Void>().orTimeout(ASYNC_WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS) |
| 242 | + project.messageBus |
| 243 | + .connect() |
| 244 | + .subscribe( |
| 245 | + topic, |
| 246 | + object : CodyInlineEditActionNotifier { |
| 247 | + override fun afterAction() { |
| 248 | + logger.warn("Notification sent for topic '${topic.displayName}'") |
| 249 | + future.complete(null) |
| 250 | + } |
| 251 | + }) |
| 252 | + logger.warn("Subscribed to topic: $topic") |
| 253 | + return future |
| 254 | + } |
| 255 | + |
| 256 | + protected fun hasJavadocComment(text: String): Boolean { |
| 257 | + // TODO: Check for the exact contents once they are frozen. |
| 258 | + val javadocPattern = Pattern.compile("/\\*\\*.*?\\*/", Pattern.DOTALL) |
| 259 | + return javadocPattern.matcher(text).find() |
| 260 | + } |
| 261 | + |
| 262 | + companion object { |
| 263 | + const val ASYNC_WAIT_TIMEOUT_SECONDS = 10L |
| 264 | + var myProject: Project? = null |
| 265 | + } |
| 266 | +} |
0 commit comments