diff --git a/.gitea/workflows/e2e-staging-saas.yml b/.gitea/workflows/e2e-staging-saas.yml index c0c15a80..40997fb9 100644 --- a/.gitea/workflows/e2e-staging-saas.yml +++ b/.gitea/workflows/e2e-staging-saas.yml @@ -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. diff --git a/canvas/src/components/tabs/SkillsTab.tsx b/canvas/src/components/tabs/SkillsTab.tsx index a201112e..0a80869f 100644 --- a/canvas/src/components/tabs/SkillsTab.tsx +++ b/canvas/src/components/tabs/SkillsTab.tsx @@ -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(`/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) { - {/* 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 ? ( + + ) : installed.length > 0 ? (
{installed.map((p) => { // Plugin was installed but does NOT declare support for @@ -409,7 +425,7 @@ export function SkillsTab({ workspaceId, data }: Props) { ); })}
- )} + ) : null} {/* Plugin registry (expandable) */} {showRegistry && ( @@ -486,7 +502,7 @@ export function SkillsTab({ workspaceId, data }: Props) { )} {registryLoading && registry.length === 0 ? ( -
Loading registry…
+ ) : registryError ? (
@@ -663,6 +679,39 @@ export function extractSkills(agentCard: Record | 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 ( +
+ {Array.from({ length: 2 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} + {label}… +
+ ); +} + function MetaPill({ label, value }: { label: string; value: string }) { return ( diff --git a/canvas/src/components/tabs/__tests__/SkillsTab.loadingState.test.tsx b/canvas/src/components/tabs/__tests__/SkillsTab.loadingState.test.tsx new file mode 100644 index 00000000..3a034ad9 --- /dev/null +++ b/canvas/src/components/tabs/__tests__/SkillsTab.loadingState.test.tsx @@ -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[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() { + let resolve!: (v: T) => void; + const promise = new Promise((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(); + apiGet.mockImplementation((path: string) => { + if (path === `/workspaces/ws-1/plugins`) return installedDef.promise; + return Promise.resolve([]); // /plugins, /plugins/sources + }); + + render(); + + // 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(); + + 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(); + 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(); + + // 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(); + + 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(); + }); +}); diff --git a/workspace-server/internal/staginge2e/plugin_lifecycle_e2e_test.go b/workspace-server/internal/staginge2e/plugin_lifecycle_e2e_test.go new file mode 100644 index 00000000..156ad065 --- /dev/null +++ b/workspace-server/internal/staginge2e/plugin_lifecycle_e2e_test.go @@ -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://). + 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 +}