验收20%
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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*:',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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*:',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user