189 lines
6.2 KiB
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())
|
|
}
|
|
}
|