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
This commit is contained in:
Peter Steinberger
2026-08-27 14:11:22 -07:00
committed by GitHub
parent 1b26747cfd
commit e0680fdd42
4 changed files with 334 additions and 122 deletions
+2
View File
@@ -171,6 +171,8 @@ openclaw channels logout --channel whatsapp
- `channels login` supports `--account <id>` and `--verbose`; `channels logout` supports `--account <id>`.
- `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.
+211 -9
View File
@@ -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 },
+50 -55
View File
@@ -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<boolean> {
}) {
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." : ""
}`,
);
}
@@ -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<HTMLElement>(selector)!);
const bounds = (selector: string) =>
document.querySelector<HTMLElement>(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<HTMLElement>(selector)!);
const bounds = (selector: string) =>
document.querySelector<HTMLElement>(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<HTMLElement>(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<HTMLElement>(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<HTMLElement>(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<HTMLElement>(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);