Files
openchamber/packages/ui/src/sync/session-ui-store.test.js
T
Bohdan Triapitsyn a9dfd32347 fix: avoid stale project binding for new sessions
Keeps implicit new sessions tied to the current directory
Prevents unmatched directories from inheriting the active project
Adds regression coverage for draft project selection
2026-06-24 10:56:54 +03:00

385 lines
14 KiB
JavaScript

import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { opencodeClient } from '@/lib/opencode/client';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSessionWorktreeStore } from './session-worktree-store';
import { routeMessage, useSessionUIStore } from './session-ui-store';
import { setActionRefs, setOptimisticRefs } from './session-actions';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { useCommandsStore } from '@/stores/useCommandsStore';
import { useConfigStore } from '@/stores/useConfigStore';
/**
* Unit tests for session worktree routing through the authoritative store.
*
* These tests verify that session-worktree-store is properly integrated as the
* authoritative holder of session↔worktree attachments, and that session-ui-store
* routes through it for switching and creation flows.
*
* Note: Full integration tests for setCurrentSession require runtime mocking.
* These tests focus on the contract layer: that setAttachment/getAttachment work
* correctly and that the contract helpers produce correct results.
*/
describe('session-worktree-store worktree routing', () => {
beforeEach(() => {
// Clear all attachments before each test
const store = useSessionWorktreeStore.getState();
const attachments = store.attachments;
for (const sessionId of attachments.keys()) {
store.clearAttachment(sessionId);
}
useSessionUIStore.setState({ currentSessionId: null, worktreeMetadata: new Map() });
});
test('getDirectoryForSession prefers authoritative attachment cwd over sync fallback', () => {
useSessionWorktreeStore.getState().setAttachment('session-dir', {
worktreeRoot: '/repo/worktrees/feat-a',
cwd: '/repo/worktrees/feat-a/src',
branch: 'feat-a',
headState: 'branch',
worktreeStatus: 'ready',
worktreeSource: 'existing',
legacy: false,
degraded: false,
});
expect(useSessionUIStore.getState().getDirectoryForSession('session-dir')).toBe('/repo/worktrees/feat-a/src');
});
test('getDirectoryForSession falls back to authoritative worktreeRoot when attachment is degraded', () => {
useSessionWorktreeStore.getState().setAttachment('session-dir', {
worktreeRoot: '/repo/worktrees/feat-a',
cwd: '/tmp/outside',
branch: 'feat-a',
headState: 'branch',
worktreeStatus: 'invalid',
worktreeSource: 'existing',
legacy: false,
degraded: true,
});
expect(useSessionUIStore.getState().getDirectoryForSession('session-dir')).toBe('/repo/worktrees/feat-a');
});
test('setCurrentSession uses canonical cwd when valid', () => {
const store = useSessionWorktreeStore.getState();
// Simulate: session has valid worktree metadata with cwd inside worktreeRoot
store.setAttachment('session-1', {
worktreeRoot: '/repo/worktrees/feat-a',
cwd: '/repo/worktrees/feat-a/src',
branch: 'feat-a',
headState: 'branch',
worktreeStatus: 'ready',
worktreeSource: 'existing',
legacy: false,
degraded: false,
});
const attachment = store.getAttachment('session-1');
expect(attachment).toBeDefined();
expect(attachment.cwd).toBe('/repo/worktrees/feat-a/src');
expect(attachment.worktreeRoot).toBe('/repo/worktrees/feat-a');
expect(attachment.degraded).toBe(false);
expect(attachment.worktreeStatus).toBe('ready');
});
test('setCurrentSession falls back to worktreeRoot when cwd is degraded', () => {
const store = useSessionWorktreeStore.getState();
// Simulate: cwd is outside worktreeRoot (degraded)
store.setAttachment('session-2', {
worktreeRoot: '/repo/worktrees/feat-a',
cwd: '/repo/worktrees/feat-a', // same as worktreeRoot means not degraded for this case
branch: 'feat-a',
headState: 'branch',
worktreeStatus: 'ready',
worktreeSource: 'existing',
legacy: false,
degraded: true, // marked degraded because cwd was resolved from invalid state
});
const attachment = store.getAttachment('session-2');
expect(attachment).toBeDefined();
expect(attachment.degraded).toBe(true);
// cwd should equal worktreeRoot when degraded (fallback)
expect(attachment.cwd).toBe(attachment.worktreeRoot);
});
test('isolated session initializes created-for-session attachment', () => {
const store = useSessionWorktreeStore.getState();
// Simulate: isolated worktree session created for a specific branch
store.setAttachment('session-isolated', {
worktreeRoot: '/repo/worktrees/feature-xyz',
cwd: '/repo/worktrees/feature-xyz',
branch: 'feature-xyz',
headState: 'branch',
worktreeStatus: 'ready',
worktreeSource: 'created-for-session',
legacy: false,
degraded: false,
});
const attachment = store.getAttachment('session-isolated');
expect(attachment).toBeDefined();
expect(attachment.worktreeSource).toBe('created-for-session');
expect(attachment.worktreeStatus).toBe('ready');
expect(attachment.legacy).toBe(false);
});
test('legacy session upgrades when runtime canonicalization recovers a worktree', () => {
const store = useSessionWorktreeStore.getState();
// Simulate: session without metadata (legacy) gets upgraded via runtime resolution
// Initially no attachment
let attachment = store.getAttachment('session-legacy');
expect(attachment).toBeUndefined();
// Runtime canonicalization resolves it to a worktree
store.setAttachment('session-legacy', {
worktreeRoot: '/repo/worktrees/recovered',
cwd: '/repo/worktrees/recovered',
branch: 'recovered',
headState: 'branch',
worktreeStatus: 'ready',
worktreeSource: 'existing',
legacy: false, // upgraded from legacy=true to false
degraded: false,
});
attachment = store.getAttachment('session-legacy');
expect(attachment).toBeDefined();
expect(attachment.legacy).toBe(false);
expect(attachment.worktreeRoot).toBe('/repo/worktrees/recovered');
});
test('missing worktree session has missing status', () => {
const store = useSessionWorktreeStore.getState();
// Simulate: session whose worktree was deleted
store.setAttachment('session-missing', {
worktreeRoot: null,
cwd: null,
branch: null,
headState: 'branch',
worktreeStatus: 'missing',
worktreeSource: null,
legacy: false,
degraded: true,
});
const attachment = store.getAttachment('session-missing');
expect(attachment).toBeDefined();
expect(attachment.worktreeStatus).toBe('missing');
expect(attachment.degraded).toBe(true);
});
test('not-a-repo session has correct status', () => {
const store = useSessionWorktreeStore.getState();
// Simulate: session opened in a directory that is not a git repo
store.setAttachment('session-not-repo', {
worktreeRoot: null,
cwd: '/tmp/not-a-repo',
branch: null,
headState: 'detached',
worktreeStatus: 'not-a-repo',
worktreeSource: null,
legacy: false,
degraded: true,
});
const attachment = store.getAttachment('session-not-repo');
expect(attachment).toBeDefined();
expect(attachment.worktreeStatus).toBe('not-a-repo');
});
});
describe('routeMessage directory scoping', () => {
test('runs sends in the provided session directory', async () => {
// The session directory travels as an explicit request param (not via
// client-wide directory scoping), so concurrent sends can't cross-talk.
const calls = [];
const originalShellSession = opencodeClient.shellSession;
opencodeClient.shellSession = async (params) => {
calls.push(params);
return { info: {}, parts: [] };
};
try {
await routeMessage({
sessionId: 'session-a',
directory: '/session/project',
content: 'pwd',
providerID: 'provider-a',
modelID: 'model-a',
inputMode: 'shell',
});
} finally {
opencodeClient.shellSession = originalShellSession;
}
expect(calls).toHaveLength(1);
expect(calls[0].sessionId).toBe('session-a');
expect(calls[0].directory).toBe('/session/project');
});
});
describe('openNewSessionDraft project binding', () => {
const projectA = { id: 'proj-a', path: '/projects/alpha', label: 'Alpha' };
const projectB = { id: 'proj-b', path: '/projects/beta', label: 'Beta' };
beforeEach(() => {
useSessionUIStore.setState({
currentSessionId: null,
currentSessionDirectory: null,
newSessionDraft: { open: false, directoryOverride: null, parentID: null },
availableWorktreesByProject: new Map(),
});
useProjectsStore.setState({
projects: [projectA, projectB],
activeProjectId: projectA.id,
});
useDirectoryStore.getState().setDirectory(projectB.path, { showOverlay: false });
});
test('keeps implicit draft on current directory when active project differs', () => {
useSessionUIStore.getState().openNewSessionDraft();
const draft = useSessionUIStore.getState().newSessionDraft;
expect(draft.open).toBe(true);
expect(draft.selectedProjectId).toBe(projectB.id);
expect(draft.directoryOverride).toBe(projectB.path);
});
test('does not attach active project when current directory is unmatched', () => {
useDirectoryStore.getState().setDirectory('/external/worktree', { showOverlay: false });
useSessionUIStore.getState().openNewSessionDraft();
const draft = useSessionUIStore.getState().newSessionDraft;
expect(draft.open).toBe(true);
expect(draft.selectedProjectId).toBeNull();
expect(draft.directoryOverride).toBe('/external/worktree');
});
test('respects explicit directoryOverride over active project', () => {
useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/projects/beta/src' });
const draft = useSessionUIStore.getState().newSessionDraft;
expect(draft.open).toBe(true);
expect(draft.directoryOverride).toBe('/projects/beta/src');
});
test('respects explicit selectedProjectId over active project', () => {
useSessionUIStore.getState().openNewSessionDraft({ selectedProjectId: projectB.id });
const draft = useSessionUIStore.getState().newSessionDraft;
expect(draft.open).toBe(true);
expect(draft.selectedProjectId).toBe(projectB.id);
});
});
describe('routeMessage skill invocation', () => {
// OpenCode registers every skill as a command (source: "skill"), so a skill
// selected from the slash menu must be dispatched via session.command so its
// content is injected — not sent as a plain "/name" text message (issue #1605).
const sendCommandCalls = [];
const sendMessageCalls = [];
let originalSendCommand;
let originalSendMessage;
beforeEach(() => {
sendCommandCalls.length = 0;
sendMessageCalls.length = 0;
// Minimal optimistic + connection machinery so routeMessage can dispatch.
const childStore = {
getState: () => ({ session_status: {} }),
setState: () => {},
};
const childStores = {
children: new Map(),
ensureChild: () => childStore,
getChild: () => childStore,
};
setActionRefs(opencodeClient, childStores, () => '/skills/project');
setOptimisticRefs(() => {}, () => {});
useConfigStore.setState({ isConnected: true });
// The sync command list and the commands store both exclude user skills,
// so they start empty here — the skill is only known to the skills store.
useCommandsStore.setState({ commands: [] });
useSkillsStore.setState({ skills: [] });
originalSendCommand = opencodeClient.sendCommand;
originalSendMessage = opencodeClient.sendMessage;
opencodeClient.sendCommand = async (params) => {
sendCommandCalls.push(params);
return 'msg';
};
opencodeClient.sendMessage = async (params) => {
sendMessageCalls.push(params);
return 'msg';
};
});
afterEach(() => {
opencodeClient.sendCommand = originalSendCommand;
opencodeClient.sendMessage = originalSendMessage;
useSkillsStore.setState({ skills: [] });
});
test('invokes a user-installed skill as a command', async () => {
useSkillsStore.setState({
skills: [{ name: 'grill-with-docs', path: '/skills/grill-with-docs/SKILL.md', scope: 'user', source: 'opencode' }],
});
await routeMessage({
sessionId: 'session-skill',
directory: '/skills/project',
content: '/grill-with-docs',
providerID: 'provider-a',
modelID: 'model-a',
});
expect(sendCommandCalls).toHaveLength(1);
expect(sendCommandCalls[0].command).toBe('grill-with-docs');
expect(sendMessageCalls).toHaveLength(0);
});
test('forwards trailing arguments to the skill command', async () => {
useSkillsStore.setState({
skills: [{ name: 'grill-with-docs', path: '/skills/grill-with-docs/SKILL.md', scope: 'user', source: 'opencode' }],
});
await routeMessage({
sessionId: 'session-skill',
directory: '/skills/project',
content: '/grill-with-docs focus on auth',
providerID: 'provider-a',
modelID: 'model-a',
});
expect(sendCommandCalls).toHaveLength(1);
expect(sendCommandCalls[0].command).toBe('grill-with-docs');
expect(sendCommandCalls[0].arguments).toBe('focus on auth');
});
test('sends an unknown slash token as a plain message', async () => {
await routeMessage({
sessionId: 'session-skill',
directory: '/skills/project',
content: '/not-a-real-skill',
providerID: 'provider-a',
modelID: 'model-a',
});
expect(sendMessageCalls).toHaveLength(1);
expect(sendCommandCalls).toHaveLength(0);
});
});