relay-flow 0.2.0-alpha → 0.2.2-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.
- package/README.md +171 -26
- package/cmd/relay-flow/beads_composition_test.go +451 -0
- package/cmd/relay-flow/commands_test.go +593 -15
- package/cmd/relay-flow/main.go +356 -119
- package/cmd/relay-flow/scenario_test.go +246 -35
- package/cmd/relay-flow/serve.go +5 -2
- package/examples/beads-workflow.yaml +74 -0
- package/examples/default-story-workflow.yaml +88 -0
- package/go.mod +2 -1
- package/go.sum +2 -0
- package/internal/config/config.go +13 -2
- package/internal/config/merge_test.go +18 -0
- package/internal/execution/goworkflows/activities.go +136 -87
- package/internal/execution/goworkflows/end_feedback_test.go +124 -0
- package/internal/execution/goworkflows/engine.go +41 -8
- package/internal/execution/goworkflows/engine_test.go +100 -22
- package/internal/execution/goworkflows/fakes_test.go +63 -25
- package/internal/execution/goworkflows/interpreter.go +45 -30
- package/internal/execution/goworkflows/mailbox_test.go +85 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
- package/internal/execution/goworkflows/node_runtime_test.go +72 -23
- package/internal/execution/goworkflows/recovery_test.go +7 -7
- package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
- package/internal/execution/goworkflows/retry_log_test.go +11 -11
- package/internal/harness/contract_test.go +15 -0
- package/internal/harness/factory.go +25 -3
- package/internal/harness/harness.go +30 -4
- package/internal/harness/opencode/opencode.go +125 -10
- package/internal/harness/opencode/opencode_test.go +183 -0
- package/internal/harness/opencode/repo_setup.go +361 -0
- package/internal/harness/plugin_selection_test.go +5 -5
- package/internal/paths/paths.go +18 -16
- package/internal/recover/recover.go +11 -6
- package/internal/repo/repo.go +13 -0
- package/internal/repo/service.go +10 -0
- package/internal/repo/service_test.go +55 -6
- package/internal/router/router.go +3 -2
- package/internal/router/router_test.go +87 -0
- package/internal/run/manager.go +14 -1
- package/internal/run/run.go +6 -4
- package/internal/run/run_manager_test.go +21 -1
- package/internal/runner/contract_test.go +64 -26
- package/internal/runner/orca/orca.go +30 -54
- package/internal/runner/orca/orca_test.go +143 -4
- package/internal/runner/orca/orcacli/orcacli.go +5 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
- package/internal/runner/runner.go +15 -8
- package/internal/task/auth_test.go +48 -0
- package/internal/task/beads/bdcli/bdcli.go +323 -0
- package/internal/task/beads/bdcli/bdcli_test.go +297 -0
- package/internal/task/beads/bdcli/testdata/array.json +1 -0
- package/internal/task/beads/bdcli/testdata/children.json +1 -0
- package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
- package/internal/task/beads/bdcli/testdata/commented.json +1 -0
- package/internal/task/beads/bdcli/testdata/comments.json +1 -0
- package/internal/task/beads/bdcli/testdata/created.json +1 -0
- package/internal/task/beads/bdcli/testdata/object.json +1 -0
- package/internal/task/beads/bdcli/testdata/ready.json +1 -0
- package/internal/task/beads/bdcli/testdata/show.json +1 -0
- package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
- package/internal/task/beads/bdcli/testdata/updated.json +1 -0
- package/internal/task/beads/beads.go +840 -0
- package/internal/task/beads/beads_test.go +609 -0
- package/internal/task/beads/comments_test.go +242 -0
- package/internal/task/beads/config_compatibility_test.go +163 -0
- package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
- package/internal/task/beads/repo_composition_test.go +232 -0
- package/internal/task/beads/runtime_config_test.go +81 -0
- package/internal/task/beads/status_compatibility_test.go +233 -0
- package/internal/task/beads/status_test.go +257 -0
- package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
- package/internal/task/beads/validation_test.go +110 -0
- package/internal/task/contract_test.go +12 -0
- package/internal/task/factory.go +52 -3
- package/internal/task/jira/auth.go +209 -0
- package/internal/task/jira/auth_test.go +160 -0
- package/internal/task/jira/effects_test.go +39 -0
- package/internal/task/jira/filters_test.go +96 -16
- package/internal/task/jira/helpers_test.go +29 -19
- package/internal/task/jira/jira.go +263 -90
- package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
- package/internal/task/jira/normalize.go +32 -14
- package/internal/task/jira/rest/adf.go +165 -0
- package/internal/task/jira/rest/adf_test.go +60 -0
- package/internal/task/jira/rest/client.go +573 -0
- package/internal/task/jira/rest/client_test.go +381 -0
- package/internal/task/jira/templates_test.go +118 -0
- package/internal/task/jira/transition_defaults_test.go +22 -18
- package/internal/task/jira/validation_test.go +1 -1
- package/internal/task/task.go +32 -0
- package/internal/workflow/report_test.go +45 -0
- package/internal/workflow/workflow.go +9 -6
- package/internal/workflow/workflow_test.go +14 -12
- package/package.json +2 -1
- package/internal/task/jira/acli/acli.go +0 -306
- package/internal/task/jira/acli/acli_test.go +0 -208
- package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
- /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
- /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
|
@@ -1,20 +1,30 @@
|
|
|
1
1
|
package main
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"bytes"
|
|
4
5
|
"context"
|
|
6
|
+
"database/sql"
|
|
5
7
|
"errors"
|
|
8
|
+
"fmt"
|
|
9
|
+
"io"
|
|
6
10
|
"net"
|
|
7
11
|
"net/http"
|
|
8
12
|
"os"
|
|
13
|
+
"os/exec"
|
|
9
14
|
"path/filepath"
|
|
10
15
|
"strings"
|
|
16
|
+
"sync"
|
|
17
|
+
"syscall"
|
|
11
18
|
"testing"
|
|
12
19
|
"time"
|
|
13
20
|
|
|
21
|
+
"github.com/charmbracelet/huh"
|
|
22
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
14
23
|
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
15
24
|
runsvc "github.com/rajpopat27/relay-flow/internal/run"
|
|
16
25
|
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
17
26
|
"github.com/rajpopat27/relay-flow/internal/server"
|
|
27
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
18
28
|
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
19
29
|
)
|
|
20
30
|
|
|
@@ -33,7 +43,7 @@ func cli(t *testing.T, home string, stdin string, args ...string) (code int) {
|
|
|
33
43
|
func TestCommandSurfaceExists(t *testing.T) {
|
|
34
44
|
home := t.TempDir()
|
|
35
45
|
commands := [][]string{
|
|
36
|
-
{"init"}, {"serve", "--recover"}, {"stop"}, {"report"},
|
|
46
|
+
{"init"}, {"task", "auth"}, {"serve", "--recover"}, {"stop"}, {"report"},
|
|
37
47
|
{"workflow", "submit", "--file", "x.yaml"}, {"workflow", "remove", "--name", "x"},
|
|
38
48
|
{"workflow", "list"}, {"workflow", "get", "--name", "x"},
|
|
39
49
|
{"repo", "register"}, {"repo", "remove", "--name", "x"}, {"repo", "list"}, {"repo", "get", "--name", "x"},
|
|
@@ -92,11 +102,69 @@ func TestRunListRowShowsLifecycleAndActiveRetry(t *testing.T) {
|
|
|
92
102
|
// without them per 5.5).
|
|
93
103
|
func initHome(t *testing.T, home string) {
|
|
94
104
|
t.Helper()
|
|
95
|
-
if code := cli(t, home, "
|
|
105
|
+
if code := cli(t, home, "", initArgs()...); code != 0 {
|
|
96
106
|
t.Fatalf("init exit = %d, want 0", code)
|
|
97
107
|
}
|
|
98
108
|
}
|
|
99
109
|
|
|
110
|
+
func initArgs() []string {
|
|
111
|
+
return []string{"init", "--task-plugin", "jira", "--runner-plugin", "orca", "--harness-plugin", "opencode"}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func initInput() string {
|
|
115
|
+
return "jira\norca\nopencode\n"
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const authTestPlugin = "auth-dispatch-test"
|
|
119
|
+
|
|
120
|
+
var (
|
|
121
|
+
registerAuthTestPlugin sync.Once
|
|
122
|
+
authTestArgs []string
|
|
123
|
+
authTestInput string
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
func ensureAuthTestPlugin() {
|
|
127
|
+
registerAuthTestPlugin.Do(func() {
|
|
128
|
+
task.Register(authTestPlugin, task.Factory{
|
|
129
|
+
RequiredRepoKeys: func() []string { return nil },
|
|
130
|
+
TaskScopeKey: func(_, _ config.RawValues) (string, error) { return "auth-test", nil },
|
|
131
|
+
Auth: func(_ context.Context, args []string, stdin io.Reader) error {
|
|
132
|
+
authTestArgs = append([]string(nil), args...)
|
|
133
|
+
body, err := io.ReadAll(stdin)
|
|
134
|
+
if err != nil {
|
|
135
|
+
return err
|
|
136
|
+
}
|
|
137
|
+
authTestInput = string(body)
|
|
138
|
+
return nil
|
|
139
|
+
},
|
|
140
|
+
New: func(context.Context, task.RepoSpec) (task.System, error) { return nil, nil },
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
func TestTaskAuthDispatchesSelectedPlugin(t *testing.T) {
|
|
146
|
+
ensureAuthTestPlugin()
|
|
147
|
+
home := t.TempDir()
|
|
148
|
+
root := filepath.Join(home, ".relay-flow")
|
|
149
|
+
if err := os.MkdirAll(root, 0o700); err != nil {
|
|
150
|
+
t.Fatal(err)
|
|
151
|
+
}
|
|
152
|
+
if err := config.SaveMachine(filepath.Join(root, "config.yaml"), &config.Machine{TaskPlugin: authTestPlugin}); err != nil {
|
|
153
|
+
t.Fatal(err)
|
|
154
|
+
}
|
|
155
|
+
authTestArgs = nil
|
|
156
|
+
authTestInput = ""
|
|
157
|
+
if code := cli(t, home, "plugin-owned-input", "task", "auth", "--custom", "value"); code != 0 {
|
|
158
|
+
t.Fatalf("task auth exit = %d, want 0", code)
|
|
159
|
+
}
|
|
160
|
+
if got := strings.Join(authTestArgs, " "); got != "--custom value" {
|
|
161
|
+
t.Fatalf("plugin args = %q", got)
|
|
162
|
+
}
|
|
163
|
+
if authTestInput != "plugin-owned-input" {
|
|
164
|
+
t.Fatalf("plugin stdin = %q", authTestInput)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
100
168
|
// 3.29 (lock): serve creates server.lock owner-only. Asserted through the
|
|
101
169
|
// serve fixture.
|
|
102
170
|
func TestServerLockIsOwnerOnly(t *testing.T) {
|
|
@@ -193,7 +261,7 @@ func TestReportUnreachableServerExits1(t *testing.T) {
|
|
|
193
261
|
func TestInitRefusesToOverwrite(t *testing.T) {
|
|
194
262
|
home := t.TempDir()
|
|
195
263
|
// init reads the three plugin selections from stdin.
|
|
196
|
-
if code := cli(t, home,
|
|
264
|
+
if code := cli(t, home, initInput(), "init"); code != 0 {
|
|
197
265
|
t.Fatalf("first init exit = %d, want 0", code)
|
|
198
266
|
}
|
|
199
267
|
cfgPath := filepath.Join(home, ".relay-flow", "config.yaml")
|
|
@@ -214,7 +282,7 @@ func TestInitRefusesToOverwrite(t *testing.T) {
|
|
|
214
282
|
t.Fatalf("state.db is not a SQLite database (missing magic header); got %q", dbBefore[:min(16, len(dbBefore))])
|
|
215
283
|
}
|
|
216
284
|
|
|
217
|
-
if code := cli(t, home,
|
|
285
|
+
if code := cli(t, home, initInput(), "init"); code == 0 {
|
|
218
286
|
t.Fatal("init overwrote existing config/history")
|
|
219
287
|
}
|
|
220
288
|
if readFile(t, cfgPath) != cfg {
|
|
@@ -225,19 +293,266 @@ func TestInitRefusesToOverwrite(t *testing.T) {
|
|
|
225
293
|
}
|
|
226
294
|
}
|
|
227
295
|
|
|
296
|
+
func TestInitSelectionTitlesAndSingletons(t *testing.T) {
|
|
297
|
+
for _, tc := range []struct {
|
|
298
|
+
title string
|
|
299
|
+
value string
|
|
300
|
+
}{
|
|
301
|
+
{"Select task system", "jira"},
|
|
302
|
+
{"Select runner", "orca"},
|
|
303
|
+
{"Select harness", "opencode"},
|
|
304
|
+
} {
|
|
305
|
+
var selected string
|
|
306
|
+
field, err := pluginSelectField(tc.title, []string{tc.value}, &selected)
|
|
307
|
+
if err != nil {
|
|
308
|
+
t.Fatal(err)
|
|
309
|
+
}
|
|
310
|
+
if field == nil {
|
|
311
|
+
t.Fatalf("%s singleton field is nil", tc.title)
|
|
312
|
+
}
|
|
313
|
+
var singletonOut bytes.Buffer
|
|
314
|
+
if err := field.RunAccessible(&singletonOut, strings.NewReader("1\n")); err != nil {
|
|
315
|
+
t.Fatal(err)
|
|
316
|
+
}
|
|
317
|
+
if selected != tc.value || !strings.Contains(singletonOut.String(), tc.title) {
|
|
318
|
+
t.Fatalf("%s singleton selection = %q, output %q", tc.title, selected, singletonOut.String())
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
field, err = pluginSelectField(tc.title, []string{tc.value, "other"}, &selected)
|
|
322
|
+
if err != nil {
|
|
323
|
+
t.Fatal(err)
|
|
324
|
+
}
|
|
325
|
+
var out bytes.Buffer
|
|
326
|
+
if err := field.RunAccessible(&out, strings.NewReader("1\n")); err != nil {
|
|
327
|
+
t.Fatal(err)
|
|
328
|
+
}
|
|
329
|
+
if !strings.Contains(out.String(), tc.title) {
|
|
330
|
+
t.Fatalf("selection output %q missing title %q", out.String(), tc.title)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
func TestInitPrintsSelectedValuesAndCompletion(t *testing.T) {
|
|
336
|
+
code, out := captureStdout(t, func() int {
|
|
337
|
+
return cli(t, t.TempDir(), "", initArgs()...)
|
|
338
|
+
})
|
|
339
|
+
if code != 0 {
|
|
340
|
+
t.Fatalf("init exit = %d", code)
|
|
341
|
+
}
|
|
342
|
+
for _, want := range []string{"Task system: jira", "Runner: orca", "Harness: opencode", "Relay-flow initialized"} {
|
|
343
|
+
if !strings.Contains(out, want) {
|
|
344
|
+
t.Fatalf("init output %q missing %q", out, want)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
func TestInitWritesHarnessPromptDefaults(t *testing.T) {
|
|
350
|
+
home := t.TempDir()
|
|
351
|
+
if code := cli(t, home, "", initArgs()...); code != 0 {
|
|
352
|
+
t.Fatalf("init exit = %d", code)
|
|
353
|
+
}
|
|
354
|
+
cfg, err := config.LoadMachine(filepath.Join(home, ".relay-flow", "config.yaml"))
|
|
355
|
+
if err != nil {
|
|
356
|
+
t.Fatal(err)
|
|
357
|
+
}
|
|
358
|
+
for _, key := range []string{"initial", "feedback", "hitl"} {
|
|
359
|
+
value, ok := cfg.HarnessConfig[key].(string)
|
|
360
|
+
if !ok || value == "" {
|
|
361
|
+
t.Fatalf("harnessConfig.%s = %#v", key, cfg.HarnessConfig[key])
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
func TestInitWritesTaskTextDefaults(t *testing.T) {
|
|
367
|
+
home := t.TempDir()
|
|
368
|
+
if code := cli(t, home, "", initArgs()...); code != 0 {
|
|
369
|
+
t.Fatalf("init exit = %d", code)
|
|
370
|
+
}
|
|
371
|
+
cfg, err := config.LoadMachine(filepath.Join(home, ".relay-flow", "config.yaml"))
|
|
372
|
+
if err != nil {
|
|
373
|
+
t.Fatal(err)
|
|
374
|
+
}
|
|
375
|
+
templates, ok := rawValuesMap(cfg.TaskConfig["templates"])
|
|
376
|
+
if !ok {
|
|
377
|
+
t.Fatalf("taskConfig.templates = %#v", cfg.TaskConfig["templates"])
|
|
378
|
+
}
|
|
379
|
+
for _, key := range []string{"mailboxDescription", "summaryComment", "feedbackComment"} {
|
|
380
|
+
if value, ok := templates[key].(string); !ok || value == "" {
|
|
381
|
+
t.Fatalf("taskConfig.templates.%s = %#v", key, templates[key])
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
func TestInitDoesNotCollectOrWriteTaskCredentials(t *testing.T) {
|
|
387
|
+
home := t.TempDir()
|
|
388
|
+
if code := cli(t, home, "", initArgs()...); code != 0 {
|
|
389
|
+
t.Fatalf("init exit = %d", code)
|
|
390
|
+
}
|
|
391
|
+
root := filepath.Join(home, ".relay-flow")
|
|
392
|
+
configText := readFile(t, filepath.Join(root, "config.yaml"))
|
|
393
|
+
if strings.Contains(configText, "jira-site") || strings.Contains(configText, "email") || strings.Contains(configText, "token") {
|
|
394
|
+
t.Fatalf("normal config contains task credentials: %s", configText)
|
|
395
|
+
}
|
|
396
|
+
if _, err := os.Stat(filepath.Join(root, "credentials.yaml")); !os.IsNotExist(err) {
|
|
397
|
+
t.Fatalf("init wrote credentials.yaml: %v", err)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
func TestInitForceRejectsRunningServerAndNonterminalRun(t *testing.T) {
|
|
402
|
+
t.Run("server running", func(t *testing.T) {
|
|
403
|
+
home := t.TempDir()
|
|
404
|
+
initHome(t, home)
|
|
405
|
+
lockPath := filepath.Join(home, ".relay-flow", "server.lock")
|
|
406
|
+
lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0600)
|
|
407
|
+
if err != nil {
|
|
408
|
+
t.Fatal(err)
|
|
409
|
+
}
|
|
410
|
+
defer lock.Close()
|
|
411
|
+
if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
|
412
|
+
t.Fatal(err)
|
|
413
|
+
}
|
|
414
|
+
defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
|
|
415
|
+
if code := cli(t, home, "", "init", "--force",
|
|
416
|
+
"--task-plugin", "jira", "--runner-plugin", "orca", "--harness-plugin", "opencode"); code != 1 {
|
|
417
|
+
t.Fatalf("forced init with server lock exit = %d, want 1", code)
|
|
418
|
+
}
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
t.Run("nonterminal run", func(t *testing.T) {
|
|
422
|
+
home := t.TempDir()
|
|
423
|
+
initHome(t, home)
|
|
424
|
+
root := filepath.Join(home, ".relay-flow")
|
|
425
|
+
insertRun(t, filepath.Join(root, "state.db"), "active", "starting")
|
|
426
|
+
configBefore := readFile(t, filepath.Join(root, "config.yaml"))
|
|
427
|
+
if code := cli(t, home, "", "init", "--force",
|
|
428
|
+
"--task-plugin", "jira", "--runner-plugin", "orca", "--harness-plugin", "opencode"); code != 1 {
|
|
429
|
+
t.Fatalf("forced init with active run exit = %d, want 1", code)
|
|
430
|
+
}
|
|
431
|
+
if got := readFile(t, filepath.Join(root, "config.yaml")); got != configBefore {
|
|
432
|
+
t.Fatal("rejected forced init changed config")
|
|
433
|
+
}
|
|
434
|
+
})
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
func TestInitForcePreservesDurableAndUserState(t *testing.T) {
|
|
438
|
+
home := t.TempDir()
|
|
439
|
+
initHome(t, home)
|
|
440
|
+
root := filepath.Join(home, ".relay-flow")
|
|
441
|
+
dbPath := filepath.Join(root, "state.db")
|
|
442
|
+
insertRun(t, dbPath, "completed", "completed")
|
|
443
|
+
dbBefore, err := os.Stat(dbPath)
|
|
444
|
+
if err != nil {
|
|
445
|
+
t.Fatal(err)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
cfg, err := config.LoadMachine(filepath.Join(root, "config.yaml"))
|
|
449
|
+
if err != nil {
|
|
450
|
+
t.Fatal(err)
|
|
451
|
+
}
|
|
452
|
+
cfg.Repos = map[string]config.Repo{"existing": {Path: "/srv/existing"}}
|
|
453
|
+
cfg.HarnessConfig["initial"] = "custom {{ticket}}"
|
|
454
|
+
taskTemplates, ok := rawValuesMap(cfg.TaskConfig["templates"])
|
|
455
|
+
if !ok {
|
|
456
|
+
t.Fatalf("taskConfig.templates = %#v", cfg.TaskConfig["templates"])
|
|
457
|
+
}
|
|
458
|
+
taskTemplates["mailboxDescription"] = "custom {{nodeDescription}}"
|
|
459
|
+
if err := config.SaveMachine(filepath.Join(root, "config.yaml"), cfg); err != nil {
|
|
460
|
+
t.Fatal(err)
|
|
461
|
+
}
|
|
462
|
+
workflowDir := filepath.Join(root, "workflows")
|
|
463
|
+
if err := os.MkdirAll(workflowDir, 0700); err != nil {
|
|
464
|
+
t.Fatal(err)
|
|
465
|
+
}
|
|
466
|
+
workflowPath := filepath.Join(workflowDir, "saved.yaml")
|
|
467
|
+
logPath := filepath.Join(root, "server.log")
|
|
468
|
+
if err := os.WriteFile(workflowPath, []byte("saved workflow\n"), 0644); err != nil {
|
|
469
|
+
t.Fatal(err)
|
|
470
|
+
}
|
|
471
|
+
if err := os.WriteFile(logPath, []byte("saved log\n"), 0600); err != nil {
|
|
472
|
+
t.Fatal(err)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
args := append([]string{"init", "--force"}, initArgs()[1:]...)
|
|
476
|
+
if code := cli(t, home, "", args...); code != 0 {
|
|
477
|
+
t.Fatalf("forced init exit = %d, want 0", code)
|
|
478
|
+
}
|
|
479
|
+
if got := readFile(t, workflowPath); got != "saved workflow\n" {
|
|
480
|
+
t.Fatalf("workflow changed: %q", got)
|
|
481
|
+
}
|
|
482
|
+
if got := readFile(t, logPath); got != "saved log\n" {
|
|
483
|
+
t.Fatalf("log changed: %q", got)
|
|
484
|
+
}
|
|
485
|
+
dbAfter, err := os.Stat(dbPath)
|
|
486
|
+
if err != nil {
|
|
487
|
+
t.Fatal(err)
|
|
488
|
+
}
|
|
489
|
+
if !os.SameFile(dbBefore, dbAfter) {
|
|
490
|
+
t.Fatal("forced init recreated state.db")
|
|
491
|
+
}
|
|
492
|
+
cfg, err = config.LoadMachine(filepath.Join(root, "config.yaml"))
|
|
493
|
+
if err != nil {
|
|
494
|
+
t.Fatal(err)
|
|
495
|
+
}
|
|
496
|
+
if _, ok := cfg.Repos["existing"]; !ok {
|
|
497
|
+
t.Fatal("forced init removed existing repo config")
|
|
498
|
+
}
|
|
499
|
+
if got := cfg.HarnessConfig["initial"]; got != "custom {{ticket}}" {
|
|
500
|
+
t.Fatalf("forced init changed prompt override: %#v", got)
|
|
501
|
+
}
|
|
502
|
+
gotTaskTemplates, ok := rawValuesMap(cfg.TaskConfig["templates"])
|
|
503
|
+
if !ok {
|
|
504
|
+
t.Fatalf("taskConfig.templates = %#v", cfg.TaskConfig["templates"])
|
|
505
|
+
}
|
|
506
|
+
if got := gotTaskTemplates["mailboxDescription"]; got != "custom {{nodeDescription}}" {
|
|
507
|
+
t.Fatalf("forced init changed task text override: %#v", got)
|
|
508
|
+
}
|
|
509
|
+
for _, key := range []string{"summaryComment", "feedbackComment"} {
|
|
510
|
+
if got, ok := gotTaskTemplates[key].(string); !ok || got == "" {
|
|
511
|
+
t.Fatalf("forced init omitted task default %s: %#v", key, gotTaskTemplates[key])
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
for _, key := range []string{"feedback", "hitl"} {
|
|
515
|
+
if got, ok := cfg.HarnessConfig[key].(string); !ok || got == "" {
|
|
516
|
+
t.Fatalf("forced init omitted harness default %s: %#v", key, cfg.HarnessConfig[key])
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
db, err := sql.Open("sqlite", dbPath)
|
|
520
|
+
if err != nil {
|
|
521
|
+
t.Fatal(err)
|
|
522
|
+
}
|
|
523
|
+
defer db.Close()
|
|
524
|
+
var state string
|
|
525
|
+
if err := db.QueryRow(`SELECT state FROM relay_runs WHERE id = 'completed'`).Scan(&state); err != nil {
|
|
526
|
+
t.Fatalf("completed history missing: %v", err)
|
|
527
|
+
}
|
|
528
|
+
if state != "completed" {
|
|
529
|
+
t.Fatalf("completed history state = %q", state)
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
func rawValuesMap(value any) (map[string]any, bool) {
|
|
534
|
+
switch values := value.(type) {
|
|
535
|
+
case map[string]any:
|
|
536
|
+
return values, true
|
|
537
|
+
case config.RawValues:
|
|
538
|
+
return map[string]any(values), true
|
|
539
|
+
default:
|
|
540
|
+
return nil, false
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
228
544
|
// 8.2: --task-plugin/--runner-plugin/--harness-plugin run init without any
|
|
229
545
|
// prompt/stdin and write the same machine config as the stdin path.
|
|
230
546
|
func TestInitFlagsNonInteractive(t *testing.T) {
|
|
231
547
|
home := t.TempDir()
|
|
232
548
|
// Flags fully replace stdin: empty stdin must still succeed.
|
|
233
|
-
if code := cli(t, home, "",
|
|
234
|
-
"--task-plugin", "jira", "--runner-plugin", "orca", "--harness-plugin", "opencode"); code != 0 {
|
|
549
|
+
if code := cli(t, home, "", initArgs()...); code != 0 {
|
|
235
550
|
t.Fatalf("flagged init exit = %d, want 0", code)
|
|
236
551
|
}
|
|
237
552
|
cfgFlags := readFile(t, filepath.Join(home, ".relay-flow", "config.yaml"))
|
|
238
553
|
|
|
239
554
|
home2 := t.TempDir()
|
|
240
|
-
if code := cli(t, home2,
|
|
555
|
+
if code := cli(t, home2, initInput(), "init"); code != 0 {
|
|
241
556
|
t.Fatalf("stdin init exit = %d, want 0", code)
|
|
242
557
|
}
|
|
243
558
|
cfgStdin := readFile(t, filepath.Join(home2, ".relay-flow", "config.yaml"))
|
|
@@ -271,13 +586,57 @@ func min(a, b int) int {
|
|
|
271
586
|
return b
|
|
272
587
|
}
|
|
273
588
|
|
|
589
|
+
func captureStdout(t *testing.T, fn func() int) (int, string) {
|
|
590
|
+
t.Helper()
|
|
591
|
+
old := os.Stdout
|
|
592
|
+
r, w, err := os.Pipe()
|
|
593
|
+
if err != nil {
|
|
594
|
+
t.Fatal(err)
|
|
595
|
+
}
|
|
596
|
+
os.Stdout = w
|
|
597
|
+
done := make(chan []byte, 1)
|
|
598
|
+
go func() {
|
|
599
|
+
b, _ := io.ReadAll(r)
|
|
600
|
+
done <- b
|
|
601
|
+
}()
|
|
602
|
+
code := fn()
|
|
603
|
+
_ = w.Close()
|
|
604
|
+
os.Stdout = old
|
|
605
|
+
out := <-done
|
|
606
|
+
_ = r.Close()
|
|
607
|
+
return code, string(out)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
func insertRun(t *testing.T, path, id, state string) {
|
|
611
|
+
t.Helper()
|
|
612
|
+
db, err := sql.Open("sqlite", path)
|
|
613
|
+
if err != nil {
|
|
614
|
+
t.Fatal(err)
|
|
615
|
+
}
|
|
616
|
+
defer db.Close()
|
|
617
|
+
now := time.Now().UTC()
|
|
618
|
+
var finished any
|
|
619
|
+
if state == "completed" || state == "canceled" {
|
|
620
|
+
finished = now
|
|
621
|
+
}
|
|
622
|
+
_, err = db.Exec(`INSERT INTO relay_runs
|
|
623
|
+
(id, repo, workflow, ticket_id, ticket_key, state, started_at, updated_at, finished_at)
|
|
624
|
+
VALUES (?, 'repo', 'workflow', ?, ?, ?, ?, ?, ?)`, id, id, id, state, now, now, finished)
|
|
625
|
+
if err != nil {
|
|
626
|
+
t.Fatal(err)
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
274
630
|
// 8.4: fully-flagged repo register never prompts and produces the same
|
|
275
631
|
// repo entry the interactive run would post.
|
|
276
632
|
type registerServer struct {
|
|
277
|
-
ackServer
|
|
278
|
-
fields
|
|
279
|
-
gotInput
|
|
280
|
-
|
|
633
|
+
ackServer // embed for the unreachable stubs
|
|
634
|
+
fields []string
|
|
635
|
+
gotInput *repo.RegisterInput
|
|
636
|
+
gotInputs []repo.RegisterInput
|
|
637
|
+
successful []repo.RegisterInput
|
|
638
|
+
failName string
|
|
639
|
+
calls int
|
|
281
640
|
}
|
|
282
641
|
|
|
283
642
|
func (s *registerServer) TaskFields(context.Context) ([]string, error) {
|
|
@@ -288,6 +647,11 @@ func (s *registerServer) RegisterRepo(_ context.Context, in repo.RegisterInput)
|
|
|
288
647
|
s.calls++
|
|
289
648
|
cp := in
|
|
290
649
|
s.gotInput = &cp
|
|
650
|
+
s.gotInputs = append(s.gotInputs, cp)
|
|
651
|
+
if in.Name == s.failName {
|
|
652
|
+
return repo.Info{}, errors.New("registration failed")
|
|
653
|
+
}
|
|
654
|
+
s.successful = append(s.successful, cp)
|
|
291
655
|
return repo.Info{Name: in.Name, Path: in.Path, TaskConfig: in.TaskConfig}, nil
|
|
292
656
|
}
|
|
293
657
|
|
|
@@ -319,7 +683,7 @@ func TestRepoRegisterFlagsNonInteractive(t *testing.T) {
|
|
|
319
683
|
// Fully-flagged run: no stdin, no TTY — must not prompt.
|
|
320
684
|
code := cli(t, home, "", "repo", "register",
|
|
321
685
|
"--name", "payments", "--path", "/srv/payments",
|
|
322
|
-
"--set", "project=PAY"
|
|
686
|
+
"--set", "project=PAY")
|
|
323
687
|
if code != 0 {
|
|
324
688
|
t.Fatalf("fully-flagged register exit = %d, want 0", code)
|
|
325
689
|
}
|
|
@@ -329,16 +693,16 @@ func TestRepoRegisterFlagsNonInteractive(t *testing.T) {
|
|
|
329
693
|
if deps.gotInput.Name != "payments" || deps.gotInput.Path != "/srv/payments" {
|
|
330
694
|
t.Fatalf("name/path = %q/%q", deps.gotInput.Name, deps.gotInput.Path)
|
|
331
695
|
}
|
|
332
|
-
if deps.gotInput.TaskConfig["project"] != "PAY" || deps.gotInput.TaskConfig["component"] != "
|
|
696
|
+
if deps.gotInput.TaskConfig["project"] != "PAY" || deps.gotInput.TaskConfig["component"] != "payments" {
|
|
333
697
|
t.Fatalf("taskConfig = %v", deps.gotInput.TaskConfig)
|
|
334
698
|
}
|
|
335
699
|
|
|
336
|
-
// Missing
|
|
700
|
+
// Missing project is a usage error; component is derived from --name.
|
|
337
701
|
// is allowed to hit the server (RegisterRepo must not be called).
|
|
338
702
|
home2 := t.TempDir()
|
|
339
703
|
deps2 := serveRegister(t, home2, []string{"project", "component"})
|
|
340
704
|
if code := cli(t, home2, "", "repo", "register",
|
|
341
|
-
"--name", "x", "--path", "/x"
|
|
705
|
+
"--name", "x", "--path", "/x"); code != 2 {
|
|
342
706
|
t.Fatalf("missing required key exit = %d, want 2", code)
|
|
343
707
|
}
|
|
344
708
|
if deps2.gotInput != nil {
|
|
@@ -388,6 +752,220 @@ func TestRepoRegisterFlagsNonInteractive(t *testing.T) {
|
|
|
388
752
|
"--set", "project=PAY", "--set", "bogus=v"); code != 2 {
|
|
389
753
|
t.Fatalf("unknown key exit = %d, want 2", code)
|
|
390
754
|
}
|
|
755
|
+
|
|
756
|
+
// Component can never be prompted or overridden.
|
|
757
|
+
home6 := t.TempDir()
|
|
758
|
+
deps6 := serveRegister(t, home6, []string{"project", "component"})
|
|
759
|
+
if code := cli(t, home6, "", "repo", "register",
|
|
760
|
+
"--name", "x", "--path", "/x",
|
|
761
|
+
"--set", "project=PAY", "--set", "component=override"); code != 2 {
|
|
762
|
+
t.Fatalf("component override exit = %d, want 2", code)
|
|
763
|
+
}
|
|
764
|
+
if deps6.gotInput != nil {
|
|
765
|
+
t.Fatal("component override reached RegisterRepo")
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
func TestRepoMultiSelectAndSharedJiraMapping(t *testing.T) {
|
|
770
|
+
selected := []int{0, 1}
|
|
771
|
+
field := repoMultiSelect([]huh.Option[int]{
|
|
772
|
+
huh.NewOption("payments", 0),
|
|
773
|
+
huh.NewOption("checkout", 1),
|
|
774
|
+
}, &selected)
|
|
775
|
+
var out bytes.Buffer
|
|
776
|
+
if err := field.RunAccessible(&out, strings.NewReader("0\n")); err != nil {
|
|
777
|
+
t.Fatal(err)
|
|
778
|
+
}
|
|
779
|
+
if !strings.Contains(out.String(), "Select repositories") {
|
|
780
|
+
t.Fatalf("multi-select output %q missing title", out.String())
|
|
781
|
+
}
|
|
782
|
+
if fmt.Sprint(selected) != "[0 1]" {
|
|
783
|
+
t.Fatalf("selected = %v, want [0 1]", selected)
|
|
784
|
+
}
|
|
785
|
+
if got := registrationSharedFields([]string{"project", "component"}); fmt.Sprint(got) != "[project]" {
|
|
786
|
+
t.Fatalf("prompted fields = %v, want project only", got)
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
home := t.TempDir()
|
|
790
|
+
deps := serveRegister(t, home, []string{"project", "component"})
|
|
791
|
+
client := server.NewClient(filepath.Join(home, ".relay-flow", "server.sock"))
|
|
792
|
+
candidates := []runner.RepoCandidate{
|
|
793
|
+
{Name: "payments", Path: "/srv/payments"},
|
|
794
|
+
{Name: "checkout", Path: "/srv/checkout"},
|
|
795
|
+
}
|
|
796
|
+
if err := registerSelectedRepos(context.Background(), client, candidates, selected,
|
|
797
|
+
[]string{"project", "component"}, kvFlags{"project": "PAY"}); err != nil {
|
|
798
|
+
t.Fatal(err)
|
|
799
|
+
}
|
|
800
|
+
if len(deps.successful) != 2 {
|
|
801
|
+
t.Fatalf("registrations = %d, want 2", len(deps.successful))
|
|
802
|
+
}
|
|
803
|
+
for _, input := range deps.successful {
|
|
804
|
+
if input.TaskConfig["project"] != "PAY" || input.TaskConfig["component"] != input.Name {
|
|
805
|
+
t.Fatalf("registration %+v has task config %v", input, input.TaskConfig)
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
func TestRepoRegistrationPartialFailureKeepsPriorSuccess(t *testing.T) {
|
|
811
|
+
home := t.TempDir()
|
|
812
|
+
deps := serveRegister(t, home, []string{"project", "component"})
|
|
813
|
+
deps.failName = "checkout"
|
|
814
|
+
client := server.NewClient(filepath.Join(home, ".relay-flow", "server.sock"))
|
|
815
|
+
candidates := []runner.RepoCandidate{
|
|
816
|
+
{Name: "payments", Path: "/srv/payments"},
|
|
817
|
+
{Name: "checkout", Path: "/srv/checkout"},
|
|
818
|
+
{Name: "later", Path: "/srv/later"},
|
|
819
|
+
}
|
|
820
|
+
err := registerSelectedRepos(context.Background(), client, candidates, []int{0, 1, 2},
|
|
821
|
+
[]string{"project", "component"}, kvFlags{"project": "PAY"})
|
|
822
|
+
if err == nil || !strings.Contains(err.Error(), "checkout") {
|
|
823
|
+
t.Fatalf("partial failure = %v, want failed repo name", err)
|
|
824
|
+
}
|
|
825
|
+
if len(deps.successful) != 1 || deps.successful[0].Name != "payments" {
|
|
826
|
+
t.Fatalf("successful registrations = %+v, want payments preserved", deps.successful)
|
|
827
|
+
}
|
|
828
|
+
if len(deps.gotInputs) != 2 {
|
|
829
|
+
t.Fatalf("registration attempts = %d, want stop after failed second repo", len(deps.gotInputs))
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
func TestBackgroundServeReadinessAndStop(t *testing.T) {
|
|
834
|
+
home := t.TempDir()
|
|
835
|
+
root := filepath.Join(home, ".relay-flow")
|
|
836
|
+
t.Setenv("RELAY_FLOW_HOME", root)
|
|
837
|
+
if code := run(initArgs(), strings.NewReader("")); code != 0 {
|
|
838
|
+
t.Fatalf("init exit = %d", code)
|
|
839
|
+
}
|
|
840
|
+
binary := buildCLIBinary(t)
|
|
841
|
+
start := exec.Command(binary, "serve", "--background")
|
|
842
|
+
start.Env = os.Environ()
|
|
843
|
+
out, err := start.CombinedOutput()
|
|
844
|
+
if err != nil {
|
|
845
|
+
t.Fatalf("background serve: %v\n%s", err, out)
|
|
846
|
+
}
|
|
847
|
+
if !strings.Contains(string(out), "Relay-flow server started") {
|
|
848
|
+
t.Fatalf("background output = %q", out)
|
|
849
|
+
}
|
|
850
|
+
client := server.NewClient(filepath.Join(root, "server.sock"))
|
|
851
|
+
if _, err := client.ListRepos(context.Background()); err != nil {
|
|
852
|
+
t.Fatalf("ready server did not respond: %v", err)
|
|
853
|
+
}
|
|
854
|
+
stop := exec.Command(binary, "stop")
|
|
855
|
+
stop.Env = os.Environ()
|
|
856
|
+
if out, err := stop.CombinedOutput(); err != nil {
|
|
857
|
+
t.Fatalf("stop: %v\n%s", err, out)
|
|
858
|
+
}
|
|
859
|
+
waitForServerStop(t, client)
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
func TestBackgroundServeFailurePointsToLog(t *testing.T) {
|
|
863
|
+
home := t.TempDir()
|
|
864
|
+
root := filepath.Join(home, ".relay-flow")
|
|
865
|
+
t.Setenv("RELAY_FLOW_HOME", root)
|
|
866
|
+
if code := run(initArgs(), strings.NewReader("")); code != 0 {
|
|
867
|
+
t.Fatalf("init exit = %d", code)
|
|
868
|
+
}
|
|
869
|
+
if err := os.WriteFile(filepath.Join(root, "config.yaml"), []byte("invalid: true\n"), 0600); err != nil {
|
|
870
|
+
t.Fatal(err)
|
|
871
|
+
}
|
|
872
|
+
binary := buildCLIBinary(t)
|
|
873
|
+
start := exec.Command(binary, "serve", "--background")
|
|
874
|
+
start.Env = os.Environ()
|
|
875
|
+
out, err := start.CombinedOutput()
|
|
876
|
+
if err == nil {
|
|
877
|
+
t.Fatalf("background serve unexpectedly succeeded: %s", out)
|
|
878
|
+
}
|
|
879
|
+
if !strings.Contains(string(out), "server.log") {
|
|
880
|
+
t.Fatalf("background failure %q does not point to server.log", out)
|
|
881
|
+
}
|
|
882
|
+
log := readFile(t, filepath.Join(root, "server.log"))
|
|
883
|
+
if !strings.Contains(log, "server startup failed") {
|
|
884
|
+
t.Fatalf("server.log missing startup error: %q", log)
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
func TestForegroundServeRemainsBlocking(t *testing.T) {
|
|
889
|
+
home := t.TempDir()
|
|
890
|
+
root := filepath.Join(home, ".relay-flow")
|
|
891
|
+
t.Setenv("RELAY_FLOW_HOME", root)
|
|
892
|
+
if code := run(initArgs(), strings.NewReader("")); code != 0 {
|
|
893
|
+
t.Fatalf("init exit = %d", code)
|
|
894
|
+
}
|
|
895
|
+
binary := buildCLIBinary(t)
|
|
896
|
+
serve := exec.Command(binary, "serve")
|
|
897
|
+
serve.Env = os.Environ()
|
|
898
|
+
if err := serve.Start(); err != nil {
|
|
899
|
+
t.Fatal(err)
|
|
900
|
+
}
|
|
901
|
+
wait := make(chan error, 1)
|
|
902
|
+
go func() { wait <- serve.Wait() }()
|
|
903
|
+
t.Cleanup(func() {
|
|
904
|
+
if serve.Process != nil {
|
|
905
|
+
_ = serve.Process.Kill()
|
|
906
|
+
}
|
|
907
|
+
})
|
|
908
|
+
client := server.NewClient(filepath.Join(root, "server.sock"))
|
|
909
|
+
waitForServer(t, client)
|
|
910
|
+
select {
|
|
911
|
+
case err := <-wait:
|
|
912
|
+
t.Fatalf("foreground serve returned before stop: %v", err)
|
|
913
|
+
case <-time.After(200 * time.Millisecond):
|
|
914
|
+
}
|
|
915
|
+
stop := exec.Command(binary, "stop")
|
|
916
|
+
stop.Env = os.Environ()
|
|
917
|
+
if out, err := stop.CombinedOutput(); err != nil {
|
|
918
|
+
t.Fatalf("stop: %v\n%s", err, out)
|
|
919
|
+
}
|
|
920
|
+
select {
|
|
921
|
+
case err := <-wait:
|
|
922
|
+
if err != nil {
|
|
923
|
+
t.Fatalf("foreground serve exit: %v", err)
|
|
924
|
+
}
|
|
925
|
+
case <-time.After(5 * time.Second):
|
|
926
|
+
t.Fatal("foreground serve did not exit after stop")
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
func buildCLIBinary(t *testing.T) string {
|
|
931
|
+
t.Helper()
|
|
932
|
+
binary := filepath.Join(t.TempDir(), "relay-flow")
|
|
933
|
+
cmd := exec.Command("go", "build", "-buildvcs=false", "-o", binary, ".")
|
|
934
|
+
out, err := cmd.CombinedOutput()
|
|
935
|
+
if err != nil {
|
|
936
|
+
t.Fatalf("build relay-flow: %v\n%s", err, out)
|
|
937
|
+
}
|
|
938
|
+
return binary
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
func waitForServer(t *testing.T, client *server.Client) {
|
|
942
|
+
t.Helper()
|
|
943
|
+
deadline := time.Now().Add(5 * time.Second)
|
|
944
|
+
for time.Now().Before(deadline) {
|
|
945
|
+
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
946
|
+
_, err := client.ListRepos(ctx)
|
|
947
|
+
cancel()
|
|
948
|
+
if err == nil {
|
|
949
|
+
return
|
|
950
|
+
}
|
|
951
|
+
time.Sleep(20 * time.Millisecond)
|
|
952
|
+
}
|
|
953
|
+
t.Fatal("server did not become ready")
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
func waitForServerStop(t *testing.T, client *server.Client) {
|
|
957
|
+
t.Helper()
|
|
958
|
+
deadline := time.Now().Add(5 * time.Second)
|
|
959
|
+
for time.Now().Before(deadline) {
|
|
960
|
+
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
961
|
+
_, err := client.ListRepos(ctx)
|
|
962
|
+
cancel()
|
|
963
|
+
if err != nil {
|
|
964
|
+
return
|
|
965
|
+
}
|
|
966
|
+
time.Sleep(20 * time.Millisecond)
|
|
967
|
+
}
|
|
968
|
+
t.Fatal("server remained reachable after stop")
|
|
391
969
|
}
|
|
392
970
|
|
|
393
971
|
var errReportInvalid = errors.New("invalid report")
|