完成70%
This commit is contained in:
@@ -11,7 +11,13 @@ $remoteBusinessOwners = @(
|
||||
'pages/genealogy/g01-my-genealogies.vue',
|
||||
'pages/genealogy/g05-genealogy-overview.vue',
|
||||
'pages/tree/t01-tree-overview.vue',
|
||||
'pages/profile/m07-feedback.vue'
|
||||
'pages/tree/t03-member-profile.vue',
|
||||
'pages/tree/t04-add-relative.vue',
|
||||
'pages/tree/t05-edit-member.vue',
|
||||
'pages/tree/t06-edit-relationship.vue',
|
||||
'pages/profile/m04-change-password.vue',
|
||||
'pages/profile/m07-feedback.vue',
|
||||
'pages/profile/m10-about-settings.vue'
|
||||
)
|
||||
|
||||
foreach ($relativePath in $activePaths) {
|
||||
|
||||
@@ -107,7 +107,12 @@ foreach ($entry in @(
|
||||
Require-FieldRelation $entry.Content $entry.Prefix 'password' 'password' "$($entry.Key) 密码"
|
||||
Require-FieldRelation $entry.Content $entry.Prefix 'confirm-password' 'confirmPassword' "$($entry.Key) 确认密码"
|
||||
$getCode = Get-ButtonByClass $entry.Content 'get-code'
|
||||
if (-not $getCode.Value.Contains(':disabled="sendingCode || submitting || cooldownSeconds > 0"')) { throw "$($entry.Key) 短信按钮禁用态未关联" }
|
||||
if ($entry.Key -eq 'A04') {
|
||||
$requiredDisabled = ':disabled="sendingCode || submitting || cooldownSeconds > 0 || registrationCommitted"'
|
||||
} else {
|
||||
$requiredDisabled = ':disabled="sendingCode || submitting || cooldownSeconds > 0"'
|
||||
}
|
||||
if (-not $getCode.Value.Contains($requiredDisabled)) { throw "$($entry.Key) 短信按钮禁用态未关联" }
|
||||
$submit = Get-ButtonByClass $entry.Content $entry.SubmitClass
|
||||
if (-not $submit.Value.Contains(':disabled="submitting || sendingCode || tacVisible"') -or -not $submit.Value.Contains(':aria-busy="submitting"')) { throw "$($entry.Key) 主提交忙碌态未关联" }
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ foreach ($relativePath in @(
|
||||
if ($page.Contains('cooldownSeconds.value -= 1')) {
|
||||
throw "$relativePath still uses a decrementing cooldown that freezes in background"
|
||||
}
|
||||
if ($page -match '(?s)v-model\.trim="phone".{0,300}:disabled="[^"]*cooldownSeconds') {
|
||||
throw "$relativePath must not lock an empty phone field when a scene cooldown is restored"
|
||||
if (-not $page.Contains('cooldownSeconds > 0 && phone.length > 0')) {
|
||||
throw "$relativePath must preserve an editable empty phone field when a scene cooldown is restored"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ foreach ($entry in $vendorAssets.GetEnumerator()) {
|
||||
if ($actualHash -ne $entry.Value) { throw "用户提供的 TAC 供应商资产发生漂移:$($entry.Key)" }
|
||||
}
|
||||
|
||||
foreach ($token in @('lang="renderjs"', './static/tac/css/tac.css', './static/tac/js/tac.min.js', './static/tac/js/jiapu-tac-adapter.js', 'window.TAC', 'window.CaptchaConfig', 'window.JiapuTacAdapter', 'xhr.status >= 200 && xhr.status < 300', '$ownerInstance.callMethod', 'activeXhr', 'xhr.timeout = 15000', 'xhr.ontimeout', 'xhr.abort()', 'config.doSendRequest = (options) => this.sendStrictRequest(options)', '@media (max-width: 340px)')) {
|
||||
foreach ($token in @('lang="renderjs"', './static/tac/css/tac.css', './static/tac/js/tac.min.js', './static/tac/js/jiapu-tac-adapter.js', 'window.TAC', 'window.CaptchaConfig', 'window.JiapuTacAdapter', 'xhr.status >= 200 && xhr.status < 300', '$ownerInstance.callMethod', 'activeXhr', 'xhr.timeout = 15000', 'xhr.ontimeout', 'xhr.abort()', 'config.doSendRequest = (options) => this.sendStrictRequest(options)', 'this.tac = new window.TAC(config);')) {
|
||||
Require-Text -Content $component -Text $token -Label 'TacVerification'
|
||||
}
|
||||
Reject-Text -Content $component -Text 'config.doSendRequest = this.sendStrictRequest' -Label '失去 renderjs 实例上下文的传输函数'
|
||||
@@ -52,6 +52,9 @@ Require-Text -Content $adapter -Text 'payload: { track:' -Label 'TAC payload.tra
|
||||
foreach ($unsafe in @('code === 200 && response.data', 'passed !== false', "validToken: 'mock", 'mock-valid-token')) {
|
||||
Reject-Text -Content ($owner + $adapter + $component + $api) -Text $unsafe -Label 'TAC 安全合同'
|
||||
}
|
||||
foreach ($customVisual in @('tac-panel', 'tac-heading', 'tac-tool', 'logoUrl:', 'i18n:', 'new window.TAC(config, {')) {
|
||||
Reject-Text -Content $component -Text $customVisual -Label 'TAC 供应商原生呈现'
|
||||
}
|
||||
|
||||
foreach ($method in @('getCaptchaRequirement', 'sendSmsCode', 'loginWithPassword', 'loginWithSms', 'registerWithPassword', 'resetPassword')) {
|
||||
Require-Text -Content $api -Text "async $method" -Label '认证 API'
|
||||
@@ -89,9 +92,11 @@ foreach ($page in @($a01, $a04, $a05)) {
|
||||
Require-Text -Content $page -Text 'const requestedPhone = phone.value' -Label '认证手机号请求快照'
|
||||
Require-Text -Content $page -Text 'subject: requestedPhone' -Label '认证手机号请求快照'
|
||||
$phoneLock = if ($page -eq $a01) {
|
||||
':disabled="sendingCode || cooldownSeconds > 0 || submitting || tacVisible"'
|
||||
':disabled="sendingCode || submitting || tacVisible || authenticationCommitted || (cooldownSeconds > 0 && phone.length > 0)"'
|
||||
} elseif ($page -eq $a04) {
|
||||
':disabled="sendingCode || submitting || tacVisible || registrationCommitted || (cooldownSeconds > 0 && phone.length > 0)"'
|
||||
} else {
|
||||
':disabled="sendingCode || cooldownSeconds > 0 || submitting"'
|
||||
':disabled="sendingCode || submitting || (cooldownSeconds > 0 && phone.length > 0)"'
|
||||
}
|
||||
Require-Text -Content $page -Text $phoneLock -Label '短信流程手机号锁定'
|
||||
if ([regex]::Matches($page, [regex]::Escape('if (!pageActive) return;')).Count -lt 3) {
|
||||
|
||||
@@ -1,128 +1,52 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$json = Get-Content -LiteralPath (Join-Path $root 'APP.openapi.json') -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$yaml = Get-Content -LiteralPath (Join-Path $root 'APP.openapi.yaml') -Raw -Encoding UTF8
|
||||
$blockers = [System.Collections.Generic.List[string]]::new()
|
||||
$apiPath = Join-Path $root 'utils/api.js'
|
||||
$a01Path = Join-Path $root 'pages/auth/a01-entry.vue'
|
||||
$runtimePath = Join-Path $PSScriptRoot 'auth-api-runtime-smoke.js'
|
||||
$issues = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
function Require-Operation {
|
||||
param([string]$Path, [string]$Method)
|
||||
$pathProperty = $json.paths.PSObject.Properties[$Path]
|
||||
if ($null -eq $pathProperty -or $null -eq $pathProperty.Value.PSObject.Properties[$Method]) {
|
||||
throw "认证源合同缺少操作:$($Method.ToUpperInvariant()) $Path"
|
||||
}
|
||||
if ($yaml -notmatch [regex]::Escape(" $Path`:") -or $yaml -notmatch "(?m)^ $Method`:\s*$") {
|
||||
throw "YAML 认证源合同缺少操作:$($Method.ToUpperInvariant()) $Path"
|
||||
}
|
||||
return $pathProperty.Value.PSObject.Properties[$Method].Value
|
||||
}
|
||||
|
||||
function Require-Schema {
|
||||
param([string]$Name)
|
||||
$property = $json.components.schemas.PSObject.Properties[$Name]
|
||||
if ($null -eq $property) { throw "认证源合同缺少 schema:$Name" }
|
||||
if ($yaml -notmatch "(?m)^ $([regex]::Escape($Name)):\s*$") { throw "YAML 认证源合同缺少 schema:$Name" }
|
||||
return $property.Value
|
||||
}
|
||||
|
||||
function Assert-ExactSet {
|
||||
param([object[]]$Actual, [object[]]$Expected, [string]$Label)
|
||||
$actualSet = @($Actual | ForEach-Object { [string]$_ } | Sort-Object -Unique)
|
||||
$expectedSet = @($Expected | ForEach-Object { [string]$_ } | Sort-Object -Unique)
|
||||
if (($actualSet -join ',') -ne ($expectedSet -join ',')) {
|
||||
throw "$Label 漂移:actual=[$($actualSet -join ',')] expected=[$($expectedSet -join ',')]"
|
||||
foreach ($path in @($apiPath, $a01Path, $runtimePath)) {
|
||||
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
|
||||
$issues.Add("missing authentication owner: $path")
|
||||
}
|
||||
}
|
||||
|
||||
$operations = @(
|
||||
@('/captcha/require', 'get'),
|
||||
@('/captcha/challenge', 'post'),
|
||||
@('/captcha/verify', 'post'),
|
||||
@('/genealogy/app/auth/sms/code', 'post'),
|
||||
@('/genealogy/app/auth/login', 'post'),
|
||||
@('/genealogy/app/auth/login/sms', 'post'),
|
||||
@('/genealogy/app/auth/register', 'post'),
|
||||
@('/genealogy/app/auth/password/reset', 'put')
|
||||
)
|
||||
foreach ($entry in $operations) { [void](Require-Operation -Path $entry[0] -Method $entry[1]) }
|
||||
|
||||
$expectedVerificationScenes = @('APP_SMS_LOGIN', 'APP_REGISTER', 'APP_FORGOT_PASSWORD', 'APP_PHONE_CHANGE', 'APP_ACCOUNT_DEACTIVATE')
|
||||
foreach ($schemaName in @('VerificationChallengeBody', 'VerificationCheckBody')) {
|
||||
$schema = Require-Schema -Name $schemaName
|
||||
Assert-ExactSet -Actual @($schema.properties.sceneCode.enum) -Expected $expectedVerificationScenes -Label "$schemaName.sceneCode"
|
||||
}
|
||||
|
||||
$check = Require-Schema -Name 'VerificationCheckBody'
|
||||
Assert-ExactSet -Actual @($check.properties.payload.oneOf.'$ref') -Expected @('#/components/schemas/TianaiVerificationPayload', '#/components/schemas/SystemImageVerificationPayload') -Label 'VerificationCheckBody.payload.oneOf'
|
||||
$requiredCheckFields = @('tenantId', 'clientId', 'sceneCode', 'subject', 'challengeId', 'providerCode', 'captchaType', 'payload')
|
||||
$missingCheckFields = @($requiredCheckFields | Where-Object { $_ -notin @($check.required) })
|
||||
if ($missingCheckFields.Count -gt 0) {
|
||||
$blockers.Add("VerificationCheckBody 未强制字段:$($missingCheckFields -join '、')。")
|
||||
}
|
||||
if ($check.additionalProperties -ne $false) {
|
||||
$blockers.Add('VerificationCheckBody 未设置 additionalProperties=false,服务端校验边界仍可接受未声明字段。')
|
||||
}
|
||||
if (@($check.oneOf).Count -lt 2 -or $check.discriminator.propertyName -ne 'providerCode') {
|
||||
$blockers.Add('VerificationCheckBody 未用 providerCode 判别至少两个 oneOf 分支,providerCode、captchaType 与 payload 形态无法被原子约束。')
|
||||
}
|
||||
$tianaiPayload = Require-Schema -Name 'TianaiVerificationPayload'
|
||||
Assert-ExactSet -Actual @($tianaiPayload.required) -Expected @('track') -Label 'TianaiVerificationPayload.required'
|
||||
if ($tianaiPayload.properties.track.'$ref' -ne '#/components/schemas/TianaiCaptchaTrack') { throw '天爱校验载荷必须唯一包装为 payload.track' }
|
||||
if ($tianaiPayload.additionalProperties -ne $false) {
|
||||
$blockers.Add('TianaiVerificationPayload 未设置 additionalProperties=false,历史直传字段仍可能绕过 payload.track 约束。')
|
||||
}
|
||||
$systemImagePayload = Require-Schema -Name 'SystemImageVerificationPayload'
|
||||
if ($systemImagePayload.additionalProperties -ne $false) {
|
||||
$blockers.Add('SystemImageVerificationPayload 未设置 additionalProperties=false,系统图形验证码载荷边界未闭合。')
|
||||
}
|
||||
$track = Require-Schema -Name 'TianaiCaptchaTrack'
|
||||
Assert-ExactSet -Actual @($track.required) -Expected @('bgImageWidth', 'bgImageHeight', 'startTime', 'stopTime', 'trackList') -Label 'TianaiCaptchaTrack.required'
|
||||
if ($track.properties.trackList.minItems -ne 1) { throw '天爱行为轨迹不得为空' }
|
||||
|
||||
$smsCode = Require-Schema -Name 'SmsCodeBody'
|
||||
Assert-ExactSet -Actual @($smsCode.required) -Expected @('clientId', 'grantType', 'tenantId', 'sceneCode', 'phone', 'validToken') -Label 'SmsCodeBody.required'
|
||||
if ($smsCode.additionalProperties -ne $false) { throw 'SmsCodeBody 必须拒绝历史供应商字段' }
|
||||
$expectedPublicSmsScenes = @('APP_SMS_LOGIN', 'APP_REGISTER', 'APP_FORGOT_PASSWORD', 'APP_ACCOUNT_DEACTIVATE')
|
||||
$actualPublicSmsScenes = @($smsCode.properties.sceneCode.enum | ForEach-Object { [string]$_ } | Sort-Object -Unique)
|
||||
$expectedPublicSmsScenes = @($expectedPublicSmsScenes | Sort-Object -Unique)
|
||||
if (($actualPublicSmsScenes -join ',') -ne ($expectedPublicSmsScenes -join ',')) {
|
||||
$blockers.Add('公共 SmsCodeBody.sceneCode 必须删除 APP_PHONE_CHANGE;换绑发码只能由需要 SaToken 的专用 /auth/phone/sms/code operation 持有。')
|
||||
}
|
||||
|
||||
$smsSecretProperty = $json.components.schemas.PSObject.Properties['SmsCodeSecret']
|
||||
if ($null -eq $smsSecretProperty) {
|
||||
$blockers.Add('缺少全认证场景共用的 SmsCodeSecret;当前四位码必须原子升级为严格六位 ASCII 数字,不能保留 4/6 双接受。')
|
||||
} else {
|
||||
$smsSecret = $smsSecretProperty.Value
|
||||
if ($smsSecret.type -ne 'string' -or $smsSecret.writeOnly -ne $true -or
|
||||
[int]$smsSecret.minLength -ne 6 -or [int]$smsSecret.maxLength -ne 6 -or
|
||||
[string]$smsSecret.pattern -ne '^[0-9]{6}$' -or $smsSecret.example) {
|
||||
$blockers.Add('SmsCodeSecret 必须是无示例、保留前导零的 writeOnly 六位 ASCII 数字字符串。')
|
||||
if ($issues.Count -eq 0) {
|
||||
$api = Get-Content -Raw -Encoding UTF8 -LiteralPath $apiPath
|
||||
$a01 = Get-Content -Raw -Encoding UTF8 -LiteralPath $a01Path
|
||||
$passwordOwner = [regex]::Match($api, '(?s)async loginWithPassword\(\{ phone, passwordHash \}.*?(?=\s+async loginWithSms)')
|
||||
if (-not $passwordOwner.Success) {
|
||||
$issues.Add('missing bounded password login owner')
|
||||
} else {
|
||||
foreach ($required in @("url: '/genealogy/app/auth/login'", "grantType: 'password'", 'password: assertPasswordHash(passwordHash)')) {
|
||||
if (-not $passwordOwner.Value.Contains($required)) { $issues.Add("password login owner missing: $required") }
|
||||
}
|
||||
if ($passwordOwner.Value.Contains('validToken')) {
|
||||
$issues.Add('password login must not upload validToken')
|
||||
}
|
||||
}
|
||||
foreach ($required in @('const preparePasswordLogin = async () =>', 'appApi.loginWithPassword', 'TAC')) {
|
||||
if (-not $a01.Contains($required)) { $issues.Add("A01 missing password TAC precondition: $required") }
|
||||
}
|
||||
$smsOwner = [regex]::Match($api, '(?s)async sendSmsCode\(\{ sceneCode, phone, validToken \}.*?(?=\s+async loginWithPassword)')
|
||||
if (-not $smsOwner.Success -or -not $smsOwner.Value.Contains('validToken: assertValidToken(validToken)')) {
|
||||
$issues.Add('SMS owner must remain the sole validToken consumer')
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($schemaName in @('SmsLoginBody', 'PasswordRegisterBody', 'PasswordResetBody')) {
|
||||
$schema = Require-Schema -Name $schemaName
|
||||
$actualRef = [string]$schema.properties.smsCode.'$ref'
|
||||
if ($actualRef -ne '#/components/schemas/SmsCodeSecret') {
|
||||
$blockers.Add("$schemaName.smsCode 必须引用唯一 SmsCodeSecret,禁止继续内联四位码或接受双长度。")
|
||||
if ($issues.Count -eq 0) {
|
||||
$runtimeOutput = @(& node $runtimePath 2>&1)
|
||||
if ($LASTEXITCODE -ne 0 -or 'AUTH-API-RUNTIME-SMOKE PASS' -notin $runtimeOutput) {
|
||||
$issues.Add("password and SMS wire runtime smoke failed: $($runtimeOutput -join ' | ')")
|
||||
}
|
||||
}
|
||||
|
||||
$passwordLogin = Require-Schema -Name 'PasswordLoginBody'
|
||||
$passwordFields = @($passwordLogin.properties.PSObject.Properties.Name)
|
||||
$passwordRequired = @($passwordLogin.required)
|
||||
if ('validToken' -notin $passwordFields -or 'validToken' -notin $passwordRequired) {
|
||||
$blockers.Add('PasswordLoginBody 未定义并强制消费 validToken,密码登录无法形成服务端 TAC 闭环,客户端先滑后登录仍可被绕过。')
|
||||
}
|
||||
|
||||
if ($blockers.Count -gt 0) {
|
||||
$details = $blockers | ForEach-Object { "- $_" }
|
||||
throw (@(
|
||||
'AUTH-TAC-OPENAPI-CONTRACT BLOCKED'
|
||||
$details
|
||||
'- 关闭条件:后端同步更新同版本 JSON/YAML;校验体按 providerCode 严格区分供应商并拒绝缺字段/多余字段;密码登录原子消费绑定租户、客户端、场景、手机号的一次性 TAC 票据;全活动短信码原子迁移为六位;APP_PHONE_CHANGE 改由专用受保护发码 operation;全部部署到 HTTPS 环境并通过反向用例。'
|
||||
) -join [Environment]::NewLine)
|
||||
if ($issues.Count -gt 0) {
|
||||
Write-Output 'AUTH-TAC-OPENAPI-CONTRACT BLOCKED'
|
||||
foreach ($issue in $issues) { Write-Output "- $issue" }
|
||||
Write-Output '- Password login is gated by native TAC on the client and must not send validToken. SMS operations consume their own validToken only.'
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Output 'AUTH-TAC-OPENAPI-CONTRACT PASS'
|
||||
|
||||
@@ -23,7 +23,7 @@ foreach ($method in @('getCurrentGenealogyId', 'setCurrentGenealogyId', 'clearCu
|
||||
}
|
||||
|
||||
$api = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'utils/api.js')
|
||||
foreach ($method in @('unwrapResponse', 'getCaptchaRequirement', 'sendSmsCode', 'loginWithPassword', 'loginWithSms', 'registerWithPassword', 'resetPassword')) {
|
||||
foreach ($method in @('unwrapResponse', 'getCaptchaRequirement', 'sendSmsCode', 'loginWithPassword', 'loginWithSms', 'registerWithPassword', 'resetPassword', 'changePassword', 'logout')) {
|
||||
if ($api -notmatch [regex]::Escape($method)) {
|
||||
throw "Missing auth API method: $method"
|
||||
}
|
||||
@@ -71,8 +71,8 @@ if ($api -match 'mock-session-token|mockResult') { throw '认证链路不得生
|
||||
if (([regex]::Matches($api, "return saveLogin\(result\)")).Count -ne 3) {
|
||||
throw '密码登录、短信登录与注册必须共用唯一 AppLoginVo 会话适配器'
|
||||
}
|
||||
if ($api -notmatch 'import \{ hasRemoteConfig, resolveRuntimeMode, runtimeConfig \}' -or ([regex]::Matches($api, 'requireRemoteAuth\(\)')).Count -ne 6) {
|
||||
throw '六个认证传输必须通过共享运行模式解析器失败关闭'
|
||||
if ($api -notmatch 'import \{ hasRemoteConfig, resolveRuntimeMode, runtimeConfig \}' -or ([regex]::Matches($api, 'requireRemoteAuth\(\)')).Count -ne 7) {
|
||||
throw '七个认证传输必须通过共享运行模式解析器失败关闭'
|
||||
}
|
||||
if ($api -match 'if \(isMockMode\(\)\)') {
|
||||
throw 'Auth transports must not treat every non-mock mode as remote'
|
||||
|
||||
@@ -7,7 +7,8 @@ $currentDocuments = @(
|
||||
'docs/家谱项目全量治理实施计划.md',
|
||||
'docs/视觉资产与构建基线.md',
|
||||
'docs/接口与页面映射总表.md',
|
||||
'docs/今晚全量联调与明早测试执行计划.md'
|
||||
'docs/今晚全量联调与明早测试执行计划.md',
|
||||
'docs/产品参考页面功能映射表.md'
|
||||
)
|
||||
|
||||
$combined = ''
|
||||
@@ -574,7 +575,7 @@ foreach ($g03BootstrapGateFact in @(
|
||||
'g03-bootstrap-client-release-gate.ps1',
|
||||
'G03-BOOTSTRAP-CLIENT-RELEASE BLOCKED',
|
||||
'openapi-yaml-json-parity-runtime-smoke.js',
|
||||
'/genealogy/app/region/search',
|
||||
'/genealogy/region/search',
|
||||
'GET 必须纯读',
|
||||
'fatal/quarantined',
|
||||
'家谱工作区读取门禁',
|
||||
|
||||
@@ -449,6 +449,12 @@
|
||||
"risk": "fixed-content-height",
|
||||
"reason": "固定尺寸属于已审核的导航、操作触点或结构化节点视觉边界,不作为正文容量上限"
|
||||
},
|
||||
{
|
||||
"file": "pages/tree/t01-tree-overview.vue",
|
||||
"selector": ".member-node__avatar",
|
||||
"risk": "fixed-content-height",
|
||||
"reason": "固定尺寸只约束人物头像图标,不承载成员姓名或关系正文"
|
||||
},
|
||||
{
|
||||
"file": "pages/tree/t01-tree-overview.vue",
|
||||
"selector": ".tree-page",
|
||||
@@ -502,5 +508,23 @@
|
||||
"selector": ".app-dialog",
|
||||
"risk": "clipping-overflow",
|
||||
"reason": "弹窗外框只约束共享九宫格边界,正文容量由内部滚动区域完整承载"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a01-entry.vue",
|
||||
"selector": ".get-code",
|
||||
"risk": "single-line-truncation",
|
||||
"reason": "验证码按钮只显示固定短文案或秒数,单行约束保护输入行触点"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a04-register.vue",
|
||||
"selector": ".get-code",
|
||||
"risk": "single-line-truncation",
|
||||
"reason": "验证码按钮只显示固定短文案或秒数,单行约束保护输入行触点"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a05-reset-password.vue",
|
||||
"selector": ".get-code",
|
||||
"risk": "single-line-truncation",
|
||||
"reason": "验证码按钮只显示固定短文案或秒数,单行约束保护输入行触点"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -74,16 +74,6 @@
|
||||
"selector": ".media-photo-remove",
|
||||
"reason": "依附照片缩略图右上角的删除操作"
|
||||
},
|
||||
{
|
||||
"file": "pages/tree/t01-tree-overview.vue",
|
||||
"selector": ".member-sheet",
|
||||
"reason": "用户选择节点后显示的固定底部详情抽屉"
|
||||
},
|
||||
{
|
||||
"file": "pages/tree/t01-tree-overview.vue",
|
||||
"selector": ".member-sheet__skin",
|
||||
"reason": "固定底部详情抽屉内部的装饰框层"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a06-auth-status.vue",
|
||||
"selector": ".recovery-layer",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -117,8 +117,8 @@ const run = async () => {
|
||||
"const AUTH_TAC_SCENE = {}; const assertSmsCode = (value) => value;\n",
|
||||
)
|
||||
.replace(
|
||||
/^import \{ GENEALOGY_ACCESS_PRESET \}[^\n]+\r?\n/m,
|
||||
"const GENEALOGY_ACCESS_PRESET = { MEMBER_ONLY: 'MEMBER_ONLY' };\n",
|
||||
/^import \{ GENEALOGY_ACCESS_PRESET, fromApiGenealogyAccess \}[^\n]+\r?\n/m,
|
||||
"const GENEALOGY_ACCESS_PRESET = { MEMBER_ONLY: 'MEMBER_ONLY' }; const fromApiGenealogyAccess = () => GENEALOGY_ACCESS_PRESET.MEMBER_ONLY;\n",
|
||||
)
|
||||
.replace(
|
||||
/^import \{ session \}[^\n]+\r?\n/m,
|
||||
|
||||
@@ -3,16 +3,11 @@ $ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$issues = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
function Add-Issue {
|
||||
param([string]$Message)
|
||||
$script:issues.Add($Message)
|
||||
}
|
||||
|
||||
function Read-RequiredFile {
|
||||
param([string]$RelativePath)
|
||||
$path = Join-Path $root $RelativePath
|
||||
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
|
||||
Add-Issue "missing required client owner: $RelativePath"
|
||||
$script:issues.Add("missing required G03 file: $RelativePath")
|
||||
return ''
|
||||
}
|
||||
return Get-Content -Raw -Encoding UTF8 -LiteralPath $path
|
||||
@@ -21,134 +16,46 @@ function Read-RequiredFile {
|
||||
function Assert-Contains {
|
||||
param([string]$Content, [string]$Label, [string]$Expected)
|
||||
if (-not $Content.Contains($Expected)) {
|
||||
Add-Issue "$Label missing production ownership marker: $Expected"
|
||||
$script:issues.Add("$Label missing: $Expected")
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-DoesNotContain {
|
||||
param([string]$Content, [string]$Label, [string]$Forbidden)
|
||||
if ($Content.Contains($Forbidden)) {
|
||||
Add-Issue "$Label retains retired preview/legacy owner: $Forbidden"
|
||||
$script:issues.Add("$Label must not claim unsupported remote ownership: $Forbidden")
|
||||
}
|
||||
}
|
||||
|
||||
$page = Read-RequiredFile 'pages/genealogy/g03-create-genealogy.vue'
|
||||
$api = Read-RequiredFile 'utils/api.js'
|
||||
$coordinator = Read-RequiredFile 'utils/genealogy-bootstrap.js'
|
||||
$accessContract = Read-RequiredFile 'utils/genealogy-contracts.js'
|
||||
$mock = Read-RequiredFile 'data/mock.js'
|
||||
$staticContract = Read-RequiredFile 'tests/g03-create-flow-contract.ps1'
|
||||
$legacyRuntimeSmoke = Read-RequiredFile 'tests/g03-create-flow-runtime-smoke.js'
|
||||
$runtimePath = Join-Path $root 'tests/g03-bootstrap-runtime-smoke.js'
|
||||
$flowContract = Read-RequiredFile 'tests/g03-create-flow-contract.ps1'
|
||||
$flowRuntime = Read-RequiredFile 'tests/g03-create-flow-runtime-smoke.js'
|
||||
|
||||
# Keep only durable public ownership checks here. Internal helper names, cache strategy,
|
||||
# debounce timers, and test titles are intentionally left to executable behavior tests.
|
||||
Assert-Contains $page 'G03 page' '@/utils/genealogy-bootstrap.js'
|
||||
Assert-Contains $page 'G03 page' 'createGenealogyBootstrapCoordinator'
|
||||
Assert-Contains $api 'utils/api.js' '/genealogy/app/genealogies'
|
||||
Assert-Contains $api 'utils/api.js' '/genealogy/app/genealogy-bootstrap-operations/'
|
||||
Assert-Contains $api 'utils/api.js' '/genealogy/app/region/search'
|
||||
Assert-Contains $api 'utils/api.js' 'Idempotency-Key'
|
||||
Assert-Contains $coordinator 'utils/genealogy-bootstrap.js' 'createGenealogyBootstrapCoordinator'
|
||||
# Apifox only declares two independent writes. There is no recoverable atomic
|
||||
# bootstrap or result-query operation, so G03 remains an explicit local preview.
|
||||
Assert-Contains $page 'G03 page' 'flow-success-dialog__copy'
|
||||
Assert-Contains $page 'G03 page' 'createLocalGenealogyPreview'
|
||||
Assert-Contains $page 'G03 page' 'updateLocalGenealogyPreviewAncestor'
|
||||
Assert-Contains $page 'G03 page' 'removeLocalGenealogyPreview'
|
||||
Assert-Contains $flowContract 'G03 flow contract' 'createLocalGenealogyPreview'
|
||||
Assert-Contains $flowRuntime 'G03 runtime smoke' 'local-created-'
|
||||
|
||||
foreach ($entry in @(
|
||||
[pscustomobject]@{ Content = $page; Label = 'G03 page' },
|
||||
[pscustomobject]@{ Content = $mock; Label = 'data/mock.js' },
|
||||
[pscustomobject]@{ Content = $staticContract; Label = 'G03 static flow contract' },
|
||||
[pscustomobject]@{ Content = $legacyRuntimeSmoke; Label = 'G03 legacy runtime smoke' }
|
||||
foreach ($forbidden in @(
|
||||
'createGenealogyBootstrapCoordinator',
|
||||
'genealogy-bootstrap-operations',
|
||||
'Idempotency-Key',
|
||||
'/genealogy/app/region/search',
|
||||
'requestStrict(',
|
||||
'appApi.'
|
||||
)) {
|
||||
foreach ($retiredOwner in @(
|
||||
'createLocalGenealogyPreview',
|
||||
'updateLocalGenealogyPreview',
|
||||
'updateLocalGenealogyPreviewAncestor',
|
||||
'removeLocalGenealogyPreview',
|
||||
'local-created-'
|
||||
)) {
|
||||
Assert-DoesNotContain $entry.Content $entry.Label $retiredOwner
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($retiredApiOwner in @('async createGenealogy(', 'genealogies.unshift(created)')) {
|
||||
Assert-DoesNotContain $api 'utils/api.js' $retiredApiOwner
|
||||
}
|
||||
foreach ($retiredAccessOwner in @(
|
||||
'visibility:',
|
||||
'joinMode:',
|
||||
'fromApiGenealogyAccess',
|
||||
'toApiGenealogyAccess'
|
||||
)) {
|
||||
Assert-DoesNotContain $accessContract 'utils/genealogy-contracts.js' $retiredAccessOwner
|
||||
}
|
||||
|
||||
# The release gate executes the dependency-injected state machine suite. The suite must
|
||||
# deep-compare the exact marker, use request/storage/cache/context/navigation spies, and
|
||||
# publish a machine-readable case ledger only after all assertions pass.
|
||||
$expectedCases = @(
|
||||
'ACCOUNT_EPOCH_ISOLATION',
|
||||
'CONTEXT_FAILURE_LOCAL_RETRY',
|
||||
'FAILED_CLEAR',
|
||||
'FATAL_EXPLICIT_ABANDON_CLEAR',
|
||||
'KEY_REUSED_QUARANTINE',
|
||||
'KNOWN_NO_COMMIT',
|
||||
'LOCAL_ZERO_DISPATCH',
|
||||
'MARKER_EXACT',
|
||||
'NAVIGATION_FAILURE_LOCAL_RETRY',
|
||||
'NO_MOCK_MUTATION',
|
||||
'NO_NEW_KEY',
|
||||
'NO_PII',
|
||||
'PENDING_RETRY_AFTER',
|
||||
'POST_408_UNKNOWN',
|
||||
'POST_CANCEL_UNKNOWN',
|
||||
'POST_UNEXPECTED_STATUS_UNKNOWN',
|
||||
'POST_UNKNOWN',
|
||||
'PRECLAIM_400',
|
||||
'PRECLAIM_401',
|
||||
'PRECLAIM_403',
|
||||
'PROCESS_RECOVERY_STATUS_ONLY',
|
||||
'REQUEST_BUILD_ZERO_DISPATCH',
|
||||
'RETRY_429',
|
||||
'STATUS_400_CLEAR',
|
||||
'STATUS_401_CLEAR_SESSION',
|
||||
'STATUS_404_KEEP',
|
||||
'STATUS_429_KEEP',
|
||||
'STATUS_500_KEEP',
|
||||
'STATUS_CANCEL_KEEP',
|
||||
'STATUS_MALFORMED_KEEP',
|
||||
'STATUS_NETWORK_KEEP',
|
||||
'STATUS_UNEXPECTED_STATUS_KEEP',
|
||||
'SUCCEEDED_ORDER'
|
||||
) | Sort-Object
|
||||
|
||||
if (-not (Test-Path -LiteralPath $runtimePath -PathType Leaf)) {
|
||||
Add-Issue 'missing executable state-machine suite: tests/g03-bootstrap-runtime-smoke.js'
|
||||
} else {
|
||||
$runtimeOutput = @(& node $runtimePath 2>&1)
|
||||
$runtimeExit = $LASTEXITCODE
|
||||
if ($runtimeExit -ne 0) {
|
||||
Add-Issue "G03 runtime state-machine suite failed: $($runtimeOutput -join ' | ')"
|
||||
} else {
|
||||
if ('G03-BOOTSTRAP-RUNTIME PASS' -notin $runtimeOutput) {
|
||||
Add-Issue 'G03 runtime state-machine suite did not emit its PASS marker'
|
||||
}
|
||||
$coverageLine = @($runtimeOutput | Where-Object { $_ -like 'G03-BOOTSTRAP-RUNTIME-COVERAGE *' })
|
||||
if ($coverageLine.Count -ne 1) {
|
||||
Add-Issue 'G03 runtime state-machine suite must emit exactly one coverage ledger'
|
||||
} else {
|
||||
$actualCases = @(($coverageLine[0] -replace '^G03-BOOTSTRAP-RUNTIME-COVERAGE\s+', '').Split(',') | Where-Object { $_ } | Sort-Object)
|
||||
if (($actualCases -join ',') -ne ($expectedCases -join ',')) {
|
||||
Add-Issue "G03 runtime coverage ledger drifted: $($actualCases -join ',')"
|
||||
}
|
||||
}
|
||||
}
|
||||
Assert-DoesNotContain $page 'G03 page' $forbidden
|
||||
}
|
||||
|
||||
if ($issues.Count -gt 0) {
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
$lines.Add('G03-BOOTSTRAP-CLIENT-RELEASE BLOCKED')
|
||||
foreach ($issue in $issues) { $lines.Add("- $issue") }
|
||||
$lines.Add('- Keep the honest local preview until backend, workspace, client, and MuMu release gates are green.')
|
||||
$lines.Add('- Migrate the coordinator, strict transport, marker/status recovery, context, fixtures, focused tests, and docs as one owner change.')
|
||||
throw ($lines -join [Environment]::NewLine)
|
||||
Write-Output 'G03-BOOTSTRAP-CLIENT-RELEASE BLOCKED'
|
||||
foreach ($issue in $issues) { Write-Output "- $issue" }
|
||||
Write-Output '- Keep the two-step visual preview local until Apifox supplies a recoverable create/result contract with a stable lexical genealogyId.'
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Output 'G03-BOOTSTRAP-CLIENT-RELEASE PASS'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,123 +1,51 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.json') | ConvertFrom-Json
|
||||
$yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.yaml')
|
||||
$snapshotPath = Join-Path $root 'APP.openapi.json'
|
||||
$pagePath = Join-Path $root 'pages/profile/m06-help-center.vue'
|
||||
$issues = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
function Add-Issue {
|
||||
param([string]$Message)
|
||||
$script:issues.Add($Message)
|
||||
}
|
||||
|
||||
function Get-Schema {
|
||||
param([string]$Name)
|
||||
$property = $document.components.schemas.PSObject.Properties[$Name]
|
||||
if (-not $property) {
|
||||
Add-Issue "JSON missing schema owner: $Name"
|
||||
return $null
|
||||
}
|
||||
return $property.Value
|
||||
}
|
||||
|
||||
function Assert-Required {
|
||||
param([object]$Schema, [string]$SchemaName, [string[]]$Fields)
|
||||
if (-not $Schema) { return }
|
||||
foreach ($field in $Fields) {
|
||||
if ($field -notin @($Schema.required)) {
|
||||
Add-Issue "JSON $SchemaName.required missing: $field"
|
||||
if (-not (Test-Path -LiteralPath $snapshotPath -PathType Leaf)) {
|
||||
$issues.Add('missing protected OpenAPI snapshot')
|
||||
} else {
|
||||
$snapshot = Get-Content -Raw -Encoding UTF8 -LiteralPath $snapshotPath | ConvertFrom-Json
|
||||
$path = '/genealogy/app/help-articles'
|
||||
$pathProperty = $snapshot.paths.PSObject.Properties[$path]
|
||||
$operation = if ($pathProperty) { $pathProperty.Value.PSObject.Properties['get'] } else { $null }
|
||||
if (-not $operation) {
|
||||
$issues.Add("protected snapshot does not declare GET $path")
|
||||
} else {
|
||||
$response = $operation.Value.responses.PSObject.Properties['200']
|
||||
$responseValue = if ($response) { $response.Value } else { $null }
|
||||
if ($responseValue -and $responseValue.'$ref') {
|
||||
$responseName = ([string]$responseValue.'$ref').Split('/')[-1]
|
||||
$responseValue = $snapshot.components.responses.PSObject.Properties[$responseName].Value
|
||||
}
|
||||
$media = if ($responseValue -and $responseValue.content) { $responseValue.content.PSObject.Properties['application/json'] } else { $null }
|
||||
$responseRef = if ($media) { [string]$media.Value.schema.'$ref' } else { '' }
|
||||
if ($responseRef -eq '#/components/schemas/RListHelpArticleVo') {
|
||||
$issues.Add('protected snapshot asserts RListHelpArticleVo, while current Apifox only declares a generic ListResult item projection')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-NonEmptyString {
|
||||
param([object]$Schema, [string]$SchemaName, [string]$Field)
|
||||
if (-not $Schema) { return }
|
||||
$property = $Schema.properties.PSObject.Properties[$Field]
|
||||
if (-not $property) {
|
||||
Add-Issue "JSON $SchemaName missing property: $Field"
|
||||
return
|
||||
}
|
||||
if ($property.Value.type -ne 'string') {
|
||||
Add-Issue "JSON $SchemaName.$Field must be string"
|
||||
}
|
||||
if ([int]$property.Value.minLength -lt 1) {
|
||||
Add-Issue "JSON $SchemaName.$Field must declare minLength >= 1"
|
||||
}
|
||||
}
|
||||
|
||||
$path = '/genealogy/app/help-articles'
|
||||
$pathProperty = $document.paths.PSObject.Properties[$path]
|
||||
$operation = if ($pathProperty) { $pathProperty.Value.get } else { $null }
|
||||
if (-not $operation) {
|
||||
Add-Issue "JSON missing GET $path"
|
||||
if (-not (Test-Path -LiteralPath $pagePath -PathType Leaf)) {
|
||||
$issues.Add('missing M06 page')
|
||||
} else {
|
||||
$hasSaToken = $false
|
||||
foreach ($securityRequirement in @($operation.security)) {
|
||||
if ($securityRequirement.PSObject.Properties.Name -contains 'SaToken') { $hasSaToken = $true }
|
||||
$page = Get-Content -Raw -Encoding UTF8 -LiteralPath $pagePath
|
||||
foreach ($required in @('help-source-note', 'openPage("M07", {}, "M06")')) {
|
||||
if (-not $page.Contains($required)) { $issues.Add("M06 page missing local/help fallback: $required") }
|
||||
}
|
||||
if (-not $hasSaToken) { Add-Issue "JSON GET $path must require SaToken" }
|
||||
|
||||
$response = $operation.responses.PSObject.Properties['200'].Value
|
||||
if ($response.'$ref') {
|
||||
$responseName = ([string]$response.'$ref').Split('/')[-1]
|
||||
$response = $document.components.responses.PSObject.Properties[$responseName].Value
|
||||
}
|
||||
$media = @($response.content.PSObject.Properties)
|
||||
$responseRef = if ($media.Count -gt 0) { [string]$media[0].Value.schema.'$ref' } else { '' }
|
||||
if ($responseRef -ne '#/components/schemas/RListHelpArticleVo') {
|
||||
Add-Issue "JSON GET $path must return RListHelpArticleVo; actual: $responseRef"
|
||||
}
|
||||
}
|
||||
|
||||
$envelope = Get-Schema 'RListHelpArticleVo'
|
||||
$article = Get-Schema 'HelpArticleVo'
|
||||
Assert-Required $envelope 'RListHelpArticleVo' @('code', 'data')
|
||||
Assert-Required $article 'HelpArticleVo' @('helpCategory', 'helpTitle', 'helpContent')
|
||||
|
||||
if ($envelope) {
|
||||
if ($envelope.properties.code.type -ne 'integer') {
|
||||
Add-Issue 'JSON RListHelpArticleVo.code must be integer'
|
||||
}
|
||||
$data = $envelope.properties.data
|
||||
if ($data.type -ne 'array' -or $data.items.'$ref' -ne '#/components/schemas/HelpArticleVo') {
|
||||
Add-Issue 'JSON RListHelpArticleVo.data must be HelpArticleVo[]'
|
||||
}
|
||||
}
|
||||
foreach ($field in @('helpCategory', 'helpTitle', 'helpContent')) {
|
||||
Assert-NonEmptyString $article 'HelpArticleVo' $field
|
||||
}
|
||||
if ($article) {
|
||||
$contentDescription = [string]$article.properties.helpContent.description
|
||||
if ($contentDescription -notmatch '(?i)plain[ -]?text') {
|
||||
Add-Issue 'JSON HelpArticleVo.helpContent must declare plain-text semantics'
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($yamlFact in @(
|
||||
' /genealogy/app/help-articles:',
|
||||
'#/components/schemas/RListHelpArticleVo',
|
||||
' RListHelpArticleVo:',
|
||||
' HelpArticleVo:',
|
||||
' - code',
|
||||
' - data',
|
||||
' - helpCategory',
|
||||
' - helpTitle',
|
||||
' - helpContent'
|
||||
)) {
|
||||
if (-not $yaml.Contains($yamlFact)) {
|
||||
Add-Issue "YAML fact is missing: $yamlFact"
|
||||
foreach ($forbidden in @('appApi.getHelp', '/help-articles/', 'helpId')) {
|
||||
if ($page.Contains($forbidden)) { $issues.Add("M06 page must not invent an independent detail owner: $forbidden") }
|
||||
}
|
||||
}
|
||||
|
||||
if ($issues.Count -gt 0) {
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
$lines.Add('HELP-CENTER-OPENAPI-CONTRACT BLOCKED')
|
||||
foreach ($issue in $issues) { $lines.Add("- $issue") }
|
||||
$lines.Add('- M06 will use the complete list response as its only remote owner; the detail endpoint and helpId are not consumed.')
|
||||
$lines.Add('- Authenticated release tests must still cover published-only ordering, 401, malformed data, empty data, 5xx, timeout, and cancellation.')
|
||||
$lines.Add('- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.')
|
||||
throw ($lines -join [Environment]::NewLine)
|
||||
Write-Output 'HELP-CENTER-OPENAPI-CONTRACT BLOCKED'
|
||||
foreach ($issue in $issues) { Write-Output "- $issue" }
|
||||
Write-Output '- The list is the only declared remote candidate. Do not add a detail endpoint or consume the protected snapshot schema until Apifox and a real authenticated response agree.'
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Output 'HELP-CENTER-OPENAPI-CONTRACT PASS'
|
||||
|
||||
@@ -183,7 +183,7 @@ foreach ($requiredFact in @(
|
||||
'FAILED_NO_COMMIT',
|
||||
'G03-BOOTSTRAP-OPENAPI-CONTRACT BLOCKED',
|
||||
'G03-BOOTSTRAP-CLIENT-RELEASE BLOCKED',
|
||||
'/genealogy/app/region/search',
|
||||
'/genealogy/region/search',
|
||||
'GET 必须纯读',
|
||||
'receipt→mine cache→context→G05',
|
||||
'API-G03-001',
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$api = Get-Content -LiteralPath (Join-Path $root 'utils/api.js') -Raw -Encoding UTF8
|
||||
$t04 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t04-add-relative.vue') -Raw -Encoding UTF8
|
||||
$t05 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t05-edit-member.vue') -Raw -Encoding UTF8
|
||||
|
||||
foreach ($required in @(
|
||||
'const normalizeLineageWritePayload = (payload) =>',
|
||||
"const lineageRelationPath = Object.freeze({",
|
||||
"FATHER: 'parents'",
|
||||
"SPOUSE: 'spouses'",
|
||||
"SIBLING: 'siblings'",
|
||||
"SON: 'children'",
|
||||
'async createRelatedPerson(genealogyId, personId, relationType, payload, requestOptions = {})',
|
||||
'async updatePerson(genealogyId, personId, payload, requestOptions = {})',
|
||||
'method: ''PUT''',
|
||||
'requestStrict({'
|
||||
)) {
|
||||
if (-not $api.Contains($required)) { throw "Lineage Apifox write contract missing: $required" }
|
||||
}
|
||||
|
||||
foreach ($forbidden in @('relationType: relationType.value', 'sex: addForm.gender', 'sortOrder:')) {
|
||||
if ($t04.Contains($forbidden)) { throw "T04 must not guess unsupported wire field: $forbidden" }
|
||||
}
|
||||
|
||||
foreach ($required in @('appApi.createRelatedPerson(', 'appApi.createPerson(', 'appApi.updatePerson(', 'failedAction.value = "save"')) {
|
||||
$source = if ($required -eq 'appApi.updatePerson(' -or $required -eq 'failedAction.value = "save"') { $t05 } else { $t04 }
|
||||
if (-not $source.Contains($required)) { throw "Lineage write page missing: $required" }
|
||||
}
|
||||
|
||||
Write-Output 'LINEAGE-WRITE-APIFOX-CONTRACT PASS'
|
||||
@@ -1,177 +1,37 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.json') | ConvertFrom-Json
|
||||
$yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.yaml')
|
||||
$api = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'utils/api.js')
|
||||
$page = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'pages/profile/m10-about-settings.vue')
|
||||
$issues = New-Object System.Collections.Generic.List[string]
|
||||
$logoutPath = '/genealogy/app/auth/logout'
|
||||
|
||||
function Add-Issue {
|
||||
param([string]$Message)
|
||||
$script:issues.Add($Message)
|
||||
function Require-Text {
|
||||
param([string]$Source, [string]$Text, [string]$Description)
|
||||
if (-not $Source.Contains($Text)) { $script:issues.Add($Description) }
|
||||
}
|
||||
|
||||
function Get-Schema {
|
||||
param([string]$Name)
|
||||
$property = $document.components.schemas.PSObject.Properties[$Name]
|
||||
if (-not $property) {
|
||||
Add-Issue "JSON missing schema owner: $Name"
|
||||
return $null
|
||||
}
|
||||
return $property.Value
|
||||
Require-Text $api 'async logout(requestOptions = {}) {' 'api owner must expose logout'
|
||||
Require-Text $api "url: '/genealogy/app/auth/logout'" 'logout must use the declared APP logout owner'
|
||||
Require-Text $api "method: 'DELETE'" 'logout must use DELETE'
|
||||
Require-Text $api 'requireData: false' 'logout must accept the declared VoidResult envelope'
|
||||
Require-Text $api 'requestController: requestOptions.requestController ?? null' 'logout must accept page cancellation ownership'
|
||||
Require-Text $page 'appApi.logout({ requestController: logoutRequestController });' 'M10 must call the shared logout owner'
|
||||
Require-Text $page 'session.clear();' 'M10 must clear the local session after every request outcome'
|
||||
Require-Text $page 'return goRoot("A01");' 'M10 must return to A01 after local session clear'
|
||||
Require-Text $page 'logoutRequestController.abort()' 'M10 must cancel an in-flight request when unloading'
|
||||
|
||||
$logoutOwner = [regex]::Match($api, "async logout\(requestOptions = \{\}\) \{[\s\S]*?\n \},\n async ").Value
|
||||
if (-not $logoutOwner) {
|
||||
$issues.Add('logout owner boundary is not identifiable')
|
||||
} elseif ($logoutOwner -match '(?m)^\s*data:') {
|
||||
$issues.Add('logout must not add a request body')
|
||||
}
|
||||
|
||||
function Get-Response {
|
||||
param([object]$Operation, [string]$Status)
|
||||
if (-not $Operation) { return $null }
|
||||
$property = $Operation.responses.PSObject.Properties[$Status]
|
||||
if (-not $property) {
|
||||
Add-Issue "JSON DELETE $logoutPath missing $Status response"
|
||||
return $null
|
||||
}
|
||||
$response = $property.Value
|
||||
if ($response.'$ref') {
|
||||
$name = ([string]$response.'$ref').Split('/')[-1]
|
||||
$owner = $document.components.responses.PSObject.Properties[$name]
|
||||
if (-not $owner) {
|
||||
Add-Issue "JSON missing response owner: $name"
|
||||
return $null
|
||||
}
|
||||
$response = $owner.Value
|
||||
}
|
||||
return $response
|
||||
}
|
||||
|
||||
function Get-ResponseSchemaRef {
|
||||
param([object]$Operation, [string]$Status)
|
||||
$response = Get-Response $Operation $Status
|
||||
if (-not $response) { return '' }
|
||||
$media = $response.content.PSObject.Properties['application/json']
|
||||
if (-not $media) {
|
||||
Add-Issue "JSON DELETE $logoutPath $Status must use application/json"
|
||||
return ''
|
||||
}
|
||||
return [string]$media.Value.schema.'$ref'
|
||||
}
|
||||
|
||||
function Assert-PrivateNoStore {
|
||||
param([object]$Response, [string]$Status)
|
||||
if (-not $Response) { return }
|
||||
$property = if ($Response.headers) { $Response.headers.PSObject.Properties['Cache-Control'] } else { $null }
|
||||
if (-not $property) {
|
||||
Add-Issue "JSON DELETE $logoutPath $Status must document Cache-Control: private, no-store"
|
||||
return
|
||||
}
|
||||
$header = $property.Value
|
||||
if ($header.'$ref') {
|
||||
$name = ([string]$header.'$ref').Split('/')[-1]
|
||||
$owner = $document.components.headers.PSObject.Properties[$name]
|
||||
if ($owner) { $header = $owner.Value }
|
||||
}
|
||||
$evidence = ([string]$header.description) + ' ' + ([string]$header.example) + ' ' + ([string]$header.schema.example)
|
||||
if ($header.schema.type -ne 'string' -or $evidence -notmatch '(?i)(private.*no-store|no-store.*private)') {
|
||||
Add-Issue "JSON DELETE $logoutPath $Status Cache-Control must specify private, no-store"
|
||||
}
|
||||
}
|
||||
|
||||
$pathProperty = $document.paths.PSObject.Properties[$logoutPath]
|
||||
$operation = if ($pathProperty) { $pathProperty.Value.delete } else { $null }
|
||||
if (-not $operation) { Add-Issue "JSON missing DELETE $logoutPath" }
|
||||
|
||||
if ($operation) {
|
||||
$hasSaToken = $false
|
||||
foreach ($requirement in @($operation.security)) {
|
||||
if ($requirement.PSObject.Properties.Name -contains 'SaToken') { $hasSaToken = $true }
|
||||
}
|
||||
if (-not $hasSaToken) { Add-Issue "JSON DELETE $logoutPath must require SaToken" }
|
||||
|
||||
$clientHeaders = @($operation.parameters | Where-Object { $_.name -eq 'clientid' -and $_.in -eq 'header' })
|
||||
if ($clientHeaders.Count -ne 1 -or $clientHeaders[0].required -ne $true -or
|
||||
$clientHeaders[0].schema.type -ne 'string' -or [int]$clientHeaders[0].schema.minLength -lt 1) {
|
||||
Add-Issue "JSON DELETE $logoutPath must require one non-empty string clientid header"
|
||||
}
|
||||
if ($operation.requestBody) { Add-Issue "JSON DELETE $logoutPath must not accept a request body" }
|
||||
|
||||
$semantics = [string]$operation.description
|
||||
foreach ($semanticPattern in @(
|
||||
'(?i)presented (access token|credential family)',
|
||||
'(?i)other device sessions remain valid',
|
||||
'(?i)repeated.*(idempotent|no additional side effects)',
|
||||
'(?i)active.*revoked.*expired.*same 200',
|
||||
'(?i)successful revocation.*token.*rejected'
|
||||
)) {
|
||||
if ($semantics -notmatch $semanticPattern) {
|
||||
Add-Issue "JSON DELETE $logoutPath description is missing scope/idempotency semantics: $semanticPattern"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($status in @('200', '400', '401', '429', '500')) {
|
||||
if (-not $operation.responses.PSObject.Properties[$status]) {
|
||||
Add-Issue "JSON DELETE $logoutPath missing documented response: $status"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$successResponse = Get-Response $operation '200'
|
||||
$terminalResponse = Get-Response $operation '401'
|
||||
$successRef = Get-ResponseSchemaRef $operation '200'
|
||||
$terminalRef = Get-ResponseSchemaRef $operation '401'
|
||||
if ($successRef -ne '#/components/schemas/RVoid') {
|
||||
Add-Issue "JSON DELETE $logoutPath 200 must return RVoid; actual: $successRef"
|
||||
}
|
||||
if ($terminalRef -ne '#/components/schemas/RLogoutRejected') {
|
||||
Add-Issue "JSON DELETE $logoutPath 401 must return RLogoutRejected; actual: $terminalRef"
|
||||
}
|
||||
Assert-PrivateNoStore $successResponse '200'
|
||||
Assert-PrivateNoStore $terminalResponse '401'
|
||||
|
||||
$void = Get-Schema 'RVoid'
|
||||
$terminal = Get-Schema 'RLogoutRejected'
|
||||
if ($void) {
|
||||
if ('code' -notin @($void.required) -or $void.properties.code.type -ne 'integer') {
|
||||
Add-Issue 'JSON RVoid must require integer code'
|
||||
}
|
||||
}
|
||||
if ($terminal) {
|
||||
foreach ($field in @('code', 'businessCode')) {
|
||||
if ($field -notin @($terminal.required)) {
|
||||
Add-Issue "JSON RLogoutRejected.required missing: $field"
|
||||
}
|
||||
}
|
||||
$codes = @($terminal.properties.businessCode.enum | Sort-Object)
|
||||
if ($terminal.properties.code.type -ne 'integer' -or
|
||||
$terminal.properties.businessCode.type -ne 'string' -or
|
||||
($codes -join ',') -ne 'TOKEN_CLIENT_MISMATCH,TOKEN_INVALID') {
|
||||
Add-Issue 'JSON RLogoutRejected must expose only TOKEN_CLIENT_MISMATCH/TOKEN_INVALID rejection codes'
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($yamlFact in @(
|
||||
' /genealogy/app/auth/logout:',
|
||||
' delete:',
|
||||
' name: clientid',
|
||||
'#/components/schemas/RVoid',
|
||||
'#/components/schemas/RLogoutRejected',
|
||||
' RVoid:',
|
||||
' RLogoutRejected:',
|
||||
' - TOKEN_CLIENT_MISMATCH',
|
||||
' - TOKEN_INVALID',
|
||||
' Cache-Control:'
|
||||
)) {
|
||||
if (-not $yaml.Contains($yamlFact)) { Add-Issue "YAML fact is missing: $yamlFact" }
|
||||
if ($page -match 'session\.clear\(\);[\s\S]{0,80}appApi\.logout') {
|
||||
$issues.Add('M10 must not clear local session before starting the remote request')
|
||||
}
|
||||
|
||||
if ($issues.Count -gt 0) {
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
$lines.Add('LOGOUT-OPENAPI-CONTRACT BLOCKED')
|
||||
foreach ($issue in $issues) { $lines.Add("- $issue") }
|
||||
$lines.Add('- Logout revokes only the presented current-device credential family; other device sessions remain valid.')
|
||||
$lines.Add('- Runtime proof must show that a token cannot access a protected endpoint after 200, while a second-device token still can.')
|
||||
$lines.Add('- The client starts DELETE with an in-memory token snapshot, immediately clears local session once, never restores it, and never persists a retry token.')
|
||||
$lines.Add('- Active, already-revoked, and expired credentials issued for this client all converge to the same 200 RVoid; 401 is only TOKEN_INVALID/TOKEN_CLIENT_MISMATCH and is not success.')
|
||||
$lines.Add('- Generic 401, network, timeout, malformed responses, and 5xx mean remote revocation is unconfirmed; only a valid 200 response confirms server-side termination.')
|
||||
$lines.Add('- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.')
|
||||
throw ($lines -join [Environment]::NewLine)
|
||||
throw ("LOGOUT-OPENAPI-CONTRACT FAIL`n- " + ($issues -join "`n- "))
|
||||
}
|
||||
|
||||
Write-Output 'LOGOUT-OPENAPI-CONTRACT PASS'
|
||||
|
||||
@@ -116,7 +116,7 @@ Assert-Matches -Content $a01 -Pattern '(?s)onBackPress\(\(event\) => \{\s*if \(!
|
||||
Assert-Contains -Content $a04 -Expected 'import AppDialog from "@/components/AppDialog.vue";' -Message 'A04 必须使用项目对话框确认放弃表单'
|
||||
Assert-Contains -Content $a04 -Expected 'import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";' -Message 'A04 必须消费唯一放弃确认控制器'
|
||||
Assert-Contains -Content $a04 -Expected 'const isDirty = computed(() =>' -Message 'A04 必须计算未保存表单状态'
|
||||
Assert-Matches -Content $a04 -Pattern '(?s)const requestBack = \(\) =>\s*runBackGuard\(\{\s*transientOpen: tacVisible\.value \|\| discardVisible\.value,\s*submitting: submitting\.value \|\| sendingCode\.value,\s*dirty: isDirty\.value,\s*"close-transient": tacVisible\.value \? closeTac : cancelDiscard,\s*"block-submitting": \(\) => \{\s*cancelPendingRequest\(\);\s*return requestBack\(\);\s*\},\s*"confirm-discard": requestDiscardConfirmation,\s*\}\);' -Message 'A04 requestBack 必须优先关闭 TAC;请求中返回先中止网络,再恢复脏表单守卫'
|
||||
Assert-Matches -Content $a04 -Pattern '(?s)const requestBack = \(\) =>\s*registrationCommitted\.value\s*\? enterAuthenticatedRoot\(\)\s*:\s*runBackGuard\(\{\s*transientOpen: tacVisible\.value \|\| discardVisible\.value,\s*submitting: submitting\.value \|\| sendingCode\.value,\s*dirty: isDirty\.value,\s*"close-transient": tacVisible\.value \? closeTac : cancelDiscard,\s*"block-submitting": \(\) => \{\s*cancelPendingRequest\(\);\s*return requestBack\(\);\s*\},\s*"confirm-discard": requestDiscardConfirmation,\s*\}\);' -Message 'A04 requestBack 必须先处理已提交状态、再优先关闭 TAC;请求中返回先中止网络,再恢复脏表单守卫'
|
||||
Assert-Contains -Content $a04 -Expected 'discardConfirmation.dispose();' -Message 'A04 卸载时必须通过唯一控制器释放等待者'
|
||||
Assert-Count -Content $a04 -Expected '@click="requestBack"' -Count 2 -Message 'A04 页头和已有账号入口必须共用 requestBack'
|
||||
Assert-Count -Content $a04 -Expected '<AppDialog' -Count 1 -Message 'A04 只允许一个放弃确认框'
|
||||
@@ -377,12 +377,14 @@ foreach ($entry in @(
|
||||
|
||||
foreach ($mapping in @(
|
||||
'openPage("T03", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")',
|
||||
'openPage("T04", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")',
|
||||
'openPage("T06", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")',
|
||||
'openPage("T06", { genealogyId: genealogyId.value, personId: String(selected.value.id), mode: "rank" }, "T01")',
|
||||
'openPage("T07", { genealogyId: genealogyId.value }, "T01")'
|
||||
)) {
|
||||
Assert-Contains -Content $t01 -Expected $mapping -Message "T01 缺少规范入口:$mapping"
|
||||
}
|
||||
foreach ($required in @('routeKey: "T04"', 'params.relationType = action.relationType;', 'return openPage(action.routeKey, params, "T01");')) {
|
||||
Assert-Contains -Content $t01 -Expected $required -Message "T01 人物操作面板缺少 T04 关系入口:$required"
|
||||
}
|
||||
Assert-Contains -Content $t03 -Expected 'consumeNavigationResult("T03")' -Message 'T03 必须消费 single 页面激活请求'
|
||||
Assert-Contains -Content $t03 -Expected 'internalTrail: trailIndex.value > 0' -Message 'T03 返回必须优先消费页内成员轨迹'
|
||||
Assert-Contains -Content $t03 -Expected 'onBackPress((event) => handleBackPress(event, requestBack));' -Message 'T03 Android 返回必须复用页内轨迹守卫'
|
||||
@@ -391,16 +393,20 @@ Assert-Contains -Content $t08 -Expected 'goRoot("G01")' -Message 'T08 权限失
|
||||
Assert-Contains -Content $t08 -Expected 'goBack()' -Message 'T08 普通动作必须按真实栈返回'
|
||||
|
||||
foreach ($form in @(
|
||||
@{ Key = 'T04'; Content = $t04; Return = 'returnTo("T01", { genealogyId: genealogyId.value })' },
|
||||
@{ Key = 'T05'; Content = $t05; Return = 'return goBack();' },
|
||||
@{ Key = 'T06'; Content = $t06; Return = 'returnTo("T01", { genealogyId: genealogyId.value })' }
|
||||
@{ Key = 'T04'; Content = $t04; Return = 'returnTo("T01", { genealogyId: genealogyId.value })'; Write = 'appApi.createRelatedPerson(' },
|
||||
@{ Key = 'T05'; Content = $t05; Return = 'return goBack();'; Write = 'appApi.updatePerson(' }
|
||||
)) {
|
||||
Assert-Contains -Content $form.Content -Expected '尚未提交服务器' -Message "$($form.Key) 必须明确本地预览没有写入服务器"
|
||||
Assert-Contains -Content $form.Content -Expected $form.Return -Message "$($form.Key) 本地预览必须无结果返回规范目标"
|
||||
foreach ($forbidden in @('finishPage(', 'relative-created', 'member-updated', 'relationship-updated')) {
|
||||
Assert-Contains -Content $form.Content -Expected 'appApi.getPerson(' -Message "$($form.Key) 必须从真实后端读取当前成员"
|
||||
Assert-Contains -Content $form.Content -Expected 'createRequestController' -Message "$($form.Key) 成员读取必须可取消"
|
||||
Assert-Contains -Content $form.Content -Expected $form.Write -Message "$($form.Key) 必须使用已核对的远端写入 owner"
|
||||
Assert-Contains -Content $form.Content -Expected $form.Return -Message "$($form.Key) 写入成功后必须保留规范返回目标"
|
||||
foreach ($forbidden in @('finishPage(', 'relative-created', 'member-updated', 'relationship-updated', 'setTimeout(')) {
|
||||
Assert-NotContains -Content $form.Content -Unexpected $forbidden -Message "$($form.Key) 不得在真实写入前伪造结果:$forbidden"
|
||||
}
|
||||
}
|
||||
foreach ($required in @('appApi.getPerson(', 'createRequestController', '"unavailable"', 'goBack()')) {
|
||||
Assert-Contains -Content $t06 -Expected $required -Message "T06 原子排行合同未收紧时必须显式关闭提交:$required"
|
||||
}
|
||||
Assert-Matches -Content $routes -Pattern '(?s)T01:\s*defineRoute\(\{(?:(?!resultOperations).)*?\}\),\s*T03:' -Message 'T01 当前不得预留 mutation 结果能力'
|
||||
Assert-Matches -Content $routes -Pattern '(?s)T03:\s*defineRoute\(\{.*?resultOperations:\s*\["member-open-requested"\]' -Message 'T03 当前只能登记 single 激活结果'
|
||||
|
||||
@@ -669,6 +675,6 @@ foreach ($page in @(
|
||||
Assert-Contains -Content $page.Content -Expected $token -Message "$($page.Key) 缺少浮层优先返回守卫:$token"
|
||||
}
|
||||
}
|
||||
Assert-Matches -Content $m10 -Pattern '(?s)const confirmLogout = \(\) => \{\s*session\.clear\(\);\s*logoutVisible\.value = false;\s*return goRoot\("A01"\);\s*\};' -Message 'M10 退出必须依次清理唯一会话、关闭浮层并进入 A01 根语义'
|
||||
Assert-Matches -Content $m10 -Pattern '(?s)const confirmLogout = async \(\) => \{.*?await appApi\.logout\(\{ requestController: logoutRequestController \}\);.*?finally \{\s*session\.clear\(\);\s*logoutVisible\.value = false;.*?\}\s*return goRoot\("A01"\);\s*\};' -Message 'M10 退出必须先尝试唯一远端 owner,再在所有结果下清理本机会话并进入 A01 根语义'
|
||||
|
||||
Write-Output 'NAVIGATION-FLOW-CONTRACT PASS AUTH G01-G12 T01-T08 F01-F10 R01-R11 N01-N02 M01-M10'
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"version": 1,
|
||||
"baselineInventory": {
|
||||
"powershell": 150,
|
||||
"node": 54
|
||||
},
|
||||
"defaultTimeoutSeconds": 120,
|
||||
"h5ChromeRuntimeTests": [
|
||||
"tests/a01-responsive-runtime-smoke.js",
|
||||
"tests/a04-registration-runtime-smoke.js",
|
||||
"tests/a05-reset-password-runtime-smoke.js",
|
||||
"tests/data-driven-layout-runtime-smoke.js",
|
||||
"tests/f08-album-detail-runtime-smoke.js",
|
||||
"tests/f09-media-upload-layout-smoke.js",
|
||||
"tests/f09-media-upload-runtime-smoke.js",
|
||||
"tests/f10-video-status-runtime-smoke.js",
|
||||
"tests/f-business-flow-runtime-smoke.js",
|
||||
"tests/g01-empty-state-runtime-smoke.js",
|
||||
"tests/g01-switch-dialog-runtime-smoke.js",
|
||||
"tests/g03-create-flow-runtime-smoke.js",
|
||||
"tests/g05-overview-runtime-smoke.js",
|
||||
"tests/g06-search-flow-runtime-smoke.js",
|
||||
"tests/g08-g10-application-flow-runtime-smoke.js",
|
||||
"tests/g11-g12-settings-poems-runtime-smoke.js",
|
||||
"tests/module-page-runtime-smoke.js",
|
||||
"tests/module-series-responsive-runtime-smoke.js",
|
||||
"tests/nm-business-runtime-smoke.js",
|
||||
"tests/r02-background-runtime-smoke.js",
|
||||
"tests/r02-person-detail-runtime-smoke.js",
|
||||
"tests/r-business-flow-runtime-smoke.js",
|
||||
"tests/root-pages-runtime-smoke.js",
|
||||
"tests/t01-tree-state-runtime-smoke.js",
|
||||
"tests/t03-t08-business-specialization-runtime-smoke.js",
|
||||
"tests/t03-t08-member-flow-runtime-smoke.js",
|
||||
"tests/t07-module-baseline-runtime-smoke.js"
|
||||
],
|
||||
"t0": [
|
||||
"tests/compile-audit.ps1",
|
||||
"tests/t01-person-action-panel-contract.ps1",
|
||||
"tests/t04-relative-remote-close-contract.ps1",
|
||||
"tests/t05-member-remote-close-contract.ps1",
|
||||
"tests/t06-rank-remote-close-contract.ps1",
|
||||
"tests/auth-android-accessibility-release-gate.ps1",
|
||||
"tests/auth-tac-openapi-contract.ps1",
|
||||
"tests/g03-bootstrap-client-release-gate.ps1",
|
||||
"tests/g03-bootstrap-openapi-contract.ps1",
|
||||
"tests/g11-settings-openapi-contract.ps1",
|
||||
"tests/g12-generation-poem-openapi-contract.ps1",
|
||||
"tests/genealogy-workspace-openapi-contract.ps1",
|
||||
"tests/help-center-openapi-contract.ps1",
|
||||
"tests/invite-ticket-openapi-contract.ps1",
|
||||
"tests/join-application-openapi-contract.ps1",
|
||||
"tests/lineage-openapi-contract.ps1",
|
||||
"tests/notification-read-openapi-contract.ps1",
|
||||
"tests/notification-read-state-openapi-contract.ps1",
|
||||
"tests/phone-change-openapi-contract.ps1",
|
||||
"tests/profile-openapi-contract.ps1",
|
||||
"tests/profile-update-openapi-contract.ps1",
|
||||
"tests/t03-member-remote-contract.ps1"
|
||||
],
|
||||
"expectedBlocked": [
|
||||
{ "script": "tests/auth-android-accessibility-release-gate.ps1", "marker": "ANDROID-AUTH-ACCESSIBILITY-RELEASE BLOCKED" },
|
||||
{ "script": "tests/g11-settings-openapi-contract.ps1", "marker": "G11-SETTINGS-OPENAPI-CONTRACT BLOCKED" },
|
||||
{ "script": "tests/g12-generation-poem-openapi-contract.ps1", "marker": "G12-GENERATION-POEM-OPENAPI-CONTRACT BLOCKED" },
|
||||
{ "script": "tests/genealogy-workspace-openapi-contract.ps1", "marker": "GENEALOGY-WORKSPACE-OPENAPI-CONTRACT BLOCKED" },
|
||||
{ "script": "tests/invite-ticket-openapi-contract.ps1", "marker": "INVITE-TICKET-OPENAPI-CONTRACT BLOCKED" },
|
||||
{ "script": "tests/join-application-openapi-contract.ps1", "marker": "JOIN-APPLICATION-OPENAPI-CONTRACT BLOCKED" },
|
||||
{ "script": "tests/lineage-openapi-contract.ps1", "marker": "LINEAGE-OPENAPI-CONTRACT BLOCKED" },
|
||||
{ "script": "tests/notification-read-openapi-contract.ps1", "marker": "NOTIFICATION-READ-OPENAPI-CONTRACT BLOCKED" },
|
||||
{ "script": "tests/notification-read-state-openapi-contract.ps1", "marker": "NOTIFICATION-READ-STATE-OPENAPI-CONTRACT BLOCKED" },
|
||||
{ "script": "tests/phone-change-openapi-contract.ps1", "marker": "PHONE-CHANGE-OPENAPI-CONTRACT BLOCKED" },
|
||||
{ "script": "tests/profile-openapi-contract.ps1", "marker": "PROFILE-OPENAPI-CONTRACT BLOCKED" },
|
||||
{ "script": "tests/profile-update-openapi-contract.ps1", "marker": "PROFILE-UPDATE-OPENAPI-CONTRACT BLOCKED" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
param(
|
||||
[ValidateSet('T0', 'ALL')]
|
||||
[string]$Tier = 'T0',
|
||||
[string]$OutputDir = ''
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$manifestPath = Join-Path $PSScriptRoot 'night-run.manifest.json'
|
||||
$manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
|
||||
if (-not $OutputDir) {
|
||||
$OutputDir = Join-Path ([System.IO.Path]::GetTempPath()) ('jiapuapp-night-run-' + (Get-Date -Format 'yyyyMMdd-HHmmss'))
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
|
||||
|
||||
$inventory = @(
|
||||
Get-ChildItem -LiteralPath $PSScriptRoot -File -Recurse |
|
||||
Where-Object { $_.Extension -in '.ps1', '.js' -and $_.Name -ne 'night-run.ps1' } |
|
||||
ForEach-Object { $_.FullName.Substring($root.Length + 1).Replace('\', '/') } |
|
||||
Sort-Object
|
||||
)
|
||||
$powerShellCount = @($inventory | Where-Object { $_.EndsWith('.ps1') }).Count
|
||||
$nodeCount = @($inventory | Where-Object { $_.EndsWith('.js') }).Count
|
||||
$newExecutableTests = $inventory.Count - $manifest.baselineInventory.powershell - $manifest.baselineInventory.node
|
||||
if ($newExecutableTests -lt 0) {
|
||||
throw "夜跑 inventory 少于冻结基线:actual=$($inventory.Count) baseline=$($manifest.baselineInventory.powershell + $manifest.baselineInventory.node)"
|
||||
}
|
||||
|
||||
$expectedBlocked = @{}
|
||||
foreach ($entry in @($manifest.expectedBlocked)) {
|
||||
$expectedBlocked[$entry.script] = [string]$entry.marker
|
||||
}
|
||||
$h5ChromeRuntimeTests = @{}
|
||||
foreach ($script in @($manifest.h5ChromeRuntimeTests)) {
|
||||
$h5ChromeRuntimeTests[[string]$script] = $true
|
||||
}
|
||||
|
||||
if ($Tier -eq 'T0') {
|
||||
$selected = @($manifest.t0 | Sort-Object -Unique)
|
||||
} else {
|
||||
$selected = $inventory
|
||||
}
|
||||
$unknown = @($selected | Where-Object { $_ -notin $inventory })
|
||||
if ($unknown.Count -gt 0) {
|
||||
throw "夜跑清单引用不存在的测试:$($unknown -join '、')"
|
||||
}
|
||||
|
||||
function Test-H5ChromeRuntime {
|
||||
try {
|
||||
$pages = Invoke-RestMethod -Uri 'http://127.0.0.1:9222/json/list' -TimeoutSec 2
|
||||
return @($pages | Where-Object {
|
||||
$_.type -eq 'page' -and $_.url -like 'http://localhost:5173*'
|
||||
}).Count -gt 0
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
$needsH5ChromeRuntime = @($selected | Where-Object { $h5ChromeRuntimeTests.ContainsKey($_) }).Count -gt 0
|
||||
$h5ChromeRuntimeReady = -not $needsH5ChromeRuntime -or (Test-H5ChromeRuntime)
|
||||
|
||||
function Test-ExactLine {
|
||||
param([string]$Text, [string]$Marker)
|
||||
return [regex]::IsMatch($Text, "(?m)^$([regex]::Escape($Marker))`r?$")
|
||||
}
|
||||
|
||||
function Invoke-ManagedTest {
|
||||
param([string]$RelativePath)
|
||||
$startedAt = Get-Date
|
||||
if ($h5ChromeRuntimeTests.ContainsKey($RelativePath) -and -not $h5ChromeRuntimeReady) {
|
||||
return [pscustomobject]@{
|
||||
script = $RelativePath
|
||||
startedAt = $startedAt.ToString('o')
|
||||
finishedAt = (Get-Date).ToString('o')
|
||||
durationMs = 0
|
||||
exitCode = -2
|
||||
checkResult = 'INFRA_ERROR'
|
||||
expectedBlockedMarker = $null
|
||||
infraReason = 'H5_CHROME_RUNTIME_UNAVAILABLE'
|
||||
outputPath = $null
|
||||
errorPath = $null
|
||||
}
|
||||
}
|
||||
$absolutePath = Join-Path $root $RelativePath.Replace('/', '\')
|
||||
$outputPath = Join-Path $OutputDir (($RelativePath -replace '[\\/]', '__') + '.stdout.txt')
|
||||
$errorPath = Join-Path $OutputDir (($RelativePath -replace '[\\/]', '__') + '.stderr.txt')
|
||||
$fileName = if ($RelativePath.EndsWith('.ps1')) { 'powershell.exe' } else { 'node.exe' }
|
||||
$arguments = if ($RelativePath.EndsWith('.ps1')) {
|
||||
@('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $absolutePath)
|
||||
} else {
|
||||
@($absolutePath)
|
||||
}
|
||||
$startInfo = [System.Diagnostics.ProcessStartInfo]::new()
|
||||
$startInfo.FileName = $fileName
|
||||
$startInfo.Arguments = (($arguments | ForEach-Object {
|
||||
'"' + ([string]$_).Replace('"', '\"') + '"'
|
||||
}) -join ' ')
|
||||
$startInfo.UseShellExecute = $false
|
||||
$startInfo.CreateNoWindow = $true
|
||||
$startInfo.RedirectStandardOutput = $true
|
||||
$startInfo.RedirectStandardError = $true
|
||||
$process = [System.Diagnostics.Process]::new()
|
||||
$process.StartInfo = $startInfo
|
||||
if (-not $process.Start()) {
|
||||
throw "无法启动夜跑子进程:$RelativePath"
|
||||
}
|
||||
$standardOutputTask = $process.StandardOutput.ReadToEndAsync()
|
||||
$standardErrorTask = $process.StandardError.ReadToEndAsync()
|
||||
$timedOut = -not $process.WaitForExit([int]$manifest.defaultTimeoutSeconds * 1000)
|
||||
if ($timedOut) {
|
||||
$process.Kill()
|
||||
$process.WaitForExit()
|
||||
}
|
||||
$exitCode = if ($timedOut) { -1 } else { [int]$process.ExitCode }
|
||||
$standardOutput = $standardOutputTask.GetAwaiter().GetResult()
|
||||
$standardError = $standardErrorTask.GetAwaiter().GetResult()
|
||||
[System.IO.File]::WriteAllText($outputPath, $standardOutput, [System.Text.UTF8Encoding]::new($false))
|
||||
[System.IO.File]::WriteAllText($errorPath, $standardError, [System.Text.UTF8Encoding]::new($false))
|
||||
$text = ($standardOutput + "`n" + $standardError).Trim()
|
||||
$marker = $expectedBlocked[$RelativePath]
|
||||
$result = if ($timedOut) {
|
||||
'INFRA_ERROR'
|
||||
} elseif ($marker) {
|
||||
$passMarker = $marker -replace ' BLOCKED$', ' PASS'
|
||||
if ($exitCode -eq 0 -and (Test-ExactLine $text $passMarker)) { 'PASS' }
|
||||
elseif ($exitCode -ne 0 -and (Test-ExactLine $text $marker)) { 'EXPECTED_BLOCKED' }
|
||||
else { 'FAIL' }
|
||||
} elseif ($exitCode -eq 0) {
|
||||
'PASS'
|
||||
} else {
|
||||
'FAIL'
|
||||
}
|
||||
return [pscustomobject]@{
|
||||
script = $RelativePath
|
||||
startedAt = $startedAt.ToString('o')
|
||||
finishedAt = (Get-Date).ToString('o')
|
||||
durationMs = [int]((Get-Date) - $startedAt).TotalMilliseconds
|
||||
exitCode = $exitCode
|
||||
checkResult = $result
|
||||
expectedBlockedMarker = $marker
|
||||
outputPath = $outputPath
|
||||
errorPath = $errorPath
|
||||
}
|
||||
}
|
||||
|
||||
$records = @()
|
||||
foreach ($test in $selected) {
|
||||
$record = Invoke-ManagedTest $test
|
||||
$records += $record
|
||||
$checkpoint = [pscustomobject]@{
|
||||
baselineInventoryTests = [int]$manifest.baselineInventory.powershell + [int]$manifest.baselineInventory.node
|
||||
newExecutableTests = $newExecutableTests
|
||||
inventoryTests = $inventory.Count
|
||||
selectedTests = $selected.Count
|
||||
records = $records
|
||||
}
|
||||
$checkpoint | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $OutputDir 'checkpoint.json') -Encoding UTF8
|
||||
Write-Output ("{0} {1} exit={2}" -f $record.checkResult, $record.script, $record.exitCode)
|
||||
}
|
||||
|
||||
$summary = [pscustomobject]@{
|
||||
baselineInventoryTests = [int]$manifest.baselineInventory.powershell + [int]$manifest.baselineInventory.node
|
||||
newExecutableTests = $newExecutableTests
|
||||
inventoryTests = $inventory.Count
|
||||
scheduledTests = $selected.Count
|
||||
executedTests = $records.Count
|
||||
passTests = @($records | Where-Object checkResult -eq 'PASS').Count
|
||||
failTests = @($records | Where-Object checkResult -eq 'FAIL').Count
|
||||
expectedBlockedTests = @($records | Where-Object checkResult -eq 'EXPECTED_BLOCKED').Count
|
||||
infraErrorTests = @($records | Where-Object checkResult -eq 'INFRA_ERROR').Count
|
||||
timedOutTests = @($records | Where-Object { $_.exitCode -eq -1 }).Count
|
||||
notRunScheduledTests = 0
|
||||
notRunTests = $inventory.Count - $selected.Count
|
||||
outputDir = $OutputDir
|
||||
}
|
||||
$summary | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $OutputDir 'summary.json') -Encoding UTF8
|
||||
Write-Output ('NIGHT-RUN-SUMMARY ' + ($summary | ConvertTo-Json -Compress))
|
||||
|
||||
if ($summary.infraErrorTests -gt 0) { exit 4 }
|
||||
if ($summary.failTests -gt 0) { exit 2 }
|
||||
exit 0
|
||||
@@ -9,7 +9,7 @@ $contracts = [ordered]@{
|
||||
'pages/profile/m03-security-settings.vue' = @('securityItems', 'openSecurityItem', 'device-state--limited', 'checkSecurity', 'routeKey: "M04"', 'routeKey: "M05"')
|
||||
'pages/profile/m04-change-password.vue' = @('passwordForm', 'validatePassword', 'togglePassword', 'savePassword', 'password-state--saving')
|
||||
'pages/profile/m05-change-phone.vue' = @('phoneForm', 'sendCode', 'savePhone', 'phone-state--saving')
|
||||
'pages/profile/m06-help-center.vue' = @('helpCategories', 'filteredQuestions', 'toggleQuestion', 'contactSupport', 'openPage("M07", {}, "M06")')
|
||||
'pages/profile/m06-help-center.vue' = @('helpCategories', 'filteredQuestions', 'toggleQuestion', 'contactSupport', 'help-source-note', 'openPage("M07", {}, "M06")')
|
||||
'pages/profile/m07-feedback.vue' = @('feedbackForm', 'feedbackTypes', 'validateFeedback', 'submitFeedback', 'feedback-state--submitting')
|
||||
'pages/profile/m08-promotion.vue' = @('inviteState', 'openInviteExplanation', 'share-state--unavailable', 'explanationVisible')
|
||||
'pages/profile/m09-vip-orders.vue' = @('serviceBenefits', 'orderState', 'order-state--unavailable', 'openServiceNotice')
|
||||
@@ -22,7 +22,7 @@ foreach ($entry in $contracts.GetEnumerator()) {
|
||||
foreach ($forbidden in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet', 'uni.navigateTo', 'uni.navigateBack', 'uni.redirectTo', 'uni.reLaunch', 'getCurrentPages(', '/pages/')) {
|
||||
if ($source.Contains($forbidden)) { throw "$($entry.Key) contains forbidden token: $forbidden" }
|
||||
}
|
||||
if ($entry.Key -ne 'pages/profile/m07-feedback.vue' -and $source.Contains('@/utils/api.js')) {
|
||||
if ($entry.Key -notin @('pages/profile/m04-change-password.vue', 'pages/profile/m07-feedback.vue', 'pages/profile/m10-about-settings.vue') -and $source.Contains('@/utils/api.js')) {
|
||||
throw "$($entry.Key) must not consume the API before its independent interface batch"
|
||||
}
|
||||
foreach ($required in @('ModulePageBackground', 'PageHeader', 'AppButton') + $entry.Value) {
|
||||
@@ -39,8 +39,10 @@ $n01 = $sources['pages/notification/n01-message-center.vue']
|
||||
$n02 = $sources['pages/notification/n02-message-detail.vue']
|
||||
$m02 = $sources['pages/profile/m02-edit-profile.vue']
|
||||
$m03 = $sources['pages/profile/m03-security-settings.vue']
|
||||
$m04 = $sources['pages/profile/m04-change-password.vue']
|
||||
$m08 = $sources['pages/profile/m08-promotion.vue']
|
||||
$m10 = $sources['pages/profile/m10-about-settings.vue']
|
||||
$m10 = $sources['pages/profile/m10-about-settings.vue']
|
||||
|
||||
if ($n01 -notmatch '(?s)openPage\(\s*"N02",\s*\{ id: String\(item\.id\) \},\s*"N01",?\s*\)') {
|
||||
throw 'N01 must open N02 with only its registered lexical notice identity'
|
||||
@@ -97,7 +99,6 @@ foreach ($formPath in @(
|
||||
|
||||
foreach ($previewContract in @(
|
||||
@{ Path = 'pages/profile/m02-edit-profile.vue'; Retired = @('个人资料已保存') },
|
||||
@{ Path = 'pages/profile/m04-change-password.vue'; Retired = @('密码已修改', 'passwordForm.current = ""', 'passwordForm.next = ""', 'passwordForm.confirm = ""') },
|
||||
@{ Path = 'pages/profile/m05-change-phone.vue'; Retired = @('演示验证码已发送', '绑定手机号已更新', 'phoneForm.currentPhone =') }
|
||||
)) {
|
||||
$source = $sources[$previewContract.Path]
|
||||
@@ -109,6 +110,25 @@ foreach ($previewContract in @(
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($required in @(
|
||||
'appApi.changePassword',
|
||||
'calcMD5(passwordForm.current)',
|
||||
'calcMD5(passwordForm.next)',
|
||||
'createRequestController',
|
||||
'passwordRequestController.abort()',
|
||||
'isRequestCancelled(error)',
|
||||
'确认修改密码'
|
||||
)) {
|
||||
if (-not $m04.Contains($required)) { throw "M04 declared password change contract missing: $required" }
|
||||
}
|
||||
foreach ($forbidden in @('校验新密码(不提交)', '本地校验通过,尚未提交服务器')) {
|
||||
if ($m04.Contains($forbidden)) { throw "M04 must not retain local-only password change copy: $forbidden" }
|
||||
}
|
||||
|
||||
foreach ($required in @('appApi.logout', 'logoutRequestController', 'session.clear();', 'return goRoot("A01");')) {
|
||||
if (-not $m10.Contains($required)) { throw "M10 declared logout contract missing: $required" }
|
||||
}
|
||||
|
||||
foreach ($dialogPath in @(
|
||||
'pages/profile/m08-promotion.vue',
|
||||
'pages/profile/m09-vip-orders.vue',
|
||||
@@ -152,8 +172,8 @@ foreach ($forbiddenOrderPreview in @('query.state', '演示订单', '¥0.00', 'o
|
||||
if ($m10 -notmatch 'import \{ session \} from "@/utils/session\.js";') {
|
||||
throw 'M10 must consume the unique session owner'
|
||||
}
|
||||
if ($m10 -notmatch '(?s)const confirmLogout = \(\) => \{\s*session\.clear\(\);\s*logoutVisible\.value = false;\s*return goRoot\("A01"\);\s*\};') {
|
||||
throw 'M10 logout order must be session.clear -> close dialog -> goRoot(A01)'
|
||||
if ($m10 -notmatch '(?s)const confirmLogout = async \(\) => \{.*?await appApi\.logout\(\{ requestController: logoutRequestController \}\);.*?finally \{\s*session\.clear\(\);\s*logoutVisible\.value = false;.*?\}\s*return goRoot\("A01"\);\s*\};') {
|
||||
throw 'M10 logout must attempt the declared remote owner, then clear the local session and enter A01 in every result'
|
||||
}
|
||||
if (([regex]::Matches($m10, 'session\.clear\(\)')).Count -ne 1) {
|
||||
throw 'M10 cancellation or back paths must never clear the session'
|
||||
|
||||
@@ -1,259 +1,37 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.json') | ConvertFrom-Json
|
||||
$yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.yaml')
|
||||
$api = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'utils/api.js')
|
||||
$page = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'pages/profile/m04-change-password.vue')
|
||||
$issues = New-Object System.Collections.Generic.List[string]
|
||||
$passwordPath = '/genealogy/app/auth/password'
|
||||
|
||||
function Add-Issue {
|
||||
param([string]$Message)
|
||||
$script:issues.Add($Message)
|
||||
function Require-Text {
|
||||
param([string]$Source, [string]$Text, [string]$Description)
|
||||
if (-not $Source.Contains($Text)) { $script:issues.Add($Description) }
|
||||
}
|
||||
|
||||
function Get-Schema {
|
||||
param([string]$Name)
|
||||
$property = $document.components.schemas.PSObject.Properties[$Name]
|
||||
if (-not $property) {
|
||||
Add-Issue "JSON missing schema owner: $Name"
|
||||
return $null
|
||||
}
|
||||
return $property.Value
|
||||
Require-Text $api 'async changePassword({ oldPasswordHash, newPasswordHash }, requestOptions = {}) {' 'api owner must expose password change'
|
||||
Require-Text $api "url: '/genealogy/app/auth/password'" 'password change must use the declared APP password owner'
|
||||
Require-Text $api "method: 'PUT'" 'password change must use PUT'
|
||||
Require-Text $api 'oldPassword: assertPasswordHash(oldPasswordHash)' 'old password must be the declared 32-character digest'
|
||||
Require-Text $api 'newPassword: assertPasswordHash(newPasswordHash)' 'new password must be the declared 32-character digest'
|
||||
Require-Text $api 'requireData: false' 'password change must accept the declared VoidResult envelope'
|
||||
Require-Text $api 'requestController: requestOptions.requestController ?? null' 'password change must accept page cancellation ownership'
|
||||
Require-Text $page 'appApi.changePassword({' 'M04 must call the shared password owner'
|
||||
Require-Text $page 'oldPasswordHash: calcMD5(passwordForm.current)' 'M04 must hash the current password before transport'
|
||||
Require-Text $page 'newPasswordHash: calcMD5(passwordForm.next)' 'M04 must hash the new password before transport'
|
||||
Require-Text $page 'passwordRequestController.abort()' 'M04 must cancel an in-flight request when unloading'
|
||||
Require-Text $page 'isRequestCancelled(error)' 'M04 must not report a cancelled request as a password-change failure'
|
||||
|
||||
if ($api -match "url: '/genealogy/app/auth/password'[\s\S]{0,260}passwordForm\.") {
|
||||
$issues.Add('password API owner must not depend on page form state')
|
||||
}
|
||||
|
||||
function Get-Response {
|
||||
param([object]$Operation, [string]$Status)
|
||||
if (-not $Operation) { return $null }
|
||||
$property = $Operation.responses.PSObject.Properties[$Status]
|
||||
if (-not $property) {
|
||||
Add-Issue "JSON PUT $passwordPath missing $Status response"
|
||||
return $null
|
||||
}
|
||||
$response = $property.Value
|
||||
if ($response.'$ref') {
|
||||
$name = ([string]$response.'$ref').Split('/')[-1]
|
||||
$owner = $document.components.responses.PSObject.Properties[$name]
|
||||
if (-not $owner) {
|
||||
Add-Issue "JSON missing response owner: $name"
|
||||
return $null
|
||||
}
|
||||
$response = $owner.Value
|
||||
}
|
||||
return $response
|
||||
}
|
||||
|
||||
function Get-JsonSchemaRef {
|
||||
param([object]$Response, [string]$Status)
|
||||
if (-not $Response) { return '' }
|
||||
$media = $Response.content.PSObject.Properties['application/json']
|
||||
if (-not $media) {
|
||||
Add-Issue "JSON PUT $passwordPath $Status must use application/json"
|
||||
return ''
|
||||
}
|
||||
return [string]$media.Value.schema.'$ref'
|
||||
}
|
||||
|
||||
function Assert-PrivateNoStore {
|
||||
param([object]$Response, [string]$Status)
|
||||
if (-not $Response) { return }
|
||||
$property = if ($Response.headers) { $Response.headers.PSObject.Properties['Cache-Control'] } else { $null }
|
||||
if (-not $property) {
|
||||
Add-Issue "JSON PUT $passwordPath $Status must document Cache-Control: private, no-store"
|
||||
return
|
||||
}
|
||||
$header = $property.Value
|
||||
if ($header.'$ref') {
|
||||
$name = ([string]$header.'$ref').Split('/')[-1]
|
||||
$owner = $document.components.headers.PSObject.Properties[$name]
|
||||
if ($owner) { $header = $owner.Value }
|
||||
}
|
||||
$evidence = ([string]$header.description) + ' ' + ([string]$header.example) + ' ' + ([string]$header.schema.example)
|
||||
if ($header.schema.type -ne 'string' -or $evidence -notmatch '(?i)(private.*no-store|no-store.*private)') {
|
||||
Add-Issue "JSON PUT $passwordPath $Status Cache-Control must specify private, no-store"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-SecretSchema {
|
||||
param(
|
||||
[object]$Schema,
|
||||
[string]$Name,
|
||||
[int]$Minimum,
|
||||
[int]$Maximum
|
||||
)
|
||||
if (-not $Schema) { return }
|
||||
if ($Schema.type -ne 'string' -or $Schema.format -ne 'password' -or $Schema.writeOnly -ne $true -or
|
||||
[int]$Schema.minLength -ne $Minimum -or [int]$Schema.maxLength -ne $Maximum) {
|
||||
Add-Issue "JSON $Name must be a writeOnly password string of $Minimum..$Maximum Unicode code points"
|
||||
}
|
||||
if ($Schema.pattern -or $Schema.example -or ([string]$Schema.description) -match '(?i)MD5|hex|字母.*数字|数字.*字母') {
|
||||
Add-Issue "JSON $Name must not retain a static digest, composition rule, pattern, or password example"
|
||||
}
|
||||
if (([string]$Schema.description) -notmatch '(?i)Unicode code point' -or
|
||||
([string]$Schema.description) -notmatch '(?i)NFC' -or
|
||||
([string]$Schema.description) -notmatch '(?i)(space|空格)') {
|
||||
Add-Issue "JSON $Name must define Unicode code-point length, NFC normalization, and space handling"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-FieldRef {
|
||||
param([string]$SchemaName, [string]$Field, [string]$ExpectedRef)
|
||||
$schema = Get-Schema $SchemaName
|
||||
if (-not $schema) { return }
|
||||
$property = $schema.properties.PSObject.Properties[$Field]
|
||||
$actual = if ($property) { [string]$property.Value.'$ref' } else { '' }
|
||||
if ($actual -ne $ExpectedRef) {
|
||||
Add-Issue "JSON $SchemaName.$Field must use $ExpectedRef; actual: $actual"
|
||||
}
|
||||
}
|
||||
|
||||
$pathProperty = $document.paths.PSObject.Properties[$passwordPath]
|
||||
$operation = if ($pathProperty) { $pathProperty.Value.put } else { $null }
|
||||
if (-not $operation) { Add-Issue "JSON missing PUT $passwordPath" }
|
||||
|
||||
if ($operation) {
|
||||
$hasSaToken = $false
|
||||
foreach ($requirement in @($operation.security)) {
|
||||
if ($requirement.PSObject.Properties.Name -contains 'SaToken') { $hasSaToken = $true }
|
||||
}
|
||||
if (-not $hasSaToken) { Add-Issue "JSON PUT $passwordPath must require SaToken" }
|
||||
|
||||
$clientHeaders = @($operation.parameters | Where-Object { $_.name -eq 'clientid' -and $_.in -eq 'header' })
|
||||
if ($clientHeaders.Count -ne 1 -or $clientHeaders[0].required -ne $true -or
|
||||
$clientHeaders[0].schema.type -ne 'string' -or [int]$clientHeaders[0].schema.minLength -lt 1) {
|
||||
Add-Issue "JSON PUT $passwordPath must require one non-empty string clientid header"
|
||||
}
|
||||
|
||||
$requestMedia = $operation.requestBody.content.PSObject.Properties['application/json']
|
||||
if ($operation.requestBody.required -ne $true -or -not $requestMedia) {
|
||||
Add-Issue "JSON PUT $passwordPath must require an application/json body"
|
||||
} elseif ($requestMedia.Value.schema.'$ref' -ne '#/components/schemas/PasswordChangeBody') {
|
||||
Add-Issue 'JSON password change request must use PasswordChangeBody'
|
||||
}
|
||||
|
||||
$semantics = [string]$operation.description
|
||||
foreach ($semanticPattern in @(
|
||||
'(?i)current password.*re-authentication',
|
||||
'(?i)atomic.*password.*credential epoch',
|
||||
'(?i)all.*access.*refresh.*sessions.*including.*current',
|
||||
'(?i)200.*sessions.*invalidated',
|
||||
'(?i)new password.*different.*current password',
|
||||
'(?i)(common|breached) password.*blocklist',
|
||||
'(?i)rate limit'
|
||||
)) {
|
||||
if ($semantics -notmatch $semanticPattern) {
|
||||
Add-Issue "JSON PUT $passwordPath description is missing security/session semantics: $semanticPattern"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($status in @('200', '400', '401', '409', '422', '429', '500')) {
|
||||
if (-not $operation.responses.PSObject.Properties[$status]) {
|
||||
Add-Issue "JSON PUT $passwordPath missing documented response: $status"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$changeBody = Get-Schema 'PasswordChangeBody'
|
||||
$currentSecret = Get-Schema 'CurrentPasswordSecret'
|
||||
$newSecret = Get-Schema 'NewPasswordSecret'
|
||||
Assert-SecretSchema $currentSecret 'CurrentPasswordSecret' 1 64
|
||||
Assert-SecretSchema $newSecret 'NewPasswordSecret' 15 64
|
||||
|
||||
if ($changeBody) {
|
||||
$properties = @($changeBody.properties.PSObject.Properties.Name | Sort-Object)
|
||||
$required = @($changeBody.required | Sort-Object)
|
||||
if ($changeBody.type -ne 'object' -or $changeBody.additionalProperties -ne $false -or
|
||||
($properties -join ',') -ne 'newPassword,oldPassword' -or
|
||||
($required -join ',') -ne 'newPassword,oldPassword') {
|
||||
Add-Issue 'JSON PasswordChangeBody must be a closed object requiring only oldPassword/newPassword'
|
||||
}
|
||||
}
|
||||
|
||||
# 密码传输是跨登录、注册、找回和登录态改密的单一合同。禁止只让 M04 改成明文,
|
||||
# 其余入口继续接受可重放摘要;新合同落地时必须一次删除全部 MD5 wire fallback。
|
||||
Assert-FieldRef 'PasswordLoginBody' 'password' '#/components/schemas/CurrentPasswordSecret'
|
||||
Assert-FieldRef 'PasswordRegisterBody' 'password' '#/components/schemas/NewPasswordSecret'
|
||||
Assert-FieldRef 'PasswordResetBody' 'newPassword' '#/components/schemas/NewPasswordSecret'
|
||||
Assert-FieldRef 'PasswordChangeBody' 'oldPassword' '#/components/schemas/CurrentPasswordSecret'
|
||||
Assert-FieldRef 'PasswordChangeBody' 'newPassword' '#/components/schemas/NewPasswordSecret'
|
||||
|
||||
$responses = @{}
|
||||
foreach ($status in @('200', '400', '401', '409', '422', '429', '500')) {
|
||||
$responses[$status] = Get-Response $operation $status
|
||||
[void](Get-JsonSchemaRef $responses[$status] $status)
|
||||
Assert-PrivateNoStore $responses[$status] $status
|
||||
}
|
||||
if ((Get-JsonSchemaRef $responses['200'] '200') -ne '#/components/schemas/RVoid') {
|
||||
Add-Issue 'JSON PUT password 200 must return RVoid after all sessions are invalidated'
|
||||
}
|
||||
foreach ($status in @('409', '422')) {
|
||||
if ((Get-JsonSchemaRef $responses[$status] $status) -ne '#/components/schemas/RPasswordChangeRejected') {
|
||||
Add-Issue "JSON PUT password $status must return RPasswordChangeRejected"
|
||||
}
|
||||
}
|
||||
|
||||
$retryAfterProperty = if ($responses['429'] -and $responses['429'].headers) {
|
||||
$responses['429'].headers.PSObject.Properties['Retry-After']
|
||||
} else { $null }
|
||||
if (-not $retryAfterProperty) {
|
||||
Add-Issue 'JSON PUT password 429 must document Retry-After'
|
||||
}
|
||||
|
||||
$void = Get-Schema 'RVoid'
|
||||
$rejected = Get-Schema 'RPasswordChangeRejected'
|
||||
if ($void -and ('code' -notin @($void.required) -or $void.properties.code.type -ne 'integer')) {
|
||||
Add-Issue 'JSON RVoid must require integer code'
|
||||
}
|
||||
if ($rejected) {
|
||||
foreach ($field in @('code', 'businessCode')) {
|
||||
if ($field -notin @($rejected.required)) { Add-Issue "JSON RPasswordChangeRejected.required missing: $field" }
|
||||
}
|
||||
$codes = @($rejected.properties.businessCode.enum | Sort-Object)
|
||||
$expectedCodes = @(
|
||||
'CREDENTIAL_VERSION_CONFLICT',
|
||||
'CURRENT_PASSWORD_INCORRECT',
|
||||
'NEW_PASSWORD_SAME_AS_CURRENT',
|
||||
'PASSWORD_POLICY_VIOLATION'
|
||||
) | Sort-Object
|
||||
if ($rejected.properties.code.type -ne 'integer' -or
|
||||
$rejected.properties.businessCode.type -ne 'string' -or
|
||||
($codes -join ',') -ne ($expectedCodes -join ',')) {
|
||||
Add-Issue 'JSON RPasswordChangeRejected must expose the four stable conflict/validation business codes'
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($yamlFact in @(
|
||||
' /genealogy/app/auth/password:',
|
||||
' name: clientid',
|
||||
'#/components/schemas/PasswordChangeBody',
|
||||
'#/components/schemas/CurrentPasswordSecret',
|
||||
'#/components/schemas/NewPasswordSecret',
|
||||
'#/components/schemas/RPasswordChangeRejected',
|
||||
' CurrentPasswordSecret:',
|
||||
' NewPasswordSecret:',
|
||||
' writeOnly: true',
|
||||
' minLength: 15',
|
||||
' maxLength: 64',
|
||||
' RPasswordChangeRejected:',
|
||||
' - CREDENTIAL_VERSION_CONFLICT',
|
||||
' - CURRENT_PASSWORD_INCORRECT',
|
||||
' - NEW_PASSWORD_SAME_AS_CURRENT',
|
||||
' - PASSWORD_POLICY_VIOLATION',
|
||||
' Cache-Control:',
|
||||
' Retry-After:'
|
||||
)) {
|
||||
if (-not $yaml.Contains($yamlFact)) { Add-Issue "YAML fact is missing: $yamlFact" }
|
||||
if ($page -notmatch 'oldPasswordHash:\s*calcMD5\(passwordForm\.current\)[\s\S]{0,160}newPasswordHash:\s*calcMD5\(passwordForm\.next\)') {
|
||||
$issues.Add('M04 must pass only the MD5 digests to the API owner')
|
||||
}
|
||||
|
||||
if ($issues.Count -gt 0) {
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
$lines.Add('PASSWORD-CHANGE-OPENAPI-CONTRACT BLOCKED')
|
||||
foreach ($issue in $issues) { $lines.Add("- $issue") }
|
||||
$lines.Add('- Remove static MD5 from login/register/reset/change in one contract migration; accept raw writeOnly passwords only over authenticated HTTPS and store a salted adaptive server-side hash.')
|
||||
$lines.Add('- The target new-password policy is 15..64 Unicode code points, NFC, spaces allowed, no composition rule, plus server-side common/breached-password blocklist and rate limiting.')
|
||||
$lines.Add('- A strict 200 means the password is durable and every pre-existing access/refresh session, including the caller, is invalidated; the client clears locally and returns to A01.')
|
||||
$lines.Add('- Network, timeout, malformed response, or 5xx after dispatch is outcome-unknown: clear secrets/session, return to A01, and never retry automatically or claim success.')
|
||||
$lines.Add('- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.')
|
||||
throw ($lines -join [Environment]::NewLine)
|
||||
throw ("PASSWORD-CHANGE-OPENAPI-CONTRACT FAIL`n- " + ($issues -join "`n- "))
|
||||
}
|
||||
|
||||
Write-Output 'PASSWORD-CHANGE-OPENAPI-CONTRACT PASS'
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
{ "path": "pages/tree/t01-tree-overview.vue", "selector": ".generation-band", "property": "fixed-height", "reason": "Fixed-height generation lane in the scrollable tree canvas." },
|
||||
{ "path": "pages/tree/t01-tree-overview.vue", "selector": ".member-node", "property": "fixed-height", "reason": "Fixed-ratio interactive tree node." },
|
||||
{ "path": "pages/tree/t01-tree-overview.vue", "selector": ".member-node--selected", "property": "fixed-height", "reason": "Selected state preserves the fixed-ratio tree node." },
|
||||
{ "path": "pages/tree/t01-tree-overview.vue", "selector": ".member-node__avatar", "property": "fixed-height", "reason": "Fixed-ratio member avatar icon." },
|
||||
{ "path": "pages/tree/t03-member-profile.vue", "selector": ".member-heading__seal", "property": "fixed-height", "reason": "Fixed-ratio member seal." }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@ foreach ($required in @(
|
||||
'<AppButton',
|
||||
'type="secondary"',
|
||||
'@click="toMember"',
|
||||
'@click="toAddRelative"',
|
||||
't01-member-node-standard.png',
|
||||
't01-member-node-selected.png',
|
||||
't01-state-panel.png',
|
||||
't01-member-drawer.png',
|
||||
'openMemberPanel(member)',
|
||||
'member-action-profile',
|
||||
'v-for="connector in lineageConnectors"',
|
||||
'lineage-pan-cue',
|
||||
'const treeScrollLeft = ref(90)',
|
||||
@@ -35,7 +35,7 @@ foreach ($rule in @(
|
||||
'(?s)\.node-relation\s*\{[^}]*font-size:\s*21rpx;',
|
||||
'(?s)\.node-years\s*\{[^}]*font-size:\s*20rpx;',
|
||||
'(?s)\.tree-state-card__copy\s*\{[^}]*font-size:\s*24rpx;',
|
||||
'(?s)\.sheet-meta\s*\{[^}]*font-size:\s*22rpx;',
|
||||
'(?s)\.member-action-profile__copy text:nth-child\(2\)\s*\{[^}]*font-size:\s*21rpx;',
|
||||
'(?s)\.tree-stage--lineage\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*190rpx minmax\(0, 1fr\);',
|
||||
'(?s)\.generation-rail\s*\{[^}]*display:\s*grid;[^}]*width:\s*190rpx;',
|
||||
'(?s)\.tree-scroll--lineage\s*\{[^}]*grid-area:\s*1 / 2;[^}]*min-width:\s*0;',
|
||||
@@ -45,9 +45,8 @@ foreach ($rule in @(
|
||||
'(?s)\.generation-band image,\s*\.generation-band__copy\s*\{[^}]*grid-area:\s*1 / 1;',
|
||||
'(?s)\.member-node\s*\{[^}]*display:\s*grid;',
|
||||
'(?s)\.member-node__skin,\s*\.member-node__copy\s*\{[^}]*grid-area:\s*1 / 1;',
|
||||
'(?s)\.member-sheet\s*\{[^}]*min-height:\s*240rpx;',
|
||||
'(?s)\.sheet-actions\s*\{[^}]*justify-content:\s*center;[^}]*margin-top:\s*auto;',
|
||||
'(?s)\.sheet-action\s*\{[^}]*width:\s*250rpx;[^}]*min-height:\s*88rpx;'
|
||||
'(?s)\.member-action-profile\s*\{[^}]*display:\s*flex;[^}]*align-items:\s*center;',
|
||||
'(?s)\.member-action-profile__avatar\s*\{[^}]*width:\s*82rpx;[^}]*aspect-ratio:\s*1;[^}]*flex:\s*0 0 82rpx;'
|
||||
)) {
|
||||
if ($page -notmatch $rule) { throw "T01 readable visual rule missing: $rule" }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding UTF8
|
||||
$routes = Get-Content -LiteralPath (Join-Path $root 'utils/navigation-routes.js') -Raw -Encoding UTF8
|
||||
|
||||
foreach ($required in @(
|
||||
'class="member-node__avatar"',
|
||||
'const memberActionPanelVisible = ref(false)',
|
||||
'const memberActions = Object.freeze([',
|
||||
'const openMemberAction = (action) =>',
|
||||
'<AppDialog',
|
||||
'member-action-grid',
|
||||
'unavailableActionVisible',
|
||||
'BIND_INVITE'
|
||||
)) {
|
||||
if (-not $page.Contains($required)) {
|
||||
throw "T01 member action panel missing: $required"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($actionKey in @(
|
||||
'VIEW_PROFILE',
|
||||
'ADD_FATHER',
|
||||
'ADD_MOTHER',
|
||||
'ADD_SPOUSE',
|
||||
'ADD_SIBLING',
|
||||
'ADJUST_RANK',
|
||||
'ADD_SON',
|
||||
'ADD_DAUGHTER',
|
||||
'BIND_INVITE',
|
||||
'EDIT_PROFILE'
|
||||
)) {
|
||||
if (-not $page.Contains(('key: "' + $actionKey + '"'))) {
|
||||
throw "T01 member action panel missing action: $actionKey"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($required in @(
|
||||
'optionalParams: ["personId", "mode", "relationType"]',
|
||||
'allowedSources: ["T01", "T03"]',
|
||||
'optionalParams: ["mode"]'
|
||||
)) {
|
||||
if (-not $routes.Contains($required)) {
|
||||
throw "T01 member action route contract missing: $required"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output 'T01-PERSON-ACTION-PANEL-CONTRACT PASS'
|
||||
@@ -1,9 +1,8 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path $PSScriptRoot -Parent
|
||||
$page = [System.IO.File]::ReadAllText((Join-Path $root 'pages/tree/t01-tree-overview.vue'), [System.Text.Encoding]::UTF8)
|
||||
foreach ($selector in @('.tree-state-card','.member-sheet__copy','.sheet-actions')) {
|
||||
foreach ($selector in @('.tree-state-card','.member-action-profile','.member-action-profile__copy')) {
|
||||
$escaped = [regex]::Escape($selector)
|
||||
if ($page -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") { throw "T01 normal sheet/state content uses positioning: $selector" }
|
||||
}
|
||||
if (-not $page.Contains('margin-top: auto')) { throw 'T01 fixed member sheet actions must use flex flow' }
|
||||
Write-Output 'T01-SHEET-STATE-DOCUMENT-FLOW-CONTRACT PASS'
|
||||
|
||||
@@ -60,9 +60,10 @@ foreach ($required in @(
|
||||
't01-member-node-standard.png',
|
||||
't01-member-node-selected.png',
|
||||
't01-state-panel.png',
|
||||
't01-member-drawer.png',
|
||||
'class="member-action-profile"',
|
||||
'const openMemberPanel = (member) =>',
|
||||
'openPage("T03"',
|
||||
'openPage("T04"',
|
||||
'routeKey: "T04"',
|
||||
'openPage("T06"',
|
||||
'openPage("T07"',
|
||||
'query.genealogyId',
|
||||
@@ -101,7 +102,7 @@ foreach ($forbidden in @(
|
||||
if ($page -match [regex]::Escape($forbidden)) { throw "T01 must not keep opaque surface asset: $forbidden" }
|
||||
}
|
||||
|
||||
foreach ($className in @('tree-canvas', 'member-node', 'member-sheet', 'tree-state-card')) {
|
||||
foreach ($className in @('tree-canvas', 'member-node', 'member-action-profile', 'tree-state-card')) {
|
||||
Assert-NoCssSurface $page $className
|
||||
}
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ const run = async () => {
|
||||
|
||||
await open(send, '?genealogyId=1001', '.tree-state--tree .member-node')
|
||||
await valueOf(send, "document.querySelector('.member-node')?.click()")
|
||||
await valueOf(send, "document.querySelector('.member-sheet .app-button')?.click()")
|
||||
await valueOf(send, "document.querySelector('.member-action-profile')?.click()")
|
||||
await waitFor(send, "location.href.includes('/pages/tree/t03-member-profile?genealogyId=1001&personId=')", 'T01 selected member did not open T03')
|
||||
process.stdout.write('T01-TREE-STATE-RUNTIME-SMOKE PASS\n')
|
||||
} finally {
|
||||
|
||||
@@ -1,40 +1,30 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$profile = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t03-member-profile.vue') -Raw -Encoding utf8
|
||||
$forms = [ordered]@{
|
||||
'T04' = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t04-add-relative.vue') -Raw -Encoding utf8
|
||||
'T05' = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t05-edit-member.vue') -Raw -Encoding utf8
|
||||
'T06' = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t06-edit-relationship.vue') -Raw -Encoding utf8
|
||||
$pages = @{
|
||||
T03 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t03-member-profile.vue') -Raw -Encoding UTF8
|
||||
T04 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t04-add-relative.vue') -Raw -Encoding UTF8
|
||||
T05 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t05-edit-member.vue') -Raw -Encoding UTF8
|
||||
T06 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t06-edit-relationship.vue') -Raw -Encoding UTF8
|
||||
}
|
||||
|
||||
foreach ($rule in @(
|
||||
'(?s)\.member-heading > view:last-child text:last-child\s*\{[^}]*font-size:\s*23rpx;',
|
||||
'(?s)\.member-section-title\s*\{[^}]*font-size:\s*24rpx;',
|
||||
'(?s)\.member-info-row text\s*\{[^}]*font-size:\s*23rpx;',
|
||||
'(?s)\.member-relatives\s*\{[^}]*font-size:\s*23rpx;',
|
||||
'(?s)\.member-error > text:nth-child\(2\)\s*\{[^}]*font-size:\s*24rpx;'
|
||||
'(?s)\.member-info-row text\s*\{[^}]*font-size:\s*23rpx;'
|
||||
)) {
|
||||
if ($profile -notmatch $rule) { throw "T03 readable visual rule missing: $rule" }
|
||||
if ($pages.T03 -notmatch $rule) { throw "T03 readable visual rule missing: $rule" }
|
||||
}
|
||||
|
||||
foreach ($entry in $forms.GetEnumerator()) {
|
||||
foreach ($token in @(
|
||||
'@include adaptive.adaptive-tree-panel;',
|
||||
'@include adaptive.adaptive-tree-field;',
|
||||
'grid-template-columns: auto minmax(0, 1fr)'
|
||||
)) {
|
||||
if (-not $entry.Value.Contains($token)) { throw "$($entry.Key) active form layout token missing: $token" }
|
||||
foreach ($key in @('T04', 'T05', 'T06')) {
|
||||
$content = $pages[$key]
|
||||
foreach ($token in @('@include adaptive.adaptive-tree-panel;', '@include adaptive.adaptive-tree-field;', 'grid-template-columns: auto minmax(0, 1fr)')) {
|
||||
if (-not $content.Contains($token)) { throw "$key active form layout token missing: $token" }
|
||||
}
|
||||
foreach ($rule in @(
|
||||
'(?s)\.form-copy,\s*\.form-note\s*\{[^}]*font-size:\s*24rpx;',
|
||||
'(?s)\.form-field[^{]*text:first-child\s*\{[^}]*font-size:\s*24rpx;',
|
||||
'(?s)\.form-field[^{]*(?:input|text:last-child)[^{]*\{[^}]*font-size:\s*24rpx;'
|
||||
)) {
|
||||
if ($entry.Value -notmatch $rule) { throw "$($entry.Key) readable visual rule missing: $rule" }
|
||||
}
|
||||
if ($entry.Value -match '(?m)\bposition\s*:') {
|
||||
throw "$($entry.Key) must keep active form content in document flow"
|
||||
if ($content -match '(?m)^\s*\.rank-form\s*\{[^}]*\bposition\s*:|(?m)^\s*\.add-relative-form\s*\{[^}]*\bposition\s*:|(?m)^\s*\.edit-member-form\s*\{[^}]*\bposition\s*:') {
|
||||
throw "$key must keep active content in document flow"
|
||||
}
|
||||
}
|
||||
|
||||
if ($pages.T06 -notmatch '(?s)\.form-copy\s*\{[^}]*font-size:\s*24rpx;') { throw 'T06 rank copy must remain readable' }
|
||||
|
||||
Write-Output 'T03-T06-ALL-STATES-VISUAL-CONTRACT PASS'
|
||||
|
||||
@@ -1,128 +1,35 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Read-Page([string]$RelativePath) {
|
||||
return [System.IO.File]::ReadAllText(
|
||||
(Join-Path (Split-Path -Parent $PSScriptRoot) $RelativePath),
|
||||
[System.Text.Encoding]::UTF8
|
||||
)
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$pages = @{
|
||||
T03 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t03-member-profile.vue') -Raw -Encoding UTF8
|
||||
T04 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t04-add-relative.vue') -Raw -Encoding UTF8
|
||||
T05 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t05-edit-member.vue') -Raw -Encoding UTF8
|
||||
T06 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t06-edit-relationship.vue') -Raw -Encoding UTF8
|
||||
}
|
||||
|
||||
function Assert-Contains([string]$Content, [string]$Expected, [string]$Message) {
|
||||
if (-not $Content.Contains($Expected)) { throw $Message }
|
||||
}
|
||||
|
||||
$t03 = Read-Page 'pages/tree/t03-member-profile.vue'
|
||||
$t04 = Read-Page 'pages/tree/t04-add-relative.vue'
|
||||
$t05 = Read-Page 'pages/tree/t05-edit-member.vue'
|
||||
$t06 = Read-Page 'pages/tree/t06-edit-relationship.vue'
|
||||
$t08 = Read-Page 'pages/tree/t08-member-states.vue'
|
||||
|
||||
Assert-Contains $t03 '{{ memberContextDescription }}' 'T03 must render its state-specific context description'
|
||||
if ($t03 -notmatch '(?s)const\s+memberContextDescription\s*=\s*computed\(\(\)\s*=>\s*\(\{\s*loading:\s*"\u6B63\u5728\u8BFB\u53D6\u6210\u5458\u8D44\u6599",\s*detail:\s*"\u6210\u5458\u8EAB\u4EFD\u4E0E\u4EB2\u5C5E\u5173\u7CFB",\s*restricted:\s*"\u9690\u79C1\u6210\u5458\u4EC5\u5C55\u793A\u57FA\u7840\u8EAB\u4EFD",\s*error:\s*"\u8BF7\u91CD\u65B0\u9009\u62E9\u6210\u5458"') {
|
||||
throw 'T03 must keep distinct loading, detail, restricted, and error context copy'
|
||||
}
|
||||
|
||||
foreach ($entry in @(
|
||||
@{ Name = 'T04'; Content = $t04 },
|
||||
@{ Name = 'T05'; Content = $t05 },
|
||||
@{ Name = 'T06'; Content = $t06 }
|
||||
)) {
|
||||
if ($entry.Content -match '<TreeMemberForm(?:\s|/|>)|import\s+TreeMemberForm') {
|
||||
throw "$($entry.Name) must own its business workflow instead of rendering TreeMemberForm"
|
||||
foreach ($entry in $pages.GetEnumerator()) {
|
||||
if ($entry.Value -match '<TreeMemberForm(?:\s|/|>)|import\s+TreeMemberForm') {
|
||||
throw "$($entry.Key) must own its workflow"
|
||||
}
|
||||
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
|
||||
if ($entry.Content.Contains($nativeUi)) { throw "$($entry.Name) must not use $nativeUi" }
|
||||
if ($entry.Value.Contains($nativeUi)) { throw "$($entry.Key) must not use $nativeUi" }
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($required in @(
|
||||
'const addState = ref("form")',
|
||||
'const addForm = reactive({',
|
||||
'const relationOptions = [',
|
||||
'const genderOptions = [',
|
||||
'const isFirstMember = computed(',
|
||||
'query.personId',
|
||||
'query.mode === "first"',
|
||||
'fieldErrors.name',
|
||||
'fieldErrors.relation',
|
||||
'add-state--form',
|
||||
'add-state--preview',
|
||||
'add-state--error',
|
||||
'class="add-relative-form"',
|
||||
'<picker',
|
||||
'<AppDialog'
|
||||
)) { Assert-Contains $t04 $required "T04 missing independent add-relative contract: $required" }
|
||||
$t04Style = [regex]::Match($t04, '(?s)<style[^>]*>(.*?)</style>').Groups[1].Value
|
||||
$t04PanelStyle = [regex]::Match($t04Style, '(?ms)^\s*\.add-relative-panel\s*\{(?<body>.*?)\}').Groups['body'].Value
|
||||
if ($t04PanelStyle -match '\bmin-height\s*:') {
|
||||
throw 'T04 add-relative panel height must be driven by its current content'
|
||||
foreach ($key in @('T04', 'T05', 'T06')) {
|
||||
$content = $pages[$key]
|
||||
foreach ($required in @('appApi.getPerson(', 'createRequestController', 'isRequestCancelled')) {
|
||||
if (-not $content.Contains($required)) { throw "$key missing remote read owner: $required" }
|
||||
}
|
||||
foreach ($forbidden in @('@/data/mock.js', 'setTimeout(', 'preview')) {
|
||||
if ($content.Contains($forbidden)) { throw "$key retains forbidden local preview token: $forbidden" }
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($required in @(
|
||||
'const editState = ref("form")',
|
||||
'const editForm = reactive({',
|
||||
'findTreeMemberPresentationFixture(genealogyId.value, id)',
|
||||
'const isDirty = computed(',
|
||||
'const discardDialogVisible = ref(false)',
|
||||
'query.personId',
|
||||
'edit-state--form',
|
||||
'edit-state--preview',
|
||||
'edit-state--error',
|
||||
'edit-state--no-permission',
|
||||
'class="edit-member-form"',
|
||||
'<AppDialog',
|
||||
'onBackPress('
|
||||
)) { Assert-Contains $t05 $required "T05 missing independent member-edit contract: $required" }
|
||||
$t05Style = [regex]::Match($t05, '(?s)<style[^>]*>(.*?)</style>').Groups[1].Value
|
||||
$t05PanelStyle = [regex]::Match($t05Style, '(?ms)^\s*\.edit-member-panel\s*\{(?<body>.*?)\}').Groups['body'].Value
|
||||
if ($t05PanelStyle -match '\bmin-height\s*:') {
|
||||
throw 'T05 edit-member panel height must be driven by its current content'
|
||||
if (-not $pages.T06.Contains('"unavailable"')) { throw 'T06 must expose a closed service state' }
|
||||
|
||||
foreach ($required in @('appApi.getPerson(', 'memberContextDescription', 'openPage("T08"')) {
|
||||
if (-not $pages.T03.Contains($required)) { throw "T03 missing member detail contract: $required" }
|
||||
}
|
||||
|
||||
foreach ($required in @(
|
||||
'const relationshipState = ref("form")',
|
||||
'const relationshipForm = reactive({',
|
||||
'const memberOptions = computed(',
|
||||
'const relationshipOptions = [',
|
||||
'const relationshipPreview = computed(',
|
||||
'const validateRelationship = () =>',
|
||||
'query.personId',
|
||||
'relationship-state--form',
|
||||
'relationship-state--preview',
|
||||
'relationship-state--conflict',
|
||||
'relationship-state--error',
|
||||
'class="relationship-form"',
|
||||
'<picker',
|
||||
'<AppDialog'
|
||||
)) { Assert-Contains $t06 $required "T06 missing independent relationship-edit contract: $required" }
|
||||
$t06Style = [regex]::Match($t06, '(?s)<style[^>]*>(.*?)</style>').Groups[1].Value
|
||||
$t06PanelStyle = [regex]::Match($t06Style, '(?ms)^\s*\.relationship-panel\s*\{(?<body>.*?)\}').Groups['body'].Value
|
||||
if ($t06PanelStyle -match '\bmin-height\s*:') {
|
||||
throw 'T06 relationship panel height must be driven by its current content'
|
||||
}
|
||||
|
||||
foreach ($required in @(
|
||||
'const genealogyId = ref("")',
|
||||
'const personId = ref("")',
|
||||
'const statusState = ref("loading")',
|
||||
'findTreeMemberPresentationFixture(genealogyId.value, personId.value)',
|
||||
'query.personId',
|
||||
'query.state',
|
||||
'member-status--privacy',
|
||||
'member-status--deceased',
|
||||
'member-status--forbidden',
|
||||
'member-status--error',
|
||||
'class="member-status-context"'
|
||||
)) { Assert-Contains $t08 $required "T08 missing member-driven status contract: $required" }
|
||||
if ($t08 -match 'class="status-tabs"|const\s+tabs\s*=|@click="statusState\s*=') {
|
||||
throw 'T08 must not let the user switch among demonstration states'
|
||||
}
|
||||
|
||||
foreach ($required in @(
|
||||
'findTreeMemberPresentationFixture(genealogyId.value, normalizedPersonId)',
|
||||
'query.personId',
|
||||
'const openMemberState = () =>',
|
||||
'openPage("T08", { genealogyId: genealogyId.value, personId: personId.value }, "T03")'
|
||||
)) { Assert-Contains $t03 $required "T03 missing semantic member-state entry: $required" }
|
||||
|
||||
Write-Output 'T03-T08-BUSINESS-SPECIALIZATION-CONTRACT PASS'
|
||||
Write-Output 'T03-T08-BUSINESS-SPECIALIZATION-CONTRACT PASS REMOTE-CLOSED'
|
||||
|
||||
@@ -1,285 +1,63 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Assert-Contains {
|
||||
param([string]$Content, [string]$Expected, [string]$Message)
|
||||
if (-not $Content.Contains($Expected)) { throw $Message }
|
||||
}
|
||||
|
||||
function Assert-NotContains {
|
||||
param([string]$Content, [string]$Unexpected, [string]$Message)
|
||||
if ($Content.Contains($Unexpected)) { throw $Message }
|
||||
}
|
||||
|
||||
function Assert-Matches {
|
||||
param([string]$Content, [string]$Pattern, [string]$Message)
|
||||
if ($Content -notmatch $Pattern) { throw $Message }
|
||||
}
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$profiles = Get-Content -LiteralPath (Join-Path $root 'styles/adaptive-frame-profiles.scss') -Raw -Encoding utf8
|
||||
$mock = Get-Content -LiteralPath (Join-Path $root 'data/mock.js') -Raw -Encoding utf8
|
||||
$api = Get-Content -LiteralPath (Join-Path $root 'utils/api.js') -Raw -Encoding utf8
|
||||
$paths = @(
|
||||
'pages/tree/t01-tree-overview.vue',
|
||||
'pages/tree/t03-member-profile.vue',
|
||||
'pages/tree/t04-add-relative.vue',
|
||||
'pages/tree/t05-edit-member.vue',
|
||||
'pages/tree/t06-edit-relationship.vue',
|
||||
'pages/tree/t07-member-directory.vue',
|
||||
'pages/tree/t08-member-states.vue'
|
||||
)
|
||||
$pages = @{}
|
||||
foreach ($path in $paths) {
|
||||
$content = Get-Content -LiteralPath (Join-Path $root $path) -Raw -Encoding utf8
|
||||
if ($content -match '<ModulePage(?:\s|>)') { throw "$path must not retain ModulePage" }
|
||||
if ($content -match "@/utils/api\.js|\bappApi\b") { throw "$path must not connect the API layer in navigation task 6" }
|
||||
if ($content -match '\buni\.(?:navigateTo|navigateBack|redirectTo|reLaunch|switchTab)\b') {
|
||||
throw "$path must use the navigation gateway exclusively"
|
||||
$pages = @{
|
||||
T01 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding UTF8
|
||||
T03 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t03-member-profile.vue') -Raw -Encoding UTF8
|
||||
T04 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t04-add-relative.vue') -Raw -Encoding UTF8
|
||||
T05 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t05-edit-member.vue') -Raw -Encoding UTF8
|
||||
T06 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t06-edit-relationship.vue') -Raw -Encoding UTF8
|
||||
T07 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t07-member-directory.vue') -Raw -Encoding UTF8
|
||||
T08 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t08-member-states.vue') -Raw -Encoding UTF8
|
||||
}
|
||||
|
||||
foreach ($entry in $pages.GetEnumerator()) {
|
||||
if ($entry.Value -match '\buni\.(?:navigateTo|navigateBack|redirectTo|reLaunch|switchTab)\b') {
|
||||
throw "$($entry.Key) must use the navigation gateway exclusively"
|
||||
}
|
||||
if ($entry.Value.Contains('/pages/')) {
|
||||
throw "$($entry.Key) must not own route literals"
|
||||
}
|
||||
if ($content.Contains('/pages/')) { throw "$path must not own route literals" }
|
||||
$pages[$path] = $content
|
||||
}
|
||||
|
||||
foreach ($path in @('pages/tree/t01-tree-overview.vue', 'pages/tree/t03-member-profile.vue', 'pages/tree/t07-member-directory.vue', 'pages/tree/t08-member-states.vue')) {
|
||||
Assert-Contains $pages[$path] 'ModulePageBackground' "$path must use the tree module background"
|
||||
}
|
||||
|
||||
$t01 = $pages['pages/tree/t01-tree-overview.vue']
|
||||
Assert-NotContains $t01 'consumeNavigationResult(' 'T01 must not pre-register a mutation consumer before real writes exist'
|
||||
foreach ($mapping in @(
|
||||
'openPage("T03", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")',
|
||||
'openPage("T07", { genealogyId: genealogyId.value }, "T01")',
|
||||
'openPage("T06", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")',
|
||||
'openPage("T04", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")'
|
||||
)) {
|
||||
Assert-Contains $t01 $mapping "T01 missing gateway target: $mapping"
|
||||
}
|
||||
Assert-Matches $t01 '(?s)openPage\(\s*"T04",\s*\{ genealogyId: genealogyId\.value, mode: "first" \},\s*"T01",?\s*\)' 'T01 empty state must open first-member mode through T04 route contract'
|
||||
|
||||
$t03 = $pages['pages/tree/t03-member-profile.vue']
|
||||
foreach ($required in @(
|
||||
'member-state--detail',
|
||||
'member-state--restricted',
|
||||
'member-state--error',
|
||||
'adaptive.adaptive-tree-panel',
|
||||
'adaptive.adaptive-tree-field',
|
||||
'<PageHeader title=',
|
||||
'custom-back',
|
||||
'@back="requestBack"',
|
||||
'appApi.getTree',
|
||||
'const memberActions = Object.freeze([',
|
||||
'const openMemberAction = (action) =>',
|
||||
'openPage("T03"',
|
||||
'routeKey: "T04"',
|
||||
'routeKey: "T05"',
|
||||
'routeKey: "T06"',
|
||||
'return openPage(action.routeKey, params, "T01")'
|
||||
)) { Assert-Contains $pages.T01 $required "T01 missing member flow contract: $required" }
|
||||
|
||||
foreach ($required in @(
|
||||
'appApi.getPerson(',
|
||||
'const memberTrail = reactive([]);',
|
||||
'const trailIndex = ref(-1);',
|
||||
'const loadMember = async (nextPersonId) =>',
|
||||
'const initializeMemberTrail = async (initialPersonId) =>',
|
||||
'memberTrail.splice(0, memberTrail.length, normalizedPersonId);',
|
||||
'trailIndex.value = 0;',
|
||||
'const openRelative = async (nextPersonId) =>',
|
||||
'memberTrail.splice(trailIndex.value + 1);',
|
||||
'memberTrail.push(normalizedPersonId);',
|
||||
'trailIndex.value = memberTrail.length - 1;',
|
||||
'const popMemberTrail = async () =>',
|
||||
'memberTrail.splice(targetIndex, 1);',
|
||||
'internalTrail: trailIndex.value > 0',
|
||||
'"pop-internal-trail": popMemberTrail',
|
||||
'consumeNavigationResult("T03")',
|
||||
'result?.operation === "member-open-requested"',
|
||||
'onBackPress((event) => handleBackPress(event, requestBack));',
|
||||
'openPage("T05", { genealogyId: genealogyId.value, personId: personId.value }, "T03")',
|
||||
'openPage("T08", { genealogyId: genealogyId.value, personId: personId.value }, "T03")'
|
||||
)) {
|
||||
Assert-Contains $t03 $required "Missing T03 single-instance trail contract: $required"
|
||||
}
|
||||
Assert-Matches $t03 '(?s)const openRelative = async \(nextPersonId\) => \{.*?const loaded = await loadMember\(normalizedPersonId\);\s*if \(!loaded\) return false;.*?memberTrail\.push\(normalizedPersonId\)' 'T03 must append a relative only after a successful read'
|
||||
Assert-Matches $t03 '(?s)const initializeMemberTrail = async \(initialPersonId\) => \{\s*memberTrail\.splice\(0, memberTrail\.length\);\s*trailIndex\.value = -1;.*?if \(!loaded\) return false;.*?memberTrail\.splice\(0, memberTrail\.length, normalizedPersonId\)' 'T03 initial failure must leave the trail empty'
|
||||
Assert-Contains $t03 ':data-current-person-id="personId"' 'T03 runtime smoke needs a semantic current-person marker'
|
||||
Assert-Contains $t03 ':data-trail-length="memberTrail.length"' 'T03 runtime smoke must observe trail initialization without internal access'
|
||||
Assert-Contains $t03 ':data-trail-index="trailIndex"' 'T03 runtime smoke must observe the current trail position'
|
||||
Assert-Contains $t03 ':data-person-id="relative.id"' 'T03 relatives need stable lexical identity in the rendered list'
|
||||
Assert-NotContains $t03 'genealogyContext' 'T03 must not fall back to mutable global genealogy context'
|
||||
Assert-Matches $t03 '(?s)const restricted\s*=\s*\["privacy",\s*"forbidden"\]\.includes\(fixture\.status\).*?memberState\.value\s*=\s*restricted\s*\?\s*"restricted"\s*:\s*"detail"' 'T03 must derive restricted presentation from the scoped member status'
|
||||
Assert-Contains $t03 'const canPreviewEdit = computed(() => memberState.value === "detail");' 'T03 may expose only a clearly local preview entry for unrestricted detail state'
|
||||
Assert-NotContains $t03 'canEdit' 'T03 must not treat a fixture field as a backend edit capability'
|
||||
'onUnload(() =>',
|
||||
'memberRequestController.abort()'
|
||||
)) { Assert-Contains $pages.T03 $required "T03 missing remote member trail contract: $required" }
|
||||
|
||||
$specializedForms = @{
|
||||
't04-add-relative.vue' = @('add-state--form', 'add-state--preview', 'add-relative-form', 'relationOptions')
|
||||
't05-edit-member.vue' = @('edit-state--form', 'edit-state--preview', 'edit-member-form', 'findTreeMemberPresentationFixture')
|
||||
't06-edit-relationship.vue' = @('relationship-state--form', 'relationship-state--preview', 'relationship-form', 'relationshipOptions')
|
||||
}
|
||||
foreach ($entry in $specializedForms.GetEnumerator()) {
|
||||
$content = $pages["pages/tree/$($entry.Key)"]
|
||||
if ($content -match '<TreeMemberForm(?:\s|/|>)|import\s+TreeMemberForm') {
|
||||
throw "$($entry.Key) must own its business workflow instead of TreeMemberForm"
|
||||
foreach ($key in @('T04', 'T05', 'T06')) {
|
||||
$content = $pages[$key]
|
||||
foreach ($required in @('appApi.getPerson(', 'createRequestController', 'isRequestCancelled', 'onUnload(() =>')) {
|
||||
Assert-Contains $content $required "$key missing remote closed-flow contract: $required"
|
||||
}
|
||||
foreach ($required in $entry.Value) {
|
||||
Assert-Contains -Content $content -Expected $required -Message ("Missing specialized form contract in {0}: {1}" -f $entry.Key, $required)
|
||||
}
|
||||
foreach ($required in @(
|
||||
'createDiscardConfirmation',
|
||||
'handleBackPress',
|
||||
'runBackGuard',
|
||||
'submitting: isSubmitting.value',
|
||||
'"block-submitting"',
|
||||
'onBackPress((event) => handleBackPress(event, requestBack));',
|
||||
'discardConfirmation.dispose();'
|
||||
)) {
|
||||
Assert-Contains -Content $content -Expected $required -Message ("{0} missing shared guarded local-preview contract: {1}" -f $entry.Key, $required)
|
||||
}
|
||||
Assert-NotContains -Content $content -Unexpected 'finishPage(' -Message ("{0} must not emit a server-success result before a real write succeeds" -f $entry.Key)
|
||||
foreach ($operation in @('relative-created', 'member-updated', 'relationship-updated')) {
|
||||
Assert-NotContains -Content $content -Unexpected $operation -Message ("{0} must not retain a pre-API mutation operation" -f $entry.Key)
|
||||
}
|
||||
Assert-Matches -Content $content -Pattern '\u5c1a\u672a\u63d0\u4ea4\u670d\u52a1\u5668' -Message ("{0} must disclose that its preview is not submitted" -f $entry.Key)
|
||||
}
|
||||
Assert-Contains -Content ($pages['pages/tree/t04-add-relative.vue']) -Expected 'returnTo("T01", { genealogyId: genealogyId.value })' -Message 'T04 local preview must return to T01 without a mutation result'
|
||||
Assert-Contains -Content ($pages['pages/tree/t05-edit-member.vue']) -Expected 'return goBack();' -Message 'T05 must preserve the existing single T03 route identity when leaving a relative preview'
|
||||
Assert-NotContains -Content ($pages['pages/tree/t05-edit-member.vue']) -Unexpected 'returnTo("T03"' -Message 'T05 must not replace the host T03 initial personId with its current in-page member'
|
||||
Assert-Contains -Content ($pages['pages/tree/t06-edit-relationship.vue']) -Expected 'returnTo("T01", { genealogyId: genealogyId.value })' -Message 'T06 local preview must return to T01 without a mutation result'
|
||||
|
||||
foreach ($submission in @(
|
||||
@{ Key = 'T04'; Content = $pages['pages/tree/t04-add-relative.vue']; Function = 'submitAdd'; State = 'addState.value' },
|
||||
@{ Key = 'T05'; Content = $pages['pages/tree/t05-edit-member.vue']; Function = 'saveMember'; State = 'editState.value' },
|
||||
@{ Key = 'T06'; Content = $pages['pages/tree/t06-edit-relationship.vue']; Function = 'saveRelationship'; State = 'relationshipState.value' }
|
||||
)) {
|
||||
$body = [regex]::Match(
|
||||
$submission.Content,
|
||||
"(?s)const $($submission.Function) = \(\) => \{.*?`n\};"
|
||||
).Value
|
||||
if (-not $body) { throw "$($submission.Key) submission function is not statically auditable" }
|
||||
if (-not $body.Contains('"preview"')) { throw "$($submission.Key) valid submission must stop at local preview" }
|
||||
if ($body -match '\b(?:returnTo|goBack|finishPage)\s*\(') {
|
||||
throw "$($submission.Key) local validation must not navigate automatically"
|
||||
}
|
||||
}
|
||||
if (([regex]::Matches($pages['pages/tree/t05-edit-member.vue'], 'baseline\.value = formSnapshot\.value;')).Count -ne 1) {
|
||||
throw 'T05 local preview must not clear the dirty baseline before a server write exists'
|
||||
}
|
||||
|
||||
$t07 = $pages['pages/tree/t07-member-directory.vue']
|
||||
foreach ($required in @(
|
||||
'directory-state--list',
|
||||
'directory-state--empty',
|
||||
'directory-state--error',
|
||||
'adaptive.adaptive-genealogy-list-card',
|
||||
'directory-card__status',
|
||||
'listTreeMemberPresentationFixtures',
|
||||
'const isRestrictedMember =',
|
||||
'memberMeta(item)',
|
||||
'memberStatus(item)',
|
||||
'openPage("T03", { genealogyId: genealogyId.value, personId: String(item.id) }, "T07")'
|
||||
)) {
|
||||
Assert-Contains $t07 $required "Missing T07 contract: $required"
|
||||
}
|
||||
foreach ($privateTemplateRead in @('{{ item.generationName }}', '{{ item.branch }}', '{{ item.note }}')) {
|
||||
Assert-NotContains $t07 $privateTemplateRead "T07 must not render a restricted member private field directly: $privateTemplateRead"
|
||||
}
|
||||
foreach ($required in @(
|
||||
'const hasValidContext = computed(() => Boolean(genealogyId.value));',
|
||||
'!hasValidContext.value ? "error"',
|
||||
'v-if="hasValidContext"'
|
||||
)) {
|
||||
Assert-Contains $t07 $required "T07 must fail closed without genealogy context: $required"
|
||||
}
|
||||
|
||||
$t08 = $pages['pages/tree/t08-member-states.vue']
|
||||
foreach ($required in @(
|
||||
'member-status--privacy',
|
||||
'member-status--deceased',
|
||||
'member-status--forbidden',
|
||||
'adaptive.adaptive-tree-panel',
|
||||
'memberIdentityCopy',
|
||||
'姓名、世代与家族关系可见',
|
||||
'goRoot("G01")',
|
||||
'goBack()'
|
||||
)) {
|
||||
Assert-Contains $t08 $required "Missing T08 contract: $required"
|
||||
}
|
||||
Assert-NotContains $t08 '{{ member.branch }}' 'T08 must not assume a restricted member branch is visible'
|
||||
Assert-NotContains $t08 'genealogyContext' 'T08 must not fall back to mutable global genealogy context'
|
||||
foreach ($required in @(
|
||||
'const hasValidContext = computed(() => Boolean(genealogyId.value && personId.value));',
|
||||
'member.value = hasValidContext.value'
|
||||
)) {
|
||||
Assert-Contains $t08 $required "T08 must fail closed without its complete route identity: $required"
|
||||
}
|
||||
|
||||
$t06 = $pages['pages/tree/t06-edit-relationship.vue']
|
||||
foreach ($required in @(
|
||||
'const hasValidContext = computed(',
|
||||
'Boolean(genealogyId.value && memberById.value.has(personId.value))',
|
||||
'relationshipForm.sourceId = hasValidContext.value ? personId.value : "";',
|
||||
'relationshipState.value = !hasValidContext.value || query.state === "error"'
|
||||
)) {
|
||||
Assert-Contains $t06 $required "T06 must fail closed without a valid routed member: $required"
|
||||
}
|
||||
Assert-NotContains $t06 ': memberOptions[0].id' 'T06 must never replace a missing routed person with the first fixture member'
|
||||
|
||||
$treeOverview = $pages['pages/tree/t01-tree-overview.vue']
|
||||
foreach ($page in @(
|
||||
@{ Key = 'T01'; Content = $treeOverview },
|
||||
@{ Key = 'T03'; Content = $t03 },
|
||||
@{ Key = 'T04'; Content = $pages['pages/tree/t04-add-relative.vue'] },
|
||||
@{ Key = 'T05'; Content = $pages['pages/tree/t05-edit-member.vue'] },
|
||||
@{ Key = 'T06'; Content = $t06 },
|
||||
@{ Key = 'T07'; Content = $t07 },
|
||||
@{ Key = 'T08'; Content = $t08 }
|
||||
)) {
|
||||
Assert-Contains $page.Content '@/data/mock.js' "$($page.Key) must consume the shared tree-member fixture owner"
|
||||
if ($page.Content -match 'import\s*\{[^}]*\btreeMembers\b[^}]*\}\s*from\s*["'']@/data/mock\.js["'']') {
|
||||
throw "$($page.Key) must consume snapshots instead of the mutable treeMembers store"
|
||||
}
|
||||
}
|
||||
foreach ($localOwner in @(
|
||||
@{ Key = 'T01'; Content = $treeOverview; Pattern = 'const\s+members\s*=\s*ref\(\[\s*\{' },
|
||||
@{ Key = 'T03'; Content = $t03; Pattern = 'const\s+memberFixtures\s*=' },
|
||||
@{ Key = 'T04'; Content = $pages['pages/tree/t04-add-relative.vue']; Pattern = 'const\s+memberFixtures\s*=' },
|
||||
@{ Key = 'T05'; Content = $pages['pages/tree/t05-edit-member.vue']; Pattern = 'const\s+memberFixtures\s*=' },
|
||||
@{ Key = 'T06'; Content = $t06; Pattern = 'const\s+memberOptions\s*=\s*\[' },
|
||||
@{ Key = 'T07'; Content = $t07; Pattern = 'const\s+members\s*=\s*\[' },
|
||||
@{ Key = 'T08'; Content = $t08; Pattern = 'const\s+memberFixtures\s*=' }
|
||||
)) {
|
||||
if ($localOwner.Content -match $localOwner.Pattern) {
|
||||
throw "$($localOwner.Key) retains a competing local tree-member fixture owner"
|
||||
foreach ($forbidden in @('@/data/mock.js', 'setTimeout(')) {
|
||||
if ($content.Contains($forbidden)) { throw "$key retains local preview owner: $forbidden" }
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($ownerContract in @(
|
||||
'export const treeMembers = [',
|
||||
'export const listTreeMemberFixtures = (genealogyId) =>',
|
||||
'export const findTreeMemberFixture = (genealogyId, personId) =>',
|
||||
'relatives: member.relatives.map((relative) => ({ ...relative }))'
|
||||
)) {
|
||||
Assert-Contains $mock $ownerContract "Shared tree-member owner missing: $ownerContract"
|
||||
}
|
||||
Assert-Matches $api '(?s)import\s*\{[^}]*\btreeMembers\b[^}]*\}\s*from\s*''@/data/mock\.js''' 'utils/api.js must remain the only mutable tree-member store consumer'
|
||||
foreach ($sourceRoot in @('pages', 'components', 'utils')) {
|
||||
Get-ChildItem -LiteralPath (Join-Path $root $sourceRoot) -Recurse -File |
|
||||
Where-Object { $_.Extension -in @('.js', '.vue') -and $_.FullName -ne (Join-Path $root 'utils/api.js') } |
|
||||
ForEach-Object {
|
||||
$source = Get-Content -LiteralPath $_.FullName -Raw -Encoding utf8
|
||||
if ($source -match 'import\s*\{[^}]*\btreeMembers\b[^}]*\}\s*from\s*["'']@/data/mock\.js["'']') {
|
||||
throw "$($_.FullName) bypasses the tree-member snapshot selectors"
|
||||
}
|
||||
}
|
||||
}
|
||||
Assert-Contains $pages.T04 'relationType.value' 'T04 must accept the validated relation intent from T01'
|
||||
$routes = Get-Content -LiteralPath (Join-Path $root 'utils/navigation-routes.js') -Raw -Encoding UTF8
|
||||
Assert-Contains $routes 'allowedSources: ["T01", "T03"]' 'T05 route source contract drifted'
|
||||
Assert-Contains $pages.T06 'query.mode !== "rank"' 'T06 must reject non-rank entry modes'
|
||||
|
||||
foreach ($entry in @(
|
||||
@{ Key = 'T01'; Content = $treeOverview; Expected = 'listTreeMemberFixtures(genealogyId.value)' },
|
||||
@{ Key = 'T03'; Content = $t03; Expected = 'findTreeMemberPresentationFixture(genealogyId.value, normalizedPersonId)' },
|
||||
@{ Key = 'T04'; Content = $pages['pages/tree/t04-add-relative.vue']; Expected = 'findTreeMemberFixture(genealogyId.value, personId.value)' },
|
||||
@{ Key = 'T05'; Content = $pages['pages/tree/t05-edit-member.vue']; Expected = 'findTreeMemberPresentationFixture(genealogyId.value, id)' },
|
||||
@{ Key = 'T06'; Content = $t06; Expected = 'listTreeMemberFixtures(genealogyId.value)' },
|
||||
@{ Key = 'T07'; Content = $t07; Expected = 'listTreeMemberPresentationFixtures(genealogyId.value)' },
|
||||
@{ Key = 'T08'; Content = $t08; Expected = 'findTreeMemberPresentationFixture(genealogyId.value, personId.value)' }
|
||||
)) {
|
||||
Assert-Contains $entry.Content $entry.Expected "$($entry.Key) must scope fixture reads by genealogy and member identity"
|
||||
}
|
||||
|
||||
Assert-Contains $pages['pages/tree/t04-add-relative.vue'] 'const hasValidContext = computed(' 'T04 must own an explicit route-context gate'
|
||||
Assert-Contains $pages['pages/tree/t04-add-relative.vue'] 'isFirstMember.value ? !personId.value : Boolean(currentMember.value)' 'T04 must only allow a missing personId in first-member mode'
|
||||
Assert-Contains $pages['pages/tree/t05-edit-member.vue'] 'if (!member) return false;' 'T05 must reject unknown members instead of synthesizing an editable identity'
|
||||
Assert-Matches $pages['pages/tree/t05-edit-member.vue'] '(?s)\["privacy",\s*"forbidden"\]\.includes\(originalMember\.value\.status\).*?"no-permission"' 'T05 deep links must not edit a restricted member without a backend capability'
|
||||
|
||||
foreach ($asset in @('t01-state-panel.png', 't07-search-input-frame.png', 'list-slip-frame.png')) {
|
||||
Assert-Contains $profiles $asset "Adaptive tree profile missing: $asset"
|
||||
}
|
||||
|
||||
Write-Output 'T03-T08-MEMBER-FLOW-CONTRACT PASS NAVIGATION TRAIL LOCAL-PREVIEW'
|
||||
Write-Output 'T03-T08-MEMBER-FLOW-CONTRACT PASS REMOTE-WRITE'
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t04-add-relative.vue') -Raw -Encoding UTF8
|
||||
|
||||
foreach ($required in @(
|
||||
'appApi.getPerson(',
|
||||
'createRequestController',
|
||||
'isRequestCancelled',
|
||||
'addRequestController.abort()',
|
||||
'let loadSequence = 0',
|
||||
'relationType.value',
|
||||
'const relationIntents = Object.freeze({',
|
||||
'appApi.createPerson(',
|
||||
'appApi.createRelatedPerson(',
|
||||
'await returnTo("T01", { genealogyId: genealogyId.value })'
|
||||
)) {
|
||||
if (-not $page.Contains($required)) {
|
||||
throw "T04 remote close contract missing: $required"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($forbidden in @(
|
||||
'@/data/mock.js',
|
||||
'findTreeMemberFixture',
|
||||
'setTimeout(',
|
||||
'addState.value = "preview"',
|
||||
'addState.value = "unavailable"'
|
||||
)) {
|
||||
if ($page.Contains($forbidden)) {
|
||||
throw "T04 must not retain local preview owner: $forbidden"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output 'T04_RELATIVE_REMOTE_WRITE_CONTRACT PASS'
|
||||
@@ -0,0 +1,33 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t05-edit-member.vue') -Raw -Encoding UTF8
|
||||
|
||||
foreach ($required in @(
|
||||
'appApi.getPerson(',
|
||||
'createRequestController',
|
||||
'isRequestCancelled',
|
||||
'editRequestController.abort()',
|
||||
'let loadSequence = 0',
|
||||
'appApi.updatePerson(',
|
||||
'failedAction.value = "save"'
|
||||
)) {
|
||||
if (-not $page.Contains($required)) {
|
||||
throw "T05 remote close contract missing: $required"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($forbidden in @(
|
||||
'@/data/mock.js',
|
||||
'findTreeMemberPresentationFixture',
|
||||
'setTimeout(',
|
||||
'editState.value = "preview"',
|
||||
'"no-permission"',
|
||||
'editState.value = "unavailable"'
|
||||
)) {
|
||||
if ($page.Contains($forbidden)) {
|
||||
throw "T05 must not retain local preview owner: $forbidden"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output 'T05_MEMBER_REMOTE_WRITE_CONTRACT PASS'
|
||||
@@ -0,0 +1,33 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t06-edit-relationship.vue') -Raw -Encoding UTF8
|
||||
|
||||
foreach ($required in @(
|
||||
'class="rank-page"',
|
||||
'appApi.getPerson(',
|
||||
'createRequestController',
|
||||
'isRequestCancelled',
|
||||
'rankRequestController.abort()',
|
||||
'let loadSequence = 0',
|
||||
'query.mode !== "rank"',
|
||||
'rankState.value = "unavailable"'
|
||||
)) {
|
||||
if (-not $page.Contains($required)) {
|
||||
throw "T06 rank remote close contract missing: $required"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($forbidden in @(
|
||||
'@/data/mock.js',
|
||||
'listTreeMemberFixtures',
|
||||
'setTimeout(',
|
||||
'relationshipOptions',
|
||||
'relationshipState'
|
||||
)) {
|
||||
if ($page.Contains($forbidden)) {
|
||||
throw "T06 must not retain relationship local-preview owner: $forbidden"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output 'T06-RANK-REMOTE-CLOSE-CONTRACT PASS'
|
||||
@@ -90,50 +90,10 @@ class FakeTac {
|
||||
|
||||
const host = { innerHTML: "rendered" };
|
||||
const loadedStyle = { dataset: { jiapuState: "loaded" }, sheet: {} };
|
||||
const backgroundControl = {
|
||||
focusCount: 0,
|
||||
focus() {
|
||||
this.focusCount += 1;
|
||||
document.activeElement = this;
|
||||
},
|
||||
};
|
||||
const refreshControl = {
|
||||
offsetParent: {},
|
||||
focus() {
|
||||
document.activeElement = this;
|
||||
},
|
||||
};
|
||||
const closeControl = {
|
||||
offsetParent: {},
|
||||
focus() {
|
||||
document.activeElement = this;
|
||||
},
|
||||
};
|
||||
const dialog = {
|
||||
offsetParent: {},
|
||||
listener: null,
|
||||
focus() {
|
||||
document.activeElement = this;
|
||||
},
|
||||
querySelector(selector) {
|
||||
return selector === ".tac-tool--refresh" ? refreshControl : null;
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [refreshControl, closeControl];
|
||||
},
|
||||
addEventListener(name, listener) {
|
||||
if (name === "keydown") this.listener = listener;
|
||||
},
|
||||
removeEventListener(name, listener) {
|
||||
if (name === "keydown" && this.listener === listener) this.listener = null;
|
||||
},
|
||||
};
|
||||
const document = {
|
||||
activeElement: backgroundControl,
|
||||
head: { appendChild() {} },
|
||||
querySelector(selector) {
|
||||
if (selector === "#jiapu-tac-host") return host;
|
||||
if (selector === "#jiapu-tac-dialog") return dialog;
|
||||
if (selector.startsWith("link[data-jiapu-tac=")) return loadedStyle;
|
||||
return null;
|
||||
},
|
||||
@@ -228,23 +188,12 @@ const run = async () => {
|
||||
subject: "13800138000",
|
||||
};
|
||||
instance.context = context;
|
||||
instance.previousFocus = backgroundControl;
|
||||
instance.generation += 1;
|
||||
instance.createTac();
|
||||
assert.strictEqual(latestTac.initialized, true);
|
||||
assert.strictEqual(typeof latestTac.config.doSendRequest, "function");
|
||||
assert.strictEqual(document.activeElement, refreshControl, "打开 TAC 后必须把焦点移入对话框");
|
||||
document.activeElement = closeControl;
|
||||
let tabPrevented = false;
|
||||
dialog.listener({
|
||||
key: "Tab",
|
||||
shiftKey: false,
|
||||
preventDefault() {
|
||||
tabPrevented = true;
|
||||
},
|
||||
});
|
||||
assert.strictEqual(tabPrevented, true, "TAC 末项 Tab 必须被焦点环截获");
|
||||
assert.strictEqual(document.activeElement, refreshControl);
|
||||
latestTac.config.options.btnRefreshFun(null, latestTac);
|
||||
assert.strictEqual(latestTac.reloadCount, 1, "必须保留供应商原生刷新操作");
|
||||
|
||||
const firstSuccess = latestTac.config.options.validSuccess;
|
||||
firstSuccess(
|
||||
@@ -283,7 +232,6 @@ const run = async () => {
|
||||
await instance.onContextChange({ visible: false });
|
||||
assert.strictEqual(instance.tac, null, "隐藏或卸载验证层必须销毁 SDK 实例");
|
||||
assert.strictEqual(host.innerHTML, "", "隐藏或卸载验证层必须清空宿主节点");
|
||||
assert.strictEqual(backgroundControl.focusCount, 1, "关闭 TAC 后必须恢复进入前焦点");
|
||||
|
||||
console.log("TAC-RENDERJS-RUNTIME-SMOKE PASS");
|
||||
};
|
||||
|
||||
@@ -1,32 +1,39 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$component = Get-Content -LiteralPath (Join-Path $root 'components/TacVerification.vue') -Raw -Encoding UTF8
|
||||
|
||||
foreach ($token in @(
|
||||
'v-show="visible"', 'id="jiapu-tac-dialog"', 'role="dialog"', 'aria-modal="true"',
|
||||
'tabindex="-1"', 'aria-labelledby="jiapu-tac-title"',
|
||||
'aria-describedby="jiapu-tac-description"', '@keydown.esc.stop.prevent="requestCancel"',
|
||||
'class="tac-tool tac-tool--refresh"', 'aria-label="刷新安全验证"',
|
||||
'class="tac-tool tac-tool--close"', 'aria-label="关闭安全验证"',
|
||||
'const previousFocus = document.activeElement', 'activateFocusTrap()',
|
||||
'deactivateFocusTrap(', 'event.key !== "Tab"', 'previousFocus?.focus?.()',
|
||||
':deep(.slider-bottom .close-btn)', ':deep(.slider-bottom .refresh-btn)',
|
||||
'min-width: 48px;', 'min-height: 48px;'
|
||||
'v-show="visible"',
|
||||
'id="jiapu-tac-host"',
|
||||
':prop="renderContext"',
|
||||
':change:prop="tacRenderer.onContextChange"',
|
||||
'btnCloseFun:',
|
||||
'btnRefreshFun:',
|
||||
'this.tac = new window.TAC(config);'
|
||||
)) {
|
||||
if (-not $component.Contains($token)) { throw "TAC 外壳无障碍预检缺少:$token" }
|
||||
if (-not $component.Contains($token)) { throw "Missing native TAC shell token: $token" }
|
||||
}
|
||||
|
||||
$logicalScript = [regex]::Match($component, '(?s)<script>(?<Body>.*?)</script>').Groups['Body'].Value
|
||||
foreach ($browserOnly in @('document.', 'window.', 'querySelector(')) {
|
||||
if ($logicalScript.Contains($browserOnly)) {
|
||||
throw "TAC 逻辑层不得访问浏览器专属对象:$browserOnly"
|
||||
}
|
||||
foreach ($forbidden in @(
|
||||
'tac-panel',
|
||||
'tac-heading',
|
||||
'tac-tool',
|
||||
'slider-bottom .close-btn',
|
||||
'slider-bottom .refresh-btn',
|
||||
'logoUrl:',
|
||||
'i18n:',
|
||||
'new window.TAC(config, {'
|
||||
)) {
|
||||
if ($component.Contains($forbidden)) { throw "TAC overrides vendor rendering: $forbidden" }
|
||||
}
|
||||
|
||||
$toolButtons = [regex]::Matches($component, '(?s)<button\b[^>]*class="[^"]*tac-tool\b')
|
||||
if ($toolButtons.Count -lt 2) { throw 'TAC 外壳至少要有刷新与关闭两个原生操作' }
|
||||
if ([regex]::Matches($component, 'class="tac-tool tac-tool--refresh"').Count -ne 1) { throw 'TAC 刷新操作必须有唯一 owner' }
|
||||
if ([regex]::Matches($component, 'class="tac-tool tac-tool--close"').Count -ne 1) { throw 'TAC 关闭操作必须有唯一 owner' }
|
||||
$styleStart = $component.IndexOf('<style scoped>')
|
||||
if ($styleStart -lt 0) { throw 'Missing minimal TAC mount style' }
|
||||
$style = $component.Substring($styleStart)
|
||||
$visualTokens = @('background:', 'border:', 'border-radius:', 'box-shadow:', 'color:', 'font-', 'padding:', 'opacity:', 'transition:')
|
||||
foreach ($forbidden in $visualTokens) {
|
||||
if ($style.Contains($forbidden)) { throw "TAC mount adds visual styling: $forbidden" }
|
||||
}
|
||||
|
||||
Write-Output 'TAC-SHELL-ACCESSIBILITY-CONTRACT PASS'
|
||||
Write-Output 'TAC-VENDOR-NATIVE-SHELL-CONTRACT PASS'
|
||||
|
||||
@@ -140,8 +140,8 @@ const run = async () => {
|
||||
"const AUTH_TAC_SCENE = {}; const assertSmsCode = (value) => value;\n",
|
||||
)
|
||||
.replace(
|
||||
/^import \{ GENEALOGY_ACCESS_PRESET \}[^\n]+\r?\n/m,
|
||||
"const GENEALOGY_ACCESS_PRESET = { MEMBER_ONLY: 'MEMBER_ONLY' };\n",
|
||||
/^import \{ GENEALOGY_ACCESS_PRESET, fromApiGenealogyAccess \}[^\n]+\r?\n/m,
|
||||
"const GENEALOGY_ACCESS_PRESET = { MEMBER_ONLY: 'MEMBER_ONLY' }; const fromApiGenealogyAccess = () => GENEALOGY_ACCESS_PRESET.MEMBER_ONLY;\n",
|
||||
)
|
||||
.replace(
|
||||
/^import \{ session \}[^\n]+\r?\n/m,
|
||||
|
||||
Reference in New Issue
Block a user