mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
fix(channels): clarify remote install hints
Clarify remote channel install hints and align onboarding install source labels with progress-bar coverage.
This commit is contained in:
committed by
GitHub
parent
9a899a29b8
commit
15bbf4f2f3
@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/update: allow beta binaries to refresh gateway services when the config was last written by the matching stable release version, avoiding false newer-config downgrade blocks during beta channel updates.
|
||||
- Channels: keep Matrix and Mattermost bundled in the core package instead of advertising external npm installs before those channels are cut over. Thanks @vincentkoc.
|
||||
- Bonjour: disable LAN mDNS advertising after a repeated stuck-announcing recovery instead of repeatedly restarting ciao and saturating the Gateway event loop.
|
||||
- Channels/setup: label installable channel picker hints as remote npm installs and hide remote install hints for bundled plugins that already ship with OpenClaw.
|
||||
- CLI/plugins: stop treating the non-plugin `auth` command root as a bundled plugin id, so restrictive `plugins.allow` configs no longer tell users to add stale `auth` plugin entries.
|
||||
- Doctor/plugins: update configured plugin installs whose stale manifests still declare channels without `channelConfigs`, so beta upgrades repair old Discord-style package payloads during `doctor --fix`.
|
||||
- Active Memory: keep non-empty `memory_search` results from being fast-failed as empty when debug telemetry reports zero hits.
|
||||
|
||||
@@ -518,7 +518,7 @@ describe("ensureChannelSetupPluginInstalled", () => {
|
||||
options: [
|
||||
expect.objectContaining({
|
||||
value: "npm",
|
||||
label: `Download from npm (${bundledChatForkNpmSpec})`,
|
||||
label: `Remote install from npm (${bundledChatForkNpmSpec})`,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
value: "skip",
|
||||
@@ -562,7 +562,7 @@ describe("ensureChannelSetupPluginInstalled", () => {
|
||||
options: [
|
||||
expect.objectContaining({
|
||||
value: "clawhub",
|
||||
label: "Download from ClawHub (clawhub:openclaw/clawhub-chat@2026.5.2)",
|
||||
label: "Remote install from ClawHub (clawhub:openclaw/clawhub-chat@2026.5.2)",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
value: "skip",
|
||||
|
||||
@@ -72,6 +72,23 @@ vi.mock("../utils/with-timeout.js", () => ({
|
||||
|
||||
import { ensureOnboardingPluginInstalled } from "./onboarding-plugin-install.js";
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((next) => {
|
||||
resolve = next;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function waitForMockCall(mock: { mock: { calls: unknown[][] } }) {
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
if (mock.mock.calls.length > 0) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
describe("ensureOnboardingPluginInstalled", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -241,6 +258,114 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
expect(refreshPluginRegistryAfterConfigMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("animates ClawHub install progress while the remote install is running", async () => {
|
||||
const deferred = createDeferred<Awaited<ReturnType<typeof installPluginFromClawHub>>>();
|
||||
installPluginFromClawHub.mockImplementation(async (params) => {
|
||||
params.logger?.info?.("Downloading demo-plugin from ClawHub…");
|
||||
return await deferred.promise;
|
||||
});
|
||||
const stop = vi.fn();
|
||||
const update = vi.fn();
|
||||
|
||||
const install = ensureOnboardingPluginInstalled({
|
||||
cfg: {},
|
||||
entry: {
|
||||
pluginId: "demo-plugin",
|
||||
label: "Demo Provider",
|
||||
install: {
|
||||
clawhubSpec: "clawhub:demo-plugin@2026.5.2",
|
||||
defaultChoice: "clawhub",
|
||||
},
|
||||
},
|
||||
prompter: {
|
||||
select: vi.fn(async () => "clawhub"),
|
||||
progress: vi.fn(() => ({ update, stop })),
|
||||
} as never,
|
||||
runtime: {} as never,
|
||||
});
|
||||
|
||||
await waitForMockCall(installPluginFromClawHub);
|
||||
expect(installPluginFromClawHub).toHaveBeenCalled();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
expect(update).toHaveBeenCalledWith("Downloading");
|
||||
expect(
|
||||
update.mock.calls.some(
|
||||
([message]) =>
|
||||
typeof message === "string" && /^Downloading {2}\[[█░]{16}\] \d+%$/u.test(message),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
deferred.resolve({
|
||||
ok: true,
|
||||
pluginId: "demo-plugin",
|
||||
targetDir: "/tmp/demo-plugin",
|
||||
version: "2026.5.2",
|
||||
packageName: "demo-plugin",
|
||||
clawhub: {
|
||||
source: "clawhub",
|
||||
clawhubUrl: "https://clawhub.ai",
|
||||
clawhubPackage: "demo-plugin",
|
||||
clawhubFamily: "code-plugin",
|
||||
clawhubChannel: "official",
|
||||
version: "2026.5.2",
|
||||
integrity: "sha256-clawpack",
|
||||
resolvedAt: "2026-05-02T00:00:00.000Z",
|
||||
clawpackSha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
clawpackSpecVersion: 1,
|
||||
clawpackManifestSha256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
clawpackSize: 4096,
|
||||
},
|
||||
});
|
||||
await install;
|
||||
});
|
||||
|
||||
it("animates npm install progress while the remote install is running", async () => {
|
||||
const deferred = createDeferred<Awaited<ReturnType<typeof installPluginFromNpmSpec>>>();
|
||||
installPluginFromNpmSpec.mockImplementation(async (params) => {
|
||||
params.logger?.info?.("Resolving npm package…");
|
||||
return await deferred.promise;
|
||||
});
|
||||
const stop = vi.fn();
|
||||
const update = vi.fn();
|
||||
|
||||
const install = ensureOnboardingPluginInstalled({
|
||||
cfg: {},
|
||||
entry: {
|
||||
pluginId: "demo-plugin",
|
||||
label: "Demo Plugin",
|
||||
install: {
|
||||
npmSpec: "@demo/plugin@1.2.3",
|
||||
},
|
||||
},
|
||||
prompter: {
|
||||
select: vi.fn(async () => "npm"),
|
||||
progress: vi.fn(() => ({ update, stop })),
|
||||
} as never,
|
||||
runtime: {} as never,
|
||||
});
|
||||
|
||||
await waitForMockCall(installPluginFromNpmSpec);
|
||||
expect(installPluginFromNpmSpec).toHaveBeenCalled();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
expect(update).toHaveBeenCalledWith("Resolving");
|
||||
expect(
|
||||
update.mock.calls.some(
|
||||
([message]) =>
|
||||
typeof message === "string" && /^Resolving {2}\[[█░]{16}\] \d+%$/u.test(message),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
deferred.resolve({
|
||||
ok: true,
|
||||
pluginId: "demo-plugin",
|
||||
targetDir: "/tmp/demo-plugin",
|
||||
version: "1.2.3",
|
||||
});
|
||||
await install;
|
||||
});
|
||||
|
||||
it("returns a timed out status and notes the retry path when npm install hangs", async () => {
|
||||
const note = vi.fn(async () => {});
|
||||
const stop = vi.fn();
|
||||
@@ -310,7 +435,7 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
});
|
||||
|
||||
expect(captured?.options).toEqual([
|
||||
{ value: "npm", label: "Download from npm (@demo/plugin)" },
|
||||
{ value: "npm", label: "Remote install from npm (@demo/plugin)" },
|
||||
{ value: "skip", label: "Skip for now" },
|
||||
]);
|
||||
expect(captured?.initialValue).toBe("npm");
|
||||
@@ -349,8 +474,11 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
});
|
||||
|
||||
expect(captured?.options).toEqual([
|
||||
{ value: "clawhub", label: "Download from ClawHub (clawhub:demo-plugin@2026.5.2)" },
|
||||
{ value: "npm", label: "Download from npm (@openclaw/demo-plugin@2026.5.2)" },
|
||||
{
|
||||
value: "clawhub",
|
||||
label: "Remote install from ClawHub (clawhub:demo-plugin@2026.5.2)",
|
||||
},
|
||||
{ value: "npm", label: "Remote install from npm (@openclaw/demo-plugin@2026.5.2)" },
|
||||
{ value: "skip", label: "Skip for now" },
|
||||
]);
|
||||
expect(captured?.initialValue).toBe("clawhub");
|
||||
@@ -460,7 +588,7 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
expect(captured).toBeDefined();
|
||||
expect(captured?.message).toBe("Install Demo Plugin\\n plugin?");
|
||||
expect(captured?.options).toEqual([
|
||||
{ value: "npm", label: "Download from npm (@demo/plugin@1.2.3)" },
|
||||
{ value: "npm", label: "Remote install from npm (@demo/plugin@1.2.3)" },
|
||||
{
|
||||
value: "local",
|
||||
label: "Use local plugin path",
|
||||
@@ -674,7 +802,7 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
});
|
||||
|
||||
expect(captured).toBeDefined();
|
||||
// "Download from npm (@openclaw/tlon)" must NOT appear: the bundled
|
||||
// "Remote install from npm (@openclaw/tlon)" must NOT appear: the bundled
|
||||
// copy is what gets enabled, so the npm hint would only confuse
|
||||
// users into thinking the plugin is missing.
|
||||
expect(captured?.options).toEqual([
|
||||
|
||||
@@ -320,7 +320,7 @@ async function promptInstallChoice(params: {
|
||||
// `extensions/<id>` and is discovered via `resolveBundledPluginSources`),
|
||||
// the bundled copy is the source of truth: it is version-locked to the
|
||||
// current host build and is what `defaultChoice` will pick anyway (see
|
||||
// `resolveInstallDefaultChoice`). Surfacing remote download options in that
|
||||
// `resolveInstallDefaultChoice`). Surfacing remote install options in that
|
||||
// case is misleading; those catalog specs only exist as fallback metadata for
|
||||
// non-bundled builds. Hide them so bundled channels like Tlon look identical
|
||||
// to Twitch / Slack in the menu.
|
||||
@@ -334,13 +334,13 @@ async function promptInstallChoice(params: {
|
||||
if (safeClawHubSpec) {
|
||||
options.push({
|
||||
value: "clawhub",
|
||||
label: `Download from ClawHub (${safeClawHubSpec})`,
|
||||
label: formatRemoteInstallChoiceLabel("clawhub", safeClawHubSpec),
|
||||
});
|
||||
}
|
||||
if (safeNpmSpec) {
|
||||
options.push({
|
||||
value: "npm",
|
||||
label: `Download from npm (${safeNpmSpec})`,
|
||||
label: formatRemoteInstallChoiceLabel("npm", safeNpmSpec),
|
||||
});
|
||||
}
|
||||
if (params.localPath) {
|
||||
@@ -420,6 +420,11 @@ function isTimeoutError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message === "timeout";
|
||||
}
|
||||
|
||||
function formatRemoteInstallChoiceLabel(source: "clawhub" | "npm", spec: string): string {
|
||||
const sourceLabel = source === "clawhub" ? "ClawHub" : "npm";
|
||||
return `Remote install from ${sourceLabel} (${spec})`;
|
||||
}
|
||||
|
||||
async function applyPluginEnablement(params: {
|
||||
cfg: OpenClawConfig;
|
||||
pluginId: string;
|
||||
|
||||
@@ -12,6 +12,10 @@ type FormatChannelPrimerLine = typeof import("../channels/registry.js").formatCh
|
||||
type FormatChannelSelectionLine =
|
||||
typeof import("../channels/registry.js").formatChannelSelectionLine;
|
||||
type IsChannelConfigured = typeof import("../config/channel-configured.js").isChannelConfigured;
|
||||
type ResolveBundledPluginSources =
|
||||
typeof import("../plugins/bundled-sources.js").resolveBundledPluginSources;
|
||||
type FindBundledPluginSourceInMap =
|
||||
typeof import("../plugins/bundled-sources.js").findBundledPluginSourceInMap;
|
||||
type NoteChannelPrimerChannels = Parameters<
|
||||
typeof import("./channel-setup.status.js").noteChannelPrimer
|
||||
>[1];
|
||||
@@ -33,6 +37,21 @@ const formatChannelSelectionLine = vi.hoisted(() =>
|
||||
vi.fn<FormatChannelSelectionLine>((meta) => `${meta.label} — ${meta.blurb}`),
|
||||
);
|
||||
const isChannelConfigured = vi.hoisted(() => vi.fn<IsChannelConfigured>(() => false));
|
||||
const resolveBundledPluginSources = vi.hoisted(() =>
|
||||
vi.fn<ResolveBundledPluginSources>(() => new Map()),
|
||||
);
|
||||
const findBundledPluginSourceInMap = vi.hoisted(() =>
|
||||
vi.fn<FindBundledPluginSourceInMap>(({ bundled, lookup }) => {
|
||||
const value = lookup.value.trim();
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
if (lookup.kind === "pluginId") {
|
||||
return bundled.get(value);
|
||||
}
|
||||
return Array.from(bundled.values()).find((source) => source.npmSpec === value);
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("../channels/chat-meta.js", () => ({
|
||||
listChatChannels: () => listChatChannels(),
|
||||
@@ -62,20 +81,20 @@ vi.mock("../config/channel-configured.js", () => ({
|
||||
) => isChannelConfigured(cfg, channelId),
|
||||
}));
|
||||
|
||||
// Avoid touching the real `extensions/<id>` tree from unit tests. Status
|
||||
// rendering for installable catalog entries asks `bundled-sources` whether
|
||||
// a plugin already lives in-tree to decide between
|
||||
// "install plugin to enable" vs "bundled · enable to use". For these tests
|
||||
// we want the installable-catalog branch unconditionally, so we stub the
|
||||
// bundled lookup to "nothing is bundled".
|
||||
// Avoid touching the real `extensions/<id>` tree from unit tests. Tests opt
|
||||
// into bundled-source entries explicitly when they cover bundled catalog
|
||||
// rendering; the default fixture behaves as if nothing is bundled.
|
||||
vi.mock("../plugins/bundled-sources.js", () => ({
|
||||
resolveBundledPluginSources: () => new Map(),
|
||||
findBundledPluginSourceInMap: () => undefined,
|
||||
resolveBundledPluginSources: (params: Parameters<ResolveBundledPluginSources>[0]) =>
|
||||
resolveBundledPluginSources(params),
|
||||
findBundledPluginSourceInMap: (params: Parameters<FindBundledPluginSourceInMap>[0]) =>
|
||||
findBundledPluginSourceInMap(params),
|
||||
}));
|
||||
|
||||
import {
|
||||
collectChannelStatus,
|
||||
noteChannelPrimer,
|
||||
resolveCatalogChannelSelectionHint,
|
||||
resolveChannelSelectionNoteLines,
|
||||
resolveChannelSetupSelectionContributions,
|
||||
} from "./channel-setup.status.js";
|
||||
@@ -93,6 +112,17 @@ describe("resolveChannelSetupSelectionContributions", () => {
|
||||
);
|
||||
formatChannelSelectionLine.mockImplementation((meta) => `${meta.label} — ${meta.blurb}`);
|
||||
isChannelConfigured.mockReturnValue(false);
|
||||
resolveBundledPluginSources.mockReturnValue(new Map());
|
||||
findBundledPluginSourceInMap.mockImplementation(({ bundled, lookup }) => {
|
||||
const value = lookup.value.trim();
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
if (lookup.kind === "pluginId") {
|
||||
return bundled.get(value);
|
||||
}
|
||||
return Array.from(bundled.values()).find((source) => source.npmSpec === value);
|
||||
});
|
||||
});
|
||||
|
||||
it("sorts channels alphabetically by picker label", () => {
|
||||
@@ -158,6 +188,67 @@ describe("resolveChannelSetupSelectionContributions", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("describes installable catalog choices as remote npm installs", () => {
|
||||
expect(
|
||||
resolveCatalogChannelSelectionHint({
|
||||
install: { npmSpec: "@openclaw/googlechat" },
|
||||
}),
|
||||
).toBe("remote install from npm: @openclaw/googlechat");
|
||||
});
|
||||
|
||||
it("sanitizes remote npm install hints", () => {
|
||||
expect(
|
||||
resolveCatalogChannelSelectionHint({
|
||||
install: { npmSpec: "@openclaw/googlechat\u001B[31m\nbeta" },
|
||||
}),
|
||||
).toBe("remote install from npm: @openclaw/googlechat\\nbeta");
|
||||
});
|
||||
|
||||
it("suppresses remote install hints for bundled channels", () => {
|
||||
expect(
|
||||
resolveCatalogChannelSelectionHint(
|
||||
{
|
||||
install: { npmSpec: "@openclaw/googlechat" },
|
||||
},
|
||||
{ bundledLocalPath: "extensions/googlechat" },
|
||||
),
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("renders bundled catalog statuses without remote install hints", async () => {
|
||||
const entry = makeCatalogEntry("slack", "Slack", {
|
||||
pluginId: "@openclaw/slack",
|
||||
install: { npmSpec: "@openclaw/slack" },
|
||||
});
|
||||
listChatChannels.mockReturnValue([]);
|
||||
resolveBundledPluginSources.mockReturnValue(
|
||||
new Map([
|
||||
[
|
||||
"@openclaw/slack",
|
||||
{
|
||||
pluginId: "@openclaw/slack",
|
||||
localPath: "extensions/slack",
|
||||
npmSpec: "@openclaw/slack",
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
resolveChannelSetupEntries.mockReturnValue(
|
||||
makeChannelSetupEntries({
|
||||
installableCatalogEntries: [entry],
|
||||
}),
|
||||
);
|
||||
|
||||
const summary = await collectChannelStatus({
|
||||
cfg: {} as never,
|
||||
accountOverrides: {},
|
||||
installedPlugins: [],
|
||||
});
|
||||
|
||||
expect(summary.statusLines).toEqual(["Slack: bundled · enable to use"]);
|
||||
expect(summary.statusByChannel.get("slack")?.selectionHint).toBe("");
|
||||
});
|
||||
|
||||
it("combines real status and disabled hints when available", () => {
|
||||
const contributions = resolveChannelSetupSelectionContributions({
|
||||
entries: [
|
||||
|
||||
@@ -135,17 +135,17 @@ function formatSetupDisplayMeta(meta: ChannelMeta): ChannelMeta {
|
||||
/**
|
||||
* Hint shown next to an installable channel option in the selection menu when
|
||||
* we don't yet have a runtime-collected status. Mirrors the "configured" /
|
||||
* "installed" affordance other channels get so users can see "download from
|
||||
* <npm-spec>" before committing to install.
|
||||
* "installed" affordance other channels get so users can see "remote install
|
||||
* from npm: <npm-spec>" before committing to install.
|
||||
*
|
||||
* Bundled channels (the plugin lives under `extensions/<id>` in the host
|
||||
* repo, e.g. Signal / Tlon / Twitch / Slack) are NOT downloaded from npm —
|
||||
* they ship with the host. Even when their `package.json` declares an
|
||||
* `npmSpec` (or the catalog falls back to the package name), surfacing
|
||||
* "download from <npm-spec>" misleads users into believing the plugin is
|
||||
* missing. For bundled channels we suppress the npm hint entirely so the
|
||||
* menu shows the same neutral "plugin · install" affordance used when no
|
||||
* npm source is known.
|
||||
* "remote install from npm: <npm-spec>" misleads users into believing the
|
||||
* plugin is missing. For bundled channels we suppress the npm hint entirely
|
||||
* so the menu shows the same neutral "plugin · install" affordance used when
|
||||
* no npm source is known.
|
||||
*/
|
||||
export function resolveCatalogChannelSelectionHint(
|
||||
entry: { install?: { npmSpec?: string } },
|
||||
@@ -153,7 +153,7 @@ export function resolveCatalogChannelSelectionHint(
|
||||
): string {
|
||||
const npmSpec = entry.install?.npmSpec?.trim();
|
||||
if (npmSpec && !options?.bundledLocalPath) {
|
||||
return `download from ${formatSetupSelectionLabel(npmSpec, npmSpec)}`;
|
||||
return `remote install from npm: ${formatSetupSelectionLabel(npmSpec, npmSpec)}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -162,8 +162,8 @@ export function resolveCatalogChannelSelectionHint(
|
||||
* Look up the bundled-source entry for a catalog channel, regardless of
|
||||
* whether the catalog refers to it by `pluginId` or `npmSpec`. We use this
|
||||
* to detect bundled channels in the selection menu so we can suppress the
|
||||
* misleading "download from <npm-spec>" hint for plugins that already ship
|
||||
* with the host (Signal / Tlon / Twitch / Slack ...).
|
||||
* misleading "remote install from npm: <npm-spec>" hint for plugins that
|
||||
* already ship with the host (Signal / Tlon / Twitch / Slack ...).
|
||||
*/
|
||||
export function findBundledSourceForCatalogChannel(params: {
|
||||
bundled: ReadonlyMap<string, BundledPluginSource>;
|
||||
|
||||
@@ -9,6 +9,12 @@ type ChannelSetupPlugin = import("../channels/plugins/setup-wizard-types.js").Ch
|
||||
type ResolveChannelSetupEntries =
|
||||
typeof import("../commands/channel-setup/discovery.js").resolveChannelSetupEntries;
|
||||
type CollectChannelStatus = typeof import("./channel-setup.status.js").collectChannelStatus;
|
||||
type FindBundledSourceForCatalogChannel =
|
||||
typeof import("./channel-setup.status.js").findBundledSourceForCatalogChannel;
|
||||
type ResolveCatalogChannelSelectionHint =
|
||||
typeof import("./channel-setup.status.js").resolveCatalogChannelSelectionHint;
|
||||
type ResolveChannelSetupSelectionContributions =
|
||||
typeof import("./channel-setup.status.js").resolveChannelSetupSelectionContributions;
|
||||
type EnsureChannelSetupPluginInstalled =
|
||||
typeof import("../commands/channel-setup/plugin-install.js").ensureChannelSetupPluginInstalled;
|
||||
type LoadChannelSetupPluginRegistrySnapshotForChannel =
|
||||
@@ -117,6 +123,18 @@ const collectChannelStatus = vi.hoisted(() =>
|
||||
statusLines: [],
|
||||
})),
|
||||
);
|
||||
const findBundledSourceForCatalogChannel = vi.hoisted(() =>
|
||||
vi.fn<FindBundledSourceForCatalogChannel>(() => undefined),
|
||||
);
|
||||
const resolveCatalogChannelSelectionHint = vi.hoisted(() =>
|
||||
vi.fn<ResolveCatalogChannelSelectionHint>((entry, options) => {
|
||||
const npmSpec = entry.install?.npmSpec?.trim();
|
||||
return npmSpec && !options?.bundledLocalPath ? `remote install from npm: ${npmSpec}` : "";
|
||||
}),
|
||||
);
|
||||
const resolveChannelSetupSelectionContributions = vi.hoisted(() =>
|
||||
vi.fn<ResolveChannelSetupSelectionContributions>(() => []),
|
||||
);
|
||||
const isChannelConfigured = vi.hoisted(() => vi.fn((_cfg?: unknown, _channel?: unknown) => true));
|
||||
|
||||
vi.mock("../agents/agent-scope.js", () => ({
|
||||
@@ -178,12 +196,18 @@ vi.mock("./channel-setup.prompts.js", () => ({
|
||||
vi.mock("./channel-setup.status.js", () => ({
|
||||
collectChannelStatus: (params: Parameters<CollectChannelStatus>[0]) =>
|
||||
collectChannelStatus(params),
|
||||
findBundledSourceForCatalogChannel: vi.fn(() => undefined),
|
||||
findBundledSourceForCatalogChannel: (params: Parameters<FindBundledSourceForCatalogChannel>[0]) =>
|
||||
findBundledSourceForCatalogChannel(params),
|
||||
noteChannelPrimer: vi.fn(),
|
||||
noteChannelStatus: vi.fn(),
|
||||
resolveCatalogChannelSelectionHint: vi.fn(() => "download from <npm>"),
|
||||
resolveCatalogChannelSelectionHint: (
|
||||
entry: Parameters<ResolveCatalogChannelSelectionHint>[0],
|
||||
options: Parameters<ResolveCatalogChannelSelectionHint>[1],
|
||||
) => resolveCatalogChannelSelectionHint(entry, options),
|
||||
resolveChannelSelectionNoteLines: vi.fn(() => []),
|
||||
resolveChannelSetupSelectionContributions: vi.fn(() => []),
|
||||
resolveChannelSetupSelectionContributions: (
|
||||
params: Parameters<ResolveChannelSetupSelectionContributions>[0],
|
||||
) => resolveChannelSetupSelectionContributions(params),
|
||||
resolveQuickstartDefault: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
@@ -219,6 +243,12 @@ describe("setupChannels workspace shadow exclusion", () => {
|
||||
statusByChannel: new Map(),
|
||||
statusLines: [],
|
||||
});
|
||||
findBundledSourceForCatalogChannel.mockReturnValue(undefined);
|
||||
resolveCatalogChannelSelectionHint.mockImplementation((entry, options) => {
|
||||
const npmSpec = entry.install?.npmSpec?.trim();
|
||||
return npmSpec && !options?.bundledLocalPath ? `remote install from npm: ${npmSpec}` : "";
|
||||
});
|
||||
resolveChannelSetupSelectionContributions.mockReturnValue([]);
|
||||
isChannelConfigured.mockReturnValue(true);
|
||||
});
|
||||
|
||||
@@ -338,6 +368,48 @@ describe("setupChannels workspace shadow exclusion", () => {
|
||||
expect(collectChannelStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses deferred picker remote install hints for bundled catalog choices", async () => {
|
||||
const installableCatalogEntry = makeCatalogEntry("external-chat", "External Chat", {
|
||||
pluginId: "@openclaw/external-chat",
|
||||
install: { npmSpec: "@openclaw/external-chat" },
|
||||
});
|
||||
resolveChannelSetupEntries.mockReturnValue(
|
||||
externalChatSetupEntries({
|
||||
installableCatalogEntries: [installableCatalogEntry],
|
||||
installableCatalogById: new Map([["external-chat", installableCatalogEntry]]),
|
||||
}),
|
||||
);
|
||||
findBundledSourceForCatalogChannel.mockReturnValue({
|
||||
pluginId: "@openclaw/external-chat",
|
||||
localPath: "extensions/external-chat",
|
||||
npmSpec: "@openclaw/external-chat",
|
||||
});
|
||||
const select = vi.fn(async () => "__done__");
|
||||
|
||||
await setupChannels(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{
|
||||
confirm: vi.fn(async () => true),
|
||||
note: vi.fn(async () => undefined),
|
||||
select,
|
||||
} as never,
|
||||
{
|
||||
deferStatusUntilSelection: true,
|
||||
skipConfirm: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(resolveCatalogChannelSelectionHint).toHaveBeenCalledWith(installableCatalogEntry, {
|
||||
bundledLocalPath: "extensions/external-chat",
|
||||
});
|
||||
expect(
|
||||
resolveChannelSetupSelectionContributions.mock.calls[0]?.[0].statusByChannel.get(
|
||||
"external-chat",
|
||||
)?.selectionHint,
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("uses an active deferred setup plugin without enabling config on selection", async () => {
|
||||
const setupWizard = {
|
||||
channel: "custom-chat",
|
||||
|
||||
@@ -320,13 +320,14 @@ export async function setupChannels(
|
||||
// installable catalog channels (e.g. WeCom shipped via npm). In QuickStart we
|
||||
// run with `deferStatusUntilSelection`, which leaves `statusByChannel` empty
|
||||
// until the user picks a channel — without this overlay the selection menu
|
||||
// would render those options without any "download from <npm-spec>" hint.
|
||||
// would render those options without any "remote install from npm:
|
||||
// <npm-spec>" hint.
|
||||
//
|
||||
// Bundled channels (Signal / Tlon / Twitch / Slack ...) reach this code path
|
||||
// too whenever their plugin is not yet enabled, because they share the same
|
||||
// "installable catalog" bucket. For those we must NOT show "download from
|
||||
// <npm-spec>" — the plugin already lives under `extensions/<id>` and the
|
||||
// hint would mislead users into thinking the plugin is missing.
|
||||
// "installable catalog" bucket. For those we must NOT show "remote install
|
||||
// from npm: <npm-spec>" — the plugin already lives under `extensions/<id>`
|
||||
// and the hint would mislead users into thinking the plugin is missing.
|
||||
const buildStatusByChannelForSelection = (
|
||||
catalogById: ReturnType<typeof getChannelEntries>["catalogById"],
|
||||
): Map<ChannelChoice, ChannelSetupStatus> => {
|
||||
|
||||
Reference in New Issue
Block a user