From 4f4b5643d458dcba1efae7f7dab94ffbb84a5360 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 27 Oct 2026 23:44:48 +0200 Subject: [PATCH] test: await completed ChatGPT sign-in and consent saves Replace arbitrary event-loop budgets in two release-gate tests with fresh per-attempt sign-in notifications and the actual consent control reply, bounded by a clear watchdog. Retain all final-state assertions and explicitly verify successful connected/error-free completion. Controlled token/save gates demonstrate both old 100-turn waits return before completion; the corrected waits remain pending and retain the final persistence/UI assertions after release. Add small watchdog result/error/timeout tests. No production, workflow or dependency changes. Isolated checks: focused 87/87, full suite once 2517/2517, full ESLint and typecheck. The full run preceded three runtime-erased non-null array-index annotations; focused tests/typecheck/full ESLint passed again on final source. --- tests/chatgpt-auth.test.ts | 47 +++++++++++++++++++++++----- tests/chatgpt-connection-ui.test.tsx | 46 ++++++++++++++++++++++----- tests/completion.test.ts | 16 ++++++++++ tests/completion.ts | 15 +++++++++ 4 files changed, 109 insertions(+), 15 deletions(-) create mode 100644 tests/completion.test.ts create mode 100644 tests/completion.ts diff --git a/tests/chatgpt-auth.test.ts b/tests/chatgpt-auth.test.ts index 14c3baa..1d23c22 100644 --- a/tests/chatgpt-auth.test.ts +++ b/tests/chatgpt-auth.test.ts @@ -6,6 +6,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { exportJWK, generateKeyPair, SignJWT } from "jose"; +import type { ChatGPTStatus } from "../src/core/contracts/chatgpt.ts"; +import { completedWithin } from "./completion.ts"; const require = createRequire(import.meta.url); const { createChatGPT, fileStore, allowedAuthEndpoint, SCOPES } = require("../electron/chatgpt.cjs"); const { privateKey, publicKey } = await generateKeyPair("RS256"); @@ -611,10 +613,17 @@ test("disconnect persistence failure is not reported as local forgetting and lea } finally { f.service.close(); } }); -test("one app-wide background preference defaults ON over legacy OFF, persists with zero accounts, and never changes on auth/account operations", async () => { +for (const holdToken of [false, true]) test(`one app-wide background preference defaults ON over legacy OFF, persists with zero accounts, and never changes on auth/account operations${holdToken ? " (completion held beyond old wait budget)" : ""}`, async () => { const first = await loggedIn(); first.service.close(); const legacy = structuredClone(first.saved); legacy.registrations[0].background = false; - const f = fixture({ state: legacy }); + const tokenEntered = deferred(), releaseToken = deferred(); + let completion = deferred(); + const change = ({ status }: { status: ChatGPTStatus }) => { if (!status.pending) completion.resolve(status); }; + const f = fixture({ state: legacy, change, token: async (form, url) => { + tokenEntered.resolve(); + if (holdToken) await releaseToken.promise; + return Response.json({ access_token: "scratch-oauth-token", refresh_token: "scratch-refresh", id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: 3600, scope: SCOPES.join(" ") }); + } }); try { assert.equal((await f.service.status()).allowBackgroundUse, true, "absent new setting ignores legacy per-account OFF"); await f.service.lease(true); @@ -624,21 +633,43 @@ test("one app-wide background preference defaults ON over legacy OFF, persists w await f.service.lease(false); const id = f.saved.activeId; await f.service.select(id); assert.equal((await f.service.status()).allowBackgroundUse, false); - await f.service.login(id); await f.callback(); - for (let i = 0; i < 100 && (await f.service.status()).pending; i++) await new Promise((r) => setImmediate(r)); + // A fresh signal per attempt: the fixture's original changed promise + // was already resolved by earlier account/consent notifications. + completion = deferred(); + await f.service.login(id); + const response = await f.callback(); assert.equal(response.status, 200); await response.text(); + const waiting = completedWithin(completion.promise, "returning verified sign-in"); + let settled = false; void waiting.then(() => { settled = true; }, () => { settled = true; }); + if (holdToken) { + await completedWithin(tokenEntered.promise, "held token request"); + // Regression only: exhaust the OLD wait while real completion is + // explicitly held, rather than relying on machine speed or sleeps. + for (let i = 0; i < 100 && (await f.service.status()).pending; i++) await new Promise((r) => setImmediate(r)); + assert.equal((await f.service.status()).pending, true); + assert.equal(f.saved.registrations[0].background, false, "the old assertion would run before replacement was saved"); + assert.equal(settled, false, "the corrected wait must still await completion"); + releaseToken.resolve(); + } + const status = await waiting; + assert.equal(status.pending, false); assert.equal(status.error, null, "reconnecting must actually succeed"); + assert.equal(status.activeId, id); assert.equal(status.accounts[0]!.connected, true); assert.equal(f.saved.allowBackgroundUse, false, "reconnecting cannot overwrite app preference"); assert.equal(f.saved.registrations[0].background, undefined, "new verified registrations omit obsolete per-account preference"); await f.service.disconnect(f.saved.activeId); assert.deepEqual(f.saved.registrations, []); assert.equal(f.saved.allowBackgroundUse, false); - const restarted = fixture({ state: f.saved }); + const restarted = fixture({ state: f.saved, change }); try { assert.equal((await restarted.service.status()).allowBackgroundUse, false, "zero-account restart preserves global OFF"); - await restarted.service.login(); await restarted.callback(); - for (let i = 0; i < 100 && (await restarted.service.status()).pending; i++) await new Promise((r) => setImmediate(r)); + completion = deferred(); + await restarted.service.login(); + const response = await restarted.callback(); assert.equal(response.status, 200); await response.text(); + const status = await completedWithin(completion.promise, "verified sign-in after zero-account restart"); + assert.equal(status.pending, false); assert.equal(status.error, null); + assert.notEqual(status.activeId, null); assert.equal(status.accounts[0]!.connected, true); assert.equal(restarted.saved.allowBackgroundUse, false); await restarted.service.consent(true); assert.equal(restarted.saved.allowBackgroundUse, true); } finally { restarted.service.close(); } - } finally { f.service.close(); } + } finally { releaseToken.resolve(); f.service.close(); } }); test("private leases bind active account, authority revision and raw visible model membership", async () => { diff --git a/tests/chatgpt-connection-ui.test.tsx b/tests/chatgpt-connection-ui.test.tsx index f5f8f74..2edcf2f 100644 --- a/tests/chatgpt-connection-ui.test.tsx +++ b/tests/chatgpt-connection-ui.test.tsx @@ -12,6 +12,7 @@ import { SettingsDialogBody } from "../components/settings/settings-dialog.tsx"; import { KeysSection } from "../components/settings/keys-section.tsx"; import { ModelControl } from "../components/model-picker.tsx"; import type { ChatGPTStatus, ChatGPTControlBridge } from "../src/core/index.ts"; +import { completedWithin } from "./completion.ts"; const dom = parseHTML(""); Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true, localStorage: { getItem: () => null, setItem() {} } }); @@ -23,7 +24,7 @@ const connected = (allowBackgroundUse = true): ChatGPTStatus => ({ ...disconnect const storageError = "ChatGPT credentials could not be read or saved. Check local file access, then reconnect before making requests."; const warning = "Account disconnected locally. Remote revocation could not be confirmed."; const errorReply = () => ({ error: storageError } as ChatGPTStatus); -function deferred() { let resolve!: (v: T) => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; } +function deferred() { let resolve!: (v: T) => void; let reject!: (error: unknown) => void; const promise = new Promise((done, fail) => { resolve = done; reject = fail; }); return { promise, resolve, reject }; } function fixture(initial = disconnected(), overrides: Partial = {}) { let status = initial; const calls = { login: [] as (string | undefined)[], cancel: 0, consent: [] as boolean[], disconnect: [] as string[], select: [] as string[], logout: 0 }; @@ -494,26 +495,57 @@ test("canceled attempt's late reply cannot release a newer pending attempt or ap await act(async () => f.complete()); assert.deepEqual(f.calls.consent, []); assert.equal(f.status.allowBackgroundUse, true); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); }); -test("real main/control bridge: immediate pending, actual cancellation, verified callback, then explicit global draft persistence", async () => { +for (const holdSave of [false, true]) test(`real main/control bridge: immediate pending, actual cancellation, verified callback, then explicit global draft persistence${holdSave ? " (completion held beyond old wait budget)" : ""}`, async () => { const require = createRequire(import.meta.url); const { createChatGPT, SCOPES } = require("../electron/chatgpt.cjs"); const { chatGPTControl } = require("../electron/chatgpt-ipc.cjs"); const { privateKey, publicKey } = await generateKeyPair("RS256"); const jwk = { ...await exportJWK(publicKey), kid: "ui-fixture", alg: "RS256", use: "sig" }; - let saved: any = null, authorize!: URL, tokenCalls = 0; const listeners = new Set<() => void>(); - const service = createChatGPT({ store: { secure() {}, async read() { return saved; }, async write(s: any) { saved = structuredClone(s); } }, openExternal: async (url: string) => { authorize = new URL(url); }, onChange: () => { for (const cb of listeners) cb(); }, fetch: async (url: string, init: RequestInit) => { + const consentCompleted = deferred(), saveEntered = deferred(), releaseSave = deferred(); + let saved: any = null, authorize!: URL, tokenCalls = 0, completionArmed = false; const listeners = new Set<() => void>(); + const service = createChatGPT({ store: { secure() {}, async read() { return saved; }, async write(s: any) { + if (holdSave && s.allowBackgroundUse === false) { saveEntered.resolve(); await releaseSave.promise; } + saved = structuredClone(s); + } }, openExternal: async (url: string) => { authorize = new URL(url); }, onChange: ({ status }: { status: ChatGPTStatus }) => { + if (completionArmed && !status.pending && status.error) consentCompleted.reject(new Error(`Fixture sign-in failed: ${status.error}`)); + for (const cb of listeners) cb(); + }, fetch: async (url: string, init: RequestInit) => { if (url.endsWith("jwks.json")) return Response.json({ keys: [jwk] }); assert.equal(url, "https://auth.openai.com/api/accounts/oauth/token"); tokenCalls++; const form = init.body as URLSearchParams; const jwt = await new SignJWT({ nonce: authorize.searchParams.get("nonce"), email: "fixture@example.invalid" }).setProtectedHeader({ alg: "RS256", kid: "ui-fixture" }).setIssuer("https://auth.openai.com").setSubject("fixture-subject").setAudience(form.get("client_id")!).setIssuedAt().setExpirationTime("1h").sign(privateKey); return Response.json({ access_token: "fixture-access", refresh_token: "fixture-refresh", id_token: jwt, token_type: "Bearer", expires_in: 3600, scope: SCOPES.join(" ") }); } }); - window.lh = { expandWindow() {}, chatgpt: { status: () => chatGPTControl({ op: "status" }, service), login: (id) => chatGPTControl({ op: "login", id }, service), cancel: () => chatGPTControl({ op: "cancel" }, service), select: (id) => chatGPTControl({ op: "select", id }, service), disconnect: (id) => chatGPTControl({ op: "disconnect", id }, service), logout: (id) => chatGPTControl({ op: "logout", id }, service), consent: (enabled) => chatGPTControl({ op: "consent", enabled }, service), onChange: (cb) => { listeners.add(cb); return () => listeners.delete(cb); } } }; + window.lh = { expandWindow() {}, chatgpt: { status: () => chatGPTControl({ op: "status" }, service), login: (id) => chatGPTControl({ op: "login", id }, service), cancel: () => chatGPTControl({ op: "cancel" }, service), select: (id) => chatGPTControl({ op: "select", id }, service), disconnect: (id) => chatGPTControl({ op: "disconnect", id }, service), logout: (id) => chatGPTControl({ op: "logout", id }, service), consent: (enabled) => { + const reply = chatGPTControl({ op: "consent", enabled }, service); + void reply.then(consentCompleted.resolve, consentCompleted.reject); + return reply; + }, onChange: (cb) => { listeners.add(cb); return () => listeners.delete(cb); } } }; try { await mount(); await begin(); assert.equal((await service.status()).pending, true); await click(button("Cancel", byTest("chatgpt-account-dialog"))); assert.equal((await service.status()).pending, false); assert.equal(tokenCalls, 0); await click(byTest("chatgpt-login")); await click(toggle()); await click(byTest("chatgpt-proceed")); const callback = new URL(authorize.searchParams.get("redirect_uri")!); callback.search = new URLSearchParams({ state: authorize.searchParams.get("state")!, code: "fixture-code", client_id: "oaiapp_ui_fixture" }).toString(); - await act(async () => { const response = await originalFetch(callback); await response.text(); for (let i = 0; i < 100 && (saved.allowBackgroundUse !== false || (await service.status()).pending); i++) await new Promise((r) => setImmediate(r)); }); + await act(async () => { + completionArmed = true; + // Callback acknowledgment is not verified login, nor is login completion + // the subsequent consent save. Wait for the actual control reply. + const waiting = completedWithin(consentCompleted.promise, "verified sign-in and global draft persistence"); + let settled = false; void waiting.then(() => { settled = true; }, () => { settled = true; }); + const response = await originalFetch(callback); assert.equal(response.status, 200); await response.text(); + if (holdSave) { + await completedWithin(saveEntered.promise, "held global draft save"); + // Regression only: the OLD loop returns with the save still held. + for (let i = 0; i < 100 && (saved.allowBackgroundUse !== false || (await service.status()).pending); i++) await new Promise((r) => setImmediate(r)); + assert.equal((await service.status()).pending, false, "verification finished before the consent save"); + assert.equal(saved.allowBackgroundUse, undefined, "the old assertion would run before the global draft was saved"); + assert.equal(settled, false, "the corrected wait must still await persistence"); + releaseSave.resolve(); + } + const status = await waiting; + assert.equal(status.error, null); assert.equal(status.pending, false); + assert.equal(status.allowBackgroundUse, false); assert.equal(status.accounts[0]!.connected, true); + }); assert.equal(tokenCalls, 1); assert.equal(saved.allowBackgroundUse, false); assert.equal(saved.registrations[0].background, undefined); + assert.equal(document.querySelector('[role="alert"]'), null); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); assert(button("Manage…")); - } finally { await act(async () => service.close()); } + } finally { releaseSave.resolve(); await act(async () => service.close()); } }); test("browser-only sign-in is disabled; foreground toolbar has no plan badge or background action", async () => { diff --git a/tests/completion.test.ts b/tests/completion.test.ts new file mode 100644 index 0000000..8e5e154 --- /dev/null +++ b/tests/completion.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { completedWithin } from "./completion.ts"; + +test("fixture completion wait returns its result and preserves its actual error", async () => { + assert.equal(await completedWithin(Promise.resolve("saved"), "fixture save"), "saved"); + const failure = new Error("fixture persistence failed"); + await assert.rejects(completedWithin(Promise.reject(failure), "fixture save"), (error) => error === failure); +}); + +test("fixture completion wait fails clearly if completion never arrives", async () => { + await assert.rejects( + completedWithin(new Promise(() => {}), "verified fixture sign-in", 0), + /Timed out after 0ms waiting for verified fixture sign-in/, + ); +}); diff --git a/tests/completion.ts b/tests/completion.ts new file mode 100644 index 0000000..b3404eb --- /dev/null +++ b/tests/completion.ts @@ -0,0 +1,15 @@ +/** Bound an actual fixture completion signal, not a count of event-loop + * turns. Always clear the watchdog, and preserve real fixture failures. */ +export async function completedWithin(completion: Promise, description: string, timeoutMs = 10_000): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + completion, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms waiting for ${description}`)), timeoutMs); + }), + ]); + } finally { + clearTimeout(timer); + } +}