mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
test(ui): stabilize session archive e2e lanes (#122068)
This commit is contained in:
committed by
GitHub
parent
3f427fb63c
commit
db7eda0146
@@ -12,11 +12,26 @@ const suite = createControlUiE2eSuite({
|
||||
});
|
||||
|
||||
type VisibleVirtualRow = {
|
||||
index: number;
|
||||
key: string;
|
||||
totalSize: number;
|
||||
viewportTop: number;
|
||||
};
|
||||
|
||||
async function firstVisibleVirtualRow(thread: Locator): Promise<VisibleVirtualRow> {
|
||||
type VirtualRowPaintSample = {
|
||||
index: number | null;
|
||||
intersectsViewport: boolean;
|
||||
totalSize: number;
|
||||
viewportTop: number | null;
|
||||
};
|
||||
|
||||
type VirtualRowPaintProbe = {
|
||||
frameIds: number[];
|
||||
observer: MutationObserver;
|
||||
samples: VirtualRowPaintSample[];
|
||||
};
|
||||
|
||||
async function captureTopVisibleVirtualRow(thread: Locator): Promise<VisibleVirtualRow> {
|
||||
return thread.evaluate((element) => {
|
||||
const viewport = element.getBoundingClientRect();
|
||||
const row = Array.from(
|
||||
@@ -32,95 +47,127 @@ async function firstVisibleVirtualRow(thread: Locator): Promise<VisibleVirtualRo
|
||||
if (!row) {
|
||||
throw new Error("expected a visible virtual transcript row");
|
||||
}
|
||||
const index = Number.parseInt(row.dataset.index ?? "", 10);
|
||||
if (!Number.isFinite(index)) {
|
||||
throw new Error("expected the virtual transcript anchor to expose its row index");
|
||||
}
|
||||
return {
|
||||
index,
|
||||
key: row.dataset.virtualRowKey ?? "",
|
||||
viewportTop: Math.round(row.getBoundingClientRect().top - viewport.top),
|
||||
totalSize:
|
||||
element.querySelector<HTMLElement>(".chat-virtual-sizer")?.getBoundingClientRect().height ??
|
||||
0,
|
||||
viewportTop: row.getBoundingClientRect().top - viewport.top,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
type VirtualRowPrependSample = {
|
||||
phase: "before" | "mutation" | "frame";
|
||||
viewportTop: number | null;
|
||||
};
|
||||
|
||||
async function startVirtualRowPrependProbe(thread: Locator, anchor: VisibleVirtualRow) {
|
||||
async function startVirtualRowPaintProbe(thread: Locator, anchor: VisibleVirtualRow) {
|
||||
await thread.evaluate((element, expected) => {
|
||||
const target = globalThis as typeof globalThis & {
|
||||
chatPrependProbe?: {
|
||||
observer: MutationObserver;
|
||||
samples: VirtualRowPrependSample[];
|
||||
};
|
||||
chatPrependPaintProbe?: VirtualRowPaintProbe;
|
||||
};
|
||||
const samples: VirtualRowPrependSample[] = [];
|
||||
let framePending = false;
|
||||
const sample = (phase: VirtualRowPrependSample["phase"]) => {
|
||||
const staleProbe = target.chatPrependPaintProbe;
|
||||
if (staleProbe) {
|
||||
staleProbe.observer.disconnect();
|
||||
staleProbe.frameIds.forEach((frameId) => cancelAnimationFrame(frameId));
|
||||
delete target.chatPrependPaintProbe;
|
||||
}
|
||||
const probe: VirtualRowPaintProbe = {
|
||||
frameIds: [],
|
||||
observer: new MutationObserver(() => undefined),
|
||||
samples: [],
|
||||
};
|
||||
const sample = () => {
|
||||
const viewport = element.getBoundingClientRect();
|
||||
const row = Array.from(
|
||||
element.querySelectorAll<HTMLElement>(".chat-virtual-row[data-virtual-row-key]"),
|
||||
).find((candidate) => candidate.dataset.virtualRowKey === expected.key);
|
||||
samples.push({
|
||||
phase,
|
||||
viewportTop: row
|
||||
? Math.round(row.getBoundingClientRect().top - element.getBoundingClientRect().top)
|
||||
: null,
|
||||
).find(
|
||||
(candidate) =>
|
||||
candidate.dataset.virtualRowKey !== "history" &&
|
||||
candidate.dataset.virtualRowKey === expected.key,
|
||||
);
|
||||
const rect = row?.getBoundingClientRect();
|
||||
const index = row ? Number.parseInt(row.dataset.index ?? "", 10) : Number.NaN;
|
||||
probe.samples.push({
|
||||
index: Number.isFinite(index) ? index : null,
|
||||
intersectsViewport: Boolean(
|
||||
rect && rect.bottom > viewport.top && rect.top < viewport.bottom,
|
||||
),
|
||||
totalSize:
|
||||
element.querySelector<HTMLElement>(".chat-virtual-sizer")?.getBoundingClientRect()
|
||||
.height ?? 0,
|
||||
viewportTop: rect ? rect.top - viewport.top : null,
|
||||
});
|
||||
};
|
||||
sample("before");
|
||||
const observer = new MutationObserver(() => {
|
||||
sample("mutation");
|
||||
if (framePending) {
|
||||
const scheduleSample = () => {
|
||||
if (probe.frameIds.length > 0) {
|
||||
return;
|
||||
}
|
||||
framePending = true;
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
framePending = false;
|
||||
sample("frame");
|
||||
const firstFrame = requestAnimationFrame(() => {
|
||||
const secondFrame = requestAnimationFrame(() => {
|
||||
probe.frameIds = [];
|
||||
sample();
|
||||
});
|
||||
probe.frameIds = [secondFrame];
|
||||
});
|
||||
});
|
||||
observer.observe(element, {
|
||||
probe.frameIds = [firstFrame];
|
||||
};
|
||||
probe.observer = new MutationObserver(scheduleSample);
|
||||
probe.observer.observe(element, {
|
||||
attributeFilter: ["style"],
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
target.chatPrependProbe = { observer, samples };
|
||||
target.chatPrependPaintProbe = probe;
|
||||
}, anchor);
|
||||
}
|
||||
|
||||
async function finishVirtualRowPrependProbe(thread: Locator) {
|
||||
async function stopVirtualRowPaintProbe(thread: Locator): Promise<VirtualRowPaintSample[]> {
|
||||
return thread.evaluate(() => {
|
||||
const target = globalThis as typeof globalThis & {
|
||||
chatPrependProbe?: {
|
||||
observer: MutationObserver;
|
||||
samples: VirtualRowPrependSample[];
|
||||
};
|
||||
chatPrependPaintProbe?: VirtualRowPaintProbe;
|
||||
};
|
||||
const probe = target.chatPrependProbe;
|
||||
const probe = target.chatPrependPaintProbe;
|
||||
if (!probe) {
|
||||
throw new Error("expected an active virtual row prepend probe");
|
||||
throw new Error("expected an active virtual row paint probe");
|
||||
}
|
||||
probe.observer.disconnect();
|
||||
delete target.chatPrependProbe;
|
||||
probe.frameIds.forEach((frameId) => cancelAnimationFrame(frameId));
|
||||
delete target.chatPrependPaintProbe;
|
||||
return probe.samples;
|
||||
});
|
||||
}
|
||||
|
||||
function expectStableVirtualRowPrepend(
|
||||
function expectPaintedVirtualRowAnchor(
|
||||
anchor: VisibleVirtualRow,
|
||||
samples: VirtualRowPrependSample[],
|
||||
samples: VirtualRowPaintSample[],
|
||||
) {
|
||||
expect(samples.some((sample) => sample.phase === "mutation")).toBe(true);
|
||||
expect(samples.some((sample) => sample.phase === "frame")).toBe(true);
|
||||
const paintedSamples = samples.filter((sample) => sample.phase !== "mutation");
|
||||
const evidence = JSON.stringify({ anchor, samples });
|
||||
expect(samples.length, evidence).toBeGreaterThan(0);
|
||||
expect(
|
||||
paintedSamples.every((sample) => sample.viewportTop !== null),
|
||||
JSON.stringify({ anchor, samples }),
|
||||
samples.some(
|
||||
(sample) =>
|
||||
(sample.index !== null && sample.index > anchor.index) ||
|
||||
sample.totalSize > anchor.totalSize,
|
||||
),
|
||||
evidence,
|
||||
).toBe(true);
|
||||
expect(
|
||||
paintedSamples.every((sample) => Math.abs((sample.viewportTop ?? 0) - anchor.viewportTop) <= 1),
|
||||
JSON.stringify({ anchor, samples }),
|
||||
samples.every((sample) => sample.viewportTop !== null),
|
||||
evidence,
|
||||
).toBe(true);
|
||||
expect(
|
||||
samples.every((sample) => sample.intersectsViewport),
|
||||
evidence,
|
||||
).toBe(true);
|
||||
expect(
|
||||
samples.every(
|
||||
(sample) =>
|
||||
sample.viewportTop !== null && Math.abs(sample.viewportTop - anchor.viewportTop) <= 2,
|
||||
),
|
||||
evidence,
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
@@ -655,19 +702,25 @@ suite.define(() => {
|
||||
.toBe(initialReadCount + 1);
|
||||
await catalogPane.locator(".chat-history-loading").waitFor();
|
||||
expect(await catalogPane.getByRole("button", { name: "Load older" }).count()).toBe(0);
|
||||
const anchor = await firstVisibleVirtualRow(thread);
|
||||
await startVirtualRowPrependProbe(thread, anchor);
|
||||
await gateway.resolveDeferred("sessions.catalog.read");
|
||||
await expect
|
||||
.poll(() =>
|
||||
catalogPane.evaluate(
|
||||
(element) =>
|
||||
(element as HTMLElement & { catalogMessages: unknown[] }).catalogMessages.length,
|
||||
),
|
||||
)
|
||||
.toBe(41);
|
||||
await page.clock.runFor(100);
|
||||
expectStableVirtualRowPrepend(anchor, await finishVirtualRowPrependProbe(thread));
|
||||
const anchor = await captureTopVisibleVirtualRow(thread);
|
||||
await startVirtualRowPaintProbe(thread, anchor);
|
||||
let paintedSamples: VirtualRowPaintSample[];
|
||||
try {
|
||||
await gateway.resolveDeferred("sessions.catalog.read");
|
||||
await expect
|
||||
.poll(() =>
|
||||
catalogPane.evaluate(
|
||||
(element) =>
|
||||
(element as HTMLElement & { catalogMessages: unknown[] }).catalogMessages.length,
|
||||
),
|
||||
)
|
||||
.toBe(41);
|
||||
// Each mutation is sampled after a full paint, once anchor compensation has settled.
|
||||
await page.clock.runFor(100);
|
||||
} finally {
|
||||
paintedSamples = await stopVirtualRowPaintProbe(thread);
|
||||
}
|
||||
expectPaintedVirtualRowAnchor(anchor, paintedSamples);
|
||||
expect(
|
||||
await catalogPane.locator(".agent-chat__composer-combobox > textarea").isDisabled(),
|
||||
).toBe(true);
|
||||
@@ -785,22 +838,28 @@ suite.define(() => {
|
||||
await page.locator('.chat-virtual-row:not([data-virtual-row-key="history"])').first().waitFor();
|
||||
await gateway.waitForRequest("chat.history");
|
||||
await page.locator(".chat-history-loading").waitFor();
|
||||
const anchor = await firstVisibleVirtualRow(thread);
|
||||
await startVirtualRowPrependProbe(thread, anchor);
|
||||
await gateway.resolveDeferred("chat.history");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator("openclaw-chat-pane")
|
||||
.evaluate(
|
||||
(element) =>
|
||||
(element as HTMLElement & { state: { chatMessages: unknown[] } }).state.chatMessages
|
||||
.length,
|
||||
),
|
||||
)
|
||||
.toBe(140);
|
||||
await page.clock.runFor(100);
|
||||
expectStableVirtualRowPrepend(anchor, await finishVirtualRowPrependProbe(thread));
|
||||
const anchor = await captureTopVisibleVirtualRow(thread);
|
||||
await startVirtualRowPaintProbe(thread, anchor);
|
||||
let paintedSamples: VirtualRowPaintSample[];
|
||||
try {
|
||||
await gateway.resolveDeferred("chat.history");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator("openclaw-chat-pane")
|
||||
.evaluate(
|
||||
(element) =>
|
||||
(element as HTMLElement & { state: { chatMessages: unknown[] } }).state.chatMessages
|
||||
.length,
|
||||
),
|
||||
)
|
||||
.toBe(140);
|
||||
// Each mutation is sampled after a full paint, once anchor compensation has settled.
|
||||
await page.clock.runFor(100);
|
||||
} finally {
|
||||
paintedSamples = await stopVirtualRowPaintProbe(thread);
|
||||
}
|
||||
expectPaintedVirtualRowAnchor(anchor, paintedSamples);
|
||||
expect((await gateway.getRequests("chat.history")).at(-1)?.params).toMatchObject({
|
||||
limit: 100,
|
||||
offset: 100,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { expectRequestCountStable } from "./chat-flow.test-support.ts";
|
||||
import {
|
||||
activateMenuItem,
|
||||
activateSelfRemovingControl,
|
||||
captureUiProof,
|
||||
controlUiSessionPath,
|
||||
controlUiSessionUrl,
|
||||
@@ -180,7 +180,7 @@ suite.define(() => {
|
||||
const archiveItem = menuHost.getByRole("menuitem", { name: "Archive session" });
|
||||
expect(await archiveItem.isDisabled()).toBe(false);
|
||||
expect(await menuHost.getByRole("menuitem", { name: "Delete…" }).isDisabled()).toBe(true);
|
||||
await activateMenuItem(archiveItem);
|
||||
await activateSelfRemovingControl(archiveItem);
|
||||
const patch = await waitForPatch(
|
||||
gateway,
|
||||
(params) => params.key === "agent:main:research" && params.archived === true,
|
||||
@@ -336,15 +336,14 @@ suite.define(() => {
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
const activePane = page.locator("openclaw-chat-pane.chat-pane-cache__pane--active");
|
||||
const sidebar = page.locator("openclaw-app-sidebar");
|
||||
const rowFor = (key: string) =>
|
||||
sidebar.locator(`.sidebar-recent-session[data-session-key="${key}"]`);
|
||||
await rowFor(selected.key).waitFor({ state: "visible", timeout: 10_000 });
|
||||
await rowFor(selected.key).locator("a").first().click();
|
||||
await assertSelectedRoute();
|
||||
await page
|
||||
.locator('openclaw-chat-pane[aria-hidden="false"] .agent-chat__input textarea')
|
||||
.waitFor({ state: "visible" });
|
||||
await activePane.locator(".agent-chat__input textarea").waitFor({ state: "visible" });
|
||||
await page.evaluate((sessionKey) => {
|
||||
const titleHistory: string[] = [];
|
||||
const paneTitleHistory: string[] = [];
|
||||
@@ -431,7 +430,7 @@ suite.define(() => {
|
||||
}
|
||||
await rowFor(batchRows[0]!.key).click({ button: "right" });
|
||||
const batchMenu = page.locator("openclaw-session-menu");
|
||||
await activateMenuItem(
|
||||
await activateSelfRemovingControl(
|
||||
batchMenu.getByRole("menuitem", { name: `Archive ${batchRows.length}` }),
|
||||
);
|
||||
await gateway.waitForRequest("sessions.patchMany");
|
||||
@@ -451,7 +450,7 @@ suite.define(() => {
|
||||
});
|
||||
await selectedRow.hover();
|
||||
await selectedRow.getByRole("button", { name: "Open session menu" }).click();
|
||||
await activateMenuItem(
|
||||
await activateSelfRemovingControl(
|
||||
page.locator("openclaw-session-menu").getByRole("menuitem", {
|
||||
name: "Archive session",
|
||||
}),
|
||||
@@ -460,6 +459,8 @@ suite.define(() => {
|
||||
gateway,
|
||||
(params) => params.key === selected.key && params.archived === true,
|
||||
);
|
||||
const archiveToast = page.locator("openclaw-toast-host .app-toast");
|
||||
await expect.poll(() => archiveToast.textContent()).toContain("Session archived");
|
||||
await gateway.emitGatewayEvent("sessions.changed", {
|
||||
...selected,
|
||||
archived: true,
|
||||
@@ -514,17 +515,13 @@ suite.define(() => {
|
||||
).archiveDocumentTitleHistory ?? [],
|
||||
),
|
||||
).not.toContain("New session — OpenClaw");
|
||||
const archivedNotice = page.locator(".agent-chat__disabled-banner");
|
||||
const archivedNotice = activePane.locator(".agent-chat__disabled-banner");
|
||||
await archivedNotice.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await expect.poll(() => archivedNotice.textContent()).toContain("This session is archived.");
|
||||
await expect
|
||||
.poll(() => page.locator(".chat-pane-cache__pane--visible .agent-chat__input").count())
|
||||
.toBe(0);
|
||||
await expect.poll(() => activePane.locator(".agent-chat__input").count()).toBe(0);
|
||||
|
||||
const archiveToast = page.locator("openclaw-toast-host .app-toast");
|
||||
await expect.poll(() => archiveToast.textContent()).toContain("Session archived");
|
||||
await archiveToast.getByRole("button", { name: "Dismiss" }).click();
|
||||
await archivedNotice.getByRole("button", { name: "Unarchive" }).click();
|
||||
await activateSelfRemovingControl(archivedNotice.getByRole("button", { name: "Unarchive" }));
|
||||
await waitForPatch(
|
||||
gateway,
|
||||
(params) => params.key === selected.key && params.archived === false,
|
||||
@@ -538,9 +535,14 @@ suite.define(() => {
|
||||
|
||||
await assertSelectedRoute();
|
||||
await archivedNotice.waitFor({ state: "detached", timeout: 10_000 });
|
||||
await page
|
||||
.locator(".chat-pane-cache__pane--visible .agent-chat__input textarea")
|
||||
.waitFor({ state: "visible" });
|
||||
await activePane.locator(".agent-chat__input textarea").waitFor({ state: "visible" });
|
||||
await expect
|
||||
.poll(() =>
|
||||
activePane.evaluate(
|
||||
(element) => (element as HTMLElement & { sessionKey?: string }).sessionKey,
|
||||
),
|
||||
)
|
||||
.toBe(selected.key);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -573,6 +575,7 @@ suite.define(() => {
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}chat?session=${encodeURIComponent(archived.key)}`);
|
||||
const activePane = page.locator("openclaw-chat-pane.chat-pane-cache__pane--active");
|
||||
|
||||
const selectedRow = page.locator(
|
||||
`.sidebar-recent-session[data-session-key="${archived.key}"]`,
|
||||
@@ -584,15 +587,15 @@ suite.define(() => {
|
||||
await selectedRow.locator(".sidebar-session__archive-glyph").waitFor({ state: "visible" });
|
||||
await expect.poll(() => page.getByText("Archived planning", { exact: true }).count()).toBe(2);
|
||||
|
||||
const archivedNotice = page.locator(".agent-chat__disabled-banner");
|
||||
const archivedNotice = activePane.locator(".agent-chat__disabled-banner");
|
||||
await archivedNotice.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await expect.poll(() => archivedNotice.textContent()).toContain("This session is archived.");
|
||||
await expect.poll(() => page.locator(".agent-chat__input").count()).toBe(0);
|
||||
await expect.poll(() => activePane.locator(".agent-chat__input").count()).toBe(0);
|
||||
|
||||
await gateway.setMethodResponse("sessions.describe", {
|
||||
session: { ...archived, archived: false },
|
||||
});
|
||||
await archivedNotice.getByRole("button", { name: "Unarchive" }).click();
|
||||
await activateSelfRemovingControl(archivedNotice.getByRole("button", { name: "Unarchive" }));
|
||||
await waitForPatch(
|
||||
gateway,
|
||||
(params) => params.key === archived.key && params.archived === false,
|
||||
@@ -605,7 +608,14 @@ suite.define(() => {
|
||||
});
|
||||
|
||||
await archivedNotice.waitFor({ state: "detached", timeout: 10_000 });
|
||||
await page.locator(".agent-chat__input textarea").waitFor({ state: "visible" });
|
||||
await activePane.locator(".agent-chat__input textarea").waitFor({ state: "visible" });
|
||||
await expect
|
||||
.poll(() =>
|
||||
activePane.evaluate(
|
||||
(element) => (element as HTMLElement & { sessionKey?: string }).sessionKey,
|
||||
),
|
||||
)
|
||||
.toBe(archived.key);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -640,8 +650,9 @@ suite.define(() => {
|
||||
|
||||
try {
|
||||
await page.goto(controlUiSessionUrl(suite.server.baseUrl, deletedKey));
|
||||
await page
|
||||
.locator(".chat-pane-cache__pane--visible .agent-chat__input textarea")
|
||||
const activePane = page.locator("openclaw-chat-pane.chat-pane-cache__pane--active");
|
||||
await activePane
|
||||
.locator(".agent-chat__input textarea")
|
||||
.waitFor({ state: "visible", timeout: 10_000 });
|
||||
|
||||
const requestsBeforeDeletion = (await gateway.getRequests("sessions.list")).length;
|
||||
@@ -655,8 +666,15 @@ suite.define(() => {
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).pathname, { timeout: 15_000 })
|
||||
.toBe(controlUiSessionPath(mainKey));
|
||||
await page
|
||||
.locator(".chat-pane-cache__pane--visible .agent-chat__input textarea")
|
||||
await expect
|
||||
.poll(() =>
|
||||
activePane.evaluate(
|
||||
(element) => (element as HTMLElement & { sessionKey?: string }).sessionKey,
|
||||
),
|
||||
)
|
||||
.toBe(mainKey);
|
||||
await activePane
|
||||
.locator(".agent-chat__input textarea")
|
||||
.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("sessions.list")).length)
|
||||
@@ -709,7 +727,7 @@ suite.define(() => {
|
||||
await row.waitFor({ state: "visible", timeout: 10_000 });
|
||||
|
||||
await row.getByRole("button", { name: "Open session menu" }).click();
|
||||
await activateMenuItem(
|
||||
await activateSelfRemovingControl(
|
||||
page.locator("openclaw-session-menu").getByRole("menuitem", { name: "Delete…" }),
|
||||
);
|
||||
await confirmDelete(page);
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
actionOpacity,
|
||||
activateMenuItem,
|
||||
activateSelfRemovingControl,
|
||||
captureUiProof,
|
||||
captureUiProofEnabled,
|
||||
collapsedSessionSectionsStorageKey,
|
||||
@@ -317,7 +317,7 @@ suite.define(() => {
|
||||
await page.keyboard.press("Escape");
|
||||
await sidebarResearch.hover();
|
||||
await sidebarResearch.getByRole("button", { name: "Open session menu" }).click();
|
||||
await activateMenuItem(page.getByRole("menuitem", { name: "Archive session" }));
|
||||
await activateSelfRemovingControl(page.getByRole("menuitem", { name: "Archive session" }));
|
||||
const archivePatch = await waitForPatch(
|
||||
gateway,
|
||||
(params) => params.key === "agent:main:research" && params.archived === true,
|
||||
@@ -572,7 +572,7 @@ suite.define(() => {
|
||||
await groupMenuButton.click();
|
||||
await page.getByRole("menuitem", { name: "Rename group…" }).waitFor({ state: "visible" });
|
||||
await captureUiProof(page, "sidebar-group-menu.png");
|
||||
await activateMenuItem(page.getByRole("menuitem", { name: "Rename group…" }));
|
||||
await activateSelfRemovingControl(page.getByRole("menuitem", { name: "Rename group…" }));
|
||||
// The rename runs in the owned dialog, prefilled with the name it is
|
||||
// changing; a native prompt here would be a regression.
|
||||
const renameDialog = page.getByRole("dialog", { name: 'Rename group "Research"' });
|
||||
@@ -606,7 +606,7 @@ suite.define(() => {
|
||||
});
|
||||
await projectsGroup.locator(".sidebar-recent-sessions__head").hover();
|
||||
await projectsMenuButton.click();
|
||||
await activateMenuItem(page.getByRole("menuitem", { name: "Delete group…" }));
|
||||
await activateSelfRemovingControl(page.getByRole("menuitem", { name: "Delete group…" }));
|
||||
// The confirm names the group and what happens to its sessions, and only
|
||||
// the operator's answer sends sessions.groups.delete.
|
||||
await page
|
||||
@@ -648,7 +648,7 @@ suite.define(() => {
|
||||
const showAutomationSessions = page.getByRole("menuitemcheckbox", {
|
||||
name: "Show automation sessions",
|
||||
});
|
||||
await activateMenuItem(showAutomationSessions);
|
||||
await activateSelfRemovingControl(showAutomationSessions);
|
||||
await expect.poll(() => sortSessionsButton.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
await sortSessionsButton.click();
|
||||
@@ -683,7 +683,7 @@ suite.define(() => {
|
||||
await captureUiProof(page, "sidebar-groupby-sort-menu-closed.png");
|
||||
|
||||
await sortSessionsButton.click();
|
||||
await activateMenuItem(page.getByRole("menuitemradio", { name: "None" }));
|
||||
await activateSelfRemovingControl(page.getByRole("menuitemradio", { name: "None" }));
|
||||
await expect.poll(() => groups.count()).toBe(1);
|
||||
await expect.poll(() => groups.first().locator(".sidebar-recent-session").count()).toBe(3);
|
||||
} finally {
|
||||
@@ -743,7 +743,7 @@ suite.define(() => {
|
||||
await expect.poll(() => researchGroup.locator(".sidebar-recent-session").count()).toBe(0);
|
||||
await researchGroup.locator(".sidebar-recent-sessions__head").hover();
|
||||
await researchGroup.getByRole("button", { name: "Group options for Research" }).click();
|
||||
await activateMenuItem(page.getByRole("menuitem", { name: "Rename group…" }));
|
||||
await activateSelfRemovingControl(page.getByRole("menuitem", { name: "Rename group…" }));
|
||||
await submitInputDialog(page, "Projects");
|
||||
await gateway.waitForRequest("sessions.groups.rename");
|
||||
await gateway.rejectDeferred("sessions.groups.rename", {
|
||||
@@ -824,7 +824,7 @@ suite.define(() => {
|
||||
await sessionTen.hover();
|
||||
await sessionTen.getByRole("button", { name: "Open session menu" }).click();
|
||||
await openSessionMenuSubmenu(page, "Move to group");
|
||||
await activateMenuItem(page.getByRole("menuitem", { name: "New group…" }));
|
||||
await activateSelfRemovingControl(page.getByRole("menuitem", { name: "New group…" }));
|
||||
await submitInputDialog(page, "Gamma");
|
||||
const gamma = page.locator('[data-session-section="category:Gamma"]');
|
||||
await gamma.waitFor({ state: "visible" });
|
||||
@@ -913,7 +913,7 @@ suite.define(() => {
|
||||
const sortSessionsButton = page.getByRole("button", { name: "Sort sessions" });
|
||||
await sortSessionsButton.locator("..").hover();
|
||||
await sortSessionsButton.click();
|
||||
await activateMenuItem(page.getByRole("menuitemradio", { name: "None" }));
|
||||
await activateSelfRemovingControl(page.getByRole("menuitemradio", { name: "None" }));
|
||||
const flatSection = page.locator('[data-session-section="ungrouped"]');
|
||||
await flatSection
|
||||
.locator('.sidebar-recent-session[data-session-key="agent:main:session-1"]')
|
||||
@@ -954,7 +954,7 @@ suite.define(() => {
|
||||
// A header-menu-created group starts empty and still gets a section.
|
||||
await firstGroup.locator(".sidebar-recent-sessions__head").hover();
|
||||
await firstGroup.getByRole("button", { name: "Group options for First group" }).click();
|
||||
await activateMenuItem(page.getByRole("menuitem", { name: "New group…" }));
|
||||
await activateSelfRemovingControl(page.getByRole("menuitem", { name: "New group…" }));
|
||||
await submitInputDialog(page, "Second group");
|
||||
await page.locator('[data-session-section="category:Second group"]').waitFor({
|
||||
state: "visible",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
activateMenuItem,
|
||||
activateSelfRemovingControl,
|
||||
captureUiProof,
|
||||
createSessionManagementE2eSuite,
|
||||
installMockGateway,
|
||||
@@ -50,7 +50,7 @@ suite.define(() => {
|
||||
await row.hover();
|
||||
await row.getByRole("button", { name: "Open session menu" }).click();
|
||||
await openSessionMenuSubmenu(page, "Move to group");
|
||||
await activateMenuItem(page.getByRole("menuitem", { name: "New group…" }));
|
||||
await activateSelfRemovingControl(page.getByRole("menuitem", { name: "New group…" }));
|
||||
const field = page.getByLabel("New group name");
|
||||
await field.waitFor({ state: "visible" });
|
||||
return field;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
activateMenuItem,
|
||||
activateSelfRemovingControl,
|
||||
captureUiProof,
|
||||
captureUiProofEnabled,
|
||||
createSessionManagementE2eSuite,
|
||||
@@ -112,7 +112,7 @@ suite.define(() => {
|
||||
await pinnedRow.hover();
|
||||
await pinnedRow.getByRole("button", { name: "Open session menu: Pin me" }).click();
|
||||
const menuHost = page.locator("openclaw-session-menu");
|
||||
await activateMenuItem(menuHost.getByRole("menuitem", { name: "Unpin session" }));
|
||||
await activateSelfRemovingControl(menuHost.getByRole("menuitem", { name: "Unpin session" }));
|
||||
|
||||
await expect.poll(() => zoneEntry.count()).toBe(0);
|
||||
await expect.poll(() => row.count()).toBe(1);
|
||||
|
||||
@@ -121,8 +121,33 @@ export async function waitForPatch(
|
||||
throw new Error(`No matching sessions.patch request found: ${JSON.stringify(requests)}`);
|
||||
}
|
||||
|
||||
export async function activateMenuItem(item: Locator): Promise<void> {
|
||||
await item.evaluate((element) => (element as HTMLElement).click());
|
||||
/** Dispatches before a successful action can remove its own control from the DOM. */
|
||||
export async function activateSelfRemovingControl(control: Locator): Promise<void> {
|
||||
await control.evaluate((element) => {
|
||||
const target = element as HTMLElement & { disabled?: boolean };
|
||||
const style = getComputedStyle(target);
|
||||
const bounds = target.getBoundingClientRect();
|
||||
const root = target.getRootNode();
|
||||
const hitTestRoot = root instanceof ShadowRoot ? root : document;
|
||||
const hitTarget = hitTestRoot.elementFromPoint(
|
||||
bounds.left + bounds.width / 2,
|
||||
bounds.top + bounds.height / 2,
|
||||
);
|
||||
if (
|
||||
!target.isConnected ||
|
||||
style.display === "none" ||
|
||||
style.visibility === "hidden" ||
|
||||
bounds.width <= 0 ||
|
||||
bounds.height <= 0 ||
|
||||
target.disabled === true ||
|
||||
target.getAttribute("aria-disabled") === "true" ||
|
||||
!hitTarget ||
|
||||
(hitTarget !== target && !target.contains(hitTarget))
|
||||
) {
|
||||
throw new Error("Self-removing control must be visible and enabled before activation");
|
||||
}
|
||||
target.click();
|
||||
});
|
||||
}
|
||||
|
||||
export function trimmedTextContents(locator: Locator): Promise<string[]> {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Page } from "playwright";
|
||||
import { expect, it } from "vitest";
|
||||
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
|
||||
import {
|
||||
activateMenuItem,
|
||||
activateSelfRemovingControl,
|
||||
controlUiSessionPath,
|
||||
installMockGateway,
|
||||
requireRecord,
|
||||
@@ -123,7 +123,7 @@ suite.define(() => {
|
||||
.filter({ has: page.locator(`.session-label-chip[title="${research.label}"]`) });
|
||||
await actionRow.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await actionRow.getByRole("button", { name: "Open session menu" }).click();
|
||||
await activateMenuItem(
|
||||
await activateSelfRemovingControl(
|
||||
page.locator("openclaw-session-menu").getByRole("menuitem", {
|
||||
name: "Archive session",
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user