toolbox-updates: IDEA-264161 - integrate with Notification actions UI + tests

GitOrigin-RevId: decebea1255f76d5c2bea45b97506564868a9d8a
This commit is contained in:
Eugene Petrenko
2021-03-23 18:06:43 +00:00
committed by intellij-monorepo-bot
parent 314f02a866
commit d58ed02bca
7 changed files with 338 additions and 29 deletions
@@ -37,6 +37,9 @@
<httpRequestHandler implementation="org.jetbrains.ide.ToolboxRestService"/>
<toolboxServiceHandler implementation="org.jetbrains.ide.ToolboxUpdateNotificationHandler"/>
<toolboxServiceHandler implementation="org.jetbrains.ide.ToolboxRestartNotificationHandler"/>
<settingsEntryPointActionProvider implementation="org.jetbrains.ide.ToolboxSettingsActionRegistryActionProvider"/>
<httpRequestHandler implementation="org.jetbrains.builtInWebServer.liveReload.WebServerPageConnectionService$WebServerPageRequestHandler"/>
<registryKey description="Enables automatic reloading of a web page loaded from built-in web server when a related file is changed and saved."
@@ -45,7 +45,8 @@ interface ToolboxServiceHandler<T> {
*
* Use the [onResult] to pass a result back to Toolbox and
* to close the connection. The [lifetime] is disposed after
* the connection is closed too
* the connection is closed too, it must not be used after [onResult]
* callback is executed
*/
fun handleToolboxRequest(
lifetime: Disposable,
@@ -160,17 +161,23 @@ internal class ToolboxRestService : RestService() {
}
.thenAcceptAsync(
{ json ->
try {
//kill the heartbeat, it may close the lifetime too
runCatching {
heartbeat.cancel(false)
heartbeat.await()
channel.write(Unpooled.copiedBuffer(gson.toJson(json), Charsets.UTF_8))
}
finally {
runCatching {
channel.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT)
}
Disposer.dispose(lifetime)
//no need to do anything if it's already disposed
if (Disposer.isDisposed(lifetime)) {
//closing the channel just in case
runCatching { channel.close() }
return@thenAcceptAsync
}
runCatching { channel.write(Unpooled.copiedBuffer(gson.toJson(json), Charsets.UTF_8)) }
runCatching { channel.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT) }
Disposer.dispose(lifetime)
},
AppExecutorUtil.getAppExecutorService()
)
@@ -0,0 +1,77 @@
// Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package org.jetbrains.ide
import com.intellij.ide.actions.SettingsEntryPointAction
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.application.invokeLater
import com.intellij.openapi.components.*
import com.intellij.openapi.util.Disposer
import com.intellij.util.Alarm
import com.intellij.util.ui.update.MergingUpdateQueue
import com.intellij.util.ui.update.Update
import java.util.concurrent.ConcurrentHashMap
internal class ToolboxSettingsActionRegistryState: BaseState() {
val knownActions by list<String>()
}
@Service(Service.Level.APP)
@State(name = "toolbox-update-state", storages = [Storage(StoragePathMacros.CACHE_FILE)], allowLoadInTests = true)
internal class ToolboxSettingsActionRegistry : SimplePersistentStateComponent<ToolboxSettingsActionRegistryState>(ToolboxSettingsActionRegistryState()), Disposable {
private val pendingActions : MutableMap<String, AnAction> = ConcurrentHashMap()
private val alarm = MergingUpdateQueue("toolbox-updates", 500, true, null, this, null, Alarm.ThreadToUse.POOLED_THREAD).usePassThroughInUnitTestMode()
override fun dispose() = Unit
fun scheduleUpdate() {
alarm.queue(object: Update(this){
override fun run() {
//we would not like it to overflow
if (state.knownActions.size > 300) {
val tail = state.knownActions.toList().takeLast(30).toHashSet()
state.knownActions.clear()
state.knownActions.addAll(tail)
state.intIncrementModificationCount()
}
val ids = pendingActions.keys.toSortedSet()
val iconState = if (!state.knownActions.containsAll(ids)) {
state.knownActions.addAll(ids)
state.intIncrementModificationCount()
SettingsEntryPointAction.IconState.ApplicationUpdate
} else {
SettingsEntryPointAction.IconState.Current
}
invokeLater {
SettingsEntryPointAction.updateState(iconState)
}
}
})
}
fun registerUpdateAction(lifetime: Disposable, persistentActionId: String, action: AnAction) {
val dispose = Disposable {
pendingActions.remove(persistentActionId, action)
scheduleUpdate()
}
pendingActions[persistentActionId] = action
if (!Disposer.tryRegister(lifetime, dispose)) {
Disposer.dispose(dispose)
return
}
scheduleUpdate()
}
fun getActions() : List<AnAction> = pendingActions.entries.sortedBy { it.key }.map { it.value }
}
class ToolboxSettingsActionRegistryActionProvider : SettingsEntryPointAction.ActionProvider {
override fun getUpdateActions(context: DataContext) = service<ToolboxSettingsActionRegistry>().getActions()
}
@@ -3,26 +3,82 @@ package org.jetbrains.ide
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.intellij.ide.IdeBundle
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.invokeLater
import com.intellij.openapi.components.service
import com.intellij.openapi.project.DumbAwareAction
import com.intellij.openapi.util.Disposer
import com.intellij.util.Consumer
import com.intellij.util.concurrency.AppExecutorUtil
import java.util.concurrent.TimeUnit
internal class ToolboxUpdateNotificationHandler : ToolboxServiceHandler<ToolboxUpdateNotificationHandler.UpdateNotification> {
data class UpdateNotification(val version: String, val build: String)
internal data class UpdateNotification(val version: String, val build: String)
private fun parseUpdateNotificationRequest(request: JsonElement): UpdateNotification {
require(request.isJsonObject) { "JSON Object was expected" }
val obj = request.asJsonObject
val build = obj["build"]?.asString
val version = obj["version"]?.asString
require(!build.isNullOrBlank()) { "the `build` attribute must not be blank" }
require(!version.isNullOrBlank()) { "the `version` attribute must not be blank" }
return UpdateNotification(version = version, build = build)
}
internal class ToolboxUpdateNotificationHandler : ToolboxServiceHandler<UpdateNotification> {
override val requestName: String = "update-notification"
override fun parseRequest(request: JsonElement): UpdateNotification {
require(request.isJsonObject) { "JSON Object was expected" }
val obj = request.asJsonObject
val build = obj["build"]?.asString
val version = obj["version"]?.asString
require(!build.isNullOrBlank()) { "the `build` attribute must not be blank" }
require(!version.isNullOrBlank()) { "the `version` attribute must not be blank" }
return UpdateNotification(version = version, build = build)
}
override fun parseRequest(request: JsonElement) = parseUpdateNotificationRequest(request)
override fun handleToolboxRequest(lifetime: Disposable, request: UpdateNotification, onResult: (JsonElement) -> Unit) {
onResult(JsonObject().apply { addProperty("status", "not implemented") })
val actionHandler = Consumer<AnActionEvent> {
onResult(JsonObject().apply { addProperty("status", "accepted") })
}
val title = IdeBundle.message("toolbox.updates.download.update.action.text", request.build, request.version)
val description = IdeBundle.message("toolbox.updates.download.update.action.description", request.build, request.version)
val action = DumbAwareAction.create(title, actionHandler)
action.templatePresentation.description = description
service<ToolboxSettingsActionRegistry>().registerUpdateAction(lifetime, "toolbox-02-update-${request.build}", action)
}
}
internal class ToolboxRestartNotificationHandler : ToolboxServiceHandler<UpdateNotification> {
override val requestName: String = "restart-notification"
override fun parseRequest(request: JsonElement) = parseUpdateNotificationRequest(request)
override fun handleToolboxRequest(lifetime: Disposable, request: UpdateNotification, onResult: (JsonElement) -> Unit) {
val actionHandler = Consumer<AnActionEvent> {
//at the normal scenario, the lifetime is disposed after the connection is closed
//so Toolbox should get everything needed to handle the restart
//otherwise an exception is thrown here, so it's OK
Disposer.register(lifetime) {
AppExecutorUtil.getAppScheduledExecutorService().schedule(Runnable {
invokeLater {
val app = ApplicationManager.getApplication()
if (app?.isUnitTestMode == false) {
app.exit(false, true, false)
} else {
System.setProperty("toolbox-service-test-restart", "1")
}
}
}, 300, TimeUnit.MICROSECONDS)
}
onResult(JsonObject().apply {
addProperty("status", "accepted")
addProperty("pid", ProcessHandle.current().pid())
})
}
val title = IdeBundle.message("toolbox.updates.download.ready.action.text", request.build, request.version)
val description = IdeBundle.message("toolbox.updates.download.ready.action.description", request.build, request.version)
val action = DumbAwareAction.create(title, actionHandler)
action.templatePresentation.description = description
service<ToolboxSettingsActionRegistry>().registerUpdateAction(lifetime, "toolbox-02-update-${request.build}", action)
}
}
@@ -0,0 +1,25 @@
# run IDEA with -Dtoolbox.notification.token=token234
POST 127.0.0.1:63343/api/toolbox/update-notification
Authorization: toolbox token234
Content-Type: application/json
{
"build": "2024.3.5 RC",
"version": "2021.1 RC"
}
###
POST 127.0.0.1:63343/api/toolbox/restart-notification
Authorization: toolbox token234
Content-Type: application/json
{
"build": "2024.3.5 RC",
"version": "2021.1 RC"
}
@@ -4,13 +4,23 @@ package org.jetbrains.ide
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import com.intellij.ide.actions.SettingsEntryPointAction
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.impl.NonBlockingReadActionImpl
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.ApplicationRule
import com.intellij.testFramework.ExtensionTestUtil
import com.intellij.testFramework.LightPlatformTestCase
import com.intellij.testFramework.TestApplicationManager
import com.intellij.util.ui.UIUtil
import io.netty.handler.codec.http.HttpResponseStatus
import org.assertj.core.api.Assertions
import org.junit.Assert
import org.junit.Test
import org.junit.*
import java.awt.event.KeyEvent
import java.io.InputStream
import java.net.URI
import java.net.http.HttpClient
@@ -20,16 +30,31 @@ import java.nio.charset.StandardCharsets
import java.util.*
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import javax.swing.JPanel
import kotlin.concurrent.thread
private abstract class ToolboxServiceHandlerX : ToolboxServiceHandler<JsonElement> {
override val requestName: String = "update-notification"
override fun parseRequest(request: JsonElement): JsonElement = request
}
internal class ToolboxUpdateServiceTest : BuiltInServerTestCase() {
internal class ToolboxUpdateServiceTest {
private val notifyUpdatePayload = "{'build': '123', 'version': '2021.1 RC'}"
private val notifyUpdateAction = "update-notification"
private val notifyRestartPayload = "{'build': '123', 'version': '2021.1 RC'}"
private val notifyRestartAction = "restart-notification"
@Rule
@JvmField
val appRule = ApplicationRule()
@Before
fun runServer() {
BuiltInServerManager.getInstance().waitForStart()
}
@Test
fun testShouldCheckToken_noToken() {
doToolbox(
@@ -45,6 +70,89 @@ internal class ToolboxUpdateServiceTest : BuiltInServerTestCase() {
)
}
@Test
fun testRestartAction() = doActionTest(notifyRestartAction, notifyRestartPayload) {
Assert.assertEquals("$actions", 1, actions.size)
val theAction = actions.single()
runAction(theAction)
doEventsWhile(disconnected)
println("payload: $payload")
val json = JsonParser.parseString(payload)
Assert.assertTrue(json.isJsonObject)
Assert.assertEquals("accepted", json.asJsonObject["status"]?.asString)
Assert.assertEquals(ProcessHandle.current().pid(), json.asJsonObject["pid"]?.asLong)
doEventsWhile(iterations = 10) { actions.isNotEmpty() }
Assert.assertEquals("$actions", 0, actions.size)
val cond = { System.getProperty("toolbox-service-test-restart") == "1" }
doEventsWhile(iterations = 10) { !cond() }
Assert.assertTrue("Application must be disposed", cond())
}
@Test
fun testUpdateAction() = doActionTest(notifyUpdateAction, notifyRestartPayload) {
Assert.assertEquals("$actions", 1, actions.size)
val theAction = actions.single()
runAction(theAction)
doEventsWhile(disconnected)
println("payload: $payload")
val json = JsonParser.parseString(payload)
Assert.assertTrue(json.isJsonObject)
Assert.assertEquals("accepted", json.asJsonObject["status"]?.asString)
doEventsWhile(10) { actions.isNotEmpty() }
Assert.assertEquals("$actions", 0, actions.size)
}
private class ToolboxActionFixture {
val connected = CountDownLatch(1)
val disconnected = CountDownLatch(1)
lateinit var payload: String
val actions
get() = SettingsEntryPointAction.ActionProvider.EP_NAME
.extensionList.filterIsInstance<ToolboxSettingsActionRegistryActionProvider>()
.flatMap {
it.getUpdateActions(DataContext.EMPTY_CONTEXT)
}
}
private fun doActionTest(requestName: String,
requestPayload: String,
logic: ToolboxActionFixture.() -> Unit) {
val f = ToolboxActionFixture()
val thread = thread {
doToolbox(
action = requestName, payload = requestPayload,
includeToken = true, responseStatus = HttpResponseStatus.OK
) {
f.connected.countDown()
try {
f.payload = it.body().readAllBytes().toString(Charsets.UTF_8)
}
finally {
f.disconnected.countDown()
}
}
}
try {
doEventsWhile(f.connected)
doEventsWhile(10) { f.actions.isEmpty() }
return f.logic()
}
finally {
thread.interrupt()
thread.join()
}
}
@Test
fun testShouldCheckToken_withToken_beat_and_response() {
val condition = CountDownLatch(10)
@@ -61,7 +169,7 @@ internal class ToolboxUpdateServiceTest : BuiltInServerTestCase() {
withExtension(ext) {
doToolbox(
action = notifyUpdateAction, payload = notifyUpdatePayload,
action = ext.requestName,
includeToken = true, responseStatus = HttpResponseStatus.OK,
) { response ->
val body = response.body()
@@ -93,7 +201,7 @@ internal class ToolboxUpdateServiceTest : BuiltInServerTestCase() {
withExtension(ext) {
doToolbox(
action = notifyUpdateAction, payload = notifyUpdatePayload,
action = ext.requestName,
includeToken = true, responseStatus = HttpResponseStatus.OK,
) { response ->
var counter = 10
@@ -121,7 +229,7 @@ internal class ToolboxUpdateServiceTest : BuiltInServerTestCase() {
}
private fun doToolbox(action: String,
payload: String,
payload: String = " { } ",
includeToken: Boolean = true,
responseStatus: HttpResponseStatus,
body : (HttpResponse<InputStream>) -> Unit = {}
@@ -165,3 +273,29 @@ private inline fun <Y> withSystemProperty(key: String, value: String, action: ()
}
}
}
private fun doEventsWhile(latch: CountDownLatch) = doEventsWhile {
!latch.await(10, TimeUnit.MILLISECONDS)
}
private fun doEventsWhile(iterations: Int = Int.MAX_VALUE / 2,
condition: () -> Boolean = { true }) {
repeat(iterations) {
if (!condition()) return
ApplicationManager.getApplication().invokeAndWait {
NonBlockingReadActionImpl.waitForAsyncTaskCompletion()
UIUtil.dispatchAllInvocationEvents()
}
if (!condition()) return
Thread.sleep(30)
}
}
private fun runAction(theAction: AnAction) {
ApplicationManager.getApplication().invokeAndWait {
val event = KeyEvent(JPanel(), 1, 0, 0, 0, ' ')
ActionManager.getInstance().tryToExecute(theAction, event, null, null, true)
}
}
@@ -2563,3 +2563,10 @@ trusted.hosts.settings.new.trusted.folder.file.chooser.title=Select Trusted Loca
windows.native.common.dialog.open=Open
windows.native.common.dialog.select.folder=Select Folder
dialog.message.press.for.field.hints=Press {0} for field hints
toolbox.updates.download.update.action.text=Update to {0}...
toolbox.updates.download.update.action.description=Toolbox App will download the update to {0} ({1}) and let you \
know when the update is ready to switch
toolbox.updates.download.ready.action.text=Restart to {0}...
toolbox.updates.download.ready.action.description=Toolbox App has prepared an update to {0} ({1}). Restart to the new version?