Fix pending ChatGPT login lifecycle and IPC error handling

This commit is contained in:
amosblomqvist
2026-10-24 14:22:44 +03:00
parent b8abad2b4f
commit 8691470d03
2 changed files with 289 additions and 49 deletions
+130 -47
View File
@@ -13,10 +13,23 @@ const REVOCATION_WARNING = "Signed out locally. Remote revocation was not confir
const empty: ChatGPTStatus = { available: false, pending: false, activeId: null, accounts: [], error: null }; const empty: ChatGPTStatus = { available: false, pending: false, activeId: null, accounts: [], error: null };
type Account = ChatGPTStatus["accounts"][number]; type Account = ChatGPTStatus["accounts"][number];
type Dialog = { kind: "connect" } | { kind: "manage"; accountId: string } | { kind: "warning" }; type Dialog = { kind: "connect" } | { kind: "manage"; accountId: string } | { kind: "warning" };
type LoginAttempt = { generation: number; dialog: Dialog; registration: string; background: boolean; pending: boolean; accountId?: string; phase: "starting" | "pending" | "consent" };
const CONTROL_ERROR = "The ChatGPT connection could not be changed. Try again.";
function isStatus(value: unknown): value is ChatGPTStatus {
if (!value || typeof value !== "object") return false;
const s = value as ChatGPTStatus;
return typeof s.available === "boolean" && typeof s.pending === "boolean" && (s.activeId === null || typeof s.activeId === "string") &&
(s.error === null || typeof s.error === "string") && (s.catalogError == null || typeof s.catalogError === "string") && Array.isArray(s.accounts) &&
s.accounts.every((a) => a && typeof a.id === "string" && typeof a.email === "string" && typeof a.registration === "string" && typeof a.connected === "boolean" && typeof a.background === "boolean" && (a.planEnabled === undefined || typeof a.planEnabled === "boolean"));
}
function replyError(value: unknown): string {
return value && typeof value === "object" && "error" in value && typeof value.error === "string" ? value.error : CONTROL_ERROR;
}
const Context = createContext({ const Context = createContext({
status: empty, status: empty,
busy: false, busy: false,
error: null as string | null, error: null as string | null,
inlineError: null as string | null,
warning: null as string | null, warning: null as string | null,
connect: () => {}, connect: () => {},
manage: (_id: string) => {}, manage: (_id: string) => {},
@@ -34,95 +47,164 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
const [dismissedError, setDismissedError] = useState<string | null>(null); const [dismissedError, setDismissedError] = useState<string | null>(null);
const [background, setBackground] = useState(false); const [background, setBackground] = useState(false);
const [registration, setRegistration] = useState(""); const [registration, setRegistration] = useState("");
const operation = useRef<"login" | "change" | "logout" | null>(null); const operation = useRef<"login" | "cancel" | "change" | "logout" | null>(null);
const generation = useRef(0); const generation = useRef(0);
const statusSequence = useRef(0); const statusSequence = useRef(0);
const mounted = useRef(false); const mounted = useRef(false);
function publishStatus(next: ChatGPTStatus) { statusSequence.current++; setStatus(next); } const dialogRef = useRef<Dialog | null>(null);
const statusRef = useRef(status);
const loginAttempt = useRef<LoginAttempt | null>(null);
const receive = useRef<(value: unknown) => void>(() => {});
function showDialog(next: Dialog | null) { dialogRef.current = next; setDialog(next); }
function publishStatus(value: unknown): ChatGPTStatus | null {
if (!isStatus(value)) { setError(replyError(value)); return null; }
statusSequence.current++; statusRef.current = value; setStatus(value);
return value;
}
function owns(attempt: LoginAttempt) { return mounted.current && loginAttempt.current === attempt && generation.current === attempt.generation; }
function finish(attempt: LoginAttempt) {
if (!owns(attempt)) return;
loginAttempt.current = null; operation.current = null; setBusy(false);
}
async function complete(attempt: LoginAttempt, next: ChatGPTStatus) {
const account = next.accounts.find((a) => a.id === next.activeId && a.connected);
if (next.error || !next.available || !account || (attempt.registration && account.id !== attempt.registration)) {
finish(attempt); setError(next.error || CONTROL_ERROR); return;
}
attempt.phase = "consent"; attempt.accountId = account.id;
try {
if (account.background !== attempt.background) {
operation.current = "change";
const result: unknown = await window.lh!.chatgpt!.consent(account.id, attempt.background);
if (!owns(attempt)) return;
const saved = publishStatus(result);
if (!saved) { finish(attempt); return; }
next = saved;
}
if (!owns(attempt)) return;
const current = next.accounts.find((a) => a.id === account.id && a.id === next.activeId && a.connected);
if (next.error || next.pending || !next.available || !current || current.background !== attempt.background) setError(next.error || CONTROL_ERROR);
else if (dialogRef.current === attempt.dialog) showDialog(null);
} catch { if (owns(attempt)) setError(CONTROL_ERROR); }
finally { finish(attempt); }
}
function reconcile(next: ChatGPTStatus, loginReply = false) {
const attempt = loginAttempt.current;
if (!attempt || !owns(attempt)) return;
if (attempt.phase === "consent") {
if (next.pending || next.error || !next.available || next.activeId !== attempt.accountId || !next.accounts.some((a) => a.id === attempt.accountId && a.connected)) {
finish(attempt); setError(next.error || CONTROL_ERROR);
}
return;
}
if (next.pending) { attempt.pending = true; attempt.phase = "pending"; return; }
// login() normally acknowledges pending immediately; verified completion is
// a later status notification. The acknowledgement itself is not success.
if (attempt.pending || loginReply) void complete(attempt, next);
}
receive.current = (value) => {
const next = publishStatus(value);
if (next) reconcile(next);
};
useEffect(() => { useEffect(() => {
mounted.current = true; mounted.current = true;
const bridge = window.lh?.chatgpt; const bridge = window.lh?.chatgpt;
const refresh = async () => { const refresh = async () => {
const request = ++statusSequence.current; const request = ++statusSequence.current;
try { try {
const next = await bridge?.status(); const next: unknown = await bridge?.status();
if (next && mounted.current && request === statusSequence.current) setStatus(next); if (next && mounted.current && request === statusSequence.current) receive.current(next);
} catch { if (mounted.current) setError("The desktop connection is unavailable. Retry when the app is ready."); } } catch { if (mounted.current && request === statusSequence.current) setError("The desktop connection is unavailable. Retry when the app is ready."); }
}; };
void refresh(); void refresh();
const off = bridge?.onChange(() => void refresh()); const off = bridge?.onChange(() => void refresh());
return () => { mounted.current = false; generation.current++; off?.(); }; return () => { mounted.current = false; generation.current++; loginAttempt.current = null; off?.(); };
}, []); }, []);
const managed = dialog?.kind === "manage" ? status.accounts.find((a) => a.id === dialog.accountId && a.connected) : undefined; const managed = dialog?.kind === "manage" ? status.accounts.find((a) => a.id === dialog.accountId && a.connected) : undefined;
const visibleError = error || (status.error !== dismissedError ? status.error : null) || status.catalogError || null; const visibleError = error || (status.error !== dismissedError ? status.error : null) || status.catalogError || null;
const warning = visibleError === REVOCATION_WARNING ? visibleError : null; const warning = visibleError === REVOCATION_WARNING ? visibleError : null;
function openConnect() { function openConnect() {
if (operation.current) return;
setBackground(false); setBackground(false);
setRegistration(""); setRegistration("");
setError(null); setError(null);
setDismissedError(status.error); setDismissedError(status.error);
setDialog({ kind: "connect" }); showDialog({ kind: "connect" });
} }
function close() { function close() {
if (operation.current === "logout" || operation.current === "change") return; if (operation.current === "logout" || operation.current === "change" || operation.current === "cancel") return;
generation.current++; const cancel = loginAttempt.current !== null || statusRef.current.pending;
setDialog(null); const intent = ++generation.current;
const owner = dialogRef.current;
loginAttempt.current = null;
if (!cancel) showDialog(null);
setError(null); setError(null);
setDismissedError(status.error); setDismissedError(status.error);
if (operation.current === "login") { if (cancel) {
// Closing an in-flight sign-in cancels it and never applies the draft consent. // Keep cancellation serialized even after login's pending IPC reply.
void window.lh?.chatgpt?.cancel().then((next) => { operation.current = "cancel"; setBusy(true);
if (mounted.current) { publishStatus(next); setDismissedError(next.error); } void window.lh?.chatgpt?.cancel().then((value: unknown) => {
}).catch(() => {}); if (!mounted.current || generation.current !== intent) return;
const next = publishStatus(value);
if (next && !next.pending) {
setDismissedError(next.error);
if (dialogRef.current === owner) showDialog(null);
} else if (next) setError(CONTROL_ERROR);
}).catch(() => { if (mounted.current && generation.current === intent) setError(CONTROL_ERROR); }).finally(() => {
if (mounted.current && generation.current === intent) { operation.current = null; setBusy(false); }
});
} }
} }
async function control(kind: "change" | "logout", action: () => Promise<ChatGPTStatus>) { async function control(kind: "change" | "logout", action: () => Promise<ChatGPTStatus>) {
if (operation.current) return; if (operation.current) return;
const intent = ++generation.current;
operation.current = kind; operation.current = kind;
setBusy(true); setError(null); setDismissedError(null); setBusy(true); setError(null); setDismissedError(null);
try { try {
const next = await action(); const value: unknown = await action();
if (!mounted.current) return; if (!mounted.current || intent !== generation.current) return;
publishStatus(next); const next = publishStatus(value);
if (kind === "logout" && !next.accounts.find((a) => a.id === status.activeId && a.connected)) { if (!next) return; // Error-only IPC replies never replace safe status.
setDialog(next.error === REVOCATION_WARNING ? { kind: "warning" } : null); if (kind === "logout" && !next.error && !next.accounts.find((a) => a.id === status.activeId && a.connected)) showDialog(null);
} else setError(next.error); else if (kind === "logout" && next.error === REVOCATION_WARNING) showDialog({ kind: "warning" });
} catch { if (mounted.current) setError("The ChatGPT connection could not be changed. Try again."); } else setError(next.error);
finally { operation.current = null; if (mounted.current) setBusy(false); } } catch { if (mounted.current && intent === generation.current) setError(CONTROL_ERROR); }
finally { if (intent === generation.current) { operation.current = null; if (mounted.current) setBusy(false); } }
} }
async function login() { async function login() {
const bridge = window.lh?.chatgpt; const bridge = window.lh?.chatgpt;
if (!bridge || operation.current || !status.available) return; const owner = dialogRef.current;
const attempt = ++generation.current; if (!bridge || operation.current || !status.available || status.pending || owner?.kind !== "connect") return;
const allowBackground = background; const attempt: LoginAttempt = { generation: ++generation.current, dialog: owner, registration, background, pending: false, phase: "starting" };
operation.current = "login"; loginAttempt.current = attempt; operation.current = "login";
const request = ++statusSequence.current;
setBusy(true); setError(null); setDismissedError(null); setBusy(true); setError(null); setDismissedError(null);
try { try {
let next = await bridge.login(registration || undefined); let value: unknown = await bridge.login(registration || undefined);
if (!mounted.current || attempt !== generation.current) return; if (!owns(attempt) || attempt.phase === "consent") return;
publishStatus(next); // Never publish an old acknowledgement over a newer event. If events
const account = next.accounts.find((a) => a.id === next.activeId && a.connected); // coalesced past pending before the acknowledgement arrived, read the
if (account && !next.error) { // current backend state after it rather than losing that completion.
// Explicit initial choice is applied only after verified successful login. if (request !== statusSequence.current && isStatus(value)) {
// Choosing OFF also revokes any prior consent on a returning registration. if (value.error) { setError(value.error); finish(attempt); return; }
if (account.background !== allowBackground) { attempt.pending = true; attempt.phase = "pending";
operation.current = "change"; const refresh = ++statusSequence.current;
next = await bridge.consent(account.id, allowBackground); value = await bridge.status();
} if (!owns(attempt) || loginAttempt.current?.phase === "consent" || refresh !== statusSequence.current) return;
if (!mounted.current || attempt !== generation.current) return;
publishStatus(next);
if (!next.error) setDialog(null);
} }
setError(next.error); const next = publishStatus(value);
} catch { if (mounted.current && attempt === generation.current) setError("The ChatGPT connection could not be changed. Try again."); } if (!next) { finish(attempt); return; }
finally { operation.current = null; if (mounted.current) setBusy(false); } reconcile(next, true);
} catch { if (owns(attempt)) { setError(CONTROL_ERROR); finish(attempt); } }
// No finally: pending OAuth outlives this IPC promise. Only its status
// completion, failure, or explicit cancellation releases the attempt.
} }
const bridge = typeof window !== "undefined" ? window.lh?.chatgpt : undefined; const bridge = typeof window !== "undefined" ? window.lh?.chatgpt : undefined;
const context = { const context = {
status, busy, error: warning ? null : visibleError, warning: dialog?.kind === "warning" ? null : warning, status, busy, error: warning ? null : visibleError, inlineError: dialog || warning ? null : visibleError, warning: dialog?.kind === "warning" ? null : warning,
connect: openConnect, connect: openConnect,
manage: (id: string) => { setError(null); setDialog({ kind: "manage", accountId: id }); }, manage: (id: string) => { if (!operation.current) { setError(null); showDialog({ kind: "manage", accountId: id }); } },
select: (id: string) => { if (bridge) void control("change", () => bridge.select(id)); }, select: (id: string) => { if (bridge) void control("change", () => bridge.select(id)); },
dismissWarning: () => { setDismissedError(REVOCATION_WARNING); setError(null); }, dismissWarning: () => { setDismissedError(REVOCATION_WARNING); setError(null); },
}; };
@@ -132,7 +214,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
<h2 className="text-sm font-semibold text-foreground">{dialog.kind === "connect" ? "Continue with ChatGPT" : "ChatGPT account"}</h2> <h2 className="text-sm font-semibold text-foreground">{dialog.kind === "connect" ? "Continue with ChatGPT" : "ChatGPT account"}</h2>
{dialog.kind === "warning" ? <> {dialog.kind === "warning" ? <>
<p role="alert" className="mt-3 rounded-sm border border-border px-3 py-2 text-xs text-foreground" data-testid="chatgpt-revocation-warning">{REVOCATION_WARNING}</p> <p role="alert" className="mt-3 rounded-sm border border-border px-3 py-2 text-xs text-foreground" data-testid="chatgpt-revocation-warning">{REVOCATION_WARNING}</p>
<div className="mt-4 flex justify-end"><Button variant="outline" size="sm" onClick={() => { context.dismissWarning(); setDialog(null); }}>Dismiss</Button></div> <div className="mt-4 flex justify-end"><Button variant="outline" size="sm" onClick={() => { context.dismissWarning(); showDialog(null); }}>Dismiss</Button></div>
</> : <> </> : <>
{dialog.kind === "connect" && status.accounts.length > 0 && <label className="mt-3 flex flex-col gap-1 text-xs text-muted-foreground"> {dialog.kind === "connect" && status.accounts.length > 0 && <label className="mt-3 flex flex-col gap-1 text-xs text-muted-foreground">
Saved ChatGPT account Saved ChatGPT account
@@ -219,6 +301,7 @@ export function ChatGPTConnection({ setup = false }: { setup?: boolean }) {
<Button type="button" size="xs" variant="ghost" disabled={ui.busy} onClick={() => ui.manage(active.id)}>Manage…</Button> <Button type="button" size="xs" variant="ghost" disabled={ui.busy} onClick={() => ui.manage(active.id)}>Manage…</Button>
</> : <ContinueButton onClick={ui.connect} disabled={!ui.status.available || ui.busy || ui.status.pending} />} </> : <ContinueButton onClick={ui.connect} disabled={!ui.status.available || ui.busy || ui.status.pending} />}
</div> </div>
{ui.inlineError && <p role="alert" className="text-xs text-red-400">{ui.inlineError}</p>}
{ui.warning && <div role="alert" data-testid="chatgpt-revocation-warning" className="rounded-sm border border-border px-3 py-2 text-xs text-muted-foreground">{ui.warning}<button type="button" className="ml-2 cursor-pointer underline underline-offset-2" onClick={ui.dismissWarning}>Dismiss</button></div>} {ui.warning && <div role="alert" data-testid="chatgpt-revocation-warning" className="rounded-sm border border-border px-3 py-2 text-xs text-muted-foreground">{ui.warning}<button type="button" className="ml-2 cursor-pointer underline underline-offset-2" onClick={ui.dismissWarning}>Dismiss</button></div>}
</div>; </div>;
} }
+159 -2
View File
@@ -20,7 +20,7 @@ const connected = (background = false): ChatGPTStatus => ({ ...disconnected(), a
function deferred<T>() { let resolve!: (v: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { promise, resolve }; } function deferred<T>() { let resolve!: (v: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { promise, resolve }; }
function fixture(initial = disconnected(), overrides: Partial<ChatGPTControlBridge> = {}) { function fixture(initial = disconnected(), overrides: Partial<ChatGPTControlBridge> = {}) {
let status = initial; let status = initial;
const calls = { login: 0, cancel: 0, consent: [] as boolean[], logout: 0 }; const calls = { login: 0, cancel: 0, consent: [] as boolean[], consentIds: [] as string[], logout: 0 };
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
const bridge: ChatGPTControlBridge = { const bridge: ChatGPTControlBridge = {
status: async () => status, status: async () => status,
@@ -28,7 +28,7 @@ function fixture(initial = disconnected(), overrides: Partial<ChatGPTControlBrid
cancel: async () => { calls.cancel++; return status = disconnected(); }, cancel: async () => { calls.cancel++; return status = disconnected(); },
select: async (id) => { status = { ...status, activeId: id }; return status; }, select: async (id) => { status = { ...status, activeId: id }; return status; },
logout: async () => { calls.logout++; return status = disconnected(); }, logout: async () => { calls.logout++; return status = disconnected(); },
consent: async (id, enabled) => { calls.consent.push(enabled); status = { ...status, accounts: status.accounts.map((a) => a.id === id ? { ...a, background: enabled } : a) }; return status; }, consent: async (id, enabled) => { calls.consentIds.push(id); calls.consent.push(enabled); status = { ...status, accounts: status.accounts.map((a) => a.id === id ? { ...a, background: enabled } : a) }; return status; },
onChange: (cb) => { listeners.add(cb); return () => listeners.delete(cb); }, onChange: (cb) => { listeners.add(cb); return () => listeners.delete(cb); },
...overrides, ...overrides,
}; };
@@ -170,3 +170,160 @@ test("foreground model/dictation toolbar has no plan badge or background-consent
assert(!document.body.textContent?.includes("Enable background use")); assert(!document.body.textContent?.includes("Enable background use"));
assert(!document.querySelector('a[href="https://chatgpt.com/settings/usage"]')); assert(!document.querySelector('a[href="https://chatgpt.com/settings/usage"]'));
}); });
const storageError = "ChatGPT credentials could not be saved securely. Reconnect before making requests.";
const errorReply = () => ({ error: storageError } as ChatGPTStatus); // Actual IPC catch envelope, deliberately not a snapshot.
function pendingFixture(initial = disconnected(), overrides: Partial<ChatGPTControlBridge> = {}) {
const f = fixture(initial, { login: async () => { f.calls.login++; const pending = { ...initial, pending: true }; f.push(pending); return pending; }, ...overrides });
return f;
}
test("production lifecycle: Cancel after the immediate pending reply cancels OAuth; later events cannot apply the old choice or close a new dialog", async () => {
const f = pendingFixture(); await mount(); await click(byTest("chatgpt-login"));
await click(document.querySelector<HTMLElement>('[role="switch"]')!); await click(byTest("chatgpt-proceed"));
assert(document.body.textContent?.includes("Waiting for browser sign-in"));
await click(button("Cancel", byTest("chatgpt-account-dialog"))); assert.equal(f.calls.cancel, 1);
await click(byTest("chatgpt-login")); const nextDialog = byTest("chatgpt-account-dialog");
await act(async () => f.push(connected(false)));
assert.deepEqual(f.calls.consent, []); assert.equal(f.calls.login, 1); assert.equal(byTest("chatgpt-account-dialog"), nextDialog);
});
test("production lifecycle: verified onChange applies explicit ON once to the completed account and closes only its disclosure", async () => {
const f = pendingFixture(); await mount(); await click(byTest("chatgpt-login"));
await click(document.querySelector<HTMLElement>('[role="switch"]')!); await click(byTest("chatgpt-proceed"));
assert.equal(f.calls.login, 1); assert.deepEqual(f.calls.consent, []);
assert((byTest("chatgpt-proceed") as HTMLButtonElement).disabled);
await act(async () => f.push(connected(false)));
assert.deepEqual(f.calls.consent, [true]); assert.deepEqual(f.calls.consentIds, ["private-registration-id"]);
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
await act(async () => f.push(connected(true))); assert.deepEqual(f.calls.consent, [true]);
});
test("production lifecycle: initial OFF revokes retained ON only after verified returning-account completion", async () => {
const initial = connected(true); initial.activeId = null; initial.accounts[0]!.connected = false;
const f = pendingFixture(initial); await mount(); await click(byTest("chatgpt-login"));
assert.equal(document.querySelector('[role="switch"]')?.getAttribute("aria-checked"), "false");
await click(byTest("chatgpt-proceed")); assert.deepEqual(f.calls.consent, []);
await act(async () => f.push(connected(true)));
assert.deepEqual(f.calls.consent, [false]); assert.deepEqual(f.calls.consentIds, ["private-registration-id"]);
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
});
test("error-only logout reply preserves valid status, shows recoverable error, never claims disconnect, and permits explicit retry", async () => {
const f = fixture(connected(), { logout: async () => { f.calls.logout++; return errorReply(); } });
await mount(); await click(button("Manage…"));
await assert.doesNotReject(() => click(byTest("chatgpt-disconnect")));
assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError);
assert.equal((byTest("chatgpt-disconnect") as HTMLButtonElement).disabled, false);
assert.equal(document.querySelector('select[aria-label="ChatGPT account"] option')?.textContent, "learner@example.invalid");
assert(!document.querySelector('[data-testid="chatgpt-login"]')); assert(!document.body.textContent?.includes("Signed out locally"));
f.bridge.logout = async () => { f.calls.logout++; return disconnected(); };
await click(byTest("chatgpt-disconnect")); assert.equal(f.calls.logout, 2);
assert(byTest("chatgpt-login")); assert(!document.querySelector('[data-testid="chatgpt-disconnect"]'));
});
test("error-only consent reply leaves the saved toggle unchanged with a contextual error", async () => {
fixture(connected(), { consent: async () => errorReply() }); await mount(); await click(button("Manage…"));
await assert.doesNotReject(() => click(document.querySelector<HTMLElement>('[role="switch"]')!));
assert.equal(document.querySelector('[role="switch"]')?.getAttribute("aria-checked"), "false");
assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError); assert(byTest("chatgpt-disconnect"));
});
test("error-only initial login reply is recoverable without corrupting status or automatically retrying", async () => {
const f = fixture(disconnected(), { login: async () => { f.calls.login++; return errorReply(); } });
await mount(); await click(byTest("chatgpt-login")); await assert.doesNotReject(() => click(byTest("chatgpt-proceed")));
assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError);
assert.equal((byTest("chatgpt-proceed") as HTMLButtonElement).disabled, false); assert.equal(f.calls.login, 1);
assert.deepEqual(f.calls.consent, []);
});
test("error-only consent after verified login keeps disclosure/error open and cannot be retried by a duplicate completion push", async () => {
let consentCalls = 0;
const f = pendingFixture(disconnected(), { consent: async () => { consentCalls++; return errorReply(); } });
await mount(); await click(byTest("chatgpt-login")); await click(document.querySelector<HTMLElement>('[role="switch"]')!); await click(byTest("chatgpt-proceed"));
await act(async () => f.push(connected(false)));
assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError); assert(byTest("chatgpt-account-dialog"));
await act(async () => f.push(connected(false))); assert.equal(consentCalls, 1); assert.equal(f.calls.login, 1);
});
test("failed pending attempt never applies draft consent, starts another login, or closes disclosure on a later clean status", async () => {
const f = pendingFixture(); await mount(); await click(byTest("chatgpt-login")); await click(document.querySelector<HTMLElement>('[role="switch"]')!); await click(byTest("chatgpt-proceed"));
await act(async () => f.push({ ...connected(false), error: "ChatGPT sign-in could not be verified." }));
assert.equal(document.querySelector('[role="alert"]')?.textContent, "ChatGPT sign-in could not be verified.");
await act(async () => f.push(connected(false)));
assert.deepEqual(f.calls.consent, []); assert.equal(f.calls.login, 1); assert(byTest("chatgpt-account-dialog"));
});
test("saved-registration attempt cannot apply its choice to a different active account", async () => {
const f = pendingFixture(); await mount(); await click(byTest("chatgpt-login"));
const select = document.querySelector<HTMLSelectElement>('select[aria-label="Saved ChatGPT account"]')!;
await act(async () => { select.querySelector<HTMLOptionElement>('option[value="signed-out-record"]')!.selected = true; select.dispatchEvent(new window.Event("change", { bubbles: true })); });
await click(document.querySelector<HTMLElement>('[role="switch"]')!); await click(byTest("chatgpt-proceed"));
await act(async () => f.push(connected(false)));
assert.deepEqual(f.calls.consent, []); assert(byTest("chatgpt-account-dialog")); assert(document.querySelector('[role="alert"]'));
});
test("account invalidation while initial consent is awaiting IPC fences its stale reply and preserves an unrelated later Manage dialog", async () => {
const consent = deferred<ChatGPTStatus>();
const f = pendingFixture(disconnected(), { consent: () => consent.promise });
await mount(); await click(byTest("chatgpt-login")); await click(document.querySelector<HTMLElement>('[role="switch"]')!); await click(byTest("chatgpt-proceed"));
await act(async () => f.push(connected(false)));
const other = { ...connected(), activeId: "other", accounts: [account("other", "other@example.invalid")] };
await act(async () => f.push(other));
await click(button("Cancel", byTest("chatgpt-account-dialog"))); await click(button("Manage…")); const manage = byTest("chatgpt-account-dialog");
await act(async () => consent.resolve(connected(true)));
assert.equal(byTest("chatgpt-account-dialog"), manage); assert(manage.textContent?.includes("other@example.invalid"));
assert.equal(document.querySelector('select[aria-label="ChatGPT account"] option')?.textContent, "other@example.invalid");
});
test("verified event before the initial IPC acknowledgement is handled once; the late pending reply cannot reopen or overwrite it", async () => {
const ack = deferred<ChatGPTStatus>();
const f = fixture(disconnected(), { login: () => { f.calls.login++; return ack.promise; } });
await mount(); await click(byTest("chatgpt-login")); await click(byTest("chatgpt-proceed"));
await act(async () => f.push({ ...disconnected(), pending: true }));
await act(async () => f.push(connected(false)));
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
await act(async () => ack.resolve({ ...disconnected(), pending: true }));
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); assert(button("Manage…")); assert.equal(f.calls.login, 1);
});
test("error-only pending cancellation reply remains visible and recoverable without replacing status", async () => {
const f = pendingFixture(disconnected(), { cancel: async () => { f.calls.cancel++; return errorReply(); } });
await mount(); await click(byTest("chatgpt-login")); await click(byTest("chatgpt-proceed"));
await assert.doesNotReject(() => click(button("Cancel", byTest("chatgpt-account-dialog"))));
assert.equal(f.calls.cancel, 1); assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError);
assert(document.querySelector('[data-testid="chatgpt-connection"]')); assert.deepEqual(f.calls.consent, []);
assert(byTest("chatgpt-account-dialog"));
f.bridge.cancel = async () => { f.calls.cancel++; return disconnected(); };
await click(button("Cancel", byTest("chatgpt-account-dialog")));
assert.equal(f.calls.cancel, 2); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
});
test("coalesced completion before the pending acknowledgement is reconciled from fresh post-ack status", async () => {
const ack = deferred<ChatGPTStatus>();
const f = fixture(disconnected(), { login: () => { f.calls.login++; return ack.promise; } });
await mount(); await click(byTest("chatgpt-login")); await click(document.querySelector<HTMLElement>('[role="switch"]')!); await click(byTest("chatgpt-proceed"));
// A status reader can miss pending entirely when two notifications coalesce.
await act(async () => f.push(connected(false))); assert.deepEqual(f.calls.consent, []);
await act(async () => ack.resolve({ ...disconnected(), pending: true }));
assert.deepEqual(f.calls.consent, [true]); assert.deepEqual(f.calls.consentIds, ["private-registration-id"]);
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
});
test("a canceled attempt's late IPC reply cannot release a newer pending attempt or apply its old ON choice", async () => {
const oldAck = deferred<ChatGPTStatus>();
const f = fixture(disconnected(), { login: async () => {
f.calls.login++;
if (f.calls.login === 1) return oldAck.promise;
const pending = { ...disconnected(), pending: true }; f.push(pending); return pending;
} });
await mount(); await click(byTest("chatgpt-login")); await click(document.querySelector<HTMLElement>('[role="switch"]')!); await click(byTest("chatgpt-proceed"));
await act(async () => f.push({ ...disconnected(), pending: true })); await click(button("Cancel", byTest("chatgpt-account-dialog")));
await click(byTest("chatgpt-login")); await click(byTest("chatgpt-proceed"));
await act(async () => oldAck.resolve(connected(false)));
assert((byTest("chatgpt-proceed") as HTMLButtonElement).disabled); assert.deepEqual(f.calls.consent, []);
const next = { ...connected(), activeId: "new-attempt-account", accounts: [account("new-attempt-account", "new@example.invalid", true, true)] };
await act(async () => f.push(next));
assert.deepEqual(f.calls.consent, [false]); assert.deepEqual(f.calls.consentIds, ["new-attempt-account"]);
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); assert.equal(f.calls.login, 2);
});