2026-04-17 01:13:59 +08:00
|
|
|
import { beforeEach, describe, expect, test } from 'bun:test';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { opencodeClient } from '@/lib/opencode/client';
|
2026-04-17 01:13:59 +08:00
|
|
|
import { useSessionWorktreeStore } from './session-worktree-store';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { routeMessage, useSessionUIStore } from './session-ui-store';
|
2026-04-17 01:13:59 +08:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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');
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-06-02 00:43:05 +03:00
|
|
|
|
|
|
|
|
describe('routeMessage directory scoping', () => {
|
|
|
|
|
test('runs sends in the provided session directory', async () => {
|
2026-06-10 13:50:24 +03:00
|
|
|
// The session directory travels as an explicit request param (not via
|
|
|
|
|
// client-wide directory scoping), so concurrent sends can't cross-talk.
|
2026-06-02 00:43:05 +03:00
|
|
|
const calls = [];
|
|
|
|
|
const originalShellSession = opencodeClient.shellSession;
|
|
|
|
|
|
|
|
|
|
opencodeClient.shellSession = async (params) => {
|
2026-06-10 13:50:24 +03:00
|
|
|
calls.push(params);
|
2026-06-02 00:43:05 +03:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 13:50:24 +03:00
|
|
|
expect(calls).toHaveLength(1);
|
|
|
|
|
expect(calls[0].sessionId).toBe('session-a');
|
|
|
|
|
expect(calls[0].directory).toBe('/session/project');
|
2026-06-02 00:43:05 +03:00
|
|
|
});
|
|
|
|
|
});
|