// 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) } }