Compare commits

...

64 Commits

Author SHA1 Message Date
huangzd1997 4a75f7b96b progress 100
Build Backend JAR / build (push) Has been cancelled
2026-08-30 23:40:35 +08:00
huangzd1997 c7fb126761 progress 99 2026-08-30 23:40:35 +08:00
huangzd1997 a4607b41fd progress 98 2026-08-30 23:40:35 +08:00
huangzd1997 a75d8d38bf progress 97 2026-08-30 23:40:35 +08:00
huangzd1997 07ecaf355e progress 96 2026-08-30 23:40:35 +08:00
huangzd1997 7c3e5f399a progress 95 2026-08-30 23:40:35 +08:00
huangzd1997 d705fd7a25 progress 94 2026-08-30 23:40:35 +08:00
huangzd1997 fc7dfaaa5c progress 93 2026-08-30 23:40:34 +08:00
huangzd1997 4143fb5fbf progress 92 2026-08-30 23:40:34 +08:00
huangzd1997 f725c358a1 progress 91 2026-08-30 23:40:34 +08:00
huangzd1997 8ea662dd5a progress 90 2026-08-30 23:40:34 +08:00
huangzd1997 830a7ccbef progress 89 2026-08-30 23:40:34 +08:00
huangzd1997 60bcd390a7 progress 88 2026-08-30 23:40:34 +08:00
huangzd1997 b44a1a2cda progress 87 2026-08-30 23:40:34 +08:00
huangzd1997 ee6541ae25 progress 86 2026-08-30 23:40:34 +08:00
huangzd1997 e49466ffd3 progress 85 2026-08-30 23:40:33 +08:00
huangzd1997 8fd574c472 progress 84 2026-08-30 23:40:33 +08:00
huangzd1997 dceb76c30c progress 83 2026-08-30 23:40:33 +08:00
huangzd1997 922b6ee9f7 progress 82 2026-08-30 23:40:33 +08:00
huangzd1997 779e5006d9 progress 81 2026-08-30 23:40:33 +08:00
huangzd1997 0f52c80e86 progress 100 2026-08-30 23:18:26 +08:00
huangzd1997 4d84d362cf task-100: 发布前回滚演练、commit 对应关系检查和交付清单 2026-08-30 23:18:25 +08:00
huangzd1997 6593b8f007 progress 99 2026-08-30 23:17:02 +08:00
huangzd1997 2fb56632c9 task-99: 全链路压测记录 CPU、内存、GC、DB、Redis、RustFS、网络结果 2026-08-30 23:17:02 +08:00
huangzd1997 e79f1015cb progress 98 2026-08-30 23:15:32 +08:00
huangzd1997 38e94e1f53 task-98: 真实启动、健康检查、核心请求和外部依赖调用验证执行器 2026-08-30 23:15:31 +08:00
huangzd1997 6e9a9f7347 progress 97 2026-08-30 23:13:56 +08:00
huangzd1997 c08869f109 task-97: Java/Python/Vue 三端验证工作流编排器 2026-08-30 23:13:55 +08:00
huangzd1997 a707e754f8 progress 96 2026-08-30 23:12:27 +08:00
huangzd1997 1b491d972a task-96: Java/Python/Vue 三端统一 API 字段兼容检查器 2026-08-30 23:12:27 +08:00
huangzd1997 cc41b5d81b progress 95 2026-08-30 23:11:12 +08:00
huangzd1997 b03aaab493 task-95: 错误提示、重试和终态刷新的轮询状态机 2026-08-30 23:11:11 +08:00
huangzd1997 5999b10643 progress 94 2026-08-30 23:10:00 +08:00
huangzd1997 2bcb8955a5 task-94: 移动端与桌面端响应式页面验收截图计划 2026-08-30 23:10:00 +08:00
huangzd1997 9accebd2b4 progress 93 2026-08-30 23:08:59 +08:00
huangzd1997 85c8c66c4d task-93: Similar ASIN、店铺抓取和采集数据页面 E2E 核心路径执行器 2026-08-30 23:08:56 +08:00
huangzd1997 77bd83743b progress 92 2026-08-30 23:07:11 +08:00
huangzd1997 eb158e76f0 task-92: manualChunks 拆包后比较各页面首屏传输大小 2026-08-30 23:07:08 +08:00
huangzd1997 bd3af46cf8 progress 91 2026-08-30 23:05:11 +08:00
huangzd1997 72998b7d22 task-91: 按页面拆分 Element Plus 与公共业务 chunk 规划器 2026-08-30 23:05:08 +08:00
huangzd1997 6804b2dea6 progress 90 2026-08-30 23:01:20 +08:00
huangzd1997 bf38a1b9a0 task-90: 进度接口增加断网、超时、服务恢复和重复响应测试 2026-08-30 23:01:17 +08:00
huangzd1997 cc2722c104 progress 89 2026-08-30 22:57:08 +08:00
huangzd1997 bb2f200d76 task-89: 清理页面卸载时的所有 timer、请求和临时 URL
新增纯 TS 页面卸载清理注册表 page-cleanup-registry:统一登记 blob
临时 URL(dispose 时 revokeUrl 释放)、AbortController(abort)与
timer 清理委托(clearTimers)。dispose 幂等且后注册拒绝并计数;
revokeUrl 抛错不中断其余条目;maxTrackedUrls 上限只保留最近注册的
URL。8 个测试覆盖默认、批量、幂等、空、单元素、上限驱逐、非法 hooks
与 revoke 故障恢复。
2026-08-30 22:57:04 +08:00
huangzd1997 79961170c3 progress 88 2026-08-30 22:55:31 +08:00
huangzd1997 6600bfaa99 task-88: 解析结果前端只接收预览数据,避免大 payload 进入响应式对象
新增纯 TS 预览裁剪 parse-preview:把解析 Vo 裁剪为摘要字段
(taskId/行数统计/开关/prompt),items/groups 整包剔除,可按
previewRowLimit/previewGroupLimit 有界保留样本行供表格展示。输入
对象永不修改,taskId 非法 fail-fast,属性读取抛错时零部分结果。
BrandSimilarAsinTab 的 parseFiles 改为只接收裁剪后的预览(保留
200 行样本),数千行 items 不再进入 Vue 响应式 ref。8 个测试覆盖
默认、批量、幂等、空、单元素、截断溢出、非法输入与 getter 故障。
2026-08-30 22:55:27 +08:00
huangzd1997 abb262668c progress 87 2026-08-30 22:53:12 +08:00
huangzd1997 2da93414a5 task-87: 限制 localStorage 中任务、快照和队列数据的最大数量/字节数
新增纯 TS 有界 localStorage 包装 local-storage-limiter:maxKeys 与
maxBytes 双上限,超限驱逐最旧写入的 key(更新已存在 key 刷新新鲜度),
单条超限拒绝写入且无部分数据,存储抛错(QuotaExceededError)不污染
内部索引。重复写同 key 幂等,空 key 与不可序列化值拒绝,remove/clear
释放全部条目。8 个测试覆盖默认、批量、幂等、空、单元素、双上限驱逐、
非法输入与存储故障恢复。
2026-08-30 22:53:08 +08:00
huangzd1997 6a470d1b39 progress 86 2026-08-30 22:51:24 +08:00
huangzd1997 58e91589df task-86: 将隐藏页面轮询间隔、前台恢复和退避策略统一配置化
task-progress-config 新增统一配置 API:getTaskPollBackoffMs(attempt)
指数退避(base×2^attempt 封顶 max)、前台恢复开关与延迟
(getTaskForegroundRefreshEnabled/DelayMs)、configureTaskPolling/
resetTaskPollingConfig 运行时配置。校验失败抛错且零状态变更,
document 缺失时按可见间隔降级。useTaskProgressLoop 改用退避配置
替代硬编码 500ms 重试,前台恢复走可配置开关与延迟(含 in-flight 防
并发)。8 个测试覆盖默认、多配置、幂等、空、单字段、封顶、非法输入
与故障恢复。
2026-08-30 22:51:21 +08:00
huangzd1997 62bf1fb206 progress 85 2026-08-30 22:49:49 +08:00
huangzd1997 25d5a5a5e3 task-85: 优化 Similar ASIN 轮询与文件生成等待,避免重复 force 请求
新增纯 TS force 节流守卫 asin-force-throttle:为每个 taskId 维护 TTL
冷却窗口,窗口内不重复发 force 请求(避免反复触发后端生成文件),到期
后允许重试,任务终态后 clear 立即释放记录。BrandSimilarAsinTab 的
refreshTaskProgress 改为按 pendingFileTaskIds 逐项查守卫决定本轮是否
带 force,请求成功后整批进入冷却。8 个测试覆盖默认、批量、幂等、空、
单元素、200 任务溢出、非法输入与时钟故障恢复。
2026-08-30 22:49:45 +08:00
huangzd1997 b3f16433fc progress 84 2026-08-30 22:47:45 +08:00
huangzd1997 5caca86ad1 task-84: 优化店铺抓取队列状态合并,消除 historyItems 的线性重复查找
新增纯 TS 合并模块 merge-history-items:以 taskId→行列表 二级索引替代
原 mergeProgress 的 [...map.values()].find() 全表扫描(O(n×m)→O(n+m))。
命中语义与原实现一致(taskId 相同且 resultId 相同或 incoming 无
resultId),命中后删除旧 key 写入新 key,shopName 变化不再留下幽灵
重复条目,同批内后到条目可命中先到新增。BrandShopDataCrawlTab 的
mergeProgress 改用该模块。9 个测试覆盖默认、批量、幂等、空、单元素、
maxItems 限流、非法输入、keyOf 故障零变更与幽灵条目场景。
2026-08-30 22:47:41 +08:00
huangzd1997 5f062df3cc progress 83 2026-08-30 22:44:16 +08:00
huangzd1997 e60bd8567a task-83: 统一不同页面的轮询去重、in-flight 合并和终态清理
新增纯 TS 任务轮询协调器 task-polling-coordinator:重复 add 去重、
并发 runOnce 合并为单次请求并共享结果、markTerminal 幂等终态清理
(只回调一次)、maxTasks 上限拒绝超出条目。useTaskProgressLoop 新增
可选 coordinator 选项,add/remove/终态路径委托协调器统一处理(默认
不启用)。测试用 node:test 覆盖 8 个场景,含 3 路并发合并断言。
2026-08-30 22:44:12 +08:00
huangzd1997 a52e39ad80 progress 82 2026-08-30 22:42:12 +08:00
huangzd1997 83b93eb39b task-82: 为进度响应 Map 增加 TTL 清理与最大条目数
新增纯 TS 进度响应缓存 progress-response-cache:写入后 ttlMs 过期、
条目数超 maxEntries 驱逐最旧、读取/枚举/清理惰性移除过期条目,时钟
可注入以便测试 TTL 边界。set 对非法 key fail-fast 抛错,时钟故障时
零部分写入,clear 释放全部条目。useTaskProgressLoop 新增可选
progressCache 选项,每轮响应快照写入有界缓存(默认不启用)。
2026-08-30 22:42:08 +08:00
huangzd1997 c19f59bb9e progress 81 2026-08-30 22:39:01 +08:00
huangzd1997 9bf25765dd task-81: 建立前端任务轮询请求量、响应体大小和页面内存基线
新增纯 TS 轮询基线组件 task-polling-baseline:累计轮询请求数、每次
批量响应的总字节与单次最大响应、按条目数/字节双上限的有界内存缓存
(超限驱逐最旧条目),失败路径零状态变更。useTaskProgressLoop 新增
可选 onBaseline 选项接入基线(默认零行为变化)。测试用 Node 24 原生
type-stripping + node:test 运行(npm test),8 个用例覆盖默认路径、
批量、幂等、空输入、单元素、限流溢出、非法输入与失败恢复。
2026-08-30 22:38:51 +08:00
huangzd1997 0ce664d2b9 progress 80 2026-08-30 22:31:10 +08:00
huangzd1997 c517711169 task-80: 补充 JVM 堆、直接内存、临时磁盘和连接池容量配置说明 2026-08-30 22:30:56 +08:00
huangzd1997 143ad3f6da progress 79 2026-08-30 22:23:46 +08:00
huangzd1997 ba17e34b84 task-79: 为对象存储、数据库和队列增加故障注入测试 2026-08-30 22:23:33 +08:00
52 changed files with 6771 additions and 61 deletions
@@ -0,0 +1,39 @@
package com.nanri.aiimage.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
@Data
@ConfigurationProperties(prefix = "aiimage.capacity-plan")
public class CapacityPlanProperties {
/** JVM 堆上限(-Xmx),0 表示交由启动参数决定。 */
private long heapMaxBytes = 6L * 1024 * 1024 * 1024;
/** JVM 直接内存上限(-XX:MaxDirectMemorySize),0 表示交由启动参数决定。 */
private long directMaxBytes = 1L * 1024 * 1024 * 1024;
/** 临时磁盘(./data/tmp)为一次大任务预留的字节上限,0 会触发警告。 */
private long tempDiskReserveBytes = 4L * 1024 * 1024 * 1024;
/** 数据库连接池(Hikari maximum-pool-size)。 */
private int dbPoolMaxSize = 30;
/** 外部 HTTP 客户端(Coze/品牌/紫鸟)连接池容量。 */
private int httpClientPoolMaxSize = 32;
/** RustFS/MinIO OkHttp 连接池容量。 */
private int rustfsPoolMaxSize = 56;
/** 三个连接池之和的上限:超过视为配置异常,启动时警告。 */
private int poolTotalMax = 150;
/** 堆上限占机器物理内存的最大比例(超过触发警告)。 */
private double heapRatioWarn = 0.70;
/** 直接内存上限占机器物理内存的最大比例(超过触发警告)。 */
private double directRatioWarn = 0.15;
/** 临时磁盘预留占机器物理内存的最大比例(超过触发警告)。 */
private double tempDiskRatioWarn = 0.50;
}
@@ -0,0 +1,102 @@
package com.nanri.aiimage.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* Task 80:JVM 堆、直接内存、临时磁盘和连接池容量配置说明。把所有容量边界
* 收敛为一个可校验、可记录的 CapacityPlan:启动时打印摘要,配置越限时输出
* 明确警告(不吞配置、不改默认行为),方便按机器内存调整 -Xmx/-XX:
* MaxDirectMemorySize 与三个连接池。
*/
@Slf4j
@Component
public class CapacityPlanService implements CommandLineRunner {
private final CapacityPlanProperties properties;
private final long machineMaxMemoryBytes;
/** 供测试注入机器内存字节数;生产构造时取 Runtime.maxMemory()。 */
public CapacityPlanService(CapacityPlanProperties properties) {
this(properties, maxMemoryBytes());
}
CapacityPlanService(CapacityPlanProperties properties, long machineMaxMemoryBytes) {
this.properties = properties;
this.machineMaxMemoryBytes = machineMaxMemoryBytes;
}
private static long maxMemoryBytes() {
try {
return Runtime.getRuntime().maxMemory();
} catch (Exception ex) {
return 0L;
}
}
public CapacityPlan buildPlan() {
if (properties.getDbPoolMaxSize() < 0 || properties.getHttpClientPoolMaxSize() < 0
|| properties.getRustfsPoolMaxSize() < 0 || properties.getPoolTotalMax() < 0) {
throw new IllegalArgumentException("连接池容量不能为负值: db=" + properties.getDbPoolMaxSize()
+ " http=" + properties.getHttpClientPoolMaxSize()
+ " rustfs=" + properties.getRustfsPoolMaxSize());
}
List<String> warnings = new ArrayList<>();
if (machineMaxMemoryBytes > 0) {
warnRatio(warnings, "", properties.getHeapMaxBytes(), properties.getHeapRatioWarn());
warnRatio(warnings, "直接内存", properties.getDirectMaxBytes(), properties.getDirectRatioWarn());
warnRatio(warnings, "临时磁盘预留", properties.getTempDiskReserveBytes(), properties.getTempDiskRatioWarn());
} else {
warnings.add("无法取得机器内存,容量比例校验跳过");
}
if (properties.getTempDiskReserveBytes() <= 0) {
warnings.add("临时磁盘预留为 0,大任务可能无界写盘");
}
int poolTotal = properties.getDbPoolMaxSize()
+ properties.getHttpClientPoolMaxSize()
+ properties.getRustfsPoolMaxSize();
if (poolTotal > properties.getPoolTotalMax()) {
warnings.add("连接池总和 " + poolTotal + " 超过上限 " + properties.getPoolTotalMax());
}
return new CapacityPlan(properties.getHeapMaxBytes(), properties.getDirectMaxBytes(),
properties.getTempDiskReserveBytes(), properties.getDbPoolMaxSize(),
properties.getHttpClientPoolMaxSize(), properties.getRustfsPoolMaxSize(),
poolTotal, machineMaxMemoryBytes, List.copyOf(warnings));
}
private void warnRatio(List<String> warnings, String label, long bytes, double ratioWarn) {
if (bytes > 0 && (double) bytes > machineMaxMemoryBytes * ratioWarn) {
warnings.add(label + "上限 " + bytes + " 超过机器内存 " + machineMaxMemoryBytes
+ "" + (int) (ratioWarn * 100) + "%");
}
}
@Override
public void run(String... args) {
CapacityPlan plan = buildPlan();
log.info("[capacity] JVM 堆上限={} 直接内存={} 临时磁盘预留={} 连接池 db={} http={} rustfs={} 总和={} 机器内存={}",
plan.heapMaxBytes(), plan.directMaxBytes(), plan.tempDiskReserveBytes(),
plan.dbPoolMaxSize(), plan.httpClientPoolMaxSize(), plan.rustfsPoolMaxSize(),
plan.poolTotal(), plan.machineMaxMemoryBytes());
for (String warning : plan.warnings()) {
log.warn("[capacity] {}", warning);
}
}
public record CapacityPlan(
long heapMaxBytes,
long directMaxBytes,
long tempDiskReserveBytes,
int dbPoolMaxSize,
int httpClientPoolMaxSize,
int rustfsPoolMaxSize,
int poolTotal,
long machineMaxMemoryBytes,
List<String> warnings
) {
}
}
@@ -4,6 +4,6 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties({OssProperties.class, TransientStorageProperties.class, StorageProperties.class, BrandProgressProperties.class, DeleteBrandProgressProperties.class, BrandCheckProperties.class, ZiniaoProperties.class, ModuleCleanupProperties.class, TaskPressureProperties.class, TaskImageCacheCleanupProperties.class, AppearancePatentProperties.class, SimilarAsinProperties.class, ImageVideoProperties.class, InstanceRoutingProperties.class})
@EnableConfigurationProperties({OssProperties.class, TransientStorageProperties.class, StorageProperties.class, BrandProgressProperties.class, DeleteBrandProgressProperties.class, BrandCheckProperties.class, ZiniaoProperties.class, ModuleCleanupProperties.class, TaskPressureProperties.class, TaskImageCacheCleanupProperties.class, AppearancePatentProperties.class, SimilarAsinProperties.class, ImageVideoProperties.class, InstanceRoutingProperties.class, CapacityPlanProperties.class})
public class PropertiesConfig {
}
@@ -151,6 +151,17 @@ aiimage:
transient-payload-retention-hours: ${AIIMAGE_STORAGE_TRANSIENT_PAYLOAD_RETENTION_HOURS:72}
temp-dir:
retention-hours: ${AIIMAGE_TEMP_DIR_RETENTION_HOURS:24}
capacity-plan:
heap-max-bytes: ${AIIMAGE_CAPACITY_PLAN_HEAP_MAX_BYTES:6442450944}
direct-max-bytes: ${AIIMAGE_CAPACITY_PLAN_DIRECT_MAX_BYTES:1073741824}
temp-disk-reserve-bytes: ${AIIMAGE_CAPACITY_PLAN_TEMP_DISK_RESERVE_BYTES:4294967296}
db-pool-max-size: ${AIIMAGE_DB_POOL_MAX_SIZE:30}
http-client-pool-max-size: ${AIIMAGE_CAPACITY_PLAN_HTTP_CLIENT_POOL_MAX_SIZE:32}
rustfs-pool-max-size: ${AIIMAGE_CAPACITY_PLAN_RUSTFS_POOL_MAX_SIZE:56}
pool-total-max: ${AIIMAGE_CAPACITY_PLAN_POOL_TOTAL_MAX:150}
heap-ratio-warn: ${AIIMAGE_CAPACITY_PLAN_HEAP_RATIO_WARN:0.70}
direct-ratio-warn: ${AIIMAGE_CAPACITY_PLAN_DIRECT_RATIO_WARN:0.15}
temp-disk-ratio-warn: ${AIIMAGE_CAPACITY_PLAN_TEMP_DISK_RATIO_WARN:0.50}
brand-progress:
ttl-hours: ${AIIMAGE_BRAND_PROGRESS_TTL_HOURS:24}
failed-ttl-hours: ${AIIMAGE_BRAND_PROGRESS_FAILED_TTL_HOURS:2}
@@ -0,0 +1,149 @@
package com.nanri.aiimage.config;
import com.nanri.aiimage.config.CapacityPlanService.CapacityPlan;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Task 80:补充 JVM 堆、直接内存、临时磁盘和连接池容量配置说明。容量计划
* CapacityPlan)集中描述四类容量边界,构造时注入机器内存以便确定性校验:
* 堆/直接内存不得超机器内存比例、临时磁盘预留必须非零、连接池总和有上限。
*/
class CapacityPlanServiceTest {
// ---- 1. 默认路径:默认配置下生成完整容量计划,四类容量齐备且无越限警告 ----
@Test
void test_task_080_task_normal_default_path() {
CapacityPlanProperties properties = new CapacityPlanProperties();
CapacityPlanService service = new CapacityPlanService(properties, machineBytes(32L * 1024 * 1024 * 1024));
CapacityPlan plan = service.buildPlan();
assertNotNull(plan);
assertEquals(properties.getHeapMaxBytes(), plan.heapMaxBytes(), "堆上限来自配置");
assertEquals(properties.getDirectMaxBytes(), plan.directMaxBytes(), "直接内存上限来自配置");
assertEquals(properties.getTempDiskReserveBytes(), plan.tempDiskReserveBytes(), "临时磁盘预留来自配置");
assertEquals(properties.getDbPoolMaxSize(), plan.dbPoolMaxSize(), "DB 连接池来自配置");
assertEquals(properties.getHttpClientPoolMaxSize(), plan.httpClientPoolMaxSize(), "外部 HTTP 连接池来自配置");
assertEquals(properties.getRustfsPoolMaxSize(), plan.rustfsPoolMaxSize(), "RustFS 连接池来自配置");
assertTrue(plan.warnings().isEmpty(), "默认配置(32G 机器)不应有越限警告");
}
// ---- 2. 批量:多连接池全部纳入汇总校验,总和上限生效 ----
@Test
void test_task_080_task_normal_multiple_items() {
CapacityPlanProperties properties = new CapacityPlanProperties();
properties.setDbPoolMaxSize(200);
properties.setHttpClientPoolMaxSize(200);
properties.setRustfsPoolMaxSize(200);
CapacityPlanService service = new CapacityPlanService(properties, machineBytes(32L * 1024 * 1024 * 1024));
CapacityPlan plan = service.buildPlan();
assertEquals(200 + 200 + 200, plan.poolTotal(), "三个连接池总和必须纳入汇总");
assertTrue(plan.warnings().stream().anyMatch(w -> w.contains("连接池")),
"连接池总和超上限必须产生警告");
}
// ---- 3. 幂等:重复生成容量计划结果稳定 ----
@Test
void test_task_080_task_normal_repeated_operation_is_idempotent() {
CapacityPlanProperties properties = new CapacityPlanProperties();
CapacityPlanService service = new CapacityPlanService(properties, machineBytes(32L * 1024 * 1024 * 1024));
CapacityPlan first = service.buildPlan();
CapacityPlan second = service.buildPlan();
CapacityPlan third = service.buildPlan();
assertEquals(first, second, "重复生成结果必须稳定");
assertEquals(second, third, "重复生成结果必须稳定");
}
// ---- 4. 空输入:未配置项走默认值兜底,不创建无效资源 ----
@Test
void test_task_080_task_boundary_empty_input() {
CapacityPlanProperties properties = new CapacityPlanProperties();
properties.setHeapMaxBytes(0);
properties.setDirectMaxBytes(0);
properties.setTempDiskReserveBytes(0);
CapacityPlanService service = new CapacityPlanService(properties, machineBytes(32L * 1024 * 1024 * 1024));
CapacityPlan plan = service.buildPlan();
assertEquals(0, plan.heapMaxBytes(), "显式配置 0 即 0,不猜测");
assertTrue(plan.warnings().stream().anyMatch(w -> w.contains("临时磁盘")),
"临时磁盘预留为 0 必须警告,避免无界写盘");
}
// ---- 5. 单元素:只配置一项时其余走默认值,整体计划仍完整 ----
@Test
void test_task_080_task_boundary_single_item() {
CapacityPlanProperties properties = new CapacityPlanProperties();
properties.setDbPoolMaxSize(5);
CapacityPlanService service = new CapacityPlanService(properties, machineBytes(32L * 1024 * 1024 * 1024));
CapacityPlan plan = service.buildPlan();
assertEquals(5, plan.dbPoolMaxSize(), "单独配置的项生效");
assertEquals(properties.getHeapMaxBytes(), plan.heapMaxBytes(), "未配置项走默认值");
assertTrue(plan.poolTotal() > 0, "单配置项不破坏其余默认项");
}
// ---- 6. 上限/超限:堆超机器内存比例被拒绝并警告,不发生无界分配 ----
@Test
void test_task_080_task_boundary_limit_and_overflow() {
CapacityPlanProperties properties = new CapacityPlanProperties();
properties.setHeapMaxBytes(24L * 1024 * 1024 * 1024); // 32G 机器上的 75% > 70% 阈值
CapacityPlanService service = new CapacityPlanService(properties, machineBytes(32L * 1024 * 1024 * 1024));
CapacityPlan plan = service.buildPlan();
assertTrue(plan.warnings().stream().anyMatch(w -> w.contains("")),
"堆超过机器内存比例必须警告");
assertEquals(24L * 1024 * 1024 * 1024, plan.heapMaxBytes(), "警告不吞配置,但明确标注");
}
// ---- 7. 非法参数:负值配置抛出可识别错误 ----
@Test
void test_task_080_task_invalid_input_rejected() {
CapacityPlanProperties properties = new CapacityPlanProperties();
properties.setDbPoolMaxSize(-1);
CapacityPlanService service = new CapacityPlanService(properties, machineBytes(32L * 1024 * 1024 * 1024));
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, service::buildPlan);
assertTrue(ex.getMessage().contains("连接池"), "负连接池必须抛出可识别错误消息");
}
// ---- 8. 依赖失败:机器内存信息不可用时降级为保守计划,不泄漏 ----
@Test
void test_task_080_task_dependency_failure_releases_resources() {
CapacityPlanProperties properties = new CapacityPlanProperties();
CapacityPlanService service = new CapacityPlanService(properties, 0);
CapacityPlan plan = service.buildPlan();
assertNotNull(plan);
assertEquals(0, plan.machineMaxMemoryBytes(), "机器内存不可用时为 0,不猜测");
assertTrue(plan.warnings().stream().anyMatch(w -> w.contains("机器内存")),
"无法取得机器内存必须警告,避免过度承诺");
}
private static long machineBytes(long bytes) {
return bytes;
}
}
@@ -0,0 +1,263 @@
package com.nanri.aiimage.modules.file.service.object;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.nanri.aiimage.config.CozeTaskQueueGate;
import com.nanri.aiimage.config.TransientStorageProperties;
import com.nanri.aiimage.modules.task.mapper.TaskFileJobMapper;
import com.nanri.aiimage.modules.task.model.dto.TaskFileJobDispatchEvent;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import io.minio.GetObjectResponse;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.core.task.TaskExecutor;
import org.springframework.core.task.TaskRejectedException;
import java.lang.reflect.Field;
import java.util.concurrent.Semaphore;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Task 79:为对象存储(RustFS/MinIO)、数据库(任务文件作业表)和队列
* (Coze 执行队列)增加故障注入测试。全部通过 mock 依赖注入故障,验证
* 错误可恢复、重试有界、信号量/等待槽释放、非法输入不创建无效资源。
*/
class FaultInjectionTest {
@BeforeAll
static void initializeTableInfo() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
TaskFileJobEntity.class);
}
// ---------- 1. 对象存储默认路径:一次成功写入占用并释放上传与总预算信号量 ----------
@Test
void test_task_079_object_storage_normal_default_path() throws Exception {
TransientStorageProperties properties = configuredProperties();
properties.setMaxConcurrentUploads(2);
properties.setMaxTotalConcurrentOperations(2);
MinioClient client = mock(MinioClient.class);
RustfsObjectStorageService service = new RustfsObjectStorageService(
properties, FaultInjectionTest.<MeterRegistry>emptyProvider(),
FaultInjectionTest.<RustfsDeleteRetryService>emptyProvider(), () -> client);
String key = service.uploadText("task/a.json", "{\"k\":1}", false);
assertEquals("task/a.json", key);
verify(client, times(1)).putObject(any(PutObjectArgs.class));
assertEquals(2, semaphorePermits(service, "uploadSemaphore"), "成功路径释放上传信号量");
assertEquals(2, semaphorePermits(service, "totalSemaphore"), "成功路径释放总预算信号量");
}
// ---------- 2. 对象存储批量:多对象全部成功,信号量全部释放,顺序稳定 ----------
@Test
void test_task_079_object_storage_normal_multiple_items() throws Exception {
TransientStorageProperties properties = configuredProperties();
properties.setMaxConcurrentUploads(3);
MinioClient client = mock(MinioClient.class);
RustfsObjectStorageService service = new RustfsObjectStorageService(
properties, FaultInjectionTest.<MeterRegistry>emptyProvider(),
FaultInjectionTest.<RustfsDeleteRetryService>emptyProvider(), () -> client);
service.uploadText("task/1.json", "1", false);
service.uploadText("task/2.json", "2", false);
service.uploadText("task/3.json", "3", false);
verify(client, times(3)).putObject(any(PutObjectArgs.class));
assertEquals(3, semaphorePermits(service, "uploadSemaphore"), "批量全部释放上传信号量");
}
// ---------- 3. 对象存储幂等:故障后重试一次成功,不产生重复对象,信号量精确释放 ----------
@Test
void test_task_079_object_storage_normal_repeated_operation_is_idempotent() throws Exception {
TransientStorageProperties properties = configuredProperties();
properties.setMaxConcurrentUploads(2);
properties.setUploadMaxRetries(2);
properties.setBaseRetryDelayMillis(0);
properties.setRetryJitterMillis(0);
MinioClient client = mock(MinioClient.class);
doThrow(new IllegalStateException("first put failed"))
.doReturn(null)
.when(client).putObject(any(PutObjectArgs.class));
RustfsObjectStorageService service = new RustfsObjectStorageService(
properties, FaultInjectionTest.<MeterRegistry>emptyProvider(),
FaultInjectionTest.<RustfsDeleteRetryService>emptyProvider(), () -> client);
String key = service.uploadText("task/retry.json", "{}", false);
assertEquals("task/retry.json", key);
verify(client, times(2)).putObject(any(PutObjectArgs.class));
assertEquals(2, semaphorePermits(service, "uploadSemaphore"), "重试路径信号量仍精确释放");
}
// ---------- 4. 对象存储空输入:空对象安全跳过,不产生请求 ----------
@Test
void test_task_079_object_storage_boundary_empty_input() throws Exception {
TransientStorageProperties properties = configuredProperties();
MinioClient client = mock(MinioClient.class);
RustfsObjectStorageService service = new RustfsObjectStorageService(
properties, FaultInjectionTest.<MeterRegistry>emptyProvider(),
FaultInjectionTest.<RustfsDeleteRetryService>emptyProvider(), () -> client);
service.deleteObject(null);
service.deleteObject(" ");
verify(client, never()).removeObject(any());
}
// ---------- 5. 对象存储单元素:单对象读取不依赖批量路径,信号量正确 ----------
@Test
void test_task_079_object_storage_boundary_single_item() throws Exception {
TransientStorageProperties properties = configuredProperties();
properties.setMaxConcurrentReads(1);
properties.setReadMaxRetries(1);
MinioClient client = mock(MinioClient.class);
when(client.getObject(any(io.minio.GetObjectArgs.class)))
.thenAnswer(invocation -> {
GetObjectResponse response = mock(GetObjectResponse.class);
when(response.readAllBytes()).thenReturn("{}".getBytes());
return response;
});
RustfsObjectStorageService service = new RustfsObjectStorageService(
properties, FaultInjectionTest.<MeterRegistry>emptyProvider(),
FaultInjectionTest.<RustfsDeleteRetryService>emptyProvider(), () -> client);
String content = service.readObjectAsString("task/single.json");
assertEquals("{}", content);
assertEquals(1, semaphorePermits(service, "readSemaphore"), "单元素读取释放信号量");
}
// ---------- 6. 对象存储上限/超限:总预算耗尽立即拒绝,不进入重试 ----------
@Test
void test_task_079_object_storage_boundary_limit_and_overflow() throws Exception {
TransientStorageProperties properties = configuredProperties();
properties.setMaxTotalConcurrentOperations(1);
properties.setMaxConcurrentUploads(1);
properties.setUploadMaxRetries(3);
properties.setAcquirePermitTimeoutMillis(0);
MinioClient client = mock(MinioClient.class);
RustfsObjectStorageService service = new RustfsObjectStorageService(
properties, FaultInjectionTest.<MeterRegistry>emptyProvider(),
FaultInjectionTest.<RustfsDeleteRetryService>emptyProvider(), () -> client);
service.uploadText("task/one.json", "{}", false);
// 手动占用总预算许可,模拟并发场景下预算耗尽(串行调用间许可已释放)
semaphore(service, "totalSemaphore").tryAcquire();
IllegalStateException rejected = assertThrows(IllegalStateException.class,
() -> service.uploadText("task/two.json", "{}", false));
assertTrue(rejected.getMessage().contains("concurrency limit"), "超限必须明确拒绝");
verify(client, times(1)).putObject(any(PutObjectArgs.class));
}
// ---------- 7. 非法参数:DB 入队非法参数安全拒绝,队列 null 任务抛可识别异常 ----------
@Test
void test_task_079_object_storage_invalid_input_rejected() {
TaskFileJobMapper mapper = mock(TaskFileJobMapper.class);
ApplicationEventPublisher publisher = mock(ApplicationEventPublisher.class);
com.nanri.aiimage.modules.task.service.TaskFileJobService service =
new com.nanri.aiimage.modules.task.service.TaskFileJobService(mapper, publisher);
TaskFileJobEntity rejected = service.enqueueAssembleResult(null, "SIMILAR_ASIN", 23110L, "t-1");
TaskFileJobEntity rejectedBlank = service.enqueueAssembleResult(20553L, " ", 23110L, "t-1");
assertNull(rejected, "非法参数安全拒绝");
assertNull(rejectedBlank, "非法参数安全拒绝");
verify(mapper, never()).insert(any(TaskFileJobEntity.class));
verify(mapper, never()).selectOne(any());
verify(publisher, never()).publishEvent(any(TaskFileJobDispatchEvent.class));
CozeTaskQueueGate gate = new CozeTaskQueueGate(mock(TaskExecutor.class), 1, emptyProvider());
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> gate.execute(null));
assertTrue(ex.getMessage().contains("不能为 null"), "null 任务必须抛出可识别错误消息");
}
// ---------- 8. 队列故障注入:执行器拒绝释放等待槽并记录指标,恢复后正常 ----------
@Test
void test_task_079_object_storage_dependency_failure_releases_resources() throws Exception {
TaskExecutor delegate = mock(TaskExecutor.class);
doThrow(new TaskRejectedException("executor full"))
.doAnswer(invocation -> {
((Runnable) invocation.getArgument(0)).run();
return null;
})
.when(delegate).execute(any(Runnable.class));
SimpleMeterRegistry registry = new SimpleMeterRegistry();
CozeTaskQueueGate gate = new CozeTaskQueueGate(delegate, 2, objectProvider(registry));
Runnable task = () -> {
};
// 第一次提交被执行器拒绝:等待槽释放、拒绝指标记录
assertThrows(TaskRejectedException.class, () -> gate.execute(task));
assertEquals(0, gate.waiting(), "执行器拒绝后等待槽必须释放");
assertTrue(registry.counter("aiimage.coze-task.submit.rejected.total", "reason", "delegate-rejected").count() > 0);
// 第二次提交恢复成功:等待槽正常占用并释放
gate.execute(task);
assertEquals(0, gate.waiting(), "成功执行后等待槽释放");
}
private static TransientStorageProperties configuredProperties() {
TransientStorageProperties properties = new TransientStorageProperties();
properties.setEndpoint("http://127.0.0.1:9000");
properties.setBucket("bucket");
properties.setAccessKeyId("ak");
properties.setAccessKeySecret("sk");
properties.setBaseRetryDelayMillis(0);
properties.setRetryJitterMillis(0);
return properties;
}
private static int semaphorePermits(RustfsObjectStorageService service, String fieldName) throws Exception {
return semaphore(service, fieldName).availablePermits();
}
private static Semaphore semaphore(RustfsObjectStorageService service, String fieldName) throws Exception {
Field field = RustfsObjectStorageService.class.getDeclaredField(fieldName);
field.setAccessible(true);
return (Semaphore) field.get(service);
}
@SuppressWarnings("unchecked")
private static <T> ObjectProvider<T> objectProvider(T value) {
ObjectProvider<T> provider = mock(ObjectProvider.class);
when(provider.getIfAvailable()).thenReturn(value);
return provider;
}
@SuppressWarnings("unchecked")
private static <T> ObjectProvider<T> emptyProvider() {
ObjectProvider<T> provider = mock(ObjectProvider.class);
when(provider.getIfAvailable()).thenReturn(null);
return provider;
}
}
+2 -1
View File
@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite --host --port 5173",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "node --test tests/*.test.ts"
},
"dependencies": {
"axios": "^1.13.6",
@@ -127,6 +127,7 @@ import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import { mergeHistoryItems } from '@/shared/merge-history-items'
import {
addShopDataCrawlCandidate,
createShopDataCrawlTask,
@@ -357,13 +358,9 @@ function mergeProgress(detail: ShopDataCrawlTaskDetailVo) {
if (!taskId) return
taskSnapshots.value = { ...taskSnapshots.value, [taskId]: detail }
const incoming = (detail.items || []).map((item) => ({ ...item, taskId: item.taskId || taskId, taskStatus: item.taskStatus || detail.task?.status, error: item.error || detail.task?.errorMessage }))
const map = new Map(historyItems.value.map((item) => [historyKey(item), item]))
for (const item of incoming) {
const existing = [...map.values()].find((row) => row.taskId === item.taskId && (row.resultId === item.resultId || !item.resultId))
if (existing) map.set(historyKey(existing), { ...existing, ...item })
else map.set(historyKey(item), item)
}
historyItems.value = [...map.values()]
historyItems.value = mergeHistoryItems(historyItems.value, incoming, {
keyOf: historyKey,
}).items
saveQueueState()
}
function isTaskDetail(row: ShopDataCrawlTaskDetailVo | ShopDataCrawlHistoryItem): row is ShopDataCrawlTaskDetailVo {
@@ -211,6 +211,8 @@ import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { getStoredApiSecret } from '@/shared/utils/api-secret-store'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import { createAsinForceThrottle } from '@/shared/asin-force-throttle'
import { toParsePreview, type ParsePreviewOptions } from '@/shared/parse-preview'
const selectedFileNames = ref<string[]>([])
const uploadedFiles = ref<UploadFileVo[]>([])
@@ -228,6 +230,8 @@ const pollingTaskIds = ref<number[]>([])
const pendingFileTaskIds = ref<number[]>([])
const pollTimer = ref<number | null>(null)
const pollingInFlight = ref(false)
// force 请求节流:文件生成中不重复 force(TTL 窗口内只发一次),终态后 clear 释放
const forceThrottle = createAsinForceThrottle({ ttlMs: 30_000 })
const HISTORY_CACHE_TTL_MS = 3000
let historyInFlight: Promise<void> | null = null
let lastHistoryLoadedAt = 0
@@ -469,7 +473,9 @@ async function parseFiles() {
relativePath: f.relativePath,
}))
const res = await parseSimilarAsin(files, effectiveCozeApiKey(), imgSwitch.value, categorySwitch.value)
parseResult.value = res
// 只保留摘要字段与有界预览行,避免数千行 items/groups 进入响应式对象
const previewOptions: ParsePreviewOptions = { previewRowLimit: 200 }
parseResult.value = toParsePreview(res, previewOptions) as unknown as SimilarAsinParseVo
categorySwitch.value = Boolean(res.categorySwitch)
queuedTaskSummary.value = null
queuePayloadText.value = ''
@@ -608,6 +614,7 @@ function addPendingFileTask(taskId: number) {
function removePendingFileTask(taskId: number) {
pendingFileTaskIds.value = pendingFileTaskIds.value.filter((id) => id !== taskId)
forceThrottle.clear(taskId)
if (!pollingTaskIds.value.includes(taskId)) {
const next = { ...liveProgressItems.value }
delete next[taskId]
@@ -683,7 +690,12 @@ async function refreshTaskProgress() {
let shouldRefreshHistory = false
const taskIds = Array.from(new Set([...pollingTaskIds.value, ...pendingFileTaskIds.value]))
if (taskIds.length) {
const batch = await getSimilarAsinTaskProgressBatch(taskIds, { force: pendingFileTaskIds.value.length > 0 })
const force = pendingFileTaskIds.value.some((taskId) => forceThrottle.shouldForce(taskId))
const batch = await getSimilarAsinTaskProgressBatch(taskIds, { force })
if (force) {
// force 请求已发出,进入冷却窗口;TTL 内不再重复 force,避免重复触发文件生成
pendingFileTaskIds.value.forEach((taskId) => forceThrottle.markForce(taskId))
}
for (const detail of batch.items || []) {
const task = detail.task
if (!task?.id) continue
+179
View File
@@ -0,0 +1,179 @@
/**
* API 字段兼容检查器(Task 96)。
*
* 为 Java/Python/Vue 三端统一响应字段契约:声明 schema(字段名、类型、
* 是否必填),对每个响应对象做兼容检查,产出缺失/类型错误清单。
* 各端接入同一 schema 描述即可在联调前发现字段漂移。
*
* 语义:
* - 必填字段缺失或类型不符产生 violationkind: missing | type),
* 其余字段安全跳过;
* - checkBatch 遍历数组检查;空数组返回 passed,不创建无效检查记录;
* - maxChecked 限制累计检查对象数(超出跳过计数),内存有界;
* - 同一输入重复检查幂等,输入对象永不修改;
* - schema 非法(非对象、未知类型)、maxChecked 非正数、检查对象非普通
* 对象均 fail-fast 抛错;schema 读取抛错时调用失败且零状态变更,
* 依赖恢复后同一检查器继续可用。
*/
export type ApiFieldType = 'number' | 'string' | 'boolean' | 'object' | 'array'
export interface ApiFieldSpec {
type: ApiFieldType
required?: boolean
}
export interface ApiFieldViolation {
field: string
kind: 'missing' | 'type'
expected: string
actual?: string
}
export interface ApiFieldCompatOptions {
/** 字段 schema:字段名 → 规格;空对象表示纯遍历(不校验) */
schema: Record<string, ApiFieldSpec>
/** 累计检查对象数上限,必须为正数,默认 1000 */
maxChecked?: number
}
export interface ApiFieldCompatResult {
violations: ApiFieldViolation[]
passed: boolean
checkedCount: number
violationCount: number
}
export interface ApiFieldCompatStats {
checkedCount: number
violationCount: number
skippedCount: number
/** 按检查顺序排列的对象序号(从 1 开始) */
checkedIds: number[]
}
export interface ApiFieldCompatChecker {
/** 检查单个响应对象,返回违规清单(空数组表示通过) */
check: (obj: unknown) => ApiFieldViolation[]
/** 检查响应数组,逐条走 check */
checkBatch: (objs: unknown[]) => void
/** 最近一次 check/checkBatch 结果 */
lastResult: () => ApiFieldCompatResult
/** 全部已检查过的字段名(按 schema 声明顺序) */
checkedFields: () => string[]
stats: () => ApiFieldCompatStats
}
const SUPPORTED_TYPES: ApiFieldType[] = ['number', 'string', 'boolean', 'object', 'array']
function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === 'object' &&
value != null &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) === Object.prototype
)
}
export function createApiFieldCompatChecker(options: ApiFieldCompatOptions): ApiFieldCompatChecker {
if (!isPlainObject(options.schema)) {
throw new Error('schema 必须是对象')
}
const maxChecked = options.maxChecked ?? 1000
if (!(maxChecked > 0)) {
throw new Error('maxChecked 必须为正数: ' + maxChecked)
}
const fieldOrder: string[] = []
for (const field of Object.keys(options.schema)) {
const spec = options.schema[field]
if (!SUPPORTED_TYPES.includes(spec.type)) {
throw new Error('不支持的字段类型: ' + spec.type)
}
fieldOrder.push(field)
}
let checkedCount = 0
let violationCount = 0
let skippedCount = 0
const checkedIds: number[] = []
let lastViolations: ApiFieldViolation[] = []
function typeOf(value: unknown): ApiFieldType {
if (value == null) return 'object'
if (Array.isArray(value)) return 'array'
if (typeof value === 'number') return 'number'
if (typeof value === 'string') return 'string'
if (typeof value === 'boolean') return 'boolean'
if (typeof value === 'object') return 'object'
return 'object'
}
function checkOne(obj: Record<string, unknown>): ApiFieldViolation[] {
const violations: ApiFieldViolation[] = []
for (const field of fieldOrder) {
const spec = options.schema[field]
const value = obj[field]
if (value === undefined) {
if (spec.required) {
violations.push({ field, kind: 'missing', expected: spec.type })
}
continue
}
const actual = typeOf(value)
if (actual !== spec.type) {
violations.push({ field, kind: 'type', expected: spec.type, actual })
}
}
return violations
}
function record(violations: ApiFieldViolation[]) {
checkedCount += 1
checkedIds.push(checkedCount)
if (violations.length > 0) {
violationCount += violations.length
}
lastViolations = violations
}
function check(obj: unknown): ApiFieldViolation[] {
if (!isPlainObject(obj)) {
throw new Error('对象必须是普通对象')
}
if (checkedCount >= maxChecked) {
skippedCount += 1
return []
}
const violations = checkOne(obj)
record(violations)
return violations
}
function checkBatch(objs: unknown[]) {
if (!Array.isArray(objs)) {
throw new Error('对象数组必须是数组')
}
for (const obj of objs) {
check(obj)
}
}
function lastResult(): ApiFieldCompatResult {
return {
violations: lastViolations,
passed: lastViolations.length === 0,
checkedCount,
violationCount,
}
}
function checkedFields(): string[] {
return [...fieldOrder]
}
function stats(): ApiFieldCompatStats {
return { checkedCount, violationCount, skippedCount, checkedIds: [...checkedIds] }
}
return { check, checkBatch, lastResult, checkedFields, stats }
}
+13 -16
View File
@@ -8,19 +8,20 @@
unwrapJavaResponse,
} from "@/shared/api/http";
import { getTaskProgressCacheTtlMs } from "@/shared/task-progress-config";
import { createTaskProgressRequestCache } from "@/shared/task-progress-request-cache";
const JAVA_API_PREFIX = "/newApi/api";
type TaskProgressCacheEntry = {
expiresAt: number;
data: unknown;
};
interface TaskProgressBatchOptions {
force?: boolean;
}
const taskProgressResponseCache = new Map<string, TaskProgressCacheEntry>();
const taskProgressInflightRequests = new Map<string, Promise<unknown>>();
/** 进度批量接口的响应缓存与并发合并:TTL 过期、有界条目、in-flight 去重 */
const taskProgressResponseCache = createTaskProgressRequestCache<unknown>({
ttlMs: () => getTaskProgressCacheTtlMs(),
maxEntries: 100,
maxInflight: 16,
});
function getCurrentUserId() {
const raw =
@@ -59,13 +60,12 @@ async function postTaskProgressBatch<T>(
}
const cacheKey = buildTaskProgressRequestKey(path, normalizedTaskIds);
const now = Date.now();
const cached = taskProgressResponseCache.get(cacheKey);
if (!options.force && cached && cached.expiresAt > now) {
return cached.data as T;
if (!options.force && cached !== undefined) {
return cached as T;
}
const inflight = taskProgressInflightRequests.get(cacheKey);
const inflight = taskProgressResponseCache.getInflight(cacheKey);
if (!options.force && inflight) {
return (await inflight) as T;
}
@@ -74,17 +74,14 @@ async function postTaskProgressBatch<T>(
post<JavaApiResponse<T>, { taskIds: number[] }>(path, { taskIds: normalizedTaskIds }),
)
.then((data) => {
taskProgressResponseCache.set(cacheKey, {
data,
expiresAt: Date.now() + getTaskProgressCacheTtlMs(),
});
taskProgressResponseCache.set(cacheKey, data);
return data;
})
.finally(() => {
taskProgressInflightRequests.delete(cacheKey);
taskProgressResponseCache.endInflight(cacheKey);
});
taskProgressInflightRequests.set(cacheKey, requestPromise);
taskProgressResponseCache.startInflight(cacheKey, requestPromise);
return requestPromise;
}
@@ -0,0 +1,110 @@
/**
* Similar ASIN force 请求节流守卫(Task 85)。
*
* 原实现只要 pendingFileTaskIds 非空,每轮轮询都以 force=true 请求批量
* 进度,可能反复触发后端重复生成文件。本守卫为每个 taskId 维护一个 TTL
* 窗口:窗口内 shouldForce 返回 false(不重复发 force 请求),到期后
* 允许重试(文件生成可能仍在进行,但后端可再次确认);任务终态后调用
* clear 立即释放记录,窗口表不会无界增长。
*
* 纯 TS 无副作用模块:markForce 对非法 taskId fail-fast 抛错,读取路径
* shouldForce/isThrottled/clear/stats)对非法 id 宽容;时钟抛错时
* 读取失败但记录不丢失,恢复后可继续工作。
*/
export interface AsinForceThrottleOptions {
/** force 请求的冷却窗口(毫秒),必须为正数 */
ttlMs: number
/** 时钟来源;默认 Date.now(),测试可注入虚拟时钟 */
now?: () => number
}
export interface AsinForceThrottleStats {
/** 已执行的 force 标记次数 */
forcedCount: number
/** 被节流跳过的 force 请求次数 */
skippedCount: number
/** 当前处于冷却窗口内的 taskId 数 */
activeCount: number
}
export function createAsinForceThrottle(options: AsinForceThrottleOptions) {
const ttlMs = options.ttlMs
const now = options.now ?? Date.now
if (!(ttlMs > 0)) {
throw new Error('ttlMs 必须为正数: ' + ttlMs)
}
const cooldowns = new Map<number, number>()
let forcedCount = 0
let skippedCount = 0
function validateTaskId(taskId: number): boolean {
return Number.isFinite(taskId) && taskId > 0 && Number.isInteger(taskId)
}
function isThrottled(taskId: number): boolean {
if (!validateTaskId(taskId)) return false
const at = now()
const until = cooldowns.get(taskId)
if (until == null) return false
if (at >= until) {
cooldowns.delete(taskId)
return false
}
return true
}
function shouldForce(taskId: number): boolean {
if (!validateTaskId(taskId)) return false
if (isThrottled(taskId)) {
skippedCount += 1
return false
}
return true
}
function markForce(taskId: number) {
if (!validateTaskId(taskId)) {
throw new Error('taskId 必须是正整数: ' + taskId)
}
if (isThrottled(taskId)) return
cooldowns.set(taskId, now() + ttlMs)
forcedCount += 1
}
function clear(taskId: number): boolean {
if (!validateTaskId(taskId)) return false
return cooldowns.delete(taskId)
}
function clearAll() {
cooldowns.clear()
}
function stats(): AsinForceThrottleStats {
const at = now()
for (const [taskId, until] of cooldowns) {
if (at >= until) cooldowns.delete(taskId)
}
return {
forcedCount,
skippedCount,
activeCount: cooldowns.size,
}
}
return {
get activeCount() {
stats()
return cooldowns.size
},
shouldForce,
markForce,
isThrottled,
clear,
clearAll,
stats,
}
}
export type AsinForceThrottle = ReturnType<typeof createAsinForceThrottle>
@@ -0,0 +1,196 @@
/**
* 构建拆包规划器(Task 91)。
*
* 为多页应用(MPA)生成 manualChunks 配置,按页面拆分公共业务 chunk:
* - vendorRules:全局 vendor 规则(Element Plus、Vue 运行时等),所有入口共享;
* - perEntryRules:按入口隔离的页面私有 chunk(匹配需同时给出 entryName
* 未匹配入口的模块不会被误拆到其他页面 chunk);
* - 同名 chunk 的规则自动合并去重;未匹配模块返回 undefined,交由 Vite
* 默认拆包逻辑处理,不强制拆包。
*
* 有界配置:maxRules 限制 vendor 规则数量(超限拒绝计数,规则不生效);
* maxEntries 限制登记的入口数量(超出部分连同其 per-entry 规则一并跳过)。
* 校验失败(空 chunk 名、空 patterns、非法上限)fail-fast 抛错且零状态变更;
* 规则读取抛错(依赖故障)时调用失败,规划状态不被污染,恢复后可继续使用。
*/
export interface ChunkRule {
/** chunk 名,不能为空 */
chunk: string
/** 匹配模块 id 的字符串 pattern 列表,不能为空 */
patterns: string[]
}
export interface EntryChunkRule extends ChunkRule {
/** 关联的入口名,不能为空 */
entry: string
}
export interface ChunkPlannerOptions {
/** 页面入口列表;超过 maxEntries 的部分被跳过 */
entries: string[]
/** 全局 vendor 拆包规则;数量超过 maxRules 的部分被拒绝 */
vendorRules?: ChunkRule[]
/** 按入口隔离的页面 chunk 规则 */
perEntryRules?: EntryChunkRule[]
/** vendor 规则数量上限,必须为正数,默认 100 */
maxRules?: number
/** 登记的入口数量上限,必须为正数,默认 100 */
maxEntries?: number
}
export interface ChunkPlan {
entry: string
chunks: string[]
}
export interface ChunkPlannerStats {
entryCount: number
chunkCount: number
sharedChunkCount: number
unmatchedCount: number
rejectedCount: number
}
export interface ChunkPlanner {
/** 分配模块到 chunk;未匹配返回 undefined(交给 Vite 默认拆包) */
assign: (moduleId: string, entryName?: string) => string | undefined
/** 生成 manualChunks 配置对象(chunk 名 → patterns */
manualChunksConfig: () => Record<string, string[]>
/** 每个入口实际包含的 chunk 清单(公共 vendor + 页面私有) */
planByEntry: () => ChunkPlan[]
stats: () => ChunkPlannerStats
}
export function createChunkPlanner(options: ChunkPlannerOptions): ChunkPlanner {
if (!Array.isArray(options.entries)) {
throw new Error('entries 必须是数组')
}
const maxRules = options.maxRules ?? 100
const maxEntries = options.maxEntries ?? 100
if (!(maxRules > 0)) {
throw new Error('maxRules 必须为正数: ' + maxRules)
}
if (!(maxEntries > 0)) {
throw new Error('maxEntries 必须为正数: ' + maxEntries)
}
const entries = options.entries.slice(0, maxEntries)
const entrySet = new Set(entries)
const vendorRules: Array<{ chunk: string; patterns: string[] }> = []
const perEntryRulesByEntry = new Map<string, Array<{ chunk: string; patterns: string[] }>>()
const chunkNames: string[] = []
let rejectedCount = 0
let unmatchedCount = 0
function validateRule(rule: ChunkRule) {
if (typeof rule.chunk !== 'string' || rule.chunk.length === 0) {
throw new Error('chunk 名不能为空')
}
if (!Array.isArray(rule.patterns) || rule.patterns.length === 0) {
throw new Error('patterns 必须是非空数组')
}
}
function mergePatterns(target: string[] | undefined, patterns: string[]): string[] {
const result = target ? [...target] : []
for (const pattern of patterns) {
if (!result.includes(pattern)) result.push(pattern)
}
return result
}
function addVendorRule(rule: ChunkRule) {
validateRule(rule)
if (vendorRules.length >= maxRules) {
rejectedCount += 1
return
}
const existing = vendorRules.find((item) => item.chunk === rule.chunk)
if (existing) {
existing.patterns = mergePatterns(existing.patterns, rule.patterns)
} else {
vendorRules.push({ chunk: rule.chunk, patterns: [...rule.patterns] })
chunkNames.push(rule.chunk)
}
}
function addPerEntryRule(rule: EntryChunkRule) {
if (typeof rule.entry !== 'string' || rule.entry.length === 0) {
throw new Error('entry 名不能为空')
}
validateRule(rule)
if (!entrySet.has(rule.entry)) return
const rules = perEntryRulesByEntry.get(rule.entry) ?? []
const existing = rules.find((item) => item.chunk === rule.chunk)
if (existing) {
existing.patterns = mergePatterns(existing.patterns, rule.patterns)
} else {
rules.push({ chunk: rule.chunk, patterns: [...rule.patterns] })
perEntryRulesByEntry.set(rule.entry, rules)
chunkNames.push(rule.chunk)
}
}
for (const rule of options.vendorRules ?? []) {
addVendorRule(rule)
}
for (const rule of options.perEntryRules ?? []) {
addPerEntryRule(rule)
}
function matches(patterns: string[], moduleId: string): boolean {
for (const pattern of patterns) {
if (moduleId.includes(pattern)) return true
}
return false
}
function assign(moduleId: string, entryName?: string): string | undefined {
for (const rule of vendorRules) {
if (matches(rule.patterns, moduleId)) return rule.chunk
}
if (entryName !== undefined) {
const rules = perEntryRulesByEntry.get(entryName)
if (rules) {
for (const rule of rules) {
if (matches(rule.patterns, moduleId)) return rule.chunk
}
}
}
unmatchedCount += 1
return undefined
}
function manualChunksConfig(): Record<string, string[]> {
const config: Record<string, string[]> = {}
for (const rule of vendorRules) {
config[rule.chunk] = mergePatterns(config[rule.chunk], rule.patterns).sort()
}
for (const rules of perEntryRulesByEntry.values()) {
for (const rule of rules) {
config[rule.chunk] = mergePatterns(config[rule.chunk], rule.patterns).sort()
}
}
return config
}
function planByEntry(): ChunkPlan[] {
const vendorNames = vendorRules.map((rule) => rule.chunk)
return entries.map((entry) => ({
entry,
chunks: [...vendorNames, ...(perEntryRulesByEntry.get(entry)?.map((rule) => rule.chunk) ?? [])],
}))
}
function stats(): ChunkPlannerStats {
return {
entryCount: entries.length,
chunkCount: chunkNames.length,
sharedChunkCount: vendorRules.length,
unmatchedCount,
rejectedCount,
}
}
return { assign, manualChunksConfig, planByEntry, stats }
}
@@ -0,0 +1,189 @@
/**
* 首屏传输大小报告(Task 92)。
*
* 在 manualChunks 拆包后比较各页面首屏传输大小:每个入口的首屏字节 =
* 入口自身 JS + 其依赖的全部 chunk 字节(公共 chunk 在多个入口之间共享,
* 计入各自首屏但只在报告总览里计一次)。
*
* 有界统计:maxEntries 限制统计的入口数量;maxChunksPerEntry 限制每个入口
* 计入的 chunk 数量(超出部分忽略,防止页面私有 chunk 过多导致报告失真)。
* 缺失入口的拆包计划或尺寸、缺失 chunk 尺寸、尺寸为负数均 fail-fast 抛错,
* 不产生部分结果;同一输入重复计算幂等,输入对象永不修改。
*/
export interface ChunkPlanEntry {
entry: string
chunks: string[]
}
export interface ChunkTransferReportOptions {
/** 页面入口列表(顺序即报告顺序);超过 maxEntries 的部分被跳过 */
entries: string[]
/** 每个入口的 chunk 清单(来自拆包规划) */
plans: ChunkPlanEntry[]
/** chunk 名 → 字节数(构建产物尺寸) */
chunkSizes: Record<string, number>
/** 入口 → 入口自身 JS 字节数 */
entrySizes: Record<string, number>
/** 统计的入口数量上限,必须为正数,默认 100 */
maxEntries?: number
/** 每个入口计入的 chunk 数量上限,必须为正数,默认 100 */
maxChunksPerEntry?: number
}
export interface ChunkTransferLine {
entry: string
entryBytes: number
chunkBytes: number
transferBytes: number
chunkCount: number
}
export interface ChunkTransferReport {
/** 按入口顺序排列的首屏传输明细 */
entries: ChunkTransferLine[]
/** 被多个入口共享的 chunk 总字节(每个共享 chunk 只计一次) */
sharedBytes: number
/** 全部入口 transferBytes 之和 */
totalBytes: number
/** 首屏最大的入口(空输入为 undefined) */
largest: ChunkTransferLine | undefined
/** 首屏最小的入口(空输入为 undefined) */
smallest: ChunkTransferLine | undefined
/** 单个入口的 chunk 字节明细(chunk 名 → 字节数,不含被截断的 chunk) */
chunkBytesOf: (line: ChunkTransferLine) => Record<string, number>
}
function isRecord(value: unknown): value is Record<string, unknown> {
return (
typeof value === 'object' &&
value != null &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) === Object.prototype
)
}
export function createChunkTransferReport(options: ChunkTransferReportOptions): ChunkTransferReport {
if (!Array.isArray(options.entries)) {
throw new Error('entries 必须是数组')
}
if (!Array.isArray(options.plans)) {
throw new Error('plans 必须是数组')
}
if (!isRecord(options.chunkSizes)) {
throw new Error('chunkSizes 必须是对象')
}
if (!isRecord(options.entrySizes)) {
throw new Error('entrySizes 必须是对象')
}
const maxEntries = options.maxEntries ?? 100
const maxChunksPerEntry = options.maxChunksPerEntry ?? 100
if (!(maxEntries > 0)) {
throw new Error('maxEntries 必须为正数: ' + maxEntries)
}
if (!(maxChunksPerEntry > 0)) {
throw new Error('maxChunksPerEntry 必须为正数: ' + maxChunksPerEntry)
}
const entries = options.entries.slice(0, maxEntries)
const planByEntry = new Map<string, string[]>()
for (const plan of options.plans) {
planByEntry.set(plan.entry, plan.chunks)
}
for (const entry of entries) {
if (!planByEntry.has(entry)) {
throw new Error('缺少 entry 的拆包计划: ' + entry)
}
if (!(entry in options.entrySizes)) {
throw new Error('缺少 entry 尺寸: ' + entry)
}
}
const chunkSizes: Record<string, number> = {}
for (const key of Object.keys(options.chunkSizes)) {
const size = options.chunkSizes[key]
if (!(size >= 0)) {
throw new Error('尺寸不能为负数: ' + key + '=' + size)
}
chunkSizes[key] = size
}
const entrySizes: Record<string, number> = {}
for (const key of Object.keys(options.entrySizes)) {
const size = options.entrySizes[key]
if (!(size >= 0)) {
throw new Error('尺寸不能为负数: ' + key + '=' + size)
}
entrySizes[key] = size
}
const chunkPerEntry = new Map<string, number[]>()
for (const entry of entries) {
const chunks = planByEntry.get(entry) ?? []
const sizes: number[] = []
for (const chunk of chunks.slice(0, maxChunksPerEntry)) {
if (!(chunk in chunkSizes)) {
throw new Error('缺少 chunk 尺寸: ' + chunk)
}
sizes.push(chunkSizes[chunk])
}
chunkPerEntry.set(entry, sizes)
}
// 被多个入口共享的 chunk 总字节(每个共享 chunk 只计一次)
const chunkCountByEntry = new Map<string, Set<string>>()
let sharedBytes = 0
{
const counts = new Map<string, number>()
for (const entry of entries) {
for (const chunk of planByEntry.get(entry) ?? []) {
counts.set(chunk, (counts.get(chunk) ?? 0) + 1)
}
}
for (const [chunk, count] of counts) {
if (count > 1 && chunk in chunkSizes) {
sharedBytes += chunkSizes[chunk]
}
}
}
const lines: ChunkTransferLine[] = []
for (const entry of entries) {
const sizes = chunkPerEntry.get(entry) ?? []
const entryBytes = entrySizes[entry]
const chunkBytes = sizes.reduce((sum, size) => sum + size, 0)
lines.push({
entry,
entryBytes,
chunkBytes,
transferBytes: entryBytes + chunkBytes,
chunkCount: sizes.length,
})
chunkCountByEntry.set(entry, new Set(planByEntry.get(entry) ?? []))
}
let totalBytes = 0
for (const line of lines) totalBytes += line.transferBytes
let largest: ChunkTransferLine | undefined = undefined
let smallest: ChunkTransferLine | undefined = undefined
for (const line of lines) {
if (!largest || line.transferBytes > largest.transferBytes) largest = line
if (!smallest || line.transferBytes < smallest.transferBytes) smallest = line
}
function chunkBytesOf(line: ChunkTransferLine): Record<string, number> {
const chunks = planByEntry.get(line.entry) ?? []
const detail: Record<string, number> = {}
for (const chunk of chunks.slice(0, maxChunksPerEntry)) {
detail[chunk] = chunkSizes[chunk]
}
return detail
}
return {
entries: lines,
sharedBytes,
totalBytes,
largest,
smallest,
chunkBytesOf,
}
}
@@ -1,6 +1,23 @@
import { onBeforeUnmount, ref, watch, type Ref } from 'vue'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import {
getTaskPollIntervalMs,
getTaskPollBackoffMs,
getTaskForegroundRefreshEnabled,
getTaskForegroundRefreshDelayMs,
} from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
import {
createTaskPollingBaseline,
type TaskPollingBaseline,
} from '@/shared/task-polling-baseline'
import {
createProgressResponseCache,
type ProgressResponseCache,
} from '@/shared/progress-response-cache'
import {
createTaskPollingCoordinator,
type TaskPollingCoordinator,
} from '@/shared/task-polling-coordinator'
/**
* 通用任务进度轮询组合式函数。
@@ -46,6 +63,24 @@ export interface TaskProgressLoopOptions<TDetail> {
onError?: (error: unknown) => void
/** 自定义轮询间隔;默认根据 document.visibilityState 自适应(5s/30s */
getIntervalMs?: () => number
/**
* 可选轮询基线统计(Task 81):传入后每轮请求与响应都会被记录到基线,
* 并在每次 refreshOnce 结束后把基线实例回传,供页面/测试观测请求量、
* 响应体大小与缓存内存占用。
*/
onBaseline?: (baseline: TaskPollingBaseline) => void
/**
* 可选进度响应缓存(Task 82):传入后每轮响应快照按 taskId 写入
* TTL+最大条目数的有界缓存(读取时惰性清理过期条目),供页面在
* 轮询间隙复用最近一次进度快照。
*/
progressCache?: ProgressResponseCache<TDetail>
/**
* 可选轮询协调器(Task 83):传入后 add/remove/终态清理委托给协调器
* 统一去重、合并并发请求;终态任务经协调器回调后从轮询集合移除,
* 同一任务只触发一次终态回调。
*/
coordinator?: TaskPollingCoordinator
}
export interface TaskProgressLoopHandle<TDetail> {
@@ -95,6 +130,8 @@ export function useTaskProgressLoop<TDetail>(
const timers = createCategorizedTimers(`task-progress-loop:${options.scope}`)
const isTerminal = options.isTerminal ?? DEFAULT_TERMINAL
const intervalMs = options.getIntervalMs ?? getTaskPollIntervalMs
const baseline = options.onBaseline ? createTaskPollingBaseline() : null
const coordinator = options.coordinator ?? null
const taskIds = ref<number[]>(readIdsFromStorage(options.storageKey))
const taskStatuses = ref<Record<number, string>>({})
@@ -108,6 +145,7 @@ export function useTaskProgressLoop<TDetail>(
function add(taskId: number) {
if (!Number.isFinite(taskId) || taskId <= 0) return
if (coordinator && !coordinator.add(taskId)) return
if (taskIds.value.includes(taskId)) return
taskIds.value = [...taskIds.value, taskId]
persist()
@@ -115,6 +153,7 @@ export function useTaskProgressLoop<TDetail>(
}
function remove(taskId: number) {
coordinator?.remove(taskId)
if (!taskIds.value.includes(taskId)) return
taskIds.value = taskIds.value.filter((id) => id !== taskId)
persist()
@@ -141,6 +180,7 @@ export function useTaskProgressLoop<TDetail>(
const ids = taskIds.value.filter((id) => id > 0)
if (!ids.length) return
inFlight.value = true
baseline?.recordRequest()
try {
const result = await options.fetchProgress(ids)
const items = result?.items || []
@@ -155,6 +195,7 @@ export function useTaskProgressLoop<TDetail>(
} catch {
/* onUpdate 抛错不应中断本轮 */
}
options.progressCache?.set(id, detail)
if (status) {
nextStatuses[id] = status
if (isTerminal(status)) {
@@ -164,6 +205,7 @@ export function useTaskProgressLoop<TDetail>(
}
taskStatuses.value = nextStatuses
for (const event of terminalEvents) {
coordinator?.markTerminal(event.taskId)
remove(event.taskId)
try {
await options.onTerminal?.(event.taskId, event.detail, event.status)
@@ -175,6 +217,7 @@ export function useTaskProgressLoop<TDetail>(
options.onError?.(error)
} finally {
inFlight.value = false
if (baseline) options.onBaseline?.(baseline)
}
}
@@ -195,8 +238,8 @@ export function useTaskProgressLoop<TDetail>(
if (disposed) return
if (!taskIds.value.length) return
if (inFlight.value) {
// 上一次还没回,500ms 后再试
pollTimer = timers.setTimeout('task-poll', run, 500)
// 上一次还没回,按退避策略延迟重试(默认 500ms,可配置)
pollTimer = timers.setTimeout('task-poll', run, getTaskPollBackoffMs(0))
return
}
await refreshOnce()
@@ -239,17 +282,44 @@ export function useTaskProgressLoop<TDetail>(
{ flush: 'post' },
)
// 切到前台后立刻拉一次,让用户回到页面看到的是最新状态
// 切到前台后立刻拉一次(可配置开关与延迟),让用户回到页面看到的是最新状态
let visibilityHandler: (() => void) | null = null
if (typeof document !== 'undefined') {
visibilityHandler = () => {
if (document.visibilityState === 'visible' && taskIds.value.length > 0) {
scheduleNext(true)
if (
getTaskForegroundRefreshEnabled() &&
document.visibilityState === 'visible' &&
taskIds.value.length > 0
) {
const delay = getTaskForegroundRefreshDelayMs()
if (delay > 0) {
scheduleNextDelayed(delay)
} else {
scheduleNext(true)
}
}
}
document.addEventListener('visibilitychange', visibilityHandler)
}
function scheduleNextDelayed(delayMs: number) {
if (disposed || pollTimer != null) return
clearPollTimer()
const run = () => {
pollTimer = null
if (disposed || !taskIds.value.length) return
if (inFlight.value) {
pollTimer = timers.setTimeout('task-poll', run, getTaskPollBackoffMs(0))
return
}
void refreshOnce()
if (!disposed && taskIds.value.length > 0) {
pollTimer = timers.setTimeout('task-poll', run, intervalMs())
}
}
pollTimer = timers.setTimeout('task-poll', run, delayMs)
}
function dispose() {
if (disposed) return
disposed = true
@@ -0,0 +1,121 @@
/**
* 健康检查执行器(Task 98)。
*
* 把真实启动、健康检查、核心请求、外部依赖调用四个验收探针收敛为
* 可注入探针的确定性流程:探针按序执行,全部产出结果与耗时。
*
* 语义:
* - 单个探针失败(返回非 ok 输出或抛错)计入 failed,不中断其余探针,
* 健康检查输出全量报告;ok=false 当且仅当存在 failed
* - 空探针列表视为通过;单探针不依赖批量路径;
* - maxProbes 限制执行的探针数量(超出部分安全跳过),执行有界;
* - timeoutMs 为单个探针设置超时上限:超时按失败计入,不泄漏未完成
* 的探测任务(探针异步任务自行收尾);
* - 校验失败 fail-fastprobes 非数组、id 为空、probe 非函数、maxProbes/
* timeoutMs 非正数、探针返回非字符串均抛错;失败可恢复——修复探针后
* 同一 runner 重跑即全绿。
*/
export interface HealthProbe {
id: string
label: string
probe: () => Promise<string> | string
}
export interface HealthCheckOptions {
probes: HealthProbe[]
/** 执行的探针数量上限,必须为正数,默认 100 */
maxProbes?: number
/** 单个探针超时(毫秒),必须为正数,默认 30_000 */
timeoutMs?: number
}
export interface ProbeResult {
id: string
label: string
ok: boolean
output: string
durationMs: number
}
export interface HealthCheckResult {
ok: boolean
passed: ProbeResult[]
failed: ProbeResult[]
totalDurationMs: number
}
export interface HealthCheckRunner {
runAll: () => Promise<HealthCheckResult>
}
export function createHealthCheckRunner(options: HealthCheckOptions): HealthCheckRunner {
if (!Array.isArray(options.probes)) {
throw new Error('probes 必须是数组')
}
for (const probe of options.probes) {
if (typeof probe.id !== 'string' || probe.id.length === 0) {
throw new Error('probe id 不能为空')
}
if (typeof probe.probe !== 'function') {
throw new Error('probe 必须是函数')
}
}
const maxProbes = options.maxProbes ?? 100
const timeoutMs = options.timeoutMs ?? 30_000
if (!(maxProbes > 0)) {
throw new Error('maxProbes 必须为正数: ' + maxProbes)
}
if (!(timeoutMs > 0)) {
throw new Error('timeoutMs 必须为正数: ' + timeoutMs)
}
async function runOne(probe: HealthProbe): Promise<ProbeResult> {
const start = Date.now()
let output = ''
let failed = false
let timer: ReturnType<typeof setTimeout> | null = null
try {
const raw = await Promise.race([
Promise.resolve(probe.probe()),
new Promise<string>((resolve) => {
timer = setTimeout(() => resolve(''), timeoutMs)
}),
])
if (timer != null) clearTimeout(timer)
output = raw
} catch (error) {
if (timer != null) clearTimeout(timer)
failed = true
output = error instanceof Error ? error.message : String(error)
}
const durationMs = Date.now() - start
if (typeof output !== 'string') {
throw new Error('output 必须是字符串')
}
const ok = !failed && output !== ''
return { id: probe.id, label: probe.label, ok, output, durationMs }
}
async function runAll(): Promise<HealthCheckResult> {
const start = Date.now()
const passed: ProbeResult[] = []
const failed: ProbeResult[] = []
for (let i = 0; i < options.probes.length; i++) {
if (i >= maxProbes) break
const result = await runOne(options.probes[i])
if (result.ok) {
passed.push(result)
} else {
failed.push(result)
}
}
return {
ok: failed.length === 0,
passed,
failed,
totalDurationMs: Date.now() - start,
}
}
return { runAll }
}
@@ -0,0 +1,180 @@
/**
* 全链路压测记录器(Task 99)。
*
* 执行压测请求并记录 CPU、内存、GC、DB、Redis、RustFS、网络结果:
* metric(i) 产生单次请求结果(ok/延迟),sample() 周期采集系统指标,
* 输出请求统计 + 各指标的平均/最大/min/95 分位汇总。
*
* 语义:
* - 并发有界:concurrency 个并发槽执行 requests 个请求,不无界扩张;
* - 失败有界:maxFailures 设定失败容忍上限,超过后 ok=false,但仍完成
* 全部请求并输出完整报告;
* - 采样失败(依赖故障)不中断压测:计入 sampleFailures,统计从有效
* 采样计算;依赖恢复后同一 recorder 重跑即全量恢复;
* - 空请求数安全返回;单请求不依赖批量路径;同一输入重复执行幂等,
* 无残留状态;
* - 校验失败 fail-fastmetric/sample 非函数、requests 非非负整数、
* concurrency/maxFailures 非法、latencyMs 非法均抛错。
*/
export interface LoadMetric {
ok: boolean
output: string
latencyMs: number
}
export interface LoadSample {
cpuPercent: number
heapBytes: number
gcCount: number
dbQps: number
redisQps: number
rustfsQps: number
networkBytesPerSec: number
}
export interface LoadTestRecorderOptions {
/** 单次请求执行器;抛错按失败计入 */
metric: (i: number) => Promise<LoadMetric>
/** 系统指标采样器;抛错计入 sampleFailures 不中断压测 */
sample: () => Promise<LoadSample>
/** 请求总数,必须为非负整数,默认 100 */
requests?: number
/** 并发槽数量,必须为正数,默认 1 */
concurrency?: number
/** 失败容忍上限,必须为非负整数,默认 0 */
maxFailures?: number
/** 采样间隔(每 N 个请求采样一次),必须为正数,默认 1 */
sampleEvery?: number
}
export interface MetricSummary {
avg: number
max: number
min: number
p95: number
}
export interface LoadTestSummary {
ok: boolean
requests: number
failures: number
samples: Record<string, MetricSummary> & { avgCount: number }
}
export interface LoadTestResult {
ok: boolean
summary: LoadTestSummary
metrics: Array<LoadMetric & { index: number }>
sampleFailures: number
}
export interface LoadTestRecorder {
run: () => Promise<LoadTestResult>
}
const SAMPLE_KEYS: Array<keyof LoadSample> = [
'cpuPercent',
'heapBytes',
'gcCount',
'dbQps',
'redisQps',
'rustfsQps',
'networkBytesPerSec',
]
function p95(values: number[]): number {
if (values.length === 0) return 0
const sorted = [...values].sort((a, b) => a - b)
const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1)
return sorted[index]
}
function summarize(values: number[]): MetricSummary {
return {
avg: values.length ? values.reduce((sum, v) => sum + v, 0) / values.length : 0,
max: values.length ? Math.max(...values) : 0,
min: values.length ? Math.min(...values) : 0,
p95: p95(values),
}
}
export function createLoadTestRecorder(options: LoadTestRecorderOptions): LoadTestRecorder {
if (typeof options.metric !== 'function') {
throw new Error('metric 必须是函数')
}
if (typeof options.sample !== 'function') {
throw new Error('sample 必须是函数')
}
const requests = options.requests ?? 100
const concurrency = options.concurrency ?? 1
const maxFailures = options.maxFailures ?? 0
const sampleEvery = options.sampleEvery ?? 1
if (!Number.isInteger(requests) || requests < 0) {
throw new Error('requests 必须是非负整数: ' + requests)
}
if (!(concurrency > 0)) {
throw new Error('concurrency 必须为正数: ' + concurrency)
}
if (!Number.isInteger(maxFailures) || maxFailures < 0) {
throw new Error('maxFailures 必须是非负整数: ' + maxFailures)
}
if (!(sampleEvery > 0)) {
throw new Error('sampleEvery 必须为正数: ' + sampleEvery)
}
async function run(): Promise<LoadTestResult> {
const metrics: Array<LoadMetric & { index: number }> = []
const samplesByKey: Record<string, number[]> = {}
for (const key of SAMPLE_KEYS) samplesByKey[key] = []
let sampleFailures = 0
const runRequest = async (i: number) => {
let metric: LoadMetric
try {
metric = await options.metric(i)
} catch (error) {
metric = { ok: false, output: error instanceof Error ? error.message : String(error), latencyMs: 0 }
}
if (typeof metric.latencyMs !== 'number' || !Number.isFinite(metric.latencyMs) || metric.latencyMs < 0) {
throw new Error('latencyMs 必须为非负数值: ' + metric.latencyMs)
}
metrics.push({ ...metric, index: i })
if (i % sampleEvery === 0) {
try {
const sample = await options.sample()
for (const key of SAMPLE_KEYS) {
samplesByKey[key].push(sample[key])
}
} catch {
sampleFailures += 1
}
}
}
for (let offset = 0; offset < requests; offset += concurrency) {
const batch = []
for (let i = offset; i < Math.min(offset + concurrency, requests); i++) {
batch.push(runRequest(i))
}
await Promise.all(batch)
}
metrics.sort((a, b) => a.index - b.index)
const failures = metrics.filter((m) => !m.ok).length
const summary: LoadTestSummary = {
ok: failures <= maxFailures,
requests: metrics.length,
failures,
samples: {
avgCount: samplesByKey.cpuPercent.length,
} as LoadTestSummary['samples'],
}
for (const key of SAMPLE_KEYS) {
;(summary.samples as Record<string, MetricSummary>)[key] = summarize(samplesByKey[key])
}
return { ok: failures <= maxFailures, summary, metrics, sampleFailures }
}
return { run }
}
@@ -0,0 +1,143 @@
/**
* localStorage 有界写入(Task 87)。
*
* 限制 localStorage 中任务、快照和队列数据的最大数量与字节数:
* - maxKeys:同页面下管理的 key 总数上限,超过时驱逐最旧写入的 key
* (更新已存在 key 会刷新其新鲜度,最近使用的 key 不被驱逐);
* - maxBytes:全部条目序列化后总字节数上限,超过时同样驱逐最旧条目;
* 单条超过上限的写入被拒绝,不产生部分数据;
* - 存储抛错(如 QuotaExceededError)时写入失败且不污染内部索引;
* - 重复写同一 key 幂等(只更新内容与新鲜度,不增加条数)。
*
* 纯 TS 模块:key 为空字符串时拒绝;JSON 序列化失败时拒绝写入并保持
* 原状态;remove/clear 释放全部条目。
*/
export interface LocalStorageLimiterOptions {
/** 底层存储(window.localStorage 的窄接口,便于测试注入) */
storage: {
getItem: (key: string) => string | null
setItem: (key: string, value: string) => void
removeItem: (key: string) => void
}
/** 管理的 key 总数上限,必须为正数 */
maxKeys: number
/** 全部条目序列化后总字节数上限,必须为正数 */
maxBytes: number
}
export interface LocalStorageLimiterStats {
/** 当前管理的 key 数 */
keyCount: number
/** 全部条目序列化后的总字节数 */
totalBytes: number
/** 被驱逐的条目数 */
evictedCount: number
}
const encoder = new TextEncoder()
export function createLocalStorageLimiter(options: LocalStorageLimiterOptions) {
const { storage, maxKeys, maxBytes } = options
if (!storage || typeof storage.getItem !== 'function' || typeof storage.setItem !== 'function') {
throw new Error('storage 必须提供 getItem/setItem/removeItem')
}
if (!(maxKeys > 0)) {
throw new Error('maxKeys 必须为正数: ' + maxKeys)
}
if (!(maxBytes > 0)) {
throw new Error('maxBytes 必须为正数: ' + maxBytes)
}
const order: string[] = []
let totalBytes = 0
let evictedCount = 0
function evictIfNeeded() {
while ((order.length > 0 && order.length > maxKeys) || (order.length > 0 && totalBytes > maxBytes)) {
const oldest = order.shift() as string
const raw = storage.getItem(oldest)
if (raw != null) {
totalBytes -= encoder.encode(raw).byteLength
storage.removeItem(oldest)
}
evictedCount += 1
}
}
function touch(key: string) {
const index = order.indexOf(key)
if (index >= 0) order.splice(index, 1)
order.push(key)
}
function write(key: string, value: unknown): boolean {
if (!key) return false
let raw: string
try {
raw = JSON.stringify(value)
} catch {
return false
}
if (raw == null) return false
const existing = storage.getItem(key)
const bytes = encoder.encode(raw).byteLength
if (bytes > maxBytes) return false
try {
storage.setItem(key, raw)
} catch {
return false
}
if (existing == null) {
totalBytes += bytes
touch(key)
evictIfNeeded()
} else {
totalBytes -= encoder.encode(existing).byteLength
totalBytes += bytes
touch(key)
evictIfNeeded()
}
return true
}
function read(key: string): unknown {
if (!key) return undefined
const raw = storage.getItem(key)
if (raw == null) return undefined
try {
return JSON.parse(raw)
} catch {
return undefined
}
}
function remove(key: string): boolean {
if (!key) return false
const raw = storage.getItem(key)
if (raw == null) return false
storage.removeItem(key)
totalBytes -= encoder.encode(raw).byteLength
const index = order.indexOf(key)
if (index >= 0) order.splice(index, 1)
return true
}
function clear() {
for (const key of order) storage.removeItem(key)
order.length = 0
totalBytes = 0
evictedCount = 0
}
function stats(): LocalStorageLimiterStats {
return {
keyCount: order.length,
totalBytes,
evictedCount,
}
}
return { write, read, remove, clear, stats }
}
export type LocalStorageLimiter = ReturnType<typeof createLocalStorageLimiter>
@@ -0,0 +1,119 @@
/**
* 历史条目合并(Task 84)。
*
* 消除店铺抓取队列状态合并中 historyItems 的线性重复查找:原实现
* `[...map.values()].find(...)` 对每个 incoming 条目全表扫描(O(n×m)),
* 且命中后沿用旧 key 写回,shopName 变化时会留下幽灵重复条目。本模块以
* taskId → 行列表 二级索引替代全表扫描(O(n+m)),命中后删除旧 key、
* 写入新 key,同批内后到条目可命中先到条目,结果幂等。
*
* 命中语义与原 mergeProgress 一致:incoming 的 taskId 为正数,且
* existing.resultId 与 incoming.resultId 相同(或 incoming 无 resultId
* 时命中同 taskId 的第一行)。key 相同(taskId/resultId/shopName 均未变)
* 的更新保持原有位置;key 变化的更新视为内容更新,写入新 key。
*
* 纯 TS 无副作用模块:入参列表永不修改(先全量建索引再产出新数组);
* keyOf 抛错时整个合并失败且零状态变更,可恢复后继续工作。maxItems 为
* 可选的输出上限(默认不限制),超过时保留最晚到达的条目。
*/
export interface MergeHistoryItemsOptions<T> {
/** 从条目提取唯一 key 的函数 */
keyOf: (item: T) => string
/** 输出条目的最大条数(默认不限制),超过时驱逐最旧条目 */
maxItems?: number
}
export interface MergeHistoryItemsResult<T> {
/** 合并后的条目数组(新数组,原数组不修改) */
items: T[]
/** 命中现有条目并替换的条数 */
updatedCount: number
/** 作为新条目追加的条数 */
addedCount: number
}
/** 命中语义:同 taskId,且 resultId 相同或 incoming 无 resultId(与原 mergeProgress 一致) */
function matches(
existing: Record<string, unknown>,
incoming: Record<string, unknown>,
): boolean {
const incomingTaskId = incoming.taskId
if (typeof incomingTaskId !== 'number' || incomingTaskId <= 0) return false
if (existing.taskId !== incomingTaskId) return false
const incomingResultId = incoming.resultId
if (typeof incomingResultId === 'number' && incomingResultId > 0) {
return existing.resultId === incomingResultId
}
return true
}
interface IndexedRow<T> {
key: string
row: T
}
export function mergeHistoryItems<T>(
existing: T[],
incoming: T[],
options: MergeHistoryItemsOptions<T>,
): MergeHistoryItemsResult<T> {
if (!Array.isArray(existing)) throw new Error('existing 必须是数组')
if (!Array.isArray(incoming)) throw new Error('incoming 必须是数组')
const { keyOf, maxItems } = options ?? {}
if (typeof keyOf !== 'function') throw new Error('keyOf 必须是函数')
if (maxItems != null && !(maxItems > 0)) {
throw new Error('maxItems 必须为正数: ' + maxItems)
}
const byKey = new Map<string, T>()
const byTaskId = new Map<number, IndexedRow<T>[]>()
function indexRow(row: T) {
const key = keyOf(row)
byKey.set(key, row)
const taskId = (row as Record<string, unknown>).taskId
if (typeof taskId === 'number' && taskId > 0) {
const list = byTaskId.get(taskId)
if (list) list.push({ key, row })
else byTaskId.set(taskId, [{ key, row }])
}
}
// 第一遍建索引:keyOf 抛错发生在任何状态变更之前
for (const row of existing) indexRow(row)
let updatedCount = 0
let addedCount = 0
for (const row of incoming) {
const taskId = (row as Record<string, unknown>).taskId
let hit: IndexedRow<T> | undefined
if (typeof taskId === 'number' && taskId > 0) {
const list = byTaskId.get(taskId)
if (list) {
hit = list.find((entry) =>
matches(entry.row as Record<string, unknown>, row as Record<string, unknown>),
)
}
}
if (hit) {
const oldKey = hit.key
const newKey = keyOf(row)
if (newKey !== oldKey) {
byKey.delete(oldKey)
hit.key = newKey
}
byKey.set(newKey, row)
hit.row = row
updatedCount += 1
} else {
indexRow(row)
addedCount += 1
}
}
const items = [...byKey.values()]
if (maxItems != null && items.length > maxItems) {
return { items: items.slice(items.length - maxItems), updatedCount, addedCount }
}
return { items, updatedCount, addedCount }
}
@@ -0,0 +1,116 @@
/**
* 页面卸载清理注册表(Task 89)。
*
* 统一管理页面级需在卸载时释放的资源:
* - 临时 URLcreateObjectURL 产生的 blob URL 通过 revokeUrl 释放;
* - 请求:AbortController 在卸载时 abortregisterRequestController);
* - timerdispose 时委托 hooks.clearTimers()(配合 categorized-timers
* 的 clearScope 全量清理)。
*
* dispose 幂等:第二次及以后的调用不再重复清理;dispose 后注册被拒绝
* 并计数。revokeUrl 抛错不中断其余条目的清理;maxTrackedUrls(默认不
* 限)用于限制同一页面累计注册的 URL 数量,超过时只保留最近注册的。
*/
export interface PageCleanupRegistryHooks {
/** 卸载时清空全部 timer */
clearTimers: () => void
/** 卸载时释放单个 blob URL */
revokeUrl: (url: string) => void
}
export interface PageCleanupRegistryOptions {
/** 累计跟踪的 URL 上限(0 表示不限制),超过时只保留最近注册的 */
maxTrackedUrls?: number
}
export function createPageCleanupRegistry(
hooks: PageCleanupRegistryHooks,
options: PageCleanupRegistryOptions = {},
) {
if (typeof hooks?.clearTimers !== 'function') {
throw new Error('clearTimers 必须是函数')
}
if (typeof hooks?.revokeUrl !== 'function') {
throw new Error('revokeUrl 必须是函数')
}
const maxTrackedUrls = options.maxTrackedUrls ?? 0
const urls: string[] = []
const controllers: AbortController[] = []
let disposed = false
let disposeCount = 0
let rejectedCount = 0
function registerObjectUrl(url: string): string {
if (disposed) {
rejectedCount += 1
return url
}
if (!url) return url
urls.push(url)
if (maxTrackedUrls > 0 && urls.length > maxTrackedUrls) {
urls.splice(0, urls.length - maxTrackedUrls)
}
return url
}
function registerRequestController(controller: AbortController) {
if (disposed) {
rejectedCount += 1
return
}
controllers.push(controller)
}
function dispose(): number {
if (disposed) {
return 0
}
disposed = true
let released = 0
for (const url of urls) {
try {
hooks.revokeUrl(url)
} catch {
/* 单个 URL 释放失败不中断其余清理 */
}
released += 1
}
urls.length = 0
for (const controller of controllers) {
try {
controller.abort()
} catch {
/* abort 失败不中断其余清理 */
}
released += 1
}
controllers.length = 0
hooks.clearTimers()
released += 1
disposeCount += 1
return released
}
return {
get trackedUrlCount() {
return urls.length
},
get requestCount() {
return controllers.length
},
get disposed() {
return disposed
},
get disposeCount() {
return disposeCount
},
get rejectedCount() {
return rejectedCount
},
registerObjectUrl,
registerRequestController,
dispose,
}
}
export type PageCleanupRegistry = ReturnType<typeof createPageCleanupRegistry>
@@ -0,0 +1,99 @@
/**
* 页面 E2E 核心路径执行器(Task 93)。
*
* 把 Similar ASIN / 店铺抓取 / 采集数据三个页面的 E2E 核心路径收敛为
* 可注入依赖的确定性流程:解析文件(parse)→ 建任务(createTask)→
* 轮询到终态(poll)→ 刷新历史(refreshHistory),各步由 deps 提供实现,
* 页面接入时传真实接口即可复用同一套语义。
*
* 流程语义:
* - parse 返回 0 表示无可处理数据:安全跳过后续步骤,不创建无效资源;
* - poll 返回非终态时按最大次数轮询,超过 maxPollAttempts 降级返回当前
* 状态(不抛错、不无限轮询、不刷新历史);
* - 任一步抛错:执行立即终止,cleanup 必被调用(失败与成功路径都执行),
* 调用方可重新执行同一 deps 恢复;
* - 输入校验 fail-fastdeps 缺函数、parse 返回非法 taskId、maxPollAttempts
* 非法均抛错且零状态变更。
*/
export interface PageE2EDeps {
/** 解析文件/参数,返回 taskId;返回 0 表示无可处理数据 */
parse: () => Promise<number>
/** 创建任务 */
createTask: (taskId: number) => Promise<void>
/** 单次轮询任务进度,返回状态字符串 */
poll: (taskId: number) => Promise<string>
/** 任务到达终态后刷新历史列表(可选) */
refreshHistory?: () => Promise<void>
/** 释放临时资源(URL、控制器、计时器等),失败与成功路径都会调用(可选) */
cleanup?: () => Promise<void>
}
export interface PageE2EOptions {
/** 轮询次数上限,必须为正数,默认 60 */
maxPollAttempts?: number
/** 判定终态,默认 SUCCESS/FAILED */
isTerminal?: (status: string) => boolean
}
export interface PageE2EResult {
taskId: number
status: string
/** 已完成的步骤(按执行顺序) */
completedSteps: string[]
/** 实际轮询次数 */
attempts: number
}
export async function runPageE2ECorePath(
deps: PageE2EDeps,
options: PageE2EOptions = {},
): Promise<PageE2EResult> {
if (typeof deps !== 'object' || deps == null) {
throw new Error('deps 必须是对象')
}
if (typeof deps.parse !== 'function') {
throw new Error('parse 必须是函数')
}
if (typeof deps.createTask !== 'function') {
throw new Error('createTask 必须是函数')
}
if (typeof deps.poll !== 'function') {
throw new Error('poll 必须是函数')
}
const maxPollAttempts = options.maxPollAttempts ?? 60
if (!(maxPollAttempts > 0)) {
throw new Error('maxPollAttempts 必须为正数: ' + maxPollAttempts)
}
const isTerminal = options.isTerminal ?? ((status: string) => status === 'SUCCESS' || status === 'FAILED')
const completedSteps: string[] = []
let status = ''
let attempts = 0
try {
const taskId = await deps.parse()
if (taskId === 0) {
completedSteps.push('parse')
return { taskId: 0, status: '', completedSteps, attempts: 0 }
}
if (typeof taskId !== 'number' || !Number.isFinite(taskId) || taskId <= 0) {
throw new Error('parse 必须返回正整数 taskId: ' + taskId)
}
completedSteps.push('parse')
await deps.createTask(taskId)
completedSteps.push('create-task')
while (attempts < maxPollAttempts) {
attempts += 1
status = await deps.poll(taskId)
completedSteps.push('poll')
if (isTerminal(status)) {
await deps.refreshHistory?.()
completedSteps.push('refresh-history')
return { taskId, status, completedSteps, attempts }
}
}
return { taskId, status, completedSteps, attempts }
} finally {
await deps.cleanup?.()
}
}
+62
View File
@@ -0,0 +1,62 @@
/**
* 解析结果预览裁剪(Task 88)。
*
* 解析接口(similar-asin/parse)返回完整解析 Vo,其中 items 可能达数千行,
* 直接放入 Vue 响应式 ref 会造成大量响应式代理与内存放大。本模块把 Vo
* 裁剪为预览对象:只保留页面实际使用的摘要字段(taskId、行数统计、开关、
* prompt 等),items/groups 整包剔除;可按 previewRowLimit / previewGroupLimit
* 有界保留前 N 条样本行用于表格展示(limit 省略或为 0 时不保留)。
*
* 纯 TS 无副作用模块:输入对象永不修改;taskId 非法时 fail-fast 抛错;
* 属性读取抛错(如 Proxy getter 故障)时调用失败,不产生部分结果。
*/
export interface ParsePreviewOptions {
/** 保留的样本行条数(0 或不传表示不保留),不能为负数 */
previewRowLimit?: number
/** 保留的样本分组条数(0 或不传表示不保留),不能为负数 */
previewGroupLimit?: number
}
export function toParsePreview<T>(
result: T,
options: ParsePreviewOptions = {},
) {
if (typeof result !== 'object' || result == null) {
throw new Error('解析结果必须是对象')
}
const { previewRowLimit, previewGroupLimit } = options
if (previewRowLimit != null && !(previewRowLimit >= 0)) {
throw new Error('previewRowLimit 不能为负数: ' + previewRowLimit)
}
if (previewGroupLimit != null && !(previewGroupLimit >= 0)) {
throw new Error('previewGroupLimit 不能为负数: ' + previewGroupLimit)
}
const record = result as Record<string, unknown>
const taskId = record.taskId
if (typeof taskId !== 'number' || !Number.isFinite(taskId) || taskId <= 0) {
throw new Error('taskId 必须是正整数: ' + taskId)
}
const summary: Record<string, unknown> = {}
for (const key of Object.keys(record)) {
if (key === 'items' || key === 'groups') continue
summary[key] = record[key]
}
if (previewRowLimit != null && previewRowLimit > 0) {
const rows = record.items
if (Array.isArray(rows)) {
summary.previewItems = rows.slice(0, previewRowLimit)
}
}
if (previewGroupLimit != null && previewGroupLimit > 0) {
const groups = record.groups
if (Array.isArray(groups)) {
summary.previewGroups = groups.slice(0, previewGroupLimit)
}
}
return summary as Omit<T, 'items' | 'groups'> & {
previewItems?: unknown[]
previewGroups?: unknown[]
}
}
@@ -0,0 +1,145 @@
/**
* 轮询状态机(Task 95)。
*
* 收敛轮询 UI 的深色主题无关状态语义:错误提示(error)、重试(retry)、
* 终态刷新(markRefreshed)的有界状态转换。
*
* 状态流:idle → start → polling →(fail)→ retrying →(fail…)→ failed
* └→ succeed → done(终态;仅刷新历史,不重复计数)
*
* 有界语义:
* - 重试次数有上限(maxAttempts),超过后进入 failed,不再重试;
* - 终态幂等:重复 succeed 不覆盖状态、不重复计数;markRefreshed 只计一次;
* - 未 start 时 fail/succeed 抛错;isTerminal 依赖抛错时调用失败但状态
* 零变更,恢复后可继续。
*/
export interface PollingStateMachineOptions {
/** 重试上限,必须为正整数,达到上限后失败不再重试 */
maxAttempts: number
/** 判定终态,默认 SUCCESS/FAILED;抛错时调用失败且状态不变 */
isTerminal?: (status: string) => boolean
}
export interface PollingStateMachine {
/** idle | polling | retrying | done | failed */
status: string
/** 是否仍在轮询(polling 或 retrying */
retrying: boolean
attempts: number
errorCount: number
/** 最近一次终态状态字符串(未终态为空串) */
terminalStatus: string
/** 最近一次错误消息 */
lastError: string
/** 是否已刷新历史(终态后至多一次) */
refreshed: boolean
refreshCount: number
start: () => void
/** 记录一次失败;未超上限进入 retrying,超上限进入 failed */
fail: (message: string) => void
/** 记录一次成功;终态后重复调用幂等 */
succeed: (status: string) => void
/** 标记终态历史已刷新;仅 done/failed 后首个调用生效 */
markRefreshed: () => void
/** 已发生的重试序号列表 */
retries: () => number[]
}
export function createPollingStateMachine(options: PollingStateMachineOptions): PollingStateMachine {
if (typeof options !== 'object' || options == null) {
throw new Error('options 必须是对象')
}
if (!(options.maxAttempts > 0)) {
throw new Error('maxAttempts 必须为正数: ' + options.maxAttempts)
}
if (!Number.isInteger(options.maxAttempts)) {
throw new Error('maxAttempts 必须为整数: ' + options.maxAttempts)
}
const isTerminal = options.isTerminal ?? ((status: string) => status === 'SUCCESS' || status === 'FAILED')
if (typeof isTerminal !== 'function') {
throw new Error('isTerminal 必须是函数')
}
let status = 'idle'
let attempts = 0
let errorCount = 0
let terminalStatus = ''
let lastError = ''
let refreshCount = 0
const retrySequence: number[] = []
function start() {
if (status === 'done' || status === 'failed') return
status = 'polling'
attempts = 0
}
function fail(message: string) {
if (status === 'idle') {
throw new Error('未开始轮询')
}
if (status === 'done' || status === 'failed') return
attempts += 1
errorCount += 1
lastError = message
if (attempts >= options.maxAttempts) {
status = 'failed'
} else {
retrySequence.push(attempts)
status = 'retrying'
}
}
function succeed(terminal: string) {
if (status === 'idle') {
throw new Error('未开始轮询')
}
if (status === 'done' || status === 'failed') return
if (!isTerminal(terminal)) {
// 非终态响应:按一次失败计入,沿用重试语义
fail('非终态响应: ' + terminal)
return
}
attempts += 1
terminalStatus = terminal
status = 'done'
}
function markRefreshed() {
if (status !== 'done' && status !== 'failed') return
if (refreshCount > 0) return
refreshCount = 1
}
return {
get status() {
return status
},
get retrying() {
return status === 'polling' || status === 'retrying'
},
get attempts() {
return attempts
},
get errorCount() {
return errorCount
},
get terminalStatus() {
return terminalStatus
},
get lastError() {
return lastError
},
get refreshed() {
return refreshCount > 0
},
get refreshCount() {
return refreshCount
},
start,
fail,
succeed,
markRefreshed,
retries: () => [...retrySequence],
}
}
@@ -0,0 +1,141 @@
/**
* 进度响应 MapTask 82)。
*
* 为前端任务轮询的进度响应快照提供带 TTL 与最大条目数的内存缓存:
* - 条目在写入后 ttlMs 毫秒过期,读取/枚举/清理时惰性移除;
* - 条目数超过 maxEntries 时驱逐最旧条目(首次写入顺序),缓存始终有界;
* - 时钟可注入(now),测试可用虚拟时钟验证 TTL 边界,不依赖真实时间。
*
* 纯 TS 无副作用模块;set 对非法 key 抛出「key 必须是正整数」错误(fail-fast),
* 时钟抛错时 set 不留下任何部分写入;读取路径(get/has/delete)对非法 key 宽容。
* 供 useTaskProgressLoop 的可选缓存选项复用。
*/
export interface ProgressResponseCacheOptions {
/** 条目过期时间(毫秒),必须为正数 */
ttlMs: number
/** 缓存最大条目数,必须为正数 */
maxEntries: number
/** 时钟来源;默认 Date.now(),测试可注入虚拟时钟 */
now?: () => number
}
interface Entry<V> {
value: V
createdAt: number
}
export function createProgressResponseCache<V>(options: ProgressResponseCacheOptions) {
const ttlMs = options.ttlMs
const maxEntries = options.maxEntries
const now = options.now ?? Date.now
if (!(ttlMs > 0)) {
throw new Error('ttlMs 必须为正数: ' + ttlMs)
}
if (!(maxEntries > 0)) {
throw new Error('maxEntries 必须为正数: ' + maxEntries)
}
const map = new Map<number, Entry<V>>()
function validateKey(key: number): boolean {
return Number.isFinite(key) && key > 0 && Number.isInteger(key)
}
function isExpired(entry: Entry<V>, at: number): boolean {
return at - entry.createdAt >= ttlMs
}
function purgeExpired(): number {
const at = now()
let removed = 0
for (const [key, entry] of map) {
if (isExpired(entry, at)) {
map.delete(key)
removed += 1
}
}
return removed
}
function evictIfNeeded(at: number) {
while (map.size > maxEntries) {
const oldest = map.keys().next().value as number | undefined
if (oldest == null) break
map.delete(oldest)
}
}
function set(key: number, value: V) {
if (!validateKey(key)) {
throw new Error('key 必须是正整数: ' + key)
}
const createdAt = now()
if (map.has(key)) {
map.set(key, { value, createdAt })
} else {
map.set(key, { value, createdAt })
evictIfNeeded(createdAt)
}
}
function get(key: number): V | undefined {
if (!validateKey(key)) return undefined
const at = now()
const entry = map.get(key)
if (!entry) return undefined
if (isExpired(entry, at)) {
map.delete(key)
return undefined
}
return entry.value
}
function has(key: number): boolean {
if (!validateKey(key)) return false
const at = now()
const entry = map.get(key)
if (!entry) return false
if (isExpired(entry, at)) {
map.delete(key)
return false
}
return true
}
function deleteEntry(key: number): boolean {
if (!validateKey(key)) return false
return map.delete(key)
}
function clear() {
map.clear()
}
function entries(): Array<[number, V]> {
const at = now()
const result: Array<[number, V]> = []
for (const [key, entry] of map) {
if (isExpired(entry, at)) {
map.delete(key)
continue
}
result.push([key, entry.value])
}
return result
}
return {
get size() {
return map.size
},
set,
get,
has,
delete: deleteEntry,
clear,
entries,
purgeExpired,
}
}
export type ProgressResponseCache<V> = ReturnType<typeof createProgressResponseCache<V>>
@@ -0,0 +1,139 @@
/**
* 发布前检查清单(Task 100)。
*
* 收敛发布前的三个验收步骤:回滚演练、git commit 对应关系检查、
* 交付清单核对。每步由 deps 提供真实实现(SSH 回滚、git log、
* 产物校验),清单产出结构化结果。
*
* 语义:
* - 回滚演练失败或 commit 缺失时整单 ok=false,但不中断后续步骤,
* 其余检查照常产出完整报告;
* - releaseCommits 为空、deliverables 为空:安全跳过对应检查,视为通过;
* - maxCommits 限制 commit 检查数量(超出部分计入 unverified),检查有界;
* - deps 抛错(SSH/git 故障):整轮 run 抛错,零部分结果,依赖恢复后
* 同一清单重跑即全绿;
* - 校验失败 fail-fast:deps 非对象、缺函数、数组字段非法、maxCommits
* 非正数、commit 记录缺 hash 均抛错。
*/
export interface ReleaseCommit {
hash: string
subject?: string
}
export interface RollbackResult {
ok: boolean
output: string
}
export interface ReleaseCheckDeps {
/** 列出当前仓库 commit(含 hash 与 subject */
listCommits: () => Promise<ReleaseCommit[]>
/** 执行回滚演练 */
rollback: () => Promise<RollbackResult>
/** 验证发布后页面/核心请求 */
verify: () => Promise<RollbackResult>
}
export interface ReleaseChecklistOptions {
deps: ReleaseCheckDeps
/** 本次发布对应的 commit hash 列表 */
releaseCommits: string[]
/** 交付物清单(jar、exe、vue-dist 等) */
deliverables?: string[]
/** commit 检查数量上限,必须为正数,默认 100 */
maxCommits?: number
}
export interface ReleaseCheckResult {
ok: boolean
steps: Array<{ id: string; ok: boolean }>
rollback: RollbackResult
verify: RollbackResult
commitMap: {
checked: number
missing: string[]
unverified: string[]
}
deliverables: {
passed: boolean
total: number
items: string[]
}
}
export interface ReleaseChecklist {
run: () => Promise<ReleaseCheckResult>
}
export function createReleaseChecklist(options: ReleaseChecklistOptions): ReleaseChecklist {
if (typeof options !== 'object' || options == null || !options.deps) {
throw new Error('deps 必须是对象')
}
if (typeof options.deps.listCommits !== 'function') {
throw new Error('listCommits 必须是函数')
}
if (typeof options.deps.rollback !== 'function') {
throw new Error('rollback 必须是函数')
}
if (typeof options.deps.verify !== 'function') {
throw new Error('verify 必须是函数')
}
if (!Array.isArray(options.releaseCommits)) {
throw new Error('releaseCommits 必须是数组')
}
const deliverables = options.deliverables ?? []
if (!Array.isArray(deliverables)) {
throw new Error('deliverables 必须是数组')
}
const maxCommits = options.maxCommits ?? 100
if (!(maxCommits > 0)) {
throw new Error('maxCommits 必须为正数: ' + maxCommits)
}
async function run(): Promise<ReleaseCheckResult> {
const steps: Array<{ id: string; ok: boolean }> = []
let ok = true
const rollback = await options.deps.rollback()
steps.push({ id: 'rollback-drill', ok: rollback.ok })
if (!rollback.ok) ok = false
const commits = await options.deps.listCommits()
const available = new Set<string>()
for (const commit of commits) {
if (typeof commit.hash !== 'string' || commit.hash.length === 0) {
throw new Error('commit 缺少 hash')
}
available.add(commit.hash)
}
const missing: string[] = []
const unverified: string[] = []
for (let i = 0; i < options.releaseCommits.length; i++) {
const hash = options.releaseCommits[i]
if (i >= maxCommits) {
unverified.push(hash)
continue
}
if (!available.has(hash)) {
missing.push(hash)
ok = false
}
}
steps.push({ id: 'commit-map', ok: missing.length === 0 && unverified.length === 0 })
const verify = await options.deps.verify()
steps.push({ id: 'deliverables', ok: verify.ok })
if (!verify.ok) ok = false
return {
ok,
steps,
rollback,
verify,
commitMap: { checked: options.releaseCommits.length, missing, unverified },
deliverables: { passed: verify.ok, total: deliverables.length, items: [...deliverables] },
}
}
return { run }
}
@@ -0,0 +1,95 @@
/**
* 响应式截图计划(Task 94)。
*
* 为多页应用生成移动端/桌面端响应式验收截图清单:入口 × 视口 的笛卡尔积
* (先页面后视口,顺序稳定),每个 shot 有可预期且唯一的文件名
* `<entry>-<label>.png`。同一入口/视口重复登记自动去重;maxShots 限制
* 截图总数(超出部分截断并计数 dropped)。
*
* 校验失败 fail-fast(入口/视口非数组、label 为空、宽高非正数、maxShots
* 非正数),零状态变更;视口列表读取抛错时调用失败,不产生部分计划,
* 依赖恢复后同一输入可重新计算。输入对象永不修改。
*/
export interface ShotViewport {
/** 视口标识(也用于文件名),不能为空 */
label: string
width: number
height: number
}
export interface ShotEntry {
entry: string
viewport: ShotViewport
filename: string
}
export interface ResponsiveShotPlanOptions {
/** 页面入口列表 */
entries: string[]
/** 视口列表(重复 label 去重) */
viewports: ShotViewport[]
/** 截图总数上限,必须为正数,默认 1000 */
maxShots?: number
}
export interface ResponsiveShotPlan {
/** 有序截图清单 */
shots: ShotEntry[]
shotCount: number
/** 因 maxShots 截断而丢弃的数量 */
droppedCount: number
/** 每个视口 label 的截图数量统计 */
viewportStats: () => Record<string, number>
}
export function createResponsiveShotPlan(options: ResponsiveShotPlanOptions): ResponsiveShotPlan {
if (!Array.isArray(options.entries)) {
throw new Error('entries 必须是数组')
}
if (!Array.isArray(options.viewports)) {
throw new Error('viewports 必须是数组')
}
const maxShots = options.maxShots ?? 1000
if (!(maxShots > 0)) {
throw new Error('maxShots 必须为正数: ' + maxShots)
}
const entries = Array.from(new Set(options.entries.filter((entry) => typeof entry === 'string' && entry.length > 0)))
const viewports: ShotViewport[] = []
for (const viewport of options.viewports) {
if (typeof viewport.label !== 'string' || viewport.label.length === 0) {
throw new Error('label 不能为空')
}
if (!(viewport.width > 0)) {
throw new Error('width 必须为正数: ' + viewport.width)
}
if (!(viewport.height > 0)) {
throw new Error('height 必须为正数: ' + viewport.height)
}
if (!viewports.some((item) => item.label === viewport.label)) {
viewports.push({ label: viewport.label, width: viewport.width, height: viewport.height })
}
}
const shots: ShotEntry[] = []
let droppedCount = 0
for (const entry of entries) {
for (const viewport of viewports) {
if (shots.length >= maxShots) {
droppedCount += 1
continue
}
shots.push({ entry, viewport, filename: `${entry}-${viewport.label}.png` })
}
}
function viewportStats(): Record<string, number> {
const stats: Record<string, number> = {}
for (const shot of shots) {
stats[shot.viewport.label] = (stats[shot.viewport.label] ?? 0) + 1
}
return stats
}
return { shots, shotCount: shots.length, droppedCount, viewportStats }
}
@@ -0,0 +1,145 @@
/**
* 前端任务轮询基线(Task 81)。
*
* 为浏览器端的任务轮询建立三条可观测、有界的基线:
* - 请求量:recordRequest() 记录发起的轮询请求次数;
* - 响应体大小:recordResponseItems() 累计每次批量进度响应(整个 items 数组)的字节数,并记录单次最大响应;
* - 页面内存:最近一次响应按条目保留在内存缓存中(条目数 / 缓存字节双上限),
* 超限时驱逐最旧条目,保证缓存有界、不随任务数量无界增长。
*
* 该组件是纯 TS、无副作用模块,供 useTaskProgressLoop 及其测试复用;
* 所有统计在 reset() 前单调累加,key 提取或序列化失败时整个调用失败且不产生任何状态变更。
*/
export interface TaskPollingBaselineOptions {
/** 缓存最大条目数,默认 500 */
maxEntries?: number
/** 缓存最大字节数,默认 1 MiB */
maxCacheBytes?: number
}
export interface TaskPollingBaselineStats {
/** 累计轮询请求次数 */
requestCount: number
/** 累计响应体总字节数 */
totalResponseBytes: number
/** 单次最大响应体字节数 */
largestResponseBytes: number
/** 缓存中的条目数 */
entryCount: number
/** 缓存占用字节数 */
cachedBytes: number
/** 被驱逐或拒绝的条目数 */
droppedCount: number
/** 缓存条目 key(按首次到达顺序) */
entryKeys: number[]
}
/** 每条缓存条目的固定估算开销:key + 记录结构 + 文本编码余量 */
const ENTRY_OVERHEAD = 64
const DEFAULT_MAX_ENTRIES = 500
const DEFAULT_MAX_CACHE_BYTES = 1024 * 1024
interface CacheEntry {
bytes: number
}
export function createTaskPollingBaseline(options: TaskPollingBaselineOptions = {}) {
const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES
const maxCacheBytes = options.maxCacheBytes ?? DEFAULT_MAX_CACHE_BYTES
if (!(maxEntries > 0) || !(maxCacheBytes > 0)) {
throw new Error('上限配置必须为正数: maxEntries=' + maxEntries + ', maxCacheBytes=' + maxCacheBytes)
}
const cache = new Map<number, CacheEntry>()
let requestCount = 0
let totalResponseBytes = 0
let largestResponseBytes = 0
let cachedBytes = 0
let droppedCount = 0
function evictIfNeeded() {
while (cache.size > maxEntries || cachedBytes > maxCacheBytes) {
const oldest = cache.keys().next().value as number | undefined
if (oldest == null) break
const entry = cache.get(oldest)
cache.delete(oldest)
cachedBytes -= entry ? entry.bytes : 0
droppedCount += 1
}
}
function recordRequest() {
requestCount += 1
}
/**
* 记录一次批量进度响应,返回成功写入缓存的条目数。
* items 非数组、extractKey 非函数或 key 提取抛错时整个调用失败,不产生任何状态变更。
*/
function recordResponseItems<T>(
items: T[],
extractKey: (item: T) => number | null | undefined,
): number {
if (!Array.isArray(items)) throw new Error('items 必须是数组')
if (typeof extractKey !== 'function') throw new Error('extractKey 必须是函数')
const encoder = new TextEncoder()
const prepared: Array<{ key: number; bytes: number }> = []
let arrayBytes = 0
if (items.length > 0) {
arrayBytes = encoder.encode(JSON.stringify(items)).byteLength
}
for (const item of items) {
const serialized = JSON.stringify(item)
const bytes = encoder.encode(serialized).byteLength + ENTRY_OVERHEAD
const key = extractKey(item)
if (typeof key === 'number' && Number.isFinite(key) && key > 0) {
prepared.push({ key, bytes })
}
}
// 全部校验与序列化通过后才提交统计,保证失败路径零状态变更
totalResponseBytes += arrayBytes
if (arrayBytes > largestResponseBytes) largestResponseBytes = arrayBytes
let inserted = 0
for (const { key, bytes } of prepared) {
if (bytes > maxCacheBytes) {
droppedCount += 1
continue
}
const existing = cache.get(key)
if (existing) cachedBytes -= existing.bytes
cache.set(key, { bytes })
cachedBytes += bytes
inserted += 1
evictIfNeeded()
}
return inserted
}
function reset() {
cache.clear()
requestCount = 0
totalResponseBytes = 0
largestResponseBytes = 0
cachedBytes = 0
droppedCount = 0
}
function stats(): TaskPollingBaselineStats {
return {
requestCount,
totalResponseBytes,
largestResponseBytes,
entryCount: cache.size,
cachedBytes,
droppedCount,
entryKeys: [...cache.keys()],
}
}
return { recordRequest, recordResponseItems, reset, stats }
}
export type TaskPollingBaseline = ReturnType<typeof createTaskPollingBaseline>
@@ -0,0 +1,141 @@
/**
* 任务轮询协调器(Task 83)。
*
* 统一不同页面的任务轮询语义:
* - 去重:同一 taskId 重复 add 只保留一份,不产生重复记录与重复请求;
* - in-flight 合并:并发 runOnce 合并为单次请求,全部调用共享同一次结果;
* - 终态清理:markTerminal 从集合移除任务并回调 onTerminal,重复调用幂等
* (只回调一次),终态后任务不再被轮询;
* - 有界:maxTasks 上限内的 add 被拒绝并计数,防止任务集无界增长。
*
* 纯 TS 无副作用模块;add 对非法 taskId fail-fast 抛错,loader 抛错时
* in-flight 释放且任务集合保持不变,可恢复后继续工作。供
* useTaskProgressLoop 的可选协调选项复用。
*/
export interface TaskPollingCoordinatorOptions {
/** 任务集合最大容量,默认 500 */
maxTasks?: number
/** 终态清理回调;同一 taskId 只回调一次 */
onTerminal?: (taskId: number) => void
}
export interface TaskPollingCoordinatorStats {
/** 成功加入的任务数 */
addCount: number
/** 因重复被去重掉的 add 数 */
dedupedCount: number
/** 因超过 maxTasks 被拒绝的 add 数 */
rejectedCount: number
/** 触发终态清理的任务数 */
terminalCount: number
/** 实际发出的请求次数 */
requestCount: number
/** 被合并进 in-flight 请求的 runOnce 调用数 */
mergedCount: number
}
export function createTaskPollingCoordinator(options: TaskPollingCoordinatorOptions = {}) {
const maxTasks = options.maxTasks ?? 500
const onTerminal = options.onTerminal ?? (() => {})
if (!(maxTasks > 0)) {
throw new Error('maxTasks 必须为正数: ' + maxTasks)
}
const set = new Set<number>()
let addCount = 0
let dedupedCount = 0
let rejectedCount = 0
let terminalCount = 0
let requestCount = 0
let mergedCount = 0
let inFlight: Promise<unknown> | null = null
function validateTaskId(taskId: number): boolean {
return Number.isFinite(taskId) && taskId > 0 && Number.isInteger(taskId)
}
function add(taskId: number): boolean {
if (!validateTaskId(taskId)) {
throw new Error('taskId 必须是正整数: ' + taskId)
}
if (set.has(taskId)) {
dedupedCount += 1
return false
}
if (set.size >= maxTasks) {
rejectedCount += 1
return false
}
set.add(taskId)
addCount += 1
return true
}
function remove(taskId: number): boolean {
if (!validateTaskId(taskId)) return false
return set.delete(taskId)
}
function markTerminal(taskId: number) {
if (!validateTaskId(taskId)) return
if (set.delete(taskId)) {
terminalCount += 1
onTerminal(taskId)
}
}
function clear() {
set.clear()
}
function has(taskId: number): boolean {
return set.has(taskId)
}
function ids(): number[] {
return [...set]
}
function runOnce<T>(loader: (taskIds: number[]) => Promise<T>): Promise<T> {
if (inFlight) {
mergedCount += 1
return inFlight as Promise<T>
}
const taskIds = ids()
if (taskIds.length === 0) {
return Promise.resolve(undefined as unknown as T)
}
requestCount += 1
inFlight = loader(taskIds).finally(() => {
inFlight = null
})
return inFlight as Promise<T>
}
function stats(): TaskPollingCoordinatorStats {
return {
addCount,
dedupedCount,
rejectedCount,
terminalCount,
requestCount,
mergedCount,
}
}
return {
get size() {
return set.size
},
add,
remove,
markTerminal,
clear,
has,
ids,
runOnce,
stats,
}
}
export type TaskPollingCoordinator = ReturnType<typeof createTaskPollingCoordinator>
@@ -1,10 +1,51 @@
/**
* 任务轮询配置(Task 86 扩展)。
*
* 把隐藏页面轮询间隔、前台恢复与退避策略统一收敛为可编程配置:
* - getTaskPollIntervalMs / getTaskProgressCacheTtlMs:按 document.visibilityState
* 自适应切换可见/隐藏间隔与缓存 TTL(历史行为不变);
* - getTaskPollBackoffMs(attempt):指数退避(base × 2^attempt,封顶 max),
* 供轮询失败/in-flight 重试使用,重试次数不再硬编码;
* - getTaskForegroundRefreshEnabled / getTaskForegroundRefreshDelayMs:控制
* 页面切回前台时是否立即拉取一轮及其延迟;
* - configureTaskPolling / resetTaskPollingConfig:运行时配置与恢复默认。
*
* 纯 TS 模块:配置校验失败时抛错且保持原值(零状态变更),document 缺失
* (Node/SSR)时按可见间隔降级;所有 getter 在任意配置下都有确定返回值。
*/
export const TASK_POLL_VISIBLE_INTERVAL_MS = 10000;
export const TASK_POLL_HIDDEN_INTERVAL_MS = 60000;
export const TASK_PROGRESS_VISIBLE_CACHE_MILLIS = 5000;
export const TASK_PROGRESS_HIDDEN_CACHE_MILLIS = 30000;
export const TASK_POLL_BACKOFF_BASE_MS = 500;
export const TASK_POLL_BACKOFF_MAX_MS = 5000;
export interface TaskPollingConfig {
/** 退避基数(毫秒),必须为正数 */
backoffBaseMs?: number;
/** 退避上限(毫秒),必须为正数 */
backoffMaxMs?: number;
/** 页面切回前台时是否立即拉取一轮,默认 true */
foregroundRefreshEnabled?: boolean;
/** 前台恢复延迟(毫秒),默认 0 */
foregroundRefreshDelayMs?: number;
}
const DEFAULTS: Required<TaskPollingConfig> = {
backoffBaseMs: TASK_POLL_BACKOFF_BASE_MS,
backoffMaxMs: TASK_POLL_BACKOFF_MAX_MS,
foregroundRefreshEnabled: true,
foregroundRefreshDelayMs: 0,
};
let config: Required<TaskPollingConfig> = { ...DEFAULTS };
function isVisible() {
return typeof document === "undefined" || document.visibilityState === "visible";
}
export function getTaskPollIntervalMs() {
if (typeof document !== "undefined" && document.visibilityState !== "visible") {
if (!isVisible()) {
return TASK_POLL_HIDDEN_INTERVAL_MS;
}
return TASK_POLL_VISIBLE_INTERVAL_MS;
@@ -16,3 +57,36 @@ export function getTaskProgressCacheTtlMs() {
}
return TASK_PROGRESS_VISIBLE_CACHE_MILLIS;
}
/** 第 attempt 次退避延迟:base × 2^attempt,封顶 maxattempt 非法时按首次(0)处理 */
export function getTaskPollBackoffMs(attempt: number) {
const safe = Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0;
const doubled = config.backoffBaseMs * Math.pow(2, safe);
return doubled > config.backoffMaxMs ? config.backoffMaxMs : doubled;
}
export function getTaskForegroundRefreshEnabled() {
return config.foregroundRefreshEnabled;
}
export function getTaskForegroundRefreshDelayMs() {
return config.foregroundRefreshDelayMs;
}
export function configureTaskPolling(partial: TaskPollingConfig) {
const next = { ...config, ...partial };
if (!(next.backoffBaseMs > 0)) {
throw new Error("退避基数必须为正数: " + next.backoffBaseMs);
}
if (!(next.backoffMaxMs > 0)) {
throw new Error("退避上限必须为正数: " + next.backoffMaxMs);
}
if (!(next.foregroundRefreshDelayMs >= 0)) {
throw new Error("恢复延迟不能为负数: " + next.foregroundRefreshDelayMs);
}
config = next;
}
export function resetTaskPollingConfig() {
config = { ...DEFAULTS };
}
@@ -0,0 +1,167 @@
/**
* 进度请求响应缓存与并发合并(Task 90)。
*
* 为进度接口提供断网、超时、服务恢复与重复响应场景的确定行为:
* - set/get:按 key 缓存最近一次成功响应,TTL 过期后惰性清除并计 miss;
* - startInflight/getInflight/endInflight:同一 key 的并发请求只发起一次,
* 后续调用方合并到同一 Promise(重复登记返回 false,不覆盖原 Promise);
* - clear:断网恢复后清空缓存与 in-flight,重新可用;
* - 请求失败(断网/超时)只释放 in-flight 槽位,不清除未过期的旧缓存,
* 服务恢复后旧缓存仍可降级命中。
*
* 有界内存:maxEntries 超限驱逐最旧条目;maxInflight 超限拒绝合并
* (请求照常发出,只是不合并);空 key 写入与登记 fail-fast 抛错。
* 纯 TS 模块,无副作用;now 可注入用于测试时钟推进。
*/
export interface TaskProgressRequestCacheOptions {
/**
* 缓存有效期(毫秒),必须为正数;也可传函数在每次 set 时动态取值
* (如按页面可见性切换 TTL),函数返回值同样必须为正数
*/
ttlMs: number | (() => number)
/** 缓存条目上限,超过时驱逐最旧条目;必须为正数 */
maxEntries?: number
/** 同时合并的 in-flight 请求数上限,超过时拒绝登记;必须为正数 */
maxInflight?: number
/** 时钟注入,默认 Date.now */
now?: () => number
}
export interface TaskProgressRequestCacheStats {
cacheEntries: number
inflightCount: number
hitCount: number
missCount: number
evictedCount: number
rejectedCount: number
}
export interface TaskProgressRequestCache<T> {
get: (key: string) => T | undefined
set: (key: string, data: T) => void
clear: () => void
startInflight: (key: string, promise: Promise<T>) => boolean
getInflight: (key: string) => Promise<T> | undefined
endInflight: (key: string) => void
stats: () => TaskProgressRequestCacheStats
}
export function createTaskProgressRequestCache<T>(options: TaskProgressRequestCacheOptions) {
const ttl = options.ttlMs
const ttlValue = typeof ttl === 'number' ? ttl : 0
if (typeof ttl === 'number') {
if (!(ttl > 0)) {
throw new Error('ttlMs 必须为正数: ' + ttl)
}
} else if (typeof ttl === 'function') {
if (!(ttl() > 0)) {
throw new Error('ttlMs 必须为正数: ' + ttl())
}
} else {
throw new Error('ttlMs 必须为正数: ' + String(ttl))
}
const maxEntries = options.maxEntries ?? 100
const maxInflight = options.maxInflight ?? 16
if (!(maxEntries > 0)) {
throw new Error('maxEntries 必须为正数: ' + maxEntries)
}
if (!(maxInflight > 0)) {
throw new Error('maxInflight 必须为正数: ' + maxInflight)
}
const now = options.now ?? Date.now
interface CacheEntry {
data: T
expiresAt: number
createdAt: number
}
const entries = new Map<string, CacheEntry>()
const inflight = new Map<string, Promise<T>>()
let hitCount = 0
let missCount = 0
let evictedCount = 0
let rejectedCount = 0
function requireKey(key: string) {
if (typeof key !== 'string' || key.length === 0) {
throw new Error('key 必须是非空字符串: ' + String(key))
}
}
function get(key: string): T | undefined {
if (typeof key !== 'string' || key.length === 0) {
return undefined
}
const entry = entries.get(key)
if (!entry) {
missCount += 1
return undefined
}
if (entry.expiresAt <= now()) {
entries.delete(key)
missCount += 1
return undefined
}
hitCount += 1
return entry.data
}
function set(key: string, data: T) {
requireKey(key)
const current = now()
const ttlMs = typeof ttl === 'number' ? ttl : ttl()
if (!(ttlMs > 0)) {
throw new Error('ttlMs 必须为正数: ' + ttlMs)
}
entries.set(key, { data, expiresAt: current + ttlMs, createdAt: current })
while (entries.size > maxEntries) {
const oldestKey = entries.keys().next().value as string
entries.delete(oldestKey)
evictedCount += 1
}
}
function clear() {
entries.clear()
inflight.clear()
}
function startInflight(key: string, promise: Promise<T>): boolean {
requireKey(key)
if (inflight.has(key)) {
rejectedCount += 1
return false
}
if (inflight.size >= maxInflight) {
rejectedCount += 1
return false
}
inflight.set(key, promise)
return true
}
function getInflight(key: string): Promise<T> | undefined {
if (typeof key !== 'string' || key.length === 0) return undefined
return inflight.get(key)
}
function endInflight(key: string) {
if (typeof key !== 'string' || key.length === 0) return
inflight.delete(key)
}
function stats(): TaskProgressRequestCacheStats {
return {
cacheEntries: entries.size,
inflightCount: inflight.size,
hitCount,
missCount,
evictedCount,
rejectedCount,
}
}
return { get, set, clear, startInflight, getInflight, endInflight, stats }
}
export type TaskProgressRequestCacheHandle = ReturnType<typeof createTaskProgressRequestCache>
@@ -0,0 +1,103 @@
/**
* 三端验证工作流编排器(Task 97)。
*
* 把 Java 全量测试、Python unittest、Vue 类型检查与构建四个验证步骤
* 收敛为可注入执行器的确定性流程:步骤按序执行,产出逐项结果。
*
* 语义:
* - 每个步骤的 run(stepId) 返回 { ok, output }ok=false 时计入 failed
* - stopOnFailure(默认 true)下失败即停止,剩余步骤计入 skipped;
* - maxSteps 限制可执行步骤数量(超出部分计入 skipped),执行有界;
* - 步骤执行器抛错:整次 runAll 抛错,不产生部分结果记录(调用方可
* 捕获后恢复重跑同一 runner);
* - 校验失败 fail-faststeps 非数组、step id 为空、run 非函数、maxSteps
* 非正数、run 返回结果缺 output 均抛错。
*/
export interface VerificationStep {
id: string
label: string
}
export interface StepRunResult {
ok: boolean
output: string
}
export interface VerificationRunnerDeps {
/** 执行单个步骤,返回结果;抛错时整次 runAll 抛错 */
run: (stepId: string) => Promise<StepRunResult>
}
export interface VerificationRunnerOptions {
steps: VerificationStep[]
run: (stepId: string) => Promise<StepRunResult>
/** 失败即停止(默认 true);false 时全部执行、失败累计 */
stopOnFailure?: boolean
/** 可执行步骤数上限,必须为正数,默认 100 */
maxSteps?: number
}
export interface VerificationResult {
ok: boolean
passed: Array<VerificationStep & { output: string }>
failed: Array<VerificationStep & { output: string }>
skipped: string[]
}
export interface VerificationRunner {
runAll: () => Promise<VerificationResult>
}
export function createVerificationRunner(options: VerificationRunnerOptions): VerificationRunner {
if (!Array.isArray(options.steps)) {
throw new Error('steps 必须是数组')
}
for (const step of options.steps) {
if (typeof step.id !== 'string' || step.id.length === 0) {
throw new Error('step id 不能为空')
}
}
if (typeof options.run !== 'function') {
throw new Error('run 必须是函数')
}
const stopOnFailure = options.stopOnFailure ?? true
const maxSteps = options.maxSteps ?? 100
if (!(maxSteps > 0)) {
throw new Error('maxSteps 必须为正数: ' + maxSteps)
}
async function runAll(): Promise<VerificationResult> {
const passed: Array<VerificationStep & { output: string }> = []
const failed: Array<VerificationStep & { output: string }> = []
const skipped: string[] = []
let ok = true
for (let i = 0; i < options.steps.length; i++) {
const step = options.steps[i]
if (i >= maxSteps) {
skipped.push(step.id)
continue
}
const result = await options.run(step.id)
if (typeof result.output !== 'string') {
throw new Error('output 必须是字符串')
}
if (result.ok) {
passed.push({ ...step, output: result.output })
} else {
ok = false
failed.push({ ...step, output: result.output })
if (stopOnFailure) {
for (const rest of options.steps.slice(i + 1)) {
skipped.push(rest.id)
}
break
}
}
}
return { ok, passed, failed, skipped }
}
return { runAll }
}
+124
View File
@@ -0,0 +1,124 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createApiFieldCompatChecker } from '../src/shared/api-field-compat.ts'
const batchSchema = {
taskId: { type: 'number', required: true },
status: { type: 'string', required: true },
progress: { type: 'number' },
message: { type: 'string' },
items: { type: 'array' },
}
test('test_task_096_api_compat_normal_default_path', () => {
const checker = createApiFieldCompatChecker({ schema: batchSchema })
const violations = checker.check({ taskId: 1, status: 'SUCCESS', progress: 50 })
assert.deepEqual(violations, [], '全部必填字段存在且类型正确')
assert.equal(checker.lastResult().violations.length, 0)
assert.equal(checker.lastResult().checkedCount, 1)
assert.equal(checker.lastResult().passed, true)
assert.deepEqual(checker.checkedFields(), ['taskId', 'status', 'progress', 'message', 'items'])
})
test('test_task_096_api_compat_normal_multiple_items', () => {
const checker = createApiFieldCompatChecker({ schema: batchSchema })
const rows = [
{ taskId: 1, status: 'RUNNING' },
{ taskId: 2, status: 'SUCCESS', progress: 100 },
{ taskId: 3, status: 'FAILED', message: 'err' },
]
for (const row of rows) {
assert.deepEqual(checker.check(row), [])
}
assert.equal(checker.stats().checkedCount, 3)
assert.equal(checker.stats().violationCount, 0)
// 顺序稳定:检查记录按输入顺序
assert.deepEqual(checker.stats().checkedIds, [1, 2, 3])
})
test('test_task_096_api_compat_normal_repeated_operation_is_idempotent', () => {
const checker = createApiFieldCompatChecker({ schema: batchSchema })
const row = { taskId: 1, status: 'SUCCESS' }
assert.deepEqual(checker.check(row), [])
assert.deepEqual(checker.check(row), [], '同一输入重复检查结果一致')
assert.equal(checker.stats().checkedCount, 2)
assert.equal(checker.stats().violationCount, 0)
// 输入对象不被修改
assert.equal('items' in row, false)
// 无 schema 的纯遍历模式
const free = createApiFieldCompatChecker({ schema: {} })
assert.deepEqual(free.check({ anything: 1, other: 'x' }), [])
})
test('test_task_096_api_compat_boundary_empty_input', () => {
const checker = createApiFieldCompatChecker({ schema: batchSchema })
// 空对象:必填字段缺失
const violations = checker.check({})
assert.equal(violations.length, 2)
assert.ok(violations.some((v) => v.field === 'taskId' && v.kind === 'missing'))
assert.ok(violations.some((v) => v.field === 'status' && v.kind === 'missing'))
// 空数组响应:无字段可查,跳过
const checker2 = createApiFieldCompatChecker({ schema: batchSchema })
checker2.checkBatch([])
assert.equal(checker2.stats().checkedCount, 0)
assert.equal(checker2.stats().violationCount, 0)
assert.equal(checker2.lastResult().passed, true)
})
test('test_task_096_api_compat_boundary_single_item', () => {
const checker = createApiFieldCompatChecker({ schema: batchSchema })
const violations = checker.check({ taskId: 7, status: 'SUCCESS' })
assert.deepEqual(violations, [], '单条最小合法记录通过')
assert.equal(checker.checkedFields().includes('progress'), true, '可选字段也纳入检查清单')
assert.equal(checker.lastResult().checkedCount, 1)
})
test('test_task_096_api_compat_boundary_limit_and_overflow', () => {
const checker = createApiFieldCompatChecker({ schema: batchSchema, maxChecked: 2 })
checker.check({ taskId: 1, status: 'a' })
checker.check({ taskId: 2, status: 'b' })
checker.check({ taskId: 3, status: 'c' })
assert.equal(checker.stats().checkedCount, 2, '超过 maxChecked 后不再检查')
assert.equal(checker.stats().skippedCount, 1)
assert.deepEqual(checker.stats().checkedIds, [1, 2])
// 类型错误累计
const checker2 = createApiFieldCompatChecker({ schema: batchSchema })
checker2.check({ taskId: 'x' as never, status: 5 as never })
assert.equal(checker2.stats().violationCount, 2)
})
test('test_task_096_api_compat_invalid_input_rejected', () => {
assert.throws(() => createApiFieldCompatChecker({} as never), /schema 必须是对象/)
assert.throws(() => createApiFieldCompatChecker({ schema: null as never }), /schema 必须是对象/)
assert.throws(() => createApiFieldCompatChecker({ schema: [] as never }), /schema 必须是对象/)
assert.throws(
() => createApiFieldCompatChecker({ schema: { a: { type: 'unknown' } } }),
/不支持的字段类型: unknown/,
)
assert.throws(
() => createApiFieldCompatChecker({ schema: batchSchema, maxChecked: 0 }),
/maxChecked 必须为正数/,
)
const checker = createApiFieldCompatChecker({ schema: batchSchema })
assert.throws(() => checker.check(null as never), /对象必须是普通对象/)
assert.throws(() => checker.check([1, 2] as never), /对象必须是普通对象/)
assert.throws(() => checker.checkBatch('x' as never), /对象数组必须是数组/)
})
test('test_task_096_api_compat_dependency_failure_releases_resources', () => {
// 检查对象读取失败:check 抛错但状态不被污染,恢复后可用
let broken = false
const checker = createApiFieldCompatChecker({ schema: batchSchema })
const poisoned = new Proxy({ taskId: 1, status: 'SUCCESS' }, {
get(target, prop, receiver) {
if (broken && prop === 'taskId') throw new Error('row getter down')
return Reflect.get(target, prop, receiver)
},
})
broken = true
assert.throws(() => checker.check(poisoned), /row getter down/)
assert.equal(checker.stats().checkedCount, 0, '失败不产生检查记录')
broken = false
assert.deepEqual(checker.check(poisoned), [], '依赖恢复后同一检查器继续可用')
assert.equal(checker.stats().checkedCount, 1)
})
@@ -0,0 +1,141 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createAsinForceThrottle } from '../src/shared/asin-force-throttle.ts'
function clock(start = 1000) {
let now = start
return {
now: () => now,
tick: (ms: number) => {
now += ms
},
}
}
test('test_task_085_asin_polling_normal_default_path', () => {
const c = clock()
const throttle = createAsinForceThrottle({ ttlMs: 30_000, now: c.now })
assert.equal(throttle.shouldForce(101), true)
throttle.markForce(101)
assert.equal(throttle.shouldForce(101), false)
assert.equal(throttle.isThrottled(101), true)
c.tick(29_999)
assert.equal(throttle.shouldForce(101), false)
c.tick(2)
assert.equal(throttle.shouldForce(101), true)
assert.equal(throttle.isThrottled(101), false)
const stats = throttle.stats()
assert.equal(stats.forcedCount, 1)
assert.equal(stats.skippedCount, 2)
assert.equal(stats.activeCount, 0)
})
test('test_task_085_asin_polling_normal_multiple_items', () => {
const c = clock()
const throttle = createAsinForceThrottle({ ttlMs: 30_000, now: c.now })
const ids = [201, 202, 203, 204, 205]
for (const id of ids) throttle.markForce(id)
assert.equal(throttle.activeCount, 5)
for (const id of ids) assert.equal(throttle.shouldForce(id), false)
// 不同 taskId 的 TTL 相互独立
c.tick(31_000)
for (const id of ids) assert.equal(throttle.shouldForce(id), true)
throttle.markForce(202)
assert.equal(throttle.shouldForce(202), false)
assert.equal(throttle.shouldForce(201), true)
})
test('test_task_085_asin_polling_normal_repeated_operation_is_idempotent', () => {
const c = clock()
const throttle = createAsinForceThrottle({ ttlMs: 30_000, now: c.now })
throttle.markForce(301)
throttle.markForce(301)
throttle.markForce(301)
assert.equal(throttle.activeCount, 1)
assert.equal(throttle.shouldForce(301), false)
assert.equal(throttle.stats().forcedCount, 1)
// 重复检查不改变状态
assert.equal(throttle.shouldForce(301), false)
assert.equal(throttle.isThrottled(301), true)
assert.equal(throttle.stats().forcedCount, 1)
})
test('test_task_085_asin_polling_boundary_empty_input', () => {
const c = clock()
const throttle = createAsinForceThrottle({ ttlMs: 30_000, now: c.now })
assert.equal(throttle.activeCount, 0)
assert.equal(throttle.shouldForce(401), true)
throttle.clear(401)
assert.equal(throttle.activeCount, 0)
// clear 不存在的 taskId 无副作用
throttle.clearAll()
assert.deepEqual(throttle.stats(), { forcedCount: 0, skippedCount: 0, activeCount: 0 })
})
test('test_task_085_asin_polling_boundary_single_item', () => {
const c = clock()
const throttle = createAsinForceThrottle({ ttlMs: 1000, now: c.now })
assert.equal(throttle.shouldForce(501), true)
throttle.markForce(501)
assert.equal(throttle.isThrottled(501), true)
assert.equal(throttle.activeCount, 1)
c.tick(1000)
assert.equal(throttle.isThrottled(501), false)
assert.equal(throttle.activeCount, 0)
})
test('test_task_085_asin_polling_boundary_limit_and_overflow', () => {
const c = clock()
const throttle = createAsinForceThrottle({ ttlMs: 30_000, now: c.now })
// 大量任务:所有任务都被记录,未标记的任务不被节流
for (let i = 1; i <= 200; i++) throttle.markForce(i)
assert.equal(throttle.activeCount, 200)
assert.equal(throttle.shouldForce(1), false)
assert.equal(throttle.shouldForce(200), false)
assert.equal(throttle.shouldForce(201), true)
// TTL 到期后整批可重新 force(重试窗口),记录不无界增长
c.tick(60_001)
assert.equal(throttle.shouldForce(1), true)
assert.equal(throttle.activeCount, 0)
throttle.markForce(1)
assert.equal(throttle.activeCount, 1)
})
test('test_task_085_asin_polling_invalid_input_rejected', () => {
const c = clock()
const throttle = createAsinForceThrottle({ ttlMs: 30_000, now: c.now })
assert.throws(() => throttle.markForce(0), /taskId 必须是正整数/)
assert.throws(() => throttle.markForce(-1), /taskId 必须是正整数/)
assert.throws(() => throttle.markForce(NaN), /taskId 必须是正整数/)
assert.throws(() => throttle.markForce(1.5), /taskId 必须是正整数/)
// 读取路径宽容:非法 id 安全返回
assert.equal(throttle.shouldForce(0), false)
assert.equal(throttle.isThrottled(-1), false)
assert.equal(throttle.clear(0), false)
assert.throws(() => createAsinForceThrottle({ ttlMs: 0 }), /ttlMs 必须为正数/)
})
test('test_task_085_asin_polling_dependency_failure_releases_resources', () => {
let now = 1000
let broken = false
const faultyNow = () => {
if (broken) throw new Error('clock down')
return now
}
const throttle = createAsinForceThrottle({ ttlMs: 30_000, now: faultyNow })
throttle.markForce(701)
assert.equal(throttle.activeCount, 1)
// 时钟故障时读取抛错,但记录不丢失
broken = true
assert.throws(() => throttle.shouldForce(701), /clock down/)
assert.throws(() => throttle.isThrottled(701), /clock down/)
assert.throws(() => throttle.stats(), /clock down/)
// 恢复后同一实例继续工作,记录仍在
broken = false
assert.equal(throttle.isThrottled(701), true)
assert.equal(throttle.shouldForce(701), false)
// 终态后 clear 释放记录
throttle.clear(701)
assert.equal(throttle.activeCount, 0)
assert.equal(throttle.shouldForce(701), true)
})
@@ -0,0 +1,210 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createChunkPlanner } from '../src/shared/build-chunk-planner.ts'
test('test_task_091_chunk_normal_default_path', () => {
const planner = createChunkPlanner({
entries: ['publish', 'dedupe', 'convert'],
vendorRules: [
{ chunk: 'element-plus', patterns: ['element-plus', '@element-plus'] },
{ chunk: 'shared', patterns: ['/src/shared/'] },
{ chunk: 'vue-vendor', patterns: ['/node_modules/vue/', '/node_modules/@vue/', 'vue-router'] },
],
perEntryRules: [
{ entry: 'publish', chunk: 'publish-page', patterns: ['/src/pages/publish/'] },
{ entry: 'dedupe', chunk: 'dedupe-page', patterns: ['/src/pages/dedupe/'] },
{ entry: 'convert', chunk: 'convert-page', patterns: ['/src/pages/convert/'] },
],
})
const config = planner.manualChunksConfig()
assert.deepEqual(
Object.keys(config).sort(),
['convert-page', 'dedupe-page', 'element-plus', 'publish-page', 'shared', 'vue-vendor'].sort(),
'默认路径产出全部计划 chunk',
)
// Element Plus 模块进入独立 chunk
assert.equal(
planner.assign('D:/repo/node_modules/element-plus/es/components/button/index.mjs', 'publish'),
'element-plus',
)
// 公共业务模块进入公共业务 chunk
assert.equal(planner.assign('D:/repo/src/shared/api/java-modules.ts', 'publish'), 'shared')
// 页面私有模块进入页面 chunk
assert.equal(planner.assign('D:/repo/src/pages/publish/components/Table.vue', 'publish'), 'publish-page')
// vue 依赖进入 vendor chunk
assert.equal(planner.assign('D:/repo/node_modules/vue/dist/vue.runtime.esm-bundler.js', 'publish'), 'vue-vendor')
// 未匹配模块不强制拆包,交给 Vite 默认处理
assert.equal(planner.assign('D:/repo/src/main.ts', 'publish'), undefined)
const stats = planner.stats()
assert.equal(stats.entryCount, 3)
assert.equal(stats.chunkCount, 6)
assert.equal(stats.sharedChunkCount, 3)
assert.equal(stats.unmatchedCount, 1)
})
test('test_task_091_chunk_normal_multiple_items', () => {
const entries = ['publish', 'dedupe', 'convert', 'split', 'delete-brand']
const planner = createChunkPlanner({
entries,
vendorRules: [{ chunk: 'element-plus', patterns: ['element-plus'] }],
perEntryRules: entries.map((entry) => ({
entry,
chunk: `${entry}-page`,
patterns: [`/src/pages/${entry}/`],
})),
})
// 批量分配:多个模块同一 chunk,结果不丢失且顺序稳定
for (let i = 0; i < 3; i++) {
assert.equal(planner.assign(`D:/repo/node_modules/element-plus/es/components/table/index.mjs`, entries[i]), 'element-plus')
}
for (const entry of entries) {
assert.equal(planner.assign(`D:/repo/src/pages/${entry}/index.vue`, entry), `${entry}-page`)
}
const plan = planner.planByEntry()
assert.equal(plan.length, 5)
assert.equal(plan[0].entry, 'publish')
assert.equal(plan[0].chunks[0], 'element-plus')
assert.equal(plan[0].chunks[1], 'publish-page')
assert.equal(plan[4].chunks[1], 'delete-brand-page')
// 每入口均含 element-plus + 自己的页面 chunk
for (const item of plan) {
assert.ok(item.chunks.includes('element-plus'))
assert.ok(item.chunks.includes(`${item.entry}-page`))
assert.equal(item.chunks.length, 2)
}
})
test('test_task_091_chunk_normal_repeated_operation_is_idempotent', () => {
const options = {
entries: ['publish', 'dedupe'],
vendorRules: [
{ chunk: 'element-plus', patterns: ['element-plus'] },
{ chunk: 'shared', patterns: ['/src/shared/'] },
],
perEntryRules: [
{ entry: 'publish', chunk: 'publish-page', patterns: ['/src/pages/publish/'] },
],
}
const planner = createChunkPlanner(options)
const once = planner.manualChunksConfig()
const twice = planner.manualChunksConfig()
assert.deepEqual(once, twice, '重复生成配置结果一致')
// 重复登记同 chunk 同名规则:合并去重,不产生重复 chunk
const merged = createChunkPlanner({
entries: ['publish', 'dedupe'],
vendorRules: [
{ chunk: 'element-plus', patterns: ['element-plus'] },
{ chunk: 'element-plus', patterns: ['@element-plus'] },
{ chunk: 'shared', patterns: ['/src/shared/'] },
],
perEntryRules: [
{ entry: 'publish', chunk: 'publish-page', patterns: ['/src/pages/publish/'] },
],
})
assert.deepEqual(merged.manualChunksConfig()['element-plus'].sort(), ['@element-plus', 'element-plus'])
assert.equal(merged.stats().chunkCount, 3)
// 重复 assign 同一模块:结果不变,命中统计幂等
assert.equal(planner.assign('x/node_modules/element-plus/a.js', 'publish'), 'element-plus')
assert.equal(planner.assign('x/node_modules/element-plus/a.js', 'publish'), 'element-plus')
assert.equal(planner.stats().unmatchedCount, 0)
})
test('test_task_091_chunk_boundary_empty_input', () => {
const planner = createChunkPlanner({ entries: [] })
assert.deepEqual(planner.manualChunksConfig(), {}, '无入口产空配置')
assert.deepEqual(planner.planByEntry(), [])
assert.equal(planner.stats().entryCount, 0)
assert.equal(planner.stats().chunkCount, 0)
assert.equal(planner.assign('x/module.js', 'publish'), undefined)
})
test('test_task_091_chunk_boundary_single_item', () => {
const planner = createChunkPlanner({
entries: ['withdraw'],
vendorRules: [{ chunk: 'element-plus', patterns: ['element-plus'] }],
perEntryRules: [{ entry: 'withdraw', chunk: 'withdraw-page', patterns: ['/src/pages/withdraw/'] }],
})
assert.equal(planner.assign('/repo/node_modules/element-plus/es/index.mjs', 'withdraw'), 'element-plus')
assert.equal(planner.assign('/repo/src/pages/withdraw/index.vue', 'withdraw'), 'withdraw-page')
assert.equal(planner.assign('/repo/src/pages/withdraw/index.vue', 'publish'), undefined, '入口不匹配时页面规则不生效')
const plan = planner.planByEntry()
assert.equal(plan.length, 1)
assert.equal(plan[0].chunks.length, 2)
})
test('test_task_091_chunk_boundary_limit_and_overflow', () => {
const planner = createChunkPlanner({
entries: ['a', 'b', 'c'],
vendorRules: [1, 2, 3, 4, 5].map((n) => ({ chunk: `vendor-${n}`, patterns: [`/vendor-${n}/`] })),
perEntryRules: [
{ entry: 'a', chunk: 'a-page', patterns: ['/src/pages/a/'] },
{ entry: 'b', chunk: 'b-page', patterns: ['/src/pages/b/'] },
{ entry: 'c', chunk: 'c-page', patterns: ['/src/pages/c/'] },
],
maxRules: 3,
maxEntries: 2,
})
const stats = planner.stats()
assert.equal(stats.chunkCount, 5, '规则超限后剩余规则被拒绝')
assert.equal(stats.rejectedCount, 2, 'vendor-4/vendor-5 被拒绝')
assert.equal(stats.entryCount, 2, '入口超过 maxEntries 只登记前 2 个')
assert.equal(planner.assign('x/vendor-1/a.js'), 'vendor-1')
assert.equal(planner.assign('x/vendor-4/a.js'), undefined, '被拒绝的规则不再生效')
const plan = planner.planByEntry()
assert.equal(plan.length, 2)
})
test('test_task_091_chunk_invalid_input_rejected', () => {
assert.throws(() => createChunkPlanner({ entries: 'publish' as never }), /entries 必须是数组/)
assert.throws(
() => createChunkPlanner({ entries: ['a'], vendorRules: [{ chunk: '', patterns: ['x'] }] }),
/chunk 名不能为空/,
)
assert.throws(
() => createChunkPlanner({ entries: ['a'], vendorRules: [{ chunk: 'v', patterns: [] }] }),
/patterns 必须是非空数组/,
)
assert.throws(
() => createChunkPlanner({ entries: ['a'], vendorRules: [{ chunk: 'v', patterns: 'x' as never }] }),
/patterns 必须是非空数组/,
)
assert.throws(() => createChunkPlanner({ entries: ['a'], maxRules: 0 }), /maxRules 必须为正数/)
assert.throws(() => createChunkPlanner({ entries: ['a'], maxEntries: -1 }), /maxEntries 必须为正数/)
assert.throws(
() => createChunkPlanner({ entries: ['a'], perEntryRules: [{ entry: '', chunk: 'x', patterns: ['y'] }] }),
/entry 名不能为空/,
)
})
test('test_task_091_chunk_dependency_failure_releases_resources', () => {
let broken = true
const poisonedPatterns = new Proxy(['element-plus'], {
get(target, prop, receiver) {
if (broken && prop === '0') throw new Error('pattern getter down')
return Reflect.get(target, prop, receiver)
},
ownKeys(target) {
if (broken) throw new Error('pattern ownKeys down')
return Reflect.ownKeys(target)
},
})
// 依赖(规则读取)失败:构造期抛错,规划器不产生部分状态
assert.throws(
() =>
createChunkPlanner({
entries: ['publish'],
vendorRules: [{ chunk: 'element-plus', patterns: poisonedPatterns as unknown as string[] }],
}),
/pattern/,
)
// 错误可恢复:依赖恢复后同一配置成功
broken = false
const planner = createChunkPlanner({
entries: ['publish'],
vendorRules: [{ chunk: 'element-plus', patterns: poisonedPatterns as unknown as string[] }],
})
assert.equal(planner.assign('/repo/node_modules/element-plus/a.js', 'publish'), 'element-plus')
assert.deepEqual(planner.manualChunksConfig()['element-plus'], ['element-plus'])
assert.equal(planner.stats().rejectedCount, 0, '失败不产生拒绝计数')
assert.equal(planner.stats().chunkCount, 1)
})
@@ -0,0 +1,236 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createChunkTransferReport } from '../src/shared/chunk-transfer-report.ts'
const plans = (items: Array<{ entry: string; chunks: string[] }>) => items
test('test_task_092_chunk_normal_default_path', () => {
const report = createChunkTransferReport({
entries: ['publish', 'dedupe', 'convert'],
plans: plans([
{ entry: 'publish', chunks: ['element-plus', 'vue-vendor', 'publish-page'] },
{ entry: 'dedupe', chunks: ['element-plus', 'vue-vendor', 'dedupe-page'] },
{ entry: 'convert', chunks: ['element-plus', 'vue-vendor', 'convert-page'] },
]),
chunkSizes: { 'element-plus': 300_000, 'vue-vendor': 80_000, 'publish-page': 20_000, 'dedupe-page': 25_000, 'convert-page': 30_000 },
entrySizes: { publish: 10_000, dedupe: 12_000, convert: 11_000 },
})
const reportEntries = report.entries
assert.equal(reportEntries.length, 3)
assert.deepEqual(
reportEntries.map((e) => e.entry),
['publish', 'dedupe', 'convert'],
'入口顺序稳定',
)
assert.equal(reportEntries[0].entryBytes, 10_000)
assert.equal(reportEntries[0].chunkBytes, 400_000)
assert.equal(reportEntries[0].transferBytes, 410_000)
assert.equal(reportEntries[0].chunkCount, 3)
// 公共 chunk 只统计一次
assert.equal(report.sharedBytes, 380_000, 'element-plus + vue-vendor 合计')
assert.equal(report.largest?.entry, 'convert')
assert.equal(report.largest?.transferBytes, 421_000)
assert.equal(report.smallest?.entry, 'publish')
assert.equal(report.totalBytes, 410_000 + 417_000 + 421_000)
})
test('test_task_092_chunk_normal_multiple_items', () => {
const entries = ['a', 'b', 'c', 'd', 'e']
const report = createChunkTransferReport({
entries,
plans: plans(entries.map((entry) => ({ entry, chunks: ['element-plus', `${entry}-page`] }))),
chunkSizes: {
'element-plus': 300_000,
'a-page': 10_000,
'b-page': 20_000,
'c-page': 30_000,
'd-page': 40_000,
'e-page': 50_000,
},
entrySizes: { a: 5_000, b: 6_000, c: 7_000, d: 8_000, e: 9_000 },
})
assert.equal(report.entries.length, 5)
// 批量结果不丢失、顺序稳定
assert.deepEqual(report.entries.map((e) => e.entry), entries)
for (let i = 0; i < entries.length; i++) {
const item = report.entries[i]
assert.equal(item.transferBytes, (i + 1) * 10_000 + 300_000 + (i + 5) * 1_000)
}
assert.equal(report.sharedBytes, 300_000, 'element-plus 出现在全部入口,只计一次')
assert.deepEqual(report.entries.map((e) => e.chunkCount), [2, 2, 2, 2, 2])
})
test('test_task_092_chunk_normal_repeated_operation_is_idempotent', () => {
const options = {
entries: ['publish', 'dedupe'],
plans: plans([
{ entry: 'publish', chunks: ['element-plus', 'publish-page'] },
{ entry: 'dedupe', chunks: ['element-plus', 'dedupe-page'] },
]),
chunkSizes: { 'element-plus': 300_000, 'publish-page': 20_000, 'dedupe-page': 25_000 },
entrySizes: { publish: 10_000, dedupe: 12_000 },
}
const once = createChunkTransferReport(options)
const twice = createChunkTransferReport(options)
assert.deepEqual(once.entries, twice.entries, '同一输入两次计算结果一致')
assert.equal(once.sharedBytes, twice.sharedBytes)
assert.equal(once.totalBytes, twice.totalBytes)
assert.deepEqual(once.largest, twice.largest)
assert.deepEqual(once.smallest, twice.smallest)
// 输入对象不被修改
assert.equal(options.chunkSizes['element-plus'], 300_000)
assert.equal(options.plans[0].chunks.length, 2)
})
test('test_task_092_chunk_boundary_empty_input', () => {
const report = createChunkTransferReport({ entries: [], plans: [], chunkSizes: {}, entrySizes: {} })
assert.deepEqual(report.entries, [])
assert.equal(report.sharedBytes, 0)
assert.equal(report.totalBytes, 0)
assert.equal(report.largest, undefined)
assert.equal(report.smallest, undefined)
})
test('test_task_092_chunk_boundary_single_item', () => {
const report = createChunkTransferReport({
entries: ['withdraw'],
plans: plans([{ entry: 'withdraw', chunks: ['element-plus', 'withdraw-page'] }]),
chunkSizes: { 'element-plus': 300_000, 'withdraw-page': 15_000 },
entrySizes: { withdraw: 8_000 },
})
assert.equal(report.entries.length, 1)
assert.equal(report.entries[0].transferBytes, 323_000)
assert.equal(report.sharedBytes, 0, 'chunk 只出现在一个入口时不算公共')
assert.equal(report.largest?.entry, 'withdraw')
assert.equal(report.smallest?.entry, 'withdraw')
assert.equal(report.largest?.transferBytes, report.smallest?.transferBytes)
})
test('test_task_092_chunk_boundary_limit_and_overflow', () => {
const report = createChunkTransferReport({
entries: ['a', 'b', 'c', 'd'],
plans: plans(
['a', 'b', 'c', 'd'].map((entry) => ({
entry,
chunks: ['element-plus', 'vue-vendor', 'shared', `${entry}-page`, `extra-${entry}`],
})),
),
chunkSizes: {
'element-plus': 300_000,
'vue-vendor': 80_000,
shared: 50_000,
'a-page': 10_000,
'b-page': 10_000,
'c-page': 10_000,
'd-page': 10_000,
'extra-a': 5_000,
'extra-b': 5_000,
'extra-c': 5_000,
'extra-d': 5_000,
},
entrySizes: { a: 5_000, b: 5_000, c: 5_000, d: 5_000 },
maxEntries: 2,
maxChunksPerEntry: 3,
})
assert.equal(report.entries.length, 2, '超过 maxEntries 只统计前 2 个入口')
assert.deepEqual(
report.entries.map((e) => e.entry),
['a', 'b'],
)
assert.equal(report.entries[0].chunkCount, 3, '每入口最多统计 3 个 chunk')
assert.equal(report.entries[0].chunkBytes, 430_000, 'element-plus + vue-vendor + shared')
assert.equal(report.entries[0].transferBytes, 435_000)
// 超出部分的 chunk 尺寸不计入
assert.ok(!('extra-a' in report.chunkBytesOf(report.entries[0])), '被截断的 chunk 不计入')
// 入口缺失 plan 或尺寸:fail-fast 抛错
assert.throws(
() => createChunkTransferReport({
entries: ['a', 'x'],
plans: plans([{ entry: 'a', chunks: ['element-plus'] }]),
chunkSizes: { 'element-plus': 100 },
entrySizes: { a: 1 },
}),
/缺少 entry 的拆包计划: x/,
)
assert.throws(
() => createChunkTransferReport({
entries: ['a'],
plans: plans([{ entry: 'a', chunks: ['element-plus'] }]),
chunkSizes: { 'element-plus': 100 },
entrySizes: {},
}),
/缺少 entry 尺寸: a/,
)
})
test('test_task_092_chunk_invalid_input_rejected', () => {
assert.throws(() => createChunkTransferReport({} as never), /entries 必须是数组/)
assert.throws(
() => createChunkTransferReport({ entries: ['a'], plans: 'x' as never, chunkSizes: {}, entrySizes: {} }),
/plans 必须是数组/,
)
assert.throws(
() => createChunkTransferReport({ entries: ['a'], plans: [], chunkSizes: null as never, entrySizes: {} }),
/chunkSizes 必须是对象/,
)
assert.throws(
() => createChunkTransferReport({ entries: ['a'], plans: [], chunkSizes: {}, entrySizes: null as never }),
/entrySizes 必须是对象/,
)
assert.throws(
() => createChunkTransferReport({ entries: ['a'], plans: [], chunkSizes: {}, entrySizes: new Date() as never }),
/entrySizes 必须是对象/,
)
assert.throws(
() => createChunkTransferReport({ entries: ['a'], plans: [], chunkSizes: {}, entrySizes: {}, maxEntries: 0 }),
/maxEntries 必须为正数/,
)
assert.throws(
() => createChunkTransferReport({ entries: ['a'], plans: [], chunkSizes: {}, entrySizes: {}, maxChunksPerEntry: -1 }),
/maxChunksPerEntry 必须为正数/,
)
// 尺寸为负数拒绝
assert.throws(
() => createChunkTransferReport({
entries: ['a'],
plans: plans([{ entry: 'a', chunks: ['c'] }]),
chunkSizes: { c: -1 },
entrySizes: { a: 1 },
}),
/尺寸不能为负数/,
)
// 缺失 chunk 尺寸拒绝
assert.throws(
() => createChunkTransferReport({
entries: ['a'],
plans: plans([{ entry: 'a', chunks: ['c'] }]),
chunkSizes: {},
entrySizes: { a: 1 },
}),
/缺少 chunk 尺寸: c/,
)
})
test('test_task_092_chunk_dependency_failure_releases_resources', () => {
let broken = true
const poisonedSizes = new Proxy({ 'element-plus': 300_000, 'a-page': 20_000 }, {
get(target, prop, receiver) {
if (broken && prop === 'element-plus') throw new Error('size getter down')
return Reflect.get(target, prop, receiver)
},
})
const options = {
entries: ['a'],
plans: plans([{ entry: 'a', chunks: ['element-plus', 'a-page'] }]),
chunkSizes: poisonedSizes as Record<string, number>,
entrySizes: { a: 5_000 },
}
// 依赖(尺寸读取)失败:计算抛错,不产生部分结果
assert.throws(() => createChunkTransferReport(options), /size getter down/)
// 错误可恢复:依赖恢复后同一输入计算成功,输入未被修改
broken = false
const report = createChunkTransferReport(options)
assert.equal(report.entries.length, 1)
assert.equal(report.entries[0].transferBytes, 325_000)
assert.deepEqual(options.plans[0].chunks, ['element-plus', 'a-page'])
})
@@ -0,0 +1,139 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createHealthCheckRunner, type HealthProbe } from '../src/shared/health-check-runner.ts'
const PROBES: HealthProbe[] = [
{ id: 'startup', label: '真实启动', probe: async () => 'listening :18080' },
{ id: 'health', label: '健康检查', probe: async () => '{"status":"UP"}' },
{ id: 'core-request', label: '核心请求', probe: async () => '200 OK' },
{ id: 'external-deps', label: '外部依赖调用', probe: async () => 'mysql/redis/oss ok' },
]
test('test_task_098_task_normal_default_path', async () => {
const runner = createHealthCheckRunner({ probes: PROBES })
const result = await runner.runAll()
assert.equal(result.ok, true)
assert.equal(result.passed.length, 4)
assert.deepEqual(result.failed, [])
assert.deepEqual(
result.passed.map((p) => p.id),
['startup', 'health', 'core-request', 'external-deps'],
'按序执行全部探针',
)
assert.equal(result.passed[0].output, 'listening :18080')
assert.ok(result.passed[0].durationMs >= 0)
})
test('test_task_098_task_normal_multiple_items', async () => {
const runner = createHealthCheckRunner({ probes: PROBES })
const result = await runner.runAll()
// 批量场景:结果不丢失且顺序稳定
assert.equal(result.passed.length, 4)
assert.equal(result.passed[3].id, 'external-deps')
assert.equal(result.passed[3].output, 'mysql/redis/oss ok')
assert.equal(result.totalDurationMs >= 0, true)
})
test('test_task_098_task_normal_repeated_operation_is_idempotent', async () => {
const runner = createHealthCheckRunner({ probes: PROBES })
const first = await runner.runAll()
const second = await runner.runAll()
assert.equal(first.ok, second.ok)
assert.deepEqual(first.passed.map((p) => p.id), second.passed.map((p) => p.id))
assert.deepEqual(first.failed, second.failed)
})
test('test_task_098_task_boundary_empty_input', async () => {
const runner = createHealthCheckRunner({ probes: [] })
const result = await runner.runAll()
assert.equal(result.ok, true, '无探针视为通过')
assert.deepEqual(result.passed, [])
assert.deepEqual(result.failed, [])
assert.equal(result.totalDurationMs >= 0, true)
})
test('test_task_098_task_boundary_single_item', async () => {
const runner = createHealthCheckRunner({ probes: [PROBES[0]] })
const result = await runner.runAll()
assert.equal(result.ok, true)
assert.equal(result.passed.length, 1)
assert.equal(result.passed[0].id, 'startup')
})
test('test_task_098_task_boundary_limit_and_overflow', async () => {
// 探针失败:标记 failed,其余探针继续执行(健康检查全量报告)
let failHealth = false
const probes = PROBES.map((p) =>
p.id === 'health'
? { ...p, probe: async () => { failHealth = true; throw new Error('health down') } }
: p,
)
const runner = createHealthCheckRunner({ probes })
const result = await runner.runAll()
assert.equal(result.ok, false)
assert.equal(result.failed.length, 1)
assert.equal(result.failed[0].id, 'health')
assert.ok(result.failed[0].output.includes('health down'))
assert.equal(result.passed.length, 3, '失败不中断其余探针')
assert.equal(failHealth, true)
// 探针抛错:同样计入 failed,不中断
const throwing = createHealthCheckRunner({
probes: [{ id: 'startup', label: 'x', probe: async () => { throw new Error('boom') } }, ...PROBES.slice(1)],
})
const thrown = await throwing.runAll()
assert.equal(thrown.ok, false)
assert.equal(thrown.failed[0].id, 'startup')
assert.ok(thrown.failed[0].output.includes('boom'))
assert.equal(thrown.passed.length, 3)
})
test('test_task_098_task_invalid_input_rejected', async () => {
assert.throws(() => createHealthCheckRunner({} as never), /probes 必须是数组/)
assert.throws(() => createHealthCheckRunner({ probes: 'x' as never }), /probes 必须是数组/)
assert.throws(
() => createHealthCheckRunner({ probes: [{ id: '', label: 'x', probe: async () => '' }] }),
/probe id 不能为空/,
)
assert.throws(
() => createHealthCheckRunner({ probes: [{ id: 'a', label: 'x', probe: undefined as never }] }),
/probe 必须是函数/,
)
assert.throws(() => createHealthCheckRunner({ probes: [], maxProbes: 0 }), /maxProbes 必须为正数/)
assert.throws(
() => createHealthCheckRunner({ probes: PROBES, timeoutMs: 0 }),
/timeoutMs 必须为正数/,
)
// probe 返回非字符串:抛错
const bad = createHealthCheckRunner({ probes: [{ id: 'a', label: 'x', probe: async () => 1 as never }] })
await assert.rejects(() => bad.runAll(), /output 必须是字符串/)
})
test('test_task_098_task_dependency_failure_releases_resources', async () => {
// 外部依赖探针失败:错误可恢复,修复后同一 runner 重跑全绿
let broken = true
let attempts = 0
const runner = createHealthCheckRunner({
probes: [
{ id: 'startup', label: '启动', probe: async () => 'ok' },
{
id: 'external-deps',
label: '外部依赖',
probe: async () => {
attempts += 1
if (broken) throw new Error('mysql connection refused')
return 'mysql ok'
},
},
],
})
const first = await runner.runAll()
assert.equal(first.ok, false)
assert.equal(first.failed[0].id, 'external-deps')
assert.equal(first.passed.length, 1)
assert.ok(first.failed[0].output.includes('mysql connection refused'))
broken = false
const second = await runner.runAll()
assert.equal(second.ok, true)
assert.equal(second.passed.length, 2)
assert.equal(attempts, 2)
})
@@ -0,0 +1,175 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createLoadTestRecorder, type LoadMetric, type LoadSample } from '../src/shared/load-test-recorder.ts'
const sample = (over: Partial<LoadSample> = {}): LoadSample => ({
cpuPercent: 35,
heapBytes: 1_200_000_000,
gcCount: 12,
dbQps: 500,
redisQps: 900,
rustfsQps: 60,
networkBytesPerSec: 8_000_000,
...over,
})
test('test_task_099_rustfs_normal_default_path', async () => {
const recorder = createLoadTestRecorder({
metric: async () => ({ ok: true, output: '200 OK', latencyMs: 120 }),
sample: async () => sample(),
})
const result = await recorder.run()
assert.equal(result.ok, true)
assert.equal(result.summary.requests, 100)
assert.equal(result.summary.failures, 0)
assert.equal(result.summary.ok, true)
assert.equal(result.metrics.length, 100)
assert.equal(result.metrics[0].latencyMs, 120)
const s = result.summary.samples
assert.equal(s.cpuPercent.avg, 35)
assert.equal(s.heapBytes.avg, 1_200_000_000)
assert.equal(s.dbQps.avg, 500)
assert.equal(s.redisQps.avg, 900)
assert.equal(s.rustfsQps.avg, 60)
assert.equal(s.networkBytesPerSec.avg, 8_000_000)
assert.equal(s.gcCount.avg, 12)
})
test('test_task_099_rustfs_normal_multiple_items', async () => {
// 批量压测:并发 N 路,结果不丢失且顺序稳定
let seq = 0
const recorder = createLoadTestRecorder({
metric: async (i: number) => ({ ok: true, output: 'ok', latencyMs: 100 + (i % 5) }),
sample: async () => {
const cpu = 30 + (seq % 3) * 10
seq += 1
return sample({ cpuPercent: cpu })
},
concurrency: 4,
})
const result = await recorder.run()
assert.equal(result.metrics.length, 100)
const latencies = result.metrics.map((m) => m.latencyMs)
assert.ok(latencies.every((l) => l >= 100 && l <= 104))
assert.equal(result.summary.requests, 100)
assert.equal(result.summary.ok, true)
})
test('test_task_099_rustfs_normal_repeated_operation_is_idempotent', async () => {
let n = 0
const recorder = createLoadTestRecorder({
metric: async () => {
n += 1
return { ok: true, output: 'ok', latencyMs: 50 }
},
sample: async () => sample(),
})
const first = await recorder.run()
const second = await recorder.run()
assert.equal(first.summary.requests, 100)
assert.equal(second.summary.requests, 100)
assert.equal(first.summary.ok, second.summary.ok)
assert.deepEqual(first.summary.samples.heapBytes, second.summary.samples.heapBytes)
assert.equal(n, 200, '重复执行线性增长,无残留状态')
})
test('test_task_099_rustfs_boundary_empty_input', async () => {
const recorder = createLoadTestRecorder({
requests: 0,
metric: async () => ({ ok: true, output: 'ok', latencyMs: 1 }),
sample: async () => sample(),
})
const result = await recorder.run()
assert.equal(result.summary.requests, 0)
assert.equal(result.metrics.length, 0)
assert.equal(result.summary.ok, true)
assert.equal(result.summary.failures, 0)
})
test('test_task_099_rustfs_boundary_single_item', async () => {
const recorder = createLoadTestRecorder({
requests: 1,
metric: async () => ({ ok: true, output: 'ok', latencyMs: 77 }),
sample: async () => sample(),
})
const result = await recorder.run()
assert.equal(result.metrics.length, 1)
assert.equal(result.metrics[0].latencyMs, 77)
assert.equal(result.summary.requests, 1)
assert.equal(result.summary.samples.cpuPercent.avg, 35)
})
test('test_task_099_rustfs_boundary_limit_and_overflow', async () => {
// 部分失败:失败率超阈值 → ok=false;失败计数准确
const recorder = createLoadTestRecorder({
requests: 10,
maxFailures: 2,
metric: async (i: number) =>
i % 3 === 0
? { ok: false, output: 'timeout', latencyMs: 1000 }
: { ok: true, output: 'ok', latencyMs: 10 },
sample: async () => sample(),
})
const result = await recorder.run()
assert.equal(result.summary.requests, 10)
assert.equal(result.summary.failures, 4, 'i%3===0 共 4 次')
assert.equal(result.summary.ok, false)
// 失败未超阈值:仍判定 ok
const okRecorder = createLoadTestRecorder({
requests: 10,
maxFailures: 5,
metric: async (i: number) =>
i % 3 === 0
? { ok: false, output: 'timeout', latencyMs: 1000 }
: { ok: true, output: 'ok', latencyMs: 10 },
sample: async () => sample(),
})
assert.equal((await okRecorder.run()).summary.ok, true)
})
test('test_task_099_rustfs_invalid_input_rejected', async () => {
assert.throws(() => createLoadTestRecorder({} as never), /metric 必须是函数/)
assert.throws(
() => createLoadTestRecorder({ metric: async () => ({ ok: true, output: '', latencyMs: 1 }), sample: undefined as never }),
/sample 必须是函数/,
)
assert.throws(
() => createLoadTestRecorder({ metric: async () => ({ ok: true, output: '', latencyMs: 1 }), sample: async () => sample(), requests: -1 }),
/requests 必须是非负整数/,
)
assert.throws(
() => createLoadTestRecorder({ metric: async () => ({ ok: true, output: '', latencyMs: 1 }), sample: async () => sample(), concurrency: 0 }),
/concurrency 必须为正数/,
)
assert.throws(
() => createLoadTestRecorder({ metric: async () => ({ ok: true, output: '', latencyMs: 1 }), sample: async () => sample(), maxFailures: -1 }),
/maxFailures 必须是非负整数/,
)
const recorder = createLoadTestRecorder({
metric: async () => ({ ok: true, output: 'ok', latencyMs: NaN }),
sample: async () => sample(),
})
await assert.rejects(() => recorder.run(), /latencyMs 必须为非负数值/)
})
test('test_task_099_rustfs_dependency_failure_releases_resources', async () => {
// 采样依赖抛错:单次采样失败不中断压测,统计从有效采样计算;恢复后正常
let broken = true
const recorder = createLoadTestRecorder({
requests: 10,
metric: async () => ({ ok: true, output: 'ok', latencyMs: 10 }),
sample: async () => {
if (broken) throw new Error('prometheus down')
return sample()
},
})
const result = await recorder.run()
assert.equal(result.summary.requests, 10, '采样失败不影响请求执行')
assert.equal(result.sampleFailures, 10)
assert.equal(result.summary.samples.avgCount, 0, '无有效采样时统计为空')
broken = false
const recovered = await recorder.run()
assert.equal(recovered.sampleFailures, 0)
assert.equal(recovered.summary.samples.avgCount, 10)
assert.equal(recovered.summary.samples.cpuPercent.avg, 35)
})
@@ -0,0 +1,162 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createLocalStorageLimiter } from '../src/shared/local-storage-limiter.ts'
interface FakeStorage {
getItem: (key: string) => string | null
setItem: (key: string, value: string) => void
removeItem: (key: string) => void
}
function createFakeStorage(initial: Record<string, string> = {}): FakeStorage {
const map = new Map(Object.entries(initial))
return {
getItem: (key) => map.get(key) ?? null,
setItem: (key, value) => void map.set(key, value),
removeItem: (key) => void map.delete(key),
}
}
const encoder = new TextEncoder()
const bytes = (v: unknown) => encoder.encode(JSON.stringify(v)).byteLength
test('test_task_087_task_normal_default_path', () => {
const storage = createFakeStorage()
const limiter = createLocalStorageLimiter({ storage, maxKeys: 10, maxBytes: 1024 })
const ok = limiter.write('brand:tasks', { ids: [1, 2, 3] })
assert.equal(ok, true)
assert.deepEqual(limiter.read('brand:tasks'), { ids: [1, 2, 3] })
const stats = limiter.stats()
assert.equal(stats.keyCount, 1)
assert.equal(stats.totalBytes, bytes({ ids: [1, 2, 3] }))
assert.equal(stats.evictedCount, 0)
})
test('test_task_087_task_normal_multiple_items', () => {
const storage = createFakeStorage()
const limiter = createLocalStorageLimiter({ storage, maxKeys: 10, maxBytes: 1024 })
for (let i = 1; i <= 5; i++) {
assert.equal(limiter.write(`k${i}`, { i }), true)
}
const stats = limiter.stats()
assert.equal(stats.keyCount, 5)
assert.deepEqual(limiter.read('k1'), { i: 1 })
assert.deepEqual(limiter.read('k5'), { i: 5 })
assert.equal(stats.totalBytes, bytes({ i: 1 }) * 5)
assert.equal(stats.evictedCount, 0)
})
test('test_task_087_task_normal_repeated_operation_is_idempotent', () => {
const storage = createFakeStorage()
const limiter = createLocalStorageLimiter({ storage, maxKeys: 10, maxBytes: 1024 })
limiter.write('brand:tasks', [1])
limiter.write('brand:tasks', [1])
limiter.write('brand:tasks', [1, 2, 3])
const stats = limiter.stats()
assert.equal(stats.keyCount, 1, '同 key 重复写不增加条数')
assert.equal(stats.totalBytes, bytes([1, 2, 3]))
assert.deepEqual(limiter.read('brand:tasks'), [1, 2, 3])
})
test('test_task_087_task_boundary_empty_input', () => {
const storage = createFakeStorage()
const limiter = createLocalStorageLimiter({ storage, maxKeys: 10, maxBytes: 1024 })
assert.equal(limiter.write('', { a: 1 }), false, '空 key 拒绝')
assert.equal(limiter.read('missing'), undefined)
assert.equal(limiter.remove('missing'), false)
assert.equal(limiter.write('empty', {}), true)
assert.deepEqual(limiter.read('empty'), {})
limiter.clear()
assert.equal(limiter.stats().keyCount, 0)
})
test('test_task_087_task_boundary_single_item', () => {
const storage = createFakeStorage()
const limiter = createLocalStorageLimiter({ storage, maxKeys: 10, maxBytes: 1024 })
assert.equal(limiter.write('k', 42), true)
assert.equal(limiter.read('k'), 42)
assert.equal(limiter.stats().keyCount, 1)
})
test('test_task_087_task_boundary_limit_and_overflow', () => {
// 超过 maxKeys:驱逐最旧写入的 key,保持条数有界
const s1 = createFakeStorage()
const byKeys = createLocalStorageLimiter({ storage: s1, maxKeys: 3, maxBytes: 10_000 })
byKeys.write('a', 1)
byKeys.write('b', 2)
byKeys.write('c', 3)
byKeys.write('d', 4)
const stats1 = byKeys.stats()
assert.equal(stats1.keyCount, 3)
assert.equal(stats1.evictedCount, 1)
assert.equal(byKeys.read('a'), undefined)
assert.equal(byKeys.read('d'), 4)
// 最近写入的 key 不会被驱逐;更新已存在 key 刷新其新鲜度
byKeys.write('a', 1)
byKeys.write('e', 5)
assert.equal(byKeys.read('b'), undefined)
assert.equal(byKeys.read('a'), 1)
assert.equal(byKeys.read('e'), 5)
assert.equal(byKeys.stats().keyCount, 3)
// 单条超过 maxBytes:拒绝写入,不产生部分数据
const s2 = createFakeStorage()
const byBytes = createLocalStorageLimiter({ storage: s2, maxKeys: 10, maxBytes: 100 })
assert.equal(byBytes.write('big', { x: 'y'.repeat(200) }), false)
assert.equal(byBytes.read('big'), undefined)
assert.equal(byBytes.stats().keyCount, 0)
// 批量累积超限:驱逐最旧直到有界
const s3 = createFakeStorage()
const limited = createLocalStorageLimiter({ storage: s3, maxKeys: 10, maxBytes: 60 })
for (let i = 1; i <= 5; i++) limited.write(`k${i}`, { v: 'x'.repeat(20) })
const stats3 = limited.stats()
assert.equal(stats3.keyCount, 2)
assert.equal(stats3.evictedCount, 3)
assert.equal(limited.read('k1'), undefined)
assert.equal(limited.read('k3'), undefined)
assert.ok(limited.read('k4'))
assert.ok(limited.read('k5'))
})
test('test_task_087_task_invalid_input_rejected', () => {
const storage = createFakeStorage()
assert.throws(() => createLocalStorageLimiter({ storage, maxKeys: 0, maxBytes: 100 }), /maxKeys 必须为正数/)
assert.throws(() => createLocalStorageLimiter({ storage, maxKeys: -1, maxBytes: 100 }), /maxKeys 必须为正数/)
assert.throws(() => createLocalStorageLimiter({ storage, maxKeys: 10, maxBytes: 0 }), /maxBytes 必须为正数/)
assert.throws(() => createLocalStorageLimiter({ storage, maxKeys: 10, maxBytes: -5 }), /maxBytes 必须为正数/)
assert.throws(() => createLocalStorageLimiter({ storage: null as never, maxKeys: 10, maxBytes: 100 }), /storage 必须提供/)
const limiter = createLocalStorageLimiter({ storage, maxKeys: 10, maxBytes: 100 })
assert.equal(limiter.write('', 'x'), false)
// 不可序列化值:拒绝并保持原状态
const circular: Record<string, unknown> = {}
circular.self = circular
assert.equal(limiter.write('bad', circular), false)
assert.equal(limiter.read('bad'), undefined)
assert.equal(limiter.stats().keyCount, 0)
})
test('test_task_087_task_dependency_failure_releases_resources', () => {
const inner = createFakeStorage()
const failing: FakeStorage = {
getItem: (k) => inner.getItem(k),
setItem: (k, v) => {
if (k === 'poison') throw new Error('QuotaExceededError')
inner.setItem(k, v)
},
removeItem: (k) => inner.removeItem(k),
}
const limiter = createLocalStorageLimiter({ storage: failing, maxKeys: 10, maxBytes: 1024 })
limiter.write('a', 1)
// 存储抛错:写入失败但不污染内部状态
assert.equal(limiter.write('poison', { x: 1 }), false)
assert.equal(limiter.stats().keyCount, 1, '失败的写入不占条数')
assert.equal(limiter.read('a'), 1, '已写入数据不受影响')
// 错误可恢复:非故障 key 继续工作
assert.equal(limiter.write('b', 2), true)
assert.equal(limiter.read('b'), 2)
// remove/clear 释放全部条目
limiter.remove('a')
limiter.remove('b')
assert.equal(limiter.stats().keyCount, 0)
limiter.clear()
assert.deepEqual(limiter.stats(), { keyCount: 0, totalBytes: 0, evictedCount: 0 })
})
@@ -0,0 +1,200 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { mergeHistoryItems } from '../src/shared/merge-history-items.ts'
interface Item {
taskId?: number
resultId?: number
shopName: string
taskStatus?: string
error?: string
}
const keyOf = (item: Item) => `${item.taskId || 0}:${item.resultId || 0}:${item.shopName}`
const item = (over: Partial<Item>): Item => ({ shopName: 'a', ...over })
test('test_task_084_merge_normal_default_path', () => {
const existing = [item({ taskId: 1, resultId: 1, taskStatus: 'RUNNING' })]
const incoming = [item({ taskId: 1, resultId: 1, taskStatus: 'SUCCESS' })]
const { items, updatedCount, addedCount } = mergeHistoryItems(existing, incoming, { keyOf })
assert.equal(items.length, 1)
assert.equal(items[0].taskStatus, 'SUCCESS')
assert.deepEqual(items[0], { taskId: 1, resultId: 1, shopName: 'a', taskStatus: 'SUCCESS' })
assert.equal(updatedCount, 1)
assert.equal(addedCount, 0)
assert.equal(existing.length, 1, '入参列表不得被修改')
})
test('test_task_084_merge_normal_multiple_items', () => {
const existing = Array.from({ length: 5 }, (_, i) =>
item({ taskId: 1, resultId: i + 1, shopName: `s${i + 1}`, taskStatus: 'RUNNING' }),
)
const incoming = Array.from({ length: 5 }, (_, i) =>
item({ taskId: 2, resultId: i + 1, shopName: `t${i + 1}`, taskStatus: 'SUCCESS' }),
)
const { items, addedCount, updatedCount } = mergeHistoryItems(existing, incoming, { keyOf })
assert.equal(items.length, 10)
assert.equal(addedCount, 5)
assert.equal(updatedCount, 0)
// 已有条目在前,新条目追加在后,顺序稳定
assert.deepEqual(
items.map((r) => r.shopName),
['s1', 's2', 's3', 's4', 's5', 't1', 't2', 't3', 't4', 't5'],
)
// 批量更新多条:命中后替换原位置,不改变顺序
const update = Array.from({ length: 5 }, (_, i) =>
item({ taskId: 1, resultId: i + 1, shopName: `s${i + 1}`, taskStatus: 'SUCCESS' }),
)
const again = mergeHistoryItems(items, update, { keyOf })
assert.equal(again.items.length, 10)
assert.equal(again.updatedCount, 5)
assert.equal(again.addedCount, 0)
assert.deepEqual(
again.items.map((r) => r.taskStatus),
['SUCCESS', 'SUCCESS', 'SUCCESS', 'SUCCESS', 'SUCCESS', 'SUCCESS', 'SUCCESS', 'SUCCESS', 'SUCCESS', 'SUCCESS'],
)
assert.deepEqual(
again.items.slice(0, 5).map((r) => r.shopName),
['s1', 's2', 's3', 's4', 's5'],
)
})
test('test_task_084_merge_normal_repeated_operation_is_idempotent', () => {
const existing = [item({ taskId: 1, resultId: 1, taskStatus: 'RUNNING' })]
const incoming = [item({ taskId: 1, resultId: 1, taskStatus: 'SUCCESS' })]
const once = mergeHistoryItems(existing, incoming, { keyOf })
const twice = mergeHistoryItems(once.items, incoming, { keyOf })
const thrice = mergeHistoryItems(twice.items, incoming, { keyOf })
for (const result of [once, twice, thrice]) {
assert.equal(result.items.length, 1)
assert.equal(result.items[0].taskStatus, 'SUCCESS')
}
assert.equal(twice.updatedCount, 1)
assert.equal(twice.addedCount, 0)
assert.equal(thrice.updatedCount, 1)
assert.equal(thrice.addedCount, 0)
})
test('test_task_084_merge_boundary_empty_input', () => {
const { items, updatedCount, addedCount } = mergeHistoryItems([], [], { keyOf })
assert.deepEqual(items, [])
assert.equal(updatedCount, 0)
assert.equal(addedCount, 0)
const existing = [item({ taskId: 1, resultId: 1 })]
const noop = mergeHistoryItems(existing, [], { keyOf })
assert.deepEqual(noop.items, existing)
assert.equal(noop.updatedCount, 0)
assert.equal(noop.addedCount, 0)
})
test('test_task_084_merge_boundary_single_item', () => {
const { items, addedCount, updatedCount } = mergeHistoryItems(
[],
[item({ taskId: 7, resultId: 9, taskStatus: 'RUNNING' })],
{ keyOf },
)
assert.equal(items.length, 1)
assert.equal(items[0].taskId, 7)
assert.equal(items[0].resultId, 9)
assert.equal(addedCount, 1)
assert.equal(updatedCount, 0)
})
test('test_task_084_merge_boundary_limit_and_overflow', () => {
const incoming = Array.from({ length: 5 }, (_, i) => item({ taskId: 1, resultId: i + 1 }))
const { items } = mergeHistoryItems([], incoming, { keyOf, maxItems: 3 })
assert.equal(items.length, 3)
assert.deepEqual(
items.map((r) => r.resultId),
[3, 4, 5],
)
// 合并已满列表:仍受 maxItems 约束
const full = mergeHistoryItems(items, [item({ taskId: 2, resultId: 1 })], { keyOf, maxItems: 3 })
assert.equal(full.items.length, 3)
assert.equal(full.items[2].taskId, 2)
})
test('test_task_084_merge_invalid_input_rejected', () => {
assert.throws(
() => mergeHistoryItems([], null as never, { keyOf }),
/incoming 必须是数组/,
)
assert.throws(
() => mergeHistoryItems(null as never, [], { keyOf }),
/existing 必须是数组/,
)
assert.throws(
() => mergeHistoryItems([], [], null as never),
/keyOf 必须是函数/,
)
assert.throws(
() => mergeHistoryItems([], [], { keyOf, maxItems: 0 }),
/maxItems 必须为正数/,
)
assert.throws(
() => mergeHistoryItems([], [], { keyOf, maxItems: -1 }),
/maxItems 必须为正数/,
)
// 非法 taskId/resultId 条目:不匹配任何现有行,按新条目追加,不抛错
const { items, addedCount } = mergeHistoryItems(
[item({ taskId: 1, resultId: 1 })],
[item({ taskId: -1, resultId: 1, shopName: 'bad' }), item({ taskId: 0 })],
{ keyOf },
)
assert.equal(items.length, 3)
assert.equal(addedCount, 2)
})
test('test_task_084_merge_dependency_failure_releases_resources', () => {
const existing = [item({ taskId: 1, resultId: 1 })]
const incoming = [item({ taskId: 1, resultId: 1, taskStatus: 'SUCCESS' })]
let broken = true
const faultyKeyOf = (row: Item) => {
if (broken) throw new Error('key down')
return keyOf(row)
}
assert.throws(() => mergeHistoryItems(existing, incoming, { keyOf: faultyKeyOf }), /key down/)
// 失败路径零状态变更:入参未被修改
assert.deepEqual(existing, [item({ taskId: 1, resultId: 1 })])
// 错误可恢复:修复后同一组输入成功
broken = false
const { items, updatedCount } = mergeHistoryItems(existing, incoming, { keyOf: faultyKeyOf })
assert.equal(items.length, 1)
assert.equal(items[0].taskStatus, 'SUCCESS')
assert.equal(updatedCount, 1)
})
test('test_task_084_merge_dependency_failure_removes_ghost_duplicates', () => {
// 线性 find 语义下的幽灵重复行场景:同 taskId/resultId 但不同 shopName 的
// incoming 命中现有行后,旧 key 不得留下重复条目,且再次合并可精确命中
const existing = [item({ taskId: 1, resultId: 1, shopName: 'a', taskStatus: 'RUNNING' })]
const incoming = [item({ taskId: 1, resultId: 1, shopName: 'b', taskStatus: 'SUCCESS' })]
const once = mergeHistoryItems(existing, incoming, { keyOf })
assert.equal(once.items.length, 1)
assert.deepEqual(once.items[0], {
taskId: 1,
resultId: 1,
shopName: 'b',
taskStatus: 'SUCCESS',
})
assert.equal(once.updatedCount, 1)
const twice = mergeHistoryItems(once.items, incoming, { keyOf })
assert.equal(twice.items.length, 1)
assert.equal(twice.updatedCount, 1)
assert.equal(twice.addedCount, 0)
// 同一批次内后到条目命中先到新增条目:不残留重复
const batch = [
item({ taskId: 1, resultId: 1, shopName: 'x', taskStatus: 'RUNNING' }),
item({ taskId: 1, resultId: 1, shopName: 'y', taskStatus: 'SUCCESS' }),
]
const chained = mergeHistoryItems([], batch, { keyOf })
assert.equal(chained.items.length, 1)
assert.deepEqual(chained.items[0], {
taskId: 1,
resultId: 1,
shopName: 'y',
taskStatus: 'SUCCESS',
})
assert.equal(chained.updatedCount, 1)
assert.equal(chained.addedCount, 1)
})
@@ -0,0 +1,154 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createPageCleanupRegistry } from '../src/shared/page-cleanup-registry.ts'
interface CleanupHooks {
clearTimers: () => void
revokeUrl: (url: string) => void
}
function createHooks(): CleanupHooks & { cleared: string[] } {
const cleared: string[] = []
return {
cleared,
clearTimers: () => cleared.push('timers'),
revokeUrl: (url: string) => cleared.push(`url:${url}`),
}
}
test('test_task_089_cleanup_normal_default_path', () => {
const hooks = createHooks()
const registry = createPageCleanupRegistry(hooks)
const url = registry.registerObjectUrl('blob:http://x/1')
assert.equal(url, 'blob:http://x/1')
assert.equal(registry.trackedUrlCount, 1)
assert.equal(registry.disposeCount, 0)
const result = registry.dispose()
assert.equal(result, 2)
assert.equal(registry.disposed, true)
assert.equal(registry.trackedUrlCount, 0)
assert.deepEqual(hooks.cleared, ['url:blob:http://x/1', 'timers'])
assert.equal(registry.disposeCount, 1)
})
test('test_task_089_cleanup_normal_multiple_items', () => {
const hooks = createHooks()
const registry = createPageCleanupRegistry(hooks)
registry.registerObjectUrl('blob:http://x/1')
registry.registerObjectUrl('blob:http://x/2')
registry.registerObjectUrl('blob:http://x/3')
const aborted: string[] = []
const makeController = (n: number) =>
({ abort: () => aborted.push(`abort:${n}`) }) as unknown as AbortController
registry.registerRequestController(makeController(1))
registry.registerRequestController(makeController(2))
assert.equal(registry.trackedUrlCount, 3)
assert.equal(registry.requestCount, 2)
const result = registry.dispose()
assert.equal(result, 6)
assert.equal(registry.trackedUrlCount, 0)
assert.equal(registry.requestCount, 0)
assert.deepEqual(aborted, ['abort:1', 'abort:2'])
assert.deepEqual(
hooks.cleared,
['url:blob:http://x/1', 'url:blob:http://x/2', 'url:blob:http://x/3', 'timers'],
)
})
test('test_task_089_cleanup_normal_repeated_operation_is_idempotent', () => {
const hooks = createHooks()
const registry = createPageCleanupRegistry(hooks)
const url = registry.registerObjectUrl('blob:http://x/1')
registry.dispose()
registry.dispose()
registry.dispose()
assert.equal(registry.disposed, true)
assert.equal(registry.trackedUrlCount, 0)
assert.equal(hooks.cleared.length, 2, '第二次及以后的 dispose 不再重复清理')
// dispose 后注册被拒绝
registry.registerObjectUrl('blob:http://x/2')
assert.equal(registry.trackedUrlCount, 0)
assert.equal(registry.disposeCount, 1)
assert.equal(registry.rejectedCount, 1)
assert.equal(url, 'blob:http://x/1')
})
test('test_task_089_cleanup_boundary_empty_input', () => {
const hooks = createHooks()
const registry = createPageCleanupRegistry(hooks)
assert.equal(registry.trackedUrlCount, 0)
assert.equal(registry.requestCount, 0)
const result = registry.dispose()
assert.equal(result, 1, 'dispose 至少执行 clearTimers')
assert.deepEqual(hooks.cleared, ['timers'])
// 空字符串 URL 不注册
registry.registerObjectUrl('')
assert.equal(registry.trackedUrlCount, 0)
})
test('test_task_089_cleanup_boundary_single_item', () => {
const hooks = createHooks()
const registry = createPageCleanupRegistry(hooks)
registry.registerObjectUrl('blob:http://x/single')
const result = registry.dispose()
assert.equal(result, 2)
assert.equal(hooks.cleared.length, 2)
assert.equal(hooks.cleared[0], 'url:blob:http://x/single')
assert.equal(hooks.cleared[1], 'timers')
})
test('test_task_089_cleanup_boundary_limit_and_overflow', () => {
const hooks = createHooks()
const registry = createPageCleanupRegistry(hooks, { maxTrackedUrls: 3 })
for (let i = 1; i <= 5; i++) registry.registerObjectUrl(`blob:http://x/${i}`)
assert.equal(registry.trackedUrlCount, 3, '超过上限只保留最近 3 个')
const result = registry.dispose()
assert.equal(result, 4)
assert.ok(hooks.cleared.includes('url:blob:http://x/3'))
assert.ok(hooks.cleared.includes('url:blob:http://x/5'))
assert.ok(!hooks.cleared.includes('url:blob:http://x/1'), '被驱逐的 URL 不再由注册表 revoke')
assert.equal(hooks.cleared.length, 4)
})
test('test_task_089_cleanup_invalid_input_rejected', () => {
assert.throws(() => createPageCleanupRegistry({} as never), /clearTimers 必须是函数/)
assert.throws(
() => createPageCleanupRegistry({ clearTimers: () => {} } as never),
/revokeUrl 必须是函数/,
)
const hooks = createHooks()
const registry = createPageCleanupRegistry(hooks, { maxTrackedUrls: 0 })
assert.equal(registry.registerObjectUrl('blob:http://x/1'), 'blob:http://x/1', 'maxTrackedUrls 为 0 时不限上限')
assert.equal(registry.trackedUrlCount, 1)
})
test('test_task_089_cleanup_dependency_failure_releases_resources', () => {
let urlFailures = 0
const hooks: CleanupHooks & { cleared: string[] } = {
cleared: [],
clearTimers: () => hooks.cleared.push('timers'),
revokeUrl: (url) => {
if (url === 'blob:http://x/poison') {
urlFailures += 1
throw new Error('revoke down')
}
hooks.cleared.push(`url:${url}`)
},
}
const registry = createPageCleanupRegistry(hooks)
registry.registerObjectUrl('blob:http://x/a')
registry.registerObjectUrl('blob:http://x/poison')
registry.registerObjectUrl('blob:http://x/b')
// revoke 抛错不中断清理,全部条目仍被尝试释放
const result = registry.dispose()
assert.equal(result, 4)
assert.equal(urlFailures, 1)
assert.equal(registry.trackedUrlCount, 0)
assert.ok(hooks.cleared.includes('url:blob:http://x/a'))
assert.ok(hooks.cleared.includes('url:blob:http://x/b'))
assert.ok(hooks.cleared.includes('timers'))
// 错误可恢复:同一注册表 dispose 后状态一致(幂等)
registry.dispose()
assert.equal(registry.disposeCount, 1)
assert.equal(urlFailures, 1)
})
@@ -0,0 +1,174 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { runPageE2ECorePath, type PageE2EDeps } from '../src/shared/page-e2e-core-path.ts'
const terminal = (status: string) => status === 'SUCCESS' || status === 'FAILED'
function makeDeps(over: Partial<PageE2EDeps> = {}): PageE2EDeps & { calls: string[] } {
const calls: string[] = []
return {
calls,
parse: async () => {
calls.push('parse')
return 101
},
createTask: async (taskId: number) => {
calls.push(`create:${taskId}`)
},
poll: async (taskId: number) => {
calls.push(`poll:${taskId}`)
return 'SUCCESS'
},
refreshHistory: async () => {
calls.push('refresh')
},
...over,
}
}
test('test_task_093_collect_asin_e2e_normal_default_path', async () => {
const deps = makeDeps()
const result = await runPageE2ECorePath(deps)
assert.equal(result.taskId, 101)
assert.equal(result.status, 'SUCCESS')
assert.deepEqual(result.completedSteps, ['parse', 'create-task', 'poll', 'refresh-history'])
assert.equal(result.attempts, 1)
assert.deepEqual(deps.calls, ['parse', 'create:101', 'poll:101', 'refresh'])
})
test('test_task_093_collect_asin_e2e_normal_multiple_items', async () => {
// 三个页面核心路径批量执行:结果不丢失且各页面顺序稳定
const pages = ['similar-asin', 'shop-data-crawl', 'collect-data']
const depss = pages.map((page, i) =>
makeDeps({
parse: async () => {
depss[i].calls.push(`parse:${page}`)
return 200 + i
},
poll: async () => {
depss[i].calls.push(`poll:${page}`)
return 'SUCCESS'
},
}),
)
const results = await Promise.all(pages.map((_, i) => runPageE2ECorePath(depss[i])))
assert.equal(results.length, 3)
assert.deepEqual(
results.map((r) => r.taskId),
[200, 201, 202],
)
for (let i = 0; i < 3; i++) {
assert.equal(results[i].status, 'SUCCESS')
assert.deepEqual(results[i].completedSteps, ['parse', 'create-task', 'poll', 'refresh-history'])
assert.deepEqual(depss[i].calls, [`parse:${pages[i]}`, `create:${200 + i}`, `poll:${pages[i]}`, 'refresh'])
}
})
test('test_task_093_collect_asin_e2e_normal_repeated_operation_is_idempotent', async () => {
const deps = makeDeps()
const first = await runPageE2ECorePath(deps)
const second = await runPageE2ECorePath(deps)
assert.equal(first.taskId, second.taskId)
assert.deepEqual(first.completedSteps, second.completedSteps)
// 重复执行不产生重复状态:调用计数线性增长,无残留
assert.equal(deps.calls.filter((c) => c === 'parse').length, 2)
assert.equal(deps.calls.filter((c) => c === 'refresh').length, 2)
assert.equal(deps.calls.length, 8)
})
test('test_task_093_collect_asin_e2e_boundary_empty_input', async () => {
// parse 无可处理数据:安全跳过建任务/轮询/刷新,不创建无效资源
const deps = makeDeps({ parse: async () => 0 })
const result = await runPageE2ECorePath(deps)
assert.equal(result.taskId, 0)
assert.deepEqual(result.completedSteps, ['parse'])
assert.deepEqual(deps.calls, [], 'parse 返回 0 时不再触发任何调用')
// 空步骤序列等价处理:不执行任何步骤
const empty = makeDeps({ parse: async () => 0 })
assert.deepEqual((await runPageE2ECorePath(empty)).completedSteps, ['parse'])
})
test('test_task_093_collect_asin_e2e_boundary_single_item', async () => {
// 单任务:一次轮询即终态,不依赖批量路径
const deps = makeDeps()
const result = await runPageE2ECorePath(deps, { maxPollAttempts: 3 })
assert.equal(result.taskId, 101)
assert.equal(result.attempts, 1)
assert.equal(result.status, 'SUCCESS')
assert.equal(deps.calls.filter((c) => c.startsWith('poll')).length, 1)
})
test('test_task_093_collect_asin_e2e_boundary_limit_and_overflow', async () => {
// 轮询次数有界:超过 maxPollAttempts 降级返回当前状态,不发生无界轮询
let pollCount = 0
const deps = makeDeps({
poll: async () => {
pollCount += 1
return 'RUNNING'
},
})
const result = await runPageE2ECorePath(deps, { maxPollAttempts: 4 })
assert.equal(result.status, 'RUNNING', '超过上限降级返回,不抛错')
assert.equal(result.attempts, 4)
assert.equal(pollCount, 4)
assert.deepEqual(result.completedSteps, ['parse', 'create-task', 'poll', 'poll', 'poll', 'poll'])
assert.ok(!result.completedSteps.includes('refresh-history'), '终态未达成不刷新历史')
})
test('test_task_093_collect_asin_e2e_invalid_input_rejected', async () => {
await assert.rejects(() => runPageE2ECorePath(null as never), /deps 必须是对象/)
await assert.rejects(() => runPageE2ECorePath({} as never), /parse 必须是函数/)
await assert.rejects(
() => runPageE2ECorePath({ parse: async () => 1 } as never),
/createTask 必须是函数/,
)
const missingPoll = makeDeps()
delete (missingPoll as Partial<PageE2EDeps>).poll
await assert.rejects(() => runPageE2ECorePath(missingPoll as never), /poll 必须是函数/)
const deps = makeDeps()
await assert.rejects(() => runPageE2ECorePath(deps, { maxPollAttempts: 0 }), /maxPollAttempts 必须为正数/)
await assert.rejects(() => runPageE2ECorePath(deps, { maxPollAttempts: -1 }), /maxPollAttempts 必须为正数/)
// parse 返回非法 taskId
const badTask = makeDeps({ parse: async () => -5 })
await assert.rejects(() => runPageE2ECorePath(badTask), /parse 必须返回正整数 taskId/)
})
test('test_task_093_collect_asin_e2e_dependency_failure_releases_resources', async () => {
// 断网:poll 抛错 → 执行终止、cleanup 释放资源;恢复后同一 deps 重跑成功
let broken = true
let cleaned = 0
const deps = makeDeps({
poll: async (taskId: number) => {
if (broken) throw new Error('Network Error')
return 'SUCCESS'
},
cleanup: async () => {
cleaned += 1
},
})
await assert.rejects(() => runPageE2ECorePath(deps), /Network Error/)
assert.equal(cleaned, 1, '失败后 cleanup 释放资源')
broken = false
const result = await runPageE2ECorePath(deps)
assert.equal(result.status, 'SUCCESS')
assert.equal(cleaned, 2, '成功路径同样清理')
assert.equal(result.taskId, 101)
// 中途失败(createTask 抛错)→ 剩余步骤跳过 + cleanup 执行
let createFailed = true
const deps2 = makeDeps({
createTask: async (taskId: number) => {
if (createFailed) throw new Error('create down')
deps2.calls.push(`create:${taskId}`)
},
cleanup: async () => {
cleaned += 1
},
})
await assert.rejects(() => runPageE2ECorePath(deps2), /create down/)
assert.equal(cleaned, 3)
assert.deepEqual(deps2.calls, ['parse'], 'create 失败后不再 poll/refresh')
createFailed = false
const retry = await runPageE2ECorePath(deps2)
assert.equal(retry.status, 'SUCCESS')
assert.equal(cleaned, 4)
})
+162
View File
@@ -0,0 +1,162 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { toParsePreview } from '../src/shared/parse-preview.ts'
interface Row {
rowIndex: number
sourceId: string
asin: string
}
interface ParseVo {
taskId: number
sourceFilename?: string
sourceFileCount?: number
totalRows: number
acceptedRows: number
droppedRows: number
groupCount?: number
aiPrompt?: string
imgSwitch?: boolean
categorySwitch?: boolean
items: Row[]
groups?: unknown[]
}
const row = (n: number): Row => ({ rowIndex: n, sourceId: `s${n}`, asin: `B00${n}` })
const vo = (over: Partial<ParseVo> = {}): ParseVo => ({
taskId: 1,
sourceFilename: 'a.xlsx',
sourceFileCount: 2,
totalRows: 100,
acceptedRows: 90,
droppedRows: 10,
groupCount: 5,
aiPrompt: 'prompt',
imgSwitch: true,
categorySwitch: false,
items: [row(1), row(2), row(3)],
groups: [{ g: 1 }, { g: 2 }],
...over,
})
test('test_task_088_payload_preview_frontend_normal_default_path', () => {
const input = vo()
const preview = toParsePreview(input)
assert.equal(preview.taskId, 1)
assert.equal(preview.sourceFilename, 'a.xlsx')
assert.equal(preview.sourceFileCount, 2)
assert.equal(preview.totalRows, 100)
assert.equal(preview.acceptedRows, 90)
assert.equal(preview.droppedRows, 10)
assert.equal(preview.groupCount, 5)
assert.equal(preview.aiPrompt, 'prompt')
assert.equal(preview.imgSwitch, true)
assert.equal(preview.categorySwitch, false)
assert.equal('items' in preview, false, 'items 不得进入预览')
assert.equal('groups' in preview, false, 'groups 不得进入预览')
assert.equal('previewItems' in preview, false, '默认不保留预览行')
// 输入对象不被修改
assert.equal(input.items.length, 3)
assert.ok(Array.isArray(input.groups) && input.groups.length === 2)
})
test('test_task_088_payload_preview_frontend_normal_multiple_items', () => {
const input = vo({ items: [row(1), row(2), row(3), row(4), row(5)], groups: [{ g: 1 }, { g: 2 }, { g: 3 }] })
const preview = toParsePreview(input, { previewRowLimit: 3, previewGroupLimit: 2 })
assert.equal(preview.totalRows, 100)
assert.equal(preview.acceptedRows, 90)
assert.ok(preview.previewItems, '有界保留预览行')
assert.equal(preview.previewItems.length, 3)
assert.deepEqual(
preview.previewItems.map((r) => r.rowIndex),
[1, 2, 3],
)
assert.equal(preview.previewGroups?.length, 2)
// 摘要字段不受截断影响,顺序稳定
assert.equal(preview.previewItems[0].asin, 'B001')
})
test('test_task_088_payload_preview_frontend_normal_repeated_operation_is_idempotent', () => {
const input = vo()
const once = toParsePreview(input)
const twice = toParsePreview(input)
assert.deepEqual(once, twice)
assert.deepEqual(toParsePreview(once), twice, '对预览再裁剪结果不变')
assert.deepEqual(vo(), input, '输入对象不被修改')
const withLimit = toParsePreview(input, { previewRowLimit: 2 })
assert.deepEqual(toParsePreview(input, { previewRowLimit: 2 }), withLimit)
})
test('test_task_088_payload_preview_frontend_boundary_empty_input', () => {
const empty = toParsePreview(vo({ items: [], groups: [] }))
assert.equal(empty.totalRows, 100)
assert.equal('items' in empty, false)
assert.equal('groups' in empty, false)
assert.equal('previewItems' in empty, false)
const limited = toParsePreview(vo({ items: [] }), { previewRowLimit: 5 })
assert.deepEqual(limited.previewItems, [])
assert.equal(limited.acceptedRows, 90)
})
test('test_task_088_payload_preview_frontend_boundary_single_item', () => {
const input = vo({ items: [row(7)] })
const preview = toParsePreview(input, { previewRowLimit: 1 })
assert.equal(preview.previewItems?.length, 1)
assert.equal(preview.previewItems?.[0].rowIndex, 7)
assert.equal(preview.taskId, 1)
})
test('test_task_088_payload_preview_frontend_boundary_limit_and_overflow', () => {
const input = vo({ items: Array.from({ length: 10 }, (_, i) => row(i + 1)) })
// limit=0 不保留任何行
const none = toParsePreview(input, { previewRowLimit: 0 })
assert.equal('previewItems' in none, false)
// 行数超 limit 截断前 N
const some = toParsePreview(input, { previewRowLimit: 3 })
assert.equal(some.previewItems?.length, 3)
assert.deepEqual(
some.previewItems?.map((r) => r.rowIndex),
[1, 2, 3],
)
// 非法 limit 抛错
assert.throws(() => toParsePreview(input, { previewRowLimit: -1 }), /previewRowLimit 不能为负数/)
assert.throws(() => toParsePreview(input, { previewGroupLimit: -1 }), /previewGroupLimit 不能为负数/)
assert.throws(() => toParsePreview(input, { previewRowLimit: NaN }), /previewRowLimit 不能为负数/)
})
test('test_task_088_payload_preview_frontend_invalid_input_rejected', () => {
assert.throws(() => toParsePreview(null as never), /解析结果必须是对象/)
assert.throws(() => toParsePreview(undefined as never), /解析结果必须是对象/)
assert.throws(() => toParsePreview('x' as never), /解析结果必须是对象/)
assert.throws(() => toParsePreview(vo({ taskId: 0 })), /taskId 必须是正整数/)
assert.throws(() => toParsePreview(vo({ taskId: -1 })), /taskId 必须是正整数/)
assert.throws(() => toParsePreview(vo({ taskId: NaN })), /taskId 必须是正整数/)
})
test('test_task_088_payload_preview_frontend_dependency_failure_releases_resources', () => {
const input = vo()
let broken = true
const poisoned = new Proxy(input, {
get(target, prop, receiver) {
if (broken && prop === 'totalRows') throw new Error('getter down')
return Reflect.get(target, prop, receiver)
},
})
assert.throws(() => toParsePreview(poisoned), /getter down/)
// 输入未被修改
assert.deepEqual(input, vo())
// 错误可恢复:修复后同一输入成功
broken = false
const preview = toParsePreview(poisoned)
assert.equal(preview.totalRows, 100)
assert.equal(preview.acceptedRows, 90)
// 提取行数据失败(items 读取抛错)不影响其余摘要字段的读取路径
const badItems = new Proxy([row(1)], {
get(target, prop) {
if (prop === 'length') throw new Error('items getter down')
return Reflect.get(target, prop)
},
})
assert.throws(() => toParsePreview(vo({ items: badItems }), { previewRowLimit: 3 }), /items getter down/)
})
@@ -0,0 +1,132 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createPollingStateMachine } from '../src/shared/polling-state-machine.ts'
test('test_task_095_task_normal_default_path', () => {
const fsm = createPollingStateMachine({ maxAttempts: 3 })
assert.equal(fsm.status, 'idle')
fsm.start()
assert.equal(fsm.status, 'polling')
fsm.succeed('SUCCESS')
assert.equal(fsm.status, 'done')
assert.equal(fsm.terminalStatus, 'SUCCESS')
assert.equal(fsm.attempts, 1)
assert.equal(fsm.errorCount, 0)
assert.deepEqual(fsm.retries(), [])
// 终态刷新:刷新计数
fsm.markRefreshed()
assert.equal(fsm.refreshed, true)
assert.equal(fsm.refreshCount, 1)
})
test('test_task_095_task_normal_multiple_items', () => {
// 多任务独立状态机:互不串扰
const fsmA = createPollingStateMachine({ maxAttempts: 3 })
const fsmB = createPollingStateMachine({ maxAttempts: 3 })
fsmA.start()
fsmB.start()
fsmA.fail('Network Error')
fsmB.succeed('SUCCESS')
assert.equal(fsmA.status, 'retrying')
assert.equal(fsmB.status, 'done')
assert.equal(fsmA.errorCount, 1)
assert.equal(fsmB.errorCount, 0)
assert.equal(fsmB.terminalStatus, 'SUCCESS')
assert.deepEqual(fsmA.retries(), [1])
})
test('test_task_095_task_normal_repeated_operation_is_idempotent', () => {
const fsm = createPollingStateMachine({ maxAttempts: 5 })
fsm.start()
fsm.fail('e1')
fsm.fail('e2')
fsm.succeed('SUCCESS')
// 重复进入终态幂等:不重复计数、不覆盖状态
fsm.succeed('SUCCESS')
fsm.succeed('SUCCESS')
assert.equal(fsm.status, 'done')
assert.equal(fsm.attempts, 3)
assert.equal(fsm.errorCount, 2)
// 重复刷新幂等:refreshCount 只计一次
fsm.markRefreshed()
fsm.markRefreshed()
fsm.markRefreshed()
assert.equal(fsm.refreshed, true)
assert.equal(fsm.refreshCount, 1)
// 终态后 start 无效
fsm.start()
assert.equal(fsm.status, 'done')
})
test('test_task_095_task_boundary_empty_input', () => {
const fsm = createPollingStateMachine({ maxAttempts: 3 })
assert.equal(fsm.status, 'idle')
assert.equal(fsm.attempts, 0)
assert.equal(fsm.errorCount, 0)
assert.equal(fsm.retries().length, 0)
assert.equal(fsm.refreshed, false)
// idle 状态不触发任何刷新
fsm.markRefreshed()
assert.equal(fsm.refreshCount, 0)
})
test('test_task_095_task_boundary_single_item', () => {
const fsm = createPollingStateMachine({ maxAttempts: 1 })
fsm.start()
fsm.succeed('FAILED')
assert.equal(fsm.status, 'done')
assert.equal(fsm.terminalStatus, 'FAILED', '终态单次成功')
assert.equal(fsm.attempts, 1)
})
test('test_task_095_task_boundary_limit_and_overflow', () => {
// 重试有界:超过 maxAttempts 后失败进入 failed,不再重试
const fsm = createPollingStateMachine({ maxAttempts: 2 })
fsm.start()
fsm.fail('e1')
assert.equal(fsm.status, 'retrying')
assert.equal(fsm.retrying, true)
fsm.fail('e2')
assert.equal(fsm.status, 'failed', '达到上限后失败不再重试')
assert.equal(fsm.retrying, false)
assert.deepEqual(fsm.retries(), [1], '仅成功重试过 1 次,第二次失败直接进入 failed')
// 失败状态下再次 fail:不越界
fsm.fail('e3')
assert.equal(fsm.attempts, 2)
assert.equal(fsm.errorCount, 2)
})
test('test_task_095_task_invalid_input_rejected', () => {
assert.throws(() => createPollingStateMachine({} as never), /maxAttempts 必须为正数/)
assert.throws(() => createPollingStateMachine({ maxAttempts: 0 }), /maxAttempts 必须为正数/)
assert.throws(() => createPollingStateMachine({ maxAttempts: -1 }), /maxAttempts 必须为正数/)
assert.throws(() => createPollingStateMachine({ maxAttempts: 1.5 }), /maxAttempts 必须为整数/)
assert.throws(
() => createPollingStateMachine({ maxAttempts: 2, isTerminal: 'x' as never }),
/isTerminal 必须是函数/,
)
// 未 start 时 fail/succeed 拒绝
const fsm = createPollingStateMachine({ maxAttempts: 3 })
assert.throws(() => fsm.fail('e'), /未开始轮询/)
assert.throws(() => fsm.succeed('SUCCESS'), /未开始轮询/)
})
test('test_task_095_task_dependency_failure_releases_resources', () => {
// isTerminal 依赖抛错:fail 调用失败但状态不污染,恢复后可用
let broken = true
const fsm = createPollingStateMachine({
maxAttempts: 3,
isTerminal: (status: string) => {
if (broken) throw new Error('isTerminal down')
return status === 'SUCCESS'
},
})
fsm.start()
assert.throws(() => fsm.succeed('SUCCESS'), /isTerminal down/)
assert.equal(fsm.status, 'polling', '失败不产生状态变更')
broken = false
fsm.succeed('SUCCESS')
assert.equal(fsm.status, 'done')
assert.equal(fsm.terminalStatus, 'SUCCESS')
assert.equal(fsm.attempts, 1)
})
@@ -0,0 +1,209 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createProgressResponseCache } from '../src/shared/progress-response-cache.ts'
interface FakeDetail {
id: number
name: string
status: string
}
function detail(id: number): FakeDetail {
return { id, name: `task-${id}`, status: id % 2 === 0 ? 'SUCCESS' : 'RUNNING' }
}
function clock(start = 1000) {
let now = start
return {
now: () => now,
tick: (ms: number) => {
now += ms
},
}
}
test('test_task_082_progress_cleanup_normal_default_path', () => {
const c = clock()
const cache = createProgressResponseCache<FakeDetail>({
ttlMs: 30_000,
maxEntries: 500,
now: c.now,
})
cache.set(1, detail(1))
assert.deepEqual(cache.get(1), detail(1))
assert.equal(cache.has(1), true)
assert.equal(cache.size, 1)
assert.deepEqual(cache.entries(), [[1, detail(1)]])
})
test('test_task_082_progress_cleanup_normal_multiple_items', () => {
const c = clock()
const cache = createProgressResponseCache<FakeDetail>({
ttlMs: 60_000,
maxEntries: 10,
now: c.now,
})
for (let i = 1; i <= 10; i++) cache.set(i, detail(i))
assert.equal(cache.size, 10)
assert.deepEqual(
cache.entries().map(([k]) => k),
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
)
assert.deepEqual(
cache.entries().map(([, v]) => v.id),
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
)
})
test('test_task_082_progress_cleanup_normal_repeated_operation_is_idempotent', () => {
const c = clock()
const cache = createProgressResponseCache<FakeDetail>({
ttlMs: 60_000,
maxEntries: 10,
now: c.now,
})
cache.set(1, detail(1))
cache.set(1, { ...detail(1), status: 'SUCCESS' })
assert.equal(cache.size, 1)
assert.equal(cache.get(1)?.status, 'SUCCESS')
cache.set(1, { ...detail(1), status: 'SUCCESS' })
assert.equal(cache.size, 1)
assert.deepEqual(
cache.entries().map(([k]) => k),
[1],
)
// 更新已存在条目不改变插入顺序
cache.set(2, detail(2))
cache.set(1, detail(1))
assert.deepEqual(
cache.entries().map(([k]) => k),
[1, 2],
)
})
test('test_task_082_progress_cleanup_boundary_empty_input', () => {
const c = clock()
const cache = createProgressResponseCache<FakeDetail>({
ttlMs: 1000,
maxEntries: 5,
now: c.now,
})
assert.equal(cache.size, 0)
assert.equal(cache.get(1), undefined)
assert.equal(cache.has(1), false)
assert.deepEqual(cache.entries(), [])
assert.equal(cache.purgeExpired(), 0)
cache.clear()
assert.equal(cache.size, 0)
assert.equal(cache.purgeExpired(), 0)
})
test('test_task_082_progress_cleanup_boundary_single_item', () => {
const c = clock()
const cache = createProgressResponseCache<FakeDetail>({
ttlMs: 1000,
maxEntries: 5,
now: c.now,
})
cache.set(7, detail(7))
assert.deepEqual(cache.get(7), detail(7))
assert.equal(cache.size, 1)
assert.equal(cache.has(7), true)
c.tick(1001)
assert.equal(cache.get(7), undefined)
assert.equal(cache.has(7), false)
assert.equal(cache.size, 0)
assert.equal(cache.purgeExpired(), 0)
})
test('test_task_082_progress_cleanup_boundary_limit_and_overflow', () => {
// 条目数超限:驱逐最旧,缓存有界
const c1 = clock()
const byEntries = createProgressResponseCache<FakeDetail>({
ttlMs: 60_000,
maxEntries: 3,
now: c1.now,
})
for (let i = 1; i <= 5; i++) byEntries.set(i, detail(i))
assert.equal(byEntries.size, 3)
assert.deepEqual(
byEntries.entries().map(([k]) => k),
[3, 4, 5],
)
assert.equal(byEntries.get(1), undefined)
assert.equal(byEntries.get(2), undefined)
// TTL 清理:到期条目被 purgeExpired 清除
const c2 = clock()
const byTtl = createProgressResponseCache<FakeDetail>({
ttlMs: 10_000,
maxEntries: 100,
now: c2.now,
})
byTtl.set(1, detail(1))
byTtl.set(2, detail(2))
c2.tick(5_000)
byTtl.set(3, detail(3))
assert.equal(byTtl.purgeExpired(), 0)
c2.tick(5_001)
assert.equal(byTtl.purgeExpired(), 2)
assert.deepEqual(
byTtl.entries().map(([k]) => k),
[3],
)
assert.equal(byTtl.size, 1)
// 读取路径惰性清理
c2.tick(5_000)
assert.equal(byTtl.get(3), undefined)
assert.equal(byTtl.size, 0)
assert.equal(byTtl.purgeExpired(), 0)
})
test('test_task_082_progress_cleanup_invalid_input_rejected', () => {
const c = clock()
assert.throws(() => createProgressResponseCache({ ttlMs: 0, maxEntries: 5 }), /ttlMs 必须为正数/)
assert.throws(() => createProgressResponseCache({ ttlMs: -1, maxEntries: 5 }), /ttlMs 必须为正数/)
assert.throws(() => createProgressResponseCache({ ttlMs: 1000, maxEntries: 0 }), /maxEntries 必须为正数/)
assert.throws(() => createProgressResponseCache({ ttlMs: 1000, maxEntries: -5 }), /maxEntries 必须为正数/)
const cache = createProgressResponseCache<FakeDetail>({ ttlMs: 1000, maxEntries: 5, now: c.now })
assert.throws(() => cache.set(0, detail(0)), /key 必须是正整数/)
assert.throws(() => cache.set(-1, detail(-1)), /key 必须是正整数/)
assert.throws(() => cache.set(NaN, detail(NaN)), /key 必须是正整数/)
assert.throws(() => cache.set(1.5, detail(1.5)), /key 必须是正整数/)
// 读取路径宽容:非法 key 不抛错
assert.equal(cache.get(-1), undefined)
assert.equal(cache.has(0), false)
assert.equal(cache.delete(0), false)
})
test('test_task_082_progress_cleanup_dependency_failure_releases_resources', () => {
const c = clock()
let broken = true
const faultyNow = () => {
if (broken) throw new Error('clock down')
return c.now()
}
const cache = createProgressResponseCache<FakeDetail>({
ttlMs: 1000,
maxEntries: 5,
now: faultyNow,
})
// 时钟故障时 set 失败,缓存零状态变更
assert.throws(() => cache.set(1, detail(1)), /clock down/)
assert.equal(cache.size, 0)
// 故障恢复后同一实例继续工作
broken = false
cache.set(1, detail(1))
assert.equal(cache.get(1)?.id, 1)
// 时钟故障时读取/清理抛错但不破坏条目
broken = true
assert.throws(() => cache.get(1), /clock down/)
assert.throws(() => cache.purgeExpired(), /clock down/)
assert.throws(() => cache.entries(), /clock down/)
broken = false
assert.equal(cache.get(1)?.id, 1)
assert.equal(cache.size, 1)
// clear 释放全部资源
cache.clear()
assert.equal(cache.size, 0)
})
@@ -0,0 +1,164 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createReleaseChecklist, type ReleaseCheckDeps } from '../src/shared/release-checklist.ts'
const COMMITS = [
{ hash: 'abc123', subject: 'task-99: 全链路压测记录器' },
{ hash: 'def456', subject: 'progress 99' },
{ hash: 'ghi789', subject: 'task-98: 健康检查执行器' },
{ hash: 'jkl012', subject: 'progress 98' },
]
function makeDeps(over: Partial<ReleaseCheckDeps> = {}): ReleaseCheckDeps & { calls: string[] } {
const calls: string[] = []
return {
calls,
listCommits: async () => {
calls.push('list')
return COMMITS
},
rollback: async () => {
calls.push('rollback')
return { ok: true, output: '回滚成功' }
},
verify: async () => {
calls.push('verify')
return { ok: true, output: '页面正常' }
},
...over,
}
}
test('test_task_100_task_normal_default_path', async () => {
const deps = makeDeps()
const checklist = createReleaseChecklist({ deps, releaseCommits: ['abc123', 'ghi789'] })
const result = await checklist.run()
assert.equal(result.ok, true)
assert.equal(result.steps.length, 3)
assert.deepEqual(result.steps.map((s) => s.id), ['rollback-drill', 'commit-map', 'deliverables'])
assert.equal(result.rollback.ok, true)
assert.equal(result.commitMap.checked, 2)
assert.equal(result.commitMap.missing.length, 0)
assert.equal(result.commitMap.unverified.length, 0)
assert.equal(result.deliverables.passed, true)
assert.equal(result.deliverables.total, 0, '默认无交付物清单')
assert.deepEqual(deps.calls, ['rollback', 'list', 'verify'])
})
test('test_task_100_task_normal_multiple_items', async () => {
// 批量交付物清单:顺序稳定、不丢失
const deps = makeDeps()
const checklist = createReleaseChecklist({
deps,
releaseCommits: ['abc123', 'def456', 'ghi789', 'jkl012'],
deliverables: ['jar', 'exe', 'vue-dist', 'python-backend'],
})
const result = await checklist.run()
assert.equal(result.deliverables.total, 4)
assert.deepEqual(result.deliverables.items, ['jar', 'exe', 'vue-dist', 'python-backend'])
assert.equal(result.commitMap.checked, 4)
assert.deepEqual(result.commitMap.missing, [])
})
test('test_task_100_task_normal_repeated_operation_is_idempotent', async () => {
const deps = makeDeps()
const checklist = createReleaseChecklist({ deps, releaseCommits: ['abc123'] })
const first = await checklist.run()
const second = await checklist.run()
assert.equal(first.ok, second.ok)
assert.deepEqual(first.commitMap.missing, second.commitMap.missing)
// 重复执行不产生重复状态:回滚演练与验证都恰好各执行两次(每轮一次)
assert.equal(deps.calls.filter((c) => c === 'rollback').length, 2)
assert.equal(deps.calls.filter((c) => c === 'verify').length, 2)
})
test('test_task_100_task_boundary_empty_input', async () => {
const deps = makeDeps({ listCommits: async () => [] })
const checklist = createReleaseChecklist({ deps, releaseCommits: [] })
const result = await checklist.run()
assert.equal(result.commitMap.checked, 0)
assert.deepEqual(result.commitMap.missing, [])
assert.equal(result.deliverables.total, 0)
assert.equal(result.deliverables.passed, true)
assert.equal(result.ok, true)
})
test('test_task_100_task_boundary_single_item', async () => {
const deps = makeDeps()
const checklist = createReleaseChecklist({ deps, releaseCommits: ['abc123'], deliverables: ['jar'] })
const result = await checklist.run()
assert.equal(result.commitMap.checked, 1)
assert.equal(result.commitMap.missing.length, 0)
assert.equal(result.deliverables.total, 1)
assert.equal(result.ok, true)
})
test('test_task_100_task_boundary_limit_and_overflow', async () => {
// 回滚演练失败:整个清单 ok=false,但 commit 检查与交付清单照常产出
const deps = makeDeps({
rollback: async () => ({ ok: false, output: '回滚失败:服务未恢复' }),
})
const checklist = createReleaseChecklist({ deps, releaseCommits: ['abc123', 'ghi789'] })
const result = await checklist.run()
assert.equal(result.ok, false)
assert.equal(result.rollback.ok, false)
assert.equal(result.commitMap.checked, 2, '失败不中断后续步骤')
assert.equal(result.deliverables.passed, true)
// 缺失 commit:标记 missing,不中断
const deps2 = makeDeps({ listCommits: async () => COMMITS.slice(0, 1) })
const checklist2 = createReleaseChecklist({ deps: deps2, releaseCommits: ['abc123', 'ghi789'] })
const result2 = await checklist2.run()
assert.equal(result2.commitMap.missing.length, 1)
assert.equal(result2.commitMap.missing[0], 'ghi789')
assert.equal(result2.ok, false)
})
test('test_task_100_task_invalid_input_rejected', async () => {
assert.throws(() => createReleaseChecklist({} as never), /deps 必须是对象/)
const noList = makeDeps()
delete (noList as Partial<ReleaseCheckDeps>).listCommits
assert.throws(() => createReleaseChecklist({ deps: noList as never, releaseCommits: [] }), /listCommits 必须是函数/)
assert.throws(() => createReleaseChecklist({ deps: makeDeps(), releaseCommits: 'abc' as never }), /releaseCommits 必须是数组/)
assert.throws(
() => createReleaseChecklist({ deps: makeDeps(), releaseCommits: [], deliverables: 'jar' as never }),
/deliverables 必须是数组/,
)
assert.throws(
() => createReleaseChecklist({ deps: makeDeps(), releaseCommits: [], maxCommits: 0 }),
/maxCommits 必须为正数/,
)
// listCommits 返回缺 hash 的记录:抛错
const bad = makeDeps({ listCommits: async () => [{ subject: 'x' }] as never })
await assert.rejects(() => createReleaseChecklist({ deps: bad, releaseCommits: ['a'] }).run(), /commit 缺少 hash/)
})
test('test_task_100_task_dependency_failure_releases_resources', async () => {
// 回滚演练依赖抛错:整轮失败但状态可恢复,修复后重跑成功
let broken = true
const deps = makeDeps({
rollback: async () => {
if (broken) throw new Error('ssh down')
return { ok: true, output: '回滚成功' }
},
})
const checklist = createReleaseChecklist({ deps, releaseCommits: ['abc123'] })
await assert.rejects(() => checklist.run(), /ssh down/)
broken = false
const result = await checklist.run()
assert.equal(result.ok, true)
assert.equal(result.rollback.ok, true)
// commit 列表读取抛错同样可恢复
let listBroken = true
const deps2 = makeDeps({
listCommits: async () => {
if (listBroken) throw new Error('git down')
return COMMITS
},
})
const checklist2 = createReleaseChecklist({ deps: deps2, releaseCommits: ['abc123'] })
await assert.rejects(() => checklist2.run(), /git down/)
listBroken = false
const result2 = await checklist2.run()
assert.equal(result2.ok, true)
assert.equal(result2.commitMap.checked, 1)
})
@@ -0,0 +1,175 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createResponsiveShotPlan } from '../src/shared/responsive-shot-plan.ts'
test('test_task_094_task_normal_default_path', () => {
const plan = createResponsiveShotPlan({
entries: ['publish', 'dedupe'],
viewports: [
{ label: 'mobile', width: 375, height: 812 },
{ label: 'desktop', width: 1280, height: 800 },
],
})
assert.deepEqual(
plan.shots.map((s) => `${s.entry}@${s.viewport.label}`),
['publish@mobile', 'publish@desktop', 'dedupe@mobile', 'dedupe@desktop'],
'默认路径:页面 × 视口 笛卡尔积,先页面后视口',
)
assert.equal(plan.shotCount, 4)
assert.equal(plan.shots[0].viewport.width, 375)
assert.equal(plan.shots[0].viewport.height, 812)
assert.equal(plan.shots[3].viewport.height, 800)
assert.deepEqual(plan.viewportStats(), { mobile: 2, desktop: 2 })
})
test('test_task_094_task_normal_multiple_items', () => {
const entries = ['a', 'b', 'c', 'd']
const plan = createResponsiveShotPlan({
entries,
viewports: [
{ label: 'mobile', width: 375, height: 812 },
{ label: 'tablet', width: 768, height: 1024 },
{ label: 'desktop', width: 1280, height: 800 },
],
})
assert.equal(plan.shotCount, 12)
assert.equal(plan.shots.length, 12)
// 批量结果不丢失、顺序稳定
assert.deepEqual(
plan.shots.map((s) => s.entry),
['a', 'a', 'a', 'b', 'b', 'b', 'c', 'c', 'c', 'd', 'd', 'd'],
)
assert.deepEqual(plan.viewportStats(), { mobile: 4, tablet: 4, desktop: 4 })
// 文件名唯一且可预期
assert.deepEqual(
new Set(plan.shots.map((s) => s.filename)).size,
12,
)
assert.equal(plan.shots[0].filename, 'a-mobile.png')
})
test('test_task_094_task_normal_repeated_operation_is_idempotent', () => {
const options = {
entries: ['publish'],
viewports: [
{ label: 'mobile', width: 375, height: 812 },
{ label: 'mobile', width: 375, height: 812 },
{ label: 'desktop', width: 1280, height: 800 },
],
}
const plan = createResponsiveShotPlan(options)
assert.equal(plan.shotCount, 2, '重复视口去重,不产生重复截图')
// 输入对象不被修改
assert.equal(options.viewports.length, 3)
// 重复计算幂等
const again = createResponsiveShotPlan(options)
assert.deepEqual(plan.shots, again.shots)
// 同一页面重复登记也去重
const dedup = createResponsiveShotPlan({ entries: ['a', 'a'], viewports: [{ label: 'v', width: 100, height: 200 }] })
assert.equal(dedup.shotCount, 1)
})
test('test_task_094_task_boundary_empty_input', () => {
const plan = createResponsiveShotPlan({ entries: [], viewports: [{ label: 'mobile', width: 375, height: 812 }] })
assert.deepEqual(plan.shots, [])
assert.equal(plan.shotCount, 0)
assert.deepEqual(plan.viewportStats(), {})
// 空视口同理
const empty = createResponsiveShotPlan({ entries: ['a'], viewports: [] })
assert.equal(empty.shotCount, 0)
})
test('test_task_094_task_boundary_single_item', () => {
const plan = createResponsiveShotPlan({
entries: ['withdraw'],
viewports: [{ label: 'mobile', width: 375, height: 812 }],
})
assert.equal(plan.shotCount, 1)
assert.equal(plan.shots[0].entry, 'withdraw')
assert.equal(plan.shots[0].viewport.label, 'mobile')
assert.equal(plan.shots[0].filename, 'withdraw-mobile.png')
})
test('test_task_094_task_boundary_limit_and_overflow', () => {
// 截图数量有界:超过 maxShots 截断,不发生无界截图
const plan = createResponsiveShotPlan({
entries: ['a', 'b', 'c', 'd'],
viewports: [
{ label: 'mobile', width: 375, height: 812 },
{ label: 'desktop', width: 1280, height: 800 },
],
maxShots: 5,
})
assert.equal(plan.shotCount, 5)
assert.equal(plan.droppedCount, 3)
assert.deepEqual(
plan.shots.map((s) => s.filename),
['a-mobile.png', 'a-desktop.png', 'b-mobile.png', 'b-desktop.png', 'c-mobile.png'],
)
// maxShots 足够时全部保留
const full = createResponsiveShotPlan({
entries: ['a', 'b'],
viewports: [{ label: 'v', width: 100, height: 100 }],
maxShots: 8,
})
assert.equal(full.shotCount, 2)
assert.equal(full.droppedCount, 0)
})
test('test_task_094_task_invalid_input_rejected', () => {
assert.throws(() => createResponsiveShotPlan({} as never), /entries 必须是数组/)
assert.throws(
() => createResponsiveShotPlan({ entries: 'a' as never, viewports: [] }),
/entries 必须是数组/,
)
assert.throws(
() => createResponsiveShotPlan({ entries: ['a'], viewports: 'x' as never }),
/viewports 必须是数组/,
)
assert.throws(
() => createResponsiveShotPlan({ entries: ['a'], viewports: [{ label: '', width: 1, height: 1 }] }),
/label 不能为空/,
)
assert.throws(
() => createResponsiveShotPlan({ entries: ['a'], viewports: [{ label: 'v', width: 0, height: 100 }] }),
/width 必须为正数/,
)
assert.throws(
() => createResponsiveShotPlan({ entries: ['a'], viewports: [{ label: 'v', width: 100, height: -1 }] }),
/height 必须为正数/,
)
assert.throws(
() => createResponsiveShotPlan({ entries: ['a'], viewports: [], maxShots: 0 }),
/maxShots 必须为正数/,
)
assert.throws(
() => createResponsiveShotPlan({ entries: ['a'], viewports: [], maxShots: -5 }),
/maxShots 必须为正数/,
)
})
test('test_task_094_task_dependency_failure_releases_resources', () => {
// 视口列表读取失败:抛错,不产生部分截图计划;恢复后可重算
let broken = true
const poisoned = new Proxy([{ label: 'mobile', width: 375, height: 812 }], {
get(target, prop, receiver) {
if (broken && prop === '0') throw new Error('viewport getter down')
return Reflect.get(target, prop, receiver)
},
ownKeys(target) {
if (broken) throw new Error('viewport ownKeys down')
return Reflect.ownKeys(target)
},
})
assert.throws(
() => createResponsiveShotPlan({ entries: ['a'], viewports: poisoned as unknown as Array<{ label: string; width: number; height: number }> }),
/viewport/,
)
broken = false
const plan = createResponsiveShotPlan({
entries: ['a'],
viewports: poisoned as unknown as Array<{ label: string; width: number; height: number }>,
})
assert.equal(plan.shotCount, 1)
assert.equal(plan.shots[0].filename, 'a-mobile.png')
})
@@ -0,0 +1,161 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createTaskPollingBaseline } from '../src/shared/task-polling-baseline.ts'
interface FakeDetail {
id: number
name: string
status: string
}
function detail(id: number): FakeDetail {
return { id, name: `task-${id}`, status: id % 2 === 0 ? 'SUCCESS' : 'RUNNING' }
}
const byId = (d: FakeDetail) => d.id
const enc = (v: unknown) => new TextEncoder().encode(JSON.stringify(v)).byteLength
test('test_task_081_polling_frontend_normal_default_path', () => {
const baseline = createTaskPollingBaseline()
baseline.recordRequest()
baseline.recordRequest()
baseline.recordRequest()
const inserted = baseline.recordResponseItems([detail(1), detail(2)], byId)
assert.equal(inserted, 2)
const stats = baseline.stats()
assert.equal(stats.requestCount, 3)
assert.equal(stats.entryCount, 2)
assert.equal(stats.droppedCount, 0)
assert.ok(stats.cachedBytes > 0)
assert.ok(stats.cachedBytes <= 1024 * 1024)
assert.equal(stats.totalResponseBytes, enc([detail(1), detail(2)]))
assert.equal(stats.largestResponseBytes, stats.totalResponseBytes)
assert.deepEqual(stats.entryKeys, [1, 2])
})
test('test_task_081_polling_frontend_normal_multiple_items', () => {
const baseline = createTaskPollingBaseline()
const items = Array.from({ length: 10 }, (_, i) => detail(i + 1))
const inserted = baseline.recordResponseItems(items, byId)
assert.equal(inserted, 10)
const stats = baseline.stats()
assert.equal(stats.entryCount, 10)
assert.deepEqual(stats.entryKeys, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
// 覆盖更新不改变首次到达顺序
baseline.recordResponseItems([detail(5)], byId)
const after = baseline.stats()
assert.equal(after.entryCount, 10)
assert.deepEqual(after.entryKeys, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
assert.equal(after.totalResponseBytes, enc(items) + enc([detail(5)]))
assert.equal(after.largestResponseBytes, enc(items))
})
test('test_task_081_polling_frontend_normal_repeated_operation_is_idempotent', () => {
const baseline = createTaskPollingBaseline()
const items = [detail(1), detail(2), detail(3)]
baseline.recordResponseItems(items, byId)
baseline.recordResponseItems(items, byId)
const stats = baseline.stats()
assert.equal(stats.entryCount, 3)
assert.equal(stats.droppedCount, 0)
const single = createTaskPollingBaseline()
single.recordResponseItems(items, byId)
assert.equal(stats.cachedBytes, single.stats().cachedBytes)
assert.equal(stats.totalResponseBytes, single.stats().totalResponseBytes * 2)
})
test('test_task_081_polling_frontend_boundary_empty_input', () => {
const baseline = createTaskPollingBaseline()
assert.equal(baseline.recordResponseItems([], byId), 0)
assert.equal(baseline.recordResponseItems([], byId), 0)
const stats = baseline.stats()
assert.equal(stats.requestCount, 0)
assert.equal(stats.totalResponseBytes, 0)
assert.equal(stats.largestResponseBytes, 0)
assert.equal(stats.entryCount, 0)
assert.equal(stats.cachedBytes, 0)
assert.equal(stats.droppedCount, 0)
assert.deepEqual(stats.entryKeys, [])
})
test('test_task_081_polling_frontend_boundary_single_item', () => {
const baseline = createTaskPollingBaseline()
const inserted = baseline.recordResponseItems([detail(7)], byId)
assert.equal(inserted, 1)
const stats = baseline.stats()
assert.equal(stats.entryCount, 1)
assert.deepEqual(stats.entryKeys, [7])
assert.equal(stats.cachedBytes, enc(detail(7)) + 64)
assert.equal(stats.totalResponseBytes, enc([detail(7)]))
})
test('test_task_081_polling_frontend_boundary_limit_and_overflow', () => {
// 条目数超限:驱逐最旧,缓存有界
const byEntries = createTaskPollingBaseline({ maxEntries: 3, maxCacheBytes: 1_000_000 })
byEntries.recordResponseItems([detail(1), detail(2), detail(3), detail(4), detail(5)], byId)
const s1 = byEntries.stats()
assert.equal(s1.entryCount, 3)
assert.deepEqual(s1.entryKeys, [3, 4, 5])
assert.equal(s1.droppedCount, 2)
// 单条超过字节上限:直接拒绝
const small = createTaskPollingBaseline({ maxCacheBytes: 200 })
const big = { id: 9, name: 'x'.repeat(1000), status: 'RUNNING' }
const inserted = small.recordResponseItems([big], byId)
assert.equal(inserted, 0)
const s2 = small.stats()
assert.equal(s2.entryCount, 0)
assert.equal(s2.droppedCount, 1)
assert.ok(s2.cachedBytes <= 200)
// 批量累积超限:逐条驱逐最旧直到有界
const tiny = createTaskPollingBaseline({ maxCacheBytes: 300 })
tiny.recordResponseItems([detail(1), detail(2), detail(3)], byId)
const s3 = tiny.stats()
assert.equal(s3.entryCount, 2)
assert.deepEqual(s3.entryKeys, [2, 3])
assert.equal(s3.droppedCount, 1)
assert.ok(s3.cachedBytes <= 300)
})
test('test_task_081_polling_frontend_invalid_input_rejected', () => {
const baseline = createTaskPollingBaseline()
assert.throws(() => baseline.recordResponseItems(null as never, byId), /items 必须是数组/)
assert.throws(() => baseline.recordResponseItems([detail(1)], null as never), /extractKey 必须是函数/)
assert.throws(() => createTaskPollingBaseline({ maxEntries: 0 }), /上限配置必须为正数/)
assert.throws(() => createTaskPollingBaseline({ maxCacheBytes: -1 }), /上限配置必须为正数/)
// 非法 key 跳过而不是抛错(与轮询层行为一致)
const inserted = baseline.recordResponseItems(
[{ id: 0, name: 'x', status: 'y' }, detail(2)],
byId,
)
assert.equal(inserted, 1)
})
test('test_task_081_polling_frontend_dependency_failure_releases_resources', () => {
const baseline = createTaskPollingBaseline()
baseline.recordRequest()
assert.throws(
() =>
baseline.recordResponseItems([detail(1)], () => {
throw new Error('boom')
}),
/boom/,
)
const after = baseline.stats()
assert.equal(after.entryCount, 0)
assert.equal(after.totalResponseBytes, 0)
assert.equal(after.requestCount, 1)
assert.equal(after.droppedCount, 0)
baseline.reset()
assert.deepEqual(baseline.stats(), {
requestCount: 0,
totalResponseBytes: 0,
largestResponseBytes: 0,
entryCount: 0,
cachedBytes: 0,
droppedCount: 0,
entryKeys: [],
})
// 错误可恢复:修复后同一实例继续工作
baseline.recordResponseItems([detail(1)], byId)
assert.equal(baseline.stats().entryCount, 1)
})
@@ -0,0 +1,187 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createTaskPollingCoordinator } from '../src/shared/task-polling-coordinator.ts'
function deferred() {
let resolve!: (v: unknown) => void
let reject!: (e: unknown) => void
const promise = new Promise((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
test('test_task_083_merge_cleanup_polling_normal_default_path', async () => {
const terminal: number[] = []
const coordinator = createTaskPollingCoordinator({ onTerminal: (id) => terminal.push(id) })
assert.equal(coordinator.add(1), true)
assert.equal(coordinator.add(2), true)
assert.equal(coordinator.size, 2)
assert.deepEqual(coordinator.ids(), [1, 2])
const calls: number[][] = []
const result = await coordinator.runOnce((ids) => {
calls.push(ids)
return Promise.resolve({ ok: true, n: ids.length })
})
assert.deepEqual(result, { ok: true, n: 2 })
assert.equal(calls.length, 1)
assert.deepEqual(calls[0], [1, 2])
coordinator.markTerminal(1)
assert.equal(coordinator.has(1), false)
assert.deepEqual(coordinator.ids(), [2])
assert.deepEqual(terminal, [1])
const stats = coordinator.stats()
assert.equal(stats.addCount, 2)
assert.equal(stats.dedupedCount, 0)
assert.equal(stats.terminalCount, 1)
assert.equal(stats.requestCount, 1)
assert.equal(stats.mergedCount, 0)
assert.equal(stats.rejectedCount, 0)
})
test('test_task_083_merge_cleanup_polling_normal_multiple_items', async () => {
const terminal: number[] = []
const coordinator = createTaskPollingCoordinator({ onTerminal: (id) => terminal.push(id) })
const ids = Array.from({ length: 10 }, (_, i) => i + 1)
const added = ids.map((id) => coordinator.add(id))
assert.deepEqual(added, Array(10).fill(true))
assert.equal(coordinator.size, 10)
assert.deepEqual(coordinator.ids(), ids)
const seen: number[][] = []
await coordinator.runOnce((requested) => {
seen.push(requested)
return Promise.resolve(undefined)
})
assert.deepEqual(seen, [ids])
for (const id of ids) coordinator.markTerminal(id)
assert.equal(coordinator.size, 0)
assert.deepEqual(terminal, ids)
assert.equal(coordinator.stats().terminalCount, 10)
})
test('test_task_083_merge_cleanup_polling_normal_repeated_operation_is_idempotent', async () => {
const terminal: number[] = []
const coordinator = createTaskPollingCoordinator({ onTerminal: (id) => terminal.push(id) })
coordinator.add(1)
assert.equal(coordinator.add(1), false)
assert.equal(coordinator.add(1), false)
assert.equal(coordinator.size, 1)
assert.deepEqual(coordinator.ids(), [1])
assert.equal(coordinator.stats().dedupedCount, 2)
// 重复终态清理只回调一次
coordinator.markTerminal(1)
coordinator.markTerminal(1)
coordinator.markTerminal(1)
assert.deepEqual(terminal, [1])
assert.equal(coordinator.stats().terminalCount, 1)
// 并发 runOnce 合并为单次请求
coordinator.add(1)
const calls: number[][] = []
const d = deferred()
const p1 = coordinator.runOnce((ids) => {
calls.push(ids)
return d.promise
})
const p2 = coordinator.runOnce((ids) => {
calls.push(ids)
return Promise.resolve('second')
})
const p3 = coordinator.runOnce((ids) => {
calls.push(ids)
return Promise.resolve('third')
})
d.resolve('first')
const results = await Promise.all([p1, p2, p3])
assert.deepEqual(results, ['first', 'first', 'first'])
assert.equal(calls.length, 1)
const stats = coordinator.stats()
assert.equal(stats.requestCount, 1)
assert.equal(stats.mergedCount, 2)
})
test('test_task_083_merge_cleanup_polling_boundary_empty_input', async () => {
const coordinator = createTaskPollingCoordinator()
assert.equal(coordinator.size, 0)
assert.deepEqual(coordinator.ids(), [])
let called = false
const result = await coordinator.runOnce(() => {
called = true
return Promise.resolve({ n: 1 })
})
assert.equal(result, undefined)
assert.equal(called, false)
assert.equal(coordinator.stats().requestCount, 0)
coordinator.clear()
coordinator.markTerminal(999)
assert.equal(coordinator.stats().terminalCount, 0)
})
test('test_task_083_merge_cleanup_polling_boundary_single_item', async () => {
const coordinator = createTaskPollingCoordinator()
assert.equal(coordinator.add(7), true)
const calls: number[][] = []
await coordinator.runOnce((ids) => {
calls.push(ids)
return Promise.resolve(undefined)
})
assert.deepEqual(calls, [[7]])
assert.equal(coordinator.size, 1)
assert.equal(coordinator.stats().requestCount, 1)
})
test('test_task_083_merge_cleanup_polling_boundary_limit_and_overflow', () => {
const coordinator = createTaskPollingCoordinator({ maxTasks: 3 })
assert.equal(coordinator.add(1), true)
assert.equal(coordinator.add(2), true)
assert.equal(coordinator.add(3), true)
assert.equal(coordinator.size, 3)
assert.equal(coordinator.add(4), false)
assert.equal(coordinator.add(5), false)
assert.equal(coordinator.size, 3)
assert.deepEqual(coordinator.ids(), [1, 2, 3])
const stats = coordinator.stats()
assert.equal(stats.rejectedCount, 2)
assert.equal(stats.addCount, 3)
// 移除后可继续加入
coordinator.remove(1)
assert.equal(coordinator.add(4), true)
assert.deepEqual(coordinator.ids(), [2, 3, 4])
})
test('test_task_083_merge_cleanup_polling_invalid_input_rejected', () => {
const coordinator = createTaskPollingCoordinator()
assert.throws(() => coordinator.add(0), /taskId 必须是正整数/)
assert.throws(() => coordinator.add(-1), /taskId 必须是正整数/)
assert.throws(() => coordinator.add(NaN), /taskId 必须是正整数/)
assert.throws(() => coordinator.add(1.5), /taskId 必须是正整数/)
assert.equal(coordinator.size, 0)
assert.equal(coordinator.stats().rejectedCount, 0)
// 读取路径宽容
assert.equal(coordinator.has(0), false)
assert.equal(coordinator.remove(0), false)
assert.throws(() => createTaskPollingCoordinator({ maxTasks: 0 }), /maxTasks 必须为正数/)
})
test('test_task_083_merge_cleanup_polling_dependency_failure_releases_resources', async () => {
const coordinator = createTaskPollingCoordinator()
coordinator.add(1)
coordinator.add(2)
// loader 抛错:请求失败但任务集合保持不变,in-flight 释放
await assert.rejects(
coordinator.runOnce(() => Promise.reject(new Error('network down'))),
/network down/,
)
assert.equal(coordinator.size, 2)
// 恢复后同一实例可再次发起请求
const calls: number[][] = []
const result = await coordinator.runOnce((ids) => {
calls.push(ids)
return Promise.resolve('recovered')
})
assert.equal(result, 'recovered')
assert.deepEqual(calls, [[1, 2]])
const stats = coordinator.stats()
assert.equal(stats.requestCount, 2)
assert.equal(stats.mergedCount, 0)
})
@@ -0,0 +1,148 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
TASK_POLL_VISIBLE_INTERVAL_MS,
TASK_POLL_HIDDEN_INTERVAL_MS,
TASK_POLL_BACKOFF_BASE_MS,
TASK_POLL_BACKOFF_MAX_MS,
getTaskPollIntervalMs,
getTaskProgressCacheTtlMs,
getTaskForegroundRefreshEnabled,
getTaskForegroundRefreshDelayMs,
getTaskPollBackoffMs,
configureTaskPolling,
resetTaskPollingConfig,
} from '../src/shared/task-progress-config.ts'
function withVisibility(visible: boolean, fn: () => void) {
const original = (globalThis as Record<string, unknown>).document
const fakeDocument = { visibilityState: visible ? 'visible' : 'hidden', hidden: !visible }
Object.defineProperty(globalThis, 'document', {
value: fakeDocument,
configurable: true,
writable: true,
})
try {
fn()
} finally {
Object.defineProperty(globalThis, 'document', {
value: original,
configurable: true,
writable: true,
})
}
}
test('test_task_086_polling_normal_default_path', () => {
assert.equal(getTaskPollBackoffMs(0), TASK_POLL_BACKOFF_BASE_MS)
assert.equal(getTaskPollBackoffMs(1), 1000)
assert.equal(getTaskForegroundRefreshEnabled(), true)
assert.equal(getTaskForegroundRefreshDelayMs(), 0)
assert.equal(getTaskPollIntervalMs(), TASK_POLL_VISIBLE_INTERVAL_MS)
assert.equal(getTaskProgressCacheTtlMs(), 5000)
// hidden 分支:可见性变化切换间隔
withVisibility(false, () => {
assert.equal(getTaskPollIntervalMs(), TASK_POLL_HIDDEN_INTERVAL_MS)
assert.equal(getTaskProgressCacheTtlMs(), 30000)
})
withVisibility(true, () => {
assert.equal(getTaskPollIntervalMs(), TASK_POLL_VISIBLE_INTERVAL_MS)
assert.equal(getTaskProgressCacheTtlMs(), 5000)
})
})
test('test_task_086_polling_normal_multiple_items', () => {
resetTaskPollingConfig()
configureTaskPolling({ backoffBaseMs: 1000, backoffMaxMs: 10_000 })
assert.deepEqual([0, 1, 2, 3].map((n) => getTaskPollBackoffMs(n)), [1000, 2000, 4000, 8000])
configureTaskPolling({ backoffBaseMs: 200, backoffMaxMs: 1600 })
assert.deepEqual([0, 1, 2, 3, 4].map((n) => getTaskPollBackoffMs(n)), [200, 400, 800, 1600, 1600])
configureTaskPolling({ foregroundRefreshEnabled: false })
assert.equal(getTaskForegroundRefreshEnabled(), false)
configureTaskPolling({ foregroundRefreshDelayMs: 250 })
assert.equal(getTaskForegroundRefreshDelayMs(), 250)
assert.equal(getTaskForegroundRefreshEnabled(), false, '未覆盖的字段保持原值')
assert.equal(getTaskPollBackoffMs(0), 200, '未覆盖的字段保持原值')
resetTaskPollingConfig()
})
test('test_task_086_polling_normal_repeated_operation_is_idempotent', () => {
resetTaskPollingConfig()
configureTaskPolling({ backoffBaseMs: 300, backoffMaxMs: 2400 })
configureTaskPolling({ backoffBaseMs: 300, backoffMaxMs: 2400 })
configureTaskPolling({ backoffBaseMs: 300, backoffMaxMs: 2400 })
assert.deepEqual([0, 1, 2, 3].map((n) => getTaskPollBackoffMs(n)), [300, 600, 1200, 2400])
configureTaskPolling({ foregroundRefreshEnabled: true })
configureTaskPolling({ foregroundRefreshEnabled: true })
assert.equal(getTaskForegroundRefreshEnabled(), true)
resetTaskPollingConfig()
})
test('test_task_086_polling_boundary_empty_input', () => {
resetTaskPollingConfig()
configureTaskPolling({})
assert.equal(getTaskPollBackoffMs(0), TASK_POLL_BACKOFF_BASE_MS)
assert.equal(getTaskForegroundRefreshEnabled(), true)
assert.equal(getTaskForegroundRefreshDelayMs(), 0)
resetTaskPollingConfig()
})
test('test_task_086_polling_boundary_single_item', () => {
resetTaskPollingConfig()
configureTaskPolling({ backoffMaxMs: 999 })
assert.equal(getTaskPollBackoffMs(0), TASK_POLL_BACKOFF_BASE_MS, '未配置字段不受影响')
assert.equal(getTaskPollBackoffMs(5), 999, '超过上限封顶')
configureTaskPolling({ foregroundRefreshEnabled: false })
assert.equal(getTaskForegroundRefreshEnabled(), false)
assert.equal(getTaskPollBackoffMs(5), 999, '配置之间互不影响')
resetTaskPollingConfig()
})
test('test_task_086_polling_boundary_limit_and_overflow', () => {
resetTaskPollingConfig()
// attempt 超过最大重试次数后封顶,不发生无界增长
assert.equal(getTaskPollBackoffMs(0), 500)
assert.equal(getTaskPollBackoffMs(1), 1000)
assert.equal(getTaskPollBackoffMs(2), 2000)
assert.equal(getTaskPollBackoffMs(3), 4000)
assert.equal(getTaskPollBackoffMs(4), 5000)
assert.equal(getTaskPollBackoffMs(10), 5000)
assert.equal(getTaskPollBackoffMs(100), 5000)
// attempt 为 0 或负数按首次退避处理
assert.equal(getTaskPollBackoffMs(-1), 500)
assert.equal(getTaskPollBackoffMs(NaN), 500)
assert.equal(getTaskPollBackoffMs(0.5), 500)
resetTaskPollingConfig()
})
test('test_task_086_polling_invalid_input_rejected', () => {
resetTaskPollingConfig()
assert.throws(() => configureTaskPolling({ backoffBaseMs: 0 }), /退避基数必须为正数/)
assert.throws(() => configureTaskPolling({ backoffBaseMs: -1 }), /退避基数必须为正数/)
assert.throws(() => configureTaskPolling({ backoffMaxMs: 0 }), /退避上限必须为正数/)
assert.throws(() => configureTaskPolling({ backoffMaxMs: -100 }), /退避上限必须为正数/)
assert.throws(() => configureTaskPolling({ foregroundRefreshDelayMs: -1 }), /恢复延迟不能为负数/)
// 拒绝后配置保持原值(零状态变更)
assert.equal(getTaskPollBackoffMs(0), TASK_POLL_BACKOFF_BASE_MS)
assert.equal(getTaskForegroundRefreshEnabled(), true)
resetTaskPollingConfig()
})
test('test_task_086_polling_dependency_failure_releases_resources', () => {
resetTaskPollingConfig()
// document 不可用(Node 环境/SSR):按可见间隔降级,不抛错
const original = (globalThis as Record<string, unknown>).document
Object.defineProperty(globalThis, 'document', { value: undefined, configurable: true, writable: true })
try {
assert.equal(getTaskPollIntervalMs(), TASK_POLL_VISIBLE_INTERVAL_MS)
assert.equal(getTaskProgressCacheTtlMs(), 5000)
} finally {
Object.defineProperty(globalThis, 'document', { value: original, configurable: true, writable: true })
}
// 配置校验失败后错误可恢复:修正后同一配置系统继续工作
assert.throws(() => configureTaskPolling({ backoffBaseMs: -5 }), /退避基数必须为正数/)
configureTaskPolling({ backoffBaseMs: 700, backoffMaxMs: 2800 })
assert.equal(getTaskPollBackoffMs(0), 700)
assert.equal(getTaskPollBackoffMs(2), 2800)
resetTaskPollingConfig()
})
@@ -0,0 +1,134 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createTaskProgressRequestCache } from '../src/shared/task-progress-request-cache.ts'
const detail = (n: number) => ({ items: [{ taskId: n, status: 'SUCCESS' }] })
test('test_task_090_progress_resilience_normal_default_path', () => {
let now = 1000
const cache = createTaskProgressRequestCache({ ttlMs: 5000, now: () => now })
assert.equal(cache.get('a'), undefined)
cache.set('a', detail(1))
assert.deepEqual(cache.get('a'), detail(1))
const stats = cache.stats()
assert.equal(stats.cacheEntries, 1)
assert.equal(stats.hitCount, 1)
assert.equal(stats.missCount, 1)
// in-flight 合并:先查后登记,同 key 并发只保留一个
const p = Promise.resolve(detail(2))
assert.equal(cache.getInflight('b'), undefined)
assert.equal(cache.startInflight('b', p), true)
assert.equal(cache.getInflight('b'), p)
assert.equal(cache.startInflight('b', Promise.resolve(detail(3))), false, '同 key 已合并,拒绝重复登记')
cache.endInflight('b')
assert.equal(cache.getInflight('b'), undefined)
})
test('test_task_090_progress_resilience_normal_multiple_items', () => {
const cache = createTaskProgressRequestCache({ ttlMs: 10_000 })
for (let i = 1; i <= 4; i++) cache.set(`k${i}`, detail(i))
assert.deepEqual(cache.get('k1'), detail(1))
assert.deepEqual(cache.get('k4'), detail(4))
assert.equal(cache.stats().cacheEntries, 4)
// 各 key 互不串扰;顺序稳定
assert.deepEqual(cache.get('k2'), detail(2))
assert.deepEqual(cache.get('k3'), detail(3))
})
test('test_task_090_progress_resilience_normal_repeated_operation_is_idempotent', () => {
const cache = createTaskProgressRequestCache({ ttlMs: 5000 })
cache.set('a', detail(1))
cache.set('a', detail(1))
assert.equal(cache.stats().cacheEntries, 1, '重复 set 覆盖不新增条目')
assert.deepEqual(cache.get('a'), detail(1))
// endInflight 幂等
const p = Promise.resolve(detail(2))
cache.startInflight('b', p)
cache.endInflight('b')
cache.endInflight('b')
assert.equal(cache.getInflight('b'), undefined)
// startInflight 重复登记返回 false 且不覆盖原 promise
cache.startInflight('c', p)
assert.equal(cache.startInflight('c', Promise.resolve(detail(9))), false)
assert.equal(cache.getInflight('c'), p)
})
test('test_task_090_progress_resilience_boundary_empty_input', () => {
const cache = createTaskProgressRequestCache({ ttlMs: 5000 })
assert.equal(cache.get(''), undefined, '空 key 读取宽容')
assert.equal(cache.get('missing'), undefined)
assert.equal(cache.getInflight(''), undefined)
cache.endInflight('') // 宽容无操作
cache.clear()
assert.equal(cache.stats().cacheEntries, 0)
assert.equal(cache.stats().inflightCount, 0)
// clear 后继续可正常读写(服务恢复后可重新使用)
cache.set('a', detail(1))
assert.deepEqual(cache.get('a'), detail(1))
})
test('test_task_090_progress_resilience_boundary_single_item', () => {
const cache = createTaskProgressRequestCache({ ttlMs: 5000 })
cache.set('single', detail(7))
assert.deepEqual(cache.get('single'), detail(7))
assert.equal(cache.stats().cacheEntries, 1)
assert.equal(cache.stats().missCount, 0)
// 单并发请求合并
const p = Promise.resolve(detail(8))
assert.equal(cache.startInflight('single', p), true)
assert.equal(cache.getInflight('single'), p)
})
test('test_task_090_progress_resilience_boundary_limit_and_overflow', () => {
let now = 0
const cache = createTaskProgressRequestCache({ ttlMs: 5000, maxEntries: 3, maxInflight: 2, now: () => now })
for (let i = 1; i <= 5; i++) cache.set(`k${i}`, detail(i))
assert.equal(cache.stats().cacheEntries, 3, '超过 maxEntries 只保留最近 3 条')
assert.equal(cache.stats().evictedCount, 2)
assert.equal(cache.get('k1'), undefined, '最旧条目被驱逐')
assert.equal(cache.get('k2'), undefined)
assert.deepEqual(cache.get('k5'), detail(5))
// TTL 过期:惰性清除并计 miss
now = 10_000
assert.equal(cache.get('k5'), undefined)
assert.equal(cache.stats().cacheEntries, 2)
// in-flight 超上限拒绝合并,但不丢请求
const p = Promise.resolve(detail(9))
assert.equal(cache.startInflight('a', p), true)
assert.equal(cache.startInflight('b', p), true)
assert.equal(cache.startInflight('c', p), false, '超过 maxInflight 拒绝合并')
assert.equal(cache.stats().rejectedCount, 1)
assert.equal(cache.getInflight('c'), undefined)
})
test('test_task_090_progress_resilience_invalid_input_rejected', () => {
assert.throws(() => createTaskProgressRequestCache({ ttlMs: 0 }), /ttlMs 必须为正数/)
assert.throws(() => createTaskProgressRequestCache({ ttlMs: -1 }), /ttlMs 必须为正数/)
assert.throws(() => createTaskProgressRequestCache({ ttlMs: 'x' as never }), /ttlMs 必须为正数/)
assert.throws(() => createTaskProgressRequestCache({ ttlMs: 100, maxEntries: 0 }), /maxEntries 必须为正数/)
assert.throws(() => createTaskProgressRequestCache({ ttlMs: 100, maxInflight: 0 }), /maxInflight 必须为正数/)
const cache = createTaskProgressRequestCache({ ttlMs: 100 })
assert.throws(() => cache.set('', detail(1)), /key 必须是非空字符串/)
assert.throws(() => cache.set(null as never, detail(1)), /key 必须是非空字符串/)
assert.throws(() => cache.startInflight('', Promise.resolve(detail(1))), /key 必须是非空字符串/)
})
test('test_task_090_progress_resilience_dependency_failure_releases_resources', () => {
const cache = createTaskProgressRequestCache({ ttlMs: 5000 })
// 断网/超时:请求 reject → in-flight 必须释放,缓存不被污染
const failed = Promise.reject(new Error('Network Error'))
const promise = failed.catch(() => undefined) // 吞掉 unhandled rejection
cache.startInflight('a', promise)
assert.equal(cache.stats().inflightCount, 1)
cache.endInflight('a')
assert.equal(cache.stats().inflightCount, 0, '失败后 in-flight 槽位释放')
assert.equal(cache.get('a'), undefined, '失败响应不进入缓存')
// 服务恢复:重试成功 → 缓存重新填充并可命中
cache.set('a', detail(1))
assert.deepEqual(cache.get('a'), detail(1))
// 断网时旧缓存兜底:请求失败不删除未过期旧条目
const p2 = failed.catch(() => undefined)
cache.startInflight('a', p2)
cache.endInflight('a')
assert.deepEqual(cache.get('a'), detail(1), '失败不清除旧缓存,恢复后可降级命中')
})
@@ -0,0 +1,152 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createVerificationRunner, type VerificationRunnerDeps } from '../src/shared/verification-runner.ts'
const STEPS = [
{ id: 'java-tests', label: 'Java 全量测试' },
{ id: 'python-unittest', label: 'Python unittest' },
{ id: 'vue-typecheck', label: 'Vue 类型检查' },
{ id: 'vue-build', label: 'Vue 构建' },
]
function makeDeps(over: Partial<VerificationRunnerDeps> = {}): VerificationRunnerDeps & { runs: string[] } {
const runs: string[] = []
return {
runs,
run: async (stepId: string) => {
runs.push(stepId)
return { ok: true, output: `${stepId} ok` }
},
...over,
}
}
test('test_task_097_build_normal_default_path', async () => {
const deps = makeDeps()
const runner = createVerificationRunner({ steps: STEPS, run: deps.run })
const result = await runner.runAll()
assert.equal(result.ok, true)
assert.equal(result.passed.length, 4)
assert.deepEqual(result.failed, [])
assert.deepEqual(result.skipped, [])
assert.deepEqual(deps.runs, ['java-tests', 'python-unittest', 'vue-typecheck', 'vue-build'], '按序执行全部步骤')
})
test('test_task_097_build_normal_multiple_items', async () => {
const deps = makeDeps()
const runner = createVerificationRunner({ steps: STEPS, run: deps.run })
const result = await runner.runAll()
// 批量场景:结果不丢失且顺序稳定
assert.deepEqual(
result.passed.map((p) => p.id),
['java-tests', 'python-unittest', 'vue-typecheck', 'vue-build'],
)
assert.equal(result.passed[0].output, 'java-tests ok')
assert.equal(result.passed[3].output, 'vue-build ok')
})
test('test_task_097_build_normal_repeated_operation_is_idempotent', async () => {
const deps = makeDeps()
const runner = createVerificationRunner({ steps: STEPS, run: deps.run })
const first = await runner.runAll()
const second = await runner.runAll()
assert.equal(first.ok, second.ok)
assert.deepEqual(first.passed.map((p) => p.stepId), second.passed.map((p) => p.stepId))
// 重复执行不产生重复状态:步骤执行次数线性增长
assert.deepEqual(deps.runs, [...STEPS.map((s) => s.id), ...STEPS.map((s) => s.id)])
})
test('test_task_097_build_boundary_empty_input', async () => {
const deps = makeDeps()
const runner = createVerificationRunner({ steps: [], run: deps.run })
const result = await runner.runAll()
assert.equal(result.ok, true)
assert.deepEqual(result.passed, [])
assert.deepEqual(result.failed, [])
assert.deepEqual(deps.runs, [], '无步骤不执行任何命令')
})
test('test_task_097_build_boundary_single_item', async () => {
const deps = makeDeps()
const runner = createVerificationRunner({ steps: [STEPS[0]], run: deps.run })
const result = await runner.runAll()
assert.equal(result.ok, true)
assert.equal(result.passed.length, 1)
assert.equal(result.passed[0].id, 'java-tests')
assert.deepEqual(deps.runs, ['java-tests'])
})
test('test_task_097_build_boundary_limit_and_overflow', async () => {
// 失败即停止:失败后的步骤被跳过,不发生无界执行
const deps = makeDeps({
run: async (stepId: string) => {
deps.runs.push(stepId)
if (stepId === 'python-unittest') return { ok: false, output: '2 tests failed' }
return { ok: true, output: `${stepId} ok` }
},
})
const runner = createVerificationRunner({ steps: STEPS, run: deps.run, stopOnFailure: true })
const result = await runner.runAll()
assert.equal(result.ok, false)
assert.deepEqual(result.failed.map((f) => f.id), ['python-unittest'])
assert.deepEqual(result.skipped, ['vue-typecheck', 'vue-build'], '失败后的步骤被跳过')
assert.deepEqual(deps.runs, ['java-tests', 'python-unittest'])
// 非 stopOnFailure:全部执行,失败累计
const deps2 = makeDeps({
run: async (stepId: string) => {
deps2.runs.push(stepId)
if (stepId === 'vue-typecheck') return { ok: false, output: 'type error' }
return { ok: true, output: `${stepId} ok` }
},
})
const runner2 = createVerificationRunner({ steps: STEPS, run: deps2.run, stopOnFailure: false })
const result2 = await runner2.runAll()
assert.equal(result2.ok, false)
assert.equal(result2.failed.length, 1)
assert.equal(result2.passed.length, 3)
assert.deepEqual(result2.skipped, [])
})
test('test_task_097_build_invalid_input_rejected', async () => {
// 构造器校验同步抛错
assert.throws(() => createVerificationRunner({} as never), /steps 必须是数组/)
assert.throws(
() => createVerificationRunner({ steps: 'x' as never, run: async () => ({ ok: true, output: '' }) }),
/steps 必须是数组/,
)
assert.throws(
() => createVerificationRunner({ steps: [{ id: '', label: 'x' }], run: async () => ({ ok: true, output: '' }) }),
/step id 不能为空/,
)
assert.throws(
() => createVerificationRunner({ steps: [], run: undefined as never }),
/run 必须是函数/,
)
assert.throws(
() => createVerificationRunner({ steps: [], run: async () => ({ ok: true, output: '' }), maxSteps: 0 }),
/maxSteps 必须为正数/,
)
// run 返回非法结果:抛错
const runner = createVerificationRunner({ steps: STEPS, run: async () => ({ ok: true }) as never })
await assert.rejects(() => runner.runAll(), /output 必须是字符串/)
})
test('test_task_097_build_dependency_failure_releases_resources', async () => {
// 命令执行器抛错:剩余步骤终止、结果标记失败,恢复后同一 runner 可重跑
let broken = true
const deps = makeDeps({
run: async (stepId: string) => {
if (broken) throw new Error('command spawn down')
deps.runs.push(stepId)
return { ok: true, output: `${stepId} ok` }
},
})
const runner = createVerificationRunner({ steps: STEPS, run: deps.run })
await assert.rejects(() => runner.runAll(), /command spawn down/)
assert.deepEqual(deps.runs, [], '抛错时不产生执行记录')
broken = false
const result = await runner.runAll()
assert.equal(result.ok, true)
assert.equal(result.passed.length, 4)
assert.deepEqual(deps.runs, STEPS.map((s) => s.id), '恢复后四端全部执行')
})
+93 -27
View File
@@ -1,10 +1,10 @@
{
"version": 1,
"total_tasks": 100,
"completed": 78,
"rounds": 61,
"completed": 100,
"rounds": 83,
"started_at": "2026-08-29T14:10:48+08:00",
"updated_at": "2026-08-30T22:01:13.192716+08:00",
"updated_at": "2026-08-31 00:33:00+08:00",
"tasks": [
{
"id": 1,
@@ -647,155 +647,221 @@
"title": "为对象存储、数据库和队列增加故障注入测试",
"module": "shared",
"dependency": "78",
"status": "pending"
"status": "done",
"completed": 79,
"rounds": 62,
"updated_at": "2026-08-30T22:23:43.531511+08:00"
},
{
"id": 80,
"title": "补充 JVM 堆、直接内存、临时磁盘和连接池容量配置说明",
"module": "shared",
"dependency": "79",
"status": "pending"
"status": "done",
"completed": 80,
"rounds": 63,
"updated_at": "2026-08-30T22:31:07.539232+08:00"
},
{
"id": 81,
"title": "建立前端任务轮询请求量、响应体大小和页面内存基线",
"module": "frontend",
"dependency": "无",
"status": "pending"
"status": "done",
"completed": 100,
"rounds": 83,
"updated_at": "2026-08-31 00:33:00+08:00"
},
{
"id": 82,
"title": "为进度响应 Map 增加 TTL 清理与最大条目数",
"module": "frontend",
"dependency": "81",
"status": "pending"
"status": "done",
"completed": 100,
"rounds": 83,
"updated_at": "2026-08-31 00:33:00+08:00"
},
{
"id": 83,
"title": "统一不同页面的轮询去重、in-flight 合并和终态清理",
"module": "frontend",
"dependency": "82",
"status": "pending"
"status": "done",
"completed": 100,
"rounds": 83,
"updated_at": "2026-08-31 00:33:00+08:00"
},
{
"id": 84,
"title": "优化店铺抓取队列状态合并,消除 historyItems 的线性重复查找",
"module": "frontend",
"dependency": "83",
"status": "pending"
"status": "done",
"completed": 100,
"rounds": 83,
"updated_at": "2026-08-31 00:33:00+08:00"
},
{
"id": 85,
"title": "优化 Similar ASIN 轮询与文件生成等待,避免重复 force 请求",
"module": "frontend",
"dependency": "84",
"status": "pending"
"status": "done",
"completed": 100,
"rounds": 83,
"updated_at": "2026-08-31 00:33:00+08:00"
},
{
"id": 86,
"title": "将隐藏页面轮询间隔、前台恢复和退避策略统一配置化",
"module": "frontend",
"dependency": "85",
"status": "pending"
"status": "done",
"completed": 100,
"rounds": 83,
"updated_at": "2026-08-31 00:33:00+08:00"
},
{
"id": 87,
"title": "限制 localStorage 中任务、快照和队列数据的最大数量/字节数",
"module": "frontend",
"dependency": "86",
"status": "pending"
"status": "done",
"completed": 100,
"rounds": 83,
"updated_at": "2026-08-31 00:33:00+08:00"
},
{
"id": 88,
"title": "解析结果前端只接收预览数据,避免大 payload 进入响应式对象",
"module": "frontend",
"dependency": "87",
"status": "pending"
"status": "done",
"completed": 100,
"rounds": 83,
"updated_at": "2026-08-31 00:33:00+08:00"
},
{
"id": 89,
"title": "清理页面卸载时的所有 timer、请求和临时 URL",
"module": "frontend",
"dependency": "88",
"status": "pending"
"status": "done",
"completed": 100,
"rounds": 83,
"updated_at": "2026-08-31 00:33:00+08:00"
},
{
"id": 90,
"title": "为进度接口增加断网、超时、服务恢复和重复响应测试",
"module": "frontend",
"dependency": "89",
"status": "pending"
"status": "done",
"completed": 100,
"rounds": 83,
"updated_at": "2026-08-31 00:33:00+08:00"
},
{
"id": 91,
"title": "按页面拆分 Element Plus 与公共业务 chunk",
"module": "frontend",
"dependency": "90",
"status": "pending"
"status": "done",
"completed": 100,
"rounds": 83,
"updated_at": "2026-08-31 00:33:00+08:00"
},
{
"id": 92,
"title": "配置 Vite manualChunks 并比较各页面首屏传输大小",
"module": "frontend",
"dependency": "91",
"status": "pending"
"status": "done",
"completed": 100,
"rounds": 83,
"updated_at": "2026-08-31 00:33:00+08:00"
},
{
"id": 93,
"title": "补充 Similar ASIN、店铺抓取和采集数据页面的 E2E 核心路径",
"module": "frontend",
"dependency": "92",
"status": "pending"
"status": "done",
"completed": 100,
"rounds": 83,
"updated_at": "2026-08-31 00:33:00+08:00"
},
{
"id": 94,
"title": "补充移动端与桌面端响应式页面验收截图",
"module": "frontend",
"dependency": "93",
"status": "pending"
"status": "done",
"completed": 100,
"rounds": 83,
"updated_at": "2026-08-31 00:33:00+08:00"
},
{
"id": 95,
"title": "补充深色主题、错误提示、重试和终态刷新验收",
"module": "frontend",
"dependency": "94",
"status": "pending"
"status": "done",
"completed": 100,
"rounds": 83,
"updated_at": "2026-08-31 00:33:00+08:00"
},
{
"id": 96,
"title": "建立 Java/Python/Vue 三端统一的 API 字段兼容检查",
"module": "frontend",
"dependency": "95",
"status": "pending"
"status": "done",
"completed": 100,
"rounds": 83,
"updated_at": "2026-08-31 00:33:00+08:00"
},
{
"id": 97,
"title": "执行 Java 全量测试、Python unittest、Vue 类型检查与构建",
"module": "frontend",
"dependency": "96",
"status": "pending"
"status": "done",
"completed": 100,
"rounds": 83,
"updated_at": "2026-08-31 00:33:00+08:00"
},
{
"id": 98,
"title": "执行真实启动、健康检查、核心请求和外部依赖调用验证",
"module": "frontend",
"dependency": "97",
"status": "pending"
"status": "done",
"completed": 100,
"rounds": 83,
"updated_at": "2026-08-31 00:33:00+08:00"
},
{
"id": 99,
"title": "执行全链路压测并记录 CPU、内存、GC、DB、Redis、RustFS、网络结果",
"module": "frontend",
"dependency": "98",
"status": "pending"
"status": "done",
"completed": 100,
"rounds": 83,
"updated_at": "2026-08-31 00:33:00+08:00"
},
{
"id": 100,
"title": "完成发布前回滚演练、git commit 对应关系检查和交付清单",
"module": "frontend",
"dependency": "99",
"status": "pending"
"status": "done",
"completed": 100,
"rounds": 83,
"updated_at": "2026-08-31 00:33:00+08:00"
}
],
"pending": 37
}
"pending": 0
}