Skip to content

Commit 8067e45

Browse files
Copilothuangyiirene
andcommitted
Add tests and documentation for dev handler
Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com>
1 parent a4665cb commit 8067e45

2 files changed

Lines changed: 358 additions & 0 deletions

File tree

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,44 @@ npm run package
220220

221221
See [`packages/tools/vscode-objectql/README.md`](./packages/tools/vscode-objectql/README.md) for detailed documentation.
222222

223+
### Browser-Based Development Playground 🌐
224+
225+
Work on ObjectQL projects directly in your browser with the **Dev Playground** - a web-based IDE similar to shadcn's approach.
226+
227+
**Features:**
228+
- 📁 **File Browser** - Tree view of your project with real-time refresh
229+
- ✏️ **Code Editor** - Multi-tab editing with syntax highlighting for YAML, TypeScript, JSON
230+
- 🧪 **API Testing** - Interactive playground to test CRUD operations
231+
- 💾 **Live Editing** - Edit `.object.yml`, `.validation.yml`, `.action.ts` files directly
232+
- 🔄 **Auto-save** - Keyboard shortcuts (Ctrl+S / Cmd+S) for quick saves
233+
- 🔒 **Secure** - Only enabled in development mode with file type restrictions
234+
235+
**Quick Start:**
236+
```bash
237+
# Start the example playground
238+
cd examples/integrations/dev-playground
239+
pnpm install
240+
pnpm run server # Start backend on :3000
241+
pnpm run dev # Start frontend on :5173
242+
```
243+
244+
**Integration Example:**
245+
```typescript
246+
import { createDevHandler } from '@objectql/server';
247+
248+
const devHandler = createDevHandler({
249+
enabled: process.env.NODE_ENV === 'development',
250+
baseDir: process.cwd()
251+
});
252+
253+
// Add to your HTTP server
254+
if (req.url?.startsWith('/api/dev/')) {
255+
await devHandler(req, res);
256+
}
257+
```
258+
259+
See [`examples/integrations/dev-playground/README.md`](./examples/integrations/dev-playground/README.md) for complete documentation.
260+
223261
---
224262

