mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 08:01:33 +00:00
fix(ui): deduplicate gateway-native session hosts
This commit is contained in:
@@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Control UI native session catalogs:** omit the Gateway's same-install node-host copies from Codex and Claude Code catalogs, hide empty host groups, and remove the empty Coding section when no live work sessions exist.
|
||||
- **Control UI dynamic deep links:** reuse the initial route loader result when publishing real agent, session, dashboard, Workboard, Memory, and Plugins paths, avoiding redundant route-loader work during startup. Thanks @shakkernerd.
|
||||
- **Linux gateway service ownership:** refuse user-scope systemd publication and activation when the same gateway unit name is already owned or cannot be verified in the system scope, including `--force`, with actionable recovery guidance instead of creating restart-looping dual managers. Fixes #116129.
|
||||
- **macOS remote tunnel lifecycle:** prevent cancelled or superseded restart backoffs from recreating SSH tunnels, and join a tunnel create that another caller started while the actor was suspended.
|
||||
|
||||
@@ -2568,6 +2568,51 @@ describe("Claude session catalog", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits the Gateway's same-install node host from native discovery", async () => {
|
||||
const home = await createHome();
|
||||
process.env.HOME = home;
|
||||
const invoke = vi.fn(async ({ nodeId }: { nodeId: string }) => ({
|
||||
payloadJSON: JSON.stringify({
|
||||
sessions: [
|
||||
{
|
||||
threadId: `remote-${nodeId}`,
|
||||
status: "stored",
|
||||
source: "claude-cli",
|
||||
archived: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
const provider = captureCatalogProvider({
|
||||
nodes: {
|
||||
list: vi.fn().mockResolvedValue({
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "gateway-node",
|
||||
displayName: "Gateway node",
|
||||
gatewayLocal: true,
|
||||
connected: true,
|
||||
commands: [CLAUDE_SESSIONS_LIST_COMMAND],
|
||||
},
|
||||
{
|
||||
nodeId: "remote-node",
|
||||
displayName: "Remote node",
|
||||
connected: true,
|
||||
commands: [CLAUDE_SESSIONS_LIST_COMMAND],
|
||||
},
|
||||
],
|
||||
}),
|
||||
invoke,
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
|
||||
const hosts = await provider.list({});
|
||||
|
||||
expect(hosts.map((host) => host.hostId)).toEqual(["gateway:local", "node:remote-node"]);
|
||||
expect(invoke).toHaveBeenCalledTimes(1);
|
||||
expect(invoke).toHaveBeenCalledWith(expect.objectContaining({ nodeId: "remote-node" }));
|
||||
});
|
||||
|
||||
it("bounds how long a hung paired-node catalog can delay the caller", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
@@ -1497,6 +1497,7 @@ async function listClaudeSessionCatalog(params: {
|
||||
const eligible = nodes
|
||||
.filter(
|
||||
(node) =>
|
||||
node.gatewayLocal !== true &&
|
||||
node.commands?.includes(CLAUDE_SESSIONS_LIST_COMMAND) &&
|
||||
(!requested || requested.has(`node:${node.nodeId}`)),
|
||||
)
|
||||
|
||||
@@ -1162,6 +1162,51 @@ describe("Codex supervision catalog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("omits the Gateway's same-install node host from native discovery", async () => {
|
||||
const control = createControl({
|
||||
listPage: vi.fn(async () => ({
|
||||
sessions: [{ threadId: "local", status: "idle", archived: false }],
|
||||
})),
|
||||
});
|
||||
const invoke = vi.fn<PluginRuntime["nodes"]["invoke"]>(async ({ nodeId }) => ({
|
||||
payloadJSON: JSON.stringify({
|
||||
sessions: [{ threadId: `remote-${nodeId}`, status: "idle", archived: false }],
|
||||
}),
|
||||
}));
|
||||
const { runtime } = createRuntime({
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "gateway-node",
|
||||
displayName: "Gateway node",
|
||||
gatewayLocal: true,
|
||||
connected: true,
|
||||
commands: [CODEX_APP_SERVER_THREADS_LIST_COMMAND],
|
||||
},
|
||||
{
|
||||
nodeId: "remote-node",
|
||||
displayName: "Remote node",
|
||||
connected: true,
|
||||
commands: [CODEX_APP_SERVER_THREADS_LIST_COMMAND],
|
||||
},
|
||||
],
|
||||
invoke,
|
||||
});
|
||||
|
||||
const result = await listCodexSessionCatalog({
|
||||
bindingStore: createCodexTestBindingStore(),
|
||||
config,
|
||||
runtime,
|
||||
control,
|
||||
});
|
||||
|
||||
expect(result.hosts.map((host) => host.hostId)).toEqual([
|
||||
CODEX_LOCAL_SESSION_HOST_ID,
|
||||
"node:remote-node",
|
||||
]);
|
||||
expect(invoke).toHaveBeenCalledTimes(1);
|
||||
expect(invoke).toHaveBeenCalledWith(expect.objectContaining({ nodeId: "remote-node" }));
|
||||
});
|
||||
|
||||
it("isolates federated host failures while preserving selected healthy hosts", async () => {
|
||||
const control = createControl({
|
||||
listPage: vi.fn(async () => ({
|
||||
|
||||
@@ -627,6 +627,7 @@ async function listCodexSessionCatalog(params: {
|
||||
nodes = (await (params.listNodes?.() ?? params.runtime.nodes.list())).nodes
|
||||
.filter(
|
||||
(node) =>
|
||||
node.gatewayLocal !== true &&
|
||||
node.commands?.includes(CODEX_APP_SERVER_THREADS_LIST_COMMAND) &&
|
||||
(!requestedHostIds || requestedHostIds.has(`node:${node.nodeId}`)),
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { listDevicePairing, resolveNodePairingState } from "../../infra/device-pairing.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { projectNodePairing } from "../../infra/node-pairing.js";
|
||||
import { resolveLocalNodeId } from "../../node-host/local-id.js";
|
||||
import type { NodeListNode } from "../../shared/node-list-types.js";
|
||||
import { replaceRemoteNodeSkills } from "../../skills/runtime/remote-skills.js";
|
||||
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../skills/runtime/remote.js";
|
||||
@@ -61,6 +62,7 @@ function listNodesForClient(params: {
|
||||
pairedNodes: ReturnType<typeof projectNodePairing>["paired"];
|
||||
pendingNodes: ReturnType<typeof projectNodePairing>["pending"];
|
||||
connectedNodes: readonly NodeSession[];
|
||||
localNodeId: string | null;
|
||||
}): NodeListNode[] {
|
||||
const catalog = createKnownNodeCatalog({
|
||||
pairedDevices: params.pairedDevices,
|
||||
@@ -68,7 +70,9 @@ function listNodesForClient(params: {
|
||||
pendingNodes: params.pendingNodes,
|
||||
connectedNodes: params.connectedNodes,
|
||||
});
|
||||
const nodes = listKnownNodes(catalog);
|
||||
const nodes = listKnownNodes(catalog).map((node) =>
|
||||
node.nodeId === params.localNodeId ? Object.assign({}, node, { gatewayLocal: true }) : node,
|
||||
);
|
||||
if (nodeInvokePolicy.canReadPendingNodePairing(params.client)) {
|
||||
return nodes;
|
||||
}
|
||||
@@ -223,12 +227,19 @@ export const nodeReadHandlers: GatewayRequestHandlers = {
|
||||
const devicePairing = await listDevicePairing();
|
||||
const nodePairing = projectNodePairing(devicePairing.paired);
|
||||
const connectedNodes = listCurrentConnectedNodes(context, devicePairing.paired);
|
||||
const localNodeId = await resolveLocalNodeId().catch((error: unknown) => {
|
||||
context.logGateway.warn(
|
||||
`failed to resolve same-install node-host identity: ${formatErrorMessage(error)}`,
|
||||
);
|
||||
return null;
|
||||
});
|
||||
const nodes = listNodesForClient({
|
||||
client,
|
||||
pairedDevices: devicePairing.paired,
|
||||
pairedNodes: nodePairing.paired,
|
||||
pendingNodes: nodePairing.pending,
|
||||
connectedNodes,
|
||||
localNodeId,
|
||||
});
|
||||
const activeNodeId = context.nodeRegistry.getActiveNode(connectedNodes)?.nodeId;
|
||||
const nodesWithPresence = activeNodeId
|
||||
|
||||
@@ -1054,7 +1054,7 @@ describe("loadGatewayPlugins", () => {
|
||||
expect(opts.req.method).toBe("node.list");
|
||||
opts.respond(true, {
|
||||
nodes: [
|
||||
{ nodeId: "connected", connected: true },
|
||||
{ nodeId: "connected", connected: true, gatewayLocal: true },
|
||||
{ nodeId: "offline", connected: false },
|
||||
],
|
||||
});
|
||||
@@ -1066,7 +1066,7 @@ describe("loadGatewayPlugins", () => {
|
||||
const result = await runtime.nodes.list({ connected: true });
|
||||
|
||||
expect(getLastDispatchedParams()).toStrictEqual({});
|
||||
expect(result.nodes).toEqual([{ nodeId: "connected", connected: true }]);
|
||||
expect(result.nodes).toEqual([{ nodeId: "connected", connected: true, gatewayLocal: true }]);
|
||||
});
|
||||
|
||||
test("projects effective node-command policy into the plugin node runtime", async () => {
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { upsertPresence } from "../../../infra/system-presence.js";
|
||||
import { loadVoiceWakeRoutingConfig } from "../../../infra/voicewake-routing.js";
|
||||
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
|
||||
import { loadNodeHostConfig } from "../../../node-host/config.js";
|
||||
import { resolveLocalNodeId } from "../../../node-host/local-id.js";
|
||||
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../skills/runtime/remote.js";
|
||||
import { ensureProfileForEmail } from "../../../state/user-profiles.js";
|
||||
import {
|
||||
@@ -63,16 +63,6 @@ function isReleasedVersion(version: string): boolean {
|
||||
return RELEASED_VERSION_RE.test(version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily resolve the local node host's nodeId from canonical shared SQLite state.
|
||||
* Process-stable: only changes on `openclaw node install`, which requires restart.
|
||||
*/
|
||||
let cachedLocalNodeId: Promise<string | null> | undefined;
|
||||
async function resolveLocalNodeId(): Promise<string | null> {
|
||||
cachedLocalNodeId ??= loadNodeHostConfig().then((config) => config?.nodeId ?? null);
|
||||
return await cachedLocalNodeId;
|
||||
}
|
||||
|
||||
function setSocketMaxPayload(socket: WebSocket, maxPayload: number): void {
|
||||
const receiver = (socket as { _receiver?: { _maxPayload?: number } })["_receiver"];
|
||||
if (receiver) {
|
||||
|
||||
68
src/node-host/local-id.test.ts
Normal file
68
src/node-host/local-id.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import {
|
||||
createOpenClawTestState,
|
||||
type OpenClawTestState,
|
||||
} from "../test-utils/openclaw-test-state.js";
|
||||
import { configureNodeHost } from "./config.js";
|
||||
import { resolveLocalNodeId } from "./local-id.js";
|
||||
|
||||
const states: OpenClawTestState[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
while (states.length > 0) {
|
||||
await states.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
describe("resolveLocalNodeId", () => {
|
||||
it("reads and process-caches the canonical same-install node id", async () => {
|
||||
const state = await createOpenClawTestState({
|
||||
label: "local-node-id",
|
||||
layout: "state-only",
|
||||
});
|
||||
states.push(state);
|
||||
await configureNodeHost({
|
||||
candidateNodeId: "gateway-local-node",
|
||||
fallbackDisplayName: "Gateway node",
|
||||
gateway: {},
|
||||
env: state.env,
|
||||
nowMs: 1,
|
||||
});
|
||||
|
||||
await expect(resolveLocalNodeId(state.env)).resolves.toBe("gateway-local-node");
|
||||
|
||||
await configureNodeHost({
|
||||
candidateNodeId: "replacement-node",
|
||||
fallbackDisplayName: "Replacement node",
|
||||
gateway: {},
|
||||
env: state.env,
|
||||
nowMs: 2,
|
||||
});
|
||||
await expect(resolveLocalNodeId(state.env)).resolves.toBe("gateway-local-node");
|
||||
});
|
||||
|
||||
it("retries after a failed canonical config read", async () => {
|
||||
const state = await createOpenClawTestState({
|
||||
label: "local-node-id-retry",
|
||||
layout: "state-only",
|
||||
});
|
||||
states.push(state);
|
||||
const legacyPath = state.statePath("node.json");
|
||||
await fs.writeFile(legacyPath, "{}\n", "utf8");
|
||||
|
||||
await expect(resolveLocalNodeId(state.env)).rejects.toThrow("openclaw doctor --fix");
|
||||
|
||||
await fs.rm(legacyPath);
|
||||
await configureNodeHost({
|
||||
candidateNodeId: "recovered-local-node",
|
||||
fallbackDisplayName: "Recovered node",
|
||||
gateway: {},
|
||||
env: state.env,
|
||||
nowMs: 1,
|
||||
});
|
||||
await expect(resolveLocalNodeId(state.env)).resolves.toBe("recovered-local-node");
|
||||
});
|
||||
});
|
||||
27
src/node-host/local-id.ts
Normal file
27
src/node-host/local-id.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { loadNodeHostConfig } from "./config.js";
|
||||
|
||||
const localNodeIdByStateDir = new Map<string, Promise<string | null>>();
|
||||
|
||||
/**
|
||||
* Resolve the same-install node host from canonical shared SQLite state.
|
||||
* Node-host config changes require restart, so this fact stays process-stable.
|
||||
*/
|
||||
export async function resolveLocalNodeId(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<string | null> {
|
||||
const stateDir = resolveStateDir(env);
|
||||
let pending = localNodeIdByStateDir.get(stateDir);
|
||||
if (!pending) {
|
||||
pending = loadNodeHostConfig(env).then((config) => config?.nodeId ?? null);
|
||||
localNodeIdByStateDir.set(stateDir, pending);
|
||||
}
|
||||
try {
|
||||
return await pending;
|
||||
} catch (error) {
|
||||
if (localNodeIdByStateDir.get(stateDir) === pending) {
|
||||
localNodeIdByStateDir.delete(stateDir);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,8 @@ type RuntimeNodeListResult = {
|
||||
connected?: boolean;
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
/** True only for the node host installed alongside this Gateway. */
|
||||
gatewayLocal?: boolean;
|
||||
/** Advertised commands currently permitted by Gateway node-command policy. */
|
||||
invocableCommands?: string[];
|
||||
nodePluginTools?: NodePluginToolDescriptor[];
|
||||
|
||||
@@ -10,6 +10,8 @@ export type NodeListNode = {
|
||||
uiVersion?: string;
|
||||
clientId?: string;
|
||||
clientMode?: string;
|
||||
/** This node host runs from the Gateway's own canonical node-host installation. */
|
||||
gatewayLocal?: boolean;
|
||||
remoteIp?: string;
|
||||
deviceFamily?: string;
|
||||
modelIdentifier?: string;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
formatSidebarTimestamp,
|
||||
type CatalogBackingSessionDisplay,
|
||||
type CatalogSessionMenuRequest,
|
||||
visibleCatalogHosts,
|
||||
} from "./app-sidebar-session-catalogs.ts";
|
||||
import { renderSidebarSessionSectionHeader } from "./app-sidebar-session-section-header.ts";
|
||||
import { icons } from "./icons.ts";
|
||||
@@ -119,15 +120,8 @@ export function renderSessionCatalogGroups(params: SessionCatalogGroupsParams) {
|
||||
const sectionId = `catalog:${catalog.id}`;
|
||||
const collapsed = params.collapsedSections.has(sectionId);
|
||||
const hosts = catalog.hosts;
|
||||
const visibleHosts: SessionCatalogHost[] = [];
|
||||
for (const host of hosts) {
|
||||
const sessions = host.sessions.filter(
|
||||
(session) => !params.creatorId || session.createdActor?.id === params.creatorId,
|
||||
);
|
||||
if (sessions.length > 0) {
|
||||
visibleHosts.push(sessions.length === host.sessions.length ? host : { ...host, sessions });
|
||||
}
|
||||
}
|
||||
// Catalog providers own host identity; the sidebar only removes hosts with no visible rows.
|
||||
const visibleHosts = visibleCatalogHosts(hosts, params.creatorId);
|
||||
const rows = visibleHosts.flatMap((host) =>
|
||||
host.sessions.map((session) => ({ host, session })),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SessionCatalogHost } from "../../../packages/gateway-protocol/src/index.ts";
|
||||
import { i18n } from "../i18n/index.ts";
|
||||
import { formatSidebarTimestamp } from "./app-sidebar-session-catalogs.ts";
|
||||
import { formatSidebarTimestamp, visibleCatalogHosts } from "./app-sidebar-session-catalogs.ts";
|
||||
|
||||
describe("formatSidebarTimestamp", () => {
|
||||
afterEach(async () => {
|
||||
@@ -31,3 +32,60 @@ describe("formatSidebarTimestamp", () => {
|
||||
expect(formatSidebarTimestamp(Date.now() + 5 * 60_000)).toBe("in 5m");
|
||||
});
|
||||
});
|
||||
|
||||
describe("visibleCatalogHosts", () => {
|
||||
const session = (threadId: string, name: string) => ({
|
||||
threadId,
|
||||
name,
|
||||
status: "idle",
|
||||
archived: false,
|
||||
canContinue: true,
|
||||
canArchive: false,
|
||||
});
|
||||
|
||||
it("removes empty hosts", () => {
|
||||
const hosts: SessionCatalogHost[] = [
|
||||
{
|
||||
hostId: "gateway:local",
|
||||
label: "Gateway",
|
||||
kind: "gateway",
|
||||
connected: true,
|
||||
sessions: [session("shared", "Gateway copy")],
|
||||
},
|
||||
{
|
||||
hostId: "node:empty",
|
||||
label: "Empty node",
|
||||
kind: "node",
|
||||
connected: true,
|
||||
sessions: [],
|
||||
},
|
||||
];
|
||||
|
||||
expect(visibleCatalogHosts(hosts)).toEqual([hosts[0]]);
|
||||
});
|
||||
|
||||
it("filters sessions by creator without inferring host identity", () => {
|
||||
const hosts: SessionCatalogHost[] = [
|
||||
{
|
||||
hostId: "node:remote",
|
||||
label: "Remote node",
|
||||
kind: "node",
|
||||
connected: true,
|
||||
sessions: [
|
||||
{
|
||||
...session("mine", "Mine"),
|
||||
createdActor: { id: "operator:mine", type: "human" },
|
||||
},
|
||||
{
|
||||
...session("theirs", "Theirs"),
|
||||
createdActor: { id: "operator:theirs", type: "human" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(visibleCatalogHosts(hosts, "operator:mine")).toEqual([
|
||||
{ ...hosts[0]!, sessions: [hosts[0]!.sessions[0]!] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
SessionCatalog,
|
||||
SessionCatalogHost,
|
||||
SessionCatalogSession,
|
||||
} from "../../../packages/gateway-protocol/src/index.ts";
|
||||
import type { ApplicationNavigationOptions } from "../app/context.ts";
|
||||
@@ -42,6 +43,22 @@ export function adoptedCatalogSessionKeys(catalogs: readonly SessionCatalog[]):
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function visibleCatalogHosts(
|
||||
hosts: readonly SessionCatalogHost[],
|
||||
creatorId?: string | null,
|
||||
): SessionCatalogHost[] {
|
||||
const visible: SessionCatalogHost[] = [];
|
||||
for (const host of hosts) {
|
||||
const sessions = host.sessions.filter(
|
||||
(session) => !creatorId || session.createdActor?.id === creatorId,
|
||||
);
|
||||
if (sessions.length > 0) {
|
||||
visible.push(sessions.length === host.sessions.length ? host : { ...host, sessions });
|
||||
}
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
export type CatalogBackingSessionDisplay = {
|
||||
label: string;
|
||||
subtitle?: string;
|
||||
|
||||
@@ -395,15 +395,7 @@ function renderSessionListBody(params: {
|
||||
: nothing}`;
|
||||
}
|
||||
if (section.id === "work") {
|
||||
// Keep the Coding header visible beside catalog sections just as it
|
||||
// was when those sections were nested inside it.
|
||||
if (
|
||||
section.totalRowCount === 0 &&
|
||||
!(
|
||||
catalogsVisible &&
|
||||
(params.catalogs.catalogs.length > 0 || params.catalogs.refreshStatus.error !== null)
|
||||
)
|
||||
) {
|
||||
if (section.totalRowCount === 0) {
|
||||
return nothing;
|
||||
}
|
||||
return renderSessionSection({ host, section });
|
||||
|
||||
@@ -207,7 +207,15 @@ function hostGroupedNativeCatalogs() {
|
||||
|
||||
async function expandCodingSection(page: Page) {
|
||||
const toggle = page.locator('[data-session-section="work"] .sidebar-session-group-toggle');
|
||||
await toggle.waitFor({ state: "visible" });
|
||||
await page.waitForFunction(() =>
|
||||
Boolean(
|
||||
document.querySelector('[data-session-section="work"]') ??
|
||||
document.querySelector('[data-session-section^="catalog:"]'),
|
||||
),
|
||||
);
|
||||
if ((await toggle.count()) === 0) {
|
||||
return;
|
||||
}
|
||||
if ((await toggle.getAttribute("aria-expanded")) === "false") {
|
||||
await toggle.click();
|
||||
}
|
||||
@@ -259,6 +267,9 @@ suite("Claude native session catalog", () => {
|
||||
expect(await gatewayHost.getByText("Gateway Mac", { exact: true }).count()).toBe(0);
|
||||
expect(await gatewayHost.locator(".sidebar-recent-session").count()).toBe(1);
|
||||
expect(await buildHost.locator(".sidebar-recent-session").count()).toBe(1);
|
||||
expect(await section.getByText(`${catalogLabel} local plan`, { exact: true }).count()).toBe(
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
|
||||
|
||||
@@ -29,9 +29,21 @@ const uiProofArtifactDir = path.join(
|
||||
"native-session-discovery",
|
||||
);
|
||||
|
||||
async function expandCodingSection(page: Page) {
|
||||
async function expandCodingSection(page: Page, required = false) {
|
||||
const toggle = page.locator('[data-session-section="work"] .sidebar-session-group-toggle');
|
||||
await toggle.waitFor({ state: "visible" });
|
||||
if (required) {
|
||||
await toggle.waitFor({ state: "visible" });
|
||||
} else {
|
||||
await page.waitForFunction(() =>
|
||||
Boolean(
|
||||
document.querySelector('[data-session-section="work"]') ??
|
||||
document.querySelector('[data-session-section^="catalog:"]'),
|
||||
),
|
||||
);
|
||||
if ((await toggle.count()) === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ((await toggle.getAttribute("aria-expanded")) === "false") {
|
||||
await toggle.click();
|
||||
}
|
||||
@@ -198,7 +210,7 @@ suite("Codex native session catalog", () => {
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await page.evaluate(() => document.documentElement.setAttribute("data-theme-mode", "dark"));
|
||||
await expandCodingSection(page);
|
||||
await expandCodingSection(page, true);
|
||||
const sessionGroups = page.locator(".sidebar-recent-sessions");
|
||||
const workSection = sessionGroups.locator(':scope > [data-session-section="work"]');
|
||||
const liveRows = workSection.locator(":scope > .sidebar-recent-sessions__list");
|
||||
|
||||
@@ -116,7 +116,6 @@ 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();
|
||||
|
||||
139
ui/src/e2e/native-session-sidebar.e2e.test.ts
Normal file
139
ui/src/e2e/native-session-sidebar.e2e.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { chromium, type Browser } from "playwright";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
canRunPlaywrightChromium,
|
||||
installMockGateway,
|
||||
resolvePlaywrightChromiumExecutablePath,
|
||||
startControlUiE2eServer,
|
||||
type ControlUiE2eServer,
|
||||
} from "../test-helpers/control-ui-e2e.ts";
|
||||
|
||||
const executablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
|
||||
const available = canRunPlaywrightChromium(executablePath);
|
||||
const allowMissing = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
|
||||
const suite = available || !allowMissing ? describe : describe.skip;
|
||||
const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
|
||||
const collapsedSessionSectionsStorageKey = "openclaw:sidebar:sessions:collapsed-sections";
|
||||
const uiProofArtifactDir = path.join(
|
||||
process.cwd(),
|
||||
".artifacts",
|
||||
"control-ui-e2e",
|
||||
"native-session-discovery",
|
||||
);
|
||||
|
||||
let browser: Browser;
|
||||
let server: ControlUiE2eServer;
|
||||
|
||||
suite("native session sidebar", () => {
|
||||
beforeAll(async () => {
|
||||
if (!available) {
|
||||
throw new Error(`Playwright Chromium is unavailable at ${executablePath}`);
|
||||
}
|
||||
server = await startControlUiE2eServer();
|
||||
browser = await chromium.launch({ executablePath });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
it("hides empty native hosts and the empty Coding section", async () => {
|
||||
const page = await browser.newPage({
|
||||
deviceScaleFactor: 2,
|
||||
viewport: { height: 1100, width: 1440 },
|
||||
});
|
||||
await page.addInitScript(
|
||||
(key) => localStorage.removeItem(key),
|
||||
collapsedSessionSectionsStorageKey,
|
||||
);
|
||||
await installMockGateway(page, {
|
||||
featureMethods: ["chat.metadata", "chat.startup", "sessions.catalog.list"],
|
||||
methodResponses: {
|
||||
"sessions.catalog.list": {
|
||||
catalogs: [
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
capabilities: { continueSession: true, archive: true },
|
||||
hosts: [
|
||||
{
|
||||
hostId: "gateway:local",
|
||||
label: "Gateway",
|
||||
kind: "gateway",
|
||||
connected: true,
|
||||
sessions: [
|
||||
{
|
||||
threadId: "thread-shared",
|
||||
name: "Shared gateway session",
|
||||
cwd: "/workspace/openclaw",
|
||||
status: "idle",
|
||||
archived: false,
|
||||
canContinue: true,
|
||||
canArchive: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
hostId: "node:remote",
|
||||
label: "Remote Workstation",
|
||||
kind: "node",
|
||||
connected: true,
|
||||
nodeId: "remote",
|
||||
sessions: [
|
||||
{
|
||||
threadId: "thread-remote",
|
||||
name: "Remote-only session",
|
||||
cwd: "/workspace/remote",
|
||||
status: "idle",
|
||||
archived: false,
|
||||
canContinue: false,
|
||||
canArchive: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
hostId: "node:empty",
|
||||
label: "Empty Workstation",
|
||||
kind: "node",
|
||||
connected: true,
|
||||
nodeId: "empty",
|
||||
sessions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.setAttribute("data-theme", "openknot");
|
||||
document.documentElement.setAttribute("data-theme-mode", "dark");
|
||||
});
|
||||
const sessionGroups = page.locator(".sidebar-recent-sessions");
|
||||
const section = sessionGroups.locator(':scope > [data-session-section="catalog:codex"]');
|
||||
await section.waitFor({ state: "visible" });
|
||||
await section.getByText("Shared gateway session", { exact: true }).waitFor();
|
||||
await section.getByText("Remote-only session", { exact: true }).waitFor();
|
||||
if (captureUiProofEnabled) {
|
||||
await mkdir(uiProofArtifactDir, { recursive: true });
|
||||
await sessionGroups.screenshot({
|
||||
animations: "disabled",
|
||||
path: path.join(uiProofArtifactDir, "08-after-deduplicated-session-hosts.png"),
|
||||
});
|
||||
}
|
||||
|
||||
expect(await sessionGroups.locator(':scope > [data-session-section="work"]').count()).toBe(0);
|
||||
expect(await section.getByText("Shared gateway session", { exact: true }).count()).toBe(1);
|
||||
expect(await section.locator('[data-session-catalog-host="node:remote"]').count()).toBe(1);
|
||||
expect(await section.locator('[data-session-catalog-host="node:empty"]').count()).toBe(0);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user