qa: validate server queries and prepare offline backend web update

This commit is contained in:
weiyuqing committed 2026-10-17 14:41:45 +08:00
1 parent 6292755ff9
commit c076bbc0d6
11 files changed
+640 -1

No files matched your search

+9 -1
View File
@@ -6,7 +6,7 @@
2026-10-17:backend/worker为20261017-audit3,web为20261017-ui7;UI7已成功部署。包含小米中国区云同步、SQLite长期保存与原始归档、备份恢复、密码鉴权、八类健康查询、紧凑参考线、纵轴下限和工作日/非工作日标记。ChatGPT隧道仍未启用。
新的后端汇总与分页优化处于设计审阅阶段,尚未实现或部署。
后端汇总与分页优化已在本机以合成数据完成集成验证,离线候选镜像为 `20261017-summary8`;NAS仍为上述audit3/ui7,尚未部署。八类查询的统计、最多120个图表桶与每页10条明细由后端返回,CSV按流传输。
## 目录
@@ -24,6 +24,14 @@
此仓库不包含.env、密钥环、数据库、真实健康记录、镜像包和第三方依赖缓存;不向Git提交这些内容。上游历史保持,远程upstream只作来源,未创建或推送任何网络仓库。
汇总优化的合成数据、旧查询对照和独立Edge验收入口为 `qa/serve_query_fixture.py`、`qa/benchmark_server_queries.py`、`qa/verify_server_queries_browser.cjs`。全部数据库、浏览器profile、截图、日志和离线包仅放忽略的 `artifacts/query-summary/`。实际测量、限制与复现步骤见 [后端查询验证报告](docs/nas/backend-query-validation.md)。
## summary8候选更新包
`deploy/query-summary/`包含离线增量Dockerfile、仅替换backend/web镜像的覆盖配置、哈希防覆盖和回退脚本。基础镜像audit3必须在本机已有,构建不安装依赖;worker继续audit3。发布包为本机 `artifacts/query-summary/release/`,不提交Git。
正式更新前须明确确认仅替换健康项目backend和web。确认后由用户在交互PowerShell运行 `deploy/query-summary/Update-Nas.ps1 -Package artifacts/query-summary/release -Deploy` 并在终端输入sudo密码;不加 `-Deploy` 只校验本地发布包。脚本沿用原端口、密码来源和数据/日历挂载,不处理其他NAS服务。就绪检查只检查资源、代理、鉴权和无效日期的新路由,不运行合成测试或读取健康记录。
## 后续维护
首先修改本目录并按影响运行本机测试,再执行兼容副本同步并核对Git差异。NAS部署需使用已验证的版本和明确批准的容器范围。现有旧版增量Dockerfile依赖本机基础镜像;完整可复现构建将在后端优化实施时统一整理,不能把旧覆盖镜像文件当成从零构建方案。
+1
View File
@@ -0,0 +1 @@
* text eol=lf
+6
View File
@@ -0,0 +1,6 @@
# 离线增量镜像;发布清单另固定基础镜像digest,不联网安装依赖。
FROM local/mi-fitness-nas:20261017-audit3
USER root
COPY src/mi_fitness_mcp/ /src/src/mi_fitness_mcp/
RUN python -c "import shutil,sysconfig; shutil.copytree('/src/src/mi_fitness_mcp',sysconfig.get_paths()['purelib']+'/mi_fitness_mcp',dirs_exist_ok=True)"
USER 1000:1001
+30
View File
@@ -0,0 +1,30 @@
param(
[Parameter(Mandatory=$true)][string]$Package,
[string]$SshHost = 'fn',
[switch]$Deploy
)
$ErrorActionPreference = 'Stop'
$release = (Resolve-Path -LiteralPath $Package).Path
$manifest = Join-Path $release 'manifest.sha256'
foreach ($line in Get-Content -LiteralPath $manifest -Encoding utf8) {
if ($line -notmatch '^([a-f0-9]{64}) ([A-Za-z0-9._-]+)$') { throw '发布清单格式不符' }
if ((Get-FileHash -LiteralPath (Join-Path $release $Matches[2]) -Algorithm SHA256).Hash.ToLowerInvariant() -ne $Matches[1]) { throw '本地发布文件校验失败' }
}
if (-not $Deploy) {
Write-Output '本地发布包校验通过。仅更新backend/web,worker、端口、密码和数据挂载沿用。'
Write-Output '得到明确部署确认后,在交互PowerShell使用同一命令加 -Deploy;sudo密码只在终端输入。'
return
}
if ($SshHost -notmatch '^[A-Za-z0-9._-]+$') { throw 'SSH别名无效' }
$remote = '/tmp/mi-fitness-summary8-' + (Get-Date -Format 'yyyyMMdd-HHmmss')
& ssh $SshHost "umask 077; mkdir '$remote'"
if ($LASTEXITCODE -ne 0) { throw '创建远程发布目录失败' }
foreach ($line in Get-Content -LiteralPath $manifest -Encoding utf8) {
$name = ($line -split ' ',2)[1]
& scp (Join-Path $release $name) "${SshHost}:$remote/$name"
if ($LASTEXITCODE -ne 0) { throw '发布文件传输失败' }
}
& scp $manifest "${SshHost}:$remote/manifest.sha256"
if ($LASTEXITCODE -ne 0) { throw '发布清单传输失败' }
& ssh -t $SshHost "sudo sh '$remote/update-health.sh'"
if ($LASTEXITCODE -ne 0) { throw '部署未通过,请保留终端输出和回退备份。' }
+40
View File
@@ -0,0 +1,40 @@
"""部署后最小就绪核对;不查询健康记录,不输出凭据。"""
import json
import subprocess
import sys
IMAGE = 'local/mi-fitness-nas:20261017-summary8'
items = json.loads(subprocess.check_output(['docker', 'inspect', 'mi-fitness-health-backend-1', 'mi-fitness-health-web-1', 'mi-fitness-health-worker-1']))
backend, web, worker = items
assert backend['Config']['Image'] == web['Config']['Image'] == IMAGE
assert worker['Config']['Image'] == 'local/mi-fitness-nas:20261017-audit3'
if len(sys.argv) > 1:
assert backend['Image'] == web['Image'] == sys.argv[1]
assert backend['HostConfig']['ReadonlyRootfs'] and web['HostConfig']['ReadonlyRootfs']
assert any(m['Destination'] == '/state' for m in backend['Mounts'])
assert any(m['Destination'] == '/run/secrets/keyring_master' and not m['RW'] for m in backend['Mounts'])
assert any(m['Destination'] == '/calendar' for m in web['Mounts'])
assert set(web['HostConfig']['PortBindings']) == {'8322/tcp'}
assert {(p['HostIp'], p['HostPort']) for p in web['HostConfig']['PortBindings']['8322/tcp']} == {('127.0.0.1', '18322'), ('192.168.31.201', '18322')}
# 密码只在容器内部使用,不读取到控制端、不打印、不放在命令行。
probe = r'''
import base64,json,os,urllib.error,urllib.request
opener=urllib.request.build_opener(urllib.request.ProxyHandler({}))
token=base64.b64encode(('admin:'+os.environ['MI_FITNESS_WEB_PASSWORD']).encode()).decode()
def get(path,auth=True):
req=urllib.request.Request('http://127.0.0.1:8322'+path,headers={'Authorization':'Basic '+token} if auth else {})
try:
with opener.open(req,timeout=10) as r:return r.status,r.read(300000).decode()
except urllib.error.HTTPError as e:return e.code,''
assert get('/',False)[0]==401
assert '20261017-summary8' in get('/')[1]
for path,marker in [('/static/js/health-views.js','fromSummary'),('/static/js/health-ui.js','/api/views/'),('/static/js/app.js','/api/overview')]:
status,body=get(path);assert status==200 and marker in body
assert json.loads(get('/api/proxy_status')[1])['backend_reachable']
for path in ['/proxy/api/views/stress','/proxy/api/views/stress/records','/proxy/api/views/stress/export','/proxy/api/overview']:
assert get(path+'?start_date=invalid&end_date=invalid')[0]==422
assert os.environ.get('MI_FITNESS_CALENDAR_DIR')=='/calendar'
assert json.loads(get('/api/calendar?start=2026&end=2026')[1])['years']['2026']['days']
print('backend/web资源、代理、匿名拒绝和新路由就绪;未查询健康记录。')
'''
subprocess.run(['docker', 'exec', '-i', 'mi-fitness-health-web-1', 'python', '-'], input=probe.encode('utf-8'), check=True)
+17
View File
@@ -0,0 +1,17 @@
# 沿用当前NAS的端口、密码来源和日历挂载,只升级backend/web。
services:
backend:
image: local/mi-fitness-nas:20261017-summary8
web:
image: local/mi-fitness-nas:20261017-summary8
environment:
MI_FITNESS_CALENDAR_DIR: /calendar
MI_FITNESS_ALLOWED_HOSTS: localhost,127.0.0.1,192.168.31.201
MI_FITNESS_WEB_PASSWORD: ${MI_FITNESS_WEB_PASSWORD:?尚未初始化网页访问密码}
volumes:
- ./calendar:/calendar
ports: !override
- '127.0.0.1:18322:8322'
- '192.168.31.201:18322:8322'
worker:
image: local/mi-fitness-nas:20261017-audit3
+47
View File
@@ -0,0 +1,47 @@
#!/bin/sh
set -eu
umask 077
[ "$(id -u)" = 0 ] || { echo '请通过sudo在交互终端执行。'; exit 1; }
package=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
target=/vol2/1000/Soft/mi-fitness-health
cd "$package"
sha256sum -c manifest.sha256
python3 - "$target/compose.override.yml" "$package/expected-current.sha256" <<'PY'
import hashlib,sys
from pathlib import Path
assert hashlib.sha256(Path(sys.argv[1]).read_bytes()).hexdigest()==Path(sys.argv[2]).read_text().strip(), '现有覆盖配置已变化,停止避免覆盖'
PY
[ -d "$target/calendar" ] && [ ! -L "$target/calendar" ] || { echo '日历挂载目录缺失或异常,停止。'; exit 1; }
cd "$target"
compose() { docker compose --project-name mi-fitness-health --env-file .env -f compose.yml -f compose.override.yml "$@"; }
snapshot() { docker ps -aq --filter label=com.docker.compose.project=mi-fitness-health | sort | while read -r id; do docker inspect --format '{{index .Config.Labels "com.docker.compose.service"}} {{.Id}} {{.Image}}' "$id"; done | grep -v -E '^(backend|web) ' | sort; }
before=$(snapshot)
[ "$(docker inspect --format '{{.Config.Image}}' mi-fitness-health-worker-1)" = local/mi-fitness-nas:20261017-audit3 ] || { echo 'worker版本已变化,停止。'; exit 1; }
backup="$target/compose.override.pre-summary8-$(date +%Y%m%d-%H%M%S).yml"
cp compose.override.yml "$backup"
docker inspect --format '{{.Config.Image}} {{.Image}}' mi-fitness-health-backend-1 mi-fitness-health-web-1 > "$backup.images"
# 不清理旧镜像;覆盖配置和镜像ID均保留以便回退。
docker load -i "$package/health-summary8.tar"
expected=$(cat "$package/image-id.txt")
[ "$(docker image inspect --format '{{.Id}}' local/mi-fitness-nas:20261017-summary8)" = "$expected" ] || { echo '导入镜像ID不符。'; exit 1; }
docker compose --project-name mi-fitness-health --env-file .env -f compose.yml -f "$package/compose.override.yml" config --quiet
complete=0
recover() {
if [ "$complete" = 0 ]; then
cp "$backup" "$target/compose.override.yml"
chown 1000:1001 "$target/compose.override.yml"
chmod 600 "$target/compose.override.yml"
compose up -d --no-deps --no-build --pull never backend web || true
echo "更新未通过,已恢复原配置并尝试回退backend/web。备份:$backup"
fi
}
trap recover EXIT
cp "$package/compose.override.yml" compose.override.yml
chown 1000:1001 compose.override.yml
chmod 600 compose.override.yml
compose up -d --no-deps --no-build --pull never --wait --wait-timeout 90 backend web
python3 "$package/check-ready.py" "$expected"
[ "$before" = "$(snapshot)" ] || { echo 'worker或其他健康服务发生变化,停止验收。'; exit 1; }
complete=1
trap - EXIT
printf '\nbackend/web更新完成;worker及其他健康服务未重建,原覆盖配置和旧镜像已保留。\n'
+62
View File
@@ -0,0 +1,62 @@
# 后端汇总与分页:本机验证记录
日期:2026-10-17。候选镜像 `local/mi-fitness-nas:20261017-summary8`。本报告全部结果来自本机生成的SQLite和独立浏览器/Linux容器,**NAS未部署,没有NAS性能实测**。没有读取真实凭据、健康库、原始归档或导出。
## 数据与统计一致性
合成范围2026-09-18至2026-10-17,共30天。1x每天3000条心率、1000条血氧、1000条压力;另有每日两设备活动/体重、夜间睡眠与小睡、两条运动/异常样本。10x仅增加三个高频类别密度,分别90万/30万/30万条,不改变日期范围。
同一个只读SQLite通过旧QueryService+冻结前端oracle和新ViewQueryService核对7/30天的八类卡片、图表点、覆盖天数;完整CSV的共有业务字段集合与旧查询相同。每类另对带limit=31的完整筛选结果核对分页并集、唯一id和重复读取次序;相同时间的体重设备、运动和异常记录均覆盖。没有用大库的数千次OFFSET翻页冒充现实浏览过程。
同刻多设备体重是明确的规格差异:旧接口没有主键次序,冻结oracle最后一天取72.9kg;新接口按真实时间及id倒序取72.91kg。旧oracle及其输入不改,新的latest/每日点单独按稳定主键规则断言;记录集合和均值不变。睡眠CSV的小睡值True/False按布尔语义与旧库0/1核对,原CSV字节不改。
## 实测体积、耗时与内存
JSON均按UTF-8紧凑编码计字节。耗时是一次本机服务调用,不含浏览器渲染和网络;两条路径都启用tracemalloc。内存为Python分配峰值,**不包含SQLite原生内存、进程RSS或浏览器内存**。旧全量响应只在本机测量,未交给浏览器渲染。
|密度/类型|记录数|旧JSON字节|新首屏字节|旧/新秒|旧/新Python峰值字节|
|---|---:|---:|---:|---:|---:|
|1x心率|90,000|7,255,988|3,470|0.9609 / 0.8096|106,300,937 / 33,031|
|1x血氧|30,000|1,851,368|2,863|0.2890 / 0.2311|33,815,314 / 30,301|
|1x压力|30,000|2,775,578|3,162|0.3056 / 0.2509|35,601,460 / 31,109|
|10x心率|900,000|73,569,039|3,570|9.6272 / 8.3072|1,062,972,329 / 32,869|
|10x血氧|300,000|18,849,369|2,911|3.0202 / 2.5436|336,293,170 / 30,147|
|10x压力|300,000|28,092,399|3,239|3.2429 / 2.6703|356,123,564 / 30,963|
六个新首屏均只有10条明细和30个图表桶,没有原始全量数组。密度10倍时首屏只增加100/48/77字节;`test_payload_does_not_scale_with_raw_samples`断言响应低于1.25倍并验证固定明细/桶数。SQL仍须扫描所选记录;本机90万心率的新汇总需要8.3072秒,不能据此宣称NAS查询会瞬时完成。
## 真实浏览器与Linux容器
独立无头Edge profile实际访问总览和八页,7/30天共16视图;每次首屏恰一条views请求,翻页只取records且保留图表。完整数据模式下首屏JSON为1571–6599字节,整个文档节点812–3093(包括已打开但隐藏的页面)。本机30天首屏心率约0.8秒、血氧/压力约0.25秒;具体每页耗时在忽略目录的browser/report.json,属于本机单次观测。
心率参考线、40bpm纵轴下限、工作日橙色/非工作日绿色已在真实SVG断言;深浅主题和1440px/390px截图已检查。窄屏沿用已有布局,头部控件较拥挤,本任务没有改样式。控制台、失败空态和日期/页面取消均覆盖;最终完整验收0个页面异常、0条旧全量接口请求。迟到/筛选/翻页竞态另由现有Node测试覆盖。
手填Key、扫码state Key、仅server Key、server Key优先于不同浏览器Key四种身份的原生附件均保存31条,1403字节且内容逐字节相同;POST表单立即清除,Key不在URL,没有CSV fetch或Blob缓冲。server-only是原生新标签GET附件,由Browser下载事件观测,容器HTTP再独立确认GET。QA最初用请求头模拟Basic,原生新标签GET未带该头而401;改成真实Basic挑战缓存后四模式通过,产品未改。
首轮浏览器只保存到一条text为Uncaught的异常,缺少stack,无法定位或宣称修复。随后增强exceptionDetails并完成两轮验收,未复现;原失败摘要仍保留在artifacts,后续若再出现须收集完整stack继续定位。
本机Linux两个独立容器使用summary8安装包代码,通过内部网络互通,无宿主机端口,CPU各0.5、内存512/256MiB、根文件系统只读;仅挂载QA脚本和合成库(只读)。GET/POST导出均200/chunked、31条1403字节;8次读首块后关闭,再次查询成功。匿名web为401,无默认上下文backend为503,错误Key为401,另一合成用户查询0条,外源Origin为403。取消后backend文件描述符8、RSS约68MiB;资源释放的精确路径由streaming专项测试覆盖,不把单次RSS当内存泄漏证明。audit3与summary8的 `/opt/health/runtime.py` SHA256同为 `1ac5a53cdbd59376a07a0021c145e55ce03e36e6890278291bed9298e03a54a5`。
相关Python验证85项通过(1条Starlette/httpx既有弃用警告),全部Node验证53项通过。部署shell和PowerShell语法通过;用虚构环境渲染前后Compose逐字段比较,恰只有backend/web镜像变化,worker、端口、环境和挂载一致。
## 复现与离线交付
在主仓库使用现有Python依赖环境并设置 `PYTHONPATH=src`。下列命令只处理本仓库artifacts中的合成数据,不得替换成真实库路径:
```powershell
python qa/benchmark_server_queries.py
python qa/serve_query_fixture.py backend --port 18431
python qa/serve_query_fixture.py web --port 18432
python qa/serve_query_fixture.py web --port 18433 --server-key
node qa/verify_server_queries_browser.cjs
python -m pytest tests/test_view_queries.py tests/test_view_statistics.py tests/test_view_api.py tests/test_view_export.py tests/test_web_streaming.py tests/test_web.py -q
node --test qa/*.test.cjs
```
三个fixture各在独立本机终端启动;自动启动时必须隐藏窗口。浏览器脚本自行创建独立Edge profile并退出。密度库已存在时benchmark复用它;重新生成时使用新的artifacts输出目录,generate拒绝覆盖现有库。
离线构建使用 `deploy/query-summary/Dockerfile`,上下文只复制当前 `src/mi_fitness_mcp/` 源码(排除pycache),不把仓库运行文件送入构建。基础镜像须核对为audit3 `sha256:f4cbe187a98e3d3223e28e79b6edd115446ba62a78931f54f3bb0a6991a9395d`,构建选 `--network none --pull=false`;同时覆盖源码目录与installed purelib,保持audit3运行入口,无联网安装。
本机离线发布目录为 `artifacts/query-summary/release/`:镜像tar、image-id、基础镜像id、覆盖配置、就绪/更新脚本、预期当前配置哈希及manifest.sha256。摘要和镜像包不提交Git。`Update-Nas.ps1`不带Deploy仅校验;正式执行必须先取得仅更新backend/web的明确确认,由用户交互输入sudo密码。更新脚本核对manifest和现有覆盖配置SHA256、保留旧覆盖与镜像、不变更.env,使用 `up -d --no-deps --no-build --pull never backend web`,比较其余健康服务容器id/镜像。失败恢复旧覆盖并尝试回退这两个服务。
当前NAS覆盖配置防覆盖哈希由controller在部署前只读取得:`3cdbdc882b169f5785d6d1c3c46ceb159e209c567d6e28ceb31b15d4280fcdd8`。实际正式更新时脚本再次验证;若有漂移即停止。此记录不是部署确认。NAS就绪脚本只访问资源/代理状态、匿名认证、日历和无效日期路由,不进行健康导出、合成/压力测试。
+161
View File
@@ -0,0 +1,161 @@
"""合成只读库的旧查询基线、新汇总、分页与 CSV 对照;输出到 artifacts。"""
import argparse
import csv
import gc
import io
import json
import math
import shutil
import subprocess
import time
import tracemalloc
from collections import Counter
from dataclasses import replace
from serve_query_fixture import KINDS, OWNER, ROOT, ReadOnlyDatabase, artifact_path, generate
from mi_fitness_mcp.services.query_service import QueryService
from mi_fitness_mcp.services.view_query_service import ViewQueryService
from mi_fitness_mcp.storage.view_queries import KINDS as SPECS, ViewSelection
from mi_fitness_mcp.api import VIEW_CSV_COLUMNS, _view_csv_chunks
METHODS = dict(zip(KINDS, ['get_daily_summaries', 'get_heart_rate_samples', 'get_sleep_sessions', 'get_workouts', 'get_body_measurements', 'get_spo2_samples', 'get_stress_samples', 'get_abnormal_heart_beat_events']))
def measured(call):
gc.collect()
tracemalloc.start()
started = time.perf_counter()
value = call()
elapsed = time.perf_counter() - started
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
return value, dict(seconds=round(elapsed, 4), python_peak_bytes=peak)
def encoded(value):
return json.dumps(value, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
def test_payload_does_not_scale_with_raw_samples(results):
for kind in ('heartrate', 'spo2', 'stress'):
a, b = [next(row for row in results if row['density'] == density and row['kind'] == kind) for density in (1, 10)]
assert b['raw_count'] == a['raw_count'] * 10
assert b['summary_bytes'] < a['summary_bytes'] * 1.25, (kind, a, b)
assert a['detail_rows'] == b['detail_rows'] == 10
assert a['point_count'] == b['point_count'] == 30
def cards(kind, result):
s = result['statistics']
return {
'activity': [s.get('primary_sum'), s.get('total_active_kcal'), (s.get('total_distance_m') or 0) / 1000, result['coverage']],
'heartrate': [s.get('passive_mean_bpm'), s.get('resting_mean_bpm'), s.get('primary_min'), s.get('primary_max')],
'sleep': [s.get('mean_daily_asleep_minutes'), result['coverage'], s.get('mean_daily_deep_minutes'), s.get('nap_count')],
'workouts': [s['count'], s.get('primary_sum'), (s.get('total_distance_m') or 0) / 1000, s.get('total_calories_kcal')],
'body': [(s.get('latest') or {}).get('weight_kg'), (s.get('latest') or {}).get('bmi'), (s.get('latest') or {}).get('body_fat_pct'), s['count']],
'spo2': [s.get('primary_mean'), s.get('primary_min'), s.get('primary_max'), result['coverage']],
'stress': [s.get('primary_mean'), s.get('primary_min'), s.get('primary_max'), result['coverage']],
'abnormal': [s['count'], s.get('primary_sum'), result['coverage'], None],
}[kind]
def close_enough(actual, expected):
if isinstance(expected, (int, float)):
assert math.isclose(actual, expected, abs_tol=1e-8), (actual, expected)
elif isinstance(expected, list):
assert len(actual) == len(expected)
for a, b in zip(actual, expected):
close_enough(a, b)
elif isinstance(expected, dict):
for key, value in expected.items():
close_enough(actual[key], value)
else:
assert actual == expected, (actual, expected)
def consistency(db, node):
old, new = QueryService(db, OWNER), ViewQueryService(db, OWNER)
checked = []
for days, start in [(7, '2026-10-11'), (30, '2026-09-18')]:
for kind in KINDS:
selection = ViewSelection(kind, start, '2026-10-17')
rows = getattr(old, METHODS[kind])(start, selection.end_date)
expected = json.loads(subprocess.run([node, str(ROOT / 'qa/view-statistics-oracle.cjs')], cwd=ROOT,
input=encoded(dict(kind=kind, rows=rows, range=dict(start=start, end=selection.end_date))).decode('utf-8'),
text=True, encoding='utf-8', capture_output=True, check=True).stdout)
result = new.get_view(selection)
if kind == 'body':
# 多设备同刻的旧接口次序未定义,冻结oracle保持原样;单独按批准规格验新主键次序。
with db._get_connection() as conn:
stable = dict(conn.execute('SELECT id,weight_kg,bmi,body_fat_pct FROM body_measurements WHERE user_id=? ORDER BY timestamp DESC,id DESC LIMIT 1', (OWNER,)).fetchone())
assert result['statistics']['latest']['id'] == stable['id']
close_enough(cards(kind, result)[:3], [stable['weight_kg'], stable['bmi'], stable['body_fat_pct']])
assert cards(kind, result)[3] == expected['cards'][3]
close_enough(result['statistics']['primary_mean'], sum(row['weight_kg'] for row in rows) / len(rows))
for point in result['points']:
with db._get_connection() as conn:
wanted = conn.execute('SELECT weight_kg FROM body_measurements WHERE user_id=? AND substr(timestamp,1,10)=? ORDER BY timestamp DESC,id DESC LIMIT 1', (OWNER, point['date'])).fetchone()[0]
close_enough(point['values'], [wanted])
else:
close_enough(cards(kind, result), expected['cards'])
close_enough(result['points'], expected['points'])
assert result['coverage'] == expected['coverage']
assert result['statistics']['count'] == expected['count']
# CSV 与旧查询逐行业务字段集合核对;投影附加字段另由专项测试验证。
data = b''.join(_view_csv_chunks(new, selection)).decode('utf-8-sig')
parsed = list(csv.reader(io.StringIO(data)))
columns = [field for field, _ in VIEW_CSV_COLUMNS[kind]]
shared = [field for field in columns if all(field in row for row in rows)]
def cell(value):
return '' if value is None else json.dumps(value, ensure_ascii=False) if isinstance(value, (list, dict)) else str(int(value)) if isinstance(value, bool) else str(value)
actual = Counter(tuple(row[columns.index(field)] for field in shared) for row in parsed[1:])
wanted = Counter(tuple(str(bool(row.get(field))) if field == 'is_nap' else cell(row.get(field)) for field in shared) for row in rows)
assert actual == wanted, (kind, days, 'CSV集合不一致')
# 有界31条检查分页并集;body/workouts/abnormal 包含相同时间的稳定id次序。
limited = replace(selection, limit=31)
exported = list(new.iter_export(limited))
pages = sum([new.get_page(limited, page)['page']['rows'] for page in range(1, (len(exported) + 9) // 10 + 1)], [])
assert pages == exported
assert len({row['id'] for row in pages}) == len(pages)
assert new.get_page(limited, 1)['page']['rows'] == pages[:10]
checked.append(dict(kind=kind, days=days, count=len(rows), csv_rows=len(parsed) - 1, pagination_rows=len(pages)))
return checked
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', default=str(ROOT / 'artifacts/query-summary/benchmark.json'))
parser.add_argument('--node', default=shutil.which('node') or 'D:/nodejs/node.exe')
args = parser.parse_args()
output = artifact_path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
report = dict(scope='本机合成数据;没有NAS性能实测', measurements=[], consistency=[])
for density in (1, 10):
path = output.parent / f'synthetic-{density}x.db'
if not path.exists():
generate(path, density)
db = ReadOnlyDatabase(path)
old, new = QueryService(db, OWNER), ViewQueryService(db, OWNER)
for kind in ('heartrate', 'spo2', 'stress'):
rows, baseline = measured(lambda: getattr(old, METHODS[kind])('2026-09-18', '2026-10-17'))
raw_bytes = len(encoded(dict(status='ok', count=len(rows), data=rows)))
raw_count = len(rows)
del rows
result, summary = measured(lambda: new.get_view(ViewSelection(kind, '2026-09-18', '2026-10-17')))
assert set(result) == {'status', 'kind', 'range', 'statistics', 'points', 'coverage', 'stride', 'page'}
assert len(result['page']['rows']) == 10 and len(result['points']) <= 120
row = dict(density=density, kind=kind, raw_count=raw_count, raw_json_bytes=raw_bytes, summary_bytes=len(encoded(result)), detail_rows=len(result['page']['rows']), point_count=len(result['points']), baseline=baseline, summary=summary)
report['measurements'].append(row)
print(json.dumps(row), flush=True)
output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
if density == 1:
report['consistency'] = consistency(db, args.node)
output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
test_payload_does_not_scale_with_raw_samples(report['measurements'])
report['test_payload_does_not_scale_with_raw_samples'] = 'PASS'
output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
print('PASS: 16组统计/CSV/分页对照;1x/10x首屏有界。')
if __name__ == '__main__':
main()
+139
View File
@@ -0,0 +1,139 @@
"""仅本机合成数据入口;不加载用户配置、凭据或同步适配器。"""
import argparse
import json
import os
import sqlite3
import threading
import time
from contextlib import asynccontextmanager, contextmanager
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from mi_fitness_mcp.storage import Database
ROOT = Path(__file__).resolve().parents[1]
KINDS = ('activity', 'heartrate', 'sleep', 'workouts', 'body', 'spo2', 'stress', 'abnormal')
OWNER = 'synthetic-owner'
KEY = 'synthetic-query-key'
PASSWORD = 'synthetic-web-password'
def artifact_path(value):
path = Path(value).resolve()
if not path.is_relative_to(ROOT / 'artifacts'):
raise ValueError('合成数据和输出必须位于本仓库 artifacts 目录')
return path
def generate(path, density=1):
path = artifact_path(path)
if path.exists():
raise FileExistsError('拒绝覆盖已有文件:' + str(path))
db = Database(path)
tz = timezone(timedelta(hours=8))
with db._get_connection() as conn:
def insert(table, rows):
fields = ('id', 'provider', 'source_type', 'user_id') + tuple(rows[0])
conn.executemany(f"INSERT INTO {table} ({','.join(fields)}) VALUES ({','.join('?' for _ in fields)})",
[(f'{table}-{offset:08}', 'synthetic', 'generated', OWNER, *row.values()) for offset, row in enumerate(rows)])
for table, count, field in [('heart_rate_samples', 3000, 'bpm'), ('spo2_samples', 1000, 'spo2_pct'), ('stress_samples', 1000, 'stress_score')]:
rows = []
for day in range(30):
base = datetime(2026, 9, 18, tzinfo=tz) + timedelta(days=day)
for n in range(count * density):
stamp = (base + timedelta(seconds=n * 86400 / (count * density))).isoformat()
row = dict(timestamp=stamp)
row[field] = 55 + n % 70 if field == 'bpm' else 92 + n % 9 if field == 'spo2_pct' else n % 102
if field == 'bpm':
row['sample_type'] = ('passive', 'resting', 'active')[n % 3]
if field == 'stress_score':
row['level'] = 'synthetic-source'
rows.append(row)
insert(table, rows)
for table in ('daily_activity', 'sleep_sessions', 'workouts', 'body_measurements', 'abnormal_heart_beat_events'):
rows = []
for day in range(30):
d = date(2026, 9, 18) + timedelta(days=day)
stamp = d.isoformat() + 'T08:00:00+08:00'
for n in range(2):
if table == 'daily_activity':
row = dict(date=d.isoformat(), device_id=f'device-{n}', steps=2000 + day * 10 + n, distance_m=1800 + day, active_kcal=100 + n, total_kcal=1000 + day)
elif table == 'sleep_sessions':
row = dict(sleep_id=f's-{day}-{n}', start_at=(d - timedelta(days=1)).isoformat() + 'T23:00:00+08:00', end_at=stamp, duration_minutes=495 if n == 0 else 30, time_asleep_minutes=480 if n == 0 else 30, time_awake_minutes=15 if n == 0 else 0, is_nap=n, stages=json.dumps([dict(stage='deep', minutes=120), dict(stage='light', minutes=280), dict(stage='rem', minutes=80)]) if n == 0 else None)
elif table == 'workouts':
row = dict(workout_id=f'w-{day}-{n}', activity_type='run', start_at=stamp, end_at=d.isoformat() + 'T09:00:00+08:00', duration_minutes=60, distance_m=8000, calories_kcal=400)
elif table == 'body_measurements':
row = dict(timestamp=stamp, device_id=f'device-{n}', weight_kg=70 + day / 10 + n / 100, bmi=22, body_fat_pct=20)
else:
row = dict(event_id=f'a-{day}-{n}', start_at=stamp, end_at=d.isoformat() + 'T08:01:00+08:00', duration_seconds=60)
rows.append(row)
insert(table, rows)
conn.execute('CREATE TABLE api_keys (key TEXT PRIMARY KEY,user_id TEXT,pass_token TEXT,region TEXT,revoked INTEGER DEFAULT 0)')
conn.executemany('INSERT INTO api_keys VALUES (?,?,?,?,0)', [(KEY, OWNER, '', 'cn'), ('synthetic-other-key', 'synthetic-other', '', 'cn')])
conn.commit()
return path
class ReadOnlyDatabase(Database):
def __init__(self, path):
self.db_path = artifact_path(path)
if not self.db_path.is_file():
raise FileNotFoundError(self.db_path)
@contextmanager
def _get_connection(self):
conn = sqlite3.connect(self.db_path.as_uri() + '?mode=ro', uri=True)
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.close()
def backend(path):
# 替换 lifespan 只为隔离配置和密钥环;路由、鉴权、查询服务使用产品实现。
from mi_fitness_mcp import api
from mi_fitness_mcp.services.query_service import QueryService
db = ReadOnlyDatabase(path)
@asynccontextmanager
async def lifespan(app):
app.state.db = db
app.state.default_context = None
app.state.key_contexts = {}
app.state.key_context_lock = threading.RLock()
for key, owner in [(KEY, OWNER), ('synthetic-other-key', 'synthetic-other')]:
ctx = api.UserContext(owner, 'synthetic-unused', 'cn', query_service=QueryService(db, owner))
app.state.key_contexts[key] = dict(expires=time.monotonic() + 86400, identity=(owner, 'cn', ''), context=ctx)
yield
api.app.router.lifespan_context = lifespan
return api.app
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('mode', choices=['generate', 'backend', 'web'])
parser.add_argument('--db', default=str(ROOT / 'artifacts/query-summary/synthetic-1x.db'))
parser.add_argument('--density', type=int, choices=[1, 10], default=1)
parser.add_argument('--port', type=int, default=18431)
parser.add_argument('--backend-url', default='http://127.0.0.1:18431')
parser.add_argument('--server-key', action='store_true')
parser.add_argument('--host', default='127.0.0.1', choices=['127.0.0.1', '0.0.0.0'])
args = parser.parse_args()
for name in ('MI_FITNESS_API_KEY', 'MI_FITNESS_ADMIN_KEY'):
os.environ.pop(name, None)
os.environ['MI_FITNESS_ALLOWED_HOSTS'] = '127.0.0.1,localhost,backend,web'
os.environ['MI_FITNESS_WEB_PASSWORD'] = PASSWORD
os.environ['MI_FITNESS_CALENDAR_DIR'] = str(ROOT / 'artifacts/query-summary/calendar')
if args.mode == 'generate':
print(generate(args.db, args.density))
elif args.mode == 'backend':
import uvicorn
uvicorn.run(backend(args.db), host=args.host, port=args.port, access_log=False)
else:
from mi_fitness_mcp.web import create_app
app = create_app(backend_url=args.backend_url, api_key=KEY if args.server_key else None)
app.run(host=args.host, port=args.port, debug=False, use_reloader=False)
if __name__ == '__main__':
main()
+128
View File
@@ -0,0 +1,128 @@
/* 本机 Edge 独立profile真实DOM验收,无第三方依赖。仅访问合成fixture。 */
const assert=require('node:assert/strict');
const fs=require('node:fs');
const path=require('node:path');
const {spawn}=require('node:child_process');
const ROOT=path.resolve(__dirname,'..');
const OUT=path.resolve(process.env.QUERY_QA_OUTPUT||path.join(ROOT,'artifacts/query-summary/browser'));
assert(OUT.startsWith(path.join(ROOT,'artifacts')+path.sep));
const EDGE=process.env.QUERY_QA_EDGE||'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe';
const BASE=process.env.QUERY_QA_URL||'http://127.0.0.1:18432';
assert(['127.0.0.1','localhost'].includes(new URL(BASE).hostname),'只允许本机fixture');
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
class CDP{
constructor(ws){this.ws=ws;this.next=0;this.pending=new Map();this.handlers=new Map();ws.addEventListener('message',event=>{const m=JSON.parse(event.data);if(m.id){const p=this.pending.get(m.id);this.pending.delete(m.id);m.error?p.reject(Error(JSON.stringify(m.error))):p.resolve(m.result);}else for(const fn of this.handlers.get(m.method)||[])fn(m.params,m.sessionId);});}
send(method,params={},sessionId){return new Promise((resolve,reject)=>{const id=++this.next;this.pending.set(id,{resolve,reject});this.ws.send(JSON.stringify({id,method,params,sessionId}));});}
on(method,fn){if(!this.handlers.has(method))this.handlers.set(method,[]);this.handlers.get(method).push(fn);}
}
async function main(){
fs.mkdirSync(OUT,{recursive:true});
const profile=path.join(OUT,'profile-'+Date.now());
const edge=spawn(EDGE,['--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check','--disable-background-networking','--remote-debugging-port=0','--remote-debugging-address=127.0.0.1','--user-data-dir='+profile,'about:blank'],{windowsHide:true,stdio:'ignore'});
let cdp;
try{
let port;for(let n=0;n<100;n++){try{port=fs.readFileSync(path.join(profile,'DevToolsActivePort'),'utf8').split('\n')[0];break;}catch{await sleep(100);}}
assert(port,'Edge未就绪');
const info=await(await fetch('http://127.0.0.1:'+port+'/json/version')).json();
const ws=new WebSocket(info.webSocketDebuggerUrl);await new Promise((resolve,reject)=>{ws.addEventListener('open',resolve,{once:true});ws.addEventListener('error',reject,{once:true});});cdp=new CDP(ws);
const {targetId}=await cdp.send('Target.createTarget',{url:'about:blank'});
const {sessionId:sid}=await cdp.send('Target.attachToTarget',{targetId,flatten:true});
const send=(m,p)=>cdp.send(m,p,sid);
const evaluate=async expression=>{const r=await send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});assert(!r.exceptionDetails,JSON.stringify(r.exceptionDetails));return r.result.value;};
const requests=[],errors=[],downloads=[],downloadStarts=[],responses=[],report={base:BASE,scope:'本机合成fixture;未接触用户浏览器或NAS',views:[],downloads:[],failures:[]};
cdp.on('Network.requestWillBeSent',(p,s)=>{if(s===sid)requests.push({id:p.requestId,url:p.request.url,method:p.request.method});});
cdp.on('Network.responseReceived',(p,s)=>{if(s===sid)responses.push({id:p.requestId,url:p.response.url,status:p.response.status,bytes:p.response.encodedDataLength});});
cdp.on('Runtime.exceptionThrown',(p,s)=>{if(s===sid)errors.push(p.exceptionDetails);});
cdp.on('Browser.downloadProgress',p=>downloads.push(p));
cdp.on('Browser.downloadWillBegin',p=>downloadStarts.push(p));
await send('Runtime.enable');await send('Network.enable');await send('Page.enable');
// 通过真实Basic挑战建立浏览器凭据缓存;注入请求头无法代表原生GET附件认证。
cdp.on('Fetch.requestPaused',(p,s)=>{if(s===sid)send('Fetch.continueRequest',{requestId:p.requestId}).catch(()=>{});});
cdp.on('Fetch.authRequired',(p,s)=>{if(s===sid)send('Fetch.continueWithAuth',{requestId:p.requestId,authChallengeResponse:{response:'ProvideCredentials',username:'admin',password:'synthetic-web-password'}}).catch(()=>{});});
await send('Fetch.enable',{handleAuthRequests:true});
await cdp.send('Browser.setDownloadBehavior',{behavior:'allow',downloadPath:OUT,eventsEnabled:true});
await send('Page.addScriptToEvaluateOnNewDocument',{source:"localStorage.setItem('mi_fitness_api_key','synthetic-query-key');"});
await send('Page.navigate',{url:BASE});
for(let n=0;n<100;n++){if(await evaluate("typeof switchTab==='function' && document.readyState==='complete'"))break;await sleep(100);}
if(process.env.QUERY_QA_SCREENSHOTS_ONLY==='1'){
await send('Emulation.setDeviceMetricsOverride',{width:1440,height:1100,deviceScaleFactor:1,mobile:false});
await evaluate("(async()=>{$('startDate').value='2026-09-18';$('endDate').value='2026-10-17';$('hrLimit').value='';await switchTab('heartrate');})()");
await sleep(4500);
for(const theme of ['dark','light']){
await evaluate(`state.theme='${theme}';document.documentElement.setAttribute('data-theme','${theme}');`);
fs.writeFileSync(path.join(OUT,theme+'.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
}
await send('Emulation.setDeviceMetricsOverride',{width:390,height:1100,deviceScaleFactor:1,mobile:false});
fs.writeFileSync(path.join(OUT,'narrow.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
console.log('PASS: 1440px深浅主题与390px窄屏截图;只重拍截图,未重复性能/整体验证。');return;
}
await evaluate("$('startDate').value='2026-10-11';$('endDate').value='2026-10-17';loadOverview()");
assert.equal(await evaluate("$('statSteps').textContent.includes('--')"),false);
for(const days of [7,30])for(const kind of ['activity','heartrate','sleep','workouts','body','spo2','stress','abnormal']){
const start=days===7?'2026-10-11':'2026-09-18',before=requests.length;
const elapsed=await evaluate(`(async()=>{$('startDate').value='${start}';$('endDate').value='2026-10-17';$('hrLimit').value='';$('spo2Limit').value='';$('stressLimit').value='';$('latestOnlyCheck').checked=false;const t=performance.now();await switchTab('${kind}');return performance.now()-t;})()`);
const value=await evaluate(`(()=>{const m=state.healthViews.${kind}?.model;return m?{rows:m.records.length,total:m.total,points:m.points.length,dom:document.querySelectorAll('*').length,html:$('healthView-${kind}').innerHTML,cards:m.cards.map(c=>c.raw),references:m.references}:null;})()`);
assert(value,kind+'未生成模型');assert(value.rows<=10);assert(value.points<=120);assert(value.html.includes('<svg'),kind+'缺图表');
const relevant=requests.slice(before).filter(r=>new URL(r.url).pathname==='/proxy/api/views/'+kind);
assert.equal(relevant.length,1,kind+'首屏请求次数');
const response=responses.find(r=>r.id===relevant[0].id);assert.equal(response.status,200);
const body=await send('Network.getResponseBody',{requestId:relevant[0].id});const bytes=Buffer.byteLength(body.body);
report.views.push({days,kind,detailRows:value.rows,total:value.total,points:value.points,domNodes:value.dom,summaryBytes:bytes,loadMs:Math.round(elapsed*100)/100});
if(value.total>10){const chart=await evaluate(`$('healthView-${kind}').querySelector('svg').outerHTML`);await evaluate(`healthPage('${kind}',2)`);assert.equal(await evaluate(`state.healthViews.${kind}.page`),2);assert.equal(await evaluate(`$('healthView-${kind}').querySelector('svg').outerHTML`),chart);assert(requests.slice(before).some(r=>r.url.includes('/records?')&&r.url.includes('page=2')));}
}
await evaluate("(async()=>{$('hrLimit').value='31';await switchTab('heartrate');})()");
assert(await evaluate("document.querySelector('#healthView-heartrate svg').innerHTML.includes('40')"),'心率纵轴下限');
assert(await evaluate("state.healthViews.heartrate.model.references.length>0"),'参考线');
for(let n=0;n<30;n++){if(await evaluate("Object.keys(state.healthViews.heartrate.model.range.calendar||{}).length>0"))break;await sleep(100);}
assert(await evaluate("Object.keys(state.healthViews.heartrate.model.range.calendar||{}).length>0"),'日历未载入');
assert(await evaluate("document.querySelector('#healthView-heartrate svg').innerHTML.includes('#22a06b')"),'非工作日颜色');
for(const theme of ['light','dark']){
await evaluate(`if(state.theme!=='${theme}')toggleTheme();`);
assert.equal(await evaluate("document.documentElement.dataset.theme"),theme);
const image=await send('Page.captureScreenshot',{format:'png',captureBeyondViewport:false});fs.writeFileSync(path.join(OUT,theme+'.png'),Buffer.from(image.data,'base64'));
}
await send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:false});
fs.writeFileSync(path.join(OUT,'narrow.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
await send('Emulation.clearDeviceMetricsOverride');
// 手填、扫码state、server优先(独立fixture端口)均使用原生附件,不在浏览器读取CSV。
for(const mode of ['input','state']){
await evaluate(`(async()=>{state.apiKey=${mode==='state'?"'synthetic-query-key'":"''"};$('apiKeyInput').value='synthetic-query-key';$('hrLimit').value='31';await loadHealthView('heartrate');exportHealthRecords('heartrate');})()`);
let completed;for(let n=0;n<150;n++){completed=downloads.filter(d=>d.state==='completed').length;if(completed>=report.downloads.length+1)break;await sleep(100);}
assert(completed>=report.downloads.length+1,'原生附件未完成');
const file=path.join(OUT,'mi_fitness_heartrate_2026-09-18_2026-10-17.csv');assert(fs.existsSync(file));
const csv=fs.readFileSync(file,'utf8');assert.equal(csv.trim().split('\n').length,32);
assert.equal(await evaluate("document.querySelectorAll('form input[name=api_key]').length"),0);
const request=requests.filter(r=>r.url.includes('/heartrate/export?')).at(-1);assert.equal(request.method,'POST');assert(!request.url.includes('synthetic'));
fs.renameSync(file,path.join(OUT,mode+'.csv'));report.downloads.push({mode,method:request.method,bytes:Buffer.byteLength(csv),rows:31});
}
const serverBase=process.env.QUERY_QA_SERVER_URL||'http://127.0.0.1:18433';
assert(['127.0.0.1','localhost'].includes(new URL(serverBase).hostname));
await send('Page.navigate',{url:serverBase});
for(let n=0;n<100;n++){if(await evaluate("typeof switchTab==='function' && document.readyState==='complete' && location.port==='"+new URL(serverBase).port+"'"))break;await sleep(100);}
for(const mode of ['server-only','server-priority']){
await evaluate(`(async()=>{state.apiKey=${mode==='server-priority'?"'synthetic-other-key'":"''"};$('apiKeyInput').value='';$('startDate').value='2026-09-18';$('endDate').value='2026-10-17';$('hrLimit').value='31';await switchTab('heartrate');exportHealthRecords('heartrate');})()`);
assert.equal(await evaluate('state.healthViews.heartrate.model.total'),31,'服务端Key身份应优先');
let completed;for(let n=0;n<150;n++){completed=downloads.filter(d=>d.state==='completed').length;if(completed>=report.downloads.length+1)break;await sleep(100);}
if(completed<report.downloads.length+1)fs.writeFileSync(path.join(OUT,'download-failure.json'),JSON.stringify({mode,downloads,requests:requests.slice(-8),responses:responses.slice(-8),errors},null,2));
assert(completed>=report.downloads.length+1,'server Key原生附件未完成');
const file=path.join(OUT,'mi_fitness_heartrate_2026-09-18_2026-10-17.csv'),csv=fs.readFileSync(file,'utf8');
assert.equal(csv,fs.readFileSync(path.join(OUT,'input.csv'),'utf8'),'摘要/附件合成身份不一致');
const download=downloadStarts.at(-1);assert(new URL(download.url).pathname==='/proxy/api/views/heartrate/export');assert(!download.url.includes('synthetic'));
// GET原生链接target=_blank,下载由Browser事件观测;POST在当前页另验真实method。
const method=mode==='server-only'?'GET (native anchor)':'POST';
if(mode==='server-priority')assert.equal(requests.filter(r=>r.url.includes('/heartrate/export?')).at(-1).method,'POST');
fs.renameSync(file,path.join(OUT,mode+'.csv'));report.downloads.push({mode,method,bytes:Buffer.byteLength(csv),rows:31});
}
// 真实代理故障和取消;取消只应留下当前范围的模型。
await evaluate("(async()=>{const saved=window.fetch;window.fetch=(url,options)=>url.includes('/views/stress?')?Promise.resolve(new Response('{}',{status:503})):saved(url,options);await switchTab('stress');window.fetch=saved;})()");
assert(await evaluate("$('healthView-stress').textContent.includes('查询失败')"));
await evaluate("(async()=>{const original=window.fetch;window.fetch=(url,opt)=>url.includes('/views/stress?')?new Promise(resolve=>setTimeout(()=>resolve(new Response(JSON.stringify({status:'error'}))),300)):original(url,opt);const old=switchTab('stress');await switchTab('body');await old;window.fetch=original;})()");
assert.equal(await evaluate('state.activeTab'),'body');assert(await evaluate('!!state.healthViews.body?.model'));
const raw=requests.filter(r=>/\/proxy\/api\/(heart-rate|sleep|workouts|body-measurements|spo2|stress|abnormal-heart-beat|summary|metric-series)(\?|$)/.test(r.url));assert.deepEqual(raw,[],'浏览器不应调用全量原始接口');
report.pageExceptions=errors;report.rawRequests=raw;report.requestCount=requests.length;
fs.writeFileSync(path.join(OUT,'report.json'),JSON.stringify(report,null,2)+'\n','utf8');
assert.deepEqual(errors,[]);
console.log(JSON.stringify(report,null,2));
}finally{if(cdp){await cdp.send('Browser.close').catch(()=>{});cdp.ws.close();}else edge.kill();}
}
main().catch(error=>{console.error(error);process.exitCode=1;});