Files
Jyotisha/frontend/tests/shell-session-actions-20261028.test.tsx
T
Jesse_ChenandClaude Opus 5.5 0f0fea325e
Independent Staging Quality Gate / validate (push) Failing after 15m9s
Independent Staging Quality Gate / publish (push) Skipped
Sidebar session menu and drag on every page (issue #116)
Rename, delete with confirmation, pin, share and reorder moved from Home's
session hook into the shared (app) shell, so /chart, /ephemeris, /reports
and /people rows get the same menu and drag as /. Home only adds its paging
cursor, what the open conversation does when deleted, and which local chats
are unsaved. BUG-1467.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
2026-10-28 01:52:47 +08:00

148 lines
5.5 KiB
TypeScript

import "./install-test-document";
import assert from "node:assert/strict";
import test from "node:test";
import React, { useEffect } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { AppSidebar } from "../src/components/app-sidebar.tsx";
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
import { useShellSessionActions } from "../src/hooks/use-shell-session-actions.ts";
import type { Account, ChatSession } from "../src/lib/home-types.ts";
import type { SessionListContextValue, ShellSessionHooks } from "../src/lib/session-list-context.tsx";
import { useSessionStore } from "../src/stores/session-store.ts";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
Object.assign(globalThis, { React });
function syntheticSession(index: number): ChatSession {
return {
id: `session-${index}`,
title: `Synthetic session ${index}`,
theme: "general",
modelId: "test",
messages: [],
createdAt: 1,
updatedAt: 1_700_000_000_000 + index,
sessionType: "consultation",
rectificationCaseId: null,
chartProfileId: null,
chartProfileName: null,
chartProfileRole: null,
pinned: false,
archivedAt: null,
messagesHydrated: false,
};
}
const account = { user: { id: "user-1", email: "synthetic@example.test" }, credits: 3, profile: null, avatar: null } as unknown as Account;
function fakeList(): SessionListContextValue {
return {
sessions: [],
setSessions: () => {},
sessionsCursor: null,
setSessionsCursor: () => {},
account,
setAccount: () => {},
signedOut: false,
settled: true,
ready: Promise.resolve(),
waitUntilReady: async () => {},
boot: () => null,
reload: () => {},
registerShellControls: () => {},
};
}
const row = { id: "session-1", title: "Synthetic session 1", pinned: false, updatedAt: 0 };
const noop = () => {};
const sessionControls = {
menuSessionId: null,
disabled: false,
onMenuSessionChange: noop,
onTogglePinned: noop,
onRename: noop,
onShare: noop,
onDelete: noop,
};
// Issue #116: off `/` nothing registers Home's controls, yet the rows carry the menu.
test("a row off `/` has the 「…」 menu once the shell supplies session controls", () => {
const render = (withControls: boolean) => renderToStaticMarkup(
<SidebarProvider>
<AppSidebar
sessions={[row]}
activeSessionId={null}
account={null}
sessionControls={withControls ? sessionControls : null}
onOpenSession={noop}
/>
</SidebarProvider>,
);
const managed = render(true);
assert.match(managed, /session-menu-trigger/);
assert.doesNotMatch(managed, /data-readonly="true"/);
// Signed out or not read yet: still plain links.
assert.match(render(false), /data-readonly="true"/);
});
test("the shell deletes after confirmation, tells Home, and puts the row back when the server refuses", async () => {
const harness = createClientLifecycleHarness();
const originalFetch = globalThis.fetch;
const requests: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
requests.push(`${init?.method ?? "GET"} ${String(input)}`);
return new Response(JSON.stringify({ error: "服务暂时不可用" }), { status: 503 });
}) as typeof fetch;
const sessions = [syntheticSession(1), syntheticSession(2)];
useSessionStore.setState({ sessions, pendingSessionDeletion: null, sessionMenuId: null, pendingSessionId: null, cancellationPending: false });
const deleted: Array<{ id: string; remaining: string[] }> = [];
const hooks: ShellSessionHooks = {
hasMore: false,
onLoadMore: noop,
onDeleted: (session, remaining) => { deleted.push({ id: session.id, remaining: remaining.map((item) => item.id) }); },
isSaved: () => true,
preview: () => false,
};
let actions: ReturnType<typeof useShellSessionActions> | null = null;
function Probe({ list, shellHooks }: { list: SessionListContextValue; shellHooks: ShellSessionHooks | null }) {
const value = useShellSessionActions(list, shellHooks);
useEffect(() => { actions = value; });
return null;
}
try {
await harness.render(<Probe list={fakeList()} shellHooks={hooks} />);
assert.ok(actions);
await harness.update(() => actions?.controls?.onDelete("session-1"));
assert.equal(useSessionStore.getState().pendingSessionDeletion?.id, "session-1", "delete asks first");
assert.deepEqual(requests, [], "nothing is sent before the confirmation");
await harness.update(() => actions?.confirmDeletion());
assert.equal(useSessionStore.getState().pendingSessionDeletion, null);
assert.deepEqual(deleted, [{ id: "session-1", remaining: ["session-2"] }]);
assert.deepEqual(requests, ["DELETE /api/sessions/session-1"]);
await harness.idle();
assert.deepEqual(useSessionStore.getState().sessions.map((item) => item.id), ["session-1", "session-2"], "rolled back");
assert.deepEqual(harness.errors, []);
} finally {
globalThis.fetch = originalFetch;
useSessionStore.setState({ sessions: [], pendingSessionDeletion: null });
await harness.close();
}
});
test("signed out, the shell offers no session controls", async () => {
const harness = createClientLifecycleHarness();
let controls: unknown = "unset";
function Probe() {
const value = useShellSessionActions({ ...fakeList(), account: null, signedOut: true }, null);
useEffect(() => { controls = value.controls; });
return null;
}
try {
await harness.render(<Probe />);
assert.equal(controls, null);
} finally {
await harness.close();
}
});