This commit is contained in:
2026-10-19 17:56:36 +08:00
parent bb917c954c
commit dcfba1053c
232 changed files with 15655 additions and 6005 deletions
+1
View File
@@ -10,6 +10,7 @@
| 类别 | 正式入口 | 管理边界 |
|---|---|---|
| 本机重型检查限流 | `local-check.ps1` | 所有工作目录、对话共用一个用户级执行名额;PowerShell 7,状态/超时/并发与 Job Object 回收规则见外层 `docs/AI工程执行与协作约束.md`;只读状态不申请重型名额 |
| 测试/灰度单票验收 | `prod-release-acceptance.mjs`、`prod-release-delivery-stage.mjs`、内部 `prod-release-order-stage.mjs` | 显式 test/gray、统一 Gateway/accessToken;同一票义乌到洛杉矶、前置同时核对东莞;full-flow 串行覆盖买单FBA正常链至签收回传及车次资源释放,保留分阶段入口与独立检查点;不调用测试造数模块,不创建账号/主档或删除数据;配置和边界见 README |
| Admin 全量 HTTP 验证 | `admin-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;默认模块覆盖平台全链路,定向验证使用 `--modules`,模块注册和依赖以总入口及 `admin-test-env-modules/README.md` 为准 |
| Client 全量 HTTP 验证 | `client-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;覆盖客户工作台完整链路,`client-phase2-test-env-acceptance.mjs` 只是入库测量与计费专项,不替代全量入口 |
+2
View File
@@ -1,5 +1,7 @@
# 后端脚本管理与全量 HTTP 验收索引
`local-check.ps1`:Windows / PowerShell 7 本机重型检查统一入口,适用于前后端测试、类型检查、构建、安装依赖及 Maven。示例:`& ./scripts/local-check.ps1 -WorkingDirectory ../apps/admin-web -Label 'Admin typecheck' -Command @('pnpm', 'typecheck')`;`-Status` 查看占用。当前用户所有工作目录共用一个文件句柄锁,默认排队900秒、执行1200秒;超时分别退出75/124,其余透传命令退出码。Vitest workers=2,Node堆2048MB,Java堆1536MB/处理器2;直接 Maven 自动单线程/单fork,直接 Node 内置测试自动并发2。Windows Job Object 仅回收本次后代,不接管旧进程;嵌套调用拒绝,批次内部执行原始命令。无凭据、不主动联网或写业务数据;被包装命令的环境授权不变。状态和最近结果仅写 `%LOCALAPPDATA%/Chongchong/LocalChecks/`,不存命令参数或输出;不自动缓存成功结果。失败保留最近摘要,确认输入后重新排队;锁文件无需手工删除,状态不是验收报告。完整规则见 `../../docs/AI工程执行与协作约束.md` 的“本机重型检查与多对话限流”;离线行为测试为 `local-check.test.mjs`。
`local-transfer-pickup-acceptance.mjs`:仅本机 LOCAL 仓储转运取货造数与 HTTP 验收,拒绝非 localhost/127.0.0.1 Gateway。通过 `node --env-file=<本轮明确的本地配置> scripts/local-transfer-pickup-acceptance.mjs` 注入 `ADMIN_TEST_GATEWAY`、`ADMIN_TEST_USERNAME`、`ADMIN_TEST_PASSWORD`、`LOCAL_TRANSFER_PDA_PASSWORD`,不猜测或读取其他环境文件。创建16张明确 TEST 标记的业务单、专用PDA账号、司机车辆和多状态取货车次,含客户下单、跨客户共车、取消改派、资源占用、凭证隔离、送仓门禁与完整PDA测量。所有业务均走正式 Gateway HTTP,不用业务 SQL;文件是合成测试 PNG,不代表实际拍照或有效证照。报告默认 `reports/PICKUP-<新批次>.json`,可用 `PICKUP_ACCEPTANCE_OUTPUT` 指定;写入前保存脱敏意图与幂等键,逐阶段落盘。失败立即停止并保留已成功数据及报告,未知结果先核对原请求,不自动重发;重新执行会创建新批次,不自动续跑或清理旧记录。人工核实原请求结果后,可显式用 `PICKUP_ACCEPTANCE_RESUME=<本轮报告>` 沿已知单号继续验收;已成功批次禁止重复执行。脚本记录每次执行范围与结果,旧失败不删除;本机缺失的正式车次取消/结案/释放权限仅补权限资源并授权新建专用验收角色,不修改现有角色授权。账号和单据保留供本地页面验收,不能作为远端 TEST/生产或视觉验收结论。
`db/fleet-transfer-pickup-20261017.sql`:本功能 MySQL8 可重复执行结构增量,显式指向 `baiyue_lastleg`。为原车次增加 `trip_purpose`,新增取货计划摘要、原单任务、命令幂等、上传归属及历史五表;不写业务数据。结构同步于模块 `lastleg.sql` 和统一部署增量。需在已授权环境用运行者显式数据库凭据执行;失败保留诊断,修正后可重复执行,不删旧结构或业务数据。
@@ -58,7 +58,9 @@ test('customer identity joins use the same explicit collation as existing fulfil
test('commercial schemas do not invent container or palletizing facts for pool orders', () => {
for (const name of ['ll_local_looseboard_order']) {
const definition = table(overseas, 'baiyue_lastleg', name)
assert.match(definition, /container_type\s+varchar\(32\)(?:\s+COLLATE\s+\w+)?\s+(?:NULL\s+)?DEFAULT NULL/i)
assert.doesNotMatch(definition, /container_type/i)
assert.match(definition, /estimated_pallet_count INT NULL/i)
assert.match(definition, /product_name VARCHAR\(255\) NULL/i)
assert.doesNotMatch(definition, /container_type[^\n]*DEFAULT '40HQ'/i)
}
assert.doesNotMatch(table(overseas, 'baiyue_lastleg', 'll_us_truck_order'), /container_type/i)
+86 -13
View File
@@ -94,14 +94,23 @@ Assert-MarkdownTableUnique $businessIndexPath '## 业务快速路由表' 0 '详
Assert-MarkdownTableUnique $businessIndexPath '## 常用说法与歧义分流' 0 '业务歧义分流表'
Assert-Contains $businessIndex '## 业务快速路由表' '详细业务表存在'
Assert-Contains $businessIndex '## 常用说法与歧义分流' '业务歧义分流存在'
$adminAgents = Read-RequiredText $adminAgentsPath
$adminEntry = Read-RequiredText $adminAgentsPath
$adminIndexPath = Join-Path $workspaceRoot 'apps/admin-web/docs/business-index.md'
$adminIndex = Read-RequiredText $adminIndexPath
$adminAgents = $adminEntry + "`n" + $adminIndex
$adminOverseasAgentsPath = Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/AGENTS.md'
$adminOverseasAgents = Read-RequiredText $adminOverseasAgentsPath
Assert-Contains $adminAgents 'src/modules/overseas-warehouse/AGENTS.md' 'Admin 海外仓按需指引入口'
Assert-MarkdownTableUnique $adminOverseasAgentsPath '## 页面与业务快速索引' 0 'Admin 海外仓模块表'
$clientAgents = Read-RequiredText $clientAgentsPath
$clientEntry = Read-RequiredText $clientAgentsPath
$clientIndexPath = Join-Path $workspaceRoot 'apps/client-web/docs/business-index.md'
$clientIndex = Read-RequiredText $clientIndexPath
$clientAgents = $clientEntry + "`n" + $clientIndex
$pdaAgents = Read-RequiredText $pdaAgentsPath
$overseasPdaAgents = Read-RequiredText $overseasPdaAgentsPath
$overseasPdaEntry = Read-RequiredText $overseasPdaAgentsPath
$overseasPdaIndexPath = Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/docs/business-index.md'
$overseasPdaIndex = Read-RequiredText $overseasPdaIndexPath
$overseasPdaAgents = $overseasPdaEntry + "`n" + $overseasPdaIndex
$backendAgents = Read-RequiredText $backendAgentsPath
$backendIndexPath = Join-Path $backendRoot 'docs/backend-business-index.md'
$lastlegIndexPath = Join-Path $backendRoot 'lastleg-service/docs/business-index.md'
@@ -110,30 +119,64 @@ $lastlegIndex = Read-RequiredText $lastlegIndexPath
Assert-Contains $backendAgents 'docs/backend-business-index.md' '后端按需索引入口'
$lastlegEntry = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/AGENTS.md')
Assert-Contains $lastlegEntry 'docs/business-index.md' 'Lastleg 按需索引入口'
$firstlegEntryPath = Join-Path $backendRoot 'firstleg/AGENTS.md'
$firstlegIndexPath = Join-Path $backendRoot 'firstleg/docs/business-index.md'
$firstlegEntry = Read-RequiredText $firstlegEntryPath
$firstlegIndex = Read-RequiredText $firstlegIndexPath
Assert-Contains $firstlegEntry 'docs/business-index.md' 'Firstleg 按需索引入口'
Assert-Contains $firstlegEntry '修改前必须核对该索引对应入口' 'Firstleg 业务变更细则读取边界'
Assert-Contains $firstlegIndex '以 `firstleg/` 服务目录起算' 'Firstleg 详细索引路径起点'
Assert-Contains $adminEntry 'docs/business-index.md' 'Admin 按需索引入口'
Assert-Contains $clientEntry 'docs/business-index.md' 'Client 按需索引入口'
Assert-Contains $overseasPdaEntry 'docs/business-index.md' '海外 PDA 按需索引入口'
foreach ($applicationDetailIndex in @(
@{ Name = 'Admin'; Text = $adminIndex; Base = 'apps/admin-web' },
@{ Name = 'Client'; Text = $clientIndex; Base = 'apps/client-web' },
@{ Name = '海外 PDA'; Text = $overseasPdaIndex; Base = 'apps/pda-overseas-uniapp' }
)) {
Assert-Matches $applicationDetailIndex.Text '\[[^\]]+\]\(\.\./AGENTS\.md\)' "$($applicationDetailIndex.Name) 详细索引链接应用主指引"
Assert-Path "$($applicationDetailIndex.Base)/docs/../AGENTS.md"
}
Assert-MarkdownTableUnique $adminAgentsPath '## 模块快速路由' 0 'Admin 短入口表'
Assert-MarkdownTableUnique $adminIndexPath '## 模块快速路由' 0 'Admin 详细索引表'
Assert-MarkdownTableUnique $clientAgentsPath '## 模块快速查询' 0 'Client 短入口表'
Assert-MarkdownTableUnique $clientIndexPath '## 模块快速查询' 0 'Client 详细索引表'
Assert-MarkdownTableUnique $overseasPdaAgentsPath '## 页面与能力快速索引' 0 '海外 PDA 短入口表'
Assert-MarkdownTableUnique $overseasPdaIndexPath '## 页面与能力快速索引' 0 '海外 PDA 详细索引表'
foreach ($readingBoundary in @($adminEntry, $clientEntry, $overseasPdaEntry)) {
Assert-Contains $readingBoundary '涉及业务行为变更时必须核对对应细则' '终端业务细则读取边界'
}
foreach ($budget in @(
@{ Name = '后端根'; Text = $backendAgents; Limit = 13000 },
@{ Name = 'Lastleg'; Text = $lastlegEntry; Limit = 18000 }
@{ Name = 'Firstleg'; Text = $firstlegEntry; Limit = 8000 },
@{ Name = 'Lastleg'; Text = $lastlegEntry; Limit = 9000 },
@{ Name = 'Admin'; Text = $adminEntry; Limit = 8000 },
@{ Name = 'Client'; Text = $clientEntry; Limit = 8000 },
@{ Name = '海外 PDA'; Text = $overseasPdaEntry; Limit = 8000 }
)) {
$script:checked++
if ($budget.Text.Length -gt $budget.Limit) { Add-Failure "$($budget.Name) 指引超出字符预算 $($budget.Limit),请将细节按需下沉" }
$normalizedLength = ($budget.Text -replace "`r`n", "`n").Length
if ($normalizedLength -gt $budget.Limit) { Add-Failure "$($budget.Name) 指引超出字符预算 $($budget.Limit),请将细节按需下沉" }
}
Assert-MarkdownTableUnique $backendIndexPath '## 服务路由(按关键词定位)' 0 '后端详细索引'
Assert-MarkdownTableUnique $lastlegIndexPath '## 快速索引' 0 'Lastleg 详细索引'
Assert-MarkdownTableUnique $firstlegEntryPath '## 模块入口' 0 'Firstleg 首个代码入口'
Assert-MarkdownTableUnique $firstlegIndexPath '## 模块入口' 0 'Firstleg 详细索引'
# 保留全部业务语义断言,只将事实读取范围扩展到明确链接的按需文档。
$backendAgents += "`n" + $backendIndex
# 固定样本只检查目标索引行和真实文件,不递归搜索;字符数是读取量指标,不是耗时或 Token 实测。
$lookupSamples = @(
@{ Key = '客户专属价格簿'; Text = $adminAgents; Entry = 'modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue'; Base = 'apps/admin-web/src' },
@{ Key = '入库扣货、解除与凭证历史'; Text = $adminAgents; Entry = 'modules/unified-orders/pages/UnifiedOrdersView.vue'; Base = 'apps/admin-web/src' },
@{ Key = '国内/海外 PDA 独立人员'; Text = $adminAgents; Entry = 'modules/iam/pages/InternalUsersView.vue'; Base = 'apps/admin-web/src' },
@{ Key = '客户专属价格簿'; Text = $adminEntry; Entry = 'modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue'; Base = 'apps/admin-web/src' },
@{ Key = '入库扣货、解除与凭证历史'; Text = $adminEntry; Entry = 'modules/unified-orders/pages/UnifiedOrdersView.vue'; Base = 'apps/admin-web/src' },
@{ Key = '国内/海外 PDA 独立人员'; Text = $adminEntry; Entry = 'modules/iam/pages/InternalUsersView.vue'; Base = 'apps/admin-web/src' },
@{ Key = '尾程订单控制'; Text = $adminOverseasAgents; Entry = 'pages/LastMileOrdersView.vue'; Base = 'apps/admin-web/src/modules/overseas-warehouse' },
@{ Key = '排车查询'; Text = $adminOverseasAgents; Entry = 'pages/DispatchesView.vue'; Base = 'apps/admin-web/src/modules/overseas-warehouse' },
@{ Key = '海外出库与派送'; Text = $adminOverseasAgents; Entry = 'pages/OutboundsView.vue'; Base = 'apps/admin-web/src/modules/overseas-warehouse' },
@{ Key = '异常退回与客户回访'; Text = $adminOverseasAgents; Entry = 'pages/ExceptionOrdersView.vue'; Base = 'apps/admin-web/src/modules/overseas-warehouse' },
@{ Key = '库存余额'; Text = $adminOverseasAgents; Entry = 'pages/InventoryView.vue'; Base = 'apps/admin-web/src/modules/overseas-warehouse' },
@{ Key = '退货入库签收与合箱'; Text = $overseasPdaAgents; Entry = 'src/pages/return-receipt/return-receipt.vue'; Base = 'apps/pda-overseas-uniapp' },
@{ Key = '退货入库签收与合箱'; Text = $overseasPdaAgents; Entry = 'src/pages/return-packing/return-packing.vue'; Base = 'apps/pda-overseas-uniapp' }
@{ Key = '退货入库签收与合箱'; Text = $overseasPdaEntry; Entry = 'src/pages/return-receipt/return-receipt.vue'; Base = 'apps/pda-overseas-uniapp' },
@{ Key = '退货入库签收与合箱'; Text = $overseasPdaEntry; Entry = 'src/pages/return-packing/return-packing.vue'; Base = 'apps/pda-overseas-uniapp' }
)
$lookupCharacters = 0
foreach ($alias in @(
@@ -278,7 +321,7 @@ Assert-Path 'baiyue-firstleg/scripts/README.md'
Assert-Path 'baiyue-firstleg/scripts/script-documentation-contract.test.mjs'
Assert-Contains $mapperScanContract 'annotationClass = Mapper.class' 'Mapper 根包注解过滤合同'
Assert-Contains $mapperScanContract '禁止分散维护 MapperScan' 'Mapper 单一扫描入口合同'
$firstlegAgents = Read-RequiredText (Join-Path $backendRoot 'firstleg/AGENTS.md')
$firstlegAgents = $firstlegEntry + "`n" + $firstlegIndex
$lastlegAgents = $lastlegEntry + "`n" + $lastlegIndex
$warehouseAgents = Read-RequiredText (Join-Path $backendRoot 'warehouse/AGENTS.md')
$coloadOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/coload-groups-v1.yaml')
@@ -382,7 +425,9 @@ Assert-Path 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricing/
# 海外异常处理必须贯通证据、原出库来源和库存恢复,不能退化为仅修改派送状态。
$exceptionOrderService = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java')
$inventoryCommandService = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java')
Assert-Contains $businessIndex '退回海外仓时先把实收恢复到指定真实托盘/库位' '根海外异常库存恢复路由'
Assert-Contains $businessIndex '海外出库、排车、派送、异常与回访' '根海外异常库存恢复路由'
Assert-Contains $businessIndex 'exceptionorder/' '根海外异常服务入口'
Assert-Contains $lastlegIndex '退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位' 'Lastleg 海外异常库存恢复规则'
Assert-Contains $adminOverseasAgents '库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进' 'Admin 海外异常原子边界'
Assert-Contains $backendAgents '海外异常退回、库存恢复与客户回访' '后端海外异常库存恢复路由'
Assert-Contains $lastlegAgents 'ExceptionEvidenceFileGateway.java' 'Lastleg 异常证据入口'
@@ -719,6 +764,30 @@ Assert-Contains $lastlegAgents '海外仓全流程连续覆盖' 'Lastleg 海外
Assert-Contains $overseasPdaAgents '排车拣货与发车' '海外 PDA 排车入口'
Assert-Contains $overseasPdaAgents '普通/一件代发出库' '海外 PDA 出库入口'
# 排车新主流程按单号、公开版本和动作资格定位,不再指向独立车次发车请求。
Assert-Contains $overseasPdaEntry 'departDispatch' '海外 PDA 当前发车调用入口'
Assert-Contains $overseasPdaIndex 'actions.PICK/LOADING_COMPLETE/DEPART' '海外 PDA 服务端动作资格'
Assert-Contains $overseasPdaIndex 'recordVersion' '海外 PDA 排车公开版本'
Assert-Contains $lastlegIndex 'docs/海外仓业务/排车列表统一作业改造契约.md' 'Lastleg 当前排车主契约'
Assert-NotContains $overseasPdaAgents 'tripDepartureRequest' '海外 PDA 旧车次发车入口退出'
Assert-NotContains $overseasPdaAgents 'tripRecordVersion' '海外 PDA 旧车次版本退出'
Assert-Contains $lastlegEntry 'dispatch/controller/DispatchAggregateController.java' 'Lastleg 主指引平台整车入口'
Assert-Contains $lastlegEntry 'dispatch/pda/controller/DispatchPdaController.java' 'Lastleg 主指引 PDA 整车入口'
Assert-Contains $lastlegEntry 'DispatchDepartureService.java' 'Lastleg 主指引共用发车服务'
foreach ($lastlegDispatchGuide in @(
@{ Name = 'Lastleg 主指引'; Text = $lastlegEntry },
@{ Name = 'Lastleg 详细索引'; Text = $lastlegIndex }
)) {
Assert-Contains $lastlegDispatchGuide.Text 'recordVersion' "$($lastlegDispatchGuide.Name) 排车公开版本"
foreach ($obsoleteDispatchRule in @('提交车次版本', 'tripRecordVersion', 'READY_TO_DEPART')) {
Assert-NotContains $lastlegDispatchGuide.Text $obsoleteDispatchRule "$($lastlegDispatchGuide.Name) 旧排车规则退出"
}
}
$lastlegPdaPublicController = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java')
Assert-Contains $lastlegPdaPublicController 'DispatchDepartureService' 'Lastleg PDA 真实入口使用共用发车服务'
Assert-Contains $lastlegPdaPublicController 'DispatchCommands.Depart' 'Lastleg PDA 真实入口使用公开发车命令'
Assert-Path 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDepartureService.java'
# Admin 仅对静态资源过期做一次受控恢复,不能吞掉普通路由错误或覆盖未保存编辑。
$navigationRecovery = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/core/routing/navigationRecovery.ts')
Assert-Contains $navigationRecovery 'const RECOVERY_WINDOW_MS = 30_000' 'Admin 路由恢复窗口'
@@ -796,7 +865,7 @@ Assert-Contains $carrierScheduleOpenApi 'estimated facts never advance actual pr
Assert-Contains $adminContainerDetail 'confirmDeparture' 'Admin 货柜详情确认离仓'
# 装柜完成后的整柜报关必须保持客户退税资料、平台冻结/完成与 File 二进制边界一致。
$firstlegAgents = Read-RequiredText (Join-Path $backendRoot 'firstleg/AGENTS.md')
$firstlegAgents = $firstlegEntry + "`n" + $firstlegIndex
$containerCustomsOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/customs-v1.yaml')
$adminContainerCustomsApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/container-loading/api/platformContainerCustoms.ts')
$clientContainerCustomsApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/first-leg/api/customs.ts')
@@ -1242,6 +1311,10 @@ $indexedAgentFiles = @(
$backendAgentsPath,
$backendIndexPath,
$lastlegIndexPath,
$firstlegIndexPath,
$adminIndexPath,
$clientIndexPath,
$overseasPdaIndexPath,
(Join-Path $backendRoot 'firstleg/AGENTS.md'),
(Join-Path $backendRoot 'finance/AGENTS.md'),
(Join-Path $backendRoot 'lastleg-service/AGENTS.md'),
+5 -1
View File
@@ -5,6 +5,10 @@ param(
)
$ErrorActionPreference = "Stop"
if (-not $DryRun -and -not $env:CHONGCHONG_LOCAL_CHECK_OWNER) {
& (Join-Path $PSScriptRoot 'local-check.ps1') -Label 'Backend full regression' -Command @('pwsh', '-NoProfile', '-File', $PSCommandPath, '-MavenCommand', $MavenCommand)
exit $LASTEXITCODE
}
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$pom = Join-Path $repoRoot "pom.xml"
$mapperScanContract = Join-Path $repoRoot "scripts/mapper-scan-contract.mjs"
@@ -39,7 +43,7 @@ $requiredTests = @(
"warehouse/src/test/java/com/baiyue/warehouse/repository/OverseasStorageFeeSqlContractTest.java",
"warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeControllerContractTest.java"
)
$mavenArgs = @("-f", $pom, "test")
$mavenArgs = @('-T', '1', '-DforkCount=1', '-DreuseForks=true', "-f", $pom, "test")
Write-Host "Backend regression root: $repoRoot"
Write-Host "Scope: complete root POM reactor (all active modules and their tests)"
+17 -2
View File
@@ -7,14 +7,29 @@ param(
)
$ErrorActionPreference = 'Stop'
if (-not $DryRun -and -not $env:CHONGCHONG_LOCAL_CHECK_OWNER) {
$batch = @('pwsh', '-NoProfile', '-File', $PSCommandPath)
foreach ($flag in @('SkipAdmin', 'SkipClient', 'SkipPda')) {
if ($PSBoundParameters[$flag]) { $batch += "-$flag" }
}
& (Join-Path $PSScriptRoot 'local-check.ps1') -Label 'Frontend full regression' -Command $batch
exit $LASTEXITCODE
}
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
function Invoke-FrontendStep {
param([string]$Name, [string]$Directory, [string]$Task)
$display = "pnpm --dir `"$Directory`" $Task"
if ($Task -eq 'test' -and (Split-Path -Leaf $Directory) -eq 'client-web') {
$display = "pnpm --dir `"$Directory`" exec node --test --test-concurrency=2 src/**/*.test.mjs"
}
Write-Host " [$Name] $display"
if ($DryRun) { return }
& pnpm --dir $Directory $Task
if ($Task -eq 'test' -and (Split-Path -Leaf $Directory) -eq 'client-web') {
& pnpm --dir $Directory exec node --test --test-concurrency=2 'src/**/*.test.mjs'
} else {
& pnpm --dir $Directory $Task
}
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
@@ -23,7 +38,7 @@ function Invoke-PdaTests {
$pattern = Join-Path $Directory 'tests/*.test.mjs'
Write-Host " [test] node --test `"$pattern`""
if ($DryRun) { return }
& node --test $pattern
& node --test --test-concurrency=2 $pattern
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
@@ -22,7 +22,7 @@ test('frontend full regression includes domestic and overseas PDA node tests by
test('backend full regression uses the entire root reactor and preserves critical file/container coverage', () => {
const args = backendScript.match(/\$mavenArgs\s*=\s*@\(([^\r\n]+)\)/)?.[1]
assert.equal(args, '"-f", $pom, "test"')
assert.equal(args, '\'-T\', \'1\', \'-DforkCount=1\', \'-DreuseForks=true\', "-f", $pom, "test"')
assert.match(backendScript, /\$pom = Join-Path \$repoRoot "pom.xml"/)
assert.doesNotMatch(args, /-pl|-rf|-N|skipTests|maven.test.skip/)
assert.match(backendScript, /complete root POM reactor/)
+199
View File
@@ -0,0 +1,199 @@
[CmdletBinding()]
param(
[string[]]$Command,
[string]$WorkingDirectory = (Get-Location).Path,
[string]$Label = 'local check',
[ValidateRange(0, 86400)][int]$WaitSeconds = 900,
[ValidateRange(1, 86400)][int]$TimeoutSeconds = 1200,
[switch]$Status
)
$ErrorActionPreference = 'Stop'
if (-not $IsWindows -or $PSVersionTable.PSVersion.Major -lt 7) {
throw 'local-check requires Windows and PowerShell 7 (pwsh).'
}
# 所有工作目录使用同一用户级锁;句柄随进程退出关闭,不依赖删除过期锁文件。
$stateDirectory = Join-Path $env:LOCALAPPDATA 'Chongchong/LocalChecks'
[IO.Directory]::CreateDirectory($stateDirectory) | Out-Null
$lockPath = Join-Path $stateDirectory 'heavy.lock'
$ownerPath = Join-Path $stateDirectory 'owner.json'
$resultPath = Join-Path $stateDirectory 'last-result.json'
function Read-Owner {
try { return [IO.File]::ReadAllText($ownerPath) } catch { return 'Owner information is being updated.' }
}
function Open-Slot {
try { return [IO.File]::Open($lockPath, 'OpenOrCreate', 'ReadWrite', 'None') }
catch [IO.IOException] {
if (($_.Exception.HResult -band 0xffff) -notin @(32, 33)) { throw }
return $null
}
}
if ($Status) {
$probe = Open-Slot
if ($probe) { $probe.Dispose(); Write-Host 'IDLE: heavy check slot is free.' }
else { Write-Host 'BUSY:'; Write-Host (Read-Owner) }
if (Test-Path -LiteralPath $resultPath) { Write-Host 'Last result:'; Write-Host ([IO.File]::ReadAllText($resultPath)) }
exit 0
}
if (-not $Command -or -not $Command[0]) { throw 'Pass -Command as an array, for example @("pnpm", "typecheck").' }
$directory = (Resolve-Path -LiteralPath $WorkingDirectory).Path
if (-not (Test-Path -LiteralPath $directory -PathType Container)) { throw 'WorkingDirectory must be a directory.' }
if ($env:CHONGCHONG_LOCAL_CHECK_OWNER) {
# 拒绝嵌套申请,避免外层等待内层、内层等待同一锁。
throw 'Already inside local-check. Run the original command directly inside this checked batch.'
}
$timer = [Diagnostics.Stopwatch]::StartNew()
$slot = $null
$job = $null
$child = $null
$gatePath = Join-Path $stateDirectory ("gate-{0}.tmp" -f [guid]::NewGuid())
$owner = $null
$exitCode = 1
try {
$nextNotice = 0
while (-not ($slot = Open-Slot)) {
if ($timer.Elapsed.TotalSeconds -ge $WaitSeconds) {
Write-Host "QUEUE TIMEOUT after $WaitSeconds seconds. No check was started."
exit 75
}
if ($timer.Elapsed.TotalSeconds -ge $nextNotice) {
Write-Host "WAITING for the shared heavy check slot: $(Read-Owner)"
$nextNotice = $timer.Elapsed.TotalSeconds + 15
}
Start-Sleep -Milliseconds 250
}
$owner = [ordered]@{ label = $Label; pid = $PID; directory = $directory; tool = $Command[0]; startedAt = [DateTimeOffset]::Now.ToString('o') }
[IO.File]::WriteAllText($ownerPath, ($owner | ConvertTo-Json -Compress))
Write-Host "RUNNING [$Label] in $directory (one shared slot; timeout ${TimeoutSeconds}s)"
# Job Object 在包装器被强制结束时也回收本次子进程树,不影响其他 Node/Java。
if (-not ('LocalCheckJob' -as [type])) {
Add-Type -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
public static class LocalCheckJob {
[StructLayout(LayoutKind.Sequential)] struct BasicLimits {
public long PerProcessUserTimeLimit, PerJobUserTimeLimit;
public uint LimitFlags;
public UIntPtr MinimumWorkingSetSize, MaximumWorkingSetSize;
public uint ActiveProcessLimit;
public UIntPtr Affinity;
public uint PriorityClass, SchedulingClass;
}
[StructLayout(LayoutKind.Sequential)] struct IoCounters {
public ulong ReadOperationCount, WriteOperationCount, OtherOperationCount;
public ulong ReadTransferCount, WriteTransferCount, OtherTransferCount;
}
[StructLayout(LayoutKind.Sequential)] struct ExtendedLimits {
public BasicLimits BasicLimitInformation;
public IoCounters IoInfo;
public UIntPtr ProcessMemoryLimit, JobMemoryLimit, PeakProcessMemoryUsed, PeakJobMemoryUsed;
}
[StructLayout(LayoutKind.Sequential)] struct Accounting {
public long UserTime, KernelTime, PeriodUserTime, PeriodKernelTime;
public uint PageFaults, TotalProcesses, ActiveProcesses, TerminatedProcesses;
}
[DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern IntPtr CreateJobObject(IntPtr attributes, string name);
[DllImport("kernel32.dll", SetLastError=true)] static extern bool SetInformationJobObject(IntPtr job, int infoClass, ref ExtendedLimits info, uint length);
[DllImport("kernel32.dll", SetLastError=true)] static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
[DllImport("kernel32.dll", SetLastError=true)] static extern bool TerminateJobObject(IntPtr job, uint code);
[DllImport("kernel32.dll", SetLastError=true)] static extern bool QueryInformationJobObject(IntPtr job, int infoClass, out Accounting info, uint length, IntPtr returnedLength);
[DllImport("kernel32.dll")] public static extern bool CloseHandle(IntPtr handle);
public static IntPtr Create() {
var job = CreateJobObject(IntPtr.Zero, null);
if (job == IntPtr.Zero) throw new Win32Exception();
var limits = new ExtendedLimits();
limits.BasicLimitInformation.LimitFlags = 0x2000; // KILL_ON_JOB_CLOSE
if (!SetInformationJobObject(job, 9, ref limits, (uint)Marshal.SizeOf<ExtendedLimits>())) {
int error = Marshal.GetLastWin32Error(); CloseHandle(job); throw new Win32Exception(error);
}
return job;
}
public static void Attach(IntPtr job, IntPtr process) {
if (!AssignProcessToJobObject(job, process)) throw new Win32Exception();
}
public static void Stop(IntPtr job) {
if (!TerminateJobObject(job, 124)) throw new Win32Exception();
// 后代全部退出再归还执行名额,避免退出瞬间下一任务与遗留 worker 重叠。
Accounting info;
do {
if (!QueryInformationJobObject(job, 1, out info, (uint)Marshal.SizeOf<Accounting>(), IntPtr.Zero)) throw new Win32Exception();
if (info.ActiveProcesses != 0) System.Threading.Thread.Sleep(50);
} while (info.ActiveProcesses != 0);
}
}
'@
}
$job = [LocalCheckJob]::Create()
$payload = @{ command = $Command; gate = $gatePath } | ConvertTo-Json -Compress
$payload64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($payload))
$bootstrap = @'
$ErrorActionPreference = 'Stop'
$payload = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($env:CHONGCHONG_LOCAL_CHECK_PAYLOAD)) | ConvertFrom-Json
while (-not (Test-Path -LiteralPath $payload.gate)) { Start-Sleep -Milliseconds 50 }
$env:CHONGCHONG_LOCAL_CHECK_PAYLOAD = $null
$commandArgs = @($payload.command | Select-Object -Skip 1)
$executable = [string]$payload.command[0]
if ([IO.Path]::GetFileNameWithoutExtension($executable) -in @('mvn', 'mvnw')) {
if (@($commandArgs | Where-Object { $_ -match '^-T|^--threads|^-D(forkCount|reuseForks|parallel)=' }).Count) {
throw 'Do not override Maven concurrency in local-check; -T 1 and forkCount=1 are supplied automatically.'
}
$commandArgs = @('-T', '1', '-DforkCount=1', '-DreuseForks=true') + $commandArgs
}
if ([IO.Path]::GetFileNameWithoutExtension($executable) -eq 'node' -and $commandArgs -contains '--test') {
if (@($commandArgs | Where-Object { $_ -match '^--test-concurrency' }).Count) { throw 'local-check supplies --test-concurrency=2.' }
$commandArgs = @('--test-concurrency=2') + $commandArgs
}
$global:LASTEXITCODE = 0
& $executable @commandArgs
if (-not $?) { if ($LASTEXITCODE) { exit $LASTEXITCODE }; exit 1 }
exit $LASTEXITCODE
'@
$startInfo = [Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = (Get-Process -Id $PID).Path
foreach ($arg in @('-NoLogo', '-NoProfile', '-NonInteractive', '-OutputFormat', 'Text', '-EncodedCommand', [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($bootstrap)))) { $startInfo.ArgumentList.Add($arg) }
$startInfo.WorkingDirectory = $directory
$startInfo.UseShellExecute = $false
$startInfo.Environment['CHONGCHONG_LOCAL_CHECK_OWNER'] = [string]$PID
$startInfo.Environment['CHONGCHONG_LOCAL_CHECK_PAYLOAD'] = $payload64
$startInfo.Environment['VITEST_MAX_WORKERS'] = '2'
$startInfo.Environment['NODE_OPTIONS'] = (($env:NODE_OPTIONS -replace '--max-old-space-size(?:=|\s+)\S+', '') + ' --max-old-space-size=2048').Trim()
$startInfo.Environment['JAVA_TOOL_OPTIONS'] = (($env:JAVA_TOOL_OPTIONS -replace '-XX:ActiveProcessorCount=\S+|-Xmx\S+', '') + ' -XX:ActiveProcessorCount=2 -Xmx1536m').Trim()
# Maven 启动器可能在 JAVA_TOOL_OPTIONS 之后应用 MAVEN_OPTS,末尾再限定一次。
$startInfo.Environment['MAVEN_OPTS'] = (($env:MAVEN_OPTS -replace '-XX:ActiveProcessorCount=\S+|-Xmx\S+', '') + ' -XX:ActiveProcessorCount=2 -Xmx1536m').Trim()
$child = [Diagnostics.Process]::Start($startInfo)
[LocalCheckJob]::Attach($job, $child.Handle)
# 先纳入 Job 再放行,防止短命包装器提前产生未受管理的后代。
[IO.File]::WriteAllText($gatePath, '')
$running = [Diagnostics.Stopwatch]::StartNew()
while (-not $child.WaitForExit(250)) {
if ($running.Elapsed.TotalSeconds -ge $TimeoutSeconds) {
Write-Host "EXECUTION TIMEOUT [$Label]; stopping only this check's process tree."
$exitCode = 124
break
}
}
if ($exitCode -ne 124) { $exitCode = $child.ExitCode }
} finally {
if ($job) {
try { [LocalCheckJob]::Stop($job) }
finally { [LocalCheckJob]::CloseHandle($job) | Out-Null }
}
if ($child) {
if (-not $child.HasExited) { $child.Kill($true); $child.WaitForExit() }
$child.Dispose()
}
if (Test-Path -LiteralPath $gatePath) { Remove-Item -LiteralPath $gatePath -Force }
if ($slot) {
try {
if ($owner) {
$owner['finishedAt'] = [DateTimeOffset]::Now.ToString('o')
$owner['exitCode'] = $exitCode
[IO.File]::WriteAllText($resultPath, ($owner | ConvertTo-Json -Compress))
}
} finally { $slot.Dispose() }
}
}
Write-Host "FINISHED [$Label] exit=$exitCode"
exit $exitCode
+112
View File
@@ -0,0 +1,112 @@
import assert from 'node:assert/strict'
import { spawn } from 'node:child_process'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const launcher = fileURLToPath(new URL('./local-check.ps1', import.meta.url))
const quote = value => `'${String(value).replaceAll("'", "''")}'`
const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
function run(state, args, { cwd = os.tmpdir(), timeout = 15, wait = 15 } = {}) {
const script = `& ${quote(launcher)} -WorkingDirectory ${quote(cwd)} -TimeoutSeconds ${timeout} -WaitSeconds ${wait} -Command @(${args.map(quote).join(',')}); exit $LASTEXITCODE`
const env = { ...process.env, LOCALAPPDATA: state }
delete env.CHONGCHONG_LOCAL_CHECK_OWNER
const child = spawn('pwsh', ['-NoProfile', '-NonInteractive', '-OutputFormat', 'Text', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { env })
let output = ''
child.stdout.on('data', chunk => { output += chunk })
child.stderr.on('data', chunk => { output += chunk })
const done = new Promise((resolve, reject) => {
child.on('error', reject)
child.on('close', code => resolve({ code, output }))
})
return { child, done, output: () => output }
}
async function until(check, message) {
const deadline = Date.now() + 12000
while (Date.now() < deadline) {
if (await check()) return
await delay(100)
}
assert.fail(message)
}
test('local-check serializes different directories, handles failures and recovers process trees', { skip: process.platform !== 'win32', timeout: 90000 }, async t => {
const state = await mkdtemp(path.join(os.tmpdir(), 'local-check-test-'))
const processes = []
const start = (...args) => { const result = run(state, ...args); processes.push(result); return result }
try {
await t.test('literal arguments, environment caps and exit codes are preserved', async () => {
const result = await start([process.execPath, '-e', 'console.log(JSON.stringify([process.argv[1],process.env.VITEST_MAX_WORKERS,process.env.NODE_OPTIONS,process.env.JAVA_TOOL_OPTIONS]));process.exit(7)', 'space quote \' $() ` ; 中文']).done
assert.equal(result.code, 7, result.output)
assert.ok(result.output.includes(JSON.stringify('space quote \' $() ` ; 中文')))
assert.match(result.output, /"2","--max-old-space-size=2048"/)
assert.match(result.output, /ActiveProcessorCount=2 -Xmx1536m/)
})
await t.test('a running check excludes another directory and queue timeout starts nothing', async () => {
const first = start([process.execPath, '-e', 'console.log("HOLDER_READY");setTimeout(()=>{},4000)'])
await until(() => first.output().includes('HOLDER_READY'), first.output())
const timedOut = await start([process.execPath, '-e', 'console.log("SHOULD_NOT_RUN")'], { wait: 0, cwd: path.dirname(launcher) }).done
assert.equal(timedOut.code, 75, timedOut.output)
assert.doesNotMatch(timedOut.output, /SHOULD_NOT_RUN/)
const second = start([process.execPath, '-e', 'console.log("SECOND_READY")'], { cwd: path.dirname(launcher) })
await until(() => second.output().includes('WAITING'), second.output())
assert.doesNotMatch(second.output(), /SECOND_READY/)
assert.equal((await first.done).code, 0)
const result = await second.done
assert.equal(result.code, 0, result.output)
assert.match(result.output, /SECOND_READY/)
})
await t.test('execution timeout kills descendants and frees the slot', async () => {
const pidFile = path.join(state, 'descendant.pid')
const code = `const {spawn}=require('child_process');const fs=require('fs');const c=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'});fs.writeFileSync(${JSON.stringify(pidFile)},String(c.pid));setInterval(()=>{},1000)`
const result = await start([process.execPath, '-e', code], { timeout: 3 }).done
assert.equal(result.code, 124, result.output)
const descendant = Number(await readFile(pidFile, 'utf8'))
await until(() => { try { process.kill(descendant, 0); return false } catch { return true } }, 'descendant survived timeout')
assert.equal((await start([process.execPath, '-e', 'process.exit(0)']).done).code, 0)
})
await t.test('abrupt wrapper termination kills its descendants and releases the file lock', async () => {
const pidFile = path.join(state, 'abrupt.pid')
const code = `require('fs').writeFileSync(${JSON.stringify(pidFile)},String(process.pid));console.log('ABRUPT_READY');setInterval(()=>{},1000)`
const active = start([process.execPath, '-e', code])
await until(() => active.output().includes('ABRUPT_READY'), active.output())
const descendant = Number(await readFile(pidFile, 'utf8'))
active.child.kill()
await active.done
await until(() => { try { process.kill(descendant, 0); return false } catch { return true } }, 'descendant survived wrapper termination')
assert.equal((await start([process.execPath, '-e', 'process.exit(0)']).done).code, 0)
})
await t.test('missing commands fail and do not leave the slot occupied', async () => {
const result = await start(['chongchong-missing-command-for-test']).done
assert.notEqual(result.code, 0, result.output)
assert.equal((await start([process.execPath, '-e', 'process.exit(0)']).done).code, 0)
})
await t.test('Maven arguments are limited without launching Maven', async () => {
const fakeMaven = path.join(state, 'mvn.ps1')
await writeFile(fakeMaven, 'ConvertTo-Json -InputObject @($args) -Compress\n')
const result = await start([fakeMaven, '-pl', 'example', 'test']).done
assert.equal(result.code, 0, result.output)
assert.match(result.output, /\["-T","1","-DforkCount=1","-DreuseForks=true","-pl","example","test"\]/)
const override = await start([fakeMaven, '-T4', 'test']).done
assert.notEqual(override.code, 0, override.output)
assert.match(override.output, /Do not override Maven concurrency/)
})
await t.test('direct Node test concurrency is supplied and nested locks are rejected', async () => {
const nodeTest = path.join(state, 'probe.test.mjs')
await writeFile(nodeTest, `import assert from 'node:assert/strict'; assert.ok(process.execArgv.includes('--test-concurrency=2'));\n`)
const result = await start([process.execPath, '--test', nodeTest]).done
assert.equal(result.code, 0, result.output)
const nested = await start(['pwsh', '-NoProfile', '-File', launcher, '-Command', 'node']).done
assert.notEqual(nested.code, 0, nested.output)
assert.match(nested.output, /Already inside local-check/)
})
} finally {
for (const item of processes) if (item.child.exitCode === null) item.child.kill()
await Promise.all(processes.map(item => item.done))
await rm(state, { recursive: true, force: true })
}
})