test(ui): split the oversized Control UI e2e suites (#114889)

* test(ui): split oversized Control UI e2e suites

* test(ui): bound new-session locator polling

* test(ui): fix split e2e metadata

* test(ui): give startup waits an explicit budget
This commit is contained in:
Peter Steinberger
2026-07-28 01:07:21 -04:00
committed by GitHub
parent 318807a969
commit a5ee5039ce
26 changed files with 11237 additions and 10961 deletions
-3
View File
@@ -986,9 +986,6 @@ src/worker/worker.runtime.test.ts
ui/src/api/gateway.node.test.ts
ui/src/api/types.ts
ui/src/app/app-host.ts
ui/src/e2e/chat-flow.e2e.test.ts
ui/src/e2e/new-session-page.e2e.test.ts
ui/src/e2e/session-management.e2e.test.ts
ui/src/lib/config/index.test.ts
ui/src/lib/config/index.ts
ui/src/lib/cron/index.test.ts
@@ -12,9 +12,9 @@ scenario:
docsRefs:
- docs/web/control-ui.md
codeRefs:
- ui/src/e2e/chat-flow.e2e.test.ts
- ui/src/e2e/chat-flow.messaging.e2e.test.ts
execution:
kind: playwright
path: ui/src/e2e/chat-flow.e2e.test.ts
path: ui/src/e2e/chat-flow.messaging.e2e.test.ts
testNamePattern: sends a chat turn through the GUI and renders the final Gateway event
summary: Focused Playwright smoke coverage for the Control UI chat flow.
+8 -3
View File
@@ -15,6 +15,11 @@ import { bootstrapApplication } from "./bootstrap.ts";
import type { ApplicationContext } from "./context.ts";
import { loadSettings, saveSettings } from "./settings.ts";
// Startup progress (dynamic imports, gateway subscribe, router start) is not a
// performance assertion, so these waits must not inherit vi.waitFor's 1s default:
// under a loaded CI runner that budget expires before startup reaches the step.
const STARTUP_STEP_WAIT = { timeout: 15_000 };
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
@@ -366,7 +371,7 @@ describe("normalizeInitialApplicationLocation", () => {
features: { methods: ["openclaw.setup.detect"] },
},
} as Parameters<GatewayListener>[0]);
await vi.waitFor(() => expect(replaceRoute).toHaveBeenCalledOnce());
await vi.waitFor(() => expect(replaceRoute).toHaveBeenCalledOnce(), STARTUP_STEP_WAIT);
expect(replaceRoute).toHaveBeenCalledWith("model-setup", { search: "?firstRun=1" });
});
@@ -515,7 +520,7 @@ describe("normalizeInitialApplicationLocation", () => {
try {
const start = runtime.start();
await vi.waitFor(() => expect(activeSubscriptions.size).toBe(1));
await vi.waitFor(() => expect(activeSubscriptions.size).toBe(1), STARTUP_STEP_WAIT);
runtime.stop();
await start;
@@ -545,7 +550,7 @@ describe("normalizeInitialApplicationLocation", () => {
try {
const start = runtime.start();
await vi.waitFor(() => expect(routerStart).toHaveBeenCalledOnce());
await vi.waitFor(() => expect(routerStart).toHaveBeenCalledOnce(), STARTUP_STEP_WAIT);
runtime.stop();
expect(routerStop).toHaveBeenCalledOnce();
File diff suppressed because it is too large Load Diff
+583
View File
@@ -0,0 +1,583 @@
import { expect, it } from "vitest";
import {
chatSessionListResponse,
createChatFlowE2eSuite,
installMockGateway,
requireRecord,
requireString,
waitForRequests,
} from "./chat-flow.test-support.ts";
const suite = createChatFlowE2eSuite();
suite.define(() => {
it("opens a git-backed agent draft from the sidebar new-session action", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, { workspaceGit: true });
try {
await page.goto(`${suite.server.baseUrl}chat`);
const newSessionButton = page.locator("openclaw-app-sidebar .sidebar-brand__new-thread");
await newSessionButton.waitFor({ state: "visible", timeout: 10_000 });
await newSessionButton.click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/new");
await expect.poll(() => new URL(page.url()).searchParams.get("agent")).toBe("main");
expect(await gateway.getRequests("sessions.create")).toHaveLength(0);
} finally {
await suite.closeBrowserContext(context);
}
});
it("starts a model-suggested follow-up in a fresh worktree session", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const suggestion = {
id: "task_123",
title: "Remove stale adapter",
prompt: "Delete the stale adapter in src/example.ts and update tests.",
tldr: "The adapter is unreachable and adds maintenance cost.",
cwd: "/projects/example",
sessionKey: "main",
agentId: "main",
createdAt: Date.now(),
};
const gateway = await installMockGateway(page, {
deferredMethods: ["taskSuggestions.list"],
featureMethods: [
"chat.metadata",
"chat.startup",
"taskSuggestions.list",
"taskSuggestions.accept",
],
methodResponses: {
"taskSuggestions.list": { suggestions: [suggestion] },
"taskSuggestions.accept": {
taskId: "task_123",
key: "agent:main:dashboard:suggested",
},
},
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
await gateway.waitForRequest("taskSuggestions.list");
await gateway.emitGatewayEvent("task.suggestion", {
action: "created",
suggestion,
});
await gateway.resolveDeferred("taskSuggestions.list", { suggestions: [] });
const startButton = page.getByRole("button", { name: "Start in worktree" });
await startButton.waitFor({ state: "visible", timeout: 10_000 });
await page
.getByText("/projects/example", { exact: true })
.waitFor({ state: "visible", timeout: 10_000 });
await page
.getByText("Delete the stale adapter in src/example.ts and update tests.", {
exact: true,
})
.waitFor({ state: "visible", timeout: 10_000 });
await startButton.click();
const acceptRequest = await gateway.waitForRequest("taskSuggestions.accept");
expect(acceptRequest.params).toEqual({ taskId: "task_123" });
} finally {
await suite.closeBrowserContext(context);
}
});
it("clears model-suggested follow-ups while switching sessions", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
featureMethods: ["chat.metadata", "chat.startup", "taskSuggestions.list"],
methodResponses: {
"sessions.list": chatSessionListResponse(),
"taskSuggestions.list": {
suggestions: [
{
id: "task_session_a",
title: "Follow up from session A",
prompt: "Complete the follow-up discovered in session A.",
tldr: "This suggestion belongs only to session A.",
cwd: "/projects/example",
sessionKey: "agent:main:session-a",
agentId: "main",
createdAt: Date.now(),
},
],
},
},
sessionKey: "agent:main:session-a",
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const startButton = page.getByRole("button", { name: "Start in worktree" });
await startButton.waitFor({ state: "visible", timeout: 10_000 });
await gateway.deferNext("taskSuggestions.list");
await page
.locator(
'.sidebar-recent-session[data-session-key="agent:main:session-b"] a.sidebar-recent-session__link',
)
.click();
await waitForRequests(gateway, "taskSuggestions.list", 2);
await expect.poll(() => startButton.count()).toBe(0);
await gateway.resolveDeferred("taskSuggestions.list", { suggestions: [] });
} finally {
await suite.closeBrowserContext(context);
}
});
it("keeps the composer visible when follow-up suggestions overflow", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 720, width: 1280 },
});
const page = await context.newPage();
await installMockGateway(page, {
featureMethods: ["chat.metadata", "chat.startup", "taskSuggestions.list"],
methodResponses: {
"taskSuggestions.list": {
suggestions: Array.from({ length: 12 }, (_, index) => ({
id: `task_overflow_${index}`,
title: `Follow-up ${index}`,
prompt: "Inspect the related implementation and tests. ".repeat(12),
tldr: "This follow-up remains useful but must not hide the composer.",
cwd: "/projects/example",
sessionKey: "main",
agentId: "main",
createdAt: Date.now() + index,
})),
},
},
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const tray = page.locator(".task-suggestions");
await tray.waitFor({ state: "visible", timeout: 10_000 });
expect(await tray.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(
true,
);
const composer = page.locator(".agent-chat__composer-shell");
await composer.waitFor({ state: "visible", timeout: 10_000 });
const box = await composer.boundingBox();
expect(box).not.toBeNull();
expect((box?.y ?? 720) + (box?.height ?? 0)).toBeLessThanOrEqual(720);
} finally {
await suite.closeBrowserContext(context);
}
});
it("sends the first chat turn while agents startup loading is still pending", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
defaultAgentId: "ops",
deferredMethods: ["chat.startup"],
historyMessages: [],
sessionKey: "global",
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
await gateway.waitForRequest("chat.startup");
expect(await gateway.getRequests("agents.list")).toHaveLength(0);
// chat.startup owns the initial metadata load; the old parallel
// chat.metadata request was only a synchronization point for this test.
expect(await gateway.getRequests("chat.metadata")).toHaveLength(0);
expect(await gateway.getRequests("commands.list")).toHaveLength(0);
expect(await gateway.getRequests("models.list")).toHaveLength(0);
const prompt = "send before agents list completes";
await page
.locator(".agent-chat__composer-combobox textarea")
.waitFor({ state: "visible", timeout: 10_000 });
await page.locator(".agent-chat__composer-combobox textarea").fill(prompt);
await page.getByRole("button", { name: "Send message" }).click();
const sendRequest = await gateway.waitForRequest("chat.send");
await expect
.poll(() => page.locator(".agent-chat__composer-combobox textarea").inputValue(), {
timeout: 10_000,
})
.toBe("");
const params = requireRecord(sendRequest.params);
expect(params.message).toBe(prompt);
expect(params.sessionKey).toBe("global");
expect(params.agentId).toBe("ops");
const runId = requireString(params.idempotencyKey, "chat send idempotency key");
await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 });
await gateway.emitGatewayEvent("chat", {
deltaText: "First token visible.",
message: {
content: [{ text: "First token visible.", type: "text" }],
role: "assistant",
timestamp: Date.now(),
},
runId,
agentId: "ops",
sessionKey: "global",
state: "delta",
});
await page.getByText("First token visible.").waitFor({ timeout: 10_000 });
await gateway.resolveDeferred("chat.startup", {
agentsList: {
agents: [{ id: "ops", name: "OpenClaw" }],
defaultId: "ops",
mainKey: "main",
scope: "agent",
},
messages: [],
metadata: {
commands: [
{
acceptsArgs: false,
description: "Loaded after startup completes",
name: "startup-ready",
scope: "text",
source: "native",
},
],
models: [
{
available: true,
id: "startup-model",
name: "Startup Model",
provider: "openai",
},
],
},
sessionId: "control-ui-e2e-session",
thinkingLevel: null,
});
await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 });
await page.getByText("First token visible.").waitFor({ timeout: 10_000 });
await expect
.poll(() => page.locator('[data-chat-model-option="openai/startup-model"]').count())
.toBe(1);
await gateway.emitChatFinal({ runId, text: "History race stayed visible." });
await page
.locator(".chat-thread-inner")
.getByText("History race stayed visible.")
.waitFor({ timeout: 10_000 });
await page.locator(".agent-chat__composer-combobox textarea").fill("/");
await page.getByRole("option", { name: /\/startup-ready/ }).waitFor({ timeout: 10_000 });
// Check after both controls render so no late fallback RPC supplied either catalog.
expect({
commands: (await gateway.getRequests("commands.list")).length,
metadata: (await gateway.getRequests("chat.metadata")).length,
models: (await gateway.getRequests("models.list")).length,
}).toEqual({ commands: 0, metadata: 0, models: 0 });
expect(await gateway.getRequests("agents.list")).toHaveLength(0);
} finally {
await suite.closeBrowserContext(context);
}
});
it("steers ordinary follow-ups when the server default is steer", async () => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
...(artifactDir
? { recordVideo: { dir: artifactDir, size: { height: 900, width: 1280 } } }
: {}),
});
const page = await context.newPage();
const runtimeConfig = {
messages: { queue: { byChannel: { webchat: "steer" }, mode: "followup" } },
};
const gateway = await installMockGateway(page, {
methodResponses: {
"config.get": {
config: runtimeConfig,
hash: "queue-steer-config",
issues: [],
raw: JSON.stringify(runtimeConfig),
runtimeConfig,
valid: true,
},
},
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
await page.locator(".agent-chat__composer-combobox textarea").fill("keep this run active");
await page.getByRole("button", { name: "Send message" }).click();
await gateway.waitForRequest("chat.send");
await page.getByRole("button", { name: "Stop generating" }).waitFor({ timeout: 10_000 });
const followUp = "tighten the active plan";
await page.locator(".agent-chat__composer-combobox textarea").fill(followUp);
await page.getByRole("button", { name: "Steer into the active run" }).click();
const sends = await waitForRequests(gateway, "chat.send", 2);
expect(requireRecord(sends[1]?.params)).toMatchObject({
deliver: false,
message: followUp,
sessionKey: "main",
});
const queue = page.locator(".chat-queue");
await queue.getByText("Steered").waitFor({ timeout: 10_000 });
await queue.getByText(followUp).waitFor({ timeout: 10_000 });
if (artifactDir) {
await page.screenshot({
path: `${artifactDir}/steer-default.png`,
fullPage: true,
});
}
} finally {
await suite.closeBrowserContext(context);
}
});
it("preserves a non-steer server default for active-run follow-ups", async () => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const runtimeConfig = {
messages: { queue: { byChannel: { webchat: "followup" }, mode: "steer" } },
};
const gateway = await installMockGateway(page, {
methodResponses: {
"config.get": {
config: runtimeConfig,
hash: "queue-followup-config",
issues: [],
raw: JSON.stringify(runtimeConfig),
runtimeConfig,
valid: true,
},
},
});
try {
await page.goto(`${suite.server.baseUrl}settings/appearance`);
const followUpSelect = page.locator("[data-settings-follow-up-mode]");
await followUpSelect.waitFor({ state: "visible", timeout: 10_000 });
expect(await followUpSelect.inputValue()).toBe("server");
await page.getByText("Using server default (followup)").waitFor({ timeout: 10_000 });
if (artifactDir) {
await page.screenshot({
path: `${artifactDir}/server-followup-setting.png`,
fullPage: true,
});
}
const configPatchCount = (await gateway.getRequests("config.patch")).length;
const configGetCount = (await gateway.getRequests("config.get")).length;
const overrideConfig = {
...runtimeConfig,
ui: { prefs: { chatFollowUpMode: "steer" } },
};
await gateway.setMethodResponse("config.get", {
config: overrideConfig,
hash: "queue-followup-override-config",
issues: [],
raw: JSON.stringify(overrideConfig),
runtimeConfig: overrideConfig,
valid: true,
});
await followUpSelect.selectOption("steer");
await waitForRequests(gateway, "config.patch", configPatchCount + 1);
await waitForRequests(gateway, "config.get", configGetCount + 1);
await page.getByText("Overriding server default (followup)").waitFor({ timeout: 10_000 });
await gateway.setMethodResponse("config.get", {
config: runtimeConfig,
hash: "queue-followup-reset-config",
issues: [],
raw: JSON.stringify(runtimeConfig),
runtimeConfig,
valid: true,
});
if (artifactDir) {
await page.screenshot({
path: `${artifactDir}/server-followup-override.png`,
fullPage: true,
});
}
await page.getByRole("button", { name: "Reset to server default" }).click();
await waitForRequests(gateway, "config.patch", configPatchCount + 2);
await waitForRequests(gateway, "config.get", configGetCount + 2);
await page.getByText("Using server default (followup)").waitFor({ timeout: 10_000 });
expect(await followUpSelect.inputValue()).toBe("server");
await page.goto(`${suite.server.baseUrl}chat`);
const activePrompt = "keep this run active";
await page.locator(".agent-chat__composer-combobox textarea").fill(activePrompt);
await page.getByRole("button", { name: "Send message" }).click();
await gateway.waitForRequest("chat.send");
await page.getByRole("button", { name: "Stop generating" }).waitFor({ timeout: 10_000 });
const queuedPrompt = "queue this on the server";
await page.locator(".agent-chat__composer-combobox textarea").fill(queuedPrompt);
await page.getByRole("button", { name: "Queue message" }).click();
const sends = await waitForRequests(gateway, "chat.send", 2);
expect(requireRecord(sends[1]?.params)).toMatchObject({
message: queuedPrompt,
queueMode: "followup",
sessionKey: "main",
});
await page.locator(".chat-queue").waitFor({ state: "detached", timeout: 10_000 });
} finally {
await suite.closeBrowserContext(context);
}
});
it("honors a session interrupt override ahead of the webchat config default", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const sessionKey = "main";
const runtimeConfig = {
messages: { queue: { byChannel: { webchat: "steer" }, mode: "steer" } },
};
const gateway = await installMockGateway(page, {
methodResponses: {
"config.get": {
config: runtimeConfig,
hash: "queue-session-override-config",
issues: [],
raw: JSON.stringify(runtimeConfig),
runtimeConfig,
valid: true,
},
"sessions.list": chatSessionListResponse([
{
effectiveQueueMode: "interrupt",
key: "agent:main:main",
kind: "direct",
label: "Main",
queueMode: "interrupt",
updatedAt: Date.now(),
},
]),
},
sessionInfo: {
effectiveQueueMode: "interrupt",
hasActiveRun: false,
key: "agent:main:main",
queueMode: "interrupt",
status: "done",
},
sessionKey,
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
await page.locator(".agent-chat__composer-combobox textarea").fill("keep this run active");
await page.getByRole("button", { name: "Send message" }).click();
await gateway.waitForRequest("chat.send");
await page.getByRole("button", { name: "Stop generating" }).waitFor({ timeout: 10_000 });
const followUp = "interrupt for this session override";
await page.locator(".agent-chat__composer-combobox textarea").fill(followUp);
await page.getByRole("button", { name: "Send message" }).click();
const sends = await waitForRequests(gateway, "chat.send", 2);
expect(requireRecord(sends[1]?.params)).toMatchObject({
message: followUp,
queueMode: "interrupt",
sessionKey,
});
} finally {
await suite.closeBrowserContext(context);
}
});
it("steers a restored queued message when only the session row reports the active run", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page);
try {
await page.goto(`${suite.server.baseUrl}settings/appearance`);
await page.locator("[data-settings-follow-up-mode]").selectOption("queue");
await page.goto(`${suite.server.baseUrl}chat`);
await page.locator(".agent-chat__composer-combobox textarea").fill("keep this run active");
await page.getByRole("button", { name: "Send message" }).click();
await gateway.waitForRequest("chat.send");
await page.getByRole("button", { name: "Stop generating" }).waitFor({ timeout: 10_000 });
const queuedPrompt = "steer this after restoring the queue";
await page.locator(".agent-chat__composer-combobox textarea").fill(queuedPrompt);
await page.getByRole("button", { name: "Queue message" }).click();
await page.locator(".chat-queue").getByText(queuedPrompt).waitFor({ timeout: 10_000 });
await gateway.setMethodResponse(
"sessions.list",
chatSessionListResponse([
{
hasActiveRun: true,
key: "main",
kind: "direct",
label: "Main",
updatedAt: Date.now(),
},
]),
);
await page.reload();
const queue = page.locator(".chat-queue");
await queue.getByText(queuedPrompt).waitFor({ timeout: 10_000 });
await queue.getByRole("button", { name: "Steer" }).click();
const steerRequest = await gateway.waitForRequest("chat.send");
const steerParams = requireRecord(steerRequest.params);
expect(steerParams).toMatchObject({
deliver: false,
message: queuedPrompt,
sessionKey: "main",
});
await queue.getByText("Steered").waitFor({ timeout: 10_000 });
await gateway.emitChatFinal({
runId: requireString(steerParams.idempotencyKey, "restored steer idempotency key"),
text: "Restored steer completed.",
});
await queue.getByText(queuedPrompt).waitFor({ state: "detached", timeout: 10_000 });
} finally {
await suite.closeBrowserContext(context);
}
});
});
@@ -0,0 +1,629 @@
import { expect, it } from "vitest";
import {
chatSessionListResponse,
controlUiSessionUrl,
createChatFlowE2eSuite,
expectDefined,
expectRequestCountStable,
installMockGateway,
requireRecord,
requireString,
scrollChatThreadToTop,
visibleChatBubbleTexts,
waitForChatScrollIdle,
waitForRequests,
} from "./chat-flow.test-support.ts";
const suite = createChatFlowE2eSuite();
suite.define(() => {
it("shows persisted user messages after opening History and scrolling mixed history", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const baseTs = Date.now() - 100_000;
const currentSessionMessages = [
{
content: [{ text: "Current session placeholder", type: "text" }],
role: "assistant",
timestamp: baseTs - 1,
},
];
const historyMessages = Array.from({ length: 70 }, (_, index) => ({
content: [
{
text: `${index % 2 === 0 ? "User history question" : "Assistant history answer"} ${index}\n${"history detail line\n".repeat(4)}`,
type: index % 2 === 0 ? "input_text" : "output_text",
},
],
role: index % 2 === 0 ? "user" : "assistant",
timestamp: baseTs + index,
}));
const gateway = await installMockGateway(page, {
historyMessages: currentSessionMessages,
methodResponses: {
"chat.startup": {
cases: [
{
match: { sessionKey: "agent:main:session-b" },
response: {
messages: historyMessages,
sessionId: "control-ui-e2e-history-session-b",
thinkingLevel: null,
},
},
{
match: { sessionKey: "agent:main:session-a" },
response: {
messages: currentSessionMessages,
sessionId: "control-ui-e2e-history-session-a",
thinkingLevel: null,
},
},
],
},
"sessions.list": chatSessionListResponse(),
},
sessionKey: "agent:main:session-a",
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
await page.getByText("Current session placeholder").waitFor({ timeout: 10_000 });
const startupCountBeforeSwitch = (await gateway.getRequests("chat.startup")).length;
await page
.locator(
'.sidebar-recent-session[data-session-key="agent:main:session-b"] a.sidebar-recent-session__link',
)
.click();
const startupRequests = await waitForRequests(
gateway,
"chat.startup",
startupCountBeforeSwitch + 1,
);
const historyRequest = expectDefined(startupRequests.at(-1), "session B startup request");
expect(requireRecord(historyRequest.params)).toMatchObject({
sessionKey: "agent:main:session-b",
});
await page.locator(".chat-thread").getByText("User history question 68").waitFor({
timeout: 10_000,
});
await page.locator(".chat-thread").getByText("Assistant history answer 69").waitFor({
timeout: 10_000,
});
await expect
.poll(
async () => {
const texts = await visibleChatBubbleTexts(page);
return (
texts.some((text) => text.includes("User history question 68")) &&
texts.some((text) => text.includes("Assistant history answer 69"))
);
},
{ timeout: 10_000 },
)
.toBe(true);
await waitForChatScrollIdle(page);
await scrollChatThreadToTop(page);
await page.locator(".chat-thread").getByText("User history question 10").waitFor({
timeout: 10_000,
});
await scrollChatThreadToTop(page);
await page.locator(".chat-thread").getByText("User history question 0").waitFor({
timeout: 10_000,
});
await scrollChatThreadToTop(page);
await expect
.poll(
async () => {
const texts = await visibleChatBubbleTexts(page);
return (
texts.some((text) => text.includes("User history question 0")) &&
texts.some((text) => text.includes("Assistant history answer 1"))
);
},
{ timeout: 10_000 },
)
.toBe(true);
} finally {
await suite.closeBrowserContext(context);
}
});
it("keeps retained paginated history stable when returning to a session", async () => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const context = await suite.newBrowserContext({
locale: "en-US",
...(artifactDir
? { recordVideo: { dir: artifactDir, size: { height: 900, width: 1280 } } }
: {}),
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const historyMessage = (seq: number, label: string) => ({
__openclaw: { id: `history-${seq}`, seq },
content: [
{
text: `${label} ${seq}\n${"retained transcript detail\n".repeat(3)}`,
type: seq % 2 === 0 ? "output_text" : "input_text",
},
],
role: seq % 2 === 0 ? "assistant" : "user",
timestamp: 1_800_000_000_000 + seq,
});
const shortMessages = [historyMessage(1, "short session"), historyMessage(2, "short session")];
const recentMessages = Array.from({ length: 100 }, (_, index) =>
historyMessage(index + 41, "recent retained message"),
);
const olderMessages = Array.from({ length: 40 }, (_, index) =>
historyMessage(index + 1, "older retained message"),
);
const gateway = await installMockGateway(page, {
historyMessages: shortMessages,
methodResponses: {
"chat.history": {
cases: [
{
match: { offset: 100, sessionKey: "agent:main:session-b" },
response: {
hasMore: false,
messages: olderMessages,
sessionId: "retained-history-session",
thinkingLevel: null,
totalMessages: 140,
},
},
{
match: { sessionKey: "agent:main:session-b" },
response: {
hasMore: true,
messages: recentMessages,
nextOffset: 100,
sessionId: "retained-history-session",
thinkingLevel: null,
totalMessages: 140,
},
},
{
match: { sessionKey: "agent:main:session-a" },
response: {
hasMore: false,
messages: shortMessages,
sessionId: "short-history-session",
thinkingLevel: null,
totalMessages: 2,
},
},
],
},
"chat.startup": {
cases: [
{
match: { sessionKey: "agent:main:session-b" },
response: {
hasMore: true,
messages: recentMessages,
nextOffset: 100,
sessionId: "retained-history-session",
thinkingLevel: null,
totalMessages: 140,
},
},
{
match: {},
response: {
hasMore: false,
messages: shortMessages,
sessionId: "short-history-session",
thinkingLevel: null,
totalMessages: 2,
},
},
],
},
"sessions.list": chatSessionListResponse(),
},
sessionKey: "agent:main:session-a",
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
await page.getByText(/^short session 2\n/).waitFor({ timeout: 10_000 });
const sessionB = page.locator(
'.sidebar-recent-session[data-session-key="agent:main:session-b"] a.sidebar-recent-session__link',
);
const sessionA = page.locator(
'.sidebar-recent-session[data-session-key="agent:main:session-a"] a.sidebar-recent-session__link',
);
await sessionB.click();
await page.getByText(/^recent retained message 140\n/).waitFor({ timeout: 10_000 });
const thread = page.locator(".chat-thread");
await thread.hover();
await page.mouse.wheel(0, -1_000_000);
await expect
.poll(() =>
page
.locator("openclaw-chat-pane")
.evaluate(
(element) =>
(element as HTMLElement & { state: { chatMessages: unknown[] } }).state.chatMessages
.length,
),
)
.toBe(140);
// Prepending preserves the visible anchor. A renewed upward gesture
// reaches the newly loaded start instead of teleporting the reader.
await page.mouse.wheel(0, -1_000_000);
await page.getByText(/^older retained message 1\n/).waitFor({ timeout: 10_000 });
await sessionA.click();
await page.getByText(/^short session 2\n/).waitFor({ timeout: 10_000 });
const historyRequestsBeforeReturn = (await gateway.getRequests("chat.history")).length;
await page.evaluate(() => {
type FrameSample = {
hiddenNotice: boolean;
loading: boolean;
messageCount: number;
minOpacity: number;
sessionKey: string;
};
const samples: FrameSample[] = [];
(
globalThis as typeof globalThis & {
chatSessionReturnSamples: FrameSample[];
}
).chatSessionReturnSamples = samples;
const deadline = performance.now() + 750;
const sample = () => {
const pane = document.querySelector("openclaw-chat-pane") as
| (HTMLElement & {
state?: { chatMessages?: unknown[]; sessionKey?: string };
})
| null;
const rows = Array.from(document.querySelectorAll<HTMLElement>("[data-chat-row-key]"));
samples.push({
hiddenNotice: document.body.textContent?.includes("Showing last") ?? false,
loading: document.querySelector(".chat-history-loading") !== null,
messageCount: pane?.state?.chatMessages?.length ?? 0,
minOpacity: rows.reduce(
(minimum, row) => Math.min(minimum, Number.parseFloat(getComputedStyle(row).opacity)),
1,
),
sessionKey: pane?.state?.sessionKey ?? "",
});
if (performance.now() < deadline) {
requestAnimationFrame(sample);
}
};
requestAnimationFrame(sample);
});
await sessionB.click();
await page.getByText(/^recent retained message 140\n/).waitFor({ timeout: 10_000 });
await new Promise<void>((resolve) => {
setTimeout(resolve, 800);
});
const samples = await page.evaluate(
() =>
(
globalThis as typeof globalThis & {
chatSessionReturnSamples: Array<{
hiddenNotice: boolean;
loading: boolean;
messageCount: number;
minOpacity: number;
sessionKey: string;
}>;
}
).chatSessionReturnSamples,
);
const returnedSamples = samples.filter(
(sample) => sample.sessionKey === "agent:main:session-b",
);
const restoredIndex = returnedSamples.findIndex((sample) => sample.messageCount === 140);
expect(restoredIndex).toBeGreaterThanOrEqual(0);
expect(
returnedSamples.slice(restoredIndex).every((sample) => sample.messageCount === 140),
).toBe(true);
expect(returnedSamples.every((sample) => sample.minOpacity === 1)).toBe(true);
expect(returnedSamples.every((sample) => !sample.hiddenNotice)).toBe(true);
expect(returnedSamples.every((sample) => !sample.loading)).toBe(true);
expect(await page.getByRole("button", { name: "Load older" }).count()).toBe(0);
await expectRequestCountStable(gateway, "chat.history", historyRequestsBeforeReturn + 1);
if (artifactDir) {
await page.screenshot({
path: `${artifactDir}/retained-history-return.png`,
fullPage: true,
});
}
} finally {
await suite.closeBrowserContext(context);
}
});
it("keeps rejected pre-ACK sends visible and restores the draft", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page);
try {
await page.goto(`${suite.server.baseUrl}chat`);
await gateway.deferNext("chat.send");
const prompt = "policy should not eat this";
const composer = page.locator(".agent-chat__composer-combobox textarea");
await composer.fill(prompt);
await page.getByRole("button", { name: "Send message" }).click();
await gateway.waitForRequest("chat.send");
await gateway.rejectDeferred("chat.send", {
code: "INVALID_REQUEST",
message: "send blocked by session policy",
});
await page.locator(".chat-queue").getByText("Failed").waitFor({ timeout: 10_000 });
await page.locator(".chat-queue").getByText(prompt).waitFor({ timeout: 10_000 });
expect(await composer.inputValue()).toBe(prompt);
} finally {
await suite.closeBrowserContext(context);
}
});
it("parks an ACK-lost send for review before a same-key manual retry", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: {
"chat.history": {
messages: [],
sessionId: "control-ui-e2e-session",
sessionInfo: { hasActiveRun: false, status: "done" },
thinkingLevel: null,
},
},
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
await gateway.deferNext("chat.send");
const prompt = "retry with the same key";
await page.locator(".agent-chat__composer-combobox textarea").fill(prompt);
await page.getByRole("button", { name: "Send message" }).click();
const firstRequest = await gateway.waitForRequest("chat.send");
const firstParams = requireRecord(firstRequest.params);
const runId = requireString(firstParams.idempotencyKey, "first idempotency key");
await gateway.closeLatest(1006, "lost ack");
const queue = page.locator(".chat-queue");
await queue.getByText("Needs review").waitFor({ timeout: 10_000 });
expect(await gateway.getRequests("chat.send")).toHaveLength(1);
await queue.locator(".chat-queue__retry").click();
const sends = await waitForRequests(gateway, "chat.send", 2);
const secondParams = requireRecord(sends[1]?.params);
expect(secondParams.idempotencyKey).toBe(runId);
expect(secondParams.message).toBe(prompt);
await queue.waitFor({ state: "detached", timeout: 10_000 });
} finally {
await suite.closeBrowserContext(context);
}
});
it("stores new input while offline and sends it after reconnect", async () => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const context = await suite.newBrowserContext({
locale: "en-US",
...(artifactDir
? { recordVideo: { dir: artifactDir, size: { height: 900, width: 1280 } } }
: {}),
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: {
"chat.history": {
messages: [],
sessionId: "control-ui-e2e-session",
sessionInfo: { hasActiveRun: false, status: "done" },
thinkingLevel: null,
},
},
});
try {
await page.goto(controlUiSessionUrl(suite.server.baseUrl, "global"));
const composer = page.locator(".agent-chat__composer-combobox textarea");
await composer.waitFor({ state: "visible", timeout: 10_000 });
await gateway.setOnline(false);
await page.locator(".agent-chat__offline-hint").waitFor({ timeout: 10_000 });
const prompt = "send this when the Gateway returns";
const attachmentName = "offline-proof.txt";
const attachmentMimeType = "text/plain";
const attachmentText = "offline attachment proof";
const attachmentBase64 = Buffer.from(attachmentText).toString("base64");
const attachmentDataUrl = `data:${attachmentMimeType};base64,${attachmentBase64}`;
const composerEnabled = await composer.isEnabled();
expect(composerEnabled).toBe(true);
await composer.fill(prompt);
await page.locator(".agent-chat__file-input").setInputFiles({
name: attachmentName,
mimeType: attachmentMimeType,
buffer: Buffer.from(attachmentText),
});
await page.locator(".chat-attachment-file__name", { hasText: attachmentName }).waitFor({
timeout: 10_000,
});
const send = page.getByRole("button", { name: "Send message" });
const sendEnabled = await send.isEnabled();
expect(sendEnabled).toBe(true);
await send.click();
const queue = page.locator(".chat-queue");
await queue.getByText("Waiting for reconnect").waitFor({ timeout: 10_000 });
await queue.getByText(prompt).waitFor({ timeout: 10_000 });
const requestsBeforeReconnect = await gateway.getRequests("chat.send");
expect(requestsBeforeReconnect).toHaveLength(0);
const readStoredProof = () =>
page.evaluate(
({ expectedAttachmentName, expectedAttachmentDataUrl, expectedPrompt }) => {
const storedValues = Object.entries(sessionStorage)
.filter(([key]) => key.startsWith("openclaw.control.chatComposer.v2:"))
.map(([, value]) => value);
const stored = storedValues.join("\n");
let runId: string | null = null;
for (const value of storedValues) {
try {
const parsed = JSON.parse(value) as {
sessions?: Record<
string,
{
queue?: Array<{
attachments?: Array<{ dataUrl?: unknown; fileName?: unknown }>;
sendRunId?: unknown;
text?: unknown;
}>;
}
>;
};
const item = Object.values(parsed.sessions ?? {})
.flatMap((session) => session.queue ?? [])
.find((entry) => entry.text === expectedPrompt);
if (typeof item?.sendRunId === "string") {
runId = item.sendRunId;
const attachment = item.attachments?.find(
(entry) => entry.fileName === expectedAttachmentName,
);
return {
attachment: attachment?.dataUrl === expectedAttachmentDataUrl,
prompt: true,
runId,
waitingReconnect: value.includes('"sendState":"waiting-reconnect"'),
};
}
} catch {
// Ignore unrelated malformed session storage in this focused proof.
}
}
return {
attachment: false,
prompt: stored.includes(expectedPrompt),
runId,
waitingReconnect: stored.includes('"sendState":"waiting-reconnect"'),
};
},
{
expectedAttachmentDataUrl: attachmentDataUrl,
expectedAttachmentName: attachmentName,
expectedPrompt: prompt,
},
);
await expect.poll(readStoredProof).toEqual({
attachment: true,
prompt: true,
runId: expect.any(String),
waitingReconnect: true,
});
const storedProof = await readStoredProof();
const storedRunId = requireString(storedProof.runId, "stored offline send idempotency key");
if (artifactDir) {
await page.screenshot({ path: `${artifactDir}/01-offline-queued.png`, fullPage: true });
}
await page.reload();
await expect.poll(readStoredProof).toEqual({
attachment: true,
prompt: true,
runId: storedRunId,
waitingReconnect: true,
});
// A cold reload waits for initial Gateway bootstrap before rebuilding the
// UI. Storage survival here plus replay below is the reload contract.
expect(await gateway.getRequests("chat.send")).toHaveLength(0);
await gateway.setOnline(true);
await page.locator("openclaw-chat-pane").waitFor({ state: "attached", timeout: 10_000 });
const request = await gateway.waitForRequest("chat.send");
const params = requireRecord(request.params);
const runId = requireString(params.idempotencyKey, "offline send idempotency key");
expect(params.message).toBe(prompt);
expect(runId).toBe(storedRunId);
expect(params.attachments).toEqual([
{
content: attachmentBase64,
fileName: attachmentName,
mimeType: attachmentMimeType,
type: "file",
},
]);
await queue.getByText("Needs review").waitFor({ timeout: 10_000 });
await queue
.getByText("Delivery could not be confirmed after reconnect.", { exact: false })
.waitFor({ timeout: 10_000 });
if (artifactDir) {
await page.screenshot({ path: `${artifactDir}/02-reconnected-review.png`, fullPage: true });
}
await expectRequestCountStable(gateway, "chat.send", 1);
const requestsAfterReconnect = await gateway.getRequests("chat.send");
await gateway.setHistoryMessages([
{
role: "user",
__openclaw: { idempotencyKey: `${runId}:user` },
},
]);
await gateway.emitChatFinal({ runId, text: "Delivered after reconnect." });
await queue.waitFor({ state: "detached", timeout: 10_000 });
await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 });
await expect
.poll(async () => {
const proof = await readStoredProof();
return proof.attachment || proof.prompt || proof.runId === runId;
})
.toBe(false);
await page.locator(".agent-chat__offline-hint").waitFor({ state: "detached" });
await expectRequestCountStable(gateway, "chat.send", 1);
if (artifactDir) {
await page.screenshot({ path: `${artifactDir}/03-online-delivered.png`, fullPage: true });
}
if (process.env.OPENCLAW_BEHAVIOR_PROOF === "1") {
process.stdout.write(
`${JSON.stringify({
proof: "offline-chat-reconnect",
composerEnabled,
sendEnabled,
waitingStateVisible: true,
storedPrompt: storedProof.prompt,
storedWaitingState: storedProof.waitingReconnect,
requestsBeforeReconnect: requestsBeforeReconnect.length,
requestsAfterReconnect: requestsAfterReconnect.length,
idempotencyKeyPresent: runId.length > 0,
queueClearedAfterDelivery: true,
})}\n`,
);
}
} finally {
await suite.closeBrowserContext(context);
}
});
});
@@ -0,0 +1,716 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import {
captureUiProofEnabled,
copiedViaExec,
createChatFlowE2eSuite,
expectDefined,
installMockGateway,
installPlainHttpClipboardCapture,
managedImageCacheProofDir,
waitForChatScrollIdle,
} from "./chat-flow.test-support.ts";
const suite = createChatFlowE2eSuite();
suite.define(() => {
it("downloads an assistant document with the server-provided Unicode filename", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const source = "/tmp/openclaw/测试 report.pdf";
const mediaUrl = `/__openclaw__/assistant-media?source=${encodeURIComponent(source)}&mediaTicket=ticket-download`;
const requestedUrls: URL[] = [];
// The document opens in a new tab, so intercept at the context boundary.
await context.route("**/__openclaw__/assistant-media?**", async (route) => {
const url = new URL(route.request().url());
requestedUrls.push(url);
await route.fulfill({
body: "%PDF-1.4\n",
contentType: "application/pdf",
headers: {
"Content-Disposition": `attachment; filename="__ report.pdf"; filename*=UTF-8''%E6%B5%8B%E8%AF%95%20report.pdf`,
},
});
});
await installMockGateway(page, {
historyMessages: [
{
role: "assistant",
content: [
{ type: "text", text: "Your report is ready." },
{
type: "attachment",
attachment: {
kind: "document",
label: "测试 report.pdf",
mimeType: "application/pdf",
url: mediaUrl,
},
},
],
timestamp: Date.now(),
},
],
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const link = page.getByRole("link", { name: "测试 report.pdf" });
await link.waitFor({ state: "visible", timeout: 10_000 });
const [download] = await Promise.all([page.waitForEvent("download"), link.click()]);
expect(download.suggestedFilename()).toBe("测试 report.pdf");
expect(requestedUrls).toHaveLength(1);
expect(requestedUrls[0]?.searchParams.get("source")).toBe(source);
expect(requestedUrls[0]?.searchParams.get("mediaTicket")).toBe("ticket-download");
} finally {
await suite.closeBrowserContext(context);
}
});
it("renders a direct tool-result image from Gateway history", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const imageData =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAF/gL+X3q8AAAAAElFTkSuQmCC";
const gateway = await installMockGateway(page, {
historyMessages: [
{
content: [
{ alt: "Tool result preview", data: imageData, mimeType: "image/png", type: "image" },
],
role: "assistant",
timestamp: Date.now(),
},
],
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const image = page.getByAltText("Tool result preview");
await image.waitFor({ state: "visible", timeout: 10_000 });
expect(await image.getAttribute("src")).toBe(`data:image/png;base64,${imageData}`);
await gateway.waitForRequest("chat.startup");
await expect
.poll(() => image.evaluate((element) => (element as HTMLImageElement).naturalWidth))
.toBe(1);
} finally {
await suite.closeBrowserContext(context);
}
});
it("renders a canonical inbound image through the ticketed media route", async () => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const requestedMediaUrls: URL[] = [];
await page.route("**/__openclaw__/assistant-media?**", async (route) => {
const request = route.request();
const url = new URL(request.url());
requestedMediaUrls.push(url);
expect(url.searchParams.get("source")).toBe("media://inbound/telegram-photo.png");
if (url.searchParams.get("meta") === "1") {
expect(request.headers().authorization).toBe("Bearer e2e-device-token");
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
available: true,
mediaTicket: "ticket-inbound",
mediaTicketExpiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
}),
});
return;
}
expect(url.searchParams.get("mediaTicket")).toBe("ticket-inbound");
await route.fulfill({
contentType: "image/png",
body: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zl1sAAAAASUVORK5CYII=",
"base64",
),
});
});
await installMockGateway(page, {
historyMessages: [
{
id: "user-inbound-media-ref",
role: "user",
content: [{ type: "text", text: "🖼️ Attached image" }],
__openclaw: {
media: [{ path: "media://inbound/telegram-photo.png", contentType: "image/png" }],
},
timestamp: Date.now(),
},
],
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
await expect.poll(() => requestedMediaUrls.length, { timeout: 10_000 }).toBe(2);
const image = page.locator("img.chat-message-image");
await image.waitFor({ state: "visible", timeout: 10_000 });
await expect
.poll(() =>
image.evaluate((element) =>
element instanceof HTMLImageElement && element.complete ? element.naturalWidth : 0,
),
)
.toBe(1);
if (artifactDir) {
await mkdir(artifactDir, { recursive: true });
await page.screenshot({
fullPage: true,
path: `${artifactDir}/canonical-inbound-image.png`,
});
}
} finally {
await suite.closeBrowserContext(context);
}
});
it("evicts and refetches managed image Blob URLs after the cache reaches capacity", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
await page.addInitScript(() => {
const originalCreateObjectURL = URL.createObjectURL.bind(URL);
const originalRevokeObjectURL = URL.revokeObjectURL.bind(URL);
const proof = { created: [] as string[], revoked: [] as string[] };
Object.defineProperty(globalThis, "managedImageCacheProof", {
configurable: true,
value: proof,
});
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: (blob: Blob) => {
const blobUrl = originalCreateObjectURL(blob);
proof.created.push(blobUrl);
return blobUrl;
},
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: (blobUrl: string) => {
proof.revoked.push(blobUrl);
originalRevokeObjectURL(blobUrl);
},
});
});
const imageUrls = Array.from({ length: 65 }, (_, index) => {
const id = String(index + 1).padStart(12, "0");
return `/api/chat/media/outgoing/agent%3Amain%3Amain/00000000-0000-4000-8000-${id}/full`;
});
const fetchedMedia: Array<{
authorization: string | undefined;
pathname: string;
requesterSessionKey: string | undefined;
}> = [];
await page.route("**/api/chat/media/outgoing/**", async (route) => {
const request = route.request();
const url = new URL(request.url());
fetchedMedia.push({
authorization: request.headers().authorization,
pathname: url.pathname,
requesterSessionKey: request.headers()["x-openclaw-requester-session-key"],
});
await route.fulfill({
body: '<svg xmlns="http://www.w3.org/2000/svg" width="160" height="90"><rect width="160" height="90" rx="12" fill="#0f766e"/><text x="80" y="50" text-anchor="middle" fill="white" font-family="sans-serif" font-size="14">managed preview</text></svg>',
contentType: "image/svg+xml",
});
});
const historyFor = (indexes: number[], labelPrefix: string) => [
{
content: indexes.map((index) => ({
alt: `${labelPrefix} ${index + 1}`,
type: "image",
url: imageUrls[index],
})),
role: "assistant",
timestamp: Date.now(),
},
];
const gateway = await installMockGateway(page, {
historyMessages: historyFor(
Array.from({ length: 64 }, (_, index) => index),
"Initial managed image",
),
});
const readBlobProof = () =>
page.evaluate(() => {
const proof = (
globalThis as typeof globalThis & {
managedImageCacheProof: { created: string[]; revoked: string[] };
}
).managedImageCacheProof;
return { created: [...proof.created], revoked: [...proof.revoked] };
});
let proofMessageSequence = 100;
const replaceHistory = async (messages: unknown[], visibleAlt: string) => {
const historyRequestsBefore = (await gateway.getRequests("chat.history")).length;
await gateway.setHistoryMessages(messages);
proofMessageSequence += 1;
await gateway.emitGatewayEvent("session.message", {
activeRunIds: [],
hasActiveRun: false,
message: messages[0],
messageId: `managed-image-cache-proof-${proofMessageSequence}`,
messageSeq: proofMessageSequence,
session: {
activeRunIds: [],
hasActiveRun: false,
key: "main",
kind: "direct",
status: "done",
updatedAt: Date.now(),
},
sessionKey: "main",
});
await expect
.poll(async () => (await gateway.getRequests("chat.history")).length, {
timeout: 15_000,
})
.toBeGreaterThan(historyRequestsBefore);
await page.getByAltText(visibleAlt).waitFor({ state: "visible", timeout: 10_000 });
};
try {
await page.goto(`${suite.server.baseUrl}chat`);
await gateway.waitForRequest("chat.startup");
await expect.poll(async () => (await readBlobProof()).created.length).toBe(64);
await expect
.poll(() =>
page
.locator("img.chat-message-image")
.evaluateAll(
(images) =>
images.filter(
(image) =>
image instanceof HTMLImageElement &&
image.complete &&
image.naturalWidth === 160,
).length,
),
)
.toBe(64);
await replaceHistory(
historyFor([0], "Recently viewed managed image"),
"Recently viewed managed image 1",
);
expect((await readBlobProof()).created).toHaveLength(64);
await replaceHistory(historyFor([64], "Overflow managed image"), "Overflow managed image 65");
await expect.poll(async () => (await readBlobProof()).created.length).toBe(65);
const overflowProof = await readBlobProof();
const retainedRecentBlobUrl = expectDefined(
overflowProof.created[0],
"recent managed image Blob URL",
);
const evictedBlobUrl = expectDefined(
overflowProof.created[1],
"evicted managed image Blob URL",
);
expect(overflowProof.revoked).toContain(evictedBlobUrl);
expect(overflowProof.revoked).not.toContain(retainedRecentBlobUrl);
const evictedPath = new URL(
expectDefined(imageUrls[1], "evicted managed image URL"),
suite.server.baseUrl,
).pathname;
const fetchesBeforeRevisit = fetchedMedia.filter(
(request) => request.pathname === evictedPath,
).length;
await replaceHistory(historyFor([1], "Refetched managed image"), "Refetched managed image 2");
const revisitedImage = page.getByAltText("Refetched managed image 2");
await expect
.poll(() =>
revisitedImage.evaluate((image) =>
image instanceof HTMLImageElement && image.complete ? image.naturalWidth : 0,
),
)
.toBe(160);
await expect.poll(async () => (await readBlobProof()).created.length).toBe(66);
const finalProof = await readBlobProof();
const evictedImageFetches = fetchedMedia.filter(
(request) => request.pathname === evictedPath,
).length;
expect(evictedImageFetches).toBe(fetchesBeforeRevisit + 1);
expect(fetchedMedia).not.toHaveLength(0);
expect(
fetchedMedia.every((request) => request.authorization === "Bearer e2e-device-token"),
).toBe(true);
expect(
fetchedMedia.every((request) => request.requesterSessionKey === "agent:main:main"),
).toBe(true);
const proofSummary = {
cacheCapacity: 64,
createdBlobUrls: finalProof.created.length,
evictedBlobIndex: 1,
evictedImageFetches,
refetchedImageNaturalWidth: await revisitedImage.evaluate(
(image) => (image as HTMLImageElement).naturalWidth,
),
retainedRecentBlobRevoked: finalProof.revoked.includes(retainedRecentBlobUrl),
revokedBlobUrls: finalProof.revoked.length,
};
if (captureUiProofEnabled) {
await mkdir(managedImageCacheProofDir, { recursive: true });
await page.evaluate((summary) => {
const panel = document.createElement("pre");
panel.setAttribute("data-managed-image-cache-proof", "true");
panel.style.cssText =
"position:fixed;right:16px;bottom:16px;z-index:99999;max-width:460px;padding:16px;border:2px solid #5eead4;border-radius:10px;background:#0f172a;color:#ccfbf1;font:14px/1.45 monospace;white-space:pre-wrap";
panel.textContent = `Managed image cache browser proof\n${JSON.stringify(summary, null, 2)}`;
document.body.append(panel);
}, proofSummary);
await page.screenshot({
fullPage: true,
path: path.join(managedImageCacheProofDir, "after-refetch.png"),
});
await writeFile(
path.join(managedImageCacheProofDir, "after-refetch.json"),
`${JSON.stringify(proofSummary, null, 2)}\n`,
"utf8",
);
}
if (process.env.OPENCLAW_BEHAVIOR_PROOF === "1") {
process.stdout.write(
`${JSON.stringify({ proof: "managed-image-cache", ...proofSummary })}\n`,
);
}
} finally {
await suite.closeBrowserContext(context);
}
});
it("copies a code block over a non-secure context via the execCommand fallback", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
// Simulate a plain-HTTP deployment where navigator.clipboard is unavailable.
await installPlainHttpClipboardCapture(page);
const code = "const hello = 1;";
const gateway = await installMockGateway(page, {
historyMessages: [
{
content: [
{
text: `${"long response line\n\n".repeat(80)}\`\`\`js\n${code}\n\`\`\``,
type: "text",
},
],
role: "assistant",
timestamp: Date.now(),
},
],
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const copyButton = page.locator(".code-block-copy").first();
await copyButton.waitFor({ timeout: 10_000 });
await waitForChatScrollIdle(page);
await copyButton.evaluate((element) => element.scrollIntoView({ block: "center" }));
const thread = page.locator(".chat-thread");
const scrollTopBefore = await thread.evaluate((element) =>
Math.round((element as HTMLElement).scrollTop),
);
// The copied class clears after 1500ms, so click and read it in one browser step.
const copied = await copyButton.evaluate(async (element) => {
const button = element as HTMLButtonElement;
const owner = element.closest("openclaw-chat-pane") as
| (HTMLElement & {
updateComplete: Promise<unknown>;
})
| null;
if (!owner) {
throw new Error("Chat pane owner is unavailable");
}
let copyObserver: MutationObserver | undefined;
const copySettled = new Promise<void>((resolve) => {
copyObserver = new MutationObserver(() => {
if (button.classList.contains("copied")) {
copyObserver?.disconnect();
resolve();
}
});
copyObserver.observe(button, { attributeFilter: ["class"], attributes: true });
});
button.click();
await owner.updateComplete;
if (!button.classList.contains("copied")) {
await copySettled;
}
copyObserver?.disconnect();
return button.classList.contains("copied");
});
expect(copied).toBe(true);
expect(await copiedViaExec(page)).toContain(code);
await expect
.poll(() => thread.evaluate((element) => Math.round((element as HTMLElement).scrollTop)))
.toBe(scrollTopBefore);
expect(await gateway.getRequests("chat.send")).toHaveLength(0);
} finally {
await suite.closeBrowserContext(context);
}
});
it("copies a workspace file path over a non-secure context via the execCommand fallback", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
await installPlainHttpClipboardCapture(page);
const gateway = await installMockGateway(page, {
methodResponses: {
"artifacts.list": { artifacts: [] },
"sessions.files.list": {
browser: { entries: [], path: "" },
files: [
{
kind: "modified",
missing: false,
name: "AGENTS.md",
path: "/workspace/AGENTS.md",
size: 2048,
},
],
root: "/workspace",
sessionKey: "main",
},
},
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
await page.locator(".chat-workspace-toggle").click();
await page.locator(".chat-workspace-rail__file-name", { hasText: "AGENTS.md" }).waitFor({
timeout: 10_000,
});
await page.getByRole("button", { name: "Copy path" }).click();
expect(await copiedViaExec(page)).toContain("/workspace/AGENTS.md");
expect(await gateway.getRequests("sessions.files.list")).toHaveLength(1);
expect(await gateway.getRequests("chat.send")).toHaveLength(0);
} finally {
await suite.closeBrowserContext(context);
}
});
it("starts the workspace files panel collapsed and toggles it open", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: {
"artifacts.list": {
artifacts: [
{
download: { mode: "bytes" },
id: "artifact-1",
mimeType: "image/png",
sizeBytes: 128,
title: "preview.png",
type: "image",
},
],
},
"sessions.files.list": {
browser: {
entries: [
{
kind: "directory",
name: "src",
path: "src",
sessionKind: "modified",
},
{
kind: "file",
name: "package.json",
path: "package.json",
size: 4096,
},
],
path: "",
},
files: [
{
kind: "modified",
missing: false,
name: "AGENTS.md",
path: "/workspace/AGENTS.md",
size: 2048,
},
],
root: "/workspace",
sessionKey: "main",
},
},
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
// Collapsed rails render nothing; the title-bar toggle carries the
// changed-file badge.
const opener = page.locator(".chat-workspace-toggle");
await opener.waitFor({ timeout: 10_000 });
expect(await gateway.getRequests("sessions.files.list")).toHaveLength(0);
expect(await page.locator(".chat-workspace-rail").count()).toBe(0);
await opener.click();
await page.locator(".chat-workspace-rail__collapse-toggle").waitFor({ timeout: 10_000 });
await expect.poll(() => opener.getAttribute("aria-expanded")).toBe("true");
await page.locator(".chat-workspace-rail__file-name", { hasText: "AGENTS.md" }).waitFor({
timeout: 10_000,
});
await page
.locator(".chat-workspace-rail__file-name", { hasText: "preview.png" })
.waitFor({ timeout: 10_000 });
await page.getByText("Project files").waitFor({ timeout: 10_000 });
await page.locator(".chat-workspace-rail__file-name", { hasText: "package.json" }).waitFor({
timeout: 10_000,
});
expect(await gateway.getRequests("sessions.files.list")).toHaveLength(1);
expect(await gateway.getRequests("artifacts.list")).toHaveLength(1);
// The rail docks flush to the window edge (no content gutter).
expect(
await page.locator(".chat-workspace-rail").evaluate((element) => {
return window.innerWidth - element.getBoundingClientRect().right;
}),
).toBe(0);
await page.locator(".chat-workspace-rail__collapse-toggle").click();
await opener.waitFor({ timeout: 10_000 });
expect(await page.locator(".chat-workspace-rail").count()).toBe(0);
await opener.click();
await page.locator(".chat-workspace-rail__collapse-toggle").waitFor({ timeout: 10_000 });
await page.locator(".chat-workspace-rail__file-name", { hasText: "AGENTS.md" }).waitFor({
timeout: 10_000,
});
expect(await gateway.getRequests("sessions.files.list")).toHaveLength(1);
await page.setViewportSize({ height: 900, width: 760 });
const workbench = page.locator(".chat-workbench");
await expect
.poll(() => workbench.getAttribute("class"))
.toContain("chat-workbench--dock-bottom");
const workspaceRail = page.locator(".chat-workspace-rail");
await expect
.poll(async () => {
const box = await workspaceRail.boundingBox();
return Boolean(box && box.width > 0 && box.height > 0);
})
.toBe(true);
expect(await page.locator(".chat-workspace-rail__dock").count()).toBe(0);
expect(await page.locator(".chat-workspace-rail__grip").count()).toBe(0);
} finally {
await suite.closeBrowserContext(context);
}
});
it("keeps long workspace file sections scrollable inside the rail", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 720, width: 1280 },
});
const page = await context.newPage();
const browserEntries = Array.from({ length: 60 }, (_, index) => ({
kind: "file" as const,
name: `file-${String(index + 1).padStart(2, "0")}.ts`,
path: `src/file-${String(index + 1).padStart(2, "0")}.ts`,
size: 2048 + index,
}));
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.files.list": {
browser: {
entries: browserEntries,
path: "",
},
files: [],
root: "/workspace",
sessionKey: "main",
},
},
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
await page.locator(".chat-workspace-toggle").click();
await page.locator(".chat-workspace-rail__file-name", { hasText: "file-60.ts" }).waitFor({
timeout: 10_000,
});
expect(await gateway.getRequests("sessions.files.list")).toHaveLength(1);
const browserSection = page.locator(".chat-workspace-rail__section", {
hasText: "Project files",
});
await expect
.poll(
() =>
browserSection.evaluate((section) => {
const element = section as HTMLElement;
const scroll = element.closest(".chat-workspace-rail__scroll") as HTMLElement | null;
if (!scroll) {
throw new Error("Expected workspace rail scroll container");
}
const sectionRect = element.getBoundingClientRect();
const scrollRect = scroll.getBoundingClientRect();
const style = getComputedStyle(element);
return {
bottomWithinRail: Math.ceil(sectionRect.bottom) <= Math.ceil(scrollRect.bottom),
clientHeight: element.clientHeight,
overflowY: style.overflowY,
scrollHeight: element.scrollHeight,
};
}),
{ timeout: 10_000 },
)
.toMatchObject({
bottomWithinRail: true,
overflowY: "auto",
});
const sectionMetrics = await browserSection.evaluate((section) => {
const element = section as HTMLElement;
return {
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
};
});
expect(sectionMetrics.scrollHeight).toBeGreaterThan(sectionMetrics.clientHeight);
} finally {
await suite.closeBrowserContext(context);
}
});
});
+594
View File
@@ -0,0 +1,594 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import {
captureUiProofEnabled,
channelStopProofDir,
chatSessionListResponse,
controlUiSessionUrl,
createChatFlowE2eSuite,
expectRequestCountStable,
installMockGateway,
requireRecord,
requireString,
waitForRequests,
} from "./chat-flow.test-support.ts";
const suite = createChatFlowE2eSuite();
suite.define(() => {
it("sends a chat turn through the GUI and renders the final Gateway event", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
historyMessages: [
{
content: [{ text: "Ready for an end-to-end GUI check.", type: "text" }],
role: "assistant",
timestamp: Date.now(),
},
],
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
await page.getByText("Ready for an end-to-end GUI check.").waitFor({ timeout: 10_000 });
const prompt = "verify the control UI e2e harness";
await page.locator(".agent-chat__composer-combobox textarea").fill(prompt);
await page.getByRole("button", { name: "Send message" }).click();
const sendRequest = await gateway.waitForRequest("chat.send");
const params = requireRecord(sendRequest.params);
expect(params.sessionKey).toBe("main");
expect(params.message).toBe(prompt);
expect(params.deliver).toBe(false);
const runId = requireString(params.idempotencyKey, "chat send idempotency key");
await gateway.emitChatFinal({ runId, text: "Harness verified." });
await page
.locator(".chat-thread-inner")
.getByText("Harness verified.")
.waitFor({ timeout: 10_000 });
const spacedPairCommand = "/ pair qr";
await page.locator(".agent-chat__composer-combobox textarea").fill(spacedPairCommand);
await page.getByRole("button", { name: "Send message" }).click();
const commandRequests = await waitForRequests(gateway, "chat.send", 2);
const commandParams = requireRecord(commandRequests[1]?.params);
expect(commandParams.message).toBe(spacedPairCommand);
} finally {
await suite.closeBrowserContext(context);
}
});
it("keeps a browser-local prompt before a clock-skewed Gateway reply", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, { historyMessages: [] });
const prompt = "verify clock-skewed chat order";
const partial = "The Gateway is replying from an earlier clock.";
const reply = "The Gateway reply stayed below its prompt.";
const appearsBefore = (lowerSelector: string, lowerText: string) =>
page.locator(".chat-thread-inner").evaluate(
(thread: Element, texts: { lowerSelector: string; lowerText: string; prompt: string }) => {
const findByText = (selector: string, text: string) =>
Array.from(thread.querySelectorAll(selector)).find((row) =>
(row.textContent ?? "").includes(text),
);
const promptRow = findByText(".chat-group.user", texts.prompt);
const lowerRow = findByText(texts.lowerSelector, texts.lowerText);
if (!promptRow || !lowerRow) {
return false;
}
return promptRow.getBoundingClientRect().top < lowerRow.getBoundingClientRect().top;
},
{ lowerSelector, lowerText, prompt },
);
try {
await page.goto(`${suite.server.baseUrl}chat`);
await gateway.deferNext("chat.send");
await page.locator(".agent-chat__composer-combobox textarea").fill(prompt);
await page.getByRole("button", { name: "Send message" }).click();
const sendRequest = await gateway.waitForRequest("chat.send");
const runId = requireString(
requireRecord(sendRequest.params).idempotencyKey,
"chat send idempotency key",
);
const browserTimestamp = await page.evaluate(() => Date.now());
const gatewayTimestamp = browserTimestamp - 60_000;
await gateway.emitGatewayEvent("chat", {
deltaText: partial,
message: {
content: [{ text: partial, type: "text" }],
role: "assistant",
timestamp: gatewayTimestamp,
},
runId,
sessionKey: "main",
state: "delta",
});
await page.locator(".chat-bubble.streaming", { hasText: partial }).waitFor({
timeout: 10_000,
});
expect(await appearsBefore(".chat-bubble.streaming", partial)).toBe(true);
await gateway.emitGatewayEvent("chat", {
message: {
content: [{ text: reply, type: "text" }],
role: "assistant",
timestamp: gatewayTimestamp,
},
runId,
sessionKey: "main",
state: "final",
});
await page.locator(".chat-group.assistant .chat-text", { hasText: reply }).waitFor({
timeout: 10_000,
});
expect(await appearsBefore(".chat-group.assistant", reply)).toBe(true);
await gateway.resolveDeferred("chat.send", { runId, status: "started" });
expect(await appearsBefore(".chat-group.assistant", reply)).toBe(true);
} finally {
await suite.closeBrowserContext(context);
}
});
it("reconciles authoritative history before a trailing final by run identity", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, { historyMessages: [] });
try {
await page.goto(`${suite.server.baseUrl}chat`);
const composer = page.locator(".agent-chat__composer-combobox textarea");
await composer.fill("reconcile the terminal event ordering");
await page.getByRole("button", { name: "Send message" }).click();
const send = await gateway.waitForRequest("chat.send");
const runId = requireString(
requireRecord(send.params).idempotencyKey,
"chat send idempotency key",
);
const finalText = "One authoritative final response.";
const messageId = "assistant-authoritative-final";
const authoritative = {
__openclaw: { id: messageId, seq: 2 },
content: [{ text: finalText, type: "text" }],
role: "assistant",
timestamp: Date.now(),
};
await gateway.emitGatewayEvent("chat", {
deltaText: finalText,
message: {
content: [{ text: finalText, type: "text" }],
role: "assistant",
timestamp: Date.now(),
},
runId,
sessionKey: "main",
state: "delta",
});
await page.locator(".chat-bubble.streaming", { hasText: finalText }).waitFor();
await gateway.setHistoryMessages([
{
__openclaw: { id: "user-reconcile", seq: 1 },
content: [{ text: "reconcile the terminal event ordering", type: "text" }],
role: "user",
timestamp: Date.now() - 1,
},
authoritative,
]);
const historyRequestsBefore = (await gateway.getRequests("chat.history")).length;
await gateway.emitGatewayEvent("session.message", {
activeRunIds: [],
clientRunId: runId,
hasActiveRun: false,
message: authoritative,
messageId,
messageSeq: 2,
session: {
activeRunIds: [],
hasActiveRun: false,
key: "main",
kind: "direct",
status: "done",
updatedAt: Date.now(),
},
sessionKey: "main",
});
await expect
.poll(async () => (await gateway.getRequests("chat.history")).length)
.toBeGreaterThan(historyRequestsBefore);
await page.locator(".chat-thread-inner").getByText(finalText, { exact: true }).waitFor();
await expect
.poll(() =>
page.locator(".chat-group.assistant .chat-text", { hasText: finalText }).count(),
)
.toBe(1);
await gateway.emitChatFinal({ runId, text: finalText });
await expect
.poll(() => page.locator(".chat-group.assistant .chat-duplicate-count").count())
.toBe(0);
await expect
.poll(() =>
page.locator(".chat-group.assistant .chat-text", { hasText: finalText }).count(),
)
.toBe(1);
await gateway.emitChatFinal({ runId: "a-different-legitimate-run", text: finalText });
await expect
.poll(() =>
page.locator(".chat-group.assistant .chat-text", { hasText: finalText }).count(),
)
.toBe(2);
} finally {
await suite.closeBrowserContext(context);
}
});
it("restores the selected session transcript after a hard reload", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const historyText = "Transcript survives a hard reload.";
const gateway = await installMockGateway(page, {
historyMessages: [
{
content: [{ text: historyText, type: "text" }],
role: "assistant",
timestamp: Date.now(),
},
],
sessionKey: "agent:main:main",
});
try {
await page.goto(controlUiSessionUrl(suite.server.baseUrl, "main"));
await page.getByText(historyText).waitFor({ timeout: 10_000 });
await gateway.waitForRequest("chat.startup");
await page.reload();
await page.getByText(historyText).waitFor({ timeout: 10_000 });
await expect.poll(async () => (await gateway.getRequests("chat.startup")).length).toBe(2);
} finally {
await suite.closeBrowserContext(context);
}
});
it("sends idle stop aliases as ordinary chat messages", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page);
try {
await page.goto(`${suite.server.baseUrl}chat`);
const composer = page.locator(".agent-chat__composer-combobox textarea");
await composer.waitFor({ state: "visible", timeout: 10_000 });
await composer.fill("wait");
await page.getByRole("button", { name: "Send message" }).click();
const sendRequest = await gateway.waitForRequest("chat.send");
expect(requireRecord(sendRequest.params).message).toBe("wait");
expect(await gateway.getRequests("chat.abort")).toHaveLength(0);
} finally {
await suite.closeBrowserContext(context);
}
});
it("sends /stop to the exact selected channel session and clears its working indicator", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const channelSessionKey = "agent:main:openclaw-weixin:direct:wechat-user";
const gateway = await installMockGateway(page, {
sessionKey: channelSessionKey,
methodResponses: {
"sessions.abort": { abortedRunId: null, ok: true, status: "aborted" },
"sessions.list": chatSessionListResponse([
{
hasActiveRun: true,
key: channelSessionKey,
kind: "direct",
label: "WeChat user",
status: "running",
updatedAt: Date.now(),
},
]),
},
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const composer = page.locator(".agent-chat__composer-combobox textarea");
await composer.waitFor({ state: "visible", timeout: 10_000 });
await gateway.waitForRequest("sessions.list");
const workingIndicator = page.locator(".chat-working-indicator");
await workingIndicator.waitFor({ state: "visible", timeout: 10_000 });
await composer.fill("/stop");
await page.getByRole("option", { name: /\/stop/ }).waitFor();
await composer.press("Enter");
const abortRequest = await gateway.waitForRequest("sessions.abort");
expect(requireRecord(abortRequest.params)).toEqual({
key: channelSessionKey,
clearQueued: true,
});
await gateway.setMethodResponse(
"sessions.list",
chatSessionListResponse([
{
activeRunIds: [],
hasActiveRun: false,
key: channelSessionKey,
kind: "direct",
label: "WeChat user",
status: "running",
updatedAt: Date.now(),
},
]),
);
await gateway.emitGatewayEvent("sessions.changed", {
activeRunIds: [],
hasActiveRun: false,
reason: "abort",
sessionKey: channelSessionKey,
status: "running",
updatedAt: Date.now(),
});
await workingIndicator.waitFor({ state: "detached", timeout: 10_000 });
await expectRequestCountStable(gateway, "chat.abort", 0);
await expectRequestCountStable(gateway, "chat.send", 0);
await expect.poll(() => page.getByRole("listbox").count()).toBe(0);
expect(await composer.inputValue()).toBe("");
if (captureUiProofEnabled) {
await mkdir(channelStopProofDir, { recursive: true });
await page.screenshot({
path: path.join(channelStopProofDir, "stopped.png"),
fullPage: true,
});
}
} finally {
await suite.closeBrowserContext(context);
}
});
it("persists the chat send shortcut and keeps multiline and IME input safe", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page);
try {
await page.goto(`${suite.server.baseUrl}chat`);
const composer = page.locator(".agent-chat__composer-combobox textarea");
await composer.waitFor({ state: "visible", timeout: 10_000 });
await composer.fill("default enter send");
await composer.press("Enter");
const defaultRequest = await gateway.waitForRequest("chat.send");
const defaultParams = requireRecord(defaultRequest.params);
expect(defaultParams.message).toBe("default enter send");
await gateway.emitChatFinal({
runId: requireString(defaultParams.idempotencyKey, "default send idempotency key"),
text: "Default shortcut received.",
});
await page
.locator(".chat-thread-inner")
.getByText("Default shortcut received.")
.waitFor({ timeout: 10_000 });
// The send shortcut moved to the Settings appearance page; picking it
// there must apply to the chat composer after navigating back.
await page.goto(`${suite.server.baseUrl}settings/appearance`);
const shortcutSelect = page.locator("[data-settings-send-shortcut]");
await shortcutSelect.selectOption("modifier-enter");
expect(await shortcutSelect.inputValue()).toBe("modifier-enter");
await page.goto(`${suite.server.baseUrl}chat`);
await composer.waitFor({ state: "visible", timeout: 10_000 });
expect(await composer.getAttribute("aria-keyshortcuts")).toBe("Control+Enter Meta+Enter");
await composer.fill("plain enter stays in the draft");
await composer.press("Enter");
expect(await composer.inputValue()).toContain("\n");
expect(await gateway.getRequests("chat.send")).toHaveLength(0);
await composer.fill("composition must not send");
await composer.dispatchEvent("compositionstart");
await composer.press("Control+Enter");
await composer.dispatchEvent("compositionend");
expect(await gateway.getRequests("chat.send")).toHaveLength(0);
await composer.fill("modifier send");
await composer.press("Meta+Enter");
const modifierRequest = await gateway.waitForRequest("chat.send");
expect(requireRecord(modifierRequest.params).message).toBe("modifier send");
} finally {
await suite.closeBrowserContext(context);
}
});
it("steers an active run when the session row only reports hasActiveRun", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const sessionKey = "main";
const gateway = await installMockGateway(page, {
historyMessages: [
{
content: [{ text: "Active run is waiting for steering.", type: "text" }],
role: "assistant",
timestamp: Date.now(),
},
],
methodResponses: {
"sessions.list": chatSessionListResponse([
{
hasActiveRun: true,
key: "agent:main:main",
kind: "direct",
label: "Main",
updatedAt: Date.now(),
},
]),
},
sessionKey,
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
await page.getByText("Active run is waiting for steering.").waitFor({ timeout: 10_000 });
await gateway.waitForRequest("sessions.list");
await page.getByRole("button", { name: "Stop generating" }).waitFor({ timeout: 10_000 });
await page
.locator(".agent-chat__composer-combobox textarea")
.fill("/steer use the smaller fix");
await page.getByRole("button", { name: "Send message" }).click();
const steerRequest = await gateway.waitForRequest("chat.send");
const params = requireRecord(steerRequest.params);
expect(params.sessionKey).toBe(sessionKey);
expect(params.message).toBe("use the smaller fix");
expect(params.deliver).toBe(false);
await page.getByText("Steered.", { exact: true }).waitFor({ timeout: 10_000 });
expect(await page.getByText("No active run").count()).toBe(0);
} finally {
await suite.closeBrowserContext(context);
}
});
it("keeps a targetless message-tool source reply beside the automatic final reply", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page);
try {
await page.goto(`${suite.server.baseUrl}chat`);
const prompt = "send progress through the message tool and then finish";
await page.locator(".agent-chat__composer-combobox textarea").fill(prompt);
await page.getByRole("button", { name: "Send message" }).click();
const sendRequest = await gateway.waitForRequest("chat.send");
const params = requireRecord(sendRequest.params);
expect(params).toMatchObject({ sessionKey: "main", message: prompt, deliver: false });
const runId = requireString(params.idempotencyKey, "chat send idempotency key");
await gateway.emitChatFinal({
runId,
text: "Visible progress from the targetless message tool.",
});
await page
.locator(".chat-thread-inner")
.getByText("Visible progress from the targetless message tool.")
.waitFor({ timeout: 10_000 });
await gateway.emitChatFinal({ runId, text: "Visible automatic final reply." });
await page
.locator(".chat-thread-inner")
.getByText("Visible automatic final reply.")
.waitFor({ timeout: 10_000 });
const bubbleTexts = await page.locator(".chat-thread .chat-bubble").allTextContents();
for (const expectedText of [
prompt,
"Visible progress from the targetless message tool.",
"Visible automatic final reply.",
]) {
expect(bubbleTexts.some((text) => text.includes(expectedText))).toBe(true);
}
} finally {
await suite.closeBrowserContext(context);
}
});
it("keeps the composer clear when a stale native input replay arrives after send", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
historyMessages: [
{
content: [{ text: "Ready for stale replay check.", type: "text" }],
role: "assistant",
timestamp: Date.now(),
},
],
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
await page.getByText("Ready for stale replay check.").waitFor({ timeout: 10_000 });
const prompt = "submitted message";
const composer = page.locator(".agent-chat__composer-combobox textarea");
await composer.fill(prompt);
await page.getByRole("button", { name: "Send message" }).click();
await gateway.waitForRequest("chat.send");
expect(await composer.inputValue()).toBe("");
const afterReplay = await composer.evaluate((element, submitted) => {
const textarea = element as HTMLTextAreaElement;
textarea.value = submitted;
textarea.dispatchEvent(
new InputEvent("input", {
bubbles: true,
data: submitted,
inputType: "insertText",
}),
);
return textarea.value;
}, prompt);
expect(afterReplay).toBe("");
expect(await composer.inputValue()).toBe("");
await composer.pressSequentially(prompt);
expect(await composer.inputValue()).toBe(prompt);
} finally {
await suite.closeBrowserContext(context);
}
});
});
@@ -0,0 +1,465 @@
import { expect, it } from "vitest";
import {
chatSessionListResponse,
createChatFlowE2eSuite,
expectRequestCountStable,
installMockGateway,
requireRecord,
waitForRequests,
} from "./chat-flow.test-support.ts";
const suite = createChatFlowE2eSuite();
suite.define(() => {
it("routes runtime-aware model commands through the server directive path", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
sessionKey: "agent:main:main",
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const command = "/model openai/gpt-5.6-luna --runtime codex continue with the selected model";
await page.locator(".agent-chat__composer-combobox textarea").fill(command);
await page.getByRole("button", { name: "Send message" }).click();
const sendRequest = await gateway.waitForRequest("chat.send");
expect(requireRecord(sendRequest.params)).toMatchObject({
message: command,
sessionKey: "agent:main:main",
});
expect(await gateway.getRequests("sessions.patch")).toHaveLength(0);
} finally {
await suite.closeBrowserContext(context);
}
});
it("keeps a session model override selected after switching away and back", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.list": chatSessionListResponse(),
},
models: [
{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai" },
{ id: "claude-opus-4.5", name: "Claude Opus 4.5", provider: "bedrock" },
],
sessionKey: "agent:main:session-a",
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const main = page.getByRole("main");
const openModelSelect = async () => {
const trigger = main.locator('[data-chat-model-select="true"]').first();
await trigger.waitFor({ state: "visible", timeout: 10_000 });
return trigger;
};
const selectModel = async (value: string) => {
await main.locator('[data-chat-model-select="true"]').click();
const provider = value.split("/", 1)[0];
await main.locator(`[data-chat-model-provider="${provider}"]`).click();
const option = main.locator(`[data-chat-model-option="${value}"]`);
await option.waitFor({ state: "visible", timeout: 10_000 });
await option.click();
await page.keyboard.press("Escape");
};
let modelSelect = await openModelSelect();
expect(await modelSelect.getAttribute("data-chat-select-value")).toBe("");
await selectModel("bedrock/claude-opus-4.5");
const patchRequest = await gateway.waitForRequest("sessions.patch");
expect(requireRecord(patchRequest.params)).toMatchObject({
key: "agent:main:session-a",
model: "bedrock/claude-opus-4.5",
});
expect(await modelSelect.getAttribute("data-chat-select-value")).toBe(
"bedrock/claude-opus-4.5",
);
await page
.locator(
'.sidebar-recent-session[data-session-key="agent:main:session-b"] a.sidebar-recent-session__link',
)
.click();
await page.locator(".sidebar-recent-session--active").getByText("Session B").waitFor({
timeout: 10_000,
});
modelSelect = await openModelSelect();
expect(await modelSelect.getAttribute("data-chat-select-value")).toBe("");
await page
.locator(
'.sidebar-recent-session[data-session-key="agent:main:session-a"] a.sidebar-recent-session__link',
)
.click();
await page.locator(".sidebar-recent-session--active").getByText("Session A").waitFor({
timeout: 10_000,
});
modelSelect = await openModelSelect();
expect(await modelSelect.getAttribute("data-chat-select-value")).toBe(
"bedrock/claude-opus-4.5",
);
} finally {
await suite.closeBrowserContext(context);
}
});
it("restores the selected agent model after clearing a session override", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const agentsList = {
agents: [
{
id: "ops",
model: { primary: "anthropic/claude-opus-4-5" },
name: "Operations",
},
],
defaultId: "ops",
mainKey: "main",
scope: "agent",
};
const sessionsList = {
count: 1,
defaults: {
contextTokens: null,
model: "gpt-5.5",
modelProvider: "openai",
},
path: "",
sessions: [
{
key: "agent:ops:session-a",
kind: "direct",
label: "Operations",
updatedAt: Date.now(),
},
],
ts: Date.now(),
};
const gateway = await installMockGateway(page, {
assistantAgentId: "ops",
defaultAgentId: "ops",
methodResponses: {
"agents.list": agentsList,
"chat.startup": {
agentsList,
messages: [],
metadata: {
models: [
{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai" },
{
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
provider: "anthropic",
},
],
},
sessionId: "control-ui-e2e-session",
thinkingLevel: null,
},
"sessions.list": sessionsList,
},
models: [
{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai" },
{ id: "claude-opus-4-5", name: "Claude Opus 4.5", provider: "anthropic" },
],
sessionKey: "agent:ops:session-a",
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const main = page.getByRole("main");
const modelSelect = main.locator('[data-chat-model-select="true"]').first();
await modelSelect.waitFor({ state: "visible", timeout: 10_000 });
expect(await modelSelect.textContent()).toContain("Claude Opus 4.5");
expect(await modelSelect.getAttribute("data-chat-select-value")).toBe("");
await modelSelect.click();
await main.locator('[data-chat-model-provider="openai"]').click();
await main.locator('[data-chat-model-option="openai/gpt-5.5"]').click();
const firstPatch = await gateway.waitForRequest("sessions.patch");
expect(requireRecord(firstPatch.params)).toMatchObject({
key: "agent:ops:session-a",
model: "openai/gpt-5.5",
});
expect(await modelSelect.textContent()).toContain("GPT-5.5");
// The picker stays open after an immediate apply. Return to the default
// model's provider and select its real catalog row to clear the override.
await main.locator('[data-chat-model-provider="anthropic"]').click();
const defaultModel = main.locator(
'[data-chat-model-option="anthropic/claude-opus-4-5"][data-chat-model-default="true"]',
);
await defaultModel.waitFor({ state: "visible", timeout: 10_000 });
expect(await defaultModel.textContent()).toContain("Default");
expect(await main.locator('[data-chat-model-option=""]').count()).toBe(0);
await defaultModel.click();
const patches = await waitForRequests(gateway, "sessions.patch", 2);
expect(requireRecord(patches[1]?.params)).toMatchObject({
key: "agent:ops:session-a",
model: null,
});
expect(await modelSelect.textContent()).toContain("Claude Opus 4.5");
expect(await modelSelect.getAttribute("data-chat-select-value")).toBe("");
} finally {
await suite.closeBrowserContext(context);
}
});
it("shows a pending send while a model override update is still pending", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
deferredMethods: ["sessions.patch"],
methodResponses: {
"sessions.list": chatSessionListResponse(),
},
models: [
{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai" },
{ id: "claude-opus-4.5", name: "Claude Opus 4.5", provider: "bedrock" },
],
sessionKey: "agent:main:session-a",
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const main = page.getByRole("main");
await main.locator('[data-chat-model-select="true"]').click();
await main.locator('[data-chat-model-provider="bedrock"]').click();
await main.locator('[data-chat-model-option="bedrock/claude-opus-4.5"]').click();
await page.keyboard.press("Escape");
await gateway.waitForRequest("sessions.patch");
const prompt = "send while the model save is pending";
await page.locator(".agent-chat__composer-combobox textarea").fill(prompt);
await page.getByRole("button", { name: "Send message" }).click();
await page.locator(".chat-queue").getByText("Applying chat settings").waitFor({
timeout: 10_000,
});
await page.locator(".chat-queue").getByText(prompt).waitFor({ timeout: 10_000 });
expect(await gateway.getRequests("chat.send")).toHaveLength(0);
await gateway.resolveDeferred("sessions.patch", {});
const sendRequest = await gateway.waitForRequest("chat.send");
const params = requireRecord(sendRequest.params);
expect(params.message).toBe(prompt);
expect(params.sessionKey).toBe("agent:main:session-a");
} finally {
await suite.closeBrowserContext(context);
}
});
it("applies provider-specific reasoning after the selected model is saved", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const sessionKey = "agent:main:session-a";
const fableSession = {
key: sessionKey,
kind: "direct",
label: "Session A",
model: "claude-fable-5",
modelProvider: "anthropic",
thinkingDefault: "high",
thinkingLevel: "high",
thinkingLevels: [
{ id: "off", label: "off" },
{ id: "adaptive", label: "adaptive" },
{ id: "high", label: "high" },
{ id: "xhigh", label: "xhigh" },
{ id: "max", label: "max" },
],
updatedAt: 2,
};
const solSession = {
...fableSession,
model: "gpt-5.6-sol",
modelProvider: "openai",
thinkingLevels: [
{ id: "off", label: "off" },
{ id: "low", label: "low" },
{ id: "medium", label: "medium" },
{ id: "high", label: "high" },
{ id: "ultra", label: "ultra" },
],
};
const gateway = await installMockGateway(page, {
deferredMethods: ["sessions.patch"],
methodResponses: {
"sessions.list": chatSessionListResponse([fableSession]),
},
models: [
{ id: "claude-fable-5", name: "Claude Fable 5", provider: "anthropic" },
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol", provider: "openai" },
],
sessionKey,
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const main = page.getByRole("main");
await main.locator('[data-chat-model-select="true"]').click();
await main.locator('[data-chat-model-provider="openai"]').click();
await main.locator('[data-chat-model-option="openai/gpt-5.6-sol"]').click();
const modelPatch = await gateway.waitForRequest("sessions.patch");
expect(requireRecord(modelPatch.params)).toMatchObject({
key: sessionKey,
model: "openai/gpt-5.6-sol",
});
const thinkingSlider = main.locator('[data-chat-thinking-slider="true"]');
await expect.poll(() => thinkingSlider.isDisabled()).toBe(true);
await thinkingSlider.evaluate((input) => {
const slider = input as HTMLInputElement;
slider.value = slider.max;
slider.dispatchEvent(new Event("change", { bubbles: true }));
});
await expectRequestCountStable(gateway, "sessions.patch", 1);
await gateway.setMethodResponse("sessions.list", chatSessionListResponse([solSession]));
await gateway.resolveDeferred("sessions.patch");
await expect
.poll(() => thinkingSlider.getAttribute("data-chat-thinking-values"))
.toContain("ultra");
await expect.poll(() => thinkingSlider.isEnabled()).toBe(true);
await thinkingSlider.evaluate((input) => {
const slider = input as HTMLInputElement;
const values = (slider.dataset.chatThinkingValues ?? "").split(",");
slider.value = String(values.indexOf("ultra"));
slider.dispatchEvent(new Event("input", { bubbles: true }));
slider.dispatchEvent(new Event("change", { bubbles: true }));
});
const patches = await waitForRequests(gateway, "sessions.patch", 2);
expect(requireRecord(patches[1]?.params)).toMatchObject({
key: sessionKey,
thinkingLevel: "ultra",
});
await expect.poll(() => page.getByText(/not supported for/u).count()).toBe(0);
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
if (artifactDir) {
await page.screenshot({
path: `${artifactDir}/model-thinking-sync.png`,
fullPage: true,
});
}
} finally {
await suite.closeBrowserContext(context);
}
});
it("keeps send pending until reasoning and speed patches finish", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.list": {
...chatSessionListResponse([
{
effectiveFastMode: false,
fastMode: false,
key: "agent:main:session-a",
kind: "direct",
label: "Session A",
model: "gpt-5.5",
modelProvider: "openai",
thinkingLevel: "high",
updatedAt: 2,
},
]),
defaults: {
contextTokens: null,
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" },
],
},
},
},
models: [{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai" }],
sessionKey: "agent:main:session-a",
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const main = page.getByRole("main");
await main.locator('[data-chat-model-select="true"]').click();
await gateway.deferNext("sessions.patch");
await main.locator('[data-chat-thinking-slider="true"]').press("ArrowLeft");
const firstPatch = await gateway.waitForRequest("sessions.patch");
expect(requireRecord(firstPatch.params).thinkingLevel).toBe("medium");
await gateway.deferNext("sessions.patch");
await main.locator('[data-chat-speed-toggle="on"]').click();
await expectRequestCountStable(gateway, "sessions.patch", 1);
await page.keyboard.press("Escape");
const prompt = "send with the new reasoning and speed";
await page.locator(".agent-chat__composer-combobox textarea").fill(prompt);
await page.getByRole("button", { name: "Send message" }).click();
await page.locator(".chat-queue").getByText("Applying chat settings").waitFor({
timeout: 10_000,
});
expect(await gateway.getRequests("chat.send")).toHaveLength(0);
const sessionListCount = (await gateway.getRequests("sessions.list")).length;
await gateway.resolveDeferred("sessions.patch", {});
const patches = await waitForRequests(gateway, "sessions.patch", 2);
expect(requireRecord(patches[1]?.params).fastMode).toBe(true);
await expect
.poll(async () => (await gateway.getRequests("sessions.list")).length)
.toBeGreaterThan(sessionListCount);
expect(await gateway.getRequests("chat.send")).toHaveLength(0);
await gateway.resolveDeferred("sessions.patch", {});
const sendRequest = await gateway.waitForRequest("chat.send");
expect(requireRecord(sendRequest.params)).toMatchObject({
message: prompt,
sessionKey: "agent:main:session-a",
});
} finally {
await suite.closeBrowserContext(context);
}
});
});
@@ -0,0 +1,774 @@
import { expect, it } from "vitest";
import {
SESSION_DRAG_MIME,
captureSessionAccessibilityProof,
chatSessionListResponse,
controlUiSessionPath,
createChatFlowE2eSuite,
expectDefined,
installMockGateway,
requireRecord,
sidebarSessionOrder,
} from "./chat-flow.test-support.ts";
const suite = createChatFlowE2eSuite();
suite.define(() => {
it("renders always-on pane headers without desktop topbar chrome", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1440 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
historyMessages: [
{
content: [{ type: "text", text: "Split toolbar proof." }],
role: "assistant",
timestamp: Date.now(),
},
],
methodResponses: { "sessions.list": chatSessionListResponse() },
sessionKey: "agent:main:session-a",
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
await page.getByText("Split toolbar proof.").waitFor({ timeout: 10_000 });
// Desktop renders no topbar row: the sidebar owns navigation.
await expect.poll(() => page.locator(".topbar").isVisible()).toBe(false);
const splitEntry = page.getByRole("button", { name: "Open split view" });
await expect.poll(() => splitEntry.isVisible()).toBe(true);
await expect.poll(() => page.locator(".chat-pane__header").count()).toBe(1);
await page.evaluate(() => {
document.documentElement.classList.add("openclaw-native-macos");
document.querySelector(".shell")?.classList.add("shell--nav-collapsed");
});
await expect
.poll(() =>
page
.locator(".chat-pane__header")
.evaluate((header) => getComputedStyle(header).paddingLeft),
)
.toBe("90px");
await page.evaluate(() => {
document.documentElement.classList.remove("openclaw-native-macos");
document.querySelector(".shell")?.classList.remove("shell--nav-collapsed");
});
await page.setViewportSize({ height: 900, width: 1100 });
await expect.poll(() => splitEntry.isVisible()).toBe(true);
await page.setViewportSize({ height: 900, width: 1440 });
await expect
.poll(() =>
splitEntry.evaluate((node) => node.closest(".agent-chat__composer-shell") == null),
)
.toBe(true);
await page.locator("openclaw-chat-pane").evaluate((pane) => {
(
globalThis as typeof globalThis & {
classicChatPane?: Element;
}
).classicChatPane = pane;
});
const startupRequestsBeforeSplit = (await gateway.getRequests("chat.startup")).length;
await gateway.deferNext("chat.startup");
await splitEntry.click();
await expect
.poll(async () => (await gateway.getRequests("chat.startup")).length)
.toBeGreaterThan(startupRequestsBeforeSplit);
// Each pane owns the same in-flow header in classic and split layouts.
const panes = page.locator("openclaw-chat-pane.chat-split-view__pane");
const headers = page.locator(".chat-pane__header");
await expect.poll(() => panes.count()).toBe(2);
await panes.last().getByText("Split toolbar proof.").waitFor();
await expect.poll(() => panes.last().locator(".chat-loading-skeleton").count()).toBe(0);
await gateway.resolveDeferred("chat.startup");
await expect
.poll(() =>
panes.first().evaluate(
(pane) =>
(
globalThis as typeof globalThis & {
classicChatPane?: Element;
}
).classicChatPane === pane,
),
)
.toBe(true);
await expect.poll(() => headers.count()).toBe(2);
await expect
.poll(async () => {
const visible = await Promise.all((await headers.all()).map((pane) => pane.isVisible()));
return visible.every(Boolean);
})
.toBe(true);
await expect.poll(() => splitEntry.count()).toBe(0);
// The pane header hosts the session workspace toggle (the old collapsed
// rail strip is gone).
await expect.poll(() => headers.first().locator(".chat-workspace-toggle").count()).toBe(1);
await expect.poll(() => page.locator(".chat-workspace-rail").count()).toBe(0);
// Keyboard focus on a header action marks the pane active.
await headers.first().getByRole("button", { name: "Split down" }).focus();
const cells = page.locator(".chat-split-view__cell");
await expect.poll(() => cells.first().getAttribute("class")).toContain("--active");
const lastPane = page.locator(".chat-split-view__pane").last();
await lastPane.click({ position: { x: 20, y: 80 } });
await expect.poll(() => cells.last().getAttribute("class")).toContain("--active");
const targetHeader = headers.first();
await expect
.poll(() =>
targetHeader.evaluate((header) => {
const owner = header.closest("openclaw-chat-pane");
return (
owner === header.parentElement && owner?.classList.contains("chat-split-view__pane")
);
}),
)
.toBe(true);
const dataTransfer = await page.evaluateHandle(() => new DataTransfer());
await dataTransfer.evaluate(
(transfer, data) => {
transfer.setData(data.mime, data.sessionKey);
},
{ mime: SESSION_DRAG_MIME, sessionKey: "agent:main:session-b" },
);
const unrelatedTarget = page.locator(".chat-split-view");
const unrelatedDrag = {
bubbles: true,
clientX: 0,
clientY: 0,
dataTransfer,
};
await unrelatedTarget.dispatchEvent("dragenter", unrelatedDrag);
await unrelatedTarget.dispatchEvent("dragover", unrelatedDrag);
await expect.poll(() => page.locator(".chat-split-view__drop-indicator").count()).toBe(0);
await unrelatedTarget.dispatchEvent("drop", unrelatedDrag);
await expect.poll(() => panes.count()).toBe(2);
await expect
.poll(() => new URL(page.url()).pathname)
.toBe(controlUiSessionPath("agent:main:session-a"));
// Start with no retained pane preview and target the visible header.
const targetBox = await targetHeader.boundingBox();
if (!targetBox) {
throw new Error("expected the pane header to have a layout box");
}
const directHeaderDrag = {
bubbles: true,
clientX: targetBox.x + targetBox.width / 2,
clientY: targetBox.y + targetBox.height / 2,
dataTransfer,
};
await targetHeader.dispatchEvent("dragenter", directHeaderDrag);
await targetHeader.dispatchEvent("dragover", directHeaderDrag);
await expect.poll(() => page.locator(".chat-split-view__drop-indicator").count()).toBe(1);
await targetHeader.dispatchEvent("drop", directHeaderDrag);
await dataTransfer.dispose();
await expect.poll(() => panes.count()).toBe(3);
await expect
.poll(async () =>
(await page.locator(".chat-pane__session-title").allTextContents()).map((title) =>
title.trim(),
),
)
.toContain("Session B");
await expect
.poll(() => new URL(page.url()).pathname)
.toBe(controlUiSessionPath("agent:main:session-b"));
} finally {
await suite.closeBrowserContext(context);
}
});
it("opens current context and latest-run usage from the composer ring", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
await installMockGateway(page, {
historyMessages: [
{ role: "user", content: "Show current usage", timestamp: Date.now() - 1_000 },
{
role: "assistant",
content: "Usage ready.",
cost: {
input: 0.003456,
output: 0.018,
cacheRead: 0.0015,
cacheWrite: 0.0005,
total: 0.023456,
},
model: "gpt-5.5",
provider: "openai",
timestamp: Date.now(),
},
],
methodResponses: {
"sessions.list": {
count: 1,
defaults: {
contextTokens: 200_000,
model: "gpt-5.5",
modelProvider: "openai",
},
path: "",
sessions: [
{
contextTokens: 200_000,
estimatedCostUsd: 0.023456,
inputTokens: 757_300,
key: "main",
kind: "direct",
model: "gpt-5.5",
modelProvider: "openai",
outputTokens: 42_300,
totalTokens: 46_000,
updatedAt: Date.now(),
},
],
ts: Date.now(),
},
},
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const trigger = page.locator("summary.context-ring");
await trigger.waitFor({ timeout: 10_000 });
await trigger.click();
const popover = page.locator(".context-usage__popover");
await expect.poll(() => popover.isVisible()).toBe(true);
await expect.poll(() => popover.textContent()).toContain("46k / 200k · 23%");
await expect.poll(() => popover.textContent()).toContain("757.3k");
await expect.poll(() => popover.textContent()).toContain("42.3k");
await expect.poll(() => popover.textContent()).toContain("Est. cost");
await expect.poll(() => popover.textContent()).toContain("$0.023");
await expect.poll(() => popover.textContent()).toContain("Cost by Type");
await expect.poll(() => popover.textContent()).toContain("$0.0035");
await expect.poll(() => popover.textContent()).toContain("$0.018");
await expect.poll(() => popover.textContent()).toContain("$0.0015");
await expect.poll(() => popover.textContent()).toContain("$0.0005");
await expect.poll(() => popover.textContent()).toContain("openai");
await expect.poll(() => popover.textContent()).toContain("gpt-5.5");
await page.keyboard.press("Escape");
await expect.poll(() => popover.isHidden()).toBe(true);
} finally {
await suite.closeBrowserContext(context);
}
});
it("routes page typing to the active composer without stealing text input focus", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
await installMockGateway(page, {
historyMessages: [
{
content: [{ text: "Type whenever you are ready.", type: "text" }],
role: "assistant",
timestamp: Date.now(),
},
],
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
await page.getByText("Type whenever you are ready.").click();
const composer = page.locator(".agent-chat__composer-combobox textarea");
await expect
.poll(() => composer.evaluate((element) => element === document.activeElement))
.toBe(false);
await page.keyboard.type("first character preserved");
expect(await composer.inputValue()).toBe("first character preserved");
await expect
.poll(() => composer.evaluate((element) => element === document.activeElement))
.toBe(true);
await page.getByRole("button", { name: "Open command palette" }).click();
const paletteInput = page.locator(".cmd-palette__input");
await paletteInput.waitFor({ state: "visible", timeout: 10_000 });
await expect
.poll(() => paletteInput.evaluate((element) => element === document.activeElement))
.toBe(true);
await page.keyboard.type("session search");
expect(await paletteInput.inputValue()).toBe("session search");
expect(await composer.inputValue()).toBe("first character preserved");
} finally {
await suite.closeBrowserContext(context);
}
});
it("keeps stale context visible as approximate without warning or compaction", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
await installMockGateway(page, {
methodResponses: {
"sessions.list": {
count: 1,
defaults: { contextTokens: 200_000, model: "gpt-5.5", modelProvider: "openai" },
path: "",
sessions: [
{
contextTokens: 200_000,
key: "main",
kind: "direct",
totalTokens: 190_000,
totalTokensFresh: false,
updatedAt: Date.now(),
},
],
ts: Date.now(),
},
},
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const trigger = page.locator("summary.context-ring");
await trigger.waitFor({ timeout: 10_000 });
expect((await trigger.textContent())?.trim()).toBe("~95%");
expect(await trigger.getAttribute("aria-label")).toBe(
"Thread context usage: ~190k of 200k (~95%)",
);
expect(
await trigger.evaluate((element) => element.classList.contains("context-ring--warning")),
).toBe(false);
await trigger.click();
await expect
.poll(() => page.locator(".context-usage__popover").textContent())
.toContain("~190k / 200k · ~95%");
expect(await page.locator(".context-ring__action").count()).toBe(0);
} finally {
await suite.closeBrowserContext(context);
}
});
it("keeps chat usable while sessions are still loading", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
deferredMethods: ["sessions.list"],
historyMessages: [
{
content: [{ text: "History renders before sessions finish.", type: "text" }],
role: "assistant",
timestamp: Date.now(),
},
],
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
await page.getByText("History renders before sessions finish.").waitFor({ timeout: 10_000 });
const composer = page.locator(".agent-chat__composer-combobox textarea");
await composer.waitFor({ state: "visible", timeout: 10_000 });
// The chat boot hydrates the sidebar session list; that request stays
// deferred here while the composer must remain fully usable.
await gateway.waitForRequest("sessions.list");
await composer.fill("draft while sessions load");
expect(await composer.inputValue()).toBe("draft while sessions load");
await composer.fill("");
// The background hydrate must not take the shared sessions loading
// flag, which would disable New thread for the whole request.
const newThread = page.getByRole("button", { name: "New thread" }).first();
expect(await newThread.isEnabled()).toBe(true);
await gateway.resolveDeferred("sessions.list");
await expect.poll(() => newThread.isEnabled()).toBe(true);
} finally {
await suite.closeBrowserContext(context);
}
});
it("keeps every sidebar session stable while selecting sessions and supports sort modes", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const createdSessionKeys = Array.from(
{ length: 11 },
(_, index) => `agent:main:session-${String.fromCharCode(97 + index)}`,
);
const pinnedSessionKey = "agent:main:session-pinned";
const createdOrder = [pinnedSessionKey, ...createdSessionKeys];
const updatedOrder = [pinnedSessionKey, ...createdSessionKeys.toReversed()];
const sessions = {
count: createdSessionKeys.length + 1,
defaults: {
contextTokens: null,
model: "gpt-5.5",
modelProvider: "openai",
},
path: "",
sessions: [
{
key: pinnedSessionKey,
kind: "direct",
label: "Pinned Session",
pinned: true,
pinnedAt: 1,
updatedAt: 50,
},
...createdSessionKeys.map((key, index) => ({
key,
kind: "direct",
label: `Session ${key.slice(-1).toUpperCase()}`,
updatedAt: (index + 1) * 100,
})),
],
ts: Date.now(),
};
await installMockGateway(page, {
methodResponses: { "sessions.list": sessions },
sessionKey: "agent:main:session-a",
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
await page
.locator('.sidebar-recent-session[data-session-key="agent:main:session-a"]')
.waitFor({
timeout: 10_000,
});
await expect.poll(() => sidebarSessionOrder(page)).toEqual(createdOrder.slice(0, 11));
await page.getByRole("button", { name: "Show more" }).click();
await expect.poll(() => sidebarSessionOrder(page)).toEqual(createdOrder);
await page
.locator(
'.sidebar-recent-session[data-session-key="agent:main:session-b"] a.sidebar-recent-session__link',
)
.click();
await page.locator(".sidebar-recent-session--active").getByText("Session B").waitFor({
timeout: 10_000,
});
await expect.poll(() => sidebarSessionOrder(page)).toEqual(createdOrder);
const activeWeight = await page
.locator('.sidebar-recent-session[data-session-key="agent:main:session-b"]')
.locator(".sidebar-recent-session__name")
.evaluate((label) => getComputedStyle(label).fontWeight);
const inactiveWeight = await page
.locator('.sidebar-recent-session[data-session-key="agent:main:session-a"]')
.locator(".sidebar-recent-session__name")
.evaluate((label) => getComputedStyle(label).fontWeight);
expect(activeWeight).toBe(inactiveWeight);
const sortThreads = page.getByRole("button", { name: "Sort threads" });
await sortThreads.locator("..").hover();
await sortThreads.click();
await page.getByRole("menuitemradio", { name: "Last updated" }).click();
await expect.poll(() => sidebarSessionOrder(page)).toEqual(updatedOrder);
await sortThreads.locator("..").hover();
await sortThreads.click();
await page.getByRole("menuitemradio", { name: "Created" }).click();
await expect.poll(() => sidebarSessionOrder(page)).toEqual(createdOrder);
await sortThreads.locator("..").hover();
await sortThreads.click();
await page.getByRole("main").click();
await expect.poll(() => page.getByRole("menuitemradio", { name: "Created" }).count()).toBe(0);
} finally {
await suite.closeBrowserContext(context);
}
});
it("keeps derived sidebar titles and accessible state after session patch refreshes", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const initialKey = "agent:main:session-a";
const key = "agent:main:session-b";
const readableTitle = "Readable planning title";
const baseTime = Date.now();
const sessionsWithDerivedTitle = chatSessionListResponse([
{
key: initialKey,
kind: "direct",
label: initialKey,
displayName: initialKey,
derivedTitle: "Initial readable title",
updatedAt: baseTime,
},
{
key,
kind: "direct",
label: key,
displayName: key,
derivedTitle: readableTitle,
updatedAt: baseTime - 60_000,
},
]);
const sessionsWithoutDerivedTitle = chatSessionListResponse([
{
key: initialKey,
kind: "direct",
label: initialKey,
displayName: initialKey,
updatedAt: baseTime,
},
{
key,
kind: "direct",
label: key,
displayName: key,
updatedAt: baseTime - 60_000,
},
]);
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.list": {
cases: [
{ match: { includeDerivedTitles: true }, response: sessionsWithDerivedTitle },
{ match: {}, response: sessionsWithoutDerivedTitle },
],
},
},
sessionKey: initialKey,
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const row = page.locator(`.sidebar-recent-session[data-session-key="${key}"]`);
await row.locator("a.sidebar-recent-session__link").click();
await expect
.poll(async () => {
const requests = await gateway.getRequests("sessions.list");
return requests.map((request) => request.params);
})
.toContainEqual(expect.objectContaining({ includeDerivedTitles: true }));
const label = row.locator(".sidebar-recent-session__name");
const link = row.locator("a.sidebar-recent-session__link");
await expect.poll(() => label.textContent()).toBe(readableTitle);
expect(await row.getAttribute("role")).toBe("listitem");
expect(await row.getAttribute("aria-label")).toBeNull();
expect(await link.getAttribute("aria-label")).toBeNull();
expect(await link.getAttribute("aria-current")).toBe("page");
expect(await link.getAttribute("aria-describedby")).toBeNull();
expect(await link.ariaSnapshot()).toContain(`link "${readableTitle}"`);
await captureSessionAccessibilityProof(page, "after-derived-title");
const listCountBeforePatch = (await gateway.getRequests("sessions.list")).length;
await row.hover();
await row.getByRole("button", { name: "Pin thread" }).click();
const patchRequest = await gateway.waitForRequest("sessions.patch");
expect(requireRecord(patchRequest.params)).toMatchObject({
key,
pinned: true,
});
await expect
.poll(async () => {
const requests = await gateway.getRequests("sessions.list");
return requests.slice(listCountBeforePatch).map((request) => request.params);
})
.toContainEqual(expect.objectContaining({ includeDerivedTitles: true }));
await expect.poll(() => label.textContent()).toBe(readableTitle);
expect(await link.getAttribute("aria-current")).toBe("page");
expect(await link.ariaSnapshot()).toContain(`link "${readableTitle}"`);
await captureSessionAccessibilityProof(page, "after-patch-refresh");
} finally {
await suite.closeBrowserContext(context);
}
});
it("keeps long sidebar labels clipped after a session switch", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
await page.clock.install();
const sessions = chatSessionListResponse();
const firstSession = expectDefined(sessions.sessions[0], "first chat session fixture");
const secondSession = expectDefined(sessions.sessions[1], "second chat session fixture");
firstSession.label = "Short";
secondSession.label =
"Review and repair the intentionally overlong sidebar session title before navigation ".repeat(
4,
);
await installMockGateway(page, {
methodResponses: { "sessions.list": sessions },
sessionKey: "agent:main:session-a",
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const recentRow = page.locator(
'.sidebar-recent-session[data-session-key="agent:main:session-b"]',
);
const recentLabel = recentRow.locator(".sidebar-recent-session__name");
await recentLabel.waitFor({ state: "visible", timeout: 10_000 });
const layout = await recentLabel.evaluate((label) => ({
clientWidth: label.clientWidth,
linkWidth: label.parentElement?.clientWidth ?? 0,
rowWidth: label.closest<HTMLElement>(".sidebar-recent-session")?.clientWidth ?? 0,
scrollWidth: label.scrollWidth,
text: label.textContent,
}));
expect(layout.scrollWidth, JSON.stringify(layout)).toBeGreaterThan(layout.clientWidth);
await recentRow.dispatchEvent("mouseenter");
await page.clock.runFor(250);
expect(await recentLabel.evaluate((label) => label.classList.value)).not.toContain(
"hover-marquee--scrolling",
);
await recentRow.dispatchEvent("mouseleave");
await page.clock.runFor(300);
expect(await recentLabel.evaluate((label) => label.classList.value)).not.toContain(
"hover-marquee--scrolling",
);
await recentRow.dispatchEvent("mouseenter");
await expect
.poll(() => recentLabel.evaluate((label) => label.classList.value), { timeout: 1_500 })
.toContain("hover-marquee--scrolling");
await recentRow.dispatchEvent("mouseleave");
await expect
.poll(
() =>
recentLabel.evaluate((label) => ({
textIndent: getComputedStyle(label).textIndent,
textOverflow: getComputedStyle(label).textOverflow,
})),
{ timeout: 1_500 },
)
.toEqual({ textIndent: "0px", textOverflow: "ellipsis" });
await recentRow.locator("a.sidebar-recent-session__link").dispatchEvent("click", {
button: 0,
});
await page.locator(".sidebar-recent-session--active").getByText(secondSession.label).waitFor({
timeout: 10_000,
});
const activeRow = page.locator(
'.sidebar-recent-session[data-session-key="agent:main:session-b"]',
);
expect(
await activeRow.locator(".sidebar-recent-session__name").evaluate((label) => ({
textIndent: getComputedStyle(label).textIndent,
textOverflow: getComputedStyle(label).textOverflow,
})),
).toEqual({ textIndent: "0px", textOverflow: "ellipsis" });
} finally {
await suite.closeBrowserContext(context);
}
});
it("renders the visible authenticated assistant avatar after switching sessions", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const avatarBody = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2nPcAAAAASUVORK5CYII=",
"base64",
);
let avatarRequestCount = 0;
await page.route(/\/avatar\/main\?meta=1$/, (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ avatarUrl: "/avatar/main", avatarStatus: "local" }),
}),
);
await page.route(/\/avatar\/main$/, (route) => {
avatarRequestCount += 1;
return route.fulfill({ contentType: "image/png", body: avatarBody });
});
await installMockGateway(page, {
methodResponses: { "sessions.list": chatSessionListResponse() },
sessionKey: "agent:main:session-a",
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const documentMarker = await page.evaluate(() => {
const marker = crypto.randomUUID();
(window as Window & { __openclawAvatarTestDocument?: string })[
"__openclawAvatarTestDocument"
] = marker;
return marker;
});
const avatar = page.locator("img.agent-chat__welcome-avatar");
await avatar.waitFor({ state: "visible" });
await expect.poll(() => avatar.getAttribute("src")).toMatch(/^blob:/);
const initialAvatarSrc = await avatar.getAttribute("src");
const initialRequestCount = avatarRequestCount;
const sessionRow = (sessionKey: string) =>
page.locator(`.sidebar-recent-session[data-session-key="${sessionKey}"]`);
const sessionB = sessionRow("agent:main:session-b");
await sessionB.locator("a.sidebar-recent-session__link").click();
await expect
.poll(() => sessionB.getAttribute("class"))
.toContain("sidebar-recent-session--active");
await expect.poll(() => avatarRequestCount).toBeGreaterThan(initialRequestCount);
await expect.poll(() => avatar.getAttribute("src")).not.toBe(initialAvatarSrc);
await expect.poll(() => avatar.getAttribute("src")).toMatch(/^blob:/);
await expect.poll(() => avatar.isVisible()).toBe(true);
const sessionBAvatarSrc = await avatar.getAttribute("src");
const sessionBRequestCount = avatarRequestCount;
const sessionA = sessionRow("agent:main:session-a");
await sessionA.locator("a.sidebar-recent-session__link").click();
await expect
.poll(() => sessionA.getAttribute("class"))
.toContain("sidebar-recent-session--active");
await expect.poll(() => avatarRequestCount).toBeGreaterThan(sessionBRequestCount);
await expect.poll(() => avatar.getAttribute("src")).not.toBe(sessionBAvatarSrc);
await expect.poll(() => avatar.getAttribute("src")).toMatch(/^blob:/);
await expect.poll(() => avatar.isVisible()).toBe(true);
expect(
await page.evaluate(
() =>
(window as Window & { __openclawAvatarTestDocument?: string })[
"__openclawAvatarTestDocument"
],
),
).toBe(documentMarker);
} finally {
await suite.closeBrowserContext(context);
}
});
});
+623
View File
@@ -0,0 +1,623 @@
import { expect, it } from "vitest";
import {
chatThreadDistanceFromBottom,
createChatFlowE2eSuite,
installMockGateway,
requireRecord,
requireString,
scrollChatThreadToTop,
waitForChatScrollIdle,
waitForRequests,
} from "./chat-flow.test-support.ts";
const suite = createChatFlowE2eSuite();
suite.define(() => {
it("renders stable markdown during a streaming chat turn and finalizes the tail", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page);
try {
await page.goto(`${suite.server.baseUrl}chat`);
const prompt = "stream markdown through the GUI";
await gateway.deferNext("chat.send");
await page.locator(".agent-chat__composer-combobox textarea").fill(prompt);
await page.getByRole("button", { name: "Send message" }).click();
const sendRequest = await gateway.waitForRequest("chat.send");
const params = requireRecord(sendRequest.params);
const runId = requireString(params.idempotencyKey, "chat send idempotency key");
const streamingText = "## Streaming heading\n\nworking **tail";
await gateway.emitGatewayEvent("chat", {
deltaText: streamingText,
message: {
content: [{ text: streamingText, type: "text" }],
role: "assistant",
timestamp: Date.now(),
},
runId,
sessionKey: "main",
state: "delta",
});
await page.locator(".chat-thread h2").getByText("Streaming heading").waitFor({
timeout: 10_000,
});
await page.locator(".chat-bubble.streaming strong").getByText("tail").waitFor({
timeout: 10_000,
});
expect(await page.locator(".markdown-plain-text-fallback").count()).toBe(0);
await gateway.resolveDeferred("chat.send", { runId, status: "started" });
await page.locator(".chat-thread h2").getByText("Streaming heading").waitFor({
timeout: 10_000,
});
await gateway.emitChatFinal({
runId,
text: "## Streaming heading\n\nworking **tail**",
});
await page.locator(".chat-thread strong").getByText("tail").waitFor({ timeout: 10_000 });
expect(await page.locator(".markdown-plain-text-fallback").count()).toBe(0);
} finally {
await suite.closeBrowserContext(context);
}
});
it("normalizes Unicode line separators in streaming and final chat DOM", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page);
try {
await page.goto(`${suite.server.baseUrl}chat`);
await gateway.deferNext("chat.send");
await page
.locator(".agent-chat__composer-combobox textarea")
.fill("render Unicode separators");
await page.getByRole("button", { name: "Send message" }).click();
const sendRequest = await gateway.waitForRequest("chat.send");
const params = requireRecord(sendRequest.params);
const runId = requireString(params.idempotencyKey, "chat send idempotency key");
const streamingText = "## Unicode stream\u2028\u2028working **tail";
await gateway.emitGatewayEvent("chat", {
deltaText: streamingText,
message: {
content: [{ text: streamingText, type: "text" }],
role: "assistant",
timestamp: Date.now(),
},
runId,
sessionKey: "main",
state: "delta",
});
await page.locator(".chat-thread h2").getByText("Unicode stream").waitFor({
timeout: 10_000,
});
await page.locator(".chat-bubble.streaming strong").getByText("tail").waitFor({
timeout: 10_000,
});
expect(await page.locator(".markdown-plain-text-fallback").count()).toBe(0);
await gateway.resolveDeferred("chat.send", { runId, status: "started" });
await gateway.emitChatFinal({
runId,
text: "## Unicode final\u2028\u2028- first\u2029- second",
});
await page.locator(".chat-thread h2").getByText("Unicode final").waitFor({
timeout: 10_000,
});
await expect
.poll(() => page.locator(".chat-thread li").allTextContents(), { timeout: 10_000 })
.toEqual(["first", "second"]);
const finalChatText = await page.locator(".chat-thread .chat-text").last().textContent();
expect(finalChatText).not.toContain("\u2028");
expect(finalChatText).not.toContain("\u2029");
} finally {
await suite.closeBrowserContext(context);
}
});
it.each([
{ label: "desktop", viewport: { height: 900, width: 1280 } },
{ label: "mobile", viewport: { height: 844, width: 390 } },
])(
"keeps streamed text visible when a chat error terminates the turn on $label",
async ({ viewport }) => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport,
});
const page = await context.newPage();
const gateway = await installMockGateway(page);
try {
await page.goto(`${suite.server.baseUrl}chat`);
const prompt = "stream before terminal error";
await page.locator(".agent-chat__composer-combobox textarea").fill(prompt);
await page.getByRole("button", { name: "Send message" }).click();
const sendRequest = await gateway.waitForRequest("chat.send");
const params = requireRecord(sendRequest.params);
const runId = requireString(params.idempotencyKey, "chat send idempotency key");
const partialText = "Partial answer before gateway error.";
await gateway.emitGatewayEvent("chat", {
deltaText: partialText,
message: {
content: [{ text: partialText, type: "text" }],
role: "assistant",
timestamp: Date.now(),
},
runId,
sessionKey: "main",
state: "delta",
});
await page
.locator(".chat-thread-inner")
.getByText(partialText)
.waitFor({ timeout: 10_000 });
const gatewayErrorText =
"⚠️ Model login expired on the gateway for openai. Send `/login codex` from a private chat or Web UI session to pair a new Codex login, or re-auth with `openclaw models auth login --provider openai` in a terminal, then try again.";
const errorText = gatewayErrorText.replace(/^\s*/u, "");
await gateway.emitGatewayEvent("chat", {
errorMessage: gatewayErrorText,
message: {
content: [{ text: gatewayErrorText, type: "text" }],
role: "assistant",
timestamp: Date.now(),
},
runId,
sessionKey: "main",
state: "error",
});
await page
.locator(".chat-thread-inner")
.getByText(partialText)
.waitFor({ timeout: 10_000 });
const alert = page.locator(".chat-run-error");
await alert.getByText(errorText).waitFor({ timeout: 10_000 });
expect(await alert.locator("button").count()).toBe(0);
expect(await page.locator(".chat-thread-inner").getByText(errorText).count()).toBe(0);
expect(
await alert.evaluate((element) =>
element.nextElementSibling?.classList.contains("agent-chat__composer-shell"),
),
).toBe(true);
const [alertBox, composerBox] = await Promise.all([
alert.boundingBox(),
page.locator(".agent-chat__composer-shell").boundingBox(),
]);
expect(alertBox).not.toBeNull();
expect(composerBox).not.toBeNull();
expect(Math.abs((alertBox?.x ?? 0) - (composerBox?.x ?? 0))).toBeLessThan(1);
expect(Math.abs((alertBox?.width ?? 0) - (composerBox?.width ?? 0))).toBeLessThan(1);
await page.locator(".agent-chat__composer-combobox textarea").fill("retry after error");
await page.getByRole("button", { name: "Send message" }).click();
await waitForRequests(gateway, "chat.send", 2);
await alert.waitFor({ state: "detached", timeout: 10_000 });
} finally {
await suite.closeBrowserContext(context);
}
},
);
it("keeps the pending working row stable through acknowledgement and streaming", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page);
try {
await page.goto(`${suite.server.baseUrl}chat`);
await gateway.deferNext("chat.send");
const prompt = "hold this until the ack arrives";
await page.locator(".agent-chat__composer-combobox textarea").fill(prompt);
await page.getByRole("button", { name: "Send message" }).click();
const sendRequest = await gateway.waitForRequest("chat.send");
await expect
.poll(() => page.locator(".agent-chat__composer-combobox textarea").inputValue(), {
timeout: 10_000,
})
.toBe("");
const params = requireRecord(sendRequest.params);
const runId = requireString(params.idempotencyKey, "chat send idempotency key");
await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 });
const indicator = page.locator(".chat-reading-indicator");
await indicator.waitFor({ timeout: 10_000 });
expect(await page.locator(".chat-queue").count()).toBe(0);
await page.locator(".chat-working-indicator").evaluate(async (element) => {
await Promise.all(element.getAnimations().map((animation) => animation.finished));
});
const pendingRow = await indicator
.locator(
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' chat-virtual-row ')][1]",
)
.elementHandle();
if (!pendingRow) {
throw new Error("expected pending working indicator virtual row");
}
const pendingLayout = await pendingRow.evaluate((row) => {
const rect = row.getBoundingClientRect();
Reflect.set(window, "__openclawPendingWorkingRow", row);
return {
height: rect.height,
key: row.getAttribute("data-virtual-row-key"),
top: rect.top,
};
});
expect(pendingLayout.key).not.toBeNull();
await page.evaluate(() => {
const samples: Array<{
height: number | null;
key: string | null;
sameRow: boolean;
top: number | null;
}> = [];
Reflect.set(window, "__openclawWorkingRowSamples", samples);
let remaining = 20;
const sample = () => {
const originalRow = Reflect.get(window, "__openclawPendingWorkingRow");
const currentRow = document
.querySelector(".chat-reading-indicator")
?.closest<HTMLElement>(".chat-virtual-row");
const rect = currentRow?.getBoundingClientRect();
samples.push({
height: rect?.height ?? null,
key: currentRow?.getAttribute("data-virtual-row-key") ?? null,
sameRow: currentRow === originalRow,
top: rect?.top ?? null,
});
remaining -= 1;
if (remaining > 0) {
requestAnimationFrame(sample);
}
};
sample();
});
await gateway.resolveDeferred("chat.send", { runId, status: "started" });
await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 });
await indicator.waitFor({ timeout: 10_000 });
const samples = await page.evaluate(
() =>
new Promise<
Array<{
height: number | null;
key: string | null;
sameRow: boolean;
top: number | null;
}>
>((resolve) => {
const read = () => {
const current = Reflect.get(window, "__openclawWorkingRowSamples");
if (Array.isArray(current) && current.length >= 20) {
resolve(current);
return;
}
requestAnimationFrame(read);
};
read();
}),
);
const layouts = samples.filter(
(sample): sample is { height: number; key: string; sameRow: true; top: number } =>
sample.sameRow &&
typeof sample.height === "number" &&
typeof sample.key === "string" &&
typeof sample.top === "number",
);
expect(layouts).toHaveLength(20);
expect(new Set(layouts.map((sample) => sample.key))).toEqual(new Set([pendingLayout.key]));
const tops = layouts.map((sample) => sample.top);
const heights = layouts.map((sample) => sample.height);
expect(Math.max(...tops) - Math.min(...tops)).toBeLessThan(1);
expect(Math.max(...heights) - Math.min(...heights)).toBeLessThan(1);
const response = "The streamed response is now visible.";
await gateway.emitGatewayEvent("chat", {
deltaText: response,
message: {
content: [{ text: response, type: "text" }],
role: "assistant",
timestamp: Date.now(),
},
runId,
sessionKey: "main",
state: "delta",
});
await page.getByText(response).waitFor({ timeout: 10_000 });
await page.locator(".chat-reading-indicator").waitFor({ state: "detached", timeout: 10_000 });
const streamingLayout = await pendingRow.evaluate(
(row, visibleResponse) => ({
connected: row.isConnected,
hasResponse: row.textContent?.includes(visibleResponse) ?? false,
key: row.getAttribute("data-virtual-row-key"),
}),
response,
);
expect(streamingLayout).toEqual({
connected: true,
hasResponse: true,
key: pendingLayout.key,
});
} finally {
await suite.closeBrowserContext(context);
}
});
it("scrolls a delayed pending send into view before the ACK resolves", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const baseTs = Date.now() - 100_000;
const historyMessages = Array.from({ length: 50 }, (_, index) => ({
content: [
{
text: `History message ${index}\n${"extra transcript line\n".repeat(4)}`,
type: "text",
},
],
role: index % 2 === 0 ? "assistant" : "user",
timestamp: baseTs + index,
}));
const gateway = await installMockGateway(page, { historyMessages });
try {
await page.goto(`${suite.server.baseUrl}chat`);
await page.getByText("History message 49").waitFor({ timeout: 10_000 });
await expect
.poll(() => chatThreadDistanceFromBottom(page), { timeout: 10_000 })
.toBeLessThanOrEqual(4);
await waitForChatScrollIdle(page);
await expect
.poll(
async () => {
await scrollChatThreadToTop(page);
return chatThreadDistanceFromBottom(page);
},
{ timeout: 10_000 },
)
.toBeGreaterThan(200);
await gateway.deferNext("chat.send");
const prompt = `pending send should scroll before ack\n${"visible now\n".repeat(6)}`;
await page.locator(".agent-chat__composer-combobox textarea").fill(prompt);
await page.getByRole("button", { name: "Send message" }).click();
const sendRequest = await gateway.waitForRequest("chat.send");
const params = requireRecord(sendRequest.params);
const runId = requireString(params.idempotencyKey, "chat send idempotency key");
await page.locator(".chat-thread").getByText("pending send should scroll").waitFor({
timeout: 10_000,
});
await expect
.poll(() => chatThreadDistanceFromBottom(page), { timeout: 10_000 })
.toBeLessThanOrEqual(4);
await gateway.resolveDeferred("chat.send", { runId, status: "started" });
} finally {
await suite.closeBrowserContext(context);
}
});
it("overlays the scroll-to-bottom affordance without shrinking the transcript", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const baseTs = Date.now() - 100_000;
const historyMessages = Array.from({ length: 50 }, (_, index) => ({
content: [
{
text: `Scrollable history ${index}\n${"extra transcript line\n".repeat(4)}`,
type: "text",
},
],
role: index % 2 === 0 ? "assistant" : "user",
timestamp: baseTs + index,
}));
await installMockGateway(page, { historyMessages });
try {
await page.goto(`${suite.server.baseUrl}chat`);
await page.getByText("Scrollable history 49").waitFor({ timeout: 10_000 });
await waitForChatScrollIdle(page);
const readLayout = () =>
page.locator(".chat-main").evaluate((container) => {
const thread = container.querySelector<HTMLElement>(".chat-thread");
const composer = container.querySelector<HTMLElement>(".agent-chat__composer-shell");
const button = container.querySelector<HTMLElement>(".chat-scroll-to-bottom");
if (!thread || !composer) {
throw new Error("expected chat thread and composer");
}
const threadRect = thread.getBoundingClientRect();
const composerRect = composer.getBoundingClientRect();
const buttonRect = button?.getBoundingClientRect();
return {
buttonBottom: buttonRect ? Math.round(buttonRect.bottom) : null,
composerTop: Math.round(composerRect.top),
threadBottom: Math.round(threadRect.bottom),
};
});
const before = await readLayout();
expect(before.buttonBottom).toBeNull();
await scrollChatThreadToTop(page);
await page.getByRole("button", { name: "Scroll to latest" }).waitFor({ timeout: 10_000 });
const after = await readLayout();
expect(after.threadBottom).toBe(before.threadBottom);
expect(after.composerTop).toBe(before.composerTop);
expect(after.buttonBottom).not.toBeNull();
expect(after.buttonBottom!).toBeLessThan(after.composerTop);
} finally {
await suite.closeBrowserContext(context);
}
});
it("refreshes history after a tool-call window disconnects and reconnects", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page);
try {
await page.goto(`${suite.server.baseUrl}chat`);
const prompt = "use a tool then reconnect";
await page.locator(".agent-chat__composer-combobox textarea").fill(prompt);
await page.getByRole("button", { name: "Send message" }).click();
const sendRequest = await gateway.waitForRequest("chat.send");
const params = requireRecord(sendRequest.params);
const runId = requireString(params.idempotencyKey, "chat send idempotency key");
await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 });
await gateway.emitGatewayEvent("agent", {
data: {
args: { query: "status" },
name: "status",
phase: "start",
toolCallId: "tool-1",
},
runId,
seq: 1,
sessionKey: "main",
stream: "tool",
ts: Date.now(),
});
await gateway.setHistoryMessages([
{
__openclaw: { idempotencyKey: `${runId}:user` },
content: [{ text: prompt, type: "text" }],
role: "user",
timestamp: Date.now(),
},
{
content: [{ text: "Recovered from refreshed history.", type: "text" }],
role: "assistant",
timestamp: Date.now(),
},
]);
await gateway.closeLatest(1006, "lost during tool call");
await page
.locator(".chat-thread-inner")
.getByText("Recovered from refreshed history.")
.waitFor({ timeout: 15_000 });
expect(await page.locator(".chat-queue").count()).toBe(0);
} finally {
await suite.closeBrowserContext(context);
}
});
it("keeps live assistant stream text before the matching tool card", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page);
try {
await page.goto(`${suite.server.baseUrl}chat`);
const prompt = "stream before tool";
await page.locator(".agent-chat__composer-combobox textarea").fill(prompt);
await page.getByRole("button", { name: "Send message" }).click();
const sendRequest = await gateway.waitForRequest("chat.send");
const params = requireRecord(sendRequest.params);
const runId = requireString(params.idempotencyKey, "chat send idempotency key");
await gateway.emitGatewayEvent("chat", {
deltaText: "I will inspect the file.",
message: {
content: [{ text: "I will inspect the file.", type: "text" }],
role: "assistant",
timestamp: Date.now(),
},
runId,
sessionKey: "main",
state: "delta",
});
await page.getByText("I will inspect the file.").waitFor({ timeout: 10_000 });
await gateway.emitGatewayEvent("agent", {
data: {
name: "read",
phase: "result",
result: "file contents",
toolCallId: "call-read",
},
runId,
seq: 1,
sessionKey: "main",
stream: "tool",
ts: Date.now() - 10_000,
});
const toolBubble = page.locator('[data-message-id^="tool:assistant:call-read"]');
await toolBubble.waitFor({ timeout: 10_000 });
const visibleOrder = await page.locator(".chat-thread").evaluate((thread: Element) => {
return Array.from(thread.querySelectorAll(".chat-group")).flatMap((group: Element) => {
const text = group.textContent ?? "";
if (text.includes("I will inspect the file.")) {
return ["assistant stream"];
}
if (group.querySelector('[data-message-id^="tool:assistant:call-read"]')) {
return ["tool card"];
}
return [];
});
});
expect(visibleOrder).toEqual(["assistant stream", "tool card"]);
} finally {
await suite.closeBrowserContext(context);
}
});
});
+233
View File
@@ -0,0 +1,233 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import type { Page } from "playwright";
import { expect } from "vitest";
import { SESSION_DRAG_MIME } from "../lib/sessions/drag.ts";
import {
controlUiSessionPath,
controlUiSessionUrl,
installMockGateway,
type MockGatewayRequest,
} from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
export {
expectDefined,
SESSION_DRAG_MIME,
controlUiSessionPath,
controlUiSessionUrl,
installMockGateway,
};
export const managedImageCacheProofDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"managed-image-cache",
);
export const channelStopProofDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"channel-stop",
);
export const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const sessionAccessibilityProofDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"session-accessibility",
);
export function createChatFlowE2eSuite() {
return createControlUiE2eSuite({
name: "Control UI mocked Gateway E2E",
trackBrowserContexts: true,
unavailableMessage: (executablePath) =>
`Playwright Chromium is not installed or cannot start at ${executablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`, set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH to a compatible browser, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`,
});
}
export function requireRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Expected object value");
}
return value as Record<string, unknown>;
}
export function requireString(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`Expected non-empty ${label}`);
}
return value;
}
export async function waitForRequests(
gateway: Awaited<ReturnType<typeof installMockGateway>>,
method: string,
count: number,
): Promise<MockGatewayRequest[]> {
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
const requests = await gateway.getRequests(method);
if (requests.length >= count) {
return requests;
}
await new Promise((resolve) => {
setTimeout(resolve, 10);
});
}
throw new Error(`Timed out waiting for ${count} ${method} requests`);
}
export async function expectRequestCountStable(
gateway: Awaited<ReturnType<typeof installMockGateway>>,
method: string,
count: number,
durationMs = 500,
): Promise<void> {
const deadline = Date.now() + durationMs;
do {
expect(await gateway.getRequests(method)).toHaveLength(count);
await new Promise((resolve) => {
setTimeout(resolve, 50);
});
} while (Date.now() < deadline);
}
export async function installPlainHttpClipboardCapture(page: Page): Promise<void> {
await page.addInitScript(() => {
Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined });
(globalThis as unknown as { copiedViaExec: string[] }).copiedViaExec = [];
document.execCommand = ((command: string) => {
if (command !== "copy") {
return false;
}
const active = document.activeElement as HTMLTextAreaElement | null;
(globalThis as unknown as { copiedViaExec: string[] }).copiedViaExec.push(
active?.value ?? "",
);
return true;
}) as typeof document.execCommand;
});
}
export async function copiedViaExec(page: Page): Promise<string[]> {
return page.evaluate(() => (globalThis as unknown as { copiedViaExec: string[] }).copiedViaExec);
}
export async function chatThreadDistanceFromBottom(page: Page): Promise<number> {
return page.locator(".chat-thread").evaluate((element) => {
const thread = element as HTMLElement;
return Math.round(thread.scrollHeight - thread.scrollTop - thread.clientHeight);
});
}
export async function waitForChatScrollIdle(page: Page): Promise<void> {
await expect
.poll(
() =>
page.locator(".chat-thread").evaluate(async (element) => {
const thread = element as HTMLElement;
const readGeometry = () => ({
clientHeight: thread.clientHeight,
scrollHeight: thread.scrollHeight,
scrollTop: Math.round(thread.scrollTop),
});
const before = readGeometry();
await new Promise<void>((resolve) => {
globalThis.setTimeout(resolve, 180);
});
await new Promise<void>((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve());
});
});
const after = readGeometry();
return (
before.clientHeight === after.clientHeight &&
before.scrollHeight === after.scrollHeight &&
before.scrollTop === after.scrollTop
);
}),
{ timeout: 10_000 },
)
.toBe(true);
}
export async function scrollChatThreadToTop(page: Page): Promise<void> {
await page.locator(".chat-thread").evaluate((element) => {
const thread = element as HTMLElement;
thread.scrollTop = 0;
thread.dispatchEvent(new Event("scroll", { bubbles: true }));
});
}
export async function captureSessionAccessibilityProof(page: Page, name: string): Promise<void> {
if (!captureUiProofEnabled) {
return;
}
await mkdir(sessionAccessibilityProofDir, { recursive: true });
const sidebar = page.locator("openclaw-app-sidebar");
await page.screenshot({
fullPage: true,
path: path.join(sessionAccessibilityProofDir, `${name}.png`),
});
await writeFile(
path.join(sessionAccessibilityProofDir, `${name}.yml`),
await sidebar.ariaSnapshot(),
"utf8",
);
}
export async function visibleChatBubbleTexts(page: Page): Promise<string[]> {
return page.locator(".chat-thread").evaluate((element) => {
const thread = element as HTMLElement;
const viewport = thread.getBoundingClientRect();
return Array.from(thread.querySelectorAll(".chat-bubble"))
.filter((candidate) => {
const rect = candidate.getBoundingClientRect();
return (
rect.height > 0 &&
rect.width > 0 &&
rect.bottom > viewport.top &&
rect.top < viewport.bottom
);
})
.map((candidate) => candidate.textContent?.trim() ?? "")
.filter(Boolean);
});
}
export function chatSessionListResponse(
sessions: Array<
Record<string, unknown> & {
key: string;
kind: string;
label: string;
updatedAt: number;
}
> = [
{ key: "agent:main:session-a", kind: "direct", label: "Session A", updatedAt: 2 },
{ key: "agent:main:session-b", kind: "direct", label: "Session B", updatedAt: 1 },
],
) {
return {
count: sessions.length,
defaults: { contextTokens: null, model: "gpt-5.5", modelProvider: "openai" },
path: "",
sessions,
ts: Date.now(),
};
}
export async function sidebarSessionOrder(page: Page): Promise<string[]> {
return page
.locator(".sidebar-recent-session")
.evaluateAll((rows) =>
rows
.map((row) => row.getAttribute("data-session-key") ?? "")
.filter((key) => key.startsWith("agent:main:session-")),
);
}
@@ -0,0 +1,93 @@
import { chromium, type Browser, type BrowserContext } from "playwright";
import { afterAll, afterEach, beforeAll, describe } from "vitest";
import {
canRunPlaywrightChromium,
resolvePlaywrightChromiumExecutablePath,
startControlUiE2eServer,
type ControlUiE2eServer,
} from "../test-helpers/control-ui-e2e.ts";
type ControlUiE2eSuiteOptions = {
name: string;
unavailableMessage: (executablePath: string) => string;
trackBrowserContexts?: boolean;
};
type ControlUiE2eSuite = {
readonly browser: Browser;
readonly server: ControlUiE2eServer;
closeBrowserContext: (context: BrowserContext) => Promise<void>;
define: (defineTests: () => void) => void;
newBrowserContext: (options: Parameters<Browser["newContext"]>[0]) => Promise<BrowserContext>;
};
export function createControlUiE2eSuite(options: ControlUiE2eSuiteOptions): ControlUiE2eSuite {
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e =
chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const openBrowserContexts = new Set<BrowserContext>();
let browser: Browser | undefined;
let server: ControlUiE2eServer | undefined;
const closeBrowserContext = async (context: BrowserContext): Promise<void> => {
openBrowserContexts.delete(context);
await context.close().catch(() => {});
};
const closeOpenBrowserContexts = async (): Promise<void> => {
await Promise.all([...openBrowserContexts].map((context) => closeBrowserContext(context)));
};
return {
get browser() {
if (!browser) {
throw new Error("Control UI E2E browser accessed before suite setup");
}
return browser;
},
get server() {
if (!server) {
throw new Error("Control UI E2E server accessed before suite setup");
}
return server;
},
closeBrowserContext,
define(defineTests) {
describeControlUiE2e(options.name, () => {
beforeAll(async () => {
if (!chromiumAvailable) {
throw new Error(options.unavailableMessage(chromiumExecutablePath));
}
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
try {
server = await startControlUiE2eServer();
} catch (error) {
await browser.close();
throw error;
}
});
afterAll(async () => {
await closeOpenBrowserContexts();
await browser?.close();
await server?.close();
});
if (options.trackBrowserContexts) {
afterEach(closeOpenBrowserContexts);
}
defineTests();
});
},
async newBrowserContext(contextOptions) {
if (!browser) {
throw new Error("Control UI E2E browser accessed before suite setup");
}
const context = await browser.newContext(contextOptions);
openBrowserContexts.add(context);
return context;
},
};
}
@@ -0,0 +1,427 @@
import { expect, it } from "vitest";
import {
REFRESHED_RESEARCH_WORKSPACE,
SESSION_LIST_DEFAULTS,
WORKSPACE,
controlUiSessionPath,
createNewSessionPageE2eSuite,
installMockGateway,
pollLocatorText,
} from "./new-session-page.test-support.ts";
const suite = createNewSessionPageE2eSuite();
suite.define(() => {
it("creates a catalog-targeted draft with its advertised model", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: {
"agents.list": {
agents: [
{
id: "main",
identity: { name: "Main" },
name: "Main",
workspace: WORKSPACE,
workspaceGit: true,
},
{
id: "research",
identity: { name: "Research" },
name: "Research",
workspace: "/home/peter/research",
workspaceGit: true,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
},
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
"sessions.catalog.list": {
catalogs: [
{
id: "claude",
label: "Claude Code",
capabilities: {
continueSession: true,
archive: false,
createSession: { model: "anthropic/claude-opus-4-8" },
},
hosts: [],
},
],
},
"sessions.create": { key: "agent:main:claude-draft" },
},
});
try {
const model = "anthropic/claude-opus-4-8";
await page.goto(
`${suite.server.baseUrl}new?agent=Research&catalog=claude&model=${encodeURIComponent("openai/gpt-5")}&label=Spoofed`,
);
const catalogRequest = await gateway.waitForRequest("sessions.catalog.list");
expect(catalogRequest.params).toMatchObject({
agentId: "research",
catalogId: "claude",
});
const runtime = page.locator(".new-session-page__runtime");
await pollLocatorText(runtime).toContain("Claude Code");
expect(await runtime.getAttribute("title")).toBe(model);
expect(await page.locator('.new-session-page__trigger[title="Agent"]').count()).toBe(0);
expect(await page.locator('[data-chat-model-select="true"]').count()).toBe(0);
await page.locator(".new-session-page__message").fill("use Claude Code");
await page.getByRole("button", { name: "Start thread" }).click();
const create = await gateway.waitForRequest("sessions.create");
expect(create.params).toMatchObject({
agentId: "research",
message: "use Claude Code",
catalogId: "claude",
});
expect(create.params).not.toHaveProperty("model");
} finally {
await context.close();
}
});
it("navigates to a created session while canonical session refresh is pending", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const sessionKey = "agent:main:refresh-overlap-e2e";
const listResponse = {
count: 0,
defaults: SESSION_LIST_DEFAULTS,
path: "",
sessions: [],
ts: Date.now(),
};
const gateway = await installMockGateway(page, {
workspaceGit: true,
methodResponses: {
"agents.list": {
agents: [
{
id: "main",
identity: { name: "Main" },
name: "Main",
workspace: WORKSPACE,
workspaceGit: true,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
},
"sessions.create": { key: sessionKey },
"sessions.list": listResponse,
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
const message = page.locator(".new-session-page__message");
await message.waitFor({ state: "visible", timeout: 10_000 });
const listCalls = (await gateway.getRequests("sessions.list")).length;
await gateway.deferNext("sessions.list");
await gateway.emitGatewayEvent("sessions.changed", {
key: "agent:main:other-client",
kind: "direct",
reason: "update",
sessionKey: "agent:main:other-client",
updatedAt: Date.now(),
});
await expect
.poll(async () => (await gateway.getRequests("sessions.list")).length)
.toBe(listCalls + 1);
await message.fill("create during refresh");
await page.getByRole("button", { name: "Start thread" }).click();
const create = await gateway.waitForRequest("sessions.create");
expect(create.params).toMatchObject({
agentId: "main",
message: "create during refresh",
});
await expect.poll(() => new URL(page.url()).pathname).toBe(controlUiSessionPath(sessionKey));
await gateway.resolveDeferred("sessions.list", listResponse);
} finally {
await context.close();
}
});
it("resolves a pending catalog target after reconnect without clearing the draft", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: {
"agents.list": {
agents: [
{
id: "main",
identity: { name: "Main" },
name: "Main",
workspace: WORKSPACE,
workspaceGit: true,
},
{
id: "research",
identity: { name: "Research" },
name: "Research",
workspace: "/home/peter/research",
workspaceGit: true,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
},
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
"sessions.catalog.list": {
catalogs: [
{
id: "claude",
label: "Claude Code",
capabilities: {
continueSession: true,
archive: false,
createSession: { model: "anthropic/claude-opus-4-8" },
},
hosts: [],
},
],
},
"sessions.create": { key: "agent:research:claude-reconnect" },
},
});
try {
await page.goto(`${suite.server.baseUrl}new?agent=research`);
await page.getByRole("heading", { name: "Research" }).waitFor();
await gateway.setOnline(false);
await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 });
await page.evaluate(() => {
history.pushState(null, "", "new?agent=research&catalog=claude");
dispatchEvent(new PopStateEvent("popstate"));
});
const message = page.locator(".new-session-page__message");
await message.fill("keep this reconnect draft");
await pollLocatorText(page.locator(".new-session-page__runtime")).toContain("claude");
await expect
.poll(() => page.getByRole("button", { name: "Start thread" }).isEnabled())
.toBe(false);
expect(await gateway.getRequests("sessions.catalog.list")).toHaveLength(0);
await gateway.deferNext("sessions.catalog.list");
await gateway.setOnline(true);
await gateway.waitForRequest("sessions.catalog.list");
await gateway.deferNext("sessions.catalog.list");
await gateway.rejectDeferred("sessions.catalog.list", {
code: "UNAVAILABLE",
message: "catalog warming up",
retryable: true,
});
await expect
.poll(async () => (await gateway.getRequests("sessions.catalog.list")).length)
.toBe(2);
await gateway.resolveDeferred("sessions.catalog.list", { catalogs: [] });
await expect
.poll(async () => (await gateway.getRequests("sessions.catalog.list")).length, {
timeout: 10_000,
})
.toBe(3);
await pollLocatorText(page.locator(".new-session-page__runtime")).toContain("Claude Code");
await expect.poll(() => message.inputValue()).toBe("keep this reconnect draft");
await pollLocatorText(page.getByRole("heading").first()).toContain("Research");
await page.getByRole("button", { name: "Start thread" }).click();
const create = await gateway.waitForRequest("sessions.create");
expect(create.params).toMatchObject({
agentId: "research",
message: "keep this reconnect draft",
catalogId: "claude",
});
expect(create.params).not.toHaveProperty("model");
expect(create.params).not.toHaveProperty("cwd");
} finally {
await context.close();
}
});
it("preserves a manually selected agent across a same-client reconnect", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: {
"agents.list": {
agents: [
{
id: "main",
identity: { name: "Main" },
name: "Main",
workspace: WORKSPACE,
workspaceGit: true,
},
{
id: "research",
identity: { name: "Research" },
name: "Research",
workspace: "/home/peter/research",
workspaceGit: true,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
},
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
"sessions.create": { key: "agent:research:manual-reconnect" },
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await page.getByRole("heading", { name: "Main" }).waitFor();
await gateway.waitForRequest("worktrees.branches");
const agentPicker = page.locator(".new-session-page__select--agent openclaw-agent-select");
await agentPicker.locator(".agent-select__trigger").click();
await agentPicker
.locator("wa-dropdown-item[data-agent-option]")
.filter({ hasText: "Research" })
.click();
await page.getByRole("heading", { name: "Research" }).waitFor();
const message = page.locator(".new-session-page__message");
await message.fill("keep my selected agent");
const agentRequestsBefore = (await gateway.getRequests("agents.list")).length;
const branchRequestsBefore = (await gateway.getRequests("worktrees.branches")).length;
await gateway.setOnline(false);
await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 });
await gateway.setMethodResponse("agents.list", {
agents: [
{
id: "main",
identity: { name: "Main" },
name: "Main",
workspace: WORKSPACE,
workspaceGit: true,
},
{
id: "research",
identity: { name: "Research" },
name: "Research",
workspace: REFRESHED_RESEARCH_WORKSPACE,
workspaceGit: true,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
});
await gateway.setOnline(true);
await expect
.poll(async () => (await gateway.getRequests("agents.list")).length)
.toBe(agentRequestsBefore + 1);
await expect.poll(() => message.inputValue()).toBe("keep my selected agent");
await pollLocatorText(page.getByRole("heading").first()).toContain("Research");
await pollLocatorText(
page.locator("#new-session-place-trigger .new-session-page__trigger-label"),
).toBe("research-next");
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).length)
.toBe(branchRequestsBefore + 1);
expect((await gateway.getRequests("worktrees.branches")).at(-1)?.params).toEqual({
repoRoot: REFRESHED_RESEARCH_WORKSPACE,
includeRepositoryStatus: true,
});
const placeSelect = page.locator("wa-popover.new-session-page__place-popover");
const placeTrigger = page.locator("#new-session-place-trigger");
await placeTrigger.click();
const worktreeItem = placeSelect.getByRole("button", { name: "Worktree" });
await worktreeItem.click();
const baseInput = page.getByLabel("Base branch");
await expect.poll(() => baseInput.inputValue()).toBe("main");
await page.keyboard.press("Escape");
await gateway.deferNext("worktrees.branches");
const branchesBeforeSameWorkspaceReconnect = (await gateway.getRequests("worktrees.branches"))
.length;
await gateway.setOnline(false);
await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 });
await gateway.setOnline(true);
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).length)
.toBe(branchesBeforeSameWorkspaceReconnect + 1);
expect((await gateway.getRequests("worktrees.branches")).at(-1)?.params).toEqual({
repoRoot: REFRESHED_RESEARCH_WORKSPACE,
includeRepositoryStatus: true,
});
expect(await baseInput.inputValue()).toBe("");
expect(await baseInput.getAttribute("placeholder")).toBe("Loading…");
await placeTrigger.click();
await baseInput.fill("feature-choice");
await gateway.resolveDeferred("worktrees.branches", {
branches: [{ kind: "local", name: "beta" }],
defaultBranch: "beta",
repositoryStatus: "git",
});
await expect.poll(() => baseInput.inputValue()).toBe("feature-choice");
await page.getByRole("button", { name: "Start thread" }).click();
const create = await gateway.waitForRequest("sessions.create");
expect(create.params).toMatchObject({
agentId: "research",
message: "keep my selected agent",
worktree: true,
worktreeBaseRef: "feature-choice",
});
expect(create.params).not.toHaveProperty("cwd");
} finally {
await context.close();
}
});
});
@@ -0,0 +1,944 @@
import { expect, it } from "vitest";
import {
ONE_PIXEL_PNG_B64,
SESSION_LIST_DEFAULTS,
TARGET_REPO,
WORKSPACE,
captureUiProof,
controlUiSessionPath,
controlUiSessionUrl,
createNewSessionPageE2eSuite,
createdSessionListResult,
installMockGateway,
pastePng,
pollLocatorText,
replaceGatewayClient,
} from "./new-session-page.test-support.ts";
const suite = createNewSessionPageE2eSuite();
suite.define(() => {
it("dispatches a cloud target before sending its first turn and shows placement", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const sessionKey = "agent:cloud:cloud-e2e";
const gateway = await installMockGateway(page, {
defaultAgentId: "cloud",
deferredMethods: ["sessions.dispatch"],
featureMethods: ["chat.metadata", "chat.startup", "sessions.reclaim"],
workspaceGit: true,
sessionKey: "agent:cloud:neutral-e2e",
methodResponses: {
"agents.list": {
agents: [
{
id: "cloud",
identity: { name: "Cloud" },
name: "Cloud",
workspace: WORKSPACE,
workspaceGit: true,
},
],
defaultId: "cloud",
mainKey: "main",
scope: "agent",
},
"environments.list": {
environments: [],
profiles: [{ id: "aws", providerId: "crabbox" }],
},
"fs.listDir": {
path: WORKSPACE,
parent: "/home/peter",
home: "/home/peter",
entries: [],
},
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
"sessions.create": { key: sessionKey },
"sessions.list": createdSessionListResult(sessionKey),
"sessions.dispatch": {
ok: true,
key: sessionKey,
sessionId: "session-cloud-e2e",
placement: {
state: "active",
generation: 1,
createdAtMs: 1,
updatedAtMs: 2,
stateChangedAtMs: 2,
environmentId: "worker-1",
activeOwnerEpoch: 1,
workerBundleHash: "a".repeat(64),
workspaceBaseManifestRef: "manifest-1",
remoteWorkspaceDir: "/workspace",
},
},
"sessions.describe": { session: {} },
"sessions.delete": { ok: true, deleted: true },
"sessions.reclaim": { ok: true },
"sessions.send": { runId: "run-cloud-e2e", status: "started" },
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
expect(
await page.evaluate(() => ({
hasSubtleCrypto: Boolean(globalThis.crypto.subtle),
isSecureContext: globalThis.isSecureContext,
})),
).toEqual({ hasSubtleCrypto: true, isSecureContext: true });
await gateway.waitForRequest("environments.list");
await page.locator("#new-session-place-trigger").click();
const place = page.locator("wa-popover.new-session-page__place-popover");
await place.getByRole("button", { name: "Cloud · aws" }).click();
const trigger = page.locator("#new-session-place-trigger");
await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws");
await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true");
await expect.poll(() => page.getByLabel("Base branch").inputValue()).toBe("main");
const modelSelect = page.locator(
'.new-session-page__composer [data-chat-model-select="true"]',
);
await modelSelect.click();
await expect.poll(() => modelSelect.getAttribute("data-chat-thinking-select")).toBe("true");
const thinkingSlider = page.locator(
'.new-session-page__composer [data-chat-thinking-slider="true"]',
);
await expect
.poll(() => thinkingSlider.getAttribute("data-chat-thinking-values"))
.toBe("off,minimal,low,medium,high");
await expect
.poll(() => page.locator(".new-session-page__composer [data-chat-speed-toggle]").count())
.toBe(0);
await thinkingSlider.press("End");
await expect.poll(() => modelSelect.getAttribute("data-chat-thinking-value")).toBe("high");
await captureUiProof(page, "01-cloud-thinking-level.png");
await modelSelect.click();
await expect
.poll(() => modelSelect.evaluate((element) => element.closest("details")?.open ?? false))
.toBe(false);
// Picking a Gateway repo keeps the cloud selection: that folder is what
// the managed worktree checks out and dispatch syncs to the worker.
await trigger.click();
await place.getByRole("button", { name: "Browse folders" }).click();
await page.locator("input.new-session-page__browser-path").fill(TARGET_REPO);
await page.getByRole("button", { name: "Use this folder" }).click();
await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws");
await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true");
await trigger.click();
await pollLocatorText(place.locator(".new-session-page__menu-note")).toContain(
"Syncs target-repo to the cloud worker",
);
await captureUiProof(page, "01-cloud-worker-target.png");
await page.keyboard.press("Escape");
const message = "fix the cloud-only failure";
const composer = page.locator(".new-session-page__message");
await composer.fill(message);
await pastePng(composer);
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
const startButton = page.getByRole("button", { name: "Start thread" });
await gateway.deferNext("environments.list");
const profileRequests = (await gateway.getRequests("environments.list")).length;
await replaceGatewayClient(page);
await expect
.poll(async () => (await gateway.getRequests("environments.list")).length)
.toBeGreaterThan(profileRequests);
await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws");
await expect.poll(() => startButton.isDisabled()).toBe(true);
await gateway.rejectDeferred("environments.list", {
code: "UNAVAILABLE",
message: "profile lookup unavailable",
});
await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws");
await expect.poll(() => startButton.isDisabled()).toBe(true);
const failedProfileRequests = (await gateway.getRequests("environments.list")).length;
await expect
.poll(async () => (await gateway.getRequests("environments.list")).length)
.toBeGreaterThan(failedProfileRequests);
await expect.poll(() => startButton.isDisabled()).toBe(false);
await startButton.click();
const create = await gateway.waitForRequest("sessions.create");
expect(create.params).toMatchObject({
agentId: "cloud",
message: "",
worktree: true,
worktreeBaseRef: "main",
cwd: TARGET_REPO,
thinkingLevel: "high",
});
expect(create.params).not.toHaveProperty("attachments");
await gateway.waitForRequest("sessions.dispatch");
await gateway.rejectDeferred("sessions.dispatch", {
code: "UNAVAILABLE",
message: "allocation response lost",
});
await pollLocatorText(page.locator(".new-session-page__error")).toContain(
"cloud worker placement could not be verified",
);
const alert = page.locator(".new-session-page__alert");
await expect.poll(() => alert.getAttribute("role")).toBe("alert");
await expect.poll(() => alert.locator("svg").count()).toBe(1);
const [alertBox, composerBox] = await Promise.all([
alert.boundingBox(),
page.locator(".new-session-page__composer").boundingBox(),
]);
expect(alertBox).not.toBeNull();
expect(composerBox).not.toBeNull();
expect(
Math.abs(
(alertBox?.x ?? 0) +
(alertBox?.width ?? 0) / 2 -
((composerBox?.x ?? 0) + (composerBox?.width ?? 0) / 2),
),
).toBeLessThanOrEqual(1);
await expect.poll(() => startButton.isDisabled()).toBe(false);
await page.getByRole("button", { name: "Start thread" }).click();
await expect
.poll(async () => (await gateway.getRequests("sessions.dispatch")).length)
.toBe(2);
expect(await gateway.getRequests("sessions.create")).toHaveLength(1);
expect(await gateway.getRequests("sessions.delete")).toHaveLength(0);
const dispatches = await gateway.getRequests("sessions.dispatch");
expect(dispatches.at(-1)?.params).toEqual({
key: sessionKey,
agentId: "cloud",
profileId: "aws",
});
const send = await gateway.waitForRequest("sessions.send");
expect(send.params).toMatchObject({
key: sessionKey,
agentId: "cloud",
message,
attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }],
});
const orderedMethods = (await gateway.getRequests())
.map((request) => request.method)
.filter((method) =>
["sessions.create", "sessions.dispatch", "sessions.send"].includes(method),
);
expect(orderedMethods).toEqual([
"sessions.create",
"sessions.dispatch",
"sessions.dispatch",
"sessions.send",
]);
await gateway.setMethodResponse("sessions.list", {
count: 4,
path: "",
defaults: {},
sessions: [
{
key: sessionKey,
kind: "direct",
label: "Cloud session",
updatedAt: Date.now(),
worktree: { id: "worktree-1", branch: "openclaw/cloud-e2e", repoRoot: WORKSPACE },
placement: { state: "active" },
},
{
key: "agent:cloud:managed-e2e",
kind: "direct",
label: "Managed session",
updatedAt: Date.now() - 1,
placement: { state: "active" },
},
{
key: "agent:cloud:local-e2e",
kind: "direct",
label: "Local session",
updatedAt: Date.now() - 2,
placement: { state: "local" },
},
{
key: "agent:cloud:neutral-e2e",
kind: "direct",
label: "Neutral session",
updatedAt: Date.now() - 3,
placement: { state: "local" },
},
],
ts: Date.now(),
});
await gateway.emitGatewayEvent("sessions.changed", { sessionKey, reason: "dispatch" });
await page.goto(controlUiSessionUrl(suite.server.baseUrl, "agent:cloud:neutral-e2e"));
const managedSessionKey = "agent:cloud:managed-e2e";
const sessionRow = page.locator(`[data-session-key="${managedSessionKey}"]`);
const localSessionRow = page.locator('[data-session-key="agent:cloud:local-e2e"]');
await sessionRow.waitFor();
await localSessionRow.waitFor();
const cloudPlacementBadge = sessionRow.locator('[data-placement-state="active"]');
await cloudPlacementBadge.waitFor();
await sessionRow.hover();
await sessionRow.getByRole("button", { name: "Open thread menu" }).click();
const stopWorker = page
.locator("openclaw-session-menu")
.getByRole("menuitem", { name: "Stop cloud worker…" });
await stopWorker.waitFor();
await captureUiProof(page, "02-active-cloud-worker-stop.png");
expect(await localSessionRow.locator(".session-row-badge--cloud").count()).toBe(0);
expect(await cloudPlacementBadge.locator("circle").count()).toBe(1);
expect(await cloudPlacementBadge.locator("rect").count()).toBe(0);
page.once("dialog", (dialog) => void dialog.accept());
await stopWorker.click();
const reclaim = await gateway.waitForRequest("sessions.reclaim");
expect(reclaim.params).toEqual({ key: managedSessionKey, agentId: "cloud" });
} finally {
await context.close();
}
});
it("clears cloud placement when the selected agent changes", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const gateway = await installMockGateway(page, {
workspaceGit: true,
methodResponses: {
"agents.list": {
agents: [
{
id: "cloud",
identity: { name: "Cloud" },
name: "Cloud",
workspace: WORKSPACE,
workspaceGit: true,
},
{
id: "local",
identity: { name: "Local" },
name: "Local",
workspace: "/home/peter/local",
workspaceGit: false,
},
],
defaultId: "cloud",
mainKey: "main",
scope: "agent",
},
"environments.list": {
environments: [],
profiles: [{ id: "aws", providerId: "crabbox" }],
},
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("environments.list");
await page.locator("#new-session-place-trigger").click();
await page
.locator("wa-popover.new-session-page__place-popover")
.getByRole("button", { name: "Cloud · aws" })
.click();
const trigger = page.locator("#new-session-place-trigger");
await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws");
await gateway.setMethodResponse("environments.list", { environments: [], profiles: [] });
const profileRequests = (await gateway.getRequests("environments.list")).length;
await replaceGatewayClient(page);
await expect
.poll(async () => (await gateway.getRequests("environments.list")).length)
.toBeGreaterThan(profileRequests);
await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws");
await pollLocatorText(trigger).toContain("Cloud · aws");
await expect
.poll(() => page.getByRole("button", { name: "Start thread" }).isDisabled())
.toBe(true);
await trigger.click();
await expect
.poll(() =>
page
.locator("wa-popover.new-session-page__place-popover")
.getByRole("button", { name: "Cloud · aws" })
.isDisabled(),
)
.toBe(true);
await page.keyboard.press("Escape");
const agentPicker = page.locator(".new-session-page__select--agent openclaw-agent-select");
await agentPicker.locator(".agent-select__trigger").click();
await agentPicker
.locator("wa-dropdown-item[data-agent-option]")
.filter({ hasText: "Local" })
.click();
await page.getByRole("heading", { name: "Local" }).waitFor();
await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBeNull();
await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("false");
} finally {
await context.close();
}
});
it("restores a cloud startup after a page reload without creating another session", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const sessionKey = "agent:cloud:reload-recovery";
const message = "resume this cloud task after reload";
const gateway = await installMockGateway(page, {
workspaceGit: true,
methodResponses: {
"agents.list": {
agents: [
{
id: "cloud",
identity: { name: "Cloud" },
name: "Cloud",
workspace: WORKSPACE,
workspaceGit: true,
},
],
defaultId: "cloud",
mainKey: "main",
scope: "agent",
},
"environments.list": {
environments: [],
profiles: [{ id: "aws", providerId: "crabbox" }],
},
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
"sessions.create": { key: sessionKey },
"sessions.dispatch": {
ok: true,
key: sessionKey,
sessionId: "session-reload-recovery",
placement: {
state: "active",
generation: 1,
createdAtMs: 1,
updatedAtMs: 2,
stateChangedAtMs: 2,
environmentId: "worker-reload-recovery",
activeOwnerEpoch: 1,
workerBundleHash: "a".repeat(64),
workspaceBaseManifestRef: "manifest-reload-recovery",
remoteWorkspaceDir: "/workspace",
},
},
"sessions.list": {
count: 1,
defaults: SESSION_LIST_DEFAULTS,
path: "",
sessions: [{ key: sessionKey, kind: "direct", updatedAt: Date.now() }],
ts: Date.now(),
},
"chat.history": {
messages: [],
sessionId: "session-reload-recovery",
sessionInfo: { hasActiveRun: false, key: sessionKey, status: "done" },
},
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("environments.list");
await page.locator("#new-session-place-trigger").click();
await page
.locator("wa-popover.new-session-page__place-popover")
.getByRole("button", { name: "Cloud · aws" })
.click();
await page.evaluate(() => {
const originalSetItem = sessionStorage.setItem.bind(sessionStorage);
Storage.prototype.setItem = function (key: string, value: string) {
if (
key.startsWith("openclaw.new-session.cloud-recovery.v1:") ||
key.startsWith("openclaw.control-ui-e2e.")
) {
originalSetItem(key, value);
return;
}
throw new DOMException("composer storage disabled", "SecurityError");
};
});
await gateway.deferNext("sessions.send");
await page.locator(".new-session-page__message").fill(message);
await pastePng(page.locator(".new-session-page__message"));
await page.getByRole("button", { name: "Start thread" }).click();
const firstSend = await gateway.waitForRequest("sessions.send");
expect(firstSend.params).toMatchObject({
attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }],
});
await gateway.rejectDeferred("sessions.send", {
code: "UNAVAILABLE",
message: "send outcome unknown",
});
await pollLocatorText(page.locator(".new-session-page__error")).toContain(
"send outcome unknown",
);
await gateway.setMethodResponse("sessions.send", {
runId: "run-reload-recovery",
status: "started",
});
await page.reload();
await gateway.waitForRequest("environments.list");
await expect
.poll(() => page.locator(".new-session-page__message").inputValue())
.toBe(message);
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
await expect
.poll(() => page.getByRole("button", { name: "Remove attachment" }).isDisabled())
.toBe(true);
await expect
.poll(() => page.getByRole("button", { name: "Start thread" }).isDisabled())
.toBe(false);
await page.getByRole("button", { name: "Start thread" }).click();
const resumedSend = await gateway.waitForRequest("sessions.send");
expect(resumedSend.params).toMatchObject({
attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }],
idempotencyKey: (firstSend.params as { idempotencyKey: string }).idempotencyKey,
key: sessionKey,
message,
});
expect(await gateway.getRequests("sessions.create")).toHaveLength(0);
await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey), {
timeout: 30_000,
});
} finally {
await context.close();
}
});
it("restores cloud recovery added while the Gateway is disconnected", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const gateway = await installMockGateway(page, {
workspaceGit: true,
methodResponses: {
"agents.list": {
agents: [
{
id: "cloud",
identity: { name: "Cloud" },
name: "Cloud",
workspace: WORKSPACE,
workspaceGit: true,
},
],
defaultId: "cloud",
mainKey: "main",
scope: "agent",
},
"environments.list": {
environments: [],
profiles: [{ id: "aws", providerId: "crabbox" }],
},
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("environments.list");
const recoveryIdentity = await page.evaluate(() => {
const app = document.querySelector("openclaw-app") as HTMLElement & {
runtime?: {
context: {
gateway: {
connection: { gatewayUrl: string };
snapshot: { client?: { recoveryScope?: string } | null };
};
};
};
};
const gatewaySnapshot = app.runtime?.context.gateway;
const gatewayUrl = gatewaySnapshot?.connection.gatewayUrl ?? "";
const recoveryScope = gatewaySnapshot?.snapshot.client?.recoveryScope ?? "";
if (!gatewayUrl || !recoveryScope) {
throw new Error("Gateway recovery identity is unavailable");
}
return { gatewayUrl, recoveryScope };
});
await gateway.setOnline(false);
await expect
.poll(() =>
page.evaluate(() => {
const app = document.querySelector("openclaw-app") as HTMLElement & {
runtime?: { context: { gateway: { snapshot: { phase: string } } } };
};
return app.runtime?.context.gateway.snapshot.phase === "connected";
}),
)
.toBe(false);
await page.evaluate(({ gatewayUrl, recoveryScope }) => {
sessionStorage.setItem(
`openclaw.new-session.cloud-recovery.v1:${gatewayUrl}:${recoveryScope}`,
JSON.stringify({
sessionKey: "agent:cloud:offline-recovery",
messageId: "message-offline-recovery",
message: "restore after reconnect",
attachments: [
{
type: "image",
mimeType: "image/png",
fileName: "pixel.png",
content:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=",
},
],
profileId: "aws",
agentId: "cloud",
gatewayUrl,
recoveryScope,
phase: "sending",
}),
);
}, recoveryIdentity);
await gateway.setOnline(true);
await expect
.poll(() => page.locator(".new-session-page__message").inputValue())
.toBe("restore after reconnect");
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
await expect
.poll(() => page.getByRole("button", { name: "Start thread" }).isDisabled())
.toBe(false);
await page.evaluate(() => {
const app = document.querySelector("openclaw-app") as HTMLElement & {
runtime?: {
context: {
gateway: {
snapshot: {
client?: { recoveryScopeTracker?: { ready: boolean } } | null;
};
};
};
};
};
const client = app.runtime?.context.gateway.snapshot.client;
if (!client?.recoveryScopeTracker) {
throw new Error("Gateway recovery tracker is unavailable");
}
client.recoveryScopeTracker.ready = false;
(
document.querySelector("openclaw-new-session-page") as
| (HTMLElement & { requestUpdate: () => void })
| null
)?.requestUpdate();
});
await expect
.poll(() => page.getByRole("button", { name: "Start thread" }).isDisabled())
.toBe(true);
} finally {
await context.close();
}
});
it("retries an ambiguous cloud create with the same session key", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const message = "recover the cloud create";
const gateway = await installMockGateway(page, {
deferredMethods: ["sessions.create"],
workspaceGit: true,
methodResponses: {
"agents.list": {
agents: [
{
id: "cloud",
identity: { name: "Cloud" },
name: "Cloud",
workspace: WORKSPACE,
workspaceGit: true,
},
],
defaultId: "cloud",
mainKey: "main",
scope: "agent",
},
"environments.list": {
environments: [],
profiles: [{ id: "aws", providerId: "crabbox" }],
},
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
"sessions.dispatch": {
placement: { state: "active", environmentId: "worker-create-recovery" },
},
"sessions.send": { runId: "run-create-recovery", status: "started" },
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("environments.list");
await page.locator("#new-session-place-trigger").click();
await page
.locator("wa-popover.new-session-page__place-popover")
.getByRole("button", { name: "Cloud · aws" })
.click();
await page.locator(".new-session-page__message").fill(message);
await page.getByRole("button", { name: "Start thread" }).click();
const firstCreate = await gateway.waitForRequest("sessions.create");
const firstKey = (firstCreate.params as { key?: string }).key;
if (!firstKey) {
throw new Error("expected the first recovery create to include a session key");
}
expect(firstKey).toMatch(/^agent:cloud:dashboard:/);
await page.reload();
await gateway.waitForRequest("environments.list");
await expect
.poll(() => page.locator(".new-session-page__message").inputValue())
.toBe(message);
await page.getByRole("button", { name: "Start thread" }).click();
const retryCreate = await gateway.waitForRequest("sessions.create");
expect(retryCreate.params).toMatchObject({ key: firstKey, message: "", worktree: true });
await gateway.resolveDeferred("sessions.create", { key: firstKey });
expect(await gateway.waitForRequest("sessions.dispatch")).toMatchObject({
params: { key: firstKey, agentId: "cloud", profileId: "aws" },
});
expect(await gateway.waitForRequest("sessions.send")).toMatchObject({
params: { key: firstKey, agentId: "cloud", message },
});
await page.waitForURL((url) => url.pathname === controlUiSessionPath(firstKey), {
timeout: 30_000,
});
} finally {
await context.close();
}
});
it("keeps the original recovery identity when a cloud create settles after reset", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const message = "preserve this late cloud create";
const gateway = await installMockGateway(page, {
deferredMethods: ["sessions.create", "sessions.delete"],
workspaceGit: true,
methodResponses: {
"agents.list": {
agents: [
{
id: "cloud",
identity: { name: "Cloud" },
name: "Cloud",
workspace: WORKSPACE,
workspaceGit: true,
},
],
defaultId: "cloud",
mainKey: "main",
scope: "agent",
},
"environments.list": {
environments: [],
profiles: [{ id: "aws", providerId: "crabbox" }],
},
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
},
});
const readRecovery = () =>
page.evaluate(() => {
const key = Object.keys(sessionStorage).find((candidate) =>
candidate.startsWith("openclaw.new-session.cloud-recovery.v1:"),
);
return key ? (JSON.parse(sessionStorage.getItem(key) ?? "null") as unknown) : null;
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("environments.list");
await page.locator("#new-session-place-trigger").click();
await page
.locator("wa-popover.new-session-page__place-popover")
.getByRole("button", { name: "Cloud · aws" })
.click();
await page.locator(".new-session-page__message").fill(message);
await page.getByRole("button", { name: "Start thread" }).click();
const create = await gateway.waitForRequest("sessions.create");
const sessionKey = (create.params as { key: string }).key;
const staged = await readRecovery();
await page.evaluate(() => {
history.pushState(null, "", "new?agent=cloud");
dispatchEvent(new PopStateEvent("popstate"));
});
await gateway.resolveDeferred("sessions.create", { key: sessionKey });
await gateway.waitForRequest("sessions.delete");
await gateway.rejectDeferred("sessions.delete", {
code: "UNAVAILABLE",
message: "cleanup unavailable",
});
await pollLocatorText(page.locator(".new-session-page__error")).toContain(
"cleanup unavailable",
);
const stagedIdentity = staged as { messageId: string; profileId: string; agentId: string };
expect(await readRecovery()).toMatchObject({
sessionKey,
messageId: stagedIdentity.messageId,
message,
profileId: stagedIdentity.profileId,
agentId: stagedIdentity.agentId,
phase: "dispatching",
});
} finally {
await context.close();
}
});
it("retries an unpersisted cloud turn with its original recovery identity", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const sessionKey = "agent:cloud:storage-recovery";
const message = "keep this cloud recovery task";
const gateway = await installMockGateway(page, {
deferredMethods: ["sessions.send"],
workspaceGit: true,
methodResponses: {
"agents.list": {
agents: [
{
id: "cloud",
identity: { name: "Cloud" },
name: "Cloud",
workspace: WORKSPACE,
workspaceGit: true,
},
],
defaultId: "cloud",
mainKey: "main",
scope: "agent",
},
"environments.list": {
environments: [],
profiles: [{ id: "aws", providerId: "crabbox" }],
},
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
"sessions.create": { key: sessionKey },
"sessions.dispatch": {
ok: true,
key: sessionKey,
sessionId: "session-storage-recovery",
placement: {
state: "active",
generation: 1,
createdAtMs: 1,
updatedAtMs: 2,
stateChangedAtMs: 2,
environmentId: "worker-storage-recovery",
activeOwnerEpoch: 1,
workerBundleHash: "a".repeat(64),
workspaceBaseManifestRef: "manifest-storage-recovery",
remoteWorkspaceDir: "/workspace",
},
},
"sessions.list": {
count: 1,
defaults: SESSION_LIST_DEFAULTS,
path: "",
sessions: [{ key: sessionKey, kind: "direct", updatedAt: Date.now() }],
ts: Date.now(),
},
"chat.history": {
messages: [],
sessionId: "session-storage-recovery",
sessionInfo: { hasActiveRun: false, key: sessionKey, status: "done" },
},
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("environments.list");
await page.locator("#new-session-place-trigger").click();
await page
.locator("wa-popover.new-session-page__place-popover")
.getByRole("button", { name: "Cloud · aws" })
.click();
await page.evaluate(() => {
const originalSetItem = sessionStorage.setItem.bind(sessionStorage);
Storage.prototype.setItem = function (key: string, value: string) {
if (key.startsWith("openclaw.new-session.cloud-recovery.v1:")) {
originalSetItem(key, value);
return;
}
throw new DOMException("composer storage disabled", "SecurityError");
};
});
await page.locator(".new-session-page__message").fill(message);
await page.getByRole("button", { name: "Start thread" }).click();
const firstSend = await gateway.waitForRequest("sessions.send");
await gateway.rejectDeferred("sessions.send", {
code: "UNAVAILABLE",
message: "send outcome unknown",
});
await pollLocatorText(page.locator(".new-session-page__error")).toContain(
"send outcome unknown",
);
await expect.poll(() => page.locator(".new-session-page__message").isDisabled()).toBe(true);
expect(await page.locator(".new-session-page__message").inputValue()).toBe(message);
expect(new URL(page.url()).pathname).toContain("/new");
await gateway.setMethodResponse("environments.list", { environments: [], profiles: [] });
const profileRequests = (await gateway.getRequests("environments.list")).length;
await replaceGatewayClient(page);
await expect
.poll(async () => (await gateway.getRequests("environments.list")).length)
.toBeGreaterThan(profileRequests);
await page.getByRole("button", { name: "Start thread" }).click();
await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey), {
timeout: 30_000,
});
const sends = await gateway.getRequests("sessions.send");
expect(sends).toHaveLength(2);
expect(sends[1]?.params).toMatchObject({
idempotencyKey: (firstSend.params as { idempotencyKey: string }).idempotencyKey,
key: sessionKey,
message,
});
expect(await gateway.getRequests("sessions.create")).toHaveLength(1);
const dispatches = await gateway.getRequests("sessions.dispatch");
expect(dispatches).toHaveLength(2);
expect(dispatches[1]?.params).toMatchObject({ profileId: "aws" });
} finally {
await context.close();
}
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,789 @@
import { expect, it } from "vitest";
import {
EXEC_ONLY_PICKED,
NODE_HOME,
NODE_PICKED,
NODE_UNC,
PICKED,
SESSION_LIST_DEFAULTS,
WORKSPACE,
controlUiSessionPath,
createNewSessionPageE2eSuite,
createdSessionListResult,
installMockGateway,
pollLocatorText,
replaceGatewayClient,
} from "./new-session-page.test-support.ts";
const suite = createNewSessionPageE2eSuite();
suite.define(() => {
it("drafts a session with a browsed folder and creates it on first message", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
workspaceGit: true,
methodResponses: {
"agents.list": {
agents: [
{
id: "main",
identity: { name: "Main" },
name: "Main",
workspace: WORKSPACE,
workspaceGit: true,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
},
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
"fs.listDir": {
cases: [
{
match: { path: WORKSPACE },
response: {
path: WORKSPACE,
parent: "/home/peter",
home: "/home/peter",
entries: [
{ name: "packages", path: PICKED },
{ name: ".git", path: `${WORKSPACE}/.git`, hidden: true },
],
},
},
{
match: { path: PICKED },
response: {
path: PICKED,
parent: WORKSPACE,
home: "/home/peter",
entries: [],
},
},
],
},
"sessions.create": { key: "agent:main:draft-e2e" },
},
});
try {
// Deep-link to /new: the page loads agents via agents.list (the sidebar
// "+" navigates to the same route with ?agent=<id>).
const response = await page.goto(`${suite.server.baseUrl}new`);
expect(response?.status()).toBe(200);
// The draft page shows the start-screen welcome hero for the agent.
await page.getByRole("heading", { name: "Main" }).waitFor();
await page.locator(".new-session-page__message").waitFor();
// Unified layout: the trigger row (menus above the composer) sits
// inside the start-screen welcome, below the hero.
const heroBox = await page.locator(".agent-chat__welcome h2").boundingBox();
const triggersBox = await page.locator(".new-session-page__triggers").boundingBox();
const composerBox = await page.locator(".new-session-page__composer").boundingBox();
const modelBox = await page.locator('[data-chat-model-select="true"]').boundingBox();
const modelWrapperBox = await page
.locator(".new-session-page__composer .chat-composer-model-control")
.boundingBox();
const footerBox = await page
.locator(".new-session-page__composer .agent-chat__composer-footer")
.boundingBox();
expect(heroBox).not.toBeNull();
expect(triggersBox).not.toBeNull();
expect(composerBox).not.toBeNull();
expect(modelBox).not.toBeNull();
expect(modelWrapperBox).not.toBeNull();
expect(footerBox).not.toBeNull();
expect((heroBox?.y ?? 0) + (heroBox?.height ?? 0)).toBeLessThanOrEqual(
(triggersBox?.y ?? 0) + 1,
);
expect((triggersBox?.y ?? 0) + (triggersBox?.height ?? 0)).toBeLessThanOrEqual(
(composerBox?.y ?? 0) + 1,
);
expect(
await page.locator(".new-session-page__composer .agent-chat__composer-footer").count(),
).toBe(1);
expect(
await page
.locator('[data-chat-model-select="true"]')
.evaluate((element) => element.closest(".agent-chat__composer-footer") != null),
).toBe(true);
expect(modelWrapperBox?.x ?? 0).toBeGreaterThan(
(footerBox?.x ?? 0) + (footerBox?.width ?? 0) / 2,
);
expect(
(footerBox?.x ?? 0) +
(footerBox?.width ?? 0) -
((modelWrapperBox?.x ?? 0) + (modelWrapperBox?.width ?? 0)),
).toBeLessThanOrEqual(12);
expect(triggersBox?.x).toBeCloseTo(composerBox?.x ?? 0, 0);
expect(triggersBox?.width).toBeCloseTo(composerBox?.width ?? 0, 0);
expect(composerBox?.width).toBeCloseTo(48 * 16, 0);
expect(await page.locator(".new-session-page__message").getAttribute("rows")).toBe("1");
// The place trigger labels the workspace and opens the unified menu.
const placeSelect = page.locator("wa-popover.new-session-page__place-popover");
const placeTrigger = page.locator("#new-session-place-trigger");
await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe(
"openclaw",
);
// Browse from the workspace, descend one level, then adopt the folder.
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "Browse folders" }).click();
await page.locator(".new-session-page__browser-entry", { hasText: "packages" }).click();
await expect
.poll(() => page.locator("input.new-session-page__browser-path").inputValue())
.toBe(PICKED);
await page.getByRole("button", { name: "Use this folder" }).click();
// The adopted folder closes the menu and updates the trigger label.
await expect.poll(() => placeSelect.getAttribute("open")).toBeNull();
await expect
.poll(() => page.evaluate(() => document.activeElement?.id))
.toBe("new-session-place-trigger");
await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe(
"packages",
);
// Git-backed custom folders stay direct until the user explicitly chooses isolation.
await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("false");
await placeTrigger.click();
const worktreeItem = page.getByRole("button", { name: "Worktree" });
await expect.poll(() => worktreeItem.getAttribute("aria-pressed")).toBe("false");
expect(await worktreeItem.isEnabled()).toBe(true);
await worktreeItem.click();
await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("true");
await page.keyboard.press("Escape");
await expect
.poll(() => page.evaluate(() => document.activeElement?.id))
.toBe("new-session-place-trigger");
// Pointer light-dismiss still retires the unified popover after its
// asynchronous hide animation completes.
await placeTrigger.click();
const afterPointerHide = placeSelect.evaluate(
(element) =>
new Promise<void>((resolve) => {
element.addEventListener("wa-after-hide", () => resolve(), { once: true });
}),
);
await page.locator(".agent-chat__welcome h2").click();
await afterPointerHide;
await expect.poll(() => placeSelect.getAttribute("open")).toBeNull();
const message = page.locator(".new-session-page__message");
await message.fill("fix the flaky test");
await page.getByRole("button", { name: "Start thread" }).click();
const createRequest = await gateway.waitForRequest("sessions.create");
expect(createRequest.params).toMatchObject({
agentId: "main",
message: "fix the flaky test",
worktree: true,
worktreeBaseRef: "main",
cwd: PICKED,
});
await expect
.poll(() => new URL(page.url()).pathname)
.toBe(controlUiSessionPath("agent:main:draft-e2e"));
} finally {
await context.close();
}
});
it("runs directly in a custom non-Git Gateway folder", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const gateway = await installMockGateway(page, {
workspaceGit: true,
methodResponses: {
"agents.list": {
agents: [
{
id: "main",
identity: { name: "Main" },
name: "Main",
workspace: WORKSPACE,
workspaceGit: true,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
},
"environments.list": {
environments: [],
profiles: [{ id: "aws", providerId: "crabbox" }],
},
"fs.listDir": { path: WORKSPACE, home: "/home/peter", entries: [] },
"worktrees.branches": {
cases: [
{
match: { repoRoot: WORKSPACE },
response: {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
},
{
match: { repoRoot: "/home" },
response: { branches: [], repositoryStatus: "not_git" },
},
],
},
"sessions.create": { key: "agent:main:plain-folder" },
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("environments.list");
const trigger = page.locator("#new-session-place-trigger");
const place = page.locator("wa-popover.new-session-page__place-popover");
await trigger.click();
await place.getByRole("button", { name: "Browse folders" }).click();
await page.locator("input.new-session-page__browser-path").fill("/home");
await page.getByRole("button", { name: "Use this folder" }).click();
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).at(-1)?.params)
.toEqual({ repoRoot: "/home", includeRepositoryStatus: true });
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe(
"home · Gateway · local",
);
await trigger.click();
expect(await place.getByRole("button", { name: "Worktree" }).count()).toBe(0);
const cloud = place.getByRole("button", { name: "Cloud · aws" });
expect(await cloud.isDisabled()).toBe(true);
expect(await cloud.getAttribute("title")).toBe("Cloud workers require a managed worktree");
await page.keyboard.press("Escape");
await page.locator(".new-session-page__message").fill("clone and inspect this project");
await page.getByRole("button", { name: "Start thread" }).click();
const create = await gateway.waitForRequest("sessions.create");
expect(create.params).toMatchObject({
agentId: "main",
cwd: "/home",
message: "clone and inspect this project",
});
expect(create.params).not.toHaveProperty("worktree");
expect(create.params).not.toHaveProperty("worktreeBaseRef");
} finally {
await context.close();
}
});
it("hides the destination axis when the Gateway is the only place", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const gateway = await installMockGateway(page, {
workspace: WORKSPACE,
workspaceGit: true,
methodResponses: {
"node.list": { nodes: [] },
"environments.list": { environments: [], profiles: [] },
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("node.list");
const trigger = page.locator("#new-session-place-trigger");
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("openclaw");
await trigger.click();
const place = page.locator("wa-popover.new-session-page__place-popover");
expect(await place.getByText("Places", { exact: true }).count()).toBe(0);
await place.getByText("Runs on Gateway · local", { exact: true }).waitFor();
} finally {
await context.close();
}
});
it("uses advertised system info for Gateway place labels", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const gateway = await installMockGateway(page, {
workspace: WORKSPACE,
workspaceGit: true,
featureMethods: ["chat.metadata", "chat.startup", "system.info"],
methodResponses: {
"system.info": {
machineName: "Peters-Mac-Studio",
hostname: "peters-mac-studio.local",
platform: "darwin",
},
"node.list": {
nodes: [
{
nodeId: "macbook",
displayName: "MacBook",
connected: true,
commands: ["system.run"],
},
],
},
"environments.list": { environments: [], profiles: [] },
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("system.info");
const trigger = page.locator("#new-session-place-trigger");
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe(
"openclaw · Gateway · Peters-Mac-Studio",
);
await trigger.click();
const place = page.locator("wa-popover.new-session-page__place-popover");
await place.getByRole("button", { name: "Gateway · Peters-Mac-Studio" }).waitFor();
await place.getByRole("button", { name: "Browse folders" }).click();
await expect
.poll(() =>
page.locator("input.new-session-page__browser-path").getAttribute("placeholder"),
)
.toBe("Gateway · Peters-Mac-Studio");
await gateway.setMethodResponse("node.list", { nodes: [] });
const nodeRequests = (await gateway.getRequests("node.list")).length;
await replaceGatewayClient(page);
await expect
.poll(async () => (await gateway.getRequests("node.list")).length)
.toBeGreaterThan(nodeRequests);
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("openclaw");
await trigger.click();
await place.getByText("Runs on Gateway · Peters-Mac-Studio", { exact: true }).waitFor();
} finally {
await context.close();
}
});
it("disambiguates duplicate node names without changing the selected chip", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const gateway = await installMockGateway(page, {
workspace: WORKSPACE,
workspaceGit: true,
methodResponses: {
"node.list": {
nodes: [
{
nodeId: "11111111aaaaaaaa",
displayName: "Mac Studio",
platform: "darwin",
modelIdentifier: "Mac14,12",
remoteIp: "192.168.1.11",
connected: true,
commands: ["system.run"],
},
{
nodeId: "22222222bbbbbbbb",
displayName: "Mac Studio",
platform: "darwin",
modelIdentifier: "Mac15,14",
remoteIp: "192.168.1.12",
connected: true,
commands: ["system.run"],
},
{
nodeId: "33333333cccccccc",
displayName: "iPhone",
platform: "iOS 26.4",
deviceFamily: "iPhone",
modelIdentifier: "iPhone17,2",
remoteIp: "192.168.1.30",
connected: true,
commands: ["system.run"],
},
],
},
"environments.list": { environments: [], profiles: [] },
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("node.list");
const trigger = page.locator("#new-session-place-trigger");
await trigger.click();
const first = page.locator('[data-value="node:11111111aaaaaaaa"]');
const second = page.locator('[data-value="node:22222222bbbbbbbb"]');
const phone = page.locator('[data-value="node:33333333cccccccc"]');
await pollLocatorText(first.locator(".session-menu__sub")).toBe("Mac14,12");
await pollLocatorText(second.locator(".session-menu__sub")).toBe("Mac15,14");
await pollLocatorText(phone.locator(".session-menu__text")).toBe("iPhone");
expect(await first.locator(".session-menu__icon svg").count()).toBe(1);
expect(await second.locator(".session-menu__icon svg").count()).toBe(1);
expect(await phone.locator(".session-menu__icon svg").count()).toBe(1);
expect(await first.getAttribute("title")).toBe("macOS · Mac14,12 · 192.168.1.11");
expect(await second.getAttribute("title")).toContain("192.168.1.12");
await second.click();
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe(
"Agent workspace · Mac Studio",
);
expect(await trigger.textContent()).not.toContain("Mac15,14");
expect(await trigger.textContent()).not.toContain("192.168.1.12");
} finally {
await context.close();
}
});
it("disambiguates duplicate recent basenames and applies the selected path", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const gateway = await installMockGateway(page, {
workspace: WORKSPACE,
workspaceGit: true,
methodResponses: {
"node.list": { nodes: [] },
"environments.list": { environments: [], profiles: [] },
"sessions.list": {
count: 2,
defaults: SESSION_LIST_DEFAULTS,
path: "",
sessions: [
{ key: "agent:main:a", kind: "direct", updatedAt: 2, execCwd: "/a/openclaw" },
{ key: "agent:main:b", kind: "direct", updatedAt: 1, execCwd: "/b/openclaw" },
],
ts: Date.now(),
},
"sessions.create": { key: "agent:main:recent-collision" },
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("node.list");
const trigger = page.locator("#new-session-place-trigger");
await trigger.click();
const first = page.locator('[data-value="recent::/a/openclaw"]');
const second = page.locator('[data-value="recent::/b/openclaw"]');
await pollLocatorText(first.locator(".session-menu__sub")).toBe("a");
await pollLocatorText(second.locator(".session-menu__sub")).toBe("b");
await second.click();
await page.locator(".new-session-page__message").fill("continue in work checkout");
await page.getByRole("button", { name: "Start thread" }).click();
const create = await gateway.waitForRequest("sessions.create");
expect(create.params).toMatchObject({
cwd: "/b/openclaw",
message: "continue in work checkout",
});
} finally {
await context.close();
}
});
it("applies a recent folder and node as one place", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const gateway = await installMockGateway(page, {
workspace: WORKSPACE,
workspaceGit: true,
methodResponses: {
"node.list": {
nodes: [
{
nodeId: "macbook",
displayName: "MacBook",
connected: true,
commands: ["system.run", "fs.listDir"],
},
],
},
"sessions.list": {
count: 2,
defaults: SESSION_LIST_DEFAULTS,
path: "",
sessions: [
{
key: "agent:main:recent-node",
kind: "direct",
updatedAt: 2,
execCwd: NODE_PICKED,
execNode: "macbook",
},
{
key: "agent:main:workspace",
kind: "direct",
updatedAt: 1,
execCwd: WORKSPACE,
},
],
ts: Date.now(),
},
"sessions.create": { key: "agent:main:recent-place" },
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("node.list");
const trigger = page.locator("#new-session-place-trigger");
await trigger.click();
await page
.locator("wa-popover.new-session-page__place-popover")
.getByRole("button", { name: "Projects · MacBook", exact: true })
.click();
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe(
"Projects · MacBook",
);
await page.locator(".new-session-page__message").fill("continue on the recent node");
await page.getByRole("button", { name: "Start thread" }).click();
const create = await gateway.waitForRequest("sessions.create");
expect(create.params).toMatchObject({
cwd: NODE_PICKED,
execNode: "macbook",
message: "continue on the recent node",
});
} finally {
await context.close();
}
});
it("returns from the browse root to the place menu", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const gateway = await installMockGateway(page, {
workspace: WORKSPACE,
workspaceGit: true,
methodResponses: {
"fs.listDir": {
path: WORKSPACE,
home: WORKSPACE,
entries: [],
},
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
const trigger = page.locator("#new-session-place-trigger");
const place = page.locator("wa-popover.new-session-page__place-popover");
await trigger.click();
await place.getByRole("button", { name: "Browse folders" }).click();
await gateway.waitForRequest("fs.listDir");
await place.getByRole("button", { name: "Parent folder" }).click();
await place.getByRole("button", { name: "Worktree" }).waitFor();
expect(await place.getAttribute("open")).not.toBeNull();
await place.getByRole("button", { name: "Browse folders" }).click();
await page.locator("input.new-session-page__browser-path").press("Escape");
await place.getByRole("button", { name: "Worktree" }).waitFor();
expect(await place.getAttribute("open")).not.toBeNull();
} finally {
await context.close();
}
});
it("browses capable nodes and accepts manual paths for exec-only nodes", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
workspaceGit: true,
methodResponses: {
"agents.list": {
agents: [
{
id: "main",
identity: { name: "Main" },
name: "Main",
workspace: WORKSPACE,
workspaceGit: true,
},
{
id: "research",
identity: { name: "Research" },
name: "Research",
workspace: "/home/peter/research",
workspaceGit: true,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
},
"node.list": {
nodes: [
{
nodeId: "macbook",
displayName: "MacBook",
connected: true,
commands: ["system.run", "fs.listDir"],
},
{
nodeId: "old-node",
displayName: "Old node",
connected: true,
commands: ["system.run"],
},
{
nodeId: "offline-node",
displayName: "Offline node",
connected: false,
commands: ["system.run", "fs.listDir"],
},
],
},
"fs.listDir": {
cases: [
{
match: { nodeId: "macbook", path: NODE_UNC },
response: {
path: NODE_UNC,
parent: "\\\\server\\share",
home: "C:\\Users\\peter",
entries: [],
},
},
{
match: { nodeId: "macbook", path: NODE_PICKED },
response: {
path: NODE_PICKED,
parent: NODE_HOME,
home: NODE_HOME,
entries: [],
},
},
{
match: { nodeId: "macbook" },
response: {
path: NODE_HOME,
home: NODE_HOME,
entries: [{ name: "Projects", path: NODE_PICKED }],
},
},
],
},
"sessions.create": { key: "agent:main:node-draft-e2e" },
"sessions.list": createdSessionListResult("agent:main:node-draft-e2e"),
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await page.locator(".new-session-page__message").waitFor();
const placeSelect = page.locator("wa-popover.new-session-page__place-popover");
const placeTrigger = page.locator("#new-session-place-trigger");
const placeLabel = placeTrigger.locator(".new-session-page__trigger-label");
const browserEntries = page.locator(".new-session-page__browser-list");
// Pick the node from Places.
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "MacBook" }).click();
await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook");
// Node sessions cannot use managed worktrees, so the menu drops the item.
await placeTrigger.click();
expect(await placeSelect.getByRole("button", { name: "Worktree" }).count()).toBe(0);
await page.keyboard.press("Escape");
// Manual path entry in the browser head preserves UNC paths; these
// cannot be rediscovered by starting at the node home directory.
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "Browse folders" }).click();
const pathInput = page.locator("input.new-session-page__browser-path");
await expect.poll(() => pathInput.inputValue()).toBe(NODE_HOME);
await pathInput.fill(NODE_UNC);
await pathInput.press("Enter");
await expect.poll(() => pathInput.inputValue()).toBe(NODE_UNC);
// Escape returns to the picker root without applying or closing it.
await page.keyboard.press("Escape");
await expect
.poll(() =>
placeSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open),
)
.toBe(true);
await placeSelect.getByText("Places", { exact: true }).waitFor();
// Destination selection stays in Places; browsing is fixed to the current target.
await placeSelect.getByRole("button", { name: "Gateway · local" }).click();
await pollLocatorText(placeLabel).toBe("openclaw · Gateway · local");
await placeTrigger.click();
expect(await placeSelect.getByRole("button", { name: "Offline node" }).count()).toBe(0);
await placeSelect.getByRole("button", { name: "MacBook" }).click();
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "Browse folders" }).click();
await browserEntries.getByRole("button", { name: "Projects" }).click();
await page.getByRole("button", { name: "Use this folder" }).click();
// Using a node folder retargets the draft to that node.
await pollLocatorText(placeLabel).toBe("Projects · MacBook");
// A node cwd belongs to the selected agent's draft and must not leak
// across an agent change, even though the execution node stays selected.
const agentPicker = page.locator(".new-session-page__select--agent openclaw-agent-select");
await agentPicker.locator(".agent-select__trigger").click();
await agentPicker
.locator("wa-dropdown-item[data-agent-option]")
.filter({ hasText: "Research" })
.click();
await page.getByRole("heading", { name: "Research" }).waitFor();
await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook");
// Clearing the path applies the node's default directory (empty folder),
// the state the replaced clearable folder textbox could express.
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "Browse folders" }).click();
await expect.poll(() => pathInput.inputValue()).toBe(NODE_HOME);
await pathInput.fill("");
await page.getByRole("button", { name: "Use this folder" }).click();
await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook");
// Browse back to the custom folder, then retarget to the exec-only node
// with a manual absolute path for the final create assertion.
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "Browse folders" }).click();
await browserEntries.getByRole("button", { name: "Projects" }).click();
await page.getByRole("button", { name: "Use this folder" }).click();
await pollLocatorText(placeLabel).toBe("Projects · MacBook");
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "Old node" }).click();
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "Browse folders" }).click();
await expect.poll(() => pathInput.inputValue()).toBe("");
await pathInput.fill(EXEC_ONLY_PICKED);
await pathInput.press("Enter");
expect(
(await gateway.getRequests("fs.listDir")).filter(
(request) => (request.params as { nodeId?: string } | undefined)?.nodeId === "old-node",
),
).toHaveLength(0);
await page.getByRole("button", { name: "Use this folder" }).click();
await pollLocatorText(placeLabel).toBe("repo · Old node");
await page.locator(".new-session-page__message").fill("inspect the remote checkout");
await page.getByRole("button", { name: "Start thread" }).click();
const createRequest = await gateway.waitForRequest("sessions.create");
expect(createRequest.params).toMatchObject({
agentId: "research",
message: "inspect the remote checkout",
execNode: "old-node",
cwd: EXEC_ONLY_PICKED,
});
expect(createRequest.params).not.toHaveProperty("worktree");
} finally {
await context.close();
}
});
});
@@ -0,0 +1,836 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import {
ONE_PIXEL_PNG_B64,
SESSION_LIST_DEFAULTS,
WORKSPACE,
captureUiProof,
captureUiProofEnabled,
controlUiSessionPath,
createNewSessionPageE2eSuite,
createdSessionListResult,
installMockGateway,
pastePng,
pollLocatorText,
reconnectProofArtifactDir,
} from "./new-session-page.test-support.ts";
const suite = createNewSessionPageE2eSuite();
suite.define(() => {
it("grows the first prompt through ten lines before using a narrow scrollbar", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page);
try {
await page.goto(`${suite.server.baseUrl}new`);
const message = page.locator(".new-session-page__message");
await message.waitFor();
const initial = await message.evaluate((element) => ({
height: element.clientHeight,
overflowY: getComputedStyle(element).overflowY,
}));
await message.fill(Array.from({ length: 10 }, (_, index) => `line ${index + 1}`).join("\n"));
const tenLines = await message.evaluate((element) => {
const style = getComputedStyle(element);
return {
height: element.clientHeight,
lineHeight: Number.parseFloat(style.lineHeight),
overflowY: style.overflowY,
padding: Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom),
scrollbarWidth: style.scrollbarWidth,
webkitScrollbarWidth: getComputedStyle(element, "::-webkit-scrollbar").width,
};
});
expect(tenLines.height).toBeGreaterThan(initial.height);
expect(tenLines.height).toBeGreaterThanOrEqual(
Math.floor(tenLines.lineHeight * 10 + tenLines.padding) - 1,
);
expect(tenLines.overflowY).toBe("hidden");
expect(tenLines.scrollbarWidth).toBe("thin");
expect(Number.parseFloat(tenLines.webkitScrollbarWidth)).toBeLessThanOrEqual(6);
await captureUiProof(page, "new-session-composer-ten-lines.png");
const longPrompt = Array.from({ length: 14 }, (_, index) => `line ${index + 1}`).join("\n");
await message.fill(longPrompt);
const capped = await message.evaluate((element) => ({
clientHeight: element.clientHeight,
overflowY: getComputedStyle(element).overflowY,
scrollHeight: element.scrollHeight,
}));
expect(capped.clientHeight).toBeLessThan(capped.scrollHeight);
expect(capped.overflowY).toBe("auto");
await captureUiProof(page, "new-session-composer-capped-scrollbar.png");
const start = page.getByRole("button", { name: "Start thread" });
await expect(start.isVisible()).resolves.toBe(true);
await expect(start.isEnabled()).resolves.toBe(true);
await start.focus();
await expect(start.evaluate((element) => document.activeElement === element)).resolves.toBe(
true,
);
await start.press("Enter");
await expect(gateway.waitForRequest("sessions.create")).resolves.toMatchObject({
params: { message: longPrompt },
});
} finally {
await context.close();
}
});
it("pastes an image into the draft and forwards it with the initial turn", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.create": { key: "agent:main:image-draft", runStarted: true },
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
const message = page.locator(".new-session-page__message");
await message.waitFor();
await pastePng(message);
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
await page.getByRole("button", { name: "Start thread" }).click();
const create = await gateway.waitForRequest("sessions.create");
expect(create.params).toMatchObject({
agentId: "main",
message: "",
attachments: [
{
type: "image",
mimeType: "image/png",
fileName: "pixel.png",
content: ONE_PIXEL_PNG_B64,
},
],
});
} finally {
await context.close();
}
});
it("shows the initial prompt while the newly created session is still running", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const sessionKey = "agent:main:visible-initial-prompt";
const message = "keep this prompt visible while the agent works";
const activeOutputTimestamp = Date.now() + 60_000;
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.create": { key: sessionKey, runStarted: true },
"sessions.list": createdSessionListResult(sessionKey),
"chat.startup": {
messages: [
{
role: "assistant",
content: [
{
type: "toolCall",
id: "active-tool-call",
name: "read",
arguments: { path: "SKILL.md" },
},
],
timestamp: activeOutputTimestamp,
__openclaw: { id: "active-assistant", seq: 2 },
},
{
role: "toolResult",
toolCallId: "active-tool-call",
toolName: "read",
content: [{ type: "text", text: "working" }],
timestamp: activeOutputTimestamp + 1,
__openclaw: { id: "active-tool-result", seq: 3 },
},
],
sessionId: "visible-initial-prompt",
sessionInfo: { hasActiveRun: true, key: sessionKey, status: "running" },
},
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await page.locator(".new-session-page__message").fill(message);
await page.getByRole("button", { name: "Start thread" }).click();
await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey), {
timeout: 30_000,
});
await gateway.waitForRequest("chat.startup");
await page.getByText("SKILL.md", { exact: true }).waitFor();
await pollLocatorText(page.locator(".chat-group.user")).toContain(message);
const userRow = await page.locator(".chat-group.user").boundingBox();
const toolRow = await page.getByText("SKILL.md", { exact: true }).boundingBox();
expect(userRow).not.toBeNull();
expect(toolRow).not.toBeNull();
if (!userRow || !toolRow) {
throw new Error("expected visible prompt and tool rows");
}
expect(userRow.y).toBeLessThan(toolRow.y);
} finally {
await context.close();
}
});
it("keeps the initial prompt visible across a Gateway reconnect", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const sessionKey = "agent:main:reconnected-initial-prompt";
const message = "keep this first prompt through reconnect";
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.create": { key: sessionKey, runStarted: true },
"sessions.list": createdSessionListResult(sessionKey),
"chat.startup": {
messages: [],
sessionId: "reconnected-initial-prompt",
sessionInfo: { hasActiveRun: true, key: sessionKey, status: "running" },
},
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await page.locator(".new-session-page__message").fill(message);
await page.getByRole("button", { name: "Start thread" }).click();
await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey), {
timeout: 30_000,
});
await gateway.waitForRequest("chat.startup");
await pollLocatorText(page.locator(".chat-group.user")).toContain(message);
const socketsBeforeReconnect = await gateway.getSocketCount();
await gateway.setOnline(false);
await expect
.poll(() => gateway.getSocketCount(), { timeout: 10_000 })
.toBeGreaterThan(socketsBeforeReconnect);
await gateway.setOnline(true);
await expect
.poll(() =>
page.evaluate(() => {
const app = document.querySelector("openclaw-app") as HTMLElement & {
runtime?: { context: { gateway: { snapshot: { phase: string } } } };
};
return app.runtime?.context.gateway.snapshot.phase;
}),
)
.toBe("connected");
await page.evaluate((selectedSessionKey) => {
const pane = document.querySelector("openclaw-chat-pane") as unknown as HTMLElement & {
state: { chatMessages: unknown[]; chatMessagesBySession: Map<string, unknown> };
switchPaneSession: (sessionKey: string) => void;
};
pane.state.chatMessages = [];
pane.state.chatMessagesBySession.clear();
pane.switchPaneSession("agent:main:temporary-session");
pane.switchPaneSession(selectedSessionKey);
}, sessionKey);
if (captureUiProofEnabled) {
await mkdir(reconnectProofArtifactDir, { recursive: true });
await page.screenshot({
path: path.join(reconnectProofArtifactDir, "reconnected-session.png"),
fullPage: true,
});
}
await pollLocatorText(page.locator(".chat-group.user")).toContain(message);
await expect.poll(() => page.locator(".chat-group.user").count()).toBe(1);
} finally {
await context.close();
}
});
it("reconciles an image-bearing initial prompt into one user row", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const sessionKey = "agent:main:single-image-prompt";
const message = "testing if dual prompts show";
const gateway = await installMockGateway(page, {
deferredMethods: ["chat.startup"],
methodResponses: {
"sessions.create": {
key: sessionKey,
runId: "initial-image-send",
runStarted: true,
messageSeq: 1,
},
"sessions.list": createdSessionListResult(sessionKey),
"chat.startup": {
messages: [
{
role: "user",
content: [
{
type: "image",
source: { type: "url", url: "/persisted-image.png" },
},
{ type: "text", text: message },
],
timestamp: Date.now(),
__openclaw: {
id: "persisted-image-prompt",
idempotencyKey: "initial-image-send:user",
seq: 1,
},
},
],
sessionId: "single-image-prompt",
sessionInfo: { hasActiveRun: true, key: sessionKey, status: "running" },
},
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
const composer = page.locator(".new-session-page__message");
await composer.fill(message);
await pastePng(composer);
await page.getByRole("button", { name: "Start thread" }).click();
await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey), {
timeout: 30_000,
});
await gateway.waitForRequest("chat.startup");
const userRow = page.locator(".chat-group.user");
const userImage = userRow.locator("img.chat-message-image");
await expect.poll(() => userRow.count()).toBe(1);
await expect.poll(() => userImage.count()).toBe(1);
await expect.poll(() => userImage.getAttribute("src")).toMatch(/^data:image\/png;base64,/u);
const initialImageSrc = await userImage.getAttribute("src");
await userImage.evaluate((image) => image.setAttribute("data-initial-image-node", "true"));
await pollLocatorText(userRow).toContain(message);
await pollLocatorText(userRow).not.toContain("Attached image");
await gateway.resolveDeferred("chat.startup");
await expect.poll(() => userRow.count()).toBe(1);
await expect.poll(() => userImage.count()).toBe(1);
await expect.poll(() => userImage.getAttribute("data-initial-image-node")).toBe("true");
await expect.poll(() => userImage.getAttribute("src")).toBe(initialImageSrc);
await pollLocatorText(userRow).toContain(message);
await pollLocatorText(userRow).not.toContain("Attached image");
} finally {
await context.close();
}
});
it("waits for pasted image reads before enabling session creation", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
await page.addInitScript(() => {
const readAsDataUrl = Object.getOwnPropertyDescriptor(FileReader.prototype, "readAsDataURL")
?.value as FileReader["readAsDataURL"];
FileReader.prototype.readAsDataURL = function (blob: Blob) {
(globalThis as unknown as { finishPastedImageRead?: () => void }).finishPastedImageRead =
() => readAsDataUrl.call(this, blob);
};
});
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.create": { key: "agent:main:delayed-image-draft", runStarted: true },
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
const composer = page.locator(".new-session-page__message");
const submit = page.getByRole("button", { name: "Start thread" });
await composer.fill("include the image that is still loading");
await pastePng(composer);
await expect.poll(() => submit.isDisabled()).toBe(true);
expect(await gateway.getRequests("sessions.create")).toHaveLength(0);
await page.evaluate(() => {
const finish = (globalThis as unknown as { finishPastedImageRead?: () => void })
.finishPastedImageRead;
if (!finish) {
throw new Error("Pasted image read was not started");
}
finish();
});
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
await expect.poll(() => submit.isEnabled()).toBe(true);
await submit.click();
const create = await gateway.waitForRequest("sessions.create");
expect(create.params).toMatchObject({
message: "include the image that is still loading",
attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }],
});
} finally {
await context.close();
}
});
it("releases a completed file when the rest of its pasted batch is aborted", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
await page.addInitScript(() => {
const readAsDataUrl = Object.getOwnPropertyDescriptor(FileReader.prototype, "readAsDataURL")
?.value as FileReader["readAsDataURL"];
let readCount = 0;
FileReader.prototype.readAsDataURL = function (blob: Blob) {
readCount += 1;
if (readCount === 1) {
readAsDataUrl.call(this, blob);
}
};
const createObjectURL = URL.createObjectURL.bind(URL);
const revokeObjectURL = URL.revokeObjectURL.bind(URL);
const proof = { created: 0, revoked: 0 };
(globalThis as unknown as { attachmentUrlProof: typeof proof }).attachmentUrlProof = proof;
URL.createObjectURL = (blob: Blob) => {
proof.created += 1;
return createObjectURL(blob);
};
URL.revokeObjectURL = (url: string) => {
proof.revoked += 1;
revokeObjectURL(url);
};
});
await installMockGateway(page);
try {
await page.goto(`${suite.server.baseUrl}new`);
const composer = page.locator(".new-session-page__message");
await pastePng(composer, 2);
await expect
.poll(() =>
page.evaluate(
() =>
(globalThis as unknown as { attachmentUrlProof: { created: number } })
.attachmentUrlProof.created,
),
)
.toBe(1);
await page.evaluate(() => {
const app = document.querySelector("openclaw-app") as HTMLElement & {
runtime?: { context: { navigate: (routeId: string) => void } };
};
app.runtime?.context.navigate("chat");
});
await page.waitForURL((url) => url.pathname.endsWith("/chat"));
await expect
.poll(() =>
page.evaluate(
() =>
(globalThis as unknown as { attachmentUrlProof: { revoked: number } })
.attachmentUrlProof.revoked,
),
)
.toBe(1);
} finally {
await context.close();
}
});
it("releases pasted image previews after remove, reset, disconnect, and success", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
await page.addInitScript(() => {
const createObjectURL = URL.createObjectURL.bind(URL);
const revokeObjectURL = URL.revokeObjectURL.bind(URL);
const proof = { created: 0, revoked: 0 };
(globalThis as unknown as { attachmentUrlProof: typeof proof }).attachmentUrlProof = proof;
URL.createObjectURL = (blob: Blob) => {
proof.created += 1;
return createObjectURL(blob);
};
URL.revokeObjectURL = (url: string) => {
proof.revoked += 1;
revokeObjectURL(url);
};
});
await installMockGateway(page, {
methodResponses: {
"agents.list": {
defaultId: "main",
mainKey: "main",
scope: "agent",
agents: [
{ id: "main", name: "Main" },
{ id: "writer", name: "Writer" },
],
},
"sessions.create": { key: "agent:main:preview-cleanup", runStarted: true },
},
});
const proof = () =>
page.evaluate(
() =>
(globalThis as unknown as { attachmentUrlProof: { created: number; revoked: number } })
.attachmentUrlProof,
);
const navigate = (routeId: string, search = "") =>
page.evaluate(
({ targetRouteId, targetSearch }) => {
const app = document.querySelector("openclaw-app") as HTMLElement & {
runtime?: {
context: {
navigate: (routeId: string, options?: { search?: string }) => void;
};
};
};
if (!app.runtime) {
throw new Error("OpenClaw application runtime is unavailable");
}
app.runtime.context.navigate(targetRouteId, { search: targetSearch });
},
{ targetRouteId: routeId, targetSearch: search },
);
try {
await page.goto(`${suite.server.baseUrl}new`);
const composer = page.locator(".new-session-page__message");
await pastePng(composer);
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
await page.getByRole("button", { name: "Remove attachment" }).click();
await expect.poll(async () => (await proof()).revoked).toBe(1);
await pastePng(composer);
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
const agentDropdown = page.locator(".new-session-page__select--agent wa-dropdown");
await page.locator(".new-session-page__select--agent .agent-select__trigger").click();
await expect
.poll(() =>
agentDropdown.evaluate((dropdown) => (dropdown as HTMLElement & { open: boolean }).open),
)
.toBe(true);
await navigate("new-session", "?agent=main&catalog=missing");
await expect
.poll(() =>
page.evaluate(
() =>
(
document.querySelector(".new-session-page__select--agent wa-dropdown") as
| (HTMLElement & { open: boolean })
| null
)?.open ?? false,
),
)
.toBe(false);
await expect.poll(() => page.locator(".chat-attachment-thumb").count()).toBe(0);
await expect.poll(async () => (await proof()).revoked).toBe(2);
await navigate("new-session");
await composer.waitFor();
await pastePng(composer);
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
await navigate("chat");
await page.waitForURL((url) => url.pathname.endsWith("/chat"));
await expect.poll(async () => (await proof()).revoked).toBe(3);
await navigate("new-session");
await composer.waitFor();
await pastePng(composer);
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
await page.getByRole("button", { name: "Start thread" }).click();
await page.waitForURL(
(url) => url.pathname === controlUiSessionPath("agent:main:preview-cleanup"),
);
await expect.poll(async () => await proof()).toEqual({ created: 4, revoked: 4 });
} finally {
await context.close();
}
});
it("locks the submitted draft until creation settles and restores it after failure", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const sessionKey = "agent:main:locked-new-session-draft";
const submittedMessage = "keep this submitted draft atomic";
const gateway = await installMockGateway(page, {
workspaceGit: true,
methodResponses: {
"agents.list": {
agents: [
{
id: "main",
identity: { name: "Main" },
name: "Main",
workspace: WORKSPACE,
workspaceGit: true,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
},
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
"sessions.list": {
count: 0,
defaults: SESSION_LIST_DEFAULTS,
path: "",
sessions: [],
ts: Date.now(),
},
"sessions.create": { key: sessionKey },
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.deferNext("sessions.create");
const draft = page.locator(".new-session-page__scroll");
const message = page.locator(".new-session-page__message");
const placeSelect = page.locator("wa-popover.new-session-page__place-popover");
const placeSummary = page.locator("#new-session-place-trigger");
await message.fill(submittedMessage);
await placeSummary.click();
expect(await placeSelect.getAttribute("open")).not.toBeNull();
await page.getByRole("button", { name: "Start thread" }).click();
const create = await gateway.waitForRequest("sessions.create");
expect(create.params).toMatchObject({ message: submittedMessage });
await expect.poll(() => message.isDisabled()).toBe(true);
expect(await draft.getAttribute("inert")).not.toBeNull();
expect(await draft.getAttribute("aria-busy")).toBe("true");
expect(await placeSelect.getAttribute("open")).toBeNull();
expect(await placeSummary.isDisabled()).toBe(true);
await expect(
message.fill("silently discarded late edit", { timeout: 250 }),
).rejects.toThrow();
await placeSummary.click({ force: true });
await page.locator(".agent-chat__suggestion").first().click({ force: true });
expect(await placeSelect.getAttribute("open")).toBeNull();
expect(await message.inputValue()).toBe(submittedMessage);
expect(await gateway.getRequests("sessions.create")).toHaveLength(1);
await gateway.rejectDeferred("sessions.create", {
code: "UNAVAILABLE",
message: "session creation unavailable",
});
await expect.poll(() => message.isDisabled()).toBe(false);
expect(await draft.getAttribute("inert")).toBeNull();
expect(await draft.getAttribute("aria-busy")).toBe("false");
expect(await message.inputValue()).toBe(submittedMessage);
expect(await placeSummary.isDisabled()).toBe(false);
await page.getByRole("button", { name: "Start thread" }).click();
await expect.poll(async () => (await gateway.getRequests("sessions.create")).length).toBe(2);
const retry = (await gateway.getRequests("sessions.create")).at(-1);
expect(retry?.params).toMatchObject({ message: submittedMessage });
await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey), {
timeout: 30_000,
});
} finally {
await context.close();
}
});
it("keeps a rejected first message visible and retryable after reload", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const sessionKey = "agent:main:rejected-first-message";
const message = "keep this rejected first message";
const runError = "send blocked by session policy";
const gateway = await installMockGateway(page, {
methodResponses: {
"agents.list": {
agents: [
{
id: "main",
identity: { name: "Main" },
name: "Main",
workspace: WORKSPACE,
workspaceGit: true,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
},
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
"sessions.list": {
count: 1,
defaults: SESSION_LIST_DEFAULTS,
path: "",
sessions: [
{
hasActiveRun: false,
key: sessionKey,
kind: "direct",
status: "done",
updatedAt: Date.now(),
},
],
ts: Date.now(),
},
"sessions.create": {
key: sessionKey,
runStarted: false,
runError: { code: "INVALID_REQUEST", message: runError },
},
"chat.history": {
messages: [],
sessionId: "rejected-first-message",
sessionInfo: { hasActiveRun: false, key: sessionKey, status: "done" },
},
"chat.send": { runId: "retry-run", status: "started" },
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
const composer = page.locator(".new-session-page__message");
await composer.fill(message);
await pastePng(composer);
await page.getByRole("button", { name: "Start thread" }).click();
const create = await gateway.waitForRequest("sessions.create");
expect(create.params).toMatchObject({
message,
attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }],
});
await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey), {
timeout: 30_000,
});
await expect
.poll(() => page.locator(".chat-queue__text").allInnerTexts(), { timeout: 30_000 })
.toContain(message);
await expect
.poll(() => page.locator(".chat-queue__error").allInnerTexts(), { timeout: 30_000 })
.toContain(runError);
await page.reload();
await expect
.poll(() => page.locator(".chat-queue__text").allInnerTexts(), { timeout: 30_000 })
.toContain(message);
await expect
.poll(() => page.locator(".chat-queue__error").allInnerTexts(), { timeout: 30_000 })
.toContain(runError);
await page.getByRole("button", { name: "Retry queued message" }).click();
const retry = await gateway.waitForRequest("chat.send");
expect(retry.params).toMatchObject({
sessionKey,
message,
attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }],
});
expect(await gateway.getRequests("sessions.create")).toHaveLength(0);
} finally {
await context.close();
}
});
it("adopts a created session when rejected-turn persistence exceeds browser storage", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
await page.addInitScript(() => {
const setItem = Object.getOwnPropertyDescriptor(Storage.prototype, "setItem")
?.value as Storage["setItem"];
Storage.prototype.setItem = function (key: string, value: string) {
if (key.startsWith("openclaw.control.chatComposer.v2:")) {
throw new DOMException("Quota exceeded", "QuotaExceededError");
}
return setItem.call(this, key, value);
};
});
const sessionKey = "agent:main:storage-failed-initial-turn";
const message = "retry this in the session that already exists";
const runError = "initial send rejected";
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.create": {
key: sessionKey,
runStarted: false,
runError: { code: "INVALID_REQUEST", message: runError },
},
"sessions.list": createdSessionListResult(sessionKey),
"chat.history": {
messages: [],
sessionId: "storage-failed-initial-turn",
sessionInfo: { hasActiveRun: false, key: sessionKey, status: "done" },
},
"chat.send": { runId: "storage-failure-retry", status: "started" },
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
const composer = page.locator(".new-session-page__message");
await composer.fill(message);
await pastePng(composer);
await page.getByRole("button", { name: "Start thread" }).click();
await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey), {
timeout: 30_000,
});
await expect
.poll(() => page.locator(".chat-queue__text").allInnerTexts(), { timeout: 30_000 })
.toContain(message);
await expect
.poll(() => page.locator(".chat-queue__error").allInnerTexts(), { timeout: 30_000 })
.toContain(runError);
await page.getByRole("button", { name: "Retry queued message" }).click();
const retry = await gateway.waitForRequest("chat.send");
expect(retry.params).toMatchObject({
sessionKey,
message,
attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }],
});
expect(await gateway.getRequests("sessions.create")).toHaveLength(1);
} finally {
await context.close();
}
});
});
+182
View File
@@ -0,0 +1,182 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Locator, Page } from "playwright";
import { expect } from "vitest";
import {
controlUiSessionPath,
controlUiSessionUrl,
installMockGateway,
} from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
export { controlUiSessionPath, controlUiSessionUrl, installMockGateway };
export const WORKSPACE = "/home/peter/openclaw";
export const PICKED = "/home/peter/openclaw/packages";
export const SOURCE_REPO = "/tmp/source-repo";
export const TARGET_REPO = "/tmp/target-repo";
export const REFRESHED_RESEARCH_WORKSPACE = "/home/peter/research-next";
export const MOVED_WORKSPACE = "/home/peter/openclaw-next";
export const NODE_HOME = "/Users/peter";
export const NODE_PICKED = "/Users/peter/Projects";
export const NODE_UNC = "\\\\server\\share\\repo";
export const EXEC_ONLY_PICKED = "C:\\Users\\peter\\repo";
const LOCATOR_TEXT_READ_TIMEOUT_MS = 500;
const LOCATOR_TEXT_POLL_TIMEOUT_MS = 10_000;
export const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const uiProofArtifactDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"cloud-worker-session",
);
export const reconnectProofArtifactDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"initial-prompt-reconnect",
);
export const ONE_PIXEL_PNG_B64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
export const SESSION_LIST_DEFAULTS = {
contextTokens: null,
model: "gpt-5.5",
modelProvider: "openai",
};
export function pollLocatorText(locator: Locator) {
return expect.poll(() => locator.textContent({ timeout: LOCATOR_TEXT_READ_TIMEOUT_MS }), {
timeout: LOCATOR_TEXT_POLL_TIMEOUT_MS,
});
}
export function createNewSessionPageE2eSuite() {
return createControlUiE2eSuite({
name: "Control UI new-session page mocked Gateway E2E",
unavailableMessage: (executablePath) =>
`Playwright Chromium is unavailable at ${executablePath}`,
});
}
export function createdSessionListResult(sessionKey: string) {
return {
count: 1,
defaults: SESSION_LIST_DEFAULTS,
path: "",
sessions: [
{
contextTokens: null,
displayName: "Created session",
key: sessionKey,
kind: "direct",
model: "gpt-5.5",
modelProvider: "openai",
totalTokens: 0,
updatedAt: Date.now(),
},
],
ts: Date.now(),
};
}
export async function captureUiProof(page: Page, fileName: string) {
if (!captureUiProofEnabled) {
return;
}
await mkdir(uiProofArtifactDir, { recursive: true });
await page.screenshot({
animations: "disabled",
fullPage: true,
path: path.join(uiProofArtifactDir, fileName),
});
}
export async function pastePng(target: Locator, count = 1) {
await target.evaluate(
(element, { base64, fileCount }) => {
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
const clipboard = new DataTransfer();
for (let index = 0; index < fileCount; index += 1) {
const fileName = fileCount === 1 ? "pixel.png" : `pixel-${index + 1}.png`;
clipboard.items.add(new File([bytes], fileName, { type: "image/png" }));
}
element.dispatchEvent(
new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: clipboard }),
);
},
{ base64: ONE_PIXEL_PNG_B64, fileCount: count },
);
}
export async function replaceGatewayClient(page: Page) {
await page.evaluate(() => {
const app = document.querySelector("openclaw-app") as HTMLElement & {
runtime?: { context: { gateway: { connect: () => void } } };
};
if (!app.runtime) {
throw new Error("OpenClaw application runtime is unavailable");
}
app.runtime.context.gateway.connect();
});
}
export async function navigateInApp(page: Page, routeId: string, search = "") {
await page.evaluate(
({ targetRouteId, targetSearch }) => {
const app = document.querySelector("openclaw-app") as HTMLElement & {
runtime?: {
context: {
navigate: (routeId: string, options?: { search?: string }) => void;
};
};
};
if (!app.runtime) {
throw new Error("OpenClaw application runtime is unavailable");
}
app.runtime.context.navigate(targetRouteId, { search: targetSearch });
},
{ targetRouteId: routeId, targetSearch: search },
);
}
/**
* Chat first pushes its base path and then commits the canonical session path.
* A repeated URL alone cannot prove that the router has finished, so require
* the successful active match and browser location to agree before leaving.
*/
export async function waitForCommittedChatRoute(page: Page) {
await page.waitForURL((url) => url.pathname.startsWith("/chat/"));
await expect
.poll(() =>
page.evaluate(() => {
const app = document.querySelector("openclaw-app") as HTMLElement & {
runtime?: {
router: {
getState: () => {
status: string;
resolvedLocation: { pathname: string } | null;
matches: { routeId: string }[];
pendingMatches: unknown[];
};
};
};
};
const state = app.runtime?.router.getState();
return (
state?.status === "success" &&
state.matches[0]?.routeId === "chat" &&
state.resolvedLocation?.pathname === window.location.pathname &&
state.pendingMatches.length === 0
);
}),
)
.toBe(true);
}
export async function choosePackagesFolder(page: Page) {
await page.locator("#new-session-place-trigger").click();
await page.getByRole("button", { name: "Browse folders" }).click();
await page.locator(".new-session-page__browser-entry", { hasText: "packages" }).click();
await page.getByRole("button", { name: "Use this folder" }).click();
}
@@ -0,0 +1,719 @@
import { expect, it } from "vitest";
import {
MOVED_WORKSPACE,
PICKED,
TARGET_REPO,
WORKSPACE,
captureUiProof,
choosePackagesFolder,
createNewSessionPageE2eSuite,
installMockGateway,
navigateInApp,
pollLocatorText,
waitForCommittedChatRoute,
} from "./new-session-page.test-support.ts";
const suite = createNewSessionPageE2eSuite();
suite.define(() => {
it("selects the model for a plain new session", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
models: [
{ id: "gpt-5.5", name: "GPT 5.5", provider: "openai" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", provider: "anthropic" },
],
methodResponses: {
"sessions.create": { key: "agent:main:model-draft", runStarted: true },
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
const modelSelect = page.locator('[data-chat-model-select="true"]');
await modelSelect.waitFor();
expect(
await page.locator('.new-session-page__composer [data-chat-model-select="true"]').count(),
).toBe(1);
expect(
await page.locator('.new-session-page__triggers [data-chat-model-select="true"]').count(),
).toBe(0);
await modelSelect.click();
const pickerOpen = () =>
modelSelect.evaluate(
(element) => element.closest("details")?.hasAttribute("open") ?? false,
);
const modelTriggerBox = await modelSelect.boundingBox();
const modelMenuBox = await page
.locator(".chat-controls__inline-select-menu--combined")
.boundingBox();
expect(modelTriggerBox).not.toBeNull();
expect(modelMenuBox).not.toBeNull();
expect(modelMenuBox?.x ?? 0).toBeLessThan(modelTriggerBox?.x ?? 0);
expect((modelMenuBox?.x ?? 0) + (modelMenuBox?.width ?? 0)).toBeCloseTo(
(modelTriggerBox?.x ?? 0) + (modelTriggerBox?.width ?? 0),
0,
);
await page.locator('[data-chat-model-provider="anthropic"]').click();
await expect.poll(pickerOpen).toBe(true);
await page.locator('[data-chat-model-option="anthropic/claude-sonnet-4-6"]').click();
// Inside changes stay grouped; only explicit light-dismissal closes the picker.
await expect.poll(pickerOpen).toBe(true);
await page.keyboard.press("Escape");
await expect.poll(pickerOpen).toBe(false);
await expect
.poll(() => modelSelect.evaluate((element) => element === document.activeElement))
.toBe(true);
await modelSelect.click();
await expect.poll(pickerOpen).toBe(true);
await page.mouse.click(8, 8);
await expect.poll(pickerOpen).toBe(false);
await page.locator(".new-session-page__message").fill("use this model");
await page.getByRole("button", { name: "Start thread" }).click();
const create = await gateway.waitForRequest("sessions.create");
expect(create.params).toMatchObject({
message: "use this model",
model: "anthropic/claude-sonnet-4-6",
});
} finally {
await context.close();
}
});
it("restores valid preferences and repairs a worktree rejected by workspace metadata", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
workspaceGit: true,
models: [
{ id: "gpt-5.5", name: "GPT 5.5", provider: "openai" },
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
provider: "anthropic",
},
],
methodResponses: {
"agents.list": {
agents: [
{
id: "main",
identity: { name: "Main" },
name: "Main",
workspace: WORKSPACE,
workspaceGit: true,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
},
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
"fs.listDir": {
cases: [
{
match: { path: WORKSPACE },
response: {
path: WORKSPACE,
parent: "/home/peter",
home: "/home/peter",
entries: [{ name: "packages", path: PICKED }],
},
},
{
match: { path: PICKED },
response: {
path: PICKED,
parent: WORKSPACE,
home: "/home/peter",
entries: [],
},
},
],
},
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
const placeTrigger = page.locator("#new-session-place-trigger");
await choosePackagesFolder(page);
await placeTrigger.click();
await page.getByRole("button", { name: "Worktree" }).click();
await page.keyboard.press("Escape");
const modelSelect = page.locator('[data-chat-model-select="true"]');
await modelSelect.click();
await page.locator('[data-chat-model-provider="anthropic"]').click();
await page.locator('[data-chat-model-option="anthropic/claude-sonnet-4-6"]').click();
const thinkingSlider = page.locator('[data-chat-thinking-slider="true"]');
await thinkingSlider.press("End");
await expect.poll(() => modelSelect.getAttribute("data-chat-thinking-value")).toBe("high");
await page.goto(`${suite.server.baseUrl}new`);
await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe(
"packages",
);
await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("true");
await expect
.poll(() => modelSelect.getAttribute("data-chat-select-value"))
.toBe("anthropic/claude-sonnet-4-6");
await expect.poll(() => modelSelect.getAttribute("data-chat-thinking-value")).toBe("high");
await captureUiProof(page, "new-session-preferences-restored.png");
const branchRequests = await gateway.getRequests("worktrees.branches");
expect(branchRequests.at(-1)?.params).toMatchObject({ repoRoot: PICKED });
await page.evaluate((workspace) => {
const key = Array.from({ length: localStorage.length }, (_, index) =>
localStorage.key(index),
).find((candidate) => candidate?.startsWith("openclaw.new-session.preferences.v1:"));
if (!key) {
throw new Error("missing new-session preference");
}
const value = JSON.parse(localStorage.getItem(key) ?? "null") as {
agents?: Record<string, { folder?: string; workspace?: string }>;
};
const main = value.agents?.main;
if (!main) {
throw new Error("missing main-agent preference");
}
main.folder = workspace;
main.workspace = workspace;
localStorage.setItem(key, JSON.stringify(value));
}, WORKSPACE);
await gateway.setMethodResponse("agents.list", {
agents: [
{
id: "main",
identity: { name: "Main" },
name: "Main",
workspace: WORKSPACE,
workspaceGit: false,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
});
await page.reload();
await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("false");
const storedWorktree = await page.evaluate(() => {
const key = Array.from({ length: localStorage.length }, (_, index) =>
localStorage.key(index),
).find((candidate) => candidate?.startsWith("openclaw.new-session.preferences.v1:"));
const value = key
? (JSON.parse(localStorage.getItem(key) ?? "null") as {
agents?: Record<string, { worktree?: boolean }>;
} | null)
: null;
return value?.agents?.main?.worktree;
});
expect(storedWorktree).toBe(false);
} finally {
await context.close();
}
});
it("blocks an immediate submit until remembered model and worktree choices validate", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const models = [
{ id: "gpt-5.5", name: "GPT 5.5", provider: "openai" },
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
provider: "anthropic",
},
];
const branches = {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
};
const gateway = await installMockGateway(page, {
workspaceGit: true,
models,
methodResponses: {
"agents.list": {
agents: [
{
id: "main",
identity: { name: "Main" },
name: "Main",
workspace: WORKSPACE,
workspaceGit: true,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
},
"worktrees.branches": branches,
"fs.listDir": {
cases: [
{
match: { path: WORKSPACE },
response: {
path: WORKSPACE,
parent: "/home/peter",
home: "/home/peter",
entries: [{ name: "packages", path: PICKED }],
},
},
{
match: { path: PICKED },
response: { path: PICKED, parent: WORKSPACE, home: "/home/peter", entries: [] },
},
],
},
"sessions.create": { key: "agent:main:restored-fast-submit", runStarted: true },
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await choosePackagesFolder(page);
const placeTrigger = page.locator("#new-session-place-trigger");
await placeTrigger.click();
await page.getByRole("button", { name: "Worktree" }).click();
await page.keyboard.press("Escape");
const modelSelect = page.locator('[data-chat-model-select="true"]');
await modelSelect.click();
await page.locator('[data-chat-model-provider="anthropic"]').click();
await page.locator('[data-chat-model-option="anthropic/claude-sonnet-4-6"]').click();
await navigateInApp(page, "chat");
await waitForCommittedChatRoute(page);
const metadataRequests = (await gateway.getRequests("chat.metadata")).length;
const branchRequests = (await gateway.getRequests("worktrees.branches")).length;
await gateway.deferNext("chat.metadata");
await gateway.deferNext("worktrees.branches");
await navigateInApp(page, "new-session");
await expect.poll(() => new URL(page.url()).pathname).toBe("/new");
await expect
.poll(async () => (await gateway.getRequests("chat.metadata")).length)
.toBe(metadataRequests + 1);
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).length)
.toBe(branchRequests + 1);
await page.locator(".new-session-page__message").fill("keep both remembered choices");
const start = page.getByRole("button", { name: "Start thread" });
await expect.poll(() => start.isDisabled()).toBe(true);
await gateway.resolveDeferred("chat.metadata", { models });
await expect.poll(() => start.isDisabled()).toBe(true);
await gateway.rejectDeferred("worktrees.branches", {
code: "UNAVAILABLE",
message: "branch lookup unavailable",
});
// A failed lookup disables the worktree toggle, so restoring the stored
// choice would strand the draft behind a control the user cannot clear.
// The draft drops it and stays submittable; storage keeps the preference.
await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("false");
await expect.poll(() => start.isDisabled()).toBe(false);
await page.reload();
await page.locator(".new-session-page__message").fill("keep both remembered choices");
await expect
.poll(() => modelSelect.getAttribute("data-chat-select-value"))
.toBe("anthropic/claude-sonnet-4-6");
await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("true");
await expect.poll(() => start.isDisabled()).toBe(false);
await start.click();
const create = await gateway.waitForRequest("sessions.create");
expect(create.params).toMatchObject({
cwd: PICKED,
message: "keep both remembered choices",
model: "anthropic/claude-sonnet-4-6",
worktree: true,
});
} finally {
await context.close();
}
});
it("repairs a remembered default folder when the agent workspace moves", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const gateway = await installMockGateway(page, {
workspaceGit: true,
models: [
{ id: "gpt-5.5", name: "GPT 5.5", provider: "openai" },
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
provider: "anthropic",
},
],
methodResponses: {
"agents.list": {
agents: [
{
id: "main",
identity: { name: "Main" },
name: "Main",
workspace: WORKSPACE,
workspaceGit: true,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
},
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
"fs.listDir": {
path: WORKSPACE,
parent: "/home/peter",
home: "/home/peter",
entries: [],
},
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
const placeTrigger = page.locator("#new-session-place-trigger");
await placeTrigger.click();
await page.getByRole("button", { name: "Browse folders" }).click();
await page.getByRole("button", { name: "Use this folder" }).click();
await navigateInApp(page, "chat");
await page.waitForURL((url) => url.pathname.endsWith("/chat"));
await gateway.setMethodResponse("agents.list", {
agents: [
{
id: "main",
identity: { name: "Main" },
name: "Main",
workspace: MOVED_WORKSPACE,
workspaceGit: true,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
});
await page.reload();
await navigateInApp(page, "new-session");
await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe(
"openclaw-next",
);
const modelSelect = page.locator('[data-chat-model-select="true"]');
await modelSelect.click();
await page.locator('[data-chat-model-provider="anthropic"]').click();
await page.locator('[data-chat-model-option="anthropic/claude-sonnet-4-6"]').click();
const storedPreference = await page.evaluate(() => {
const key = Array.from({ length: localStorage.length }, (_, index) =>
localStorage.key(index),
).find((candidate) => candidate?.startsWith("openclaw.new-session.preferences.v1:"));
if (!key) {
return null;
}
const value = JSON.parse(localStorage.getItem(key) ?? "null") as {
agents?: Record<string, unknown>;
} | null;
return value?.agents?.main ?? null;
});
expect(storedPreference).toMatchObject({
workspace: MOVED_WORKSPACE,
folder: MOVED_WORKSPACE,
model: "anthropic/claude-sonnet-4-6",
});
} finally {
await context.close();
}
});
it("falls back to the current workspace when the remembered folder is gone", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const gateway = await installMockGateway(page, {
workspace: WORKSPACE,
workspaceGit: true,
methodResponses: {
"agents.list": {
agents: [
{
id: "main",
identity: { name: "Main" },
name: "Main",
workspace: WORKSPACE,
workspaceGit: true,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
},
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
"fs.listDir": {
cases: [
{
match: { path: WORKSPACE },
response: {
path: WORKSPACE,
parent: "/home/peter",
home: "/home/peter",
entries: [{ name: "packages", path: PICKED }],
},
},
{
match: { path: PICKED },
response: { path: PICKED, parent: WORKSPACE, home: "/home/peter", entries: [] },
},
],
},
"sessions.create": { key: "agent:main:stale-folder-fallback", runStarted: true },
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await choosePackagesFolder(page);
await navigateInApp(page, "chat");
await waitForCommittedChatRoute(page);
const validationRequests = (await gateway.getRequests("fs.listDir")).length;
await gateway.deferNext("fs.listDir");
await navigateInApp(page, "new-session");
await expect
.poll(async () => (await gateway.getRequests("fs.listDir")).length)
.toBeGreaterThan(validationRequests);
const message = page.locator(".new-session-page__message");
await message.fill("use a safe folder");
await expect
.poll(() => page.getByRole("button", { name: "Start thread" }).isDisabled())
.toBe(true);
await gateway.rejectDeferred("fs.listDir", {
code: "INVALID_REQUEST",
message: `Error: ENOENT: no such file or directory, scandir '${PICKED}'`,
});
const placeTrigger = page.locator("#new-session-place-trigger");
await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe(
"openclaw",
);
const pickedListRequests = (await gateway.getRequests("fs.listDir")).filter(
(request) =>
typeof request.params === "object" &&
request.params != null &&
"path" in request.params &&
request.params.path === PICKED,
).length;
await navigateInApp(page, "chat");
await waitForCommittedChatRoute(page);
await navigateInApp(page, "new-session");
await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe(
"openclaw",
);
await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("false");
await expect
.poll(
async () =>
(await gateway.getRequests("fs.listDir")).filter(
(request) =>
typeof request.params === "object" &&
request.params != null &&
"path" in request.params &&
request.params.path === PICKED,
).length,
)
.toBe(pickedListRequests);
await message.fill("use the repaired preference");
await expect
.poll(() => page.getByRole("button", { name: "Start thread" }).isDisabled())
.toBe(false);
await page.getByRole("button", { name: "Start thread" }).click();
const create = await gateway.waitForRequest("sessions.create");
expect(create.params).not.toHaveProperty("cwd");
} finally {
await context.close();
}
});
it("keeps a newer folder choice when remembered-folder validation finishes late", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const gateway = await installMockGateway(page, {
workspace: WORKSPACE,
workspaceGit: true,
methodResponses: {
"agents.list": {
agents: [
{
id: "main",
identity: { name: "Main" },
name: "Main",
workspace: WORKSPACE,
workspaceGit: true,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
},
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
"fs.listDir": {
cases: [
{
match: { path: WORKSPACE },
response: {
path: WORKSPACE,
parent: "/home/peter",
home: "/home/peter",
entries: [{ name: "packages", path: PICKED }],
},
},
{
match: { path: PICKED },
response: { path: PICKED, parent: WORKSPACE, home: "/home/peter", entries: [] },
},
],
},
"sessions.create": { key: "agent:main:newer-folder-wins", runStarted: true },
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await choosePackagesFolder(page);
await navigateInApp(page, "chat");
await waitForCommittedChatRoute(page);
const validationRequests = (await gateway.getRequests("fs.listDir")).length;
await gateway.deferNext("fs.listDir");
await navigateInApp(page, "new-session");
await expect
.poll(async () => (await gateway.getRequests("fs.listDir")).length)
.toBeGreaterThan(validationRequests);
const placeTrigger = page.locator("#new-session-place-trigger");
await placeTrigger.click();
await page
.locator('wa-popover.new-session-page__place-popover [data-value="workspace"]')
.click();
await gateway.resolveDeferred("fs.listDir", {
path: PICKED,
parent: WORKSPACE,
home: "/home/peter",
entries: [],
});
await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe(
"openclaw",
);
await page.locator(".new-session-page__message").fill("keep the newer choice");
await page.getByRole("button", { name: "Start thread" }).click();
const create = await gateway.waitForRequest("sessions.create");
expect(create.params).not.toHaveProperty("cwd");
} finally {
await context.close();
}
});
it("keeps a folder chosen before the agent roster finishes loading submit-ready", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const gateway = await installMockGateway(page, {
deferredMethods: ["agents.list"],
workspaceGit: true,
methodResponses: {
"agents.list": {
agents: [
{
id: "main",
identity: { name: "Main" },
name: "Main",
workspace: WORKSPACE,
workspaceGit: true,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
},
"fs.listDir": { path: TARGET_REPO, home: "/home/peter", entries: [] },
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
const trigger = page.locator("#new-session-place-trigger");
await trigger.click();
await page.getByRole("button", { name: "Browse folders" }).click();
const browserPath = page.locator("input.new-session-page__browser-path");
// Resolve the browser listing without resolving the deferred agent roster;
// otherwise its initial path update can race Playwright's folder input.
await expect.poll(() => browserPath.inputValue()).toBe(TARGET_REPO);
await browserPath.fill(TARGET_REPO);
await page.getByRole("button", { name: "Use this folder" }).click();
await gateway.resolveDeferred("agents.list", {
agents: [
{
id: "main",
identity: { name: "Main" },
name: "Main",
workspace: WORKSPACE,
workspaceGit: true,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
});
await page.locator(".new-session-page__message").fill("keep my early folder choice");
await expect
.poll(() => page.getByRole("button", { name: "Start thread" }).isDisabled())
.toBe(false);
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toContain(
"target-repo",
);
const storedPreference = await page.evaluate(() => {
const key = Array.from({ length: localStorage.length }, (_, index) =>
localStorage.key(index),
).find((candidate) => candidate?.startsWith("openclaw.new-session.preferences.v1:"));
if (!key) {
return null;
}
const value = JSON.parse(localStorage.getItem(key) ?? "null") as {
agents?: Record<string, unknown>;
} | null;
return value?.agents?.main ?? null;
});
expect(storedPreference).toMatchObject({ folder: TARGET_REPO });
} finally {
await context.close();
}
});
});
@@ -0,0 +1,657 @@
import { expect, it } from "vitest";
import {
SOURCE_REPO,
TARGET_REPO,
WORKSPACE,
createNewSessionPageE2eSuite,
installMockGateway,
pollLocatorText,
replaceGatewayClient,
} from "./new-session-page.test-support.ts";
const suite = createNewSessionPageE2eSuite();
suite.define(() => {
it("preserves a selected workspace worktree when branch rediscovery is unavailable", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const gateway = await installMockGateway(page, {
workspace: WORKSPACE,
workspaceGit: true,
methodResponses: {
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
"sessions.create": { key: "agent:main:worktree-unavailable" },
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("worktrees.branches");
const trigger = page.locator("#new-session-place-trigger");
const place = page.locator("wa-popover.new-session-page__place-popover");
await trigger.click();
await place.getByRole("button", { name: "Worktree" }).click();
await page.keyboard.press("Escape");
await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true");
const branchRequests = (await gateway.getRequests("worktrees.branches")).length;
await gateway.deferNext("worktrees.branches");
await gateway.setOnline(false);
await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 });
await gateway.setOnline(true);
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).length)
.toBe(branchRequests + 1);
await gateway.rejectDeferred("worktrees.branches", {
code: "UNAVAILABLE",
message: "branch lookup unavailable",
});
await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true");
await trigger.click();
const worktree = place.getByRole("button", { name: "Worktree" });
await expect.poll(() => worktree.getAttribute("aria-pressed")).toBe("true");
expect(await worktree.isEnabled()).toBe(true);
await page.keyboard.press("Escape");
await page.locator(".new-session-page__message").fill("keep this task isolated");
await page.getByRole("button", { name: "Start thread" }).click();
const create = await gateway.waitForRequest("sessions.create");
expect(create.params).toMatchObject({
agentId: "main",
message: "keep this task isolated",
worktree: true,
});
} finally {
await context.close();
}
});
it("clears a custom worktree when the folder becomes confirmed non-Git", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const gateway = await installMockGateway(page, {
workspace: WORKSPACE,
workspaceGit: true,
methodResponses: {
"fs.listDir": { path: WORKSPACE, home: "/home/peter", entries: [] },
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
"sessions.create": { key: "agent:main:custom-now-direct" },
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
const trigger = page.locator("#new-session-place-trigger");
const place = page.locator("wa-popover.new-session-page__place-popover");
await trigger.click();
await place.getByRole("button", { name: "Browse folders" }).click();
await page.locator("input.new-session-page__browser-path").fill(TARGET_REPO);
await page.getByRole("button", { name: "Use this folder" }).click();
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).at(-1)?.params)
.toEqual({ repoRoot: TARGET_REPO, includeRepositoryStatus: true });
await trigger.click();
await place.getByRole("button", { name: "Worktree" }).click();
await page.keyboard.press("Escape");
await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true");
await gateway.setMethodResponse("worktrees.branches", {
branches: [],
repositoryStatus: "not_git",
});
const branchRequests = (await gateway.getRequests("worktrees.branches")).length;
await gateway.setOnline(false);
await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 });
await gateway.setOnline(true);
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).length)
.toBe(branchRequests + 1);
await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("false");
const storedWorktree = await page.evaluate(() => {
const key = Array.from({ length: localStorage.length }, (_, index) =>
localStorage.key(index),
).find((candidate) => candidate?.startsWith("openclaw.new-session.preferences.v1:"));
const value = key
? (JSON.parse(localStorage.getItem(key) ?? "null") as {
agents?: Record<string, { worktree?: boolean }>;
} | null)
: null;
return value?.agents?.main?.worktree;
});
expect(storedWorktree).toBe(false);
await trigger.click();
expect(await place.getByRole("button", { name: "Worktree" }).count()).toBe(0);
await page.keyboard.press("Escape");
await page.locator(".new-session-page__message").fill("continue directly");
await page.getByRole("button", { name: "Start thread" }).click();
const create = await gateway.waitForRequest("sessions.create");
expect(create.params).toMatchObject({ cwd: TARGET_REPO, message: "continue directly" });
expect(create.params).not.toHaveProperty("worktree");
} finally {
await context.close();
}
});
it("blocks a custom worktree when Git rediscovery is unavailable", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const gateway = await installMockGateway(page, {
workspace: WORKSPACE,
workspaceGit: true,
methodResponses: {
"fs.listDir": { path: WORKSPACE, home: "/home/peter", entries: [] },
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
const trigger = page.locator("#new-session-place-trigger");
const place = page.locator("wa-popover.new-session-page__place-popover");
await trigger.click();
await place.getByRole("button", { name: "Browse folders" }).click();
await page.locator("input.new-session-page__browser-path").fill(TARGET_REPO);
await page.getByRole("button", { name: "Use this folder" }).click();
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).at(-1)?.params)
.toEqual({ repoRoot: TARGET_REPO, includeRepositoryStatus: true });
await trigger.click();
await place.getByRole("button", { name: "Worktree" }).click();
await page.keyboard.press("Escape");
await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true");
await gateway.setMethodResponse("worktrees.branches", {
branches: [],
repositoryStatus: "unavailable",
});
const branchRequests = (await gateway.getRequests("worktrees.branches")).length;
await gateway.setOnline(false);
await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 });
await gateway.setOnline(true);
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).length)
.toBe(branchRequests + 1);
await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true");
await page.locator(".new-session-page__message").fill("do not run directly");
const start = page.getByRole("button", { name: "Start thread" });
await expect.poll(() => start.isDisabled()).toBe(true);
await trigger.click();
const worktree = place.getByRole("button", { name: "Worktree" });
expect(await worktree.isDisabled()).toBe(true);
expect(await worktree.getAttribute("title")).toBe(
"Couldn't verify Git for this folder. Choose it again to retry.",
);
expect(await gateway.getRequests("sessions.create")).toHaveLength(0);
} finally {
await context.close();
}
});
it("blocks a custom cloud worktree when Git rediscovery is unavailable", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const gateway = await installMockGateway(page, {
workspace: WORKSPACE,
workspaceGit: true,
methodResponses: {
"environments.list": {
environments: [],
profiles: [{ id: "aws", providerId: "crabbox" }],
},
"fs.listDir": { path: WORKSPACE, home: "/home/peter", entries: [] },
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("environments.list");
const trigger = page.locator("#new-session-place-trigger");
const place = page.locator("wa-popover.new-session-page__place-popover");
await trigger.click();
await place.getByRole("button", { name: "Browse folders" }).click();
await page.locator("input.new-session-page__browser-path").fill(TARGET_REPO);
await page.getByRole("button", { name: "Use this folder" }).click();
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).at(-1)?.params)
.toEqual({ repoRoot: TARGET_REPO, includeRepositoryStatus: true });
await trigger.click();
await place.getByRole("button", { name: "Cloud · aws" }).click();
await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws");
await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true");
await gateway.setMethodResponse("worktrees.branches", {
branches: [],
repositoryStatus: "unavailable",
});
const branchRequests = (await gateway.getRequests("worktrees.branches")).length;
await gateway.setOnline(false);
await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 });
await gateway.setOnline(true);
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).length)
.toBe(branchRequests + 1);
await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws");
await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true");
await page.locator(".new-session-page__message").fill("do not run directly");
const start = page.getByRole("button", { name: "Start thread" });
await expect.poll(() => start.isDisabled()).toBe(true);
await trigger.click();
const cloud = place.getByRole("button", { name: "Cloud · aws" });
expect(await cloud.isDisabled()).toBe(true);
expect(await cloud.getAttribute("title")).toBe(
"Couldn't verify Git for this folder. Choose it again to retry.",
);
expect(await gateway.getRequests("sessions.create")).toHaveLength(0);
} finally {
await context.close();
}
});
it("validates a retained device before enabling submit after reconnect", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: {
"agents.list": {
agents: [
{
id: "main",
identity: { name: "Main" },
name: "Main",
workspace: WORKSPACE,
workspaceGit: true,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
},
"node.list": {
nodes: [
{
nodeId: "old-device",
displayName: "Old device",
connected: true,
commands: ["system.run", "fs.listDir"],
},
],
},
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
"sessions.create": { key: "agent:main:validated-device" },
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("node.list");
const placeSelect = page.locator("wa-popover.new-session-page__place-popover");
await page.locator("#new-session-place-trigger").click();
await placeSelect.getByRole("button", { name: "Old device" }).click();
await page.locator(".new-session-page__message").fill("use a validated device");
const start = page.locator("button.chat-send-btn");
const nodeRequestsBefore = (await gateway.getRequests("node.list")).length;
await gateway.setOnline(false);
await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 });
await gateway.deferNext("node.list");
await gateway.setOnline(true);
await expect
.poll(async () => (await gateway.getRequests("node.list")).length)
.toBe(nodeRequestsBefore + 1);
await expect.poll(() => start.isDisabled()).toBe(true);
expect(await gateway.getRequests("sessions.create")).toHaveLength(0);
await gateway.resolveDeferred("node.list", { nodes: [] });
await expect.poll(() => start.isEnabled()).toBe(true);
await start.click();
const create = await gateway.waitForRequest("sessions.create");
expect(create.params).not.toHaveProperty("execNode");
expect(create.params).not.toHaveProperty("cwd");
} finally {
await context.close();
}
});
it("rediscovers Gateway-owned draft state when the app replaces its client", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: {
"agents.list": {
agents: [
{
id: "main",
identity: { name: "Original agent" },
name: "Original agent",
workspace: SOURCE_REPO,
workspaceGit: true,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
},
"node.list": {
nodes: [
{
nodeId: "old-device",
displayName: "Old device",
connected: true,
commands: ["system.run", "fs.listDir"],
},
],
},
"worktrees.branches": {
branches: [{ kind: "local", name: "alpha" }],
defaultBranch: "alpha",
repositoryStatus: "git",
},
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await page.getByRole("heading", { name: "Original agent" }).waitFor();
await gateway.waitForRequest("node.list");
await gateway.waitForRequest("worktrees.branches");
const message = page.locator(".new-session-page__message");
const placeSelect = page.locator("wa-popover.new-session-page__place-popover");
const placeTrigger = page.locator("#new-session-place-trigger");
await message.fill("preserve this replacement draft");
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "Old device" }).click();
// Keep an old-client browser request in flight. Replacement must close
// its menu and prevent its eventual completion from reviving old state.
await gateway.deferNext("fs.listDir");
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "Browse folders" }).click();
await gateway.waitForRequest("fs.listDir");
await gateway.setMethodResponse("agents.list", {
agents: [
{
id: "main",
identity: { name: "Replacement agent" },
name: "Replacement agent",
workspace: TARGET_REPO,
workspaceGit: true,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
});
await gateway.setMethodResponse("node.list", {
nodes: [
{
nodeId: "new-device",
displayName: "New device",
connected: true,
commands: ["system.run", "fs.listDir"],
},
],
});
await gateway.setMethodResponse("worktrees.branches", {
branches: [{ kind: "local", name: "beta" }],
defaultBranch: "beta",
repositoryStatus: "git",
});
const socketsBefore = await gateway.getSocketCount();
const nodesBefore = (await gateway.getRequests("node.list")).length;
const branchesBefore = (await gateway.getRequests("worktrees.branches")).length;
await replaceGatewayClient(page);
await expect.poll(() => gateway.getSocketCount()).toBe(socketsBefore + 1);
await expect
.poll(async () => (await gateway.getRequests("node.list")).length)
.toBe(nodesBefore + 1);
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).length)
.toBe(branchesBefore + 1);
await page.getByRole("heading", { name: "Replacement agent" }).waitFor();
await expect.poll(() => message.inputValue()).toBe("preserve this replacement draft");
await expect
.poll(() =>
placeSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open),
)
.toBe(false);
await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe(
"target-repo · Gateway · local",
);
const branchRequests = await gateway.getRequests("worktrees.branches");
expect(branchRequests.at(-1)?.params).toEqual({
repoRoot: TARGET_REPO,
includeRepositoryStatus: true,
});
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "New device" }).waitFor();
expect(await placeSelect.getByRole("button", { name: "Old device" }).count()).toBe(0);
await placeSelect.getByRole("button", { name: "Worktree" }).click();
await expect.poll(() => page.getByLabel("Base branch").inputValue()).toBe("beta");
await page.keyboard.press("Escape");
await gateway.resolveDeferred("fs.listDir", {
path: "/stale-device-path",
home: "/stale-device-path",
entries: [],
});
await expect
.poll(() =>
placeSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open),
)
.toBe(false);
await expect.poll(() => message.inputValue()).toBe("preserve this replacement draft");
} finally {
await context.close();
}
});
for (const reconnectKind of ["same-client reconnect", "client replacement"] as const) {
it(`marks a pending creation outcome unknown after ${reconnectKind}`, async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const sessionKey = `agent:main:unknown-${reconnectKind.replaceAll(" ", "-")}`;
const gateway = await installMockGateway(page, {
methodResponses: {
"agents.list": {
agents: [
{
id: "main",
identity: { name: "Original agent" },
name: "Original agent",
workspace: SOURCE_REPO,
workspaceGit: true,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
},
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
"sessions.create": { key: sessionKey },
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await page.getByRole("heading", { name: "Original agent" }).waitFor();
const message = page.locator(".new-session-page__message");
const start = page.locator("button.chat-send-btn");
await message.fill("retry this draft after reconnect");
await gateway.deferNext("sessions.create");
await start.click();
await gateway.waitForRequest("sessions.create");
await expect.poll(() => start.isDisabled()).toBe(true);
if (reconnectKind === "client replacement") {
await gateway.setMethodResponse("agents.list", {
agents: [
{
id: "main",
identity: { name: "Replacement agent" },
name: "Replacement agent",
workspace: TARGET_REPO,
workspaceGit: true,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
});
const socketsBefore = await gateway.getSocketCount();
await replaceGatewayClient(page);
await expect.poll(() => gateway.getSocketCount()).toBe(socketsBefore + 1);
await page.getByRole("heading", { name: "Replacement agent" }).waitFor();
} else {
const agentRequestsBefore = (await gateway.getRequests("agents.list")).length;
await gateway.setOnline(false);
await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 });
await gateway.setOnline(true);
await expect
.poll(async () => (await gateway.getRequests("agents.list")).length)
.toBe(agentRequestsBefore + 1);
}
await expect.poll(() => message.inputValue()).toBe("retry this draft after reconnect");
await expect.poll(() => message.isEnabled()).toBe(true);
await expect.poll(() => start.isDisabled()).toBe(true);
await page
.getByText(
"The Gateway changed while this thread was starting. Check recent threads before starting this task again.",
)
.waitFor();
expect(new URL(page.url()).pathname).toBe("/new");
expect(await gateway.getRequests("sessions.create")).toHaveLength(1);
} finally {
await context.close();
}
});
}
it("resets agent-derived workspace state when retargeted to a catalog", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: {
"agents.list": {
agents: [
{
id: "main",
identity: { name: "Main" },
name: "Main",
workspace: WORKSPACE,
workspaceGit: true,
},
{
id: "research",
identity: { name: "Research" },
name: "Research",
workspace: "/home/peter/research",
workspaceGit: true,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
},
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
"sessions.catalog.list": {
catalogs: [
{
id: "claude",
label: "Claude Code",
capabilities: {
continueSession: true,
archive: false,
createSession: { model: "anthropic/claude-opus-4-8" },
},
hosts: [],
},
],
},
"sessions.create": { key: "agent:main:claude-retarget" },
},
});
try {
await page.goto(`${suite.server.baseUrl}new?agent=research`);
const folderLabel = page.locator(
"#new-session-place-trigger .new-session-page__trigger-label",
);
await pollLocatorText(folderLabel).toBe("research");
await page.evaluate(() => {
history.pushState(null, "", "new?agent=main&catalog=claude");
dispatchEvent(new PopStateEvent("popstate"));
});
await pollLocatorText(page.locator(".new-session-page__runtime")).toContain("Claude Code");
await pollLocatorText(folderLabel).toBe("openclaw");
await page.locator(".new-session-page__message").fill("retarget this draft");
await page.getByRole("button", { name: "Start thread" }).click();
const create = await gateway.waitForRequest("sessions.create");
expect(create.params).toMatchObject({
agentId: "main",
message: "retarget this draft",
catalogId: "claude",
});
expect(create.params).not.toHaveProperty("model");
expect(create.params).not.toHaveProperty("cwd");
} finally {
await context.close();
}
});
});
@@ -0,0 +1,352 @@
import { expect, it } from "vitest";
import {
activateMenuItem,
captureUiProof,
controlUiSessionPath,
createSessionManagementE2eSuite,
installMockGateway,
requireRecord,
sessionRow,
sessionsListResponse,
waitForPatch,
} from "./session-management.test-support.ts";
const suite = createSessionManagementE2eSuite();
suite.define(() => {
it("archives a session from the Sessions page context menu and kebab", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.list": sessionsListResponse([
sessionRow("agent:main:main", "Main", Date.parse("2026-07-01T16:00:00.000Z")),
sessionRow(
"agent:main:research",
"Research notes",
Date.parse("2026-07-01T15:00:00.000Z"),
),
]),
"sessions.patch": {},
},
sessionKey: "agent:main:main",
});
try {
await page.goto(`${suite.server.baseUrl}sessions`);
const row = page.locator(".session-data-row").filter({ hasText: "Research notes" });
await row.waitFor({ state: "visible", timeout: 10_000 });
await row.click({ button: "right" });
const menuHost = page.locator("openclaw-session-menu");
await menuHost
.getByRole("menuitem", { name: "Archive thread" })
.waitFor({ state: "visible" });
await page.keyboard.press("Escape");
await row.getByRole("button", { name: "Open thread menu" }).click();
await activateMenuItem(menuHost.getByRole("menuitem", { name: "Archive thread" }));
const patch = await waitForPatch(
gateway,
(params) => params.key === "agent:main:research" && params.archived === true,
);
expect(requireRecord(patch.params)).toMatchObject({
archived: true,
key: "agent:main:research",
});
} finally {
await context.close();
}
});
// Batch archiving used to force one canonical sessions.list per row, which is
// what made a multi-select stall. The whole selection must archive on one
// refresh and leave the sidebar settled with the rows gone.
it("archives a sidebar multi-select with one canonical list refresh", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const baseTime = Date.parse("2026-07-01T16:00:00.000Z");
const batchKeys = ["agent:main:batch-a", "agent:main:batch-b", "agent:main:batch-c"] as const;
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.list": sessionsListResponse([
sessionRow("agent:main:main", "Main", baseTime),
sessionRow(batchKeys[0], "Batch A", baseTime - 1_000),
sessionRow(batchKeys[1], "Batch B", baseTime - 2_000),
sessionRow(batchKeys[2], "Batch C", baseTime - 3_000),
]),
"sessions.patch": {},
},
sessionKey: "agent:main:main",
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const sidebar = page.locator("openclaw-app-sidebar");
const rowFor = (key: string) =>
sidebar.locator(`.sidebar-recent-session[data-session-key="${key}"]`);
await rowFor(batchKeys[0]).waitFor({ state: "visible", timeout: 10_000 });
for (const key of batchKeys.slice(1)) {
await rowFor(key).waitFor({ state: "visible" });
}
const listCountBeforeBatch = (await gateway.getRequests("sessions.list")).length;
for (const key of batchKeys) {
await rowFor(key).click({ modifiers: ["Meta"] });
}
await rowFor(batchKeys[0]).click({ button: "right" });
const batchMenu = page.locator("openclaw-session-menu");
const archiveItem = batchMenu.getByRole("menuitem", { name: `Archive ${batchKeys.length}` });
await archiveItem.waitFor({ state: "visible", timeout: 10_000 });
await captureUiProof(page, "sidebar-multi-select-archive-menu.png");
await activateMenuItem(archiveItem);
for (const key of batchKeys) {
await waitForPatch(gateway, (params) => params.key === key && params.archived === true);
}
const patches = await gateway.getRequests("sessions.patch");
expect(patches.map((request) => requireRecord(request.params).key)).toEqual([...batchKeys]);
// The whole selection costs one canonical refresh, not one per archived row.
await expect
.poll(async () => (await gateway.getRequests("sessions.list")).length, { timeout: 10_000 })
.toBe(listCountBeforeBatch + 1);
await captureUiProof(page, "sidebar-multi-select-archive-settled.png");
// Hold past the batch so a late per-row refresh would still be caught.
await page.waitForTimeout(500);
expect((await gateway.getRequests("sessions.list")).length).toBe(listCountBeforeBatch + 1);
} finally {
await context.close();
}
});
it("keeps the selected session through archive refreshes and restores the composer", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const baseTime = Date.parse("2026-07-01T16:00:00.000Z");
const sessionRows = Array.from({ length: 15 }, (_, index) =>
sessionRow(
`agent:main:archive-refresh-${index}`,
`Archive refresh ${index}`,
baseTime - (index + 1) * 1_000,
),
);
const selected = sessionRows[2]!;
const batchRows = [sessionRows[0]!, sessionRows[1]!, sessionRows[3]!];
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.list": sessionsListResponse([
sessionRow("agent:main:main", "Main", baseTime),
...sessionRows,
]),
"sessions.patch": {},
},
sessionArchiveFiltering: true,
sessionKey: "agent:main:main",
});
const assertSelectedRoute = async () => {
await expect
.poll(() => new URL(page.url()).pathname)
.toBe(controlUiSessionPath(selected.key));
const row = page.locator(`.sidebar-recent-session[data-session-key="${selected.key}"]`);
await row.waitFor({ state: "visible", timeout: 10_000 });
await expect
.poll(() => row.getAttribute("class"))
.toContain("sidebar-recent-session--active");
};
try {
await page.goto(`${suite.server.baseUrl}chat`);
const sidebar = page.locator("openclaw-app-sidebar");
const rowFor = (key: string) =>
sidebar.locator(`.sidebar-recent-session[data-session-key="${key}"]`);
await rowFor(selected.key).waitFor({ state: "visible", timeout: 10_000 });
await rowFor(selected.key).locator("a").first().click();
await assertSelectedRoute();
await page.locator(".agent-chat__input textarea").waitFor({ state: "visible" });
for (const row of batchRows) {
await rowFor(row.key).click({ modifiers: ["Meta"] });
}
await rowFor(batchRows[0]!.key).click({ button: "right" });
const batchMenu = page.locator("openclaw-session-menu");
await activateMenuItem(
batchMenu.getByRole("menuitem", { name: `Archive ${batchRows.length}` }),
);
for (const row of batchRows) {
await waitForPatch(gateway, (params) => params.key === row.key && params.archived === true);
await gateway.emitGatewayEvent("sessions.changed", {
...row,
archived: true,
reason: "update",
sessionKey: row.key,
});
await assertSelectedRoute();
}
await gateway.setMethodResponse("sessions.describe", {
session: { ...selected, archived: true },
});
const selectedRow = rowFor(selected.key);
await selectedRow.hover();
await selectedRow.getByRole("button", { name: "Open thread menu" }).click();
await activateMenuItem(
page.locator("openclaw-session-menu").getByRole("menuitem", {
name: "Archive thread",
}),
);
await waitForPatch(
gateway,
(params) => params.key === selected.key && params.archived === true,
);
await gateway.emitGatewayEvent("sessions.changed", {
...selected,
archived: true,
reason: "update",
sessionKey: selected.key,
});
await assertSelectedRoute();
await selectedRow.locator(".sidebar-session__archive-glyph").waitFor({ state: "visible" });
const archivedNotice = page.locator(".agent-chat__disabled-banner");
await archivedNotice.waitFor({ state: "visible", timeout: 10_000 });
await expect.poll(() => archivedNotice.textContent()).toContain("This session is archived.");
await expect.poll(() => page.locator(".agent-chat__input").count()).toBe(0);
await archivedNotice.getByRole("button", { name: "Unarchive" }).click();
await waitForPatch(
gateway,
(params) => params.key === selected.key && params.archived === false,
);
await gateway.emitGatewayEvent("sessions.changed", {
...selected,
archived: false,
reason: "update",
sessionKey: selected.key,
});
await assertSelectedRoute();
await archivedNotice.waitFor({ state: "detached", timeout: 10_000 });
await page.locator(".agent-chat__input textarea").waitFor({ state: "visible" });
} finally {
await context.close();
}
});
it("shows the archived notice when an archived session is cold-loaded outside the active list", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const archived = sessionRow(
"agent:main:dashboard:cold-archive",
"Archived planning",
Date.parse("2026-07-01T16:00:00.000Z"),
{ archived: true },
);
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.describe": { session: archived },
"sessions.list": sessionsListResponse([
sessionRow("agent:main:main", "Main", archived.updatedAt + 1),
]),
"sessions.patch": {},
},
sessionArchiveFiltering: true,
sessionKey: archived.key,
});
try {
await page.goto(`${suite.server.baseUrl}chat?session=${encodeURIComponent(archived.key)}`);
const selectedRow = page.locator(
`.sidebar-recent-session[data-session-key="${archived.key}"]`,
);
await selectedRow.waitFor({ state: "visible", timeout: 10_000 });
await expect
.poll(() => selectedRow.getAttribute("class"))
.toContain("sidebar-recent-session--active");
await selectedRow.locator(".sidebar-session__archive-glyph").waitFor({ state: "visible" });
await expect.poll(() => page.getByText("Archived planning", { exact: true }).count()).toBe(2);
const archivedNotice = page.locator(".agent-chat__disabled-banner");
await archivedNotice.waitFor({ state: "visible", timeout: 10_000 });
await expect.poll(() => archivedNotice.textContent()).toContain("This session is archived.");
await expect.poll(() => page.locator(".agent-chat__input").count()).toBe(0);
await gateway.setMethodResponse("sessions.describe", {
session: { ...archived, archived: false },
});
await archivedNotice.getByRole("button", { name: "Unarchive" }).click();
await waitForPatch(
gateway,
(params) => params.key === archived.key && params.archived === false,
);
await gateway.emitGatewayEvent("sessions.changed", {
...archived,
archived: false,
reason: "update",
sessionKey: archived.key,
});
await archivedNotice.waitFor({ state: "detached", timeout: 10_000 });
await page.locator(".agent-chat__input textarea").waitFor({ state: "visible" });
} finally {
await context.close();
}
});
it("keeps a session row when the Gateway reports no deletion", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const key = "agent:main:research";
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.delete": { ok: true, deleted: false },
"sessions.list": sessionsListResponse([
sessionRow("agent:main:main", "Main", Date.parse("2026-07-01T16:00:00.000Z")),
sessionRow(key, "Research notes", Date.parse("2026-07-01T15:00:00.000Z")),
]),
},
sessionKey: "agent:main:main",
});
page.on("dialog", (dialog) => void dialog.accept());
try {
await page.goto(`${suite.server.baseUrl}sessions`);
const row = page.locator(".session-data-row").filter({ hasText: "Research notes" });
await row.waitFor({ state: "visible", timeout: 10_000 });
await row.getByRole("button", { name: "Open thread menu" }).click();
await activateMenuItem(
page.locator("openclaw-session-menu").getByRole("menuitem", { name: "Delete…" }),
);
const request = await gateway.waitForRequest("sessions.delete");
expect(requireRecord(request.params)).toMatchObject({ key });
await row.waitFor({ state: "visible" });
} finally {
await context.close();
}
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,790 @@
import { expect, it } from "vitest";
import {
actionOpacity,
activateMenuItem,
captureUiProof,
collapsedSessionSectionsStorageKey,
controlUiSessionPath,
createSessionManagementE2eSuite,
installMockGateway,
requireRecord,
sessionRow,
sessionsListResponse,
waitForPatch,
} from "./session-management.test-support.ts";
const suite = createSessionManagementE2eSuite();
suite.define(() => {
it("recovers an empty group catalog after a transient load failure", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
deferredMethods: ["sessions.groups.list"],
featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"],
methodResponses: {
"sessions.list": sessionsListResponse([]),
},
sessionGroups: ["Recovered group"],
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
await gateway.waitForRequest("sessions.groups.list");
await gateway.rejectDeferred("sessions.groups.list", {
code: "UNAVAILABLE",
message: "temporary catalog failure",
retryable: true,
});
await expect
.poll(async () => (await gateway.getRequests("sessions.groups.list")).length, {
timeout: 10_000,
})
.toBe(2);
await page.locator('[data-session-section="category:Recovered group"]').waitFor({
state: "visible",
});
} finally {
await context.close();
}
});
it("keeps a rejected sidebar mutation visible until the user dismisses it", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
deferredMethods: ["sessions.patch"],
methodResponses: {
"sessions.list": sessionsListResponse([
sessionRow("agent:main:rename-me", "Rename me", Date.now()),
]),
},
sessionKey: "agent:main:rename-me",
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const row = page.locator('[data-session-key="agent:main:rename-me"]');
await row.waitFor({ state: "visible", timeout: 10_000 });
await row.hover();
await row.getByRole("button", { name: "Open thread menu" }).click();
page.once("dialog", (dialog) => void dialog.accept("Rejected rename"));
await page.getByRole("menuitem", { name: "Rename…" }).click();
await gateway.waitForRequest("sessions.patch");
await gateway.rejectDeferred("sessions.patch", {
code: "INVALID_REQUEST",
message: "sidebar rename rejected",
});
const error = page.locator("[data-sidebar-session-error]");
await error.waitFor({ state: "visible" });
await expect.poll(() => error.textContent()).toContain("sidebar rename rejected");
expect(
await error
.locator("xpath=ancestor::*[contains(@class, 'sidebar-recent-sessions')]")
.count(),
).toBe(0);
await error.getByRole("button", { name: "Dismiss error" }).click();
await expect.poll(() => error.count()).toBe(0);
} finally {
await context.close();
}
});
it("manages sessions through the sidebar groups and command palette", async () => {
const baseTime = Date.parse("2026-07-01T16:00:00.000Z");
const context = await suite.browser.newContext({
colorScheme: "dark",
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
await page.clock.install();
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.list": {
cases: [
...[50, 100, 150].map((offset) => ({
match: { offset, search: "helpers" },
response: sessionsListResponse(
Array.from({ length: 50 }, (_, index) =>
sessionRow(
`agent:main:hidden-helper-${offset + index}`,
`Hidden helper ${offset + index}`,
baseTime - offset - index,
{ spawnedBy: "agent:main:main" },
),
),
{ hasMore: true, nextOffset: offset + 50, offset, totalCount: 250 },
),
})),
{
match: { search: "helpers" },
response: sessionsListResponse(
Array.from({ length: 50 }, (_, index) =>
sessionRow(
`agent:main:hidden-helper-${index}`,
`Hidden helper ${index}`,
baseTime - index,
{ spawnedBy: "agent:main:main" },
),
),
{ hasMore: true, nextOffset: 50, totalCount: 250 },
),
},
{
match: { offset: 50, search: "release" },
response: sessionsListResponse(
[sessionRow("agent:main:release", "Release planning", baseTime - 60_000)],
{ offset: 50, totalCount: 51 },
),
},
{
match: { search: "release" },
response: sessionsListResponse(
Array.from({ length: 50 }, (_, index) =>
sessionRow(
`agent:main:release-helper-${index}`,
`Release helper ${index}`,
baseTime - index,
{ spawnedBy: "agent:main:main" },
),
),
{ hasMore: true, nextOffset: 50, totalCount: 51 },
),
},
{
match: {},
response: sessionsListResponse([
sessionRow("agent:main:main", "Main", baseTime),
sessionRow("agent:main:release", "Release planning", baseTime - 60_000, {
pinned: true,
pinnedAt: baseTime - 30_000,
}),
sessionRow("agent:main:migration", "Data migration", baseTime - 90_000, {
hasActiveRun: true,
status: "running",
}),
sessionRow("agent:main:research", "Research notes", baseTime - 120_000),
]),
},
],
},
"sessions.patch": {},
},
sessionKey: "agent:main:main",
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
// Sidebar: pinned rows join the ordered page zone while staying out of Threads.
const sidebarRows = page.locator(".sidebar-recent-session");
await sidebarRows.first().waitFor({ state: "visible", timeout: 10_000 });
const pinnedZoneRow = page.locator(
'[data-sidebar-entry="session:agent:main:release"] .sidebar-recent-session',
);
await expect.poll(() => pinnedZoneRow.textContent()).toContain("Release planning");
const groups = page.locator(".sidebar-recent-sessions__group");
await expect.poll(() => groups.count()).toBe(1);
await expect
.poll(() => groups.first().getAttribute("data-session-section"))
.toBe("ungrouped");
await expect.poll(() => page.locator('[data-session-section="pinned"]').count()).toBe(0);
// Chats keep recency order with the open session highlighted in place —
// selecting a row must not reshuffle the list.
const chatRows = page.locator('[data-session-section="ungrouped"] .sidebar-recent-session');
const rowNames = () =>
chatRows.evaluateAll((rows) =>
rows.map((row) => row.querySelector(".sidebar-recent-session__name")?.textContent ?? ""),
);
await expect.poll(rowNames).toEqual(["Data migration", "Research notes"]);
const sidebarMigration = sidebarRows.filter({ hasText: "Data migration" });
await expect
.poll(() => sidebarMigration.locator(".session-run-spinner").isVisible())
.toBe(true);
// Hover-revealed management actions on sidebar rows.
const sidebarResearch = sidebarRows.filter({ hasText: "Research notes" });
const sidebarResearchPin = sidebarResearch.getByRole("button", { name: "Pin thread" });
await page.mouse.move(900, 500);
await expect.poll(() => actionOpacity(sidebarResearchPin)).toBe("0");
const sidebarReleasePin = sidebarRows
.filter({ hasText: "Release planning" })
.getByRole("button", { name: "Unpin thread" });
await expect.poll(() => actionOpacity(sidebarReleasePin)).toBe("0");
await sidebarResearch.hover();
await expect.poll(() => actionOpacity(sidebarResearchPin)).toBe("1");
await captureUiProof(page, "sidebar-sessions.png");
await sidebarRows.filter({ hasText: "Release planning" }).hover();
await expect.poll(() => actionOpacity(sidebarReleasePin)).toBe("1");
await sidebarReleasePin.click();
const pinPatch = await waitForPatch(
gateway,
(params) => params.key === "agent:main:release" && params.pinned === false,
);
expect(requireRecord(pinPatch.params)).toMatchObject({
key: "agent:main:release",
pinned: false,
});
// The current-main full context menu remains intact: active rows cannot
// archive, while an idle row can.
await sidebarMigration.hover();
await sidebarMigration.getByRole("button", { name: "Open thread menu" }).click();
await expect
.poll(() => page.getByRole("menuitem", { name: "Archive thread" }).isDisabled())
.toBe(true);
await page.keyboard.press("Escape");
await sidebarResearch.hover();
await sidebarResearch.getByRole("button", { name: "Open thread menu" }).click();
await activateMenuItem(page.getByRole("menuitem", { name: "Archive thread" }));
const archivePatch = await waitForPatch(
gateway,
(params) => params.key === "agent:main:research" && params.archived === true,
);
expect(requireRecord(archivePatch.params)).toMatchObject({
archived: true,
key: "agent:main:research",
});
// Selecting a visible row must not reshuffle the list: the highlight
// moves while every row keeps its slot. (The mocked gateway keeps
// returning the same list, so the archived row stays visible here.)
const researchLink = sidebarResearch.locator("a").first();
await researchLink.click();
await expect
.poll(() => new URL(page.url()).pathname)
.toBe(controlUiSessionPath("agent:main:research"));
await expect.poll(rowNames).toEqual(["Release planning", "Data migration", "Research notes"]);
await expect
.poll(() =>
chatRows
.filter({ hasText: "Research notes" })
.first()
.evaluate((row) => row.classList.contains("sidebar-recent-session--active")),
)
.toBe(true);
// Command palette is the single search surface: querying lists matching
// chats from the gateway and selecting one navigates to it.
await page.getByRole("button", { name: "Open command palette" }).click();
const paletteInput = page.locator(".cmd-palette__input");
await paletteInput.waitFor({ state: "visible", timeout: 10_000 });
// Automatic search is intentionally bounded: an all-hidden result set
// must not scan the entire session store from one palette query.
await paletteInput.fill("helpers");
await expect
.poll(async () => {
const requests = await gateway.getRequests("sessions.list");
return requests.filter((request) => requireRecord(request.params).search === "helpers")
.length;
})
.toBe(4);
await page.clock.runFor(400);
const boundedSearchRequests = await gateway.getRequests("sessions.list");
expect(
boundedSearchRequests.filter(
(request) => requireRecord(request.params).search === "helpers",
),
).toHaveLength(4);
await paletteInput.fill("release");
const paletteOption = page
.locator(".cmd-palette__item")
.filter({ hasText: "Release planning" });
await paletteOption.waitFor({ state: "visible", timeout: 10_000 });
// The first result page contains 50 hidden child sessions; search must
// follow nextOffset before exposing the visible chat on page two.
await expect
.poll(() =>
page.locator(".cmd-palette__item").filter({ hasText: "Release helper" }).count(),
)
.toBe(0);
const searchRequests = await gateway.getRequests("sessions.list");
expect(
searchRequests.some((request) => {
const params = requireRecord(request.params);
return params.search === "release" && params.offset === 50;
}),
).toBe(true);
await captureUiProof(page, "command-palette-session-search.png");
await paletteOption.click();
await expect
.poll(() => new URL(page.url()).pathname)
.toBe(controlUiSessionPath("agent:main:release"));
} finally {
await context.close();
}
});
it("shows a rejected Sessions-page custom group instead of leaking a page error", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
deferredMethods: ["sessions.groups.put"],
featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"],
methodResponses: {
"sessions.list": sessionsListResponse([]),
},
sessionKey: "agent:main:main",
});
const pageErrors: string[] = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
try {
await page.goto(`${suite.server.baseUrl}sessions`);
await page.locator(".session-groupby__select").selectOption("category");
page.once("dialog", (dialog) => void dialog.accept("X".repeat(513)));
await page.getByRole("button", { name: "New group…" }).click();
await gateway.waitForRequest("sessions.groups.put");
await gateway.rejectDeferred("sessions.groups.put", {
code: "INVALID_REQUEST",
message: "group name exceeds 512 characters",
});
const error = page.locator(".sessions-error");
await error.waitFor({ state: "visible" });
await expect.poll(() => error.textContent()).toContain("group name exceeds 512 characters");
expect(pageErrors).toEqual([]);
} finally {
await context.close();
}
});
it("renames, deletes, and toggles sidebar session groups", async () => {
const baseTime = Date.parse("2026-07-01T16:00:00.000Z");
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.list": {
cases: [
{
match: { archived: true },
response: sessionsListResponse([
sessionRow("agent:main:old-notes", "Old notes", baseTime - 300_000, {
archived: true,
category: "Research",
}),
]),
},
{
match: {},
response: sessionsListResponse([
sessionRow("agent:main:main", "Main", baseTime),
sessionRow("agent:main:apps", "Apps", baseTime - 30_000, {
category: "Apps",
}),
sessionRow("agent:main:paper-a", "Paper A", baseTime - 60_000, {
category: "Research",
}),
sessionRow("agent:main:paper-b", "Paper B", baseTime - 90_000, {
category: "Research",
}),
]),
},
],
},
"sessions.patch": {},
},
featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"],
sessionKey: "agent:main:main",
sessionGroups: ["Apps", "Research"],
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
// Categorized rows render as their own sidebar group section.
const groups = page.locator(".sidebar-recent-sessions__group");
const researchGroup = groups.filter({ hasText: "Research" });
await researchGroup.waitFor({ state: "visible", timeout: 10_000 });
await expect.poll(() => researchGroup.locator(".sidebar-recent-session").count()).toBe(2);
await captureUiProof(page, "sidebar-session-groups.png");
// Rename group: the gateway renames the catalog entry and repoints every
// member session server-side (sessions.groups.rename), no per-member patches.
const groupMenuButton = researchGroup.getByRole("button", {
name: "Group options for Research",
});
await researchGroup.locator(".sidebar-recent-sessions__head").hover();
await groupMenuButton.click();
await page.getByRole("menuitem", { name: "Rename group…" }).waitFor({ state: "visible" });
await captureUiProof(page, "sidebar-group-menu.png");
page.once("dialog", (dialog) => void dialog.accept("Projects"));
await activateMenuItem(page.getByRole("menuitem", { name: "Rename group…" }));
const renameRequest = await gateway.waitForRequest("sessions.groups.rename");
expect(requireRecord(renameRequest.params)).toMatchObject({
name: "Research",
to: "Projects",
});
await expect
.poll(() =>
page
.locator('[data-session-section^="category:"]')
.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-session-section")),
),
)
.toEqual(["category:Apps", "category:Projects"]);
const projectsGroup = groups.filter({ hasText: "Projects" });
await expect.poll(() => projectsGroup.locator(".sidebar-recent-session").count()).toBe(2);
// Delete group: the gateway drops the catalog entry and moves member
// sessions back to Chats server-side (sessions.groups.delete).
const projectsMenuButton = projectsGroup.getByRole("button", {
name: "Group options for Projects",
});
await projectsGroup.locator(".sidebar-recent-sessions__head").hover();
page.once("dialog", (dialog) => void dialog.accept());
await projectsMenuButton.click();
await activateMenuItem(page.getByRole("menuitem", { name: "Delete group…" }));
const deleteRequest = await gateway.waitForRequest("sessions.groups.delete");
expect(requireRecord(deleteRequest.params)).toMatchObject({ name: "Projects" });
await expect
.poll(() =>
page
.locator('[data-session-section^="category:"]')
.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-session-section")),
),
)
.toEqual(["category:Apps"]);
await expect
.poll(() =>
page.locator('[data-session-section="ungrouped"] .sidebar-recent-session').count(),
)
.toBe(2);
// Group by "None" flattens the category sections into the plain list.
const sortSessionsButton = page.locator(
"button.sidebar-session-sort:not(.sidebar-session-new)",
);
await sortSessionsButton.click();
await page.getByRole("menuitemradio", { name: "None" }).waitFor({ state: "visible" });
await captureUiProof(page, "sidebar-groupby-sort-menu.png");
await sortSessionsButton.click();
await expect.poll(() => sortSessionsButton.getAttribute("aria-expanded")).toBe("false");
await expect.poll(() => page.getByRole("menuitemradio", { name: "None" }).count()).toBe(0);
await captureUiProof(page, "sidebar-groupby-sort-menu-closed.png");
await sortSessionsButton.click();
await activateMenuItem(page.getByRole("menuitemradio", { name: "None" }));
await expect.poll(() => groups.count()).toBe(1);
await expect.poll(() => groups.first().locator(".sidebar-recent-session").count()).toBe(3);
} finally {
await context.close();
}
});
it("preserves a collapsed sidebar group when its rename is rejected", async () => {
const baseTime = Date.parse("2026-07-01T16:00:00.000Z");
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
await page.addInitScript(
({ key, value }) => {
try {
if (localStorage.getItem(key) === null) {
localStorage.setItem(key, value);
}
} catch {
// The opaque initial document has no storage; the app origin does.
}
},
{
key: collapsedSessionSectionsStorageKey,
value: JSON.stringify(["category:Research"]),
},
);
const gateway = await installMockGateway(page, {
deferredMethods: ["sessions.groups.rename"],
featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"],
methodResponses: {
"sessions.list": sessionsListResponse([
sessionRow("agent:main:main", "Main", baseTime),
sessionRow("agent:main:paper", "Paper", baseTime - 60_000, {
category: "Research",
}),
]),
},
sessionGroups: ["Research"],
sessionKey: "agent:main:main",
});
const pageErrors: string[] = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
try {
await page.goto(`${suite.server.baseUrl}chat`);
const researchGroup = page.locator('[data-session-section="category:Research"]');
await researchGroup.waitFor({ state: "visible", timeout: 10_000 });
await expect.poll(() => researchGroup.locator(".sidebar-recent-session").count()).toBe(0);
await researchGroup.locator(".sidebar-recent-sessions__head").hover();
await researchGroup.getByRole("button", { name: "Group options for Research" }).click();
page.once("dialog", (dialog) => void dialog.accept("Projects"));
await activateMenuItem(page.getByRole("menuitem", { name: "Rename group…" }));
await gateway.waitForRequest("sessions.groups.rename");
await gateway.rejectDeferred("sessions.groups.rename", {
code: "INVALID_REQUEST",
message: "rejected group rename",
});
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
expect(
await page.evaluate((key) => localStorage.getItem(key), collapsedSessionSectionsStorageKey),
).toBe(JSON.stringify(["category:Research"]));
await researchGroup.waitFor({ state: "visible" });
expect(await page.locator('[data-session-section="category:Projects"]').count()).toBe(0);
expect(pageErrors).toEqual([]);
await page.reload();
await researchGroup.waitFor({ state: "visible", timeout: 10_000 });
await expect.poll(() => researchGroup.locator(".sidebar-recent-session").count()).toBe(0);
expect(
await page.evaluate((key) => localStorage.getItem(key), collapsedSessionSectionsStorageKey),
).toBe(JSON.stringify(["category:Research"]));
} finally {
await context.close();
}
});
it("pages sidebar sessions and supports complete drag-managed groups", async () => {
const baseTime = Date.parse("2026-07-01T16:00:00.000Z");
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const sessions = Array.from({ length: 13 }, (_, index) =>
sessionRow(`agent:main:session-${index}`, `Session ${index}`, baseTime - index * 60_000, {
...(index === 0 ? { category: "Alpha" } : {}),
...(index === 1 ? { category: "Beta" } : {}),
}),
);
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.list": sessionsListResponse(sessions),
"sessions.patch": {},
},
featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"],
sessionKey: "agent:main:session-0",
sessionGroups: ["Alpha", "Beta"],
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const sidebarRows = page.locator(".sidebar-recent-session");
// Category sections page independently: Alpha and Beta stay visible
// alongside the first ten rows in the ungrouped section.
await expect.poll(() => sidebarRows.count()).toBe(12);
await page.getByRole("button", { name: "Show more" }).click();
await expect.poll(() => sidebarRows.count()).toBe(13);
await expect.poll(() => page.getByText("All threads", { exact: true }).count()).toBe(0);
await captureUiProof(page, "sidebar-all-sessions.png");
// New groups are created from a session's menu (Move to group → New group…),
// which files that session into the new group.
const sessionTen = page.locator(
'.sidebar-recent-session[data-session-key="agent:main:session-10"]',
);
await sessionTen.hover();
await sessionTen.getByRole("button", { name: "Open thread menu" }).click();
const moveToGroup = page.getByRole("menuitem", { name: "Move to group" });
await expect.poll(() => moveToGroup.getAttribute("aria-haspopup")).toBe("menu");
const moveToGroupIndex = await moveToGroup.evaluate((element) =>
[...(element.parentElement?.children ?? [])]
.filter(
(item) =>
item.localName === "wa-dropdown-item" &&
item.getAttribute("slot") !== "submenu" &&
!(item as HTMLElement & { disabled?: boolean }).disabled,
)
.indexOf(element),
);
expect(moveToGroupIndex).toBeGreaterThanOrEqual(0);
// Submenu ARIA is ready before Web Awesome finishes opening the dropdown.
// Wait for its focus contract so navigation keys cannot outrun the menu.
await expect
.poll(() =>
page.locator("openclaw-session-menu > wa-dropdown > wa-dropdown-item:focus").count(),
)
.toBe(1);
await page.keyboard.press("Home");
for (let index = 0; index < moveToGroupIndex; index += 1) {
await page.keyboard.press("ArrowDown");
}
await expect
.poll(() => moveToGroup.evaluate((element) => element === document.activeElement))
.toBe(true);
await page.keyboard.press("ArrowRight");
await expect.poll(() => moveToGroup.getAttribute("aria-expanded")).toBe("true");
page.once("dialog", (dialog) => void dialog.accept("Gamma"));
await activateMenuItem(page.getByRole("menuitem", { name: "New group…" }));
const gamma = page.locator('[data-session-section="category:Gamma"]');
await gamma.waitFor({ state: "visible" });
const createdPatch = await waitForPatch(
gateway,
(params) => params.key === "agent:main:session-10" && params.category === "Gamma",
);
expect(requireRecord(createdPatch.params)).toMatchObject({
category: "Gamma",
key: "agent:main:session-10",
});
const sessionEleven = page.locator(
'.sidebar-recent-session[data-session-key="agent:main:session-11"]',
);
await sessionEleven.dragTo(gamma);
const groupedPatch = await waitForPatch(
gateway,
(params) => params.key === "agent:main:session-11" && params.category === "Gamma",
);
expect(requireRecord(groupedPatch.params)).toMatchObject({
category: "Gamma",
key: "agent:main:session-11",
});
await expect
.poll(() => gamma.locator(".sidebar-recent-session").count(), { timeout: 10_000 })
.toBe(2);
await captureUiProof(page, "sidebar-session-dropped-into-group.png");
const ungrouped = page.locator('[data-session-section="ungrouped"]');
await gamma
.locator('.sidebar-recent-session[data-session-key="agent:main:session-11"]')
.dragTo(ungrouped);
const ungroupedPatch = await waitForPatch(
gateway,
(params) => params.key === "agent:main:session-11" && params.category === null,
);
expect(requireRecord(ungroupedPatch.params)).toMatchObject({
category: null,
key: "agent:main:session-11",
});
await expect
.poll(() => ungrouped.locator(".sidebar-recent-session").count(), { timeout: 10_000 })
.toBe(10);
const alpha = page.locator('[data-session-section="category:Alpha"]');
const alphaToggle = alpha.getByRole("button", { name: "Alpha", exact: true });
await alphaToggle.click();
await expect.poll(() => alpha.locator(".sidebar-recent-session").count()).toBe(0);
await captureUiProof(page, "sidebar-session-group-collapsed.png");
await alphaToggle.click();
await expect.poll(() => alpha.locator(".sidebar-recent-session").count()).toBe(1);
await alphaToggle.click();
await expect.poll(() => alpha.locator(".sidebar-recent-session").count()).toBe(0);
// Header buttons intentionally keep their click behavior; reorder from
// the dedicated grip beside them.
await gamma.locator(".sidebar-session-group-drag-handle").dragTo(alpha, {
targetPosition: { x: 4, y: 2 },
});
const customGroupOrder = () =>
page
.locator('[data-session-section^="category:"]')
.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-session-section")),
);
await expect
.poll(customGroupOrder)
.toEqual(["category:Gamma", "category:Alpha", "category:Beta"]);
await captureUiProof(page, "sidebar-session-groups-reordered.png");
await page.reload();
await expect
.poll(customGroupOrder)
.toEqual(["category:Gamma", "category:Alpha", "category:Beta"]);
await expect
.poll(() =>
page
.locator('[data-session-section="category:Alpha"] .sidebar-session-group-toggle')
.getAttribute("aria-expanded"),
)
.toBe("false");
await expect.poll(() => page.locator(".sidebar-recent-session").count()).toBe(11);
const patchCountBeforeFlatDrag = (await gateway.getRequests("sessions.patch")).length;
const sortSessionsButton = page.getByRole("button", { name: "Sort threads" });
await sortSessionsButton.locator("..").hover();
await sortSessionsButton.click();
await activateMenuItem(page.getByRole("menuitemradio", { name: "None" }));
const flatSection = page.locator('[data-session-section="ungrouped"]');
await flatSection
.locator('.sidebar-recent-session[data-session-key="agent:main:session-1"]')
.dragTo(flatSection);
expect((await gateway.getRequests("sessions.patch")).length).toBe(patchCountBeforeFlatDrag);
} finally {
await context.close();
}
});
it("keeps a new empty group visible before the first saved session", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.list": sessionsListResponse([]),
},
featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"],
sessionKey: "agent:main:main",
// Stored-but-empty catalog groups stay visible as sections/move targets.
sessionGroups: ["First group"],
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const firstGroup = page.locator('[data-session-section="category:First group"]');
await firstGroup.waitFor({ state: "visible" });
// A header-menu-created group starts empty and still gets a section.
await firstGroup.locator(".sidebar-recent-sessions__head").hover();
await firstGroup.getByRole("button", { name: "Group options for First group" }).click();
page.once("dialog", (dialog) => void dialog.accept("Second group"));
await activateMenuItem(page.getByRole("menuitem", { name: "New group…" }));
await page.locator('[data-session-section="category:Second group"]').waitFor({
state: "visible",
});
const putRequest = await gateway.waitForRequest("sessions.groups.put");
expect(requireRecord(putRequest.params)).toMatchObject({
names: ["First group", "Second group"],
});
} finally {
await context.close();
}
});
});
@@ -0,0 +1,677 @@
import path from "node:path";
import { expect, it } from "vitest";
import {
actionOpacity,
actionPointerEvents,
captureUiProof,
captureUiProofEnabled,
controlUiSessionPath,
controlUiSessionUrl,
createSessionManagementE2eSuite,
installMockGateway,
requireRecord,
sessionRow,
sessionsListResponse,
trimmedTextContents,
uiProofArtifactDir,
waitForPatch,
} from "./session-management.test-support.ts";
const suite = createSessionManagementE2eSuite();
suite.define(() => {
it("expands child sessions inline and opens a child chat", async () => {
const baseTime = Date.parse("2026-07-01T16:00:00.000Z");
const parentKey = "agent:main:release-plan";
const childOneKey = "agent:main:research-sources";
const childTwoKey = "agent:main:verify-tests";
const context = await suite.browser.newContext({
colorScheme: "dark",
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.list": {
cases: [
{
match: { spawnedBy: parentKey },
response: sessionsListResponse([
sessionRow(childOneKey, "Research sources", baseTime - 1_000, {
hasActiveRun: true,
spawnedBy: parentKey,
startedAt: baseTime - 61_000,
status: "running",
}),
sessionRow(childTwoKey, "Verify tests", baseTime - 2_000, {
endedAt: baseTime - 2_000,
spawnedBy: parentKey,
startedAt: baseTime - 62_000,
status: "done",
}),
]),
},
{
response: sessionsListResponse([
sessionRow(parentKey, "Plan release", baseTime, {
childSessions: [childOneKey, childTwoKey],
}),
]),
},
],
},
},
sessionKey: parentKey,
});
try {
await page.goto(controlUiSessionUrl(suite.server.baseUrl, parentKey));
const parent = page.locator(`[data-session-key="${parentKey}"]`);
await parent.waitFor({ state: "visible", timeout: 10_000 });
await expect.poll(() => page.locator(".sidebar-recent-session--child").count()).toBe(0);
await captureUiProof(page, "child-sessions-collapsed.png");
await parent.getByRole("button", { name: "Show 2 child threads for Plan release" }).click();
await page.getByText("Research sources", { exact: true }).waitFor({ state: "visible" });
await page.getByText("Verify tests", { exact: true }).waitFor({ state: "visible" });
await expect
.poll(async () =>
(await gateway.getRequests("sessions.list")).some(
(request) => requireRecord(request.params).spawnedBy === parentKey,
),
)
.toBe(true);
const childRows = page.locator(".sidebar-recent-session--child");
await expect.poll(() => childRows.count()).toBe(2);
expect(await childRows.getByRole("button", { name: "Open thread menu" }).count()).toBe(0);
await childRows.nth(0).getByRole("img", { name: "Active run" }).waitFor();
await childRows.nth(1).getByRole("img", { name: "Done" }).waitFor();
await captureUiProof(page, "child-sessions-expanded.png");
await childRows.nth(1).getByRole("link").click();
await expect.poll(() => new URL(page.url()).pathname).toBe(controlUiSessionPath(childTwoKey));
} finally {
await context.close();
}
});
it("dismisses fixed session menus before the sidebar or drawer hides", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.list": sessionsListResponse([
sessionRow("agent:main:main", "Main", Date.parse("2026-07-01T16:00:00.000Z")),
sessionRow(
"agent:main:research",
"Research notes",
Date.parse("2026-07-01T15:00:00.000Z"),
),
]),
"sessions.patch": {},
},
sessionKey: "agent:main:main",
});
const dialogs: string[] = [];
page.on("dialog", (dialog) => {
dialogs.push(dialog.message());
void dialog.dismiss();
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const sidebar = page.locator("openclaw-app-sidebar");
const row = sidebar.locator(
'.sidebar-recent-session[data-session-key="agent:main:research"]',
);
const shell = page.locator(".shell");
const shellNav = page.locator(".shell-nav");
const collapseButton = page
.locator(".shell-chrome-controls")
.getByRole("button", { name: "Collapse sidebar" });
const expandButton = page.locator(".shell-chrome-controls__nav-toggle");
const drawerToggle = page
.locator(".topbar-nav-toggle:visible, .chat-pane__nav-toggle:visible")
.first();
const sessionMenu = page.getByRole("menu", { name: "Actions for Research notes" });
await row.waitFor({ state: "visible", timeout: 10_000 });
const openSessionMenu = async () => {
await row.hover();
await row.getByRole("button", { name: "Open thread menu" }).click();
await page
.getByRole("menu", { name: "Actions for Research notes" })
.waitFor({ state: "visible" });
};
const expectDesktopCollapsed = async () => {
await expect.poll(() => sidebar.isVisible()).toBe(false);
await expect.poll(() => expandButton.isVisible()).toBe(true);
await expect
.poll(() => expandButton.evaluate((element) => element === document.activeElement))
.toBe(true);
};
const expectDrawerClosed = async () => {
await expect
.poll(() => shell.getAttribute("class"))
.not.toContain("shell--nav-drawer-open");
await expect
.poll(() => shellNav.evaluate((element) => element.getBoundingClientRect().right))
.toBeLessThanOrEqual(0);
};
const hiddenActionCounts = async () => ({
dialogs: dialogs.length,
patches: (await gateway.getRequests("sessions.patch")).length,
});
const expectHiddenShortcutsInert = async (
before: Awaited<ReturnType<typeof hiddenActionCounts>>,
) => {
for (const shortcut of ["p", "a", "d"] as const) {
await page.keyboard.press(shortcut);
}
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
expect(await hiddenActionCounts()).toEqual(before);
};
// Keyboard collapse bypasses the menu's outside-pointer handler. The shell
// must explicitly unmount it before the sidebar becomes display:none.
await openSessionMenu();
const beforeKeyboardCollapse = await hiddenActionCounts();
await page.keyboard.press("Meta+B");
await expectDesktopCollapsed();
await expect.poll(() => sessionMenu.count()).toBe(0);
await expectHiddenShortcutsInert(beforeKeyboardCollapse);
await expandButton.click();
await expect.poll(() => sidebar.isVisible()).toBe(true);
// The visible desktop control follows the same focus handoff contract.
await openSessionMenu();
await collapseButton.click();
await expectDesktopCollapsed();
await expect.poll(() => sessionMenu.count()).toBe(0);
await expandButton.click();
await expect.poll(() => sidebar.isVisible()).toBe(true);
// Crossing into drawer layout hides the desktop sidebar without toggling
// persisted collapse state, so resize owns this dismissal and focus move.
await openSessionMenu();
const beforeNarrowTransition = await hiddenActionCounts();
await page.setViewportSize({ height: 900, width: 900 });
await expectDrawerClosed();
await expect.poll(() => sessionMenu.count()).toBe(0);
await expect
.poll(() => drawerToggle.evaluate((element) => element === document.activeElement))
.toBe(true);
await expectHiddenShortcutsInert(beforeNarrowTransition);
await drawerToggle.click();
await expect.poll(() => shell.getAttribute("class")).toContain("shell--nav-drawer-open");
await expect
.poll(() => shellNav.evaluate((element) => element.getBoundingClientRect().left))
.toBe(0);
await openSessionMenu();
const beforeDrawerCollapse = await hiddenActionCounts();
await page.keyboard.press("Meta+B");
await expectDrawerClosed();
await expect.poll(() => sessionMenu.count()).toBe(0);
await expect
.poll(() => drawerToggle.evaluate((element) => element === document.activeElement))
.toBe(true);
await expectHiddenShortcutsInert(beforeDrawerCollapse);
} finally {
await context.close();
}
});
it("keeps sidebar sessions visible through a same-client Gateway reconnect", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const sessionKey = "agent:main:disconnect-proof";
const otherSessionKeys = ["agent:main:other-a", "agent:main:other-b"] as const;
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.list": sessionsListResponse([
sessionRow(sessionKey, "Disconnect proof", Date.parse("2026-07-01T16:00:00.000Z")),
sessionRow(otherSessionKeys[0], "Other A", Date.parse("2026-07-01T15:59:00.000Z")),
sessionRow(otherSessionKeys[1], "Other B", Date.parse("2026-07-01T15:58:00.000Z")),
]),
},
sessionKey,
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const sidebarRow = page.locator(`.sidebar-recent-session[data-session-key="${sessionKey}"]`);
await sidebarRow.waitFor({ state: "visible", timeout: 10_000 });
const sidebarRows = page.locator(".sidebar-recent-session");
await expect.poll(() => sidebarRows.count()).toBe(3);
const initialListCount = (await gateway.getRequests("sessions.list")).length;
await gateway.closeLatest(1006, "disconnect proof");
await gateway.deferNext("sessions.list");
await sidebarRow.waitFor({ state: "visible" });
await captureUiProof(page, "sidebar-sessions-during-reconnect.png");
await expect.poll(() => gateway.getSocketCount(), { timeout: 15_000 }).toBeGreaterThan(1);
await expect
.poll(async () => (await gateway.getRequests("sessions.list")).length, { timeout: 15_000 })
.toBeGreaterThan(initialListCount);
await sidebarRow.waitFor({ state: "visible" });
expect(await sidebarRows.count()).toBe(3);
for (const otherKey of otherSessionKeys) {
await page
.locator(`.sidebar-recent-session[data-session-key="${otherKey}"]`)
.waitFor({ state: "visible" });
}
const firstReconnectListCount = (await gateway.getRequests("sessions.list")).length;
const refreshedResponse = sessionsListResponse([
sessionRow(sessionKey, "Reconnect refreshed", Date.parse("2026-07-01T16:01:00.000Z")),
sessionRow(otherSessionKeys[0], "Other A", Date.parse("2026-07-01T15:59:00.000Z")),
sessionRow(otherSessionKeys[1], "Other B", Date.parse("2026-07-01T15:58:00.000Z")),
]);
// Reconnect can queue a second refresh behind session hydration. Hold both
// so each response carries the changed black-box fixture.
await gateway.deferNext("sessions.list");
await gateway.resolveDeferred("sessions.list", refreshedResponse);
await expect.poll(() => sidebarRow.textContent()).toContain("Reconnect refreshed");
await expect.poll(() => sidebarRows.count()).toBe(3);
await expect
.poll(async () => (await gateway.getRequests("sessions.list")).length, { timeout: 10_000 })
.toBeGreaterThan(firstReconnectListCount);
await gateway.resolveDeferred("sessions.list", refreshedResponse);
await expect.poll(() => sidebarRow.textContent()).toContain("Reconnect refreshed");
} finally {
await context.close();
}
});
it("does not duplicate the active chat when its only session is pinned", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.list": sessionsListResponse([
sessionRow("agent:main:pinned", "Pinned only", Date.parse("2026-07-01T16:00:00.000Z"), {
pinned: true,
}),
]),
"sessions.patch": {},
},
sessionKey: "agent:main:pinned",
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const pinnedEntry = page.locator('[data-sidebar-entry="session:agent:main:pinned"]');
const chatsGroup = page.locator('[data-session-section="ungrouped"]');
await expect
.poll(() => trimmedTextContents(pinnedEntry.locator(".sidebar-recent-session__name")))
.toEqual(["Pinned only"]);
await expect.poll(() => chatsGroup.locator(".sidebar-recent-session").count()).toBe(0);
await expect.poll(() => page.locator(".sidebar-recent-session--active").count()).toBe(1);
// The empty Threads section only materializes after dragstart. Move the
// real pointer first so Playwright does not wait for a hidden target.
const pinnedRow = pinnedEntry.locator(".sidebar-recent-session");
const sourceBox = await pinnedRow.boundingBox();
if (!sourceBox) {
throw new Error("expected pinned session bounds");
}
await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2);
await page.mouse.down();
await page.mouse.move(sourceBox.x + sourceBox.width / 2 + 12, sourceBox.y + 12, {
steps: 4,
});
const sessionList = page.locator(".sidebar-sessions");
await sessionList.waitFor({ state: "visible" });
const targetBox = await sessionList.boundingBox();
if (!targetBox) {
throw new Error("expected session list bounds");
}
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, {
steps: 8,
});
await page.mouse.up();
const unpinPatch = await waitForPatch(
gateway,
(params) => params.key === "agent:main:pinned" && params.pinned === false,
);
expect(requireRecord(unpinPatch.params)).toMatchObject({
key: "agent:main:pinned",
pinned: false,
});
await expect.poll(() => pinnedEntry.count()).toBe(0);
await expect.poll(() => chatsGroup.locator(".sidebar-recent-session").count()).toBe(1);
} finally {
await context.close();
}
});
it("pins a session dropped into the interleaved sidebar zone", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
recordVideo: captureUiProofEnabled
? { dir: uiProofArtifactDir, size: { height: 900, width: 1280 } }
: undefined,
});
const page = await context.newPage();
const proofVideo = page.video();
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.list": sessionsListResponse([
sessionRow(
"agent:main:pinned",
"Already pinned",
Date.parse("2026-07-01T16:00:00.000Z"),
{
pinned: true,
},
),
sessionRow("agent:main:candidate", "Pin me", Date.parse("2026-07-01T15:59:00.000Z"), {
category: "Research",
}),
]),
"sessions.patch": {},
},
featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"],
sessionKey: "agent:main:candidate",
sessionGroups: ["Research"],
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const pinnedEntry = page.locator('[data-sidebar-entry="session:agent:main:pinned"]');
const researchGroup = page.locator('[data-session-section="category:Research"]');
await expect
.poll(() => trimmedTextContents(pinnedEntry.locator(".sidebar-recent-session__name")))
.toEqual(["Already pinned"]);
await captureUiProof(page, "sidebar-session-before-pinned-drop.png");
await researchGroup
.locator('.sidebar-recent-session[data-session-key="agent:main:candidate"]')
.dragTo(pinnedEntry);
const pinPatch = await waitForPatch(
gateway,
(params) => params.key === "agent:main:candidate" && params.pinned === true,
);
expect(requireRecord(pinPatch.params)).toMatchObject({
key: "agent:main:candidate",
pinned: true,
});
expect(requireRecord(pinPatch.params)).not.toHaveProperty("category");
await expect
.poll(() =>
trimmedTextContents(
page.locator('[data-sidebar-entry^="session:"] .sidebar-recent-session__name'),
),
)
.toEqual(["Already pinned", "Pin me"]);
await expect.poll(() => researchGroup.locator(".sidebar-recent-session").count()).toBe(0);
await captureUiProof(page, "sidebar-session-dropped-into-pinned.png");
} finally {
await context.close();
if (proofVideo) {
await proofVideo.saveAs(path.join(uiProofArtifactDir, "sidebar-session-pinned-drop.webm"));
}
}
});
it("keeps raw ids out of work rows while their metadata grows in place", async () => {
const baseTime = Date.parse("2026-07-01T16:00:00.000Z");
const nodeHash = "11c38726acc6fac280357576c87acc6fac280357";
const rows = (withWork: boolean) => {
const ts = baseTime + (withWork ? 5_000 : 0);
return [
sessionRow("agent:main:main", "Main", ts),
sessionRow(
"agent:main:dashboard:0f9d5c1e-6d0f-4c9a-9d84-1c2f3a4b5c6d",
"",
ts - 60_000,
withWork ? { execNode: nodeHash } : {},
),
sessionRow(
"agent:main:dashboard:0f9d5c1e-6d0f-4c9a-9d84-1c2f3a4b5c6e",
"",
ts - 120_000,
withWork
? {
execNode: nodeHash,
worktree: { branch: "openclaw/wt-1", repoRoot: "/Users/dev/Projects/clawdbot" },
}
: {},
),
sessionRow("agent:main:node-mcp-debug-4de003fbff138fcb9239c9378b2e", "", ts - 180_000),
];
};
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.list": sessionsListResponse(rows(false)),
},
sessionKey: "agent:main:main",
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
await expect
.poll(() => page.locator(".sidebar-recent-session").count(), { timeout: 15_000 })
.toBeGreaterThan(0);
// Add work metadata only after first layout so the WebKit overlap
// regression still exercises in-place row growth.
const listRequests = (await gateway.getRequests("sessions.list")).length;
await gateway.setMethodResponse("sessions.list", sessionsListResponse(rows(true)));
await gateway.emitGatewayEvent("sessions.changed", {
reason: "update",
sessionKey: "agent:main:dashboard:0f9d5c1e-6d0f-4c9a-9d84-1c2f3a4b5c6e",
});
await expect
.poll(async () => (await gateway.getRequests("sessions.list")).length)
.toBeGreaterThan(listRequests);
const codingToggle = page.locator(
'[data-session-section="work"] .sidebar-session-group-toggle',
);
await codingToggle.waitFor({ state: "visible" });
await expect.poll(() => codingToggle.getAttribute("aria-expanded")).toBe("false");
await codingToggle.click();
const namesLocator = page.locator(".sidebar-recent-session__name");
await expect
.poll(() => trimmedTextContents(namesLocator))
.toContain("clawdbot ⎇ wt-1 · …0357");
// Names and subtitles never show raw node ids or raw agent keys.
const names = await trimmedTextContents(page.locator(".sidebar-recent-session__name"));
expect(names).toContain("New thread");
expect(names).toContain("clawdbot ⎇ wt-1 · …0357");
expect(names).toContain("node-mcp-debug-…8b2e");
const subtitles = await trimmedTextContents(
page.locator(".sidebar-recent-session__subtitle"),
);
expect(subtitles).toContain("…0357");
for (const text of [...names, ...subtitles]) {
expect(text).not.toContain(nodeHash);
expect(text).not.toContain("agent:main:");
}
// Sections must lay out below the rows above them, not paint over them.
const overlaps = await page.evaluate(() => {
const rects = [
...document.querySelectorAll(".sidebar-recent-session, .sidebar-recent-sessions__head"),
]
.map((element) => {
const rect = element.getBoundingClientRect();
return { top: rect.top, bottom: rect.bottom };
})
.filter((rect) => rect.bottom > rect.top)
.toSorted((a, b) => a.top - b.top);
let bad = 0;
let previousBottom: number | undefined;
for (const rect of rects) {
if (previousBottom !== undefined && rect.top < previousBottom - 2) {
bad += 1;
}
previousBottom = rect.bottom;
}
return bad;
});
expect(overlaps).toBe(0);
} finally {
await context.close();
}
});
it("scrolls long session lists in short windows instead of squeezing sections", async () => {
const baseTime = Date.parse("2026-07-01T16:00:00.000Z");
const rows = [
...Array.from({ length: 8 }, (_, index) =>
sessionRow(`agent:main:work-${index}`, `Work session ${index}`, baseTime - index * 60_000, {
worktree: { branch: `openclaw/wt-${index}`, repoRoot: "/Users/dev/Projects/clawdbot" },
}),
),
...Array.from({ length: 30 }, (_, index) =>
sessionRow(`agent:main:chat-${index}`, `Chat ${index}`, baseTime - (index + 10) * 60_000),
),
];
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 620, width: 1280 },
});
const page = await context.newPage();
await installMockGateway(page, {
methodResponses: {
"sessions.list": sessionsListResponse(rows),
},
sessionKey: "agent:main:main",
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
await page.locator('[data-session-section="work"] .sidebar-session-group-toggle').click();
const loadMore = page
.locator('[data-session-section="ungrouped"]')
.getByRole("button", { name: "Show more" });
for (let pageIndex = 0; pageIndex < 3 && (await loadMore.isVisible()); pageIndex += 1) {
await loadMore.click();
}
await page.locator(".sidebar-shell__body").evaluate((element) => {
element.scrollTop = 0;
});
await expect
.poll(() => page.locator(".sidebar-recent-session").count(), { timeout: 15_000 })
.toBe(rows.length);
await captureUiProof(page, "short-window-session-sections.png");
// Sections must stack below each other, not paint over the rows above.
const overlaps = await page.evaluate(() => {
const rects = [
...document.querySelectorAll(".sidebar-recent-session, .sidebar-recent-sessions__head"),
]
.map((element) => {
const rect = element.getBoundingClientRect();
return { top: rect.top, bottom: rect.bottom };
})
.filter((rect) => rect.bottom > rect.top)
.toSorted((a, b) => a.top - b.top);
let bad = 0;
let previousBottom: number | undefined;
for (const rect of rects) {
if (previousBottom !== undefined && rect.top < previousBottom - 2) {
bad += 1;
}
previousBottom = rect.bottom;
}
return bad;
});
expect(overlaps).toBe(0);
// The squeeze regression compressed sections into the viewport with no
// overflow; a healthy sidebar body is taller than its viewport and scrolls.
const scroll = await page.evaluate(() => {
const list = document.querySelector(".sidebar-shell__body");
if (!list) {
return null;
}
list.scrollTop = list.scrollHeight;
return {
clientHeight: list.clientHeight,
scrollHeight: list.scrollHeight,
scrollTop: list.scrollTop,
};
});
expect(scroll).not.toBeNull();
expect(scroll?.scrollHeight ?? 0).toBeGreaterThan(scroll?.clientHeight ?? 0);
expect(scroll?.scrollTop ?? 0).toBeGreaterThan(0);
} finally {
await context.close();
}
});
it("keeps sidebar session controls reachable on touch pointers", async () => {
const context = await suite.browser.newContext({
hasTouch: true,
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
await installMockGateway(page, {
methodResponses: {
"sessions.list": sessionsListResponse([
sessionRow("agent:main:main", "Main", Date.parse("2026-07-01T16:00:00.000Z")),
sessionRow(
"agent:main:research",
"Research notes",
Date.parse("2026-07-01T15:00:00.000Z"),
),
]),
},
sessionKey: "agent:main:main",
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const row = page
.locator(".sidebar-recent-sessions__list .sidebar-recent-session")
.filter({ hasText: "Research notes" });
await row.waitFor({ state: "visible", timeout: 10_000 });
const pin = row.getByRole("button", { name: "Pin thread" });
const menu = row.getByRole("button", { name: "Open thread menu" });
await expect.poll(() => actionOpacity(pin)).toBe("1");
await expect.poll(() => actionPointerEvents(pin)).toBe("auto");
await expect.poll(() => actionOpacity(menu)).toBe("1");
await expect.poll(() => actionPointerEvents(menu)).toBe("auto");
await menu.click();
await page.getByRole("menuitem", { name: "Archive thread" }).waitFor({ state: "visible" });
} finally {
await context.close();
}
});
});
@@ -0,0 +1,144 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Locator, Page } from "playwright";
import {
controlUiSessionPath,
controlUiSessionUrl,
installMockGateway,
type MockGatewayControls,
type MockGatewayRequest,
} from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
export { controlUiSessionPath, controlUiSessionUrl, installMockGateway };
export const collapsedSessionSectionsStorageKey = "openclaw:sidebar:sessions:collapsed-sections";
export const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
export const uiProofArtifactDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"thread-management",
);
export function createSessionManagementE2eSuite() {
return createControlUiE2eSuite({
name: "Control UI session management mocked Gateway E2E",
unavailableMessage: (executablePath) =>
`Playwright Chromium is not installed or cannot start at ${executablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`,
});
}
export function sessionRow(
key: string,
label: string,
updatedAt: number,
options: {
archived?: boolean;
category?: string;
pinned?: boolean;
pinnedAt?: number;
hasActiveRun?: boolean;
status?: string;
spawnedBy?: string;
startedAt?: number;
endedAt?: number;
childSessions?: string[];
execNode?: string;
worktree?: { branch?: string; repoRoot?: string };
} = {},
) {
return {
contextTokens: null,
displayName: label,
hasActiveRun: false,
key,
kind: "direct",
label,
model: "gpt-5.5",
modelProvider: "openai",
status: "done",
totalTokens: 0,
updatedAt,
...options,
};
}
export function sessionsListResponse(
sessions: unknown[],
options: {
hasMore?: boolean;
nextOffset?: number | null;
offset?: number;
totalCount?: number;
} = {},
) {
return {
count: sessions.length,
defaults: {
contextTokens: null,
model: "gpt-5.5",
modelProvider: "openai",
},
hasMore: options.hasMore ?? false,
limitApplied: 50,
nextOffset: options.nextOffset ?? null,
offset: options.offset ?? 0,
path: "",
sessions,
totalCount: options.totalCount ?? sessions.length,
ts: Date.now(),
};
}
export function requireRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Expected object value");
}
return value as Record<string, unknown>;
}
export async function waitForPatch(
gateway: MockGatewayControls,
predicate: (params: Record<string, unknown>) => boolean,
): Promise<MockGatewayRequest> {
const deadline = Date.now() + 10_000;
let requests: MockGatewayRequest[] = [];
while (Date.now() < deadline) {
requests = await gateway.getRequests("sessions.patch");
const match = requests.find((request) => predicate(requireRecord(request.params)));
if (match) {
return match;
}
await new Promise((resolve) => {
setTimeout(resolve, 10);
});
}
throw new Error(`No matching sessions.patch request found: ${JSON.stringify(requests)}`);
}
export async function activateMenuItem(item: Locator): Promise<void> {
await item.evaluate((element) => (element as HTMLElement).click());
}
export function trimmedTextContents(locator: Locator): Promise<string[]> {
return locator.evaluateAll((elements) =>
elements.map((element) => element.textContent?.trim() ?? ""),
);
}
export function actionOpacity(button: Locator): Promise<string> {
return button.evaluate((element) => globalThis.getComputedStyle(element).opacity);
}
export function actionPointerEvents(button: Locator): Promise<string> {
return button.evaluate((element) => globalThis.getComputedStyle(element).pointerEvents);
}
export async function captureUiProof(page: Page, fileName: string) {
if (!captureUiProofEnabled) {
return;
}
await mkdir(uiProofArtifactDir, { recursive: true });
await page.screenshot({ fullPage: true, path: path.join(uiProofArtifactDir, fileName) });
}