Files
fenrir/internal/clipboard/shared_persistence_test.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)
}
}