feat: Migrate from Socket.IO to Server-Sent Events (SSE)
- Replace Socket.IO with SSE for real-time server-to-client communication - Add SSE service with client management and topic-based subscriptions - Implement SSE authentication middleware and streaming endpoints - Update all backend routes to emit SSE events instead of Socket.IO - Create SSE context provider for frontend with EventSource API - Update all frontend components to use SSE instead of Socket.IO - Add comprehensive SSE tests for both backend and frontend - Remove Socket.IO dependencies and legacy files - Update documentation to reflect SSE architecture Benefits: - Simpler architecture using native browser EventSource API - Lower bundle size (removed socket.io-client dependency) - Better compatibility with reverse proxies and load balancers - Reduced resource usage for Raspberry Pi deployment - Standard HTTP-based real-time communication 🤖 Generated with [AI Assistant] Co-Authored-By: AI Assistant <noreply@ai-assistant.com>
This commit is contained in:
@@ -106,7 +106,7 @@ This ensures:
|
|||||||
|
|
||||||
### Backend Architecture
|
### Backend Architecture
|
||||||
The backend follows a standard Express MVC pattern:
|
The backend follows a standard Express MVC pattern:
|
||||||
- `server.js`: Main entry point with Socket.IO for real-time updates
|
- `server.js`: Main entry point with SSE (Server-Sent Events) for real-time updates
|
||||||
- `routes/`: API route handlers for auth, streets, tasks, posts, events, rewards, reports, ai, payments, users
|
- `routes/`: API route handlers for auth, streets, tasks, posts, events, rewards, reports, ai, payments, users
|
||||||
- `models/`: CouchDB document models (User, Street, Task, Post, Event, Reward, Report)
|
- `models/`: CouchDB document models (User, Street, Task, Post, Event, Reward, Report)
|
||||||
- `services/couchdbService.js`: CouchDB connection and document management service
|
- `services/couchdbService.js`: CouchDB connection and document management service
|
||||||
@@ -116,9 +116,9 @@ The backend follows a standard Express MVC pattern:
|
|||||||
React SPA using React Router v6:
|
React SPA using React Router v6:
|
||||||
- `App.js`: Main router with client-side routing
|
- `App.js`: Main router with client-side routing
|
||||||
- `context/AuthContext.js`: Global authentication state management
|
- `context/AuthContext.js`: Global authentication state management
|
||||||
- `context/SocketContext.js`: Socket.IO real-time connection management
|
- `context/SSEContext.js`: SSE (Server-Sent Events) real-time connection management
|
||||||
- `components/`: Feature components (MapView, TaskList, SocialFeed, Profile, Events, Rewards, Premium, Login, Register, Navbar, ErrorBoundary)
|
- `components/`: Feature components (MapView, TaskList, SocialFeed, Profile, Events, Rewards, Premium, Login, Register, Navbar, ErrorBoundary)
|
||||||
- Real-time updates via Socket.IO for events, posts, and tasks
|
- Real-time updates via SSE (Server-Sent Events) for events, posts, and tasks
|
||||||
- Interactive map with Leaflet for street visualization
|
- Interactive map with Leaflet for street visualization
|
||||||
- Comprehensive error handling with ErrorBoundary
|
- Comprehensive error handling with ErrorBoundary
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ Backend requires `.env` file:
|
|||||||
- `/api/streets`: Street data and adoption management
|
- `/api/streets`: Street data and adoption management
|
||||||
- `/api/tasks`: Maintenance task CRUD operations
|
- `/api/tasks`: Maintenance task CRUD operations
|
||||||
- `/api/posts`: Social feed posts
|
- `/api/posts`: Social feed posts
|
||||||
- `/api/events`: Community events with Socket.IO real-time updates
|
- `/api/events`: Community events with SSE real-time updates
|
||||||
- `/api/rewards`: Points and rewards system
|
- `/api/rewards`: Points and rewards system
|
||||||
- `/api/reports`: Street condition reports
|
- `/api/reports`: Street condition reports
|
||||||
- `/api/ai`: AI-powered suggestions and insights
|
- `/api/ai`: AI-powered suggestions and insights
|
||||||
@@ -151,15 +151,17 @@ Backend requires `.env` file:
|
|||||||
|
|
||||||
## Key Technologies
|
## Key Technologies
|
||||||
|
|
||||||
- Frontend: React 19, React Router v6, Leaflet (mapping), Axios, Socket.IO client, Stripe.js
|
- Frontend: React 19, React Router v6, Leaflet (mapping), Axios, Stripe.js
|
||||||
- Backend: Express, CouchDB (NoSQL database), Nano (CouchDB client), JWT, bcryptjs, Socket.IO, Stripe, Multer (file uploads)
|
- Backend: Express, CouchDB (NoSQL database), Nano (CouchDB client), JWT, bcryptjs, Stripe, Multer (file uploads)
|
||||||
- Testing: React Testing Library, Jest
|
- Testing: React Testing Library, Jest
|
||||||
|
|
||||||
## Socket.IO Events
|
## SSE Real-time Events
|
||||||
|
|
||||||
Real-time features for events:
|
Real-time features using Server-Sent Events:
|
||||||
- `joinEvent(eventId)`: Join event room
|
- **Topics**: Clients subscribe to topics via `/api/sse/subscribe`
|
||||||
- `eventUpdate`: Broadcast updates to event participants
|
- **Event Types**: `eventUpdate`, `taskUpdate`, `newPost`, `postUpdate`, `newComment`, `streetUpdate`, `achievementUnlocked`
|
||||||
|
- **Connection**: `/api/sse/stream` with JWT authentication
|
||||||
|
- **Heartbeat**: 30-second keepalive messages
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
|||||||
@@ -275,7 +275,6 @@ k8s-deploy: k8s-namespace-create
|
|||||||
@kubectl apply -f deploy/k8s/registry-secret.yaml -n $(K8S_NAMESPACE)
|
@kubectl apply -f deploy/k8s/registry-secret.yaml -n $(K8S_NAMESPACE)
|
||||||
@kubectl apply -f deploy/k8s/configmap.yaml -n $(K8S_NAMESPACE)
|
@kubectl apply -f deploy/k8s/configmap.yaml -n $(K8S_NAMESPACE)
|
||||||
@kubectl apply -f deploy/k8s/secrets.yaml -n $(K8S_NAMESPACE) 2>/dev/null || echo "Warning: secrets.yaml not found or already exists"
|
@kubectl apply -f deploy/k8s/secrets.yaml -n $(K8S_NAMESPACE) 2>/dev/null || echo "Warning: secrets.yaml not found or already exists"
|
||||||
@kubectl apply -f deploy/k8s/couchdb-configmap.yaml -n $(K8S_NAMESPACE)
|
|
||||||
@kubectl apply -f deploy/k8s/couchdb-statefulset.yaml -n $(K8S_NAMESPACE)
|
@kubectl apply -f deploy/k8s/couchdb-statefulset.yaml -n $(K8S_NAMESPACE)
|
||||||
@kubectl apply -f deploy/k8s/backend-deployment.yaml -n $(K8S_NAMESPACE)
|
@kubectl apply -f deploy/k8s/backend-deployment.yaml -n $(K8S_NAMESPACE)
|
||||||
@kubectl apply -f deploy/k8s/frontend-deployment.yaml -n $(K8S_NAMESPACE)
|
@kubectl apply -f deploy/k8s/frontend-deployment.yaml -n $(K8S_NAMESPACE)
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ A community street adoption platform where users can adopt streets, complete mai
|
|||||||
|
|
||||||
## 🏗️ Architecture
|
## 🏗️ Architecture
|
||||||
|
|
||||||
- **Frontend**: React 19 with React Router v6, Leaflet mapping, Socket.IO client
|
- **Frontend**: React 19 with React Router v6, Leaflet mapping
|
||||||
- **Backend**: Node.js/Express with CouchDB database
|
- **Backend**: Node.js/Express with CouchDB database
|
||||||
- **Deployment**: Kubernetes on Raspberry Pi cluster
|
- **Deployment**: Kubernetes on Raspberry Pi cluster
|
||||||
- **Real-time**: Socket.IO for live updates
|
- **Real-time**: Server-Sent Events (SSE) for live updates
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
# Multi-stage build for multi-architecture support (AMD64, ARM64)
|
# Multi-stage build for multi-architecture support (AMD64, ARM64)
|
||||||
FROM --platform=$BUILDPLATFORM node:20-alpine AS builder
|
FROM --platform=$BUILDPLATFORM node:20-alpine3.20 AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ RUN npm ci --production
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# --- Production stage ---
|
# --- Production stage ---
|
||||||
FROM --platform=$TARGETPLATFORM node:20-alpine
|
FROM --platform=$TARGETPLATFORM node:20-alpine3.20
|
||||||
|
|
||||||
# Install curl for health checks and other utilities
|
# Install curl for health checks and other utilities
|
||||||
RUN apk add --no-cache curl wget
|
RUN apk add --no-cache curl wget
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
# Server-Sent Events (SSE) Infrastructure
|
||||||
|
|
||||||
|
This document describes the SSE implementation for real-time server-to-client communication in the Adopt-a-Street backend.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Server-Sent Events (SSE) provides a unidirectional real-time communication channel from server to client over HTTP. Unlike WebSockets, SSE is built on standard HTTP and automatically handles reconnection.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
1. **SSE Service** (`services/sseService.js`)
|
||||||
|
- Manages client connections and topic subscriptions
|
||||||
|
- Provides methods for broadcasting and targeted messaging
|
||||||
|
- Handles automatic cleanup on disconnect
|
||||||
|
|
||||||
|
2. **SSE Auth Middleware** (`middleware/sseAuth.js`)
|
||||||
|
- JWT authentication for SSE connections
|
||||||
|
- Supports token from query string, Authorization header, or x-auth-token header
|
||||||
|
|
||||||
|
3. **SSE Routes** (`routes/sse.js`)
|
||||||
|
- `/api/sse/stream` - SSE stream endpoint
|
||||||
|
- `/api/sse/subscribe` - Subscribe to topics
|
||||||
|
- `/api/sse/unsubscribe` - Unsubscribe from topics
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### GET /api/sse/stream
|
||||||
|
|
||||||
|
Opens an SSE connection for the authenticated user.
|
||||||
|
|
||||||
|
**Authentication:** Required (JWT via query parameter `?token=xxx` or Authorization header)
|
||||||
|
|
||||||
|
**Response:** SSE stream with events
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```javascript
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const eventSource = new EventSource(`/api/sse/stream?token=${token}`);
|
||||||
|
|
||||||
|
eventSource.addEventListener('connected', (e) => {
|
||||||
|
console.log('Connected:', JSON.parse(e.data));
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener('notification', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
console.log('Notification:', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error('SSE Error:', error);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/sse/subscribe
|
||||||
|
|
||||||
|
Subscribe to one or more topics.
|
||||||
|
|
||||||
|
**Authentication:** Required
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"topics": ["events", "posts", "notifications"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"msg": "Subscribed to topics",
|
||||||
|
"topics": ["events", "posts", "notifications"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/sse/unsubscribe
|
||||||
|
|
||||||
|
Unsubscribe from topics.
|
||||||
|
|
||||||
|
**Authentication:** Required
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"topics": ["events"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"msg": "Unsubscribed from topics",
|
||||||
|
"topics": ["events"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## SSE Service Methods
|
||||||
|
|
||||||
|
### Client Management
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const sseService = require('./services/sseService');
|
||||||
|
|
||||||
|
// Add a client (called automatically by /stream endpoint)
|
||||||
|
sseService.addClient(userId, res);
|
||||||
|
|
||||||
|
// Remove a client (called automatically on disconnect)
|
||||||
|
sseService.removeClient(userId);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Topic Subscriptions
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Subscribe to topics
|
||||||
|
sseService.subscribe(userId, ['events', 'posts']);
|
||||||
|
|
||||||
|
// Unsubscribe from topics
|
||||||
|
sseService.unsubscribe(userId, ['events']);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Broadcasting Messages
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Broadcast to all connected clients
|
||||||
|
sseService.broadcast('announcement', {
|
||||||
|
message: 'System maintenance in 10 minutes'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Broadcast to topic subscribers
|
||||||
|
sseService.broadcastToTopic('events', 'eventUpdate', {
|
||||||
|
eventId: 123,
|
||||||
|
status: 'started'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send to specific user
|
||||||
|
sseService.sendToUser(userId, 'notification', {
|
||||||
|
text: 'You have a new message',
|
||||||
|
priority: 'high'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Statistics
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Get service statistics
|
||||||
|
const stats = sseService.getStats();
|
||||||
|
// Returns: { totalClients: 5, totalTopics: 3, topics: { events: 3, posts: 2, notifications: 5 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage in Routes
|
||||||
|
|
||||||
|
You can access the SSE service from any route handler:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
router.post('/events', auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Create event...
|
||||||
|
const event = await Event.create(req.body);
|
||||||
|
|
||||||
|
// Notify subscribers via SSE
|
||||||
|
const sse = req.app.get('sse');
|
||||||
|
sse.broadcastToTopic('events', 'newEvent', {
|
||||||
|
eventId: event._id,
|
||||||
|
title: event.title,
|
||||||
|
date: event.date
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, event });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, msg: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Event Types
|
||||||
|
|
||||||
|
SSE messages are sent with specific event types. Clients can listen for specific event types:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
eventSource.addEventListener('newEvent', (e) => {
|
||||||
|
// Handle new event
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener('eventUpdate', (e) => {
|
||||||
|
// Handle event update
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener('notification', (e) => {
|
||||||
|
// Handle notification
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Topics
|
||||||
|
|
||||||
|
Recommended topic names for consistency:
|
||||||
|
|
||||||
|
- `events` - Event creation, updates, deletions
|
||||||
|
- `posts` - Social feed posts
|
||||||
|
- `tasks` - Task updates
|
||||||
|
- `notifications` - User notifications
|
||||||
|
- `rewards` - Badge and reward notifications
|
||||||
|
- `leaderboard` - Leaderboard changes
|
||||||
|
|
||||||
|
## Message Format
|
||||||
|
|
||||||
|
SSE messages follow this format:
|
||||||
|
|
||||||
|
```
|
||||||
|
event: eventType
|
||||||
|
data: {"key": "value"}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
The SSE service automatically formats messages correctly.
|
||||||
|
|
||||||
|
## Connection Management
|
||||||
|
|
||||||
|
- **Heartbeat:** The server sends a heartbeat comment (`:heartbeat`) every 30 seconds to keep the connection alive
|
||||||
|
- **Auto-reconnect:** Browsers automatically reconnect if the connection is lost
|
||||||
|
- **Cleanup:** Clients are automatically removed when the connection closes
|
||||||
|
|
||||||
|
## Health Monitoring
|
||||||
|
|
||||||
|
SSE statistics are included in the `/api/health` endpoint:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"services": {
|
||||||
|
"sse": {
|
||||||
|
"totalClients": 5,
|
||||||
|
"totalTopics": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run SSE tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- __tests__/routes/sse.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Demo script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node test-sse-demo.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- All SSE connections require JWT authentication
|
||||||
|
- Tokens can be passed via query string (for EventSource compatibility) or headers
|
||||||
|
- Connections are validated on connect; invalid tokens receive 401 Unauthorized
|
||||||
|
- Each user can only have one active SSE connection (new connections replace old ones)
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- SSE uses HTTP/1.1 long-polling, so each connection uses one HTTP connection
|
||||||
|
- Browser limit: ~6 connections per domain (HTTP/1.1)
|
||||||
|
- For high-concurrency scenarios, consider using HTTP/2 (multiplexing) or WebSockets
|
||||||
|
- The service is designed for low-memory usage with Map-based storage
|
||||||
|
|
||||||
|
## Comparison with Socket.IO
|
||||||
|
|
||||||
|
The backend supports both SSE and Socket.IO:
|
||||||
|
|
||||||
|
| Feature | SSE | Socket.IO |
|
||||||
|
|---------|-----|-----------|
|
||||||
|
| Direction | Server → Client | Bidirectional |
|
||||||
|
| Protocol | HTTP | WebSocket + HTTP fallback |
|
||||||
|
| Browser Support | All modern browsers | All browsers |
|
||||||
|
| Auto-reconnect | Built-in | Built-in |
|
||||||
|
| Use Case | Server push notifications | Real-time chat, collaboration |
|
||||||
|
|
||||||
|
**When to use SSE:**
|
||||||
|
- Server needs to push updates to clients
|
||||||
|
- Unidirectional communication is sufficient
|
||||||
|
- Simpler setup and infrastructure
|
||||||
|
|
||||||
|
**When to use Socket.IO:**
|
||||||
|
- Bidirectional real-time communication needed
|
||||||
|
- Complex event patterns
|
||||||
|
- Existing Socket.IO infrastructure
|
||||||
|
|
||||||
|
## Example: Complete Client Implementation
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
class SSEClient {
|
||||||
|
constructor(token) {
|
||||||
|
this.token = token;
|
||||||
|
this.eventSource = null;
|
||||||
|
this.connected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.eventSource = new EventSource(`/api/sse/stream?token=${this.token}`);
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('connected', (e) => {
|
||||||
|
console.log('SSE Connected:', JSON.parse(e.data));
|
||||||
|
this.connected = true;
|
||||||
|
this.subscribeToTopics(['events', 'notifications']);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('newEvent', (e) => {
|
||||||
|
const event = JSON.parse(e.data);
|
||||||
|
this.handleNewEvent(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('notification', (e) => {
|
||||||
|
const notification = JSON.parse(e.data);
|
||||||
|
this.handleNotification(notification);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventSource.onerror = (error) => {
|
||||||
|
console.error('SSE Error:', error);
|
||||||
|
this.connected = false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribeToTopics(topics) {
|
||||||
|
const response = await fetch('/api/sse/subscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-auth-token': this.token
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ topics })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Subscribed:', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleNewEvent(event) {
|
||||||
|
console.log('New event:', event);
|
||||||
|
// Update UI with new event
|
||||||
|
}
|
||||||
|
|
||||||
|
handleNotification(notification) {
|
||||||
|
console.log('Notification:', notification);
|
||||||
|
// Show notification to user
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
if (this.eventSource) {
|
||||||
|
this.eventSource.close();
|
||||||
|
this.connected = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
const sseClient = new SSEClient(localStorage.getItem('token'));
|
||||||
|
sseClient.connect();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Connection not established
|
||||||
|
- Check that JWT token is valid and not expired
|
||||||
|
- Verify token is passed correctly (query param or header)
|
||||||
|
- Check browser console for CORS errors
|
||||||
|
|
||||||
|
### Not receiving messages
|
||||||
|
- Verify client is subscribed to the correct topics
|
||||||
|
- Check that server is broadcasting to the correct topic
|
||||||
|
- Ensure client is still connected (check `eventSource.readyState`)
|
||||||
|
|
||||||
|
### High memory usage
|
||||||
|
- Review the number of connected clients
|
||||||
|
- Check for memory leaks in client connection handlers
|
||||||
|
- Monitor SSE stats via `/api/health` endpoint
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
const request = require("supertest");
|
||||||
|
const { app, server } = require("../../server");
|
||||||
|
const User = require("../../models/User");
|
||||||
|
const sseService = require("../../services/sseService");
|
||||||
|
const jwt = require("jsonwebtoken");
|
||||||
|
const couchdbService = require("../../services/couchdbService");
|
||||||
|
|
||||||
|
describe("SSE Routes", () => {
|
||||||
|
let token;
|
||||||
|
let userId;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await couchdbService.initialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create a test user with unique email to avoid conflicts
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const user = await User.create({
|
||||||
|
name: "SSE Test User",
|
||||||
|
username: `sseuser${timestamp}`,
|
||||||
|
email: `sse${timestamp}@test.com`,
|
||||||
|
password: "Password123!",
|
||||||
|
});
|
||||||
|
userId = user._id;
|
||||||
|
|
||||||
|
// Generate token
|
||||||
|
const payload = { user: { id: user._id } };
|
||||||
|
token = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: "1h" });
|
||||||
|
|
||||||
|
// Clear SSE service state
|
||||||
|
sseService.clients.clear();
|
||||||
|
sseService.topics.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await couchdbService.shutdown();
|
||||||
|
server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/sse/subscribe", () => {
|
||||||
|
test("should require authentication", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/sse/subscribe")
|
||||||
|
.send({ topics: ["test"] });
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(res.body.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should subscribe to topics with valid token", async () => {
|
||||||
|
// First, add client to SSE service
|
||||||
|
const mockRes = { write: jest.fn() };
|
||||||
|
sseService.addClient(userId, mockRes);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/sse/subscribe")
|
||||||
|
.set("x-auth-token", token)
|
||||||
|
.send({ topics: ["events", "posts"] });
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(res.body.topics).toEqual(["events", "posts"]);
|
||||||
|
|
||||||
|
// Verify subscription in service
|
||||||
|
const stats = sseService.getStats();
|
||||||
|
expect(stats.topics).toHaveProperty("events");
|
||||||
|
expect(stats.topics).toHaveProperty("posts");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should fail if user not connected to SSE stream", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/sse/subscribe")
|
||||||
|
.set("x-auth-token", token)
|
||||||
|
.send({ topics: ["events"] });
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.body.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should validate topics array", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/sse/subscribe")
|
||||||
|
.set("x-auth-token", token)
|
||||||
|
.send({ topics: "not-an-array" });
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.body.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/sse/unsubscribe", () => {
|
||||||
|
test("should require authentication", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/sse/unsubscribe")
|
||||||
|
.send({ topics: ["test"] });
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(res.body.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should unsubscribe from topics", async () => {
|
||||||
|
// Setup: Add client and subscribe
|
||||||
|
const mockRes = { write: jest.fn() };
|
||||||
|
sseService.addClient(userId, mockRes);
|
||||||
|
sseService.subscribe(userId, ["events", "posts"]);
|
||||||
|
|
||||||
|
// Unsubscribe from one topic
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/sse/unsubscribe")
|
||||||
|
.set("x-auth-token", token)
|
||||||
|
.send({ topics: ["events"] });
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
|
||||||
|
// Verify unsubscription
|
||||||
|
const stats = sseService.getStats();
|
||||||
|
expect(stats.topics).not.toHaveProperty("events");
|
||||||
|
expect(stats.topics).toHaveProperty("posts");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should validate topics array", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/sse/unsubscribe")
|
||||||
|
.set("x-auth-token", token)
|
||||||
|
.send({ topics: null });
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.body.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SSE Service", () => {
|
||||||
|
test("should add and remove clients", () => {
|
||||||
|
const mockRes = { write: jest.fn() };
|
||||||
|
|
||||||
|
sseService.addClient(userId, mockRes);
|
||||||
|
let stats = sseService.getStats();
|
||||||
|
expect(stats.totalClients).toBe(1);
|
||||||
|
|
||||||
|
sseService.removeClient(userId);
|
||||||
|
stats = sseService.getStats();
|
||||||
|
expect(stats.totalClients).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should broadcast to all clients", () => {
|
||||||
|
const mockRes1 = { write: jest.fn() };
|
||||||
|
const mockRes2 = { write: jest.fn() };
|
||||||
|
|
||||||
|
sseService.addClient("user1", mockRes1);
|
||||||
|
sseService.addClient("user2", mockRes2);
|
||||||
|
|
||||||
|
sseService.broadcast("testEvent", { message: "Hello" });
|
||||||
|
|
||||||
|
expect(mockRes1.write).toHaveBeenCalledWith(
|
||||||
|
'event: testEvent\ndata: {"message":"Hello"}\n\n'
|
||||||
|
);
|
||||||
|
expect(mockRes2.write).toHaveBeenCalledWith(
|
||||||
|
'event: testEvent\ndata: {"message":"Hello"}\n\n'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should broadcast to topic subscribers only", () => {
|
||||||
|
const mockRes1 = { write: jest.fn() };
|
||||||
|
const mockRes2 = { write: jest.fn() };
|
||||||
|
|
||||||
|
sseService.addClient("user1", mockRes1);
|
||||||
|
sseService.addClient("user2", mockRes2);
|
||||||
|
sseService.subscribe("user1", ["events"]);
|
||||||
|
|
||||||
|
sseService.broadcastToTopic("events", "eventUpdate", { id: 1 });
|
||||||
|
|
||||||
|
expect(mockRes1.write).toHaveBeenCalled();
|
||||||
|
expect(mockRes2.write).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should send to specific user", () => {
|
||||||
|
const mockRes = { write: jest.fn() };
|
||||||
|
|
||||||
|
sseService.addClient(userId, mockRes);
|
||||||
|
const success = sseService.sendToUser(userId, "notification", { text: "Test" });
|
||||||
|
|
||||||
|
expect(success).toBe(true);
|
||||||
|
expect(mockRes.write).toHaveBeenCalledWith(
|
||||||
|
'event: notification\ndata: {"text":"Test"}\n\n'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return false when sending to non-existent user", () => {
|
||||||
|
const success = sseService.sendToUser("nonexistent", "test", {});
|
||||||
|
expect(success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should get accurate stats", () => {
|
||||||
|
const mockRes1 = { write: jest.fn() };
|
||||||
|
const mockRes2 = { write: jest.fn() };
|
||||||
|
|
||||||
|
sseService.addClient("user1", mockRes1);
|
||||||
|
sseService.addClient("user2", mockRes2);
|
||||||
|
sseService.subscribe("user1", ["events", "posts"]);
|
||||||
|
sseService.subscribe("user2", ["events"]);
|
||||||
|
|
||||||
|
const stats = sseService.getStats();
|
||||||
|
|
||||||
|
expect(stats.totalClients).toBe(2);
|
||||||
|
expect(stats.totalTopics).toBe(2);
|
||||||
|
expect(stats.topics.events).toBe(2);
|
||||||
|
expect(stats.topics.posts).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should clean up topics when last subscriber leaves", () => {
|
||||||
|
const mockRes = { write: jest.fn() };
|
||||||
|
|
||||||
|
sseService.addClient(userId, mockRes);
|
||||||
|
sseService.subscribe(userId, ["events"]);
|
||||||
|
|
||||||
|
let stats = sseService.getStats();
|
||||||
|
expect(stats.totalTopics).toBe(1);
|
||||||
|
|
||||||
|
sseService.removeClient(userId);
|
||||||
|
stats = sseService.getStats();
|
||||||
|
expect(stats.totalTopics).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
const request = require("supertest");
|
|
||||||
const socketIoClient = require("socket.io-client");
|
|
||||||
const jwt = require("jsonwebtoken");
|
|
||||||
const { createServer } = require("http");
|
|
||||||
const { Server } = require("socket.io");
|
|
||||||
|
|
||||||
// Create test server with Socket.IO
|
|
||||||
const createTestServer = () => {
|
|
||||||
const app = require("express")();
|
|
||||||
const server = createServer(app);
|
|
||||||
const io = new Server(server, {
|
|
||||||
cors: {
|
|
||||||
origin: "*",
|
|
||||||
methods: ["GET", "POST"]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Socket.IO authentication middleware
|
|
||||||
io.use((socket, next) => {
|
|
||||||
const token = socket.handshake.auth.token;
|
|
||||||
if (!token) {
|
|
||||||
return next(new Error("Authentication error"));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET || "test_secret");
|
|
||||||
socket.userId = decoded.user.id;
|
|
||||||
next();
|
|
||||||
} catch (err) {
|
|
||||||
next(new Error("Authentication error"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
io.on("connection", (socket) => {
|
|
||||||
console.log("User connected:", socket.userId);
|
|
||||||
|
|
||||||
// Join event rooms
|
|
||||||
socket.on("joinEvent", (eventId) => {
|
|
||||||
socket.join(`event_${eventId}`);
|
|
||||||
socket.emit("joinedEvent", { eventId });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Leave event rooms
|
|
||||||
socket.on("leaveEvent", (eventId) => {
|
|
||||||
socket.leave(`event_${eventId}`);
|
|
||||||
socket.emit("leftEvent", { eventId });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle event updates
|
|
||||||
socket.on("eventUpdate", (data) => {
|
|
||||||
socket.to(`event_${data.eventId}`).emit("eventUpdate", data);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle new posts
|
|
||||||
socket.on("newPost", (data) => {
|
|
||||||
socket.broadcast.emit("newPost", data);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle task updates
|
|
||||||
socket.on("taskUpdate", (data) => {
|
|
||||||
socket.broadcast.emit("taskUpdate", data);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on("disconnect", () => {
|
|
||||||
console.log("User disconnected:", socket.userId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return { server, io };
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("Socket.IO Real-time Features", () => {
|
|
||||||
let server;
|
|
||||||
let io;
|
|
||||||
let clientSocket;
|
|
||||||
let testUser;
|
|
||||||
let authToken;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
// Create test server
|
|
||||||
const testServer = createTestServer();
|
|
||||||
server = testServer.server;
|
|
||||||
io = testServer.io;
|
|
||||||
|
|
||||||
// Start server on random port
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
server.listen(0, resolve);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create mock test user
|
|
||||||
testUser = {
|
|
||||||
_id: "test_user_123",
|
|
||||||
name: "Test User",
|
|
||||||
email: "test@example.com"
|
|
||||||
};
|
|
||||||
|
|
||||||
// Generate auth token
|
|
||||||
authToken = jwt.sign(
|
|
||||||
{ user: { id: testUser._id } },
|
|
||||||
process.env.JWT_SECRET || "test_secret"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
if (clientSocket) {
|
|
||||||
clientSocket.disconnect();
|
|
||||||
}
|
|
||||||
io.close();
|
|
||||||
server.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach((done) => {
|
|
||||||
// Connect client socket with authentication
|
|
||||||
clientSocket = socketIoClient(`http://localhost:${server.address().port}`, {
|
|
||||||
auth: { token: authToken },
|
|
||||||
});
|
|
||||||
|
|
||||||
clientSocket.on("connect", () => {
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
|
|
||||||
clientSocket.on("connect_error", (err) => {
|
|
||||||
done(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (clientSocket && clientSocket.connected) {
|
|
||||||
clientSocket.disconnect();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Socket Authentication", () => {
|
|
||||||
test("should connect with valid token", (done) => {
|
|
||||||
expect(clientSocket.connected).toBe(true);
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject connection with invalid token", (done) => {
|
|
||||||
const invalidSocket = socketIoClient(
|
|
||||||
`http://localhost:${server.address().port}`,
|
|
||||||
{
|
|
||||||
auth: { token: "invalid_token" },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
invalidSocket.on("connect_error", (err) => {
|
|
||||||
expect(err.message).toBe("Authentication error");
|
|
||||||
invalidSocket.disconnect();
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject connection without token", (done) => {
|
|
||||||
const noTokenSocket = socketIoClient(
|
|
||||||
`http://localhost:${server.address().port}`
|
|
||||||
);
|
|
||||||
|
|
||||||
noTokenSocket.on("connect_error", (err) => {
|
|
||||||
expect(err.message).toBe("Authentication error");
|
|
||||||
noTokenSocket.disconnect();
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Event Participation", () => {
|
|
||||||
let testEvent;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
testEvent = {
|
|
||||||
_id: "test_event_123",
|
|
||||||
title: "Test Event",
|
|
||||||
description: "Test Description",
|
|
||||||
date: new Date(Date.now() + 86400000), // Tomorrow
|
|
||||||
location: "Test Location",
|
|
||||||
participants: [],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should join event room", (done) => {
|
|
||||||
clientSocket.emit("joinEvent", testEvent._id);
|
|
||||||
|
|
||||||
clientSocket.on("joinedEvent", (data) => {
|
|
||||||
expect(data.eventId).toBe(testEvent._id);
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should receive event updates in room", (done) => {
|
|
||||||
clientSocket.emit("joinEvent", testEvent._id);
|
|
||||||
|
|
||||||
// Create another client to send updates to the room
|
|
||||||
const anotherClient = socketIoClient(`http://localhost:${server.address().port}`, {
|
|
||||||
auth: { token: authToken },
|
|
||||||
});
|
|
||||||
|
|
||||||
anotherClient.on("connect", () => {
|
|
||||||
// Listen for updates from first client
|
|
||||||
clientSocket.on("eventUpdate", (data) => {
|
|
||||||
expect(data.message).toBe("Event status updated to ongoing");
|
|
||||||
anotherClient.disconnect();
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Join the same event room
|
|
||||||
anotherClient.emit("joinEvent", testEvent._id);
|
|
||||||
|
|
||||||
// Send update from second client (will be broadcast to room)
|
|
||||||
setTimeout(() => {
|
|
||||||
anotherClient.emit("eventUpdate", {
|
|
||||||
eventId: testEvent._id,
|
|
||||||
message: "Event status updated to ongoing",
|
|
||||||
});
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should not receive updates for events not joined", (done) => {
|
|
||||||
const anotherEventId = "another_event_456";
|
|
||||||
|
|
||||||
// Listen for updates (should not receive any)
|
|
||||||
let updateReceived = false;
|
|
||||||
clientSocket.on("eventUpdate", () => {
|
|
||||||
updateReceived = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send update for event not joined
|
|
||||||
setTimeout(() => {
|
|
||||||
clientSocket.emit("eventUpdate", {
|
|
||||||
eventId: anotherEventId,
|
|
||||||
message: "This should not be received",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check after delay that no update was received
|
|
||||||
setTimeout(() => {
|
|
||||||
expect(updateReceived).toBe(false);
|
|
||||||
done();
|
|
||||||
}, 100);
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Post Interactions", () => {
|
|
||||||
let testPost;
|
|
||||||
let testEvent;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
testPost = {
|
|
||||||
_id: "test_post_123",
|
|
||||||
user: {
|
|
||||||
userId: testUser._id,
|
|
||||||
name: testUser.name,
|
|
||||||
},
|
|
||||||
content: "Test post content",
|
|
||||||
likes: [],
|
|
||||||
commentsCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
testEvent = {
|
|
||||||
_id: "test_event_123",
|
|
||||||
title: "Test Event",
|
|
||||||
description: "Test Description",
|
|
||||||
date: new Date(Date.now() + 86400000),
|
|
||||||
location: "Test Location",
|
|
||||||
participants: [],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should broadcast new posts", (done) => {
|
|
||||||
// Create another client to receive broadcasts
|
|
||||||
const anotherClient = socketIoClient(`http://localhost:${server.address().port}`, {
|
|
||||||
auth: { token: authToken },
|
|
||||||
});
|
|
||||||
|
|
||||||
anotherClient.on("connect", () => {
|
|
||||||
// Listen for new posts
|
|
||||||
anotherClient.on("newPost", (data) => {
|
|
||||||
expect(data.content).toBe("Test broadcast post");
|
|
||||||
anotherClient.disconnect();
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send new post from first client
|
|
||||||
clientSocket.emit("newPost", {
|
|
||||||
content: "Test broadcast post",
|
|
||||||
user: testUser
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle multiple event joins", (done) => {
|
|
||||||
const testEvent2 = {
|
|
||||||
_id: "test_event_456",
|
|
||||||
title: "Another Event",
|
|
||||||
description: "Another Description",
|
|
||||||
date: new Date(Date.now() + 86400000),
|
|
||||||
location: "Another Location",
|
|
||||||
participants: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
let joinCount = 0;
|
|
||||||
const checkJoins = () => {
|
|
||||||
joinCount++;
|
|
||||||
if (joinCount === 2) {
|
|
||||||
done();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
clientSocket.on("joinedEvent", (data) => {
|
|
||||||
checkJoins();
|
|
||||||
});
|
|
||||||
|
|
||||||
clientSocket.emit("joinEvent", testEvent._id);
|
|
||||||
clientSocket.emit("joinEvent", testEvent2._id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Connection Stability", () => {
|
|
||||||
test("should handle disconnection gracefully", (done) => {
|
|
||||||
// Simple test that disconnection doesn't throw errors
|
|
||||||
expect(() => {
|
|
||||||
clientSocket.disconnect();
|
|
||||||
}).not.toThrow();
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
expect(clientSocket.connected).toBe(false);
|
|
||||||
done();
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should maintain connection under load", async () => {
|
|
||||||
const startTime = Date.now();
|
|
||||||
const messageCount = 50; // Reduced for test stability
|
|
||||||
|
|
||||||
for (let i = 0; i < messageCount; i++) {
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
clientSocket.emit("eventUpdate", {
|
|
||||||
eventId: `test_event_${i}`,
|
|
||||||
message: `Test message ${i}`,
|
|
||||||
});
|
|
||||||
setTimeout(resolve, 10);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const endTime = Date.now();
|
|
||||||
const duration = endTime - startTime;
|
|
||||||
|
|
||||||
// Should complete within reasonable time (less than 5 seconds)
|
|
||||||
expect(duration).toBeLessThan(5000);
|
|
||||||
expect(clientSocket.connected).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Concurrent Connections", () => {
|
|
||||||
test("should handle multiple simultaneous connections", async () => {
|
|
||||||
const clients = [];
|
|
||||||
const connectionPromises = [];
|
|
||||||
|
|
||||||
// Create 10 concurrent connections
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
const promise = new Promise((resolve) => {
|
|
||||||
const client = socketIoClient(
|
|
||||||
`http://localhost:${server.address().port}`,
|
|
||||||
{
|
|
||||||
auth: { token: authToken },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
client.on("connect", () => {
|
|
||||||
clients.push(client);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on("connect_error", (err) => {
|
|
||||||
resolve(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
connectionPromises.push(promise);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(connectionPromises);
|
|
||||||
|
|
||||||
// All connections should succeed
|
|
||||||
expect(clients.length).toBe(10);
|
|
||||||
clients.forEach((client) => {
|
|
||||||
expect(client.connected).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
clients.forEach((client) => client.disconnect());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
const jwt = require("jsonwebtoken");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Socket.IO Authentication Middleware
|
|
||||||
* Verifies JWT token before allowing socket connections
|
|
||||||
*/
|
|
||||||
const socketAuth = (socket, next) => {
|
|
||||||
try {
|
|
||||||
// Get token from handshake auth or query
|
|
||||||
const token =
|
|
||||||
socket.handshake.auth.token || socket.handshake.query.token;
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return next(new Error("Authentication error: No token provided"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify token
|
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
||||||
|
|
||||||
// Attach user data to socket
|
|
||||||
socket.user = decoded.user;
|
|
||||||
|
|
||||||
next();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Socket authentication error:", err.message);
|
|
||||||
return next(new Error("Authentication error: Invalid token"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = socketAuth;
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
const jwt = require("jsonwebtoken");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSE Authentication Middleware
|
||||||
|
* Supports token from query string (for SSE connections) or Authorization header
|
||||||
|
*/
|
||||||
|
module.exports = function (req, res, next) {
|
||||||
|
let token;
|
||||||
|
|
||||||
|
// Try to get token from query string (for SSE EventSource connections)
|
||||||
|
if (req.query.token) {
|
||||||
|
token = req.query.token;
|
||||||
|
}
|
||||||
|
// Try to get token from Authorization header (Bearer token)
|
||||||
|
else if (req.headers.authorization && req.headers.authorization.startsWith("Bearer ")) {
|
||||||
|
token = req.headers.authorization.substring(7);
|
||||||
|
}
|
||||||
|
// Try to get token from x-auth-token header (legacy support)
|
||||||
|
else if (req.header("x-auth-token")) {
|
||||||
|
token = req.header("x-auth-token");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if no token found
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
msg: "No token, authorization denied"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify token
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
req.user = decoded.user;
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Token is not valid"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
Generated
+325
-246
@@ -24,7 +24,6 @@
|
|||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"nano": "^10.1.4",
|
"nano": "^10.1.4",
|
||||||
"node-cache": "^5.1.2",
|
"node-cache": "^5.1.2",
|
||||||
"socket.io": "^4.8.1",
|
|
||||||
"stripe": "^17.7.0",
|
"stripe": "^17.7.0",
|
||||||
"xss-clean": "^0.1.4"
|
"xss-clean": "^0.1.4"
|
||||||
},
|
},
|
||||||
@@ -34,7 +33,6 @@
|
|||||||
"eslint": "^9.38.0",
|
"eslint": "^9.38.0",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-node": "^30.2.0",
|
"jest-environment-node": "^30.2.0",
|
||||||
"socket.io-client": "^4.8.1",
|
|
||||||
"supertest": "^7.1.4"
|
"supertest": "^7.1.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -63,6 +61,7 @@
|
|||||||
"version": "7.28.5",
|
"version": "7.28.5",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -530,6 +529,40 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@emnapi/core": {
|
||||||
|
"version": "1.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
|
||||||
|
"integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.1.0",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
|
||||||
|
"integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@epic-web/invariant": {
|
"node_modules/@epic-web/invariant": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -1320,6 +1353,19 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
|
"version": "0.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||||
|
"integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/core": "^1.4.3",
|
||||||
|
"@emnapi/runtime": "^1.4.3",
|
||||||
|
"@tybys/wasm-util": "^0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@noble/hashes": {
|
"node_modules/@noble/hashes": {
|
||||||
"version": "1.8.0",
|
"version": "1.8.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -1380,9 +1426,16 @@
|
|||||||
"@sinonjs/commons": "^3.0.1"
|
"@sinonjs/commons": "^3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@socket.io/component-emitter": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "3.1.2",
|
"version": "0.10.1",
|
||||||
"license": "MIT"
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
|
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
@@ -1421,13 +1474,6 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/cors": {
|
|
||||||
"version": "2.8.19",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -1498,6 +1544,188 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-android-arm64": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-darwin-arm64": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-darwin-x64": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-freebsd-x64": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-arm64-musl": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@unrs/resolver-binding-linux-x64-gnu": {
|
"node_modules/@unrs/resolver-binding-linux-x64-gnu": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
@@ -1522,6 +1750,65 @@
|
|||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-wasm32-wasi": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==",
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@napi-rs/wasm-runtime": "^0.2.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-win32-x64-msvc": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -1537,6 +1824,7 @@
|
|||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -1729,13 +2017,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/base64id": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "^4.5.0 || >= 5.9"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.8.23",
|
"version": "2.8.23",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -1822,6 +2103,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.19",
|
"baseline-browser-mapping": "^2.8.19",
|
||||||
"caniuse-lite": "^1.0.30001751",
|
"caniuse-lite": "^1.0.30001751",
|
||||||
@@ -2368,85 +2650,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/engine.io": {
|
|
||||||
"version": "6.6.4",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/cors": "^2.8.12",
|
|
||||||
"@types/node": ">=10.0.0",
|
|
||||||
"accepts": "~1.3.4",
|
|
||||||
"base64id": "2.0.0",
|
|
||||||
"cookie": "~0.7.2",
|
|
||||||
"cors": "~2.8.5",
|
|
||||||
"debug": "~4.3.1",
|
|
||||||
"engine.io-parser": "~5.2.1",
|
|
||||||
"ws": "~8.17.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/engine.io-client": {
|
|
||||||
"version": "6.6.3",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
|
||||||
"debug": "~4.3.1",
|
|
||||||
"engine.io-parser": "~5.2.1",
|
|
||||||
"ws": "~8.17.1",
|
|
||||||
"xmlhttprequest-ssl": "~2.1.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/engine.io-client/node_modules/debug": {
|
|
||||||
"version": "4.3.7",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/engine.io-parser": {
|
|
||||||
"version": "5.2.3",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/engine.io/node_modules/cookie": {
|
|
||||||
"version": "0.7.2",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/engine.io/node_modules/debug": {
|
|
||||||
"version": "4.3.7",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/engine.io/node_modules/debug/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/error-ex": {
|
"node_modules/error-ex": {
|
||||||
"version": "1.3.4",
|
"version": "1.3.4",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -2519,6 +2722,7 @@
|
|||||||
"version": "9.39.0",
|
"version": "9.39.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -3005,6 +3209,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"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/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -5096,128 +5315,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/socket.io": {
|
|
||||||
"version": "4.8.1",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"accepts": "~1.3.4",
|
|
||||||
"base64id": "~2.0.0",
|
|
||||||
"cors": "~2.8.5",
|
|
||||||
"debug": "~4.3.2",
|
|
||||||
"engine.io": "~6.6.0",
|
|
||||||
"socket.io-adapter": "~2.5.2",
|
|
||||||
"socket.io-parser": "~4.2.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-adapter": {
|
|
||||||
"version": "2.5.5",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "~4.3.4",
|
|
||||||
"ws": "~8.17.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-adapter/node_modules/debug": {
|
|
||||||
"version": "4.3.7",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-adapter/node_modules/debug/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-client": {
|
|
||||||
"version": "4.8.1",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
|
||||||
"debug": "~4.3.2",
|
|
||||||
"engine.io-client": "~6.6.1",
|
|
||||||
"socket.io-parser": "~4.2.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-client/node_modules/debug": {
|
|
||||||
"version": "4.3.7",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-parser": {
|
|
||||||
"version": "4.2.4",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
|
||||||
"debug": "~4.3.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-parser/node_modules/debug": {
|
|
||||||
"version": "4.3.7",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-parser/node_modules/debug/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/socket.io/node_modules/debug": {
|
|
||||||
"version": "4.3.7",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io/node_modules/debug/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
"version": "0.6.1",
|
"version": "0.6.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -5580,6 +5677,14 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -5859,32 +5964,6 @@
|
|||||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
|
||||||
"version": "8.17.1",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"bufferutil": "^4.0.1",
|
|
||||||
"utf-8-validate": ">=5.0.2"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"bufferutil": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"utf-8-validate": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/xmlhttprequest-ssl": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/xss-clean": {
|
"node_modules/xss-clean": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/xss-clean/-/xss-clean-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/xss-clean/-/xss-clean-0.1.4.tgz",
|
||||||
|
|||||||
@@ -32,7 +32,6 @@
|
|||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"nano": "^10.1.4",
|
"nano": "^10.1.4",
|
||||||
"node-cache": "^5.1.2",
|
"node-cache": "^5.1.2",
|
||||||
"socket.io": "^4.8.1",
|
|
||||||
"stripe": "^17.7.0",
|
"stripe": "^17.7.0",
|
||||||
"xss-clean": "^0.1.4"
|
"xss-clean": "^0.1.4"
|
||||||
},
|
},
|
||||||
@@ -42,7 +41,6 @@
|
|||||||
"eslint": "^9.38.0",
|
"eslint": "^9.38.0",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-node": "^30.2.0",
|
"jest-environment-node": "^30.2.0",
|
||||||
"socket.io-client": "^4.8.1",
|
|
||||||
"supertest": "^7.1.4"
|
"supertest": "^7.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,10 +61,10 @@ router.post(
|
|||||||
content,
|
content,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Emit Socket.IO event for new comment
|
// Emit SSE event for new comment
|
||||||
const io = req.app.get("io");
|
const sse = req.app.get("sse");
|
||||||
if (io) {
|
if (sse) {
|
||||||
io.to(`post_${postId}`).emit("newComment", {
|
sse.broadcastToTopic(`post_${postId}`, "newComment", {
|
||||||
postId,
|
postId,
|
||||||
comment,
|
comment,
|
||||||
});
|
});
|
||||||
@@ -111,10 +111,10 @@ router.delete(
|
|||||||
// Delete comment
|
// Delete comment
|
||||||
await Comment.deleteComment(commentId);
|
await Comment.deleteComment(commentId);
|
||||||
|
|
||||||
// Emit Socket.IO event for deleted comment
|
// Emit SSE event for deleted comment
|
||||||
const io = req.app.get("io");
|
const sse = req.app.get("sse");
|
||||||
if (io) {
|
if (sse) {
|
||||||
io.to(`post_${postId}`).emit("commentDeleted", {
|
sse.broadcastToTopic(`post_${postId}`, "commentDeleted", {
|
||||||
postId,
|
postId,
|
||||||
commentId,
|
commentId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ router.post(
|
|||||||
// Invalidate events cache
|
// Invalidate events cache
|
||||||
invalidateCacheByPattern('/api/events');
|
invalidateCacheByPattern('/api/events');
|
||||||
|
|
||||||
|
// Emit SSE event for new event
|
||||||
|
const sse = req.app.get("sse");
|
||||||
|
if (sse) {
|
||||||
|
sse.broadcastToTopic("events", "eventUpdate", {
|
||||||
|
type: "new_event",
|
||||||
|
event,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json(event);
|
res.json(event);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -120,6 +129,16 @@ router.put(
|
|||||||
// Check and award badges
|
// Check and award badges
|
||||||
await couchdbService.checkAndAwardBadges(userId, updatedUser.points);
|
await couchdbService.checkAndAwardBadges(userId, updatedUser.points);
|
||||||
|
|
||||||
|
// Emit SSE event for RSVP
|
||||||
|
const sse = req.app.get("sse");
|
||||||
|
if (sse) {
|
||||||
|
sse.broadcastToTopic(`event_${eventId}`, "eventUpdate", {
|
||||||
|
type: "participants_updated",
|
||||||
|
eventId,
|
||||||
|
participants: updatedEvent.participants,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
participants: updatedEvent.participants,
|
participants: updatedEvent.participants,
|
||||||
pointsAwarded: 15,
|
pointsAwarded: 15,
|
||||||
@@ -172,6 +191,16 @@ router.put(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updatedEvent = await Event.update(req.params.id, updateData);
|
const updatedEvent = await Event.update(req.params.id, updateData);
|
||||||
|
|
||||||
|
// Emit SSE event for event update
|
||||||
|
const sse = req.app.get("sse");
|
||||||
|
if (sse) {
|
||||||
|
sse.broadcastToTopic(`event_${req.params.id}`, "eventUpdate", {
|
||||||
|
type: "event_updated",
|
||||||
|
event: updatedEvent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json(updatedEvent);
|
res.json(updatedEvent);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -244,6 +273,16 @@ router.delete(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await Event.delete(req.params.id);
|
await Event.delete(req.params.id);
|
||||||
|
|
||||||
|
// Emit SSE event for event deletion
|
||||||
|
const sse = req.app.get("sse");
|
||||||
|
if (sse) {
|
||||||
|
sse.broadcastToTopic(`event_${req.params.id}`, "eventUpdate", {
|
||||||
|
type: "event_deleted",
|
||||||
|
eventId: req.params.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ msg: "Event deleted successfully" });
|
res.json({ msg: "Event deleted successfully" });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ router.post(
|
|||||||
// Invalidate posts cache
|
// Invalidate posts cache
|
||||||
invalidateCacheByPattern('/api/posts');
|
invalidateCacheByPattern('/api/posts');
|
||||||
|
|
||||||
|
// Emit SSE event for new post
|
||||||
|
const sse = req.app.get("sse");
|
||||||
|
if (sse) {
|
||||||
|
sse.broadcastToTopic("posts", "newPost", {
|
||||||
|
post,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
post,
|
post,
|
||||||
pointsAwarded: 5, // Standard post creation points
|
pointsAwarded: 5, // Standard post creation points
|
||||||
@@ -132,6 +140,16 @@ router.put(
|
|||||||
|
|
||||||
const updatedPost = await Post.addLike(req.params.id, req.user.id);
|
const updatedPost = await Post.addLike(req.params.id, req.user.id);
|
||||||
|
|
||||||
|
// Emit SSE event for post like
|
||||||
|
const sse = req.app.get("sse");
|
||||||
|
if (sse) {
|
||||||
|
sse.broadcastToTopic("posts", "postUpdate", {
|
||||||
|
type: "post_liked",
|
||||||
|
postId: req.params.id,
|
||||||
|
likes: updatedPost.likes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json(updatedPost.likes);
|
res.json(updatedPost.likes);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const router = express.Router();
|
||||||
|
const sseAuth = require("../middleware/sseAuth");
|
||||||
|
const sseService = require("../services/sseService");
|
||||||
|
const logger = require("../utils/logger");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/sse/stream
|
||||||
|
* @desc SSE stream endpoint
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.get("/stream", sseAuth, (req, res) => {
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
// Set SSE headers
|
||||||
|
res.setHeader("Content-Type", "text/event-stream");
|
||||||
|
res.setHeader("Cache-Control", "no-cache");
|
||||||
|
res.setHeader("Connection", "keep-alive");
|
||||||
|
res.setHeader("X-Accel-Buffering", "no"); // Disable nginx buffering
|
||||||
|
|
||||||
|
// Send initial connection success message
|
||||||
|
res.write(`event: connected\ndata: ${JSON.stringify({ userId, timestamp: new Date().toISOString() })}\n\n`);
|
||||||
|
|
||||||
|
// Register client
|
||||||
|
sseService.addClient(userId, res);
|
||||||
|
|
||||||
|
// Send heartbeat every 30 seconds to keep connection alive
|
||||||
|
const heartbeatInterval = setInterval(() => {
|
||||||
|
try {
|
||||||
|
res.write(`:heartbeat\n\n`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Heartbeat failed for user`, { userId, error: error.message });
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
sseService.removeClient(userId);
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
// Handle client disconnect
|
||||||
|
req.on("close", () => {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
sseService.removeClient(userId);
|
||||||
|
logger.info(`SSE stream closed`, { userId });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle connection errors
|
||||||
|
req.on("error", (error) => {
|
||||||
|
logger.error(`SSE stream error`, { userId, error: error.message });
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
sseService.removeClient(userId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/sse/subscribe
|
||||||
|
* @desc Subscribe to SSE topics
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.post("/subscribe", sseAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.id;
|
||||||
|
const { topics } = req.body;
|
||||||
|
|
||||||
|
// Validate topics
|
||||||
|
if (!topics || !Array.isArray(topics) || topics.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Topics must be a non-empty array"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to topics
|
||||||
|
const success = sseService.subscribe(userId, topics);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
msg: "User not connected to SSE stream"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
msg: "Subscribed to topics",
|
||||||
|
topics
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Subscribe error`, { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Server error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/sse/unsubscribe
|
||||||
|
* @desc Unsubscribe from SSE topics
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.post("/unsubscribe", sseAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.id;
|
||||||
|
const { topics } = req.body;
|
||||||
|
|
||||||
|
// Validate topics
|
||||||
|
if (!topics || !Array.isArray(topics) || topics.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Topics must be a non-empty array"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe from topics
|
||||||
|
const success = sseService.unsubscribe(userId, topics);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
msg: "User not connected to SSE stream"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
msg: "Unsubscribed from topics",
|
||||||
|
topics
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Unsubscribe error`, { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Server error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -177,6 +177,16 @@ router.put(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Emit SSE event for street adoption
|
||||||
|
const sse = req.app.get("sse");
|
||||||
|
if (sse) {
|
||||||
|
sse.broadcastToTopic("streets", "streetUpdate", {
|
||||||
|
type: "street_adopted",
|
||||||
|
streetId: street._id,
|
||||||
|
userId: req.user.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
street,
|
street,
|
||||||
pointsAwarded: 50,
|
pointsAwarded: 50,
|
||||||
|
|||||||
@@ -71,6 +71,15 @@ router.post(
|
|||||||
description,
|
description,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Emit SSE event for new task
|
||||||
|
const sse = req.app.get("sse");
|
||||||
|
if (sse) {
|
||||||
|
sse.broadcastToTopic("tasks", "taskUpdate", {
|
||||||
|
type: "new_task",
|
||||||
|
task,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json(task);
|
res.json(task);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -120,6 +129,15 @@ router.put(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Emit SSE event for task completion
|
||||||
|
const sse = req.app.get("sse");
|
||||||
|
if (sse) {
|
||||||
|
sse.broadcastToTopic("tasks", "taskUpdate", {
|
||||||
|
type: "task_completed",
|
||||||
|
task,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
task,
|
task,
|
||||||
pointsAwarded: task.pointsAwarded || 10,
|
pointsAwarded: task.pointsAwarded || 10,
|
||||||
|
|||||||
+13
-85
@@ -1,15 +1,14 @@
|
|||||||
require("dotenv").config();
|
require("dotenv").config();
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
const couchdbService = require("./services/couchdbService");
|
const couchdbService = require("./services/couchdbService");
|
||||||
|
const sseService = require("./services/sseService");
|
||||||
const cors = require("cors");
|
const cors = require("cors");
|
||||||
const http = require("http");
|
const http = require("http");
|
||||||
const socketio = require("socket.io");
|
|
||||||
const helmet = require("helmet");
|
const helmet = require("helmet");
|
||||||
const rateLimit = require("express-rate-limit");
|
const rateLimit = require("express-rate-limit");
|
||||||
const mongoSanitize = require("express-mongo-sanitize");
|
const mongoSanitize = require("express-mongo-sanitize");
|
||||||
const xss = require("xss-clean");
|
const xss = require("xss-clean");
|
||||||
const { errorHandler } = require("./middleware/errorHandler");
|
const { errorHandler } = require("./middleware/errorHandler");
|
||||||
const socketAuth = require("./middleware/socketAuth");
|
|
||||||
const requestLogger = require("./middleware/requestLogger");
|
const requestLogger = require("./middleware/requestLogger");
|
||||||
const logger = require("./utils/logger");
|
const logger = require("./utils/logger");
|
||||||
const { validateEnv, logEnvConfig } = require("./utils/validateEnv");
|
const { validateEnv, logEnvConfig } = require("./utils/validateEnv");
|
||||||
@@ -26,13 +25,6 @@ try {
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
const io = socketio(server, {
|
|
||||||
cors: {
|
|
||||||
origin: process.env.FRONTEND_URL || "http://localhost:3000",
|
|
||||||
methods: ["GET", "POST"],
|
|
||||||
credentials: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const port = process.env.PORT || 5000;
|
const port = process.env.PORT || 5000;
|
||||||
|
|
||||||
// Trust proxy - required when behind ingress/reverse proxy
|
// Trust proxy - required when behind ingress/reverse proxy
|
||||||
@@ -101,34 +93,8 @@ if (process.env.NODE_ENV !== 'test') {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Socket.IO Authentication Middleware
|
// Make sse available to routes
|
||||||
io.use(socketAuth);
|
app.set("sse", sseService);
|
||||||
|
|
||||||
// Socket.IO Setup with Authentication
|
|
||||||
io.on("connection", (socket) => {
|
|
||||||
logger.info(`Socket.IO client connected`, { userId: socket.user.id });
|
|
||||||
|
|
||||||
socket.on("joinEvent", (eventId) => {
|
|
||||||
socket.join(`event_${eventId}`);
|
|
||||||
logger.debug(`User joined event`, { userId: socket.user.id, eventId });
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on("joinPost", (postId) => {
|
|
||||||
socket.join(`post_${postId}`);
|
|
||||||
logger.debug(`User joined post`, { userId: socket.user.id, postId });
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on("eventUpdate", (data) => {
|
|
||||||
io.to(`event_${data.eventId}`).emit("update", data.message);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on("disconnect", () => {
|
|
||||||
logger.info(`Socket.IO client disconnected`, { userId: socket.user.id });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Make io available to routes
|
|
||||||
app.set("io", io);
|
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
const authRoutes = require("./routes/auth");
|
const authRoutes = require("./routes/auth");
|
||||||
@@ -147,6 +113,7 @@ const cacheRoutes = require("./routes/cache");
|
|||||||
const profileRoutes = require("./routes/profile");
|
const profileRoutes = require("./routes/profile");
|
||||||
const analyticsRoutes = require("./routes/analytics");
|
const analyticsRoutes = require("./routes/analytics");
|
||||||
const leaderboardRoutes = require("./routes/leaderboard");
|
const leaderboardRoutes = require("./routes/leaderboard");
|
||||||
|
const sseRoutes = require("./routes/sse");
|
||||||
|
|
||||||
// Apply rate limiters
|
// Apply rate limiters
|
||||||
app.use("/api/auth/register", authLimiter);
|
app.use("/api/auth/register", authLimiter);
|
||||||
@@ -158,15 +125,10 @@ app.get("/api/health", async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const couchdbStatus = await couchdbService.checkConnection();
|
const couchdbStatus = await couchdbService.checkConnection();
|
||||||
|
|
||||||
// Check Socket.IO status
|
// Get SSE stats
|
||||||
const socketIOStatus = {
|
const sseStats = sseService.getStats();
|
||||||
engine: io.engine ? "running" : "stopped",
|
|
||||||
connectedClients: io.engine ? io.engine.clientsCount : 0,
|
|
||||||
// Get number of connected sockets
|
|
||||||
sockets: io.sockets ? io.sockets.sockets.size : 0
|
|
||||||
};
|
|
||||||
|
|
||||||
const isHealthy = couchdbStatus && io.engine;
|
const isHealthy = couchdbStatus;
|
||||||
|
|
||||||
res.status(isHealthy ? 200 : 503).json({
|
res.status(isHealthy ? 200 : 503).json({
|
||||||
status: isHealthy ? "healthy" : "degraded",
|
status: isHealthy ? "healthy" : "degraded",
|
||||||
@@ -174,10 +136,9 @@ app.get("/api/health", async (req, res) => {
|
|||||||
uptime: process.uptime(),
|
uptime: process.uptime(),
|
||||||
services: {
|
services: {
|
||||||
couchdb: couchdbStatus ? "connected" : "disconnected",
|
couchdb: couchdbStatus ? "connected" : "disconnected",
|
||||||
socketIO: {
|
sse: {
|
||||||
status: socketIOStatus.engine,
|
totalClients: sseStats.totalClients,
|
||||||
connectedClients: socketIOStatus.connectedClients,
|
totalTopics: sseStats.totalTopics
|
||||||
activeSockets: socketIOStatus.sockets
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
memory: {
|
memory: {
|
||||||
@@ -193,47 +154,13 @@ app.get("/api/health", async (req, res) => {
|
|||||||
uptime: process.uptime(),
|
uptime: process.uptime(),
|
||||||
services: {
|
services: {
|
||||||
couchdb: "disconnected",
|
couchdb: "disconnected",
|
||||||
socketIO: "unknown"
|
sse: "unknown"
|
||||||
},
|
},
|
||||||
error: error.message,
|
error: error.message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Detailed Socket.IO health check endpoint
|
|
||||||
app.get("/api/health/socketio", (req, res) => {
|
|
||||||
try {
|
|
||||||
const socketIOInfo = {
|
|
||||||
status: io.engine ? "running" : "stopped",
|
|
||||||
connectedClients: io.engine ? io.engine.clientsCount : 0,
|
|
||||||
activeSockets: io.sockets ? io.sockets.sockets.size : 0,
|
|
||||||
rooms: [],
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get list of active rooms (excluding auto-generated socket ID rooms)
|
|
||||||
if (io.sockets && io.sockets.adapter && io.sockets.adapter.rooms) {
|
|
||||||
const rooms = Array.from(io.sockets.adapter.rooms.keys()).filter(room => {
|
|
||||||
// Filter out socket ID rooms (they start with socket ID pattern)
|
|
||||||
return room.startsWith('event_') || room.startsWith('post_');
|
|
||||||
});
|
|
||||||
|
|
||||||
socketIOInfo.rooms = rooms.map(room => {
|
|
||||||
const roomSize = io.sockets.adapter.rooms.get(room)?.size || 0;
|
|
||||||
return { name: room, members: roomSize };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).json(socketIOInfo);
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({
|
|
||||||
status: "error",
|
|
||||||
error: error.message,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
app.use("/api/auth", authRoutes);
|
app.use("/api/auth", authRoutes);
|
||||||
app.use("/api/streets", streetRoutes);
|
app.use("/api/streets", streetRoutes);
|
||||||
@@ -251,6 +178,7 @@ app.use("/api/cache", cacheRoutes);
|
|||||||
app.use("/api/profile", profileRoutes);
|
app.use("/api/profile", profileRoutes);
|
||||||
app.use("/api/analytics", analyticsRoutes);
|
app.use("/api/analytics", analyticsRoutes);
|
||||||
app.use("/api/leaderboard", leaderboardRoutes);
|
app.use("/api/leaderboard", leaderboardRoutes);
|
||||||
|
app.use("/api/sse", sseRoutes);
|
||||||
|
|
||||||
app.get("/", (req, res) => {
|
app.get("/", (req, res) => {
|
||||||
res.send("Street Adoption App Backend");
|
res.send("Street Adoption App Backend");
|
||||||
@@ -267,7 +195,7 @@ if (require.main === module) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Export app and server for testing
|
// Export app and server for testing
|
||||||
module.exports = { app, server, io };
|
module.exports = { app, server };
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
process.on("SIGTERM", async () => {
|
process.on("SIGTERM", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
const logger = require("../utils/logger");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSE Service for Server-Sent Events
|
||||||
|
* Manages SSE connections, topic subscriptions, and broadcasting
|
||||||
|
*/
|
||||||
|
class SSEService {
|
||||||
|
constructor() {
|
||||||
|
// Map of userId -> {res: Response, topics: Set<string>}
|
||||||
|
this.clients = new Map();
|
||||||
|
|
||||||
|
// Map of topicName -> Set<userId>
|
||||||
|
this.topics = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a client connection
|
||||||
|
* @param {string} userId - User ID
|
||||||
|
* @param {Response} res - Express response object
|
||||||
|
*/
|
||||||
|
addClient(userId, res) {
|
||||||
|
// Remove existing client if any
|
||||||
|
this.removeClient(userId);
|
||||||
|
|
||||||
|
this.clients.set(userId, {
|
||||||
|
res,
|
||||||
|
topics: new Set(),
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`SSE client added`, { userId, totalClients: this.clients.size });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a client connection
|
||||||
|
* @param {string} userId - User ID
|
||||||
|
*/
|
||||||
|
removeClient(userId) {
|
||||||
|
const client = this.clients.get(userId);
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe from all topics
|
||||||
|
client.topics.forEach((topic) => {
|
||||||
|
const topicSubscribers = this.topics.get(topic);
|
||||||
|
if (topicSubscribers) {
|
||||||
|
topicSubscribers.delete(userId);
|
||||||
|
if (topicSubscribers.size === 0) {
|
||||||
|
this.topics.delete(topic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.clients.delete(userId);
|
||||||
|
logger.info(`SSE client removed`, { userId, totalClients: this.clients.size });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe a user to topics
|
||||||
|
* @param {string} userId - User ID
|
||||||
|
* @param {string[]} topicList - Array of topic names
|
||||||
|
*/
|
||||||
|
subscribe(userId, topicList) {
|
||||||
|
const client = this.clients.get(userId);
|
||||||
|
if (!client) {
|
||||||
|
logger.warn(`Cannot subscribe: client not found`, { userId });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
topicList.forEach((topic) => {
|
||||||
|
// Add to client's topics
|
||||||
|
client.topics.add(topic);
|
||||||
|
|
||||||
|
// Add to topic's subscribers
|
||||||
|
if (!this.topics.has(topic)) {
|
||||||
|
this.topics.set(topic, new Set());
|
||||||
|
}
|
||||||
|
this.topics.get(topic).add(userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`User subscribed to topics`, { userId, topics: topicList });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe a user from topics
|
||||||
|
* @param {string} userId - User ID
|
||||||
|
* @param {string[]} topicList - Array of topic names
|
||||||
|
*/
|
||||||
|
unsubscribe(userId, topicList) {
|
||||||
|
const client = this.clients.get(userId);
|
||||||
|
if (!client) {
|
||||||
|
logger.warn(`Cannot unsubscribe: client not found`, { userId });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
topicList.forEach((topic) => {
|
||||||
|
// Remove from client's topics
|
||||||
|
client.topics.delete(topic);
|
||||||
|
|
||||||
|
// Remove from topic's subscribers
|
||||||
|
const topicSubscribers = this.topics.get(topic);
|
||||||
|
if (topicSubscribers) {
|
||||||
|
topicSubscribers.delete(userId);
|
||||||
|
if (topicSubscribers.size === 0) {
|
||||||
|
this.topics.delete(topic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`User unsubscribed from topics`, { userId, topics: topicList });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast an event to all connected clients
|
||||||
|
* @param {string} eventType - Event type
|
||||||
|
* @param {object} data - Event data
|
||||||
|
*/
|
||||||
|
broadcast(eventType, data) {
|
||||||
|
const message = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||||
|
let sentCount = 0;
|
||||||
|
|
||||||
|
this.clients.forEach((client, userId) => {
|
||||||
|
try {
|
||||||
|
client.res.write(message);
|
||||||
|
sentCount++;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to send SSE to user`, { userId, error: error.message });
|
||||||
|
this.removeClient(userId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug(`Broadcast event`, { eventType, recipients: sentCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast an event to all subscribers of a topic
|
||||||
|
* @param {string} topic - Topic name
|
||||||
|
* @param {string} eventType - Event type
|
||||||
|
* @param {object} data - Event data
|
||||||
|
*/
|
||||||
|
broadcastToTopic(topic, eventType, data) {
|
||||||
|
const subscribers = this.topics.get(topic);
|
||||||
|
if (!subscribers || subscribers.size === 0) {
|
||||||
|
logger.debug(`No subscribers for topic`, { topic });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||||
|
let sentCount = 0;
|
||||||
|
|
||||||
|
subscribers.forEach((userId) => {
|
||||||
|
const client = this.clients.get(userId);
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
client.res.write(message);
|
||||||
|
sentCount++;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to send SSE to user`, { userId, error: error.message });
|
||||||
|
this.removeClient(userId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug(`Broadcast to topic`, { topic, eventType, recipients: sentCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an event to a specific user
|
||||||
|
* @param {string} userId - User ID
|
||||||
|
* @param {string} eventType - Event type
|
||||||
|
* @param {object} data - Event data
|
||||||
|
*/
|
||||||
|
sendToUser(userId, eventType, data) {
|
||||||
|
const client = this.clients.get(userId);
|
||||||
|
if (!client) {
|
||||||
|
logger.debug(`User not connected`, { userId });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
client.res.write(message);
|
||||||
|
logger.debug(`Sent event to user`, { userId, eventType });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to send SSE to user`, { userId, error: error.message });
|
||||||
|
this.removeClient(userId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get service statistics
|
||||||
|
* @returns {object} Stats object
|
||||||
|
*/
|
||||||
|
getStats() {
|
||||||
|
const topicStats = {};
|
||||||
|
this.topics.forEach((subscribers, topic) => {
|
||||||
|
topicStats[topic] = subscribers.size;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalClients: this.clients.size,
|
||||||
|
totalTopics: this.topics.size,
|
||||||
|
topics: topicStats,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
module.exports = new SSEService();
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* SSE Demo Script
|
||||||
|
* Demonstrates the SSE service functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
const sseService = require('./services/sseService');
|
||||||
|
|
||||||
|
console.log('=== SSE Service Demo ===\n');
|
||||||
|
|
||||||
|
// Mock response objects
|
||||||
|
const createMockRes = (userId) => ({
|
||||||
|
write: (data) => console.log(`[${userId}] Received:`, data.trim()),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Add clients
|
||||||
|
console.log('1. Adding clients...');
|
||||||
|
const res1 = createMockRes('user1');
|
||||||
|
const res2 = createMockRes('user2');
|
||||||
|
const res3 = createMockRes('user3');
|
||||||
|
|
||||||
|
sseService.addClient('user1', res1);
|
||||||
|
sseService.addClient('user2', res2);
|
||||||
|
sseService.addClient('user3', res3);
|
||||||
|
|
||||||
|
let stats = sseService.getStats();
|
||||||
|
console.log('Stats after adding clients:', stats);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// 2. Subscribe to topics
|
||||||
|
console.log('2. Subscribing to topics...');
|
||||||
|
sseService.subscribe('user1', ['events', 'posts']);
|
||||||
|
sseService.subscribe('user2', ['events']);
|
||||||
|
sseService.subscribe('user3', ['posts']);
|
||||||
|
|
||||||
|
stats = sseService.getStats();
|
||||||
|
console.log('Stats after subscriptions:', stats);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// 3. Broadcast to all
|
||||||
|
console.log('3. Broadcasting to all clients...');
|
||||||
|
sseService.broadcast('announcement', { message: 'Hello everyone!' });
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// 4. Broadcast to topic
|
||||||
|
console.log('4. Broadcasting to "events" topic (user1, user2)...');
|
||||||
|
sseService.broadcastToTopic('events', 'eventUpdate', { eventId: 123, status: 'started' });
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
console.log('5. Broadcasting to "posts" topic (user1, user3)...');
|
||||||
|
sseService.broadcastToTopic('posts', 'newPost', { postId: 456, author: 'Alice' });
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// 6. Send to specific user
|
||||||
|
console.log('6. Sending to specific user (user2)...');
|
||||||
|
sseService.sendToUser('user2', 'notification', { text: 'You have a new message' });
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// 7. Unsubscribe
|
||||||
|
console.log('7. Unsubscribing user1 from "events"...');
|
||||||
|
sseService.unsubscribe('user1', ['events']);
|
||||||
|
stats = sseService.getStats();
|
||||||
|
console.log('Stats after unsubscribe:', stats);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// 8. Remove client
|
||||||
|
console.log('8. Removing user3...');
|
||||||
|
sseService.removeClient('user3');
|
||||||
|
stats = sseService.getStats();
|
||||||
|
console.log('Stats after removing user3:', stats);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
sseService.removeClient('user1');
|
||||||
|
sseService.removeClient('user2');
|
||||||
|
|
||||||
|
console.log('=== Demo Complete ===');
|
||||||
@@ -2,7 +2,6 @@ apiVersion: v1
|
|||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: adopt-a-street-backend
|
name: adopt-a-street-backend
|
||||||
namespace: adopt-a-street
|
|
||||||
labels:
|
labels:
|
||||||
app: backend
|
app: backend
|
||||||
spec:
|
spec:
|
||||||
@@ -19,7 +18,6 @@ apiVersion: apps/v1
|
|||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: adopt-a-street-backend
|
name: adopt-a-street-backend
|
||||||
namespace: adopt-a-street
|
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
selector:
|
selector:
|
||||||
@@ -46,7 +44,7 @@ spec:
|
|||||||
containers:
|
containers:
|
||||||
- name: backend
|
- name: backend
|
||||||
# Update with your registry and tag
|
# Update with your registry and tag
|
||||||
image: gitea-gitea-http.taildb3494.ts.net/will/adopt-a-street/backend:latest
|
image: gitea-http.taildb3494.ts.net/will/adopt-a-street/backend:latest
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 5000
|
- containerPort: 5000
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ apiVersion: v1
|
|||||||
kind: ConfigMap
|
kind: ConfigMap
|
||||||
metadata:
|
metadata:
|
||||||
name: adopt-a-street-config
|
name: adopt-a-street-config
|
||||||
namespace: adopt-a-street
|
|
||||||
data:
|
data:
|
||||||
# CouchDB Connection
|
# CouchDB Connection
|
||||||
COUCHDB_URL: "http://adopt-a-street-couchdb:5984"
|
COUCHDB_URL: "http://adopt-a-street-couchdb:5984"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ apiVersion: v1
|
|||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: adopt-a-street-frontend
|
name: adopt-a-street-frontend
|
||||||
namespace: adopt-a-street
|
|
||||||
labels:
|
labels:
|
||||||
app: frontend
|
app: frontend
|
||||||
spec:
|
spec:
|
||||||
@@ -19,7 +18,6 @@ apiVersion: apps/v1
|
|||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: adopt-a-street-frontend
|
name: adopt-a-street-frontend
|
||||||
namespace: adopt-a-street
|
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
selector:
|
selector:
|
||||||
@@ -36,7 +34,7 @@ spec:
|
|||||||
containers:
|
containers:
|
||||||
- name: frontend
|
- name: frontend
|
||||||
# Update with your registry and tag
|
# Update with your registry and tag
|
||||||
image: gitea-gitea-http.taildb3494.ts.net/will/adopt-a-street/frontend:latest
|
image: gitea-http.taildb3494.ts.net/will/adopt-a-street/frontend:latest
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 80
|
- containerPort: 80
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ apiVersion: v1
|
|||||||
kind: Secret
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
name: regcred
|
name: regcred
|
||||||
namespace: adopt-a-street
|
|
||||||
type: kubernetes.io/dockerconfigjson
|
type: kubernetes.io/dockerconfigjson
|
||||||
data:
|
data:
|
||||||
.dockerconfigjson: eyJhdXRocyI6eyJnaXRlYS1odHRwLnRhaWxkYjM0OTQudHMubmV0Ijp7InVzZXJuYW1lIjoid2lsbCIsInBhc3N3b3JkIjoiW1lPVVJfR0lURUFfUEFTU1dPUkRdIiwiYXV0aCI6IltBVVRIX1RPS0VOXSJ9fX0=
|
.dockerconfigjson: eyJhdXRocyI6eyJnaXRlYS1odHRwLnRhaWxkYjM0OTQudHMubmV0Ijp7InVzZXJuYW1lIjoid2lsbCIsInBhc3N3b3JkIjoiW1lPVVJfR0lURUFfUEFTU1dPUkRdIiwiYXV0aCI6IltBVVRIX1RPS0VOXSJ9fX0=
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ apiVersion: networking.k8s.io/v1
|
|||||||
kind: Ingress
|
kind: Ingress
|
||||||
metadata:
|
metadata:
|
||||||
name: adopt-a-street-ingress
|
name: adopt-a-street-ingress
|
||||||
namespace: adopt-a-street
|
|
||||||
annotations:
|
annotations:
|
||||||
# Uncomment the appropriate ingress class for your cluster
|
# Uncomment the appropriate ingress class for your cluster
|
||||||
kubernetes.io/ingress.class: "haproxy" # For HAProxy Ingress
|
kubernetes.io/ingress.class: "haproxy" # For HAProxy Ingress
|
||||||
@@ -15,8 +14,9 @@ metadata:
|
|||||||
# traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
|
# traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
|
||||||
# traefik.ingress.kubernetes.io/router.middlewares: default-redirect-https@kubernetescrd
|
# traefik.ingress.kubernetes.io/router.middlewares: default-redirect-https@kubernetescrd
|
||||||
spec:
|
spec:
|
||||||
|
ingressClassName: haproxy
|
||||||
rules:
|
rules:
|
||||||
- host: adopt-a-street.local # CHANGE THIS to your actual domain
|
- host: app.adopt-a-street.192.168.153.241.nip.io # CHANGE THIS to your actual domain
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
# API endpoints
|
# API endpoints
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
data:
|
||||||
|
.dockerconfigjson: eyJhdXRocyI6eyJnaXRlYS1odHRwLnRhaWxkYjM0OTQudHMubmV0Ijp7InVzZXJuYW1lIjoid2lsbCIsInBhc3N3b3JkIjoiWU9VUl9BQ1RVQUxfR0lURUFfUEFTU1dPUkQiLCJlbWFpbCI6IndpbGxAdGFpbGRiMzQ5NC50cy5uZXQiLCJhdXRoIjoiZDJsc2JEcFpUMVZTWDBGRFZGVkJURjlIU1ZSRlFWOVFRVk5UVjA5U1JBPT0ifX19
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: regcred
|
||||||
|
namespace: adopt-a-street-prod
|
||||||
|
type: kubernetes.io/dockerconfigjson
|
||||||
@@ -2,7 +2,6 @@ apiVersion: v1
|
|||||||
kind: Secret
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
name: adopt-a-street-secrets
|
name: adopt-a-street-secrets
|
||||||
namespace: adopt-a-street
|
|
||||||
type: Opaque
|
type: Opaque
|
||||||
stringData:
|
stringData:
|
||||||
# JWT Secret - CHANGE THIS IN PRODUCTION!
|
# JWT Secret - CHANGE THIS IN PRODUCTION!
|
||||||
|
|||||||
@@ -39,9 +39,13 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
|
image: ${DOCKER_REGISTRY:-your-registry}/adopt-a-street-backend:${TAG:-latest}
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
platforms:
|
||||||
|
- linux/amd64
|
||||||
|
- linux/arm64
|
||||||
container_name: adopt-a-street-backend
|
container_name: adopt-a-street-backend
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
@@ -74,9 +78,13 @@ services:
|
|||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
|
image: ${DOCKER_REGISTRY:-your-registry}/adopt-a-street-frontend:${TAG:-latest}
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
platforms:
|
||||||
|
- linux/amd64
|
||||||
|
- linux/arm64
|
||||||
container_name: adopt-a-street-frontend
|
container_name: adopt-a-street-frontend
|
||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "3000:80"
|
||||||
|
|||||||
Generated
+29
-140
@@ -23,7 +23,6 @@
|
|||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^11.0.5",
|
||||||
"recharts": "^3.3.0",
|
"recharts": "^3.3.0",
|
||||||
"socket.io-client": "^4.8.1",
|
|
||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -89,6 +88,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz",
|
||||||
"integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==",
|
"integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ampproject/remapping": "^2.2.0",
|
"@ampproject/remapping": "^2.2.0",
|
||||||
"@babel/code-frame": "^7.26.2",
|
"@babel/code-frame": "^7.26.2",
|
||||||
@@ -729,6 +729,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.26.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.26.0.tgz",
|
||||||
"integrity": "sha512-B+O2DnPc0iG+YXFqOxv2WNuNU97ToWjOomUQ78DouOENWUaM5sVrmet9mcomUGQFwpJd//gvUagXBSdzO1fRKg==",
|
"integrity": "sha512-B+O2DnPc0iG+YXFqOxv2WNuNU97ToWjOomUQ78DouOENWUaM5sVrmet9mcomUGQFwpJd//gvUagXBSdzO1fRKg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.25.9"
|
"@babel/helper-plugin-utils": "^7.25.9"
|
||||||
},
|
},
|
||||||
@@ -1593,6 +1594,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.25.9.tgz",
|
||||||
"integrity": "sha512-s5XwpQYCqGerXl+Pu6VDL3x0j2d82eiV77UJ8a2mDHAW7j9SWRqQ2y1fNo1Z74CdcYipl5Z41zvjj4Nfzq36rw==",
|
"integrity": "sha512-s5XwpQYCqGerXl+Pu6VDL3x0j2d82eiV77UJ8a2mDHAW7j9SWRqQ2y1fNo1Z74CdcYipl5Z41zvjj4Nfzq36rw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-annotate-as-pure": "^7.25.9",
|
"@babel/helper-annotate-as-pure": "^7.25.9",
|
||||||
"@babel/helper-module-imports": "^7.25.9",
|
"@babel/helper-module-imports": "^7.25.9",
|
||||||
@@ -3432,12 +3434,6 @@
|
|||||||
"@sinonjs/commons": "^1.7.0"
|
"@sinonjs/commons": "^1.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@socket.io/component-emitter": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
||||||
@@ -3697,6 +3693,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
|
||||||
"integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
|
"integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.10.4",
|
"@babel/code-frame": "^7.10.4",
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
@@ -4264,6 +4261,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
|
||||||
"integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==",
|
"integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/regexpp": "^4.4.0",
|
"@eslint-community/regexpp": "^4.4.0",
|
||||||
"@typescript-eslint/scope-manager": "5.62.0",
|
"@typescript-eslint/scope-manager": "5.62.0",
|
||||||
@@ -4317,6 +4315,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz",
|
||||||
"integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
|
"integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "5.62.0",
|
"@typescript-eslint/scope-manager": "5.62.0",
|
||||||
"@typescript-eslint/types": "5.62.0",
|
"@typescript-eslint/types": "5.62.0",
|
||||||
@@ -4686,6 +4685,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
|
||||||
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
|
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -4772,6 +4772,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
"fast-json-stable-stringify": "^2.0.0",
|
"fast-json-stable-stringify": "^2.0.0",
|
||||||
@@ -5685,6 +5686,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"caniuse-lite": "^1.0.30001688",
|
"caniuse-lite": "^1.0.30001688",
|
||||||
"electron-to-chromium": "^1.5.73",
|
"electron-to-chromium": "^1.5.73",
|
||||||
@@ -7384,66 +7386,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/engine.io-client": {
|
|
||||||
"version": "6.6.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
|
|
||||||
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
|
||||||
"debug": "~4.3.1",
|
|
||||||
"engine.io-parser": "~5.2.1",
|
|
||||||
"ws": "~8.17.1",
|
|
||||||
"xmlhttprequest-ssl": "~2.1.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/engine.io-client/node_modules/debug": {
|
|
||||||
"version": "4.3.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
|
||||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/engine.io-client/node_modules/ws": {
|
|
||||||
"version": "8.17.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
|
|
||||||
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"bufferutil": "^4.0.1",
|
|
||||||
"utf-8-validate": ">=5.0.2"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"bufferutil": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"utf-8-validate": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/engine.io-parser": {
|
|
||||||
"version": "5.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
|
||||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.18.1",
|
"version": "5.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
|
||||||
@@ -7736,6 +7678,7 @@
|
|||||||
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
|
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
|
||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -10544,6 +10487,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz",
|
||||||
"integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==",
|
"integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jest/core": "^27.5.1",
|
"@jest/core": "^27.5.1",
|
||||||
"import-local": "^3.0.2",
|
"import-local": "^3.0.2",
|
||||||
@@ -11691,7 +11635,8 @@
|
|||||||
"version": "1.9.4",
|
"version": "1.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/leven": {
|
"node_modules/leven": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
@@ -12985,6 +12930,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.8",
|
"nanoid": "^3.3.8",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -14172,6 +14118,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
||||||
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
|
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cssesc": "^3.0.0",
|
"cssesc": "^3.0.0",
|
||||||
"util-deprecate": "^1.0.2"
|
"util-deprecate": "^1.0.2"
|
||||||
@@ -14537,6 +14484,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
|
||||||
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
|
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -14674,6 +14622,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
|
||||||
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
|
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.25.0"
|
"scheduler": "^0.25.0"
|
||||||
},
|
},
|
||||||
@@ -14691,7 +14640,8 @@
|
|||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/react-leaflet": {
|
"node_modules/react-leaflet": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
@@ -14712,6 +14662,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/use-sync-external-store": "^0.0.6",
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
"use-sync-external-store": "^1.4.0"
|
"use-sync-external-store": "^1.4.0"
|
||||||
@@ -14735,6 +14686,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
|
||||||
"integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==",
|
"integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -14964,7 +14916,8 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/redux-thunk": {
|
"node_modules/redux-thunk": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
@@ -15318,6 +15271,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz",
|
||||||
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"rollup": "dist/bin/rollup"
|
"rollup": "dist/bin/rollup"
|
||||||
},
|
},
|
||||||
@@ -15560,6 +15514,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-uri": "^3.0.1",
|
||||||
@@ -15948,68 +15903,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/socket.io-client": {
|
|
||||||
"version": "4.8.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
|
|
||||||
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
|
||||||
"debug": "~4.3.2",
|
|
||||||
"engine.io-client": "~6.6.1",
|
|
||||||
"socket.io-parser": "~4.2.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-client/node_modules/debug": {
|
|
||||||
"version": "4.3.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
|
||||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-parser": {
|
|
||||||
"version": "4.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
|
|
||||||
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
|
||||||
"debug": "~4.3.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-parser/node_modules/debug": {
|
|
||||||
"version": "4.3.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
|
||||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sockjs": {
|
"node_modules/sockjs": {
|
||||||
"version": "0.3.24",
|
"version": "0.3.24",
|
||||||
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz",
|
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz",
|
||||||
@@ -17269,6 +17162,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
|
||||||
"integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
|
"integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
|
||||||
"license": "(MIT OR CC0-1.0)",
|
"license": "(MIT OR CC0-1.0)",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
},
|
},
|
||||||
@@ -17739,6 +17633,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz",
|
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz",
|
||||||
"integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==",
|
"integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/eslint-scope": "^3.7.7",
|
"@types/eslint-scope": "^3.7.7",
|
||||||
"@types/estree": "^1.0.6",
|
"@types/estree": "^1.0.6",
|
||||||
@@ -17808,6 +17703,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz",
|
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz",
|
||||||
"integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==",
|
"integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/bonjour": "^3.5.9",
|
"@types/bonjour": "^3.5.9",
|
||||||
"@types/connect-history-api-fallback": "^1.3.5",
|
"@types/connect-history-api-fallback": "^1.3.5",
|
||||||
@@ -18220,6 +18116,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-uri": "^3.0.1",
|
||||||
@@ -18540,14 +18437,6 @@
|
|||||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/xmlhttprequest-ssl": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^11.0.5",
|
||||||
"recharts": "^3.3.0",
|
"recharts": "^3.3.0",
|
||||||
"socket.io-client": "^4.8.1",
|
|
||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4"
|
||||||
},
|
},
|
||||||
"proxy": "http://localhost:5000",
|
"proxy": "http://localhost:5000",
|
||||||
|
|||||||
+3
-3
@@ -5,7 +5,7 @@ import "react-toastify/dist/ReactToastify.css";
|
|||||||
import "./styles/toastStyles.css";
|
import "./styles/toastStyles.css";
|
||||||
|
|
||||||
import AuthProvider from "./context/AuthContext";
|
import AuthProvider from "./context/AuthContext";
|
||||||
import SocketProvider from "./context/SocketContext";
|
import SSEProvider from "./context/SSEContext";
|
||||||
import NotificationProvider from "./context/NotificationProvider";
|
import NotificationProvider from "./context/NotificationProvider";
|
||||||
import Login from "./components/Login";
|
import Login from "./components/Login";
|
||||||
import Register from "./components/Register";
|
import Register from "./components/Register";
|
||||||
@@ -24,7 +24,7 @@ import PrivateRoute from "./components/PrivateRoute";
|
|||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SocketProvider>
|
<SSEProvider>
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
<Router>
|
<Router>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
@@ -59,7 +59,7 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
</Router>
|
</Router>
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</SocketProvider>
|
</SSEProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,15 +27,13 @@ jest.mock('react-leaflet', () => ({
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Mock Socket.IO
|
// Mock EventSource for SSE
|
||||||
jest.mock('socket.io-client', () => {
|
global.EventSource = jest.fn(() => ({
|
||||||
return jest.fn(() => ({
|
addEventListener: jest.fn(),
|
||||||
on: jest.fn(),
|
removeEventListener: jest.fn(),
|
||||||
emit: jest.fn(),
|
close: jest.fn(),
|
||||||
off: jest.fn(),
|
readyState: 1,
|
||||||
disconnect: jest.fn(),
|
|
||||||
}));
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
describe('Authentication Flow Integration Tests', () => {
|
describe('Authentication Flow Integration Tests', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from "react";
|
|||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import NotificationProvider, { notify } from "../../context/NotificationProvider";
|
import NotificationProvider, { notify } from "../../context/NotificationProvider";
|
||||||
import { SocketContext } from "../../context/SocketContext";
|
import { SSEContext } from "../../context/SSEContext";
|
||||||
import { AuthContext } from "../../context/AuthContext";
|
import { AuthContext } from "../../context/AuthContext";
|
||||||
|
|
||||||
// Mock axios to prevent import errors
|
// Mock axios to prevent import errors
|
||||||
@@ -20,25 +20,21 @@ jest.mock("react-toastify", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("NotificationProvider", () => {
|
describe("NotificationProvider", () => {
|
||||||
let mockSocket;
|
let mockSSEContext;
|
||||||
let mockSocketContext;
|
|
||||||
let mockAuthContext;
|
let mockAuthContext;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|
||||||
// Create mock socket with event listener support
|
mockSSEContext = {
|
||||||
mockSocket = {
|
|
||||||
on: jest.fn(),
|
|
||||||
off: jest.fn(),
|
|
||||||
emit: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockSocketContext = {
|
|
||||||
socket: mockSocket,
|
|
||||||
connected: true,
|
connected: true,
|
||||||
|
notifications: [],
|
||||||
on: jest.fn(),
|
on: jest.fn(),
|
||||||
off: jest.fn(),
|
off: jest.fn(),
|
||||||
|
subscribe: jest.fn().mockResolvedValue({ subscribed: [] }),
|
||||||
|
unsubscribe: jest.fn().mockResolvedValue({ unsubscribed: [] }),
|
||||||
|
clearNotification: jest.fn(),
|
||||||
|
clearAllNotifications: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
mockAuthContext = {
|
mockAuthContext = {
|
||||||
@@ -52,9 +48,9 @@ describe("NotificationProvider", () => {
|
|||||||
const renderWithProviders = (children) => {
|
const renderWithProviders = (children) => {
|
||||||
return render(
|
return render(
|
||||||
<AuthContext.Provider value={mockAuthContext}>
|
<AuthContext.Provider value={mockAuthContext}>
|
||||||
<SocketContext.Provider value={mockSocketContext}>
|
<SSEContext.Provider value={mockSSEContext}>
|
||||||
<NotificationProvider>{children}</NotificationProvider>
|
<NotificationProvider>{children}</NotificationProvider>
|
||||||
</SocketContext.Provider>
|
</SSEContext.Provider>
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -64,84 +60,17 @@ describe("NotificationProvider", () => {
|
|||||||
expect(screen.getByText("Test Content")).toBeInTheDocument();
|
expect(screen.getByText("Test Content")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("subscribes to socket events when connected", () => {
|
|
||||||
renderWithProviders(<div>Test</div>);
|
|
||||||
|
|
||||||
// Verify socket event listeners were registered
|
|
||||||
expect(mockSocket.on).toHaveBeenCalledWith("connect", expect.any(Function));
|
|
||||||
expect(mockSocket.on).toHaveBeenCalledWith("disconnect", expect.any(Function));
|
|
||||||
expect(mockSocket.on).toHaveBeenCalledWith("reconnect", expect.any(Function));
|
|
||||||
expect(mockSocket.on).toHaveBeenCalledWith("reconnect_error", expect.any(Function));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("subscribes to custom events via context", () => {
|
test("subscribes to custom events via context", () => {
|
||||||
renderWithProviders(<div>Test</div>);
|
renderWithProviders(<div>Test</div>);
|
||||||
|
|
||||||
// Verify custom event listeners were registered via context
|
// Verify custom event listeners were registered via context
|
||||||
expect(mockSocketContext.on).toHaveBeenCalledWith("eventUpdate", expect.any(Function));
|
expect(mockSSEContext.on).toHaveBeenCalledWith("eventUpdate", expect.any(Function));
|
||||||
expect(mockSocketContext.on).toHaveBeenCalledWith("taskUpdate", expect.any(Function));
|
expect(mockSSEContext.on).toHaveBeenCalledWith("taskUpdate", expect.any(Function));
|
||||||
expect(mockSocketContext.on).toHaveBeenCalledWith("streetUpdate", expect.any(Function));
|
expect(mockSSEContext.on).toHaveBeenCalledWith("streetUpdate", expect.any(Function));
|
||||||
expect(mockSocketContext.on).toHaveBeenCalledWith("achievementUnlocked", expect.any(Function));
|
expect(mockSSEContext.on).toHaveBeenCalledWith("achievementUnlocked", expect.any(Function));
|
||||||
expect(mockSocketContext.on).toHaveBeenCalledWith("newPost", expect.any(Function));
|
expect(mockSSEContext.on).toHaveBeenCalledWith("newPost", expect.any(Function));
|
||||||
expect(mockSocketContext.on).toHaveBeenCalledWith("newComment", expect.any(Function));
|
expect(mockSSEContext.on).toHaveBeenCalledWith("newComment", expect.any(Function));
|
||||||
expect(mockSocketContext.on).toHaveBeenCalledWith("notification", expect.any(Function));
|
expect(mockSSEContext.on).toHaveBeenCalledWith("notification", expect.any(Function));
|
||||||
});
|
|
||||||
|
|
||||||
test("shows success toast on connect", () => {
|
|
||||||
renderWithProviders(<div>Test</div>);
|
|
||||||
|
|
||||||
// Get the connect handler
|
|
||||||
const connectHandler = mockSocket.on.mock.calls.find(
|
|
||||||
(call) => call[0] === "connect"
|
|
||||||
)?.[1];
|
|
||||||
|
|
||||||
// Trigger connect event
|
|
||||||
if (connectHandler) {
|
|
||||||
connectHandler();
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(toast.success).toHaveBeenCalledWith(
|
|
||||||
"Connected to real-time updates",
|
|
||||||
expect.objectContaining({ toastId: "socket-connected" })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows error toast on server disconnect", () => {
|
|
||||||
renderWithProviders(<div>Test</div>);
|
|
||||||
|
|
||||||
// Get the disconnect handler
|
|
||||||
const disconnectHandler = mockSocket.on.mock.calls.find(
|
|
||||||
(call) => call[0] === "disconnect"
|
|
||||||
)?.[1];
|
|
||||||
|
|
||||||
// Trigger disconnect event with server reason
|
|
||||||
if (disconnectHandler) {
|
|
||||||
disconnectHandler("io server disconnect");
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(toast.error).toHaveBeenCalledWith(
|
|
||||||
"Server disconnected. Attempting to reconnect...",
|
|
||||||
expect.objectContaining({ toastId: "socket-disconnected" })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows warning toast on transport error", () => {
|
|
||||||
renderWithProviders(<div>Test</div>);
|
|
||||||
|
|
||||||
// Get the disconnect handler
|
|
||||||
const disconnectHandler = mockSocket.on.mock.calls.find(
|
|
||||||
(call) => call[0] === "disconnect"
|
|
||||||
)?.[1];
|
|
||||||
|
|
||||||
// Trigger disconnect event with transport error
|
|
||||||
if (disconnectHandler) {
|
|
||||||
disconnectHandler("transport error");
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(toast.warning).toHaveBeenCalledWith(
|
|
||||||
"Connection lost. Reconnecting...",
|
|
||||||
expect.objectContaining({ toastId: "socket-reconnecting" })
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("cleans up event listeners on unmount", () => {
|
test("cleans up event listeners on unmount", () => {
|
||||||
@@ -149,34 +78,23 @@ describe("NotificationProvider", () => {
|
|||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
|
|
||||||
// Verify socket event listeners were removed
|
|
||||||
expect(mockSocket.off).toHaveBeenCalledWith("connect", expect.any(Function));
|
|
||||||
expect(mockSocket.off).toHaveBeenCalledWith("disconnect", expect.any(Function));
|
|
||||||
expect(mockSocket.off).toHaveBeenCalledWith("reconnect", expect.any(Function));
|
|
||||||
expect(mockSocket.off).toHaveBeenCalledWith("reconnect_error", expect.any(Function));
|
|
||||||
|
|
||||||
// Verify custom event listeners were removed via context
|
// Verify custom event listeners were removed via context
|
||||||
expect(mockSocketContext.off).toHaveBeenCalledWith("eventUpdate", expect.any(Function));
|
expect(mockSSEContext.off).toHaveBeenCalledWith("eventUpdate", expect.any(Function));
|
||||||
expect(mockSocketContext.off).toHaveBeenCalledWith("taskUpdate", expect.any(Function));
|
expect(mockSSEContext.off).toHaveBeenCalledWith("taskUpdate", expect.any(Function));
|
||||||
expect(mockSocketContext.off).toHaveBeenCalledWith("streetUpdate", expect.any(Function));
|
expect(mockSSEContext.off).toHaveBeenCalledWith("streetUpdate", expect.any(Function));
|
||||||
|
expect(mockSSEContext.off).toHaveBeenCalledWith("achievementUnlocked", expect.any(Function));
|
||||||
|
expect(mockSSEContext.off).toHaveBeenCalledWith("newPost", expect.any(Function));
|
||||||
|
expect(mockSSEContext.off).toHaveBeenCalledWith("newComment", expect.any(Function));
|
||||||
|
expect(mockSSEContext.off).toHaveBeenCalledWith("notification", expect.any(Function));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not subscribe when socket is not connected", () => {
|
test("does not subscribe when not connected", () => {
|
||||||
mockSocketContext.connected = false;
|
mockSSEContext.connected = false;
|
||||||
|
|
||||||
renderWithProviders(<div>Test</div>);
|
renderWithProviders(<div>Test</div>);
|
||||||
|
|
||||||
// Socket event listeners should not be registered when not connected
|
// Event listeners should not be registered when not connected
|
||||||
expect(mockSocket.on).not.toHaveBeenCalled();
|
expect(mockSSEContext.on).not.toHaveBeenCalled();
|
||||||
});
|
|
||||||
|
|
||||||
test("does not subscribe when socket is null", () => {
|
|
||||||
mockSocketContext.socket = null;
|
|
||||||
|
|
||||||
renderWithProviders(<div>Test</div>);
|
|
||||||
|
|
||||||
// Socket event listeners should not be registered when socket is null
|
|
||||||
expect(mockSocket.on).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,656 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||||
|
import SSEProvider, { SSEContext, useSSE } from "../../context/SSEContext";
|
||||||
|
import { AuthContext } from "../../context/AuthContext";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
// Mock axios
|
||||||
|
jest.mock("axios");
|
||||||
|
|
||||||
|
// Mock EventSource
|
||||||
|
class MockEventSource {
|
||||||
|
constructor(url) {
|
||||||
|
this.url = url;
|
||||||
|
this.onopen = null;
|
||||||
|
this.onerror = null;
|
||||||
|
this.onmessage = null;
|
||||||
|
this.readyState = 0;
|
||||||
|
MockEventSource.instances.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.readyState = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
static instances = [];
|
||||||
|
static reset() {
|
||||||
|
MockEventSource.instances = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
global.EventSource = MockEventSource;
|
||||||
|
|
||||||
|
describe("SSEContext", () => {
|
||||||
|
let mockAuthContext;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
MockEventSource.reset();
|
||||||
|
|
||||||
|
mockAuthContext = {
|
||||||
|
auth: {
|
||||||
|
isAuthenticated: true,
|
||||||
|
token: "mock-token",
|
||||||
|
user: { id: "user123", name: "Test User" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock axios responses
|
||||||
|
axios.post.mockResolvedValue({ data: { subscribed: [] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderWithAuth = (children, authValue = mockAuthContext) => {
|
||||||
|
return render(
|
||||||
|
<AuthContext.Provider value={authValue}>
|
||||||
|
<SSEProvider>{children}</SSEProvider>
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Connection Lifecycle", () => {
|
||||||
|
it("renders children correctly", () => {
|
||||||
|
renderWithAuth(<div>Test Content</div>);
|
||||||
|
expect(screen.getByText("Test Content")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("connects to SSE stream when authenticated", async () => {
|
||||||
|
renderWithAuth(<div>Test</div>);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(MockEventSource.instances.length).toBe(1);
|
||||||
|
expect(MockEventSource.instances[0].url).toContain("/api/sse/stream");
|
||||||
|
expect(MockEventSource.instances[0].url).toContain("token=mock-token");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not connect when not authenticated", () => {
|
||||||
|
const unauthContext = {
|
||||||
|
auth: {
|
||||||
|
isAuthenticated: false,
|
||||||
|
token: null,
|
||||||
|
user: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithAuth(<div>Test</div>, unauthContext);
|
||||||
|
|
||||||
|
expect(MockEventSource.instances.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets connected state to true on open", async () => {
|
||||||
|
const TestComponent = () => {
|
||||||
|
const { connected } = useSSE();
|
||||||
|
return <div>{connected ? "Connected" : "Disconnected"}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithAuth(<TestComponent />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(MockEventSource.instances.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger onopen
|
||||||
|
act(() => {
|
||||||
|
MockEventSource.instances[0].onopen();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Connected")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles connection errors", async () => {
|
||||||
|
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
renderWithAuth(<div>Test</div>);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(MockEventSource.instances.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger onerror
|
||||||
|
act(() => {
|
||||||
|
MockEventSource.instances[0].onerror(new Error("Connection error"));
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
"SSE: Connection error:",
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disconnects when user logs out", async () => {
|
||||||
|
const { rerender } = renderWithAuth(<div>Test</div>);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(MockEventSource.instances.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventSource = MockEventSource.instances[0];
|
||||||
|
const closeSpy = jest.spyOn(eventSource, "close");
|
||||||
|
|
||||||
|
// Update auth to unauthenticated
|
||||||
|
const unauthContext = {
|
||||||
|
auth: {
|
||||||
|
isAuthenticated: false,
|
||||||
|
token: null,
|
||||||
|
user: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<AuthContext.Provider value={unauthContext}>
|
||||||
|
<SSEProvider>
|
||||||
|
<div>Test</div>
|
||||||
|
</SSEProvider>
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(closeSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes connection on unmount", async () => {
|
||||||
|
const { unmount } = renderWithAuth(<div>Test</div>);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(MockEventSource.instances.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventSource = MockEventSource.instances[0];
|
||||||
|
eventSource.close = jest.fn(); // Replace close method with spy
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
// Wait for cleanup to complete
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(eventSource.close).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Event Handler Registration", () => {
|
||||||
|
it("registers event handlers with on()", async () => {
|
||||||
|
const TestComponent = () => {
|
||||||
|
const { on } = useSSE();
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handler = jest.fn();
|
||||||
|
on("testEvent", handler);
|
||||||
|
}, [on]);
|
||||||
|
return <div>Test</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithAuth(<TestComponent />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Test")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unregisters event handlers with off()", async () => {
|
||||||
|
const TestComponent = () => {
|
||||||
|
const { on, off } = useSSE();
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handler = jest.fn();
|
||||||
|
on("testEvent", handler);
|
||||||
|
return () => {
|
||||||
|
off("testEvent", handler);
|
||||||
|
};
|
||||||
|
}, [on, off]);
|
||||||
|
return <div>Test</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { unmount } = renderWithAuth(<TestComponent />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Test")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls registered handlers when event is received", async () => {
|
||||||
|
const handler = jest.fn();
|
||||||
|
|
||||||
|
const TestComponent = () => {
|
||||||
|
const { on } = useSSE();
|
||||||
|
React.useEffect(() => {
|
||||||
|
on("testEvent", handler);
|
||||||
|
}, [on]);
|
||||||
|
return <div>Test</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithAuth(<TestComponent />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(MockEventSource.instances.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate receiving a message
|
||||||
|
const eventData = {
|
||||||
|
type: "testEvent",
|
||||||
|
payload: { message: "Test message" },
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
MockEventSource.instances[0].onmessage({
|
||||||
|
data: JSON.stringify(eventData),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(handler).toHaveBeenCalledWith({ message: "Test message" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple handlers for the same event type", async () => {
|
||||||
|
const handler1 = jest.fn();
|
||||||
|
const handler2 = jest.fn();
|
||||||
|
|
||||||
|
const TestComponent = () => {
|
||||||
|
const { on } = useSSE();
|
||||||
|
React.useEffect(() => {
|
||||||
|
on("testEvent", handler1);
|
||||||
|
on("testEvent", handler2);
|
||||||
|
}, [on]);
|
||||||
|
return <div>Test</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithAuth(<TestComponent />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(MockEventSource.instances.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate receiving a message
|
||||||
|
const eventData = {
|
||||||
|
type: "testEvent",
|
||||||
|
payload: { message: "Test message" },
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
MockEventSource.instances[0].onmessage({
|
||||||
|
data: JSON.stringify(eventData),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(handler1).toHaveBeenCalledWith({ message: "Test message" });
|
||||||
|
expect(handler2).toHaveBeenCalledWith({ message: "Test message" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Topic Subscription API", () => {
|
||||||
|
it("subscribes to topics via API call", async () => {
|
||||||
|
const TestComponent = () => {
|
||||||
|
const { subscribe } = useSSE();
|
||||||
|
return (
|
||||||
|
<button onClick={() => subscribe(["events", "tasks"])}>
|
||||||
|
Subscribe
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithAuth(<TestComponent />);
|
||||||
|
|
||||||
|
const subscribeButton = screen.getByText("Subscribe");
|
||||||
|
act(() => {
|
||||||
|
subscribeButton.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(axios.post).toHaveBeenCalledWith("/api/sse/subscribe", {
|
||||||
|
topics: ["events", "tasks"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unsubscribes from topics via API call", async () => {
|
||||||
|
const TestComponent = () => {
|
||||||
|
const { unsubscribe } = useSSE();
|
||||||
|
return (
|
||||||
|
<button onClick={() => unsubscribe(["events", "tasks"])}>
|
||||||
|
Unsubscribe
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithAuth(<TestComponent />);
|
||||||
|
|
||||||
|
const unsubscribeButton = screen.getByText("Unsubscribe");
|
||||||
|
act(() => {
|
||||||
|
unsubscribeButton.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(axios.post).toHaveBeenCalledWith("/api/sse/unsubscribe", {
|
||||||
|
topics: ["events", "tasks"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not subscribe when not authenticated", async () => {
|
||||||
|
const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
|
const unauthContext = {
|
||||||
|
auth: {
|
||||||
|
isAuthenticated: false,
|
||||||
|
token: null,
|
||||||
|
user: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const TestComponent = () => {
|
||||||
|
const { subscribe } = useSSE();
|
||||||
|
return (
|
||||||
|
<button onClick={() => subscribe(["events"])}>Subscribe</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithAuth(<TestComponent />, unauthContext);
|
||||||
|
|
||||||
|
const subscribeButton = screen.getByText("Subscribe");
|
||||||
|
act(() => {
|
||||||
|
subscribeButton.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||||
|
"SSE: Cannot subscribe - not authenticated"
|
||||||
|
);
|
||||||
|
expect(axios.post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
consoleWarnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles subscription errors gracefully", async () => {
|
||||||
|
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
axios.post.mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
|
||||||
|
const TestComponent = () => {
|
||||||
|
const { subscribe } = useSSE();
|
||||||
|
return (
|
||||||
|
<button onClick={() => subscribe(["events"])}>Subscribe</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithAuth(<TestComponent />);
|
||||||
|
|
||||||
|
const subscribeButton = screen.getByText("Subscribe");
|
||||||
|
act(() => {
|
||||||
|
subscribeButton.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
"SSE: Error subscribing to topics:",
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Notification State Management", () => {
|
||||||
|
it("adds notifications to state when received", async () => {
|
||||||
|
const TestComponent = () => {
|
||||||
|
const { notifications } = useSSE();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{notifications.map((n) => (
|
||||||
|
<div key={n.id}>{n.message}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithAuth(<TestComponent />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(MockEventSource.instances.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate receiving a notification
|
||||||
|
const eventData = {
|
||||||
|
type: "notification",
|
||||||
|
payload: { message: "New notification" },
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
MockEventSource.instances[0].onmessage({
|
||||||
|
data: JSON.stringify(eventData),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("New notification")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a specific notification", async () => {
|
||||||
|
const TestComponent = () => {
|
||||||
|
const { notifications, clearNotification } = useSSE();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{notifications.map((n) => (
|
||||||
|
<div key={n.id}>
|
||||||
|
{n.message}
|
||||||
|
<button onClick={() => clearNotification(n.id)}>Clear</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithAuth(<TestComponent />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(MockEventSource.instances.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a notification
|
||||||
|
const eventData = {
|
||||||
|
type: "notification",
|
||||||
|
payload: { message: "Test notification" },
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
MockEventSource.instances[0].onmessage({
|
||||||
|
data: JSON.stringify(eventData),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Test notification")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear the notification
|
||||||
|
const clearButton = screen.getByText("Clear");
|
||||||
|
act(() => {
|
||||||
|
clearButton.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText("Test notification")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears all notifications", async () => {
|
||||||
|
const TestComponent = () => {
|
||||||
|
const { notifications, clearAllNotifications } = useSSE();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={clearAllNotifications}>Clear All</button>
|
||||||
|
{notifications.map((n) => (
|
||||||
|
<div key={n.id}>{n.message}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithAuth(<TestComponent />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(MockEventSource.instances.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add multiple notifications
|
||||||
|
act(() => {
|
||||||
|
MockEventSource.instances[0].onmessage({
|
||||||
|
data: JSON.stringify({
|
||||||
|
type: "notification",
|
||||||
|
payload: { message: "Notification 1" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
MockEventSource.instances[0].onmessage({
|
||||||
|
data: JSON.stringify({
|
||||||
|
type: "notification",
|
||||||
|
payload: { message: "Notification 2" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Notification 1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Notification 2")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear all notifications
|
||||||
|
const clearAllButton = screen.getByText("Clear All");
|
||||||
|
act(() => {
|
||||||
|
clearAllButton.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText("Notification 1")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Notification 2")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useSSE Hook", () => {
|
||||||
|
it("throws error when used outside SSEProvider", () => {
|
||||||
|
const TestComponent = () => {
|
||||||
|
useSSE();
|
||||||
|
return <div>Test</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Suppress console.error for this test
|
||||||
|
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
render(<TestComponent />);
|
||||||
|
}).toThrow("useSSE must be used within an SSEProvider");
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns SSE context value when used correctly", () => {
|
||||||
|
const TestComponent = () => {
|
||||||
|
const context = useSSE();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{context.connected ? "Connected" : "Disconnected"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithAuth(<TestComponent />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Disconnected")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Error Handling", () => {
|
||||||
|
it("handles malformed JSON messages gracefully", async () => {
|
||||||
|
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
renderWithAuth(<div>Test</div>);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(MockEventSource.instances.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send malformed JSON
|
||||||
|
act(() => {
|
||||||
|
MockEventSource.instances[0].onmessage({
|
||||||
|
data: "invalid json",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
"SSE: Error parsing message:",
|
||||||
|
expect.any(Error),
|
||||||
|
"invalid json"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles errors in event handlers gracefully", async () => {
|
||||||
|
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
const throwingHandler = jest.fn(() => {
|
||||||
|
throw new Error("Handler error");
|
||||||
|
});
|
||||||
|
|
||||||
|
const TestComponent = () => {
|
||||||
|
const { on } = useSSE();
|
||||||
|
React.useEffect(() => {
|
||||||
|
on("testEvent", throwingHandler);
|
||||||
|
}, [on]);
|
||||||
|
return <div>Test</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithAuth(<TestComponent />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(MockEventSource.instances.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger the handler
|
||||||
|
act(() => {
|
||||||
|
MockEventSource.instances[0].onmessage({
|
||||||
|
data: JSON.stringify({
|
||||||
|
type: "testEvent",
|
||||||
|
payload: { message: "Test" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
"SSE: Error in event handler for testEvent:",
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,12 +2,12 @@ import React, { useState, useEffect, useContext, useCallback } from "react";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
import { SocketContext } from "../context/SocketContext";
|
import { SSEContext } from "../context/SSEContext";
|
||||||
import { AuthContext } from "../context/AuthContext";
|
import { AuthContext } from "../context/AuthContext";
|
||||||
|
|
||||||
const Events = () => {
|
const Events = () => {
|
||||||
const { auth } = useContext(AuthContext);
|
const { auth } = useContext(AuthContext);
|
||||||
const { socket, connected, on, off, joinEvent, leaveEvent } = useContext(SocketContext);
|
const { connected, on, off, subscribe, unsubscribe } = useContext(SSEContext);
|
||||||
const [events, setEvents] = useState([]);
|
const [events, setEvents] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
@@ -32,9 +32,20 @@ const Events = () => {
|
|||||||
loadEvents();
|
loadEvents();
|
||||||
}, [loadEvents]);
|
}, [loadEvents]);
|
||||||
|
|
||||||
|
// Subscribe to global events topic on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (!connected) return;
|
||||||
|
|
||||||
|
subscribe(["events"]);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe(["events"]);
|
||||||
|
};
|
||||||
|
}, [connected, subscribe, unsubscribe]);
|
||||||
|
|
||||||
// Handle real-time event updates
|
// Handle real-time event updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!socket || !connected) return;
|
if (!connected) return;
|
||||||
|
|
||||||
const handleEventUpdate = (data) => {
|
const handleEventUpdate = (data) => {
|
||||||
console.log("Received event update:", data);
|
console.log("Received event update:", data);
|
||||||
@@ -72,26 +83,33 @@ const Events = () => {
|
|||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
return () => {
|
return () => {
|
||||||
off("eventUpdate", handleEventUpdate);
|
off("eventUpdate", handleEventUpdate);
|
||||||
|
|
||||||
// Leave all joined event rooms
|
|
||||||
joinedEvents.forEach((eventId) => {
|
|
||||||
leaveEvent(eventId);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
}, [socket, connected, on, off, joinedEvents, leaveEvent]);
|
}, [connected, on, off]);
|
||||||
|
|
||||||
// Join event room when viewing events
|
// Subscribe to individual event topics when viewing events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!socket || !connected) return;
|
if (!connected) return;
|
||||||
|
|
||||||
// Join each event room for real-time updates
|
// Subscribe to each event topic for real-time updates
|
||||||
events.forEach((event) => {
|
const newEventIds = events
|
||||||
if (!joinedEvents.has(event._id)) {
|
.map((event) => event._id)
|
||||||
joinEvent(event._id);
|
.filter((id) => !joinedEvents.has(id));
|
||||||
setJoinedEvents((prev) => new Set([...prev, event._id]));
|
|
||||||
|
if (newEventIds.length > 0) {
|
||||||
|
subscribe(newEventIds.map((id) => `event_${id}`));
|
||||||
|
setJoinedEvents((prev) => new Set([...prev, ...newEventIds]));
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}, [events, socket, connected, joinEvent, joinedEvents]);
|
// Cleanup: unsubscribe from event topics that are no longer in the list
|
||||||
|
return () => {
|
||||||
|
const eventIdsToUnsubscribe = Array.from(joinedEvents).filter(
|
||||||
|
(id) => !events.some((event) => event._id === id)
|
||||||
|
);
|
||||||
|
if (eventIdsToUnsubscribe.length > 0) {
|
||||||
|
unsubscribe(eventIdsToUnsubscribe.map((id) => `event_${id}`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [events, connected, subscribe, unsubscribe, joinedEvents]);
|
||||||
|
|
||||||
const rsvp = async (id) => {
|
const rsvp = async (id) => {
|
||||||
if (!auth.isAuthenticated) {
|
if (!auth.isAuthenticated) {
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ import axios from "axios";
|
|||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
import { AuthContext } from "../context/AuthContext";
|
import { AuthContext } from "../context/AuthContext";
|
||||||
import { SocketContext } from "../context/SocketContext";
|
import { SSEContext } from "../context/SSEContext";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SocialFeed component displays community posts and allows creating new posts
|
* SocialFeed component displays community posts and allows creating new posts
|
||||||
* Includes real-time updates via Socket.IO
|
* Includes real-time updates via SSE
|
||||||
*/
|
*/
|
||||||
const SocialFeed = () => {
|
const SocialFeed = () => {
|
||||||
const { auth } = useContext(AuthContext);
|
const { auth } = useContext(AuthContext);
|
||||||
const { socket, connected, on, off } = useContext(SocketContext);
|
const { connected, on, off, subscribe, unsubscribe } = useContext(SSEContext);
|
||||||
const [posts, setPosts] = useState([]);
|
const [posts, setPosts] = useState([]);
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -43,9 +43,20 @@ const SocialFeed = () => {
|
|||||||
loadPosts();
|
loadPosts();
|
||||||
}, [loadPosts]);
|
}, [loadPosts]);
|
||||||
|
|
||||||
// Handle real-time post updates via Socket.IO
|
// Subscribe to posts topic on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!socket || !connected) return;
|
if (!connected) return;
|
||||||
|
|
||||||
|
subscribe(["posts"]);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe(["posts"]);
|
||||||
|
};
|
||||||
|
}, [connected, subscribe, unsubscribe]);
|
||||||
|
|
||||||
|
// Handle real-time post updates via SSE
|
||||||
|
useEffect(() => {
|
||||||
|
if (!connected) return;
|
||||||
|
|
||||||
const handleNewPost = (data) => {
|
const handleNewPost = (data) => {
|
||||||
console.log("Received new post:", data);
|
console.log("Received new post:", data);
|
||||||
@@ -101,7 +112,7 @@ const SocialFeed = () => {
|
|||||||
off("postUpdate", handlePostUpdate);
|
off("postUpdate", handlePostUpdate);
|
||||||
off("newComment", handleNewComment);
|
off("newComment", handleNewComment);
|
||||||
};
|
};
|
||||||
}, [socket, connected, on, off]);
|
}, [connected, on, off]);
|
||||||
|
|
||||||
// Like a post
|
// Like a post
|
||||||
const likePost = async (id) => {
|
const likePost = async (id) => {
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ import axios from "axios";
|
|||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
import { AuthContext } from "../context/AuthContext";
|
import { AuthContext } from "../context/AuthContext";
|
||||||
import { SocketContext } from "../context/SocketContext";
|
import { SSEContext } from "../context/SSEContext";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TaskList component displays maintenance tasks and allows task completion
|
* TaskList component displays maintenance tasks and allows task completion
|
||||||
* Includes real-time updates via Socket.IO
|
* Includes real-time updates via SSE
|
||||||
*/
|
*/
|
||||||
const TaskList = () => {
|
const TaskList = () => {
|
||||||
const { auth } = useContext(AuthContext);
|
const { auth } = useContext(AuthContext);
|
||||||
const { socket, connected, on, off } = useContext(SocketContext);
|
const { connected, on, off, subscribe, unsubscribe } = useContext(SSEContext);
|
||||||
const [tasks, setTasks] = useState([]);
|
const [tasks, setTasks] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
@@ -41,9 +41,20 @@ const TaskList = () => {
|
|||||||
loadTasks();
|
loadTasks();
|
||||||
}, [loadTasks]);
|
}, [loadTasks]);
|
||||||
|
|
||||||
// Handle real-time task updates via Socket.IO
|
// Subscribe to tasks topic on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!socket || !connected) return;
|
if (!connected) return;
|
||||||
|
|
||||||
|
subscribe(["tasks"]);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe(["tasks"]);
|
||||||
|
};
|
||||||
|
}, [connected, subscribe, unsubscribe]);
|
||||||
|
|
||||||
|
// Handle real-time task updates via SSE
|
||||||
|
useEffect(() => {
|
||||||
|
if (!connected) return;
|
||||||
|
|
||||||
const handleTaskUpdate = (data) => {
|
const handleTaskUpdate = (data) => {
|
||||||
console.log("Received task update:", data);
|
console.log("Received task update:", data);
|
||||||
@@ -82,7 +93,7 @@ const TaskList = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
off("taskUpdate", handleTaskUpdate);
|
off("taskUpdate", handleTaskUpdate);
|
||||||
};
|
};
|
||||||
}, [socket, connected, on, off]);
|
}, [connected, on, off]);
|
||||||
|
|
||||||
// Complete a task
|
// Complete a task
|
||||||
const completeTask = async (id) => {
|
const completeTask = async (id) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { AuthContext } from '../../context/AuthContext';
|
import { AuthContext } from '../../context/AuthContext';
|
||||||
import { SocketContext } from '../../context/SocketContext';
|
import { SSEContext } from '../../context/SSEContext';
|
||||||
import Events from '../Events';
|
import Events from '../Events';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
@@ -13,13 +13,15 @@ const mockAuthContext = {
|
|||||||
logout: jest.fn(),
|
logout: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockSocketContext = {
|
const mockSSEContext = {
|
||||||
socket: null,
|
|
||||||
connected: true,
|
connected: true,
|
||||||
|
notifications: [],
|
||||||
on: jest.fn(),
|
on: jest.fn(),
|
||||||
off: jest.fn(),
|
off: jest.fn(),
|
||||||
joinEvent: jest.fn(),
|
subscribe: jest.fn().mockResolvedValue({ subscribed: [] }),
|
||||||
leaveEvent: jest.fn(),
|
unsubscribe: jest.fn().mockResolvedValue({ unsubscribed: [] }),
|
||||||
|
clearNotification: jest.fn(),
|
||||||
|
clearAllNotifications: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
jest.mock('axios');
|
jest.mock('axios');
|
||||||
@@ -83,9 +85,9 @@ describe('Events Component', () => {
|
|||||||
return render(
|
return render(
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthContext.Provider value={mockAuthContext}>
|
<AuthContext.Provider value={mockAuthContext}>
|
||||||
<SocketContext.Provider value={mockSocketContext}>
|
<SSEContext.Provider value={mockSSEContext}>
|
||||||
<Events />
|
<Events />
|
||||||
</SocketContext.Provider>
|
</SSEContext.Provider>
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
@@ -296,19 +298,19 @@ describe('Events Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles real-time updates', async () => {
|
it('handles real-time updates', async () => {
|
||||||
const { on } = mockSocketContext;
|
const { on } = mockSSEContext;
|
||||||
|
|
||||||
renderEvents();
|
renderEvents();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// Simulate receiving a new event via socket
|
// Simulate receiving a new event via SSE
|
||||||
const socketCallback = on.mock.calls[0][1];
|
const sseCallback = on.mock.calls[0][1];
|
||||||
const newEventData = {
|
const newEventData = {
|
||||||
type: 'new_event',
|
type: 'new_event',
|
||||||
data: { ...mockEvents[0], _id: 'event5' }
|
event: { ...mockEvents[0], _id: 'event5' }
|
||||||
};
|
};
|
||||||
|
|
||||||
socketCallback(newEventData);
|
sseCallback(newEventData);
|
||||||
|
|
||||||
// Verify new event appears in the list
|
// Verify new event appears in the list
|
||||||
expect(screen.getByText('Community Cleanup Day')).toBeInTheDocument();
|
expect(screen.getByText('Community Cleanup Day')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { AuthContext } from '../../context/AuthContext';
|
import { AuthContext } from '../../context/AuthContext';
|
||||||
import { SocketContext } from '../../context/SocketContext';
|
import { SSEContext } from '../../context/SSEContext';
|
||||||
import SocialFeed from '../SocialFeed';
|
import SocialFeed from '../SocialFeed';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
@@ -13,11 +13,15 @@ const mockAuthContext = {
|
|||||||
logout: jest.fn(),
|
logout: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockSocketContext = {
|
const mockSSEContext = {
|
||||||
socket: null,
|
|
||||||
connected: true,
|
connected: true,
|
||||||
|
notifications: [],
|
||||||
on: jest.fn(),
|
on: jest.fn(),
|
||||||
off: jest.fn(),
|
off: jest.fn(),
|
||||||
|
subscribe: jest.fn().mockResolvedValue({ subscribed: [] }),
|
||||||
|
unsubscribe: jest.fn().mockResolvedValue({ unsubscribed: [] }),
|
||||||
|
clearNotification: jest.fn(),
|
||||||
|
clearAllNotifications: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock axios
|
// Mock axios
|
||||||
@@ -75,9 +79,9 @@ describe('SocialFeed Component', () => {
|
|||||||
return render(
|
return render(
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthContext.Provider value={mockAuthContext}>
|
<AuthContext.Provider value={mockAuthContext}>
|
||||||
<SocketContext.Provider value={mockSocketContext}>
|
<SSEContext.Provider value={mockSSEContext}>
|
||||||
<SocialFeed />
|
<SocialFeed />
|
||||||
</SocketContext.Provider>
|
</SSEContext.Provider>
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
@@ -265,19 +269,19 @@ describe('SocialFeed Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles real-time updates', async () => {
|
it('handles real-time updates', async () => {
|
||||||
const { on } = mockSocketContext;
|
const { on } = mockSSEContext;
|
||||||
|
|
||||||
renderSocialFeed();
|
renderSocialFeed();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// Simulate receiving a new post via socket
|
// Simulate receiving a new post via SSE
|
||||||
const socketCallback = on.mock.calls[0][1];
|
const sseCallback = on.mock.calls[0][1];
|
||||||
const newPostData = {
|
const newPostData = {
|
||||||
type: 'new_post',
|
type: 'new_post',
|
||||||
data: { ...mockPosts[0], _id: 'post3', content: 'New real-time post!' }
|
data: { ...mockPosts[0], _id: 'post3', content: 'New real-time post!' }
|
||||||
};
|
};
|
||||||
|
|
||||||
socketCallback(newPostData);
|
sseCallback(newPostData);
|
||||||
|
|
||||||
// Verify the new post appears in the feed
|
// Verify the new post appears in the feed
|
||||||
expect(screen.getByText('New real-time post!')).toBeInTheDocument();
|
expect(screen.getByText('New real-time post!')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { AuthContext } from '../../context/AuthContext';
|
import { AuthContext } from '../../context/AuthContext';
|
||||||
import { SocketContext } from '../../context/SocketContext';
|
import { SSEContext } from '../../context/SSEContext';
|
||||||
import TaskList from '../TaskList';
|
import TaskList from '../TaskList';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
@@ -13,11 +13,15 @@ const mockAuthContext = {
|
|||||||
logout: jest.fn(),
|
logout: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockSocketContext = {
|
const mockSSEContext = {
|
||||||
socket: null,
|
|
||||||
connected: true,
|
connected: true,
|
||||||
|
notifications: [],
|
||||||
on: jest.fn(),
|
on: jest.fn(),
|
||||||
off: jest.fn(),
|
off: jest.fn(),
|
||||||
|
subscribe: jest.fn().mockResolvedValue({ subscribed: [] }),
|
||||||
|
unsubscribe: jest.fn().mockResolvedValue({ unsubscribed: [] }),
|
||||||
|
clearNotification: jest.fn(),
|
||||||
|
clearAllNotifications: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock axios
|
// Mock axios
|
||||||
@@ -61,9 +65,9 @@ describe('TaskList Component', () => {
|
|||||||
return render(
|
return render(
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthContext.Provider value={mockAuthContext}>
|
<AuthContext.Provider value={mockAuthContext}>
|
||||||
<SocketContext.Provider value={mockSocketContext}>
|
<SSEContext.Provider value={mockSSEContext}>
|
||||||
<TaskList />
|
<TaskList />
|
||||||
</SocketContext.Provider>
|
</SSEContext.Provider>
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
@@ -232,19 +236,19 @@ describe('TaskList Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles real-time updates', async () => {
|
it('handles real-time updates', async () => {
|
||||||
const { on } = mockSocketContext;
|
const { on } = mockSSEContext;
|
||||||
|
|
||||||
renderTaskList();
|
renderTaskList();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// Simulate receiving a task update via socket
|
// Simulate receiving a task update via SSE
|
||||||
const socketCallback = on.mock.calls[0][1];
|
const sseCallback = on.mock.calls[0][1];
|
||||||
const taskUpdateData = {
|
const taskUpdateData = {
|
||||||
type: 'task_update',
|
type: 'task_update',
|
||||||
data: { ...mockTasks[0], status: 'completed' }
|
data: { ...mockTasks[0], status: 'completed' }
|
||||||
};
|
};
|
||||||
|
|
||||||
socketCallback(taskUpdateData);
|
sseCallback(taskUpdateData);
|
||||||
|
|
||||||
// Verify the task list updates with new data
|
// Verify the task list updates with new data
|
||||||
expect(screen.getByText('Clean up the street')).toBeInTheDocument();
|
expect(screen.getByText('Clean up the street')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,50 +1,31 @@
|
|||||||
import React, { useEffect, useContext } from "react";
|
import React, { useEffect, useContext } from "react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { SocketContext } from "./SocketContext";
|
import { SSEContext } from "./SSEContext";
|
||||||
import { AuthContext } from "./AuthContext";
|
import { AuthContext } from "./AuthContext";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NotificationProvider integrates Socket.IO events with toast notifications
|
* NotificationProvider integrates SSE events with toast notifications
|
||||||
* Automatically displays toast notifications for various real-time events
|
* Automatically displays toast notifications for various real-time events
|
||||||
*/
|
*/
|
||||||
const NotificationProvider = ({ children }) => {
|
const NotificationProvider = ({ children }) => {
|
||||||
const { socket, connected, on, off } = useContext(SocketContext);
|
const { connected, on, off } = useContext(SSEContext);
|
||||||
const { auth } = useContext(AuthContext);
|
const { auth } = useContext(AuthContext);
|
||||||
|
|
||||||
|
// Watch connection state for connection status toasts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!socket || !connected) return;
|
if (connected) {
|
||||||
|
|
||||||
// Connection status notifications
|
|
||||||
const handleConnect = () => {
|
|
||||||
toast.success("Connected to real-time updates", {
|
toast.success("Connected to real-time updates", {
|
||||||
toastId: "socket-connected", // Prevent duplicate toasts
|
toastId: "sse-connected", // Prevent duplicate toasts
|
||||||
});
|
});
|
||||||
};
|
} else {
|
||||||
|
|
||||||
const handleDisconnect = (reason) => {
|
|
||||||
if (reason === "io server disconnect") {
|
|
||||||
toast.error("Server disconnected. Attempting to reconnect...", {
|
|
||||||
toastId: "socket-disconnected",
|
|
||||||
});
|
|
||||||
} else if (reason === "transport close" || reason === "transport error") {
|
|
||||||
toast.warning("Connection lost. Reconnecting...", {
|
toast.warning("Connection lost. Reconnecting...", {
|
||||||
toastId: "socket-reconnecting",
|
toastId: "sse-reconnecting",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
}, [connected]);
|
||||||
|
|
||||||
const handleReconnect = () => {
|
useEffect(() => {
|
||||||
toast.success("Reconnected to server", {
|
if (!connected) return;
|
||||||
toastId: "socket-reconnected",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReconnectError = () => {
|
|
||||||
toast.error("Failed to reconnect. Please refresh the page.", {
|
|
||||||
toastId: "socket-reconnect-error",
|
|
||||||
autoClose: false, // Keep visible until user dismisses
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Event-related notifications
|
// Event-related notifications
|
||||||
const handleEventUpdate = (data) => {
|
const handleEventUpdate = (data) => {
|
||||||
@@ -169,12 +150,7 @@ const NotificationProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Subscribe to socket events
|
// Subscribe to SSE events
|
||||||
socket.on("connect", handleConnect);
|
|
||||||
socket.on("disconnect", handleDisconnect);
|
|
||||||
socket.on("reconnect", handleReconnect);
|
|
||||||
socket.on("reconnect_error", handleReconnectError);
|
|
||||||
|
|
||||||
on("eventUpdate", handleEventUpdate);
|
on("eventUpdate", handleEventUpdate);
|
||||||
on("taskUpdate", handleTaskUpdate);
|
on("taskUpdate", handleTaskUpdate);
|
||||||
on("streetUpdate", handleStreetUpdate);
|
on("streetUpdate", handleStreetUpdate);
|
||||||
@@ -186,11 +162,6 @@ const NotificationProvider = ({ children }) => {
|
|||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
return () => {
|
return () => {
|
||||||
socket.off("connect", handleConnect);
|
|
||||||
socket.off("disconnect", handleDisconnect);
|
|
||||||
socket.off("reconnect", handleReconnect);
|
|
||||||
socket.off("reconnect_error", handleReconnectError);
|
|
||||||
|
|
||||||
off("eventUpdate", handleEventUpdate);
|
off("eventUpdate", handleEventUpdate);
|
||||||
off("taskUpdate", handleTaskUpdate);
|
off("taskUpdate", handleTaskUpdate);
|
||||||
off("streetUpdate", handleStreetUpdate);
|
off("streetUpdate", handleStreetUpdate);
|
||||||
@@ -200,7 +171,7 @@ const NotificationProvider = ({ children }) => {
|
|||||||
off("newComment", handleNewComment);
|
off("newComment", handleNewComment);
|
||||||
off("notification", handleNotification);
|
off("notification", handleNotification);
|
||||||
};
|
};
|
||||||
}, [socket, connected, on, off, auth.user]);
|
}, [connected, on, off, auth.user]);
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import React, { createContext, useContext, useEffect, useState, useCallback, useRef } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import { AuthContext } from "./AuthContext";
|
||||||
|
|
||||||
|
export const SSEContext = createContext();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSEProvider manages Server-Sent Events connections and real-time event handling
|
||||||
|
* Automatically reconnects on disconnection and provides event subscription methods
|
||||||
|
*/
|
||||||
|
const SSEProvider = ({ children }) => {
|
||||||
|
const { auth } = useContext(AuthContext);
|
||||||
|
const [eventSource, setEventSource] = useState(null);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [notifications, setNotifications] = useState([]);
|
||||||
|
const eventHandlersRef = useRef(new Map());
|
||||||
|
const reconnectTimeoutRef = useRef(null);
|
||||||
|
const reconnectAttemptsRef = useRef(0);
|
||||||
|
|
||||||
|
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||||
|
const RECONNECT_DELAY = 1000;
|
||||||
|
|
||||||
|
// Clean up reconnect timeout on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Connect to SSE stream
|
||||||
|
const connectSSE = useCallback(() => {
|
||||||
|
if (!auth.isAuthenticated || !auth.token) {
|
||||||
|
console.log("SSE: Not authenticated, skipping connection");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("SSE: Connecting to event stream");
|
||||||
|
const url = `/api/sse/stream?token=${encodeURIComponent(auth.token)}`;
|
||||||
|
const es = new EventSource(url);
|
||||||
|
|
||||||
|
es.onopen = () => {
|
||||||
|
console.log("SSE: Connection established");
|
||||||
|
setConnected(true);
|
||||||
|
reconnectAttemptsRef.current = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
es.onerror = (error) => {
|
||||||
|
console.error("SSE: Connection error:", error);
|
||||||
|
setConnected(false);
|
||||||
|
es.close();
|
||||||
|
setEventSource(null);
|
||||||
|
|
||||||
|
// Attempt reconnection with exponential backoff
|
||||||
|
if (reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) {
|
||||||
|
reconnectAttemptsRef.current += 1;
|
||||||
|
const delay = RECONNECT_DELAY * Math.pow(2, reconnectAttemptsRef.current - 1);
|
||||||
|
console.log(`SSE: Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current}/${MAX_RECONNECT_ATTEMPTS})`);
|
||||||
|
|
||||||
|
reconnectTimeoutRef.current = setTimeout(() => {
|
||||||
|
if (auth.isAuthenticated) {
|
||||||
|
connectSSE();
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
} else {
|
||||||
|
console.error("SSE: Max reconnection attempts reached");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle incoming messages
|
||||||
|
es.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
console.log("SSE: Received message:", data);
|
||||||
|
|
||||||
|
const { type, payload } = data;
|
||||||
|
|
||||||
|
// Add to notifications array
|
||||||
|
if (type === "notification" || !type) {
|
||||||
|
setNotifications((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: Date.now(),
|
||||||
|
timestamp: new Date(),
|
||||||
|
...payload,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call registered event handlers
|
||||||
|
if (type) {
|
||||||
|
const handlers = eventHandlersRef.current.get(type);
|
||||||
|
if (handlers && handlers.size > 0) {
|
||||||
|
handlers.forEach((callback) => {
|
||||||
|
try {
|
||||||
|
callback(payload || data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`SSE: Error in event handler for ${type}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SSE: Error parsing message:", error, event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setEventSource(es);
|
||||||
|
}, [auth.isAuthenticated, auth.token]);
|
||||||
|
|
||||||
|
// Connect when user is authenticated
|
||||||
|
useEffect(() => {
|
||||||
|
if (auth.isAuthenticated && auth.token) {
|
||||||
|
connectSSE();
|
||||||
|
} else {
|
||||||
|
// Disconnect when user logs out
|
||||||
|
if (eventSource) {
|
||||||
|
console.log("SSE: Disconnecting due to logout");
|
||||||
|
eventSource.close();
|
||||||
|
setEventSource(null);
|
||||||
|
setConnected(false);
|
||||||
|
}
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
}
|
||||||
|
reconnectAttemptsRef.current = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
return () => {
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
}
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [auth.isAuthenticated, auth.token, connectSSE]);
|
||||||
|
|
||||||
|
// Subscribe to a specific event type
|
||||||
|
const on = useCallback((eventType, callback) => {
|
||||||
|
if (!eventHandlersRef.current.has(eventType)) {
|
||||||
|
eventHandlersRef.current.set(eventType, new Set());
|
||||||
|
}
|
||||||
|
eventHandlersRef.current.get(eventType).add(callback);
|
||||||
|
console.log(`SSE: Registered handler for event type: ${eventType}`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Unsubscribe from a specific event type
|
||||||
|
const off = useCallback((eventType, callback) => {
|
||||||
|
const handlers = eventHandlersRef.current.get(eventType);
|
||||||
|
if (handlers) {
|
||||||
|
handlers.delete(callback);
|
||||||
|
if (handlers.size === 0) {
|
||||||
|
eventHandlersRef.current.delete(eventType);
|
||||||
|
}
|
||||||
|
console.log(`SSE: Unregistered handler for event type: ${eventType}`);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Subscribe to specific topics via backend
|
||||||
|
const subscribe = useCallback(
|
||||||
|
async (topics) => {
|
||||||
|
if (!auth.isAuthenticated) {
|
||||||
|
console.warn("SSE: Cannot subscribe - not authenticated");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.post("/api/sse/subscribe", { topics });
|
||||||
|
console.log("SSE: Subscribed to topics:", topics);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SSE: Error subscribing to topics:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[auth.isAuthenticated]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Unsubscribe from specific topics via backend
|
||||||
|
const unsubscribe = useCallback(
|
||||||
|
async (topics) => {
|
||||||
|
if (!auth.isAuthenticated) {
|
||||||
|
console.warn("SSE: Cannot unsubscribe - not authenticated");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.post("/api/sse/unsubscribe", { topics });
|
||||||
|
console.log("SSE: Unsubscribed from topics:", topics);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SSE: Error unsubscribing from topics:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[auth.isAuthenticated]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear a notification
|
||||||
|
const clearNotification = useCallback((notificationId) => {
|
||||||
|
setNotifications((prev) =>
|
||||||
|
prev.filter((notification) => notification.id !== notificationId)
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Clear all notifications
|
||||||
|
const clearAllNotifications = useCallback(() => {
|
||||||
|
setNotifications([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
eventSource,
|
||||||
|
connected,
|
||||||
|
notifications,
|
||||||
|
eventHandlers: eventHandlersRef.current,
|
||||||
|
on,
|
||||||
|
off,
|
||||||
|
subscribe,
|
||||||
|
unsubscribe,
|
||||||
|
clearNotification,
|
||||||
|
clearAllNotifications,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SSEContext.Provider value={value}>{children}</SSEContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SSEProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to use SSE context
|
||||||
|
* @returns {Object} SSE context value
|
||||||
|
*/
|
||||||
|
export const useSSE = () => {
|
||||||
|
const context = useContext(SSEContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useSSE must be used within an SSEProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
import React, { createContext, useContext, useEffect, useState, useCallback } from "react";
|
|
||||||
import { io } from "socket.io-client";
|
|
||||||
import { AuthContext } from "./AuthContext";
|
|
||||||
|
|
||||||
export const SocketContext = createContext();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SocketProvider manages WebSocket connections and real-time event handling
|
|
||||||
* Automatically reconnects on disconnection and provides event subscription methods
|
|
||||||
*/
|
|
||||||
const SocketProvider = ({ children }) => {
|
|
||||||
const { auth } = useContext(AuthContext);
|
|
||||||
const [socket, setSocket] = useState(null);
|
|
||||||
const [connected, setConnected] = useState(false);
|
|
||||||
const [notifications, setNotifications] = useState([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Initialize socket connection
|
|
||||||
const socketInstance = io("http://localhost:5000", {
|
|
||||||
autoConnect: false,
|
|
||||||
reconnection: true,
|
|
||||||
reconnectionAttempts: 5,
|
|
||||||
reconnectionDelay: 1000,
|
|
||||||
reconnectionDelayMax: 5000,
|
|
||||||
timeout: 20000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Connection event handlers
|
|
||||||
socketInstance.on("connect", () => {
|
|
||||||
console.log("Socket.IO connected:", socketInstance.id);
|
|
||||||
setConnected(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
socketInstance.on("disconnect", (reason) => {
|
|
||||||
console.log("Socket.IO disconnected:", reason);
|
|
||||||
setConnected(false);
|
|
||||||
|
|
||||||
// Automatically reconnect if disconnection was unexpected
|
|
||||||
if (reason === "io server disconnect") {
|
|
||||||
// Server initiated disconnect, reconnect manually
|
|
||||||
socketInstance.connect();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socketInstance.on("connect_error", (error) => {
|
|
||||||
console.error("Socket.IO connection error:", error);
|
|
||||||
setConnected(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
socketInstance.on("reconnect", (attemptNumber) => {
|
|
||||||
console.log("Socket.IO reconnected after", attemptNumber, "attempts");
|
|
||||||
setConnected(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
socketInstance.on("reconnect_attempt", (attemptNumber) => {
|
|
||||||
console.log("Socket.IO reconnection attempt", attemptNumber);
|
|
||||||
});
|
|
||||||
|
|
||||||
socketInstance.on("reconnect_error", (error) => {
|
|
||||||
console.error("Socket.IO reconnection error:", error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socketInstance.on("reconnect_failed", () => {
|
|
||||||
console.error("Socket.IO reconnection failed");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generic notification handler
|
|
||||||
socketInstance.on("notification", (data) => {
|
|
||||||
console.log("Received notification:", data);
|
|
||||||
setNotifications((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: Date.now(),
|
|
||||||
timestamp: new Date(),
|
|
||||||
...data,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
setSocket(socketInstance);
|
|
||||||
|
|
||||||
// Connect socket if user is authenticated
|
|
||||||
if (auth.isAuthenticated) {
|
|
||||||
socketInstance.connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
return () => {
|
|
||||||
socketInstance.disconnect();
|
|
||||||
socketInstance.removeAllListeners();
|
|
||||||
};
|
|
||||||
}, [auth.isAuthenticated]);
|
|
||||||
|
|
||||||
// Join a specific event room
|
|
||||||
const joinEvent = useCallback(
|
|
||||||
(eventId) => {
|
|
||||||
if (socket && connected) {
|
|
||||||
console.log("Joining event room:", eventId);
|
|
||||||
socket.emit("joinEvent", eventId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[socket, connected]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Leave a specific event room
|
|
||||||
const leaveEvent = useCallback(
|
|
||||||
(eventId) => {
|
|
||||||
if (socket && connected) {
|
|
||||||
console.log("Leaving event room:", eventId);
|
|
||||||
socket.emit("leaveEvent", eventId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[socket, connected]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Subscribe to a specific event
|
|
||||||
const on = useCallback(
|
|
||||||
(event, callback) => {
|
|
||||||
if (socket) {
|
|
||||||
socket.on(event, callback);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[socket]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Unsubscribe from a specific event
|
|
||||||
const off = useCallback(
|
|
||||||
(event, callback) => {
|
|
||||||
if (socket) {
|
|
||||||
socket.off(event, callback);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[socket]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Emit an event
|
|
||||||
const emit = useCallback(
|
|
||||||
(event, data) => {
|
|
||||||
if (socket && connected) {
|
|
||||||
socket.emit(event, data);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[socket, connected]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clear a notification
|
|
||||||
const clearNotification = useCallback((notificationId) => {
|
|
||||||
setNotifications((prev) =>
|
|
||||||
prev.filter((notification) => notification.id !== notificationId)
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Clear all notifications
|
|
||||||
const clearAllNotifications = useCallback(() => {
|
|
||||||
setNotifications([]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const value = {
|
|
||||||
socket,
|
|
||||||
connected,
|
|
||||||
notifications,
|
|
||||||
joinEvent,
|
|
||||||
leaveEvent,
|
|
||||||
on,
|
|
||||||
off,
|
|
||||||
emit,
|
|
||||||
clearNotification,
|
|
||||||
clearAllNotifications,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SocketContext.Provider value={value}>{children}</SocketContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SocketProvider;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook to use socket context
|
|
||||||
* @returns {Object} Socket context value
|
|
||||||
*/
|
|
||||||
export const useSocket = () => {
|
|
||||||
const context = useContext(SocketContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useSocket must be used within a SocketProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
../@playwright/test/cli.js
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
../playwright-core/cli.js
|
||||||
+48
@@ -12,6 +12,22 @@
|
|||||||
"sparse-bitfield": "^3.0.3"
|
"sparse-bitfield": "^3.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.57.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
|
||||||
|
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.57.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/webidl-conversions": {
|
"node_modules/@types/webidl-conversions": {
|
||||||
"version": "7.0.3",
|
"version": "7.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
|
||||||
@@ -182,6 +198,38 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.57.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
|
||||||
|
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.57.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.57.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
|
||||||
|
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
|
|||||||
+202
@@ -0,0 +1,202 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Portions Copyright (c) Microsoft Corporation.
|
||||||
|
Portions Copyright 2017 Google Inc.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
Playwright
|
||||||
|
Copyright (c) Microsoft Corporation
|
||||||
|
|
||||||
|
This software contains code derived from the Puppeteer project (https://github.com/puppeteer/puppeteer),
|
||||||
|
available under the Apache 2.0 license (https://github.com/puppeteer/puppeteer/blob/master/LICENSE).
|
||||||
+168
@@ -0,0 +1,168 @@
|
|||||||
|
# 🎭 Playwright
|
||||||
|
|
||||||
|
[](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[](https://webkit.org/)<!-- GEN:stop --> [](https://aka.ms/playwright/discord)
|
||||||
|
|
||||||
|
## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright)
|
||||||
|
|
||||||
|
Playwright is a framework for Web Testing and Automation. It allows testing [Chromium](https://www.chromium.org/Home), [Firefox](https://www.mozilla.org/en-US/firefox/new/) and [WebKit](https://webkit.org/) with a single API. Playwright is built to enable cross-browser web automation that is **ever-green**, **capable**, **reliable** and **fast**.
|
||||||
|
|
||||||
|
| | Linux | macOS | Windows |
|
||||||
|
| :--- | :---: | :---: | :---: |
|
||||||
|
| Chromium <!-- GEN:chromium-version -->143.0.7499.4<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||||
|
| WebKit <!-- GEN:webkit-version -->26.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||||
|
| Firefox <!-- GEN:firefox-version -->144.0.2<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||||
|
|
||||||
|
Headless execution is supported for all browsers on all platforms. Check out [system requirements](https://playwright.dev/docs/intro#system-requirements) for details.
|
||||||
|
|
||||||
|
Looking for Playwright for [Python](https://playwright.dev/python/docs/intro), [.NET](https://playwright.dev/dotnet/docs/intro), or [Java](https://playwright.dev/java/docs/intro)?
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Playwright has its own test runner for end-to-end tests, we call it Playwright Test.
|
||||||
|
|
||||||
|
### Using init command
|
||||||
|
|
||||||
|
The easiest way to get started with Playwright Test is to run the init command.
|
||||||
|
|
||||||
|
```Shell
|
||||||
|
# Run from your project's root directory
|
||||||
|
npm init playwright@latest
|
||||||
|
# Or create a new project
|
||||||
|
npm init playwright@latest new-project
|
||||||
|
```
|
||||||
|
|
||||||
|
This will create a configuration file, optionally add examples, a GitHub Action workflow and a first test example.spec.ts. You can now jump directly to writing assertions section.
|
||||||
|
|
||||||
|
### Manually
|
||||||
|
|
||||||
|
Add dependency and install browsers.
|
||||||
|
|
||||||
|
```Shell
|
||||||
|
npm i -D @playwright/test
|
||||||
|
# install supported browsers
|
||||||
|
npx playwright install
|
||||||
|
```
|
||||||
|
|
||||||
|
You can optionally install only selected browsers, see [install browsers](https://playwright.dev/docs/cli#install-browsers) for more details. Or you can install no browsers at all and use existing [browser channels](https://playwright.dev/docs/browsers).
|
||||||
|
|
||||||
|
* [Getting started](https://playwright.dev/docs/intro)
|
||||||
|
* [API reference](https://playwright.dev/docs/api/class-playwright)
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### Resilient • No flaky tests
|
||||||
|
|
||||||
|
**Auto-wait**. Playwright waits for elements to be actionable prior to performing actions. It also has a rich set of introspection events. The combination of the two eliminates the need for artificial timeouts - a primary cause of flaky tests.
|
||||||
|
|
||||||
|
**Web-first assertions**. Playwright assertions are created specifically for the dynamic web. Checks are automatically retried until the necessary conditions are met.
|
||||||
|
|
||||||
|
**Tracing**. Configure test retry strategy, capture execution trace, videos and screenshots to eliminate flakes.
|
||||||
|
|
||||||
|
### No trade-offs • No limits
|
||||||
|
|
||||||
|
Browsers run web content belonging to different origins in different processes. Playwright is aligned with the architecture of the modern browsers and runs tests out-of-process. This makes Playwright free of the typical in-process test runner limitations.
|
||||||
|
|
||||||
|
**Multiple everything**. Test scenarios that span multiple tabs, multiple origins and multiple users. Create scenarios with different contexts for different users and run them against your server, all in one test.
|
||||||
|
|
||||||
|
**Trusted events**. Hover elements, interact with dynamic controls and produce trusted events. Playwright uses real browser input pipeline indistinguishable from the real user.
|
||||||
|
|
||||||
|
Test frames, pierce Shadow DOM. Playwright selectors pierce shadow DOM and allow entering frames seamlessly.
|
||||||
|
|
||||||
|
### Full isolation • Fast execution
|
||||||
|
|
||||||
|
**Browser contexts**. Playwright creates a browser context for each test. Browser context is equivalent to a brand new browser profile. This delivers full test isolation with zero overhead. Creating a new browser context only takes a handful of milliseconds.
|
||||||
|
|
||||||
|
**Log in once**. Save the authentication state of the context and reuse it in all the tests. This bypasses repetitive log-in operations in each test, yet delivers full isolation of independent tests.
|
||||||
|
|
||||||
|
### Powerful Tooling
|
||||||
|
|
||||||
|
**[Codegen](https://playwright.dev/docs/codegen)**. Generate tests by recording your actions. Save them into any language.
|
||||||
|
|
||||||
|
**[Playwright inspector](https://playwright.dev/docs/inspector)**. Inspect page, generate selectors, step through the test execution, see click points and explore execution logs.
|
||||||
|
|
||||||
|
**[Trace Viewer](https://playwright.dev/docs/trace-viewer)**. Capture all the information to investigate the test failure. Playwright trace contains test execution screencast, live DOM snapshots, action explorer, test source and many more.
|
||||||
|
|
||||||
|
Looking for Playwright for [TypeScript](https://playwright.dev/docs/intro), [JavaScript](https://playwright.dev/docs/intro), [Python](https://playwright.dev/python/docs/intro), [.NET](https://playwright.dev/dotnet/docs/intro), or [Java](https://playwright.dev/java/docs/intro)?
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
To learn how to run these Playwright Test examples, check out our [getting started docs](https://playwright.dev/docs/intro).
|
||||||
|
|
||||||
|
#### Page screenshot
|
||||||
|
|
||||||
|
This code snippet navigates to Playwright homepage and saves a screenshot.
|
||||||
|
|
||||||
|
```TypeScript
|
||||||
|
import { test } from '@playwright/test';
|
||||||
|
|
||||||
|
test('Page Screenshot', async ({ page }) => {
|
||||||
|
await page.goto('https://playwright.dev/');
|
||||||
|
await page.screenshot({ path: `example.png` });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Mobile and geolocation
|
||||||
|
|
||||||
|
This snippet emulates Mobile Safari on a device at given geolocation, navigates to maps.google.com, performs the action and takes a screenshot.
|
||||||
|
|
||||||
|
```TypeScript
|
||||||
|
import { test, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
test.use({
|
||||||
|
...devices['iPhone 13 Pro'],
|
||||||
|
locale: 'en-US',
|
||||||
|
geolocation: { longitude: 12.492507, latitude: 41.889938 },
|
||||||
|
permissions: ['geolocation'],
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Mobile and geolocation', async ({ page }) => {
|
||||||
|
await page.goto('https://maps.google.com');
|
||||||
|
await page.getByText('Your location').click();
|
||||||
|
await page.waitForRequest(/.*preview\/pwa/);
|
||||||
|
await page.screenshot({ path: 'colosseum-iphone.png' });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Evaluate in browser context
|
||||||
|
|
||||||
|
This code snippet navigates to example.com, and executes a script in the page context.
|
||||||
|
|
||||||
|
```TypeScript
|
||||||
|
import { test } from '@playwright/test';
|
||||||
|
|
||||||
|
test('Evaluate in browser context', async ({ page }) => {
|
||||||
|
await page.goto('https://www.example.com/');
|
||||||
|
const dimensions = await page.evaluate(() => {
|
||||||
|
return {
|
||||||
|
width: document.documentElement.clientWidth,
|
||||||
|
height: document.documentElement.clientHeight,
|
||||||
|
deviceScaleFactor: window.devicePixelRatio
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(dimensions);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Intercept network requests
|
||||||
|
|
||||||
|
This code snippet sets up request routing for a page to log all network requests.
|
||||||
|
|
||||||
|
```TypeScript
|
||||||
|
import { test } from '@playwright/test';
|
||||||
|
|
||||||
|
test('Intercept network requests', async ({ page }) => {
|
||||||
|
// Log and continue all network requests
|
||||||
|
await page.route('**', route => {
|
||||||
|
console.log(route.request().url());
|
||||||
|
route.continue();
|
||||||
|
});
|
||||||
|
await page.goto('http://todomvc.com');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
* [Documentation](https://playwright.dev)
|
||||||
|
* [API reference](https://playwright.dev/docs/api/class-playwright/)
|
||||||
|
* [Contribution guide](CONTRIBUTING.md)
|
||||||
|
* [Changelog](https://github.com/microsoft/playwright/releases)
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { program } = require('playwright/lib/program');
|
||||||
|
program.parse(process.argv);
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from 'playwright/test';
|
||||||
|
export { default } from 'playwright/test';
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = require('playwright/test');
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from 'playwright/test';
|
||||||
|
export { default } from 'playwright/test';
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "@playwright/test",
|
||||||
|
"version": "1.57.0",
|
||||||
|
"description": "A high-level API to automate web browsers",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/microsoft/playwright.git"
|
||||||
|
},
|
||||||
|
"homepage": "https://playwright.dev",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"author": {
|
||||||
|
"name": "Microsoft Corporation"
|
||||||
|
},
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./index.d.ts",
|
||||||
|
"import": "./index.mjs",
|
||||||
|
"require": "./index.js",
|
||||||
|
"default": "./index.js"
|
||||||
|
},
|
||||||
|
"./cli": "./cli.js",
|
||||||
|
"./package.json": "./package.json",
|
||||||
|
"./reporter": "./reporter.js"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"scripts": {},
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.57.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from 'playwright/types/testReporter';
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// We only export types in reporter.d.ts.
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// We only export types in reporter.d.ts.
|
||||||
+202
@@ -0,0 +1,202 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Portions Copyright (c) Microsoft Corporation.
|
||||||
|
Portions Copyright 2017 Google Inc.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
Playwright
|
||||||
|
Copyright (c) Microsoft Corporation
|
||||||
|
|
||||||
|
This software contains code derived from the Puppeteer project (https://github.com/puppeteer/puppeteer),
|
||||||
|
available under the Apache 2.0 license (https://github.com/puppeteer/puppeteer/blob/master/LICENSE).
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
# playwright-core
|
||||||
|
|
||||||
|
This package contains the no-browser flavor of [Playwright](http://github.com/microsoft/playwright).
|
||||||
+1161
File diff suppressed because it is too large
Load Diff
+5
@@ -0,0 +1,5 @@
|
|||||||
|
$osInfo = Get-WmiObject -Class Win32_OperatingSystem
|
||||||
|
# check if running on Windows Server
|
||||||
|
if ($osInfo.ProductType -eq 3) {
|
||||||
|
Install-WindowsFeature Server-Media-Foundation
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
# This script sets up a WSL distribution that will be used to run WebKit.
|
||||||
|
|
||||||
|
$Distribution = "playwright"
|
||||||
|
$Username = "pwuser"
|
||||||
|
|
||||||
|
$distributions = (wsl --list --quiet) -split "\r?\n"
|
||||||
|
if ($distributions -contains $Distribution) {
|
||||||
|
Write-Host "WSL distribution '$Distribution' already exists. Skipping installation."
|
||||||
|
} else {
|
||||||
|
Write-Host "Installing new WSL distribution '$Distribution'..."
|
||||||
|
$VhdSize = "10GB"
|
||||||
|
wsl --install -d Ubuntu-24.04 --name $Distribution --no-launch --vhd-size $VhdSize
|
||||||
|
wsl -d $Distribution -u root adduser --gecos GECOS --disabled-password $Username
|
||||||
|
}
|
||||||
|
|
||||||
|
$pwshDirname = (Resolve-Path -Path $PSScriptRoot).Path;
|
||||||
|
$playwrightCoreRoot = Resolve-Path (Join-Path $pwshDirname "..")
|
||||||
|
|
||||||
|
$initScript = @"
|
||||||
|
if [ ! -f "/home/$Username/node/bin/node" ]; then
|
||||||
|
mkdir -p /home/$Username/node
|
||||||
|
curl -fsSL https://nodejs.org/dist/v22.17.0/node-v22.17.0-linux-x64.tar.xz -o /home/$Username/node/node-v22.17.0-linux-x64.tar.xz
|
||||||
|
tar -xJf /home/$Username/node/node-v22.17.0-linux-x64.tar.xz -C /home/$Username/node --strip-components=1
|
||||||
|
sudo -u $Username echo 'export PATH=/home/$Username/node/bin:\`$PATH' >> /home/$Username/.profile
|
||||||
|
fi
|
||||||
|
/home/$Username/node/bin/node cli.js install-deps webkit
|
||||||
|
sudo -u $Username PLAYWRIGHT_SKIP_BROWSER_GC=1 /home/$Username/node/bin/node cli.js install webkit
|
||||||
|
"@ -replace "\r\n", "`n"
|
||||||
|
|
||||||
|
wsl -d $Distribution --cd $playwrightCoreRoot -u root -- bash -c "$initScript"
|
||||||
|
Write-Host "Done!"
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
set -x
|
||||||
|
|
||||||
|
if [[ $(arch) == "aarch64" ]]; then
|
||||||
|
echo "ERROR: not supported on Linux Arm64"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
|
||||||
|
if [[ ! -f "/etc/os-release" ]]; then
|
||||||
|
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ID=$(bash -c 'source /etc/os-release && echo $ID')
|
||||||
|
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
|
||||||
|
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. make sure to remove old beta if any.
|
||||||
|
if dpkg --get-selections | grep -q "^google-chrome-beta[[:space:]]*install$" >/dev/null; then
|
||||||
|
apt-get remove -y google-chrome-beta
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Update apt lists (needed to install curl and chrome dependencies)
|
||||||
|
apt-get update
|
||||||
|
|
||||||
|
# 3. Install curl to download chrome
|
||||||
|
if ! command -v curl >/dev/null; then
|
||||||
|
apt-get install -y curl
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. download chrome beta from dl.google.com and install it.
|
||||||
|
cd /tmp
|
||||||
|
curl -O https://dl.google.com/linux/direct/google-chrome-beta_current_amd64.deb
|
||||||
|
apt-get install -y ./google-chrome-beta_current_amd64.deb
|
||||||
|
rm -rf ./google-chrome-beta_current_amd64.deb
|
||||||
|
cd -
|
||||||
|
google-chrome-beta --version
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
set -x
|
||||||
|
|
||||||
|
rm -rf "/Applications/Google Chrome Beta.app"
|
||||||
|
cd /tmp
|
||||||
|
curl --retry 3 -o ./googlechromebeta.dmg https://dl.google.com/chrome/mac/universal/beta/googlechromebeta.dmg
|
||||||
|
hdiutil attach -nobrowse -quiet -noautofsck -noautoopen -mountpoint /Volumes/googlechromebeta.dmg ./googlechromebeta.dmg
|
||||||
|
cp -pR "/Volumes/googlechromebeta.dmg/Google Chrome Beta.app" /Applications
|
||||||
|
hdiutil detach /Volumes/googlechromebeta.dmg
|
||||||
|
rm -rf /tmp/googlechromebeta.dmg
|
||||||
|
|
||||||
|
/Applications/Google\ Chrome\ Beta.app/Contents/MacOS/Google\ Chrome\ Beta --version
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$url = 'https://dl.google.com/tag/s/dl/chrome/install/beta/googlechromebetastandaloneenterprise64.msi'
|
||||||
|
|
||||||
|
Write-Host "Downloading Google Chrome Beta"
|
||||||
|
$wc = New-Object net.webclient
|
||||||
|
$msiInstaller = "$env:temp\google-chrome-beta.msi"
|
||||||
|
$wc.Downloadfile($url, $msiInstaller)
|
||||||
|
|
||||||
|
Write-Host "Installing Google Chrome Beta"
|
||||||
|
$arguments = "/i `"$msiInstaller`" /quiet"
|
||||||
|
Start-Process msiexec.exe -ArgumentList $arguments -Wait
|
||||||
|
Remove-Item $msiInstaller
|
||||||
|
|
||||||
|
$suffix = "\\Google\\Chrome Beta\\Application\\chrome.exe"
|
||||||
|
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
|
||||||
|
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
|
||||||
|
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
|
||||||
|
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
|
||||||
|
} else {
|
||||||
|
Write-Host "ERROR: Failed to install Google Chrome Beta."
|
||||||
|
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
set -x
|
||||||
|
|
||||||
|
if [[ $(arch) == "aarch64" ]]; then
|
||||||
|
echo "ERROR: not supported on Linux Arm64"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
|
||||||
|
if [[ ! -f "/etc/os-release" ]]; then
|
||||||
|
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ID=$(bash -c 'source /etc/os-release && echo $ID')
|
||||||
|
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
|
||||||
|
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. make sure to remove old stable if any.
|
||||||
|
if dpkg --get-selections | grep -q "^google-chrome[[:space:]]*install$" >/dev/null; then
|
||||||
|
apt-get remove -y google-chrome
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Update apt lists (needed to install curl and chrome dependencies)
|
||||||
|
apt-get update
|
||||||
|
|
||||||
|
# 3. Install curl to download chrome
|
||||||
|
if ! command -v curl >/dev/null; then
|
||||||
|
apt-get install -y curl
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. download chrome stable from dl.google.com and install it.
|
||||||
|
cd /tmp
|
||||||
|
curl -O https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||||
|
apt-get install -y ./google-chrome-stable_current_amd64.deb
|
||||||
|
rm -rf ./google-chrome-stable_current_amd64.deb
|
||||||
|
cd -
|
||||||
|
google-chrome --version
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
set -x
|
||||||
|
|
||||||
|
rm -rf "/Applications/Google Chrome.app"
|
||||||
|
cd /tmp
|
||||||
|
curl --retry 3 -o ./googlechrome.dmg https://dl.google.com/chrome/mac/universal/stable/GGRO/googlechrome.dmg
|
||||||
|
hdiutil attach -nobrowse -quiet -noautofsck -noautoopen -mountpoint /Volumes/googlechrome.dmg ./googlechrome.dmg
|
||||||
|
cp -pR "/Volumes/googlechrome.dmg/Google Chrome.app" /Applications
|
||||||
|
hdiutil detach /Volumes/googlechrome.dmg
|
||||||
|
rm -rf /tmp/googlechrome.dmg
|
||||||
|
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$url = 'https://dl.google.com/tag/s/dl/chrome/install/googlechromestandaloneenterprise64.msi'
|
||||||
|
|
||||||
|
$wc = New-Object net.webclient
|
||||||
|
$msiInstaller = "$env:temp\google-chrome.msi"
|
||||||
|
Write-Host "Downloading Google Chrome"
|
||||||
|
$wc.Downloadfile($url, $msiInstaller)
|
||||||
|
|
||||||
|
Write-Host "Installing Google Chrome"
|
||||||
|
$arguments = "/i `"$msiInstaller`" /quiet"
|
||||||
|
Start-Process msiexec.exe -ArgumentList $arguments -Wait
|
||||||
|
Remove-Item $msiInstaller
|
||||||
|
|
||||||
|
|
||||||
|
$suffix = "\\Google\\Chrome\\Application\\chrome.exe"
|
||||||
|
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
|
||||||
|
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
|
||||||
|
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
|
||||||
|
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
|
||||||
|
} else {
|
||||||
|
Write-Host "ERROR: Failed to install Google Chrome."
|
||||||
|
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
set -x
|
||||||
|
|
||||||
|
if [[ $(arch) == "aarch64" ]]; then
|
||||||
|
echo "ERROR: not supported on Linux Arm64"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
|
||||||
|
if [[ ! -f "/etc/os-release" ]]; then
|
||||||
|
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ID=$(bash -c 'source /etc/os-release && echo $ID')
|
||||||
|
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
|
||||||
|
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. make sure to remove old beta if any.
|
||||||
|
if dpkg --get-selections | grep -q "^microsoft-edge-beta[[:space:]]*install$" >/dev/null; then
|
||||||
|
apt-get remove -y microsoft-edge-beta
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Install curl to download Microsoft gpg key
|
||||||
|
if ! command -v curl >/dev/null; then
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y curl
|
||||||
|
fi
|
||||||
|
|
||||||
|
# GnuPG is not preinstalled in slim images
|
||||||
|
if ! command -v gpg >/dev/null; then
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y gpg
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Add the GPG key, the apt repo, update the apt cache, and install the package
|
||||||
|
curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /tmp/microsoft.gpg
|
||||||
|
install -o root -g root -m 644 /tmp/microsoft.gpg /etc/apt/trusted.gpg.d/
|
||||||
|
sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > /etc/apt/sources.list.d/microsoft-edge-dev.list'
|
||||||
|
rm /tmp/microsoft.gpg
|
||||||
|
apt-get update && apt-get install -y microsoft-edge-beta
|
||||||
|
|
||||||
|
microsoft-edge-beta --version
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
set -x
|
||||||
|
|
||||||
|
cd /tmp
|
||||||
|
curl --retry 3 -o ./msedge_beta.pkg "$1"
|
||||||
|
# Note: there's no way to uninstall previously installed MSEdge.
|
||||||
|
# However, running PKG again seems to update installation.
|
||||||
|
sudo installer -pkg /tmp/msedge_beta.pkg -target /
|
||||||
|
rm -rf /tmp/msedge_beta.pkg
|
||||||
|
/Applications/Microsoft\ Edge\ Beta.app/Contents/MacOS/Microsoft\ Edge\ Beta --version
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$url = $args[0]
|
||||||
|
|
||||||
|
Write-Host "Downloading Microsoft Edge Beta"
|
||||||
|
$wc = New-Object net.webclient
|
||||||
|
$msiInstaller = "$env:temp\microsoft-edge-beta.msi"
|
||||||
|
$wc.Downloadfile($url, $msiInstaller)
|
||||||
|
|
||||||
|
Write-Host "Installing Microsoft Edge Beta"
|
||||||
|
$arguments = "/i `"$msiInstaller`" /quiet"
|
||||||
|
Start-Process msiexec.exe -ArgumentList $arguments -Wait
|
||||||
|
Remove-Item $msiInstaller
|
||||||
|
|
||||||
|
$suffix = "\\Microsoft\\Edge Beta\\Application\\msedge.exe"
|
||||||
|
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
|
||||||
|
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
|
||||||
|
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
|
||||||
|
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
|
||||||
|
} else {
|
||||||
|
Write-Host "ERROR: Failed to install Microsoft Edge Beta."
|
||||||
|
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
set -x
|
||||||
|
|
||||||
|
if [[ $(arch) == "aarch64" ]]; then
|
||||||
|
echo "ERROR: not supported on Linux Arm64"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
|
||||||
|
if [[ ! -f "/etc/os-release" ]]; then
|
||||||
|
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ID=$(bash -c 'source /etc/os-release && echo $ID')
|
||||||
|
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
|
||||||
|
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. make sure to remove old dev if any.
|
||||||
|
if dpkg --get-selections | grep -q "^microsoft-edge-dev[[:space:]]*install$" >/dev/null; then
|
||||||
|
apt-get remove -y microsoft-edge-dev
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Install curl to download Microsoft gpg key
|
||||||
|
if ! command -v curl >/dev/null; then
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y curl
|
||||||
|
fi
|
||||||
|
|
||||||
|
# GnuPG is not preinstalled in slim images
|
||||||
|
if ! command -v gpg >/dev/null; then
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y gpg
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Add the GPG key, the apt repo, update the apt cache, and install the package
|
||||||
|
curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /tmp/microsoft.gpg
|
||||||
|
install -o root -g root -m 644 /tmp/microsoft.gpg /etc/apt/trusted.gpg.d/
|
||||||
|
sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > /etc/apt/sources.list.d/microsoft-edge-dev.list'
|
||||||
|
rm /tmp/microsoft.gpg
|
||||||
|
apt-get update && apt-get install -y microsoft-edge-dev
|
||||||
|
|
||||||
|
microsoft-edge-dev --version
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
set -x
|
||||||
|
|
||||||
|
cd /tmp
|
||||||
|
curl --retry 3 -o ./msedge_dev.pkg "$1"
|
||||||
|
# Note: there's no way to uninstall previously installed MSEdge.
|
||||||
|
# However, running PKG again seems to update installation.
|
||||||
|
sudo installer -pkg /tmp/msedge_dev.pkg -target /
|
||||||
|
rm -rf /tmp/msedge_dev.pkg
|
||||||
|
/Applications/Microsoft\ Edge\ Dev.app/Contents/MacOS/Microsoft\ Edge\ Dev --version
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$url = $args[0]
|
||||||
|
|
||||||
|
Write-Host "Downloading Microsoft Edge Dev"
|
||||||
|
$wc = New-Object net.webclient
|
||||||
|
$msiInstaller = "$env:temp\microsoft-edge-dev.msi"
|
||||||
|
$wc.Downloadfile($url, $msiInstaller)
|
||||||
|
|
||||||
|
Write-Host "Installing Microsoft Edge Dev"
|
||||||
|
$arguments = "/i `"$msiInstaller`" /quiet"
|
||||||
|
Start-Process msiexec.exe -ArgumentList $arguments -Wait
|
||||||
|
Remove-Item $msiInstaller
|
||||||
|
|
||||||
|
$suffix = "\\Microsoft\\Edge Dev\\Application\\msedge.exe"
|
||||||
|
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
|
||||||
|
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
|
||||||
|
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
|
||||||
|
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
|
||||||
|
} else {
|
||||||
|
Write-Host "ERROR: Failed to install Microsoft Edge Dev."
|
||||||
|
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
set -x
|
||||||
|
|
||||||
|
if [[ $(arch) == "aarch64" ]]; then
|
||||||
|
echo "ERROR: not supported on Linux Arm64"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
|
||||||
|
if [[ ! -f "/etc/os-release" ]]; then
|
||||||
|
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ID=$(bash -c 'source /etc/os-release && echo $ID')
|
||||||
|
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
|
||||||
|
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. make sure to remove old stable if any.
|
||||||
|
if dpkg --get-selections | grep -q "^microsoft-edge-stable[[:space:]]*install$" >/dev/null; then
|
||||||
|
apt-get remove -y microsoft-edge-stable
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Install curl to download Microsoft gpg key
|
||||||
|
if ! command -v curl >/dev/null; then
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y curl
|
||||||
|
fi
|
||||||
|
|
||||||
|
# GnuPG is not preinstalled in slim images
|
||||||
|
if ! command -v gpg >/dev/null; then
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y gpg
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Add the GPG key, the apt repo, update the apt cache, and install the package
|
||||||
|
curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /tmp/microsoft.gpg
|
||||||
|
install -o root -g root -m 644 /tmp/microsoft.gpg /etc/apt/trusted.gpg.d/
|
||||||
|
sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > /etc/apt/sources.list.d/microsoft-edge-stable.list'
|
||||||
|
rm /tmp/microsoft.gpg
|
||||||
|
apt-get update && apt-get install -y microsoft-edge-stable
|
||||||
|
|
||||||
|
microsoft-edge-stable --version
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
set -x
|
||||||
|
|
||||||
|
cd /tmp
|
||||||
|
curl --retry 3 -o ./msedge_stable.pkg "$1"
|
||||||
|
# Note: there's no way to uninstall previously installed MSEdge.
|
||||||
|
# However, running PKG again seems to update installation.
|
||||||
|
sudo installer -pkg /tmp/msedge_stable.pkg -target /
|
||||||
|
rm -rf /tmp/msedge_stable.pkg
|
||||||
|
/Applications/Microsoft\ Edge.app/Contents/MacOS/Microsoft\ Edge --version
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$url = $args[0]
|
||||||
|
|
||||||
|
Write-Host "Downloading Microsoft Edge"
|
||||||
|
$wc = New-Object net.webclient
|
||||||
|
$msiInstaller = "$env:temp\microsoft-edge-stable.msi"
|
||||||
|
$wc.Downloadfile($url, $msiInstaller)
|
||||||
|
|
||||||
|
Write-Host "Installing Microsoft Edge"
|
||||||
|
$arguments = "/i `"$msiInstaller`" /quiet"
|
||||||
|
Start-Process msiexec.exe -ArgumentList $arguments -Wait
|
||||||
|
Remove-Item $msiInstaller
|
||||||
|
|
||||||
|
$suffix = "\\Microsoft\\Edge\\Application\\msedge.exe"
|
||||||
|
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
|
||||||
|
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
|
||||||
|
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
|
||||||
|
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
|
||||||
|
} else {
|
||||||
|
Write-Host "ERROR: Failed to install Microsoft Edge."
|
||||||
|
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"comment": "Do not edit this file, use utils/roll_browser.js",
|
||||||
|
"browsers": [
|
||||||
|
{
|
||||||
|
"name": "chromium",
|
||||||
|
"revision": "1200",
|
||||||
|
"installByDefault": true,
|
||||||
|
"browserVersion": "143.0.7499.4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "chromium-headless-shell",
|
||||||
|
"revision": "1200",
|
||||||
|
"installByDefault": true,
|
||||||
|
"browserVersion": "143.0.7499.4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "chromium-tip-of-tree",
|
||||||
|
"revision": "1380",
|
||||||
|
"installByDefault": false,
|
||||||
|
"browserVersion": "143.0.7488.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "chromium-tip-of-tree-headless-shell",
|
||||||
|
"revision": "1380",
|
||||||
|
"installByDefault": false,
|
||||||
|
"browserVersion": "143.0.7488.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "firefox",
|
||||||
|
"revision": "1497",
|
||||||
|
"installByDefault": true,
|
||||||
|
"browserVersion": "144.0.2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "firefox-beta",
|
||||||
|
"revision": "1493",
|
||||||
|
"installByDefault": false,
|
||||||
|
"browserVersion": "145.0b10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "webkit",
|
||||||
|
"revision": "2227",
|
||||||
|
"installByDefault": true,
|
||||||
|
"revisionOverrides": {
|
||||||
|
"debian11-x64": "2105",
|
||||||
|
"debian11-arm64": "2105",
|
||||||
|
"mac10.14": "1446",
|
||||||
|
"mac10.15": "1616",
|
||||||
|
"mac11": "1816",
|
||||||
|
"mac11-arm64": "1816",
|
||||||
|
"mac12": "2009",
|
||||||
|
"mac12-arm64": "2009",
|
||||||
|
"mac13": "2140",
|
||||||
|
"mac13-arm64": "2140",
|
||||||
|
"ubuntu20.04-x64": "2092",
|
||||||
|
"ubuntu20.04-arm64": "2092"
|
||||||
|
},
|
||||||
|
"browserVersion": "26.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ffmpeg",
|
||||||
|
"revision": "1011",
|
||||||
|
"installByDefault": true,
|
||||||
|
"revisionOverrides": {
|
||||||
|
"mac12": "1010",
|
||||||
|
"mac12-arm64": "1010"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "winldd",
|
||||||
|
"revision": "1007",
|
||||||
|
"installByDefault": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "android",
|
||||||
|
"revision": "1001",
|
||||||
|
"installByDefault": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
const { program } = require('./lib/cli/programWithTestStub');
|
||||||
|
program.parse(process.argv);
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './types/types';
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
const minimumMajorNodeVersion = 18;
|
||||||
|
const currentNodeVersion = process.versions.node;
|
||||||
|
const semver = currentNodeVersion.split('.');
|
||||||
|
const [major] = [+semver[0]];
|
||||||
|
|
||||||
|
if (major < minimumMajorNodeVersion) {
|
||||||
|
console.error(
|
||||||
|
'You are running Node.js ' +
|
||||||
|
currentNodeVersion +
|
||||||
|
'.\n' +
|
||||||
|
`Playwright requires Node.js ${minimumMajorNodeVersion} or higher. \n` +
|
||||||
|
'Please update your version of Node.js.'
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = require('./lib/inprocess');
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import playwright from './index.js';
|
||||||
|
|
||||||
|
export const chromium = playwright.chromium;
|
||||||
|
export const firefox = playwright.firefox;
|
||||||
|
export const webkit = playwright.webkit;
|
||||||
|
export const selectors = playwright.selectors;
|
||||||
|
export const devices = playwright.devices;
|
||||||
|
export const errors = playwright.errors;
|
||||||
|
export const request = playwright.request;
|
||||||
|
export const _electron = playwright._electron;
|
||||||
|
export const _android = playwright._android;
|
||||||
|
export default playwright;
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
"use strict";
|
||||||
|
var __defProp = Object.defineProperty;
|
||||||
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||||
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||||
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||||
|
var __export = (target, all) => {
|
||||||
|
for (var name in all)
|
||||||
|
__defProp(target, name, { get: all[name], enumerable: true });
|
||||||
|
};
|
||||||
|
var __copyProps = (to, from, except, desc) => {
|
||||||
|
if (from && typeof from === "object" || typeof from === "function") {
|
||||||
|
for (let key of __getOwnPropNames(from))
|
||||||
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||||
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||||
|
}
|
||||||
|
return to;
|
||||||
|
};
|
||||||
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||||
|
var androidServerImpl_exports = {};
|
||||||
|
__export(androidServerImpl_exports, {
|
||||||
|
AndroidServerLauncherImpl: () => AndroidServerLauncherImpl
|
||||||
|
});
|
||||||
|
module.exports = __toCommonJS(androidServerImpl_exports);
|
||||||
|
var import_playwrightServer = require("./remote/playwrightServer");
|
||||||
|
var import_playwright = require("./server/playwright");
|
||||||
|
var import_crypto = require("./server/utils/crypto");
|
||||||
|
var import_utilsBundle = require("./utilsBundle");
|
||||||
|
var import_progress = require("./server/progress");
|
||||||
|
class AndroidServerLauncherImpl {
|
||||||
|
async launchServer(options = {}) {
|
||||||
|
const playwright = (0, import_playwright.createPlaywright)({ sdkLanguage: "javascript", isServer: true });
|
||||||
|
const controller = new import_progress.ProgressController();
|
||||||
|
let devices = await controller.run((progress) => playwright.android.devices(progress, {
|
||||||
|
host: options.adbHost,
|
||||||
|
port: options.adbPort,
|
||||||
|
omitDriverInstall: options.omitDriverInstall
|
||||||
|
}));
|
||||||
|
if (devices.length === 0)
|
||||||
|
throw new Error("No devices found");
|
||||||
|
if (options.deviceSerialNumber) {
|
||||||
|
devices = devices.filter((d) => d.serial === options.deviceSerialNumber);
|
||||||
|
if (devices.length === 0)
|
||||||
|
throw new Error(`No device with serial number '${options.deviceSerialNumber}' was found`);
|
||||||
|
}
|
||||||
|
if (devices.length > 1)
|
||||||
|
throw new Error(`More than one device found. Please specify deviceSerialNumber`);
|
||||||
|
const device = devices[0];
|
||||||
|
const path = options.wsPath ? options.wsPath.startsWith("/") ? options.wsPath : `/${options.wsPath}` : `/${(0, import_crypto.createGuid)()}`;
|
||||||
|
const server = new import_playwrightServer.PlaywrightServer({ mode: "launchServer", path, maxConnections: 1, preLaunchedAndroidDevice: device });
|
||||||
|
const wsEndpoint = await server.listen(options.port, options.host);
|
||||||
|
const browserServer = new import_utilsBundle.ws.EventEmitter();
|
||||||
|
browserServer.wsEndpoint = () => wsEndpoint;
|
||||||
|
browserServer.close = () => device.close();
|
||||||
|
browserServer.kill = () => device.close();
|
||||||
|
device.on("close", () => {
|
||||||
|
server.close();
|
||||||
|
browserServer.emit("close");
|
||||||
|
});
|
||||||
|
return browserServer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Annotate the CommonJS export names for ESM import in node:
|
||||||
|
0 && (module.exports = {
|
||||||
|
AndroidServerLauncherImpl
|
||||||
|
});
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
"use strict";
|
||||||
|
var __create = Object.create;
|
||||||
|
var __defProp = Object.defineProperty;
|
||||||
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||||
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||||
|
var __getProtoOf = Object.getPrototypeOf;
|
||||||
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||||
|
var __export = (target, all) => {
|
||||||
|
for (var name in all)
|
||||||
|
__defProp(target, name, { get: all[name], enumerable: true });
|
||||||
|
};
|
||||||
|
var __copyProps = (to, from, except, desc) => {
|
||||||
|
if (from && typeof from === "object" || typeof from === "function") {
|
||||||
|
for (let key of __getOwnPropNames(from))
|
||||||
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||||
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||||
|
}
|
||||||
|
return to;
|
||||||
|
};
|
||||||
|
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||||
|
// If the importer is in node compatibility mode or this is not an ESM
|
||||||
|
// file that has been converted to a CommonJS file using a Babel-
|
||||||
|
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||||
|
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||||
|
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||||
|
mod
|
||||||
|
));
|
||||||
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||||
|
var browserServerImpl_exports = {};
|
||||||
|
__export(browserServerImpl_exports, {
|
||||||
|
BrowserServerLauncherImpl: () => BrowserServerLauncherImpl
|
||||||
|
});
|
||||||
|
module.exports = __toCommonJS(browserServerImpl_exports);
|
||||||
|
var import_playwrightServer = require("./remote/playwrightServer");
|
||||||
|
var import_helper = require("./server/helper");
|
||||||
|
var import_playwright = require("./server/playwright");
|
||||||
|
var import_crypto = require("./server/utils/crypto");
|
||||||
|
var import_debug = require("./server/utils/debug");
|
||||||
|
var import_stackTrace = require("./utils/isomorphic/stackTrace");
|
||||||
|
var import_time = require("./utils/isomorphic/time");
|
||||||
|
var import_utilsBundle = require("./utilsBundle");
|
||||||
|
var validatorPrimitives = __toESM(require("./protocol/validatorPrimitives"));
|
||||||
|
var import_progress = require("./server/progress");
|
||||||
|
class BrowserServerLauncherImpl {
|
||||||
|
constructor(browserName) {
|
||||||
|
this._browserName = browserName;
|
||||||
|
}
|
||||||
|
async launchServer(options = {}) {
|
||||||
|
const playwright = (0, import_playwright.createPlaywright)({ sdkLanguage: "javascript", isServer: true });
|
||||||
|
const metadata = { id: "", startTime: 0, endTime: 0, type: "Internal", method: "", params: {}, log: [], internal: true };
|
||||||
|
const validatorContext = {
|
||||||
|
tChannelImpl: (names, arg, path2) => {
|
||||||
|
throw new validatorPrimitives.ValidationError(`${path2}: channels are not expected in launchServer`);
|
||||||
|
},
|
||||||
|
binary: "buffer",
|
||||||
|
isUnderTest: import_debug.isUnderTest
|
||||||
|
};
|
||||||
|
let launchOptions = {
|
||||||
|
...options,
|
||||||
|
ignoreDefaultArgs: Array.isArray(options.ignoreDefaultArgs) ? options.ignoreDefaultArgs : void 0,
|
||||||
|
ignoreAllDefaultArgs: !!options.ignoreDefaultArgs && !Array.isArray(options.ignoreDefaultArgs),
|
||||||
|
env: options.env ? envObjectToArray(options.env) : void 0,
|
||||||
|
timeout: options.timeout ?? import_time.DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT
|
||||||
|
};
|
||||||
|
let browser;
|
||||||
|
try {
|
||||||
|
const controller = new import_progress.ProgressController(metadata);
|
||||||
|
browser = await controller.run(async (progress) => {
|
||||||
|
if (options._userDataDir !== void 0) {
|
||||||
|
const validator = validatorPrimitives.scheme["BrowserTypeLaunchPersistentContextParams"];
|
||||||
|
launchOptions = validator({ ...launchOptions, userDataDir: options._userDataDir }, "", validatorContext);
|
||||||
|
const context = await playwright[this._browserName].launchPersistentContext(progress, options._userDataDir, launchOptions);
|
||||||
|
return context._browser;
|
||||||
|
} else {
|
||||||
|
const validator = validatorPrimitives.scheme["BrowserTypeLaunchParams"];
|
||||||
|
launchOptions = validator(launchOptions, "", validatorContext);
|
||||||
|
return await playwright[this._browserName].launch(progress, launchOptions, toProtocolLogger(options.logger));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const log = import_helper.helper.formatBrowserLogs(metadata.log);
|
||||||
|
(0, import_stackTrace.rewriteErrorMessage)(e, `${e.message} Failed to launch browser.${log}`);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
const path = options.wsPath ? options.wsPath.startsWith("/") ? options.wsPath : `/${options.wsPath}` : `/${(0, import_crypto.createGuid)()}`;
|
||||||
|
const server = new import_playwrightServer.PlaywrightServer({ mode: options._sharedBrowser ? "launchServerShared" : "launchServer", path, maxConnections: Infinity, preLaunchedBrowser: browser });
|
||||||
|
const wsEndpoint = await server.listen(options.port, options.host);
|
||||||
|
const browserServer = new import_utilsBundle.ws.EventEmitter();
|
||||||
|
browserServer.process = () => browser.options.browserProcess.process;
|
||||||
|
browserServer.wsEndpoint = () => wsEndpoint;
|
||||||
|
browserServer.close = () => browser.options.browserProcess.close();
|
||||||
|
browserServer[Symbol.asyncDispose] = browserServer.close;
|
||||||
|
browserServer.kill = () => browser.options.browserProcess.kill();
|
||||||
|
browserServer._disconnectForTest = () => server.close();
|
||||||
|
browserServer._userDataDirForTest = browser._userDataDirForTest;
|
||||||
|
browser.options.browserProcess.onclose = (exitCode, signal) => {
|
||||||
|
server.close();
|
||||||
|
browserServer.emit("close", exitCode, signal);
|
||||||
|
};
|
||||||
|
return browserServer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function toProtocolLogger(logger) {
|
||||||
|
return logger ? (direction, message) => {
|
||||||
|
if (logger.isEnabled("protocol", "verbose"))
|
||||||
|
logger.log("protocol", "verbose", (direction === "send" ? "SEND \u25BA " : "\u25C0 RECV ") + JSON.stringify(message), [], {});
|
||||||
|
} : void 0;
|
||||||
|
}
|
||||||
|
function envObjectToArray(env) {
|
||||||
|
const result = [];
|
||||||
|
for (const name in env) {
|
||||||
|
if (!Object.is(env[name], void 0))
|
||||||
|
result.push({ name, value: String(env[name]) });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
// Annotate the CommonJS export names for ESM import in node:
|
||||||
|
0 && (module.exports = {
|
||||||
|
BrowserServerLauncherImpl
|
||||||
|
});
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
"use strict";
|
||||||
|
var __create = Object.create;
|
||||||
|
var __defProp = Object.defineProperty;
|
||||||
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||||
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||||
|
var __getProtoOf = Object.getPrototypeOf;
|
||||||
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||||
|
var __export = (target, all) => {
|
||||||
|
for (var name in all)
|
||||||
|
__defProp(target, name, { get: all[name], enumerable: true });
|
||||||
|
};
|
||||||
|
var __copyProps = (to, from, except, desc) => {
|
||||||
|
if (from && typeof from === "object" || typeof from === "function") {
|
||||||
|
for (let key of __getOwnPropNames(from))
|
||||||
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||||
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||||
|
}
|
||||||
|
return to;
|
||||||
|
};
|
||||||
|
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||||
|
// If the importer is in node compatibility mode or this is not an ESM
|
||||||
|
// file that has been converted to a CommonJS file using a Babel-
|
||||||
|
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||||
|
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||||
|
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||||
|
mod
|
||||||
|
));
|
||||||
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||||
|
var driver_exports = {};
|
||||||
|
__export(driver_exports, {
|
||||||
|
launchBrowserServer: () => launchBrowserServer,
|
||||||
|
printApiJson: () => printApiJson,
|
||||||
|
runDriver: () => runDriver,
|
||||||
|
runServer: () => runServer
|
||||||
|
});
|
||||||
|
module.exports = __toCommonJS(driver_exports);
|
||||||
|
var import_fs = __toESM(require("fs"));
|
||||||
|
var playwright = __toESM(require("../.."));
|
||||||
|
var import_pipeTransport = require("../server/utils/pipeTransport");
|
||||||
|
var import_playwrightServer = require("../remote/playwrightServer");
|
||||||
|
var import_server = require("../server");
|
||||||
|
var import_processLauncher = require("../server/utils/processLauncher");
|
||||||
|
function printApiJson() {
|
||||||
|
console.log(JSON.stringify(require("../../api.json")));
|
||||||
|
}
|
||||||
|
function runDriver() {
|
||||||
|
const dispatcherConnection = new import_server.DispatcherConnection();
|
||||||
|
new import_server.RootDispatcher(dispatcherConnection, async (rootScope, { sdkLanguage }) => {
|
||||||
|
const playwright2 = (0, import_server.createPlaywright)({ sdkLanguage });
|
||||||
|
return new import_server.PlaywrightDispatcher(rootScope, playwright2);
|
||||||
|
});
|
||||||
|
const transport = new import_pipeTransport.PipeTransport(process.stdout, process.stdin);
|
||||||
|
transport.onmessage = (message) => dispatcherConnection.dispatch(JSON.parse(message));
|
||||||
|
const isJavaScriptLanguageBinding = !process.env.PW_LANG_NAME || process.env.PW_LANG_NAME === "javascript";
|
||||||
|
const replacer = !isJavaScriptLanguageBinding && String.prototype.toWellFormed ? (key, value) => {
|
||||||
|
if (typeof value === "string")
|
||||||
|
return value.toWellFormed();
|
||||||
|
return value;
|
||||||
|
} : void 0;
|
||||||
|
dispatcherConnection.onmessage = (message) => transport.send(JSON.stringify(message, replacer));
|
||||||
|
transport.onclose = () => {
|
||||||
|
dispatcherConnection.onmessage = () => {
|
||||||
|
};
|
||||||
|
(0, import_processLauncher.gracefullyProcessExitDoNotHang)(0);
|
||||||
|
};
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function runServer(options) {
|
||||||
|
const {
|
||||||
|
port,
|
||||||
|
host,
|
||||||
|
path = "/",
|
||||||
|
maxConnections = Infinity,
|
||||||
|
extension
|
||||||
|
} = options;
|
||||||
|
const server = new import_playwrightServer.PlaywrightServer({ mode: extension ? "extension" : "default", path, maxConnections });
|
||||||
|
const wsEndpoint = await server.listen(port, host);
|
||||||
|
process.on("exit", () => server.close().catch(console.error));
|
||||||
|
console.log("Listening on " + wsEndpoint);
|
||||||
|
process.stdin.on("close", () => (0, import_processLauncher.gracefullyProcessExitDoNotHang)(0));
|
||||||
|
}
|
||||||
|
async function launchBrowserServer(browserName, configFile) {
|
||||||
|
let options = {};
|
||||||
|
if (configFile)
|
||||||
|
options = JSON.parse(import_fs.default.readFileSync(configFile).toString());
|
||||||
|
const browserType = playwright[browserName];
|
||||||
|
const server = await browserType.launchServer(options);
|
||||||
|
console.log(server.wsEndpoint());
|
||||||
|
}
|
||||||
|
// Annotate the CommonJS export names for ESM import in node:
|
||||||
|
0 && (module.exports = {
|
||||||
|
launchBrowserServer,
|
||||||
|
printApiJson,
|
||||||
|
runDriver,
|
||||||
|
runServer
|
||||||
|
});
|
||||||
+590
@@ -0,0 +1,590 @@
|
|||||||
|
"use strict";
|
||||||
|
var __create = Object.create;
|
||||||
|
var __defProp = Object.defineProperty;
|
||||||
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||||
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||||
|
var __getProtoOf = Object.getPrototypeOf;
|
||||||
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||||
|
var __export = (target, all) => {
|
||||||
|
for (var name in all)
|
||||||
|
__defProp(target, name, { get: all[name], enumerable: true });
|
||||||
|
};
|
||||||
|
var __copyProps = (to, from, except, desc) => {
|
||||||
|
if (from && typeof from === "object" || typeof from === "function") {
|
||||||
|
for (let key of __getOwnPropNames(from))
|
||||||
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||||
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||||
|
}
|
||||||
|
return to;
|
||||||
|
};
|
||||||
|
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||||
|
// If the importer is in node compatibility mode or this is not an ESM
|
||||||
|
// file that has been converted to a CommonJS file using a Babel-
|
||||||
|
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||||
|
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||||
|
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||||
|
mod
|
||||||
|
));
|
||||||
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||||
|
var program_exports = {};
|
||||||
|
__export(program_exports, {
|
||||||
|
program: () => import_utilsBundle2.program
|
||||||
|
});
|
||||||
|
module.exports = __toCommonJS(program_exports);
|
||||||
|
var import_fs = __toESM(require("fs"));
|
||||||
|
var import_os = __toESM(require("os"));
|
||||||
|
var import_path = __toESM(require("path"));
|
||||||
|
var playwright = __toESM(require("../.."));
|
||||||
|
var import_driver = require("./driver");
|
||||||
|
var import_server = require("../server");
|
||||||
|
var import_utils = require("../utils");
|
||||||
|
var import_traceViewer = require("../server/trace/viewer/traceViewer");
|
||||||
|
var import_utils2 = require("../utils");
|
||||||
|
var import_ascii = require("../server/utils/ascii");
|
||||||
|
var import_utilsBundle = require("../utilsBundle");
|
||||||
|
var import_utilsBundle2 = require("../utilsBundle");
|
||||||
|
const packageJSON = require("../../package.json");
|
||||||
|
import_utilsBundle.program.version("Version " + (process.env.PW_CLI_DISPLAY_VERSION || packageJSON.version)).name(buildBasePlaywrightCLICommand(process.env.PW_LANG_NAME));
|
||||||
|
import_utilsBundle.program.command("mark-docker-image [dockerImageNameTemplate]", { hidden: true }).description("mark docker image").allowUnknownOption(true).action(function(dockerImageNameTemplate) {
|
||||||
|
(0, import_utils2.assert)(dockerImageNameTemplate, "dockerImageNameTemplate is required");
|
||||||
|
(0, import_server.writeDockerVersion)(dockerImageNameTemplate).catch(logErrorAndExit);
|
||||||
|
});
|
||||||
|
commandWithOpenOptions("open [url]", "open page in browser specified via -b, --browser", []).action(function(url, options) {
|
||||||
|
open(options, url).catch(logErrorAndExit);
|
||||||
|
}).addHelpText("afterAll", `
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
$ open
|
||||||
|
$ open -b webkit https://example.com`);
|
||||||
|
commandWithOpenOptions(
|
||||||
|
"codegen [url]",
|
||||||
|
"open page and generate code for user actions",
|
||||||
|
[
|
||||||
|
["-o, --output <file name>", "saves the generated script to a file"],
|
||||||
|
["--target <language>", `language to generate, one of javascript, playwright-test, python, python-async, python-pytest, csharp, csharp-mstest, csharp-nunit, java, java-junit`, codegenId()],
|
||||||
|
["--test-id-attribute <attributeName>", "use the specified attribute to generate data test ID selectors"]
|
||||||
|
]
|
||||||
|
).action(async function(url, options) {
|
||||||
|
await codegen(options, url);
|
||||||
|
}).addHelpText("afterAll", `
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
$ codegen
|
||||||
|
$ codegen --target=python
|
||||||
|
$ codegen -b webkit https://example.com`);
|
||||||
|
function printInstalledBrowsers(browsers2) {
|
||||||
|
const browserPaths = /* @__PURE__ */ new Set();
|
||||||
|
for (const browser of browsers2)
|
||||||
|
browserPaths.add(browser.browserPath);
|
||||||
|
console.log(` Browsers:`);
|
||||||
|
for (const browserPath of [...browserPaths].sort())
|
||||||
|
console.log(` ${browserPath}`);
|
||||||
|
console.log(` References:`);
|
||||||
|
const references = /* @__PURE__ */ new Set();
|
||||||
|
for (const browser of browsers2)
|
||||||
|
references.add(browser.referenceDir);
|
||||||
|
for (const reference of [...references].sort())
|
||||||
|
console.log(` ${reference}`);
|
||||||
|
}
|
||||||
|
function printGroupedByPlaywrightVersion(browsers2) {
|
||||||
|
const dirToVersion = /* @__PURE__ */ new Map();
|
||||||
|
for (const browser of browsers2) {
|
||||||
|
if (dirToVersion.has(browser.referenceDir))
|
||||||
|
continue;
|
||||||
|
const packageJSON2 = require(import_path.default.join(browser.referenceDir, "package.json"));
|
||||||
|
const version = packageJSON2.version;
|
||||||
|
dirToVersion.set(browser.referenceDir, version);
|
||||||
|
}
|
||||||
|
const groupedByPlaywrightMinorVersion = /* @__PURE__ */ new Map();
|
||||||
|
for (const browser of browsers2) {
|
||||||
|
const version = dirToVersion.get(browser.referenceDir);
|
||||||
|
let entries = groupedByPlaywrightMinorVersion.get(version);
|
||||||
|
if (!entries) {
|
||||||
|
entries = [];
|
||||||
|
groupedByPlaywrightMinorVersion.set(version, entries);
|
||||||
|
}
|
||||||
|
entries.push(browser);
|
||||||
|
}
|
||||||
|
const sortedVersions = [...groupedByPlaywrightMinorVersion.keys()].sort((a, b) => {
|
||||||
|
const aComponents = a.split(".");
|
||||||
|
const bComponents = b.split(".");
|
||||||
|
const aMajor = parseInt(aComponents[0], 10);
|
||||||
|
const bMajor = parseInt(bComponents[0], 10);
|
||||||
|
if (aMajor !== bMajor)
|
||||||
|
return aMajor - bMajor;
|
||||||
|
const aMinor = parseInt(aComponents[1], 10);
|
||||||
|
const bMinor = parseInt(bComponents[1], 10);
|
||||||
|
if (aMinor !== bMinor)
|
||||||
|
return aMinor - bMinor;
|
||||||
|
return aComponents.slice(2).join(".").localeCompare(bComponents.slice(2).join("."));
|
||||||
|
});
|
||||||
|
for (const version of sortedVersions) {
|
||||||
|
console.log(`
|
||||||
|
Playwright version: ${version}`);
|
||||||
|
printInstalledBrowsers(groupedByPlaywrightMinorVersion.get(version));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
import_utilsBundle.program.command("install [browser...]").description("ensure browsers necessary for this version of Playwright are installed").option("--with-deps", "install system dependencies for browsers").option("--dry-run", "do not execute installation, only print information").option("--list", "prints list of browsers from all playwright installations").option("--force", "force reinstall of stable browser channels").option("--only-shell", "only install headless shell when installing chromium").option("--no-shell", "do not install chromium headless shell").action(async function(args, options) {
|
||||||
|
if ((0, import_utils.isLikelyNpxGlobal)()) {
|
||||||
|
console.error((0, import_ascii.wrapInASCIIBox)([
|
||||||
|
`WARNING: It looks like you are running 'npx playwright install' without first`,
|
||||||
|
`installing your project's dependencies.`,
|
||||||
|
``,
|
||||||
|
`To avoid unexpected behavior, please install your dependencies first, and`,
|
||||||
|
`then run Playwright's install command:`,
|
||||||
|
``,
|
||||||
|
` npm install`,
|
||||||
|
` npx playwright install`,
|
||||||
|
``,
|
||||||
|
`If your project does not yet depend on Playwright, first install the`,
|
||||||
|
`applicable npm package (most commonly @playwright/test), and`,
|
||||||
|
`then run Playwright's install command to download the browsers:`,
|
||||||
|
``,
|
||||||
|
` npm install @playwright/test`,
|
||||||
|
` npx playwright install`,
|
||||||
|
``
|
||||||
|
].join("\n"), 1));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (options.shell === false && options.onlyShell)
|
||||||
|
throw new Error(`Only one of --no-shell and --only-shell can be specified`);
|
||||||
|
const shell = options.shell === false ? "no" : options.onlyShell ? "only" : void 0;
|
||||||
|
const executables = import_server.registry.resolveBrowsers(args, { shell });
|
||||||
|
if (options.withDeps)
|
||||||
|
await import_server.registry.installDeps(executables, !!options.dryRun);
|
||||||
|
if (options.dryRun && options.list)
|
||||||
|
throw new Error(`Only one of --dry-run and --list can be specified`);
|
||||||
|
if (options.dryRun) {
|
||||||
|
for (const executable of executables) {
|
||||||
|
const version = executable.browserVersion ? `version ` + executable.browserVersion : "";
|
||||||
|
console.log(`browser: ${executable.name}${version ? " " + version : ""}`);
|
||||||
|
console.log(` Install location: ${executable.directory ?? "<system>"}`);
|
||||||
|
if (executable.downloadURLs?.length) {
|
||||||
|
const [url, ...fallbacks] = executable.downloadURLs;
|
||||||
|
console.log(` Download url: ${url}`);
|
||||||
|
for (let i = 0; i < fallbacks.length; ++i)
|
||||||
|
console.log(` Download fallback ${i + 1}: ${fallbacks[i]}`);
|
||||||
|
}
|
||||||
|
console.log(``);
|
||||||
|
}
|
||||||
|
} else if (options.list) {
|
||||||
|
const browsers2 = await import_server.registry.listInstalledBrowsers();
|
||||||
|
printGroupedByPlaywrightVersion(browsers2);
|
||||||
|
} else {
|
||||||
|
const force = args.length === 0 ? false : !!options.force;
|
||||||
|
await import_server.registry.install(executables, { force });
|
||||||
|
await import_server.registry.validateHostRequirementsForExecutablesIfNeeded(executables, process.env.PW_LANG_NAME || "javascript").catch((e) => {
|
||||||
|
e.name = "Playwright Host validation warning";
|
||||||
|
console.error(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`Failed to install browsers
|
||||||
|
${e}`);
|
||||||
|
(0, import_utils.gracefullyProcessExitDoNotHang)(1);
|
||||||
|
}
|
||||||
|
}).addHelpText("afterAll", `
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- $ install
|
||||||
|
Install default browsers.
|
||||||
|
|
||||||
|
- $ install chrome firefox
|
||||||
|
Install custom browsers, supports ${import_server.registry.suggestedBrowsersToInstall()}.`);
|
||||||
|
import_utilsBundle.program.command("uninstall").description("Removes browsers used by this installation of Playwright from the system (chromium, firefox, webkit, ffmpeg). This does not include branded channels.").option("--all", "Removes all browsers used by any Playwright installation from the system.").action(async (options) => {
|
||||||
|
delete process.env.PLAYWRIGHT_SKIP_BROWSER_GC;
|
||||||
|
await import_server.registry.uninstall(!!options.all).then(({ numberOfBrowsersLeft }) => {
|
||||||
|
if (!options.all && numberOfBrowsersLeft > 0) {
|
||||||
|
console.log("Successfully uninstalled Playwright browsers for the current Playwright installation.");
|
||||||
|
console.log(`There are still ${numberOfBrowsersLeft} browsers left, used by other Playwright installations.
|
||||||
|
To uninstall Playwright browsers for all installations, re-run with --all flag.`);
|
||||||
|
}
|
||||||
|
}).catch(logErrorAndExit);
|
||||||
|
});
|
||||||
|
import_utilsBundle.program.command("install-deps [browser...]").description("install dependencies necessary to run browsers (will ask for sudo permissions)").option("--dry-run", "Do not execute installation commands, only print them").action(async function(args, options) {
|
||||||
|
try {
|
||||||
|
await import_server.registry.installDeps(import_server.registry.resolveBrowsers(args, {}), !!options.dryRun);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`Failed to install browser dependencies
|
||||||
|
${e}`);
|
||||||
|
(0, import_utils.gracefullyProcessExitDoNotHang)(1);
|
||||||
|
}
|
||||||
|
}).addHelpText("afterAll", `
|
||||||
|
Examples:
|
||||||
|
- $ install-deps
|
||||||
|
Install dependencies for default browsers.
|
||||||
|
|
||||||
|
- $ install-deps chrome firefox
|
||||||
|
Install dependencies for specific browsers, supports ${import_server.registry.suggestedBrowsersToInstall()}.`);
|
||||||
|
const browsers = [
|
||||||
|
{ alias: "cr", name: "Chromium", type: "chromium" },
|
||||||
|
{ alias: "ff", name: "Firefox", type: "firefox" },
|
||||||
|
{ alias: "wk", name: "WebKit", type: "webkit" }
|
||||||
|
];
|
||||||
|
for (const { alias, name, type } of browsers) {
|
||||||
|
commandWithOpenOptions(`${alias} [url]`, `open page in ${name}`, []).action(function(url, options) {
|
||||||
|
open({ ...options, browser: type }, url).catch(logErrorAndExit);
|
||||||
|
}).addHelpText("afterAll", `
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
$ ${alias} https://example.com`);
|
||||||
|
}
|
||||||
|
commandWithOpenOptions(
|
||||||
|
"screenshot <url> <filename>",
|
||||||
|
"capture a page screenshot",
|
||||||
|
[
|
||||||
|
["--wait-for-selector <selector>", "wait for selector before taking a screenshot"],
|
||||||
|
["--wait-for-timeout <timeout>", "wait for timeout in milliseconds before taking a screenshot"],
|
||||||
|
["--full-page", "whether to take a full page screenshot (entire scrollable area)"]
|
||||||
|
]
|
||||||
|
).action(function(url, filename, command) {
|
||||||
|
screenshot(command, command, url, filename).catch(logErrorAndExit);
|
||||||
|
}).addHelpText("afterAll", `
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
$ screenshot -b webkit https://example.com example.png`);
|
||||||
|
commandWithOpenOptions(
|
||||||
|
"pdf <url> <filename>",
|
||||||
|
"save page as pdf",
|
||||||
|
[
|
||||||
|
["--paper-format <format>", "paper format: Letter, Legal, Tabloid, Ledger, A0, A1, A2, A3, A4, A5, A6"],
|
||||||
|
["--wait-for-selector <selector>", "wait for given selector before saving as pdf"],
|
||||||
|
["--wait-for-timeout <timeout>", "wait for given timeout in milliseconds before saving as pdf"]
|
||||||
|
]
|
||||||
|
).action(function(url, filename, options) {
|
||||||
|
pdf(options, options, url, filename).catch(logErrorAndExit);
|
||||||
|
}).addHelpText("afterAll", `
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
$ pdf https://example.com example.pdf`);
|
||||||
|
import_utilsBundle.program.command("run-driver", { hidden: true }).action(function(options) {
|
||||||
|
(0, import_driver.runDriver)();
|
||||||
|
});
|
||||||
|
import_utilsBundle.program.command("run-server", { hidden: true }).option("--port <port>", "Server port").option("--host <host>", "Server host").option("--path <path>", "Endpoint Path", "/").option("--max-clients <maxClients>", "Maximum clients").option("--mode <mode>", 'Server mode, either "default" or "extension"').action(function(options) {
|
||||||
|
(0, import_driver.runServer)({
|
||||||
|
port: options.port ? +options.port : void 0,
|
||||||
|
host: options.host,
|
||||||
|
path: options.path,
|
||||||
|
maxConnections: options.maxClients ? +options.maxClients : Infinity,
|
||||||
|
extension: options.mode === "extension" || !!process.env.PW_EXTENSION_MODE
|
||||||
|
}).catch(logErrorAndExit);
|
||||||
|
});
|
||||||
|
import_utilsBundle.program.command("print-api-json", { hidden: true }).action(function(options) {
|
||||||
|
(0, import_driver.printApiJson)();
|
||||||
|
});
|
||||||
|
import_utilsBundle.program.command("launch-server", { hidden: true }).requiredOption("--browser <browserName>", 'Browser name, one of "chromium", "firefox" or "webkit"').option("--config <path-to-config-file>", "JSON file with launchServer options").action(function(options) {
|
||||||
|
(0, import_driver.launchBrowserServer)(options.browser, options.config);
|
||||||
|
});
|
||||||
|
import_utilsBundle.program.command("show-trace [trace]").option("-b, --browser <browserType>", "browser to use, one of cr, chromium, ff, firefox, wk, webkit", "chromium").option("-h, --host <host>", "Host to serve trace on; specifying this option opens trace in a browser tab").option("-p, --port <port>", "Port to serve trace on, 0 for any free port; specifying this option opens trace in a browser tab").option("--stdin", "Accept trace URLs over stdin to update the viewer").description("show trace viewer").action(function(trace, options) {
|
||||||
|
if (options.browser === "cr")
|
||||||
|
options.browser = "chromium";
|
||||||
|
if (options.browser === "ff")
|
||||||
|
options.browser = "firefox";
|
||||||
|
if (options.browser === "wk")
|
||||||
|
options.browser = "webkit";
|
||||||
|
const openOptions = {
|
||||||
|
host: options.host,
|
||||||
|
port: +options.port,
|
||||||
|
isServer: !!options.stdin
|
||||||
|
};
|
||||||
|
if (options.port !== void 0 || options.host !== void 0)
|
||||||
|
(0, import_traceViewer.runTraceInBrowser)(trace, openOptions).catch(logErrorAndExit);
|
||||||
|
else
|
||||||
|
(0, import_traceViewer.runTraceViewerApp)(trace, options.browser, openOptions, true).catch(logErrorAndExit);
|
||||||
|
}).addHelpText("afterAll", `
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
$ show-trace
|
||||||
|
$ show-trace https://example.com/trace.zip`);
|
||||||
|
async function launchContext(options, extraOptions) {
|
||||||
|
validateOptions(options);
|
||||||
|
const browserType = lookupBrowserType(options);
|
||||||
|
const launchOptions = extraOptions;
|
||||||
|
if (options.channel)
|
||||||
|
launchOptions.channel = options.channel;
|
||||||
|
launchOptions.handleSIGINT = false;
|
||||||
|
const contextOptions = (
|
||||||
|
// Copy the device descriptor since we have to compare and modify the options.
|
||||||
|
options.device ? { ...playwright.devices[options.device] } : {}
|
||||||
|
);
|
||||||
|
if (!extraOptions.headless)
|
||||||
|
contextOptions.deviceScaleFactor = import_os.default.platform() === "darwin" ? 2 : 1;
|
||||||
|
if (browserType.name() === "webkit" && process.platform === "linux") {
|
||||||
|
delete contextOptions.hasTouch;
|
||||||
|
delete contextOptions.isMobile;
|
||||||
|
}
|
||||||
|
if (contextOptions.isMobile && browserType.name() === "firefox")
|
||||||
|
contextOptions.isMobile = void 0;
|
||||||
|
if (options.blockServiceWorkers)
|
||||||
|
contextOptions.serviceWorkers = "block";
|
||||||
|
if (options.proxyServer) {
|
||||||
|
launchOptions.proxy = {
|
||||||
|
server: options.proxyServer
|
||||||
|
};
|
||||||
|
if (options.proxyBypass)
|
||||||
|
launchOptions.proxy.bypass = options.proxyBypass;
|
||||||
|
}
|
||||||
|
if (options.viewportSize) {
|
||||||
|
try {
|
||||||
|
const [width, height] = options.viewportSize.split(",").map((n) => +n);
|
||||||
|
if (isNaN(width) || isNaN(height))
|
||||||
|
throw new Error("bad values");
|
||||||
|
contextOptions.viewport = { width, height };
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('Invalid viewport size format: use "width,height", for example --viewport-size="800,600"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (options.geolocation) {
|
||||||
|
try {
|
||||||
|
const [latitude, longitude] = options.geolocation.split(",").map((n) => parseFloat(n.trim()));
|
||||||
|
contextOptions.geolocation = {
|
||||||
|
latitude,
|
||||||
|
longitude
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('Invalid geolocation format, should be "lat,long". For example --geolocation="37.819722,-122.478611"');
|
||||||
|
}
|
||||||
|
contextOptions.permissions = ["geolocation"];
|
||||||
|
}
|
||||||
|
if (options.userAgent)
|
||||||
|
contextOptions.userAgent = options.userAgent;
|
||||||
|
if (options.lang)
|
||||||
|
contextOptions.locale = options.lang;
|
||||||
|
if (options.colorScheme)
|
||||||
|
contextOptions.colorScheme = options.colorScheme;
|
||||||
|
if (options.timezone)
|
||||||
|
contextOptions.timezoneId = options.timezone;
|
||||||
|
if (options.loadStorage)
|
||||||
|
contextOptions.storageState = options.loadStorage;
|
||||||
|
if (options.ignoreHttpsErrors)
|
||||||
|
contextOptions.ignoreHTTPSErrors = true;
|
||||||
|
if (options.saveHar) {
|
||||||
|
contextOptions.recordHar = { path: import_path.default.resolve(process.cwd(), options.saveHar), mode: "minimal" };
|
||||||
|
if (options.saveHarGlob)
|
||||||
|
contextOptions.recordHar.urlFilter = options.saveHarGlob;
|
||||||
|
contextOptions.serviceWorkers = "block";
|
||||||
|
}
|
||||||
|
let browser;
|
||||||
|
let context;
|
||||||
|
if (options.userDataDir) {
|
||||||
|
context = await browserType.launchPersistentContext(options.userDataDir, { ...launchOptions, ...contextOptions });
|
||||||
|
browser = context.browser();
|
||||||
|
} else {
|
||||||
|
browser = await browserType.launch(launchOptions);
|
||||||
|
context = await browser.newContext(contextOptions);
|
||||||
|
}
|
||||||
|
let closingBrowser = false;
|
||||||
|
async function closeBrowser() {
|
||||||
|
if (closingBrowser)
|
||||||
|
return;
|
||||||
|
closingBrowser = true;
|
||||||
|
if (options.saveStorage)
|
||||||
|
await context.storageState({ path: options.saveStorage }).catch((e) => null);
|
||||||
|
if (options.saveHar)
|
||||||
|
await context.close();
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
context.on("page", (page) => {
|
||||||
|
page.on("dialog", () => {
|
||||||
|
});
|
||||||
|
page.on("close", () => {
|
||||||
|
const hasPage = browser.contexts().some((context2) => context2.pages().length > 0);
|
||||||
|
if (hasPage)
|
||||||
|
return;
|
||||||
|
closeBrowser().catch(() => {
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
process.on("SIGINT", async () => {
|
||||||
|
await closeBrowser();
|
||||||
|
(0, import_utils.gracefullyProcessExitDoNotHang)(130);
|
||||||
|
});
|
||||||
|
const timeout = options.timeout ? parseInt(options.timeout, 10) : 0;
|
||||||
|
context.setDefaultTimeout(timeout);
|
||||||
|
context.setDefaultNavigationTimeout(timeout);
|
||||||
|
delete launchOptions.headless;
|
||||||
|
delete launchOptions.executablePath;
|
||||||
|
delete launchOptions.handleSIGINT;
|
||||||
|
delete contextOptions.deviceScaleFactor;
|
||||||
|
return { browser, browserName: browserType.name(), context, contextOptions, launchOptions, closeBrowser };
|
||||||
|
}
|
||||||
|
async function openPage(context, url) {
|
||||||
|
let page = context.pages()[0];
|
||||||
|
if (!page)
|
||||||
|
page = await context.newPage();
|
||||||
|
if (url) {
|
||||||
|
if (import_fs.default.existsSync(url))
|
||||||
|
url = "file://" + import_path.default.resolve(url);
|
||||||
|
else if (!url.startsWith("http") && !url.startsWith("file://") && !url.startsWith("about:") && !url.startsWith("data:"))
|
||||||
|
url = "http://" + url;
|
||||||
|
await page.goto(url);
|
||||||
|
}
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
async function open(options, url) {
|
||||||
|
const { context } = await launchContext(options, { headless: !!process.env.PWTEST_CLI_HEADLESS, executablePath: process.env.PWTEST_CLI_EXECUTABLE_PATH });
|
||||||
|
await openPage(context, url);
|
||||||
|
}
|
||||||
|
async function codegen(options, url) {
|
||||||
|
const { target: language, output: outputFile, testIdAttribute: testIdAttributeName } = options;
|
||||||
|
const tracesDir = import_path.default.join(import_os.default.tmpdir(), `playwright-recorder-trace-${Date.now()}`);
|
||||||
|
const { context, browser, launchOptions, contextOptions, closeBrowser } = await launchContext(options, {
|
||||||
|
headless: !!process.env.PWTEST_CLI_HEADLESS,
|
||||||
|
executablePath: process.env.PWTEST_CLI_EXECUTABLE_PATH,
|
||||||
|
tracesDir
|
||||||
|
});
|
||||||
|
const donePromise = new import_utils.ManualPromise();
|
||||||
|
maybeSetupTestHooks(browser, closeBrowser, donePromise);
|
||||||
|
import_utilsBundle.dotenv.config({ path: "playwright.env" });
|
||||||
|
await context._enableRecorder({
|
||||||
|
language,
|
||||||
|
launchOptions,
|
||||||
|
contextOptions,
|
||||||
|
device: options.device,
|
||||||
|
saveStorage: options.saveStorage,
|
||||||
|
mode: "recording",
|
||||||
|
testIdAttributeName,
|
||||||
|
outputFile: outputFile ? import_path.default.resolve(outputFile) : void 0,
|
||||||
|
handleSIGINT: false
|
||||||
|
});
|
||||||
|
await openPage(context, url);
|
||||||
|
donePromise.resolve();
|
||||||
|
}
|
||||||
|
async function maybeSetupTestHooks(browser, closeBrowser, donePromise) {
|
||||||
|
if (!process.env.PWTEST_CLI_IS_UNDER_TEST)
|
||||||
|
return;
|
||||||
|
const logs = [];
|
||||||
|
require("playwright-core/lib/utilsBundle").debug.log = (...args) => {
|
||||||
|
const line = require("util").format(...args) + "\n";
|
||||||
|
logs.push(line);
|
||||||
|
process.stderr.write(line);
|
||||||
|
};
|
||||||
|
browser.on("disconnected", () => {
|
||||||
|
const hasCrashLine = logs.some((line) => line.includes("process did exit:") && !line.includes("process did exit: exitCode=0, signal=null"));
|
||||||
|
if (hasCrashLine) {
|
||||||
|
process.stderr.write("Detected browser crash.\n");
|
||||||
|
(0, import_utils.gracefullyProcessExitDoNotHang)(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const close = async () => {
|
||||||
|
await donePromise;
|
||||||
|
await closeBrowser();
|
||||||
|
};
|
||||||
|
if (process.env.PWTEST_CLI_EXIT_AFTER_TIMEOUT) {
|
||||||
|
setTimeout(close, +process.env.PWTEST_CLI_EXIT_AFTER_TIMEOUT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let stdin = "";
|
||||||
|
process.stdin.on("data", (data) => {
|
||||||
|
stdin += data.toString();
|
||||||
|
if (stdin.startsWith("exit")) {
|
||||||
|
process.stdin.destroy();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function waitForPage(page, captureOptions) {
|
||||||
|
if (captureOptions.waitForSelector) {
|
||||||
|
console.log(`Waiting for selector ${captureOptions.waitForSelector}...`);
|
||||||
|
await page.waitForSelector(captureOptions.waitForSelector);
|
||||||
|
}
|
||||||
|
if (captureOptions.waitForTimeout) {
|
||||||
|
console.log(`Waiting for timeout ${captureOptions.waitForTimeout}...`);
|
||||||
|
await page.waitForTimeout(parseInt(captureOptions.waitForTimeout, 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function screenshot(options, captureOptions, url, path2) {
|
||||||
|
const { context } = await launchContext(options, { headless: true });
|
||||||
|
console.log("Navigating to " + url);
|
||||||
|
const page = await openPage(context, url);
|
||||||
|
await waitForPage(page, captureOptions);
|
||||||
|
console.log("Capturing screenshot into " + path2);
|
||||||
|
await page.screenshot({ path: path2, fullPage: !!captureOptions.fullPage });
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
async function pdf(options, captureOptions, url, path2) {
|
||||||
|
if (options.browser !== "chromium")
|
||||||
|
throw new Error("PDF creation is only working with Chromium");
|
||||||
|
const { context } = await launchContext({ ...options, browser: "chromium" }, { headless: true });
|
||||||
|
console.log("Navigating to " + url);
|
||||||
|
const page = await openPage(context, url);
|
||||||
|
await waitForPage(page, captureOptions);
|
||||||
|
console.log("Saving as pdf into " + path2);
|
||||||
|
await page.pdf({ path: path2, format: captureOptions.paperFormat });
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
function lookupBrowserType(options) {
|
||||||
|
let name = options.browser;
|
||||||
|
if (options.device) {
|
||||||
|
const device = playwright.devices[options.device];
|
||||||
|
name = device.defaultBrowserType;
|
||||||
|
}
|
||||||
|
let browserType;
|
||||||
|
switch (name) {
|
||||||
|
case "chromium":
|
||||||
|
browserType = playwright.chromium;
|
||||||
|
break;
|
||||||
|
case "webkit":
|
||||||
|
browserType = playwright.webkit;
|
||||||
|
break;
|
||||||
|
case "firefox":
|
||||||
|
browserType = playwright.firefox;
|
||||||
|
break;
|
||||||
|
case "cr":
|
||||||
|
browserType = playwright.chromium;
|
||||||
|
break;
|
||||||
|
case "wk":
|
||||||
|
browserType = playwright.webkit;
|
||||||
|
break;
|
||||||
|
case "ff":
|
||||||
|
browserType = playwright.firefox;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (browserType)
|
||||||
|
return browserType;
|
||||||
|
import_utilsBundle.program.help();
|
||||||
|
}
|
||||||
|
function validateOptions(options) {
|
||||||
|
if (options.device && !(options.device in playwright.devices)) {
|
||||||
|
const lines = [`Device descriptor not found: '${options.device}', available devices are:`];
|
||||||
|
for (const name in playwright.devices)
|
||||||
|
lines.push(` "${name}"`);
|
||||||
|
throw new Error(lines.join("\n"));
|
||||||
|
}
|
||||||
|
if (options.colorScheme && !["light", "dark"].includes(options.colorScheme))
|
||||||
|
throw new Error('Invalid color scheme, should be one of "light", "dark"');
|
||||||
|
}
|
||||||
|
function logErrorAndExit(e) {
|
||||||
|
if (process.env.PWDEBUGIMPL)
|
||||||
|
console.error(e);
|
||||||
|
else
|
||||||
|
console.error(e.name + ": " + e.message);
|
||||||
|
(0, import_utils.gracefullyProcessExitDoNotHang)(1);
|
||||||
|
}
|
||||||
|
function codegenId() {
|
||||||
|
return process.env.PW_LANG_NAME || "playwright-test";
|
||||||
|
}
|
||||||
|
function commandWithOpenOptions(command, description, options) {
|
||||||
|
let result = import_utilsBundle.program.command(command).description(description);
|
||||||
|
for (const option of options)
|
||||||
|
result = result.option(option[0], ...option.slice(1));
|
||||||
|
return result.option("-b, --browser <browserType>", "browser to use, one of cr, chromium, ff, firefox, wk, webkit", "chromium").option("--block-service-workers", "block service workers").option("--channel <channel>", 'Chromium distribution channel, "chrome", "chrome-beta", "msedge-dev", etc').option("--color-scheme <scheme>", 'emulate preferred color scheme, "light" or "dark"').option("--device <deviceName>", 'emulate device, for example "iPhone 11"').option("--geolocation <coordinates>", 'specify geolocation coordinates, for example "37.819722,-122.478611"').option("--ignore-https-errors", "ignore https errors").option("--load-storage <filename>", "load context storage state from the file, previously saved with --save-storage").option("--lang <language>", 'specify language / locale, for example "en-GB"').option("--proxy-server <proxy>", 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"').option("--proxy-bypass <bypass>", 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"').option("--save-har <filename>", "save HAR file with all network activity at the end").option("--save-har-glob <glob pattern>", "filter entries in the HAR by matching url against this glob pattern").option("--save-storage <filename>", "save context storage state at the end, for later use with --load-storage").option("--timezone <time zone>", 'time zone to emulate, for example "Europe/Rome"').option("--timeout <timeout>", "timeout for Playwright actions in milliseconds, no timeout by default").option("--user-agent <ua string>", "specify user agent string").option("--user-data-dir <directory>", "use the specified user data directory instead of a new context").option("--viewport-size <size>", 'specify browser viewport size in pixels, for example "1280, 720"');
|
||||||
|
}
|
||||||
|
function buildBasePlaywrightCLICommand(cliTargetLang) {
|
||||||
|
switch (cliTargetLang) {
|
||||||
|
case "python":
|
||||||
|
return `playwright`;
|
||||||
|
case "java":
|
||||||
|
return `mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="...options.."`;
|
||||||
|
case "csharp":
|
||||||
|
return `pwsh bin/Debug/netX/playwright.ps1`;
|
||||||
|
default: {
|
||||||
|
const packageManagerCommand = (0, import_utils2.getPackageManagerExecCommand)();
|
||||||
|
return `${packageManagerCommand} playwright`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Annotate the CommonJS export names for ESM import in node:
|
||||||
|
0 && (module.exports = {
|
||||||
|
program
|
||||||
|
});
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
"use strict";
|
||||||
|
var __defProp = Object.defineProperty;
|
||||||
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||||
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||||
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||||
|
var __export = (target, all) => {
|
||||||
|
for (var name in all)
|
||||||
|
__defProp(target, name, { get: all[name], enumerable: true });
|
||||||
|
};
|
||||||
|
var __copyProps = (to, from, except, desc) => {
|
||||||
|
if (from && typeof from === "object" || typeof from === "function") {
|
||||||
|
for (let key of __getOwnPropNames(from))
|
||||||
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||||
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||||
|
}
|
||||||
|
return to;
|
||||||
|
};
|
||||||
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||||
|
var programWithTestStub_exports = {};
|
||||||
|
__export(programWithTestStub_exports, {
|
||||||
|
program: () => import_program2.program
|
||||||
|
});
|
||||||
|
module.exports = __toCommonJS(programWithTestStub_exports);
|
||||||
|
var import_processLauncher = require("../server/utils/processLauncher");
|
||||||
|
var import_utils = require("../utils");
|
||||||
|
var import_program = require("./program");
|
||||||
|
var import_program2 = require("./program");
|
||||||
|
function printPlaywrightTestError(command) {
|
||||||
|
const packages = [];
|
||||||
|
for (const pkg of ["playwright", "playwright-chromium", "playwright-firefox", "playwright-webkit"]) {
|
||||||
|
try {
|
||||||
|
require.resolve(pkg);
|
||||||
|
packages.push(pkg);
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!packages.length)
|
||||||
|
packages.push("playwright");
|
||||||
|
const packageManager = (0, import_utils.getPackageManager)();
|
||||||
|
if (packageManager === "yarn") {
|
||||||
|
console.error(`Please install @playwright/test package before running "yarn playwright ${command}"`);
|
||||||
|
console.error(` yarn remove ${packages.join(" ")}`);
|
||||||
|
console.error(" yarn add -D @playwright/test");
|
||||||
|
} else if (packageManager === "pnpm") {
|
||||||
|
console.error(`Please install @playwright/test package before running "pnpm exec playwright ${command}"`);
|
||||||
|
console.error(` pnpm remove ${packages.join(" ")}`);
|
||||||
|
console.error(" pnpm add -D @playwright/test");
|
||||||
|
} else {
|
||||||
|
console.error(`Please install @playwright/test package before running "npx playwright ${command}"`);
|
||||||
|
console.error(` npm uninstall ${packages.join(" ")}`);
|
||||||
|
console.error(" npm install -D @playwright/test");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const kExternalPlaywrightTestCommands = [
|
||||||
|
["test", "Run tests with Playwright Test."],
|
||||||
|
["show-report", "Show Playwright Test HTML report."],
|
||||||
|
["merge-reports", "Merge Playwright Test Blob reports"]
|
||||||
|
];
|
||||||
|
function addExternalPlaywrightTestCommands() {
|
||||||
|
for (const [command, description] of kExternalPlaywrightTestCommands) {
|
||||||
|
const playwrightTest = import_program.program.command(command).allowUnknownOption(true).allowExcessArguments(true);
|
||||||
|
playwrightTest.description(`${description} Available in @playwright/test package.`);
|
||||||
|
playwrightTest.action(async () => {
|
||||||
|
printPlaywrightTestError(command);
|
||||||
|
(0, import_processLauncher.gracefullyProcessExitDoNotHang)(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!process.env.PW_LANG_NAME)
|
||||||
|
addExternalPlaywrightTestCommands();
|
||||||
|
// Annotate the CommonJS export names for ESM import in node:
|
||||||
|
0 && (module.exports = {
|
||||||
|
program
|
||||||
|
});
|
||||||
+361
@@ -0,0 +1,361 @@
|
|||||||
|
"use strict";
|
||||||
|
var __defProp = Object.defineProperty;
|
||||||
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||||
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||||
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||||
|
var __export = (target, all) => {
|
||||||
|
for (var name in all)
|
||||||
|
__defProp(target, name, { get: all[name], enumerable: true });
|
||||||
|
};
|
||||||
|
var __copyProps = (to, from, except, desc) => {
|
||||||
|
if (from && typeof from === "object" || typeof from === "function") {
|
||||||
|
for (let key of __getOwnPropNames(from))
|
||||||
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||||
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||||
|
}
|
||||||
|
return to;
|
||||||
|
};
|
||||||
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||||
|
var android_exports = {};
|
||||||
|
__export(android_exports, {
|
||||||
|
Android: () => Android,
|
||||||
|
AndroidDevice: () => AndroidDevice,
|
||||||
|
AndroidInput: () => AndroidInput,
|
||||||
|
AndroidSocket: () => AndroidSocket,
|
||||||
|
AndroidWebView: () => AndroidWebView
|
||||||
|
});
|
||||||
|
module.exports = __toCommonJS(android_exports);
|
||||||
|
var import_eventEmitter = require("./eventEmitter");
|
||||||
|
var import_browserContext = require("./browserContext");
|
||||||
|
var import_channelOwner = require("./channelOwner");
|
||||||
|
var import_errors = require("./errors");
|
||||||
|
var import_events = require("./events");
|
||||||
|
var import_waiter = require("./waiter");
|
||||||
|
var import_timeoutSettings = require("./timeoutSettings");
|
||||||
|
var import_rtti = require("../utils/isomorphic/rtti");
|
||||||
|
var import_time = require("../utils/isomorphic/time");
|
||||||
|
var import_timeoutRunner = require("../utils/isomorphic/timeoutRunner");
|
||||||
|
var import_webSocket = require("./webSocket");
|
||||||
|
class Android extends import_channelOwner.ChannelOwner {
|
||||||
|
static from(android) {
|
||||||
|
return android._object;
|
||||||
|
}
|
||||||
|
constructor(parent, type, guid, initializer) {
|
||||||
|
super(parent, type, guid, initializer);
|
||||||
|
this._timeoutSettings = new import_timeoutSettings.TimeoutSettings(this._platform);
|
||||||
|
}
|
||||||
|
setDefaultTimeout(timeout) {
|
||||||
|
this._timeoutSettings.setDefaultTimeout(timeout);
|
||||||
|
}
|
||||||
|
async devices(options = {}) {
|
||||||
|
const { devices } = await this._channel.devices(options);
|
||||||
|
return devices.map((d) => AndroidDevice.from(d));
|
||||||
|
}
|
||||||
|
async launchServer(options = {}) {
|
||||||
|
if (!this._serverLauncher)
|
||||||
|
throw new Error("Launching server is not supported");
|
||||||
|
return await this._serverLauncher.launchServer(options);
|
||||||
|
}
|
||||||
|
async connect(wsEndpoint, options = {}) {
|
||||||
|
return await this._wrapApiCall(async () => {
|
||||||
|
const deadline = options.timeout ? (0, import_time.monotonicTime)() + options.timeout : 0;
|
||||||
|
const headers = { "x-playwright-browser": "android", ...options.headers };
|
||||||
|
const connectParams = { wsEndpoint, headers, slowMo: options.slowMo, timeout: options.timeout || 0 };
|
||||||
|
const connection = await (0, import_webSocket.connectOverWebSocket)(this._connection, connectParams);
|
||||||
|
let device;
|
||||||
|
connection.on("close", () => {
|
||||||
|
device?._didClose();
|
||||||
|
});
|
||||||
|
const result = await (0, import_timeoutRunner.raceAgainstDeadline)(async () => {
|
||||||
|
const playwright = await connection.initializePlaywright();
|
||||||
|
if (!playwright._initializer.preConnectedAndroidDevice) {
|
||||||
|
connection.close();
|
||||||
|
throw new Error("Malformed endpoint. Did you use Android.launchServer method?");
|
||||||
|
}
|
||||||
|
device = AndroidDevice.from(playwright._initializer.preConnectedAndroidDevice);
|
||||||
|
device._shouldCloseConnectionOnClose = true;
|
||||||
|
device.on(import_events.Events.AndroidDevice.Close, () => connection.close());
|
||||||
|
return device;
|
||||||
|
}, deadline);
|
||||||
|
if (!result.timedOut) {
|
||||||
|
return result.result;
|
||||||
|
} else {
|
||||||
|
connection.close();
|
||||||
|
throw new Error(`Timeout ${options.timeout}ms exceeded`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class AndroidDevice extends import_channelOwner.ChannelOwner {
|
||||||
|
constructor(parent, type, guid, initializer) {
|
||||||
|
super(parent, type, guid, initializer);
|
||||||
|
this._webViews = /* @__PURE__ */ new Map();
|
||||||
|
this._shouldCloseConnectionOnClose = false;
|
||||||
|
this._android = parent;
|
||||||
|
this.input = new AndroidInput(this);
|
||||||
|
this._timeoutSettings = new import_timeoutSettings.TimeoutSettings(this._platform, parent._timeoutSettings);
|
||||||
|
this._channel.on("webViewAdded", ({ webView }) => this._onWebViewAdded(webView));
|
||||||
|
this._channel.on("webViewRemoved", ({ socketName }) => this._onWebViewRemoved(socketName));
|
||||||
|
this._channel.on("close", () => this._didClose());
|
||||||
|
}
|
||||||
|
static from(androidDevice) {
|
||||||
|
return androidDevice._object;
|
||||||
|
}
|
||||||
|
_onWebViewAdded(webView) {
|
||||||
|
const view = new AndroidWebView(this, webView);
|
||||||
|
this._webViews.set(webView.socketName, view);
|
||||||
|
this.emit(import_events.Events.AndroidDevice.WebView, view);
|
||||||
|
}
|
||||||
|
_onWebViewRemoved(socketName) {
|
||||||
|
const view = this._webViews.get(socketName);
|
||||||
|
this._webViews.delete(socketName);
|
||||||
|
if (view)
|
||||||
|
view.emit(import_events.Events.AndroidWebView.Close);
|
||||||
|
}
|
||||||
|
setDefaultTimeout(timeout) {
|
||||||
|
this._timeoutSettings.setDefaultTimeout(timeout);
|
||||||
|
}
|
||||||
|
serial() {
|
||||||
|
return this._initializer.serial;
|
||||||
|
}
|
||||||
|
model() {
|
||||||
|
return this._initializer.model;
|
||||||
|
}
|
||||||
|
webViews() {
|
||||||
|
return [...this._webViews.values()];
|
||||||
|
}
|
||||||
|
async webView(selector, options) {
|
||||||
|
const predicate = (v) => {
|
||||||
|
if (selector.pkg)
|
||||||
|
return v.pkg() === selector.pkg;
|
||||||
|
if (selector.socketName)
|
||||||
|
return v._socketName() === selector.socketName;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const webView = [...this._webViews.values()].find(predicate);
|
||||||
|
if (webView)
|
||||||
|
return webView;
|
||||||
|
return await this.waitForEvent("webview", { ...options, predicate });
|
||||||
|
}
|
||||||
|
async wait(selector, options = {}) {
|
||||||
|
await this._channel.wait({ androidSelector: toSelectorChannel(selector), ...options, timeout: this._timeoutSettings.timeout(options) });
|
||||||
|
}
|
||||||
|
async fill(selector, text, options = {}) {
|
||||||
|
await this._channel.fill({ androidSelector: toSelectorChannel(selector), text, ...options, timeout: this._timeoutSettings.timeout(options) });
|
||||||
|
}
|
||||||
|
async press(selector, key, options = {}) {
|
||||||
|
await this.tap(selector, options);
|
||||||
|
await this.input.press(key);
|
||||||
|
}
|
||||||
|
async tap(selector, options = {}) {
|
||||||
|
await this._channel.tap({ androidSelector: toSelectorChannel(selector), ...options, timeout: this._timeoutSettings.timeout(options) });
|
||||||
|
}
|
||||||
|
async drag(selector, dest, options = {}) {
|
||||||
|
await this._channel.drag({ androidSelector: toSelectorChannel(selector), dest, ...options, timeout: this._timeoutSettings.timeout(options) });
|
||||||
|
}
|
||||||
|
async fling(selector, direction, options = {}) {
|
||||||
|
await this._channel.fling({ androidSelector: toSelectorChannel(selector), direction, ...options, timeout: this._timeoutSettings.timeout(options) });
|
||||||
|
}
|
||||||
|
async longTap(selector, options = {}) {
|
||||||
|
await this._channel.longTap({ androidSelector: toSelectorChannel(selector), ...options, timeout: this._timeoutSettings.timeout(options) });
|
||||||
|
}
|
||||||
|
async pinchClose(selector, percent, options = {}) {
|
||||||
|
await this._channel.pinchClose({ androidSelector: toSelectorChannel(selector), percent, ...options, timeout: this._timeoutSettings.timeout(options) });
|
||||||
|
}
|
||||||
|
async pinchOpen(selector, percent, options = {}) {
|
||||||
|
await this._channel.pinchOpen({ androidSelector: toSelectorChannel(selector), percent, ...options, timeout: this._timeoutSettings.timeout(options) });
|
||||||
|
}
|
||||||
|
async scroll(selector, direction, percent, options = {}) {
|
||||||
|
await this._channel.scroll({ androidSelector: toSelectorChannel(selector), direction, percent, ...options, timeout: this._timeoutSettings.timeout(options) });
|
||||||
|
}
|
||||||
|
async swipe(selector, direction, percent, options = {}) {
|
||||||
|
await this._channel.swipe({ androidSelector: toSelectorChannel(selector), direction, percent, ...options, timeout: this._timeoutSettings.timeout(options) });
|
||||||
|
}
|
||||||
|
async info(selector) {
|
||||||
|
return (await this._channel.info({ androidSelector: toSelectorChannel(selector) })).info;
|
||||||
|
}
|
||||||
|
async screenshot(options = {}) {
|
||||||
|
const { binary } = await this._channel.screenshot();
|
||||||
|
if (options.path)
|
||||||
|
await this._platform.fs().promises.writeFile(options.path, binary);
|
||||||
|
return binary;
|
||||||
|
}
|
||||||
|
async [Symbol.asyncDispose]() {
|
||||||
|
await this.close();
|
||||||
|
}
|
||||||
|
async close() {
|
||||||
|
try {
|
||||||
|
if (this._shouldCloseConnectionOnClose)
|
||||||
|
this._connection.close();
|
||||||
|
else
|
||||||
|
await this._channel.close();
|
||||||
|
} catch (e) {
|
||||||
|
if ((0, import_errors.isTargetClosedError)(e))
|
||||||
|
return;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_didClose() {
|
||||||
|
this.emit(import_events.Events.AndroidDevice.Close, this);
|
||||||
|
}
|
||||||
|
async shell(command) {
|
||||||
|
const { result } = await this._channel.shell({ command });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
async open(command) {
|
||||||
|
return AndroidSocket.from((await this._channel.open({ command })).socket);
|
||||||
|
}
|
||||||
|
async installApk(file, options) {
|
||||||
|
await this._channel.installApk({ file: await loadFile(this._platform, file), args: options && options.args });
|
||||||
|
}
|
||||||
|
async push(file, path, options) {
|
||||||
|
await this._channel.push({ file: await loadFile(this._platform, file), path, mode: options ? options.mode : void 0 });
|
||||||
|
}
|
||||||
|
async launchBrowser(options = {}) {
|
||||||
|
const contextOptions = await (0, import_browserContext.prepareBrowserContextParams)(this._platform, options);
|
||||||
|
const result = await this._channel.launchBrowser(contextOptions);
|
||||||
|
const context = import_browserContext.BrowserContext.from(result.context);
|
||||||
|
const selectors = this._android._playwright.selectors;
|
||||||
|
selectors._contextsForSelectors.add(context);
|
||||||
|
context.once(import_events.Events.BrowserContext.Close, () => selectors._contextsForSelectors.delete(context));
|
||||||
|
await context._initializeHarFromOptions(options.recordHar);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
async waitForEvent(event, optionsOrPredicate = {}) {
|
||||||
|
return await this._wrapApiCall(async () => {
|
||||||
|
const timeout = this._timeoutSettings.timeout(typeof optionsOrPredicate === "function" ? {} : optionsOrPredicate);
|
||||||
|
const predicate = typeof optionsOrPredicate === "function" ? optionsOrPredicate : optionsOrPredicate.predicate;
|
||||||
|
const waiter = import_waiter.Waiter.createForEvent(this, event);
|
||||||
|
waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`);
|
||||||
|
if (event !== import_events.Events.AndroidDevice.Close)
|
||||||
|
waiter.rejectOnEvent(this, import_events.Events.AndroidDevice.Close, () => new import_errors.TargetClosedError());
|
||||||
|
const result = await waiter.waitForEvent(this, event, predicate);
|
||||||
|
waiter.dispose();
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class AndroidSocket extends import_channelOwner.ChannelOwner {
|
||||||
|
static from(androidDevice) {
|
||||||
|
return androidDevice._object;
|
||||||
|
}
|
||||||
|
constructor(parent, type, guid, initializer) {
|
||||||
|
super(parent, type, guid, initializer);
|
||||||
|
this._channel.on("data", ({ data }) => this.emit(import_events.Events.AndroidSocket.Data, data));
|
||||||
|
this._channel.on("close", () => this.emit(import_events.Events.AndroidSocket.Close));
|
||||||
|
}
|
||||||
|
async write(data) {
|
||||||
|
await this._channel.write({ data });
|
||||||
|
}
|
||||||
|
async close() {
|
||||||
|
await this._channel.close();
|
||||||
|
}
|
||||||
|
async [Symbol.asyncDispose]() {
|
||||||
|
await this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function loadFile(platform, file) {
|
||||||
|
if ((0, import_rtti.isString)(file))
|
||||||
|
return await platform.fs().promises.readFile(file);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
class AndroidInput {
|
||||||
|
constructor(device) {
|
||||||
|
this._device = device;
|
||||||
|
}
|
||||||
|
async type(text) {
|
||||||
|
await this._device._channel.inputType({ text });
|
||||||
|
}
|
||||||
|
async press(key) {
|
||||||
|
await this._device._channel.inputPress({ key });
|
||||||
|
}
|
||||||
|
async tap(point) {
|
||||||
|
await this._device._channel.inputTap({ point });
|
||||||
|
}
|
||||||
|
async swipe(from, segments, steps) {
|
||||||
|
await this._device._channel.inputSwipe({ segments, steps });
|
||||||
|
}
|
||||||
|
async drag(from, to, steps) {
|
||||||
|
await this._device._channel.inputDrag({ from, to, steps });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function toSelectorChannel(selector) {
|
||||||
|
const {
|
||||||
|
checkable,
|
||||||
|
checked,
|
||||||
|
clazz,
|
||||||
|
clickable,
|
||||||
|
depth,
|
||||||
|
desc,
|
||||||
|
enabled,
|
||||||
|
focusable,
|
||||||
|
focused,
|
||||||
|
hasChild,
|
||||||
|
hasDescendant,
|
||||||
|
longClickable,
|
||||||
|
pkg,
|
||||||
|
res,
|
||||||
|
scrollable,
|
||||||
|
selected,
|
||||||
|
text
|
||||||
|
} = selector;
|
||||||
|
const toRegex = (value) => {
|
||||||
|
if (value === void 0)
|
||||||
|
return void 0;
|
||||||
|
if ((0, import_rtti.isRegExp)(value))
|
||||||
|
return value.source;
|
||||||
|
return "^" + value.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&").replace(/-/g, "\\x2d") + "$";
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
checkable,
|
||||||
|
checked,
|
||||||
|
clazz: toRegex(clazz),
|
||||||
|
pkg: toRegex(pkg),
|
||||||
|
desc: toRegex(desc),
|
||||||
|
res: toRegex(res),
|
||||||
|
text: toRegex(text),
|
||||||
|
clickable,
|
||||||
|
depth,
|
||||||
|
enabled,
|
||||||
|
focusable,
|
||||||
|
focused,
|
||||||
|
hasChild: hasChild ? { androidSelector: toSelectorChannel(hasChild.selector) } : void 0,
|
||||||
|
hasDescendant: hasDescendant ? { androidSelector: toSelectorChannel(hasDescendant.selector), maxDepth: hasDescendant.maxDepth } : void 0,
|
||||||
|
longClickable,
|
||||||
|
scrollable,
|
||||||
|
selected
|
||||||
|
};
|
||||||
|
}
|
||||||
|
class AndroidWebView extends import_eventEmitter.EventEmitter {
|
||||||
|
constructor(device, data) {
|
||||||
|
super(device._platform);
|
||||||
|
this._device = device;
|
||||||
|
this._data = data;
|
||||||
|
}
|
||||||
|
pid() {
|
||||||
|
return this._data.pid;
|
||||||
|
}
|
||||||
|
pkg() {
|
||||||
|
return this._data.pkg;
|
||||||
|
}
|
||||||
|
_socketName() {
|
||||||
|
return this._data.socketName;
|
||||||
|
}
|
||||||
|
async page() {
|
||||||
|
if (!this._pagePromise)
|
||||||
|
this._pagePromise = this._fetchPage();
|
||||||
|
return await this._pagePromise;
|
||||||
|
}
|
||||||
|
async _fetchPage() {
|
||||||
|
const { context } = await this._device._channel.connectToWebView({ socketName: this._data.socketName });
|
||||||
|
return import_browserContext.BrowserContext.from(context).pages()[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Annotate the CommonJS export names for ESM import in node:
|
||||||
|
0 && (module.exports = {
|
||||||
|
Android,
|
||||||
|
AndroidDevice,
|
||||||
|
AndroidInput,
|
||||||
|
AndroidSocket,
|
||||||
|
AndroidWebView
|
||||||
|
});
|
||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
"use strict";
|
||||||
|
var __defProp = Object.defineProperty;
|
||||||
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||||
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||||
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||||
|
var __export = (target, all) => {
|
||||||
|
for (var name in all)
|
||||||
|
__defProp(target, name, { get: all[name], enumerable: true });
|
||||||
|
};
|
||||||
|
var __copyProps = (to, from, except, desc) => {
|
||||||
|
if (from && typeof from === "object" || typeof from === "function") {
|
||||||
|
for (let key of __getOwnPropNames(from))
|
||||||
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||||
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||||
|
}
|
||||||
|
return to;
|
||||||
|
};
|
||||||
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||||
|
var api_exports = {};
|
||||||
|
__export(api_exports, {
|
||||||
|
APIRequest: () => import_fetch.APIRequest,
|
||||||
|
APIRequestContext: () => import_fetch.APIRequestContext,
|
||||||
|
APIResponse: () => import_fetch.APIResponse,
|
||||||
|
Android: () => import_android.Android,
|
||||||
|
AndroidDevice: () => import_android.AndroidDevice,
|
||||||
|
AndroidInput: () => import_android.AndroidInput,
|
||||||
|
AndroidSocket: () => import_android.AndroidSocket,
|
||||||
|
AndroidWebView: () => import_android.AndroidWebView,
|
||||||
|
Browser: () => import_browser.Browser,
|
||||||
|
BrowserContext: () => import_browserContext.BrowserContext,
|
||||||
|
BrowserType: () => import_browserType.BrowserType,
|
||||||
|
CDPSession: () => import_cdpSession.CDPSession,
|
||||||
|
Clock: () => import_clock.Clock,
|
||||||
|
ConsoleMessage: () => import_consoleMessage.ConsoleMessage,
|
||||||
|
Coverage: () => import_coverage.Coverage,
|
||||||
|
Dialog: () => import_dialog.Dialog,
|
||||||
|
Download: () => import_download.Download,
|
||||||
|
Electron: () => import_electron.Electron,
|
||||||
|
ElectronApplication: () => import_electron.ElectronApplication,
|
||||||
|
ElementHandle: () => import_elementHandle.ElementHandle,
|
||||||
|
FileChooser: () => import_fileChooser.FileChooser,
|
||||||
|
Frame: () => import_frame.Frame,
|
||||||
|
FrameLocator: () => import_locator.FrameLocator,
|
||||||
|
JSHandle: () => import_jsHandle.JSHandle,
|
||||||
|
Keyboard: () => import_input.Keyboard,
|
||||||
|
Locator: () => import_locator.Locator,
|
||||||
|
Mouse: () => import_input.Mouse,
|
||||||
|
Page: () => import_page.Page,
|
||||||
|
Playwright: () => import_playwright.Playwright,
|
||||||
|
Request: () => import_network.Request,
|
||||||
|
Response: () => import_network.Response,
|
||||||
|
Route: () => import_network.Route,
|
||||||
|
Selectors: () => import_selectors.Selectors,
|
||||||
|
TimeoutError: () => import_errors.TimeoutError,
|
||||||
|
Touchscreen: () => import_input.Touchscreen,
|
||||||
|
Tracing: () => import_tracing.Tracing,
|
||||||
|
Video: () => import_video.Video,
|
||||||
|
WebError: () => import_webError.WebError,
|
||||||
|
WebSocket: () => import_network.WebSocket,
|
||||||
|
WebSocketRoute: () => import_network.WebSocketRoute,
|
||||||
|
Worker: () => import_worker.Worker
|
||||||
|
});
|
||||||
|
module.exports = __toCommonJS(api_exports);
|
||||||
|
var import_android = require("./android");
|
||||||
|
var import_browser = require("./browser");
|
||||||
|
var import_browserContext = require("./browserContext");
|
||||||
|
var import_browserType = require("./browserType");
|
||||||
|
var import_clock = require("./clock");
|
||||||
|
var import_consoleMessage = require("./consoleMessage");
|
||||||
|
var import_coverage = require("./coverage");
|
||||||
|
var import_dialog = require("./dialog");
|
||||||
|
var import_download = require("./download");
|
||||||
|
var import_electron = require("./electron");
|
||||||
|
var import_locator = require("./locator");
|
||||||
|
var import_elementHandle = require("./elementHandle");
|
||||||
|
var import_fileChooser = require("./fileChooser");
|
||||||
|
var import_errors = require("./errors");
|
||||||
|
var import_frame = require("./frame");
|
||||||
|
var import_input = require("./input");
|
||||||
|
var import_jsHandle = require("./jsHandle");
|
||||||
|
var import_network = require("./network");
|
||||||
|
var import_fetch = require("./fetch");
|
||||||
|
var import_page = require("./page");
|
||||||
|
var import_selectors = require("./selectors");
|
||||||
|
var import_tracing = require("./tracing");
|
||||||
|
var import_video = require("./video");
|
||||||
|
var import_worker = require("./worker");
|
||||||
|
var import_cdpSession = require("./cdpSession");
|
||||||
|
var import_playwright = require("./playwright");
|
||||||
|
var import_webError = require("./webError");
|
||||||
|
// Annotate the CommonJS export names for ESM import in node:
|
||||||
|
0 && (module.exports = {
|
||||||
|
APIRequest,
|
||||||
|
APIRequestContext,
|
||||||
|
APIResponse,
|
||||||
|
Android,
|
||||||
|
AndroidDevice,
|
||||||
|
AndroidInput,
|
||||||
|
AndroidSocket,
|
||||||
|
AndroidWebView,
|
||||||
|
Browser,
|
||||||
|
BrowserContext,
|
||||||
|
BrowserType,
|
||||||
|
CDPSession,
|
||||||
|
Clock,
|
||||||
|
ConsoleMessage,
|
||||||
|
Coverage,
|
||||||
|
Dialog,
|
||||||
|
Download,
|
||||||
|
Electron,
|
||||||
|
ElectronApplication,
|
||||||
|
ElementHandle,
|
||||||
|
FileChooser,
|
||||||
|
Frame,
|
||||||
|
FrameLocator,
|
||||||
|
JSHandle,
|
||||||
|
Keyboard,
|
||||||
|
Locator,
|
||||||
|
Mouse,
|
||||||
|
Page,
|
||||||
|
Playwright,
|
||||||
|
Request,
|
||||||
|
Response,
|
||||||
|
Route,
|
||||||
|
Selectors,
|
||||||
|
TimeoutError,
|
||||||
|
Touchscreen,
|
||||||
|
Tracing,
|
||||||
|
Video,
|
||||||
|
WebError,
|
||||||
|
WebSocket,
|
||||||
|
WebSocketRoute,
|
||||||
|
Worker
|
||||||
|
});
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
"use strict";
|
||||||
|
var __defProp = Object.defineProperty;
|
||||||
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||||
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||||
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||||
|
var __export = (target, all) => {
|
||||||
|
for (var name in all)
|
||||||
|
__defProp(target, name, { get: all[name], enumerable: true });
|
||||||
|
};
|
||||||
|
var __copyProps = (to, from, except, desc) => {
|
||||||
|
if (from && typeof from === "object" || typeof from === "function") {
|
||||||
|
for (let key of __getOwnPropNames(from))
|
||||||
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||||
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||||
|
}
|
||||||
|
return to;
|
||||||
|
};
|
||||||
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||||
|
var artifact_exports = {};
|
||||||
|
__export(artifact_exports, {
|
||||||
|
Artifact: () => Artifact
|
||||||
|
});
|
||||||
|
module.exports = __toCommonJS(artifact_exports);
|
||||||
|
var import_channelOwner = require("./channelOwner");
|
||||||
|
var import_stream = require("./stream");
|
||||||
|
var import_fileUtils = require("./fileUtils");
|
||||||
|
class Artifact extends import_channelOwner.ChannelOwner {
|
||||||
|
static from(channel) {
|
||||||
|
return channel._object;
|
||||||
|
}
|
||||||
|
async pathAfterFinished() {
|
||||||
|
if (this._connection.isRemote())
|
||||||
|
throw new Error(`Path is not available when connecting remotely. Use saveAs() to save a local copy.`);
|
||||||
|
return (await this._channel.pathAfterFinished()).value;
|
||||||
|
}
|
||||||
|
async saveAs(path) {
|
||||||
|
if (!this._connection.isRemote()) {
|
||||||
|
await this._channel.saveAs({ path });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await this._channel.saveAsStream();
|
||||||
|
const stream = import_stream.Stream.from(result.stream);
|
||||||
|
await (0, import_fileUtils.mkdirIfNeeded)(this._platform, path);
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
stream.stream().pipe(this._platform.fs().createWriteStream(path)).on("finish", resolve).on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async failure() {
|
||||||
|
return (await this._channel.failure()).error || null;
|
||||||
|
}
|
||||||
|
async createReadStream() {
|
||||||
|
const result = await this._channel.stream();
|
||||||
|
const stream = import_stream.Stream.from(result.stream);
|
||||||
|
return stream.stream();
|
||||||
|
}
|
||||||
|
async readIntoBuffer() {
|
||||||
|
const stream = await this.createReadStream();
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const chunks = [];
|
||||||
|
stream.on("data", (chunk) => {
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
stream.on("end", () => {
|
||||||
|
resolve(Buffer.concat(chunks));
|
||||||
|
});
|
||||||
|
stream.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async cancel() {
|
||||||
|
return await this._channel.cancel();
|
||||||
|
}
|
||||||
|
async delete() {
|
||||||
|
return await this._channel.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Annotate the CommonJS export names for ESM import in node:
|
||||||
|
0 && (module.exports = {
|
||||||
|
Artifact
|
||||||
|
});
|
||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
"use strict";
|
||||||
|
var __defProp = Object.defineProperty;
|
||||||
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||||
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||||
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||||
|
var __export = (target, all) => {
|
||||||
|
for (var name in all)
|
||||||
|
__defProp(target, name, { get: all[name], enumerable: true });
|
||||||
|
};
|
||||||
|
var __copyProps = (to, from, except, desc) => {
|
||||||
|
if (from && typeof from === "object" || typeof from === "function") {
|
||||||
|
for (let key of __getOwnPropNames(from))
|
||||||
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||||
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||||
|
}
|
||||||
|
return to;
|
||||||
|
};
|
||||||
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||||
|
var browser_exports = {};
|
||||||
|
__export(browser_exports, {
|
||||||
|
Browser: () => Browser
|
||||||
|
});
|
||||||
|
module.exports = __toCommonJS(browser_exports);
|
||||||
|
var import_artifact = require("./artifact");
|
||||||
|
var import_browserContext = require("./browserContext");
|
||||||
|
var import_cdpSession = require("./cdpSession");
|
||||||
|
var import_channelOwner = require("./channelOwner");
|
||||||
|
var import_errors = require("./errors");
|
||||||
|
var import_events = require("./events");
|
||||||
|
var import_fileUtils = require("./fileUtils");
|
||||||
|
class Browser extends import_channelOwner.ChannelOwner {
|
||||||
|
constructor(parent, type, guid, initializer) {
|
||||||
|
super(parent, type, guid, initializer);
|
||||||
|
this._contexts = /* @__PURE__ */ new Set();
|
||||||
|
this._isConnected = true;
|
||||||
|
this._shouldCloseConnectionOnClose = false;
|
||||||
|
this._options = {};
|
||||||
|
this._name = initializer.name;
|
||||||
|
this._channel.on("context", ({ context }) => this._didCreateContext(import_browserContext.BrowserContext.from(context)));
|
||||||
|
this._channel.on("close", () => this._didClose());
|
||||||
|
this._closedPromise = new Promise((f) => this.once(import_events.Events.Browser.Disconnected, f));
|
||||||
|
}
|
||||||
|
static from(browser) {
|
||||||
|
return browser._object;
|
||||||
|
}
|
||||||
|
browserType() {
|
||||||
|
return this._browserType;
|
||||||
|
}
|
||||||
|
async newContext(options = {}) {
|
||||||
|
return await this._innerNewContext(options, false);
|
||||||
|
}
|
||||||
|
async _newContextForReuse(options = {}) {
|
||||||
|
return await this._innerNewContext(options, true);
|
||||||
|
}
|
||||||
|
async _disconnectFromReusedContext(reason) {
|
||||||
|
const context = [...this._contexts].find((context2) => context2._forReuse);
|
||||||
|
if (!context)
|
||||||
|
return;
|
||||||
|
await this._instrumentation.runBeforeCloseBrowserContext(context);
|
||||||
|
for (const page of context.pages())
|
||||||
|
page._onClose();
|
||||||
|
context._onClose();
|
||||||
|
await this._channel.disconnectFromReusedContext({ reason });
|
||||||
|
}
|
||||||
|
async _innerNewContext(options = {}, forReuse) {
|
||||||
|
options = this._browserType._playwright.selectors._withSelectorOptions({
|
||||||
|
...this._browserType._playwright._defaultContextOptions,
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
const contextOptions = await (0, import_browserContext.prepareBrowserContextParams)(this._platform, options);
|
||||||
|
const response = forReuse ? await this._channel.newContextForReuse(contextOptions) : await this._channel.newContext(contextOptions);
|
||||||
|
const context = import_browserContext.BrowserContext.from(response.context);
|
||||||
|
if (forReuse)
|
||||||
|
context._forReuse = true;
|
||||||
|
if (options.logger)
|
||||||
|
context._logger = options.logger;
|
||||||
|
await context._initializeHarFromOptions(options.recordHar);
|
||||||
|
await this._instrumentation.runAfterCreateBrowserContext(context);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
_connectToBrowserType(browserType, browserOptions, logger) {
|
||||||
|
this._browserType = browserType;
|
||||||
|
this._options = browserOptions;
|
||||||
|
this._logger = logger;
|
||||||
|
for (const context of this._contexts)
|
||||||
|
this._setupBrowserContext(context);
|
||||||
|
}
|
||||||
|
_didCreateContext(context) {
|
||||||
|
context._browser = this;
|
||||||
|
this._contexts.add(context);
|
||||||
|
if (this._browserType)
|
||||||
|
this._setupBrowserContext(context);
|
||||||
|
}
|
||||||
|
_setupBrowserContext(context) {
|
||||||
|
context._logger = this._logger;
|
||||||
|
context.tracing._tracesDir = this._options.tracesDir;
|
||||||
|
this._browserType._contexts.add(context);
|
||||||
|
this._browserType._playwright.selectors._contextsForSelectors.add(context);
|
||||||
|
context.setDefaultTimeout(this._browserType._playwright._defaultContextTimeout);
|
||||||
|
context.setDefaultNavigationTimeout(this._browserType._playwright._defaultContextNavigationTimeout);
|
||||||
|
}
|
||||||
|
contexts() {
|
||||||
|
return [...this._contexts];
|
||||||
|
}
|
||||||
|
version() {
|
||||||
|
return this._initializer.version;
|
||||||
|
}
|
||||||
|
async newPage(options = {}) {
|
||||||
|
return await this._wrapApiCall(async () => {
|
||||||
|
const context = await this.newContext(options);
|
||||||
|
const page = await context.newPage();
|
||||||
|
page._ownedContext = context;
|
||||||
|
context._ownerPage = page;
|
||||||
|
return page;
|
||||||
|
}, { title: "Create page" });
|
||||||
|
}
|
||||||
|
isConnected() {
|
||||||
|
return this._isConnected;
|
||||||
|
}
|
||||||
|
async newBrowserCDPSession() {
|
||||||
|
return import_cdpSession.CDPSession.from((await this._channel.newBrowserCDPSession()).session);
|
||||||
|
}
|
||||||
|
async startTracing(page, options = {}) {
|
||||||
|
this._path = options.path;
|
||||||
|
await this._channel.startTracing({ ...options, page: page ? page._channel : void 0 });
|
||||||
|
}
|
||||||
|
async stopTracing() {
|
||||||
|
const artifact = import_artifact.Artifact.from((await this._channel.stopTracing()).artifact);
|
||||||
|
const buffer = await artifact.readIntoBuffer();
|
||||||
|
await artifact.delete();
|
||||||
|
if (this._path) {
|
||||||
|
await (0, import_fileUtils.mkdirIfNeeded)(this._platform, this._path);
|
||||||
|
await this._platform.fs().promises.writeFile(this._path, buffer);
|
||||||
|
this._path = void 0;
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
async [Symbol.asyncDispose]() {
|
||||||
|
await this.close();
|
||||||
|
}
|
||||||
|
async close(options = {}) {
|
||||||
|
this._closeReason = options.reason;
|
||||||
|
try {
|
||||||
|
if (this._shouldCloseConnectionOnClose)
|
||||||
|
this._connection.close();
|
||||||
|
else
|
||||||
|
await this._channel.close(options);
|
||||||
|
await this._closedPromise;
|
||||||
|
} catch (e) {
|
||||||
|
if ((0, import_errors.isTargetClosedError)(e))
|
||||||
|
return;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_didClose() {
|
||||||
|
this._isConnected = false;
|
||||||
|
this.emit(import_events.Events.Browser.Disconnected, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Annotate the CommonJS export names for ESM import in node:
|
||||||
|
0 && (module.exports = {
|
||||||
|
Browser
|
||||||
|
});
|
||||||
+547
@@ -0,0 +1,547 @@
|
|||||||
|
"use strict";
|
||||||
|
var __create = Object.create;
|
||||||
|
var __defProp = Object.defineProperty;
|
||||||
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||||
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||||
|
var __getProtoOf = Object.getPrototypeOf;
|
||||||
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||||
|
var __export = (target, all) => {
|
||||||
|
for (var name in all)
|
||||||
|
__defProp(target, name, { get: all[name], enumerable: true });
|
||||||
|
};
|
||||||
|
var __copyProps = (to, from, except, desc) => {
|
||||||
|
if (from && typeof from === "object" || typeof from === "function") {
|
||||||
|
for (let key of __getOwnPropNames(from))
|
||||||
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||||
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||||
|
}
|
||||||
|
return to;
|
||||||
|
};
|
||||||
|
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||||
|
// If the importer is in node compatibility mode or this is not an ESM
|
||||||
|
// file that has been converted to a CommonJS file using a Babel-
|
||||||
|
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||||
|
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||||
|
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||||
|
mod
|
||||||
|
));
|
||||||
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||||
|
var browserContext_exports = {};
|
||||||
|
__export(browserContext_exports, {
|
||||||
|
BrowserContext: () => BrowserContext,
|
||||||
|
prepareBrowserContextParams: () => prepareBrowserContextParams,
|
||||||
|
toClientCertificatesProtocol: () => toClientCertificatesProtocol
|
||||||
|
});
|
||||||
|
module.exports = __toCommonJS(browserContext_exports);
|
||||||
|
var import_artifact = require("./artifact");
|
||||||
|
var import_cdpSession = require("./cdpSession");
|
||||||
|
var import_channelOwner = require("./channelOwner");
|
||||||
|
var import_clientHelper = require("./clientHelper");
|
||||||
|
var import_clock = require("./clock");
|
||||||
|
var import_consoleMessage = require("./consoleMessage");
|
||||||
|
var import_dialog = require("./dialog");
|
||||||
|
var import_errors = require("./errors");
|
||||||
|
var import_events = require("./events");
|
||||||
|
var import_fetch = require("./fetch");
|
||||||
|
var import_frame = require("./frame");
|
||||||
|
var import_harRouter = require("./harRouter");
|
||||||
|
var network = __toESM(require("./network"));
|
||||||
|
var import_page = require("./page");
|
||||||
|
var import_tracing = require("./tracing");
|
||||||
|
var import_waiter = require("./waiter");
|
||||||
|
var import_webError = require("./webError");
|
||||||
|
var import_worker = require("./worker");
|
||||||
|
var import_timeoutSettings = require("./timeoutSettings");
|
||||||
|
var import_fileUtils = require("./fileUtils");
|
||||||
|
var import_headers = require("../utils/isomorphic/headers");
|
||||||
|
var import_urlMatch = require("../utils/isomorphic/urlMatch");
|
||||||
|
var import_rtti = require("../utils/isomorphic/rtti");
|
||||||
|
var import_stackTrace = require("../utils/isomorphic/stackTrace");
|
||||||
|
class BrowserContext extends import_channelOwner.ChannelOwner {
|
||||||
|
constructor(parent, type, guid, initializer) {
|
||||||
|
super(parent, type, guid, initializer);
|
||||||
|
this._pages = /* @__PURE__ */ new Set();
|
||||||
|
this._routes = [];
|
||||||
|
this._webSocketRoutes = [];
|
||||||
|
// Browser is null for browser contexts created outside of normal browser, e.g. android or electron.
|
||||||
|
this._browser = null;
|
||||||
|
this._bindings = /* @__PURE__ */ new Map();
|
||||||
|
this._forReuse = false;
|
||||||
|
this._serviceWorkers = /* @__PURE__ */ new Set();
|
||||||
|
this._harRecorders = /* @__PURE__ */ new Map();
|
||||||
|
this._closingStatus = "none";
|
||||||
|
this._harRouters = [];
|
||||||
|
this._options = initializer.options;
|
||||||
|
this._timeoutSettings = new import_timeoutSettings.TimeoutSettings(this._platform);
|
||||||
|
this.tracing = import_tracing.Tracing.from(initializer.tracing);
|
||||||
|
this.request = import_fetch.APIRequestContext.from(initializer.requestContext);
|
||||||
|
this.request._timeoutSettings = this._timeoutSettings;
|
||||||
|
this.clock = new import_clock.Clock(this);
|
||||||
|
this._channel.on("bindingCall", ({ binding }) => this._onBinding(import_page.BindingCall.from(binding)));
|
||||||
|
this._channel.on("close", () => this._onClose());
|
||||||
|
this._channel.on("page", ({ page }) => this._onPage(import_page.Page.from(page)));
|
||||||
|
this._channel.on("route", ({ route }) => this._onRoute(network.Route.from(route)));
|
||||||
|
this._channel.on("webSocketRoute", ({ webSocketRoute }) => this._onWebSocketRoute(network.WebSocketRoute.from(webSocketRoute)));
|
||||||
|
this._channel.on("serviceWorker", ({ worker }) => {
|
||||||
|
const serviceWorker = import_worker.Worker.from(worker);
|
||||||
|
serviceWorker._context = this;
|
||||||
|
this._serviceWorkers.add(serviceWorker);
|
||||||
|
this.emit(import_events.Events.BrowserContext.ServiceWorker, serviceWorker);
|
||||||
|
});
|
||||||
|
this._channel.on("console", (event) => {
|
||||||
|
const worker = import_worker.Worker.fromNullable(event.worker);
|
||||||
|
const page = import_page.Page.fromNullable(event.page);
|
||||||
|
const consoleMessage = new import_consoleMessage.ConsoleMessage(this._platform, event, page, worker);
|
||||||
|
worker?.emit(import_events.Events.Worker.Console, consoleMessage);
|
||||||
|
page?.emit(import_events.Events.Page.Console, consoleMessage);
|
||||||
|
if (worker && this._serviceWorkers.has(worker)) {
|
||||||
|
const scope = this._serviceWorkerScope(worker);
|
||||||
|
for (const page2 of this._pages) {
|
||||||
|
if (scope && page2.url().startsWith(scope))
|
||||||
|
page2.emit(import_events.Events.Page.Console, consoleMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.emit(import_events.Events.BrowserContext.Console, consoleMessage);
|
||||||
|
});
|
||||||
|
this._channel.on("pageError", ({ error, page }) => {
|
||||||
|
const pageObject = import_page.Page.from(page);
|
||||||
|
const parsedError = (0, import_errors.parseError)(error);
|
||||||
|
this.emit(import_events.Events.BrowserContext.WebError, new import_webError.WebError(pageObject, parsedError));
|
||||||
|
if (pageObject)
|
||||||
|
pageObject.emit(import_events.Events.Page.PageError, parsedError);
|
||||||
|
});
|
||||||
|
this._channel.on("dialog", ({ dialog }) => {
|
||||||
|
const dialogObject = import_dialog.Dialog.from(dialog);
|
||||||
|
let hasListeners = this.emit(import_events.Events.BrowserContext.Dialog, dialogObject);
|
||||||
|
const page = dialogObject.page();
|
||||||
|
if (page)
|
||||||
|
hasListeners = page.emit(import_events.Events.Page.Dialog, dialogObject) || hasListeners;
|
||||||
|
if (!hasListeners) {
|
||||||
|
if (dialogObject.type() === "beforeunload")
|
||||||
|
dialog.accept({}).catch(() => {
|
||||||
|
});
|
||||||
|
else
|
||||||
|
dialog.dismiss().catch(() => {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this._channel.on("request", ({ request, page }) => this._onRequest(network.Request.from(request), import_page.Page.fromNullable(page)));
|
||||||
|
this._channel.on("requestFailed", ({ request, failureText, responseEndTiming, page }) => this._onRequestFailed(network.Request.from(request), responseEndTiming, failureText, import_page.Page.fromNullable(page)));
|
||||||
|
this._channel.on("requestFinished", (params) => this._onRequestFinished(params));
|
||||||
|
this._channel.on("response", ({ response, page }) => this._onResponse(network.Response.from(response), import_page.Page.fromNullable(page)));
|
||||||
|
this._channel.on("recorderEvent", ({ event, data, page, code }) => {
|
||||||
|
if (event === "actionAdded")
|
||||||
|
this._onRecorderEventSink?.actionAdded?.(import_page.Page.from(page), data, code);
|
||||||
|
else if (event === "actionUpdated")
|
||||||
|
this._onRecorderEventSink?.actionUpdated?.(import_page.Page.from(page), data, code);
|
||||||
|
else if (event === "signalAdded")
|
||||||
|
this._onRecorderEventSink?.signalAdded?.(import_page.Page.from(page), data);
|
||||||
|
});
|
||||||
|
this._closedPromise = new Promise((f) => this.once(import_events.Events.BrowserContext.Close, f));
|
||||||
|
this._setEventToSubscriptionMapping(/* @__PURE__ */ new Map([
|
||||||
|
[import_events.Events.BrowserContext.Console, "console"],
|
||||||
|
[import_events.Events.BrowserContext.Dialog, "dialog"],
|
||||||
|
[import_events.Events.BrowserContext.Request, "request"],
|
||||||
|
[import_events.Events.BrowserContext.Response, "response"],
|
||||||
|
[import_events.Events.BrowserContext.RequestFinished, "requestFinished"],
|
||||||
|
[import_events.Events.BrowserContext.RequestFailed, "requestFailed"]
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
static from(context) {
|
||||||
|
return context._object;
|
||||||
|
}
|
||||||
|
static fromNullable(context) {
|
||||||
|
return context ? BrowserContext.from(context) : null;
|
||||||
|
}
|
||||||
|
async _initializeHarFromOptions(recordHar) {
|
||||||
|
if (!recordHar)
|
||||||
|
return;
|
||||||
|
const defaultContent = recordHar.path.endsWith(".zip") ? "attach" : "embed";
|
||||||
|
await this._recordIntoHAR(recordHar.path, null, {
|
||||||
|
url: recordHar.urlFilter,
|
||||||
|
updateContent: recordHar.content ?? (recordHar.omitContent ? "omit" : defaultContent),
|
||||||
|
updateMode: recordHar.mode ?? "full"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_onPage(page) {
|
||||||
|
this._pages.add(page);
|
||||||
|
this.emit(import_events.Events.BrowserContext.Page, page);
|
||||||
|
if (page._opener && !page._opener.isClosed())
|
||||||
|
page._opener.emit(import_events.Events.Page.Popup, page);
|
||||||
|
}
|
||||||
|
_onRequest(request, page) {
|
||||||
|
this.emit(import_events.Events.BrowserContext.Request, request);
|
||||||
|
if (page)
|
||||||
|
page.emit(import_events.Events.Page.Request, request);
|
||||||
|
}
|
||||||
|
_onResponse(response, page) {
|
||||||
|
this.emit(import_events.Events.BrowserContext.Response, response);
|
||||||
|
if (page)
|
||||||
|
page.emit(import_events.Events.Page.Response, response);
|
||||||
|
}
|
||||||
|
_onRequestFailed(request, responseEndTiming, failureText, page) {
|
||||||
|
request._failureText = failureText || null;
|
||||||
|
request._setResponseEndTiming(responseEndTiming);
|
||||||
|
this.emit(import_events.Events.BrowserContext.RequestFailed, request);
|
||||||
|
if (page)
|
||||||
|
page.emit(import_events.Events.Page.RequestFailed, request);
|
||||||
|
}
|
||||||
|
_onRequestFinished(params) {
|
||||||
|
const { responseEndTiming } = params;
|
||||||
|
const request = network.Request.from(params.request);
|
||||||
|
const response = network.Response.fromNullable(params.response);
|
||||||
|
const page = import_page.Page.fromNullable(params.page);
|
||||||
|
request._setResponseEndTiming(responseEndTiming);
|
||||||
|
this.emit(import_events.Events.BrowserContext.RequestFinished, request);
|
||||||
|
if (page)
|
||||||
|
page.emit(import_events.Events.Page.RequestFinished, request);
|
||||||
|
if (response)
|
||||||
|
response._finishedPromise.resolve(null);
|
||||||
|
}
|
||||||
|
async _onRoute(route) {
|
||||||
|
route._context = this;
|
||||||
|
const page = route.request()._safePage();
|
||||||
|
const routeHandlers = this._routes.slice();
|
||||||
|
for (const routeHandler of routeHandlers) {
|
||||||
|
if (page?._closeWasCalled || this._closingStatus !== "none")
|
||||||
|
return;
|
||||||
|
if (!routeHandler.matches(route.request().url()))
|
||||||
|
continue;
|
||||||
|
const index = this._routes.indexOf(routeHandler);
|
||||||
|
if (index === -1)
|
||||||
|
continue;
|
||||||
|
if (routeHandler.willExpire())
|
||||||
|
this._routes.splice(index, 1);
|
||||||
|
const handled = await routeHandler.handle(route);
|
||||||
|
if (!this._routes.length)
|
||||||
|
this._updateInterceptionPatterns({ internal: true }).catch(() => {
|
||||||
|
});
|
||||||
|
if (handled)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route._innerContinue(
|
||||||
|
true
|
||||||
|
/* isFallback */
|
||||||
|
).catch(() => {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async _onWebSocketRoute(webSocketRoute) {
|
||||||
|
const routeHandler = this._webSocketRoutes.find((route) => route.matches(webSocketRoute.url()));
|
||||||
|
if (routeHandler)
|
||||||
|
await routeHandler.handle(webSocketRoute);
|
||||||
|
else
|
||||||
|
webSocketRoute.connectToServer();
|
||||||
|
}
|
||||||
|
async _onBinding(bindingCall) {
|
||||||
|
const func = this._bindings.get(bindingCall._initializer.name);
|
||||||
|
if (!func)
|
||||||
|
return;
|
||||||
|
await bindingCall.call(func);
|
||||||
|
}
|
||||||
|
_serviceWorkerScope(serviceWorker) {
|
||||||
|
try {
|
||||||
|
let url = new URL(".", serviceWorker.url()).href;
|
||||||
|
if (!url.endsWith("/"))
|
||||||
|
url += "/";
|
||||||
|
return url;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setDefaultNavigationTimeout(timeout) {
|
||||||
|
this._timeoutSettings.setDefaultNavigationTimeout(timeout);
|
||||||
|
}
|
||||||
|
setDefaultTimeout(timeout) {
|
||||||
|
this._timeoutSettings.setDefaultTimeout(timeout);
|
||||||
|
}
|
||||||
|
browser() {
|
||||||
|
return this._browser;
|
||||||
|
}
|
||||||
|
pages() {
|
||||||
|
return [...this._pages];
|
||||||
|
}
|
||||||
|
async newPage() {
|
||||||
|
if (this._ownerPage)
|
||||||
|
throw new Error("Please use browser.newContext()");
|
||||||
|
return import_page.Page.from((await this._channel.newPage()).page);
|
||||||
|
}
|
||||||
|
async cookies(urls) {
|
||||||
|
if (!urls)
|
||||||
|
urls = [];
|
||||||
|
if (urls && typeof urls === "string")
|
||||||
|
urls = [urls];
|
||||||
|
return (await this._channel.cookies({ urls })).cookies;
|
||||||
|
}
|
||||||
|
async addCookies(cookies) {
|
||||||
|
await this._channel.addCookies({ cookies });
|
||||||
|
}
|
||||||
|
async clearCookies(options = {}) {
|
||||||
|
await this._channel.clearCookies({
|
||||||
|
name: (0, import_rtti.isString)(options.name) ? options.name : void 0,
|
||||||
|
nameRegexSource: (0, import_rtti.isRegExp)(options.name) ? options.name.source : void 0,
|
||||||
|
nameRegexFlags: (0, import_rtti.isRegExp)(options.name) ? options.name.flags : void 0,
|
||||||
|
domain: (0, import_rtti.isString)(options.domain) ? options.domain : void 0,
|
||||||
|
domainRegexSource: (0, import_rtti.isRegExp)(options.domain) ? options.domain.source : void 0,
|
||||||
|
domainRegexFlags: (0, import_rtti.isRegExp)(options.domain) ? options.domain.flags : void 0,
|
||||||
|
path: (0, import_rtti.isString)(options.path) ? options.path : void 0,
|
||||||
|
pathRegexSource: (0, import_rtti.isRegExp)(options.path) ? options.path.source : void 0,
|
||||||
|
pathRegexFlags: (0, import_rtti.isRegExp)(options.path) ? options.path.flags : void 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async grantPermissions(permissions, options) {
|
||||||
|
await this._channel.grantPermissions({ permissions, ...options });
|
||||||
|
}
|
||||||
|
async clearPermissions() {
|
||||||
|
await this._channel.clearPermissions();
|
||||||
|
}
|
||||||
|
async setGeolocation(geolocation) {
|
||||||
|
await this._channel.setGeolocation({ geolocation: geolocation || void 0 });
|
||||||
|
}
|
||||||
|
async setExtraHTTPHeaders(headers) {
|
||||||
|
network.validateHeaders(headers);
|
||||||
|
await this._channel.setExtraHTTPHeaders({ headers: (0, import_headers.headersObjectToArray)(headers) });
|
||||||
|
}
|
||||||
|
async setOffline(offline) {
|
||||||
|
await this._channel.setOffline({ offline });
|
||||||
|
}
|
||||||
|
async setHTTPCredentials(httpCredentials) {
|
||||||
|
await this._channel.setHTTPCredentials({ httpCredentials: httpCredentials || void 0 });
|
||||||
|
}
|
||||||
|
async addInitScript(script, arg) {
|
||||||
|
const source = await (0, import_clientHelper.evaluationScript)(this._platform, script, arg);
|
||||||
|
await this._channel.addInitScript({ source });
|
||||||
|
}
|
||||||
|
async exposeBinding(name, callback, options = {}) {
|
||||||
|
await this._channel.exposeBinding({ name, needsHandle: options.handle });
|
||||||
|
this._bindings.set(name, callback);
|
||||||
|
}
|
||||||
|
async exposeFunction(name, callback) {
|
||||||
|
await this._channel.exposeBinding({ name });
|
||||||
|
const binding = (source, ...args) => callback(...args);
|
||||||
|
this._bindings.set(name, binding);
|
||||||
|
}
|
||||||
|
async route(url, handler, options = {}) {
|
||||||
|
this._routes.unshift(new network.RouteHandler(this._platform, this._options.baseURL, url, handler, options.times));
|
||||||
|
await this._updateInterceptionPatterns({ title: "Route requests" });
|
||||||
|
}
|
||||||
|
async routeWebSocket(url, handler) {
|
||||||
|
this._webSocketRoutes.unshift(new network.WebSocketRouteHandler(this._options.baseURL, url, handler));
|
||||||
|
await this._updateWebSocketInterceptionPatterns({ title: "Route WebSockets" });
|
||||||
|
}
|
||||||
|
async _recordIntoHAR(har, page, options = {}) {
|
||||||
|
const { harId } = await this._channel.harStart({
|
||||||
|
page: page?._channel,
|
||||||
|
options: {
|
||||||
|
zip: har.endsWith(".zip"),
|
||||||
|
content: options.updateContent ?? "attach",
|
||||||
|
urlGlob: (0, import_rtti.isString)(options.url) ? options.url : void 0,
|
||||||
|
urlRegexSource: (0, import_rtti.isRegExp)(options.url) ? options.url.source : void 0,
|
||||||
|
urlRegexFlags: (0, import_rtti.isRegExp)(options.url) ? options.url.flags : void 0,
|
||||||
|
mode: options.updateMode ?? "minimal"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this._harRecorders.set(harId, { path: har, content: options.updateContent ?? "attach" });
|
||||||
|
}
|
||||||
|
async routeFromHAR(har, options = {}) {
|
||||||
|
const localUtils = this._connection.localUtils();
|
||||||
|
if (!localUtils)
|
||||||
|
throw new Error("Route from har is not supported in thin clients");
|
||||||
|
if (options.update) {
|
||||||
|
await this._recordIntoHAR(har, null, options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const harRouter = await import_harRouter.HarRouter.create(localUtils, har, options.notFound || "abort", { urlMatch: options.url });
|
||||||
|
this._harRouters.push(harRouter);
|
||||||
|
await harRouter.addContextRoute(this);
|
||||||
|
}
|
||||||
|
_disposeHarRouters() {
|
||||||
|
this._harRouters.forEach((router) => router.dispose());
|
||||||
|
this._harRouters = [];
|
||||||
|
}
|
||||||
|
async unrouteAll(options) {
|
||||||
|
await this._unrouteInternal(this._routes, [], options?.behavior);
|
||||||
|
this._disposeHarRouters();
|
||||||
|
}
|
||||||
|
async unroute(url, handler) {
|
||||||
|
const removed = [];
|
||||||
|
const remaining = [];
|
||||||
|
for (const route of this._routes) {
|
||||||
|
if ((0, import_urlMatch.urlMatchesEqual)(route.url, url) && (!handler || route.handler === handler))
|
||||||
|
removed.push(route);
|
||||||
|
else
|
||||||
|
remaining.push(route);
|
||||||
|
}
|
||||||
|
await this._unrouteInternal(removed, remaining, "default");
|
||||||
|
}
|
||||||
|
async _unrouteInternal(removed, remaining, behavior) {
|
||||||
|
this._routes = remaining;
|
||||||
|
if (behavior && behavior !== "default") {
|
||||||
|
const promises = removed.map((routeHandler) => routeHandler.stop(behavior));
|
||||||
|
await Promise.all(promises);
|
||||||
|
}
|
||||||
|
await this._updateInterceptionPatterns({ title: "Unroute requests" });
|
||||||
|
}
|
||||||
|
async _updateInterceptionPatterns(options) {
|
||||||
|
const patterns = network.RouteHandler.prepareInterceptionPatterns(this._routes);
|
||||||
|
await this._wrapApiCall(() => this._channel.setNetworkInterceptionPatterns({ patterns }), options);
|
||||||
|
}
|
||||||
|
async _updateWebSocketInterceptionPatterns(options) {
|
||||||
|
const patterns = network.WebSocketRouteHandler.prepareInterceptionPatterns(this._webSocketRoutes);
|
||||||
|
await this._wrapApiCall(() => this._channel.setWebSocketInterceptionPatterns({ patterns }), options);
|
||||||
|
}
|
||||||
|
_effectiveCloseReason() {
|
||||||
|
return this._closeReason || this._browser?._closeReason;
|
||||||
|
}
|
||||||
|
async waitForEvent(event, optionsOrPredicate = {}) {
|
||||||
|
return await this._wrapApiCall(async () => {
|
||||||
|
const timeout = this._timeoutSettings.timeout(typeof optionsOrPredicate === "function" ? {} : optionsOrPredicate);
|
||||||
|
const predicate = typeof optionsOrPredicate === "function" ? optionsOrPredicate : optionsOrPredicate.predicate;
|
||||||
|
const waiter = import_waiter.Waiter.createForEvent(this, event);
|
||||||
|
waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`);
|
||||||
|
if (event !== import_events.Events.BrowserContext.Close)
|
||||||
|
waiter.rejectOnEvent(this, import_events.Events.BrowserContext.Close, () => new import_errors.TargetClosedError(this._effectiveCloseReason()));
|
||||||
|
const result = await waiter.waitForEvent(this, event, predicate);
|
||||||
|
waiter.dispose();
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async storageState(options = {}) {
|
||||||
|
const state = await this._channel.storageState({ indexedDB: options.indexedDB });
|
||||||
|
if (options.path) {
|
||||||
|
await (0, import_fileUtils.mkdirIfNeeded)(this._platform, options.path);
|
||||||
|
await this._platform.fs().promises.writeFile(options.path, JSON.stringify(state, void 0, 2), "utf8");
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
backgroundPages() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
serviceWorkers() {
|
||||||
|
return [...this._serviceWorkers];
|
||||||
|
}
|
||||||
|
async newCDPSession(page) {
|
||||||
|
if (!(page instanceof import_page.Page) && !(page instanceof import_frame.Frame))
|
||||||
|
throw new Error("page: expected Page or Frame");
|
||||||
|
const result = await this._channel.newCDPSession(page instanceof import_page.Page ? { page: page._channel } : { frame: page._channel });
|
||||||
|
return import_cdpSession.CDPSession.from(result.session);
|
||||||
|
}
|
||||||
|
_onClose() {
|
||||||
|
this._closingStatus = "closed";
|
||||||
|
this._browser?._contexts.delete(this);
|
||||||
|
this._browser?._browserType._contexts.delete(this);
|
||||||
|
this._browser?._browserType._playwright.selectors._contextsForSelectors.delete(this);
|
||||||
|
this._disposeHarRouters();
|
||||||
|
this.tracing._resetStackCounter();
|
||||||
|
this.emit(import_events.Events.BrowserContext.Close, this);
|
||||||
|
}
|
||||||
|
async [Symbol.asyncDispose]() {
|
||||||
|
await this.close();
|
||||||
|
}
|
||||||
|
async close(options = {}) {
|
||||||
|
if (this._closingStatus !== "none")
|
||||||
|
return;
|
||||||
|
this._closeReason = options.reason;
|
||||||
|
this._closingStatus = "closing";
|
||||||
|
await this.request.dispose(options);
|
||||||
|
await this._instrumentation.runBeforeCloseBrowserContext(this);
|
||||||
|
await this._wrapApiCall(async () => {
|
||||||
|
for (const [harId, harParams] of this._harRecorders) {
|
||||||
|
const har = await this._channel.harExport({ harId });
|
||||||
|
const artifact = import_artifact.Artifact.from(har.artifact);
|
||||||
|
const isCompressed = harParams.content === "attach" || harParams.path.endsWith(".zip");
|
||||||
|
const needCompressed = harParams.path.endsWith(".zip");
|
||||||
|
if (isCompressed && !needCompressed) {
|
||||||
|
const localUtils = this._connection.localUtils();
|
||||||
|
if (!localUtils)
|
||||||
|
throw new Error("Uncompressed har is not supported in thin clients");
|
||||||
|
await artifact.saveAs(harParams.path + ".tmp");
|
||||||
|
await localUtils.harUnzip({ zipFile: harParams.path + ".tmp", harFile: harParams.path });
|
||||||
|
} else {
|
||||||
|
await artifact.saveAs(harParams.path);
|
||||||
|
}
|
||||||
|
await artifact.delete();
|
||||||
|
}
|
||||||
|
}, { internal: true });
|
||||||
|
await this._channel.close(options);
|
||||||
|
await this._closedPromise;
|
||||||
|
}
|
||||||
|
async _enableRecorder(params, eventSink) {
|
||||||
|
if (eventSink)
|
||||||
|
this._onRecorderEventSink = eventSink;
|
||||||
|
await this._channel.enableRecorder(params);
|
||||||
|
}
|
||||||
|
async _disableRecorder() {
|
||||||
|
this._onRecorderEventSink = void 0;
|
||||||
|
await this._channel.disableRecorder();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function prepareStorageState(platform, storageState) {
|
||||||
|
if (typeof storageState !== "string")
|
||||||
|
return storageState;
|
||||||
|
try {
|
||||||
|
return JSON.parse(await platform.fs().promises.readFile(storageState, "utf8"));
|
||||||
|
} catch (e) {
|
||||||
|
(0, import_stackTrace.rewriteErrorMessage)(e, `Error reading storage state from ${storageState}:
|
||||||
|
` + e.message);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function prepareBrowserContextParams(platform, options) {
|
||||||
|
if (options.videoSize && !options.videosPath)
|
||||||
|
throw new Error(`"videoSize" option requires "videosPath" to be specified`);
|
||||||
|
if (options.extraHTTPHeaders)
|
||||||
|
network.validateHeaders(options.extraHTTPHeaders);
|
||||||
|
const contextParams = {
|
||||||
|
...options,
|
||||||
|
viewport: options.viewport === null ? void 0 : options.viewport,
|
||||||
|
noDefaultViewport: options.viewport === null,
|
||||||
|
extraHTTPHeaders: options.extraHTTPHeaders ? (0, import_headers.headersObjectToArray)(options.extraHTTPHeaders) : void 0,
|
||||||
|
storageState: options.storageState ? await prepareStorageState(platform, options.storageState) : void 0,
|
||||||
|
serviceWorkers: options.serviceWorkers,
|
||||||
|
colorScheme: options.colorScheme === null ? "no-override" : options.colorScheme,
|
||||||
|
reducedMotion: options.reducedMotion === null ? "no-override" : options.reducedMotion,
|
||||||
|
forcedColors: options.forcedColors === null ? "no-override" : options.forcedColors,
|
||||||
|
contrast: options.contrast === null ? "no-override" : options.contrast,
|
||||||
|
acceptDownloads: toAcceptDownloadsProtocol(options.acceptDownloads),
|
||||||
|
clientCertificates: await toClientCertificatesProtocol(platform, options.clientCertificates)
|
||||||
|
};
|
||||||
|
if (!contextParams.recordVideo && options.videosPath) {
|
||||||
|
contextParams.recordVideo = {
|
||||||
|
dir: options.videosPath,
|
||||||
|
size: options.videoSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (contextParams.recordVideo && contextParams.recordVideo.dir)
|
||||||
|
contextParams.recordVideo.dir = platform.path().resolve(contextParams.recordVideo.dir);
|
||||||
|
return contextParams;
|
||||||
|
}
|
||||||
|
function toAcceptDownloadsProtocol(acceptDownloads) {
|
||||||
|
if (acceptDownloads === void 0)
|
||||||
|
return void 0;
|
||||||
|
if (acceptDownloads)
|
||||||
|
return "accept";
|
||||||
|
return "deny";
|
||||||
|
}
|
||||||
|
async function toClientCertificatesProtocol(platform, certs) {
|
||||||
|
if (!certs)
|
||||||
|
return void 0;
|
||||||
|
const bufferizeContent = async (value, path) => {
|
||||||
|
if (value)
|
||||||
|
return value;
|
||||||
|
if (path)
|
||||||
|
return await platform.fs().promises.readFile(path);
|
||||||
|
};
|
||||||
|
return await Promise.all(certs.map(async (cert) => ({
|
||||||
|
origin: cert.origin,
|
||||||
|
cert: await bufferizeContent(cert.cert, cert.certPath),
|
||||||
|
key: await bufferizeContent(cert.key, cert.keyPath),
|
||||||
|
pfx: await bufferizeContent(cert.pfx, cert.pfxPath),
|
||||||
|
passphrase: cert.passphrase
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
// Annotate the CommonJS export names for ESM import in node:
|
||||||
|
0 && (module.exports = {
|
||||||
|
BrowserContext,
|
||||||
|
prepareBrowserContextParams,
|
||||||
|
toClientCertificatesProtocol
|
||||||
|
});
|
||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
"use strict";
|
||||||
|
var __defProp = Object.defineProperty;
|
||||||
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||||
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||||
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||||
|
var __export = (target, all) => {
|
||||||
|
for (var name in all)
|
||||||
|
__defProp(target, name, { get: all[name], enumerable: true });
|
||||||
|
};
|
||||||
|
var __copyProps = (to, from, except, desc) => {
|
||||||
|
if (from && typeof from === "object" || typeof from === "function") {
|
||||||
|
for (let key of __getOwnPropNames(from))
|
||||||
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||||
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||||
|
}
|
||||||
|
return to;
|
||||||
|
};
|
||||||
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||||
|
var browserType_exports = {};
|
||||||
|
__export(browserType_exports, {
|
||||||
|
BrowserType: () => BrowserType
|
||||||
|
});
|
||||||
|
module.exports = __toCommonJS(browserType_exports);
|
||||||
|
var import_browser = require("./browser");
|
||||||
|
var import_browserContext = require("./browserContext");
|
||||||
|
var import_channelOwner = require("./channelOwner");
|
||||||
|
var import_clientHelper = require("./clientHelper");
|
||||||
|
var import_events = require("./events");
|
||||||
|
var import_assert = require("../utils/isomorphic/assert");
|
||||||
|
var import_headers = require("../utils/isomorphic/headers");
|
||||||
|
var import_time = require("../utils/isomorphic/time");
|
||||||
|
var import_timeoutRunner = require("../utils/isomorphic/timeoutRunner");
|
||||||
|
var import_webSocket = require("./webSocket");
|
||||||
|
var import_timeoutSettings = require("./timeoutSettings");
|
||||||
|
class BrowserType extends import_channelOwner.ChannelOwner {
|
||||||
|
constructor() {
|
||||||
|
super(...arguments);
|
||||||
|
this._contexts = /* @__PURE__ */ new Set();
|
||||||
|
}
|
||||||
|
static from(browserType) {
|
||||||
|
return browserType._object;
|
||||||
|
}
|
||||||
|
executablePath() {
|
||||||
|
if (!this._initializer.executablePath)
|
||||||
|
throw new Error("Browser is not supported on current platform");
|
||||||
|
return this._initializer.executablePath;
|
||||||
|
}
|
||||||
|
name() {
|
||||||
|
return this._initializer.name;
|
||||||
|
}
|
||||||
|
async launch(options = {}) {
|
||||||
|
(0, import_assert.assert)(!options.userDataDir, "userDataDir option is not supported in `browserType.launch`. Use `browserType.launchPersistentContext` instead");
|
||||||
|
(0, import_assert.assert)(!options.port, "Cannot specify a port without launching as a server.");
|
||||||
|
const logger = options.logger || this._playwright._defaultLaunchOptions?.logger;
|
||||||
|
options = { ...this._playwright._defaultLaunchOptions, ...options };
|
||||||
|
const launchOptions = {
|
||||||
|
...options,
|
||||||
|
ignoreDefaultArgs: Array.isArray(options.ignoreDefaultArgs) ? options.ignoreDefaultArgs : void 0,
|
||||||
|
ignoreAllDefaultArgs: !!options.ignoreDefaultArgs && !Array.isArray(options.ignoreDefaultArgs),
|
||||||
|
env: options.env ? (0, import_clientHelper.envObjectToArray)(options.env) : void 0,
|
||||||
|
timeout: new import_timeoutSettings.TimeoutSettings(this._platform).launchTimeout(options)
|
||||||
|
};
|
||||||
|
return await this._wrapApiCall(async () => {
|
||||||
|
const browser = import_browser.Browser.from((await this._channel.launch(launchOptions)).browser);
|
||||||
|
browser._connectToBrowserType(this, options, logger);
|
||||||
|
return browser;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async launchServer(options = {}) {
|
||||||
|
if (!this._serverLauncher)
|
||||||
|
throw new Error("Launching server is not supported");
|
||||||
|
options = { ...this._playwright._defaultLaunchOptions, ...options };
|
||||||
|
return await this._serverLauncher.launchServer(options);
|
||||||
|
}
|
||||||
|
async launchPersistentContext(userDataDir, options = {}) {
|
||||||
|
const logger = options.logger || this._playwright._defaultLaunchOptions?.logger;
|
||||||
|
(0, import_assert.assert)(!options.port, "Cannot specify a port without launching as a server.");
|
||||||
|
options = this._playwright.selectors._withSelectorOptions({
|
||||||
|
...this._playwright._defaultLaunchOptions,
|
||||||
|
...this._playwright._defaultContextOptions,
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
const contextParams = await (0, import_browserContext.prepareBrowserContextParams)(this._platform, options);
|
||||||
|
const persistentParams = {
|
||||||
|
...contextParams,
|
||||||
|
ignoreDefaultArgs: Array.isArray(options.ignoreDefaultArgs) ? options.ignoreDefaultArgs : void 0,
|
||||||
|
ignoreAllDefaultArgs: !!options.ignoreDefaultArgs && !Array.isArray(options.ignoreDefaultArgs),
|
||||||
|
env: options.env ? (0, import_clientHelper.envObjectToArray)(options.env) : void 0,
|
||||||
|
channel: options.channel,
|
||||||
|
userDataDir: this._platform.path().isAbsolute(userDataDir) || !userDataDir ? userDataDir : this._platform.path().resolve(userDataDir),
|
||||||
|
timeout: new import_timeoutSettings.TimeoutSettings(this._platform).launchTimeout(options)
|
||||||
|
};
|
||||||
|
const context = await this._wrapApiCall(async () => {
|
||||||
|
const result = await this._channel.launchPersistentContext(persistentParams);
|
||||||
|
const browser = import_browser.Browser.from(result.browser);
|
||||||
|
browser._connectToBrowserType(this, options, logger);
|
||||||
|
const context2 = import_browserContext.BrowserContext.from(result.context);
|
||||||
|
await context2._initializeHarFromOptions(options.recordHar);
|
||||||
|
return context2;
|
||||||
|
});
|
||||||
|
await this._instrumentation.runAfterCreateBrowserContext(context);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
async connect(optionsOrWsEndpoint, options) {
|
||||||
|
if (typeof optionsOrWsEndpoint === "string")
|
||||||
|
return await this._connect({ ...options, wsEndpoint: optionsOrWsEndpoint });
|
||||||
|
(0, import_assert.assert)(optionsOrWsEndpoint.wsEndpoint, "options.wsEndpoint is required");
|
||||||
|
return await this._connect(optionsOrWsEndpoint);
|
||||||
|
}
|
||||||
|
async _connect(params) {
|
||||||
|
const logger = params.logger;
|
||||||
|
return await this._wrapApiCall(async () => {
|
||||||
|
const deadline = params.timeout ? (0, import_time.monotonicTime)() + params.timeout : 0;
|
||||||
|
const headers = { "x-playwright-browser": this.name(), ...params.headers };
|
||||||
|
const connectParams = {
|
||||||
|
wsEndpoint: params.wsEndpoint,
|
||||||
|
headers,
|
||||||
|
exposeNetwork: params.exposeNetwork ?? params._exposeNetwork,
|
||||||
|
slowMo: params.slowMo,
|
||||||
|
timeout: params.timeout || 0
|
||||||
|
};
|
||||||
|
if (params.__testHookRedirectPortForwarding)
|
||||||
|
connectParams.socksProxyRedirectPortForTest = params.__testHookRedirectPortForwarding;
|
||||||
|
const connection = await (0, import_webSocket.connectOverWebSocket)(this._connection, connectParams);
|
||||||
|
let browser;
|
||||||
|
connection.on("close", () => {
|
||||||
|
for (const context of browser?.contexts() || []) {
|
||||||
|
for (const page of context.pages())
|
||||||
|
page._onClose();
|
||||||
|
context._onClose();
|
||||||
|
}
|
||||||
|
setTimeout(() => browser?._didClose(), 0);
|
||||||
|
});
|
||||||
|
const result = await (0, import_timeoutRunner.raceAgainstDeadline)(async () => {
|
||||||
|
if (params.__testHookBeforeCreateBrowser)
|
||||||
|
await params.__testHookBeforeCreateBrowser();
|
||||||
|
const playwright = await connection.initializePlaywright();
|
||||||
|
if (!playwright._initializer.preLaunchedBrowser) {
|
||||||
|
connection.close();
|
||||||
|
throw new Error("Malformed endpoint. Did you use BrowserType.launchServer method?");
|
||||||
|
}
|
||||||
|
playwright.selectors = this._playwright.selectors;
|
||||||
|
browser = import_browser.Browser.from(playwright._initializer.preLaunchedBrowser);
|
||||||
|
browser._connectToBrowserType(this, {}, logger);
|
||||||
|
browser._shouldCloseConnectionOnClose = true;
|
||||||
|
browser.on(import_events.Events.Browser.Disconnected, () => connection.close());
|
||||||
|
return browser;
|
||||||
|
}, deadline);
|
||||||
|
if (!result.timedOut) {
|
||||||
|
return result.result;
|
||||||
|
} else {
|
||||||
|
connection.close();
|
||||||
|
throw new Error(`Timeout ${params.timeout}ms exceeded`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async connectOverCDP(endpointURLOrOptions, options) {
|
||||||
|
if (typeof endpointURLOrOptions === "string")
|
||||||
|
return await this._connectOverCDP(endpointURLOrOptions, options);
|
||||||
|
const endpointURL = "endpointURL" in endpointURLOrOptions ? endpointURLOrOptions.endpointURL : endpointURLOrOptions.wsEndpoint;
|
||||||
|
(0, import_assert.assert)(endpointURL, "Cannot connect over CDP without wsEndpoint.");
|
||||||
|
return await this.connectOverCDP(endpointURL, endpointURLOrOptions);
|
||||||
|
}
|
||||||
|
async _connectOverCDP(endpointURL, params = {}) {
|
||||||
|
if (this.name() !== "chromium")
|
||||||
|
throw new Error("Connecting over CDP is only supported in Chromium.");
|
||||||
|
const headers = params.headers ? (0, import_headers.headersObjectToArray)(params.headers) : void 0;
|
||||||
|
const result = await this._channel.connectOverCDP({
|
||||||
|
endpointURL,
|
||||||
|
headers,
|
||||||
|
slowMo: params.slowMo,
|
||||||
|
timeout: new import_timeoutSettings.TimeoutSettings(this._platform).timeout(params)
|
||||||
|
});
|
||||||
|
const browser = import_browser.Browser.from(result.browser);
|
||||||
|
browser._connectToBrowserType(this, {}, params.logger);
|
||||||
|
if (result.defaultContext)
|
||||||
|
await this._instrumentation.runAfterCreateBrowserContext(import_browserContext.BrowserContext.from(result.defaultContext));
|
||||||
|
return browser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Annotate the CommonJS export names for ESM import in node:
|
||||||
|
0 && (module.exports = {
|
||||||
|
BrowserType
|
||||||
|
});
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
"use strict";
|
||||||
|
var __defProp = Object.defineProperty;
|
||||||
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||||
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||||
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||||
|
var __export = (target, all) => {
|
||||||
|
for (var name in all)
|
||||||
|
__defProp(target, name, { get: all[name], enumerable: true });
|
||||||
|
};
|
||||||
|
var __copyProps = (to, from, except, desc) => {
|
||||||
|
if (from && typeof from === "object" || typeof from === "function") {
|
||||||
|
for (let key of __getOwnPropNames(from))
|
||||||
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||||
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||||
|
}
|
||||||
|
return to;
|
||||||
|
};
|
||||||
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||||
|
var cdpSession_exports = {};
|
||||||
|
__export(cdpSession_exports, {
|
||||||
|
CDPSession: () => CDPSession
|
||||||
|
});
|
||||||
|
module.exports = __toCommonJS(cdpSession_exports);
|
||||||
|
var import_channelOwner = require("./channelOwner");
|
||||||
|
class CDPSession extends import_channelOwner.ChannelOwner {
|
||||||
|
static from(cdpSession) {
|
||||||
|
return cdpSession._object;
|
||||||
|
}
|
||||||
|
constructor(parent, type, guid, initializer) {
|
||||||
|
super(parent, type, guid, initializer);
|
||||||
|
this._channel.on("event", ({ method, params }) => {
|
||||||
|
this.emit(method, params);
|
||||||
|
});
|
||||||
|
this.on = super.on;
|
||||||
|
this.addListener = super.addListener;
|
||||||
|
this.off = super.removeListener;
|
||||||
|
this.removeListener = super.removeListener;
|
||||||
|
this.once = super.once;
|
||||||
|
}
|
||||||
|
async send(method, params) {
|
||||||
|
const result = await this._channel.send({ method, params });
|
||||||
|
return result.result;
|
||||||
|
}
|
||||||
|
async detach() {
|
||||||
|
return await this._channel.detach();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Annotate the CommonJS export names for ESM import in node:
|
||||||
|
0 && (module.exports = {
|
||||||
|
CDPSession
|
||||||
|
});
|
||||||
+194
@@ -0,0 +1,194 @@
|
|||||||
|
"use strict";
|
||||||
|
var __defProp = Object.defineProperty;
|
||||||
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||||
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||||
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||||
|
var __export = (target, all) => {
|
||||||
|
for (var name in all)
|
||||||
|
__defProp(target, name, { get: all[name], enumerable: true });
|
||||||
|
};
|
||||||
|
var __copyProps = (to, from, except, desc) => {
|
||||||
|
if (from && typeof from === "object" || typeof from === "function") {
|
||||||
|
for (let key of __getOwnPropNames(from))
|
||||||
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||||
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||||
|
}
|
||||||
|
return to;
|
||||||
|
};
|
||||||
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||||
|
var channelOwner_exports = {};
|
||||||
|
__export(channelOwner_exports, {
|
||||||
|
ChannelOwner: () => ChannelOwner
|
||||||
|
});
|
||||||
|
module.exports = __toCommonJS(channelOwner_exports);
|
||||||
|
var import_eventEmitter = require("./eventEmitter");
|
||||||
|
var import_validator = require("../protocol/validator");
|
||||||
|
var import_protocolMetainfo = require("../utils/isomorphic/protocolMetainfo");
|
||||||
|
var import_clientStackTrace = require("./clientStackTrace");
|
||||||
|
var import_stackTrace = require("../utils/isomorphic/stackTrace");
|
||||||
|
class ChannelOwner extends import_eventEmitter.EventEmitter {
|
||||||
|
constructor(parent, type, guid, initializer) {
|
||||||
|
const connection = parent instanceof ChannelOwner ? parent._connection : parent;
|
||||||
|
super(connection._platform);
|
||||||
|
this._objects = /* @__PURE__ */ new Map();
|
||||||
|
this._eventToSubscriptionMapping = /* @__PURE__ */ new Map();
|
||||||
|
this._wasCollected = false;
|
||||||
|
this.setMaxListeners(0);
|
||||||
|
this._connection = connection;
|
||||||
|
this._type = type;
|
||||||
|
this._guid = guid;
|
||||||
|
this._parent = parent instanceof ChannelOwner ? parent : void 0;
|
||||||
|
this._instrumentation = this._connection._instrumentation;
|
||||||
|
this._connection._objects.set(guid, this);
|
||||||
|
if (this._parent) {
|
||||||
|
this._parent._objects.set(guid, this);
|
||||||
|
this._logger = this._parent._logger;
|
||||||
|
}
|
||||||
|
this._channel = this._createChannel(new import_eventEmitter.EventEmitter(connection._platform));
|
||||||
|
this._initializer = initializer;
|
||||||
|
}
|
||||||
|
_setEventToSubscriptionMapping(mapping) {
|
||||||
|
this._eventToSubscriptionMapping = mapping;
|
||||||
|
}
|
||||||
|
_updateSubscription(event, enabled) {
|
||||||
|
const protocolEvent = this._eventToSubscriptionMapping.get(String(event));
|
||||||
|
if (protocolEvent)
|
||||||
|
this._channel.updateSubscription({ event: protocolEvent, enabled }).catch(() => {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
on(event, listener) {
|
||||||
|
if (!this.listenerCount(event))
|
||||||
|
this._updateSubscription(event, true);
|
||||||
|
super.on(event, listener);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
addListener(event, listener) {
|
||||||
|
if (!this.listenerCount(event))
|
||||||
|
this._updateSubscription(event, true);
|
||||||
|
super.addListener(event, listener);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
prependListener(event, listener) {
|
||||||
|
if (!this.listenerCount(event))
|
||||||
|
this._updateSubscription(event, true);
|
||||||
|
super.prependListener(event, listener);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
off(event, listener) {
|
||||||
|
super.off(event, listener);
|
||||||
|
if (!this.listenerCount(event))
|
||||||
|
this._updateSubscription(event, false);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
removeListener(event, listener) {
|
||||||
|
super.removeListener(event, listener);
|
||||||
|
if (!this.listenerCount(event))
|
||||||
|
this._updateSubscription(event, false);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
_adopt(child) {
|
||||||
|
child._parent._objects.delete(child._guid);
|
||||||
|
this._objects.set(child._guid, child);
|
||||||
|
child._parent = this;
|
||||||
|
}
|
||||||
|
_dispose(reason) {
|
||||||
|
if (this._parent)
|
||||||
|
this._parent._objects.delete(this._guid);
|
||||||
|
this._connection._objects.delete(this._guid);
|
||||||
|
this._wasCollected = reason === "gc";
|
||||||
|
for (const object of [...this._objects.values()])
|
||||||
|
object._dispose(reason);
|
||||||
|
this._objects.clear();
|
||||||
|
}
|
||||||
|
_debugScopeState() {
|
||||||
|
return {
|
||||||
|
_guid: this._guid,
|
||||||
|
objects: Array.from(this._objects.values()).map((o) => o._debugScopeState())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_validatorToWireContext() {
|
||||||
|
return {
|
||||||
|
tChannelImpl: tChannelImplToWire,
|
||||||
|
binary: this._connection.rawBuffers() ? "buffer" : "toBase64",
|
||||||
|
isUnderTest: () => this._platform.isUnderTest()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_createChannel(base) {
|
||||||
|
const channel = new Proxy(base, {
|
||||||
|
get: (obj, prop) => {
|
||||||
|
if (typeof prop === "string") {
|
||||||
|
const validator = (0, import_validator.maybeFindValidator)(this._type, prop, "Params");
|
||||||
|
const { internal } = import_protocolMetainfo.methodMetainfo.get(this._type + "." + prop) || {};
|
||||||
|
if (validator) {
|
||||||
|
return async (params) => {
|
||||||
|
return await this._wrapApiCall(async (apiZone) => {
|
||||||
|
const validatedParams = validator(params, "", this._validatorToWireContext());
|
||||||
|
if (!apiZone.internal && !apiZone.reported) {
|
||||||
|
apiZone.reported = true;
|
||||||
|
this._instrumentation.onApiCallBegin(apiZone, { type: this._type, method: prop, params });
|
||||||
|
logApiCall(this._platform, this._logger, `=> ${apiZone.apiName} started`);
|
||||||
|
return await this._connection.sendMessageToServer(this, prop, validatedParams, apiZone);
|
||||||
|
}
|
||||||
|
return await this._connection.sendMessageToServer(this, prop, validatedParams, { internal: true });
|
||||||
|
}, { internal });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return obj[prop];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
channel._object = this;
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
async _wrapApiCall(func, options) {
|
||||||
|
const logger = this._logger;
|
||||||
|
const existingApiZone = this._platform.zones.current().data();
|
||||||
|
if (existingApiZone)
|
||||||
|
return await func(existingApiZone);
|
||||||
|
const stackTrace = (0, import_clientStackTrace.captureLibraryStackTrace)(this._platform);
|
||||||
|
const apiZone = { title: options?.title, apiName: stackTrace.apiName, frames: stackTrace.frames, internal: options?.internal ?? false, reported: false, userData: void 0, stepId: void 0 };
|
||||||
|
try {
|
||||||
|
const result = await this._platform.zones.current().push(apiZone).run(async () => await func(apiZone));
|
||||||
|
if (!options?.internal) {
|
||||||
|
logApiCall(this._platform, logger, `<= ${apiZone.apiName} succeeded`);
|
||||||
|
this._instrumentation.onApiCallEnd(apiZone);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
const innerError = (this._platform.showInternalStackFrames() || this._platform.isUnderTest()) && e.stack ? "\n<inner error>\n" + e.stack : "";
|
||||||
|
if (apiZone.apiName && !apiZone.apiName.includes("<anonymous>"))
|
||||||
|
e.message = apiZone.apiName + ": " + e.message;
|
||||||
|
const stackFrames = "\n" + (0, import_stackTrace.stringifyStackFrames)(stackTrace.frames).join("\n") + innerError;
|
||||||
|
if (stackFrames.trim())
|
||||||
|
e.stack = e.message + stackFrames;
|
||||||
|
else
|
||||||
|
e.stack = "";
|
||||||
|
if (!options?.internal) {
|
||||||
|
apiZone.error = e;
|
||||||
|
logApiCall(this._platform, logger, `<= ${apiZone.apiName} failed`);
|
||||||
|
this._instrumentation.onApiCallEnd(apiZone);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
_type: this._type,
|
||||||
|
_guid: this._guid
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function logApiCall(platform, logger, message) {
|
||||||
|
if (logger && logger.isEnabled("api", "info"))
|
||||||
|
logger.log("api", "info", message, [], { color: "cyan" });
|
||||||
|
platform.log("api", message);
|
||||||
|
}
|
||||||
|
function tChannelImplToWire(names, arg, path, context) {
|
||||||
|
if (arg._object instanceof ChannelOwner && (names === "*" || names.includes(arg._object._type)))
|
||||||
|
return { guid: arg._object._guid };
|
||||||
|
throw new import_validator.ValidationError(`${path}: expected channel ${names.toString()}`);
|
||||||
|
}
|
||||||
|
// Annotate the CommonJS export names for ESM import in node:
|
||||||
|
0 && (module.exports = {
|
||||||
|
ChannelOwner
|
||||||
|
});
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
"use strict";
|
||||||
|
var __defProp = Object.defineProperty;
|
||||||
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||||
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||||
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||||
|
var __export = (target, all) => {
|
||||||
|
for (var name in all)
|
||||||
|
__defProp(target, name, { get: all[name], enumerable: true });
|
||||||
|
};
|
||||||
|
var __copyProps = (to, from, except, desc) => {
|
||||||
|
if (from && typeof from === "object" || typeof from === "function") {
|
||||||
|
for (let key of __getOwnPropNames(from))
|
||||||
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||||
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||||
|
}
|
||||||
|
return to;
|
||||||
|
};
|
||||||
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||||
|
var clientHelper_exports = {};
|
||||||
|
__export(clientHelper_exports, {
|
||||||
|
addSourceUrlToScript: () => addSourceUrlToScript,
|
||||||
|
envObjectToArray: () => envObjectToArray,
|
||||||
|
evaluationScript: () => evaluationScript
|
||||||
|
});
|
||||||
|
module.exports = __toCommonJS(clientHelper_exports);
|
||||||
|
var import_rtti = require("../utils/isomorphic/rtti");
|
||||||
|
function envObjectToArray(env) {
|
||||||
|
const result = [];
|
||||||
|
for (const name in env) {
|
||||||
|
if (!Object.is(env[name], void 0))
|
||||||
|
result.push({ name, value: String(env[name]) });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
async function evaluationScript(platform, fun, arg, addSourceUrl = true) {
|
||||||
|
if (typeof fun === "function") {
|
||||||
|
const source = fun.toString();
|
||||||
|
const argString = Object.is(arg, void 0) ? "undefined" : JSON.stringify(arg);
|
||||||
|
return `(${source})(${argString})`;
|
||||||
|
}
|
||||||
|
if (arg !== void 0)
|
||||||
|
throw new Error("Cannot evaluate a string with arguments");
|
||||||
|
if ((0, import_rtti.isString)(fun))
|
||||||
|
return fun;
|
||||||
|
if (fun.content !== void 0)
|
||||||
|
return fun.content;
|
||||||
|
if (fun.path !== void 0) {
|
||||||
|
let source = await platform.fs().promises.readFile(fun.path, "utf8");
|
||||||
|
if (addSourceUrl)
|
||||||
|
source = addSourceUrlToScript(source, fun.path);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
throw new Error("Either path or content property must be present");
|
||||||
|
}
|
||||||
|
function addSourceUrlToScript(source, path) {
|
||||||
|
return `${source}
|
||||||
|
//# sourceURL=${path.replace(/\n/g, "")}`;
|
||||||
|
}
|
||||||
|
// Annotate the CommonJS export names for ESM import in node:
|
||||||
|
0 && (module.exports = {
|
||||||
|
addSourceUrlToScript,
|
||||||
|
envObjectToArray,
|
||||||
|
evaluationScript
|
||||||
|
});
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
"use strict";
|
||||||
|
var __defProp = Object.defineProperty;
|
||||||
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||||
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||||
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||||
|
var __export = (target, all) => {
|
||||||
|
for (var name in all)
|
||||||
|
__defProp(target, name, { get: all[name], enumerable: true });
|
||||||
|
};
|
||||||
|
var __copyProps = (to, from, except, desc) => {
|
||||||
|
if (from && typeof from === "object" || typeof from === "function") {
|
||||||
|
for (let key of __getOwnPropNames(from))
|
||||||
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||||
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||||
|
}
|
||||||
|
return to;
|
||||||
|
};
|
||||||
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||||
|
var clientInstrumentation_exports = {};
|
||||||
|
__export(clientInstrumentation_exports, {
|
||||||
|
createInstrumentation: () => createInstrumentation
|
||||||
|
});
|
||||||
|
module.exports = __toCommonJS(clientInstrumentation_exports);
|
||||||
|
function createInstrumentation() {
|
||||||
|
const listeners = [];
|
||||||
|
return new Proxy({}, {
|
||||||
|
get: (obj, prop) => {
|
||||||
|
if (typeof prop !== "string")
|
||||||
|
return obj[prop];
|
||||||
|
if (prop === "addListener")
|
||||||
|
return (listener) => listeners.push(listener);
|
||||||
|
if (prop === "removeListener")
|
||||||
|
return (listener) => listeners.splice(listeners.indexOf(listener), 1);
|
||||||
|
if (prop === "removeAllListeners")
|
||||||
|
return () => listeners.splice(0, listeners.length);
|
||||||
|
if (prop.startsWith("run")) {
|
||||||
|
return async (...params) => {
|
||||||
|
for (const listener of listeners)
|
||||||
|
await listener[prop]?.(...params);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (prop.startsWith("on")) {
|
||||||
|
return (...params) => {
|
||||||
|
for (const listener of listeners)
|
||||||
|
listener[prop]?.(...params);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return obj[prop];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Annotate the CommonJS export names for ESM import in node:
|
||||||
|
0 && (module.exports = {
|
||||||
|
createInstrumentation
|
||||||
|
});
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
"use strict";
|
||||||
|
var __defProp = Object.defineProperty;
|
||||||
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||||
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||||
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||||
|
var __export = (target, all) => {
|
||||||
|
for (var name in all)
|
||||||
|
__defProp(target, name, { get: all[name], enumerable: true });
|
||||||
|
};
|
||||||
|
var __copyProps = (to, from, except, desc) => {
|
||||||
|
if (from && typeof from === "object" || typeof from === "function") {
|
||||||
|
for (let key of __getOwnPropNames(from))
|
||||||
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||||
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||||
|
}
|
||||||
|
return to;
|
||||||
|
};
|
||||||
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||||
|
var clientStackTrace_exports = {};
|
||||||
|
__export(clientStackTrace_exports, {
|
||||||
|
captureLibraryStackTrace: () => captureLibraryStackTrace
|
||||||
|
});
|
||||||
|
module.exports = __toCommonJS(clientStackTrace_exports);
|
||||||
|
var import_stackTrace = require("../utils/isomorphic/stackTrace");
|
||||||
|
function captureLibraryStackTrace(platform) {
|
||||||
|
const stack = (0, import_stackTrace.captureRawStack)();
|
||||||
|
let parsedFrames = stack.map((line) => {
|
||||||
|
const frame = (0, import_stackTrace.parseStackFrame)(line, platform.pathSeparator, platform.showInternalStackFrames());
|
||||||
|
if (!frame || !frame.file)
|
||||||
|
return null;
|
||||||
|
const isPlaywrightLibrary = !!platform.coreDir && frame.file.startsWith(platform.coreDir);
|
||||||
|
const parsed = {
|
||||||
|
frame,
|
||||||
|
frameText: line,
|
||||||
|
isPlaywrightLibrary
|
||||||
|
};
|
||||||
|
return parsed;
|
||||||
|
}).filter(Boolean);
|
||||||
|
let apiName = "";
|
||||||
|
for (let i = 0; i < parsedFrames.length - 1; i++) {
|
||||||
|
const parsedFrame = parsedFrames[i];
|
||||||
|
if (parsedFrame.isPlaywrightLibrary && !parsedFrames[i + 1].isPlaywrightLibrary) {
|
||||||
|
apiName = apiName || normalizeAPIName(parsedFrame.frame.function);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function normalizeAPIName(name) {
|
||||||
|
if (!name)
|
||||||
|
return "";
|
||||||
|
const match = name.match(/(API|JS|CDP|[A-Z])(.*)/);
|
||||||
|
if (!match)
|
||||||
|
return name;
|
||||||
|
return match[1].toLowerCase() + match[2];
|
||||||
|
}
|
||||||
|
const filterPrefixes = platform.boxedStackPrefixes();
|
||||||
|
parsedFrames = parsedFrames.filter((f) => {
|
||||||
|
if (filterPrefixes.some((prefix) => f.frame.file.startsWith(prefix)))
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
frames: parsedFrames.map((p) => p.frame),
|
||||||
|
apiName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Annotate the CommonJS export names for ESM import in node:
|
||||||
|
0 && (module.exports = {
|
||||||
|
captureLibraryStackTrace
|
||||||
|
});
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
"use strict";
|
||||||
|
var __defProp = Object.defineProperty;
|
||||||
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||||
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||||
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||||
|
var __export = (target, all) => {
|
||||||
|
for (var name in all)
|
||||||
|
__defProp(target, name, { get: all[name], enumerable: true });
|
||||||
|
};
|
||||||
|
var __copyProps = (to, from, except, desc) => {
|
||||||
|
if (from && typeof from === "object" || typeof from === "function") {
|
||||||
|
for (let key of __getOwnPropNames(from))
|
||||||
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||||
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||||
|
}
|
||||||
|
return to;
|
||||||
|
};
|
||||||
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||||
|
var clock_exports = {};
|
||||||
|
__export(clock_exports, {
|
||||||
|
Clock: () => Clock
|
||||||
|
});
|
||||||
|
module.exports = __toCommonJS(clock_exports);
|
||||||
|
class Clock {
|
||||||
|
constructor(browserContext) {
|
||||||
|
this._browserContext = browserContext;
|
||||||
|
}
|
||||||
|
async install(options = {}) {
|
||||||
|
await this._browserContext._channel.clockInstall(options.time !== void 0 ? parseTime(options.time) : {});
|
||||||
|
}
|
||||||
|
async fastForward(ticks) {
|
||||||
|
await this._browserContext._channel.clockFastForward(parseTicks(ticks));
|
||||||
|
}
|
||||||
|
async pauseAt(time) {
|
||||||
|
await this._browserContext._channel.clockPauseAt(parseTime(time));
|
||||||
|
}
|
||||||
|
async resume() {
|
||||||
|
await this._browserContext._channel.clockResume({});
|
||||||
|
}
|
||||||
|
async runFor(ticks) {
|
||||||
|
await this._browserContext._channel.clockRunFor(parseTicks(ticks));
|
||||||
|
}
|
||||||
|
async setFixedTime(time) {
|
||||||
|
await this._browserContext._channel.clockSetFixedTime(parseTime(time));
|
||||||
|
}
|
||||||
|
async setSystemTime(time) {
|
||||||
|
await this._browserContext._channel.clockSetSystemTime(parseTime(time));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function parseTime(time) {
|
||||||
|
if (typeof time === "number")
|
||||||
|
return { timeNumber: time };
|
||||||
|
if (typeof time === "string")
|
||||||
|
return { timeString: time };
|
||||||
|
if (!isFinite(time.getTime()))
|
||||||
|
throw new Error(`Invalid date: ${time}`);
|
||||||
|
return { timeNumber: time.getTime() };
|
||||||
|
}
|
||||||
|
function parseTicks(ticks) {
|
||||||
|
return {
|
||||||
|
ticksNumber: typeof ticks === "number" ? ticks : void 0,
|
||||||
|
ticksString: typeof ticks === "string" ? ticks : void 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Annotate the CommonJS export names for ESM import in node:
|
||||||
|
0 && (module.exports = {
|
||||||
|
Clock
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user