project-graph-mcp 1.0.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.
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Test Annotations Parser
3
+ * Extracts @test/@expect JSDoc annotations for browser testing
4
+ */
5
+
6
+ import { readFileSync, readdirSync, statSync, writeFileSync } from 'fs';
7
+ import { join, basename, relative, resolve } from 'path';
8
+
9
+ /**
10
+ * @typedef {Object} TestStep
11
+ * @property {string} id - Unique ID (e.g., "togglePin.1")
12
+ * @property {string} type - Action type (click, key, drag, etc.)
13
+ * @property {string} description - What to do
14
+ * @property {boolean} completed - Whether test passed
15
+ * @property {string} [failReason] - Why it failed (if failed)
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} Feature
20
+ * @property {string} name - Method name
21
+ * @property {string} description - What the method does
22
+ * @property {TestStep[]} tests - Test steps
23
+ * @property {Array<{type: string, description: string}>} expects - Expected outcomes
24
+ * @property {string} file - Source file
25
+ * @property {number} line - Line number
26
+ */
27
+
28
+ // In-memory state for test progress
29
+ const testState = new Map();
30
+
31
+ /**
32
+ * Parse @test/@expect annotations from a file
33
+ * @param {string} content
34
+ * @param {string} filePath
35
+ * @returns {Feature[]}
36
+ */
37
+ export function parseAnnotations(content, filePath) {
38
+ const results = [];
39
+ const blockRegex = /\/\*\*([^]*?)\*\//g;
40
+
41
+ let match;
42
+ while ((match = blockRegex.exec(content)) !== null) {
43
+ const block = match[1];
44
+
45
+ // Check if block has @test or @expect
46
+ if (!block.includes('@test') && !block.includes('@expect')) continue;
47
+
48
+ // Find method name after the block
49
+ // Supports: methodName( | propName: ( | propName: async (
50
+ const afterBlock = content.slice(match.index + match[0].length);
51
+ const methodMatch = afterBlock.match(
52
+ /^\s*(?:async\s+)?(\w+)\s*\(/ // class method: methodName(
53
+ ) || afterBlock.match(
54
+ /^\s*(\w+)\s*:\s*(?:async\s*)?\(/ // arrow in object: propName: (
55
+ ) || afterBlock.match(
56
+ /^\s*(\w+)\s*:\s*(?:async\s+)?\(/ // arrow in object: propName: async (
57
+ );
58
+ if (!methodMatch) continue;
59
+
60
+ const methodName = methodMatch[1];
61
+
62
+ // Extract description (first line)
63
+ const descMatch = block.match(/^\s*\*\s*([^@\n][^\n]*)/m);
64
+ const description = descMatch ? descMatch[1].trim() : methodName;
65
+
66
+ // Extract @test annotations with unique IDs
67
+ const tests = [];
68
+ const testRegex = /@test\s+(\w+):\s*(.+)/g;
69
+ let testMatch;
70
+ let testIndex = 0;
71
+ while ((testMatch = testRegex.exec(block)) !== null) {
72
+ tests.push({
73
+ id: `${methodName}.${testIndex++}`,
74
+ type: testMatch[1],
75
+ description: testMatch[2].trim(),
76
+ completed: false,
77
+ failReason: null,
78
+ });
79
+ }
80
+
81
+ // Extract @expect annotations
82
+ const expects = [];
83
+ const expectRegex = /@expect\s+(\w+):\s*(.+)/g;
84
+ let expectMatch;
85
+ while ((expectMatch = expectRegex.exec(block)) !== null) {
86
+ expects.push({
87
+ type: expectMatch[1],
88
+ description: expectMatch[2].trim(),
89
+ });
90
+ }
91
+
92
+ if (tests.length || expects.length) {
93
+ const lineNumber = content.slice(0, match.index).split('\n').length;
94
+
95
+ results.push({
96
+ name: methodName,
97
+ description,
98
+ tests,
99
+ expects,
100
+ file: filePath,
101
+ line: lineNumber,
102
+ });
103
+ }
104
+ }
105
+
106
+ return results;
107
+ }
108
+
109
+ /**
110
+ * Find all JS files in directory
111
+ * @param {string} dir
112
+ * @returns {string[]}
113
+ */
114
+ function findJSFiles(dir) {
115
+ const files = [];
116
+
117
+ try {
118
+ for (const entry of readdirSync(dir)) {
119
+ const fullPath = join(dir, entry);
120
+ const stat = statSync(fullPath);
121
+
122
+ if (stat.isDirectory() && !entry.startsWith('.') && entry !== 'node_modules') {
123
+ files.push(...findJSFiles(fullPath));
124
+ } else if (entry.endsWith('.js') && !entry.endsWith('.css.js') && !entry.endsWith('.tpl.js')) {
125
+ files.push(fullPath);
126
+ }
127
+ }
128
+ } catch (e) {
129
+ // Directory not found
130
+ }
131
+
132
+ return files;
133
+ }
134
+
135
+ /**
136
+ * Get all features from a directory
137
+ * @param {string} dir
138
+ * @returns {Feature[]}
139
+ */
140
+ export function getAllFeatures(dir) {
141
+ const files = findJSFiles(dir);
142
+ const features = [];
143
+
144
+ for (const file of files) {
145
+ const content = readFileSync(file, 'utf-8');
146
+ const parsed = parseAnnotations(content, file);
147
+ features.push(...parsed);
148
+ }
149
+
150
+ return features;
151
+ }
152
+
153
+ /**
154
+ * Get pending (uncompleted) tests
155
+ * @param {string} dir
156
+ * @returns {TestStep[]}
157
+ */
158
+ export function getPendingTests(dir) {
159
+ const resolvedDir = resolve(dir);
160
+ const features = getAllFeatures(dir);
161
+ const pending = [];
162
+
163
+ for (const feature of features) {
164
+ for (const test of feature.tests) {
165
+ const state = testState.get(test.id);
166
+ if (!state || !state.completed) {
167
+ pending.push({
168
+ ...test,
169
+ feature: feature.name,
170
+ file: relative(resolvedDir, feature.file),
171
+ });
172
+ }
173
+ }
174
+ }
175
+
176
+ return pending;
177
+ }
178
+
179
+ /**
180
+ * Mark a test as passed
181
+ * @param {string} testId
182
+ */
183
+ export function markTestPassed(testId) {
184
+ testState.set(testId, { completed: true, passed: true });
185
+ return { success: true, testId };
186
+ }
187
+
188
+ /**
189
+ * Mark a test as failed
190
+ * @param {string} testId
191
+ * @param {string} reason
192
+ */
193
+ export function markTestFailed(testId, reason) {
194
+ testState.set(testId, { completed: true, passed: false, reason });
195
+ return { success: true, testId, reason };
196
+ }
197
+
198
+ /**
199
+ * Get test summary
200
+ * @param {string} dir
201
+ * @returns {Object}
202
+ */
203
+ export function getTestSummary(dir) {
204
+ const features = getAllFeatures(dir);
205
+
206
+ let total = 0;
207
+ let passed = 0;
208
+ let failed = 0;
209
+ let pending = 0;
210
+ const failures = [];
211
+
212
+ for (const feature of features) {
213
+ for (const test of feature.tests) {
214
+ total++;
215
+ const state = testState.get(test.id);
216
+
217
+ if (!state || !state.completed) {
218
+ pending++;
219
+ } else if (state.passed) {
220
+ passed++;
221
+ } else {
222
+ failed++;
223
+ failures.push({
224
+ id: test.id,
225
+ reason: state.reason,
226
+ });
227
+ }
228
+ }
229
+ }
230
+
231
+ return {
232
+ total,
233
+ passed,
234
+ failed,
235
+ pending,
236
+ progress: total > 0 ? Math.round((passed + failed) / total * 100) : 0,
237
+ failures,
238
+ };
239
+ }
240
+
241
+ /**
242
+ * Reset test state
243
+ */
244
+ export function resetTestState() {
245
+ testState.clear();
246
+ return { success: true };
247
+ }
248
+
249
+ /**
250
+ * Generate markdown checklist
251
+ * @param {Feature[]} features
252
+ * @returns {string}
253
+ */
254
+ export function generateMarkdown(features) {
255
+ const lines = [
256
+ '# Browser Test Checklist',
257
+ '',
258
+ `> Auto-generated from JSDoc @test/@expect annotations`,
259
+ `> Generated: ${new Date().toISOString().split('T')[0]}`,
260
+ '',
261
+ ];
262
+
263
+ // Group by file
264
+ const byFile = {};
265
+ for (const feature of features) {
266
+ const key = feature.file;
267
+ if (!byFile[key]) byFile[key] = [];
268
+ byFile[key].push(feature);
269
+ }
270
+
271
+ for (const [file, fileFeatures] of Object.entries(byFile)) {
272
+ lines.push(`## ${basename(file, '.js')}`);
273
+ lines.push('');
274
+
275
+ for (const feature of fileFeatures) {
276
+ lines.push(`### ${feature.name}()`);
277
+ lines.push(`${feature.description}`);
278
+ lines.push('');
279
+
280
+ if (feature.tests.length) {
281
+ lines.push('**Steps:**');
282
+ for (const test of feature.tests) {
283
+ const state = testState.get(test.id);
284
+ const check = state?.passed ? '[x]' : '[ ]';
285
+ lines.push(`- ${check} \`${test.type}\`: ${test.description}`);
286
+ }
287
+ lines.push('');
288
+ }
289
+
290
+ if (feature.expects.length) {
291
+ lines.push('**Expected:**');
292
+ for (const expect of feature.expects) {
293
+ lines.push(`- ✅ \`${expect.type}\`: ${expect.description}`);
294
+ }
295
+ lines.push('');
296
+ }
297
+ }
298
+ }
299
+
300
+ return lines.join('\n');
301
+ }