mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
feat(mobile): add filesystem path picker
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @fileoverview Fast VM/static regressions for the shared filesystem picker and
|
||||
* extended mobile keyboard actions. No browser or real server required.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const keyboardSource = readFileSync(resolve('src/web/public/keyboard-accessory.js'), 'utf8');
|
||||
const terminalSource = readFileSync(resolve('src/web/public/terminal-ui.js'), 'utf8');
|
||||
const sessionSource = readFileSync(resolve('src/web/public/session-ui.js'), 'utf8');
|
||||
const indexSource = readFileSync(resolve('src/web/public/index.html'), 'utf8');
|
||||
|
||||
function loadTerminalMixin() {
|
||||
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, (...args: unknown[]) => unknown> };
|
||||
const cjkClear = vi.fn();
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
requestAnimationFrame: vi.fn(),
|
||||
CodemanApp: FakeCodemanApp,
|
||||
CjkInput: { clear: cjkClear },
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
document: { addEventListener: vi.fn() },
|
||||
});
|
||||
vm.runInContext(terminalSource, context, { filename: 'terminal-ui.js' });
|
||||
return { mixin: FakeCodemanApp.prototype, cjkClear };
|
||||
}
|
||||
|
||||
const terminalHarness = loadTerminalMixin();
|
||||
|
||||
function loadKeyboardModule() {
|
||||
const app = {
|
||||
activeSessionId: 'session-1',
|
||||
sessions: new Map([['session-1', { workingDir: '/mnt/d/AI' }]]),
|
||||
terminal: { focus: vi.fn() },
|
||||
clearTerminalInput: vi.fn(),
|
||||
insertTerminalText: vi.fn(),
|
||||
sendInput: vi.fn(),
|
||||
};
|
||||
const context = vm.createContext({
|
||||
app,
|
||||
MobileDetection: { isTouchDevice: () => false },
|
||||
URLSearchParams,
|
||||
fetch: vi.fn(),
|
||||
document: {},
|
||||
setTimeout: (fn: () => void) => {
|
||||
fn();
|
||||
return 1;
|
||||
},
|
||||
clearTimeout: vi.fn(),
|
||||
});
|
||||
vm.runInContext(
|
||||
`${keyboardSource}\nglobalThis.__bar = KeyboardAccessoryBar; globalThis.__picker = PathPicker;`,
|
||||
context
|
||||
);
|
||||
return {
|
||||
app,
|
||||
bar: (context as unknown as { __bar: { handleAction(action: string): void } }).__bar,
|
||||
picker: (context as unknown as { __picker: { open: ReturnType<typeof vi.fn> } }).__picker,
|
||||
};
|
||||
}
|
||||
|
||||
describe('mobile filesystem picker actions', () => {
|
||||
it('keeps clear-input separate from the destructive /clear command', () => {
|
||||
const { app, bar } = loadKeyboardModule();
|
||||
bar.handleAction('clear-input');
|
||||
|
||||
expect(app.clearTerminalInput).toHaveBeenCalledOnce();
|
||||
expect(app.sendInput).not.toHaveBeenCalled();
|
||||
expect(keyboardSource).toContain('data-action="clear-input"');
|
||||
expect(keyboardSource).toContain('data-action="clear" title="/clear"');
|
||||
});
|
||||
|
||||
it('opens at the active working directory and inserts the selected path without Enter', () => {
|
||||
const { app, bar, picker } = loadKeyboardModule();
|
||||
picker.open = vi.fn();
|
||||
|
||||
bar.handleAction('pick-path');
|
||||
|
||||
expect(picker.open).toHaveBeenCalledOnce();
|
||||
const options = picker.open.mock.calls[0][0];
|
||||
expect(options).toMatchObject({
|
||||
sessionId: 'session-1',
|
||||
initialPath: '/mnt/d/AI',
|
||||
directoriesOnly: false,
|
||||
});
|
||||
options.onSelect('/mnt/d/AI/project/file.ts');
|
||||
expect(app.insertTerminalText).toHaveBeenCalledWith('/mnt/d/AI/project/file.ts');
|
||||
expect(app.sendInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wires Link Existing to the shared folder-only picker', () => {
|
||||
expect(indexSource).toContain('onclick="app.openLinkCasePathPicker()"');
|
||||
expect(indexSource).toContain('id="linkCasePath"');
|
||||
expect(sessionSource).toContain('openLinkCasePathPicker()');
|
||||
expect(sessionSource).toContain('directoriesOnly: true');
|
||||
});
|
||||
|
||||
it('inserts a selected path into the editable local-echo prompt without sending it', () => {
|
||||
const appendText = vi.fn();
|
||||
const sendInput = vi.fn();
|
||||
const focus = vi.fn();
|
||||
const app = {
|
||||
activeSessionId: 'session-1',
|
||||
_localEchoEnabled: true,
|
||||
_localEchoOverlay: { appendText },
|
||||
terminal: { focus },
|
||||
sendInput,
|
||||
};
|
||||
|
||||
terminalHarness.mixin.insertTerminalText.call(app, '/mnt/d/AI/project');
|
||||
|
||||
expect(appendText).toHaveBeenCalledWith('/mnt/d/AI/project');
|
||||
expect(sendInput).not.toHaveBeenCalled();
|
||||
expect(focus).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('clears pending and already-flushed prompt text without invoking /clear', () => {
|
||||
const clear = vi.fn();
|
||||
const suppressBufferDetection = vi.fn();
|
||||
const sendInput = vi.fn(() => Promise.resolve());
|
||||
const showToast = vi.fn();
|
||||
const focus = vi.fn();
|
||||
const app = {
|
||||
activeSessionId: 'session-1',
|
||||
_inputFlushTimeout: null,
|
||||
_pendingInput: 'pending text',
|
||||
_localEchoEnabled: true,
|
||||
_localEchoOverlay: {
|
||||
getFlushed: () => ({ count: 4, text: 'sent' }),
|
||||
clear,
|
||||
suppressBufferDetection,
|
||||
},
|
||||
_flushedOffsets: new Map([['session-1', 4]]),
|
||||
_flushedTexts: new Map([['session-1', 'sent']]),
|
||||
sendInput,
|
||||
showToast,
|
||||
terminal: { focus },
|
||||
};
|
||||
|
||||
terminalHarness.mixin.clearTerminalInput.call(app);
|
||||
|
||||
expect(app._pendingInput).toBe('');
|
||||
expect(clear).toHaveBeenCalledOnce();
|
||||
expect(suppressBufferDetection).toHaveBeenCalledOnce();
|
||||
expect(sendInput).toHaveBeenCalledWith('\x7f'.repeat(4));
|
||||
expect(sendInput).not.toHaveBeenCalledWith('/clear');
|
||||
expect(app._flushedOffsets.size).toBe(0);
|
||||
expect(app._flushedTexts.size).toBe(0);
|
||||
expect(showToast).toHaveBeenCalledWith('Input cleared', 'success');
|
||||
expect(focus).toHaveBeenCalledOnce();
|
||||
expect(terminalHarness.cjkClear).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses Ctrl+U to clear the TUI-owned prompt when local echo is disabled', () => {
|
||||
const sendInput = vi.fn(() => Promise.resolve());
|
||||
const app = {
|
||||
activeSessionId: 'session-1',
|
||||
_inputFlushTimeout: null,
|
||||
_pendingInput: '',
|
||||
_localEchoEnabled: false,
|
||||
_localEchoOverlay: null,
|
||||
sendInput,
|
||||
showToast: vi.fn(),
|
||||
terminal: { focus: vi.fn() },
|
||||
};
|
||||
|
||||
terminalHarness.mixin.clearTerminalInput.call(app);
|
||||
|
||||
expect(sendInput).toHaveBeenCalledWith('\x15');
|
||||
});
|
||||
});
|
||||
@@ -8,13 +8,14 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||
import { ApiErrorCode } from '../../src/types.js';
|
||||
|
||||
// Mock fs/promises for file operations
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
readdir: vi.fn(async () => []),
|
||||
readFile: vi.fn(async () => 'file content'),
|
||||
stat: vi.fn(async () => ({ size: 100, isFile: () => true })),
|
||||
stat: vi.fn(async () => ({ size: 100, isFile: () => true, isDirectory: () => true })),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -55,13 +56,99 @@ describe('file-routes', () => {
|
||||
// Default: realpathSync returns the path unchanged
|
||||
mockedRealpathSync.mockImplementation((p: string) => p as never);
|
||||
// Default stat
|
||||
mockedStat.mockResolvedValue({ size: 100, isFile: () => true } as never);
|
||||
mockedStat.mockResolvedValue({ size: 100, isFile: () => true, isDirectory: () => true } as never);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
// ========== GET /api/filesystem/browse ==========
|
||||
|
||||
describe('GET /api/filesystem/browse', () => {
|
||||
it('lists the active session folder lazily with directories first', async () => {
|
||||
mockedReaddir.mockResolvedValueOnce([
|
||||
{
|
||||
name: 'notes.txt',
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
isSymbolicLink: () => false,
|
||||
},
|
||||
{
|
||||
name: 'src',
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
},
|
||||
] as never);
|
||||
|
||||
const path = harness.ctx._session.workingDir;
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.path).toBe(path);
|
||||
expect(body.data.roots[0]).toEqual({ label: 'Current Folder', path });
|
||||
expect(body.data.entries.map((entry: { name: string; type: string }) => [entry.name, entry.type])).toEqual([
|
||||
['src', 'directory'],
|
||||
['notes.txt', 'file'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects paths outside the configured roots', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/browse?path=${encodeURIComponent('/tmp/not-an-allowed-root')}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('does not expose hidden entries or symlinks that escape the allowed roots', async () => {
|
||||
const root = harness.ctx._session.workingDir;
|
||||
mockedReaddir.mockResolvedValueOnce([
|
||||
{
|
||||
name: '.secret',
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
isSymbolicLink: () => false,
|
||||
},
|
||||
{
|
||||
name: 'outside-link',
|
||||
isDirectory: () => false,
|
||||
isFile: () => false,
|
||||
isSymbolicLink: () => true,
|
||||
},
|
||||
] as never);
|
||||
mockedRealpathSync.mockImplementation((path: string) =>
|
||||
path === `${root}/outside-link` ? ('/etc/shadow' as never) : (path as never)
|
||||
);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(root)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body).data.entries).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown session scope', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/filesystem/browse?sessionId=missing-session',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.NOT_FOUND });
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/sessions/:id/files ==========
|
||||
|
||||
describe('GET /api/sessions/:id/files', () => {
|
||||
|
||||
Reference in New Issue
Block a user