mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
committed by
GitHub
parent
1b26747cfd
commit
e0680fdd42
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user