验收20%

This commit is contained in:
2026-07-16 07:42:07 +08:00
parent 0dec90ffdd
commit cb25317412
108 changed files with 6477 additions and 1308 deletions
+19 -9
View File
@@ -56,10 +56,8 @@ $entry = Get-Content -LiteralPath $entryPath -Raw -Encoding utf8
# Final decorative surfaces must come from real bitmap assets.
foreach ($asset in @(
'a01-login-header-v1.png',
'a01-login-header-exact-v2.png',
'auth-ink-scenery.png',
'a01-login-scroll-frame-v1.png',
'a01-red-hall-ink-backdrop-v1.png',
'brand-seal.png',
'a01-title-divider-v2.png',
'a01-primary-button.png',
'a01-secondary-button.png',
@@ -74,8 +72,16 @@ foreach ($asset in @(
)) {
Assert-Contains -Content $entry -Expected $asset -Message "A01 is missing asset reference: $asset"
}
foreach ($obsoleteHeaderAsset in @('auth-header.png', 'brand-seal.png', 'a01-paper-transition-v1.png')) {
Assert-NotContains -Content $entry -Unexpected $obsoleteHeaderAsset -Message "A01 must use the selected design's single exact header bitmap instead of composing $obsoleteHeaderAsset."
foreach ($obsoleteVisualAsset in @(
'auth-header.png',
'a01-paper-transition-v1.png',
'a01-login-header-v1.png',
'a01-login-header-exact-v2.png',
'a01-login-scroll-frame-v1.png',
'a01-vnext-scroll-v1.png',
'auth-ink-scenery.png'
)) {
Assert-NotContains -Content $entry -Unexpected $obsoleteVisualAsset -Message "A01 must not retain the rejected header, scenery, or scroll asset: $obsoleteVisualAsset"
}
$requiredCopy = @(
@@ -96,6 +102,8 @@ $requiredCopy = @(
foreach ($copy in $requiredCopy) {
Assert-Contains -Content $entry -Expected $copy -Message "A01 is missing required visible copy: $copy"
}
$smsCodeCopy = ConvertFrom-Utf8Base64 '55+t5L+h6aqM6K+B56CB'
Assert-Contains -Content $entry -Expected $smsCodeCopy -Message 'A01 SMS state must use the full SMS code field label.'
foreach ($contract in @(
"const activeLoginMethod = ref('password')",
@@ -109,7 +117,9 @@ foreach ($contract in @(
"url: '/pages/auth/a05-reset-password'",
'class="feedback-toast"',
'class="verification-layer"',
'class="login-submit"'
'class="login-submit"',
'class="agreement-error"',
'const agreementError = ref(false)'
)) {
Assert-Contains -Content $entry -Expected $contract -Message "A01 is missing state or interaction contract: $contract"
}
@@ -121,10 +131,10 @@ Assert-NotContains -Content $entry -Unexpected "url: '/pages/auth/a02-login" -Me
Assert-NotContains -Content $entry -Unexpected 'brand-intro' -Message 'A01 must remove the legacy oversized brand introduction.'
Assert-NotContains -Content $entry -Unexpected 'min-height: 100vh' -Message 'A01 must not depend on 100vh for its page shell.'
Assert-NotContains -Content $entry -Unexpected 'exact-chrome-overlay' -Message 'A01 must not stack a second full-screen scroll perimeter over the complete scroll frame.'
Assert-NotContains -Content $entry -Unexpected 'exact-header-overlay' -Message 'A01 must not stack a second header bitmap over the selected backdrop.'
Assert-NotContains -Content $entry -Unexpected 'auth-design-scenery-v1.png' -Message 'A01 must not use the scenery bitmap that contains stray scroll handles.'
Assert-NotContains -Content $entry -Unexpected 'a01-icon-eye-closed-v1.png' -Message 'A01 hidden-password icon must retain the center pupil instead of using the hollow crossed eye.'
Assert-NotContains -Content $entry -Unexpected 'a01-icon-verification-v1.png' -Message 'A01 SMS field must use a message-code icon instead of a success-state checkmark.'
Assert-NotContains -Content $entry -Unexpected 'a01-icon-sms-code-v1.svg' -Message 'A01 SMS field must use the custom generated icon instead of the temporary third-party SVG.'
Assert-NotContains -Content $entry -Unexpected '@media (max-height:' -Message 'A01 must preserve the design ratio on short screens and scroll instead of compressing with pixel breakpoints.'
Assert-NotContains -Content $entry -Unexpected '@media (max-height:' -Message 'A01 must scroll naturally on short screens instead of compressing the selected design with height breakpoints.'
Write-Output 'A01-LOGIN-MERGE-CONTRACT PASS'
+112
View File
@@ -0,0 +1,112 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$manifestRelativePath = 'design-pipeline/manifests/a01.json'
$requiredFiles = @(
'design-pipeline/package.json',
$manifestRelativePath,
'design-pipeline/scripts/build-a01.mjs'
)
foreach ($relativePath in $requiredFiles) {
$absolutePath = Join-Path $root $relativePath
if (-not (Test-Path -LiteralPath $absolutePath -PathType Leaf)) {
throw "Missing A01 code pipeline file: $relativePath"
}
}
$manifest = Get-Content -LiteralPath (Join-Path $root $manifestRelativePath) -Raw -Encoding UTF8 | ConvertFrom-Json
if ($manifest.schemaVersion -ne 1) {
throw "Expected A01 pipeline schemaVersion 1, found $($manifest.schemaVersion)."
}
if ($manifest.canvas.width -ne 412 -or $manifest.canvas.height -ne 915) {
throw 'A01 logical canvas must be 412x915.'
}
$requiredStates = @('password-hidden', 'password-visible', 'sms-default', 'sms-countdown')
foreach ($stateName in $requiredStates) {
if ($manifest.states.name -notcontains $stateName) {
throw "Missing A01 preview state: $stateName"
}
}
$workspacePrefix = [IO.Path]::GetFullPath($root).TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
$runtimePrefix = 'static/assets/modules/auth/'
$assetIds = @{}
foreach ($asset in $manifest.assets) {
if (-not $asset.id -or $assetIds.ContainsKey($asset.id)) {
throw "A01 asset id must be present and unique: $($asset.id)"
}
$assetIds[$asset.id] = $true
foreach ($propertyName in @('source', 'output', 'width', 'height', 'alpha', 'maxBytes')) {
if ($null -eq $asset.$propertyName -or "$($asset.$propertyName)" -eq '') {
throw "A01 asset '$($asset.id)' is missing property: $propertyName"
}
}
if (-not $asset.output.StartsWith($runtimePrefix, [StringComparison]::OrdinalIgnoreCase)) {
throw "A01 runtime asset must stay in ${runtimePrefix}: $($asset.output)"
}
if ($asset.source -ne $asset.output) {
throw "A01 portable pipeline must use the committed runtime asset as its stable source: $($asset.id)"
}
if ($asset.source -match '(?i)candidates|review|generated|node_modules|\.psd$') {
throw "A01 portable pipeline source must not depend on local intermediate files: $($asset.source)"
}
if ($asset.output -match '(?i)review|page-v2|\.psd$') {
throw "A01 runtime asset must not use a preview, full-page image, or PSD: $($asset.output)"
}
$sourcePath = [IO.Path]::GetFullPath((Join-Path $root $asset.source))
$outputPath = [IO.Path]::GetFullPath((Join-Path $root $asset.output))
foreach ($path in @($sourcePath, $outputPath)) {
if (-not $path.StartsWith($workspacePrefix, [StringComparison]::OrdinalIgnoreCase)) {
throw "A01 pipeline path escaped the workspace: $path"
}
}
if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) {
throw "Missing A01 source asset: $($asset.source)"
}
if (-not (Test-Path -LiteralPath $outputPath -PathType Leaf)) {
throw "Missing generated A01 runtime asset: $($asset.output)"
}
$bytes = [IO.File]::ReadAllBytes($outputPath)
if ($bytes.Length -lt 24 -or $bytes[0] -ne 0x89 -or $bytes[1] -ne 0x50 -or $bytes[2] -ne 0x4E -or $bytes[3] -ne 0x47) {
throw "Generated A01 asset is not a PNG: $($asset.output)"
}
$width = [Net.IPAddress]::NetworkToHostOrder([BitConverter]::ToInt32($bytes, 16))
$height = [Net.IPAddress]::NetworkToHostOrder([BitConverter]::ToInt32($bytes, 20))
if ($width -ne $asset.width -or $height -ne $asset.height) {
throw "Generated A01 asset has wrong dimensions: $($asset.output) expected $($asset.width)x$($asset.height), found ${width}x${height}."
}
if ($bytes.Length -gt $asset.maxBytes) {
throw "Generated A01 asset exceeds maxBytes: $($asset.output) has $($bytes.Length), limit $($asset.maxBytes)."
}
$pngColorType = $bytes[25]
$hasAlpha = $pngColorType -in @(4, 6)
if ([bool]$asset.alpha -ne $hasAlpha) {
throw "Generated A01 asset Alpha contract mismatch: $($asset.output)"
}
}
foreach ($preview in $manifest.previews) {
$previewPath = Join-Path $root $preview.output
if (-not (Test-Path -LiteralPath $previewPath -PathType Leaf)) {
throw "Missing generated A01 preview: $($preview.output)"
}
}
$buildReport = Join-Path $root 'design-pipeline/generated/a01/build-report.json'
if (-not (Test-Path -LiteralPath $buildReport -PathType Leaf)) {
throw 'Missing A01 code pipeline build report.'
}
$builder = Get-Content -LiteralPath (Join-Path $root 'design-pipeline/scripts/build-a01.mjs') -Raw -Encoding UTF8
foreach ($forbidden in @('Photoshop.Application', '.psd', 'scripts/photoshop')) {
if ($builder -match [regex]::Escape($forbidden)) {
throw "A01 code pipeline must not depend on Photoshop or PSD: $forbidden"
}
}
Write-Output 'A01-CODE-PIPELINE-CONTRACT PASS'
+88
View File
@@ -0,0 +1,88 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$manifestRelativePath = 'docs/design/assets/a01-vnext/source/a01-psd-manifest.json'
function ConvertFrom-Utf8Base64 {
param([string]$Value)
return [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Value))
}
$requiredFiles = @(
$manifestRelativePath,
'scripts/build-a01-layered-psd.ps1',
'scripts/export-a01-layered-assets.ps1',
'scripts/photoshop/a01-build-layered-psd.jsx',
'scripts/photoshop/a01-export-layered-assets.jsx',
'scripts/photoshop/a01-compose-page-preview.jsx'
)
foreach ($relativePath in $requiredFiles) {
$absolutePath = Join-Path $root $relativePath
if (-not (Test-Path -LiteralPath $absolutePath -PathType Leaf)) {
throw "Missing layered PSD contract file: $relativePath"
}
}
$manifestPath = Join-Path $root $manifestRelativePath
$manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding utf8 | ConvertFrom-Json
if ($manifest.schemaVersion -ne 1) {
throw "Expected PSD manifest schemaVersion 1, found $($manifest.schemaVersion)."
}
if ($manifest.canvas.width -ne 1236 -or $manifest.canvas.height -ne 2745) {
throw 'PSD canvas must be 1236x2745.'
}
if ($manifest.canvas.logicalWidth -ne 412 -or $manifest.canvas.logicalHeight -ne 915 -or $manifest.canvas.scale -ne 3) {
throw 'PSD logical canvas must be 412x915 at scale 3.'
}
$requiredGroups = @(
'MDAt5Y+C6ICD',
'MTAt6IOM5pmv',
'MjAt5Y236L20',
'MzAt5ZOB54mM5LiO5qCH6aKY6KOF6aWw',
'NDAt5YWs5YWx5o6n5Lu255qu6IKk',
'NTAt5a+G56CB55m75b2V',
'NjAt6aqM6K+B56CB55m75b2V',
'NzAt5YaF5a655LiO5qCH5rOo'
) | ForEach-Object { ConvertFrom-Utf8Base64 $_ }
foreach ($groupName in $requiredGroups) {
if ($manifest.groups.name -notcontains $groupName) {
throw "Missing PSD group: $groupName"
}
}
foreach ($stateName in @('password-hidden', 'password-visible', 'sms-default', 'sms-countdown')) {
if ($manifest.states.name -notcontains $stateName) {
throw "Missing PSD state: $stateName"
}
}
$buildWrapper = Get-Content -LiteralPath (Join-Path $root 'scripts/build-a01-layered-psd.ps1') -Raw -Encoding utf8
$exportWrapper = Get-Content -LiteralPath (Join-Path $root 'scripts/export-a01-layered-assets.ps1') -Raw -Encoding utf8
$buildJsx = Get-Content -LiteralPath (Join-Path $root 'scripts/photoshop/a01-build-layered-psd.jsx') -Raw -Encoding utf8
$exportJsx = Get-Content -LiteralPath (Join-Path $root 'scripts/photoshop/a01-export-layered-assets.jsx') -Raw -Encoding utf8
$composeJsx = Get-Content -LiteralPath (Join-Path $root 'scripts/photoshop/a01-compose-page-preview.jsx') -Raw -Encoding utf8
foreach ($wrapper in @($buildWrapper, $exportWrapper)) {
foreach ($variableName in @('JIAPU_A01_ROOT', 'JIAPU_A01_MANIFEST', 'JIAPU_A01_PSD')) {
if ($wrapper -notmatch [regex]::Escape("var $variableName =")) {
throw "Photoshop wrapper must inject the JSX variable directly: $variableName"
}
}
}
foreach ($jsx in @($buildJsx, $exportJsx)) {
if ($jsx -match [regex]::Escape('$.getenv(')) {
throw 'Photoshop JSX must not depend on environment variables from an already-running process.'
}
}
if ($composeJsx -notmatch [regex]::Escape('new Folder(JIAPU_A01_ROOT)') -or
$composeJsx -notmatch [regex]::Escape('new Folder(JIAPU_A01_OUTPUT_DIR)')) {
throw 'Photoshop preview composer must construct Folder objects explicitly.'
}
if ($composeJsx -match [regex]::Escape("var password = app.open(File(OUTPUT.fsName + '/A01-page-password-v1-1236x2745.png'))")) {
throw 'Contact-sheet composition must not pre-open a source that placeImage opens itself.'
}
Write-Output 'A01-LAYERED-PSD-CONTRACT PASS'
+132
View File
@@ -0,0 +1,132 @@
const assert = require('assert')
const url = 'http://localhost:5173/#/pages/auth/a01-entry'
const sizes = [
{ width: 320, height: 568 },
{ width: 360, height: 640 },
{ width: 360, height: 800 },
{ width: 412, height: 915 }
]
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const connect = async () => {
const port = process.env.CHROME_DEBUGGING_PORT || '9222'
const pages = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith('http://localhost:5173'))
if (!page) throw new Error('Chrome debugging has no application page')
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true })
})
let id = 0
const pending = new Map()
const exceptions = []
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
if (message.method === 'Runtime.exceptionThrown') exceptions.push(message.params.exceptionDetails.text)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
if (message.error) request.reject(new Error(message.error.message))
else request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
return { socket, send, exceptions }
}
const valueOf = async (send, expression) => {
const result = await send('Runtime.evaluate', { expression, returnByValue: true })
return result.result?.value
}
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 40; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(100)
}
throw new Error(message)
}
const run = async () => {
const { socket, send, exceptions } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
for (const size of sizes) {
await send('Emulation.setDeviceMetricsOverride', {
width: size.width,
height: size.height,
deviceScaleFactor: 1,
mobile: true
})
await send('Page.navigate', { url })
await send('Page.reload')
await waitFor(send, "document.querySelectorAll('.login-tab').length === 2", `A01 did not render at ${size.width}x${size.height}`)
if (size.width === 320 && size.height === 568) {
await waitFor(send, "document.querySelector('.auth-page')?.scrollHeight > document.querySelector('.auth-page')?.clientHeight", 'A01 did not establish natural scrolling at 320x568')
}
const metrics = await valueOf(send, `({
innerWidth: window.innerWidth,
innerHeight: window.innerHeight,
scrollWidth: document.documentElement.scrollWidth,
scrollHeight: document.documentElement.scrollHeight,
pageClientHeight: document.querySelector('.auth-page')?.clientHeight,
pageScrollHeight: document.querySelector('.auth-page')?.scrollHeight
})`)
assert.strictEqual(metrics.innerWidth, size.width, `A01 viewport width mismatch at ${size.width}x${size.height}`)
assert(metrics.scrollWidth <= size.width + 1, `A01 has horizontal overflow at ${size.width}x${size.height}: ${metrics.scrollWidth}`)
if (size.width === 320 && size.height === 568) {
assert(metrics.pageScrollHeight > metrics.pageClientHeight, 'A01 must scroll naturally at 320x568')
}
}
await valueOf(send, "document.querySelectorAll('.login-tab')[1].click()")
await waitFor(send, "document.querySelectorAll('.login-tab')[1].classList.contains('active')", 'A01 SMS tab did not activate')
await waitFor(send, "Boolean(document.querySelector('.input-icon--sms img'))", 'A01 SMS field image did not render after tab activation')
const smsIconSource = await valueOf(send, "document.querySelector('.input-icon--sms img')?.getAttribute('src')")
assert(smsIconSource?.includes('a01-icon-sms-code-v2.png'), `A01 SMS state did not use the approved message icon: ${smsIconSource}`)
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.password-toggle'))"), false, 'A01 SMS state retained the password eye')
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.forgot-password'))"), false, 'A01 SMS state retained forgot password')
await valueOf(send, "document.querySelectorAll('.login-tab')[0].click()")
await waitFor(send, "document.querySelectorAll('.login-tab')[0].classList.contains('active')", 'A01 password tab did not activate')
assert(await valueOf(send, "Boolean(document.querySelector('.password-toggle'))"), 'A01 password state is missing the eye control')
assert(await valueOf(send, "Boolean(document.querySelector('.forgot-password'))"), 'A01 password state is missing forgot password')
await valueOf(send, `(() => {
const inputs = document.querySelectorAll('.auth-input input')
inputs[0].value = '13800138000'
inputs[0].dispatchEvent(new Event('input', { bubbles: true }))
inputs[1].value = 'demo-password'
inputs[1].dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector('.login-submit').click()
})()`)
await waitFor(send, "Boolean(document.querySelector('.agreement-error'))", 'A01 did not show inline agreement validation')
await valueOf(send, "document.querySelector('.agreement-row').click()")
await waitFor(send, "!document.querySelector('.agreement-error')", 'A01 agreement error did not clear after selection')
await valueOf(send, "document.querySelector('.login-submit').click()")
await waitFor(send, "Boolean(document.querySelector('.verification-layer'))", 'A01 did not open the custom verification layer after local validation')
assert.deepStrictEqual(exceptions, [], `A01 raised browser exceptions: ${exceptions.join('; ')}`)
process.stdout.write('A01-RESPONSIVE-RUNTIME-SMOKE PASS\n')
} finally {
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+13 -3
View File
@@ -26,8 +26,8 @@ $loginCopy = ConvertFrom-Utf8Base64 '55m75b2V'
if (-not ($pages.pages.path -contains 'pages/auth/a04-register')) { throw 'A-04 route must remain declared in pages.json' }
Assert-NotContains -Content $register -Unexpected 'ModulePage' -Message 'A-04 must not remain a ModulePage shell'
foreach ($required in @(
'auth-header',
'register-panel__skin',
'a01-red-hall-ink-backdrop-v1.png',
'brand-seal.png',
'a02-login-panel.png',
'a01-primary-button.png',
'a02-agreement-unchecked.png',
@@ -36,11 +36,17 @@ foreach ($required in @(
'v-model="password"',
'v-model="confirmPassword"',
'const agreed = ref(false)',
'const agreementError = ref(false)',
'const fieldErrors = ref({',
'const verificationVisible = ref(false)',
'const toggleAgreement = () =>',
'const submitRegister = () =>',
"const prepareLogin = () => uni.navigateTo({ url: '/pages/auth/a01-entry' })",
'class="feedback-toast"',
'class="verification-layer"',
"const prepareLogin = () => uni.redirectTo({ url: '/pages/auth/a01-entry' })",
'if (!/^1\d{10}$/.test(phone.value))',
'if (!password.value)',
'if (!confirmPassword.value)',
'if (password.value !== confirmPassword.value)'
)) {
Assert-Contains -Content $register -Expected $required -Message "Missing A-04 registration contract: $required"
@@ -49,6 +55,10 @@ Assert-Contains -Content $register -Expected $registerCopy -Message 'A-04 must v
Assert-Contains -Content $register -Expected $loginCopy -Message 'A-04 must provide a login return path'
Assert-NotContains -Content $catalog -Unexpected 'a04:' -Message 'A-04 must not remain in the temporary page catalog'
Assert-NotContains -Content $register -Unexpected 'register-intro' -Message 'A-04 must not retain the redundant registration intro copy'
Assert-NotContains -Content $register -Unexpected 'auth-design-scenery-v1.png' -Message 'A-04 must use the shared red-hall ink backdrop instead of the rejected scenery layer'
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
Assert-NotContains -Content $register -Unexpected $nativeUi -Message "A-04 must not use native UniApp feedback: $nativeUi"
}
foreach ($required in @(
'auth-divider-knot.png',
'register-divider',
+116
View File
@@ -0,0 +1,116 @@
const assert = require('assert')
const a01Url = 'http://localhost:5173/#/pages/auth/a01-entry'
const a04Url = 'http://localhost:5173/#/pages/auth/a04-register'
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const connect = async () => {
const port = process.env.CHROME_DEBUGGING_PORT || '9222'
const pages = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith('http://localhost:5173'))
if (!page) throw new Error('Chrome debugging has no application page')
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true })
})
let id = 0
const pending = new Map()
const exceptions = []
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
if (message.method === 'Runtime.exceptionThrown') exceptions.push(message.params.exceptionDetails.text)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
if (message.error) request.reject(new Error(message.error.message))
else request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
return { socket, send, exceptions }
}
const valueOf = async (send, expression) => {
const result = await send('Runtime.evaluate', { expression, returnByValue: true })
if (result.exceptionDetails) {
throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text)
}
return result.result?.value
}
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 40; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(100)
}
throw new Error(message)
}
const navigate = async (send, url, selector) => {
// 每次增加查询戳,确保同一路由也会建立全新文档,避免沿用上一次表单状态。
const targetUrl = url.replace('/#/', `/?runtime=${Date.now()}#/`)
await send('Page.navigate', { url: targetUrl })
await waitFor(send, `location.href.includes(${JSON.stringify(new URL(url).hash.slice(1))})`, `Page did not navigate to ${url}`)
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `Page did not render ${selector}`)
}
const run = async () => {
const { socket, send, exceptions } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
await navigate(send, a01Url, '.register-link')
await valueOf(send, "document.querySelector('.register-link').click()")
await waitFor(send, "location.href.includes('/pages/auth/a04-register')", 'A01 registration entry did not open A04')
for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) {
await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true })
await navigate(send, a04Url, '.register-submit')
const metrics = await valueOf(send, `({
width: document.documentElement.scrollWidth,
pageClientHeight: document.querySelector('.auth-page').clientHeight,
pageScrollHeight: document.querySelector('.auth-page').scrollHeight
})`)
assert(metrics.width <= size.width + 1, `A04 has horizontal overflow at ${size.width}x${size.height}`)
if (size.width === 320) assert(metrics.pageScrollHeight > metrics.pageClientHeight, 'A04 must scroll naturally at 320x568')
}
await valueOf(send, "document.querySelector('.register-submit').click()")
await waitFor(send, "document.querySelectorAll('.field-error').length === 3", 'A04 empty submit did not show three field errors')
assert(await valueOf(send, "Boolean(document.querySelector('.agreement-error'))"), 'A04 empty submit did not show agreement error')
await valueOf(send, `(() => {
const inputs = document.querySelectorAll('.auth-input input')
const values = ['13800138000', 'demo-password', 'demo-password']
inputs.forEach((input, index) => {
input.value = values[index]
input.dispatchEvent(new Event('input', { bubbles: true }))
})
document.querySelector('.agreement-row').click()
document.querySelector('.register-submit').click()
})()`)
await waitFor(send, "Boolean(document.querySelector('.verification-layer'))", 'A04 valid submit did not open custom verification')
await valueOf(send, "document.querySelector('.verification-action--primary').click()")
await waitFor(send, "Boolean(document.querySelector('.feedback-toast'))", 'A04 verification result did not use custom feedback')
await navigate(send, a04Url, '.login-entry__link')
await valueOf(send, "document.querySelector('.login-entry__link').click()")
await waitFor(send, "Boolean(document.querySelector('.login-tab')) && (location.hash === '#/' || location.href.includes('/pages/auth/a01-entry'))", 'A04 login entry did not return to A01')
assert.deepStrictEqual(exceptions, [], `A04 raised browser exceptions: ${exceptions.join('; ')}`)
process.stdout.write('A04-REGISTRATION-RUNTIME-SMOKE PASS\n')
} finally {
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+13 -3
View File
@@ -28,8 +28,8 @@ $loginCopy = ConvertFrom-Utf8Base64 '6L+U5Zue55m75b2V'
if (-not ($pages.pages.path -contains 'pages/auth/a05-reset-password')) { throw 'A-05 route must remain declared in pages.json' }
Assert-NotContains -Content $page -Unexpected 'ModulePage' -Message 'A-05 must not remain a ModulePage shell'
foreach ($required in @(
'auth-header',
'reset-panel__skin',
'a01-red-hall-ink-backdrop-v1.png',
'brand-seal.png',
'a02-login-panel.png',
'a01-primary-button.png',
'auth-divider-knot.png',
@@ -40,7 +40,13 @@ foreach ($required in @(
'v-model="confirmPassword"',
'const prepareGetCode = () =>',
'const submitReset = () =>',
"const prepareLogin = () => uni.navigateTo({ url: '/pages/auth/a01-entry' })",
'const fieldErrors = ref({',
'const verificationVisible = ref(false)',
'const successVisible = ref(false)',
'class="verification-layer"',
'class="feedback-toast"',
'class="success-layer"',
"const prepareLogin = () => uni.redirectTo({ url: '/pages/auth/a01-entry' })",
'if (!/^1\d{10}$/.test(phone.value))',
'if (!/^\d{6}$/.test(verificationCode.value))',
'if (!password.value)',
@@ -53,6 +59,10 @@ Assert-Contains -Content $page -Expected $confirmPasswordCopy -Message 'A-05 mus
Assert-Contains -Content $page -Expected $getCodeCopy -Message 'A-05 must expose the verification-code action'
Assert-Contains -Content $page -Expected $loginCopy -Message 'A-05 must provide the A01 return path'
Assert-NotContains -Content $catalog -Unexpected 'a05:' -Message 'A-05 must not remain in the temporary page catalog'
Assert-NotContains -Content $page -Unexpected 'auth-design-scenery-v1.png' -Message 'A-05 must use the selected red-hall ink backdrop'
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
Assert-NotContains -Content $page -Unexpected $nativeUi -Message "A-05 must not use native UniApp feedback: $nativeUi"
}
foreach ($forbidden in @(
'(?s)\.reset-panel\s*\{[^}]*\bborder\s*:',
+134
View File
@@ -0,0 +1,134 @@
const assert = require('assert')
const a01Url = 'http://localhost:5173/#/pages/auth/a01-entry'
const a05Url = 'http://localhost:5173/#/pages/auth/a05-reset-password'
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const connect = async () => {
const port = process.env.CHROME_DEBUGGING_PORT || '9222'
const pages = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith('http://localhost:5173'))
if (!page) throw new Error('Chrome debugging has no application page')
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true })
})
let id = 0
const pending = new Map()
const exceptions = []
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
if (message.method === 'Runtime.exceptionThrown') exceptions.push(message.params.exceptionDetails.text)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
if (message.error) request.reject(new Error(message.error.message))
else request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
return { socket, send, exceptions }
}
const valueOf = async (send, expression) => {
const result = await send('Runtime.evaluate', { expression, returnByValue: true })
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text)
return result.result?.value
}
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 40; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(100)
}
throw new Error(message)
}
const navigate = async (send, url, selector) => {
const targetUrl = url.replace('/#/', `/?runtime=${Date.now()}#/`)
await send('Page.navigate', { url: targetUrl })
await waitFor(send, `location.href.includes(${JSON.stringify(new URL(url).hash.slice(1))})`, `Page did not navigate to ${url}`)
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `Page did not render ${selector}`)
}
const setInputs = (values) => `(() => {
const inputs = document.querySelectorAll('.auth-input input')
;${JSON.stringify(values)}.forEach((value, index) => {
inputs[index].value = value
inputs[index].dispatchEvent(new Event('input', { bubbles: true }))
})
})()`
const run = async () => {
const { socket, send, exceptions } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
await navigate(send, a01Url, '.forgot-password')
await valueOf(send, "document.querySelector('.forgot-password').click()")
await waitFor(send, "location.href.includes('/pages/auth/a05-reset-password')", 'A01 forgot-password entry did not open A05')
for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) {
await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true })
await navigate(send, a05Url, '.reset-submit')
const metrics = await valueOf(send, `({
width: document.documentElement.scrollWidth,
pageClientHeight: document.querySelector('.auth-page').clientHeight,
pageScrollHeight: document.querySelector('.auth-page').scrollHeight
})`)
assert(metrics.width <= size.width + 1, `A05 has horizontal overflow at ${size.width}x${size.height}`)
if (size.width === 320) assert(metrics.pageScrollHeight > metrics.pageClientHeight, 'A05 must scroll naturally at 320x568')
}
await valueOf(send, "document.querySelector('.get-code').click()")
await waitFor(send, "Boolean(document.querySelector('.field-error'))", 'A05 invalid phone did not show inline error')
await valueOf(send, setInputs(['13800138000', '', '', '']))
await valueOf(send, "document.querySelector('.get-code').click()")
await waitFor(send, "Boolean(document.querySelector('.verification-layer'))", 'A05 code request did not open custom verification')
await valueOf(send, "document.querySelector('.verification-action').click()")
await waitFor(send, "!document.querySelector('.verification-layer')", 'A05 verification did not close')
assert.strictEqual(await valueOf(send, "document.querySelector('.auth-input input').value"), '13800138000', 'A05 verification close cleared the form')
await valueOf(send, "document.querySelector('.get-code').click()")
await waitFor(send, "Boolean(document.querySelector('.verification-layer'))", 'A05 verification did not reopen')
await valueOf(send, "document.querySelector('.verification-action--primary').click()")
await waitFor(send, "Boolean(document.querySelector('.feedback-toast'))", 'A05 verified code request did not use custom feedback')
assert.strictEqual(await valueOf(send, "document.querySelector('.get-code').textContent"), '重新获取', 'A05 code action did not enter requested state')
await valueOf(send, "document.querySelector('.reset-submit').click()")
await waitFor(send, "document.querySelectorAll('.field-error').length === 3", 'A05 incomplete submit did not show three remaining field errors')
await valueOf(send, setInputs(['13800138000', '123456', 'new-password', 'different-password']))
await sleep(100)
await valueOf(send, "document.querySelector('.reset-submit').click()")
await sleep(100)
const mismatchState = await valueOf(send, `({
values: Array.from(document.querySelectorAll('.auth-input input')).map((item) => item.value),
errors: Array.from(document.querySelectorAll('.field-error')).map((item) => item.textContent),
success: Boolean(document.querySelector('.success-layer'))
})`)
assert(mismatchState.errors.some((message) => message.includes('不一致')), `A05 mismatched passwords did not show inline error: ${JSON.stringify(mismatchState)}`)
await valueOf(send, setInputs(['13800138000', '123456', 'new-password', 'new-password']))
await sleep(100)
await valueOf(send, "document.querySelector('.reset-submit').click()")
await waitFor(send, "Boolean(document.querySelector('.success-layer'))", 'A05 valid submit did not show custom success result')
await valueOf(send, "document.querySelector('.success-action').click()")
await waitFor(send, "Boolean(document.querySelector('.login-tab')) && (location.hash === '#/' || location.href.includes('/pages/auth/a01-entry'))", 'A05 success action did not return to A01')
assert.deepStrictEqual(exceptions, [], `A05 raised browser exceptions: ${exceptions.join('; ')}`)
process.stdout.write('A05-RESET-PASSWORD-RUNTIME-SMOKE PASS\n')
} finally {
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+20 -13
View File
@@ -17,37 +17,44 @@ $catalog = Get-Content -LiteralPath (Join-Path $root 'data/page-catalog.js') -Ra
if (-not ($pages.pages.path -contains 'pages/auth/a06-auth-status')) { throw 'A-06 route must remain declared in pages.json' }
if (-not ($pages.pages.path -contains 'pages/auth/a01-entry')) { throw 'A-06 must retain the A01 return target' }
if (-not ($pages.pages.path -contains 'pages/auth/a04-register')) { throw 'A-06 must retain the A04 registration target' }
Assert-NotContains -Content $page -Unexpected 'ModulePage' -Message 'A-06 must not remain a ModulePage shell'
Assert-NotContains -Content $catalog -Unexpected 'a06:' -Message 'A-06 must not remain in the temporary page catalog'
foreach ($required in @(
"const status = ref('normal')",
"const status = ref('risk')",
"const statusConfig = {",
'normal:',
'failed:',
'restricted:',
"'register-pending':",
"'wechat-cancelled':",
"'wechat-failed':",
'frozen:',
'disabled:',
'risk:',
'reasonLabel:',
'impactLabel:',
'recoveryLabel:',
'const resolveStatus = () =>',
'const goLogin = () =>',
'const goRegister = () =>',
'const handlePrimary = () =>',
'const openRecovery = () =>',
'const closeRecovery = () =>',
'const goBack = () =>',
'auth-header.png',
'a01-red-hall-ink-backdrop-v1.png',
'brand-seal.png',
'a02-login-panel.png',
'a01-primary-button.png',
'auth-title-cloud.png',
'auth-divider-knot.png',
'chevron-right.png',
'auth-login-outline.png'
'auth-login-outline.png',
'class="recovery-layer"'
)) {
Assert-Contains -Content $page -Expected $required -Message "Missing A-06 auth-status contract: $required"
}
foreach ($removedState in @('normal:', 'failed:', 'restricted:', "'register-pending':", "'wechat-cancelled':", "'wechat-failed':", 'const goRegister = () =>')) {
Assert-NotContains -Content $page -Unexpected $removedState -Message "A-06 must remove obsolete non-blocking state: $removedState"
}
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
Assert-NotContains -Content $page -Unexpected $nativeUi -Message "A-06 must not use native UniApp feedback: $nativeUi"
}
Assert-NotContains -Content $page -Unexpected 'auth-design-scenery-v1.png' -Message 'A-06 must use the selected red-hall ink backdrop'
foreach ($forbidden in @(
'(?s)\.status-panel\s*\{[^}]*\bborder\s*:',
'(?s)\.status-panel\s*\{[^}]*\bbackground\s*:',
+43 -38
View File
@@ -1,93 +1,98 @@
const assert = require('assert')
const baseUrl = 'http://localhost:5173/#/pages/auth/a06-auth-status'
const text = (base64) => Buffer.from(base64, 'base64').toString('utf8')
const expected = {
normal: text('55m75b2V5pyq5a6M5oiQ'),
restricted: text('6LSm5Y+35pqC5pe25Y+X6ZmQ'),
help: text('6LSm5Y+355Sz6K+J5Yqf6IO95b6F5o6l5YWl')
}
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.includes('localhost:5173'))
if (!page) throw new Error('Chrome debugging has no localhost:5173 page')
const port = process.env.CHROME_DEBUGGING_PORT || '9222'
const pages = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith('http://localhost:5173'))
if (!page) throw new Error('Chrome debugging has no application page')
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true })
})
let id = 0
const pending = new Map()
const exceptions = []
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
if (message.method === 'Runtime.exceptionThrown') exceptions.push(message.params.exceptionDetails.text)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
if (message.error) request.reject(new Error(message.error.message))
else request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
return { socket, send }
return { socket, send, exceptions }
}
const valueOf = async (send, expression) => {
const result = await send('Runtime.evaluate', { expression, returnByValue: true })
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text)
return result.result?.value
}
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 30; attempt += 1) {
for (let attempt = 0; attempt < 40; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(100)
}
throw new Error(message)
}
const navigate = async (send, url, title) => {
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `Did not navigate to ${url}`)
await send('Page.reload')
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `Reload did not retain ${url}`)
try {
await waitFor(send, `document.querySelector('.status-title')?.innerText === ${JSON.stringify(title)}`, `A06 did not render ${title}`)
} catch (error) {
const heading = await valueOf(send, "document.querySelector('.status-title')?.innerText")
throw new Error(`${error.message}; current heading: ${heading}`)
}
const navigate = async (send, status, title) => {
const routeQuery = status ? `?status=${status}` : ''
const targetUrl = `http://localhost:5173/?runtime=${Date.now()}#/pages/auth/a06-auth-status${routeQuery}`
await send('Page.navigate', { url: targetUrl })
await waitFor(send, `document.querySelector('.status-title')?.textContent === ${JSON.stringify(title)}`, `A06 did not render ${title}`)
}
const run = async () => {
const { socket, send } = await connect()
const { socket, send, exceptions } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
await navigate(send, baseUrl, expected.normal)
await valueOf(send, "document.querySelector('.status-primary')?.click()")
await waitFor(send, "location.href.includes('/pages/auth/a01-entry')", 'Default A06 action did not return to A01')
for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) {
await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true })
await navigate(send, 'risk', '账号存在安全风险')
const metrics = await valueOf(send, `({
width: document.documentElement.scrollWidth,
pageClientHeight: document.querySelector('.auth-page').clientHeight,
pageScrollHeight: document.querySelector('.auth-page').scrollHeight
})`)
assert(metrics.width <= size.width + 1, `A06 has horizontal overflow at ${size.width}x${size.height}`)
if (size.width === 320) assert(metrics.pageScrollHeight > metrics.pageClientHeight, 'A06 must scroll naturally at 320x568')
}
await navigate(send, `${baseUrl}?status=restricted`, expected.restricted)
await valueOf(send, "document.querySelector('.status-primary')?.click()")
await waitFor(send, `document.body.innerText.includes(${JSON.stringify(expected.help)})`, 'Restricted A06 action did not show the local help notice')
for (const state of [
{ query: 'frozen', title: '账号已被冻结' },
{ query: 'disabled', title: '账号已被停用' },
{ query: 'risk', title: '账号存在安全风险' },
{ query: 'failed', title: '账号存在安全风险' }
]) {
await navigate(send, state.query, state.title)
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.status-detail').length"), 3, `A06 ${state.query} is missing reason/impact/recovery details`)
}
await navigate(send, `${baseUrl}?status=register-pending`, text('5rOo5YaM5bCa5pyq5a6M5oiQ'))
await valueOf(send, "document.querySelector('.status-primary')?.click()")
await waitFor(send, "location.href.includes('/pages/auth/a04-register')", 'Registration-pending A06 action did not open A04')
await valueOf(send, "document.querySelector('.status-primary').click()")
await waitFor(send, "Boolean(document.querySelector('.recovery-layer'))", 'A06 did not open the custom recovery layer')
await valueOf(send, "document.querySelector('.recovery-action').click()")
await waitFor(send, "!document.querySelector('.recovery-layer')", 'A06 recovery layer did not close')
await valueOf(send, "document.querySelector('.status-secondary').click()")
await waitFor(send, "Boolean(document.querySelector('.login-tab')) && (location.hash === '#/' || location.href.includes('/pages/auth/a01-entry'))", 'A06 did not return to A01')
assert.deepStrictEqual(exceptions, [], `A06 raised browser exceptions: ${exceptions.join('; ')}`)
process.stdout.write('A06-AUTH-STATUS-RUNTIME-SMOKE PASS\n')
} finally {
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
socket.close()
}
}
+12 -1
View File
@@ -45,11 +45,22 @@ if ($captureScript -notmatch 'await waitForFreshDocument\(send, documentTimeOrig
if ($captureScript -notmatch "action !== 'fresh-navigation'") {
throw 'Chrome capture must support fresh-navigation for root pages whose tab lifecycle closes a reloaded CDP target'
}
foreach ($g06Action in @('g06-results', 'g06-empty')) {
foreach ($g06Action in @('g06-results', 'g06-empty', 'g06-invite')) {
if ($captureScript -notmatch [regex]::Escape($g06Action)) {
throw "Chrome capture must support the $g06Action visual state"
}
}
foreach ($a01Action in @('password-tab', 'sms-tab')) {
if ($captureScript -notmatch [regex]::Escape($a01Action)) {
throw "Chrome capture must support the $a01Action A01 state"
}
}
if ($captureScript -notmatch 'const prepareA01LoginState = async \(send, action\) =>') {
throw 'Chrome capture must wait for A01 login tabs before switching state'
}
if ($captureScript -notmatch 'await prepareA01LoginState\(send, action\)') {
throw 'Chrome capture must verify the requested A01 state before taking a screenshot'
}
if ($captureScript -notmatch 'const prepareG06State = async \(send, action\) =>') {
throw 'Chrome capture must prepare G06 search states through the real input and action controls'
}
+3 -1
View File
@@ -26,10 +26,12 @@ if ($paths -contains 'pages/genealogy/g02-empty-genealogies') { throw 'G02 route
foreach ($required in @(
'forceEmptyState',
'query.get("state") === "empty"',
'query.get("state") || "default"',
'g01-empty-panel.png',
'empty-create-action',
'empty-search-action',
'empty-invite-action',
'empty-create-note',
'const createGenealogy = () =>',
'/pages/genealogy/g03-create-genealogy',
'/pages/genealogy/g06-search-genealogies',
+43 -2
View File
@@ -13,8 +13,10 @@ const connect = async () => {
let id = 0
const pending = new Map()
const exceptions = []
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
if (message.method === 'Runtime.exceptionThrown') exceptions.push(message.params.exceptionDetails.text)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
@@ -28,11 +30,12 @@ const connect = async () => {
socket.send(JSON.stringify({ id, method, params }))
})
return { socket, send }
return { socket, send, exceptions }
}
const valueOf = async (send, expression) => {
const result = await send('Runtime.evaluate', { expression, returnByValue: true })
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text)
return result.result?.value
}
@@ -64,7 +67,7 @@ const openEmptyG01 = async (send) => {
}
const run = async () => {
const { socket, send } = await connect()
const { socket, send, exceptions } = await connect()
try {
await send('Page.enable')
@@ -78,6 +81,10 @@ const run = async () => {
await valueOf(send, "document.querySelector('.empty-search-action')?.click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g06-search-genealogies')", 'G01 empty search action did not open G06')
await openEmptyG01(send)
await valueOf(send, "document.querySelector('.empty-invite-action')?.click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g06-search-genealogies?mode=invite')", 'G01 empty invite action did not open G06 invite mode')
const defaultUrl = nextUrl()
await send('Page.navigate', { url: defaultUrl })
await waitFor(send, "location.href.endsWith('/pages/genealogy/g01-my-genealogies')", 'Did not navigate to default G01')
@@ -85,9 +92,43 @@ const run = async () => {
if (await valueOf(send, "Boolean(document.querySelector('.genealogy-empty-state'))")) {
throw new Error('Default G01 must not render the empty state')
}
if ((await valueOf(send, "document.querySelectorAll('.application-record').length")) !== 3) {
throw new Error('Default G01 must render the application status group')
}
await valueOf(send, "document.querySelector('.create-action').click()")
await waitFor(send, "Boolean(document.querySelector('.add-dialog-layer'))", 'G01 add action did not open the custom add dialog')
if ((await valueOf(send, "document.querySelectorAll('.add-dialog .dialog-primary, .add-dialog .dialog-secondary, .add-dialog .dialog-create').length")) !== 3) {
throw new Error('G01 add dialog must keep search, invite, and create visible together')
}
await valueOf(send, "document.querySelector('.dialog-close').click()")
await waitFor(send, "!document.querySelector('.add-dialog-layer')", 'G01 add dialog did not close')
await valueOf(send, "document.querySelector('.current-slip').click()")
await waitFor(send, "Boolean(document.querySelector('.genealogy-switcher-layer'))", 'G01 current genealogy did not open the switcher')
await valueOf(send, "document.querySelectorAll('.switcher-item')[1].click()")
await waitFor(send, "document.querySelector('.current-name')?.textContent.includes('汤氏宗谱')", 'G01 switcher did not change the displayed current genealogy')
for (const state of ['loading', 'error']) {
const stateUrl = nextUrl(`?state=${state}`)
await send('Page.navigate', { url: stateUrl })
await waitFor(send, `Boolean(document.querySelector('.state-panel--${state}'))`, `G01 did not render ${state}`)
}
for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) {
await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true })
const responsiveUrl = nextUrl()
await send('Page.navigate', { url: responsiveUrl })
await waitFor(send, "Boolean(document.querySelector('.current-slip'))", `G01 did not render at ${size.width}x${size.height}`)
const width = await valueOf(send, 'document.documentElement.scrollWidth')
if (width > size.width + 1) throw new Error(`G01 has horizontal overflow at ${size.width}x${size.height}`)
}
if (exceptions.length) throw new Error(`G01 raised browser exceptions: ${exceptions.join('; ')}`)
process.stdout.write('G01-EMPTY-STATE-RUNTIME-SMOKE PASS\n')
} finally {
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
socket.close()
}
}
+9
View File
@@ -59,6 +59,15 @@ if ($page -match "from '@/utils/api\.js'") { throw 'G-01 visual phase must not c
foreach ($state in @('isLoading', 'hasGenealogies', 'createdGenealogies', 'joinedGenealogies')) {
if ($page -notmatch $state) { throw "G-01 is missing presentation state: $state" }
}
foreach ($state in @('hasError', 'applicationRecords', 'addDialogVisible', 'switcherVisible')) {
if ($page -notmatch $state) { throw "G-01 is missing current acceptance state: $state" }
}
foreach ($required in @('class="add-dialog-layer"', 'class="genealogy-switcher-layer"', 'application-section')) {
if ($page -notmatch [regex]::Escape($required)) { throw "G-01 is missing current acceptance structure: $required" }
}
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
if ($page -match [regex]::Escape($nativeUi)) { throw "G-01 must not use native UniApp UI: $nativeUi" }
}
foreach ($asset in @('shortcut-tree.png', 'shortcut-members.png', 'shortcut-generation-poem.png', 'shortcut-application.png', 'add.png')) {
if ($page -notmatch [regex]::Escape($asset)) { throw "G-01 does not consume $asset" }
}
+10 -4
View File
@@ -29,11 +29,13 @@ foreach ($required in @(
'query.get("step") === "ancestor"',
'window.addEventListener("hashchange", syncFlowFromRoute)',
'step=ancestor&genealogyId=',
'appApi.createGenealogy',
'genealogyContext.setCurrentGenealogyId',
'appApi.createPerson',
'const createState = ref("form");',
'const ancestorState = ref("form");',
'const fieldErrors = reactive({',
'const duplicateReminderVisible = ref(false);',
'class="duplicate-reminder-layer"',
'if (isSubmitting.value) return;',
'/pages/tree/t01-tree-overview?genealogyId=',
'/pages/genealogy/g05-genealogy-overview?genealogyId=',
'g03-create-flow-panel.png',
'root-header-cinnabar.jpg',
'a01-primary-button.png',
@@ -43,6 +45,10 @@ foreach ($required in @(
Assert-Contains -Content $g03 -Expected $required -Message "Missing G03 flow contract: $required"
}
foreach ($forbidden in @("from '@/utils/api.js'", 'appApi.', 'uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet', '/pages/tree/t01-tree-overview?genealogyId=')) {
if ($g03 -match [regex]::Escape($forbidden)) { throw "G03 retains forbidden implementation: $forbidden" }
}
foreach ($className in @('create-flow-panel', 'flow-primary-action')) {
Assert-NoCssSurface -Content $g03 -ClassName $className
}
+23 -4
View File
@@ -2,7 +2,7 @@ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.includes('localhost:5173'))
const page = pages.find((item) => item.type === 'page' && item.url.startsWith('http://localhost:5173'))
if (!page) throw new Error('Chrome debugging has no localhost:5173 page')
const socket = new WebSocket(page.webSocketDebuggerUrl)
@@ -13,8 +13,10 @@ const connect = async () => {
let id = 0
const pending = new Map()
const exceptions = []
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
if (message.method === 'Runtime.exceptionThrown') exceptions.push(message.params.exceptionDetails.text)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
@@ -28,11 +30,12 @@ const connect = async () => {
socket.send(JSON.stringify({ id, method, params }))
})
return { socket, send }
return { socket, send, exceptions }
}
const valueOf = async (send, expression) => {
const result = await send('Runtime.evaluate', { expression, returnByValue: true })
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text)
return result.result?.value
}
@@ -70,7 +73,7 @@ const openDefaultG03 = async (send) => {
}
const run = async () => {
const { socket, send } = await connect()
const { socket, send, exceptions } = await connect()
try {
await send('Page.enable')
@@ -79,23 +82,39 @@ const run = async () => {
await openDefaultG03(send)
await setInput(send, 0, '汤')
await setInput(send, 1, '自动验证汤氏家谱')
await setInput(send, 3, '河南·洛阳')
await sleep(100)
await valueOf(send, "document.querySelector('.flow-primary-action')?.click()")
await waitFor(send, "Boolean(document.querySelector('.duplicate-reminder-layer'))", 'G03 did not show the existing-genealogy reminder before creation')
await valueOf(send, "document.querySelector('.duplicate-reminder__confirm').click()")
await waitFor(send, "location.href.includes('g03-create-genealogy?step=ancestor&genealogyId=')", 'Creating a genealogy did not open G03 ancestor step')
await waitFor(send, "Boolean(document.querySelector('.intro-field'))", 'G03 ancestor step did not render')
await send('Page.reload')
await waitFor(send, "Boolean(document.querySelector('.intro-field'))", 'G03 ancestor step did not survive reload')
await setInput(send, 0, '汤始祖')
await sleep(100)
await valueOf(send, "document.querySelector('.flow-primary-action')?.click()")
await waitFor(send, "location.href.includes('/pages/tree/t01-tree-overview?genealogyId=')", 'Saving the first ancestor did not open T01')
await waitFor(send, "Boolean(document.querySelector('.flow-success-layer'))", 'Saving the first ancestor did not show the custom success result')
await valueOf(send, "document.querySelector('.flow-success-dialog__action').click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g05-genealogy-overview?genealogyId=')", 'Saving the first ancestor did not open G05')
await openDefaultG03(send)
if (await valueOf(send, "Boolean(document.querySelector('.intro-field'))")) {
throw new Error('Default G03 must not render the ancestor step')
}
for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) {
await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true })
await openDefaultG03(send)
const width = await valueOf(send, 'document.documentElement.scrollWidth')
if (width > size.width + 1) throw new Error(`G03 has horizontal overflow at ${size.width}x${size.height}`)
}
if (exceptions.length) throw new Error(`G03 raised browser exceptions: ${exceptions.join('; ')}`)
process.stdout.write('G03-CREATE-FLOW-RUNTIME-SMOKE PASS\n')
} finally {
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
socket.close()
}
}
+8
View File
@@ -25,9 +25,14 @@ if ($g01 -match 'g05-genealogy-overview\?id=') { throw 'G01 must not retain the
foreach ($required in @(
"const overviewState = ref('loading')",
"query.genealogyId",
"const viewMode = ref('member')",
"const accessRole = ref('owner')",
'const overviewFixture = {',
'overview-ready',
'overview-public',
'overview-state--empty',
'overview-state--error',
'overview-state--no-permission',
'g05-overview-surface.png',
'/pages/genealogy/g03-create-genealogy?step=ancestor&genealogyId=',
'/pages/tree/t01-tree-overview?genealogyId=',
@@ -38,6 +43,9 @@ foreach ($required in @(
)) {
Assert-Contains $g05 $required "Missing G05 overview contract: $required"
}
foreach ($forbidden in @("from '@/utils/api.js'", 'appApi.', 'uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
if ($g05 -match [regex]::Escape($forbidden)) { throw "G05 retains forbidden implementation: $forbidden" }
}
if ($g05 -match 'g04-first-ancestor') { throw 'G05 must not navigate to the removed G04 route' }
if ($g05 -match 'toJoinApplication') { throw 'G05 must not mislabel the applicant join form as an owner invitation action' }
+30 -3
View File
@@ -13,8 +13,10 @@ const connect = async () => {
})
let id = 0
const pending = new Map()
const exceptions = []
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
if (message.method === 'Runtime.exceptionThrown') exceptions.push(message.params.exceptionDetails.text)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
@@ -25,10 +27,14 @@ const connect = async () => {
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
return { socket, send }
return { socket, send, exceptions }
}
const valueOf = async (send, expression) => (await send('Runtime.evaluate', { expression, returnByValue: true })).result?.value
const valueOf = async (send, expression) => {
const result = await send('Runtime.evaluate', { expression, returnByValue: true })
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text)
return result.result?.value
}
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 40; attempt += 1) {
if (await valueOf(send, expression)) return
@@ -45,17 +51,38 @@ const open = async (send, query, selector) => {
}
const run = async () => {
const { socket, send } = await connect()
const { socket, send, exceptions } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
await open(send, '?state=empty', '.overview-state--empty')
await open(send, '?state=error&genealogyId=1001', '.overview-state--error')
await open(send, '?state=no-permission&genealogyId=1001', '.overview-state--no-permission')
await open(send, '?mode=public&genealogyId=2001', '.overview-public')
if ((await valueOf(send, "document.querySelectorAll('.overview-public__details > uni-view').length")) !== 5) throw new Error('G05 public preview is missing identity details')
if (await valueOf(send, "Boolean(document.querySelector('.overview-actions'))")) throw new Error('G05 public preview exposed member actions')
await valueOf(send, "document.querySelector('.overview-public__action').click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g08-join-application?source=search&genealogyId=2001')", 'G05 public apply action did not open G08 search source')
await open(send, '?role=member&genealogyId=1001', '.overview-ready')
if ((await valueOf(send, "document.querySelectorAll('.overview-action').length")) !== 2) throw new Error('G05 member overview exposed owner actions')
if (await valueOf(send, "document.querySelector('.header-action')?.textContent.trim().length > 0")) throw new Error('G05 member overview exposed management action')
await open(send, '?genealogyId=1001', '.overview-ready')
if ((await valueOf(send, "document.querySelectorAll('.overview-action').length")) !== 4) throw new Error('G05 owner overview is missing management actions')
await valueOf(send, "document.querySelector('.overview-action--ancestor')?.click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g03-create-genealogy?step=ancestor&genealogyId=1001')", 'G05 ancestor action did not open the G03 ancestor step')
for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) {
await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true })
await open(send, '?mode=public&genealogyId=2001', '.overview-public')
const width = await valueOf(send, 'document.documentElement.scrollWidth')
if (width > size.width + 1) throw new Error(`G05 has horizontal overflow at ${size.width}x${size.height}`)
}
if (exceptions.length) throw new Error(`G05 raised browser exceptions: ${exceptions.join('; ')}`)
process.stdout.write('G05-OVERVIEW-RUNTIME-SMOKE PASS\n')
} finally {
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
socket.close()
}
}
+20 -4
View File
@@ -27,11 +27,23 @@ if ($paths -contains 'pages/genealogy/g07-search-result') { throw 'G07 route mus
if (Test-Path -LiteralPath $g07) { throw 'G07 page file must be deleted; search results are a G06 state' }
foreach ($required in @(
"const mode = ref('search')",
"const searchState = ref('initial')",
"searchState.value = 'loading'",
"searchState.value = 'initial'",
"searchState.value = results.value.length ? 'results' : 'empty'",
'/pages/genealogy/g08-join-application?genealogyId=',
"const inviteState = ref('initial')",
'const resultFixtures = [',
"relation: 'available'",
"relation: 'joined'",
"relation: 'pending'",
"relation: 'rejected'",
"relation: 'removed'",
"relation: 'owned'",
'parentName:',
'branchName:',
'manager:',
'certification:',
'/pages/genealogy/g08-join-application?source=search&genealogyId=',
'/pages/genealogy/g08-join-application?source=invite&genealogyId=',
'/pages/genealogy/g05-genealogy-overview?mode=public&genealogyId=',
'search-page__header',
'g06-search-input-wide.png',
'g06-search-button.png',
@@ -50,6 +62,10 @@ foreach ($required in @(
Assert-Contains -Content $g06 -Expected $required -Message "Missing G06 flow contract: $required"
}
foreach ($forbidden in @("from '@/utils/api.js'", 'appApi.', 'uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
if ($g06 -match [regex]::Escape($forbidden)) { throw "G06 retains forbidden implementation: $forbidden" }
}
if ($g06 -match [regex]::Escape('appApi.applyToJoin')) { throw 'G06 must navigate to G08 instead of submitting an application directly' }
if ($g06 -match [regex]::Escape('g06-search-panel-v2.png')) { throw 'G06 must not embed the search controls in a large panel image' }
if ($g06 -match [regex]::Escape('search-hero')) { throw 'G06 must replace the floating hero with the selected title-strip hierarchy' }
+59 -41
View File
@@ -1,98 +1,116 @@
const assert = require('assert')
const origin = process.argv[2] || 'http://localhost:5173'
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith('http://localhost:5173'))
if (!page) throw new Error('Chrome debugging has no localhost:5173 page')
const port = process.env.CHROME_DEBUGGING_PORT || '9222'
const pages = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith(origin))
if (!page) throw new Error('Chrome debugging has no localhost application page')
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true })
})
let id = 0
const pending = new Map()
const exceptions = []
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
if (message.method === 'Runtime.exceptionThrown') exceptions.push(message.params.exceptionDetails.text)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
if (message.error) request.reject(new Error(message.error.message))
else request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
return { socket, send }
return { socket, send, exceptions }
}
const valueOf = async (send, expression) => {
const result = await send('Runtime.evaluate', { expression, returnByValue: true })
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text)
return result.result?.value
}
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 40; attempt += 1) {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(100)
}
throw new Error(message)
}
const setSearchKeyword = async (send, value) => {
const expression = `(() => {
const input = document.querySelector('.search-input input')
const action = document.querySelector('.search-action')
if (!input || !action) return false
input.value = ${JSON.stringify(value)}
input.dispatchEvent(new Event('input', { bubbles: true }))
action.click()
return true
})()`
if (!await valueOf(send, expression)) throw new Error('Could not enter and submit a G06 search')
let navigationId = 0
const openG06 = async (send, mode = 'search') => {
navigationId += 1
const modeQuery = mode === 'invite' ? '?mode=invite' : ''
const url = `${origin}/?g06Audit=${navigationId}#/pages/genealogy/g06-search-genealogies${modeQuery}`
await send('Page.navigate', { url })
await waitFor(send, `document.querySelector('.mode-tab--active')?.textContent.includes(${JSON.stringify(mode === 'invite' ? '邀请码' : '搜索')})`, `G06 ${mode} mode did not render`)
}
const origin = process.argv[2] || 'http://localhost:5173'
const g06Url = `${origin}/#/pages/genealogy/g06-search-genealogies`
const openG06 = async (send) => {
await send('Page.navigate', { url: g06Url })
await waitFor(send, `location.href === ${JSON.stringify(g06Url)}`, 'Browser did not navigate back to G06')
await send('Page.reload')
await waitFor(send, "Boolean(document.querySelector('.search-initial'))", 'G06 initial state did not render')
const setInput = async (send, selector, value) => {
await valueOf(send, `(() => {
const input = document.querySelector(${JSON.stringify(selector)})
if (!input) return false
input.value = ${JSON.stringify(value)}
input.dispatchEvent(new Event('input', { bubbles: true }))
return true
})()`)
await sleep(100)
}
const run = async () => {
const { socket, send } = await connect()
const { socket, send, exceptions } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
await openG06(send)
if (await valueOf(send, "Boolean(document.querySelector('.genealogy-card'))")) {
throw new Error('G06 initial state must not reveal a result card')
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.genealogy-card'))"), false, 'G06 initial state revealed results')
await setInput(send, '.search-input input', '汤')
await valueOf(send, "document.querySelector('.search-action').click()")
await waitFor(send, "document.querySelectorAll('.search-results .result-card').length === 6", 'G06 did not render all six relation states')
const firstCardText = await valueOf(send, "document.querySelector('.result-card').textContent")
for (const detail of ['地区', '所属上级谱', '当前支系', '管理信息', '位成员', '更新于']) {
assert(firstCardText.includes(detail), `G06 result card is missing ${detail}`)
}
await setSearchKeyword(send, '汤')
await waitFor(send, "Boolean(document.querySelector('.search-results .genealogy-card'))", 'G06 matching search did not render a result card')
await valueOf(send, "document.querySelector('.search-results .genealogy-card')?.click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g08-join-application?genealogyId=')", 'Selecting a G06 search result did not open G08')
await valueOf(send, "document.querySelector('.result-card').click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g05-genealogy-overview?mode=public&genealogyId=')", 'G06 available card did not open G05 public preview')
await openG06(send)
await setSearchKeyword(send, '不存在的家谱')
await waitFor(send, "Boolean(document.querySelector('.search-empty'))", 'G06 empty search state did not render')
if (await valueOf(send, "Boolean(document.querySelector('.search-empty .genealogy-card'))")) {
throw new Error('G06 empty search state must not contain a result card')
await setInput(send, '.search-input input', '不存在的家谱')
await valueOf(send, "document.querySelector('.search-action').click()")
await waitFor(send, "Boolean(document.querySelector('.search-empty'))", 'G06 empty result state did not render')
await openG06(send, 'invite')
await setInput(send, '.invite-input input', 'BAD-CODE')
await valueOf(send, "document.querySelector('.search-action').click()")
await waitFor(send, "Boolean(document.querySelector('.invite-invalid'))", 'G06 invalid invite state did not render')
await setInput(send, '.invite-input input', 'JP2026')
await valueOf(send, "document.querySelector('.search-action').click()")
await waitFor(send, "Boolean(document.querySelector('.invite-result .result-card'))", 'G06 valid invite target did not render')
await valueOf(send, "document.querySelector('.invite-result .result-card__action').click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g08-join-application?source=invite&genealogyId=')", 'G06 invite action did not open G08 invite source')
for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) {
await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true })
await openG06(send)
const width = await valueOf(send, 'document.documentElement.scrollWidth')
assert(width <= size.width + 1, `G06 has horizontal overflow at ${size.width}x${size.height}`)
}
assert.deepStrictEqual(exceptions, [], `G06 raised browser exceptions: ${exceptions.join('; ')}`)
process.stdout.write('G06-SEARCH-FLOW-RUNTIME-SMOKE PASS\n')
} finally {
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
socket.close()
}
}
+24 -3
View File
@@ -35,8 +35,15 @@ foreach ($required in @(
'a01-primary-button.png',
'const genealogyPreview',
'query.genealogyId',
'/pages/genealogy/g09-my-applications'
"const source = ref('search')",
'const sourceContract = computed(() =>',
'const fieldErrors = reactive({',
'/pages/genealogy/g09-my-applications',
'/pages/genealogy/g01-my-genealogies?genealogyId='
)) { Assert-Contains $g08 $required "Missing G08 join contract: $required" }
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
if ($g08 -match [regex]::Escape($nativeUi)) { throw "G08 must not use native UniApp UI: $nativeUi" }
}
foreach ($required in @(
"const applicationState = ref('loading')",
@@ -47,8 +54,16 @@ foreach ($required in @(
"'PENDING'",
"'APPROVED'",
"'REJECTED'",
'const applicationSamples'
"'WITHDRAWN'",
'const applicationSamples',
'const actionFor = (item) =>',
'const withdrawTarget = ref(null)',
'class="withdraw-layer"',
'/pages/genealogy/g08-join-application?source=search&previous=rejected&genealogyId='
)) { Assert-Contains $g09 $required "Missing G09 application contract: $required" }
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
if ($g09 -match [regex]::Escape($nativeUi)) { throw "G09 must not use native UniApp UI: $nativeUi" }
}
foreach ($required in @(
"const reviewState = ref('loading')",
@@ -60,8 +75,14 @@ foreach ($required in @(
'a01-primary-button.png',
'a01-secondary-button.png',
'const reviewSamples',
'uni.showModal'
'const confirmation = ref(null)',
'const helpVisible = ref(false)',
'class="review-dialog-layer"',
'review-state--no-permission'
)) { Assert-Contains $g10 $required "Missing G10 review contract: $required" }
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
if ($g10 -match [regex]::Escape($nativeUi)) { throw "G10 must not use native UniApp UI: $nativeUi" }
}
foreach ($className in @('join-panel', 'join-action', 'application-card', 'review-action')) {
foreach ($page in @($g08, $g09, $g10)) { Assert-NoCssSurface $page $className }
@@ -12,8 +12,10 @@ const connect = async () => {
})
let id = 0
const pending = new Map()
const exceptions = []
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
if (message.method === 'Runtime.exceptionThrown') exceptions.push(message.params.exceptionDetails.text)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
@@ -24,10 +26,14 @@ const connect = async () => {
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
return { socket, send }
return { socket, send, exceptions }
}
const valueOf = async (send, expression) => (await send('Runtime.evaluate', { expression, returnByValue: true })).result?.value
const valueOf = async (send, expression) => {
const result = await send('Runtime.evaluate', { expression, returnByValue: true })
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text)
return result.result?.value
}
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 40; attempt += 1) {
if (await valueOf(send, expression)) return
@@ -45,7 +51,7 @@ const open = async (send, route, query, selector) => {
}
const run = async () => {
const { socket, send } = await connect()
const { socket, send, exceptions } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
@@ -63,12 +69,56 @@ const run = async () => {
})()`)
if (!prepared) throw new Error('G08 form controls could not be prepared')
await waitFor(send, "Boolean(document.querySelector('.join-state--success'))", 'G08 real form submission did not reach the same-page success state')
await open(send, '/pages/genealogy/g08-join-application', '?source=invite&genealogyId=2001', '.join-state--form')
await valueOf(send, "document.querySelector('.join-action').click()")
await waitFor(send, "document.querySelectorAll('.join-field-error').length === 2", 'G08 invite source did not show two inline required errors')
const invitePrepared = await valueOf(send, `(() => {
const inputs = Array.from(document.querySelectorAll('.join-field input'))
inputs[0].value = '汤明远'
inputs[0].dispatchEvent(new Event('input', { bubbles: true }))
inputs[1].value = '汤正华堂侄'
inputs[1].dispatchEvent(new Event('input', { bubbles: true }))
return true
})()`)
if (!invitePrepared) throw new Error('G08 invite controls could not be prepared')
await sleep(100)
await valueOf(send, "document.querySelector('.join-action').click()")
await waitFor(send, "Boolean(document.querySelector('.join-state--success'))", 'G08 invite source did not reach success')
if (!(await valueOf(send, "document.body.textContent.includes('无需等待审核')"))) throw new Error('G08 invite source did not preserve direct-join semantics')
await valueOf(send, "document.querySelector('.join-result .join-action').click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g01-my-genealogies?genealogyId=2001')", 'G08 invite success did not return to selected G01 genealogy')
for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) {
await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true })
await open(send, '/pages/genealogy/g08-join-application', '?source=search&genealogyId=2001', '.join-state--form')
const width = await valueOf(send, 'document.documentElement.scrollWidth')
if (width > size.width + 1) throw new Error(`G08 has horizontal overflow at ${size.width}x${size.height}`)
}
await open(send, '/pages/genealogy/g09-my-applications', '', '.application-state--list')
if ((await valueOf(send, "document.querySelectorAll('.application-card__action').length")) !== 3) throw new Error('G09 list did not render the three status-specific actions')
await valueOf(send, "document.querySelector('.application-card__action').click()")
await waitFor(send, "Boolean(document.querySelector('.withdraw-layer'))", 'G09 pending action did not open custom withdrawal confirmation')
await valueOf(send, "document.querySelector('.withdraw-action--danger').click()")
await waitFor(send, "document.querySelector('.application-card__status')?.textContent.includes('已撤回')", 'G09 withdrawal did not update the local status')
await valueOf(send, "document.querySelectorAll('.application-card__action')[1].click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g08-join-application?source=search&previous=rejected&genealogyId=')", 'G09 rejected action did not reopen G08 search source')
await open(send, '/pages/genealogy/g09-my-applications', '?state=empty', '.application-state--empty')
await open(send, '/pages/genealogy/g10-application-review', '?genealogyId=1001', '.review-state--list')
await valueOf(send, "document.querySelectorAll('.review-action')[1].click()")
await waitFor(send, "Boolean(document.querySelector('.review-dialog-layer'))", 'G10 approve action did not open custom confirmation')
await valueOf(send, "document.querySelector('.review-dialog__action--primary').click()")
await waitFor(send, "document.querySelector('.application-card__status')?.textContent.includes('已通过')", 'G10 approval did not update the card')
await waitFor(send, "Boolean(document.querySelector('.review-feedback'))", 'G10 approval did not show custom feedback')
await valueOf(send, "document.querySelector('.header-action').click()")
await waitFor(send, "document.querySelector('.review-dialog__title')?.textContent.includes('审核说明')", 'G10 help did not use the custom dialog')
await valueOf(send, "document.querySelector('.review-dialog__single').click()")
await open(send, '/pages/genealogy/g10-application-review', '?state=empty&genealogyId=1001', '.review-state--empty')
await open(send, '/pages/genealogy/g10-application-review', '?state=no-permission&genealogyId=1001', '.review-state--no-permission')
if (exceptions.length) throw new Error(`G08-G10 raised browser exceptions: ${exceptions.join('; ')}`)
process.stdout.write('G08-G10-APPLICATION-FLOW-RUNTIME-SMOKE PASS\n')
} finally {
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
socket.close()
}
}
+12
View File
@@ -31,6 +31,9 @@ foreach ($required in @(
'settings-state--form',
'settings-state--success',
'settings-state--error',
'settings-state--no-permission',
'const nameError = ref',
'class="settings-feedback"',
'g06-search-input-wide.png',
'a01-primary-button.png',
'const genealogyDraft',
@@ -45,6 +48,9 @@ foreach ($required in @(
'poem-state--empty',
'poem-state--edit',
'poem-state--error',
'poem-state--no-permission',
'const poemError = ref',
'class="poem-feedback"',
'g06-search-input-wide.png',
'a01-primary-button.png',
'a01-secondary-button.png',
@@ -53,6 +59,12 @@ foreach ($required in @(
'query.genealogyId'
)) { Assert-Contains $g12 $required "Missing G12 generation-poem contract: $required" }
foreach ($page in @($g11, $g12)) {
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
if ($page -match [regex]::Escape($nativeUi)) { throw "G11-G12 must not use native UniApp UI: $nativeUi" }
}
}
foreach ($className in @('settings-panel', 'settings-action', 'poem-row', 'poem-editor', 'poem-action')) {
foreach ($page in @($g11, $g12)) { Assert-NoCssSurface $page $className }
}
+3
View File
@@ -35,6 +35,9 @@ $formComponentPath = Join-Path $root 'components/tree/TreeMemberForm.vue'
if (-not (Test-Path -LiteralPath $formComponentPath)) { throw 'T04-T06 require shared TreeMemberForm component' }
$form = Get-Content -LiteralPath $formComponentPath -Raw -Encoding utf8
if ($form -match "@/utils/api\.js|\bappApi\b") { throw 'TreeMemberForm must not connect the API layer' }
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
if ($form -match [regex]::Escape($nativeUi)) { throw "TreeMemberForm must use project-owned feedback instead of $nativeUi" }
}
foreach ($required in @('form-state--form', 'form-state--success', 'form-state--conflict', 'form-state--error', 'g03-create-flow-panel.png', 'g06-search-input-wide.png', 'a01-primary-button.png', 'a01-secondary-button.png')) {
Assert-Contains $form $required "Missing shared form contract: $required"
}