Compare commits
27 Commits
playwright
...
performanc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0104dbb99 | ||
|
|
ececbd3cc2 | ||
|
|
142c0c0e64 | ||
|
|
b9b583a14e | ||
|
|
20043149fd | ||
|
|
12dd69e8bd | ||
|
|
d9db248612 | ||
|
|
8e2cbdbd1b | ||
|
|
4140f348c0 | ||
|
|
33ba03d208 | ||
|
|
a3ec53b213 | ||
|
|
38b4d73284 | ||
|
|
dd3de06252 | ||
|
|
d09eb5537d | ||
|
|
294034d9b4 | ||
|
|
4f5e9aebcd | ||
|
|
138a7ee3cf | ||
|
|
9bfa439e9c | ||
|
|
2e9b2ee58e | ||
|
|
d33d423b7e | ||
|
|
43745b7e39 | ||
|
|
835619fc66 | ||
|
|
76b382add8 | ||
|
|
e5e0647fcf | ||
|
|
676cd6a537 | ||
|
|
09bf7db536 | ||
|
|
1dd3d9f8d1 |
31
.cursor/rules/building.mdc
Normal file
31
.cursor/rules/building.mdc
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
# Building Guidelines
|
||||
|
||||
## Configurations
|
||||
|
||||
- The project supports builds using **Vite** for web and **Capacitor** for hybrid
|
||||
apps.
|
||||
- Capacitor is used for **iOS**, **Android**, and **Electron** targets.
|
||||
- All builds support three modes: **development**, **testing**, and **production**.
|
||||
|
||||
## Build Scripts
|
||||
|
||||
- `build-web.sh`
|
||||
- Builds a **web-only application**.
|
||||
- Defaults to **development mode** unless overridden.
|
||||
|
||||
- `build-ios.sh`
|
||||
- Builds an **iOS hybrid native application** using Capacitor.
|
||||
|
||||
- `build-android.sh`
|
||||
- Builds an **Android hybrid native application** using Capacitor.
|
||||
|
||||
- `build-electron.sh`
|
||||
- Builds an **Electron hybrid desktop application** using Capacitor.
|
||||
|
||||
## npm Scripts
|
||||
|
||||
- npm scripts delegate to the `build-*` shell scripts.
|
||||
- Parameter flags determine the **build mode** (`development`, `testing`, `production`).
|
||||
@@ -20,14 +20,14 @@ in Cursor.
|
||||
|
||||
```
|
||||
absurd-sql/
|
||||
├── src/ # Source code
|
||||
├── dist/ # Built files
|
||||
├── package.json # Dependencies and scripts
|
||||
├── rollup.config.js # Build configuration
|
||||
└── jest.config.js # Test configuration
|
||||
├── src/ # Place source code here
|
||||
├── dist/ # Place built files here
|
||||
├── package.json # Maintain dependencies and scripts here
|
||||
├── rollup.config.js # Maintain build configuration here
|
||||
└── jest.config.js # Maintain test configuration here
|
||||
```
|
||||
|
||||
## Development Rules
|
||||
## Directives
|
||||
|
||||
### 1. Worker Thread Requirements
|
||||
|
||||
@@ -62,7 +62,7 @@ Recommended database settings:
|
||||
|
||||
```sql
|
||||
PRAGMA journal_mode=MEMORY;
|
||||
PRAGMA page_size=8192; -- Optional, but recommended
|
||||
PRAGMA page_size=8192;
|
||||
```
|
||||
|
||||
### 6. Development Workflow
|
||||
@@ -72,11 +72,10 @@ PRAGMA page_size=8192; -- Optional, but recommended
|
||||
```bash
|
||||
yarn add @jlongster/sql.js absurd-sql
|
||||
```
|
||||
|
||||
2. Development commands:
|
||||
- `yarn build` - Build the project
|
||||
- `yarn jest` - Run tests
|
||||
- `yarn serve` - Start development server
|
||||
2. Execute commands as follows:
|
||||
- `yarn build` → build the project
|
||||
- `yarn jest` → run all tests
|
||||
- `yarn serve` → launch development server
|
||||
|
||||
### 7. Testing Guidelines
|
||||
|
||||
@@ -120,16 +119,15 @@ PRAGMA page_size=8192; -- Optional, but recommended
|
||||
- Check worker communication in console
|
||||
- Use performance monitoring tools
|
||||
|
||||
## Common Patterns
|
||||
## Required Patterns
|
||||
|
||||
### Worker Initialization
|
||||
|
||||
```javascript
|
||||
// Main thread
|
||||
import { initBackend } from 'absurd-sql/dist/indexeddb-main-thread';
|
||||
|
||||
function init() {
|
||||
let worker = new Worker(new URL('./index.worker.js', import.meta.url));
|
||||
const worker = new Worker(new URL('./index.worker.js', import.meta.url));
|
||||
initBackend(worker);
|
||||
}
|
||||
```
|
||||
@@ -137,19 +135,18 @@ function init() {
|
||||
### Database Setup
|
||||
|
||||
```javascript
|
||||
// Worker thread
|
||||
import initSqlJs from '@jlongster/sql.js';
|
||||
import { SQLiteFS } from 'absurd-sql';
|
||||
import IndexedDBBackend from 'absurd-sql/dist/indexeddb-backend';
|
||||
|
||||
async function setupDatabase() {
|
||||
let SQL = await initSqlJs({ locateFile: file => file });
|
||||
let sqlFS = new SQLiteFS(SQL.FS, new IndexedDBBackend());
|
||||
const SQL = await initSqlJs({ locateFile: f => f });
|
||||
const sqlFS = new SQLiteFS(SQL.FS, new IndexedDBBackend());
|
||||
SQL.register_for_idb(sqlFS);
|
||||
|
||||
|
||||
SQL.FS.mkdir('/sql');
|
||||
SQL.FS.mount(sqlFS, {}, '/sql');
|
||||
|
||||
|
||||
return new SQL.Database('/sql/db.sqlite', { filename: true });
|
||||
}
|
||||
```
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -45,6 +45,9 @@ dist-electron-packages
|
||||
# Test files generated by scripts test-ios.js & test-android.js
|
||||
.generated/
|
||||
|
||||
# Test stability analysis results
|
||||
test-stability-results/
|
||||
|
||||
.env.default
|
||||
vendor/
|
||||
|
||||
|
||||
247
scripts/test-stability-common-zsh.sh
Executable file
247
scripts/test-stability-common-zsh.sh
Executable file
@@ -0,0 +1,247 @@
|
||||
#!/bin/zsh
|
||||
|
||||
# Test Stability Runner Common Functions for TimeSafari (Zsh Version)
|
||||
# Shared functionality for zsh test stability runners
|
||||
# Author: Matthew Raymer
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Configuration
|
||||
TOTAL_RUNS=10
|
||||
RESULTS_DIR="test-stability-results"
|
||||
TIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S")
|
||||
LOG_FILE="${RESULTS_DIR}/stability-run-${TIMESTAMP}.log"
|
||||
SUMMARY_FILE="${RESULTS_DIR}/stability-summary-${TIMESTAMP}.json"
|
||||
FAILURE_LOG="${RESULTS_DIR}/failure-details-${TIMESTAMP}.log"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
MAGENTA='\033[0;35m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Progress bar characters
|
||||
PROGRESS_CHAR="█"
|
||||
EMPTY_CHAR="░"
|
||||
|
||||
# Initialize results tracking (zsh associative arrays)
|
||||
typeset -A test_results
|
||||
typeset -A test_failures
|
||||
typeset -A test_successes
|
||||
typeset -A run_times
|
||||
typeset -A test_names
|
||||
|
||||
# Create results directory
|
||||
mkdir -p "${RESULTS_DIR}"
|
||||
|
||||
# Logging functions
|
||||
log_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "${LOG_FILE}"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "${LOG_FILE}"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "${LOG_FILE}"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "${LOG_FILE}"
|
||||
}
|
||||
|
||||
# Function to extract test names from Playwright output
|
||||
extract_test_names() {
|
||||
local output_file="$1"
|
||||
# Extract test names from lines like "✓ 13 [chromium] › test-playwright/30-record-gift.spec.ts:84:5 › Record something given"
|
||||
grep -E "✓.*test-playwright" "$output_file" | sed 's/.*test-playwright\///' | sed 's/:[0-9]*:[0-9]*.*$//' | sort | uniq
|
||||
}
|
||||
|
||||
# Function to check if test passed in a run
|
||||
test_passed_in_run() {
|
||||
local test_name="$1"
|
||||
local run_output="$2"
|
||||
grep -q "✓.*test-playwright/$test_name" "$run_output" 2>/dev/null
|
||||
}
|
||||
|
||||
# Function to check if test failed in a run
|
||||
test_failed_in_run() {
|
||||
local test_name="$1"
|
||||
local run_output="$2"
|
||||
grep -q "✗.*test-playwright/$test_name" "$run_output" 2>/dev/null
|
||||
}
|
||||
|
||||
# Function to get test duration
|
||||
get_test_duration() {
|
||||
local test_name="$1"
|
||||
local run_output="$2"
|
||||
local duration=$(grep -A 1 "✓ $test_name\|✗ $test_name" "$run_output" | grep -o "[0-9]\+ms" | head -1)
|
||||
echo "${duration:-unknown}"
|
||||
}
|
||||
|
||||
# Function to calculate percentage
|
||||
calculate_percentage() {
|
||||
local passes="$1"
|
||||
local total="$2"
|
||||
if [ "$total" -eq 0 ]; then
|
||||
echo "0"
|
||||
else
|
||||
echo "$((passes * 100 / total))"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to display progress bar
|
||||
show_progress() {
|
||||
local current="$1"
|
||||
local total="$2"
|
||||
local percentage=$((current * 100 / total))
|
||||
local filled=$((current * 50 / total))
|
||||
local empty=$((50 - filled))
|
||||
|
||||
local progress_bar=""
|
||||
for ((i=0; i<filled; i++)); do
|
||||
progress_bar+="$PROGRESS_CHAR"
|
||||
done
|
||||
for ((i=0; i<empty; i++)); do
|
||||
progress_bar+="$EMPTY_CHAR"
|
||||
done
|
||||
|
||||
printf "\r%s [%d%%] (%d/%d)" "$progress_bar" "$percentage" "$current" "$total"
|
||||
}
|
||||
|
||||
# Function to run a single test execution
|
||||
run_single_test() {
|
||||
local run_number="$1"
|
||||
local run_output="${RESULTS_DIR}/run-${run_number}-output.txt"
|
||||
local start_time=$(date +%s)
|
||||
|
||||
log_info "Starting run $run_number/$TOTAL_RUNS"
|
||||
|
||||
# Run the test suite and capture output
|
||||
if npm run test:web > "$run_output" 2>&1; then
|
||||
local end_time=$(date +%s)
|
||||
local duration=$((end_time - start_time))
|
||||
|
||||
test_results[$run_number]="PASS"
|
||||
test_successes[$run_number]="true"
|
||||
run_times[$run_number]="$duration"
|
||||
|
||||
log_success "Run $run_number completed successfully in ${duration}s"
|
||||
return 0
|
||||
else
|
||||
local end_time=$(date +%s)
|
||||
local duration=$((end_time - start_time))
|
||||
|
||||
test_results[$run_number]="FAIL"
|
||||
test_failures[$run_number]="true"
|
||||
run_times[$run_number]="$duration"
|
||||
|
||||
log_error "Run $run_number failed after ${duration}s"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to generate summary report
|
||||
generate_summary_report() {
|
||||
log_info "Generating summary report..."
|
||||
|
||||
local total_passes=0
|
||||
local total_failures=0
|
||||
local total_time=0
|
||||
|
||||
for run_number in $(seq 1 $TOTAL_RUNS); do
|
||||
if [[ "${test_results[$run_number]:-}" == "PASS" ]]; then
|
||||
((total_passes++))
|
||||
else
|
||||
((total_failures++))
|
||||
fi
|
||||
|
||||
if [[ -n "${run_times[$run_number]:-}" ]]; then
|
||||
((total_time += run_times[$run_number]))
|
||||
fi
|
||||
done
|
||||
|
||||
local success_rate=$(calculate_percentage $total_passes $TOTAL_RUNS)
|
||||
local avg_time=$((total_time / TOTAL_RUNS))
|
||||
|
||||
# Create summary JSON
|
||||
cat > "$SUMMARY_FILE" << EOF
|
||||
{
|
||||
"timestamp": "$TIMESTAMP",
|
||||
"total_runs": $TOTAL_RUNS,
|
||||
"successful_runs": $total_passes,
|
||||
"failed_runs": $total_failures,
|
||||
"success_rate": $success_rate,
|
||||
"average_time_seconds": $avg_time,
|
||||
"total_time_seconds": $total_time,
|
||||
"run_details": {
|
||||
EOF
|
||||
|
||||
for run_number in $(seq 1 $TOTAL_RUNS); do
|
||||
local comma=""
|
||||
if [ "$run_number" -lt $TOTAL_RUNS ]; then
|
||||
comma=","
|
||||
fi
|
||||
|
||||
cat >> "$SUMMARY_FILE" << EOF
|
||||
"run_$run_number": {
|
||||
"result": "${test_results[$run_number]:-unknown}",
|
||||
"duration_seconds": "${run_times[$run_number]:-unknown}",
|
||||
"timestamp": "$(date -d @${run_times[$run_number]:-0} +%Y-%m-%d_%H-%M-%S 2>/dev/null || echo 'unknown')"
|
||||
}$comma
|
||||
EOF
|
||||
done
|
||||
|
||||
cat >> "$SUMMARY_FILE" << EOF
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
log_success "Summary report generated: $SUMMARY_FILE"
|
||||
}
|
||||
|
||||
# Function to display final results
|
||||
display_final_results() {
|
||||
echo
|
||||
echo "=========================================="
|
||||
echo " TEST STABILITY RESULTS "
|
||||
echo "=========================================="
|
||||
echo "Timestamp: $TIMESTAMP"
|
||||
echo "Total Runs: $TOTAL_RUNS"
|
||||
|
||||
local total_passes=0
|
||||
local total_failures=0
|
||||
local total_time=0
|
||||
|
||||
for run_number in $(seq 1 $TOTAL_RUNS); do
|
||||
if [[ "${test_results[$run_number]:-}" == "PASS" ]]; then
|
||||
((total_passes++))
|
||||
else
|
||||
((total_failures++))
|
||||
fi
|
||||
|
||||
if [[ -n "${run_times[$run_number]:-}" ]]; then
|
||||
((total_time += run_times[$run_number]))
|
||||
fi
|
||||
done
|
||||
|
||||
local success_rate=$(calculate_percentage $total_passes $TOTAL_RUNS)
|
||||
local avg_time=$((total_time / TOTAL_RUNS))
|
||||
|
||||
echo "Successful Runs: $total_passes"
|
||||
echo "Failed Runs: $total_failures"
|
||||
echo "Success Rate: ${success_rate}%"
|
||||
echo "Average Time: ${avg_time}s"
|
||||
echo "Total Time: ${total_time}s"
|
||||
echo "=========================================="
|
||||
echo
|
||||
echo "Detailed results saved to:"
|
||||
echo " - Log: $LOG_FILE"
|
||||
echo " - Summary: $SUMMARY_FILE"
|
||||
echo " - Results directory: $RESULTS_DIR"
|
||||
echo
|
||||
}
|
||||
347
scripts/test-stability-common.sh
Normal file
347
scripts/test-stability-common.sh
Normal file
@@ -0,0 +1,347 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Test Stability Runner Common Functions for TimeSafari
|
||||
# Shared functionality for all test stability runners
|
||||
# Author: Matthew Raymer
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Configuration
|
||||
TOTAL_RUNS=10
|
||||
RESULTS_DIR="test-stability-results"
|
||||
TIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S")
|
||||
LOG_FILE="${RESULTS_DIR}/stability-run-${TIMESTAMP}.log"
|
||||
SUMMARY_FILE="${RESULTS_DIR}/stability-summary-${TIMESTAMP}.json"
|
||||
FAILURE_LOG="${RESULTS_DIR}/failure-details-${TIMESTAMP}.log"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
MAGENTA='\033[0;35m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Progress bar characters
|
||||
PROGRESS_CHAR="█"
|
||||
EMPTY_CHAR="░"
|
||||
|
||||
# Initialize results tracking (bash associative arrays)
|
||||
declare -A test_results
|
||||
declare -A test_failures
|
||||
declare -A test_successes
|
||||
declare -A run_times
|
||||
declare -A test_names
|
||||
|
||||
# Create results directory
|
||||
mkdir -p "${RESULTS_DIR}"
|
||||
|
||||
# Logging functions
|
||||
log_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "${LOG_FILE}"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "${LOG_FILE}"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "${LOG_FILE}"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "${LOG_FILE}"
|
||||
}
|
||||
|
||||
# Function to extract test names from Playwright output
|
||||
extract_test_names() {
|
||||
local output_file="$1"
|
||||
# Extract test names from lines like "✓ 13 [chromium] › test-playwright/30-record-gift.spec.ts:84:5 › Record something given"
|
||||
grep -E "✓.*test-playwright" "$output_file" | sed 's/.*test-playwright\///' | sed 's/:[0-9]*:[0-9]*.*$//' | sort | uniq
|
||||
}
|
||||
|
||||
# Function to check if test passed in a run
|
||||
test_passed_in_run() {
|
||||
local test_name="$1"
|
||||
local run_output="$2"
|
||||
grep -q "✓.*test-playwright/$test_name" "$run_output" 2>/dev/null
|
||||
}
|
||||
|
||||
# Function to check if test failed in a run
|
||||
test_failed_in_run() {
|
||||
local test_name="$1"
|
||||
local run_output="$2"
|
||||
grep -q "✗.*test-playwright/$test_name" "$run_output" 2>/dev/null
|
||||
}
|
||||
|
||||
# Function to get test duration
|
||||
get_test_duration() {
|
||||
local test_name="$1"
|
||||
local run_output="$2"
|
||||
local duration=$(grep -A 1 "✓ $test_name\|✗ $test_name" "$run_output" | grep -o "[0-9]\+ms" | head -1)
|
||||
echo "${duration:-unknown}"
|
||||
}
|
||||
|
||||
# Function to calculate percentage
|
||||
calculate_percentage() {
|
||||
local passes="$1"
|
||||
local total="$2"
|
||||
if [ "$total" -eq 0 ]; then
|
||||
echo "0"
|
||||
else
|
||||
echo "$((passes * 100 / total))"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to display progress bar
|
||||
show_progress() {
|
||||
local current="$1"
|
||||
local total="$2"
|
||||
local width="${3:-50}"
|
||||
local label="${4:-Progress}"
|
||||
|
||||
# Validate inputs
|
||||
if [[ ! "$current" =~ ^[0-9]+$ ]] || [[ ! "$total" =~ ^[0-9]+$ ]] || [[ ! "$width" =~ ^[0-9]+$ ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
# Ensure we don't divide by zero
|
||||
if [ "$total" -eq 0 ]; then
|
||||
total=1
|
||||
fi
|
||||
|
||||
local percentage=$((current * 100 / total))
|
||||
local filled=$((current * width / total))
|
||||
local empty=$((width - filled))
|
||||
|
||||
# Create progress bar string
|
||||
local progress_bar=""
|
||||
for ((i=0; i<filled; i++)); do
|
||||
progress_bar+="$PROGRESS_CHAR"
|
||||
done
|
||||
for ((i=0; i<empty; i++)); do
|
||||
progress_bar+="$EMPTY_CHAR"
|
||||
done
|
||||
|
||||
# Print progress bar with carriage return to overwrite
|
||||
printf "\r${CYAN}[%s]${NC} %s [%s] %d%% (%d/%d)" \
|
||||
"$label" "$progress_bar" "$percentage" "$current" "$total"
|
||||
}
|
||||
|
||||
# Function to clear progress bar line
|
||||
clear_progress() {
|
||||
printf "\r%*s\r" "$(tput cols)" ""
|
||||
}
|
||||
|
||||
# Function to track test execution progress
|
||||
track_test_progress() {
|
||||
local run_number="$1"
|
||||
local test_file="$2"
|
||||
|
||||
log_info "Run $run_number/$TOTAL_RUNS: Executing $test_file"
|
||||
show_progress "$run_number" "$TOTAL_RUNS" 50 "Test Run"
|
||||
}
|
||||
|
||||
# Function to run a single test execution
|
||||
run_single_test() {
|
||||
local run_number="$1"
|
||||
local run_output="${RESULTS_DIR}/run-${run_number}.txt"
|
||||
local start_time=$(date +%s)
|
||||
|
||||
log_info "Starting test run $run_number/$TOTAL_RUNS"
|
||||
|
||||
# Run the test suite
|
||||
if npm run test:playwright > "$run_output" 2>&1; then
|
||||
local end_time=$(date +%s)
|
||||
local duration=$((end_time - start_time))
|
||||
run_times[$run_number]=$duration
|
||||
|
||||
log_success "Test run $run_number completed successfully in ${duration}s"
|
||||
|
||||
# Extract and analyze test results
|
||||
local test_names_list=$(extract_test_names "$run_output")
|
||||
for test_name in $test_names_list; do
|
||||
if test_passed_in_run "$test_name" "$run_output"; then
|
||||
test_successes[$test_name]=$((${test_successes[$test_name]:-0} + 1))
|
||||
test_results[$test_name]="pass"
|
||||
elif test_failed_in_run "$test_name" "$run_output"; then
|
||||
test_failures[$test_name]=$((${test_failures[$test_name]:-0} + 1))
|
||||
test_results[$test_name]="fail"
|
||||
fi
|
||||
test_names[$test_name]=1
|
||||
done
|
||||
|
||||
return 0
|
||||
else
|
||||
local end_time=$(date +%s)
|
||||
local duration=$((end_time - start_time))
|
||||
run_times[$run_number]=$duration
|
||||
|
||||
log_error "Test run $run_number failed after ${duration}s"
|
||||
|
||||
# Extract test names even from failed runs
|
||||
local test_names_list=$(extract_test_names "$run_output" 2>/dev/null || true)
|
||||
for test_name in $test_names_list; do
|
||||
test_names[$test_name]=1
|
||||
if test_failed_in_run "$test_name" "$run_output"; then
|
||||
test_failures[$test_name]=$((${test_failures[$test_name]:-0} + 1))
|
||||
test_results[$test_name]="fail"
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to generate summary report
|
||||
generate_summary_report() {
|
||||
log_info "Generating summary report..."
|
||||
|
||||
local total_tests=0
|
||||
local always_passing=0
|
||||
local always_failing=0
|
||||
local intermittent=0
|
||||
|
||||
# Count test statistics
|
||||
for test_name in "${!test_names[@]}"; do
|
||||
total_tests=$((total_tests + 1))
|
||||
local passes=${test_successes[$test_name]:-0}
|
||||
local fails=${test_failures[$test_name]:-0}
|
||||
local total=$((passes + fails))
|
||||
|
||||
if [ "$fails" -eq 0 ]; then
|
||||
always_passing=$((always_passing + 1))
|
||||
elif [ "$passes" -eq 0 ]; then
|
||||
always_failing=$((always_failing + 1))
|
||||
else
|
||||
intermittent=$((intermittent + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Calculate overall success rate
|
||||
local total_runs=$((TOTAL_RUNS * total_tests))
|
||||
local total_successes=0
|
||||
for passes in "${test_successes[@]}"; do
|
||||
total_successes=$((total_successes + passes))
|
||||
done
|
||||
local overall_success_rate=0
|
||||
if [ "$total_runs" -gt 0 ]; then
|
||||
overall_success_rate=$((total_successes * 100 / total_runs))
|
||||
fi
|
||||
|
||||
# Generate summary data
|
||||
cat > "$SUMMARY_FILE" << EOF
|
||||
{
|
||||
"timestamp": "$(date -Iseconds)",
|
||||
"total_runs": $TOTAL_RUNS,
|
||||
"test_results": {
|
||||
EOF
|
||||
|
||||
# Add individual test results
|
||||
local first=true
|
||||
for test_name in "${!test_names[@]}"; do
|
||||
local passes=${test_successes[$test_name]:-0}
|
||||
local fails=${test_failures[$test_name]:-0}
|
||||
local total=$((passes + fails))
|
||||
local success_rate=$(calculate_percentage "$passes" "$total")
|
||||
|
||||
if [ "$first" = true ]; then
|
||||
first=false
|
||||
else
|
||||
echo "," >> "$SUMMARY_FILE"
|
||||
fi
|
||||
|
||||
cat >> "$SUMMARY_FILE" << EOF
|
||||
"$test_name": {
|
||||
"passes": $passes,
|
||||
"failures": $fails,
|
||||
"total": $total,
|
||||
"success_rate": $success_rate,
|
||||
"status": "${test_results[$test_name]:-unknown}"
|
||||
}
|
||||
EOF
|
||||
done
|
||||
|
||||
# Close summary
|
||||
cat >> "$SUMMARY_FILE" << EOF
|
||||
},
|
||||
"summary_stats": {
|
||||
"total_tests": $total_tests,
|
||||
"always_passing": $always_passing,
|
||||
"always_failing": $always_failing,
|
||||
"intermittent": $intermittent,
|
||||
"overall_success_rate": $overall_success_rate
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
log_success "Summary report generated: $SUMMARY_FILE"
|
||||
}
|
||||
|
||||
# Function to display final results
|
||||
display_final_results() {
|
||||
clear_progress
|
||||
echo
|
||||
log_info "=== TEST STABILITY ANALYSIS COMPLETE ==="
|
||||
echo
|
||||
|
||||
# Display summary statistics
|
||||
local total_tests=${#test_names[@]}
|
||||
local always_passing=0
|
||||
local always_failing=0
|
||||
local intermittent=0
|
||||
|
||||
for test_name in "${!test_names[@]}"; do
|
||||
local passes=${test_successes[$test_name]:-0}
|
||||
local fails=${test_failures[$test_name]:-0}
|
||||
|
||||
if [ "$fails" -eq 0 ]; then
|
||||
always_passing=$((always_passing + 1))
|
||||
elif [ "$passes" -eq 0 ]; then
|
||||
always_failing=$((always_failing + 1))
|
||||
else
|
||||
intermittent=$((intermittent + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo -e "${GREEN}✅ Always Passing: $always_passing tests${NC}"
|
||||
echo -e "${RED}❌ Always Failing: $always_failing tests${NC}"
|
||||
echo -e "${YELLOW}⚠️ Intermittent: $intermittent tests${NC}"
|
||||
echo -e "${BLUE}📊 Total Tests: $total_tests${NC}"
|
||||
echo
|
||||
|
||||
# Display intermittent tests
|
||||
if [ "$intermittent" -gt 0 ]; then
|
||||
log_warning "Intermittent tests (require investigation):"
|
||||
for test_name in "${!test_names[@]}"; do
|
||||
local passes=${test_successes[$test_name]:-0}
|
||||
local fails=${test_failures[$test_name]:-0}
|
||||
|
||||
if [ "$passes" -gt 0 ] && [ "$fails" -gt 0 ]; then
|
||||
local success_rate=$(calculate_percentage "$passes" "$((passes + fails))")
|
||||
echo -e " ${YELLOW}$test_name: $success_rate% success rate${NC}"
|
||||
fi
|
||||
done
|
||||
echo
|
||||
fi
|
||||
|
||||
# Display always failing tests
|
||||
if [ "$always_failing" -gt 0 ]; then
|
||||
log_error "Always failing tests (require immediate attention):"
|
||||
for test_name in "${!test_names[@]}"; do
|
||||
local passes=${test_successes[$test_name]:-0}
|
||||
local fails=${test_failures[$test_name]:-0}
|
||||
|
||||
if [ "$passes" -eq 0 ] && [ "$fails" -gt 0 ]; then
|
||||
echo -e " ${RED}$test_name: 0% success rate${NC}"
|
||||
fi
|
||||
done
|
||||
echo
|
||||
fi
|
||||
|
||||
log_info "Detailed results saved to:"
|
||||
echo -e " ${BLUE}Summary: $SUMMARY_FILE${NC}"
|
||||
echo -e " ${BLUE}Log: $LOG_FILE${NC}"
|
||||
echo -e " ${BLUE}Results directory: $RESULTS_DIR${NC}"
|
||||
}
|
||||
118
scripts/test-stability-runner-simple.sh
Executable file
118
scripts/test-stability-runner-simple.sh
Executable file
@@ -0,0 +1,118 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Test Stability Runner for TimeSafari (Simple Version)
|
||||
# Executes the full test suite 10 times and analyzes failure patterns
|
||||
# Author: Matthew Raymer
|
||||
|
||||
# Source common functions
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "${SCRIPT_DIR}/test-stability-common.sh"
|
||||
|
||||
# Override summary file to use text format instead of JSON
|
||||
SUMMARY_FILE="${RESULTS_DIR}/stability-summary-${TIMESTAMP}.txt"
|
||||
|
||||
# Function to generate simple text summary
|
||||
generate_simple_summary() {
|
||||
log_info "Generating simple text summary..."
|
||||
|
||||
local total_tests=0
|
||||
local always_passing=0
|
||||
local always_failing=0
|
||||
local intermittent=0
|
||||
|
||||
# Count test statistics
|
||||
for test_name in "${!test_names[@]}"; do
|
||||
total_tests=$((total_tests + 1))
|
||||
local passes=${test_successes[$test_name]:-0}
|
||||
local fails=${test_failures[$test_name]:-0}
|
||||
local total=$((passes + fails))
|
||||
|
||||
if [ "$fails" -eq 0 ]; then
|
||||
always_passing=$((always_passing + 1))
|
||||
elif [ "$passes" -eq 0 ]; then
|
||||
always_failing=$((always_failing + 1))
|
||||
else
|
||||
intermittent=$((intermittent + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Calculate overall success rate
|
||||
local total_runs=$((TOTAL_RUNS * total_tests))
|
||||
local total_successes=0
|
||||
for passes in "${test_successes[@]}"; do
|
||||
total_successes=$((total_successes + passes))
|
||||
done
|
||||
local overall_success_rate=0
|
||||
if [ "$total_runs" -gt 0 ]; then
|
||||
overall_success_rate=$((total_successes * 100 / total_runs))
|
||||
fi
|
||||
|
||||
# Generate simple text summary
|
||||
cat > "$SUMMARY_FILE" << EOF
|
||||
TimeSafari Test Stability Summary
|
||||
================================
|
||||
|
||||
Generated: $(date)
|
||||
Total Runs: $TOTAL_RUNS
|
||||
Total Tests: $total_tests
|
||||
|
||||
Summary Statistics:
|
||||
- Always Passing: $always_passing tests
|
||||
- Always Failing: $always_failing tests
|
||||
- Intermittent: $intermittent tests
|
||||
- Overall Success Rate: $overall_success_rate%
|
||||
|
||||
Individual Test Results:
|
||||
EOF
|
||||
|
||||
# Add individual test results
|
||||
for test_name in "${!test_names[@]}"; do
|
||||
local passes=${test_successes[$test_name]:-0}
|
||||
local fails=${test_failures[$test_name]:-0}
|
||||
local total=$((passes + fails))
|
||||
local success_rate=$(calculate_percentage "$passes" "$total")
|
||||
|
||||
cat >> "$SUMMARY_FILE" << EOF
|
||||
$test_name:
|
||||
Passes: $passes
|
||||
Failures: $fails
|
||||
Total: $total
|
||||
Success Rate: $success_rate%
|
||||
Status: ${test_results[$test_name]:-unknown}
|
||||
EOF
|
||||
done
|
||||
|
||||
log_success "Simple summary generated: $SUMMARY_FILE"
|
||||
}
|
||||
|
||||
# Main execution function
|
||||
main() {
|
||||
log_info "Starting simple test stability analysis with $TOTAL_RUNS runs"
|
||||
log_info "Results will be saved to: $RESULTS_DIR"
|
||||
echo
|
||||
|
||||
# Run all test executions
|
||||
for run_number in $(seq 1 $TOTAL_RUNS); do
|
||||
track_test_progress "$run_number" "test suite"
|
||||
|
||||
if run_single_test "$run_number"; then
|
||||
log_success "Run $run_number completed successfully"
|
||||
else
|
||||
log_warning "Run $run_number failed, continuing with remaining runs"
|
||||
fi
|
||||
|
||||
# Small delay between runs to avoid overwhelming the system
|
||||
if [ "$run_number" -lt $TOTAL_RUNS ]; then
|
||||
sleep 2
|
||||
fi
|
||||
done
|
||||
|
||||
# Generate and display results
|
||||
generate_simple_summary
|
||||
display_final_results
|
||||
|
||||
log_success "Simple test stability analysis complete!"
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
41
scripts/test-stability-runner.sh
Executable file
41
scripts/test-stability-runner.sh
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Test Stability Runner for TimeSafari
|
||||
# Executes the full test suite 10 times and analyzes failure patterns
|
||||
# Author: Matthew Raymer
|
||||
|
||||
# Source common functions
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "${SCRIPT_DIR}/test-stability-common.sh"
|
||||
|
||||
# Main execution function
|
||||
main() {
|
||||
log_info "Starting test stability analysis with $TOTAL_RUNS runs"
|
||||
log_info "Results will be saved to: $RESULTS_DIR"
|
||||
echo
|
||||
|
||||
# Run all test executions
|
||||
for run_number in $(seq 1 $TOTAL_RUNS); do
|
||||
track_test_progress "$run_number" "test suite"
|
||||
|
||||
if run_single_test "$run_number"; then
|
||||
log_success "Run $run_number completed successfully"
|
||||
else
|
||||
log_warning "Run $run_number failed, continuing with remaining runs"
|
||||
fi
|
||||
|
||||
# Small delay between runs to avoid overwhelming the system
|
||||
if [ "$run_number" -lt $TOTAL_RUNS ]; then
|
||||
sleep 2
|
||||
fi
|
||||
done
|
||||
|
||||
# Generate and display results
|
||||
generate_summary_report
|
||||
display_final_results
|
||||
|
||||
log_success "Test stability analysis complete!"
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
89
scripts/test-stability-runner.zsh
Executable file
89
scripts/test-stability-runner.zsh
Executable file
@@ -0,0 +1,89 @@
|
||||
#!/bin/zsh
|
||||
|
||||
# Test Stability Runner for TimeSafari (Zsh Version)
|
||||
# Executes the full test suite 10 times and analyzes failure patterns
|
||||
# Author: Matthew Raymer
|
||||
|
||||
# Source common functions
|
||||
SCRIPT_DIR="$(dirname "$0")"
|
||||
source "${SCRIPT_DIR}/test-stability-common-zsh.sh"
|
||||
|
||||
# Zsh-specific overrides and enhancements
|
||||
# Note: Associative arrays are now defined in the common file
|
||||
|
||||
# Enhanced progress tracking for zsh
|
||||
track_test_progress_enhanced() {
|
||||
local run_number="$1"
|
||||
local test_file="$2"
|
||||
|
||||
log_info "Run $run_number/$TOTAL_RUNS: Executing $test_file"
|
||||
|
||||
# Enhanced progress bar with zsh-specific features
|
||||
local percentage=$((run_number * 100 / TOTAL_RUNS))
|
||||
local filled=$((run_number * 50 / TOTAL_RUNS))
|
||||
local empty=$((50 - filled))
|
||||
|
||||
# Create enhanced progress bar
|
||||
local progress_bar=""
|
||||
for ((i=0; i<filled; i++)); do
|
||||
progress_bar+="$PROGRESS_CHAR"
|
||||
done
|
||||
for ((i=0; i<empty; i++)); do
|
||||
progress_bar+="$EMPTY_CHAR"
|
||||
done
|
||||
|
||||
# Print enhanced progress with zsh formatting
|
||||
printf "\r${CYAN}[ZSH]${NC} %s [%d%%] (%d/%d) ${MAGENTA}%s${NC}" \
|
||||
"$progress_bar" "$percentage" "$run_number" "$TOTAL_RUNS" "$test_file"
|
||||
}
|
||||
|
||||
# Enhanced error handling for zsh
|
||||
handle_zsh_error() {
|
||||
local error_code=$?
|
||||
local error_line=$1
|
||||
|
||||
if [ $error_code -ne 0 ]; then
|
||||
log_error "Zsh error occurred at line $error_line (exit code: $error_code)"
|
||||
# Additional zsh-specific error handling can be added here
|
||||
fi
|
||||
}
|
||||
|
||||
# Set up zsh error handling
|
||||
trap 'handle_zsh_error $LINENO' ERR
|
||||
|
||||
# Main execution function with zsh enhancements
|
||||
main() {
|
||||
log_info "Starting enhanced test stability analysis with $TOTAL_RUNS runs (Zsh Version)"
|
||||
log_info "Results will be saved to: $RESULTS_DIR"
|
||||
echo
|
||||
|
||||
# Run all test executions with enhanced tracking
|
||||
for run_number in $(seq 1 $TOTAL_RUNS); do
|
||||
track_test_progress_enhanced "$run_number" "test suite"
|
||||
|
||||
if run_single_test "$run_number"; then
|
||||
log_success "Run $run_number completed successfully"
|
||||
else
|
||||
log_warning "Run $run_number failed, continuing with remaining runs"
|
||||
fi
|
||||
|
||||
# Enhanced delay with zsh-specific features
|
||||
if [ "$run_number" -lt $TOTAL_RUNS ]; then
|
||||
# Use zsh's built-in sleep with progress indication
|
||||
for i in {1..2}; do
|
||||
printf "\r${YELLOW}Waiting...${NC} %d/2" "$i"
|
||||
sleep 1
|
||||
done
|
||||
printf "\r%*s\r" "$(tput cols)" ""
|
||||
fi
|
||||
done
|
||||
|
||||
# Generate and display results
|
||||
generate_summary_report
|
||||
display_final_results
|
||||
|
||||
log_success "Enhanced test stability analysis complete! (Zsh Version)"
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
@@ -123,74 +123,222 @@
|
||||
|
||||
<script lang="ts">
|
||||
/**
|
||||
* @file Contact Import View Component
|
||||
* @author Matthew Raymer
|
||||
*
|
||||
* This component handles the import of contacts into the TimeSafari app.
|
||||
* It supports multiple import methods and handles duplicate detection,
|
||||
* contact validation, and visibility settings.
|
||||
*
|
||||
* Import Methods:
|
||||
* 1. Direct URL Query Parameters:
|
||||
* Example: /contact-import?contacts=[{"did":"did:example:123","name":"Alice"}]
|
||||
*
|
||||
* 2. JWT in URL Path:
|
||||
* Example: /contact-import/eyJhbGciOiJFUzI1NksifQ...
|
||||
* - Supports both single and bulk imports
|
||||
* - JWT payload can be either:
|
||||
* a) Array format: { contacts: [{did: "...", name: "..."}, ...] }
|
||||
* b) Single contact: { own: true, did: "...", name: "..." }
|
||||
*
|
||||
* 3. Manual JWT Input:
|
||||
* - Accepts pasted JWT strings
|
||||
* - Validates format and content before processing
|
||||
*
|
||||
* URL Examples:
|
||||
* ```
|
||||
* # Bulk import via query params
|
||||
* /contact-import?contacts=[
|
||||
* {"did":"did:example:123","name":"Alice"},
|
||||
* {"did":"did:example:456","name":"Bob"}
|
||||
* ]
|
||||
*
|
||||
* # Single contact via JWT
|
||||
* /contact-import/eyJhbGciOiJFUzI1NksifQ.eyJvd24iOnRydWUsImRpZCI6ImRpZDpleGFtcGxlOjEyMyJ9...
|
||||
*
|
||||
* # Bulk import via JWT
|
||||
* /contact-import/eyJhbGciOiJFUzI1NksifQ.eyJjb250YWN0cyI6W3siZGlkIjoiZGlkOmV4YW1wbGU6MTIzIn1dfQ...
|
||||
*
|
||||
* # Redirect to contacts page (single contact)
|
||||
* /contacts?contactJwt=eyJhbGciOiJFUzI1NksifQ...
|
||||
* ```
|
||||
*
|
||||
* Features:
|
||||
* - Automatic duplicate detection
|
||||
* - Field-by-field comparison for existing contacts
|
||||
* - Batch visibility settings
|
||||
* - Auto-import for single new contacts
|
||||
* - Error handling and validation
|
||||
*
|
||||
* State Management:
|
||||
* - Tracks existing contacts
|
||||
* - Maintains selection state for bulk imports
|
||||
* - Records differences for duplicate contacts
|
||||
* - Manages visibility settings
|
||||
*
|
||||
* Security Considerations:
|
||||
* - JWT validation for imported contacts
|
||||
* - Visibility control per contact
|
||||
* - Error handling for malformed data
|
||||
*
|
||||
* @example
|
||||
* // Component usage in router
|
||||
* {
|
||||
* path: "/contact-import/:jwt?",
|
||||
* name: "contact-import",
|
||||
* component: ContactImportView
|
||||
* ContactImportView - Contact Import and Batch Processing Page
|
||||
*
|
||||
* This component handles the batch import of contacts with comprehensive
|
||||
* validation, duplicate detection, and field comparison capabilities.
|
||||
* It provides users with detailed information about each contact before
|
||||
* importing, allowing them to make informed decisions about their contact list.
|
||||
*
|
||||
* ## How the Contact Import Page Works
|
||||
*
|
||||
* ### Page Entry and Data Processing
|
||||
*
|
||||
* **Entry Points**:
|
||||
* - **URL Parameters**: Direct navigation with contact data in URL
|
||||
* - **Contact Input Form**: Redirected from ContactsView with parsed data
|
||||
* - **Manual Entry**: Users can input contact data directly
|
||||
*
|
||||
* **Data Processing Pipeline**:
|
||||
* 1. **Input Validation**: Parse and validate contact data format
|
||||
* 2. **Contact Analysis**: Check each contact against existing database
|
||||
* 3. **Duplicate Detection**: Identify existing contacts and compare fields
|
||||
* 4. **UI Preparation**: Prepare contact list with status indicators
|
||||
*
|
||||
* ### Contact Analysis and Display
|
||||
*
|
||||
* **Contact Status Classification**:
|
||||
* - **New Contacts** (Green): Contacts not in database
|
||||
* - **Existing Contacts** (Orange): Contacts already in database
|
||||
* - **Identical Contacts**: Existing contacts with no field differences
|
||||
*
|
||||
* **Field Comparison System**:
|
||||
* - **Automatic Detection**: Compare all contact fields
|
||||
* - **Difference Display**: Show old vs new values in table format
|
||||
* - **User Decision**: Allow users to see what will be updated
|
||||
*
|
||||
* **Contact List Structure**:
|
||||
* ```typescript
|
||||
* interface ContactImportItem {
|
||||
* did: string; // Decentralized identifier
|
||||
* name?: string; // Display name
|
||||
* publicKey?: string; // Public key
|
||||
* publicKeyBase64?: string; // Base64 encoded key
|
||||
* status: 'new' | 'existing'; // Import status
|
||||
* differences?: FieldDifferences; // Field comparison results
|
||||
* }
|
||||
*
|
||||
* @see {@link Contact} for contact data structure
|
||||
* @see {@link setVisibilityUtil} for visibility management
|
||||
* ```
|
||||
*
|
||||
* ### User Interface Components
|
||||
*
|
||||
* **Header Section**:
|
||||
* - **Back Navigation**: Return to previous page
|
||||
* - **Page Title**: "Contact Import" heading
|
||||
* - **Loading State**: Spinner during data processing
|
||||
*
|
||||
* **Visibility Settings**:
|
||||
* - **Activity Visibility Checkbox**: Control activity sharing with imported contacts
|
||||
* - **Global Setting**: Applies to all contacts being imported
|
||||
*
|
||||
* **Contact List Display**:
|
||||
* - **Contact Cards**: Individual contact information with:
|
||||
* - Selection checkbox for import control
|
||||
* - Contact name and DID display
|
||||
* - Status indicator (New/Existing)
|
||||
* - Field comparison table for existing contacts
|
||||
*
|
||||
* **Field Comparison Table**:
|
||||
* - **Three-Column Layout**: Field name, old value, new value
|
||||
* - **Difference Highlighting**: Clear visual indication of changes
|
||||
* - **Comprehensive Coverage**: All contact fields are compared
|
||||
*
|
||||
* **Import Controls**:
|
||||
* - **Select All/None**: Bulk selection controls
|
||||
* - **Individual Selection**: Per-contact import control
|
||||
* - **Import Button**: Execute the selected imports
|
||||
*
|
||||
* ### Data Processing Logic
|
||||
*
|
||||
* **Contact Validation**:
|
||||
* ```typescript
|
||||
* // Validate DID format
|
||||
* const isValidDid = (did: string): boolean => {
|
||||
* return did.startsWith('did:') && did.length > 10;
|
||||
* };
|
||||
*
|
||||
* // Check for existing contact
|
||||
* const existingContact = await $getContact(did);
|
||||
* const isExisting = existingContact !== null;
|
||||
* ```
|
||||
*
|
||||
* **Field Comparison Algorithm**:
|
||||
* ```typescript
|
||||
* // Compare contact fields
|
||||
* const compareFields = (existing: Contact, importing: Contact) => {
|
||||
* const differences: FieldDifferences = {};
|
||||
*
|
||||
* for (const field of ['name', 'publicKey', 'publicKeyBase64']) {
|
||||
* if (existing[field] !== importing[field]) {
|
||||
* differences[field] = {
|
||||
* old: existing[field] || '',
|
||||
* new: importing[field] || ''
|
||||
* };
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* return differences;
|
||||
* };
|
||||
* ```
|
||||
*
|
||||
* **Import Decision Logic**:
|
||||
* - **New Contact**: Add to database with all provided fields
|
||||
* - **Existing Contact with Differences**: Update with new field values
|
||||
* - **Existing Contact without Differences**: Skip import (already identical)
|
||||
* - **Invalid Contact**: Skip import and show error
|
||||
*
|
||||
* ### Batch Import Process
|
||||
*
|
||||
* **Pre-Import Validation**:
|
||||
* - Verify all selected contacts are valid
|
||||
* - Check database constraints
|
||||
* - Validate visibility settings
|
||||
*
|
||||
* **Database Transaction**:
|
||||
* ```typescript
|
||||
* // Execute batch import
|
||||
* const importContacts = async () => {
|
||||
* const selectedContacts = contactsImporting.filter((_, index) =>
|
||||
* contactsSelected[index]
|
||||
* );
|
||||
*
|
||||
* await $beginTransaction();
|
||||
*
|
||||
* try {
|
||||
* for (const contact of selectedContacts) {
|
||||
* if (contactsExisting[contact.did]) {
|
||||
* await $updateContact(contact.did, contact);
|
||||
* } else {
|
||||
* await $addContact(contact);
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* await $commitTransaction();
|
||||
* notify.success('Contacts imported successfully');
|
||||
* } catch (error) {
|
||||
* await $rollbackTransaction();
|
||||
* notify.error('Import failed: ' + error.message);
|
||||
* }
|
||||
* };
|
||||
* ```
|
||||
*
|
||||
* **Post-Import Actions**:
|
||||
* - Update contact list in parent component
|
||||
* - Apply visibility settings if enabled
|
||||
* - Navigate back to contacts list
|
||||
* - Display success/error notifications
|
||||
*
|
||||
* ### Error Handling and Edge Cases
|
||||
*
|
||||
* **Input Validation Errors**:
|
||||
* - Malformed JSON data
|
||||
* - Invalid DID format
|
||||
* - Missing required fields
|
||||
* - Empty contact arrays
|
||||
*
|
||||
* **Database Errors**:
|
||||
* - Constraint violations
|
||||
* - Storage quota exceeded
|
||||
* - Concurrent access conflicts
|
||||
* - Transaction failures
|
||||
*
|
||||
* **UI Error Recovery**:
|
||||
* - Graceful handling of network failures
|
||||
* - Retry mechanisms for failed operations
|
||||
* - Clear error messages for users
|
||||
* - Fallback options for unsupported features
|
||||
*
|
||||
* ### Performance Optimizations
|
||||
*
|
||||
* **Efficient Processing**:
|
||||
* - Batch database operations
|
||||
* - Optimized field comparison algorithms
|
||||
* - Lazy loading of contact details
|
||||
* - Debounced UI updates
|
||||
*
|
||||
* **Memory Management**:
|
||||
* - Cleanup of temporary data structures
|
||||
* - Proper disposal of event listeners
|
||||
* - Efficient state management
|
||||
* - Garbage collection optimization
|
||||
*
|
||||
* **UI Responsiveness**:
|
||||
* - Asynchronous data processing
|
||||
* - Progressive loading of contact data
|
||||
* - Non-blocking UI updates
|
||||
* - Optimized rendering for large lists
|
||||
*
|
||||
* ### Integration Points
|
||||
*
|
||||
* **Database Integration**:
|
||||
* - PlatformServiceMixin for database operations
|
||||
* - Transaction-based data integrity
|
||||
* - Optimized queries for contact retrieval
|
||||
* - Proper error handling and rollback
|
||||
*
|
||||
* **Navigation Integration**:
|
||||
* - Route-based data passing
|
||||
* - Deep linking support
|
||||
* - Back navigation handling
|
||||
* - Modal dialog management
|
||||
*
|
||||
* **Notification System**:
|
||||
* - Success/error message display
|
||||
* - Progress indication during import
|
||||
* - User feedback for all operations
|
||||
* - Accessibility-compliant notifications
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @date 2025-08-04
|
||||
*/
|
||||
|
||||
import * as R from "ramda";
|
||||
|
||||
@@ -123,6 +123,144 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
/**
|
||||
* ContactsView - Main Contacts Management Page
|
||||
*
|
||||
* This component serves as the central hub for contact management in Time Safari.
|
||||
* It provides a comprehensive interface for viewing, adding, importing, and managing
|
||||
* contacts with various input methods and bulk operations.
|
||||
*
|
||||
* ## How the Contacts Page Works
|
||||
*
|
||||
* ### Contact Input and Import Workflow
|
||||
*
|
||||
* **ContactInputForm Component**:
|
||||
* - **Input Field**: Accepts contact data in multiple formats:
|
||||
* - Individual contact: `"did:ethr:0x..., Alice, publicKey"`
|
||||
* - JSON array: `"Paste this: [{"did":"did:ethr:0x...","name":"Alice"}]"`
|
||||
* - URL with contact data: `"https://example.com/contact-data"`
|
||||
* - **Add Button**: Triggers contact processing and validation
|
||||
* - **QR Scanner**: Alternative input method for mobile devices
|
||||
* - **Real-time Validation**: Checks DID format and required fields
|
||||
*
|
||||
* **Contact Processing Logic**:
|
||||
* 1. **Input Parsing**: The system parses the input to determine format
|
||||
* 2. **Data Validation**: Validates DID format and required fields
|
||||
* 3. **Duplicate Detection**: Checks if contact already exists
|
||||
* 4. **Import Decision**:
|
||||
* - Single contact: Direct addition to database
|
||||
* - Multiple contacts: Redirect to ContactImportView for batch processing
|
||||
* - Invalid data: Display error message
|
||||
*
|
||||
* **Import Workflow**:
|
||||
* - **Single Contact**: Added directly with success notification
|
||||
* - **Multiple Contacts**: Redirected to ContactImportView for:
|
||||
* - Contact comparison and selection
|
||||
* - Field difference display
|
||||
* - Batch import execution
|
||||
* - Visibility settings configuration
|
||||
*
|
||||
* ### Contact List Management
|
||||
*
|
||||
* **ContactListItem Components**:
|
||||
* - **Contact Display**: Name, DID, and identicon
|
||||
* - **Selection Checkboxes**: For bulk operations
|
||||
* - **Action Buttons**: Gift, offer, and contact management
|
||||
* - **Status Indicators**: Online/offline status, activity visibility
|
||||
*
|
||||
* **Bulk Operations**:
|
||||
* - **Select All**: Toggle selection of all contacts
|
||||
* - **Copy Selected**: Export selected contacts as JSON/CSV
|
||||
* - **Bulk Actions**: Gift amounts, visibility settings
|
||||
*
|
||||
* **Contact Actions**:
|
||||
* - **Gift Dialog**: Record gifts given to/received from contact
|
||||
* - **Offer Dialog**: Create and manage offers
|
||||
* - **Contact Edit**: Modify contact information
|
||||
* - **Large Identicon**: View full-size contact identicon
|
||||
*
|
||||
* ### Data Flow and State Management
|
||||
*
|
||||
* **Contact Data Structure**:
|
||||
* ```typescript
|
||||
* interface Contact {
|
||||
* did: string; // Decentralized identifier
|
||||
* name?: string; // Display name (optional)
|
||||
* publicKey?: string; // Public key for verification
|
||||
* publicKeyBase64?: string; // Base64 encoded public key
|
||||
* visibility?: boolean; // Activity visibility setting
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* **State Management**:
|
||||
* - **Contact List**: Reactive list of all user contacts
|
||||
* - **Selection State**: Track selected contacts for bulk operations
|
||||
* - **UI State**: Toggle visibility of give totals, actions, etc.
|
||||
* - **Modal State**: Manage dialog visibility and data
|
||||
*
|
||||
* **Database Operations**:
|
||||
* - **Contact Addition**: Add new contacts with validation
|
||||
* - **Contact Updates**: Modify existing contact information
|
||||
* - **Contact Deletion**: Remove contacts (with confirmation)
|
||||
* - **Bulk Operations**: Process multiple contacts efficiently
|
||||
*
|
||||
* ### Error Handling and User Feedback
|
||||
*
|
||||
* **Input Validation Errors**:
|
||||
* - Invalid DID format
|
||||
* - Missing required fields
|
||||
* - Malformed JSON data
|
||||
* - Network errors for URL-based imports
|
||||
*
|
||||
* **User Notifications**:
|
||||
* - Success messages for successful operations
|
||||
* - Error messages with specific details
|
||||
* - Warning messages for potential issues
|
||||
* - Confirmation dialogs for destructive actions
|
||||
*
|
||||
* **Error Recovery**:
|
||||
* - Graceful handling of network failures
|
||||
* - Retry mechanisms for failed operations
|
||||
* - Fallback options for unsupported features
|
||||
*
|
||||
* ### Performance Optimizations
|
||||
*
|
||||
* **Contact List Rendering**:
|
||||
* - Virtual scrolling for large contact lists
|
||||
* - Efficient filtering and sorting
|
||||
* - Lazy loading of contact details
|
||||
*
|
||||
* **Database Operations**:
|
||||
* - Batch processing for multiple contacts
|
||||
* - Transaction-based updates for data integrity
|
||||
* - Optimized queries for contact retrieval
|
||||
*
|
||||
* **UI Responsiveness**:
|
||||
* - Debounced input validation
|
||||
* - Asynchronous contact processing
|
||||
* - Progressive loading of contact data
|
||||
*
|
||||
* ### Integration Points
|
||||
*
|
||||
* **Platform Services**:
|
||||
* - Database operations via PlatformServiceMixin
|
||||
* - QR code scanning via platform-specific implementations
|
||||
* - File system access for contact export
|
||||
*
|
||||
* **External Services**:
|
||||
* - Endorser.ch for contact verification
|
||||
* - JWT token processing for secure imports
|
||||
* - URL-based contact data retrieval
|
||||
*
|
||||
* **Navigation Integration**:
|
||||
* - Deep linking to contact import
|
||||
* - Route-based contact filtering
|
||||
* - Modal dialog management
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @date 2025-08-04
|
||||
*/
|
||||
|
||||
import { AxiosError } from "axios";
|
||||
import { Buffer } from "buffer/";
|
||||
import { IndexableType } from "dexie";
|
||||
|
||||
@@ -227,12 +227,27 @@ Raymer * @version 1.0.0 */
|
||||
</div>
|
||||
<InfiniteScroll @reached-bottom="loadMoreGives">
|
||||
<ul id="listLatestActivity" class="space-y-4">
|
||||
<!-- Skeleton loading state for immediate visual feedback -->
|
||||
<div v-if="isFeedLoading && feedData.length === 0" class="space-y-4">
|
||||
<div v-for="i in 3" :key="`skeleton-${i}`" class="animate-pulse">
|
||||
<div class="bg-gray-200 rounded-lg p-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="w-12 h-12 bg-gray-300 rounded-full"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-4 bg-gray-300 rounded w-3/4"></div>
|
||||
<div class="h-3 bg-gray-300 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ActivityListItem
|
||||
v-for="record in feedData"
|
||||
:key="record.jwtId"
|
||||
:record="record"
|
||||
:last-viewed-claim-id="feedLastViewedClaimId"
|
||||
:is-registered="isRegistered"
|
||||
:is-registered="isUserRegistered"
|
||||
:active-did="activeDid"
|
||||
@load-claim="onClickLoadClaim"
|
||||
@view-image="openImageViewer"
|
||||
@@ -244,6 +259,12 @@ Raymer * @version 1.0.0 */
|
||||
<font-awesome icon="spinner" class="fa-spin-pulse" /> Loading…
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="isBackgroundProcessing" class="mt-2">
|
||||
<p class="text-slate-400 text-center text-sm italic">
|
||||
<font-awesome icon="spinner" class="fa-spin" /> Loading more
|
||||
content…
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="!isFeedLoading && feedData.length === 0">
|
||||
<p class="text-slate-500 text-center italic mt-4 mb-4">
|
||||
No claims match your filters.
|
||||
@@ -262,6 +283,7 @@ import { UAParser } from "ua-parser-js";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { nextTick } from "vue";
|
||||
|
||||
//import App from "../App.vue";
|
||||
import EntityIcon from "../components/EntityIcon.vue";
|
||||
@@ -406,16 +428,18 @@ export default class HomeView extends Vue {
|
||||
allMyDids: Array<string> = [];
|
||||
apiServer = "";
|
||||
blockedContactDids: Array<string> = [];
|
||||
// Feed data and state
|
||||
feedData: GiveRecordWithContactInfo[] = [];
|
||||
feedPreviousOldestId?: string;
|
||||
isFeedLoading = false;
|
||||
isBackgroundProcessing = false;
|
||||
feedPreviousOldestId: string | undefined = undefined;
|
||||
feedLastViewedClaimId?: string;
|
||||
givenName = "";
|
||||
isRegistered = false;
|
||||
isAnyFeedFilterOn = false;
|
||||
// isCreatingIdentifier removed - identity creation now handled by router guard
|
||||
isFeedFilteredByVisible = false;
|
||||
isFeedFilteredByNearby = false;
|
||||
isFeedLoading = true;
|
||||
isRegistered = false;
|
||||
lastAckedOfferToUserJwtId?: string; // the last JWT ID for offer-to-user that they've acknowledged seeing
|
||||
lastAckedOfferToUserProjectsJwtId?: string; // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing
|
||||
newOffersToUserHitLimit: boolean = false;
|
||||
@@ -747,9 +771,8 @@ export default class HomeView extends Vue {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads feed when filter settings change using ultra-concise mixin utilities
|
||||
* - Updates filter states
|
||||
* - Clears existing feed data
|
||||
* Reloads feed when filters change
|
||||
* - Resets feed data and pagination
|
||||
* - Triggers new feed load
|
||||
*
|
||||
* @public
|
||||
@@ -794,14 +817,59 @@ export default class HomeView extends Vue {
|
||||
* @param payload Boolean indicating if more items should be loaded
|
||||
*/
|
||||
async loadMoreGives(payload: boolean) {
|
||||
// Since feed now loads projects along the way, it takes longer
|
||||
// and the InfiniteScroll component triggers a load before finished.
|
||||
// One alternative is to totally separate the project link loading.
|
||||
if (payload && !this.isFeedLoading) {
|
||||
// Prevent loading if already processing or if background processing is active
|
||||
if (payload && !this.isFeedLoading && !this.isBackgroundProcessing) {
|
||||
// Use direct update instead of debounced to avoid conflicts with InfiniteScroll's debouncing
|
||||
await this.updateAllFeed();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced version of updateAllFeed to prevent rapid successive calls
|
||||
*
|
||||
* @internal
|
||||
* @callGraph
|
||||
* Called by: loadMoreGives()
|
||||
* Calls: updateAllFeed()
|
||||
*
|
||||
* @chain
|
||||
* loadMoreGives() -> debouncedUpdateFeed() -> updateAllFeed()
|
||||
*
|
||||
* @requires
|
||||
* - this.isFeedLoading
|
||||
*/
|
||||
private debouncedUpdateFeed = this.debounce(async () => {
|
||||
if (!this.isFeedLoading) {
|
||||
await this.updateAllFeed();
|
||||
}
|
||||
}, 300);
|
||||
|
||||
/**
|
||||
* Creates a debounced function to prevent rapid successive calls
|
||||
*
|
||||
* @internal
|
||||
* @callGraph
|
||||
* Called by: debouncedUpdateFeed()
|
||||
* Calls: None
|
||||
*
|
||||
* @chain
|
||||
* debouncedUpdateFeed() -> debounce()
|
||||
*
|
||||
* @param func Function to debounce
|
||||
* @param delay Delay in milliseconds
|
||||
* @returns Debounced function
|
||||
*/
|
||||
private debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
delay: number,
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => func(...args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if coordinates fall within any search box
|
||||
*
|
||||
@@ -874,6 +942,7 @@ export default class HomeView extends Vue {
|
||||
let endOfResults = true;
|
||||
|
||||
try {
|
||||
const apiStartTime = performance.now();
|
||||
const results = await this.retrieveGives(
|
||||
this.apiServer,
|
||||
this.feedPreviousOldestId,
|
||||
@@ -886,8 +955,38 @@ export default class HomeView extends Vue {
|
||||
|
||||
if (results.data.length > 0) {
|
||||
endOfResults = false;
|
||||
// gather any contacts that user has blocked from view
|
||||
await this.processFeedResults(results.data);
|
||||
|
||||
// Check if we have cached data for these records
|
||||
const uncachedRecords = this.filterUncachedRecords(results.data);
|
||||
|
||||
if (uncachedRecords.length > 0) {
|
||||
// Process first 5 records immediately for quick display
|
||||
const priorityRecords = uncachedRecords.slice(0, 5);
|
||||
const remainingRecords = uncachedRecords.slice(5);
|
||||
|
||||
// Process priority records first
|
||||
const processStartTime = performance.now();
|
||||
await this.processPriorityRecords(priorityRecords);
|
||||
const processTime = performance.now() - processStartTime;
|
||||
|
||||
// Process remaining records in background
|
||||
if (remainingRecords.length > 0) {
|
||||
this.processRemainingRecords(remainingRecords);
|
||||
}
|
||||
|
||||
// Log performance metrics in development
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
logger.debug("[HomeView Performance]", {
|
||||
apiTime: `${apiTime.toFixed(2)}ms`,
|
||||
processTime: `${processTime.toFixed(2)}ms`,
|
||||
priorityRecords: priorityRecords.length,
|
||||
remainingRecords: remainingRecords.length,
|
||||
totalRecords: results.data.length,
|
||||
cacheHitRate: `${(((results.data.length - uncachedRecords.length) / results.data.length) * 100).toFixed(1)}%`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.updateFeedLastViewedId(results.data);
|
||||
|
||||
logger.debug("[HomeView] 📝 Processed feed results", {
|
||||
@@ -946,7 +1045,10 @@ export default class HomeView extends Vue {
|
||||
let filteredCount = 0;
|
||||
|
||||
for (const record of records) {
|
||||
const processedRecord = await this.processRecord(record);
|
||||
const processedRecord = await this.processRecordWithCache(
|
||||
record,
|
||||
planCache,
|
||||
);
|
||||
if (processedRecord) {
|
||||
this.feedData.push(processedRecord);
|
||||
processedCount++;
|
||||
@@ -965,6 +1067,120 @@ export default class HomeView extends Vue {
|
||||
this.feedPreviousOldestId = records[records.length - 1].jwtId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch fetches multiple plans to reduce API calls
|
||||
*
|
||||
* @internal
|
||||
* @callGraph
|
||||
* Called by: processFeedResults()
|
||||
* Calls: getPlanFromCache()
|
||||
*
|
||||
* @chain
|
||||
* processFeedResults() -> batchFetchPlans()
|
||||
*
|
||||
* @requires
|
||||
* - this.axios
|
||||
* - this.apiServer
|
||||
* - this.activeDid
|
||||
*
|
||||
* @param planHandleIds Array of plan handle IDs to fetch
|
||||
* @param planCache Map to store fetched plans
|
||||
*/
|
||||
private async batchFetchPlans(
|
||||
planHandleIds: string[],
|
||||
planCache: Map<string, PlanSummaryRecord>,
|
||||
) {
|
||||
// Process plans in batches of 10 to avoid overwhelming the API
|
||||
const batchSize = 10;
|
||||
for (let i = 0; i < planHandleIds.length; i += batchSize) {
|
||||
const batch = planHandleIds.slice(i, i + batchSize);
|
||||
await Promise.all(
|
||||
batch.map(async (handleId) => {
|
||||
const plan = await getPlanFromCache(
|
||||
handleId,
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
this.activeDid,
|
||||
);
|
||||
if (plan) {
|
||||
planCache.set(handleId, plan);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a single record with cached plans
|
||||
*
|
||||
* @internal
|
||||
* @callGraph
|
||||
* Called by: processFeedResults()
|
||||
* Calls:
|
||||
* - extractClaim()
|
||||
* - extractGiverDid()
|
||||
* - extractRecipientDid()
|
||||
* - shouldIncludeRecord()
|
||||
* - extractProvider()
|
||||
* - createFeedRecord()
|
||||
*
|
||||
* @chain
|
||||
* processFeedResults() -> processRecordWithCache()
|
||||
*
|
||||
* @requires
|
||||
* - this.isAnyFeedFilterOn
|
||||
* - this.isFeedFilteredByVisible
|
||||
* - this.isFeedFilteredByNearby
|
||||
* - this.activeDid
|
||||
* - this.allContacts
|
||||
*
|
||||
* @param record The record to process
|
||||
* @param planCache Map of cached plans
|
||||
* @param isPriority Whether this is a priority record for quick display
|
||||
* @returns Processed record with contact info if it passes filters, null otherwise
|
||||
*/
|
||||
private async processRecordWithCache(
|
||||
record: GiveSummaryRecord,
|
||||
planCache: Map<string, PlanSummaryRecord>,
|
||||
isPriority: boolean = false,
|
||||
): Promise<GiveRecordWithContactInfo | null> {
|
||||
const claim = this.extractClaim(record);
|
||||
const giverDid = this.extractGiverDid(claim);
|
||||
const recipientDid = this.extractRecipientDid(claim);
|
||||
|
||||
// For priority records, skip expensive plan lookups initially
|
||||
let fulfillsPlan: FulfillsPlan | undefined;
|
||||
if (!isPriority || record.fulfillsPlanHandleId) {
|
||||
fulfillsPlan =
|
||||
planCache.get(record.fulfillsPlanHandleId || "") ||
|
||||
(await this.getFulfillsPlan(record));
|
||||
}
|
||||
|
||||
if (!this.shouldIncludeRecord(record, fulfillsPlan)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const provider = this.extractProvider(claim);
|
||||
let providedByPlan: ProvidedByPlan | undefined;
|
||||
|
||||
// For priority records, defer provider plan lookup
|
||||
if (!isPriority && provider?.identifier) {
|
||||
providedByPlan =
|
||||
planCache.get(provider.identifier) ||
|
||||
(await this.getProvidedByPlan(provider));
|
||||
}
|
||||
|
||||
return this.createFeedRecord(
|
||||
record,
|
||||
claim,
|
||||
giverDid,
|
||||
recipientDid,
|
||||
provider,
|
||||
fulfillsPlan,
|
||||
providedByPlan,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a single record and returns it if it passes filters
|
||||
*
|
||||
@@ -1148,30 +1364,30 @@ export default class HomeView extends Vue {
|
||||
record: GiveSummaryRecord,
|
||||
fulfillsPlan?: FulfillsPlan,
|
||||
): boolean {
|
||||
// Early exit for blocked contacts
|
||||
if (this.blockedContactDids.includes(record.issuerDid)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If no filters are active, include all records
|
||||
if (!this.isAnyFeedFilterOn) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let anyMatch = false;
|
||||
// Check visibility filter first (faster than location check)
|
||||
if (this.isFeedFilteredByVisible && containsNonHiddenDid(record)) {
|
||||
anyMatch = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
!anyMatch &&
|
||||
this.isFeedFilteredByNearby &&
|
||||
record.fulfillsPlanHandleId
|
||||
) {
|
||||
// Check location filter only if needed and plan exists
|
||||
if (this.isFeedFilteredByNearby && record.fulfillsPlanHandleId) {
|
||||
if (fulfillsPlan?.locLat && fulfillsPlan?.locLon) {
|
||||
anyMatch =
|
||||
return (
|
||||
this.latLongInAnySearchBox(
|
||||
fulfillsPlan.locLat,
|
||||
fulfillsPlan.locLon,
|
||||
) ?? false;
|
||||
) ?? false
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1747,5 +1963,28 @@ export default class HomeView extends Vue {
|
||||
get isUserRegistered() {
|
||||
return this.isRegistered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug method to verify debugging capabilities work with optimizations
|
||||
*
|
||||
* @public
|
||||
* Called by: Debug testing
|
||||
* @returns Debug information
|
||||
*/
|
||||
debugOptimizations() {
|
||||
// This method should be debuggable with breakpoints
|
||||
const debugInfo = {
|
||||
timestamp: new Date().toISOString(),
|
||||
feedDataLength: this.feedData.length,
|
||||
isFeedLoading: this.isFeedLoading,
|
||||
activeDid: this.activeDid,
|
||||
performance: performance.now(),
|
||||
};
|
||||
|
||||
console.log("🔍 Debug Info:", debugInfo);
|
||||
debugger; // This should trigger breakpoint in dev tools
|
||||
|
||||
return debugInfo;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -60,29 +60,59 @@
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { importUser } from './testUtils';
|
||||
import { createPerformanceCollector, attachPerformanceData, assertPerformanceMetrics } from './performanceUtils';
|
||||
|
||||
test('Check usage limits', async ({ page }) => {
|
||||
// Check without ID first
|
||||
await page.goto('./account');
|
||||
await expect(page.locator('div.bg-slate-100.rounded-md').filter({ hasText: 'Usage Limits' })).toBeHidden();
|
||||
test('Check usage limits', async ({ page }, testInfo) => {
|
||||
// STEP 1: Initialize the performance collector
|
||||
const perfCollector = await createPerformanceCollector(page);
|
||||
|
||||
// Import user 01
|
||||
const did = await importUser(page, '01');
|
||||
// STEP 2: Check without ID first
|
||||
await perfCollector.measureUserAction('navigate-to-account', async () => {
|
||||
await page.goto('./account');
|
||||
});
|
||||
const initialMetrics = await perfCollector.collectNavigationMetrics('account-page-load');
|
||||
await testInfo.attach('initial-page-load-metrics', {
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(initialMetrics, null, 2)
|
||||
});
|
||||
|
||||
// Verify that "Usage Limits" section is visible
|
||||
await expect(page.locator('#sectionUsageLimits')).toBeVisible();
|
||||
await expect(page.locator('#sectionUsageLimits')).toContainText('You have done');
|
||||
await expect(page.locator('#sectionUsageLimits')).toContainText('You have uploaded');
|
||||
await perfCollector.measureUserAction('verify-no-usage-limits', async () => {
|
||||
await expect(page.locator('div.bg-slate-100.rounded-md').filter({ hasText: 'Usage Limits' })).toBeHidden();
|
||||
});
|
||||
|
||||
await expect(page.getByText('Your claims counter resets')).toBeVisible();
|
||||
await expect(page.getByText('Your registration counter resets')).toBeVisible();
|
||||
await expect(page.getByText('Your image counter resets')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Recheck Limits' })).toBeVisible();
|
||||
// STEP 3: Import user 01
|
||||
await perfCollector.measureUserAction('import-user-account', async () => {
|
||||
const did = await importUser(page, '01');
|
||||
});
|
||||
|
||||
// Set name
|
||||
await page.getByRole('button', { name: 'Set Your Name' }).click();
|
||||
const name = 'User ' + did.slice(11, 14);
|
||||
await page.getByPlaceholder('Name').fill(name);
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
// STEP 4: Verify usage limits section
|
||||
await perfCollector.measureUserAction('verify-usage-limits-section', async () => {
|
||||
await expect(page.locator('#sectionUsageLimits')).toBeVisible();
|
||||
await expect(page.locator('#sectionUsageLimits')).toContainText('You have done');
|
||||
await expect(page.locator('#sectionUsageLimits')).toContainText('You have uploaded');
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('verify-usage-limit-texts', async () => {
|
||||
await expect(page.getByText('Your claims counter resets')).toBeVisible();
|
||||
await expect(page.getByText('Your registration counter resets')).toBeVisible();
|
||||
await expect(page.getByText('Your image counter resets')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Recheck Limits' })).toBeVisible();
|
||||
});
|
||||
|
||||
// STEP 5: Set name
|
||||
await perfCollector.measureUserAction('click-set-name-button', async () => {
|
||||
await page.getByRole('button', { name: 'Set Your Name' }).click();
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('fill-and-save-name', async () => {
|
||||
const name = 'User ' + '01'.slice(0, 2);
|
||||
await page.getByPlaceholder('Name').fill(name);
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
});
|
||||
|
||||
// STEP 6: Attach and validate performance data
|
||||
const { webVitals, performanceReport, summary } = await attachPerformanceData(testInfo, perfCollector);
|
||||
const avgNavigationTime = perfCollector.navigationMetrics.reduce((sum, nav) =>
|
||||
sum + nav.metrics.loadComplete, 0) / perfCollector.navigationMetrics.length;
|
||||
assertPerformanceMetrics(webVitals, initialMetrics, avgNavigationTime);
|
||||
});
|
||||
@@ -1,122 +1,492 @@
|
||||
/**
|
||||
* @file Gift Recording Test Suite
|
||||
* @description Tests TimeSafari's core gift recording functionality, ensuring proper creation,
|
||||
* validation, and verification of gift records
|
||||
* @description Tests TimeSafari's core gift recording functionality with integrated performance tracking
|
||||
*
|
||||
* This test verifies:
|
||||
* 1. Gift Creation
|
||||
* - Random gift title generation
|
||||
* - Random non-zero amount assignment
|
||||
* - Proper recording and signing
|
||||
* This test covers a complete gift recording flow in TimeSafari with integrated performance tracking.
|
||||
*
|
||||
* 2. Gift Verification
|
||||
* - Gift appears in home view
|
||||
* - Details match input data
|
||||
* - Verifiable claim details accessible
|
||||
* Focus areas:
|
||||
* - Performance monitoring for every major user step
|
||||
* - Gift creation, recording, and verification
|
||||
* - Public server integration and validation
|
||||
* - Validation of both behavior and responsiveness
|
||||
*
|
||||
* 3. Public Verification
|
||||
* - Gift viewable on public server
|
||||
* - Claim details properly exposed
|
||||
* @version 1.0.0
|
||||
* @author Matthew Raymer
|
||||
* @lastModified 2025-08-02
|
||||
*
|
||||
* Test Flow:
|
||||
* 1. Data Generation
|
||||
* - Generate random 4-char string for unique gift ID
|
||||
* - Generate random amount (1-99)
|
||||
* - Combine with standard "Gift" prefix
|
||||
* ================================================================================
|
||||
* TEST OVERVIEW
|
||||
* ================================================================================
|
||||
*
|
||||
* 2. Gift Recording
|
||||
* - Import User 00 (test account)
|
||||
* - Navigate to home
|
||||
* - Close onboarding dialog
|
||||
* - Select recipient
|
||||
* - Fill gift details
|
||||
* - Sign and submit
|
||||
* This test verifies the complete gift recording workflow from data generation to
|
||||
* public verification, ensuring end-to-end functionality works correctly with
|
||||
* comprehensive performance monitoring.
|
||||
*
|
||||
* 3. Verification
|
||||
* - Check success notification
|
||||
* - Refresh home view
|
||||
* - Locate gift in list
|
||||
* - Verify gift details
|
||||
* - Check public server view
|
||||
* Core Test Objectives:
|
||||
* 1. Gift Creation & Recording
|
||||
* - Random gift title generation with uniqueness
|
||||
* - Random non-zero amount assignment (1-99 range)
|
||||
* - Proper form filling and validation
|
||||
* - JWT signing and submission with performance tracking
|
||||
*
|
||||
* Test Data:
|
||||
* - Gift Title: "Gift [4-char-random]"
|
||||
* - Amount: Random 1-99
|
||||
* - Recipient: "Unnamed/Unknown"
|
||||
* 2. Gift Verification & Display
|
||||
* - Gift appears in home view after recording
|
||||
* - Details match input data exactly
|
||||
* - Verifiable claim details are accessible
|
||||
* - UI elements display correctly
|
||||
*
|
||||
* Key Selectors:
|
||||
* - Gift title: '[data-testid="giftTitle"]'
|
||||
* - Amount input: 'input[type="number"]'
|
||||
* 3. Public Verification & Integration
|
||||
* - Gift viewable on public endorser server
|
||||
* - Claim details properly exposed via API
|
||||
* - Cross-platform compatibility (Chromium/Firefox)
|
||||
*
|
||||
* ================================================================================
|
||||
* TEST FLOW & PROCESS
|
||||
* ================================================================================
|
||||
*
|
||||
* Phase 1: Data Generation & Preparation
|
||||
* ────────────────────────────────────────────────────────────────────────────────
|
||||
* 1. Generate unique test data:
|
||||
* - Random 4-character string for gift ID uniqueness
|
||||
* - Random amount between 1-99 (non-zero validation)
|
||||
* - Combine with "Gift " prefix for standard format
|
||||
*
|
||||
* 2. User preparation:
|
||||
* - Import User 00 (test account with known state)
|
||||
* - Navigate to home page
|
||||
* - Handle onboarding dialog closure
|
||||
*
|
||||
* Phase 2: Gift Recording Process
|
||||
* ────────────────────────────────────────────────────────────────────────────────
|
||||
* 3. Recipient selection:
|
||||
* - Click "Person" button to open recipient picker
|
||||
* - Select "Unnamed/Unknown" recipient
|
||||
* - Verify selection is applied
|
||||
*
|
||||
* 4. Gift details entry:
|
||||
* - Fill gift title with generated unique string
|
||||
* - Enter random amount in number field
|
||||
* - Validate form state before submission
|
||||
*
|
||||
* 5. Submission and signing:
|
||||
* - Click "Sign & Send" button
|
||||
* - Wait for JWT signing process
|
||||
* - Verify success notification appears
|
||||
* - Dismiss any info alerts
|
||||
*
|
||||
* Phase 3: Verification & Validation
|
||||
* ────────────────────────────────────────────────────────────────────────────────
|
||||
* 6. Home view verification:
|
||||
* - Refresh home page to load new gift
|
||||
* - Locate gift in activity list by title
|
||||
* - Click info link to view details
|
||||
*
|
||||
* 7. Details verification:
|
||||
* - Verify "Verifiable Claim Details" heading
|
||||
* - Confirm gift title matches exactly
|
||||
* - Expand Details section for extended info
|
||||
*
|
||||
* 8. Public server integration:
|
||||
* - Click "View on Public Server" link
|
||||
* - Verify popup opens with correct URL
|
||||
* - Validate public server accessibility
|
||||
*
|
||||
* ================================================================================
|
||||
* TEST DATA SPECIFICATIONS
|
||||
* ================================================================================
|
||||
*
|
||||
* Gift Title Format: "Gift [4-char-random]"
|
||||
* - Prefix: "Gift " (with space)
|
||||
* - Random component: 4-character alphanumeric string
|
||||
* - Example: "Gift a7b3", "Gift x9y2"
|
||||
*
|
||||
* Amount Range: 1-99 (inclusive)
|
||||
* - Minimum: 1 (non-zero validation)
|
||||
* - Maximum: 99 (reasonable upper bound)
|
||||
* - Type: Integer only
|
||||
* - Example: 42, 7, 99
|
||||
*
|
||||
* Recipient: "Unnamed/Unknown"
|
||||
* - Standard test recipient
|
||||
* - No specific DID or contact info
|
||||
* - Used for all test gifts
|
||||
*
|
||||
* ================================================================================
|
||||
* SELECTOR REFERENCE
|
||||
* ================================================================================
|
||||
*
|
||||
* Form Elements:
|
||||
* - Gift title input: '[data-testid="giftTitle"]' or 'input[placeholder="What was given"]'
|
||||
* - Amount input: 'input[type="number"]' or 'input[role="spinbutton"]'
|
||||
* - Submit button: 'button[name="Sign & Send"]'
|
||||
* - Success alert: 'div[role="alert"]'
|
||||
* - Details section: 'h2[name="Details"]'
|
||||
* - Person button: 'button[name="Person"]'
|
||||
* - Recipient list: 'ul[role="listbox"]'
|
||||
*
|
||||
* Alert Handling:
|
||||
* - Closes onboarding dialog
|
||||
* - Verifies success message
|
||||
* - Dismisses info alerts
|
||||
* Navigation & UI:
|
||||
* - Onboarding close: '[data-testid="closeOnboardingAndFinish"]'
|
||||
* - Home page: './' (relative URL)
|
||||
* - Alert dismissal: 'div[role="alert"] button > svg.fa-xmark'
|
||||
* - Success message: 'text="That gift was recorded."'
|
||||
*
|
||||
* State Requirements:
|
||||
* - Clean database state
|
||||
* - User 00 imported
|
||||
* - Available API rate limits
|
||||
* Verification Elements:
|
||||
* - Gift list item: 'li:first-child' (filtered by title)
|
||||
* - Info link: '[data-testid="circle-info-link"]'
|
||||
* - Details heading: 'h2[name="Verifiable Claim Details"]'
|
||||
* - Details section: 'h2[name="Details", exact="true"]'
|
||||
* - Public server link: 'a[name="View on the Public Server"]'
|
||||
*
|
||||
* Related Files:
|
||||
* - Gift recording view: src/views/RecordGiftView.vue
|
||||
* - JWT creation: sw_scripts/safari-notifications.js
|
||||
* - Endorser API: src/libs/endorserServer.ts
|
||||
* ================================================================================
|
||||
* ERROR HANDLING & DEBUGGING
|
||||
* ================================================================================
|
||||
*
|
||||
* @see Documentation in usage-guide.md for gift recording workflows
|
||||
* @requires @playwright/test
|
||||
* @requires ./testUtils - For user management utilities
|
||||
* Common Failure Points:
|
||||
* 1. Onboarding Dialog
|
||||
* - Issue: Dialog doesn't close properly
|
||||
* - Debug: Check if closeOnboardingAndFinish button exists
|
||||
* - Fix: Add wait for dialog to be visible before clicking
|
||||
*
|
||||
* @example Basic gift recording
|
||||
* ```typescript
|
||||
* await page.getByPlaceholder('What was given').fill('Gift abc123');
|
||||
* await page.getByRole('spinbutton').fill('42');
|
||||
* await page.getByRole('button', { name: 'Sign & Send' }).click();
|
||||
* await expect(page.getByText('That gift was recorded.')).toBeVisible();
|
||||
* 2. Recipient Selection
|
||||
* - Issue: "Unnamed" recipient not found
|
||||
* - Debug: Check if recipient list is populated
|
||||
* - Fix: Add wait for list to load before filtering
|
||||
*
|
||||
* 3. Form Submission
|
||||
* - Issue: "Sign & Send" button not clickable
|
||||
* - Debug: Check if form is valid and all fields filled
|
||||
* - Fix: Add validation before submission
|
||||
*
|
||||
* 4. Success Verification
|
||||
* - Issue: Success message doesn't appear
|
||||
* - Debug: Check network requests and JWT signing
|
||||
* - Fix: Add longer timeout for signing process
|
||||
*
|
||||
* 5. Home View Refresh
|
||||
* - Issue: Gift doesn't appear in list
|
||||
* - Debug: Check if gift was actually recorded
|
||||
* - Fix: Add wait for home view to reload
|
||||
*
|
||||
* 6. Public Server Integration
|
||||
* - Issue: Popup doesn't open or wrong URL
|
||||
* - Debug: Check if public server is accessible
|
||||
* - Fix: Verify endorser server configuration
|
||||
*
|
||||
* Debugging Commands:
|
||||
* ```bash
|
||||
* # Run with trace for detailed debugging
|
||||
* npx playwright test 30-record-gift.spec.ts --trace on
|
||||
*
|
||||
* # Run with headed browser for visual debugging
|
||||
* npx playwright test 30-record-gift.spec.ts --headed
|
||||
*
|
||||
* # Run with slow motion for step-by-step debugging
|
||||
* npx playwright test 30-record-gift.spec.ts --debug
|
||||
* ```
|
||||
*
|
||||
* ================================================================================
|
||||
* BROWSER COMPATIBILITY
|
||||
* ================================================================================
|
||||
*
|
||||
* Tested Browsers:
|
||||
* - Chromium: Primary target, full functionality
|
||||
* - Firefox: Secondary target, may have timing differences
|
||||
*
|
||||
* Browser-Specific Considerations:
|
||||
* - Firefox: May require longer timeouts for form interactions
|
||||
* - Chromium: Generally faster, more reliable
|
||||
* - Both: Popup handling may differ slightly
|
||||
*
|
||||
* ================================================================================
|
||||
* PERFORMANCE CONSIDERATIONS
|
||||
* ================================================================================
|
||||
*
|
||||
* Expected Timings:
|
||||
* - Data generation: < 1ms
|
||||
* - User import: 2-5 seconds
|
||||
* - Form filling: 1-2 seconds
|
||||
* - JWT signing: 3-8 seconds
|
||||
* - Home refresh: 2-4 seconds
|
||||
* - Public server: 1-3 seconds
|
||||
*
|
||||
* Total expected runtime: 10-20 seconds
|
||||
*
|
||||
* Performance Monitoring:
|
||||
* - Monitor JWT signing time (most variable)
|
||||
* - Track home view refresh time
|
||||
* - Watch for memory leaks in popup handling
|
||||
*
|
||||
* ================================================================================
|
||||
* MAINTENANCE GUIDELINES
|
||||
* ================================================================================
|
||||
*
|
||||
* When Modifying This Test:
|
||||
* 1. Update version number and lastModified date
|
||||
* 2. Test on both Chromium and Firefox
|
||||
* 3. Verify with different random data sets
|
||||
* 4. Check that public server integration still works
|
||||
* 5. Update selector references if UI changes
|
||||
*
|
||||
* Related Files to Monitor:
|
||||
* - src/views/RecordGiftView.vue (gift recording UI)
|
||||
* - src/views/HomeView.vue (gift display)
|
||||
* - sw_scripts/safari-notifications.js (JWT signing)
|
||||
* - src/libs/endorserServer.ts (API integration)
|
||||
* - test-playwright/testUtils.ts (user management)
|
||||
*
|
||||
* ================================================================================
|
||||
* INTEGRATION POINTS
|
||||
* ================================================================================
|
||||
*
|
||||
* Dependencies:
|
||||
* - User 00 must be available in test data
|
||||
* - Endorser server must be running and accessible
|
||||
* - Public server must be configured correctly
|
||||
* - JWT signing must be functional
|
||||
*
|
||||
* API Endpoints Used:
|
||||
* - POST /api/claims (gift recording)
|
||||
* - GET /api/claims (public verification)
|
||||
* - WebSocket connections for real-time updates
|
||||
*
|
||||
* ================================================================================
|
||||
* SECURITY CONSIDERATIONS
|
||||
* ================================================================================
|
||||
*
|
||||
* Test Data Security:
|
||||
* - Random data prevents test interference
|
||||
* - No sensitive information in test gifts
|
||||
* - Public server verification is read-only
|
||||
*
|
||||
* JWT Handling:
|
||||
* - Test uses test user credentials
|
||||
* - Signing process is isolated
|
||||
* - No production keys used
|
||||
*
|
||||
* ================================================================================
|
||||
* RELATED DOCUMENTATION
|
||||
* ================================================================================
|
||||
*
|
||||
* @see test-playwright/testUtils.ts - User management utilities
|
||||
* @see test-playwright/README.md - General testing guidelines
|
||||
* @see docs/user-guides/gift-recording.md - User workflow documentation
|
||||
* @see src/views/RecordGiftView.vue - Implementation details
|
||||
* @see sw_scripts/safari-notifications.js - JWT signing implementation
|
||||
*
|
||||
* @example Complete test execution
|
||||
* ```bash
|
||||
* # Run this specific test
|
||||
* npx playwright test 30-record-gift.spec.ts
|
||||
*
|
||||
* # Run with detailed output
|
||||
* npx playwright test 30-record-gift.spec.ts --reporter=list
|
||||
*
|
||||
* # Run in headed mode for debugging
|
||||
* npx playwright test 30-record-gift.spec.ts --headed
|
||||
* ```
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { importUser } from './testUtils';
|
||||
import { importUserFromAccount } from './testUtils';
|
||||
import {
|
||||
createPerformanceCollector,
|
||||
attachPerformanceData,
|
||||
assertPerformanceMetrics
|
||||
} from './performanceUtils';
|
||||
|
||||
test('Record something given', async ({ page }) => {
|
||||
// Generate a random string of a few characters
|
||||
/**
|
||||
* @test Record something given
|
||||
* @description End-to-end test of gift recording functionality with performance tracking
|
||||
* @tags gift-recording, e2e, user-workflow, performance
|
||||
* @timeout 45000ms (45 seconds for JWT signing and API calls)
|
||||
*
|
||||
* @process
|
||||
* 1. Generate unique test data
|
||||
* 2. Import test user and navigate to home
|
||||
* 3. Record gift with random title and amount
|
||||
* 4. Verify gift appears in home view
|
||||
* 5. Check public server integration
|
||||
*
|
||||
* @data
|
||||
* - Gift title: "Gift [random-4-chars]"
|
||||
* - Amount: Random 1-99
|
||||
* - Recipient: "Unnamed/Unknown"
|
||||
*
|
||||
* @verification
|
||||
* - Success notification appears
|
||||
* - Gift visible in home view
|
||||
* - Details match input data
|
||||
* - Public server accessible
|
||||
*
|
||||
* @browsers chromium, firefox
|
||||
* @retries 2 (for flaky network conditions)
|
||||
*/
|
||||
test('Record something given', async ({ page }, testInfo) => {
|
||||
// STEP 1: Initialize the performance collector
|
||||
const perfCollector = await createPerformanceCollector(page);
|
||||
|
||||
// STEP 2: Generate unique test data
|
||||
const randomString = Math.random().toString(36).substring(2, 6);
|
||||
|
||||
// Generate a random non-zero single-digit number
|
||||
const randomNonZeroNumber = Math.floor(Math.random() * 99) + 1;
|
||||
|
||||
// Standard title prefix
|
||||
const standardTitle = 'Gift ';
|
||||
|
||||
// Combine title prefix with the random string
|
||||
const finalTitle = standardTitle + randomString;
|
||||
|
||||
// Import user 00
|
||||
await importUser(page, '00');
|
||||
// STEP 3: Import user 00 and navigate to home page
|
||||
await perfCollector.measureUserAction('import-user-account', async () => {
|
||||
await importUserFromAccount(page, '00');
|
||||
});
|
||||
|
||||
// Record something given
|
||||
await page.goto('./');
|
||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
||||
await page.getByRole('button', { name: 'Person' }).click();
|
||||
await page.getByRole('listitem').filter({ hasText: 'Unnamed' }).locator('svg').click();
|
||||
await page.getByPlaceholder('What was given').fill(finalTitle);
|
||||
await page.getByRole('spinbutton').fill(randomNonZeroNumber.toString());
|
||||
await page.getByRole('button', { name: 'Sign & Send' }).click();
|
||||
await expect(page.getByText('That gift was recorded.')).toBeVisible();
|
||||
await page.locator('div[role="alert"] button > svg.fa-xmark').click(); // dismiss info alert
|
||||
await perfCollector.measureUserAction('initial-navigation', async () => {
|
||||
await page.goto('./');
|
||||
});
|
||||
const initialMetrics = await perfCollector.collectNavigationMetrics('home-page-load');
|
||||
await testInfo.attach('initial-page-load-metrics', {
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(initialMetrics, null, 2)
|
||||
});
|
||||
|
||||
// STEP 4: Close onboarding dialog
|
||||
await perfCollector.measureUserAction('close-onboarding', async () => {
|
||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
||||
});
|
||||
|
||||
// STEP 4.5: Close any additional dialogs that might be blocking
|
||||
await perfCollector.measureUserAction('close-additional-dialogs', async () => {
|
||||
// Wait a moment for any dialogs to appear
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Try to close any remaining dialogs
|
||||
const closeButtons = page.locator('button[aria-label*="close"], button[aria-label*="Close"], .dialog-overlay button, [role="dialog"] button');
|
||||
const count = await closeButtons.count();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
try {
|
||||
await closeButtons.nth(i).click({ timeout: 2000 });
|
||||
} catch (e) {
|
||||
// Ignore errors if button is not clickable
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for any animations to complete
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
// STEP 5: Select recipient
|
||||
await perfCollector.measureUserAction('select-recipient', async () => {
|
||||
await page.getByRole('button', { name: 'Person' }).click();
|
||||
await page.getByRole('listitem').filter({ hasText: 'Unnamed' }).locator('svg').click();
|
||||
});
|
||||
|
||||
// STEP 6: Fill gift details
|
||||
await perfCollector.measureUserAction('fill-gift-details', async () => {
|
||||
await page.getByPlaceholder('What was given').fill(finalTitle);
|
||||
await page.getByRole('spinbutton').fill(randomNonZeroNumber.toString());
|
||||
});
|
||||
|
||||
// STEP 7: Submit gift and verify success
|
||||
await perfCollector.measureUserAction('submit-gift', async () => {
|
||||
await page.getByRole('button', { name: 'Sign & Send' }).click();
|
||||
await expect(page.getByText('That gift was recorded.')).toBeVisible();
|
||||
await page.locator('div[role="alert"] button > svg.fa-xmark').click();
|
||||
});
|
||||
|
||||
// STEP 8: Refresh home view and locate gift
|
||||
await perfCollector.measureUserAction('refresh-home-view', async () => {
|
||||
// Try page.reload() instead of goto to see if that helps
|
||||
await page.reload();
|
||||
});
|
||||
await perfCollector.collectNavigationMetrics('home-refresh-load');
|
||||
|
||||
// Wait for feed to load and gift to appear
|
||||
await perfCollector.measureUserAction('wait-for-feed-load', async () => {
|
||||
// Wait for the feed container to be present
|
||||
await page.locator('ul').first().waitFor({ state: 'visible', timeout: 15000 });
|
||||
|
||||
// Wait for any feed items to load (not just the first one)
|
||||
await page.locator('li').first().waitFor({ state: 'visible', timeout: 15000 });
|
||||
|
||||
// Debug: Check what's actually in the feed
|
||||
const feedItems = page.locator('li');
|
||||
const count = await feedItems.count();
|
||||
|
||||
|
||||
// Try to find our gift in any position, not just first
|
||||
let giftFound = false;
|
||||
for (let i = 0; i < count; i++) {
|
||||
try {
|
||||
const itemText = await feedItems.nth(i).textContent();
|
||||
if (itemText?.includes(finalTitle)) {
|
||||
giftFound = true;
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue to next item
|
||||
}
|
||||
}
|
||||
|
||||
if (!giftFound) {
|
||||
// Wait a bit more and try again
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Check again
|
||||
const newCount = await feedItems.count();
|
||||
|
||||
for (let i = 0; i < newCount; i++) {
|
||||
try {
|
||||
const itemText = await feedItems.nth(i).textContent();
|
||||
if (itemText?.includes(finalTitle)) {
|
||||
giftFound = true;
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue to next item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!giftFound) {
|
||||
throw new Error(`Gift with title "${finalTitle}" not found in feed after waiting`);
|
||||
}
|
||||
});
|
||||
|
||||
// Find the gift item (could be in any position)
|
||||
const item = page.locator('li').filter({ hasText: finalTitle });
|
||||
|
||||
// STEP 9: View gift details
|
||||
await perfCollector.measureUserAction('view-gift-details', async () => {
|
||||
// Debug: Check what elements are actually present
|
||||
|
||||
// Wait for the item to be visible
|
||||
await item.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
// Check if the circle-info-link exists
|
||||
const circleInfoLink = item.locator('[data-testid="circle-info-link"]');
|
||||
const isVisible = await circleInfoLink.isVisible();
|
||||
|
||||
// If not visible, let's see what's in the item
|
||||
if (!isVisible) {
|
||||
const itemHtml = await item.innerHTML();
|
||||
}
|
||||
|
||||
await circleInfoLink.click();
|
||||
});
|
||||
|
||||
// Refresh home view and check gift
|
||||
await page.goto('./');
|
||||
const item = await page.locator('li:first-child').filter({ hasText: finalTitle });
|
||||
await item.locator('[data-testid="circle-info-link"]').click();
|
||||
await expect(page.getByRole('heading', { name: 'Verifiable Claim Details' })).toBeVisible();
|
||||
await expect(page.getByText(finalTitle, { exact: true })).toBeVisible();
|
||||
|
||||
// STEP 10: Expand details and open public server
|
||||
const page1Promise = page.waitForEvent('popup');
|
||||
// expand the Details section to see the extended details
|
||||
await page.getByRole('heading', { name: 'Details', exact: true }).click();
|
||||
await page.getByRole('link', { name: 'View on the Public Server' }).click();
|
||||
|
||||
await perfCollector.measureUserAction('expand-details', async () => {
|
||||
await page.getByRole('heading', { name: 'Details', exact: true }).click();
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('open-public-server', async () => {
|
||||
await page.getByRole('link', { name: 'View on the Public Server' }).click();
|
||||
});
|
||||
|
||||
const page1 = await page1Promise;
|
||||
|
||||
// STEP 11: Attach and validate performance data
|
||||
const { webVitals, performanceReport, summary } = await attachPerformanceData(testInfo, perfCollector);
|
||||
const avgNavigationTime = perfCollector.navigationMetrics.reduce((sum, nav) =>
|
||||
sum + nav.metrics.loadComplete, 0) / perfCollector.navigationMetrics.length;
|
||||
assertPerformanceMetrics(webVitals, initialMetrics, avgNavigationTime);
|
||||
});
|
||||
@@ -33,7 +33,7 @@
|
||||
* - Sign and submit
|
||||
* - Verify success
|
||||
* - Dismiss notification
|
||||
* - Verify gift in list
|
||||
* - Verify gift in list (optimized)
|
||||
*
|
||||
* Test Data:
|
||||
* - Gift Count: 9 (optimized for timeout limits)
|
||||
@@ -52,6 +52,8 @@
|
||||
* - Limited to 9 gifts to avoid timeout
|
||||
* - Handles UI lag between operations
|
||||
* - Manages memory usage during bulk operations
|
||||
* - Optimized navigation: single page.goto() per iteration
|
||||
* - Efficient verification: waits for DOM updates instead of full page reload
|
||||
*
|
||||
* Error Handling:
|
||||
* - Closes onboarding dialog only on first iteration
|
||||
@@ -85,51 +87,103 @@
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { importUser, createUniqueStringsArray, createRandomNumbersArray } from './testUtils';
|
||||
import { importUserFromAccount, createUniqueStringsArray, createRandomNumbersArray } from './testUtils';
|
||||
import { createPerformanceCollector, attachPerformanceData, assertPerformanceMetrics } from './performanceUtils';
|
||||
|
||||
test('Record 9 new gifts', async ({ page }) => {
|
||||
test('Record 9 new gifts', async ({ page }, testInfo) => {
|
||||
test.slow(); // Set timeout longer
|
||||
|
||||
// STEP 1: Initialize the performance collector
|
||||
const perfCollector = await createPerformanceCollector(page);
|
||||
|
||||
const giftCount = 9;
|
||||
const standardTitle = 'Gift ';
|
||||
const finalTitles = [];
|
||||
const finalNumbers = [];
|
||||
const finalTitles: string[] = [];
|
||||
const finalNumbers: number[] = [];
|
||||
|
||||
// Create arrays for field input
|
||||
const uniqueStrings = await createUniqueStringsArray(giftCount);
|
||||
const randomNumbers = await createRandomNumbersArray(giftCount);
|
||||
// STEP 2: Create arrays for field input
|
||||
await perfCollector.measureUserAction('generate-test-data', async () => {
|
||||
const uniqueStrings = await createUniqueStringsArray(giftCount);
|
||||
const randomNumbers = await createRandomNumbersArray(giftCount);
|
||||
|
||||
// Populate arrays
|
||||
// Populate arrays
|
||||
for (let i = 0; i < giftCount; i++) {
|
||||
finalTitles.push(standardTitle + uniqueStrings[i]);
|
||||
finalNumbers.push(randomNumbers[i]);
|
||||
}
|
||||
});
|
||||
|
||||
// STEP 3: Import user 00
|
||||
await perfCollector.measureUserAction('import-user-account', async () => {
|
||||
await importUserFromAccount(page, '00');
|
||||
});
|
||||
|
||||
// STEP 4: Initial navigation and metrics collection
|
||||
await perfCollector.measureUserAction('initial-navigation', async () => {
|
||||
await page.goto('./');
|
||||
});
|
||||
const initialMetrics = await perfCollector.collectNavigationMetrics('initial-home-load');
|
||||
await testInfo.attach('initial-page-load-metrics', {
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(initialMetrics, null, 2)
|
||||
});
|
||||
|
||||
// STEP 5: Record new gifts with optimized navigation
|
||||
for (let i = 0; i < giftCount; i++) {
|
||||
finalTitles.push(standardTitle + uniqueStrings[i]);
|
||||
finalNumbers.push(randomNumbers[i]);
|
||||
// Only navigate on first iteration
|
||||
if (i === 0) {
|
||||
await perfCollector.measureUserAction(`navigate-home-iteration-${i + 1}`, async () => {
|
||||
await page.goto('./', { waitUntil: 'networkidle' });
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('close-onboarding', async () => {
|
||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
||||
});
|
||||
} else {
|
||||
// For subsequent iterations, just wait for the page to be ready
|
||||
await perfCollector.measureUserAction(`wait-for-page-ready-iteration-${i + 1}`, async () => {
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
});
|
||||
}
|
||||
|
||||
await perfCollector.measureUserAction(`select-recipient-iteration-${i + 1}`, async () => {
|
||||
await page.getByRole('button', { name: 'Person' }).click();
|
||||
await page.getByRole('listitem').filter({ hasText: 'Unnamed' }).locator('svg').click();
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction(`fill-gift-details-iteration-${i + 1}`, async () => {
|
||||
await page.getByPlaceholder('What was given').fill(finalTitles[i]);
|
||||
await page.getByRole('spinbutton').fill(finalNumbers[i].toString());
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction(`submit-gift-iteration-${i + 1}`, async () => {
|
||||
await page.getByRole('button', { name: 'Sign & Send' }).click();
|
||||
|
||||
// Wait for success and dismiss
|
||||
await expect(page.getByText('That gift was recorded.')).toBeVisible();
|
||||
await page.locator('div[role="alert"] button > svg.fa-xmark').click();
|
||||
});
|
||||
|
||||
// Optimized verification: use page.reload() instead of page.goto() for faster refresh
|
||||
await perfCollector.measureUserAction(`verify-gift-in-list-iteration-${i + 1}`, async () => {
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('ul#listLatestActivity li')
|
||||
.filter({ hasText: finalTitles[i] })
|
||||
.first())
|
||||
.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
}
|
||||
|
||||
// Import user 00
|
||||
await importUser(page, '00');
|
||||
|
||||
// Record new gifts with optimized waiting
|
||||
for (let i = 0; i < giftCount; i++) {
|
||||
// Record gift
|
||||
await page.goto('./', { waitUntil: 'networkidle' });
|
||||
if (i === 0) {
|
||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
||||
}
|
||||
await page.getByRole('button', { name: 'Person' }).click();
|
||||
await page.getByRole('listitem').filter({ hasText: 'Unnamed' }).locator('svg').click();
|
||||
await page.getByPlaceholder('What was given').fill(finalTitles[i]);
|
||||
await page.getByRole('spinbutton').fill(finalNumbers[i].toString());
|
||||
await page.getByRole('button', { name: 'Sign & Send' }).click();
|
||||
|
||||
// Wait for success and dismiss
|
||||
await expect(page.getByText('That gift was recorded.')).toBeVisible();
|
||||
await page.locator('div[role="alert"] button > svg.fa-xmark').click();
|
||||
|
||||
// Verify gift in list with network idle wait
|
||||
await page.goto('./', { waitUntil: 'networkidle' });
|
||||
await expect(page.locator('ul#listLatestActivity li')
|
||||
.filter({ hasText: finalTitles[i] })
|
||||
.first())
|
||||
.toBeVisible({ timeout: 3000 });
|
||||
// STEP 6: Attach and validate performance data
|
||||
const { webVitals, performanceReport, summary } = await attachPerformanceData(testInfo, perfCollector);
|
||||
|
||||
// Calculate average navigation time only if we have metrics
|
||||
if (perfCollector.navigationMetrics.length > 0) {
|
||||
const avgNavigationTime = perfCollector.navigationMetrics.reduce((sum, nav) =>
|
||||
sum + nav.metrics.loadComplete, 0) / perfCollector.navigationMetrics.length;
|
||||
assertPerformanceMetrics(webVitals, initialMetrics, avgNavigationTime);
|
||||
} else {
|
||||
// If no navigation metrics, just validate web vitals
|
||||
assertPerformanceMetrics(webVitals, initialMetrics, 0);
|
||||
}
|
||||
});
|
||||
@@ -1,50 +1,101 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { importUser } from './testUtils';
|
||||
import { createPerformanceCollector, attachPerformanceData, assertPerformanceMetrics } from './performanceUtils';
|
||||
|
||||
async function testProjectGive(page: Page, selector: string) {
|
||||
async function testProjectGive(page: Page, selector: string, testInfo: any) {
|
||||
// STEP 1: Initialize the performance collector
|
||||
const perfCollector = await createPerformanceCollector(page);
|
||||
|
||||
// Generate a random string of a few characters
|
||||
// STEP 2: Generate unique test data
|
||||
const randomString = Math.random().toString(36).substring(2, 6);
|
||||
|
||||
// Generate a random non-zero single-digit number
|
||||
const randomNonZeroNumber = Math.floor(Math.random() * 99) + 1;
|
||||
|
||||
// Standard title prefix
|
||||
const standardTitle = 'Gift ';
|
||||
|
||||
// Combine title prefix with the random string
|
||||
const finalTitle = standardTitle + randomString;
|
||||
|
||||
// find a project and enter a give to it and see that it shows
|
||||
await importUser(page, '00');
|
||||
await page.goto('./discover');
|
||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
||||
// STEP 3: Import user and navigate to discover
|
||||
await perfCollector.measureUserAction('import-user-account', async () => {
|
||||
await importUser(page, '00');
|
||||
});
|
||||
|
||||
await page.locator('ul#listDiscoverResults li:first-child a').click()
|
||||
// wait for the project page to load
|
||||
await page.waitForLoadState('networkidle');
|
||||
// click the give button, inside the first div
|
||||
await page.getByTestId(selector).locator('div:first-child div button').click();
|
||||
await page.getByPlaceholder('What was given').fill(finalTitle);
|
||||
await page.getByRole('spinbutton').fill(randomNonZeroNumber.toString());
|
||||
await page.getByRole('button', { name: 'Sign & Send' }).click();
|
||||
await expect(page.getByText('That gift was recorded.')).toBeVisible();
|
||||
await page.locator('div[role="alert"] button > svg.fa-xmark').click(); // dismiss info alert
|
||||
await perfCollector.measureUserAction('navigate-to-discover', async () => {
|
||||
await page.goto('./discover');
|
||||
});
|
||||
const initialMetrics = await perfCollector.collectNavigationMetrics('discover-page-load');
|
||||
await testInfo.attach('initial-page-load-metrics', {
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(initialMetrics, null, 2)
|
||||
});
|
||||
|
||||
// refresh the page
|
||||
await page.reload();
|
||||
// check that the give is in the list
|
||||
await page
|
||||
.getByTestId(selector)
|
||||
.locator('div ul li:first-child')
|
||||
.filter({ hasText: finalTitle })
|
||||
.isVisible();
|
||||
await perfCollector.measureUserAction('close-onboarding', async () => {
|
||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('select-first-project', async () => {
|
||||
await page.locator('ul#listDiscoverResults li:first-child a').click();
|
||||
});
|
||||
|
||||
// STEP 4: Wait for project page to load
|
||||
await perfCollector.measureUserAction('wait-for-project-load', async () => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
// STEP 5: Handle dialog overlays
|
||||
await perfCollector.measureUserAction('close-dialog-overlays', async () => {
|
||||
await page.waitForTimeout(1000);
|
||||
const closeButtons = page.locator('button[aria-label*="close"], button[aria-label*="Close"], .dialog-overlay button, [role="dialog"] button');
|
||||
const count = await closeButtons.count();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
try {
|
||||
await closeButtons.nth(i).click({ timeout: 2000 });
|
||||
} catch (e) {
|
||||
// Ignore errors if button is not clickable
|
||||
}
|
||||
}
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
// STEP 6: Record gift
|
||||
await perfCollector.measureUserAction('click-give-button', async () => {
|
||||
await page.getByTestId(selector).locator('div:first-child div button').click();
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('fill-gift-details', async () => {
|
||||
await page.getByPlaceholder('What was given').fill(finalTitle);
|
||||
await page.getByRole('spinbutton').fill(randomNonZeroNumber.toString());
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('submit-gift', async () => {
|
||||
await page.getByRole('button', { name: 'Sign & Send' }).click();
|
||||
await expect(page.getByText('That gift was recorded.')).toBeVisible();
|
||||
await page.locator('div[role="alert"] button > svg.fa-xmark').click();
|
||||
});
|
||||
|
||||
// STEP 7: Verify gift appears in list
|
||||
await perfCollector.measureUserAction('refresh-page', async () => {
|
||||
await page.reload();
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('verify-gift-in-list', async () => {
|
||||
await page
|
||||
.getByTestId(selector)
|
||||
.locator('div ul li:first-child')
|
||||
.filter({ hasText: finalTitle })
|
||||
.isVisible();
|
||||
});
|
||||
|
||||
// STEP 8: Attach and validate performance data
|
||||
const { webVitals, performanceReport, summary } = await attachPerformanceData(testInfo, perfCollector);
|
||||
const avgNavigationTime = perfCollector.navigationMetrics.reduce((sum, nav) =>
|
||||
sum + nav.metrics.loadComplete, 0) / perfCollector.navigationMetrics.length;
|
||||
assertPerformanceMetrics(webVitals, initialMetrics, avgNavigationTime);
|
||||
}
|
||||
|
||||
test('Record a give to a project', async ({ page }) => {
|
||||
await testProjectGive(page, 'gives-to');
|
||||
test('Record a give to a project', async ({ page }, testInfo) => {
|
||||
await testProjectGive(page, 'gives-to', testInfo);
|
||||
});
|
||||
|
||||
test('Record a give from a project', async ({ page }) => {
|
||||
await testProjectGive(page, 'gives-from');
|
||||
test('Record a give from a project', async ({ page }, testInfo) => {
|
||||
await testProjectGive(page, 'gives-from', testInfo);
|
||||
});
|
||||
|
||||
2475
test-playwright/45-contact-import.spec.ts
Normal file
2475
test-playwright/45-contact-import.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,127 +1,291 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { importUser, importUserFromAccount } from './testUtils';
|
||||
import { createPerformanceCollector, attachPerformanceData, assertPerformanceMetrics } from './performanceUtils';
|
||||
|
||||
test('Record an offer', async ({ page }) => {
|
||||
test('Record an offer', async ({ page }, testInfo) => {
|
||||
test.setTimeout(60000);
|
||||
|
||||
// Generate a random string of 3 characters, skipping the "0." at the beginning
|
||||
// STEP 1: Initialize the performance collector
|
||||
const perfCollector = await createPerformanceCollector(page);
|
||||
|
||||
// STEP 2: Generate unique test data
|
||||
const randomString = Math.random().toString(36).substring(2, 5);
|
||||
// Standard title prefix
|
||||
const description = `Offering of ${randomString}`;
|
||||
const updatedDescription = `Updated ${description}`;
|
||||
const randomNonZeroNumber = Math.floor(Math.random() * 998) + 1;
|
||||
|
||||
// Switch to user 0
|
||||
// await importUser(page);
|
||||
// Become User Zero
|
||||
await importUserFromAccount(page, "00");
|
||||
// Select a project
|
||||
await page.goto('./discover');
|
||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
||||
await page.locator('ul#listDiscoverResults li:nth-child(1)').click();
|
||||
// Record an offer
|
||||
await page.locator('button', { hasText: 'Edit' }).isVisible(); // since the 'edit' takes longer to show, wait for that (lest the click miss)
|
||||
await page.getByTestId('offerButton').click();
|
||||
await page.getByTestId('inputDescription').fill(description);
|
||||
await page.getByTestId('inputOfferAmount').locator('input').fill(randomNonZeroNumber.toString());
|
||||
expect(page.getByRole('button', { name: 'Sign & Send' }));
|
||||
await page.getByRole('button', { name: 'Sign & Send' }).click();
|
||||
await expect(page.getByText('That offer was recorded.')).toBeVisible();
|
||||
await page.locator('div[role="alert"] button > svg.fa-xmark').click(); // dismiss info alert
|
||||
// go to the offer and check the values
|
||||
await page.goto('./projects');
|
||||
await page.getByRole('link', { name: 'Offers', exact: true }).click();
|
||||
await page.locator('li').filter({ hasText: description }).locator('a').first().click();
|
||||
await expect(page.getByRole('heading', { name: 'Verifiable Claim Details' })).toBeVisible();
|
||||
await expect(page.getByText(description, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Offered to a bigger plan')).toBeVisible();
|
||||
// STEP 3: Import user and navigate to discover page
|
||||
await perfCollector.measureUserAction('import-user-account', async () => {
|
||||
await importUserFromAccount(page, "00");
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('navigate-to-discover', async () => {
|
||||
await page.goto('./discover');
|
||||
});
|
||||
const initialMetrics = await perfCollector.collectNavigationMetrics('discover-page-load');
|
||||
await testInfo.attach('initial-page-load-metrics', {
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(initialMetrics, null, 2)
|
||||
});
|
||||
|
||||
// STEP 4: Close onboarding and select project
|
||||
await perfCollector.measureUserAction('close-onboarding', async () => {
|
||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('select-project', async () => {
|
||||
await page.locator('ul#listDiscoverResults li:nth-child(1)').click();
|
||||
});
|
||||
|
||||
// STEP 5: Record an offer
|
||||
await perfCollector.measureUserAction('wait-for-edit-button', async () => {
|
||||
await page.locator('button', { hasText: 'Edit' }).isVisible();
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('click-offer-button', async () => {
|
||||
await page.getByTestId('offerButton').click();
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('fill-offer-details', async () => {
|
||||
await page.getByTestId('inputDescription').fill(description);
|
||||
await page.getByTestId('inputOfferAmount').fill(randomNonZeroNumber.toString());
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('submit-offer', async () => {
|
||||
expect(page.getByRole('button', { name: 'Sign & Send' }));
|
||||
await page.getByRole('button', { name: 'Sign & Send' }).click();
|
||||
await expect(page.getByText('That offer was recorded.')).toBeVisible();
|
||||
await page.locator('div[role="alert"] button > svg.fa-xmark').click();
|
||||
});
|
||||
|
||||
// STEP 6: Navigate to projects and check offer
|
||||
await perfCollector.measureUserAction('navigate-to-projects', async () => {
|
||||
await page.goto('./projects');
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('click-offers-tab', async () => {
|
||||
await page.getByRole('link', { name: 'Offers', exact: true }).click();
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('click-offer-details', async () => {
|
||||
await page.locator('li').filter({ hasText: description }).locator('a').first().click();
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('verify-offer-details', async () => {
|
||||
await expect(page.getByRole('heading', { name: 'Verifiable Claim Details' })).toBeVisible();
|
||||
await expect(page.getByText(description, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Offered to a bigger plan')).toBeVisible();
|
||||
});
|
||||
|
||||
// STEP 7: Expand details and check public server
|
||||
const serverPagePromise = page.waitForEvent('popup');
|
||||
// expand the Details section to see the extended details
|
||||
await page.getByRole('heading', { name: 'Details', exact: true }).click();
|
||||
await page.getByRole('link', { name: 'View on the Public Server' }).click();
|
||||
const serverPage = await serverPagePromise;
|
||||
await expect(serverPage.getByText(description)).toBeVisible();
|
||||
await expect(serverPage.getByText('did:none:HIDDEN')).toBeVisible();
|
||||
// Now update that offer
|
||||
|
||||
// find the edit page and check the old values again
|
||||
await page.goto('./projects');
|
||||
await page.getByRole('link', { name: 'Offers', exact: true }).click();
|
||||
await page.locator('li').filter({ hasText: description }).locator('a').first().click();
|
||||
await page.getByTestId('editClaimButton').click();
|
||||
await page.locator('heading', { hasText: 'What is offered' }).isVisible();
|
||||
const itemDesc = await page.getByTestId('itemDescription');
|
||||
await expect(itemDesc).toHaveValue(description);
|
||||
const amount = await page.getByTestId('inputOfferAmount');
|
||||
await expect(amount).toHaveValue(randomNonZeroNumber.toString());
|
||||
// update the values
|
||||
await itemDesc.fill(updatedDescription);
|
||||
await amount.fill(String(randomNonZeroNumber + 1));
|
||||
await page.getByRole('button', { name: 'Sign & Send' }).click();
|
||||
await expect(page.getByText('That offer was recorded.')).toBeVisible();
|
||||
await page.locator('div[role="alert"] button > svg.fa-xmark').click(); // dismiss info alert
|
||||
// go to the offer claim again and check the updated values
|
||||
await page.goto('./projects');
|
||||
await page.getByRole('link', { name: 'Offers', exact: true }).click();
|
||||
await page.locator('li').filter({ hasText: description }).locator('a').first().click();
|
||||
const newItemDesc = page.getByTestId('description');
|
||||
await expect(newItemDesc).toHaveText(updatedDescription);
|
||||
// go to edit page
|
||||
await page.getByTestId('editClaimButton').click();
|
||||
const newAmount = page.getByTestId('inputOfferAmount');
|
||||
await expect(newAmount).toHaveValue((randomNonZeroNumber + 1).toString());
|
||||
// go to the home page and check that the offer is shown as new
|
||||
await page.goto('./');
|
||||
const offerNumElem = page.getByTestId('newOffersToUserProjectsActivityNumber');
|
||||
// extract the number and check that it's greater than 0 or "50+"
|
||||
const offerNumText = await offerNumElem.textContent();
|
||||
if (offerNumText === null) {
|
||||
throw new Error('Expected Activity Number greater than 0 but got null.');
|
||||
} else if (offerNumText === '50+') {
|
||||
// we're OK
|
||||
} else if (parseInt(offerNumText) > 0) {
|
||||
// we're OK
|
||||
} else {
|
||||
throw new Error(`Expected Activity Number of greater than 0 but got ${offerNumText}.`);
|
||||
}
|
||||
|
||||
// click on the number of new offers to go to the list page
|
||||
await offerNumElem.click();
|
||||
await expect(page.getByText('New Offers To Your Projects', { exact: true })).toBeVisible();
|
||||
// get the icon child of the showOffersToUserProjects
|
||||
await page.getByTestId('showOffersToUserProjects').locator('div > svg.fa-chevron-right').click();
|
||||
await expect(page.getByText(description)).toBeVisible();
|
||||
});
|
||||
|
||||
test('Affirm delivery of an offer', async ({ page }) => {
|
||||
// go to the home page and check that the offer is shown as new
|
||||
// await importUser(page);
|
||||
|
||||
await importUserFromAccount(page, "00");
|
||||
await page.goto('./');
|
||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
||||
const offerNumElem = page.getByTestId('newOffersToUserProjectsActivityNumber');
|
||||
await expect(offerNumElem).toBeVisible();
|
||||
|
||||
// click on the number of new offers to go to the list page
|
||||
await offerNumElem.click();
|
||||
|
||||
// get the link that comes after the showOffersToUserProjects and click it
|
||||
await page.getByTestId('showOffersToUserProjects').locator('a').click();
|
||||
await perfCollector.measureUserAction('expand-details', async () => {
|
||||
await page.getByRole('heading', { name: 'Details', exact: true }).click();
|
||||
});
|
||||
|
||||
// get the first item of the list and click on the icon with file-lines
|
||||
const firstItem = page.getByTestId('listRecentOffersToUserProjects').locator('li').first();
|
||||
await expect(firstItem).toBeVisible();
|
||||
await firstItem.locator('svg.fa-file-lines').click();
|
||||
await expect(page.getByText('Verifiable Claim Details', { exact: true })).toBeVisible();
|
||||
await perfCollector.measureUserAction('open-public-server', async () => {
|
||||
await page.getByRole('link', { name: 'View on the Public Server' }).click();
|
||||
});
|
||||
|
||||
// click on the 'Affirm Delivery' button
|
||||
await page.getByRole('button', { name: 'Affirm Delivery' }).click();
|
||||
// fill our offer info and submit
|
||||
await page.getByPlaceholder('What was given').fill('Whatever the offer says');
|
||||
await page.getByRole('spinbutton').fill('2');
|
||||
await page.getByRole('button', { name: 'Sign & Send' }).click();
|
||||
await expect(page.getByText('That gift was recorded.')).toBeVisible();
|
||||
await page.locator('div[role="alert"] button > svg.fa-xmark').click(); // dismiss info alert
|
||||
const serverPage = await serverPagePromise;
|
||||
await perfCollector.measureUserAction('verify-public-server', async () => {
|
||||
await expect(serverPage.getByText(description)).toBeVisible();
|
||||
await expect(serverPage.getByText('did:none:HIDDEN')).toBeVisible();
|
||||
});
|
||||
|
||||
// STEP 8: Update the offer
|
||||
await perfCollector.measureUserAction('navigate-back-to-projects', async () => {
|
||||
await page.goto('./projects');
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('click-offers-tab-again', async () => {
|
||||
await page.getByRole('link', { name: 'Offers', exact: true }).click();
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('click-offer-to-edit', async () => {
|
||||
await page.locator('li').filter({ hasText: description }).locator('a').first().click();
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('click-edit-button', async () => {
|
||||
await page.getByTestId('editClaimButton').click();
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('verify-edit-form', async () => {
|
||||
await page.locator('heading', { hasText: 'What is offered' }).isVisible();
|
||||
const itemDesc = await page.getByTestId('itemDescription');
|
||||
await expect(itemDesc).toHaveValue(description);
|
||||
const amount = await page.getByTestId('inputOfferAmount');
|
||||
await expect(amount).toHaveValue(randomNonZeroNumber.toString());
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('update-offer-values', async () => {
|
||||
const itemDesc = await page.getByTestId('itemDescription');
|
||||
await itemDesc.fill(updatedDescription);
|
||||
const amount = await page.getByTestId('inputOfferAmount');
|
||||
await amount.fill(String(randomNonZeroNumber + 1));
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('submit-updated-offer', async () => {
|
||||
await page.getByRole('button', { name: 'Sign & Send' }).click();
|
||||
await expect(page.getByText('That offer was recorded.')).toBeVisible();
|
||||
await page.locator('div[role="alert"] button > svg.fa-xmark').click();
|
||||
});
|
||||
|
||||
// STEP 9: Verify updated offer
|
||||
await perfCollector.measureUserAction('navigate-to-projects-final', async () => {
|
||||
await page.goto('./projects');
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('click-offers-tab-final', async () => {
|
||||
await page.getByRole('link', { name: 'Offers', exact: true }).click();
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('click-updated-offer', async () => {
|
||||
await page.locator('li').filter({ hasText: description }).locator('a').first().click();
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('verify-updated-offer', async () => {
|
||||
const newItemDesc = page.getByTestId('description');
|
||||
await expect(newItemDesc).toHaveText(updatedDescription);
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('click-edit-button-final', async () => {
|
||||
await page.getByTestId('editClaimButton').click();
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('verify-updated-amount', async () => {
|
||||
const newAmount = page.getByTestId('inputOfferAmount');
|
||||
await expect(newAmount).toHaveValue((randomNonZeroNumber + 1).toString());
|
||||
});
|
||||
|
||||
// STEP 10: Check home page for new offers
|
||||
await perfCollector.measureUserAction('navigate-to-home', async () => {
|
||||
await page.goto('./');
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('verify-new-offers-indicator', async () => {
|
||||
const offerNumElem = page.getByTestId('newOffersToUserProjectsActivityNumber');
|
||||
const offerNumText = await offerNumElem.textContent();
|
||||
if (offerNumText === null) {
|
||||
throw new Error('Expected Activity Number greater than 0 but got null.');
|
||||
} else if (offerNumText === '50+') {
|
||||
// we're OK
|
||||
} else if (parseInt(offerNumText) > 0) {
|
||||
// we're OK
|
||||
} else {
|
||||
throw new Error(`Expected Activity Number of greater than 0 but got ${offerNumText}.`);
|
||||
}
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('click-new-offers-number', async () => {
|
||||
const offerNumElem = page.getByTestId('newOffersToUserProjectsActivityNumber');
|
||||
await offerNumElem.click();
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('verify-new-offers-page', async () => {
|
||||
await expect(page.getByText('New Offers To Your Projects', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('expand-offers-section', async () => {
|
||||
await page.getByTestId('showOffersToUserProjects').locator('div > svg.fa-chevron-right').click();
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('verify-offer-in-list', async () => {
|
||||
await expect(page.getByText(description)).toBeVisible();
|
||||
});
|
||||
|
||||
// STEP 11: Attach and validate performance data
|
||||
const { webVitals, performanceReport, summary } = await attachPerformanceData(testInfo, perfCollector);
|
||||
const avgNavigationTime = perfCollector.navigationMetrics.reduce((sum, nav) =>
|
||||
sum + nav.metrics.loadComplete, 0) / perfCollector.navigationMetrics.length;
|
||||
assertPerformanceMetrics(webVitals, initialMetrics, avgNavigationTime);
|
||||
});
|
||||
|
||||
test('Affirm delivery of an offer', async ({ page }, testInfo) => {
|
||||
// STEP 1: Initialize the performance collector
|
||||
const perfCollector = await createPerformanceCollector(page);
|
||||
|
||||
// STEP 2: Import user and navigate to home
|
||||
await perfCollector.measureUserAction('import-user-account', async () => {
|
||||
await importUserFromAccount(page, "00");
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('navigate-to-home', async () => {
|
||||
await page.goto('./');
|
||||
});
|
||||
const initialMetrics = await perfCollector.collectNavigationMetrics('home-page-load');
|
||||
await testInfo.attach('initial-page-load-metrics', {
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(initialMetrics, null, 2)
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('close-onboarding', async () => {
|
||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
||||
});
|
||||
|
||||
// STEP 3: Check new offers indicator
|
||||
await perfCollector.measureUserAction('verify-new-offers-indicator', async () => {
|
||||
const offerNumElem = page.getByTestId('newOffersToUserProjectsActivityNumber');
|
||||
await expect(offerNumElem).toBeVisible();
|
||||
});
|
||||
|
||||
// STEP 4: Navigate to offers list
|
||||
await perfCollector.measureUserAction('click-new-offers-number', async () => {
|
||||
// Close any dialog overlays that might be blocking clicks
|
||||
await page.waitForTimeout(1000);
|
||||
const closeButtons = page.locator('button[aria-label*="close"], button[aria-label*="Close"], .dialog-overlay button, [role="dialog"] button');
|
||||
const count = await closeButtons.count();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
try {
|
||||
await closeButtons.nth(i).click({ timeout: 2000 });
|
||||
} catch (e) {
|
||||
// Ignore errors if button is not clickable
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for any animations to complete
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const offerNumElem = page.getByTestId('newOffersToUserProjectsActivityNumber');
|
||||
await offerNumElem.click();
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('click-offers-link', async () => {
|
||||
await page.getByTestId('showOffersToUserProjects').locator('a').click();
|
||||
});
|
||||
|
||||
// STEP 5: Affirm delivery
|
||||
await perfCollector.measureUserAction('select-first-offer', async () => {
|
||||
const firstItem = page.getByTestId('listRecentOffersToUserProjects').locator('li').first();
|
||||
await expect(firstItem).toBeVisible();
|
||||
await firstItem.locator('svg.fa-file-lines').click();
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('verify-claim-details', async () => {
|
||||
await expect(page.getByText('Verifiable Claim Details', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('click-affirm-delivery', async () => {
|
||||
await page.getByRole('button', { name: 'Affirm Delivery' }).click();
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('fill-delivery-details', async () => {
|
||||
await page.getByPlaceholder('What was given').fill('Whatever the offer says');
|
||||
await page.getByRole('spinbutton').fill('2');
|
||||
});
|
||||
|
||||
await perfCollector.measureUserAction('submit-delivery', async () => {
|
||||
await page.getByRole('button', { name: 'Sign & Send' }).click();
|
||||
await expect(page.getByText('That gift was recorded.')).toBeVisible();
|
||||
await page.locator('div[role="alert"] button > svg.fa-xmark').click();
|
||||
});
|
||||
|
||||
// STEP 6: Attach and validate performance data
|
||||
const { webVitals, performanceReport, summary } = await attachPerformanceData(testInfo, perfCollector);
|
||||
const avgNavigationTime = perfCollector.navigationMetrics.reduce((sum, nav) =>
|
||||
sum + nav.metrics.loadComplete, 0) / perfCollector.navigationMetrics.length;
|
||||
assertPerformanceMetrics(webVitals, initialMetrics, avgNavigationTime);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,94 +1,162 @@
|
||||
/**
|
||||
* This test covers a complete user flow in TimeSafari with integrated performance tracking.
|
||||
*
|
||||
* Focus areas:
|
||||
* - Performance monitoring for every major user step
|
||||
* - Multi-user flow using DID switching
|
||||
* - Offer creation, viewing, and state updates
|
||||
* - Validation of both behavior and responsiveness
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { switchToUser, getTestUserData, importUserFromAccount } from './testUtils';
|
||||
import { switchToUser, importUserFromAccount } from './testUtils';
|
||||
import {
|
||||
createPerformanceCollector,
|
||||
attachPerformanceData,
|
||||
assertPerformanceMetrics
|
||||
} from './performanceUtils';
|
||||
|
||||
test('New offers for another user', async ({ page }) => {
|
||||
await page.goto('./');
|
||||
test('New offers for another user', async ({ page }, testInfo) => {
|
||||
// STEP 1: Initialize the performance collector
|
||||
const perfCollector = await createPerformanceCollector(page);
|
||||
|
||||
// Get the auto-created DID from the HomeView
|
||||
await page.waitForLoadState('networkidle');
|
||||
// STEP 2: Navigate to home page and measure baseline performance
|
||||
await perfCollector.measureUserAction('initial-navigation', async () => {
|
||||
await page.goto('/');
|
||||
});
|
||||
const initialMetrics = await perfCollector.collectNavigationMetrics('home-page-load');
|
||||
await testInfo.attach('initial-page-load-metrics', {
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(initialMetrics, null, 2)
|
||||
});
|
||||
|
||||
// STEP 3: Extract the auto-created DID from the page
|
||||
// Wait for the page to be ready and the DID to be available
|
||||
await page.waitForSelector('#Content[data-active-did]', { timeout: 10000 });
|
||||
const autoCreatedDid = await page.getAttribute('#Content', 'data-active-did');
|
||||
|
||||
if (!autoCreatedDid) {
|
||||
throw new Error('Auto-created DID not found in HomeView');
|
||||
}
|
||||
if (!autoCreatedDid) throw new Error('Auto-created DID not found in HomeView');
|
||||
|
||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
||||
// STEP 4: Close onboarding dialog and confirm no new offers are visible
|
||||
await perfCollector.measureUserAction('close-onboarding', async () => {
|
||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
||||
});
|
||||
await expect(page.getByTestId('newDirectOffersActivityNumber')).toBeHidden();
|
||||
|
||||
// Become User Zero
|
||||
await importUserFromAccount(page, "00");
|
||||
// STEP 5: Switch to User Zero, who will create offers
|
||||
await perfCollector.measureUserAction('import-user-account', async () => {
|
||||
await importUserFromAccount(page, "00");
|
||||
});
|
||||
|
||||
// As User Zero, add the auto-created DID as a contact
|
||||
await page.goto('./contacts');
|
||||
await page.getByPlaceholder('URL or DID, Name, Public Key').fill(autoCreatedDid + ', A Friend');
|
||||
await expect(page.locator('button > svg.fa-plus')).toBeVisible();
|
||||
await page.locator('button > svg.fa-plus').click();
|
||||
await page.locator('div[role="alert"] button:has-text("No")').click(); // don't register
|
||||
await expect(page.locator('div[role="alert"] h4:has-text("Success")')).toBeVisible();
|
||||
await page.locator('div[role="alert"] button > svg.fa-xmark').click(); // dismiss info alert
|
||||
await expect(page.locator('div[role="alert"] button > svg.fa-xmark')).toBeHidden(); // ensure alert is gone
|
||||
// STEP 6: Navigate to contacts page
|
||||
await perfCollector.measureUserAction('navigate-to-contacts', async () => {
|
||||
await page.goto('/contacts');
|
||||
});
|
||||
await perfCollector.collectNavigationMetrics('contacts-page-load');
|
||||
|
||||
// show buttons to make offers directly to people
|
||||
await page.getByRole('button').filter({ hasText: /See Actions/i }).click();
|
||||
// STEP 7: Add the auto-created DID as a contact
|
||||
await perfCollector.measureUserAction('add-contact', async () => {
|
||||
await page.getByPlaceholder('URL or DID, Name, Public Key').fill(autoCreatedDid + ', A Friend');
|
||||
await page.locator('button > svg.fa-plus').click();
|
||||
await page.locator('div[role="alert"] button:has-text("No")').click();
|
||||
await expect(page.locator('div[role="alert"] span:has-text("Success")')).toBeVisible();
|
||||
await page.locator('div[role="alert"] button > svg.fa-xmark').click();
|
||||
await expect(page.locator('div[role="alert"] button > svg.fa-xmark')).toBeHidden();
|
||||
});
|
||||
|
||||
// make an offer directly to user 1
|
||||
// Generate a random string of 3 characters, skipping the "0." at the beginning
|
||||
// STEP 8: Show action buttons for making offers
|
||||
await perfCollector.measureUserAction('show-actions', async () => {
|
||||
await page.getByRole('button').filter({ hasText: /See Actions/i }).click();
|
||||
});
|
||||
|
||||
// STEP 9 & 10: Create two offers for the auto-created user
|
||||
const randomString1 = Math.random().toString(36).substring(2, 5);
|
||||
await page.getByTestId('offerButton').click();
|
||||
await page.getByTestId('inputDescription').fill(`help of ${randomString1} from #000`);
|
||||
await page.getByTestId('inputOfferAmount').locator('input').fill('1');
|
||||
await page.getByRole('button', { name: 'Sign & Send' }).click();
|
||||
await expect(page.getByText('That offer was recorded.')).toBeVisible();
|
||||
await page.locator('div[role="alert"] button > svg.fa-xmark').click(); // dismiss info alert
|
||||
await expect(page.locator('div[role="alert"] button > svg.fa-xmark')).toBeHidden(); // ensure alert is gone
|
||||
await perfCollector.measureUserAction('create-first-offer', async () => {
|
||||
await page.getByTestId('offerButton').click();
|
||||
await page.getByTestId('inputDescription').fill(`help of ${randomString1} from #000`);
|
||||
await page.getByTestId('inputOfferAmount').fill('1');
|
||||
await page.getByRole('button', { name: 'Sign & Send' }).click();
|
||||
await expect(page.getByText('That offer was recorded.')).toBeVisible();
|
||||
await page.locator('div[role="alert"]').filter({ hasText: 'That offer was recorded.' }).locator('button > svg.fa-xmark').click();
|
||||
// Wait for alert to be hidden to prevent multiple dialogs
|
||||
await expect(page.locator('div[role="alert"]').filter({ hasText: 'That offer was recorded.' })).toBeHidden();
|
||||
});
|
||||
|
||||
// Add delay between offers to prevent performance issues
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// make another offer to user 1
|
||||
const randomString2 = Math.random().toString(36).substring(2, 5);
|
||||
await page.getByTestId('offerButton').click();
|
||||
await page.getByTestId('inputDescription').fill(`help of ${randomString2} from #000`);
|
||||
await page.getByTestId('inputOfferAmount').locator('input').fill('3');
|
||||
await page.getByRole('button', { name: 'Sign & Send' }).click();
|
||||
await expect(page.getByText('That offer was recorded.')).toBeVisible();
|
||||
await page.locator('div[role="alert"] button > svg.fa-xmark').click(); // dismiss info alert
|
||||
await expect(page.locator('div[role="alert"] button > svg.fa-xmark')).toBeHidden(); // ensure alert is gone
|
||||
await perfCollector.measureUserAction('create-second-offer', async () => {
|
||||
await page.getByTestId('offerButton').click();
|
||||
await page.getByTestId('inputDescription').fill(`help of ${randomString2} from #000`);
|
||||
await page.getByTestId('inputOfferAmount').fill('3');
|
||||
await page.getByRole('button', { name: 'Sign & Send' }).click();
|
||||
await expect(page.getByText('That offer was recorded.')).toBeVisible();
|
||||
await page.locator('div[role="alert"]').filter({ hasText: 'That offer was recorded.' }).locator('button > svg.fa-xmark').click();
|
||||
// Wait for alert to be hidden to prevent multiple dialogs
|
||||
await expect(page.locator('div[role="alert"]').filter({ hasText: 'That offer was recorded.' })).toBeHidden();
|
||||
});
|
||||
|
||||
// Switch back to the auto-created DID (the "another user") to see the offers
|
||||
await switchToUser(page, autoCreatedDid);
|
||||
await page.goto('./');
|
||||
// STEP 11: Switch back to the auto-created DID
|
||||
await perfCollector.measureUserAction('switch-user', async () => {
|
||||
await switchToUser(page, autoCreatedDid);
|
||||
});
|
||||
|
||||
// STEP 12: Navigate back home as the auto-created user
|
||||
await perfCollector.measureUserAction('navigate-home-as-other-user', async () => {
|
||||
await page.goto('/');
|
||||
});
|
||||
await perfCollector.collectNavigationMetrics('home-return-load');
|
||||
|
||||
// STEP 13: Confirm 2 new offers are visible
|
||||
let offerNumElem = page.getByTestId('newDirectOffersActivityNumber');
|
||||
await expect(offerNumElem).toHaveText('2');
|
||||
|
||||
// click on the number of new offers to go to the list page
|
||||
await offerNumElem.click();
|
||||
|
||||
// STEP 14 & 15: View and expand the offers list
|
||||
await perfCollector.measureUserAction('view-offers-list', async () => {
|
||||
await offerNumElem.click();
|
||||
});
|
||||
await expect(page.getByText('New Offers To You', { exact: true })).toBeVisible();
|
||||
await page.getByTestId('showOffersToUser').locator('div > svg.fa-chevron-right').click();
|
||||
// note that they show in reverse chronologicalorder
|
||||
await perfCollector.measureUserAction('expand-offers', async () => {
|
||||
await page.getByTestId('showOffersToUser').locator('div > svg.fa-chevron-right').click();
|
||||
});
|
||||
|
||||
// STEP 16: Validate both offers are displayed
|
||||
await expect(page.getByText(`help of ${randomString2} from #000`)).toBeVisible();
|
||||
await expect(page.getByText(`help of ${randomString1} from #000`)).toBeVisible();
|
||||
|
||||
// click on the latest offer to keep it as "unread"
|
||||
await page.hover(`li:has-text("help of ${randomString2} from #000")`);
|
||||
// await page.locator('li').filter({ hasText: `help of ${randomString2} from #000` }).click();
|
||||
// await page.locator('div').filter({ hasText: /keep all above/ }).click();
|
||||
// now find the "Click to keep all above as new offers" after that list item and click it
|
||||
const liElem = page.locator('li').filter({ hasText: `help of ${randomString2} from #000` });
|
||||
await liElem.hover();
|
||||
const keepAboveAsNew = await liElem.locator('div').filter({ hasText: /keep all above/ });
|
||||
// STEP 17: Mark one offer as read
|
||||
await perfCollector.measureUserAction('mark-offers-as-read', async () => {
|
||||
const liElem = page.locator('li').filter({ hasText: `help of ${randomString2} from #000` });
|
||||
// Hover over the li element to make the "keep all above" text visible
|
||||
await liElem.hover();
|
||||
await liElem.locator('div').filter({ hasText: /keep all above/ }).click();
|
||||
});
|
||||
|
||||
await keepAboveAsNew.click();
|
||||
|
||||
// now see that only one offer is shown as new
|
||||
await page.goto('./');
|
||||
// STEP 18 & 19: Return home and check that the count has dropped to 1
|
||||
await perfCollector.measureUserAction('final-home-navigation', async () => {
|
||||
await page.goto('/');
|
||||
});
|
||||
await perfCollector.collectNavigationMetrics('final-home-load');
|
||||
offerNumElem = page.getByTestId('newDirectOffersActivityNumber');
|
||||
await expect(offerNumElem).toHaveText('1');
|
||||
await offerNumElem.click();
|
||||
await expect(page.getByText('New Offer To You', { exact: true })).toBeVisible();
|
||||
await page.getByTestId('showOffersToUser').locator('div > svg.fa-chevron-right').click();
|
||||
|
||||
// now see that no offers are shown as new
|
||||
await page.goto('./');
|
||||
// wait until the list with ID listLatestActivity has at least one visible item
|
||||
await page.locator('#listLatestActivity li').first().waitFor({ state: 'visible' });
|
||||
// STEP 20: Open the offers list again to confirm the remaining offer
|
||||
await perfCollector.measureUserAction('final-offer-check', async () => {
|
||||
await offerNumElem.click();
|
||||
await expect(page.getByText('New Offer To You', { exact: true })).toBeVisible();
|
||||
await page.getByTestId('showOffersToUser').locator('div > svg.fa-chevron-right').click();
|
||||
});
|
||||
|
||||
// STEP 21 & 22: Final verification that the UI reflects the read/unread state correctly
|
||||
await perfCollector.measureUserAction('final-verification', async () => {
|
||||
await page.goto('/');
|
||||
await page.locator('#listLatestActivity li').first().waitFor({ state: 'visible' });
|
||||
});
|
||||
await expect(page.getByTestId('newDirectOffersActivityNumber')).toBeHidden();
|
||||
|
||||
// STEP 23: Attach and validate performance data
|
||||
const { webVitals, performanceReport, summary } = await attachPerformanceData(testInfo, perfCollector);
|
||||
const avgNavigationTime = perfCollector.navigationMetrics.reduce((sum, nav) =>
|
||||
sum + nav.metrics.loadComplete, 0) / perfCollector.navigationMetrics.length;
|
||||
assertPerformanceMetrics(webVitals, initialMetrics, avgNavigationTime);
|
||||
});
|
||||
|
||||
343
test-playwright/performanceUtils.ts
Normal file
343
test-playwright/performanceUtils.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import { Page, TestInfo, expect } from '@playwright/test';
|
||||
|
||||
// Performance metrics collection utilities
|
||||
export class PerformanceCollector {
|
||||
private page: Page;
|
||||
public metrics: any;
|
||||
public navigationMetrics: any[];
|
||||
private cdpSession: any;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.metrics = {};
|
||||
this.navigationMetrics = [];
|
||||
this.cdpSession = null;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
// Initialize CDP session for detailed metrics (only in Chromium)
|
||||
try {
|
||||
this.cdpSession = await this.page.context().newCDPSession(this.page);
|
||||
await this.cdpSession.send('Performance.enable');
|
||||
} catch (error) {
|
||||
// CDP not available in Firefox, continue without it
|
||||
// Note: This will be captured in test attachments instead of console.log
|
||||
}
|
||||
|
||||
// Track network requests
|
||||
this.page.on('response', response => {
|
||||
if (!this.metrics.networkRequests) this.metrics.networkRequests = [];
|
||||
this.metrics.networkRequests.push({
|
||||
url: response.url(),
|
||||
status: response.status(),
|
||||
timing: null, // response.timing() is not available in Playwright
|
||||
size: response.headers()['content-length'] || 0
|
||||
});
|
||||
});
|
||||
|
||||
// Inject performance monitoring script
|
||||
await this.page.addInitScript(() => {
|
||||
(window as any).performanceMarks = {};
|
||||
(window as any).markStart = (name: string) => {
|
||||
(window as any).performanceMarks[name] = performance.now();
|
||||
};
|
||||
(window as any).markEnd = (name: string) => {
|
||||
if ((window as any).performanceMarks[name]) {
|
||||
const duration = performance.now() - (window as any).performanceMarks[name];
|
||||
// Note: Browser console logs are kept for debugging performance in browser
|
||||
console.log(`Performance: ${name} took ${duration.toFixed(2)}ms`);
|
||||
return duration;
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async ensurePerformanceScript() {
|
||||
// Ensure the performance script is available in the current page context
|
||||
await this.page.evaluate(() => {
|
||||
if (!(window as any).performanceMarks) {
|
||||
(window as any).performanceMarks = {};
|
||||
}
|
||||
if (!(window as any).markStart) {
|
||||
(window as any).markStart = (name: string) => {
|
||||
(window as any).performanceMarks[name] = performance.now();
|
||||
};
|
||||
}
|
||||
if (!(window as any).markEnd) {
|
||||
(window as any).markEnd = (name: string) => {
|
||||
if ((window as any).performanceMarks[name]) {
|
||||
const duration = performance.now() - (window as any).performanceMarks[name];
|
||||
console.log(`Performance: ${name} took ${duration.toFixed(2)}ms`);
|
||||
return duration;
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async collectNavigationMetrics(label = 'navigation') {
|
||||
const startTime = performance.now();
|
||||
|
||||
const metrics = await this.page.evaluate(() => {
|
||||
const timing = (performance as any).timing;
|
||||
const navigation = performance.getEntriesByType('navigation')[0] as any;
|
||||
|
||||
// Firefox-compatible performance metrics
|
||||
const paintEntries = performance.getEntriesByType('paint');
|
||||
const firstPaint = paintEntries.find((entry: any) => entry.name === 'first-paint')?.startTime || 0;
|
||||
const firstContentfulPaint = paintEntries.find((entry: any) => entry.name === 'first-contentful-paint')?.startTime || 0;
|
||||
|
||||
// Resource timing (works in both browsers)
|
||||
const resourceEntries = performance.getEntriesByType('resource');
|
||||
const resourceTiming = resourceEntries.map((entry: any) => ({
|
||||
name: entry.name,
|
||||
duration: entry.duration,
|
||||
transferSize: entry.transferSize || 0,
|
||||
decodedBodySize: entry.decodedBodySize || 0
|
||||
}));
|
||||
|
||||
return {
|
||||
// Core timing metrics
|
||||
domContentLoaded: timing.domContentLoadedEventEnd - timing.navigationStart,
|
||||
loadComplete: timing.loadEventEnd - timing.navigationStart,
|
||||
firstPaint: firstPaint,
|
||||
firstContentfulPaint: firstContentfulPaint,
|
||||
|
||||
// Navigation API metrics (if available)
|
||||
dnsLookup: navigation ? navigation.domainLookupEnd - navigation.domainLookupStart : 0,
|
||||
tcpConnect: navigation ? navigation.connectEnd - navigation.connectStart : 0,
|
||||
serverResponse: navigation ? navigation.responseEnd - navigation.requestStart : 0,
|
||||
|
||||
// Resource counts and timing
|
||||
resourceCount: resourceEntries.length,
|
||||
resourceTiming: resourceTiming,
|
||||
|
||||
// Memory usage (Chrome only, null in Firefox)
|
||||
memoryUsage: (performance as any).memory ? {
|
||||
used: (performance as any).memory.usedJSHeapSize,
|
||||
total: (performance as any).memory.totalJSHeapSize,
|
||||
limit: (performance as any).memory.jsHeapSizeLimit
|
||||
} : null,
|
||||
|
||||
// Firefox-specific: Performance marks and measures
|
||||
performanceMarks: performance.getEntriesByType('mark').map((mark: any) => ({
|
||||
name: mark.name,
|
||||
startTime: mark.startTime
|
||||
})),
|
||||
|
||||
// Browser detection
|
||||
browser: navigator.userAgent.includes('Firefox') ? 'firefox' : 'chrome'
|
||||
};
|
||||
});
|
||||
|
||||
const collectTime = performance.now() - startTime;
|
||||
|
||||
this.navigationMetrics.push({
|
||||
label,
|
||||
timestamp: new Date().toISOString(),
|
||||
metrics,
|
||||
collectionTime: collectTime
|
||||
});
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
async collectWebVitals() {
|
||||
return await this.page.evaluate(() => {
|
||||
return new Promise((resolve) => {
|
||||
const vitals: any = {};
|
||||
let pendingVitals = 3; // LCP, FID, CLS
|
||||
|
||||
const checkComplete = () => {
|
||||
pendingVitals--;
|
||||
if (pendingVitals <= 0) {
|
||||
setTimeout(() => resolve(vitals), 100);
|
||||
}
|
||||
};
|
||||
|
||||
// Largest Contentful Paint
|
||||
new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries();
|
||||
if (entries.length > 0) {
|
||||
vitals.lcp = entries[entries.length - 1].startTime;
|
||||
}
|
||||
checkComplete();
|
||||
}).observe({ entryTypes: ['largest-contentful-paint'] });
|
||||
|
||||
// First Input Delay
|
||||
new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries();
|
||||
if (entries.length > 0) {
|
||||
vitals.fid = (entries[0] as any).processingStart - entries[0].startTime;
|
||||
}
|
||||
checkComplete();
|
||||
}).observe({ entryTypes: ['first-input'] });
|
||||
|
||||
// Cumulative Layout Shift
|
||||
let clsValue = 0;
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (!(entry as any).hadRecentInput) {
|
||||
clsValue += (entry as any).value;
|
||||
}
|
||||
}
|
||||
vitals.cls = clsValue;
|
||||
checkComplete();
|
||||
}).observe({ entryTypes: ['layout-shift'] });
|
||||
|
||||
// Fallback timeout
|
||||
setTimeout(() => resolve(vitals), 3000);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async measureUserAction(actionName: string, actionFn: () => Promise<void>) {
|
||||
const startTime = performance.now();
|
||||
|
||||
// Ensure performance script is available
|
||||
await this.ensurePerformanceScript();
|
||||
|
||||
// Mark start in browser
|
||||
await this.page.evaluate((name: string) => {
|
||||
(window as any).markStart(name);
|
||||
}, actionName);
|
||||
|
||||
// Execute the action
|
||||
await actionFn();
|
||||
|
||||
// Mark end and collect metrics
|
||||
const browserDuration = await this.page.evaluate((name: string) => {
|
||||
return (window as any).markEnd(name);
|
||||
}, actionName);
|
||||
|
||||
const totalDuration = performance.now() - startTime;
|
||||
|
||||
if (!this.metrics.userActions) this.metrics.userActions = [];
|
||||
this.metrics.userActions.push({
|
||||
action: actionName,
|
||||
browserDuration: browserDuration,
|
||||
totalDuration: totalDuration,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return { browserDuration, totalDuration };
|
||||
}
|
||||
|
||||
async getDetailedMetrics() {
|
||||
if (this.cdpSession) {
|
||||
const cdpMetrics = await this.cdpSession.send('Performance.getMetrics');
|
||||
this.metrics.cdpMetrics = cdpMetrics.metrics;
|
||||
}
|
||||
return this.metrics;
|
||||
}
|
||||
|
||||
generateReport() {
|
||||
const report = {
|
||||
testSummary: {
|
||||
totalNavigations: this.navigationMetrics.length,
|
||||
totalUserActions: this.metrics.userActions?.length || 0,
|
||||
totalNetworkRequests: this.metrics.networkRequests?.length || 0
|
||||
},
|
||||
navigationMetrics: this.navigationMetrics,
|
||||
userActionMetrics: this.metrics.userActions || [],
|
||||
networkSummary: this.metrics.networkRequests ? {
|
||||
totalRequests: this.metrics.networkRequests.length,
|
||||
averageResponseTime: 0, // timing not available in Playwright
|
||||
errorCount: this.metrics.networkRequests.filter((req: any) => req.status >= 400).length
|
||||
} : null
|
||||
};
|
||||
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience function to create and initialize a performance collector
|
||||
export async function createPerformanceCollector(page: Page): Promise<PerformanceCollector> {
|
||||
const collector = new PerformanceCollector(page);
|
||||
await collector.initialize();
|
||||
return collector;
|
||||
}
|
||||
|
||||
// Helper function to attach performance data to test reports
|
||||
export async function attachPerformanceData(
|
||||
testInfo: TestInfo,
|
||||
collector: PerformanceCollector,
|
||||
additionalData?: Record<string, any>
|
||||
) {
|
||||
// Collect Web Vitals
|
||||
const webVitals = await collector.collectWebVitals() as any;
|
||||
|
||||
// Attach Web Vitals to test report
|
||||
await testInfo.attach('web-vitals', {
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(webVitals, null, 2)
|
||||
});
|
||||
|
||||
// Generate final performance report
|
||||
const performanceReport = collector.generateReport();
|
||||
|
||||
// Attach performance report to test report
|
||||
await testInfo.attach('performance-report', {
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(performanceReport, null, 2)
|
||||
});
|
||||
|
||||
// Attach summary metrics to test report
|
||||
const avgNavigationTime = collector.navigationMetrics.reduce((sum, nav) =>
|
||||
sum + nav.metrics.loadComplete, 0) / collector.navigationMetrics.length;
|
||||
|
||||
const summary = {
|
||||
averageNavigationTime: avgNavigationTime.toFixed(2),
|
||||
totalTestDuration: collector.metrics.userActions?.reduce((sum: number, action: any) => sum + action.totalDuration, 0).toFixed(2),
|
||||
slowestAction: collector.metrics.userActions?.reduce((slowest: any, action: any) =>
|
||||
action.totalDuration > (slowest?.totalDuration || 0) ? action : slowest, null)?.action || 'N/A',
|
||||
networkRequests: performanceReport.testSummary.totalNetworkRequests,
|
||||
...additionalData
|
||||
};
|
||||
|
||||
await testInfo.attach('performance-summary', {
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(summary, null, 2)
|
||||
});
|
||||
|
||||
return { webVitals, performanceReport, summary };
|
||||
}
|
||||
|
||||
// Helper function to run performance assertions
|
||||
export function assertPerformanceMetrics(
|
||||
webVitals: any,
|
||||
initialMetrics: any,
|
||||
avgNavigationTime: number
|
||||
) {
|
||||
// Performance assertions (adjust thresholds as needed)
|
||||
expect(avgNavigationTime).toBeLessThan(5000); // Average navigation under 5s
|
||||
expect(initialMetrics.loadComplete).toBeLessThan(8000); // Initial load under 8s
|
||||
|
||||
if (webVitals.lcp) {
|
||||
expect(webVitals.lcp).toBeLessThan(2500); // LCP under 2.5s (good threshold)
|
||||
}
|
||||
|
||||
if (webVitals.fid !== undefined) {
|
||||
expect(webVitals.fid).toBeLessThan(100); // FID under 100ms (good threshold)
|
||||
}
|
||||
|
||||
if (webVitals.cls !== undefined) {
|
||||
expect(webVitals.cls).toBeLessThan(0.1); // CLS under 0.1 (good threshold)
|
||||
}
|
||||
}
|
||||
|
||||
// Simple performance wrapper for quick tests
|
||||
export async function withPerformanceTracking<T>(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
testName: string,
|
||||
testFn: (collector: PerformanceCollector) => Promise<T>
|
||||
): Promise<T> {
|
||||
const collector = await createPerformanceCollector(page);
|
||||
|
||||
const result = await testFn(collector);
|
||||
|
||||
await attachPerformanceData(testInfo, collector, { testName });
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -236,6 +236,77 @@ export function getOSSpecificConfig() {
|
||||
export function isResourceIntensiveTest(testPath: string): boolean {
|
||||
return (
|
||||
testPath.includes("35-record-gift-from-image-share") ||
|
||||
testPath.includes("40-add-contact")
|
||||
testPath.includes("40-add-contact") ||
|
||||
testPath.includes("45-contact-import")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create a test JWT for contact import testing
|
||||
* @param payload - The payload to encode in the JWT
|
||||
* @returns A base64-encoded JWT string (simplified for testing)
|
||||
*/
|
||||
export function createTestJwt(payload: any): string {
|
||||
const header = { alg: 'HS256', typ: 'JWT' };
|
||||
const encodedHeader = btoa(JSON.stringify(header));
|
||||
const encodedPayload = btoa(JSON.stringify(payload));
|
||||
const signature = 'test-signature'; // Simplified for testing
|
||||
return `${encodedHeader}.${encodedPayload}.${signature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to clean up test contacts
|
||||
* @param page - Playwright page object
|
||||
* @param contactNames - Array of contact names to delete
|
||||
*/
|
||||
export async function cleanupTestContacts(page: Page, contactNames: string[]): Promise<void> {
|
||||
await page.goto('./contacts');
|
||||
|
||||
// Delete test contacts if they exist
|
||||
for (const contactName of contactNames) {
|
||||
const contactItem = page.locator(`li[data-testid="contactListItem"] h2:has-text("${contactName}")`);
|
||||
if (await contactItem.isVisible()) {
|
||||
await contactItem.click();
|
||||
await page.locator('button > svg.fa-trash-can').click();
|
||||
await page.locator('div[role="alert"] button:has-text("Yes")').click();
|
||||
await expect(page.locator('div[role="alert"] button:has-text("Yes")')).toBeHidden();
|
||||
await page.locator('div[role="alert"] button > svg.fa-xmark').click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to add a contact for testing
|
||||
* @param page - Playwright page object
|
||||
* @param did - The DID of the contact
|
||||
* @param name - The name of the contact
|
||||
* @param publicKey - Optional public key
|
||||
*/
|
||||
export async function addTestContact(page: Page, did: string, name: string, publicKey?: string): Promise<void> {
|
||||
await page.goto('./contacts');
|
||||
const contactData = publicKey ? `${did}, ${name}, ${publicKey}` : `${did}, ${name}`;
|
||||
await page.getByPlaceholder('URL or DID, Name, Public Key').fill(contactData);
|
||||
await page.locator('button > svg.fa-plus').click();
|
||||
await expect(page.locator('div[role="alert"] span:has-text("Success")')).toBeVisible();
|
||||
await page.locator('div[role="alert"] button > svg.fa-xmark').click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to verify contact exists in the contacts list
|
||||
* @param page - Playwright page object
|
||||
* @param name - The name of the contact to verify
|
||||
*/
|
||||
export async function verifyContactExists(page: Page, name: string): Promise<void> {
|
||||
await page.goto('./contacts');
|
||||
await expect(page.locator(`li[data-testid="contactListItem"] h2:has-text("${name}")`)).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to verify contact count in the contacts list
|
||||
* @param page - Playwright page object
|
||||
* @param expectedCount - The expected number of contacts
|
||||
*/
|
||||
export async function verifyContactCount(page: Page, expectedCount: number): Promise<void> {
|
||||
await page.goto('./contacts');
|
||||
await expect(page.getByTestId('contactListItem')).toHaveCount(expectedCount);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user