mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
The cases-folder refusal grew to 143 characters, and the status line is one line with an ellipsis and no title, so at 513px on desktop it ended at "or make the case with Ad..." and lost the instruction. .path-picker-status.error now wraps: 2 lines on desktop, 4 on a phone, measured clear in Chromium at both, and it covers the picker's other refusals too. Tests: a pin on that rule (fails without the fix), and the option check now reads each picker's whole options object instead of a fixed 400 characters, which stopped short of the container-workdir picker's 829. Retitled to say what it checks: Create New has the flag too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
198 lines
8.1 KiB
TypeScript
198 lines
8.1 KiB
TypeScript
/**
|
|
* @fileoverview PathPicker "New folder" (opt-in via `allowNewFolder`). Same jsdom harness as
|
|
* path-picker-hidden.test.ts (see its header for why the environment is built by hand).
|
|
*
|
|
* What can silently go wrong: the button showing for a picker that never asked for it (the
|
|
* container-workdir picker cannot create on the host), the folder made in the wrong place, the new
|
|
* folder not selected afterwards, and Escape closing the whole picker instead of just the form.
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { JSDOM } from 'jsdom';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
|
const accessoryJs = readFileSync(resolve(PUBLIC, 'keyboard-accessory.js'), 'utf8');
|
|
const sessionUiJs = readFileSync(resolve(PUBLIC, 'session-ui.js'), 'utf8');
|
|
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
|
|
|
|
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://localhost/' });
|
|
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
|
|
const doc = jsdomWindow.document;
|
|
|
|
type Call = { url: string; init?: { method?: string; body?: string } };
|
|
|
|
function loadPathPicker(fetchImpl: (url: string, init?: Call['init']) => Promise<unknown>): any {
|
|
const factory = new Function(
|
|
'window',
|
|
'document',
|
|
'localStorage',
|
|
'fetch',
|
|
'MobileDetection',
|
|
`${accessoryJs}\nreturn PathPicker;`
|
|
);
|
|
return factory(jsdomWindow, doc, jsdomWindow.localStorage, fetchImpl, { isTouchDevice: () => false });
|
|
}
|
|
|
|
const listing = (path: string, names: string[]) => ({
|
|
json: async () => ({
|
|
success: true,
|
|
data: {
|
|
path,
|
|
parent: null,
|
|
root: '/home/dev',
|
|
roots: [{ label: 'Home', path: '/home/dev' }],
|
|
entries: names.map((name) => ({ name, type: 'directory', path: `${path}/${name}` })),
|
|
truncated: false,
|
|
},
|
|
}),
|
|
});
|
|
|
|
describe('PathPicker New folder', () => {
|
|
let PathPicker: any;
|
|
let calls: Call[];
|
|
let names: string[];
|
|
let mkdirReply: () => unknown;
|
|
|
|
beforeEach(() => {
|
|
jsdomWindow.localStorage.clear();
|
|
doc.body.replaceChildren();
|
|
calls = [];
|
|
names = ['src'];
|
|
mkdirReply = () => {
|
|
names.push('fresh');
|
|
return { json: async () => ({ success: true, data: { path: '/home/dev/project/fresh' } }) };
|
|
};
|
|
PathPicker = loadPathPicker(async (url, init) => {
|
|
calls.push({ url, init });
|
|
return url.startsWith('/api/filesystem/mkdir') ? mkdirReply() : listing('/home/dev/project', names);
|
|
});
|
|
});
|
|
afterEach(() => {
|
|
PathPicker?.close?.(false);
|
|
doc.body.replaceChildren();
|
|
});
|
|
|
|
const open = async (options: Record<string, unknown> = {}) => {
|
|
PathPicker.open({ onSelect: () => {}, directoriesOnly: true, ...options });
|
|
await vi.waitFor(() => expect(doc.querySelectorAll('.path-picker-item').length).toBeGreaterThan(0));
|
|
};
|
|
const button = () => doc.querySelector('.path-picker-newfolder') as HTMLButtonElement;
|
|
const form = () => doc.querySelector('.path-picker-newfolder-form') as HTMLFormElement;
|
|
const nameInput = () => doc.querySelector('.path-picker-newfolder-name') as HTMLInputElement;
|
|
const submit = (name: string) => {
|
|
button().click();
|
|
nameInput().value = name;
|
|
form().dispatchEvent(new jsdomWindow.Event('submit', { cancelable: true, bubbles: true }));
|
|
};
|
|
|
|
it('is hidden unless the caller opts in', async () => {
|
|
await open();
|
|
expect(button().hidden).toBe(true);
|
|
expect(form().hidden).toBe(true);
|
|
});
|
|
|
|
it('shows the button, and opens the name form on click, when allowed', async () => {
|
|
await open({ allowNewFolder: true });
|
|
expect(button().hidden).toBe(false);
|
|
expect(form().hidden).toBe(true);
|
|
button().click();
|
|
expect(form().hidden).toBe(false);
|
|
});
|
|
|
|
it('creates inside the folder being viewed, reloads it and selects the new folder', async () => {
|
|
await open({ allowNewFolder: true, sessionId: 'abc123' });
|
|
submit('fresh');
|
|
await vi.waitFor(() =>
|
|
expect(doc.querySelector('.path-picker-selection-value')?.textContent).toBe('/home/dev/project/fresh')
|
|
);
|
|
const post = calls.find((c) => c.url === '/api/filesystem/mkdir');
|
|
expect(post?.init?.method).toBe('POST');
|
|
expect(JSON.parse(post?.init?.body ?? '{}')).toEqual({
|
|
path: '/home/dev/project',
|
|
name: 'fresh',
|
|
sessionId: 'abc123',
|
|
});
|
|
expect(Array.from(doc.querySelectorAll('.path-picker-item-name')).map((e) => e.textContent)).toContain('fresh');
|
|
expect(form().hidden).toBe(true);
|
|
expect((doc.querySelector('.path-picker-confirm') as HTMLButtonElement).disabled).toBe(false);
|
|
});
|
|
|
|
it('shows the server reason and keeps the form open when the folder cannot be made', async () => {
|
|
mkdirReply = () => ({ json: async () => ({ success: false, error: '"fresh" already exists in this folder' }) });
|
|
await open({ allowNewFolder: true });
|
|
submit('fresh');
|
|
await vi.waitFor(() =>
|
|
expect(doc.querySelector('.path-picker-status')?.textContent).toBe('"fresh" already exists in this folder')
|
|
);
|
|
expect(doc.querySelector('.path-picker-status')?.classList.contains('error')).toBe(true);
|
|
expect(form().hidden).toBe(false);
|
|
});
|
|
|
|
it('forwards showHidden while the picker is showing hidden entries', async () => {
|
|
jsdomWindow.localStorage.setItem('codeman:pathPickerShowHidden', '1');
|
|
await open({ allowNewFolder: true });
|
|
submit('fresh');
|
|
await vi.waitFor(() => expect(calls.some((c) => c.url === '/api/filesystem/mkdir')).toBe(true));
|
|
const post = calls.find((c) => c.url === '/api/filesystem/mkdir');
|
|
expect(JSON.parse(post?.init?.body ?? '{}').showHidden).toBe('true');
|
|
});
|
|
|
|
it('disables Create while the request runs, so a double tap sends one', async () => {
|
|
let finish: (v: unknown) => void = () => {};
|
|
mkdirReply = () =>
|
|
new Promise((resolve) => {
|
|
finish = resolve;
|
|
});
|
|
await open({ allowNewFolder: true });
|
|
const create = doc.querySelector('.path-picker-newfolder-create') as HTMLButtonElement;
|
|
submit('fresh');
|
|
await vi.waitFor(() => expect(create.disabled).toBe(true));
|
|
finish({ json: async () => ({ success: true, data: { path: '/home/dev/project/fresh' } }) });
|
|
await vi.waitFor(() => expect(create.disabled).toBe(false));
|
|
});
|
|
|
|
it('sends nothing for a blank name', async () => {
|
|
await open({ allowNewFolder: true });
|
|
submit(' ');
|
|
await Promise.resolve();
|
|
expect(calls.some((c) => c.url === '/api/filesystem/mkdir')).toBe(false);
|
|
});
|
|
|
|
it('Escape in the name field closes the form, not the picker', async () => {
|
|
await open({ allowNewFolder: true });
|
|
button().click();
|
|
nameInput().dispatchEvent(new jsdomWindow.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
expect(form().hidden).toBe(true);
|
|
expect(doc.querySelector('.path-picker-overlay')).not.toBeNull();
|
|
});
|
|
|
|
// A picker's whole options object: a fixed window stops short of the container picker's long one.
|
|
const pickerOptions = (name: string) => {
|
|
const start = sessionUiJs.indexOf(`${name}() {`);
|
|
expect(start, name).toBeGreaterThan(-1);
|
|
const open = sessionUiJs.indexOf('PathPicker.open', start);
|
|
expect(open, name).toBeGreaterThan(start);
|
|
const end = sessionUiJs.indexOf('\n });', open);
|
|
expect(end, name).toBeGreaterThan(open);
|
|
return sessionUiJs.slice(open, end);
|
|
};
|
|
|
|
it("is on for Create New's custom folder picker too", () => {
|
|
expect(pickerOptions('openNewCasePathPicker')).toContain('allowNewFolder: true');
|
|
});
|
|
|
|
it('is on for Link Existing and off for the Clone and Docker pickers', () => {
|
|
expect(pickerOptions('openLinkCasePathPicker')).toContain('allowNewFolder: true');
|
|
for (const other of ['openDockerWorkspacePathPicker', 'openDockerWorkdirPicker', 'openCloneCasePathPicker']) {
|
|
expect(pickerOptions(other), other).not.toContain('allowNewFolder');
|
|
}
|
|
});
|
|
|
|
it('lets an error wrap: the status line has no other way to show the rest of a long refusal', () => {
|
|
const start = stylesCss.indexOf('.path-picker-status.error {');
|
|
expect(start).toBeGreaterThan(-1);
|
|
expect(stylesCss.slice(start, stylesCss.indexOf('}', start))).toContain('white-space: normal');
|
|
});
|
|
});
|