mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
test(microsoft-foundry): remove global test bridge (#122881)
* test(microsoft-foundry): remove global test bridge * test(microsoft-foundry): type boundary fixtures * perf(ui): keep route transition out of startup
This commit is contained in:
committed by
GitHub
parent
3a0cb17739
commit
fd0fc80c8c
@@ -44,12 +44,6 @@ function shouldTestFoundryTextConnection(params: {
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env.VITEST === "true") {
|
||||
const key = Symbol.for("openclaw.microsoftFoundryTestApi");
|
||||
const api = (Reflect.get(globalThis, key) as Record<string, unknown> | undefined) ?? {};
|
||||
Reflect.set(globalThis, key, { ...api, shouldTestFoundryTextConnection });
|
||||
}
|
||||
|
||||
export const entraIdAuthMethod: ProviderAuthMethod = {
|
||||
id: "entra-id",
|
||||
label: "Entra ID (az login)",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Microsoft Foundry tests cover index plugin behavior.
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { ProviderAuthMethod } from "openclaw/plugin-sdk/core";
|
||||
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { azLoginDeviceCodeWithOptions, getAccessTokenResultAsync } from "./cli.js";
|
||||
@@ -22,19 +23,6 @@ import {
|
||||
requiresFoundryEntraIdClaudeAuth,
|
||||
usesFoundryResponsesByDefault,
|
||||
} from "./shared.js";
|
||||
import { microsoftFoundryTesting } from "./test-support.js";
|
||||
|
||||
const {
|
||||
buildFoundryConnectionTest,
|
||||
isAnthropicFoundryDeployment,
|
||||
isValidTenantIdentifier,
|
||||
resetFoundryRuntimeAuthCaches,
|
||||
shouldTestFoundryTextConnection,
|
||||
supportsFoundryImageInput,
|
||||
supportsFoundryReasoningContent,
|
||||
supportsFoundryReasoningEffort,
|
||||
} = microsoftFoundryTesting;
|
||||
|
||||
const execFileMock = vi.hoisted(() => vi.fn());
|
||||
const execFileSyncMock = vi.hoisted(() => vi.fn());
|
||||
const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn());
|
||||
@@ -138,6 +126,8 @@ const defaultFoundryModelId = "gpt-5.4";
|
||||
const defaultFoundryProfileId = "microsoft-foundry:entra";
|
||||
const defaultFoundryAgentDir = "/tmp/test-agent";
|
||||
const defaultAzureCliLoginError = "Please run 'az login' to setup account.";
|
||||
let runtimeAuthTestSequence = 0;
|
||||
let runtimeAuthTestTenantId = "tenant-0";
|
||||
|
||||
function buildFoundryModel(
|
||||
overrides: Partial<{
|
||||
@@ -224,7 +214,7 @@ function buildEntraProfileStore(
|
||||
modelId: "custom-deployment",
|
||||
modelName: defaultFoundryModelId,
|
||||
api: "openai-responses",
|
||||
tenantId: "tenant-id",
|
||||
tenantId: runtimeAuthTestTenantId,
|
||||
...overrides,
|
||||
},
|
||||
},
|
||||
@@ -291,7 +281,8 @@ function mockAzureCliLoginFailure(delayMs?: number) {
|
||||
|
||||
describe("microsoft-foundry plugin", () => {
|
||||
beforeEach(() => {
|
||||
resetFoundryRuntimeAuthCaches();
|
||||
runtimeAuthTestSequence += 1;
|
||||
runtimeAuthTestTenantId = `tenant-${runtimeAuthTestSequence}`;
|
||||
execFileMock.mockReset();
|
||||
execFileSyncMock.mockReset();
|
||||
ensureAuthProfileStoreMock.mockReset();
|
||||
@@ -862,12 +853,6 @@ describe("microsoft-foundry plugin", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts tenant domains as valid tenant identifiers", () => {
|
||||
expect(isValidTenantIdentifier("contoso.onmicrosoft.com")).toBe(true);
|
||||
expect(isValidTenantIdentifier("00000000-0000-0000-0000-000000000000")).toBe(true);
|
||||
expect(isValidTenantIdentifier("not a tenant")).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults Azure OpenAI model families to the documented API surfaces", () => {
|
||||
expect(usesFoundryResponsesByDefault("gpt-5.4")).toBe(true);
|
||||
expect(usesFoundryResponsesByDefault("gpt-5.2-codex")).toBe(true);
|
||||
@@ -879,16 +864,6 @@ describe("microsoft-foundry plugin", () => {
|
||||
expect(requiresFoundryMaxCompletionTokens("gpt-5-chat")).toBe(true);
|
||||
expect(requiresFoundryMaxCompletionTokens("o3")).toBe(true);
|
||||
expect(requiresFoundryMaxCompletionTokens("gpt-4o")).toBe(false);
|
||||
expect(supportsFoundryReasoningEffort("gpt-5.4")).toBe(true);
|
||||
expect(supportsFoundryReasoningEffort("gpt-5-chat")).toBe(false);
|
||||
expect(supportsFoundryReasoningEffort("gpt-5.1-chat")).toBe(true);
|
||||
expect(supportsFoundryReasoningEffort("o3")).toBe(true);
|
||||
expect(supportsFoundryReasoningEffort("o1-mini")).toBe(false);
|
||||
expect(supportsFoundryReasoningEffort("MAI-DS-R1")).toBe(false);
|
||||
expect(supportsFoundryReasoningContent("MAI-DS-R1")).toBe(true);
|
||||
expect(supportsFoundryImageInput("gpt-5.4")).toBe(true);
|
||||
expect(supportsFoundryImageInput("gpt-4o")).toBe(true);
|
||||
expect(supportsFoundryImageInput("MAI-DS-R1")).toBe(false);
|
||||
expect(isFoundryMaiImageModel("MAI-Image-2.5-Flash")).toBe(true);
|
||||
expect(isFoundryMaiImageModel("MAI-Image-2e")).toBe(true);
|
||||
expect(isFoundryMaiImageModel("MAI-DS-R1")).toBe(false);
|
||||
@@ -940,19 +915,59 @@ describe("microsoft-foundry plugin", () => {
|
||||
expect(requireFoundryProviderPatch(result).models[0]?.name).toBe("MAI-Image-2.5");
|
||||
});
|
||||
|
||||
it("skips chat connection probes for MAI image deployments", () => {
|
||||
it("skips chat connection probes for MAI image deployments", async () => {
|
||||
execFileSyncMock.mockImplementation((_command, args) => {
|
||||
const azArgs = args as string[];
|
||||
if (azArgs[0] === "version") {
|
||||
return "";
|
||||
}
|
||||
if (azArgs[0] === "account" && azArgs[1] === "show") {
|
||||
return JSON.stringify({
|
||||
name: "Foundry Account",
|
||||
id: "account-id",
|
||||
tenantId: "tenant-id",
|
||||
user: { name: "operator@example.com" },
|
||||
});
|
||||
}
|
||||
if (azArgs[0] === "account" && azArgs[1] === "list") {
|
||||
return "[]";
|
||||
}
|
||||
throw new Error(`unexpected az command: ${azArgs.join(" ")}`);
|
||||
});
|
||||
const provider = registerProvider();
|
||||
const authMethod = provider.auth.find((method: ProviderAuthMethod) => method.id === "entra-id");
|
||||
if (!authMethod) {
|
||||
throw new Error("expected Microsoft Foundry Entra auth method");
|
||||
}
|
||||
const text = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce("https://example.services.ai.azure.com")
|
||||
.mockResolvedValueOnce("prod-image");
|
||||
const select = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce("mai-image")
|
||||
.mockResolvedValueOnce("MAI-Image-2.5");
|
||||
|
||||
const result = await authMethod.run({
|
||||
config: {},
|
||||
agentDir: defaultFoundryAgentDir,
|
||||
prompter: {
|
||||
confirm: vi.fn(async () => true),
|
||||
note: vi.fn(async () => undefined),
|
||||
text,
|
||||
select,
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(
|
||||
shouldTestFoundryTextConnection({
|
||||
modelId: "prod-image",
|
||||
modelNameHint: "MAI-Image-2.5",
|
||||
execFileSyncMock.mock.calls.some((call) => {
|
||||
const args = call[1];
|
||||
return Array.isArray(args) && args[0] === "account" && args[1] === "get-access-token";
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldTestFoundryTextConnection({
|
||||
modelId: "prod-chat",
|
||||
modelNameHint: "gpt-5.4",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(result.configPatch?.agents?.defaults?.mediaModels?.image).toEqual({
|
||||
primary: "microsoft-foundry/prod-image",
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies custom API-key MAI image deployments during manual setup", async () => {
|
||||
@@ -1273,19 +1288,6 @@ describe("microsoft-foundry plugin", () => {
|
||||
expect(Object.hasOwn(provider, "headers")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the minimum supported response token count for GPT-5 connection tests", () => {
|
||||
const testRequest = buildFoundryConnectionTest({
|
||||
endpoint: "https://example.services.ai.azure.com",
|
||||
modelId: "gpt-5.4",
|
||||
modelNameHint: "gpt-5.4",
|
||||
api: "openai-responses",
|
||||
});
|
||||
|
||||
expect(testRequest.url).toContain("/responses");
|
||||
expect(testRequest.body.model).toBe("gpt-5.4");
|
||||
expect(testRequest.body.max_output_tokens).toBe(16);
|
||||
});
|
||||
|
||||
it("marks Foundry responses models to omit explicit store=false payloads", () => {
|
||||
const result = buildFoundryAuthResult({
|
||||
profileId: "microsoft-foundry:entra",
|
||||
@@ -1855,36 +1857,6 @@ describe("microsoft-foundry plugin", () => {
|
||||
).toBe("https://example.services.ai.azure.com");
|
||||
});
|
||||
|
||||
it("includes api-version for non GPT-5 chat completion connection tests", () => {
|
||||
const testRequest = buildFoundryConnectionTest({
|
||||
endpoint: "https://example.services.ai.azure.com",
|
||||
modelId: "FW-GLM-5",
|
||||
modelNameHint: "FW-GLM-5",
|
||||
api: "openai-completions",
|
||||
});
|
||||
|
||||
expect(testRequest.url).toContain("/chat/completions");
|
||||
expect(testRequest.body.model).toBe("FW-GLM-5");
|
||||
expect(testRequest.body.max_tokens).toBe(1);
|
||||
});
|
||||
|
||||
it("builds Anthropic Messages connection tests for Claude deployments", () => {
|
||||
const testRequest = buildFoundryConnectionTest({
|
||||
endpoint: "https://example.services.ai.azure.com/openai/v1",
|
||||
modelId: "prod-fable",
|
||||
modelNameHint: "claude-fable-5",
|
||||
api: "anthropic-messages",
|
||||
});
|
||||
|
||||
expect(testRequest.url).toBe("https://example.services.ai.azure.com/anthropic/v1/messages");
|
||||
expect(testRequest.body).toEqual({
|
||||
model: "prod-fable",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
thinking: { type: "adaptive" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns actionable Azure CLI login errors", async () => {
|
||||
mockAzureCliLoginFailure();
|
||||
|
||||
@@ -2088,21 +2060,6 @@ describe("selectFoundryDeployment", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAnthropicFoundryDeployment", () => {
|
||||
it.each(["claude-opus-4-6", "Claude-Sonnet-4", "claude-3.5-haiku", "CLAUDE-instant"])(
|
||||
"detects Anthropic model: %s",
|
||||
(name) => {
|
||||
expect(isAnthropicFoundryDeployment(name)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["gpt-5.4", "o4-mini", "phi-4", "llama-3", undefined, null, ""])(
|
||||
"rejects non-Anthropic model: %s",
|
||||
(name) => {
|
||||
expect(isAnthropicFoundryDeployment(name)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
describe("azLoginDeviceCodeWithOptions utf-8 chunk boundary", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
// Microsoft Foundry tests cover bounded connection-test error reads.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as cli from "./cli.js";
|
||||
import { testFoundryConnection } from "./onboard.js";
|
||||
import { DEFAULT_API } from "./shared.js";
|
||||
import { promptTenantId, testFoundryConnection } from "./onboard.js";
|
||||
import {
|
||||
ANTHROPIC_MESSAGES_API,
|
||||
DEFAULT_API,
|
||||
DEFAULT_GPT5_API,
|
||||
type FoundryProviderApi,
|
||||
} from "./shared.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuard: vi.fn(),
|
||||
@@ -12,6 +17,59 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
fetchWithSsrFGuard: hoisted.fetchWithSsrFGuard,
|
||||
}));
|
||||
|
||||
type FoundryConnectionRequestCase = {
|
||||
name: string;
|
||||
endpoint: string;
|
||||
modelId: string;
|
||||
modelNameHint: string;
|
||||
api: FoundryProviderApi;
|
||||
expectedUrl: string;
|
||||
expectedBody: Record<string, unknown>;
|
||||
expectedHeaders: Record<string, string>;
|
||||
};
|
||||
|
||||
const foundryConnectionRequestCases: FoundryConnectionRequestCase[] = [
|
||||
{
|
||||
name: "Responses",
|
||||
endpoint: "https://example.services.ai.azure.com",
|
||||
modelId: "gpt-5.4",
|
||||
modelNameHint: "gpt-5.4",
|
||||
api: DEFAULT_GPT5_API,
|
||||
expectedUrl: "https://example.services.ai.azure.com/openai/v1/responses",
|
||||
expectedBody: { model: "gpt-5.4", input: "hi", max_output_tokens: 16 },
|
||||
expectedHeaders: {},
|
||||
},
|
||||
{
|
||||
name: "Chat Completions",
|
||||
endpoint: "https://example.services.ai.azure.com",
|
||||
modelId: "FW-GLM-5",
|
||||
modelNameHint: "FW-GLM-5",
|
||||
api: DEFAULT_API,
|
||||
expectedUrl: "https://example.services.ai.azure.com/openai/v1/chat/completions",
|
||||
expectedBody: {
|
||||
model: "FW-GLM-5",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
},
|
||||
expectedHeaders: {},
|
||||
},
|
||||
{
|
||||
name: "Anthropic Messages",
|
||||
endpoint: "https://example.services.ai.azure.com/openai/v1",
|
||||
modelId: "prod-fable",
|
||||
modelNameHint: "claude-fable-5",
|
||||
api: ANTHROPIC_MESSAGES_API,
|
||||
expectedUrl: "https://example.services.ai.azure.com/anthropic/v1/messages",
|
||||
expectedBody: {
|
||||
model: "prod-fable",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
thinking: { type: "adaptive" },
|
||||
},
|
||||
expectedHeaders: { "anthropic-version": "2023-06-01" },
|
||||
},
|
||||
];
|
||||
|
||||
function cancelTrackedResponse(
|
||||
text: string,
|
||||
init: ResponseInit,
|
||||
@@ -44,6 +102,39 @@ describe("testFoundryConnection", () => {
|
||||
hoisted.fetchWithSsrFGuard.mockReset();
|
||||
});
|
||||
|
||||
it.each(foundryConnectionRequestCases)(
|
||||
"sends the $name connection request through the guarded transport",
|
||||
async (testCase) => {
|
||||
const release = vi.fn(async () => undefined);
|
||||
hoisted.fetchWithSsrFGuard.mockResolvedValue({
|
||||
response: new Response(null, { status: 200 }),
|
||||
release,
|
||||
});
|
||||
|
||||
await testFoundryConnection({
|
||||
ctx: { prompter: { note: vi.fn() } } as never,
|
||||
endpoint: testCase.endpoint,
|
||||
modelId: testCase.modelId,
|
||||
modelNameHint: testCase.modelNameHint,
|
||||
api: testCase.api,
|
||||
});
|
||||
|
||||
const request = hoisted.fetchWithSsrFGuard.mock.calls[0]?.[0];
|
||||
expect(request?.url).toBe(testCase.expectedUrl);
|
||||
expect(request?.timeoutMs).toBe(15_000);
|
||||
expect(request?.init?.method).toBe("POST");
|
||||
expect(request?.init?.body).toBe(JSON.stringify(testCase.expectedBody));
|
||||
expect(new Headers(request?.init?.headers)).toEqual(
|
||||
new Headers({
|
||||
Authorization: "Bearer token",
|
||||
"Content-Type": "application/json",
|
||||
...testCase.expectedHeaders,
|
||||
}),
|
||||
);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("bounds connection-test error bodies without using response.text()", async () => {
|
||||
const note = vi.fn();
|
||||
const tracked = cancelTrackedResponse(`${"foundry failure ".repeat(1024)}tail`, {
|
||||
@@ -107,3 +198,20 @@ describe("testFoundryConnection", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("promptTenantId", () => {
|
||||
it("validates tenant domains and UUIDs through the prompt boundary", async () => {
|
||||
const text = vi.fn(async (options: { validate?: (value: string) => string | undefined }) => {
|
||||
expect(options.validate?.("contoso.onmicrosoft.com")).toBeUndefined();
|
||||
expect(options.validate?.("00000000-0000-0000-0000-000000000000")).toBeUndefined();
|
||||
expect(options.validate?.("not a tenant")).toBe("Enter a valid tenant ID or tenant domain");
|
||||
expect(options.validate?.("")).toBe("Tenant ID is required");
|
||||
return "contoso.onmicrosoft.com";
|
||||
});
|
||||
|
||||
await expect(promptTenantId({ prompter: { text } } as never, { required: true })).resolves.toBe(
|
||||
"contoso.onmicrosoft.com",
|
||||
);
|
||||
expect(text).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -503,12 +503,6 @@ function isValidTenantIdentifier(value: string): boolean {
|
||||
return isTenantUuid || isTenantDomain;
|
||||
}
|
||||
|
||||
if (process.env.VITEST === "true") {
|
||||
const key = Symbol.for("openclaw.microsoftFoundryTestApi");
|
||||
const api = (Reflect.get(globalThis, key) as Record<string, unknown> | undefined) ?? {};
|
||||
Reflect.set(globalThis, key, { ...api, buildFoundryConnectionTest, isValidTenantIdentifier });
|
||||
}
|
||||
|
||||
export async function promptTenantId(
|
||||
ctx: ProviderAuthContext,
|
||||
params?: {
|
||||
|
||||
@@ -28,17 +28,6 @@ const cachedTokens = new Map<string, CachedTokenEntry>();
|
||||
const refreshPromises = new Map<string, Promise<{ apiKey: string; expiresAt: number }>>();
|
||||
const FOUNDRY_TOKEN_FALLBACK_LIFETIME_MS = 55 * 60 * 1000;
|
||||
|
||||
function resetFoundryRuntimeAuthCaches(): void {
|
||||
cachedTokens.clear();
|
||||
refreshPromises.clear();
|
||||
}
|
||||
|
||||
if (process.env.VITEST === "true") {
|
||||
const key = Symbol.for("openclaw.microsoftFoundryTestApi");
|
||||
const api = (Reflect.get(globalThis, key) as Record<string, unknown> | undefined) ?? {};
|
||||
Reflect.set(globalThis, key, { ...api, resetFoundryRuntimeAuthCaches });
|
||||
}
|
||||
|
||||
async function refreshEntraToken(params?: {
|
||||
scope?: string;
|
||||
subscriptionId?: string;
|
||||
|
||||
@@ -295,18 +295,6 @@ function supportsFoundryReasoningEffort(value?: string | null): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env.VITEST === "true") {
|
||||
const key = Symbol.for("openclaw.microsoftFoundryTestApi");
|
||||
const api = (Reflect.get(globalThis, key) as Record<string, unknown> | undefined) ?? {};
|
||||
Reflect.set(globalThis, key, {
|
||||
...api,
|
||||
isAnthropicFoundryDeployment,
|
||||
supportsFoundryImageInput,
|
||||
supportsFoundryReasoningContent,
|
||||
supportsFoundryReasoningEffort,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveFoundryReasoningEfforts(value?: string | null): string[] | undefined {
|
||||
const normalized = normalizeFoundryModelName(value);
|
||||
if (!normalized || !supportsFoundryReasoningEffort(normalized)) {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { FoundryProviderApi } from "./shared.js";
|
||||
|
||||
type MicrosoftFoundryTestApi = {
|
||||
buildFoundryConnectionTest: (params: {
|
||||
endpoint: string;
|
||||
modelId: string;
|
||||
modelNameHint?: string | null;
|
||||
api: FoundryProviderApi;
|
||||
}) => { url: string; body: Record<string, unknown> };
|
||||
isAnthropicFoundryDeployment: (value?: string | null) => boolean;
|
||||
isValidTenantIdentifier: (value: string) => boolean;
|
||||
resetFoundryRuntimeAuthCaches: () => void;
|
||||
shouldTestFoundryTextConnection: (params: {
|
||||
modelId: string;
|
||||
modelNameHint?: string | null;
|
||||
}) => boolean;
|
||||
supportsFoundryImageInput: (value?: string | null) => boolean;
|
||||
supportsFoundryReasoningContent: (value?: string | null) => boolean;
|
||||
supportsFoundryReasoningEffort: (value?: string | null) => boolean;
|
||||
};
|
||||
|
||||
const api = Reflect.get(globalThis, Symbol.for("openclaw.microsoftFoundryTestApi"));
|
||||
if (!api) {
|
||||
throw new Error("Microsoft Foundry test API is unavailable");
|
||||
}
|
||||
|
||||
export const microsoftFoundryTesting = api as MicrosoftFoundryTestApi;
|
||||
+4
-13
@@ -44,7 +44,6 @@ import { createNativeChatDrafts } from "./native-bridge.ts";
|
||||
import { startNativeLinkRouting } from "./native-link-routing.ts";
|
||||
import { createNativeNotificationsCapability } from "./native-notifications.ts";
|
||||
import { createApplicationOverlays } from "./overlays.ts";
|
||||
import { navigateWithRouteTransition } from "./route-transition.ts";
|
||||
import {
|
||||
loadSettings,
|
||||
patchSettings,
|
||||
@@ -457,19 +456,11 @@ export function bootstrapApplication(
|
||||
if (!routerStarted) {
|
||||
pendingRouterStartNavigation = { routeId, location, mode: "push" };
|
||||
}
|
||||
// New-session submission awaits this promise so its live progress remains
|
||||
// visible until the destination route has completed the UI handoff.
|
||||
return navigateWithRouteTransition({
|
||||
document,
|
||||
from: router.getState().matches[0]?.routeId,
|
||||
to: routeId,
|
||||
prefersReducedMotion:
|
||||
globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false,
|
||||
prepare: () => router.preloadLocation(location, context),
|
||||
navigate: () => router.navigate(routeId, context, { history: "push" }, location),
|
||||
}).catch((error: unknown) => {
|
||||
const navigationPromise = router.navigate(routeId, context, { history: "push" }, location);
|
||||
void navigationPromise.catch((error: unknown) => {
|
||||
console.error("[openclaw] route navigation failed", error);
|
||||
});
|
||||
return navigationPromise;
|
||||
};
|
||||
const context: ApplicationContext<RouteId> = {
|
||||
basePath,
|
||||
@@ -508,7 +499,7 @@ export function bootstrapApplication(
|
||||
});
|
||||
},
|
||||
revalidate: (routeId) => router.revalidate(context, routeId),
|
||||
preload: (routeId) => router.preloadRoute(routeId, context),
|
||||
preload: (routeId, options) => router.preloadLocation(routeLocation(routeId, options), context),
|
||||
};
|
||||
return {
|
||||
context,
|
||||
|
||||
@@ -123,7 +123,7 @@ export type ApplicationContext<TRouteId extends string = string> = {
|
||||
) => Promise<void>;
|
||||
readonly replace: (routeId: TRouteId, options?: ApplicationNavigationOptions) => void;
|
||||
readonly revalidate: (routeId?: TRouteId) => Promise<void>;
|
||||
readonly preload: (routeId: TRouteId) => Promise<void>;
|
||||
readonly preload: (routeId: TRouteId, options?: ApplicationNavigationOptions) => Promise<void>;
|
||||
};
|
||||
|
||||
export const applicationContext =
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import { CHAT_ROUTE_READY_EVENT } from "../../app/route-transition.ts";
|
||||
import { buildDraftSessionCreateParams } from "./create-params.ts";
|
||||
import { DraftGatewayState } from "./draft-gateway-state.ts";
|
||||
import { DraftPlaceBrowser } from "./draft-place-browser.ts";
|
||||
@@ -32,12 +33,15 @@ describe("DraftSubmissionFlow", () => {
|
||||
);
|
||||
let finishNavigation!: () => void;
|
||||
const navigateAndWait = vi.fn(
|
||||
() =>
|
||||
(_routeId: string, _options?: Parameters<ApplicationContext["navigateAndWait"]>[1]) =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishNavigation = resolve;
|
||||
}),
|
||||
);
|
||||
const preload = vi.fn(async () => undefined);
|
||||
const preload = vi.fn(
|
||||
async (_routeId: string, _options?: Parameters<ApplicationContext["preload"]>[1]) =>
|
||||
undefined,
|
||||
);
|
||||
const setSessionKey = vi.fn();
|
||||
const selectAgent = vi.fn();
|
||||
const client = {
|
||||
@@ -198,7 +202,12 @@ describe("DraftSubmissionFlow", () => {
|
||||
await vi.waitFor(() => expect(navigateAndWait).toHaveBeenCalledOnce());
|
||||
|
||||
expect(flow.submitting).toBe(true);
|
||||
expect(preload).toHaveBeenCalledWith("chat", navigateAndWait.mock.calls[0]?.[1]);
|
||||
expect(preload.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
navigateAndWait.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
|
||||
);
|
||||
finishNavigation();
|
||||
document.dispatchEvent(new Event(CHAT_ROUTE_READY_EVENT));
|
||||
await submission;
|
||||
|
||||
expect(start).toHaveBeenCalledOnce();
|
||||
@@ -213,6 +222,6 @@ describe("DraftSubmissionFlow", () => {
|
||||
expect(createResult).toHaveBeenCalledOnce();
|
||||
expect(setSessionKey).toHaveBeenCalledWith(start.mock.calls[0]?.[0].recovery.sessionKey);
|
||||
expect(selectAgent).toHaveBeenCalledWith("cloud");
|
||||
expect(preload).not.toHaveBeenCalled();
|
||||
expect(preload).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SessionsCatalogStartTerminalResult } from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import { selectApplicationSession } from "../../app/agent-selection.ts";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import type { ApplicationContext, ApplicationNavigationOptions } from "../../app/context.ts";
|
||||
import { navigateWithRouteTransition } from "../../app/route-transition.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import {
|
||||
readSessionMethodAccess,
|
||||
@@ -364,6 +365,22 @@ export class DraftSubmissionFlow {
|
||||
this.callbacks.requestUpdate();
|
||||
}
|
||||
|
||||
private navigateToStartedSession(
|
||||
context: ApplicationContext,
|
||||
options: ApplicationNavigationOptions,
|
||||
): Promise<void> {
|
||||
// Keep transition code on the lazy new-session path instead of the startup bundle.
|
||||
return navigateWithRouteTransition({
|
||||
document,
|
||||
from: "new-session",
|
||||
to: "chat",
|
||||
prefersReducedMotion:
|
||||
globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false,
|
||||
prepare: () => context.preload("chat", options),
|
||||
navigate: () => context.navigateAndWait("chat", options),
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
async submit() {
|
||||
const context = this.read().context;
|
||||
if (!context || !this.canSubmit()) {
|
||||
@@ -528,8 +545,8 @@ export class DraftSubmissionFlow {
|
||||
sessionKey: result.key,
|
||||
agentId: submissionAgentId,
|
||||
});
|
||||
await context.navigateAndWait(
|
||||
"chat",
|
||||
await this.navigateToStartedSession(
|
||||
context,
|
||||
sessionNavigationTarget({
|
||||
context,
|
||||
face: "chat",
|
||||
@@ -572,8 +589,8 @@ export class DraftSubmissionFlow {
|
||||
sessionKey: result.key,
|
||||
agentId: submissionAgentId,
|
||||
});
|
||||
await context.navigateAndWait(
|
||||
"chat",
|
||||
await this.navigateToStartedSession(
|
||||
context,
|
||||
sessionNavigationTarget({
|
||||
context,
|
||||
face: "chat",
|
||||
|
||||
Reference in New Issue
Block a user