Files
openchamber/packages/ui/src/sync/session-worktree-contract.test.js
T

661 lines
20 KiB
JavaScript

import { describe, expect, test } from 'bun:test';
import {
buildAttachmentFromCanonicalization,
getAttachedSessionDirectory,
resolveSessionWorktreeState,
formatSessionWorktreeBadge,
getSessionWorktreeRepairActions,
getMutationBlockingReasons,
isWithinWorktreeRoot,
buildSessionTargetOptions,
} from './session-worktree-contract';
describe('isWithinWorktreeRoot', () => {
test('returns true when candidate equals root', () => {
expect(isWithinWorktreeRoot('/repo/worktrees/feat-a', '/repo/worktrees/feat-a')).toBe(true);
});
test('returns true when candidate is a subdirectory of root', () => {
expect(isWithinWorktreeRoot('/repo/worktrees/feat-a/src', '/repo/worktrees/feat-a')).toBe(true);
});
test('returns false when candidate is outside root', () => {
expect(isWithinWorktreeRoot('/tmp/outside', '/repo/worktrees/feat-a')).toBe(false);
});
test('returns false when either is null/empty', () => {
expect(isWithinWorktreeRoot(null, '/repo')).toBe(false);
expect(isWithinWorktreeRoot('/repo', null)).toBe(false);
expect(isWithinWorktreeRoot('', '/repo')).toBe(false);
});
});
describe('getAttachedSessionDirectory', () => {
test('prefers canonical cwd when attachment is healthy', () => {
expect(getAttachedSessionDirectory({
worktreeRoot: '/repo/worktrees/feat-a',
cwd: '/repo/worktrees/feat-a/src',
branch: 'feat-a',
headState: 'branch',
worktreeStatus: 'ready',
worktreeSource: 'existing',
legacy: false,
degraded: false,
}, '/repo')).toBe('/repo/worktrees/feat-a/src');
});
test('falls back to worktree root when attachment is degraded', () => {
expect(getAttachedSessionDirectory({
worktreeRoot: '/repo/worktrees/feat-a',
cwd: '/tmp/outside',
branch: 'feat-a',
headState: 'branch',
worktreeStatus: 'invalid',
worktreeSource: 'existing',
legacy: false,
degraded: true,
}, '/repo')).toBe('/repo/worktrees/feat-a');
});
test('uses fallback when no attachment exists', () => {
expect(getAttachedSessionDirectory(null, '/repo')).toBe('/repo');
});
});
describe('buildAttachmentFromCanonicalization', () => {
test('builds a canonical attachment for a healthy current-worktree session', () => {
const result = buildAttachmentFromCanonicalization({
worktreeRoot: '/repo',
cwd: '/repo/src',
branch: 'main',
headState: 'branch',
worktreeStatus: 'ready',
legacy: false,
degraded: false,
}, {
fallbackDirectory: '/repo/src',
});
expect(result.worktreeRoot).toBe('/repo');
expect(result.cwd).toBe('/repo/src');
expect(result.branch).toBe('main');
expect(result.legacy).toBe(false);
});
test('preserves worktreeSource while recovering a legacy session', () => {
const result = buildAttachmentFromCanonicalization({
worktreeRoot: '/repo/worktrees/feat-a',
cwd: '/repo/worktrees/feat-a',
branch: 'feat-a',
headState: 'branch',
worktreeStatus: 'ready',
legacy: false,
degraded: false,
}, {
existingAttachment: {
worktreeRoot: null,
cwd: '/repo/worktrees/feat-a',
branch: null,
headState: 'detached',
worktreeStatus: 'invalid',
worktreeSource: 'created-for-session',
legacy: true,
degraded: true,
},
fallbackDirectory: '/repo/worktrees/feat-a',
});
expect(result.worktreeSource).toBe('created-for-session');
expect(result.legacy).toBe(false);
});
test('uses worktree root as cwd when canonicalization is degraded', () => {
const result = buildAttachmentFromCanonicalization({
worktreeRoot: '/repo/worktrees/feat-a',
cwd: '/tmp/outside',
branch: 'feat-a',
headState: 'branch',
worktreeStatus: 'invalid',
legacy: true,
degraded: true,
}, {
fallbackDirectory: '/repo/worktrees/feat-a',
});
expect(result.cwd).toBe('/repo/worktrees/feat-a');
expect(result.degraded).toBe(true);
});
});
describe('resolveSessionWorktreeState', () => {
test('keeps cwd when inside worktreeRoot', () => {
const result = resolveSessionWorktreeState({
sessionDirectory: '/repo/worktrees/feat-a/src',
metadata: {
path: '/repo/worktrees/feat-a',
projectDirectory: '/repo',
branch: 'feat-a',
label: 'feat-a',
worktreeRoot: '/repo/worktrees/feat-a',
worktreeStatus: 'ready',
headState: 'branch',
},
cwdExists: true,
});
expect(result.cwd).toBe('/repo/worktrees/feat-a/src');
expect(result.worktreeRoot).toBe('/repo/worktrees/feat-a');
expect(result.degraded).toBe(false);
expect(result.worktreeStatus).toBe('ready');
expect(result.headState).toBe('branch');
});
test('falls back to worktreeRoot when cwd is invalid', () => {
const result = resolveSessionWorktreeState({
sessionDirectory: '/tmp/outside',
metadata: {
path: '/repo/worktrees/feat-a',
projectDirectory: '/repo',
branch: 'feat-a',
label: 'feat-a',
worktreeRoot: '/repo/worktrees/feat-a',
worktreeStatus: 'ready',
headState: 'branch',
},
cwdExists: false,
});
expect(result.cwd).toBe('/repo/worktrees/feat-a');
expect(result.degraded).toBe(true);
});
test('falls back to worktreeRoot when cwd escapes root', () => {
const result = resolveSessionWorktreeState({
sessionDirectory: '/repo/worktrees/feat-a/src',
metadata: {
path: '/repo/worktrees/feat-a',
projectDirectory: '/repo',
branch: 'feat-a',
label: 'feat-a',
worktreeRoot: '/repo/worktrees/feat-a',
worktreeStatus: 'ready',
headState: 'branch',
},
cwdExists: true,
});
// cwd is inside worktreeRoot so should be kept
expect(result.cwd).toBe('/repo/worktrees/feat-a/src');
expect(result.degraded).toBe(false);
});
test('marks missing metadata as legacy with invalid status', () => {
const result = resolveSessionWorktreeState({
sessionDirectory: '/repo',
metadata: null,
cwdExists: true,
});
expect(result.legacy).toBe(true);
expect(result.worktreeStatus).toBe('invalid');
expect(result.degraded).toBe(true);
});
test('preserves unborn head state', () => {
const result = resolveSessionWorktreeState({
sessionDirectory: '/repo/worktrees/new-branch',
metadata: {
path: '/repo/worktrees/new-branch',
projectDirectory: '/repo',
branch: '',
label: 'new-branch',
worktreeRoot: '/repo/worktrees/new-branch',
worktreeStatus: 'ready',
headState: 'unborn',
},
cwdExists: true,
});
expect(result.headState).toBe('unborn');
});
test('recovers legacy session when runtime canonicalization resolves a worktree', () => {
const result = resolveSessionWorktreeState({
sessionDirectory: '/repo/worktrees/feat-a/src',
metadata: null,
cwdExists: true,
runtimeResolution: {
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(result.legacy).toBe(false);
expect(result.worktreeRoot).toBe('/repo/worktrees/feat-a');
expect(result.worktreeSource).toBe('existing');
expect(result.degraded).toBe(false);
});
test('defaults detached when branch is empty but headState not specified', () => {
const result = resolveSessionWorktreeState({
sessionDirectory: '/repo/worktrees/detached',
metadata: {
path: '/repo/worktrees/detached',
projectDirectory: '/repo',
branch: '',
label: 'detached',
},
cwdExists: true,
});
expect(result.headState).toBe('detached');
});
test('canonical producer metadata preserves branch/detached/unborn states', () => {
// branch state
const branchResult = resolveSessionWorktreeState({
sessionDirectory: '/repo/worktrees/feat-a',
metadata: {
path: '/repo/worktrees/feat-a',
projectDirectory: '/repo',
branch: 'feat-a',
label: 'feat-a',
worktreeRoot: '/repo/worktrees/feat-a',
worktreeStatus: 'ready',
headState: 'branch',
worktreeSource: 'created-for-session',
},
cwdExists: true,
});
expect(branchResult.headState).toBe('branch');
expect(branchResult.worktreeStatus).toBe('ready');
// detached state
const detachedResult = resolveSessionWorktreeState({
sessionDirectory: '/repo/worktrees/detached',
metadata: {
path: '/repo/worktrees/detached',
projectDirectory: '/repo',
branch: '',
label: 'detached',
worktreeRoot: '/repo/worktrees/detached',
worktreeStatus: 'ready',
headState: 'detached',
worktreeSource: 'existing',
},
cwdExists: true,
});
expect(detachedResult.headState).toBe('detached');
// unborn state
const unbornResult = resolveSessionWorktreeState({
sessionDirectory: '/repo/worktrees/unborn',
metadata: {
path: '/repo/worktrees/unborn',
projectDirectory: '/repo',
branch: '',
label: 'unborn',
worktreeRoot: '/repo/worktrees/unborn',
worktreeStatus: 'ready',
headState: 'unborn',
worktreeSource: 'created-for-session',
},
cwdExists: true,
});
expect(unbornResult.headState).toBe('unborn');
});
});
describe('formatSessionWorktreeBadge', () => {
test('formats needs-attention badge for invalid worktree', () => {
const badge = formatSessionWorktreeBadge({
worktreeStatus: 'invalid',
degraded: true,
legacy: false,
branch: null,
headState: 'detached',
worktreeRoot: null,
cwd: null,
worktreeSource: null,
});
expect(badge).toBe('Needs attention');
});
test('formats legacy session badge', () => {
const badge = formatSessionWorktreeBadge({
legacy: true,
worktreeStatus: 'invalid',
degraded: true,
branch: null,
headState: 'branch',
worktreeRoot: null,
cwd: null,
worktreeSource: null,
});
expect(badge).toBe('Legacy session');
});
test('formats detached HEAD', () => {
const badge = formatSessionWorktreeBadge({
headState: 'detached',
degraded: false,
legacy: false,
branch: null,
worktreeStatus: 'ready',
worktreeRoot: '/repo',
cwd: '/repo',
worktreeSource: 'existing',
});
expect(badge).toBe('Detached HEAD');
});
test('formats unborn branch', () => {
const badge = formatSessionWorktreeBadge({
headState: 'unborn',
degraded: false,
legacy: false,
branch: null,
worktreeStatus: 'ready',
worktreeRoot: '/repo',
cwd: '/repo',
worktreeSource: 'existing',
});
expect(badge).toBe('Unborn branch');
});
test('formats current branch name', () => {
const badge = formatSessionWorktreeBadge({
branch: 'feature/my-branch',
headState: 'branch',
degraded: false,
legacy: false,
worktreeStatus: 'ready',
worktreeRoot: '/repo',
cwd: '/repo',
worktreeSource: 'existing',
});
expect(badge).toBe('Current branch: feature/my-branch');
});
test('formats missing worktree', () => {
const badge = formatSessionWorktreeBadge({
worktreeStatus: 'missing',
degraded: true,
legacy: false,
branch: null,
headState: 'branch',
worktreeRoot: null,
cwd: null,
worktreeSource: null,
});
expect(badge).toBe('Worktree missing');
});
test('formats needs-attention for in-progress git operation', () => {
const badge = formatSessionWorktreeBadge({
worktreeStatus: 'ready',
attentionReason: 'merge',
degraded: false,
legacy: false,
branch: 'main',
headState: 'branch',
worktreeRoot: '/repo',
cwd: '/repo',
worktreeSource: 'existing',
});
expect(badge).toBe('Needs attention');
});
});
describe('getSessionWorktreeRepairActions', () => {
test('returns open-without-worktree-features for missing worktree', () => {
const actions = getSessionWorktreeRepairActions({
worktreeStatus: 'missing',
degraded: true,
legacy: false,
branch: null,
headState: 'branch',
worktreeRoot: null,
cwd: null,
worktreeSource: null,
});
expect(actions).toContain('open-without-worktree-features');
});
test('returns open-without-worktree-features for invalid worktree', () => {
const actions = getSessionWorktreeRepairActions({
worktreeStatus: 'invalid',
degraded: true,
legacy: false,
branch: null,
headState: 'branch',
worktreeRoot: null,
cwd: null,
worktreeSource: null,
});
expect(actions).toContain('open-without-worktree-features');
});
test('returns empty for ready worktree', () => {
const actions = getSessionWorktreeRepairActions({
worktreeStatus: 'ready',
degraded: false,
legacy: false,
branch: 'main',
headState: 'branch',
worktreeRoot: '/repo',
cwd: '/repo',
worktreeSource: 'existing',
});
expect(actions).toHaveLength(0);
});
});
describe('buildSessionTargetOptions', () => {
test('labels root directory and isolated worktrees distinctly', () => {
const options = buildSessionTargetOptions({
projectRoot: '/repo',
rootBranch: 'main',
worktrees: [
{ path: '/repo/.worktrees/feat-a', branch: 'feat-a', label: 'feat-a', projectDirectory: '/repo' },
],
});
expect(options[0]?.label).toContain('main');
expect(options[1]?.label).toContain('feat-a');
expect(options[0]?.kind).toBe('root');
expect(options[1]?.kind).toBe('worktree');
});
test('excludes worktree path that equals projectRoot', () => {
const options = buildSessionTargetOptions({
projectRoot: '/repo',
rootBranch: 'main',
worktrees: [
{ path: '/repo', branch: 'main', label: 'main', projectDirectory: '/repo' },
{ path: '/repo/worktrees/feat-a', branch: 'feat-a', label: 'feat-a', projectDirectory: '/repo' },
],
});
expect(options).toHaveLength(2); // root + one worktree, not three
});
test('handles empty worktrees array', () => {
const options = buildSessionTargetOptions({
projectRoot: '/repo',
rootBranch: 'main',
worktrees: [],
});
expect(options).toHaveLength(1);
expect(options[0]?.kind).toBe('root');
});
test('marks pending bootstrap worktree distinctly', () => {
const options = buildSessionTargetOptions({
projectRoot: '/repo',
rootBranch: 'main',
worktrees: [
{ path: '/repo/worktrees/feat-a', branch: 'feat-a', label: 'feat-a', projectDirectory: '/repo' },
{ path: '/repo/worktrees/feat-b', branch: 'feat-b', label: 'feat-b', projectDirectory: '/repo' },
],
pendingBootstrapDirectory: '/repo/worktrees/feat-b',
});
const root = options.find((o) => o.kind === 'root');
const pending = options.find((o) => o.value === '/repo/worktrees/feat-b');
const nonPending = options.find((o) => o.value === '/repo/worktrees/feat-a');
expect(root?.pending).toBeUndefined();
expect(pending?.pending).toBe(true);
expect(nonPending?.pending).toBeUndefined();
});
});
describe('getMutationBlockingReasons', () => {
test('returns empty when attachment is null', () => {
expect(getMutationBlockingReasons(null)).toHaveLength(0);
expect(getMutationBlockingReasons(undefined)).toHaveLength(0);
});
test('blocks mutation when worktree is missing', () => {
const reasons = getMutationBlockingReasons({
worktreeRoot: null,
cwd: null,
branch: null,
headState: 'branch',
worktreeStatus: 'missing',
worktreeSource: null,
legacy: false,
degraded: true,
});
expect(reasons).toHaveLength(1);
expect(reasons[0]).toEqual({ reason: 'missing' });
});
test('blocks mutation when worktree is invalid', () => {
const reasons = getMutationBlockingReasons({
worktreeRoot: null,
cwd: null,
branch: null,
headState: 'branch',
worktreeStatus: 'invalid',
worktreeSource: null,
legacy: false,
degraded: true,
});
expect(reasons).toHaveLength(1);
expect(reasons[0]).toEqual({ reason: 'invalid' });
});
test('blocks mutation during merge attention state', () => {
const reasons = getMutationBlockingReasons({
worktreeRoot: '/repo/worktrees/feat-a',
cwd: '/repo/worktrees/feat-a',
branch: 'feat-a',
headState: 'branch',
worktreeStatus: 'ready',
worktreeSource: 'existing',
legacy: false,
degraded: false,
attentionReason: 'merge',
});
expect(reasons).toHaveLength(1);
expect(reasons[0]).toEqual({ reason: 'attention', attentionReason: 'merge' });
});
test('blocks mutation during rebase attention state', () => {
const reasons = getMutationBlockingReasons({
worktreeRoot: '/repo/worktrees/feat-a',
cwd: '/repo/worktrees/feat-a',
branch: 'feat-a',
headState: 'branch',
worktreeStatus: 'ready',
worktreeSource: 'existing',
legacy: false,
degraded: false,
attentionReason: 'rebase',
});
expect(reasons).toHaveLength(1);
expect(reasons[0]).toEqual({ reason: 'attention', attentionReason: 'rebase' });
});
test('returns empty for ready worktree with no attention', () => {
const reasons = getMutationBlockingReasons({
worktreeRoot: '/repo/worktrees/feat-a',
cwd: '/repo/worktrees/feat-a',
branch: 'feat-a',
headState: 'branch',
worktreeStatus: 'ready',
worktreeSource: 'existing',
legacy: false,
degraded: false,
});
expect(reasons).toHaveLength(0);
});
test('blocks mutation during cherry-pick attention state', () => {
const reasons = getMutationBlockingReasons({
worktreeRoot: '/repo/worktrees/feat-a',
cwd: '/repo/worktrees/feat-a',
branch: 'feat-a',
headState: 'branch',
worktreeStatus: 'ready',
worktreeSource: 'existing',
legacy: false,
degraded: false,
attentionReason: 'cherry-pick',
});
expect(reasons).toHaveLength(1);
expect(reasons[0]).toEqual({ reason: 'attention', attentionReason: 'cherry-pick' });
});
test('blocks mutation when git status is dirty', () => {
const reasons = getMutationBlockingReasons(
{ worktreeRoot: '/repo', cwd: '/repo', branch: 'main', headState: 'branch', worktreeStatus: 'ready', worktreeSource: 'existing', legacy: false, degraded: false },
{ isClean: false, files: [{ path: 'a.ts' }, { path: 'b.ts' }] }
);
expect(reasons).toHaveLength(1);
expect(reasons[0]).toEqual({ reason: 'dirty', dirtyFiles: 2 });
});
test('blocks mutation for dirty tree even without attachment', () => {
const reasons = getMutationBlockingReasons(null, { isClean: false, files: [{ path: 'a.ts' }] });
expect(reasons).toHaveLength(1);
expect(reasons[0]).toEqual({ reason: 'dirty', dirtyFiles: 1 });
});
test('does not block when git status is clean', () => {
const reasons = getMutationBlockingReasons(
{ worktreeRoot: '/repo', cwd: '/repo', branch: 'main', headState: 'branch', worktreeStatus: 'ready', worktreeSource: 'existing', legacy: false, degraded: false },
{ isClean: true, files: [] }
);
expect(reasons).toHaveLength(0);
});
test('returns dirty and missing reasons together', () => {
const reasons = getMutationBlockingReasons(
{ worktreeRoot: '/repo', cwd: '/repo', branch: 'main', headState: 'branch', worktreeStatus: 'missing', worktreeSource: 'existing', legacy: false, degraded: false },
{ isClean: false, files: [{ path: 'a.ts' }] }
);
expect(reasons).toHaveLength(2);
expect(reasons[0]).toEqual({ reason: 'dirty', dirtyFiles: 1 });
expect(reasons[1]).toEqual({ reason: 'missing' });
});
test('returns dirty without file count when files is undefined', () => {
const reasons = getMutationBlockingReasons(
null,
{ isClean: false }
);
expect(reasons).toHaveLength(1);
expect(reasons[0]).toEqual({ reason: 'dirty' });
});
});