Merge branch 'v2/main' into v2/fix/1941-cli-vitest-teardown-crash
This commit is contained in:
commit
138e07fab2
44 changed files with 6840 additions and 1277 deletions
|
|
@ -507,6 +507,9 @@ function renderApp(servers: Record<string, TuiServer>) {
|
|||
async function mount(servers: Record<string, TuiServer>) {
|
||||
const r = renderApp(servers);
|
||||
await tick();
|
||||
// The mount commit's effect flush can still be queued behind this tick, and
|
||||
// this write is the one that absorbs the dropped first keypress.
|
||||
await settleInputHandlers();
|
||||
r.stdin.write("x");
|
||||
await tick();
|
||||
return r;
|
||||
|
|
@ -532,6 +535,11 @@ const ENTER = "\r";
|
|||
*/
|
||||
async function press(r: RenderResult, keys: string[]) {
|
||||
for (const k of keys) {
|
||||
// Same hazard `waitUntil` guards against, at the other end: a caller can
|
||||
// reach here on a turn that still has React's passive-effect flush queued
|
||||
// (e.g. straight after a plain `tick`, or after an earlier key committed a
|
||||
// render), so settle before every write rather than only after a poll.
|
||||
await settleInputHandlers();
|
||||
r.stdin.write(k);
|
||||
await tick();
|
||||
await tick();
|
||||
|
|
@ -552,9 +560,29 @@ async function press(r: RenderResult, keys: string[]) {
|
|||
*/
|
||||
const POLL_TRIES = 100;
|
||||
|
||||
/**
|
||||
* One check-phase turn, queued BEHIND React's already-scheduled passive-effect
|
||||
* flush. A frame observed by a poll predicate is written during React's
|
||||
* COMMIT, but ink re-arms its useInput listeners in the passive-effect flush
|
||||
* React schedules (via setImmediate in Node) during that same commit. Node's
|
||||
* event loop runs the timers phase before the check phase, so a 25ms poll
|
||||
* tick can observe the new frame and let the test write the next keypress
|
||||
* BEFORE that flush has run — the key is then dispatched to the previous
|
||||
* commit's stale useInput closures (where e.g. pendingStepUp is still null)
|
||||
* and silently swallowed (#1942). Yielding one setImmediate turn after the
|
||||
* predicate passes sequences the next stdin write after the flush (FIFO
|
||||
* within the check queue), so "frame visible" once again implies "input
|
||||
* handlers armed".
|
||||
*/
|
||||
const settleInputHandlers = () =>
|
||||
new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
async function waitUntil(predicate: () => boolean, tries = POLL_TRIES) {
|
||||
for (let i = 0; i < tries; i++) {
|
||||
if (predicate()) return;
|
||||
if (predicate()) {
|
||||
await settleInputHandlers();
|
||||
return;
|
||||
}
|
||||
await tick();
|
||||
}
|
||||
}
|
||||
|
|
@ -626,6 +654,23 @@ afterEach(() => {
|
|||
while (mounted.length) mounted.pop()?.unmount();
|
||||
});
|
||||
|
||||
// Pins the synchronization contract the OAuth step-up assertions depend on
|
||||
// (#1942). The `setImmediate` sentinel below stands in for React's pending
|
||||
// passive-effect flush — the turn where ink re-arms `useInput`. If `waitUntil`
|
||||
// ever returns without yielding a check-phase turn, the sentinel has not run
|
||||
// and this fails, instead of the regression resurfacing as a differently-named
|
||||
// flaky OAuth test under coverage instrumentation.
|
||||
describe("test helpers", () => {
|
||||
it("waitUntil settles input handlers before resolving", async () => {
|
||||
let flushed = false;
|
||||
setImmediate(() => {
|
||||
flushed = true;
|
||||
});
|
||||
await waitUntil(() => true);
|
||||
expect(flushed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("App (foundation)", () => {
|
||||
it("renders the server list with the MCP Servers header", async () => {
|
||||
const r = renderApp(stdioServer());
|
||||
|
|
|
|||
|
|
@ -598,6 +598,7 @@ const DEFAULT_USE_INSPECTOR_CLIENT: ReturnType<typeof useInspectorClient> = {
|
|||
serverInfo: undefined,
|
||||
instructions: undefined,
|
||||
excludedTools: [],
|
||||
malformedListItems: [],
|
||||
appRendererClient: null,
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedReq
|
|||
import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSubscriptionsState.js";
|
||||
import {
|
||||
cleanRoots,
|
||||
oauthAuthorizationParamsFromSettings,
|
||||
serializeMcpConfig,
|
||||
} from "@inspector/core/mcp/serverList.js";
|
||||
import type { ClientConfig } from "@inspector/core/client/types.js";
|
||||
|
|
@ -163,6 +164,7 @@ import {
|
|||
import { buildExportFilename, downloadJsonFile } from "./lib/downloadFile";
|
||||
import { INSPECTOR_SERVERS_TAB } from "./utils/inspectorTabs";
|
||||
import { enrichProtocolEntries } from "./utils/correlateTransportErrors";
|
||||
import { visibleMalformedListItems } from "./utils/malformedListReport";
|
||||
import {
|
||||
parseDeepLink,
|
||||
deepLinkConfigEquals,
|
||||
|
|
@ -990,6 +992,7 @@ function App() {
|
|||
protocolEra,
|
||||
discoverResult,
|
||||
excludedTools,
|
||||
malformedListItems,
|
||||
lastError,
|
||||
} = useInspectorClient(inspectorClient);
|
||||
const {
|
||||
|
|
@ -1055,6 +1058,14 @@ function App() {
|
|||
setPaginatedListsOverride(null);
|
||||
}, [persistedPaginatedLists, activeServerId]);
|
||||
const paginatedLists = paginatedListsOverride ?? persistedPaginatedLists;
|
||||
// The malformed-entry report is written by the aggregate walk's salvage. In
|
||||
// paginated mode the tools/prompts/resources panels render the paged stores
|
||||
// instead, which never write or clear it — so it would linger above a page it
|
||||
// does not describe. See `visibleMalformedListItems` (#1909 + #1721).
|
||||
const shownMalformedListItems = useMemo(
|
||||
() => visibleMalformedListItems(malformedListItems, paginatedLists),
|
||||
[malformedListItems, paginatedLists],
|
||||
);
|
||||
const connected = connectionStatus === "connected";
|
||||
const toolsPagination = usePaginatedList({
|
||||
connected,
|
||||
|
|
@ -2301,11 +2312,15 @@ function App() {
|
|||
.map((m) => [m.key, m.value]),
|
||||
)
|
||||
: undefined;
|
||||
const serverAuthorizationParams = savedSettings
|
||||
? oauthAuthorizationParamsFromSettings(savedSettings)
|
||||
: undefined;
|
||||
const oauthFromServer =
|
||||
savedSettings &&
|
||||
(savedSettings.oauthClientId ||
|
||||
savedSettings.oauthClientSecret ||
|
||||
savedSettings.oauthScopes ||
|
||||
serverAuthorizationParams ||
|
||||
savedSettings.enterpriseManaged)
|
||||
? {
|
||||
...(savedSettings.oauthClientId && {
|
||||
|
|
@ -2317,6 +2332,9 @@ function App() {
|
|||
...(savedSettings.oauthScopes && {
|
||||
scope: savedSettings.oauthScopes,
|
||||
}),
|
||||
...(serverAuthorizationParams && {
|
||||
authorizationParams: serverAuthorizationParams,
|
||||
}),
|
||||
...(savedSettings.enterpriseManaged && {
|
||||
enterpriseManaged: true,
|
||||
}),
|
||||
|
|
@ -4376,6 +4394,7 @@ function App() {
|
|||
latencyMs={latencyMs}
|
||||
tools={tools}
|
||||
excludedTools={excludedTools}
|
||||
malformedListItems={shownMalformedListItems}
|
||||
prompts={prompts}
|
||||
resources={resources}
|
||||
resourceTemplates={resourceTemplates}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,17 @@ describe("CopyButton", () => {
|
|||
expect(screen.getByText("⎘")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names itself 'Copy' with no label, and qualifies the name when given one", () => {
|
||||
const { unmount } = renderWithMantine(<CopyButton value="hello" />);
|
||||
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
renderWithMantine(<CopyButton value="hello" label="ID Token" />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Copy ID Token" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("triggers a click without throwing when clipboard is unavailable", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<CopyButton value="hello" />);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,15 @@ export interface CopyButtonProps {
|
|||
* rows (e.g. beside a Code block). Icon size is unchanged.
|
||||
*/
|
||||
flush?: boolean;
|
||||
/**
|
||||
* Names what is being copied, for the accessible label only ("Copy ID
|
||||
* Token"). Set it when more than one CopyButton can appear in the same
|
||||
* region, so button-by-button screen-reader navigation can tell them apart;
|
||||
* a lone control needs no qualifier. The visible tooltip stays "Copy" either
|
||||
* way, and the accessible name keeps "Copy"/"Copied" as its first word so it
|
||||
* still contains the visible text (WCAG 2.5.3).
|
||||
*/
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const CopyActionIcon = ActionIcon.withProps({
|
||||
|
|
@ -18,7 +27,8 @@ const CopyActionIcon = ActionIcon.withProps({
|
|||
fz: 24,
|
||||
});
|
||||
|
||||
export function CopyButton({ value, flush = false }: CopyButtonProps) {
|
||||
export function CopyButton({ value, flush = false, label }: CopyButtonProps) {
|
||||
const qualifier = label ? ` ${label}` : "";
|
||||
return (
|
||||
<MantineCopyButton value={value}>
|
||||
{({ copied, copy }) => (
|
||||
|
|
@ -26,7 +36,7 @@ export function CopyButton({ value, flush = false }: CopyButtonProps) {
|
|||
<CopyActionIcon
|
||||
color={copied ? "green" : "var(--inspector-text-primary)"}
|
||||
onClick={copy}
|
||||
aria-label={copied ? "Copied" : "Copy"}
|
||||
aria-label={(copied ? "Copied" : "Copy") + qualifier}
|
||||
{...(flush && { p: 0, h: "auto", w: "auto" })}
|
||||
>
|
||||
{copied ? "\u2713" : "\u2398"}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { MalformedItemsWarning } from "./MalformedItemsWarning";
|
||||
|
||||
const meta: Meta<typeof MalformedItemsWarning> = {
|
||||
title: "Elements/MalformedItemsWarning",
|
||||
component: MalformedItemsWarning,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof MalformedItemsWarning>;
|
||||
|
||||
/** The failure this was built for: a PHP server whose empty `annotations`
|
||||
* object reaches the wire as `[]` (#1909). */
|
||||
export const OneDroppedTemplate: Story = {
|
||||
args: {
|
||||
method: "resources/templates/list",
|
||||
what: "resource templates",
|
||||
items: [
|
||||
{
|
||||
method: "resources/templates/list",
|
||||
index: 1,
|
||||
label: "array_annotations",
|
||||
reason: "annotations: Invalid input: expected object, received array",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const SeveralDropped: Story = {
|
||||
args: {
|
||||
method: "tools/list",
|
||||
what: "tools",
|
||||
items: [
|
||||
{
|
||||
method: "tools/list",
|
||||
index: 0,
|
||||
label: "get_weather",
|
||||
reason: "inputSchema: Invalid input: expected object, received string",
|
||||
},
|
||||
{
|
||||
method: "tools/list",
|
||||
index: 4,
|
||||
label: "add",
|
||||
reason: "name: Invalid input: expected string, received number",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** An entry too broken to carry a name is reported by its position. */
|
||||
export const UnlabeledEntry: Story = {
|
||||
args: {
|
||||
method: "prompts/list",
|
||||
what: "prompts",
|
||||
items: [
|
||||
{
|
||||
method: "prompts/list",
|
||||
index: 2,
|
||||
reason: "Invalid input: expected object, received null",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** Entries belonging to another list method are ignored — the same set is
|
||||
* passed to every panel, and each selects its own. */
|
||||
export const OtherMethodOnly: Story = {
|
||||
args: {
|
||||
method: "resources/templates/list",
|
||||
what: "resource templates",
|
||||
items: [
|
||||
{
|
||||
method: "tools/list",
|
||||
index: 0,
|
||||
label: "get_weather",
|
||||
reason: "inputSchema: Invalid input: expected object, received string",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** The resting state: a conforming server, nothing rendered. */
|
||||
export const Conforming: Story = {
|
||||
args: {
|
||||
method: "resources/templates/list",
|
||||
what: "resource templates",
|
||||
items: [],
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import type { MalformedListItem } from "@inspector/core/mcp";
|
||||
import { renderWithMantine } from "../../../test/renderWithMantine";
|
||||
import { MalformedItemsWarning } from "./MalformedItemsWarning";
|
||||
|
||||
const TEMPLATE_ENTRY: MalformedListItem = {
|
||||
method: "resources/templates/list",
|
||||
index: 1,
|
||||
label: "array_annotations",
|
||||
reason: "annotations: Invalid input: expected object, received array",
|
||||
};
|
||||
|
||||
const TOOL_ENTRY: MalformedListItem = {
|
||||
method: "tools/list",
|
||||
index: 0,
|
||||
label: "get_weather",
|
||||
reason: "inputSchema: Invalid input: expected object, received string",
|
||||
};
|
||||
|
||||
describe("MalformedItemsWarning", () => {
|
||||
it("names the dropped entry and why it was dropped", () => {
|
||||
renderWithMantine(
|
||||
<MalformedItemsWarning
|
||||
items={[TEMPLATE_ENTRY]}
|
||||
method="resources/templates/list"
|
||||
what="resource templates"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("1 malformed entry dropped")).toBeInTheDocument();
|
||||
expect(screen.getByText("array_annotations")).toBeInTheDocument();
|
||||
expect(screen.getByText(TEMPLATE_ENTRY.reason)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing when the server was conforming", () => {
|
||||
renderWithMantine(
|
||||
<MalformedItemsWarning
|
||||
items={[]}
|
||||
method="resources/templates/list"
|
||||
what="resource templates"
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores entries belonging to another list method", () => {
|
||||
// Every panel receives the whole set and selects its own — a tools failure
|
||||
// must not surface above the templates list.
|
||||
renderWithMantine(
|
||||
<MalformedItemsWarning
|
||||
items={[TOOL_ENTRY]}
|
||||
method="resources/templates/list"
|
||||
what="resource templates"
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("get_weather")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pluralizes the count and lists every entry", () => {
|
||||
renderWithMantine(
|
||||
<MalformedItemsWarning
|
||||
items={[
|
||||
TOOL_ENTRY,
|
||||
{ ...TOOL_ENTRY, index: 4, label: "add", reason: "name: bad" },
|
||||
TEMPLATE_ENTRY,
|
||||
]}
|
||||
method="tools/list"
|
||||
what="tools"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("2 malformed entries dropped")).toBeInTheDocument();
|
||||
expect(screen.getByText("get_weather")).toBeInTheDocument();
|
||||
expect(screen.getByText("add")).toBeInTheDocument();
|
||||
// The template entry belongs to the other panel.
|
||||
expect(screen.queryByText("array_annotations")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the position when the entry had no usable name", () => {
|
||||
renderWithMantine(
|
||||
<MalformedItemsWarning
|
||||
items={[
|
||||
{
|
||||
method: "prompts/list",
|
||||
index: 2,
|
||||
reason: "Invalid input: expected object, received null",
|
||||
},
|
||||
]}
|
||||
method="prompts/list"
|
||||
what="prompts"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("entry 2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import { Alert, Code, List, ScrollArea, Text } from "@mantine/core";
|
||||
import type { MalformedListItem } from "@inspector/core/mcp";
|
||||
|
||||
export interface MalformedItemsWarningProps {
|
||||
/**
|
||||
* Every malformed entry the client is currently reporting, across all list
|
||||
* methods — pass the hook's set straight through; the component selects its
|
||||
* own by {@link method}.
|
||||
*/
|
||||
items: MalformedListItem[];
|
||||
/** The list method this panel renders, e.g. `"resources/templates/list"`. */
|
||||
method: string;
|
||||
/** What the entries are, for the alert title — e.g. "resource templates". */
|
||||
what: string;
|
||||
}
|
||||
|
||||
// Yellow, not red: unlike ListLoadError this is not a failure — the list DID
|
||||
// load and is rendered below. It reports what the server sent that the
|
||||
// Inspector could not accept.
|
||||
const WarningAlert = Alert.withProps({
|
||||
color: "yellow",
|
||||
variant: "light",
|
||||
});
|
||||
|
||||
// The entry list stays compact; the reasons are the payload, so they get the
|
||||
// monospace treatment while the labels stay in body type.
|
||||
const EntryList = List.withProps({
|
||||
size: "xs",
|
||||
spacing: 4,
|
||||
withPadding: true,
|
||||
});
|
||||
|
||||
const EntryReason = Code.withProps({
|
||||
variant: "wrapping",
|
||||
});
|
||||
|
||||
// A server can be wrong about many entries at once; cap the height so a badly
|
||||
// broken list can't push the list itself off the sidebar.
|
||||
const EntryScroll = ScrollArea.withProps({
|
||||
mah: 180,
|
||||
type: "auto",
|
||||
});
|
||||
|
||||
const SummaryText = Text.withProps({
|
||||
size: "xs",
|
||||
mb: 4,
|
||||
});
|
||||
|
||||
// The entry's identity, emphasized against its monospaced reason.
|
||||
const EntryLabel = Text.withProps({
|
||||
component: "span",
|
||||
size: "xs",
|
||||
fw: 600,
|
||||
});
|
||||
|
||||
/** `label` when the entry had one, else its position in the response. */
|
||||
function describeEntry(item: MalformedListItem): string {
|
||||
return item.label ?? `entry ${item.index}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Some entries were dropped" — the visible half of the #1909 salvage.
|
||||
*
|
||||
* The client keeps the valid entries of a list whose result failed schema
|
||||
* validation, rather than losing the whole list to one bad entry. Dropping them
|
||||
* silently would trade one lie for another, so what was dropped, and why, is
|
||||
* reported here beside the list that rendered without them.
|
||||
*/
|
||||
export function MalformedItemsWarning({
|
||||
items,
|
||||
method,
|
||||
what,
|
||||
}: MalformedItemsWarningProps) {
|
||||
const mine = items.filter((item) => item.method === method);
|
||||
if (mine.length === 0) return null;
|
||||
|
||||
return (
|
||||
<WarningAlert
|
||||
title={`${mine.length} malformed ${
|
||||
mine.length === 1 ? "entry" : "entries"
|
||||
} dropped`}
|
||||
>
|
||||
<SummaryText>
|
||||
The server returned {what} that don't match the MCP schema. The
|
||||
rest of the list is shown below.
|
||||
</SummaryText>
|
||||
<EntryScroll>
|
||||
<EntryList>
|
||||
{mine.map((item) => (
|
||||
<List.Item key={`${item.method}:${item.index}`}>
|
||||
<EntryLabel>{describeEntry(item)}</EntryLabel>{" "}
|
||||
<EntryReason>{item.reason}</EntryReason>
|
||||
</List.Item>
|
||||
))}
|
||||
</EntryList>
|
||||
</EntryScroll>
|
||||
</WarningAlert>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import type {
|
|||
InitializeResult,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, within } from "storybook/test";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import {
|
||||
ConnectionInfoContent,
|
||||
SERVER_INFO_NOT_REPORTED_LABEL,
|
||||
|
|
@ -161,3 +161,37 @@ export const WithOAuth: Story = {
|
|||
},
|
||||
},
|
||||
};
|
||||
|
||||
// An authorization server that also returned an `id_token` (#2019). Shown for
|
||||
// inspection only — decoding it is display-only, and it is never used as a
|
||||
// credential.
|
||||
export const WithOAuthIdToken: Story = {
|
||||
args: {
|
||||
initializeResult: {
|
||||
protocolVersion: "2025-03-26",
|
||||
serverInfo: { name: "OIDC-fronted Server", version: "3.0.0" },
|
||||
capabilities: { tools: { listChanged: true } },
|
||||
},
|
||||
clientCapabilities: { roots: { listChanged: true } },
|
||||
transport: "streamable-http",
|
||||
oauth: {
|
||||
protocol: "standard",
|
||||
authorized: true,
|
||||
authUrl: "https://auth.example.com/oauth2/authorize",
|
||||
scopes: ["openid", "email", "read"],
|
||||
accessToken: "opaque-access-token",
|
||||
// header {"alg":"none"} / payload {"sub":"user-42","email":"a@b.test"}
|
||||
idToken:
|
||||
"eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyLTQyIiwiZW1haWwiOiJhQGIudGVzdCJ9.",
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("ID Token")).toBeInTheDocument();
|
||||
// Only the ID token is a JWT here, so exactly one decode toggle is offered.
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Decode JWT for ID Token" }),
|
||||
);
|
||||
expect(canvas.getByText(/"sub": "user-42"/)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -473,6 +473,95 @@ describe("ConnectionInfoContent", () => {
|
|||
expect(screen.queryByText("Auth URL")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Scopes")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Access Token")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("ID Token")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the ID Token row when the token set carries one", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={fullResult}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
oauth={{
|
||||
protocol: "standard",
|
||||
authorized: true,
|
||||
accessToken: "token-123",
|
||||
idToken: "id-token-456",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Access Token")).toBeInTheDocument();
|
||||
expect(screen.getByText("ID Token")).toBeInTheDocument();
|
||||
expect(screen.getByText("id-token-456")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives the two token rows' controls distinct accessible names", () => {
|
||||
// Both rows carry a copy control, and both tokens here are JWTs so both
|
||||
// carry a decode toggle. Screen-reader button navigation has only the
|
||||
// accessible name to go on, so the four must not collide (#2019 review).
|
||||
const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0.";
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={fullResult}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
oauth={{
|
||||
protocol: "standard",
|
||||
authorized: true,
|
||||
accessToken: jwt,
|
||||
idToken: jwt,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const names = screen
|
||||
.getAllByRole("button")
|
||||
.map((button) => button.getAttribute("aria-label") ?? button.textContent)
|
||||
.filter((name): name is string => Boolean(name));
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
|
||||
for (const name of [
|
||||
"Copy Access Token",
|
||||
"Copy ID Token",
|
||||
"Decode JWT for Access Token",
|
||||
"Decode JWT for ID Token",
|
||||
]) {
|
||||
expect(screen.getByRole("button", { name })).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the ID Token row on its own when there is no access token", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={fullResult}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
oauth={{
|
||||
protocol: "standard",
|
||||
authorized: true,
|
||||
idToken: "id-token-456",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("Access Token")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("ID Token")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the ID Token row when only an access token is present", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={fullResult}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
oauth={{
|
||||
protocol: "standard",
|
||||
authorized: true,
|
||||
accessToken: "token-123",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Access Token")).toBeInTheDocument();
|
||||
expect(screen.queryByText("ID Token")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render OAuth section when oauth prop is omitted", () => {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import {
|
|||
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
||||
import { EraBadge } from "../../elements/EraBadge/EraBadge";
|
||||
import { isModernEra } from "../../elements/EraBadge/eraUtils";
|
||||
import { OAuthAccessTokenField } from "./OAuthAccessTokenField";
|
||||
import { OAuthTokenField } from "./OAuthTokenField";
|
||||
|
||||
export interface OAuthDetails {
|
||||
protocol: "standard" | "ema";
|
||||
|
|
@ -34,6 +34,13 @@ export interface OAuthDetails {
|
|||
authUrl?: string;
|
||||
scopes?: string[];
|
||||
accessToken?: string;
|
||||
/**
|
||||
* OIDC `id_token` from the stored token set, when the authorization server
|
||||
* returned one. Shown for inspection only — the MCP authorization spec is
|
||||
* plain OAuth 2.1 and the Inspector never treats this as a credential
|
||||
* (#2019). Absent when the token set carries none, so no empty row renders.
|
||||
*/
|
||||
idToken?: string;
|
||||
/** EMA only — install-level IdP session for legs 1–2. */
|
||||
idpSession?: "none" | "logged_in" | "expired";
|
||||
}
|
||||
|
|
@ -370,20 +377,27 @@ export function ConnectionInfoContent({
|
|||
<ValueText>{formatScopes(oauth.scopes)}</ValueText>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
{oauth.accessToken ? (
|
||||
<OAuthAccessTokenField
|
||||
accessToken={oauth.accessToken}
|
||||
{oauth.accessToken && (
|
||||
<OAuthTokenField
|
||||
label="Access Token"
|
||||
token={oauth.accessToken}
|
||||
onClear={onClearOAuth}
|
||||
clearLabel={CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
|
||||
/>
|
||||
) : (
|
||||
onClearOAuth && (
|
||||
<Flex justify="flex-end">
|
||||
<ClearOAuthButton onClick={onClearOAuth}>
|
||||
{CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
|
||||
</ClearOAuthButton>
|
||||
</Flex>
|
||||
)
|
||||
)}
|
||||
{/* Viewer only — an `id_token` the AS happened to return, decoded
|
||||
on request. It carries no clear action: clearing OAuth state is
|
||||
one action for the whole token set, owned by the access-token
|
||||
row (or the standalone button below when there is none). */}
|
||||
{oauth.idToken && (
|
||||
<OAuthTokenField label="ID Token" token={oauth.idToken} />
|
||||
)}
|
||||
{!oauth.accessToken && onClearOAuth && (
|
||||
<Flex justify="flex-end">
|
||||
<ClearOAuthButton onClick={onClearOAuth}>
|
||||
{CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
|
||||
</ClearOAuthButton>
|
||||
</Flex>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -1,90 +0,0 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL } from "./ConnectionInfoContent";
|
||||
import { OAuthAccessTokenField } from "./OAuthAccessTokenField";
|
||||
|
||||
const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0.";
|
||||
|
||||
describe("OAuthAccessTokenField", () => {
|
||||
it("renders token with copy control beside the content", () => {
|
||||
renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
|
||||
expect(screen.getByText("Access Token")).toBeInTheDocument();
|
||||
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/eyJzdWIiOiJ1c2VyIn0/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("replaces raw token with decoded JSON and restores on toggle", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
|
||||
|
||||
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Decode JWT" }));
|
||||
expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/eyJhbGciOiJub25lIn0/)).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Show token" }));
|
||||
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/"sub": "user"/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits decode toggle for opaque tokens", () => {
|
||||
renderWithMantine(
|
||||
<OAuthAccessTokenField accessToken="opaque-access-token-value" />,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Decode JWT" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("copies decoded JSON while decode view is shown", async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
|
||||
await user.click(screen.getByRole("button", { name: "Decode JWT" }));
|
||||
await user.click(screen.getByRole("button", { name: "Copy" }));
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"sub": "user"'),
|
||||
);
|
||||
expect(writeText).not.toHaveBeenCalledWith(jwt);
|
||||
});
|
||||
|
||||
it("copies the raw token while token view is shown", async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
|
||||
await user.click(screen.getByRole("button", { name: "Copy" }));
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(jwt);
|
||||
});
|
||||
|
||||
it("renders clear action on the access token header row", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClear = vi.fn();
|
||||
renderWithMantine(
|
||||
<OAuthAccessTokenField
|
||||
accessToken="opaque-access-token-value"
|
||||
onClear={onClear}
|
||||
clearLabel={CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
|
||||
/>,
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL,
|
||||
}),
|
||||
);
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL } from "./ConnectionInfoContent";
|
||||
import { OAuthTokenField } from "./OAuthTokenField";
|
||||
|
||||
const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0.";
|
||||
|
||||
describe("OAuthTokenField", () => {
|
||||
it("renders token with copy control beside the content", () => {
|
||||
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
|
||||
expect(screen.getByText("Access Token")).toBeInTheDocument();
|
||||
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/eyJzdWIiOiJ1c2VyIn0/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Copy Access Token" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("replaces raw token with decoded JSON and restores on toggle", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
|
||||
|
||||
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Decode JWT for Access Token" }),
|
||||
);
|
||||
expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/eyJhbGciOiJub25lIn0/)).not.toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Show token for Access Token" }),
|
||||
);
|
||||
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/"sub": "user"/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits decode toggle for opaque tokens", () => {
|
||||
renderWithMantine(
|
||||
<OAuthTokenField
|
||||
label="Access Token"
|
||||
token="opaque-access-token-value"
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Decode JWT for Access Token" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("copies decoded JSON while decode view is shown", async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Decode JWT for Access Token" }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Copy Access Token" }));
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"sub": "user"'),
|
||||
);
|
||||
expect(writeText).not.toHaveBeenCalledWith(jwt);
|
||||
});
|
||||
|
||||
it("copies the raw token while token view is shown", async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
|
||||
await user.click(screen.getByRole("button", { name: "Copy Access Token" }));
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(jwt);
|
||||
});
|
||||
|
||||
it("renders clear action on the access token header row", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClear = vi.fn();
|
||||
renderWithMantine(
|
||||
<OAuthTokenField
|
||||
label="Access Token"
|
||||
token="opaque-access-token-value"
|
||||
onClear={onClear}
|
||||
clearLabel={CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
|
||||
/>,
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL,
|
||||
}),
|
||||
);
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("labels the row from the label prop and decodes an ID token", async () => {
|
||||
const user = userEvent.setup();
|
||||
// header {"alg":"none"} / payload {"sub":"user"} — same shape an AS returns
|
||||
// for an `id_token`; the decode path is identical to the access token's.
|
||||
renderWithMantine(<OAuthTokenField label="ID Token" token={jwt} />);
|
||||
|
||||
expect(screen.getByText("ID Token")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Access Token")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Decode JWT for ID Token" }),
|
||||
);
|
||||
expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("qualifies the decode and copy accessible names with the row label", () => {
|
||||
// Two rows can render simultaneously (Access Token + ID Token), so the
|
||||
// controls must not share accessible names — see #2019 review.
|
||||
renderWithMantine(<OAuthTokenField label="ID Token" token={jwt} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Decode JWT for ID Token" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Copy ID Token" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the clear action when no handler is given", () => {
|
||||
renderWithMantine(<OAuthTokenField label="ID Token" token={jwt} />);
|
||||
expect(
|
||||
screen.queryByRole("button", {
|
||||
name: CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
import { Fragment, useMemo, useState } from "react";
|
||||
import { Button, Code, Flex, Stack, Text } from "@mantine/core";
|
||||
import { decodeJwtPayload, isJwtFormat } from "@inspector/core/auth/ema/jwt.js";
|
||||
import { CopyButton } from "../../elements/CopyButton/CopyButton";
|
||||
|
||||
/**
|
||||
* One labelled OAuth token row: the raw value, a copy control, and — when the
|
||||
* value has the three-segment JWT shape — an in-place "Decode JWT" toggle.
|
||||
*
|
||||
* Shared by the Access Token and ID Token rows (#2019). The decode is
|
||||
* display-only (`decodeJwtPayload` does no signature verification), so this is
|
||||
* a viewer: neither token is treated as a credential here.
|
||||
*/
|
||||
export interface OAuthTokenFieldProps {
|
||||
/** Row caption, e.g. "Access Token" / "ID Token". */
|
||||
label: string;
|
||||
token: string;
|
||||
onClear?: () => void;
|
||||
clearLabel?: string;
|
||||
}
|
||||
|
||||
const CaptionRow = Flex.withProps({
|
||||
justify: "space-between",
|
||||
align: "center",
|
||||
gap: "sm",
|
||||
wrap: "nowrap",
|
||||
});
|
||||
|
||||
const Caption = Text.withProps({ size: "sm" });
|
||||
|
||||
const Toolbar = Flex.withProps({
|
||||
gap: 4,
|
||||
align: "center",
|
||||
wrap: "nowrap",
|
||||
});
|
||||
|
||||
const ToolbarButton = Button.withProps({
|
||||
variant: "subtle",
|
||||
size: "compact-xs",
|
||||
});
|
||||
|
||||
const TokenRow = Flex.withProps({
|
||||
align: "flex-start",
|
||||
gap: 4,
|
||||
wrap: "nowrap",
|
||||
});
|
||||
|
||||
const TokenColumn = Flex.withProps({
|
||||
flex: 1,
|
||||
miw: 0,
|
||||
direction: "column",
|
||||
});
|
||||
|
||||
const TokenCode = Code.withProps({
|
||||
block: true,
|
||||
py: "xs",
|
||||
ps: "xs",
|
||||
pe: 0,
|
||||
variant: "wrapping",
|
||||
});
|
||||
|
||||
/** Wrap JWT at segment boundaries; break long segments without orphaning `.`. */
|
||||
function JwtTokenText({ token }: { token: string }) {
|
||||
const parts = token.split(".");
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) => (
|
||||
<Fragment key={index}>
|
||||
{index > 0 && "."}
|
||||
{part}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function OAuthTokenField({
|
||||
label,
|
||||
token,
|
||||
onClear,
|
||||
clearLabel = "Clear",
|
||||
}: OAuthTokenFieldProps) {
|
||||
const [showDecoded, setShowDecoded] = useState(false);
|
||||
const isJwt = isJwtFormat(token);
|
||||
const jwtDecoded = useMemo(
|
||||
() => (isJwt ? decodeJwtPayload(token) : undefined),
|
||||
[token, isJwt],
|
||||
);
|
||||
|
||||
const decodedText = useMemo(() => {
|
||||
if (!jwtDecoded) return undefined;
|
||||
return JSON.stringify(
|
||||
{ header: jwtDecoded.header, payload: jwtDecoded.payload },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}, [jwtDecoded]);
|
||||
|
||||
const copyValue = showDecoded && decodedText ? decodedText : token;
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<CaptionRow>
|
||||
<Caption>{label}</Caption>
|
||||
<Toolbar>
|
||||
{jwtDecoded && (
|
||||
<ToolbarButton
|
||||
onClick={() => setShowDecoded((open) => !open)}
|
||||
aria-pressed={showDecoded}
|
||||
aria-label={`${showDecoded ? "Show token" : "Decode JWT"} for ${label}`}
|
||||
>
|
||||
{showDecoded ? "Show token" : "Decode JWT"}
|
||||
</ToolbarButton>
|
||||
)}
|
||||
{onClear && (
|
||||
<ToolbarButton color="red" onClick={onClear}>
|
||||
{clearLabel}
|
||||
</ToolbarButton>
|
||||
)}
|
||||
</Toolbar>
|
||||
</CaptionRow>
|
||||
<TokenRow>
|
||||
<TokenColumn>
|
||||
<TokenCode>
|
||||
{showDecoded && decodedText ? (
|
||||
decodedText
|
||||
) : isJwt ? (
|
||||
<JwtTokenText token={token} />
|
||||
) : (
|
||||
token
|
||||
)}
|
||||
</TokenCode>
|
||||
</TokenColumn>
|
||||
<CopyButton value={copyValue} flush label={label} />
|
||||
</TokenRow>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -34,6 +34,39 @@ describe("oauthDetailsFromConnectionState", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("surfaces an id_token when the token set carries one", () => {
|
||||
const state: OAuthConnectionState = {
|
||||
authorized: true,
|
||||
protocol: "standard",
|
||||
serverUrl: "https://mcp.example.com/mcp",
|
||||
tokens: {
|
||||
access_token: "tok",
|
||||
token_type: "Bearer",
|
||||
id_token: "header.payload.signature",
|
||||
},
|
||||
};
|
||||
|
||||
expect(oauthDetailsFromConnectionState(state)).toEqual({
|
||||
protocol: "standard",
|
||||
authorized: true,
|
||||
accessToken: "tok",
|
||||
idToken: "header.payload.signature",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits idToken when the token set carries none", () => {
|
||||
const state: OAuthConnectionState = {
|
||||
authorized: true,
|
||||
protocol: "standard",
|
||||
serverUrl: "https://mcp.example.com/mcp",
|
||||
tokens: { access_token: "tok", token_type: "Bearer" },
|
||||
};
|
||||
|
||||
expect(oauthDetailsFromConnectionState(state)).not.toHaveProperty(
|
||||
"idToken",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes EMA idp session when present", () => {
|
||||
const state: OAuthConnectionState = {
|
||||
authorized: false,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ export function oauthDetailsFromConnectionState(
|
|||
...(state.tokens?.access_token && {
|
||||
accessToken: state.tokens.access_token,
|
||||
}),
|
||||
// Surfaced for inspection when the AS returned one; omitted entirely
|
||||
// otherwise so Connection Info renders no empty row (#2019).
|
||||
...(state.tokens?.id_token && {
|
||||
idToken: state.tokens.id_token,
|
||||
}),
|
||||
...(state.protocol === "ema" &&
|
||||
state.ema?.idpSession && { idpSession: state.ema.idpSession }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { ClearButton } from "../../elements/ClearButton/ClearButton";
|
|||
import type { Prompt } from "@modelcontextprotocol/client";
|
||||
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
|
||||
import { ListLoadError } from "../../elements/ListLoadError/ListLoadError";
|
||||
import { MalformedItemsWarning } from "../../elements/MalformedItemsWarning/MalformedItemsWarning";
|
||||
import type { MalformedListItem } from "@inspector/core/mcp";
|
||||
import {
|
||||
ListPaginationControls,
|
||||
type ListPaginationControlsProps,
|
||||
|
|
@ -41,6 +43,12 @@ export interface PromptControlsProps {
|
|||
* A failed list load, surfaced above the list instead of leaving the panel
|
||||
* empty (which reads as "this server has none") (#1953).
|
||||
*/
|
||||
/**
|
||||
* Entries the client dropped from this list because they failed the MCP
|
||||
* schema. Rendered as a warning above the list, which still shows the rest
|
||||
* (#1909).
|
||||
*/
|
||||
malformedListItems?: MalformedListItem[];
|
||||
loadError?: Error | null;
|
||||
/** Pagination controls (#1721). */
|
||||
pagination: ListPaginationControlsProps;
|
||||
|
|
@ -54,6 +62,7 @@ export function PromptControls({
|
|||
searchText = "",
|
||||
listChanged,
|
||||
onRefreshList,
|
||||
malformedListItems = [],
|
||||
loadError,
|
||||
pagination,
|
||||
onSearchChange,
|
||||
|
|
@ -83,6 +92,11 @@ export function PromptControls({
|
|||
/>
|
||||
<ListPaginationControls {...pagination} />
|
||||
<ListLoadError error={loadError} what="prompts" onRetry={onRefreshList} />
|
||||
<MalformedItemsWarning
|
||||
items={malformedListItems}
|
||||
method="prompts/list"
|
||||
what="prompts"
|
||||
/>
|
||||
<ListScroll viewportRef={viewportRef}>
|
||||
<Stack gap="xs">
|
||||
{filteredPrompts.map((prompt) => (
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import { isModernEra } from "../../elements/EraBadge/eraUtils";
|
|||
import { SubscriptionStreamBadge } from "../../elements/SubscriptionStreamBadge/SubscriptionStreamBadge";
|
||||
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
|
||||
import { ListLoadError } from "../../elements/ListLoadError/ListLoadError";
|
||||
import { MalformedItemsWarning } from "../../elements/MalformedItemsWarning/MalformedItemsWarning";
|
||||
import type { MalformedListItem } from "@inspector/core/mcp";
|
||||
import {
|
||||
ListPaginationControls,
|
||||
type ListPaginationControlsProps,
|
||||
|
|
@ -73,6 +75,12 @@ export interface ResourceControlsProps {
|
|||
* A failed list load, surfaced above the list instead of leaving the panel
|
||||
* empty (which reads as "this server has none") (#1953).
|
||||
*/
|
||||
/**
|
||||
* Entries the client dropped from `resources/list` or
|
||||
* `resources/templates/list` because they failed the MCP schema. Rendered as
|
||||
* a warning above the list, which still shows the rest (#1909).
|
||||
*/
|
||||
malformedListItems?: MalformedListItem[];
|
||||
loadError?: Error | null;
|
||||
/** Pagination controls for the Resources list (#1721). */
|
||||
pagination: ListPaginationControlsProps;
|
||||
|
|
@ -117,6 +125,7 @@ export function ResourceControls({
|
|||
openSections: controlledOpenSections,
|
||||
listChanged,
|
||||
onRefreshList,
|
||||
malformedListItems = [],
|
||||
loadError,
|
||||
pagination,
|
||||
onSearchChange,
|
||||
|
|
@ -248,6 +257,16 @@ export function ResourceControls({
|
|||
what="resources"
|
||||
onRetry={onRefreshList}
|
||||
/>
|
||||
<MalformedItemsWarning
|
||||
items={malformedListItems}
|
||||
method="resources/list"
|
||||
what="resources"
|
||||
/>
|
||||
<MalformedItemsWarning
|
||||
items={malformedListItems}
|
||||
method="resources/templates/list"
|
||||
what="resource templates"
|
||||
/>
|
||||
{/* Stays inline: Accordion is a compound, `multiple`-discriminated generic,
|
||||
so `.withProps({ multiple: true, ... })` loses its JSX call signature
|
||||
(same tooling limit as Box). */}
|
||||
|
|
|
|||
|
|
@ -1477,3 +1477,316 @@ describe("SchemaForm draft validity (#2020)", () => {
|
|||
expect(screen.getByText(/Not valid JSON/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Three separate reports of one defect: the JSON editor re-escaping the text
|
||||
// being typed. #1853 saw it while typing an array, #1856 on the Backspace that
|
||||
// first makes a valid array invalid, and #1885 on a nullable parameter whose
|
||||
// default `null` had to be edited in place. All three come from the same
|
||||
// mechanism — unparseable draft text stored back as the field's *value*, which
|
||||
// the next controlled render re-`JSON.stringify`d, adding a layer of quotes and
|
||||
// backslashes per keystroke.
|
||||
//
|
||||
// The mechanism was removed by the draft/value split in `SchemaJsonField`
|
||||
// (#1928/#2007) and the nullable-union collapse that keeps a `T | null` field
|
||||
// off this editor entirely (#1928). These lock each report's own reproduction
|
||||
// to it, since the fixes were made for differently-framed issues and nothing
|
||||
// otherwise pins the reported flows.
|
||||
describe("JSON editor escaping (#1853, #1856, #1885)", () => {
|
||||
function EscapingHarness({
|
||||
schema,
|
||||
initial = {},
|
||||
}: {
|
||||
schema: InspectorFormSchema;
|
||||
initial?: Record<string, unknown>;
|
||||
}) {
|
||||
const [values, setValues] = useState<Record<string, unknown>>(initial);
|
||||
return <SchemaForm schema={schema} values={values} onChange={setValues} />;
|
||||
}
|
||||
|
||||
// #1853: `batch_process_items`, typed character by character.
|
||||
it("types a string array through without escaping it (#1853)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
itemIds: {
|
||||
type: "array",
|
||||
title: "Item Ids",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
required: ["itemIds"],
|
||||
};
|
||||
renderWithMantine(<EscapingHarness schema={schema} />);
|
||||
|
||||
const jsonInput = screen.getByLabelText(/Item Ids/) as HTMLTextAreaElement;
|
||||
// `[` opens a userEvent keyboard descriptor, so it is escaped as `[[`.
|
||||
await user.type(jsonInput, '[["item-1","item-2"]');
|
||||
|
||||
expect(jsonInput.value).toBe('["item-1","item-2"]');
|
||||
expect(jsonInput.value).not.toContain("\\");
|
||||
});
|
||||
|
||||
// #1853 again, from the comment thread: the caret sitting *outside* the
|
||||
// quotes of a `""` value. One keystroke there made the draft invalid, which
|
||||
// is all it took — `""` + `a` rendered as `"\"\"a"`.
|
||||
it("keeps a keystroke typed outside a string's quotes literal (#1853)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
// No `type`, so the field lands on the JSON editor holding a string.
|
||||
properties: { note: { title: "Note" } },
|
||||
};
|
||||
renderWithMantine(
|
||||
<EscapingHarness schema={schema} initial={{ note: "" }} />,
|
||||
);
|
||||
|
||||
const jsonInput = screen.getByLabelText(/Note/) as HTMLTextAreaElement;
|
||||
await user.click(jsonInput);
|
||||
await user.keyboard("{End}a");
|
||||
|
||||
expect(jsonInput.value).toBe('""a');
|
||||
});
|
||||
|
||||
// #1856: `sum_numbers`, Backspace with the caret after the closing `]`.
|
||||
it("keeps the draft raw when Backspace invalidates an array (#1856)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
numbers: {
|
||||
type: "array",
|
||||
title: "Numbers",
|
||||
items: { type: "number" },
|
||||
},
|
||||
},
|
||||
required: ["numbers"],
|
||||
};
|
||||
renderWithMantine(
|
||||
<EscapingHarness schema={schema} initial={{ numbers: [1, 2] }} />,
|
||||
);
|
||||
|
||||
const jsonInput = screen.getByLabelText(/Numbers/) as HTMLTextAreaElement;
|
||||
await user.click(jsonInput);
|
||||
await user.keyboard("{End}{Backspace}");
|
||||
|
||||
// Exactly the seeded text minus its last character — the draft the reporter
|
||||
// expected, where the field instead showed a quoted, escaped string.
|
||||
expect(jsonInput.value).toBe("[\n 1,\n 2\n");
|
||||
|
||||
// Each further edit compounded the escaping, so keep going.
|
||||
await user.keyboard("{Backspace}{Backspace}");
|
||||
expect(jsonInput.value).not.toContain("\\");
|
||||
expect(jsonInput.value.startsWith('"')).toBe(false);
|
||||
|
||||
// And the draft is still live: closing it back up produces a real array.
|
||||
await user.keyboard("2]");
|
||||
expect(jsonInput.value).toBe("[\n 1,\n 2]");
|
||||
});
|
||||
|
||||
// #1885: FastMCP's `b: int | None = None`, which compiles to an `anyOf` with
|
||||
// a null branch and `default: null`. The reporter was editing the literal
|
||||
// `null` token in a raw JSON box; collapsing the union routes the field to a
|
||||
// real number input, so there is no JSON token to edit in the first place.
|
||||
it("edits a nullable integer as a number input, not a null token (#1885)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
a: { type: "integer", title: "A" },
|
||||
b: {
|
||||
title: "B",
|
||||
anyOf: [{ type: "integer" }, { type: "null" }],
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
required: ["a"],
|
||||
};
|
||||
// No seeded value: the `null` has to come from the schema's `default`,
|
||||
// which is where the reporter's did.
|
||||
renderWithMantine(<EscapingHarness schema={schema} />);
|
||||
|
||||
const b = screen.getByLabelText(/^B$/) as HTMLInputElement;
|
||||
expect(b.tagName).toBe("INPUT");
|
||||
// `null` is rendered as "no value", not as four editable characters.
|
||||
expect(b.value).toBe("");
|
||||
|
||||
await user.click(b);
|
||||
await user.keyboard("42{Backspace}");
|
||||
expect(b.value).toBe("4");
|
||||
expect(b.value).not.toContain("\\");
|
||||
});
|
||||
|
||||
// The same nullable-with-`default: null` shape on a field the collapse still
|
||||
// sends to the JSON editor (an array union). Backspacing the `null` token
|
||||
// there is the exact keystroke sequence from #1885's recording.
|
||||
it("backspaces a null default in the JSON editor without escaping (#1885)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
cfg: {
|
||||
title: "Cfg",
|
||||
anyOf: [{ type: "array" }, { type: "null" }],
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
// Again unseeded, so the editor's opening `null` is the schema default
|
||||
// being resolved — the state the reporter's recording starts from.
|
||||
renderWithMantine(<EscapingHarness schema={schema} />);
|
||||
|
||||
const jsonInput = screen.getByLabelText(/Cfg/) as HTMLTextAreaElement;
|
||||
expect(jsonInput.value).toBe("null");
|
||||
|
||||
await user.click(jsonInput);
|
||||
await user.keyboard("{End}{Backspace}");
|
||||
expect(jsonInput.value).toBe("nul");
|
||||
|
||||
await user.keyboard("{Backspace}{Backspace}{Backspace}");
|
||||
expect(jsonInput.value).toBe("");
|
||||
expect(jsonInput.value).not.toContain("\\");
|
||||
});
|
||||
});
|
||||
|
||||
// A schema `default` is what a field *opens* with, not a value re-imposed on
|
||||
// every unsendable draft. Unsendable text reports `undefined` by design, and
|
||||
// `resolveValue` used to turn that straight back into the default — a change
|
||||
// from the previous value, so the draft/value re-sync rewrote the box and
|
||||
// reverted the keystroke.
|
||||
//
|
||||
// The two halves differed in how reachable they were, which the tests below
|
||||
// mirror deliberately:
|
||||
//
|
||||
// - **The number field reverted in the app.** Numbers compare by value, so
|
||||
// clearing a defaulted box (value `3` → resolved default `30`) always fired
|
||||
// the re-sync and refilled itself.
|
||||
// - **The JSON field was latent.** `collectSchemaDefaults` assigns
|
||||
// `fieldSchema.default` itself, so a field seeded from the schema holds the
|
||||
// *same reference* the substitution returns and nothing fires. It needs a
|
||||
// structurally-equal but distinct value object to become observable — which
|
||||
// is what parsed wire/deep-link values and rebuilt nested-object defaults
|
||||
// produce, and what these tests construct.
|
||||
describe("SchemaForm defaulted fields (#2026)", () => {
|
||||
function DefaultHarness({
|
||||
schema,
|
||||
initial = {},
|
||||
}: {
|
||||
schema: InspectorFormSchema;
|
||||
initial?: Record<string, unknown>;
|
||||
}) {
|
||||
const [values, setValues] = useState<Record<string, unknown>>(initial);
|
||||
return <SchemaForm schema={schema} values={values} onChange={setValues} />;
|
||||
}
|
||||
|
||||
const arrayWithDefault: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: { tags: { type: "array", title: "Tags", default: ["a"] } },
|
||||
};
|
||||
|
||||
it("opens the JSON editor on the schema default", () => {
|
||||
renderWithMantine(<DefaultHarness schema={arrayWithDefault} />);
|
||||
expect((screen.getByLabelText(/Tags/) as HTMLTextAreaElement).value).toBe(
|
||||
'[\n "a"\n]',
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a keystroke typed into a defaulted JSON field", async () => {
|
||||
const user = userEvent.setup();
|
||||
// Seeded with a value that is equal to the schema default but is not the
|
||||
// same object — how values parsed off the wire or out of a deep link
|
||||
// arrive. Sharing the reference (what `collectSchemaDefaults` produces) is
|
||||
// what keeps this latent rather than what makes it safe.
|
||||
renderWithMantine(
|
||||
<DefaultHarness schema={arrayWithDefault} initial={{ tags: ["a"] }} />,
|
||||
);
|
||||
|
||||
const jsonInput = screen.getByLabelText(/Tags/) as HTMLTextAreaElement;
|
||||
await user.click(jsonInput);
|
||||
await user.keyboard("{End}x");
|
||||
|
||||
// The invalid draft is the user's, not the default reasserting itself.
|
||||
expect(jsonInput.value).toBe('[\n "a"\n]x');
|
||||
});
|
||||
|
||||
it("lets a defaulted JSON field be edited to a new value", async () => {
|
||||
const user = userEvent.setup();
|
||||
// Distinct-object seed again, for the reason above.
|
||||
renderWithMantine(
|
||||
<DefaultHarness schema={arrayWithDefault} initial={{ tags: ["a"] }} />,
|
||||
);
|
||||
|
||||
const jsonInput = screen.getByLabelText(/Tags/) as HTMLTextAreaElement;
|
||||
await user.clear(jsonInput);
|
||||
await user.type(jsonInput, '[["b"]');
|
||||
|
||||
expect(jsonInput.value).toBe('["b"]');
|
||||
expect(screen.queryByText(/Not valid JSON/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lets a defaulted number field be emptied", async () => {
|
||||
const user = userEvent.setup();
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: { n: { type: "integer", title: "N", default: 30 } },
|
||||
};
|
||||
renderWithMantine(<DefaultHarness schema={schema} initial={{ n: 30 }} />);
|
||||
|
||||
const n = screen.getByLabelText(/N/) as HTMLInputElement;
|
||||
expect(n.value).toBe("30");
|
||||
|
||||
await user.click(n);
|
||||
await user.keyboard("{End}{Backspace}{Backspace}");
|
||||
|
||||
// The box stays empty instead of refilling itself with the default.
|
||||
expect(n.value).toBe("");
|
||||
});
|
||||
|
||||
// An explicit `null` is a value, not an absent one, so a non-null default
|
||||
// must not be substituted for it. Note the field itself never emits `null` —
|
||||
// clearing it reports `undefined` (pinned by "passes undefined to onChange
|
||||
// when a number field is cleared") — so this arrives from parent state: a
|
||||
// value received from the server, restored from a deep link, or written by a
|
||||
// caller for a nullable schema.
|
||||
it("shows an explicit null as empty, not as the default", () => {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
n: {
|
||||
title: "N",
|
||||
anyOf: [{ type: "integer" }, { type: "null" }],
|
||||
default: 30,
|
||||
},
|
||||
},
|
||||
};
|
||||
renderWithMantine(<DefaultHarness schema={schema} initial={{ n: null }} />);
|
||||
|
||||
expect((screen.getByLabelText(/^N$/) as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
|
||||
it("still re-syncs a defaulted field when the value changes externally", async () => {
|
||||
function ExternalHarness() {
|
||||
const [values, setValues] = useState<Record<string, unknown>>({
|
||||
tags: ["a"],
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<SchemaForm
|
||||
schema={arrayWithDefault}
|
||||
values={values}
|
||||
onChange={setValues}
|
||||
/>
|
||||
<button type="button" onClick={() => setValues({ tags: ["z"] })}>
|
||||
load example
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ExternalHarness />);
|
||||
|
||||
const jsonInput = screen.getByLabelText(/Tags/) as HTMLTextAreaElement;
|
||||
await user.click(screen.getByRole("button", { name: "load example" }));
|
||||
expect(jsonInput.value).toBe('[\n "z"\n]');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -207,7 +207,14 @@ interface SchemaJsonFieldProps {
|
|||
description?: string;
|
||||
withAsterisk: boolean;
|
||||
disabled: boolean;
|
||||
/**
|
||||
* The field's own entry in the form's values — **not** run through
|
||||
* `resolveValue`. A draft-holding field is handed the raw value and the
|
||||
* schema default separately; see `defaultValue` for why.
|
||||
*/
|
||||
value: unknown;
|
||||
/** The schema `default`, used to seed the draft only. See `defaultValue`. */
|
||||
defaultValue: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
onValidityChange: DraftValidityReporter;
|
||||
}
|
||||
|
|
@ -244,18 +251,39 @@ interface SchemaJsonFieldProps {
|
|||
function SchemaJsonField({
|
||||
fieldName,
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
onValidityChange,
|
||||
...inputProps
|
||||
}: SchemaJsonFieldProps) {
|
||||
const [draft, setDraft] = useState(() =>
|
||||
value === undefined ? "" : serializeJson(value),
|
||||
);
|
||||
const [draft, setDraft] = useState(() => {
|
||||
const initial = value === undefined ? defaultValue : value;
|
||||
return initial === undefined ? "" : serializeJson(initial);
|
||||
});
|
||||
|
||||
// Re-sync only when the parent's value genuinely diverges from what the draft
|
||||
// parses to, which leaves an external reset (a cleared form, a loaded
|
||||
// example) working while an in-progress `[` — whose parse is `undefined`,
|
||||
// matching the `undefined` we just emitted — is left alone.
|
||||
//
|
||||
// The value compared here is the field's *raw* entry, never `resolveValue`'s
|
||||
// default substitution (#2026). Unsendable text reports `undefined`, and a
|
||||
// defaulted field resolves that straight back to its default — a change from
|
||||
// the previous value, so comparing against the resolved one would rewrite the
|
||||
// draft to the default and revert the keystroke. Almost every edit passes
|
||||
// through an unsendable state, so a defaulted array or object argument would
|
||||
// be uneditable.
|
||||
//
|
||||
// Would be, not was: this is latent under today's seeding, where
|
||||
// `collectSchemaDefaults` assigns `fieldSchema.default` itself, so the
|
||||
// substitution returns the *same reference* and `useValueChange` never fires.
|
||||
// It goes live as soon as the value is a structurally-equal but distinct
|
||||
// object — parsed from the wire or a deep link, or a nested-object default,
|
||||
// which `collectSchemaDefaults` rebuilds per call. `SchemaNumberInput` has no
|
||||
// such accidental protection, since a number compares by value.
|
||||
//
|
||||
// Either way the default belongs to what the field *opens* with, seeded
|
||||
// above, not to what is re-imposed while it is being typed into.
|
||||
useValueChange(value, (next) => {
|
||||
if (!isSameJson(parseJsonDraft(draft), next)) {
|
||||
setDraft(next === undefined ? "" : serializeJson(next));
|
||||
|
|
@ -309,7 +337,17 @@ interface SchemaNumberInputProps {
|
|||
description?: string;
|
||||
withAsterisk: boolean;
|
||||
disabled: boolean;
|
||||
value: number | undefined;
|
||||
/**
|
||||
* Raw, not `resolveValue`-substituted — see `SchemaJsonFieldProps.value`.
|
||||
*
|
||||
* `null` is admitted alongside `undefined` because a nullable number schema
|
||||
* produces it — by way of parent state, never by clearing the box — and the
|
||||
* two are not interchangeable here: `undefined` means "no value supplied"
|
||||
* and takes `defaultValue`, while `null` is a value and displays empty.
|
||||
*/
|
||||
value: number | null | undefined;
|
||||
/** The schema `default`, used to seed the draft only (#2026). May be `null`. */
|
||||
defaultValue: number | null | undefined;
|
||||
min?: number;
|
||||
max?: number;
|
||||
allowDecimal: boolean;
|
||||
|
|
@ -343,12 +381,29 @@ interface SchemaNumberInputProps {
|
|||
function SchemaNumberInput({
|
||||
fieldName,
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
onValidityChange,
|
||||
...inputProps
|
||||
}: SchemaNumberInputProps) {
|
||||
const [draft, setDraft] = useState<string | number>(value ?? "");
|
||||
// `undefined` means "no value supplied", which is what the default is for.
|
||||
// An explicit `null` is a value, so it must not be overwritten by a non-null
|
||||
// default; it displays as the empty box `resolveValue` produced before.
|
||||
// Hence the `undefined` test rather than `??`, matching `SchemaJsonField`.
|
||||
//
|
||||
// Clearing the box does *not* produce that `null` — `toNumericValue("")`
|
||||
// reports `undefined`, which is the behavior the "passes undefined to
|
||||
// onChange when a number field is cleared" test pins. A `null` reaches this
|
||||
// field only from parent state: a value received from the server, restored
|
||||
// from a deep link, or written by a caller for a nullable schema.
|
||||
const [draft, setDraft] = useState<string | number>(
|
||||
(value === undefined ? defaultValue : value) ?? "",
|
||||
);
|
||||
|
||||
// Compared against the raw value, never the default-substituted one, for the
|
||||
// reason spelled out on `SchemaJsonField` (#2026): clearing this box reports
|
||||
// no value, which a defaulted field resolved straight back to its default —
|
||||
// so the box refilled itself and the argument could not be emptied.
|
||||
useValueChange(value, (next) => {
|
||||
if (!Object.is(toNumericValue(draft), next)) {
|
||||
setDraft(next ?? "");
|
||||
|
|
@ -584,7 +639,12 @@ export function SchemaForm({
|
|||
description={description}
|
||||
withAsterisk={isRequired}
|
||||
disabled={disabled}
|
||||
value={rawValue as number | undefined}
|
||||
// Raw and default kept apart, not pre-resolved (#2026). Both admit
|
||||
// `null`, which a nullable number schema really produces.
|
||||
value={values[fieldName] as number | null | undefined}
|
||||
defaultValue={
|
||||
getDefaultValue(fieldSchema) as number | null | undefined
|
||||
}
|
||||
min={fieldSchema.minimum}
|
||||
max={fieldSchema.maximum}
|
||||
// An `integer` field rejects the decimal point outright rather than
|
||||
|
|
@ -692,7 +752,9 @@ export function SchemaForm({
|
|||
description={description}
|
||||
withAsterisk={isRequired}
|
||||
disabled={disabled}
|
||||
value={rawValue}
|
||||
// Raw and default kept apart, not pre-resolved (#2026).
|
||||
value={values[fieldName]}
|
||||
defaultValue={getDefaultValue(fieldSchema)}
|
||||
onChange={(val) => handleFieldChange(fieldName, val)}
|
||||
onValidityChange={reportFieldValidity}
|
||||
/>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue