package atspi import ( "bufio" "context" "encoding/json" "fmt" "os" "os/exec" "strings" "testing" "time" ) func TestNativeCurrentLine(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_CURRENT_LINE") != "1" { t.Skip("requires private Xvfb and AT-SPI") } ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) defer cancel() adapter := New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer adapter.Stop(context.Background()) fixture := exec.CommandContext(ctx, "/usr/bin/python3", "-u", "../../tests/current_line_fixture.py") fixture.Stderr = os.Stderr stdin, _ := fixture.StdinPipe() stdout, _ := fixture.StdoutPipe() if err := fixture.Start(); err != nil { t.Fatal(err) } defer func() { stdin.Close(); fixture.Wait() }() scanner := bufio.NewScanner(stdout) ready := func() { t.Helper() if !scanner.Scan() || scanner.Text() != "ready" { t.Fatalf("fixture not ready: %q %v", scanner.Text(), scanner.Err()) } } ready() var focus ObjectID lastControl := "" for _, probe := range []struct { control, text string offset int want string password bool }{ {"entry", "Café 猫 complete", 7, "Café 猫 complete", false}, {"text", "First\nSecond café 猫\nThird", 11, "Second café 猫", false}, {"entry", "Final line", 10, "Final line", false}, {"text", "First\n\nLast", 6, "", false}, {"entry", "", 0, "", false}, {"text", "Final\n", 6, "", false}, {"password", "private test text", 5, "", true}, {"button", "", 0, "Example button", false}, {"deep", "Deep local line", 3, "Deep local line", false}, } { command, _ := json.Marshal(map[string]any{"action": "focus", "control": probe.control, "text": probe.text, "offset": probe.offset}) fmt.Fprintln(stdin, string(command)) ready() if probe.control != lastControl { for { select { case event := <-adapter.Events(): if event.Type == "object:state-changed:focused" && event.Detail1 == 1 && (event.Source.Name == map[string]string{"entry": "Entry", "text": "Text", "password": "Password", "button": "Example button", "deep": "Deep entry"}[probe.control]) { focus = event.Source.ID() goto focused } case <-ctx.Done(): t.Fatal(ctx.Err()) } } } focused: lastControl = probe.control content, err := adapter.ReadFocusedLine(ctx, focus) focusedSnapshot, snapshotErr := adapter.ReadFocusedSnapshot(ctx, focus) if snapshotErr != nil || focusedSnapshot.Snapshot.ID() != focus { t.Fatalf("focused snapshot: %+v %v", focusedSnapshot, snapshotErr) } if probe.password { if err == nil || content.Text != "" || content.Snapshot.Name != "" { t.Fatalf("password report: %+v %v", content, err) } if focusedSnapshot.Snapshot.Text != "" || focusedSnapshot.Snapshot.Role != "password text" { t.Fatal("focused snapshot exposed password text") } continue } if err != nil || strings.TrimRight(content.Text, "\r\n") != probe.want { t.Fatalf("%s offset %d: %+v %v, want %q", probe.control, probe.offset, content, err, probe.want) } if probe.control == "deep" { if len(content.SourceContext) != 0 || len(focusedSnapshot.SourceContext) != 0 { t.Fatal("truncated native ancestry authorized export") } } else if len(content.SourceContext) == 0 || content.SourceContext[0].ID() != focus || content.SourceContext[len(content.SourceContext)-1].Role != "application" { t.Fatalf("native report lost complete source ancestry: %+v", content.SourceContext) } if probe.control != "deep" && (len(focusedSnapshot.SourceContext) == 0 || focusedSnapshot.SourceContext[0].ID() != focus || focusedSnapshot.SourceContext[len(focusedSnapshot.SourceContext)-1].Role != "application") { t.Fatal("focused snapshot lost complete source ancestry") } if probe.control == "entry" && probe.want != "" { for _, role := range []struct{ atk, want string }{{"PASSWORD_TEXT", "password text"}, {"UNKNOWN", "unknown"}, {"INVALID", "invalid"}} { fmt.Fprintf(stdin, "{\"action\":\"privacy\",\"role\":%q}\n", role.atk) ready() private, err := adapter.ReadFocusedLine(ctx, focus) if err != nil || private.Text != content.Text { t.Fatal("ancestor privacy changed local text", err) } found := false for _, ancestor := range private.SourceContext { found = found || ancestor.Role == role.want } if len(private.SourceContext) != 0 && !found { t.Fatal("native capture omitted a private ancestor", role.want) } privateSnapshot, err := adapter.ReadFocusedSnapshot(ctx, focus) if err != nil || privateSnapshot.Snapshot.Text != focusedSnapshot.Snapshot.Text { t.Fatal("ancestor privacy changed the focused snapshot", err) } found = false for _, ancestor := range privateSnapshot.SourceContext { found = found || ancestor.Role == role.want } if len(privateSnapshot.SourceContext) != 0 && !found { t.Fatal("focused snapshot omitted a private ancestor", role.want) } } fmt.Fprintln(stdin, "{\"action\":\"privacy\",\"role\":\"FILLER\"}") ready() } if probe.control != "button" { fmt.Fprintf(stdin, "{\"action\":\"caret\",\"control\":%q}\n", probe.control) if !scanner.Scan() || scanner.Text() != fmt.Sprint(probe.offset) { t.Fatalf("caret moved: %q, want %d", scanner.Text(), probe.offset) } ready() } } if _, err := adapter.ReadFocusedLine(ctx, ObjectID{BusName: focus.BusName, Path: "/stale"}); err == nil { t.Fatal("accepted stale native focus") } if _, err := adapter.ReadFocusedSnapshot(ctx, ObjectID{BusName: focus.BusName, Path: "/stale"}); err == nil { t.Fatal("accepted stale focused snapshot") } }