@gotza02/sequential-thinking 10000.0.0 → 10000.0.2
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/README.md +6 -0
- package/dist/chaos.test.d.ts +1 -0
- package/dist/chaos.test.js +73 -0
- package/dist/codestore.test.d.ts +1 -0
- package/dist/codestore.test.js +65 -0
- package/dist/coding.test.d.ts +1 -0
- package/dist/coding.test.js +140 -0
- package/dist/e2e.test.d.ts +1 -0
- package/dist/e2e.test.js +122 -0
- package/dist/filesystem.test.d.ts +1 -0
- package/dist/filesystem.test.js +190 -0
- package/dist/graph.test.d.ts +1 -0
- package/dist/graph.test.js +150 -0
- package/dist/graph_extra.test.d.ts +1 -0
- package/dist/graph_extra.test.js +93 -0
- package/dist/graph_repro.test.d.ts +1 -0
- package/dist/graph_repro.test.js +50 -0
- package/dist/human.test.d.ts +1 -0
- package/dist/human.test.js +221 -0
- package/dist/integration.test.d.ts +1 -0
- package/dist/integration.test.js +58 -0
- package/dist/knowledge.test.d.ts +1 -0
- package/dist/knowledge.test.js +105 -0
- package/dist/lib.js +1 -0
- package/dist/notes.test.d.ts +1 -0
- package/dist/notes.test.js +84 -0
- package/dist/registration.test.d.ts +1 -0
- package/dist/registration.test.js +39 -0
- package/dist/server.test.d.ts +1 -0
- package/dist/server.test.js +127 -0
- package/dist/stress.test.d.ts +1 -0
- package/dist/stress.test.js +72 -0
- package/dist/tools/codestore_tools.test.d.ts +1 -0
- package/dist/tools/codestore_tools.test.js +115 -0
- package/dist/tools/filesystem.js +1 -0
- package/dist/tools/sports/core/constants.d.ts +2 -1
- package/dist/tools/sports/core/constants.js +18 -6
- package/dist/tools/sports/providers/scraper.d.ts +6 -1
- package/dist/tools/sports/providers/scraper.js +63 -8
- package/dist/tools/sports/tools/match.js +44 -21
- package/dist/tools/sports/tracker.test.d.ts +1 -0
- package/dist/tools/sports/tracker.test.js +100 -0
- package/dist/utils.test.d.ts +1 -0
- package/dist/utils.test.js +40 -0
- package/dist/verify_cache.test.d.ts +1 -0
- package/dist/verify_cache.test.js +185 -0
- package/dist/web_fallback.test.d.ts +1 -0
- package/dist/web_fallback.test.js +103 -0
- package/dist/web_read.test.d.ts +1 -0
- package/dist/web_read.test.js +60 -0
- package/package.json +7 -6
|
@@ -85,19 +85,62 @@ export class ScraperProvider extends ScraperProviderBase {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
/**
|
|
88
|
-
* Specialized extractor for Understat
|
|
88
|
+
* Specialized extractor for Understat
|
|
89
|
+
* Extracts xG, shots, and other advanced metrics from the JSON data embedded in script tags.
|
|
89
90
|
*/
|
|
90
91
|
extractUnderstatData(html) {
|
|
91
92
|
const dom = new JSDOM(html);
|
|
92
93
|
const title = dom.window.document.title;
|
|
93
|
-
|
|
94
|
-
//
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
let output = `## Understat Analysis: ${title}\n\n`;
|
|
95
|
+
// Helper to extract JSON from script variables
|
|
96
|
+
const extractJson = (varName) => {
|
|
97
|
+
const scripts = Array.from(dom.window.document.querySelectorAll('script'));
|
|
98
|
+
for (const script of scripts) {
|
|
99
|
+
const content = script.textContent || '';
|
|
100
|
+
const regex = new RegExp(`var ${varName}\\s*=\\s*JSON\\.parse\\('([^']+)'\\)`);
|
|
101
|
+
const match = content.match(regex);
|
|
102
|
+
if (match && match[1]) {
|
|
103
|
+
try {
|
|
104
|
+
// Decode hex sequences if any (Understat sometimes uses hex encoding)
|
|
105
|
+
const jsonString = match[1].replace(/\\x([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
106
|
+
return JSON.parse(jsonString);
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
};
|
|
115
|
+
const matchData = extractJson('match_info');
|
|
116
|
+
const rosterData = extractJson('rostersData');
|
|
117
|
+
if (matchData) {
|
|
118
|
+
output += `### Match Overview\n`;
|
|
119
|
+
output += `**Team:** ${matchData.h_team} vs ${matchData.a_team}\n`;
|
|
120
|
+
output += `**Score:** ${matchData.h_goals} - ${matchData.a_goals}\n`;
|
|
121
|
+
output += `**xG:** ${matchData.h_xg} - ${matchData.a_xg}\n`;
|
|
122
|
+
output += `**Probabilities:** Home Win: ${matchData.h_win}, Draw: ${matchData.draw}, Away Win: ${matchData.a_win}\n\n`;
|
|
99
123
|
}
|
|
100
|
-
|
|
124
|
+
if (rosterData) {
|
|
125
|
+
output += `### Key Player Stats (xG > 0.1)\n`;
|
|
126
|
+
const processPlayers = (players, teamName) => {
|
|
127
|
+
output += `**${teamName}:**\n`;
|
|
128
|
+
Object.values(players).forEach((p) => {
|
|
129
|
+
if (parseFloat(p.xG) > 0.1) {
|
|
130
|
+
output += `- ${p.player}: xG ${p.xG}, xA ${p.xA}, Shots: ${p.shots}\n`;
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
output += '\n';
|
|
134
|
+
};
|
|
135
|
+
if (rosterData.h)
|
|
136
|
+
processPlayers(rosterData.h, matchData?.h_team || 'Home');
|
|
137
|
+
if (rosterData.a)
|
|
138
|
+
processPlayers(rosterData.a, matchData?.a_team || 'Away');
|
|
139
|
+
}
|
|
140
|
+
if (!matchData && !rosterData) {
|
|
141
|
+
return `Title: ${title}\n\n(Understat extraction failed - JSON data not found)`;
|
|
142
|
+
}
|
|
143
|
+
return output;
|
|
101
144
|
}
|
|
102
145
|
/**
|
|
103
146
|
* Specialized extractor for WhoScored (Example)
|
|
@@ -216,3 +259,15 @@ export function findBestMatchUrl(urls) {
|
|
|
216
259
|
// Return first URL if no priority match
|
|
217
260
|
return urls[0] || null;
|
|
218
261
|
}
|
|
262
|
+
/**
|
|
263
|
+
* Find best URL for news/lineups from search results
|
|
264
|
+
*/
|
|
265
|
+
export function findBestNewsUrl(urls) {
|
|
266
|
+
const newsDomains = SCRAPER_CONFIG.NEWS_DOMAINS;
|
|
267
|
+
for (const url of urls) {
|
|
268
|
+
if (newsDomains.some(domain => url.includes(domain))) {
|
|
269
|
+
return url;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import { createAPIProvider } from '../providers/api.js';
|
|
7
7
|
import { getSearchProvider } from '../providers/search.js';
|
|
8
|
-
import { scrapeMatchContent, findBestMatchUrl } from '../providers/scraper.js';
|
|
8
|
+
import { scrapeMatchContent, findBestMatchUrl, findBestNewsUrl } from '../providers/scraper.js';
|
|
9
9
|
import { getGlobalCache, CacheService } from '../core/cache.js';
|
|
10
10
|
import { formatMatchesTable, formatScore, formatMatchStatus } from '../utils/formatter.js';
|
|
11
11
|
import { CACHE_CONFIG, LEAGUES } from '../core/constants.js';
|
|
@@ -76,7 +76,7 @@ async function performMatchAnalysis(homeTeam, awayTeam, league, context) {
|
|
|
76
76
|
{ type: 'form', query: `${homeTeam} ${awayTeam} recent form last 5 matches${dateQuery}`, title: 'Recent Form' },
|
|
77
77
|
{ type: 'news', query: `${baseQuery} team news injuries lineups${dateQuery}`, title: 'Team News & Lineups' },
|
|
78
78
|
{ type: 'stats', query: `${baseQuery} xG expected goals stats${dateQuery}`, title: 'Advanced Metrics' },
|
|
79
|
-
{ type: 'odds', query: `${baseQuery} odds
|
|
79
|
+
{ type: 'odds', query: `${baseQuery} Asian Handicap odds prediction today${dateQuery}`, title: 'Latest Handicap Odds' },
|
|
80
80
|
{ type: 'fatigue', query: `${homeTeam} ${awayTeam} days rest fixture congestion${dateQuery}`, title: 'Fatigue & Schedule' },
|
|
81
81
|
{ type: 'setpieces', query: `${baseQuery} set pieces corners aerial duels${dateQuery}`, title: 'Set Pieces' },
|
|
82
82
|
];
|
|
@@ -111,24 +111,45 @@ async function performMatchAnalysis(homeTeam, awayTeam, league, context) {
|
|
|
111
111
|
const batchResults = await Promise.all(batchPromises);
|
|
112
112
|
combinedResults += batchResults.join('\n');
|
|
113
113
|
}
|
|
114
|
-
// Deep dive: Scrape the best
|
|
114
|
+
// Deep dive: Scrape the best URLs (Dual-Source Strategy)
|
|
115
115
|
if (candidateUrls.length > 0) {
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
116
|
+
const bestStatsUrl = findBestMatchUrl(candidateUrls);
|
|
117
|
+
const bestNewsUrl = findBestNewsUrl(candidateUrls);
|
|
118
|
+
// 1. Scrape Stats Source (Priority)
|
|
119
|
+
if (bestStatsUrl) {
|
|
120
|
+
combinedResults += `\n--- DEEP DIVE ANALYSIS: STATS & DATA (${bestStatsUrl}) ---\n`;
|
|
119
121
|
try {
|
|
120
|
-
const scrapedContent = await scrapeMatchContent(
|
|
122
|
+
const scrapedContent = await scrapeMatchContent(bestStatsUrl);
|
|
121
123
|
if (scrapedContent.success) {
|
|
122
124
|
combinedResults += scrapedContent.data || '';
|
|
123
125
|
}
|
|
124
126
|
else {
|
|
125
|
-
combinedResults += `(
|
|
127
|
+
combinedResults += `(Stats scrape failed: ${scrapedContent.error})`;
|
|
126
128
|
}
|
|
127
129
|
}
|
|
128
130
|
catch (scrapeError) {
|
|
129
|
-
combinedResults += `(
|
|
131
|
+
combinedResults += `(Stats scrape failed: ${scrapeError instanceof Error ? scrapeError.message : String(scrapeError)})`;
|
|
130
132
|
}
|
|
131
|
-
combinedResults += `\n--- END
|
|
133
|
+
combinedResults += `\n--- END STATS ---\n`;
|
|
134
|
+
}
|
|
135
|
+
// 2. Scrape News Source (if different from stats)
|
|
136
|
+
if (bestNewsUrl && bestNewsUrl !== bestStatsUrl) {
|
|
137
|
+
combinedResults += `\n--- DEEP DIVE ANALYSIS: NEWS & LINEUPS (${bestNewsUrl}) ---\n`;
|
|
138
|
+
try {
|
|
139
|
+
const scrapedContent = await scrapeMatchContent(bestNewsUrl);
|
|
140
|
+
if (scrapedContent.success) {
|
|
141
|
+
// Truncate news to avoid blowing up context, keep first 3000 chars
|
|
142
|
+
const newsContent = scrapedContent.data?.substring(0, 3000) || '';
|
|
143
|
+
combinedResults += newsContent + (scrapedContent.data && scrapedContent.data.length > 3000 ? '\n...(truncated)' : '');
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
combinedResults += `(News scrape failed: ${scrapedContent.error})`;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (scrapeError) {
|
|
150
|
+
combinedResults += `(News scrape failed: ${scrapeError instanceof Error ? scrapeError.message : String(scrapeError)})`;
|
|
151
|
+
}
|
|
152
|
+
combinedResults += `\n--- END NEWS ---\n`;
|
|
132
153
|
}
|
|
133
154
|
}
|
|
134
155
|
combinedResults += `\n--- END DATA ---\n\n`;
|
|
@@ -161,19 +182,16 @@ Market Intelligence, Referee stats, Weather, Fatigue analysis, Set Pieces.`, {
|
|
|
161
182
|
}, async ({ homeTeam, awayTeam, league, context, useApi }) => {
|
|
162
183
|
const errors = [];
|
|
163
184
|
let analysisResult = null;
|
|
185
|
+
// Check API availability explicitly
|
|
186
|
+
const api = createAPIProvider();
|
|
187
|
+
const apiAvailable = api.getStatus().available;
|
|
188
|
+
const forcedFallback = useApi && !apiAvailable;
|
|
164
189
|
// Try API providers first if requested
|
|
165
|
-
if (useApi) {
|
|
190
|
+
if (useApi && apiAvailable) {
|
|
166
191
|
try {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
// Could try to fetch match data from API here
|
|
171
|
-
// For now, we'll use the search-based analysis as fallback
|
|
172
|
-
analysisResult = await performMatchAnalysis(homeTeam, awayTeam, league, context);
|
|
173
|
-
}
|
|
174
|
-
else {
|
|
175
|
-
errors.push('API providers not configured');
|
|
176
|
-
}
|
|
192
|
+
// Could try to fetch match data from API here
|
|
193
|
+
// For now, we'll use the search-based analysis as fallback
|
|
194
|
+
analysisResult = await performMatchAnalysis(homeTeam, awayTeam, league, context);
|
|
177
195
|
}
|
|
178
196
|
catch (error) {
|
|
179
197
|
errors.push(`API error: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -183,6 +201,11 @@ Market Intelligence, Referee stats, Weather, Fatigue analysis, Set Pieces.`, {
|
|
|
183
201
|
if (!analysisResult) {
|
|
184
202
|
analysisResult = await performMatchAnalysis(homeTeam, awayTeam, league, context);
|
|
185
203
|
}
|
|
204
|
+
// Add warning if running in forced fallback mode
|
|
205
|
+
if (forcedFallback && analysisResult) {
|
|
206
|
+
const warning = "⚠️ **WARNING: Fallback Mode Active**\nNo API keys configured. Using web search and scraping (accuracy may vary).\n\n";
|
|
207
|
+
analysisResult = warning + analysisResult;
|
|
208
|
+
}
|
|
186
209
|
return {
|
|
187
210
|
content: [{ type: 'text', text: analysisResult }],
|
|
188
211
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
|
|
2
|
+
import { PredictionTracker } from './core/tracker.js';
|
|
3
|
+
import * as fs from 'fs/promises';
|
|
4
|
+
const TEST_LOG_FILE = 'test_betting_log.json';
|
|
5
|
+
describe('ROI Tracker System', () => {
|
|
6
|
+
let tracker;
|
|
7
|
+
beforeEach(async () => {
|
|
8
|
+
// Use a test file instead of the real one
|
|
9
|
+
tracker = new PredictionTracker(TEST_LOG_FILE);
|
|
10
|
+
// Clear previous test data
|
|
11
|
+
try {
|
|
12
|
+
await fs.unlink(TEST_LOG_FILE);
|
|
13
|
+
}
|
|
14
|
+
catch { }
|
|
15
|
+
});
|
|
16
|
+
afterAll(async () => {
|
|
17
|
+
try {
|
|
18
|
+
await fs.unlink(TEST_LOG_FILE);
|
|
19
|
+
}
|
|
20
|
+
catch { }
|
|
21
|
+
});
|
|
22
|
+
it('should track a new prediction', async () => {
|
|
23
|
+
const bet = await tracker.track({
|
|
24
|
+
league: 'Premier League',
|
|
25
|
+
homeTeam: 'Arsenal',
|
|
26
|
+
awayTeam: 'Man City',
|
|
27
|
+
selection: 'Over 2.5 Goals',
|
|
28
|
+
odds: 1.85,
|
|
29
|
+
stake: 10,
|
|
30
|
+
confidence: 80,
|
|
31
|
+
analysis: 'High scoring teams',
|
|
32
|
+
date: new Date().toISOString()
|
|
33
|
+
});
|
|
34
|
+
expect(bet.id).toBeDefined();
|
|
35
|
+
expect(bet.status).toBe('pending');
|
|
36
|
+
expect(bet.profit).toBeUndefined();
|
|
37
|
+
const pending = await tracker.list('pending');
|
|
38
|
+
expect(pending.length).toBe(1);
|
|
39
|
+
expect(pending[0].id).toBe(bet.id);
|
|
40
|
+
});
|
|
41
|
+
it('should resolve a prediction and calculate profit correctly', async () => {
|
|
42
|
+
const bet = await tracker.track({
|
|
43
|
+
league: 'Premier League',
|
|
44
|
+
homeTeam: 'Liverpool',
|
|
45
|
+
awayTeam: 'Chelsea',
|
|
46
|
+
selection: 'Liverpool Win',
|
|
47
|
+
odds: 2.0,
|
|
48
|
+
stake: 50,
|
|
49
|
+
confidence: 90,
|
|
50
|
+
analysis: 'Form is good',
|
|
51
|
+
date: new Date().toISOString()
|
|
52
|
+
});
|
|
53
|
+
// Resolve as WON
|
|
54
|
+
const resolved = await tracker.resolve(bet.id, 'won', '2-0');
|
|
55
|
+
expect(resolved).toBeDefined();
|
|
56
|
+
expect(resolved.status).toBe('won');
|
|
57
|
+
expect(resolved.profit).toBe(50); // (50 * 2.0) - 50 = 50
|
|
58
|
+
expect(resolved.resultScore).toBe('2-0');
|
|
59
|
+
// Check Stats
|
|
60
|
+
const stats = await tracker.getStats();
|
|
61
|
+
expect(stats.totalBets).toBe(1);
|
|
62
|
+
expect(stats.wins).toBe(1);
|
|
63
|
+
expect(stats.totalProfit).toBe(50);
|
|
64
|
+
expect(stats.roi).toBe(100); // (50/50)*100
|
|
65
|
+
});
|
|
66
|
+
it('should handle lost bets correctly', async () => {
|
|
67
|
+
const bet = await tracker.track({
|
|
68
|
+
league: 'La Liga',
|
|
69
|
+
homeTeam: 'Real Madrid',
|
|
70
|
+
awayTeam: 'Barca',
|
|
71
|
+
selection: 'Real Win',
|
|
72
|
+
odds: 2.5,
|
|
73
|
+
stake: 100,
|
|
74
|
+
confidence: 60,
|
|
75
|
+
analysis: 'El Classico',
|
|
76
|
+
date: new Date().toISOString()
|
|
77
|
+
});
|
|
78
|
+
// Resolve as LOST
|
|
79
|
+
const resolved = await tracker.resolve(bet.id, 'lost', '0-3');
|
|
80
|
+
expect(resolved.status).toBe('lost');
|
|
81
|
+
expect(resolved.profit).toBe(-100);
|
|
82
|
+
const stats = await tracker.getStats();
|
|
83
|
+
expect(stats.totalProfit).toBe(-100);
|
|
84
|
+
expect(stats.roi).toBe(-100); // (-100/100)*100
|
|
85
|
+
});
|
|
86
|
+
it('should filter stats by league', async () => {
|
|
87
|
+
await tracker.track({
|
|
88
|
+
league: 'EPL', homeTeam: 'A', awayTeam: 'B', selection: 'Win', odds: 2.0, stake: 10, confidence: 50, analysis: '.', date: new Date().toISOString()
|
|
89
|
+
}).then(b => tracker.resolve(b.id, 'won')); // Profit +10
|
|
90
|
+
await tracker.track({
|
|
91
|
+
league: 'La Liga', homeTeam: 'C', awayTeam: 'D', selection: 'Win', odds: 2.0, stake: 10, confidence: 50, analysis: '.', date: new Date().toISOString()
|
|
92
|
+
}).then(b => tracker.resolve(b.id, 'lost')); // Profit -10
|
|
93
|
+
const eplStats = await tracker.getStats({ league: 'EPL' });
|
|
94
|
+
expect(eplStats.totalProfit).toBe(10);
|
|
95
|
+
const ligaStats = await tracker.getStats({ league: 'La Liga' });
|
|
96
|
+
expect(ligaStats.totalProfit).toBe(-10);
|
|
97
|
+
const allStats = await tracker.getStats();
|
|
98
|
+
expect(allStats.totalProfit).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
|
+
import { validatePath } from './utils.js';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
describe('Utils: validatePath', () => {
|
|
5
|
+
// Mock process.cwd to be a known fixed path
|
|
6
|
+
const mockCwd = '/app/project';
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
vi.spyOn(process, 'cwd').mockReturnValue(mockCwd);
|
|
9
|
+
});
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.restoreAllMocks();
|
|
12
|
+
});
|
|
13
|
+
it('should allow paths within the project root', () => {
|
|
14
|
+
const p = validatePath('src/index.ts');
|
|
15
|
+
expect(p).toBe(path.resolve(mockCwd, 'src/index.ts'));
|
|
16
|
+
});
|
|
17
|
+
it('should allow explicit ./ paths', () => {
|
|
18
|
+
const p = validatePath('./package.json');
|
|
19
|
+
expect(p).toBe(path.resolve(mockCwd, 'package.json'));
|
|
20
|
+
});
|
|
21
|
+
it('should block traversal to parent directory', () => {
|
|
22
|
+
expect(() => {
|
|
23
|
+
validatePath('../outside.txt');
|
|
24
|
+
}).toThrow(/Access denied/);
|
|
25
|
+
});
|
|
26
|
+
it('should block multiple level traversal', () => {
|
|
27
|
+
expect(() => {
|
|
28
|
+
validatePath('src/../../etc/passwd');
|
|
29
|
+
}).toThrow(/Access denied/);
|
|
30
|
+
});
|
|
31
|
+
it('should block absolute paths outside root', () => {
|
|
32
|
+
// Only run this check if we can reliably simulate absolute paths
|
|
33
|
+
// For now, let's assume standard unix paths for the test logic
|
|
34
|
+
if (path.sep === '/') {
|
|
35
|
+
expect(() => {
|
|
36
|
+
validatePath('/etc/passwd');
|
|
37
|
+
}).toThrow(/Access denied/);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { ProjectKnowledgeGraph } from './graph.js';
|
|
3
|
+
import * as fs from 'fs/promises';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import * as os from 'os';
|
|
6
|
+
describe('Graph Caching Verification', () => {
|
|
7
|
+
const root = process.cwd();
|
|
8
|
+
const cachePath = path.join(root, '.gemini_graph_cache.json');
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
// Cleanup existing cache before each test
|
|
11
|
+
try {
|
|
12
|
+
await fs.unlink(cachePath);
|
|
13
|
+
}
|
|
14
|
+
catch (e) { }
|
|
15
|
+
});
|
|
16
|
+
it('should use cache on second run', async () => {
|
|
17
|
+
const graph = new ProjectKnowledgeGraph();
|
|
18
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'graph-cache-test-'));
|
|
19
|
+
const testFile = path.join(tempDir, 'main.ts');
|
|
20
|
+
try {
|
|
21
|
+
// Create a dummy file to scan
|
|
22
|
+
await fs.writeFile(testFile, 'export const val = 1;', 'utf-8');
|
|
23
|
+
console.log('--- Run 1 (Fresh) ---');
|
|
24
|
+
const res1 = await graph.build(tempDir);
|
|
25
|
+
console.log('Result 1:', res1);
|
|
26
|
+
expect(res1.parsedFiles).toBeGreaterThan(0);
|
|
27
|
+
expect(res1.cachedFiles).toBe(0);
|
|
28
|
+
console.log('--- Run 2 (Cached) ---');
|
|
29
|
+
const res2 = await graph.build(tempDir);
|
|
30
|
+
console.log('Result 2:', res2);
|
|
31
|
+
expect(res2.parsedFiles).toBe(0);
|
|
32
|
+
expect(res2.cachedFiles).toBeGreaterThan(0);
|
|
33
|
+
expect(res2.nodeCount).toBe(res1.nodeCount);
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
// Cleanup
|
|
37
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
it('should invalidate cache when file mtime changes', async () => {
|
|
41
|
+
const graph = new ProjectKnowledgeGraph();
|
|
42
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'graph-mtime-test-'));
|
|
43
|
+
const testFile = path.join(tempDir, 'test.ts');
|
|
44
|
+
try {
|
|
45
|
+
// Create initial file
|
|
46
|
+
await fs.writeFile(testFile, 'export const foo = 1;', 'utf-8');
|
|
47
|
+
// Build 1: Fresh
|
|
48
|
+
const res1 = await graph.build(tempDir);
|
|
49
|
+
expect(res1.parsedFiles).toBe(1);
|
|
50
|
+
expect(res1.cachedFiles).toBe(0);
|
|
51
|
+
console.log('Build 1 (fresh):', res1);
|
|
52
|
+
// Build 2: Should use cache
|
|
53
|
+
const res2 = await graph.build(tempDir);
|
|
54
|
+
expect(res2.parsedFiles).toBe(0);
|
|
55
|
+
expect(res2.cachedFiles).toBe(1);
|
|
56
|
+
console.log('Build 2 (cached):', res2);
|
|
57
|
+
// Modify file content (this changes mtime)
|
|
58
|
+
await new Promise(resolve => setTimeout(resolve, 50)); // Small delay to ensure mtime changes
|
|
59
|
+
await fs.writeFile(testFile, 'export const bar = 2;', 'utf-8');
|
|
60
|
+
// Build 3: Should detect mtime change and re-parse
|
|
61
|
+
const res3 = await graph.build(tempDir);
|
|
62
|
+
expect(res3.parsedFiles).toBe(1); // File should be re-parsed due to mtime change
|
|
63
|
+
expect(res3.cachedFiles).toBe(0);
|
|
64
|
+
console.log('Build 3 (mtime changed):', res3);
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
// Cleanup
|
|
68
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
it('should detect new files added after cache was built', async () => {
|
|
72
|
+
const graph = new ProjectKnowledgeGraph();
|
|
73
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'graph-newfile-test-'));
|
|
74
|
+
const file1 = path.join(tempDir, 'a.ts');
|
|
75
|
+
const file2 = path.join(tempDir, 'b.ts');
|
|
76
|
+
try {
|
|
77
|
+
// Create initial file
|
|
78
|
+
await fs.writeFile(file1, 'export const a = 1;', 'utf-8');
|
|
79
|
+
// Build 1: Only one file
|
|
80
|
+
const res1 = await graph.build(tempDir);
|
|
81
|
+
expect(res1.nodeCount).toBe(1);
|
|
82
|
+
expect(res1.totalFiles).toBe(1);
|
|
83
|
+
console.log('Build 1 (1 file):', res1);
|
|
84
|
+
// Add new file
|
|
85
|
+
await fs.writeFile(file2, 'export const b = 2;', 'utf-8');
|
|
86
|
+
// Build 2: Should detect new file
|
|
87
|
+
const res2 = await graph.build(tempDir);
|
|
88
|
+
expect(res2.nodeCount).toBe(2);
|
|
89
|
+
expect(res2.totalFiles).toBe(2);
|
|
90
|
+
expect(res2.parsedFiles).toBe(1); // Only the new file should be parsed
|
|
91
|
+
expect(res2.cachedFiles).toBe(1); // Old file should use cache
|
|
92
|
+
console.log('Build 2 (new file added):', res2);
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
// Cleanup
|
|
96
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
it('should handle deleted files by pruning from cache', async () => {
|
|
100
|
+
const graph = new ProjectKnowledgeGraph();
|
|
101
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'graph-delete-test-'));
|
|
102
|
+
const file1 = path.join(tempDir, 'keep.ts');
|
|
103
|
+
const file2 = path.join(tempDir, 'delete.ts');
|
|
104
|
+
try {
|
|
105
|
+
// Create two files
|
|
106
|
+
await fs.writeFile(file1, 'export const keep = 1;', 'utf-8');
|
|
107
|
+
await fs.writeFile(file2, 'export const deleted = 2;', 'utf-8');
|
|
108
|
+
// Build 1: Two files
|
|
109
|
+
const res1 = await graph.build(tempDir);
|
|
110
|
+
expect(res1.nodeCount).toBe(2);
|
|
111
|
+
console.log('Build 1 (2 files):', res1);
|
|
112
|
+
// Delete one file
|
|
113
|
+
await fs.unlink(file2);
|
|
114
|
+
// Build 2: Should only have one file, cache pruned
|
|
115
|
+
const res2 = await graph.build(tempDir);
|
|
116
|
+
expect(res2.nodeCount).toBe(1);
|
|
117
|
+
expect(res2.totalFiles).toBe(1);
|
|
118
|
+
console.log('Build 2 (after delete):', res2);
|
|
119
|
+
// Verify cache file doesn't have deleted file
|
|
120
|
+
const cacheContent = JSON.parse(await fs.readFile(path.join(tempDir, '.gemini_graph_cache.json'), 'utf-8'));
|
|
121
|
+
expect(cacheContent.files[file2]).toBeUndefined();
|
|
122
|
+
expect(cacheContent.files[file1]).toBeDefined();
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
// Cleanup
|
|
126
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
it('should force rebuild when forceRebuild() is called', async () => {
|
|
130
|
+
const graph = new ProjectKnowledgeGraph();
|
|
131
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'graph-force-test-'));
|
|
132
|
+
const testFile = path.join(tempDir, 'test.ts');
|
|
133
|
+
try {
|
|
134
|
+
// Create file
|
|
135
|
+
await fs.writeFile(testFile, 'export const x = 1;', 'utf-8');
|
|
136
|
+
// Build 1: Fresh
|
|
137
|
+
const res1 = await graph.build(tempDir);
|
|
138
|
+
expect(res1.parsedFiles).toBe(1);
|
|
139
|
+
console.log('Build 1 (fresh):', res1);
|
|
140
|
+
// Build 2: Should use cache
|
|
141
|
+
const res2 = await graph.build(tempDir);
|
|
142
|
+
expect(res2.parsedFiles).toBe(0);
|
|
143
|
+
expect(res2.cachedFiles).toBe(1);
|
|
144
|
+
console.log('Build 2 (cached):', res2);
|
|
145
|
+
// Force rebuild: Should ignore cache
|
|
146
|
+
const res3 = await graph.forceRebuild(tempDir);
|
|
147
|
+
expect(res3.parsedFiles).toBe(1); // Force re-parse everything
|
|
148
|
+
expect(res3.cachedFiles).toBe(0);
|
|
149
|
+
console.log('Build 3 (force):', res3);
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
// Cleanup
|
|
153
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
it('should have consistent results between cached and fresh builds', async () => {
|
|
157
|
+
const graph = new ProjectKnowledgeGraph();
|
|
158
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'graph-consistent-test-'));
|
|
159
|
+
const file1 = path.join(tempDir, 'main.ts');
|
|
160
|
+
const file2 = path.join(tempDir, 'utils.ts');
|
|
161
|
+
try {
|
|
162
|
+
// Create files with imports
|
|
163
|
+
await fs.writeFile(file2, 'export function helper() { return 42; }', 'utf-8');
|
|
164
|
+
await fs.writeFile(file1, 'import { helper } from "./utils.js";\nexport const result = helper();', 'utf-8');
|
|
165
|
+
// Build 1: Fresh
|
|
166
|
+
const res1 = await graph.build(tempDir);
|
|
167
|
+
const rel1 = graph.getRelationships('main.ts');
|
|
168
|
+
// Build 2: Cached
|
|
169
|
+
const res2 = await graph.build(tempDir);
|
|
170
|
+
const rel2 = graph.getRelationships('main.ts');
|
|
171
|
+
// Force rebuild: Fresh again
|
|
172
|
+
const res3 = await graph.forceRebuild(tempDir);
|
|
173
|
+
const rel3 = graph.getRelationships('main.ts');
|
|
174
|
+
// All should have same relationships
|
|
175
|
+
expect(rel1).toEqual(rel2);
|
|
176
|
+
expect(rel2).toEqual(rel3);
|
|
177
|
+
expect(rel1?.imports.length).toBeGreaterThan(0);
|
|
178
|
+
console.log('Relationships consistent:', JSON.stringify(rel1, null, 2));
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
// Cleanup
|
|
182
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { registerWebTools } from './tools/web.js';
|
|
3
|
+
import * as utils from './utils.js';
|
|
4
|
+
// Mock utils
|
|
5
|
+
vi.mock('./utils.js', async (importOriginal) => {
|
|
6
|
+
const actual = await importOriginal();
|
|
7
|
+
return {
|
|
8
|
+
...actual,
|
|
9
|
+
fetchWithRetry: vi.fn(),
|
|
10
|
+
};
|
|
11
|
+
});
|
|
12
|
+
describe('web_search fallback', () => {
|
|
13
|
+
let mockToolCallback;
|
|
14
|
+
const mockServer = {
|
|
15
|
+
tool: vi.fn((name, desc, schema, callback) => {
|
|
16
|
+
if (name === 'web_search') {
|
|
17
|
+
mockToolCallback = callback;
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
};
|
|
21
|
+
const originalEnv = process.env;
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
process.env = { ...originalEnv };
|
|
24
|
+
vi.clearAllMocks();
|
|
25
|
+
});
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
process.env = originalEnv;
|
|
28
|
+
});
|
|
29
|
+
it('should use Brave if configured and no provider specified', async () => {
|
|
30
|
+
process.env.BRAVE_API_KEY = 'test-brave-key';
|
|
31
|
+
delete process.env.EXA_API_KEY;
|
|
32
|
+
delete process.env.GOOGLE_SEARCH_API_KEY;
|
|
33
|
+
registerWebTools(mockServer);
|
|
34
|
+
const mockResponse = {
|
|
35
|
+
ok: true,
|
|
36
|
+
json: async () => ({ web: { results: ['brave result'] } })
|
|
37
|
+
};
|
|
38
|
+
utils.fetchWithRetry.mockResolvedValue(mockResponse);
|
|
39
|
+
const result = await mockToolCallback({ query: 'test' });
|
|
40
|
+
expect(utils.fetchWithRetry).toHaveBeenCalledWith(expect.stringContaining('api.search.brave.com'), expect.anything());
|
|
41
|
+
expect(result.isError).toBeUndefined();
|
|
42
|
+
expect(JSON.parse(result.content[0].text)).toEqual(['brave result']);
|
|
43
|
+
});
|
|
44
|
+
it('should fallback to Exa if Brave fails', async () => {
|
|
45
|
+
process.env.BRAVE_API_KEY = 'test-brave-key';
|
|
46
|
+
process.env.EXA_API_KEY = 'test-exa-key';
|
|
47
|
+
registerWebTools(mockServer);
|
|
48
|
+
// First call (Brave) fails
|
|
49
|
+
utils.fetchWithRetry
|
|
50
|
+
.mockResolvedValueOnce({ ok: false, statusText: 'Brave Error', status: 500 })
|
|
51
|
+
// Second call (Exa) succeeds
|
|
52
|
+
.mockResolvedValueOnce({
|
|
53
|
+
ok: true,
|
|
54
|
+
json: async () => ({ results: ['exa result'] })
|
|
55
|
+
});
|
|
56
|
+
const result = await mockToolCallback({ query: 'test' });
|
|
57
|
+
expect(utils.fetchWithRetry).toHaveBeenCalledTimes(2);
|
|
58
|
+
// 1. Brave
|
|
59
|
+
expect(utils.fetchWithRetry).toHaveBeenNthCalledWith(1, expect.stringContaining('api.search.brave.com'), expect.anything());
|
|
60
|
+
// 2. Exa
|
|
61
|
+
expect(utils.fetchWithRetry).toHaveBeenNthCalledWith(2, expect.stringContaining('api.exa.ai'), expect.anything());
|
|
62
|
+
expect(result.isError).toBeUndefined();
|
|
63
|
+
expect(JSON.parse(result.content[0].text)).toEqual(['exa result']);
|
|
64
|
+
});
|
|
65
|
+
it('should respect requested provider and verify its availability', async () => {
|
|
66
|
+
process.env.BRAVE_API_KEY = 'test-brave-key';
|
|
67
|
+
// Exa not configured
|
|
68
|
+
delete process.env.EXA_API_KEY;
|
|
69
|
+
registerWebTools(mockServer);
|
|
70
|
+
const result = await mockToolCallback({ query: 'test', provider: 'exa' });
|
|
71
|
+
expect(result.isError).toBe(true);
|
|
72
|
+
expect(result.content[0].text).toContain("Requested provider 'exa' is not configured");
|
|
73
|
+
});
|
|
74
|
+
it('should try requested provider first, then fallback', async () => {
|
|
75
|
+
process.env.BRAVE_API_KEY = 'test-brave-key';
|
|
76
|
+
process.env.EXA_API_KEY = 'test-exa-key';
|
|
77
|
+
registerWebTools(mockServer);
|
|
78
|
+
// Request Exa
|
|
79
|
+
// Mock Exa fail, Brave success
|
|
80
|
+
utils.fetchWithRetry
|
|
81
|
+
.mockResolvedValueOnce({ ok: false, statusText: 'Exa Error', status: 500 })
|
|
82
|
+
.mockResolvedValueOnce({
|
|
83
|
+
ok: true,
|
|
84
|
+
json: async () => ({ web: { results: ['brave result'] } })
|
|
85
|
+
});
|
|
86
|
+
const result = await mockToolCallback({ query: 'test', provider: 'exa' });
|
|
87
|
+
expect(utils.fetchWithRetry).toHaveBeenCalledTimes(2);
|
|
88
|
+
// 1. Exa (requested)
|
|
89
|
+
expect(utils.fetchWithRetry).toHaveBeenNthCalledWith(1, expect.stringContaining('api.exa.ai'), expect.anything());
|
|
90
|
+
// 2. Brave (fallback)
|
|
91
|
+
expect(utils.fetchWithRetry).toHaveBeenNthCalledWith(2, expect.stringContaining('api.search.brave.com'), expect.anything());
|
|
92
|
+
expect(result.isError).toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
it('should return error if all fail', async () => {
|
|
95
|
+
process.env.BRAVE_API_KEY = 'test-brave-key';
|
|
96
|
+
registerWebTools(mockServer);
|
|
97
|
+
utils.fetchWithRetry.mockResolvedValue({ ok: false, statusText: 'Some Error', status: 500 });
|
|
98
|
+
const result = await mockToolCallback({ query: 'test' });
|
|
99
|
+
expect(result.isError).toBe(true);
|
|
100
|
+
expect(result.content[0].text).toContain("All search providers failed");
|
|
101
|
+
expect(result.content[0].text).toContain("Brave API error");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|