test(ui): stabilize session archive e2e lanes (#122068)

This commit is contained in:
Peter Steinberger
2026-08-11 09:46:46 -07:00
committed by GitHub
parent 3f427fb63c
commit db7eda0146
7 changed files with 225 additions and 123 deletions
+138 -79
View File
@@ -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);
+27 -2
View File
@@ -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",
}),