test(ui): deduplicate control UI e2e setup (#118180)

This commit is contained in:
Peter Steinberger
2026-08-02 14:00:48 -07:00
committed by GitHub
parent b35b8e286d
commit aab2bd2bb9
4 changed files with 889 additions and 1431 deletions
+192 -300
View File
@@ -1,5 +1,6 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Page } from "playwright";
import { expect, it } from "vitest";
import {
captureUiProofEnabled,
@@ -16,25 +17,31 @@ import {
const suite = createChatFlowE2eSuite();
async function withChatPage(run: (page: Page) => Promise<void>): Promise<void> {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
try {
await run(await context.newPage());
} finally {
await suite.closeBrowserContext(context);
}
}
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 withChatPage(async (page) => {
const gateway = await installMockGateway(page, {
historyMessages: [
{
content: [{ text: "Ready for an end-to-end GUI check.", type: "text" }],
role: "assistant",
timestamp: Date.now(),
},
],
});
await page.goto(`${suite.server.baseUrl}chat`);
await page.getByText("Ready for an end-to-end GUI check.").waitFor({ timeout: 10_000 });
@@ -63,22 +70,13 @@ suite.define(() => {
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("adopts a browser-local prompt from a metadata-free gateway event without duplication", 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 = "Keep my browser-local prompt synchronized.";
try {
await withChatPage(async (page) => {
const gateway = await installMockGateway(page, { historyMessages: [] });
const prompt = "Keep my browser-local prompt synchronized.";
await page.goto(`${suite.server.baseUrl}chat`);
await page.locator(".agent-chat__composer-combobox textarea").fill(prompt);
await page.getByRole("button", { name: "Send message" }).click();
@@ -135,32 +133,23 @@ suite.define(() => {
timeout: 10_000,
});
await expect.poll(() => userRow.count()).toBe(1);
} finally {
await suite.closeBrowserContext(context);
}
});
});
it("preserves distinct same-text peer messages through conflicting envelopes and stale history", 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 = "Both clients independently sent the same prompt.";
const persistedMessages = ["web", "tui"].map((client, index) => ({
__openclaw: {
id: `canonical-${client}-same-text`,
idempotencyKey: `${client}-same-text-run:user`,
seq: index + 1,
},
content: [{ text: prompt, type: "text" }],
role: "user",
timestamp: 1_700_000_000_000 + index,
}));
try {
await withChatPage(async (page) => {
const gateway = await installMockGateway(page, { historyMessages: [] });
const prompt = "Both clients independently sent the same prompt.";
const persistedMessages = ["web", "tui"].map((client, index) => ({
__openclaw: {
id: `canonical-${client}-same-text`,
idempotencyKey: `${client}-same-text-run:user`,
seq: index + 1,
},
content: [{ text: prompt, type: "text" }],
role: "user",
timestamp: 1_700_000_000_000 + index,
}));
await page.goto(`${suite.server.baseUrl}chat`);
await gateway.waitForRequest("chat.startup");
const historyCount = (await gateway.getRequests("chat.history")).length;
@@ -217,9 +206,7 @@ suite.define(() => {
await emitPersistedMessage(0);
await expectDistinctPeerBubbles();
} finally {
await suite.closeBrowserContext(context);
}
});
});
it.each([
@@ -228,41 +215,34 @@ suite.define(() => {
])(
"keeps another client's $identity user turn ahead of its already-streaming reply",
async ({ includeMessageMetadata }) => {
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 runId = "shared-session-tui-run";
const prompt = "Sent from the other client.";
const secondPrompt = "A distinct turn in the same shared run.";
const partial = "Streaming into both clients.";
const userMessage = {
...(includeMessageMetadata
? { __openclaw: { id: "shared-session-user", idempotencyKey: `${runId}:user`, seq: 1 } }
: {}),
content: [{ text: prompt, type: "text" }],
role: "user",
timestamp: Date.now(),
};
const secondUserMessage = {
...(includeMessageMetadata
? {
__openclaw: {
id: "shared-session-second-user",
idempotencyKey: `${runId}:user`,
seq: 2,
},
}
: {}),
content: [{ text: secondPrompt, type: "text" }],
role: "user",
timestamp: Date.now(),
};
try {
await withChatPage(async (page) => {
const gateway = await installMockGateway(page, { historyMessages: [] });
const runId = "shared-session-tui-run";
const prompt = "Sent from the other client.";
const secondPrompt = "A distinct turn in the same shared run.";
const partial = "Streaming into both clients.";
const userMessage = {
...(includeMessageMetadata
? { __openclaw: { id: "shared-session-user", idempotencyKey: `${runId}:user`, seq: 1 } }
: {}),
content: [{ text: prompt, type: "text" }],
role: "user",
timestamp: Date.now(),
};
const secondUserMessage = {
...(includeMessageMetadata
? {
__openclaw: {
id: "shared-session-second-user",
idempotencyKey: `${runId}:user`,
seq: 2,
},
}
: {}),
content: [{ text: secondPrompt, type: "text" }],
role: "user",
timestamp: Date.now(),
};
await page.goto(`${suite.server.baseUrl}chat`);
await gateway.waitForRequest("chat.startup");
await gateway.setHistoryMessages([userMessage]);
@@ -368,9 +348,7 @@ suite.define(() => {
});
await expect.poll(() => userRow.count()).toBe(1);
await expect.poll(() => secondUserRow.count()).toBe(1);
} finally {
await suite.closeBrowserContext(context);
}
});
},
);
@@ -380,30 +358,23 @@ suite.define(() => {
])(
"preserves a delayed persisted prompt ahead of a finalized reply with $history history",
async ({ includesPrompt }) => {
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 runId = "shared-session-finalized-run";
const prompt = "The persisted prompt arrived after the final.";
const finalText = "The reply was already finished.";
const userMessage = {
__openclaw: { id: "finalized-run-user", idempotencyKey: `${runId}:user`, seq: 1 },
content: [{ text: prompt, type: "text" }],
role: "user",
timestamp: Date.now(),
};
const assistantMessage = {
__openclaw: { id: "finalized-run-assistant", seq: 2 },
content: [{ text: finalText, type: "text" }],
role: "assistant",
timestamp: Date.now(),
};
try {
await withChatPage(async (page) => {
const gateway = await installMockGateway(page, { historyMessages: [] });
const runId = "shared-session-finalized-run";
const prompt = "The persisted prompt arrived after the final.";
const finalText = "The reply was already finished.";
const userMessage = {
__openclaw: { id: "finalized-run-user", idempotencyKey: `${runId}:user`, seq: 1 },
content: [{ text: prompt, type: "text" }],
role: "user",
timestamp: Date.now(),
};
const assistantMessage = {
__openclaw: { id: "finalized-run-assistant", seq: 2 },
content: [{ text: finalText, type: "text" }],
role: "assistant",
timestamp: Date.now(),
};
await page.goto(`${suite.server.baseUrl}chat`);
await gateway.waitForRequest("chat.startup");
await gateway.emitGatewayEvent("chat", {
@@ -473,41 +444,35 @@ suite.define(() => {
await expect
.poll(() => page.locator(".chat-group.user", { hasText: prompt }).count())
.toBe(1);
} 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 withChatPage(async (page) => {
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 },
);
await page.goto(`${suite.server.baseUrl}chat`);
await gateway.deferNext("chat.send");
await page.locator(".agent-chat__composer-combobox textarea").fill(prompt);
@@ -552,20 +517,12 @@ suite.define(() => {
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 withChatPage(async (page) => {
const gateway = await installMockGateway(page, { historyMessages: [] });
await page.goto(`${suite.server.baseUrl}chat`);
const composer = page.locator(".agent-chat__composer-combobox textarea");
await composer.fill("reconcile the terminal event ordering");
@@ -648,31 +605,22 @@ suite.define(() => {
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 withChatPage(async (page) => {
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",
});
await page.goto(controlUiSessionUrl(suite.server.baseUrl, "main"));
await page.getByText(historyText).waitFor({ timeout: 10_000 });
await gateway.waitForRequest("chat.startup");
@@ -684,21 +632,12 @@ suite.define(() => {
// on reload, so the restored page records its own startup request once.
await gateway.waitForRequest("chat.startup");
expect(await gateway.getRequests("chat.startup")).toHaveLength(1);
} 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 withChatPage(async (page) => {
const gateway = await installMockGateway(page);
await page.goto(`${suite.server.baseUrl}chat`);
const composer = page.locator(".agent-chat__composer-combobox textarea");
await composer.waitFor({ state: "visible", timeout: 10_000 });
@@ -708,37 +647,28 @@ suite.define(() => {
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 withChatPage(async (page) => {
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(),
},
]),
},
});
await page.goto(`${suite.server.baseUrl}chat`);
const composer = page.locator(".agent-chat__composer-combobox textarea");
await composer.waitFor({ state: "visible", timeout: 10_000 });
@@ -789,21 +719,12 @@ suite.define(() => {
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 withChatPage(async (page) => {
const gateway = await installMockGateway(page);
await page.goto(`${suite.server.baseUrl}chat`);
const composer = page.locator(".agent-chat__composer-combobox textarea");
await composer.waitFor({ state: "visible", timeout: 10_000 });
@@ -848,42 +769,33 @@ suite.define(() => {
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([
await withChatPage(async (page) => {
const sessionKey = "main";
const gateway = await installMockGateway(page, {
historyMessages: [
{
hasActiveRun: true,
key: "agent:main:main",
kind: "direct",
label: "Main",
updatedAt: Date.now(),
content: [{ text: "Active run is waiting for steering.", type: "text" }],
role: "assistant",
timestamp: Date.now(),
},
]),
},
sessionKey,
});
try {
],
methodResponses: {
"sessions.list": chatSessionListResponse([
{
hasActiveRun: true,
key: "agent:main:main",
kind: "direct",
label: "Main",
updatedAt: Date.now(),
},
]),
},
sessionKey,
});
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");
@@ -902,21 +814,12 @@ suite.define(() => {
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 withChatPage(async (page) => {
const gateway = await installMockGateway(page);
await page.goto(`${suite.server.baseUrl}chat`);
const prompt = "send progress through the message tool and then finish";
@@ -950,29 +853,20 @@ suite.define(() => {
]) {
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 withChatPage(async (page) => {
const gateway = await installMockGateway(page, {
historyMessages: [
{
content: [{ text: "Ready for stale replay check.", type: "text" }],
role: "assistant",
timestamp: Date.now(),
},
],
});
await page.goto(`${suite.server.baseUrl}chat`);
await page.getByText("Ready for stale replay check.").waitFor({ timeout: 10_000 });
@@ -1001,8 +895,6 @@ suite.define(() => {
await composer.pressSequentially(prompt);
expect(await composer.inputValue()).toBe(prompt);
} finally {
await suite.closeBrowserContext(context);
}
});
});
});
@@ -1,5 +1,6 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Page } from "playwright";
import { expect, it } from "vitest";
import {
ONE_PIXEL_PNG_B64,
@@ -18,16 +19,23 @@ import {
const suite = createNewSessionPageE2eSuite();
async function withNewSessionPage(run: (page: Page) => Promise<void>): Promise<void> {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
try {
await run(await context.newPage());
} finally {
await context.close();
}
}
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 withNewSessionPage(async (page) => {
const gateway = await installMockGateway(page);
await page.goto(`${suite.server.baseUrl}new`);
const message = page.locator(".new-session-page__message");
await message.waitFor();
@@ -79,24 +87,16 @@ suite.define(() => {
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 withNewSessionPage(async (page) => {
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.create": { key: "agent:main:image-draft", runStarted: true },
},
});
await page.goto(`${suite.server.baseUrl}new`);
const message = page.locator(".new-session-page__message");
await message.waitFor();
@@ -118,55 +118,47 @@ suite.define(() => {
},
],
});
} 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" },
await withNewSessionPage(async (page) => {
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();
@@ -185,32 +177,24 @@ suite.define(() => {
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" },
await withNewSessionPage(async (page) => {
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();
@@ -256,55 +240,47 @@ suite.define(() => {
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" },
await withNewSessionPage(async (page) => {
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,
},
{ 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" },
],
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);
@@ -333,32 +309,24 @@ suite.define(() => {
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 withNewSessionPage(async (page) => {
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 },
},
});
await page.goto(`${suite.server.baseUrl}new`);
const composer = page.locator(".new-session-page__message");
const submit = page.getByRole("button", { name: "Start thread" });
@@ -384,43 +352,35 @@ suite.define(() => {
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 withNewSessionPage(async (page) => {
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);
await page.goto(`${suite.server.baseUrl}new`);
const composer = page.locator(".new-session-page__message");
await pastePng(composer, 2);
@@ -450,71 +410,62 @@ suite.define(() => {
),
)
.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" },
],
await withNewSessionPage(async (page) => {
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 },
},
"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;
});
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 {
if (!app.runtime) {
throw new Error("OpenClaw application runtime is unavailable");
}
app.runtime.context.navigate(targetRouteId, { search: targetSearch });
},
{ targetRouteId: routeId, targetSearch: search },
);
await page.goto(`${suite.server.baseUrl}new`);
const composer = page.locator(".new-session-page__message");
@@ -565,54 +516,45 @@ suite.define(() => {
(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",
await withNewSessionPage(async (page) => {
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 },
},
"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");
@@ -660,72 +602,63 @@ suite.define(() => {
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",
await withNewSessionPage(async (page) => {
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" },
},
"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);
@@ -763,49 +696,40 @@ suite.define(() => {
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 },
await withNewSessionPage(async (page) => {
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" },
},
"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);
@@ -829,8 +753,6 @@ suite.define(() => {
attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }],
});
expect(await gateway.getRequests("sessions.create")).toHaveLength(1);
} finally {
await context.close();
}
});
});
});
@@ -1,3 +1,4 @@
import type { BrowserContextOptions, Page } from "playwright";
import { expect, it } from "vitest";
import {
MOVED_WORKSPACE,
@@ -14,19 +15,91 @@ import {
} from "./new-session-page.test-support.ts";
const suite = createNewSessionPageE2eSuite();
const BASE_CONTEXT: BrowserContextOptions = { locale: "en-US", serviceWorkers: "block" };
const DESKTOP_CONTEXT: BrowserContextOptions = {
...BASE_CONTEXT,
viewport: { height: 900, width: 1280 },
};
const MOBILE_CONTEXT: BrowserContextOptions = {
...BASE_CONTEXT,
viewport: { height: 568, width: 320 },
};
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 GIT_BRANCHES = {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
};
const FOLDER_LISTINGS = {
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: [] },
},
],
};
function mainAgentList(workspace = WORKSPACE, workspaceGit = true) {
return {
agents: [
{
id: "main",
identity: { name: "Main" },
name: "Main",
workspace,
workspaceGit,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
};
}
async function readMainPreference(page: Page): Promise<Record<string, unknown> | null> {
return 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, Record<string, unknown>>;
} | null)
: null;
return value?.agents?.main ?? null;
});
}
async function withNewSessionPage(
options: BrowserContextOptions,
run: (page: Page) => Promise<void>,
): Promise<void> {
const context = await suite.browser.newContext(options);
try {
await run(await context.newPage());
} finally {
await context.close();
}
}
suite.define(() => {
it("keeps the mobile incognito and model controls separated", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 568, width: 320 },
});
const page = await context.newPage();
await installMockGateway(page, {
models: [{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol", provider: "openai" }],
});
try {
await withNewSessionPage(MOBILE_CONTEXT, async (page) => {
await installMockGateway(page, {
models: [{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol", provider: "openai" }],
});
await page.goto(`${suite.server.baseUrl}new`);
const footer = page.locator(".new-session-page__composer .agent-chat__composer-footer");
const incognito = page.getByRole("switch", { name: "Incognito" });
@@ -50,28 +123,17 @@ suite.define(() => {
(footerBox?.x ?? 0) + (footerBox?.width ?? 0),
);
}
} finally {
await context.close();
}
});
});
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 withNewSessionPage(DESKTOP_CONTEXT, async (page) => {
const gateway = await installMockGateway(page, {
models: MODELS,
methodResponses: {
"sessions.create": { key: "agent:main:model-draft", runStarted: true },
},
});
await page.goto(`${suite.server.baseUrl}new`);
const modelSelect = page.locator('[data-chat-model-select="true"]');
await modelSelect.waitFor();
@@ -119,73 +181,20 @@ suite.define(() => {
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",
await withNewSessionPage(DESKTOP_CONTEXT, async (page) => {
const gateway = await installMockGateway(page, {
workspaceGit: true,
models: MODELS,
methodResponses: {
"agents.list": mainAgentList(),
"worktrees.branches": GIT_BRANCHES,
"fs.listDir": FOLDER_LISTINGS,
},
],
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);
@@ -233,95 +242,28 @@ suite.define(() => {
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 gateway.setMethodResponse("agents.list", mainAgentList(WORKSPACE, false));
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;
});
const storedWorktree = (await readMainPreference(page))?.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",
await withNewSessionPage(BASE_CONTEXT, async (page) => {
const models = MODELS;
const branches = GIT_BRANCHES;
const gateway = await installMockGateway(page, {
workspaceGit: true,
models,
methodResponses: {
"agents.list": mainAgentList(),
"worktrees.branches": branches,
"fs.listDir": FOLDER_LISTINGS,
"sessions.create": { key: "agent:main:restored-fast-submit", runStarted: true },
},
"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");
@@ -380,53 +322,25 @@ suite.define(() => {
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",
await withNewSessionPage(BASE_CONTEXT, async (page) => {
const gateway = await installMockGateway(page, {
workspaceGit: true,
models: MODELS,
methodResponses: {
"agents.list": mainAgentList(),
"worktrees.branches": GIT_BRANCHES,
"fs.listDir": {
path: WORKSPACE,
parent: "/home/peter",
home: "/home/peter",
entries: [],
},
},
],
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();
@@ -435,20 +349,7 @@ suite.define(() => {
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 gateway.setMethodResponse("agents.list", mainAgentList(MOVED_WORKSPACE));
await page.reload();
await navigateInApp(page, "new-session");
await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe(
@@ -459,75 +360,27 @@ suite.define(() => {
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;
});
const storedPreference = await readMainPreference(page);
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",
await withNewSessionPage(BASE_CONTEXT, async (page) => {
const gateway = await installMockGateway(page, {
workspace: WORKSPACE,
workspaceGit: true,
methodResponses: {
"agents.list": mainAgentList(),
"worktrees.branches": GIT_BRANCHES,
"fs.listDir": FOLDER_LISTINGS,
"sessions.create": { key: "agent:main:stale-folder-fallback", runStarted: true },
},
"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);
@@ -588,58 +441,21 @@ suite.define(() => {
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",
await withNewSessionPage(BASE_CONTEXT, async (page) => {
const gateway = await installMockGateway(page, {
workspace: WORKSPACE,
workspaceGit: true,
methodResponses: {
"agents.list": mainAgentList(),
"worktrees.branches": GIT_BRANCHES,
"fs.listDir": FOLDER_LISTINGS,
"sessions.create": { key: "agent:main:newer-folder-wins", runStarted: true },
},
"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);
@@ -671,41 +487,20 @@ suite.define(() => {
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",
await withNewSessionPage(BASE_CONTEXT, async (page) => {
const gateway = await installMockGateway(page, {
deferredMethods: ["agents.list"],
workspaceGit: true,
methodResponses: {
"agents.list": mainAgentList(),
"fs.listDir": { path: TARGET_REPO, home: "/home/peter", entries: [] },
"worktrees.branches": GIT_BRANCHES,
},
"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();
@@ -716,20 +511,7 @@ suite.define(() => {
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 gateway.resolveDeferred("agents.list", mainAgentList());
await page.locator(".new-session-page__message").fill("keep my early folder choice");
await expect
@@ -738,21 +520,8 @@ suite.define(() => {
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;
});
const storedPreference = await readMainPreference(page);
expect(storedPreference).toMatchObject({ folder: TARGET_REPO });
} finally {
await context.close();
}
});
});
});
@@ -1,3 +1,4 @@
import type { BrowserContextOptions, Page } from "playwright";
import { expect, it } from "vitest";
import {
SOURCE_REPO,
@@ -10,25 +11,85 @@ import {
} from "./new-session-page.test-support.ts";
const suite = createNewSessionPageE2eSuite();
const BASE_CONTEXT: BrowserContextOptions = { locale: "en-US", serviceWorkers: "block" };
const DESKTOP_CONTEXT: BrowserContextOptions = {
...BASE_CONTEXT,
viewport: { height: 900, width: 1280 },
};
function mainAgentList(name = "Main", workspace = WORKSPACE) {
return {
agents: [
{
id: "main",
identity: { name },
name,
workspace,
workspaceGit: true,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
};
}
function branchList(name = "main") {
return {
branches: [{ kind: "local", name }],
defaultBranch: name,
repositoryStatus: "git",
};
}
async function withNewSessionPage(
options: BrowserContextOptions,
run: (page: Page) => Promise<void>,
): Promise<void> {
const context = await suite.browser.newContext(options);
try {
await run(await context.newPage());
} finally {
await context.close();
}
}
type MockGateway = Awaited<ReturnType<typeof installMockGateway>>;
async function chooseCustomFolder(page: Page, gateway: MockGateway) {
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 });
return { place, trigger };
}
async function reconnectForBranchRediscovery(page: Page, gateway: MockGateway) {
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);
}
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",
await withNewSessionPage(BASE_CONTEXT, async (page) => {
const gateway = await installMockGateway(page, {
workspace: WORKSPACE,
workspaceGit: true,
methodResponses: {
"worktrees.branches": branchList(),
"sessions.create": { key: "agent:main:worktree-unavailable" },
},
"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");
@@ -66,39 +127,22 @@ suite.define(() => {
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",
await withNewSessionPage(BASE_CONTEXT, async (page) => {
const gateway = await installMockGateway(page, {
workspace: WORKSPACE,
workspaceGit: true,
methodResponses: {
"fs.listDir": { path: WORKSPACE, home: "/home/peter", entries: [] },
"worktrees.branches": branchList(),
"sessions.create": { key: "agent:main:custom-now-direct" },
},
"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 });
const { place, trigger } = await chooseCustomFolder(page, gateway);
await trigger.click();
await place.getByRole("button", { name: "Worktree" }).click();
await page.keyboard.press("Escape");
@@ -108,13 +152,7 @@ suite.define(() => {
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 reconnectForBranchRediscovery(page, gateway);
await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("false");
const storedWorktree = await page.evaluate(() => {
@@ -137,39 +175,22 @@ suite.define(() => {
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("allows clearing 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",
await withNewSessionPage(BASE_CONTEXT, async (page) => {
const gateway = await installMockGateway(page, {
workspace: WORKSPACE,
workspaceGit: true,
methodResponses: {
"fs.listDir": { path: WORKSPACE, home: "/home/peter", entries: [] },
"worktrees.branches": branchList(),
"sessions.create": { key: "agent:main:custom-worktree-cleared" },
},
"sessions.create": { key: "agent:main:custom-worktree-cleared" },
},
});
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 });
const { place, trigger } = await chooseCustomFolder(page, gateway);
await trigger.click();
await place.getByRole("button", { name: "Worktree" }).click();
await page.keyboard.press("Escape");
@@ -179,13 +200,7 @@ suite.define(() => {
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 reconnectForBranchRediscovery(page, gateway);
await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true");
await page.locator(".new-session-page__message").fill("do not run directly");
@@ -210,43 +225,26 @@ suite.define(() => {
message: "do not run directly",
});
expect(create.params).not.toHaveProperty("worktree");
} 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" }],
await withNewSessionPage(BASE_CONTEXT, async (page) => {
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": branchList(),
},
"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 });
const { place, trigger } = await chooseCustomFolder(page, gateway);
await trigger.click();
await place.getByRole("button", { name: "Cloud · aws" }).click();
await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws");
@@ -256,13 +254,7 @@ suite.define(() => {
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 reconnectForBranchRediscovery(page, gateway);
await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws");
await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true");
@@ -279,54 +271,28 @@ suite.define(() => {
expect(await worktree.getAttribute("aria-pressed")).toBe("true");
expect(await worktree.isDisabled()).toBe(true);
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",
await withNewSessionPage(DESKTOP_CONTEXT, async (page) => {
const gateway = await installMockGateway(page, {
methodResponses: {
"agents.list": mainAgentList(),
"node.list": {
nodes: [
{
nodeId: "old-device",
displayName: "Old device",
connected: true,
commands: ["system.run", "fs.listDir"],
},
],
},
"worktrees.branches": branchList(),
"sessions.create": { key: "agent:main:validated-device" },
},
"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");
@@ -352,53 +318,27 @@ suite.define(() => {
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",
await withNewSessionPage(DESKTOP_CONTEXT, async (page) => {
const gateway = await installMockGateway(page, {
methodResponses: {
"agents.list": mainAgentList("Original agent", SOURCE_REPO),
"node.list": {
nodes: [
{
nodeId: "old-device",
displayName: "Old device",
connected: true,
commands: ["system.run", "fs.listDir"],
},
],
},
"worktrees.branches": branchList("alpha"),
},
"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");
@@ -418,20 +358,10 @@ suite.define(() => {
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(
"agents.list",
mainAgentList("Replacement agent", TARGET_REPO),
);
await gateway.setMethodResponse("node.list", {
nodes: [
{
@@ -442,11 +372,7 @@ suite.define(() => {
},
],
});
await gateway.setMethodResponse("worktrees.branches", {
branches: [{ kind: "local", name: "beta" }],
defaultBranch: "beta",
repositoryStatus: "git",
});
await gateway.setMethodResponse("worktrees.branches", branchList("beta"));
const socketsBefore = await gateway.getSocketCount();
const nodesBefore = (await gateway.getRequests("node.list")).length;
const branchesBefore = (await gateway.getRequests("worktrees.branches")).length;
@@ -494,46 +420,20 @@ suite.define(() => {
)
.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",
await withNewSessionPage(DESKTOP_CONTEXT, async (page) => {
const sessionKey = `agent:main:unknown-${reconnectKind.replaceAll(" ", "-")}`;
const gateway = await installMockGateway(page, {
methodResponses: {
"agents.list": mainAgentList("Original agent", SOURCE_REPO),
"worktrees.branches": branchList(),
"sessions.create": { key: sessionKey },
},
"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");
@@ -545,20 +445,10 @@ suite.define(() => {
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",
});
await gateway.setMethodResponse(
"agents.list",
mainAgentList("Replacement agent", TARGET_REPO),
);
const socketsBefore = await gateway.getSocketCount();
await replaceGatewayClient(page);
await expect.poll(() => gateway.getSocketCount()).toBe(socketsBefore + 1);
@@ -582,66 +472,53 @@ suite.define(() => {
.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" },
await withNewSessionPage(DESKTOP_CONTEXT, async (page) => {
const gateway = await installMockGateway(page, {
methodResponses: {
"agents.list": {
agents: [
{
id: "main",
identity: { name: "Main" },
name: "Main",
workspace: WORKSPACE,
workspaceGit: true,
},
hosts: [],
},
],
{
id: "research",
identity: { name: "Research" },
name: "Research",
workspace: "/home/peter/research",
workspaceGit: true,
},
],
defaultId: "main",
mainKey: "main",
scope: "agent",
},
"worktrees.branches": branchList(),
"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" },
},
"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",
@@ -666,8 +543,6 @@ suite.define(() => {
});
expect(create.params).not.toHaveProperty("model");
expect(create.params).not.toHaveProperty("cwd");
} finally {
await context.close();
}
});
});
});