Skip to content

Commit bb0e8e8

Browse files
add examples for LLM with desktop
improve desktop snapshot summary fix self-healing fixes in find by prompt logic
1 parent b6ef935 commit bb0e8e8

File tree

10 files changed

+247
-54
lines changed

10 files changed

+247
-54
lines changed

src/Bellatrix.Desktop/components/Core/Component.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ protected WindowsElement GetAndWaitWebDriverElement()
247247
if (_llmSettings.EnableSelfHealing)
248248
{
249249
var snapshot = _viewSnapshotProvider.GetCurrentViewSnapshot();
250-
LocatorSelfHealingService.SaveWorkingLocator(By.ToString(), snapshot, WrappedDriver.CurrentWindowHandle);
250+
LocatorSelfHealingService.SaveWorkingLocator(By.ToString(), snapshot, WrappedDriver.Title);
251251
}
252252

253253
_untils.Clear();
@@ -263,7 +263,7 @@ protected WindowsElement GetAndWaitWebDriverElement()
263263
Logger.LogWarning($"⚠️ Element not found with locator: {By}. Trying AI-based healing...");
264264

265265
var snapshot = _viewSnapshotProvider.GetCurrentViewSnapshot();
266-
var healedXpath = LocatorSelfHealingService.TryHeal(By.ToString(), snapshot, WrappedDriver.CurrentWindowHandle);
266+
var healedXpath = LocatorSelfHealingService.TryHeal(By.ToString(), snapshot, WrappedDriver.Title);
267267

