Files
openchamber/packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.test.ts
T

263 lines
9.9 KiB
TypeScript
Raw Normal View History

import { describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import type { SessionGroup, SessionNode } from '../types';
// ---------------------------------------------------------------------------
// Helper: simulate the projectSessionMeta computation from the hook
// (same visitNodes logic as useProjectSessionSelection.ts lines 46-71)
// ---------------------------------------------------------------------------
type ProjectSection = {
project: { id: string; normalizedPath: string };
groups: SessionGroup[];
};
function computeProjectMeta(projectSections: ProjectSection[]) {
const metaByProject = new Map<string, Map<string, { directory: string | null }>>();
const firstSessionByProject = new Map<string, { id: string; directory: string | null }>();
const visitNodes = (
projectId: string,
projectRoot: string,
fallbackDirectory: string | null,
nodes: SessionNode[],
) => {
if (!metaByProject.has(projectId)) {
metaByProject.set(projectId, new Map());
}
const projectMap = metaByProject.get(projectId)!;
nodes.forEach((node) => {
const sessionDirectory = (
node.worktree?.path
?? (node.session as Session & { directory?: string | null }).directory
?? fallbackDirectory
?? projectRoot
).replace(/\\/g, '/').replace(/\/+$/, '');
projectMap.set(node.session.id, { directory: sessionDirectory });
if (!firstSessionByProject.has(projectId)) {
firstSessionByProject.set(projectId, { id: node.session.id, directory: sessionDirectory });
}
if (node.children.length > 0) {
visitNodes(projectId, projectRoot, sessionDirectory, node.children);
}
});
};
projectSections.forEach((section) => {
section.groups.forEach((group) => {
visitNodes(section.project.id, section.project.normalizedPath, group.directory, group.sessions);
});
});
return { metaByProject, firstSessionByProject };
}
// ---------------------------------------------------------------------------
// Test data
// ---------------------------------------------------------------------------
const makeSession = (id: string, directory?: string): Session =>
({ id, directory } as unknown as Session);
const rootSession1 = makeSession('root-session-1', '/workspace/project');
const rootSession2 = makeSession('root-session-2', '/workspace/project');
const worktreeSession1 = makeSession('wt-session-1', '/workspace/project-wt');
const project2Session1 = makeSession('project-2-session-1', '/workspace/project-2');
const project2Session2 = makeSession('project-2-session-2', '/workspace/project-2');
const WORKTREE_PATH = '/workspace/project-wt';
// staleSections: root group only, no worktree group
const staleSections: ProjectSection[] = [
{
project: { id: 'project-1', normalizedPath: '/workspace/project' },
groups: [
{
id: 'root',
label: 'Main',
branch: null,
description: null,
isMain: true,
worktree: null,
directory: '/workspace/project',
sessions: [
{ session: rootSession1, children: [], worktree: null },
{ session: rootSession2, children: [], worktree: null },
],
},
],
},
];
// updatedSections: includes the worktree group
const updatedSections: ProjectSection[] = [
{
project: { id: 'project-1', normalizedPath: '/workspace/project' },
groups: [
{
id: 'root',
label: 'Main',
branch: null,
description: null,
isMain: true,
worktree: null,
directory: '/workspace/project',
sessions: [
{ session: rootSession1, children: [], worktree: null },
{ session: rootSession2, children: [], worktree: null },
],
},
{
id: 'wt-group',
label: 'feature-branch',
branch: 'feature-branch',
description: 'Worktree at ' + WORKTREE_PATH,
isMain: false,
worktree: { path: WORKTREE_PATH, projectDirectory: '/workspace/project', branch: 'feature-branch', label: 'feature-branch' },
directory: WORKTREE_PATH,
sessions: [
{ session: worktreeSession1, children: [], worktree: { path: WORKTREE_PATH, projectDirectory: '/workspace/project', branch: 'feature-branch', label: 'feature-branch' } },
],
},
],
},
];
// project-2Sections: separate project for project-switching tests
const project2Sections: ProjectSection[] = [
{
project: { id: 'project-2', normalizedPath: '/workspace/project-2' },
groups: [
{
id: 'root',
label: 'Main',
branch: null,
description: null,
isMain: true,
worktree: null,
directory: '/workspace/project-2',
sessions: [
{ session: project2Session1, children: [], worktree: null },
{ session: project2Session2, children: [], worktree: null },
],
},
],
},
];
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('useProjectSessionSelection — worktree session click race', () => {
test('stale projectSections (no worktree group) excludes worktree sessions from projectMap', () => {
const { metaByProject } = computeProjectMeta(staleSections);
const projectMap = metaByProject.get('project-1');
// Root sessions are present
expect(projectMap?.has('root-session-1')).toBe(true);
expect(projectMap?.has('root-session-2')).toBe(true);
// Worktree session is NOT present — this is what triggers the bug
expect(projectMap?.has('wt-session-1')).toBe(false);
});
test('stale data firstSessionByProject points to first root session, not worktree session', () => {
const { firstSessionByProject } = computeProjectMeta(staleSections);
// Path C would fall back to firstSessionByProject, which is the first ROOT session
const first = firstSessionByProject.get('project-1');
expect(first?.id).toBe('root-session-1');
expect(first?.id).not.toBe('wt-session-1');
});
test('updated projectSections includes all sessions including worktree', () => {
const { metaByProject } = computeProjectMeta(updatedSections);
const projectMap = metaByProject.get('project-1');
expect(projectMap?.has('root-session-1')).toBe(true);
expect(projectMap?.has('root-session-2')).toBe(true);
expect(projectMap?.has('wt-session-1')).toBe(true);
});
test('guard preserves currentSessionId when projectMap is stale (the bug fix)', () => {
const { metaByProject, firstSessionByProject } = computeProjectMeta(staleSections);
const projectMap = metaByProject.get('project-1')!;
const currentSessionId = 'wt-session-1';
// Path A fails: currentSessionId is set but not in stale projectMap
const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId));
expect(pathAHit).toBe(false);
// Guard: if (currentSessionId) return;
// This is what prevents the fallthrough to Path C (auto-select wrong session)
// Without the guard, Path C would select firstSessionByProject = root-session-1
// instead of preserving the user's wt-session-1 selection
const fallback = firstSessionByProject.get('project-1')?.id ?? null;
expect(fallback).toBe('root-session-1');
expect(fallback).not.toBe(currentSessionId);
});
test('second click works correctly when projectSections is updated', () => {
const { metaByProject } = computeProjectMeta(updatedSections);
const projectMap = metaByProject.get('project-1')!;
const currentSessionId = 'wt-session-1';
// After data arrives, Path A succeeds — no guard needed
const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId));
expect(pathAHit).toBe(true);
});
test('project switch: guard does NOT fire when currentSessionId matches new project', () => {
// Simulates: user clicks a session in project-2 (normal click, not worktree)
const { metaByProject } = computeProjectMeta(project2Sections);
const projectMap = metaByProject.get('project-2')!;
const currentSessionId = 'project-2-session-1';
// Path A succeeds — the session is in the new project's projectMap
const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId));
expect(pathAHit).toBe(true);
// Guard condition only fires when Path A fails — should not fire here
const guardWouldFire = Boolean(currentSessionId && !(projectMap?.has(currentSessionId)));
expect(guardWouldFire).toBe(false);
});
test('guard does NOT fire when currentSessionId is null (deleted/archived session)', () => {
const { metaByProject } = computeProjectMeta(staleSections);
const projectMap = metaByProject.get('project-1')!;
const currentSessionId = null;
// Path A: currentSessionId is null → skipped
const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId));
expect(pathAHit).toBe(false);
// Guard: currentSessionId is null → skipped, falls through to Path B/C
const guardWouldFire = currentSessionId !== null && !pathAHit;
expect(guardWouldFire).toBe(false);
});
test('guard does NOT fire for empty projects — falls through to Path B (open draft)', () => {
// Empty project: no groups/sessions in projectSections
const emptySections: ProjectSection[] = [
{
project: { id: 'empty-project', normalizedPath: '/workspace/empty' },
groups: [],
},
];
const { metaByProject } = computeProjectMeta(emptySections);
const projectMap = metaByProject.get('empty-project');
const currentSessionId = 'some-session-id';
// projectMap is undefined for empty project
expect(projectMap).toBe(undefined);
// Guard: projectMap is undefined → skipped, falls through to Path B
// which opens a new session draft for the empty project
const guardWouldFire = Boolean(currentSessionId && projectMap);
expect(guardWouldFire).toBe(false);
});
});