225263
## 🛠️ Validation & Logic
Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
import request from 'supertest';
2+
import { createServer } from 'http';
3+
import { createDevHandler } from '../src/dev-handler';
4+
import * as fs from 'fs';
5+
import * as path from 'path';
6+
import * as os from 'os';
7+
8+
describe('DevHandler', () => {
9+
let testDir: string;
10+
let srcDir: string;
11+
let server: any;
12+
13+
beforeEach(() => {
14+
// Create a temporary directory for testing
15+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'objectql-dev-test-'));
16+
srcDir = path.join(testDir, 'src');
17+
fs.mkdirSync(srcDir, { recursive: true });
18+
19+
// Create test files
20+
const objectsDir = path.join(srcDir, 'objects');
21+
fs.mkdirSync(objectsDir, { recursive: true });
22+
23+
fs.writeFileSync(
24+
path.join(objectsDir, 'project.object.yml'),
25+
'name: project\nlabel: Project\nfields:\n name:\n type: text\n required: true\n'
26+
);
27+
28+
fs.writeFileSync(
29+
path.join(objectsDir, 'task.object.yml'),
30+
'name: task\nlabel: Task\nfields:\n title:\n type: text\n required: true\n'
31+
);
32+
33+
// Create dev handler with test directory
34+
const devHandler = createDevHandler({
35+
baseDir: testDir,
36+
enabled: true
37+
});
38+
39+
server = createServer(devHandler);
40+
});
41+
42+
afterEach(() => {
43+
// Clean up test directory
44+
if (fs.existsSync(testDir)) {
45+
fs.rmSync(testDir, { recursive: true, force: true });
46+
}
47+
if (server) {
48+
server.close();
49+
}
50+
});
51+
52+
describe('GET /api/dev/files', () => {
53+
it('should list files in directory tree', async () => {
54+
const response = await request(server)
55+
.get('/api/dev/files')
56+
.expect(200);
57+
58+
expect(response.body).toHaveProperty('baseDir');
59+
expect(response.body).toHaveProperty('tree');
60+
expect(Array.isArray(response.body.tree)).toBe(true);
61+
62+
// Should have objects directory
63+
const objectsNode = response.body.tree.find((node: any) => node.name === 'objects');
64+
expect(objectsNode).toBeDefined();
65+
expect(objectsNode.type).toBe('directory');
66+
expect(objectsNode.children).toBeDefined();
67+
68+
// Should have project.object.yml and task.object.yml
69+
const projectFile = objectsNode.children.find((node: any) => node.name === 'project.object.yml');
70+
expect(projectFile).toBeDefined();
71+
expect(projectFile.type).toBe('file');
72+
73+
const taskFile = objectsNode.children.find((node: any) => node.name === 'task.object.yml');
74+
expect(taskFile).toBeDefined();
75+
expect(taskFile.type).toBe('file');
76+
});
77+
78+
it('should filter by allowed extensions', async () => {
79+
// Create a non-allowed file
80+
fs.writeFileSync(path.join(srcDir, 'test.exe'), 'binary content');
81+
82+
const response = await request(server)
83+
.get('/api/dev/files')
84+
.expect(200);
85+
86+
// Should not include .exe files
87+
const exeFile = response.body.tree.find((node: any) => node.name === 'test.exe');
88+
expect(exeFile).toBeUndefined();
89+
});
90+
});
91+
92+
describe('GET /api/dev/files/:path', () => {
93+
it('should read file content', async () => {
94+
const response = await request(server)
95+
.get('/api/dev/files/objects/project.object.yml')
96+
.expect(200);
97+
98+
expect(response.body).toHaveProperty('path', 'objects/project.object.yml');
99+
expect(response.body).toHaveProperty('content');
100+
expect(response.body.content).toContain('name: project');
101+
expect(response.body).toHaveProperty('size');
102+
expect(response.body).toHaveProperty('modified');
103+
});
104+
105+
it('should return 404 for non-existent file', async () => {
106+
const response = await request(server)
107+
.get('/api/dev/files/objects/nonexistent.yml')
108+
.expect(404);
109+
110+
expect(response.body).toHaveProperty('error');
111+
expect(response.body.error.message).toContain('File not found');
112+
});
113+
114+
it('should prevent path traversal', async () => {
115+
const response = await request(server)
116+
.get('/api/dev/files/../../../etc/passwd');
117+
118+
// Should either be 403 (access denied) or 404 (not found after normalization)
119+
expect([403, 404]).toContain(response.status);
120+
expect(response.body).toHaveProperty('error');
121+
});
122+
});
123+
124+
describe('PUT /api/dev/files/:path', () => {
125+
it('should update file content', async () => {
126+
const newContent = 'name: project\nlabel: Updated Project\nfields:\n name:\n type: text\n';
127+
128+
const response = await request(server)
129+
.put('/api/dev/files/objects/project.object.yml')
130+
.send({ content: newContent })
131+
.expect(200);
132+
133+
expect(response.body).toHaveProperty('message', 'File updated successfully');
134+
expect(response.body).toHaveProperty('path', 'objects/project.object.yml');
135+
136+
// Verify file was actually updated
137+
const actualContent = fs.readFileSync(
138+
path.join(srcDir, 'objects', 'project.object.yml'),
139+
'utf-8'
140+
);
141+
expect(actualContent).toBe(newContent);
142+
});
143+
144+
it('should return 404 for non-existent file', async () => {
145+
const response = await request(server)
146+
.put('/api/dev/files/objects/nonexistent.yml')
147+
.send({ content: 'test' })
148+
.expect(404);
149+
150+
expect(response.body).toHaveProperty('error');
151+
});
152+
153+
it('should prevent path traversal', async () => {
154+
const response = await request(server)
155+
.put('/api/dev/files/../../../tmp/malicious.txt')
156+
.send({ content: 'malicious' });
157+
158+
// Should either be 403 (access denied) or 404 (not found after normalization)
159+
expect([403, 404]).toContain(response.status);
160+
expect(response.body).toHaveProperty('error');
161+
});
162+
163+
it('should reject disallowed file extensions', async () => {
164+
// First create a .exe file manually
165+
const exePath = path.join(srcDir, 'test.exe');
166+
fs.writeFileSync(exePath, 'binary');
167+
168+
const response = await request(server)
169+
.put('/api/dev/files/test.exe')
170+
.send({ content: 'malicious' })
171+
.expect(403);
172+
173+
expect(response.body).toHaveProperty('error');
174+
expect(response.body.error.message).toContain('File type not allowed');
175+
});
176+
});
177+
178+
describe('POST /api/dev/files', () => {
179+
it('should create new file', async () => {
180+
const response = await request(server)
181+
.post('/api/dev/files')
182+
.send({
183+
path: 'objects/user.object.yml',
184+
content: 'name: user\nlabel: User\nfields:\n name:\n type: text\n'
185+
})
186+
.expect(201);
187+
188+
expect(response.body).toHaveProperty('message', 'File created successfully');
189+
expect(response.body).toHaveProperty('path', 'objects/user.object.yml');
190+
191+
// Verify file was created
192+
const filePath = path.join(srcDir, 'objects', 'user.object.yml');
193+
expect(fs.existsSync(filePath)).toBe(true);
194+
195+
const content = fs.readFileSync(filePath, 'utf-8');
196+
expect(content).toContain('name: user');
197+
});
198+
199+
it('should return 409 if file already exists', async () => {
200+
const response = await request(server)
201+
.post('/api/dev/files')
202+
.send({
203+
path: 'objects/project.object.yml',
204+
content: 'test'
205+
})
206+
.expect(409);
207+
208+
expect(response.body).toHaveProperty('error');
209+
expect(response.body.error.message).toContain('already exists');
210+
});
211+
212+
it('should create directory if needed', async () => {
213+
const response = await request(server)
214+
.post('/api/dev/files')
215+
.send({
216+
path: 'modules/crm/account.object.yml',
217+
content: 'name: account\n'
218+
})
219+
.expect(201);
220+
221+
// Verify directory was created
222+
const dirPath = path.join(srcDir, 'modules', 'crm');
223+
expect(fs.existsSync(dirPath)).toBe(true);
224+
});
225+
226+
it('should reject disallowed file extensions', async () => {
227+
const response = await request(server)
228+
.post('/api/dev/files')
229+
.send({
230+
path: 'malicious.exe',
231+
content: 'malicious'
232+
})
233+
.expect(403);
234+
235+
expect(response.body).toHaveProperty('error');
236+
expect(response.body.error.message).toContain('File type not allowed');
237+
});
238+
});
239+
240+
describe('DELETE /api/dev/files/:path', () => {
241+
it('should delete file', async () => {
242+
const response = await request(server)
243+
.delete('/api/dev/files/objects/task.object.yml')
244+
.expect(200);
245+
246+
expect(response.body).toHaveProperty('message', 'File deleted successfully');
247+
248+
// Verify file was deleted
249+
const filePath = path.join(srcDir, 'objects', 'task.object.yml');
250+
expect(fs.existsSync(filePath)).toBe(false);
251+
});
252+
253+
it('should return 404 for non-existent file', async () => {
254+
const response = await request(server)
255+
.delete('/api/dev/files/objects/nonexistent.yml')
256+
.expect(404);
257+
258+
expect(response.body).toHaveProperty('error');
259+
});
260+
261+
it('should prevent path traversal', async () => {
262+
const response = await request(server)
263+
.delete('/api/dev/files/../../../tmp/file.txt');
264+
265+
// Should either be 403 (access denied) or 404 (not found after normalization)
266+
expect([403, 404]).toContain(response.status);
267+
expect(response.body).toHaveProperty('error');
268+
});
269+
});
270+
271+
describe('GET /api/dev/metadata', () => {
272+
it('should return ObjectQL metadata', async () => {
273+
const response = await request(server)
274+
.get('/api/dev/metadata')
275+
.expect(200);
276+
277+
expect(response.body).toHaveProperty('objects');
278+
expect(response.body).toHaveProperty('validations');
279+
expect(response.body).toHaveProperty('permissions');
280+
expect(response.body).toHaveProperty('apps');
281+
expect(response.body).toHaveProperty('hooks');
282+
expect(response.body).toHaveProperty('actions');
283+
expect(response.body).toHaveProperty('total');
284+
285+
expect(Array.isArray(response.body.objects)).toBe(true);
286+
expect(response.body.objects.length).toBe(2); // project and task
287+
288+
expect(response.body.total.objects).toBe(2);
289+
});
290+
});
291+
292+
describe('Security', () => {
293+
it('should be disabled in production mode', async () => {
294+
const prodHandler = createDevHandler({
295+
baseDir: testDir,
296+
enabled: false
297+
});
298+
299+
const prodServer = createServer(prodHandler);
300+
301+
const response = await request(prodServer)
302+
.get('/api/dev/files')
303+
.expect(403);
304+
305+
expect(response.body).toHaveProperty('error');
306+
expect(response.body.error.message).toContain('development mode');
307+
308+
prodServer.close();
309+
});
310+
311+
it('should support CORS headers', async () => {
312+
const response = await request(server)
313+
.options('/api/dev/files')
314+
.expect(200);
315+
316+
expect(response.headers).toHaveProperty('access-control-allow-origin');
317+
expect(response.headers).toHaveProperty('access-control-allow-methods');
318+
});
319+
});
320+
});

0 commit comments

Comments
 (0)