486 lines
17 KiB
TypeScript
486 lines
17 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import {
|
|
abortMerge,
|
|
abortRebase,
|
|
applyGitStash,
|
|
checkoutBranch,
|
|
checkoutCommit,
|
|
cherryPick,
|
|
continueMerge,
|
|
continueRebase,
|
|
createBranch,
|
|
deleteGitBranch,
|
|
deleteRemoteBranch,
|
|
dropGitStash,
|
|
getGitBranches,
|
|
getGitStatus,
|
|
gitFetch,
|
|
merge,
|
|
popGitStash,
|
|
rebase,
|
|
removeRemote,
|
|
renameBranch,
|
|
resetToCommit,
|
|
revertCommit,
|
|
stageGitFile,
|
|
stageGitFiles,
|
|
stashGitChanges,
|
|
unstageGitFile,
|
|
unstageGitFiles,
|
|
} from './gitApiHttp';
|
|
import type { GitStatus } from './api/types';
|
|
import { sessionEvents } from './sessionEvents';
|
|
|
|
type FetchCall = {
|
|
input: RequestInfo | URL;
|
|
init?: RequestInit;
|
|
};
|
|
|
|
const previousFetch = globalThis.fetch;
|
|
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
|
|
|
const installFetchMock = () => {
|
|
const calls: FetchCall[] = [];
|
|
globalThis.fetch = (async (input, init) => {
|
|
calls.push({ input, init });
|
|
return new Response(JSON.stringify({ success: true }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}) as typeof fetch;
|
|
return calls;
|
|
};
|
|
|
|
const installWindowMock = () => {
|
|
Object.defineProperty(globalThis, 'window', {
|
|
configurable: true,
|
|
value: {
|
|
location: { origin: 'http://localhost:3000' },
|
|
},
|
|
});
|
|
};
|
|
|
|
const restoreMocks = () => {
|
|
globalThis.fetch = previousFetch;
|
|
if (previousWindowDescriptor) {
|
|
Object.defineProperty(globalThis, 'window', previousWindowDescriptor);
|
|
} else {
|
|
delete (globalThis as { window?: Window }).window;
|
|
}
|
|
};
|
|
|
|
const captureError = async (callback: () => Promise<void>): Promise<unknown> => {
|
|
try {
|
|
await callback();
|
|
return null;
|
|
} catch (error) {
|
|
return error;
|
|
}
|
|
};
|
|
|
|
describe('gitApiHttp index mutations', () => {
|
|
test('sends bulk stage payloads as paths', async () => {
|
|
installWindowMock();
|
|
const calls = installFetchMock();
|
|
try {
|
|
await stageGitFiles('/repo', ['a.ts', 'b.ts']);
|
|
|
|
expect(calls).toHaveLength(1);
|
|
expect(String(calls[0].input)).toBe('/api/git/stage?directory=%2Frepo');
|
|
expect(calls[0].init?.method).toBe('POST');
|
|
expect(JSON.parse(String(calls[0].init?.body))).toEqual({ paths: ['a.ts', 'b.ts'] });
|
|
} finally {
|
|
restoreMocks();
|
|
}
|
|
});
|
|
|
|
test('sends bulk unstage payloads as paths', async () => {
|
|
installWindowMock();
|
|
const calls = installFetchMock();
|
|
try {
|
|
await unstageGitFiles('/repo', ['a.ts', 'b.ts']);
|
|
|
|
expect(calls).toHaveLength(1);
|
|
expect(String(calls[0].input)).toBe('/api/git/unstage?directory=%2Frepo');
|
|
expect(calls[0].init?.method).toBe('POST');
|
|
expect(JSON.parse(String(calls[0].init?.body))).toEqual({ paths: ['a.ts', 'b.ts'] });
|
|
} finally {
|
|
restoreMocks();
|
|
}
|
|
});
|
|
|
|
test('single-file helpers use the bulk paths payload shape', async () => {
|
|
installWindowMock();
|
|
const calls = installFetchMock();
|
|
try {
|
|
await stageGitFile('/repo', 'a.ts');
|
|
await unstageGitFile('/repo', 'b.ts');
|
|
|
|
expect(JSON.parse(String(calls[0].init?.body))).toEqual({ paths: ['a.ts'] });
|
|
expect(JSON.parse(String(calls[1].init?.body))).toEqual({ paths: ['b.ts'] });
|
|
} finally {
|
|
restoreMocks();
|
|
}
|
|
});
|
|
|
|
test('rejects empty bulk path lists before fetching', async () => {
|
|
installWindowMock();
|
|
const calls = installFetchMock();
|
|
try {
|
|
const stageError = await captureError(() => stageGitFiles('/repo', [' ', '']));
|
|
const unstageError = await captureError(() => unstageGitFiles('/repo', []));
|
|
|
|
expect(stageError).toBeInstanceOf(Error);
|
|
expect((stageError as Error).message).toBe('path is required to stage git changes');
|
|
expect(unstageError).toBeInstanceOf(Error);
|
|
expect((unstageError as Error).message).toBe('path is required to unstage git changes');
|
|
expect(calls).toHaveLength(0);
|
|
} finally {
|
|
restoreMocks();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('gitApiHttp status cache', () => {
|
|
test('a Git refresh hint invalidates the cached status before listeners fetch', async () => {
|
|
installWindowMock();
|
|
let statusRequestCount = 0;
|
|
globalThis.fetch = async () => {
|
|
statusRequestCount += 1;
|
|
return jsonResponse(statusPayload({ behind: statusRequestCount }));
|
|
};
|
|
|
|
try {
|
|
const directory = '/repo-cache-tool-mutation';
|
|
const first = await getGitStatus(directory);
|
|
sessionEvents.requestGitRefresh({ directory });
|
|
const afterMutation = await getGitStatus(directory);
|
|
|
|
expect(first.behind).toBe(1);
|
|
expect(afterMutation.behind).toBe(2);
|
|
expect(statusRequestCount).toBe(2);
|
|
} finally {
|
|
restoreMocks();
|
|
}
|
|
});
|
|
|
|
test('invalidates cached status after fetch', async () => {
|
|
installWindowMock();
|
|
const calls: FetchCall[] = [];
|
|
let statusRequestCount = 0;
|
|
globalThis.fetch = (async (input, init) => {
|
|
calls.push({ input, init });
|
|
const url = String(input);
|
|
if (url.startsWith('/api/git/status')) {
|
|
statusRequestCount += 1;
|
|
return new Response(JSON.stringify({
|
|
current: 'main',
|
|
tracking: 'origin/main',
|
|
ahead: 0,
|
|
behind: statusRequestCount === 1 ? 0 : 2,
|
|
files: [],
|
|
isClean: true,
|
|
}), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({ success: true }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}) as typeof fetch;
|
|
|
|
try {
|
|
const directory = '/repo-cache-fetch';
|
|
const first = await getGitStatus(directory);
|
|
const cached = await getGitStatus(directory);
|
|
await gitFetch(directory, { remote: 'origin' });
|
|
const afterFetch = await getGitStatus(directory);
|
|
|
|
expect(first.behind).toBe(0);
|
|
expect(cached.behind).toBe(0);
|
|
expect(afterFetch.behind).toBe(2);
|
|
expect(statusRequestCount).toBe(2);
|
|
expect(calls.map((call) => String(call.input))).toEqual([
|
|
'/api/git/status?directory=%2Frepo-cache-fetch',
|
|
'/api/git/fetch?directory=%2Frepo-cache-fetch',
|
|
'/api/git/status?directory=%2Frepo-cache-fetch',
|
|
]);
|
|
} finally {
|
|
restoreMocks();
|
|
}
|
|
});
|
|
|
|
test('fresh status bypasses an unexpired cached snapshot', async () => {
|
|
installWindowMock();
|
|
let statusRequestCount = 0;
|
|
globalThis.fetch = (async () => {
|
|
statusRequestCount += 1;
|
|
return jsonResponse(statusPayload({ behind: statusRequestCount }));
|
|
}) as typeof fetch;
|
|
|
|
try {
|
|
const directory = '/repo-cache-fresh';
|
|
const first = await getGitStatus(directory);
|
|
const cached = await getGitStatus(directory);
|
|
const fresh = await getGitStatus(directory, { fresh: true });
|
|
|
|
expect(first.behind).toBe(1);
|
|
expect(cached.behind).toBe(1);
|
|
expect(fresh.behind).toBe(2);
|
|
expect(statusRequestCount).toBe(2);
|
|
} finally {
|
|
restoreMocks();
|
|
}
|
|
});
|
|
|
|
test('fresh status cannot be replaced in cache by an older in-flight response', async () => {
|
|
installWindowMock();
|
|
const statusResolvers: Array<(response: Response) => void> = [];
|
|
// SAFETY: the mock accepts the same arguments as fetch and always returns
|
|
// a pending Response promise controlled by this test.
|
|
globalThis.fetch = (async () => new Promise<Response>((resolve) => {
|
|
statusResolvers.push(resolve);
|
|
})) as typeof fetch;
|
|
|
|
try {
|
|
const directory = '/repo-cache-fresh-race';
|
|
const older = getGitStatus(directory);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
const fresh = getGitStatus(directory, { fresh: true });
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
expect(statusResolvers).toHaveLength(2);
|
|
statusResolvers[1](jsonResponse(statusPayload({ current: 'fresh' })));
|
|
statusResolvers[0](jsonResponse(statusPayload({ current: 'stale' })));
|
|
|
|
expect((await fresh).current).toBe('fresh');
|
|
expect((await older).current).toBe('stale');
|
|
expect((await getGitStatus(directory)).current).toBe('fresh');
|
|
expect(statusResolvers).toHaveLength(2);
|
|
} finally {
|
|
restoreMocks();
|
|
}
|
|
});
|
|
});
|
|
|
|
const statusPayload = (overrides: Partial<GitStatus> = {}): GitStatus => ({
|
|
current: 'main',
|
|
tracking: null,
|
|
ahead: 0,
|
|
behind: 0,
|
|
files: [],
|
|
isClean: true,
|
|
...overrides,
|
|
});
|
|
|
|
const jsonResponse = <T>(payload: T) => new Response(JSON.stringify(payload), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
const installStatusMutationFetchMock = () => {
|
|
// SAFETY: `statusUrls` starts empty and only ever receives request URLs, which
|
|
// are strings; the annotation names that element type up front.
|
|
const mock = {
|
|
statusUrls: [] as string[],
|
|
behind: 0,
|
|
};
|
|
// SAFETY: the mock receives only the (input, init) pair production code passes
|
|
// and always resolves to a Response, so it honours the fetch contract; the
|
|
// assertion supplies the overload signatures a plain arrow function cannot.
|
|
globalThis.fetch = (async (input) => {
|
|
const url = String(input);
|
|
if (url.startsWith('/api/git/status')) {
|
|
mock.statusUrls.push(url);
|
|
return jsonResponse(statusPayload({ behind: mock.behind }));
|
|
}
|
|
return jsonResponse({ success: true });
|
|
}) as typeof fetch;
|
|
return mock;
|
|
};
|
|
|
|
/**
|
|
* Seeds the status cache, performs the mutation, and asserts the next status
|
|
* read issues a fresh request that observes the post-mutation state instead of
|
|
* serving the pre-mutation cache entry.
|
|
*/
|
|
const expectStatusInvalidatedBy = async <T>(
|
|
directory: string,
|
|
mutate: () => Promise<T>
|
|
): Promise<void> => {
|
|
const mock = installStatusMutationFetchMock();
|
|
|
|
const seeded = await getGitStatus(directory);
|
|
expect(seeded.behind).toBe(0);
|
|
|
|
mock.behind = 2;
|
|
const cached = await getGitStatus(directory);
|
|
expect(cached.behind).toBe(0);
|
|
expect(mock.statusUrls).toHaveLength(1);
|
|
|
|
await mutate();
|
|
|
|
const refreshed = await getGitStatus(directory);
|
|
expect(refreshed.behind).toBe(2);
|
|
expect(mock.statusUrls).toHaveLength(2);
|
|
};
|
|
|
|
describe('gitApiHttp post-mutation status invalidation (#2281)', () => {
|
|
test('checkout and branch mutations invalidate cached status', async () => {
|
|
installWindowMock();
|
|
try {
|
|
await expectStatusInvalidatedBy('/repo-2281-checkout', () => checkoutBranch('/repo-2281-checkout', 'feature'));
|
|
await expectStatusInvalidatedBy('/repo-2281-create-branch', () => createBranch('/repo-2281-create-branch', 'feature/new'));
|
|
await expectStatusInvalidatedBy('/repo-2281-rename-branch', () => renameBranch('/repo-2281-rename-branch', 'old', 'new'));
|
|
await expectStatusInvalidatedBy('/repo-2281-delete-branch', () => deleteGitBranch('/repo-2281-delete-branch', { branch: 'feature/old' }));
|
|
} finally {
|
|
restoreMocks();
|
|
}
|
|
});
|
|
|
|
test('stash lifecycle mutations invalidate cached status', async () => {
|
|
installWindowMock();
|
|
try {
|
|
await expectStatusInvalidatedBy('/repo-2281-stash', () => stashGitChanges('/repo-2281-stash', { message: 'WIP' }));
|
|
await expectStatusInvalidatedBy('/repo-2281-stash-apply', () => applyGitStash('/repo-2281-stash-apply', { ref: 'stash@{0}' }));
|
|
await expectStatusInvalidatedBy('/repo-2281-stash-pop', () => popGitStash('/repo-2281-stash-pop', { ref: 'stash@{0}' }));
|
|
await expectStatusInvalidatedBy('/repo-2281-stash-drop', () => dropGitStash('/repo-2281-stash-drop', { ref: 'stash@{0}' }));
|
|
} finally {
|
|
restoreMocks();
|
|
}
|
|
});
|
|
|
|
test('merge and rebase lifecycle mutations invalidate cached status', async () => {
|
|
installWindowMock();
|
|
try {
|
|
await expectStatusInvalidatedBy('/repo-2281-merge', () => merge('/repo-2281-merge', { branch: 'feature' }));
|
|
await expectStatusInvalidatedBy('/repo-2281-merge-abort', () => abortMerge('/repo-2281-merge-abort'));
|
|
await expectStatusInvalidatedBy('/repo-2281-merge-continue', () => continueMerge('/repo-2281-merge-continue'));
|
|
await expectStatusInvalidatedBy('/repo-2281-rebase', () => rebase('/repo-2281-rebase', { onto: 'main' }));
|
|
await expectStatusInvalidatedBy('/repo-2281-rebase-abort', () => abortRebase('/repo-2281-rebase-abort'));
|
|
await expectStatusInvalidatedBy('/repo-2281-rebase-continue', () => continueRebase('/repo-2281-rebase-continue'));
|
|
} finally {
|
|
restoreMocks();
|
|
}
|
|
});
|
|
|
|
test('history mutations invalidate cached status', async () => {
|
|
installWindowMock();
|
|
try {
|
|
await expectStatusInvalidatedBy('/repo-2281-checkout-commit', () => checkoutCommit('/repo-2281-checkout-commit', 'abc123'));
|
|
await expectStatusInvalidatedBy('/repo-2281-cherry-pick', () => cherryPick('/repo-2281-cherry-pick', 'abc123'));
|
|
await expectStatusInvalidatedBy('/repo-2281-revert-commit', () => revertCommit('/repo-2281-revert-commit', 'abc123'));
|
|
await expectStatusInvalidatedBy('/repo-2281-reset', () => resetToCommit('/repo-2281-reset', 'abc123', 'mixed'));
|
|
} finally {
|
|
restoreMocks();
|
|
}
|
|
});
|
|
|
|
test('remote-side mutations invalidate cached status', async () => {
|
|
installWindowMock();
|
|
try {
|
|
await expectStatusInvalidatedBy('/repo-2281-delete-remote-branch', () => deleteRemoteBranch('/repo-2281-delete-remote-branch', { branch: 'feature', remote: 'origin' }));
|
|
await expectStatusInvalidatedBy('/repo-2281-remove-remote', () => removeRemote('/repo-2281-remove-remote', { remote: 'origin' }));
|
|
} finally {
|
|
restoreMocks();
|
|
}
|
|
});
|
|
|
|
test('a failed mutation does not invalidate cached status', async () => {
|
|
installWindowMock();
|
|
const statusUrls: string[] = [];
|
|
// SAFETY: see installStatusMutationFetchMock - the mock honours the fetch
|
|
// contract; the assertion supplies its overload signatures.
|
|
globalThis.fetch = (async (input) => {
|
|
const url = String(input);
|
|
if (url.startsWith('/api/git/status')) {
|
|
statusUrls.push(url);
|
|
return jsonResponse(statusPayload());
|
|
}
|
|
return new Response(JSON.stringify({ error: 'checkout failed' }), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}) as typeof fetch;
|
|
|
|
try {
|
|
const directory = '/repo-2281-failed-checkout';
|
|
await getGitStatus(directory);
|
|
|
|
const error = await captureError(async () => {
|
|
await checkoutBranch(directory, 'feature');
|
|
});
|
|
expect(error).toBeInstanceOf(Error);
|
|
// SAFETY: the assertion above established that `error` is an Error.
|
|
expect((error as Error).message).toBe('checkout failed');
|
|
|
|
await getGitStatus(directory);
|
|
expect(statusUrls).toHaveLength(1);
|
|
} finally {
|
|
restoreMocks();
|
|
}
|
|
});
|
|
|
|
test('a status request admitted before a mutation cannot satisfy the post-mutation refresh', async () => {
|
|
installWindowMock();
|
|
const statusResolvers: Array<(response: Response) => void> = [];
|
|
const statusUrls: string[] = [];
|
|
// SAFETY: see installStatusMutationFetchMock - the mock honours the fetch
|
|
// contract; the assertion supplies its overload signatures.
|
|
globalThis.fetch = (async (input) => {
|
|
const url = String(input);
|
|
if (url.startsWith('/api/git/status')) {
|
|
statusUrls.push(url);
|
|
return new Promise<Response>((resolve) => {
|
|
statusResolvers.push(resolve);
|
|
});
|
|
}
|
|
return jsonResponse({ success: true });
|
|
}) as typeof fetch;
|
|
|
|
try {
|
|
const directory = '/repo-2281-deferred';
|
|
const preMutationRead = getGitStatus(directory);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(statusUrls).toHaveLength(1);
|
|
|
|
await checkoutBranch(directory, 'feature');
|
|
|
|
const postMutationRead = getGitStatus(directory);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(statusUrls).toHaveLength(2);
|
|
|
|
statusResolvers[1](jsonResponse(statusPayload({ current: 'feature' })));
|
|
statusResolvers[0](jsonResponse(statusPayload({ current: 'main' })));
|
|
|
|
const [preMutationStatus, postMutationStatus] = await Promise.all([preMutationRead, postMutationRead]);
|
|
expect(preMutationStatus.current).toBe('main');
|
|
expect(postMutationStatus.current).toBe('feature');
|
|
|
|
// The late pre-mutation response must not repopulate the cache.
|
|
const cachedRead = await getGitStatus(directory);
|
|
expect(cachedRead.current).toBe('feature');
|
|
expect(statusUrls).toHaveLength(2);
|
|
} finally {
|
|
restoreMocks();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('gitApiHttp request priority', () => {
|
|
test('leaves low-level reads outside the background policy', async () => {
|
|
installWindowMock();
|
|
const calls = installFetchMock();
|
|
try {
|
|
await getGitBranches('/repo-interactive');
|
|
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0].init?.priority).toBe(undefined);
|
|
} finally {
|
|
restoreMocks();
|
|
}
|
|
});
|
|
});
|