@poolzin/pool-bot 2026.3.7 → 2026.3.9
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/CHANGELOG.md +16 -0
- package/dist/.buildstamp +1 -1
- package/dist/agents/error-classifier.js +302 -0
- package/dist/agents/skills/security.js +217 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/lazy-commands.example.js +113 -0
- package/dist/cli/lazy-commands.js +329 -0
- package/dist/cli/program/command-registry.js +13 -0
- package/dist/cli/program/register.skills.js +4 -0
- package/dist/config/config.js +1 -0
- package/dist/config/secrets-integration.js +88 -0
- package/dist/context-engine/index.js +33 -0
- package/dist/context-engine/legacy.js +181 -0
- package/dist/context-engine/registry.js +86 -0
- package/dist/context-engine/summarizing.js +293 -0
- package/dist/context-engine/types.js +7 -0
- package/dist/infra/abort-pattern.js +106 -0
- package/dist/infra/retry.js +94 -0
- package/dist/secrets/index.js +28 -0
- package/dist/secrets/resolver.js +185 -0
- package/dist/secrets/runtime.js +142 -0
- package/dist/secrets/types.js +11 -0
- package/dist/security/dangerous-tools.js +80 -0
- package/dist/security/types.js +12 -0
- package/dist/skills/commands.js +351 -0
- package/dist/skills/index.js +167 -0
- package/dist/skills/loader.js +282 -0
- package/dist/skills/parser.js +461 -0
- package/dist/skills/registry.js +397 -0
- package/dist/skills/security.js +318 -0
- package/dist/skills/types.js +21 -0
- package/dist/test-utils/index.js +219 -0
- package/dist/tui/index.js +595 -0
- package/docs/INTEGRATION_PLAN.md +475 -0
- package/docs/INTEGRATION_SUMMARY.md +215 -0
- package/docs/integrations/HEXSTRIKE_PLAN.md +796 -0
- package/docs/integrations/INTEGRATION_PLAN.md +424 -0
- package/docs/integrations/PAGE_AGENT_PLAN.md +370 -0
- package/docs/integrations/XYOPS_PLAN.md +978 -0
- package/docs/skills/IMPLEMENTATION_SUMMARY.md +145 -0
- package/docs/skills/SKILL.md +524 -0
- package/docs/skills.md +405 -0
- package/package.json +1 -1
- package/skills/example-skill/SKILL.md +195 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill security scanner
|
|
3
|
+
* Detects potential security issues in skill files
|
|
4
|
+
*
|
|
5
|
+
* @module skills/security
|
|
6
|
+
*/
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// Constants
|
|
9
|
+
// ============================================================================
|
|
10
|
+
const SCANNER_VERSION = "1.0.0";
|
|
11
|
+
// Patterns that indicate potential security issues
|
|
12
|
+
const PATTERNS = [
|
|
13
|
+
// Prompt injection attempts
|
|
14
|
+
{
|
|
15
|
+
type: "prompt_injection",
|
|
16
|
+
severity: "critical",
|
|
17
|
+
pattern: /ignore\s+(?:previous|above|prior)|disregard\s+(?:instructions?|prompt)|system\s*:\s*you\s+are|new\s+instructions?\s*:/i,
|
|
18
|
+
description: "Potential prompt injection attempt detected",
|
|
19
|
+
remediation: "Review skill content for malicious instruction overrides",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
type: "prompt_injection",
|
|
23
|
+
severity: "high",
|
|
24
|
+
pattern: /\[\s*system\s*\]|\(\s*system\s*\)|\{\s*system\s*\}|\bDAN\b|do\s+anything\s+now/i,
|
|
25
|
+
description: "Suspicious system role reference",
|
|
26
|
+
remediation: "Verify skill doesn't attempt to override system behavior",
|
|
27
|
+
},
|
|
28
|
+
// Command injection
|
|
29
|
+
{
|
|
30
|
+
type: "command_injection",
|
|
31
|
+
severity: "critical",
|
|
32
|
+
pattern: /(?:bash|sh|zsh|cmd|powershell)\s+-c\s+["']|exec\s*\(|eval\s*\(|system\s*\(/i,
|
|
33
|
+
description: "Potential command injection pattern",
|
|
34
|
+
remediation: "Avoid executing arbitrary shell commands from skill content",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
type: "command_injection",
|
|
38
|
+
severity: "high",
|
|
39
|
+
pattern: /`[^`]*(?:rm|del|format|mkfs|dd|wget|curl|fetch)[^`]*`|\$\([^)]*(?:rm|del|wget|curl)[^)]*\)/i,
|
|
40
|
+
description: "Dangerous command in template literal",
|
|
41
|
+
remediation: "Review shell command usage for safety",
|
|
42
|
+
},
|
|
43
|
+
// Path traversal
|
|
44
|
+
{
|
|
45
|
+
type: "path_traversal",
|
|
46
|
+
severity: "high",
|
|
47
|
+
pattern: /\.\.[/\\]|\.\.%2f|\.\.%5c|%2e%2e[/\\]/i,
|
|
48
|
+
description: "Path traversal attempt detected",
|
|
49
|
+
remediation: "Validate and sanitize all file paths",
|
|
50
|
+
},
|
|
51
|
+
// Suspicious patterns
|
|
52
|
+
{
|
|
53
|
+
type: "suspicious_pattern",
|
|
54
|
+
severity: "medium",
|
|
55
|
+
pattern: /(?:password|secret|token|key|credential)\s*=\s*["'][^"']{8,}["']/i,
|
|
56
|
+
description: "Hardcoded credential-like pattern",
|
|
57
|
+
remediation: "Use environment variables or secure secret storage",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
type: "suspicious_pattern",
|
|
61
|
+
severity: "medium",
|
|
62
|
+
pattern: /base64\s*\(\s*["'][^"']{20,}["']\s*\)|atob\s*\(|btoa\s*\(/i,
|
|
63
|
+
description: "Suspicious encoding/decoding pattern",
|
|
64
|
+
remediation: "Verify encoding is not used to obfuscate malicious content",
|
|
65
|
+
},
|
|
66
|
+
// External dependencies
|
|
67
|
+
{
|
|
68
|
+
type: "external_dependency",
|
|
69
|
+
severity: "low",
|
|
70
|
+
pattern: /(?:npm|pip|gem|cargo|go\s+get)\s+install/i,
|
|
71
|
+
description: "External package installation mentioned",
|
|
72
|
+
remediation: "Verify all external dependencies are trustworthy",
|
|
73
|
+
},
|
|
74
|
+
// Permission requests
|
|
75
|
+
{
|
|
76
|
+
type: "permission_request",
|
|
77
|
+
severity: "medium",
|
|
78
|
+
pattern: /(?:sudo|administrator|root|elevated|privileged)\s*(?:access|permission|required|needed)/i,
|
|
79
|
+
description: "Elevated permissions mentioned",
|
|
80
|
+
remediation: "Ensure elevated permissions are truly necessary",
|
|
81
|
+
},
|
|
82
|
+
// Network access
|
|
83
|
+
{
|
|
84
|
+
type: "network_access",
|
|
85
|
+
severity: "low",
|
|
86
|
+
pattern: /(?:http|https|ftp|ssh|telnet):\/\/|(?:fetch|axios|request|curl)\s*\(/i,
|
|
87
|
+
description: "Network access pattern detected",
|
|
88
|
+
remediation: "Verify all network requests are legitimate",
|
|
89
|
+
},
|
|
90
|
+
// File system access
|
|
91
|
+
{
|
|
92
|
+
type: "file_system_access",
|
|
93
|
+
severity: "low",
|
|
94
|
+
pattern: /(?:fs\.|file|readFile|writeFile|appendFile|unlink|mkdir|rmdir)\s*\(/i,
|
|
95
|
+
description: "File system access pattern detected",
|
|
96
|
+
remediation: "Validate file operations are scoped appropriately",
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
// Content limits
|
|
100
|
+
const MAX_CONTENT_LENGTH = 1024 * 1024; // 1MB
|
|
101
|
+
const MAX_LINE_LENGTH = 1000;
|
|
102
|
+
// ============================================================================
|
|
103
|
+
// Scanner Implementation
|
|
104
|
+
// ============================================================================
|
|
105
|
+
/**
|
|
106
|
+
* Scan skill content for security issues
|
|
107
|
+
*/
|
|
108
|
+
export async function scanSkill(skill) {
|
|
109
|
+
const startTime = Date.now();
|
|
110
|
+
const findings = [];
|
|
111
|
+
// Combine all content for scanning
|
|
112
|
+
const contentToScan = [
|
|
113
|
+
skill.metadata.name,
|
|
114
|
+
skill.metadata.description,
|
|
115
|
+
skill.content.usage,
|
|
116
|
+
skill.content.quickstart,
|
|
117
|
+
skill.content.configuration,
|
|
118
|
+
skill.content.environment,
|
|
119
|
+
skill.content.examples,
|
|
120
|
+
skill.content.api,
|
|
121
|
+
skill.content.troubleshooting,
|
|
122
|
+
]
|
|
123
|
+
.filter(Boolean)
|
|
124
|
+
.join("\n\n");
|
|
125
|
+
// Check content size
|
|
126
|
+
if (contentToScan.length > MAX_CONTENT_LENGTH) {
|
|
127
|
+
findings.push({
|
|
128
|
+
type: "suspicious_pattern",
|
|
129
|
+
severity: "medium",
|
|
130
|
+
description: `Skill content exceeds maximum size (${contentToScan.length} > ${MAX_CONTENT_LENGTH})`,
|
|
131
|
+
location: "content",
|
|
132
|
+
remediation: "Consider splitting large skills into smaller modules",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
// Scan line by line for better location reporting
|
|
136
|
+
const lines = contentToScan.split("\n");
|
|
137
|
+
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
|
138
|
+
const line = lines[lineNum];
|
|
139
|
+
// Check line length
|
|
140
|
+
if (line.length > MAX_LINE_LENGTH) {
|
|
141
|
+
findings.push({
|
|
142
|
+
type: "suspicious_pattern",
|
|
143
|
+
severity: "low",
|
|
144
|
+
description: `Line ${lineNum + 1} exceeds maximum length`,
|
|
145
|
+
location: `line:${lineNum + 1}`,
|
|
146
|
+
remediation: "Break long lines for readability and safety",
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
// Check against patterns
|
|
150
|
+
for (const { type, severity, pattern, description, remediation, } of PATTERNS) {
|
|
151
|
+
if (pattern.test(line)) {
|
|
152
|
+
findings.push({
|
|
153
|
+
type,
|
|
154
|
+
severity,
|
|
155
|
+
description,
|
|
156
|
+
location: `line:${lineNum + 1}`,
|
|
157
|
+
remediation,
|
|
158
|
+
evidence: line.trim().slice(0, 100), // First 100 chars
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// Check linked files
|
|
164
|
+
for (const linkedFile of skill.linkedFiles) {
|
|
165
|
+
// Flag executable files
|
|
166
|
+
if (/\.(exe|bat|cmd|sh|bin|app)$/i.test(linkedFile.path)) {
|
|
167
|
+
findings.push({
|
|
168
|
+
type: "suspicious_pattern",
|
|
169
|
+
severity: "high",
|
|
170
|
+
description: `Linked executable file detected: ${linkedFile.path}`,
|
|
171
|
+
location: `linked:${linkedFile.path}`,
|
|
172
|
+
remediation: "Review executable content before execution",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
// Flag scripts
|
|
176
|
+
if (/\.(js|ts|py|rb|pl|php)$/i.test(linkedFile.path)) {
|
|
177
|
+
findings.push({
|
|
178
|
+
type: "external_dependency",
|
|
179
|
+
severity: "low",
|
|
180
|
+
description: `Linked script file: ${linkedFile.path}`,
|
|
181
|
+
location: `linked:${linkedFile.path}`,
|
|
182
|
+
remediation: "Review script content for safety",
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Determine overall result
|
|
187
|
+
const hasCritical = findings.some((f) => f.severity === "critical");
|
|
188
|
+
const hasHigh = findings.some((f) => f.severity === "high");
|
|
189
|
+
const hasWarnings = findings.some((f) => f.severity === "medium" || f.severity === "low");
|
|
190
|
+
let result;
|
|
191
|
+
if (hasCritical) {
|
|
192
|
+
result = "failed";
|
|
193
|
+
}
|
|
194
|
+
else if (hasHigh) {
|
|
195
|
+
result = "failed";
|
|
196
|
+
}
|
|
197
|
+
else if (hasWarnings) {
|
|
198
|
+
result = "warning";
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
result = "verified";
|
|
202
|
+
}
|
|
203
|
+
const durationMs = Date.now() - startTime;
|
|
204
|
+
return {
|
|
205
|
+
scannedAt: new Date(),
|
|
206
|
+
result,
|
|
207
|
+
findings,
|
|
208
|
+
durationMs,
|
|
209
|
+
scannerVersion: SCANNER_VERSION,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Quick scan for critical issues only
|
|
214
|
+
*/
|
|
215
|
+
export async function quickSecurityCheck(skill) {
|
|
216
|
+
const criticalPatterns = PATTERNS.filter((p) => p.severity === "critical" || p.severity === "high");
|
|
217
|
+
const contentToScan = [
|
|
218
|
+
skill.metadata.name,
|
|
219
|
+
skill.metadata.description,
|
|
220
|
+
skill.content.usage,
|
|
221
|
+
skill.content.quickstart,
|
|
222
|
+
]
|
|
223
|
+
.filter(Boolean)
|
|
224
|
+
.join("\n");
|
|
225
|
+
for (const { pattern } of criticalPatterns) {
|
|
226
|
+
if (pattern.test(contentToScan)) {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Get security summary for display
|
|
234
|
+
*/
|
|
235
|
+
export function getSecuritySummary(report) {
|
|
236
|
+
const counts = {
|
|
237
|
+
critical: 0,
|
|
238
|
+
high: 0,
|
|
239
|
+
medium: 0,
|
|
240
|
+
low: 0,
|
|
241
|
+
info: 0,
|
|
242
|
+
};
|
|
243
|
+
for (const finding of report.findings) {
|
|
244
|
+
counts[finding.severity]++;
|
|
245
|
+
}
|
|
246
|
+
let status;
|
|
247
|
+
let color;
|
|
248
|
+
let summary;
|
|
249
|
+
switch (report.result) {
|
|
250
|
+
case "verified":
|
|
251
|
+
status = "✓ Verified";
|
|
252
|
+
color = "green";
|
|
253
|
+
summary = "No security issues found";
|
|
254
|
+
break;
|
|
255
|
+
case "warning":
|
|
256
|
+
status = "⚠ Warning";
|
|
257
|
+
color = "yellow";
|
|
258
|
+
summary = `${counts.medium + counts.low} warning(s) found`;
|
|
259
|
+
break;
|
|
260
|
+
case "failed":
|
|
261
|
+
status = "✗ Failed";
|
|
262
|
+
color = "red";
|
|
263
|
+
summary = `${counts.critical + counts.high} critical issue(s) found`;
|
|
264
|
+
break;
|
|
265
|
+
default:
|
|
266
|
+
status = "? Unverified";
|
|
267
|
+
color = "gray";
|
|
268
|
+
summary = "Not yet scanned";
|
|
269
|
+
}
|
|
270
|
+
return { status, color, summary, counts };
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Format security findings for display
|
|
274
|
+
*/
|
|
275
|
+
export function formatFindings(findings) {
|
|
276
|
+
if (findings.length === 0) {
|
|
277
|
+
return ["No security issues found."];
|
|
278
|
+
}
|
|
279
|
+
const lines = [];
|
|
280
|
+
// Group by severity
|
|
281
|
+
const bySeverity = {
|
|
282
|
+
critical: [],
|
|
283
|
+
high: [],
|
|
284
|
+
medium: [],
|
|
285
|
+
low: [],
|
|
286
|
+
info: [],
|
|
287
|
+
};
|
|
288
|
+
for (const finding of findings) {
|
|
289
|
+
bySeverity[finding.severity].push(finding);
|
|
290
|
+
}
|
|
291
|
+
for (const severity of [
|
|
292
|
+
"critical",
|
|
293
|
+
"high",
|
|
294
|
+
"medium",
|
|
295
|
+
"low",
|
|
296
|
+
"info",
|
|
297
|
+
]) {
|
|
298
|
+
const group = bySeverity[severity];
|
|
299
|
+
if (group.length === 0)
|
|
300
|
+
continue;
|
|
301
|
+
lines.push(`\n${severity.toUpperCase()} (${group.length}):`);
|
|
302
|
+
for (const finding of group) {
|
|
303
|
+
lines.push(` [${finding.type}] ${finding.description}`);
|
|
304
|
+
lines.push(` Location: ${finding.location}`);
|
|
305
|
+
if (finding.evidence) {
|
|
306
|
+
lines.push(` Evidence: ${finding.evidence}`);
|
|
307
|
+
}
|
|
308
|
+
if (finding.remediation) {
|
|
309
|
+
lines.push(` Fix: ${finding.remediation}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return lines;
|
|
314
|
+
}
|
|
315
|
+
// ============================================================================
|
|
316
|
+
// Export patterns for testing
|
|
317
|
+
// ============================================================================
|
|
318
|
+
export { PATTERNS };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill types and interfaces for PoolBot Skills System
|
|
3
|
+
* Compatible with agentskills.io ecosystem
|
|
4
|
+
*
|
|
5
|
+
* @module skills/types
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Skill system error
|
|
9
|
+
*/
|
|
10
|
+
export class SkillError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
skillId;
|
|
13
|
+
cause;
|
|
14
|
+
constructor(code, message, skillId, cause) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.skillId = skillId;
|
|
18
|
+
this.cause = cause;
|
|
19
|
+
this.name = "SkillError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test Utilities for PoolBot
|
|
3
|
+
*
|
|
4
|
+
* Shared testing helpers and fixtures for consistent testing.
|
|
5
|
+
*/
|
|
6
|
+
import { afterEach } from "vitest";
|
|
7
|
+
/**
|
|
8
|
+
* Frozen time utility for deterministic time-based tests
|
|
9
|
+
*/
|
|
10
|
+
export class FrozenTime {
|
|
11
|
+
originalDateNow;
|
|
12
|
+
currentTime;
|
|
13
|
+
constructor(startTime = 1700000000000) {
|
|
14
|
+
this.originalDateNow = Date.now;
|
|
15
|
+
this.currentTime = startTime;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Freeze time at a specific timestamp
|
|
19
|
+
*/
|
|
20
|
+
freeze(timestamp) {
|
|
21
|
+
if (timestamp !== undefined) {
|
|
22
|
+
this.currentTime = timestamp;
|
|
23
|
+
}
|
|
24
|
+
Date.now = () => this.currentTime;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Advance time by milliseconds
|
|
28
|
+
*/
|
|
29
|
+
advance(ms) {
|
|
30
|
+
this.currentTime += ms;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Get current frozen time
|
|
34
|
+
*/
|
|
35
|
+
now() {
|
|
36
|
+
return this.currentTime;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Restore original Date.now
|
|
40
|
+
*/
|
|
41
|
+
restore() {
|
|
42
|
+
Date.now = this.originalDateNow;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Create ISO timestamp from current frozen time
|
|
46
|
+
*/
|
|
47
|
+
toISOString() {
|
|
48
|
+
return new Date(this.currentTime).toISOString();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Create a test fixture with isolated state
|
|
53
|
+
*/
|
|
54
|
+
export function createFixture(factory, options = {}) {
|
|
55
|
+
let instance = null;
|
|
56
|
+
const get = () => {
|
|
57
|
+
if (!instance) {
|
|
58
|
+
instance = factory();
|
|
59
|
+
}
|
|
60
|
+
return instance;
|
|
61
|
+
};
|
|
62
|
+
const reset = () => {
|
|
63
|
+
instance = null;
|
|
64
|
+
};
|
|
65
|
+
if (options.autoCleanup !== false) {
|
|
66
|
+
afterEach(reset);
|
|
67
|
+
}
|
|
68
|
+
return { get, reset };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Type-safe test case builder
|
|
72
|
+
*/
|
|
73
|
+
export function typedCases(cases) {
|
|
74
|
+
return cases;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Temporary directory helper
|
|
78
|
+
*/
|
|
79
|
+
export class TempDir {
|
|
80
|
+
dirs = [];
|
|
81
|
+
/**
|
|
82
|
+
* Create a temporary directory
|
|
83
|
+
*/
|
|
84
|
+
create(prefix = "poolbot-test-") {
|
|
85
|
+
const dir = `${prefix}${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
86
|
+
this.dirs.push(dir);
|
|
87
|
+
return dir;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Clean up all created directories
|
|
91
|
+
*/
|
|
92
|
+
cleanup() {
|
|
93
|
+
this.dirs = [];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Mock utilities for common dependencies
|
|
98
|
+
*/
|
|
99
|
+
export class MockFactory {
|
|
100
|
+
/**
|
|
101
|
+
* Create a mock function with tracking
|
|
102
|
+
*/
|
|
103
|
+
static fn(implementation) {
|
|
104
|
+
const calls = [];
|
|
105
|
+
const mockFn = ((...args) => {
|
|
106
|
+
const result = implementation ? implementation(...args) : undefined;
|
|
107
|
+
calls.push({ args, result });
|
|
108
|
+
return result;
|
|
109
|
+
});
|
|
110
|
+
mockFn.calls = calls;
|
|
111
|
+
mockFn.mockClear = () => {
|
|
112
|
+
calls.length = 0;
|
|
113
|
+
};
|
|
114
|
+
return mockFn;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Create a mock logger
|
|
118
|
+
*/
|
|
119
|
+
static logger() {
|
|
120
|
+
return {
|
|
121
|
+
info: MockFactory.fn(),
|
|
122
|
+
warn: MockFactory.fn(),
|
|
123
|
+
error: MockFactory.fn(),
|
|
124
|
+
debug: MockFactory.fn(),
|
|
125
|
+
logs: [],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Assertion helpers
|
|
131
|
+
*/
|
|
132
|
+
export class Assert {
|
|
133
|
+
/**
|
|
134
|
+
* Assert that a promise resolves within a timeout
|
|
135
|
+
*/
|
|
136
|
+
static async resolvesWithin(promise, timeoutMs, message) {
|
|
137
|
+
const timeout = new Promise((_, reject) => {
|
|
138
|
+
setTimeout(() => reject(new Error(message ?? `Promise did not resolve within ${timeoutMs}ms`)), timeoutMs);
|
|
139
|
+
});
|
|
140
|
+
return Promise.race([promise, timeout]);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Assert that an error is thrown
|
|
144
|
+
*/
|
|
145
|
+
static async throws(fn, expectedMessage) {
|
|
146
|
+
try {
|
|
147
|
+
await fn();
|
|
148
|
+
throw new Error("Expected function to throw but it did not");
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
if (error instanceof Error) {
|
|
152
|
+
if (expectedMessage) {
|
|
153
|
+
if (expectedMessage instanceof RegExp) {
|
|
154
|
+
if (!expectedMessage.test(error.message)) {
|
|
155
|
+
throw new Error(`Expected error message to match ${expectedMessage}, got: ${error.message}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
else if (!error.message.includes(expectedMessage)) {
|
|
159
|
+
throw new Error(`Expected error message to include "${expectedMessage}", got: ${error.message}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return error;
|
|
163
|
+
}
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Data generators for tests
|
|
170
|
+
*/
|
|
171
|
+
export class Generators {
|
|
172
|
+
/**
|
|
173
|
+
* Generate a UUID v4
|
|
174
|
+
*/
|
|
175
|
+
static uuid() {
|
|
176
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
177
|
+
const r = (Math.random() * 16) | 0;
|
|
178
|
+
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
|
179
|
+
return v.toString(16);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Generate a random session ID
|
|
184
|
+
*/
|
|
185
|
+
static sessionId() {
|
|
186
|
+
return `session-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Generate a timestamp with optional offset
|
|
190
|
+
*/
|
|
191
|
+
static timestamp(offsetMs = 0) {
|
|
192
|
+
return Date.now() + offsetMs;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Generate random text of specified length
|
|
196
|
+
*/
|
|
197
|
+
static text(length = 100) {
|
|
198
|
+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 ";
|
|
199
|
+
let result = "";
|
|
200
|
+
for (let i = 0; i < length; i++) {
|
|
201
|
+
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
202
|
+
}
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Setup helper for common test patterns
|
|
208
|
+
*/
|
|
209
|
+
export function setupTestEnvironment() {
|
|
210
|
+
const frozenTime = new FrozenTime();
|
|
211
|
+
const tempDir = new TempDir();
|
|
212
|
+
frozenTime.freeze();
|
|
213
|
+
const cleanup = () => {
|
|
214
|
+
frozenTime.restore();
|
|
215
|
+
tempDir.cleanup();
|
|
216
|
+
};
|
|
217
|
+
afterEach(cleanup);
|
|
218
|
+
return { frozenTime, tempDir, cleanup };
|
|
219
|
+
}
|