detached bb1a57d

test(agent): stabilize cross-platform CI

commit
bb1a57d03ce96a4c66e4e5a65d61b27fcf14eaac
author
brice · · 2026-08-24 10:59 UTC
parent
6f4c43c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +57 -31
M packages/agent/build_orchestrator_test.go +4 -2
@@ -13,13 +13,15 @@ import (
1313 func TestResolveSeparatesInteractiveProactiveAndHeadlessStrictPolicies(t *testing.T) {
1414 root := t.TempDir()
1515 zutHome := filepath.Join(root, "zut-home")
16+ home := filepath.Join(root, "home")
1617 project := filepath.Join(root, "project")
1718 t.Setenv("ZUT_HOME", zutHome)
18- t.Setenv("HOME", filepath.Join(root, "home"))
19+ t.Setenv("HOME", home)
20+ t.Setenv("USERPROFILE", home)
1921 if err := os.MkdirAll(project, 0o755); err != nil {
2022 t.Fatal(err)
2123 }
22- profilesDir := filepath.Join(root, "home", ".agents", "agents")
24+ profilesDir := filepath.Join(home, ".agents", "agents")
2325 if err := os.MkdirAll(profilesDir, 0o755); err != nil {
2426 t.Fatal(err)
2527 }
@@ -13,13 +13,15 @@ import (
13 func TestResolveSeparatesInteractiveProactiveAndHeadlessStrictPolicies(t *testing.T) {13 func TestResolveSeparatesInteractiveProactiveAndHeadlessStrictPolicies(t *testing.T) {
14 root := t.TempDir()14 root := t.TempDir()
15 zutHome := filepath.Join(root, "zut-home")15 zutHome := filepath.Join(root, "zut-home")
16+ home := filepath.Join(root, "home")
16 project := filepath.Join(root, "project")17 project := filepath.Join(root, "project")
17 t.Setenv("ZUT_HOME", zutHome)18 t.Setenv("ZUT_HOME", zutHome)
18- t.Setenv("HOME", filepath.Join(root, "home"))19+ t.Setenv("HOME", home)
20+ t.Setenv("USERPROFILE", home)
19 if err := os.MkdirAll(project, 0o755); err != nil {21 if err := os.MkdirAll(project, 0o755); err != nil {
20 t.Fatal(err)22 t.Fatal(err)
21 }23 }
22- profilesDir := filepath.Join(root, "home", ".agents", "agents")24+ profilesDir := filepath.Join(home, ".agents", "agents")
23 if err := os.MkdirAll(profilesDir, 0o755); err != nil {25 if err := os.MkdirAll(profilesDir, 0o755); err != nil {
24 t.Fatal(err)26 t.Fatal(err)
25 }27 }
M packages/agent/modes/theme_watch.go +32 -20
@@ -51,12 +51,12 @@ func watchThemeSource(ctx context.Context, home, preference string, accepted *tu
5151 }
5252 ticker := time.NewTicker(interval)
5353 defer ticker.Stop()
54- watchThemeSourceTicks(ctx, home, preference, accepted, ticker.C, events)
54+ watchThemeSourceTicks(ctx, home, preference, accepted, ticker.C, events, nil)
5555 }
5656
5757 // watchThemeSourceTicks is the test seam for polling: tests advance explicit
58-// timestamps rather than synchronizing with sleeps.
59-func watchThemeSourceTicks(ctx context.Context, home, preference string, accepted *tui.ThemeSource, ticks <-chan time.Time, events chan<- themeWatchEvent) {
58+// timestamps and may receive a processed acknowledgement instead of sleeping.
59+func watchThemeSourceTicks(ctx context.Context, home, preference string, accepted *tui.ThemeSource, ticks <-chan time.Time, events chan<- themeWatchEvent, processed chan<- struct{}) {
6060 if accepted == nil || accepted.Path == "" {
6161 return
6262 }
@@ -68,37 +68,49 @@ func watchThemeSourceTicks(ctx context.Context, home, preference string, accepte
6868 case <-ctx.Done():
6969 return
7070 case now := <-ticks:
71+ stop := false
7172 if _, statErr := os.Stat(accepted.Path); errors.Is(statErr, os.ErrNotExist) {
7273 if missingSince.IsZero() {
7374 missingSince = now
7475 }
7576 if now.Sub(missingSince) >= themeMissingGrace {
7677 sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, deleted: true})
77- return
78+ stop = true
7879 }
79- continue
80- }
81- // A present-but-invalid revision remains watchable and must restart
82- // the deletion grace period just like a valid replacement does.
83- missingSince = time.Time{}
84- source, err := tui.LoadThemeSource(home, preference)
85- if err == nil && source != nil {
86- lastError = ""
87- if source.Path != accepted.Path || source.Digest == lastDigest {
88- continue
80+ } else {
81+ // A present-but-invalid revision remains watchable and must restart
82+ // the deletion grace period just like a valid replacement does.
83+ missingSince = time.Time{}
84+ source, err := tui.LoadThemeSource(home, preference)
85+ if err == nil && source != nil {
86+ lastError = ""
87+ if source.Path == accepted.Path && source.Digest != lastDigest {
88+ lastDigest = source.Digest
89+ sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, source: source})
90+ }
91+ } else if err != nil && err.Error() != lastError {
92+ lastError = err.Error()
93+ sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, err: err})
8994 }
90- lastDigest = source.Digest
91- sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, source: source})
92- continue
9395 }
94- if err != nil && err.Error() != lastError {
95- lastError = err.Error()
96- sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, err: err})
96+ acknowledgeThemeWatchTick(ctx, processed)
97+ if stop {
98+ return
9799 }
98100 }
99101 }
100102 }
101103
104+func acknowledgeThemeWatchTick(ctx context.Context, processed chan<- struct{}) {
105+ if processed == nil {
106+ return
107+ }
108+ select {
109+ case processed <- struct{}{}:
110+ case <-ctx.Done():
111+ }
112+}
113+
102114 func sendThemeWatchEvent(ctx context.Context, events chan<- themeWatchEvent, event themeWatchEvent) {
103115 select {
104116 case events <- event:
@@ -51,12 +51,12 @@ func watchThemeSource(ctx context.Context, home, preference string, accepted *tu
51 }51 }
52 ticker := time.NewTicker(interval)52 ticker := time.NewTicker(interval)
53 defer ticker.Stop()53 defer ticker.Stop()
54- watchThemeSourceTicks(ctx, home, preference, accepted, ticker.C, events)54+ watchThemeSourceTicks(ctx, home, preference, accepted, ticker.C, events, nil)
55 }55 }
56 56
57 // watchThemeSourceTicks is the test seam for polling: tests advance explicit57 // watchThemeSourceTicks is the test seam for polling: tests advance explicit
58-// timestamps rather than synchronizing with sleeps.58+// timestamps and may receive a processed acknowledgement instead of sleeping.
59-func watchThemeSourceTicks(ctx context.Context, home, preference string, accepted *tui.ThemeSource, ticks <-chan time.Time, events chan<- themeWatchEvent) {59+func watchThemeSourceTicks(ctx context.Context, home, preference string, accepted *tui.ThemeSource, ticks <-chan time.Time, events chan<- themeWatchEvent, processed chan<- struct{}) {
60 if accepted == nil || accepted.Path == "" {60 if accepted == nil || accepted.Path == "" {
61 return61 return
62 }62 }
@@ -68,37 +68,49 @@ func watchThemeSourceTicks(ctx context.Context, home, preference string, accepte
68 case <-ctx.Done():68 case <-ctx.Done():
69 return69 return
70 case now := <-ticks:70 case now := <-ticks:
71+ stop := false
71 if _, statErr := os.Stat(accepted.Path); errors.Is(statErr, os.ErrNotExist) {72 if _, statErr := os.Stat(accepted.Path); errors.Is(statErr, os.ErrNotExist) {
72 if missingSince.IsZero() {73 if missingSince.IsZero() {
73 missingSince = now74 missingSince = now
74 }75 }
75 if now.Sub(missingSince) >= themeMissingGrace {76 if now.Sub(missingSince) >= themeMissingGrace {
76 sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, deleted: true})77 sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, deleted: true})
77- return78+ stop = true
78 }79 }
79- continue80+ } else {
80- }81+ // A present-but-invalid revision remains watchable and must restart
81- // A present-but-invalid revision remains watchable and must restart82+ // the deletion grace period just like a valid replacement does.
82- // the deletion grace period just like a valid replacement does.83+ missingSince = time.Time{}
83- missingSince = time.Time{}84+ source, err := tui.LoadThemeSource(home, preference)
84- source, err := tui.LoadThemeSource(home, preference)85+ if err == nil && source != nil {
85- if err == nil && source != nil {86+ lastError = ""
86- lastError = ""87+ if source.Path == accepted.Path && source.Digest != lastDigest {
87- if source.Path != accepted.Path || source.Digest == lastDigest {88+ lastDigest = source.Digest
88- continue89+ sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, source: source})
90+ }
91+ } else if err != nil && err.Error() != lastError {
92+ lastError = err.Error()
93+ sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, err: err})
89 }94 }
90- lastDigest = source.Digest
91- sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, source: source})
92- continue
93 }95 }
94- if err != nil && err.Error() != lastError {96+ acknowledgeThemeWatchTick(ctx, processed)
95- lastError = err.Error()97+ if stop {
96- sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, err: err})98+ return
97 }99 }
98 }100 }
99 }101 }
100 }102 }
101 103
104+func acknowledgeThemeWatchTick(ctx context.Context, processed chan<- struct{}) {
105+ if processed == nil {
106+ return
107+ }
108+ select {
109+ case processed <- struct{}{}:
110+ case <-ctx.Done():
111+ }
112+}
113+
102 func sendThemeWatchEvent(ctx context.Context, events chan<- themeWatchEvent, event themeWatchEvent) {114 func sendThemeWatchEvent(ctx context.Context, events chan<- themeWatchEvent, event themeWatchEvent) {
103 select {115 select {
104 case events <- event:116 case events <- event:
M packages/agent/modes/theme_watch_test.go +21 -9
@@ -22,6 +22,16 @@ func writeWatchedTheme(t *testing.T, home, body string) string {
2222 return path
2323 }
2424
25+func advanceThemeWatch(t *testing.T, ticks chan<- time.Time, processed <-chan struct{}, now time.Time) {
26+ t.Helper()
27+ ticks <- now
28+ select {
29+ case <-processed:
30+ case <-time.After(time.Second):
31+ t.Fatal("watcher did not process tick")
32+ }
33+}
34+
2535 func TestWatchThemeSourceTicksPublishesOnlyValidRevisions(t *testing.T) {
2636 home := t.TempDir()
2737 path := writeWatchedTheme(t, home, `{"colors":{"accent":20}}`)
@@ -32,13 +42,14 @@ func TestWatchThemeSourceTicksPublishesOnlyValidRevisions(t *testing.T) {
3242 ctx, cancel := context.WithCancel(context.Background())
3343 defer cancel()
3444 ticks := make(chan time.Time)
45+ processed := make(chan struct{})
3546 events := make(chan themeWatchEvent, 3)
36- go watchThemeSourceTicks(ctx, home, "live", accepted, ticks, events)
47+ go watchThemeSourceTicks(ctx, home, "live", accepted, ticks, events, processed)
3748
3849 if err := os.WriteFile(path, []byte(`{"colors":{"accent":21}}`), 0o644); err != nil {
3950 t.Fatal(err)
4051 }
41- ticks <- time.Unix(1, 0)
52+ advanceThemeWatch(t, ticks, processed, time.Unix(1, 0))
4253 select {
4354 case event := <-events:
4455 if event.source == nil || event.source.File.Colors.Base.Accent == nil || event.source.File.Colors.Base.Accent.Index != 21 {
@@ -51,7 +62,7 @@ func TestWatchThemeSourceTicksPublishesOnlyValidRevisions(t *testing.T) {
5162 if err := os.WriteFile(path, []byte(`{"colors":{"accent":999}}`), 0o644); err != nil {
5263 t.Fatal(err)
5364 }
54- ticks <- time.Unix(2, 0)
65+ advanceThemeWatch(t, ticks, processed, time.Unix(2, 0))
5566 select {
5667 case event := <-events:
5768 if event.err == nil || event.source != nil {
@@ -72,17 +83,18 @@ func TestWatchThemeSourceTicksRestartsDeletionGraceAfterInvalidReappearance(t *t
7283 ctx, cancel := context.WithCancel(context.Background())
7384 defer cancel()
7485 ticks := make(chan time.Time)
86+ processed := make(chan struct{})
7587 events := make(chan themeWatchEvent, 1)
76- go watchThemeSourceTicks(ctx, home, "live", accepted, ticks, events)
88+ go watchThemeSourceTicks(ctx, home, "live", accepted, ticks, events, processed)
7789
7890 if err := os.Remove(path); err != nil {
7991 t.Fatal(err)
8092 }
81- ticks <- time.Unix(1, 0)
93+ advanceThemeWatch(t, ticks, processed, time.Unix(1, 0))
8294 if err := os.WriteFile(path, []byte(`{"colors":{"accent":999}}`), 0o644); err != nil {
8395 t.Fatal(err)
8496 }
85- ticks <- time.Unix(2, 0)
97+ advanceThemeWatch(t, ticks, processed, time.Unix(2, 0))
8698 select {
8799 case event := <-events:
88100 if event.err == nil {
@@ -95,14 +107,14 @@ func TestWatchThemeSourceTicksRestartsDeletionGraceAfterInvalidReappearance(t *t
95107 if err := os.Remove(path); err != nil {
96108 t.Fatal(err)
97109 }
98- ticks <- time.Unix(3, 0)
99- ticks <- time.Unix(4, 0)
110+ advanceThemeWatch(t, ticks, processed, time.Unix(3, 0))
111+ advanceThemeWatch(t, ticks, processed, time.Unix(4, 0))
100112 select {
101113 case event := <-events:
102114 t.Fatalf("deletion happened before restarted grace period: %#v", event)
103115 default:
104116 }
105- ticks <- time.Unix(6, 0)
117+ advanceThemeWatch(t, ticks, processed, time.Unix(6, 0))
106118 select {
107119 case event := <-events:
108120 if !event.deleted {
@@ -22,6 +22,16 @@ func writeWatchedTheme(t *testing.T, home, body string) string {
22 return path22 return path
23 }23 }
24 24
25+func advanceThemeWatch(t *testing.T, ticks chan<- time.Time, processed <-chan struct{}, now time.Time) {
26+ t.Helper()
27+ ticks <- now
28+ select {
29+ case <-processed:
30+ case <-time.After(time.Second):
31+ t.Fatal("watcher did not process tick")
32+ }
33+}
34+
25 func TestWatchThemeSourceTicksPublishesOnlyValidRevisions(t *testing.T) {35 func TestWatchThemeSourceTicksPublishesOnlyValidRevisions(t *testing.T) {
26 home := t.TempDir()36 home := t.TempDir()
27 path := writeWatchedTheme(t, home, `{"colors":{"accent":20}}`)37 path := writeWatchedTheme(t, home, `{"colors":{"accent":20}}`)
@@ -32,13 +42,14 @@ func TestWatchThemeSourceTicksPublishesOnlyValidRevisions(t *testing.T) {
32 ctx, cancel := context.WithCancel(context.Background())42 ctx, cancel := context.WithCancel(context.Background())
33 defer cancel()43 defer cancel()
34 ticks := make(chan time.Time)44 ticks := make(chan time.Time)
45+ processed := make(chan struct{})
35 events := make(chan themeWatchEvent, 3)46 events := make(chan themeWatchEvent, 3)
36- go watchThemeSourceTicks(ctx, home, "live", accepted, ticks, events)47+ go watchThemeSourceTicks(ctx, home, "live", accepted, ticks, events, processed)
37 48
38 if err := os.WriteFile(path, []byte(`{"colors":{"accent":21}}`), 0o644); err != nil {49 if err := os.WriteFile(path, []byte(`{"colors":{"accent":21}}`), 0o644); err != nil {
39 t.Fatal(err)50 t.Fatal(err)
40 }51 }
41- ticks <- time.Unix(1, 0)52+ advanceThemeWatch(t, ticks, processed, time.Unix(1, 0))
42 select {53 select {
43 case event := <-events:54 case event := <-events:
44 if event.source == nil || event.source.File.Colors.Base.Accent == nil || event.source.File.Colors.Base.Accent.Index != 21 {55 if event.source == nil || event.source.File.Colors.Base.Accent == nil || event.source.File.Colors.Base.Accent.Index != 21 {
@@ -51,7 +62,7 @@ func TestWatchThemeSourceTicksPublishesOnlyValidRevisions(t *testing.T) {
51 if err := os.WriteFile(path, []byte(`{"colors":{"accent":999}}`), 0o644); err != nil {62 if err := os.WriteFile(path, []byte(`{"colors":{"accent":999}}`), 0o644); err != nil {
52 t.Fatal(err)63 t.Fatal(err)
53 }64 }
54- ticks <- time.Unix(2, 0)65+ advanceThemeWatch(t, ticks, processed, time.Unix(2, 0))
55 select {66 select {
56 case event := <-events:67 case event := <-events:
57 if event.err == nil || event.source != nil {68 if event.err == nil || event.source != nil {
@@ -72,17 +83,18 @@ func TestWatchThemeSourceTicksRestartsDeletionGraceAfterInvalidReappearance(t *t
72 ctx, cancel := context.WithCancel(context.Background())83 ctx, cancel := context.WithCancel(context.Background())
73 defer cancel()84 defer cancel()
74 ticks := make(chan time.Time)85 ticks := make(chan time.Time)
86+ processed := make(chan struct{})
75 events := make(chan themeWatchEvent, 1)87 events := make(chan themeWatchEvent, 1)
76- go watchThemeSourceTicks(ctx, home, "live", accepted, ticks, events)88+ go watchThemeSourceTicks(ctx, home, "live", accepted, ticks, events, processed)
77 89
78 if err := os.Remove(path); err != nil {90 if err := os.Remove(path); err != nil {
79 t.Fatal(err)91 t.Fatal(err)
80 }92 }
81- ticks <- time.Unix(1, 0)93+ advanceThemeWatch(t, ticks, processed, time.Unix(1, 0))
82 if err := os.WriteFile(path, []byte(`{"colors":{"accent":999}}`), 0o644); err != nil {94 if err := os.WriteFile(path, []byte(`{"colors":{"accent":999}}`), 0o644); err != nil {
83 t.Fatal(err)95 t.Fatal(err)
84 }96 }
85- ticks <- time.Unix(2, 0)97+ advanceThemeWatch(t, ticks, processed, time.Unix(2, 0))
86 select {98 select {
87 case event := <-events:99 case event := <-events:
88 if event.err == nil {100 if event.err == nil {
@@ -95,14 +107,14 @@ func TestWatchThemeSourceTicksRestartsDeletionGraceAfterInvalidReappearance(t *t
95 if err := os.Remove(path); err != nil {107 if err := os.Remove(path); err != nil {
96 t.Fatal(err)108 t.Fatal(err)
97 }109 }
98- ticks <- time.Unix(3, 0)110+ advanceThemeWatch(t, ticks, processed, time.Unix(3, 0))
99- ticks <- time.Unix(4, 0)111+ advanceThemeWatch(t, ticks, processed, time.Unix(4, 0))
100 select {112 select {
101 case event := <-events:113 case event := <-events:
102 t.Fatalf("deletion happened before restarted grace period: %#v", event)114 t.Fatalf("deletion happened before restarted grace period: %#v", event)
103 default:115 default:
104 }116 }
105- ticks <- time.Unix(6, 0)117+ advanceThemeWatch(t, ticks, processed, time.Unix(6, 0))
106 select {118 select {
107 case event := <-events:119 case event := <-events:
108 if !event.deleted {120 if !event.deleted {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close