Compare commits

2 Commits

Author SHA1 Message Date
naomi c829ec97c4 feat: testy 2026-02-02 21:15:34 -08:00
naomi dac875c413 feat: add adb scripts 2026-02-02 18:32:18 -08:00
26 changed files with 2740 additions and 19 deletions
+6
View File
@@ -15,3 +15,9 @@ Thumbs.db
# IDE
.vscode/
.idea/
# Coverage
coverage/
.coverage
htmlcov/
*.lcov
+42 -6
View File
@@ -1,4 +1,4 @@
.PHONY: help install install-ts install-py build lint lint-ts lint-py format format-py format-check format-check-py test clean run
.PHONY: help install install-ts install-py build lint lint-ts lint-py format format-py format-check format-check-py test test-ts test-py coverage coverage-ts coverage-py clean run
# Default target - show help
help:
@@ -12,11 +12,17 @@ help:
@echo " make lint-py - Run Python linter only"
@echo " make format - Format Python code"
@echo " make format-check - Check Python formatting without modifying"
@echo " make test - Run tests"
@echo " make test - Run all tests (TypeScript and Python)"
@echo " make test-ts - Run TypeScript tests only"
@echo " make test-py - Run Python tests only"
@echo " make coverage - Run all tests with coverage"
@echo " make coverage-ts - Run TypeScript tests with coverage"
@echo " make coverage-py - Run Python tests with coverage"
@echo " make clean - Clean build artifacts and caches"
@echo ""
@echo "Running scripts:"
@echo " make run - Interactive script runner (select language, category, script)"
@echo " make run-bash - Run a bash script (e.g., make run-bash SCRIPT=bash/adb/push.sh)"
# Install all dependencies
install: install-ts install-py
@@ -60,10 +66,27 @@ format-check: format-check-py
format-check-py:
cd python && uv run ruff format --check .
# Run tests
test:
@echo "No tests configured yet"
@exit 0
# Run all tests
test: test-ts test-py
# Run TypeScript tests
test-ts:
cd typescript && pnpm test
# Run Python tests
test-py:
cd python && uv run pytest -v
# Run all tests with coverage
coverage: coverage-ts coverage-py
# Run TypeScript tests with coverage
coverage-ts:
cd typescript && pnpm test:coverage
# Run Python tests with coverage
coverage-py:
cd python && uv run pytest --cov=. --cov-report=term-missing -v
# Clean build artifacts and caches
clean:
@@ -76,3 +99,16 @@ clean:
# Interactive script runner
run:
@./run.sh
# Run a specific bash script
run-bash:
@if [ -z "$(SCRIPT)" ]; then \
echo "Please specify a script: make run-bash SCRIPT=bash/adb/push.sh"; \
exit 1; \
fi
@if [ ! -f "$(SCRIPT)" ]; then \
echo "Script not found: $(SCRIPT)"; \
exit 1; \
fi
@echo "Running bash script: $(SCRIPT)"
@op run --env-file=prod.env --no-masking -- bash "$(SCRIPT)"
+150
View File
@@ -0,0 +1,150 @@
# ADB File Transfer Scripts
Easy-to-use bash scripts for transferring files between your computer and Android device using ADB.
## Prerequisites
- Android Debug Bridge (ADB) installed and in your PATH
- USB debugging enabled on your Android device
- Device connected via USB cable
### Installing ADB
**Ubuntu/Debian:**
```bash
sudo apt update
sudo apt install android-tools-adb
```
**macOS:**
```bash
brew install android-platform-tools
```
**Windows:**
Download from [Android Developer website](https://developer.android.com/studio/releases/platform-tools)
## Scripts Overview
### 🚀 push.sh - Push files to Android
Transfer files from your computer to your Android device.
**Usage:**
```bash
# Interactive mode (recommended)
./push.sh
# Direct mode
./push.sh ~/Documents/file.pdf /sdcard/Download/
./push.sh ~/Pictures/vacation/ /sdcard/Pictures/
```
**Features:**
- Interactive mode with common destination suggestions
- Automatic path validation
- Directory creation if needed
- Progress feedback
- Support for both files and directories
### 📥 pull.sh - Pull files from Android
Transfer files from your Android device to your computer.
**Usage:**
```bash
# Interactive mode (recommended)
./pull.sh
# Direct mode
./pull.sh /sdcard/DCIM/Camera/ ~/Pictures/phone-backup/
./pull.sh /sdcard/Download/document.pdf ~/Downloads/
```
**Features:**
- Browse Android filesystem interactively
- Quick access to common folders
- File count and size summary
- Creates destination directories automatically
- Support for both files and directories
### 📱 adb-transfer.sh - All-in-One Menu
Interactive menu system for all file transfer operations.
**Usage:**
```bash
./adb-transfer.sh
```
**Features:**
- Device status and information display
- Quick actions (backup photos, screenshots, WhatsApp media)
- Access to push/pull scripts
- Device information viewer
- Beautiful CLI interface
## Common Android Paths
- `/sdcard/Download/` - Downloads folder
- `/sdcard/DCIM/Camera/` - Camera photos and videos
- `/sdcard/Pictures/` - General pictures folder
- `/sdcard/Screenshots/` - Screenshots (varies by device)
- `/sdcard/WhatsApp/Media/` - WhatsApp media files
- `/sdcard/Documents/` - Documents folder
- `/sdcard/Music/` - Music files
- `/sdcard/Movies/` - Video files
## Making Scripts Executable
First time setup:
```bash
chmod +x push.sh pull.sh adb-transfer.sh
```
## Examples
### Backup all camera photos
```bash
./pull.sh /sdcard/DCIM/Camera/ ~/Pictures/android-backup/
```
### Push multiple PDFs to Downloads
```bash
./push.sh ~/Documents/*.pdf /sdcard/Download/
```
### Interactive file browser
```bash
./pull.sh
# Then select option 1 to browse filesystem
```
### Quick backup using menu
```bash
./adb-transfer.sh
# Select option 3 for quick actions
# Select option 1 to backup all camera photos
```
## Troubleshooting
### "No device connected" error
1. Check USB cable connection
2. Enable USB debugging: Settings → Developer options → USB debugging
3. Accept the authorization prompt on your phone
4. Try `adb devices` to verify connection
### "Permission denied" errors
- Some system directories require root access
- Stick to `/sdcard/` paths for normal usage
### Slow transfer speeds
- Use USB 3.0 ports and cables when possible
- Large files/directories take time - the scripts show progress
## Tips
1. **Use interactive mode** - It's easier and prevents typos
2. **Backup regularly** - Use the quick actions menu for easy backups
3. **Check free space** - Use device info option to see available storage
4. **Organize transfers** - The scripts create timestamped folders for backups
## Created with 💕 by Naomi & Hikari
+225
View File
@@ -0,0 +1,225 @@
#!/bin/bash
# Interactive ADB file transfer menu
# Author: Naomi Carrigan & Hikari 💕
set -euo pipefail
# Color codes for pretty output
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
NC='\033[0m' # No Color
# ASCII art banner
banner() {
echo -e "${CYAN}"
echo "╔═══════════════════════════════════╗"
echo "║ 📱 ADB File Transfer 📱 ║"
echo "║ Made with 💕 by Hikari ║"
echo "╚═══════════════════════════════════╝"
echo -e "${NC}"
}
# Check if ADB is available and device connected
check_adb_status() {
if ! command -v adb &> /dev/null; then
echo -e "${RED}❌ ADB not found${NC}"
return 1
fi
if adb devices | grep -q "device$"; then
local device=$(adb devices | grep "device$" | awk '{print $1}')
local model=$(adb shell getprop ro.product.model 2>/dev/null | tr -d '\r\n')
local android_version=$(adb shell getprop ro.build.version.release 2>/dev/null | tr -d '\r\n')
echo -e "${GREEN}✅ Device connected${NC}"
echo -e "${YELLOW}📱 Model: ${model:-Unknown}${NC}"
echo -e "${YELLOW}🤖 Android: ${android_version:-Unknown}${NC}"
echo -e "${YELLOW}🔌 ID: ${device}${NC}"
return 0
else
echo -e "${RED}❌ No device connected${NC}"
return 1
fi
}
# Quick actions menu
quick_actions() {
echo -e "\n${BLUE}⚡ Quick Actions${NC}"
echo "1) Pull all photos from camera"
echo "2) Pull all screenshots"
echo "3) Pull WhatsApp media"
echo "4) Push files to Downloads"
echo "5) Back to main menu"
echo ""
read -p "Select action (1-5): " action
case $action in
1)
# Pull camera photos
echo -e "${CYAN}📸 Pulling camera photos...${NC}"
local backup_dir="$HOME/Pictures/android-camera-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$backup_dir"
if adb pull /sdcard/DCIM/Camera/ "$backup_dir/"; then
echo -e "${GREEN}✅ Photos backed up to: $backup_dir${NC}"
echo "Files: $(find "$backup_dir" -type f | wc -l)"
echo "Size: $(du -sh "$backup_dir" | cut -f1)"
else
echo -e "${RED}❌ Failed to pull photos${NC}"
fi
;;
2)
# Pull screenshots
echo -e "${CYAN}📸 Pulling screenshots...${NC}"
local screenshot_dir="$HOME/Pictures/android-screenshots-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$screenshot_dir"
# Try multiple possible screenshot locations
local found=false
for path in "/sdcard/Pictures/Screenshots" "/sdcard/Screenshots" "/sdcard/DCIM/Screenshots"; do
if adb shell "test -d '$path' && echo 'exists'" 2>/dev/null | grep -q "exists"; then
if adb pull "$path" "$screenshot_dir/"; then
found=true
break
fi
fi
done
if [[ "$found" == true ]]; then
echo -e "${GREEN}✅ Screenshots backed up to: $screenshot_dir${NC}"
echo "Files: $(find "$screenshot_dir" -type f | wc -l)"
else
echo -e "${RED}❌ No screenshots found or failed to pull${NC}"
fi
;;
3)
# Pull WhatsApp media
echo -e "${CYAN}💬 Pulling WhatsApp media...${NC}"
local whatsapp_dir="$HOME/Pictures/whatsapp-backup-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$whatsapp_dir"
if adb shell "test -d '/sdcard/WhatsApp/Media' && echo 'exists'" 2>/dev/null | grep -q "exists"; then
if adb pull /sdcard/WhatsApp/Media/ "$whatsapp_dir/"; then
echo -e "${GREEN}✅ WhatsApp media backed up to: $whatsapp_dir${NC}"
echo "Files: $(find "$whatsapp_dir" -type f | wc -l)"
echo "Size: $(du -sh "$whatsapp_dir" | cut -f1)"
else
echo -e "${RED}❌ Failed to pull WhatsApp media${NC}"
fi
else
echo -e "${RED}❌ WhatsApp media folder not found${NC}"
fi
;;
4)
# Push to Downloads
echo -e "${CYAN}📤 Push files to Downloads folder${NC}"
read -p "Enter file/folder path to push: " -e local_path
local_path="${local_path/#\~/$HOME}"
if [[ -e "$local_path" ]]; then
if adb push "$local_path" /sdcard/Download/; then
echo -e "${GREEN}✅ Files pushed to Downloads folder${NC}"
else
echo -e "${RED}❌ Failed to push files${NC}"
fi
else
echo -e "${RED}❌ Path not found: $local_path${NC}"
fi
;;
5)
return
;;
*)
echo -e "${RED}Invalid choice${NC}"
;;
esac
echo ""
read -p "Press Enter to continue..."
}
# Device info
device_info() {
echo -e "\n${BLUE}📱 Device Information${NC}"
echo "================================"
# Basic info
echo -e "${YELLOW}Model:${NC} $(adb shell getprop ro.product.model 2>/dev/null | tr -d '\r\n')"
echo -e "${YELLOW}Manufacturer:${NC} $(adb shell getprop ro.product.manufacturer 2>/dev/null | tr -d '\r\n')"
echo -e "${YELLOW}Android Version:${NC} $(adb shell getprop ro.build.version.release 2>/dev/null | tr -d '\r\n')"
echo -e "${YELLOW}SDK Version:${NC} $(adb shell getprop ro.build.version.sdk 2>/dev/null | tr -d '\r\n')"
# Storage info
echo -e "\n${CYAN}Storage:${NC}"
adb shell df -h /sdcard | tail -n 1 | awk '{print " Used: " $3 " / " $2 " (" $5 ")"}'
# Battery info
echo -e "\n${CYAN}Battery:${NC}"
local battery_level=$(adb shell dumpsys battery | grep "level:" | awk '{print $2}')
local battery_status=$(adb shell dumpsys battery | grep "status:" | awk '{print $2}')
echo " Level: ${battery_level}%"
echo " Status: ${battery_status}"
echo ""
read -p "Press Enter to continue..."
}
# Main menu
main_menu() {
while true; do
clear
banner
# Check device status
echo -e "${MAGENTA}Device Status:${NC}"
if ! check_adb_status; then
echo -e "\n${YELLOW}Please connect your Android device and enable USB debugging${NC}"
echo ""
read -p "Press Enter to retry or Ctrl+C to exit..."
continue
fi
echo -e "\n${GREEN}Main Menu:${NC}"
echo "1) Push files to Android"
echo "2) Pull files from Android"
echo "3) Quick actions"
echo "4) Device information"
echo "5) Exit"
echo ""
read -p "Select option (1-5): " choice
case $choice in
1)
echo -e "\n${CYAN}Starting push mode...${NC}\n"
bash "$(dirname "$0")/push.sh"
;;
2)
echo -e "\n${CYAN}Starting pull mode...${NC}\n"
bash "$(dirname "$0")/pull.sh"
;;
3)
quick_actions
;;
4)
device_info
;;
5)
echo -e "${GREEN}👋 Goodbye!${NC}"
exit 0
;;
*)
echo -e "${RED}Invalid choice${NC}"
sleep 1
;;
esac
done
}
# Run main menu
main_menu
+298
View File
@@ -0,0 +1,298 @@
#!/bin/bash
# Pull files from Android device via ADB
# Author: Naomi Carrigan & Hikari 💕
set -euo pipefail
# Color codes for pretty output
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Function to display usage
usage() {
echo -e "${BLUE}Usage: $0 [android_source] [local_destination]${NC}"
echo -e "${YELLOW}If no arguments provided, interactive mode will start${NC}"
echo ""
echo "Common Android paths:"
echo " /sdcard/Download/ - Downloads folder"
echo " /sdcard/DCIM/ - Camera folder"
echo " /sdcard/Pictures/ - Pictures folder"
echo " /sdcard/WhatsApp/ - WhatsApp media"
echo " /sdcard/Screenshots/ - Screenshots"
echo ""
echo "Examples:"
echo " $0 /sdcard/DCIM/Camera/ ~/Pictures/phone-backup/"
echo " $0 /sdcard/Download/document.pdf ~/Downloads/"
echo " $0"
exit 1
}
# Function to check if ADB is installed and device is connected
check_adb() {
if ! command -v adb &> /dev/null; then
echo -e "${RED}❌ Error: ADB is not installed or not in PATH${NC}"
echo "Please install Android Debug Bridge (ADB) first"
exit 1
fi
# Check if device is connected
if ! adb devices | grep -q "device$"; then
echo -e "${RED}❌ Error: No Android device connected${NC}"
echo "Please connect your device and enable USB debugging"
echo ""
echo "Current devices:"
adb devices
exit 1
fi
}
# Function to browse Android filesystem
browse_android_fs() {
local current_path="${1:-/sdcard/}"
while true; do
echo -e "${CYAN}📱 Current path: $current_path${NC}"
echo ""
# List contents
echo -e "${YELLOW}Contents:${NC}"
local items=$(adb shell "ls -la '$current_path' 2>/dev/null" | tail -n +2 | awk '{print $NF}' | grep -v "^\.$" | grep -v "^\.\.$")
local i=1
local -a entries=()
# Add parent directory option if not at root
if [[ "$current_path" != "/" ]]; then
echo "0) .. (Go up)"
entries[0]=".."
fi
# List items
while IFS= read -r item; do
if [[ -n "$item" ]]; then
# Check if directory
if adb shell "test -d '$current_path/$item' 2>/dev/null && echo 'dir'" | grep -q "dir"; then
echo "$i) $item/"
else
echo "$i) $item"
fi
entries[$i]="$item"
((i++))
fi
done <<< "$items"
echo ""
echo "s) Select this path"
echo "q) Quit"
echo ""
read -p "Enter choice: " choice
case $choice in
s|S)
echo "$current_path"
return 0
;;
q|Q)
return 1
;;
0)
if [[ "$current_path" != "/" ]]; then
current_path=$(dirname "$current_path")
fi
;;
[0-9]*)
if [[ -n "${entries[$choice]:-}" ]]; then
local selected="${entries[$choice]}"
local new_path="$current_path/$selected"
# Clean up path
new_path=$(echo "$new_path" | sed 's|//|/|g')
# Check if it's a directory
if adb shell "test -d '$new_path' 2>/dev/null && echo 'dir'" | grep -q "dir"; then
current_path="$new_path"
else
# It's a file, return it
echo "$new_path"
return 0
fi
else
echo -e "${RED}Invalid choice${NC}"
fi
;;
*)
echo -e "${RED}Invalid choice${NC}"
;;
esac
echo ""
done
}
# Interactive mode
interactive_mode() {
echo -e "${BLUE}📲 ADB Pull - Interactive Mode${NC}"
echo ""
# Choose method
echo -e "${YELLOW}How would you like to select the source?${NC}"
echo "1) Browse Android filesystem"
echo "2) Enter path directly"
echo "3) Quick access to common folders"
echo ""
read -p "Select method (1-3): " method
case $method in
1)
# Browse mode
if source_path=$(browse_android_fs); then
echo -e "${GREEN}Selected: $source_path${NC}"
else
echo -e "${RED}❌ Cancelled${NC}"
exit 1
fi
;;
2)
# Direct path entry
read -p "Enter Android source path: " -e source_path
;;
3)
# Quick access
echo ""
echo -e "${YELLOW}Common locations:${NC}"
echo "1) /sdcard/DCIM/Camera/"
echo "2) /sdcard/Pictures/"
echo "3) /sdcard/Download/"
echo "4) /sdcard/WhatsApp/Media/"
echo "5) /sdcard/Screenshots/"
echo "6) /sdcard/Documents/"
echo "7) /sdcard/Music/"
echo ""
read -p "Select location (1-7): " quick_choice
case $quick_choice in
1) source_path="/sdcard/DCIM/Camera/" ;;
2) source_path="/sdcard/Pictures/" ;;
3) source_path="/sdcard/Download/" ;;
4) source_path="/sdcard/WhatsApp/Media/" ;;
5) source_path="/sdcard/Screenshots/" ;;
6) source_path="/sdcard/Documents/" ;;
7) source_path="/sdcard/Music/" ;;
*)
echo -e "${RED}❌ Invalid choice${NC}"
exit 1
;;
esac
;;
*)
echo -e "${RED}❌ Invalid choice${NC}"
exit 1
;;
esac
# Get destination
echo ""
read -p "Enter local destination path (default: current directory): " -e dest_path
dest_path="${dest_path:-.}"
dest_path="${dest_path/#\~/$HOME}"
# Pull the file/directory
pull_from_android "$source_path" "$dest_path"
}
# Function to pull files from Android
pull_from_android() {
local source="$1"
local dest="$2"
# Validate source exists
if ! adb shell "test -e '$source' 2>/dev/null && echo 'exists'" | grep -q "exists"; then
echo -e "${RED}❌ Error: Source '$source' does not exist on device${NC}"
exit 1
fi
# Create destination directory if needed
if [[ ! -d "$dest" ]]; then
mkdir -p "$dest"
fi
echo -e "${BLUE}📥 Pulling from Android device...${NC}"
echo "Source: $source"
echo "Destination: $dest"
echo ""
# Check if source is directory
if adb shell "test -d '$source' 2>/dev/null && echo 'dir'" | grep -q "dir"; then
echo -e "${YELLOW}Pulling directory...${NC}"
# Count files for progress
local file_count=$(adb shell "find '$source' -type f 2>/dev/null | wc -l" | tr -d '\r\n')
echo "Found $file_count files to pull"
echo ""
if adb pull "$source" "$dest"; then
echo -e "${GREEN}✅ Directory pulled successfully!${NC}"
# Show summary
local pulled_dir="$dest/$(basename "$source")"
if [[ -d "$pulled_dir" ]]; then
echo ""
echo -e "${BLUE}📊 Summary:${NC}"
echo "Location: $pulled_dir"
echo "Files: $(find "$pulled_dir" -type f | wc -l)"
echo "Total size: $(du -sh "$pulled_dir" | cut -f1)"
fi
else
echo -e "${RED}❌ Failed to pull directory${NC}"
exit 1
fi
else
# Single file
if adb pull "$source" "$dest"; then
echo -e "${GREEN}✅ File pulled successfully!${NC}"
# Show file info
local filename=$(basename "$source")
local full_path="$dest/$filename"
echo ""
echo -e "${BLUE}📄 File info:${NC}"
ls -lh "$full_path"
else
echo -e "${RED}❌ Failed to pull file${NC}"
exit 1
fi
fi
}
# Main script
main() {
check_adb
if [[ $# -eq 0 ]]; then
# No arguments, run interactive mode
interactive_mode
elif [[ $# -eq 1 ]]; then
if [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]]; then
usage
else
echo -e "${RED}❌ Error: Missing destination path${NC}"
usage
fi
elif [[ $# -eq 2 ]]; then
# Arguments provided
dest_path="${2/#\~/$HOME}"
pull_from_android "$1" "$dest_path"
else
echo -e "${RED}❌ Error: Too many arguments${NC}"
usage
fi
}
main "$@"
+202
View File
@@ -0,0 +1,202 @@
#!/bin/bash
# Push files to Android device via ADB
# Author: Naomi Carrigan & Hikari 💕
set -euo pipefail
# Color codes for pretty output
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Function to display usage
usage() {
echo -e "${BLUE}Usage: $0 [source_file/directory] [android_destination]${NC}"
echo -e "${YELLOW}If no arguments provided, interactive mode will start${NC}"
echo ""
echo "Common Android paths:"
echo " /sdcard/Download/ - Downloads folder"
echo " /sdcard/DCIM/ - Camera folder"
echo " /sdcard/Pictures/ - Pictures folder"
echo " /sdcard/Documents/ - Documents folder"
echo " /sdcard/Music/ - Music folder"
echo ""
echo "Examples:"
echo " $0 photo.jpg /sdcard/Pictures/"
echo " $0 ~/Documents/file.pdf /sdcard/Download/"
echo " $0"
exit 1
}
# Function to check if ADB is installed and device is connected
check_adb() {
if ! command -v adb &> /dev/null; then
echo -e "${RED}❌ Error: ADB is not installed or not in PATH${NC}"
echo "Please install Android Debug Bridge (ADB) first"
exit 1
fi
# Check if device is connected
if ! adb devices | grep -q "device$"; then
echo -e "${RED}❌ Error: No Android device connected${NC}"
echo "Please connect your device and enable USB debugging"
echo ""
echo "Current devices:"
adb devices
exit 1
fi
}
# Function to validate Android path
validate_android_path() {
local path="$1"
# Check if path starts with /
if [[ ! "$path" =~ ^/ ]]; then
echo -e "${YELLOW}⚠️ Warning: Path doesn't start with /, prepending /sdcard/${NC}"
path="/sdcard/$path"
fi
# Check if destination exists (create if it's a directory)
if [[ "$path" =~ /$ ]]; then
adb shell "mkdir -p '$path' 2>/dev/null || true"
else
# Check if parent directory exists
local parent_dir=$(dirname "$path")
adb shell "mkdir -p '$parent_dir' 2>/dev/null || true"
fi
echo "$path"
}
# Interactive mode
interactive_mode() {
echo -e "${BLUE}🚀 ADB Push - Interactive Mode${NC}"
echo ""
# Get source file/directory
read -p "Enter source file/directory path: " -e source_path
# Expand tilde and validate source
source_path="${source_path/#\~/$HOME}"
if [[ ! -e "$source_path" ]]; then
echo -e "${RED}❌ Error: Source '$source_path' does not exist${NC}"
exit 1
fi
# Show common destinations
echo ""
echo -e "${YELLOW}Common Android destinations:${NC}"
echo "1) /sdcard/Download/"
echo "2) /sdcard/Pictures/"
echo "3) /sdcard/DCIM/"
echo "4) /sdcard/Documents/"
echo "5) /sdcard/Music/"
echo "6) /sdcard/Movies/"
echo "7) Custom path"
echo ""
read -p "Select destination (1-7): " choice
case $choice in
1) dest_path="/sdcard/Download/" ;;
2) dest_path="/sdcard/Pictures/" ;;
3) dest_path="/sdcard/DCIM/" ;;
4) dest_path="/sdcard/Documents/" ;;
5) dest_path="/sdcard/Music/" ;;
6) dest_path="/sdcard/Movies/" ;;
7)
read -p "Enter custom destination path: " -e dest_path
;;
*)
echo -e "${RED}❌ Invalid choice${NC}"
exit 1
;;
esac
# Push the file/directory
push_to_android "$source_path" "$dest_path"
}
# Function to push files to Android
push_to_android() {
local source="$1"
local dest="$2"
# Validate destination path
dest=$(validate_android_path "$dest")
echo -e "${BLUE}📦 Pushing to Android device...${NC}"
echo "Source: $source"
echo "Destination: $dest"
echo ""
# Check if source is directory
if [[ -d "$source" ]]; then
echo -e "${YELLOW}Pushing directory...${NC}"
# For directories, adb push handles recursion automatically
if adb push "$source" "$dest"; then
echo -e "${GREEN}✅ Directory pushed successfully!${NC}"
else
echo -e "${RED}❌ Failed to push directory${NC}"
exit 1
fi
else
# Single file
if adb push "$source" "$dest"; then
echo -e "${GREEN}✅ File pushed successfully!${NC}"
# Show file info on device
if [[ "$dest" =~ /$ ]]; then
# Destination is a directory
filename=$(basename "$source")
full_path="${dest}${filename}"
else
# Destination is a file
full_path="$dest"
fi
echo ""
echo -e "${BLUE}📱 File on device:${NC}"
adb shell "ls -lh '$full_path'" 2>/dev/null || true
else
echo -e "${RED}❌ Failed to push file${NC}"
exit 1
fi
fi
}
# Main script
main() {
check_adb
if [[ $# -eq 0 ]]; then
# No arguments, run interactive mode
interactive_mode
elif [[ $# -eq 1 ]]; then
if [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]]; then
usage
else
echo -e "${RED}❌ Error: Missing destination path${NC}"
usage
fi
elif [[ $# -eq 2 ]]; then
# Arguments provided
source_path="${1/#\~/$HOME}"
if [[ ! -e "$source_path" ]]; then
echo -e "${RED}❌ Error: Source '$source_path' does not exist${NC}"
exit 1
fi
push_to_android "$source_path" "$2"
else
echo -e "${RED}❌ Error: Too many arguments${NC}"
usage
fi
}
main "$@"
+3 -3
View File
@@ -13,9 +13,9 @@ DISCORD_CLIENT_SECRET="op://Private/Guild Counter/client secret"
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Amari/bot token"
# AWS
AWS_ACCESS_KEY_ID="op://Private/Hetzner/S3 Access Key ID"
AWS_SECRET_ACCESS_KEY="op://Private/Hetzner/S3 Secret Access Key"
S3_ENDPOINT="op://Private/Hetzner/S3 Endpoint"
AWS_ACCESS_KEY_ID="op://Private/S3/S3 Access Key ID"
AWS_SECRET_ACCESS_KEY="op://Private/S3/S3 Secret Access Key"
S3_ENDPOINT="op://Private/S3/S3 Endpoint"
# Gitea
GITEA_TOKEN="op://Private/Gitea/token"
+5
View File
@@ -92,3 +92,8 @@ quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
+4
View File
@@ -1,5 +1,9 @@
# Development dependencies
ruff==0.14.14
pytest==8.3.5
pytest-mock==3.14.0
responses==0.25.3
pytest-asyncio==0.24.0
# Runtime dependencies
requests==2.32.3
+1
View File
@@ -0,0 +1 @@
# Test package for ephemere Python scripts
+207
View File
@@ -0,0 +1,207 @@
"""Tests for analyse_availability functions.
@copyright NHCarrigan
@license Naomi's Public License
@author Naomi Carrigan
"""
import sys
from pathlib import Path
import pytest
# Add the cohort directory to the path for imports
sys.path.insert(0, str(Path(__file__).parent.parent / "cohort"))
from analyse_availability import (
analyze_applicant_availability,
get_utc_blocks_for_hour,
local_hour_to_utc,
parse_time_slots,
parse_utc_offset,
)
class TestParseUtcOffset:
"""Tests for parse_utc_offset function."""
def test_positive_offset(self):
"""Should parse positive UTC offset."""
assert parse_utc_offset("Europe/London (UTC+0)") == 0
assert parse_utc_offset("Europe/Paris (UTC+1)") == 1
assert parse_utc_offset("Asia/Tokyo (UTC+9)") == 9
def test_negative_offset(self):
"""Should parse negative UTC offset."""
assert parse_utc_offset("America/New_York (UTC-5)") == -5
assert parse_utc_offset("America/Los_Angeles (UTC-8)") == -8
def test_offset_with_minutes(self):
"""Should parse offset with minutes component."""
assert parse_utc_offset("Asia/Kolkata (UTC+5:30)") == 5.5
assert parse_utc_offset("Asia/Kathmandu (UTC+5:45)") == 5.75
def test_negative_offset_with_minutes(self):
"""Should parse negative offset with minutes."""
assert parse_utc_offset("Canada/Newfoundland (UTC-3:30)") == -3.5
def test_no_match_returns_zero(self):
"""Should return 0 when no UTC offset found."""
assert parse_utc_offset("Unknown/Timezone") == 0
assert parse_utc_offset("") == 0
class TestParseTimeSlots:
"""Tests for parse_time_slots function."""
def test_single_slot(self):
"""Should parse a single time slot."""
result = parse_time_slots("17:00-18:00")
assert result == [(17, 18)]
def test_multiple_slots(self):
"""Should parse multiple time slots separated by semicolon."""
result = parse_time_slots("07:00-08:00; 19:00-20:00")
assert result == [(7, 8), (19, 20)]
def test_na_values(self):
"""Should return empty list for N/A values."""
assert parse_time_slots("N/A") == []
assert parse_time_slots("na") == []
assert parse_time_slots("") == []
def test_wider_time_range(self):
"""Should parse wider time ranges."""
result = parse_time_slots("09:00-17:00")
assert result == [(9, 17)]
def test_midnight_crossing_slots(self):
"""Should parse time slots that approach midnight."""
result = parse_time_slots("22:00-23:00")
assert result == [(22, 23)]
class TestLocalHourToUtc:
"""Tests for local_hour_to_utc function."""
def test_positive_offset(self):
"""Should convert local hour with positive UTC offset."""
assert local_hour_to_utc(12, 1) == 11 # Noon in UTC+1 -> 11:00 UTC
assert local_hour_to_utc(0, 9) == 15 # Midnight in UTC+9 -> 15:00 UTC
def test_negative_offset(self):
"""Should convert local hour with negative UTC offset."""
assert local_hour_to_utc(12, -5) == 17 # Noon in UTC-5 -> 17:00 UTC
assert local_hour_to_utc(20, -8) == 4 # 8pm in UTC-8 -> 4:00 UTC (next day)
def test_wrapping_around_midnight(self):
"""Should wrap around correctly at midnight."""
assert local_hour_to_utc(1, 5) == 20 # 1am in UTC+5 -> 20:00 UTC (prev day)
assert local_hour_to_utc(23, -3) == 2 # 11pm in UTC-3 -> 2:00 UTC (next day)
def test_zero_offset(self):
"""Should return same hour for zero offset."""
assert local_hour_to_utc(15, 0) == 15
class TestGetUtcBlocksForHour:
"""Tests for get_utc_blocks_for_hour function."""
def test_mornings_block(self):
"""Should return mornings for 6-11 UTC."""
assert "mornings" in get_utc_blocks_for_hour(6)
assert "mornings" in get_utc_blocks_for_hour(9)
assert "mornings" in get_utc_blocks_for_hour(11)
assert "mornings" not in get_utc_blocks_for_hour(12)
def test_afternoons_block(self):
"""Should return afternoons for 12-17 UTC."""
assert "afternoons" in get_utc_blocks_for_hour(12)
assert "afternoons" in get_utc_blocks_for_hour(15)
assert "afternoons" in get_utc_blocks_for_hour(17)
assert "afternoons" not in get_utc_blocks_for_hour(18)
def test_evenings_block(self):
"""Should return evenings for 18-23 UTC."""
assert "evenings" in get_utc_blocks_for_hour(18)
assert "evenings" in get_utc_blocks_for_hour(21)
assert "evenings" in get_utc_blocks_for_hour(23)
assert "evenings" not in get_utc_blocks_for_hour(0)
def test_nights_block(self):
"""Should return nights for 0-5 UTC."""
assert "nights" in get_utc_blocks_for_hour(0)
assert "nights" in get_utc_blocks_for_hour(3)
assert "nights" in get_utc_blocks_for_hour(5)
assert "nights" not in get_utc_blocks_for_hour(6)
class TestAnalyzeApplicantAvailability:
"""Tests for analyze_applicant_availability function."""
def test_basic_availability(self):
"""Should analyze basic availability correctly."""
day_slots = {
"Monday": [(9, 12)],
"Tuesday": [(9, 12)],
"Wednesday": [(9, 12)],
"Thursday": [],
"Friday": [],
"Saturday": [],
"Sunday": [],
}
result = analyze_applicant_availability("UTC (UTC+0)", day_slots)
assert result["utc_offset"] == 0
assert "mornings" in result["available_blocks"]
def test_no_availability(self):
"""Should return empty blocks when no availability."""
day_slots = {
"Monday": [],
"Tuesday": [],
"Wednesday": [],
"Thursday": [],
"Friday": [],
"Saturday": [],
"Sunday": [],
}
result = analyze_applicant_availability("UTC (UTC+0)", day_slots)
assert result["available_blocks"] == []
assert result["total_unique_utc_hours"] == 0
def test_timezone_conversion(self):
"""Should correctly convert timezones."""
day_slots = {
"Monday": [(17, 20)], # 5pm-8pm local
"Tuesday": [(17, 20)],
"Wednesday": [(17, 20)],
"Thursday": [],
"Friday": [],
"Saturday": [],
"Sunday": [],
}
result = analyze_applicant_availability(
"America/New_York (UTC-5)", day_slots
)
assert result["utc_offset"] == -5
# 5pm-8pm in UTC-5 = 22:00-01:00 UTC -> evenings/nights
assert "evenings" in result["available_blocks"]
def test_block_count_threshold(self):
"""Should only include blocks with 3+ occurrences."""
day_slots = {
"Monday": [(9, 10)], # Only 1 hour
"Tuesday": [(9, 10)], # Only 1 hour
"Wednesday": [],
"Thursday": [],
"Friday": [],
"Saturday": [],
"Sunday": [],
}
result = analyze_applicant_availability("UTC (UTC+0)", day_slots)
# Only 2 hours, threshold is 3
assert "mornings" not in result["available_blocks"]
+24 -6
View File
@@ -96,7 +96,7 @@ select_option() {
# Step 1: Select Language
echo ""
languages=("TypeScript" "Python")
languages=("TypeScript" "Python" "Bash")
select_option "Select a language:" "${languages[@]}"
lang_index=$?
language="${languages[$lang_index]}"
@@ -109,7 +109,7 @@ if [ "$language" == "TypeScript" ]; then
runner="pnpm tsx"
# Get subdirectories as categories (excluding utils and interfaces)
mapfile -t categories < <(find "$script_dir" -mindepth 1 -maxdepth 1 -type d ! -name 'utils' ! -name 'interfaces' -exec basename {} \; | sort)
else
elif [ "$language" == "Python" ]; then
script_dir="python"
runner="uv run python"
# Get subdirectories as categories (excluding __pycache__ and .venv)
@@ -118,6 +118,12 @@ else
if ls "$script_dir"/*.py &>/dev/null 2>&1; then
categories=("Root Scripts" "${categories[@]}")
fi
else # Bash
script_dir="bash"
runner="bash"
# Get subdirectories as categories
mapfile -t categories < <(find "$script_dir" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | sort)
# Add "Root Scripts" option for bash files in root
fi
if [ ${#categories[@]} -eq 0 ]; then
@@ -134,13 +140,20 @@ echo -e "\n ${GREEN}$STAR Selected: ${WHITE}$category${RESET}\n"
# Step 3: Get scripts in category
if [ "$category" == "Root Scripts" ]; then
search_dir="$script_dir"
mapfile -t scripts < <(find "$search_dir" -maxdepth 1 -name "*.py" -exec basename {} \; | sort)
if [ "$language" == "Python" ]; then
mapfile -t scripts < <(find "$search_dir" -maxdepth 1 -name "*.py" -exec basename {} \; | sort)
elif [ "$language" == "Bash" ]; then
mapfile -t scripts < <(find "$search_dir" -maxdepth 1 -name "*.sh" -exec basename {} \; | sort)
fi
elif [ "$language" == "TypeScript" ]; then
search_dir="$script_dir/$category"
mapfile -t scripts < <(find "$search_dir" -name "*.ts" -exec basename {} \; | sort)
else
elif [ "$language" == "Python" ]; then
search_dir="$script_dir/$category"
mapfile -t scripts < <(find "$search_dir" -name "*.py" ! -name "__init__.py" -exec basename {} \; | sort)
else # Bash
search_dir="$script_dir/$category"
mapfile -t scripts < <(find "$search_dir" -name "*.sh" -exec basename {} \; | sort)
fi
if [ ${#scripts[@]} -eq 0 ]; then
@@ -159,8 +172,10 @@ if [ "$category" == "Root Scripts" ]; then
script_path="$script"
elif [ "$language" == "TypeScript" ]; then
script_path="src/$category/$script"
else
elif [ "$language" == "Python" ]; then
script_path="$category/$script"
else # Bash
script_path="bash/$category/$script"
fi
# Show what we're about to run
@@ -178,10 +193,13 @@ if [ "$language" == "TypeScript" ]; then
cd typescript
echo -e " ${DIM}$ op run --env-file=../prod.env -- $runner $script_path${RESET}\n"
op run --env-file=../prod.env --no-masking -- $runner "$script_path"
else
elif [ "$language" == "Python" ]; then
cd python
echo -e " ${DIM}$ op run --env-file=../prod.env -- $runner $script_path${RESET}\n"
op run --env-file=../prod.env --no-masking -- $runner "$script_path"
else # Bash
echo -e " ${DIM}$ op run --env-file=prod.env -- $runner $script_path${RESET}\n"
op run --env-file=prod.env --no-masking -- $runner "$script_path"
fi
exit_code=$?
+8 -1
View File
@@ -5,7 +5,10 @@
"main": "index.js",
"type": "module",
"scripts": {
"start": "op run --env-file=prod.env --no-masking -- tsx"
"start": "op run --env-file=prod.env --no-masking -- tsx",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"keywords": [],
"author": "",
@@ -24,5 +27,9 @@
"open": "11.0.0",
"tsx": "4.20.5",
"typescript": "5.9.2"
},
"devDependencies": {
"@vitest/coverage-v8": "^3.2.4",
"vitest": "3.2.4"
}
}
+332
View File
@@ -44,9 +44,20 @@ importers:
typescript:
specifier: 5.9.2
version: 5.9.2
devDependencies:
'@vitest/coverage-v8':
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/node@24.3.0)(tsx@4.20.5))
vitest:
specifier: 3.2.4
version: 3.2.4(@types/node@24.3.0)(tsx@4.20.5)
packages:
'@ampproject/remapping@2.3.0':
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'}
'@aws-crypto/crc32@5.2.0':
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
engines: {node: '>=16.0.0'}
@@ -210,10 +221,31 @@ packages:
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.27.1':
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.0':
resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/types@7.29.0':
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@es-joy/jsdoccomment@0.49.0':
resolution: {integrity: sha512-xjZTSFgECpb9Ohuk5yMX5RhUEbfeQcuOp8IF60e+wyzWEF0M5xeSgqsfLtvPEX8BIyOX9saZqzuGPmZ8oWc+5Q==}
engines: {node: '>=16'}
@@ -589,9 +621,27 @@ packages:
'@types/node':
optional: true
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
'@istanbuljs/schema@0.1.3':
resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==}
engines: {node: '>=8'}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@nhcarrigan/eslint-config@5.2.0':
resolution: {integrity: sha512-YpTTqhviKMlRwKF+RC/GYiA5i2jTCmg8uftuiufldneNV5HMbGpTfBbV7tpa8++5mpYJc4+eZaf40QbDiz84dQ==}
engines: {node: '>=22', pnpm: '>=9'}
@@ -672,6 +722,10 @@ packages:
'@octokit/types@14.1.0':
resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@pkgr/core@0.1.2':
resolution: {integrity: sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@@ -1136,6 +1190,15 @@ packages:
resolution: {integrity: sha512-+GeGMebMCy0elMNg67LRNoVnUFPIm37iu5CmHESVx56/9Jsfdpsvbv605DQ81Pi/x11IdKUsS5nzgTYbCQU9fg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vitest/coverage-v8@3.2.4':
resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==}
peerDependencies:
'@vitest/browser': 3.2.4
vitest: 3.2.4
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/eslint-plugin@1.1.24':
resolution: {integrity: sha512-7IaENe4NNy33g0iuuy5bHY69JYYRjpv4lMx6H5Wp30W7ez2baLHwxsXF5TM4wa8JDYZt8ut99Ytoj7GiDO01hw==}
peerDependencies:
@@ -1200,10 +1263,18 @@ packages:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-regex@6.2.2:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
are-docs-informative@0.0.2:
resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==}
engines: {node: '>=14'}
@@ -1251,6 +1322,9 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
ast-v8-to-istanbul@0.3.11:
resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==}
async-function@1.0.0:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
@@ -1434,12 +1508,18 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
electron-to-chromium@1.5.210:
resolution: {integrity: sha512-20kSVv1tyNBN2VFsjCIJZfyvxqo7ylHPrJLME040f/030lzNMA7uQNpxtqJjWSNpccD8/2sqe53EAjrFPvQmjw==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
error-ex@1.3.2:
resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
@@ -1679,6 +1759,10 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1722,6 +1806,10 @@ packages:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
hasBin: true
globals@13.24.0:
resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
engines: {node: '>=8'}
@@ -1779,6 +1867,9 @@ packages:
hosted-git-info@2.8.9:
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
iconv-lite@0.7.0:
resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==}
engines: {node: '>=0.10.0'}
@@ -1938,10 +2029,32 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
istanbul-lib-report@3.0.1:
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
engines: {node: '>=10'}
istanbul-lib-source-maps@5.0.6:
resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==}
engines: {node: '>=10'}
istanbul-reports@3.2.0:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
iterator.prototype@1.1.5:
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
engines: {node: '>= 0.4'}
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -2013,9 +2126,19 @@ packages:
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
magic-string@0.30.18:
resolution: {integrity: sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==}
magicast@0.3.5:
resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==}
make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -2042,6 +2165,10 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minipass@7.1.2:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -2127,6 +2254,9 @@ packages:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -2150,6 +2280,10 @@ packages:
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
path-scurry@1.11.1:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
@@ -2379,6 +2513,9 @@ packages:
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
std-env@3.9.0:
resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==}
@@ -2390,6 +2527,10 @@ packages:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
string-width@5.1.2:
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
engines: {node: '>=12'}
string.prototype.matchall@4.0.12:
resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
engines: {node: '>= 0.4'}
@@ -2413,6 +2554,10 @@ packages:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
strip-ansi@7.1.2:
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
engines: {node: '>=12'}
strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
@@ -2443,6 +2588,10 @@ packages:
resolution: {integrity: sha512-JJoOEKTfL1urb1mDoEblhD9NhEbWmq9jHEMEnxoC4ujUaZ4itA8vKgwkFAyNClgxplLi9tsUKX+EduK0p/l7sg==}
engines: {node: ^14.18.0 || >=16.0.0}
test-exclude@7.0.1:
resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==}
engines: {node: '>=18'}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -2658,6 +2807,14 @@ packages:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
wrap-ansi@8.1.0:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
wsl-utils@0.3.0:
resolution: {integrity: sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ==}
engines: {node: '>=20'}
@@ -2672,6 +2829,11 @@ packages:
snapshots:
'@ampproject/remapping@2.3.0':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@aws-crypto/crc32@5.2.0':
dependencies:
'@aws-crypto/util': 5.2.0
@@ -3157,8 +3319,23 @@ snapshots:
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.27.1': {}
'@babel/helper-validator-identifier@7.28.5': {}
'@babel/parser@7.29.0':
dependencies:
'@babel/types': 7.29.0
'@babel/types@7.29.0':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@bcoe/v8-coverage@1.0.2': {}
'@es-joy/jsdoccomment@0.49.0':
dependencies:
comment-parser: 1.4.1
@@ -3451,8 +3628,31 @@ snapshots:
optionalDependencies:
'@types/node': 24.3.0
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
string-width-cjs: string-width@4.2.3
strip-ansi: 7.1.2
strip-ansi-cjs: strip-ansi@6.0.1
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0
'@istanbuljs/schema@0.1.3': {}
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@nhcarrigan/eslint-config@5.2.0(@typescript-eslint/utils@8.41.0(eslint@9.34.0)(typescript@5.9.2))(eslint@9.34.0)(playwright@1.55.0)(react@19.1.1)(typescript@5.9.2)(vitest@3.2.4(@types/node@24.3.0)(tsx@4.20.5))':
dependencies:
'@eslint-community/eslint-plugin-eslint-comments': 4.4.1(eslint@9.34.0)
@@ -3560,6 +3760,9 @@ snapshots:
dependencies:
'@octokit/openapi-types': 25.1.0
'@pkgjs/parseargs@0.11.0':
optional: true
'@pkgr/core@0.1.2': {}
'@rollup/rollup-android-arm-eabi@4.49.0':
@@ -4163,6 +4366,25 @@ snapshots:
'@typescript-eslint/types': 8.41.0
eslint-visitor-keys: 4.2.1
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@24.3.0)(tsx@4.20.5))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
ast-v8-to-istanbul: 0.3.11
debug: 4.4.1
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-lib-source-maps: 5.0.6
istanbul-reports: 3.2.0
magic-string: 0.30.18
magicast: 0.3.5
std-env: 3.10.0
test-exclude: 7.0.1
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/node@24.3.0)(tsx@4.20.5)
transitivePeerDependencies:
- supports-color
'@vitest/eslint-plugin@1.1.24(@typescript-eslint/utils@8.41.0(eslint@9.34.0)(typescript@5.9.2))(eslint@9.34.0)(typescript@5.9.2)(vitest@3.2.4(@types/node@24.3.0)(tsx@4.20.5))':
dependencies:
'@typescript-eslint/utils': 8.41.0(eslint@9.34.0)(typescript@5.9.2)
@@ -4234,10 +4456,14 @@ snapshots:
ansi-regex@5.0.1: {}
ansi-regex@6.2.2: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
ansi-styles@6.2.3: {}
are-docs-informative@0.0.2: {}
argparse@2.0.1: {}
@@ -4313,6 +4539,12 @@ snapshots:
assertion-error@2.0.1: {}
ast-v8-to-istanbul@0.3.11:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
estree-walker: 3.0.3
js-tokens: 10.0.0
async-function@1.0.0: {}
available-typed-arrays@1.0.7:
@@ -4488,10 +4720,14 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
eastasianwidth@0.2.0: {}
electron-to-chromium@1.5.210: {}
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
error-ex@1.3.2:
dependencies:
is-arrayish: 0.2.1
@@ -4900,6 +5136,11 @@ snapshots:
dependencies:
is-callable: 1.2.7
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
signal-exit: 4.1.0
fsevents@2.3.2:
optional: true
@@ -4955,6 +5196,15 @@ snapshots:
dependencies:
is-glob: 4.0.3
glob@10.5.0:
dependencies:
foreground-child: 3.3.1
jackspeak: 3.4.3
minimatch: 9.0.5
minipass: 7.1.2
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
globals@13.24.0:
dependencies:
type-fest: 0.20.2
@@ -5005,6 +5255,8 @@ snapshots:
hosted-git-info@2.8.9: {}
html-escaper@2.0.2: {}
iconv-lite@0.7.0:
dependencies:
safer-buffer: 2.1.2
@@ -5157,6 +5409,27 @@ snapshots:
isexe@2.0.0: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
dependencies:
istanbul-lib-coverage: 3.2.2
make-dir: 4.0.0
supports-color: 7.2.0
istanbul-lib-source-maps@5.0.6:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
debug: 4.4.1
istanbul-lib-coverage: 3.2.2
transitivePeerDependencies:
- supports-color
istanbul-reports@3.2.0:
dependencies:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
iterator.prototype@1.1.5:
dependencies:
define-data-property: 1.1.4
@@ -5166,6 +5439,14 @@ snapshots:
has-symbols: 1.1.0
set-function-name: 2.0.2
jackspeak@3.4.3:
dependencies:
'@isaacs/cliui': 8.0.2
optionalDependencies:
'@pkgjs/parseargs': 0.11.0
js-tokens@10.0.0: {}
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
@@ -5226,10 +5507,22 @@ snapshots:
loupe@3.2.1: {}
lru-cache@10.4.3: {}
magic-string@0.30.18:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magicast@0.3.5:
dependencies:
'@babel/parser': 7.29.0
'@babel/types': 7.29.0
source-map-js: 1.2.1
make-dir@4.0.0:
dependencies:
semver: 7.7.2
math-intrinsics@1.1.0: {}
merge2@1.4.1: {}
@@ -5251,6 +5544,8 @@ snapshots:
minimist@1.2.8: {}
minipass@7.1.2: {}
ms@2.1.3: {}
mute-stream@2.0.0: {}
@@ -5352,6 +5647,8 @@ snapshots:
p-try@2.2.0: {}
package-json-from-dist@1.0.1: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -5374,6 +5671,11 @@ snapshots:
path-parse@1.0.7: {}
path-scurry@1.11.1:
dependencies:
lru-cache: 10.4.3
minipass: 7.1.2
path-type@4.0.0: {}
pathe@2.0.3: {}
@@ -5627,6 +5929,8 @@ snapshots:
stackback@0.0.2: {}
std-env@3.10.0: {}
std-env@3.9.0: {}
stop-iteration-iterator@1.1.0:
@@ -5640,6 +5944,12 @@ snapshots:
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
string-width@5.1.2:
dependencies:
eastasianwidth: 0.2.0
emoji-regex: 9.2.2
strip-ansi: 7.1.2
string.prototype.matchall@4.0.12:
dependencies:
call-bind: 1.0.8
@@ -5688,6 +5998,10 @@ snapshots:
dependencies:
ansi-regex: 5.0.1
strip-ansi@7.1.2:
dependencies:
ansi-regex: 6.2.2
strip-bom@3.0.0: {}
strip-indent@3.0.0:
@@ -5713,6 +6027,12 @@ snapshots:
'@pkgr/core': 0.1.2
tslib: 2.8.1
test-exclude@7.0.1:
dependencies:
'@istanbuljs/schema': 0.1.3
glob: 10.5.0
minimatch: 9.0.5
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
@@ -5960,6 +6280,18 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@8.1.0:
dependencies:
ansi-styles: 6.2.3
string-width: 5.1.2
strip-ansi: 7.1.2
wsl-utils@0.3.0:
dependencies:
is-wsl: 3.1.0
@@ -0,0 +1,107 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { getFiles } from "../getFiles.js";
describe("getFiles", () => {
beforeEach(() => {
vi.spyOn(global, "fetch");
vi.spyOn(console, "log").mockImplementation(() => {
return undefined;
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should fetch files from a project", async() => {
const mockFiles = [
{ data: { id: 1, name: "file1.json", path: "/file1.json" } },
{ data: { id: 2, name: "file2.json", path: "/file2.json" } },
];
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: mockFiles });
},
} as Response);
const result = await getFiles(
"123",
"https://api.crowdin.com/api/v2",
"test-token",
);
expect(result).toEqual(mockFiles);
expect(global.fetch).toHaveBeenCalledWith(
"https://api.crowdin.com/api/v2/projects/123/files?limit=500",
{
headers: {
authorization: "Bearer test-token",
},
},
);
});
it("should handle pagination when there are 500+ files", async() => {
const files500 = Array.from({ length: 500 }, (_, index) => {
return { data: { id: index, name: `file${String(index)}.json` } };
});
const filesRemaining = [
{ data: { id: 500, name: "file500.json" } },
{ data: { id: 501, name: "file501.json" } },
];
vi.mocked(global.fetch).
mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: files500 });
},
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: filesRemaining });
},
} as Response);
const result = await getFiles(
"123",
"https://api.crowdin.com/api/v2",
"token",
);
expect(result).toHaveLength(502);
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(global.fetch).toHaveBeenNthCalledWith(
1,
"https://api.crowdin.com/api/v2/projects/123/files?limit=500",
expect.any(Object),
);
expect(global.fetch).toHaveBeenNthCalledWith(
2,
"https://api.crowdin.com/api/v2/projects/123/files?limit=500&offset=500",
expect.any(Object),
);
});
it("should return empty array when no files exist", async() => {
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: [] });
},
} as Response);
const result = await getFiles(
"456",
"https://api.crowdin.com/api/v2",
"token",
);
expect(result).toEqual([]);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,98 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { getLanguages } from "../getLanguages.js";
describe("getLanguages", () => {
beforeEach(() => {
vi.spyOn(global, "fetch");
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should fetch project and return target language IDs", async() => {
const mockResponse = {
data: {
id: 1,
name: "Test Project",
targetLanguageIds: [ "de", "fr", "es", "ja" ],
},
};
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve(mockResponse);
},
} as Response);
const result = await getLanguages(
"123",
"https://api.crowdin.com/api/v2",
"test-token",
);
expect(result).toEqual([ "de", "fr", "es", "ja" ]);
expect(global.fetch).toHaveBeenCalledWith(
"https://api.crowdin.com/api/v2/projects/123",
{
headers: {
authorization: "Bearer test-token",
},
},
);
});
it("should return empty array when no target languages", async() => {
const mockResponse = {
data: {
id: 2,
name: "Empty Project",
targetLanguageIds: [],
},
};
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve(mockResponse);
},
} as Response);
const result = await getLanguages(
"456",
"https://api.crowdin.com/api/v2",
"token",
);
expect(result).toEqual([]);
});
it("should use the correct API URL", async() => {
const mockResponse = {
data: {
targetLanguageIds: [ "en" ],
},
};
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve(mockResponse);
},
} as Response);
await getLanguages(
"789",
"https://custom.crowdin.com/v2",
"my-token",
);
expect(global.fetch).toHaveBeenCalledWith(
"https://custom.crowdin.com/v2/projects/789",
expect.any(Object),
);
});
});
@@ -0,0 +1,139 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { getStrings } from "../getStrings.js";
describe("getStrings", () => {
beforeEach(() => {
vi.spyOn(global, "fetch");
vi.spyOn(console, "log").mockImplementation(() => {
return undefined;
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should fetch strings from a project", async() => {
const mockStrings = [
{ data: { id: 1, text: "Hello", identifier: "greeting" } },
{ data: { id: 2, text: "Goodbye", identifier: "farewell" } },
];
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: mockStrings });
},
} as Response);
const result = await getStrings(
"123",
"https://api.crowdin.com/api/v2",
"test-token",
);
expect(result).toEqual([
{ id: 1, text: "Hello", identifier: "greeting" },
{ id: 2, text: "Goodbye", identifier: "farewell" },
]);
expect(global.fetch).toHaveBeenCalledWith(
"https://api.crowdin.com/api/v2/projects/123/strings?limit=500",
{
headers: {
authorization: "Bearer test-token",
},
},
);
});
it("should handle pagination when there are 500+ strings", async() => {
const strings500 = Array.from({ length: 500 }, (_, index) => {
return { data: { id: index, text: `String ${String(index)}` } };
});
const stringsRemaining = [
{ data: { id: 500, text: "String 500" } },
{ data: { id: 501, text: "String 501" } },
];
vi.mocked(global.fetch).
mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: strings500 });
},
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: stringsRemaining });
},
} as Response);
const result = await getStrings(
"123",
"https://api.crowdin.com/api/v2",
"token",
);
expect(result).toHaveLength(502);
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(global.fetch).toHaveBeenNthCalledWith(
2,
"https://api.crowdin.com/api/v2/projects/123/strings?limit=500&offset=500",
expect.any(Object),
);
});
it("should return empty array when no strings exist", async() => {
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: [] });
},
} as Response);
const result = await getStrings(
"456",
"https://api.crowdin.com/api/v2",
"token",
);
expect(result).toEqual([]);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it("should unwrap the data from each string object", async() => {
const mockStrings = [
{
data: {
context: "greeting context",
id: 1,
identifier: "hello",
isHidden: false,
text: "Hello World",
},
},
];
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: mockStrings });
},
} as Response);
const result = await getStrings(
"789",
"https://api.crowdin.com/api/v2",
"token",
);
expect(result[0]).toEqual({
context: "greeting context",
id: 1,
identifier: "hello",
isHidden: false,
text: "Hello World",
});
});
});
@@ -0,0 +1,158 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { backoffAndRetry } from "../backoffAndRetry.js";
describe("backoffAndRetry", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.spyOn(global, "fetch");
vi.spyOn(console, "error").mockImplementation(() => {
return undefined;
});
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("should return JSON data on successful response", async() => {
const mockData = { success: true, data: "test" };
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve(mockData);
},
ok: true,
} as Response);
const result = await backoffAndRetry<typeof mockData>(
"https://example.com/api",
);
expect(result).toEqual(mockData);
expect(global.fetch).toHaveBeenCalledWith(
"https://example.com/api",
{},
);
});
it("should pass options to fetch", async() => {
const mockData = { result: "success" };
const options: RequestInit = {
headers: { authorization: "Bearer token" },
method: "POST",
};
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve(mockData);
},
ok: true,
} as Response);
await backoffAndRetry("https://example.com/api", options);
expect(global.fetch).toHaveBeenCalledWith(
"https://example.com/api",
options,
);
});
it("should retry after 5 seconds on 429 response", async() => {
const mockData = { result: "success" };
vi.mocked(global.fetch).
mockResolvedValueOnce({
ok: false,
status: 429,
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve(mockData);
},
ok: true,
} as Response);
const resultPromise = backoffAndRetry<typeof mockData>(
"https://example.com/api",
);
await vi.advanceTimersByTimeAsync(5000);
const result = await resultPromise;
expect(result).toEqual(mockData);
expect(global.fetch).toHaveBeenCalledTimes(2);
});
it("should handle multiple 429 responses with backoff", async() => {
const mockData = { result: "finally" };
vi.mocked(global.fetch).
mockResolvedValueOnce({
ok: false,
status: 429,
} as Response).
mockResolvedValueOnce({
ok: false,
status: 429,
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve(mockData);
},
ok: true,
} as Response);
const resultPromise = backoffAndRetry<typeof mockData>(
"https://example.com/api",
);
await vi.advanceTimersByTimeAsync(5000);
await vi.advanceTimersByTimeAsync(5000);
const result = await resultPromise;
expect(result).toEqual(mockData);
expect(global.fetch).toHaveBeenCalledTimes(3);
});
it("should return null on non-429 error response", async() => {
vi.mocked(global.fetch).mockResolvedValueOnce({
ok: false,
status: 500,
} as Response);
const result = await backoffAndRetry("https://example.com/api");
expect(result).toBeNull();
expect(console.error).toHaveBeenCalled();
});
it("should return null on fetch error", async() => {
vi.mocked(global.fetch).mockRejectedValueOnce(new Error("Network error"));
const result = await backoffAndRetry("https://example.com/api");
expect(result).toBeNull();
expect(console.error).toHaveBeenCalled();
});
it("should return null on JSON parse error", async() => {
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.reject(new Error("Invalid JSON"));
},
ok: true,
} as Response);
const result = await backoffAndRetry("https://example.com/api");
expect(result).toBeNull();
expect(console.error).toHaveBeenCalled();
});
});
@@ -0,0 +1,242 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { describe, it, expect } from "vitest";
import { getMimeType } from "../mimeType.js";
describe("getMimeType", () => {
describe("image types", () => {
it("should return image/png for .png files", () => {
expect(getMimeType("image.png")).toBe("image/png");
expect(getMimeType("/path/to/image.png")).toBe("image/png");
});
it("should return image/jpeg for .jpg and .jpeg files", () => {
expect(getMimeType("photo.jpg")).toBe("image/jpeg");
expect(getMimeType("photo.jpeg")).toBe("image/jpeg");
});
it("should return image/gif for .gif files", () => {
expect(getMimeType("animation.gif")).toBe("image/gif");
});
it("should return image/webp for .webp files", () => {
expect(getMimeType("image.webp")).toBe("image/webp");
});
it("should return image/svg+xml for .svg files", () => {
expect(getMimeType("icon.svg")).toBe("image/svg+xml");
});
it("should return image/bmp for .bmp files", () => {
expect(getMimeType("bitmap.bmp")).toBe("image/bmp");
});
it("should return image/tiff for .tif and .tiff files", () => {
expect(getMimeType("scan.tif")).toBe("image/tiff");
expect(getMimeType("scan.tiff")).toBe("image/tiff");
});
it("should return image/x-icon for .ico files", () => {
expect(getMimeType("favicon.ico")).toBe("image/x-icon");
});
});
describe("video types", () => {
it("should return video/mp4 for .mp4 files", () => {
expect(getMimeType("video.mp4")).toBe("video/mp4");
});
it("should return video/webm for .webm files", () => {
expect(getMimeType("video.webm")).toBe("video/webm");
});
it("should return video/x-msvideo for .avi files", () => {
expect(getMimeType("video.avi")).toBe("video/x-msvideo");
});
it("should return video/quicktime for .mov files", () => {
expect(getMimeType("video.mov")).toBe("video/quicktime");
});
it("should return video/x-matroska for .mkv files", () => {
expect(getMimeType("video.mkv")).toBe("video/x-matroska");
});
});
describe("audio types", () => {
it("should return audio/mpeg for .mp3 files", () => {
expect(getMimeType("song.mp3")).toBe("audio/mpeg");
});
it("should return audio/wav for .wav files", () => {
expect(getMimeType("sound.wav")).toBe("audio/wav");
});
it("should return audio/ogg for .ogg files", () => {
expect(getMimeType("audio.ogg")).toBe("audio/ogg");
});
it("should return audio/aac for .aac files", () => {
expect(getMimeType("audio.aac")).toBe("audio/aac");
});
it("should return audio/flac for .flac files", () => {
expect(getMimeType("music.flac")).toBe("audio/flac");
});
});
describe("document types", () => {
it("should return application/pdf for .pdf files", () => {
expect(getMimeType("document.pdf")).toBe("application/pdf");
});
it("should return application/json for .json files", () => {
expect(getMimeType("data.json")).toBe("application/json");
});
it("should return text/plain for .txt files", () => {
expect(getMimeType("readme.txt")).toBe("text/plain");
});
it("should return text/markdown for .md files", () => {
expect(getMimeType("README.md")).toBe("text/markdown");
});
it("should return text/html for .html and .htm files", () => {
expect(getMimeType("page.html")).toBe("text/html");
expect(getMimeType("page.htm")).toBe("text/html");
});
it("should return text/css for .css files", () => {
expect(getMimeType("styles.css")).toBe("text/css");
});
it("should return text/javascript for .js files", () => {
expect(getMimeType("script.js")).toBe("text/javascript");
});
it("should return text/csv for .csv files", () => {
expect(getMimeType("data.csv")).toBe("text/csv");
});
it("should return application/xml for .xml files", () => {
expect(getMimeType("data.xml")).toBe("application/xml");
});
});
describe("Microsoft Office types", () => {
it("should return correct MIME type for .doc files", () => {
expect(getMimeType("document.doc")).toBe("application/msword");
});
it("should return correct MIME type for .docx files", () => {
expect(getMimeType("document.docx")).toBe(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
);
});
it("should return correct MIME type for .xls files", () => {
expect(getMimeType("spreadsheet.xls")).toBe("application/vnd.ms-excel");
});
it("should return correct MIME type for .xlsx files", () => {
expect(getMimeType("spreadsheet.xlsx")).toBe(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
});
it("should return correct MIME type for .ppt files", () => {
expect(getMimeType("presentation.ppt")).toBe(
"application/vnd.ms-powerpoint",
);
});
it("should return correct MIME type for .pptx files", () => {
expect(getMimeType("presentation.pptx")).toBe(
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
);
});
});
describe("font types", () => {
it("should return font/ttf for .ttf files", () => {
expect(getMimeType("font.ttf")).toBe("font/ttf");
});
it("should return font/otf for .otf files", () => {
expect(getMimeType("font.otf")).toBe("font/otf");
});
it("should return font/woff for .woff files", () => {
expect(getMimeType("font.woff")).toBe("font/woff");
});
it("should return font/woff2 for .woff2 files", () => {
expect(getMimeType("font.woff2")).toBe("font/woff2");
});
it("should return application/vnd.ms-fontobject for .eot files", () => {
expect(getMimeType("font.eot")).toBe("application/vnd.ms-fontobject");
});
});
describe("archive types", () => {
it("should return application/zip for .zip files", () => {
expect(getMimeType("archive.zip")).toBe("application/zip");
});
it("should return application/gzip for .gz files", () => {
expect(getMimeType("archive.gz")).toBe("application/gzip");
});
it("should return application/x-tar for .tar files", () => {
expect(getMimeType("archive.tar")).toBe("application/x-tar");
});
it("should return application/x-rar-compressed for .rar files", () => {
expect(getMimeType("archive.rar")).toBe("application/x-rar-compressed");
});
it("should return application/x-7z-compressed for .7z files", () => {
expect(getMimeType("archive.7z")).toBe("application/x-7z-compressed");
});
});
describe("case insensitivity", () => {
it("should handle uppercase extensions", () => {
expect(getMimeType("IMAGE.PNG")).toBe("image/png");
expect(getMimeType("VIDEO.MP4")).toBe("video/mp4");
});
it("should handle mixed case extensions", () => {
expect(getMimeType("file.JpG")).toBe("image/jpeg");
expect(getMimeType("file.Mp3")).toBe("audio/mpeg");
});
});
describe("unknown types", () => {
it("should return undefined for unknown extensions", () => {
expect(getMimeType("file.xyz")).toBeUndefined();
expect(getMimeType("file.unknown")).toBeUndefined();
});
it("should return undefined for files without extensions", () => {
expect(getMimeType("filename")).toBeUndefined();
});
});
describe("path handling", () => {
it("should handle full paths", () => {
expect(getMimeType("/home/user/documents/image.png")).toBe("image/png");
expect(getMimeType("./relative/path/video.mp4")).toBe("video/mp4");
});
it("should handle filenames with multiple dots", () => {
expect(getMimeType("file.name.with.dots.png")).toBe("image/png");
expect(getMimeType("archive.tar.gz")).toBe("application/gzip");
});
});
});
@@ -0,0 +1,258 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { paginatedFetch } from "../paginatedFetch.js";
type TestItem = Record<string, unknown>;
describe("paginatedFetch", () => {
beforeEach(() => {
vi.spyOn(global, "fetch");
vi.spyOn(console, "log").mockImplementation(() => {
return undefined;
});
vi.spyOn(console, "error").mockImplementation(() => {
return undefined;
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should fetch a single page when results fit in one request", async() => {
const mockData = [
{ id: 1, name: "item1" },
{ id: 2, name: "item2" },
];
vi.mocked(global.fetch).
mockResolvedValueOnce({
json: () => {
return Promise.resolve(mockData);
},
ok: true,
status: 200,
statusText: "OK",
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve([]);
},
ok: true,
status: 200,
statusText: "OK",
} as Response);
const result = await paginatedFetch<Array<TestItem>>(
"https://example.com/api",
10,
);
expect(result).toEqual(mockData);
expect(global.fetch).toHaveBeenCalledWith(
"https://example.com/api?limit=10&page=1&offset=0",
{},
);
});
it("should handle multiple pages", async() => {
const page1 = [
{ id: 1 },
{ id: 2 },
];
const page2 = [
{ id: 3 },
{ id: 4 },
];
const page3: Array<TestItem> = [];
vi.mocked(global.fetch).
mockResolvedValueOnce({
json: () => {
return Promise.resolve(page1);
},
ok: true,
status: 200,
statusText: "OK",
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve(page2);
},
ok: true,
status: 200,
statusText: "OK",
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve(page3);
},
ok: true,
status: 200,
statusText: "OK",
} as Response);
const result = await paginatedFetch<Array<TestItem>>(
"https://example.com/api",
2,
);
expect(result).toEqual([ ...page1, ...page2 ]);
expect(global.fetch).toHaveBeenCalledTimes(3);
expect(global.fetch).toHaveBeenNthCalledWith(
1,
"https://example.com/api?limit=2&page=1&offset=0",
{},
);
expect(global.fetch).toHaveBeenNthCalledWith(
2,
"https://example.com/api?limit=2&page=2&offset=2",
{},
);
expect(global.fetch).toHaveBeenNthCalledWith(
3,
"https://example.com/api?limit=2&page=3&offset=4",
{},
);
});
it("should pass options to fetch", async() => {
const options: RequestInit = {
headers: { authorization: "Bearer token" },
};
vi.mocked(global.fetch).
mockResolvedValueOnce({
json: () => {
return Promise.resolve([{ id: 1 }]);
},
ok: true,
status: 200,
statusText: "OK",
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve([]);
},
ok: true,
status: 200,
statusText: "OK",
} as Response);
await paginatedFetch<Array<TestItem>>(
"https://example.com/api",
10,
options,
);
expect(global.fetch).toHaveBeenCalledWith(
expect.any(String),
options,
);
});
it("should throw on first page error", async() => {
vi.mocked(global.fetch).mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Internal Server Error",
} as Response);
await expect(
paginatedFetch<Array<TestItem>>("https://example.com/api", 10),
).rejects.toThrow(
"Failed to fetch https://example.com/api?limit=10&page=1&offset=0: 500 Internal Server Error",
);
});
it("should throw if first page is not an array", async() => {
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: "not an array" });
},
ok: true,
status: 200,
statusText: "OK",
} as Response);
await expect(
paginatedFetch<Array<TestItem>>("https://example.com/api", 10),
).rejects.toThrow("Expected array response but got object");
});
it("should stop pagination on subsequent page error", async() => {
vi.mocked(global.fetch).
mockResolvedValueOnce({
json: () => {
return Promise.resolve([{ id: 1 }]);
},
ok: true,
status: 200,
statusText: "OK",
} as Response).
mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Internal Server Error",
} as Response);
const result = await paginatedFetch<Array<TestItem>>(
"https://example.com/api",
10,
);
expect(result).toEqual([{ id: 1 }]);
expect(console.error).toHaveBeenCalled();
});
it("should stop pagination if subsequent page is not an array", async() => {
vi.mocked(global.fetch).
mockResolvedValueOnce({
json: () => {
return Promise.resolve([{ id: 1 }]);
},
ok: true,
status: 200,
statusText: "OK",
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve({ notAnArray: true });
},
ok: true,
status: 200,
statusText: "OK",
} as Response);
const result = await paginatedFetch<Array<TestItem>>(
"https://example.com/api",
10,
);
expect(result).toEqual([{ id: 1 }]);
expect(console.error).toHaveBeenCalled();
});
it("should handle empty first page", async() => {
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve([]);
},
ok: true,
status: 200,
statusText: "OK",
} as Response);
const result = await paginatedFetch<Array<TestItem>>(
"https://example.com/api",
10,
);
expect(result).toEqual([]);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,128 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { describe, it, expect } from "vitest";
import { serialiseJsonOrError } from "../serialiseJsonOrError.js";
describe("serialiseJsonOrError", () => {
describe("valid JSON parsing", () => {
it("should parse a simple object", () => {
const result = serialiseJsonOrError('{"key": "value"}');
expect(result).toEqual({ key: "value" });
});
it("should parse an object with multiple properties", () => {
const result = serialiseJsonOrError(
'{"name": "test", "count": 42, "active": true}',
);
expect(result).toEqual({ name: "test", count: 42, active: true });
});
it("should parse nested objects", () => {
const result = serialiseJsonOrError(
'{"outer": {"inner": {"deep": "value"}}}',
);
expect(result).toEqual({ outer: { inner: { deep: "value" } } });
});
it("should parse objects with arrays", () => {
const result = serialiseJsonOrError('{"items": [1, 2, 3]}');
expect(result).toEqual({ items: [ 1, 2, 3 ] });
});
it("should parse objects with null values", () => {
const result = serialiseJsonOrError('{"value": null}');
expect(result).toEqual({ value: null });
});
it("should parse an empty object", () => {
const result = serialiseJsonOrError("{}");
expect(result).toEqual({});
});
it("should parse objects with special characters in strings", () => {
const result = serialiseJsonOrError('{"text": "hello\\nworld"}');
expect(result).toEqual({ text: "hello\nworld" });
});
it("should parse objects with unicode characters", () => {
const result = serialiseJsonOrError('{"emoji": "🎉", "text": "日本語"}');
expect(result).toEqual({ emoji: "🎉", text: "日本語" });
});
it("should parse objects with numeric keys (as strings)", () => {
const result = serialiseJsonOrError('{"123": "numeric key"}');
expect(result).toEqual({ "123": "numeric key" });
});
});
describe("invalid JSON handling", () => {
it("should return null for invalid JSON syntax", () => {
expect(serialiseJsonOrError("{invalid}")).toBeNull();
expect(serialiseJsonOrError("not json")).toBeNull();
});
it("should return null for unclosed braces", () => {
expect(serialiseJsonOrError('{"key": "value"')).toBeNull();
expect(serialiseJsonOrError('{"key": {')).toBeNull();
});
it("should return null for trailing commas", () => {
expect(serialiseJsonOrError('{"key": "value",}')).toBeNull();
});
it("should return null for single quotes", () => {
expect(serialiseJsonOrError("{'key': 'value'}")).toBeNull();
});
it("should return null for unquoted keys", () => {
expect(serialiseJsonOrError('{key: "value"}')).toBeNull();
});
it("should return null for empty string", () => {
expect(serialiseJsonOrError("")).toBeNull();
});
it("should return null for whitespace only", () => {
expect(serialiseJsonOrError(" ")).toBeNull();
});
});
describe("edge cases", () => {
it("should parse a plain array (returns as object)", () => {
const result = serialiseJsonOrError("[1, 2, 3]");
expect(result).toEqual([ 1, 2, 3 ]);
});
it("should parse primitive values", () => {
expect(serialiseJsonOrError("42")).toBe(42);
expect(serialiseJsonOrError('"string"')).toBe("string");
expect(serialiseJsonOrError("true")).toBe(true);
expect(serialiseJsonOrError("false")).toBe(false);
expect(serialiseJsonOrError("null")).toBeNull();
});
it("should handle very large numbers", () => {
const result = serialiseJsonOrError('{"big": 9007199254740991}');
expect(result).toEqual({ big: 9007199254740991 });
});
it("should handle scientific notation", () => {
const result = serialiseJsonOrError('{"sci": 1.23e10}');
expect(result).toEqual({ sci: 1.23e10 });
});
it("should handle deeply nested structures", () => {
const deepJson = '{"a":{"b":{"c":{"d":{"e":"deep"}}}}}';
const result = serialiseJsonOrError(deepJson);
expect(result).toEqual({ a: { b: { c: { d: { e: "deep" } } } } });
});
it("should handle JSON with whitespace", () => {
const result = serialiseJsonOrError(' { "key" : "value" } ');
expect(result).toEqual({ key: "value" });
});
});
});
@@ -0,0 +1,81 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { sleep } from "../sleep.js";
describe("sleep", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("should resolve after the specified time", async() => {
const sleepPromise = sleep(1000);
vi.advanceTimersByTime(1000);
await expect(sleepPromise).resolves.toBeUndefined();
});
it("should not resolve before the specified time", async() => {
let resolved = false;
const sleepPromise = sleep(1000).then(() => {
resolved = true;
});
vi.advanceTimersByTime(999);
await Promise.resolve();
expect(resolved).toBe(false);
vi.advanceTimersByTime(1);
await sleepPromise;
expect(resolved).toBe(true);
});
it("should handle zero milliseconds", async() => {
const sleepPromise = sleep(0);
vi.advanceTimersByTime(0);
await expect(sleepPromise).resolves.toBeUndefined();
});
it("should handle small delays", async() => {
const sleepPromise = sleep(10);
vi.advanceTimersByTime(10);
await expect(sleepPromise).resolves.toBeUndefined();
});
it("should handle large delays", async() => {
const sleepPromise = sleep(60000);
vi.advanceTimersByTime(60000);
await expect(sleepPromise).resolves.toBeUndefined();
});
describe("real timer tests", () => {
beforeEach(() => {
vi.useRealTimers();
});
it("should actually wait with real timers (short delay)", async() => {
const start = Date.now();
await sleep(50);
const elapsed = Date.now() - start;
expect(elapsed).toBeGreaterThanOrEqual(45);
expect(elapsed).toBeLessThan(150);
});
});
});
+19
View File
@@ -0,0 +1,19 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
include: ["src/**/__tests__/**/*.test.ts"],
coverage: {
provider: "v8",
reporter: ["text", "html"],
exclude: ["node_modules/", "**/__tests__/**"],
},
},
});