mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
fix(ui): restore Control UI E2E coverage (#111036)
* fix(ui): restore control ui e2e coverage * test(ui): align e2e coverage with thread labels * test(ui): wait for fake talk channel readiness * chore: keep release note in PR body
This commit is contained in:
committed by
GitHub
parent
e3ae52b792
commit
dcffb3f66f
+13
-7
@@ -812,6 +812,12 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
resolved?.focus();
|
||||
}
|
||||
|
||||
private visibleNavDrawerToggle(): HTMLElement | undefined {
|
||||
return [
|
||||
...this.querySelectorAll<HTMLElement>(".topbar-nav-toggle, .chat-pane__nav-toggle"),
|
||||
].find((candidate) => candidate.checkVisibility());
|
||||
}
|
||||
|
||||
private closeNavDrawer(options: { restoreFocus?: boolean } = {}) {
|
||||
if (this.navDrawerOpen) {
|
||||
this.dismissSidebarTransientMenus();
|
||||
@@ -884,14 +890,14 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
};
|
||||
|
||||
private readonly handleWindowResize = () => {
|
||||
const dismissedHiddenMenus =
|
||||
isMobileNavLayout() && !this.navDrawerOpen && this.dismissSidebarTransientMenus();
|
||||
this.requestUpdate();
|
||||
if (dismissedHiddenMenus) {
|
||||
void this.updateComplete.then(() => {
|
||||
this.restoreFocusTo(this.querySelector<HTMLElement>(".topbar-nav-toggle"));
|
||||
});
|
||||
}
|
||||
void this.updateComplete.then(() => {
|
||||
if (isMobileNavLayout() && !this.navDrawerOpen && this.dismissSidebarTransientMenus()) {
|
||||
requestAnimationFrame(() => {
|
||||
this.restoreFocusTo(this.visibleNavDrawerToggle());
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
private dismissSidebarTransientMenus(): boolean {
|
||||
|
||||
@@ -114,14 +114,16 @@ describeControlUiE2e("Control UI agent page scope", () => {
|
||||
await page.goto(`${server.baseUrl}usage`);
|
||||
await gateway.waitForRequest("agents.list");
|
||||
const sidebar = page.locator("openclaw-app-sidebar");
|
||||
await sidebar.getByRole("button", { name: /Agent menu/ }).click();
|
||||
await sidebar.getByRole("button", { name: /Switch agent/ }).click();
|
||||
await sidebar
|
||||
.locator("wa-dropdown.sidebar-agent-menu")
|
||||
.locator('wa-dropdown-item[value="agent:writer"]')
|
||||
.click();
|
||||
await waitForRequest(gateway, "sessions.list", (params) => params.agentId === "writer");
|
||||
await expect
|
||||
.poll(() => sidebar.locator(".sidebar-agent-card__name").textContent())
|
||||
.poll(async () =>
|
||||
(await sidebar.locator(".sidebar-agent-card__name").textContent())?.trim(),
|
||||
)
|
||||
.toBe("Writer");
|
||||
|
||||
await sidebar.getByRole("link", { name: "Usage" }).click();
|
||||
@@ -142,11 +144,13 @@ describeControlUiE2e("Control UI agent page scope", () => {
|
||||
})
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(() => sidebar.locator(".sidebar-agent-card__name").textContent())
|
||||
.poll(async () =>
|
||||
(await sidebar.locator(".sidebar-agent-card__name").textContent())?.trim(),
|
||||
)
|
||||
.toBe("Writer");
|
||||
await screenshot(page, "02-all-agents-usage.png");
|
||||
|
||||
await sidebar.getByRole("button", { name: /Agent menu/ }).click();
|
||||
await sidebar.getByRole("button", { name: /Switch agent/ }).click();
|
||||
await sidebar
|
||||
.locator("wa-dropdown.sidebar-agent-menu")
|
||||
.locator('wa-dropdown-item[value="command:agent-settings"]')
|
||||
|
||||
@@ -74,7 +74,7 @@ describeControlUiE2e("Control UI agent picker avatar timeout", () => {
|
||||
avatarAuthorization = route.request().headers().authorization;
|
||||
// Leave the route unanswered. The page-owned deadline must cancel it.
|
||||
});
|
||||
await installMockGateway(page, {
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"agent.identity.get": {
|
||||
agentId: "main",
|
||||
@@ -88,11 +88,12 @@ describeControlUiE2e("Control UI agent picker avatar timeout", () => {
|
||||
try {
|
||||
const response = await page.goto(`${server.baseUrl}agents`);
|
||||
expect(response?.status()).toBe(200);
|
||||
await gateway.waitForRequest("agent.identity.get");
|
||||
await expect.poll(() => avatarRequestCount).toBe(1);
|
||||
const picker = page.locator("openclaw-agent-select");
|
||||
await expect
|
||||
.poll(() => picker.locator(".agent-select__avatar--text").first().textContent())
|
||||
.toBe("O");
|
||||
expect(avatarRequestCount).toBe(1);
|
||||
expect(avatarAuthorization).toBe("Bearer e2e-device-token");
|
||||
await screenshot(page, "01-request-stalled.png");
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@ describeControlUiE2e("Control UI app chrome interaction mocked Gateway E2E", ()
|
||||
expect(await dragAcross(page, transcript)).toContain("Selectable transcript");
|
||||
await captureUiProof(page, "01-chat-selectable-transcript.png");
|
||||
|
||||
await page.setViewportSize({ height: 650, width: 1440 });
|
||||
await page.goto(`${server.baseUrl}settings/general`);
|
||||
const settingsSidebar = page.locator(".settings-sidebar");
|
||||
const settingsTitle = settingsSidebar.locator(".settings-sidebar__title");
|
||||
|
||||
@@ -108,7 +108,7 @@ describeControlUiE2e("Control UI approval flow", () => {
|
||||
await currentPage.getByRole("button", { name: "Stop generating" }).waitFor();
|
||||
|
||||
await composer.fill("/approve approval-123 allow-once");
|
||||
await currentPage.getByRole("button", { name: "Steer into the active run" }).click();
|
||||
await currentPage.getByRole("button", { name: "Send message" }).click();
|
||||
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("chat.send")).length, { timeout: 10_000 })
|
||||
|
||||
@@ -20,6 +20,15 @@ let server: ControlUiE2eServer;
|
||||
// Browser contexts preserve test isolation; keep one process warm for this file.
|
||||
let browser: Browser;
|
||||
|
||||
function videoTalkCatalog(activeProvider: "google" | "openai") {
|
||||
return {
|
||||
realtime: {
|
||||
activeProvider,
|
||||
providers: [{ id: activeProvider, label: activeProvider, supportsVideoFrames: true }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function installTalkBrowserFixtures(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
type InputProcessor = {
|
||||
@@ -474,11 +483,12 @@ describeControlUiE2e("Control UI browser Talk", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("starts OpenAI Video Talk, previews a fake camera, and submits describe_view", async () => {
|
||||
it("starts OpenAI Talk, enables a fake camera, and submits describe_view", async () => {
|
||||
const context = await browser.newContext({ permissions: ["camera", "microphone"] });
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"talk.catalog": videoTalkCatalog("openai"),
|
||||
"talk.client.create": {
|
||||
provider: "openai",
|
||||
transport: "webrtc",
|
||||
@@ -528,14 +538,25 @@ describeControlUiE2e("Control UI browser Talk", () => {
|
||||
super();
|
||||
(
|
||||
window as Window & {
|
||||
openclawVideoTalkE2e?: { peer: FakePeerConnection };
|
||||
openclawVideoTalkE2e?: {
|
||||
dataChannelCreated: boolean;
|
||||
peer: FakePeerConnection;
|
||||
};
|
||||
}
|
||||
).openclawVideoTalkE2e = { peer: this };
|
||||
).openclawVideoTalkE2e = { dataChannelCreated: false, peer: this };
|
||||
}
|
||||
|
||||
addTrack() {}
|
||||
|
||||
createDataChannel() {
|
||||
const harness = (
|
||||
window as Window & {
|
||||
openclawVideoTalkE2e?: { dataChannelCreated: boolean };
|
||||
}
|
||||
).openclawVideoTalkE2e;
|
||||
if (harness) {
|
||||
harness.dataChannelCreated = true;
|
||||
}
|
||||
return this.channel;
|
||||
}
|
||||
|
||||
@@ -570,13 +591,36 @@ describeControlUiE2e("Control UI browser Talk", () => {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await captureVideoTalkProof(page, "01-before-video-talk.png");
|
||||
|
||||
await page.getByRole("button", { name: "Start video talk" }).click();
|
||||
await page.getByRole("button", { name: "Start voice input" }).click();
|
||||
const request = await gateway.waitForRequest("talk.client.create");
|
||||
expect(request.params).toMatchObject({
|
||||
capabilities: ["camera-frame"],
|
||||
sessionKey: "main",
|
||||
});
|
||||
console.info("[video-talk-e2e] session=provider:openai,transport:webrtc");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
Boolean(
|
||||
(
|
||||
window as Window & {
|
||||
openclawVideoTalkE2e?: { dataChannelCreated: boolean };
|
||||
}
|
||||
).openclawVideoTalkE2e?.dataChannelCreated,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
await page.evaluate(() => {
|
||||
const channel = (
|
||||
window as Window & {
|
||||
openclawVideoTalkE2e?: { peer: { channel: EventTarget } };
|
||||
}
|
||||
).openclawVideoTalkE2e?.peer.channel;
|
||||
channel?.dispatchEvent(new Event("open"));
|
||||
});
|
||||
const turnCameraOn = page.getByRole("button", { name: "Turn camera on" });
|
||||
await expect.poll(() => turnCameraOn.isEnabled()).toBe(true);
|
||||
await turnCameraOn.click();
|
||||
const preview = page.locator('video[aria-label="Camera preview"]');
|
||||
await expect.poll(() => preview.isVisible()).toBe(true);
|
||||
await expect
|
||||
@@ -591,14 +635,6 @@ describeControlUiE2e("Control UI browser Talk", () => {
|
||||
console.info(
|
||||
`[video-talk-e2e] preview=live,width:${dimensions.width},height:${dimensions.height}`,
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const channel = (
|
||||
window as Window & {
|
||||
openclawVideoTalkE2e?: { peer: { channel: EventTarget } };
|
||||
}
|
||||
).openclawVideoTalkE2e?.peer.channel;
|
||||
channel?.dispatchEvent(new Event("open"));
|
||||
});
|
||||
await captureVideoTalkProof(page, "02-live-camera-preview.png");
|
||||
|
||||
await page.evaluate(() => {
|
||||
@@ -647,7 +683,10 @@ describeControlUiE2e("Control UI browser Talk", () => {
|
||||
const talkRequests = (await gateway.getRequests()).filter((entry) =>
|
||||
entry.method.startsWith("talk."),
|
||||
);
|
||||
expect(talkRequests.map((entry) => entry.method)).toEqual(["talk.client.create"]);
|
||||
expect(talkRequests.map((entry) => entry.method)).toEqual([
|
||||
"talk.catalog",
|
||||
"talk.client.create",
|
||||
]);
|
||||
console.info(
|
||||
"[video-talk-e2e] describe_view=input_image+function_output+response_create,gateway_frame_requests:0",
|
||||
);
|
||||
@@ -670,11 +709,12 @@ describeControlUiE2e("Control UI browser Talk", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("starts Gemini Live Video Talk, streams a fake camera directly, and handles describe_view", async () => {
|
||||
it("starts Gemini Live Talk, enables a fake camera, and handles describe_view", async () => {
|
||||
const context = await browser.newContext({ permissions: ["camera", "microphone"] });
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"talk.catalog": videoTalkCatalog("google"),
|
||||
"talk.client.create": {
|
||||
provider: "google",
|
||||
transport: "provider-websocket",
|
||||
@@ -740,12 +780,14 @@ describeControlUiE2e("Control UI browser Talk", () => {
|
||||
try {
|
||||
await page.setViewportSize({ width: 1366, height: 900 });
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await page.getByRole("button", { name: "Start video talk" }).click();
|
||||
await page.getByRole("button", { name: "Start voice input" }).click();
|
||||
const request = await gateway.waitForRequest("talk.client.create");
|
||||
expect(request.params).toMatchObject({
|
||||
capabilities: ["camera-frame"],
|
||||
sessionKey: "main",
|
||||
});
|
||||
const turnCameraOn = page.getByRole("button", { name: "Turn camera on" });
|
||||
await expect.poll(() => turnCameraOn.isEnabled()).toBe(true);
|
||||
await turnCameraOn.click();
|
||||
const preview = page.locator('video[aria-label="Camera preview"]');
|
||||
await expect.poll(() => preview.isVisible()).toBe(true);
|
||||
await expect
|
||||
@@ -786,7 +828,10 @@ describeControlUiE2e("Control UI browser Talk", () => {
|
||||
const talkRequests = (await gateway.getRequests()).filter((entry) =>
|
||||
entry.method.startsWith("talk."),
|
||||
);
|
||||
expect(talkRequests.map((entry) => entry.method)).toEqual(["talk.client.create"]);
|
||||
expect(talkRequests.map((entry) => entry.method)).toEqual([
|
||||
"talk.catalog",
|
||||
"talk.client.create",
|
||||
]);
|
||||
await captureVideoTalkProof(page, "05-gemini-live-camera-preview.png");
|
||||
console.info(
|
||||
"[video-talk-e2e] gemini=realtimeInput.video+functionResponse,gateway_frame_requests:0",
|
||||
@@ -814,6 +859,7 @@ describeControlUiE2e("Control UI browser Talk", () => {
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"talk.catalog": videoTalkCatalog("google"),
|
||||
"talk.client.create": {
|
||||
provider: "google",
|
||||
transport: "provider-websocket",
|
||||
@@ -831,19 +877,32 @@ describeControlUiE2e("Control UI browser Talk", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await page.routeWebSocket("wss://generativelanguage.googleapis.com/**", (ws) => {
|
||||
ws.onMessage((message) => {
|
||||
const parsed = JSON.parse(typeof message === "string" ? message : message.toString()) as {
|
||||
setup?: unknown;
|
||||
};
|
||||
if (parsed.setup) {
|
||||
ws.send(JSON.stringify({ setupComplete: {} }));
|
||||
}
|
||||
});
|
||||
});
|
||||
await installBlockedVideoTalkFixture(page);
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1366, height: 900 });
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await page.getByRole("button", { name: "Start video talk" }).click();
|
||||
await page.getByRole("button", { name: "Start voice input" }).click();
|
||||
await gateway.waitForRequest("talk.client.create");
|
||||
const turnCameraOn = page.getByRole("button", { name: "Turn camera on" });
|
||||
await expect.poll(() => turnCameraOn.isEnabled()).toBe(true);
|
||||
await turnCameraOn.click();
|
||||
|
||||
const alert = page.getByRole("alert");
|
||||
await expect.poll(() => alert.textContent()).toContain("Camera access is blocked.");
|
||||
await expect.poll(() => page.locator('video[aria-label="Camera preview"]').count()).toBe(0);
|
||||
await expect
|
||||
.poll(() => page.getByRole("button", { name: "Start video talk" }).isVisible())
|
||||
.poll(() => page.getByRole("button", { name: "Turn camera on" }).isVisible())
|
||||
.toBe(true);
|
||||
await captureVideoTalkProof(page, "03-camera-permission-blocked.png");
|
||||
console.info("[video-talk-e2e] camera_denial=actionable,no-audio-fallback");
|
||||
|
||||
@@ -47,11 +47,9 @@ function containsBrokenSurrogate(value: string): boolean {
|
||||
}
|
||||
|
||||
async function openBuildDetails(page: Page) {
|
||||
const sidebar = page.locator("openclaw-app-sidebar");
|
||||
const agentMenu = sidebar.getByRole("button", { name: /Agent menu/ });
|
||||
await agentMenu.waitFor();
|
||||
await agentMenu.click();
|
||||
const buildLink = page.getByRole("link", { name: "Control UI build details", exact: true });
|
||||
const buildLink = page
|
||||
.locator("openclaw-app-sidebar")
|
||||
.getByRole("link", { name: "Control UI build details", exact: true });
|
||||
await buildLink.waitFor();
|
||||
const compactText = (await buildLink.textContent()) ?? "";
|
||||
expect(compactText).toContain(`${COMPACT_BRANCH}@0123456`);
|
||||
|
||||
@@ -377,13 +377,12 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
|
||||
// Keyboard focus on a header action marks the pane active.
|
||||
await headers.first().getByRole("button", { name: "Split down" }).focus();
|
||||
await expect.poll(() => headers.first().getAttribute("class")).toContain("--active");
|
||||
|
||||
const cells = page.locator(".chat-split-view__cell");
|
||||
await expect.poll(() => cells.first().getAttribute("class")).toContain("--active");
|
||||
|
||||
const lastPane = page.locator(".chat-split-view__pane").last();
|
||||
await lastPane.click({ position: { x: 20, y: 80 } });
|
||||
await expect.poll(() => cells.last().getAttribute("class")).toContain("--active");
|
||||
await expect.poll(() => headers.last().getAttribute("class")).toContain("--active");
|
||||
const targetHeader = headers.first();
|
||||
await expect
|
||||
.poll(() =>
|
||||
@@ -722,6 +721,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const sessionKey = "main";
|
||||
const gateway = await installMockGateway(page, {
|
||||
historyMessages: [
|
||||
{
|
||||
@@ -734,28 +734,30 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
"sessions.list": chatSessionListResponse([
|
||||
{
|
||||
hasActiveRun: true,
|
||||
key: "main",
|
||||
key: "agent:main:main",
|
||||
kind: "direct",
|
||||
label: "Main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
]),
|
||||
},
|
||||
sessionKey,
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await page.getByText("Active run is waiting for steering.").waitFor({ timeout: 10_000 });
|
||||
await gateway.waitForRequest("sessions.list");
|
||||
await page.getByRole("button", { name: "Stop generating" }).waitFor({ timeout: 10_000 });
|
||||
|
||||
await page
|
||||
.locator(".agent-chat__composer-combobox textarea")
|
||||
.fill("/steer use the smaller fix");
|
||||
await page.getByRole("button", { name: "Steer into the active run" }).click();
|
||||
await page.getByRole("button", { name: "Send message" }).click();
|
||||
|
||||
const steerRequest = await gateway.waitForRequest("chat.send");
|
||||
const params = requireRecord(steerRequest.params);
|
||||
expect(params.sessionKey).toBe("main");
|
||||
expect(params.sessionKey).toBe(sessionKey);
|
||||
expect(params.message).toBe("use the smaller fix");
|
||||
expect(params.deliver).toBe(false);
|
||||
|
||||
@@ -1865,13 +1867,11 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
|
||||
// The background hydrate must not take the shared sessions loading
|
||||
// flag, which would disable New thread for the whole request.
|
||||
expect(await page.getByRole("button", { name: "New thread" }).first().isEnabled()).toBe(true);
|
||||
const newThread = page.getByRole("button", { name: "New thread" }).first();
|
||||
expect(await newThread.isEnabled()).toBe(true);
|
||||
|
||||
await gateway.resolveDeferred("sessions.list");
|
||||
await page
|
||||
.locator(".sidebar-recent-session", { hasText: "Main" })
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await expect.poll(() => newThread.isEnabled()).toBe(true);
|
||||
} finally {
|
||||
await closeBrowserContext(context);
|
||||
}
|
||||
@@ -2384,6 +2384,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const sessionKey = "main";
|
||||
const runtimeConfig = {
|
||||
messages: { queue: { byChannel: { webchat: "steer" }, mode: "steer" } },
|
||||
};
|
||||
@@ -2400,7 +2401,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
"sessions.list": chatSessionListResponse([
|
||||
{
|
||||
effectiveQueueMode: "interrupt",
|
||||
key: "main",
|
||||
key: "agent:main:main",
|
||||
kind: "direct",
|
||||
label: "Main",
|
||||
queueMode: "interrupt",
|
||||
@@ -2408,6 +2409,14 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
},
|
||||
]),
|
||||
},
|
||||
sessionInfo: {
|
||||
effectiveQueueMode: "interrupt",
|
||||
hasActiveRun: false,
|
||||
key: "agent:main:main",
|
||||
queueMode: "interrupt",
|
||||
status: "done",
|
||||
},
|
||||
sessionKey,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -2426,7 +2435,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
expect(requireRecord(sends[1]?.params)).toMatchObject({
|
||||
message: followUp,
|
||||
queueMode: "interrupt",
|
||||
sessionKey: "main",
|
||||
sessionKey,
|
||||
});
|
||||
} finally {
|
||||
await closeBrowserContext(context);
|
||||
@@ -3450,7 +3459,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
.waitFor({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect.poll(() => sidebarSessionOrder(page)).toEqual(createdOrder.slice(0, 10));
|
||||
await expect.poll(() => sidebarSessionOrder(page)).toEqual(createdOrder.slice(0, 11));
|
||||
await page.getByRole("button", { name: "Load more" }).click();
|
||||
await expect.poll(() => sidebarSessionOrder(page)).toEqual(createdOrder);
|
||||
|
||||
@@ -3474,15 +3483,19 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
.evaluate((label) => getComputedStyle(label).fontWeight);
|
||||
expect(activeWeight).toBe(inactiveWeight);
|
||||
|
||||
await page.getByRole("button", { name: "Sort threads" }).click();
|
||||
const sortThreads = page.getByRole("button", { name: "Sort threads" });
|
||||
await sortThreads.locator("..").hover();
|
||||
await sortThreads.click();
|
||||
await page.getByRole("menuitemradio", { name: "Last updated" }).click();
|
||||
await expect.poll(() => sidebarSessionOrder(page)).toEqual(updatedOrder);
|
||||
|
||||
await page.getByRole("button", { name: "Sort threads" }).click();
|
||||
await sortThreads.locator("..").hover();
|
||||
await sortThreads.click();
|
||||
await page.getByRole("menuitemradio", { name: "Created" }).click();
|
||||
await expect.poll(() => sidebarSessionOrder(page)).toEqual(createdOrder);
|
||||
|
||||
await page.getByRole("button", { name: "Sort threads" }).click();
|
||||
await sortThreads.locator("..").hover();
|
||||
await sortThreads.click();
|
||||
await page.getByRole("main").click();
|
||||
await expect.poll(() => page.getByRole("menuitemradio", { name: "Created" }).count()).toBe(0);
|
||||
} finally {
|
||||
@@ -3564,9 +3577,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
expect(await row.getAttribute("aria-label")).toBeNull();
|
||||
expect(await link.getAttribute("aria-label")).toBeNull();
|
||||
expect(await link.getAttribute("aria-current")).toBe("page");
|
||||
const descriptionId = await link.getAttribute("aria-describedby");
|
||||
expect(descriptionId).toBeTruthy();
|
||||
expect(await page.locator(`[id="${descriptionId}"]`).textContent()).toBeTruthy();
|
||||
expect(await link.getAttribute("aria-describedby")).toBeNull();
|
||||
expect(await link.ariaSnapshot()).toContain(`link "${readableTitle}"`);
|
||||
await captureSessionAccessibilityProof(page, "after-derived-title");
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ describeControlUiE2e("Control UI chat run lifecycle", () => {
|
||||
const runId = params.idempotencyKey as string;
|
||||
|
||||
await currentPage.getByRole("button", { name: "Stop generating" }).waitFor();
|
||||
const mainSession = currentPage.locator(".sidebar-recent-session").filter({ hasText: "Main" });
|
||||
const mainSession = currentPage.locator(".nav-item--home");
|
||||
await mainSession.waitFor({ state: "visible" });
|
||||
const sessionListsBeforeActive = (await gateway.getRequests("sessions.list")).length;
|
||||
await gateway.deferNext("sessions.list");
|
||||
@@ -161,7 +161,7 @@ describeControlUiE2e("Control UI chat run lifecycle", () => {
|
||||
],
|
||||
ts: activeUpdatedAt,
|
||||
});
|
||||
await currentPage.getByText(staleActiveLabel, { exact: true }).waitFor();
|
||||
await currentPage.locator(".chat-pane__session-title", { hasText: staleActiveLabel }).waitFor();
|
||||
expect(await currentPage.getByRole("button", { name: "Stop generating" }).count()).toBe(0);
|
||||
await expect.poll(() => mainSession.locator(".session-run-spinner").count()).toBe(0);
|
||||
|
||||
@@ -258,7 +258,7 @@ describeControlUiE2e("Control UI chat run lifecycle", () => {
|
||||
const runId = params.idempotencyKey as string;
|
||||
|
||||
await currentPage.getByRole("button", { name: "Stop generating" }).waitFor();
|
||||
const mainSession = currentPage.locator(".sidebar-recent-session").filter({ hasText: "Main" });
|
||||
const mainSession = currentPage.locator(".nav-item--home");
|
||||
await mainSession.waitFor({ state: "visible" });
|
||||
const sessionListsBeforeActive = (await gateway.getRequests("sessions.list")).length;
|
||||
await gateway.deferNext("sessions.list");
|
||||
|
||||
@@ -205,8 +205,17 @@ function hostGroupedNativeCatalogs() {
|
||||
return { catalogs: [catalog("claude", "Claude Code"), catalog("codex", "Codex")] };
|
||||
}
|
||||
|
||||
async function expandCodingSection(page: Page) {
|
||||
const toggle = page.locator('[data-session-section="work"] .sidebar-session-group-toggle');
|
||||
await toggle.waitFor({ state: "visible" });
|
||||
if ((await toggle.getAttribute("aria-expanded")) === "false") {
|
||||
await toggle.click();
|
||||
}
|
||||
}
|
||||
|
||||
async function openClaudeCatalogTerminal(page: Page) {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await expandCodingSection(page);
|
||||
const row = page.locator('[data-session-key^="catalog:"]').filter({
|
||||
hasText: "Native Claude terminal",
|
||||
});
|
||||
@@ -237,6 +246,7 @@ suite("Claude native session catalog", () => {
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await expandCodingSection(page);
|
||||
for (const catalogId of ["claude", "codex"]) {
|
||||
const section = page.locator(`[data-session-section="catalog:${catalogId}"]`);
|
||||
const gatewayHost = section.locator('[data-session-catalog-host="gateway:local"]');
|
||||
@@ -459,6 +469,7 @@ suite("Claude native session catalog", () => {
|
||||
},
|
||||
});
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await expandCodingSection(page);
|
||||
await page.getByRole("button", { name: "Load more threads" }).click();
|
||||
await page.getByText("Older remote review", { exact: true }).waitFor();
|
||||
expect((await gateway.getRequests("sessions.catalog.list")).at(-1)?.params).toEqual({
|
||||
@@ -508,7 +519,7 @@ suite("Claude native session catalog", () => {
|
||||
expectStableVirtualRowPrepend(anchor, await finishVirtualRowPrependProbe(thread));
|
||||
expect(await page.locator(".agent-chat__composer-combobox > textarea").isDisabled()).toBe(true);
|
||||
await expect
|
||||
.poll(() => page.getByText("This session is on a paired node and is view-only.").count())
|
||||
.poll(() => page.getByText("This thread is on a paired node and is view-only.").count())
|
||||
.toBe(1);
|
||||
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
|
||||
const expectCenteredLayout = async (screenshotName: string) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { chromium, type Browser } from "playwright";
|
||||
import { chromium, type Browser, type Page } from "playwright";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { SessionsCatalogHostEvent } from "../../../packages/gateway-protocol/src/index.ts";
|
||||
import {
|
||||
@@ -28,6 +28,14 @@ const uiProofArtifactDir = path.join(
|
||||
"native-session-discovery",
|
||||
);
|
||||
|
||||
async function expandCodingSection(page: Page) {
|
||||
const toggle = page.locator('[data-session-section="work"] .sidebar-session-group-toggle');
|
||||
await toggle.waitFor({ state: "visible" });
|
||||
if ((await toggle.getAttribute("aria-expanded")) === "false") {
|
||||
await toggle.click();
|
||||
}
|
||||
}
|
||||
|
||||
suite("Codex native session catalog", () => {
|
||||
beforeAll(async () => {
|
||||
if (!available) {
|
||||
@@ -135,6 +143,7 @@ suite("Codex native session catalog", () => {
|
||||
},
|
||||
} satisfies SessionsCatalogHostEvent);
|
||||
|
||||
await expandCodingSection(page);
|
||||
await page.getByText("Progressive node result", { exact: true }).waitFor();
|
||||
expect((await gateway.getRequests("sessions.catalog.list")).length).toBe(1);
|
||||
if (captureUiProofEnabled) {
|
||||
@@ -245,6 +254,7 @@ suite("Codex native session catalog", () => {
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await expandCodingSection(page);
|
||||
const section = page.locator('[data-session-section="catalog:codex"]');
|
||||
await section.waitFor({ state: "visible" });
|
||||
await expect.poll(() => section.locator("[data-session-catalog-host]").count()).toBe(2);
|
||||
@@ -441,6 +451,7 @@ suite("Codex native session catalog", () => {
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await expandCodingSection(page);
|
||||
const warning = page.locator(
|
||||
'[data-session-section="catalog:codex"] .sidebar-session-group-toggle',
|
||||
);
|
||||
@@ -555,6 +566,7 @@ suite("Codex native session catalog", () => {
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await expandCodingSection(page);
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("sessions.catalog.list")).length)
|
||||
.toBe(1);
|
||||
@@ -627,6 +639,7 @@ suite("Codex native session catalog", () => {
|
||||
},
|
||||
});
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await expandCodingSection(page);
|
||||
await page.getByText("Release checklist", { exact: true }).click();
|
||||
await expect.poll(() => page.getByText("prepare release", { exact: true }).count()).toBe(1);
|
||||
const composer = page.locator(".agent-chat__composer-combobox > textarea");
|
||||
|
||||
@@ -90,10 +90,9 @@ describeControlUiE2e("Control UI config form guidance mocked Gateway E2E", () =>
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await page.goto(`${server.baseUrl}settings/general`);
|
||||
const response = await page.goto(`${server.baseUrl}settings/advanced`);
|
||||
expect(response?.status()).toBe(200);
|
||||
|
||||
await page.locator('.content-header wa-radio[value="advanced"]').click();
|
||||
await page.getByRole("button", { name: "Core" }).click();
|
||||
await page.getByRole("button", { name: "Updates", exact: true }).click();
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@ suite("OpenCode and Pi external session catalogs", () => {
|
||||
});
|
||||
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await page.locator('[data-session-section="work"] .sidebar-session-group-toggle').click();
|
||||
await page.getByText("OpenCode release review", { exact: true }).click();
|
||||
await expect.poll(() => page.getByText("OpenCode transcript loaded").count()).toBe(1);
|
||||
await page.getByText("Pi architecture notes", { exact: true }).click();
|
||||
|
||||
@@ -38,6 +38,11 @@ const EXEC_ONLY_PICKED = "C:\\Users\\peter\\repo";
|
||||
|
||||
const ONE_PIXEL_PNG_B64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
|
||||
const SESSION_LIST_DEFAULTS = {
|
||||
contextTokens: null,
|
||||
model: "gpt-5.5",
|
||||
modelProvider: "openai",
|
||||
};
|
||||
|
||||
async function captureUiProof(page: Page, fileName: string) {
|
||||
if (!captureUiProofEnabled) {
|
||||
@@ -698,9 +703,11 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
|
||||
const page = await context.newPage();
|
||||
const sessionKey = "agent:cloud:cloud-e2e";
|
||||
const gateway = await installMockGateway(page, {
|
||||
defaultAgentId: "cloud",
|
||||
deferredMethods: ["sessions.dispatch"],
|
||||
featureMethods: ["chat.metadata", "chat.startup", "sessions.reclaim"],
|
||||
workspaceGit: true,
|
||||
sessionKey: "agent:cloud:neutral-e2e",
|
||||
methodResponses: {
|
||||
"agents.list": {
|
||||
agents: [
|
||||
@@ -757,6 +764,12 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}new`);
|
||||
expect(
|
||||
await page.evaluate(() => ({
|
||||
hasSubtleCrypto: Boolean(globalThis.crypto.subtle),
|
||||
isSecureContext: globalThis.isSecureContext,
|
||||
})),
|
||||
).toEqual({ hasSubtleCrypto: true, isSecureContext: true });
|
||||
await gateway.waitForRequest("environments.list");
|
||||
await page.locator("#new-session-where-trigger").click();
|
||||
const where = page.locator("wa-popover.new-session-page__where-popover");
|
||||
@@ -901,7 +914,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
|
||||
]);
|
||||
|
||||
await gateway.setMethodResponse("sessions.list", {
|
||||
count: 2,
|
||||
count: 4,
|
||||
path: "",
|
||||
defaults: {},
|
||||
sessions: [
|
||||
@@ -913,18 +926,36 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
|
||||
worktree: { id: "worktree-1", branch: "openclaw/cloud-e2e", repoRoot: WORKSPACE },
|
||||
placement: { state: "active" },
|
||||
},
|
||||
{
|
||||
key: "agent:cloud:managed-e2e",
|
||||
kind: "direct",
|
||||
label: "Managed session",
|
||||
updatedAt: Date.now() - 1,
|
||||
placement: { state: "active" },
|
||||
},
|
||||
{
|
||||
key: "agent:cloud:local-e2e",
|
||||
kind: "direct",
|
||||
label: "Local session",
|
||||
updatedAt: Date.now() - 1,
|
||||
updatedAt: Date.now() - 2,
|
||||
placement: { state: "local" },
|
||||
},
|
||||
{
|
||||
key: "agent:cloud:neutral-e2e",
|
||||
kind: "direct",
|
||||
label: "Neutral session",
|
||||
updatedAt: Date.now() - 3,
|
||||
placement: { state: "local" },
|
||||
},
|
||||
],
|
||||
ts: Date.now(),
|
||||
});
|
||||
await gateway.emitGatewayEvent("sessions.changed", { sessionKey, reason: "dispatch" });
|
||||
const sessionRow = page.locator('[data-session-key="agent:cloud:cloud-e2e"]');
|
||||
await page.goto(
|
||||
`${server.baseUrl}chat?session=${encodeURIComponent("agent:cloud:neutral-e2e")}`,
|
||||
);
|
||||
const managedSessionKey = "agent:cloud:managed-e2e";
|
||||
const sessionRow = page.locator(`[data-session-key="${managedSessionKey}"]`);
|
||||
const localSessionRow = page.locator('[data-session-key="agent:cloud:local-e2e"]');
|
||||
await sessionRow.waitFor();
|
||||
await localSessionRow.waitFor();
|
||||
@@ -943,7 +974,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
await stopWorker.click();
|
||||
const reclaim = await gateway.waitForRequest("sessions.reclaim");
|
||||
expect(reclaim.params).toEqual({ key: sessionKey, agentId: "cloud" });
|
||||
expect(reclaim.params).toEqual({ key: managedSessionKey, agentId: "cloud" });
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -1083,6 +1114,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
|
||||
},
|
||||
"sessions.list": {
|
||||
count: 1,
|
||||
defaults: SESSION_LIST_DEFAULTS,
|
||||
path: "",
|
||||
sessions: [{ key: sessionKey, kind: "direct", updatedAt: Date.now() }],
|
||||
ts: Date.now(),
|
||||
@@ -1507,6 +1539,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
|
||||
},
|
||||
"sessions.list": {
|
||||
count: 1,
|
||||
defaults: SESSION_LIST_DEFAULTS,
|
||||
path: "",
|
||||
sessions: [{ key: sessionKey, kind: "direct", updatedAt: Date.now() }],
|
||||
ts: Date.now(),
|
||||
@@ -1672,6 +1705,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
|
||||
const sessionKey = "agent:main:refresh-overlap-e2e";
|
||||
const listResponse = {
|
||||
count: 0,
|
||||
defaults: SESSION_LIST_DEFAULTS,
|
||||
path: "",
|
||||
sessions: [],
|
||||
ts: Date.now(),
|
||||
@@ -2412,6 +2446,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
|
||||
},
|
||||
"sessions.list": {
|
||||
count: 0,
|
||||
defaults: SESSION_LIST_DEFAULTS,
|
||||
path: "",
|
||||
sessions: [],
|
||||
ts: Date.now(),
|
||||
@@ -2575,6 +2610,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
|
||||
},
|
||||
"sessions.list": {
|
||||
count: 1,
|
||||
defaults: SESSION_LIST_DEFAULTS,
|
||||
path: "",
|
||||
sessions: [
|
||||
{
|
||||
|
||||
@@ -431,7 +431,7 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
|
||||
chatRows.evaluateAll((rows) =>
|
||||
rows.map((row) => row.querySelector(".sidebar-recent-session__name")?.textContent ?? ""),
|
||||
);
|
||||
await expect.poll(rowNames).toEqual(["Main", "Data migration", "Research notes"]);
|
||||
await expect.poll(rowNames).toEqual(["Data migration", "Research notes"]);
|
||||
const sidebarMigration = sidebarRows.filter({ hasText: "Data migration" });
|
||||
await expect
|
||||
.poll(() => sidebarMigration.locator(".session-run-spinner").isVisible())
|
||||
@@ -488,9 +488,7 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
|
||||
const researchLink = sidebarResearch.locator("a").first();
|
||||
await researchLink.click();
|
||||
await expect.poll(() => page.url()).toContain("session=agent%3Amain%3Aresearch");
|
||||
await expect
|
||||
.poll(rowNames)
|
||||
.toEqual(["Main", "Release planning", "Data migration", "Research notes"]);
|
||||
await expect.poll(rowNames).toEqual(["Release planning", "Data migration", "Research notes"]);
|
||||
await expect
|
||||
.poll(() =>
|
||||
chatRows
|
||||
@@ -589,7 +587,9 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
|
||||
.locator(".sidebar-brand")
|
||||
.getByRole("button", { name: "Collapse sidebar" });
|
||||
const expandButton = page.locator(".shell-nav-expand");
|
||||
const drawerToggle = page.locator(".topbar-nav-toggle");
|
||||
const drawerToggle = page
|
||||
.locator(".topbar-nav-toggle:visible, .chat-pane__nav-toggle:visible")
|
||||
.first();
|
||||
const sessionMenu = page.getByRole("menu", { name: "Actions for Research notes" });
|
||||
await row.waitFor({ state: "visible", timeout: 10_000 });
|
||||
|
||||
@@ -986,7 +986,7 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
|
||||
.poll(() =>
|
||||
page.locator('[data-session-section="ungrouped"] .sidebar-recent-session').count(),
|
||||
)
|
||||
.toBe(3);
|
||||
.toBe(2);
|
||||
|
||||
// Group by "None" flattens the category sections into the plain list.
|
||||
const sortSessionsButton = page.locator(
|
||||
@@ -1003,7 +1003,7 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
|
||||
await sortSessionsButton.click();
|
||||
await activateMenuItem(page.getByRole("menuitemradio", { name: "None" }));
|
||||
await expect.poll(() => groups.count()).toBe(1);
|
||||
await expect.poll(() => groups.first().locator(".sidebar-recent-session").count()).toBe(4);
|
||||
await expect.poll(() => groups.first().locator(".sidebar-recent-session").count()).toBe(3);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -1229,12 +1229,13 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
|
||||
.getAttribute("aria-expanded"),
|
||||
)
|
||||
.toBe("false");
|
||||
await expect.poll(() => page.locator(".sidebar-recent-session").count()).toBe(9);
|
||||
await page.getByRole("button", { name: "Load more" }).click();
|
||||
await expect.poll(() => page.locator(".sidebar-recent-session").count()).toBe(10);
|
||||
await page.getByRole("button", { name: "Load more threads" }).click();
|
||||
await expect.poll(() => page.locator(".sidebar-recent-session").count()).toBe(11);
|
||||
|
||||
const patchCountBeforeFlatDrag = (await gateway.getRequests("sessions.patch")).length;
|
||||
const sortSessionsButton = page.getByRole("button", { name: "Sort threads" });
|
||||
await sortSessionsButton.locator("..").hover();
|
||||
await sortSessionsButton.click();
|
||||
await activateMenuItem(page.getByRole("menuitemradio", { name: "None" }));
|
||||
const flatSection = page.locator('[data-session-section="ungrouped"]');
|
||||
@@ -1268,9 +1269,6 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
const firstGroup = page.locator('[data-session-section="category:First group"]');
|
||||
await firstGroup.waitFor({ state: "visible" });
|
||||
await page.locator('[data-session-section="ungrouped"] .sidebar-recent-session').waitFor({
|
||||
state: "visible",
|
||||
});
|
||||
|
||||
// A header-menu-created group starts empty and still gets a section.
|
||||
await firstGroup.locator(".sidebar-recent-sessions__head").hover();
|
||||
@@ -1410,7 +1408,7 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps raw ids out of work rows and survives rows growing subtitles in place", async () => {
|
||||
it("keeps raw ids out of work rows while their metadata grows in place", async () => {
|
||||
const baseTime = Date.parse("2026-07-01T16:00:00.000Z");
|
||||
const nodeHash = "11c38726acc6fac280357576c87acc6fac280357";
|
||||
const rows = (withWork: boolean) => {
|
||||
@@ -1447,42 +1445,35 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
|
||||
methodResponses: {
|
||||
"sessions.list": sessionsListResponse(rows(false)),
|
||||
},
|
||||
// Defer every list request; the test resolves them per phase so rows
|
||||
// first paint single-line and only then grow a subtitle line in place.
|
||||
deferredMethods: Array.from({ length: 12 }, () => "sessions.list"),
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
const resolveNextList = async (withWork: boolean) => {
|
||||
try {
|
||||
await gateway.resolveDeferred("sessions.list", sessionsListResponse(rows(withWork)));
|
||||
} catch {
|
||||
// No list request queued yet; the poll below retries.
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await resolveNextList(false);
|
||||
return page.locator(".sidebar-recent-session").count();
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.poll(() => page.locator(".sidebar-recent-session").count(), { timeout: 15_000 })
|
||||
.toBeGreaterThan(0);
|
||||
// Rows grow a second subtitle line only after first layout: the WebKit
|
||||
// overlap regression needs in-place growth, not a static two-line list.
|
||||
await gateway.emitGatewayEvent("sessions.changed", {});
|
||||
// Add work metadata only after first layout so the WebKit overlap
|
||||
// regression still exercises in-place row growth.
|
||||
const listRequests = (await gateway.getRequests("sessions.list")).length;
|
||||
await gateway.setMethodResponse("sessions.list", sessionsListResponse(rows(true)));
|
||||
await gateway.emitGatewayEvent("sessions.changed", {
|
||||
reason: "update",
|
||||
sessionKey: "agent:main:dashboard:0f9d5c1e-6d0f-4c9a-9d84-1c2f3a4b5c6e",
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await resolveNextList(true);
|
||||
return page.locator(".sidebar-recent-session__subtitle").count();
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
.poll(async () => (await gateway.getRequests("sessions.list")).length)
|
||||
.toBeGreaterThan(listRequests);
|
||||
const codingToggle = page.locator(
|
||||
'[data-session-section="work"] .sidebar-session-group-toggle',
|
||||
);
|
||||
await codingToggle.waitFor({ state: "visible" });
|
||||
await expect.poll(() => codingToggle.getAttribute("aria-expanded")).toBe("false");
|
||||
await codingToggle.click();
|
||||
const namesLocator = page.locator(".sidebar-recent-session__name");
|
||||
await expect
|
||||
.poll(() => trimmedTextContents(namesLocator))
|
||||
.toContain("clawdbot ⎇ wt-1 · …0357");
|
||||
|
||||
// Names and subtitles never show raw node ids or raw agent keys.
|
||||
const names = await trimmedTextContents(page.locator(".sidebar-recent-session__name"));
|
||||
@@ -1551,16 +1542,17 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
|
||||
});
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
const seeMore = page.getByRole("button", { name: "Load more" });
|
||||
for (let pageIndex = 0; pageIndex < 3; pageIndex += 1) {
|
||||
await seeMore.click();
|
||||
await page.locator('[data-session-section="work"] .sidebar-session-group-toggle').click();
|
||||
const loadMore = page.getByRole("button", { name: "Load more threads" });
|
||||
for (let pageIndex = 0; pageIndex < 3 && (await loadMore.isVisible()); pageIndex += 1) {
|
||||
await loadMore.click();
|
||||
}
|
||||
await page.locator(".sidebar-shell__body").evaluate((element) => {
|
||||
element.scrollTop = 0;
|
||||
});
|
||||
await expect
|
||||
.poll(() => page.locator(".sidebar-recent-session").count(), { timeout: 15_000 })
|
||||
.toBeGreaterThanOrEqual(rows.length);
|
||||
.toBe(rows.length);
|
||||
await captureUiProof(page, "short-window-session-sections.png");
|
||||
|
||||
// Sections must stack below each other, not paint over the rows above.
|
||||
|
||||
@@ -300,6 +300,7 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
|
||||
"About",
|
||||
"General",
|
||||
"Appearance",
|
||||
"Notifications",
|
||||
]);
|
||||
await captureSettingsSidebarProof(settingsSidebar, "01c-settings-search-group.png");
|
||||
await holdUiProof(page);
|
||||
@@ -557,7 +558,7 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
|
||||
}
|
||||
});
|
||||
|
||||
it("opens the start screen from the sidebar brand without carrying the active session", async () => {
|
||||
it("opens the start screen from the sidebar action without carrying the active session", async () => {
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
@@ -568,15 +569,13 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat?session=${encodeURIComponent("agent:main:work")}`);
|
||||
const brand = page.locator("openclaw-app-sidebar").getByRole("link", { name: "New thread" });
|
||||
await expect.poll(() => brand.getAttribute("href")).toBe("/new");
|
||||
|
||||
await brand.click();
|
||||
await page.locator("openclaw-app-sidebar .sidebar-brand__new-thread").click();
|
||||
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe("/new");
|
||||
await expect.poll(() => new URL(page.url()).search).toBe("");
|
||||
await expect.poll(() => new URL(page.url()).searchParams.get("agent")).toBe("main");
|
||||
await expect.poll(() => new URL(page.url()).searchParams.has("session")).toBe(false);
|
||||
await expect.poll(() => page.locator(".new-session-page").isVisible()).toBe(true);
|
||||
await captureUiProof(page, "07-brand-start-screen.png");
|
||||
await captureUiProof(page, "07-sidebar-start-screen.png");
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SessionsListResult } from "../../api/types.ts";
|
||||
import { isSessionRunActive } from "../../lib/session-run-state.ts";
|
||||
import {
|
||||
CHAT_RUN_STATUS_TOAST_DURATION_MS,
|
||||
hasAbortableSessionRun,
|
||||
reconcileChatRunFromCurrentSessionRow,
|
||||
reconcileChatRunFromSessionRow,
|
||||
reconcileChatRunLifecycle,
|
||||
@@ -23,6 +24,20 @@ function makeSessionsResult(rows: TestRow[]): SessionsListResult {
|
||||
return { sessions: rows } as unknown as SessionsListResult;
|
||||
}
|
||||
|
||||
describe("hasAbortableSessionRun", () => {
|
||||
it("recognizes the canonical main row while chat uses its main alias", () => {
|
||||
expect(
|
||||
hasAbortableSessionRun({
|
||||
chatRunId: null,
|
||||
sessionKey: "main",
|
||||
sessionsResult: makeSessionsResult([
|
||||
{ key: "agent:main:main", hasActiveRun: true, status: "running" },
|
||||
]),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function makeHost(over: Partial<ReconcileHost> = {}): ReconcileHost {
|
||||
return {
|
||||
sessionKey: "s1",
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
type SessionRunTerminal,
|
||||
type SessionScopeHost,
|
||||
} from "../../lib/sessions/index.ts";
|
||||
import { uiSessionRowMatchesSelectedChat } from "../../lib/sessions/session-key.ts";
|
||||
import {
|
||||
areUiSessionKeysEquivalent,
|
||||
uiSessionRowMatchesSelectedChat,
|
||||
} from "../../lib/sessions/session-key.ts";
|
||||
import { normalizeLowercaseStringOrEmpty } from "../../lib/string-coerce.ts";
|
||||
import { formatConnectError } from "./connect-error.ts";
|
||||
import { resetChatInputHistoryNavigation, type ChatInputHistoryState } from "./input-history.ts";
|
||||
@@ -123,7 +126,8 @@ export function hasAbortableSessionRun(host: {
|
||||
}
|
||||
return Boolean(
|
||||
host.sessionsResult?.sessions.some(
|
||||
(session) => session.key === host.sessionKey && isSessionRunActive(session),
|
||||
(session) =>
|
||||
areUiSessionKeysEquivalent(session.key, host.sessionKey) && isSessionRunActive(session),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -566,9 +566,9 @@ describeControlUiE2e("Control UI Plugins mocked Gateway E2E", () => {
|
||||
}
|
||||
const sidebar = page.locator("openclaw-app-sidebar");
|
||||
await sidebar.waitFor({ state: "visible" });
|
||||
const moreButton = sidebar.getByRole("button", { name: "More" });
|
||||
if ((await moreButton.getAttribute("aria-expanded")) !== "true") {
|
||||
await moreButton.click();
|
||||
const pagesButton = sidebar.locator(".sidebar-nav__head-action");
|
||||
if ((await pagesButton.getAttribute("aria-expanded")) !== "true") {
|
||||
await pagesButton.click();
|
||||
}
|
||||
const workboardMenuItem = sidebar
|
||||
.locator("wa-dropdown.sidebar-more-menu")
|
||||
|
||||
@@ -94,7 +94,7 @@ describeBrowser("Skill Workshop history scan browser flow", () => {
|
||||
}
|
||||
await findIdeas.click();
|
||||
|
||||
await expect.poll(() => page.getByText("34 sessions reviewed").isVisible()).toBe(true);
|
||||
await expect.poll(() => page.getByText("34 threads reviewed").isVisible()).toBe(true);
|
||||
await expect.poll(() => page.getByText("2 ideas found").isVisible()).toBe(true);
|
||||
await expect
|
||||
.poll(() => page.getByRole("button", { name: "Scan earlier work" }).isVisible())
|
||||
|
||||
@@ -442,7 +442,7 @@ describeControlUiE2e("Control UI Workboard mocked Gateway E2E", () => {
|
||||
await expect.poll(() => createDialog.isVisible()).toBe(true);
|
||||
await createForm.getByLabel("Title").fill(createdCard.title);
|
||||
await createForm.getByLabel("Notes").fill(createdCard.notes ?? "");
|
||||
await chooseWorkboardSelectOption(createForm, "Session", linkedSessionName);
|
||||
await chooseWorkboardSelectOption(createForm, "Thread", linkedSessionName);
|
||||
await createForm.getByLabel("Labels").fill("ui, proof");
|
||||
await captureScreenshot(writable.page, artifacts, "02-create-dialog");
|
||||
const createBefore = (await writableGateway.getRequests("workboard.cards.create")).length;
|
||||
|
||||
Reference in New Issue
Block a user