fix(canvas/plugins): explicit loading state for Plugins tab + CI regression guards #3132
@@ -961,3 +961,65 @@ jobs:
|
||||
# (e2e-cncrg-* slug), running even on a t.Fatal. The age-guarded
|
||||
# sweep-stale-e2e-orgs workflow (30-min floor, e2e- prefix) is the final
|
||||
# net for a tenant orphaned by a hard runner cancel.
|
||||
|
||||
# Tenant plugin-install lifecycle guard (canvas Plugins tab regression class):
|
||||
# boots a real staging workspace, asserts the registry is non-empty (the
|
||||
# "no registry available" report), installs a registry plugin via
|
||||
# POST /workspaces/:id/plugins, then asserts (a) GET /workspaces/:id/plugins
|
||||
# returns it — guards the SaaS EIC readback (CP #3125), (b) the workspace
|
||||
# comes back online+routable and serves A2A after the install-triggered
|
||||
# restart — guards the #159 mgmt-MCP self-heal online-gate. Drives
|
||||
# TestPluginInstallLifecycle_Staging (workspace-server/internal/staginge2e).
|
||||
#
|
||||
# GATING: fail-loud (no continue-on-error) on push/dispatch/cron — a real
|
||||
# red here blocks the deploy path. NOT yet a branch-protection REQUIRED
|
||||
# context: to make it merge-blocking, add
|
||||
# "E2E Staging SaaS (full lifecycle) / E2E Staging Plugin Install Lifecycle"
|
||||
# to .gitea/required-contexts.txt AND to branch_protections/main
|
||||
# status_check_contexts (owner action — same two-step the platform-boot
|
||||
# gate documents above). Until then it runs + fails loud but is advisory,
|
||||
# exactly like e2e-staging-workspace-requests.
|
||||
# bp-required: pending #3133
|
||||
e2e-staging-plugin-lifecycle:
|
||||
name: E2E Staging Plugin Install Lifecycle
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
CP_BASE_URL: https://staging-api.moleculesai.app
|
||||
CP_ADMIN_API_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
|
||||
STAGING_E2E: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
# cache:false — see the concierge-platform job; the self-hosted
|
||||
# runner bind-mounts a persistent GOCACHE/GOMODCACHE and
|
||||
# actions/cache corrupts it.
|
||||
cache: false
|
||||
cache-dependency-path: workspace-server/go.sum
|
||||
- name: Verify admin token present
|
||||
run: |
|
||||
if [ -z "$CP_ADMIN_API_TOKEN" ]; then
|
||||
echo "::error::CP_STAGING_ADMIN_API_TOKEN secret not set (Railway staging CP_ADMIN_API_TOKEN)"
|
||||
exit 2
|
||||
fi
|
||||
echo "Admin token present"
|
||||
- name: CP staging health preflight
|
||||
run: |
|
||||
code=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 10 "$CP_BASE_URL/health")
|
||||
if [ "$code" != "200" ]; then
|
||||
echo "::error::Staging CP unhealthy (HTTP $code) — infra, not a plugin-lifecycle bug."
|
||||
exit 1
|
||||
fi
|
||||
echo "Staging CP healthy"
|
||||
- name: Run plugin-install-lifecycle staginge2e
|
||||
working-directory: workspace-server
|
||||
run: go test -tags staging_e2e ./internal/staginge2e/ -run TestPluginInstallLifecycle_Staging -count=1 -v -timeout 40m
|
||||
# Teardown: the test installs a t.Cleanup admin-DELETE of its own tenant
|
||||
# (e2e-plgn-* slug), running even on a t.Fatal. The age-guarded
|
||||
# sweep-stale-e2e-orgs workflow (30-min floor, e2e- prefix) is the final
|
||||
# net for a tenant orphaned by a hard runner cancel.
|
||||
|
||||
@@ -92,7 +92,17 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
||||
// positive.
|
||||
const [installedLoaded, setInstalledLoaded] = useState(false);
|
||||
|
||||
// Tracks an in-flight installed-plugins fetch so the panel can render
|
||||
// skeleton rows instead of the bare "0 installed" header while the
|
||||
// request is pending. Without this the full panel paints "0 installed"
|
||||
// with no list during the initial load (compactEmpty is gated on
|
||||
// installedLoaded so it can't fire yet) — indistinguishable from a
|
||||
// genuinely-empty workspace, the "looks broken until data arrives a
|
||||
// second later" report.
|
||||
const [installedLoading, setInstalledLoading] = useState(true);
|
||||
|
||||
const loadInstalled = useCallback(async () => {
|
||||
if (mountedRef.current) setInstalledLoading(true);
|
||||
try {
|
||||
const result = await api.get<PluginInfo[]>(`/workspaces/${workspaceId}/plugins`);
|
||||
if (mountedRef.current) {
|
||||
@@ -101,6 +111,8 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("SkillsTab: installed plugins load failed", e);
|
||||
} finally {
|
||||
if (mountedRef.current) setInstalledLoading(false);
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
@@ -357,8 +369,12 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Installed plugins */}
|
||||
{installed.length > 0 && (
|
||||
{/* Installed plugins — skeleton while the initial fetch is in
|
||||
flight (distinguish loading from a genuinely-empty list),
|
||||
then the rows. */}
|
||||
{installedLoading && !installedLoaded && installed.length === 0 ? (
|
||||
<PluginSkeletonRows label="Loading installed plugins" />
|
||||
) : installed.length > 0 ? (
|
||||
<div className="mt-3 space-y-1.5">
|
||||
{installed.map((p) => {
|
||||
// Plugin was installed but does NOT declare support for
|
||||
@@ -409,7 +425,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Plugin registry (expandable) */}
|
||||
{showRegistry && (
|
||||
@@ -486,7 +502,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
||||
)}
|
||||
</div>
|
||||
{registryLoading && registry.length === 0 ? (
|
||||
<div className="text-[10px] text-ink-mid">Loading registry…</div>
|
||||
<PluginSkeletonRows label="Loading registry" />
|
||||
) : registryError ? (
|
||||
<div className="rounded-lg border border-red-800/40 bg-red-950/20 px-2 py-1.5">
|
||||
<div className="text-[10px] text-bad font-semibold mb-0.5">
|
||||
@@ -663,6 +679,39 @@ export function extractSkills(agentCard: Record<string, unknown> | null): SkillE
|
||||
.filter((skill) => skill.id.length > 0);
|
||||
}
|
||||
|
||||
// Animated skeleton placeholder rows for the async plugin sections
|
||||
// (installed list + registry). Rendered while the fetch is in flight so
|
||||
// the user sees an explicit "loading" affordance rather than the empty
|
||||
// state ("0 installed" / "Registry returned 0 plugins"). aria-busy +
|
||||
// role=status keep it announced for assistive tech. Mirrors the
|
||||
// MemoryInspectorPanel skeleton style. `motion-safe:` honours
|
||||
// prefers-reduced-motion.
|
||||
function PluginSkeletonRows({ label }: { label: string }) {
|
||||
return (
|
||||
<div
|
||||
className="mt-3 space-y-1.5"
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-label={label}
|
||||
data-testid="plugin-skeleton"
|
||||
>
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-lg border border-line/60 bg-surface/40 px-3 py-2 motion-safe:animate-pulse"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 rounded bg-surface-card/50 w-24" />
|
||||
<div className="h-2 rounded bg-surface-card/50 w-8" />
|
||||
</div>
|
||||
<div className="mt-1.5 h-2 rounded bg-surface-card/40 w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
<span className="sr-only">{label}…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaPill({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-line/60 bg-surface/60 px-2 py-1 text-[9px] text-ink-mid">
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Regression guard for the Plugins-tab async loading states (user-
|
||||
// reported: the tab shows the EMPTY state — "0 installed", registry
|
||||
// "Registry returned 0 plugins" — while the fetch is still in flight,
|
||||
// so it looks broken until data arrives a second later).
|
||||
//
|
||||
// Pins, for each async section:
|
||||
// - installed plugins: skeleton while pending, NOT the "0 installed"
|
||||
// empty look; rows on resolve-with-data; empty (compact pill) on
|
||||
// resolve-empty.
|
||||
// - install-dialog registry: skeleton while pending, registry rows
|
||||
// when the /plugins fetch returns entries (the "no registry / still
|
||||
// no registry" report — confirm the dialog lists the registry).
|
||||
//
|
||||
// A future refactor that re-introduces the empty-during-load flash, or
|
||||
// drops the registry list from the dialog, fails here.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const apiGet = vi.fn();
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: (path: string, opts?: unknown) => apiGet(path, opts),
|
||||
post: vi.fn(() => Promise.resolve({})),
|
||||
del: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
put: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
import { SkillsTab } from "../SkillsTab";
|
||||
|
||||
const minimalData = {
|
||||
status: "online" as const,
|
||||
runtime: "claude-code",
|
||||
currentTask: "",
|
||||
agentCard: undefined,
|
||||
} as unknown as Parameters<typeof SkillsTab>[0]["data"];
|
||||
|
||||
// Returns a controllable deferred promise so a test can hold a fetch
|
||||
// "in flight" and assert the loading state before resolving.
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const REGISTRY_PLUGINS = [
|
||||
{ name: "browser-automation", version: "0.2.0", description: "Drive a headless browser", tags: ["web"], skills: [], author: "" },
|
||||
{ name: "image-gen", version: "0.1.1", description: "Generate images", tags: ["media"], skills: [], author: "" },
|
||||
];
|
||||
|
||||
describe("SkillsTab Plugins async loading states", () => {
|
||||
it("shows a loading skeleton (not the empty state) while installed plugins are in flight", async () => {
|
||||
const installedDef = deferred<unknown>();
|
||||
apiGet.mockImplementation((path: string) => {
|
||||
if (path === `/workspaces/ws-1/plugins`) return installedDef.promise;
|
||||
return Promise.resolve([]); // /plugins, /plugins/sources
|
||||
});
|
||||
|
||||
render(<SkillsTab workspaceId="ws-1" data={minimalData} />);
|
||||
|
||||
// While the installed fetch is pending: skeleton present, and the
|
||||
// compact "0 installed" empty pill MUST NOT be shown.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("plugin-skeleton")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByLabelText(/Plugins \(none installed\)/i)).toBeNull();
|
||||
|
||||
// Resolve empty → the skeleton goes away and the empty (compact)
|
||||
// state appears only AFTER the fetch resolves.
|
||||
installedDef.resolve([]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Plugins \(none installed\)/i)).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByTestId("plugin-skeleton")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows installed rows after the fetch resolves with data", async () => {
|
||||
apiGet.mockImplementation((path: string) => {
|
||||
if (path === `/workspaces/ws-2/plugins`) {
|
||||
return Promise.resolve([
|
||||
{ name: "memory-postgres", version: "1.0.0", description: "memory backend", supported_on_runtime: true },
|
||||
]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
render(<SkillsTab workspaceId="ws-2" data={minimalData} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/1 installed/i)).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText("memory-postgres")).toBeTruthy();
|
||||
// No skeleton lingering after resolve.
|
||||
expect(screen.queryByTestId("plugin-skeleton")).toBeNull();
|
||||
});
|
||||
|
||||
it("install dialog shows a skeleton while the registry is in flight, then lists registry plugins", async () => {
|
||||
const registryDef = deferred<unknown>();
|
||||
apiGet.mockImplementation((path: string) => {
|
||||
if (path === "/plugins") return registryDef.promise;
|
||||
// installed resolves empty immediately so the compact pill renders
|
||||
// and we can click "+ Install Plugin".
|
||||
if (path === `/workspaces/ws-3/plugins`) return Promise.resolve([]);
|
||||
return Promise.resolve([]); // /plugins/sources
|
||||
});
|
||||
|
||||
render(<SkillsTab workspaceId="ws-3" data={minimalData} />);
|
||||
|
||||
// Open the install dialog from the compact pill.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Plugins \(none installed\)/i)).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ Install Plugin/i }));
|
||||
|
||||
// Dialog open, registry still loading → skeleton, NOT the "Registry
|
||||
// returned 0 plugins" empty banner.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("plugin-skeleton")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByText(/Registry returned 0 plugins/i)).toBeNull();
|
||||
|
||||
// Registry resolves with entries → the dialog lists them (name +
|
||||
// version + description), and the skeleton is gone.
|
||||
registryDef.resolve(REGISTRY_PLUGINS);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("browser-automation")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText("image-gen")).toBeTruthy();
|
||||
expect(screen.getByText("v0.2.0")).toBeTruthy();
|
||||
expect(screen.getByText("Generate images")).toBeTruthy();
|
||||
expect(screen.queryByTestId("plugin-skeleton")).toBeNull();
|
||||
// The empty banner must NOT show when the registry has entries.
|
||||
expect(screen.queryByText(/Registry returned 0 plugins/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the empty registry banner only after the registry resolves with zero entries", async () => {
|
||||
apiGet.mockImplementation((path: string) => {
|
||||
if (path === `/workspaces/ws-4/plugins`) return Promise.resolve([]);
|
||||
return Promise.resolve([]); // /plugins resolves empty, /plugins/sources
|
||||
});
|
||||
|
||||
render(<SkillsTab workspaceId="ws-4" data={minimalData} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Plugins \(none installed\)/i)).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ Install Plugin/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Registry returned 0 plugins/i)).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByTestId("plugin-skeleton")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
//go:build staging_e2e
|
||||
|
||||
package staginge2e
|
||||
|
||||
// plugin_lifecycle_e2e_test.go — live, against-real-staging guard for the
|
||||
// tenant plugin-install lifecycle that backs the canvas Plugins tab.
|
||||
//
|
||||
// Guards three regressions that were each invisible to the existing suites:
|
||||
//
|
||||
// 1. Registry is non-empty (the canvas "no registry available" /
|
||||
// "Registry returned 0 plugins" report): GET /plugins on the tenant
|
||||
// ws-server must list the plugins clone-manifest.sh populates at deploy
|
||||
// time. A 0-length registry here is the exact server-side state that
|
||||
// makes the install dialog look broken.
|
||||
//
|
||||
// 2. ListInstalled returns an installed plugin on a SaaS workspace
|
||||
// (guards CP #3125 — the EIC branch that fixed "[] readback after a
|
||||
// successful install" for every SaaS tenant): install a registry plugin
|
||||
// via POST /workspaces/:id/plugins, then GET /workspaces/:id/plugins must
|
||||
// include it. Without the EIC branch this read back [] forever.
|
||||
//
|
||||
// 3. The agent stays online after the install-triggered restart (guards
|
||||
// the #159 mgmt-MCP self-heal — a plugin install restarts the workspace,
|
||||
// and a broken online-gate would leave it stuck 'failed'/offline): the
|
||||
// workspace must return to online+routable AND serve A2A after the
|
||||
// install.
|
||||
//
|
||||
// Reuses the workspace_lifecycle_test.go harness wholesale (requireStagingEnv
|
||||
// / adminCreateOrg / adminDeleteTenant / tenantAdminToken / tenantCreateWorkspace
|
||||
// / waitForWorkspaceOnlineRoutable / waitForWorkspaceStatus / doTenantJSON /
|
||||
// serveProbe / jsonField). NOTHING here re-implements org provisioning or
|
||||
// teardown — t.Cleanup-driven admin DELETE guarantees no leaked tenant.
|
||||
//
|
||||
// Guarded by the staging_e2e build tag + STAGING_E2E=1 (requireStagingEnv).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPluginInstallLifecycle_Staging(t *testing.T) {
|
||||
cfg := requireStagingEnv(t)
|
||||
|
||||
slug := fmt.Sprintf("e2e-plgn-%d", time.Now().Unix()%100000000)
|
||||
t.Logf("plugin-lifecycle: slug=%s", slug)
|
||||
|
||||
// --- Step 1: provision throwaway org + tenant (reused scaffolding) ---
|
||||
orgID := adminCreateOrg(t, cfg, slug)
|
||||
t.Cleanup(func() { adminDeleteTenant(t, cfg, slug) })
|
||||
host := slug + "." + cfg.subdomainSuffix
|
||||
token := tenantAdminToken(t, cfg, slug)
|
||||
waitForHTTP(t, host, http.StatusOK, 10*time.Minute, "tenant /health ready")
|
||||
t.Logf("tenant TLS ready: %s", host)
|
||||
|
||||
// --- Step 2: registry must be non-empty BEFORE we pick a plugin ----------
|
||||
// This is independent of any workspace — the registry is host-local
|
||||
// (clone-manifest.sh populated). We both assert it's non-empty (guard #1)
|
||||
// AND use the first runtime-agnostic entry as the plugin to install, so the
|
||||
// test never hardcodes a registry slug that could be renamed/removed.
|
||||
var pluginName string
|
||||
t.Run("registry_non_empty", func(t *testing.T) {
|
||||
hs, body := doTenantJSON(t, "GET", "https://"+host+"/plugins", token, orgID, "")
|
||||
if hs != http.StatusOK {
|
||||
t.Fatalf("GET /plugins registry: HTTP %d: %s", hs, body)
|
||||
}
|
||||
reg := parsePluginList(t, body)
|
||||
if len(reg) == 0 {
|
||||
t.Fatalf("GET /plugins returned an EMPTY registry — the canvas install dialog would "+
|
||||
"show 'no registry available'. clone-manifest.sh must populate plugins/ at deploy. body=%s",
|
||||
truncate(body, 400))
|
||||
}
|
||||
pluginName = pickInstallablePlugin(reg)
|
||||
if pluginName == "" {
|
||||
t.Fatalf("registry has %d entries but none with a usable name: %s", len(reg), truncate(body, 400))
|
||||
}
|
||||
t.Logf("registry has %d plugins; will install %q", len(reg), pluginName)
|
||||
})
|
||||
if pluginName == "" {
|
||||
t.Fatal("no installable plugin established by registry_non_empty — dependent subtests cannot run")
|
||||
}
|
||||
|
||||
// --- Step 3: create + boot a workspace ----------------------------------
|
||||
wsID := tenantCreateWorkspace(t, cfg, host, token, orgID)
|
||||
t.Logf("workspace created: %s", wsID)
|
||||
waitForWorkspaceOnlineRoutable(t, host, token, orgID, wsID, 15*time.Minute, "initial boot")
|
||||
t.Logf("workspace %s online + routable", wsID)
|
||||
|
||||
// --- Step 4: install → ListInstalled returns it → agent stays online -----
|
||||
t.Run("install_then_list_then_stay_online", func(t *testing.T) {
|
||||
// Install the registry plugin via the local:// source — the same path
|
||||
// the canvas "Install" button uses (handleInstall → local://<name>).
|
||||
installBody, _ := json.Marshal(map[string]string{"source": "local://" + pluginName})
|
||||
hs, body := doTenantJSON(t, "POST", "https://"+host+"/workspaces/"+wsID+"/plugins", token, orgID, string(installBody))
|
||||
if hs != http.StatusOK && hs != http.StatusCreated && hs != http.StatusAccepted {
|
||||
t.Fatalf("install %q: HTTP %d: %s", pluginName, hs, body)
|
||||
}
|
||||
t.Logf("install %q accepted (HTTP %d) — workspace will restart", pluginName, hs)
|
||||
|
||||
// The install restarts the workspace. Wait for it to come back
|
||||
// online+routable. A stuck 'failed'/offline here is the #159 self-heal
|
||||
// regression (online-gate refusing a freshly-rebooted box).
|
||||
waitForWorkspaceOnlineRoutable(t, host, token, orgID, wsID, 15*time.Minute, "post-install restart→online")
|
||||
|
||||
// ListInstalled must now include the plugin (guard #3125 EIC branch).
|
||||
// The readback can race the restart, so poll until it appears.
|
||||
if !pollListInstalledContains(t, host, token, orgID, wsID, pluginName, 5*time.Minute) {
|
||||
_, listBody := doTenantJSON(t, "GET", "https://"+host+"/workspaces/"+wsID+"/plugins", token, orgID, "")
|
||||
t.Fatalf("installed plugin %q never appeared in ListInstalled within 5m — "+
|
||||
"the SaaS EIC readback (#3125) regressed; last list=%s", pluginName, truncate(listBody, 600))
|
||||
}
|
||||
t.Logf("ListInstalled returned %q after install (EIC readback OK)", pluginName)
|
||||
|
||||
// And the agent must actually SERVE — online-row + a live A2A reply.
|
||||
if served, code := serveProbe(t, host, token, orgID, wsID); !served {
|
||||
t.Fatalf("agent did not serve A2A after plugin install (code=%d) — "+
|
||||
"the install-triggered restart left it un-serveable (#159 self-heal regression)", code)
|
||||
}
|
||||
t.Logf("agent served A2A after install — stayed online through the restart")
|
||||
})
|
||||
}
|
||||
|
||||
// ─── helpers (plugin-lifecycle specific; lifecycle suite owns the shared ones) ──
|
||||
|
||||
// pluginListRow is the flat view of one /plugins or /workspaces/:id/plugins row.
|
||||
type pluginListRow struct {
|
||||
Name string
|
||||
Version string
|
||||
}
|
||||
|
||||
func parsePluginList(t *testing.T, body string) []pluginListRow {
|
||||
t.Helper()
|
||||
var raw []map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(body), &raw); err != nil {
|
||||
t.Fatalf("plugins body not a JSON array: %v (%s)", err, truncate(body, 300))
|
||||
}
|
||||
out := make([]pluginListRow, 0, len(raw))
|
||||
for _, m := range raw {
|
||||
out = append(out, pluginListRow{
|
||||
Name: rawString(m["name"]),
|
||||
Version: rawString(m["version"]),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pickInstallablePlugin returns the first registry entry with a non-empty name.
|
||||
// (Runtime filtering is handled server-side; an unnamed entry is unusable.)
|
||||
func pickInstallablePlugin(reg []pluginListRow) string {
|
||||
for _, p := range reg {
|
||||
if p.Name != "" {
|
||||
return p.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// pollListInstalledContains polls GET /workspaces/:id/plugins until name appears
|
||||
// or the timeout elapses. Returns true if found.
|
||||
func pollListInstalledContains(t *testing.T, host, token, orgID, wsID, name string, timeout time.Duration) bool {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
hs, body := doTenantJSON(t, "GET", "https://"+host+"/workspaces/"+wsID+"/plugins", token, orgID, "")
|
||||
if hs == http.StatusOK {
|
||||
for _, p := range parsePluginList(t, body) {
|
||||
if p.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user