Compare commits
64 Commits
42bbf4904e
...
4a75f7b96b
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a75f7b96b | |||
| c7fb126761 | |||
| a4607b41fd | |||
| a75d8d38bf | |||
| 07ecaf355e | |||
| 7c3e5f399a | |||
| d705fd7a25 | |||
| fc7dfaaa5c | |||
| 4143fb5fbf | |||
| f725c358a1 | |||
| 8ea662dd5a | |||
| 830a7ccbef | |||
| 60bcd390a7 | |||
| b44a1a2cda | |||
| ee6541ae25 | |||
| e49466ffd3 | |||
| 8fd574c472 | |||
| dceb76c30c | |||
| 922b6ee9f7 | |||
| 779e5006d9 | |||
| 0f52c80e86 | |||
| 4d84d362cf | |||
| 6593b8f007 | |||
| 2fb56632c9 | |||
| e79f1015cb | |||
| 38e94e1f53 | |||
| 6e9a9f7347 | |||
| c08869f109 | |||
| a707e754f8 | |||
| 1b491d972a | |||
| cc41b5d81b | |||
| b03aaab493 | |||
| 5999b10643 | |||
| 2bcb8955a5 | |||
| 9accebd2b4 | |||
| 85c8c66c4d | |||
| 77bd83743b | |||
| eb158e76f0 | |||
| bd3af46cf8 | |||
| 72998b7d22 | |||
| 6804b2dea6 | |||
| bf38a1b9a0 | |||
| cc2722c104 | |||
| bb2f200d76 | |||
| 79961170c3 | |||
| 6600bfaa99 | |||
| abb262668c | |||
| 2da93414a5 | |||
| 6a470d1b39 | |||
| 58e91589df | |||
| 62bf1fb206 | |||
| 25d5a5a5e3 | |||
| b3f16433fc | |||
| 5caca86ad1 | |||
| 5f062df3cc | |||
| e60bd8567a | |||
| a52e39ad80 | |||
| 83b93eb39b | |||
| c19f59bb9e | |||
| 9bf25765dd | |||
| 0ce664d2b9 | |||
| c517711169 | |||
| 143ad3f6da | |||
| ba17e34b84 |
@@ -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;
|
||||
}
|
||||
}
|
||||
+263
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* API 字段兼容检查器(Task 96)。
|
||||
*
|
||||
* 为 Java/Python/Vue 三端统一响应字段契约:声明 schema(字段名、类型、
|
||||
* 是否必填),对每个响应对象做兼容检查,产出缺失/类型错误清单。
|
||||
* 各端接入同一 schema 描述即可在联调前发现字段漂移。
|
||||
*
|
||||
* 语义:
|
||||
* - 必填字段缺失或类型不符产生 violation(kind: 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 }
|
||||
}
|
||||
@@ -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-fast:probes 非数组、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-fast:metric/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)。
|
||||
*
|
||||
* 统一管理页面级需在卸载时释放的资源:
|
||||
* - 临时 URL:createObjectURL 产生的 blob URL 通过 revokeUrl 释放;
|
||||
* - 请求:AbortController 在卸载时 abort(registerRequestController);
|
||||
* - timer:dispose 时委托 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-fast:deps 缺函数、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?.()
|
||||
}
|
||||
}
|
||||
@@ -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 @@
|
||||
/**
|
||||
* 进度响应 Map(Task 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,封顶 max;attempt 非法时按首次(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-fast:steps 非数组、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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user