Fence stale ChatGPT cancellation replies and steady toggle cursor
This commit is contained in:
@@ -161,11 +161,19 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (cancel) {
|
if (cancel) {
|
||||||
// Keep cancellation serialized even after login's pending IPC reply.
|
// Keep cancellation serialized even after login's pending IPC reply.
|
||||||
operation.current = "cancel"; setBusy(true);
|
operation.current = "cancel"; setBusy(true);
|
||||||
void window.lh?.chatgpt?.cancel().then((value: unknown) => {
|
const request = ++statusSequence.current;
|
||||||
|
void window.lh?.chatgpt?.cancel().then(async (value: unknown) => {
|
||||||
if (!mounted.current || generation.current !== intent) return;
|
if (!mounted.current || generation.current !== intent) return;
|
||||||
const next = publishStatus(value);
|
if (!isStatus(value)) { setError(replyError(value)); return; }
|
||||||
|
let next: ChatGPTStatus | null;
|
||||||
|
if (request !== statusSequence.current) {
|
||||||
|
const refresh = ++statusSequence.current;
|
||||||
|
const current: unknown = await window.lh!.chatgpt!.status();
|
||||||
|
if (!mounted.current || generation.current !== intent) return;
|
||||||
|
next = refresh === statusSequence.current ? publishStatus(current) : statusRef.current;
|
||||||
|
} else next = publishStatus(value);
|
||||||
if (next && !next.pending) {
|
if (next && !next.pending) {
|
||||||
setDismissedError(next.error);
|
setDismissedError(value.error); // Dismiss only this cancellation's error, not a newer failure.
|
||||||
if (dialogRef.current === owner) showDialog(null);
|
if (dialogRef.current === owner) showDialog(null);
|
||||||
} else if (next) setError(CONTROL_ERROR);
|
} else if (next) setError(CONTROL_ERROR);
|
||||||
}).catch(() => { if (mounted.current && generation.current === intent) setError(CONTROL_ERROR); }).finally(() => {
|
}).catch(() => { if (mounted.current && generation.current === intent) setError(CONTROL_ERROR); }).finally(() => {
|
||||||
@@ -281,7 +289,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
|||||||
<Button variant="ghost" size="xs" className="cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300" data-testid="chatgpt-disconnect" disabled={busy || status.pending || !managedPresent} onClick={() => dialog.kind === "manage" && showDialog({ ...dialog, confirm: true })}>Disconnect account</Button>
|
<Button variant="ghost" size="xs" className="cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300" data-testid="chatgpt-disconnect" disabled={busy || status.pending || !managedPresent} onClick={() => dialog.kind === "manage" && showDialog({ ...dialog, confirm: true })}>Disconnect account</Button>
|
||||||
</div>
|
</div>
|
||||||
</>}
|
</>}
|
||||||
<div className="mt-4 flex items-center gap-3 text-xs text-foreground" data-testid="chatgpt-global-background">
|
<div className={`mt-4 flex items-center gap-3 text-xs text-foreground ${busy && operation.current === "change" && status.available && !status.pending ? "[&>button:disabled]:cursor-auto" : ""}`} data-testid="chatgpt-global-background">
|
||||||
<Toggle label={BACKGROUND_LABEL} checked={dialog.kind === "connect" ? background : status.allowBackgroundUse} disabled={busy || status.pending || !status.available} onChange={(enabled) => {
|
<Toggle label={BACKGROUND_LABEL} checked={dialog.kind === "connect" ? background : status.allowBackgroundUse} disabled={busy || status.pending || !status.available} onChange={(enabled) => {
|
||||||
if (dialog.kind === "connect") { setBackground(enabled); setBackgroundDirty(true); }
|
if (dialog.kind === "connect") { setBackground(enabled); setBackgroundDirty(true); }
|
||||||
else if (bridge) void control("change", () => bridge.consent(enabled));
|
else if (bridge) void control("change", () => bridge.consent(enabled));
|
||||||
|
|||||||
@@ -120,6 +120,60 @@ test("Cancel after initial IPC promise ends cancels actual OAuth; late completio
|
|||||||
await act(async () => f.complete()); assert.deepEqual(f.calls.consent, []); assert(byTest("chatgpt-account-dialog") === dialog); assert.equal(f.calls.login.length, 1);
|
await act(async () => f.complete()); assert.deepEqual(f.calls.consent, []); assert(byTest("chatgpt-account-dialog") === dialog); assert.equal(f.calls.login.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("review: late cancel acknowledgement must not roll back newer global authority", async () => {
|
||||||
|
const held = deferred<ChatGPTStatus>();
|
||||||
|
const f = fixture(disconnected(true), { cancel: async () => { f.calls.cancel++; return held.promise; } });
|
||||||
|
await mount(); await begin(); await click(button("Cancel", byTest("chatgpt-account-dialog")));
|
||||||
|
await act(async () => f.push(disconnected(false)));
|
||||||
|
await act(async () => held.resolve(disconnected(true)));
|
||||||
|
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
|
||||||
|
assert.equal(f.status.allowBackgroundUse, false, "backend truth remains OFF");
|
||||||
|
await click(byTest("chatgpt-login"));
|
||||||
|
assert.equal(toggle().getAttribute("aria-checked"), "false", "new disclosure must use the newer authoritative global OFF, not the old cancellation snapshot");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("late cancel acknowledgement cannot roll newer global ON back to OFF", async () => {
|
||||||
|
const held = deferred<ChatGPTStatus>(); const f = fixture(disconnected(false), { cancel: () => held.promise });
|
||||||
|
await mount(); await begin(); await click(button("Cancel", byTest("chatgpt-account-dialog")));
|
||||||
|
await act(async () => f.push(disconnected(true))); await act(async () => held.resolve(disconnected(false)));
|
||||||
|
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); assert.equal(f.status.allowBackgroundUse, true);
|
||||||
|
await click(byTest("chatgpt-login")); assert.equal(toggle().getAttribute("aria-checked"), "true"); assert.deepEqual(f.calls.consent, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancel reconciliation read is itself fenced when a newer status arrives before that read returns", async () => {
|
||||||
|
const held = deferred<ChatGPTStatus>(), current = deferred<ChatGPTStatus>();
|
||||||
|
const f = fixture(disconnected(), { cancel: () => held.promise }); let reads = 0;
|
||||||
|
await mount(); await begin(); await click(button("Cancel", byTest("chatgpt-account-dialog")));
|
||||||
|
await act(async () => f.push(disconnected(false)));
|
||||||
|
f.bridge.status = () => { reads++; return current.promise; };
|
||||||
|
await act(async () => held.resolve(disconnected(true))); assert.equal(reads, 1);
|
||||||
|
f.bridge.status = async () => f.status;
|
||||||
|
await act(async () => f.push(disconnected(true))); await act(async () => current.resolve(disconnected(false)));
|
||||||
|
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
|
||||||
|
await click(byTest("chatgpt-login")); assert.equal(toggle().getAttribute("aria-checked"), "true"); assert.deepEqual(f.calls.consent, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("late cancellation preserves newer availability and error instead of dismissing an unrelated failure", async () => {
|
||||||
|
const held = deferred<ChatGPTStatus>(); const f = fixture(disconnected(), { cancel: () => held.promise });
|
||||||
|
await mount(); await begin(); await click(button("Cancel", byTest("chatgpt-account-dialog")));
|
||||||
|
await act(async () => f.push({ ...disconnected(false), available: false, error: storageError }));
|
||||||
|
await act(async () => held.resolve(disconnected(true)));
|
||||||
|
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
|
||||||
|
assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError); assert((byTest("chatgpt-login") as HTMLButtonElement).disabled);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("error-only cancellation reconciliation keeps last valid authority and leaves cancellation recoverable", async () => {
|
||||||
|
const held = deferred<ChatGPTStatus>(); const f = fixture(disconnected(), { cancel: () => held.promise });
|
||||||
|
await mount(); await begin(); await click(button("Cancel", byTest("chatgpt-account-dialog")));
|
||||||
|
await act(async () => f.push(disconnected(false)));
|
||||||
|
f.bridge.status = async () => errorReply(); await act(async () => held.resolve(disconnected(true)));
|
||||||
|
assert(byTest("chatgpt-account-dialog")); assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError);
|
||||||
|
assert.equal((button("Cancel", byTest("chatgpt-account-dialog")) as HTMLButtonElement).disabled, false);
|
||||||
|
f.bridge.status = async () => f.status; f.bridge.cancel = async () => f.status;
|
||||||
|
await click(button("Cancel", byTest("chatgpt-account-dialog")));
|
||||||
|
await click(byTest("chatgpt-login")); assert.equal(toggle().getAttribute("aria-checked"), "false"); assert.deepEqual(f.calls.consent, []);
|
||||||
|
});
|
||||||
|
|
||||||
test("email-only connected selector and Manage remain responsive; account selection never changes global OFF", async () => {
|
test("email-only connected selector and Manage remain responsive; account selection never changes global OFF", async () => {
|
||||||
const initial = connected(false); initial.accounts.push(account("second", "second@example.invalid"), account("missing", ""));
|
const initial = connected(false); initial.accounts.push(account("second", "second@example.invalid"), account("missing", ""));
|
||||||
const f = fixture(initial); await mount();
|
const f = fixture(initial); await mount();
|
||||||
@@ -205,6 +259,32 @@ test("error-only disconnect reply preserves coherent status/confirmation and per
|
|||||||
await click(byTest("chatgpt-confirm-disconnect")); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); assert(byTest("chatgpt-login"));
|
await click(byTest("chatgpt-confirm-disconnect")); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); assert(byTest("chatgpt-login"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const value of [true, false]) test(`global toggle keeps its normal cursor while saving ${!value}, without accepting duplicate clicks`, async () => {
|
||||||
|
const held = deferred<ChatGPTStatus>();
|
||||||
|
const f = fixture(connected(value), { consent: (enabled) => { f.calls.consent.push(enabled); return held.promise; } });
|
||||||
|
await mount(); await click(button("Manage…")); await click(toggle());
|
||||||
|
await act(async () => f.push(connected(!value)));
|
||||||
|
assert((toggle() as HTMLButtonElement).disabled);
|
||||||
|
assert(byTest("chatgpt-global-background").className.includes("[&>button:disabled]:cursor-auto"));
|
||||||
|
assert.equal(toggle().getAttribute("aria-checked"), String(!value));
|
||||||
|
await click(toggle()); assert.deepEqual(f.calls.consent, [!value]);
|
||||||
|
await act(async () => held.resolve(connected(!value)));
|
||||||
|
assert.equal((toggle() as HTMLButtonElement).disabled, false);
|
||||||
|
assert(!byTest("chatgpt-global-background").className.includes("cursor-auto"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("global toggle retains the unavailable cursor if secure availability is lost during a save", async () => {
|
||||||
|
const held = deferred<ChatGPTStatus>(); const f = fixture(connected(), { consent: () => held.promise });
|
||||||
|
await mount(); await click(button("Manage…")); await click(toggle());
|
||||||
|
const denied = connected(false); denied.available = false; denied.error = storageError;
|
||||||
|
denied.accounts = denied.accounts.map((a) => ({ ...a, connected: false, planEnabled: false }));
|
||||||
|
await act(async () => f.push(denied));
|
||||||
|
assert((toggle() as HTMLButtonElement).disabled); assert.match(toggle().className, /cursor-not-allowed/);
|
||||||
|
assert(!byTest("chatgpt-global-background").className.includes("cursor-auto"));
|
||||||
|
await act(async () => held.resolve(connected(false)));
|
||||||
|
assert((toggle() as HTMLButtonElement).disabled); assert(!byTest("chatgpt-global-background").className.includes("cursor-auto"));
|
||||||
|
});
|
||||||
|
|
||||||
test("error-only global consent reply keeps saved state visible and a recoverable error", async () => {
|
test("error-only global consent reply keeps saved state visible and a recoverable error", async () => {
|
||||||
fixture(connected(), { consent: async () => errorReply() }); await mount(); await click(button("Manage…"));
|
fixture(connected(), { consent: async () => errorReply() }); await mount(); await click(button("Manage…"));
|
||||||
await assert.doesNotReject(() => click(toggle())); assert.equal(toggle().getAttribute("aria-checked"), "true"); assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError);
|
await assert.doesNotReject(() => click(toggle())); assert.equal(toggle().getAttribute("aria-checked"), "true"); assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError);
|
||||||
|
|||||||
Reference in New Issue
Block a user