diff --git a/extensions/zalouser/src/setup-surface.test.ts b/extensions/zalouser/src/setup-surface.test.ts index 2fcf4c4b769c..881f70fca74b 100644 --- a/extensions/zalouser/src/setup-surface.test.ts +++ b/extensions/zalouser/src/setup-surface.test.ts @@ -304,44 +304,90 @@ describe("zalouser setup wizard", () => { it.each([ { name: "first login", authenticated: false, expectedLogouts: 0 }, { name: "forced re-login", authenticated: true, expectedLogouts: 1 }, - ])("reports $name QR startup failures", async ({ authenticated, expectedLogouts }) => { - checkZaloAuthenticatedMock.mockResolvedValueOnce(authenticated); - logoutZaloProfileMock.mockClear(); - startZaloQrLoginMock.mockClear(); + ])( + "cancels $name when QR startup remains pending", + async ({ authenticated, expectedLogouts }) => { + checkZaloAuthenticatedMock.mockResolvedValueOnce(authenticated); + cancelZaloQrLoginMock.mockClear(); + logoutZaloProfileMock.mockClear(); + startZaloQrLoginMock.mockClear(); + startZaloQrLoginMock.mockResolvedValueOnce({ + message: "Still preparing QR. Call wait to continue checking login status.", + }); + waitForZaloQrLoginMock.mockClear(); + const note = vi.fn(async (_message: string, _title?: string) => {}); + const prompter = createTestWizardPrompter({ + note, + confirm: vi.fn(async ({ message }: { message: string }) => { + if (message === "Login via QR code now?") { + return true; + } + if (message === "Zalo Personal already logged in. Keep session?") { + return false; + } + return false; + }), + }); + + await runSetup({ prompter }); + + expect(note).toHaveBeenCalledWith( + "QR login stopped. Start login again to retry.", + "QR Login", + ); + expect(note).not.toHaveBeenCalledWith( + expect.stringContaining("Call wait"), + expect.anything(), + ); + expect(cancelZaloQrLoginMock).toHaveBeenCalledWith("default"); + expect(cancelZaloQrLoginMock.mock.invocationCallOrder[0]).toBeLessThan( + note.mock.invocationCallOrder.at(-1)!, + ); + expect(logoutZaloProfileMock).toHaveBeenCalledTimes(expectedLogouts); + expect(prompter.confirm).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Did you scan and approve the QR on your phone?" }), + ); + if (expectedLogouts > 0) { + expect(logoutZaloProfileMock.mock.invocationCallOrder[0]).toBeLessThan( + startZaloQrLoginMock.mock.invocationCallOrder[0]!, + ); + } + expect(waitForZaloQrLoginMock).not.toHaveBeenCalled(); + }, + ); + + it("cancels a disconnected QR wait before reporting retry guidance", async () => { + checkZaloAuthenticatedMock.mockResolvedValueOnce(false); + cancelZaloQrLoginMock.mockClear(); startZaloQrLoginMock.mockResolvedValueOnce({ - message: "Failed to start QR login: invalid QR image", + message: "Scan this QR with the Zalo app.", + qrDataUrl: `data:image/png;base64,${PNG_1X1}`, + }); + waitForZaloQrLoginMock.mockResolvedValueOnce({ + connected: false, + message: "Still waiting for QR scan confirmation.", }); - waitForZaloQrLoginMock.mockClear(); const note = vi.fn(async (_message: string, _title?: string) => {}); const prompter = createTestWizardPrompter({ note, confirm: vi.fn(async ({ message }: { message: string }) => { - if (message === "Login via QR code now?") { - return true; - } - if (message === "Zalo Personal already logged in. Keep session?") { - return false; - } - return false; + return ( + message === "Login via QR code now?" || + message === "Did you scan and approve the QR on your phone?" + ); }), }); await runSetup({ prompter }); - expect(note).toHaveBeenCalledWith( - "Failed to start QR login: invalid QR image", - "Login pending", + expect(cancelZaloQrLoginMock).toHaveBeenCalledWith("default"); + expect(note).toHaveBeenLastCalledWith( + "Still waiting for QR scan confirmation.\nQR login stopped. Start login again to retry.", + "QR Login", ); - expect(logoutZaloProfileMock).toHaveBeenCalledTimes(expectedLogouts); - expect(prompter.confirm).not.toHaveBeenCalledWith( - expect.objectContaining({ message: "Did you scan and approve the QR on your phone?" }), + expect(cancelZaloQrLoginMock.mock.invocationCallOrder[0]).toBeLessThan( + note.mock.invocationCallOrder.at(-1)!, ); - if (expectedLogouts > 0) { - expect(logoutZaloProfileMock.mock.invocationCallOrder[0]).toBeLessThan( - startZaloQrLoginMock.mock.invocationCallOrder[0]!, - ); - } - expect(waitForZaloQrLoginMock).not.toHaveBeenCalled(); }); it("prompts DM policy before group access in quickstart", async () => { diff --git a/extensions/zalouser/src/setup-surface.ts b/extensions/zalouser/src/setup-surface.ts index f3d4c4104973..703d4905ec6d 100644 --- a/extensions/zalouser/src/setup-surface.ts +++ b/extensions/zalouser/src/setup-surface.ts @@ -292,7 +292,13 @@ async function runZalouserQrLogin(params: { : {}), }); if (!start.qrDataUrl) { - await params.prompter.note(start.message, t("wizard.zalouser.loginPendingTitle")); + // Setup has no follow-up wait once it returns. Retire ownership before the + // operator-facing note so a late vendor result cannot persist credentials. + cancelZaloQrLogin(params.profile); + await params.prompter.note( + t("wizard.zalouser.qrLoginRetry"), + t("wizard.zalouser.qrLoginTitle"), + ); return; } @@ -340,9 +346,16 @@ async function runZalouserQrLogin(params: { profile: params.profile, timeoutMs: 120_000, }); + if (!waited.connected) { + // A wait timeout deliberately leaves the generic QR session active. Setup + // is terminal here, so release it before any awaited presentation work. + cancelZaloQrLogin(params.profile); + } await params.prompter.note( - waited.message, - waited.connected ? t("common.done") : t("wizard.zalouser.loginPendingTitle"), + waited.connected + ? waited.message + : [waited.message, t("wizard.zalouser.qrLoginRetry")].join("\n"), + waited.connected ? t("common.done") : t("wizard.zalouser.qrLoginTitle"), ); } diff --git a/extensions/zalouser/src/zalo-js.credentials.test.ts b/extensions/zalouser/src/zalo-js.credentials.test.ts index fad906d8ca2b..a30ce596674b 100644 --- a/extensions/zalouser/src/zalo-js.credentials.test.ts +++ b/extensions/zalouser/src/zalo-js.credentials.test.ts @@ -278,7 +278,44 @@ describe("zalouser credential persistence", () => { expect(createZaloMock).toHaveBeenCalledTimes(2); }); - it("cancels the active vendor login before a late result can persist credentials", async () => { + it("fences a late vendor completion after QR startup times out", async () => { + const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-")); + const profile = "qr-start-timeout-cancel"; + let resolveLogin: (api: API) => void = () => undefined; + const loginResult = new Promise((resolve) => { + resolveLogin = resolve; + }); + const api = createMockApi({ + imei: "late-start-imei", + userAgent: "late-start-user-agent", + cookies: [{ key: "zpsid", value: "late-start", domain: "chat.zalo.me" }], + }); + + createZaloMock.mockResolvedValueOnce({ + loginQR: async () => await loginResult, + }); + + try { + await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => { + const started = await startZaloQrLogin({ profile, timeoutMs: 3000 }); + expect(started).toEqual({ + message: "Still preparing QR. Call wait to continue checking login status.", + }); + + cancelZaloQrLogin(profile); + resolveLogin(api); + await new Promise((resolve) => { + setImmediate(resolve); + }); + + expect(loadStoredZaloCredentials(profile)).toBeNull(); + }); + } finally { + await rm(stateDir, { recursive: true, force: true }); + } + }); + + it("fences a late vendor completion after QR wait times out", async () => { const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-")); const profile = "qr-presentation-cancel"; const abort = vi.fn(); @@ -315,6 +352,11 @@ describe("zalouser credential persistence", () => { const started = await startZaloQrLogin({ profile, timeoutMs: 1000 }); expect(started.qrDataUrl).toBe(`data:image/png;base64,${PNG_1X1}`); + await expect(waitForZaloQrLogin({ profile, timeoutMs: 1000 })).resolves.toEqual({ + connected: false, + message: "Still waiting for QR scan confirmation.", + }); + cancelZaloQrLogin(profile); resolveLogin(api); await new Promise((resolve) => { diff --git a/src/wizard/i18n/locales/en.ts b/src/wizard/i18n/locales/en.ts index 090883b93517..2901b640cef4 100644 --- a/src/wizard/i18n/locales/en.ts +++ b/src/wizard/i18n/locales/en.ts @@ -1004,6 +1004,7 @@ export const en = { peersLookupTip: "Tip: use `{command}` to look up people after onboarding.", qrImageSaved: "QR image saved to: {path}", qrImageWriteFailed: "Could not write QR image file; use gateway web login UI instead.", + qrLoginRetry: "QR login stopped. Start login again to retry.", qrLoginTitle: "QR Login", qrScannedPrompt: "Did you scan and approve the QR on your phone?", scanApproveContinue: "Scan + approve on phone, then continue.", diff --git a/src/wizard/i18n/locales/zh-CN.ts b/src/wizard/i18n/locales/zh-CN.ts index be381ed4e945..db913fbf9e98 100644 --- a/src/wizard/i18n/locales/zh-CN.ts +++ b/src/wizard/i18n/locales/zh-CN.ts @@ -972,6 +972,7 @@ export const zh_CN = { peersLookupTip: "提示:onboarding 后用 `{command}` 查找联系人。", qrImageSaved: "二维码图片已保存到:{path}", qrImageWriteFailed: "无法写入二维码图片文件;请改用 gateway web login UI。", + qrLoginRetry: "二维码登录已停止。请重新开始登录以重试。", qrLoginTitle: "二维码登录", qrScannedPrompt: "你已经在手机上扫描并批准二维码了吗?", scanApproveContinue: "在手机上扫描并批准,然后继续。", diff --git a/src/wizard/i18n/locales/zh-TW.ts b/src/wizard/i18n/locales/zh-TW.ts index 56e0cc9cade0..c143027b4c90 100644 --- a/src/wizard/i18n/locales/zh-TW.ts +++ b/src/wizard/i18n/locales/zh-TW.ts @@ -973,6 +973,7 @@ export const zh_TW = { peersLookupTip: "提示:onboarding 後用 `{command}` 查找聯絡人。", qrImageSaved: "QR code 圖片已儲存到:{path}", qrImageWriteFailed: "無法寫入 QR code 圖片檔;請改用 gateway web login UI。", + qrLoginRetry: "QR code 登入已停止。請重新開始登入以重試。", qrLoginTitle: "QR code 登入", qrScannedPrompt: "你已經在手機上掃描並核准 QR code 了嗎?", scanApproveContinue: "在手機上掃描並核准,然後繼續。",