feat(cases): create a new case in a custom folder

POST /api/cases takes an optional path; Add Case > Create New gets a 'Create in a
custom folder' option with Browse. The folder is created (or an empty one filled),
scaffolded like a normal case and registered as a linked case. System, home,
credential and Codeman folders are refused; a folder with files is Link Existing's
job; a failure after the first write undoes what this call created. Admin only in
multi-user mode, like Link Existing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Devvyn
2026-10-05 07:03:10 +08:00
co-authored by Claude Sonnet 5.5
parent ffaa5ee80c
commit 4150707a6b
13 changed files with 775 additions and 8 deletions
+139
View File
@@ -0,0 +1,139 @@
/** @fileoverview Add Case → Create New → "Create in a custom folder", end to end: real server, real Chromium, real folders. */
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
declare const PathPicker: any; // evaluated inside the page, where it is a global
const PORT = 3193;
describe('Create a case in a custom folder', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
let parent: string;
beforeAll(async () => {
parent = mkdtempSync(join(homedir(), 'custom-case-'));
server = new WebServer(PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
}, 90000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
rmSync(parent, { recursive: true, force: true });
}, 60000);
const open = async () => {
await page.evaluate(() => {
(window as any).app.showCreateCaseModal();
(window as any).app.switchCaseModalTab('case-create');
});
};
const toastText = () =>
page.evaluate(() => [...document.querySelectorAll('.toast')].map((t) => t.textContent).join('|'));
it('hides the parent-folder field until the box is ticked, and shows what it will create', async () => {
await open();
expect(await page.isVisible('#newCaseCustomPathRow')).toBe(false);
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
expect(await page.isVisible('#newCaseCustomPathRow')).toBe(true);
await page.fill('#newCaseName', 'my-app');
await page.fill('#newCasePath', '~/projects/');
expect(await page.textContent('#newCasePathPreview')).toBe('Will create: ~/projects/my-app');
});
it('is exclusive with the Docker option, in both directions', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
expect(await page.isDisabled('#newCaseDocker')).toBe(true);
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
expect(await page.isDisabled('#newCaseDocker')).toBe(false);
await page.click('label.checkbox-row:has(#newCaseDocker)');
expect(await page.isDisabled('#newCaseCustomPathToggle')).toBe(true);
await page.click('label.checkbox-row:has(#newCaseDocker)');
});
it('Browse opens the folder picker for directories only and fills the field with the choice', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'picked');
const opts = await page.evaluate(() => {
// A top-level `const` in a classic script: a global binding, not a window property.
const picker = PathPicker;
let captured: any = null;
const original = picker.open;
picker.open = (o: any) => (captured = o);
(document.querySelector('#newCaseCustomPathRow .path-input-browse') as HTMLElement).click();
picker.open = original;
captured.onSelect('/srv/work');
return { directoriesOnly: captured.directoriesOnly };
});
expect(opts.directoriesOnly).toBe(true);
expect(await page.inputValue('#newCasePath')).toBe('/srv/work');
expect(await page.textContent('#newCasePathPreview')).toBe('Will create: /srv/work/picked');
});
it('asks for a folder when the box is ticked and the field is empty, and creates nothing', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'no-folder');
await page.evaluate(() => (window as any).app.createCase());
expect(await toastText()).toMatch(/Choose the folder/);
});
it('creates the case in the chosen folder, scaffolds it, and lists it at that path', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'in-custom');
await page.fill('#newCasePath', parent);
await page.evaluate(() => (window as any).app.submitCaseModal());
await page.waitForFunction(() =>
/created in/.test([...document.querySelectorAll('.toast')].map((t) => t.textContent).join('|'))
);
const target = join(parent, 'in-custom');
expect(readFileSync(join(target, 'CLAUDE.md'), 'utf8')).toContain('in-custom');
expect(existsSync(join(target, 'src'))).toBe(true);
const cases = await page.evaluate(async () => {
const body = await (await fetch('/api/cases')).json();
return Array.isArray(body) ? body : body.data;
});
expect(cases.find((c: { name: string }) => c.name === 'in-custom')).toMatchObject({ path: target });
expect(existsSync(join(homedir(), 'codeman-cases', 'in-custom'))).toBe(false);
expect(await page.isVisible('#createCaseModal.active')).toBe(false);
});
it('shows the server’s reason for a folder that already has files, and leaves it untouched', async () => {
const busy = join(parent, 'busy');
mkdirSync(busy);
writeFileSync(join(busy, 'keep.txt'), 'mine');
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'busy');
await page.fill('#newCasePath', parent);
await page.evaluate(() => (window as any).app.createCase());
await page.waitForFunction(() =>
/Link Existing/.test([...document.querySelectorAll('.toast')].map((t) => t.textContent).join('|'))
);
expect(readFileSync(join(busy, 'keep.txt'), 'utf8')).toBe('mine');
expect(existsSync(join(busy, 'CLAUDE.md'))).toBe(false);
});
it('starts unticked every time the modal opens', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCasePath', '/tmp');
await open();
expect(await page.isChecked('#newCaseCustomPathToggle')).toBe(false);
expect(await page.inputValue('#newCasePath')).toBe('');
expect(await page.isVisible('#newCaseCustomPathRow')).toBe(false);
});
});