Files
Codeman/test/case-custom-path.browser.test.ts
T
DevvynandClaude Sonnet 5.5 4150707a6b 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>
2026-10-05 07:03:10 +08:00

140 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** @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);
});
});