Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 38 additions & 30 deletions src/__tests__/context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ function StatusDisplay() {
}

function deleteNativeModelContext() {
const desc = Object.getOwnPropertyDescriptor(navigator, "modelContext");
const desc = Object.getOwnPropertyDescriptor(document, "modelContext");
if (desc) {
Object.defineProperty(navigator, "modelContext", {
Object.defineProperty(document, "modelContext", {
value: undefined,
configurable: true,
writable: true,
});
delete navigator.modelContext;
delete document.modelContext;
}
}

Expand Down Expand Up @@ -52,10 +52,11 @@ describe("WebMCPProvider availability", () => {

it("available is true when native API exists", async () => {
const native = {
registerTool() {},
unregisterTool() {},
registerTool() {
return Promise.resolve(undefined);
},
};
Object.defineProperty(navigator, "modelContext", {
Object.defineProperty(document, "modelContext", {
value: native,
configurable: true,
enumerable: true,
Expand All @@ -73,18 +74,19 @@ describe("WebMCPProvider availability", () => {
expect(getByTestId("status")).toHaveTextContent("yes");
});
// Native API should still be the original, not replaced by polyfill
expect(navigator.modelContext).toBe(native);
expect(document.modelContext).toBe(native);
} finally {
deleteNativeModelContext();
}
});

it("available is true when native API lacks unregisterTool (Chrome 148+)", async () => {
it("available is true with a signal-only native API (Chrome 148+)", async () => {
const native = {
registerTool() {},
// no unregisterTool — simulates Chrome 148+
registerTool() {
return Promise.resolve(undefined);
},
};
Object.defineProperty(navigator, "modelContext", {
Object.defineProperty(document, "modelContext", {
value: native,
configurable: true,
enumerable: true,
Expand All @@ -101,7 +103,7 @@ describe("WebMCPProvider availability", () => {
await waitFor(() => {
expect(getByTestId("status")).toHaveTextContent("yes");
});
expect(navigator.modelContext).toBe(native);
expect(document.modelContext).toBe(native);
} finally {
deleteNativeModelContext();
}
Expand All @@ -119,17 +121,20 @@ describe("polyfill lifecycle", () => {
);

await waitFor(() => {
expect(navigator.modelContext).toBeDefined();
expect(document.modelContext).toBeDefined();
});
expect((navigator.modelContext as Record<string, unknown>).__isWebMCPPolyfill).toBe(true);
expect((document.modelContext as unknown as Record<string, unknown>).__isWebMCPPolyfill).toBe(
true,
);
});

it("does not install polyfill when native API present", async () => {
const native = {
registerTool() {},
unregisterTool() {},
registerTool() {
return Promise.resolve(undefined);
},
};
Object.defineProperty(navigator, "modelContext", {
Object.defineProperty(document, "modelContext", {
value: native,
configurable: true,
enumerable: true,
Expand All @@ -144,10 +149,10 @@ describe("polyfill lifecycle", () => {
);

await waitFor(() => {
expect(navigator.modelContext).toBe(native);
expect(document.modelContext).toBe(native);
});
expect(
(navigator.modelContext as Record<string, unknown>).__isWebMCPPolyfill,
(document.modelContext as unknown as Record<string, unknown>).__isWebMCPPolyfill,
).toBeUndefined();
} finally {
deleteNativeModelContext();
Expand All @@ -162,19 +167,20 @@ describe("polyfill lifecycle", () => {
);

await waitFor(() => {
expect(navigator.modelContext).toBeDefined();
expect(document.modelContext).toBeDefined();
});

unmount();
expect(navigator.modelContext).toBeUndefined();
expect(document.modelContext).toBeUndefined();
});

it("does not clean up native API on unmount", async () => {
const native = {
registerTool() {},
unregisterTool() {},
registerTool() {
return Promise.resolve(undefined);
},
};
Object.defineProperty(navigator, "modelContext", {
Object.defineProperty(document, "modelContext", {
value: native,
configurable: true,
enumerable: true,
Expand All @@ -189,11 +195,11 @@ describe("polyfill lifecycle", () => {
);

await waitFor(() => {
expect(navigator.modelContext).toBe(native);
expect(document.modelContext).toBe(native);
});

unmount();
expect(navigator.modelContext).toBe(native);
expect(document.modelContext).toBe(native);
} finally {
deleteNativeModelContext();
}
Expand Down Expand Up @@ -222,12 +228,14 @@ describe("multi-provider lifecycle", () => {

// Unmount first provider — polyfill should still be alive
result1.unmount();
expect(navigator.modelContext).toBeDefined();
expect((navigator.modelContext as Record<string, unknown>).__isWebMCPPolyfill).toBe(true);
expect(document.modelContext).toBeDefined();
expect((document.modelContext as unknown as Record<string, unknown>).__isWebMCPPolyfill).toBe(
true,
);

// Unmount second provider — now polyfill should be cleaned up
result2.unmount();
expect(navigator.modelContext).toBeUndefined();
expect(document.modelContext).toBeUndefined();
});
});

Expand Down Expand Up @@ -308,6 +316,6 @@ describe("Strict Mode", () => {
});

unmount();
expect(navigator.modelContext).toBeUndefined();
expect(document.modelContext).toBeUndefined();
});
});
19 changes: 5 additions & 14 deletions src/__tests__/smoke.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ describe("smoke: SSR + client mount availability", () => {
// ─── Tool registration lifecycle ──────────────────────────────────

describe("smoke: tool registration lifecycle", () => {
it("tool visible in listTools after mount, unregisterTool called on unmount", async () => {
it("tool visible in listTools after mount, removed on unmount", async () => {
const { unmount } = render(
<WebMCPProvider name="smoke" version="1.0">
<SmokeToolComponent
Expand All @@ -107,21 +107,17 @@ describe("smoke: tool registration lifecycle", () => {
expect(tools[0].name).toBe("smoke_greet");
expect(tools[0].description).toBe("Smoke test greeter");

// Spy before unmount — provider cleanup removes the polyfill
const mc = navigator.modelContext;
expect(mc).toBeDefined();
const spy = vi.spyOn(mc as NonNullable<typeof mc>, "unregisterTool");

unmount();

expect(spy).toHaveBeenCalledWith("smoke_greet");
// Provider cleanup removes the polyfill, so the tool is gone.
await waitFor(() => expect(navigator.modelContextTesting?.listTools() ?? []).toHaveLength(0));
});
});

// ─── StrictMode safety ────────────────────────────────────────────

describe("smoke: StrictMode safety", () => {
it("one tool registered after double-mount, clean unregister on real unmount", async () => {
it("one tool registered after double-mount, removed on real unmount", async () => {
const { unmount } = render(
<StrictMode>
<WebMCPProvider name="smoke" version="1.0">
Expand All @@ -143,14 +139,9 @@ describe("smoke: StrictMode safety", () => {
expect(tools).toHaveLength(1);
expect(tools[0].name).toBe("strict_tool");

const mc = navigator.modelContext;
expect(mc).toBeDefined();
const spy = vi.spyOn(mc as NonNullable<typeof mc>, "unregisterTool");

unmount();

expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith("strict_tool");
await waitFor(() => expect(navigator.modelContextTesting?.listTools() ?? []).toHaveLength(0));
});
});

Expand Down
4 changes: 2 additions & 2 deletions src/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,15 @@ function WebMCPProvider({ name, version, children }: WebMCPProviderProps) {

useEffect(() => {
const hasNativeApi =
!!navigator.modelContext && !("__isWebMCPPolyfill" in navigator.modelContext);
!!document.modelContext && !("__isWebMCPPolyfill" in document.modelContext);

if (!hasNativeApi) {
installPolyfill();
usesPolyfillRef.current = true;
polyfillConsumerCount++;
}

setAvailable(!!navigator.modelContext);
setAvailable(!!document.modelContext);

return () => {
if (usesPolyfillRef.current) {
Expand Down
Loading
Loading