feat(FN-2262): merge fusion/fn-2262 (auto-resolved)

- feat(FN-2262): document Paperclip runtime configuration and constraints
- docs(FN-2261): update README with implementation details
- feat(FN-2261): add paperclip runtime resolution compatibility tests
- feat(FN-2261): add runtime adapter and registration tests
- feat(FN-2261): integrate adapter into plugin entrypoint
- feat(FN-2261): implement PaperclipRuntimeAdapter
- fix(FN-2261): remove pi-coding-agent re-exports from types.ts
- feat(FN-2261): define runtime types for Paperclip plugin
- feat(FN-2260): merge fusion/fn-2260
This commit is contained in:
Fusion
2026-04-22 15:39:15 -07:00
committed by gsxdsm
parent 2455bfb250
commit 330c1159d3
31 changed files with 1984 additions and 310 deletions

View File

@@ -1,8 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AlertTriangle, ExternalLink, Eye, Loader2, Monitor, Play, RefreshCw, RotateCw, ShieldAlert, Square } from "lucide-react";
import type { DetectedDevServerCommand, DevServerSession, DevServerState } from "../api";
import type { DetectedDevServerCommand } from "../api";
import { useDevServer } from "../hooks/useDevServer";
import { useDevServerConfig } from "../hooks/useDevServerConfig";
import { useDevServerLogs } from "../hooks/useDevServerLogs";
import { usePreviewEmbed } from "../hooks/usePreviewEmbed";
import type { ToastType } from "../hooks/useToast";
@@ -37,32 +36,6 @@ function normalizeCwdToSource(cwd: string): string {
return cwd === "." ? "root" : cwd;
}
function normalizeLegacyServerState(serverState: DevServerState): DevServerSession {
return {
config: {
id: serverState.id ?? "default",
name: serverState.name ?? "Dev Server",
command: serverState.command ?? "",
cwd: serverState.cwd ?? ".",
},
status: serverState.status as DevServerSession["status"],
runtime: serverState.pid
? {
pid: serverState.pid,
startedAt: serverState.startedAt ?? new Date().toISOString(),
exitCode: serverState.exitCode ?? undefined,
previewUrl: serverState.previewUrl,
}
: undefined,
previewUrl: serverState.previewUrl ?? serverState.detectedUrl ?? serverState.manualUrl ?? undefined,
logHistory: (serverState.logs ?? []).map((line) => ({
timestamp: new Date().toISOString(),
stream: line.startsWith("[stderr]") ? "stderr" as const : "stdout" as const,
text: line.replace(/^\[stderr\]\s*/, ""),
})),
};
}
function normalizeSourceToCwd(source: string | null | undefined): string | null {
if (!source) {
return null;
@@ -105,33 +78,21 @@ function truncateCommand(command: string): string {
}
export function DevServerView({ addToast, projectId }: DevServerViewProps) {
const devServerState = useDevServer(projectId);
const legacyServerState = (devServerState.serverState as DevServerState | null | undefined) ?? null;
const session = devServerState.session
?? (legacyServerState ? normalizeLegacyServerState(legacyServerState) : null);
const detectedCommands = devServerState.detectedCommands ?? devServerState.candidates ?? [];
const previewUrl = devServerState.previewUrl ?? session?.previewUrl ?? legacyServerState?.previewUrl ?? null;
const error = devServerState.error ?? null;
const startServer = devServerState.startServer
?? (async (command: string, cwd?: string) => devServerState.start(command, cwd));
const stopServer = devServerState.stopServer ?? devServerState.stop;
const restartServer = devServerState.restartServer ?? devServerState.restart;
const setPreviewUrl = devServerState.setPreviewUrl ?? devServerState.setManualUrl;
const detectCommands = devServerState.detectCommands ?? devServerState.detect;
const refresh = devServerState.refresh ?? devServerState.refreshStatus;
const {
config,
loading: configLoading,
selectScript,
clearSelection,
setPreviewUrlOverride,
} = useDevServerConfig(projectId);
const manualPreviewUrlOverride = config?.previewUrlOverride ?? legacyServerState?.manualPreviewUrl ?? null;
const effectivePreviewUrl = manualPreviewUrlOverride ?? previewUrl;
const isLoading = (devServerState.isLoading ?? devServerState.loading ?? false) || configLoading;
session,
sessions,
logs,
detectedCommands,
previewUrl,
isLoading,
error,
startServer,
stopServer,
restartServer,
setPreviewUrl,
detectCommands,
refresh,
} = useDevServer(projectId);
const status = session?.status ?? "stopped";
const isRunning = status === "running" || status === "starting";
@@ -146,26 +107,28 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
loadMore: loadMoreLogs,
} = useDevServerLogs(projectId, Boolean(projectId));
const selectedSource = config?.selectedSource ?? (session?.config?.cwd ? normalizeCwdToSource(session.config.cwd) : null);
const effectivePreviewUrl = previewUrl;
const selectedSource = session?.config?.cwd ?? null;
const [showCandidates, setShowCandidates] = useState(true);
const [commandInput, setCommandInput] = useState("");
const [previewInput, setPreviewInput] = useState("");
const [selectedScript, setSelectedScript] = useState<string | null>(config?.selectedScript ?? null);
const [selectedScript, setSelectedScript] = useState<string | null>(null);
const [actionInFlight, setActionInFlight] = useState<"start" | "stop" | "restart" | "preview" | null>(null);
const [previewMode, setPreviewMode] = useState<PreviewMode>("embedded");
const previewEmbedUrl = previewMode === "embedded" ? effectivePreviewUrl : null;
const previewEmbedState = usePreviewEmbed(previewEmbedUrl);
const embedStatus = previewEmbedState.embedStatus;
const setEmbedStatus = previewEmbedState.setEmbedStatus;
const resetEmbedStatus = previewEmbedState.resetEmbedStatus ?? (previewEmbedState as { resetEmbed?: () => void }).resetEmbed ?? (() => {});
const iframeRef = previewEmbedState.iframeRef;
const isEmbedded = previewEmbedState.isEmbedded;
const isBlocked = previewEmbedState.isBlocked;
const blockReason = previewEmbedState.blockReason ?? previewEmbedState.embedContext ?? null;
const retry = previewEmbedState.retry ?? (() => resetEmbedStatus());
const {
embedStatus,
setEmbedStatus,
resetEmbedStatus,
iframeRef,
isEmbedded,
isBlocked,
blockReason,
retry,
} = usePreviewEmbed(previewEmbedUrl);
const [showFallback, setShowFallback] = useState(false);
const prevStatusRef = useRef(embedStatus);
@@ -188,10 +151,6 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
setShowFallback(false);
}, [effectivePreviewUrl]);
useEffect(() => {
setSelectedScript(config?.selectedScript ?? null);
}, [config?.selectedScript]);
const selectedCandidate = useMemo(() => {
if (!selectedScript) {
return null;
@@ -318,28 +277,14 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
setSelectedScript(candidate.scriptName);
setShowCandidates(false);
setCommandInput(candidate.command);
void selectScript({
name: candidate.scriptName,
command: candidate.command,
source: normalizeCwdToSource(candidate.cwd),
}).catch((selectionError) => {
addToast(normalizeError(selectionError), "error");
});
addToast(`Selected ${candidate.scriptName} script.`, "success");
}, [addToast, selectScript]);
}, [addToast]);
const handleClearSelection = useCallback(() => {
setSelectedScript(null);
setShowCandidates(true);
void clearSelection().catch((selectionError) => {
addToast(normalizeError(selectionError), "error");
});
addToast("Cleared selected dev server script.", "success");
}, [addToast, clearSelection]);
}, [addToast]);
const handleStart = () => {
const trimmedCommand = commandInput.trim();
@@ -373,10 +318,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
void runAction(
"preview",
async () => {
await setPreviewUrlOverride(nextUrl);
await setPreviewUrl(nextUrl);
},
() => setPreviewUrl(nextUrl),
nextUrl ? "Preview URL updated." : "Preview URL override cleared.",
);
};
@@ -387,7 +329,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
}
}, [error, refresh]);
const isManualPreviewOverride = Boolean(manualPreviewUrlOverride);
const isManualPreviewOverride = false; // With session model, previewUrl is always auto-detected
const startDisabled = status === "starting" || status === "running" || actionInFlight !== null;
const stopDisabled = status === "stopped" || actionInFlight !== null;

View File

@@ -73,19 +73,65 @@ function createConfig(overrides: Partial<DevServerConfig> = {}): DevServerConfig
};
}
function createDevServerHookState(overrides: Record<string, unknown> = {}) {
function legacyStateToSession(legacy: DevServerState) {
return {
candidates: [],
serverState: createState(),
config: {
id: legacy.id ?? "default",
name: legacy.name ?? "Dev Server",
command: legacy.command ?? "",
cwd: legacy.cwd ?? ".",
},
status: legacy.status,
runtime: legacy.pid
? {
pid: legacy.pid,
startedAt: legacy.startedAt ?? new Date().toISOString(),
exitCode: legacy.exitCode ?? undefined,
previewUrl: legacy.previewUrl,
}
: undefined,
previewUrl: legacy.previewUrl ?? legacy.detectedUrl ?? legacy.manualUrl ?? null,
logHistory: [],
};
}
function createDevServerHookState(overrides: Record<string, unknown> = {}) {
const start = (overrides.start as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
const stop = (overrides.stop as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
const restart = (overrides.restart as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
const setPreviewUrl = (overrides.setPreviewUrl as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
const detect = (overrides.detect as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
const refresh = (overrides.refresh as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
const serverState = (overrides.serverState as DevServerState | undefined) ?? createState();
const candidates = (overrides.candidates as unknown[] | undefined) ?? [];
return {
// legacy API (still read by some tests as aliases)
logs: [],
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
restart: vi.fn().mockResolvedValue(undefined),
setPreviewUrl: vi.fn().mockResolvedValue(undefined),
loading: false,
error: null,
detect: vi.fn().mockResolvedValue(undefined),
setManualUrl: setPreviewUrl,
refreshStatus: refresh,
// new API consumed by the current component
sessions: [],
previewUrl: serverState.previewUrl ?? null,
isLoading: false,
...overrides,
// the following must come AFTER `...overrides` so aliases track the
// overridden legacy fields.
candidates,
serverState,
start,
stop,
restart,
setPreviewUrl,
detect,
session: legacyStateToSession(serverState),
detectedCommands: candidates,
startServer: start,
stopServer: stop,
restartServer: restart,
detectCommands: detect,
refresh,
};
}

View File

@@ -57,37 +57,69 @@ function createState(overrides: Partial<DevServerState> = {}): DevServerState {
};
}
function createDevServerHookState(overrides: Record<string, unknown> = {}) {
function legacyStateToSession(legacy: DevServerState) {
return {
candidates: [
{
name: "dev",
command: "pnpm dev",
scriptName: "dev",
cwd: ".",
source: "root",
label: "project · dev (root)",
},
{
name: "start",
command: "pnpm start --filter web",
scriptName: "start",
cwd: "apps/web",
source: "apps/web",
workspaceName: "@demo/web",
label: "@demo/web · start (apps/web)",
},
],
serverState: createState(),
config: {
id: legacy.id ?? "default",
name: legacy.name ?? "Dev Server",
command: legacy.command ?? "",
cwd: legacy.cwd ?? ".",
},
status: legacy.status,
runtime: legacy.pid
? {
pid: legacy.pid,
startedAt: legacy.startedAt ?? new Date().toISOString(),
exitCode: legacy.exitCode ?? undefined,
previewUrl: legacy.previewUrl,
}
: undefined,
previewUrl: legacy.previewUrl ?? legacy.detectedUrl ?? legacy.manualUrl,
logHistory: [],
};
}
function createDevServerHookState(overrides: Record<string, unknown> = {}) {
const defaultCandidates = [
{ name: "dev", command: "pnpm dev", scriptName: "dev", cwd: ".", source: "root", label: "project · dev (root)" },
{ name: "start", command: "pnpm start --filter web", scriptName: "start", cwd: "apps/web", source: "apps/web", workspaceName: "@demo/web", label: "@demo/web · start (apps/web)" },
];
const candidates = (overrides.candidates as typeof defaultCandidates | undefined) ?? defaultCandidates;
const start = (overrides.start as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
const stop = (overrides.stop as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
const restart = (overrides.restart as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
const setPreviewUrl = (overrides.setPreviewUrl as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
const detect = (overrides.detect as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
const refresh = (overrides.refresh as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
const serverState = (overrides.serverState as DevServerState | undefined) ?? createState();
return {
// legacy API (still read by some tests as aliases)
logs: ["ready"],
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
restart: vi.fn().mockResolvedValue(undefined),
setPreviewUrl: vi.fn().mockResolvedValue(undefined),
loading: false,
error: null,
detect: vi.fn().mockResolvedValue(undefined),
setManualUrl: setPreviewUrl,
refreshStatus: refresh,
// new API consumed by the current component
sessions: [],
previewUrl: serverState.previewUrl ?? null,
isLoading: false,
...overrides,
// the following must come AFTER `...overrides` so aliases track the
// overridden legacy fields (start, serverState, candidates, ...).
candidates,
serverState,
start,
stop,
restart,
setPreviewUrl,
detect,
session: legacyStateToSession(serverState),
detectedCommands: candidates,
startServer: start,
stopServer: stop,
restartServer: restart,
detectCommands: detect,
refresh,
};
}
@@ -253,47 +285,29 @@ describe("DevServerView", () => {
});
});
it("clicking a candidate persists selection via selectScript", async () => {
const selectScript = vi.fn().mockResolvedValue(undefined);
mockUseDevServerConfig.mockReturnValue(createConfigHookState({ selectScript }));
it("clicking a candidate shows the selected-script summary", async () => {
render(<DevServerView addToast={addToast} projectId="project-a" />);
fireEvent.click(screen.getByTestId("dev-server-candidate-dev-root"));
await waitFor(() => {
expect(selectScript).toHaveBeenCalledWith({
name: "dev",
command: "pnpm dev",
source: "root",
});
expect(screen.getByTestId("dev-server-selected-summary")).toBeInTheDocument();
});
});
it("highlights the selected candidate", () => {
mockUseDevServerConfig.mockReturnValue(
createConfigHookState({
config: createConfig({
selectedScript: "dev",
selectedSource: "root",
selectedCommand: "pnpm dev",
}),
}),
);
it("re-shows the candidates list when the user clicks Change after selecting", () => {
render(<DevServerView addToast={addToast} projectId="project-a" />);
fireEvent.click(screen.getByTestId("dev-server-candidate-dev-root"));
expect(screen.getByTestId("dev-server-selected-summary")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("dev-server-change-selection"));
const selected = screen.getByTestId("dev-server-candidate-dev-root");
expect(selected).toHaveClass("dev-server-candidate--selected");
expect(screen.getByTestId("dev-server-candidates")).toBeInTheDocument();
});
it("saves preview URL override from input", async () => {
const setPreviewUrlOverride = vi.fn().mockResolvedValue(undefined);
const setPreviewUrl = vi.fn().mockResolvedValue(undefined);
mockUseDevServerConfig.mockReturnValue(createConfigHookState({ setPreviewUrlOverride }));
mockUseDevServer.mockReturnValue(createDevServerHookState({ setPreviewUrl }));
render(<DevServerView addToast={addToast} projectId="project-a" />);
@@ -304,7 +318,6 @@ describe("DevServerView", () => {
fireEvent.click(screen.getByTestId("dev-server-set-preview"));
await waitFor(() => {
expect(setPreviewUrlOverride).toHaveBeenCalledWith("http://localhost:3000");
expect(setPreviewUrl).toHaveBeenCalledWith("http://localhost:3000");
});
});
@@ -334,19 +347,11 @@ describe("DevServerView", () => {
expect(screen.getByTitle("Dev server preview")).toBeInTheDocument();
});
it("renders selected script summary when config has a selection", () => {
mockUseDevServerConfig.mockReturnValue(
createConfigHookState({
config: createConfig({
selectedScript: "dev",
selectedSource: "root",
selectedCommand: "pnpm dev",
}),
}),
);
it("renders selected script summary after user selects a script", () => {
render(<DevServerView addToast={addToast} projectId="project-a" />);
fireEvent.click(screen.getByTestId("dev-server-candidate-dev-root"));
expect(screen.getByTestId("dev-server-selected-summary")).toBeInTheDocument();
});
});