mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Merge #764 fix(cases): serialize every write to linked-cases.json
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
78b1cb26dc
10 files changed
+541
-84
No files matched your search
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* @fileoverview Every writer of the linked-cases registry is serialized, atomic, and refuses to
|
||||
* overwrite a file that does not parse (`mutateLinkedCases()`, src/web/linked-cases-writer.ts). Real
|
||||
* filesystem under test/setup.ts's temp HOME. Port: N/A (app.inject()).
|
||||
*
|
||||
* The CI flake this fixes was "a name linked while the clone ran is a 409" in
|
||||
* test/routes/case-clone-custom-folder.test.ts: two clones claiming one name both re-read the
|
||||
* registry before either wrote it, so both answered success and the later write replaced the
|
||||
* earlier entry. Writes to the registry (and its temp files) are slowed down here, so two writers
|
||||
* that both read before either writes lose an entry every time instead of once in a while.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
realpathSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { createRouteTestHarness } from './routes/_route-test-utils.js';
|
||||
import { registerCaseRoutes } from '../src/web/routes/case-routes.js';
|
||||
import { LinkedCasesWriteRefusedError, linkedCasesPath, mutateLinkedCases } from '../src/web/linked-cases-writer.js';
|
||||
import { isSandboxedHome, safeRmHomeTree } from './mocks/index.js';
|
||||
|
||||
const LINKED = () => linkedCasesPath();
|
||||
const linked = (): Record<string, string> => JSON.parse(readFileSync(LINKED(), 'utf8'));
|
||||
/** Temp files the writer left beside the registry (there must never be any). */
|
||||
const leftovers = () =>
|
||||
readdirSync(dirname(LINKED())).filter((n) => n.startsWith(`${basename(LINKED())}.`) && n.endsWith('.tmp'));
|
||||
/** The bytes of a registry torn by two overlapping truncate-then-write calls. */
|
||||
const TORN = '{\n "kept": "/srv/kept"\n}' + 'ept-2"\n}';
|
||||
|
||||
/** How long every write to the registry (or a temp file beside it) is held before it starts. */
|
||||
const SLOW_WRITE_MS = 60;
|
||||
const realWriteFile = fs.writeFile.bind(fs);
|
||||
|
||||
let workDir = '';
|
||||
|
||||
beforeEach(() => {
|
||||
if (!isSandboxedHome()) {
|
||||
throw new Error('linked-cases-writer.test.ts rewrites the linked-cases registry: run it via npm test');
|
||||
}
|
||||
safeRmHomeTree(LINKED());
|
||||
mkdirSync(dirname(LINKED()), { recursive: true });
|
||||
workDir = realpathSync(mkdtempSync(join(homedir(), 'linked-cases-writer-')));
|
||||
vi.spyOn(fs, 'writeFile').mockImplementation((async (file: unknown, data: unknown, options?: unknown) => {
|
||||
if (typeof file === 'string' && file.startsWith(LINKED())) {
|
||||
await new Promise((resolve) => setTimeout(resolve, SLOW_WRITE_MS));
|
||||
}
|
||||
return realWriteFile(file as string, data as string, options as undefined);
|
||||
}) as typeof fs.writeFile);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (workDir) safeRmHomeTree(workDir);
|
||||
workDir = '';
|
||||
if (isSandboxedHome()) safeRmHomeTree(LINKED());
|
||||
});
|
||||
|
||||
describe('mutateLinkedCases()', () => {
|
||||
it('runs concurrent mutations one at a time, so none loses another one’s entry', async () => {
|
||||
writeFileSync(LINKED(), JSON.stringify({ kept: '/srv/kept' }));
|
||||
await Promise.all(
|
||||
Array.from({ length: 8 }, (_, i) => mutateLinkedCases((current) => ({ ...current, [`c${i}`]: `/srv/c${i}` })))
|
||||
);
|
||||
expect(Object.keys(linked()).sort()).toEqual(['c0', 'c1', 'c2', 'c3', 'c4', 'c5', 'c6', 'c7', 'kept']);
|
||||
expect(leftovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it('a check that throws inside the mutation rejects that caller only and writes nothing', async () => {
|
||||
const claim = (name: string, path: string) =>
|
||||
mutateLinkedCases((current) => {
|
||||
if (current[name]) throw Object.assign(new Error(`${name} is taken`), { conflict: true });
|
||||
return { ...current, [name]: path };
|
||||
});
|
||||
const results = await Promise.allSettled([
|
||||
claim('twin', '/srv/a'),
|
||||
claim('twin', '/srv/b'),
|
||||
claim('solo', '/srv/s'),
|
||||
]);
|
||||
expect(results.map((r) => r.status)).toEqual(['fulfilled', 'rejected', 'fulfilled']);
|
||||
expect((results[1] as PromiseRejectedResult).reason.message).toBe('twin is taken');
|
||||
expect(linked()).toEqual({ twin: '/srv/a', solo: '/srv/s' });
|
||||
});
|
||||
|
||||
it('starts from an empty registry when the file is missing or empty, and writes nothing for undefined', async () => {
|
||||
const { before, after } = await mutateLinkedCases((current) => ({ ...current, a: '/srv/a' }));
|
||||
expect(before).toEqual({});
|
||||
expect(after).toEqual({ a: '/srv/a' });
|
||||
writeFileSync(LINKED(), ' \n');
|
||||
await mutateLinkedCases((current) => ({ ...current, b: '/srv/b' }));
|
||||
expect(linked()).toEqual({ b: '/srv/b' });
|
||||
const untouched = readFileSync(LINKED(), 'utf8');
|
||||
expect((await mutateLinkedCases(() => undefined)).after).toBeUndefined();
|
||||
expect(readFileSync(LINKED(), 'utf8')).toBe(untouched);
|
||||
});
|
||||
|
||||
it('refuses a registry that is not a JSON object and leaves it byte for byte', async () => {
|
||||
for (const content of [TORN, '[]', 'null', '"text"']) {
|
||||
writeFileSync(LINKED(), content);
|
||||
const fn = vi.fn((current: Record<string, string>) => ({ ...current, a: '/srv/a' }));
|
||||
await expect(mutateLinkedCases(fn)).rejects.toBeInstanceOf(LinkedCasesWriteRefusedError);
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
expect(readFileSync(LINKED(), 'utf8')).toBe(content);
|
||||
}
|
||||
// The chain keeps going after a refusal.
|
||||
writeFileSync(LINKED(), '{}');
|
||||
await mutateLinkedCases((current) => ({ ...current, a: '/srv/a' }));
|
||||
expect(linked()).toEqual({ a: '/srv/a' });
|
||||
});
|
||||
|
||||
it('leaves the previous registry and no temp file when the write fails', async () => {
|
||||
writeFileSync(LINKED(), JSON.stringify({ kept: '/srv/kept' }));
|
||||
const realRename = fs.rename.bind(fs);
|
||||
vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
|
||||
if (to === LINKED()) throw Object.assign(new Error('no space left on device'), { code: 'ENOSPC' });
|
||||
return realRename(from, to);
|
||||
});
|
||||
await expect(mutateLinkedCases((current) => ({ ...current, a: '/srv/a' }))).rejects.toThrow('no space left');
|
||||
expect(linked()).toEqual({ kept: '/srv/kept' });
|
||||
expect(leftovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it('writes through a symlinked registry onto its target and keeps the file’s permissions', async () => {
|
||||
const real = join(workDir, 'linked-cases.json');
|
||||
writeFileSync(real, JSON.stringify({ kept: '/srv/kept' }), { mode: 0o600 });
|
||||
safeRmHomeTree(LINKED());
|
||||
symlinkSync(real, LINKED());
|
||||
await mutateLinkedCases((current) => ({ ...current, a: '/srv/a' }));
|
||||
expect(lstatSync(LINKED()).isSymbolicLink()).toBe(true);
|
||||
expect(readlinkSync(LINKED())).toBe(real);
|
||||
expect(JSON.parse(readFileSync(real, 'utf8'))).toEqual({ kept: '/srv/kept', a: '/srv/a' });
|
||||
expect(statSync(real).mode & 0o777).toBe(0o600);
|
||||
expect(readdirSync(workDir).filter((n) => n.endsWith('.tmp'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the case routes share the writer', () => {
|
||||
const folder = (name: string) => {
|
||||
const dir = join(workDir, name);
|
||||
mkdirSync(dir);
|
||||
return dir;
|
||||
};
|
||||
|
||||
it('two links of different names at once both survive', async () => {
|
||||
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||
const [a, b] = await Promise.all([
|
||||
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'one', path: folder('one') } }),
|
||||
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'two', path: folder('two') } }),
|
||||
]);
|
||||
expect([a.json().success, b.json().success]).toEqual([true, true]);
|
||||
expect(linked()).toEqual({ one: join(workDir, 'one'), two: join(workDir, 'two') });
|
||||
});
|
||||
|
||||
it('two links of one name at once: exactly one wins and the other is told the name is taken', async () => {
|
||||
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||
const [a, b] = await Promise.all([
|
||||
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'twin', path: folder('twin-a') } }),
|
||||
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'twin', path: folder('twin-b') } }),
|
||||
]);
|
||||
expect([a.json().success, b.json().success].filter(Boolean)).toHaveLength(1);
|
||||
const loser = a.json().success ? b : a;
|
||||
expect(loser.json().errorCode).toBe('ALREADY_EXISTS');
|
||||
expect(Object.keys(linked())).toEqual(['twin']);
|
||||
});
|
||||
|
||||
it('an unlink at the same time as a link keeps the new link', async () => {
|
||||
writeFileSync(LINKED(), JSON.stringify({ old: folder('old') }));
|
||||
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||
const [unlink, link] = await Promise.all([
|
||||
app.inject({ method: 'DELETE', url: '/api/cases/old' }),
|
||||
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'fresh', path: folder('fresh') } }),
|
||||
]);
|
||||
expect([unlink.json().success, link.json().success]).toEqual([true, true]);
|
||||
expect(linked()).toEqual({ fresh: join(workDir, 'fresh') });
|
||||
expect(existsSync(join(workDir, 'old'))).toBe(true);
|
||||
});
|
||||
|
||||
it('a link refuses a registry that does not parse instead of replacing it', async () => {
|
||||
writeFileSync(LINKED(), TORN);
|
||||
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'n', path: folder('n') } });
|
||||
expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' });
|
||||
expect(res.json().error).toContain('is not a valid JSON object');
|
||||
expect(readFileSync(LINKED(), 'utf8')).toBe(TORN);
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
|
||||
import { dataPath } from '../../src/config/instance.js';
|
||||
@@ -178,6 +178,20 @@ describe.skipIf(!isGitAvailable())('POST /api/cases/clone with a custom folder',
|
||||
expect(Object.keys(linked())).toEqual(['twin']);
|
||||
});
|
||||
|
||||
it('a registry that is not a JSON object is a 409 that keeps the folder, names it and leaves the file alone', async () => {
|
||||
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||
const target = join(workDir, 'refused');
|
||||
mkdirSync(dirname(LINKED()), { recursive: true });
|
||||
writeFileSync(LINKED(), '[]');
|
||||
const res = await clone(app, { name: 'refused', repository: origin, path: target });
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(res.json().errorCode).toBe('CONFLICT');
|
||||
expect(res.json().error).toContain(target);
|
||||
expect(res.json().error).toContain('Use Link Existing');
|
||||
expect(existsSync(join(target, 'README.md'))).toBe(true);
|
||||
expect(readFileSync(LINKED(), 'utf8')).toBe('[]');
|
||||
});
|
||||
|
||||
it('a direct clone whose git-slot wait times out leaves what was written into the folder meanwhile', async () => {
|
||||
const target = join(workDir, 'queued');
|
||||
mkdirSync(target);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* to start there. Each test's folders sit in a fresh mkdtemp dir, and everything is removed through
|
||||
* safeRmHomeTree, so a run can only ever delete what it created.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, join } from 'node:path';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
@@ -40,7 +41,7 @@ beforeEach(() => {
|
||||
if (!sandboxed()) {
|
||||
throw new Error('case-custom-path-routes.test.ts deletes the linked-cases registry: run it via npm test');
|
||||
}
|
||||
// Recursive: the rollback tests turn the registry into a directory.
|
||||
// Recursive, so a test that left anything at the registry's path cannot leak into the next one.
|
||||
safeRmHomeTree(LINKED());
|
||||
// Resolved, because the server answers with the symlink-resolved folder (macOS temp is under /private).
|
||||
workDir = realpathSync(mkdtempSync(join(homedir(), 'case-custom-path-')));
|
||||
@@ -134,20 +135,36 @@ describe('POST /api/cases with a custom path', () => {
|
||||
|
||||
it('undoes what it created when registering fails (a new folder is removed entirely)', async () => {
|
||||
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||
mkdirSync(LINKED(), { recursive: true }); // writeFile onto a directory fails
|
||||
const res = await create(app, { name: 'doomed', path: join(work(), 'doomed') });
|
||||
// The registry is replaced by renaming a temp file over it: fail that rename.
|
||||
const realRename = fs.rename;
|
||||
const spy = vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
|
||||
if (to === LINKED()) throw Object.assign(new Error('permission denied'), { code: 'EACCES' });
|
||||
return realRename(from, to);
|
||||
});
|
||||
let res;
|
||||
try {
|
||||
res = await create(app, { name: 'doomed', path: join(work(), 'doomed') });
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(existsSync(join(work(), 'doomed'))).toBe(false);
|
||||
expect(linked()).toEqual({});
|
||||
});
|
||||
|
||||
it('undoes only the scaffold inside an empty folder the user picked, leaving the folder', async () => {
|
||||
it('undoes only the scaffold inside an empty folder the user picked when the registry is refused', async () => {
|
||||
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||
mkdirSync(join(work(), 'picked'));
|
||||
mkdirSync(LINKED(), { recursive: true });
|
||||
// A registry that does not parse is never written over (that would unlink every other case).
|
||||
const torn = '{\n "kept": "/somewhere"\n}tail"\n}';
|
||||
writeFileSync(LINKED(), torn);
|
||||
const res = await create(app, { name: 'picked', path: join(work(), 'picked') });
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(res.json().errorCode).toBe('CONFLICT');
|
||||
expect(res.json().error).toContain('is not a valid JSON object');
|
||||
expect(existsSync(join(work(), 'picked'))).toBe(true);
|
||||
expect(readdirSync(join(work(), 'picked'))).toEqual([]);
|
||||
expect(readFileSync(LINKED(), 'utf8')).toBe(torn);
|
||||
});
|
||||
|
||||
it('a request without a path still creates under the cases directory, as before', async () => {
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
@@ -26,6 +25,7 @@ import { SETTINGS_PATH, resolveCasesDir } from '../../src/web/route-helpers.js';
|
||||
import { caseSessionSettled, claudeProjectDirName } from '../../src/case-rename.js';
|
||||
import { Session } from '../../src/session.js';
|
||||
import { safeRmHomeTree } from '../mocks/index.js';
|
||||
import { mutateLinkedCases } from '../../src/web/linked-cases-writer.js';
|
||||
import { mutateSettingsFile } from '../../src/web/settings-writer.js';
|
||||
|
||||
const LINKED = () => dataPath('linked-cases.json');
|
||||
@@ -36,6 +36,32 @@ let claudeCfg = '';
|
||||
const made: string[] = [];
|
||||
|
||||
const linked = (): Record<string, string> => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {});
|
||||
/**
|
||||
* The registry is replaced by renaming a temp file over it, which a read-only file does not stop, so a
|
||||
* failed registry write is injected at that rename. `before` runs first (another writer landing then).
|
||||
*/
|
||||
const failRegistryWrite = (before?: () => Promise<unknown>, alsoFail?: (from: string) => boolean) => {
|
||||
const realRename = fs.rename;
|
||||
return vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
|
||||
if (to === LINKED()) {
|
||||
if (before) await before();
|
||||
throw Object.assign(new Error('permission denied'), { code: 'EACCES' });
|
||||
}
|
||||
if (alsoFail?.(String(from))) throw Object.assign(new Error('resource busy'), { code: 'EBUSY' });
|
||||
return realRename(from, to);
|
||||
});
|
||||
};
|
||||
/**
|
||||
* Runs `during` right after `folder` moved: past every check the route makes before it writes the
|
||||
* registry, and before that write starts. Every other rename goes through untouched.
|
||||
*/
|
||||
const afterFolderMove = (folder: string, during: () => Promise<unknown>) => {
|
||||
const realRename = fs.rename;
|
||||
return vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
|
||||
await realRename(from, to);
|
||||
if (from === folder) await during();
|
||||
});
|
||||
};
|
||||
const settings = (): Record<string, unknown> => JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'));
|
||||
const rename = (app: Awaited<ReturnType<typeof createRouteTestHarness>>['app'], name: string, payload: object) =>
|
||||
app.inject({ method: 'POST', url: `/api/cases/${name}/rename`, payload });
|
||||
@@ -278,8 +304,13 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
|
||||
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
|
||||
// the registry reads fine but cannot be written: the rename must undo the folder move
|
||||
chmodSync(LINKED(), 0o444);
|
||||
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
|
||||
const spy = failRegistryWrite();
|
||||
let res;
|
||||
try {
|
||||
res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(existsSync(real)).toBe(true);
|
||||
expect(linked()).toEqual({ proj: real });
|
||||
@@ -294,14 +325,9 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
|
||||
mkdirSync(real);
|
||||
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
|
||||
chmodSync(LINKED(), 0o444);
|
||||
// Another writer (a settings save) lands after the rename wrote settings.json and before the
|
||||
// registry write fails.
|
||||
const realWriteFile = fs.writeFile;
|
||||
const spy = vi.spyOn(fs, 'writeFile').mockImplementation(async (file, data, options) => {
|
||||
if (file === LINKED()) await mutateSettingsFile((current) => ({ ...current, agentSkillEnabled: true }));
|
||||
return realWriteFile(file, data, options);
|
||||
});
|
||||
const spy = failRegistryWrite(() => mutateSettingsFile((current) => ({ ...current, agentSkillEnabled: true })));
|
||||
try {
|
||||
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
|
||||
expect(res.statusCode).toBe(500);
|
||||
@@ -311,6 +337,74 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
|
||||
expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj', agentSkillEnabled: true });
|
||||
});
|
||||
|
||||
describe('when the registry changes after the first check', () => {
|
||||
let real = '';
|
||||
type App = Awaited<ReturnType<typeof createRouteTestHarness>>['app'];
|
||||
const renameRacing = async (app: App, during: () => Promise<unknown>) => {
|
||||
const spy = afterFolderMove(real, during);
|
||||
try {
|
||||
return await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
};
|
||||
const expectFolderAndSettingsBack = () => {
|
||||
expect(existsSync(real)).toBe(true);
|
||||
expect(existsSync(join(work, 'renamed'))).toBe(false);
|
||||
expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' });
|
||||
};
|
||||
beforeEach(() => {
|
||||
real = join(work, 'my-project');
|
||||
mkdirSync(real);
|
||||
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
|
||||
});
|
||||
|
||||
it('a name linked meanwhile is 409 EXISTS: the link stands and the rename is undone', async () => {
|
||||
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||
const other = join(work, 'other');
|
||||
mkdirSync(other);
|
||||
const res = await renameRacing(app, () =>
|
||||
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'renamed', path: other } })
|
||||
);
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(res.json()).toMatchObject({ errorCode: 'ALREADY_EXISTS', renameCode: 'EXISTS', stoppedSessions: 0 });
|
||||
expect(linked()).toEqual({ proj: real, renamed: other });
|
||||
expectFolderAndSettingsBack();
|
||||
});
|
||||
|
||||
it('an old entry unlinked meanwhile is 409 BUSY: the rename does not bring it back', async () => {
|
||||
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||
const res = await renameRacing(app, () => app.inject({ method: 'DELETE', url: '/api/cases/proj' }));
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(res.json()).toMatchObject({ errorCode: 'CONFLICT', renameCode: 'BUSY', stoppedSessions: 0 });
|
||||
expect(linked()).toEqual({});
|
||||
expectFolderAndSettingsBack();
|
||||
});
|
||||
|
||||
it('an old entry pointed at another folder meanwhile is 409 BUSY: that entry is kept', async () => {
|
||||
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||
const other = join(work, 'other');
|
||||
const res = await renameRacing(app, () => mutateLinkedCases((current) => ({ ...current, proj: other })));
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(res.json()).toMatchObject({ errorCode: 'CONFLICT', renameCode: 'BUSY' });
|
||||
expect(linked()).toEqual({ proj: other });
|
||||
expectFolderAndSettingsBack();
|
||||
});
|
||||
|
||||
it('a registry that stopped parsing is refused 409 CONFLICT and left byte for byte', async () => {
|
||||
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||
const torn = `{ "proj": "${real}" }tail"`;
|
||||
const res = await renameRacing(app, async () => writeFileSync(LINKED(), torn));
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(res.json()).toMatchObject({ errorCode: 'CONFLICT', stoppedSessions: 0 });
|
||||
expect(res.json().error).toContain('is not a valid JSON object');
|
||||
expect(res.json()).not.toHaveProperty('renameCode');
|
||||
expect(readFileSync(LINKED(), 'utf8')).toBe(torn);
|
||||
expectFolderAndSettingsBack();
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses to rename while settings.json is not a JSON object, leaving the folder and the file alone', async () => {
|
||||
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||
const oldDir = localCase('alpha');
|
||||
@@ -351,13 +445,8 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
|
||||
const real = join(work, 'my-project');
|
||||
mkdirSync(real);
|
||||
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
|
||||
chmodSync(LINKED(), 0o444);
|
||||
const moved = join(work, 'renamed');
|
||||
const realRename = fs.rename;
|
||||
const spy = vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
|
||||
if (from === moved) throw Object.assign(new Error('resource busy'), { code: 'EBUSY' });
|
||||
return realRename(from, to);
|
||||
});
|
||||
const spy = failRegistryWrite(undefined, (from) => from === moved);
|
||||
try {
|
||||
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
|
||||
expect(res.statusCode).toBe(500);
|
||||
@@ -373,9 +462,14 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
|
||||
const real = join(work, 'my-project');
|
||||
mkdirSync(real);
|
||||
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
|
||||
chmodSync(LINKED(), 0o444);
|
||||
(ctx.sessions as Map<string, unknown>).set('s1', { id: 's1', name: 'w1', workingDir: real });
|
||||
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true, stopSessions: true });
|
||||
const spy = failRegistryWrite();
|
||||
let res;
|
||||
try {
|
||||
res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true, stopSessions: true });
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.json().stoppedSessions).toBe(1);
|
||||
expect(ctx.cleanupSession).toHaveBeenCalledWith('s1', true, 'case_rename');
|
||||
|
||||
@@ -54,6 +54,12 @@ vi.mock('node:fs/promises', () => ({
|
||||
throw err;
|
||||
}),
|
||||
writeFile: vi.fn(async () => {}),
|
||||
// What the linked-cases writer needs to replace the registry atomically (temp file + rename).
|
||||
realpath: vi.fn(async (p: string) => p),
|
||||
mkdir: vi.fn(async () => undefined),
|
||||
chmod: vi.fn(async () => {}),
|
||||
rename: vi.fn(async () => {}),
|
||||
rm: vi.fn(async () => {}),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -704,6 +710,7 @@ describe('case-routes', () => {
|
||||
});
|
||||
// linked-cases.json doesn't exist yet
|
||||
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
mockedStat.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
|
||||
Reference in new issue
Block a user