mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
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:
+117
-260
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user