mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
test(ui): deduplicate control UI e2e setup (#118180)
This commit is contained in:
committed by
GitHub
parent
b35b8e286d
commit
aab2bd2bb9
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user