Files
cthulhu/internal/app/browser_replacement_native_test.go

189 lines
6.2 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/review"
)
func TestNativeBrowserInlineReplacementNavigation(t *testing.T) {
run_native_replacement_navigation(t, false)
}
func TestNativeBrowserGeneratedLinkNavigation(t *testing.T) {
run_native_replacement_navigation(t, true)
}
func run_native_replacement_navigation(t *testing.T, generated bool) {
t.Helper()
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
t.Skip("requires private browser session")
}
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if generated {
fmt.Fprint(w, `<!doctype html><title>Inline replacement regression</title>
<style>
a:link {background-image:linear-gradient(blue,blue);background-size:0% 0.1em;background-position:50% 100%;background-repeat:no-repeat;transition:none;text-decoration:underline}
a:hover {background-size:100% 0.1em;text-decoration:none}
a[href^="http"]::before {content:"↺ ";color:black}
a[href^="http"]:hover {text-underline-offset:0.2em;transition:background-size 0.2s ease-in-out}
</style>
<p>Published <a id="attribution" href="https://example.invalid/source">by <em>Publisher</em> some hours ago</a>. Article continues</p>`)
return
}
fmt.Fprint(w, `<!doctype html><title>Inline replacement regression</title>
<p id="paragraph">Published<br aria-hidden="true">↺ by Publisher<br aria-hidden="true">Article continues</p>
<script>
document.addEventListener('keydown', event => {
if (event.key !== 'F8') return;
event.preventDefault();
const paragraph = document.getElementById('paragraph');
paragraph.replaceChild(document.createTextNode('↺ by Publisher'), paragraph.childNodes[2]);
});
</script>`)
}))
defer server.Close()
adapter := atspi.New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer adapter.Stop(context.Background())
command := func(args ...string) {
t.Helper()
if output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil {
t.Fatalf("xdotool: %v: %s", err, output)
}
}
command("key", "--clearmodifiers", "ctrl+l")
command("type", "--clearmodifiers", "--delay", "0", server.URL)
command("key", "Return")
var document atspi.ObjectID
for !document.Valid() {
select {
case event := <-adapter.Events():
if event.Type == atspi.DocumentLoadCompleteEvent && event.Source.Name == "Inline replacement regression" {
document = event.Source.ID()
}
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
worker, err := review.NewWorker(adapter, 2*time.Second)
if err != nil {
t.Fatal(err)
}
if err := worker.Start(ctx); err != nil {
t.Fatal(err)
}
defer worker.Stop(context.Background())
worker.SyncFocus(document)
dump_generated_tree := func() {
if !generated {
return
}
tree, captureErr := adapter.CaptureDocument(ctx, document)
if captureErr != nil {
return
}
ref, _ := tree.Reference(document)
doc, _ := tree.Document(ctx, ref)
for _, node := range doc.Nodes {
if node.Snapshot.Role == "static" {
text, err := adapter.ReadDocumentCaret(ctx, node.Reference, 0, atspi.DocumentCaretLine)
t.Logf("native node %s parent=%s name=%q count=%d text=%+v err=%v", node.Snapshot.Path, node.Parent.Path, node.Snapshot.Name, node.Snapshot.CharacterCount, text, err)
}
}
}
move := func(command review.DocumentCaretCommand, want string) review.Result {
t.Helper()
if err := worker.MoveDocumentCaret(command); err != nil {
t.Fatal(err)
}
select {
case result := <-worker.Results():
// Generated text can disappear between a successful virtual move
// and native placement. A caret error must not undo that move;
// require the exact text and subsequent navigation/replacement
// checks rather than requiring the transient native caret to stick.
if result.Err != nil || result.CaretErr != nil && !generated || !result.Moved || result.DocumentCaretText != want {
dump_generated_tree()
t.Fatalf("%s: text=%q want=%q err=%v caret=%v diagnostics=%+v", command,
result.DocumentCaretText, want, result.Err, result.CaretErr, result.DocumentDiagnostics)
}
if generated && result.CaretErr != nil {
t.Logf("generated native caret: %v; virtual movement succeeded", result.CaretErr)
}
return result
case <-ctx.Done():
t.Fatal(ctx.Err())
return review.Result{}
}
}
move(review.CaretNextLine, "Published")
attribution, after := "↺ by Publisher", "Article continues"
if generated {
attribution, after = "↺ by Publisher some hours ago", ". Article continues"
}
current := move(review.CaretNextLine, attribution)
for generation := 0; generation < 4; generation++ {
old := current.DocumentPosition.Object
if !generated {
command("key", "--clearmodifiers", "F8")
}
// Require a genuinely replaced native static-text object. Merely
// changing a label would not exercise recovery of a missing cursor.
for {
tree, err := adapter.CaptureDocument(ctx, document)
if err != nil {
t.Fatal(err)
}
if _, err := tree.Resolve(old); err != nil {
break
}
select {
case <-adapter.Events():
case <-ctx.Done():
t.Fatal("native attribution object was not replaced: ", ctx.Err())
}
}
if generation%2 == 0 {
move(review.CaretNextLine, after)
current = move(review.CaretPreviousLine, attribution)
} else {
move(review.CaretPreviousLine, "Published")
current = move(review.CaretNextLine, attribution)
}
}
if err := worker.ReadAll(ctx); err != nil {
t.Fatal(err)
}
select {
case result := <-worker.Results():
if result.Err != nil || len(result.ReadAllLines) != 2 {
t.Fatalf("Read All after replacement: err=%v lines=%d", result.Err, len(result.ReadAllLines))
}
for index, want := range []string{attribution, after} {
if got := result.ReadAllLines[index].Contents[0].Text; got != want {
dump_generated_tree()
t.Fatalf("Read All line %d: %q, want %q", index, got, want)
}
}
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}