Files
DashBoard/tests/stress/test_frontend_stress.py
egg be22571421 feat(wip): preserve filters between Overview and Detail with thundering-herd fix
URL is now single source of truth for filter state (workorder, lotid,
package, type, status) across WIP Overview and Detail pages. Drill-down
carries all filters + status; back button dynamically reflects Detail
changes. Backend Detail API now supports pj_type filter parameter.

Harden concurrency: add pagehide abort for MPA navigation, double-check
locking on Redis JSON parse and snapshot build to prevent thread pool
saturation during rapid page switching. Fix watchdog setsid and PID
discovery. Fix test_realtime_equipment_cache RUNCARDLOTID field mismatch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 07:22:48 +08:00

484 lines
18 KiB
Python

# -*- coding: utf-8 -*-
"""Frontend stress tests using Playwright.
Tests frontend stability under high-frequency operations:
- Toast notification system under rapid fire
- MesApi client under rapid requests
- AbortController behavior
- Page navigation stress
Run with: pytest tests/stress/test_frontend_stress.py -v -s
"""
import pytest
import time
import re
import requests
from urllib.parse import quote
from playwright.sync_api import Page, expect
@pytest.fixture(scope="session")
def app_server() -> str:
"""Get the base URL for stress testing."""
import os
return os.environ.get('STRESS_TEST_URL', 'http://127.0.0.1:8080')
@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
"""Configure browser context for stress tests."""
return {
**browser_context_args,
"viewport": {"width": 1280, "height": 720},
"locale": "zh-TW",
}
def load_page_with_js(page: Page, url: str, timeout: int = 60000):
"""Load page and wait for JS to initialize."""
page.goto(url, wait_until='domcontentloaded', timeout=timeout)
page.wait_for_timeout(1000) # Allow JS initialization
@pytest.mark.stress
class TestToastStress:
"""Stress tests for Toast notification system."""
def test_rapid_toast_creation(self, page: Page, app_server: str):
"""Test Toast system under rapid creation - should enforce max limit."""
load_page_with_js(page, f"{app_server}/tables")
# Create 50 toasts rapidly
start_time = time.time()
for i in range(50):
page.evaluate(f"Toast.info('Rapid toast {i}')")
creation_time = time.time() - start_time
print(f"\n Created 50 toasts in {creation_time:.3f}s")
page.wait_for_timeout(500)
# Should only have max 5 toasts visible
toast_count = page.locator('.mes-toast').count()
assert toast_count <= 5, f"Toast count {toast_count} exceeds max limit of 5"
print(f" Toast count enforced: {toast_count} (max 5)")
def test_toast_type_cycling(self, page: Page, app_server: str):
"""Test rapid cycling through all toast types - system remains stable."""
load_page_with_js(page, f"{app_server}/tables")
toast_types = ['info', 'success', 'warning', 'error']
start_time = time.time()
for i in range(100):
toast_type = toast_types[i % len(toast_types)]
page.evaluate(f"Toast.{toast_type}('Type cycle {i}')")
cycle_time = time.time() - start_time
print(f"\n Cycled 100 toasts in {cycle_time:.3f}s")
# Wait for animations to complete
page.wait_for_timeout(1000)
# Dismiss all and verify system can recover
page.evaluate("Toast.dismissAll()")
page.wait_for_timeout(500)
toast_count = page.locator('.mes-toast').count()
assert toast_count <= 5, f"Toast overflow after dismissAll: {toast_count}"
print(f" System stable after cleanup, toast count: {toast_count}")
def test_toast_dismiss_stress(self, page: Page, app_server: str):
"""Test rapid toast creation and dismissal."""
load_page_with_js(page, f"{app_server}/tables")
start_time = time.time()
# Create and immediately dismiss
for i in range(30):
toast_id = page.evaluate(f"Toast.info('Dismiss test {i}')")
page.evaluate(f"Toast.dismiss({toast_id})")
dismiss_time = time.time() - start_time
print(f"\n Created and dismissed 30 toasts in {dismiss_time:.3f}s")
page.wait_for_timeout(500)
# Should have no or few toasts
toast_count = page.locator('.mes-toast').count()
assert toast_count <= 2, f"Undismissed toasts remain: {toast_count}"
print(f" Remaining toasts: {toast_count}")
def test_loading_toast_stress(self, page: Page, app_server: str):
"""Test loading toasts can be created and properly dismissed."""
load_page_with_js(page, f"{app_server}/tables")
toast_ids = []
# Create 10 loading toasts
for i in range(10):
toast_id = page.evaluate(f"Toast.loading('Loading {i}...')")
toast_ids.append(toast_id)
page.wait_for_timeout(200)
# Loading toasts are created
loading_count = page.locator('.mes-toast-loading').count()
print(f"\n Created {len(toast_ids)} loading toasts, visible: {loading_count}")
# Dismiss all using dismissAll
page.evaluate("Toast.dismissAll()")
page.wait_for_timeout(500)
# All should be gone after dismissAll
loading_count = page.locator('.mes-toast-loading').count()
assert loading_count == 0, f"Loading toasts not dismissed: {loading_count}"
print(f" Loading toast dismiss test passed")
@pytest.mark.stress
class TestMesApiStress:
"""Stress tests for MesApi client."""
def test_rapid_api_requests(self, page: Page, app_server: str):
"""Test MesApi under rapid sequential requests."""
load_page_with_js(page, f"{app_server}/tables")
# Make 20 rapid API requests
results = page.evaluate("""
async () => {
const results = [];
const startTime = Date.now();
for (let i = 0; i < 20; i++) {
try {
const response = await MesApi.get('/api/wip/meta/workcenters');
results.push({ success: true, status: response?.status || 'ok' });
} catch (e) {
results.push({ success: false, error: e.message });
}
}
return {
results,
duration: Date.now() - startTime,
successCount: results.filter(r => r.success).length
};
}
""")
print(f"\n 20 requests in {results['duration']}ms")
print(f" Success: {results['successCount']}/20")
assert results['successCount'] >= 15, f"Too many failures: {20 - results['successCount']}"
def test_concurrent_api_requests(self, page: Page, app_server: str):
"""Test MesApi with concurrent requests using Promise.all."""
load_page_with_js(page, f"{app_server}/tables")
# Make 10 concurrent requests
results = page.evaluate("""
async () => {
const endpoints = [
'/api/wip/overview/summary',
'/api/wip/overview/matrix',
'/api/wip/meta/workcenters',
'/api/wip/meta/packages',
];
const startTime = Date.now();
const promises = [];
// 2 requests per endpoint = 8 total concurrent
for (const endpoint of endpoints) {
promises.push(MesApi.get(endpoint).catch(e => ({ error: e.message })));
promises.push(MesApi.get(endpoint).catch(e => ({ error: e.message })));
}
const results = await Promise.all(promises);
const successCount = results.filter(r => !r.error).length;
return {
duration: Date.now() - startTime,
total: results.length,
successCount
};
}
""")
print(f"\n {results['total']} concurrent requests in {results['duration']}ms")
print(f" Success: {results['successCount']}/{results['total']}")
assert results['successCount'] >= 6, f"Too many concurrent failures"
def test_abort_controller_stress(self, page: Page, app_server: str):
"""Test AbortController under rapid request cancellation."""
load_page_with_js(page, f"{app_server}/tables")
# Start requests and cancel them rapidly
results = page.evaluate("""
async () => {
const results = { started: 0, aborted: 0, completed: 0, errors: 0 };
for (let i = 0; i < 10; i++) {
results.started++;
const controller = new AbortController();
const request = fetch('/api/wip/overview/summary', {
signal: controller.signal
}).then(() => {
results.completed++;
}).catch(e => {
if (e.name === 'AbortError') {
results.aborted++;
} else {
results.errors++;
}
});
// Cancel after 50ms
setTimeout(() => controller.abort(), 50);
await new Promise(resolve => setTimeout(resolve, 100));
}
return results;
}
""")
print(f"\n Started: {results['started']}")
print(f" Aborted: {results['aborted']}")
print(f" Completed: {results['completed']}")
print(f" Errors: {results['errors']}")
# Most should either abort or complete
total_resolved = results['aborted'] + results['completed']
assert total_resolved >= 5, f"Too many unresolved requests"
@pytest.mark.stress
class TestPageNavigationStress:
"""Stress tests for rapid page navigation."""
def test_rapid_tab_switching(self, page: Page, app_server: str):
"""Test rapid tab switching in portal."""
page.goto(app_server, wait_until='domcontentloaded', timeout=30000)
page.wait_for_timeout(500)
# Only use released pages that are visible without admin login
tabs = [
'.tab:has-text("WIP 即時概況")',
'.tab:has-text("設備即時概況")',
'.tab:has-text("設備歷史績效")',
'.tab:has-text("設備維修查詢")',
]
start_time = time.time()
# Rapidly switch tabs 20 times
for i in range(20):
tab = tabs[i % len(tabs)]
page.locator(tab).click()
page.wait_for_timeout(50)
switch_time = time.time() - start_time
print(f"\n 20 tab switches in {switch_time:.3f}s")
# Page should still be responsive
expect(page.locator('h1')).to_contain_text('MES 報表入口')
print(" Portal remained stable")
def test_portal_iframe_stress(self, page: Page, app_server: str):
"""Test portal remains responsive with iframe loading."""
page.goto(app_server, wait_until='domcontentloaded', timeout=30000)
page.wait_for_timeout(500)
# Switch through released tabs (dev tabs hidden without admin login)
tabs = [
'WIP 即時概況',
'設備即時概況',
'設備歷史績效',
'設備維修查詢',
]
for tab_name in tabs:
page.locator(f'.tab:has-text("{tab_name}")').click()
page.wait_for_timeout(200)
# Verify tab is active
tab = page.locator(f'.tab:has-text("{tab_name}")')
expect(tab).to_have_class(re.compile(r'active'))
print(f"\n All {len(tabs)} tabs clickable and responsive")
@pytest.mark.stress
class TestWipHoldPageStress:
"""Stress tests focused on WIP Overview / WIP Detail / Hold Detail pages."""
def _pick_workcenter(self, app_server: str) -> str:
"""Get one available workcenter for WIP detail tests."""
try:
response = requests.get(f"{app_server}/api/wip/meta/workcenters", timeout=10)
if response.status_code != 200:
return "TMTT"
payload = response.json()
items = payload.get("data") or []
if not items:
return "TMTT"
return str(items[0].get("name") or "TMTT")
except Exception:
return "TMTT"
def _pick_reason(self, app_server: str) -> str:
"""Get one hold reason for hold-detail tests."""
try:
response = requests.get(f"{app_server}/api/wip/overview/hold", timeout=10)
if response.status_code != 200:
return "YieldLimit"
payload = response.json()
items = (payload.get("data") or {}).get("items") or []
if not items:
return "YieldLimit"
return str(items[0].get("reason") or "YieldLimit")
except Exception:
return "YieldLimit"
def test_rapid_navigation_across_wip_and_hold_pages(self, page: Page, app_server: str):
"""Rapid page switching should keep pages responsive and error-free."""
workcenter = self._pick_workcenter(app_server)
reason = self._pick_reason(app_server)
urls = [
f"{app_server}/wip-overview",
f"{app_server}/wip-overview?type=PJA3460&status=queue",
f"{app_server}/wip-detail?workcenter={quote(workcenter)}&type=PJA3460&status=queue",
f"{app_server}/hold-detail?reason={quote(reason)}",
]
js_errors = []
page.on("pageerror", lambda error: js_errors.append(str(error)))
start_time = time.time()
for i in range(16):
page.goto(urls[i % len(urls)], wait_until='domcontentloaded', timeout=60000)
expect(page.locator("body")).to_be_visible()
page.wait_for_timeout(150)
elapsed = time.time() - start_time
print(f"\n Rapid navigation across 3 pages completed in {elapsed:.2f}s")
assert len(js_errors) == 0, f"JavaScript errors detected: {js_errors[:3]}"
def test_wip_and_hold_api_burst_from_browser(self, page: Page, app_server: str):
"""Browser-side API burst should still return mostly successful responses."""
load_page_with_js(page, f"{app_server}/wip-overview")
result = page.evaluate("""
async () => {
const safeJson = async (resp) => {
try {
return await resp.json();
} catch (_) {
return null;
}
};
const wcResp = await fetch('/api/wip/meta/workcenters');
const wcPayload = await safeJson(wcResp) || {};
const workcenter = (wcPayload.data && wcPayload.data[0] && wcPayload.data[0].name) || 'TMTT';
const holdResp = await fetch('/api/wip/overview/hold');
const holdPayload = await safeJson(holdResp) || {};
const holdItems = (holdPayload.data && holdPayload.data.items) || [];
const reason = (holdItems[0] && holdItems[0].reason) || 'YieldLimit';
const endpoints = [
'/api/wip/overview/summary',
'/api/wip/overview/matrix',
'/api/wip/overview/hold',
`/api/wip/detail/${encodeURIComponent(workcenter)}?page=1&page_size=100`,
`/api/wip/hold-detail/lots?reason=${encodeURIComponent(reason)}&page=1&per_page=50`,
];
let total = 0;
let success = 0;
let failures = 0;
for (let round = 0; round < 5; round++) {
const responses = await Promise.all(
endpoints.map((endpoint) =>
fetch(endpoint)
.then((r) => ({ ok: r.status < 500 }))
.catch(() => ({ ok: false }))
)
);
total += responses.length;
success += responses.filter((r) => r.ok).length;
failures += responses.filter((r) => !r.ok).length;
}
return { total, success, failures };
}
""")
print(f"\n Browser burst total={result['total']}, success={result['success']}, failures={result['failures']}")
assert result['success'] >= 20, f"Too many failed API requests: {result}"
@pytest.mark.stress
class TestMemoryStress:
"""Tests for memory leak detection."""
def test_toast_memory_cleanup(self, page: Page, app_server: str):
"""Check Toast system cleans up properly."""
load_page_with_js(page, f"{app_server}/tables")
# Create and dismiss many toasts
for batch in range(5):
for i in range(20):
page.evaluate(f"Toast.info('Memory test {batch}-{i}')")
page.evaluate("Toast.dismissAll()")
page.wait_for_timeout(100)
page.wait_for_timeout(500)
# Check DOM is clean
toast_count = page.locator('.mes-toast').count()
assert toast_count <= 5, f"Toast elements not cleaned up: {toast_count}"
print(f"\n Toast memory cleanup test passed (remaining: {toast_count})")
@pytest.mark.stress
class TestConsoleErrorMonitoring:
"""Monitor for JavaScript errors under stress."""
def test_no_js_errors_under_stress(self, page: Page, app_server: str):
"""Verify no JavaScript errors occur under stress conditions."""
js_errors = []
page.on("pageerror", lambda error: js_errors.append(str(error)))
load_page_with_js(page, f"{app_server}/tables")
# Perform stress operations
for i in range(30):
page.evaluate(f"Toast.info('Error check {i}')")
for i in range(10):
page.evaluate("""
MesApi.get('/api/wip/overview/summary').catch(() => {})
""")
page.wait_for_timeout(2000)
if js_errors:
print(f"\n JavaScript errors detected:")
for err in js_errors[:5]:
print(f" - {err[:100]}")
assert len(js_errors) == 0, f"Found {len(js_errors)} JavaScript errors"
print("\n No JavaScript errors under stress")