feat: render N14 selector reports
This commit is contained in:
@@ -1,6 +1,13 @@
|
|||||||
package uiaselector
|
package uiaselector
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
StrictExitOK = 0
|
StrictExitOK = 0
|
||||||
@@ -100,3 +107,90 @@ func StrictExitCode(report Report) int {
|
|||||||
}
|
}
|
||||||
return StrictExitOK
|
return StrictExitOK
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RenderReportJSON(report Report) ([]byte, error) {
|
||||||
|
return json.MarshalIndent(report, "", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteReportJSON(report Report, path string) error {
|
||||||
|
body, err := RenderReportJSON(report)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeFileWithParents(path, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenderReportMarkdown(report Report) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("# N14 UIA Selector Report\n\n")
|
||||||
|
b.WriteString("- Source dump: `" + escapeMarkdownInline(report.SourceDump) + "`\n")
|
||||||
|
b.WriteString(fmt.Sprintf("- Catalog size: %d\n", report.CatalogSize))
|
||||||
|
b.WriteString(fmt.Sprintf("- Matched: %d\n", report.MatchedCount))
|
||||||
|
b.WriteString(fmt.Sprintf("- Unmatched: %d\n", report.UnmatchedCount))
|
||||||
|
b.WriteString(fmt.Sprintf("- Ambiguous: %d\n", report.AmbiguousCount))
|
||||||
|
b.WriteString("\n## Summary\n\n")
|
||||||
|
b.WriteString("| selector_id | status | matches | stability | evidence_path |\n")
|
||||||
|
b.WriteString("| --- | --- | ---: | ---: | --- |\n")
|
||||||
|
for _, selector := range report.Selectors {
|
||||||
|
status := "unmatched"
|
||||||
|
if selector.MatchCount > 1 {
|
||||||
|
status = "ambiguous"
|
||||||
|
} else if selector.Matched {
|
||||||
|
status = "matched"
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf(
|
||||||
|
"| %s | %s | %d | %d | %s |\n",
|
||||||
|
escapeMarkdownCell(selector.SelectorID),
|
||||||
|
status,
|
||||||
|
selector.MatchCount,
|
||||||
|
selector.StabilityScore,
|
||||||
|
escapeMarkdownCell(selector.Evidence.UIAPath),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
b.WriteString("\n## Selector Details\n")
|
||||||
|
for _, selector := range report.Selectors {
|
||||||
|
b.WriteString("\n### " + escapeMarkdownInline(selector.SelectorID) + "\n\n")
|
||||||
|
b.WriteString("- Description: " + escapeMarkdownInline(selector.Description) + "\n")
|
||||||
|
b.WriteString("- Allowed use: " + escapeMarkdownInline(selector.AllowedUse) + "\n")
|
||||||
|
b.WriteString(fmt.Sprintf("- Stability score: %d\n", selector.StabilityScore))
|
||||||
|
b.WriteString(fmt.Sprintf("- Match count: %d\n", selector.MatchCount))
|
||||||
|
b.WriteString("\nMatches:\n\n")
|
||||||
|
b.WriteString("| path | control_type | automation_id | class_name | framework_id | has_name | name_length |\n")
|
||||||
|
b.WriteString("| --- | --- | --- | --- | --- | --- | ---: |\n")
|
||||||
|
for _, match := range selector.Matches {
|
||||||
|
b.WriteString(fmt.Sprintf(
|
||||||
|
"| %s | %s | %s | %s | %s | %t | %d |\n",
|
||||||
|
escapeMarkdownCell(match.Path),
|
||||||
|
escapeMarkdownCell(match.ControlType),
|
||||||
|
escapeMarkdownCell(match.AutomationID),
|
||||||
|
escapeMarkdownCell(match.ClassName),
|
||||||
|
escapeMarkdownCell(match.FrameworkID),
|
||||||
|
match.HasName,
|
||||||
|
match.NameLength,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteReportMarkdown(report Report, path string) error {
|
||||||
|
return writeFileWithParents(path, []byte(RenderReportMarkdown(report)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFileWithParents(path string, body []byte) error {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if dir != "." && dir != "" {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, body, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeMarkdownInline(value string) string {
|
||||||
|
return strings.NewReplacer("`", "'", "\r", " ", "\n", " ").Replace(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeMarkdownCell(value string) string {
|
||||||
|
return strings.NewReplacer("|", "\\|", "\r", " ", "\n", " ").Replace(value)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package uiaselector
|
package uiaselector
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -96,3 +97,47 @@ func TestReportRejectsNonReadOnlyCatalogEntries(t *testing.T) {
|
|||||||
t.Fatalf("StrictExitCode = %d, want %d", got, StrictExitInvalidCatalog)
|
t.Fatalf("StrictExitCode = %d, want %d", got, StrictExitInvalidCatalog)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReportDoesNotExposeVisibleText(t *testing.T) {
|
||||||
|
report := RunCatalogReport(
|
||||||
|
"testdata/n12r-2026-07-09-uia-redacted.json",
|
||||||
|
loadN14Fixture(t),
|
||||||
|
DefaultCatalog(),
|
||||||
|
fixedReportTime(),
|
||||||
|
)
|
||||||
|
body, err := RenderReportJSON(report)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderReportJSON: %v", err)
|
||||||
|
}
|
||||||
|
jsonText := string(body)
|
||||||
|
for _, forbidden := range []string{`"name"`, `"value"`, "国网甘肃省电力公司"} {
|
||||||
|
if strings.Contains(jsonText, forbidden) {
|
||||||
|
t.Fatalf("JSON report exposed forbidden text %q in %s", forbidden, jsonText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, required := range []string{`"has_name"`, `"name_length"`, `"selector_id"`, `"automation_id"`} {
|
||||||
|
if !strings.Contains(jsonText, required) {
|
||||||
|
t.Fatalf("JSON report missing required structural field %q in %s", required, jsonText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkdownReportDoesNotExposeVisibleText(t *testing.T) {
|
||||||
|
report := RunCatalogReport(
|
||||||
|
"testdata/n12r-2026-07-09-uia-redacted.json",
|
||||||
|
loadN14Fixture(t),
|
||||||
|
DefaultCatalog(),
|
||||||
|
fixedReportTime(),
|
||||||
|
)
|
||||||
|
markdown := RenderReportMarkdown(report)
|
||||||
|
for _, forbidden := range []string{"国网甘肃省电力公司", "| name |", "| value |"} {
|
||||||
|
if strings.Contains(markdown, forbidden) {
|
||||||
|
t.Fatalf("Markdown report exposed forbidden text %q in %s", forbidden, markdown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, required := range []string{"# N14 UIA Selector Report", "## Summary", "automation_id", "control_type", "class_name", "has_name", "name_length"} {
|
||||||
|
if !strings.Contains(markdown, required) {
|
||||||
|
t.Fatalf("Markdown report missing %q in %s", required, markdown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user