diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index f5d56734..0a57d854 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -1412,6 +1412,39 @@ describe("App (mid-session auth lifecycle events)", () => { await expectFrame(r, "unreachable"); }); + // The wiring is the contract here, not just `AuthTab`'s own behavior: a + // `void`-ing arrow between them resolves instantly, which makes the pending + // state, the repeat lock and the rejection path all inert while revocation + // is still running. Driven through the real App so the arrow cannot come + // back (#2144). + it("holds the pending state until the clear actually settles", async () => { + let settle: () => void = () => {}; + h.clientSpies.clearOAuthTokens.mockImplementation( + () => + new Promise((resolve) => { + settle = () => + resolve({ status: "skipped", reason: "no_endpoint" as const }); + }), + ); + const r = await mount(oneHttp()); + await press(r, ["a", "s"]); + await waitUntil(() => h.clientSpies.clearOAuthTokens.mock.calls.length > 0); + await tick(); + + // Still running: the pending line is up and the confirmation is not. + expect(r.lastFrame() ?? "").toContain("Clearing OAuth state"); + expect(r.lastFrame() ?? "").not.toContain("OAuth state cleared"); + + // And the repeat lock is live, so a second press does not start another. + await press(r, ["s"]); + expect(h.clientSpies.clearOAuthTokens).toHaveBeenCalledTimes(1); + + settle(); + await waitUntil(() => + (r.lastFrame() ?? "").includes("OAuth state cleared"), + ); + }); + it("says nothing when there was nothing to revoke", async () => { const r = await mount(oneHttp()); await press(r, ["a", "s"]); diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index 48e75bab..9270c198 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -1899,9 +1899,12 @@ function App({ focused={ focus === "tabContentList" || focus === "tabContentDetails" } - onClearOAuth={() => { - void handleClearOAuth(); - }} + // Passed directly, NOT wrapped in a `void`-ing arrow: AuthTab + // awaits this to hold its pending state, keep its repeat lock, + // and route a rejection to the failure line. Dropping the + // promise here would resolve it instantly and make all three + // inert while revocation was still running (#2144). + onClearOAuth={handleClearOAuth} connectionStatus={inspectorStatus} /> ) : null}