refactor(app): unify pre-handshake guard to console.warn (#630)
_assertInitialized used console.error while _assertHandlerTiming and the AppBridge override used console.warn. Align all three on console.warn for the non-strict path; strict still throws. Tests: hoist the console.warn spy to beforeEach/afterEach in the pre-handshake describe to avoid bun:test spy stacking now that all three guards target the same console method.
This commit is contained in:
parent
6fa5d4e6ed
commit
9658678eaa
2 changed files with 34 additions and 49 deletions
|
|
@ -1907,9 +1907,18 @@ describe("App <-> AppBridge integration", () => {
|
||||||
describe("pre-handshake guard (claude-ai-mcp#149)", () => {
|
describe("pre-handshake guard (claude-ai-mcp#149)", () => {
|
||||||
const guardMsg =
|
const guardMsg =
|
||||||
/called before connect\(\) completed the ui\/initialize handshake/;
|
/called before connect\(\) completed the ui\/initialize handshake/;
|
||||||
|
let warnSpy: ReturnType<typeof spyOn<Console, "warn">>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
const warnings = () => warnSpy.mock.calls.map((c) => String(c[0]));
|
||||||
|
|
||||||
it("callServerTool warns when called before connect() completes", async () => {
|
it("callServerTool warns when called before connect() completes", async () => {
|
||||||
const errSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
bridge.oncalltool = async () => ({ content: [] });
|
bridge.oncalltool = async () => ({ content: [] });
|
||||||
await bridge.connect(bridgeTransport);
|
await bridge.connect(bridgeTransport);
|
||||||
|
|
||||||
|
|
@ -1917,40 +1926,34 @@ describe("App <-> AppBridge integration", () => {
|
||||||
// Handshake is in flight; _initializedSent is still false.
|
// Handshake is in flight; _initializedSent is still false.
|
||||||
await app.callServerTool({ name: "t", arguments: {} }).catch(() => {});
|
await app.callServerTool({ name: "t", arguments: {} }).catch(() => {});
|
||||||
|
|
||||||
expect(errSpy).toHaveBeenCalledTimes(1);
|
const appSide = warnings().filter((m) => guardMsg.test(m));
|
||||||
expect(errSpy.mock.calls[0][0]).toMatch(guardMsg);
|
expect(appSide).toHaveLength(1);
|
||||||
expect(errSpy.mock.calls[0][0]).toContain("App.callServerTool()");
|
expect(appSide[0]).toContain("App.callServerTool()");
|
||||||
|
|
||||||
await connecting;
|
await connecting;
|
||||||
errSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("callServerTool does not warn after connect() resolves", async () => {
|
it("callServerTool does not warn after connect() resolves", async () => {
|
||||||
const errSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
bridge.oncalltool = async () => ({ content: [] });
|
bridge.oncalltool = async () => ({ content: [] });
|
||||||
await bridge.connect(bridgeTransport);
|
await bridge.connect(bridgeTransport);
|
||||||
await app.connect(appTransport);
|
await app.connect(appTransport);
|
||||||
|
|
||||||
await app.callServerTool({ name: "t", arguments: {} });
|
await app.callServerTool({ name: "t", arguments: {} });
|
||||||
|
|
||||||
expect(errSpy).not.toHaveBeenCalled();
|
expect(warnings()).toEqual([]);
|
||||||
errSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sendMessage and readServerResource also warn before handshake", async () => {
|
it("sendMessage and readServerResource also warn before handshake", async () => {
|
||||||
const errSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
|
|
||||||
await app.sendMessage({ role: "user", content: [] }).catch(() => {});
|
await app.sendMessage({ role: "user", content: [] }).catch(() => {});
|
||||||
await app.readServerResource({ uri: "test://r" }).catch(() => {});
|
await app.readServerResource({ uri: "test://r" }).catch(() => {});
|
||||||
|
|
||||||
expect(errSpy).toHaveBeenCalledTimes(2);
|
const appSide = warnings().filter((m) => guardMsg.test(m));
|
||||||
expect(errSpy.mock.calls[0][0]).toContain("App.sendMessage()");
|
expect(appSide).toHaveLength(2);
|
||||||
expect(errSpy.mock.calls[1][0]).toContain("App.readServerResource()");
|
expect(appSide[0]).toContain("App.sendMessage()");
|
||||||
errSpy.mockRestore();
|
expect(appSide[1]).toContain("App.readServerResource()");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws instead of warning when strict: true", async () => {
|
it("throws instead of warning when strict: true", async () => {
|
||||||
const errSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
const strictApp = new App(
|
const strictApp = new App(
|
||||||
testAppInfo,
|
testAppInfo,
|
||||||
{},
|
{},
|
||||||
|
|
@ -1960,9 +1963,7 @@ describe("App <-> AppBridge integration", () => {
|
||||||
await expect(
|
await expect(
|
||||||
strictApp.callServerTool({ name: "t", arguments: {} }),
|
strictApp.callServerTool({ name: "t", arguments: {} }),
|
||||||
).rejects.toThrow(guardMsg);
|
).rejects.toThrow(guardMsg);
|
||||||
expect(errSpy).not.toHaveBeenCalled();
|
expect(warnings()).toEqual([]);
|
||||||
|
|
||||||
errSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("late handler registration", () => {
|
describe("late handler registration", () => {
|
||||||
|
|
@ -1970,68 +1971,57 @@ describe("App <-> AppBridge integration", () => {
|
||||||
/handler registered after connect\(\) completed the ui\/initialize handshake/;
|
/handler registered after connect\(\) completed the ui\/initialize handshake/;
|
||||||
|
|
||||||
it("warns when ontoolresult is set after connect() resolves", async () => {
|
it("warns when ontoolresult is set after connect() resolves", async () => {
|
||||||
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
||||||
await bridge.connect(bridgeTransport);
|
await bridge.connect(bridgeTransport);
|
||||||
await app.connect(appTransport);
|
await app.connect(appTransport);
|
||||||
|
|
||||||
app.ontoolresult = () => {};
|
app.ontoolresult = () => {};
|
||||||
|
|
||||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
expect(warnings()).toHaveLength(1);
|
||||||
expect(warnSpy.mock.calls[0][0]).toMatch(lateMsg);
|
expect(warnings()[0]).toMatch(lateMsg);
|
||||||
expect(warnSpy.mock.calls[0][0]).toContain('"toolresult"');
|
expect(warnings()[0]).toContain('"toolresult"');
|
||||||
warnSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("warns when addEventListener('toolinput', …) is called after connect()", async () => {
|
it("warns when addEventListener('toolinput', …) is called after connect()", async () => {
|
||||||
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
||||||
await bridge.connect(bridgeTransport);
|
await bridge.connect(bridgeTransport);
|
||||||
await app.connect(appTransport);
|
await app.connect(appTransport);
|
||||||
|
|
||||||
app.addEventListener("toolinput", () => {});
|
app.addEventListener("toolinput", () => {});
|
||||||
|
|
||||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
expect(warnings()).toHaveLength(1);
|
||||||
expect(warnSpy.mock.calls[0][0]).toContain('"toolinput"');
|
expect(warnings()[0]).toContain('"toolinput"');
|
||||||
warnSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not warn for handlers set before connect()", async () => {
|
it("does not warn for handlers set before connect()", async () => {
|
||||||
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
||||||
app.ontoolinput = () => {};
|
app.ontoolinput = () => {};
|
||||||
app.addEventListener("toolresult", () => {});
|
app.addEventListener("toolresult", () => {});
|
||||||
|
|
||||||
await bridge.connect(bridgeTransport);
|
await bridge.connect(bridgeTransport);
|
||||||
await app.connect(appTransport);
|
await app.connect(appTransport);
|
||||||
|
|
||||||
expect(warnSpy).not.toHaveBeenCalled();
|
expect(warnings()).toEqual([]);
|
||||||
warnSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not warn for hostcontextchanged (repeating event)", async () => {
|
it("does not warn for hostcontextchanged (repeating event)", async () => {
|
||||||
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
||||||
await bridge.connect(bridgeTransport);
|
await bridge.connect(bridgeTransport);
|
||||||
await app.connect(appTransport);
|
await app.connect(appTransport);
|
||||||
|
|
||||||
app.onhostcontextchanged = () => {};
|
app.onhostcontextchanged = () => {};
|
||||||
app.addEventListener("hostcontextchanged", () => {});
|
app.addEventListener("hostcontextchanged", () => {});
|
||||||
|
|
||||||
expect(warnSpy).not.toHaveBeenCalled();
|
expect(warnings()).toEqual([]);
|
||||||
warnSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not warn when clearing a handler (set to undefined)", async () => {
|
it("does not warn when clearing a handler (set to undefined)", async () => {
|
||||||
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
||||||
app.ontoolinput = () => {};
|
app.ontoolinput = () => {};
|
||||||
await bridge.connect(bridgeTransport);
|
await bridge.connect(bridgeTransport);
|
||||||
await app.connect(appTransport);
|
await app.connect(appTransport);
|
||||||
|
|
||||||
app.ontoolinput = undefined;
|
app.ontoolinput = undefined;
|
||||||
|
|
||||||
expect(warnSpy).not.toHaveBeenCalled();
|
expect(warnings()).toEqual([]);
|
||||||
warnSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws instead of warning when strict: true", async () => {
|
it("throws instead of warning when strict: true", async () => {
|
||||||
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
||||||
const [strictAppT, strictBridgeT] =
|
const [strictAppT, strictBridgeT] =
|
||||||
InMemoryTransport.createLinkedPair();
|
InMemoryTransport.createLinkedPair();
|
||||||
const strictBridge = new AppBridge(
|
const strictBridge = new AppBridge(
|
||||||
|
|
@ -2053,13 +2043,11 @@ describe("App <-> AppBridge integration", () => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
strictApp.addEventListener("toolinput", () => {});
|
strictApp.addEventListener("toolinput", () => {});
|
||||||
}).toThrow(lateMsg);
|
}).toThrow(lateMsg);
|
||||||
expect(warnSpy).not.toHaveBeenCalled();
|
expect(warnings()).toEqual([]);
|
||||||
warnSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("AppBridge warns on tools/call from a View that skipped the handshake", async () => {
|
it("AppBridge warns on tools/call from a View that skipped the handshake", async () => {
|
||||||
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
||||||
bridge.oncalltool = async () => ({ content: [] });
|
bridge.oncalltool = async () => ({ content: [] });
|
||||||
await bridge.connect(bridgeTransport);
|
await bridge.connect(bridgeTransport);
|
||||||
|
|
||||||
|
|
@ -2073,23 +2061,20 @@ describe("App <-> AppBridge integration", () => {
|
||||||
});
|
});
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
expect(warnings()).toHaveLength(1);
|
||||||
expect(warnSpy.mock.calls[0][0]).toContain(
|
expect(warnings()[0]).toContain(
|
||||||
"received 'tools/call' before ui/notifications/initialized",
|
"received 'tools/call' before ui/notifications/initialized",
|
||||||
);
|
);
|
||||||
warnSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("AppBridge does not warn after initialized is received", async () => {
|
it("AppBridge does not warn after initialized is received", async () => {
|
||||||
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
||||||
bridge.oncalltool = async () => ({ content: [] });
|
bridge.oncalltool = async () => ({ content: [] });
|
||||||
await bridge.connect(bridgeTransport);
|
await bridge.connect(bridgeTransport);
|
||||||
await app.connect(appTransport);
|
await app.connect(appTransport);
|
||||||
|
|
||||||
await app.callServerTool({ name: "t", arguments: {} });
|
await app.callServerTool({ name: "t", arguments: {} });
|
||||||
|
|
||||||
expect(warnSpy).not.toHaveBeenCalled();
|
expect(warnings()).toEqual([]);
|
||||||
warnSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -176,12 +176,12 @@ export type AppOptions = ProtocolOptions & {
|
||||||
*/
|
*/
|
||||||
autoResize?: boolean;
|
autoResize?: boolean;
|
||||||
/**
|
/**
|
||||||
* Throw on detected misuse instead of logging a console error.
|
* Throw on detected misuse instead of logging a console warning.
|
||||||
*
|
*
|
||||||
* Currently this affects calling host-bound methods (e.g.
|
* Currently this affects calling host-bound methods (e.g.
|
||||||
* {@link App.callServerTool `callServerTool`}, {@link App.sendMessage `sendMessage`})
|
* {@link App.callServerTool `callServerTool`}, {@link App.sendMessage `sendMessage`})
|
||||||
* before {@link App.connect `connect`} has completed the `ui/initialize`
|
* before {@link App.connect `connect`} has completed the `ui/initialize`
|
||||||
* handshake. With `strict: false` (default) a `console.error` is emitted;
|
* handshake. With `strict: false` (default) a `console.warn` is emitted;
|
||||||
* with `strict: true` an `Error` is thrown.
|
* with `strict: true` an `Error` is thrown.
|
||||||
*
|
*
|
||||||
* @remarks Throwing will become the default in a future release.
|
* @remarks Throwing will become the default in a future release.
|
||||||
|
|
@ -375,7 +375,7 @@ export class App extends ProtocolWithEvents<
|
||||||
throw new Error(msg);
|
throw new Error(msg);
|
||||||
}
|
}
|
||||||
// TODO(next-minor): make `strict: true` the default.
|
// TODO(next-minor): make `strict: true` the default.
|
||||||
console.error(`${msg}. This will throw in a future release.`);
|
console.warn(`${msg}. This will throw in a future release.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected readonly eventSchemas = {
|
protected readonly eventSchemas = {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue