fix(canvas/plugins): explicit loading state for Plugins tab + CI regression guards #3132

Merged
core-devops merged 2 commits from fix/plugins-tab-loading-state into main 2026-06-22 00:25:55 +00:00
4 changed files with 460 additions and 4 deletions
+62
View File
@@ -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.
+53 -4
View File
@@ -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
}