mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-02 01:50:22 +00:00
test: dedupe plugin provider runtime suites
This commit is contained in:
@@ -29,6 +29,19 @@ function expectGeneratedAuthEnvVarModuleState(params: {
|
||||
expect(result.wrote).toBe(params.expectedWrote);
|
||||
}
|
||||
|
||||
function expectBundledProviderEnvVars(expected: Record<string, readonly string[]>) {
|
||||
expect(
|
||||
Object.fromEntries(
|
||||
Object.keys(expected).map((providerId) => [
|
||||
providerId,
|
||||
BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES[
|
||||
providerId as keyof typeof BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES
|
||||
],
|
||||
]),
|
||||
),
|
||||
).toEqual(expected);
|
||||
}
|
||||
|
||||
describe("bundled provider auth env vars", () => {
|
||||
it("matches the generated manifest snapshot", () => {
|
||||
expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES).toEqual(
|
||||
@@ -37,25 +50,7 @@ describe("bundled provider auth env vars", () => {
|
||||
});
|
||||
|
||||
it("reads bundled provider auth env vars from plugin manifests", () => {
|
||||
expect(
|
||||
Object.fromEntries(
|
||||
[
|
||||
["brave", ["BRAVE_API_KEY"]],
|
||||
["firecrawl", ["FIRECRAWL_API_KEY"]],
|
||||
["github-copilot", ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]],
|
||||
["perplexity", ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"]],
|
||||
["tavily", ["TAVILY_API_KEY"]],
|
||||
["minimax-portal", ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"]],
|
||||
["openai", ["OPENAI_API_KEY"]],
|
||||
["fal", ["FAL_KEY"]],
|
||||
].map(([providerId]) => [
|
||||
providerId,
|
||||
BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES[
|
||||
providerId as keyof typeof BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES
|
||||
],
|
||||
]),
|
||||
),
|
||||
).toEqual({
|
||||
expectBundledProviderEnvVars({
|
||||
brave: ["BRAVE_API_KEY"],
|
||||
firecrawl: ["FIRECRAWL_API_KEY"],
|
||||
"github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
|
||||
|
||||
@@ -38,6 +38,10 @@ vi.mock("./bundled-compat.js", () => ({
|
||||
|
||||
let resolvePluginCapabilityProviders: typeof import("./capability-provider-runtime.js").resolvePluginCapabilityProviders;
|
||||
|
||||
function expectResolvedCapabilityProviderIds(providers: Array<{ id: string }>, expected: string[]) {
|
||||
expect(providers.map((provider) => provider.id)).toEqual(expected);
|
||||
}
|
||||
|
||||
function expectBundledCompatLoadPath(params: {
|
||||
cfg: OpenClawConfig;
|
||||
allowlistCompat: { plugins: { allow: string[] } };
|
||||
@@ -100,6 +104,27 @@ function setBundledCapabilityFixture(contractKey: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function setActiveSpeechCapabilityRegistry(providerId: string) {
|
||||
const active = createEmptyPluginRegistry();
|
||||
active.speechProviders.push({
|
||||
pluginId: providerId,
|
||||
pluginName: "OpenAI",
|
||||
source: "test",
|
||||
provider: {
|
||||
id: providerId,
|
||||
label: "OpenAI",
|
||||
isConfigured: () => true,
|
||||
synthesize: async () => ({
|
||||
audioBuffer: Buffer.from("x"),
|
||||
outputFormat: "mp3",
|
||||
voiceCompatible: false,
|
||||
fileExtension: ".mp3",
|
||||
}),
|
||||
},
|
||||
});
|
||||
setActivePluginRegistry(active);
|
||||
}
|
||||
|
||||
describe("resolvePluginCapabilityProviders", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
@@ -118,28 +143,11 @@ describe("resolvePluginCapabilityProviders", () => {
|
||||
});
|
||||
|
||||
it("uses the active registry when capability providers are already loaded", () => {
|
||||
const active = createEmptyPluginRegistry();
|
||||
active.speechProviders.push({
|
||||
pluginId: "openai",
|
||||
pluginName: "OpenAI",
|
||||
source: "test",
|
||||
provider: {
|
||||
id: "openai",
|
||||
label: "OpenAI",
|
||||
isConfigured: () => true,
|
||||
synthesize: async () => ({
|
||||
audioBuffer: Buffer.from("x"),
|
||||
outputFormat: "mp3",
|
||||
voiceCompatible: false,
|
||||
fileExtension: ".mp3",
|
||||
}),
|
||||
},
|
||||
});
|
||||
setActivePluginRegistry(active);
|
||||
setActiveSpeechCapabilityRegistry("openai");
|
||||
|
||||
const providers = resolvePluginCapabilityProviders({ key: "speechProviders" });
|
||||
|
||||
expect(providers.map((provider) => provider.id)).toEqual(["openai"]);
|
||||
expectResolvedCapabilityProviderIds(providers, ["openai"]);
|
||||
expect(mocks.loadPluginManifestRegistry).not.toHaveBeenCalled();
|
||||
expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -37,6 +37,10 @@ function createOwnedAdapterEntry(id: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function expectMemoryEmbeddingProviderIds(expectedIds: readonly string[]) {
|
||||
expect(listMemoryEmbeddingProviders().map((adapter) => adapter.id)).toEqual([...expectedIds]);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
clearMemoryEmbeddingProviders();
|
||||
});
|
||||
@@ -47,7 +51,7 @@ describe("memory embedding provider registry", () => {
|
||||
registerMemoryEmbeddingProvider(createAdapter("beta"));
|
||||
|
||||
expect(getMemoryEmbeddingProvider("alpha")?.id).toBe("alpha");
|
||||
expect(listMemoryEmbeddingProviders().map((adapter) => adapter.id)).toEqual(["alpha", "beta"]);
|
||||
expectMemoryEmbeddingProviderIds(["alpha", "beta"]);
|
||||
});
|
||||
|
||||
it("restores a previous snapshot", () => {
|
||||
@@ -92,7 +96,7 @@ describe("memory embedding provider registry", () => {
|
||||
|
||||
clearMemoryEmbeddingProviders();
|
||||
|
||||
expect(listMemoryEmbeddingProviders()).toEqual([]);
|
||||
expectMemoryEmbeddingProviderIds([]);
|
||||
});
|
||||
|
||||
it("stores adapters in a process-global singleton map", () => {
|
||||
|
||||
@@ -11,16 +11,18 @@ const MIN_HOST_REQUIREMENT = {
|
||||
minimumLabel: "2026.3.22",
|
||||
};
|
||||
|
||||
function expectValidHostCheck(currentVersion: string, minHostVersion?: string) {
|
||||
expect(checkMinHostVersion({ currentVersion, minHostVersion })).toEqual({
|
||||
ok: true,
|
||||
requirement: minHostVersion ? MIN_HOST_REQUIREMENT : null,
|
||||
});
|
||||
}
|
||||
|
||||
describe("min-host-version", () => {
|
||||
it("accepts empty metadata", () => {
|
||||
expect(validateMinHostVersion(undefined)).toBeNull();
|
||||
expect(parseMinHostVersionRequirement(undefined)).toBeNull();
|
||||
expect(checkMinHostVersion({ currentVersion: "2026.3.22", minHostVersion: undefined })).toEqual(
|
||||
{
|
||||
ok: true,
|
||||
requirement: null,
|
||||
},
|
||||
);
|
||||
expectValidHostCheck("2026.3.22");
|
||||
});
|
||||
|
||||
it("parses semver floors", () => {
|
||||
@@ -69,10 +71,7 @@ describe("min-host-version", () => {
|
||||
it.each(["2026.3.22", "2026.4.0"] as const)(
|
||||
"accepts equal or newer hosts: %s",
|
||||
(currentVersion) => {
|
||||
expect(checkMinHostVersion({ currentVersion, minHostVersion: ">=2026.3.22" })).toEqual({
|
||||
ok: true,
|
||||
requirement: MIN_HOST_REQUIREMENT,
|
||||
});
|
||||
expectValidHostCheck(currentVersion, ">=2026.3.22");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,13 @@ import {
|
||||
resolveManifestProviderOnboardAuthFlags,
|
||||
} from "./provider-auth-choices.js";
|
||||
|
||||
function createManifestPlugin(id: string, providerAuthChoices: Array<Record<string, unknown>>) {
|
||||
return {
|
||||
id,
|
||||
providerAuthChoices,
|
||||
};
|
||||
}
|
||||
|
||||
function setManifestPlugins(plugins: Array<Record<string, unknown>>) {
|
||||
loadPluginManifestRegistry.mockReturnValue({
|
||||
plugins,
|
||||
@@ -23,24 +30,27 @@ function expectAuthChoice(choiceId: string, providerId: string) {
|
||||
expect(resolveManifestProviderAuthChoice(choiceId)?.providerId).toBe(providerId);
|
||||
}
|
||||
|
||||
function expectDeprecatedAuthChoice(choiceIds: string[], expectedChoiceId?: string) {
|
||||
for (const choiceId of choiceIds) {
|
||||
expect(resolveManifestDeprecatedProviderAuthChoice(choiceId)?.choiceId).toBe(expectedChoiceId);
|
||||
}
|
||||
}
|
||||
|
||||
describe("provider auth choice manifest helpers", () => {
|
||||
it("flattens manifest auth choices", () => {
|
||||
setManifestPlugins([
|
||||
{
|
||||
id: "openai",
|
||||
providerAuthChoices: [
|
||||
{
|
||||
provider: "openai",
|
||||
method: "api-key",
|
||||
choiceId: "openai-api-key",
|
||||
choiceLabel: "OpenAI API key",
|
||||
onboardingScopes: ["text-inference"],
|
||||
optionKey: "openaiApiKey",
|
||||
cliFlag: "--openai-api-key",
|
||||
cliOption: "--openai-api-key <key>",
|
||||
},
|
||||
],
|
||||
},
|
||||
createManifestPlugin("openai", [
|
||||
{
|
||||
provider: "openai",
|
||||
method: "api-key",
|
||||
choiceId: "openai-api-key",
|
||||
choiceLabel: "OpenAI API key",
|
||||
onboardingScopes: ["text-inference"],
|
||||
optionKey: "openaiApiKey",
|
||||
cliFlag: "--openai-api-key",
|
||||
cliOption: "--openai-api-key <key>",
|
||||
},
|
||||
]),
|
||||
]);
|
||||
|
||||
expect(resolveManifestProviderAuthChoices()).toEqual([
|
||||
@@ -63,31 +73,28 @@ describe("provider auth choice manifest helpers", () => {
|
||||
{
|
||||
name: "deduplicates flag metadata by option key + flag",
|
||||
plugins: [
|
||||
{
|
||||
id: "moonshot",
|
||||
providerAuthChoices: [
|
||||
{
|
||||
provider: "moonshot",
|
||||
method: "api-key",
|
||||
choiceId: "moonshot-api-key",
|
||||
choiceLabel: "Kimi API key (.ai)",
|
||||
optionKey: "moonshotApiKey",
|
||||
cliFlag: "--moonshot-api-key",
|
||||
cliOption: "--moonshot-api-key <key>",
|
||||
cliDescription: "Moonshot API key",
|
||||
},
|
||||
{
|
||||
provider: "moonshot",
|
||||
method: "api-key-cn",
|
||||
choiceId: "moonshot-api-key-cn",
|
||||
choiceLabel: "Kimi API key (.cn)",
|
||||
optionKey: "moonshotApiKey",
|
||||
cliFlag: "--moonshot-api-key",
|
||||
cliOption: "--moonshot-api-key <key>",
|
||||
cliDescription: "Moonshot API key",
|
||||
},
|
||||
],
|
||||
},
|
||||
createManifestPlugin("moonshot", [
|
||||
{
|
||||
provider: "moonshot",
|
||||
method: "api-key",
|
||||
choiceId: "moonshot-api-key",
|
||||
choiceLabel: "Kimi API key (.ai)",
|
||||
optionKey: "moonshotApiKey",
|
||||
cliFlag: "--moonshot-api-key",
|
||||
cliOption: "--moonshot-api-key <key>",
|
||||
cliDescription: "Moonshot API key",
|
||||
},
|
||||
{
|
||||
provider: "moonshot",
|
||||
method: "api-key-cn",
|
||||
choiceId: "moonshot-api-key-cn",
|
||||
choiceLabel: "Kimi API key (.cn)",
|
||||
optionKey: "moonshotApiKey",
|
||||
cliFlag: "--moonshot-api-key",
|
||||
cliOption: "--moonshot-api-key <key>",
|
||||
cliDescription: "Moonshot API key",
|
||||
},
|
||||
]),
|
||||
],
|
||||
run: () =>
|
||||
expect(resolveManifestProviderOnboardAuthFlags()).toEqual([
|
||||
@@ -103,26 +110,18 @@ describe("provider auth choice manifest helpers", () => {
|
||||
{
|
||||
name: "resolves deprecated auth-choice aliases through manifest metadata",
|
||||
plugins: [
|
||||
{
|
||||
id: "minimax",
|
||||
providerAuthChoices: [
|
||||
{
|
||||
provider: "minimax",
|
||||
method: "api-global",
|
||||
choiceId: "minimax-global-api",
|
||||
deprecatedChoiceIds: ["minimax", "minimax-api"],
|
||||
},
|
||||
],
|
||||
},
|
||||
createManifestPlugin("minimax", [
|
||||
{
|
||||
provider: "minimax",
|
||||
method: "api-global",
|
||||
choiceId: "minimax-global-api",
|
||||
deprecatedChoiceIds: ["minimax", "minimax-api"],
|
||||
},
|
||||
]),
|
||||
],
|
||||
run: () => {
|
||||
expect(resolveManifestDeprecatedProviderAuthChoice("minimax")?.choiceId).toBe(
|
||||
"minimax-global-api",
|
||||
);
|
||||
expect(resolveManifestDeprecatedProviderAuthChoice("minimax-api")?.choiceId).toBe(
|
||||
"minimax-global-api",
|
||||
);
|
||||
expect(resolveManifestDeprecatedProviderAuthChoice("openai")).toBeUndefined();
|
||||
expectDeprecatedAuthChoice(["minimax", "minimax-api"], "minimax-global-api");
|
||||
expectDeprecatedAuthChoice(["openai"]);
|
||||
},
|
||||
},
|
||||
])("$name", ({ plugins, run }) => {
|
||||
|
||||
@@ -53,6 +53,47 @@ function expectPairedCatalogProviders(
|
||||
});
|
||||
}
|
||||
|
||||
function createSingleCatalogProvider(overrides: Partial<ModelProviderConfig> & { apiKey: string }) {
|
||||
return {
|
||||
provider: {
|
||||
...createProviderConfig(overrides),
|
||||
apiKey: overrides.apiKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function expectSingleCatalogResult(params: {
|
||||
ctx: ProviderCatalogContext;
|
||||
allowExplicitBaseUrl?: boolean;
|
||||
buildProvider?: () => ModelProviderConfig;
|
||||
expected: Awaited<ReturnType<typeof buildSingleProviderApiKeyCatalog>>;
|
||||
}) {
|
||||
const result = await buildSingleProviderApiKeyCatalog({
|
||||
ctx: params.ctx,
|
||||
providerId: "test-provider",
|
||||
buildProvider: params.buildProvider ?? (() => createProviderConfig()),
|
||||
allowExplicitBaseUrl: params.allowExplicitBaseUrl,
|
||||
});
|
||||
|
||||
expect(result).toEqual(params.expected);
|
||||
}
|
||||
|
||||
async function expectPairedCatalogResult(params: {
|
||||
ctx: ProviderCatalogContext;
|
||||
expected: Record<string, ModelProviderConfig & { apiKey: string }>;
|
||||
}) {
|
||||
const result = await buildPairedProviderApiKeyCatalog({
|
||||
ctx: params.ctx,
|
||||
providerId: "test-provider",
|
||||
buildProviders: async () => ({
|
||||
alpha: createProviderConfig(),
|
||||
beta: createProviderConfig(),
|
||||
}),
|
||||
});
|
||||
|
||||
expectPairedCatalogProviders(result, params.expected);
|
||||
}
|
||||
|
||||
describe("buildSingleProviderApiKeyCatalog", () => {
|
||||
it.each([
|
||||
{
|
||||
@@ -92,14 +133,9 @@ describe("buildSingleProviderApiKeyCatalog", () => {
|
||||
apiKeys: { "test-provider": "secret-key" },
|
||||
}),
|
||||
undefined,
|
||||
{
|
||||
provider: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://default.example/v1",
|
||||
models: [],
|
||||
apiKey: "secret-key",
|
||||
},
|
||||
},
|
||||
createSingleCatalogProvider({
|
||||
apiKey: "secret-key",
|
||||
}),
|
||||
],
|
||||
[
|
||||
"prefers explicit base url when allowed",
|
||||
@@ -117,24 +153,17 @@ describe("buildSingleProviderApiKeyCatalog", () => {
|
||||
},
|
||||
}),
|
||||
true,
|
||||
{
|
||||
provider: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://override.example/v1/",
|
||||
models: [],
|
||||
apiKey: "secret-key",
|
||||
},
|
||||
},
|
||||
createSingleCatalogProvider({
|
||||
baseUrl: "https://override.example/v1/",
|
||||
apiKey: "secret-key",
|
||||
}),
|
||||
],
|
||||
] as const)("%s", async (_name, ctx, allowExplicitBaseUrl, expected) => {
|
||||
const result = await buildSingleProviderApiKeyCatalog({
|
||||
await expectSingleCatalogResult({
|
||||
ctx,
|
||||
providerId: "test-provider",
|
||||
buildProvider: () => createProviderConfig(),
|
||||
allowExplicitBaseUrl,
|
||||
expected,
|
||||
});
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it("matches explicit base url config across canonical provider aliases", async () => {
|
||||
@@ -166,29 +195,23 @@ describe("buildSingleProviderApiKeyCatalog", () => {
|
||||
});
|
||||
|
||||
it("adds api key to each paired provider", async () => {
|
||||
const result = await buildPairedProviderApiKeyCatalog({
|
||||
await expectPairedCatalogResult({
|
||||
ctx: createCatalogContext({
|
||||
apiKeys: { "test-provider": "secret-key" },
|
||||
}),
|
||||
providerId: "test-provider",
|
||||
buildProviders: async () => ({
|
||||
alpha: createProviderConfig(),
|
||||
beta: createProviderConfig(),
|
||||
}),
|
||||
});
|
||||
|
||||
expectPairedCatalogProviders(result, {
|
||||
alpha: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://default.example/v1",
|
||||
models: [],
|
||||
apiKey: "secret-key",
|
||||
},
|
||||
beta: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://default.example/v1",
|
||||
models: [],
|
||||
apiKey: "secret-key",
|
||||
expected: {
|
||||
alpha: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://default.example/v1",
|
||||
models: [],
|
||||
apiKey: "secret-key",
|
||||
},
|
||||
beta: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://default.example/v1",
|
||||
models: [],
|
||||
apiKey: "secret-key",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,11 +38,13 @@ function expectGroupedProviderIds(
|
||||
expected: Record<ProviderDiscoveryOrder | "late", readonly string[]>,
|
||||
) {
|
||||
const grouped = groupPluginDiscoveryProvidersByOrder([...providers]);
|
||||
|
||||
expect(grouped.simple.map((provider) => provider.id)).toEqual(expected.simple);
|
||||
expect(grouped.profile.map((provider) => provider.id)).toEqual(expected.profile);
|
||||
expect(grouped.paired.map((provider) => provider.id)).toEqual(expected.paired);
|
||||
expect(grouped.late.map((provider) => provider.id)).toEqual(expected.late);
|
||||
const actual = {
|
||||
simple: grouped.simple.map((provider) => provider.id),
|
||||
profile: grouped.profile.map((provider) => provider.id),
|
||||
paired: grouped.paired.map((provider) => provider.id),
|
||||
late: grouped.late.map((provider) => provider.id),
|
||||
};
|
||||
expect(actual).toEqual(expected);
|
||||
}
|
||||
|
||||
function createCatalogRuntimeContext() {
|
||||
|
||||
@@ -30,6 +30,18 @@ function createTemplateModel(
|
||||
} as ProviderRuntimeModel;
|
||||
}
|
||||
|
||||
function expectClonedTemplateModel(
|
||||
params: Parameters<typeof cloneFirstTemplateModel>[0],
|
||||
expected: Record<string, unknown> | undefined,
|
||||
) {
|
||||
const model = cloneFirstTemplateModel(params);
|
||||
if (expected == null) {
|
||||
expect(model).toBeUndefined();
|
||||
return;
|
||||
}
|
||||
expect(model).toMatchObject(expected);
|
||||
}
|
||||
|
||||
describe("cloneFirstTemplateModel", () => {
|
||||
it.each([
|
||||
{
|
||||
@@ -60,12 +72,7 @@ describe("cloneFirstTemplateModel", () => {
|
||||
expected: undefined,
|
||||
},
|
||||
] as const)("$name", ({ params, expected }) => {
|
||||
const model = cloneFirstTemplateModel(params);
|
||||
if (expected == null) {
|
||||
expect(model).toBeUndefined();
|
||||
return;
|
||||
}
|
||||
expect(model).toMatchObject(expected);
|
||||
expectClonedTemplateModel(params, expected);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -36,6 +36,15 @@ function expectCatalogPrimaryModel(cfg: OpenClawConfig) {
|
||||
});
|
||||
}
|
||||
|
||||
function expectProviderModels(
|
||||
cfg: OpenClawConfig,
|
||||
providerId: string,
|
||||
expected: Record<string, unknown>,
|
||||
) {
|
||||
const providers = cfg.models?.providers as Record<string, unknown> | undefined;
|
||||
expect(providers?.[providerId]).toMatchObject(expected);
|
||||
}
|
||||
|
||||
function createDemoProviderParams(params?: {
|
||||
providerId?: string;
|
||||
baseUrl?: string;
|
||||
@@ -119,7 +128,7 @@ describe("provider onboarding preset appliers", () => {
|
||||
});
|
||||
|
||||
const cfg = appliers.applyConfig({}, "https://alt.test/v1");
|
||||
expect(cfg.models?.providers?.demo).toMatchObject({
|
||||
expectProviderModels(cfg, "demo", {
|
||||
baseUrl: "https://alt.test/v1",
|
||||
models: [
|
||||
{ id: "a", name: "Model A" },
|
||||
|
||||
@@ -60,6 +60,7 @@ const MODEL: ProviderRuntimeModel = {
|
||||
maxTokens: 8_192,
|
||||
};
|
||||
const DEMO_PROVIDER_ID = "demo";
|
||||
const EMPTY_MODEL_REGISTRY = { find: () => null } as never;
|
||||
|
||||
function createOpenAiCatalogProviderPlugin(
|
||||
overrides: Partial<ProviderPlugin> = {},
|
||||
@@ -134,6 +135,12 @@ function createDemoResolvedModelContext<TContext extends Record<string, unknown>
|
||||
});
|
||||
}
|
||||
|
||||
function expectCalledOnce(...mocks: Array<{ mock: { calls: unknown[] } }>) {
|
||||
for (const mockFn of mocks) {
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
}
|
||||
|
||||
describe("provider-runtime", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
@@ -307,7 +314,7 @@ describe("provider-runtime", () => {
|
||||
runProviderDynamicModel({
|
||||
provider: DEMO_PROVIDER_ID,
|
||||
context: createDemoRuntimeContext({
|
||||
modelRegistry: { find: () => null } as never,
|
||||
modelRegistry: EMPTY_MODEL_REGISTRY,
|
||||
}),
|
||||
}),
|
||||
).toMatchObject(MODEL);
|
||||
@@ -315,7 +322,7 @@ describe("provider-runtime", () => {
|
||||
await prepareProviderDynamicModel({
|
||||
provider: DEMO_PROVIDER_ID,
|
||||
context: createDemoRuntimeContext({
|
||||
modelRegistry: { find: () => null } as never,
|
||||
modelRegistry: EMPTY_MODEL_REGISTRY,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -555,13 +562,15 @@ describe("provider-runtime", () => {
|
||||
expectCodexBuiltInSuppression(resolveProviderBuiltInModelSuppression);
|
||||
await expectAugmentedCodexCatalog(augmentModelCatalogWithProviderPlugins);
|
||||
|
||||
expect(prepareDynamicModel).toHaveBeenCalledTimes(1);
|
||||
expect(refreshOAuth).toHaveBeenCalledTimes(1);
|
||||
expect(resolveSyntheticAuth).toHaveBeenCalledTimes(1);
|
||||
expect(buildUnknownModelHint).toHaveBeenCalledTimes(1);
|
||||
expect(prepareRuntimeAuth).toHaveBeenCalledTimes(1);
|
||||
expect(resolveUsageAuth).toHaveBeenCalledTimes(1);
|
||||
expect(fetchUsageSnapshot).toHaveBeenCalledTimes(1);
|
||||
expectCalledOnce(
|
||||
prepareDynamicModel,
|
||||
refreshOAuth,
|
||||
resolveSyntheticAuth,
|
||||
buildUnknownModelHint,
|
||||
prepareRuntimeAuth,
|
||||
resolveUsageAuth,
|
||||
fetchUsageSnapshot,
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves bundled catalog hooks through provider plugins", async () => {
|
||||
|
||||
@@ -48,6 +48,25 @@ function normalizeProviderFixture(provider: ProviderPlugin) {
|
||||
};
|
||||
}
|
||||
|
||||
function expectNormalizedProviderFixture(params: {
|
||||
provider: ProviderPlugin;
|
||||
expectedProvider?: Record<string, unknown>;
|
||||
expectedDiagnostics?: ReadonlyArray<{ level: PluginDiagnostic["level"]; message: string }>;
|
||||
expectedDiagnosticText?: readonly string[];
|
||||
}) {
|
||||
const result = normalizeProviderFixture(params.provider);
|
||||
if (params.expectedProvider) {
|
||||
expect(result.provider).toMatchObject(params.expectedProvider);
|
||||
}
|
||||
if (params.expectedDiagnostics) {
|
||||
expectDiagnosticMessages(result.diagnostics, params.expectedDiagnostics);
|
||||
}
|
||||
if (params.expectedDiagnosticText) {
|
||||
expectDiagnosticText(result.diagnostics, params.expectedDiagnosticText);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("normalizeRegisteredProvider", () => {
|
||||
it.each([
|
||||
{
|
||||
@@ -168,21 +187,22 @@ describe("normalizeRegisteredProvider", () => {
|
||||
] as const)(
|
||||
"$name",
|
||||
({ provider: inputProvider, expectedProvider, expectedDiagnostics, assert }) => {
|
||||
const { diagnostics, provider } = normalizeProviderFixture(inputProvider);
|
||||
const { diagnostics, provider } = expectNormalizedProviderFixture({
|
||||
provider: inputProvider,
|
||||
...(expectedProvider ? { expectedProvider } : {}),
|
||||
...(expectedDiagnostics ? { expectedDiagnostics } : {}),
|
||||
});
|
||||
|
||||
if (assert) {
|
||||
assert(provider, diagnostics);
|
||||
return;
|
||||
}
|
||||
|
||||
expect(provider).toMatchObject(expectedProvider);
|
||||
expectDiagnosticMessages(diagnostics, expectedDiagnostics);
|
||||
},
|
||||
);
|
||||
|
||||
it("prefers catalog when a provider registers both catalog and discovery", () => {
|
||||
const { diagnostics, provider } = normalizeProviderFixture(
|
||||
makeProvider({
|
||||
const { provider } = expectNormalizedProviderFixture({
|
||||
provider: makeProvider({
|
||||
catalog: {
|
||||
run: async () => null,
|
||||
},
|
||||
@@ -195,12 +215,12 @@ describe("normalizeRegisteredProvider", () => {
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
expectedDiagnosticText: [
|
||||
'provider "demo" registered both catalog and discovery; using catalog',
|
||||
],
|
||||
});
|
||||
|
||||
expect(provider?.catalog).toBeDefined();
|
||||
expect(provider?.discovery).toBeUndefined();
|
||||
expectDiagnosticText(diagnostics, [
|
||||
'provider "demo" registered both catalog and discovery; using catalog',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,7 +98,9 @@ function expectProviderResolutionCall(params?: {
|
||||
config?: object;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
workspaceDir?: string;
|
||||
count?: number;
|
||||
}) {
|
||||
expect(resolvePluginProviders).toHaveBeenCalledTimes(params?.count ?? 1);
|
||||
expect(resolvePluginProviders).toHaveBeenCalledWith({
|
||||
...createWizardRuntimeParams(params),
|
||||
bundledProviderAllowlistCompat: true,
|
||||
@@ -106,6 +108,10 @@ function expectProviderResolutionCall(params?: {
|
||||
});
|
||||
}
|
||||
|
||||
function setResolvedProviders(...providers: ProviderPlugin[]) {
|
||||
resolvePluginProviders.mockReturnValue(providers);
|
||||
}
|
||||
|
||||
function resolveWizardOptionsTwice(params: {
|
||||
config?: object;
|
||||
env: NodeJS.ProcessEnv;
|
||||
@@ -118,7 +124,7 @@ function resolveWizardOptionsTwice(params: {
|
||||
|
||||
function expectWizardProviderCacheMiss(params: { config?: object; env: NodeJS.ProcessEnv }) {
|
||||
resolveWizardOptionsTwice(params);
|
||||
expect(resolvePluginProviders).toHaveBeenCalledTimes(2);
|
||||
expectProviderResolutionCall({ ...params, count: 2 });
|
||||
}
|
||||
|
||||
function expectSingleWizardChoice(params: {
|
||||
@@ -127,7 +133,7 @@ function expectSingleWizardChoice(params: {
|
||||
expectedOption: Record<string, unknown>;
|
||||
expectedWizard: unknown;
|
||||
}) {
|
||||
resolvePluginProviders.mockReturnValue([params.provider]);
|
||||
setResolvedProviders(params.provider);
|
||||
expect(resolveProviderWizardOptions({})).toEqual([params.expectedOption]);
|
||||
expect(
|
||||
resolveProviderPluginChoice({
|
||||
@@ -236,7 +242,7 @@ describe("provider wizard boundaries", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
resolvePluginProviders.mockReturnValue([provider]);
|
||||
setResolvedProviders(provider);
|
||||
|
||||
expect(resolveProviderWizardOptions({})).toEqual([
|
||||
{
|
||||
@@ -299,7 +305,7 @@ describe("provider wizard boundaries", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
resolvePluginProviders.mockReturnValue([provider]);
|
||||
setResolvedProviders(provider);
|
||||
|
||||
expect(resolveProviderModelPickerEntries({})).toEqual([
|
||||
{
|
||||
@@ -314,13 +320,12 @@ describe("provider wizard boundaries", () => {
|
||||
const provider = createSglangWizardProvider({ includeModelPicker: true });
|
||||
const config = {};
|
||||
const env = createHomeEnv();
|
||||
resolvePluginProviders.mockReturnValue([provider]);
|
||||
setResolvedProviders(provider);
|
||||
|
||||
const runtimeParams = createWizardRuntimeParams({ config, env });
|
||||
expect(resolveProviderWizardOptions(runtimeParams)).toHaveLength(1);
|
||||
expect(resolveProviderModelPickerEntries(runtimeParams)).toHaveLength(1);
|
||||
|
||||
expect(resolvePluginProviders).toHaveBeenCalledTimes(1);
|
||||
expectProviderResolutionCall({ config, env });
|
||||
});
|
||||
|
||||
@@ -328,7 +333,7 @@ describe("provider wizard boundaries", () => {
|
||||
const provider = createSglangSetupProvider();
|
||||
const config = createSglangConfig();
|
||||
const env = createHomeEnv("-a");
|
||||
resolvePluginProviders.mockReturnValue([provider]);
|
||||
setResolvedProviders(provider);
|
||||
|
||||
expect(resolveProviderWizardOptions(createWizardRuntimeParams({ config, env }))).toHaveLength(
|
||||
1,
|
||||
@@ -341,7 +346,7 @@ describe("provider wizard boundaries", () => {
|
||||
1,
|
||||
);
|
||||
|
||||
expect(resolvePluginProviders).toHaveBeenCalledTimes(2);
|
||||
expectProviderResolutionCall({ config, env, count: 2 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -360,7 +365,7 @@ describe("provider wizard boundaries", () => {
|
||||
] as const)("$name", ({ env }) => {
|
||||
const provider = createSglangSetupProvider();
|
||||
const config = createSglangConfig();
|
||||
resolvePluginProviders.mockReturnValue([provider]);
|
||||
setResolvedProviders(provider);
|
||||
|
||||
expectWizardProviderCacheMiss({ config, env });
|
||||
});
|
||||
@@ -373,7 +378,7 @@ describe("provider wizard boundaries", () => {
|
||||
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5",
|
||||
OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: "20",
|
||||
});
|
||||
resolvePluginProviders.mockReturnValue([provider]);
|
||||
setResolvedProviders(provider);
|
||||
const runtimeParams = createWizardRuntimeParams({ config, env });
|
||||
|
||||
resolveProviderWizardOptions(runtimeParams);
|
||||
@@ -382,7 +387,7 @@ describe("provider wizard boundaries", () => {
|
||||
vi.advanceTimersByTime(2);
|
||||
resolveProviderWizardOptions(runtimeParams);
|
||||
|
||||
expect(resolvePluginProviders).toHaveBeenCalledTimes(2);
|
||||
expectProviderResolutionCall({ config, env, count: 2 });
|
||||
});
|
||||
|
||||
it("invalidates provider-wizard snapshots when cache-control env values change in place", () => {
|
||||
@@ -391,7 +396,7 @@ describe("provider wizard boundaries", () => {
|
||||
const env = createHomeEnv("", {
|
||||
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "1000",
|
||||
});
|
||||
resolvePluginProviders.mockReturnValue([provider]);
|
||||
setResolvedProviders(provider);
|
||||
|
||||
resolveProviderWizardOptions(createWizardRuntimeParams({ config, env }));
|
||||
|
||||
@@ -399,13 +404,13 @@ describe("provider wizard boundaries", () => {
|
||||
|
||||
resolveProviderWizardOptions(createWizardRuntimeParams({ config, env }));
|
||||
|
||||
expect(resolvePluginProviders).toHaveBeenCalledTimes(2);
|
||||
expectProviderResolutionCall({ config, env, count: 2 });
|
||||
});
|
||||
|
||||
it("routes model-selected hooks only to the matching provider", async () => {
|
||||
const matchingHook = vi.fn(async () => {});
|
||||
const otherHook = vi.fn(async () => {});
|
||||
resolvePluginProviders.mockReturnValue([
|
||||
setResolvedProviders(
|
||||
makeProvider({
|
||||
id: "ollama",
|
||||
label: "Ollama",
|
||||
@@ -416,7 +421,7 @@ describe("provider wizard boundaries", () => {
|
||||
label: "vLLM",
|
||||
onModelSelected: matchingHook,
|
||||
}),
|
||||
]);
|
||||
);
|
||||
|
||||
const env = createHomeEnv();
|
||||
await runProviderModelSelectedHook({
|
||||
|
||||
@@ -19,6 +19,13 @@ vi.mock("./manifest-registry.js", () => ({
|
||||
let resolveOwningPluginIdsForProvider: typeof import("./providers.js").resolveOwningPluginIdsForProvider;
|
||||
let resolvePluginProviders: typeof import("./providers.runtime.js").resolvePluginProviders;
|
||||
|
||||
function setManifestPlugins(plugins: Array<Record<string, unknown>>) {
|
||||
loadPluginManifestRegistryMock.mockReturnValue({
|
||||
plugins,
|
||||
diagnostics: [],
|
||||
});
|
||||
}
|
||||
|
||||
function getLastLoadPluginsCall(): Record<string, unknown> {
|
||||
const call = loadOpenClawPluginsMock.mock.calls.at(-1)?.[0];
|
||||
expect(call).toBeDefined();
|
||||
@@ -29,6 +36,10 @@ function cloneOptions<T>(value: T): T {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
function expectResolvedProviders(providers: unknown, expected: unknown[]) {
|
||||
expect(providers).toEqual(expected);
|
||||
}
|
||||
|
||||
function expectLastLoadPluginsCall(params?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
onlyPluginIds?: readonly string[];
|
||||
@@ -90,6 +101,10 @@ function expectResolvedAllowlistState(params?: {
|
||||
});
|
||||
}
|
||||
|
||||
function expectOwningPluginIds(provider: string, expectedPluginIds?: string[]) {
|
||||
expect(resolveOwningPluginIdsForProvider({ provider })).toEqual(expectedPluginIds);
|
||||
}
|
||||
|
||||
describe("resolvePluginProviders", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
@@ -103,17 +118,14 @@ describe("resolvePluginProviders", () => {
|
||||
config: params.config,
|
||||
changes: [],
|
||||
}));
|
||||
loadPluginManifestRegistryMock.mockReturnValue({
|
||||
plugins: [
|
||||
{ id: "google", providers: ["google"], origin: "bundled" },
|
||||
{ id: "browser", providers: [], origin: "bundled" },
|
||||
{ id: "kilocode", providers: ["kilocode"], origin: "bundled" },
|
||||
{ id: "moonshot", providers: ["moonshot"], origin: "bundled" },
|
||||
{ id: "google-gemini-cli-auth", providers: [], origin: "bundled" },
|
||||
{ id: "workspace-provider", providers: ["workspace-provider"], origin: "workspace" },
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
setManifestPlugins([
|
||||
{ id: "google", providers: ["google"], origin: "bundled" },
|
||||
{ id: "browser", providers: [], origin: "bundled" },
|
||||
{ id: "kilocode", providers: ["kilocode"], origin: "bundled" },
|
||||
{ id: "moonshot", providers: ["moonshot"], origin: "bundled" },
|
||||
{ id: "google-gemini-cli-auth", providers: [], origin: "bundled" },
|
||||
{ id: "workspace-provider", providers: ["workspace-provider"], origin: "workspace" },
|
||||
]);
|
||||
({ resolveOwningPluginIdsForProvider } = await import("./providers.js"));
|
||||
({ resolvePluginProviders } = await import("./providers.runtime.js"));
|
||||
});
|
||||
@@ -126,7 +138,7 @@ describe("resolvePluginProviders", () => {
|
||||
env,
|
||||
});
|
||||
|
||||
expect(providers).toEqual([{ id: "demo-provider", pluginId: "google" }]);
|
||||
expectResolvedProviders(providers, [{ id: "demo-provider", pluginId: "google" }]);
|
||||
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceDir: "/workspace/explicit",
|
||||
@@ -267,16 +279,13 @@ describe("resolvePluginProviders", () => {
|
||||
});
|
||||
|
||||
it("maps provider ids to owning plugin ids via manifests", () => {
|
||||
loadPluginManifestRegistryMock.mockReturnValue({
|
||||
plugins: [
|
||||
{ id: "minimax", providers: ["minimax", "minimax-portal"] },
|
||||
{ id: "openai", providers: ["openai", "openai-codex"] },
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
setManifestPlugins([
|
||||
{ id: "minimax", providers: ["minimax", "minimax-portal"] },
|
||||
{ id: "openai", providers: ["openai", "openai-codex"] },
|
||||
]);
|
||||
|
||||
expect(resolveOwningPluginIdsForProvider({ provider: "minimax-portal" })).toEqual(["minimax"]);
|
||||
expect(resolveOwningPluginIdsForProvider({ provider: "openai-codex" })).toEqual(["openai"]);
|
||||
expect(resolveOwningPluginIdsForProvider({ provider: "gemini-cli" })).toBeUndefined();
|
||||
expectOwningPluginIds("minimax-portal", ["minimax"]);
|
||||
expectOwningPluginIds("openai-codex", ["openai"]);
|
||||
expectOwningPluginIds("gemini-cli");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,17 +35,26 @@ function guardAssertions() {
|
||||
]);
|
||||
}
|
||||
|
||||
function expectGuardState(params: {
|
||||
source: string;
|
||||
type: "required" | "forbidden";
|
||||
needle: string;
|
||||
message: string;
|
||||
}) {
|
||||
if (params.type === "required") {
|
||||
expect(params.source, params.message).toContain(params.needle);
|
||||
return;
|
||||
}
|
||||
expect(params.source, params.message).not.toContain(params.needle);
|
||||
}
|
||||
|
||||
describe("runtime live state guardrails", () => {
|
||||
it.each(guardAssertions())(
|
||||
"keeps split-runtime state holders on explicit direct globals: $relativePath $type $needle",
|
||||
({ relativePath, type, needle, message }) => {
|
||||
const source = readFileSync(resolve(repoRoot, relativePath), "utf8");
|
||||
|
||||
if (type === "required") {
|
||||
expect(source, message).toContain(needle);
|
||||
} else {
|
||||
expect(source, message).not.toContain(needle);
|
||||
}
|
||||
expectGuardState({ source, type, needle, message });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -34,6 +34,10 @@ function createRegistrySet() {
|
||||
};
|
||||
}
|
||||
|
||||
function expectActiveChannelRegistry(registry: ReturnType<typeof createEmptyPluginRegistry>) {
|
||||
expect(getActivePluginChannelRegistry()).toBe(registry);
|
||||
}
|
||||
|
||||
describe("channel registry pinning", () => {
|
||||
afterEach(() => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
@@ -42,7 +46,7 @@ describe("channel registry pinning", () => {
|
||||
it("returns the active registry when not pinned", () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
setActivePluginRegistry(registry);
|
||||
expect(getActivePluginChannelRegistry()).toBe(registry);
|
||||
expectActiveChannelRegistry(registry);
|
||||
});
|
||||
|
||||
it("preserves pinned channel registry across setActivePluginRegistry calls", () => {
|
||||
@@ -54,7 +58,7 @@ describe("channel registry pinning", () => {
|
||||
const replacement = createEmptyPluginRegistry();
|
||||
setActivePluginRegistry(replacement);
|
||||
|
||||
expect(getActivePluginChannelRegistry()).toBe(startup);
|
||||
expectActiveChannelRegistry(startup);
|
||||
expect(getActivePluginChannelRegistry()!.channels).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -110,15 +114,13 @@ describe("channel registry pinning", () => {
|
||||
}
|
||||
|
||||
setActivePluginRegistry(replacement);
|
||||
expect(getActivePluginChannelRegistry()).toBe(expectDuringPin ? startup : replacement);
|
||||
expectActiveChannelRegistry(expectDuringPin ? startup : replacement);
|
||||
|
||||
if (pin) {
|
||||
releasePinnedPluginChannelRegistry(releasePinnedRegistry ? startup : unrelated);
|
||||
}
|
||||
|
||||
expect(getActivePluginChannelRegistry()).toBe(
|
||||
expectAfterSwap === "second" ? replacement : startup,
|
||||
);
|
||||
expectActiveChannelRegistry(expectAfterSwap === "second" ? replacement : startup);
|
||||
});
|
||||
|
||||
it("requireActivePluginChannelRegistry creates a registry when none exists", () => {
|
||||
@@ -136,6 +138,6 @@ describe("channel registry pinning", () => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
|
||||
setActivePluginRegistry(fresh);
|
||||
expect(getActivePluginChannelRegistry()).toBe(fresh);
|
||||
expectActiveChannelRegistry(fresh);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,11 @@ function createRuntimeRegistryPair() {
|
||||
};
|
||||
}
|
||||
|
||||
function expectRegistryVersions(params: { active: number; routes: number }) {
|
||||
expect(getActivePluginRegistryVersion()).toBe(params.active);
|
||||
expect(getActivePluginHttpRouteRegistryVersion()).toBe(params.routes);
|
||||
}
|
||||
|
||||
function expectActiveRouteRegistryResolution(params: {
|
||||
pinnedRegistry: ReturnType<typeof createEmptyPluginRegistry>;
|
||||
explicitRegistry: ReturnType<typeof createEmptyPluginRegistry>;
|
||||
@@ -78,8 +83,10 @@ describe("plugin runtime route registry", () => {
|
||||
|
||||
pinActivePluginHttpRouteRegistry(repinnedRegistry);
|
||||
|
||||
expect(getActivePluginRegistryVersion()).toBe(activeVersionBeforeRepin);
|
||||
expect(getActivePluginHttpRouteRegistryVersion()).toBe(routeVersionBeforeRepin + 1);
|
||||
expectRegistryVersions({
|
||||
active: activeVersionBeforeRepin,
|
||||
routes: routeVersionBeforeRepin + 1,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -24,20 +24,27 @@ describe("gateway request scope", () => {
|
||||
});
|
||||
}
|
||||
|
||||
function expectGatewayScope(
|
||||
runtimeScope: Awaited<ReturnType<typeof importGatewayRequestScopeModule>>,
|
||||
expected: PluginRuntimeGatewayRequestScope,
|
||||
) {
|
||||
expect(runtimeScope.getPluginRuntimeGatewayRequestScope()).toEqual(expected);
|
||||
}
|
||||
|
||||
it("reuses AsyncLocalStorage across reloaded module instances", async () => {
|
||||
const first = await importGatewayRequestScopeModule();
|
||||
|
||||
await first.withPluginRuntimeGatewayRequestScope(TEST_SCOPE, async () => {
|
||||
vi.resetModules();
|
||||
const second = await importGatewayRequestScopeModule();
|
||||
expect(second.getPluginRuntimeGatewayRequestScope()).toEqual(TEST_SCOPE);
|
||||
expectGatewayScope(second, TEST_SCOPE);
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches plugin id to the active scope", async () => {
|
||||
await withTestGatewayScope(async (runtimeScope) => {
|
||||
await runtimeScope.withPluginRuntimePluginIdScope("voice-call", async () => {
|
||||
expect(runtimeScope.getPluginRuntimeGatewayRequestScope()).toEqual({
|
||||
expectGatewayScope(runtimeScope, {
|
||||
...TEST_SCOPE,
|
||||
pluginId: "voice-call",
|
||||
});
|
||||
|
||||
@@ -50,6 +50,12 @@ function expectGatewaySubagentRunFailure(
|
||||
);
|
||||
}
|
||||
|
||||
function expectFunctionKeys(value: Record<string, unknown>, keys: readonly string[]) {
|
||||
keys.forEach((key) => {
|
||||
expect(typeof value[key]).toBe("function");
|
||||
});
|
||||
}
|
||||
|
||||
describe("plugin runtime command execution", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -96,10 +102,12 @@ describe("plugin runtime command execution", () => {
|
||||
{
|
||||
name: "exposes runtime.mediaUnderstanding helpers and keeps stt as an alias",
|
||||
assert: (runtime: ReturnType<typeof createPluginRuntime>) => {
|
||||
expect(typeof runtime.mediaUnderstanding.runFile).toBe("function");
|
||||
expect(typeof runtime.mediaUnderstanding.describeImageFile).toBe("function");
|
||||
expect(typeof runtime.mediaUnderstanding.describeImageFileWithModel).toBe("function");
|
||||
expect(typeof runtime.mediaUnderstanding.describeVideoFile).toBe("function");
|
||||
expectFunctionKeys(runtime.mediaUnderstanding as Record<string, unknown>, [
|
||||
"runFile",
|
||||
"describeImageFile",
|
||||
"describeImageFileWithModel",
|
||||
"describeVideoFile",
|
||||
]);
|
||||
expect(runtime.mediaUnderstanding.transcribeAudioFile).toBe(
|
||||
runtime.stt.transcribeAudioFile,
|
||||
);
|
||||
@@ -108,15 +116,19 @@ describe("plugin runtime command execution", () => {
|
||||
{
|
||||
name: "exposes runtime.imageGeneration helpers",
|
||||
assert: (runtime: ReturnType<typeof createPluginRuntime>) => {
|
||||
expect(typeof runtime.imageGeneration.generate).toBe("function");
|
||||
expect(typeof runtime.imageGeneration.listProviders).toBe("function");
|
||||
expectFunctionKeys(runtime.imageGeneration as Record<string, unknown>, [
|
||||
"generate",
|
||||
"listProviders",
|
||||
]);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "exposes runtime.webSearch helpers",
|
||||
assert: (runtime: ReturnType<typeof createPluginRuntime>) => {
|
||||
expect(typeof runtime.webSearch.listProviders).toBe("function");
|
||||
expect(typeof runtime.webSearch.search).toBe("function");
|
||||
expectFunctionKeys(runtime.webSearch as Record<string, unknown>, [
|
||||
"listProviders",
|
||||
"search",
|
||||
]);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -126,17 +138,23 @@ describe("plugin runtime command execution", () => {
|
||||
model: DEFAULT_MODEL,
|
||||
provider: DEFAULT_PROVIDER,
|
||||
});
|
||||
expect(typeof runtime.agent.runEmbeddedPiAgent).toBe("function");
|
||||
expect(typeof runtime.agent.resolveAgentDir).toBe("function");
|
||||
expect(typeof runtime.agent.session.resolveSessionFilePath).toBe("function");
|
||||
expectFunctionKeys(runtime.agent as Record<string, unknown>, [
|
||||
"runEmbeddedPiAgent",
|
||||
"resolveAgentDir",
|
||||
]);
|
||||
expectFunctionKeys(runtime.agent.session as Record<string, unknown>, [
|
||||
"resolveSessionFilePath",
|
||||
]);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "exposes runtime.modelAuth with getApiKeyForModel and resolveApiKeyForProvider",
|
||||
assert: (runtime: ReturnType<typeof createPluginRuntime>) => {
|
||||
expect(runtime.modelAuth).toBeDefined();
|
||||
expect(typeof runtime.modelAuth.getApiKeyForModel).toBe("function");
|
||||
expect(typeof runtime.modelAuth.resolveApiKeyForProvider).toBe("function");
|
||||
expectFunctionKeys(runtime.modelAuth as Record<string, unknown>, [
|
||||
"getApiKeyForModel",
|
||||
"resolveApiKeyForProvider",
|
||||
]);
|
||||
},
|
||||
},
|
||||
] as const)("$name", ({ assert }) => {
|
||||
|
||||
@@ -23,6 +23,13 @@ function buildTelegramTypingParams(
|
||||
};
|
||||
}
|
||||
|
||||
function expectTelegramPulseCount(
|
||||
pulse: ReturnType<typeof vi.fn<() => Promise<unknown>>>,
|
||||
expected: number,
|
||||
) {
|
||||
expect(pulse).toHaveBeenCalledTimes(expected);
|
||||
}
|
||||
|
||||
describe("createTelegramTypingLease", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
@@ -65,11 +72,11 @@ describe("createTelegramTypingLease", () => {
|
||||
pulse,
|
||||
});
|
||||
|
||||
expect(pulse).toHaveBeenCalledTimes(1);
|
||||
expectTelegramPulseCount(pulse, 1);
|
||||
await vi.advanceTimersByTimeAsync(TELEGRAM_TYPING_DEFAULT_INTERVAL_MS - 1);
|
||||
expect(pulse).toHaveBeenCalledTimes(1);
|
||||
expectTelegramPulseCount(pulse, 1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(pulse).toHaveBeenCalledTimes(2);
|
||||
expectTelegramPulseCount(pulse, 2);
|
||||
|
||||
lease.stop();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { expect, vi } from "vitest";
|
||||
|
||||
function expectPulseCount(pulse: { mock: { calls: unknown[] } }, expected: number) {
|
||||
expect(pulse.mock.calls).toHaveLength(expected);
|
||||
}
|
||||
|
||||
export async function expectIndependentTypingLeases<
|
||||
TParams extends { intervalMs?: number; pulse: (...args: never[]) => Promise<unknown> },
|
||||
TLease extends { refresh: () => Promise<void>; stop: () => void },
|
||||
@@ -13,17 +17,17 @@ export async function expectIndependentTypingLeases<
|
||||
const leaseA = await params.createLease(params.buildParams(pulse));
|
||||
const leaseB = await params.createLease(params.buildParams(pulse));
|
||||
|
||||
expect(pulse).toHaveBeenCalledTimes(2);
|
||||
expectPulseCount(pulse as unknown as { mock: { calls: unknown[] } }, 2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(pulse).toHaveBeenCalledTimes(4);
|
||||
expectPulseCount(pulse as unknown as { mock: { calls: unknown[] } }, 4);
|
||||
|
||||
leaseA.stop();
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(pulse).toHaveBeenCalledTimes(5);
|
||||
expectPulseCount(pulse as unknown as { mock: { calls: unknown[] } }, 5);
|
||||
|
||||
await leaseB.refresh();
|
||||
expect(pulse).toHaveBeenCalledTimes(6);
|
||||
expectPulseCount(pulse as unknown as { mock: { calls: unknown[] } }, 6);
|
||||
|
||||
leaseB.stop();
|
||||
}
|
||||
@@ -41,7 +45,7 @@ export async function expectBackgroundTypingPulseFailuresAreSwallowed<
|
||||
const lease = await params.createLease(params.buildParams(params.pulse));
|
||||
|
||||
await expect(vi.advanceTimersByTimeAsync(2_000)).resolves.toBe(vi);
|
||||
expect(params.pulse).toHaveBeenCalledTimes(2);
|
||||
expectPulseCount(params.pulse as unknown as { mock: { calls: unknown[] } }, 2);
|
||||
|
||||
lease.stop();
|
||||
}
|
||||
|
||||
@@ -75,9 +75,14 @@ function setSinglePluginLoadResult(
|
||||
});
|
||||
}
|
||||
|
||||
function expectInspectReport(pluginId: string) {
|
||||
function expectInspectReport(
|
||||
pluginId: string,
|
||||
): NonNullable<ReturnType<typeof buildPluginInspectReport>> {
|
||||
const inspect = buildPluginInspectReport({ id: pluginId });
|
||||
expect(inspect).not.toBeNull();
|
||||
if (!inspect) {
|
||||
throw new Error(`expected inspect report for ${pluginId}`);
|
||||
}
|
||||
return inspect;
|
||||
}
|
||||
|
||||
@@ -100,6 +105,15 @@ function expectNoCompatibilityWarnings() {
|
||||
expect(buildPluginCompatibilityWarnings()).toEqual([]);
|
||||
}
|
||||
|
||||
function expectCompatibilityOutput(params: { notices?: unknown[]; warnings?: string[] }) {
|
||||
if (params.notices) {
|
||||
expect(buildPluginCompatibilityNotices()).toEqual(params.notices);
|
||||
}
|
||||
if (params.warnings) {
|
||||
expect(buildPluginCompatibilityWarnings()).toEqual(params.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
function expectCapabilityKinds(
|
||||
inspect: NonNullable<ReturnType<typeof buildPluginInspectReport>>,
|
||||
kinds: readonly string[],
|
||||
@@ -107,6 +121,31 @@ function expectCapabilityKinds(
|
||||
expect(inspect.capabilities.map((entry) => entry.kind)).toEqual(kinds);
|
||||
}
|
||||
|
||||
function expectInspectShape(
|
||||
inspect: NonNullable<ReturnType<typeof buildPluginInspectReport>>,
|
||||
params: {
|
||||
shape: string;
|
||||
capabilityMode: string;
|
||||
capabilityKinds: readonly string[];
|
||||
},
|
||||
) {
|
||||
expect(inspect.shape).toBe(params.shape);
|
||||
expect(inspect.capabilityMode).toBe(params.capabilityMode);
|
||||
expectCapabilityKinds(inspect, params.capabilityKinds);
|
||||
}
|
||||
|
||||
function expectBundleInspectState(
|
||||
inspect: NonNullable<ReturnType<typeof buildPluginInspectReport>>,
|
||||
params: {
|
||||
bundleCapabilities: readonly string[];
|
||||
shape: string;
|
||||
},
|
||||
) {
|
||||
expect(inspect.bundleCapabilities).toEqual(params.bundleCapabilities);
|
||||
expect(inspect.mcpServers).toEqual([]);
|
||||
expect(inspect.shape).toBe(params.shape);
|
||||
}
|
||||
|
||||
describe("buildPluginStatusReport", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
@@ -313,15 +352,17 @@ describe("buildPluginStatusReport", () => {
|
||||
const inspect = buildPluginInspectReport({ id: "google" });
|
||||
|
||||
expect(inspect).not.toBeNull();
|
||||
expect(inspect?.shape).toBe("hybrid-capability");
|
||||
expect(inspect?.capabilityMode).toBe("hybrid");
|
||||
expectCapabilityKinds(inspect!, [
|
||||
"cli-backend",
|
||||
"text-inference",
|
||||
"media-understanding",
|
||||
"image-generation",
|
||||
"web-search",
|
||||
]);
|
||||
expectInspectShape(inspect!, {
|
||||
shape: "hybrid-capability",
|
||||
capabilityMode: "hybrid",
|
||||
capabilityKinds: [
|
||||
"cli-backend",
|
||||
"text-inference",
|
||||
"media-understanding",
|
||||
"image-generation",
|
||||
"web-search",
|
||||
],
|
||||
});
|
||||
expect(inspect?.usesLegacyBeforeAgentStart).toBe(true);
|
||||
expect(inspect?.compatibility).toEqual([
|
||||
createCompatibilityNotice({ pluginId: "google", code: "legacy-before-agent-start" }),
|
||||
@@ -378,10 +419,12 @@ describe("buildPluginStatusReport", () => {
|
||||
|
||||
const inspect = expectInspectReport("anthropic");
|
||||
|
||||
expect(inspect?.shape).toBe("plain-capability");
|
||||
expect(inspect?.capabilityMode).toBe("plain");
|
||||
expectCapabilityKinds(inspect!, ["cli-backend"]);
|
||||
expect(inspect?.capabilities).toEqual([{ kind: "cli-backend", ids: ["claude-cli"] }]);
|
||||
expectInspectShape(inspect, {
|
||||
shape: "plain-capability",
|
||||
capabilityMode: "plain",
|
||||
capabilityKinds: ["cli-backend"],
|
||||
});
|
||||
expect(inspect.capabilities).toEqual([{ kind: "cli-backend", ids: ["claude-cli"] }]);
|
||||
});
|
||||
|
||||
it("builds compatibility warnings for legacy compatibility paths", () => {
|
||||
@@ -397,10 +440,9 @@ describe("buildPluginStatusReport", () => {
|
||||
typedHooks: [createTypedHook({ pluginId: "lca", hookName: "before_agent_start" })],
|
||||
});
|
||||
|
||||
expect(buildPluginCompatibilityWarnings()).toEqual([
|
||||
`lca ${LEGACY_BEFORE_AGENT_START_MESSAGE}`,
|
||||
`lca ${HOOK_ONLY_MESSAGE}`,
|
||||
]);
|
||||
expectCompatibilityOutput({
|
||||
warnings: [`lca ${LEGACY_BEFORE_AGENT_START_MESSAGE}`, `lca ${HOOK_ONLY_MESSAGE}`],
|
||||
});
|
||||
});
|
||||
|
||||
it("builds structured compatibility notices with deterministic ordering", () => {
|
||||
@@ -422,10 +464,12 @@ describe("buildPluginStatusReport", () => {
|
||||
typedHooks: [createTypedHook({ pluginId: "legacy-only", hookName: "before_agent_start" })],
|
||||
});
|
||||
|
||||
expect(buildPluginCompatibilityNotices()).toEqual([
|
||||
createCompatibilityNotice({ pluginId: "hook-only", code: "hook-only" }),
|
||||
createCompatibilityNotice({ pluginId: "legacy-only", code: "legacy-before-agent-start" }),
|
||||
]);
|
||||
expectCompatibilityOutput({
|
||||
notices: [
|
||||
createCompatibilityNotice({ pluginId: "hook-only", code: "hook-only" }),
|
||||
createCompatibilityNotice({ pluginId: "legacy-only", code: "legacy-before-agent-start" }),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns no compatibility warnings for modern capability plugins", () => {
|
||||
@@ -474,9 +518,10 @@ describe("buildPluginStatusReport", () => {
|
||||
|
||||
const inspect = expectInspectReport(expectedId);
|
||||
|
||||
expect(inspect?.bundleCapabilities).toEqual(expectedBundleCapabilities);
|
||||
expect(inspect?.mcpServers).toEqual([]);
|
||||
expect(inspect?.shape).toBe(expectedShape);
|
||||
expectBundleInspectState(inspect, {
|
||||
bundleCapabilities: expectedBundleCapabilities,
|
||||
shape: expectedShape,
|
||||
});
|
||||
});
|
||||
|
||||
it("formats and summarizes compatibility notices", () => {
|
||||
|
||||
@@ -179,6 +179,27 @@ function expectScopedWebSearchCandidates(pluginIds: readonly string[]) {
|
||||
);
|
||||
}
|
||||
|
||||
function expectSnapshotMemoization(params: {
|
||||
config: { plugins?: Record<string, unknown> };
|
||||
env: NodeJS.ProcessEnv;
|
||||
expectedLoaderCalls: number;
|
||||
}) {
|
||||
const runtimeParams = createSnapshotParams({
|
||||
config: params.config,
|
||||
env: params.env,
|
||||
});
|
||||
|
||||
const first = resolvePluginWebSearchProviders(runtimeParams);
|
||||
const second = resolvePluginWebSearchProviders(runtimeParams);
|
||||
|
||||
if (params.expectedLoaderCalls === 1) {
|
||||
expect(second).toBe(first);
|
||||
} else {
|
||||
expect(second).not.toBe(first);
|
||||
}
|
||||
expectLoaderCallCount(params.expectedLoaderCalls);
|
||||
}
|
||||
|
||||
describe("resolvePluginWebSearchProviders", () => {
|
||||
beforeAll(async () => {
|
||||
({ createEmptyPluginRegistry } = await import("./registry.js"));
|
||||
@@ -270,15 +291,11 @@ describe("resolvePluginWebSearchProviders", () => {
|
||||
});
|
||||
|
||||
it("memoizes snapshot provider resolution for the same config and env", () => {
|
||||
const config = createBraveAllowConfig();
|
||||
const env = createWebSearchEnv();
|
||||
const runtimeParams = createSnapshotParams({ config, env });
|
||||
|
||||
const first = resolvePluginWebSearchProviders(runtimeParams);
|
||||
const second = resolvePluginWebSearchProviders(runtimeParams);
|
||||
|
||||
expect(second).toBe(first);
|
||||
expectLoaderCallCount(1);
|
||||
expectSnapshotMemoization({
|
||||
config: createBraveAllowConfig(),
|
||||
env: createWebSearchEnv(),
|
||||
expectedLoaderCalls: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("invalidates the snapshot cache when config or env contents change in place", () => {
|
||||
@@ -307,12 +324,11 @@ describe("resolvePluginWebSearchProviders", () => {
|
||||
},
|
||||
},
|
||||
])("$title", ({ env }) => {
|
||||
const config = createBraveAllowConfig();
|
||||
|
||||
resolvePluginWebSearchProviders(createSnapshotParams({ config, env: createWebSearchEnv(env) }));
|
||||
resolvePluginWebSearchProviders(createSnapshotParams({ config, env: createWebSearchEnv(env) }));
|
||||
|
||||
expectLoaderCallCount(2);
|
||||
expectSnapshotMemoization({
|
||||
config: createBraveAllowConfig(),
|
||||
env: createWebSearchEnv(env),
|
||||
expectedLoaderCalls: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not leak host Vitest env into an explicit non-Vitest cache key", () => {
|
||||
|
||||
@@ -58,6 +58,16 @@ function expectResolvedPluginIds(
|
||||
expect(providers.map((provider) => provider.pluginId)).toEqual(expectedPluginIds);
|
||||
}
|
||||
|
||||
function expectResolvedPluginIdsExcluding(
|
||||
providers: ReturnType<typeof resolveBundledPluginWebSearchProviders>,
|
||||
unexpectedPluginIds: readonly string[],
|
||||
) {
|
||||
const pluginIds = providers.map((provider) => provider.pluginId);
|
||||
for (const pluginId of unexpectedPluginIds) {
|
||||
expect(pluginIds).not.toContain(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
describe("resolveBundledPluginWebSearchProviders", () => {
|
||||
it(
|
||||
"returns bundled providers in alphabetical order",
|
||||
@@ -144,7 +154,7 @@ describe("resolveBundledPluginWebSearchProviders", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(providers.map((provider) => provider.pluginId)).not.toContain("perplexity");
|
||||
expectResolvedPluginIdsExcluding(providers, ["perplexity"]);
|
||||
});
|
||||
|
||||
it("can resolve bundled providers through the manifest-scoped loader path", () => {
|
||||
|
||||
Reference in New Issue
Block a user