@termwright/probe-tview 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,428 @@
1
+ package tview
2
+
3
+ // Tests for the injected probe. They ship with the patch set and run inside
4
+ // the instrumented copy, which is the only place the probe's internals exist.
5
+ //
6
+ // The one that matters is the stalled driver: publication happens under the
7
+ // application's write lock, so a driver that stops reading must cost frames
8
+ // and never the application's ability to draw.
9
+
10
+ import (
11
+ "encoding/binary"
12
+ "errors"
13
+ "io"
14
+ "net"
15
+ "os"
16
+ "path/filepath"
17
+ "runtime"
18
+ "strconv"
19
+ "strings"
20
+ "testing"
21
+ "time"
22
+
23
+ "github.com/gdamore/tcell/v2"
24
+
25
+ "github.com/gorce-ai/termwright/clients/go/annotate"
26
+ "github.com/gorce-ai/termwright/clients/go/protocol"
27
+ )
28
+
29
+ // stalledDriver answers the handshake and then reads nothing until released.
30
+ type stalledDriver struct {
31
+ listener net.Listener
32
+ resume chan struct{}
33
+ conn net.Conn
34
+ }
35
+
36
+ func startStalledDriver(t *testing.T, path string) *stalledDriver {
37
+ t.Helper()
38
+ listener, err := net.Listen("unix", path)
39
+ if err != nil {
40
+ t.Fatal(err)
41
+ }
42
+ driver := &stalledDriver{listener: listener, resume: make(chan struct{})}
43
+
44
+ go func() {
45
+ conn, err := listener.Accept()
46
+ if err != nil {
47
+ return
48
+ }
49
+ driver.conn = conn
50
+ // Keep this test independent of the host kernel's default socket
51
+ // capacity. Linux CI otherwise accepted every test frame, so the test
52
+ // reported a skip without exercising the write deadline at all.
53
+ if unix, ok := conn.(*net.UnixConn); ok {
54
+ if err := unix.SetReadBuffer(4 * 1024); err != nil {
55
+ return
56
+ }
57
+ }
58
+
59
+ // Read exactly the hello frame. A broad Read may also consume the first
60
+ // snapshot when the client writes it immediately after the handshake.
61
+ // That accidentally frees the receive buffer and makes this test depend
62
+ // on scheduler timing instead of exercising the stalled-writer path.
63
+ header := make([]byte, protocol.FrameHeaderBytes)
64
+ if _, err := io.ReadFull(conn, header); err != nil {
65
+ return
66
+ }
67
+ bodyLength := int(binary.BigEndian.Uint32(header))
68
+ if bodyLength <= 0 || bodyLength > protocol.DefaultLimits.MaxFrameBytes {
69
+ return
70
+ }
71
+ if _, err := io.ReadFull(conn, make([]byte, bodyLength)); err != nil {
72
+ return
73
+ }
74
+ ack, _ := protocol.EncodeFrame(map[string]any{
75
+ "type": "hello-ack", "protocol": protocol.ProtocolID, "sessionId": "s-1",
76
+ "limits": protocol.DefaultLimits, "subscribe": "diffs",
77
+ "marker": map[string]any{"enabled": true},
78
+ }, protocol.DefaultLimits.MaxFrameBytes)
79
+ _, _ = conn.Write(ack)
80
+
81
+ <-driver.resume
82
+ buffer := make([]byte, 64*1024)
83
+ for {
84
+ if _, err := conn.Read(buffer); err != nil {
85
+ return
86
+ }
87
+ }
88
+ }()
89
+
90
+ t.Cleanup(func() { _ = listener.Close() })
91
+ return driver
92
+ }
93
+
94
+ // probeAgainst builds a probe wired to `endpoint`, without touching the
95
+ // package-level one that a real run installs.
96
+ func probeAgainst(t *testing.T, endpoint string) *termwrightProbeState {
97
+ t.Helper()
98
+ t.Setenv("TERMWRIGHT_ENDPOINT", endpoint)
99
+ t.Setenv("TERMWRIGHT_TOKEN", "token")
100
+
101
+ probe := newTermwrightProbe()
102
+ if probe == nil {
103
+ t.Fatal("the probe stayed dormant with the handshake variables set")
104
+ }
105
+ t.Cleanup(func() { _ = probe.client.Close() })
106
+
107
+ deadline := time.Now().Add(2 * time.Second)
108
+ for !probe.client.Connected() && time.Now().Before(deadline) {
109
+ time.Sleep(5 * time.Millisecond)
110
+ }
111
+ if !probe.client.Connected() {
112
+ t.Fatal("the handshake did not complete")
113
+ }
114
+ return probe
115
+ }
116
+
117
+ // sampleApplication builds a tree with enough in it to be worth publishing.
118
+ func sampleApplication(t *testing.T) (*Application, tcell.Screen, *List) {
119
+ t.Helper()
120
+ screen := tcell.NewSimulationScreen("UTF-8")
121
+ if err := screen.Init(); err != nil {
122
+ t.Fatal(err)
123
+ }
124
+ screen.SetSize(80, 24)
125
+ t.Cleanup(screen.Fini)
126
+
127
+ // Big enough that a handful of frames fills a socket buffer. A small tree
128
+ // makes the stalled-driver test skip itself, which looks like a pass and
129
+ // covers nothing.
130
+ label := strings.Repeat("a reasonably long list item label ", 4)
131
+ list := NewList().ShowSecondaryText(false)
132
+ for index := 0; index < 400; index++ {
133
+ list.AddItem(label+strconv.Itoa(index), "", 0, nil)
134
+ }
135
+ root := NewFlex().SetDirection(FlexRow).
136
+ AddItem(NewTextView().SetText("header"), 1, 0, false).
137
+ AddItem(list, 0, 1, true)
138
+
139
+ app := NewApplication()
140
+ app.root = root
141
+ root.SetRect(0, 0, 80, 24)
142
+ return app, screen, list
143
+ }
144
+
145
+ // churn rewrites every label, so the frame really differs from the last one.
146
+ //
147
+ // Without it the driver subscribes to diffs and an unchanged tree produces a
148
+ // delta of almost nothing — which never fills a socket buffer, so the stalled
149
+ // tests skip themselves and cover nothing while looking green.
150
+ func churn(list *List, round int) {
151
+ suffix := strconv.Itoa(round)
152
+ for index := 0; index < list.GetItemCount(); index++ {
153
+ main, _ := list.GetItemText(index)
154
+ list.SetItemText(index, main[:len(main)-len(suffixOf(main))]+suffix, "")
155
+ }
156
+ }
157
+
158
+ // suffixOf returns the trailing digits of a label.
159
+ func suffixOf(label string) string {
160
+ end := len(label)
161
+ for end > 0 && label[end-1] >= '0' && label[end-1] <= '9' {
162
+ end--
163
+ }
164
+ return label[end:]
165
+ }
166
+
167
+ func TestTheProbeIsDormantWithoutTheHandshakeVariables(t *testing.T) {
168
+ t.Setenv("TERMWRIGHT_ENDPOINT", "")
169
+ t.Setenv("TERMWRIGHT_TOKEN", "")
170
+
171
+ if probe := newTermwrightProbe(); probe != nil {
172
+ t.Fatal("an uninstrumented run built a probe")
173
+ }
174
+ }
175
+
176
+ func TestAStalledDriverCostsFramesAndNotTheApplication(t *testing.T) {
177
+ // The requirement in one test: no probe write may block the render loop
178
+ // indefinitely, and the application must survive termwright disappearing.
179
+ path := filepath.Join(shortDir(t), "s")
180
+ driver := startStalledDriver(t, path)
181
+ probe := probeAgainst(t, path)
182
+ app, screen, list := sampleApplication(t)
183
+
184
+ started := time.Now()
185
+ var deltasBefore int64
186
+ for attempt := 0; attempt < 400 && probe.timedOut.Load() == 0; attempt++ {
187
+ churn(list, attempt)
188
+ probe.afterFrame(app, screen)
189
+ if probe.frames.Load() > 0 && deltasBefore == 0 {
190
+ deltasBefore = probe.client.DeltasSent()
191
+ }
192
+ }
193
+ elapsed := time.Since(started)
194
+
195
+ if probe.timedOut.Load() == 0 {
196
+ t.Fatal("the bounded socket accepted every frame; the stalled-driver path was not exercised")
197
+ }
198
+
199
+ // Bounded: 400 frames against a driver that never reads must not take
200
+ // anything like 400 × the deadline, because the session closes on the
201
+ // first timeout and every later publish returns at once.
202
+ if elapsed > 5*time.Second {
203
+ t.Fatalf("publishing against a stalled driver took %s, which is not bounded", elapsed)
204
+ }
205
+ if probe.dropped.Load() == 0 {
206
+ t.Fatal("frames were lost but nothing was counted")
207
+ }
208
+ // The application is still drawable: the hook returns, and it returns
209
+ // without having held anything open.
210
+ probe.afterFrame(app, screen)
211
+
212
+ // And the obligation is outstanding, so the driver cannot be handed a
213
+ // delta based on a revision it never received.
214
+ if !probe.client.FullSnapshotRequired() {
215
+ t.Fatal("frames were dropped without demanding a full snapshot next")
216
+ }
217
+ _ = driver
218
+ }
219
+
220
+ func TestAFailedPublishWritesNoMarker(t *testing.T) {
221
+ // A marker names a revision. Writing one for a tree that never arrived
222
+ // makes the driver wait for it and then blame the adapter's timing.
223
+ path := filepath.Join(shortDir(t), "s")
224
+ _ = startStalledDriver(t, path)
225
+ probe := probeAgainst(t, path)
226
+ app, screen, list := sampleApplication(t)
227
+
228
+ read, write, err := os.Pipe()
229
+ if err != nil {
230
+ t.Fatal(err)
231
+ }
232
+ original := os.Stdout
233
+ os.Stdout = write
234
+ t.Cleanup(func() { os.Stdout = original })
235
+
236
+ for attempt := 0; attempt < 400 && probe.timedOut.Load() == 0; attempt++ {
237
+ churn(list, attempt)
238
+ probe.afterFrame(app, screen)
239
+ }
240
+ _ = write.Close()
241
+
242
+ buffer := make([]byte, 64*1024)
243
+ n, _ := read.Read(buffer)
244
+ written := string(buffer[:n])
245
+
246
+ if probe.timedOut.Load() == 0 {
247
+ t.Fatal("the bounded socket accepted every frame; the failed-publish path was not exercised")
248
+ }
249
+ // Whatever markers the first few successful frames wrote, the dropped
250
+ // ones must not have added any: one marker per published revision.
251
+ if markers := countMarkers(written); uint64(markers) != probe.frames.Load() {
252
+ t.Fatalf("wrote %d markers for %d published frames", markers, probe.frames.Load())
253
+ }
254
+ }
255
+
256
+ func TestTheProbeRecognisesARejectedSnapshotSeparately(t *testing.T) {
257
+ // A snapshot refused by validation is not a driver that stopped reading,
258
+ // and the two must not share a diagnosis.
259
+ if errors.Is(protocol.ErrWriteTimeout, os.ErrDeadlineExceeded) {
260
+ return
261
+ }
262
+ if protocol.ValidationCode(protocol.ErrWriteTimeout) != "" {
263
+ t.Fatal("a write timeout was reported as a validation failure")
264
+ }
265
+ }
266
+
267
+ func TestMarkerCounterRecognisesOnlyTermwrightOSCMarkers(t *testing.T) {
268
+ text := "plain\x1b]0;window title\x07" +
269
+ "\x1b]8487;twm;1;first\x07between" +
270
+ "\x1b]8487;twm;2;second\x07"
271
+ if markers := countMarkers(text); markers != 2 {
272
+ t.Fatalf("counted %d Termwright markers, want 2", markers)
273
+ }
274
+ }
275
+
276
+ func TestAnnotationsResolveKeysAfterTheWholeRetainedTreeIsKnown(t *testing.T) {
277
+ annotate.Reset()
278
+ t.Cleanup(annotate.Reset)
279
+
280
+ control := NewButton("Save")
281
+ label := NewTextView().SetText("Release name")
282
+ help := NewTextView().SetText("Use a unique name")
283
+ annotate.Tag(control, annotate.Semantics{
284
+ Name: "Save release",
285
+ Actions: []protocol.Action{protocol.ActionActivate, protocol.ActionActivate, protocol.Action("invalid")},
286
+ LabelledBy: []annotate.SemanticKey{"release-label"},
287
+ DescribedBy: []annotate.SemanticKey{"release-help", "missing"},
288
+ })
289
+ annotate.Tag(label, annotate.Semantics{Key: "release-label"})
290
+ annotate.Tag(help, annotate.Semantics{Key: "release-help"})
291
+
292
+ // The control deliberately comes first. A one-pass resolver would miss
293
+ // both targets because neither has been walked yet.
294
+ root := NewFlex().SetDirection(FlexRow).
295
+ AddItem(control, 1, 0, false).
296
+ AddItem(label, 1, 0, false).
297
+ AddItem(help, 1, 0, false)
298
+ app := NewApplication()
299
+ app.root = root
300
+ root.SetRect(0, 0, 40, 3)
301
+
302
+ probe := &termwrightProbeState{ids: make(map[Primitive]string)}
303
+ snapshot := probe.snapshot(app, 40, 3, false)
304
+ controlID := probe.identity(control)
305
+ labelID := probe.identity(label)
306
+ helpID := probe.identity(help)
307
+
308
+ var node *protocol.Node
309
+ for index := range snapshot.Nodes {
310
+ if snapshot.Nodes[index].ID == controlID {
311
+ node = &snapshot.Nodes[index]
312
+ break
313
+ }
314
+ }
315
+ if node == nil {
316
+ t.Fatal("annotated control was not published")
317
+ }
318
+ if strings.Join(node.LabelledBy, ",") != labelID || strings.Join(node.DescribedBy, ",") != helpID {
319
+ t.Fatalf("relations were not resolved by key: labelledBy=%v describedBy=%v", node.LabelledBy, node.DescribedBy)
320
+ }
321
+ if len(node.Actions) != 1 || node.Actions[0] != protocol.ActionActivate {
322
+ t.Fatalf("actions were not closed and deduplicated: %v", node.Actions)
323
+ }
324
+ if node.P != protocol.ProvenanceFramework {
325
+ t.Fatalf("node-wide provenance = %q, want framework", node.P)
326
+ }
327
+ for _, field := range []string{"name", "actions", "labelledBy", "describedBy"} {
328
+ if node.PX[field] != protocol.ProvenanceAnnotation {
329
+ t.Fatalf("%s provenance = %q, want annotation (all px=%v)", field, node.PX[field], node.PX)
330
+ }
331
+ }
332
+ if node.Bounds == nil || node.Role != protocol.RoleButton || node.PX["role"] != protocol.ProvenanceRecognizer {
333
+ t.Fatalf("annotation replaced framework/recognizer facts: %+v", node)
334
+ }
335
+ }
336
+
337
+ func TestDuplicateSemanticKeysCannotBecomeAmbiguousRelations(t *testing.T) {
338
+ annotate.Reset()
339
+ t.Cleanup(annotate.Reset)
340
+
341
+ control := NewButton("Save")
342
+ first := NewTextView().SetText("First")
343
+ second := NewTextView().SetText("Second")
344
+ annotate.Tag(control, annotate.Semantics{LabelledBy: []annotate.SemanticKey{"duplicate"}})
345
+ annotate.Tag(first, annotate.Semantics{Key: "duplicate"})
346
+ annotate.Tag(second, annotate.Semantics{Key: "duplicate"})
347
+
348
+ root := NewFlex().
349
+ AddItem(control, 1, 0, false).
350
+ AddItem(first, 1, 0, false).
351
+ AddItem(second, 1, 0, false)
352
+ app := NewApplication()
353
+ app.root = root
354
+ root.SetRect(0, 0, 30, 1)
355
+ probe := &termwrightProbeState{ids: make(map[Primitive]string)}
356
+ snapshot := probe.snapshot(app, 30, 1, false)
357
+ controlID := probe.identity(control)
358
+ for _, node := range snapshot.Nodes {
359
+ if node.ID == controlID && len(node.LabelledBy) != 0 {
360
+ t.Fatalf("duplicate key resolved arbitrarily to %v", node.LabelledBy)
361
+ }
362
+ }
363
+ }
364
+
365
+ func TestQualifiedSnapshotReportsOnlyObservableTviewGeometry(t *testing.T) {
366
+ root := NewFlex()
367
+ button := NewButton("Approve")
368
+ hidden := NewButton("Hidden")
369
+ root.AddItem(button, 1, 0, false).AddItem(hidden, 1, 0, false)
370
+ root.SetRect(75, 23, 10, 2)
371
+ button.SetRect(75, 23, 10, 1)
372
+ hidden.SetRect(75, 24, 10, 1)
373
+
374
+ app := NewApplication()
375
+ app.root = root
376
+ probe := &termwrightProbeState{ids: make(map[Primitive]string)}
377
+ snapshot := probe.snapshot(app, 80, 24, true)
378
+
379
+ if snapshot.V != 2 || snapshot.CoordinateSpace == nil || snapshot.HitGrid == nil || snapshot.HitGrid.Status != "unsupported" {
380
+ t.Fatalf("snapshot is not honestly qualified: %+v", snapshot)
381
+ }
382
+ for index, node := range snapshot.Nodes {
383
+ if node.Geometry == nil {
384
+ t.Fatalf("node %d has no qualified geometry: %+v", index, node)
385
+ }
386
+ if node.Bounds != nil || node.Occlusion != "" {
387
+ t.Fatalf("v2 node retained legacy geometry fields: %+v", node)
388
+ }
389
+ }
390
+ geometry := snapshot.Nodes[1].Geometry
391
+ if geometry.Displayed.Status != "known" || geometry.Displayed.Value == nil || !*geometry.Displayed.Value {
392
+ t.Fatalf("displayed observation = %+v", geometry.Displayed)
393
+ }
394
+ if geometry.IntendedRect.Value == nil || *geometry.IntendedRect.Value != (protocol.Rect{Row: 23, Column: 75, Width: 10, Height: 1}) {
395
+ t.Fatalf("intended rect = %+v", geometry.IntendedRect)
396
+ }
397
+ if geometry.VisibleRect.Value == nil || *geometry.VisibleRect.Value != (protocol.Rect{Row: 23, Column: 75, Width: 5, Height: 1}) {
398
+ t.Fatalf("visible rect = %+v", geometry.VisibleRect)
399
+ }
400
+ }
401
+
402
+ func countMarkers(text string) int {
403
+ count := 0
404
+ const prefix = "\x1b]8487;twm;"
405
+ for index := 0; index+len(prefix) <= len(text); index++ {
406
+ if text[index:index+len(prefix)] == prefix {
407
+ count++
408
+ index += len(prefix) - 1
409
+ }
410
+ }
411
+ return count
412
+ }
413
+
414
+ // shortDir keeps a unix socket path under the platform limit, which the
415
+ // default temporary directory on macOS routinely exceeds.
416
+ func shortDir(t *testing.T) string {
417
+ t.Helper()
418
+ base := "/tmp"
419
+ if runtime.GOOS == "windows" {
420
+ base = ""
421
+ }
422
+ dir, err := os.MkdirTemp(base, "tw")
423
+ if err != nil {
424
+ t.Fatal(err)
425
+ }
426
+ t.Cleanup(func() { _ = os.RemoveAll(dir) })
427
+ return dir
428
+ }
@@ -0,0 +1,42 @@
1
+ {
2
+ "framework": "github.com/rivo/tview",
3
+ "frameworkVersion": "v0.42.0",
4
+ "patchSetVersion": 11,
5
+ "note": "Anchored after screen.Show() in draw(); see docs/architecture/audit/tview.md §1. go.mod gains the protocol client, supplied from disk by the generated workspace.",
6
+ "requires": [
7
+ {
8
+ "module": "github.com/gorce-ai/termwright/clients/go",
9
+ "suppliedBy": "workspace"
10
+ },
11
+ {
12
+ "module": "github.com/gorce-ai/termwright/clients/go/annotate",
13
+ "suppliedBy": "workspace"
14
+ }
15
+ ],
16
+ "patched": [
17
+ {
18
+ "path": "application.go",
19
+ "patch": "patches/application.go.patch",
20
+ "sha256Before": "sha256:acf51c64021e45132cd28848f5559b51567df26f1c823cc9249d55581bcd7e39",
21
+ "sha256After": "sha256:f79553c643934bcef1038d19be0473ccd6edbe1a24b2935bffbec4de562538cd"
22
+ },
23
+ {
24
+ "path": "go.mod",
25
+ "patch": "patches/go.mod.patch",
26
+ "sha256Before": "sha256:a2f3b131206042b58863c3c04474e7a9dd865c74c54f41d4179b77a34bafd9d9",
27
+ "sha256After": "sha256:068951344e45181175b58e0ccd002ef7222ca3dd8bf21017df601879d4a1966a"
28
+ }
29
+ ],
30
+ "added": [
31
+ {
32
+ "path": "termwright_probe.go",
33
+ "source": "add/termwright_probe.go",
34
+ "sha256": "sha256:97566d3783f9996c07c087f0e243557f0266a50aaa89e9c2f676abe72c1f9185"
35
+ },
36
+ {
37
+ "path": "termwright_probe_test.go",
38
+ "source": "add/termwright_probe_test.go",
39
+ "sha256": "sha256:1c97fba8f84ad9b646219df06af80d76dc74cf30c408364f20cf2ecf3ea07aec"
40
+ }
41
+ ]
42
+ }
@@ -0,0 +1,14 @@
1
+ --- a/application.go
2
+ +++ b/application.go
3
+ @@ -743,6 +743,11 @@
4
+ // Sync screen.
5
+ screen.Show()
6
+
7
+ + // Injected by termwright: the frame's bytes have been flushed, so this is
8
+ + // where an observation may be taken and a render-commit marker written.
9
+ + // Still under a.Lock() — see termwright_probe.go for what that forbids.
10
+ + termwrightAfterFrame(a, screen)
11
+ +
12
+ return a
13
+ }
14
+
@@ -0,0 +1,15 @@
1
+ --- a/go.mod
2
+ +++ b/go.mod
3
+ @@ -8,6 +8,12 @@
4
+ github.com/rivo/uniseg v0.4.7
5
+ )
6
+
7
+ +// Added by termwright: the injected probe speaks the protocol through the
8
+ +// published client rather than reimplementing framing and markers. The
9
+ +// generated workspace supplies this module from disk, so no network fetch and
10
+ +// no go.sum entry are involved.
11
+ +require github.com/gorce-ai/termwright/clients/go v0.0.0
12
+ +
13
+ require (
14
+ github.com/gdamore/encoding v1.0.1 // indirect
15
+ github.com/mattn/go-runewidth v0.0.16 // indirect