yo-bug 0.1.0
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/LICENSE +21 -0
- package/README.md +160 -0
- package/bin/cli.ts +17 -0
- package/bin/install.ts +34 -0
- package/bin/mcp.ts +2 -0
- package/dist/bin/cli.d.ts +3 -0
- package/dist/bin/cli.d.ts.map +1 -0
- package/dist/bin/cli.js +19 -0
- package/dist/bin/cli.js.map +1 -0
- package/dist/bin/install.d.ts +3 -0
- package/dist/bin/install.d.ts.map +1 -0
- package/dist/bin/install.js +28 -0
- package/dist/bin/install.js.map +1 -0
- package/dist/bin/mcp.d.ts +3 -0
- package/dist/bin/mcp.d.ts.map +1 -0
- package/dist/bin/mcp.js +3 -0
- package/dist/bin/mcp.js.map +1 -0
- package/dist/src/detect/dev-server.d.ts +11 -0
- package/dist/src/detect/dev-server.d.ts.map +1 -0
- package/dist/src/detect/dev-server.js +92 -0
- package/dist/src/detect/dev-server.js.map +1 -0
- package/dist/src/detect/spawn-dev.d.ts +4 -0
- package/dist/src/detect/spawn-dev.d.ts.map +1 -0
- package/dist/src/detect/spawn-dev.js +37 -0
- package/dist/src/detect/spawn-dev.js.map +1 -0
- package/dist/src/install/detect-ai-tool.d.ts +7 -0
- package/dist/src/install/detect-ai-tool.d.ts.map +1 -0
- package/dist/src/install/detect-ai-tool.js +38 -0
- package/dist/src/install/detect-ai-tool.js.map +1 -0
- package/dist/src/install/write-mcp-config.d.ts +6 -0
- package/dist/src/install/write-mcp-config.d.ts.map +1 -0
- package/dist/src/install/write-mcp-config.js +47 -0
- package/dist/src/install/write-mcp-config.js.map +1 -0
- package/dist/src/mcp/index.d.ts +2 -0
- package/dist/src/mcp/index.d.ts.map +1 -0
- package/dist/src/mcp/index.js +263 -0
- package/dist/src/mcp/index.js.map +1 -0
- package/dist/src/mcp/tools/checklist.d.ts +22 -0
- package/dist/src/mcp/tools/checklist.d.ts.map +1 -0
- package/dist/src/mcp/tools/checklist.js +58 -0
- package/dist/src/mcp/tools/checklist.js.map +1 -0
- package/dist/src/mcp/tools/get-feedback.d.ts +13 -0
- package/dist/src/mcp/tools/get-feedback.d.ts.map +1 -0
- package/dist/src/mcp/tools/get-feedback.js +27 -0
- package/dist/src/mcp/tools/get-feedback.js.map +1 -0
- package/dist/src/mcp/tools/list-feedbacks.d.ts +20 -0
- package/dist/src/mcp/tools/list-feedbacks.d.ts.map +1 -0
- package/dist/src/mcp/tools/list-feedbacks.js +29 -0
- package/dist/src/mcp/tools/list-feedbacks.js.map +1 -0
- package/dist/src/mcp/tools/resolve-feedback.d.ts +8 -0
- package/dist/src/mcp/tools/resolve-feedback.d.ts.map +1 -0
- package/dist/src/mcp/tools/resolve-feedback.js +28 -0
- package/dist/src/mcp/tools/resolve-feedback.js.map +1 -0
- package/dist/src/mcp/tools/start-session.d.ts +11 -0
- package/dist/src/mcp/tools/start-session.d.ts.map +1 -0
- package/dist/src/mcp/tools/start-session.js +56 -0
- package/dist/src/mcp/tools/start-session.js.map +1 -0
- package/dist/src/mcp/tools/stop-session.d.ts +6 -0
- package/dist/src/mcp/tools/stop-session.d.ts.map +1 -0
- package/dist/src/mcp/tools/stop-session.js +80 -0
- package/dist/src/mcp/tools/stop-session.js.map +1 -0
- package/dist/src/mcp/tools/test-history.d.ts +33 -0
- package/dist/src/mcp/tools/test-history.d.ts.map +1 -0
- package/dist/src/mcp/tools/test-history.js +66 -0
- package/dist/src/mcp/tools/test-history.js.map +1 -0
- package/dist/src/server/feedback-api.d.ts +4 -0
- package/dist/src/server/feedback-api.d.ts.map +1 -0
- package/dist/src/server/feedback-api.js +152 -0
- package/dist/src/server/feedback-api.js.map +1 -0
- package/dist/src/server/html-injector.d.ts +10 -0
- package/dist/src/server/html-injector.d.ts.map +1 -0
- package/dist/src/server/html-injector.js +34 -0
- package/dist/src/server/html-injector.js.map +1 -0
- package/dist/src/server/proxy-server.d.ts +8 -0
- package/dist/src/server/proxy-server.d.ts.map +1 -0
- package/dist/src/server/proxy-server.js +87 -0
- package/dist/src/server/proxy-server.js.map +1 -0
- package/dist/src/server/sdk-serve.d.ts +3 -0
- package/dist/src/server/sdk-serve.d.ts.map +1 -0
- package/dist/src/server/sdk-serve.js +20 -0
- package/dist/src/server/sdk-serve.js.map +1 -0
- package/dist/src/storage/store.d.ts +21 -0
- package/dist/src/storage/store.d.ts.map +1 -0
- package/dist/src/storage/store.js +138 -0
- package/dist/src/storage/store.js.map +1 -0
- package/dist/src/storage/types.d.ts +74 -0
- package/dist/src/storage/types.d.ts.map +1 -0
- package/dist/src/storage/types.js +2 -0
- package/dist/src/storage/types.js.map +1 -0
- package/dist/vibe-feedback.js +399 -0
- package/package.json +67 -0
- package/src/client/annotation-mode/canvas-editor.ts +178 -0
- package/src/client/annotation-mode/history.ts +32 -0
- package/src/client/annotation-mode/region-selector.ts +123 -0
- package/src/client/annotation-mode/screenshot.ts +17 -0
- package/src/client/annotation-mode/toolbar.ts +139 -0
- package/src/client/annotation-mode/tools/arrow.ts +57 -0
- package/src/client/annotation-mode/tools/base-tool.ts +25 -0
- package/src/client/annotation-mode/tools/circle.ts +37 -0
- package/src/client/annotation-mode/tools/freehand.ts +23 -0
- package/src/client/annotation-mode/tools/rect.ts +32 -0
- package/src/client/annotation-mode/tools/text.ts +93 -0
- package/src/client/api/client.ts +48 -0
- package/src/client/capture/action-recorder.ts +157 -0
- package/src/client/capture/console-interceptor.ts +65 -0
- package/src/client/capture/context-buffer.ts +23 -0
- package/src/client/capture/error-interceptor.ts +52 -0
- package/src/client/capture/network-interceptor.ts +143 -0
- package/src/client/core/event-bus.ts +20 -0
- package/src/client/core/i18n.ts +83 -0
- package/src/client/core/sdk.ts +373 -0
- package/src/client/core/shadow-host.ts +27 -0
- package/src/client/element-mode/highlighter.ts +79 -0
- package/src/client/element-mode/inspector.ts +73 -0
- package/src/client/element-mode/selector.ts +22 -0
- package/src/client/index.ts +10 -0
- package/src/client/styles/sdk.css.ts +222 -0
- package/src/client/ui/checklist-panel.ts +279 -0
- package/src/client/ui/feedback-panel.ts +149 -0
- package/src/client/ui/floating-button.ts +103 -0
- package/src/client/ui/toast.ts +17 -0
- package/src/client/ui/verify-panel.ts +111 -0
- package/src/detect/dev-server.ts +110 -0
- package/src/detect/spawn-dev.ts +50 -0
- package/src/install/detect-ai-tool.ts +49 -0
- package/src/install/write-mcp-config.ts +49 -0
- package/src/mcp/index.ts +327 -0
- package/src/mcp/tools/checklist.ts +61 -0
- package/src/mcp/tools/get-feedback.ts +34 -0
- package/src/mcp/tools/list-feedbacks.ts +37 -0
- package/src/mcp/tools/resolve-feedback.ts +34 -0
- package/src/mcp/tools/start-session.ts +65 -0
- package/src/mcp/tools/stop-session.ts +93 -0
- package/src/mcp/tools/test-history.ts +97 -0
- package/src/server/feedback-api.ts +164 -0
- package/src/server/html-injector.ts +41 -0
- package/src/server/proxy-server.ts +107 -0
- package/src/server/sdk-serve.ts +24 -0
- package/src/storage/store.ts +172 -0
- package/src/storage/types.ts +68 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
const HISTORY_DIR = path.join(
|
|
5
|
+
process.env.HOME || process.env.USERPROFILE || '.',
|
|
6
|
+
'.yo-bug',
|
|
7
|
+
'test-history'
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
interface TestRecord {
|
|
11
|
+
module: string;
|
|
12
|
+
title: string;
|
|
13
|
+
items: any[];
|
|
14
|
+
results: { passed: number; failed: number; total: number };
|
|
15
|
+
failedItems: string[];
|
|
16
|
+
timestamp: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function saveTestRecord(args: {
|
|
20
|
+
module: string;
|
|
21
|
+
title: string;
|
|
22
|
+
items: any[];
|
|
23
|
+
results: { passed: number; failed: number; total: number };
|
|
24
|
+
failedItems: string[];
|
|
25
|
+
}): Promise<{ text: string }> {
|
|
26
|
+
await fs.mkdir(HISTORY_DIR, { recursive: true });
|
|
27
|
+
|
|
28
|
+
const record: TestRecord = {
|
|
29
|
+
...args,
|
|
30
|
+
timestamp: new Date().toISOString(),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Load existing records for this module
|
|
34
|
+
const filePath = path.join(HISTORY_DIR, `${sanitize(args.module)}.json`);
|
|
35
|
+
let records: TestRecord[] = [];
|
|
36
|
+
try {
|
|
37
|
+
const data = await fs.readFile(filePath, 'utf-8');
|
|
38
|
+
records = JSON.parse(data);
|
|
39
|
+
} catch {}
|
|
40
|
+
|
|
41
|
+
records.push(record);
|
|
42
|
+
// Keep last 20 records per module
|
|
43
|
+
if (records.length > 20) records = records.slice(-20);
|
|
44
|
+
|
|
45
|
+
await fs.writeFile(filePath, JSON.stringify(records, null, 2), 'utf-8');
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
text: `测试记录已保存。模块「${args.module}」累计 ${records.length} 次测试记录。`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function getTestHistory(args: {
|
|
53
|
+
module: string;
|
|
54
|
+
}): Promise<{ text: string; records: TestRecord[] }> {
|
|
55
|
+
const filePath = path.join(HISTORY_DIR, `${sanitize(args.module)}.json`);
|
|
56
|
+
let records: TestRecord[] = [];
|
|
57
|
+
try {
|
|
58
|
+
const data = await fs.readFile(filePath, 'utf-8');
|
|
59
|
+
records = JSON.parse(data);
|
|
60
|
+
} catch {
|
|
61
|
+
return { text: `模块「${args.module}」没有历史测试记录。`, records: [] };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const lines = [`模块「${args.module}」历史测试记录(${records.length} 次):\n`];
|
|
65
|
+
|
|
66
|
+
for (const r of records.slice(-5)) { // Show last 5
|
|
67
|
+
lines.push(`[${r.timestamp.slice(0, 10)}] ${r.title}`);
|
|
68
|
+
lines.push(` 结果: ${r.results.passed}/${r.results.total} 通过`);
|
|
69
|
+
if (r.failedItems.length > 0) {
|
|
70
|
+
lines.push(` 失败项: ${r.failedItems.join('; ')}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Aggregate: which test scenarios fail most often?
|
|
75
|
+
const failCounts: Record<string, number> = {};
|
|
76
|
+
for (const r of records) {
|
|
77
|
+
for (const f of r.failedItems) {
|
|
78
|
+
failCounts[f] = (failCounts[f] || 0) + 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const frequentFails = Object.entries(failCounts)
|
|
82
|
+
.sort((a, b) => b[1] - a[1])
|
|
83
|
+
.slice(0, 5);
|
|
84
|
+
|
|
85
|
+
if (frequentFails.length > 0) {
|
|
86
|
+
lines.push('\n高频失败场景(生成清单时请重点覆盖):');
|
|
87
|
+
for (const [scenario, count] of frequentFails) {
|
|
88
|
+
lines.push(` ${count}x 失败: ${scenario}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { text: lines.join('\n'), records };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function sanitize(name: string): string {
|
|
96
|
+
return name.replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]/g, '_').slice(0, 50);
|
|
97
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import multer from 'multer';
|
|
3
|
+
import { FeedbackStore } from '../storage/store.js';
|
|
4
|
+
|
|
5
|
+
const upload = multer({ storage: multer.memoryStorage() });
|
|
6
|
+
|
|
7
|
+
export function createFeedbackRouter(store: FeedbackStore): Router {
|
|
8
|
+
const router = Router();
|
|
9
|
+
|
|
10
|
+
// Submit feedback
|
|
11
|
+
router.post('/api/feedback', upload.single('screenshot'), async (req, res) => {
|
|
12
|
+
try {
|
|
13
|
+
let feedbackData: any;
|
|
14
|
+
|
|
15
|
+
if (req.body.feedback) {
|
|
16
|
+
// multipart form: feedback JSON string + screenshot file
|
|
17
|
+
feedbackData = JSON.parse(req.body.feedback);
|
|
18
|
+
} else {
|
|
19
|
+
// plain JSON body
|
|
20
|
+
feedbackData = req.body;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Sanitize: only allow known fields, strip anything dangerous
|
|
24
|
+
const sanitized = {
|
|
25
|
+
mode: feedbackData.mode,
|
|
26
|
+
problemType: feedbackData.problemType,
|
|
27
|
+
description: String(feedbackData.description || '').slice(0, 2000),
|
|
28
|
+
element: feedbackData.element,
|
|
29
|
+
pageUrl: String(feedbackData.pageUrl || '').slice(0, 500),
|
|
30
|
+
pageTitle: String(feedbackData.pageTitle || '').slice(0, 200),
|
|
31
|
+
viewport: feedbackData.viewport,
|
|
32
|
+
userAgent: String(feedbackData.userAgent || '').slice(0, 300),
|
|
33
|
+
timestamp: feedbackData.timestamp,
|
|
34
|
+
consoleErrors: Array.isArray(feedbackData.consoleErrors) ? feedbackData.consoleErrors.slice(0, 50) : [],
|
|
35
|
+
networkErrors: Array.isArray(feedbackData.networkErrors) ? feedbackData.networkErrors.slice(0, 50) : [],
|
|
36
|
+
unhandledErrors: Array.isArray(feedbackData.unhandledErrors) ? feedbackData.unhandledErrors.slice(0, 50) : [],
|
|
37
|
+
actionSteps: Array.isArray(feedbackData.actionSteps) ? feedbackData.actionSteps.slice(0, 100) : [],
|
|
38
|
+
hasScreenshot: false,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const screenshot = req.file?.buffer;
|
|
42
|
+
const item = await store.create(sanitized, screenshot);
|
|
43
|
+
|
|
44
|
+
res.json({ id: item.id, createdAt: item.createdAt });
|
|
45
|
+
} catch (err: any) {
|
|
46
|
+
res.status(400).json({ error: err.message });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// List feedbacks
|
|
51
|
+
router.get('/api/feedback', async (req, res) => {
|
|
52
|
+
try {
|
|
53
|
+
const result = await store.list({
|
|
54
|
+
limit: Number(req.query.limit) || 20,
|
|
55
|
+
status: (req.query.status as string) || 'open',
|
|
56
|
+
type: req.query.type as string,
|
|
57
|
+
});
|
|
58
|
+
res.json(result);
|
|
59
|
+
} catch (err: any) {
|
|
60
|
+
res.status(500).json({ error: err.message });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Get single feedback
|
|
65
|
+
router.get('/api/feedback/:id', async (req, res) => {
|
|
66
|
+
try {
|
|
67
|
+
const item = await store.getById(req.params.id);
|
|
68
|
+
if (!item) {
|
|
69
|
+
return res.status(404).json({ error: 'Not found' });
|
|
70
|
+
}
|
|
71
|
+
res.json(item);
|
|
72
|
+
} catch (err: any) {
|
|
73
|
+
res.status(500).json({ error: err.message });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Get screenshot
|
|
78
|
+
router.get('/api/feedback/:id/screenshot', async (req, res) => {
|
|
79
|
+
try {
|
|
80
|
+
const screenshot = await store.getScreenshot(req.params.id);
|
|
81
|
+
if (!screenshot) {
|
|
82
|
+
return res.status(404).json({ error: 'No screenshot' });
|
|
83
|
+
}
|
|
84
|
+
res.setHeader('Content-Type', 'image/png');
|
|
85
|
+
res.end(screenshot);
|
|
86
|
+
} catch (err: any) {
|
|
87
|
+
res.status(500).json({ error: err.message });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// --- Checklist API (AI → Browser → AI) ---
|
|
92
|
+
let checklist: { items: any[]; title: string; createdAt: string } = {
|
|
93
|
+
items: [],
|
|
94
|
+
title: '',
|
|
95
|
+
createdAt: '',
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// AI pushes checklist (supports both string[] and structured items)
|
|
99
|
+
router.post('/api/checklist', (req, res) => {
|
|
100
|
+
const { title, items } = req.body;
|
|
101
|
+
checklist = {
|
|
102
|
+
title: title || 'Test Checklist',
|
|
103
|
+
items: (items || []).map((item: any, i: number) => ({
|
|
104
|
+
id: i,
|
|
105
|
+
step: typeof item === 'string' ? item : (item.step || item.text || ''),
|
|
106
|
+
expect: typeof item === 'string' ? '' : (item.expect || ''),
|
|
107
|
+
priority: (typeof item === 'object' && item.priority) || 'normal',
|
|
108
|
+
dimension: (typeof item === 'object' && item.dimension) || '',
|
|
109
|
+
status: 'pending',
|
|
110
|
+
feedback: '',
|
|
111
|
+
})),
|
|
112
|
+
createdAt: new Date().toISOString(),
|
|
113
|
+
};
|
|
114
|
+
res.json({ ok: true, count: checklist.items.length });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Browser polls checklist
|
|
118
|
+
router.get('/api/checklist', (_req, res) => {
|
|
119
|
+
res.json(checklist);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Browser updates a checklist item
|
|
123
|
+
router.patch('/api/checklist/:itemId', (req, res) => {
|
|
124
|
+
const id = parseInt(req.params.itemId);
|
|
125
|
+
const item = checklist.items.find((i: any) => i.id === id);
|
|
126
|
+
if (!item) return res.status(404).json({ error: 'Not found' });
|
|
127
|
+
if (req.body.status) item.status = req.body.status;
|
|
128
|
+
if (req.body.feedback !== undefined) item.feedback = req.body.feedback;
|
|
129
|
+
res.json(item);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// --- Verification API (AI → Browser → AI) ---
|
|
133
|
+
let pendingVerifications: any[] = [];
|
|
134
|
+
|
|
135
|
+
// AI pushes verification request
|
|
136
|
+
router.post('/api/verify', (req, res) => {
|
|
137
|
+
pendingVerifications.push(req.body);
|
|
138
|
+
res.json({ ok: true });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Browser polls for pending verifications
|
|
142
|
+
router.get('/api/verify', (_req, res) => {
|
|
143
|
+
res.json({ items: pendingVerifications });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Browser confirms/rejects verification
|
|
147
|
+
router.patch('/api/verify/:feedbackId', async (req, res) => {
|
|
148
|
+
const { feedbackId } = req.params;
|
|
149
|
+
const { confirmed } = req.body; // true = fix works, false = still broken
|
|
150
|
+
const newStatus = confirmed ? 'resolved' : 'open';
|
|
151
|
+
await store.updateStatus(feedbackId, newStatus);
|
|
152
|
+
pendingVerifications = pendingVerifications.filter(v => v.feedbackId !== feedbackId);
|
|
153
|
+
res.json({ ok: true, status: newStatus });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Reset in-memory state
|
|
157
|
+
router.post('/api/reset', (_req, res) => {
|
|
158
|
+
checklist = { items: [], title: '', createdAt: '' };
|
|
159
|
+
pendingVerifications = [];
|
|
160
|
+
res.json({ ok: true });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
return router;
|
|
164
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'http';
|
|
2
|
+
|
|
3
|
+
const INJECT_SCRIPT = `<script src="/vibe-feedback.js"></script>`;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Middleware that intercepts HTML responses from the proxy
|
|
7
|
+
* and injects the vibe-feedback SDK script before </body>.
|
|
8
|
+
*/
|
|
9
|
+
export function createHtmlInjector(proxyRes: IncomingMessage, res: ServerResponse) {
|
|
10
|
+
const contentType = proxyRes.headers['content-type'] || '';
|
|
11
|
+
if (!contentType.includes('text/html')) {
|
|
12
|
+
return null; // Not HTML, don't intercept
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Copy all response headers except content-length (we'll modify the body)
|
|
16
|
+
res.writeHead(proxyRes.statusCode || 200, Object.fromEntries(
|
|
17
|
+
Object.entries(proxyRes.headers).filter(([key]) => key !== 'content-length')
|
|
18
|
+
));
|
|
19
|
+
|
|
20
|
+
const chunks: Buffer[] = [];
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
onData(chunk: Buffer) {
|
|
24
|
+
chunks.push(chunk);
|
|
25
|
+
},
|
|
26
|
+
onEnd() {
|
|
27
|
+
let html = Buffer.concat(chunks).toString('utf-8');
|
|
28
|
+
|
|
29
|
+
// Inject before </body> or at end if no </body>
|
|
30
|
+
if (html.includes('</body>')) {
|
|
31
|
+
html = html.replace('</body>', `${INJECT_SCRIPT}\n</body>`);
|
|
32
|
+
} else if (html.includes('</html>')) {
|
|
33
|
+
html = html.replace('</html>', `${INJECT_SCRIPT}\n</html>`);
|
|
34
|
+
} else {
|
|
35
|
+
html += `\n${INJECT_SCRIPT}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
res.end(html);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import cors from 'cors';
|
|
3
|
+
import httpProxy from 'http-proxy';
|
|
4
|
+
import type { Server } from 'http';
|
|
5
|
+
import { FeedbackStore } from '../storage/store.js';
|
|
6
|
+
import { createFeedbackRouter } from './feedback-api.js';
|
|
7
|
+
import { createSdkRouter } from './sdk-serve.js';
|
|
8
|
+
import { createHtmlInjector } from './html-injector.js';
|
|
9
|
+
|
|
10
|
+
const PROXY_PORT = 3695;
|
|
11
|
+
|
|
12
|
+
let server: Server | null = null;
|
|
13
|
+
let proxy: httpProxy | null = null;
|
|
14
|
+
|
|
15
|
+
export async function startProxyServer(
|
|
16
|
+
targetPort: number,
|
|
17
|
+
store: FeedbackStore
|
|
18
|
+
): Promise<{ proxyUrl: string; targetUrl: string }> {
|
|
19
|
+
if (server) {
|
|
20
|
+
throw new Error('Proxy server is already running');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const targetUrl = `http://localhost:${targetPort}`;
|
|
24
|
+
const proxyUrl = `http://localhost:${PROXY_PORT}`;
|
|
25
|
+
|
|
26
|
+
const app = express();
|
|
27
|
+
|
|
28
|
+
// CORS for SDK to submit feedback
|
|
29
|
+
app.use(cors());
|
|
30
|
+
|
|
31
|
+
// Parse JSON for feedback API
|
|
32
|
+
app.use(express.json({ limit: '10mb' }));
|
|
33
|
+
|
|
34
|
+
// Serve SDK JS file
|
|
35
|
+
app.use(createSdkRouter());
|
|
36
|
+
|
|
37
|
+
// Feedback API routes
|
|
38
|
+
app.use(createFeedbackRouter(store));
|
|
39
|
+
|
|
40
|
+
// Create proxy
|
|
41
|
+
proxy = httpProxy.createProxyServer({
|
|
42
|
+
target: targetUrl,
|
|
43
|
+
ws: true, // WebSocket support for HMR
|
|
44
|
+
changeOrigin: true,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
proxy.on('error', (_err, _req, res) => {
|
|
48
|
+
if (res && 'writeHead' in res) {
|
|
49
|
+
(res as any).writeHead?.(502, { 'Content-Type': 'text/plain' });
|
|
50
|
+
(res as any).end?.('Dev server not responding');
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Intercept proxy responses to inject SDK into HTML
|
|
55
|
+
proxy.on('proxyRes', (proxyRes, _req, res) => {
|
|
56
|
+
const injector = createHtmlInjector(proxyRes, res as any);
|
|
57
|
+
if (injector) {
|
|
58
|
+
// HTML response — buffer and inject
|
|
59
|
+
proxyRes.on('data', (chunk) => injector.onData(chunk));
|
|
60
|
+
proxyRes.on('end', () => injector.onEnd());
|
|
61
|
+
} else {
|
|
62
|
+
// Non-HTML — pipe through directly
|
|
63
|
+
proxyRes.pipe(res as any);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// All other requests → proxy to dev server
|
|
68
|
+
app.all('*', (req, res) => {
|
|
69
|
+
proxy!.web(req, res, {
|
|
70
|
+
selfHandleResponse: true, // We handle response in proxyRes event
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Start server
|
|
75
|
+
await new Promise<void>((resolve, reject) => {
|
|
76
|
+
server = app.listen(PROXY_PORT, () => resolve());
|
|
77
|
+
server!.on('error', (err: NodeJS.ErrnoException) => {
|
|
78
|
+
if (err.code === 'EADDRINUSE') {
|
|
79
|
+
reject(new Error(`Port ${PROXY_PORT} is already in use. Stop the other process or restart.`));
|
|
80
|
+
} else {
|
|
81
|
+
reject(err);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Handle WebSocket upgrade for HMR
|
|
87
|
+
server!.on('upgrade', (req: any, socket: any, head: any) => {
|
|
88
|
+
proxy!.ws(req, socket, head);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return { proxyUrl, targetUrl };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function stopProxyServer(): void {
|
|
95
|
+
if (proxy) {
|
|
96
|
+
proxy.close();
|
|
97
|
+
proxy = null;
|
|
98
|
+
}
|
|
99
|
+
if (server) {
|
|
100
|
+
server.close();
|
|
101
|
+
server = null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function isProxyRunning(): boolean {
|
|
106
|
+
return server !== null;
|
|
107
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
|
|
6
|
+
export function createSdkRouter(): Router {
|
|
7
|
+
const router = Router();
|
|
8
|
+
|
|
9
|
+
router.get('/vibe-feedback.js', (_req, res) => {
|
|
10
|
+
// Resolve SDK path relative to this file's location
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const sdkPath = path.resolve(__dirname, '../../dist/vibe-feedback.js');
|
|
13
|
+
|
|
14
|
+
if (!fs.existsSync(sdkPath)) {
|
|
15
|
+
return res.status(404).send('// SDK not built yet');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
res.setHeader('Content-Type', 'application/javascript');
|
|
19
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
20
|
+
fs.createReadStream(sdkPath).pipe(res);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
return router;
|
|
24
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
4
|
+
import type { FeedbackItem } from './types.js';
|
|
5
|
+
|
|
6
|
+
const ID_PATTERN = /^[a-f0-9\-]{8,36}$/;
|
|
7
|
+
|
|
8
|
+
function validateId(id: string): boolean {
|
|
9
|
+
return ID_PATTERN.test(id) && !id.includes('.') && !id.includes('/') && !id.includes('\\');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const STORAGE_DIR = path.join(
|
|
13
|
+
process.env.HOME || process.env.USERPROFILE || '.',
|
|
14
|
+
'.yo-bug',
|
|
15
|
+
'feedback'
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
export class FeedbackStore {
|
|
19
|
+
private indexPath: string;
|
|
20
|
+
|
|
21
|
+
constructor() {
|
|
22
|
+
this.indexPath = path.join(STORAGE_DIR, 'index.json');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async init(): Promise<void> {
|
|
26
|
+
await fs.mkdir(STORAGE_DIR, { recursive: true });
|
|
27
|
+
try {
|
|
28
|
+
await fs.access(this.indexPath);
|
|
29
|
+
} catch {
|
|
30
|
+
await fs.writeFile(this.indexPath, '[]', 'utf-8');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async create(
|
|
35
|
+
data: Omit<FeedbackItem, 'id' | 'createdAt' | 'status'>,
|
|
36
|
+
screenshot?: Buffer
|
|
37
|
+
): Promise<FeedbackItem> {
|
|
38
|
+
const id = uuidv4().slice(0, 12);
|
|
39
|
+
const item: FeedbackItem = {
|
|
40
|
+
...data,
|
|
41
|
+
id,
|
|
42
|
+
createdAt: new Date().toISOString(),
|
|
43
|
+
status: 'open',
|
|
44
|
+
hasScreenshot: !!screenshot,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Write feedback JSON
|
|
48
|
+
await fs.writeFile(
|
|
49
|
+
path.join(STORAGE_DIR, `${id}.json`),
|
|
50
|
+
JSON.stringify(item, null, 2),
|
|
51
|
+
'utf-8'
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// Write screenshot if provided
|
|
55
|
+
if (screenshot) {
|
|
56
|
+
await fs.writeFile(path.join(STORAGE_DIR, `${id}.png`), screenshot);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Update index (append-only line to avoid race conditions)
|
|
60
|
+
const indexEntry = JSON.stringify({
|
|
61
|
+
id: item.id,
|
|
62
|
+
mode: item.mode,
|
|
63
|
+
problemType: item.problemType,
|
|
64
|
+
description: item.description,
|
|
65
|
+
pageUrl: item.pageUrl,
|
|
66
|
+
status: item.status,
|
|
67
|
+
createdAt: item.createdAt,
|
|
68
|
+
hasScreenshot: item.hasScreenshot,
|
|
69
|
+
});
|
|
70
|
+
await fs.appendFile(
|
|
71
|
+
path.join(STORAGE_DIR, 'index.ndjson'),
|
|
72
|
+
indexEntry + '\n',
|
|
73
|
+
'utf-8'
|
|
74
|
+
);
|
|
75
|
+
// Also update the JSON index for backward compat
|
|
76
|
+
const index = await this.readIndex();
|
|
77
|
+
index.push(JSON.parse(indexEntry));
|
|
78
|
+
await fs.writeFile(this.indexPath, JSON.stringify(index, null, 2), 'utf-8');
|
|
79
|
+
|
|
80
|
+
return item;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async list(options?: {
|
|
84
|
+
limit?: number;
|
|
85
|
+
status?: string;
|
|
86
|
+
type?: string;
|
|
87
|
+
}): Promise<{ items: FeedbackItem[]; total: number }> {
|
|
88
|
+
const index = await this.readIndex();
|
|
89
|
+
let filtered = index;
|
|
90
|
+
|
|
91
|
+
if (options?.status && options.status !== 'all') {
|
|
92
|
+
filtered = filtered.filter((i: any) => i.status === options.status);
|
|
93
|
+
}
|
|
94
|
+
if (options?.type) {
|
|
95
|
+
filtered = filtered.filter((i: any) => i.problemType === options.type);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const total = filtered.length;
|
|
99
|
+
const limit = options?.limit || 20;
|
|
100
|
+
const items = filtered.slice(-limit).reverse(); // Most recent first
|
|
101
|
+
|
|
102
|
+
// Load full items
|
|
103
|
+
const fullItems: FeedbackItem[] = [];
|
|
104
|
+
for (const entry of items) {
|
|
105
|
+
const item = await this.getById(entry.id);
|
|
106
|
+
if (item) fullItems.push(item);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { items: fullItems, total };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async getById(id: string): Promise<FeedbackItem | null> {
|
|
113
|
+
if (!validateId(id)) return null;
|
|
114
|
+
try {
|
|
115
|
+
const data = await fs.readFile(
|
|
116
|
+
path.join(STORAGE_DIR, `${id}.json`),
|
|
117
|
+
'utf-8'
|
|
118
|
+
);
|
|
119
|
+
return JSON.parse(data);
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async getScreenshot(id: string): Promise<Buffer | null> {
|
|
126
|
+
if (!validateId(id)) return null;
|
|
127
|
+
try {
|
|
128
|
+
return await fs.readFile(path.join(STORAGE_DIR, `${id}.png`));
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async updateStatus(id: string, status: 'open' | 'verify' | 'resolved'): Promise<void> {
|
|
135
|
+
if (!validateId(id)) return;
|
|
136
|
+
const item = await this.getById(id);
|
|
137
|
+
if (!item) return;
|
|
138
|
+
|
|
139
|
+
item.status = status;
|
|
140
|
+
await fs.writeFile(
|
|
141
|
+
path.join(STORAGE_DIR, `${id}.json`),
|
|
142
|
+
JSON.stringify(item, null, 2),
|
|
143
|
+
'utf-8'
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// Update index
|
|
147
|
+
const index = await this.readIndex();
|
|
148
|
+
const entry = index.find((i: any) => i.id === id);
|
|
149
|
+
if (entry) {
|
|
150
|
+
entry.status = status;
|
|
151
|
+
await fs.writeFile(this.indexPath, JSON.stringify(index, null, 2), 'utf-8');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async clear(): Promise<void> {
|
|
156
|
+
const index = await this.readIndex();
|
|
157
|
+
for (const entry of index) {
|
|
158
|
+
try { await fs.unlink(path.join(STORAGE_DIR, `${entry.id}.json`)); } catch {}
|
|
159
|
+
try { await fs.unlink(path.join(STORAGE_DIR, `${entry.id}.png`)); } catch {}
|
|
160
|
+
}
|
|
161
|
+
await fs.writeFile(this.indexPath, '[]', 'utf-8');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private async readIndex(): Promise<any[]> {
|
|
165
|
+
try {
|
|
166
|
+
const data = await fs.readFile(this.indexPath, 'utf-8');
|
|
167
|
+
return JSON.parse(data);
|
|
168
|
+
} catch {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export interface ElementInfo {
|
|
2
|
+
selector: string;
|
|
3
|
+
tagName: string;
|
|
4
|
+
textContent: string;
|
|
5
|
+
rect: { x: number; y: number; width: number; height: number };
|
|
6
|
+
computedStyles: Record<string, string>;
|
|
7
|
+
attributes: Record<string, string>;
|
|
8
|
+
componentName: string | null;
|
|
9
|
+
componentFile: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ConsoleEntry {
|
|
13
|
+
type: 'error' | 'warn';
|
|
14
|
+
message: string;
|
|
15
|
+
stack?: string;
|
|
16
|
+
timestamp: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface NetworkEntry {
|
|
20
|
+
type: 'fetch' | 'xhr';
|
|
21
|
+
method: string;
|
|
22
|
+
url: string;
|
|
23
|
+
status: number;
|
|
24
|
+
statusText: string;
|
|
25
|
+
duration: number;
|
|
26
|
+
timestamp: number;
|
|
27
|
+
error?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ErrorEntry {
|
|
31
|
+
type: 'unhandled-error' | 'unhandled-rejection';
|
|
32
|
+
message: string;
|
|
33
|
+
stack?: string;
|
|
34
|
+
timestamp: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ActionStep {
|
|
38
|
+
type: 'click' | 'input' | 'navigate' | 'scroll' | 'keypress';
|
|
39
|
+
timestamp: number;
|
|
40
|
+
target?: string;
|
|
41
|
+
tagName?: string;
|
|
42
|
+
text?: string;
|
|
43
|
+
url?: string;
|
|
44
|
+
position?: { x: number; y: number };
|
|
45
|
+
elapsed?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type ProblemType = 'bug' | 'ui-issue' | 'performance' | 'feature-request' | 'other';
|
|
49
|
+
|
|
50
|
+
export interface FeedbackItem {
|
|
51
|
+
id: string;
|
|
52
|
+
mode: 'element' | 'annotation';
|
|
53
|
+
problemType: ProblemType;
|
|
54
|
+
description: string;
|
|
55
|
+
element?: ElementInfo;
|
|
56
|
+
hasScreenshot: boolean;
|
|
57
|
+
pageUrl: string;
|
|
58
|
+
pageTitle: string;
|
|
59
|
+
viewport: { width: number; height: number };
|
|
60
|
+
userAgent: string;
|
|
61
|
+
timestamp: number;
|
|
62
|
+
createdAt: string;
|
|
63
|
+
consoleErrors: ConsoleEntry[];
|
|
64
|
+
networkErrors: NetworkEntry[];
|
|
65
|
+
unhandledErrors: ErrorEntry[];
|
|
66
|
+
actionSteps: ActionStep[];
|
|
67
|
+
status: 'open' | 'verify' | 'resolved';
|
|
68
|
+
}
|