mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-21 18:08:05 -06:00
c77feb00f3
* feat(ui): promote session controls to toolbar * test(ui): add session toolbar proof capture script * chore: register session toolbar proof script * test(ui): target ungrouped session drop zone * fix(ui): trim session toolbar startup cost * docs(ui): point new-session guide at the Sessions toolbar Also name the accepted tradeoff for the inert ungrouped-collapse preference at the partition site (ClawSweeper P1/P3 response). * test(ui): deflake new-session workspace-memory e2e Menu-geometry assertions now measure relative to the picker anchor in one atomic evaluate (immune to unrelated page settles, still catches focus-induced moves). The post-reload refill raced the composer's async draft restore, which appended the stored draft to the typed text; waiting for the restored draft asserts the documented persistence instead. Failed CI shard checks-ui-e2e 5/12 on runs 32122284238 attempts 1-2. * fix(ui): use canonical session owner filter state * chore(ui): keep session toolbar proof artifacts ignored
310 lines
10 KiB
TypeScript
310 lines
10 KiB
TypeScript
#!/usr/bin/env node
|
|
import { mkdir } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { chromium, type Page } from "playwright";
|
|
import {
|
|
canRunPlaywrightChromium,
|
|
installMockGateway,
|
|
resolvePlaywrightChromiumExecutablePath,
|
|
startControlUiE2eServer,
|
|
} from "../ui/src/test-helpers/control-ui-e2e.ts";
|
|
|
|
type CaptureMode = "after" | "before";
|
|
|
|
function readOption(name: string): string | undefined {
|
|
const prefix = `--${name}=`;
|
|
const inline = process.argv.slice(2).find((arg) => arg.startsWith(prefix));
|
|
if (inline) {
|
|
return inline.slice(prefix.length);
|
|
}
|
|
const index = process.argv.indexOf(`--${name}`);
|
|
return index >= 0 ? process.argv[index + 1] : undefined;
|
|
}
|
|
|
|
function readMode(): CaptureMode {
|
|
const value = readOption("mode") ?? "after";
|
|
if (value !== "after" && value !== "before") {
|
|
throw new Error(`Expected --mode after|before, received ${value}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function sessionRow(
|
|
key: string,
|
|
label: string,
|
|
updatedAt: number,
|
|
extra: Record<string, unknown> = {},
|
|
) {
|
|
return {
|
|
contextTokens: 200_000,
|
|
displayName: label,
|
|
hasActiveRun: false,
|
|
key,
|
|
kind: "direct",
|
|
label,
|
|
model: "gpt-5.6-luna",
|
|
modelProvider: "openai",
|
|
status: "done",
|
|
totalTokens: 0,
|
|
updatedAt,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function sessionsListResponse(sessions: unknown[]) {
|
|
return {
|
|
count: sessions.length,
|
|
defaults: {
|
|
contextTokens: 200_000,
|
|
model: "gpt-5.6-luna",
|
|
modelProvider: "openai",
|
|
},
|
|
hasMore: false,
|
|
limitApplied: 50,
|
|
nextOffset: null,
|
|
offset: 0,
|
|
path: "",
|
|
sessions,
|
|
totalCount: sessions.length,
|
|
ts: Date.parse("2026-08-17T20:00:00.000Z"),
|
|
};
|
|
}
|
|
|
|
const baseTime = Date.parse("2026-08-17T20:00:00.000Z");
|
|
const groupedSessions = [
|
|
sessionRow("agent:main:main", "Main", baseTime),
|
|
sessionRow("agent:main:jesse-roadmap", "Roadmap review", baseTime - 60_000, {
|
|
category: "Jesse",
|
|
}),
|
|
sessionRow("agent:main:jesse-launch", "Launch checklist", baseTime - 120_000, {
|
|
category: "Jesse",
|
|
}),
|
|
sessionRow("agent:main:josh-design", "Design handoff", baseTime - 180_000, {
|
|
category: "Josh",
|
|
}),
|
|
sessionRow("agent:main:josh-feedback", "Customer feedback", baseTime - 240_000, {
|
|
category: "Josh",
|
|
}),
|
|
sessionRow("agent:main:weekly-planning", "Weekly planning", baseTime - 300_000),
|
|
sessionRow("agent:main:travel-notes", "Travel notes", baseTime - 360_000),
|
|
sessionRow("agent:main:reading-list", "Reading list", baseTime - 420_000),
|
|
sessionRow("agent:main:toolbar-cleanup", "Toolbar cleanup", baseTime - 480_000, {
|
|
worktree: {
|
|
branch: "feat/session-toolbar",
|
|
id: "wt-session-toolbar",
|
|
repoRoot: "/Users/demo/Projects/openclaw",
|
|
},
|
|
}),
|
|
sessionRow("agent:main:filter-followup", "Filter menu follow-up", baseTime - 540_000, {
|
|
worktree: {
|
|
branch: "fix/filter-menu",
|
|
id: "wt-filter-menu",
|
|
repoRoot: "/Users/demo/Projects/openclaw",
|
|
},
|
|
}),
|
|
];
|
|
|
|
const ungroupedSessions = [
|
|
sessionRow("agent:main:main", "Main", baseTime),
|
|
sessionRow("agent:main:weekly-planning", "Weekly planning", baseTime - 60_000),
|
|
sessionRow("agent:main:travel-notes", "Travel notes", baseTime - 120_000),
|
|
sessionRow("agent:main:reading-list", "Reading list", baseTime - 180_000),
|
|
];
|
|
|
|
const mode = readMode();
|
|
const outputDir = path.resolve(
|
|
readOption("output-dir") ?? ".artifacts/control-ui-e2e/session-toolbar-proof",
|
|
);
|
|
const executablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
|
|
if (!canRunPlaywrightChromium(executablePath)) {
|
|
throw new Error(`Playwright Chromium is unavailable at ${executablePath}`);
|
|
}
|
|
|
|
await mkdir(outputDir, { recursive: true });
|
|
const server = await startControlUiE2eServer(undefined, { source: true });
|
|
const browser = await chromium.launch({ executablePath });
|
|
const captured: string[] = [];
|
|
|
|
async function settle(page: Page): Promise<void> {
|
|
await page.evaluate(async () => {
|
|
await document.fonts.ready;
|
|
await new Promise<void>((resolve) => {
|
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function capture(page: Page, name: string): Promise<void> {
|
|
await settle(page);
|
|
const target = path.join(outputDir, name);
|
|
await page.screenshot({
|
|
animations: "disabled",
|
|
clip: { x: 0, y: 0, width: 560, height: 900 },
|
|
path: target,
|
|
});
|
|
captured.push(target);
|
|
}
|
|
|
|
async function openScenario(sessions: unknown[], groups: string[] = []) {
|
|
const context = await browser.newContext({
|
|
colorScheme: "dark",
|
|
locale: "en-US",
|
|
reducedMotion: "reduce",
|
|
serviceWorkers: "block",
|
|
viewport: { width: 1280, height: 900 },
|
|
});
|
|
const page = await context.newPage();
|
|
page.setDefaultTimeout(30_000);
|
|
await installMockGateway(page, {
|
|
methodResponses: {
|
|
"sessions.list": sessionsListResponse(sessions),
|
|
},
|
|
sessionArchiveFiltering: true,
|
|
sessionGroups: groups,
|
|
sessionKey: "agent:main:main",
|
|
});
|
|
await page.goto(`${server.baseUrl}chat`);
|
|
await page.locator("openclaw-app-sidebar").waitFor({ state: "visible" });
|
|
await page.waitForFunction(() => document.documentElement.dataset.theme === "dark");
|
|
return { context, page };
|
|
}
|
|
|
|
async function expandCoding(page: Page): Promise<void> {
|
|
const coding = page.locator('[data-session-section="work"]');
|
|
await coding.waitFor({ state: "visible" });
|
|
const toggle = coding.getByRole("button", { name: "Coding", exact: true });
|
|
if ((await toggle.getAttribute("aria-expanded")) !== "true") {
|
|
await toggle.click();
|
|
}
|
|
await page.waitForFunction(
|
|
() =>
|
|
document.querySelectorAll('[data-session-section="work"] .sidebar-recent-session').length ===
|
|
2,
|
|
);
|
|
}
|
|
|
|
try {
|
|
if (mode === "after") {
|
|
const grouped = await openScenario(groupedSessions, ["Jesse", "Josh"]);
|
|
try {
|
|
await grouped.page.waitForFunction(() => {
|
|
const counts = ["Jesse", "Josh"].map(
|
|
(name) =>
|
|
document.querySelectorAll(
|
|
`[data-session-section="category:${name}"] .sidebar-recent-session`,
|
|
).length,
|
|
);
|
|
return counts.every((count) => count === 2);
|
|
});
|
|
await grouped.page.waitForFunction(
|
|
() =>
|
|
document.querySelectorAll('[data-session-section="ungrouped"] .sidebar-recent-session')
|
|
.length === 3,
|
|
);
|
|
await expandCoding(grouped.page);
|
|
|
|
const toolbar = grouped.page.locator(".sidebar-session-toolbar");
|
|
await toolbar.getByText("Sessions", { exact: true }).waitFor();
|
|
const filter = toolbar.getByRole("button", { name: "Filter & sort" });
|
|
const add = toolbar.getByRole("button", { name: "New session" });
|
|
await grouped.page.mouse.move(1_000, 850);
|
|
const toolbarOpacity = await Promise.all([
|
|
filter.evaluate((element) => Number.parseFloat(getComputedStyle(element).opacity)),
|
|
add.evaluate((element) => Number.parseFloat(getComputedStyle(element).opacity)),
|
|
]);
|
|
if (toolbarOpacity.some((opacity) => opacity <= 0)) {
|
|
throw new Error(
|
|
`Toolbar controls are not visible without hover: ${toolbarOpacity.join(", ")}`,
|
|
);
|
|
}
|
|
await grouped.page.getByText("Other", { exact: true }).waitFor();
|
|
await capture(grouped.page, "after-grouped.png");
|
|
|
|
await filter.click();
|
|
await grouped.page.locator(".sidebar-session-sort-menu").waitFor({ state: "visible" });
|
|
await capture(grouped.page, "after-toolbar-menu.png");
|
|
|
|
await grouped.page.getByRole("menuitemradio", { name: "All", exact: true }).click();
|
|
await grouped.page.waitForFunction(
|
|
() =>
|
|
document
|
|
.querySelector(".sidebar-session-toolbar .sidebar-session-sort")
|
|
?.classList.contains("sidebar-session-sort--filtered") === true,
|
|
);
|
|
await grouped.page.mouse.move(1_000, 850);
|
|
await capture(grouped.page, "after-filter-active.png");
|
|
} finally {
|
|
await grouped.context.close();
|
|
}
|
|
|
|
const ungrouped = await openScenario(ungroupedSessions);
|
|
try {
|
|
await ungrouped.page.waitForFunction(
|
|
() =>
|
|
document.querySelectorAll('[data-session-section="ungrouped"] .sidebar-recent-session')
|
|
.length === 3,
|
|
);
|
|
if (
|
|
(await ungrouped.page
|
|
.locator('[data-session-section="ungrouped"] > .sidebar-recent-sessions__head')
|
|
.count()) !== 0
|
|
) {
|
|
throw new Error("Ungrouped-only state unexpectedly rendered a section header");
|
|
}
|
|
await ungrouped.page.getByText("Sessions", { exact: true }).waitFor();
|
|
await capture(ungrouped.page, "after-ungrouped-only.png");
|
|
} finally {
|
|
await ungrouped.context.close();
|
|
}
|
|
} else {
|
|
const grouped = await openScenario(groupedSessions, ["Jesse", "Josh"]);
|
|
try {
|
|
await grouped.page.waitForFunction(
|
|
() =>
|
|
document.querySelectorAll('[data-session-section="ungrouped"] .sidebar-recent-session')
|
|
.length === 3,
|
|
);
|
|
await expandCoding(grouped.page);
|
|
const header = grouped.page.locator(
|
|
'[data-session-section="ungrouped"] > .sidebar-recent-sessions__head',
|
|
);
|
|
const filter = header.getByRole("button", { name: "Sort sessions" });
|
|
const add = header.getByRole("button", { name: "New session" });
|
|
|
|
await grouped.page.mouse.move(1_000, 850);
|
|
await settle(grouped.page);
|
|
const idleOpacity = await Promise.all([
|
|
filter.evaluate((element) => Number.parseFloat(getComputedStyle(element).opacity)),
|
|
add.evaluate((element) => Number.parseFloat(getComputedStyle(element).opacity)),
|
|
]);
|
|
if (idleOpacity.some((opacity) => opacity !== 0)) {
|
|
throw new Error(`Legacy controls are not hover-hidden: ${idleOpacity.join(", ")}`);
|
|
}
|
|
await capture(grouped.page, "before-grouped.png");
|
|
|
|
await header.hover();
|
|
await grouped.page.waitForFunction(() => {
|
|
const controls = [
|
|
...document.querySelectorAll(
|
|
'[data-session-section="ungrouped"] > .sidebar-recent-sessions__head .sidebar-session-group-actions',
|
|
),
|
|
];
|
|
return (
|
|
controls.length === 2 &&
|
|
controls.every((element) => getComputedStyle(element).opacity === "1")
|
|
);
|
|
});
|
|
await capture(grouped.page, "before-grouped-hover.png");
|
|
} finally {
|
|
await grouped.context.close();
|
|
}
|
|
}
|
|
} finally {
|
|
await browser.close();
|
|
await server.close();
|
|
}
|
|
|
|
console.log(
|
|
JSON.stringify({ captured, fixture: "custom installMockGateway scenario", mode }, null, 2),
|
|
);
|