test: fold near-identical test bodies into tables across eight packages (#29463)

Folds groups of near-identical test bodies (same parent, same parallel
flag, differing only in inputs and expected values) into table-driven
tests in eight packages: `coderd/templatebuilder`, `scripts/develop`,
`agent/agentfiles`, `coderd/x/chatd/chatloop`,
`coderd/x/chatd/chatdebug`, `enterprise/aibridgeproxyd`, `cli`, and
`coderd/database/db2sdk`. One commit per package.

Every case keeps its previous subtest name, `t.Parallel()` placement is
unchanged, and the set of `=== RUN` names is identical except where
standalone `TestX_Y` funcs became `TestX/Y` subtests (chatloop: 9 funcs
into `TestExecuteLocalTools_BatchRuntime`; chatdebug: 15 funcs into two
tables). Members whose bodies differ in more than data stayed as they
were (listed per package in the commit messages). In `agent/agentfiles`
the four `TestFuzzyReplace_*` funcs were already tables, so the shared
request plumbing was hoisted into one helper instead of merging the
funcs and losing their per-rule docs. Net -1035 lines across 9 test
files; runtime is unchanged (all sub-100ms unit tests).

| Package | Diff | Funcs removed | Subtests | RUN lines |
|---|---|---|---|---|
| coderd/templatebuilder | +237 / -291 | 0 | 31 -> 31 | 33 -> 33 |
| scripts/develop | +194 / -246 | 0 | 27 -> 27 | 28 -> 28 |
| agent/agentfiles | +117 / -260 | 0 | unchanged | 58 -> 58 |
| coderd/x/chatd/chatloop | +224 / -306 | 9 | 3 -> 12 | 14 -> 15 |
| coderd/x/chatd/chatdebug | +251 / -537 | 13 | 0 -> 15 | 42 -> 44 |
| enterprise/aibridgeproxyd | +287 / -419 | 0 | 31 -> 31 | 32 -> 32 |
| cli | +143 / -326 | 0 | 15 -> 15 | 16 -> 16 |
| coderd/database/db2sdk | +130 / -233 | 0 | 9 -> 9 | 10 -> 10 |

## Validation

- Per package: `go test -count=1 -tags=testsmallbatch -parallel=8
./<pkg>/ -run <regex> -v` before and after, with the `=== RUN` name sets
diffed
- `go test -race -count=2 -shuffle=on` on each converted test: pass
- `golangci-lint run ./<pkg>/...` with a fresh cache: 0 issues in all
eight packages
- Repository pre-commit hook (including lint/go) passed on every commit

Part of the test-suite speedup series (see #29457); independent of the
other PRs.

## Remote validation

Remote run on a dogfood workspace (separate compute), chat
https://dogfood.cdr.dev/agents/5bbe73d0-866a-4841-9cc3-c5e5b4d97e44,
tested head `d7c55d6b0e8` against base `9d973372774`: all eight packages
pass on the head and under `-race -count=3 -shuffle=on`; package times
are sub-second on both sides (unchanged). `=== RUN` name sets: chatloop
15 to 16 lines with all nine `TestExecuteLocalTools_<X>` leaves
surviving as `TestExecuteLocalTools_BatchRuntime/<X>`; chatdebug 42 to
44; `aibridgeproxyd` `TestNew` 32 to 32, byte-identical sorted list. One
correction to the text above: seven chatdebug leaves are renamed, not
preserved verbatim (`TestRecordingTransport_*UnknownLength*` became
`TestRecordingTransport_UnknownLengthClose/<name without
"UnknownLength">`), so `-run` filters keyed on those old names need
updating. Endorsed verdict: PASS.


> Xum (an AI agent) authored this PR on behalf of @ibetitsmike.
This commit is contained in:
Michael Suchacz
2026-09-17 19:26:18 +02:00
committed by GitHub
parent bde4f68c65
commit 26e4429362
9 changed files with 1573 additions and 2608 deletions
+117 -260
View File
@@ -1793,6 +1793,50 @@ func runEditFiles(t *testing.T, api *agentfiles.API, req workspacesdk.FileEditRe
return resp
}
// fuzzyEdit is one search/replace pair for the fuzzy matcher tables.
type fuzzyEdit struct {
search, replace string
replaceAll bool
}
// applyFuzzyEdits writes content to a fresh in-memory file, posts edits
// against it through the HTTP handler, and returns the recorded response
// together with the file's bytes afterwards.
func applyFuzzyEdits(t *testing.T, name, content string, edits []fuzzyEdit) (*httptest.ResponseRecorder, string) {
t.Helper()
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
fs := afero.NewMemMapFs()
api := agentfiles.NewAPI(logger, fs, nil)
path := filepath.Join(os.TempDir(), name)
require.NoError(t, afero.WriteFile(fs, path, []byte(content), 0o644))
sdkEdits := make([]workspacesdk.FileEdit, 0, len(edits))
for _, e := range edits {
sdkEdits = append(sdkEdits, workspacesdk.FileEdit{
OldText: e.search,
NewText: e.replace,
ReplaceAll: e.replaceAll,
})
}
req := workspacesdk.FileEditRequest{
Files: []workspacesdk.FileEdits{{Path: path, Edits: sdkEdits}},
}
ctx := testutil.Context(t, testutil.WaitShort)
buf := bytes.NewBuffer(nil)
enc := json.NewEncoder(buf)
enc.SetEscapeHTML(false)
require.NoError(t, enc.Encode(req))
w := httptest.NewRecorder()
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
api.Routes().ServeHTTP(w, r)
data, err := afero.ReadFile(fs, path)
require.NoError(t, err)
return w, string(data)
}
// TestFuzzyReplace_EndingAndWhitespace exercises the line-endings
// and per-position whitespace behavior of the fuzzy matcher in
// both single-replace and replace-all modes.
@@ -1815,17 +1859,10 @@ func runEditFiles(t *testing.T, api *agentfiles.API, req workspacesdk.FileEditRe
func TestFuzzyReplace_EndingAndWhitespace(t *testing.T) {
t.Parallel()
tmpdir := os.TempDir()
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
type edit struct {
search, replace string
replaceAll bool
}
tests := []struct {
name string
content string
edits []edit
edits []fuzzyEdit
expected string
}{
// CRLF file, LF search: the ending rule lets "line\n"
@@ -1834,7 +1871,7 @@ func TestFuzzyReplace_EndingAndWhitespace(t *testing.T) {
{
name: "CRLF_Content_LFSearch_Delete",
content: "foo\r\nline\r\nbar\r\n",
edits: []edit{{search: "line\n", replace: ""}},
edits: []fuzzyEdit{{search: "line\n", replace: ""}},
expected: "foo\r\nbar\r\n",
},
// Pass 2 tolerates the file's trailing whitespace on
@@ -1843,7 +1880,7 @@ func TestFuzzyReplace_EndingAndWhitespace(t *testing.T) {
{
name: "TrailingWhitespace_Delete",
content: "foo\nline \nbar\n",
edits: []edit{{search: "line\n", replace: ""}},
edits: []fuzzyEdit{{search: "line\n", replace: ""}},
expected: "foo\nbar\n",
},
// Pass 1 handles a search without a trailing newline
@@ -1853,7 +1890,7 @@ func TestFuzzyReplace_EndingAndWhitespace(t *testing.T) {
{
name: "Pass1_SearchNoNewline_ExactSubstring",
content: "foo\nfirst line\nbar\n",
edits: []edit{{search: "first line", replace: "LINE"}},
edits: []fuzzyEdit{{search: "first line", replace: "LINE"}},
expected: "foo\nLINE\nbar\n",
},
// Fuzzy path, both search and replace lack a newline
@@ -1868,14 +1905,14 @@ func TestFuzzyReplace_EndingAndWhitespace(t *testing.T) {
{
name: "FuzzyMatchingWhitespace_FileEndingWins",
content: "foo\nkey\nbar\n",
edits: []edit{{search: "key ", replace: "KEY "}},
edits: []fuzzyEdit{{search: "key ", replace: "KEY "}},
expected: "foo\nKEY\nbar\n",
},
// Last-line-no-newline uses pass 1 exact match.
{
name: "Pass1_LastLineNoNewline",
content: "foo\nbar",
edits: []edit{{search: "bar", replace: "BAR"}},
edits: []fuzzyEdit{{search: "bar", replace: "BAR"}},
expected: "foo\nBAR",
},
// Indent-tolerant matching on a CRLF file: search and
@@ -1899,19 +1936,19 @@ func TestFuzzyReplace_EndingAndWhitespace(t *testing.T) {
{
name: "FuzzyIndent_CRLF_TwoSpaceSearch_FileTabWins",
content: "foo\r\n\tline\r\nbar\r\n",
edits: []edit{{search: " line\n", replace: " LINE\n"}},
edits: []fuzzyEdit{{search: " line\n", replace: " LINE\n"}},
expected: "foo\r\n\tLINE\r\nbar\r\n",
},
{
name: "FuzzyIndent_CRLF_SevenSpaceSearch_FileTabStillWins",
content: "foo\r\n\tline\r\nbar\r\n",
edits: []edit{{search: " line\n", replace: " LINE\n"}},
edits: []fuzzyEdit{{search: " line\n", replace: " LINE\n"}},
expected: "foo\r\n\tLINE\r\nbar\r\n",
},
{
name: "FuzzyIndent_CRLF_CallerRewritesIndent_ReplaceLeadingWins",
content: "foo\r\n\tline\r\nbar\r\n",
edits: []edit{{search: " line\n", replace: " LINE\n"}},
edits: []fuzzyEdit{{search: " line\n", replace: " LINE\n"}},
expected: "foo\r\n LINE\r\nbar\r\n",
},
@@ -1922,7 +1959,7 @@ func TestFuzzyReplace_EndingAndWhitespace(t *testing.T) {
// whitespace shape (""), and its "\n" ending.
name: "ReplaceAll_FuzzyMatchingWhitespace_FileEndingWins",
content: "key\nkey\nother\n",
edits: []edit{{search: "key ", replace: "KEY ", replaceAll: true}},
edits: []fuzzyEdit{{search: "key ", replace: "KEY ", replaceAll: true}},
expected: "KEY\nKEY\nother\n",
},
{
@@ -1930,7 +1967,7 @@ func TestFuzzyReplace_EndingAndWhitespace(t *testing.T) {
// the file's "\r\n" so the output is uniformly CRLF.
name: "ReplaceAll_CRLF_LFSearch_FileEndingWins",
content: "line one\r\nother\r\nline one\r\n",
edits: []edit{{search: "line one\n", replace: "LINE\n", replaceAll: true}},
edits: []fuzzyEdit{{search: "line one\n", replace: "LINE\n", replaceAll: true}},
expected: "LINE\r\nother\r\nLINE\r\n",
},
@@ -1942,7 +1979,7 @@ func TestFuzzyReplace_EndingAndWhitespace(t *testing.T) {
{
name: "CallerChosenFold",
content: "foo\nline\nbar\n",
edits: []edit{{search: "line\n", replace: "LINE"}},
edits: []fuzzyEdit{{search: "line\n", replace: "LINE"}},
expected: "foo\nLINEbar\n",
},
@@ -1954,7 +1991,7 @@ func TestFuzzyReplace_EndingAndWhitespace(t *testing.T) {
{
name: "CallerRewritesIndent_ReplaceLeadingWins",
content: "foo\n\tline\n\tbar\n",
edits: []edit{{search: "\tline\n", replace: " line\n"}},
edits: []fuzzyEdit{{search: "\tline\n", replace: " line\n"}},
expected: "foo\n line\n\tbar\n",
},
@@ -1967,7 +2004,7 @@ func TestFuzzyReplace_EndingAndWhitespace(t *testing.T) {
{
name: "Expansion_ExtraLinesTrackLastPair",
content: "foo\n\tline\nbar\n",
edits: []edit{{search: " line\n", replace: " line\n extra\n"}},
edits: []fuzzyEdit{{search: " line\n", replace: " line\n extra\n"}},
expected: "foo\n\tline\n\textra\nbar\n",
},
@@ -1977,7 +2014,7 @@ func TestFuzzyReplace_EndingAndWhitespace(t *testing.T) {
{
name: "Collapse_ReplaceShorterThanSearch",
content: "foo\nkeep\ndrop\nbar\n",
edits: []edit{{search: "keep\ndrop\n", replace: "keep\n"}},
edits: []fuzzyEdit{{search: "keep\ndrop\n", replace: "keep\n"}},
expected: "foo\nkeep\nbar\n",
},
@@ -1992,7 +2029,7 @@ func TestFuzzyReplace_EndingAndWhitespace(t *testing.T) {
{
name: "EmptyEndingWildcard_CRLFContent_FileEndingWins",
content: "foo\r\nkey\r\nbar\r\n",
edits: []edit{{search: " key", replace: "KEY"}},
edits: []fuzzyEdit{{search: " key", replace: "KEY"}},
expected: "foo\r\nKEY\r\nbar\r\n",
},
@@ -2003,7 +2040,7 @@ func TestFuzzyReplace_EndingAndWhitespace(t *testing.T) {
{
name: "MultiLineReplaceAtEOFNoNewline_InteriorLinesKeepNewline",
content: "foo\nbar",
edits: []edit{{search: "foo\nbar\n", replace: "foo\nbaz\nqux\n"}},
edits: []fuzzyEdit{{search: "foo\nbar\n", replace: "foo\nbaz\nqux\n"}},
expected: "foo\nbaz\nqux",
},
@@ -2013,7 +2050,7 @@ func TestFuzzyReplace_EndingAndWhitespace(t *testing.T) {
{
name: "EmptyBodyFuzzyReplace_NoWhitespaceGhost",
content: "prefix\n code \nsuffix\n",
edits: []edit{{search: "code\n", replace: "\n"}},
edits: []fuzzyEdit{{search: "code\n", replace: "\n"}},
expected: "prefix\n\nsuffix\n",
},
@@ -2026,7 +2063,7 @@ func TestFuzzyReplace_EndingAndWhitespace(t *testing.T) {
{
name: "EmptyBodyInteriorAtEOFNoNewline_BothCarveOuts",
content: "foo\nbar",
edits: []edit{{search: "foo\nbar\n", replace: "mid1\n\nmid2\n"}},
edits: []fuzzyEdit{{search: "foo\nbar\n", replace: "mid1\n\nmid2\n"}},
expected: "mid1\n\nmid2",
},
}
@@ -2035,36 +2072,9 @@ func TestFuzzyReplace_EndingAndWhitespace(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
api := agentfiles.NewAPI(logger, fs, nil)
path := filepath.Join(tmpdir, "fuzzy-"+tt.name)
require.NoError(t, afero.WriteFile(fs, path, []byte(tt.content), 0o644))
sdkEdits := make([]workspacesdk.FileEdit, 0, len(tt.edits))
for _, e := range tt.edits {
sdkEdits = append(sdkEdits, workspacesdk.FileEdit{
OldText: e.search,
NewText: e.replace,
ReplaceAll: e.replaceAll,
})
}
req := workspacesdk.FileEditRequest{
Files: []workspacesdk.FileEdits{{Path: path, Edits: sdkEdits}},
}
ctx := testutil.Context(t, testutil.WaitShort)
buf := bytes.NewBuffer(nil)
enc := json.NewEncoder(buf)
enc.SetEscapeHTML(false)
require.NoError(t, enc.Encode(req))
w := httptest.NewRecorder()
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
api.Routes().ServeHTTP(w, r)
w, got := applyFuzzyEdits(t, "fuzzy-"+tt.name, tt.content, tt.edits)
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
data, err := afero.ReadFile(fs, path)
require.NoError(t, err)
require.Equal(t, tt.expected, string(data))
require.Equal(t, tt.expected, got)
})
}
}
@@ -2084,17 +2094,10 @@ func TestFuzzyReplace_EndingAndWhitespace(t *testing.T) {
func TestFuzzyReplace_EndingNormalization(t *testing.T) {
t.Parallel()
tmpdir := os.TempDir()
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
type edit struct {
search, replace string
replaceAll bool
}
tests := []struct {
name string
content string
edits []edit
edits []fuzzyEdit
expected string
}{
// CRLF file, LF search, LF replace with expansion.
@@ -2103,7 +2106,7 @@ func TestFuzzyReplace_EndingNormalization(t *testing.T) {
{
name: "CRLFFile_LFSearchReplace_Expansion",
content: "line1\r\nline2\r\nline3\r\n",
edits: []edit{{search: "line1\nline2\n", replace: "line1\nINSERTED\nline2\n"}},
edits: []fuzzyEdit{{search: "line1\nline2\n", replace: "line1\nINSERTED\nline2\n"}},
expected: "line1\r\nINSERTED\r\nline2\r\nline3\r\n",
},
// CRLF file with no trailing newline, LF search/replace
@@ -2113,7 +2116,7 @@ func TestFuzzyReplace_EndingNormalization(t *testing.T) {
{
name: "CRLFFileNoEOL_LFSearchReplace_ExpansionAtEOF",
content: "alpha\r\nbeta\r\ngamma",
edits: []edit{{search: "gamma", replace: "gamma\ndelta\nepsilon"}},
edits: []fuzzyEdit{{search: "gamma", replace: "gamma\ndelta\nepsilon"}},
expected: "alpha\r\nbeta\r\ngamma\r\ndelta\r\nepsilon",
},
// CRLF Go file with no final newline; LLM sends LF
@@ -2122,7 +2125,7 @@ func TestFuzzyReplace_EndingNormalization(t *testing.T) {
{
name: "CRLFFileNoEOL_LFCallerExpandsFunctionBody",
content: "package main\r\n\r\nfunc main() {\r\n\tprintln(\"hi\")\r\n}",
edits: []edit{{search: "\tprintln(\"hi\")\n}", replace: "\tprintln(\"hi\")\n\tprintln(\"bye\")\n\treturn\n}"}},
edits: []fuzzyEdit{{search: "\tprintln(\"hi\")\n}", replace: "\tprintln(\"hi\")\n\tprintln(\"bye\")\n\treturn\n}"}},
expected: "package main\r\n\r\nfunc main() {\r\n\tprintln(\"hi\")\r\n\tprintln(\"bye\")\r\n\treturn\r\n}",
},
// LF file, CRLF search/replace (caller sent CRLF, file is
@@ -2131,7 +2134,7 @@ func TestFuzzyReplace_EndingNormalization(t *testing.T) {
{
name: "LFFile_CRLFSearchReplace_FileLFWins",
content: "one\ntwo\nthree\n",
edits: []edit{{search: "one\r\ntwo\r\n", replace: "ONE\r\nTWO\r\n"}},
edits: []fuzzyEdit{{search: "one\r\ntwo\r\n", replace: "ONE\r\nTWO\r\n"}},
expected: "ONE\nTWO\nthree\n",
},
// Caller got endings right: CRLF in search, replace, and file.
@@ -2139,7 +2142,7 @@ func TestFuzzyReplace_EndingNormalization(t *testing.T) {
{
name: "CRLFFile_CRLFSearchReplace_SanityPreserved",
content: "a\r\nb\r\nc\r\n",
edits: []edit{{search: "a\r\nb\r\n", replace: "A\r\nB\r\n"}},
edits: []fuzzyEdit{{search: "a\r\nb\r\n", replace: "A\r\nB\r\n"}},
expected: "A\r\nB\r\nc\r\n",
},
// ReplaceAll with expansion on a CRLF file via LF caller.
@@ -2147,7 +2150,7 @@ func TestFuzzyReplace_EndingNormalization(t *testing.T) {
{
name: "ReplaceAll_CRLFFile_LFCaller_Expansion",
content: "key\r\nother\r\nkey\r\n",
edits: []edit{{
edits: []fuzzyEdit{{
search: "key\n",
replace: "KEY\nEXTRA\n",
replaceAll: true,
@@ -2163,7 +2166,7 @@ func TestFuzzyReplace_EndingNormalization(t *testing.T) {
{
name: "CallerIntent_SearchMatchesFile_ReplaceEndingsHonored",
content: "x\r\ny\r\nz\r\n",
edits: []edit{{search: "x\r\ny\r\n", replace: "X\nY\n"}},
edits: []fuzzyEdit{{search: "x\r\ny\r\n", replace: "X\nY\n"}},
expected: "X\nY\nz\r\n",
},
// Single-line search against a CRLF file, multi-line
@@ -2172,7 +2175,7 @@ func TestFuzzyReplace_EndingNormalization(t *testing.T) {
{
name: "SingleLineSearch_MultiLineReplace_FileEndingWins",
content: "a\r\nx\r\nb\r\n",
edits: []edit{{search: "x", replace: "X\nY"}},
edits: []fuzzyEdit{{search: "x", replace: "X\nY"}},
expected: "a\r\nX\r\nY\r\nb\r\n",
},
// Trivial baseline: neither side has endings, nothing to
@@ -2180,7 +2183,7 @@ func TestFuzzyReplace_EndingNormalization(t *testing.T) {
{
name: "SingleLineSearch_SingleLineReplace_NoEndingsToNormalize",
content: "a\r\nx\r\nb\r\n",
edits: []edit{{search: "x", replace: "X"}},
edits: []fuzzyEdit{{search: "x", replace: "X"}},
expected: "a\r\nX\r\nb\r\n",
},
}
@@ -2189,36 +2192,9 @@ func TestFuzzyReplace_EndingNormalization(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
api := agentfiles.NewAPI(logger, fs, nil)
path := filepath.Join(tmpdir, "endnorm-"+tt.name)
require.NoError(t, afero.WriteFile(fs, path, []byte(tt.content), 0o644))
sdkEdits := make([]workspacesdk.FileEdit, 0, len(tt.edits))
for _, e := range tt.edits {
sdkEdits = append(sdkEdits, workspacesdk.FileEdit{
OldText: e.search,
NewText: e.replace,
ReplaceAll: e.replaceAll,
})
}
req := workspacesdk.FileEditRequest{
Files: []workspacesdk.FileEdits{{Path: path, Edits: sdkEdits}},
}
ctx := testutil.Context(t, testutil.WaitShort)
buf := bytes.NewBuffer(nil)
enc := json.NewEncoder(buf)
enc.SetEscapeHTML(false)
require.NoError(t, enc.Encode(req))
w := httptest.NewRecorder()
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
api.Routes().ServeHTTP(w, r)
w, got := applyFuzzyEdits(t, "endnorm-"+tt.name, tt.content, tt.edits)
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
data, err := afero.ReadFile(fs, path)
require.NoError(t, err)
require.Equal(t, tt.expected, string(data))
require.Equal(t, tt.expected, got)
})
}
}
@@ -2229,16 +2205,10 @@ func TestFuzzyReplace_EndingNormalization(t *testing.T) {
func TestFuzzyReplace_FuzzyCollapse_PreservesNextLine(t *testing.T) {
t.Parallel()
tmpdir := os.TempDir()
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
type edit struct {
search, replace string
}
tests := []struct {
name string
content string
edits []edit
edits []fuzzyEdit
expected string
}{
// Minimal: tab-indented file, space-indented caller
@@ -2246,7 +2216,7 @@ func TestFuzzyReplace_FuzzyCollapse_PreservesNextLine(t *testing.T) {
{
name: "Minimal",
content: "\tone\n\ttwo\n\tthree\n\tafter\n",
edits: []edit{{
edits: []fuzzyEdit{{
search: " one\n two\n three\n",
replace: " ONE\n TWO\n",
}},
@@ -2268,7 +2238,7 @@ func TestFuzzyReplace_FuzzyCollapse_PreservesNextLine(t *testing.T) {
"\t\tvalid := codersdk.NameValid(str)\n" +
"\t\treturn valid == nil\n" +
"\t}\n",
edits: []edit{{
edits: []fuzzyEdit{{
search: " f := fl.Field().Interface()\n" +
" str, ok := f.(string)\n" +
" if !ok {\n" +
@@ -2292,35 +2262,9 @@ func TestFuzzyReplace_FuzzyCollapse_PreservesNextLine(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
api := agentfiles.NewAPI(logger, fs, nil)
path := filepath.Join(tmpdir, "fuzzycollapse-"+tt.name)
require.NoError(t, afero.WriteFile(fs, path, []byte(tt.content), 0o644))
sdkEdits := make([]workspacesdk.FileEdit, 0, len(tt.edits))
for _, e := range tt.edits {
sdkEdits = append(sdkEdits, workspacesdk.FileEdit{
OldText: e.search,
NewText: e.replace,
})
}
req := workspacesdk.FileEditRequest{
Files: []workspacesdk.FileEdits{{Path: path, Edits: sdkEdits}},
}
ctx := testutil.Context(t, testutil.WaitShort)
buf := bytes.NewBuffer(nil)
enc := json.NewEncoder(buf)
enc.SetEscapeHTML(false)
require.NoError(t, enc.Encode(req))
w := httptest.NewRecorder()
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
api.Routes().ServeHTTP(w, r)
w, got := applyFuzzyEdits(t, "fuzzycollapse-"+tt.name, tt.content, tt.edits)
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
data, err := afero.ReadFile(fs, path)
require.NoError(t, err)
require.Equal(t, tt.expected, string(data))
require.Equal(t, tt.expected, got)
})
}
}
@@ -2332,9 +2276,6 @@ func TestFuzzyReplace_FuzzyCollapse_PreservesNextLine(t *testing.T) {
func TestEditFiles_WhitespaceAndLineEndings(t *testing.T) {
t.Parallel()
tmpdir := os.TempDir()
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
cases := []struct {
name string
content string
@@ -2441,45 +2382,22 @@ func TestEditFiles_WhitespaceAndLineEndings(t *testing.T) {
t.Run(ct.name, func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
api := agentfiles.NewAPI(logger, fs, nil)
path := filepath.Join(tmpdir, "ws-"+ct.name)
require.NoError(t, afero.WriteFile(fs, path, []byte(ct.content), 0o644))
req := workspacesdk.FileEditRequest{
Files: []workspacesdk.FileEdits{{
Path: path,
Edits: []workspacesdk.FileEdit{{
OldText: ct.search,
NewText: ct.replace,
ReplaceAll: ct.replaceAll,
}},
}},
}
ctx := testutil.Context(t, testutil.WaitShort)
buf := bytes.NewBuffer(nil)
enc := json.NewEncoder(buf)
enc.SetEscapeHTML(false)
require.NoError(t, enc.Encode(req))
w := httptest.NewRecorder()
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
api.Routes().ServeHTTP(w, r)
w, got := applyFuzzyEdits(t, "ws-"+ct.name, ct.content, []fuzzyEdit{{
search: ct.search,
replace: ct.replace,
replaceAll: ct.replaceAll,
}})
if ct.errSub != "" {
require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String())
got := &codersdk.Error{}
require.NoError(t, json.NewDecoder(w.Body).Decode(got))
require.ErrorContains(t, got, ct.errSub)
data, err := afero.ReadFile(fs, path)
require.NoError(t, err)
require.Equal(t, ct.content, string(data))
gotErr := &codersdk.Error{}
require.NoError(t, json.NewDecoder(w.Body).Decode(gotErr))
require.ErrorContains(t, gotErr, ct.errSub)
require.Equal(t, ct.content, got)
return
}
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
data, err := afero.ReadFile(fs, path)
require.NoError(t, err)
require.Equal(t, ct.expected, string(data))
require.Equal(t, ct.expected, got)
})
}
}
@@ -2503,17 +2421,10 @@ func TestEditFiles_WhitespaceAndLineEndings(t *testing.T) {
func TestFuzzyReplace_Rejects(t *testing.T) {
t.Parallel()
tmpdir := os.TempDir()
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
type edit struct {
search, replace string
replaceAll bool
}
tests := []struct {
name string
content string
edits []edit
edits []fuzzyEdit
errSub string
}{
// Empty search with replace_all=false: reject to prevent
@@ -2521,7 +2432,7 @@ func TestFuzzyReplace_Rejects(t *testing.T) {
{
name: "EmptySearch_Rejects",
content: "hello\n",
edits: []edit{{search: "", replace: "X"}},
edits: []fuzzyEdit{{search: "", replace: "X"}},
errSub: "old_text must not be empty",
},
// Empty search with replace_all=true: historically
@@ -2530,7 +2441,7 @@ func TestFuzzyReplace_Rejects(t *testing.T) {
{
name: "EmptySearch_ReplaceAll_Rejects",
content: "hello\n",
edits: []edit{{search: "", replace: "X", replaceAll: true}},
edits: []fuzzyEdit{{search: "", replace: "X", replaceAll: true}},
errSub: "old_text must not be empty",
},
// Ambiguous single-replace: 3 distinct matches, caller
@@ -2538,7 +2449,7 @@ func TestFuzzyReplace_Rejects(t *testing.T) {
{
name: "Ambiguous_SingleReplace_Rejects",
content: "a\na\na\nother\n",
edits: []edit{{search: "a", replace: "A"}},
edits: []fuzzyEdit{{search: "a", replace: "A"}},
errSub: "matches 3 occurrences",
},
// Search text does not appear anywhere in the file. All
@@ -2546,7 +2457,7 @@ func TestFuzzyReplace_Rejects(t *testing.T) {
{
name: "NotFound_Rejects",
content: "hello\nworld\n",
edits: []edit{{search: "nonexistent\n", replace: "X\n"}},
edits: []fuzzyEdit{{search: "nonexistent\n", replace: "X\n"}},
errSub: "old_text not found",
},
// Content mismatch that trimming cannot recover: search
@@ -2554,7 +2465,7 @@ func TestFuzzyReplace_Rejects(t *testing.T) {
{
name: "ContentMismatch_Rejects",
content: "hello\n",
edits: []edit{{search: "Hello\n", replace: "HELLO\n"}},
edits: []fuzzyEdit{{search: "Hello\n", replace: "HELLO\n"}},
errSub: "old_text not found",
},
// Blank lines in the file that the search omits: the
@@ -2563,7 +2474,7 @@ func TestFuzzyReplace_Rejects(t *testing.T) {
{
name: "BlankLineMismatch_Rejects",
content: "above\n\n\nbelow\n",
edits: []edit{{search: "above\nbelow\n", replace: "above\nbelow\n"}},
edits: []fuzzyEdit{{search: "above\nbelow\n", replace: "above\nbelow\n"}},
errSub: "old_text not found",
},
// Search/replace disagreement signals intent to rewrite
@@ -2573,7 +2484,7 @@ func TestFuzzyReplace_Rejects(t *testing.T) {
{
name: "CallerIntent_SearchDoesNotMatchFileEnding_Rejects",
content: "x\r\ny\r\nz\r\n",
edits: []edit{{search: "x\ny\n", replace: "X\r\nY\r\n"}},
edits: []fuzzyEdit{{search: "x\ny\n", replace: "X\r\nY\r\n"}},
errSub: "old_text not found",
},
}
@@ -2582,42 +2493,15 @@ func TestFuzzyReplace_Rejects(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
api := agentfiles.NewAPI(logger, fs, nil)
path := filepath.Join(tmpdir, "reject-"+tt.name)
require.NoError(t, afero.WriteFile(fs, path, []byte(tt.content), 0o644))
sdkEdits := make([]workspacesdk.FileEdit, 0, len(tt.edits))
for _, e := range tt.edits {
sdkEdits = append(sdkEdits, workspacesdk.FileEdit{
OldText: e.search,
NewText: e.replace,
ReplaceAll: e.replaceAll,
})
}
req := workspacesdk.FileEditRequest{
Files: []workspacesdk.FileEdits{{Path: path, Edits: sdkEdits}},
}
ctx := testutil.Context(t, testutil.WaitShort)
buf := bytes.NewBuffer(nil)
enc := json.NewEncoder(buf)
enc.SetEscapeHTML(false)
require.NoError(t, enc.Encode(req))
w := httptest.NewRecorder()
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
api.Routes().ServeHTTP(w, r)
w, got := applyFuzzyEdits(t, "reject-"+tt.name, tt.content, tt.edits)
require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String())
got := &codersdk.Error{}
require.NoError(t, json.NewDecoder(w.Body).Decode(got))
require.ErrorContains(t, got, tt.errSub)
gotErr := &codersdk.Error{}
require.NoError(t, json.NewDecoder(w.Body).Decode(gotErr))
require.ErrorContains(t, gotErr, tt.errSub)
// File must not have been modified by any partial
// splice or write.
data, err := afero.ReadFile(fs, path)
require.NoError(t, err)
require.Equal(t, tt.content, string(data))
require.Equal(t, tt.content, got)
})
}
}
@@ -2835,17 +2719,10 @@ func TestEditFiles_ReplaceAll_FuzzyIndentGap(t *testing.T) {
func TestEditFiles_FuzzyIndent_InsertionLevelAware(t *testing.T) {
t.Parallel()
tmpdir := os.TempDir()
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
type edit struct {
search, replace string
replaceAll bool
}
tests := []struct {
name string
content string
edits []edit
edits []fuzzyEdit
expected string
}{
// Wrap an existing line in a new block. Tab file, 4sp caller.
@@ -2855,7 +2732,7 @@ func TestEditFiles_FuzzyIndent_InsertionLevelAware(t *testing.T) {
"\tfmt.Println(\"hello\")\n" +
"\tfmt.Println(\"world\")\n" +
"}\n",
edits: []edit{{
edits: []fuzzyEdit{{
search: " fmt.Println(\"hello\")\n" +
" fmt.Println(\"world\")",
replace: " fmt.Println(\"hello\")\n" +
@@ -2879,7 +2756,7 @@ func TestEditFiles_FuzzyIndent_InsertionLevelAware(t *testing.T) {
" console.log('hello')\n" +
" console.log('world')\n" +
"}\n",
edits: []edit{{
edits: []fuzzyEdit{{
search: " console.log('hello')\n" +
" console.log('world')",
replace: " console.log('hello')\n" +
@@ -2902,7 +2779,7 @@ func TestEditFiles_FuzzyIndent_InsertionLevelAware(t *testing.T) {
"\tx := getValue()\n" +
"\tfmt.Println(x)\n" +
"}\n",
edits: []edit{{
edits: []fuzzyEdit{{
search: " x := getValue()",
replace: " x, err := getValue()\n" +
" if err != nil {\n" +
@@ -2930,7 +2807,7 @@ func TestEditFiles_FuzzyIndent_InsertionLevelAware(t *testing.T) {
"\n" +
"\treturn \u0026cfg, nil\n" +
"}\n",
edits: []edit{{
edits: []fuzzyEdit{{
search: " var cfg Config\n" +
" err = json.Unmarshal(data, \u0026cfg)\n" +
" if err != nil {\n" +
@@ -2976,7 +2853,7 @@ func TestEditFiles_FuzzyIndent_InsertionLevelAware(t *testing.T) {
"\t\tprocess(data)\n" +
"\t})\n" +
"}\n",
edits: []edit{{
edits: []fuzzyEdit{{
search: " data := readBody(r)\n" +
" process(data)",
replace: " data := readBody(r)\n" +
@@ -3014,7 +2891,7 @@ func TestEditFiles_FuzzyIndent_InsertionLevelAware(t *testing.T) {
"\t\tdoMore()\n" +
"\t}\n" +
"}\n",
edits: []edit{{
edits: []fuzzyEdit{{
search: " if condition {\n" +
" doSomething()\n" +
" doMore()\n" +
@@ -3043,7 +2920,7 @@ func TestEditFiles_FuzzyIndent_InsertionLevelAware(t *testing.T) {
"\t}\n" +
"\treturn results\n" +
"}\n",
edits: []edit{{
edits: []fuzzyEdit{{
search: " var results []Result\n" +
" for _, item := range items {\n" +
" if item.Valid {\n" +
@@ -3088,7 +2965,7 @@ func TestEditFiles_FuzzyIndent_InsertionLevelAware(t *testing.T) {
" }\n" +
" return results;\n" +
"}\n",
edits: []edit{{
edits: []fuzzyEdit{{
search: " const results = [];\n" +
" for (const item of items) {\n" +
" if (item.valid) {\n" +
@@ -3126,29 +3003,9 @@ func TestEditFiles_FuzzyIndent_InsertionLevelAware(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
api := agentfiles.NewAPI(logger, fs, nil)
path := filepath.Join(tmpdir, "fuzzyindent-"+tt.name)
require.NoError(t, afero.WriteFile(fs, path, []byte(tt.content), 0o644))
req := workspacesdk.FileEditRequest{
Files: []workspacesdk.FileEdits{{
Path: path,
Edits: make([]workspacesdk.FileEdit, 0, len(tt.edits)),
}},
}
for _, e := range tt.edits {
req.Files[0].Edits = append(req.Files[0].Edits, workspacesdk.FileEdit{
OldText: e.search,
NewText: e.replace,
ReplaceAll: e.replaceAll,
})
}
_ = runEditFiles(t, api, req)
data, err := afero.ReadFile(fs, path)
require.NoError(t, err)
require.Equal(t, tt.expected, string(data))
w, got := applyFuzzyEdits(t, "fuzzyindent-"+tt.name, tt.content, tt.edits)
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
require.Equal(t, tt.expected, got)
})
}
}
+141 -324
View File
@@ -50,41 +50,152 @@ func setupSocketServer(t *testing.T) (path string, cleanup func()) {
func TestSyncCommands_Golden(t *testing.T) {
t.Parallel()
t.Run("ping", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()
// Each case seeds the socket server through a client, runs one sync
// command against it, and compares the output with a golden file.
cases := []struct {
name string
seed func(t *testing.T, ctx context.Context, client *agentsocket.Client)
args []string
golden string
}{
{
name: "ping",
args: []string{"ping"},
golden: "ping_success",
},
{
name: "start_no_dependencies",
args: []string{"start", "test-unit"},
golden: "start_no_dependencies",
},
{
// test-unit depends on dep-unit and dep-unit-2, both already complete.
name: "start_with_satisfied_dependencies",
seed: func(t *testing.T, ctx context.Context, client *agentsocket.Client) {
require.NoError(t, client.SyncWant(ctx, "test-unit", "dep-unit"))
require.NoError(t, client.SyncWant(ctx, "test-unit", "dep-unit-2"))
require.NoError(t, client.SyncStart(ctx, "dep-unit"))
require.NoError(t, client.SyncComplete(ctx, "dep-unit"))
require.NoError(t, client.SyncStart(ctx, "dep-unit-2"))
require.NoError(t, client.SyncComplete(ctx, "dep-unit-2"))
},
args: []string{"start", "test-unit"},
golden: "start_with_satisfied_dependencies",
},
{
name: "want",
args: []string{"want", "test-unit", "dep-unit"},
golden: "want_success",
},
{
name: "complete",
seed: func(t *testing.T, ctx context.Context, client *agentsocket.Client) {
require.NoError(t, client.SyncStart(ctx, "test-unit"))
},
args: []string{"complete", "test-unit"},
golden: "complete_success",
},
{
// A unit with an unsatisfied dependency.
name: "status_pending",
seed: func(t *testing.T, ctx context.Context, client *agentsocket.Client) {
require.NoError(t, client.SyncWant(ctx, "test-unit", "dep-unit"))
},
args: []string{"status", "test-unit"},
golden: "status_pending",
},
{
name: "status_started",
seed: func(t *testing.T, ctx context.Context, client *agentsocket.Client) {
require.NoError(t, client.SyncStart(ctx, "test-unit"))
},
args: []string{"status", "test-unit"},
golden: "status_started",
},
{
name: "status_completed",
seed: func(t *testing.T, ctx context.Context, client *agentsocket.Client) {
require.NoError(t, client.SyncStart(ctx, "test-unit"))
require.NoError(t, client.SyncComplete(ctx, "test-unit"))
},
args: []string{"status", "test-unit"},
golden: "status_completed",
},
{
// dep-1 is complete, dep-2 is not.
name: "status_with_dependencies",
seed: func(t *testing.T, ctx context.Context, client *agentsocket.Client) {
require.NoError(t, client.SyncWant(ctx, "test-unit", "dep-1"))
require.NoError(t, client.SyncWant(ctx, "test-unit", "dep-2"))
require.NoError(t, client.SyncStart(ctx, "dep-1"))
require.NoError(t, client.SyncComplete(ctx, "dep-1"))
},
args: []string{"status", "test-unit"},
golden: "status_with_dependencies",
},
{
name: "status_json_format",
seed: func(t *testing.T, ctx context.Context, client *agentsocket.Client) {
require.NoError(t, client.SyncWant(ctx, "test-unit", "dep-unit"))
require.NoError(t, client.SyncStart(ctx, "dep-unit"))
require.NoError(t, client.SyncComplete(ctx, "dep-unit"))
},
args: []string{"status", "test-unit", "--output", "json"},
golden: "status_json_format",
},
{
name: "list_no_units",
args: []string{"list"},
golden: "list_no_units",
},
{
// unit-a started, unit-b completed, unit-c pending on unit-a.
name: "list_with_units",
seed: func(t *testing.T, ctx context.Context, client *agentsocket.Client) {
require.NoError(t, client.SyncStart(ctx, "unit-a"))
require.NoError(t, client.SyncStart(ctx, "unit-b"))
require.NoError(t, client.SyncComplete(ctx, "unit-b"))
require.NoError(t, client.SyncWant(ctx, "unit-c", "unit-a"))
},
args: []string{"list"},
golden: "list_with_units",
},
{
name: "list_json_format",
seed: func(t *testing.T, ctx context.Context, client *agentsocket.Client) {
require.NoError(t, client.SyncStart(ctx, "my-unit"))
},
args: []string{"list", "--output", "json"},
golden: "list_json_format",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()
ctx := testutil.Context(t, testutil.WaitShort)
ctx := testutil.Context(t, testutil.WaitShort)
var outBuf bytes.Buffer
inv, _ := clitest.New(t, "exp", "sync", "ping", "--socket-path", path)
inv.Stdout = &outBuf
inv.Stderr = &outBuf
if tc.seed != nil {
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
require.NoError(t, err)
tc.seed(t, ctx, client)
client.Close()
}
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
var outBuf bytes.Buffer
args := append([]string{"exp", "sync"}, tc.args...)
inv, _ := clitest.New(t, append(args, "--socket-path", path)...)
inv.Stdout = &outBuf
inv.Stderr = &outBuf
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/ping_success", outBuf.Bytes(), nil)
})
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
t.Run("start_no_dependencies", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()
ctx := testutil.Context(t, testutil.WaitShort)
var outBuf bytes.Buffer
inv, _ := clitest.New(t, "exp", "sync", "start", "test-unit", "--socket-path", path)
inv.Stdout = &outBuf
inv.Stderr = &outBuf
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/start_no_dependencies", outBuf.Bytes(), nil)
})
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/"+tc.golden, outBuf.Bytes(), nil)
})
}
t.Run("start_with_dependencies", func(t *testing.T) {
t.Parallel()
@@ -158,60 +269,6 @@ func TestSyncCommands_Golden(t *testing.T) {
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/start_with_dependencies", outBuf.Bytes(), nil)
})
t.Run("start_with_satisfied_dependencies", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()
ctx := testutil.Context(t, testutil.WaitShort)
// Set up dependencies: test-unit depends on dep-unit and dep-unit-2.
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
require.NoError(t, err)
err = client.SyncWant(ctx, "test-unit", "dep-unit")
require.NoError(t, err)
err = client.SyncWant(ctx, "test-unit", "dep-unit-2")
require.NoError(t, err)
err = client.SyncStart(ctx, "dep-unit")
require.NoError(t, err)
err = client.SyncComplete(ctx, "dep-unit")
require.NoError(t, err)
err = client.SyncStart(ctx, "dep-unit-2")
require.NoError(t, err)
err = client.SyncComplete(ctx, "dep-unit-2")
require.NoError(t, err)
client.Close()
var outBuf bytes.Buffer
inv, _ := clitest.New(t, "exp", "sync", "start", "test-unit", "--socket-path", path)
inv.Stdout = &outBuf
inv.Stderr = &outBuf
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/start_with_satisfied_dependencies", outBuf.Bytes(), nil)
})
t.Run("want", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()
ctx := testutil.Context(t, testutil.WaitShort)
var outBuf bytes.Buffer
inv, _ := clitest.New(t, "exp", "sync", "want", "test-unit", "dep-unit", "--socket-path", path)
inv.Stdout = &outBuf
inv.Stderr = &outBuf
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/want_success", outBuf.Bytes(), nil)
})
t.Run("want_multiple_deps", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
@@ -246,244 +303,4 @@ func TestSyncCommands_Golden(t *testing.T) {
require.Contains(t, output, "dep-2")
require.Contains(t, output, "dep-3")
})
t.Run("complete", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()
ctx := testutil.Context(t, testutil.WaitShort)
// First start the unit
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
require.NoError(t, err)
err = client.SyncStart(ctx, "test-unit")
require.NoError(t, err)
client.Close()
var outBuf bytes.Buffer
inv, _ := clitest.New(t, "exp", "sync", "complete", "test-unit", "--socket-path", path)
inv.Stdout = &outBuf
inv.Stderr = &outBuf
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/complete_success", outBuf.Bytes(), nil)
})
t.Run("status_pending", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()
ctx := testutil.Context(t, testutil.WaitShort)
// Set up a unit with unsatisfied dependency
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
require.NoError(t, err)
err = client.SyncWant(ctx, "test-unit", "dep-unit")
require.NoError(t, err)
client.Close()
var outBuf bytes.Buffer
inv, _ := clitest.New(t, "exp", "sync", "status", "test-unit", "--socket-path", path)
inv.Stdout = &outBuf
inv.Stderr = &outBuf
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/status_pending", outBuf.Bytes(), nil)
})
t.Run("status_started", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()
ctx := testutil.Context(t, testutil.WaitShort)
// Start a unit
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
require.NoError(t, err)
err = client.SyncStart(ctx, "test-unit")
require.NoError(t, err)
client.Close()
var outBuf bytes.Buffer
inv, _ := clitest.New(t, "exp", "sync", "status", "test-unit", "--socket-path", path)
inv.Stdout = &outBuf
inv.Stderr = &outBuf
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/status_started", outBuf.Bytes(), nil)
})
t.Run("status_completed", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()
ctx := testutil.Context(t, testutil.WaitShort)
// Start and complete a unit
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
require.NoError(t, err)
err = client.SyncStart(ctx, "test-unit")
require.NoError(t, err)
err = client.SyncComplete(ctx, "test-unit")
require.NoError(t, err)
client.Close()
var outBuf bytes.Buffer
inv, _ := clitest.New(t, "exp", "sync", "status", "test-unit", "--socket-path", path)
inv.Stdout = &outBuf
inv.Stderr = &outBuf
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/status_completed", outBuf.Bytes(), nil)
})
t.Run("status_with_dependencies", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()
ctx := testutil.Context(t, testutil.WaitShort)
// Set up a unit with dependencies, some satisfied, some not
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
require.NoError(t, err)
err = client.SyncWant(ctx, "test-unit", "dep-1")
require.NoError(t, err)
err = client.SyncWant(ctx, "test-unit", "dep-2")
require.NoError(t, err)
// Complete dep-1, leave dep-2 incomplete
err = client.SyncStart(ctx, "dep-1")
require.NoError(t, err)
err = client.SyncComplete(ctx, "dep-1")
require.NoError(t, err)
client.Close()
var outBuf bytes.Buffer
inv, _ := clitest.New(t, "exp", "sync", "status", "test-unit", "--socket-path", path)
inv.Stdout = &outBuf
inv.Stderr = &outBuf
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/status_with_dependencies", outBuf.Bytes(), nil)
})
t.Run("status_json_format", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()
ctx := testutil.Context(t, testutil.WaitShort)
// Set up a unit with dependencies
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
require.NoError(t, err)
err = client.SyncWant(ctx, "test-unit", "dep-unit")
require.NoError(t, err)
err = client.SyncStart(ctx, "dep-unit")
require.NoError(t, err)
err = client.SyncComplete(ctx, "dep-unit")
require.NoError(t, err)
client.Close()
var outBuf bytes.Buffer
inv, _ := clitest.New(t, "exp", "sync", "status", "test-unit", "--output", "json", "--socket-path", path)
inv.Stdout = &outBuf
inv.Stderr = &outBuf
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/status_json_format", outBuf.Bytes(), nil)
})
t.Run("list_no_units", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()
ctx := testutil.Context(t, testutil.WaitShort)
var outBuf bytes.Buffer
inv, _ := clitest.New(t, "exp", "sync", "list", "--socket-path", path)
inv.Stdout = &outBuf
inv.Stderr = &outBuf
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/list_no_units", outBuf.Bytes(), nil)
})
t.Run("list_with_units", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()
ctx := testutil.Context(t, testutil.WaitShort)
// Register some units in various states.
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
require.NoError(t, err)
// unit-a: started
err = client.SyncStart(ctx, "unit-a")
require.NoError(t, err)
// unit-b: completed
err = client.SyncStart(ctx, "unit-b")
require.NoError(t, err)
err = client.SyncComplete(ctx, "unit-b")
require.NoError(t, err)
// unit-c: pending (has unsatisfied dependency on unit-a completing)
err = client.SyncWant(ctx, "unit-c", "unit-a")
require.NoError(t, err)
client.Close()
var outBuf bytes.Buffer
inv, _ := clitest.New(t, "exp", "sync", "list", "--socket-path", path)
inv.Stdout = &outBuf
inv.Stderr = &outBuf
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/list_with_units", outBuf.Bytes(), nil)
})
t.Run("list_json_format", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()
ctx := testutil.Context(t, testutil.WaitShort)
// Register a unit.
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
require.NoError(t, err)
err = client.SyncStart(ctx, "my-unit")
require.NoError(t, err)
client.Close()
var outBuf bytes.Buffer
inv, _ := clitest.New(t, "exp", "sync", "list", "--output", "json", "--socket-path", path)
inv.Stdout = &outBuf
inv.Stderr = &outBuf
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/list_json_format", outBuf.Bytes(), nil)
})
}
+118 -221
View File
@@ -14,239 +14,136 @@ import (
func TestAggregateTokenMetadata(t *testing.T) {
t.Parallel()
t.Run("empty_input", func(t *testing.T) {
t.Parallel()
result := aggregateTokenMetadata(nil)
require.Empty(t, result)
})
t.Run("sums_across_rows", func(t *testing.T) {
t.Parallel()
tokens := []database.AIBridgeTokenUsage{
{
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{"cache_read_tokens":100,"reasoning_tokens":50}`),
Valid: true,
},
cases := []struct {
name string
rows []pqtype.NullRawMessage
want map[string]any
}{
{
name: "empty_input",
want: map[string]any{},
},
{
name: "sums_across_rows",
rows: []pqtype.NullRawMessage{
{RawMessage: json.RawMessage(`{"cache_read_tokens":100,"reasoning_tokens":50}`), Valid: true},
{RawMessage: json.RawMessage(`{"cache_read_tokens":200,"reasoning_tokens":75}`), Valid: true},
},
{
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{"cache_read_tokens":200,"reasoning_tokens":75}`),
Valid: true,
},
want: map[string]any{
"cache_read_tokens": int64(300),
"reasoning_tokens": int64(125),
},
}
result := aggregateTokenMetadata(tokens)
require.Equal(t, int64(300), result["cache_read_tokens"])
require.Equal(t, int64(125), result["reasoning_tokens"])
require.Len(t, result, 2)
})
t.Run("skips_null_and_invalid_metadata", func(t *testing.T) {
t.Parallel()
tokens := []database.AIBridgeTokenUsage{
{
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{Valid: false},
},
{
name: "skips_null_and_invalid_metadata",
rows: []pqtype.NullRawMessage{
{Valid: false},
{RawMessage: nil, Valid: true},
{RawMessage: json.RawMessage(`{"tokens":42}`), Valid: true},
},
{
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
RawMessage: nil,
Valid: true,
},
want: map[string]any{"tokens": int64(42)},
},
{
// Float values fail json.Number.Int64(), so they are silently
// dropped.
name: "skips_non_integer_values",
rows: []pqtype.NullRawMessage{
{RawMessage: json.RawMessage(`{"good":10,"fractional":1.5}`), Valid: true},
},
{
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{"tokens":42}`),
Valid: true,
},
want: map[string]any{"good": int64(10)},
},
{
// The malformed row is skipped, the valid one is counted.
name: "skips_malformed_json",
rows: []pqtype.NullRawMessage{
{RawMessage: json.RawMessage(`not json`), Valid: true},
{RawMessage: json.RawMessage(`{"tokens":5}`), Valid: true},
},
}
result := aggregateTokenMetadata(tokens)
require.Equal(t, int64(42), result["tokens"])
require.Len(t, result, 1)
})
t.Run("skips_non_integer_values", func(t *testing.T) {
t.Parallel()
tokens := []database.AIBridgeTokenUsage{
{
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
// Float values fail json.Number.Int64(), so they
// are silently dropped.
RawMessage: json.RawMessage(`{"good":10,"fractional":1.5}`),
Valid: true,
},
want: map[string]any{"tokens": int64(5)},
},
{
// Arrays are skipped.
name: "flattens_nested_objects",
rows: []pqtype.NullRawMessage{
{RawMessage: json.RawMessage(`{
"cache_read_tokens": 100,
"cache": {"creation_tokens": 40, "read_tokens": 60},
"reasoning_tokens": 50,
"tags": ["a", "b"]
}`), Valid: true},
{RawMessage: json.RawMessage(`{
"cache_read_tokens": 200,
"cache": {"creation_tokens": 10}
}`), Valid: true},
},
}
result := aggregateTokenMetadata(tokens)
require.Equal(t, int64(10), result["good"])
_, hasFractional := result["fractional"]
require.False(t, hasFractional)
})
t.Run("skips_malformed_json", func(t *testing.T) {
t.Parallel()
tokens := []database.AIBridgeTokenUsage{
{
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`not json`),
Valid: true,
},
want: map[string]any{
"cache_read_tokens": int64(300),
"reasoning_tokens": int64(50),
"cache.creation_tokens": int64(50),
"cache.read_tokens": int64(60),
},
{
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{"tokens":5}`),
Valid: true,
},
},
{
name: "flattens_deeply_nested_objects",
rows: []pqtype.NullRawMessage{
{RawMessage: json.RawMessage(`{
"provider": {
"anthropic": {"cache_creation_tokens": 100, "cache_read_tokens": 200},
"openai": {"reasoning_tokens": 50}
},
"total": 500
}`), Valid: true},
},
}
result := aggregateTokenMetadata(tokens)
// The malformed row is skipped, the valid one is counted.
require.Equal(t, int64(5), result["tokens"])
require.Len(t, result, 1)
})
t.Run("flattens_nested_objects", func(t *testing.T) {
t.Parallel()
tokens := []database.AIBridgeTokenUsage{
{
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{
"cache_read_tokens": 100,
"cache": {"creation_tokens": 40, "read_tokens": 60},
"reasoning_tokens": 50,
"tags": ["a", "b"]
}`),
Valid: true,
},
want: map[string]any{
"provider.anthropic.cache_creation_tokens": int64(100),
"provider.anthropic.cache_read_tokens": int64(200),
"provider.openai.reasoning_tokens": int64(50),
"total": int64(500),
},
{
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{
"cache_read_tokens": 200,
"cache": {"creation_tokens": 10}
}`),
Valid: true,
},
},
{
// Real-world provider metadata shapes from
// https://github.com/coder/aibridge/issues/150: Anthropic keeps
// cache fields top-level and two rows sum, OpenAI nests them
// inside input_tokens_details and they flatten with dot notation.
name: "aggregates_real_provider_metadata",
rows: []pqtype.NullRawMessage{
{RawMessage: json.RawMessage(`{
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 23490
}`), Valid: true},
{RawMessage: json.RawMessage(`{
"input_tokens_details": {"cached_tokens": 11904}
}`), Valid: true},
{RawMessage: json.RawMessage(`{
"cache_creation_input_tokens": 500,
"cache_read_input_tokens": 10000
}`), Valid: true},
},
}
result := aggregateTokenMetadata(tokens)
require.Equal(t, int64(300), result["cache_read_tokens"])
require.Equal(t, int64(50), result["reasoning_tokens"])
require.Equal(t, int64(50), result["cache.creation_tokens"])
require.Equal(t, int64(60), result["cache.read_tokens"])
// Arrays are skipped.
_, hasTags := result["tags"]
require.False(t, hasTags)
require.Len(t, result, 4)
})
t.Run("flattens_deeply_nested_objects", func(t *testing.T) {
t.Parallel()
tokens := []database.AIBridgeTokenUsage{
{
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{
"provider": {
"anthropic": {"cache_creation_tokens": 100, "cache_read_tokens": 200},
"openai": {"reasoning_tokens": 50}
},
"total": 500
}`),
Valid: true,
},
want: map[string]any{
"cache_creation_input_tokens": int64(500),
"cache_read_input_tokens": int64(33490),
"input_tokens_details.cached_tokens": int64(11904),
},
}
result := aggregateTokenMetadata(tokens)
require.Equal(t, int64(100), result["provider.anthropic.cache_creation_tokens"])
require.Equal(t, int64(200), result["provider.anthropic.cache_read_tokens"])
require.Equal(t, int64(50), result["provider.openai.reasoning_tokens"])
require.Equal(t, int64(500), result["total"])
require.Len(t, result, 4)
})
// Real-world provider metadata shapes from
// https://github.com/coder/aibridge/issues/150.
t.Run("aggregates_real_provider_metadata", func(t *testing.T) {
t.Parallel()
tokens := []database.AIBridgeTokenUsage{
{
// Anthropic-style: cache fields are top-level.
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 23490
}`),
Valid: true,
},
},
{
name: "skips_string_boolean_null_values",
rows: []pqtype.NullRawMessage{
{RawMessage: json.RawMessage(`{"tokens":10,"name":"test","enabled":true,"nothing":null}`), Valid: true},
},
{
// OpenAI-style: cache fields are nested inside
// input_tokens_details.
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{
"input_tokens_details": {"cached_tokens": 11904}
}`),
Valid: true,
},
},
{
// Second Anthropic row to verify summing.
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{
"cache_creation_input_tokens": 500,
"cache_read_input_tokens": 10000
}`),
Valid: true,
},
},
}
result := aggregateTokenMetadata(tokens)
// Anthropic fields are summed across two rows.
require.Equal(t, int64(500), result["cache_creation_input_tokens"])
require.Equal(t, int64(33490), result["cache_read_input_tokens"])
// OpenAI nested field is flattened with dot notation.
require.Equal(t, int64(11904), result["input_tokens_details.cached_tokens"])
require.Len(t, result, 3)
})
t.Run("skips_string_boolean_null_values", func(t *testing.T) {
t.Parallel()
tokens := []database.AIBridgeTokenUsage{
{
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{"tokens":10,"name":"test","enabled":true,"nothing":null}`),
Valid: true,
},
},
}
result := aggregateTokenMetadata(tokens)
require.Equal(t, int64(10), result["tokens"])
require.Len(t, result, 1)
})
want: map[string]any{"tokens": int64(10)},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tokens := make([]database.AIBridgeTokenUsage, 0, len(tc.rows))
for _, metadata := range tc.rows {
tokens = append(tokens, database.AIBridgeTokenUsage{ID: uuid.New(), Metadata: metadata})
}
require.Equal(t, tc.want, aggregateTokenMetadata(tokens))
})
}
}
func TestAggregateTokenUsage(t *testing.T) {
+130 -163
View File
@@ -117,123 +117,6 @@ func TestParseBasesFromFS(t *testing.T) {
require.NotContains(t, b.Templates, "cloud-init/config.yaml.tftpl")
})
t.Run("RejectsMissingReadme", func(t *testing.T) {
t.Parallel()
fsys := fstest.MapFS{
"bases/bad/base.json": &fstest.MapFile{
Data: []byte(`{"id": "bad", "os": "linux"}`),
},
"bases/bad/main.tf.tmpl": &fstest.MapFile{
Data: []byte(`resource {}`),
},
}
_, err := parseBasesFromFS(fsys)
require.ErrorContains(t, err, "read README.md for base")
})
t.Run("RejectsDirWithoutManifest", func(t *testing.T) {
t.Parallel()
fsys := fstest.MapFS{
"bases/nobase/readme.txt": &fstest.MapFile{Data: []byte("hi")},
}
_, err := parseBasesFromFS(fsys)
require.ErrorContains(t, err, "read nobase/base.json")
})
t.Run("RejectsEmptyID", func(t *testing.T) {
t.Parallel()
fsys := fstest.MapFS{
"bases/bad/base.json": &fstest.MapFile{
Data: []byte(`{"id": "", "os": "linux"}`),
},
}
_, err := parseBasesFromFS(fsys)
require.ErrorContains(t, err, "empty id")
})
t.Run("RejectsDuplicateID", func(t *testing.T) {
t.Parallel()
fsys := fstest.MapFS{
"bases/a/base.json": &fstest.MapFile{
Data: []byte(`{"id": "dupe", "os": "linux"}`),
},
"bases/a/README.md": &fstest.MapFile{
Data: []byte("# A\n"),
},
"bases/b/base.json": &fstest.MapFile{
Data: []byte(`{"id": "dupe", "os": "linux"}`),
},
"bases/b/README.md": &fstest.MapFile{
Data: []byte("# B\n"),
},
}
_, err := parseBasesFromFS(fsys)
require.ErrorContains(t, err, "duplicate base id")
})
t.Run("RejectsUnknownOS", func(t *testing.T) {
t.Parallel()
fsys := fstest.MapFS{
"bases/bad/base.json": &fstest.MapFile{
Data: []byte(`{"id": "bad", "os": "beos"}`),
},
}
_, err := parseBasesFromFS(fsys)
require.ErrorContains(t, err, `unknown os "beos"`)
})
t.Run("RejectsUnknownField", func(t *testing.T) {
t.Parallel()
fsys := fstest.MapFS{
"bases/bad/base.json": &fstest.MapFile{
Data: []byte(`{"id": "bad", "os": "linux", "dispaly_name": "typo"}`),
},
}
_, err := parseBasesFromFS(fsys)
require.ErrorContains(t, err, "decode")
})
t.Run("RejectsInvalidJSON", func(t *testing.T) {
t.Parallel()
fsys := fstest.MapFS{
"bases/bad/base.json": &fstest.MapFile{
Data: []byte(`{not json`),
},
}
_, err := parseBasesFromFS(fsys)
require.ErrorContains(t, err, "decode")
})
t.Run("RejectsInvalidTemplate", func(t *testing.T) {
t.Parallel()
fsys := fstest.MapFS{
"bases/bad/base.json": &fstest.MapFile{
Data: []byte(`{"id": "bad", "os": "linux"}`),
},
"bases/bad/main.tf.tmpl": &fstest.MapFile{
Data: []byte(`{{ .Broken`),
},
}
_, err := parseBasesFromFS(fsys)
require.ErrorContains(t, err, "parse templates")
})
t.Run("AllowsEmptyOS", func(t *testing.T) {
t.Parallel()
@@ -294,57 +177,141 @@ resource "coder_agent" "gpu" {}`),
require.Equal(t, []BaseAgent{{Name: "main"}, {Name: "gpu", Default: true}}, agents)
})
t.Run("RejectsEmptyAgentName", func(t *testing.T) {
t.Parallel()
fsys := fstest.MapFS{
"bases/bad/base.json": &fstest.MapFile{
Data: []byte(`{"id": "bad", "os": "linux", "agents": [{"name": ""}]}`),
rejects := []struct {
name string
fsys fstest.MapFS
wantErr string
}{
{
name: "RejectsMissingReadme",
fsys: fstest.MapFS{
"bases/bad/base.json": &fstest.MapFile{
Data: []byte(`{"id": "bad", "os": "linux"}`),
},
"bases/bad/main.tf.tmpl": &fstest.MapFile{
Data: []byte(`resource {}`),
},
},
}
_, err := parseBasesFromFS(fsys)
require.ErrorContains(t, err, "empty name")
})
t.Run("RejectsDuplicateAgentName", func(t *testing.T) {
t.Parallel()
fsys := fstest.MapFS{
"bases/bad/base.json": &fstest.MapFile{
Data: []byte(`{"id": "bad", "os": "linux", "agents": [{"name": "main"}, {"name": "main"}]}`),
wantErr: "read README.md for base",
},
{
name: "RejectsDirWithoutManifest",
fsys: fstest.MapFS{
"bases/nobase/readme.txt": &fstest.MapFile{Data: []byte("hi")},
},
}
_, err := parseBasesFromFS(fsys)
require.ErrorContains(t, err, "duplicate agent name")
})
t.Run("RejectsMultipleDefaultAgents", func(t *testing.T) {
t.Parallel()
fsys := fstest.MapFS{
"bases/bad/base.json": &fstest.MapFile{
Data: []byte(`{"id": "bad", "os": "linux", "agents": [{"name": "a", "default": true}, {"name": "b", "default": true}]}`),
wantErr: "read nobase/base.json",
},
{
name: "RejectsEmptyID",
fsys: fstest.MapFS{
"bases/bad/base.json": &fstest.MapFile{
Data: []byte(`{"id": "", "os": "linux"}`),
},
},
}
_, err := parseBasesFromFS(fsys)
require.ErrorContains(t, err, "exactly one required")
})
t.Run("RejectsMultiAgentWithoutDefault", func(t *testing.T) {
t.Parallel()
fsys := fstest.MapFS{
"bases/bad/base.json": &fstest.MapFile{
Data: []byte(`{"id": "bad", "os": "linux", "agents": [{"name": "a"}, {"name": "b"}]}`),
wantErr: "empty id",
},
{
name: "RejectsDuplicateID",
fsys: fstest.MapFS{
"bases/a/base.json": &fstest.MapFile{
Data: []byte(`{"id": "dupe", "os": "linux"}`),
},
"bases/a/README.md": &fstest.MapFile{
Data: []byte("# A\n"),
},
"bases/b/base.json": &fstest.MapFile{
Data: []byte(`{"id": "dupe", "os": "linux"}`),
},
"bases/b/README.md": &fstest.MapFile{
Data: []byte("# B\n"),
},
},
}
wantErr: "duplicate base id",
},
{
name: "RejectsUnknownOS",
fsys: fstest.MapFS{
"bases/bad/base.json": &fstest.MapFile{
Data: []byte(`{"id": "bad", "os": "beos"}`),
},
},
wantErr: `unknown os "beos"`,
},
{
name: "RejectsUnknownField",
fsys: fstest.MapFS{
"bases/bad/base.json": &fstest.MapFile{
Data: []byte(`{"id": "bad", "os": "linux", "dispaly_name": "typo"}`),
},
},
wantErr: "decode",
},
{
name: "RejectsInvalidJSON",
fsys: fstest.MapFS{
"bases/bad/base.json": &fstest.MapFile{
Data: []byte(`{not json`),
},
},
wantErr: "decode",
},
{
name: "RejectsInvalidTemplate",
fsys: fstest.MapFS{
"bases/bad/base.json": &fstest.MapFile{
Data: []byte(`{"id": "bad", "os": "linux"}`),
},
"bases/bad/main.tf.tmpl": &fstest.MapFile{
Data: []byte(`{{ .Broken`),
},
},
wantErr: "parse templates",
},
{
name: "RejectsEmptyAgentName",
fsys: fstest.MapFS{
"bases/bad/base.json": &fstest.MapFile{
Data: []byte(`{"id": "bad", "os": "linux", "agents": [{"name": ""}]}`),
},
},
wantErr: "empty name",
},
{
name: "RejectsDuplicateAgentName",
fsys: fstest.MapFS{
"bases/bad/base.json": &fstest.MapFile{
Data: []byte(`{"id": "bad", "os": "linux", "agents": [{"name": "main"}, {"name": "main"}]}`),
},
},
wantErr: "duplicate agent name",
},
{
name: "RejectsMultipleDefaultAgents",
fsys: fstest.MapFS{
"bases/bad/base.json": &fstest.MapFile{
Data: []byte(`{"id": "bad", "os": "linux", "agents": [{"name": "a", "default": true}, {"name": "b", "default": true}]}`),
},
},
wantErr: "exactly one required",
},
{
name: "RejectsMultiAgentWithoutDefault",
fsys: fstest.MapFS{
"bases/bad/base.json": &fstest.MapFile{
Data: []byte(`{"id": "bad", "os": "linux", "agents": [{"name": "a"}, {"name": "b"}]}`),
},
},
wantErr: "exactly one required",
},
}
for _, tc := range rejects {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := parseBasesFromFS(fsys)
require.ErrorContains(t, err, "exactly one required")
})
_, err := parseBasesFromFS(tc.fsys)
require.ErrorContains(t, err, tc.wantErr)
})
}
}
func TestDefaultAgentName(t *testing.T) {
+101 -122
View File
@@ -128,136 +128,115 @@ func TestParseModulesFromFS(t *testing.T) {
require.Empty(t, modules)
})
t.Run("RejectsDirWithoutManifest", func(t *testing.T) {
t.Parallel()
fsys := fstest.MapFS{
"modules/nomod/readme.txt": &fstest.MapFile{Data: []byte("hi")},
}
_, err := parseModulesFromFS(fsys)
require.ErrorContains(t, err, "read nomod/module.json")
})
t.Run("RejectsEmptyID", func(t *testing.T) {
t.Parallel()
fsys := fstest.MapFS{
"modules/bad/module.json": &fstest.MapFile{
Data: []byte(`{"id": "", "pinned_version": "1.0.0"}`),
rejects := []struct {
name string
fsys fstest.MapFS
wantErr string
}{
{
name: "RejectsDirWithoutManifest",
fsys: fstest.MapFS{
"modules/nomod/readme.txt": &fstest.MapFile{Data: []byte("hi")},
},
}
_, err := parseModulesFromFS(fsys)
require.ErrorContains(t, err, "empty id")
})
t.Run("RejectsEmptyPinnedVersion", func(t *testing.T) {
t.Parallel()
fsys := fstest.MapFS{
"modules/bad/module.json": &fstest.MapFile{
Data: []byte(`{"id": "bad", "pinned_version": ""}`),
wantErr: "read nomod/module.json",
},
{
name: "RejectsEmptyID",
fsys: fstest.MapFS{
"modules/bad/module.json": &fstest.MapFile{
Data: []byte(`{"id": "", "pinned_version": "1.0.0"}`),
},
},
}
_, err := parseModulesFromFS(fsys)
require.ErrorContains(t, err, "empty pinned_version")
})
t.Run("RejectsDuplicateID", func(t *testing.T) {
t.Parallel()
fsys := fstest.MapFS{
"modules/a/module.json": &fstest.MapFile{
Data: []byte(`{"id": "dupe", "pinned_version": "1.0.0"}`),
wantErr: "empty id",
},
{
name: "RejectsEmptyPinnedVersion",
fsys: fstest.MapFS{
"modules/bad/module.json": &fstest.MapFile{
Data: []byte(`{"id": "bad", "pinned_version": ""}`),
},
},
"modules/b/module.json": &fstest.MapFile{
Data: []byte(`{"id": "dupe", "pinned_version": "2.0.0"}`),
wantErr: "empty pinned_version",
},
{
name: "RejectsDuplicateID",
fsys: fstest.MapFS{
"modules/a/module.json": &fstest.MapFile{
Data: []byte(`{"id": "dupe", "pinned_version": "1.0.0"}`),
},
"modules/b/module.json": &fstest.MapFile{
Data: []byte(`{"id": "dupe", "pinned_version": "2.0.0"}`),
},
},
}
_, err := parseModulesFromFS(fsys)
require.ErrorContains(t, err, "duplicate module id")
})
t.Run("RejectsUnknownVariableType", func(t *testing.T) {
t.Parallel()
fsys := fstest.MapFS{
"modules/bad/module.json": &fstest.MapFile{
Data: []byte(`{
"id": "bad",
"pinned_version": "1.0.0",
"variables": [{"name": "x", "type": "list"}]
}`),
wantErr: "duplicate module id",
},
{
name: "RejectsUnknownVariableType",
fsys: fstest.MapFS{
"modules/bad/module.json": &fstest.MapFile{
Data: []byte(`{
"id": "bad",
"pinned_version": "1.0.0",
"variables": [{"name": "x", "type": "list"}]
}`),
},
},
}
_, err := parseModulesFromFS(fsys)
require.ErrorContains(t, err, `unknown type "list"`)
})
t.Run("RejectsUnknownField", func(t *testing.T) {
t.Parallel()
fsys := fstest.MapFS{
"modules/bad/module.json": &fstest.MapFile{
Data: []byte(`{"id": "bad", "pinned_version": "1.0.0", "dispaly_name": "typo"}`),
wantErr: `unknown type "list"`,
},
{
name: "RejectsUnknownField",
fsys: fstest.MapFS{
"modules/bad/module.json": &fstest.MapFile{
Data: []byte(`{"id": "bad", "pinned_version": "1.0.0", "dispaly_name": "typo"}`),
},
},
}
_, err := parseModulesFromFS(fsys)
require.ErrorContains(t, err, "decode")
})
t.Run("RejectsEmptyVariableName", func(t *testing.T) {
t.Parallel()
fsys := fstest.MapFS{
"modules/bad/module.json": &fstest.MapFile{
Data: []byte(`{
"id": "bad",
"pinned_version": "1.0.0",
"variables": [{"name": "", "type": "string"}]
}`),
wantErr: "decode",
},
{
name: "RejectsEmptyVariableName",
fsys: fstest.MapFS{
"modules/bad/module.json": &fstest.MapFile{
Data: []byte(`{
"id": "bad",
"pinned_version": "1.0.0",
"variables": [{"name": "", "type": "string"}]
}`),
},
},
}
_, err := parseModulesFromFS(fsys)
require.ErrorContains(t, err, "empty name")
})
t.Run("RejectsDuplicateVariableName", func(t *testing.T) {
t.Parallel()
fsys := fstest.MapFS{
"modules/bad/module.json": &fstest.MapFile{
Data: []byte(`{
"id": "bad",
"pinned_version": "1.0.0",
"variables": [
{"name": "x", "type": "string"},
{"name": "x", "type": "number"}
]
}`),
wantErr: "empty name",
},
{
name: "RejectsDuplicateVariableName",
fsys: fstest.MapFS{
"modules/bad/module.json": &fstest.MapFile{
Data: []byte(`{
"id": "bad",
"pinned_version": "1.0.0",
"variables": [
{"name": "x", "type": "string"},
{"name": "x", "type": "number"}
]
}`),
},
},
}
_, err := parseModulesFromFS(fsys)
require.ErrorContains(t, err, "duplicate variable name")
})
t.Run("RejectsInvalidJSON", func(t *testing.T) {
t.Parallel()
fsys := fstest.MapFS{
"modules/bad/module.json": &fstest.MapFile{
Data: []byte(`{not json`),
wantErr: "duplicate variable name",
},
{
name: "RejectsInvalidJSON",
fsys: fstest.MapFS{
"modules/bad/module.json": &fstest.MapFile{
Data: []byte(`{not json`),
},
},
}
wantErr: "decode",
},
}
for _, tc := range rejects {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := parseModulesFromFS(fsys)
require.ErrorContains(t, err, "decode")
})
_, err := parseModulesFromFS(tc.fsys)
require.ErrorContains(t, err, tc.wantErr)
})
}
}
@@ -583,185 +583,140 @@ func TestRecordingTransport_CloseAfterDecoderConsumesContentLengthSucceeds(t *te
require.Empty(t, attempts[0].Error)
}
func TestRecordingTransport_CloseAfterDecoderConsumesUnknownLengthJSONSucceeds(t *testing.T) {
// Each case serves an unknown-length body, consumes it the way the case
// describes, closes it, and checks how the attempt was recorded.
func TestRecordingTransport_UnknownLengthClose(t *testing.T) {
t.Parallel()
ctx, sink := newTestSinkContext(t)
client := &http.Client{
Transport: &RecordingTransport{
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{ //nolint:exhaustruct // Test response exercises unknown-length close semantics.
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: &scriptedReadCloser{chunks: [][]byte{[]byte(`{"token":"response-secret","safe":"ok"}`)}},
ContentLength: -1,
}, nil
}),
cases := []struct {
name string
contentType string
body string
readBytes int
decode bool
drain bool
wantStatus string
wantErr string
// wantBody is compared as JSON when set.
wantBody string
}{
{
name: "CloseAfterDecoderConsumesJSONSucceeds",
contentType: "application/json",
body: `{"token":"response-secret","safe":"ok"}`,
decode: true,
wantStatus: attemptStatusCompleted,
wantBody: `{"token":"[REDACTED]","safe":"ok"}`,
},
{
// Exercises buildAttemptLocked's decodedJSON!=nil-but-changed==false
// path: the completeness check precomputes a decoded value, but
// nothing in it needs redacting, so the original bytes must be
// recorded unchanged.
name: "CloseAfterDecoderConsumesJSONWithNoSensitiveKeysSucceeds",
contentType: "application/json",
body: `{"safe":"ok","count":1}`,
decode: true,
wantStatus: attemptStatusCompleted,
wantBody: `{"safe":"ok","count":1}`,
},
{
name: "CloseAfterDecoderConsumesJSONWithTrailingDocumentMarksFailed",
contentType: "application/json",
body: `{"token":"response-secret","safe":"ok"}{"token":"second"}`,
decode: true,
wantStatus: attemptStatusFailed,
wantErr: io.ErrUnexpectedEOF.Error(),
wantBody: `{"error":"chatdebug: body contains extra JSON values, redacted for safety"}`,
},
{
name: "CloseAfterDecoderConsumesNDJSONMarksFailed",
contentType: "application/x-ndjson",
body: "{\"token\":\"response-secret\",\"safe\":\"ok\"}\n{\"token\":\"second\"}\n",
decode: true,
wantStatus: attemptStatusFailed,
wantErr: io.ErrUnexpectedEOF.Error(),
},
{
name: "CloseAfterDecoderDrainsSucceeds",
contentType: "application/json",
body: `{"token":"response-secret","safe":"ok"}`,
decode: true,
drain: true,
wantStatus: attemptStatusCompleted,
},
{
name: "CloseWithoutReadingMarksFailed",
contentType: "application/json",
body: `{"token":"response-secret","safe":"ok"}`,
wantStatus: attemptStatusFailed,
wantErr: io.ErrUnexpectedEOF.Error(),
},
{
name: "PrematureCloseMarksFailed",
contentType: "application/json",
body: `{"token":"response-secret","safe":"ok"}`,
readBytes: 5,
wantStatus: attemptStatusFailed,
wantErr: io.ErrUnexpectedEOF.Error(),
},
{
name: "SSEClosedEarlyMarksFailed",
contentType: "text/event-stream",
body: "data: {\"token\":\"secret\"}\n\ndata: [DONE]\n\n",
readBytes: 5,
wantStatus: attemptStatusFailed,
wantErr: io.ErrUnexpectedEOF.Error(),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
require.NoError(t, err)
ctx, sink := newTestSinkContext(t)
client := &http.Client{
Transport: &RecordingTransport{
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{ //nolint:exhaustruct // Test response exercises unknown-length close semantics.
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{tc.contentType}},
Body: &scriptedReadCloser{chunks: [][]byte{[]byte(tc.body)}},
ContentLength: -1,
}, nil
}),
},
}
resp, err := client.Do(req)
require.NoError(t, err)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
require.NoError(t, err)
var decoded map[string]string
require.NoError(t, json.NewDecoder(resp.Body).Decode(&decoded))
require.Equal(t, "ok", decoded["safe"])
require.NoError(t, resp.Body.Close())
resp, err := client.Do(req)
require.NoError(t, err)
attempts := sink.snapshot()
require.Len(t, attempts, 1)
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
require.Empty(t, attempts[0].Error)
require.JSONEq(t, `{"token":"[REDACTED]","safe":"ok"}`, string(attempts[0].ResponseBody))
}
if tc.readBytes > 0 {
_, err = resp.Body.Read(make([]byte, tc.readBytes))
require.NoError(t, err)
}
if tc.decode {
var decoded map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&decoded))
require.Equal(t, "ok", decoded["safe"])
}
if tc.drain {
_, err = io.Copy(io.Discard, resp.Body)
require.NoError(t, err)
}
require.NoError(t, resp.Body.Close())
// TestRecordingTransport_CloseAfterDecoderConsumesUnknownLengthJSONWithNoSensitiveKeysSucceeds
// exercises buildAttemptLocked's decodedJSON!=nil-but-changed==false path:
// the completeness check precomputes a decoded value, but nothing in it
// needs redacting, so the original bytes must be recorded unchanged.
func TestRecordingTransport_CloseAfterDecoderConsumesUnknownLengthJSONWithNoSensitiveKeysSucceeds(t *testing.T) {
t.Parallel()
ctx, sink := newTestSinkContext(t)
client := &http.Client{
Transport: &RecordingTransport{
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{ //nolint:exhaustruct // Test response exercises unknown-length close semantics.
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: &scriptedReadCloser{chunks: [][]byte{[]byte(`{"safe":"ok","count":1}`)}},
ContentLength: -1,
}, nil
}),
},
attempts := sink.snapshot()
require.Len(t, attempts, 1)
require.Equal(t, tc.wantStatus, attempts[0].Status)
require.Equal(t, tc.wantErr, attempts[0].Error)
if tc.wantBody != "" {
require.JSONEq(t, tc.wantBody, string(attempts[0].ResponseBody))
}
})
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
var decoded map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&decoded))
require.Equal(t, "ok", decoded["safe"])
require.NoError(t, resp.Body.Close())
attempts := sink.snapshot()
require.Len(t, attempts, 1)
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
require.Empty(t, attempts[0].Error)
require.JSONEq(t, `{"safe":"ok","count":1}`, string(attempts[0].ResponseBody))
}
func TestRecordingTransport_CloseAfterDecoderConsumesUnknownLengthJSONWithTrailingDocumentMarksFailed(t *testing.T) {
t.Parallel()
ctx, sink := newTestSinkContext(t)
client := &http.Client{
Transport: &RecordingTransport{
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{ //nolint:exhaustruct // Test response exercises unknown-length close semantics.
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: &scriptedReadCloser{chunks: [][]byte{[]byte("{\"token\":\"response-secret\",\"safe\":\"ok\"}{\"token\":\"second\"}")}},
ContentLength: -1,
}, nil
}),
},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
var decoded map[string]string
require.NoError(t, json.NewDecoder(resp.Body).Decode(&decoded))
require.Equal(t, "ok", decoded["safe"])
require.NoError(t, resp.Body.Close())
attempts := sink.snapshot()
require.Len(t, attempts, 1)
require.Equal(t, attemptStatusFailed, attempts[0].Status)
require.Equal(t, io.ErrUnexpectedEOF.Error(), attempts[0].Error)
require.JSONEq(t,
`{"error":"chatdebug: body contains extra JSON values, redacted for safety"}`,
string(attempts[0].ResponseBody))
}
func TestRecordingTransport_CloseAfterDecoderConsumesUnknownLengthNDJSONMarksFailed(t *testing.T) {
t.Parallel()
ctx, sink := newTestSinkContext(t)
client := &http.Client{
Transport: &RecordingTransport{
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{ //nolint:exhaustruct // Test response exercises unknown-length close semantics.
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/x-ndjson"}},
Body: &scriptedReadCloser{chunks: [][]byte{[]byte("{\"token\":\"response-secret\",\"safe\":\"ok\"}\n{\"token\":\"second\"}\n")}},
ContentLength: -1,
}, nil
}),
},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
var decoded map[string]string
require.NoError(t, json.NewDecoder(resp.Body).Decode(&decoded))
require.Equal(t, "ok", decoded["safe"])
require.NoError(t, resp.Body.Close())
attempts := sink.snapshot()
require.Len(t, attempts, 1)
require.Equal(t, attemptStatusFailed, attempts[0].Status)
require.Equal(t, io.ErrUnexpectedEOF.Error(), attempts[0].Error)
}
func TestRecordingTransport_CloseAfterDecoderDrainsUnknownLengthSucceeds(t *testing.T) {
t.Parallel()
ctx, sink := newTestSinkContext(t)
client := &http.Client{
Transport: &RecordingTransport{
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{ //nolint:exhaustruct // Test response exercises unknown-length close semantics.
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: &scriptedReadCloser{chunks: [][]byte{[]byte(`{"token":"response-secret","safe":"ok"}`)}},
ContentLength: -1,
}, nil
}),
},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
var decoded map[string]string
require.NoError(t, json.NewDecoder(resp.Body).Decode(&decoded))
require.Equal(t, "ok", decoded["safe"])
_, err = io.Copy(io.Discard, resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
attempts := sink.snapshot()
require.Len(t, attempts, 1)
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
require.Empty(t, attempts[0].Error)
}
func TestRecordingTransport_CloseWithoutReadingHeadResponseSucceeds(t *testing.T) {
@@ -795,70 +750,6 @@ func TestRecordingTransport_CloseWithoutReadingHeadResponseSucceeds(t *testing.T
require.Empty(t, attempts[0].Error)
}
func TestRecordingTransport_CloseWithoutReadingUnknownLengthMarksFailed(t *testing.T) {
t.Parallel()
ctx, sink := newTestSinkContext(t)
client := &http.Client{
Transport: &RecordingTransport{
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{ //nolint:exhaustruct // Test response exercises unknown-length close semantics.
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: &scriptedReadCloser{chunks: [][]byte{[]byte(`{"token":"response-secret","safe":"ok"}`)}},
ContentLength: -1,
}, nil
}),
},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
attempts := sink.snapshot()
require.Len(t, attempts, 1)
require.Equal(t, attemptStatusFailed, attempts[0].Status)
require.Equal(t, io.ErrUnexpectedEOF.Error(), attempts[0].Error)
}
func TestRecordingTransport_PrematureCloseUnknownLengthMarksFailed(t *testing.T) {
t.Parallel()
ctx, sink := newTestSinkContext(t)
client := &http.Client{
Transport: &RecordingTransport{
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{ //nolint:exhaustruct // Test response exercises unknown-length close semantics.
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: &scriptedReadCloser{chunks: [][]byte{[]byte(`{"token":"response-secret","safe":"ok"}`)}},
ContentLength: -1,
}, nil
}),
},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
buf := make([]byte, 5)
_, err = resp.Body.Read(buf)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
attempts := sink.snapshot()
require.Len(t, attempts, 1)
require.Equal(t, attemptStatusFailed, attempts[0].Status)
require.Equal(t, io.ErrUnexpectedEOF.Error(), attempts[0].Error)
}
func TestRecordingTransport_PrematureCloseMarksFailed(t *testing.T) {
t.Parallel()
@@ -1003,86 +894,6 @@ func TestRecordingTransport_NilBase(t *testing.T) {
require.Equal(t, "ok", string(body))
}
func TestRecordingTransport_SSEReadToEOFMarksCompleted(t *testing.T) {
t.Parallel()
ctx, sink := newTestSinkContext(t)
ssePayload := "data: {\"token\":\"secret\"}\n\ndata: [DONE]\n\n"
client := &http.Client{
Transport: &RecordingTransport{
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{ //nolint:exhaustruct // Test SSE content type.
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(ssePayload)),
ContentLength: -1,
}, nil
}),
},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, ssePayload, string(body))
attempts := sink.snapshot()
require.Len(t, attempts, 1)
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
require.Empty(t, attempts[0].Error)
// SSE bodies should be preserved as-is, not replaced with
// a redaction diagnostic.
require.Equal(t, ssePayload, string(attempts[0].ResponseBody))
}
// TestRecordingTransport_SSEReadToEOFWithoutCloseStillRecords verifies
// that SSE consumers that reach EOF and abandon the response without
// calling Close() (the pattern fantasy's Anthropic SSE adapter follows)
// still populate the attempt sink. Close()-only recording would leave
// the chat_turn step's attempts field permanently empty.
func TestRecordingTransport_SSEReadToEOFWithoutCloseStillRecords(t *testing.T) {
t.Parallel()
ctx, sink := newTestSinkContext(t)
ssePayload := "data: {\"token\":\"secret\"}\n\ndata: [DONE]\n\n"
client := &http.Client{
Transport: &RecordingTransport{
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{ //nolint:exhaustruct // Test SSE content type.
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(ssePayload)),
ContentLength: -1,
}, nil
}),
},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
require.NoError(t, err)
resp, err := client.Do(req) //nolint:bodyclose // Intentionally skip Close() to verify EOF-only recording.
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, ssePayload, string(body))
// Deliberately do NOT call resp.Body.Close(). The attempt must be
// recorded on EOF alone.
attempts := sink.snapshot()
require.Len(t, attempts, 1)
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
require.Empty(t, attempts[0].Error)
require.Equal(t, ssePayload, string(attempts[0].ResponseBody))
}
// TestRecordingTransport_SSEEmptyBodyRecordsOnEOF verifies that an SSE
// response with zero bytes (immediate EOF on the first Read) still
// records a completed attempt. This covers the n == 0 && err == io.EOF
@@ -1225,245 +1036,148 @@ func TestRecordingBody_SSEConcurrentReadCloseNoDeadlock(t *testing.T) {
}
}
func TestRecordingTransport_SSEClosedEarlyMarksFailed(t *testing.T) {
// Each case reads a body to EOF and checks what the transport records for
// its content type: SSE and plain text are preserved as-is rather than
// replaced with a redaction diagnostic, JSON-like bodies are redacted, and
// bodies that claim JSON but are not fail closed to a diagnostic.
func TestRecordingTransport_RecordedBodyByContentType(t *testing.T) {
t.Parallel()
ctx, sink := newTestSinkContext(t)
ssePayload := "data: {\"token\":\"secret\"}\n\ndata: [DONE]\n\n"
client := &http.Client{
Transport: &RecordingTransport{
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{ //nolint:exhaustruct // Test SSE content type.
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: &scriptedReadCloser{chunks: [][]byte{[]byte(ssePayload)}},
ContentLength: -1,
}, nil
}),
cases := []struct {
name string
statusCode int
header http.Header
body string
contentLength int64
// skipClose abandons the response after EOF without Close().
skipClose bool
// Exactly one of wantRaw or wantJSONLines describes the recorded body.
wantRaw string
wantJSONLines []string
}{
{
name: "SSEReadToEOFMarksCompleted",
header: http.Header{"Content-Type": []string{"text/event-stream"}},
body: ssePayload,
contentLength: -1,
wantRaw: ssePayload,
},
{
// SSE consumers that reach EOF and abandon the response without
// calling Close() (the pattern fantasy's Anthropic SSE adapter
// follows) must still populate the attempt sink. Close()-only
// recording would leave the chat_turn step's attempts field
// permanently empty.
name: "SSEReadToEOFWithoutCloseStillRecords",
header: http.Header{"Content-Type": []string{"text/event-stream"}},
body: ssePayload,
contentLength: -1,
skipClose: true,
wantRaw: ssePayload,
},
{
name: "TextPlainPreservedNotRedacted",
header: http.Header{"Content-Type": []string{"text/plain"}},
body: "This is plain text, not JSON.",
contentLength: int64(len("This is plain text, not JSON.")),
wantRaw: "This is plain text, not JSON.",
},
{
// NDJSON bodies are redacted per line rather than treated as
// non-JSON and preserved raw.
name: "NDJSONRedacted",
header: http.Header{"Content-Type": []string{"application/x-ndjson"}},
body: "{\"api_key\":\"sk-123\",\"safe\":\"ok\"}\n{\"token\":\"tok-456\",\"data\":\"value\"}\n",
contentLength: int64(len("{\"api_key\":\"sk-123\",\"safe\":\"ok\"}\n{\"token\":\"tok-456\",\"data\":\"value\"}\n")),
wantJSONLines: []string{
`{"api_key":"[REDACTED]","safe":"ok"}`,
`{"token":"[REDACTED]","data":"value"}`,
},
},
{
// Content types with a +json suffix (e.g. application/vnd.api+json)
// are treated as JSON-like.
name: "PlusJSONSuffixRedacted",
header: http.Header{"Content-Type": []string{"application/vnd.api+json"}},
body: `{"token":"secret","safe":"ok"}`,
contentLength: int64(len(`{"token":"secret","safe":"ok"}`)),
wantJSONLines: []string{`{"token":"[REDACTED]","safe":"ok"}`},
},
{
// A non-canonical lowercase header key is not found by
// http.Header.Get and must default to JSON redaction rather than
// the raw-body preservation path.
name: "UnrecognizedContentTypeDefaultsToJSONRedaction",
header: http.Header{"content-type": []string{"application/json"}},
body: `{"token":"secret","safe":"ok"}`,
contentLength: int64(len(`{"token":"secret","safe":"ok"}`)),
wantJSONLines: []string{`{"token":"[REDACTED]","safe":"ok"}`},
},
{
// An empty Content-Type takes the JSON-or-unknown branch in
// record(), and RedactJSONSecrets fails closed on a body that is
// not valid JSON so raw HTML that could carry credentials is
// never recorded.
name: "NonJSONBodyFailClosedRedaction",
statusCode: http.StatusBadGateway,
header: http.Header{},
body: `<html><body>502 Bad Gateway</body></html>`,
contentLength: int64(len(`<html><body>502 Bad Gateway</body></html>`)),
wantJSONLines: []string{`{"error":"chatdebug: body is not valid JSON, redacted for safety"}`},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
require.NoError(t, err)
statusCode := tc.statusCode
if statusCode == 0 {
statusCode = http.StatusOK
}
ctx, sink := newTestSinkContext(t)
client := &http.Client{
Transport: &RecordingTransport{
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{ //nolint:exhaustruct // Test response exercises content type handling.
StatusCode: statusCode,
Header: tc.header,
Body: io.NopCloser(strings.NewReader(tc.body)),
ContentLength: tc.contentLength,
}, nil
}),
},
}
resp, err := client.Do(req)
require.NoError(t, err)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
require.NoError(t, err)
// Read only a few bytes then close early.
buf := make([]byte, 5)
_, err = resp.Body.Read(buf)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
resp, err := client.Do(req) //nolint:bodyclose // Closed below unless the case verifies EOF-only recording.
require.NoError(t, err)
attempts := sink.snapshot()
require.Len(t, attempts, 1)
require.Equal(t, attemptStatusFailed, attempts[0].Status)
require.Equal(t, io.ErrUnexpectedEOF.Error(), attempts[0].Error)
}
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
if !tc.skipClose {
require.NoError(t, resp.Body.Close())
}
// The caller always sees the original, unredacted payload.
require.Equal(t, tc.body, string(body))
func TestRecordingTransport_TextPlainPreservedNotRedacted(t *testing.T) {
t.Parallel()
ctx, sink := newTestSinkContext(t)
textPayload := "This is plain text, not JSON."
client := &http.Client{
Transport: &RecordingTransport{
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{ //nolint:exhaustruct // Test text/plain content type.
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/plain"}},
Body: io.NopCloser(strings.NewReader(textPayload)),
ContentLength: int64(len(textPayload)),
}, nil
}),
},
attempts := sink.snapshot()
require.Len(t, attempts, 1)
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
require.Empty(t, attempts[0].Error)
if tc.wantJSONLines == nil {
require.Equal(t, tc.wantRaw, string(attempts[0].ResponseBody))
return
}
lines := strings.Split(strings.TrimSuffix(string(attempts[0].ResponseBody), "\n"), "\n")
require.Len(t, lines, len(tc.wantJSONLines))
for i, want := range tc.wantJSONLines {
require.JSONEq(t, want, lines[i])
}
})
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
_, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
attempts := sink.snapshot()
require.Len(t, attempts, 1)
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
// Non-JSON bodies should be preserved as-is, not replaced
// with a redaction diagnostic.
require.Equal(t, textPayload, string(attempts[0].ResponseBody))
}
// TestRecordingTransport_NDJSONRedacted verifies that NDJSON response
// bodies have secrets redacted on a per-line basis rather than being
// treated as non-JSON and preserved raw.
func TestRecordingTransport_NDJSONRedacted(t *testing.T) {
t.Parallel()
ctx, sink := newTestSinkContext(t)
ndjsonPayload := "{\"api_key\":\"sk-123\",\"safe\":\"ok\"}\n{\"token\":\"tok-456\",\"data\":\"value\"}\n"
client := &http.Client{
Transport: &RecordingTransport{
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{ //nolint:exhaustruct // Test NDJSON content type.
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/x-ndjson"}},
Body: io.NopCloser(strings.NewReader(ndjsonPayload)),
ContentLength: int64(len(ndjsonPayload)),
}, nil
}),
},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
// Caller sees original unredacted payload.
require.Equal(t, ndjsonPayload, string(body))
attempts := sink.snapshot()
require.Len(t, attempts, 1)
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
// Recorded body should have secrets redacted per-line.
lines := strings.Split(string(attempts[0].ResponseBody), "\n")
require.JSONEq(t, `{"api_key":"[REDACTED]","safe":"ok"}`, lines[0])
require.JSONEq(t, `{"token":"[REDACTED]","data":"value"}`, lines[1])
}
// TestRecordingTransport_PlusJSONSuffixRedacted verifies that
// content types with a +json suffix (e.g. application/vnd.api+json)
// are treated as JSON-like and have secrets redacted in recorded
// response bodies.
func TestRecordingTransport_PlusJSONSuffixRedacted(t *testing.T) {
t.Parallel()
ctx, sink := newTestSinkContext(t)
jsonPayload := `{"token":"secret","safe":"ok"}`
client := &http.Client{
Transport: &RecordingTransport{
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{ //nolint:exhaustruct // Test +json suffix content type.
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/vnd.api+json"}},
Body: io.NopCloser(strings.NewReader(jsonPayload)),
ContentLength: int64(len(jsonPayload)),
}, nil
}),
},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
// Caller sees original unredacted payload.
require.Equal(t, jsonPayload, string(body))
attempts := sink.snapshot()
require.Len(t, attempts, 1)
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
// Token must be redacted in the recorded body.
require.JSONEq(t, `{"token":"[REDACTED]","safe":"ok"}`, string(attempts[0].ResponseBody))
}
// TestRecordingTransport_UnrecognizedContentTypeDefaultsToJSONRedaction
// verifies that an unrecognized content-type header (e.g. non-canonical
// lowercase key not found by http.Header.Get) defaults to JSON
// redaction rather than falling into the raw-body preservation path.
func TestRecordingTransport_UnrecognizedContentTypeDefaultsToJSONRedaction(t *testing.T) {
t.Parallel()
ctx, sink := newTestSinkContext(t)
client := &http.Client{
Transport: &RecordingTransport{
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
// Use lowercase header key to simulate non-canonical transport.
return &http.Response{ //nolint:exhaustruct // Test lowercase content-type.
StatusCode: http.StatusOK,
Header: http.Header{"content-type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"token":"secret","safe":"ok"}`)),
ContentLength: int64(len(`{"token":"secret","safe":"ok"}`)),
}, nil
}),
},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
_, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
attempts := sink.snapshot()
require.Len(t, attempts, 1)
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
// The token should be redacted, not preserved raw or replaced
// with the fail-closed diagnostic.
require.JSONEq(t, `{"token":"[REDACTED]","safe":"ok"}`, string(attempts[0].ResponseBody))
}
// TestRecordingTransport_NonJSONBodyFailClosedRedaction verifies that
// when the Content-Type is empty (or JSON-like) but the response body
// is not valid JSON, RedactJSONSecrets' fail-closed behavior replaces
// the body with a diagnostic message rather than preserving the raw
// content which could contain credentials.
func TestRecordingTransport_NonJSONBodyFailClosedRedaction(t *testing.T) {
t.Parallel()
htmlBody := `<html><body>502 Bad Gateway</body></html>`
ctx, sink := newTestSinkContext(t)
client := &http.Client{
Transport: &RecordingTransport{
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
// Empty Content-Type triggers the JSON-or-unknown
// branch in record(), which calls RedactJSONSecrets.
return &http.Response{ //nolint:exhaustruct // Test fail-closed redaction.
StatusCode: http.StatusBadGateway,
Header: http.Header{},
Body: io.NopCloser(strings.NewReader(htmlBody)),
ContentLength: int64(len(htmlBody)),
}, nil
}),
},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
// The caller sees the original body.
require.Equal(t, htmlBody, string(body))
attempts := sink.snapshot()
require.Len(t, attempts, 1)
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
// The recorded body must be the fail-closed diagnostic, not the
// raw HTML which could contain tokens or session data.
require.JSONEq(t,
`{"error":"chatdebug: body is not valid JSON, redacted for safety"}`,
string(attempts[0].ResponseBody))
}
// TestRecordingTransport_TruncatedUnknownLengthMarksCompleted verifies
+224 -306
View File
@@ -197,210 +197,244 @@ type serialTool struct {
func (serialTool) SerialToolCalls() bool { return true }
func TestExecuteLocalTools_BatchWindowIsMaxNotSum(t *testing.T) {
// Each case scripts one tool batch through the trapped clock: the batch
// start timestamp is released first, then every step advances the clock,
// lets one tool's calls finish, and releases the Now() calls they trap.
func TestExecuteLocalTools_BatchRuntime(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
clock := quartz.NewMock(t)
trap := clock.Trap().Now()
defer trap.Close()
fastGo := make(chan struct{})
slowGo := make(chan struct{})
resultCh := executeToolBatch(t, clock, chatloop.ExecuteLocalToolsOptions{
Tools: []fantasy.AgentTool{
blockingTool("fast_tool", fastGo, fantasy.NewTextResponse("done")),
blockingTool("slow_tool", slowGo, fantasy.NewTextErrorResponse("blew up")),
},
ActiveTools: []string{"fast_tool", "slow_tool"},
ToolCalls: []fantasy.ToolCallContent{
{ToolCallID: "call-fast", ToolName: "fast_tool", Input: "{}"},
{ToolCallID: "call-slow", ToolName: "slow_tool", Input: "{}"},
},
})
trap.MustWait(ctx).MustRelease(ctx)
clock.Advance(10 * time.Second)
close(fastGo)
trap.MustWait(ctx).MustRelease(ctx)
clock.Advance(50 * time.Second)
close(slowGo)
trap.MustWait(ctx).MustRelease(ctx)
outcome := testutil.RequireReceive(ctx, t, resultCh)
require.Equal(t, 60*time.Second, outcome.BatchRuntime)
require.Equal(t, 2, outcome.BatchBilledCalls)
}
func TestExecuteLocalTools_SimultaneousCompletionsBillOnce(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
clock := quartz.NewMock(t)
trap := clock.Trap().Now()
defer trap.Close()
release := make(chan struct{})
resultCh := executeToolBatch(t, clock, chatloop.ExecuteLocalToolsOptions{
Tools: []fantasy.AgentTool{
blockingTool("read_tool", release, fantasy.NewTextResponse("done")),
},
ActiveTools: []string{"read_tool"},
ToolCalls: []fantasy.ToolCallContent{
{ToolCallID: "call-1", ToolName: "read_tool", Input: "{}"},
{ToolCallID: "call-2", ToolName: "read_tool", Input: "{}"},
{ToolCallID: "call-3", ToolName: "read_tool", Input: "{}"},
},
})
trap.MustWait(ctx).MustRelease(ctx)
clock.Advance(10 * time.Second)
close(release)
for range 3 {
trap.MustWait(ctx).MustRelease(ctx)
type tool struct {
name string
response fantasy.ToolResponse
serial bool
}
outcome := testutil.RequireReceive(ctx, t, resultCh)
require.Equal(t, 10*time.Second, outcome.BatchRuntime)
}
func TestExecuteLocalTools_UnbilledToolNeverExtendsWindow(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
clock := quartz.NewMock(t)
trap := clock.Trap().Now()
defer trap.Close()
executeGo := make(chan struct{})
waitGo := make(chan struct{})
resultCh := executeToolBatch(t, clock, chatloop.ExecuteLocalToolsOptions{
Tools: []fantasy.AgentTool{
blockingTool("execute", executeGo, fantasy.NewTextResponse("done")),
blockingTool("wait_agent", waitGo, fantasy.NewTextResponse("child report")),
type step struct {
advance time.Duration
finish string
// releases counts the trapped Now() calls that follow finish: one
// per completing call, plus one for the next serial call's start.
releases int
}
cases := []struct {
name string
tools []tool
unbilled map[string]bool
aliases map[string]string
calls []fantasy.ToolCallContent
steps []step
wantRuntime time.Duration
wantBilledCalls int
}{
{
name: "BatchWindowIsMaxNotSum",
tools: []tool{
{name: "fast_tool", response: fantasy.NewTextResponse("done")},
{name: "slow_tool", response: fantasy.NewTextErrorResponse("blew up")},
},
calls: []fantasy.ToolCallContent{
{ToolCallID: "call-fast", ToolName: "fast_tool", Input: "{}"},
{ToolCallID: "call-slow", ToolName: "slow_tool", Input: "{}"},
},
steps: []step{
{advance: 10 * time.Second, finish: "fast_tool", releases: 1},
{advance: 50 * time.Second, finish: "slow_tool", releases: 1},
},
wantRuntime: 60 * time.Second,
wantBilledCalls: 2,
},
ActiveTools: []string{"execute", "wait_agent"},
UnbilledToolNames: map[string]bool{"wait_agent": true},
ToolCalls: []fantasy.ToolCallContent{
{ToolCallID: "call-execute", ToolName: "execute", Input: "{}"},
{ToolCallID: "call-wait", ToolName: "wait_agent", Input: "{}"},
{
name: "SimultaneousCompletionsBillOnce",
tools: []tool{
{name: "read_tool", response: fantasy.NewTextResponse("done")},
},
calls: []fantasy.ToolCallContent{
{ToolCallID: "call-1", ToolName: "read_tool", Input: "{}"},
{ToolCallID: "call-2", ToolName: "read_tool", Input: "{}"},
{ToolCallID: "call-3", ToolName: "read_tool", Input: "{}"},
},
steps: []step{
{advance: 10 * time.Second, finish: "read_tool", releases: 3},
},
wantRuntime: 10 * time.Second,
wantBilledCalls: 3,
},
})
trap.MustWait(ctx).MustRelease(ctx)
clock.Advance(10 * time.Second)
close(executeGo)
trap.MustWait(ctx).MustRelease(ctx)
clock.Advance(50 * time.Second)
close(waitGo)
trap.MustWait(ctx).MustRelease(ctx)
outcome := testutil.RequireReceive(ctx, t, resultCh)
require.Equal(t, 10*time.Second, outcome.BatchRuntime)
}
func TestExecuteLocalTools_UnbilledOnlyBatchBillsNothing(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
clock := quartz.NewMock(t)
trap := clock.Trap().Now()
defer trap.Close()
waitGo := make(chan struct{})
resultCh := executeToolBatch(t, clock, chatloop.ExecuteLocalToolsOptions{
Tools: []fantasy.AgentTool{
blockingTool("wait_agent", waitGo, fantasy.NewTextResponse("child report")),
{
name: "UnbilledToolNeverExtendsWindow",
tools: []tool{
{name: "execute", response: fantasy.NewTextResponse("done")},
{name: "wait_agent", response: fantasy.NewTextResponse("child report")},
},
unbilled: map[string]bool{"wait_agent": true},
calls: []fantasy.ToolCallContent{
{ToolCallID: "call-execute", ToolName: "execute", Input: "{}"},
{ToolCallID: "call-wait", ToolName: "wait_agent", Input: "{}"},
},
steps: []step{
{advance: 10 * time.Second, finish: "execute", releases: 1},
{advance: 50 * time.Second, finish: "wait_agent", releases: 1},
},
wantRuntime: 10 * time.Second,
wantBilledCalls: 1,
},
ActiveTools: []string{"wait_agent"},
UnbilledToolNames: map[string]bool{"wait_agent": true},
ToolCalls: []fantasy.ToolCallContent{
{ToolCallID: "call-wait", ToolName: "wait_agent", Input: "{}"},
{
name: "UnbilledOnlyBatchBillsNothing",
tools: []tool{
{name: "wait_agent", response: fantasy.NewTextResponse("child report")},
},
unbilled: map[string]bool{"wait_agent": true},
calls: []fantasy.ToolCallContent{
{ToolCallID: "call-wait", ToolName: "wait_agent", Input: "{}"},
},
steps: []step{
{advance: 60 * time.Second, finish: "wait_agent", releases: 1},
},
wantRuntime: 0,
wantBilledCalls: 0,
},
})
trap.MustWait(ctx).MustRelease(ctx)
clock.Advance(60 * time.Second)
close(waitGo)
trap.MustWait(ctx).MustRelease(ctx)
outcome := testutil.RequireReceive(ctx, t, resultCh)
require.Zero(t, outcome.BatchRuntime)
require.Zero(t, outcome.BatchBilledCalls)
}
func TestExecuteLocalTools_AliasNamesClassifyAsCalled(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
clock := quartz.NewMock(t)
trap := clock.Trap().Now()
defer trap.Close()
executeGo := make(chan struct{})
legacyGo := make(chan struct{})
resultCh := executeToolBatch(t, clock, chatloop.ExecuteLocalToolsOptions{
Tools: []fantasy.AgentTool{
blockingTool("execute", executeGo, fantasy.NewTextResponse("done")),
blockingTool("interrupt_agent", legacyGo, fantasy.NewTextResponse("stopped")),
{
name: "AliasNamesClassifyAsCalled",
tools: []tool{
{name: "execute", response: fantasy.NewTextResponse("done")},
{name: "interrupt_agent", response: fantasy.NewTextResponse("stopped")},
},
aliases: map[string]string{"close_agent": "interrupt_agent"},
unbilled: map[string]bool{
"interrupt_agent": true,
"close_agent": true,
},
calls: []fantasy.ToolCallContent{
{ToolCallID: "call-execute", ToolName: "execute", Input: "{}"},
{ToolCallID: "call-legacy", ToolName: "close_agent", Input: "{}"},
},
steps: []step{
{advance: 10 * time.Second, finish: "execute", releases: 1},
{advance: 50 * time.Second, finish: "interrupt_agent", releases: 1},
},
wantRuntime: 10 * time.Second,
wantBilledCalls: 1,
},
ActiveTools: []string{"execute", "interrupt_agent"},
ToolNameAliases: map[string]string{"close_agent": "interrupt_agent"},
UnbilledToolNames: map[string]bool{
"interrupt_agent": true,
"close_agent": true,
{
name: "DuplicateToolCallIDsKeepOccurrenceCompletions",
tools: []tool{
{name: "slow_tool", response: fantasy.NewTextResponse("done")},
{name: "fast_tool", response: fantasy.NewTextResponse("done")},
},
calls: []fantasy.ToolCallContent{
{ToolCallID: "call-dup", ToolName: "slow_tool", Input: "{}"},
{ToolCallID: "call-dup", ToolName: "fast_tool", Input: "{}"},
},
steps: []step{
{advance: 10 * time.Second, finish: "fast_tool", releases: 1},
{advance: 50 * time.Second, finish: "slow_tool", releases: 1},
},
wantRuntime: 60 * time.Second,
wantBilledCalls: 2,
},
ToolCalls: []fantasy.ToolCallContent{
{ToolCallID: "call-execute", ToolName: "execute", Input: "{}"},
{ToolCallID: "call-legacy", ToolName: "close_agent", Input: "{}"},
{
name: "EmptyToolCallIDStillBillsWindow",
tools: []tool{
{name: "idless_tool", response: fantasy.NewTextResponse("done")},
{name: "fast_tool", response: fantasy.NewTextResponse("done")},
},
calls: []fantasy.ToolCallContent{
{ToolCallID: "", ToolName: "idless_tool", Input: "{}"},
{ToolCallID: "call-fast", ToolName: "fast_tool", Input: "{}"},
},
steps: []step{
{advance: 10 * time.Second, finish: "fast_tool", releases: 1},
{advance: 50 * time.Second, finish: "idless_tool", releases: 1},
},
wantRuntime: 60 * time.Second,
wantBilledCalls: 2,
},
})
trap.MustWait(ctx).MustRelease(ctx)
clock.Advance(10 * time.Second)
close(executeGo)
trap.MustWait(ctx).MustRelease(ctx)
clock.Advance(50 * time.Second)
close(legacyGo)
trap.MustWait(ctx).MustRelease(ctx)
outcome := testutil.RequireReceive(ctx, t, resultCh)
require.Equal(t, 10*time.Second, outcome.BatchRuntime)
require.Equal(t, 1, outcome.BatchBilledCalls)
}
func TestExecuteLocalTools_DuplicateToolCallIDsKeepOccurrenceCompletions(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
clock := quartz.NewMock(t)
trap := clock.Trap().Now()
defer trap.Close()
slowGo := make(chan struct{})
fastGo := make(chan struct{})
resultCh := executeToolBatch(t, clock, chatloop.ExecuteLocalToolsOptions{
Tools: []fantasy.AgentTool{
blockingTool("slow_tool", slowGo, fantasy.NewTextResponse("done")),
blockingTool("fast_tool", fastGo, fantasy.NewTextResponse("done")),
{
// A serial call bills its own execution, not the unbilled wait
// that delayed its launch.
name: "SerialCallBillsFromItsOwnStart",
tools: []tool{
{name: "wait_agent", response: fantasy.NewTextResponse("child report")},
{name: "serial_tool", response: fantasy.NewTextResponse("done"), serial: true},
},
unbilled: map[string]bool{"wait_agent": true},
calls: []fantasy.ToolCallContent{
{ToolCallID: "call-wait", ToolName: "wait_agent", Input: "{}"},
{ToolCallID: "call-serial", ToolName: "serial_tool", Input: "{}"},
},
steps: []step{
{advance: 10 * time.Minute, finish: "wait_agent", releases: 2},
{advance: 2 * time.Second, finish: "serial_tool", releases: 1},
},
wantRuntime: 2 * time.Second,
wantBilledCalls: 1,
},
ActiveTools: []string{"slow_tool", "fast_tool"},
ToolCalls: []fantasy.ToolCallContent{
{ToolCallID: "call-dup", ToolName: "slow_tool", Input: "{}"},
{ToolCallID: "call-dup", ToolName: "fast_tool", Input: "{}"},
{
// The 3s concurrent window and the 2s serial window bill; the 7s
// span where only wait_agent ran does not.
name: "SerialAfterBilledSiblingBillsUnion",
tools: []tool{
{name: "execute", response: fantasy.NewTextResponse("done")},
{name: "wait_agent", response: fantasy.NewTextResponse("child report")},
{name: "serial_tool", response: fantasy.NewTextResponse("done"), serial: true},
},
unbilled: map[string]bool{"wait_agent": true},
calls: []fantasy.ToolCallContent{
{ToolCallID: "call-execute", ToolName: "execute", Input: "{}"},
{ToolCallID: "call-wait", ToolName: "wait_agent", Input: "{}"},
{ToolCallID: "call-serial", ToolName: "serial_tool", Input: "{}"},
},
steps: []step{
{advance: 3 * time.Second, finish: "execute", releases: 1},
{advance: 7 * time.Second, finish: "wait_agent", releases: 2},
{advance: 2 * time.Second, finish: "serial_tool", releases: 1},
},
wantRuntime: 5 * time.Second,
wantBilledCalls: 2,
},
})
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
trap.MustWait(ctx).MustRelease(ctx)
clock.Advance(10 * time.Second)
close(fastGo)
trap.MustWait(ctx).MustRelease(ctx)
clock.Advance(50 * time.Second)
close(slowGo)
trap.MustWait(ctx).MustRelease(ctx)
ctx := testutil.Context(t, testutil.WaitShort)
clock := quartz.NewMock(t)
trap := clock.Trap().Now()
defer trap.Close()
outcome := testutil.RequireReceive(ctx, t, resultCh)
require.Equal(t, 60*time.Second, outcome.BatchRuntime)
releases := make(map[string]chan struct{}, len(tc.tools))
tools := make([]fantasy.AgentTool, 0, len(tc.tools))
activeTools := make([]string, 0, len(tc.tools))
for _, tl := range tc.tools {
release := make(chan struct{})
releases[tl.name] = release
agentTool := blockingTool(tl.name, release, tl.response)
if tl.serial {
agentTool = serialTool{agentTool}
}
tools = append(tools, agentTool)
activeTools = append(activeTools, tl.name)
}
resultCh := executeToolBatch(t, clock, chatloop.ExecuteLocalToolsOptions{
Tools: tools,
ActiveTools: activeTools,
ToolNameAliases: tc.aliases,
UnbilledToolNames: tc.unbilled,
ToolCalls: tc.calls,
})
trap.MustWait(ctx).MustRelease(ctx)
for _, st := range tc.steps {
clock.Advance(st.advance)
close(releases[st.finish])
for range st.releases {
trap.MustWait(ctx).MustRelease(ctx)
}
}
outcome := testutil.RequireReceive(ctx, t, resultCh)
require.Equal(t, tc.wantRuntime, outcome.BatchRuntime)
require.Equal(t, tc.wantBilledCalls, outcome.BatchBilledCalls)
})
}
}
// recordingToolBillingRecorder is a test ToolBillingRecorder that
@@ -500,40 +534,6 @@ func TestExecuteLocalTools_BillingRecorderRecordsOnlyRuns(t *testing.T) {
})
}
func TestExecuteLocalTools_EmptyToolCallIDStillBillsWindow(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
clock := quartz.NewMock(t)
trap := clock.Trap().Now()
defer trap.Close()
idlessGo := make(chan struct{})
fastGo := make(chan struct{})
resultCh := executeToolBatch(t, clock, chatloop.ExecuteLocalToolsOptions{
Tools: []fantasy.AgentTool{
blockingTool("idless_tool", idlessGo, fantasy.NewTextResponse("done")),
blockingTool("fast_tool", fastGo, fantasy.NewTextResponse("done")),
},
ActiveTools: []string{"idless_tool", "fast_tool"},
ToolCalls: []fantasy.ToolCallContent{
{ToolCallID: "", ToolName: "idless_tool", Input: "{}"},
{ToolCallID: "call-fast", ToolName: "fast_tool", Input: "{}"},
},
})
trap.MustWait(ctx).MustRelease(ctx)
clock.Advance(10 * time.Second)
close(fastGo)
trap.MustWait(ctx).MustRelease(ctx)
clock.Advance(50 * time.Second)
close(idlessGo)
trap.MustWait(ctx).MustRelease(ctx)
outcome := testutil.RequireReceive(ctx, t, resultCh)
require.Equal(t, 60*time.Second, outcome.BatchRuntime)
}
func TestExecuteLocalTools_BillingRecorderReportsLiveCompletions(t *testing.T) {
t.Parallel()
@@ -580,88 +580,6 @@ func TestExecuteLocalTools_BillingRecorderReportsLiveCompletions(t *testing.T) {
}, outcome.ToolResultCreatedAt)
}
func TestExecuteLocalTools_SerialCallBillsFromItsOwnStart(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
clock := quartz.NewMock(t)
trap := clock.Trap().Now()
defer trap.Close()
waitGo := make(chan struct{})
serialGo := make(chan struct{})
resultCh := executeToolBatch(t, clock, chatloop.ExecuteLocalToolsOptions{
Tools: []fantasy.AgentTool{
blockingTool("wait_agent", waitGo, fantasy.NewTextResponse("child report")),
serialTool{blockingTool("serial_tool", serialGo, fantasy.NewTextResponse("done"))},
},
ActiveTools: []string{"wait_agent", "serial_tool"},
UnbilledToolNames: map[string]bool{"wait_agent": true},
ToolCalls: []fantasy.ToolCallContent{
{ToolCallID: "call-wait", ToolName: "wait_agent", Input: "{}"},
{ToolCallID: "call-serial", ToolName: "serial_tool", Input: "{}"},
},
})
trap.MustWait(ctx).MustRelease(ctx)
clock.Advance(10 * time.Minute)
close(waitGo)
trap.MustWait(ctx).MustRelease(ctx)
// Release the serial start timestamp.
trap.MustWait(ctx).MustRelease(ctx)
clock.Advance(2 * time.Second)
close(serialGo)
trap.MustWait(ctx).MustRelease(ctx)
outcome := testutil.RequireReceive(ctx, t, resultCh)
require.Equal(t, 2*time.Second, outcome.BatchRuntime,
"a serial call bills its own execution, not the unbilled wait that delayed its launch")
}
func TestExecuteLocalTools_SerialAfterBilledSiblingBillsUnion(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
clock := quartz.NewMock(t)
trap := clock.Trap().Now()
defer trap.Close()
execGo := make(chan struct{})
waitGo := make(chan struct{})
serialGo := make(chan struct{})
resultCh := executeToolBatch(t, clock, chatloop.ExecuteLocalToolsOptions{
Tools: []fantasy.AgentTool{
blockingTool("execute", execGo, fantasy.NewTextResponse("done")),
blockingTool("wait_agent", waitGo, fantasy.NewTextResponse("child report")),
serialTool{blockingTool("serial_tool", serialGo, fantasy.NewTextResponse("done"))},
},
ActiveTools: []string{"execute", "wait_agent", "serial_tool"},
UnbilledToolNames: map[string]bool{"wait_agent": true},
ToolCalls: []fantasy.ToolCallContent{
{ToolCallID: "call-execute", ToolName: "execute", Input: "{}"},
{ToolCallID: "call-wait", ToolName: "wait_agent", Input: "{}"},
{ToolCallID: "call-serial", ToolName: "serial_tool", Input: "{}"},
},
})
trap.MustWait(ctx).MustRelease(ctx)
clock.Advance(3 * time.Second)
close(execGo)
trap.MustWait(ctx).MustRelease(ctx)
clock.Advance(7 * time.Second)
close(waitGo)
trap.MustWait(ctx).MustRelease(ctx)
// Release the serial start timestamp.
trap.MustWait(ctx).MustRelease(ctx)
clock.Advance(2 * time.Second)
close(serialGo)
trap.MustWait(ctx).MustRelease(ctx)
outcome := testutil.RequireReceive(ctx, t, resultCh)
require.Equal(t, 5*time.Second, outcome.BatchRuntime,
"the 3s concurrent window and the 2s serial window bill; the 7s span where only wait_agent ran does not")
}
func TestBilledIntervalsDuration(t *testing.T) {
t.Parallel()
+293 -425
View File
@@ -478,150 +478,308 @@ func sendConnect(t *testing.T, proxyAddr, targetHost, proxyAuth string) *http.Re
func TestNew(t *testing.T) {
t.Parallel()
t.Run("MissingListenAddr", func(t *testing.T) {
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
cases := []struct {
name string
opts aibridgeproxyd.Options
// wantErr is empty when New must succeed.
wantErr string
}{
{
name: "MissingListenAddr",
opts: aibridgeproxyd.Options{
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
},
wantErr: "listen address is required",
},
{
name: "EmptyListenAddr",
opts: aibridgeproxyd.Options{
ListenAddr: "",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
},
wantErr: "listen address is required",
},
{
name: "TLSCertWithoutKey",
opts: aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
TLSCertFile: "cert.pem",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
},
wantErr: "tls cert file and tls key file must both be set",
},
{
name: "TLSKeyWithoutCert",
opts: aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
TLSKeyFile: "key.pem",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
},
wantErr: "tls cert file and tls key file must both be set",
},
{
name: "InvalidListenerTLSFiles",
opts: aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
TLSCertFile: "/nonexistent/cert.pem",
TLSKeyFile: "/nonexistent/key.pem",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
},
wantErr: "load listener TLS certificate",
},
{
name: "MissingGatewayURL",
opts: aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
},
wantErr: "AI Gateway URL is required",
},
{
name: "EmptyGatewayURL",
opts: aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: " ",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
},
wantErr: "AI Gateway URL is required",
},
{
name: "InvalidGatewayURL",
opts: aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "://invalid",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
},
wantErr: "invalid AI Gateway URL",
},
{
name: "GatewayURLWithQuery",
opts: aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000?token=secret",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
},
wantErr: "AI Gateway URL must not include query parameters",
},
{
name: "MissingCertFile",
opts: aibridgeproxyd.Options{
ListenAddr: ":0",
GatewayURL: "http://localhost:3000",
MITMKeyFile: "key.pem",
},
wantErr: "cert file and key file are required",
},
{
name: "MissingKeyFile",
opts: aibridgeproxyd.Options{
ListenAddr: ":0",
GatewayURL: "http://localhost:3000",
MITMCertFile: "cert.pem",
},
wantErr: "cert file and key file are required",
},
{
name: "InvalidCertFile",
opts: aibridgeproxyd.Options{
ListenAddr: ":0",
GatewayURL: "http://localhost:3000",
MITMCertFile: "/nonexistent/cert.pem",
MITMKeyFile: "/nonexistent/key.pem",
},
wantErr: "failed to load MITM certificate",
},
{
name: "InvalidUpstreamProxy",
opts: aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
UpstreamProxy: "://invalid-url",
},
wantErr: "invalid upstream proxy URL",
},
{
name: "UpstreamProxyCAFileNotFound",
opts: aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
UpstreamProxy: "https://proxy.example.com:8080",
UpstreamProxyCA: "/nonexistent/ca.pem",
},
wantErr: "failed to read upstream proxy CA certificate",
},
{
name: "UpstreamProxyAuthWithBothEmpty",
opts: aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
UpstreamProxy: "http://:@proxy.example.com:8080",
},
wantErr: "invalid credentials: both username and password are empty",
},
{
name: "InvalidAllowedPrivateCIDR",
opts: aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
AllowedPrivateCIDRs: []string{"not-a-cidr"},
},
wantErr: "invalid allowed private CIDR",
},
{
name: "Success",
opts: aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
},
},
{
name: "SuccessWithUpstreamProxy",
opts: aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
UpstreamProxy: "http://proxy.example.com:8080",
},
},
{
// The shared MITM certificate doubles as the upstream proxy CA
// because it is a valid PEM cert.
name: "SuccessWithHTTPSUpstreamProxyAndCA",
opts: aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
UpstreamProxy: "https://proxy.example.com:8080",
UpstreamProxyCA: mitmCertFile,
},
},
{
name: "SuccessWithUpstreamProxyAuth",
opts: aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
UpstreamProxy: "http://proxyuser:proxypass@proxy.example.com:8080",
},
},
{
name: "SuccessWithUpstreamProxyUsernameAuthColon",
opts: aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
UpstreamProxy: "http://proxyuser:@proxy.example.com:8080",
},
},
{
// Username only (no colon) also succeeds because the password is
// optional.
name: "SuccessWithUpstreamProxyUsernameAuth",
opts: aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
UpstreamProxy: "http://proxyuser@proxy.example.com:8080",
},
},
{
name: "SuccessWithUpstreamProxyTokenAuth",
opts: aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
UpstreamProxy: "http://:proxypass@proxy.example.com:8080",
},
},
{
name: "SuccessWithMetrics",
opts: aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
Metrics: aibridgeproxyd.NewMetrics(prometheus.NewRegistry()),
},
},
{
name: "SuccessWithAllowedPrivateCIDRs",
opts: aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
AllowedPrivateCIDRs: []string{"127.0.0.1/32"},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
srv, err := aibridgeproxyd.New(t.Context(), logger, tc.opts)
if tc.wantErr != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.wantErr)
return
}
require.NoError(t, err)
require.NotNil(t, srv)
})
}
t.Run("SuccessWithListenerTLS", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
listenerCertFile, listenerKeyFile := generateListenerCert(t)
logger := slogtest.Make(t, nil)
_, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
TLSCertFile: listenerCertFile,
TLSKeyFile: listenerKeyFile,
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
})
require.Error(t, err)
require.Contains(t, err.Error(), "listen address is required")
})
t.Run("EmptyListenAddr", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
logger := slogtest.Make(t, nil)
_, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
})
require.Error(t, err)
require.Contains(t, err.Error(), "listen address is required")
})
t.Run("TLSCertWithoutKey", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
logger := slogtest.Make(t, nil)
_, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
TLSCertFile: "cert.pem",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
})
require.Error(t, err)
require.Contains(t, err.Error(), "tls cert file and tls key file must both be set")
})
t.Run("TLSKeyWithoutCert", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
logger := slogtest.Make(t, nil)
_, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
TLSKeyFile: "key.pem",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
})
require.Error(t, err)
require.Contains(t, err.Error(), "tls cert file and tls key file must both be set")
})
t.Run("InvalidListenerTLSFiles", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
logger := slogtest.Make(t, nil)
_, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
TLSCertFile: "/nonexistent/cert.pem",
TLSKeyFile: "/nonexistent/key.pem",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
})
require.Error(t, err)
require.Contains(t, err.Error(), "load listener TLS certificate")
})
t.Run("MissingGatewayURL", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
logger := slogtest.Make(t, nil)
_, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
})
require.Error(t, err)
require.Contains(t, err.Error(), "AI Gateway URL is required")
})
t.Run("EmptyGatewayURL", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
logger := slogtest.Make(t, nil)
_, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: " ",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
})
require.Error(t, err)
require.Contains(t, err.Error(), "AI Gateway URL is required")
})
t.Run("InvalidGatewayURL", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
logger := slogtest.Make(t, nil)
_, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "://invalid",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
})
require.Error(t, err)
require.Contains(t, err.Error(), "invalid AI Gateway URL")
})
t.Run("GatewayURLWithQuery", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
logger := slogtest.Make(t, nil)
_, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000?token=secret",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
})
require.Error(t, err)
require.Contains(t, err.Error(), "AI Gateway URL must not include query parameters")
require.NoError(t, err)
require.NotNil(t, srv)
})
t.Run("GatewayURLDefaultHTTPPort", func(t *testing.T) {
@@ -673,296 +831,6 @@ func TestNew(t *testing.T) {
require.Equal(t, "3000", srv.GatewayURL().Port())
})
t.Run("MissingCertFile", func(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
_, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: ":0",
GatewayURL: "http://localhost:3000",
MITMKeyFile: "key.pem",
})
require.Error(t, err)
require.Contains(t, err.Error(), "cert file and key file are required")
})
t.Run("MissingKeyFile", func(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
_, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: ":0",
GatewayURL: "http://localhost:3000",
MITMCertFile: "cert.pem",
})
require.Error(t, err)
require.Contains(t, err.Error(), "cert file and key file are required")
})
t.Run("InvalidCertFile", func(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
_, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: ":0",
GatewayURL: "http://localhost:3000",
MITMCertFile: "/nonexistent/cert.pem",
MITMKeyFile: "/nonexistent/key.pem",
})
require.Error(t, err)
require.Contains(t, err.Error(), "failed to load MITM certificate")
})
t.Run("InvalidUpstreamProxy", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
logger := slogtest.Make(t, nil)
_, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
UpstreamProxy: "://invalid-url",
})
require.Error(t, err)
require.Contains(t, err.Error(), "invalid upstream proxy URL")
})
t.Run("UpstreamProxyCAFileNotFound", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
logger := slogtest.Make(t, nil)
_, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
UpstreamProxy: "https://proxy.example.com:8080",
UpstreamProxyCA: "/nonexistent/ca.pem",
})
require.Error(t, err)
require.Contains(t, err.Error(), "failed to read upstream proxy CA certificate")
})
t.Run("UpstreamProxyAuthWithBothEmpty", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
logger := slogtest.Make(t, nil)
_, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
UpstreamProxy: "http://:@proxy.example.com:8080",
})
require.Error(t, err)
require.Contains(t, err.Error(), "invalid credentials: both username and password are empty")
})
t.Run("InvalidAllowedPrivateCIDR", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
logger := slogtest.Make(t, nil)
_, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
AllowedPrivateCIDRs: []string{"not-a-cidr"},
})
require.Error(t, err)
require.Contains(t, err.Error(), "invalid allowed private CIDR")
})
t.Run("Success", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
logger := slogtest.Make(t, nil)
srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
})
require.NoError(t, err)
require.NotNil(t, srv)
})
t.Run("SuccessWithListenerTLS", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
listenerCertFile, listenerKeyFile := generateListenerCert(t)
logger := slogtest.Make(t, nil)
srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
TLSCertFile: listenerCertFile,
TLSKeyFile: listenerKeyFile,
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
})
require.NoError(t, err)
require.NotNil(t, srv)
})
t.Run("SuccessWithUpstreamProxy", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
logger := slogtest.Make(t, nil)
srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
UpstreamProxy: "http://proxy.example.com:8080",
})
require.NoError(t, err)
require.NotNil(t, srv)
})
t.Run("SuccessWithHTTPSUpstreamProxyAndCA", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
logger := slogtest.Make(t, nil)
// Use the shared MITM certificate as the upstream proxy CA (it's a valid PEM cert)
srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
UpstreamProxy: "https://proxy.example.com:8080",
UpstreamProxyCA: mitmCertFile,
})
require.NoError(t, err)
require.NotNil(t, srv)
})
t.Run("SuccessWithUpstreamProxyAuth", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
logger := slogtest.Make(t, nil)
srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
UpstreamProxy: "http://proxyuser:proxypass@proxy.example.com:8080",
})
require.NoError(t, err)
require.NotNil(t, srv)
})
t.Run("SuccessWithUpstreamProxyUsernameAuthColon", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
logger := slogtest.Make(t, nil)
srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
UpstreamProxy: "http://proxyuser:@proxy.example.com:8080",
})
require.NoError(t, err)
require.NotNil(t, srv)
})
t.Run("SuccessWithUpstreamProxyUsernameAuth", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
logger := slogtest.Make(t, nil)
// Username only (no colon) should also succeed (password is optional)
srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
UpstreamProxy: "http://proxyuser@proxy.example.com:8080",
})
require.NoError(t, err)
require.NotNil(t, srv)
})
t.Run("SuccessWithUpstreamProxyTokenAuth", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
logger := slogtest.Make(t, nil)
srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
UpstreamProxy: "http://:proxypass@proxy.example.com:8080",
})
require.NoError(t, err)
require.NotNil(t, srv)
})
t.Run("SuccessWithMetrics", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
logger := slogtest.Make(t, nil)
// Create metrics instance to verify it can be passed and stored.
reg := prometheus.NewRegistry()
metrics := aibridgeproxyd.NewMetrics(reg)
srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
Metrics: metrics,
})
require.NoError(t, err)
require.NotNil(t, srv)
})
t.Run("SuccessWithAllowedPrivateCIDRs", func(t *testing.T) {
t.Parallel()
mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t)
logger := slogtest.Make(t, nil)
srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{
ListenAddr: "127.0.0.1:0",
GatewayURL: "http://localhost:3000",
MITMCertFile: mitmCertFile,
MITMKeyFile: mitmKeyFile,
AllowedPrivateCIDRs: []string{"127.0.0.1/32"},
})
require.NoError(t, err)
require.NotNil(t, srv)
})
t.Run("GatewayURLHostPreserved", func(t *testing.T) {
t.Parallel()
+194 -246
View File
@@ -298,252 +298,200 @@ func TestDevConfigValidate(t *testing.T) {
}
}
t.Run("Valid", func(t *testing.T) {
t.Parallel()
assert.NoError(t, base().validate())
})
t.Run("AgplAndProxy", func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.agpl = true
cfg.useProxy = true
err := cfg.validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "--agpl and --use-proxy")
})
t.Run("AgplAndMultiOrg", func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.agpl = true
cfg.multiOrg = true
err := cfg.validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "--agpl and --multi-organization")
})
t.Run("PortTooLow", func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.apiPort = 0
err := cfg.validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "--port must be between 1 and 65535")
})
t.Run("PortTooHigh", func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.apiPort = 70000
err := cfg.validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "--port must be between 1 and 65535")
})
t.Run("PortConflictWithWeb", func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.apiPort = 8080
err := cfg.validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "conflicts with frontend dev server")
})
t.Run("PortConflictWithProxy", func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.apiPort = 3010
cfg.useProxy = true
err := cfg.validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "conflicts with workspace proxy")
})
t.Run("ProxyPortOKWithoutFlag", func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.apiPort = 3010
assert.NoError(t, cfg.validate())
})
t.Run("WebPortTooLow", func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.webPort = 0
err := cfg.validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "--web-port must be between 1 and 65535")
})
t.Run("ProxyPortTooHigh", func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.proxyPort = 70000
err := cfg.validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "--proxy-port must be between 1 and 65535")
})
t.Run("WebProxyPortConflict", func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.webPort = 9000
cfg.proxyPort = 9000
cfg.useProxy = true
err := cfg.validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "--web-port 9000 conflicts with --proxy-port")
})
t.Run("WebProxyPortConflictOKWithoutProxy", func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.webPort = 9000
cfg.proxyPort = 9000
assert.NoError(t, cfg.validate())
})
t.Run("PrometheusPortConflictWithAPI", func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.coderMetricsPort = 3000
err := cfg.validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "--prometheus-port 3000 conflicts with")
})
t.Run("PrometheusPortConflictWithWeb", func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.coderMetricsPort = 8080
err := cfg.validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "--prometheus-port 8080 conflicts with")
})
t.Run("PrometheusPortConflictWithProxy", func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.coderMetricsPort = 3010
cfg.useProxy = true
err := cfg.validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "--prometheus-port 3010 conflicts with")
})
t.Run("PrometheusPortZeroDisabled", func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.coderMetricsPort = 0
assert.NoError(t, cfg.validate())
})
t.Run("PrometheusPortValid", func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.coderMetricsPort = 9090
assert.NoError(t, cfg.validate())
})
t.Run("PrometheusPortTooHigh", func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.coderMetricsPort = 70000
err := cfg.validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "--prometheus-port must be 0 (disabled) or between 1 and 65535")
})
t.Run("PrometheusPortNegative", func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.coderMetricsPort = -1
err := cfg.validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "--prometheus-port must be 0 (disabled) or between 1 and 65535")
})
t.Run("PrometheusProxyProxyConflictIgnoredWithoutProxy", func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.coderMetricsPort = 3010
assert.NoError(t, cfg.validate())
})
t.Run("PrometheusServerRequiresMetrics", func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.prometheusServer = true
cfg.coderMetricsPort = 0
err := cfg.validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "--prometheus-server requires prometheus to be enabled")
})
t.Run("PrometheusServerValid", func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.prometheusServer = true
cfg.coderMetricsPort = 2114
assert.NoError(t, cfg.validate())
})
t.Run("PrometheusServerPortConflictWithAPI", func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.prometheusServer = true
cfg.apiPort = prometheusServerPort
cfg.coderMetricsPort = 2114
err := cfg.validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "--port")
assert.Contains(t, err.Error(), "conflicts with prometheus server")
})
t.Run("PrometheusServerPortConflictWithWeb", func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.prometheusServer = true
cfg.webPort = prometheusServerPort
cfg.coderMetricsPort = 2114
err := cfg.validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "--web-port")
assert.Contains(t, err.Error(), "conflicts with prometheus server")
})
t.Run("PrometheusServerPortConflictWithProxy", func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.prometheusServer = true
cfg.useProxy = true
cfg.proxyPort = prometheusServerPort
err := cfg.validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "--proxy-port")
assert.Contains(t, err.Error(), "conflicts with prometheus server")
})
t.Run("PrometheusServerPortNoProxyConflictWithoutFlag", func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.prometheusServer = true
cfg.proxyPort = prometheusServerPort
// useProxy is false, so no conflict.
assert.NoError(t, cfg.validate())
})
t.Run("PrometheusServerPortConflictWithMetrics", func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.prometheusServer = true
cfg.coderMetricsPort = prometheusServerPort
err := cfg.validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "--prometheus-port")
assert.Contains(t, err.Error(), "conflicts with prometheus server")
})
cases := []struct {
name string
mutate func(cfg *devConfig)
wantErrs []string
}{
{
name: "Valid",
},
{
name: "AgplAndProxy",
mutate: func(cfg *devConfig) {
cfg.agpl = true
cfg.useProxy = true
},
wantErrs: []string{"--agpl and --use-proxy"},
},
{
name: "AgplAndMultiOrg",
mutate: func(cfg *devConfig) {
cfg.agpl = true
cfg.multiOrg = true
},
wantErrs: []string{"--agpl and --multi-organization"},
},
{
name: "PortTooLow",
mutate: func(cfg *devConfig) { cfg.apiPort = 0 },
wantErrs: []string{"--port must be between 1 and 65535"},
},
{
name: "PortTooHigh",
mutate: func(cfg *devConfig) { cfg.apiPort = 70000 },
wantErrs: []string{"--port must be between 1 and 65535"},
},
{
name: "PortConflictWithWeb",
mutate: func(cfg *devConfig) { cfg.apiPort = 8080 },
wantErrs: []string{"conflicts with frontend dev server"},
},
{
name: "PortConflictWithProxy",
mutate: func(cfg *devConfig) {
cfg.apiPort = 3010
cfg.useProxy = true
},
wantErrs: []string{"conflicts with workspace proxy"},
},
{
name: "ProxyPortOKWithoutFlag",
mutate: func(cfg *devConfig) { cfg.apiPort = 3010 },
},
{
name: "WebPortTooLow",
mutate: func(cfg *devConfig) { cfg.webPort = 0 },
wantErrs: []string{"--web-port must be between 1 and 65535"},
},
{
name: "ProxyPortTooHigh",
mutate: func(cfg *devConfig) { cfg.proxyPort = 70000 },
wantErrs: []string{"--proxy-port must be between 1 and 65535"},
},
{
name: "WebProxyPortConflict",
mutate: func(cfg *devConfig) {
cfg.webPort = 9000
cfg.proxyPort = 9000
cfg.useProxy = true
},
wantErrs: []string{"--web-port 9000 conflicts with --proxy-port"},
},
{
name: "WebProxyPortConflictOKWithoutProxy",
mutate: func(cfg *devConfig) {
cfg.webPort = 9000
cfg.proxyPort = 9000
},
},
{
name: "PrometheusPortConflictWithAPI",
mutate: func(cfg *devConfig) { cfg.coderMetricsPort = 3000 },
wantErrs: []string{"--prometheus-port 3000 conflicts with"},
},
{
name: "PrometheusPortConflictWithWeb",
mutate: func(cfg *devConfig) { cfg.coderMetricsPort = 8080 },
wantErrs: []string{"--prometheus-port 8080 conflicts with"},
},
{
name: "PrometheusPortConflictWithProxy",
mutate: func(cfg *devConfig) {
cfg.coderMetricsPort = 3010
cfg.useProxy = true
},
wantErrs: []string{"--prometheus-port 3010 conflicts with"},
},
{
name: "PrometheusPortZeroDisabled",
mutate: func(cfg *devConfig) { cfg.coderMetricsPort = 0 },
},
{
name: "PrometheusPortValid",
mutate: func(cfg *devConfig) { cfg.coderMetricsPort = 9090 },
},
{
name: "PrometheusPortTooHigh",
mutate: func(cfg *devConfig) { cfg.coderMetricsPort = 70000 },
wantErrs: []string{"--prometheus-port must be 0 (disabled) or between 1 and 65535"},
},
{
name: "PrometheusPortNegative",
mutate: func(cfg *devConfig) { cfg.coderMetricsPort = -1 },
wantErrs: []string{"--prometheus-port must be 0 (disabled) or between 1 and 65535"},
},
{
name: "PrometheusProxyProxyConflictIgnoredWithoutProxy",
mutate: func(cfg *devConfig) { cfg.coderMetricsPort = 3010 },
},
{
name: "PrometheusServerRequiresMetrics",
mutate: func(cfg *devConfig) {
cfg.prometheusServer = true
cfg.coderMetricsPort = 0
},
wantErrs: []string{"--prometheus-server requires prometheus to be enabled"},
},
{
name: "PrometheusServerValid",
mutate: func(cfg *devConfig) {
cfg.prometheusServer = true
cfg.coderMetricsPort = 2114
},
},
{
name: "PrometheusServerPortConflictWithAPI",
mutate: func(cfg *devConfig) {
cfg.prometheusServer = true
cfg.apiPort = prometheusServerPort
cfg.coderMetricsPort = 2114
},
wantErrs: []string{"--port", "conflicts with prometheus server"},
},
{
name: "PrometheusServerPortConflictWithWeb",
mutate: func(cfg *devConfig) {
cfg.prometheusServer = true
cfg.webPort = prometheusServerPort
cfg.coderMetricsPort = 2114
},
wantErrs: []string{"--web-port", "conflicts with prometheus server"},
},
{
name: "PrometheusServerPortConflictWithProxy",
mutate: func(cfg *devConfig) {
cfg.prometheusServer = true
cfg.useProxy = true
cfg.proxyPort = prometheusServerPort
},
wantErrs: []string{"--proxy-port", "conflicts with prometheus server"},
},
{
// useProxy is false, so no conflict.
name: "PrometheusServerPortNoProxyConflictWithoutFlag",
mutate: func(cfg *devConfig) {
cfg.prometheusServer = true
cfg.proxyPort = prometheusServerPort
},
},
{
name: "PrometheusServerPortConflictWithMetrics",
mutate: func(cfg *devConfig) {
cfg.prometheusServer = true
cfg.coderMetricsPort = prometheusServerPort
},
wantErrs: []string{"--prometheus-port", "conflicts with prometheus server"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
cfg := base()
if tc.mutate != nil {
tc.mutate(cfg)
}
err := cfg.validate()
if len(tc.wantErrs) == 0 {
assert.NoError(t, err)
return
}
require.Error(t, err)
for _, want := range tc.wantErrs {
assert.Contains(t, err.Error(), want)
}
})
}
}
func TestDevConfigResolveEnv(t *testing.T) {