relay-flow 0.2.8-alpha → 0.2.9-alpha

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.
Files changed (36) hide show
  1. package/README.md +62 -35
  2. package/cmd/relay-flow/commands_test.go +117 -8
  3. package/cmd/relay-flow/main.go +167 -60
  4. package/cmd/relay-flow/onboarding.go +344 -0
  5. package/cmd/relay-flow/onboarding_test.go +515 -0
  6. package/cmd/relay-flow/repo_registration.go +219 -0
  7. package/cmd/relay-flow/serve.go +3 -0
  8. package/go.mod +12 -8
  9. package/go.sum +24 -17
  10. package/internal/execution/goworkflows/engine_test.go +3 -3
  11. package/internal/harness/opencode/opencode_test.go +4 -4
  12. package/internal/harness/opencode/repo_setup.go +1 -1
  13. package/internal/harness/pi/prompt_test.go +3 -3
  14. package/internal/repo/service.go +34 -3
  15. package/internal/repo/service_test.go +22 -0
  16. package/internal/runner/herdr/herdr.go +103 -4
  17. package/internal/runner/herdr/herdr_test.go +72 -2
  18. package/internal/runner/herdr/herdrcli/contract.go +12 -3
  19. package/internal/runner/herdr/herdrcli/herdrcli_test.go +17 -2
  20. package/internal/runner/herdr/herdrcli/operations.go +16 -0
  21. package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +8 -0
  22. package/internal/runner/herdr/herdrcli/testdata/workspace-create.json +1 -0
  23. package/internal/runner/orca/orca.go +68 -9
  24. package/internal/runner/orca/orca_test.go +134 -2
  25. package/internal/runner/orca/orcacli/orcacli.go +21 -1
  26. package/internal/runner/orca/orcacli/orcacli_test.go +22 -0
  27. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +5 -0
  28. package/internal/runner/runner.go +9 -0
  29. package/internal/server/api_test.go +19 -0
  30. package/internal/server/client.go +10 -0
  31. package/internal/server/fixture_test.go +7 -0
  32. package/internal/server/server.go +41 -0
  33. package/internal/task/jira/testdata/jira_search_issues.json +4 -4
  34. package/internal/workflow/workflow.go +4 -1
  35. package/internal/workflow/workflow_test.go +4 -4
  36. package/package.json +1 -1
