Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19d67cfda9 | |||
| 3810661098 | |||
| 8b83fb5c53 |
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import http from 'node:http';
|
||||
import { VisionLClient } from '../client.js';
|
||||
import type { ApiResponse, PageInfo } from '../types/api.js';
|
||||
|
||||
function createMockServer() {
|
||||
const server = http.createServer((req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
const url = new URL(req.url!, 'http://localhost');
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/health') {
|
||||
res.end(JSON.stringify({ status: 'ok' }));
|
||||
} else if (req.method === 'POST' && url.pathname === '/pages') {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => { body += chunk; });
|
||||
req.on('end', () => {
|
||||
const { url: pageUrl, alias } = JSON.parse(body);
|
||||
res.end(JSON.stringify({
|
||||
ok: true,
|
||||
data: { id: 'p_a1b2c3d4', url: pageUrl, alias, title: 'Test Page', status: 'active', profile: 'desktop-chrome' }
|
||||
}));
|
||||
});
|
||||
} else if (req.method === 'GET' && url.pathname === '/pages') {
|
||||
res.end(JSON.stringify({ ok: true, data: [{ id: 'p_test', url: 'https://test.com', title: 'Test', status: 'active' }] }));
|
||||
} else if (req.method === 'GET' && url.pathname.startsWith('/pages/p_test/text')) {
|
||||
res.end(JSON.stringify({ ok: true, data: { text: 'Hello World' } }));
|
||||
} else {
|
||||
res.statusCode = 404;
|
||||
res.end(JSON.stringify({ ok: false, error: { code: 'PAGE_NOT_FOUND', message: 'not found' } }));
|
||||
}
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
||||
describe('VisionLClient', () => {
|
||||
let server: http.Server;
|
||||
let client: VisionLClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = createMockServer();
|
||||
await new Promise<void>((resolve) => server.listen(19527, resolve));
|
||||
client = new VisionLClient('http://127.0.0.1:19527');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('health returns true when daemon is up', async () => {
|
||||
const result = await client.health();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('openPage returns page info', async () => {
|
||||
const result = await client.openPage('https://example.com', 'demo');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.data!.id).toBe('p_a1b2c3d4');
|
||||
expect(result.data!.alias).toBe('demo');
|
||||
});
|
||||
|
||||
it('listPages returns array', async () => {
|
||||
const result = await client.listPages();
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.data!).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('text returns page content', async () => {
|
||||
const result = await client.text('p_test');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.data!.text).toBe('Hello World');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { safeStringify, isValidJson } from '../escape.js';
|
||||
|
||||
describe('safeStringify', () => {
|
||||
it('returns valid JSON for simple objects', () => {
|
||||
const result = safeStringify({ ok: true, data: { id: 'p_123' } });
|
||||
expect(() => JSON.parse(result)).not.toThrow();
|
||||
expect(JSON.parse(result)).toEqual({ ok: true, data: { id: 'p_123' } });
|
||||
});
|
||||
|
||||
it('escapes double quotes in string values', () => {
|
||||
const result = safeStringify({ text: 'He said "hello"' });
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.text).toBe('He said "hello"');
|
||||
});
|
||||
|
||||
it('escapes backslashes in string values', () => {
|
||||
const result = safeStringify({ path: 'C:\\Users\\test' });
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.path).toBe('C:\\Users\\test');
|
||||
});
|
||||
|
||||
it('escapes control characters (newline, tab)', () => {
|
||||
const result = safeStringify({ text: 'line1\nline2\tindented' });
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.text).toBe('line1\nline2\tindented');
|
||||
});
|
||||
|
||||
it('handles unicode characters', () => {
|
||||
const result = safeStringify({ text: '你好世界 🌍' });
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.text).toBe('你好世界 🌍');
|
||||
});
|
||||
|
||||
it('handles HTML-like content without breaking JSON', () => {
|
||||
const html = '<div class="main">Hello</div>';
|
||||
const result = safeStringify({ html });
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.html).toBe(html);
|
||||
});
|
||||
|
||||
it('handles empty string and null', () => {
|
||||
expect(JSON.parse(safeStringify({ a: '' }))).toEqual({ a: '' });
|
||||
expect(JSON.parse(safeStringify({ a: null }))).toEqual({ a: null });
|
||||
});
|
||||
|
||||
it('handles arrays with special characters', () => {
|
||||
const result = safeStringify({ items: ['a"b', 'c\\d', 'e\nf'] });
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.items).toEqual(['a"b', 'c\\d', 'e\nf']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidJson', () => {
|
||||
it('returns true for valid JSON', () => {
|
||||
expect(isValidJson('{"ok":true}')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for invalid JSON', () => {
|
||||
expect(isValidJson('{ok:true}')).toBe(false);
|
||||
expect(isValidJson('')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ApiResponse, PageInfo } from './types/api.js';
|
||||
|
||||
export class VisionLClient {
|
||||
constructor(private baseUrl: string) {}
|
||||
|
||||
private async request<T>(method: string, path: string, body?: unknown): Promise<ApiResponse<T>> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
if (body !== undefined) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
const json = await response.json() as ApiResponse<T>;
|
||||
return json;
|
||||
}
|
||||
|
||||
async health(): Promise<boolean> {
|
||||
try {
|
||||
const res = await this.request<{ status: string }>('GET', '/health');
|
||||
return res.ok || (res.data as any)?.status === 'ok' || (res as any).status === 'ok';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async openPage(url: string, alias?: string, profile?: string): Promise<ApiResponse<PageInfo>> {
|
||||
return this.request<PageInfo>('POST', '/pages', { url, alias, profile });
|
||||
}
|
||||
|
||||
async listPages(): Promise<ApiResponse<PageInfo[]>> {
|
||||
return this.request<PageInfo[]>('GET', '/pages');
|
||||
}
|
||||
|
||||
async getPage(id: string): Promise<ApiResponse<PageInfo>> {
|
||||
return this.request<PageInfo>('GET', `/pages/${id}`);
|
||||
}
|
||||
|
||||
async killPage(id: string): Promise<ApiResponse<null>> {
|
||||
return this.request<null>('DELETE', `/pages/${id}`);
|
||||
}
|
||||
|
||||
async navigate(id: string, url: string): Promise<ApiResponse<{ url: string; title: string }>> {
|
||||
return this.request('POST', `/pages/${id}/navigate`, { url });
|
||||
}
|
||||
|
||||
async click(id: string, selector: string): Promise<ApiResponse<{ success: boolean }>> {
|
||||
return this.request('POST', `/pages/${id}/click`, { selector });
|
||||
}
|
||||
|
||||
async type(id: string, selector: string, text: string): Promise<ApiResponse<{ success: boolean }>> {
|
||||
return this.request('POST', `/pages/${id}/type`, { selector, text });
|
||||
}
|
||||
|
||||
async scroll(id: string, opts: { deltaY?: number; toBottom?: boolean }): Promise<ApiResponse<{ success: boolean }>> {
|
||||
return this.request('POST', `/pages/${id}/scroll`, opts);
|
||||
}
|
||||
|
||||
async eval(id: string, code: string): Promise<ApiResponse<{ result: unknown }>> {
|
||||
return this.request('POST', `/pages/${id}/eval`, { code });
|
||||
}
|
||||
|
||||
async wait(id: string, opts: { selector?: string; ms?: number }): Promise<ApiResponse<{ success: boolean }>> {
|
||||
return this.request('POST', `/pages/${id}/wait`, opts);
|
||||
}
|
||||
|
||||
async screenshot(id: string): Promise<ApiResponse<{ base64: string; mime: string }>> {
|
||||
return this.request('GET', `/pages/${id}/screenshot`);
|
||||
}
|
||||
|
||||
async text(id: string): Promise<ApiResponse<{ text: string }>> {
|
||||
return this.request('GET', `/pages/${id}/text`);
|
||||
}
|
||||
|
||||
async html(id: string): Promise<ApiResponse<{ html: string }>> {
|
||||
return this.request('GET', `/pages/${id}/html`);
|
||||
}
|
||||
|
||||
async getProfiles(): Promise<ApiResponse<Array<{ id: string; name: string }>>> {
|
||||
return this.request('GET', '/profiles');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// JSON safe serialization / JSON 安全序列化
|
||||
// Always use this instead of manual string concatenation for JSON output
|
||||
|
||||
export function safeStringify(obj: unknown): string {
|
||||
return JSON.stringify(obj);
|
||||
}
|
||||
|
||||
export function isValidJson(str: string): boolean {
|
||||
try {
|
||||
JSON.parse(str);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,5 @@ export {
|
||||
type CanvasNoiseConfig,
|
||||
type PermissionState,
|
||||
} from './types/fingerprint.js';
|
||||
export { safeStringify, isValidJson } from './escape.js';
|
||||
export { VisionLClient } from './client.js';
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import http from 'node:http';
|
||||
import { startServer } from '../server.js';
|
||||
|
||||
describe('daemon health endpoint', () => {
|
||||
let server: http.Server;
|
||||
const port = 19528;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await startServer(port);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('GET /health returns 200 with status ok', async () => {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/health`);
|
||||
expect(response.status).toBe(200);
|
||||
const json = await response.json();
|
||||
expect(json).toEqual({ status: 'ok' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
|
||||
export function healthRoute(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
const url = new URL(req.url || '/', 'http://localhost');
|
||||
if (req.method === 'GET' && url.pathname === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'ok' }));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import http from 'node:http';
|
||||
import { healthRoute } from './routes/health.js';
|
||||
|
||||
const routes = [healthRoute];
|
||||
|
||||
export function startServer(port: number): Promise<http.Server> {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
for (const route of routes) {
|
||||
if (route(req, res)) return;
|
||||
}
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: false, error: { code: 'INTERNAL', message: 'not found' } }));
|
||||
});
|
||||
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
console.log(`[daemon] VisionL daemon started on http://127.0.0.1:${port}`);
|
||||
resolve(server);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Direct start when running as script
|
||||
const port = parseInt(process.env.VISIONL_PORT || '9527', 10);
|
||||
startServer(port);
|
||||
Reference in New Issue
Block a user