chore(lint): enable no-floating-promises in all five ESLint scopes

A floated promise is invisible at review time — the call reads like an
awaited one minus four characters — and the unhandled rejection it
produces surfaces in a different test, in a different file, as a stack
pointing at SDK internals. In #1947 two un-held `callTool` calls made
`npm run ci` unpassable and took a full investigation to attribute; the
fix was two lines. `@typescript-eslint/no-floating-promises` catches the
class outright, so enable it at `error` everywhere.

The rule is type-aware and `tseslint.configs.recommended` is not, so each
scope's config gains a parser project. The four clients name their own
tsconfig projects (both of them — `src` and `__tests__`/`server` live in
different ones). The root scope has no tsconfig at all, so add
`tsconfig.lint.json`: a lint-only project covering `core/**`,
`test-servers/src/**`, and `vitest.shared.mts`, emitting nothing and
gating nothing. It sets `moduleResolution: bundler` deliberately — under
NodeNext `core/`'s extensionless imports fail to resolve, every import
degrades to `any`, and the rule silently stops seeing promises at all.

Measured surface, and the fixes:

- core (2) — `stream.close()` in two Hono `onAbort` listeners. Hono
  invokes subscribers with a bare `subscriber()`, so a promise returned
  from the listener is floated one layer up; `closeAbortedStream` owns
  the rejection where it can actually be owned.
- tui (5) — `handleDisconnect` had no catch of its own and its only
  caller is a key handler that cannot await, so a failing disconnect was
  a live unhandled rejection; it now surfaces the message the way
  `handleConnect` does. The rest are `void` at callers that genuinely
  cannot await, over callees that already own their failures.
- web (92) — 83 are Storybook `expect(...)`, which is instrumented and
  returns a promise, now awaited (`scrollAreaStoryAssertions` is async
  for the same reason). Two tests were asserting against a provider call
  they never awaited. `open(url)` in both web servers now warns instead
  of escaping a synchronous callback.
- cli, launcher — already clean.

Type-aware linting costs time: `clients/web`'s `eslint .` goes from ~8s
to ~19s, inside `validate`. Narrowing the loaded projects is the lever if
that needs reducing later; dropping the rule is not.

Closes #1959

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-16 20:57:32 -04:00
commit 519ecf49ee
42 changed files with 359 additions and 111 deletions

View file

@ -16,4 +16,22 @@ export default defineConfig([
globals: globals.node,
},
},
{
// Type-aware pass for `no-floating-promises` (#1959). The rule needs type
// information, which `tseslint.configs.recommended` does not provide, and
// the parser needs a project that literally contains the linted file — so
// both of this client's tsconfig projects are listed, exactly as
// `npm run typecheck` runs them (`src` is in the first, `__tests__` only
// in the second).
files: ["**/*.ts"],
languageOptions: {
parserOptions: {
project: ["./tsconfig.json", "./tsconfig.test.json"],
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"@typescript-eslint/no-floating-promises": "error",
},
},
]);

View file

@ -16,4 +16,19 @@ export default defineConfig([
globals: globals.node,
},
},
{
// Type-aware pass for `no-floating-promises` (#1959) — see the CLI config
// for the reasoning. Both tsconfig projects are listed because `src` and
// `__tests__` live in different ones.
files: ["**/*.{ts,mts}"],
languageOptions: {
parserOptions: {
project: ["./tsconfig.json", "./tsconfig.test.json"],
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"@typescript-eslint/no-floating-promises": "error",
},
},
]);

View file