@@ -0,0 +1,515 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "io"
7
+ "net"
8
+ "net/http"
9
+ "os"
10
+ "path/filepath"
11
+ "strings"
12
+ "sync"
13
+ "syscall"
14
+ "testing"
15
+ "time"
16
+
17
+ "github.com/rajpopat27/relay-flow/internal/config"
18
+ "github.com/rajpopat27/relay-flow/internal/execution/projection"
19
+ "github.com/rajpopat27/relay-flow/internal/paths"
20
+ "github.com/rajpopat27/relay-flow/internal/server"
21
+ "github.com/rajpopat27/relay-flow/internal/task"
22
+ )
23
+
24
+ func onboardingPaths(root string) paths.Paths {
25
+ return paths.Paths{
26
+ Root: root, Config: filepath.Join(root, "config.yaml"),
27
+ Credentials: filepath.Join(root, "credentials.yaml"),
28
+ Database: filepath.Join(root, "state.db"),
29
+ Socket: filepath.Join(root, "server.sock"),
30
+ Lock: filepath.Join(root, "server.lock"),
31
+ }
32
+ }
33
+
34
+ func setInteractiveInitTestSeams(t *testing.T, picker func() ([]string, error), prompt func() (bool, error), setup func(paths.Paths, io.Reader) error) {
35
+ t.Helper()
36
+ oldTTY := interactiveInitTTY
37
+ oldPicker := interactiveInitPluginPick
38
+ oldPrompt := interactiveInitRepoPrompt
39
+ oldSetup := interactiveInitRepoSetup
40
+ interactiveInitTTY = func(io.Reader) bool { return true }
41
+ interactiveInitPluginPick = picker
42
+ interactiveInitRepoPrompt = prompt
43
+ interactiveInitRepoSetup = setup
44
+ t.Cleanup(func() {
45
+ interactiveInitTTY = oldTTY
46
+ interactiveInitPluginPick = oldPicker
47
+ interactiveInitRepoPrompt = oldPrompt
48
+ interactiveInitRepoSetup = oldSetup
49
+ })
50
+ }
51
+
52
+ func TestInteractiveFirstRunSkipAndRegistration(t *testing.T) {
53
+ registerGuidedAuthPlugin()
54
+ for _, tc := range []struct {
55
+ name string
56
+ register bool
57
+ }{
58
+ {name: "skip", register: false},
59
+ {name: "register", register: true},
60
+ } {
61
+ t.Run(tc.name, func(t *testing.T) {
62
+ guidedFirstRunAuthCalls = 0
63
+ home := t.TempDir()
64
+ root := filepath.Join(home, ".relay-flow")
65
+ var setupCalls int
66
+ setInteractiveInitTestSeams(t,
67
+ func() ([]string, error) {
68
+ return []string{guidedFirstRunAuthPlugin, "orca", "opencode", executorGoworkflows}, nil
69
+ },
70
+ func() (bool, error) { return tc.register, nil },
71
+ func(got paths.Paths, _ io.Reader) error {
72
+ setupCalls++
73
+ if got.Config != filepath.Join(root, "config.yaml") {
74
+ t.Fatalf("setup config path = %q", got.Config)
75
+ }
76
+ return nil
77
+ },
78
+ )
79
+ t.Setenv("RELAY_FLOW_HOME", root)
80
+ if code := run([]string{"init"}, strings.NewReader("")); code != exitOK {
81
+ t.Fatalf("interactive init exit = %d", code)
82
+ }
83
+ if guidedFirstRunAuthCalls != 1 {
84
+ t.Fatalf("auth calls = %d, want 1", guidedFirstRunAuthCalls)
85
+ }
86
+ if got := setupCalls; got != map[bool]int{false: 0, true: 1}[tc.register] {
87
+ t.Fatalf("repository setup calls = %d, want %d", got, map[bool]int{false: 0, true: 1}[tc.register])
88
+ }
89
+ for _, path := range []string{filepath.Join(root, "config.yaml"), filepath.Join(root, "state.db")} {
90
+ if _, err := os.Stat(path); err != nil {
91
+ t.Fatalf("missing initialized artifact %s: %v", path, err)
92
+ }
93
+ }
94
+ if _, err := os.Stat(filepath.Join(root, "credentials.yaml")); !os.IsNotExist(err) {
95
+ t.Fatalf("unexpected credentials file: %v", err)
96
+ }
97
+ })
98
+ }
99
+ }
100
+
101
+ func TestInteractiveFirstRunAuthenticationCancellationCleansHome(t *testing.T) {
102
+ registerCanceledFirstRunAuth.Do(func() {
103
+ task.Register(canceledFirstRunAuthPlugin, task.Factory{
104
+ Auth: func(context.Context, []string, io.Reader) error {
105
+ return errCanceledFirstRunAuth
106
+ },
107
+ })
108
+ })
109
+ root := filepath.Join(t.TempDir(), ".relay-flow")
110
+ setInteractiveInitTestSeams(t,
111
+ func() ([]string, error) {
112
+ return []string{canceledFirstRunAuthPlugin, "orca", "opencode", executorGoworkflows}, nil
113
+ },
114
+ func() (bool, error) { return false, errors.New("repository prompt should not run") },
115
+ func(paths.Paths, io.Reader) error { return errors.New("repository setup should not run") },
116
+ )
117
+ t.Setenv("RELAY_FLOW_HOME", root)
118
+ if code := run([]string{"init"}, strings.NewReader("")); code != exitFail {
119
+ t.Fatalf("canceled interactive init exit = %d, want %d", code, exitFail)
120
+ }
121
+ for _, path := range []string{filepath.Join(root, "config.yaml"), filepath.Join(root, "credentials.yaml"), filepath.Join(root, "state.db")} {
122
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
123
+ t.Fatalf("canceled auth left %s: %v", path, err)
124
+ }
125
+ }
126
+ }
127
+
128
+ func TestNonInteractiveInitAndForceDoNotAuthenticateOrOnboard(t *testing.T) {
129
+ registerGuidedAuthPlugin()
130
+ guidedFirstRunAuthCalls = 0
131
+ root := filepath.Join(t.TempDir(), ".relay-flow")
132
+ setInteractiveInitTestSeams(t,
133
+ func() ([]string, error) { return nil, errors.New("plugin picker should not run") },
134
+ func() (bool, error) { return false, errors.New("repository prompt should not run") },
135
+ func(paths.Paths, io.Reader) error { return errors.New("repository setup should not run") },
136
+ )
137
+ t.Setenv("RELAY_FLOW_HOME", root)
138
+ args := []string{"init", "--task-plugin", guidedFirstRunAuthPlugin, "--runner-plugin", "orca", "--harness-plugin", "opencode"}
139
+ if code := run(args, strings.NewReader("")); code != exitOK {
140
+ t.Fatalf("non-interactive init exit = %d", code)
141
+ }
142
+ if code := run(append([]string{"init", "--force"}, args[1:]...), strings.NewReader("")); code != exitOK {
143
+ t.Fatalf("forced non-interactive init exit = %d", code)
144
+ }
145
+ if guidedFirstRunAuthCalls != 0 {
146
+ t.Fatalf("non-interactive auth calls = %d, want 0", guidedFirstRunAuthCalls)
147
+ }
148
+ }
149
+
150
+ func TestCommitFirstRunPublishesStagedState(t *testing.T) {
151
+ parent := t.TempDir()
152
+ p := onboardingPaths(filepath.Join(parent, ".relay-flow"))
153
+ stage := filepath.Join(parent, "stage")
154
+ if err := os.Mkdir(stage, 0o700); err != nil {
155
+ t.Fatal(err)
156
+ }
157
+ if err := config.SaveMachine(filepath.Join(stage, "config.yaml"), &config.Machine{
158
+ TaskPlugin: "beads", RunnerPlugin: "orca", HarnessPlugin: "opencode",
159
+ }); err != nil {
160
+ t.Fatal(err)
161
+ }
162
+ if err := os.WriteFile(filepath.Join(stage, "credentials.yaml"), []byte("plugin-owned\n"), 0o600); err != nil {
163
+ t.Fatal(err)
164
+ }
165
+
166
+ if err := commitFirstRun(p, stage, projection.ExecutorIdentity{ExecutorPlugin: executorGoworkflows}); err != nil {
167
+ t.Fatal(err)
168
+ }
169
+ for _, path := range []string{p.Config, p.Credentials, p.Database} {
170
+ if _, err := os.Stat(path); err != nil {
171
+ t.Fatalf("committed artifact %s: %v", path, err)
172
+ }
173
+ }
174
+ for _, path := range []string{p.Config, p.Credentials, p.Database} {
175
+ info, err := os.Stat(path)
176
+ if err != nil {
177
+ t.Fatal(err)
178
+ }
179
+ if info.Mode().Perm() != 0o600 {
180
+ t.Fatalf("%s mode = %o, want 600", path, info.Mode().Perm())
181
+ }
182
+ }
183
+ if _, err := os.Stat(firstRunPendingPath(p)); !os.IsNotExist(err) {
184
+ t.Fatalf("initialization marker remains: %v", err)
185
+ }
186
+ }
187
+
188
+ const (
189
+ canceledFirstRunAuthPlugin = "canceled-first-run-auth-test"
190
+ guidedFirstRunAuthPlugin = "guided-first-run-auth-test"
191
+ )
192
+
193
+ var (
194
+ registerCanceledFirstRunAuth sync.Once
195
+ registerGuidedFirstRunAuth sync.Once
196
+ errCanceledFirstRunAuth = errors.New("authentication canceled")
197
+ guidedFirstRunAuthCalls int
198
+ )
199
+
200
+ func registerGuidedAuthPlugin() {
201
+ registerGuidedFirstRunAuth.Do(func() {
202
+ task.Register(guidedFirstRunAuthPlugin, task.Factory{
203
+ Auth: func(context.Context, []string, io.Reader) error {
204
+ guidedFirstRunAuthCalls++
205
+ return nil
206
+ },
207
+ })
208
+ })
209
+ }
210
+
211
+ func TestRecoverFirstRunPublicationRemovesIncompleteArtifacts(t *testing.T) {
212
+ root := filepath.Join(t.TempDir(), ".relay-flow")
213
+ p := onboardingPaths(root)
214
+ if err := os.MkdirAll(root, 0o700); err != nil {
215
+ t.Fatal(err)
216
+ }
217
+ if err := os.WriteFile(p.Config, []byte("partial\n"), 0o600); err != nil {
218
+ t.Fatal(err)
219
+ }
220
+ if err := os.WriteFile(p.Database, []byte("partial\n"), 0o600); err != nil {
221
+ t.Fatal(err)
222
+ }
223
+ if err := config.WriteAtomic(firstRunPendingPath(p), []byte(`{"credentials":true}`), 0o600); err != nil {
224
+ t.Fatal(err)
225
+ }
226
+ if err := recoverFirstRunPublication(p); err != nil {
227
+ t.Fatal(err)
228
+ }
229
+ for _, path := range []string{p.Config, p.Credentials, p.Database, firstRunPendingPath(p)} {
230
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
231
+ t.Fatalf("incomplete publication left %s: %v", path, err)
232
+ }
233
+ }
234
+ }
235
+
236
+ func TestRecoverFirstRunPublicationKeepsCompleteArtifacts(t *testing.T) {
237
+ root := filepath.Join(t.TempDir(), ".relay-flow")
238
+ p := onboardingPaths(root)
239
+ if err := os.MkdirAll(root, 0o700); err != nil {
240
+ t.Fatal(err)
241
+ }
242
+ if err := config.SaveMachine(p.Config, &config.Machine{TaskPlugin: "beads", RunnerPlugin: "orca", HarnessPlugin: "opencode"}); err != nil {
243
+ t.Fatal(err)
244
+ }
245
+ if err := projection.InitDatabase(p.Database); err != nil {
246
+ t.Fatal(err)
247
+ }
248
+ if err := os.WriteFile(p.Credentials, []byte("plugin-owned\n"), 0o600); err != nil {
249
+ t.Fatal(err)
250
+ }
251
+ if err := config.WriteAtomic(firstRunPendingPath(p), []byte(`{"credentials":true}`), 0o600); err != nil {
252
+ t.Fatal(err)
253
+ }
254
+ if err := recoverFirstRunPublication(p); err != nil {
255
+ t.Fatal(err)
256
+ }
257
+ for _, path := range []string{p.Config, p.Credentials, p.Database} {
258
+ if _, err := os.Stat(path); err != nil {
259
+ t.Fatalf("complete publication lost %s: %v", path, err)
260
+ }
261
+ }
262
+ if _, err := os.Stat(firstRunPendingPath(p)); !os.IsNotExist(err) {
263
+ t.Fatalf("completed publication marker remains: %v", err)
264
+ }
265
+ }
266
+
267
+ func TestCleanupFailureLeavesFirstRunMarkerForRecovery(t *testing.T) {
268
+ root := filepath.Join(t.TempDir(), ".relay-flow")
269
+ p := onboardingPaths(root)
270
+ if err := os.MkdirAll(root, 0o700); err != nil {
271
+ t.Fatal(err)
272
+ }
273
+ if err := os.WriteFile(p.Config, []byte("partial\n"), 0o600); err != nil {
274
+ t.Fatal(err)
275
+ }
276
+ if err := os.WriteFile(p.Database, []byte("partial\n"), 0o600); err != nil {
277
+ t.Fatal(err)
278
+ }
279
+ if err := os.Mkdir(p.Credentials, 0o700); err != nil {
280
+ t.Fatal(err)
281
+ }
282
+ if err := os.WriteFile(filepath.Join(p.Credentials, "held"), []byte("secret\n"), 0o600); err != nil {
283
+ t.Fatal(err)
284
+ }
285
+ if err := config.WriteAtomic(firstRunPendingPath(p), []byte(`{"credentials":true}`), 0o600); err != nil {
286
+ t.Fatal(err)
287
+ }
288
+
289
+ if err := removeFirstRunArtifacts(p); err == nil {
290
+ t.Fatal("cleanup unexpectedly removed a non-empty credential directory")
291
+ }
292
+ if _, err := os.Stat(firstRunPendingPath(p)); err != nil {
293
+ t.Fatalf("cleanup failure removed recovery marker: %v", err)
294
+ }
295
+
296
+ if err := os.Remove(filepath.Join(p.Credentials, "held")); err != nil {
297
+ t.Fatal(err)
298
+ }
299
+ if err := os.Remove(p.Credentials); err != nil {
300
+ t.Fatal(err)
301
+ }
302
+ if err := recoverFirstRunPublication(p); err != nil {
303
+ t.Fatal(err)
304
+ }
305
+ for _, path := range []string{p.Config, p.Credentials, p.Database, firstRunPendingPath(p)} {
306
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
307
+ t.Fatalf("recovery left %s: %v", path, err)
308
+ }
309
+ }
310
+ }
311
+
312
+ func TestStageFirstRunAuthenticationLeavesFinalHomeUntouchedOnFailure(t *testing.T) {
313
+ registerCanceledFirstRunAuth.Do(func() {
314
+ task.Register(canceledFirstRunAuthPlugin, task.Factory{
315
+ Auth: func(context.Context, []string, io.Reader) error {
316
+ return errCanceledFirstRunAuth
317
+ },
318
+ })
319
+ })
320
+ root := filepath.Join(t.TempDir(), "nested", ".relay-flow")
321
+ p := onboardingPaths(root)
322
+ _, stage, err := stageFirstRunAuthentication(p, &config.Machine{TaskPlugin: canceledFirstRunAuthPlugin}, canceledFirstRunAuthPlugin, nil)
323
+ if !errors.Is(err, errCanceledFirstRunAuth) {
324
+ t.Fatalf("authentication error = %v, want cancellation", err)
325
+ }
326
+ if stage != "" {
327
+ t.Fatalf("failed authentication returned staging path %q", stage)
328
+ }
329
+ for _, path := range []string{p.Config, p.Credentials, p.Database} {
330
+ if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
331
+ t.Fatalf("failed authentication left %s: %v", path, statErr)
332
+ }
333
+ }
334
+ }
335
+
336
+ func TestEnsureFirstRunServerReusesReadySocket(t *testing.T) {
337
+ root := t.TempDir()
338
+ p := onboardingPaths(filepath.Join(root, ".relay-flow"))
339
+ if err := os.MkdirAll(p.Root, 0o700); err != nil {
340
+ t.Fatal(err)
341
+ }
342
+ listener, err := net.Listen("unix", p.Socket)
343
+ if err != nil {
344
+ t.Fatal(err)
345
+ }
346
+ server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
347
+ if r.URL.Path != "/repos" {
348
+ http.NotFound(w, r)
349
+ return
350
+ }
351
+ w.Header().Set("Content-Type", "application/json")
352
+ _, _ = io.WriteString(w, `{"ok":true,"data":[]}`)
353
+ })}
354
+ go func() { _ = server.Serve(listener) }()
355
+ defer server.Close()
356
+
357
+ if err := ensureFirstRunServer(p); err != nil {
358
+ t.Fatalf("reuse ready server: %v", err)
359
+ }
360
+ }
361
+
362
+ func TestEnsureFirstRunServerWaitsForExistingOwner(t *testing.T) {
363
+ root := t.TempDir()
364
+ p := onboardingPaths(filepath.Join(root, ".relay-flow"))
365
+ if err := os.MkdirAll(p.Root, 0o700); err != nil {
366
+ t.Fatal(err)
367
+ }
368
+ lock, err := os.OpenFile(p.Lock, os.O_CREATE|os.O_RDWR, 0o600)
369
+ if err != nil {
370
+ t.Fatal(err)
371
+ }
372
+ if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
373
+ lock.Close()
374
+ t.Fatal(err)
375
+ }
376
+ defer func() {
377
+ _ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
378
+ _ = lock.Close()
379
+ }()
380
+
381
+ oldTimeout, oldPoll := backgroundServeStartupTimeout, backgroundServePollInterval
382
+ backgroundServeStartupTimeout = 2 * time.Second
383
+ backgroundServePollInterval = 10 * time.Millisecond
384
+ defer func() {
385
+ backgroundServeStartupTimeout = oldTimeout
386
+ backgroundServePollInterval = oldPoll
387
+ }()
388
+
389
+ started := make(chan *http.Server, 1)
390
+ go func() {
391
+ time.Sleep(300 * time.Millisecond)
392
+ listener, listenErr := net.Listen("unix", p.Socket)
393
+ if listenErr != nil {
394
+ started <- nil
395
+ return
396
+ }
397
+ srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
398
+ w.Header().Set("Content-Type", "application/json")
399
+ _, _ = io.WriteString(w, `{"ok":true,"data":[]}`)
400
+ })}
401
+ started <- srv
402
+ _ = srv.Serve(listener)
403
+ }()
404
+ if err := ensureFirstRunServer(p); err != nil {
405
+ t.Fatalf("wait for existing owner: %v", err)
406
+ }
407
+ if srv := <-started; srv != nil {
408
+ _ = srv.Close()
409
+ } else {
410
+ t.Fatal("existing owner test server failed to start")
411
+ }
412
+ }
413
+
414
+ func TestEnsureFirstRunServerTimesOutForUnreadyOwner(t *testing.T) {
415
+ root := t.TempDir()
416
+ p := onboardingPaths(filepath.Join(root, ".relay-flow"))
417
+ if err := os.MkdirAll(p.Root, 0o700); err != nil {
418
+ t.Fatal(err)
419
+ }
420
+ lock, err := os.OpenFile(p.Lock, os.O_CREATE|os.O_RDWR, 0o600)
421
+ if err != nil {
422
+ t.Fatal(err)
423
+ }
424
+ if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
425
+ lock.Close()
426
+ t.Fatal(err)
427
+ }
428
+ defer func() {
429
+ _ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
430
+ _ = lock.Close()
431
+ }()
432
+ oldTimeout, oldPoll := backgroundServeStartupTimeout, backgroundServePollInterval
433
+ backgroundServeStartupTimeout = 300 * time.Millisecond
434
+ backgroundServePollInterval = 10 * time.Millisecond
435
+ defer func() {
436
+ backgroundServeStartupTimeout = oldTimeout
437
+ backgroundServePollInterval = oldPoll
438
+ }()
439
+
440
+ if err := ensureFirstRunServer(p); err == nil || !strings.Contains(err.Error(), "startup timed out") {
441
+ t.Fatalf("unready owner error = %v, want startup timeout", err)
442
+ }
443
+ }
444
+
445
+ func TestRunFirstRunRepositorySetupDelegatesRegistration(t *testing.T) {
446
+ root := t.TempDir()
447
+ p := onboardingPaths(filepath.Join(root, ".relay-flow"))
448
+ if err := os.MkdirAll(p.Root, 0o700); err != nil {
449
+ t.Fatal(err)
450
+ }
451
+ listener, err := net.Listen("unix", p.Socket)
452
+ if err != nil {
453
+ t.Fatal(err)
454
+ }
455
+ srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
456
+ if r.URL.Path != "/repos" {
457
+ http.NotFound(w, r)
458
+ return
459
+ }
460
+ w.Header().Set("Content-Type", "application/json")
461
+ _, _ = io.WriteString(w, `{"ok":true,"data":[]}`)
462
+ })}
463
+ go func() { _ = srv.Serve(listener) }()
464
+ defer srv.Close()
465
+
466
+ oldRegistration := firstRunRegistration
467
+ firstRunRegistration = func(c *server.Client, name, path string, sets kvFlags, stdin io.Reader) int {
468
+ if c == nil || name != "" || path != "" || sets != nil || stdin == nil {
469
+ t.Errorf("registration delegation = client=%v name=%q path=%q sets=%v stdin=%v", c != nil, name, path, sets, stdin)
470
+ }
471
+ return exitOK
472
+ }
473
+ defer func() { firstRunRegistration = oldRegistration }()
474
+ if err := runFirstRunRepositorySetup(p, strings.NewReader("input")); err != nil {
475
+ t.Fatal(err)
476
+ }
477
+ }
478
+
479
+ func TestRunOnboardingSpinner(t *testing.T) {
480
+ called := false
481
+ if err := runOnboardingSpinner("test", func() error {
482
+ called = true
483
+ return nil
484
+ }); err != nil {
485
+ t.Fatalf("spinner success = %v", err)
486
+ }
487
+ if !called {
488
+ t.Fatal("spinner action was not called")
489
+ }
490
+
491
+ wantErr := errors.New("startup failed")
492
+ if err := runOnboardingSpinner("test", func() error { return wantErr }); !errors.Is(err, wantErr) {
493
+ t.Fatalf("spinner error = %v, want %v", err, wantErr)
494
+ }
495
+ }
496
+
497
+ func TestStandaloneRepoServerHint(t *testing.T) {
498
+ err := standaloneRepoServerHint(errors.New("server call GET /repos: dial unix /tmp/server.sock: connect: no such file or directory"))
499
+ if err == nil || !containsAll(err.Error(), "serve --background", "guided init") {
500
+ t.Fatalf("server hint = %v", err)
501
+ }
502
+ original := errors.New("server bad request")
503
+ if got := standaloneRepoServerHint(original); got != original {
504
+ t.Fatalf("non-connectivity error changed: %v", got)
505
+ }
506
+ }
507
+
508
+ func containsAll(value string, parts ...string) bool {
509
+ for _, part := range parts {
510
+ if !strings.Contains(value, part) {
511
+ return false
512
+ }
513
+ }
514
+ return true
515
+ }