mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
750d0dcd9e
* perf(ui): make warm session switching instant Amp-Thread-ID: https://ampcode.com/threads/T-019fe957-0e49-707f-859f-9600ef536125 * fix(ui): harden retained session ownership Amp-Thread-ID: https://ampcode.com/threads/T-019fe957-0e49-707f-859f-9600ef536125 * test(ui): scope retained session assertions Amp-Thread-ID: https://ampcode.com/threads/T-019fe957-0e49-707f-859f-9600ef536125 * fix(ui): align generated image preview checks * fix(ui): preserve early transcript reading position Amp-Thread-ID: https://ampcode.com/threads/T-019fe957-0e49-707f-859f-9600ef536125 * fix(ui): adopt latest-navigation-wins router Amp-Thread-ID: https://ampcode.com/threads/T-019fe957-0e49-707f-859f-9600ef536125 * fix(ui): restore retained pane reactivity Amp-Thread-ID: https://ampcode.com/threads/T-019fe957-0e49-707f-859f-9600ef536125 * test(auto-reply): align item lifecycle expectations * fix(ci): repair current-main validation gates Amp-Thread-ID: https://ampcode.com/threads/T-019fe957-0e49-707f-859f-9600ef536125 --------- Co-authored-by: Amp <amp@ampcode.com>
788 lines
30 KiB
TypeScript
788 lines
30 KiB
TypeScript
// Control UI E2E tests cover the redesigned chat composer.
|
|
import { expect, it } from "vitest";
|
|
import {
|
|
controlUiSessionUrl,
|
|
installMockGateway,
|
|
navigateToControlUiSession,
|
|
} from "../test-helpers/control-ui-e2e.ts";
|
|
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
|
|
|
|
const suite = createControlUiE2eSuite({
|
|
name: "Control UI chat composer redesign",
|
|
});
|
|
|
|
// Browser contexts preserve test isolation; keep one process warm for this file.
|
|
suite.define(() => {
|
|
it("keeps the model in the bottom bar, session settings in the header, and switches the primary action with input state", async () => {
|
|
await suite.withPage({ viewport: { width: 1920, height: 1080 } }, async ({ page }) => {
|
|
const gateway = await installMockGateway(page, {
|
|
assistantName: "Rosita",
|
|
deferredMethods: ["chat.send"],
|
|
models: [
|
|
{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai" },
|
|
{
|
|
id: "gpt-5.4-pro",
|
|
name: "GPT-5.4 Pro",
|
|
provider: "openai",
|
|
available: true,
|
|
},
|
|
{
|
|
id: "gpt-5.3-codex-spark",
|
|
name: "GPT-5.3 Codex Spark",
|
|
provider: "codex",
|
|
available: false,
|
|
},
|
|
{
|
|
id: "claude-sonnet-4-6",
|
|
name: "Claude Sonnet 4.6",
|
|
provider: "anthropic",
|
|
},
|
|
],
|
|
methodResponses: {
|
|
"models.authStatus": {
|
|
ts: Date.now(),
|
|
providers: [
|
|
{
|
|
provider: "openai",
|
|
displayName: "Codex",
|
|
status: "ok",
|
|
profiles: [{ profileId: "codex", type: "oauth", status: "ok" }],
|
|
usage: { providerId: "openai", windows: [{ label: "Week", usedPercent: 72 }] },
|
|
},
|
|
],
|
|
},
|
|
"sessions.list": {
|
|
count: 1,
|
|
defaults: {
|
|
contextTokens: 200_000,
|
|
model: "gpt-5.5",
|
|
modelProvider: "openai",
|
|
thinkingDefault: "high",
|
|
thinkingLevels: [
|
|
{ id: "off", label: "off" },
|
|
{ id: "low", label: "low" },
|
|
{ id: "medium", label: "medium" },
|
|
{ id: "high", label: "high" },
|
|
],
|
|
},
|
|
path: "",
|
|
sessions: [
|
|
{
|
|
contextTokens: 200_000,
|
|
displayName: "Main",
|
|
hasActiveRun: false,
|
|
key: "main",
|
|
kind: "direct",
|
|
label: "Main",
|
|
model: "gpt-5.5",
|
|
modelProvider: "openai",
|
|
status: "done",
|
|
totalTokens: 46_000,
|
|
totalTokensFresh: true,
|
|
updatedAt: Date.now(),
|
|
},
|
|
],
|
|
ts: Date.now(),
|
|
},
|
|
},
|
|
});
|
|
|
|
await page.goto(`${suite.server.baseUrl}chat`);
|
|
await gateway.waitForRequest("chat.startup");
|
|
|
|
const composer = page.locator(".agent-chat__input");
|
|
const composerShell = page.locator(".agent-chat__composer-shell");
|
|
const chatContent = page.locator("main.content--chat");
|
|
const chatMain = page.locator(".chat-workbench__main");
|
|
const model = composer.locator('[data-chat-model-select="true"]');
|
|
const effort = composer.locator('[data-chat-thinking-select="true"]');
|
|
const usage = composer.locator('[data-chat-provider-usage="true"]');
|
|
const contextUsage = composer.locator(".context-ring");
|
|
const textarea = composer.locator("textarea");
|
|
const attach = composer.locator(
|
|
'button.agent-chat__input-btn--attach[aria-label="Add attachment"]',
|
|
);
|
|
const camera = composerShell.locator(".agent-chat__camera-btn");
|
|
const takePhoto = composerShell.getByRole("menuitem", { name: "Take photo" });
|
|
const settings = page.locator(".chat-header-session-menu__trigger");
|
|
const splitView = page.getByRole("button", { name: "Open split view" });
|
|
const voice = page.getByRole("button", { name: "Start voice input" });
|
|
const microphonePicker = page.getByRole("button", { name: "Microphone input" });
|
|
|
|
await expect.poll(() => model.isVisible()).toBe(true);
|
|
expect(await gateway.getRequests("chat.metadata")).toHaveLength(0);
|
|
expect(await gateway.getRequests("models.list")).toHaveLength(0);
|
|
await expect.poll(() => contextUsage.isVisible()).toBe(true);
|
|
await expect.poll(() => usage.isVisible()).toBe(false);
|
|
await expect.poll(() => settings.isVisible()).toBe(true);
|
|
await expect.poll(() => splitView.isVisible()).toBe(true);
|
|
await expect
|
|
.poll(() => splitView.evaluate((node) => node.closest(".chat-pane__header") != null))
|
|
.toBe(true);
|
|
await expect.poll(() => attach.isVisible()).toBe(true);
|
|
await expect.poll(() => camera.isVisible()).toBe(false);
|
|
await expect.poll(() => voice.isVisible()).toBe(true);
|
|
await expect
|
|
.poll(() => page.getByRole("button", { name: "Start video talk" }).count())
|
|
.toBe(0);
|
|
await expect
|
|
.poll(() =>
|
|
attach.evaluate((node) => node.closest(".agent-chat__composer-input-row") != null),
|
|
)
|
|
.toBe(true);
|
|
await expect
|
|
.poll(() =>
|
|
voice.evaluate((node) => node.closest(".agent-chat__composer-input-row") != null),
|
|
)
|
|
.toBe(true);
|
|
await expect
|
|
.poll(() => model.evaluate((node) => node.closest(".agent-chat__composer-footer") != null))
|
|
.toBe(true);
|
|
await expect
|
|
.poll(() => settings.evaluate((node) => node.closest(".chat-pane__header") != null))
|
|
.toBe(true);
|
|
await expect.poll(() => composer.locator(".agent-chat__composer-header").count()).toBe(0);
|
|
await expect
|
|
.poll(async () =>
|
|
(await model.locator(".chat-controls__inline-select-label").textContent())?.trim(),
|
|
)
|
|
.toBe("GPT-5.5");
|
|
await expect
|
|
.poll(async () =>
|
|
(await effort.locator(".chat-controls__inline-select-label").textContent())?.trim(),
|
|
)
|
|
.toBe("High");
|
|
await expect.poll(() => contextUsage.locator(".context-ring__detail").count()).toBe(0);
|
|
await expect
|
|
.poll(() => contextUsage.getAttribute("aria-label"))
|
|
.toBe("Session context usage: 46k of 200k (23%)");
|
|
await expect
|
|
.poll(() =>
|
|
contextUsage.evaluate((node) => node.closest(".agent-chat__composer-meta") != null),
|
|
)
|
|
.toBe(true);
|
|
await contextUsage.click();
|
|
await expect.poll(() => usage.isVisible()).toBe(true);
|
|
await expect
|
|
.poll(async () =>
|
|
(await composer.locator(".context-usage__limit").first().textContent())
|
|
?.replace(/\s+/g, " ")
|
|
.trim(),
|
|
)
|
|
.toBe("Weekly 72%");
|
|
await contextUsage.click();
|
|
|
|
await effort.click();
|
|
const thinkingSlider = composer.locator('[data-chat-thinking-slider="true"]');
|
|
const speedToggle = composer.locator("[data-chat-speed-toggle]");
|
|
await expect
|
|
.poll(() => thinkingSlider.getAttribute("data-chat-thinking-values"))
|
|
.toBe("off,low,medium,high");
|
|
await expect.poll(() => thinkingSlider.inputValue()).toBe("3");
|
|
// OpenAI sessions toggle between the standard and priority tiers.
|
|
await expect.poll(() => speedToggle.getAttribute("aria-checked")).toBe("false");
|
|
// Reasoning and speed commit immediately while the Effort picker stays open.
|
|
await thinkingSlider.press("Home");
|
|
await thinkingSlider.press("ArrowRight");
|
|
await expect
|
|
.poll(async () =>
|
|
(await gateway.getRequests("sessions.patch")).some(
|
|
(request) =>
|
|
typeof request.params === "object" &&
|
|
request.params !== null &&
|
|
"thinkingLevel" in request.params &&
|
|
request.params.thinkingLevel === "low",
|
|
),
|
|
)
|
|
.toBe(true);
|
|
await expect.poll(() => effort.getAttribute("data-chat-thinking-value")).toBe("low");
|
|
await expect.poll(() => thinkingSlider.inputValue()).toBe("1");
|
|
await speedToggle.click();
|
|
await expect
|
|
.poll(async () =>
|
|
(await gateway.getRequests("sessions.patch")).some(
|
|
(request) =>
|
|
typeof request.params === "object" &&
|
|
request.params !== null &&
|
|
"fastMode" in request.params &&
|
|
request.params.fastMode === true,
|
|
),
|
|
)
|
|
.toBe(true);
|
|
await expect.poll(() => speedToggle.getAttribute("aria-checked")).toBe("true");
|
|
await page.keyboard.press("Escape");
|
|
await expect
|
|
.poll(() => composer.locator(".chat-controls__effort-menu").isVisible())
|
|
.toBe(false);
|
|
await effort.click();
|
|
await expect.poll(() => speedToggle.getAttribute("aria-checked")).toBe("true");
|
|
await expect
|
|
.poll(() => composer.locator('[data-chat-thinking-slider="true"]').count())
|
|
.toBe(1);
|
|
await page.keyboard.press("Escape");
|
|
await model.click();
|
|
const providerHeadings = composer.locator("[data-chat-model-provider]");
|
|
await expect
|
|
.poll(async () => (await providerHeadings.allTextContents()).map((label) => label.trim()))
|
|
.toEqual(["OpenAI", "Anthropic"]);
|
|
await expect
|
|
.poll(() => composer.locator('[data-chat-model-provider-group="openai"]').textContent())
|
|
.toContain("GPT-5.4 Pro");
|
|
const anthropicModels = composer.locator('[data-chat-model-provider-group="anthropic"]');
|
|
await expect.poll(() => anthropicModels.isVisible()).toBe(true);
|
|
await expect.poll(() => anthropicModels.textContent()).toContain("Claude Sonnet 4.6");
|
|
await model.click();
|
|
|
|
const [
|
|
chatContentBox,
|
|
chatMainBox,
|
|
composerShellBox,
|
|
composerBox,
|
|
modelBox,
|
|
textareaBox,
|
|
attachBox,
|
|
voiceBox,
|
|
] = await Promise.all([
|
|
chatContent.boundingBox(),
|
|
chatMain.boundingBox(),
|
|
composerShell.boundingBox(),
|
|
composer.boundingBox(),
|
|
model.boundingBox(),
|
|
textarea.boundingBox(),
|
|
attach.boundingBox(),
|
|
voice.boundingBox(),
|
|
]);
|
|
expect(chatContentBox).not.toBeNull();
|
|
expect(chatMainBox).not.toBeNull();
|
|
expect(composerShellBox).not.toBeNull();
|
|
expect(composerBox).not.toBeNull();
|
|
expect(modelBox).not.toBeNull();
|
|
expect(textareaBox).not.toBeNull();
|
|
expect(attachBox).not.toBeNull();
|
|
expect(voiceBox).not.toBeNull();
|
|
if (
|
|
!chatContentBox ||
|
|
!chatMainBox ||
|
|
!composerShellBox ||
|
|
!composerBox ||
|
|
!modelBox ||
|
|
!textareaBox ||
|
|
!attachBox ||
|
|
!voiceBox
|
|
) {
|
|
throw new Error("expected composer controls to have layout boxes");
|
|
}
|
|
expect(Math.abs(chatMainBox.x - chatContentBox.x)).toBeLessThanOrEqual(1);
|
|
expect(composerShellBox.width).toBeGreaterThanOrEqual(767);
|
|
expect(composerShellBox.width).toBeLessThanOrEqual(769);
|
|
expect(
|
|
Math.abs(
|
|
composerShellBox.x + composerShellBox.width / 2 - (chatMainBox.x + chatMainBox.width / 2),
|
|
),
|
|
).toBeLessThanOrEqual(1);
|
|
expect(composerBox.height).toBeLessThanOrEqual(120);
|
|
expect(modelBox.y).toBeGreaterThanOrEqual(textareaBox.y);
|
|
expect(attachBox.x + attachBox.width).toBeLessThanOrEqual(
|
|
composerBox.x + composerBox.width + 1,
|
|
);
|
|
expect(voiceBox.x).toBeGreaterThanOrEqual(attachBox.x + attachBox.width - 1);
|
|
expect(voiceBox.x + voiceBox.width).toBeLessThanOrEqual(
|
|
composerBox.x + composerBox.width + 1,
|
|
);
|
|
await expect
|
|
.poll(() =>
|
|
voice.evaluate((node) => {
|
|
const bounds = node.getBoundingClientRect();
|
|
return (
|
|
bounds.width === bounds.height &&
|
|
Number.parseFloat(getComputedStyle(node).borderRadius) >= bounds.width / 2
|
|
);
|
|
}),
|
|
)
|
|
.toBe(true);
|
|
|
|
await page.setViewportSize({ width: 1280, height: 900 });
|
|
const [compactChatMainBox, compactComposerShellBox] = await Promise.all([
|
|
chatMain.boundingBox(),
|
|
composerShell.boundingBox(),
|
|
]);
|
|
expect(compactChatMainBox).not.toBeNull();
|
|
expect(compactComposerShellBox).not.toBeNull();
|
|
if (!compactChatMainBox || !compactComposerShellBox) {
|
|
throw new Error("expected compact composer layout boxes");
|
|
}
|
|
expect(compactComposerShellBox.width).toBeGreaterThanOrEqual(767);
|
|
expect(compactComposerShellBox.width).toBeLessThanOrEqual(769);
|
|
expect(
|
|
Math.abs(
|
|
compactComposerShellBox.x +
|
|
compactComposerShellBox.width / 2 -
|
|
(compactChatMainBox.x + compactChatMainBox.width / 2),
|
|
),
|
|
).toBeLessThanOrEqual(1);
|
|
|
|
await settings.click();
|
|
const viewDropdown = page.locator("wa-dropdown.chat-header-session-menu");
|
|
const viewMenu = viewDropdown.getByRole("menuitem", { name: "View", exact: true });
|
|
await expect.poll(() => viewMenu.isVisible()).toBe(true);
|
|
await viewMenu.hover();
|
|
await expect
|
|
.poll(() =>
|
|
viewMenu
|
|
.locator('wa-dropdown-item[slot="submenu"] .session-menu__text')
|
|
.allTextContents(),
|
|
)
|
|
.toEqual(["Reasoning", "Tool calls", "Keep commentary"]);
|
|
const reasoning = viewDropdown.getByRole("menuitemcheckbox", { name: "Reasoning" });
|
|
await expect.poll(() => reasoning.isVisible()).toBe(true);
|
|
await expect.poll(() => reasoning.getAttribute("aria-checked")).toBe("true");
|
|
await reasoning.click();
|
|
await expect.poll(() => reasoning.getAttribute("aria-checked")).toBe("false");
|
|
await reasoning.click();
|
|
await expect.poll(() => reasoning.getAttribute("aria-checked")).toBe("true");
|
|
await settings.click();
|
|
await expect.poll(() => viewDropdown.getAttribute("open")).toBeNull();
|
|
|
|
await textarea.fill("Send this message");
|
|
await expect
|
|
.poll(() => page.getByRole("button", { name: "Send message" }).isVisible())
|
|
.toBe(true);
|
|
await expect
|
|
.poll(() => page.getByRole("button", { name: "Start voice input" }).isVisible())
|
|
.toBe(true);
|
|
|
|
await page.getByRole("button", { name: "Send message" }).click();
|
|
const sendRequest = await gateway.waitForRequest("chat.send");
|
|
const runId =
|
|
typeof sendRequest.params === "object" &&
|
|
sendRequest.params !== null &&
|
|
"idempotencyKey" in sendRequest.params
|
|
? String(sendRequest.params.idempotencyKey)
|
|
: "";
|
|
// Pre-first-token: the thread shows the working spark; the composer
|
|
// renders no visible run status (sr-only announcement only).
|
|
const spark = page.locator(".chat-reading-indicator");
|
|
await expect.poll(() => spark.isVisible()).toBe(true);
|
|
await gateway.resolveDeferred("chat.send", { runId, status: "started" });
|
|
await expect.poll(() => spark.isVisible()).toBe(true);
|
|
const announcement = composer.locator(".agent-chat__run-status-announcement");
|
|
await expect.poll(() => announcement.textContent()).toContain("Rosita is");
|
|
await expect.poll(() => composer.locator(".agent-chat__composer-run-status").count()).toBe(0);
|
|
await gateway.emitGatewayEvent("chat", {
|
|
deltaText: "Working on it.",
|
|
message: {
|
|
content: [{ text: "Working on it.", type: "text" }],
|
|
role: "assistant",
|
|
timestamp: Date.now(),
|
|
},
|
|
runId,
|
|
sessionKey: "main",
|
|
state: "delta",
|
|
});
|
|
// The working row stays attached with elapsed/token telemetry throughout streaming.
|
|
await expect.poll(() => page.getByText("Working on it.").first().isVisible()).toBe(true);
|
|
await expect.poll(() => spark.isVisible()).toBe(true);
|
|
await expect.poll(() => announcement.textContent()).toContain("Rosita is responding");
|
|
const [activeSplitViewBox, activeModelBox, activeChatContentBox] = await Promise.all([
|
|
splitView.boundingBox(),
|
|
model.boundingBox(),
|
|
chatContent.boundingBox(),
|
|
]);
|
|
expect(activeSplitViewBox).not.toBeNull();
|
|
expect(activeModelBox).not.toBeNull();
|
|
expect(activeChatContentBox).not.toBeNull();
|
|
if (!activeSplitViewBox || !activeModelBox || !activeChatContentBox) {
|
|
throw new Error("expected chat content and composer controls to have layout boxes");
|
|
}
|
|
// The opener lives in the always-on pane header at the chat area's top edge.
|
|
const headerBox = await page.locator(".chat-pane__header").boundingBox();
|
|
expect(headerBox).not.toBeNull();
|
|
if (!headerBox) {
|
|
throw new Error("expected the pane header to have a layout box");
|
|
}
|
|
expect(
|
|
Math.abs(
|
|
activeChatContentBox.x + activeChatContentBox.width - (headerBox.x + headerBox.width),
|
|
),
|
|
).toBeLessThanOrEqual(24);
|
|
expect(Math.abs(activeSplitViewBox.y - activeChatContentBox.y)).toBeLessThanOrEqual(24);
|
|
const stop = page.getByRole("button", { name: "Stop generating" });
|
|
await expect.poll(() => stop.isVisible()).toBe(true);
|
|
await stop.click();
|
|
const abortRequest = await gateway.waitForRequest("chat.abort");
|
|
expect(abortRequest.params).toMatchObject({
|
|
runId,
|
|
sessionKey: "main",
|
|
});
|
|
await expect.poll(() => stop.count()).toBe(0);
|
|
|
|
await textarea.fill("");
|
|
await expect
|
|
.poll(() => page.getByRole("button", { name: "Start voice input" }).isVisible())
|
|
.toBe(true);
|
|
await expect.poll(() => page.getByRole("button", { name: "Send message" }).count()).toBe(0);
|
|
|
|
await page.setViewportSize({ width: 393, height: 852 });
|
|
await expect.poll(() => camera.count()).toBe(0);
|
|
const [mobileAttachBox, mobileModelBox, mobileSettingsBox, mobileContextBox, mobileVoiceBox] =
|
|
await Promise.all([
|
|
attach.boundingBox(),
|
|
model.boundingBox(),
|
|
settings.boundingBox(),
|
|
contextUsage.boundingBox(),
|
|
voice.boundingBox(),
|
|
]);
|
|
expect(mobileAttachBox).not.toBeNull();
|
|
expect(mobileModelBox).not.toBeNull();
|
|
expect(mobileSettingsBox).not.toBeNull();
|
|
expect(mobileContextBox).not.toBeNull();
|
|
expect(mobileVoiceBox).not.toBeNull();
|
|
if (
|
|
!mobileAttachBox ||
|
|
!mobileModelBox ||
|
|
!mobileSettingsBox ||
|
|
!mobileContextBox ||
|
|
!mobileVoiceBox
|
|
) {
|
|
throw new Error("expected mobile composer controls to have layout boxes");
|
|
}
|
|
for (const control of [mobileModelBox, mobileContextBox]) {
|
|
expect(
|
|
Math.abs(control.y + control.height / 2 - (mobileModelBox.y + mobileModelBox.height / 2)),
|
|
).toBeLessThanOrEqual(2);
|
|
}
|
|
expect(mobileSettingsBox.x).toBeGreaterThanOrEqual(0);
|
|
expect(mobileSettingsBox.x + mobileSettingsBox.width).toBeLessThanOrEqual(393);
|
|
expect(mobileAttachBox.x + mobileAttachBox.width).toBeLessThanOrEqual(mobileVoiceBox.x + 1);
|
|
await expect
|
|
.poll(async () => {
|
|
const [polledAttachBox, polledVoiceBox] = await Promise.all([
|
|
attach.boundingBox(),
|
|
voice.boundingBox(),
|
|
]);
|
|
if (!polledAttachBox || !polledVoiceBox) {
|
|
return Number.POSITIVE_INFINITY;
|
|
}
|
|
return Math.abs(
|
|
polledAttachBox.y +
|
|
polledAttachBox.height / 2 -
|
|
(polledVoiceBox.y + polledVoiceBox.height / 2),
|
|
);
|
|
})
|
|
.toBeLessThanOrEqual(2);
|
|
await attach.click();
|
|
await expect.poll(() => takePhoto.isVisible()).toBe(true);
|
|
await expect
|
|
.poll(() => composerShell.getByRole("menuitem", { name: "Photo", exact: true }).isVisible())
|
|
.toBe(true);
|
|
await expect
|
|
.poll(() => composerShell.getByRole("menuitem", { name: "File", exact: true }).isVisible())
|
|
.toBe(true);
|
|
await page.keyboard.press("Escape");
|
|
await textarea.fill("Keep camera access in the attachment menu");
|
|
await expect.poll(() => camera.count()).toBe(0);
|
|
await expect
|
|
.poll(() => page.getByRole("button", { name: "Send message" }).isVisible())
|
|
.toBe(true);
|
|
await textarea.fill("");
|
|
await expect.poll(() => camera.count()).toBe(0);
|
|
await model.click();
|
|
const mobilePickerBox = await composer.locator(".chat-controls__model-menu").boundingBox();
|
|
expect(mobilePickerBox).not.toBeNull();
|
|
if (!mobilePickerBox) {
|
|
throw new Error("expected mobile model picker to have a layout box");
|
|
}
|
|
expect(mobilePickerBox.x).toBeGreaterThanOrEqual(0);
|
|
expect(mobilePickerBox.x + mobilePickerBox.width).toBeLessThanOrEqual(393);
|
|
await model.click();
|
|
await settings.click();
|
|
await expect.poll(() => viewMenu.isVisible()).toBe(true);
|
|
await settings.click();
|
|
await expect.poll(() => viewMenu.isVisible()).toBe(false);
|
|
|
|
await page.setViewportSize({ width: 1280, height: 900 });
|
|
await gateway.setOnline(false);
|
|
await expect.poll(() => voice.isDisabled()).toBe(true);
|
|
await expect
|
|
.poll(async () => {
|
|
const [voiceBackground, pickerBackground] = await Promise.all([
|
|
voice.evaluate((node) => getComputedStyle(node).backgroundColor),
|
|
microphonePicker.evaluate((node) => getComputedStyle(node).backgroundColor),
|
|
]);
|
|
return voiceBackground === pickerBackground;
|
|
})
|
|
.toBe(true);
|
|
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
|
|
if (artifactDir) {
|
|
await composerShell.screenshot({
|
|
animations: "disabled",
|
|
path: `${artifactDir}/voice-picker-disabled-background.png`,
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
it("refreshes the configured usable catalog after advertised chat metadata", async () => {
|
|
await suite.withPage({ viewport: { width: 1280, height: 900 } }, async ({ page }) => {
|
|
const gateway = await installMockGateway(page, {
|
|
agentModel: "openai/gpt-5.3-codex-spark",
|
|
models: [
|
|
{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai", available: true },
|
|
{
|
|
id: "gpt-5.3-codex-spark",
|
|
name: "GPT-5.3 Codex Spark",
|
|
provider: "codex",
|
|
available: false,
|
|
},
|
|
],
|
|
methodResponses: {
|
|
"chat.startup": {
|
|
agentsList: {
|
|
agents: [{ id: "main", name: "OpenClaw" }],
|
|
defaultId: "main",
|
|
mainKey: "main",
|
|
scope: "agent",
|
|
},
|
|
messages: [],
|
|
sessionId: "control-ui-e2e-session",
|
|
thinkingLevel: null,
|
|
},
|
|
"chat.metadata": {
|
|
commands: [],
|
|
models: [
|
|
{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai", available: true },
|
|
{
|
|
id: "gpt-5.3-codex-spark",
|
|
name: "GPT-5.3 Codex Spark",
|
|
provider: "codex",
|
|
available: false,
|
|
},
|
|
],
|
|
},
|
|
"sessions.list": {
|
|
count: 1,
|
|
defaults: {
|
|
contextTokens: 200_000,
|
|
model: "gpt-5.3-codex-spark",
|
|
modelProvider: "openai",
|
|
},
|
|
path: "",
|
|
sessions: [
|
|
{
|
|
contextTokens: 200_000,
|
|
displayName: "Main",
|
|
hasActiveRun: false,
|
|
key: "main",
|
|
kind: "direct",
|
|
label: "Main",
|
|
model: "gpt-5.5",
|
|
modelProvider: "openai",
|
|
status: "done",
|
|
totalTokens: 0,
|
|
updatedAt: Date.now(),
|
|
},
|
|
],
|
|
ts: Date.now(),
|
|
},
|
|
},
|
|
});
|
|
|
|
await page.goto(`${suite.server.baseUrl}chat`);
|
|
await gateway.waitForRequest("chat.metadata");
|
|
expect(await gateway.getRequests("models.list")).toHaveLength(0);
|
|
|
|
const composer = page.locator(".agent-chat__input");
|
|
const providers = composer.locator("[data-chat-model-provider]");
|
|
await expect
|
|
.poll(async () => (await providers.allTextContents()).map((label) => label.trim()))
|
|
.toEqual(["OpenAI"]);
|
|
await expect
|
|
.poll(() => composer.locator('[data-chat-model-provider-group="openai"]').textContent())
|
|
.toContain("GPT-5.5");
|
|
await expect
|
|
.poll(() => composer.locator('[data-chat-model-provider-group="codex"]').count())
|
|
.toBe(0);
|
|
// The advertised default is unavailable, so no usable catalog row is
|
|
// marked as the default and no synthetic empty row is introduced.
|
|
await expect.poll(() => composer.locator('[data-chat-model-default="true"]').count()).toBe(0);
|
|
await expect.poll(() => composer.locator('[data-chat-model-option=""]').count()).toBe(0);
|
|
});
|
|
});
|
|
|
|
it("loads agent-scoped startup models when the route switches sessions", async () => {
|
|
await suite.withPage({ viewport: { width: 1280, height: 900 } }, async ({ page }) => {
|
|
const workModel = {
|
|
id: "work-model",
|
|
name: "Work Model",
|
|
provider: "openai",
|
|
available: true,
|
|
};
|
|
const otherModel = {
|
|
id: "other-model",
|
|
name: "Other Model",
|
|
provider: "anthropic",
|
|
available: true,
|
|
};
|
|
const startupResponse = (sessionId: string, model: typeof workModel) => ({
|
|
agentsList: {
|
|
agents: [
|
|
{ id: "work", name: "Work" },
|
|
{ id: "other", name: "Other" },
|
|
],
|
|
defaultId: "work",
|
|
mainKey: "main",
|
|
scope: "agent",
|
|
},
|
|
messages: [],
|
|
metadata: { commands: [], models: [model] },
|
|
sessionId,
|
|
thinkingLevel: null,
|
|
});
|
|
const gateway = await installMockGateway(page, {
|
|
defaultAgentId: "work",
|
|
sessionKey: "agent:work:main",
|
|
methodResponses: {
|
|
"chat.startup": {
|
|
cases: [
|
|
{
|
|
match: { sessionKey: "agent:work:main" },
|
|
response: startupResponse("work-session", workModel),
|
|
},
|
|
{
|
|
match: { sessionKey: "agent:other:main" },
|
|
response: startupResponse("other-session", otherModel),
|
|
},
|
|
],
|
|
},
|
|
"sessions.list": {
|
|
count: 2,
|
|
defaults: {
|
|
contextTokens: 200_000,
|
|
model: "other-model",
|
|
modelProvider: "anthropic",
|
|
},
|
|
path: "",
|
|
sessions: [
|
|
{
|
|
key: "agent:work:main",
|
|
kind: "direct",
|
|
model: "work-model",
|
|
modelProvider: "openai",
|
|
status: "done",
|
|
updatedAt: Date.now(),
|
|
},
|
|
{
|
|
key: "agent:other:main",
|
|
kind: "direct",
|
|
model: "other-model",
|
|
modelProvider: "anthropic",
|
|
status: "done",
|
|
updatedAt: Date.now(),
|
|
},
|
|
],
|
|
ts: Date.now(),
|
|
},
|
|
},
|
|
models: [workModel],
|
|
});
|
|
|
|
await page.goto(controlUiSessionUrl(suite.server.baseUrl, "agent:work:main"));
|
|
await gateway.waitForRequest("chat.startup");
|
|
expect(await gateway.getRequests("chat.metadata")).toHaveLength(0);
|
|
|
|
const activeComposer = () =>
|
|
page.locator('openclaw-chat-pane[aria-hidden="false"] .agent-chat__input');
|
|
await expect
|
|
.poll(() =>
|
|
activeComposer().locator('[data-chat-model-option="openai/work-model"]').count(),
|
|
)
|
|
.toBe(1);
|
|
|
|
await navigateToControlUiSession(page, "agent:other:main");
|
|
const startupRequests = await gateway.getRequests("chat.startup");
|
|
expect(
|
|
startupRequests.filter(
|
|
(request) =>
|
|
(request.params as { sessionKey?: string } | undefined)?.sessionKey ===
|
|
"agent:other:main",
|
|
),
|
|
).toHaveLength(1);
|
|
expect(await gateway.getRequests("chat.metadata")).toHaveLength(0);
|
|
await expect
|
|
.poll(() =>
|
|
activeComposer().locator('[data-chat-model-option="anthropic/other-model"]').count(),
|
|
)
|
|
.toBe(1);
|
|
await expect
|
|
.poll(() =>
|
|
activeComposer().locator('[data-chat-model-option="openai/work-model"]').count(),
|
|
)
|
|
.toBe(0);
|
|
});
|
|
});
|
|
|
|
it("does not request unscoped models when chat metadata is unavailable", async () => {
|
|
await suite.withPage({ viewport: { width: 1280, height: 900 } }, async ({ page }) => {
|
|
const gateway = await installMockGateway(page, {
|
|
models: [{ id: "gpt-default", name: "GPT Default", provider: "openai", available: true }],
|
|
methodResponses: {
|
|
connect: {
|
|
auth: {
|
|
deviceToken: "e2e-device-token",
|
|
role: "operator",
|
|
scopes: [
|
|
"operator.admin",
|
|
"operator.read",
|
|
"operator.write",
|
|
"operator.approvals",
|
|
"operator.pairing",
|
|
],
|
|
},
|
|
features: { events: [], methods: ["chat.startup"] },
|
|
protocol: 4,
|
|
server: { connId: "control-ui-e2e", version: "e2e" },
|
|
snapshot: {
|
|
sessionDefaults: {
|
|
defaultAgentId: "main",
|
|
mainKey: "main",
|
|
mainSessionKey: "agent:work:main",
|
|
scope: "agent",
|
|
},
|
|
},
|
|
type: "hello-ok",
|
|
},
|
|
"chat.startup": {
|
|
agentsList: {
|
|
agents: [{ id: "work", name: "Work" }],
|
|
defaultId: "main",
|
|
mainKey: "main",
|
|
scope: "agent",
|
|
},
|
|
messages: [],
|
|
sessionId: "control-ui-e2e-session",
|
|
thinkingLevel: null,
|
|
},
|
|
},
|
|
});
|
|
|
|
await page.goto(controlUiSessionUrl(suite.server.baseUrl, "agent:work:main"));
|
|
const startupRequest = await gateway.waitForRequest("chat.startup");
|
|
expect(startupRequest.params).toEqual(
|
|
expect.objectContaining({ sessionKey: "agent:work:main" }),
|
|
);
|
|
for (const request of await gateway.getRequests("chat.startup")) {
|
|
expect(request.params).toEqual(expect.objectContaining({ sessionKey: "agent:work:main" }));
|
|
}
|
|
|
|
const composer = page.locator(".agent-chat__input");
|
|
await expect
|
|
.poll(async () =>
|
|
(await composer.locator("[data-chat-model-option]").allTextContents()).join(" "),
|
|
)
|
|
.not.toContain("GPT Default");
|
|
expect(await gateway.getRequests("chat.metadata")).toHaveLength(0);
|
|
expect(await gateway.getRequests("models.list")).toHaveLength(0);
|
|
});
|
|
});
|
|
});
|