Merge remote-tracking branch 'origin/v2/main' into 1579-wave-1
# Conflicts: # clients/cli/package-lock.json # clients/cli/package.json # clients/web/src/App.tsx # clients/web/src/lib/environmentFactory.test.ts # clients/web/src/lib/environmentFactory.ts # clients/web/src/lib/remoteOAuthStorage.test.ts # clients/web/src/lib/remoteOAuthStorage.ts # clients/web/src/test/core/auth/connection-state.test.ts # clients/web/src/test/core/auth/providers.test.ts # clients/web/src/test/core/auth/storage-browser.test.ts # clients/web/src/test/core/auth/storage-remote.test.ts # clients/web/src/test/core/mcp/oauthManager.test.ts # clients/web/src/test/integration/auth/node/storage.test.ts # clients/web/src/test/integration/storage/adapters.test.ts # clients/web/src/utils/clearServerOAuthState.test.ts # clients/web/src/utils/clearServerOAuthState.ts # core/auth/connection-state.ts # core/auth/oauth-storage.ts # core/auth/providers.ts # core/auth/storage.ts # core/auth/store.ts # core/storage/adapters/remote-storage.ts
This commit is contained in:
commit
32a3610737
118 changed files with 5046 additions and 3271 deletions
|
|
@ -22,7 +22,7 @@ inspector/
|
||||||
│ ├── tui/ # TUI client (Ink + React, tsup bundle)
|
│ ├── tui/ # TUI client (Ink + React, tsup bundle)
|
||||||
│ ├── launcher/ # Shared launcher (relative imports into sibling build/ outputs)
|
│ ├── launcher/ # Shared launcher (relative imports into sibling build/ outputs)
|
||||||
├── core/ # Shared core code (no package.json — consumed via the `@inspector/core` vite alias)
|
├── core/ # Shared core code (no package.json — consumed via the `@inspector/core` vite alias)
|
||||||
│ ├── auth/ # OAuth: state machine, providers, discovery, storage;
|
│ ├── auth/ # OAuth: providers, discovery, OAuthStorage + persist backends;
|
||||||
│ │ # mid-session recovery (challenge.ts WWW-Authenticate
|
│ │ # mid-session recovery (challenge.ts WWW-Authenticate
|
||||||
│ │ # parsing, scopes.ts SEP-2350 scope union, oauthUx.ts
|
│ │ # parsing, scopes.ts SEP-2350 scope union, oauthUx.ts
|
||||||
│ │ # shared copy, mcpAuth.ts force-reauthorization)
|
│ │ # shared copy, mcpAuth.ts force-reauthorization)
|
||||||
|
|
@ -41,9 +41,9 @@ inspector/
|
||||||
│ │ ├── node/ # Node stdio transport factory
|
│ │ ├── node/ # Node stdio transport factory
|
||||||
│ │ ├── remote/ # Browser HTTP/SSE transport + remote logger/fetch
|
│ │ ├── remote/ # Browser HTTP/SSE transport + remote logger/fetch
|
||||||
│ │ │ └── node/ # Hono-based remote server backend (used by remote/ above)
|
│ │ │ └── node/ # Hono-based remote server backend (used by remote/ above)
|
||||||
│ │ └── state/ # Zustand-style state stores consumed by core/react/
|
│ │ └── state/ # InspectorClient state stores consumed by core/react/
|
||||||
│ ├── react/ # React hooks over the state stores
|
│ ├── react/ # React hooks over the state stores
|
||||||
│ └── storage/ # File and remote storage adapters (Zustand middleware)
|
│ └── storage/ # File I/O helpers (store-io.ts) used by OAuth persist backends
|
||||||
├── test-servers/ # Composable MCP test servers + fixtures used by integration tests.
|
├── test-servers/ # Composable MCP test servers + fixtures used by integration tests.
|
||||||
│ ├── src/ # TypeScript sources.
|
│ ├── src/ # TypeScript sources.
|
||||||
│ ├── build/ # Built JS (gitignored). Produced by `npm run test-servers:build`
|
│ ├── build/ # Built JS (gitignored). Produced by `npm run test-servers:build`
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,39 @@ import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
||||||
import { MutableRedirectUrlProvider } from "@inspector/core/auth/index.js";
|
import { MutableRedirectUrlProvider } from "@inspector/core/auth/index.js";
|
||||||
import * as runnerInteractive from "@inspector/core/auth/node/runner-interactive-oauth.js";
|
import * as runnerInteractive from "@inspector/core/auth/node/runner-interactive-oauth.js";
|
||||||
import {
|
import {
|
||||||
|
connectInspectorWithOAuth,
|
||||||
handleCliAuthRecoveryRequired,
|
handleCliAuthRecoveryRequired,
|
||||||
isStandardOAuthStepUp,
|
isStandardOAuthStepUp,
|
||||||
runCliInteractiveOAuth,
|
runCliInteractiveOAuth,
|
||||||
withCliAuthRecoveryRetry,
|
withCliAuthRecoveryRetry,
|
||||||
} from "../src/cliOAuth.js";
|
} from "../src/cliOAuth.js";
|
||||||
|
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
|
||||||
|
|
||||||
|
// `confirmStepUpFromStdin` (the default step-up confirmer) reads a line from
|
||||||
|
// stdin via node:readline/promises. Mock the module so the default path can be
|
||||||
|
// exercised deterministically without real TTY input.
|
||||||
|
const { mockQuestion, mockClose } = vi.hoisted(() => ({
|
||||||
|
mockQuestion: vi.fn(),
|
||||||
|
mockClose: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("node:readline/promises", () => ({
|
||||||
|
createInterface: vi.fn(() => ({
|
||||||
|
question: mockQuestion,
|
||||||
|
close: mockClose,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const CALLBACK_URL_CONFIG = {
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: 6276,
|
||||||
|
pathname: "/oauth/callback",
|
||||||
|
};
|
||||||
|
|
||||||
describe("cliOAuth", () => {
|
describe("cliOAuth", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
mockQuestion.mockReset();
|
||||||
|
mockClose.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isStandardOAuthStepUp", () => {
|
describe("isStandardOAuthStepUp", () => {
|
||||||
|
|
@ -227,4 +251,207 @@ describe("cliOAuth", () => {
|
||||||
expect(result).toBe("ok");
|
expect(result).toBe("ok");
|
||||||
expect(fn).toHaveBeenCalledTimes(2);
|
expect(fn).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("confirmStepUpFromStdin (default stdin confirmer)", () => {
|
||||||
|
const standardStepUpError = () =>
|
||||||
|
new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), {
|
||||||
|
reason: "insufficient_scope",
|
||||||
|
requiredScopes: ["weather:read"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const clientNeedingStepUp = () => ({
|
||||||
|
authenticate: vi.fn(),
|
||||||
|
beginInteractiveAuthorization: vi.fn(),
|
||||||
|
completeOAuthFlow: vi.fn(),
|
||||||
|
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
it("proceeds with OAuth when the user answers y (no confirmStepUp arg)", async () => {
|
||||||
|
mockQuestion.mockResolvedValue("y");
|
||||||
|
const runSpy = vi
|
||||||
|
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
|
||||||
|
.mockResolvedValue({ kind: "success" });
|
||||||
|
|
||||||
|
// Omitting the confirmStepUp argument exercises the default
|
||||||
|
// confirmStepUpFromStdin, which reads from the mocked readline interface.
|
||||||
|
await handleCliAuthRecoveryRequired(
|
||||||
|
clientNeedingStepUp(),
|
||||||
|
standardStepUpError(),
|
||||||
|
new MutableRedirectUrlProvider(),
|
||||||
|
CALLBACK_URL_CONFIG,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockQuestion).toHaveBeenCalled();
|
||||||
|
expect(mockClose).toHaveBeenCalled();
|
||||||
|
expect(runSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a whitespace-padded, upper-case 'YES'", async () => {
|
||||||
|
mockQuestion.mockResolvedValue(" YES ");
|
||||||
|
const runSpy = vi
|
||||||
|
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
|
||||||
|
.mockResolvedValue({ kind: "success" });
|
||||||
|
|
||||||
|
await handleCliAuthRecoveryRequired(
|
||||||
|
clientNeedingStepUp(),
|
||||||
|
standardStepUpError(),
|
||||||
|
new MutableRedirectUrlProvider(),
|
||||||
|
CALLBACK_URL_CONFIG,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(runSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declines (throws) when the user answers n", async () => {
|
||||||
|
mockQuestion.mockResolvedValue("n");
|
||||||
|
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handleCliAuthRecoveryRequired(
|
||||||
|
clientNeedingStepUp(),
|
||||||
|
standardStepUpError(),
|
||||||
|
new MutableRedirectUrlProvider(),
|
||||||
|
CALLBACK_URL_CONFIG,
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
).rejects.toThrow("Step-up authorization declined.");
|
||||||
|
|
||||||
|
expect(mockClose).toHaveBeenCalled();
|
||||||
|
expect(runSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("connectInspectorWithOAuth recovery branch", () => {
|
||||||
|
const oauthServerConfig = {
|
||||||
|
type: "streamable-http",
|
||||||
|
url: "https://as.example/mcp",
|
||||||
|
} as MCPServerConfig;
|
||||||
|
|
||||||
|
it("resumes without re-auth when storage already satisfies the challenge", async () => {
|
||||||
|
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||||
|
const connect = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(
|
||||||
|
new AuthRecoveryRequiredError(
|
||||||
|
new URL("https://as.example/authorize"),
|
||||||
|
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(undefined);
|
||||||
|
const client = {
|
||||||
|
connect,
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
await connectInspectorWithOAuth(
|
||||||
|
client,
|
||||||
|
oauthServerConfig,
|
||||||
|
new MutableRedirectUrlProvider(),
|
||||||
|
CALLBACK_URL_CONFIG,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(connect).toHaveBeenCalledTimes(2);
|
||||||
|
expect(runSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs interactive recovery when storage does not satisfy the challenge", async () => {
|
||||||
|
const runSpy = vi
|
||||||
|
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
|
||||||
|
.mockResolvedValue({ kind: "success" });
|
||||||
|
const connect = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(
|
||||||
|
new AuthRecoveryRequiredError(
|
||||||
|
new URL("https://as.example/authorize"),
|
||||||
|
{ reason: "token_expired" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(undefined);
|
||||||
|
const client = {
|
||||||
|
connect,
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
await connectInspectorWithOAuth(
|
||||||
|
client,
|
||||||
|
oauthServerConfig,
|
||||||
|
new MutableRedirectUrlProvider(),
|
||||||
|
CALLBACK_URL_CONFIG,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(connect).toHaveBeenCalledTimes(2);
|
||||||
|
expect(runSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs interactive OAuth on a plain unauthorized error (disconnect failure is swallowed)", async () => {
|
||||||
|
const runSpy = vi
|
||||||
|
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
|
||||||
|
.mockResolvedValue({ kind: "success" });
|
||||||
|
const connect = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error("Connection failed for server (401)"))
|
||||||
|
.mockResolvedValueOnce(undefined);
|
||||||
|
// A rejecting disconnect exercises the `.catch(() => {})` guard.
|
||||||
|
const client = {
|
||||||
|
connect,
|
||||||
|
disconnect: vi.fn().mockRejectedValue(new Error("disconnect failed")),
|
||||||
|
checkAuthChallengeSatisfied: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await connectInspectorWithOAuth(
|
||||||
|
client,
|
||||||
|
oauthServerConfig,
|
||||||
|
new MutableRedirectUrlProvider(),
|
||||||
|
CALLBACK_URL_CONFIG,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(client.disconnect).toHaveBeenCalled();
|
||||||
|
expect(runSpy).toHaveBeenCalled();
|
||||||
|
expect(connect).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rethrows a non-OAuth error unchanged", async () => {
|
||||||
|
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||||
|
const connect = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new Error("some unrelated failure"));
|
||||||
|
const client = {
|
||||||
|
connect,
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
checkAuthChallengeSatisfied: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
connectInspectorWithOAuth(
|
||||||
|
client,
|
||||||
|
oauthServerConfig,
|
||||||
|
new MutableRedirectUrlProvider(),
|
||||||
|
CALLBACK_URL_CONFIG,
|
||||||
|
),
|
||||||
|
).rejects.toThrow("some unrelated failure");
|
||||||
|
expect(runSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rethrows when the server config is not OAuth-capable", async () => {
|
||||||
|
const connect = vi.fn().mockRejectedValue(new Error("nope (401)"));
|
||||||
|
const client = {
|
||||||
|
connect,
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
checkAuthChallengeSatisfied: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
connectInspectorWithOAuth(
|
||||||
|
client,
|
||||||
|
{ type: "stdio", command: "x" } as MCPServerConfig,
|
||||||
|
new MutableRedirectUrlProvider(),
|
||||||
|
CALLBACK_URL_CONFIG,
|
||||||
|
),
|
||||||
|
).rejects.toThrow("nope (401)");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
32
clients/cli/package-lock.json
generated
32
clients/cli/package-lock.json
generated
|
|
@ -16,8 +16,7 @@
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"pino": "^9.14.0",
|
"pino": "^9.14.0",
|
||||||
"undici": "^8.5.0",
|
"undici": "^8.5.0",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6"
|
||||||
"zustand": "^5.0.13"
|
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"mcp-inspector-cli": "build/index.js"
|
"mcp-inspector-cli": "build/index.js"
|
||||||
|
|
@ -5685,35 +5684,6 @@
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.28 || ^4"
|
"zod": "^3.25.28 || ^4"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"node_modules/zustand": {
|
|
||||||
"version": "5.0.14",
|
|
||||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
|
|
||||||
"integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.20.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": ">=18.0.0",
|
|
||||||
"immer": ">=9.0.6",
|
|
||||||
"react": ">=18.0.0",
|
|
||||||
"use-sync-external-store": ">=1.2.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"immer": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"use-sync-external-store": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,7 @@
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"pino": "^9.14.0",
|
"pino": "^9.14.0",
|
||||||
"undici": "^8.5.0",
|
"undici": "^8.5.0",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6"
|
||||||
"zustand": "^5.0.13"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ export default defineConfig({
|
||||||
platform: 'node',
|
platform: 'node',
|
||||||
// Bundle core source; leave npm deps external.
|
// Bundle core source; leave npm deps external.
|
||||||
noExternal: [/^@inspector\/core/],
|
noExternal: [/^@inspector\/core/],
|
||||||
external: ['@napi-rs/keyring', '@modelcontextprotocol/sdk', 'commander', 'pino', 'zustand'],
|
external: ['@napi-rs/keyring', '@modelcontextprotocol/sdk', 'commander', 'pino'],
|
||||||
esbuildOptions(options) {
|
esbuildOptions(options) {
|
||||||
options.alias = {
|
options.alias = {
|
||||||
'@inspector/core': path.join(repoRoot, 'core'),
|
'@inspector/core': path.join(repoRoot, 'core'),
|
||||||
|
|
|
||||||
988
clients/launcher/package-lock.json
generated
988
clients/launcher/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -75,13 +75,44 @@ const h = vi.hoisted(() => {
|
||||||
start: callbackStart,
|
start: callbackStart,
|
||||||
stop: callbackStop,
|
stop: callbackStop,
|
||||||
}));
|
}));
|
||||||
|
// Registry of the auth-lifecycle listeners App registers per client, so a test
|
||||||
|
// can fire authChallengeAmbient / authChallengeRecovered / authChallengeInteractive
|
||||||
|
// / oauthError against whichever FakeClient instance App built.
|
||||||
|
// Each entry records which FakeClient registered the handler so a test can
|
||||||
|
// fire an event for a single client (`fireClientEventFor`) — needed to truly
|
||||||
|
// assert the per-server `selectedServerRef.current !== serverName` guards,
|
||||||
|
// not merely execute them. `fireClientEvent` still fires every client's
|
||||||
|
// handler for the event (the common single-server case).
|
||||||
|
type EventEntry = { client: unknown; fn: (event: unknown) => void };
|
||||||
|
const clientEvents = new Map<string, Set<EventEntry>>();
|
||||||
|
const clientInstances: Array<{ cfg?: { type?: string; url?: string } }> = [];
|
||||||
|
const fireClientEvent = (event: string, detail?: unknown) => {
|
||||||
|
clientEvents.get(event)?.forEach((e) => e.fn({ detail }));
|
||||||
|
};
|
||||||
|
const fireClientEventFor = (
|
||||||
|
client: unknown,
|
||||||
|
event: string,
|
||||||
|
detail?: unknown,
|
||||||
|
) => {
|
||||||
|
clientEvents.get(event)?.forEach((e) => {
|
||||||
|
if (e.client === client) e.fn({ detail });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// Optional per-test override for runRunnerInteractiveOAuth. Left null, the real
|
||||||
|
// runner runs (existing tests drive it via the captured callback opts); set it
|
||||||
|
// to return a specific { kind } to deterministically exercise the result
|
||||||
|
// branches without steering the real callback flow.
|
||||||
|
const runner: {
|
||||||
|
override: null | ((opts: unknown) => Promise<unknown>);
|
||||||
|
} = { override: null };
|
||||||
class FakeManager {
|
class FakeManager {
|
||||||
destroy = vi.fn();
|
destroy = vi.fn();
|
||||||
}
|
}
|
||||||
class FakeClient {
|
class FakeClient {
|
||||||
cfg: { type?: string } | undefined;
|
cfg: { type?: string; url?: string } | undefined;
|
||||||
constructor(config?: { type?: string }) {
|
constructor(config?: { type?: string; url?: string }) {
|
||||||
this.cfg = config;
|
this.cfg = config;
|
||||||
|
clientInstances.push(this);
|
||||||
}
|
}
|
||||||
// Derive the transport type from the server config the client was built
|
// Derive the transport type from the server config the client was built
|
||||||
// with (config.type aligns with the serverType union) so per-server gating
|
// with (config.type aligns with the serverType union) so per-server gating
|
||||||
|
|
@ -106,8 +137,22 @@ const h = vi.hoisted(() => {
|
||||||
readResource = vi.fn(async () => ({
|
readResource = vi.fn(async () => ({
|
||||||
result: { contents: [{ uri: "file://x", text: "hello" }] },
|
result: { contents: [{ uri: "file://x", text: "hello" }] },
|
||||||
}));
|
}));
|
||||||
addEventListener = vi.fn();
|
addEventListener = vi.fn((event: string, fn: (event: unknown) => void) => {
|
||||||
removeEventListener = vi.fn();
|
if (!clientEvents.has(event)) clientEvents.set(event, new Set());
|
||||||
|
clientEvents.get(event)!.add({ client: this, fn });
|
||||||
|
});
|
||||||
|
removeEventListener = vi.fn(
|
||||||
|
(event: string, fn: (event: unknown) => void) => {
|
||||||
|
const set = clientEvents.get(event);
|
||||||
|
if (!set) return;
|
||||||
|
for (const entry of set) {
|
||||||
|
if (entry.fn === fn) {
|
||||||
|
set.delete(entry);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
// Reject so the unmount cleanup's `.catch(() => {})` arrow is exercised.
|
// Reject so the unmount cleanup's `.catch(() => {})` arrow is exercised.
|
||||||
disconnect = vi.fn().mockRejectedValue(new Error("cleanup disconnect"));
|
disconnect = vi.fn().mockRejectedValue(new Error("cleanup disconnect"));
|
||||||
}
|
}
|
||||||
|
|
@ -121,6 +166,11 @@ const h = vi.hoisted(() => {
|
||||||
createOAuthCallbackServer,
|
createOAuthCallbackServer,
|
||||||
callbackStart,
|
callbackStart,
|
||||||
callbackStop,
|
callbackStop,
|
||||||
|
clientEvents,
|
||||||
|
clientInstances,
|
||||||
|
fireClientEvent,
|
||||||
|
fireClientEventFor,
|
||||||
|
runner,
|
||||||
FakeManager,
|
FakeManager,
|
||||||
FakeClient,
|
FakeClient,
|
||||||
useInspectorClient: vi.fn(() => ({
|
useInspectorClient: vi.fn(() => ({
|
||||||
|
|
@ -200,6 +250,12 @@ vi.mock("@inspector/core/auth/node/index.js", async (importOriginal) => {
|
||||||
...actual,
|
...actual,
|
||||||
createOAuthCallbackServer: h.createOAuthCallbackServer,
|
createOAuthCallbackServer: h.createOAuthCallbackServer,
|
||||||
NodeOAuthStorage: class {},
|
NodeOAuthStorage: class {},
|
||||||
|
runRunnerInteractiveOAuth: (opts: unknown) =>
|
||||||
|
h.runner.override
|
||||||
|
? h.runner.override(opts)
|
||||||
|
: actual.runRunnerInteractiveOAuth(
|
||||||
|
opts as Parameters<typeof actual.runRunnerInteractiveOAuth>[0],
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
vi.mock("../src/utils/openUrl.js", () => ({
|
vi.mock("../src/utils/openUrl.js", () => ({
|
||||||
|
|
@ -212,6 +268,7 @@ import {
|
||||||
AuthRecoveryRequiredError,
|
AuthRecoveryRequiredError,
|
||||||
EMA_STEP_UP_PENDING_URL,
|
EMA_STEP_UP_PENDING_URL,
|
||||||
} from "@inspector/core/auth/challenge.js";
|
} from "@inspector/core/auth/challenge.js";
|
||||||
|
import { EmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js";
|
||||||
|
|
||||||
const tick = () => new Promise((r) => setTimeout(r, 25));
|
const tick = () => new Promise((r) => setTimeout(r, 25));
|
||||||
const callbackUrlConfig = { hostname: "127.0.0.1", port: 0, pathname: "/cb" };
|
const callbackUrlConfig = { hostname: "127.0.0.1", port: 0, pathname: "/cb" };
|
||||||
|
|
@ -272,6 +329,16 @@ function oneEmaHttp(): Record<string, TuiServer> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Two OAuth-capable http servers (first auto-selected). Drives the per-server
|
||||||
|
// auth-event guards (events from the non-selected server return early) and the
|
||||||
|
// "a step-up is already pending for another server" branch.
|
||||||
|
function twoHttp(): Record<string, TuiServer> {
|
||||||
|
return {
|
||||||
|
web: { config: { type: "streamable-http", url: "http://a" } } as never,
|
||||||
|
api: { config: { type: "streamable-http", url: "http://b" } } as never,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Mixed catalog: an OAuth-capable http server first (auto-selected) followed by
|
// Mixed catalog: an OAuth-capable http server first (auto-selected) followed by
|
||||||
// a stdio server <20> drives per-server tab gating + the tab-switch-away effects.
|
// a stdio server <20> drives per-server tab gating + the tab-switch-away effects.
|
||||||
function httpThenStdio(): Record<string, TuiServer> {
|
function httpThenStdio(): Record<string, TuiServer> {
|
||||||
|
|
@ -497,6 +564,9 @@ beforeEach(() => {
|
||||||
h.cb.opts = null;
|
h.cb.opts = null;
|
||||||
h.callbackStart.mockClear();
|
h.callbackStart.mockClear();
|
||||||
h.callbackStop.mockClear();
|
h.callbackStop.mockClear();
|
||||||
|
h.clientEvents.clear();
|
||||||
|
h.clientInstances.length = 0;
|
||||||
|
h.runner.override = null;
|
||||||
h.clientSpies.authenticate.mockReset();
|
h.clientSpies.authenticate.mockReset();
|
||||||
h.clientSpies.authenticate.mockResolvedValue("https://auth.example/start");
|
h.clientSpies.authenticate.mockResolvedValue("https://auth.example/start");
|
||||||
h.clientSpies.clearOAuthTokens.mockReset();
|
h.clientSpies.clearOAuthTokens.mockReset();
|
||||||
|
|
@ -1041,3 +1111,350 @@ describe("App (OAuth flows)", () => {
|
||||||
await expectFrame(r, "Step-up authorization succeeded");
|
await expectFrame(r, "Step-up authorization succeeded");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("App (mid-session auth lifecycle events)", () => {
|
||||||
|
it("shows the ambient-refresh message then clears it on recovery", async () => {
|
||||||
|
const r = await mount(oneHttp());
|
||||||
|
await press(r, ["a"]); // Auth tab so oauthMessage is visible
|
||||||
|
h.fireClientEvent("authChallengeAmbient");
|
||||||
|
await expectFrame(r, "Refreshing authorization");
|
||||||
|
h.fireClientEvent("authChallengeRecovered");
|
||||||
|
await waitUntil(
|
||||||
|
() => !(r.lastFrame() ?? "").includes("Refreshing authorization"),
|
||||||
|
);
|
||||||
|
expect(r.lastFrame() ?? "").not.toContain("Refreshing authorization");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces an oauthError event for both Error and non-Error payloads", async () => {
|
||||||
|
const r = await mount(oneHttp());
|
||||||
|
await press(r, ["a"]);
|
||||||
|
h.fireClientEvent("oauthError", { error: new Error("oauth boom") });
|
||||||
|
await expectFrame(r, "oauth boom");
|
||||||
|
h.fireClientEvent("oauthError", { error: "plain oauth string" });
|
||||||
|
await expectFrame(r, "plain oauth string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears OAuth tokens and disconnects when connected", async () => {
|
||||||
|
h.ctrl.status = "connected";
|
||||||
|
const r = await mount(oneHttp());
|
||||||
|
await press(r, ["a", "s"]);
|
||||||
|
await waitUntil(() => h.clientSpies.clearOAuthTokens.mock.calls.length > 0);
|
||||||
|
expect(h.clientSpies.clearOAuthTokens).toHaveBeenCalled();
|
||||||
|
expect(h.disconnect).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepUpChallenge = {
|
||||||
|
reason: "insufficient_scope" as const,
|
||||||
|
requiredScopes: ["env:read"],
|
||||||
|
authorizationScopes: ["tools:read", "env:read"],
|
||||||
|
context: { toolName: "get-env" },
|
||||||
|
};
|
||||||
|
|
||||||
|
it("presents a standard step-up on an interactive auth-challenge event", async () => {
|
||||||
|
h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false);
|
||||||
|
const r = await mount(oneHttp());
|
||||||
|
await press(r, ["a"]);
|
||||||
|
h.fireClientEvent("authChallengeInteractive", {
|
||||||
|
authorizationUrl: new URL("https://as.example/authorize"),
|
||||||
|
challenge: stepUpChallenge,
|
||||||
|
});
|
||||||
|
await expectFrame(r, "needs additional OAuth scopes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips step-up when the challenge is already satisfied (interactive event)", async () => {
|
||||||
|
h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(true);
|
||||||
|
const r = await mount(oneHttp());
|
||||||
|
await press(r, ["a"]);
|
||||||
|
h.fireClientEvent("authChallengeInteractive", {
|
||||||
|
authorizationUrl: new URL("https://as.example/authorize"),
|
||||||
|
challenge: stepUpChallenge,
|
||||||
|
});
|
||||||
|
await expectFrame(r, "Authorization updated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-runs OAuth on an interactive reauth event (no step-up confirm)", async () => {
|
||||||
|
h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false);
|
||||||
|
const r = await mount(oneHttp());
|
||||||
|
await press(r, ["a"]);
|
||||||
|
h.fireClientEvent("authChallengeInteractive", {
|
||||||
|
authorizationUrl: new URL("https://as.example/authorize"),
|
||||||
|
challenge: { reason: "unauthorized" as const },
|
||||||
|
});
|
||||||
|
await waitUntil(() => h.callbackStart.mock.calls.length > 0);
|
||||||
|
expect(h.callbackStart).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes a connect AuthRecoveryRequiredError into recovery", async () => {
|
||||||
|
h.connect
|
||||||
|
.mockRejectedValueOnce(
|
||||||
|
new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), {
|
||||||
|
reason: "unauthorized",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false);
|
||||||
|
const r = await mount(oneHttp());
|
||||||
|
await press(r, ["c"]);
|
||||||
|
await waitUntil(() => h.callbackStart.mock.calls.length > 0);
|
||||||
|
expect(h.callbackStart).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces an EMA-client-not-configured error on connect", async () => {
|
||||||
|
h.connect.mockRejectedValue(
|
||||||
|
new EmaClientNotConfiguredError("not_configured"),
|
||||||
|
);
|
||||||
|
const r = await mount(oneEmaHttp());
|
||||||
|
await press(r, ["a", "c"]);
|
||||||
|
await waitUntil(() => (r.lastFrame() ?? "").length > 0);
|
||||||
|
await expectFrame(r, "enterprise");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("App (step-up authorize outcomes)", () => {
|
||||||
|
const challenge = {
|
||||||
|
reason: "insufficient_scope" as const,
|
||||||
|
requiredScopes: ["env:read"],
|
||||||
|
authorizationScopes: ["tools:read", "env:read"],
|
||||||
|
context: { toolName: "get-env" },
|
||||||
|
};
|
||||||
|
|
||||||
|
async function presentEmaStepUp() {
|
||||||
|
h.clientSpies.callTool.mockRejectedValue(
|
||||||
|
new AuthRecoveryRequiredError(EMA_STEP_UP_PENDING_URL, challenge, {
|
||||||
|
emaStepUpConfirm: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false);
|
||||||
|
h.ctrl.status = "connected";
|
||||||
|
h.ctrl.tools = [sampleTool];
|
||||||
|
const r = await mount(oneEmaHttp());
|
||||||
|
await press(r, ["t", TAB, ENTER]);
|
||||||
|
await expectFrame(r, "MOCK_FORM");
|
||||||
|
await press(r, [ENTER]);
|
||||||
|
await waitUntil(() => h.clientSpies.callTool.mock.calls.length > 0);
|
||||||
|
await expectFrame(r, "organization before it can continue");
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("reports a failed EMA step-up outcome", async () => {
|
||||||
|
const r = await presentEmaStepUp();
|
||||||
|
h.clientSpies.handleAuthChallenge.mockResolvedValue({
|
||||||
|
kind: "failed",
|
||||||
|
error: new Error("mint failed"),
|
||||||
|
});
|
||||||
|
await press(r, ["a"]);
|
||||||
|
await expectFrame(r, "mint failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs interactive OAuth when EMA step-up returns interactive", async () => {
|
||||||
|
const r = await presentEmaStepUp();
|
||||||
|
h.clientSpies.handleAuthChallenge.mockResolvedValue({
|
||||||
|
kind: "interactive",
|
||||||
|
challenge,
|
||||||
|
authorizationUrl: new URL("https://as.example/authorize"),
|
||||||
|
});
|
||||||
|
await press(r, ["a"]);
|
||||||
|
await waitUntil(() => h.callbackStart.mock.calls.length > 0);
|
||||||
|
expect(h.callbackStart).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces an error when EMA handleAuthChallenge throws", async () => {
|
||||||
|
const r = await presentEmaStepUp();
|
||||||
|
h.clientSpies.handleAuthChallenge.mockRejectedValue(
|
||||||
|
new Error("challenge boom"),
|
||||||
|
);
|
||||||
|
await press(r, ["a"]);
|
||||||
|
await expectFrame(r, "challenge boom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels a pending step-up with 'c'", async () => {
|
||||||
|
const r = await presentEmaStepUp();
|
||||||
|
await press(r, ["c"]);
|
||||||
|
await expectFrame(r, "Authorization cancelled");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes an EMA interactive step-up when OAuth succeeds", async () => {
|
||||||
|
const r = await presentEmaStepUp();
|
||||||
|
h.clientSpies.handleAuthChallenge.mockResolvedValue({
|
||||||
|
kind: "interactive",
|
||||||
|
challenge,
|
||||||
|
authorizationUrl: new URL("https://as.example/authorize"),
|
||||||
|
});
|
||||||
|
h.runner.override = async () => ({ kind: "success" });
|
||||||
|
await press(r, ["a"]);
|
||||||
|
await expectFrame(r, "Step-up authorization succeeded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes an EMA interactive step-up when OAuth returns already_authorized", async () => {
|
||||||
|
const r = await presentEmaStepUp();
|
||||||
|
h.clientSpies.handleAuthChallenge.mockResolvedValue({
|
||||||
|
kind: "interactive",
|
||||||
|
challenge,
|
||||||
|
authorizationUrl: new URL("https://as.example/authorize"),
|
||||||
|
});
|
||||||
|
h.runner.override = async () => ({ kind: "already_authorized" });
|
||||||
|
await press(r, ["a"]);
|
||||||
|
await expectFrame(r, "Step-up authorization succeeded");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("App (OAuth result branches)", () => {
|
||||||
|
const unauthorized = Object.assign(new Error("request failed (401)"), {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
const stepUpChallenge = {
|
||||||
|
reason: "insufficient_scope" as const,
|
||||||
|
requiredScopes: ["env:read"],
|
||||||
|
authorizationScopes: ["tools:read", "env:read"],
|
||||||
|
context: { toolName: "get-env" },
|
||||||
|
};
|
||||||
|
const authUrl = () => new URL("https://as.example/authorize");
|
||||||
|
|
||||||
|
it("re-connects after OAuth returns already_authorized on a 401", async () => {
|
||||||
|
h.connect.mockRejectedValueOnce(unauthorized).mockResolvedValue(undefined);
|
||||||
|
h.runner.override = async () => ({ kind: "already_authorized" });
|
||||||
|
const r = await mount(oneHttp());
|
||||||
|
await press(r, ["c"]);
|
||||||
|
await waitUntil(() => h.connect.mock.calls.length >= 2);
|
||||||
|
expect(h.connect).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles an unsupported OAuth result on a 401 without reconnecting", async () => {
|
||||||
|
h.connect.mockRejectedValueOnce(unauthorized).mockResolvedValue(undefined);
|
||||||
|
h.runner.override = async () => ({ kind: "failed" });
|
||||||
|
const r = await mount(oneHttp());
|
||||||
|
await press(r, ["c"]);
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
expect(h.connect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes an AuthRecoveryRequiredError thrown during 401 OAuth to recovery", async () => {
|
||||||
|
h.connect.mockRejectedValueOnce(unauthorized).mockResolvedValue(undefined);
|
||||||
|
h.runner.override = async () => {
|
||||||
|
throw new AuthRecoveryRequiredError(authUrl(), {
|
||||||
|
reason: "unauthorized",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(true);
|
||||||
|
const r = await mount(oneHttp());
|
||||||
|
await press(r, ["c"]);
|
||||||
|
await waitUntil(() => h.disconnect.mock.calls.length > 0);
|
||||||
|
await press(r, ["a"]); // view the Auth tab where oauthMessage renders
|
||||||
|
await expectFrame(r, "Authorization updated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces an EMA-not-configured error thrown during 401 OAuth", async () => {
|
||||||
|
h.connect.mockRejectedValueOnce(unauthorized).mockResolvedValue(undefined);
|
||||||
|
h.runner.override = async () => {
|
||||||
|
throw new EmaClientNotConfiguredError("disabled");
|
||||||
|
};
|
||||||
|
const r = await mount(oneHttp());
|
||||||
|
await press(r, ["a", "c"]);
|
||||||
|
await expectFrame(r, "enterprise");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes a standard step-up authorize when OAuth succeeds", async () => {
|
||||||
|
h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false);
|
||||||
|
h.runner.override = async () => ({ kind: "success" });
|
||||||
|
const r = await mount(oneHttp());
|
||||||
|
await press(r, ["a"]);
|
||||||
|
h.fireClientEvent("authChallengeInteractive", {
|
||||||
|
authorizationUrl: authUrl(),
|
||||||
|
challenge: stepUpChallenge,
|
||||||
|
});
|
||||||
|
await expectFrame(r, "needs additional OAuth scopes");
|
||||||
|
await press(r, ["a"]);
|
||||||
|
await expectFrame(r, "Step-up authorization succeeded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes a standard step-up authorize when OAuth returns already_authorized", async () => {
|
||||||
|
h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false);
|
||||||
|
h.runner.override = async () => ({ kind: "already_authorized" });
|
||||||
|
const r = await mount(oneHttp());
|
||||||
|
await press(r, ["a"]);
|
||||||
|
h.fireClientEvent("authChallengeInteractive", {
|
||||||
|
authorizationUrl: authUrl(),
|
||||||
|
challenge: stepUpChallenge,
|
||||||
|
});
|
||||||
|
await expectFrame(r, "needs additional OAuth scopes");
|
||||||
|
await press(r, ["a"]);
|
||||||
|
await expectFrame(r, "Step-up authorization succeeded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows insufficient-scope message when step-up OAuth stays insufficient", async () => {
|
||||||
|
h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false);
|
||||||
|
h.runner.override = async () => ({
|
||||||
|
kind: "insufficient_scope",
|
||||||
|
challenge: stepUpChallenge,
|
||||||
|
});
|
||||||
|
const r = await mount(oneHttp());
|
||||||
|
await press(r, ["a"]);
|
||||||
|
h.fireClientEvent("authChallengeInteractive", {
|
||||||
|
authorizationUrl: authUrl(),
|
||||||
|
challenge: stepUpChallenge,
|
||||||
|
});
|
||||||
|
await expectFrame(r, "needs additional OAuth scopes");
|
||||||
|
await press(r, ["a"]);
|
||||||
|
await expectFrame(r, "were not granted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips reauth when already satisfied (interactive event)", async () => {
|
||||||
|
h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(true);
|
||||||
|
const r = await mount(oneHttp());
|
||||||
|
await press(r, ["a"]);
|
||||||
|
h.fireClientEvent("authChallengeInteractive", {
|
||||||
|
authorizationUrl: authUrl(),
|
||||||
|
challenge: { reason: "unauthorized" },
|
||||||
|
});
|
||||||
|
await expectFrame(r, "Authorization updated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes reauth via OAuth on an interactive event", async () => {
|
||||||
|
h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false);
|
||||||
|
h.runner.override = async () => ({ kind: "success" });
|
||||||
|
const r = await mount(oneHttp());
|
||||||
|
await press(r, ["a"]);
|
||||||
|
h.fireClientEvent("authChallengeInteractive", {
|
||||||
|
authorizationUrl: authUrl(),
|
||||||
|
challenge: { reason: "unauthorized" },
|
||||||
|
});
|
||||||
|
await expectFrame(r, "Authorization updated. Retry your action");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes reauth when OAuth returns already_authorized", async () => {
|
||||||
|
h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false);
|
||||||
|
h.runner.override = async () => ({ kind: "already_authorized" });
|
||||||
|
const r = await mount(oneHttp());
|
||||||
|
await press(r, ["a"]);
|
||||||
|
h.fireClientEvent("authChallengeInteractive", {
|
||||||
|
authorizationUrl: authUrl(),
|
||||||
|
challenge: { reason: "unauthorized" },
|
||||||
|
});
|
||||||
|
await expectFrame(r, "Authorization updated. Retry your action");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores auth lifecycle events from a non-selected server", async () => {
|
||||||
|
const r = await mount(twoHttp()); // web is selected; api is not
|
||||||
|
await press(r, ["a"]);
|
||||||
|
const api = h.clientInstances.find((c) => c.cfg?.url === "http://b");
|
||||||
|
const web = h.clientInstances.find((c) => c.cfg?.url === "http://a");
|
||||||
|
// Firing ONLY the non-selected server's handlers must hit the
|
||||||
|
// `selectedServerRef.current !== serverName` guard on each listener and set
|
||||||
|
// no message — if any guard were removed, a negative assertion would fail.
|
||||||
|
h.fireClientEventFor(api, "authChallengeAmbient");
|
||||||
|
h.fireClientEventFor(api, "authChallengeRecovered");
|
||||||
|
h.fireClientEventFor(api, "oauthError", {
|
||||||
|
error: new Error("api-only error"),
|
||||||
|
});
|
||||||
|
await tick();
|
||||||
|
expect(r.lastFrame() ?? "").not.toContain("Refreshing authorization");
|
||||||
|
expect(r.lastFrame() ?? "").not.toContain("api-only error");
|
||||||
|
// The selected server's handlers do act (guard false): ambient refreshes,
|
||||||
|
// then an error surfaces.
|
||||||
|
h.fireClientEventFor(web, "authChallengeAmbient");
|
||||||
|
await expectFrame(r, "Refreshing authorization");
|
||||||
|
h.fireClientEventFor(web, "oauthError", { error: new Error("web error") });
|
||||||
|
await expectFrame(r, "web error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -309,6 +309,66 @@ describe("AuthTab", () => {
|
||||||
expect(lastFrame() ?? "").toContain("OAuth Details");
|
expect(lastFrame() ?? "").toContain("OAuth Details");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("moves the step-up selection back up with the up arrow", async () => {
|
||||||
|
const onAuthorizeStepUp = vi.fn();
|
||||||
|
const onCancelStepUp = vi.fn();
|
||||||
|
const { client } = makeClient(sampleOAuthState);
|
||||||
|
const { stdin } = render(
|
||||||
|
<AuthTab
|
||||||
|
{...baseProps}
|
||||||
|
focused
|
||||||
|
inspectorClient={client}
|
||||||
|
oauthStatus="idle"
|
||||||
|
oauthMessage={null}
|
||||||
|
pendingStepUp={pendingStepUp}
|
||||||
|
onAuthorizeStepUp={onAuthorizeStepUp}
|
||||||
|
onCancelStepUp={onCancelStepUp}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await tick();
|
||||||
|
// Move down to "cancel" (index 1), then back up to "authorize" (index 0).
|
||||||
|
stdin.write(DOWN);
|
||||||
|
await tick();
|
||||||
|
stdin.write(UP);
|
||||||
|
await tick();
|
||||||
|
stdin.write("\r");
|
||||||
|
await tick();
|
||||||
|
expect(onAuthorizeStepUp).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onCancelStepUp).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("authorizes step-up with 'a', cancels with 'c', and ignores other keys", async () => {
|
||||||
|
const onAuthorizeStepUp = vi.fn();
|
||||||
|
const onCancelStepUp = vi.fn();
|
||||||
|
const { client } = makeClient(sampleOAuthState);
|
||||||
|
const { stdin } = render(
|
||||||
|
<AuthTab
|
||||||
|
{...baseProps}
|
||||||
|
focused
|
||||||
|
inspectorClient={client}
|
||||||
|
oauthStatus="idle"
|
||||||
|
oauthMessage={null}
|
||||||
|
pendingStepUp={pendingStepUp}
|
||||||
|
onAuthorizeStepUp={onAuthorizeStepUp}
|
||||||
|
onCancelStepUp={onCancelStepUp}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await tick();
|
||||||
|
stdin.write("a");
|
||||||
|
await tick();
|
||||||
|
expect(onAuthorizeStepUp).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
stdin.write("c");
|
||||||
|
await tick();
|
||||||
|
expect(onCancelStepUp).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// An unrelated key is swallowed while the step-up prompt is pending.
|
||||||
|
stdin.write("x");
|
||||||
|
await tick();
|
||||||
|
expect(onAuthorizeStepUp).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onCancelStepUp).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("refreshes OAuth state when oauthComplete fires", async () => {
|
it("refreshes OAuth state when oauthComplete fires", async () => {
|
||||||
const { client, getOAuthState, fire, listeners } =
|
const { client, getOAuthState, fire, listeners } =
|
||||||
makeClient(sampleOAuthState);
|
makeClient(sampleOAuthState);
|
||||||
|
|
|
||||||
32
clients/tui/package-lock.json
generated
32
clients/tui/package-lock.json
generated
|
|
@ -20,8 +20,7 @@
|
||||||
"open": "^10.2.0",
|
"open": "^10.2.0",
|
||||||
"pino": "^9.14.0",
|
"pino": "^9.14.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6"
|
||||||
"zustand": "^5.0.13"
|
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"mcp-inspector-tui": "build/index.js"
|
"mcp-inspector-tui": "build/index.js"
|
||||||
|
|
@ -7510,35 +7509,6 @@
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"node_modules/zustand": {
|
|
||||||
"version": "5.0.14",
|
|
||||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
|
|
||||||
"integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.20.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": ">=18.0.0",
|
|
||||||
"immer": ">=9.0.6",
|
|
||||||
"react": ">=18.0.0",
|
|
||||||
"use-sync-external-store": ">=1.2.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"immer": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"use-sync-external-store": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,7 @@
|
||||||
"open": "^10.2.0",
|
"open": "^10.2.0",
|
||||||
"pino": "^9.14.0",
|
"pino": "^9.14.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6"
|
||||||
"zustand": "^5.0.13"
|
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"ink-select-input": "^6.2.0"
|
"ink-select-input": "^6.2.0"
|
||||||
|
|
|
||||||
|
|
@ -896,7 +896,7 @@ function App({
|
||||||
|
|
||||||
const handleClearOAuth = useCallback(async () => {
|
const handleClearOAuth = useCallback(async () => {
|
||||||
if (!selectedInspectorClient) return;
|
if (!selectedInspectorClient) return;
|
||||||
selectedInspectorClient.clearOAuthTokens();
|
await selectedInspectorClient.clearOAuthTokens();
|
||||||
setOauthStatus("idle");
|
setOauthStatus("idle");
|
||||||
setOauthMessage(null);
|
setOauthMessage(null);
|
||||||
setConnectError(null);
|
setConnectError(null);
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ export default defineConfig({
|
||||||
'open',
|
'open',
|
||||||
'commander',
|
'commander',
|
||||||
'pino',
|
'pino',
|
||||||
'zustand',
|
|
||||||
'@modelcontextprotocol/sdk',
|
'@modelcontextprotocol/sdk',
|
||||||
'@napi-rs/keyring',
|
'@napi-rs/keyring',
|
||||||
],
|
],
|
||||||
|
|
|
||||||
34
clients/web/package-lock.json
generated
34
clients/web/package-lock.json
generated
|
|
@ -34,8 +34,7 @@
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-syntax-highlighter": "^16.1.1",
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6"
|
||||||
"zustand": "^5.0.13"
|
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"mcp-inspector-web": "build/index.js"
|
"mcp-inspector-web": "build/index.js"
|
||||||
|
|
@ -9952,7 +9951,7 @@
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
|
@ -10359,35 +10358,6 @@
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zustand": {
|
|
||||||
"version": "5.0.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz",
|
|
||||||
"integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.20.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": ">=18.0.0",
|
|
||||||
"immer": ">=9.0.6",
|
|
||||||
"react": ">=18.0.0",
|
|
||||||
"use-sync-external-store": ">=1.2.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"immer": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"use-sync-external-store": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/zwitch": {
|
"node_modules/zwitch": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,7 @@
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-syntax-highlighter": "^16.1.1",
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6"
|
||||||
"zustand": "^5.0.13"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chromatic-com/storybook": "^5.0.1",
|
"@chromatic-com/storybook": "^5.0.1",
|
||||||
|
|
|
||||||
|
|
@ -422,3 +422,30 @@
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
box-shadow: var(--mantine-shadow-sm);
|
box-shadow: var(--mantine-shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Draggable vertical divider between the primary screen area and the pinned
|
||||||
|
* monitoring column (#1616). The col-resize cursor, the hover/active accent,
|
||||||
|
* and the drag-time user-select suppression are all pseudo-selector / cursor /
|
||||||
|
* cross-element concerns that can't be expressed as Mantine props, so the whole
|
||||||
|
* unit lives here rather than split across the theme.
|
||||||
|
*/
|
||||||
|
.resize-handle {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 6px;
|
||||||
|
cursor: col-resize;
|
||||||
|
background-color: var(--inspector-border-default);
|
||||||
|
transition: background-color 120ms ease;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle:hover,
|
||||||
|
.resize-handle:focus-visible {
|
||||||
|
background-color: var(--mantine-primary-color-filled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Suppress text selection anywhere on the page while a drag is in progress. */
|
||||||
|
body.resizing-col {
|
||||||
|
user-select: none;
|
||||||
|
cursor: col-resize;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,10 +86,7 @@ import { useServers } from "@inspector/core/react/useServers.js";
|
||||||
import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js";
|
import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js";
|
||||||
import { useClientSettingsDraft } from "@inspector/core/react/useClientSettingsDraft.js";
|
import { useClientSettingsDraft } from "@inspector/core/react/useClientSettingsDraft.js";
|
||||||
import { useEmaIdpLoginState } from "@inspector/core/react/useEmaIdpLoginState.js";
|
import { useEmaIdpLoginState } from "@inspector/core/react/useEmaIdpLoginState.js";
|
||||||
import {
|
import { getWebRemoteOAuthStorage } from "./lib/remoteOAuthStorage";
|
||||||
getRemoteOAuthStorage,
|
|
||||||
getWebOAuthBaseUrl,
|
|
||||||
} from "./lib/remoteOAuthStorage";
|
|
||||||
import { useManagedTools } from "@inspector/core/react/useManagedTools.js";
|
import { useManagedTools } from "@inspector/core/react/useManagedTools.js";
|
||||||
import { useManagedPrompts } from "@inspector/core/react/useManagedPrompts.js";
|
import { useManagedPrompts } from "@inspector/core/react/useManagedPrompts.js";
|
||||||
import { useManagedResources } from "@inspector/core/react/useManagedResources.js";
|
import { useManagedResources } from "@inspector/core/react/useManagedResources.js";
|
||||||
|
|
@ -1431,6 +1428,13 @@ function App() {
|
||||||
[messages],
|
[messages],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Shared OAuth runtime store (oauth.json via /api/storage/oauth). Memoized so
|
||||||
|
// connect, EMA IdP session, and per-server clear share one in-memory view.
|
||||||
|
const webOAuthStorage = useMemo(
|
||||||
|
() => getWebRemoteOAuthStorage(getAuthToken()),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// Backend-backed session storage used to carry the fetch (Network) log
|
// Backend-backed session storage used to carry the fetch (Network) log
|
||||||
// across the OAuth full-page redirect. The auth handshake's first half —
|
// across the OAuth full-page redirect. The auth handshake's first half —
|
||||||
// protected-resource + auth-server discovery and Dynamic Client
|
// protected-resource + auth-server discovery and Dynamic Client
|
||||||
|
|
@ -2101,10 +2105,10 @@ function App() {
|
||||||
// `onToggleConnection` unloaded the previous one), so all React state is
|
// `onToggleConnection` unloaded the previous one), so all React state is
|
||||||
// reset and we recover the initiating server from sessionStorage. We wait for
|
// reset and we recover the initiating server from sessionStorage. We wait for
|
||||||
// `servers` to hydrate before acting; the ref guard keeps the exchange to a
|
// `servers` to hydrate before acting; the ref guard keeps the exchange to a
|
||||||
// single run. The persisted PKCE verifier + DCR client info live in the
|
// single run. The persisted PKCE verifier + DCR client info live in shared
|
||||||
// backend-backed `RemoteOAuthStorage` (`~/.mcp-inspector/storage/oauth.json`)
|
// `RemoteOAuthStorage` (`oauth.json`) and survive the redirect, so
|
||||||
// and survive the redirect, so `completeOAuthFlow` exchanges the code without
|
// `completeOAuthFlow` exchanges the code without needing the original
|
||||||
// needing the original in-memory state machine.
|
// in-memory state machine.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
if (window.location.pathname !== OAUTH_CALLBACK_PATH) return;
|
if (window.location.pathname !== OAUTH_CALLBACK_PATH) return;
|
||||||
|
|
@ -2212,6 +2216,15 @@ function App() {
|
||||||
}
|
}
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await webOAuthStorage.load();
|
||||||
|
} catch (err) {
|
||||||
|
connectStartRef.current = undefined;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
showReAuthBanner(server.id, err instanceof Error ? err : String(err));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const client = setupClientForServer(server, sessionId);
|
const client = setupClientForServer(server, sessionId);
|
||||||
setActiveServerId(server.id);
|
setActiveServerId(server.id);
|
||||||
try {
|
try {
|
||||||
|
|
@ -2267,7 +2280,7 @@ function App() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [servers, setupClientForServer, showReAuthBanner]);
|
}, [servers, setupClientForServer, showReAuthBanner, webOAuthStorage]);
|
||||||
|
|
||||||
const onToggleConnection = useCallback(
|
const onToggleConnection = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
|
|
@ -3268,13 +3281,9 @@ function App() {
|
||||||
|
|
||||||
const clientSettingsModalValue = clientSettingsDraft ?? EMPTY_CLIENT_SETTINGS;
|
const clientSettingsModalValue = clientSettingsDraft ?? EMPTY_CLIENT_SETTINGS;
|
||||||
|
|
||||||
const emaOAuthStorage = useMemo(
|
|
||||||
() => getRemoteOAuthStorage(getWebOAuthBaseUrl(), getAuthToken()),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const { loginState: emaIdpLoginState, logout: logoutEmaIdp } =
|
const { loginState: emaIdpLoginState, logout: logoutEmaIdp } =
|
||||||
useEmaIdpLoginState(
|
useEmaIdpLoginState(
|
||||||
emaOAuthStorage,
|
webOAuthStorage,
|
||||||
clientSettingsModalValue.emaEnabled
|
clientSettingsModalValue.emaEnabled
|
||||||
? clientSettingsModalValue.issuer
|
? clientSettingsModalValue.issuer
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
@ -3298,14 +3307,11 @@ function App() {
|
||||||
const clearServerOAuthAndDisconnect = useCallback(
|
const clearServerOAuthAndDisconnect = useCallback(
|
||||||
async (server: { id: string; name: string; config: MCPServerConfig }) => {
|
async (server: { id: string; name: string; config: MCPServerConfig }) => {
|
||||||
const isActive = server.id === activeServerId;
|
const isActive = server.id === activeServerId;
|
||||||
const cleared = clearServerOAuthState({
|
const cleared = await clearServerOAuthState({
|
||||||
config: server.config,
|
config: server.config,
|
||||||
inspectorClient: isActive ? inspectorClient : null,
|
inspectorClient: isActive ? inspectorClient : null,
|
||||||
isActiveConnection: isActive,
|
isActiveConnection: isActive,
|
||||||
oauthStorage: getRemoteOAuthStorage(
|
oauthStorage: webOAuthStorage,
|
||||||
getWebOAuthBaseUrl(),
|
|
||||||
getAuthToken(),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
if (!cleared) return;
|
if (!cleared) return;
|
||||||
|
|
||||||
|
|
@ -3331,6 +3337,7 @@ function App() {
|
||||||
[
|
[
|
||||||
activeServerId,
|
activeServerId,
|
||||||
inspectorClient,
|
inspectorClient,
|
||||||
|
webOAuthStorage,
|
||||||
finalizeExplicitDisconnect,
|
finalizeExplicitDisconnect,
|
||||||
clearOAuthResumeOnExplicitDisconnect,
|
clearOAuthResumeOnExplicitDisconnect,
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { createRef } from "react";
|
||||||
|
import { Stack, Text } from "@mantine/core";
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { expect, within } from "storybook/test";
|
||||||
|
import { EmbeddableScrollArea } from "./EmbeddableScrollArea";
|
||||||
|
|
||||||
|
const meta: Meta<typeof EmbeddableScrollArea> = {
|
||||||
|
title: "Elements/EmbeddableScrollArea",
|
||||||
|
component: EmbeddableScrollArea,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof EmbeddableScrollArea>;
|
||||||
|
|
||||||
|
const rows = Array.from({ length: 40 }, (_, i) => (
|
||||||
|
<Text key={i}>Row {i + 1}</Text>
|
||||||
|
));
|
||||||
|
|
||||||
|
export const FullSize: Story = {
|
||||||
|
render: () => (
|
||||||
|
<EmbeddableScrollArea embedded={false} viewportRef={createRef()}>
|
||||||
|
<Stack gap="xs">{rows}</Stack>
|
||||||
|
</EmbeddableScrollArea>
|
||||||
|
),
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
expect(canvas.getByText("Row 1")).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Embedded: Story = {
|
||||||
|
render: () => (
|
||||||
|
<Stack h={240} gap={0}>
|
||||||
|
<EmbeddableScrollArea embedded viewportRef={createRef()}>
|
||||||
|
<Stack gap="xs">{rows}</Stack>
|
||||||
|
</EmbeddableScrollArea>
|
||||||
|
</Stack>
|
||||||
|
),
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
expect(canvas.getByText("Row 1")).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { createRef } from "react";
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||||
|
import { EmbeddableScrollArea } from "./EmbeddableScrollArea";
|
||||||
|
|
||||||
|
describe("EmbeddableScrollArea", () => {
|
||||||
|
it("renders its children full-size", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<EmbeddableScrollArea embedded={false} viewportRef={createRef()}>
|
||||||
|
<div>full-size content</div>
|
||||||
|
</EmbeddableScrollArea>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("full-size content")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders its children when embedded", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<EmbeddableScrollArea embedded={true} viewportRef={createRef()}>
|
||||||
|
<div>embedded content</div>
|
||||||
|
</EmbeddableScrollArea>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("embedded content")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches the viewport ref to the scroll viewport", () => {
|
||||||
|
const ref = createRef<HTMLDivElement>();
|
||||||
|
renderWithMantine(
|
||||||
|
<EmbeddableScrollArea embedded={false} viewportRef={ref}>
|
||||||
|
<div>content</div>
|
||||||
|
</EmbeddableScrollArea>,
|
||||||
|
);
|
||||||
|
expect(ref.current).toBeInstanceOf(HTMLElement);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import type { ReactNode, Ref } from "react";
|
||||||
|
import { ScrollArea, Stack } from "@mantine/core";
|
||||||
|
|
||||||
|
// Full-size, the monitor stream panels bound their scroll to the viewport
|
||||||
|
// minus the header and the panel's own chrome.
|
||||||
|
const FULLSIZE_MAH =
|
||||||
|
"calc(100vh - var(--app-shell-header-height, 0px) - 150px)";
|
||||||
|
|
||||||
|
export interface EmbeddableScrollAreaProps {
|
||||||
|
/**
|
||||||
|
* True when rendered inside the pinned monitoring column (#1616): the scroll
|
||||||
|
* region fills its flex parent instead of using the viewport calc. A
|
||||||
|
* `flex:1 / mih:0` wrapper caps the inner `ScrollArea` at the space remaining
|
||||||
|
* below the column's controls (via `mah:100%`), so no viewport math is needed
|
||||||
|
* and the final rows never clip.
|
||||||
|
*/
|
||||||
|
embedded: boolean;
|
||||||
|
viewportRef: Ref<HTMLDivElement>;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scroll region shared by the Logs / History / Network stream panels, which
|
||||||
|
* render both full-size (their own tab) and embedded (the monitoring column).
|
||||||
|
* Centralizes the one layout difference between those two hosts.
|
||||||
|
*/
|
||||||
|
export function EmbeddableScrollArea({
|
||||||
|
embedded,
|
||||||
|
viewportRef,
|
||||||
|
children,
|
||||||
|
}: EmbeddableScrollAreaProps) {
|
||||||
|
if (embedded) {
|
||||||
|
return (
|
||||||
|
<Stack flex={1} mih={0} gap={0}>
|
||||||
|
<ScrollArea.Autosize
|
||||||
|
viewportRef={viewportRef}
|
||||||
|
mah="100%"
|
||||||
|
type="scroll"
|
||||||
|
offsetScrollbars
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ScrollArea.Autosize
|
||||||
|
viewportRef={viewportRef}
|
||||||
|
mah={FULLSIZE_MAH}
|
||||||
|
type="scroll"
|
||||||
|
offsetScrollbars
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { expect, fn, within } from "storybook/test";
|
||||||
|
import { PinColumnButton } from "./PinColumnButton";
|
||||||
|
|
||||||
|
const meta: Meta<typeof PinColumnButton> = {
|
||||||
|
title: "Elements/PinColumnButton",
|
||||||
|
component: PinColumnButton,
|
||||||
|
args: { onPin: fn() },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof PinColumnButton>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
expect(
|
||||||
|
canvas.getByRole("button", { name: "Pin as column" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue