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
+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 fixture(initial = disconnected(), overrides: Partial<ChatGPTControlBridge> = {}) {
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 bridge: ChatGPTControlBridge = {
status: async () => status,
@@ -28,7 +28,7 @@ function fixture(initial = disconnected(), overrides: Partial<ChatGPTControlBrid
cancel: async () => { calls.cancel++; return status = disconnected(); },
select: async (id) => { status = { ...status, activeId: id }; return status; },
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); },
...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.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);
});