331 lines
9.2 KiB
Go
331 lines
9.2 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package clipboard
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type sharedCommand struct {
|
|
Action string
|
|
Text string
|
|
State Snapshot
|
|
Limit int
|
|
}
|
|
|
|
type sharedReply struct {
|
|
State Snapshot
|
|
Enabled bool
|
|
Error string
|
|
}
|
|
|
|
// Separate processes exercise flock and polling, rather than sharing Go state.
|
|
func TestSharedClipboardProcess(t *testing.T) {
|
|
path := os.Getenv("FENRIR_CLIPBOARD_PROCESS_PATH")
|
|
if path == "" {
|
|
return
|
|
}
|
|
store := New_persistence(path, true)
|
|
current := sharedReply{State: store.Initial, Enabled: store.InitialEnabled}
|
|
if store.LoadErr != nil {
|
|
current.Error = store.LoadErr.Error()
|
|
}
|
|
encoder, decoder := json.NewEncoder(os.Stdout), json.NewDecoder(os.Stdin)
|
|
if err := encoder.Encode(current); err != nil {
|
|
os.Exit(2)
|
|
}
|
|
for {
|
|
var command sharedCommand
|
|
if err := decoder.Decode(&command); err != nil {
|
|
store.Close()
|
|
os.Exit(0)
|
|
}
|
|
switch command.Action {
|
|
case "add":
|
|
store.Add(command.Text, command.Limit)
|
|
case "select":
|
|
store.Select(command.State)
|
|
case "resize":
|
|
store.Resize(command.Limit)
|
|
case "clear":
|
|
store.Clear()
|
|
case "disable":
|
|
store.Disable()
|
|
case "enable":
|
|
store.Enable(command.State, command.Limit)
|
|
case "quit":
|
|
if err := store.Close(); err != nil {
|
|
current.Error = err.Error()
|
|
}
|
|
encoder.Encode(current)
|
|
os.Exit(0)
|
|
}
|
|
if command.Action != "state" {
|
|
deadline := time.NewTimer(4 * time.Second)
|
|
wait:
|
|
for {
|
|
select {
|
|
case update := <-store.Updates():
|
|
current.State, current.Enabled = update.State, update.Enabled
|
|
if update.Sequence == store.Sequence() {
|
|
break wait
|
|
}
|
|
case err := <-store.Errors():
|
|
current.Error = err.Error()
|
|
break wait
|
|
case <-deadline.C:
|
|
current.Error = "timed out waiting for action"
|
|
break wait
|
|
}
|
|
}
|
|
deadline.Stop()
|
|
} else {
|
|
drain:
|
|
for {
|
|
select {
|
|
case update := <-store.Updates():
|
|
current.State, current.Enabled = update.State, update.Enabled
|
|
case err := <-store.Errors():
|
|
current.Error = err.Error()
|
|
default:
|
|
break drain
|
|
}
|
|
}
|
|
}
|
|
if err := encoder.Encode(current); err != nil {
|
|
os.Exit(2)
|
|
}
|
|
}
|
|
}
|
|
|
|
type sharedProcess struct {
|
|
command *exec.Cmd
|
|
input io.WriteCloser
|
|
encoder *json.Encoder
|
|
decoder *json.Decoder
|
|
}
|
|
|
|
func start_shared_process(t *testing.T, path string) *sharedProcess {
|
|
t.Helper()
|
|
command := exec.Command(os.Args[0], "-test.run=^TestSharedClipboardProcess$")
|
|
command.Env = append(os.Environ(), "FENRIR_CLIPBOARD_PROCESS_PATH="+path)
|
|
command.Stderr = os.Stderr
|
|
input, err := command.StdinPipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
output, err := command.StdoutPipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
process := &sharedProcess{command: command, input: input, encoder: json.NewEncoder(input), decoder: json.NewDecoder(output)}
|
|
if err := command.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { input.Close(); command.Wait() })
|
|
reply := process.read(t)
|
|
if reply.Error != "" {
|
|
t.Fatal(reply.Error)
|
|
}
|
|
return process
|
|
}
|
|
|
|
func (p *sharedProcess) read(t *testing.T) sharedReply {
|
|
t.Helper()
|
|
// A broken worker must fail the test instead of blocking the whole suite.
|
|
result := make(chan sharedReply, 1)
|
|
failures := make(chan error, 1)
|
|
go func() {
|
|
var reply sharedReply
|
|
if err := p.decoder.Decode(&reply); err != nil {
|
|
failures <- err
|
|
} else {
|
|
result <- reply
|
|
}
|
|
}()
|
|
select {
|
|
case reply := <-result:
|
|
return reply
|
|
case err := <-failures:
|
|
t.Fatal(err)
|
|
case <-time.After(6 * time.Second):
|
|
p.command.Process.Kill()
|
|
t.Fatal("clipboard process did not reply")
|
|
}
|
|
return sharedReply{}
|
|
}
|
|
|
|
func (p *sharedProcess) action(t *testing.T, command sharedCommand) sharedReply {
|
|
t.Helper()
|
|
if command.Limit == 0 {
|
|
command.Limit = 200
|
|
}
|
|
if err := p.encoder.Encode(command); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reply := p.read(t)
|
|
if reply.Error != "" {
|
|
t.Fatal(reply.Error)
|
|
}
|
|
return reply
|
|
}
|
|
|
|
func (p *sharedProcess) await(t *testing.T, entries []string, enabled bool) sharedReply {
|
|
t.Helper()
|
|
deadline := time.Now().Add(4 * time.Second)
|
|
for {
|
|
reply := p.action(t, sharedCommand{Action: "state"})
|
|
if reflect.DeepEqual(reply.State.Entries, entries) && reply.Enabled == enabled {
|
|
return reply
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("shared state = %+v, want %v enabled=%v", reply, entries, enabled)
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestPersistentClipboardSharesAcrossProcessesAndClearCannotResurrect(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "private", "terminal.json")
|
|
first, second := start_shared_process(t, path), start_shared_process(t, path)
|
|
first.action(t, sharedCommand{Action: "add", Text: "first"})
|
|
second.await(t, []string{"first"}, true)
|
|
second.action(t, sharedCommand{Action: "add", Text: "second"})
|
|
first.await(t, []string{"second", "first"}, true)
|
|
stale := Snapshot{Version: 1, Entries: []string{"second", "first"}, Selected: 1}
|
|
first.action(t, sharedCommand{Action: "select", State: stale})
|
|
second.action(t, sharedCommand{Action: "clear"})
|
|
first.action(t, sharedCommand{Action: "select", State: stale})
|
|
first.action(t, sharedCommand{Action: "resize", Limit: 10})
|
|
first.await(t, nil, true)
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Fatal("navigation recreated cleared history", err)
|
|
}
|
|
first.action(t, sharedCommand{Action: "add", Text: "after clear"})
|
|
second.await(t, []string{"after clear"}, true)
|
|
second.action(t, sharedCommand{Action: "disable"})
|
|
first.await(t, nil, false)
|
|
first.action(t, sharedCommand{Action: "select", State: stale})
|
|
first.action(t, sharedCommand{Action: "add", Text: "late copy"})
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Fatal("peer recreated disabled snapshot", err)
|
|
}
|
|
// An explicit enable is required to resume writes.
|
|
second.action(t, sharedCommand{Action: "enable", State: Snapshot{Version: 1, Entries: []string{"retained"}}})
|
|
first.await(t, []string{"retained"}, true)
|
|
first.action(t, sharedCommand{Action: "quit"})
|
|
second.action(t, sharedCommand{Action: "add", Text: "survivor"})
|
|
third := start_shared_process(t, path)
|
|
third.await(t, []string{"survivor", "retained"}, true)
|
|
if err := second.command.Process.Kill(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
third.action(t, sharedCommand{Action: "add", Text: "after crash"})
|
|
third.await(t, []string{"after crash", "survivor", "retained"}, true)
|
|
}
|
|
|
|
func TestPersistentClipboardConcurrentProcessCopiesKeepBothUpdates(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "private", "terminal.json")
|
|
first, second := start_shared_process(t, path), start_shared_process(t, path)
|
|
var group sync.WaitGroup
|
|
for _, process := range []*sharedProcess{first, second} {
|
|
group.Add(1)
|
|
go func() {
|
|
defer group.Done()
|
|
for _, text := range []string{"one", "two", "three", "four"} {
|
|
process.action(t, sharedCommand{Action: "add", Text: text})
|
|
}
|
|
}()
|
|
}
|
|
group.Wait()
|
|
state, err := load_history(path)
|
|
if err != nil || len(state.Entries) != 8 {
|
|
t.Fatal("lost concurrent copies", state, err)
|
|
}
|
|
counts := make(map[string]int)
|
|
for _, text := range state.Entries {
|
|
counts[text]++
|
|
}
|
|
for _, text := range []string{"one", "two", "three", "four"} {
|
|
if counts[text] != 2 {
|
|
t.Fatal("copy lost or duplicated", counts)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPersistentClipboardRefreshRetainsLocalSelectionAndDuplicateIdentity(t *testing.T) {
|
|
history := New_history(5)
|
|
history.Add("same")
|
|
history.Add("middle")
|
|
history.Add("same")
|
|
history.Move(2)
|
|
if err := history.Refresh(Snapshot{Version: 1, Entries: []string{"same", "same", "middle", "same"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if history.Snapshot().Selected != 3 {
|
|
t.Fatal("selected older duplicate moved", history.Snapshot())
|
|
}
|
|
if err := history.Refresh(Snapshot{Version: 1}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := history.Current(); ok {
|
|
t.Fatal("shared clear retained a local entry")
|
|
}
|
|
}
|
|
|
|
func TestPersistenceQueuedEnableThenClearStillParticipates(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "private", "terminal.json")
|
|
store := New_persistence(path, false)
|
|
store.Enable(Snapshot{Version: 1, Entries: []string{"old"}}, 200)
|
|
store.Clear()
|
|
store.Add("fresh", 200)
|
|
deadline := time.NewTimer(3 * time.Second)
|
|
defer deadline.Stop()
|
|
for {
|
|
select {
|
|
case update := <-store.Updates():
|
|
if update.Sequence == store.Sequence() {
|
|
if !update.Enabled || !reflect.DeepEqual(update.State.Entries, []string{"fresh"}) {
|
|
t.Fatal(update)
|
|
}
|
|
if err := store.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return
|
|
}
|
|
case err := <-store.Errors():
|
|
t.Fatal(err)
|
|
case <-deadline.C:
|
|
store.Close()
|
|
t.Fatal("queued enable/Clear lost participation")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPersistenceDuplicateXImportsAreOneEntryButCopiesRemainDistinct(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "private", "terminal.json")
|
|
first, second := New_persistence(path, true), New_persistence(path, true)
|
|
first.Import("shared X text", 200)
|
|
if err := first.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second.Import("shared X text", 200)
|
|
second.Add("shared X text", 200)
|
|
if err := second.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
state, err := load_history(path)
|
|
if err != nil || !reflect.DeepEqual(state.Entries, []string{"shared X text", "shared X text"}) {
|
|
t.Fatal(state, err)
|
|
}
|
|
}
|