package display import ( "context" "errors" "sync" "testing" "time" ) // diagnosticCapture collects structured reconnect records from Run. type diagnosticCapture struct { mu sync.Mutex records []Diagnostic } func (c *diagnosticCapture) record(d Diagnostic) { c.mu.Lock() defer c.mu.Unlock() c.records = append(c.records, d) } func (c *diagnosticCapture) all() []Diagnostic { c.mu.Lock() defer c.mu.Unlock() return append([]Diagnostic(nil), c.records...) } func TestRunDiagnosesEachFailedOpenWithStructuredFields(t *testing.T) { session := newFakeSession() var opens int opener := fakeOpener{open: func(int) (Session, error) { opens++ if opens <= 2 { return nil, errors.New("x11 down") } return session, nil }} capture := &diagnosticCapture{} ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { done <- Run(ctx, opener, NewStatusTemplate("hello"), Options{ Target: "x11", Layer: "fallback-canvas", Diagnostic: capture.record, RetryDelay: time.Millisecond, MaxDelay: 2 * time.Millisecond, }) }() waitFor(t, func() bool { return session.canvas.lineCount() > 0 }, "connect after retries") cancel() select { case err := <-done: if err != nil { t.Fatalf("run: %v", err) } case <-time.After(time.Second): t.Fatal("run did not stop after cancel") } records := capture.all() if len(records) != 2 { t.Fatalf("records=%d want exactly one per failed open (2)", len(records)) } first, second := records[0], records[1] if first.Target != "x11" { t.Fatalf("target=%q want x11", first.Target) } if first.Layer != "fallback-canvas" { t.Fatalf("layer=%q want fallback-canvas", first.Layer) } if first.Event != EventOpenFailed { t.Fatalf("event=%q want %q", first.Event, EventOpenFailed) } if first.Err == nil || first.Err.Error() != "x11 down" { t.Fatalf("underlying error %v must be carried in the record", first.Err) } if first.RetryDelay != time.Millisecond { t.Fatalf("first retry delay=%v want %v", first.RetryDelay, time.Millisecond) } if second.RetryDelay != 2*time.Millisecond { t.Fatalf("backed-off retry delay=%v want %v", second.RetryDelay, 2*time.Millisecond) } } func TestRunDiagnosesBrokenSessionOnceAndRecovers(t *testing.T) { first := newFakeSession() second := newFakeSession() var opens int opener := fakeOpener{open: func(int) (Session, error) { opens++ if opens == 1 { return first, nil } return second, nil }} capture := &diagnosticCapture{} ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { done <- Run(ctx, opener, NewStatusTemplate("hello"), Options{ Target: "x11", Diagnostic: capture.record, RetryDelay: time.Millisecond, MaxDelay: 2 * time.Millisecond, }) }() waitFor(t, func() bool { return first.canvas.lineCount() == 1 }, "draw on first session") first.events <- eventResult{err: errors.New("connection lost")} waitFor(t, func() bool { return second.canvas.lineCount() == 1 }, "recover on reconnected session") cancel() select { case err := <-done: if err != nil { t.Fatalf("run: %v", err) } case <-time.After(time.Second): t.Fatal("run did not stop after cancel") } records := capture.all() if len(records) != 1 { t.Fatalf("records=%d want exactly one per broken session (1)", len(records)) } record := records[0] if record.Target != "x11" { t.Fatalf("target=%q want x11", record.Target) } if record.Event != EventSessionEnded { t.Fatalf("event=%q want %q", record.Event, EventSessionEnded) } if record.Err == nil || record.Err.Error() != "connection lost" { t.Fatalf("underlying error %v must be carried in the record", record.Err) } if record.RetryDelay != time.Millisecond { t.Fatalf("retry delay=%v want the reset %v", record.RetryDelay, time.Millisecond) } } // blockingOpener fails only after the context is done, so cancellation is // the sole reason Run stops: no diagnostic may fire for shutdown. type blockingOpener struct{} func (blockingOpener) Open(ctx context.Context) (Session, error) { <-ctx.Done() return nil, ctx.Err() } func TestRunStaysSilentWhenCancellationEndsTheLoop(t *testing.T) { capture := &diagnosticCapture{} ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { done <- Run(ctx, blockingOpener{}, NewStatusTemplate("hello"), Options{ Target: "x11", Diagnostic: capture.record, RetryDelay: time.Millisecond, MaxDelay: 2 * time.Millisecond, }) }() cancel() select { case err := <-done: if err != nil { t.Fatalf("run: %v", err) } case <-time.After(time.Second): t.Fatal("run did not stop after cancel") } if records := capture.all(); len(records) != 0 { t.Fatalf("records=%v must stay empty: shutdown is not a reconnect boundary", records) } }