Skip to content

Commit 9658678

Browse files
authored
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.
1 parent 6fa5d4e commit 9658678

2 files changed

Lines changed: 34 additions & 49 deletions

File tree

src/app-bridge.test.ts

Lines changed: 31 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1907,50 +1907,53 @@ describe("App <-> AppBridge integration", () => {
19071907
describe("pre-handshake guard (claude-ai-mcp#149)", () => {
19081908
const guardMsg =
19091909
/called before connect\(\) completed the ui\/initialize handshake/;
1910+
let warnSpy: ReturnType<typeof spyOn<Console, "warn">>;
1911+
1912+
beforeEach(() => {
1913+
warnSpy = spyOn(console, "warn").mockImplementation(() => {});
1914+
});
1915+
afterEach(() => {
1916+
warnSpy.mockRestore();
1917+
});
1918+
1919+
const warnings = () => warnSpy.mock.calls.map((c) => String(c[0]));
19101920

19111921
it("callServerTool warns when called before connect() completes", async () => {
1912-
const errSpy = spyOn(console, "error").mockImplementation(() => {});
19131922
bridge.oncalltool = async () => ({ content: [] });
19141923
await bridge.connect(bridgeTransport);
19151924

19161925
const connecting = app.connect(appTransport);
19171926
// Handshake is in flight; _initializedSent is still false.
19181927
await app.callServerTool({ name: "t", arguments: {} }).catch(() => {});
19191928

1920-
expect(errSpy).toHaveBeenCalledTimes(1);
1921-
expect(errSpy.mock.calls[0][0]).toMatch(guardMsg);
1922-
expect(errSpy.mock.calls[0][0]).toContain("App.callServerTool()");
1929+
const appSide = warnings().filter((m) => guardMsg.test(m));
1930+
expect(appSide).toHaveLength(1);
1931+
expect(appSide[0]).toContain("App.callServerTool()");
19231932

19241933
await connecting;
1925-
errSpy.mockRestore();
19261934
});
19271935

19281936
it("callServerTool does not warn after connect() resolves", async () => {
1929-
const errSpy = spyOn(console, "error").mockImplementation(() => {});
19301937
bridge.oncalltool = async () => ({ content: [] });
19311938
await bridge.connect(bridgeTransport);
19321939
await app.connect(appTransport);
19331940

19341941
await app.callServerTool({ name: "t", arguments: {} });
19351942

1936-
expect(errSpy).not.toHaveBeenCalled();
1937-
errSpy.mockRestore();
1943+
expect(warnings()).toEqual([]);
19381944
});
19391945

19401946
it("sendMessage and readServerResource also warn before handshake", async () => {
1941-
const errSpy = spyOn(console, "error").mockImplementation(() => {});
1942-
19431947
await app.sendMessage({ role: "user", content: [] }).catch(() => {});
19441948
await app.readServerResource({ uri: "test://r" }).catch(() => {});
19451949

1946-
expect(errSpy).toHaveBeenCalledTimes(2);
1947-
expect(errSpy.mock.calls[0][0]).toContain("App.sendMessage()");
1948-
expect(errSpy.mock.calls[1][0]).toContain("App.readServerResource()");
1949-
errSpy.mockRestore();
1950+
const appSide = warnings().filter((m) => guardMsg.test(m));
1951+
expect(appSide).toHaveLength(2);
1952+
expect(appSide[0]).toContain("App.sendMessage()");
1953+
expect(appSide[1]).toContain("App.readServerResource()");
19501954
});
19511955

19521956
it("throws instead of warning when strict: true", async () => {
1953-
const errSpy = spyOn(console, "error").mockImplementation(() => {});
19541957
const strictApp = new App(
19551958
testAppInfo,
19561959
{},
@@ -1960,78 +1963,65 @@ describe("App <-> AppBridge integration", () => {
19601963
await expect(
19611964
strictApp.callServerTool({ name: "t", arguments: {} }),
19621965
).rejects.toThrow(guardMsg);
1963-
expect(errSpy).not.toHaveBeenCalled();
1964-
1965-
errSpy.mockRestore();
1966+
expect(warnings()).toEqual([]);
19661967
});
19671968

19681969
describe("late handler registration", () => {
19691970
const lateMsg =
19701971
/handler registered after connect\(\) completed the ui\/initialize handshake/;
19711972

19721973
it("warns when ontoolresult is set after connect() resolves", async () => {
1973-
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
19741974
await bridge.connect(bridgeTransport);
19751975
await app.connect(appTransport);
19761976

19771977
app.ontoolresult = () => {};
19781978

1979-
expect(warnSpy).toHaveBeenCalledTimes(1);
1980-
expect(warnSpy.mock.calls[0][0]).toMatch(lateMsg);
1981-
expect(warnSpy.mock.calls[0][0]).toContain('"toolresult"');
1982-
warnSpy.mockRestore();
1979+
expect(warnings()).toHaveLength(1);
1980+
expect(warnings()[0]).toMatch(lateMsg);
1981+
expect(warnings()[0]).toContain('"toolresult"');
19831982
});
19841983

19851984
it("warns when addEventListener('toolinput', …) is called after connect()", async () => {
1986-
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
19871985
await bridge.connect(bridgeTransport);
19881986
await app.connect(appTransport);
19891987

19901988
app.addEventListener("toolinput", () => {});
19911989

1992-
expect(warnSpy).toHaveBeenCalledTimes(1);
1993-
expect(warnSpy.mock.calls[0][0]).toContain('"toolinput"');
1994-
warnSpy.mockRestore();
1990+
expect(warnings()).toHaveLength(1);
1991+
expect(warnings()[0]).toContain('"toolinput"');
19951992
});
19961993

19971994
it("does not warn for handlers set before connect()", async () => {
1998-
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
19991995
app.ontoolinput = () => {};
20001996
app.addEventListener("toolresult", () => {});
20011997

20021998
await bridge.connect(bridgeTransport);
20031999
await app.connect(appTransport);
20042000

2005-
expect(warnSpy).not.toHaveBeenCalled();
2006-
warnSpy.mockRestore();
2001+
expect(warnings()).toEqual([]);
20072002
});
20082003

20092004
it("does not warn for hostcontextchanged (repeating event)", async () => {
2010-
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
20112005
await bridge.connect(bridgeTransport);
20122006
await app.connect(appTransport);
20132007

20142008
app.onhostcontextchanged = () => {};
20152009
app.addEventListener("hostcontextchanged", () => {});
20162010

2017-
expect(warnSpy).not.toHaveBeenCalled();
2018-
warnSpy.mockRestore();
2011+
expect(warnings()).toEqual([]);
20192012
});
20202013

20212014
it("does not warn when clearing a handler (set to undefined)", async () => {
2022-
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
20232015
app.ontoolinput = () => {};
20242016
await bridge.connect(bridgeTransport);
20252017
await app.connect(appTransport);
20262018

20272019
app.ontoolinput = undefined;
20282020

2029-
expect(warnSpy).not.toHaveBeenCalled();
2030-
warnSpy.mockRestore();
2021+
expect(warnings()).toEqual([]);
20312022
});
20322023

20332024
it("throws instead of warning when strict: true", async () => {
2034-
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
20352025
const [strictAppT, strictBridgeT] =
20362026
InMemoryTransport.createLinkedPair();
20372027
const strictBridge = new AppBridge(
@@ -2053,13 +2043,11 @@ describe("App <-> AppBridge integration", () => {
20532043
expect(() => {
20542044
strictApp.addEventListener("toolinput", () => {});
20552045
}).toThrow(lateMsg);
2056-
expect(warnSpy).not.toHaveBeenCalled();
2057-
warnSpy.mockRestore();
2046+
expect(warnings()).toEqual([]);
20582047
});
20592048
});
20602049

20612050
it("AppBridge warns on tools/call from a View that skipped the handshake", async () => {
2062-
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
20632051
bridge.oncalltool = async () => ({ content: [] });
20642052
await bridge.connect(bridgeTransport);
20652053

@@ -2073,23 +2061,20 @@ describe("App <-> AppBridge integration", () => {
20732061
});
20742062
await flush();
20752063

2076-
expect(warnSpy).toHaveBeenCalledTimes(1);
2077-
expect(warnSpy.mock.calls[0][0]).toContain(
2064+
expect(warnings()).toHaveLength(1);
2065+
expect(warnings()[0]).toContain(
20782066
"received 'tools/call' before ui/notifications/initialized",
20792067
);
2080-
warnSpy.mockRestore();
20812068
});
20822069

20832070
it("AppBridge does not warn after initialized is received", async () => {
2084-
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
20852071
bridge.oncalltool = async () => ({ content: [] });
20862072
await bridge.connect(bridgeTransport);
20872073
await app.connect(appTransport);
20882074

20892075
await app.callServerTool({ name: "t", arguments: {} });
20902076

2091-
expect(warnSpy).not.toHaveBeenCalled();
2092-
warnSpy.mockRestore();
2077+
expect(warnings()).toEqual([]);
20932078
});
20942079
});
20952080

src/app.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,12 +176,12 @@ export type AppOptions = ProtocolOptions & {
176176
*/
177177
autoResize?: boolean;
178178
/**
179-
* Throw on detected misuse instead of logging a console error.
179+
* Throw on detected misuse instead of logging a console warning.
180180
*
181181
* Currently this affects calling host-bound methods (e.g.
182182
* {@link App.callServerTool `callServerTool`}, {@link App.sendMessage `sendMessage`})
183183
* before {@link App.connect `connect`} has completed the `ui/initialize`
184-
* handshake. With `strict: false` (default) a `console.error` is emitted;
184+
* handshake. With `strict: false` (default) a `console.warn` is emitted;
185185
* with `strict: true` an `Error` is thrown.
186186
*
187187
* @remarks Throwing will become the default in a future release.
@@ -375,7 +375,7 @@ export class App extends ProtocolWithEvents<
375375
throw new Error(msg);
376376
}
377377
// TODO(next-minor): make `strict: true` the default.
378-
console.error(`${msg}. This will throw in a future release.`);
378+
console.warn(`${msg}. This will throw in a future release.`);
379379
}
380380

381381
protected readonly eventSchemas = {

0 commit comments

Comments
 (0)