@ -661,6 +661,35 @@ describe("App (foundation)", () => {
expect(h.disconnect).toHaveBeenCalled();
});
it("surfaces a disconnect failure instead of floating the rejection", async () => {
// 'd' is a key handler, so it cannot await handleDisconnect — the handler
// has to own the failure itself or it escapes as an unhandled rejection
// and fails the whole run from somewhere else (#1959).
// There is nothing to assert on screen — the message only renders once the
// client reports `status: "error"`, which a rejected disconnect does not
// do. The assertion is the run itself: vitest fails on an unhandled
// rejection, so this test passing is the proof the catch owns it.
h.ctrl.status = "connected";
h.disconnect.mockRejectedValue(new Error("discfail"));
const { stdin } = await mount(oneStdio());
stdin.write("d");
await tick();
await tick();
expect(h.disconnect).toHaveBeenCalled();
});
it("owns a non-Error disconnect rejection too", async () => {
// The catch stringifies a non-Error rejection rather than reading
// `.message` off it; a throw here would escape the same way.
h.ctrl.status = "connected";
h.disconnect.mockRejectedValue("plainstring");
const { stdin } = await mount(oneStdio());
stdin.write("d");
await tick();
await tick();
expect(h.disconnect).toHaveBeenCalled();
});
it("switches tabs via accelerator keys", async () => {
const r = await mount(stdioServer());
await press(r, ["t"]); // tools tab (server is auto-selected)

View file

@ -207,7 +207,9 @@ describe("tui tsup config", () => {
// the double cast is bridging a real structural gap, not hiding a mismatch.
// A hook the patch called but the stub lacks fails as undefined here rather
// than passing silently.
inkFormLabelPatch.setup(
// esbuild types `setup` as `void | Promise<void>`; await it so the
// assertions below can't run before an async setup has registered.
await inkFormLabelPatch.setup(
build as unknown as Parameters<typeof inkFormLabelPatch.setup>[0],
);

View file

@ -30,4 +30,19 @@ export default defineConfig([
"react-hooks/exhaustive-deps": "warn",
},
},
{
// Type-aware pass for `no-floating-promises` (#1959) — see the CLI config
// for the reasoning. Both tsconfig projects are listed because `src` and
// `__tests__` live in different ones.
files: ["**/*.{ts,tsx}"],
languageOptions: {
parserOptions: {
project: ["./tsconfig.json", "./tsconfig.test.json"],
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"@typescript-eslint/no-floating-promises": "error",
},
},
]);

View file

@ -887,8 +887,15 @@ function App({
// Disconnect handler
const handleDisconnect = useCallback(async () => {
if (!selectedServer) return;
await disconnectInspector();
// InspectorClient will update status automatically, and data is preserved
try {
await disconnectInspector();
// InspectorClient will update status automatically, and data is preserved
} catch (err) {
// Nothing above this catches: the only caller is the key handler, which
// cannot await. Surface the failure the same way handleConnect does
// rather than letting it escape as an unhandled rejection.
setConnectError(err instanceof Error ? err.message : String(err));
}
}, [selectedServer, disconnectInspector]);
const handleClearOAuth = useCallback(async () => {
@ -1428,12 +1435,15 @@ function App({
input.toLowerCase() === "c" &&
(inspectorStatus === "disconnected" || inspectorStatus === "error")
) {
handleConnect();
// Both handlers own their failures internally (each ends in a catch
// that surfaces the message), so there is nothing left for a key
// handler — which cannot await — to do with the promise.
void handleConnect();
} else if (
input.toLowerCase() === "d" &&
(inspectorStatus === "connected" || inspectorStatus === "connecting")
) {
handleDisconnect();
void handleDisconnect();
}
}
});

View file

@ -53,8 +53,10 @@ export function PromptsTab({
if (selectedPrompt.arguments && selectedPrompt.arguments.length > 0) {
onFetchPrompt(selectedPrompt);
} else {
// No arguments, fetch directly
(async () => {
// No arguments, fetch directly. The IIFE catches everything it can
// throw, so there is no rejection for this key handler — which
// cannot await — to own.
void (async () => {
try {
const invocation = await inspectorClient.getPrompt(
selectedPrompt.name,

View file

@ -186,7 +186,9 @@ export function ResourcesTab({
}
};
fetchContent();
// An effect body is synchronous, and fetchContent already catches
// everything it can throw, so there is no rejection left to own.
void fetchContent();
}, [shouldFetchResource, inspectorClient, onAuthRecoveryRequired]);
const listWidth = Math.floor(width * 0.4);

View file

@ -31,5 +31,32 @@ export default defineConfig([
"react-refresh/only-export-components": "off",
},
},
{
// Type-aware pass for `no-floating-promises` (#1959). The rule needs type
// information, which `tseslint.configs.recommended` does not provide, and
// the parser needs a project that literally contains the linted file — so
// all four leaf projects are listed (`tsconfig.json` itself is a
// solution file with `files: []`, so it contains nothing).
//
// `.d.ts` is excluded: `vitest.shims.d.ts` is pulled in through a `types`
// reference rather than an `include`, so no project owns it, and a
// declaration file cannot float a promise anyway.
files: ["**/*.{ts,tsx}"],
ignores: ["**/*.d.ts"],
languageOptions: {
parserOptions: {
project: [
"./tsconfig.app.json",
"./tsconfig.node.json",
"./tsconfig.storybook.json",
"./tsconfig.test.json",
],
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"@typescript-eslint/no-floating-promises": "error",
},
},
...storybook.configs["flat/recommended"],
]);

View file

@ -125,7 +125,13 @@ export async function startHonoServer(
sandboxUrl ?? undefined,
);
if (config.autoOpen) {
open(url);
// A headless or otherwise browser-less host rejects here. The server
// is already listening and the banner above printed the URL, so warn
// and carry on rather than letting the rejection escape this
// synchronous listen callback and take the process down.
open(url).catch((err: unknown) => {
console.warn("Could not open a browser automatically:", err);
});
}
},
);

View file

@ -114,7 +114,12 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
);
if (config.autoOpen) {
open(url);
// Same as the prod server: a browser-less host rejects, the dev
// server is already up, and the banner above printed the URL — so
// warn rather than let the rejection escape this sync helper.
open(url).catch((err: unknown) => {
console.warn("Could not open a browser automatically:", err);
});
}
};

View file

@ -14,7 +14,7 @@ export const Transport: Story = {
args: { category: "transport" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("transport")).toBeInTheDocument();
await expect(canvas.getByText("transport")).toBeInTheDocument();
},
};
@ -22,6 +22,6 @@ export const Auth: Story = {
args: { category: "auth" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("auth")).toBeInTheDocument();
await expect(canvas.getByText("auth")).toBeInTheDocument();
},
};

View file

@ -24,7 +24,7 @@ export const FullSize: Story = {
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Row 1")).toBeInTheDocument();
await expect(canvas.getByText("Row 1")).toBeInTheDocument();
},
};
@ -38,6 +38,6 @@ export const Embedded: Story = {
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Row 1")).toBeInTheDocument();
await expect(canvas.getByText("Row 1")).toBeInTheDocument();
},
};

View file

@ -15,7 +15,9 @@ export const Collapsed: Story = {
args: { expanded: false },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByRole("button", { name: "Expand" })).toBeInTheDocument();
await expect(
canvas.getByRole("button", { name: "Expand" }),
).toBeInTheDocument();
},
};
@ -23,7 +25,7 @@ export const Expanded: Story = {
args: { expanded: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await expect(
canvas.getByRole("button", { name: "Collapse" }),
).toBeInTheDocument();
},

View file

@ -14,7 +14,7 @@ export const HeaderMismatch: Story = {
args: { code: -32020, name: "HeaderMismatch" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("-32020 HeaderMismatch")).toBeInTheDocument();
await expect(canvas.getByText("-32020 HeaderMismatch")).toBeInTheDocument();
},
};
@ -22,7 +22,7 @@ export const MissingCapability: Story = {
args: { code: -32021, name: "MissingRequiredClientCapability" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await expect(
canvas.getByText("-32021 MissingRequiredClientCapability"),
).toBeInTheDocument();
},
@ -32,7 +32,7 @@ export const UnsupportedVersion: Story = {
args: { code: -32022, name: "UnsupportedProtocolVersion" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await expect(
canvas.getByText("-32022 UnsupportedProtocolVersion"),
).toBeInTheDocument();
},
@ -47,6 +47,6 @@ export const MethodNotFound: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("-32601 MethodNotFound")).toBeInTheDocument();
await expect(canvas.getByText("-32601 MethodNotFound")).toBeInTheDocument();
},
};

View file

@ -14,7 +14,7 @@ export const Outgoing: Story = {
args: { direction: "outgoing" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("client → server")).toBeInTheDocument();
await expect(canvas.getByText("client → server")).toBeInTheDocument();
},
};
@ -22,6 +22,6 @@ export const Incoming: Story = {
args: { direction: "incoming" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("server → client")).toBeInTheDocument();
await expect(canvas.getByText("server → client")).toBeInTheDocument();
},
};

View file

@ -14,7 +14,7 @@ export const ToolsList: Story = {
args: { method: "tools/list" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("tools/list")).toBeInTheDocument();
await expect(canvas.getByText("tools/list")).toBeInTheDocument();
},
};
@ -22,6 +22,6 @@ export const TasksList: Story = {
args: { method: "tasks/list" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("tasks/list")).toBeInTheDocument();
await expect(canvas.getByText("tasks/list")).toBeInTheDocument();
},
};

View file

@ -15,7 +15,7 @@ export const Closed: Story = {
args: { open: false },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await expect(
canvas.getByRole("button", { name: "Open monitoring sidebar" }),
).toBeInTheDocument();
},
@ -25,7 +25,7 @@ export const Open: Story = {
args: { open: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await expect(
canvas.getByRole("button", { name: "Close monitoring sidebar" }),
).toBeInTheDocument();
},

View file

@ -15,7 +15,9 @@ export const Unpinned: Story = {
args: { pinned: false },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByRole("button", { name: "Pin" })).toBeInTheDocument();
await expect(
canvas.getByRole("button", { name: "Pin" }),
).toBeInTheDocument();
},
};
@ -23,6 +25,8 @@ export const Pinned: Story = {
args: { pinned: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByRole("button", { name: "Unpin" })).toBeInTheDocument();
await expect(
canvas.getByRole("button", { name: "Unpin" }),
).toBeInTheDocument();
},
};

View file

@ -14,6 +14,8 @@ type Story = StoryObj<typeof ReplayButton>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByRole("button", { name: "Replay" })).toBeInTheDocument();
await expect(
canvas.getByRole("button", { name: "Replay" }),
).toBeInTheDocument();
},
};

Some files were not shown because too many files have changed in this diff Show more