From bad15005aa7cbc29a5d6d20db562e7840e3427fe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:12:30 +0000 Subject: [PATCH] fix(heartbeat): stop losing, duplicating and misattributing coding activity The server builds coding sessions per machine from heartbeats and closes a session after a 10 minute gap, so lost, duplicated or misattributed heartbeats skew measured effort. - Record a save only in the project that has the file open (or owns it). FileSaveListener notified every open project, so each save produced one write heartbeat per open project, each named after that project. - Listen on the editor event multicaster instead of per editor. The project service is created lazily by the first fileOpened event, after restored tabs already exist, so typing in those tabs was never tracked. The per-editor listeners also outlived their editors and stacked up when a file was reopened. - Apply settings changes to running project services. Disabling tracking did not stop collection or sending, enabling it after starting disabled never started tracking, and interval changes were ignored. - Flush pending and failed heartbeats (bounded to 2s) when a project closes or the IDE exits instead of clearing them. - Report the machine name the CLI and the other editors use (plain gethostname()). InetAddress.getLocalHost().hostName goes through DNS and can return e.g. "host.lan", which split JetBrains activity into separate sessions from terminal and AI activity on the same computer. The name is resolved once instead of on every heartbeat on the EDT. - Match exclude patterns against the path inside the project, so a project located under e.g. ~/build/ or /ci/out/ is still tracked. - Only apply the same-caret duplicate check to navigation; edits that leave the caret in place were dropped. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X5wQC8wrU4kY1jBfbzdquX --- .../jetbrains/config/ShellTimeConfigurable.kt | 8 ++ .../jetbrains/heartbeat/HeartbeatCollector.kt | 127 ++++++++++++------ .../jetbrains/listeners/FileEditorListener.kt | 22 ++- .../services/ShellTimeProjectService.kt | 58 ++++---- .../shelltime/jetbrains/utils/ProjectUtils.kt | 19 ++- .../shelltime/jetbrains/utils/SystemUtils.kt | 47 ++++++- .../jetbrains/utils/ProjectUtilsTest.kt | 26 ++++ .../jetbrains/utils/SystemUtilsTest.kt | 18 +++ 8 files changed, 244 insertions(+), 81 deletions(-) diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/config/ShellTimeConfigurable.kt b/src/main/kotlin/xyz/shelltime/jetbrains/config/ShellTimeConfigurable.kt index 9b6cdc0..11788f4 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/config/ShellTimeConfigurable.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/config/ShellTimeConfigurable.kt @@ -1,11 +1,14 @@ package xyz.shelltime.jetbrains.config +import com.intellij.openapi.components.service import com.intellij.openapi.options.Configurable +import com.intellij.openapi.project.ProjectManager import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBTextField import com.intellij.util.ui.FormBuilder import xyz.shelltime.jetbrains.ShellTimeBundle +import xyz.shelltime.jetbrains.services.ShellTimeProjectService import xyz.shelltime.jetbrains.services.ShellTimeService import javax.swing.JComponent import javax.swing.JPanel @@ -72,6 +75,11 @@ class ShellTimeConfigurable : Configurable { heartbeatInterval = heartbeatIntervalField?.text?.toLongOrNull() ?: Constants.DEFAULT_FLUSH_INTERVAL_MS ) + + // Apply the change to the running trackers, not only to the stored settings + ProjectManager.getInstance().openProjects + .filter { !it.isDisposed } + .forEach { it.service().updateSettings() } } override fun reset() { diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatCollector.kt b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatCollector.kt index 3ed1079..91484be 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatCollector.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatCollector.kt @@ -4,6 +4,7 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.editor.Document import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.editor.event.CaretEvent import com.intellij.openapi.editor.event.CaretListener import com.intellij.openapi.editor.event.DocumentEvent @@ -11,6 +12,7 @@ import com.intellij.openapi.editor.event.DocumentListener import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer import com.intellij.openapi.vfs.VirtualFile import com.intellij.xdebugger.XDebuggerManager import xyz.shelltime.jetbrains.config.Constants @@ -37,9 +39,13 @@ class HeartbeatCollector( ) : Disposable { private val logger = Logger("Collector", debug) private val lastHeartbeat = ConcurrentHashMap() + @Volatile private var lastActivity: LastActivityState? = null private val pendingHeartbeats = ConcurrentLinkedQueue() + /** Owns the editor listeners while collecting; null when stopped */ + private var listenerDisposable: Disposable? = null + private val documentListener = object : DocumentListener { override fun documentChanged(event: DocumentEvent) { handleDocumentChange(event) @@ -52,31 +58,61 @@ class HeartbeatCollector( } } + /** + * Start listening to edits and cursor moves in this project's editors. + * + * The listeners are added to the editor event multicaster, so they also cover + * editors that were opened before this collector existed (restored tabs). + * + * @param parentDisposable Disposed together with the listeners + */ + fun start(parentDisposable: Disposable) { + if (listenerDisposable != null) return + + val disposable = Disposer.newDisposable(parentDisposable, "ShellTimeHeartbeatListeners") + val multicaster = EditorFactory.getInstance().eventMulticaster + multicaster.addDocumentListener(documentListener, disposable) + multicaster.addCaretListener(caretListener, disposable) + listenerDisposable = disposable + + logger.log("Started collecting heartbeats") + } + + /** + * Stop listening to editor events. Already queued heartbeats are kept. + */ + fun stop() { + val disposable = listenerDisposable ?: return + listenerDisposable = null + Disposer.dispose(disposable) + logger.log("Stopped collecting heartbeats") + } + + /** + * Navigation events (caret moves, file switches) can repeat without the user + * doing anything, so they are dropped when the file and cursor are unchanged. + * Edits always count, even when they leave the caret in place. + */ + private fun isDuplicateNavigation(filePath: String, lineNumber: Int?, cursorPosition: Int?): Boolean { + val current = LastActivityState(filePath, lineNumber, cursorPosition) + val isDuplicate = lastActivity == current + lastActivity = current + + if (isDuplicate) { + logger.log("Skipping duplicate activity for $filePath") + } + return isDuplicate + } + /** * Check if a heartbeat should be sent for this file */ - private fun shouldSendHeartbeat( - filePath: String, - isWrite: Boolean, - lineNumber: Int?, - cursorPosition: Int? - ): Boolean { + private fun shouldSendHeartbeat(filePath: String, isWrite: Boolean): Boolean { // Saves always trigger heartbeats if (isWrite) { return true } - // Check for duplicate activity - lastActivity?.let { last -> - if (last.entity == filePath && - last.lineNumber == lineNumber && - last.cursorPosition == cursorPosition - ) { - logger.log("Skipping duplicate activity for $filePath") - return false - } - } - // Check debounce interval val now = System.currentTimeMillis() val lastTime = lastHeartbeat[filePath] ?: 0 @@ -85,8 +121,6 @@ class HeartbeatCollector( return false } - // Update last activity state - lastActivity = LastActivityState(filePath, lineNumber, cursorPosition) lastHeartbeat[filePath] = now return true } @@ -146,10 +180,21 @@ class HeartbeatCollector( private fun isValidFile(file: VirtualFile?): Boolean { if (file == null) return false if (!file.isInLocalFileSystem) return false - if (ProjectUtils.shouldExclude(file.path)) return false + if (ProjectUtils.shouldExclude(file.path, project.basePath)) return false return true } + /** + * Find an editor of this project that shows the document, preferring the selected one + */ + private fun findEditor(document: Document): Editor? { + val selected = FileEditorManager.getInstance(project).selectedTextEditor + if (selected != null && selected.document == document) { + return selected + } + return EditorFactory.getInstance().getEditors(document, project).firstOrNull() + } + /** * Handle document change events */ @@ -159,11 +204,15 @@ class HeartbeatCollector( if (!isValidFile(file)) return - val editor = getActiveEditor() - val lineNumber = editor?.let { it.caretModel.logicalPosition.line + 1 } - val cursorPosition = editor?.caretModel?.logicalPosition?.column + // The multicaster reports changes to every document in the IDE; only count + // documents that are open in an editor of this project. + val editor = findEditor(document) ?: return + val lineNumber = editor.caretModel.logicalPosition.line + 1 + val cursorPosition = editor.caretModel.logicalPosition.column + + lastActivity = LastActivityState(file.path, lineNumber, cursorPosition) - if (shouldSendHeartbeat(file.path, false, lineNumber, cursorPosition)) { + if (shouldSendHeartbeat(file.path, false)) { val heartbeat = createHeartbeat(file, document, false, lineNumber, cursorPosition) addHeartbeat(heartbeat) } @@ -174,6 +223,8 @@ class HeartbeatCollector( */ private fun handleCaretChange(event: CaretEvent) { val editor = event.editor + if (editor.project != project) return + val document = editor.document val file = FileDocumentManager.getInstance().getFile(document) ?: return @@ -182,7 +233,9 @@ class HeartbeatCollector( val lineNumber = event.newPosition.line + 1 val cursorPosition = event.newPosition.column - if (shouldSendHeartbeat(file.path, false, lineNumber, cursorPosition)) { + if (isDuplicateNavigation(file.path, lineNumber, cursorPosition)) return + + if (shouldSendHeartbeat(file.path, false)) { val heartbeat = createHeartbeat(file, document, false, lineNumber, cursorPosition) addHeartbeat(heartbeat) } @@ -195,11 +248,13 @@ class HeartbeatCollector( if (!isValidFile(file)) return val document = FileDocumentManager.getInstance().getDocument(file!!) ?: return - val editor = getActiveEditor() + val editor = findEditor(document) val lineNumber = editor?.let { it.caretModel.logicalPosition.line + 1 } val cursorPosition = editor?.caretModel?.logicalPosition?.column - if (shouldSendHeartbeat(file.path, false, lineNumber, cursorPosition)) { + if (isDuplicateNavigation(file.path, lineNumber, cursorPosition)) return + + if (shouldSendHeartbeat(file.path, false)) { val heartbeat = createHeartbeat(file, document, false, lineNumber, cursorPosition) addHeartbeat(heartbeat) } @@ -212,7 +267,7 @@ class HeartbeatCollector( if (!isValidFile(file)) return val document = FileDocumentManager.getInstance().getDocument(file!!) ?: return - val editor = getActiveEditor() + val editor = findEditor(document) val lineNumber = editor?.let { it.caretModel.logicalPosition.line + 1 } val cursorPosition = editor?.caretModel?.logicalPosition?.column @@ -225,21 +280,6 @@ class HeartbeatCollector( lastHeartbeat[file.path] = System.currentTimeMillis() } - /** - * Get the active editor for the project - */ - private fun getActiveEditor(): Editor? { - return FileEditorManager.getInstance(project).selectedTextEditor - } - - /** - * Register listeners for an editor - */ - fun registerEditor(editor: Editor) { - editor.document.addDocumentListener(documentListener, this) - editor.caretModel.addCaretListener(caretListener, this) - } - /** * Flush pending heartbeats * @@ -268,6 +308,7 @@ class HeartbeatCollector( } override fun dispose() { + stop() lastHeartbeat.clear() pendingHeartbeats.clear() logger.log("Disposed heartbeat collector") diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/listeners/FileEditorListener.kt b/src/main/kotlin/xyz/shelltime/jetbrains/listeners/FileEditorListener.kt index 257417f..1c39c9b 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/listeners/FileEditorListener.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/listeners/FileEditorListener.kt @@ -1,12 +1,15 @@ package xyz.shelltime.jetbrains.listeners import com.intellij.openapi.components.service +import com.intellij.openapi.editor.Document import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.fileEditor.FileDocumentManagerListener import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.fileEditor.FileEditorManagerEvent import com.intellij.openapi.fileEditor.FileEditorManagerListener import com.intellij.openapi.project.Project +import com.intellij.openapi.project.ProjectLocator +import com.intellij.openapi.project.ProjectManager import com.intellij.openapi.vfs.VirtualFile import xyz.shelltime.jetbrains.services.ShellTimeProjectService @@ -31,14 +34,19 @@ class FileEditorListener(private val project: Project) : FileEditorManagerListen */ class FileSaveListener : FileDocumentManagerListener { - override fun beforeDocumentSaving(document: com.intellij.openapi.editor.Document) { + override fun beforeDocumentSaving(document: Document) { val file = FileDocumentManager.getInstance().getFile(document) ?: return + val project = findProject(file) ?: return - // Notify all open projects about the save - com.intellij.openapi.project.ProjectManager.getInstance().openProjects.forEach { project -> - if (!project.isDisposed) { - project.service().onFileSave(file) - } - } + // Only the project the file belongs to records the save. Notifying every + // open project produced one write heartbeat per project, each attributed + // to that project. + project.service().onFileSave(file) + } + + private fun findProject(file: VirtualFile): Project? { + val openProjects = ProjectManager.getInstance().openProjects.filter { !it.isDisposed } + return openProjects.firstOrNull { FileEditorManager.getInstance(it).isFileOpen(file) } + ?: ProjectLocator.getInstance().guessProjectForFile(file)?.takeIf { !it.isDisposed } } } diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeProjectService.kt b/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeProjectService.kt index 1f67e3a..429bf83 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeProjectService.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeProjectService.kt @@ -2,16 +2,12 @@ package xyz.shelltime.jetbrains.services import com.intellij.openapi.Disposable import com.intellij.openapi.components.Service -import com.intellij.openapi.components.service -import com.intellij.openapi.editor.EditorFactory -import com.intellij.openapi.editor.event.EditorFactoryEvent -import com.intellij.openapi.editor.event.EditorFactoryListener -import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile import xyz.shelltime.jetbrains.heartbeat.HeartbeatCollector import xyz.shelltime.jetbrains.heartbeat.HeartbeatSender import xyz.shelltime.jetbrains.heartbeat.HeartbeatSenderCallback +import xyz.shelltime.jetbrains.utils.SystemUtils import xyz.shelltime.jetbrains.version.VersionChecker import kotlinx.coroutines.* @@ -31,16 +27,9 @@ class ShellTimeProjectService(private val project: Project) : Disposable { private var statusCallback: HeartbeatSenderCallback? = null private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - private val editorFactoryListener = object : EditorFactoryListener { - override fun editorCreated(event: EditorFactoryEvent) { - val editor = event.editor - val editorProject = editor.project - - // Only register listeners for editors in this project - if (editorProject == project) { - collector.registerEditor(editor) - } - } + companion object { + /** How long closing a project may wait for the last heartbeats to be sent */ + private const val FINAL_FLUSH_TIMEOUT_MS = 2_000L } init { @@ -71,8 +60,11 @@ class ShellTimeProjectService(private val project: Project) : Disposable { // Set status callback if already registered statusCallback?.let { sender.setCallback(it) } + // Resolve the machine name once, off the EDT + scope.launch { SystemUtils.getMachineName() } + // Register for editor events - EditorFactory.getInstance().addEditorFactoryListener(editorFactoryListener, this) + collector.start(this) // Start the sender sender.start() @@ -158,25 +150,45 @@ class ShellTimeProjectService(private val project: Project) : Disposable { */ fun updateSettings() { if (!settings.enabled) { - // Disable tracking + // Disable tracking, but still send what was collected before + if (::collector.isInitialized) { + collector.stop() + } if (::sender.isInitialized) { sender.stop() + scope.launch { sender.flush() } } return } - if (::collector.isInitialized) { - collector.setDebug(settings.debug) + if (!::collector.isInitialized) { + // Tracking was disabled when the project opened + start() + return } - if (::sender.isInitialized) { - sender.setDebug(settings.debug) - sender.setFlushInterval(settings.heartbeatInterval) - } + collector.setDebug(settings.debug) + collector.start(this) + + sender.setDebug(settings.debug) + sender.setFlushInterval(settings.heartbeatInterval) + sender.start() } override fun dispose() { + if (::collector.isInitialized) { + collector.stop() + } if (::sender.isInitialized) { + sender.stop() + // Send what is still queued; this runs when the project closes and on IDE exit + try { + runBlocking(Dispatchers.IO) { + withTimeoutOrNull(FINAL_FLUSH_TIMEOUT_MS) { sender.flush() } + } + } catch (e: Exception) { + // Best effort only + } sender.dispose() } if (::collector.isInitialized) { diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtils.kt b/src/main/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtils.kt index cfaa020..402852d 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtils.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtils.kt @@ -108,12 +108,25 @@ object ProjectUtils { /** * Check if a file should be excluded from tracking * + * Patterns are matched against the path inside the project, so a project that + * itself lives under a directory like `build/` or `out/` is still tracked. + * * @param filePath The file path to check + * @param projectBasePath The project root, if known * @return True if the file should be excluded */ - fun shouldExclude(filePath: String): Boolean { + fun shouldExclude(filePath: String, projectBasePath: String? = null): Boolean { + val normalizedPath = filePath.replace('\\', '/') + val basePath = projectBasePath?.replace('\\', '/')?.trimEnd('/') + val path = if (!basePath.isNullOrEmpty() && normalizedPath.startsWith("$basePath/")) { + // Keep the leading slash so top-level directories still match + normalizedPath.substring(basePath.length) + } else { + normalizedPath + } + // Exclude .git directory - if (filePath.contains("/.git/") || filePath.contains("\\.git\\")) { + if (path.contains("/.git/")) { return true } @@ -130,7 +143,7 @@ object ProjectUtils { ) for (pattern in excludePatterns) { - if (filePath.contains(pattern)) { + if (path.contains(pattern)) { return true } } diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/utils/SystemUtils.kt b/src/main/kotlin/xyz/shelltime/jetbrains/utils/SystemUtils.kt index 3960f49..3831147 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/utils/SystemUtils.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/utils/SystemUtils.kt @@ -1,7 +1,9 @@ package xyz.shelltime.jetbrains.utils +import java.io.File import java.net.InetAddress import java.util.UUID +import java.util.concurrent.TimeUnit /** * System-related utility functions @@ -28,16 +30,51 @@ object SystemUtils { return System.getProperty("os.version") ?: "Unknown" } + private val cachedMachineName: String by lazy { resolveMachineName() } + /** * Get the machine hostname + * + * This must match the name the ShellTime CLI and the other editor plugins report + * (plain gethostname()), because the server groups activity into sessions per + * machine. InetAddress.getLocalHost().hostName is avoided: the JDK resolves the + * name through DNS, which can return a different name (e.g. "host.lan" or an + * FQDN) and can block. The result is computed once. */ - fun getMachineName(): String { + fun getMachineName(): String = cachedMachineName + + private fun resolveMachineName(): String { + return readLinuxHostname() + ?: runHostnameCommand() + ?: System.getenv("COMPUTERNAME")?.takeIf { it.isNotBlank() } + ?: System.getenv("HOSTNAME")?.takeIf { it.isNotBlank() } + ?: try { + InetAddress.getLocalHost().hostName + } catch (e: Exception) { + null + } + ?: "Unknown" + } + + private fun readLinuxHostname(): String? { + return try { + File("/proc/sys/kernel/hostname").takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() } + } catch (e: Exception) { + null + } + } + + private fun runHostnameCommand(): String? { return try { - InetAddress.getLocalHost().hostName + val process = ProcessBuilder("hostname").redirectErrorStream(true).start() + if (!process.waitFor(2, TimeUnit.SECONDS)) { + process.destroyForcibly() + return null + } + if (process.exitValue() != 0) return null + process.inputStream.bufferedReader().readText().trim().takeIf { it.isNotEmpty() } } catch (e: Exception) { - System.getenv("COMPUTERNAME") - ?: System.getenv("HOSTNAME") - ?: "Unknown" + null } } diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtilsTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtilsTest.kt index 30e7282..954b759 100644 --- a/src/test/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtilsTest.kt +++ b/src/test/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtilsTest.kt @@ -36,6 +36,32 @@ class ProjectUtilsTest { assertFalse(ProjectUtils.shouldExclude("/project/index.js")) } + @Test + fun `shouldExclude ignores excluded directory names above the project root`() { + assertFalse(ProjectUtils.shouldExclude("/home/ci/build/repo/src/Main.kt", "/home/ci/build/repo")) + assertFalse(ProjectUtils.shouldExclude("/Users/me/out/app/index.js", "/Users/me/out/app/")) + assertFalse(ProjectUtils.shouldExclude("/go/src/vendor/tool/main.go", "/go/src/vendor/tool")) + } + + @Test + fun `shouldExclude still excludes directories inside the project`() { + assertTrue(ProjectUtils.shouldExclude("/home/ci/build/repo/build/classes/Main.class", "/home/ci/build/repo")) + assertTrue(ProjectUtils.shouldExclude("/home/ci/build/repo/.git/config", "/home/ci/build/repo")) + assertTrue(ProjectUtils.shouldExclude("/app/web/node_modules/pkg/index.js", "/app")) + } + + @Test + fun `shouldExclude checks the full path for files outside the project`() { + assertTrue(ProjectUtils.shouldExclude("/other/node_modules/pkg/index.js", "/app")) + assertTrue(ProjectUtils.shouldExclude("/application/build/Main.class", "/app")) + } + + @Test + fun `shouldExclude handles Windows separators`() { + assertTrue(ProjectUtils.shouldExclude("C:\\project\\.git\\config")) + assertFalse(ProjectUtils.shouldExclude("C:\\build\\project\\src\\Main.kt", "C:\\build\\project")) + } + @Test fun `getProjectName returns null project gracefully`() { val name = ProjectUtils.getProjectName(null, null) diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/utils/SystemUtilsTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/utils/SystemUtilsTest.kt index fc278f0..e5fdc5a 100644 --- a/src/test/kotlin/xyz/shelltime/jetbrains/utils/SystemUtilsTest.kt +++ b/src/test/kotlin/xyz/shelltime/jetbrains/utils/SystemUtilsTest.kt @@ -1,7 +1,9 @@ package xyz.shelltime.jetbrains.utils import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Assumptions.assumeTrue import org.junit.jupiter.api.Test +import java.io.File class SystemUtilsTest { @@ -23,6 +25,22 @@ class SystemUtilsTest { assertTrue(machineName.isNotEmpty()) } + @Test + fun `getMachineName is stable and has no surrounding whitespace`() { + val machineName = SystemUtils.getMachineName() + assertEquals(machineName, SystemUtils.getMachineName()) + assertEquals(machineName.trim(), machineName) + } + + @Test + fun `getMachineName matches the kernel hostname on Linux`() { + // The CLI daemon and the other editor plugins report gethostname(), and the + // server groups sessions per machine, so the names must be identical + val kernelHostname = File("/proc/sys/kernel/hostname") + assumeTrue(kernelHostname.isFile) + assertEquals(kernelHostname.readText().trim(), SystemUtils.getMachineName()) + } + @Test fun `generateUUID returns valid UUID format`() { val uuid = SystemUtils.generateUUID()