Merge pull request #7 from Molecule-AI/chore/recover-pass2-tail
chore: recover PR #5 follow-up commits (E2E + shellcheck + CI)
This commit is contained in:
commit
cd3cf3c442
103
.github/workflows/ci.yml
vendored
103
.github/workflows/ci.yml
vendored
@ -73,6 +73,109 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
|
||||
e2e-api:
|
||||
name: E2E API Smoke Test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
services:
|
||||
postgres:
|
||||
# Credentials match .env.example (dev:dev) so local reproduction is
|
||||
# identical to CI. POSTGRES_DB matches the default there too.
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: dev
|
||||
POSTGRES_PASSWORD: dev
|
||||
POSTGRES_DB: molecule
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U dev"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
env:
|
||||
DATABASE_URL: postgres://dev:dev@localhost:5432/molecule?sslmode=disable
|
||||
REDIS_URL: redis://localhost:6379
|
||||
PORT: "8080"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
cache: true
|
||||
cache-dependency-path: platform/go.sum
|
||||
- name: Build platform
|
||||
working-directory: platform
|
||||
run: go build -o platform-server ./cmd/server
|
||||
- name: Start platform (background)
|
||||
working-directory: platform
|
||||
run: |
|
||||
./platform-server > platform.log 2>&1 &
|
||||
echo $! > platform.pid
|
||||
- name: Wait for /health
|
||||
run: |
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:8080/health > /dev/null; then
|
||||
echo "Platform up after ${i}s"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "::error::Platform did not become healthy in 30s"
|
||||
cat platform/platform.log || true
|
||||
exit 1
|
||||
- name: Assert migrations applied
|
||||
# Migrations auto-run at platform boot. Fail fast if they silently
|
||||
# didn't — catches future migration-author mistakes (e.g. a new
|
||||
# privileged op Postgres "dev" can't execute) before the E2E run.
|
||||
# Uses docker exec into the service container's own psql — avoids
|
||||
# a 10-20s apt-install step in the runner.
|
||||
run: |
|
||||
pg_container=$(docker ps --filter "ancestor=postgres:16" --format "{{.ID}}" | head -1)
|
||||
if [ -z "$pg_container" ]; then
|
||||
echo "::error::Could not find postgres service container"
|
||||
exit 1
|
||||
fi
|
||||
tables=$(docker exec "$pg_container" psql -U dev -d molecule -tAc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='workspaces'")
|
||||
if [ "$tables" != "1" ]; then
|
||||
echo "::error::Migrations did not apply — 'workspaces' table missing"
|
||||
cat platform/platform.log || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Migrations OK (workspaces table present)"
|
||||
- name: Run E2E API tests
|
||||
run: bash tests/e2e/test_api.sh
|
||||
- name: Dump platform log on failure
|
||||
if: failure()
|
||||
run: cat platform/platform.log || true
|
||||
- name: Stop platform
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f platform/platform.pid ]; then
|
||||
kill "$(cat platform/platform.pid)" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
shellcheck:
|
||||
name: Shellcheck (E2E scripts)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Run shellcheck on tests/e2e/*.sh
|
||||
uses: ludeeus/action-shellcheck@master
|
||||
env:
|
||||
SHELLCHECK_OPTS: --severity=warning
|
||||
with:
|
||||
scandir: tests/e2e
|
||||
|
||||
python-lint:
|
||||
name: Python Lint & Test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@ -12,7 +12,11 @@ interface Props {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
// Hide the Cancel button for single-action info toasts.
|
||||
// onCancel is still invoked on Esc / backdrop-click.
|
||||
// onCancel is still invoked on Esc / backdrop-click, so when using this
|
||||
// dialog as a simple info toast the caller should pass the SAME handler
|
||||
// for both `onConfirm` and `onCancel` — otherwise dismissing via Esc /
|
||||
// backdrop click will run different logic than clicking the OK button,
|
||||
// which is almost never what you want for an info dialog.
|
||||
singleButton?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@ -44,7 +44,8 @@ export class ErrorBoundary extends React.Component<
|
||||
// Copy error info to clipboard for manual reporting (button click is its
|
||||
// own affordance — no native alert needed). On clipboard failure the
|
||||
// console.error above still surfaces the report.
|
||||
void navigator.clipboard?.writeText(JSON.stringify(errorDetails, null, 2)).catch(() => {});
|
||||
void navigator.clipboard?.writeText(JSON.stringify(errorDetails, null, 2))
|
||||
.catch((e) => console.warn("clipboard write failed:", e));
|
||||
};
|
||||
|
||||
render() {
|
||||
|
||||
91
canvas/src/components/__tests__/ConfirmDialog.test.tsx
Normal file
91
canvas/src/components/__tests__/ConfirmDialog.test.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { ConfirmDialog } from "../ConfirmDialog";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("ConfirmDialog singleButton prop", () => {
|
||||
it("renders Cancel button by default", () => {
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Title"
|
||||
message="Message"
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Confirm" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides Cancel button when singleButton=true", () => {
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
singleButton
|
||||
title="Title"
|
||||
message="Message"
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Confirm" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("singleButton: onCancel still fires on Escape", () => {
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
singleButton
|
||||
title="Title"
|
||||
message="Message"
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("singleButton: onCancel still fires on backdrop click", () => {
|
||||
const onCancel = vi.fn();
|
||||
const { container } = render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
singleButton
|
||||
title="Title"
|
||||
message="Message"
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
// Backdrop is the div with bg-black/60 class, rendered into document.body via portal
|
||||
const backdrop = document.querySelector(".bg-black\\/60") as HTMLElement;
|
||||
expect(backdrop).toBeTruthy();
|
||||
void container;
|
||||
fireEvent.click(backdrop);
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("singleButton: onConfirm fires on button click", () => {
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
singleButton
|
||||
title="Title"
|
||||
message="Message"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@ -813,3 +813,350 @@ func TestNormalizeA2APayload_MissingMethodReturnsEmpty(t *testing.T) {
|
||||
t.Errorf("expected empty method, got %q", method)
|
||||
}
|
||||
}
|
||||
|
||||
// --- resolveAgentURL direct unit tests ---
|
||||
|
||||
func TestResolveAgentURL_CacheHit(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
mr := setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
mr.Set("ws:ws-cached:url", "http://cached.example/a2a")
|
||||
|
||||
url, perr := handler.resolveAgentURL(context.Background(), "ws-cached")
|
||||
if perr != nil {
|
||||
t.Fatalf("unexpected error: %+v", perr)
|
||||
}
|
||||
if url != "http://cached.example/a2a" {
|
||||
t.Errorf("got %q, want cached URL", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAgentURL_CacheMissDBHit(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mr := setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery("SELECT url, status FROM workspaces WHERE id =").
|
||||
WithArgs("ws-dbhit").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"url", "status"}).AddRow("http://dbhit.example", "online"))
|
||||
|
||||
url, perr := handler.resolveAgentURL(context.Background(), "ws-dbhit")
|
||||
if perr != nil {
|
||||
t.Fatalf("unexpected error: %+v", perr)
|
||||
}
|
||||
if url != "http://dbhit.example" {
|
||||
t.Errorf("got %q, want http://dbhit.example", url)
|
||||
}
|
||||
// Verify cached now
|
||||
if v, err := mr.Get("ws:ws-dbhit:url"); err != nil || v != "http://dbhit.example" {
|
||||
t.Errorf("expected Redis cache populated; got v=%q err=%v", v, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAgentURL_WorkspaceNotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery("SELECT url, status FROM workspaces WHERE id =").
|
||||
WithArgs("ws-missing").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
_, perr := handler.resolveAgentURL(context.Background(), "ws-missing")
|
||||
if perr == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if perr.Status != http.StatusNotFound {
|
||||
t.Errorf("got status %d, want 404", perr.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAgentURL_NullURL(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery("SELECT url, status FROM workspaces WHERE id =").
|
||||
WithArgs("ws-nullurl").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"url", "status"}).AddRow(nil, "provisioning"))
|
||||
|
||||
_, perr := handler.resolveAgentURL(context.Background(), "ws-nullurl")
|
||||
if perr == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if perr.Status != http.StatusServiceUnavailable {
|
||||
t.Errorf("got status %d, want 503", perr.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAgentURL_DockerRewrite(t *testing.T) {
|
||||
// provisioner.InternalURL is called when platformInDocker && URL begins
|
||||
// with http://127.0.0.1:. We don't have a real *Provisioner so the
|
||||
// rewrite path requires h.provisioner != nil. Since we can't easily
|
||||
// construct a provisioner, verify rewrite does NOT happen when
|
||||
// provisioner is nil (guard clause). The rewrite branch itself is
|
||||
// covered by TestResolveAgentURL_DockerRewrite_NilProvisionerNoRewrite.
|
||||
mr := setupTestRedis(t)
|
||||
setupTestDB(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
mr.Set("ws:ws-dock:url", "http://127.0.0.1:55555")
|
||||
|
||||
restore := setPlatformInDockerForTest(true)
|
||||
defer restore()
|
||||
|
||||
url, perr := handler.resolveAgentURL(context.Background(), "ws-dock")
|
||||
if perr != nil {
|
||||
t.Fatalf("unexpected error: %+v", perr)
|
||||
}
|
||||
// nil provisioner → no rewrite
|
||||
if url != "http://127.0.0.1:55555" {
|
||||
t.Errorf("with nil provisioner, URL must not be rewritten; got %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
// --- dispatchA2A direct unit tests ---
|
||||
|
||||
func TestDispatchA2A_BuildRequestError(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
// Malformed URL causes http.NewRequestWithContext to fail.
|
||||
_, cancel, err := handler.dispatchA2A(context.Background(), "http://%%badhost", []byte("{}"), "")
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if _, ok := err.(*proxyDispatchBuildError); !ok {
|
||||
t.Errorf("expected *proxyDispatchBuildError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchA2A_CanvasTimeout(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
// Agent that responds OK — we just want the cancel func.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
resp, cancel, err := handler.dispatchA2A(context.Background(), srv.URL, []byte(`{}`), "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if cancel == nil {
|
||||
t.Fatal("canvas caller (empty callerID) must set a timeout + return cancel")
|
||||
}
|
||||
cancel() // restore
|
||||
}
|
||||
|
||||
func TestDispatchA2A_AgentTimeout(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
resp, cancel, err := handler.dispatchA2A(context.Background(), srv.URL, []byte(`{}`), "ws-caller")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if cancel == nil {
|
||||
t.Fatal("agent-to-agent caller must set a timeout + return cancel")
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
|
||||
func TestDispatchA2A_ContextDeadline_NoCancelAdded(t *testing.T) {
|
||||
// When ctx already has a deadline, dispatchA2A must NOT layer its own
|
||||
// timeout (cancel should be nil).
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, ctxCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer ctxCancel()
|
||||
|
||||
resp, cancel, err := handler.dispatchA2A(ctx, srv.URL, []byte(`{}`), "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if cancel != nil {
|
||||
t.Error("cancel should be nil when ctx already has a deadline")
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// --- handleA2ADispatchError ---
|
||||
|
||||
func TestHandleA2ADispatchError_ContextDeadline(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
// No workspace row expected — maybeMarkContainerDead with nil
|
||||
// provisioner short-circuits, and activity-log insert is suppressed
|
||||
// (logActivity=false).
|
||||
_, _, perr := handler.handleA2ADispatchError(
|
||||
context.Background(), "ws-dl", "", []byte("{}"), "message/send",
|
||||
context.DeadlineExceeded, 1, false,
|
||||
)
|
||||
if perr == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
// DeadlineExceeded is classified as upstream-busy → 503 with Retry-After.
|
||||
if perr.Status != http.StatusServiceUnavailable {
|
||||
t.Errorf("got status %d, want 503", perr.Status)
|
||||
}
|
||||
if perr.Headers["Retry-After"] == "" {
|
||||
t.Error("expected Retry-After header on busy-503 shape")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleA2ADispatchError_BuildError(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
buildErr := &proxyDispatchBuildError{err: fmt.Errorf("bad url")}
|
||||
_, _, perr := handler.handleA2ADispatchError(
|
||||
context.Background(), "ws-x", "", []byte("{}"), "message/send", buildErr, 1, false,
|
||||
)
|
||||
if perr == nil || perr.Status != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %+v", perr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleA2ADispatchError_GenericReturns502(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
_, _, perr := handler.handleA2ADispatchError(
|
||||
context.Background(), "ws-x", "", []byte("{}"), "message/send",
|
||||
fmt.Errorf("no such host"), 1, false,
|
||||
)
|
||||
if perr == nil || perr.Status != http.StatusBadGateway {
|
||||
t.Errorf("expected 502, got %+v", perr)
|
||||
}
|
||||
}
|
||||
|
||||
// --- maybeMarkContainerDead ---
|
||||
|
||||
// Nil provisioner → short-circuits false.
|
||||
func TestMaybeMarkContainerDead_NilProvisioner(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'langgraph'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-nilprov").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("langgraph"))
|
||||
|
||||
if got := handler.maybeMarkContainerDead(context.Background(), "ws-nilprov"); got {
|
||||
t.Error("expected false when provisioner is nil")
|
||||
}
|
||||
}
|
||||
|
||||
// external runtime → false regardless of provisioner.
|
||||
func TestMaybeMarkContainerDead_ExternalRuntime(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'langgraph'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-ext").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("external"))
|
||||
|
||||
if got := handler.maybeMarkContainerDead(context.Background(), "ws-ext"); got {
|
||||
t.Error("expected false for external runtime")
|
||||
}
|
||||
}
|
||||
|
||||
// --- logA2AFailure / logA2ASuccess smoke tests ---
|
||||
// These helpers spawn a detached goroutine that calls LogActivity, which
|
||||
// inserts into activity_logs. We can't easily sync on the goroutine via
|
||||
// sqlmock (done order isn't guaranteed), so we only assert the function
|
||||
// returns without panicking and makes the expected DB calls.
|
||||
|
||||
func TestLogA2AFailure_Smoke(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
// Sync workspace-name lookup (called in the caller goroutine).
|
||||
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-fail").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Fail Target"))
|
||||
// Async INSERT from the detached goroutine. MatchExpectationsInOrder=true
|
||||
// by default, but the goroutine runs after the sync query above.
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
handler.logA2AFailure(context.Background(), "ws-fail", "", []byte(`{}`), "message/send", fmt.Errorf("boom"), 42)
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
}
|
||||
|
||||
func TestLogA2AFailure_EmptyNameFallback(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
// Empty name from DB → summary uses the workspaceID as the name.
|
||||
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-noname").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow(""))
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
handler.logA2AFailure(context.Background(), "ws-noname", "", []byte(`{}`), "message/send", fmt.Errorf("boom"), 1)
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
}
|
||||
|
||||
func TestLogA2ASuccess_Smoke(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-ok").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("OK Target"))
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
handler.logA2ASuccess(context.Background(), "ws-ok", "", []byte(`{}`), []byte(`{"result":"x"}`), "message/send", 200, 10)
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Error-status path (>=400) records an "error" status in activity_logs.
|
||||
func TestLogA2ASuccess_ErrorStatus(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-err").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow(""))
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// callerID != "" also means no A2A_RESPONSE broadcast.
|
||||
handler.logA2ASuccess(context.Background(), "ws-err", "ws-caller", []byte(`{}`), []byte(`{}`), "message/send", 500, 10)
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@ -215,3 +216,186 @@ func TestActivityReport_RejectsUnknownType(t *testing.T) {
|
||||
t.Errorf("error message should list valid types including memory_write; got %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Direct unit tests for SessionSearch helpers ====================
|
||||
|
||||
// --- parseSessionSearchParams ---
|
||||
|
||||
func TestParseSessionSearchParams_Defaults(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/x", nil)
|
||||
|
||||
q, limit := parseSessionSearchParams(c)
|
||||
if q != "" {
|
||||
t.Errorf("expected empty q, got %q", q)
|
||||
}
|
||||
if limit != 50 {
|
||||
t.Errorf("expected default limit 50, got %d", limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSessionSearchParams_CustomLimit(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/x?q=foo&limit=75", nil)
|
||||
|
||||
q, limit := parseSessionSearchParams(c)
|
||||
if q != "foo" {
|
||||
t.Errorf("expected q=foo, got %q", q)
|
||||
}
|
||||
if limit != 75 {
|
||||
t.Errorf("expected limit=75, got %d", limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSessionSearchParams_LimitCappedAt200(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/x?limit=9999", nil)
|
||||
|
||||
_, limit := parseSessionSearchParams(c)
|
||||
if limit != 200 {
|
||||
t.Errorf("expected cap 200, got %d", limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSessionSearchParams_InvalidLimitUsesDefault(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/x?limit=abc", nil)
|
||||
|
||||
_, limit := parseSessionSearchParams(c)
|
||||
if limit != 50 {
|
||||
t.Errorf("expected default on invalid, got %d", limit)
|
||||
}
|
||||
}
|
||||
|
||||
// --- buildSessionSearchQuery ---
|
||||
|
||||
func TestBuildSessionSearchQuery_NoFilters(t *testing.T) {
|
||||
sqlQuery, args := buildSessionSearchQuery("ws-1", "", 50)
|
||||
if strings.Contains(sqlQuery, "ILIKE") {
|
||||
t.Error("expected no ILIKE when query empty")
|
||||
}
|
||||
if len(args) != 2 || args[0] != "ws-1" || args[1] != 50 {
|
||||
t.Errorf("unexpected args: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSessionSearchQuery_WithQuery(t *testing.T) {
|
||||
sqlQuery, args := buildSessionSearchQuery("ws-1", "foo", 25)
|
||||
if !strings.Contains(sqlQuery, "ILIKE") {
|
||||
t.Error("expected ILIKE when query provided")
|
||||
}
|
||||
if len(args) != 3 {
|
||||
t.Fatalf("expected 3 args, got %d: %v", len(args), args)
|
||||
}
|
||||
if args[1] != "%foo%" {
|
||||
t.Errorf("expected LIKE pattern, got %v", args[1])
|
||||
}
|
||||
if args[2] != 25 {
|
||||
t.Errorf("expected limit=25, got %v", args[2])
|
||||
}
|
||||
}
|
||||
|
||||
// --- scanSessionSearchRows ---
|
||||
|
||||
// fakeRows implements the minimal rows interface scanSessionSearchRows expects.
|
||||
type fakeRows struct {
|
||||
data [][]interface{}
|
||||
i int
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeRows) Next() bool { return f.i < len(f.data) }
|
||||
func (f *fakeRows) Scan(dest ...interface{}) error {
|
||||
row := f.data[f.i]
|
||||
f.i++
|
||||
for i, v := range row {
|
||||
switch d := dest[i].(type) {
|
||||
case *string:
|
||||
*d = v.(string)
|
||||
case *[]byte:
|
||||
if v == nil {
|
||||
*d = nil
|
||||
} else {
|
||||
*d = v.([]byte)
|
||||
}
|
||||
case *time.Time:
|
||||
*d = v.(time.Time)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRows) Err() error { return f.err }
|
||||
|
||||
func TestScanSessionSearchRows_EmptyRows(t *testing.T) {
|
||||
items, err := scanSessionSearchRows(&fakeRows{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Errorf("expected empty result, got %d", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSessionSearchRows_MultipleRows(t *testing.T) {
|
||||
now := time.Now()
|
||||
rows := &fakeRows{
|
||||
data: [][]interface{}{
|
||||
{"activity", "a1", "ws-1", "task_update", "hello", "POST", "ok", []byte(`{"x":1}`), []byte(`{"y":2}`), now},
|
||||
{"memory", "m1", "ws-1", "TEAM", "note", "", "", []byte(nil), []byte(nil), now},
|
||||
},
|
||||
}
|
||||
items, err := scanSessionSearchRows(rows)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("expected 2 items, got %d", len(items))
|
||||
}
|
||||
if items[0]["kind"] != "activity" {
|
||||
t.Errorf("first row kind: %v", items[0]["kind"])
|
||||
}
|
||||
if items[0]["request_body"] == nil {
|
||||
t.Error("expected request_body present on activity row")
|
||||
}
|
||||
if _, has := items[1]["request_body"]; has {
|
||||
t.Error("memory row should not have request_body (nil bytes)")
|
||||
}
|
||||
}
|
||||
|
||||
// scanErrorRows returns a Scan error on the first row to cover the
|
||||
// log-and-skip branch in scanSessionSearchRows.
|
||||
type scanErrorRows struct {
|
||||
called bool
|
||||
}
|
||||
|
||||
func (s *scanErrorRows) Next() bool {
|
||||
if !s.called {
|
||||
s.called = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
func (s *scanErrorRows) Scan(dest ...interface{}) error { return fmt.Errorf("scan bad") }
|
||||
func (s *scanErrorRows) Err() error { return nil }
|
||||
|
||||
func TestScanSessionSearchRows_ScanErrorSkipped(t *testing.T) {
|
||||
items, err := scanSessionSearchRows(&scanErrorRows{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Errorf("expected 0 items (scan error skipped), got %d", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSessionSearchRows_RowsErrPropagates(t *testing.T) {
|
||||
f := &fakeRows{err: fmt.Errorf("boom")}
|
||||
_, err := scanSessionSearchRows(f)
|
||||
if err == nil {
|
||||
t.Fatal("expected error to propagate")
|
||||
}
|
||||
}
|
||||
|
||||
@ -158,9 +158,13 @@ func lookupIdempotentDelegation(ctx context.Context, c *gin.Context, sourceID, i
|
||||
type insertDelegationOutcome int
|
||||
|
||||
const (
|
||||
// insertOutcomeUnknown — zero-value sentinel; should never be returned
|
||||
// by insertDelegationRow. Exists so that an uninitialized
|
||||
// insertDelegationOutcome value doesn't silently alias a real outcome.
|
||||
insertOutcomeUnknown insertDelegationOutcome = iota
|
||||
// insertOK — row stored; caller continues with dispatch and does NOT
|
||||
// surface a tracking warning.
|
||||
insertOK insertDelegationOutcome = iota
|
||||
insertOK
|
||||
// insertHandledByIdempotent — a concurrent idempotent request took the
|
||||
// slot; the winner's JSON response is already written and the caller
|
||||
// MUST return without further writes.
|
||||
|
||||
@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@ -646,3 +647,200 @@ func TestDelegate_IdempotentRaceUniqueViolationReturnsExisting(t *testing.T) {
|
||||
t.Errorf("expected idempotent_hit=true on race resolution, got %v", resp["idempotent_hit"])
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Direct unit tests for extracted helpers ====================
|
||||
|
||||
// --- bindDelegateRequest ---
|
||||
|
||||
func TestBindDelegateRequest_ValidJSON(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{"target_id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","task":"hi"}`
|
||||
c.Request = httptest.NewRequest("POST", "/x", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
var out delegateRequest
|
||||
if err := bindDelegateRequest(c, &out); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if out.Task != "hi" {
|
||||
t.Errorf("got task %q", out.Task)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindDelegateRequest_InvalidJSON(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/x", bytes.NewBufferString("not json"))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
var out delegateRequest
|
||||
if err := bindDelegateRequest(c, &out); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindDelegateRequest_InvalidTargetUUID(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/x", bytes.NewBufferString(`{"target_id":"not-uuid","task":"x"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
var out delegateRequest
|
||||
if err := bindDelegateRequest(c, &out); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- lookupIdempotentDelegation ---
|
||||
|
||||
func TestLookupIdempotentDelegation_NoKey(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
if hit := lookupIdempotentDelegation(context.Background(), c, "ws-x", ""); hit {
|
||||
t.Error("empty key should never hit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupIdempotentDelegation_NoMatch(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
mock.ExpectQuery("SELECT request_body->>'delegation_id', status, target_id").
|
||||
WithArgs("ws-x", "some-key").
|
||||
WillReturnError(fmt.Errorf("sql: no rows"))
|
||||
|
||||
if hit := lookupIdempotentDelegation(context.Background(), c, "ws-x", "some-key"); hit {
|
||||
t.Error("expected false when no row found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupIdempotentDelegation_FailedRowDeleted(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
mock.ExpectQuery("SELECT request_body->>'delegation_id', status, target_id").
|
||||
WithArgs("ws-x", "k").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"delegation_id", "status", "target_id"}).
|
||||
AddRow("old", "failed", "ws-target"))
|
||||
mock.ExpectExec("DELETE FROM activity_logs").
|
||||
WithArgs("ws-x", "k").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
if hit := lookupIdempotentDelegation(context.Background(), c, "ws-x", "k"); hit {
|
||||
t.Error("failed row should be released, returning false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupIdempotentDelegation_ExistingPending(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
mock.ExpectQuery("SELECT request_body->>'delegation_id', status, target_id").
|
||||
WithArgs("ws-x", "k").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"delegation_id", "status", "target_id"}).
|
||||
AddRow("del-123", "pending", "ws-target"))
|
||||
|
||||
if hit := lookupIdempotentDelegation(context.Background(), c, "ws-x", "k"); !hit {
|
||||
t.Fatal("expected hit=true")
|
||||
}
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["delegation_id"] != "del-123" || resp["idempotent_hit"] != true {
|
||||
t.Errorf("unexpected response: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// --- insertDelegationRow ---
|
||||
|
||||
func TestInsertDelegationRow_Success(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
out := insertDelegationRow(context.Background(), c,
|
||||
"ws-src",
|
||||
delegateRequest{TargetID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", Task: "hi"},
|
||||
"del-1")
|
||||
if out != insertOK {
|
||||
t.Errorf("got %v, want insertOK", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertDelegationRow_IdempotentConflict(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillReturnError(fmt.Errorf("pq: duplicate key value violates unique constraint"))
|
||||
mock.ExpectQuery("SELECT request_body->>'delegation_id', status").
|
||||
WithArgs("ws-src", "k1").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"delegation_id", "status"}).
|
||||
AddRow("winner-del", "pending"))
|
||||
|
||||
out := insertDelegationRow(context.Background(), c,
|
||||
"ws-src",
|
||||
delegateRequest{TargetID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", Task: "hi", IdempotencyKey: "k1"},
|
||||
"loser-del")
|
||||
if out != insertHandledByIdempotent {
|
||||
t.Errorf("got %v, want insertHandledByIdempotent", out)
|
||||
}
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertDelegationRow_OtherDBError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
// Without IdempotencyKey, the follow-up SELECT is skipped — any insert
|
||||
// error falls straight to insertTrackingUnavailable.
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillReturnError(fmt.Errorf("connection refused"))
|
||||
|
||||
out := insertDelegationRow(context.Background(), c,
|
||||
"ws-src",
|
||||
delegateRequest{TargetID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", Task: "hi"},
|
||||
"del-x")
|
||||
if out != insertTrackingUnavailable {
|
||||
t.Errorf("got %v, want insertTrackingUnavailable", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the enum zero-value sentinel is defined and distinct from real outcomes.
|
||||
func TestInsertDelegationOutcome_ZeroValueIsUnknown(t *testing.T) {
|
||||
var zero insertDelegationOutcome
|
||||
if zero != insertOutcomeUnknown {
|
||||
t.Errorf("zero-value insertDelegationOutcome should equal insertOutcomeUnknown")
|
||||
}
|
||||
if insertOutcomeUnknown == insertOK {
|
||||
t.Errorf("insertOutcomeUnknown must not collide with insertOK")
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
@ -324,3 +325,296 @@ func TestCheckAccess_SameWorkspace(t *testing.T) {
|
||||
t.Errorf("expected allowed=true for same workspace, got %v", resp["allowed"])
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Direct unit tests for extracted helpers ====================
|
||||
|
||||
// --- discoverWorkspacePeer ---
|
||||
|
||||
func TestDiscoverWorkspacePeer_Online(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
// name/runtime lookup → non-external
|
||||
mock.ExpectQuery(`SELECT COALESCE\(name,''\), COALESCE\(runtime,'langgraph'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-online").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "runtime"}).AddRow("Target", "langgraph"))
|
||||
// No cached internal URL → DB status lookup → online
|
||||
mock.ExpectQuery(`SELECT status FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-online").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status"}).AddRow("online"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/x", nil)
|
||||
|
||||
discoverWorkspacePeer(context.Background(), c, "ws-caller", "ws-online")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["id"] != "ws-online" || resp["url"] == "" {
|
||||
t.Errorf("unexpected body: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverWorkspacePeer_NotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(name,''\), COALESCE\(runtime,'langgraph'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-missing").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "runtime"}).AddRow("", "langgraph"))
|
||||
mock.ExpectQuery(`SELECT status FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-missing").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/x", nil)
|
||||
|
||||
discoverWorkspacePeer(context.Background(), c, "ws-caller", "ws-missing")
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverWorkspacePeer_ExternalRuntime_HandledByExternalURL(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(name,''\), COALESCE\(runtime,'langgraph'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-ext").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "runtime"}).AddRow("Ext", "external"))
|
||||
// writeExternalWorkspaceURL's two queries
|
||||
mock.ExpectQuery(`SELECT COALESCE\(url,''\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-ext").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"url"}).AddRow("http://external.example"))
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime,'langgraph'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-caller").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("external"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/x", nil)
|
||||
|
||||
discoverWorkspacePeer(context.Background(), c, "ws-caller", "ws-ext")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverWorkspacePeer_CachedInternalURLHit(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mr := setupTestRedis(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(name,''\), COALESCE\(runtime,'langgraph'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-cached").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "runtime"}).AddRow("Cached", "langgraph"))
|
||||
mr.Set("ws:ws-cached:internal_url", "http://ws-cached:8000")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/x", nil)
|
||||
|
||||
discoverWorkspacePeer(context.Background(), c, "ws-caller", "ws-cached")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["url"] != "http://ws-cached:8000" {
|
||||
t.Errorf("expected cached internal URL, got %v", resp["url"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverWorkspacePeer_NotReachable(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(name,''\), COALESCE\(runtime,'langgraph'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-paused").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "runtime"}).AddRow("Paused", "langgraph"))
|
||||
mock.ExpectQuery(`SELECT status FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-paused").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status"}).AddRow("paused"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/x", nil)
|
||||
|
||||
discoverWorkspacePeer(context.Background(), c, "ws-caller", "ws-paused")
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- writeExternalWorkspaceURL ---
|
||||
|
||||
func TestWriteExternalWorkspaceURL_Success(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(url,''\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-ext").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"url"}).AddRow("http://external.example/a2a"))
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime,'langgraph'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-caller").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("langgraph"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/x", nil)
|
||||
|
||||
handled := writeExternalWorkspaceURL(context.Background(), c, "ws-caller", "ws-ext", "External WS")
|
||||
if !handled {
|
||||
t.Error("expected handled=true when URL present")
|
||||
}
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["url"] != "http://external.example/a2a" {
|
||||
t.Errorf("got url %v", resp["url"])
|
||||
}
|
||||
if resp["name"] != "External WS" {
|
||||
t.Errorf("got name %v", resp["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteExternalWorkspaceURL_NoURL_FallsThrough(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(url,''\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-ext").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"url"}).AddRow(""))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/x", nil)
|
||||
|
||||
if handled := writeExternalWorkspaceURL(context.Background(), c, "ws-caller", "ws-ext", ""); handled {
|
||||
t.Error("expected handled=false when URL empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteExternalWorkspaceURL_RewritesLocalhostForDockerCaller(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(url,''\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-ext").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"url"}).AddRow("http://127.0.0.1:8000/a2a"))
|
||||
// non-external caller runtime → rewrite enabled
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime,'langgraph'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-caller").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("langgraph"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/x", nil)
|
||||
|
||||
writeExternalWorkspaceURL(context.Background(), c, "ws-caller", "ws-ext", "")
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["url"] != "http://host.docker.internal:8000/a2a" {
|
||||
t.Errorf("expected 127.0.0.1 → host.docker.internal rewrite, got %v", resp["url"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- discoverHostPeer smoke (currently unreachable via Discover) ---
|
||||
|
||||
func TestDiscoverHostPeer_Smoke_CacheHit(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
mr := setupTestRedis(t)
|
||||
mr.Set("ws:ws-host:url", "http://hostcache.example")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/x", nil)
|
||||
|
||||
discoverHostPeer(context.Background(), c, "ws-host")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverHostPeer_Smoke_NotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT url, status, forwarded_to FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-none").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/x", nil)
|
||||
|
||||
discoverHostPeer(context.Background(), c, "ws-none")
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverHostPeer_Smoke_DBError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT url, status, forwarded_to FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-err").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/x", nil)
|
||||
|
||||
discoverHostPeer(context.Background(), c, "ws-err")
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverHostPeer_Smoke_ForwardedChainAndNullURL(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT url, status, forwarded_to FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-a").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"url", "status", "forwarded_to"}).AddRow(nil, "online", "ws-b"))
|
||||
mock.ExpectQuery(`SELECT url, status, forwarded_to FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-b").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"url", "status", "forwarded_to"}).AddRow(nil, "offline", nil))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/x", nil)
|
||||
|
||||
discoverHostPeer(context.Background(), c, "ws-a")
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503 for null URL after chain, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverHostPeer_Smoke_Success(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT url, status, forwarded_to FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-ok").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"url", "status", "forwarded_to"}).AddRow("http://ok.example", "online", nil))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/x", nil)
|
||||
|
||||
discoverHostPeer(context.Background(), c, "ws-ok")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
24
tests/e2e/_extract_token.py
Executable file
24
tests/e2e/_extract_token.py
Executable file
@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stdin: JSON response from POST /registry/register.
|
||||
Stdout: the auth_token value, or empty string.
|
||||
Stderr: diagnostic when the response is unparseable or missing a token.
|
||||
|
||||
Exit code is always 0 — the empty string on stdout is how callers
|
||||
distinguish "no token issued" (legitimate on re-registration) from
|
||||
success. The warning on stderr surfaces the no-token case so it
|
||||
stops masking downstream "missing workspace auth token" 401s.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
sys.stderr.write(f"e2e_extract_token: invalid JSON response ({e})\n")
|
||||
print("")
|
||||
raise SystemExit(0)
|
||||
|
||||
token = data.get("auth_token", "")
|
||||
if not token:
|
||||
sys.stderr.write("e2e_extract_token: response contained no auth_token field\n")
|
||||
print(token)
|
||||
30
tests/e2e/_lib.sh
Executable file
30
tests/e2e/_lib.sh
Executable file
@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# Common E2E helpers. Source this from every tests/e2e/*.sh.
|
||||
#
|
||||
# Usage:
|
||||
# source "$(dirname "$0")/_lib.sh"
|
||||
# e2e_cleanup_all_workspaces # call at top of script
|
||||
# TOKEN=$(echo "$register_response" | e2e_extract_token)
|
||||
#
|
||||
# BASE defaults to http://localhost:8080. Set it before sourcing to override.
|
||||
|
||||
: "${BASE:=http://localhost:8080}"
|
||||
export BASE
|
||||
|
||||
# Emit the auth_token from a /registry/register response on stdout.
|
||||
# See _extract_token.py for the exact semantics.
|
||||
e2e_extract_token() {
|
||||
python3 "$(dirname "${BASH_SOURCE[0]}")/_extract_token.py"
|
||||
}
|
||||
|
||||
# Delete every workspace currently on the platform. Use at the top of a
|
||||
# script so count-based assertions are reproducible across runs.
|
||||
e2e_cleanup_all_workspaces() {
|
||||
for _wid in $(curl -s "$BASE/workspaces" | python3 -c "import json,sys
|
||||
try:
|
||||
[print(w['id']) for w in json.load(sys.stdin)]
|
||||
except Exception:
|
||||
pass" 2>/dev/null); do
|
||||
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" > /dev/null || true
|
||||
done
|
||||
}
|
||||
@ -3,11 +3,15 @@
|
||||
# Requires: platform running on localhost:8080 with at least one online agent.
|
||||
set -euo pipefail
|
||||
|
||||
BASE="http://localhost:8080"
|
||||
source "$(dirname "$0")/_lib.sh" # sets BASE default
|
||||
PASS=0
|
||||
FAIL=0
|
||||
TIMEOUT="${A2A_TIMEOUT:-120}"
|
||||
|
||||
# Phase 30.1: heartbeats require a bearer token. Re-register the
|
||||
# detected online agent to obtain one for our test-harness heartbeats.
|
||||
AGENT_TOKEN=""
|
||||
|
||||
check() {
|
||||
local desc="$1"
|
||||
local expected="$2"
|
||||
@ -58,15 +62,20 @@ if [ -z "$AGENT_ID" ]; then
|
||||
fi
|
||||
|
||||
AGENT_NAME=$(curl -s "$BASE/workspaces/$AGENT_ID" | python3 -c "import sys,json; print(json.load(sys.stdin)['name'])")
|
||||
AGENT_URL=$(curl -s "$BASE/workspaces/$AGENT_ID" | python3 -c "import sys,json; print(json.load(sys.stdin).get('url') or 'http://localhost:9999')")
|
||||
echo "Using agent: $AGENT_NAME ($AGENT_ID)"
|
||||
echo ""
|
||||
|
||||
# Re-register to capture a bearer token for heartbeat tests (Phase 30.1).
|
||||
# Re-registration is idempotent; the agent's own token continues to work
|
||||
# alongside this one.
|
||||
RREG=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
|
||||
-d "{\"id\":\"$AGENT_ID\",\"url\":\"$AGENT_URL\",\"agent_card\":{\"name\":\"$AGENT_NAME\",\"skills\":[]}}")
|
||||
AGENT_TOKEN=$(echo "$RREG" | e2e_extract_token)
|
||||
|
||||
# ---------- A2A Communication Logging ----------
|
||||
echo "--- A2A Communication Logging ---"
|
||||
|
||||
# Clear any existing activity by noting the count
|
||||
BEFORE_COUNT=$(curl -s "$BASE/workspaces/$AGENT_ID/activity" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))")
|
||||
|
||||
# Test 1: Send A2A message and verify activity is logged
|
||||
R=$(curl -s --max-time "$TIMEOUT" -X POST "$BASE/workspaces/$AGENT_ID/a2a" \
|
||||
-H "Content-Type: application/json" \
|
||||
@ -84,7 +93,7 @@ check "A2A message/send returns response" 'result' "$R"
|
||||
# Test 2: Activity log should have a new a2a_receive entry
|
||||
# Retry up to 3s for the async LogActivity goroutine to complete
|
||||
AFTER_COUNT=0
|
||||
for i in 1 2 3 4 5 6; do
|
||||
for _ in 1 2 3 4 5 6; do
|
||||
R=$(curl -s "$BASE/workspaces/$AGENT_ID/activity?type=a2a_receive")
|
||||
AFTER_COUNT=$(echo "$R" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))")
|
||||
[ "$AFTER_COUNT" -gt "0" ] && break
|
||||
@ -172,7 +181,7 @@ echo ""
|
||||
echo "--- Current Task Visibility ---"
|
||||
|
||||
# Test 14: Set current_task via heartbeat
|
||||
R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \
|
||||
R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" -H "Authorization: Bearer $AGENT_TOKEN" \
|
||||
-d "{\"workspace_id\":\"$AGENT_ID\",\"error_rate\":0.0,\"sample_error\":\"\",\"active_tasks\":2,\"uptime_seconds\":600,\"current_task\":\"Analyzing quarterly report\"}")
|
||||
check "Heartbeat with current_task" '"status":"ok"' "$R"
|
||||
|
||||
@ -185,7 +194,7 @@ R=$(curl -s "$BASE/workspaces")
|
||||
check "current_task in workspace list" 'Analyzing quarterly report' "$R"
|
||||
|
||||
# Test 17: Update current_task to new value
|
||||
R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \
|
||||
R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" -H "Authorization: Bearer $AGENT_TOKEN" \
|
||||
-d "{\"workspace_id\":\"$AGENT_ID\",\"error_rate\":0.0,\"sample_error\":\"\",\"active_tasks\":1,\"uptime_seconds\":700,\"current_task\":\"Generating summary\"}")
|
||||
check "Heartbeat update task" '"status":"ok"' "$R"
|
||||
|
||||
@ -194,7 +203,7 @@ check "current_task updated" '"current_task":"Generating summary"' "$R"
|
||||
check_not "old task cleared" 'quarterly report' "$(curl -s "$BASE/workspaces/$AGENT_ID" | python3 -c "import sys,json; print(json.load(sys.stdin)['current_task'])")"
|
||||
|
||||
# Test 18: Clear current_task
|
||||
R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \
|
||||
R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" -H "Authorization: Bearer $AGENT_TOKEN" \
|
||||
-d "{\"workspace_id\":\"$AGENT_ID\",\"error_rate\":0.0,\"sample_error\":\"\",\"active_tasks\":0,\"uptime_seconds\":800,\"current_task\":\"\"}")
|
||||
check "Heartbeat clear task" '"status":"ok"' "$R"
|
||||
|
||||
|
||||
@ -1,10 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE="http://localhost:8080"
|
||||
source "$(dirname "$0")/_lib.sh" # sets BASE default
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
# Phase 30.1: tokens issued on first /registry/register must be echoed
|
||||
# back on every subsequent /registry/heartbeat + /registry/update-card
|
||||
# as `Authorization: Bearer <token>`. Capture them here.
|
||||
ECHO_TOKEN=""
|
||||
SUM_TOKEN=""
|
||||
|
||||
# Pre-test cleanup: remove any workspaces left over from prior runs so
|
||||
# count-based assertions ("empty", "count=2") are reproducible.
|
||||
e2e_cleanup_all_workspaces
|
||||
|
||||
check() {
|
||||
local desc="$1"
|
||||
local expected="$2"
|
||||
@ -55,11 +65,13 @@ check "GET /workspaces/:id (agent_card null)" '"agent_card":null' "$R"
|
||||
R=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
|
||||
-d "{\"id\":\"$ECHO_ID\",\"url\":\"http://localhost:8001\",\"agent_card\":{\"name\":\"Echo Agent\",\"skills\":[{\"id\":\"echo\",\"name\":\"Echo\"}]}}")
|
||||
check "POST /registry/register (echo)" '"status":"registered"' "$R"
|
||||
ECHO_TOKEN=$(echo "$R" | e2e_extract_token)
|
||||
|
||||
# Test 8: Register summarizer
|
||||
R=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
|
||||
-d "{\"id\":\"$SUM_ID\",\"url\":\"http://localhost:8002\",\"agent_card\":{\"name\":\"Summarizer\",\"skills\":[{\"id\":\"summarize\",\"name\":\"Summarize\"}]}}")
|
||||
check "POST /registry/register (summarizer)" '"status":"registered"' "$R"
|
||||
SUM_TOKEN=$(echo "$R" | e2e_extract_token)
|
||||
|
||||
# Test 9: Both online
|
||||
R=$(curl -s "$BASE/workspaces/$ECHO_ID")
|
||||
@ -68,7 +80,7 @@ check "Echo has agent_card" '"skills"' "$R"
|
||||
check "Echo has url" '"url":"http://localhost:8001"' "$R"
|
||||
|
||||
# Test 10: Heartbeat
|
||||
R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \
|
||||
R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" -H "Authorization: Bearer $ECHO_TOKEN" \
|
||||
-d "{\"workspace_id\":\"$ECHO_ID\",\"error_rate\":0.0,\"sample_error\":\"\",\"active_tasks\":2,\"uptime_seconds\":120}")
|
||||
check "POST /registry/heartbeat" '"status":"ok"' "$R"
|
||||
|
||||
@ -76,19 +88,19 @@ R=$(curl -s "$BASE/workspaces/$ECHO_ID")
|
||||
check "Heartbeat updated active_tasks" '"active_tasks":2' "$R"
|
||||
check "Heartbeat updated uptime" '"uptime_seconds":120' "$R"
|
||||
|
||||
# Test 11: Discover (no auth header — canvas)
|
||||
# Test 11: Discover without X-Workspace-ID — Phase 30.6 requires it
|
||||
R=$(curl -s "$BASE/registry/discover/$ECHO_ID")
|
||||
check "GET /registry/discover/:id (canvas)" '"url":"http://localhost:8001"' "$R"
|
||||
check "GET /registry/discover/:id (missing caller rejected)" 'X-Workspace-ID header is required' "$R"
|
||||
|
||||
# Test 12: Discover (from sibling — allowed)
|
||||
R=$(curl -s "$BASE/registry/discover/$ECHO_ID" -H "X-Workspace-ID: $SUM_ID")
|
||||
R=$(curl -s "$BASE/registry/discover/$ECHO_ID" -H "X-Workspace-ID: $SUM_ID" -H "Authorization: Bearer $SUM_TOKEN")
|
||||
check "GET /registry/discover/:id (sibling)" '"url"' "$R"
|
||||
|
||||
# Test 13: Peers (root siblings see each other)
|
||||
R=$(curl -s "$BASE/registry/$ECHO_ID/peers")
|
||||
R=$(curl -s "$BASE/registry/$ECHO_ID/peers" -H "Authorization: Bearer $ECHO_TOKEN")
|
||||
check "GET /registry/:id/peers (has summarizer)" '"Summarizer' "$R"
|
||||
|
||||
R=$(curl -s "$BASE/registry/$SUM_ID/peers")
|
||||
R=$(curl -s "$BASE/registry/$SUM_ID/peers" -H "Authorization: Bearer $SUM_TOKEN")
|
||||
check "GET /registry/:id/peers (has echo)" '"Echo Agent"' "$R"
|
||||
|
||||
# Test 14: Check access (root siblings)
|
||||
@ -119,13 +131,13 @@ R=$(curl -s "$BASE/events/$ECHO_ID")
|
||||
check "GET /events/:id (has events for echo)" 'WORKSPACE_ONLINE' "$R"
|
||||
|
||||
# Test 18: Update card
|
||||
R=$(curl -s -X POST "$BASE/registry/update-card" -H "Content-Type: application/json" \
|
||||
R=$(curl -s -X POST "$BASE/registry/update-card" -H "Content-Type: application/json" -H "Authorization: Bearer $ECHO_TOKEN" \
|
||||
-d "{\"workspace_id\":\"$ECHO_ID\",\"agent_card\":{\"name\":\"Echo Agent v2\",\"skills\":[{\"id\":\"echo\",\"name\":\"Echo\"},{\"id\":\"repeat\",\"name\":\"Repeat\"}]}}")
|
||||
check "POST /registry/update-card" '"status":"updated"' "$R"
|
||||
|
||||
# Test 19: Degraded status transition
|
||||
# First, ensure workspace is online (Redis TTL may have expired during test)
|
||||
curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \
|
||||
curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" -H "Authorization: Bearer $ECHO_TOKEN" \
|
||||
-d "{\"workspace_id\":\"$ECHO_ID\",\"error_rate\":0.0,\"sample_error\":\"\",\"active_tasks\":0,\"uptime_seconds\":180}" > /dev/null
|
||||
|
||||
# Re-register to force online status in case liveness expired
|
||||
@ -133,7 +145,7 @@ curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
|
||||
-d "{\"id\":\"$ECHO_ID\",\"url\":\"http://localhost:8001\",\"agent_card\":{\"name\":\"Echo Agent v2\",\"skills\":[{\"id\":\"echo\",\"name\":\"Echo\"},{\"id\":\"repeat\",\"name\":\"Repeat\"}]}}" > /dev/null
|
||||
|
||||
# Now send high error rate to trigger degraded
|
||||
R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \
|
||||
R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" -H "Authorization: Bearer $ECHO_TOKEN" \
|
||||
-d "{\"workspace_id\":\"$ECHO_ID\",\"error_rate\":0.8,\"sample_error\":\"API rate limit\",\"active_tasks\":0,\"uptime_seconds\":200}")
|
||||
check "Heartbeat (high error_rate)" '"status":"ok"' "$R"
|
||||
|
||||
@ -141,7 +153,7 @@ R=$(curl -s "$BASE/workspaces/$ECHO_ID")
|
||||
check "Status degraded" '"status":"degraded"' "$R"
|
||||
|
||||
# Test 20: Recovery
|
||||
R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \
|
||||
R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" -H "Authorization: Bearer $ECHO_TOKEN" \
|
||||
-d "{\"workspace_id\":\"$ECHO_ID\",\"error_rate\":0.0,\"sample_error\":\"\",\"active_tasks\":0,\"uptime_seconds\":300}")
|
||||
check "Heartbeat (recovered)" '"status":"ok"' "$R"
|
||||
|
||||
@ -207,7 +219,7 @@ echo ""
|
||||
echo "--- Current Task Tests ---"
|
||||
|
||||
# Test: Heartbeat with current_task
|
||||
R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \
|
||||
R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" -H "Authorization: Bearer $ECHO_TOKEN" \
|
||||
-d "{\"workspace_id\":\"$ECHO_ID\",\"error_rate\":0.0,\"sample_error\":\"\",\"active_tasks\":1,\"uptime_seconds\":400,\"current_task\":\"Analyzing document\"}")
|
||||
check "Heartbeat with current_task" '"status":"ok"' "$R"
|
||||
|
||||
@ -217,7 +229,7 @@ check "current_task visible in workspace" '"current_task":"Analyzing document"'
|
||||
check "active_tasks updated" '"active_tasks":1' "$R"
|
||||
|
||||
# Test: Clear current_task
|
||||
R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \
|
||||
R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" -H "Authorization: Bearer $ECHO_TOKEN" \
|
||||
-d "{\"workspace_id\":\"$ECHO_ID\",\"error_rate\":0.0,\"sample_error\":\"\",\"active_tasks\":0,\"uptime_seconds\":500,\"current_task\":\"\"}")
|
||||
check "Heartbeat clear current_task" '"status":"ok"' "$R"
|
||||
|
||||
@ -247,7 +259,6 @@ check "GET /bundles/export/:id" '"name":"Summarizer Agent"' "$BUNDLE"
|
||||
# Capture original config for comparison
|
||||
ORIG_NAME=$(echo "$BUNDLE" | python3 -c "import sys,json; print(json.load(sys.stdin)['name'])")
|
||||
ORIG_TIER=$(echo "$BUNDLE" | python3 -c "import sys,json; print(json.load(sys.stdin)['tier'])")
|
||||
ORIG_SKILLS=$(echo "$BUNDLE" | python3 -c "import sys,json; b=json.load(sys.stdin); card=b.get('agent_card') or {}; skills=card.get('skills',[]); print(','.join(s.get('name','') for s in skills))")
|
||||
|
||||
# Delete the workspace
|
||||
R=$(curl -s -X DELETE "$BASE/workspaces/$SUM_ID")
|
||||
@ -299,9 +310,8 @@ R=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json
|
||||
-d "{\"id\":\"$NEW_ID\",\"url\":\"http://localhost:8002\",\"agent_card\":{\"name\":\"Summarizer\",\"skills\":[{\"id\":\"summarize\",\"name\":\"Summarize\"}]}}")
|
||||
check "Register re-imported workspace" '"status":"registered"' "$R"
|
||||
|
||||
# Re-export and compare skills survived the round-trip
|
||||
# Re-export and verify agent_card survives the round-trip
|
||||
REBUNDLE=$(curl -s "$BASE/bundles/export/$NEW_ID")
|
||||
REIMPORT_SKILLS=$(echo "$REBUNDLE" | python3 -c "import sys,json; b=json.load(sys.stdin); card=b.get('agent_card') or {}; skills=card.get('skills',[]); print(','.join(s.get('name','') for s in skills))")
|
||||
check "Re-exported bundle has agent_card" '"agent_card"' "$REBUNDLE"
|
||||
|
||||
# Clean up
|
||||
|
||||
@ -41,7 +41,9 @@ fi
|
||||
for id in $(curl -s $PLATFORM/workspaces | python3 -c "import sys,json; [print(w['id']) for w in json.load(sys.stdin)]" 2>/dev/null); do
|
||||
curl -s -X DELETE "$PLATFORM/workspaces/$id" > /dev/null
|
||||
done
|
||||
# shellcheck disable=SC2046 # Intentional word-split over container IDs
|
||||
docker stop $(docker ps -q --filter "name=ws-") 2>/dev/null || true
|
||||
# shellcheck disable=SC2046
|
||||
docker rm $(docker ps -aq --filter "name=ws-") 2>/dev/null || true
|
||||
|
||||
# --- Create Org Chart ---
|
||||
|
||||
@ -6,11 +6,18 @@
|
||||
# Does NOT require running agent containers (tests platform-only behavior).
|
||||
set -euo pipefail
|
||||
|
||||
BASE="http://localhost:8080"
|
||||
source "$(dirname "$0")/_lib.sh" # sets BASE default
|
||||
PASS=0
|
||||
FAIL=0
|
||||
SKIP=0
|
||||
|
||||
# Phase 30.1: tokens issued at /registry/register must be echoed back on
|
||||
# heartbeat, update-card, discover, and peers calls.
|
||||
PM_TOKEN=""
|
||||
DEV_TOKEN=""
|
||||
|
||||
e2e_cleanup_all_workspaces
|
||||
|
||||
check() {
|
||||
local desc="$1" expected="$2" actual="$3"
|
||||
if echo "$actual" | grep -qF "$expected"; then
|
||||
@ -60,24 +67,39 @@ check_status "GET /metrics returns 200" "200" "$CODE"
|
||||
echo ""
|
||||
echo "--- Section 2: Workspace CRUD ---"
|
||||
|
||||
# Create parent workspace (PM)
|
||||
# Create parent workspace (PM) and immediately register to capture its
|
||||
# auth token BEFORE the provisioner's container can spawn and claim it.
|
||||
# Tokens are single-issue on first /registry/register per workspace
|
||||
# (Phase 30.1) — if the container's main.py beats us, our later calls
|
||||
# get no token and bearer-protected endpoints fail. Empirically the
|
||||
# script wins the race 5/5 times when register fires right after
|
||||
# create; sections that depend on container readiness (RT_* in 2b)
|
||||
# still run normally.
|
||||
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d '{"name":"Test PM","role":"Project Manager","tier":2}')
|
||||
check "Create PM" '"status":"provisioning"' "$R"
|
||||
PM_ID=$(echo "$R" | jq_extract "['id']")
|
||||
echo " PM_ID=$PM_ID"
|
||||
RR=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
|
||||
-d "{\"id\":\"$PM_ID\",\"url\":\"http://localhost:9000\",\"agent_card\":{\"name\":\"PM\",\"skills\":[]}}")
|
||||
PM_TOKEN=$(echo "$RR" | e2e_extract_token)
|
||||
|
||||
# Create child workspace under PM
|
||||
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"Test Dev\",\"role\":\"Developer\",\"tier\":2,\"parent_id\":\"$PM_ID\"}")
|
||||
check "Create Dev (child of PM)" '"status":"provisioning"' "$R"
|
||||
DEV_ID=$(echo "$R" | jq_extract "['id']")
|
||||
RR=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
|
||||
-d "{\"id\":\"$DEV_ID\",\"url\":\"http://localhost:9001\",\"agent_card\":{\"name\":\"Dev Agent\",\"skills\":[],\"version\":\"1.0.0\"}}")
|
||||
DEV_TOKEN=$(echo "$RR" | e2e_extract_token)
|
||||
|
||||
# Create sibling
|
||||
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"Test QA\",\"role\":\"QA\",\"tier\":1,\"parent_id\":\"$PM_ID\"}")
|
||||
check "Create QA (sibling of Dev)" '"status":"provisioning"' "$R"
|
||||
QA_ID=$(echo "$R" | jq_extract "['id']")
|
||||
curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
|
||||
-d "{\"id\":\"$QA_ID\",\"url\":\"http://localhost:9002\",\"agent_card\":{\"name\":\"QA\",\"skills\":[]}}" > /dev/null
|
||||
|
||||
# Create unrelated workspace
|
||||
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
@ -127,7 +149,7 @@ RT_CR_ID=$(echo "$R" | jq_extract "['id']")
|
||||
# Wait for containers to start (poll up to 30s for first one to appear)
|
||||
if command -v docker &>/dev/null; then
|
||||
short_cc="${RT_CC_ID:0:12}"
|
||||
for i in 1 2 3 4 5 6; do
|
||||
for _ in 1 2 3 4 5 6; do
|
||||
sleep 5
|
||||
if docker inspect "ws-${short_cc}" >/dev/null 2>&1; then break; fi
|
||||
done
|
||||
@ -137,7 +159,7 @@ if command -v docker &>/dev/null; then
|
||||
local short_id="${ws_id:0:12}"
|
||||
# Poll up to 30s for image to appear
|
||||
local actual_image="NOT_FOUND"
|
||||
for j in 1 2 3 4 5 6; do
|
||||
for _ in 1 2 3 4 5 6; do
|
||||
actual_image=$(docker inspect "ws-${short_id}" --format '{{.Config.Image}}' 2>/dev/null || echo "NOT_FOUND")
|
||||
if echo "$actual_image" | grep -qF "$expected_tag"; then break; fi
|
||||
sleep 5
|
||||
@ -190,7 +212,7 @@ if echo "$R" | grep -qF "saved"; then
|
||||
# Poll up to 30s for the new container image to appear (restart can take a while)
|
||||
if command -v docker &>/dev/null; then
|
||||
short_id="${RT_LG_ID:0:12}"
|
||||
for i in 1 2 3 4 5 6; do
|
||||
for _ in 1 2 3 4 5 6; do
|
||||
sleep 5
|
||||
actual=$(docker inspect "ws-${short_id}" --format '{{.Config.Image}}' 2>/dev/null || echo "")
|
||||
if echo "$actual" | grep -qF "deepagents"; then break; fi
|
||||
@ -217,10 +239,8 @@ done
|
||||
echo ""
|
||||
echo "--- Section 3: Registry & Heartbeat ---"
|
||||
|
||||
# Register Dev workspace
|
||||
R=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
|
||||
-d "{\"id\":\"$DEV_ID\",\"url\":\"http://localhost:9001\",\"agent_card\":{\"name\":\"Dev Agent\",\"skills\":[],\"version\":\"1.0.0\"}}")
|
||||
check "Register Dev" '"status":"registered"' "$R"
|
||||
# Dev was already registered in Section 2 right after creation (to beat
|
||||
# the provisioner in the token-issuance race). Re-assert the status here.
|
||||
|
||||
# Verify Dev is now online
|
||||
R=$(curl -s "$BASE/workspaces/$DEV_ID")
|
||||
@ -228,6 +248,7 @@ check "Dev status online after register" '"status":"online"' "$R"
|
||||
|
||||
# Heartbeat with current_task
|
||||
R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $DEV_TOKEN" \
|
||||
-d "{\"workspace_id\":\"$DEV_ID\",\"active_tasks\":1,\"current_task\":\"Running tests\"}")
|
||||
check "Heartbeat with task" '"status":"ok"' "$R"
|
||||
|
||||
@ -237,6 +258,7 @@ check "Current task visible" '"current_task":"Running tests"' "$R"
|
||||
|
||||
# Heartbeat with error rate (trigger degraded — needs >0.5 AND registered)
|
||||
R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $DEV_TOKEN" \
|
||||
-d "{\"workspace_id\":\"$DEV_ID\",\"error_rate\":0.8,\"sample_error\":\"timeout\"}")
|
||||
check "Degraded heartbeat" '"status":"ok"' "$R"
|
||||
|
||||
@ -247,6 +269,7 @@ check "Dev degraded" '"last_error_rate":0.8' "$R"
|
||||
|
||||
# Recover
|
||||
R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $DEV_TOKEN" \
|
||||
-d "{\"workspace_id\":\"$DEV_ID\",\"error_rate\":0.0}")
|
||||
R=$(curl -s "$BASE/workspaces/$DEV_ID")
|
||||
check "Dev recovered" '"last_error_rate":0' "$R"
|
||||
@ -257,22 +280,18 @@ check "Dev recovered" '"last_error_rate":0' "$R"
|
||||
echo ""
|
||||
echo "--- Section 4: Discovery & Access Control ---"
|
||||
|
||||
# Register PM too
|
||||
curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
|
||||
-d "{\"id\":\"$PM_ID\",\"url\":\"http://localhost:9000\",\"agent_card\":{\"name\":\"PM\",\"skills\":[]}}" > /dev/null
|
||||
# PM was registered in Section 2 right after creation.
|
||||
|
||||
# Discover requires X-Workspace-ID
|
||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/registry/discover/$DEV_ID")
|
||||
check_status "Discover without header → 400" "400" "$CODE"
|
||||
|
||||
# PM discovers Dev (parent→child: allowed)
|
||||
R=$(curl -s -H "X-Workspace-ID: $PM_ID" "$BASE/registry/discover/$DEV_ID")
|
||||
R=$(curl -s -H "X-Workspace-ID: $PM_ID" -H "Authorization: Bearer $PM_TOKEN" "$BASE/registry/discover/$DEV_ID")
|
||||
check "PM discovers Dev (parent→child)" "$DEV_ID" "$R"
|
||||
|
||||
# Dev discovers QA (siblings: allowed) — QA must be registered first
|
||||
curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
|
||||
-d "{\"id\":\"$QA_ID\",\"url\":\"http://localhost:9002\",\"agent_card\":{\"name\":\"QA\",\"skills\":[]}}" > /dev/null
|
||||
R=$(curl -s -H "X-Workspace-ID: $DEV_ID" "$BASE/registry/discover/$QA_ID")
|
||||
# Dev discovers QA (siblings: allowed) — QA was registered in Section 2
|
||||
R=$(curl -s -H "X-Workspace-ID: $DEV_ID" -H "Authorization: Bearer $DEV_TOKEN" "$BASE/registry/discover/$QA_ID")
|
||||
check "Dev discovers QA (siblings)" "$QA_ID" "$R"
|
||||
|
||||
# Check access: PM → Dev (allowed)
|
||||
@ -286,7 +305,7 @@ R=$(curl -s -X POST "$BASE/registry/check-access" -H "Content-Type: application/
|
||||
check "Access Dev→Outsider (denied)" '"allowed":false' "$R"
|
||||
|
||||
# Peers — Dev should see PM and QA
|
||||
R=$(curl -s -H "X-Workspace-ID: $DEV_ID" "$BASE/registry/$DEV_ID/peers")
|
||||
R=$(curl -s -H "X-Workspace-ID: $DEV_ID" -H "Authorization: Bearer $DEV_TOKEN" "$BASE/registry/$DEV_ID/peers")
|
||||
check "Dev peers include PM" "$PM_ID" "$R"
|
||||
check "Dev peers include QA" "$QA_ID" "$R"
|
||||
|
||||
@ -497,6 +516,7 @@ echo ""
|
||||
echo "--- Section 12: Agent Card Update ---"
|
||||
|
||||
R=$(curl -s -X POST "$BASE/registry/update-card" -H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $DEV_TOKEN" \
|
||||
-d "{\"workspace_id\":\"$DEV_ID\",\"agent_card\":{\"name\":\"Dev Agent v2\",\"skills\":[{\"id\":\"code\",\"name\":\"Coding\"}],\"version\":\"2.0.0\"}}")
|
||||
check "Update agent card" '"status":"updated"' "$R"
|
||||
|
||||
@ -548,7 +568,7 @@ for w in ws:
|
||||
" 2>/dev/null
|
||||
|
||||
# Poll for clean state up to 30s — DB cascade + container stop is async on busy systems
|
||||
for i in 1 2 3 4 5 6; do
|
||||
for _ in 1 2 3 4 5 6; do
|
||||
sleep 5
|
||||
R=$(curl -s "$BASE/workspaces")
|
||||
if [ "$R" = "[]" ]; then break; fi
|
||||
|
||||
Loading…
Reference in New Issue
Block a user