docs: plan N13 UIA selector catalog implementation
This commit is contained in:
@@ -0,0 +1,943 @@
|
||||
# N13 UIA Selector Catalog Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build an offline, read-only N13 UIA selector catalog and matcher that can validate selectors against the accepted N12R UIA dump without adding live helper or MCP actions.
|
||||
|
||||
**Architecture:** Add a focused Go package `internal/uiaselector` with selector data types, a static first-pass catalog, UIA dump loading, and pure in-memory matching. Tests use a redacted N12R fixture and assert that matching returns structural node summaries without visible text.
|
||||
|
||||
**Tech Stack:** Go 1.23.x, standard library `encoding/json`, existing repository test style, PowerShell verification wrapper.
|
||||
|
||||
---
|
||||
|
||||
## Scope and boundary
|
||||
|
||||
This plan implements only offline selector catalog and matching.
|
||||
|
||||
It does not modify these files:
|
||||
|
||||
```text
|
||||
native/ISphereWinHelper/Program.cs
|
||||
internal/tools/winhelper.go
|
||||
cmd/isphere-mcp/main.go
|
||||
```
|
||||
|
||||
It does not add MCP tools or helper ops. It does not click, type, open conversations, send messages, send files, receive files, or read chat content.
|
||||
|
||||
The accepted N12R evidence used by tests is:
|
||||
|
||||
```text
|
||||
runs/internal-sandbox-live-capture-intake/2026-07-09-retry-2/2026-07-08-internal-sandbox-a/uia/dump-uia-main-redacted.json
|
||||
```
|
||||
|
||||
## File structure
|
||||
|
||||
Create these files:
|
||||
|
||||
```text
|
||||
internal/uiaselector/catalog.go
|
||||
internal/uiaselector/catalog_test.go
|
||||
internal/uiaselector/dump.go
|
||||
internal/uiaselector/dump_test.go
|
||||
internal/uiaselector/matcher.go
|
||||
internal/uiaselector/matcher_test.go
|
||||
internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json
|
||||
scripts/verify-n13-uia-selectors.ps1
|
||||
```
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- `catalog.go`: selector data structures and initial N13 selector catalog.
|
||||
- `catalog_test.go`: catalog completeness and boundary tests.
|
||||
- `dump.go`: UIA dump response and node loading helpers.
|
||||
- `dump_test.go`: fixture parse tests.
|
||||
- `matcher.go`: pure in-memory selector matcher.
|
||||
- `matcher_test.go`: matching behavior and no-visible-text result tests.
|
||||
- `testdata/n12r-2026-07-09-uia-redacted.json`: redacted accepted N12R UIA fixture copied from the ignored run artifact.
|
||||
- `scripts/verify-n13-uia-selectors.ps1`: convenience verification script for Go-enabled environments.
|
||||
|
||||
## Task 1: Add N12R redacted UIA fixture
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json`
|
||||
|
||||
- [ ] **Step 1: Create package directory and copy the accepted redacted fixture**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force -Path internal\uiaselector\testdata | Out-Null
|
||||
Copy-Item `
|
||||
-LiteralPath runs\internal-sandbox-live-capture-intake\2026-07-09-retry-2\2026-07-08-internal-sandbox-a\uia\dump-uia-main-redacted.json `
|
||||
-Destination internal\uiaselector\testdata\n12r-2026-07-09-uia-redacted.json `
|
||||
-Force
|
||||
```
|
||||
|
||||
Expected: `internal\uiaselector\testdata\n12r-2026-07-09-uia-redacted.json` exists.
|
||||
|
||||
- [ ] **Step 2: Verify fixture parses as JSON**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
Get-Content internal\uiaselector\testdata\n12r-2026-07-09-uia-redacted.json -Encoding UTF8 | ConvertFrom-Json | Out-Null
|
||||
```
|
||||
|
||||
Expected: command exits 0.
|
||||
|
||||
- [ ] **Step 3: Commit fixture**
|
||||
|
||||
```powershell
|
||||
git add internal\uiaselector\testdata\n12r-2026-07-09-uia-redacted.json
|
||||
git commit -m "test: add N13 redacted UIA fixture"
|
||||
```
|
||||
|
||||
## Task 2: Define selector catalog types and initial catalog
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/uiaselector/catalog_test.go`
|
||||
- Create: `internal/uiaselector/catalog.go`
|
||||
|
||||
- [ ] **Step 1: Write failing catalog tests**
|
||||
|
||||
Create `internal/uiaselector/catalog_test.go`:
|
||||
|
||||
```go
|
||||
package uiaselector
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultCatalogContainsInitialN13Selectors(t *testing.T) {
|
||||
catalog := DefaultCatalog()
|
||||
wantIDs := []string{
|
||||
"main_window",
|
||||
"main_plugin_tab",
|
||||
"home_page_window",
|
||||
"tab_page_content",
|
||||
"left_panel",
|
||||
"roster_panel",
|
||||
"roster_view",
|
||||
"fuzzy_query_panel",
|
||||
"fuzzy_query_window",
|
||||
"fuzzy_search_edit",
|
||||
}
|
||||
|
||||
byID := map[string]Selector{}
|
||||
for _, selector := range catalog {
|
||||
byID[selector.ID] = selector
|
||||
}
|
||||
|
||||
for _, id := range wantIDs {
|
||||
selector, ok := byID[id]
|
||||
if !ok {
|
||||
t.Fatalf("DefaultCatalog missing selector %q", id)
|
||||
}
|
||||
if selector.AllowedUse != AllowedUseReadOnlyLocate {
|
||||
t.Fatalf("selector %q allowed_use = %q, want %q", id, selector.AllowedUse, AllowedUseReadOnlyLocate)
|
||||
}
|
||||
if selector.Required.AutomationID == "" {
|
||||
t.Fatalf("selector %q must require automation_id", id)
|
||||
}
|
||||
if selector.Required.ControlType == "" {
|
||||
t.Fatalf("selector %q must require control_type", id)
|
||||
}
|
||||
if selector.StabilityScore < 80 {
|
||||
t.Fatalf("selector %q stability_score = %d, want >= 80", id, selector.StabilityScore)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCatalogDoesNotDescribeActionTools(t *testing.T) {
|
||||
for _, selector := range DefaultCatalog() {
|
||||
if selector.AllowedUse != AllowedUseReadOnlyLocate {
|
||||
t.Fatalf("selector %q allowed_use = %q, want read-only", selector.ID, selector.AllowedUse)
|
||||
}
|
||||
lower := strings.ToLower(selector.ID + " " + selector.Description)
|
||||
for _, forbidden := range []string{"send", "upload", "download", "receive", "conversation", "login", "file_transfer"} {
|
||||
if strings.Contains(lower, forbidden) {
|
||||
t.Fatalf("selector %q contains action word %q", selector.ID, forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run catalog tests and verify they fail**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
go test ./internal/uiaselector -run TestDefaultCatalog -count=1
|
||||
```
|
||||
|
||||
Expected: FAIL because package `internal/uiaselector` or `DefaultCatalog` does not exist.
|
||||
|
||||
If this environment reports `go` not found, stop execution and record the environment gap. Continue only in a Go 1.23.x environment.
|
||||
|
||||
- [ ] **Step 3: Implement catalog types and selectors**
|
||||
|
||||
Create `internal/uiaselector/catalog.go`:
|
||||
|
||||
```go
|
||||
package uiaselector
|
||||
|
||||
const AllowedUseReadOnlyLocate = "read_only_locate"
|
||||
|
||||
type Selector struct {
|
||||
ID string `json:"id"`
|
||||
Description string `json:"description"`
|
||||
AllowedUse string `json:"allowed_use"`
|
||||
Required Criteria `json:"required"`
|
||||
Preferred PreferredCriteria `json:"preferred"`
|
||||
Fallback []FallbackCriteria `json:"fallback"`
|
||||
StabilityScore int `json:"stability_score"`
|
||||
Evidence Evidence `json:"evidence"`
|
||||
}
|
||||
|
||||
type Criteria struct {
|
||||
AutomationID string `json:"automation_id,omitempty"`
|
||||
ControlType string `json:"control_type,omitempty"`
|
||||
FrameworkID string `json:"framework_id,omitempty"`
|
||||
ClassName string `json:"class_name,omitempty"`
|
||||
ClassPrefix string `json:"class_name_prefix,omitempty"`
|
||||
AncestorID string `json:"ancestor_selector,omitempty"`
|
||||
}
|
||||
|
||||
type PreferredCriteria struct {
|
||||
ClassName string `json:"class_name,omitempty"`
|
||||
ClassPrefix string `json:"class_name_prefix,omitempty"`
|
||||
PathHint string `json:"path_hint,omitempty"`
|
||||
AncestorID string `json:"ancestor_selector,omitempty"`
|
||||
}
|
||||
|
||||
type FallbackCriteria struct {
|
||||
AncestorID string `json:"ancestor_selector,omitempty"`
|
||||
ControlType string `json:"control_type,omitempty"`
|
||||
ClassPrefix string `json:"class_name_prefix,omitempty"`
|
||||
ChildAutomationIDContains []string `json:"child_automation_id_contains,omitempty"`
|
||||
ChildControlTypeContains []string `json:"child_control_type_contains,omitempty"`
|
||||
}
|
||||
|
||||
type Evidence struct {
|
||||
CaptureID string `json:"capture_id"`
|
||||
UIAPath string `json:"uia_path"`
|
||||
}
|
||||
|
||||
func DefaultCatalog() []Selector {
|
||||
return []Selector{
|
||||
{
|
||||
ID: "main_window",
|
||||
Description: "iSphere / IMPlatformClient 主窗口",
|
||||
AllowedUse: AllowedUseReadOnlyLocate,
|
||||
Required: Criteria{AutomationID: "frmMain", ControlType: "Window", FrameworkID: "WinForm"},
|
||||
Preferred: PreferredCriteria{ClassPrefix: "WindowsForms10.Window.8.app.0", PathHint: "root"},
|
||||
Fallback: []FallbackCriteria{{ControlType: "Window", ClassPrefix: "WindowsForms10.Window.8.app.0", ChildAutomationIDContains: []string{"panelLeft", "ucPluginTabControl1"}}},
|
||||
StabilityScore: 95,
|
||||
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root"},
|
||||
},
|
||||
{
|
||||
ID: "main_plugin_tab",
|
||||
Description: "主功能 tab 容器",
|
||||
AllowedUse: AllowedUseReadOnlyLocate,
|
||||
Required: Criteria{AutomationID: "ucPluginTabControl1", ControlType: "Pane", FrameworkID: "WinForm"},
|
||||
Preferred: PreferredCriteria{PathHint: "root/3", AncestorID: "main_window"},
|
||||
Fallback: []FallbackCriteria{{AncestorID: "main_window", ControlType: "Pane", ChildAutomationIDContains: []string{"FrmHomePage", "ucTabPage"}}},
|
||||
StabilityScore: 90,
|
||||
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3"},
|
||||
},
|
||||
{
|
||||
ID: "home_page_window",
|
||||
Description: "首页内容窗口",
|
||||
AllowedUse: AllowedUseReadOnlyLocate,
|
||||
Required: Criteria{AutomationID: "FrmHomePage", ControlType: "Window", FrameworkID: "WinForm"},
|
||||
Preferred: PreferredCriteria{PathHint: "root/3/0/0", AncestorID: "main_plugin_tab"},
|
||||
Fallback: []FallbackCriteria{{AncestorID: "main_plugin_tab", ControlType: "Window", ClassPrefix: "WindowsForms10.Window.8.app.0"}},
|
||||
StabilityScore: 82,
|
||||
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3/0/0"},
|
||||
},
|
||||
{
|
||||
ID: "tab_page_content",
|
||||
Description: "tab 页内容窗口",
|
||||
AllowedUse: AllowedUseReadOnlyLocate,
|
||||
Required: Criteria{AutomationID: "FrmTabPageContent", ControlType: "Window", FrameworkID: "WinForm"},
|
||||
Preferred: PreferredCriteria{PathHint: "root/3/1/0", AncestorID: "main_plugin_tab"},
|
||||
Fallback: []FallbackCriteria{{AncestorID: "main_plugin_tab", ControlType: "Window", ChildControlTypeContains: []string{"TitleBar"}}},
|
||||
StabilityScore: 82,
|
||||
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3/1/0"},
|
||||
},
|
||||
{
|
||||
ID: "left_panel",
|
||||
Description: "左侧主面板",
|
||||
AllowedUse: AllowedUseReadOnlyLocate,
|
||||
Required: Criteria{AutomationID: "panelLeft", ControlType: "Pane", FrameworkID: "WinForm"},
|
||||
Preferred: PreferredCriteria{PathHint: "root/4", AncestorID: "main_window"},
|
||||
Fallback: []FallbackCriteria{{AncestorID: "main_window", ControlType: "Pane", ChildAutomationIDContains: []string{"ucPnlRoster1", "ucFuzzyQuery1"}}},
|
||||
StabilityScore: 92,
|
||||
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4"},
|
||||
},
|
||||
{
|
||||
ID: "roster_panel",
|
||||
Description: "组织/联系人容器外层",
|
||||
AllowedUse: AllowedUseReadOnlyLocate,
|
||||
Required: Criteria{AutomationID: "ucPnlRoster1", ControlType: "Pane", FrameworkID: "WinForm"},
|
||||
Preferred: PreferredCriteria{PathHint: "root/4/0/0", AncestorID: "left_panel"},
|
||||
Fallback: []FallbackCriteria{{AncestorID: "left_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"ucRoster1"}}},
|
||||
StabilityScore: 88,
|
||||
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/0/0"},
|
||||
},
|
||||
{
|
||||
ID: "roster_view",
|
||||
Description: "组织/联系人列表区域",
|
||||
AllowedUse: AllowedUseReadOnlyLocate,
|
||||
Required: Criteria{AutomationID: "ucRoster1", ControlType: "Pane", FrameworkID: "WinForm"},
|
||||
Preferred: PreferredCriteria{PathHint: "root/4/0/0/0", AncestorID: "roster_panel"},
|
||||
Fallback: []FallbackCriteria{{AncestorID: "roster_panel", ControlType: "Pane", ClassPrefix: "WindowsForms10.Window.8.app.0"}},
|
||||
StabilityScore: 86,
|
||||
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/0/0/0"},
|
||||
},
|
||||
{
|
||||
ID: "fuzzy_query_panel",
|
||||
Description: "模糊搜索组件外层",
|
||||
AllowedUse: AllowedUseReadOnlyLocate,
|
||||
Required: Criteria{AutomationID: "ucFuzzyQuery1", ControlType: "Pane", FrameworkID: "WinForm"},
|
||||
Preferred: PreferredCriteria{PathHint: "root/4/1/0", AncestorID: "left_panel"},
|
||||
Fallback: []FallbackCriteria{{AncestorID: "left_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"FrmFuzzyQuery"}}},
|
||||
StabilityScore: 90,
|
||||
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0"},
|
||||
},
|
||||
{
|
||||
ID: "fuzzy_query_window",
|
||||
Description: "模糊搜索内部窗口",
|
||||
AllowedUse: AllowedUseReadOnlyLocate,
|
||||
Required: Criteria{AutomationID: "FrmFuzzyQuery", ControlType: "Pane", FrameworkID: "WinForm"},
|
||||
Preferred: PreferredCriteria{PathHint: "root/4/1/0/0", AncestorID: "fuzzy_query_panel"},
|
||||
Fallback: []FallbackCriteria{{AncestorID: "fuzzy_query_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"skinAlphaTxt"}}},
|
||||
StabilityScore: 88,
|
||||
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0/0"},
|
||||
},
|
||||
{
|
||||
ID: "fuzzy_search_edit",
|
||||
Description: "模糊搜索输入框",
|
||||
AllowedUse: AllowedUseReadOnlyLocate,
|
||||
Required: Criteria{AutomationID: "skinAlphaTxt", ControlType: "Edit", FrameworkID: "WinForm"},
|
||||
Preferred: PreferredCriteria{ClassName: "WindowsForms10.EDIT.app.0.d3a00f_r29_ad1", PathHint: "root/4/1/0/0/2", AncestorID: "fuzzy_query_window"},
|
||||
Fallback: []FallbackCriteria{{AncestorID: "fuzzy_query_window", ControlType: "Edit", ClassPrefix: "WindowsForms10.EDIT.app.0"}},
|
||||
StabilityScore: 93,
|
||||
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0/0/2"},
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run catalog tests and verify they pass**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
go test ./internal/uiaselector -run TestDefaultCatalog -count=1
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit catalog**
|
||||
|
||||
```powershell
|
||||
git add internal\uiaselector\catalog.go internal\uiaselector\catalog_test.go
|
||||
git commit -m "feat: add N13 UIA selector catalog"
|
||||
```
|
||||
|
||||
## Task 3: Load redacted UIA dump fixture
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/uiaselector/dump_test.go`
|
||||
- Create: `internal/uiaselector/dump.go`
|
||||
|
||||
- [ ] **Step 1: Write failing dump loading tests**
|
||||
|
||||
Create `internal/uiaselector/dump_test.go`:
|
||||
|
||||
```go
|
||||
package uiaselector
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLoadDumpFixtureParsesAcceptedN12RTree(t *testing.T) {
|
||||
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDumpFile: %v", err)
|
||||
}
|
||||
if root.AutomationID != "frmMain" {
|
||||
t.Fatalf("root automation_id = %q, want frmMain", root.AutomationID)
|
||||
}
|
||||
if root.ControlType != "Window" {
|
||||
t.Fatalf("root control_type = %q, want Window", root.ControlType)
|
||||
}
|
||||
if got := CountNodes(root); got != 66 {
|
||||
t.Fatalf("CountNodes = %d, want 66", got)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run dump tests and verify they fail**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
go test ./internal/uiaselector -run TestLoadDumpFixture -count=1
|
||||
```
|
||||
|
||||
Expected: FAIL because `LoadDumpFile` and `CountNodes` do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement dump loading**
|
||||
|
||||
Create `internal/uiaselector/dump.go`:
|
||||
|
||||
```go
|
||||
package uiaselector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type DumpResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Data *DumpData `json:"data"`
|
||||
Error any `json:"error"`
|
||||
}
|
||||
|
||||
type DumpData struct {
|
||||
Root *Node `json:"root"`
|
||||
}
|
||||
|
||||
type Node struct {
|
||||
Name string `json:"name"`
|
||||
ControlType string `json:"control_type"`
|
||||
AutomationID string `json:"automation_id"`
|
||||
ClassName string `json:"class_name"`
|
||||
FrameworkID string `json:"framework_id"`
|
||||
IsEnabled *bool `json:"is_enabled"`
|
||||
IsOffscreen *bool `json:"is_offscreen"`
|
||||
Bounds any `json:"bounds"`
|
||||
Children []Node `json:"children"`
|
||||
}
|
||||
|
||||
func LoadDumpFile(path string) (*Node, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var response DumpResponse
|
||||
if err := json.Unmarshal(content, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !response.OK {
|
||||
return nil, fmt.Errorf("dump response ok=false: %v", response.Error)
|
||||
}
|
||||
if response.Data == nil || response.Data.Root == nil {
|
||||
return nil, fmt.Errorf("dump response has no data.root")
|
||||
}
|
||||
return response.Data.Root, nil
|
||||
}
|
||||
|
||||
func CountNodes(root *Node) int {
|
||||
if root == nil {
|
||||
return 0
|
||||
}
|
||||
count := 1
|
||||
for i := range root.Children {
|
||||
count += CountNodes(&root.Children[i])
|
||||
}
|
||||
return count
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run dump tests and verify they pass**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
go test ./internal/uiaselector -run TestLoadDumpFixture -count=1
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit dump loader**
|
||||
|
||||
```powershell
|
||||
git add internal\uiaselector\dump.go internal\uiaselector\dump_test.go
|
||||
git commit -m "feat: load N13 UIA dump fixture"
|
||||
```
|
||||
|
||||
## Task 4: Implement read-only selector matcher
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/uiaselector/matcher_test.go`
|
||||
- Create: `internal/uiaselector/matcher.go`
|
||||
|
||||
- [ ] **Step 1: Write failing matcher tests**
|
||||
|
||||
Create `internal/uiaselector/matcher_test.go`:
|
||||
|
||||
```go
|
||||
package uiaselector
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMatchSelectorFindsKnownN12RNodes(t *testing.T) {
|
||||
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDumpFile: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
selectorID string
|
||||
path string
|
||||
control string
|
||||
autoID string
|
||||
}{
|
||||
{selectorID: "main_window", path: "root", control: "Window", autoID: "frmMain"},
|
||||
{selectorID: "left_panel", path: "root/4", control: "Pane", autoID: "panelLeft"},
|
||||
{selectorID: "fuzzy_search_edit", path: "root/4/1/0/0/2", control: "Edit", autoID: "skinAlphaTxt"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := MatchSelector(root, DefaultCatalog(), tt.selectorID)
|
||||
if !result.OK {
|
||||
t.Fatalf("%s OK=false: %#v", tt.selectorID, result.Error)
|
||||
}
|
||||
if !result.Matched {
|
||||
t.Fatalf("%s matched=false", tt.selectorID)
|
||||
}
|
||||
if result.MatchCount != 1 {
|
||||
t.Fatalf("%s match_count = %d, want 1", tt.selectorID, result.MatchCount)
|
||||
}
|
||||
match := result.Matches[0]
|
||||
if match.Path != tt.path || match.ControlType != tt.control || match.AutomationID != tt.autoID {
|
||||
t.Fatalf("%s match = %#v", tt.selectorID, match)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchSelectorDoesNotReturnVisibleText(t *testing.T) {
|
||||
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDumpFile: %v", err)
|
||||
}
|
||||
result := MatchSelector(root, DefaultCatalog(), "main_window")
|
||||
if !result.OK || !result.Matched || len(result.Matches) != 1 {
|
||||
t.Fatalf("unexpected result: %#v", result)
|
||||
}
|
||||
match := result.Matches[0]
|
||||
if match.Name != "" {
|
||||
t.Fatalf("match returned visible name text %q", match.Name)
|
||||
}
|
||||
if !match.HasName {
|
||||
t.Fatalf("main_window should report has_name=true")
|
||||
}
|
||||
if match.NameLength == 0 {
|
||||
t.Fatalf("main_window should report non-zero name_length")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchSelectorUnknownSelectorIsStructuredFailure(t *testing.T) {
|
||||
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDumpFile: %v", err)
|
||||
}
|
||||
result := MatchSelector(root, DefaultCatalog(), "missing_selector")
|
||||
if result.OK {
|
||||
t.Fatalf("OK=true for missing selector: %#v", result)
|
||||
}
|
||||
if result.Error.Code != "UNKNOWN_SELECTOR" {
|
||||
t.Fatalf("error code = %q, want UNKNOWN_SELECTOR", result.Error.Code)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run matcher tests and verify they fail**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
go test ./internal/uiaselector -run TestMatchSelector -count=1
|
||||
```
|
||||
|
||||
Expected: FAIL because `MatchSelector` and result types do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement matcher**
|
||||
|
||||
Create `internal/uiaselector/matcher.go`:
|
||||
|
||||
```go
|
||||
package uiaselector
|
||||
|
||||
import "strings"
|
||||
|
||||
type MatchResult struct {
|
||||
OK bool `json:"ok"`
|
||||
SelectorID string `json:"selector_id"`
|
||||
Matched bool `json:"matched"`
|
||||
MatchCount int `json:"match_count"`
|
||||
Matches []NodeMatch `json:"matches"`
|
||||
Warnings []string `json:"warnings"`
|
||||
Error MatchError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type MatchError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type NodeMatch struct {
|
||||
Path string `json:"path"`
|
||||
ControlType string `json:"control_type"`
|
||||
AutomationID string `json:"automation_id"`
|
||||
ClassName string `json:"class_name"`
|
||||
FrameworkID string `json:"framework_id"`
|
||||
HasName bool `json:"has_name"`
|
||||
NameLength int `json:"name_length"`
|
||||
Name string `json:"-"`
|
||||
}
|
||||
|
||||
type nodeRef struct {
|
||||
path string
|
||||
node *Node
|
||||
}
|
||||
|
||||
func MatchSelector(root *Node, catalog []Selector, selectorID string) MatchResult {
|
||||
selector, ok := findSelector(catalog, selectorID)
|
||||
if !ok {
|
||||
return MatchResult{OK: false, SelectorID: selectorID, Error: MatchError{Code: "UNKNOWN_SELECTOR", Message: "selector is not in catalog"}}
|
||||
}
|
||||
if selector.AllowedUse != AllowedUseReadOnlyLocate {
|
||||
return MatchResult{OK: false, SelectorID: selectorID, Error: MatchError{Code: "INVALID_SELECTOR", Message: "selector is not read-only"}}
|
||||
}
|
||||
refs := flatten(root)
|
||||
matches := matchCriteria(refs, selector.Required)
|
||||
if len(matches) == 0 {
|
||||
for _, fallback := range selector.Fallback {
|
||||
matches = matchFallback(refs, fallback)
|
||||
if len(matches) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
result := MatchResult{OK: true, SelectorID: selectorID, Matched: len(matches) > 0, MatchCount: len(matches), Warnings: []string{}}
|
||||
for _, ref := range matches {
|
||||
result.Matches = append(result.Matches, summarizeNode(ref))
|
||||
}
|
||||
if !result.Matched {
|
||||
result.Warnings = append(result.Warnings, "selector_not_found")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func findSelector(catalog []Selector, selectorID string) (Selector, bool) {
|
||||
for _, selector := range catalog {
|
||||
if selector.ID == selectorID {
|
||||
return selector, true
|
||||
}
|
||||
}
|
||||
return Selector{}, false
|
||||
}
|
||||
|
||||
func flatten(root *Node) []nodeRef {
|
||||
refs := []nodeRef{}
|
||||
var walk func(node *Node, path string)
|
||||
walk = func(node *Node, path string) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
refs = append(refs, nodeRef{path: path, node: node})
|
||||
for i := range node.Children {
|
||||
walk(&node.Children[i], path+"/"+itoa(i))
|
||||
}
|
||||
}
|
||||
walk(root, "root")
|
||||
return refs
|
||||
}
|
||||
|
||||
func itoa(value int) string {
|
||||
if value == 0 {
|
||||
return "0"
|
||||
}
|
||||
digits := []byte{}
|
||||
for value > 0 {
|
||||
digits = append([]byte{byte('0' + value%10)}, digits...)
|
||||
value = value / 10
|
||||
}
|
||||
return string(digits)
|
||||
}
|
||||
|
||||
func matchCriteria(refs []nodeRef, criteria Criteria) []nodeRef {
|
||||
matches := []nodeRef{}
|
||||
for _, ref := range refs {
|
||||
if criteria.AutomationID != "" && ref.node.AutomationID != criteria.AutomationID {
|
||||
continue
|
||||
}
|
||||
if criteria.ControlType != "" && ref.node.ControlType != criteria.ControlType {
|
||||
continue
|
||||
}
|
||||
if criteria.FrameworkID != "" && ref.node.FrameworkID != criteria.FrameworkID {
|
||||
continue
|
||||
}
|
||||
if criteria.ClassName != "" && ref.node.ClassName != criteria.ClassName {
|
||||
continue
|
||||
}
|
||||
if criteria.ClassPrefix != "" && !strings.HasPrefix(ref.node.ClassName, criteria.ClassPrefix) {
|
||||
continue
|
||||
}
|
||||
matches = append(matches, ref)
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
func matchFallback(refs []nodeRef, fallback FallbackCriteria) []nodeRef {
|
||||
matches := []nodeRef{}
|
||||
for _, ref := range refs {
|
||||
if fallback.ControlType != "" && ref.node.ControlType != fallback.ControlType {
|
||||
continue
|
||||
}
|
||||
if fallback.ClassPrefix != "" && !strings.HasPrefix(ref.node.ClassName, fallback.ClassPrefix) {
|
||||
continue
|
||||
}
|
||||
if !hasChildAutomationIDs(ref.node, fallback.ChildAutomationIDContains) {
|
||||
continue
|
||||
}
|
||||
if !hasChildControlTypes(ref.node, fallback.ChildControlTypeContains) {
|
||||
continue
|
||||
}
|
||||
matches = append(matches, ref)
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
func hasChildAutomationIDs(node *Node, ids []string) bool {
|
||||
for _, id := range ids {
|
||||
found := false
|
||||
for i := range node.Children {
|
||||
if node.Children[i].AutomationID == id {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hasChildControlTypes(node *Node, controlTypes []string) bool {
|
||||
for _, controlType := range controlTypes {
|
||||
found := false
|
||||
for i := range node.Children {
|
||||
if node.Children[i].ControlType == controlType {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func summarizeNode(ref nodeRef) NodeMatch {
|
||||
return NodeMatch{
|
||||
Path: ref.path,
|
||||
ControlType: ref.node.ControlType,
|
||||
AutomationID: ref.node.AutomationID,
|
||||
ClassName: ref.node.ClassName,
|
||||
FrameworkID: ref.node.FrameworkID,
|
||||
HasName: ref.node.Name != "",
|
||||
NameLength: len(ref.node.Name),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run matcher tests and verify they pass**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
go test ./internal/uiaselector -run TestMatchSelector -count=1
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit matcher**
|
||||
|
||||
```powershell
|
||||
git add internal\uiaselector\matcher.go internal\uiaselector\matcher_test.go
|
||||
git commit -m "feat: match N13 UIA selectors offline"
|
||||
```
|
||||
|
||||
## Task 5: Add verification script
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/verify-n13-uia-selectors.ps1`
|
||||
|
||||
- [ ] **Step 1: Create verification script**
|
||||
|
||||
Create `scripts/verify-n13-uia-selectors.ps1`:
|
||||
|
||||
```powershell
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$repo = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
|
||||
Set-Location -LiteralPath $repo
|
||||
|
||||
if (-not (Get-Command go -ErrorAction SilentlyContinue)) {
|
||||
throw "go was not found. Run this verifier in a Go 1.23.x environment."
|
||||
}
|
||||
|
||||
Write-Host "## go test ./internal/uiaselector"
|
||||
go test ./internal/uiaselector -count=1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "go test ./internal/uiaselector failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
Write-Host "## boundary grep"
|
||||
$forbidden = @(
|
||||
"send_after_approval",
|
||||
"send_file_after_approval",
|
||||
"receive_file_after_approval",
|
||||
"open_conversation",
|
||||
"ValuePattern.SetValue",
|
||||
"InvokePattern"
|
||||
)
|
||||
$files = @(
|
||||
"internal\uiaselector\catalog.go",
|
||||
"internal\uiaselector\matcher.go"
|
||||
)
|
||||
foreach ($term in $forbidden) {
|
||||
$hit = Select-String -LiteralPath $files -Pattern $term -SimpleMatch -ErrorAction SilentlyContinue
|
||||
if ($hit) {
|
||||
throw "forbidden action term found in selector implementation: $term"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "N13 UIA selector verification passed."
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run script and verify it passes**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-n13-uia-selectors.ps1
|
||||
```
|
||||
|
||||
Expected in Go-enabled environment:
|
||||
|
||||
```text
|
||||
## go test ./internal/uiaselector
|
||||
ok isphere-ai-bridge/internal/uiaselector
|
||||
## boundary grep
|
||||
N13 UIA selector verification passed.
|
||||
```
|
||||
|
||||
If `go` is unavailable, expected failure is:
|
||||
|
||||
```text
|
||||
go was not found. Run this verifier in a Go 1.23.x environment.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit verification script**
|
||||
|
||||
```powershell
|
||||
git add scripts\verify-n13-uia-selectors.ps1
|
||||
git commit -m "test: add N13 selector verifier"
|
||||
```
|
||||
|
||||
## Task 6: Final verification and checkpoint
|
||||
|
||||
**Files:**
|
||||
- No new files.
|
||||
- Verify: `internal/uiaselector/*`, `scripts/verify-n13-uia-selectors.ps1`
|
||||
|
||||
- [ ] **Step 1: Run package tests**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
go test ./internal/uiaselector -count=1
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
ok isphere-ai-bridge/internal/uiaselector
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run repository tests if Go is available**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Expected: PASS for all packages.
|
||||
|
||||
If the environment lacks Go, do not mark implementation complete. Move to a Go 1.23.x environment and rerun.
|
||||
|
||||
- [ ] **Step 3: Run N13 verifier**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-n13-uia-selectors.ps1
|
||||
```
|
||||
|
||||
Expected: PASS in Go-enabled environment.
|
||||
|
||||
- [ ] **Step 4: Check no MCP/helper action surface changed**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git diff --name-only origin/main..HEAD
|
||||
```
|
||||
|
||||
Expected changed implementation files are limited to:
|
||||
|
||||
```text
|
||||
internal/uiaselector/catalog.go
|
||||
internal/uiaselector/catalog_test.go
|
||||
internal/uiaselector/dump.go
|
||||
internal/uiaselector/dump_test.go
|
||||
internal/uiaselector/matcher.go
|
||||
internal/uiaselector/matcher_test.go
|
||||
internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json
|
||||
scripts/verify-n13-uia-selectors.ps1
|
||||
```
|
||||
|
||||
If `native/ISphereWinHelper/Program.cs`, `internal/tools/winhelper.go`, or `cmd/isphere-mcp/main.go` appears, stop and review because N13 is not supposed to add live tools.
|
||||
|
||||
- [ ] **Step 5: Commit final checkpoint if needed**
|
||||
|
||||
If any verification-only documentation changes were made, run:
|
||||
|
||||
```powershell
|
||||
git add <changed verification docs>
|
||||
git commit -m "docs: record N13 selector verification"
|
||||
```
|
||||
|
||||
If no files changed, do not create an empty commit.
|
||||
|
||||
## Self-review checklist
|
||||
|
||||
- Spec coverage: this plan covers selector catalog, fixture loading, read-only matcher, no visible text in result, and boundary verification.
|
||||
- Boundary: no helper op, no MCP tool, no click/input/send/file/conversation behavior.
|
||||
- TDD: every implementation task starts with failing tests before code.
|
||||
- Commit cadence: fixture, catalog, loader, matcher, verifier are separate commits.
|
||||
- Environment caveat: Go 1.23.x is required for implementation verification.
|
||||
Reference in New Issue
Block a user