Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cda3a01e00 |
+12
-10
@@ -145,10 +145,10 @@ jobs:
|
||||
# the diagnostic step with its own continue-on-error: true (line 203).
|
||||
# Flip confirmed by CI / Platform (Go) status = success on main HEAD 363905d3.
|
||||
continue-on-error: false
|
||||
# Job-level ceiling. The go test step below runs with a per-step 10m timeout;
|
||||
# this cap catches any step that leaks past that. Set well above 10m so
|
||||
# Job-level ceiling. The go test step below runs with a per-step 30m timeout;
|
||||
# this cap catches any step that leaks past that. Set well above 30m so
|
||||
# the per-step timeout is the active constraint.
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 35
|
||||
defaults:
|
||||
run:
|
||||
working-directory: workspace-server
|
||||
@@ -176,12 +176,14 @@ jobs:
|
||||
name: Run golangci-lint
|
||||
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
|
||||
- if: always()
|
||||
name: Diagnostic — per-package verbose 60s
|
||||
name: Diagnostic — per-package verbose (300s timeout)
|
||||
run: |
|
||||
set +e
|
||||
go test -race -v -timeout 60s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log
|
||||
# 300s allows handlers + pendinguploads packages to complete on cold
|
||||
# runners with -race instrumentation (~60-120s each vs ~14s non-race).
|
||||
go test -race -v -timeout 300s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log
|
||||
handlers_exit=$?
|
||||
go test -race -v -timeout 60s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log
|
||||
go test -race -v -timeout 300s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log
|
||||
pu_exit=$?
|
||||
echo "::group::handlers exit=$handlers_exit (last 100 lines)"
|
||||
tail -100 /tmp/test-handlers.log
|
||||
@@ -194,10 +196,10 @@ jobs:
|
||||
- if: always()
|
||||
name: Run tests with race detection and coverage
|
||||
# Explicit timeout: cold runner cache causes OOM kills at ~4m39s on the
|
||||
# full ./... suite with race detection + coverage. A 10m per-step timeout
|
||||
# lets the suite complete on cold cache (~5-7m) while failing cleanly
|
||||
# instead of OOM-killing. The job-level timeout (15m) is a backstop.
|
||||
run: go test -race -timeout 10m -coverprofile=coverage.out ./...
|
||||
# full ./... suite with race detection + coverage. A 30m per-step timeout
|
||||
# lets the suite complete on cold cache (~13-25m) while failing cleanly
|
||||
# instead of OOM-killing. The job-level timeout (35m) is a backstop.
|
||||
run: go test -race -timeout 30m -coverprofile=coverage.out ./...
|
||||
|
||||
- if: always()
|
||||
name: Per-file coverage report
|
||||
|
||||
@@ -653,239 +653,3 @@ func TestSanitizeUTF8(t *testing.T) {
|
||||
t.Errorf("sanitizeUTF8 did not produce valid UTF-8: %x", []byte(out))
|
||||
}
|
||||
}
|
||||
|
||||
// ── extractResponseSummary coverage ───────────────────────────────────────────
|
||||
|
||||
func TestExtractResponseSummary_EmptyBody(t *testing.T) {
|
||||
s := New(nil, nil)
|
||||
if got := s.extractResponseSummary(nil); got != "" {
|
||||
t.Errorf("nil body: got %q, want %q", got, "")
|
||||
}
|
||||
if got := s.extractResponseSummary([]byte{}); got != "" {
|
||||
t.Errorf("empty body: got %q, want %q", got, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResponseSummary_InvalidJSON(t *testing.T) {
|
||||
s := New(nil, nil)
|
||||
got := s.extractResponseSummary([]byte(`not json`))
|
||||
if got != "" {
|
||||
t.Errorf("invalid JSON: got %q, want %q", got, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResponseSummary_NoResultKey(t *testing.T) {
|
||||
s := New(nil, nil)
|
||||
got := s.extractResponseSummary([]byte(`{"error": "oops"}`))
|
||||
if got != "" {
|
||||
t.Errorf("no result key: got %q, want %q", got, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResponseSummary_EmptyResult(t *testing.T) {
|
||||
s := New(nil, nil)
|
||||
got := s.extractResponseSummary([]byte(`{"result": {}}`))
|
||||
if got != "" {
|
||||
t.Errorf("empty result: got %q, want %q", got, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResponseSummary_NoPartsKey(t *testing.T) {
|
||||
s := New(nil, nil)
|
||||
got := s.extractResponseSummary([]byte(`{"result": {"data": "hello"}}`))
|
||||
if got != "" {
|
||||
t.Errorf("no parts key: got %q, want %q", got, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResponseSummary_EmptyPartsArray(t *testing.T) {
|
||||
s := New(nil, nil)
|
||||
got := s.extractResponseSummary([]byte(`{"result": {"parts": []}}`))
|
||||
if got != "" {
|
||||
t.Errorf("empty parts: got %q, want %q", got, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResponseSummary_PartsWithText(t *testing.T) {
|
||||
s := New(nil, nil)
|
||||
got := s.extractResponseSummary([]byte(`{"result": {"parts": [{"text": "Hello world"}]}}`))
|
||||
if got != "Hello world" {
|
||||
t.Errorf("got %q, want %q", got, "Hello world")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResponseSummary_MultipleParts(t *testing.T) {
|
||||
// The function returns the FIRST non-empty text it finds.
|
||||
s := New(nil, nil)
|
||||
got := s.extractResponseSummary([]byte(`{"result": {"parts": [{"text": ""}, {"text": "second"}]}}`))
|
||||
if got != "second" {
|
||||
t.Errorf("got %q, want %q", got, "second")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResponseSummary_NonStringText(t *testing.T) {
|
||||
s := New(nil, nil)
|
||||
got := s.extractResponseSummary([]byte(`{"result": {"parts": [{"text": 42}]}}`))
|
||||
if got != "" {
|
||||
t.Errorf("non-string text: got %q, want %q", got, "")
|
||||
}
|
||||
}
|
||||
|
||||
// ── isEmptyResponse coverage ─────────────────────────────────────────────────────
|
||||
|
||||
func TestIsEmptyResponse_NilAndEmpty(t *testing.T) {
|
||||
if !isEmptyResponse(nil) {
|
||||
t.Error("nil body should be empty")
|
||||
}
|
||||
if !isEmptyResponse([]byte{}) {
|
||||
t.Error("empty body should be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEmptyResponse_SentinelMarkers(t *testing.T) {
|
||||
cases := []string{
|
||||
`(no response generated)`,
|
||||
`"text": "(no response generated)"`,
|
||||
`"text":""`,
|
||||
`"text": ""`,
|
||||
}
|
||||
for _, marker := range cases {
|
||||
body := []byte(`{"result":{"parts":[{"text":"` + marker + `"}]}}`)
|
||||
if !isEmptyResponse(body) {
|
||||
t.Errorf("body with marker %q should be empty", marker)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEmptyResponse_RealContent(t *testing.T) {
|
||||
// Any body containing a sentinel marker is treated as empty.
|
||||
// Bodies with no marker and with non-empty text are NOT empty.
|
||||
cases := []struct {
|
||||
body string
|
||||
isEmpty bool
|
||||
}{
|
||||
{`{"result":{"parts":[{"text":"Hello world"}]}}`, false},
|
||||
{`{"result":{"parts":[{"text":"goodbye"}]}}`, false},
|
||||
{`{"text":"hello"}`, false},
|
||||
{`{"result":{"parts":[]}}`, false},
|
||||
// sentinel markers trigger empty=true
|
||||
{`{"result":{"parts":[{"text":"(no response generated) plus more"}]}}`, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := isEmptyResponse([]byte(tc.body))
|
||||
if got != tc.isEmpty {
|
||||
t.Errorf("isEmptyResponse(%q) = %v, want %v", tc.body, got, tc.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEmptyResponse_PartialMarker(t *testing.T) {
|
||||
// The marker must appear as a complete substring. Partial matches don't count.
|
||||
body := []byte(`{"result":{"parts":[{"text":"(no response gen"}]}}`)
|
||||
if isEmptyResponse(body) {
|
||||
t.Error(`partial marker "(no response gen" should NOT match`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── maybeSweepPhantomBusy coverage ─────────────────────────────────────────────
|
||||
|
||||
// phantomSweepInterval = 5 minutes. maybeSweepPhantomBusy skips the DB query
|
||||
// when lastSweepAt is within the interval.
|
||||
|
||||
func TestMaybeSweepPhantomBusy_SkipsWithinInterval(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
// No DB calls expected since we skip within the interval.
|
||||
|
||||
s := New(nil, nil)
|
||||
s.mu.Lock()
|
||||
s.lastSweepAt = time.Now() // just swept
|
||||
s.mu.Unlock()
|
||||
|
||||
s.maybeSweepPhantomBusy(context.Background())
|
||||
|
||||
// Verify no DB calls were made.
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unexpected DB call: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeSweepPhantomBusy_RunsWhenStale(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
|
||||
// Return a row so the sweep logs one reset.
|
||||
rows := sqlmock.NewRows([]string{"id", "name"}).
|
||||
AddRow("ws-phantom", "Phantom Agent")
|
||||
mock.ExpectQuery(`UPDATE workspaces`).
|
||||
WillReturnRows(rows)
|
||||
|
||||
s := New(nil, nil)
|
||||
s.mu.Lock()
|
||||
s.lastSweepAt = time.Now().Add(-6 * time.Minute) // older than 5 min interval
|
||||
s.mu.Unlock()
|
||||
|
||||
s.maybeSweepPhantomBusy(context.Background())
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet DB expectations: %v", err)
|
||||
}
|
||||
|
||||
// Verify lastSweepAt was updated.
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if time.Since(s.lastSweepAt) > time.Second {
|
||||
t.Error("lastSweepAt should be updated to time.Now() after a sweep")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeSweepPhantomBusy_StaleFirstSweep(t *testing.T) {
|
||||
// Zero time.Time is treated as "never swept" — time.Since(zero) is many years,
|
||||
// which is > 5 min, so the sweep runs on first call.
|
||||
mock := setupTestDB(t)
|
||||
|
||||
rows := sqlmock.NewRows([]string{"id", "name"})
|
||||
mock.ExpectQuery(`UPDATE workspaces`).
|
||||
WillReturnRows(rows)
|
||||
|
||||
s := New(nil, nil)
|
||||
// lastSweepAt is zero (never swept) — this should trigger the sweep.
|
||||
s.maybeSweepPhantomBusy(context.Background())
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet DB expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── sweepPhantomBusy DB-error coverage ──────────────────────────────────────────
|
||||
|
||||
func TestSweepPhantomBusy_QueryError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
|
||||
mock.ExpectQuery(`UPDATE workspaces`).
|
||||
WillReturnError(errDBDown)
|
||||
|
||||
s := New(nil, nil)
|
||||
// Should not panic — error is logged and function returns.
|
||||
s.sweepPhantomBusy(context.Background())
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet DB expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweepPhantomBusy_RowsError(t *testing.T) {
|
||||
// Query succeeds but rows.Next() returns an error.
|
||||
mock := setupTestDB(t)
|
||||
|
||||
rows := sqlmock.NewRows([]string{"id", "name"}).
|
||||
AddRow("ws-1", "Test Agent").
|
||||
RowError(0, errDBDown)
|
||||
mock.ExpectQuery(`UPDATE workspaces`).
|
||||
WillReturnRows(rows)
|
||||
|
||||
s := New(nil, nil)
|
||||
s.sweepPhantomBusy(context.Background())
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet DB expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user