268268
if (!string.IsNullOrEmpty(healedXpath))
269269
{

src/Bellatrix.Desktop/llm/FindByPrompt.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ private string ResolveViaPromptFallback(string location, WindowsDriver<WindowsEl
171171
var prompt = SemanticKernelService.Kernel?.InvokeAsync(nameof(LocatorSkill), nameof(LocatorSkill.BuildLocatorPrompt),
172172
new()
173173
{
174-
["htmlSummary"] = summaryJson,
174+
["viewSummaryJson"] = summaryJson,
175175
["instruction"] = Value,
176176
["failedSelectors"] = failedSelectors
177177
}).Result.GetValue<string>();

src/Bellatrix.Desktop/llm/extensions/DesktopElementSummary.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,20 @@ public class DesktopElementSummary
2525
public string ControlType { get; set; }
2626
public string Value { get; set; }
2727
public string HelpText { get; set; }
28-
public bool Enabled { get; set; }
28+
public bool? IsSelected { get; set; }
29+
public string ToggleState { get; set; }
30+
public string ExpandCollapseState { get; set; }
31+
public string Selection { get; set; }
32+
public bool? IsEnabled { get; set; }
33+
public bool? IsOffscreen { get; set; }
34+
public bool? IsPassword { get; set; }
35+
public bool? IsAvailable { get; set; }
36+
public bool? HasKeyboardFocus { get; set; }
37+
public bool? IsKeyboardFocusable { get; set; }
38+
public string Orientation { get; set; }
39+
public string Minimum { get; set; }
40+
public string Maximum { get; set; }
41+
public string LargeChange { get; set; }
42+
public string SmallChange { get; set; }
43+
// Add more as needed from the XML source
2944
}

src/Bellatrix.Desktop/llm/skills/LocatorSkill.cs

Lines changed: 47 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -51,52 +51,44 @@ public string BuildLocatorPrompt(string viewSummaryJson, string instruction, Lis
5151
5252
---
5353
54-
**WinAppDriver XPath Guidelines (strict compatibility):**
54+
**STRICT WinAppDriver XPath Rules:**
5555
56-
✅ Use XPath expressions with **direct attribute matches only**:
57-
- `@AutomationId`
58-
- `@Name`
59-
- `@ClassName`
60-
- `@ControlType`
61-
- `@HelpText`
62-
- `@Value.Value` (optional, if available)
63-
64-
✅ Examples of valid XPath:
56+
✅ Use only **PascalCase** element tags (e.g., `ComboBox`, `Edit`, `Button`, `Text`, `Pane`, etc.)
57+
✅ Use only **PascalCase** attribute names (`AutomationId`, `Name`, `ClassName`, `ControlType`, `HelpText`, `Value.Value`).
58+
✅ Use only direct, case-sensitive attribute matches (no contains, no normalize-space, etc).
59+
✅ Example valid XPath:
60+
- `//ComboBox[@AutomationId='select']`
6561
- `//Edit[@Name='Username']`
6662
- `//Button[@AutomationId='SubmitBtn']`
6763
- `//Text[@HelpText='Tooltip message']`
6864
69-
✅ Format rules:
70-
- Use lowercase tag names (e.g., `edit`, `button`, `text`)
71-
- Attribute values must be wrapped in single quotes: `@Name='Login'`
72-
- Return the shortest valid XPath with a single attribute condition
65+
🚫 NEVER use:
66+
- Lowercase tag or attribute names (e.g., `//combobox[@automationid='select']` is INVALID)
67+
- Any case conversion or function (translate, lower-case, upper-case, etc)
68+
- contains(), normalize-space(), substring(), axes, positions, or multiple conditions.
7369
74-
🚫 Do NOT use:
75-
- `contains(...)`
76-
- `normalize-space(...)`
77-
- `substring(...)`
78-
- XPath axes like `ancestor::`, `following::`, `preceding-sibling::`
79-
- Position-based XPath (e.g., `(//Edit)[2]`)
80-
- Multiple conditions (e.g., `[@Name='X' and @AutomationId='Y']`)
70+
**You MUST match tag and attribute names in PascalCase exactly as in the WinAppDriver XML.**
8171
8272
---
8373
8474
**Return Format:**
85-
Only return a single valid XPath string like:
75+
Return only a single valid, case-sensitive XPath string using PascalCase for both tag and attribute, e.g.:
76+
- //ComboBox[@AutomationId='select']
8677
- //Edit[@Name='Username']
87-
- //Button[@AutomationId='LoginBtn']
88-
- //Pane[@ClassName='MainPanel']
8978
90-
Do not include:
79+
🚫 Do NOT include:
9180
- Explanations
9281
- Multiple lines
9382
- Comments
9483
- Markdown formatting
84+
- Code blocks (no triple backticks or ``` around the XPath)
85+
- Extra whitespace or newlines before or after the XPath
9586
96-
Only return the XPath string.
87+
✅ Return ONLY the XPath string as a single line, nothing else.
9788
""";
9889
}
9990

91+
10092
[KernelFunction]
10193
public string HealBrokenLocator(string failedLocator, string oldSnapshot, string newSnapshot)
10294
{
@@ -121,23 +113,42 @@ public string HealBrokenLocator(string failedLocator, string oldSnapshot, string
121113
122114
---
123115
124-
✅ XPath must match one of the following formats:
116+
**STRICT WinAppDriver XPath Rules:**
117+
118+
✅ Use only **PascalCase** element tags (e.g., `ComboBox`, `Edit`, `Button`, `Text`, `Pane`, etc.)
119+
✅ Use only **PascalCase** attribute names (`AutomationId`, `Name`, `ClassName`, `ControlType`, `HelpText`, `Value.Value`)
120+
✅ Use only direct, case-sensitive attribute matches—NO functions, partial, or case-insensitive matching
121+
122+
✅ Example valid XPath:
123+
- //ComboBox[@AutomationId='select']
125124
- //Edit[@Name='Username']
126-
- //Button[@AutomationId='Submit']
127-
- //Text[@HelpText='Tooltip']
125+
- //Button[@AutomationId='SubmitBtn']
126+
- //Text[@HelpText='Tooltip message']
127+
128+
🚫 NEVER use:
129+
- Lowercase tag or attribute names (e.g., `//combobox[@automationid='select']` is INVALID)
130+
- Any case conversion or function (`translate`, `lower-case`, `upper-case`)
131+
- contains(), normalize-space(), substring(), axes, positions, or multiple conditions
128132
129-
🚫 Do NOT use:
130-
- contains()
131-
- normalize-space()
132-
- substring()
133-
- any complex or relative XPath
133+
**You MUST match tag and attribute names in PascalCase exactly as in the WinAppDriver XML.**
134134
135135
---
136136
137137
**Return Format:**
138-
Return only a single valid XPath expression, as a one-line string.
138+
Only return a single valid, case-sensitive XPath string using PascalCase for both tag and attribute, e.g.:
139+
- //ComboBox[@AutomationId='select']
140+
- //Edit[@Name='Username']
139141
140-
Return NOTHING else.
142+
🚫 Do NOT include:
143+
- Explanations
144+
- Multiple lines
145+
- Comments
146+
- Markdown formatting
147+
- Code blocks (no triple backticks or ``` around the XPath)
148+
- Extra whitespace or newlines before or after the XPath
149+
150+
✅ Return ONLY the XPath string as a single line, nothing else.
141151
""";
142152
}
153+
143154
}

src/Bellatrix.Desktop/services/AppService.cs

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -56,19 +56,43 @@ public string GetCurrentViewSnapshot()
5656
}
5757

5858
List<DesktopElementSummary> elements = nodes
59-
.Select(node => new DesktopElementSummary
60-
{
61-
Tag = node.Name,
62-
AutomationId = node.GetAttributeValue("AutomationId", null),
63-
Name = node.GetAttributeValue("Name", null),
64-
ClassName = node.GetAttributeValue("ClassName", null),
65-
ControlType = node.GetAttributeValue("ControlType", null),
66-
Value = node.GetAttributeValue("Value.Value", null),
67-
HelpText = node.GetAttributeValue("HelpText", null)
68-
})
69-
.Where(x => !string.IsNullOrWhiteSpace(x.Name) || !string.IsNullOrWhiteSpace(x.AutomationId))
70-
.ToList();
59+
.Select(node => new DesktopElementSummary
60+
{
61+
Tag = node.Name,
62+
AutomationId = node.GetAttributeValue("AutomationId", null),
63+
Name = node.GetAttributeValue("Name", null),
64+
ClassName = node.GetAttributeValue("ClassName", null),
65+
ControlType = node.GetAttributeValue("ControlType", null),
66+
Value = node.GetAttributeValue("Value.Value", null) ?? node.GetAttributeValue("Value", null),
67+
HelpText = node.GetAttributeValue("HelpText", null),
68+
IsSelected = ParseNullableBool(node.GetAttributeValue("IsSelected", null)),
69+
ToggleState = node.GetAttributeValue("ToggleState", null),
70+
ExpandCollapseState = node.GetAttributeValue("ExpandCollapseState", null),
71+
Selection = node.GetAttributeValue("Selection", null),
72+
IsEnabled = ParseNullableBool(node.GetAttributeValue("IsEnabled", null)),
73+
IsOffscreen = ParseNullableBool(node.GetAttributeValue("IsOffscreen", null)),
74+
IsPassword = ParseNullableBool(node.GetAttributeValue("IsPassword", null)),
75+
IsAvailable = ParseNullableBool(node.GetAttributeValue("IsAvailable", null)),
76+
HasKeyboardFocus = ParseNullableBool(node.GetAttributeValue("HasKeyboardFocus", null)),
77+
IsKeyboardFocusable = ParseNullableBool(node.GetAttributeValue("IsKeyboardFocusable", null)),
78+
Orientation = node.GetAttributeValue("Orientation", null),
79+
Minimum = node.GetAttributeValue("Minimum", null),
80+
Maximum = node.GetAttributeValue("Maximum", null),
81+
LargeChange = node.GetAttributeValue("LargeChange", null),
82+
SmallChange = node.GetAttributeValue("SmallChange", null)
83+
})
84+
.Where(x => !string.IsNullOrWhiteSpace(x.Name) || !string.IsNullOrWhiteSpace(x.AutomationId))
85+
.ToList();
7186

7287
return JsonConvert.SerializeObject(elements, Formatting.None);
7388
}
89+
90+
private static bool? ParseNullableBool(string value)
91+
{
92+
if (value == null)
93+
return null;
94+
if (bool.TryParse(value, out var result))
95+
return result;
96+
return null;
97+
}
7498
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using Bellatrix.Desktop.PageObjects;
2+
3+
namespace Bellatrix.Desktop.GettingStarted.LLM;
4+
5+
// 1. All BELLATRIX page objects are implemented as partial classes which means that you have separate files for different parts of it- actions, elements, assertions
6+
// but at the end, they are all built into a single type. This makes the maintainability and readability of these classes much better. Also, you can easier locate what you need.
7+
//
8+
// You can always create BELLATRIX page objects yourself inheriting DesktopPage class
9+
// We advise you to follow the convention with partial classes, but you are always free to put all pieces in a single file.
10+
public partial class MainDesktopPage : DesktopPage
11+
{
12+
// 2. These elements are always used together when an item is transferred. There are many test cases where you need to transfer different items and so on.
13+
// This way you reuse the code instead of copy-paste it. If there is a change in the way how the item is transferred, change the workflow only here.
14+
// Even single line of code is changed in your tests.
15+
public void TransferItem(string itemToBeTransfered, string userName, string password)
16+
{
17+
PermanentTransfer.Check();
18+
Items.SelectByText(itemToBeTransfered);
19+
ReturnItemAfter.ToNotExists().WaitToBe();
20+
UserName.SetText(userName);
21+
Password.SetPassword(password);
22+
KeepMeLogged.Click();
23+
Transfer.Click();
24+
}
25+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
namespace Bellatrix.Desktop.GettingStarted.LLM;
2+
3+
public partial class MainDesktopPage
4+
{
5+
public void AssertPermanentTransferIsChecked()
6+
{
7+
App.Assert.IsTrue(PermanentTransfer.IsChecked);
8+
}
9+
10+
public void AssertRightItemSelected(string itemName)
11+
{
12+
App.Assert.AreEqual(itemName, Items.InnerText);
13+
}
14+
15+
public void AssertRightUserNameSet(string userName)
16+
{
17+
App.Assert.AreEqual(userName, UserName.InnerText);
18+
}
19+
20+
public void AssertKeepMeLoggedChecked()
21+
{
22+
App.Assert.IsTrue(KeepMeLogged.IsChecked);
23+
}
24+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace Bellatrix.Desktop.GettingStarted.LLM;
2+
3+
public partial class MainDesktopPage
4+
{
5+
// 1. All elements are placed inside the file PageName.Map so that the declarations of your elements to be in a single place.
6+
// It is convenient since if there is a change in some of the locators or elements types you can apply the fix only here.
7+
// All elements are implements as properties. Here we use the short syntax for declaring properties, but you can always use the old one.
8+
// App.Components property is actually a shorter version of ComponentCreateService
9+
public Button Transfer => App.Components.CreateByName<Button>("E Button");
10+
public CheckBox PermanentTransfer => App.Components.CreateByName<CheckBox>("BellaCheckBox");
11+
public ComboBox Items => App.Components.CreateByAutomationId<ComboBox>("select");
12+
public Button ReturnItemAfter => App.Components.CreateByName<Button>("DisappearAfterButton1");
13+
public Label Results => App.Components.CreateByAutomationId<Label>("ResultLabelId");
14+
public Password Password => App.Components.CreateByAutomationId<Password>("passwordBox");
15+
public TextField UserName => App.Components.CreateByAutomationId<TextField>("textBox");
16+
public RadioButton KeepMeLogged => App.Components.CreateByName<RadioButton>("RadioButton");
17+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
using Bellatrix.Desktop.LLM.Extensions;
2+
using Bellatrix.Desktop.NUnit;
3+
using NUnit.Framework;
4+
using static Bellatrix.AiValidator;
5+
using static Bellatrix.AiAssert;
6+
7+
namespace Bellatrix.Desktop.GettingStarted.LLM;
8+
9+
[TestFixture]
10+
public class PageObjectsTests : DesktopTest
11+
{
12+
[Test]
13+
public void ActionsWithoutPageObjects_Wpf_LLM()
14+
{
15+
var permanentTransfer = App.Components.CreateByName<CheckBox>("BellaCheckBox");
16+
17+
permanentTransfer.Check();
18+
19+
App.Assert.IsTrue(permanentTransfer.IsChecked);
20+
21+
//var items = App.Components.CreateByAutomationId<ComboBox>("select");
22+
var items = App.Components.CreateByPrompt<ComboBox>("find the select under meissa check box");
23+
24+
25+
items.SelectByText("Item1");
26+
ValidateByPrompt("verify select under meissa check box displayed");
27+
28+
var returnItemAfter = App.Components.CreateByName<Component>("DisappearAfterButton1");
29+
30+
returnItemAfter.ToNotExists().WaitToBe();
31+
32+
var password = App.Components.CreateByAutomationId<Password>("passwordBox");
33+
//var password = App.Components.CreateByAutomationId<Password>("passwordBoxUpdated");
34+
35+
password.SetPassword("topsecret");
36+
37+
var userName = App.Components.CreateByAutomationId<TextField>("textBox");
38+
39+
userName.SetText("bellatrix");
40+
41+
App.Assert.AreEqual("bellatrix", userName.InnerText);
42+
43+
var keepMeLogged = App.Components.CreateByName<RadioButton>("RadioButton");
44+
45+
keepMeLogged.Click();
46+
47+
App.Assert.IsTrue(keepMeLogged.IsChecked);
48+
49+
var byName = App.Components.CreateByName<Button>("E Button");
50+
51+
byName.Click();
52+
53+
//var label = App.Components.CreateByAutomationId<Label>("ResultLabelId");
54+
var label = App.Components.CreateByPrompt<Label>("find the label above the calendar");
55+
56+
ValidateByPrompt("validate that the label says that the button was clicked");
57+
//App.Assert.IsTrue(label.IsPresent);
58+
}
59+
60+
[Test]
61+
[Category(Categories.CI)]
62+
public void ActionsWithPageObjects_Wpf()
63+
{
64+
// 5. You can use the App Create method to get an instance of it.
65+
var mainPage = App.Create<MainDesktopPage>();
66+
67+
// 6. After you have the instance, you can directly start using the action methods of the page.
68+
// As you can see the test became much shorter and more readable.
69+
// The additional code pays off in future when changes are made to the page, or you need to reuse some of the methods.
70+
mainPage.TransferItem("Item2", "bellatrix", "topsecret");
71+
72+
mainPage.AssertKeepMeLoggedChecked();
73+
mainPage.AssertPermanentTransferIsChecked();
74+
mainPage.AssertRightItemSelected("Item2");
75+
mainPage.AssertRightUserNameSet("bellatrix");
76+
}
77+
}

0 commit comments

Comments
 (0)