From e0680fdd42be52e042e401d019a615762848b1e4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 27 Aug 2026 14:11:22 -0700 Subject: [PATCH] fix(cli): preserve authored config during channel auth (#131117) * fix(cli): preserve authored channel config during auth * fix(cli): preserve source config for auth selection * test(ui): synchronize task panel hover assertions --- docs/cli/channels.md | 2 + src/cli/channel-auth.test.ts | 220 +++++++++++++++++- src/cli/channel-auth.ts | 105 ++++----- .../chat/chat-responsive.browser.test.ts | 129 +++++----- 4 files changed, 334 insertions(+), 122 deletions(-) diff --git a/docs/cli/channels.md b/docs/cli/channels.md index 424ecf89e8a2..c2e5cecf49e7 100644 --- a/docs/cli/channels.md +++ b/docs/cli/channels.md @@ -171,6 +171,8 @@ openclaw channels logout --channel whatsapp - `channels login` supports `--account ` and `--verbose`; `channels logout` supports `--account `. - `channels login` and `logout` can infer the channel when only one configured channel supports that action; with several, pass `--channel`. - `channels logout` prefers the live Gateway path when reachable, so logout stops any active listener before clearing channel auth state. If a local Gateway is not reachable, it falls back to local auth cleanup; with `gateway.mode: "remote"` the gateway error fails the command instead. +- Logout reports whether the plugin cleared saved auth. If the plugin reports that the account is not logged out, the CLI warns that other credentials may still be active; this is not a claim that provider-side tokens were revoked. +- Login and logout base config changes on the authored source, not runtime defaults. A logout with no credentials to clear does not rewrite config merely because runtime defaults were materialized; intentional plugin enablement or installation changes can still be saved. - After a successful login, the CLI asks a reachable local Gateway to start the account; in remote mode it saves auth locally and notes that the remote runtime was not restarted. - Run `channels login` from a terminal on the gateway host. Agent `exec` blocks this interactive login flow; channel-native agent login tools, such as `whatsapp_login`, should be used from chat when available. diff --git a/src/cli/channel-auth.test.ts b/src/cli/channel-auth.test.ts index b1b984bd7e27..1ecb2486e7ab 100644 --- a/src/cli/channel-auth.test.ts +++ b/src/cli/channel-auth.test.ts @@ -1,5 +1,8 @@ // Channel auth CLI tests cover channel auth command routing and credential prompts. import { beforeEach, describe, expect, it, vi } from "vitest"; +import { materializePluginAutoEnableCandidates } from "../config/plugin-auto-enable.apply.js"; +import { makeRegistry } from "../config/plugin-auto-enable.test-helpers.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { runChannelLogin, runChannelLogout } from "./channel-auth.js"; const mocks = vi.hoisted(() => ({ @@ -140,9 +143,15 @@ describe("channel-auth", () => { mocks.getChannelPluginCatalogEntry.mockReturnValue(undefined); mocks.listChannelPluginCatalogEntries.mockReturnValue([]); mocks.loadConfig.mockReturnValue({ channels: { whatsapp: {} } }); - mocks.readConfigFileSnapshot.mockResolvedValue({ hash: "config-1" }); + mocks.readConfigFileSnapshot.mockImplementation(async () => ({ + hash: "config-1", + valid: true, + sourceConfig: mocks.loadConfig(), + })); mocks.applyPluginAutoEnable.mockImplementation(({ config }) => ({ config, changes: [] })); - mocks.replaceConfigFile.mockResolvedValue(undefined); + mocks.replaceConfigFile.mockImplementation(async ({ nextConfig }) => { + mocks.loadConfig.mockReturnValue(nextConfig); + }); mocks.commitConfigWithPendingPluginInstalls.mockImplementation( async ({ nextConfig, @@ -182,7 +191,7 @@ describe("channel-auth", () => { }; }, ); - mocks.callGateway.mockResolvedValue({ ok: true }); + mocks.callGateway.mockResolvedValue({ cleared: true, loggedOut: true }); mocks.listChannelPlugins.mockReturnValue([plugin]); mocks.resolveDefaultAgentId.mockReturnValue("main"); mocks.resolveAgentWorkspaceDir.mockReturnValue("/tmp/workspace"); @@ -199,7 +208,114 @@ describe("channel-auth", () => { }); mocks.resolveAccount.mockReturnValue({ id: "resolved-account" }); mocks.login.mockResolvedValue(undefined); - mocks.logoutAccount.mockResolvedValue(undefined); + mocks.logoutAccount.mockResolvedValue({ cleared: true, loggedOut: true }); + }); + + it.each([ + ["login", runChannelLogin, mocks.login], + ["logout", runChannelLogout, mocks.logoutAccount], + ] as const)( + "uses source intent and the active runtime snapshot for %s", + async (_mode, run, action) => { + const sourceConfig: OpenClawConfig = { channels: { whatsapp: {} } }; + mocks.readConfigFileSnapshot.mockResolvedValue({ + hash: "config-1", + valid: true, + sourceConfig, + }); + const runtimeConfig: OpenClawConfig = { + ...sourceConfig, + agents: { defaults: { maxConcurrent: 4 } }, + plugins: { entries: { "memory-core": { config: {} } } }, + }; + mocks.loadConfig.mockReturnValue(runtimeConfig); + mocks.callGateway.mockRejectedValue(new Error("gateway unreachable")); + + await run({ channel: "whatsapp" }, runtime); + + expect(mocks.applyPluginAutoEnable).toHaveBeenCalledWith({ + config: sourceConfig, + env: process.env, + }); + expect(readFirstCallArg(action).cfg).toBe(runtimeConfig); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ["login", runChannelLogin, mocks.login], + ["logout", runChannelLogout, mocks.logoutAccount], + ] as const)("uses runtime account callbacks when inferring %s", async (_mode, run, action) => { + const sourceConfig: OpenClawConfig = { + channels: { whatsapp: { accounts: { work: { authDir: "~/wa-work", enabled: true } } } }, + }; + const runtimeConfig: OpenClawConfig = { + channels: { + whatsapp: { accounts: { work: { authDir: "/runtime/wa-work", enabled: true } } }, + }, + agents: { defaults: { maxConcurrent: 4 } }, + }; + const listAccountIds = vi.fn((cfg: OpenClawConfig) => + Object.keys(cfg.channels?.whatsapp?.accounts ?? {}), + ); + const resolveAccount = vi.fn( + (cfg: OpenClawConfig, accountId: string) => cfg.channels?.whatsapp?.accounts?.[accountId], + ); + const isEnabled = vi.fn( + (account: { enabled?: boolean } | undefined, cfg: OpenClawConfig) => + cfg.channels?.whatsapp?.enabled !== false && account?.enabled !== false, + ); + const selectedPlugin = { ...plugin, config: { listAccountIds, resolveAccount, isEnabled } }; + mocks.listChannelPlugins.mockReturnValue([selectedPlugin]); + mocks.getLoadedChannelPlugin.mockReturnValue(selectedPlugin); + mocks.readConfigFileSnapshot.mockResolvedValue({ hash: "config-1", valid: true, sourceConfig }); + mocks.loadConfig.mockReturnValue(runtimeConfig); + mocks.callGateway.mockRejectedValue(new Error("gateway unreachable")); + + await run({ account: "work" }, runtime); + + expect(readFirstCallArg(action).cfg).toBe(runtimeConfig); + expect(listAccountIds.mock.calls[0]?.[0]).toBe(runtimeConfig); + expect(resolveAccount.mock.calls[0]?.[0]).toBe(runtimeConfig); + expect(isEnabled.mock.calls[0]?.[1]).toBe(runtimeConfig); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); + }); + + it("keeps repeated credential-free logout free of runtime-only plugin activation writes", async () => { + const sourceConfig: OpenClawConfig = { + channels: { whatsapp: { enabled: false } }, + plugins: { allow: ["whatsapp"], entries: { whatsapp: { enabled: true } } }, + }; + mocks.readConfigFileSnapshot.mockResolvedValue({ hash: "config-1", valid: true, sourceConfig }); + mocks.applyPluginAutoEnable.mockImplementation(({ config }: { config: OpenClawConfig }) => + materializePluginAutoEnableCandidates({ + config, + candidates: [], + manifestRegistry: makeRegistry([{ id: "memory-core", channels: [], origin: "bundled" }]), + }), + ); + mocks.callGateway.mockRejectedValue(new Error("gateway unreachable")); + mocks.logoutAccount.mockResolvedValue({ cleared: false, loggedOut: true }); + mocks.loadConfig.mockReturnValue(sourceConfig); + + await runChannelLogout({ channel: "whatsapp" }, runtime); + + // Runtime plugin schema defaults can appear on a later invocation. + const laterRuntimeConfig: OpenClawConfig = { + ...sourceConfig, + plugins: { + ...sourceConfig.plugins, + entries: { ...sourceConfig.plugins?.entries, "memory-core": { config: {} } }, + }, + }; + mocks.loadConfig.mockReturnValue(laterRuntimeConfig); + await runChannelLogout({ channel: "whatsapp" }, runtime); + + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); + expect(mocks.logoutAccount.mock.calls.map(([context]) => context.cfg)).toEqual([ + sourceConfig, + laterRuntimeConfig, + ]); }); it("runs login with explicit trimmed account and verbose flag", async () => { @@ -330,24 +446,52 @@ describe("channel-auth", () => { }); it("auto-picks the single auth-capable channel from the auto-enabled config snapshot", async () => { - const autoEnabledCfg = { channels: { whatsapp: {} }, plugins: { allow: ["whatsapp"] } }; - mocks.loadConfig.mockReturnValue({}); - mocks.applyPluginAutoEnable.mockReturnValue({ config: autoEnabledCfg, changes: ["whatsapp"] }); + const sourceConfig: OpenClawConfig = { + channels: { whatsapp: {} }, + plugins: { allow: ["whatsapp"] }, + }; + const autoEnabledCfg = { + ...sourceConfig, + channels: { whatsapp: { enabled: true } }, + }; + const runtimeConfig = { ...sourceConfig, agents: { defaults: { maxConcurrent: 4 } } }; + const refreshedRuntimeConfig = { + ...autoEnabledCfg, + agents: { defaults: { maxConcurrent: 4 } }, + }; + mocks.readConfigFileSnapshot.mockResolvedValue({ hash: "config-1", valid: true, sourceConfig }); + mocks.loadConfig.mockReturnValue(runtimeConfig); + mocks.applyPluginAutoEnable.mockImplementation(({ config }: { config: OpenClawConfig }) => + materializePluginAutoEnableCandidates({ + config, + candidates: [{ pluginId: "whatsapp", kind: "channel-configured", channelId: "whatsapp" }], + manifestRegistry: makeRegistry([ + { id: "whatsapp", channels: ["whatsapp"], origin: "bundled" }, + ]), + }), + ); + mocks.resolveAccount.mockImplementation((cfg: OpenClawConfig) => ({ + enabled: cfg.channels?.whatsapp?.enabled === true, + })); + mocks.replaceConfigFile.mockImplementation(async () => { + mocks.loadConfig.mockReturnValue(refreshedRuntimeConfig); + }); await runChannelLogin({}, runtime); expect(mocks.applyPluginAutoEnable).toHaveBeenCalledWith({ - config: {}, + config: sourceConfig, env: process.env, }); expectFields(readFirstCallArg(mocks.login), { - cfg: autoEnabledCfg, + cfg: refreshedRuntimeConfig, channelInput: "whatsapp", }); expect(mocks.replaceConfigFile).toHaveBeenCalledWith({ nextConfig: autoEnabledCfg, baseHash: "config-1", }); + expect(mocks.resolveAccount.mock.calls[0]?.[0]).toEqual(refreshedRuntimeConfig); }); it("persists auto-enabled config during logout auto-pick too", async () => { @@ -397,6 +541,15 @@ describe("channel-auth", () => { }, }; mocks.loadConfig.mockReturnValue({ channels: { whatsapp: {}, zalouser: {} } }); + mocks.applyPluginAutoEnable.mockImplementation(({ config }: { config: OpenClawConfig }) => + materializePluginAutoEnableCandidates({ + config, + candidates: [{ pluginId: "whatsapp", kind: "channel-configured", channelId: "whatsapp" }], + manifestRegistry: makeRegistry([ + { id: "whatsapp", channels: ["whatsapp"], origin: "bundled" }, + ]), + }), + ); mocks.listChannelPlugins.mockReturnValue([plugin, zaloPlugin]); mocks.normalizeChannelId.mockImplementation((value) => value); mocks.getLoadedChannelPlugin.mockImplementation((value) => @@ -411,6 +564,7 @@ describe("channel-auth", () => { "Multiple configured channels support login: whatsapp, zalouser.", ); expect(mocks.login).not.toHaveBeenCalled(); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); }); it("ignores plugins with prototype-chain IDs like __proto__", async () => { @@ -646,6 +800,54 @@ describe("channel-auth", () => { expect(mocks.setVerbose).not.toHaveBeenCalled(); }); + it.each([ + ["gateway", { cleared: true, loggedOut: true }, "Cleared saved auth for whatsapp/acct-2."], + ["local", { cleared: true, loggedOut: true }, "Cleared saved auth for whatsapp/acct-2."], + [ + "gateway", + { cleared: false, loggedOut: true }, + "No saved auth was cleared for whatsapp/acct-2.", + ], + [ + "local", + { cleared: false, loggedOut: true }, + "No saved auth was cleared for whatsapp/acct-2.", + ], + [ + "gateway", + { cleared: true, loggedOut: false }, + "Cleared saved auth for whatsapp/acct-2. Other credentials may still be active.", + ], + [ + "local", + { cleared: false, loggedOut: false }, + "No saved auth was cleared for whatsapp/acct-2. Other credentials may still be active.", + ], + ] as const)("reports the completed %s logout result %j", async (route, result, message) => { + if (route === "local") { + mocks.callGateway.mockRejectedValue(new Error("gateway unreachable")); + } else { + mocks.callGateway.mockResolvedValue(result); + } + mocks.logoutAccount.mockResolvedValue(result); + + await runChannelLogout({ channel: "whatsapp", account: "acct-2" }, runtime); + + expect(runtime.log).toHaveBeenLastCalledWith(message); + }); + + it("does not report completion or clear local auth when remote logout fails", async () => { + mocks.loadConfig.mockReturnValue({ gateway: { mode: "remote" }, channels: { whatsapp: {} } }); + mocks.callGateway.mockRejectedValue(new Error("remote gateway unreachable")); + + await expect(runChannelLogout({ channel: "whatsapp" }, runtime)).rejects.toThrow( + "remote gateway unreachable", + ); + + expect(mocks.logoutAccount).not.toHaveBeenCalled(); + expect(runtime.log).not.toHaveBeenCalled(); + }); + it("throws when channel does not support logout", async () => { mocks.getLoadedChannelPlugin.mockReturnValueOnce({ auth: { login: mocks.login }, diff --git a/src/cli/channel-auth.ts b/src/cli/channel-auth.ts index 2e99c9cf3955..964ea7012b6d 100644 --- a/src/cli/channel-auth.ts +++ b/src/cli/channel-auth.ts @@ -9,7 +9,8 @@ import { normalizeChannelId, } from "../channels/plugins/index.js"; import { resolveInstallableChannelPlugin } from "../commands/channel-setup/channel-plugin-resolution.js"; -import { getRuntimeConfig, readConfigFileSnapshot, type OpenClawConfig } from "../config/config.js"; +import { requireValidConfigFileSnapshot } from "../commands/config-validation.js"; +import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { callGateway } from "../gateway/call.js"; import { setVerbose } from "../globals.js"; @@ -68,7 +69,9 @@ function isConfiguredAuthPlugin(plugin: ChannelPlugin, cfg: OpenClawConfig): boo return false; } -function resolveConfiguredAuthChannelInput(cfg: OpenClawConfig, mode: ChannelAuthMode): string { +function resolveConfiguredAuthChannelInput(mode: ChannelAuthMode): string { + // Account callbacks need runtime values; this auto-enabled view is never persisted. + const cfg = applyPluginAutoEnable({ config: getRuntimeConfig(), env: process.env }).config; const configured = listChannelPlugins() .filter((plugin): plugin is ChannelPlugin => supportsChannelAuthMode(plugin, mode)) .filter((plugin) => isConfiguredAuthPlugin(plugin, cfg)) @@ -91,17 +94,22 @@ function resolveConfiguredAuthChannelInput(cfg: OpenClawConfig, mode: ChannelAut async function resolveChannelPluginForMode( opts: ChannelAuthOptions, mode: ChannelAuthMode, - cfg: OpenClawConfig, runtime: RuntimeEnv, ): Promise<{ cfg: OpenClawConfig; - configChanged: boolean; channelInput: string; channelId: string; plugin: ChannelPlugin; -}> { +} | null> { + const snapshot = await requireValidConfigFileSnapshot(runtime); + if (!snapshot) { + return null; + } + // Runtime defaults are not authored plugin enablement intent. + const autoEnabled = applyPluginAutoEnable({ config: snapshot.sourceConfig, env: process.env }); + const cfg = autoEnabled.config; const explicitChannel = opts.channel?.trim(); - const channelInput = explicitChannel || resolveConfiguredAuthChannelInput(cfg, mode); + const channelInput = explicitChannel || resolveConfiguredAuthChannelInput(mode); const normalizedChannelId = normalizeChannelId(channelInput); const resolved = await resolveInstallableChannelPlugin({ @@ -128,9 +136,15 @@ async function resolveChannelPluginForMode( }), ); } + if (autoEnabled.changes.length > 0 || resolved.configChanged) { + await commitConfigWithPendingPluginInstalls({ + nextConfig: resolved.cfg, + baseHash: snapshot.hash, + }); + } return { - cfg: resolved.cfg, - configChanged: resolved.configChanged, + // Execution needs resolved runtime values; successful writes refresh this snapshot. + cfg: getRuntimeConfig(), channelInput, channelId, plugin, @@ -218,9 +232,9 @@ async function logoutViaGatewayRuntime(params: { channelId: string; accountId: string; runtime: RuntimeEnv; -}): Promise { +}) { try { - await callGateway({ + return await callGateway<{ cleared: boolean; loggedOut?: boolean }>({ config: params.cfg, method: "channels.logout", params: { @@ -231,7 +245,6 @@ async function logoutViaGatewayRuntime(params: { clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, deviceIdentity: null, }); - return true; } catch (error) { if (params.cfg.gateway?.mode === "remote") { throw error; @@ -239,7 +252,7 @@ async function logoutViaGatewayRuntime(params: { params.runtime.log( `Local logout will clear auth for ${params.channelId}/${params.accountId}, but the running gateway did not stop it: ${formatErrorMessage(error)}`, ); - return false; + return null; } } @@ -247,22 +260,11 @@ export async function runChannelLogin( opts: ChannelAuthOptions, runtime: RuntimeEnv = defaultRuntime, ) { - const sourceSnapshotPromise = readConfigFileSnapshot().catch(() => null); - const autoEnabled = applyPluginAutoEnable({ - config: getRuntimeConfig(), - env: process.env, - }); - const loadedCfg = autoEnabled.config; - const resolvedChannel = await resolveChannelPluginForMode(opts, "login", loadedCfg, runtime); - let cfg = resolvedChannel.cfg; - const { configChanged, channelInput, plugin } = resolvedChannel; - if (autoEnabled.changes.length > 0 || configChanged) { - const committed = await commitConfigWithPendingPluginInstalls({ - nextConfig: cfg, - baseHash: (await sourceSnapshotPromise)?.hash, - }); - cfg = committed.config; + const resolvedChannel = await resolveChannelPluginForMode(opts, "login", runtime); + if (!resolvedChannel) { + return; } + const { cfg, channelInput, plugin } = resolvedChannel; const login = plugin.auth?.login; if (!login) { throw new Error( @@ -296,22 +298,11 @@ export async function runChannelLogout( opts: ChannelAuthOptions, runtime: RuntimeEnv = defaultRuntime, ) { - const sourceSnapshotPromise = readConfigFileSnapshot().catch(() => null); - const autoEnabled = applyPluginAutoEnable({ - config: getRuntimeConfig(), - env: process.env, - }); - const loadedCfg = autoEnabled.config; - const resolvedChannel = await resolveChannelPluginForMode(opts, "logout", loadedCfg, runtime); - let cfg = resolvedChannel.cfg; - const { configChanged, channelInput, plugin } = resolvedChannel; - if (autoEnabled.changes.length > 0 || configChanged) { - const committed = await commitConfigWithPendingPluginInstalls({ - nextConfig: cfg, - baseHash: (await sourceSnapshotPromise)?.hash, - }); - cfg = committed.config; + const resolvedChannel = await resolveChannelPluginForMode(opts, "logout", runtime); + if (!resolvedChannel) { + return; } + const { cfg, channelInput, plugin } = resolvedChannel; const logoutAccount = plugin.gateway?.logoutAccount; if (!logoutAccount) { throw new Error( @@ -324,21 +315,25 @@ export async function runChannelLogout( } // Prefer the live gateway so logout also stops any active channel runtime. const { accountId } = resolveAccountContext(plugin, opts, cfg); - if ( - await logoutViaGatewayRuntime({ - cfg, - channelId: plugin.id, - accountId, - runtime, - }) - ) { - return; - } - const account = plugin.config.resolveAccount(cfg, accountId); - await logoutAccount({ + let result = await logoutViaGatewayRuntime({ cfg, + channelId: plugin.id, accountId, - account, runtime, }); + if (!result) { + const account = plugin.config.resolveAccount(cfg, accountId); + result = await logoutAccount({ + cfg, + accountId, + account, + runtime, + }); + } + const scope = sanitizeForLog(`${plugin.id}/${accountId}`); + runtime.log( + `${result.cleared ? "Cleared saved auth" : "No saved auth was cleared"} for ${scope}.${ + result.loggedOut === false ? " Other credentials may still be active." : "" + }`, + ); } diff --git a/ui/src/pages/chat/chat-responsive.browser.test.ts b/ui/src/pages/chat/chat-responsive.browser.test.ts index 63e39da024a3..7d093c643563 100644 --- a/ui/src/pages/chat/chat-responsive.browser.test.ts +++ b/ui/src/pages/chat/chat-responsive.browser.test.ts @@ -4095,36 +4095,62 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { const card = page.locator(".session-progress-card--composer"); const list = page.locator(".session-progress-card__steps"); const widthBefore = (await card.boundingBox())?.width; - const expandedBefore = await page.evaluate(() => { - const style = (selector: string) => - getComputedStyle(document.querySelector(selector)!); - const bounds = (selector: string) => - document.querySelector(selector)!.getBoundingClientRect(); - return { - cardBackground: style(".session-progress-card--composer").backgroundColor, - summaryBackground: style(".session-progress-card__summary").backgroundColor, - titleColor: style(".session-progress-card__summary-title").color, - actionsColor: style(".session-progress-card__heading-actions").color, - chevronColor: style(".session-progress-card__summary-chevron").color, - titleLeft: bounds(".session-progress-card__summary-title").left, - firstMarkerLeft: bounds(".session-progress-card__step-marker").left, - y: bounds(".session-progress-card__summary").y, - }; - }); + const readSummaryState = () => + page.evaluate(() => { + const style = (selector: string) => + getComputedStyle(document.querySelector(selector)!); + const bounds = (selector: string) => + document.querySelector(selector)!.getBoundingClientRect(); + const spinner = style(".session-progress-card__summary-indicator .session-run-spinner"); + return { + cardBackground: style(".session-progress-card--composer").backgroundColor, + summaryBackground: style(".session-progress-card__summary").backgroundColor, + titleColor: style(".session-progress-card__summary-title").color, + actionsColor: style(".session-progress-card__heading-actions").color, + currentColor: style(".session-progress-card__current").color, + countColor: style(".session-progress-card__summary-count--collapsed").color, + chevronColor: style(".session-progress-card__summary-chevron").color, + spinnerBorderColor: spinner.borderColor, + spinnerBorderTopColor: spinner.borderTopColor, + titleLeft: bounds(".session-progress-card__summary-title").left, + firstMarkerLeft: bounds(".session-progress-card__step-marker").left, + y: bounds(".session-progress-card__summary").y, + }; + }); + const waitForSummaryColors = async ( + selectors: string[], + colors: string[], + comparison: "equal" | "different", + ) => { + const match = await page.waitForFunction( + ({ expectedColors, expectedComparison, targetSelectors }) => + targetSelectors.every((selector, index) => { + const current = getComputedStyle( + document.querySelector(selector)!, + ).color; + return (current === expectedColors[index]) === (expectedComparison === "equal"); + }), + { + expectedColors: colors, + expectedComparison: comparison, + targetSelectors: selectors, + }, + ); + await match.dispose(); + }; + const expandedBefore = await readSummaryState(); await summary.hover(); - await page.waitForTimeout(180); + await waitForSummaryColors( + [ + ".session-progress-card__summary-title", + ".session-progress-card__heading-actions", + ".session-progress-card__summary-chevron", + ], + [expandedBefore.titleColor, expandedBefore.actionsColor, expandedBefore.chevronColor], + "different", + ); const widthAfter = (await card.boundingBox())?.width; - const expandedAfter = await page.evaluate(() => { - const style = (selector: string) => - getComputedStyle(document.querySelector(selector)!); - return { - cardBackground: style(".session-progress-card--composer").backgroundColor, - summaryBackground: style(".session-progress-card__summary").backgroundColor, - titleColor: style(".session-progress-card__summary-title").color, - actionsColor: style(".session-progress-card__heading-actions").color, - chevronColor: style(".session-progress-card__summary-chevron").color, - }; - }); + const expandedAfter = await readSummaryState(); expect(widthBefore).toBeCloseTo(760, 1); expect(widthAfter).toBeCloseTo(widthBefore ?? 0, 1); expect(expandedBefore.titleLeft).toBeCloseTo(expandedBefore.firstMarkerLeft, 1); @@ -4212,37 +4238,24 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { await page.mouse.move(0, 0); await card.evaluate((node) => node.removeAttribute("open")); - await page.waitForTimeout(180); - const collapsedBefore = await page.evaluate(() => { - const style = (selector: string) => - getComputedStyle(document.querySelector(selector)!); - const spinner = style(".session-progress-card__summary-indicator .session-run-spinner"); - return { - cardBackground: style(".session-progress-card--composer").backgroundColor, - summaryBackground: style(".session-progress-card__summary").backgroundColor, - currentColor: style(".session-progress-card__current").color, - countColor: style(".session-progress-card__summary-count--collapsed").color, - chevronColor: style(".session-progress-card__summary-chevron").color, - spinnerBorderColor: spinner.borderColor, - spinnerBorderTopColor: spinner.borderTopColor, - }; - }); + const collapsedColorSelectors = [ + ".session-progress-card__current", + ".session-progress-card__summary-count--collapsed", + ".session-progress-card__summary-chevron", + ]; + await waitForSummaryColors( + collapsedColorSelectors, + [expandedBefore.currentColor, expandedBefore.countColor, expandedBefore.chevronColor], + "equal", + ); + const collapsedBefore = await readSummaryState(); await summary.hover(); - await page.waitForTimeout(180); - const collapsedAfter = await page.evaluate(() => { - const style = (selector: string) => - getComputedStyle(document.querySelector(selector)!); - const spinner = style(".session-progress-card__summary-indicator .session-run-spinner"); - return { - cardBackground: style(".session-progress-card--composer").backgroundColor, - summaryBackground: style(".session-progress-card__summary").backgroundColor, - currentColor: style(".session-progress-card__current").color, - countColor: style(".session-progress-card__summary-count--collapsed").color, - chevronColor: style(".session-progress-card__summary-chevron").color, - spinnerBorderColor: spinner.borderColor, - spinnerBorderTopColor: spinner.borderTopColor, - }; - }); + await waitForSummaryColors( + collapsedColorSelectors, + [collapsedBefore.currentColor, collapsedBefore.countColor, collapsedBefore.chevronColor], + "different", + ); + const collapsedAfter = await readSummaryState(); expect(collapsedAfter.cardBackground).toBe(collapsedBefore.cardBackground); expect(collapsedAfter.summaryBackground).toBe(collapsedBefore.summaryBackground); expect(collapsedAfter.currentColor).not.toBe(collapsedBefore.currentColor);