diff --git a/tests/com.espressif.idf.ui.test/META-INF/MANIFEST.MF b/tests/com.espressif.idf.ui.test/META-INF/MANIFEST.MF index 5556ed8d8..ee1dbab37 100644 --- a/tests/com.espressif.idf.ui.test/META-INF/MANIFEST.MF +++ b/tests/com.espressif.idf.ui.test/META-INF/MANIFEST.MF @@ -7,6 +7,7 @@ Bundle-RequiredExecutionEnvironment: JavaSE-21 Automatic-Module-Name: com.espressif.idf.tests Require-Bundle: org.eclipse.swtbot.go;bundle-version="2.7.0", org.eclipse.launchbar.core, + org.eclipse.debug.core, slf4j.api, com.espressif.idf.ui;bundle-version="1.0.1" Bundle-ActivationPolicy: lazy diff --git a/tests/com.espressif.idf.ui.test/src/com/espressif/idf/ui/test/executable/cases/project/IDFProjectDebugProcessTest.java b/tests/com.espressif.idf.ui.test/src/com/espressif/idf/ui/test/executable/cases/project/IDFProjectDebugProcessTest.java new file mode 100644 index 000000000..59f5f8b40 --- /dev/null +++ b/tests/com.espressif.idf.ui.test/src/com/espressif/idf/ui/test/executable/cases/project/IDFProjectDebugProcessTest.java @@ -0,0 +1,759 @@ +/******************************************************************************* + * Copyright 2026 Espressif Systems (Shanghai) PTE LTD. All rights reserved. + * Use is subject to license terms. + *******************************************************************************/ +package com.espressif.idf.ui.test.executable.cases.project; + +import static org.eclipse.swtbot.swt.finder.waits.Conditions.widgetIsEnabled; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; + +import java.io.IOException; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.lang3.SystemUtils; +import org.eclipse.core.resources.IBuildConfiguration; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.debug.core.DebugPlugin; +import org.eclipse.debug.core.ILaunchManager; +import org.eclipse.debug.core.ILaunchMode; +import org.eclipse.launchbar.core.ILaunchBarManager; +import org.eclipse.swtbot.eclipse.finder.SWTWorkbenchBot; +import org.eclipse.swtbot.swt.finder.waits.DefaultCondition; +import org.eclipse.swtbot.swt.finder.exceptions.WidgetNotFoundException; +import org.eclipse.swtbot.swt.finder.junit.SWTBotJunit4ClassRunner; +import org.eclipse.swtbot.swt.finder.widgets.SWTBotCheckBox; +import org.eclipse.swtbot.swt.finder.widgets.SWTBotCombo; +import org.eclipse.swtbot.swt.finder.widgets.SWTBotShell; +import org.junit.AfterClass; +import org.junit.AssumptionViolatedException; +import org.junit.BeforeClass; +import org.junit.FixMethodOrder; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TestRule; +import org.junit.runner.RunWith; +import org.junit.runners.MethodSorters; +import org.junit.runners.model.Statement; + +import com.espressif.idf.core.IDFCorePlugin; +import com.espressif.idf.core.build.IDFBuildConfigurationProvider; +import com.espressif.idf.ui.test.common.WorkBenchSWTBot; +import com.espressif.idf.ui.test.common.utility.TestWidgetWaitUtility; +import com.espressif.idf.ui.test.operations.EnvSetupOperations; +import com.espressif.idf.ui.test.operations.ProjectTestOperations; +import com.espressif.idf.ui.test.operations.selectors.LaunchBarConfigSelector; +import com.espressif.idf.ui.test.operations.selectors.LaunchBarTargetSelector; + +/** + * Hardware E2E test: create → build → UART flash (ESP32) → select ESP32-ETHERNET-KIT → + * start OpenOCD/GDB debugging via Debug As and verify the session (Step Over). + *

+ * Mirrors the VS Code hardware debug flow from {@code project-hardware-e2e-test.ts}. + * + * @author Andrii Filippov + * + */ +@SuppressWarnings("restriction") +@RunWith(SWTBotJunit4ClassRunner.class) +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +public class IDFProjectDebugProcessTest +{ + private static final String PROJECT_NAME = "NewProjectDebugProcessTest"; + private static final String ESP32_TARGET = "esp32"; + private static final String ETHERNET_KIT_BOARD_PREFIX = "ESP32-ETHERNET-KIT"; + private static final Pattern DEBUG_FATAL_ERROR_PATTERN = Pattern.compile( + "Target failure|Error: .*failed to halt|OpenOCD failed|LIBUSB_ERROR|failed to connect", + Pattern.CASE_INSENSITIVE); + + private static final Pattern[] TARGET_DETECTION_PATTERNS = new Pattern[] { + Pattern.compile("Connected to\\s+(ESP32[-A-Z0-9]*)\\b", Pattern.CASE_INSENSITIVE), + Pattern.compile("Chip type:\\s*(ESP32[-A-Z0-9]*)\\b", Pattern.CASE_INSENSITIVE), + Pattern.compile("Detecting chip type\\.\\.\\.\\s*(ESP32[-A-Z0-9]*)\\b", Pattern.CASE_INSENSITIVE) + }; + + /** + * Rewrites a blank SWTBot {@link AssertionError} so the runner summary, which prints only + * {@code class: message}, still shows the step and the last checkpoint. + */ + @Rule + public final TestRule reportDebugStep = (base, description) -> new Statement() + { + @Override + public void evaluate() throws Throwable + { + try + { + base.evaluate(); + } + catch (AssumptionViolatedException skipped) + { + throw skipped; + } + catch (Throwable failure) + { + String message = failure.getMessage(); + if (message != null && message.startsWith("Debug test failed at step [")) + { + throw failure; + } + String cause = failure.getClass().getSimpleName() + ": " + + (message == null || message.trim().isEmpty() ? "" : message.trim()); + cause = cause.replace('\n', ' ').replace('\r', ' '); + throw new AssertionError("Debug test failed at step [" + ProjectTestOperations.debugCheckpoint() + + "]. Cause: " + cause); + } + } + }; + + @BeforeClass + public static void beforeTestClass() throws Exception + { + Fixture.loadEnv(); + } + + @AfterClass + public static void tearDown() + { + Fixture.cleanupEnvironment(); + } + + @Test + public void givenNewProjectBuiltAndFlashedViaUartWhenDebugWithEthernetKitThenDebugSessionStarts() + throws Exception + { + assumeTrue("Linux only: hardware debug test requires Linux CI/lab boards", SystemUtils.IS_OS_LINUX); + + runDebugStep("create project", () -> { + Fixture.givenNewEspressifIDFProjectIsSelected("EspressIf", "Espressif IDF Project"); + Fixture.givenProjectNameIs(PROJECT_NAME); + Fixture.whenNewProjectIsSelected(); + }); + runDebugStep("turn off serial monitor after flashing", + () -> Fixture.whenTurnOffOpenSerialMonitorAfterFlashingInLaunchConfig()); + runDebugStep("detect ESP32 UART port", () -> { + String esp32SerialPort = Fixture.whenDetectAndSelectEsp32UartSerialPort(); + assumeTrue("Skipping hardware debug test: no ESP32 UART target detected", esp32SerialPort != null); + }); + runDebugStep("build project", () -> Fixture.whenProjectIsBuiltUsingContextMenu()); + runDebugStep("flash project via UART", () -> Fixture.whenFlashProject()); + runDebugStep("verify flash finished", () -> Fixture.thenVerifyFlashDoneSuccessfully()); + runDebugStep("select ESP32-ETHERNET-KIT", () -> assumeTrue( + "Skipping hardware debug test: ESP32-ETHERNET-KIT board was not detected", + Fixture.whenSelectEsp32EthernetKitBoard())); + + // Run-mode steps stay above. The ESP-IDF Application configuration becomes the debug + // configuration only after Launch Mode changes from Run to Debug. + runDebugStep("switch Launch Mode to Debug", () -> Fixture.whenLaunchModeIsSwitchedToDebug()); + runDebugStep("start debugging", () -> Fixture.whenStartDebuggingUsingContextMenu()); + runDebugStep("wait until suspended at app_main", () -> Fixture.thenVerifyDebugSessionStarted()); + runDebugStep("check OpenOCD console after suspend", () -> Fixture.thenVerifyNoFatalOpenOcdErrors()); + runDebugStep("Step Over", () -> Fixture.whenStepOver()); + runDebugStep("check session still active after Step Over", () -> Fixture.thenVerifyDebugSessionStillActive()); + runDebugStep("stop debugging", () -> Fixture.whenStopDebugging()); + } + + @FunctionalInterface + private interface DebugStep + { + void run() throws Exception; + } + + /** + * Runs one debug-test step and always reports the step name. The hardware runner summary prints + * only {@code exception class: message}, and SWTBot sometimes throws {@link AssertionError} with + * an empty message, so the step name and a short cause are put into the message itself. + */ + private static void runDebugStep(String step, DebugStep action) throws Exception + { + ProjectTestOperations.noteDebugCheckpoint(step); + System.out.println("[IDFProjectDebugProcessTest] START " + step); + long started = System.currentTimeMillis(); + try + { + action.run(); + System.out.println("[IDFProjectDebugProcessTest] OK " + step + " in " + + (System.currentTimeMillis() - started) + " ms"); + } + catch (AssumptionViolatedException skipped) + { + System.out.println("[IDFProjectDebugProcessTest] SKIP " + step + ": " + skipped.getMessage()); + throw skipped; + } + catch (Throwable failure) + { + long elapsed = System.currentTimeMillis() - started; + String checkpoint = ProjectTestOperations.debugCheckpoint(); + String message = "Debug test failed at step [" + step + "]" + + (checkpoint.isEmpty() || checkpoint.equals(step) ? "" : ", checkpoint [" + checkpoint + "]") + + " after " + elapsed + " ms. Cause: " + oneLineCause(failure); + System.err.println("[IDFProjectDebugProcessTest] FAIL " + message); + System.err.println(describeFailure(failure)); + System.err.println(Fixture.failureContext()); + // No cause chain: the runner summary prints the root exception, and that used to be a + // blank SWTBot AssertionError. + throw new AssertionError(message); + } + } + + private static String oneLineCause(Throwable failure) + { + String text = failure.getMessage(); + String summary = failure.getClass().getSimpleName() + ": " + + (text == null || text.trim().isEmpty() ? "" : text.trim()); + summary = summary.replace('\n', ' ').replace('\r', ' '); + if (summary.length() > 300) + { + summary = summary.substring(0, 300); + } + return summary; + } + + private static String describeFailure(Throwable failure) + { + StringBuilder detail = new StringBuilder(); + Throwable current = failure; + int depth = 0; + while (current != null && depth < 4) + { + if (depth > 0) + { + detail.append(" Caused by: "); + } + detail.append(current.getClass().getName()); + String message = current.getMessage(); + detail.append(": ").append(message == null || message.trim().isEmpty() ? "" : message.trim()); + StackTraceElement[] stack = current.getStackTrace(); + int frames = Math.min(stack.length, 6); + for (int i = 0; i < frames; i++) + { + detail.append("\n at ").append(stack[i]); + } + current = current.getCause(); + depth++; + } + return detail.toString(); + } + + private static class Fixture + { + private static SWTWorkbenchBot bot; + private static String category; + private static String subCategory; + private static String projectName; + + private static void loadEnv() throws Exception + { + bot = WorkBenchSWTBot.getBot(); + EnvSetupOperations.setupEspressifEnv(bot); + bot.sleep(1000); + ProjectTestOperations.deleteAllProjects(bot); + } + + private static void givenNewEspressifIDFProjectIsSelected(String category, String subCategory) + { + Fixture.category = category; + Fixture.subCategory = subCategory; + } + + private static void givenProjectNameIs(String projectName) + { + Fixture.projectName = projectName; + } + + private static void whenNewProjectIsSelected() throws Exception + { + ProjectTestOperations.setupProject(projectName, category, subCategory, bot); + TestWidgetWaitUtility.waitForOperationsInProgressToFinishAsync(bot); + } + + private static void whenTurnOffOpenSerialMonitorAfterFlashingInLaunchConfig() throws Exception + { + LaunchBarConfigSelector configSelector = new LaunchBarConfigSelector(bot); + configSelector.clickEdit(); + + TestWidgetWaitUtility.waitForDialogToAppear(bot, "Edit Configuration", 20000); + + bot.cTabItem("Main").activate(); + + SWTBotCheckBox checkBox = bot.checkBox("Open Serial Monitor After Flashing"); + if (checkBox.isChecked()) + { + checkBox.click(); + } + + bot.button("OK").click(); + } + + /** + * Opens New ESP Target, scans serial ports with detailed output, and stops as soon as + * an esp32 chip is detected. Finishes the dialog with that port selected. + * + * @return the selected ESP32 serial port, or {@code null} if none was found + */ + private static String whenDetectAndSelectEsp32UartSerialPort() throws Exception + { + LaunchBarTargetSelector targetSelector = new LaunchBarTargetSelector(bot); + targetSelector.clickEdit(); + + TestWidgetWaitUtility.waitForDialogToAppear(bot, "New ESP Target", 20000); + + SWTBotShell shell = bot.shell("New ESP Target"); + shell.setFocus(); + + SWTBotCheckBox detailedOutput = bot.checkBox("Enable detailed output"); + if (!detailedOutput.isChecked()) + { + detailedOutput.click(); + } + + SWTBotCombo serialPortCombo = bot.comboBoxWithLabel("Serial Port:"); + String[] serialPorts = serialPortCombo.items(); + + for (String serialPort : serialPorts) + { + if (serialPort == null || serialPort.trim().isEmpty()) + { + continue; + } + + System.out.println("Checking serial port: " + serialPort); + + String outputBeforeSelection = readTargetDetectionOutput(); + serialPortCombo.setSelection(serialPort); + + // Wait for target auto-detection output to be printed. + bot.sleep(3000); + + String outputAfterSelection = readTargetDetectionOutput(); + String newOutput = getNewOutputPart(outputBeforeSelection, outputAfterSelection); + String detectedTarget = extractTargetFromDetectionOutput(newOutput); + + if (detectedTarget == null || detectedTarget.trim().isEmpty()) + { + System.out.println("No ESP target detected for serial port: " + serialPort); + continue; + } + + System.out.println("Detected ESP target: " + detectedTarget + " on port: " + serialPort); + + if (ESP32_TARGET.equals(detectedTarget)) + { + System.out.println("ESP32 UART port found — stopping discovery and applying: " + serialPort); + TestWidgetWaitUtility.waitForOperationsInProgressToFinishSync(bot); + shell.setFocus(); + bot.button("Finish").click(); + TestWidgetWaitUtility.waitForOperationsInProgressToFinishAsync(bot); + return serialPort; + } + } + + System.out.println("No esp32 target among detected ports"); + bot.button("Cancel").click(); + return null; + } + + /** + * Selects an ESP32-ETHERNET-KIT board entry from the New ESP Target Board combo. + * + * @return true if a matching board was found and selected + */ + private static boolean whenSelectEsp32EthernetKitBoard() throws Exception + { + LaunchBarTargetSelector targetSelector = new LaunchBarTargetSelector(bot); + targetSelector.clickEdit(); + + TestWidgetWaitUtility.waitForDialogToAppear(bot, "New ESP Target", 20000); + + SWTBotShell shell = bot.shell("New ESP Target"); + shell.setFocus(); + + // Ensure IDF target is esp32 so Ethernet Kit boards are listed. + try + { + bot.comboBoxWithLabel("IDF Target").setSelection(ESP32_TARGET); + bot.sleep(2000); + } + catch (WidgetNotFoundException ignored) + { + // Label text may differ slightly across versions; Board combo is still attempted. + } + + SWTBotCombo boardCombo = bot.comboBoxWithLabel("Board:"); + String[] boards = boardCombo.items(); + String match = null; + + for (String board : boards) + { + if (board != null && board.startsWith(ETHERNET_KIT_BOARD_PREFIX)) + { + match = board; + break; + } + } + + if (match == null) + { + System.out.println("ESP32-ETHERNET-KIT not found in Board combo. Available: " + + String.join(", ", boards)); + bot.button("Cancel").click(); + return false; + } + + System.out.println("Selecting board: " + match); + boardCombo.setSelection(match); + TestWidgetWaitUtility.waitForOperationsInProgressToFinishSync(bot); + shell.setFocus(); + bot.button("Finish").click(); + TestWidgetWaitUtility.waitForOperationsInProgressToFinishAsync(bot); + return true; + } + + private static void whenProjectIsBuiltUsingContextMenu() throws IOException + { + ProjectTestOperations.buildProjectUsingContextMenu(projectName, bot); + ProjectTestOperations.waitForProjectBuild(bot); + TestWidgetWaitUtility.waitForOperationsInProgressToFinishAsync(bot); + } + + private static void whenFlashProject() throws IOException + { + ProjectTestOperations.launchCommandUsingContextMenuWithoutWaitingForJobs(projectName, bot, + "Run Configurations..."); + + TestWidgetWaitUtility.waitForDialogToAppear(bot, "Run Configurations", 10000); + + bot.tree().getTreeItem("ESP-IDF Application").select(); + bot.tree().getTreeItem("ESP-IDF Application").expand(); + bot.tree().getTreeItem("ESP-IDF Application").getNode(projectName).select(); + + bot.waitUntil(widgetIsEnabled(bot.button("Run")), 5000); + bot.button("Run").click(); + } + + private static void thenVerifyFlashDoneSuccessfully() throws Exception + { + ProjectTestOperations.waitForProjectFlash(bot); + } + + private static void whenLaunchModeIsSwitchedToDebug() + { + ProjectTestOperations.noteDebugCheckpoint("set Launch Mode to Debug"); + ILaunchMode mode = DebugPlugin.getDefault().getLaunchManager().getLaunchMode(ILaunchManager.DEBUG_MODE); + if (mode == null) + { + throw new AssertionError("Unknown launch mode: " + ILaunchManager.DEBUG_MODE); + } + try + { + IDFCorePlugin.getService(ILaunchBarManager.class).setActiveLaunchMode(mode); + } + catch (Exception e) + { + throw new AssertionError("Unable to select the Debug launch mode", e); + } + + String debugConfigPrefix = IDFBuildConfigurationProvider.ID + "/idf." + ILaunchManager.DEBUG_MODE + "."; + bot.waitUntil(new DefaultCondition() + { + @Override + public boolean test() throws Exception + { + return activeBuildConfigName().startsWith(debugConfigPrefix); + } + + @Override + public String getFailureMessage() + { + return "Launch Mode is Debug, but the active build configuration is still " + + activeBuildConfigName(); + } + }, 30000, 500); + ProjectTestOperations.noteDebugCheckpoint("active configuration is " + activeBuildConfigName()); + } + + private static String activeBuildConfigName() + { + IProject project = ResourcesPlugin.getWorkspace().getRoot().getProject(projectName); + if (!project.isAccessible()) + { + return IBuildConfiguration.DEFAULT_CONFIG_NAME; + } + try + { + return project.getActiveBuildConfig().getName(); + } + catch (Exception e) + { + return IBuildConfiguration.DEFAULT_CONFIG_NAME; + } + } + + private static void whenStartDebuggingUsingContextMenu() + { + ProjectTestOperations.startDebuggingUsingContextMenu(projectName, bot); + // Give OpenOCD/GDB and perspective-switch UI time to appear. + bot.sleep(3000); + } + + private static void thenVerifyDebugSessionStarted() throws Exception + { + ProjectTestOperations.waitForDebugSessionStarted(bot); + // Settle Debug perspective / toolbar after suspend at app_main. + bot.sleep(3000); + } + + private static void thenVerifyNoFatalOpenOcdErrors() + { + String consoleText = ProjectTestOperations.readDebugRelatedConsoleText(bot); + assertFalse("Fatal OpenOCD error detected during debug session.\nConsole:\n" + consoleText, + DEBUG_FATAL_ERROR_PATTERN.matcher(consoleText).find()); + assertFalse("Debug session already shut down before assertions.\nConsole:\n" + consoleText, + consoleText.contains("shutdown command invoked") + || consoleText.contains("dropped 'gdb' connection")); + assertTrue("Expected an active debug launch after suspend", ProjectTestOperations.hasActiveLaunch()); + } + + private static void whenStepOver() + { + bot.sleep(2000); + ProjectTestOperations.performDebugStepOver(projectName, bot); + bot.sleep(2000); + } + + private static void thenVerifyDebugSessionStillActive() + { + String consoleText = ProjectTestOperations.readDebugRelatedConsoleText(bot); + assertFalse("Fatal OpenOCD error after Step Over.\nConsole:\n" + consoleText, + DEBUG_FATAL_ERROR_PATTERN.matcher(consoleText).find()); + assertFalse("Debug session terminated unexpectedly after Step Over.\nConsole:\n" + consoleText, + consoleText.contains("shutdown command invoked")); + assertTrue("Debug launch terminated unexpectedly after Step Over", + ProjectTestOperations.hasActiveLaunch()); + } + + private static void whenStopDebugging() + { + stopDebugSessionAndKillProcesses(); + bot.sleep(2000); + } + + private static void stopDebugSessionAndKillProcesses() + { + ProjectTestOperations.stopDebugSessionAndKillProcesses(bot, projectName); + } + + /** + * Short snapshot attached to a failed step. Kept bounded so the runner summary stays readable. + */ + private static String failureContext() + { + StringBuilder context = new StringBuilder(); + try + { + context.append("activeLaunch=").append(ProjectTestOperations.hasActiveLaunch()); + } + catch (Throwable failure) + { + context.append("activeLaunch="); + } + try + { + String console = bot == null ? "" : ProjectTestOperations.readDebugRelatedConsoleText(bot); + if (console.length() > 1200) + { + console = console.substring(console.length() - 1200); + } + context.append("\nconsoleTail:\n").append(console.isEmpty() ? "" : console); + } + catch (Throwable failure) + { + context.append("\nconsoleTail="); + } + return context.toString(); + } + + private static void cleanupEnvironment() + { + try + { + ProjectTestOperations.leaveDebugUi(bot, projectName); + } + catch (Exception e) + { + System.err.println("leaveDebugUi failed: " + e.getMessage()); + } + + // Launch Mode is workbench-wide. Later tests edit the ESP-IDF Application configuration in Run. + try + { + ILaunchMode runMode = DebugPlugin.getDefault().getLaunchManager() + .getLaunchMode(ILaunchManager.RUN_MODE); + if (runMode != null) + { + IDFCorePlugin.getService(ILaunchBarManager.class).setActiveLaunchMode(runMode); + } + } + catch (Exception e) + { + System.err.println("restore Run launch mode failed: " + e.getMessage()); + } + + // Delete via workspace API first — UI deleteAllProjects calls WaitUtils.waitForJobs() + // which times out for ~5 minutes when Language Server jobs never go idle after debug, + // leaving NewProjectDebugProcessTest in the workspace for the next test class. + try + { + ProjectTestOperations.cancelJobsThatBlockWorkbenchIdle(); + ProjectTestOperations.deleteAllProjectsViaWorkspaceApi(); + } + catch (Exception e) + { + System.err.println("deleteAllProjectsViaWorkspaceApi failed: " + e.getMessage()); + } + + try + { + ProjectTestOperations.closeAllProjects(bot); + } + catch (Exception e) + { + System.err.println("closeAllProjects failed: " + e.getMessage()); + } + + try + { + ProjectTestOperations.cancelJobsThatBlockWorkbenchIdle(); + ProjectTestOperations.deleteAllProjects(bot); + } + catch (Exception e) + { + System.err.println("UI deleteAllProjects failed (expected if jobs never idle): " + e.getMessage()); + ProjectTestOperations.deleteAllProjectsViaWorkspaceApi(); + } + + ProjectTestOperations.openCCppPerspective(bot); + ProjectTestOperations.killDebugProcesses(projectName); + } + + private static String getNewOutputPart(String outputBeforeSelection, String outputAfterSelection) + { + if (outputAfterSelection == null) + { + return ""; + } + if (outputBeforeSelection == null || outputBeforeSelection.isEmpty()) + { + return outputAfterSelection; + } + if (outputAfterSelection.startsWith(outputBeforeSelection)) + { + return outputAfterSelection.substring(outputBeforeSelection.length()); + } + return outputAfterSelection; + } + + private static String readTargetDetectionOutput() + { + try + { + return bot.styledText().getText(); + } + catch (Exception ignored) + { + } + + String bestCandidate = ""; + for (int i = 0; i < 10; i++) + { + try + { + String text = bot.text(i).getText(); + if (text != null && containsChipInfo(text)) + { + return text; + } + if (text != null && text.length() > bestCandidate.length()) + { + bestCandidate = text; + } + } + catch (Exception ignored) + { + break; + } + } + return bestCandidate == null ? "" : bestCandidate; + } + + private static boolean containsChipInfo(String text) + { + return text != null && (text.contains("Connected to ESP32") || text.contains("Chip type:") + || text.contains("Detecting chip type")); + } + + private static String extractTargetFromDetectionOutput(String output) + { + if (output == null || output.trim().isEmpty()) + { + return null; + } + for (Pattern pattern : TARGET_DETECTION_PATTERNS) + { + Matcher matcher = pattern.matcher(output); + if (matcher.find()) + { + return normalizeDetectedChipToIdfTarget(matcher.group(1)); + } + } + return null; + } + + private static String normalizeDetectedChipToIdfTarget(String chipName) + { + if (chipName == null) + { + return null; + } + String chip = chipName.trim().toUpperCase(Locale.ROOT); + if (chip.startsWith("ESP32-C61")) + { + return "esp32c61"; + } + if (chip.startsWith("ESP32-C6")) + { + return "esp32c6"; + } + if (chip.startsWith("ESP32-C5")) + { + return "esp32c5"; + } + if (chip.startsWith("ESP32-H2")) + { + return "esp32h2"; + } + if (chip.startsWith("ESP32-S3")) + { + return "esp32s3"; + } + if (chip.startsWith("ESP32-S2")) + { + return "esp32s2"; + } + if (chip.startsWith("ESP32")) + { + return "esp32"; + } + return null; + } + } +} diff --git a/tests/com.espressif.idf.ui.test/src/com/espressif/idf/ui/test/executable/cases/project/IDFProjectJTAGFlashTest.java b/tests/com.espressif.idf.ui.test/src/com/espressif/idf/ui/test/executable/cases/project/IDFProjectJTAGFlashTest.java index d6a98cf22..ef8d7696c 100644 --- a/tests/com.espressif.idf.ui.test/src/com/espressif/idf/ui/test/executable/cases/project/IDFProjectJTAGFlashTest.java +++ b/tests/com.espressif.idf.ui.test/src/com/espressif/idf/ui/test/executable/cases/project/IDFProjectJTAGFlashTest.java @@ -112,7 +112,8 @@ private static void whenProjectIsBuiltUsingContextMenu() throws IOException private static void whenFlashProject() throws IOException { - ProjectTestOperations.launchCommandUsingContextMenu(projectName, bot, "Run Configurations..."); + ProjectTestOperations.launchCommandUsingContextMenuWithoutWaitingForJobs(projectName, bot, + "Run Configurations..."); TestWidgetWaitUtility.waitForDialogToAppear(bot, "Run Configurations", 10000); bot.tree().getTreeItem("ESP-IDF Application").select(); bot.tree().getTreeItem("ESP-IDF Application").expand(); diff --git a/tests/com.espressif.idf.ui.test/src/com/espressif/idf/ui/test/operations/ProjectTestOperations.java b/tests/com.espressif.idf.ui.test/src/com/espressif/idf/ui/test/operations/ProjectTestOperations.java index e3a7e7e85..a9b8207ed 100644 --- a/tests/com.espressif.idf.ui.test/src/com/espressif/idf/ui/test/operations/ProjectTestOperations.java +++ b/tests/com.espressif.idf.ui.test/src/com/espressif/idf/ui/test/operations/ProjectTestOperations.java @@ -6,17 +6,34 @@ import java.io.File; import java.io.IOException; +import java.lang.reflect.Method; import java.text.MessageFormat; import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; import java.util.Optional; +import java.util.Set; +import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; +import org.eclipse.core.resources.IProject; import org.eclipse.core.resources.IResource; import org.eclipse.core.resources.ResourcesPlugin; import org.eclipse.core.runtime.CoreException; import org.eclipse.core.runtime.jobs.Job; + +import org.eclipse.debug.core.DebugException; +import org.eclipse.debug.core.ILaunch; +import org.eclipse.debug.core.ILaunchConfiguration; +import org.eclipse.debug.core.ILaunchManager; +import org.eclipse.debug.core.model.IDebugTarget; +import org.eclipse.debug.core.model.IProcess; +import org.eclipse.debug.core.model.IStackFrame; +import org.eclipse.debug.core.model.IThread; +import org.eclipse.debug.core.model.RuntimeProcess; import org.eclipse.debug.core.DebugPlugin; import org.eclipse.debug.core.ILaunchMode; import org.eclipse.launchbar.core.ILaunchBarManager; @@ -25,7 +42,10 @@ import org.eclipse.swtbot.eclipse.finder.widgets.SWTBotEditor; import org.eclipse.swtbot.eclipse.finder.widgets.SWTBotView; import org.eclipse.swtbot.swt.finder.exceptions.WidgetNotFoundException; +import org.eclipse.swtbot.swt.finder.finders.UIThreadRunnable; import org.eclipse.swtbot.swt.finder.matchers.WidgetMatcherFactory; +import org.eclipse.swtbot.swt.finder.results.VoidResult; +import org.eclipse.swtbot.swt.finder.waits.Conditions; import org.eclipse.swtbot.swt.finder.waits.DefaultCondition; import org.eclipse.swtbot.swt.finder.widgets.SWTBotButton; import org.eclipse.swtbot.swt.finder.widgets.SWTBotCheckBox; @@ -37,6 +57,11 @@ import org.eclipse.swtbot.swt.finder.widgets.SWTBotTree; import org.eclipse.swtbot.swt.finder.widgets.SWTBotTreeItem; import org.eclipse.ui.IPageLayout; +import org.eclipse.ui.IPerspectiveDescriptor; +import org.eclipse.ui.IWorkbench; +import org.eclipse.ui.IWorkbenchPage; +import org.eclipse.ui.IWorkbenchWindow; +import org.eclipse.ui.PlatformUI; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -59,8 +84,23 @@ public class ProjectTestOperations private static final String DEFAULT_FLASH_WAIT_PROPERTY = "default.project.flash.wait"; + private static final String CDT_PERSPECTIVE_ID = "org.eclipse.cdt.ui.CPerspective"; + + private static final String DEBUG_PERSPECTIVE_ID = "org.eclipse.debug.ui.DebugPerspective"; + + private static final String STEP_OVER_TOOLTIP = "Step &Over (F6)"; + + private static final String[] STEP_OVER_CONTEXT_MENU_LABELS = { "Step Over (F6)", "Step Over", "Step &Over (F6)", + "Step &Over" }; + private static final Logger logger = LoggerFactory.getLogger(ProjectTestOperations.class); + /** OpenOCD and GDB PIDs captured while Eclipse still owns the debug launch. */ + private static final Set rememberedDebugProcessIds = new LinkedHashSet<>(); + + /** Last debug-test action, included in the one-line failure the runner summary prints. */ + private static volatile String debugCheckpoint = ""; + private static final int DELETE_PROJECT_TIMEOUT = 240000; /** @@ -876,4 +916,1470 @@ public static void joinJobByName(String jobName) } } } + + public static void noteDebugCheckpoint(String checkpoint) + { + debugCheckpoint = checkpoint == null ? "" : checkpoint; + System.out.println("[IDFProjectDebugProcessTest] checkpoint " + debugCheckpoint); + } + + public static String debugCheckpoint() + { + return debugCheckpoint == null ? "" : debugCheckpoint; + } + + public static void startDebuggingUsingContextMenu(String projectName, SWTWorkbenchBot bot) + { + SWTBotTreeItem projectItem = fetchProjectFromProjectExplorer(projectName, bot); + if (projectItem == null) + { + throw new WidgetNotFoundException("Project not found in Project Explorer: " + projectName); + } + + noteDebugCheckpoint("open Debug Configurations"); + projectItem.select(); + projectItem.contextMenu("Debug As").menu("Debug Configurations...").click(); + + TestWidgetWaitUtility.waitForDialogToAppear(bot, "Debug Configurations", 10000); + + noteDebugCheckpoint("select ESP-IDF GDB OpenOCD debug configuration"); + selectDebugLaunchConfiguration(bot, projectName); + + bot.waitUntil(Conditions.widgetIsEnabled(bot.button("Debug")), 5000); + noteDebugCheckpoint("click Debug"); + bot.button("Debug").click(); + // Do not switch perspective here. The Confirm Perspective Switch dialog appears while GDB + // is still printing, and accepting it cuts the console before + // "hit Temporary breakpoint ... app_main". waitForDebugSessionStarted accepts it afterwards. + } + + /** + * Selects the project's debug launch. Older IDF builds keep it under ESP-IDF GDB OpenOCD Debugging. + * After launch and debug configurations were unified, the same project config lives under ESP-IDF + * Application and is launched in debug mode. + */ + private static void selectDebugLaunchConfiguration(SWTWorkbenchBot bot, String projectName) + { + String[] configNames = { projectName + " Debug", projectName + " Configuration", projectName }; + if (selectTreeConfig(bot, "ESP-IDF GDB OpenOCD Debugging", configNames)) + { + noteDebugCheckpoint("selected OpenOCD debug configuration"); + return; + } + if (selectTreeConfig(bot, "ESP-IDF Application", new String[] { projectName })) + { + noteDebugCheckpoint("selected ESP-IDF Application configuration for debug"); + return; + } + throw new AssertionError("No debug launch configuration for " + projectName + + ". Tree: " + visibleLaunchTreeItems(bot)); + } + + private static boolean selectTreeConfig(SWTWorkbenchBot bot, String launchType, String[] configNames) + { + SWTBotTreeItem typeItem; + try + { + typeItem = bot.tree().getTreeItem(launchType); + } + catch (WidgetNotFoundException e) + { + return false; + } + typeItem.select(); + typeItem.expand(); + List nodes = typeItem.getNodes(); + for (String configName : configNames) + { + for (String node : nodes) + { + if (node.equals(configName)) + { + typeItem.getNode(configName).select(); + return true; + } + } + } + return false; + } + + private static String visibleLaunchTreeItems(SWTWorkbenchBot bot) + { + try + { + StringBuilder items = new StringBuilder(); + for (SWTBotTreeItem item : bot.tree().getAllItems()) + { + if (items.length() > 0) + { + items.append(", "); + } + items.append(item.getText()); + item.expand(); + for (String child : item.getNodes()) + { + items.append(" / ").append(child); + } + } + return items.toString(); + } + catch (RuntimeException e) + { + return ""; + } + } + + /** + * Accepts the Eclipse "Confirm Perspective Switch" dialog when it appears after the debug + * session suspends. Does not check "Remember my decision" so later UI tests are not affected. + * + * @param bot current SWT bot reference + * @param timeout how long to wait for the dialog in milliseconds + * @return {@code true} if the dialog was found and dismissed + */ + public static boolean acceptDebugPerspectiveSwitchIfPresent(SWTWorkbenchBot bot, long timeout) + { + try + { + TestWidgetWaitUtility.waitForDialogToAppear(bot, "Confirm Perspective Switch", timeout); + SWTBotShell shell = bot.shell("Confirm Perspective Switch"); + shell.activate(); + shell.setFocus(); + + try + { + SWTBotCheckBox remember = shell.bot().checkBox("Remember my decision"); + // Do not persist the decision — it poisons later UI tests in the same workbench. + if (remember.isChecked()) + { + remember.click(); + } + } + catch (WidgetNotFoundException ignored) + { + } + + try + { + shell.bot().button("Switch").click(); + } + catch (WidgetNotFoundException e) + { + shell.bot().button("Yes").click(); + } + + // Give the Debug perspective time to finish opening before further toolbar clicks. + bot.sleep(2000); + return true; + } + catch (Exception ignored) + { + // Perspective switch may already be remembered / suppressed. + return false; + } + } + + /** + * @see #acceptDebugPerspectiveSwitchIfPresent(SWTWorkbenchBot, long) + */ + public static void acceptDebugPerspectiveSwitchIfPresent(SWTWorkbenchBot bot) + { + acceptDebugPerspectiveSwitchIfPresent(bot, 30000); + } + + /** + * Waits until GDB has suspended at {@code app_main} (not merely OpenOCD "Target halted" during + * reset) and opens the Debug perspective. Does not require the Step Over toolbar button — + * that control is often missing/unreliable under SWTBot even when the session is suspended. + *

+ * Prefers the Eclipse debug model over console-page switching. Repeatedly opening + * "Display Selected Console" leaves the dropdown open and stalls the UI under SWTBot. + * + * @param bot current SWT bot reference + * @throws IOException if property lookup fails + */ + public static void waitForDebugSessionStarted(SWTWorkbenchBot bot) throws IOException + { + // Prefer a bounded debug timeout; the shared flash wait property is often hours-long. + long timeout = Math.min( + DefaultPropertyFetcher.getLongPropertyValue(DEFAULT_FLASH_WAIT_PROPERTY, 120000), + 180000); + long deadline = System.currentTimeMillis() + timeout; + long nextHeartbeat = System.currentTimeMillis(); + boolean perspectiveHandled = false; + + while (System.currentTimeMillis() < deadline) + { + // Read without show/setFocus, and do not accept the perspective dialog yet. + // Both steal focus while GDB is printing and leave the log at "[Switching to Thread". + rememberDebugProcessIds(null); + String consoleText = readConsoleTextQuietly(bot); + if (System.currentTimeMillis() >= nextHeartbeat) + { + noteDebugCheckpoint("waiting for app_main breakpoint; " + consoleTail(consoleText)); + nextHeartbeat = System.currentTimeMillis() + 15000; + } + + if (consoleText.contains("shutdown command invoked") + || consoleText.contains("dropped 'gdb' connection")) + { + throw new AssertionError( + "Debug session shut down before a breakpoint suspend was observed.\nConsole:\n" + + consoleText); + } + + if (isSuspendedAtBreakpoint(consoleText)) + { + noteDebugCheckpoint("suspend recognized: " + describeSuspendSignal(consoleText)); + if (!perspectiveHandled) + { + perspectiveHandled = acceptDebugPerspectiveSwitchIfPresent(bot, 30000); + } + openDebugPerspective(bot); + if (!hasActiveLaunch()) + { + throw new AssertionError( + "Debug launch terminated immediately after suspend at app_main.\nConsole:\n" + + consoleText); + } + return; + } + + bot.sleep(1000); + } + + String lastConsole = readVisibleConsoleText(bot); + throw new AssertionError( + "Debug session did not suspend at app_main within timeout (debug model or visible console).\nLast console text:\n" + + lastConsole); + } + + /** + * Reads text from the Console view page that is currently visible (no console-page switching). + * Switching via "Display Selected Console" leaves a sticky dropdown open under SWTBot and + * stalls the debug wait loop. + */ + public static String readVisibleConsoleText(SWTWorkbenchBot bot) + { + try + { + SWTBotView view = bot.viewByPartName("Console"); + view.show(); + view.setFocus(); + return textOf(view); + } + catch (Exception e) + { + logger.debug("Could not read visible Console view: {}", e.getMessage()); + return ""; + } + } + + /** + * Reads the Console view without activating it. Used while GDB is still printing the suspend line. + */ + private static String readConsoleTextQuietly(SWTWorkbenchBot bot) + { + try + { + return textOf(bot.viewByPartName("Console")); + } + catch (Exception e) + { + logger.debug("Could not read Console view quietly: {}", e.getMessage()); + return ""; + } + } + + private static String textOf(SWTBotView view) + { + String text = view.bot().styledText().getText(); + return text != null ? text : ""; + } + + /** + * Reads console text used for debug assertions from the currently visible Console page only. + */ + public static String readDebugRelatedConsoleText(SWTWorkbenchBot bot) + { + return readVisibleConsoleText(bot); + } + + private static boolean isSuspendedAtBreakpoint(String consoleText) + { + // Do not stop on "[Switching to Thread" or debug_reason=1. Those lines are printed + // before "hit Temporary breakpoint ... app_main", and leaving the wait there interrupts startup. + return isSuspendedAtBreakpointInConsole(consoleText); + } + + private static boolean isSuspendedAtBreakpointInConsole(String consoleText) + { + if (consoleText == null || consoleText.isEmpty()) + { + return false; + } + return consoleText.contains("hit Temporary breakpoint") || consoleText.contains("hit Breakpoint") + || consoleText.contains("hit breakpoint") + || (consoleText.contains("Temporary breakpoint") && consoleText.contains("app_main")); + } + + private static String describeSuspendSignal(String consoleText) + { + if (consoleText != null && consoleText.contains("hit Temporary breakpoint")) + { + return "console contains 'hit Temporary breakpoint ... app_main'"; + } + return "console contains the app_main breakpoint line"; + } + + private static String consoleTail(String consoleText) + { + if (consoleText == null || consoleText.isEmpty()) + { + return "console="; + } + String[] lines = consoleText.split("\\R"); + String last = lines[lines.length - 1].trim(); + if (last.length() > 160) + { + last = last.substring(last.length() - 160); + } + return "console=\"" + last.replace('\t', ' ') + "\""; + } + + /** + * True when an active debug target has a thread whose stack includes {@code app_main}. + * Prefer this over OpenOCD console text — GDB often suspends in the UI without printing + * {@code hit Temporary breakpoint} on the IDF Process Console. + *

+ * FreeRTOS-aware GDB often reports the main thread as {@code Running} even while the CPU is + * halted at {@code app_main}; do not require {@link IThread#isSuspended()}. + */ + public static boolean isSuspendedAtAppMainInDebugModel() + { + return findThreadWithAppMainFrame() != null; + } + + /** + * Performs Step Over from the Debug UI: the workbench toolbar button + * {@code Step &Over (F6)}, or right-click on the suspended thread in the Debug view. + * + * @param projectName unused; kept for call-site compatibility + * @param bot current SWT bot reference + */ + public static void performDebugStepOver(String projectName, SWTWorkbenchBot bot) + { + noteDebugCheckpoint("Step Over: open Debug perspective"); + acceptDebugPerspectiveSwitchIfPresent(bot, 2000); + openDebugPerspective(bot); + // Let Debug perspective / toolbar finish loading before probing Step Over controls. + bot.sleep(3000); + + if (!hasActiveLaunch()) + { + throw new AssertionError("Cannot Step Over — no active debug launch"); + } + + final SWTWorkbenchBot workbenchBot = bot; + noteDebugCheckpoint("Step Over: wait until the toolbar or Debug view thread is ready"); + try + { + workbenchBot.waitUntil(new DefaultCondition() + { + @Override + public boolean test() throws Exception + { + if (!hasActiveLaunch()) + { + throw new AssertionError( + "Debug launch terminated before Step Over became available (OpenOCD/GDB already stopped)"); + } + acceptDebugPerspectiveSwitchIfPresent(workbenchBot, 200); + return isStepOverToolbarPresent(workbenchBot) + || findDebugThreadItem(workbenchBot) != null + || findThreadWithAppMainFrame() != null; + } + + @Override + public String getFailureMessage() + { + return "Debug Step Over not ready within timeout — Step Over toolbar not found " + + "and no thread in the Debug view. Launch active=" + hasActiveLaunch() + + ", stepOverToolbar=" + isStepOverToolbarPresent(workbenchBot) + + ", debugThread=" + (findDebugThreadItem(workbenchBot) != null); + } + }, 60000, 500); + } + catch (AssertionError e) + { + // Ensure CI always shows a non-empty reason (some runners truncate blank AssertionError). + String detail = e.getMessage(); + if (detail == null || detail.trim().isEmpty()) + { + throw new AssertionError( + "Debug Step Over not ready within timeout (empty wait failure). Launch active=" + + hasActiveLaunch(), + e); + } + throw e; + } + + bot.sleep(1500); + + noteDebugCheckpoint("Step Over: click toolbar button"); + if (clickToolbarStepOver(bot)) + { + noteDebugCheckpoint("Step Over: toolbar click done"); + bot.sleep(3000); + return; + } + noteDebugCheckpoint("Step Over: click Debug view context menu"); + if (clickDebugViewStepOver(bot)) + { + noteDebugCheckpoint("Step Over: context menu click done"); + bot.sleep(3000); + return; + } + + throw new AssertionError( + "Failed to perform Step Over (toolbar button 'Step &Over (F6)' and Debug view " + + "thread context menu both failed). Launch active=" + hasActiveLaunch() + + ", stepOverToolbar=" + isStepOverToolbarPresent(bot) + + ", debugThread=" + (findDebugThreadItem(bot) != null)); + } + + private static IThread findThreadWithAppMainFrame() + { + ILaunchManager launchManager = DebugPlugin.getDefault().getLaunchManager(); + ILaunch[] launches = launchManager.getLaunches(); + if (launches == null) + { + return null; + } + + for (ILaunch launch : launches) + { + if (launch == null || launch.isTerminated()) + { + continue; + } + IDebugTarget[] targets = launch.getDebugTargets(); + if (targets == null) + { + continue; + } + for (IDebugTarget target : targets) + { + if (target == null || target.isTerminated()) + { + continue; + } + try + { + if (!target.hasThreads()) + { + continue; + } + for (IThread thread : target.getThreads()) + { + if (thread == null || thread.isTerminated()) + { + continue; + } + // FreeRTOS GDB often labels the halted main thread as Running, and + // hasStackFrames() is then false even though the UI shows app_main. + IStackFrame[] frames; + try + { + frames = thread.getStackFrames(); + } + catch (DebugException e) + { + logger.debug("findThreadWithAppMainFrame: {}", e.getMessage()); + continue; + } + if (frames == null) + { + continue; + } + for (IStackFrame frame : frames) + { + if (frame == null) + { + continue; + } + String name = frame.getName(); + if (name != null && name.toLowerCase(Locale.ENGLISH).contains("app_main")) + { + return thread; + } + } + } + } + catch (DebugException e) + { + logger.debug("findThreadWithAppMainFrame: {}", e.getMessage()); + } + } + } + return null; + } + + private static boolean isStepOverToolbarPresent(SWTWorkbenchBot bot) + { + try + { + bot.toolbarButtonWithTooltip(STEP_OVER_TOOLTIP); + return true; + } + catch (WidgetNotFoundException e) + { + return false; + } + } + + private static boolean clickToolbarStepOver(SWTWorkbenchBot bot) + { + try + { + bot.toolbarButtonWithTooltip(STEP_OVER_TOOLTIP).click(); + return true; + } + catch (WidgetNotFoundException e) + { + logger.debug("Step Over toolbar click failed: {}", e.getMessage()); + return false; + } + } + + /** + * Right-clicks the suspended thread in the Debug view and selects {@code Step Over (F6)}. + */ + private static boolean clickDebugViewStepOver(SWTWorkbenchBot bot) + { + try + { + SWTBotView debugView = findDebugView(bot); + if (debugView == null) + { + return false; + } + debugView.show(); + debugView.setFocus(); + bot.sleep(1000); + SWTBotTreeItem thread = findDebugThreadItem(bot); + if (thread == null) + { + return false; + } + thread.select(); + bot.sleep(500); + for (String label : STEP_OVER_CONTEXT_MENU_LABELS) + { + try + { + thread.contextMenu(label).click(); + return true; + } + catch (WidgetNotFoundException ignored) + { + } + } + return false; + } + catch (Exception e) + { + logger.debug("Debug view Step Over failed: {}", e.getMessage()); + return false; + } + } + + private static SWTBotView findDebugView(SWTWorkbenchBot bot) + { + try + { + for (SWTBotView view : bot.views()) + { + if (view != null && "Debug".equals(view.getTitle())) + { + return view; + } + } + } + catch (Exception e) + { + logger.debug("findDebugView: {}", e.getMessage()); + } + return null; + } + + private static SWTBotTreeItem findDebugThreadItem(SWTWorkbenchBot bot) + { + try + { + SWTBotView debugView = findDebugView(bot); + if (debugView == null) + { + return null; + } + debugView.show(); + SWTBotTree tree = debugView.bot().tree(); + SWTBotTreeItem[] roots = tree.getAllItems(); + SWTBotTreeItem mainThread = findThreadItem(roots, true); + if (mainThread != null) + { + return mainThread; + } + return findThreadItem(roots, false); + } + catch (Exception e) + { + logger.debug("findDebugThreadItem: {}", e.getMessage()); + return null; + } + } + + private static SWTBotTreeItem findThreadItem(SWTBotTreeItem[] items, boolean mainOnly) + { + if (items == null) + { + return null; + } + for (SWTBotTreeItem item : items) + { + if (item == null) + { + continue; + } + String label = item.getText(); + if (label != null) + { + String lower = label.toLowerCase(Locale.ENGLISH); + boolean isThread = lower.contains("thread #") || lower.startsWith("thread "); + boolean isMain = lower.contains("[main]") || lower.contains("name: main"); + if (isThread && (!mainOnly || isMain)) + { + return item; + } + } + try + { + item.expand(); + } + catch (Exception ignored) + { + } + SWTBotTreeItem nested = findThreadItem(item.getItems(), mainOnly); + if (nested != null) + { + return nested; + } + } + return null; + } + + /** + * Opens the Eclipse Debug perspective via Platform UI API (no menus/dialogs). + * + * @param bot current SWT bot reference (may be {@code null}) + */ + public static void openDebugPerspective(SWTWorkbenchBot bot) + { + try + { + UIThreadRunnable.syncExec(new VoidResult() + { + @Override + public void run() + { + IWorkbench workbench = PlatformUI.getWorkbench(); + IWorkbenchWindow window = workbench.getActiveWorkbenchWindow(); + if (window == null || window.getActivePage() == null) + { + return; + } + IPerspectiveDescriptor descriptor = workbench.getPerspectiveRegistry() + .findPerspectiveWithId(DEBUG_PERSPECTIVE_ID); + if (descriptor != null) + { + window.getActivePage().setPerspective(descriptor); + } + } + }); + if (bot != null) + { + bot.sleep(1000); + } + } + catch (Exception e) + { + logger.warn("Failed to open Debug perspective", e); + } + } + + /** + * Returns {@code true} if an active (non-terminated) Eclipse launch still exists. + */ + public static boolean hasActiveLaunch() + { + ILaunchManager launchManager = DebugPlugin.getDefault().getLaunchManager(); + ILaunch[] launches = launchManager.getLaunches(); + if (launches == null) + { + return false; + } + for (ILaunch launch : launches) + { + if (launch != null && !launch.isTerminated()) + { + return true; + } + } + return false; + } + + /** + * Stops the active launch / debug session using the Launch Bar Stop button. + * + * @param bot current SWT bot reference + */ + public static void stopLaunchUsingLaunchBar(SWTWorkbenchBot bot) + { + try + { + bot.toolbarButtonWithTooltip("Stop").click(); + bot.sleep(2000); + } + catch (WidgetNotFoundException e) + { + logger.warn("Stop button not found while trying to stop launch/debug session"); + } + } + + /** + * Leaves the Debug UI before shared project cleanup: terminate OpenOCD/GDB, close editors, + * switch to C/C++, close Debug-related views, and cancel background jobs that would otherwise + * keep {@link WaitUtils#waitForJobs()} from returning (Language Server / indexer), which + * poisons later UI tests' {@code deleteAllProjects}. + * + * @param bot current SWT bot reference + */ + public static void leaveDebugUi(SWTWorkbenchBot bot) + { + leaveDebugUi(bot, null); + } + + /** + * @see #leaveDebugUi(SWTWorkbenchBot) + * @param projectName debug project whose OpenOCD/GDB processes may be stopped; {@code null} limits + * cleanup to debug launches still registered in this workbench + */ + public static void leaveDebugUi(SWTWorkbenchBot bot, String projectName) + { + stopDebugSessionAndKillProcesses(bot, projectName); + closeAllEditorsViaApi(); + openCCppPerspective(bot); + closeDebugRelatedViews(bot); + cancelJobsThatBlockWorkbenchIdle(); + if (bot != null) + { + try + { + closeSecondaryShells(bot); + focusMainWindow(bot.shells()); + } + catch (Exception e) + { + logger.warn("leaveDebugUi: could not focus main window", e); + } + } + } + + /** + * Cancels long-running CDT/LSP/refresh jobs that prevent {@code Job.getJobManager().isIdle()} + * after a hardware debug session. Safe to call from {@code @AfterClass}. + */ + public static void cancelJobsThatBlockWorkbenchIdle() + { + Job[] jobs = Job.getJobManager().find(null); + if (jobs == null) + { + return; + } + + for (Job job : jobs) + { + if (job == null || job.getState() == Job.NONE) + { + continue; + } + + String name = job.getName(); + if (name == null) + { + continue; + } + + String lower = name.toLowerCase(Locale.ENGLISH); + if (lower.contains("language server") || lower.contains("clangd") || lower.contains("indexer") + || lower.contains("c/c++") || lower.contains("cdt ") || lower.contains("reconcil") + || lower.contains("refresh") || lower.contains("building workspace") + || lower.contains("updating") || lower.contains("decorate") + || lower.contains("openocd") || lower.contains("gdb")) + { + logger.info("Cancelling job that may block workbench idle: {}", name); + job.cancel(); + } + } + + try + { + Thread.sleep(2000); + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + } + } + + /** + * Best-effort close of views typically opened by the Debug perspective. + * + * @param bot current SWT bot reference + */ + public static void closeDebugRelatedViews(SWTWorkbenchBot bot) + { + if (bot == null) + { + return; + } + + String[] viewTitles = new String[] { "Debug", "Breakpoints", "Variables", "Expressions", + "Registers", "Memory", "Disassembly", "Modules", "Signals", "Executables" }; + + for (String title : viewTitles) + { + try + { + SWTBotView view = bot.viewByTitle(title); + view.close(); + } + catch (Exception ignored) + { + } + } + } + + /** + * Best-effort cleanup of an active debug session via the debug API and process kill. + * Avoids clicking Launch Bar Stop / Debug Terminate toolbars — those tooltips are ambiguous + * under SWTBot and can race with an active session during perspective changes. + * + * @param bot current SWT bot reference (may be {@code null} if UI is unavailable) + */ + public static void stopDebugSessionAndKillProcesses(SWTWorkbenchBot bot) + { + stopDebugSessionAndKillProcesses(bot, null); + } + + /** + * @see #stopDebugSessionAndKillProcesses(SWTWorkbenchBot) + * @param projectName project whose debug launch should be stopped + */ + public static void stopDebugSessionAndKillProcesses(SWTWorkbenchBot bot, String projectName) + { + // Capture PIDs before terminate. Eclipse may drop the Process handle once the launch ends. + killDebugProcesses(projectName); + } + + /** + * Switches the workbench back to the C/C++ perspective via the Platform UI API. + * Avoids Window → Perspective menus / "Open Perspective" dialogs, which can leave a + * modal shell open under SWTBot and block {@code @AfterClass} cleanup. + * + * @param bot current SWT bot reference (unused; kept for call-site consistency) + */ + public static void openCCppPerspective(SWTWorkbenchBot bot) + { + try + { + UIThreadRunnable.syncExec(new VoidResult() + { + @Override + public void run() + { + IWorkbench workbench = PlatformUI.getWorkbench(); + IWorkbenchWindow window = workbench.getActiveWorkbenchWindow(); + if (window == null) + { + return; + } + IWorkbenchPage page = window.getActivePage(); + if (page == null) + { + return; + } + IPerspectiveDescriptor descriptor = workbench.getPerspectiveRegistry() + .findPerspectiveWithId(CDT_PERSPECTIVE_ID); + if (descriptor == null) + { + logger.warn("C/C++ perspective id not found: {}", CDT_PERSPECTIVE_ID); + return; + } + IPerspectiveDescriptor current = page.getPerspective(); + if (current != null && DEBUG_PERSPECTIVE_ID.equals(current.getId())) + { + page.closePerspective(current, false, false); + } + page.setPerspective(descriptor); + } + }); + if (bot != null) + { + closeSecondaryShells(bot); + focusMainWindow(bot.shells()); + } + } + catch (Exception e) + { + logger.warn("Failed to switch back to C/C++ perspective", e); + } + } + + /** + * Force-cleans workbench state after a debug test (including timeout/failure). + * Stops debug processes, returns to C/C++, then deletes projects. Project deletion + * runs on the calling thread (not the UI thread) — {@code syncExec} + + * {@code IProject.delete} can deadlock / silently no-op under SWTBot. + * + * @param bot current SWT bot reference (may be {@code null}) + */ + public static void forceCleanWorkbenchAfterDebugTest(SWTWorkbenchBot bot) + { + try + { + killDebugProcesses(null); + } + catch (Exception e) + { + logger.warn("forceClean: killDebugProcesses failed", e); + } + + try + { + openCCppPerspective(bot); + } + catch (Exception e) + { + logger.warn("forceClean: openCCppPerspective failed", e); + } + + try + { + if (bot != null) + { + closeSecondaryShells(bot); + focusMainWindow(bot.shells()); + } + } + catch (Exception e) + { + logger.warn("forceClean: closeSecondaryShells failed", e); + } + + try + { + closeAllEditorsViaApi(); + } + catch (Exception e) + { + logger.warn("forceClean: closeAllEditorsViaApi failed", e); + } + + try + { + deleteAllProjectsViaWorkspaceApi(); + } + catch (Exception e) + { + logger.warn("forceClean: deleteAllProjectsViaWorkspaceApi failed", e); + } + + // Fallback used by every other UI test — UI delete if workspace API left anything. + if (bot != null && workspaceHasProjects()) + { + try + { + logger.warn("forceClean: projects still present after workspace API delete; falling back to UI delete"); + closeAllProjects(bot); + deleteAllProjects(bot); + } + catch (Exception e) + { + logger.warn("forceClean: UI project cleanup failed", e); + } + } + + killDebugProcesses(null); + } + + /** + * Closes all open editors without prompting (UI-thread API). + */ + public static void closeAllEditorsViaApi() + { + UIThreadRunnable.syncExec(new VoidResult() + { + @Override + public void run() + { + IWorkbenchWindow window = PlatformUI.getWorkbench().getActiveWorkbenchWindow(); + if (window == null || window.getActivePage() == null) + { + return; + } + window.getActivePage().closeAllEditors(false); + } + }); + } + + /** + * Deletes every workspace project via the resources API on the calling thread + * (no UI {@code syncExec}). Safe for {@code @AfterClass} cleanup. + */ + public static void deleteAllProjectsViaWorkspaceApi() + { + IProject[] projects = ResourcesPlugin.getWorkspace().getRoot().getProjects(); + if (projects == null) + { + return; + } + + for (IProject project : projects) + { + if (project == null || !project.exists()) + { + continue; + } + + String name = project.getName(); + try + { + if (project.isOpen()) + { + project.close(null); + } + } + catch (CoreException e) + { + logger.warn("Could not close project {}: {}", name, e.getMessage()); + } + + try + { + if (project.exists()) + { + project.delete(IResource.ALWAYS_DELETE_PROJECT_CONTENT | IResource.FORCE, null); + logger.info("Deleted workspace project {}", name); + } + } + catch (CoreException e) + { + logger.warn("Could not delete project {}: {}", name, e.getMessage()); + } + } + } + + private static boolean workspaceHasProjects() + { + IProject[] projects = ResourcesPlugin.getWorkspace().getRoot().getProjects(); + if (projects == null) + { + return false; + } + for (IProject project : projects) + { + if (project != null && project.exists()) + { + return true; + } + } + return false; + } + + /** + * Terminates debug launches that belong to {@code projectName}. When {@code projectName} is + * {@code null}, only launches in this workbench that still own an OpenOCD or ESP GDB process + * are terminated. + */ + public static void terminateDebugLaunches(String projectName) + { + for (ILaunch launch : debugLaunches(projectName)) + { + if (launch.isTerminated()) + { + continue; + } + try + { + launch.terminate(); + } + catch (DebugException e) + { + logger.warn("Failed to terminate launch: " + launch, e); + } + } + waitForLaunchesToTerminate(debugLaunches(projectName), 5000); + } + + /** + * Stops OpenOCD and ESP GDB processes that belong to {@code projectName}. PIDs are taken before + * {@link ILaunch#terminate()}. A process that stays alive is signaled by that PID. Host-wide + * {@code pkill -f} is not used. + * + * @param projectName project whose debug tools may be stopped; {@code null} only signals + * processes still attached to a launch in this workbench + */ + public static void killDebugProcesses(String projectName) + { + rememberDebugProcessIds(projectName); + Set pids = new LinkedHashSet<>(rememberedDebugProcessIds); + for (ILaunch launch : debugLaunches(projectName)) + { + pids.addAll(processIds(launch)); + if (!launch.isTerminated()) + { + try + { + launch.terminate(); + } + catch (DebugException e) + { + logger.warn("Failed to terminate launch before PID cleanup: " + launch, e); + } + } + } + waitForLaunchesToTerminate(debugLaunches(projectName), 5000); + for (Long pid : pids) + { + signalProcess(pid); + } + killOrphanGdbProcesses(projectName); + rememberedDebugProcessIds.clear(); + } + + /** + * Stops OpenOCD and ESP GDB processes still attached to a launch in this workbench. + * Does not signal processes outside this Eclipse session. + */ + public static void killDebugProcesses() + { + killDebugProcesses(null); + } + + private static void rememberDebugProcessIds(String projectName) + { + for (ILaunch launch : debugLaunches(projectName)) + { + for (Long pid : processIds(launch)) + { + if (rememberedDebugProcessIds.add(pid)) + { + logger.info("Remembered debug process {}", pid); + } + } + } + } + + private static ILaunch[] debugLaunches(String projectName) + { + ILaunchManager launchManager = DebugPlugin.getDefault().getLaunchManager(); + ILaunch[] launches = launchManager.getLaunches(); + if (launches == null) + { + return new ILaunch[0]; + } + return Stream.of(launches).filter(launch -> isDebugToolLaunch(launch, projectName)).toArray(ILaunch[]::new); + } + + private static boolean isDebugToolLaunch(ILaunch launch, String projectName) + { + if (launch == null) + { + return false; + } + if (projectName != null && !projectName.isEmpty() && !launchBelongsToProject(launch, projectName)) + { + return false; + } + if (projectName == null || projectName.isEmpty()) + { + return launchHasDebugToolProcess(launch); + } + return ILaunchManager.DEBUG_MODE.equals(launch.getLaunchMode()) || launchHasDebugToolProcess(launch); + } + + private static boolean launchBelongsToProject(ILaunch launch, String projectName) + { + ILaunchConfiguration configuration = launch.getLaunchConfiguration(); + if (configuration != null && configuration.getName() != null && configuration.getName().contains(projectName)) + { + return true; + } + for (IProcess process : processesOf(launch)) + { + String command = process.getAttribute(IProcess.ATTR_CMDLINE); + if (command != null && command.contains(projectName)) + { + return true; + } + } + return false; + } + + private static boolean launchHasDebugToolProcess(ILaunch launch) + { + for (IProcess process : processesOf(launch)) + { + String command = process.getAttribute(IProcess.ATTR_CMDLINE); + if (command != null && isDebugToolCommand(command)) + { + return true; + } + String label = process.getLabel(); + if (label != null && isDebugToolCommand(label)) + { + return true; + } + } + return false; + } + + private static IProcess[] processesOf(ILaunch launch) + { + IProcess[] processes = launch.getProcesses(); + return processes == null ? new IProcess[0] : processes; + } + + private static Set processIds(ILaunch launch) + { + Set pids = new LinkedHashSet<>(); + for (IProcess process : processesOf(launch)) + { + if (!(process instanceof RuntimeProcess)) + { + continue; + } + Process systemProcess = systemProcess(process); + if (systemProcess == null) + { + continue; + } + try + { + pids.add(Long.valueOf(systemProcess.pid())); + } + catch (UnsupportedOperationException e) + { + logger.debug("Debug process has no PID: {}", e.getMessage()); + } + } + return pids; + } + + /** + * {@link RuntimeProcess#getSystemProcess()} is protected. The test reads it to signal that OS + * process only. + */ + private static Process systemProcess(IProcess process) + { + try + { + Method method = RuntimeProcess.class.getDeclaredMethod("getSystemProcess"); + method.setAccessible(true); + return (Process) method.invoke(process); + } + catch (ReflectiveOperationException e) + { + logger.debug("Could not read debug process handle: {}", e.getMessage()); + return null; + } + } + + private static void waitForLaunchesToTerminate(ILaunch[] launches, long timeoutMs) + { + long deadline = System.currentTimeMillis() + timeoutMs; + while (System.currentTimeMillis() < deadline) + { + boolean finished = true; + for (ILaunch launch : launches) + { + if (launch != null && !launch.isTerminated()) + { + finished = false; + break; + } + } + if (finished) + { + return; + } + try + { + Thread.sleep(200); + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + return; + } + } + } + + private static void signalProcess(long pid) + { + if (pid <= 0 || pid == ProcessHandle.current().pid()) + { + return; + } + Optional handle = ProcessHandle.of(pid); + if (handle.isEmpty() || !handle.get().isAlive()) + { + return; + } + logger.info("Stopping debug process {}", pid); + handle.get().destroy(); + try + { + handle.get().onExit().get(2, TimeUnit.SECONDS); + } + catch (Exception e) + { + logger.debug("Debug process {} did not exit after SIGTERM: {}", pid, e.getMessage()); + } + if (handle.get().isAlive()) + { + logger.info("Killing debug process {}", pid); + handle.get().destroyForcibly(); + } + } + + /** + * Stops an ESP GDB process whose command line contains the project name or its workspace path. + * OpenOCD is not searched this way: its arguments usually omit the project directory. + */ + private static void killOrphanGdbProcesses(String projectName) + { + if (projectName == null || projectName.isEmpty() || !System.getProperty("os.name", "") + .toLowerCase(Locale.ENGLISH).contains("linux")) + { + return; + } + String output; + try + { + Process process = new ProcessBuilder("ps", "-ax", "-o", "pid=,command=").redirectErrorStream(true) + .start(); + output = new String(process.getInputStream().readAllBytes()); + process.waitFor(5, TimeUnit.SECONDS); + } + catch (Exception e) + { + logger.debug("Could not list processes for debug cleanup: {}", e.getMessage()); + return; + } + for (String line : output.split("\\R")) + { + String trimmed = line.trim(); + int split = trimmed.indexOf(' '); + if (split <= 0) + { + continue; + } + String command = trimmed.substring(split + 1); + if (!commandBelongsToProject(command, projectName) || !isGdbCommand(command)) + { + continue; + } + try + { + signalProcess(Long.parseLong(trimmed.substring(0, split).trim())); + } + catch (NumberFormatException e) + { + logger.debug("Could not parse process id from: {}", trimmed); + } + } + } + + private static boolean commandBelongsToProject(String command, String projectName) + { + if (command.contains(projectName)) + { + return true; + } + IProject project = ResourcesPlugin.getWorkspace().getRoot().getProject(projectName); + if (project != null && project.exists() && project.getLocation() != null) + { + return command.contains(project.getLocation().toOSString()); + } + return false; + } + + private static boolean isGdbCommand(String command) + { + String lower = command.toLowerCase(Locale.ENGLISH); + return lower.contains("gdb") && (lower.contains("xtensa-esp") || lower.contains("riscv32-esp")); + } + + private static boolean isDebugToolCommand(String command) + { + String lower = command.toLowerCase(Locale.ENGLISH); + return lower.contains("openocd") || (lower.contains("gdb") + && (lower.contains("xtensa-esp") || lower.contains("riscv32-esp"))); + } + + /** + * Terminates every non-terminated launch registered with the Eclipse debug framework. + */ + public static void terminateAllLaunches() + { + ILaunchManager launchManager = DebugPlugin.getDefault().getLaunchManager(); + ILaunch[] launches = launchManager.getLaunches(); + if (launches == null) + { + return; + } + + for (ILaunch launch : launches) + { + if (launch == null || launch.isTerminated()) + { + continue; + } + try + { + launch.terminate(); + } + catch (DebugException e) + { + logger.warn("Failed to terminate launch: " + launch, e); + } + } + } + + public static void launchCommandUsingContextMenuWithoutWaitingForJobs(String projectName, SWTWorkbenchBot bot, + String contextMenuLabel) + { + openCCppPerspective(bot); + focusMainWindow(bot.shells()); + + SWTBotTreeItem projectItem = fetchProjectFromProjectExplorer(projectName, bot); + if (projectItem == null) + { + throw new WidgetNotFoundException("Project not found in Project Explorer: " + projectName); + } + projectItem.select(); + projectItem.contextMenu(contextMenuLabel).click(); + } }