mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
Add GitHub resolver integration tests with mock server
This adds integration tests for the GitHub resolver feature: - Mock GitHub Server (mocks/github-mock-server.ts): - Simulates GitHub REST API endpoints - Handles webhook signature verification - Records webhook events and outgoing responses - Provides test control endpoints for assertions - Webhook Payload Templates (mocks/github-webhook-payloads.ts): - Issue labeled events - Issue comment events - PR review comment events - Mock GitHub Client (mocks/mock-github-client.ts): - Client utilities for triggering webhooks - Helpers for waiting on resolver responses - GitHub Resolver Test Spec (tests/github-resolver.spec.ts): - Mock Server Mode: Tests full webhook flow with mock server - Live Environment Mode: Tests against staging/production - Error handling tests for invalid signatures and malformed data - Tests run against the existing authenticated session - Updated package.json with new scripts: - npm run test:github-resolver - npm run mock:github - Updated README with comprehensive documentation Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
@@ -11,6 +11,7 @@ These integration tests verify the critical path of the OpenHands application:
|
||||
3. ✅ Repository selection
|
||||
4. ✅ Conversation creation
|
||||
5. ✅ Agent interaction without errors
|
||||
6. ✅ GitHub Resolver integration (enterprise)
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -295,6 +296,94 @@ async function generateTOTP(secret: string): Promise<string> {
|
||||
}
|
||||
```
|
||||
|
||||
## GitHub Resolver Integration Tests
|
||||
|
||||
The GitHub Resolver tests verify the end-to-end flow of the resolver integration, where GitHub webhooks trigger OpenHands to work on issues and pull requests.
|
||||
|
||||
### Architecture
|
||||
|
||||
The tests use a **Mock GitHub Server** instead of connecting to the real GitHub API. This allows:
|
||||
|
||||
- Complete control over webhook payloads and responses
|
||||
- Testing without requiring real GitHub credentials or installations
|
||||
- Isolation from GitHub's rate limits and service availability
|
||||
- Reproducible test scenarios
|
||||
|
||||
### Mock GitHub Server
|
||||
|
||||
The mock server (`mocks/github-mock-server.ts`) simulates:
|
||||
|
||||
- GitHub REST API endpoints (repos, issues, comments, reactions)
|
||||
- GitHub App installation token generation
|
||||
- Webhook signature verification
|
||||
- Recording of outgoing responses (comments posted by the resolver)
|
||||
|
||||
### Running GitHub Resolver Tests
|
||||
|
||||
1. **Start the OpenHands application with enterprise features:**
|
||||
|
||||
```bash
|
||||
# From the project root
|
||||
cd enterprise
|
||||
make start-backend
|
||||
```
|
||||
|
||||
2. **Configure environment variables:**
|
||||
|
||||
```bash
|
||||
# In integration_tests/.env
|
||||
GITHUB_APP_WEBHOOK_SECRET=test-webhook-secret
|
||||
APP_PORT=12000
|
||||
MOCK_GITHUB_PORT=9999
|
||||
```
|
||||
|
||||
3. **Run the tests:**
|
||||
|
||||
```bash
|
||||
cd integration_tests
|
||||
npm run test:github-resolver
|
||||
```
|
||||
|
||||
### Mock Server Standalone Mode
|
||||
|
||||
You can run the mock GitHub server standalone for debugging:
|
||||
|
||||
```bash
|
||||
npm run mock:github
|
||||
```
|
||||
|
||||
This starts the server on port 9999 (configurable via `MOCK_GITHUB_PORT`).
|
||||
|
||||
### Test Endpoints
|
||||
|
||||
The mock server exposes test control endpoints:
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/_health` | GET | Health check |
|
||||
| `/_test/webhook-events` | GET | Get recorded webhook events |
|
||||
| `/_test/outgoing-responses` | GET | Get responses posted by resolver |
|
||||
| `/_test/clear-events` | POST | Clear recorded events |
|
||||
| `/_test/reset` | POST | Reset all mock data |
|
||||
| `/_test/trigger-webhook` | POST | Trigger a webhook to target URL |
|
||||
|
||||
### Test Scenarios
|
||||
|
||||
The GitHub Resolver tests cover:
|
||||
|
||||
1. **Issue Labeled** - Adding the "openhands" label to an issue
|
||||
2. **Issue Comment** - Commenting "@openhands" on an issue
|
||||
3. **PR Review Comment** - Commenting "@openhands" on a PR review
|
||||
4. **Error Handling** - Invalid signatures, missing installation IDs
|
||||
|
||||
### Customizing Test Data
|
||||
|
||||
Edit `mocks/github-mock-server.ts` to modify the default test data:
|
||||
|
||||
- Repository information
|
||||
- Issue content
|
||||
- Installation configurations
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use dedicated test accounts** - Don't use personal accounts
|
||||
|
||||
705
integration_tests/mocks/github-mock-server.ts
Normal file
705
integration_tests/mocks/github-mock-server.ts
Normal file
@@ -0,0 +1,705 @@
|
||||
/**
|
||||
* Mock GitHub Server for Integration Testing
|
||||
*
|
||||
* This server simulates GitHub API endpoints used by the OpenHands resolver:
|
||||
* - GitHub App webhooks (issue labeled, issue comment, PR comment, etc.)
|
||||
* - GitHub REST API endpoints (repos, issues, comments, pulls)
|
||||
* - GitHub GraphQL API
|
||||
*
|
||||
* The mock server allows testing the resolver integration without connecting
|
||||
* to the real GitHub service.
|
||||
*/
|
||||
|
||||
import http from "http";
|
||||
import crypto from "crypto";
|
||||
|
||||
// Types for mock data
|
||||
interface MockIssue {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
state: "open" | "closed";
|
||||
labels: Array<{ name: string; id: number }>;
|
||||
user: { login: string; id: number };
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
comments: MockComment[];
|
||||
reactions: string[];
|
||||
}
|
||||
|
||||
interface MockComment {
|
||||
id: number;
|
||||
body: string;
|
||||
user: { login: string; id: number };
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface MockRepository {
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
private: boolean;
|
||||
owner: { login: string; id: number };
|
||||
default_branch: string;
|
||||
node_id: string;
|
||||
}
|
||||
|
||||
interface MockInstallation {
|
||||
id: number;
|
||||
account: { login: string; id: number };
|
||||
repositories: MockRepository[];
|
||||
access_token: string;
|
||||
}
|
||||
|
||||
interface WebhookEvent {
|
||||
action: string;
|
||||
payload: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Mock data store
|
||||
class MockGitHubDataStore {
|
||||
private repositories: Map<string, MockRepository> = new Map();
|
||||
|
||||
private issues: Map<string, Map<number, MockIssue>> = new Map();
|
||||
|
||||
private installations: Map<number, MockInstallation> = new Map();
|
||||
|
||||
private webhookEvents: WebhookEvent[] = [];
|
||||
|
||||
private nextCommentId = 1000;
|
||||
|
||||
private outgoingWebhookResponses: Array<{
|
||||
body: string;
|
||||
timestamp: string;
|
||||
}> = [];
|
||||
|
||||
constructor() {
|
||||
this.initializeDefaultData();
|
||||
}
|
||||
|
||||
private initializeDefaultData() {
|
||||
// Create a default test repository
|
||||
const testRepo: MockRepository = {
|
||||
id: 123456789,
|
||||
name: "test-repo",
|
||||
full_name: "test-owner/test-repo",
|
||||
private: false,
|
||||
owner: { login: "test-owner", id: 1000 },
|
||||
default_branch: "main",
|
||||
node_id: "R_kgDOTest123",
|
||||
};
|
||||
this.repositories.set(testRepo.full_name, testRepo);
|
||||
|
||||
// Create a test issue
|
||||
const testIssue: MockIssue = {
|
||||
number: 1,
|
||||
title: "Test Issue for OpenHands Resolver",
|
||||
body: "This is a test issue to verify the resolver integration works correctly. Please add a README file.",
|
||||
state: "open",
|
||||
labels: [],
|
||||
user: { login: "test-user", id: 2000 },
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
comments: [],
|
||||
reactions: [],
|
||||
};
|
||||
this.issues.set(testRepo.full_name, new Map([[1, testIssue]]));
|
||||
|
||||
// Create a default installation
|
||||
const testInstallation: MockInstallation = {
|
||||
id: 12345,
|
||||
account: { login: "test-owner", id: 1000 },
|
||||
repositories: [testRepo],
|
||||
access_token: "ghs_mock_installation_token_12345",
|
||||
};
|
||||
this.installations.set(testInstallation.id, testInstallation);
|
||||
}
|
||||
|
||||
getRepository(fullName: string): MockRepository | undefined {
|
||||
return this.repositories.get(fullName);
|
||||
}
|
||||
|
||||
getIssue(fullName: string, issueNumber: number): MockIssue | undefined {
|
||||
return this.issues.get(fullName)?.get(issueNumber);
|
||||
}
|
||||
|
||||
getIssues(fullName: string): MockIssue[] {
|
||||
const repoIssues = this.issues.get(fullName);
|
||||
return repoIssues ? Array.from(repoIssues.values()) : [];
|
||||
}
|
||||
|
||||
addComment(
|
||||
fullName: string,
|
||||
issueNumber: number,
|
||||
body: string,
|
||||
user: { login: string; id: number },
|
||||
): MockComment {
|
||||
const issue = this.getIssue(fullName, issueNumber);
|
||||
if (!issue) throw new Error(`Issue not found: ${fullName}#${issueNumber}`);
|
||||
|
||||
const comment: MockComment = {
|
||||
id: this.nextCommentId++,
|
||||
body,
|
||||
user,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
issue.comments.push(comment);
|
||||
issue.updated_at = new Date().toISOString();
|
||||
return comment;
|
||||
}
|
||||
|
||||
addReaction(fullName: string, issueNumber: number, reaction: string): void {
|
||||
const issue = this.getIssue(fullName, issueNumber);
|
||||
if (issue) {
|
||||
issue.reactions.push(reaction);
|
||||
}
|
||||
}
|
||||
|
||||
addLabel(fullName: string, issueNumber: number, label: string): void {
|
||||
const issue = this.getIssue(fullName, issueNumber);
|
||||
if (issue) {
|
||||
issue.labels.push({ name: label, id: Date.now() });
|
||||
issue.updated_at = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
getInstallation(id: number): MockInstallation | undefined {
|
||||
return this.installations.get(id);
|
||||
}
|
||||
|
||||
getAllRepositories(): MockRepository[] {
|
||||
return Array.from(this.repositories.values());
|
||||
}
|
||||
|
||||
recordWebhookEvent(action: string, payload: Record<string, unknown>): void {
|
||||
this.webhookEvents.push({
|
||||
action,
|
||||
payload,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
getWebhookEvents(): WebhookEvent[] {
|
||||
return this.webhookEvents;
|
||||
}
|
||||
|
||||
recordOutgoingWebhookResponse(body: string): void {
|
||||
this.outgoingWebhookResponses.push({
|
||||
body,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
getOutgoingWebhookResponses(): Array<{ body: string; timestamp: string }> {
|
||||
return this.outgoingWebhookResponses;
|
||||
}
|
||||
|
||||
clearEvents(): void {
|
||||
this.webhookEvents = [];
|
||||
this.outgoingWebhookResponses = [];
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.repositories.clear();
|
||||
this.issues.clear();
|
||||
this.installations.clear();
|
||||
this.webhookEvents = [];
|
||||
this.outgoingWebhookResponses = [];
|
||||
this.nextCommentId = 1000;
|
||||
this.initializeDefaultData();
|
||||
}
|
||||
}
|
||||
|
||||
const dataStore = new MockGitHubDataStore();
|
||||
|
||||
// Webhook secret for signature verification
|
||||
const WEBHOOK_SECRET =
|
||||
process.env.MOCK_GITHUB_WEBHOOK_SECRET || "test-webhook-secret";
|
||||
|
||||
// Generate webhook signature
|
||||
function generateWebhookSignature(payload: string): string {
|
||||
const hmac = crypto.createHmac("sha256", WEBHOOK_SECRET);
|
||||
hmac.update(payload);
|
||||
return `sha256=${hmac.digest("hex")}`;
|
||||
}
|
||||
|
||||
// Parse URL path and extract params
|
||||
function parseRoute(
|
||||
url: string,
|
||||
pattern: RegExp,
|
||||
): Record<string, string> | null {
|
||||
const match = url.match(pattern);
|
||||
if (!match) return null;
|
||||
return match.groups || {};
|
||||
}
|
||||
|
||||
// JSON response helper
|
||||
function jsonResponse(
|
||||
res: http.ServerResponse,
|
||||
data: unknown,
|
||||
status = 200,
|
||||
): void {
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
async function parseBody(req: http.IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () => resolve(body));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
// Request handlers
|
||||
const handlers: Array<{
|
||||
method: string;
|
||||
pattern: RegExp;
|
||||
handler: (
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
params: Record<string, string>,
|
||||
body?: unknown,
|
||||
) => Promise<void> | void;
|
||||
}> = [
|
||||
// GitHub App root endpoint
|
||||
{
|
||||
method: "GET",
|
||||
pattern: /^\/app$/,
|
||||
handler: (_req, res) => {
|
||||
jsonResponse(res, {
|
||||
id: 123456,
|
||||
slug: "openhands-test-app",
|
||||
name: "OpenHands Test App",
|
||||
owner: { login: "test-owner", id: 1000 },
|
||||
permissions: {
|
||||
issues: "write",
|
||||
pull_requests: "write",
|
||||
contents: "write",
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// Get repository
|
||||
{
|
||||
method: "GET",
|
||||
pattern: /^\/repos\/(?<owner>[^/]+)\/(?<repo>[^/]+)$/,
|
||||
handler: (_req, res, params) => {
|
||||
const fullName = `${params.owner}/${params.repo}`;
|
||||
const repo = dataStore.getRepository(fullName);
|
||||
if (repo) {
|
||||
jsonResponse(res, repo);
|
||||
} else {
|
||||
jsonResponse(res, { message: "Not Found" }, 404);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Get issue
|
||||
{
|
||||
method: "GET",
|
||||
pattern:
|
||||
/^\/repos\/(?<owner>[^/]+)\/(?<repo>[^/]+)\/issues\/(?<number>\d+)$/,
|
||||
handler: (_req, res, params) => {
|
||||
const fullName = `${params.owner}/${params.repo}`;
|
||||
const issue = dataStore.getIssue(fullName, parseInt(params.number, 10));
|
||||
if (issue) {
|
||||
const repo = dataStore.getRepository(fullName);
|
||||
jsonResponse(res, {
|
||||
...issue,
|
||||
url: `https://api.github.com/repos/${fullName}/issues/${issue.number}`,
|
||||
html_url: `https://github.com/${fullName}/issues/${issue.number}`,
|
||||
repository: repo,
|
||||
});
|
||||
} else {
|
||||
jsonResponse(res, { message: "Not Found" }, 404);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// List issues
|
||||
{
|
||||
method: "GET",
|
||||
pattern: /^\/repos\/(?<owner>[^/]+)\/(?<repo>[^/]+)\/issues$/,
|
||||
handler: (_req, res, params) => {
|
||||
const fullName = `${params.owner}/${params.repo}`;
|
||||
const issues = dataStore.getIssues(fullName);
|
||||
jsonResponse(res, issues);
|
||||
},
|
||||
},
|
||||
|
||||
// Get issue comments
|
||||
{
|
||||
method: "GET",
|
||||
pattern:
|
||||
/^\/repos\/(?<owner>[^/]+)\/(?<repo>[^/]+)\/issues\/(?<number>\d+)\/comments$/,
|
||||
handler: (_req, res, params) => {
|
||||
const fullName = `${params.owner}/${params.repo}`;
|
||||
const issue = dataStore.getIssue(fullName, parseInt(params.number, 10));
|
||||
if (issue) {
|
||||
jsonResponse(res, issue.comments);
|
||||
} else {
|
||||
jsonResponse(res, { message: "Not Found" }, 404);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Create issue comment
|
||||
{
|
||||
method: "POST",
|
||||
pattern:
|
||||
/^\/repos\/(?<owner>[^/]+)\/(?<repo>[^/]+)\/issues\/(?<number>\d+)\/comments$/,
|
||||
handler: async (_req, res, params, body) => {
|
||||
const fullName = `${params.owner}/${params.repo}`;
|
||||
const issueNumber = parseInt(params.number, 10);
|
||||
const requestBody = body as { body: string };
|
||||
|
||||
try {
|
||||
const comment = dataStore.addComment(
|
||||
fullName,
|
||||
issueNumber,
|
||||
requestBody.body,
|
||||
{
|
||||
login: "openhands[bot]",
|
||||
id: 99999,
|
||||
},
|
||||
);
|
||||
|
||||
// Record this as an outgoing response (the resolver posting back)
|
||||
dataStore.recordOutgoingWebhookResponse(requestBody.body);
|
||||
|
||||
jsonResponse(res, comment, 201);
|
||||
} catch {
|
||||
jsonResponse(res, { message: "Not Found" }, 404);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Create issue reaction
|
||||
{
|
||||
method: "POST",
|
||||
pattern:
|
||||
/^\/repos\/(?<owner>[^/]+)\/(?<repo>[^/]+)\/issues\/(?<number>\d+)\/reactions$/,
|
||||
handler: async (_req, res, params, body) => {
|
||||
const fullName = `${params.owner}/${params.repo}`;
|
||||
const issueNumber = parseInt(params.number, 10);
|
||||
const requestBody = body as { content: string };
|
||||
|
||||
dataStore.addReaction(fullName, issueNumber, requestBody.content);
|
||||
jsonResponse(res, { id: Date.now(), content: requestBody.content }, 201);
|
||||
},
|
||||
},
|
||||
|
||||
// Add issue label
|
||||
{
|
||||
method: "POST",
|
||||
pattern:
|
||||
/^\/repos\/(?<owner>[^/]+)\/(?<repo>[^/]+)\/issues\/(?<number>\d+)\/labels$/,
|
||||
handler: async (_req, res, params, body) => {
|
||||
const fullName = `${params.owner}/${params.repo}`;
|
||||
const issueNumber = parseInt(params.number, 10);
|
||||
const requestBody = body as { labels: string[] };
|
||||
|
||||
const issue = dataStore.getIssue(fullName, issueNumber);
|
||||
if (issue) {
|
||||
requestBody.labels.forEach((label) =>
|
||||
dataStore.addLabel(fullName, issueNumber, label),
|
||||
);
|
||||
jsonResponse(res, issue.labels, 201);
|
||||
} else {
|
||||
jsonResponse(res, { message: "Not Found" }, 404);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Get installation access token
|
||||
{
|
||||
method: "POST",
|
||||
pattern: /^\/app\/installations\/(?<installation_id>\d+)\/access_tokens$/,
|
||||
handler: (_req, res, params) => {
|
||||
const installation = dataStore.getInstallation(
|
||||
parseInt(params.installation_id, 10),
|
||||
);
|
||||
if (installation) {
|
||||
jsonResponse(
|
||||
res,
|
||||
{
|
||||
token: installation.access_token,
|
||||
expires_at: new Date(Date.now() + 3600000).toISOString(),
|
||||
permissions: {
|
||||
issues: "write",
|
||||
pull_requests: "write",
|
||||
contents: "write",
|
||||
},
|
||||
repository_selection: "all",
|
||||
},
|
||||
201,
|
||||
);
|
||||
} else {
|
||||
jsonResponse(res, { message: "Not Found" }, 404);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Get installation repositories
|
||||
{
|
||||
method: "GET",
|
||||
pattern: /^\/installation\/repositories$/,
|
||||
handler: (_req, res) => {
|
||||
// Return all repositories from all installations
|
||||
const repos = dataStore.getAllRepositories();
|
||||
jsonResponse(res, {
|
||||
total_count: repos.length,
|
||||
repositories: repos,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// Get user
|
||||
{
|
||||
method: "GET",
|
||||
pattern: /^\/user$/,
|
||||
handler: (_req, res) => {
|
||||
jsonResponse(res, {
|
||||
id: 2000,
|
||||
login: "test-user",
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/2000",
|
||||
name: "Test User",
|
||||
email: "test-user@example.com",
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// Get user by username
|
||||
{
|
||||
method: "GET",
|
||||
pattern: /^\/users\/(?<username>[^/]+)$/,
|
||||
handler: (_req, res, params) => {
|
||||
jsonResponse(res, {
|
||||
id: 2000,
|
||||
login: params.username,
|
||||
avatar_url: `https://avatars.githubusercontent.com/u/2000`,
|
||||
name: params.username,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// Get repository collaborator permission
|
||||
{
|
||||
method: "GET",
|
||||
pattern:
|
||||
/^\/repos\/(?<owner>[^/]+)\/(?<repo>[^/]+)\/collaborators\/(?<username>[^/]+)\/permission$/,
|
||||
handler: (_req, res) => {
|
||||
jsonResponse(res, {
|
||||
permission: "write",
|
||||
user: { login: "test-user", id: 2000 },
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// GraphQL endpoint
|
||||
{
|
||||
method: "POST",
|
||||
pattern: /^\/graphql$/,
|
||||
handler: async (_req, res, _params, _body) => {
|
||||
// Return a basic response for common queries
|
||||
// The body would contain { query: string, variables?: Record<string, unknown> }
|
||||
jsonResponse(res, {
|
||||
data: {
|
||||
repository: {
|
||||
id: "R_kgDOTest123",
|
||||
name: "test-repo",
|
||||
owner: { login: "test-owner" },
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// Test control endpoints - Get webhook events
|
||||
{
|
||||
method: "GET",
|
||||
pattern: /^\/_test\/webhook-events$/,
|
||||
handler: (_req, res) => {
|
||||
jsonResponse(res, dataStore.getWebhookEvents());
|
||||
},
|
||||
},
|
||||
|
||||
// Test control endpoints - Get outgoing webhook responses
|
||||
{
|
||||
method: "GET",
|
||||
pattern: /^\/_test\/outgoing-responses$/,
|
||||
handler: (_req, res) => {
|
||||
jsonResponse(res, dataStore.getOutgoingWebhookResponses());
|
||||
},
|
||||
},
|
||||
|
||||
// Test control endpoints - Clear events
|
||||
{
|
||||
method: "POST",
|
||||
pattern: /^\/_test\/clear-events$/,
|
||||
handler: (_req, res) => {
|
||||
dataStore.clearEvents();
|
||||
jsonResponse(res, { status: "cleared" });
|
||||
},
|
||||
},
|
||||
|
||||
// Test control endpoints - Reset data
|
||||
{
|
||||
method: "POST",
|
||||
pattern: /^\/_test\/reset$/,
|
||||
handler: (_req, res) => {
|
||||
dataStore.reset();
|
||||
jsonResponse(res, { status: "reset" });
|
||||
},
|
||||
},
|
||||
|
||||
// Test control endpoints - Trigger webhook
|
||||
{
|
||||
method: "POST",
|
||||
pattern: /^\/_test\/trigger-webhook$/,
|
||||
handler: async (req, res, _params, body) => {
|
||||
const { targetUrl, eventType, payload } = body as {
|
||||
targetUrl: string;
|
||||
eventType: string;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// Record the webhook event
|
||||
dataStore.recordWebhookEvent(eventType, payload);
|
||||
|
||||
// Send the webhook to the target URL
|
||||
const payloadString = JSON.stringify(payload);
|
||||
const signature = generateWebhookSignature(payloadString);
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-GitHub-Event": eventType,
|
||||
"X-Hub-Signature-256": signature,
|
||||
"X-GitHub-Delivery": crypto.randomUUID(),
|
||||
},
|
||||
body: payloadString,
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
jsonResponse(res, {
|
||||
status: "sent",
|
||||
targetUrl,
|
||||
eventType,
|
||||
responseStatus: response.status,
|
||||
responseBody: responseText,
|
||||
});
|
||||
} catch (error) {
|
||||
jsonResponse(
|
||||
res,
|
||||
{
|
||||
status: "error",
|
||||
error: (error as Error).message,
|
||||
},
|
||||
500,
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Health check
|
||||
{
|
||||
method: "GET",
|
||||
pattern: /^\/_health$/,
|
||||
handler: (_req, res) => {
|
||||
jsonResponse(res, { status: "healthy" });
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Create HTTP server
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = req.url || "/";
|
||||
const method = req.method || "GET";
|
||||
|
||||
// Handle CORS preflight
|
||||
if (method === "OPTIONS") {
|
||||
res.writeHead(204, {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
});
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Add CORS headers to all responses
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Headers", "*");
|
||||
|
||||
// Parse body for POST/PUT requests
|
||||
let body: unknown;
|
||||
if (method === "POST" || method === "PUT") {
|
||||
const rawBody = await parseBody(req);
|
||||
try {
|
||||
body = JSON.parse(rawBody);
|
||||
} catch {
|
||||
body = rawBody;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to match a handler
|
||||
for (const handler of handlers) {
|
||||
if (handler.method === method) {
|
||||
const params = parseRoute(url.split("?")[0], handler.pattern);
|
||||
if (params !== null) {
|
||||
try {
|
||||
await handler.handler(req, res, params, body);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error(`Error handling ${method} ${url}:`, error);
|
||||
jsonResponse(res, { error: "Internal Server Error" }, 500);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No handler found
|
||||
console.log(`No handler for ${method} ${url}`);
|
||||
jsonResponse(res, { message: "Not Found", path: url }, 404);
|
||||
});
|
||||
|
||||
// Start server
|
||||
const PORT = parseInt(process.env.MOCK_GITHUB_PORT || "9999", 10);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Mock GitHub Server running on port ${PORT}`);
|
||||
console.log(`Webhook secret: ${WEBHOOK_SECRET}`);
|
||||
console.log("\nAvailable endpoints:");
|
||||
console.log(" GET /_health - Health check");
|
||||
console.log(
|
||||
" GET /_test/webhook-events - Get recorded webhook events",
|
||||
);
|
||||
console.log(
|
||||
" GET /_test/outgoing-responses - Get responses posted by the resolver",
|
||||
);
|
||||
console.log(" POST /_test/clear-events - Clear recorded events");
|
||||
console.log(" POST /_test/reset - Reset all mock data");
|
||||
console.log(
|
||||
" POST /_test/trigger-webhook - Trigger a webhook to target URL",
|
||||
);
|
||||
console.log("\nGitHub API endpoints:");
|
||||
console.log(" GET /repos/:owner/:repo");
|
||||
console.log(" GET /repos/:owner/:repo/issues/:number");
|
||||
console.log(" POST /repos/:owner/:repo/issues/:number/comments");
|
||||
console.log(" POST /repos/:owner/:repo/issues/:number/reactions");
|
||||
console.log(" POST /app/installations/:id/access_tokens");
|
||||
console.log(" POST /graphql");
|
||||
});
|
||||
|
||||
export { server, dataStore, generateWebhookSignature, WEBHOOK_SECRET };
|
||||
281
integration_tests/mocks/github-webhook-payloads.ts
Normal file
281
integration_tests/mocks/github-webhook-payloads.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* GitHub Webhook Payload Templates
|
||||
*
|
||||
* These templates mirror the webhook payloads that GitHub sends for various events.
|
||||
* They're used to test the OpenHands resolver integration.
|
||||
*/
|
||||
|
||||
export interface GitHubWebhookPayload {
|
||||
action: string;
|
||||
installation: { id: number };
|
||||
repository: {
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
private: boolean;
|
||||
owner: { login: string; id: number };
|
||||
default_branch: string;
|
||||
};
|
||||
sender: { login: string; id: number };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IssuePayload extends GitHubWebhookPayload {
|
||||
issue: {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
state: string;
|
||||
labels: Array<{ name: string; id: number }>;
|
||||
user: { login: string; id: number };
|
||||
};
|
||||
label?: { name: string; id: number };
|
||||
}
|
||||
|
||||
export interface IssueCommentPayload extends GitHubWebhookPayload {
|
||||
issue: {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
state: string;
|
||||
labels: Array<{ name: string; id: number }>;
|
||||
user: { login: string; id: number };
|
||||
pull_request?: { url: string };
|
||||
};
|
||||
comment: {
|
||||
id: number;
|
||||
body: string;
|
||||
user: { login: string; id: number };
|
||||
};
|
||||
}
|
||||
|
||||
export interface PullRequestReviewCommentPayload extends GitHubWebhookPayload {
|
||||
pull_request: {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
state: string;
|
||||
head: { ref: string; sha: string };
|
||||
base: { ref: string };
|
||||
user: { login: string; id: number };
|
||||
};
|
||||
comment: {
|
||||
id: number;
|
||||
node_id: string;
|
||||
body: string;
|
||||
path: string;
|
||||
line: number;
|
||||
user: { login: string; id: number };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a base webhook payload with common fields
|
||||
*/
|
||||
function createBasePayload(params: {
|
||||
installationId?: number;
|
||||
repositoryId?: number;
|
||||
repositoryName?: string;
|
||||
repositoryOwner?: string;
|
||||
senderLogin?: string;
|
||||
senderId?: number;
|
||||
isPrivate?: boolean;
|
||||
}): GitHubWebhookPayload {
|
||||
const {
|
||||
installationId = 12345,
|
||||
repositoryId = 123456789,
|
||||
repositoryName = "test-repo",
|
||||
repositoryOwner = "test-owner",
|
||||
senderLogin = "test-user",
|
||||
senderId = 2000,
|
||||
isPrivate = false,
|
||||
} = params;
|
||||
|
||||
return {
|
||||
action: "",
|
||||
installation: { id: installationId },
|
||||
repository: {
|
||||
id: repositoryId,
|
||||
name: repositoryName,
|
||||
full_name: `${repositoryOwner}/${repositoryName}`,
|
||||
private: isPrivate,
|
||||
owner: { login: repositoryOwner, id: 1000 },
|
||||
default_branch: "main",
|
||||
},
|
||||
sender: { login: senderLogin, id: senderId },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a payload for an issue being labeled with the OpenHands label
|
||||
*/
|
||||
export function createIssueLabeledPayload(params: {
|
||||
installationId?: number;
|
||||
issueNumber?: number;
|
||||
issueTitle?: string;
|
||||
issueBody?: string;
|
||||
labelName?: string;
|
||||
repositoryName?: string;
|
||||
repositoryOwner?: string;
|
||||
senderLogin?: string;
|
||||
senderId?: number;
|
||||
}): IssuePayload {
|
||||
const {
|
||||
issueNumber = 1,
|
||||
issueTitle = "Test Issue for OpenHands Resolver",
|
||||
issueBody = "This is a test issue. Please add a README file.",
|
||||
labelName = "openhands",
|
||||
senderLogin = "test-user",
|
||||
senderId = 2000,
|
||||
...rest
|
||||
} = params;
|
||||
|
||||
const base = createBasePayload({ senderLogin, senderId, ...rest });
|
||||
const label = { name: labelName, id: Date.now() };
|
||||
|
||||
return {
|
||||
...base,
|
||||
action: "labeled",
|
||||
issue: {
|
||||
number: issueNumber,
|
||||
title: issueTitle,
|
||||
body: issueBody,
|
||||
state: "open",
|
||||
labels: [label],
|
||||
user: { login: senderLogin, id: senderId },
|
||||
},
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a payload for an issue comment mentioning @openhands
|
||||
*/
|
||||
export function createIssueCommentPayload(params: {
|
||||
installationId?: number;
|
||||
issueNumber?: number;
|
||||
issueTitle?: string;
|
||||
issueBody?: string;
|
||||
commentBody?: string;
|
||||
commentId?: number;
|
||||
repositoryName?: string;
|
||||
repositoryOwner?: string;
|
||||
senderLogin?: string;
|
||||
senderId?: number;
|
||||
isPullRequest?: boolean;
|
||||
}): IssueCommentPayload {
|
||||
const {
|
||||
issueNumber = 1,
|
||||
issueTitle = "Test Issue for OpenHands Resolver",
|
||||
issueBody = "This is a test issue.",
|
||||
commentBody = "@openhands please add a README file",
|
||||
commentId = 1001,
|
||||
senderLogin = "test-user",
|
||||
senderId = 2000,
|
||||
isPullRequest = false,
|
||||
...rest
|
||||
} = params;
|
||||
|
||||
const base = createBasePayload({ senderLogin, senderId, ...rest });
|
||||
|
||||
const payload: IssueCommentPayload = {
|
||||
...base,
|
||||
action: "created",
|
||||
issue: {
|
||||
number: issueNumber,
|
||||
title: issueTitle,
|
||||
body: issueBody,
|
||||
state: "open",
|
||||
labels: [],
|
||||
user: { login: "issue-creator", id: 3000 },
|
||||
},
|
||||
comment: {
|
||||
id: commentId,
|
||||
body: commentBody,
|
||||
user: { login: senderLogin, id: senderId },
|
||||
},
|
||||
};
|
||||
|
||||
if (isPullRequest) {
|
||||
payload.issue.pull_request = {
|
||||
url: `https://api.github.com/repos/${base.repository.full_name}/pulls/${issueNumber}`,
|
||||
};
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a payload for a PR review comment mentioning @openhands
|
||||
*/
|
||||
export function createPullRequestReviewCommentPayload(params: {
|
||||
installationId?: number;
|
||||
prNumber?: number;
|
||||
prTitle?: string;
|
||||
prBody?: string;
|
||||
commentBody?: string;
|
||||
commentId?: number;
|
||||
filePath?: string;
|
||||
lineNumber?: number;
|
||||
headBranch?: string;
|
||||
baseBranch?: string;
|
||||
repositoryName?: string;
|
||||
repositoryOwner?: string;
|
||||
senderLogin?: string;
|
||||
senderId?: number;
|
||||
}): PullRequestReviewCommentPayload {
|
||||
const {
|
||||
prNumber = 2,
|
||||
prTitle = "Test PR for OpenHands Resolver",
|
||||
prBody = "This is a test PR.",
|
||||
commentBody = "@openhands please fix this code",
|
||||
commentId = 2001,
|
||||
filePath = "src/main.ts",
|
||||
lineNumber = 10,
|
||||
headBranch = "feature-branch",
|
||||
baseBranch = "main",
|
||||
senderLogin = "test-user",
|
||||
senderId = 2000,
|
||||
...rest
|
||||
} = params;
|
||||
|
||||
const base = createBasePayload({ senderLogin, senderId, ...rest });
|
||||
|
||||
return {
|
||||
...base,
|
||||
action: "created",
|
||||
pull_request: {
|
||||
number: prNumber,
|
||||
title: prTitle,
|
||||
body: prBody,
|
||||
state: "open",
|
||||
head: { ref: headBranch, sha: "abc123def456" },
|
||||
base: { ref: baseBranch },
|
||||
user: { login: "pr-creator", id: 4000 },
|
||||
},
|
||||
comment: {
|
||||
id: commentId,
|
||||
node_id: `PRRC_${commentId}`,
|
||||
body: commentBody,
|
||||
path: filePath,
|
||||
line: lineNumber,
|
||||
user: { login: senderLogin, id: senderId },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the GitHub event type for a payload
|
||||
*/
|
||||
export function getEventType(payload: GitHubWebhookPayload): string {
|
||||
if ("comment" in payload && "pull_request" in payload) {
|
||||
return "pull_request_review_comment";
|
||||
}
|
||||
if ("comment" in payload) {
|
||||
return "issue_comment";
|
||||
}
|
||||
if ("issue" in payload) {
|
||||
return "issues";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
6
integration_tests/mocks/index.ts
Normal file
6
integration_tests/mocks/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Mock Server Exports
|
||||
*/
|
||||
|
||||
export * from "./github-webhook-payloads";
|
||||
export * from "./mock-github-client";
|
||||
223
integration_tests/mocks/mock-github-client.ts
Normal file
223
integration_tests/mocks/mock-github-client.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Mock GitHub Client
|
||||
*
|
||||
* Client utilities for interacting with the mock GitHub server during tests.
|
||||
*/
|
||||
|
||||
import {
|
||||
createIssueLabeledPayload,
|
||||
createIssueCommentPayload,
|
||||
createPullRequestReviewCommentPayload,
|
||||
getEventType,
|
||||
GitHubWebhookPayload,
|
||||
} from "./github-webhook-payloads";
|
||||
|
||||
export interface MockGitHubClientConfig {
|
||||
mockServerUrl: string;
|
||||
webhookTargetUrl: string;
|
||||
}
|
||||
|
||||
export interface TriggerWebhookResult {
|
||||
status: string;
|
||||
targetUrl: string;
|
||||
eventType: string;
|
||||
responseStatus: number;
|
||||
responseBody: string;
|
||||
}
|
||||
|
||||
export interface WebhookEvent {
|
||||
action: string;
|
||||
payload: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface OutgoingResponse {
|
||||
body: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client for interacting with the Mock GitHub Server
|
||||
*/
|
||||
export class MockGitHubClient {
|
||||
private mockServerUrl: string;
|
||||
|
||||
private webhookTargetUrl: string;
|
||||
|
||||
constructor(config: MockGitHubClientConfig) {
|
||||
this.mockServerUrl = config.mockServerUrl;
|
||||
this.webhookTargetUrl = config.webhookTargetUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the mock server is healthy
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.mockServerUrl}/_health`);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the mock server to be ready
|
||||
*/
|
||||
async waitForReady(timeoutMs = 30000): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
if (await this.healthCheck()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(`Mock GitHub server not ready after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a webhook to the target URL
|
||||
*/
|
||||
async triggerWebhook(
|
||||
payload: GitHubWebhookPayload,
|
||||
): Promise<TriggerWebhookResult> {
|
||||
const eventType = getEventType(payload);
|
||||
|
||||
const response = await fetch(
|
||||
`${this.mockServerUrl}/_test/trigger-webhook`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
targetUrl: this.webhookTargetUrl,
|
||||
eventType,
|
||||
payload,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an issue labeled event (simulates adding the openhands label)
|
||||
*/
|
||||
async triggerIssueLabeledEvent(
|
||||
params?: Parameters<typeof createIssueLabeledPayload>[0],
|
||||
): Promise<TriggerWebhookResult> {
|
||||
const payload = createIssueLabeledPayload(params || {});
|
||||
return this.triggerWebhook(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an issue comment event (simulates @openhands mention in issue)
|
||||
*/
|
||||
async triggerIssueCommentEvent(
|
||||
params?: Parameters<typeof createIssueCommentPayload>[0],
|
||||
): Promise<TriggerWebhookResult> {
|
||||
const payload = createIssueCommentPayload(params || {});
|
||||
return this.triggerWebhook(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a PR review comment event (simulates @openhands mention in PR)
|
||||
*/
|
||||
async triggerPRReviewCommentEvent(
|
||||
params?: Parameters<typeof createPullRequestReviewCommentPayload>[0],
|
||||
): Promise<TriggerWebhookResult> {
|
||||
const payload = createPullRequestReviewCommentPayload(params || {});
|
||||
return this.triggerWebhook(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recorded webhook events
|
||||
*/
|
||||
async getWebhookEvents(): Promise<WebhookEvent[]> {
|
||||
const response = await fetch(`${this.mockServerUrl}/_test/webhook-events`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all outgoing responses (comments posted by the resolver)
|
||||
*/
|
||||
async getOutgoingResponses(): Promise<OutgoingResponse[]> {
|
||||
const response = await fetch(
|
||||
`${this.mockServerUrl}/_test/outgoing-responses`,
|
||||
);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all recorded events
|
||||
*/
|
||||
async clearEvents(): Promise<void> {
|
||||
await fetch(`${this.mockServerUrl}/_test/clear-events`, { method: "POST" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all mock data to initial state
|
||||
*/
|
||||
async reset(): Promise<void> {
|
||||
await fetch(`${this.mockServerUrl}/_test/reset`, { method: "POST" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the resolver to post a response
|
||||
* @param timeoutMs Maximum time to wait
|
||||
* @param expectedCount Number of responses to wait for (default: 1)
|
||||
* @param checkIntervalMs How often to check for responses
|
||||
*/
|
||||
async waitForResponse(
|
||||
timeoutMs = 120000,
|
||||
expectedCount = 1,
|
||||
checkIntervalMs = 2000,
|
||||
): Promise<OutgoingResponse[]> {
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
const responses = await this.getOutgoingResponses();
|
||||
if (responses.length >= expectedCount) {
|
||||
return responses;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, checkIntervalMs));
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for ${expectedCount} response(s) after ${timeoutMs}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a response containing specific text
|
||||
*/
|
||||
async waitForResponseContaining(
|
||||
expectedText: string,
|
||||
timeoutMs = 120000,
|
||||
checkIntervalMs = 2000,
|
||||
): Promise<OutgoingResponse> {
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
const responses = await this.getOutgoingResponses();
|
||||
for (const response of responses) {
|
||||
if (response.body.includes(expectedText)) {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, checkIntervalMs));
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for response containing "${expectedText}" after ${timeoutMs}ms`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a MockGitHubClient with default configuration
|
||||
*/
|
||||
export function createMockGitHubClient(
|
||||
mockServerPort = 9999,
|
||||
appPort = 12000,
|
||||
): MockGitHubClient {
|
||||
return new MockGitHubClient({
|
||||
mockServerUrl: `http://localhost:${mockServerPort}`,
|
||||
webhookTargetUrl: `http://localhost:${appPort}/api/integration/github/events`,
|
||||
});
|
||||
}
|
||||
543
integration_tests/package-lock.json
generated
543
integration_tests/package-lock.json
generated
@@ -22,12 +22,455 @@
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-unused-imports": "^3.2.0",
|
||||
"prettier": "^3.4.2",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||
@@ -1192,6 +1635,48 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.4",
|
||||
"@esbuild/android-arm": "0.27.4",
|
||||
"@esbuild/android-arm64": "0.27.4",
|
||||
"@esbuild/android-x64": "0.27.4",
|
||||
"@esbuild/darwin-arm64": "0.27.4",
|
||||
"@esbuild/darwin-x64": "0.27.4",
|
||||
"@esbuild/freebsd-arm64": "0.27.4",
|
||||
"@esbuild/freebsd-x64": "0.27.4",
|
||||
"@esbuild/linux-arm": "0.27.4",
|
||||
"@esbuild/linux-arm64": "0.27.4",
|
||||
"@esbuild/linux-ia32": "0.27.4",
|
||||
"@esbuild/linux-loong64": "0.27.4",
|
||||
"@esbuild/linux-mips64el": "0.27.4",
|
||||
"@esbuild/linux-ppc64": "0.27.4",
|
||||
"@esbuild/linux-riscv64": "0.27.4",
|
||||
"@esbuild/linux-s390x": "0.27.4",
|
||||
"@esbuild/linux-x64": "0.27.4",
|
||||
"@esbuild/netbsd-arm64": "0.27.4",
|
||||
"@esbuild/netbsd-x64": "0.27.4",
|
||||
"@esbuild/openbsd-arm64": "0.27.4",
|
||||
"@esbuild/openbsd-x64": "0.27.4",
|
||||
"@esbuild/openharmony-arm64": "0.27.4",
|
||||
"@esbuild/sunos-x64": "0.27.4",
|
||||
"@esbuild/win32-arm64": "0.27.4",
|
||||
"@esbuild/win32-ia32": "0.27.4",
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
@@ -1921,6 +2406,19 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.6",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
||||
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
@@ -3254,6 +3752,16 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
@@ -3729,6 +4237,41 @@
|
||||
"strip-bom": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:smoke": "playwright test --grep @smoke",
|
||||
"test:github-resolver": "playwright test --grep @github-resolver",
|
||||
"test:staging": "cross-env BASE_URL=https://staging.all-hands.dev playwright test",
|
||||
"test:production": "cross-env BASE_URL=https://app.all-hands.dev playwright test",
|
||||
"test:feature": "playwright test",
|
||||
@@ -18,7 +19,9 @@
|
||||
"codegen": "playwright codegen",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "npm run typecheck && eslint . --ext .ts && prettier --check \"**/*.ts\"",
|
||||
"lint:fix": "eslint . --ext .ts --fix && prettier --write \"**/*.ts\""
|
||||
"lint:fix": "eslint . --ext .ts --fix && prettier --write \"**/*.ts\"",
|
||||
"mock:github": "tsx mocks/github-mock-server.ts",
|
||||
"mock:github:start": "tsx mocks/github-mock-server.ts &"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.57.0",
|
||||
@@ -35,6 +38,7 @@
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-unused-imports": "^3.2.0",
|
||||
"prettier": "^3.4.2",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
403
integration_tests/tests/github-resolver.spec.ts
Normal file
403
integration_tests/tests/github-resolver.spec.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { ChildProcess, spawn } from "child_process";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
import {
|
||||
MockGitHubClient,
|
||||
createMockGitHubClient,
|
||||
createIssueLabeledPayload,
|
||||
} from "../mocks";
|
||||
import { ConversationPage, HomePage } from "../pages";
|
||||
|
||||
/**
|
||||
* GitHub Resolver Integration Tests
|
||||
*
|
||||
* These tests verify the GitHub resolver integration in two modes:
|
||||
*
|
||||
* ## Mode 1: Mock Server Tests (for local development)
|
||||
* Uses a local mock GitHub server to test the full webhook flow.
|
||||
* Requires:
|
||||
* - OpenHands running locally with GITHUB_APP_WEBHOOK_SECRET=test-webhook-secret
|
||||
* - The app configured to use the mock server for GitHub API calls
|
||||
*
|
||||
* ## Mode 2: Live Environment Tests (for staging/production)
|
||||
* Tests against real deployed environments using the real GitHub API.
|
||||
* Requires:
|
||||
* - GITHUB_TEST_USERNAME and GITHUB_TEST_PASSWORD for authentication
|
||||
* - The webhook endpoint to be accessible
|
||||
*
|
||||
* Environment Variables:
|
||||
* - USE_MOCK_GITHUB: Set to "true" to use mock server mode
|
||||
* - MOCK_GITHUB_PORT: Port for the mock GitHub server (default: 9999)
|
||||
* - APP_PORT: Port where the OpenHands app is running (default: 12000)
|
||||
* - GITHUB_APP_WEBHOOK_SECRET: Webhook secret for local testing
|
||||
*
|
||||
* Tags:
|
||||
* - @github-resolver: GitHub resolver integration tests
|
||||
* - @enterprise: Tests requiring enterprise features
|
||||
*/
|
||||
|
||||
// Configuration
|
||||
const USE_MOCK_GITHUB = process.env.USE_MOCK_GITHUB === "true";
|
||||
const MOCK_GITHUB_PORT = parseInt(process.env.MOCK_GITHUB_PORT || "9999", 10);
|
||||
const APP_PORT = parseInt(process.env.APP_PORT || "12000", 10);
|
||||
const MOCK_SERVER_STARTUP_TIMEOUT = 30_000;
|
||||
const RESOLVER_RESPONSE_TIMEOUT = 180_000;
|
||||
|
||||
// Mock server process
|
||||
let mockServerProcess: ChildProcess | null = null;
|
||||
let mockClient: MockGitHubClient | null = null;
|
||||
|
||||
/**
|
||||
* Generate webhook signature for testing
|
||||
*/
|
||||
function generateWebhookSignature(payload: string, secret: string): string {
|
||||
const hmac = crypto.createHmac("sha256", secret);
|
||||
hmac.update(payload);
|
||||
return `sha256=${hmac.digest("hex")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the mock GitHub server as a background process
|
||||
*/
|
||||
async function startMockServer(): Promise<void> {
|
||||
if (!USE_MOCK_GITHUB) return;
|
||||
|
||||
const serverPath = path.join(
|
||||
import.meta.dirname,
|
||||
"../mocks/github-mock-server.ts",
|
||||
);
|
||||
|
||||
console.log(`Starting mock GitHub server on port ${MOCK_GITHUB_PORT}...`);
|
||||
|
||||
mockServerProcess = spawn("npx", ["tsx", serverPath], {
|
||||
env: {
|
||||
...process.env,
|
||||
MOCK_GITHUB_PORT: String(MOCK_GITHUB_PORT),
|
||||
MOCK_GITHUB_WEBHOOK_SECRET:
|
||||
process.env.GITHUB_APP_WEBHOOK_SECRET || "test-webhook-secret",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
mockServerProcess.stdout?.on("data", (data) => {
|
||||
console.log(`[Mock GitHub] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
mockServerProcess.stderr?.on("data", (data) => {
|
||||
console.error(`[Mock GitHub ERROR] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
mockServerProcess.on("error", (error) => {
|
||||
console.error(`[Mock GitHub] Failed to start server: ${error.message}`);
|
||||
});
|
||||
|
||||
mockServerProcess.on("exit", (code) => {
|
||||
console.log(`[Mock GitHub] Server exited with code ${code}`);
|
||||
});
|
||||
|
||||
mockClient = createMockGitHubClient(MOCK_GITHUB_PORT, APP_PORT);
|
||||
await mockClient.waitForReady(MOCK_SERVER_STARTUP_TIMEOUT);
|
||||
console.log("Mock GitHub server is ready");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the mock GitHub server
|
||||
*/
|
||||
async function stopMockServer(): Promise<void> {
|
||||
if (mockServerProcess) {
|
||||
console.log("Stopping mock GitHub server...");
|
||||
mockServerProcess.kill("SIGTERM");
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
mockServerProcess?.kill("SIGKILL");
|
||||
resolve();
|
||||
}, 5000);
|
||||
|
||||
mockServerProcess?.on("exit", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
mockServerProcess = null;
|
||||
console.log("Mock GitHub server stopped");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SERVER TESTS (for local development with mock GitHub)
|
||||
// ============================================================================
|
||||
|
||||
test.describe("GitHub Resolver - Mock Server @github-resolver @enterprise @mock", () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
// Skip this entire suite unless USE_MOCK_GITHUB is true
|
||||
test.skip(!USE_MOCK_GITHUB, "Requires USE_MOCK_GITHUB=true");
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await startMockServer();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await stopMockServer();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
if (mockClient) {
|
||||
await mockClient.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("mock server should be healthy", async () => {
|
||||
expect(mockClient).not.toBeNull();
|
||||
const isHealthy = await mockClient!.healthCheck();
|
||||
expect(isHealthy).toBe(true);
|
||||
});
|
||||
|
||||
test("should process issue labeled webhook and create conversation", async ({
|
||||
page,
|
||||
baseURL,
|
||||
}) => {
|
||||
expect(mockClient).not.toBeNull();
|
||||
|
||||
console.log("Triggering issue labeled webhook...");
|
||||
const webhookResult = await mockClient!.triggerIssueLabeledEvent({
|
||||
issueTitle: "Add README file",
|
||||
issueBody: "Please add a README.md file with project documentation.",
|
||||
labelName: "openhands",
|
||||
});
|
||||
|
||||
console.log(`Webhook response: ${JSON.stringify(webhookResult)}`);
|
||||
expect(webhookResult.responseStatus).toBe(200);
|
||||
|
||||
console.log("Waiting for resolver response...");
|
||||
const response = await mockClient!.waitForResponseContaining(
|
||||
"I'm on it",
|
||||
RESOLVER_RESPONSE_TIMEOUT,
|
||||
);
|
||||
|
||||
console.log(`Resolver response: ${response.body}`);
|
||||
expect(response.body).toContain("I'm on it");
|
||||
expect(response.body).toContain("track my progress");
|
||||
|
||||
const conversationLinkMatch = response.body.match(
|
||||
/conversations\/([a-f0-9]+)/,
|
||||
);
|
||||
expect(conversationLinkMatch).not.toBeNull();
|
||||
|
||||
const conversationId = conversationLinkMatch![1];
|
||||
console.log(`Conversation ID: ${conversationId}`);
|
||||
|
||||
const conversationPage = new ConversationPage(page);
|
||||
await page.goto(`${baseURL}/conversations/${conversationId}`);
|
||||
await conversationPage.waitForConversationReady(30_000);
|
||||
await expect(conversationPage.chatBox).toBeVisible();
|
||||
|
||||
await page.screenshot({
|
||||
path: "test-results/screenshots/github-resolver-conversation.png",
|
||||
});
|
||||
|
||||
console.log("Issue labeled webhook test passed");
|
||||
});
|
||||
|
||||
test("should process issue comment webhook with @openhands mention", async ({
|
||||
page,
|
||||
baseURL,
|
||||
}) => {
|
||||
expect(mockClient).not.toBeNull();
|
||||
|
||||
console.log("Triggering issue comment webhook...");
|
||||
const webhookResult = await mockClient!.triggerIssueCommentEvent({
|
||||
issueTitle: "Bug: Application crashes on startup",
|
||||
issueBody: "The application crashes when I try to start it.",
|
||||
commentBody: "@openhands please investigate this crash and fix it",
|
||||
});
|
||||
|
||||
console.log(`Webhook response: ${JSON.stringify(webhookResult)}`);
|
||||
expect(webhookResult.responseStatus).toBe(200);
|
||||
|
||||
console.log("Waiting for resolver response...");
|
||||
const response = await mockClient!.waitForResponseContaining(
|
||||
"I'm on it",
|
||||
RESOLVER_RESPONSE_TIMEOUT,
|
||||
);
|
||||
|
||||
console.log(`Resolver response: ${response.body}`);
|
||||
expect(response.body).toContain("I'm on it");
|
||||
|
||||
const conversationLinkMatch = response.body.match(
|
||||
/conversations\/([a-f0-9]+)/,
|
||||
);
|
||||
expect(conversationLinkMatch).not.toBeNull();
|
||||
|
||||
const conversationId = conversationLinkMatch![1];
|
||||
const conversationPage = new ConversationPage(page);
|
||||
await page.goto(`${baseURL}/conversations/${conversationId}`);
|
||||
await conversationPage.waitForConversationReady(30_000);
|
||||
|
||||
await page.screenshot({
|
||||
path: "test-results/screenshots/github-resolver-issue-comment.png",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// LIVE ENVIRONMENT TESTS (for staging/production with real GitHub)
|
||||
// ============================================================================
|
||||
|
||||
test.describe("GitHub Resolver - Live Environment @github-resolver @enterprise @live", () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
let homePage: HomePage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
homePage = new HomePage(page);
|
||||
});
|
||||
|
||||
test("should verify resolver conversations appear in conversation list", async ({
|
||||
page,
|
||||
}) => {
|
||||
/**
|
||||
* This test verifies that resolver-triggered conversations appear in the
|
||||
* user's conversation list. It checks the infrastructure is working by
|
||||
* looking at existing conversations.
|
||||
*/
|
||||
|
||||
// Navigate to home page (requires authentication via global-setup)
|
||||
await homePage.goto();
|
||||
await expect(homePage.homeScreen).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Look for recent conversations
|
||||
const recentConversations = page.getByTestId("recent-conversations");
|
||||
await expect(recentConversations).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const conversationLinks = recentConversations.locator(
|
||||
'a[href^="/conversations/"]',
|
||||
);
|
||||
const count = await conversationLinks.count();
|
||||
|
||||
console.log(`Found ${count} recent conversations`);
|
||||
|
||||
await page.screenshot({
|
||||
path: "test-results/screenshots/resolver-conversations-list.png",
|
||||
});
|
||||
|
||||
if (count > 0) {
|
||||
const firstConversation = conversationLinks.first();
|
||||
await firstConversation.click();
|
||||
|
||||
const conversationPage = new ConversationPage(page);
|
||||
await conversationPage.waitForConversationReady(30_000);
|
||||
|
||||
await page.screenshot({
|
||||
path: "test-results/screenshots/resolver-conversation-detail.png",
|
||||
});
|
||||
|
||||
console.log("Successfully navigated to a conversation");
|
||||
}
|
||||
});
|
||||
|
||||
test("should be able to send webhook with valid signature format", async ({
|
||||
baseURL,
|
||||
request,
|
||||
}) => {
|
||||
/**
|
||||
* This test verifies the webhook endpoint exists and validates signatures.
|
||||
* We send a properly formatted but invalid webhook to verify:
|
||||
* 1. The endpoint exists
|
||||
* 2. Signature verification is working
|
||||
*/
|
||||
|
||||
const payload = createIssueLabeledPayload({
|
||||
issueTitle: "Test Issue",
|
||||
issueBody: "Test body for integration test",
|
||||
labelName: "openhands",
|
||||
});
|
||||
|
||||
const payloadString = JSON.stringify(payload);
|
||||
const signature = generateWebhookSignature(payloadString, "wrong-secret");
|
||||
|
||||
const response = await request.post(
|
||||
`${baseURL}/api/integration/github/events`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-GitHub-Event": "issues",
|
||||
"X-Hub-Signature-256": signature,
|
||||
"X-GitHub-Delivery": crypto.randomUUID(),
|
||||
},
|
||||
data: payload,
|
||||
},
|
||||
);
|
||||
|
||||
console.log(`Webhook response status: ${response.status()}`);
|
||||
|
||||
// Either 403 (signature invalid) or 200 (if webhooks disabled) is acceptable
|
||||
expect([200, 403]).toContain(response.status());
|
||||
|
||||
const responseText = await response.text();
|
||||
console.log(`Webhook response: ${responseText}`);
|
||||
|
||||
if (response.status() === 403) {
|
||||
console.log(
|
||||
"Webhook signature validation is working (403 = invalid signature)",
|
||||
);
|
||||
} else if (response.status() === 200) {
|
||||
const body = JSON.parse(responseText);
|
||||
if (body.message?.includes("disabled")) {
|
||||
console.log("GitHub webhooks are disabled on this environment");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// ERROR HANDLING TESTS
|
||||
// ============================================================================
|
||||
|
||||
test.describe("GitHub Resolver - Error Handling @github-resolver @enterprise", () => {
|
||||
test("should reject webhook without signature header", async ({
|
||||
baseURL,
|
||||
request,
|
||||
}) => {
|
||||
const payload = { action: "labeled", installation: { id: 12345 } };
|
||||
|
||||
const response = await request.post(
|
||||
`${baseURL}/api/integration/github/events`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-GitHub-Event": "issues",
|
||||
},
|
||||
data: payload,
|
||||
},
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Response status: ${response.status()} (expected 403 or 200 if disabled)`,
|
||||
);
|
||||
expect([200, 403]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("should handle malformed JSON gracefully", async ({
|
||||
baseURL,
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.post(
|
||||
`${baseURL}/api/integration/github/events`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-GitHub-Event": "issues",
|
||||
"X-Hub-Signature-256": "sha256=invalid",
|
||||
},
|
||||
data: "not valid json{{{",
|
||||
},
|
||||
);
|
||||
|
||||
console.log(`Response status: ${response.status()}`);
|
||||
expect([400, 403, 422, 500]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user