ai-cli-online 3.0.13 → 3.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -3
- package/server/dist/index.js +2 -0
- package/server/dist/routes/git.d.ts +2 -0
- package/server/dist/routes/git.js +203 -0
- package/server/dist/routes/git.test.d.ts +1 -0
- package/server/dist/routes/git.test.js +170 -0
- package/server/dist/routes/sessions.test.d.ts +1 -0
- package/server/dist/routes/sessions.test.js +141 -0
- package/server/package.json +7 -3
- package/shared/package.json +1 -1
- package/web/dist/assets/index-CyBw9szi.js +33 -0
- package/web/dist/index.html +1 -1
- package/web/package.json +8 -3
- package/web/dist/assets/index-DtWYEMhe.js +0 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-cli-online",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.16",
|
|
4
4
|
"description": "AI-Cli Online - Web Terminal for Claude Code via xterm.js + tmux",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -48,10 +48,13 @@
|
|
|
48
48
|
"dev:server": "npm run dev --workspace=server",
|
|
49
49
|
"dev:web": "npm run dev --workspace=web",
|
|
50
50
|
"build": "npm run build --workspace=shared && npm run build --workspace=server && npm run build --workspace=web",
|
|
51
|
-
"start": "npm run start --workspace=server"
|
|
51
|
+
"start": "npm run start --workspace=server",
|
|
52
|
+
"test": "npm run test --workspace=server && npm run test --workspace=web",
|
|
53
|
+
"test:watch": "npm run test:watch --workspace=server"
|
|
52
54
|
},
|
|
53
55
|
"devDependencies": {
|
|
54
56
|
"concurrently": "^8.2.2",
|
|
55
|
-
"typescript": "^5.9.3"
|
|
57
|
+
"typescript": "^5.9.3",
|
|
58
|
+
"vitest": "^2.1.9"
|
|
56
59
|
}
|
|
57
60
|
}
|
package/server/dist/index.js
CHANGED
|
@@ -18,6 +18,7 @@ import sessionsRouter from './routes/sessions.js';
|
|
|
18
18
|
import filesRouter from './routes/files.js';
|
|
19
19
|
import editorRouter from './routes/editor.js';
|
|
20
20
|
import settingsRouter from './routes/settings.js';
|
|
21
|
+
import gitRouter from './routes/git.js';
|
|
21
22
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
23
|
config();
|
|
23
24
|
const PORT = process.env.PORT || 3001;
|
|
@@ -99,6 +100,7 @@ async function main() {
|
|
|
99
100
|
app.use(filesRouter);
|
|
100
101
|
app.use(editorRouter);
|
|
101
102
|
app.use(settingsRouter);
|
|
103
|
+
app.use(gitRouter);
|
|
102
104
|
// --- Static files ---
|
|
103
105
|
const webDistPath = join(__dirname, '../../web/dist');
|
|
104
106
|
if (existsSync(webDistPath)) {
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { execFile as execFileCb } from 'child_process';
|
|
3
|
+
import { promisify } from 'util';
|
|
4
|
+
import { resolveSession } from '../middleware/auth.js';
|
|
5
|
+
import { getCwd } from '../tmux.js';
|
|
6
|
+
const execFile = promisify(execFileCb);
|
|
7
|
+
const EXEC_TIMEOUT = 10000;
|
|
8
|
+
const router = Router();
|
|
9
|
+
// Git log with optional file filter
|
|
10
|
+
router.get('/api/sessions/:sessionId/git-log', async (req, res) => {
|
|
11
|
+
const sessionName = resolveSession(req, res);
|
|
12
|
+
if (!sessionName)
|
|
13
|
+
return;
|
|
14
|
+
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
|
15
|
+
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 30));
|
|
16
|
+
const file = req.query.file;
|
|
17
|
+
if (file && (file.includes('..') || file.startsWith('/'))) {
|
|
18
|
+
res.status(400).json({ error: 'Invalid file path' });
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const all = req.query.all === 'true';
|
|
22
|
+
const branch = req.query.branch;
|
|
23
|
+
if (branch && !/^[\w\-\/.]+$/.test(branch)) {
|
|
24
|
+
res.status(400).json({ error: 'Invalid branch name' });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const skip = (page - 1) * limit;
|
|
28
|
+
let cwd;
|
|
29
|
+
try {
|
|
30
|
+
cwd = await getCwd(sessionName);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
res.status(404).json({ error: 'Session not found' });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
// Use a separator that won't appear in commit messages
|
|
38
|
+
const SEP = '---GIT-LOG-SEP---';
|
|
39
|
+
const format = `${SEP}%n%H%n%h%n%P%n%D%n%s%n%an%n%aI`;
|
|
40
|
+
const args = ['log', '--topo-order', `--pretty=format:${format}`, '--numstat', `--skip=${skip}`, `-${limit + 1}`];
|
|
41
|
+
if (all)
|
|
42
|
+
args.splice(1, 0, '--all');
|
|
43
|
+
if (branch && !all)
|
|
44
|
+
args.push(branch);
|
|
45
|
+
if (file) {
|
|
46
|
+
args.push('--', file);
|
|
47
|
+
}
|
|
48
|
+
const { stdout } = await execFile('git', args, { cwd, timeout: EXEC_TIMEOUT });
|
|
49
|
+
const commits = [];
|
|
50
|
+
const blocks = stdout.split(SEP).filter((b) => b.trim());
|
|
51
|
+
for (const block of blocks) {
|
|
52
|
+
// Don't filter empty lines — %P and %D may be empty (root commits, no refs)
|
|
53
|
+
// Block starts with \n from format, so drop the leading empty entry
|
|
54
|
+
const rawLines = block.split('\n');
|
|
55
|
+
// Drop leading empty line from format separator
|
|
56
|
+
if (rawLines[0] === '')
|
|
57
|
+
rawLines.shift();
|
|
58
|
+
// First 7 lines are fixed fields; remaining are numstat file lines
|
|
59
|
+
if (rawLines.length < 7)
|
|
60
|
+
continue;
|
|
61
|
+
const [hash, shortHash, parentLine, refLine, message, author, date, ...fileLines] = rawLines;
|
|
62
|
+
const parents = parentLine.trim() ? parentLine.trim().split(' ') : [];
|
|
63
|
+
const refs = [];
|
|
64
|
+
if (refLine.trim()) {
|
|
65
|
+
for (const raw of refLine.split(',')) {
|
|
66
|
+
const part = raw.trim();
|
|
67
|
+
if (!part)
|
|
68
|
+
continue;
|
|
69
|
+
if (part.startsWith('HEAD -> ')) {
|
|
70
|
+
refs.push({ type: 'head', name: part.slice(8) });
|
|
71
|
+
}
|
|
72
|
+
else if (part === 'HEAD') {
|
|
73
|
+
refs.push({ type: 'head', name: 'HEAD' });
|
|
74
|
+
}
|
|
75
|
+
else if (part.startsWith('tag: ')) {
|
|
76
|
+
refs.push({ type: 'tag', name: part.slice(5) });
|
|
77
|
+
}
|
|
78
|
+
else if (part.includes('/')) {
|
|
79
|
+
refs.push({ type: 'remote', name: part });
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
refs.push({ type: 'branch', name: part });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const files = [];
|
|
87
|
+
for (const fl of fileLines) {
|
|
88
|
+
const match = fl.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
|
|
89
|
+
if (match) {
|
|
90
|
+
files.push({
|
|
91
|
+
additions: match[1] === '-' ? 0 : parseInt(match[1], 10),
|
|
92
|
+
deletions: match[2] === '-' ? 0 : parseInt(match[2], 10),
|
|
93
|
+
path: match[3],
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
commits.push({ hash, shortHash, parents, refs, message, author, date, files });
|
|
98
|
+
}
|
|
99
|
+
const hasMore = commits.length > limit;
|
|
100
|
+
if (hasMore)
|
|
101
|
+
commits.pop();
|
|
102
|
+
res.json({ commits, hasMore });
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
106
|
+
if (msg.includes('not a git repository')) {
|
|
107
|
+
res.json({ commits: [], hasMore: false, error: 'Not a git repository' });
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
console.error('[api:git-log]', msg);
|
|
111
|
+
res.status(500).json({ error: 'Failed to get git log' });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
// Git diff for a specific commit
|
|
116
|
+
router.get('/api/sessions/:sessionId/git-diff', async (req, res) => {
|
|
117
|
+
const sessionName = resolveSession(req, res);
|
|
118
|
+
if (!sessionName)
|
|
119
|
+
return;
|
|
120
|
+
const commit = req.query.commit;
|
|
121
|
+
if (!commit || !/^[a-f0-9]{7,40}$/.test(commit)) {
|
|
122
|
+
res.status(400).json({ error: 'Invalid commit hash' });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const file = req.query.file;
|
|
126
|
+
if (file && (file.includes('..') || file.startsWith('/'))) {
|
|
127
|
+
res.status(400).json({ error: 'Invalid file path' });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
let cwd;
|
|
131
|
+
try {
|
|
132
|
+
cwd = await getCwd(sessionName);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
res.status(404).json({ error: 'Session not found' });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
// Check if it's the root commit (no parent)
|
|
140
|
+
let args;
|
|
141
|
+
try {
|
|
142
|
+
await execFile('git', ['rev-parse', `${commit}~1`], { cwd, timeout: EXEC_TIMEOUT });
|
|
143
|
+
args = ['diff', `${commit}~1`, commit];
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// Root commit
|
|
147
|
+
args = ['diff', '--root', commit];
|
|
148
|
+
}
|
|
149
|
+
if (file) {
|
|
150
|
+
args.push('--', file);
|
|
151
|
+
}
|
|
152
|
+
const { stdout } = await execFile('git', args, { cwd, timeout: EXEC_TIMEOUT });
|
|
153
|
+
res.json({ diff: stdout });
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
157
|
+
console.error('[api:git-diff]', msg);
|
|
158
|
+
res.status(500).json({ error: 'Failed to get diff' });
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
// Git branches list
|
|
162
|
+
router.get('/api/sessions/:sessionId/git-branches', async (req, res) => {
|
|
163
|
+
const sessionName = resolveSession(req, res);
|
|
164
|
+
if (!sessionName)
|
|
165
|
+
return;
|
|
166
|
+
let cwd;
|
|
167
|
+
try {
|
|
168
|
+
cwd = await getCwd(sessionName);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
res.status(404).json({ error: 'Session not found' });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
const { stdout } = await execFile('git', ['branch', '-a', '--no-color'], { cwd, timeout: EXEC_TIMEOUT });
|
|
176
|
+
const branches = [];
|
|
177
|
+
let current = '';
|
|
178
|
+
for (const line of stdout.split('\n')) {
|
|
179
|
+
const trimmed = line.trim();
|
|
180
|
+
if (!trimmed)
|
|
181
|
+
continue;
|
|
182
|
+
if (trimmed.startsWith('* ')) {
|
|
183
|
+
current = trimmed.slice(2);
|
|
184
|
+
branches.push(current);
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
branches.push(trimmed);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
res.json({ current, branches });
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
194
|
+
if (msg.includes('not a git repository')) {
|
|
195
|
+
res.json({ current: '', branches: [] });
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
console.error('[api:git-branches]', msg);
|
|
199
|
+
res.status(500).json({ error: 'Failed to get branches' });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
export default router;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import request from 'supertest';
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Mocks — vi.mock is hoisted, so use vi.hoisted for shared state
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
const { mockExecFile, mockResolveSession, mockGetCwd } = vi.hoisted(() => ({
|
|
8
|
+
mockExecFile: vi.fn(),
|
|
9
|
+
mockResolveSession: vi.fn(() => 'mock-session'),
|
|
10
|
+
mockGetCwd: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
vi.mock('../middleware/auth.js', () => ({
|
|
13
|
+
resolveSession: mockResolveSession,
|
|
14
|
+
}));
|
|
15
|
+
vi.mock('../tmux.js', () => ({
|
|
16
|
+
getCwd: mockGetCwd,
|
|
17
|
+
}));
|
|
18
|
+
vi.mock('util', async () => {
|
|
19
|
+
const actual = await vi.importActual('util');
|
|
20
|
+
return { ...actual, promisify: () => mockExecFile };
|
|
21
|
+
});
|
|
22
|
+
import gitRouter from './git.js';
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// App setup
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
function createApp() {
|
|
27
|
+
const app = express();
|
|
28
|
+
app.use(express.json());
|
|
29
|
+
app.use(gitRouter);
|
|
30
|
+
return app;
|
|
31
|
+
}
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Tests
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
describe('GET /api/sessions/:sessionId/git-log', () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
mockResolveSession.mockReturnValue('mock-session');
|
|
39
|
+
});
|
|
40
|
+
it('returns 401 when resolveSession fails', async () => {
|
|
41
|
+
mockResolveSession.mockImplementation((_req, res) => {
|
|
42
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
43
|
+
return null;
|
|
44
|
+
});
|
|
45
|
+
const app = createApp();
|
|
46
|
+
const res = await request(app).get('/api/sessions/t1/git-log');
|
|
47
|
+
expect(res.status).toBe(401);
|
|
48
|
+
});
|
|
49
|
+
it('returns 404 when session/cwd not found', async () => {
|
|
50
|
+
mockGetCwd.mockRejectedValue(new Error('no session'));
|
|
51
|
+
const app = createApp();
|
|
52
|
+
const res = await request(app).get('/api/sessions/t1/git-log');
|
|
53
|
+
expect(res.status).toBe(404);
|
|
54
|
+
expect(res.body.error).toBe('Session not found');
|
|
55
|
+
});
|
|
56
|
+
it('returns commits on success', async () => {
|
|
57
|
+
mockGetCwd.mockResolvedValue('/home/user/project');
|
|
58
|
+
const SEP = '---GIT-LOG-SEP---';
|
|
59
|
+
const gitOutput = [
|
|
60
|
+
`${SEP}`,
|
|
61
|
+
'abc1234567890abcdef1234567890abcdef123456',
|
|
62
|
+
'abc1234',
|
|
63
|
+
'Initial commit',
|
|
64
|
+
'TestUser',
|
|
65
|
+
'2024-01-01T00:00:00+00:00',
|
|
66
|
+
'10\t2\tREADME.md',
|
|
67
|
+
].join('\n');
|
|
68
|
+
mockExecFile.mockResolvedValue({ stdout: gitOutput, stderr: '' });
|
|
69
|
+
const app = createApp();
|
|
70
|
+
const res = await request(app).get('/api/sessions/t1/git-log');
|
|
71
|
+
expect(res.status).toBe(200);
|
|
72
|
+
expect(res.body.commits).toHaveLength(1);
|
|
73
|
+
expect(res.body.commits[0]).toMatchObject({
|
|
74
|
+
hash: 'abc1234567890abcdef1234567890abcdef123456',
|
|
75
|
+
shortHash: 'abc1234',
|
|
76
|
+
message: 'Initial commit',
|
|
77
|
+
author: 'TestUser',
|
|
78
|
+
});
|
|
79
|
+
expect(res.body.commits[0].files).toHaveLength(1);
|
|
80
|
+
expect(res.body.commits[0].files[0]).toEqual({
|
|
81
|
+
path: 'README.md',
|
|
82
|
+
additions: 10,
|
|
83
|
+
deletions: 2,
|
|
84
|
+
});
|
|
85
|
+
expect(res.body.hasMore).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
it('returns empty for non-git repository', async () => {
|
|
88
|
+
mockGetCwd.mockResolvedValue('/tmp');
|
|
89
|
+
mockExecFile.mockRejectedValue(new Error('fatal: not a git repository'));
|
|
90
|
+
const app = createApp();
|
|
91
|
+
const res = await request(app).get('/api/sessions/t1/git-log');
|
|
92
|
+
expect(res.status).toBe(200);
|
|
93
|
+
expect(res.body.commits).toEqual([]);
|
|
94
|
+
expect(res.body.hasMore).toBe(false);
|
|
95
|
+
expect(res.body.error).toBe('Not a git repository');
|
|
96
|
+
});
|
|
97
|
+
it('supports file filter query param', async () => {
|
|
98
|
+
mockGetCwd.mockResolvedValue('/home/user/project');
|
|
99
|
+
mockExecFile.mockResolvedValue({ stdout: '', stderr: '' });
|
|
100
|
+
const app = createApp();
|
|
101
|
+
await request(app).get('/api/sessions/t1/git-log?file=src/index.ts');
|
|
102
|
+
expect(mockExecFile).toHaveBeenCalled();
|
|
103
|
+
const callArgs = mockExecFile.mock.calls[0];
|
|
104
|
+
const args = callArgs[1];
|
|
105
|
+
expect(args).toContain('--');
|
|
106
|
+
expect(args).toContain('src/index.ts');
|
|
107
|
+
});
|
|
108
|
+
it('supports pagination and hasMore', async () => {
|
|
109
|
+
mockGetCwd.mockResolvedValue('/home/user/project');
|
|
110
|
+
const SEP = '---GIT-LOG-SEP---';
|
|
111
|
+
const gitOutput = [
|
|
112
|
+
`${SEP}`, 'hash1', 'sh1', 'msg1', 'Author', '2024-01-01T00:00:00+00:00',
|
|
113
|
+
`${SEP}`, 'hash2', 'sh2', 'msg2', 'Author', '2024-01-02T00:00:00+00:00',
|
|
114
|
+
].join('\n');
|
|
115
|
+
mockExecFile.mockResolvedValue({ stdout: gitOutput, stderr: '' });
|
|
116
|
+
const app = createApp();
|
|
117
|
+
const res = await request(app).get('/api/sessions/t1/git-log?limit=1');
|
|
118
|
+
expect(res.status).toBe(200);
|
|
119
|
+
expect(res.body.commits).toHaveLength(1);
|
|
120
|
+
expect(res.body.hasMore).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
describe('GET /api/sessions/:sessionId/git-diff', () => {
|
|
124
|
+
beforeEach(() => {
|
|
125
|
+
vi.clearAllMocks();
|
|
126
|
+
mockResolveSession.mockReturnValue('mock-session');
|
|
127
|
+
});
|
|
128
|
+
it('returns 400 for missing commit', async () => {
|
|
129
|
+
const app = createApp();
|
|
130
|
+
const res = await request(app).get('/api/sessions/t1/git-diff');
|
|
131
|
+
expect(res.status).toBe(400);
|
|
132
|
+
expect(res.body.error).toBe('Invalid commit hash');
|
|
133
|
+
});
|
|
134
|
+
it('returns 400 for invalid commit hash', async () => {
|
|
135
|
+
const app = createApp();
|
|
136
|
+
const res = await request(app).get('/api/sessions/t1/git-diff?commit=ZZZZ');
|
|
137
|
+
expect(res.status).toBe(400);
|
|
138
|
+
});
|
|
139
|
+
it('returns diff on success', async () => {
|
|
140
|
+
mockGetCwd.mockResolvedValue('/home/user/project');
|
|
141
|
+
const diffOutput = `diff --git a/file.ts b/file.ts
|
|
142
|
+
--- a/file.ts
|
|
143
|
+
+++ b/file.ts
|
|
144
|
+
@@ -1,3 +1,4 @@
|
|
145
|
+
line1
|
|
146
|
+
+added line
|
|
147
|
+
line2
|
|
148
|
+
line3`;
|
|
149
|
+
mockExecFile
|
|
150
|
+
.mockResolvedValueOnce({ stdout: 'parenthash', stderr: '' })
|
|
151
|
+
.mockResolvedValueOnce({ stdout: diffOutput, stderr: '' });
|
|
152
|
+
const app = createApp();
|
|
153
|
+
const res = await request(app).get('/api/sessions/t1/git-diff?commit=abc1234');
|
|
154
|
+
expect(res.status).toBe(200);
|
|
155
|
+
expect(res.body.diff).toContain('+added line');
|
|
156
|
+
});
|
|
157
|
+
it('handles root commit fallback', async () => {
|
|
158
|
+
mockGetCwd.mockResolvedValue('/home/user/project');
|
|
159
|
+
mockExecFile
|
|
160
|
+
.mockRejectedValueOnce(new Error('unknown revision'))
|
|
161
|
+
.mockResolvedValueOnce({ stdout: 'root diff output', stderr: '' });
|
|
162
|
+
const app = createApp();
|
|
163
|
+
const res = await request(app).get('/api/sessions/t1/git-diff?commit=abc1234');
|
|
164
|
+
expect(res.status).toBe(200);
|
|
165
|
+
expect(res.body.diff).toBe('root diff output');
|
|
166
|
+
const secondCall = mockExecFile.mock.calls[1];
|
|
167
|
+
const args = secondCall[1];
|
|
168
|
+
expect(args).toContain('--root');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import request from 'supertest';
|
|
4
|
+
import sessionsRouter from './sessions.js';
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Mocks
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
vi.mock('../middleware/auth.js', () => ({
|
|
9
|
+
extractToken: vi.fn(() => 'test-token'),
|
|
10
|
+
checkAuth: vi.fn(() => true),
|
|
11
|
+
resolveSession: vi.fn(() => 'mock-session'),
|
|
12
|
+
}));
|
|
13
|
+
vi.mock('../tmux.js', () => ({
|
|
14
|
+
listSessions: vi.fn(),
|
|
15
|
+
killSession: vi.fn(),
|
|
16
|
+
buildSessionName: vi.fn((_token, sessionId) => `mock_${sessionId}`),
|
|
17
|
+
getCwd: vi.fn(),
|
|
18
|
+
getPaneCommand: vi.fn(),
|
|
19
|
+
isValidSessionId: vi.fn((id) => /^[\w-]+$/.test(id)),
|
|
20
|
+
}));
|
|
21
|
+
vi.mock('../websocket.js', () => ({
|
|
22
|
+
getActiveSessionNames: vi.fn(() => new Set(['mock_t1'])),
|
|
23
|
+
}));
|
|
24
|
+
vi.mock('../db.js', () => ({
|
|
25
|
+
deleteDraft: vi.fn(),
|
|
26
|
+
}));
|
|
27
|
+
import { checkAuth, resolveSession } from '../middleware/auth.js';
|
|
28
|
+
import { listSessions, killSession, getCwd, getPaneCommand } from '../tmux.js';
|
|
29
|
+
import { deleteDraft } from '../db.js';
|
|
30
|
+
const mockCheckAuth = vi.mocked(checkAuth);
|
|
31
|
+
const mockResolveSession = vi.mocked(resolveSession);
|
|
32
|
+
const mockListSessions = vi.mocked(listSessions);
|
|
33
|
+
const mockKillSession = vi.mocked(killSession);
|
|
34
|
+
const mockGetCwd = vi.mocked(getCwd);
|
|
35
|
+
const mockGetPaneCommand = vi.mocked(getPaneCommand);
|
|
36
|
+
const mockDeleteDraft = vi.mocked(deleteDraft);
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// App setup
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
function createApp() {
|
|
41
|
+
const app = express();
|
|
42
|
+
app.use(express.json());
|
|
43
|
+
app.use(sessionsRouter);
|
|
44
|
+
return app;
|
|
45
|
+
}
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Tests
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
describe('GET /api/sessions', () => {
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
vi.clearAllMocks();
|
|
52
|
+
mockCheckAuth.mockReturnValue(true);
|
|
53
|
+
});
|
|
54
|
+
it('returns 401 when auth fails', async () => {
|
|
55
|
+
mockCheckAuth.mockImplementation((_req, res) => {
|
|
56
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
57
|
+
return false;
|
|
58
|
+
});
|
|
59
|
+
const app = createApp();
|
|
60
|
+
const res = await request(app).get('/api/sessions');
|
|
61
|
+
expect(res.status).toBe(401);
|
|
62
|
+
});
|
|
63
|
+
it('returns sessions with active status', async () => {
|
|
64
|
+
mockListSessions.mockResolvedValue([
|
|
65
|
+
{ sessionId: 't1', sessionName: 'mock_t1', createdAt: 1000 },
|
|
66
|
+
{ sessionId: 't2', sessionName: 'mock_t2', createdAt: 2000 },
|
|
67
|
+
]);
|
|
68
|
+
const app = createApp();
|
|
69
|
+
const res = await request(app).get('/api/sessions');
|
|
70
|
+
expect(res.status).toBe(200);
|
|
71
|
+
expect(res.body).toHaveLength(2);
|
|
72
|
+
expect(res.body[0]).toMatchObject({
|
|
73
|
+
sessionId: 't1',
|
|
74
|
+
sessionName: 'mock_t1',
|
|
75
|
+
active: true,
|
|
76
|
+
});
|
|
77
|
+
expect(res.body[1]).toMatchObject({
|
|
78
|
+
sessionId: 't2',
|
|
79
|
+
active: false,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
describe('DELETE /api/sessions/:sessionId', () => {
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
vi.clearAllMocks();
|
|
86
|
+
mockCheckAuth.mockReturnValue(true);
|
|
87
|
+
mockKillSession.mockResolvedValue(undefined);
|
|
88
|
+
});
|
|
89
|
+
it('returns 400 for invalid sessionId', async () => {
|
|
90
|
+
const app = createApp();
|
|
91
|
+
const res = await request(app).delete('/api/sessions/invalid!id');
|
|
92
|
+
expect(res.status).toBe(400);
|
|
93
|
+
});
|
|
94
|
+
it('kills session and deletes draft', async () => {
|
|
95
|
+
const app = createApp();
|
|
96
|
+
const res = await request(app).delete('/api/sessions/t1');
|
|
97
|
+
expect(res.status).toBe(200);
|
|
98
|
+
expect(res.body.ok).toBe(true);
|
|
99
|
+
expect(mockKillSession).toHaveBeenCalledWith('mock_t1');
|
|
100
|
+
expect(mockDeleteDraft).toHaveBeenCalledWith('mock_t1');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe('GET /api/sessions/:sessionId/cwd', () => {
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
vi.clearAllMocks();
|
|
106
|
+
mockResolveSession.mockReturnValue('mock-session');
|
|
107
|
+
});
|
|
108
|
+
it('returns cwd on success', async () => {
|
|
109
|
+
mockGetCwd.mockResolvedValue('/home/user/project');
|
|
110
|
+
const app = createApp();
|
|
111
|
+
const res = await request(app).get('/api/sessions/t1/cwd');
|
|
112
|
+
expect(res.status).toBe(200);
|
|
113
|
+
expect(res.body.cwd).toBe('/home/user/project');
|
|
114
|
+
});
|
|
115
|
+
it('returns 404 when session not found', async () => {
|
|
116
|
+
mockGetCwd.mockRejectedValue(new Error('session not found'));
|
|
117
|
+
const app = createApp();
|
|
118
|
+
const res = await request(app).get('/api/sessions/t1/cwd');
|
|
119
|
+
expect(res.status).toBe(404);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
describe('GET /api/sessions/:sessionId/pane-command', () => {
|
|
123
|
+
beforeEach(() => {
|
|
124
|
+
vi.clearAllMocks();
|
|
125
|
+
mockResolveSession.mockReturnValue('mock-session');
|
|
126
|
+
});
|
|
127
|
+
it('returns command on success', async () => {
|
|
128
|
+
mockGetPaneCommand.mockResolvedValue('claude');
|
|
129
|
+
const app = createApp();
|
|
130
|
+
const res = await request(app).get('/api/sessions/t1/pane-command');
|
|
131
|
+
expect(res.status).toBe(200);
|
|
132
|
+
expect(res.body.command).toBe('claude');
|
|
133
|
+
});
|
|
134
|
+
it('returns empty string on error', async () => {
|
|
135
|
+
mockGetPaneCommand.mockRejectedValue(new Error('fail'));
|
|
136
|
+
const app = createApp();
|
|
137
|
+
const res = await request(app).get('/api/sessions/t1/pane-command');
|
|
138
|
+
expect(res.status).toBe(200);
|
|
139
|
+
expect(res.body.command).toBe('');
|
|
140
|
+
});
|
|
141
|
+
});
|
package/server/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-cli-online-server",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.16",
|
|
4
4
|
"description": "CLI-Online Backend Server",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"dev": "tsx watch src/index.ts",
|
|
9
9
|
"build": "tsc",
|
|
10
|
-
"start": "node dist/index.js"
|
|
10
|
+
"start": "node dist/index.js",
|
|
11
|
+
"test": "npx vitest run"
|
|
11
12
|
},
|
|
12
13
|
"dependencies": {
|
|
13
14
|
"ai-cli-online-shared": "*",
|
|
@@ -27,8 +28,11 @@
|
|
|
27
28
|
"@types/express": "^4.17.21",
|
|
28
29
|
"@types/multer": "^2.0.0",
|
|
29
30
|
"@types/node": "^20.19.33",
|
|
31
|
+
"@types/supertest": "^6.0.3",
|
|
30
32
|
"@types/ws": "^8.5.10",
|
|
33
|
+
"supertest": "^7.2.2",
|
|
31
34
|
"tsx": "^4.7.0",
|
|
32
|
-
"typescript": "^5.9.3"
|
|
35
|
+
"typescript": "^5.9.3",
|
|
36
|
+
"vitest": "^2.1.9"
|
|
33
37
|
}
|
|
34
38
|
}
|