test: replace legacy suite with core project checks

This commit is contained in:
2026-08-12 18:23:30 +08:00
parent cc706378c2
commit 8207965754
242 changed files with 138 additions and 30996 deletions
+5
View File
@@ -1,6 +1,11 @@
{
"name": "jiapuapp",
"private": true,
"scripts": {
"check": "node scripts/check-project.mjs && npm --prefix design-pipeline run check",
"check:project": "node scripts/check-project.mjs",
"check:assets": "node design-pipeline/scripts/validate-runtime-asset-inventory.mjs design-pipeline/manifests/runtime-assets.json"
},
"devDependencies": {
"yaml": "^2.8.1"
}
+133
View File
@@ -0,0 +1,133 @@
import fs from 'node:fs'
import path from 'node:path'
import YAML from 'yaml'
const workspace = process.cwd()
const failures = []
const fail = (message) => failures.push(message)
const readText = (filePath) => fs.readFileSync(path.join(workspace, filePath), 'utf8')
const listFiles = (entryPaths, extensions) => {
const files = []
const visit = (entryPath) => {
const absolutePath = path.join(workspace, entryPath)
if (!fs.existsSync(absolutePath)) return
const stat = fs.statSync(absolutePath)
if (stat.isDirectory()) {
for (const name of fs.readdirSync(absolutePath)) {
visit(path.join(entryPath, name))
}
return
}
if (extensions.has(path.extname(entryPath))) files.push(entryPath)
}
entryPaths.forEach(visit)
return files
}
const parseJson = (filePath) => {
try {
return JSON.parse(readText(filePath))
} catch (error) {
fail(`${filePath} 不是有效 JSON${error.message}`)
return null
}
}
const pagesConfig = parseJson('pages.json')
parseJson('manifest.json')
parseJson('package.json')
parseJson('package-lock.json')
const routeSource = readText('utils/navigation/routes.js')
const routeEntries = [...routeSource.matchAll(/^\s{2}([A-Z]\d{2}): defineRoute\(\{[\s\S]*?^\s{2}\}\),/gm)]
const routeKeys = routeEntries.map((match) => match[1])
const routePaths = routeEntries.map((match) => match[0].match(/path: "([^"]+)"/)?.[1]?.replace(/^\//, ''))
const configuredPages = pagesConfig?.pages?.map((page) => page.path) || []
if (new Set(routeKeys).size !== routeKeys.length) fail('路由键存在重复')
if (routePaths.some((routePath) => !routePath)) fail('存在没有静态 path 的路由')
for (const pagePath of configuredPages) {
if (!fs.existsSync(path.join(workspace, `${pagePath}.vue`))) fail(`页面文件缺失:${pagePath}.vue`)
if (!routePaths.includes(pagePath)) fail(`页面没有路由语义:${pagePath}`)
}
for (const routePath of routePaths) {
if (!configuredPages.includes(routePath)) fail(`路由没有在 pages.json 注册:${routePath}`)
}
const sourceFiles = listFiles(
['App.vue', 'main.js', 'pages', 'components', 'composables', 'services', 'utils'],
new Set(['.js', '.vue']),
)
const importPattern = /(?:from\s*|import\s*)["']([^"']+)["']/g
for (const filePath of sourceFiles) {
const source = readText(filePath)
if (filePath.endsWith('.vue') && !/<(?:template|script)(?:\s|>)/.test(source)) {
fail(`${filePath} 缺少 <template> 或 <script>`)
}
for (const match of source.matchAll(importPattern)) {
const specifier = match[1]
if (!specifier.startsWith('.') && !specifier.startsWith('@/')) continue
const basePath = specifier.startsWith('@/')
? path.join(workspace, specifier.slice(2))
: path.resolve(workspace, path.dirname(filePath), specifier)
const candidates = [basePath, `${basePath}.js`, `${basePath}.vue`, path.join(basePath, 'index.js')]
if (!candidates.some((candidate) => fs.existsSync(candidate))) {
fail(`${filePath} 导入不存在:${specifier}`)
}
}
}
const visualFiles = listFiles(
['App.vue', 'uni.scss', 'pages', 'components', 'styles'],
new Set(['.vue', '.scss']),
)
const assetPattern = /["'(]((?:\/@?static\/|@\/static\/)[^"')\s]+)["')]/g
for (const filePath of visualFiles) {
for (const match of readText(filePath).matchAll(assetPattern)) {
const assetPath = match[1].replace(/^@?\//, '')
if (!fs.existsSync(path.join(workspace, assetPath))) fail(`${filePath} 引用不存在的资源:${match[1]}`)
}
}
let openApi = null
try {
openApi = YAML.parse(readText('genealogy-app-openapi.yaml'))
} catch (error) {
fail(`genealogy-app-openapi.yaml 无法解析:${error.message}`)
}
if (fs.existsSync(path.join(workspace, '家谱.openapi.json'))) {
fail('检测到旧 OpenAPI:家谱.openapi.jsongenealogy-app-openapi.yaml 必须是唯一所有者')
}
const normalizeEndpointPath = (endpointPath) => endpointPath
.split('?')[0]
.replace(/\$\{[^}]+\}/g, '{}')
.replace(/\{[^}]+\}/g, '{}')
const documentedPaths = new Set(Object.keys(openApi?.paths || {}).map(normalizeEndpointPath))
const dynamicEndpointBuilders = new Set([
'/genealogy/app/genealogies/{}/{}',
'/genealogy/app/genealogies/{}/lineage/persons/{}/{}',
])
const apiFiles = listFiles(['services/api'], new Set(['.js']))
.filter((filePath) => filePath.endsWith('-service.js') || filePath.endsWith('request-client.js'))
const endpointPattern = /([`'"])(\/(?:genealogy|captcha|auth)[\s\S]*?)\1/g
for (const filePath of apiFiles) {
for (const match of readText(filePath).matchAll(endpointPattern)) {
const endpoint = match[2]
if (endpoint.includes('\n')) continue
const normalizedPath = normalizeEndpointPath(endpoint)
if (!documentedPaths.has(normalizedPath) && !dynamicEndpointBuilders.has(normalizedPath)) {
fail(`${filePath} 使用了 OpenAPI 未声明的路径:${endpoint}`)
}
}
}
if (failures.length > 0) {
console.error(`PROJECT CHECK FAILED (${failures.length})`)
failures.forEach((message) => console.error(`- ${message}`))
process.exit(1)
}
console.log(`PROJECT CHECK PASS pages=${configuredPages.length} routes=${routeKeys.length} sources=${sourceFiles.length}`)
-256
View File
@@ -1,256 +0,0 @@
$ErrorActionPreference = 'Stop'
function ConvertFrom-Utf8Base64 {
param([string]$Value)
return [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Value))
}
function Assert-Contains {
param(
[string]$Content,
[string]$Expected,
[string]$Message
)
if ($Content -notmatch [regex]::Escape($Expected)) {
throw $Message
}
}
function Assert-NotContains {
param(
[string]$Content,
[string]$Unexpected,
[string]$Message
)
if ($Content -match [regex]::Escape($Unexpected)) {
throw $Message
}
}
$root = Split-Path -Parent $PSScriptRoot
$entryPath = Join-Path $root 'pages/auth/a01-entry.vue'
$shellPath = Join-Path $root 'components/AuthPageShell.vue'
$legacyLoginPath = Join-Path $root 'pages/auth/a02-login.vue'
$pagesPath = Join-Path $root 'pages.json'
$globalStylesPath = Join-Path $root 'styles/global.scss'
$profilePath = Join-Path $root 'styles/adaptive-frame-profiles.scss'
if (-not (Test-Path -LiteralPath $entryPath)) {
throw 'Missing the single A01 login page.'
}
if (Test-Path -LiteralPath $legacyLoginPath) {
throw 'A02 was merged into A01, so the legacy page file must be removed.'
}
$pages = Get-Content -LiteralPath $pagesPath -Raw -Encoding utf8 | ConvertFrom-Json
if ($pages.pages[0].path -ne 'pages/auth/a01-entry') {
throw 'A01 must remain the first application route.'
}
if ($pages.pages.path -contains 'pages/auth/a02-login') {
throw 'pages.json must not retain the legacy A02 route.'
}
if ($pages.pages.Count -ne 52) {
throw "Expected 52 active routes after the A02 merge and A06 archive, found $($pages.pages.Count)."
}
# A01 是 A02 退役边界的唯一所有者;所有仍保留的认证源码都不得重新引用旧登录页。
foreach ($relativePath in @(
'pages/auth/a01-entry.vue',
'pages/auth/a04-register.vue',
'pages/auth/a05-reset-password.vue',
'pages/auth/a06-auth-status.vue'
)) {
$authSource = Get-Content -LiteralPath (Join-Path $root $relativePath) -Raw -Encoding utf8
Assert-NotContains -Content $authSource -Unexpected '/pages/auth/a02-login' -Message "Legacy A02 navigation remains in $relativePath."
}
$entry = Get-Content -LiteralPath $entryPath -Raw -Encoding utf8
$shell = Get-Content -LiteralPath $shellPath -Raw -Encoding utf8
$globalStyles = Get-Content -LiteralPath $globalStylesPath -Raw -Encoding utf8
$profiles = Get-Content -LiteralPath $profilePath -Raw -Encoding utf8
# A01 owns content in normal document flow. Decorative artwork must not become
# a second full-page coordinate system.
foreach ($required in @(
'class="auth-shell__header"',
'class="auth-shell__header-image"',
'a01-vnext-header-v1.png',
'mode="widthFix"',
'class="auth-shell__paper"',
'auth-page-paper.jpg'
)) {
Assert-Contains -Content $shell -Expected $required -Message "A01 adaptive shell is missing: $required"
}
Assert-Contains -Content $entry -Expected '<AuthPageShell' -Message 'A01 must consume the shared adaptive shell.'
foreach ($forbidden in @(
'class="page-canvas"',
'class="page-backdrop"',
'mode="scaleToFill"',
'--auth-paper-start',
'1665rpx'
)) {
Assert-NotContains -Content $entry -Unexpected $forbidden -Message "A01 still uses rejected page coordinates: $forbidden"
Assert-NotContains -Content $shell -Unexpected $forbidden -Message "The adaptive shell uses rejected page coordinates: $forbidden"
}
Assert-Contains -Content $shell -Expected 'a01-red-hall-ink-backdrop-v1.png' -Message 'The shared auth shell must preserve the approved paper scenery as decoration.'
$pageRule = [regex]::Match($shell, '(?ms)^\.auth-shell\s*\{(?<Body>.*?)^\}')
$headerRule = [regex]::Match($shell, '(?ms)^\.auth-shell__header\s*\{(?<Body>.*?)^\}')
$paperRule = [regex]::Match($shell, '(?ms)^\.auth-shell__paper\s*\{(?<Body>.*?)^\}')
$contentRule = [regex]::Match($entry, '(?ms)^\.login-content\s*\{(?<Body>.*?)^\}')
foreach ($rule in @($pageRule, $headerRule, $paperRule, $contentRule)) {
if (-not $rule.Success) { throw 'A01 adaptive layout rule is missing.' }
}
Assert-Contains -Content $pageRule.Groups['Body'].Value -Expected 'display: flex;' -Message 'A01 root must use flex flow.'
Assert-Contains -Content $pageRule.Groups['Body'].Value -Expected 'flex-direction: column;' -Message 'A01 root must stack header and paper.'
Assert-Contains -Content $pageRule.Groups['Body'].Value -Expected 'min-height: var(--app-viewport-height);' -Message 'A01 root must fill without locking content height.'
if ($pageRule.Groups['Body'].Value -match '(?m)^\s*height:\s*var\(--app-viewport-height\);') {
throw 'A01 root must not lock content to the viewport.'
}
Assert-Contains -Content $headerRule.Groups['Body'].Value -Expected 'height: calc(var(--app-safe-top) + min(36.5vw, 175px));' -Message 'A01 header must use the approved compact visible slot without distorting its image.'
Assert-Contains -Content $headerRule.Groups['Body'].Value -Expected 'overflow: hidden;' -Message 'A01 header must crop only the excess lower doorway inside its decorative slot.'
Assert-Contains -Content $paperRule.Groups['Body'].Value -Expected 'flex: 1;' -Message 'A01 paper must receive remaining viewport space.'
Assert-Contains -Content $contentRule.Groups['Body'].Value -Expected 'justify-content: space-between;' -Message 'A01 must distribute spare height in normal flow.'
Assert-NotContains -Content $contentRule.Groups['Body'].Value -Unexpected 'grid-area:' -Message 'A01 interactive content must not overlap page artwork.'
# Typography has one owner, and the title decoration participates in normal flow.
Assert-Contains -Content $globalStyles -Expected 'font-family: "STKaiti", "KaiTi", serif' -Message 'Global styles must own the STKaiti/KaiTi font contract.'
Assert-NotContains -Content $entry -Unexpected 'font-family:' -Message 'A01 must inherit the global font contract instead of overriding it locally.'
$headingRule = [regex]::Match($entry, '(?ms)^\.login-heading\s*\{(?<Body>.*?)^\}')
if (-not $headingRule.Success) {
throw 'A01 is missing the login heading style rule.'
}
Assert-Contains -Content $headingRule.Groups['Body'].Value -Expected 'flex-direction: column;' -Message 'A01 login heading must lay out its title and divider vertically in normal flow.'
Assert-NotContains -Content $headingRule.Groups['Body'].Value -Unexpected 'position:' -Message 'A01 login heading must not use positioning for ordinary vertical layout.'
$titleRule = [regex]::Match($entry, '(?ms)^\.login-title\s*\{(?<Body>.*?)^\}')
if (-not $titleRule.Success) {
throw 'A01 is missing the login title style rule.'
}
Assert-NotContains -Content $titleRule.Groups['Body'].Value -Unexpected 'position:' -Message 'A01 login title must remain in normal flow.'
$dividerRule = [regex]::Match($entry, '(?ms)^\.title-divider\s*\{(?<Body>.*?)^\}')
if (-not $dividerRule.Success) {
throw 'A01 is missing the title divider style rule.'
}
foreach ($positioning in @('position: absolute', 'left: 50%', 'translateX(')) {
Assert-NotContains -Content $dividerRule.Groups['Body'].Value -Unexpected $positioning -Message "A01 title divider must not use manual positioning: $positioning"
}
Assert-Contains -Content $dividerRule.Groups['Body'].Value -Expected 'width: 280rpx;' -Message 'A01 compact title divider must remain visible on paper.'
Assert-Contains -Content $dividerRule.Groups['Body'].Value -Expected 'height: 46rpx;' -Message 'A01 compact title divider must retain a legible knot and line weight.'
Assert-Contains -Content $dividerRule.Groups['Body'].Value -Expected 'filter: brightness(0.68) saturate(1.5) contrast(1.2);' -Message 'A01 title divider must keep sufficient contrast against the paper background.'
$brandSealRule = [regex]::Match($shell, '(?ms)^\.auth-shell__seal\s*\{(?<Body>.*?)^\}')
if (-not $brandSealRule.Success) {
throw 'A01 is missing the brand seal style rule.'
}
Assert-Contains -Content $brandSealRule.Groups['Body'].Value -Expected 'position: absolute;' -Message 'A01 brand seal must be scoped as decoration inside the header.'
Assert-Contains -Content $brandSealRule.Groups['Body'].Value -Expected 'left: 50%;' -Message 'A01 brand seal must remain centered inside the header.'
Assert-Contains -Content $brandSealRule.Groups['Body'].Value -Expected 'transform: translateX(-50%);' -Message 'A01 brand seal must use header-local centering.'
# Final decorative surfaces must come from real bitmap assets.
foreach ($asset in @(
'a01-vnext-header-v1.png',
'auth-page-paper.jpg',
'brand-seal.png',
'a01-vnext-divider-v1.png',
'a01-scroll-primary-v3.png',
'a01-scroll-toast-v3.png',
'a01-scroll-dialog-v3.png',
'a01-icon-phone-v1.png',
'a01-icon-lock-v1.png',
'a01-icon-eye-open-v1.png',
'a01-icon-eye-closed-pupil-v2.png',
'a01-icon-sms-code-v2.png',
'a02-agreement-unchecked.png',
'a02-agreement-checked.png'
)) {
Assert-Contains -Content ($entry + $shell + $profiles) -Expected $asset -Message "A01 is missing asset reference: $asset"
}
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',
'a01-title-divider-v2.png',
'a01-primary-button-v2.png',
'a01-secondary-button-v2.png',
'a02-login-panel.png'
)) {
Assert-NotContains -Content $entry -Unexpected $obsoleteVisualAsset -Message "A01 must not retain the rejected header, scenery, or scroll asset: $obsoleteVisualAsset"
}
$buttonSkinTags = [regex]::Matches($entry, '<image\s+class="button-skin"[^>]+>')
if ($buttonSkinTags.Count -ne 1) {
throw "A01 must have exactly one supported login button skin, found $($buttonSkinTags.Count)."
}
foreach ($buttonSkinTag in $buttonSkinTags) {
Assert-Contains -Content $buttonSkinTag.Value -Expected 'mode="aspectFit"' -Message 'A01 button skins must use uniform aspectFit rendering.'
Assert-NotContains -Content $buttonSkinTag.Value -Unexpected '/opaque/' -Message 'A01 visible button skins must use the current transparent v3 assets.'
Assert-NotContains -Content $buttonSkinTag.Value -Unexpected 'mode="scaleToFill"' -Message 'A01 button skins must not distort fixed artwork with scaleToFill.'
}
$requiredCopy = @(
'55m75b2V5a626LCx',
'5a+G56CB55m75b2V',
'6aqM6K+B56CB55m75b2V',
'5omL5py65Y+3',
'5a+G56CB',
'6aqM6K+B56CB',
'5b+Y6K6w5a+G56CB',
'6I635Y+W6aqM6K+B56CB',
'6L+Y5rKh5pyJ6LSm5Y+377yf',
'5rOo5YaM6LSm5Y+3',
'44CK55So5oi35Y2P6K6u44CL',
'44CK6ZqQ56eB5pS/562W44CL'
) | ForEach-Object { ConvertFrom-Utf8Base64 $_ }
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")',
'const passwordVisible = ref(false)',
'const switchLoginMethod = (method) =>',
'const togglePasswordVisibility = () =>',
'<AppToast :visible="feedbackVisible" :message="feedbackMessage" />',
'<TacVerification',
'AUTH_VERIFICATION_OPERATION.SMS_LOGIN',
'normalizeTacSuccess',
'appApi.loginWithPassword',
'appApi.loginWithSms',
'calcMD5(password.value)',
'const preparePasswordLogin = async () =>',
'login-submit',
'class="agreement-error"',
'const agreementError = ref(false)'
)) {
Assert-Contains -Content $entry -Expected $contract -Message "A01 is missing state or interaction contract: $contract"
}
Assert-NotContains -Content $entry -Unexpected 'login-tab--unavailable' -Message 'A01 密码登录页签不得继续显示为不可用。'
Assert-NotContains -Content $entry -Unexpected 'PASSWORD_TAC_BLOCKED_MESSAGE' -Message 'A01 密码登录不得继续被旧硬关闭文案阻断。'
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
Assert-NotContains -Content $entry -Unexpected $nativeUi -Message "A01 must not use native UniApp feedback: $nativeUi"
}
Assert-NotContains -Content $entry -Unexpected "url: '/pages/auth/a02-login" -Message 'A01 must not navigate to A02.'
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 scroll naturally on short screens instead of compressing the selected design with height breakpoints.'
Assert-Contains -Content $entry -Expected 'import AppToast from "@/components/AppToast.vue";' -Message 'A01 feedback must consume the shared live-region Toast owner.'
Assert-NotContains -Content $entry -Unexpected 'class="feedback-toast"' -Message 'A01 must not duplicate the shared Toast implementation.'
Write-Output 'A01-LOGIN-MERGE-CONTRACT PASS'
@@ -1,36 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$retiredPaths = @(
'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',
'scripts/photoshop/a01-run-page-preview-v2.jsx',
'docs/design/assets/a01-vnext/source/a01-psd-manifest.json',
'docs/design/assets/a01-vnext/source/A01-layered-source-v1.psd',
'tests/a01-layered-psd-contract.ps1'
)
foreach ($relativePath in $retiredPaths) {
if (Test-Path -LiteralPath (Join-Path $root $relativePath)) {
throw "Empty Photoshop/PSD pipeline still exists: $relativePath"
}
}
foreach ($document in @(
'docs/项目当前总览.md',
'docs/家谱项目全量治理设计.md',
'docs/家谱项目全量治理实施计划.md',
'docs/视觉资产与构建基线.md'
)) {
$content = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root $document)
foreach ($relativePath in $retiredPaths) {
if ($content.Contains($relativePath)) {
throw "Current document still exposes retired Photoshop/PSD entry: $relativePath ($document)"
}
}
}
Write-Output 'A01-NO-PHOTOSHOP-PIPELINE-CONTRACT PASS'
-249
View File
@@ -1,249 +0,0 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const source = fs.readFileSync(
path.join(__dirname, "../pages/auth/a01-entry.vue"),
"utf8",
);
const match = source.match(/<script setup>([\s\S]*?)<\/script>/);
assert(match, "A01 script setup is missing");
const pageScript = match[1].replace(
/import[\s\S]*?from\s+["'][^"']+["'];\s*/g,
"",
);
const createHarnessFactory = (
goRootResult = true,
goRootError = null,
initialSessionToken = "",
captchaRequired = true,
) => new Function(
"goRootResult",
"goRootError",
"initialSessionToken",
"captchaRequired",
`
"use strict";
const calls = [];
const ref = (value) => ({ value });
const onBackPress = () => {};
const showCallbacks = [];
const onShow = (callback) => showCallbacks.push(callback);
const onUnload = () => {};
const AuthPageShell = {};
const AppToast = {};
const TacVerification = {};
const createRequestController = () => ({
abort() {},
bind() {},
release() {},
});
const isRequestCancelled = () => false;
const isSmsDeliveryOutcomeUnknown = () => false;
const createAuthSmsCooldown = ({ onChange }) => ({
start() {
onChange(60);
},
sync() {
onChange(0);
return 0;
},
dispose() {},
});
const appApi = {
async getCaptchaRequirement(payload) {
calls.push({ type: "require", payload: { ...payload } });
return {
required: captchaRequired,
providerCode: "TIANAI",
captchaType: "SLIDER",
sceneCode: "APP_PASSWORD_LOGIN",
ttlSeconds: 300,
};
},
async loginWithPassword(payload) {
calls.push({ type: "password-login", payload: { ...payload } });
return { access_token: "token-password" };
},
async sendSmsCode(payload) {
calls.push({ type: "sms-code", payload: { ...payload } });
return null;
},
async loginWithSms(payload) {
calls.push({ type: "sms-login", payload: { ...payload } });
return { access_token: "token-sms" };
},
};
const AUTH_VERIFICATION_OPERATION = Object.freeze({
PASSWORD_LOGIN: "password-login",
SMS_LOGIN: "sms-login",
});
const createTacRenderContext = (value) => ({ ...value });
const isAuthPhone = (value) => /^1[3-9]\\d{9}$/.test(value);
const normalizeCaptchaRequirement = (value) => {
if (typeof value?.required !== "boolean") {
throw new Error("requirement invalid");
}
return value;
};
const normalizeTacSuccess = (value, expectedRequestId) => {
if (
!value ||
value.requestId !== expectedRequestId ||
typeof value.validToken !== "string" ||
!value.validToken
) {
throw new Error("TAC result invalid");
}
return value;
};
const runtimeConfig = {
baseUrl: "https://backend-api.ddxcjp.cn",
clientId: "client-1",
tenantId: "000000",
};
const calcMD5 = (value) => "md5:" + value;
const session = {
getToken: () => initialSessionToken,
};
const goRoot = async (pageId) => {
calls.push({ type: "go-root", pageId });
if (goRootError) throw goRootError;
return goRootResult;
};
const handleBackPress = () => false;
const openPage = () => {};
const runBackGuard = () => false;
const setTimeout = () => 1;
const clearTimeout = () => {};
const setInterval = () => 1;
const clearInterval = () => {};
${pageScript}
return {
calls,
phone,
password,
agreed,
tacVisible,
tacContext,
submitting,
authenticationCommitted,
feedbackMessage,
submitLogin,
completeTac,
closeTac,
async triggerShow() {
for (const callback of showCallbacks) await callback();
},
setPendingTacAction(value) {
pendingTacAction = value;
},
};
`,
)(goRootResult, goRootError, initialSessionToken, captchaRequired);
const createHarness = (options = {}) =>
createHarnessFactory(
options.goRootResult ?? true,
options.goRootError ?? null,
options.initialSessionToken ?? "",
options.captchaRequired ?? true,
);
const countCalls = (harness, type) =>
harness.calls.filter((call) => call.type === type).length;
const preparePassword = async (harness, passwordValue = "secret-1") => {
harness.phone.value = "13800138000";
harness.password.value = passwordValue;
harness.agreed.value = true;
await harness.submitLogin();
assert.deepStrictEqual(harness.calls.map((call) => call.type), ["require"]);
assert.strictEqual(harness.submitting.value, false);
assert.strictEqual(harness.tacVisible.value, true);
assert(harness.tacContext.value?.requestId?.startsWith("a01-password-"));
};
const completePasswordTac = (harness) =>
harness.completeTac({
requestId: harness.tacContext.value.requestId,
validToken: "ticket-1",
expireSeconds: 300,
});
const run = async () => {
const restored = createHarness({ initialSessionToken: "persisted-token" });
await restored.triggerShow();
assert.deepStrictEqual(
restored.calls.map((call) => call.type),
["go-root"],
"冷启动时已有会话必须直接进入家谱根页",
);
assert.strictEqual(restored.calls[0].pageId, "G01");
assert.strictEqual(restored.authenticationCommitted.value, true);
const success = createHarness();
await preparePassword(success);
assert.strictEqual(countCalls(success, "password-login"), 0);
await completePasswordTac(success);
assert.deepStrictEqual(
success.calls.map((call) => call.type),
["require", "password-login", "go-root"],
);
assert.deepStrictEqual(success.calls[1].payload, {
phone: "13800138000",
passwordHash: "md5:secret-1",
validToken: "ticket-1",
});
assert.strictEqual(success.calls[2].pageId, "G01");
assert.strictEqual(countCalls(success, "require"), 1);
assert.strictEqual(countCalls(success, "sms-code"), 0);
assert.strictEqual(success.tacVisible.value, false);
assert.strictEqual(success.tacContext.value, null);
const noCaptcha = createHarness({ captchaRequired: false });
noCaptcha.phone.value = "13800138000";
noCaptcha.password.value = "secret-1";
noCaptcha.agreed.value = true;
await noCaptcha.submitLogin();
assert.deepStrictEqual(
noCaptcha.calls.map((call) => call.type),
["require", "password-login", "go-root"],
"服务端关闭 password-login TAC 时必须直接走已发布密码登录请求",
);
assert.strictEqual(noCaptcha.tacVisible.value, false);
const navigationRejected = createHarness({ goRootResult: false });
await preparePassword(navigationRejected);
await completePasswordTac(navigationRejected);
assert.strictEqual(navigationRejected.authenticationCommitted.value, true);
assert.match(navigationRejected.feedbackMessage.value, /登录已完成/);
assert.strictEqual(countCalls(navigationRejected, "password-login"), 1);
await navigationRejected.submitLogin();
assert.strictEqual(countCalls(navigationRejected, "password-login"), 1);
assert.strictEqual(countCalls(navigationRejected, "go-root"), 2);
const cancelled = createHarness();
await preparePassword(cancelled);
cancelled.closeTac();
await cancelled.completeTac({
requestId: "stale-request",
validToken: "ticket-stale",
expireSeconds: 300,
});
assert.strictEqual(countCalls(cancelled, "password-login"), 0);
assert.strictEqual(countCalls(cancelled, "sms-code"), 0);
process.stdout.write("A01-PASSWORD-LOGIN-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
-202
View File
@@ -1,202 +0,0 @@
const assert = require('assert')
const url = 'http://localhost:5173/#/pages/auth/a01-entry'
const sizes = [
{ width: 320, height: 568 },
{ width: 360, height: 616 },
{ width: 360, height: 640 },
{ width: 360, height: 800 },
{ width: 412, height: 915 },
{ width: 480, height: 1040 }
]
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 waitForFreshDocument = async (send, previousTimeOrigin) => {
await waitFor(
send,
`performance.timeOrigin !== ${JSON.stringify(previousTimeOrigin)}`,
'A01 navigation did not create a fresh document'
)
}
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 waitFor(send, `location.href === ${JSON.stringify(url)}`, `A01 did not navigate at ${size.width}x${size.height}`)
const previousTimeOrigin = await valueOf(send, 'performance.timeOrigin')
await send('Page.reload')
await waitForFreshDocument(send, previousTimeOrigin)
await waitFor(send, "document.querySelectorAll('.login-tab').length === 2", `A01 did not render at ${size.width}x${size.height}`)
await waitFor(send, "document.querySelector('.auth-shell__header-image img')?.naturalWidth === 824", `A01 header did not load at ${size.width}x${size.height}`)
const metrics = await valueOf(send, `(() => {
const root = document.querySelector('.auth-page')
const header = document.querySelector('.auth-shell__header')
const headerImage = document.querySelector('.auth-shell__header-image img')
const paper = document.querySelector('.auth-shell__paper')
const content = document.querySelector('.login-content')
const headerRect = header?.getBoundingClientRect()
const paperRect = paper?.getBoundingClientRect()
const contentRect = content?.getBoundingClientRect()
return {
innerWidth: window.innerWidth,
innerHeight: window.innerHeight,
scrollWidth: document.documentElement.scrollWidth,
documentScrollHeight: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight),
rootScrollHeight: root?.scrollHeight,
headerBottom: headerRect?.bottom,
headerImageWidth: headerImage?.getBoundingClientRect().width,
headerImageHeight: headerImage?.getBoundingClientRect().height,
headerNaturalWidth: headerImage?.naturalWidth,
headerNaturalHeight: headerImage?.naturalHeight,
paperTop: paperRect?.top,
contentTop: contentRect?.top,
agreementBottom: document.querySelector('.agreement-area')?.getBoundingClientRect().bottom
}
})()`)
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}`)
assert.deepStrictEqual([metrics.headerNaturalWidth, metrics.headerNaturalHeight], [824, 340], `A01 header asset mismatch at ${size.width}x${size.height}`)
assert(Math.abs(metrics.headerImageHeight / metrics.headerImageWidth - 340 / 824) < 0.01, `A01 header is distorted at ${size.width}x${size.height}`)
assert(metrics.paperTop >= metrics.headerBottom - 1, `A01 paper overlaps the header at ${size.width}x${size.height}`)
assert(metrics.contentTop >= metrics.paperTop, `A01 content escapes paper flow at ${size.width}x${size.height}`)
if (size.width >= 360 && size.height >= 640) {
assert(metrics.rootScrollHeight <= metrics.innerHeight + 1, `A01 SMS state must fit at ${size.width}x${size.height}`)
assert(metrics.agreementBottom <= metrics.innerHeight + 1, `A01 SMS agreement must remain visible at ${size.width}x${size.height}`)
} else {
assert(metrics.agreementBottom <= metrics.documentScrollHeight + 1, `A01 SMS agreement must remain reachable at ${size.width}x${size.height}`)
}
const buttonAssets = await valueOf(send, `Array.from(document.querySelectorAll('.button-skin img')).map((image) => ({
src: image.currentSrc || image.src,
naturalWidth: image.naturalWidth,
naturalHeight: image.naturalHeight
}))`)
assert.strictEqual(buttonAssets.length, 2, `A01 button skins did not render at ${size.width}x${size.height}`)
assert(buttonAssets[0].src.includes('a01-scroll-primary-v3.png'), `A01 primary v3 skin is missing at ${size.width}x${size.height}`)
assert(buttonAssets[1].src.includes('a01-scroll-secondary-v3.png'), `A01 secondary v3 skin is missing at ${size.width}x${size.height}`)
assert.deepStrictEqual(
buttonAssets.map(({ naturalWidth, naturalHeight }) => [naturalWidth, naturalHeight]),
[[1866, 276], [1866, 300]],
`A01 v3 button assets have unexpected runtime dimensions at ${size.width}x${size.height}`
)
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 at ${size.width}x${size.height}`)
const smsMetrics = await valueOf(send, `({
documentScrollHeight: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight),
rootScrollHeight: document.querySelector('.auth-page')?.scrollHeight,
agreementBottom: document.querySelector('.agreement-area')?.getBoundingClientRect().bottom
})`)
if (size.width >= 360 && size.height >= 640) {
assert(smsMetrics.rootScrollHeight <= metrics.innerHeight + 1, `A01 SMS state must fit at ${size.width}x${size.height}`)
assert(smsMetrics.agreementBottom <= metrics.innerHeight + 1, `A01 SMS agreement must remain visible at ${size.width}x${size.height}`)
} else {
assert(smsMetrics.agreementBottom <= smsMetrics.documentScrollHeight + 1, `A01 SMS agreement must remain reachable at ${size.width}x${size.height}`)
}
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 at ${size.width}x${size.height}`)
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.password-toggle'))"), true, `A01 password form did not render at ${size.width}x${size.height}`)
}
await valueOf(send, "document.querySelector('.login-submit').click()")
await waitFor(send, "Boolean(document.querySelector('.app-toast'))", 'A01 invalid phone Toast did not render')
const toastBorderImage = await valueOf(send, "getComputedStyle(document.querySelector('.app-toast')).borderImageSource")
assert(toastBorderImage.includes('a01-scroll-toast-v3.png'), `A01 Toast did not render the v3 nine-slice asset: ${toastBorderImage}`)
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(await valueOf(send, "Boolean(document.querySelector('.forgot-password'))"), 'A01 SMS state must retain the password-recovery entry')
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 = '1234'
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-toggle').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,
"document.querySelector('.app-toast__copy')?.textContent === '当前为本地预览模式,真实认证服务未启用'",
'A01 local preview did not reject a fake SMS login'
)
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.verification-layer'))"), false, 'A01 retained the obsolete fake verification layer')
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.tac-layer--visible'))"), false, 'A01 opened TAC without a server-bindable password-login ticket')
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)
})
@@ -1,66 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$retiredPaths = @(
'design-pipeline/manifests/a01.json',
'design-pipeline/manifests/a01-buttons-v2.json',
'design-pipeline/manifests/a01-scroll-skins-v3.json',
'design-pipeline/scripts/build-a01.mjs',
'design-pipeline/scripts/build-a01-scroll-skins.mjs',
'design-pipeline/scripts/verify-a01-scroll-skins.mjs',
'design-pipeline/scripts/manifest-v2.mjs',
'design-pipeline/scripts/validate-manifest-v2.mjs',
'design-pipeline/scripts/rebuild-a01-buttons.mjs',
'design-pipeline/scripts/rebuild_a01_buttons.py',
'design-pipeline/scripts/verify-assets.mjs',
'design-pipeline/tests/manifest-v2.test.mjs',
'design-pipeline/tests/scroll-skins-v3.test.mjs',
'design-pipeline/tests/test_rebuild_a01_buttons.py',
'docs/design/assets/a01-vnext/masters/a01-scroll-dialog-master-v3.png',
'static/assets/foundation/opaque/a01-primary-button.png',
'static/assets/foundation/opaque/a01-secondary-button.png',
'static/assets/foundation/transparent/a01-primary-button-v2.png',
'static/assets/foundation/transparent/a01-secondary-button-v2.png',
'static/assets/modules/auth/opaque/a02-login-panel.png',
'static/assets/modules/auth/transparent/a01-vnext-eye-closed-pupil-v1.png',
'static/assets/modules/auth/transparent/a01-vnext-eye-open-v1.png',
'static/assets/modules/auth/transparent/a01-vnext-phone-v1.png',
'static/assets/modules/auth/transparent/a01-vnext-primary-button-v1.png',
'static/assets/modules/auth/transparent/a01-vnext-scroll-v1.png',
'static/assets/modules/auth/transparent/a01-vnext-secondary-button-v1.png',
'static/assets/modules/auth/transparent/a01-vnext-sms-three-dots-v1.png',
'static/assets/modules/auth/transparent/a01-vnext-title-ornament-v1.png',
'tests/a01-code-pipeline-contract.ps1',
'tests/a01-asset-alpha-audit.ps1',
'tests/a02-asset-alpha-audit.ps1'
)
foreach ($relativePath in $retiredPaths) {
if (Test-Path -LiteralPath (Join-Path $root $relativePath)) {
throw "Retired A01 fixed-canvas entry still exists: $relativePath"
}
}
$currentDocuments = @(
'docs/项目当前总览.md',
'docs/家谱项目全量治理设计.md',
'docs/家谱项目全量治理实施计划.md',
'docs/视觉资产与构建基线.md'
)
foreach ($document in $currentDocuments) {
$content = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root $document)
foreach ($relativePath in $retiredPaths) {
if ($content.Contains($relativePath)) {
throw "Current document still exposes retired A01 entry: $relativePath ($document)"
}
}
}
$package = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'design-pipeline/package.json')
foreach ($retiredScript in @('build:a01', 'validate:a01-buttons', 'rebuild:a01-buttons', 'verify:assets')) {
if ($package -match ('"' + [regex]::Escape($retiredScript) + '"\s*:')) {
throw "Retired A01 package script still exists: $retiredScript"
}
}
Write-Output 'A01-RETIRED-PIPELINE-REMOVAL-CONTRACT PASS'
-11
View File
@@ -1,11 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding utf8 | ConvertFrom-Json
$a03File = Join-Path $root 'pages/auth/a03-mobile-verify.vue'
if ($pages.pages.path -contains 'pages/auth/a03-mobile-verify') { throw 'A-03 must not remain in pages.json' }
if (Test-Path -LiteralPath $a03File) { throw 'A-03 page file must be deleted' }
if ($pages.pages.Count -ne 52) { throw "Expected 52 active routes after the approved page-state merges and A06 archive, found $($pages.pages.Count)." }
Write-Output 'A03-ROUTE-REMOVAL-CONTRACT PASS'
-134
View File
@@ -1,134 +0,0 @@
$ErrorActionPreference = 'Stop'
function ConvertFrom-Utf8Base64 {
param([string]$Value)
return [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Value))
}
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message }
}
function Assert-NotContains {
param([string]$Content, [string]$Unexpected, [string]$Message)
if ($Content -match [regex]::Escape($Unexpected)) { throw $Message }
}
$root = Split-Path -Parent $PSScriptRoot
$registerPath = Join-Path $root 'pages/auth/a04-register.vue'
$pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding utf8 | ConvertFrom-Json
$register = Get-Content -LiteralPath $registerPath -Raw -Encoding utf8
$globalStyles = Get-Content -LiteralPath (Join-Path $root 'styles/global.scss') -Raw -Encoding utf8
$registerCopy = ConvertFrom-Utf8Base64 '5rOo5YaM6LSm5Y+3'
$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 @(
'<AuthPageShell',
'import AuthPageShell from "@/components/AuthPageShell.vue";',
'a01-vnext-divider-v1.png',
'a01-scroll-primary-v3.png',
'import AppToast from "@/components/AppToast.vue";',
'a02-agreement-unchecked.png',
'a02-agreement-checked.png',
'v-model.trim="phone"',
'v-model.trim="nickName"',
'for="a04-nickname"',
'class="required-mark"',
'v-model.trim="verificationCode"',
'v-model="password"',
'v-model="confirmPassword"',
'const agreed = ref(false)',
'const agreementError = ref(false)',
'const fieldErrors = ref({',
'const toggleAgreement = () =>',
'const submitRegister = async () =>',
'<AppToast :visible="feedbackVisible" :message="feedbackMessage" />',
'<TacVerification',
'AUTH_VERIFICATION_OPERATION.REGISTER',
'appApi.registerWithPassword',
'if (!/^\d{4}$/.test(verificationCode.value))',
'if (!isAuthPhone(phone.value))',
'validatePassword(password.value)',
'PASSWORD_POLICY_MESSAGE',
'if (!confirmPassword.value)',
'if (password.value !== confirmPassword.value)'
)) {
Assert-Contains -Content $register -Expected $required -Message "Missing A-04 registration contract: $required"
}
Assert-Contains -Content $register -Expected $registerCopy -Message 'A-04 must visibly identify registration'
Assert-Contains -Content $register -Expected $loginCopy -Message 'A-04 must provide a login return path'
Assert-NotContains -Content $register -Unexpected 'register-intro' -Message 'A-04 must not retain the redundant registration intro copy'
foreach ($forbidden in @('class="page-canvas"', 'class="page-backdrop"', 'mode="scaleToFill"', '1665rpx', 'a01-red-hall-ink-backdrop-v1.png')) {
Assert-NotContains -Content $register -Unexpected $forbidden -Message "A04 retains rejected page coordinates: $forbidden"
}
Assert-Contains -Content $globalStyles -Expected 'font-family: "STKaiti", "KaiTi", serif' -Message 'Global styles must own the A04 font contract.'
Assert-NotContains -Content $register -Unexpected 'font-family:' -Message 'A04 must inherit the global font contract instead of overriding it locally.'
foreach ($obsoleteVisual in @(
'a01-primary-button.png',
'a01-secondary-button.png',
'a02-login-panel.png',
'verificationVisible',
'verification-layer',
'feedback-toast__skin'
)) {
Assert-NotContains -Content $register -Unexpected $obsoleteVisual -Message "A04 must not retain obsolete visual or placeholder state: $obsoleteVisual"
}
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"
}
Assert-NotContains -Content $register -Unexpected '/pages/auth/a02-login' -Message 'A-04 must not return to the retired A02 route.'
foreach ($required in @(
'register-divider',
'mode="aspectFit"',
'role="alert"',
'aria-describedby'
)) {
Assert-Contains -Content $register -Expected $required -Message "Missing A-04 visual balance contract: $required"
}
$contentRule = [regex]::Match($register, '(?ms)^\.register-content\s*\{(?<Body>.*?)^\}')
if (-not $contentRule.Success) { throw 'A04 register content rule is missing.' }
foreach ($required in @('display: flex;', 'flex: 1;', 'flex-direction: column;', 'justify-content: flex-start;', 'max-width: 480px;', 'margin: 0 auto;')) {
Assert-Contains -Content $contentRule.Groups['Body'].Value -Expected $required -Message "A04 register content must use document flow: $required"
}
$headingRule = [regex]::Match($register, '(?ms)^\.page-heading\s*\{(?<Body>.*?)^\}')
if (-not $headingRule.Success) { throw 'A04 page heading rule is missing.' }
Assert-Contains -Content $headingRule.Groups['Body'].Value -Expected 'grid-template-columns: minmax(0, 1fr);' -Message 'A04 heading must center against the full content width.'
foreach ($selector in @('page-title', 'page-subtitle', 'register-divider')) {
$rule = [regex]::Match($register, "(?ms)^\.$selector\s*\{(?<Body>.*?)^\}")
if (-not $rule.Success) { throw "A04 heading child rule is missing: $selector" }
Assert-Contains -Content $rule.Groups['Body'].Value -Expected 'grid-column: 1;' -Message "A04 heading child must remain in the full-width grid column: $selector"
}
foreach ($ruleContract in @(
@{ Selector = 'register-submit__content'; Expected = 'font-size: clamp(19px, 36rpx, 24px);' },
@{ Selector = 'agreement-row'; Expected = 'font-size: clamp(15px, 24rpx, 18px);' },
@{ Selector = 'agreement-row'; Expected = 'color: #493323;' },
@{ Selector = 'input-label'; Expected = 'font-size: clamp(17px, 30rpx, 22px);' },
@{ Selector = 'auth-input'; Expected = 'font-size: clamp(17px, 30rpx, 22px);' },
@{ Selector = 'auth-input'; Expected = 'color: #493323;' },
@{ Selector = 'placeholder'; Expected = 'color: #9f968d;' },
@{ Selector = 'login-entry'; Expected = 'font-size: clamp(16px, 28rpx, 20px);' },
@{ Selector = 'login-entry'; Expected = 'color: #493323;' },
@{ Selector = 'login-entry'; Expected = 'margin-top: auto;' }
)) {
$rule = [regex]::Match($register, "(?ms)^\.$($ruleContract.Selector)\s*\{(?<Body>.*?)^\}")
if (-not $rule.Success) { throw "A04 style rule is missing: $($ruleContract.Selector)" }
Assert-Contains -Content $rule.Groups['Body'].Value -Expected $ruleContract.Expected -Message "A04 typography must match A01: $($ruleContract.Selector) $($ruleContract.Expected)"
}
foreach ($forbidden in @(
'(?s)\.register-panel\s*\{[^}]*\bborder\s*:',
'(?s)\.register-panel\s*\{[^}]*\bbackground\s*:',
'(?s)\.register-submit\s*\{[^}]*\bborder\s*:',
'(?s)\.register-submit\s*\{[^}]*\bbackground\s*:',
'\.agreement-icon::(?:before|after)'
)) {
if ($register -match $forbidden) { throw "A-04 retains forbidden CSS visual construction: $forbidden" }
}
Write-Output 'A04-REGISTRATION-CONTRACT PASS'
-148
View File
@@ -1,148 +0,0 @@
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: 616 },
{ width: 360, height: 640 },
{ width: 360, height: 800 },
{ width: 412, height: 915 },
{ width: 480, height: 1040 }
]) {
await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true })
await navigate(send, a04Url, '.register-submit')
await waitFor(send, "document.querySelector('.auth-shell__header-image img')?.naturalWidth === 824", `A04 header did not load at ${size.width}x${size.height}`)
const metrics = await valueOf(send, `(() => {
const header = document.querySelector('.auth-shell__header')
const headerImage = document.querySelector('.auth-shell__header-image img')
const paper = document.querySelector('.auth-shell__paper')
const title = document.querySelector('.page-title')
const loginEntry = document.querySelector('.login-entry')
return {
width: document.documentElement.scrollWidth,
documentScrollHeight: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight),
headerBottom: header?.getBoundingClientRect().bottom,
headerImageWidth: headerImage?.getBoundingClientRect().width,
headerImageHeight: headerImage?.getBoundingClientRect().height,
headerNaturalWidth: headerImage?.naturalWidth,
headerNaturalHeight: headerImage?.naturalHeight,
paperTop: paper?.getBoundingClientRect().top,
titleCenter: title ? title.getBoundingClientRect().left + title.getBoundingClientRect().width / 2 : null,
loginEntryBottom: loginEntry?.getBoundingClientRect().bottom + window.scrollY
}
})()`)
assert(metrics.width <= size.width + 1, `A04 has horizontal overflow at ${size.width}x${size.height}`)
assert.deepStrictEqual([metrics.headerNaturalWidth, metrics.headerNaturalHeight], [824, 340], `A04 header asset changed at ${size.width}x${size.height}`)
assert(Math.abs(metrics.headerImageHeight / metrics.headerImageWidth - 340 / 824) < 0.01, `A04 header is distorted at ${size.width}x${size.height}`)
assert(metrics.paperTop >= metrics.headerBottom - 1, `A04 paper overlaps its header at ${size.width}x${size.height}`)
assert(Math.abs(metrics.titleCenter - size.width / 2) <= 1, `A04 title is not centered at ${size.width}x${size.height}: ${metrics.titleCenter}`)
assert(metrics.loginEntryBottom <= metrics.documentScrollHeight + 1, `A04 last action is unreachable at ${size.width}x${size.height}`)
}
await valueOf(send, "document.querySelector('.register-submit').click()")
await waitFor(send, "document.querySelectorAll('.field-error').length === 4", 'A04 empty submit did not show four 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', '联调昵称', '1234', 'demo-password', 'demo-password']
inputs.forEach((input, index) => {
input.value = values[index]
input.dispatchEvent(new Event('input', { bubbles: true }))
})
document.querySelector('.agreement-toggle').click()
document.querySelector('.register-submit').click()
})()`)
await waitFor(
send,
"document.querySelector('.app-toast__copy')?.textContent === '当前为本地预览模式,真实认证服务未启用'",
'A04 local preview did not fail closed instead of faking registration success'
)
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.verification-layer'))"), false, 'A04 retained the obsolete fake verification layer')
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.tac-layer--visible'))"), false, 'A04 opened TAC during registration submission instead of the SMS request phase')
const toastBorderImage = await valueOf(send, "getComputedStyle(document.querySelector('.app-toast')).borderImageSource")
assert(toastBorderImage.includes('a01-scroll-toast-v3.png'), `A04 Toast did not render the v3 nine-slice asset: ${toastBorderImage}`)
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)
})
-115
View File
@@ -1,115 +0,0 @@
$ErrorActionPreference = 'Stop'
function ConvertFrom-Utf8Base64 {
param([string]$Value)
return [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Value))
}
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message }
}
function Assert-NotContains {
param([string]$Content, [string]$Unexpected, [string]$Message)
if ($Content -match [regex]::Escape($Unexpected)) { throw $Message }
}
$root = Split-Path -Parent $PSScriptRoot
$pagePath = Join-Path $root 'pages/auth/a05-reset-password.vue'
$pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding utf8 | ConvertFrom-Json
$page = Get-Content -LiteralPath $pagePath -Raw -Encoding utf8
$resetCopy = ConvertFrom-Utf8Base64 '6YeN6K6+5a+G56CB'
$confirmPasswordCopy = ConvertFrom-Utf8Base64 '56Gu6K6k5paw5a+G56CB'
$getCodeCopy = ConvertFrom-Utf8Base64 '6I635Y+W6aqM6K+B56CB'
$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 @(
'<AuthPageShell',
'import AuthPageShell from "@/components/AuthPageShell.vue";',
'a01-vnext-divider-v1.png',
'a01-scroll-primary-v3.png',
'import AppToast from "@/components/AppToast.vue";',
'chevron-right.png',
'v-model.trim="phone"',
'v-model.trim="verificationCode"',
'v-model="password"',
'v-model="confirmPassword"',
'const prepareGetCode = async () =>',
'const submitReset = async () =>',
'const fieldErrors = ref({',
'const successVisible = ref(false)',
'<AppToast :visible="feedbackVisible" :message="feedbackMessage" />',
'title="密码已重设"',
'if (!isAuthPhone(phone.value))',
'if (!/^\d{4}$/.test(verificationCode.value))',
'<TacVerification',
'AUTH_VERIFICATION_OPERATION.FORGOT_PASSWORD',
'await appApi.resetPassword',
'validatePassword(password.value)',
'PASSWORD_POLICY_MESSAGE',
'if (password.value !== confirmPassword.value)',
'role="alert"',
'aria-describedby',
':visible="successVisible"'
)) {
Assert-Contains -Content $page -Expected $required -Message "Missing A-05 reset-password contract: $required"
}
Assert-Contains -Content $page -Expected $resetCopy -Message 'A-05 must visibly identify the password-reset task'
Assert-Contains -Content $page -Expected $confirmPasswordCopy -Message 'A-05 must require password confirmation'
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 $page -Unexpected '滑动验证待接口接入' -Message 'A-05 must not retain the retired TAC placeholder'
foreach ($forbidden in @('class="page-canvas"', 'class="page-backdrop"', 'mode="scaleToFill"', '1665rpx', 'a01-red-hall-ink-backdrop-v1.png')) {
Assert-NotContains -Content $page -Unexpected $forbidden -Message "A05 retains rejected page coordinates: $forbidden"
}
foreach ($obsoleteVisual in @(
'a01-primary-button.png',
'a01-secondary-button.png',
'a02-login-panel.png',
'auth-divider-knot.png',
'feedback-toast__skin',
'codeRequested',
'verificationVisible',
'verification-layer',
'closeVerification',
'confirmVerification',
'font-family:'
)) {
Assert-NotContains -Content $page -Unexpected $obsoleteVisual -Message "A-05 must not retain obsolete visual or placeholder state: $obsoleteVisual"
}
$primarySkinCount = ([regex]::Matches($page, 'a01-scroll-primary-v3\.png')).Count
if ($primarySkinCount -ne 1) { throw "A-05 page-local submit action must use the approved primary skin exactly once, found $primarySkinCount" }
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"
}
Assert-NotContains -Content $page -Unexpected '/pages/auth/a02-login' -Message 'A-05 must not return to the retired A02 route.'
foreach ($forbidden in @(
'(?s)\.reset-panel\s*\{[^}]*\bborder\s*:',
'(?s)\.reset-panel\s*\{[^}]*\bbackground\s*:',
'(?s)\.reset-submit\s*\{[^}]*\bborder\s*:',
'(?s)\.reset-submit\s*\{[^}]*\bbackground\s*:'
)) {
if ($page -match $forbidden) { throw "A-05 retains forbidden CSS visual construction: $forbidden" }
}
$contentRule = [regex]::Match($page, '(?ms)^\.reset-content\s*\{(?<Body>.*?)^\}')
if (-not $contentRule.Success) { throw 'A05 reset content rule is missing.' }
foreach ($required in @('display: flex;', 'flex: 1;', 'flex-direction: column;', 'max-width: 480px;', 'margin: 0 auto;')) {
Assert-Contains -Content $contentRule.Groups['Body'].Value -Expected $required -Message "A05 reset content must use document flow: $required"
}
$headingRule = [regex]::Match($page, '(?ms)^\.page-heading\s*\{(?<Body>.*?)^\}')
if (-not $headingRule.Success) { throw 'A05 page heading rule is missing.' }
Assert-Contains -Content $headingRule.Groups['Body'].Value -Expected 'grid-template-columns: minmax(0, 1fr);' -Message 'A05 heading must center against the full content width.'
foreach ($selector in @('page-title', 'page-subtitle', 'reset-divider')) {
$rule = [regex]::Match($page, "(?ms)^\.$selector\s*\{(?<Body>.*?)^\}")
if (-not $rule.Success) { throw "A05 heading child rule is missing: $selector" }
Assert-Contains -Content $rule.Groups['Body'].Value -Expected 'grid-column: 1;' -Message "A05 heading child must remain in the full-width grid column: $selector"
}
Write-Output 'A05-RESET-PASSWORD-CONTRACT PASS'
-172
View File
@@ -1,172 +0,0 @@
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 = async (send, values) => {
for (const [index, value] of values.entries()) {
await valueOf(send, `(() => {
const input = document.querySelectorAll('.auth-input input')[${index}]
input.value = ${JSON.stringify(value)}
input.dispatchEvent(new Event('input', { bubbles: true }))
})()`)
await sleep(30)
}
}
const run = async () => {
const { socket, send, exceptions } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
await navigate(send, a01Url, '.login-tab')
await valueOf(send, "document.querySelectorAll('.login-tab')[0].click()")
await waitFor(send, "Boolean(document.querySelector('.forgot-password'))", 'A01 password state did not expose forgot-password entry')
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: 616 },
{ width: 360, height: 640 },
{ width: 360, height: 800 },
{ width: 412, height: 915 },
{ width: 480, height: 1040 }
]) {
await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true })
await navigate(send, a05Url, '.reset-submit')
await waitFor(send, "document.querySelector('.auth-shell__header-image img')?.naturalWidth === 824", `A05 header did not load at ${size.width}x${size.height}`)
const metrics = await valueOf(send, `(() => {
const header = document.querySelector('.auth-shell__header')
const headerImage = document.querySelector('.auth-shell__header-image img')
const paper = document.querySelector('.auth-shell__paper')
const title = document.querySelector('.page-title')
const loginEntry = document.querySelector('.login-entry')
return {
width: document.documentElement.scrollWidth,
documentScrollHeight: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight),
headerBottom: header?.getBoundingClientRect().bottom,
headerImageWidth: headerImage?.getBoundingClientRect().width,
headerImageHeight: headerImage?.getBoundingClientRect().height,
headerNaturalWidth: headerImage?.naturalWidth,
headerNaturalHeight: headerImage?.naturalHeight,
paperTop: paper?.getBoundingClientRect().top,
titleCenter: title ? title.getBoundingClientRect().left + title.getBoundingClientRect().width / 2 : null,
loginEntryBottom: loginEntry?.getBoundingClientRect().bottom + window.scrollY
}
})()`)
assert(metrics.width <= size.width + 1, `A05 has horizontal overflow at ${size.width}x${size.height}`)
assert.deepStrictEqual([metrics.headerNaturalWidth, metrics.headerNaturalHeight], [824, 340], `A05 header asset changed at ${size.width}x${size.height}`)
assert(Math.abs(metrics.headerImageHeight / metrics.headerImageWidth - 340 / 824) < 0.01, `A05 header is distorted at ${size.width}x${size.height}`)
assert(metrics.paperTop >= metrics.headerBottom - 1, `A05 paper overlaps its header at ${size.width}x${size.height}`)
assert(Math.abs(metrics.titleCenter - size.width / 2) <= 1, `A05 title is not centered at ${size.width}x${size.height}: ${metrics.titleCenter}`)
assert(metrics.loginEntryBottom <= metrics.documentScrollHeight + 1, `A05 last action is unreachable at ${size.width}x${size.height}`)
}
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 setInputs(send, ['13800138000', '', '', ''])
await valueOf(send, "document.querySelector('.get-code').click()")
await waitFor(
send,
"document.querySelector('.app-toast__copy')?.textContent === '当前为本地预览模式,真实认证服务未启用'",
'A05 local preview did not fail closed before requesting TAC'
)
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.verification-layer'))"), false, 'A05 retained the obsolete fake verification layer')
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.tac-layer--visible'))"), false, 'A05 opened TAC without a remote requirement response')
assert.strictEqual(await valueOf(send, "document.querySelector('.auth-input input').value"), '13800138000', 'A05 unavailable remote verification cleared the form')
assert.strictEqual(await valueOf(send, "document.querySelector('.get-code').textContent.trim()"), '获取验证码', 'A05 faked a requested-code state without TAC and the SMS service')
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 setInputs(send, ['13800138000', '1234', '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: document.querySelector('.app-dialog__title')?.textContent === '密码已重设'
})`)
assert(mismatchState.errors.some((message) => message.includes('不一致')), `A05 mismatched passwords did not show inline error: ${JSON.stringify(mismatchState)}`)
await setInputs(send, ['13800138000', '1234', 'new-password', 'new-password'])
await sleep(100)
await valueOf(send, "document.querySelector('.reset-submit').click()")
await waitFor(
send,
"document.querySelector('.app-toast__copy')?.textContent === '当前为本地预览模式,真实认证服务未启用'",
'A05 local preview did not reject a fake password reset'
)
assert.strictEqual(await valueOf(send, "document.querySelector('.app-dialog__title')?.textContent === '密码已重设'"), false, 'A05 showed reset success without a successful remote response')
assert.deepStrictEqual(
await valueOf(send, "Array.from(document.querySelectorAll('.auth-input input')).map((item) => item.value)"),
['13800138000', '1234', 'new-password', 'new-password'],
'A05 cleared the form after a rejected remote reset'
)
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)
})
-8
View File
@@ -1,8 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/auth/a05-reset-password.vue') -Raw -Encoding UTF8
if ($page.Contains('<text class="success-mark">')) { throw 'A05 must not use a text symbol as its success asset' }
foreach ($token in @('<image', 'class="success-mark"', 'brand-seal.png')) {
if (-not $page.Contains($token)) { throw "A05 success asset missing: $token" }
}
Write-Output 'A05-SUCCESS-ASSET-CONTRACT PASS'
-98
View File
@@ -1,98 +0,0 @@
$ErrorActionPreference = 'Stop'
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message }
}
function Assert-NotContains {
param([string]$Content, [string]$Unexpected, [string]$Message)
if ($Content -match [regex]::Escape($Unexpected)) { throw $Message }
}
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/auth/a06-auth-status.vue') -Raw -Encoding utf8
$pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding utf8 | ConvertFrom-Json
$runtime = Get-Content -LiteralPath (Join-Path $root 'tests/a06-auth-status-runtime-smoke.js') -Raw -Encoding utf8
if ($pages.pages.path -contains 'pages/auth/a06-auth-status') { throw 'A-06 is archived and must not remain declared in pages.json' }
if (-not (Test-Path -LiteralPath (Join-Path $root 'pages/auth/a06-auth-status.vue'))) { throw 'Archived A-06 source must be retained' }
if (-not ($pages.pages.path -contains 'pages/auth/a01-entry')) { throw 'A-06 must retain the A01 return target' }
Assert-Contains -Content $runtime -Expected 'A06-AUTH-STATUS-RUNTIME-SMOKE SKIP archived route' -Message 'A-06 runtime smoke must explicitly skip while the route is archived'
Assert-NotContains -Content $page -Unexpected 'ModulePage' -Message 'A-06 must not remain a ModulePage shell'
foreach ($required in @(
'const status = ref(',
'risk',
"const statusConfig = {",
'frozen:',
'disabled:',
'risk:',
'reasonLabel:',
'impactLabel:',
'recoveryLabel:',
'const resolveStatus = (options = {}) =>',
'const openRecovery = () =>',
'const closeRecovery = () =>',
'<AuthPageShell',
'import AuthPageShell from "@/components/AuthPageShell.vue";',
'a01-vnext-divider-v1.png',
'a01-scroll-primary-v3.png',
'a01-scroll-dialog-v3.png',
'chevron-right.png',
'auth-login-outline.png',
'class="recovery-layer"',
'mode="aspectFit"',
'max-height: calc(var(--app-viewport-height) - 40px);'
)) {
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 '/pages/auth/a02-login' -Message 'A-06 must not return to the retired A02 route.'
foreach ($forbidden in @('class="page-canvas"', 'class="page-backdrop"', 'mode="scaleToFill"', '1665rpx', 'a01-red-hall-ink-backdrop-v1.png')) {
Assert-NotContains -Content $page -Unexpected $forbidden -Message "A06 retains rejected page coordinates: $forbidden"
}
foreach ($obsoleteVisual in @(
'a01-primary-button.png',
'a02-login-panel.png',
'auth-divider-knot.png',
'font-family:'
)) {
Assert-NotContains -Content $page -Unexpected $obsoleteVisual -Message "A-06 must not retain obsolete visual: $obsoleteVisual"
}
$primarySkinCount = ([regex]::Matches($page, 'a01-scroll-primary-v3\.png')).Count
if ($primarySkinCount -ne 2) { throw "A-06 must use the approved primary skin exactly twice, found $primarySkinCount" }
foreach ($forbidden in @(
'(?s)\.status-panel\s*\{[^}]*\bborder\s*:',
'(?s)\.status-panel\s*\{[^}]*\bbackground\s*:',
'(?s)\.status-primary\s*\{[^}]*\bborder\s*:',
'(?s)\.status-primary\s*\{[^}]*\bbackground\s*:'
)) {
if ($page -match $forbidden) { throw "A-06 retains forbidden CSS visual construction: $forbidden" }
}
$contentRule = [regex]::Match($page, '(?ms)^\.status-content\s*\{(?<Body>.*?)^\}')
if (-not $contentRule.Success) { throw 'A06 status content rule is missing.' }
foreach ($required in @('display: flex;', 'flex: 1;', 'flex-direction: column;', 'max-width: 480px;', 'margin: 0 auto;')) {
Assert-Contains -Content $contentRule.Groups['Body'].Value -Expected $required -Message "A06 status content must use document flow: $required"
}
$headingRule = [regex]::Match($page, '(?ms)^\.page-heading\s*\{(?<Body>.*?)^\}')
if (-not $headingRule.Success) { throw 'A06 page heading rule is missing.' }
Assert-Contains -Content $headingRule.Groups['Body'].Value -Expected 'grid-template-columns: minmax(0, 1fr);' -Message 'A06 heading must center against the full content width.'
foreach ($selector in @('page-title', 'page-subtitle', 'status-divider')) {
$rule = [regex]::Match($page, "(?ms)^\.$selector\s*\{(?<Body>.*?)^\}")
if (-not $rule.Success) { throw "A06 heading child rule is missing: $selector" }
Assert-Contains -Content $rule.Groups['Body'].Value -Expected 'grid-column: 1;' -Message "A06 heading child must remain in the full-width grid column: $selector"
}
Write-Output 'A06-AUTH-STATUS-CONTRACT PASS'
-112
View File
@@ -1,112 +0,0 @@
const assert = require('assert')
const fs = require('fs')
const path = require('path')
const routeConfig = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'pages.json'), 'utf8'))
const isActiveRoute = routeConfig.pages.some((item) => item.path === 'pages/auth/a06-auth-status')
if (!isActiveRoute) {
process.stdout.write('A06-AUTH-STATUS-RUNTIME-SMOKE SKIP archived route\n')
process.exit(0)
}
const baseUrl = 'http://localhost:5173/#/pages/auth/a06-auth-status'
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, 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, exceptions } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
for (const size of [{ width: 320, height: 568 }, { width: 360, height: 640 }, { 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')
}
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 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()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
@@ -1,37 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding UTF8 | ConvertFrom-Json
$activePaths = @($pages.pages | ForEach-Object { "$($_.path).vue" })
foreach ($relativePath in $activePaths) {
$fullPath = Join-Path $root $relativePath
if (-not (Test-Path -LiteralPath $fullPath)) { throw "Missing active page: $relativePath" }
$source = Get-Content -LiteralPath $fullPath -Raw -Encoding UTF8
if ($source -match '<ModulePage(?:\s|/|>)|import\s+ModulePage\s+from') {
throw "Active page must own its business content instead of ModulePage: $relativePath"
}
if ($source -match 'uni\.(showToast|showModal|showLoading|showActionSheet)') {
throw "$relativePath must use project feedback components"
}
}
$recordContracts = [ordered]@{
'pages/records/r03-gift-list.vue' = @('appApi.getRelativeRecords', 'appApi')
'pages/records/r04-gift-editor.vue' = @('appApi.createRelativeRecord', 'pickAndUploadImage')
'pages/records/r05-ritual-list.vue' = @('appApi.getCeremonies', 'appApi')
'pages/records/r06-ritual-detail.vue' = @('appApi.getCeremonyDetail', 'appApi')
'pages/records/r07-ritual-editor.vue' = @('appApi.createCeremony', 'appApi')
'pages/records/r08-growth-journal.vue' = @('appApi.getGrowthRecords', 'appApi.createGrowthRecord')
'pages/records/r10-memo-list.vue' = @('appApi.getMemos', 'appApi.createMemo')
'pages/records/r11-merit-records.vue' = @('appApi.getMeritRecords', 'appApi.createMeritRecord')
}
foreach ($entry in $recordContracts.GetEnumerator()) {
$source = Get-Content -LiteralPath (Join-Path $root $entry.Key) -Raw -Encoding UTF8
foreach ($token in $entry.Value) {
if (-not $source.Contains($token)) { throw "$($entry.Key) missing active interface owner: $token" }
}
}
Write-Output 'ACTIVE-PAGE-BUSINESS-OWNERSHIP-CONTRACT PASS'
@@ -1,46 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$profilePath = Join-Path $root 'styles/adaptive-frame-profiles.scss'
if (-not (Test-Path -LiteralPath $profilePath)) {
throw 'Missing adaptive frame profile owner: styles/adaptive-frame-profiles.scss'
}
$profile = Get-Content -LiteralPath $profilePath -Raw -Encoding UTF8
foreach ($token in @(
'@mixin adaptive-auth-dialog',
'@mixin adaptive-scroll-button($type)',
'@mixin adaptive-feedback-toast',
'@mixin adaptive-genealogy-current-slip',
'@mixin adaptive-genealogy-list-card',
'@mixin adaptive-genealogy-state-panel',
'@mixin adaptive-genealogy-form-field',
'@mixin adaptive-g05-overview-surface',
'@mixin adaptive-g06-search-field',
'@mixin adaptive-g06-search-action',
'@mixin adaptive-g01-add-sheet',
'@mixin adaptive-g01-switcher',
'@mixin adaptive-family-content',
'@mixin adaptive-family-panel',
'@mixin adaptive-family-field',
'@mixin adaptive-family-letter',
'@mixin adaptive-notification-content',
'@mixin adaptive-profile-content',
'@mixin adaptive-profile-field',
'@mixin adaptive-profile-summary',
'@mixin adaptive-records-content',
'@mixin adaptive-records-field',
'@mixin adaptive-records-person',
'@mixin adaptive-tree-panel',
'@mixin adaptive-tree-field',
'a01-scroll-dialog-v3.png',
'border-image-slice: 260 240 360 240 fill;',
'border-image-slice: 56 300 fill;',
'border-image-slice: 70 100 fill;'
)) {
if (-not $profile.Contains($token)) {
throw "Adaptive frame profile missing: $token"
}
}
Write-Output 'ADAPTIVE-FRAME-PROFILES-CONTRACT PASS'
-51
View File
@@ -1,51 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
function Read-Utf8([string]$path) {
Get-Content -LiteralPath (Join-Path $root $path) -Raw -Encoding UTF8
}
$expected = @{
'components/AppToast.vue' = @('adaptive.adaptive-feedback-toast')
'pages/genealogy/g01-my-genealogies.vue' = @(
'adaptive.adaptive-genealogy-current-slip',
'adaptive.adaptive-genealogy-list-card',
'adaptive.adaptive-genealogy-state-panel',
'adaptive.adaptive-g01-add-sheet',
'adaptive.adaptive-g01-switcher'
)
'pages/genealogy/g09-my-applications.vue' = @('adaptive.adaptive-genealogy-list-card')
'pages/genealogy/g10-application-review.vue' = @(
'adaptive.adaptive-genealogy-form-field',
'adaptive.adaptive-genealogy-list-card',
'adaptive.adaptive-scroll-button',
'adaptive.adaptive-feedback-toast'
)
}
foreach ($path in $expected.Keys) {
$source = Read-Utf8 $path
if (-not $source.Contains('@use "../../styles/adaptive-frame-profiles.scss" as adaptive;') -and
-not $source.Contains('@use "../styles/adaptive-frame-profiles.scss" as adaptive;')) {
throw "$path must import the adaptive frame profile owner"
}
foreach ($token in $expected[$path]) {
if (-not $source.Contains($token)) { throw "$path missing adaptive surface: $token" }
}
if ($source -match '(?im)border-image-slice\s*:') {
throw "$path must not own border-image slice values"
}
}
$g01 = Read-Utf8 'pages/genealogy/g01-my-genealogies.vue'
$g10 = Read-Utf8 'pages/genealogy/g10-application-review.vue'
foreach ($source in @($g01, $g10)) {
if ($source -match '(?im)background\s*:[^;\r\n]*100%\s+100%[^;\r\n]*;') {
throw 'A/G variable surfaces must not stretch full images to 100% 100%'
}
}
$tabbar = Read-Utf8 'components/AppTabbar.vue'
if ($tabbar -match '(?im)(?<![-\w])height\s*:\s*112rpx;') { throw 'AppTabbar must use a minimum baseline instead of fixed content height' }
Write-Output 'AG-RESPONSIVE-SURFACES-CONTRACT PASS'
-188
View File
@@ -1,188 +0,0 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const origin = process.argv[2] || "http://localhost:5173";
const cdpPort = process.env.CDP_PORT || "9222";
const genealogyId = process.env.GENEALOGY_ID || "2080557121112465409";
const feedId = process.env.FEED_ID || "2080572776100487169";
const articleId = process.env.ARTICLE_ID || "2080573188207632386";
const albumId = process.env.ALBUM_ID || "2080573946172891138";
const captureDirectory = path.join(__dirname, "..", "tmp", "all-page-audit");
const routeStart = Number(process.env.PAGE_AUDIT_START || 0);
const routeEnd = Number(process.env.PAGE_AUDIT_END || 0);
const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
const pageExpectations = {
"pages/auth/a01-entry": { title: "", noReload: true, skipTitle: true, allowAuthenticatedRedirect: true },
"pages/auth/a04-register": { title: "注册账号" },
"pages/auth/a05-reset-password": { title: "重设密码" },
"pages/genealogy/g01-my-genealogies": { title: "我的家谱", capture: "01-genealogies.png" },
"pages/genealogy/g03-create-genealogy": { title: "创建家谱", capture: "02-create-genealogy.png" },
"pages/genealogy/g05-genealogy-overview": { title: "家谱总览", query: `genealogyId=${genealogyId}` },
"pages/genealogy/g06-search-genealogies": { title: "搜索家谱" },
"pages/genealogy/g08-join-application": { title: "申请加入", query: `genealogyId=${genealogyId}` },
"pages/genealogy/g09-my-applications": { title: "我的申请" },
"pages/genealogy/g10-application-review": { title: "申请审核", query: `genealogyId=${genealogyId}` },
"pages/genealogy/g11-genealogy-settings": { title: "家谱设置", query: `genealogyId=${genealogyId}` },
"pages/genealogy/g12-generation-poems": { title: "字辈诗", query: `genealogyId=${genealogyId}` },
"pages/tree/t01-tree-overview": { title: "世系树", query: `genealogyId=${genealogyId}`, capture: "03-tree.png" },
"pages/tree/t03-member-profile": { title: "成员档案", query: `genealogyId=${genealogyId}&state=error` },
"pages/tree/t04-add-relative": { title: "录入首位成员", query: `genealogyId=${genealogyId}&mode=first` },
"pages/tree/t05-edit-member": { title: "编辑成员", query: `genealogyId=${genealogyId}&state=error` },
"pages/tree/t06-edit-relationship": { title: "调整排行", query: `genealogyId=${genealogyId}&mode=rank&state=error` },
"pages/tree/t07-member-directory": { title: "成员目录", query: `genealogyId=${genealogyId}` },
"pages/tree/t08-member-states": { title: "成员状态", query: `genealogyId=${genealogyId}&state=error` },
"pages/family/f01-family-feed": { title: "家族动态", query: `genealogyId=${genealogyId}`, capture: "04-feed.png" },
"pages/family/f02-publish-feed": { title: "发布动态", query: `genealogyId=${genealogyId}` },
"pages/family/f03-feed-detail": { title: "动态详情", query: `genealogyId=${genealogyId}&feedId=${feedId}` },
"pages/family/f04-article-list": { title: "谱文", query: `genealogyId=${genealogyId}` },
"pages/family/f05-article-detail": { title: "谱文详情", query: `genealogyId=${genealogyId}&articleId=${articleId}` },
"pages/family/f06-article-editor": { title: "新建谱文", query: `genealogyId=${genealogyId}` },
"pages/family/f07-album-list": { title: "家族相册", query: `genealogyId=${genealogyId}` },
"pages/family/f08-album-detail": { title: "相册详情", query: `genealogyId=${genealogyId}&albumId=${albumId}` },
"pages/family/f09-media-upload": { title: "添加照片", query: `genealogyId=${genealogyId}&albumId=${albumId}` },
"pages/family/f10-video-list": { title: "家族视频", query: `genealogyId=${genealogyId}` },
"pages/records/r01-people-list": { title: "人物录", query: `genealogyId=${genealogyId}` },
"pages/records/r02-person-detail": { title: "人物详情", query: `genealogyId=${genealogyId}&state=error` },
"pages/records/r03-gift-list": { title: "贺礼簿", query: `genealogyId=${genealogyId}`, capture: "05-gifts.png" },
"pages/records/r04-gift-editor": { title: "新建往来记录", query: `genealogyId=${genealogyId}&mode=create` },
"pages/records/r05-ritual-list": { title: "礼仪活动", query: `genealogyId=${genealogyId}` },
"pages/records/r06-ritual-detail": { title: "礼仪详情", query: `genealogyId=${genealogyId}&ceremonyId=0` },
"pages/records/r07-ritual-editor": { title: "新建礼仪活动", query: `genealogyId=${genealogyId}&mode=create` },
"pages/records/r08-growth-journal": { title: "成长记录", query: `genealogyId=${genealogyId}` },
"pages/records/r09-life-events": { title: "人生事件", query: `genealogyId=${genealogyId}` },
"pages/records/r10-memo-list": { title: "家族备忘", query: `genealogyId=${genealogyId}` },
"pages/records/r11-merit-records": { title: "功德记录", query: `genealogyId=${genealogyId}` },
"pages/notification/n01-message-center": { title: "消息中心", capture: "06-messages.png" },
"pages/notification/n02-message-detail": { title: "消息详情" },
"pages/profile/m01-profile-home": { title: "我的", capture: "07-profile.png" },
"pages/profile/m02-edit-profile": { title: "编辑资料" },
"pages/profile/m03-security-settings": { title: "账号与安全" },
"pages/profile/m04-change-password": { title: "修改密码" },
"pages/profile/m05-change-phone": { title: "换绑手机号" },
"pages/profile/m06-help-center": { title: "帮助中心" },
"pages/profile/m07-feedback": { title: "意见反馈" },
"pages/profile/m08-promotion": { title: "推广中心" },
"pages/profile/m09-vip-orders": { title: "VIP 与订单" },
"pages/profile/m10-about-settings": { title: "关于家谱" },
};
const connect = async () => {
const targets = await (await fetch(`http://127.0.0.1:${cdpPort}/json`)).json();
const page = targets.find((candidate) => candidate.type === "page" && candidate.url.startsWith(`${origin}/`));
if (!page) throw new Error(`Chrome debugging has no ${origin} project 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();
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
const request = pending.get(message.id);
if (!request) return;
pending.delete(message.id);
message.error ? request.reject(new Error(message.error.message)) : 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 };
};
const evaluate = async (send, expression) => (await send("Runtime.evaluate", {
expression,
returnByValue: true,
awaitPromise: true,
})).result?.value;
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 60; attempt += 1) {
if (await evaluate(send, expression)) return;
await wait(100);
}
throw new Error(message);
};
const open = async (send, route, noReload) => {
const expectation = pageExpectations[route];
const url = `${origin}/#/${route}${pageExpectations[route].query ? `?${pageExpectations[route].query}` : ""}`;
await send("Page.navigate", { url });
const authenticatedRedirect = `${origin}/#/pages/genealogy/g01-my-genealogies`;
const expectedLocation = pageExpectations[route].allowAuthenticatedRedirect
? `location.href === ${JSON.stringify(url)} || location.href === ${JSON.stringify(authenticatedRedirect)}`
: `location.href === ${JSON.stringify(url)}`;
await waitFor(send, expectedLocation, `navigation failed: ${route}`);
if (!expectation.skipTitle) {
await waitFor(send, `document.body?.innerText.includes(${JSON.stringify(expectation.title)})`, `title did not render: ${route}`);
}
if (!noReload) {
const previousTimeOrigin = await evaluate(send, "performance.timeOrigin");
send("Page.reload").catch(() => {});
await waitFor(send, `performance.timeOrigin !== ${JSON.stringify(previousTimeOrigin)}`, `reload failed: ${route}`);
if (!expectation.skipTitle) {
await waitFor(send, `document.body?.innerText.includes(${JSON.stringify(expectation.title)})`, `title did not render after reload: ${route}`);
}
}
await waitFor(send, "document.body && document.body.innerText.length > 0", `page did not render: ${route}`);
await wait(700);
};
const capture = async (send, filename) => {
fs.mkdirSync(captureDirectory, { recursive: true });
const image = await send("Page.captureScreenshot", { format: "png", captureBeyondViewport: false });
fs.writeFileSync(path.join(captureDirectory, filename), Buffer.from(image.data, "base64"));
};
const run = async () => {
const allRoutes = require("../pages.json").pages.map((page) => page.path);
assert.deepStrictEqual([...allRoutes].sort(), Object.keys(pageExpectations).sort(), "route coverage drifted from pages.json");
const routes = routeEnd > routeStart ? allRoutes.slice(routeStart, routeEnd) : allRoutes;
const { socket, send } = await connect();
const results = [];
try {
await send("Page.enable");
await send("Runtime.enable");
for (const route of routes) {
const expectation = pageExpectations[route];
try {
await open(send, route, expectation.noReload);
const state = await evaluate(send, `(() => {
const text = document.body.innerText || "";
return {
titlePresent: text.includes(${JSON.stringify(expectation.title)}),
loading: Boolean(document.querySelector('.app-loading, .uni-loading, .loading-spinner')),
textLength: text.length,
textStart: text.slice(0, 120),
hash: location.hash,
};
})()`);
if (!expectation.skipTitle) assert.strictEqual(state.titlePresent, true, `${route} title missing: ${state.textStart}`);
assert.strictEqual(state.loading, false, `${route} remained in a loading state`);
if (expectation.capture) await capture(send, expectation.capture);
results.push({ route, state: "PASS" });
} catch (error) {
results.push({ route, state: "FAIL", reason: error.message });
}
}
} finally {
socket.close();
}
for (const result of results) {
process.stdout.write(`${result.state} ${result.route}${result.reason ? `${result.reason}` : ""}\n`);
}
const failures = results.filter((result) => result.state === "FAIL");
if (failures.length) throw new Error(`${failures.length}/${results.length} routed pages failed to reach a stable expected state`);
process.stdout.write(`ALL-PAGE-ROUTE-RUNTIME-SMOKE PASS (${results.length} pages)\n`);
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
-28
View File
@@ -1,28 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$button = Get-Content -LiteralPath (Join-Path $root 'components/AppButton.vue') -Raw -Encoding UTF8
foreach ($token in @(
'compact: { type: Boolean, default: false }',
"'app-button--compact': compact",
'@use "../styles/adaptive-frame-profiles.scss" as adaptive;',
'@include adaptive-scroll-button(primary);',
'@include adaptive-scroll-button(secondary);',
'a01-scroll-primary-v3.png',
'a01-scroll-secondary-v3.png'
)) {
if (-not $button.Contains($token)) { throw "AppButton compact contract missing: $token" }
}
if ($button.Contains('border-image-slice: 56 300 fill;')) {
throw 'AppButton must consume the shared adaptive frame profile instead of owning slice values'
}
if ($button -notmatch '(?s)\.app-button--compact \.app-button__skin\s*\{[^}]*display:\s*none;') {
throw 'AppButton compact mode must hide the aspectFit image skin'
}
if ($button -notmatch '(?s)\.app-button--compact \.app-button__label\s*\{[^}]*padding:\s*0;[^}]*font-size:\s*clamp\(14px, 23rpx, 17px\);[^}]*letter-spacing:\s*0;[^}]*white-space:\s*nowrap;') {
throw 'AppButton compact label must preserve four-character action capacity'
}
Write-Output 'APP-BUTTON-COMPACT-CONTRACT PASS'
@@ -1,34 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$dialog = Get-Content -LiteralPath (Join-Path $root 'components/AppDialog.vue') -Raw -Encoding UTF8
foreach ($token in @(
'compactActions: { type: Boolean, default: false }',
'class="app-dialog__copy"',
':compact="compactActions && showCancel"',
'@use "../styles/adaptive-frame-profiles.scss" as adaptive;',
'@include adaptive-auth-dialog;',
'padding: calc(40rpx + env(safe-area-inset-top)) 40rpx calc(40rpx + env(safe-area-inset-bottom));',
'max-height: calc(var(--app-viewport-height, 100vh) - 80rpx - env(safe-area-inset-top) - env(safe-area-inset-bottom));',
'min-height: 0;',
'margin-top: 26rpx;'
)) {
if (-not $dialog.Contains($token)) { throw "AppDialog responsive contract missing: $token" }
}
foreach ($forbidden in @(
'min-height: 520rpx;',
'center / contain no-repeat',
'margin-top: auto;'
)) {
if ($dialog.Contains($forbidden)) { throw "AppDialog responsive contract forbids: $forbidden" }
}
foreach ($rule in @(
'(?s)\.app-dialog__copy\s*\{[^}]*display:\s*flex;[^}]*width:\s*100%;[^}]*flex-direction:\s*column;',
'(?s)\.app-dialog__title,\s*\.app-dialog__message\s*\{[^}]*display:\s*block;[^}]*width:\s*100%;'
)) {
if ($dialog -notmatch $rule) { throw "AppDialog responsive layout rule missing: $rule" }
}
Write-Output 'APP-DIALOG-RESPONSIVE-CONTENT-CONTRACT PASS'
-33
View File
@@ -1,33 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$component = Get-Content -LiteralPath (Join-Path $root 'components/AppLoading.vue') -Raw -Encoding utf8
function Assert-Match {
param([string]$Content, [string]$Pattern, [string]$Message)
if ($Content -notmatch $Pattern) { throw $Message }
}
Assert-Match -Content $component -Pattern ':class="`app-loading--\$\{variant\}`"' -Message 'AppLoading must expose page and section modifier classes'
Assert-Match -Content $component -Pattern 'variant:\s*\{\s*type:\s*String,\s*default:\s*["'']page["''],\s*validator:' -Message 'AppLoading must own a validated page/section variant prop'
Assert-Match -Content $component -Pattern 'description:\s*\{\s*type:\s*String,\s*default:\s*["'']["'']\s*\}' -Message 'AppLoading must expose an optional description prop'
Assert-Match -Content $component -Pattern 'v-if="description" class="app-loading__description"' -Message 'AppLoading must render its optional description'
if ($component -match '(?s)\.app-loading\s*\{[^}]*position\s*:') { throw 'AppLoading ordinary content must remain in document flow' }
Assert-Match -Content $component -Pattern 'class="app-loading__seal"\s+src="/static/assets/foundation/transparent/brand-seal\.png"' -Message 'AppLoading must render the approved real red-gold seal asset'
Assert-Match -Content $component -Pattern 'class="app-loading__knot"\s+src="/static/assets/foundation/transparent/auth-divider-knot\.png"' -Message 'AppLoading must render the approved real gold knot asset'
Assert-Match -Content $component -Pattern '(?s)\.app-loading--page\s*\{[^}]*min-height:\s*320rpx;' -Message 'AppLoading page variant must use the approved minimum height'
Assert-Match -Content $component -Pattern '(?s)\.app-loading--page\s+\.app-loading__seal\s*\{[^}]*width:\s*132rpx;[^}]*height:\s*136rpx;' -Message 'AppLoading page seal must use the approved size'
Assert-Match -Content $component -Pattern '(?s)\.app-loading--page\s+\.app-loading__copy\s*\{[^}]*font-size:\s*clamp\(17px, 30rpx, 22px\);' -Message 'AppLoading page copy must use the approved size'
Assert-Match -Content $component -Pattern '(?s)\.app-loading--page\s+\.app-loading__description\s*\{[^}]*font-size:\s*clamp\(15px, 24rpx, 18px\);' -Message 'AppLoading page description must use the approved size'
Assert-Match -Content $component -Pattern '(?s)\.app-loading--section\s*\{[^}]*min-height:\s*180rpx;' -Message 'AppLoading section variant must use the approved minimum height'
Assert-Match -Content $component -Pattern '(?s)\.app-loading--section\s+\.app-loading__seal\s*\{[^}]*width:\s*88rpx;[^}]*height:\s*90rpx;' -Message 'AppLoading section seal must use the approved size'
Assert-Match -Content $component -Pattern '(?s)\.app-loading--section\s+\.app-loading__copy\s*\{[^}]*font-size:\s*clamp\(15px, 24rpx, 18px\);' -Message 'AppLoading section copy must use the approved size'
Assert-Match -Content $component -Pattern '(?s)\.app-loading--section\s+\.app-loading__description\s*\{[^}]*font-size:\s*clamp\(14px, 22rpx, 17px\);' -Message 'AppLoading section description must use the approved size'
Assert-Match -Content $component -Pattern 'app-loading-seal-breathe\s+1\.6s' -Message 'AppLoading seal must use the approved restrained breathing rhythm'
Assert-Match -Content $component -Pattern '@media\s*\(prefers-reduced-motion:\s*reduce\)' -Message 'AppLoading must respect reduced-motion preferences'
Assert-Match -Content $component -Pattern '(?s)@media\s*\(prefers-reduced-motion:\s*reduce\).*?\.app-loading__seal\s*\{[^}]*animation:\s*none;[^}]*transform:\s*none;' -Message 'Reduced-motion seal must stop animation and transforms'
Assert-Match -Content $component -Pattern '(?s)@media\s*\(prefers-reduced-motion:\s*reduce\).*?\.app-loading__knot\s*\{[^}]*animation:\s*none;[^}]*transform:\s*none;' -Message 'Reduced-motion knot must stop animation and transforms'
if ($component -match 'app-loading__mark|border:\s*4rpx\s+double') { throw 'AppLoading must not retain the legacy CSS box mark' }
Write-Output 'APP-LOADING-CONTRACT PASS'
@@ -1,134 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$attributePattern = '(?:"[^"]*"|''[^'']*''|[^>"'']+)*'
function Read-ProjectFile {
param([string]$Path)
return Get-Content -LiteralPath (Join-Path $root $Path) -Raw -Encoding UTF8
}
function Require-Match {
param([string]$Content, [string]$Pattern, [string]$Label)
if ($Content -notmatch $Pattern) { throw "$Label 未满足" }
}
function Require-ButtonClass {
param([string]$Content, [string]$Class, [string]$Label)
$button = [regex]::Matches($Content, "(?s)<button\b$script:attributePattern>") |
Where-Object { $_.Value -match "class=`"[^`"]*\b$([regex]::Escape($Class))\b" } |
Select-Object -First 1
if ($null -eq $button) { throw "$Label 必须由原生 button 承载:$Class" }
$view = [regex]::Matches($Content, "(?s)<view\b$script:attributePattern>") |
Where-Object { $_.Value -match "class=`"[^`"]*\b$([regex]::Escape($Class))\b" -and $_.Value.Contains('@click') } |
Select-Object -First 1
if ($null -ne $view) {
throw "$Label 不得继续用 view 模拟按钮:$Class"
}
}
function Get-ButtonByClass {
param([string]$Content, [string]$Class)
return [regex]::Matches($Content, "(?s)<button\b$script:attributePattern>") |
Where-Object { $_.Value -match "class=`"[^`"]*\b$([regex]::Escape($Class))\b" } |
Select-Object -First 1
}
function Require-FieldRelation {
param([string]$Content, [string]$Prefix, [string]$IdSuffix, [string]$ErrorKey, [string]$Label)
$inputId = "$Prefix-$IdSuffix"
$errorId = "$inputId-error"
Require-Match $Content "(?s)<label\b[^>]*for=`"$([regex]::Escape($inputId))`"[^>]*>" "$Label label/for"
$input = [regex]::Matches($Content, "(?s)<input\b$script:attributePattern>") |
Where-Object { $_.Value.Contains("id=`"$inputId`"") } |
Select-Object -First 1
if ($null -eq $input) { throw "$Label 缺少对应 input$inputId" }
foreach ($pattern in @(
":aria-invalid=`"Boolean\(fieldErrors\.$([regex]::Escape($ErrorKey))\)`"",
":aria-describedby=`"fieldErrors\.$([regex]::Escape($ErrorKey)) \? '$([regex]::Escape($errorId))' : undefined`""
)) {
if ($input.Value -notmatch $pattern) { throw "$Label 输入错误关联缺失:$inputId" }
}
Require-Match $Content "(?s)<text\b(?=[^>]*id=`"$([regex]::Escape($errorId))`")(?=[^>]*role=`"alert`")[^>]*>" "$Label 错误节点"
}
$a01 = Read-ProjectFile 'pages/auth/a01-entry.vue'
$a04 = Read-ProjectFile 'pages/auth/a04-register.vue'
$a05 = Read-ProjectFile 'pages/auth/a05-reset-password.vue'
$globalStyles = Read-ProjectFile 'styles/global.scss'
foreach ($page in @($a01, $a04, $a05)) {
Require-Match $page 'import\s+AppToast\s+from\s+["'']@/components/AppToast\.vue["'']' '认证状态播报 import'
Require-Match $page '(?s)<AppToast\b(?=[^>]*:visible="feedbackVisible")(?=[^>]*:message="feedbackMessage")[^>]*/>' '认证状态播报实例'
if ($page.Contains('class="feedback-toast"')) { throw '认证页不得重复实现缺少稳定 live region 的 Toast' }
}
Require-Match $a01 '<view\s+class="login-tabs"\s+role="tablist"' 'A01 登录方式容器'
$a01Buttons = [regex]::Matches($a01, "(?s)<button\b$attributePattern>")
$tabButtons = @($a01Buttons | Where-Object { $_.Value.Contains('role="tab"') })
if ($tabButtons.Count -ne 2) { throw 'A01 两个登录方式必须都是原生 tab 按钮' }
if (@($tabButtons | Where-Object { $_.Value.Contains(':aria-selected=') }).Count -ne 2) { throw 'A01 两个 tab 都必须声明选中状态' }
foreach ($class in @(
'login-tab', 'password-toggle', 'get-code', 'forgot-password', 'login-submit',
'register-link', 'agreement-toggle', 'agreement-link'
)) { Require-ButtonClass $a01 $class 'A01' }
if (@($a01Buttons | Where-Object { $_.Value -match 'class="[^"]*\bagreement-link\b' }).Count -ne 2) { throw 'A01 两份协议必须各自可聚焦' }
foreach ($name in @('手机号', '登录密码', '短信验证码')) {
$namedInput = [regex]::Matches($a01, "(?s)<input\b$attributePattern>") |
Where-Object { $_.Value.Contains("aria-label=`"$name`"") } |
Select-Object -First 1
if ($null -eq $namedInput) { throw "A01 输入缺少名称:$name" }
}
$passwordToggle = Get-ButtonByClass $a01 'password-toggle'
if ($passwordToggle.Value -notmatch ':aria-label="passwordVisible \? ''隐藏密码'' : ''显示密码''"' -or -not $passwordToggle.Value.Contains(':aria-pressed="passwordVisible"')) { throw 'A01 密码显隐状态未关联' }
$a01GetCode = Get-ButtonByClass $a01 'get-code'
if (-not $a01GetCode.Value.Contains(':disabled="sendingCode || submitting || cooldownSeconds > 0"')) { throw 'A01 短信按钮禁用态未关联' }
$a01Submit = Get-ButtonByClass $a01 'login-submit'
if (-not $a01Submit.Value.Contains(':disabled="submitting || sendingCode || tacVisible"') -or -not $a01Submit.Value.Contains(':aria-busy="submitting"')) { throw 'A01 提交忙碌态未关联' }
$a01Agreement = Get-ButtonByClass $a01 'agreement-toggle'
if (-not $a01Agreement.Value.Contains('role="checkbox"') -or -not $a01Agreement.Value.Contains(':aria-checked="agreed"')) { throw 'A01 协议复选语义未关联' }
Require-Match $a01 '(?s)<text\b[^>]*class="agreement-error"[^>]*role="alert"' 'A01 协议错误播报'
foreach ($class in @('back-button', 'get-code', 'register-submit', 'agreement-toggle', 'agreement-link', 'login-entry__link')) {
Require-ButtonClass $a04 $class 'A04'
}
$a04Buttons = [regex]::Matches($a04, "(?s)<button\b$attributePattern>")
if (@($a04Buttons | Where-Object { $_.Value -match 'class="[^"]*\bagreement-link\b' }).Count -ne 2) { throw 'A04 两份协议必须各自可聚焦' }
foreach ($class in @('back-button', 'get-code', 'reset-submit', 'login-entry__link')) {
Require-ButtonClass $a05 $class 'A05'
}
foreach ($entry in @(
@{ Key = 'A04'; Content = $a04; Prefix = 'a04'; SubmitClass = 'register-submit' },
@{ Key = 'A05'; Content = $a05; Prefix = 'a05'; SubmitClass = 'reset-submit' }
)) {
Require-FieldRelation $entry.Content $entry.Prefix 'phone' 'phone' "$($entry.Key) 手机号"
Require-FieldRelation $entry.Content $entry.Prefix 'verification-code' 'verificationCode' "$($entry.Key) 验证码"
Require-FieldRelation $entry.Content $entry.Prefix 'password' 'password' "$($entry.Key) 密码"
Require-FieldRelation $entry.Content $entry.Prefix 'confirm-password' 'confirmPassword' "$($entry.Key) 确认密码"
$getCode = Get-ButtonByClass $entry.Content 'get-code'
if ($entry.Key -eq 'A04') {
$requiredDisabled = ':disabled="sendingCode || submitting || cooldownSeconds > 0 || registrationCommitted"'
} else {
$requiredDisabled = ':disabled="sendingCode || submitting || cooldownSeconds > 0"'
}
if (-not $getCode.Value.Contains($requiredDisabled)) { throw "$($entry.Key) 短信按钮禁用态未关联" }
$submit = Get-ButtonByClass $entry.Content $entry.SubmitClass
if (-not $submit.Value.Contains(':disabled="submitting || sendingCode || tacVisible"') -or -not $submit.Value.Contains(':aria-busy="submitting"')) { throw "$($entry.Key) 主提交忙碌态未关联" }
}
$a04Agreement = Get-ButtonByClass $a04 'agreement-toggle'
if (-not $a04Agreement.Value.Contains('role="checkbox"') -or -not $a04Agreement.Value.Contains(':aria-checked="agreed"')) { throw 'A04 协议复选语义未关联' }
Require-Match $a05 '(?s)<AppDialog\b(?=[^>]*:visible="successVisible")(?=[^>]*title="密码已重设")[^>]*>' 'A05 成功终态对话框'
if ($a05.Contains('class="success-layer"')) { throw 'A05 成功终态必须复用唯一 AppDialog owner' }
foreach ($token in @(
'.auth-plain-button {', 'margin: 0;', 'padding: 0;', 'border: 0;',
'background: transparent;', 'line-height: normal;', '.auth-plain-button::after {',
'.auth-plain-button:focus-visible {', '.auth-page.auth-page .auth-plain-button {',
'min-width: 48px;', 'min-height: 48px;'
)) {
if (-not $globalStyles.Contains($token)) { throw "认证原生按钮重置 owner 缺少:$token" }
}
Write-Output 'AUTH-ACCESSIBILITY-STATIC-CONTRACT PASS'
@@ -1,28 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$evidencePath = Join-Path $root 'tests/manual/tac-android-accessibility-evidence.json'
if (-not (Test-Path -LiteralPath $evidencePath)) {
throw @'
ANDROID-AUTH-ACCESSIBILITY-RELEASE BLOCKED
- 当前只能证明认证页与 TAC 外壳具备静态语义、键盘焦点和 48px 操作目标,不能证明天爱拖动挑战可由 TalkBack、外接键盘或低精度操作完成。
- 后端/provider 尚未提供与同一租户、客户端、场景、手机号和 challengeId 原子绑定的非拖动等价验证路径。
- 尚无 MuMu Android 上的 TalkBack、外接键盘、返回键、动态播报、焦点恢复和 48dp 触控目标三方实测证据。
- 关闭条件:先落地安全等价的非拖动验证方式,再由三位评审在 MuMu Android 对同一候选包完成实测,并提交 tests/manual/tac-android-accessibility-evidence.json。
'@
}
$evidence = Get-Content -LiteralPath $evidencePath -Raw -Encoding UTF8 | ConvertFrom-Json
if ($evidence.schemaVersion -ne 1) { throw 'Android 无障碍证据 schemaVersion 必须为 1' }
if ($evidence.platform -ne 'MuMu Android') { throw 'Android 无障碍证据必须来自 MuMu Android' }
if ([string]$evidence.artifactSha256 -notmatch '^[a-fA-F0-9]{64}$') { throw 'Android 无障碍证据必须绑定候选包 SHA256' }
if ([string]$evidence.accessibleChallenge.type -match '^(?i:slider|drag)$') { throw '拖动挑战不能作为无障碍等价路径' }
if ($evidence.accessibleChallenge.serverBound -ne $true) { throw '无障碍等价挑战必须由服务端原子绑定并消费' }
foreach ($check in @('talkBackPass', 'externalKeyboardPass', 'androidBackPass', 'liveRegionPass', 'focusRestorePass', 'touchTarget48dpPass')) {
if ($evidence.checks.PSObject.Properties[$check].Value -ne $true) { throw "Android 无障碍证据未通过:$check" }
}
$reviewers = @($evidence.reviewers | Sort-Object -Unique)
if ($reviewers.Count -ne 3) { throw 'Android 无障碍证据必须由三位不同评审者共同签署' }
Write-Output 'ANDROID-AUTH-ACCESSIBILITY-RELEASE PASS'
-302
View File
@@ -1,302 +0,0 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const toDataModuleUrl = (source) =>
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const run = async () => {
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
const savedTokens = [];
const clearedSessions = [];
const relaunchedRoutes = [];
const prelude = `
const currentUser = {};
const genealogies = [];
const publicGenealogies = [];
const treeMembers = [];
const notifications = [];
const joinApplications = [];
const listFamilyFeedFixtures = () => [];
const listFamilyArticleFixtures = () => [];
const listFamilyAlbumFixtures = () => [];
const listCeremonyFixtures = () => [];
const listGrowthRecordFixtures = () => [];
const listNotificationFixtures = () => [];
const runtimeConfig = {
mode: "remote",
baseUrl: "https://backend-api.ddxcjp.cn",
clientId: "client-1",
tenantId: "000000",
};
const hasRemoteConfig = () => true;
const resolveRuntimeMode = () => "remote";
const AUTH_VERIFICATION_OPERATION = Object.freeze({
PASSWORD_LOGIN: "password-login",
SMS_LOGIN: "sms-login",
REGISTER: "register",
FORGOT_PASSWORD: "forgot-password",
});
const assertSmsCode = (value) => {
if (typeof value !== "string" || !/^\\d{4}$/.test(value)) throw new Error("请输入 4 位短信验证码");
return value;
};
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" });
const session = {
getToken: () => "session-1",
saveToken: (token) => globalThis.__savedTokens.push(token),
clear: () => globalThis.__clearedSessions.push("cleared"),
};
`;
globalThis.__savedTokens = savedTokens;
globalThis.__clearedSessions = clearedSessions;
globalThis.__relaunchedRoutes = relaunchedRoutes;
const requests = [];
let nextResponse = null;
let holdResponse = false;
globalThis.uni = {
reLaunch(options) {
globalThis.__relaunchedRoutes.push(options.url);
options.complete?.();
},
request(options) {
requests.push(options);
const task = {
aborted: false,
abort() {
this.aborted = true;
options.fail({ errMsg: "request:fail abort" });
},
};
options.__task = task;
if (!holdResponse) queueMicrotask(() => options.success(nextResponse));
return task;
},
};
const { appApi, createRequestController } = await import(
toDataModuleUrl(`${prelude}\n${moduleBody}`)
);
const respond = (response) => {
nextResponse = response;
};
const sendSms = () => appApi.sendSmsCode({
operationCode: "register",
phone: "13800138000",
validToken: "ticket-1",
});
for (const invalid of [
{ statusCode: 200, data: null },
{ statusCode: 200, data: "<html>ok</html>" },
{ statusCode: 200, data: {} },
{ statusCode: 200, data: { code: "200", data: null } },
{ statusCode: 200, data: { code: null, data: null } },
{ statusCode: 200, data: { code: false, data: null } },
{ statusCode: 204, data: { code: 200, msg: "成功", data: null } },
]) {
respond(invalid);
await assert.rejects(sendSms(), /认证服务|响应|200/);
}
respond({ statusCode: 500, data: null });
await assert.rejects(sendSms(), /500/);
respond({ statusCode: 200, data: { code: 500, msg: "业务拒绝", data: null } });
await assert.rejects(sendSms(), /业务拒绝/);
respond({ statusCode: 200, data: { code: 200, msg: "操作成功", data: null } });
assert.strictEqual(await sendSms(), null, "合法 RVoid 必须精确解析为 null");
respond({ statusCode: 200, data: { code: 200 } });
assert.strictEqual(await sendSms(), null, "RVoid 未声明 data 必填,省略 data 仍必须解析为 null");
assert.strictEqual(requests.at(-1).header.clientid, "client-1");
assert.strictEqual(requests.at(-1).header.tenantId, "000000");
assert.strictEqual(
requests.at(-1).url,
"https://backend-api.ddxcjp.cn/genealogy/app/auth/sms/register/code",
);
assert.deepStrictEqual(requests.at(-1).data, {
tenantId: "000000",
grantType: "sms",
phone: "13800138000",
validToken: "ticket-1",
});
respond({ statusCode: 200, data: { code: 200 } });
await appApi.sendSmsCode({
operationCode: "register",
phone: "13800138000",
});
assert.deepStrictEqual(requests.at(-1).data, {
tenantId: "000000",
grantType: "sms",
phone: "13800138000",
}, "策略关闭时发码请求不得伪造 validToken");
respond({ statusCode: 200, data: { code: 200 } });
assert.strictEqual(
await appApi.changePhone({ phone: "13900139000", smsCode: "1234" }),
null,
"手机号换绑的 RVoid 响应必须解析为 null",
);
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/auth/phone");
assert.strictEqual(requests.at(-1).method, "PUT");
assert.strictEqual(requests.at(-1).header.Authorization, "Bearer session-1");
assert.deepStrictEqual(requests.at(-1).data, { phone: "13900139000", smsCode: "1234" });
// 已鉴权读取收到业务 401 时,只清理失效的本地会话;请求仍向调用方失败返回。
respond({ statusCode: 200, data: { code: 401, msg: "认证失败", data: null } });
await assert.rejects(
appApi.getProfile(),
(error) => error.code === "BUSINESS_ERROR" && error.businessCode === 401,
);
assert.deepStrictEqual(clearedSessions, ["cleared"]);
assert.deepStrictEqual(relaunchedRoutes, ["/pages/auth/a01-entry"]);
respond({ statusCode: 200, data: null });
await assert.rejects(
appApi.resetPassword({
phone: "13800138000",
passwordHash: "a".repeat(32),
smsCode: "1234",
}),
/认证服务|响应/,
);
respond({ statusCode: 200, data: { code: 200 } });
assert.strictEqual(
await appApi.resetPassword({
phone: "13800138000",
passwordHash: "a".repeat(32),
smsCode: "1234",
}),
null,
"找回密码的 RVoid 省略 data 时仍必须解析为 null",
);
assert.deepStrictEqual(requests.at(-1).data, {
tenantId: "000000",
grantType: "password",
phone: "13800138000",
newPassword: "a".repeat(32),
smsCode: "1234",
});
respond({
statusCode: 200,
data: { code: 200, msg: "操作成功", data: { access_token: "token-1" } },
});
const login = await appApi.loginWithSms({ phone: "13800138000", smsCode: "1234" });
assert.strictEqual(login.access_token, "token-1");
assert.deepStrictEqual(requests.at(-1).data, {
tenantId: "000000",
grantType: "sms",
phone: "13800138000",
smsCode: "1234",
});
assert.strictEqual(requests.at(-1).header.Authorization, undefined);
assert.deepStrictEqual(savedTokens, ["token-1"]);
respond({
statusCode: 200,
data: { code: 200, msg: "操作成功", data: { access_token: "token-password" } },
});
const passwordLogin = await appApi.loginWithPassword({
phone: "13800138000",
passwordHash: "a".repeat(32),
validToken: "ticket-password",
});
assert.strictEqual(passwordLogin.access_token, "token-password");
const passwordRequest = requests.at(-1);
assert.strictEqual(passwordRequest.url, "https://backend-api.ddxcjp.cn/genealogy/app/auth/login");
assert.strictEqual(passwordRequest.method, "POST");
assert.strictEqual(passwordRequest.header.clientid, "client-1");
assert.strictEqual(passwordRequest.header.Authorization, undefined);
assert.deepStrictEqual(passwordRequest.data, {
tenantId: "000000",
grantType: "password",
phone: "13800138000",
password: "a".repeat(32),
validToken: "ticket-password",
});
assert.deepStrictEqual(savedTokens, ["token-1", "token-password"]);
// 登录接口不携带既有会话,错误凭据不能借由 401 清理其他页面的本地会话。
respond({ statusCode: 200, data: { code: 401, msg: "密码错误", data: null } });
await assert.rejects(
appApi.loginWithPassword({
phone: "13800138000",
passwordHash: "a".repeat(32),
}),
(error) => error.code === "BUSINESS_ERROR" && error.businessCode === 401,
);
assert.deepStrictEqual(clearedSessions, ["cleared"]);
assert.deepStrictEqual(relaunchedRoutes, ["/pages/auth/a01-entry"]);
respond({
statusCode: 200,
data: { code: 200, msg: "操作成功", data: { access_token: "token-register" } },
});
const registration = await appApi.registerWithPassword({
phone: "13800138000",
nickName: " 联调昵称 ",
passwordHash: "a".repeat(32),
smsCode: "1234",
});
assert.strictEqual(registration.access_token, "token-register");
assert.deepStrictEqual(requests.at(-1).data, {
tenantId: "000000",
grantType: "password",
phone: "13800138000",
password: "a".repeat(32),
smsCode: "1234",
nickName: "联调昵称",
});
respond({
statusCode: 200,
data: { code: 200, msg: "操作成功", data: { access_token: "token-register-empty" } },
});
await appApi.registerWithPassword({
phone: "13800138000",
nickName: " ",
passwordHash: "a".repeat(32),
smsCode: "1234",
});
assert.strictEqual(Object.hasOwn(requests.at(-1).data, "nickName"), false);
respond({ statusCode: 200, data: { code: 200 } });
assert.strictEqual(await appApi.deactivateAccount({ smsCode: "1234" }), null);
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/auth/account/deactivate");
assert.strictEqual(requests.at(-1).header.Authorization, "Bearer session-1");
assert.deepStrictEqual(requests.at(-1).data, { smsCode: "1234" });
holdResponse = true;
const requestController = createRequestController();
const cancelled = appApi.sendSmsCode(
{
operationCode: "register",
phone: "13800138000",
validToken: "ticket-1",
},
{ requestController },
);
const activeRequest = requests.at(-1);
const activeTask = activeRequest.__task;
assert.strictEqual(activeRequest.timeout, 15000, "认证请求必须限制弱网等待时间");
requestController.abort();
await assert.rejects(cancelled, (error) => error.code === "REQUEST_CANCELLED");
assert.strictEqual(activeTask.aborted, true, "页面离开时必须真正中止 RequestTask");
delete globalThis.uni;
delete globalThis.__savedTokens;
delete globalThis.__clearedSessions;
delete globalThis.__relaunchedRoutes;
process.stdout.write("AUTH-API-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
@@ -1,40 +0,0 @@
$ErrorActionPreference = 'Stop'
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message }
}
$root = Split-Path -Parent $PSScriptRoot
$global = Get-Content -LiteralPath (Join-Path $root 'styles/global.scss') -Raw -Encoding utf8
Assert-Contains $global '.auth-page.auth-page .auth-plain-button[disabled]' '认证页禁用按钮必须由全局认证样式覆盖原生默认底色'
Assert-Contains $global 'background: transparent !important;' '认证页禁用按钮必须保持透明背景'
foreach ($relativePath in @(
'pages/auth/a01-entry.vue',
'pages/auth/a04-register.vue',
'pages/auth/a05-reset-password.vue'
)) {
$page = Get-Content -LiteralPath (Join-Path $root $relativePath) -Raw -Encoding utf8
foreach ($required in @(
'class="auth-plain-button get-code"',
'class="{ ''get-code--disabled'': sendingCode || cooldownSeconds > 0 }"',
'createAuthSmsCooldown({',
'const sentPhone = ref("");',
'isSmsDeliveryOutcomeUnknown',
'flex: 0 0 176rpx;',
'white-space: nowrap;',
'background: transparent !important;'
)) {
Assert-Contains $page $required "$relativePath 的验证码操作缺少视觉合同:$required"
}
if ($page.Contains('cooldownSeconds.value -= 1')) {
throw "$relativePath still uses a decrementing cooldown that freezes in background"
}
if (-not $page.Contains('cooldownSeconds > 0 && phone.length > 0')) {
throw "$relativePath must preserve an editable empty phone field when a scene cooldown is restored"
}
}
Write-Output 'AUTH-CODE-ACTION-VISUAL-CONTRACT PASS'
-41
View File
@@ -1,41 +0,0 @@
$ErrorActionPreference = 'Stop'
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message }
}
function Assert-NotContains {
param([string]$Content, [string]$Unexpected, [string]$Message)
if ($Content -match [regex]::Escape($Unexpected)) { throw $Message }
}
$root = Split-Path -Parent $PSScriptRoot
$shellPath = Join-Path $root 'components/AuthPageShell.vue'
if (-not (Test-Path -LiteralPath $shellPath)) { throw 'AuthPageShell is missing' }
$shell = Get-Content -LiteralPath $shellPath -Raw -Encoding utf8
foreach ($required in @(
'class="auth-shell__header"',
'class="auth-shell__header-image"',
'a01-vnext-header-v1.png',
'mode="widthFix"',
'class="auth-shell__paper"',
'a01-red-hall-ink-backdrop-v1.png',
'auth-page-paper.jpg',
'background-position: center calc(-48.18vw), center top;',
'background-repeat: no-repeat, repeat-y;',
'<slot />',
'<slot name="overlay" />',
'min-height: var(--app-viewport-height);'
)) {
Assert-Contains -Content $shell -Expected $required -Message "AuthPageShell is missing: $required"
}
$page = Get-Content -LiteralPath (Join-Path $root 'pages/auth/a01-entry.vue') -Raw -Encoding utf8
Assert-Contains -Content $page -Expected '<AuthPageShell' -Message 'A01 must consume AuthPageShell'
foreach ($forbidden in @('class="page-canvas"', 'class="page-backdrop"', 'mode="scaleToFill"', '1665rpx')) {
Assert-NotContains -Content $page -Unexpected $forbidden -Message "A01 retains rejected page coordinates: $forbidden"
}
Write-Output 'AUTH-PAGE-SHELL-CONTRACT PASS'
@@ -1,68 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
function Read-RequiredFile([string]$relativePath) {
$path = Join-Path $root $relativePath
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
throw "AUTH-POST-COMMIT-NAVIGATION-CONTRACT BLOCKED`n- missing file: $relativePath"
}
return Get-Content -LiteralPath $path -Raw -Encoding UTF8
}
$issues = [System.Collections.Generic.List[string]]::new()
$a01 = Read-RequiredFile 'pages/auth/a01-entry.vue'
$a04 = Read-RequiredFile 'pages/auth/a04-register.vue'
$pages = Read-RequiredFile 'pages.json'
function Require-Text(
[string]$content,
[string]$expected,
[string]$message
) {
if (-not $content.Contains($expected)) {
$script:issues.Add($message)
}
}
function Require-Pattern(
[string]$content,
[string]$pattern,
[string]$message
) {
if ($content -notmatch $pattern) {
$script:issues.Add($message)
}
}
Require-Text -content $a01 -expected 'const authenticationCommitted = ref(false);' -message 'A01 must remember that remote authentication already committed'
Require-Text -content $a01 -expected 'const enterAuthenticatedRoot = async () =>' -message 'A01 must own a navigation-only retry after authentication commits'
Require-Text -content $a01 -expected 'authenticationCommitted.value = true;' -message 'A01 must mark the remote authentication result before navigation'
Require-Text -content $a01 -expected 'const authenticationNavigationFailure =' -message 'A01 must own a navigation-specific failure message'
Require-Pattern -content $a01 -pattern '(?s)if \(authenticationCommitted\.value\)\s*return enterAuthenticatedRoot\(\);' -message 'A01 repeated submit after commit must retry only the local navigation'
Require-Text -content $a01 -expected 'const opened = await goRoot("G01");' -message 'A01 must inspect the navigation gateway boolean result'
Require-Text -content $a01 -expected 'if (opened !== true)' -message 'A01 must treat a false navigation result as retryable failure'
Require-Text -content $a04 -expected 'const registrationCommitted = ref(false);' -message 'A04 must remember that the remote registration already committed'
Require-Text -content $a04 -expected 'const enterAuthenticatedRoot = async () =>' -message 'A04 must own a navigation-only retry after registration commits'
Require-Text -content $a04 -expected 'registrationCommitted.value = true;' -message 'A04 must mark the remote registration result before navigation'
Require-Text -content $a04 -expected 'const registrationNavigationFailure =' -message 'A04 must own a navigation-specific failure message'
Require-Pattern -content $a04 -pattern '(?s)if \(registrationCommitted\.value\)\s*return enterAuthenticatedRoot\(\);' -message 'A04 repeated submit after commit must retry only the local navigation'
Require-Pattern -content $a04 -pattern '(?s)await appApi\.registerWithPassword\([\s\S]*?registrationCommitted\.value = true;\s*\}\s*catch' -message 'A04 must establish the registration commit before leaving the remote-write catch'
Require-Pattern -content $a04 -pattern '(?s)\}\s*catch \(error\) \{[\s\S]*?\}\s*finally[\s\S]*?\}\s*if \(!pageActive\) return;\s*await enterAuthenticatedRoot\(\);' -message 'A04 navigation must execute after the registration failure boundary'
Require-Text -content $a04 -expected 'const opened = await goRoot("G01");' -message 'A04 must inspect the navigation gateway boolean result'
Require-Text -content $a04 -expected 'if (opened !== true)' -message 'A04 must treat a false navigation result as retryable failure'
Require-Pattern -content $a04 -pattern '(?s)const requestBack = \(\) =>\s*registrationCommitted\.value\s*\?\s*enterAuthenticatedRoot\(\)' -message 'A04 committed registration must not enter the unsaved discard flow'
Require-Pattern -content $pages -pattern '"navigationBarTextStyle"\s*:\s*"white"' -message 'custom red headers require light Android status-bar content'
if ($issues.Count -gt 0) {
$lines = [System.Collections.Generic.List[string]]::new()
$lines.Add('AUTH-POST-COMMIT-NAVIGATION-CONTRACT BLOCKED')
foreach ($issue in $issues) {
$lines.Add("- $issue")
}
throw ($lines -join [Environment]::NewLine)
}
Write-Output 'AUTH-POST-COMMIT-NAVIGATION-CONTRACT PASS'
-87
View File
@@ -1,87 +0,0 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const toDataModuleUrl = (source) =>
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const run = async () => {
const source = fs.readFileSync(
path.join(__dirname, "../utils/auth-sms-cooldown.js"),
"utf8",
);
const { createAuthSmsCooldown } = await import(toDataModuleUrl(source));
let now = 1_000_000;
const timers = new Map();
let nextTimerId = 1;
const setIntervalFn = (callback) => {
const id = nextTimerId++;
timers.set(id, callback);
return id;
};
const clearIntervalFn = (id) => timers.delete(id);
const firstValues = [];
const first = createAuthSmsCooldown({
operationCode: "sms-login",
onChange: (value) => firstValues.push(value),
now: () => now,
setIntervalFn,
clearIntervalFn,
});
assert.strictEqual(first.sync(), 0);
first.start();
assert.strictEqual(firstValues.at(-1), 60);
now += 10_400;
assert.strictEqual(first.sync(), 50);
first.dispose();
assert.strictEqual(timers.size, 0, "dispose must stop only the page timer");
const restoredValues = [];
const restored = createAuthSmsCooldown({
operationCode: "sms-login",
onChange: (value) => restoredValues.push(value),
now: () => now,
setIntervalFn,
clearIntervalFn,
});
assert.strictEqual(
restored.sync(),
50,
"a recreated page must restore the scene cooldown without storing a phone number",
);
const other = createAuthSmsCooldown({
operationCode: "register",
onChange: () => {},
now: () => now,
setIntervalFn,
clearIntervalFn,
});
assert.strictEqual(other.sync(), 0, "cooldowns must be isolated by TAC scene");
now += 50_000;
assert.strictEqual(restored.sync(), 0);
restored.dispose();
other.dispose();
assert.throws(
() =>
createAuthSmsCooldown({
operationCode: "",
onChange: () => {},
}),
/operationCode/,
);
process.stdout.write("AUTH-SMS-COOLDOWN-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
-133
View File
@@ -1,133 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
function Read-ProjectFile {
param([string]$Path)
$fullPath = Join-Path $root $Path
if (-not (Test-Path -LiteralPath $fullPath)) { throw "缺少 TAC 集成文件:$Path" }
return Get-Content -LiteralPath $fullPath -Raw -Encoding UTF8
}
function Require-Text {
param([string]$Content, [string]$Text, [string]$Label)
if (-not $Content.Contains($Text)) { throw "$Label 缺少:$Text" }
}
function Reject-Text {
param([string]$Content, [string]$Text, [string]$Label)
if ($Content.Contains($Text)) { throw "$Label 仍保留:$Text" }
}
$owner = Read-ProjectFile 'utils/auth-verification.js'
$adapter = Read-ProjectFile 'static/tac/js/jiapu-tac-adapter.js'
$component = Read-ProjectFile 'components/TacVerification.vue'
$api = Read-ProjectFile 'utils/api.js'
$a01 = Read-ProjectFile 'pages/auth/a01-entry.vue'
$a04 = Read-ProjectFile 'pages/auth/a04-register.vue'
$a05 = Read-ProjectFile 'pages/auth/a05-reset-password.vue'
$vendorAssets = [ordered]@{
'static/tac/css/tac.css' = '181694518971a9f991d551b6a6e6dab2bf750f940bfc1673a158213f92eedbe0'
'static/tac/js/tac.min.js' = '505f73c051908d7b805db458990790be3e91f792c4001cec0ea9377d7d302b55'
'static/tac/images/icon.png' = '53e37ffc5bb81c46e6306b7d61d2eaa3de57e47ca6cdb8d5210022ae815c21c2'
'static/tac/images/dun.jpeg' = 'd9178a8c4cca36e3df6c3acd7e895ce9d34dd60ef3f1cf4a70c94d4324ed96e7'
}
foreach ($entry in $vendorAssets.GetEnumerator()) {
$absolutePath = Join-Path $root $entry.Key
if (-not (Test-Path -LiteralPath $absolutePath)) { throw "缺少用户提供的 TAC 资产:$($entry.Key)" }
$actualHash = (Get-FileHash -LiteralPath $absolutePath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -ne $entry.Value) { throw "用户提供的 TAC 供应商资产发生漂移:$($entry.Key)" }
}
foreach ($token in @('lang="renderjs"', './static/tac/css/tac.css', './static/tac/js/tac.min.js', './static/tac/js/jiapu-tac-adapter.js', 'window.TAC', 'window.CaptchaConfig', 'window.JiapuTacAdapter', 'xhr.status >= 200 && xhr.status < 300', '$ownerInstance.callMethod', 'activeXhr', 'xhr.timeout = 15000', 'xhr.ontimeout', 'xhr.abort()', 'config.doSendRequest = (options) => this.sendStrictRequest(options)', 'this.tac = new window.TAC(config);')) {
Require-Text -Content $component -Text $token -Label 'TacVerification'
}
Reject-Text -Content $component -Text 'config.doSendRequest = this.sendStrictRequest' -Label '失去 renderjs 实例上下文的传输函数'
$staleGuard = 'if (generation !== this.generation || !this.requestContext || this.requestContext.visible !== true) return;'
if ([regex]::Matches($component, [regex]::Escape($staleGuard)).Count -lt 2) {
throw 'TacVerification 必须在资源加载成功与失败两条分支都拒绝过期代次'
}
Require-Text -Content $adapter -Text 'payload: { track:' -Label 'TAC payload.track 适配器'
foreach ($unsafe in @('code === 200 && response.data', 'passed !== false', "validToken: 'mock", 'mock-valid-token')) {
Reject-Text -Content ($owner + $adapter + $component + $api) -Text $unsafe -Label 'TAC 安全合同'
}
foreach ($customVisual in @('tac-panel', 'tac-heading', 'tac-tool', 'logoUrl:', 'i18n:', 'new window.TAC(config, {')) {
Reject-Text -Content $component -Text $customVisual -Label 'TAC 供应商原生呈现'
}
foreach ($method in @('getCaptchaRequirement', 'sendSmsCode', 'loginWithPassword', 'loginWithSms', 'registerWithPassword', 'resetPassword')) {
Require-Text -Content $api -Text "async $method" -Label '认证 API'
}
$authApiMatch = [regex]::Match($api, '(?s)async getCaptchaRequirement.*?(?=\s+async getProfile)')
if (-not $authApiMatch.Success) { throw '无法定位唯一认证 API 区段' }
Reject-Text -Content $authApiMatch.Value -Text "return { success: true }" -Label '认证短信 mock'
Reject-Text -Content $authApiMatch.Value -Text "mock-session-token" -Label '认证会话 mock'
Require-Text -Content $api -Text 'operationCode, phone, validToken' -Label '短信票据请求'
foreach ($token in @('const requestAuth =', 'strictEnvelope: true', 'expectedStatus: 200', "hasOwnProperty.call(data, 'data')")) {
Require-Text -Content $api -Text $token -Label '认证严格响应 owner'
}
foreach ($token in @('const REQUEST_TIMEOUT_MS = 15000', 'export const createRequestController', "error?.code === 'REQUEST_CANCELLED'", 'task?.abort?.()')) {
Require-Text -Content $api -Text $token -Label '认证请求生命周期 owner'
}
foreach ($page in @($a01, $a04, $a05)) {
Require-Text -Content $page -Text '<TacVerification' -Label '认证页面'
Require-Text -Content $page -Text 'normalizeTacSuccess' -Label '认证页面'
Reject-Text -Content $page -Text '滑动验证待接口接入' -Label '认证页面占位'
Reject-Text -Content $page -Text '行为验证接口待接入' -Label '认证页面占位'
Reject-Text -Content $page -Text 'verification-layer' -Label '旧假验证浮层'
Require-Text -Content $page -Text 'isAuthPhone' -Label '认证手机号唯一校验器'
Reject-Text -Content $page -Text '/^1\d{10}$/' -Label '认证页面重复手机号规则'
Require-Text -Content $page -Text 'submitting: submitting.value || sendingCode.value' -Label '认证异步返回守卫'
Require-Text -Content $page -Text '"block-submitting"' -Label '认证异步返回守卫'
Require-Text -Content $page -Text 'let pageActive = true' -Label '认证页面卸载代次'
Require-Text -Content $page -Text 'pageActive = false' -Label '认证页面卸载代次'
Require-Text -Content $page -Text 'createRequestController' -Label '认证页面可取消请求'
Require-Text -Content $page -Text 'isRequestCancelled' -Label '认证页面取消静默处理'
Require-Text -Content $page -Text 'authRequestController.abort()' -Label '认证页面离页中止请求'
if ([regex]::Matches($page, [regex]::Escape('{ requestController: authRequestController }')).Count -lt 3) {
throw '认证页面的策略、短信与最终提交必须都绑定页面请求控制器'
}
Require-Text -Content $page -Text 'const requestedPhone = phone.value' -Label '认证手机号请求快照'
Require-Text -Content $page -Text 'subject: requestedPhone' -Label '认证手机号请求快照'
$phoneLock = if ($page -eq $a01) {
':disabled="sendingCode || submitting || tacVisible || authenticationCommitted || (cooldownSeconds > 0 && phone.length > 0)"'
} elseif ($page -eq $a04) {
':disabled="sendingCode || submitting || tacVisible || registrationCommitted || (cooldownSeconds > 0 && phone.length > 0)"'
} else {
':disabled="sendingCode || submitting || (cooldownSeconds > 0 && phone.length > 0)"'
}
Require-Text -Content $page -Text $phoneLock -Label '短信流程手机号锁定'
if ([regex]::Matches($page, [regex]::Escape('if (!pageActive) return;')).Count -lt 3) {
throw '认证页面必须在策略、短信与最终提交的异步回流前拒绝卸载后的旧结果'
}
}
foreach ($pageContract in @(
@{ Content = $a01; Loading = '登录中…' },
@{ Content = $a04; Loading = '注册中…' },
@{ Content = $a05; Loading = '提交中…' }
)) {
Require-Text -Content $pageContract.Content -Text '请求中…' -Label '认证短信加载文案'
Require-Text -Content $pageContract.Content -Text $pageContract.Loading -Label '认证提交加载文案'
}
foreach ($token in @('const blockBusyAction = () =>', 'if (blockBusyAction()) return;', 'const prepareForgotPassword = () => {', 'const prepareRegister = () => {')) {
Require-Text -Content $a01 -Text $token -Label 'A01 忙碌动作门禁'
}
foreach ($token in @('AUTH_VERIFICATION_OPERATION.PASSWORD_LOGIN', 'AUTH_VERIFICATION_OPERATION.SMS_LOGIN', 'appApi.loginWithPassword', 'appApi.loginWithSms', 'calcMD5(password.value)', 'const preparePasswordLogin = async () =>', '/^\d{4}$/')) {
Require-Text -Content $a01 -Text $token -Label 'A01'
}
Reject-Text -Content $a01 -Text 'PASSWORD_TAC_BLOCKED_MESSAGE' -Label 'A01 旧密码登录硬关闭'
Reject-Text -Content $a01 -Text '/^\d{6}$/' -Label 'A01 六位短信码'
foreach ($token in @('AUTH_VERIFICATION_OPERATION.REGISTER', 'v-model.trim="verificationCode"', 'appApi.registerWithPassword', 'calcMD5(password.value)', '/^\d{4}$/', 'goRoot("G01")')) {
Require-Text -Content $a04 -Text $token -Label 'A04'
}
foreach ($token in @('AUTH_VERIFICATION_OPERATION.FORGOT_PASSWORD', 'appApi.resetPassword', 'calcMD5(password.value)', '/^\d{4}$/', 'await appApi.resetPassword')) {
Require-Text -Content $a05 -Text $token -Label 'A05'
}
Reject-Text -Content $a05 -Text '/^\d{6}$/' -Label 'A05 六位短信码'
Write-Output 'AUTH-TAC-INTEGRATION-CONTRACT PASS'
-52
View File
@@ -1,52 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$apiPath = Join-Path $root 'utils/api.js'
$a01Path = Join-Path $root 'pages/auth/a01-entry.vue'
$runtimePath = Join-Path $PSScriptRoot 'auth-api-runtime-smoke.js'
$issues = New-Object System.Collections.Generic.List[string]
foreach ($path in @($apiPath, $a01Path, $runtimePath)) {
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
$issues.Add("missing authentication owner: $path")
}
}
if ($issues.Count -eq 0) {
$api = Get-Content -Raw -Encoding UTF8 -LiteralPath $apiPath
$a01 = Get-Content -Raw -Encoding UTF8 -LiteralPath $a01Path
$passwordOwner = [regex]::Match($api, '(?s)async loginWithPassword\(\{ phone, passwordHash, validToken \}.*?(?=\s+async loginWithSms)')
if (-not $passwordOwner.Success) {
$issues.Add('missing bounded password login owner')
} else {
foreach ($required in @("url: '/genealogy/app/auth/login'", "tenantId: runtimeConfig.tenantId", "grantType: 'password'", 'password: assertPasswordHash(passwordHash)', 'const normalizedValidToken = normalizeOptionalValidToken(validToken)', '...(normalizedValidToken ? { validToken: normalizedValidToken } : {})')) {
if (-not $passwordOwner.Value.Contains($required)) { $issues.Add("password login owner missing: $required") }
}
if ($passwordOwner.Value.Contains('authPayload(')) {
$issues.Add('password login must not put clientId in the body')
}
}
foreach ($required in @('const preparePasswordLogin = async () =>', 'appApi.loginWithPassword', 'TAC')) {
if (-not $a01.Contains($required)) { $issues.Add("A01 missing password TAC precondition: $required") }
}
$smsOwner = [regex]::Match($api, '(?s)async sendSmsCode\(\{ operationCode, phone, validToken \}.*?(?=\s+async loginWithPassword)')
if (-not $smsOwner.Success -or -not $smsOwner.Value.Contains('const normalizedValidToken = normalizeOptionalValidToken(validToken)') -or -not $smsOwner.Value.Contains('...(normalizedValidToken ? { validToken: normalizedValidToken } : {})')) {
$issues.Add('SMS owner must remain the sole validToken consumer and omit it only when the server policy closes TAC')
}
}
if ($issues.Count -eq 0) {
$runtimeOutput = @(& node $runtimePath 2>&1)
if ($LASTEXITCODE -ne 0 -or 'AUTH-API-RUNTIME-SMOKE PASS' -notin $runtimeOutput) {
$issues.Add("password and SMS wire runtime smoke failed: $($runtimeOutput -join ' | ')")
}
}
if ($issues.Count -gt 0) {
Write-Output 'AUTH-TAC-OPENAPI-CONTRACT BLOCKED'
foreach ($issue in $issues) { Write-Output "- $issue" }
Write-Output '- Password login and SMS operations send a validToken only after the server marks that operation as verification-required.'
exit 1
}
Write-Output 'AUTH-TAC-OPENAPI-CONTRACT PASS'
-164
View File
@@ -1,164 +0,0 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const toDataModuleUrl = (source) =>
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const run = async () => {
const source = fs.readFileSync(
path.join(__dirname, "../utils/auth-verification.js"),
"utf8",
);
const {
AUTH_VERIFICATION_OPERATION,
isAuthPhone,
assertSmsCode,
normalizeCaptchaRequirement,
createTacRenderContext,
normalizeTacSuccess,
isSmsDeliveryOutcomeUnknown,
} = await import(toDataModuleUrl(source));
assert.deepStrictEqual(
{ ...AUTH_VERIFICATION_OPERATION },
{
PASSWORD_LOGIN: "password-login",
SMS_LOGIN: "sms-login",
REGISTER: "register",
FORGOT_PASSWORD: "forgot-password",
PHONE_CHANGE: "phone-change",
ACCOUNT_DEACTIVATE: "account-deactivate",
},
"认证动作必须由受保护 OpenAPI 的唯一枚举拥有",
);
assert.strictEqual(isAuthPhone("13800138000"), true);
for (const invalid of ["", "12800138000", "1380013800", "138001380000", 13800138000, null]) {
assert.strictEqual(isAuthPhone(invalid), false, `非法认证手机号被放行:${invalid}`);
}
assert.strictEqual(assertSmsCode("1234"), "1234");
for (const invalid of ["", "123", "12345", "12a4", 1234, null]) {
assert.throws(() => assertSmsCode(invalid), /4 位短信验证码/);
}
const requirement = normalizeCaptchaRequirement(
{
required: true,
providerCode: "TIANAI",
captchaType: "SLIDER",
sceneCode: "APP_REGISTER",
ttlSeconds: 300,
},
);
assert.deepStrictEqual(requirement, {
required: true,
providerCode: "TIANAI",
captchaType: "SLIDER",
sceneCode: "APP_REGISTER",
ttlSeconds: 300,
});
assert.deepStrictEqual(
normalizeCaptchaRequirement({ ...requirement, required: false }),
{ required: false, sceneCode: "APP_REGISTER" },
"策略关闭时必须保留服务端绑定场景并跳过 TAC 渲染",
);
assert.throws(
() => normalizeCaptchaRequirement({ ...requirement, providerCode: "OTHER" }),
/TIANAI/,
);
assert.throws(
() => normalizeCaptchaRequirement({ ...requirement, captchaType: "math" }),
/验证码类型/,
);
const context = createTacRenderContext({
requestId: "register-1",
baseUrl: "https://backend-api.ddxcjp.cn/",
clientId: "client-1",
tenantId: "000000",
operationCode: AUTH_VERIFICATION_OPERATION.REGISTER,
subject: "13800138000",
requirement,
});
assert.deepStrictEqual(context, {
requestId: "register-1",
baseUrl: "https://backend-api.ddxcjp.cn",
challengeUrl: "https://backend-api.ddxcjp.cn/genealogy/app/auth/verification/register/challenge",
verifyUrl: "https://backend-api.ddxcjp.cn/genealogy/app/auth/verification/register/verify",
clientId: "client-1",
tenantId: "000000",
operationCode: AUTH_VERIFICATION_OPERATION.REGISTER,
subject: "13800138000",
providerCode: "TIANAI",
captchaType: "SLIDER",
});
assert.throws(
() => createTacRenderContext({ ...context, baseUrl: "http://backend-api.ddxcjp.cn", requirement }),
/HTTPS/,
);
assert.throws(
() => createTacRenderContext({ ...context, subject: "1380013800", requirement }),
/手机号/,
);
assert.throws(
() => createTacRenderContext({ ...context, operationCode: "APP_REGISTER", requirement }),
/认证动作/,
);
assert.throws(
() => createTacRenderContext({ ...context, requirement: { ...requirement, required: false } }),
/无需行为验证/,
);
assert.deepStrictEqual(
normalizeTacSuccess(
{ requestId: "register-1", validToken: "ticket-1", expireSeconds: 300 },
"register-1",
),
{ requestId: "register-1", validToken: "ticket-1", expireSeconds: 300 },
);
assert.throws(
() => normalizeTacSuccess({ requestId: "stale", validToken: "ticket-1" }, "register-1"),
/已过期|不匹配/,
);
assert.throws(
() => normalizeTacSuccess({ requestId: "register-1", validToken: "" }, "register-1"),
/票据/,
);
for (const error of [
{ code: "REQUEST_TIMEOUT" },
{ code: "NETWORK_ERROR" },
{ code: "RESPONSE_INVALID" },
{ code: "REQUEST_CANCELLED" },
{ code: "HTTP_ERROR", httpStatus: 201 },
{ code: "HTTP_ERROR", httpStatus: 204 },
{ code: "HTTP_ERROR", httpStatus: 302 },
{ code: "HTTP_ERROR", httpStatus: 408 },
{ code: "HTTP_ERROR", httpStatus: 500 },
{ code: "BUSINESS_ERROR", businessCode: 408 },
{ code: "BUSINESS_ERROR", businessCode: 500 },
]) {
assert.strictEqual(isSmsDeliveryOutcomeUnknown(error), true);
}
for (const error of [
null,
{ code: "HTTP_ERROR", httpStatus: 400 },
{ code: "HTTP_ERROR", httpStatus: 401 },
{ code: "HTTP_ERROR", httpStatus: 422 },
{ code: "HTTP_ERROR", httpStatus: 429 },
{ code: "BUSINESS_ERROR", businessCode: 429 },
]) {
assert.strictEqual(isSmsDeliveryOutcomeUnknown(error), false);
}
process.stdout.write("AUTH-VERIFICATION-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
@@ -1,82 +0,0 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const toDataModuleUrl = (source) =>
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const run = async () => {
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
const prelude = `
const currentUser = {};
const genealogies = [];
const publicGenealogies = [];
const treeMembers = [];
const notifications = [];
const joinApplications = [];
const listFamilyFeedFixtures = () => [];
const listFamilyArticleFixtures = () => [];
const listFamilyAlbumFixtures = () => [];
const listCeremonyFixtures = () => [];
const listGrowthRecordFixtures = () => [];
const listNotificationFixtures = () => [];
const runtimeConfig = { mode: "remote", baseUrl: "https://backend-api.ddxcjp.cn", clientId: "client-1", tenantId: "000000" };
const hasRemoteConfig = () => true;
const resolveRuntimeMode = () => "remote";
const AUTH_VERIFICATION_OPERATION = Object.freeze({});
const assertSmsCode = (value) => value;
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" });
const fromApiGenealogyAccess = () => null;
const session = { getToken: () => "session-1", saveToken() {} };
`;
const requests = [];
let nextResponse;
globalThis.uni = {
request(options) {
requests.push(options);
queueMicrotask(() => options.success(nextResponse));
return { abort() {} };
},
};
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
nextResponse = { statusCode: 200, data: { code: 200, data: [{ invitationId: "9001", inviteStatus: "PENDING" }] } };
assert.deepStrictEqual(await appApi.getCeremonyInvitations("1001", "2001"), [{ invitationId: "9001", inviteStatus: "PENDING" }]);
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/1001/ceremonies/2001/invitations");
assert.strictEqual(requests.at(-1).method, "GET");
nextResponse = { statusCode: 200, data: { code: 200, data: { invitationId: "9001", inviteStatus: "ACCEPTED" } } };
await appApi.respondToCeremonyInvitation("1001", "2001", { inviteStatus: "ACCEPTED" });
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/1001/ceremonies/2001/invitations/me");
assert.strictEqual(requests.at(-1).method, "PUT");
assert.deepStrictEqual(requests.at(-1).data, { inviteStatus: "ACCEPTED" });
nextResponse = { statusCode: 200, data: { code: 200, data: [] } };
await appApi.replaceCeremonyInvitees("1001", "2001", { inviteeUserIds: [] });
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/1001/ceremonies/2001/invitees");
assert.deepStrictEqual(requests.at(-1).data, { inviteeUserIds: [] });
await assert.rejects(
appApi.replaceCeremonyInvitees("1001", "2001", { inviteeUserIds: [7, 7] }),
/重复标识/,
);
await assert.rejects(
appApi.replaceCeremonyInvitees("1001", "2001", { inviteeUserIds: ["2081232520259612673"] }),
(error) => error?.code === "CEREMONY_INVITEE_ID_UNSAFE",
);
nextResponse = { statusCode: 200, data: { code: 200, data: [] } };
await appApi.getMyCeremonyInvitations();
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/ceremony-invitations/mine");
assert.strictEqual(requests.at(-1).method, "GET");
delete globalThis.uni;
process.stdout.write("CEREMONY-INVITATIONS-API-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
-68
View File
@@ -1,68 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$errors = [System.Collections.Generic.List[string]]::new()
function Test-LocalImport {
param(
[string]$SourceFile,
[string]$Target,
[string[]]$Extensions
)
if ($Target -match '^(https?:|sass:)') { return $true }
if ($Target.StartsWith('@/')) {
$candidate = Join-Path $root $Target.Substring(2)
} elseif ($Target.StartsWith('./') -or $Target.StartsWith('../')) {
$candidate = Join-Path (Split-Path -Parent $SourceFile) $Target
} else {
return $true
}
if (Test-Path -LiteralPath $candidate) { return $true }
foreach ($extension in $Extensions) {
if (Test-Path -LiteralPath ($candidate + $extension)) { return $true }
}
return $false
}
$pages = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages.json') | ConvertFrom-Json
foreach ($page in $pages.pages) {
$pageFile = Join-Path $root ($page.path + '.vue')
if (-not (Test-Path -LiteralPath $pageFile)) {
$errors.Add("Route has no page file: $($page.path)")
}
}
$manifestPath = Join-Path $root 'manifest.json'
$manifestVueVersion = & node.exe -e 'process.stdout.write(String(require(process.argv[1]).vueVersion||String()))' $manifestPath
if ($LASTEXITCODE -ne 0) { throw 'manifest.json 不是有效 JSON' }
if ($manifestVueVersion -eq '3' -and -not (Test-Path -LiteralPath (Join-Path $root 'index.html'))) {
$errors.Add('Vue 3 project is missing index.html.')
}
$sourceFiles = Get-ChildItem -Path $root -Recurse -File | Where-Object {
$_.FullName -notmatch '\\unpackage\\' -and $_.Extension -in '.vue', '.js', '.scss'
}
foreach ($source in $sourceFiles) {
$content = Get-Content -Raw -Encoding UTF8 $source.FullName
foreach ($match in [regex]::Matches($content, '@(?:import|use|forward)\s+["''](?<target>[^"'']+)["'']')) {
$target = $match.Groups['target'].Value
if (-not (Test-LocalImport -SourceFile $source.FullName -Target $target -Extensions @('.scss'))) {
$errors.Add("Missing Sass import in $($source.FullName.Substring($root.Length + 1)): $target")
}
}
foreach ($match in [regex]::Matches($content, '(?:from|import)\s+["''](?<target>[^"'']+)["'']')) {
$target = $match.Groups['target'].Value
if (-not (Test-LocalImport -SourceFile $source.FullName -Target $target -Extensions @('.js', '.vue', '.json'))) {
$errors.Add("Missing module import in $($source.FullName.Substring($root.Length + 1)): $target")
}
}
}
if ($errors.Count -gt 0) {
throw "Compile audit failed:`n$($errors -join "`n")"
}
Write-Output 'PASS compile audit'
-158
View File
@@ -1,158 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$requiredFiles = @('utils/config.js', 'utils/session.js', 'utils/genealogy-context.js', 'utils/api.js', 'pages/genealogy/g03-create-genealogy.vue', 'pages/family/f04-article-list.vue', 'pages/family/f02-publish-feed.vue')
foreach ($file in $requiredFiles) {
if (-not (Test-Path (Join-Path $root $file))) {
throw "Missing core-flow file: $file"
}
}
$config = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'utils/config.js')
foreach ($token in @('mode:', 'isMockMode')) {
if ($config -notmatch [regex]::Escape($token)) {
throw "Missing runtime mode contract: $token"
}
}
$context = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'utils/genealogy-context.js')
foreach ($method in @('getCurrentGenealogyId', 'setCurrentGenealogyId', 'clearCurrentGenealogyId')) {
if ($context -notmatch [regex]::Escape($method)) {
throw "Missing genealogy context method: $method"
}
}
$api = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'utils/api.js')
foreach ($method in @('unwrapResponse', 'getCaptchaRequirement', 'sendSmsCode', 'loginWithPassword', 'loginWithSms', 'registerWithPassword', 'resetPassword', 'changePassword', 'logout')) {
if ($api -notmatch [regex]::Escape($method)) {
throw "Missing auth API method: $method"
}
}
foreach ($method in @('createPerson', 'getPerson')) {
if ($api -notmatch [regex]::Escape($method)) {
throw "Missing lineage API method: $method"
}
}
foreach ($method in @('getArticles', 'getAlbums', 'getCeremonies', 'getGrowthRecords', 'createFeed')) {
if ($api -notmatch [regex]::Escape($method)) {
throw "Missing family content API method: $method"
}
}
if ($api -notmatch 'getProfile') {
throw 'Missing current-user profile API method'
}
$pages = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages.json')
if ($pages -notmatch 'pages/genealogy/g03-create-genealogy') {
throw 'Missing G03 create-flow route'
}
if ($pages -match 'pages/genealogy/g04-first-ancestor') {
throw 'G04 first-person route must be merged into G03'
}
$createFlow = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages/genealogy/g03-create-genealogy.vue')
foreach ($token in @('currentStep.value = "ancestor"', 'submitAncestor', 'genealogyId')) {
if ($createFlow -notmatch [regex]::Escape($token)) {
throw "Missing G03 first-person flow: $token"
}
}
if ($config -notmatch "mode:\s*'remote'" -or $config -notmatch "baseUrl:\s*'https://backend-api\.ddxcjp\.cn'") {
throw 'Runtime config must enable the requested remote test backend over HTTPS'
}
if ($config -match '182\.61\.18\.23|http://backend-api\.ddxcjp\.cn|https://backend-api\.ddxcjp\.cn/') {
throw 'Runtime config retains an obsolete, insecure or trailing-slash backend URL'
}
if (-not $api.Contains('loginResult?.access_token') -or $api -match 'loginResult\?\.(token|accessToken|tokenValue)') {
throw 'Session adapter must consume only the current AppLoginVo.access_token contract'
}
if ($api -match 'mock-session-token|mockResult') { throw '认证链路不得生成本地伪会话或伪票据' }
if (([regex]::Matches($api, "return saveLogin\(result\)")).Count -ne 3) {
throw '密码登录、短信登录与注册必须共用唯一 AppLoginVo 会话适配器'
}
if ($api -notmatch 'import \{ hasRemoteConfig, resolveRuntimeMode, runtimeConfig \}' -or ([regex]::Matches($api, 'requireRemoteAuth\(\)')).Count -ne 7) {
throw '七个认证传输必须通过共享运行模式解析器失败关闭'
}
if ($api -match 'if \(isMockMode\(\)\)') {
throw 'Auth transports must not treat every non-mock mode as remote'
}
# 普通加入申请的旧数字审核体与宽松 adapter 已移交给专项 OpenAPI 门禁;
# 页面在后端门禁通过前继续由 G08-G10 合同证明未接入这些遗留方法。
if ($createFlow -match 'step=ancestor|query\?\.step') { throw 'G03 first-person flow must not restore the retired route step contract' }
if ($createFlow -match 'createPerson') { throw 'G03 must not retain the removed createPerson entrypoint' }
foreach ($route in @('pages/family/f04-article-list', 'pages/family/f02-publish-feed')) {
if ($pages -notmatch [regex]::Escape($route)) {
throw "Missing content page route: $route"
}
}
foreach ($pageFile in @('pages/genealogy/g05-genealogy-overview.vue', 'pages/tree/t01-tree-overview.vue', 'pages/tree/t03-member-profile.vue')) {
$page = Get-Content -Raw -Encoding UTF8 (Join-Path $root $pageFile)
if ($page -match "id=1001|id=3") {
throw "Hard-coded route ID remains in $pageFile"
}
if ($pageFile -eq 'pages/genealogy/g05-genealogy-overview.vue') {
if ($page -notmatch 'const\s+genealogyId\s*=\s*ref\(["'']["'']\)' -or $page -notmatch 'query\.genealogyId') {
throw 'G05 must own its explicit genealogyId route context'
}
} elseif ($pageFile -eq 'pages/tree/t01-tree-overview.vue') {
if ($page -notmatch 'genealogyContext') {
throw 'T01 must retain the selected genealogy fallback until its domain-data phase'
}
} else {
if ($page -notmatch 'const\s+genealogyId\s*=\s*ref\(["'']["'']\)' -or
$page -notmatch 'const\s+personId\s*=\s*ref\(["'']["'']\)' -or
$page -notmatch 'query\.genealogyId' -or
$page -notmatch 'query\.personId' -or
$page -match 'genealogyContext') {
throw 'T03 must use its validated genealogyId/personId route identity without mutable global fallback'
}
}
}
foreach ($method in @('markNotificationRead', 'markAllNotificationsRead')) {
if ($api -notmatch [regex]::Escape($method)) {
throw "Missing notification API method: $method"
}
}
foreach ($pageFile in @('pages/genealogy/g10-application-review.vue', 'pages/notification/n01-message-center.vue')) {
$page = Get-Content -Raw -Encoding UTF8 (Join-Path $root $pageFile)
if ($page -match '1001') {
throw "Hard-coded genealogy ID remains in $pageFile"
}
}
$applications = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages/genealogy/g10-application-review.vue')
foreach ($token in @('const genealogyId = ref("")', 'genealogyId.value = String(query.genealogyId || "")', 'getGenealogyFixtureAccess(genealogyId.value)')) {
if ($applications -notmatch [regex]::Escape($token)) {
throw "Applications page must use its explicit fail-closed route context: $token"
}
}
if ($applications -match 'genealogyContext') { throw 'Applications page must not bypass its explicit route context through global genealogy context' }
$notifications = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages/notification/n01-message-center.vue')
foreach ($method in @('openNotice', 'markAllRead', 'notice-state--list')) {
if ($notifications -notmatch [regex]::Escape($method)) { throw "Notification page does not expose local design interaction: $method" }
}
if ($notifications -match "@/utils/api\.js|\bappApi\b") { throw 'Notification design page must not connect the API layer' }
$family = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages/family/f01-family-feed.vue')
foreach ($token in @('genealogyContext', 'openSection', 'articles: "F04"', 'albums: "F07"', 'rituals: "R05"', 'memos: "R10"', 'openPage("F02"')) {
if ($family -notmatch [regex]::Escape($token)) {
throw "Missing family content flow: $token"
}
}
if ($family -match '/pages/') { throw 'F01 family content flow must use the unique navigation registry' }
$profile = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages/profile/m01-profile-home.vue')
foreach ($token in @('profile-state--ready', 'menuItems', 'toProfile')) {
if ($profile -notmatch [regex]::Escape($token)) {
throw "Missing local profile design behavior: $token"
}
}
if ($profile -match "@/utils/api\.js|\bappApi\b") { throw 'Profile design page must not connect the API layer' }
Write-Output 'PASS core-flow session and auth contract'
File diff suppressed because it is too large Load Diff
@@ -1,104 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$openApiPath = Join-Path $root 'genealogy-app-openapi.yaml'
if (-not (Test-Path -LiteralPath $openApiPath)) {
throw 'Current backend OpenAPI export genealogy-app-openapi.yaml is missing at the repository root'
}
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath $openApiPath
$operationCount = [regex]::Matches($document, '(?m)^ (?:get|post|put|delete|patch):\s*$').Count
if ($operationCount -ne 137) {
throw "Current backend OpenAPI operation count drifted: $operationCount"
}
function Get-PathBlock {
param([string]$Path)
$escapedPath = [regex]::Escape($Path)
$match = [regex]::Match($document, "(?ms)^ ${escapedPath}:`r?`n(.*?)(?=^ /|\z)")
if (-not $match.Success) { throw "Missing path: $Path" }
return $match.Groups[1].Value
}
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if (-not $Content.Contains($Expected)) { throw $Message }
}
foreach ($path in @(
'/genealogy/app/auth/sms/{operationCode}/code',
'/genealogy/app/files/resumable/init',
'/genealogy/app/files/resumable/chunk',
'/genealogy/app/files/resumable/complete',
'/genealogy/app/region/children',
'/genealogy/app/region/path/{regionCode}',
'/genealogy/app/region/search',
'/genealogy/app/region/{regionCode}'
)) {
[void](Get-PathBlock $path)
}
foreach ($retiredPath in @(
'/captcha/require',
'/captcha/challenge',
'/captcha/verify',
'/auth/code',
'/genealogy/app/auth/sms/code',
'/genealogy/app/files/upload',
'/genealogy/app/files/reference',
'/genealogy/region/children',
'/genealogy/region/path/{regionCode}',
'/genealogy/region/search',
'/genealogy/region/{regionCode}'
)) {
if ($document -match "(?m)^ $([regex]::Escape($retiredPath)):") {
throw "Retired path remains in current backend OpenAPI: $retiredPath"
}
}
$videoPath = Get-PathBlock '/genealogy/app/genealogies/{genealogyId}/videos'
Assert-Contains $videoPath "`$ref: '#/components/requestBodies/Video'" 'Video create must consume VideoBody'
Assert-Contains $videoPath "`$ref: '#/components/responses/ListResult'" 'Video list remains a generic ListResult until the backend publishes a VideoView DTO'
$videoSchema = [regex]::Match($document, "(?ms)^ VideoBody:`r?`n(.*?)(?=^ [A-Za-z][A-Za-z0-9_-]*:|\z)").Value
Assert-Contains $videoSchema 'required: [videoTitle, videoOssId]' 'VideoBody required fields drifted'
foreach ($path in @('/genealogy/app/site/articles', '/genealogy/app/site/pages/{pageKey}')) {
$sitePath = Get-PathBlock $path
if ($sitePath -match 'VideoView|SiteArticleView|SitePageView') {
throw "Site content response DTO changed for $path; review M10 integration"
}
}
$phoneChangePath = Get-PathBlock '/genealogy/app/auth/phone'
Assert-Contains $phoneChangePath "`$ref: '#/components/requestBodies/PhoneChange'" 'Phone change must consume PhoneChangeBody'
$phoneChangeSchema = [regex]::Match($document, "(?ms)^ PhoneChangeBody:`r?`n(.*?)(?=^ [A-Za-z][A-Za-z0-9_-]*:|\z)").Value
Assert-Contains $phoneChangeSchema 'additionalProperties: false' 'PhoneChangeBody must reject legacy auth payload fields'
Assert-Contains $phoneChangeSchema 'required: [phone, smsCode]' 'PhoneChangeBody required fields drifted'
$apiSource = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'utils/api.js')
foreach ($expected in @(
'/genealogy/app/auth/sms/${encodeURIComponent(assertAuthVerificationOperation(operationCode))}/code',
'/genealogy/app/region/children',
'/genealogy/app/region/path/${encodeURIComponent(normalizeRegionCode(regionCode))}',
'/genealogy/app/region/search',
'/genealogy/app/region/${encodeURIComponent(normalizeRegionCode(regionCode))}'
)) {
Assert-Contains $apiSource $expected "Current API adapter missing: $expected"
}
foreach ($retiredPath in @('/genealogy/app/auth/sms/code', '/genealogy/app/files/upload', '/genealogy/app/files/reference', '/genealogy/region/')) {
if ($apiSource.Contains($retiredPath)) { throw "API adapter retains retired backend path: $retiredPath" }
}
$phonePayload = [regex]::Match($apiSource, "(?ms)const normalizePhoneChangePayload = \(payload\) => \{(.*?)^\}").Value
Assert-Contains $phonePayload 'return { phone: payload.phone.trim(), smsCode: assertSmsCode(payload.smsCode) }' 'Phone change payload must only contain the documented fields'
if ($phonePayload.Contains('authPayload(')) { throw 'Phone change payload must not add clientId or tenantId to PhoneChangeBody' }
$videoPage = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'pages/family/f10-video-list.vue')
foreach ($token in @('pickAndUploadVideo', 'appApi.createVideo', 'videoTitle', 'videoOssId: receipt.value.ossId')) {
Assert-Contains $videoPage $token "F10 video publish contract missing: $token"
}
foreach ($forbidden in @('v-model="form.videoOssId"', 'v-model="form.status"', 'v-model="form.durationSeconds"')) {
if ($videoPage.Contains($forbidden)) { throw "F10 must not expose auto or management field: $forbidden" }
}
Write-Output 'CURRENT-OPENAPI-INVENTORY-CONTRACT PASS'
-85
View File
@@ -1,85 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$allowlistPath = Join-Path $PSScriptRoot 'data-driven-layout-risk-allowlist.json'
$allowlist = Get-Content -LiteralPath $allowlistPath -Raw -Encoding UTF8 | ConvertFrom-Json
$allowed = @{}
$seenAllowed = @{}
foreach ($entry in $allowlist) {
foreach ($required in @('file', 'selector', 'risk', 'reason')) {
if (-not $entry.$required) { throw "DATA-DRIVEN-LAYOUT-CONTRACT allowlist entry missing $required" }
}
$allowed["$($entry.file)::$($entry.selector)::$($entry.risk)"] = $entry.reason
}
$violations = New-Object System.Collections.Generic.List[string]
$files = Get-ChildItem -LiteralPath (Join-Path $root 'pages'), (Join-Path $root 'components') -Recurse -File -Filter '*.vue'
function Add-Risk {
param(
[string]$File,
[string]$Selector,
[string]$Risk,
[string]$Evidence
)
$key = "$File::$Selector::$Risk"
if ($allowed.ContainsKey($key)) {
$seenAllowed[$key] = $true
} else {
$violations.Add("$File :: $Selector :: $Risk :: $Evidence")
}
}
foreach ($file in $files) {
$relative = $file.FullName.Substring($root.Length + 1).Replace('\', '/')
$source = [System.IO.File]::ReadAllText($file.FullName, [System.Text.Encoding]::UTF8)
foreach ($styleMatch in [regex]::Matches($source, '(?s)<style\b[^>]*>(?<css>.*?)</style>')) {
$css = [regex]::Replace($styleMatch.Groups['css'].Value, '(?s)/\*.*?\*/', '')
$css = [regex]::Replace($css, '(?m)^\s*@(use|forward|import)\b[^;]+;\s*', '')
foreach ($rule in [regex]::Matches($css, '(?s)(?<selector>[^{}]+)\{(?<body>[^{}]*)\}')) {
$selector = (($rule.Groups['selector'].Value -replace '(?s)^.*@media[^\{]*', '') -replace '\s+', ' ').Trim()
$body = $rule.Groups['body'].Value
if (-not $selector) { continue }
$height = [regex]::Match($body, '(?im)(?<![-\w])height\s*:\s*(?<value>\d+(?:\.\d+)?(?:rpx|px))\s*;')
if ($height.Success) {
Add-Risk -File $relative -Selector $selector -Risk 'fixed-content-height' -Evidence "height:$($height.Groups['value'].Value)"
}
$overflow = [regex]::Match($body, '(?im)(?<![-\w])overflow(?:-[xy])?\s*:\s*hidden\s*;')
if ($overflow.Success) {
Add-Risk -File $relative -Selector $selector -Risk 'clipping-overflow' -Evidence (($overflow.Value -replace '\s+', ' ').Trim())
}
$singleLine = [regex]::Match($body, '(?im)(white-space\s*:\s*nowrap|text-overflow\s*:\s*ellipsis|-webkit-line-clamp\s*:\s*\d+)\s*;')
if ($singleLine.Success) {
Add-Risk -File $relative -Selector $selector -Risk 'single-line-truncation' -Evidence (($singleLine.Value -replace '\s+', ' ').Trim())
}
$gridRows = [regex]::Match($body, '(?im)grid-template-rows\s*:\s*(?<value>[^;{}]+)\s*;')
if ($gridRows.Success -and $gridRows.Groups['value'].Value -match '(\d+(?:\.\d+)?%|\d+(?:\.\d+)?(?:rpx|px))') {
Add-Risk -File $relative -Selector $selector -Risk 'fixed-grid-track' -Evidence "grid-template-rows:$((($gridRows.Groups['value'].Value -replace '\s+', ' ').Trim()))"
}
$repeat = [regex]::Match($body, '(?im)grid-template-(?:columns|rows)\s*:\s*repeat\(\s*(?<count>\d+)\s*,')
if ($repeat.Success -and [int]$repeat.Groups['count'].Value -ge 12) {
Add-Risk -File $relative -Selector $selector -Risk 'fixed-capacity-canvas' -Evidence (($repeat.Value -replace '\s+', ' ').Trim())
}
}
}
}
$stale = @($allowed.Keys | Where-Object { -not $seenAllowed.ContainsKey($_) } | Sort-Object)
if ($stale.Count -gt 0) {
$stale | ForEach-Object { Write-Output "STALE ALLOWLIST :: $_" }
throw "DATA-DRIVEN-LAYOUT-CONTRACT found $($stale.Count) stale allowlist entries."
}
if ($violations.Count -gt 0) {
$violations | Sort-Object | ForEach-Object { Write-Output $_ }
throw "DATA-DRIVEN-LAYOUT-CONTRACT found $($violations.Count) non-allowlisted layout capacity risks."
}
Write-Output 'DATA-DRIVEN-LAYOUT-CONTRACT PASS'
@@ -1,530 +0,0 @@
[
{
"file": "components/AppLoading.vue",
"selector": ".app-loading--page .app-loading__knot",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "components/AppLoading.vue",
"selector": ".app-loading--page .app-loading__seal",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "components/AppLoading.vue",
"selector": ".app-loading--section .app-loading__knot",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "components/AppLoading.vue",
"selector": ".app-loading--section .app-loading__seal",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "components/AppTabbar.vue",
"selector": ".tab-icon",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "components/GenealogyCard.vue",
"selector": ".card-chevron",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "components/GenealogyCard.vue",
"selector": ".card-meta-icon",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "components/GenealogyCard.vue",
"selector": ".surname-seal",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "components/GenealogyCard.vue",
"selector": ".surname-seal-copy",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "components/GenealogyPageBackground.vue",
"selector": ".genealogy-page-background",
"risk": "clipping-overflow",
"reason": "裁切仅用于已审核背景、媒体缩略图或装饰资产的可视边界,不裁切正文数据"
},
{
"file": "components/ModulePageBackground.vue",
"selector": ".module-page-background",
"risk": "clipping-overflow",
"reason": "裁切仅用于已审核背景、媒体缩略图或装饰资产的可视边界,不裁切正文数据"
},
{
"file": "components/PageHeader.vue",
"selector": ".header-back",
"risk": "fixed-content-height",
"reason": "固定尺寸属于已审核的导航、操作触点或结构化节点视觉边界,不作为正文容量上限"
},
{
"file": "components/PageHeader.vue",
"selector": ".header-back__icon",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "components/PageHeader.vue",
"selector": ".header-hall",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "components/PageHeader.vue",
"selector": ".header-icon-button",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "components/PageHeader.vue",
"selector": ".header-logo",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "components/PageHeader.vue",
"selector": ".header-notice-icon",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "components/PageHeader.vue",
"selector": ".header-title",
"risk": "clipping-overflow",
"reason": "裁切仅用于已审核背景、媒体缩略图或装饰资产的可视边界,不裁切正文数据"
},
{
"file": "components/PageHeader.vue",
"selector": ".header-title",
"risk": "single-line-truncation",
"reason": "标题来自固定路由文案,导航栏保留单行边界并由完整页面语义补足"
},
{
"file": "components/PageHeader.vue",
"selector": ".notice-dot",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "components/PageHeader.vue",
"selector": ".page-header--root",
"risk": "clipping-overflow",
"reason": "裁切仅用于已审核背景、媒体缩略图或装饰资产的可视边界,不裁切正文数据"
},
{
"file": "components/PageHeader.vue",
"selector": ".page-header--root .header-logo",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".agreement-icon",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".divider-knot",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".divider-line",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".input-icon",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".other-login-divider",
"risk": "single-line-truncation",
"reason": "固定短分隔文案与两侧装饰线共同构成登录页分隔资产"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".password-toggle__icon",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".title-divider",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".wechat-icon",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/auth/a04-register.vue",
"selector": ".agreement-icon",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/auth/a04-register.vue",
"selector": ".back-button__icon",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/auth/a04-register.vue",
"selector": ".register-divider",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".back-button__icon",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".reset-divider",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".success-mark",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/auth/a06-auth-status.vue",
"selector": ".back-button__icon",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/auth/a06-auth-status.vue",
"selector": ".status-divider",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/auth/a06-auth-status.vue",
"selector": ".status-icon",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/family/f01-family-feed.vue",
"selector": ".feed-shortcut",
"risk": "fixed-content-height",
"reason": "固定尺寸属于已审核的导航、操作触点或结构化节点视觉边界,不作为正文容量上限"
},
{
"file": "pages/family/f08-album-detail.vue",
"selector": ".album-photo-tile",
"risk": "clipping-overflow",
"reason": "裁切仅用于已审核背景、媒体缩略图或装饰资产的可视边界,不裁切正文数据"
},
{
"file": "pages/family/f09-media-upload.vue",
"selector": ".media-album-card__cover",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/family/f09-media-upload.vue",
"selector": ".media-photo-editor__thumb",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/family/f09-media-upload.vue",
"selector": ".media-photo-tile, .media-add-tile",
"risk": "clipping-overflow",
"reason": "裁切仅用于已审核背景、媒体缩略图或装饰资产的可视边界,不裁切正文数据"
},
{
"file": "pages/genealogy/g01-my-genealogies.vue",
"selector": ".add-dialog__close",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/genealogy/g01-my-genealogies.vue",
"selector": ".add-dialog__close-icon",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/genealogy/g01-my-genealogies.vue",
"selector": ".application-record__chevron",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/genealogy/g01-my-genealogies.vue",
"selector": ".create-action .create-icon",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/genealogy/g01-my-genealogies.vue",
"selector": ".create-cloud",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/genealogy/g01-my-genealogies.vue",
"selector": ".create-icon",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/genealogy/g01-my-genealogies.vue",
"selector": ".current-info-divider",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/genealogy/g01-my-genealogies.vue",
"selector": ".current-meta-icon",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/genealogy/g01-my-genealogies.vue",
"selector": ".current-seal",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/genealogy/g01-my-genealogies.vue",
"selector": ".empty-divider",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/genealogy/g01-my-genealogies.vue",
"selector": ".empty-seal",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/genealogy/g01-my-genealogies.vue",
"selector": ".error-panel__divider",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/genealogy/g01-my-genealogies.vue",
"selector": ".error-panel__seal",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/genealogy/g01-my-genealogies.vue",
"selector": ".genealogy-switcher__close",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/genealogy/g01-my-genealogies.vue",
"selector": ".genealogy-switcher__close-icon",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/genealogy/g01-my-genealogies.vue",
"selector": ".section-divider",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/genealogy/g01-my-genealogies.vue",
"selector": ".section-heading::before",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/genealogy/g01-my-genealogies.vue",
"selector": ".shortcut-icon",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/genealogy/g05-genealogy-overview.vue",
"selector": ".overview-state__seal",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/genealogy/g06-search-genealogies.vue",
"selector": ".search-divider",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/genealogy/g06-search-genealogies.vue",
"selector": ".search-status__cloud",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/genealogy/g06-search-genealogies.vue",
"selector": ".search-status__hall",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/profile/m01-profile-home.vue",
"selector": ".profile-hero__cloud",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/profile/m01-profile-home.vue",
"selector": ".profile-hero__hall",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/profile/m01-profile-home.vue",
"selector": ".profile-identity__seal",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/profile/m01-profile-home.vue",
"selector": ".profile-menu__chevron",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/profile/m01-profile-home.vue",
"selector": ".profile-menu__icon",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/profile/m01-profile-home.vue",
"selector": ".profile-section-heading image",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".generation-band",
"risk": "fixed-content-height",
"reason": "固定尺寸属于已审核的导航、操作触点或结构化节点视觉边界,不作为正文容量上限"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".member-node",
"risk": "fixed-content-height",
"reason": "固定尺寸属于已审核的导航、操作触点或结构化节点视觉边界,不作为正文容量上限"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".member-node--selected",
"risk": "fixed-content-height",
"reason": "固定尺寸属于已审核的导航、操作触点或结构化节点视觉边界,不作为正文容量上限"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".member-node__avatar",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束人物头像图标,不承载成员姓名或关系正文"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".tree-page",
"risk": "clipping-overflow",
"reason": "该页面壳拥有明确的内部滚动区域,裁切只约束结构化视口而非列表内容容量"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".tree-scroll",
"risk": "single-line-truncation",
"reason": "世系画布使用独立横向滚动所有权,nowrap 只维持结构化画布宽度"
},
{
"file": "pages/tree/t03-member-profile.vue",
"selector": ".member-heading__seal",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束已审核图标或装饰资产的画布,不承载可变正文数据"
},
{
"file": "components/AppButton.vue",
"selector": ".app-button--compact .app-button__label",
"risk": "single-line-truncation",
"reason": "紧凑按钮只承载组件调用方提供的短操作标签,单行约束保护固定操作触点"
},
{
"file": "components/AuthPageShell.vue",
"selector": ".auth-shell",
"risk": "clipping-overflow",
"reason": "登录壳仅裁切横向装饰溢出,纵向正文仍由页面自然增长"
},
{
"file": "components/AuthPageShell.vue",
"selector": ".auth-shell__header",
"risk": "clipping-overflow",
"reason": "顶部品牌区只裁切内部装饰图层,不裁切可变正文"
},
{
"file": "pages/genealogy/g01-my-genealogies.vue",
"selector": ".genealogy-index",
"risk": "clipping-overflow",
"reason": "页面壳拥有独立列表滚动区,裁切只约束结构化视口"
},
{
"file": "pages/genealogy/g09-my-applications.vue",
"selector": ".application-card__action",
"risk": "single-line-truncation",
"reason": "申请卡操作仅使用固定短标签,单行约束保护操作列宽"
},
{
"file": "components/AppDialog.vue",
"selector": ".app-dialog",
"risk": "clipping-overflow",
"reason": "弹窗外框只约束共享九宫格边界,正文容量由内部滚动区域完整承载"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".get-code",
"risk": "single-line-truncation",
"reason": "验证码按钮只显示固定短文案或秒数,单行约束保护输入行触点"
},
{
"file": "pages/auth/a04-register.vue",
"selector": ".get-code",
"risk": "single-line-truncation",
"reason": "验证码按钮只显示固定短文案或秒数,单行约束保护输入行触点"
},
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".get-code",
"risk": "single-line-truncation",
"reason": "验证码按钮只显示固定短文案或秒数,单行约束保护输入行触点"
}
]
-188
View File
@@ -1,188 +0,0 @@
const assert = require('assert')
const origin = process.argv[2] || 'http://localhost:5173'
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 pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const projectPages = pages.filter((page) => page.type === 'page' && page.url.startsWith(origin))
assert.strictEqual(projectPages.length, 1, `Expected one project page, found ${projectPages.length}`)
const socket = new WebSocket(projectPages[0].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()
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
message.error ? request.reject(new Error(message.error.message)) : 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 }
}
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 < 50; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(80)
}
throw new Error(message)
}
let auditId = 0
const open = async (send, route, query, selector) => {
auditId += 1
const url = `${origin}/?dataLayoutAudit=${auditId}#${route}${query}`
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `Navigation failed: ${route}${query}`)
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `State did not render: ${selector}`)
}
const setSize = (send, size) => send('Emulation.setDeviceMetricsOverride', {
...size,
deviceScaleFactor: 1,
mobile: true,
screenWidth: size.width,
screenHeight: size.height
})
const assertViewport = async (send, label, expectedCount, selector, allowVisualClipping = false) => {
const metrics = await valueOf(send, `(() => {
const items = Array.from(document.querySelectorAll(${JSON.stringify(selector)}))
const last = items.at(-1)
last?.scrollIntoView({ block: 'end' })
const lastRect = last?.getBoundingClientRect()
return {
count: items.length,
documentWidth: document.documentElement.scrollWidth,
viewportWidth: innerWidth,
viewportHeight: innerHeight,
lastBottom: lastRect?.bottom || 0,
clipped: items.some((item) => item.scrollWidth > item.clientWidth + 1 || item.scrollHeight > item.clientHeight + 1)
}
})()`)
assert.strictEqual(metrics.count, expectedCount, `${label}: data count changed`)
assert(metrics.documentWidth <= metrics.viewportWidth + 1, `${label}: horizontal overflow ${JSON.stringify(metrics)}`)
assert(metrics.lastBottom <= metrics.viewportHeight + 1, `${label}: last item is not reachable ${JSON.stringify(metrics)}`)
if (!allowVisualClipping) assert.strictEqual(metrics.clipped, false, `${label}: a data item clips its content`)
}
const stressDirectory = async (send, size) => {
await setSize(send, size)
await open(send, '/pages/tree/t07-member-directory', '?genealogyId=1001', '.directory-state--list')
const prepared = await valueOf(send, `(() => {
const first = document.querySelector('.directory-card')
const parent = first?.parentElement
if (!first || !parent) return false
const originals = Array.from(parent.querySelectorAll('.directory-card'))
for (let index = originals.length; index < 50; index += 1) {
const clone = originals[index % originals.length].cloneNode(true)
clone.querySelector('.directory-card__name').textContent = '汤氏超长成员姓名用于三倍文案压力验证' + (index + 1)
clone.querySelector('.directory-card__meta').textContent = '第十世 · 超长字辈名称 · 超长支系与地区说明用于验证数据自然换行'
parent.appendChild(clone)
}
parent.querySelectorAll('.directory-card__name, .directory-card__meta').forEach((node) => {
node.style.fontSize = (parseFloat(getComputedStyle(node).fontSize) * 1.3) + 'px'
})
return true
})()`)
assert.strictEqual(prepared, true, 'T07 directory stress data could not be prepared')
await assertViewport(send, `T07 ${size.width}x${size.height}`, 50, '.directory-card')
}
const stressApplications = async (send, size) => {
await setSize(send, size)
await open(send, '/pages/genealogy/g09-my-applications', '', '.application-state--list')
assert(await valueOf(send, `(() => {
const first = document.querySelector('.application-card')
const parent = first?.parentElement
if (!first || !parent) return false
const originals = Array.from(parent.querySelectorAll('.application-card'))
for (let index = originals.length; index < 50; index += 1) {
const clone = originals[index % originals.length].cloneNode(true)
clone.querySelector('.application-card__name').textContent = '超长家谱名称与地区支系压力验证' + (index + 1)
clone.querySelector('.application-card__relation').textContent = '祖居河南南阳并迁居多地的三倍关系说明,验证卡片由数据自然撑高'
parent.appendChild(clone)
}
parent.querySelectorAll('.application-card__name, .application-card__relation, .application-card__hint').forEach((node) => {
node.style.fontSize = (parseFloat(getComputedStyle(node).fontSize) * 1.3) + 'px'
})
return true
})()`), 'G09 application stress data could not be prepared')
await assertViewport(send, `G09 ${size.width}x${size.height}`, 50, '.application-card')
}
const stressMedia = async (send, size) => {
await setSize(send, size)
await open(send, '/pages/family/f09-media-upload', '?genealogyId=1001&albumId=201', '.media-upload-state--initial')
await valueOf(send, "document.querySelector('.media-primary-action')?.click()")
await waitFor(send, "document.querySelectorAll('.media-photo-tile').length === 4", 'F09 selected media did not render')
assert(await valueOf(send, `(() => {
const first = document.querySelector('.media-photo-tile')
const parent = first?.parentElement
if (!first || !parent) return false
const originals = Array.from(parent.querySelectorAll('.media-photo-tile'))
for (let index = originals.length; index < 30; index += 1) parent.appendChild(originals[index % originals.length].cloneNode(true))
return true
})()`), 'F09 media stress data could not be prepared')
await assertViewport(send, `F09 ${size.width}x${size.height}`, 30, '.media-photo-tile', true)
}
const stressAutoHeightForm = async (send) => {
await setSize(send, { width: 320, height: 568 })
await open(send, '/pages/genealogy/g08-join-application', '?source=search&genealogyId=2001', '.join-state--form')
const metrics = await valueOf(send, `(() => {
const textarea = document.querySelector('.join-field textarea')
const before = textarea.getBoundingClientRect().height
textarea.value = '这是用于验证表单由数据驱动自然增高的长说明。'.repeat(8)
textarea.dispatchEvent(new Event('input', { bubbles: true }))
return { before, after: textarea.getBoundingClientRect().height, documentWidth: document.documentElement.scrollWidth }
})()`)
await sleep(100)
const after = await valueOf(send, "document.querySelector('.join-field textarea').getBoundingClientRect().height")
assert(after > metrics.before * 1.5, `G08 auto-height textarea did not grow: ${JSON.stringify({ ...metrics, after })}`)
assert(metrics.documentWidth <= 321, 'G08 long form value caused horizontal overflow')
}
const run = async () => {
const { socket, send } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
for (const size of sizes) await stressDirectory(send, size)
for (const size of [sizes[0], sizes[3]]) {
await stressApplications(send, size)
await stressMedia(send, size)
}
await stressAutoHeightForm(send)
process.stdout.write('DATA-DRIVEN-LAYOUT-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)
})
-34
View File
@@ -1,34 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$avatar = Get-Content -LiteralPath (Join-Path $root 'utils/avatar.js') -Raw -Encoding UTF8
$component = Get-Content -LiteralPath (Join-Path $root 'components/AppAvatar.vue') -Raw -Encoding UTF8
foreach ($required in @(
'DEFAULT_MALE_AVATAR',
'mjpc0703_A_cartoon_illustration_of_a_boy_wearing_a_red_Chinese__0@2x.png',
'DEFAULT_FEMALE_AVATAR',
'loyel003_Ancient_Beauty_Wearing_Tang_Dynasty_ClothingLooking_to_07cb98a9-ec37-4620-a032-ebe511fa93b5@2x.png',
'String(sex ?? "").trim() === "1"'
)) {
if (-not $avatar.Contains($required)) {
throw "Default avatar contract missing: $required"
}
}
if (-not $component.Contains('getDefaultAvatar(props.sex)')) {
throw 'AppAvatar must own the fallback-avatar selection'
}
foreach ($page in @(
'pages/tree/t01-tree-overview.vue',
'pages/tree/t03-member-profile.vue',
'pages/profile/m01-profile-home.vue'
)) {
$source = Get-Content -LiteralPath (Join-Path $root $page) -Raw -Encoding UTF8
if (-not $source.Contains('import AppAvatar from "@/components/AppAvatar.vue"')) {
throw "Shared avatar component is not wired in: $page"
}
}
Write-Output 'DEFAULT-AVATAR-CONTRACT PASS'
@@ -1,52 +0,0 @@
const fs = require("node:fs");
const path = require("node:path");
const assert = require("node:assert/strict");
const root = path.resolve(__dirname, "..");
const modulePath = path.join(root, "utils", "discard-confirmation.js");
const loadModule = async () => {
const source = fs.readFileSync(modulePath, "utf8");
return import(`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`);
};
const run = async () => {
const { createDiscardConfirmation } = await loadModule();
const visibility = [];
const controller = createDiscardConfirmation((visible) => {
visibility.push(visible);
});
const first = controller.request();
const repeated = controller.request();
assert.strictEqual(repeated, first, "重复请求必须复用同一个等待 Promise");
assert.deepEqual(visibility, [true], "重复请求不得重复打开确认框");
controller.cancel();
assert.equal(await first, false, "取消必须让等待者得到 false");
assert.deepEqual(visibility, [true, false], "取消必须关闭确认框");
const confirmed = controller.request();
controller.confirm();
assert.equal(await confirmed, true, "确认必须让等待者得到 true");
const disposed = controller.request();
controller.dispose();
assert.equal(await disposed, false, "页面卸载必须释放等待者并返回 false");
const afterDispose = controller.request();
assert.notStrictEqual(afterDispose, disposed, "释放后必须能建立新的确认周期");
controller.cancel();
assert.equal(await afterDispose, false);
assert.throws(
() => createDiscardConfirmation(null),
/setVisible 必须是函数/,
"必须拒绝无法同步可见状态的消费者",
);
console.log("DISCARD-CONFIRMATION-RUNTIME-SMOKE PASS");
};
run().catch((error) => {
console.error(error);
process.exitCode = 1;
});
-137
View File
@@ -1,137 +0,0 @@
[
{
"file": "components/PageHeader.vue",
"selector": ".page-header",
"reason": "用户指定的固定顶部导航栏"
},
{
"file": "components/AppTabbar.vue",
"selector": ".app-tabbar",
"reason": "用户指定的固定底部导航栏"
},
{
"file": "components/PageHeader.vue",
"selector": ".header-texture",
"reason": "固定顶部栏内部纯装饰纹理层"
},
{
"file": "components/PageHeader.vue",
"selector": ".header-hall",
"reason": "固定顶部栏内部纯装饰祠堂层"
},
{
"file": "components/GenealogyPageBackground.vue",
"selector": ".genealogy-page-background",
"reason": "不参与排版且覆盖视口的独立家谱背景层"
},
{
"file": "components/GenealogyPageBackground.vue",
"selector": ".genealogy-page-background__art",
"reason": "独立家谱背景层内部的底部背景画面"
},
{
"file": "components/ModulePageBackground.vue",
"selector": ".module-page-background",
"reason": "不参与排版且覆盖视口的独立模块背景层"
},
{
"file": "components/ModulePageBackground.vue",
"selector": ".module-page-background__image",
"reason": "独立模块背景层内部的底部背景画面"
},
{
"file": "pages/family/f08-album-detail.vue",
"selector": ".album-photo-tile",
"reason": "仅作为照片裁切层与底部说明叠层的精确局部边界"
},
{
"file": "pages/family/f08-album-detail.vue",
"selector": ".album-hero-photo, .album-photo-tile__image",
"reason": "在固定比例照片格内执行裁切的媒体画面层"
},
{
"file": "pages/family/f08-album-detail.vue",
"selector": ".album-photo-tile__caption",
"reason": "依附照片格底部的局部说明叠层"
},
{
"file": "pages/family/f08-album-detail.vue",
"selector": ".album-preview",
"reason": "用户触发的全屏照片预览层"
},
{
"file": "pages/family/f09-media-upload.vue",
"selector": ".media-photo-tile",
"reason": "仅作为照片缩略图角标与删除操作的精确局部边界"
},
{
"file": "pages/family/f09-media-upload.vue",
"selector": ".media-photo-order, .media-photo-current",
"reason": "依附照片缩略图的顺序与当前预览角标"
},
{
"file": "pages/family/f09-media-upload.vue",
"selector": ".media-photo-remove",
"reason": "依附照片缩略图右上角的删除操作"
},
{
"file": "pages/auth/a06-auth-status.vue",
"selector": ".recovery-layer",
"reason": "用户触发的账号恢复弹窗遮罩"
},
{
"file": "pages/genealogy/g01-my-genealogies.vue",
"selector": ".add-dialog-layer",
"reason": "用户触发的固定底部添加家谱弹层"
},
{
"file": "pages/genealogy/g01-my-genealogies.vue",
"selector": ".genealogy-switcher-layer",
"reason": "用户触发的固定家谱切换弹窗遮罩"
},
{
"file": "pages/genealogy/g10-application-review.vue",
"selector": ".review-feedback",
"reason": "审核操作后跨内容显示的固定轻提示"
},
{
"file": "pages/genealogy/g03-create-genealogy.vue",
"selector": ".duplicate-reminder-layer, .flow-success-layer",
"reason": "用户触发的重复提醒与成功弹窗遮罩"
},
{
"file": "pages/genealogy/g11-genealogy-settings.vue",
"selector": ".settings-feedback",
"reason": "保存设置后跨内容显示的固定轻提示"
},
{
"file": "pages/genealogy/g12-generation-poems.vue",
"selector": ".poem-feedback",
"reason": "保存字辈后跨内容显示的固定轻提示"
},
{
"file": "components/AppDialog.vue",
"selector": ".app-dialog-layer",
"reason": "全屏弹窗遮罩层"
},
{
"file": "components/TacVerification.vue",
"selector": ".tac-layer",
"reason": "三个认证流程共用的真实滑动验证全屏遮罩"
},
{
"file": "components/AppToast.vue",
"selector": ".app-toast",
"reason": "跨页面轻提示层"
},
{
"file": "components/AuthPageShell.vue",
"selector": ".auth-shell__header",
"reason": "为顶部品牌装饰建立局部定位上下文"
},
{
"file": "components/AuthPageShell.vue",
"selector": ".auth-shell__seal",
"reason": "仅定位顶部品牌区内的固定比例印章装饰"
}
]
-46
View File
@@ -1,46 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$allowlistPath = Join-Path $PSScriptRoot 'document-flow-position-allowlist.json'
$allowlist = Get-Content -LiteralPath $allowlistPath -Raw -Encoding UTF8 | ConvertFrom-Json
$allowed = @{}
$seenAllowed = @{}
foreach ($entry in $allowlist) {
$allowed["$($entry.file)::$($entry.selector)"] = $entry.reason
}
$violations = New-Object System.Collections.Generic.List[string]
$files = Get-ChildItem -LiteralPath (Join-Path $root 'pages'), (Join-Path $root 'components') -Recurse -File -Filter '*.vue'
foreach ($file in $files) {
$relative = $file.FullName.Substring($root.Length + 1).Replace('\', '/')
$source = [System.IO.File]::ReadAllText($file.FullName, [System.Text.Encoding]::UTF8)
foreach ($styleMatch in [regex]::Matches($source, '(?s)<style\b[^>]*>(?<css>.*?)</style>')) {
$css = [regex]::Replace($styleMatch.Groups['css'].Value, '(?m)^\s*@(use|forward|import)\b[^;]+;\s*', '')
foreach ($rule in [regex]::Matches($css, '(?s)(?<selector>[^{}]+)\{(?<body>[^{}]*)\}')) {
$body = [regex]::Replace($rule.Groups['body'].Value, '(?s)/\*.*?\*/', '')
$positionMatches = [regex]::Matches($body, '(?im)(?<![-\w])position\s*:\s*(?<value>[^;{}]+?)\s*;')
if ($positionMatches.Count -eq 0) { continue }
$selector = (($rule.Groups['selector'].Value -replace '(?s)^.*@media[^\{]*', '') -replace '\s+', ' ').Trim()
$key = "$relative::$selector"
if ($allowed.ContainsKey($key)) {
$seenAllowed[$key] = $true
} else {
foreach ($positionMatch in $positionMatches) {
$position = ($positionMatch.Groups['value'].Value -replace '\s+', ' ').Trim()
$violations.Add("$relative :: $selector :: position:$position")
}
}
}
}
}
$staleAllowlistEntries = @($allowed.Keys | Where-Object { -not $seenAllowed.ContainsKey($_) } | Sort-Object)
if ($staleAllowlistEntries.Count -gt 0) {
$staleAllowlistEntries | ForEach-Object { Write-Output "STALE ALLOWLIST :: $_" }
throw "DOCUMENT-FLOW-POSITION-CONTRACT found $($staleAllowlistEntries.Count) stale allowlist entries."
}
if ($violations.Count -gt 0) {
$violations | Sort-Object | ForEach-Object { Write-Output $_ }
throw "DOCUMENT-FLOW-POSITION-CONTRACT found $($violations.Count) non-allowlisted position declarations."
}
Write-Output 'DOCUMENT-FLOW-POSITION-CONTRACT PASS'
-40
View File
@@ -1,40 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
function Read-Utf8([string]$relativePath) {
Get-Content -LiteralPath (Join-Path $root $relativePath) -Raw -Encoding UTF8
}
function Assert-Contains([string]$source, [string]$expected, [string]$page) {
if (-not $source.Contains($expected)) { throw "$page missing F business-flow anchor: $expected" }
}
$contracts = [ordered]@{
'pages/family/f03-feed-detail.vue' = @(
'feedState', 'appApi.getFeedDetail', 'appApi.getFeedComments', 'appApi.createFeedComment',
'feedTypeLabel(feed.type)', 'returnTo("F01", { genealogyId: genealogyId.value })'
)
'pages/family/f04-article-list.vue' = @(
'listState', 'appApi.getArticles', 'openArticle', 'createArticle',
'openPage("F05"', 'openPage("F06"'
)
'pages/family/f05-article-detail.vue' = @(
'articleState', 'appApi.getArticleDetail', 'article-state--${articleState}',
'returnTo("F04", { genealogyId: genealogyId.value })'
)
'pages/family/f07-album-list.vue' = @(
'listState', 'appApi.getAlbums', 'appApi.createAlbum', 'openAlbum',
'pickAndUploadImage', 'openPage("F08"'
)
}
foreach ($entry in $contracts.GetEnumerator()) {
$source = Read-Utf8 $entry.Key
foreach ($anchor in $entry.Value) { Assert-Contains $source $anchor $entry.Key }
foreach ($forbidden in @('listFamilyArticleFixtures', 'findFamilyArticleFixture', 'listFamilyAlbumFixtures', 'localAlbumPreview', 'feed-detail-contract-note', 'data/mock')) {
if ($source.Contains($forbidden)) { throw "$($entry.Key) retains retired F business-flow implementation: $forbidden" }
}
}
Write-Output 'F-BUSINESS-FLOW-CONTRACT PASS'
-111
View File
@@ -1,111 +0,0 @@
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const origin = process.argv[2] || "http://localhost:5173";
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(`${origin}/`));
if (!page) throw new Error(`Chrome debugging has no ${origin} project 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();
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
const request = pending.get(message.id);
if (!request) return;
pending.delete(message.id);
message.error ? request.reject(new Error(message.error.message)) : 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 };
};
const valueOf = async (send, expression) => (await send("Runtime.evaluate", { expression, returnByValue: true })).result?.value;
const waitFor = async (send, expression, message) => {
for (let index = 0; index < 60; index += 1) {
if (await valueOf(send, expression)) return;
await sleep(100);
}
throw new Error(message);
};
let auditId = 0;
const open = async (send, route, selector) => {
auditId += 1;
const url = `${origin}/?fBusiness=${auditId}#${route}`;
await send("Page.navigate", { url });
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `navigation failed: ${route}`);
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `missing ${selector}: ${route}`);
};
const click = (send, selector) => valueOf(send, `document.querySelector(${JSON.stringify(selector)}).click()`);
const setInput = (send, selector, value) => valueOf(send, `(() => { const input = document.querySelector(${JSON.stringify(selector)}); input.value = ${JSON.stringify(value)}; input.dispatchEvent(new Event('input', { bubbles: true })); return input.value; })()`);
const run = async () => {
const { socket, send } = await connect();
try {
await send("Page.enable");
await send("Runtime.enable");
// 步骤一:F02 空内容必须由真实发布按钮触发项目内 Toast,不能依赖旧截图状态助手。
await open(send, "/pages/family/f02-publish-feed?genealogyId=1001", ".publish-state--form");
await click(send, ".publish-form .app-button");
await waitFor(send, "Boolean(document.querySelector('.app-toast'))", "F02 empty submit did not show the project Toast");
if (!(await valueOf(send, "document.querySelector('.app-toast')?.textContent.includes('请先写下动态内容')"))) {
throw new Error("F02 empty submit showed unexpected feedback");
}
await setInput(send, ".publish-form textarea", "这是一条本地动态预览。");
await click(send, ".publish-form .app-button");
await waitFor(send, "Boolean(document.querySelector('.publish-state--preview'))", "F02 did not enter honest local preview");
if (!(await valueOf(send, "document.querySelector('.publish-result')?.textContent.includes('尚未提交服务器')"))) {
throw new Error("F02 preview did not disclose that content was not published");
}
for (const size of [{ width: 320, height: 568 }, { width: 412, height: 915 }]) {
await send("Emulation.setDeviceMetricsOverride", { ...size, deviceScaleFactor: 1, mobile: true, screenWidth: size.width, screenHeight: size.height });
await open(send, "/pages/family/f04-article-list?genealogyId=1001", ".article-card");
if ((await valueOf(send, "document.querySelectorAll('.article-card').length")) !== 3) throw new Error(`F04 did not render the scoped article owner at ${size.width}`);
if ((await valueOf(send, "document.documentElement.scrollWidth")) > size.width + 1) throw new Error(`F04 horizontal overflow at ${size.width}`);
await valueOf(send, "document.querySelector('.article-card:last-of-type').scrollIntoView()");
await open(send, "/pages/family/f07-album-list?genealogyId=1001", ".album-card");
if ((await valueOf(send, "document.querySelectorAll('.album-card').length")) !== 3) throw new Error(`F07 did not render the scoped album owner at ${size.width}`);
if ((await valueOf(send, "document.documentElement.scrollWidth")) > size.width + 1) throw new Error(`F07 horizontal overflow at ${size.width}`);
}
await open(send, "/pages/family/f04-article-list?genealogyId=1001", ".article-card");
await click(send, ".article-card");
await waitFor(send, "location.hash.includes('/pages/family/f05-article-detail?genealogyId=1001&articleId=101')", "F04 card did not open composite-identity F05");
await open(send, "/pages/family/f03-feed-detail?genealogyId=1001&feedId=1", ".feed-detail-contract-note");
if (!(await valueOf(send, "document.querySelector('.feed-detail-contract-note')?.textContent.includes('不读取宽接口')"))) {
throw new Error("F03 did not disclose its contract-safe unavailable state");
}
await open(send, "/pages/family/f07-album-list?genealogyId=1001", ".album-list > .app-button");
const albumCount = await valueOf(send, "document.querySelectorAll('.album-card').length");
await click(send, ".album-list > .app-button");
await waitFor(send, "Boolean(document.querySelector('.app-dialog-layer'))", "F07 create dialog did not open");
await setInput(send, ".album-dialog-field input", "清明祭祖影像");
await click(send, ".app-dialog__actions .app-button:last-child");
await waitFor(send, "document.querySelector('.album-local-preview')?.textContent.includes('清明祭祖影像')", "F07 local album preview did not render");
if ((await valueOf(send, "document.querySelectorAll('.album-card').length")) !== albumCount) throw new Error("F07 inserted an unsubmitted album into the official list");
await open(send, "/pages/family/f03-feed-detail?genealogyId=1002&feedId=1", ".feed-state--expired");
await open(send, "/pages/family/f05-article-detail?genealogyId=1002&articleId=101", ".article-state--expired");
await open(send, "/pages/family/f08-album-detail?genealogyId=1002&albumId=201", ".album-state--expired");
await open(send, "/pages/family/f09-media-upload?genealogyId=1002&albumId=201", ".media-upload-state--invalid");
process.stdout.write("F-BUSINESS-FLOW-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); });
@@ -1,16 +0,0 @@
$ErrorActionPreference = 'Stop'
function Read-Utf8([string]$Path) { [System.IO.File]::ReadAllText((Join-Path (Join-Path $PSScriptRoot '..') $Path), [System.Text.Encoding]::UTF8) }
function Assert-Contains([string]$Content, [string]$Expected, [string]$Message) { if (-not $Content.Contains($Expected)) { throw $Message } }
function Assert-Match([string]$Content, [string]$Pattern, [string]$Message) { if ($Content -notmatch $Pattern) { throw $Message } }
$f01 = Read-Utf8 'pages/family/f01-family-feed.vue'
$f02 = Read-Utf8 'pages/family/f02-publish-feed.vue'
foreach ($expected in @('clamp\(15px, 24rpx, 18px\)','clamp\(14px, 23rpx, 17px\)','clamp\(14px, 22rpx, 17px\)')) {
Assert-Match $f01 "font-size:\s*$expected" "F01 readability token missing: $expected"
}
foreach ($expected in @('clamp\(15px, 24rpx, 18px\)','clamp\(14px, 23rpx, 17px\)')) {
Assert-Match $f02 "font-size:\s*$expected" "F02 readability token missing: $expected"
}
Write-Output 'F01-F02 readability visual contract passed.'
-26
View File
@@ -1,26 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$page = [System.IO.File]::ReadAllText((Join-Path $root 'pages/family/f01-family-feed.vue'), [System.Text.Encoding]::UTF8)
$profiles = [System.IO.File]::ReadAllText((Join-Path $root 'styles/adaptive-frame-profiles.scss'), [System.Text.Encoding]::UTF8)
foreach ($expected in @(
'box-sizing: border-box',
'width: 514rpx',
'@use "../../styles/adaptive-frame-profiles.scss" as adaptive;',
'@include adaptive-scroll-button(secondary);',
'@include adaptive-family-letter;',
'@include adaptive-scroll-button(primary);'
)) {
if (-not $page.Contains($expected)) { throw "F01 document-flow contract missing: $expected" }
}
foreach ($expected in @(
'@mixin adaptive-family-letter',
'"/static/assets/modules/family/transparent/f01-family-letter-card.png"'
)) {
if (-not $profiles.Contains($expected)) { throw "Adaptive profile contract missing: $expected" }
}
if ($page -match '100%\s+100%\s+no-repeat|border-image-slice\s*:') { throw 'F01 must consume adaptive frame geometry without page-local stretching or slicing' }
if ($page -match '<image\s+(?:[^>]*\s)?class="(?:feed-shortcuts__skin|feed-card__skin)"|feed-state-card\s*>\s*<image|feed-action[^>]*>\s*<image') { throw 'F01 decorative skins must be container backgrounds' }
if ($page -match 'position\s*:') { throw 'F01 must keep ordinary content in document flow' }
Write-Output 'F01-DOCUMENT-FLOW-CONTRACT PASS'
-22
View File
@@ -1,22 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/family/f01-family-feed.vue') -Raw -Encoding utf8
$profiles = Get-Content -LiteralPath (Join-Path $root 'styles/adaptive-frame-profiles.scss') -Raw -Encoding utf8
function Assert-Match([string]$Pattern, [string]$Message) {
if ($page -notmatch $Pattern) { throw $Message }
}
Assert-Match '@include\s+adaptive\.adaptive-family-letter\s*;' 'F01 must consume its dedicated adaptive family-letter profile.'
if ($profiles -notmatch '(?s)@mixin\s+adaptive-family-letter\s*\{.*?modules/family/transparent/f01-family-letter-card\.png') { throw 'The adaptive profile owner must retain F01 dedicated family-letter artwork.' }
if ($page -match 'application-status-card\.png') { throw 'F01 must not reuse the application status card.' }
Assert-Match 'class="feed-card__title"' 'F01 must expose an explicit feed title hierarchy.'
Assert-Match 'class="feed-card__meta"' 'F01 must expose category and time as secondary metadata.'
Assert-Match 'class="feed-card__summary"' 'F01 must expose feed summary copy.'
Assert-Match 'class="feed-card__author"' 'F01 must expose the author as tertiary information.'
Assert-Match '(?s)\.feed-shortcut\s*\{[^}]*min-height:\s*44px;' 'F01 shortcuts must preserve a 44 CSS px touch height.'
Assert-Match '@include\s+adaptive\.adaptive-scroll-button\(secondary\)\s*;' 'F01 shortcuts must share the adaptive secondary navigation profile.'
Assert-Match '(?s)\.feed-shortcuts\s*\{[^}]*grid-template-columns:\s*repeat\(4,\s*minmax\(0,\s*1fr\)\);' 'F01 shortcuts must use one four-column equal-width navigation strip.'
if ($page -match 'position\s*:') { throw 'F01 must keep ordinary content in document flow.' }
Write-Output 'F01-MODULE-BASELINE-CONTRACT PASS'
-10
View File
@@ -1,10 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/family/f01-family-feed.vue') -Raw -Encoding UTF8
foreach ($route in @('people: "R01"','gifts: "R03"','merits: "R11"','videos: "F10"')) {
if (-not $page.Contains($route)) { throw "F01 product entry missing: $route" }
}
if ($page -match '(?s)\.family-page\s*\{[^}]*overflow:\s*hidden') { throw 'F01 must not clip long page content' }
if (-not $page.Contains('return openPage(routes[key], { genealogyId: genealogyId.value }, "F01");')) { throw 'F01 child routes must preserve genealogy context through the navigation gateway' }
if ($page.Contains('/pages/')) { throw 'F01 must not retain route literals outside the unique registry' }
Write-Output 'F01-PRODUCT-ENTRY-CONTRACT PASS'
-18
View File
@@ -1,18 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$page = [System.IO.File]::ReadAllText((Join-Path $root 'pages/family/f02-publish-feed.vue'), [System.Text.Encoding]::UTF8)
foreach ($expected in @(
'@include adaptive-family-content;',
'@include adaptive-family-field;'
)) {
if (-not $page.Contains($expected)) { throw "F02 document-flow contract missing: $expected" }
}
if ($page -notmatch '(?s)<textarea[^>]*auto-height') { throw 'F02 publish textarea must grow from its content' }
$style = [regex]::Match($page, '(?s)<style[^>]*>(.*?)</style>').Groups[1].Value
$panelStyle = [regex]::Match($style, '(?ms)^\s*\.publish-panel\s*\{(?<body>.*?)\}').Groups['body'].Value
if ($panelStyle -match '\bmin-height\s*:') { throw 'F02 publish panel height must be driven by its current content' }
if ($page -match '<image\s+(?:[^>]*\s)?class="publish-panel__skin"|publish-field[^>]*>\s*<image') { throw 'F02 decorative frames must be container backgrounds' }
if ($page -match 'position\s*:') { throw 'F02 must keep panel, form, result, and field in document flow' }
Write-Output 'F02-DOCUMENT-FLOW-CONTRACT PASS'
-30
View File
@@ -1,30 +0,0 @@
$ErrorActionPreference = 'Stop'
function Read-Utf8([string]$Path) {
[System.IO.File]::ReadAllText(
(Join-Path (Join-Path $PSScriptRoot '..') $Path),
[System.Text.Encoding]::UTF8
)
}
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if (-not $Content.Contains($Expected)) { throw $Message }
}
$page = Read-Utf8 'pages/family/f05-article-detail.vue'
foreach ($expected in @(
'"article-state--expired": articleState.value === "expired"',
'expired: { title: "这篇谱文已无法查看"',
'当前家谱中不存在这篇谱文,页面不会回退到其他文章。',
'findFamilyArticleFixture(genealogyId.value, articleId.value)',
':label="stateCopy.action"',
'@click="handleStateAction"',
'const backToArticles = () =>',
'return backToArticles();'
)) {
Assert-Contains $page $expected "F05 real expired-state owner missing: $expected"
}
Write-Output 'F05-EXPIRED-STATE-CONTRACT PASS'
-45
View File
@@ -1,45 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/family/f06-article-editor.vue') -Raw -Encoding UTF8
function Assert-Contains([string]$expected, [string]$message) {
if (-not $page.Contains($expected)) { throw $message }
}
foreach ($expected in @(
'class="article-editor-page"',
'article-editor-state--${editorState}',
'appApi.createArticle(',
'v-model="form.articleTitle"',
'v-model="form.articleSummary"',
'v-model="form.articleContent"',
'v-model="form.authorName"',
'v-model="form.sortOrder"',
'pickAndUploadImage',
'coverOssId.value = receipt.ossId',
'coverOssId: coverOssId.value',
'class="required-mark"',
'editor-control--unavailable',
'createDiscardConfirmation',
'runBackGuard',
'onUnmounted(() =>',
'ModulePageBackground',
'AppDialog',
'AppButton',
'@include adaptive-family-panel;',
'@include adaptive-family-field;'
)) {
Assert-Contains $expected "F06 article editor contract missing: $expected"
}
if ($page -match 'v-model="form\.(categoryId|status)"') {
throw 'F06 must not ask users to enter category or status codes'
}
if ($page -match 'placeholder="[^"]*(分类 ID|状态码|例如:0)') {
throw 'F06 must not expose raw category or status code inputs'
}
if ($page -match 'border-image-slice\s*:') { throw 'F06 must consume border-image geometry from the adaptive profile owner' }
if ($page -match 'position\s*:') { throw 'F06 must keep editor content in document flow' }
Write-Output 'F06-ARTICLE-EDITOR-CONTRACT PASS'
-19
View File
@@ -1,19 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/family/f06-article-editor.vue') -Raw -Encoding UTF8
foreach ($token in @(
'genealogyId.value = String(query?.genealogyId || "");',
'query?.mode !== "create"',
'const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));',
'returnTo("F04", { genealogyId: genealogyId.value })',
'appApi.createArticle(',
'createRequestController',
'isRequestCancelled'
)) {
if (-not $page.Contains($token)) { throw "F06 editor context missing: $token" }
}
foreach ($forbidden in @('findFamilyArticleFixture', 'form.category', 'form.status', 'uni.redirectTo', '/pages/')) {
if ($page.Contains($forbidden)) { throw "F06 must not retain a context, code-input, or navigation bypass: $forbidden" }
}
Write-Output 'F06-EDITOR-CONTEXT-CONTRACT PASS'
-30
View File
@@ -1,30 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Join-Path $PSScriptRoot '..'
$files = @(
'static/assets/modules/family/f08/f08-reunion-hero.png',
'static/assets/modules/family/f08/f08-family-portrait.png',
'static/assets/modules/family/f08/f08-reunion-table.png',
'static/assets/modules/family/f08/f08-ancestral-home.png',
'static/assets/modules/family/f08/f08-ancestral-portrait.png'
)
Add-Type -AssemblyName System.Drawing
foreach ($file in $files) {
$path = Join-Path $root $file
if (-not (Test-Path -LiteralPath $path)) {
throw "Missing F08 album asset: $file"
}
$image = [System.Drawing.Image]::FromFile($path)
try {
if ($image.Width -lt 960 -or $image.Height -lt 720) {
throw "F08 album asset too small: $file $($image.Width)x$($image.Height)"
}
} finally {
$image.Dispose()
}
}
Write-Output 'F08-ALBUM-ASSETS-CONTRACT PASS'
-62
View File
@@ -1,62 +0,0 @@
$ErrorActionPreference = 'Stop'
function Read-Utf8([string]$Path) {
[System.IO.File]::ReadAllText(
(Join-Path (Join-Path $PSScriptRoot '..') $Path),
[System.Text.Encoding]::UTF8
)
}
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if (-not $Content.Contains($Expected)) { throw $Message }
}
$page = Read-Utf8 'pages/family/f08-album-detail.vue'
foreach ($expected in @(
'class="album-detail-page"',
'album-state--${albumState}',
'class="album-hero-photo"',
'class="album-photo-grid"',
'class="album-preview"',
'class="album-upload-action"',
'const albumState = ref("normal");',
'const photos = ref([]);',
'findFamilyAlbumFixture(genealogyId.value, albumId.value)',
'const openPreview = (index) =>',
'const closePreview = () =>',
'const toUpload = () =>',
'"F09"',
'genealogyId: genealogyId.value, albumId: albumId.value',
'query.state === "empty"',
'query.state === "preview"',
'query.state === "expired"',
'class="album-empty-state"',
'class="album-expired-state"',
'class="album-preview__position"',
':alt="photo.alt"',
'transientOpen: previewVisible.value',
'onBackPress((event) => handleBackPress(event, requestBack));',
'ModulePageBackground',
'PageHeader',
'AppButton'
)) {
Assert-Contains $page $expected "F08 contract missing: $expected"
}
foreach ($forbidden in @('<ModulePage page-id="f08"', 'import ModulePage from', 'uni.showToast', 'uni.showModal', 'uni.navigateTo', 'uni.redirectTo', '/pages/')) {
if ($page.Contains($forbidden)) { throw "F08 retains forbidden dependency: $forbidden" }
}
foreach ($requiredPosition in @(
'(?s)\.album-photo-tile\s*\{[^}]*position:\s*relative;',
'(?s)\.album-hero-photo,\s*\.album-photo-tile__image\s*\{[^}]*position:\s*absolute;',
'(?s)\.album-photo-tile__caption\s*\{[^}]*position:\s*absolute;',
'(?s)\.album-preview\s*\{[^}]*position:\s*fixed;'
)) {
if ($page -notmatch $requiredPosition) { throw "F08 required local overlay boundary missing: $requiredPosition" }
}
if ([regex]::Matches($page, 'position\s*:').Count -ne 4) { throw 'F08 must keep only its photo-tile overlays and full-screen preview positioned' }
Write-Output 'F08-ALBUM-DETAIL-CONTRACT PASS'
-100
View File
@@ -1,100 +0,0 @@
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(`${origin}/`))
if (!page) throw new Error(`Chrome debugging has no ${origin} project 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()
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
message.error ? request.reject(new Error(message.error.message)) : 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 }
}
const valueOf = async (send, expression) => (
await send('Runtime.evaluate', { expression, returnByValue: true })
).result?.value
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(80)
}
throw new Error(message)
}
const run = async () => {
const { socket, send } = await connect()
let auditId = 0
const navigate = async (route, selector) => {
auditId += 1
const url = `${origin}/?f08RuntimeAudit=${auditId}#${route}`
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `F08 navigation failed: ${route}`)
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `F08 state missing: ${selector}`)
}
try {
await send('Page.enable')
await send('Runtime.enable')
await navigate('/pages/family/f08-album-detail?genealogyId=1001&albumId=201', '.album-state--normal')
const photoCount = await valueOf(send, "document.querySelectorAll('.album-photo-tile').length")
assert.strictEqual(photoCount, 5, 'F08 normal state must render five photo tiles')
await send('Runtime.evaluate', { expression: "document.querySelector('.album-photo-tile')?.click()" })
await waitFor(send, "Boolean(document.querySelector('.album-preview'))", 'F08 photo click did not open preview')
await send('Runtime.evaluate', { expression: "document.querySelector('.album-preview__close')?.click()" })
await waitFor(send, "!document.querySelector('.album-preview')", 'F08 preview did not close')
await send('Runtime.evaluate', { expression: "document.querySelector('.album-upload-action')?.click()" })
await waitFor(
send,
"location.href.includes('/pages/family/f09-media-upload?genealogyId=1001&albumId=201')",
'F08 upload action did not carry the current album into F09'
)
await navigate('/pages/family/f08-album-detail?genealogyId=1001&albumId=201&state=empty', '.album-state--empty')
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.album-photo-tile').length"), 0, 'F08 empty state must not render photos')
await navigate('/pages/family/f08-album-detail?genealogyId=1001&albumId=201&state=preview', '.album-state--preview')
await waitFor(send, "Boolean(document.querySelector('.album-preview'))", 'F08 direct preview state did not render')
await navigate('/pages/family/f08-album-detail?genealogyId=1001&albumId=201&state=expired', '.album-state--expired')
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.album-photo-tile').length"), 0, 'F08 expired state must not render photos')
await navigate('/pages/family/f08-album-detail?genealogyId=1002&albumId=201', '.album-state--expired')
process.stdout.write('F08-ALBUM-DETAIL-RUNTIME-SMOKE PASS\n')
} finally {
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
-14
View File
@@ -1,14 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$detail = Get-Content -LiteralPath (Join-Path $root 'pages/family/f08-album-detail.vue') -Raw -Encoding UTF8
$upload = Get-Content -LiteralPath (Join-Path $root 'pages/family/f09-media-upload.vue') -Raw -Encoding UTF8
foreach ($token in @('const genealogyId = ref', 'const albumId = ref', 'findFamilyAlbumFixture(genealogyId.value, albumId.value)', 'toUpload', 'openPage(', 'genealogyId: genealogyId.value, albumId: albumId.value')) {
if (-not $detail.Contains($token)) { throw "F08 album context missing: $token" }
}
foreach ($token in @('const genealogyId = ref', 'const albumId = ref', 'findFamilyAlbumFixture(genealogyId.value, albumId.value)', 'returnTo("F08", {', 'genealogyId: genealogyId.value', 'albumId: albumId.value')) {
if (-not $upload.Contains($token)) { throw "F09 album context missing: $token" }
}
foreach ($page in @($detail, $upload)) {
if ($page.Contains('query.albumId || "reunion"') -or $page.Contains('/pages/')) { throw 'F08/F09 must not default the album identity or retain route literals' }
}
Write-Output 'F08-F09-ALBUM-CONTEXT-CONTRACT PASS'
-87
View File
@@ -1,87 +0,0 @@
$ErrorActionPreference = 'Stop'
function Read-Utf8([string]$Path) {
[System.IO.File]::ReadAllText(
(Join-Path (Join-Path $PSScriptRoot '..') $Path),
[System.Text.Encoding]::UTF8
)
}
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if (-not $Content.Contains($Expected)) { throw $Message }
}
$page = Read-Utf8 'pages/family/f09-media-upload.vue'
foreach ($expected in @(
'class="media-upload-page"',
'media-upload-state--${uploadState}',
'class="media-album-card"',
'class="media-photo-grid"',
'class="media-add-tile"',
'class="media-batch-field"',
'class="media-photo-field"',
'class="media-photo-editor"',
'class="media-photo-editor__thumb"',
'class="media-photo-editor__position"',
'<textarea',
'auto-height',
'const MAX_PHOTOS = 9;',
'const selectMockPhotos = () =>',
'const selectPhoto = (index) =>',
'const removePhoto = (index) =>',
'const addMockPhoto = () =>',
'const updateActiveNote = (event) =>',
'const generatePreview = () =>',
'const returnToAlbum = () =>',
'["permission", "selected", "preview"].includes(query.state)',
'findFamilyAlbumFixture(genealogyId.value, albumId.value)',
'const isDirty = computed(() =>',
'createDiscardConfirmation',
'runBackGuard',
'onBackPress((event) => handleBackPress(event, requestBack));',
'class="media-preview-title"',
'class="media-permission-action"',
'class="media-preview-title"',
'class="media-primary-action"',
'f08-reunion-hero.png',
'f08-family-portrait.png',
'f08-reunion-table.png',
'f08-ancestral-home.png',
'f08-ancestral-portrait.png',
'ModulePageBackground',
'PageHeader',
'AppButton'
)) {
Assert-Contains $page $expected "F09 contract missing: $expected"
}
foreach ($forbidden in @(
'<ModulePage page-id="f09"',
'import ModulePage from',
'uni.chooseMedia',
'uni.showModal',
'uni.redirectTo',
'/pages/',
'uploading',
'uploaded',
'success',
'retryFailed',
'/genealogy/app/files/upload',
'finishPage('
)) {
if ($page.Contains($forbidden)) { throw "F09 retains forbidden dependency: $forbidden" }
}
foreach ($requiredPosition in @(
'(?s)\.media-photo-tile\s*\{[^}]*position:\s*relative;',
'(?s)\.media-photo-order,\s*\.media-photo-current\s*\{[^}]*position:\s*absolute;',
'(?s)\.media-photo-remove\s*\{[^}]*position:\s*absolute;'
)) {
if ($page -notmatch $requiredPosition) { throw "F09 required thumbnail overlay boundary missing: $requiredPosition" }
}
if ([regex]::Matches($page, 'position\s*:').Count -ne 3) { throw 'F09 must keep only thumbnail badges and their local photo boundary positioned' }
if ([regex]::Matches($page, '<textarea(?=[^>]*\sauto-height(?:\s|>))').Count -ne 2) { throw 'F09 batch and per-photo descriptions must both grow with content' }
Write-Output 'F09-MEDIA-UPLOAD-CONTRACT PASS'
-78
View File
@@ -1,78 +0,0 @@
const assert = require('assert')
const origin = process.argv[2] || 'http://localhost:5173'
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const run = 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(`${origin}/`))
if (!page) throw new Error(`Chrome debugging has no ${origin} project 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()
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
message.error ? request.reject(new Error(message.error.message)) : 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 }))
})
const valueOf = async (expression) => (
await send('Runtime.evaluate', { expression, returnByValue: true })
).result?.value
const waitFor = async (expression, message) => {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (await valueOf(expression)) return
await sleep(80)
}
throw new Error(message)
}
try {
await send('Page.enable')
await send('Runtime.enable')
await send('Emulation.setDeviceMetricsOverride', {
width: 412,
height: 915,
deviceScaleFactor: 1,
mobile: true,
screenWidth: 412,
screenHeight: 915,
})
const url = `${origin}/?f09LayoutAudit=1#/pages/family/f09-media-upload?genealogyId=1001&albumId=201&state=selected`
await send('Page.navigate', { url })
await waitFor("Boolean(document.querySelector('.media-upload-state--selected'))", 'F09 selected state missing')
const metrics = await valueOf(`(() => {
const textareas = Array.from(document.querySelectorAll('.media-description-panel textarea'))
const action = document.querySelector('.media-primary-action')
return {
textareaHeights: textareas.map((node) => node.getBoundingClientRect().height),
actionBottom: action.getBoundingClientRect().bottom,
viewportHeight: innerHeight,
}
})()`)
process.stdout.write(`${JSON.stringify(metrics)}\n`)
assert(metrics.textareaHeights.every((height) => height <= 72), 'F09 textarea uses an oversized browser default height')
assert(metrics.actionBottom <= metrics.viewportHeight, 'F09 selected primary action is clipped at 412x915')
process.stdout.write('F09-MEDIA-UPLOAD-LAYOUT-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)
})
-117
View File
@@ -1,117 +0,0 @@
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(`${origin}/`))
if (!page) throw new Error(`Chrome debugging has no ${origin} project 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()
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
message.error ? request.reject(new Error(message.error.message)) : 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 }
}
const valueOf = async (send, expression) => (
await send('Runtime.evaluate', { expression, returnByValue: true })
).result?.value
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(80)
}
throw new Error(message)
}
const run = async () => {
const { socket, send } = await connect()
let auditId = 0
const navigate = async (route, selector) => {
auditId += 1
const url = `${origin}/?f09RuntimeAudit=${auditId}#${route}`
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `F09 navigation failed: ${route}`)
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `F09 state missing: ${selector}`)
}
try {
await send('Page.enable')
await send('Runtime.enable')
await navigate('/pages/family/f09-media-upload?genealogyId=1001&albumId=201', '.media-upload-state--initial')
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.media-photo-tile').length"), 0)
await send('Runtime.evaluate', { expression: "document.querySelector('.media-primary-action')?.click()" })
await waitFor(send, "Boolean(document.querySelector('.media-upload-state--selected'))", 'F09 did not enter selected state')
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.media-photo-tile').length"), 4)
await send('Runtime.evaluate', { expression: "document.querySelectorAll('.media-photo-tile')[1]?.click()" })
await waitFor(send, "document.querySelectorAll('.media-photo-tile')[1]?.classList.contains('media-photo-tile--active') === true", 'F09 did not switch current photo')
await waitFor(send, "document.querySelector('.media-photo-editor__position')?.textContent.includes('第 2 张 / 共 4 张') === true", 'F09 editor did not identify the second photo')
await waitFor(send, "document.querySelector('.media-photo-editor__thumb img')?.currentSrc.includes('f08-family-portrait.png') === true", 'F09 editor thumbnail did not follow the active photo')
await send('Runtime.evaluate', { expression: `(() => {
const textarea = document.querySelector('.media-photo-field textarea')
textarea.value = '院前合影'
textarea.dispatchEvent(new Event('input', { bubbles: true }))
document.querySelectorAll('.media-photo-tile')[0]?.click()
document.querySelectorAll('.media-photo-tile')[1]?.click()
})()` })
await waitFor(send, "document.querySelector('.media-photo-field textarea')?.value === '院前合影'", 'F09 per-photo note did not remain bound after switching photos')
await send('Runtime.evaluate', { expression: "document.querySelectorAll('.media-photo-remove')[1]?.click()" })
await waitFor(send, "document.querySelectorAll('.media-photo-tile').length === 3", 'F09 did not remove one photo')
await send('Runtime.evaluate', { expression: "document.querySelector('.media-primary-action')?.click()" })
await waitFor(send, "Boolean(document.querySelector('.media-field-error'))", 'F09 batch description validation did not render')
await send('Runtime.evaluate', { expression: `(() => {
const textarea = document.querySelector('.media-batch-field textarea')
textarea.value = '春节团圆照片整理'
textarea.dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector('.media-primary-action')?.click()
})()` })
await waitFor(send, "Boolean(document.querySelector('.media-upload-state--preview'))", 'F09 did not enter honest local preview')
await waitFor(send, "document.querySelector('.media-preview-card')?.textContent.includes('照片尚未上传') === true", 'F09 preview did not disclose that no upload occurred')
await navigate('/pages/family/f09-media-upload?genealogyId=1001&albumId=201&state=permission', '.media-upload-state--permission')
await send('Runtime.evaluate', { expression: "document.querySelector('.media-permission-action')?.click()" })
await waitFor(send, "Boolean(document.querySelector('.media-upload-state--selected'))", 'F09 permission action did not return to selected state')
await navigate('/pages/family/f09-media-upload?genealogyId=1001&albumId=201&state=preview', '.media-upload-state--preview')
assert.strictEqual(await valueOf(send, "document.body.textContent.includes('已上传')"), false, 'F09 must never claim an upload succeeded')
await navigate('/pages/family/f09-media-upload?genealogyId=1002&albumId=201', '.media-upload-state--invalid')
process.stdout.write('F09-MEDIA-UPLOAD-RUNTIME-SMOKE PASS\n')
} finally {
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
-45
View File
@@ -1,45 +0,0 @@
$ErrorActionPreference = 'Stop'
function Read-Utf8([string]$Path) {
[System.IO.File]::ReadAllText((Join-Path (Join-Path $PSScriptRoot '..') $Path), [System.Text.Encoding]::UTF8)
}
function Assert-Contains([string]$Content, [string]$Expected, [string]$Message) {
if (-not $Content.Contains($Expected)) { throw $Message }
}
$page = Read-Utf8 'pages/family/f10-video-list.vue'
foreach ($expected in @(
'class="video-status-page"',
'class="video-status-card"',
'class="video-return-action"',
'const returnToFamily = () =>',
'genealogyId.value = String(query.genealogyId || "");',
'returnTo("F01", { genealogyId: genealogyId.value })',
'getGenealogyFixtureAccess',
'ModulePageBackground',
'PageHeader',
'AppButton',
'@use "../../styles/adaptive-frame-profiles.scss" as adaptive;',
'@include adaptive-family-panel;'
)) {
Assert-Contains $page $expected "F10 contract missing: $expected"
}
foreach ($forbidden in @('<ModulePage page-id="f10"', 'import ModulePage from', 'class="video-status-card__skin"', 'module-content-frame.png', 'uni.reLaunch', '/pages/')) {
if ($page.Contains($forbidden)) { throw "F10 retains forbidden dependency: $forbidden" }
}
if ($page -match '(?m)^\s*position\s*:\s*relative\s*;') {
throw 'F10 ordinary page content must not use position: relative'
}
foreach ($selector in @('.video-status-lead text')) {
$escapedSelector = [regex]::Escape($selector)
if ($page -notmatch "(?s)$escapedSelector\s*\{[^}]*z-index\s*:\s*1\s*;") {
throw "F10 grid content must render above its decorative skin: $selector"
}
}
Write-Output 'F10-VIDEO-STATUS-CONTRACT PASS'
-78
View File
@@ -1,78 +0,0 @@
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(`${origin}/`))
if (!page) throw new Error(`Chrome debugging has no ${origin} project 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()
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
message.error ? request.reject(new Error(message.error.message)) : 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 }
}
const valueOf = async (send, expression) => (
await send('Runtime.evaluate', { expression, returnByValue: true })
).result?.value
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(80)
}
throw new Error(message)
}
const run = async () => {
const { socket, send } = await connect()
const url = `${origin}/?f10RuntimeAudit=${Date.now()}#/pages/family/f10-video-list?genealogyId=1001`
try {
await send('Page.enable')
await send('Runtime.enable')
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, 'F10 navigation failed')
await waitFor(send, "Boolean(document.querySelector('.video-status-page'))", 'F10 dedicated state missing: .video-status-page')
assert.strictEqual(
await valueOf(send, "Boolean(document.querySelector('.module-state--success'))"),
false
)
await send('Runtime.evaluate', { expression: "document.querySelector('.video-return-action')?.click()" })
await waitFor(send, "location.hash.startsWith('#/pages/family/f01-family-feed')", 'F10 did not return to F01')
await waitFor(send, "location.hash.includes('genealogyId=1001')", 'F10 dropped the genealogy identity on return')
await waitFor(send, "Boolean(document.querySelector('.feed-state--list'))", 'F01 did not render after F10 return')
process.stdout.write('F10-VIDEO-STATUS-RUNTIME-SMOKE PASS\n')
} finally {
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
-152
View File
@@ -1,152 +0,0 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const toDataModuleUrl = (source) =>
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const run = async () => {
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
const prelude = `
const currentUser = {};
const genealogies = [];
const publicGenealogies = [];
const treeMembers = [];
const notifications = [];
const joinApplications = [];
const listFamilyFeedFixtures = () => [];
const listFamilyArticleFixtures = () => [];
const listFamilyAlbumFixtures = () => [];
const listCeremonyFixtures = () => [];
const listGrowthRecordFixtures = () => [];
const listNotificationFixtures = () => [];
const runtimeConfig = { baseUrl: "https://backend-api.ddxcjp.cn", clientId: "client-1", tenantId: "000000" };
const hasRemoteConfig = () => true;
const resolveRuntimeMode = () => "remote";
const AUTH_TAC_SCENE = Object.freeze({});
const assertSmsCode = (value) => value;
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" });
const fromApiGenealogyAccess = () => null;
const session = { getToken: () => "session-1", saveToken() {} };
`;
const requests = [];
const feedResponse = {
feedId: "9001",
genealogyId: "1001",
publisherNickName: "家谱用户",
feedType: "photo",
feedContent: "family memory",
mediaOssIds: "2060000000000000001,2060000000000000002",
likedByMe: false,
likeCount: 0,
commentCount: 0,
pinned: "0",
createTime: "2026-07-27T10:00:00+08:00",
};
let response = { statusCode: 200, data: { code: 200, data: feedResponse } };
globalThis.uni = {
request(options) {
requests.push(options);
queueMicrotask(() => options.success(response));
return { abort() {} };
},
};
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
const createdFeed = await appApi.createFeed("1001", {
feedType: " photo ",
feedContent: " family memory ",
mediaOssIds: "2060000000000000001,2060000000000000002",
sortOrder: "0",
status: " 0 ",
});
assert.deepStrictEqual(createdFeed, {
id: "9001",
genealogyId: "1001",
content: "family memory",
publisher: "家谱用户",
type: "photo",
time: "2026-07-27T10:00:00+08:00",
mediaOssIds: "2060000000000000001,2060000000000000002",
likeCount: 0,
commentCount: 0,
likedByMe: false,
pinned: "0",
});
assert.deepStrictEqual(requests.at(-1).data, {
feedType: "photo",
feedContent: "family memory",
mediaOssIds: "2060000000000000001,2060000000000000002",
sortOrder: 0,
status: "0",
});
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/1001/feeds");
response = { statusCode: 200, data: { code: 200, data: { ...feedResponse, feedContent: "only required" } } };
await appApi.createFeed("1001", { feedContent: "only required" });
assert.deepStrictEqual(requests.at(-1).data, { feedContent: "only required" });
await assert.rejects(
appApi.createFeed("1001", { feedContent: "bad media", mediaOssIds: "1, 2" }),
/mediaOssIds/,
);
const updatedFeed = await appApi.updateFeed("1001", "9001", { feedContent: "family memory" });
assert.strictEqual(updatedFeed.id, "9001");
assert.strictEqual(requests.at(-1).method, "PUT");
response = { statusCode: 200, data: { code: 200, data: {} } };
await appApi.createArticle("1001", {
categoryId: "900040001",
articleTitle: " title ",
articleSummary: " summary ",
coverOssId: "9007199254740993",
articleContent: " body ",
authorName: " author ",
sortOrder: "1",
status: " 0 ",
});
assert.deepStrictEqual(requests.at(-1).data, {
categoryId: 900040001,
articleTitle: "title",
articleSummary: "summary",
coverOssId: "9007199254740993",
articleContent: "body",
authorName: "author",
sortOrder: 1,
status: "0",
});
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/1001/articles");
await appApi.createAlbum("1001", {
albumName: " old photos ",
albumDesc: " family album ",
coverOssId: "9007199254740993",
sortOrder: "1",
status: " 0 ",
});
assert.deepStrictEqual(requests.at(-1).data, {
albumName: "old photos",
albumDesc: "family album",
coverOssId: "9007199254740993",
sortOrder: 1,
status: "0",
});
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/1001/albums");
await assert.rejects(
appApi.createAlbum("1001", { albumName: "bad id", coverOssId: 900001 }),
/OSS ID 字符串/,
);
delete globalThis.uni;
process.stdout.write("FAMILY-CREATE-API-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
-39
View File
@@ -1,39 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
function Read-Utf8([string]$relativePath) {
Get-Content -Raw -Encoding UTF8 (Join-Path $root $relativePath)
}
function Assert-Contains([string]$text, [string]$token, [string]$message) {
if (-not $text.Contains($token)) { throw $message }
}
$f02 = Read-Utf8 'pages/family/f02-publish-feed.vue'
foreach ($token in @('form.feedType', 'mediaReceipts', 'mediaOssIds', 'form.sortOrder', 'pickAndUploadImage', 'class="required-mark"')) {
Assert-Contains $f02 $token "F02 is missing full feed field owner: $token"
}
$f06 = Read-Utf8 'pages/family/f06-article-editor.vue'
foreach ($token in @('form.articleSummary', 'coverOssId', 'form.authorName', 'form.sortOrder', 'pickAndUploadImage', 'coverOssId.value = receipt.ossId', 'class="required-mark"')) {
Assert-Contains $f06 $token "F06 is missing full article field owner: $token"
}
if ($f06 -match 'v-model="(?:form\.)?coverOssId"') { throw 'F06 must not expose a raw cover OSS ID input' }
$f07 = Read-Utf8 'pages/family/f07-album-list.vue'
foreach ($token in @('form.albumDesc', 'coverOssId', 'form.sortOrder', 'pickAndUploadImage', 'coverOssId.value = receipt.ossId', 'class="required-mark"')) {
Assert-Contains $f07 $token "F07 is missing full album field owner: $token"
}
if ($f07 -match 'v-model="(?:form\.)?coverOssId"') { throw 'F07 must not expose a raw cover OSS ID input' }
foreach ($page in @($f02, $f06, $f07)) {
if ($page -match 'v-model="form\.(status|categoryId)"') { throw 'Family create forms must not ask users to enter status or category codes' }
if ($page -match 'placeholder="[^"]*(分类 ID|状态码|例如:0)') { throw 'Family create forms must not expose raw code placeholders' }
}
$fullWidthOptionalLabel = [string]([char]0xff08) + [char]0x9009 + [char]0x586b + [char]0xff09
$asciiOptionalLabel = '(' + [char]0x9009 + [char]0x586b + ')'
foreach ($page in @($f02, $f06, $f07)) {
if ($page.Contains($fullWidthOptionalLabel) -or $page.Contains($asciiOptionalLabel)) { throw 'Family create forms must not label optional fields as optional' }
}
Write-Output 'FAMILY-CREATE-PAGES-CONTRACT PASS'
@@ -1,15 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$publish = [System.IO.File]::ReadAllText((Join-Path $root 'pages/family/f02-publish-feed.vue'), [System.Text.Encoding]::UTF8)
$editor = [System.IO.File]::ReadAllText((Join-Path $root 'pages/family/f06-article-editor.vue'), [System.Text.Encoding]::UTF8)
if ($publish -match 'position\s*:') { throw 'F02 must keep its editor in document flow' }
if ($editor -match 'position\s*:') { throw 'F06 must keep its editor in document flow' }
if (-not $publish.Contains('@include adaptive-family-field;')) { throw 'F02 field frame must come from the adaptive profile owner' }
if (-not $editor.Contains('@include adaptive-family-field;')) { throw 'F06 field frame must come from the adaptive profile owner' }
if ($publish -notmatch '(?s)<textarea[^>]*\sauto-height(?:\s|>)') { throw 'F02 long-form textarea must grow with its content' }
if ($editor -notmatch '(?s)<textarea[^>]*\sauto-height(?:\s|>)') { throw 'F06 long-form textarea must grow with its content' }
if ($publish -match '100%\s+100%\s+no-repeat' -or $editor -match '100%\s+100%\s+no-repeat') { throw 'Family editors must not stretch decorative backgrounds' }
if ($editor -match '<view[^>]+class="editor-control[^>]*>\s*<image') { throw 'F06 control must not retain a positioned decorative image' }
Write-Output 'FAMILY-EDITORS-DOCUMENT-FLOW-CONTRACT PASS'
@@ -1,990 +0,0 @@
$ErrorActionPreference = 'Stop'
$contractPath = Join-Path $PSScriptRoot 'family-feed-read-openapi-contract.ps1'
$temporaryPath = [System.IO.Path]::GetTempFileName()
$identifierPattern = '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$'
$cursorPattern = '^[A-Za-z0-9_-]{1,512}$'
function New-Ref([string]$Ref) {
return [ordered]@{ '$ref' = $Ref }
}
function New-StringOwner(
[int]$MinLength,
[int]$MaxLength,
[string]$Pattern = ''
) {
$schema = [ordered]@{
type = 'string'
minLength = $MinLength
maxLength = $MaxLength
nullable = $false
}
if ($Pattern) { $schema.pattern = $Pattern }
return $schema
}
function New-ClosedObject(
[System.Collections.Specialized.OrderedDictionary]$Properties,
[string[]]$Required
) {
return [ordered]@{
type = 'object'
properties = $Properties
required = $Required
additionalProperties = $false
nullable = $false
}
}
function New-SuccessEnvelope([string]$DataRef) {
return New-ClosedObject ([ordered]@{
code = [ordered]@{
type = 'integer'
enum = @(200)
nullable = $false
}
data = New-Ref $DataRef
}) @('code', 'data')
}
function New-ErrorEnvelope([int]$Status, [string[]]$BusinessCodes) {
return New-ClosedObject ([ordered]@{
businessCode = [ordered]@{
type = 'string'
enum = $BusinessCodes
nullable = $false
}
code = [ordered]@{
type = 'integer'
enum = @($Status)
nullable = $false
}
message = [ordered]@{
type = 'string'
minLength = 1
maxLength = 200
nullable = $false
}
}) @('businessCode', 'code', 'message')
}
function New-Response([string]$SchemaRef, [bool]$RateLimited = $false) {
$headers = [ordered]@{
'Cache-Control' = New-Ref '#/components/headers/PrivateNoStore'
}
if ($RateLimited) {
$headers.'Retry-After' = New-Ref '#/components/headers/RetryAfter'
}
return [ordered]@{
description = 'typed response'
headers = $headers
content = [ordered]@{
'application/json' = [ordered]@{
schema = New-Ref $SchemaRef
}
}
}
}
function New-Responses([string]$SuccessRef) {
return [ordered]@{
'200' = New-Response $SuccessRef
'400' = New-Response '#/components/schemas/RFamilyFeedReadBadRequest'
'401' = New-Response '#/components/schemas/RFamilyFeedReadUnauthorized'
'404' = New-Response '#/components/schemas/RFamilyFeedReadNotFound'
'429' = New-Response '#/components/schemas/RFamilyFeedReadRateLimited' $true
'500' = New-Response '#/components/schemas/RFamilyFeedReadUnavailable'
}
}
function New-ClientIdParameter {
return [ordered]@{
name = 'clientid'
in = 'header'
required = $true
schema = [ordered]@{
type = 'string'
minLength = 1
maxLength = 128
nullable = $false
}
}
}
function New-IdParameter([string]$Name, [string]$SchemaRef) {
return [ordered]@{
name = $Name
in = 'path'
required = $true
schema = New-Ref $SchemaRef
}
}
function New-CursorParameter([string]$SchemaRef) {
return [ordered]@{
name = 'cursor'
in = 'query'
required = $false
schema = New-Ref $SchemaRef
}
}
function New-LimitParameter {
return [ordered]@{
name = 'limit'
in = 'query'
required = $false
schema = [ordered]@{
type = 'integer'
minimum = 1
maximum = 50
default = 20
nullable = $false
}
}
}
function New-ReadOperation(
[string]$OperationId,
[object[]]$Parameters,
[string]$SuccessRef,
[string[]]$AuthorizationScope
) {
return [ordered]@{
operationId = $OperationId
parameters = $Parameters
responses = New-Responses $SuccessRef
security = @([ordered]@{ SaToken = @() })
'x-read-only' = $true
'x-non-disclosing-not-found' = $true
'x-authorize-every-request' = $true
'x-cors-policy-owner' = 'APP_GATEWAY_PREFLIGHT'
'x-authorization-scope' = $AuthorizationScope
}
}
function Add-CursorContract(
[System.Collections.Specialized.OrderedDictionary]$Operation,
[string[]]$Scope,
[string[]]$Order
) {
$Operation.'x-cursor-scope' = $Scope
$Operation.'x-cursor-order' = $Order
$Operation.'x-read-window' = 'UPPER_BOUND_KEYSET_LATEST_VISIBLE'
$Operation.'x-cursor-no-total' = $true
$Operation.'x-refresh-discards-cursor' = $true
$Operation.'x-authorize-every-page' = $true
$Operation.'x-invalid-or-expired-cursor' = '400_FAMILY_FEED_CURSOR_INVALID'
$Operation.'x-cross-scope-cursor' = '404_FAMILY_FEED_NOT_AVAILABLE'
}
function New-CursorPage(
[string]$ItemRef,
[string]$CursorRef,
[string[]]$Order
) {
$page = New-ClosedObject ([ordered]@{
items = [ordered]@{
type = 'array'
items = New-Ref $ItemRef
minItems = 0
maxItems = 50
nullable = $false
}
nextCursor = New-Ref $CursorRef
}) @('items')
$page.'x-no-total' = $true
$page.'x-next-cursor-absent-at-end' = $true
$page.'x-order' = $Order
$page.'x-read-window' = 'UPPER_BOUND_KEYSET_LATEST_VISIBLE'
$page.'x-new-items-after-window' = 'EXCLUDED_UNTIL_REFRESH'
$page.'x-deletion-or-visibility-change' = 'OMIT_ON_LATER_PAGE'
$page.'x-edit-policy' = 'LATEST_VISIBLE_AT_PAGE_READ'
return $page
}
function New-ValidDocument {
$genealogyId = New-StringOwner 1 128 $identifierPattern
$feedId = New-StringOwner 1 128 $identifierPattern
$feedId.'x-opaque' = $true
$feedId.'x-client-semantics' = 'COMPARE_ONLY'
$commentId = New-StringOwner 1 128 $identifierPattern
$commentId.'x-opaque' = $true
$commentId.'x-client-semantics' = 'COMPARE_ONLY'
$feedCursor = New-StringOwner 1 512 $cursorPattern
$feedCursor.'x-opaque' = $true
$feedCursor.'x-purpose' = 'FAMILY_FEED_PAGE'
$commentCursor = New-StringOwner 1 512 $cursorPattern
$commentCursor.'x-opaque' = $true
$commentCursor.'x-purpose' = 'FAMILY_FEED_ROOT_COMMENT_PAGE'
$feedContent = New-StringOwner 1 300
$feedContent.'x-text-normalizer' = 'FAMILY_FEED_TEXT_V1'
$feedContent.'x-length-unit' = 'UNICODE_CODE_POINT'
$commentContent = New-StringOwner 1 1000
$commentContent.'x-text-normalizer' = 'FAMILY_FEED_COMMENT_TEXT_V1'
$commentContent.'x-length-unit' = 'UNICODE_CODE_POINT'
$displayName = New-StringOwner 1 100
$displayName.'x-projection' = 'AUTHORIZED_DISPLAY_NAME_ONLY'
$displayName.'x-missing-author-policy' = 'NON_EMPTY_SERVER_FALLBACK'
$publishedAt = [ordered]@{
type = 'string'
format = 'date-time'
nullable = $false
'x-server-generated' = $true
'x-immutable' = $true
}
$feedItem = New-ClosedObject ([ordered]@{
authorDisplayName = New-Ref '#/components/schemas/FamilyFeedAuthorDisplayName'
feedContent = New-Ref '#/components/schemas/FamilyFeedContent'
feedId = New-Ref '#/components/schemas/FamilyFeedId'
hasMedia = [ordered]@{
type = 'boolean'
nullable = $false
}
publishedAt = New-Ref '#/components/schemas/FamilyFeedPublishedAt'
}) @('authorDisplayName', 'feedContent', 'feedId', 'hasMedia', 'publishedAt')
$feedItem.description = 'Annotations may mention phone or audit examples without becoming response fields.'
$feedItem.example = [ordered]@{ annotationOnly = 'appUserPhone is not a schema property' }
$feedItem.'x-projection' = 'VISIBLE_FEED_PRESENTATION_ONLY'
$feedItem.'x-media-policy' = 'HAS_MEDIA_REQUIRES_HONEST_CLIENT_PLACEHOLDER_UNTIL_MEDIA_READ_CONTRACT'
$commentItem = New-ClosedObject ([ordered]@{
authorDisplayName = New-Ref '#/components/schemas/FamilyFeedAuthorDisplayName'
commentContent = New-Ref '#/components/schemas/FamilyFeedCommentContent'
commentId = New-Ref '#/components/schemas/FamilyFeedCommentId'
publishedAt = New-Ref '#/components/schemas/FamilyFeedPublishedAt'
}) @('authorDisplayName', 'commentContent', 'commentId', 'publishedAt')
$commentItem.'x-projection' = 'VISIBLE_COMMENT_PRESENTATION_ONLY'
$commentItem.'x-comment-level' = 'ROOT_ONLY'
$commentItem.'x-deleted-placeholder-policy' = 'EXCLUDE'
$feedOrder = @('publishedAt:DESC', 'feedId:DESC_ORDINAL')
$commentOrder = @('publishedAt:ASC', 'commentId:ASC_ORDINAL')
$feedList = New-ReadOperation 'appListFamilyFeeds' @(
(New-ClientIdParameter),
(New-IdParameter 'genealogyId' '#/components/schemas/GenealogyId'),
(New-CursorParameter '#/components/schemas/FamilyFeedCursor'),
(New-LimitParameter)
) '#/components/schemas/RAppFamilyFeedCursorPage' @('tenant', 'genealogy', 'membership')
Add-CursorContract $feedList @(
'tenant', 'account', 'authSession', 'client', 'genealogyId', 'projection',
'order', 'limit', 'windowUpperBound', 'lastTuple'
) $feedOrder
$feedDetail = New-ReadOperation 'appGetFamilyFeed' @(
(New-ClientIdParameter),
(New-IdParameter 'genealogyId' '#/components/schemas/GenealogyId'),
(New-IdParameter 'feedId' '#/components/schemas/FamilyFeedId')
) '#/components/schemas/RAppFamilyFeedReadItem' @(
'tenant', 'genealogy', 'membership', 'feedBelongsToGenealogy', 'feedVisibility'
)
$comments = New-ReadOperation 'appListFamilyFeedRootComments' @(
(New-ClientIdParameter),
(New-IdParameter 'genealogyId' '#/components/schemas/GenealogyId'),
(New-IdParameter 'feedId' '#/components/schemas/FamilyFeedId'),
(New-CursorParameter '#/components/schemas/FamilyFeedRootCommentCursor'),
(New-LimitParameter)
) '#/components/schemas/RAppFamilyFeedRootCommentCursorPage' @(
'tenant', 'genealogy', 'membership', 'feedBelongsToGenealogy', 'feedVisibility'
)
Add-CursorContract $comments @(
'tenant', 'account', 'authSession', 'client', 'genealogyId', 'feedId',
'projection', 'order', 'limit', 'windowUpperBound', 'lastTuple'
) $commentOrder
$schemas = [ordered]@{
GenealogyId = $genealogyId
FamilyFeedId = $feedId
FamilyFeedCommentId = $commentId
FamilyFeedCursor = $feedCursor
FamilyFeedRootCommentCursor = $commentCursor
FamilyFeedContent = $feedContent
FamilyFeedCommentContent = $commentContent
FamilyFeedAuthorDisplayName = $displayName
FamilyFeedPublishedAt = $publishedAt
AppFamilyFeedReadItem = $feedItem
AppFamilyFeedRootCommentReadItem = $commentItem
AppFamilyFeedCursorPage = New-CursorPage '#/components/schemas/AppFamilyFeedReadItem' '#/components/schemas/FamilyFeedCursor' $feedOrder
AppFamilyFeedRootCommentCursorPage = New-CursorPage '#/components/schemas/AppFamilyFeedRootCommentReadItem' '#/components/schemas/FamilyFeedRootCommentCursor' $commentOrder
RAppFamilyFeedCursorPage = New-SuccessEnvelope '#/components/schemas/AppFamilyFeedCursorPage'
RAppFamilyFeedReadItem = New-SuccessEnvelope '#/components/schemas/AppFamilyFeedReadItem'
RAppFamilyFeedRootCommentCursorPage = New-SuccessEnvelope '#/components/schemas/AppFamilyFeedRootCommentCursorPage'
RFamilyFeedReadBadRequest = New-ErrorEnvelope 400 @('FAMILY_FEED_CURSOR_INVALID', 'FAMILY_FEED_QUERY_INVALID')
RFamilyFeedReadUnauthorized = New-ErrorEnvelope 401 @('AUTH_REQUIRED')
RFamilyFeedReadNotFound = New-ErrorEnvelope 404 @('FAMILY_FEED_NOT_AVAILABLE')
RFamilyFeedReadRateLimited = New-ErrorEnvelope 429 @('RATE_LIMITED')
RFamilyFeedReadUnavailable = New-ErrorEnvelope 500 @('FAMILY_FEED_READ_UNAVAILABLE')
}
$document = [ordered]@{
openapi = '3.0.1'
info = [ordered]@{
title = 'family feed read adversarial fixture'
version = '1'
}
paths = [ordered]@{
'/genealogy/app/genealogies/{genealogyId}/feeds' = [ordered]@{
get = $feedList
}
'/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}' = [ordered]@{
get = $feedDetail
}
'/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments' = [ordered]@{
get = $comments
}
}
components = [ordered]@{
schemas = $schemas
headers = [ordered]@{
PrivateNoStore = [ordered]@{
schema = [ordered]@{
type = 'string'
enum = @('private, no-store')
nullable = $false
}
}
RetryAfter = [ordered]@{
schema = [ordered]@{
type = 'integer'
minimum = 1
maximum = 300
nullable = $false
}
}
}
securitySchemes = [ordered]@{
SaToken = [ordered]@{
type = 'apiKey'
in = 'header'
name = 'Authorization'
}
}
}
}
return ($document | ConvertTo-Json -Depth 100 | ConvertFrom-Json)
}
function Copy-Document([object]$Document) {
return ($Document | ConvertTo-Json -Depth 100 | ConvertFrom-Json)
}
function Get-ContractIssues([object]$Document) {
$json = $Document | ConvertTo-Json -Depth 100
[System.IO.File]::WriteAllText($temporaryPath, $json, [System.Text.UTF8Encoding]::new($false))
$output = @(
& $contractPath -SkipProtectedParity -ReturnIssues -DocumentPath $temporaryPath
)
return @($output | Where-Object { $_ -is [string] -and $_.Length -gt 0 })
}
function Rename-NoteProperty([object]$Owner, [string]$From, [string]$To) {
$property = @($Owner.PSObject.Properties | Where-Object { $_.Name -ceq $From })[0]
if (-not $property) { throw "mutation setup missing property: $From" }
$value = $property.Value
$Owner.PSObject.Properties.Remove($From)
$Owner.PSObject.Properties.Add([System.Management.Automation.PSNoteProperty]::new($To, $value))
}
function Add-NoteProperty([object]$Owner, [string]$Name, [object]$Value) {
$Owner.PSObject.Properties.Add([System.Management.Automation.PSNoteProperty]::new($Name, $Value))
}
function Assert-MutantRejected(
[object]$Seed,
[string]$Label,
[scriptblock]$Mutate,
[string]$ExpectedIssuePattern
) {
$mutant = Copy-Document $Seed
& $Mutate $mutant
$mutantIssues = @(Get-ContractIssues $mutant)
if ($mutantIssues.Count -eq 0) {
throw "adversarial mutant fake-greened: $Label"
}
if ($ExpectedIssuePattern -and -not (($mutantIssues -join "`n") -match $ExpectedIssuePattern)) {
throw "adversarial mutant rejected for the wrong reason: $Label`n$($mutantIssues -join "`n")"
}
}
try {
$seed = New-ValidDocument
$seedIssues = @(Get-ContractIssues $seed)
if ($seedIssues.Count -gt 0) {
throw "valid zero-issue seed was rejected:`n$($seedIssues -join "`n")"
}
$mutations = @(
@{
Label = 'legacy /feeds/page GET owner'
Pattern = 'legacy duplicate GET owner'
Apply = {
param($doc)
Add-NoteProperty $doc.paths '/genealogy/app/genealogies/{genealogyId}/feeds/page' ([pscustomobject]@{
get = $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
})
}
},
@{
Label = 'operationId drift'
Pattern = 'operationId must be appListFamilyFeeds'
Apply = { param($doc) $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.operationId = 'list_17' }
},
@{
Label = 'Path Item Get keyword casing'
Pattern = 'Path Item keyword casing is invalid'
Apply = {
param($doc)
Rename-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds' 'get' 'Get'
}
},
@{
Label = 'security object instead of array'
Pattern = 'security must be a JSON array'
Apply = {
param($doc)
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.security = [pscustomobject]@{ SaToken = @() }
}
},
@{
Label = 'SaToken key casing'
Pattern = 'must require only exact SaToken'
Apply = {
param($doc)
$requirement = $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.security[0]
Rename-NoteProperty $requirement 'SaToken' 'satoken'
}
},
@{
Label = 'non-empty SaToken scopes'
Pattern = 'SaToken scopes must be an empty JSON array'
Apply = {
param($doc)
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.security[0].SaToken = @('feed:read')
}
},
@{
Label = 'int64 feed identity'
Pattern = 'sole exact local ref #/components/schemas/FamilyFeedId'
Apply = {
param($doc)
$doc.components.schemas.AppFamilyFeedReadItem.properties.feedId = [pscustomobject]@{ type = 'integer'; format = 'int64' }
}
},
@{
Label = 'open feed projection'
Pattern = 'AppFamilyFeedReadItem must be closed'
Apply = { param($doc) $doc.components.schemas.AppFamilyFeedReadItem.additionalProperties = $true }
},
@{
Label = 'phone field leak'
Pattern = 'success graph leaks forbidden/internal field'
Apply = {
param($doc)
Add-NoteProperty $doc.components.schemas.AppFamilyFeedReadItem.properties 'appUserPhone' ([pscustomobject]@{
type = 'string'; nullable = $false
})
$doc.components.schemas.AppFamilyFeedReadItem.required += 'appUserPhone'
}
},
@{
Label = 'moderation field leak'
Pattern = 'success graph leaks forbidden/internal field'
Apply = {
param($doc)
Add-NoteProperty $doc.components.schemas.AppFamilyFeedRootCommentReadItem.properties 'moderationReason' ([pscustomobject]@{
type = 'string'; nullable = $false
})
$doc.components.schemas.AppFamilyFeedRootCommentReadItem.required += 'moderationReason'
}
},
@{
Label = 'offset total in cursor page'
Pattern = 'AppFamilyFeedCursorPage must be closed'
Apply = {
param($doc)
Add-NoteProperty $doc.components.schemas.AppFamilyFeedCursorPage.properties 'total' ([pscustomobject]@{
type = 'integer'; nullable = $false
})
$doc.components.schemas.AppFamilyFeedCursorPage.required += 'total'
}
},
@{
Label = 'pageNum query bypass'
Pattern = 'parameters must be exactly'
Apply = {
param($doc)
$operation = $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
$operation.parameters += [pscustomobject]@{
name = 'pageNum'; in = 'query'; required = $false
schema = [pscustomobject]@{ type = 'integer'; minimum = 1; nullable = $false }
}
}
},
@{
Label = 'cursor scope comma string'
Pattern = 'x-cursor-scope must be the exact JSON array'
Apply = {
param($doc)
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.'x-cursor-scope' = 'tenant,account,authSession'
}
},
@{
Label = 'cross-scope cursor returns 400'
Pattern = 'cursor/refresh/per-page authorization semantics drifted'
Apply = {
param($doc)
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.'x-cross-scope-cursor' = '400_FAMILY_FEED_CURSOR_INVALID'
}
},
@{
Label = '403 resource existence split'
Pattern = 'responses must be exactly'
Apply = {
param($doc)
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}'.get.responses '403' (
New-Response '#/components/schemas/RFamilyFeedReadNotFound'
)
}
},
@{
Label = 'wildcard response media type'
Pattern = 'must expose only application/json'
Apply = {
param($doc)
$response = $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200'
Rename-NoteProperty $response.content 'application/json' '*/*'
}
},
@{
Label = 'missing private cache header'
Pattern = 'must define Cache-Control'
Apply = {
param($doc)
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200'.headers.PSObject.Properties.Remove('Cache-Control')
}
},
@{
Label = 'unbounded Retry-After'
Pattern = 'Retry-After must be a non-null integer in 1..300'
Apply = { param($doc) $doc.components.headers.RetryAfter.schema.maximum = 301 }
},
@{
Label = 'generic list response'
Pattern = '200 schema ref must be #/components/schemas/RAppFamilyFeedCursorPage'
Apply = {
param($doc)
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200'.content.'application/json'.schema.'$ref' = '#/components/schemas/RObject'
}
},
@{
Label = 'external feedId ref'
Pattern = 'must be the sole exact local ref'
Apply = {
param($doc)
$doc.components.schemas.AppFamilyFeedReadItem.properties.feedId.'$ref' = 'https://example.invalid/schemas.json#/FamilyFeedId'
}
},
@{
Label = 'hasMedia removed'
Pattern = 'AppFamilyFeedReadItem must be closed'
Apply = {
param($doc)
$doc.components.schemas.AppFamilyFeedReadItem.properties.PSObject.Properties.Remove('hasMedia')
$doc.components.schemas.AppFamilyFeedReadItem.required = @(
$doc.components.schemas.AppFamilyFeedReadItem.required | Where-Object { $_ -cne 'hasMedia' }
)
}
},
@{
Label = 'feed cursor order drift'
Pattern = 'x-cursor-order must be the exact JSON array'
Apply = {
param($doc)
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.'x-cursor-order' = @('feedId:DESC_ORDINAL', 'publishedAt:DESC')
}
},
@{
Label = 'mutable publishedAt'
Pattern = 'must be a non-null immutable server-generated RFC3339'
Apply = { param($doc) $doc.components.schemas.FamilyFeedPublishedAt.'x-immutable' = $false }
},
@{
Label = 'feed content length drift'
Pattern = 'FamilyFeedContent must be a non-null string length 1..300'
Apply = { param($doc) $doc.components.schemas.FamilyFeedContent.maxLength = 301 }
},
@{
Label = 'comment level internal field'
Pattern = 'success graph leaks forbidden/internal field'
Apply = {
param($doc)
Add-NoteProperty $doc.components.schemas.AppFamilyFeedRootCommentReadItem.properties 'commentLevel' ([pscustomobject]@{
type = 'string'; enum = @('root'); nullable = $false
})
$doc.components.schemas.AppFamilyFeedRootCommentReadItem.required += 'commentLevel'
}
},
@{
Label = 'duplicate operationId'
Pattern = 'operationId must be appListFamilyFeedRootComments|must have exactly one global operation owner'
Apply = {
param($doc)
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments'.get.operationId = 'appListFamilyFeeds'
}
},
@{
Label = 'explicit HEAD read bypass'
Pattern = 'must not expose an explicit HEAD read bypass'
Apply = {
param($doc)
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds' 'head' ([pscustomobject]@{
responses = [pscustomobject]@{}
})
}
},
@{
Label = 'callback side channel'
Pattern = 'must not define callbacks'
Apply = {
param($doc)
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get 'callbacks' ([pscustomobject]@{
leak = [pscustomobject]@{}
})
}
},
@{
Label = 'schema Type keyword casing'
Pattern = 'contains an unowned schema keyword: Type'
Apply = { param($doc) Rename-NoteProperty $doc.components.schemas.FamilyFeedContent 'type' 'Type' }
},
@{
Label = 'nextCursor made required'
Pattern = 'AppFamilyFeedCursorPage must be closed'
Apply = {
param($doc)
$doc.components.schemas.AppFamilyFeedCursorPage.required += 'nextCursor'
}
},
@{
Label = 'alternate APP GET reuses feed read projection'
Pattern = 'alternate APP operation exposes the family-feed read projection'
Apply = {
param($doc)
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
$shadow.operationId = 'shadowFamilyFeedRead'
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow' ([pscustomobject]@{ get = $shadow })
}
},
@{
Label = 'Response Object links side channel'
Pattern = 'contains an unowned Response Object keyword: links'
Apply = {
param($doc)
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200' 'links' ([pscustomobject]@{
next = [pscustomobject]@{ operationId = 'shadowFamilyFeedRead' }
})
}
},
@{
Label = 'Header Object content side channel'
Pattern = 'contains an unowned Header Object keyword: content'
Apply = {
param($doc)
Add-NoteProperty $doc.components.headers.PrivateNoStore 'content' ([pscustomobject]@{
'application/json' = [pscustomobject]@{ schema = [pscustomobject]@{ type = 'string' } }
})
}
},
@{
Label = 'OpenAPI 3.1 dialect drift'
Pattern = 'OpenAPI version must be exact 3.0.1'
Apply = { param($doc) $doc.openapi = '3.1.0' }
},
@{
Label = 'OpenAPI 3.1 webhooks keyword'
Pattern = 'OpenAPI root contains an unowned keyword: webhooks'
Apply = { param($doc) Add-NoteProperty $doc 'webhooks' ([pscustomobject]@{}) }
},
@{
Label = 'OpenAPI Paths keyword casing'
Pattern = 'OpenAPI root keyword casing is invalid: Paths'
Apply = { param($doc) Rename-NoteProperty $doc 'paths' 'Paths' }
},
@{
Label = 'OpenAPI Components keyword casing'
Pattern = 'OpenAPI root keyword casing is invalid: Components'
Apply = { param($doc) Rename-NoteProperty $doc 'components' 'Components' }
},
@{
Label = 'cursor extension keyword casing'
Pattern = 'operation keyword casing is invalid: X-Cursor-Scope'
Apply = {
param($doc)
Rename-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get 'x-cursor-scope' 'X-Cursor-Scope'
}
},
@{
Label = 'cursor scope comma join collision'
Pattern = 'x-cursor-scope must be the exact JSON array'
Apply = {
param($doc)
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.'x-cursor-scope' = @(
'tenant,account', 'authSession', 'client', 'genealogyId', 'projection',
'order', 'limit', 'windowUpperBound', 'lastTuple'
)
}
},
@{
Label = 'Parameter schema keyword casing'
Pattern = 'Parameter Object keyword casing is invalid: Schema'
Apply = {
param($doc)
$parameter = $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.parameters[1]
Rename-NoteProperty $parameter 'schema' 'Schema'
}
},
@{
Label = 'path parameter case-shadow'
Pattern = 'parameters must be exactly'
Apply = {
param($doc)
$pathItem = $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'
Add-NoteProperty $pathItem 'parameters' @(
[pscustomobject]@{
name = 'GenealogyId'
in = 'path'
required = $true
schema = New-Ref '#/components/schemas/GenealogyId'
}
)
}
},
@{
Label = 'Parameter Reference Object sibling'
Pattern = 'parameter ref must contain only its exact local'
Apply = {
param($doc)
Add-NoteProperty $doc.components 'parameters' ([pscustomobject]@{
GenealogyIdParameter = [pscustomobject]@{
name = 'genealogyId'
in = 'path'
required = $true
schema = New-Ref '#/components/schemas/GenealogyId'
}
})
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.parameters[1] = [pscustomobject]@{
'$ref' = '#/components/parameters/GenealogyIdParameter'
description = 'forbidden sibling'
}
}
},
@{
Label = 'Schema Reference Object sibling'
Pattern = 'must be the sole exact local ref'
Apply = {
param($doc)
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.parameters[1].schema 'description' 'forbidden sibling'
}
},
@{
Label = 'SaToken security scheme drift'
Pattern = 'SaToken must be the exact apiKey/header/Authorization security owner'
Apply = {
param($doc)
$doc.components.securitySchemes.SaToken = [pscustomobject]@{
type = 'oauth2'
flows = [pscustomobject]@{}
}
}
},
@{
Label = 'alternate 206 owner'
Pattern = 'alternate APP operation exposes the family-feed read projection'
Apply = {
param($doc)
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
$shadow.operationId = 'shadowFamilyFeed206'
$shadow.responses = [pscustomobject]@{
'206' = New-Response '#/components/schemas/RAppFamilyFeedCursorPage'
}
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-206' ([pscustomobject]@{ get = $shadow })
}
},
@{
Label = 'replies path reuses canonical projection'
Pattern = 'alternate APP operation exposes the family-feed read projection'
Apply = {
param($doc)
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
$shadow.operationId = 'shadowRepliesFamilyFeed'
Add-NoteProperty $doc.paths '/genealogy/app/genealogies/{genealogyId}/replies-shadow' ([pscustomobject]@{ get = $shadow })
}
},
@{
Label = 'alternate HEAD projection owner'
Pattern = 'alternate APP operation exposes the family-feed read projection'
Apply = {
param($doc)
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
$shadow.operationId = 'shadowHeadFamilyFeed'
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-head' ([pscustomobject]@{ head = $shadow })
}
},
@{
Label = 'alternate OPTIONS projection owner'
Pattern = 'alternate APP operation exposes the family-feed read projection'
Apply = {
param($doc)
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
$shadow.operationId = 'shadowOptionsFamilyFeed'
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-options' ([pscustomobject]@{ options = $shadow })
}
},
@{
Label = 'callback GET projection owner'
Pattern = 'alternate APP operation exposes the family-feed read projection'
Apply = {
param($doc)
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
$shadow.operationId = 'shadowCallbackFamilyFeed'
Add-NoteProperty $doc.paths '/genealogy/app/callback-carrier' ([pscustomobject]@{
post = [pscustomobject]@{
operationId = 'callbackCarrier'
responses = [pscustomobject]@{
'204' = [pscustomobject]@{ description = 'accepted' }
}
callbacks = [pscustomobject]@{
leak = [pscustomobject]@{
'{$request.body#/callbackUrl}' = [pscustomobject]@{ get = $shadow }
}
}
}
})
}
},
@{
Label = 'inline alternate projection owner'
Pattern = 'alternate APP operation exposes the family-feed read projection'
Apply = {
param($doc)
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
$shadow.operationId = 'shadowInlineFamilyFeed'
$shadow.responses.'200'.content.'application/json'.schema = Copy-Document $doc.components.schemas.RAppFamilyFeedCursorPage
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-inline' ([pscustomobject]@{ get = $shadow })
}
},
@{
Label = 'two-hop schema alias owner'
Pattern = 'alternate APP operation exposes the family-feed read projection'
Apply = {
param($doc)
Add-NoteProperty $doc.components.schemas 'FamilyFeedAliasTwo' (New-Ref '#/components/schemas/RAppFamilyFeedCursorPage')
Add-NoteProperty $doc.components.schemas 'FamilyFeedAliasOne' (New-Ref '#/components/schemas/FamilyFeedAliasTwo')
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
$shadow.operationId = 'shadowSchemaAliasFamilyFeed'
$shadow.responses.'200'.content.'application/json'.schema = New-Ref '#/components/schemas/FamilyFeedAliasOne'
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-schema-alias' ([pscustomobject]@{ get = $shadow })
}
},
@{
Label = 'two-hop response alias owner'
Pattern = 'alternate APP operation exposes the family-feed read projection'
Apply = {
param($doc)
Add-NoteProperty $doc.components 'responses' ([pscustomobject]@{
FamilyFeedAliasOne = New-Ref '#/components/responses/FamilyFeedAliasTwo'
FamilyFeedAliasTwo = New-Response '#/components/schemas/RAppFamilyFeedCursorPage'
})
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
$shadow.operationId = 'shadowResponseAliasFamilyFeed'
$shadow.responses = [pscustomobject]@{
'200' = New-Ref '#/components/responses/FamilyFeedAliasOne'
}
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-response-alias' ([pscustomobject]@{ get = $shadow })
}
},
@{
Label = 'external alternate response schema'
Pattern = 'schema ref is not an inspectable exact local component ref'
Apply = {
param($doc)
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
$shadow.operationId = 'shadowExternalFamilyFeed'
$shadow.responses.'200'.content.'application/json'.schema = New-Ref 'https://example.invalid/feed.json#/FeedPage'
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-external' ([pscustomobject]@{ get = $shadow })
}
},
@{
Label = 'default response projection owner'
Pattern = 'alternate APP operation exposes the family-feed read projection'
Apply = {
param($doc)
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
$shadow.operationId = 'shadowDefaultFamilyFeed'
$shadow.responses = [pscustomobject]@{
default = New-Response '#/components/schemas/RAppFamilyFeedCursorPage'
}
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-default' ([pscustomobject]@{ get = $shadow })
}
},
@{
Label = 'Response Reference Object sibling'
Pattern = 'response ref must contain only its exact local'
Apply = {
param($doc)
Add-NoteProperty $doc.components 'responses' ([pscustomobject]@{
FeedListSuccess = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200'
})
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200' = [pscustomobject]@{
'$ref' = '#/components/responses/FeedListSuccess'
description = 'forbidden sibling'
}
}
},
@{
Label = 'Header Reference Object sibling'
Pattern = 'header ref must contain only its exact local'
Apply = {
param($doc)
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200'.headers.'Cache-Control' 'description' 'forbidden sibling'
}
},
@{
Label = 'Parameter content side channel'
Pattern = 'Parameter Object contains an unowned keyword: content'
Apply = {
param($doc)
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.parameters[1] 'content' ([pscustomobject]@{
'application/json' = [pscustomobject]@{
schema = New-Ref '#/components/schemas/GenealogyId'
}
})
}
},
@{
Label = 'unknown operation extension'
Pattern = 'operation contains an unowned keyword: x-shadow-owner'
Apply = {
param($doc)
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get 'x-shadow-owner' 'legacy'
}
}
)
foreach ($mutation in $mutations) {
Assert-MutantRejected $seed $mutation.Label $mutation.Apply $mutation.Pattern
}
Write-Output "FAMILY-FEED-READ-OPENAPI-ADVERSARIAL-CONTRACT PASS MUTANTS=$($mutations.Count)"
} finally {
if (Test-Path -LiteralPath $temporaryPath -PathType Leaf) {
Remove-Item -LiteralPath $temporaryPath -Force
}
}
File diff suppressed because it is too large Load Diff
-150
View File
@@ -1,150 +0,0 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const loadMockModule = async () => {
const source = fs.readFileSync(path.join(__dirname, "../data/mock.js"), "utf8");
const contractSource = fs.readFileSync(
path.join(__dirname, "../utils/genealogy-contracts.js"),
"utf8",
);
const contractUrl = `data:text/javascript;base64,${Buffer.from(contractSource).toString("base64")}`;
const moduleUrl = `data:text/javascript;base64,${Buffer.from(source.replace("../utils/genealogy-contracts.js", contractUrl)).toString("base64")}`;
return import(moduleUrl);
};
const assertScopedOwner = ({ list, find, ids, idField = "id", mutationField = "title", nestedField }) => {
assert.deepStrictEqual(list("1001").map((item) => item[idField]), ids);
assert.deepStrictEqual(list("1002"), [], "另一家谱不得读取 1001 的内容夹具");
assert.strictEqual(find("1002", ids[0]), null, "已知 ID 换谱后必须失败关闭");
assert.strictEqual(find("1001", "unknown"), null, "未知实体不得回退首条记录");
assert.strictEqual(find("", ids[0]), null, "缺少 genealogyId 必须失败关闭");
assert.strictEqual(find("1001", ""), null, "缺少实体 ID 必须失败关闭");
const listed = list("1001");
const original = find("1001", ids[0]);
listed[0][mutationField] = "被列表调用方修改";
assert.notStrictEqual(find("1001", ids[0])[mutationField], "被列表调用方修改");
if (nestedField) {
const mutateNested = (item, value) => {
if (item[nestedField][0] && typeof item[nestedField][0] === "object") {
item[nestedField][0].__contractMutation = value;
} else {
item[nestedField][0] = value;
}
};
mutateNested(listed[0], "被列表调用方修改");
assert(!JSON.stringify(find("1001", ids[0])[nestedField]).includes("被列表调用方修改"));
mutateNested(original, "被详情调用方修改");
assert(!JSON.stringify(find("1001", ids[0])[nestedField]).includes("被详情调用方修改"));
}
};
const run = async () => {
const fixtures = await loadMockModule();
const {
listFamilyFeedFixtures,
findFamilyFeedFixture,
listFamilyArticleFixtures,
findFamilyArticleFixture,
listFamilyAlbumFixtures,
findFamilyAlbumFixture,
} = fixtures;
for (const selector of [
listFamilyFeedFixtures,
findFamilyFeedFixture,
listFamilyArticleFixtures,
findFamilyArticleFixture,
listFamilyAlbumFixtures,
findFamilyAlbumFixture,
]) {
assert.strictEqual(typeof selector, "function", "F 系列只读夹具必须由共享查询函数拥有");
}
assertScopedOwner({
list: listFamilyFeedFixtures,
find: findFamilyFeedFixture,
ids: ["1", "2"],
idField: "feedId",
mutationField: "feedContent",
});
assertScopedOwner({
list: listFamilyArticleFixtures,
find: findFamilyArticleFixture,
ids: ["101", "102", "103"],
nestedField: "paragraphs",
});
assertScopedOwner({
list: listFamilyAlbumFixtures,
find: findFamilyAlbumFixture,
ids: ["201", "202", "203"],
nestedField: "photos",
});
for (const item of listFamilyFeedFixtures("1001")) {
assert.strictEqual(typeof item.feedId, "string", "feed.feedId 必须保持词法字符串");
assert.strictEqual(item.genealogyId, "1001", "feed 必须显式持有家谱身份");
}
for (const [list, entityName] of [
[listFamilyArticleFixtures, "article"],
[listFamilyAlbumFixtures, "album"],
]) {
for (const item of list("1001")) {
assert.strictEqual(typeof item.id, "string", `${entityName}.id 必须保持词法字符串`);
assert.strictEqual(item.genealogyId, "1001", `${entityName} 必须显式持有家谱身份`);
}
}
const firstFeed = findFamilyFeedFixture("1001", "1");
firstFeed.feedContent = "被详情调用方修改";
assert.notStrictEqual(
findFamilyFeedFixture("1001", "1").feedContent,
"被详情调用方修改",
);
globalThis.__familyFixtureOwner = fixtures;
const apiSource = fs
.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8")
.replace(
/^import \{[\s\S]*?\} from '@\/data\/mock\.js'\r?\n/,
"const { currentUser, genealogies, publicGenealogies, treeMembers, notifications, joinApplications, listFamilyFeedFixtures, listFamilyArticleFixtures, listFamilyAlbumFixtures, listCeremonyFixtures, listGrowthRecordFixtures } = globalThis.__familyFixtureOwner;\n",
)
.replace(
/^import \{ hasRemoteConfig[^\n]+\r?\n/m,
"const hasRemoteConfig = () => false; const resolveRuntimeMode = () => 'mock'; const runtimeConfig = { baseUrl: '', clientId: 'test', tenantId: 'test' };\n",
)
.replace(
/^import \{ AUTH_VERIFICATION_OPERATION, assertSmsCode \}[^\n]+\r?\n/m,
"const AUTH_VERIFICATION_OPERATION = {}; const assertSmsCode = (value) => value;\n",
)
.replace(
/^import \{ GENEALOGY_ACCESS_PRESET, fromApiGenealogyAccess \}[^\n]+\r?\n/m,
"const GENEALOGY_ACCESS_PRESET = { MEMBER_ONLY: 'MEMBER_ONLY' }; const fromApiGenealogyAccess = () => GENEALOGY_ACCESS_PRESET.MEMBER_ONLY;\n",
)
.replace(
/^import \{ session \}[^\n]+\r?\n/m,
"const session = { getToken: () => '', saveToken: () => {} };\n",
);
const apiUrl = `data:text/javascript;base64,${Buffer.from(apiSource).toString("base64")}`;
const { appApi } = await import(apiUrl);
assert.deepStrictEqual((await appApi.getFeeds("1001")).map((item) => item.id), ["1", "2"]);
assert.deepStrictEqual((await appApi.getArticles("1001")).map((item) => item.id), ["101", "102", "103"]);
assert.deepStrictEqual((await appApi.getAlbums("1001")).map((item) => item.id), ["201", "202", "203"]);
assert.deepStrictEqual(await appApi.getFeeds("1002"), []);
await assert.rejects(
appApi.createFeed("1001", { feedContent: "不得写入正式夹具" }),
(error) => error?.code === "WRITE_UNAVAILABLE" && /不会保存/.test(error.message),
"mock createFeed 必须明确失败,不能污染只读内容 owner",
);
assert.deepStrictEqual(listFamilyFeedFixtures("1001").map((item) => item.feedId), ["1", "2"]);
process.stdout.write("FAMILY-FIXTURE-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
-203
View File
@@ -1,203 +0,0 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const toDataModuleUrl = (source) =>
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const run = async () => {
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
const prelude = `
const currentUser = {};
const genealogies = [];
const publicGenealogies = [];
const treeMembers = [];
const notifications = [];
const joinApplications = [];
const listFamilyFeedFixtures = () => [];
const listFamilyArticleFixtures = () => [];
const listFamilyAlbumFixtures = () => [];
const listCeremonyFixtures = () => [];
const listGrowthRecordFixtures = () => [];
const listNotificationFixtures = () => [];
const runtimeConfig = {
mode: "remote",
baseUrl: "https://backend-api.ddxcjp.cn",
clientId: "client-1",
tenantId: "000000",
};
const hasRemoteConfig = () => globalThis.__runtimeMode === "remote";
const resolveRuntimeMode = () => {
if (globalThis.__runtimeMode === "mock") return "mock";
if (globalThis.__runtimeMode === "remote") return "remote";
throw new Error("运行模式配置无效:只允许 mock 或配置完整的 remote");
};
const AUTH_TAC_SCENE = Object.freeze({
SMS_LOGIN: "APP_SMS_LOGIN",
REGISTER: "APP_REGISTER",
FORGOT_PASSWORD: "APP_FORGOT_PASSWORD",
});
const assertSmsCode = (value) => value;
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" });
const session = {
getToken: () => "session-token",
saveToken: () => {},
};
`;
globalThis.__runtimeMode = "remote";
const requests = [];
let nextResponse = null;
let nextFailure = null;
let holdResponse = false;
globalThis.uni = {
request(options) {
requests.push(options);
const task = {
aborted: false,
abort() {
this.aborted = true;
options.fail({ errMsg: "request:fail abort" });
},
};
options.__task = task;
if (!holdResponse) {
queueMicrotask(() => {
if (nextFailure) options.fail(nextFailure);
else options.success(nextResponse);
});
}
return task;
},
};
const { appApi, createRequestController, isRequestCancelled } = await import(
toDataModuleUrl(`${prelude}\n${moduleBody}`),
);
const submit = (payload = {
feedbackType: "bug",
feedbackContent: "上传照片时出现异常",
contactInfo: "user@example.com",
}) => appApi.submitFeedback(payload);
for (const invalid of [
null,
[],
{},
{ feedbackContent: " " },
{ feedbackContent: 123 },
{ feedbackContent: "内容", feedbackType: 1 },
{ feedbackContent: "内容", feedbackType: "feature" },
{ feedbackContent: "内容", contactInfo: false },
{ feedbackContent: "内容", type: "旧字段" },
Object.assign(Object.create({ feedbackType: "继承字段" }), { feedbackContent: "内容" }),
]) {
await assert.rejects(submit(invalid), /反馈|字段|对象|字符串|feedbackType/);
}
nextResponse = { statusCode: 200, data: { code: 200, msg: "成功", data: { accepted: true } } };
const contentOnlyResult = await submit({ feedbackContent: " 仅反馈内容 " });
assert.deepStrictEqual(contentOnlyResult, { accepted: true });
assert.deepStrictEqual(requests.at(-1).data, { feedbackContent: "仅反馈内容" });
nextResponse = { statusCode: 200, data: { code: 200, msg: "成功", data: { feedbackId: 1 } } };
const result = await submit({ feedbackContent: " 有效反馈 ", feedbackType: " advice ", contactInfo: " " });
assert.deepStrictEqual(result, { feedbackId: 1 });
assert.deepStrictEqual(requests.at(-1).data, {
feedbackContent: "有效反馈",
feedbackType: "advice",
});
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/feedback");
assert.strictEqual(requests.at(-1).method, "POST");
assert.strictEqual(requests.at(-1).timeout, 15000);
assert.strictEqual(requests.at(-1).header.clientid, "client-1");
assert.strictEqual(requests.at(-1).header.Authorization, "Bearer session-token");
nextResponse = { statusCode: 200, data: { code: 500, msg: "业务拒绝", data: null } };
await assert.rejects(submit(), (error) =>
error.code === "BUSINESS_ERROR" && error.businessCode === 500,
);
nextResponse = { statusCode: 401, data: { code: 401, msg: "登录失效", data: null } };
await assert.rejects(submit(), (error) =>
error.code === "HTTP_ERROR" && error.httpStatus === 401,
);
for (const statusCode of [201, 204]) {
nextResponse = { statusCode, data: { code: 200, msg: "成功", data: null } };
await assert.rejects(submit(), (error) =>
error.code === "HTTP_ERROR" && error.httpStatus === statusCode,
);
}
for (const invalidResponse of [
{ statusCode: 200, data: null },
{ statusCode: 200, data: {} },
{ statusCode: 200, data: { code: "200", data: null } },
{ statusCode: 200, data: { code: null, data: null } },
{ statusCode: 200, data: { code: false, data: null } },
]) {
nextResponse = invalidResponse;
await assert.rejects(submit(), (error) => error.code === "RESPONSE_INVALID");
}
nextResponse = { statusCode: 200, data: { code: 200, msg: "成功" } };
assert.deepStrictEqual(
await submit({ feedbackContent: "响应无需 data" }),
{ code: 200, msg: "成功" },
"反馈响应未声明 data 必填,不得把合法成功误报为未知",
);
const requestCountBeforeUnsafeOptions = requests.length;
await assert.rejects(
appApi.submitFeedback(
{ feedbackContent: "不得关闭认证头" },
{ authenticated: false },
),
/请求选项|字段/,
);
assert.strictEqual(requests.length, requestCountBeforeUnsafeOptions);
nextFailure = { errMsg: "request:fail timeout" };
await assert.rejects(submit(), (error) => error.code === "REQUEST_TIMEOUT");
nextFailure = null;
holdResponse = true;
const controller = createRequestController();
const cancelled = appApi.submitFeedback(
{ feedbackContent: "离页取消" },
{ requestController: controller },
);
const activeTask = requests.at(-1).__task;
controller.abort();
await assert.rejects(cancelled, (error) => isRequestCancelled(error));
assert.strictEqual(activeTask.aborted, true);
holdResponse = false;
globalThis.__runtimeMode = "mock";
const requestCount = requests.length;
await assert.rejects(
appApi.submitFeedback({ feedbackContent: "本地不得伪提交" }),
(error) => error.code === "WRITE_UNAVAILABLE",
);
assert.strictEqual(requests.length, requestCount, "mock 模式不得发送或伪造反馈请求");
globalThis.__runtimeMode = "invalid";
await assert.rejects(
appApi.submitFeedback({ feedbackContent: "非法环境不得降级成 mock" }),
/运行模式配置无效/,
);
assert.strictEqual(requests.length, requestCount, "非法环境不得发送请求或伪装成本地预览");
delete globalThis.uni;
delete globalThis.__runtimeMode;
process.stdout.write("FEEDBACK-API-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
-41
View File
@@ -1,41 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$documents = @(Get-ChildItem -LiteralPath $root -File -Filter '*.openapi.json')
if ($documents.Count -ne 1) { throw 'Exactly one current OpenAPI JSON export is required at the repository root' }
$json = Get-Content -Raw -Encoding UTF8 -LiteralPath $documents[0].FullName | ConvertFrom-Json
$path = '/genealogy/app/feedback'
$pathProperty = $json.paths.PSObject.Properties[$path]
if (-not $pathProperty) { throw 'Feedback POST path is missing' }
$operation = $pathProperty.Value.post
if (-not $operation) { throw 'Feedback POST operation is missing' }
if ($operation.requestBody.required -ne $true) { throw 'Feedback request body must be required' }
if ($operation.requestBody.content.'application/json'.schema.'$ref' -ne '#/components/schemas/FeedbackBody') {
throw 'Feedback operation must consume FeedbackBody'
}
$hasSaToken = $false
foreach ($securityRequirement in @($operation.security)) {
if ($securityRequirement.PSObject.Properties.Name -contains 'SaToken') { $hasSaToken = $true }
}
if (-not $hasSaToken) { throw 'Feedback operation must require SaToken' }
$body = $json.components.schemas.FeedbackBody
$fields = @($body.properties.PSObject.Properties.Name | Sort-Object)
if (($fields -join ',') -ne 'contactInfo,feedbackContent,feedbackType') {
throw "FeedbackBody fields drifted: $($fields -join ',')"
}
$required = @($body.required | Sort-Object)
if (($required -join ',') -ne 'feedbackContent') {
throw "FeedbackBody required fields drifted: $($required -join ',')"
}
foreach ($field in $fields) {
if ($body.properties.$field.type -ne 'string') {
throw "FeedbackBody.$field must be a string"
}
}
if ((@($body.properties.feedbackType.enum) -join ',') -ne 'advice,bug,complaint,other') {
throw 'FeedbackBody.feedbackType must use advice,bug,complaint,other'
}
Write-Output 'FEEDBACK-OPENAPI-CONTRACT PASS'
-161
View File
@@ -1,161 +0,0 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const toDataModuleUrl = (source) =>
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const run = async () => {
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
const prelude = `
const currentUser = {};
const genealogies = [];
const publicGenealogies = [];
const treeMembers = [];
const notifications = [];
const joinApplications = [];
const listFamilyFeedFixtures = () => [];
const listFamilyArticleFixtures = () => [];
const listFamilyAlbumFixtures = () => [];
const listCeremonyFixtures = () => [];
const listGrowthRecordFixtures = () => [];
const listNotificationFixtures = () => [];
const runtimeConfig = { baseUrl: "https://backend-api.ddxcjp.cn", clientId: "client-1", tenantId: "000000" };
const hasRemoteConfig = () => true;
const resolveRuntimeMode = () => "remote";
const AUTH_VERIFICATION_OPERATION = Object.freeze({});
const assertSmsCode = (value) => value;
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" });
const fromApiGenealogyAccess = () => null;
const session = { getToken: () => "session-1", saveToken() {} };
`;
const requests = [];
let response;
globalThis.uni = {
request(options) {
requests.push(options);
queueMicrotask(() => options.success(response));
return { abort() {} };
},
};
const uploads = [];
globalThis.uni.uploadFile = (options) => {
const task = { abort() {} };
uploads.push({ options, task });
queueMicrotask(() => options.success({ statusCode: 200, data: JSON.stringify({ code: 200, data: null }) }));
return task;
};
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
const basePayload = {
uploadId: "upload-1",
fileName: "avatar.png",
fileMd5: "a".repeat(32),
totalSize: 1024,
totalChunks: 1,
chunkSize: 1024,
contentType: "image/png",
};
const completePayload = {
uploadId: "upload-1",
fileName: basePayload.fileName,
fileMd5: basePayload.fileMd5,
totalSize: basePayload.totalSize,
totalChunks: basePayload.totalChunks,
};
response = { statusCode: 200, data: { code: 200, data: { uploadId: null, instant: true, ossId: 900001 } } };
assert.deepStrictEqual(await appApi.initializeResumableUpload(basePayload), {
uploadId: null,
instant: true,
ossId: "900001",
url: "",
fileName: "",
});
assert.deepStrictEqual(requests.at(-1).data, basePayload);
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/files/resumable/init");
response = { statusCode: 200, data: { code: 200, data: { uploadId: null, instant: false, ossId: null } } };
await assert.rejects(
appApi.initializeResumableUpload(basePayload),
/uploadId/,
);
await appApi.uploadResumableChunk({
uploadId: "upload-1",
chunkIndex: 0,
chunkMd5: "b".repeat(32),
filePath: "/storage/emulated/0/avatar.png",
});
assert.strictEqual(uploads.length, 1);
assert.strictEqual(uploads[0].options.url, "https://backend-api.ddxcjp.cn/genealogy/app/files/resumable/chunk");
assert.strictEqual(uploads[0].options.filePath, "/storage/emulated/0/avatar.png");
assert.strictEqual(uploads[0].options.name, "file");
assert.strictEqual(uploads[0].options.timeout, 15000);
assert.deepStrictEqual(uploads[0].options.header, { clientid: "client-1", tenantId: "000000", Authorization: "Bearer session-1" });
assert.deepStrictEqual(uploads[0].options.formData, { uploadId: "upload-1", chunkIndex: "0", chunkMd5: "b".repeat(32) });
globalThis.uni.uploadFile = (options) => {
queueMicrotask(() => options.fail({ errMsg: "uploadFile:fail timeout" }));
return { abort() {} };
};
await assert.rejects(appApi.uploadResumableChunk({
uploadId: "upload-1",
chunkIndex: 0,
chunkMd5: "b".repeat(32),
filePath: "/storage/emulated/0/avatar.png",
}), (error) => error.code === "REQUEST_TIMEOUT");
const browserRequests = [];
class TestFormData {
entries = [];
append(name, value, fileName) { this.entries.push({ name, value, fileName }); }
}
globalThis.FormData = TestFormData;
globalThis.fetch = async (url, options) => {
browserRequests.push({ url, options });
return { status: 200, text: async () => JSON.stringify({ code: 200, data: null }) };
};
await appApi.uploadBrowserResumableChunk({
uploadId: "upload-1",
chunkIndex: 0,
chunkMd5: "b".repeat(32),
}, { name: "avatar.png" });
assert.strictEqual(browserRequests[0].url, "https://backend-api.ddxcjp.cn/genealogy/app/files/resumable/chunk");
assert.deepStrictEqual(browserRequests[0].options.body.entries, [
{ name: "uploadId", value: "upload-1", fileName: undefined },
{ name: "chunkIndex", value: "0", fileName: undefined },
{ name: "chunkMd5", value: "b".repeat(32), fileName: undefined },
{ name: "file", value: { name: "avatar.png" }, fileName: "avatar.png" },
]);
response = { statusCode: 200, data: { code: 200, data: { ossId: "900001", url: "https://oss.example/avatar.png", thumbnailUrl: "", fileName: "avatar.png" } } };
assert.deepStrictEqual(await appApi.completeResumableUpload(completePayload), {
ossId: "900001",
url: "https://oss.example/avatar.png",
thumbnailUrl: "",
fileName: "avatar.png",
});
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/files/resumable/complete");
await assert.rejects(
appApi.initializeResumableUpload({ ...basePayload, totalSize: 0 }),
/totalSize/,
);
await assert.rejects(
appApi.uploadResumableChunk({ uploadId: "upload-1", chunkIndex: -1, chunkMd5: "b".repeat(32), filePath: "/x" }),
/chunkIndex/,
);
delete globalThis.uni;
delete globalThis.fetch;
delete globalThis.FormData;
process.stdout.write("FILE-UPLOAD-API-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
@@ -1,13 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$header = [System.IO.File]::ReadAllText((Join-Path $root 'components/PageHeader.vue'), [System.Text.Encoding]::UTF8)
$tabbar = [System.IO.File]::ReadAllText((Join-Path $root 'components/AppTabbar.vue'), [System.Text.Encoding]::UTF8)
$global = [System.IO.File]::ReadAllText((Join-Path $root 'styles/global.scss'), [System.Text.Encoding]::UTF8)
if ($header -notmatch 'class="page-header-slot"') { throw 'PageHeader must reserve normal-flow space for the fixed header' }
if ($header -notmatch '(?s)\.page-header\s*\{[^}]*position\s*:\s*fixed\s*;[^}]*top\s*:\s*0\s*;[^}]*right\s*:\s*0\s*;[^}]*left\s*:\s*0\s*;') { throw 'PageHeader must be fixed to the top viewport edge' }
if ($header -notmatch '(?s)\.page-header\s*\{[^}]*transform\s*:\s*translateZ\(0\)\s*;') { throw 'PageHeader must own a stable compositor layer above native media while scrolling' }
if ($global -notmatch '(?s)view:not\(\.page-header-slot\):has\(> \.page-header-slot\)\s*\{[^}]*position\s*:\s*relative\s*;[^}]*z-index\s*:\s*31\s*;') { throw 'Every direct PageHeader host must outrank later page content stacking contexts' }
if ($tabbar -notmatch '(?s)\.app-tabbar\s*\{[^}]*position\s*:\s*fixed\s*;[^}]*right\s*:\s*0\s*;[^}]*bottom\s*:\s*0\s*;[^}]*left\s*:\s*0\s*;') { throw 'AppTabbar must remain fixed to the bottom viewport edge' }
Write-Output 'FIXED-NAVIGATION-POSITION-CONTRACT PASS'
-62
View File
@@ -1,62 +0,0 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const toDataModuleUrl = (source) =>
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const run = async () => {
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
const prelude = `
const currentUser = {};
const genealogies = [];
const publicGenealogies = [];
const notifications = [];
const joinApplications = [];
const listFamilyFeedFixtures = () => [];
const listFamilyArticleFixtures = () => [];
const listFamilyAlbumFixtures = () => [];
const listCeremonyFixtures = () => [];
const listGrowthRecordFixtures = () => [];
const listNotificationFixtures = () => [];
const runtimeConfig = { baseUrl: "https://backend-api.ddxcjp.cn", clientId: "client-1", tenantId: "000000" };
const hasRemoteConfig = () => true;
const resolveRuntimeMode = () => "remote";
const AUTH_VERIFICATION_OPERATION = Object.freeze({});
const assertSmsCode = (value) => value;
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" });
const fromApiGenealogyAccess = () => null;
const session = { getToken: () => "session-1", saveToken() {} };
`;
const requests = [];
globalThis.uni = {
request(options) {
requests.push(options);
queueMicrotask(() => options.success({ statusCode: 200, data: { code: 200, data: {} } }));
return { abort() {} };
},
};
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
await appApi.updateProfile({ sex: "2" });
assert.strictEqual(requests.at(-1).data.sex, "2");
await assert.rejects(appApi.updateProfile({ sex: "男" }), /sex 必须为 0、1 或 2/);
await appApi.submitFeedback({ feedbackType: "bug", feedbackContent: "保存时出现错误" });
assert.strictEqual(requests.at(-1).data.feedbackType, "bug");
await assert.rejects(
appApi.submitFeedback({ feedbackType: "功能问题", feedbackContent: "保存时出现错误" }),
/feedbackType 必须为 advice、bug、complaint 或 other/,
);
delete globalThis.uni;
process.stdout.write("FORM-ENUM-API-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
-37
View File
@@ -1,37 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$errors = [System.Collections.Generic.List[string]]::new()
$sourceFiles = Get-ChildItem -LiteralPath (Join-Path $root 'pages'), (Join-Path $root 'components') -Recurse -File |
Where-Object { $_.Extension -in '.vue', '.js', '.scss' }
foreach ($sourceFile in $sourceFiles) {
$content = Get-Content -Raw -Encoding UTF8 $sourceFile.FullName
$matches = [regex]::Matches($content, '/static/assets/[^"''\s<]+')
foreach ($match in $matches) {
$assetUrl = $match.Value
if ($assetUrl.Contains('${')) { continue }
$assetPath = Join-Path $root $assetUrl.TrimStart('/')
$relativeSource = $sourceFile.FullName.Substring($root.Length + 1)
if (-not (Test-Path -LiteralPath $assetPath)) {
$errors.Add("Missing asset $assetUrl referenced by $relativeSource")
continue
}
if ($assetUrl -notmatch '^/static/assets/(foundation|modules)/') {
$errors.Add("Legacy asset path $assetUrl referenced by $relativeSource")
}
if ($assetUrl -match '-source\.') {
$errors.Add("Temporary asset name $assetUrl referenced by $relativeSource")
}
}
}
if ($errors.Count -gt 0) {
throw "Foundation asset audit failed:`n$($errors -join "`n")"
}
Write-Output 'PASS foundation asset audit'
-65
View File
@@ -1,65 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$errors = [System.Collections.Generic.List[string]]::new()
$pageCommentPrefix = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('PCEtLSDpobXpnaLnvJblj7fvvJo='))
$pages = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages.json') | ConvertFrom-Json
$registeredPaths = @($pages.pages | ForEach-Object { $_.path })
function Test-ExpectedPage {
param(
[string]$ExpectedPath,
[string]$PageId
)
$pageFile = Join-Path $root ($expectedPath + '.vue')
if ($registeredPaths -notcontains $expectedPath) {
$errors.Add("Missing final route: $expectedPath")
}
if (-not (Test-Path -LiteralPath $pageFile)) {
$errors.Add("Missing final page file: $($expectedPath).vue")
return
}
$content = Get-Content -Raw -Encoding UTF8 $pageFile
if (-not $content.StartsWith($pageCommentPrefix + $pageId)) {
$errors.Add("Missing Chinese page header: $($expectedPath).vue")
}
}
function Test-AssetDirectory {
param([string]$AssetDirectory)
if (-not (Test-Path -LiteralPath (Join-Path $root $assetDirectory))) {
$errors.Add("Missing foundation asset directory: $assetDirectory")
}
}
# P-00 final routes: each file name identifies the page and its design ID.
Test-ExpectedPage -ExpectedPath 'pages/auth/a01-entry' -PageId 'A-01'
Test-ExpectedPage -ExpectedPath 'pages/genealogy/g01-my-genealogies' -PageId 'G-01'
Test-ExpectedPage -ExpectedPath 'pages/genealogy/g03-create-genealogy' -PageId 'G-03'
Test-ExpectedPage -ExpectedPath 'pages/genealogy/g05-genealogy-overview' -PageId 'G-05'
Test-ExpectedPage -ExpectedPath 'pages/genealogy/g06-search-genealogies' -PageId 'G-06'
Test-ExpectedPage -ExpectedPath 'pages/genealogy/g10-application-review' -PageId 'G-10'
Test-ExpectedPage -ExpectedPath 'pages/tree/t01-tree-overview' -PageId 'T-01'
Test-ExpectedPage -ExpectedPath 'pages/tree/t03-member-profile' -PageId 'T-03'
Test-ExpectedPage -ExpectedPath 'pages/family/f01-family-feed' -PageId 'F-01'
Test-ExpectedPage -ExpectedPath 'pages/family/f02-publish-feed' -PageId 'F-02'
Test-ExpectedPage -ExpectedPath 'pages/notification/n01-message-center' -PageId 'N-01'
Test-ExpectedPage -ExpectedPath 'pages/profile/m01-profile-home' -PageId 'M-01'
if ($registeredPaths -contains 'pages/genealogy/g04-first-ancestor') {
$errors.Add('G04 must be represented by the G03 ancestor state, not a final route')
}
# Both asset directories are required so opaque bases and transparent overlays stay separate.
Test-AssetDirectory -AssetDirectory 'static/assets/foundation/opaque'
Test-AssetDirectory -AssetDirectory 'static/assets/foundation/transparent'
if ($errors.Count -gt 0) {
throw "P-00 foundation structure audit failed:`n$($errors -join "`n")"
}
Write-Output 'PASS foundation structure audit'
-41
View File
@@ -1,41 +0,0 @@
$ErrorActionPreference = 'Stop'
function ConvertFrom-Utf8Base64 {
param([string]$Value)
return [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Value))
}
$root = Split-Path -Parent $PSScriptRoot
$pagesPath = Join-Path $root 'pages.json'
$pageHeader = ConvertFrom-Utf8Base64 '6aG16Z2i57yW5Y+377ya'
$expectedPages = @(
'pages/auth/a01-entry', 'pages/auth/a04-register', 'pages/auth/a05-reset-password',
'pages/genealogy/g01-my-genealogies', 'pages/genealogy/g03-create-genealogy', 'pages/genealogy/g05-genealogy-overview', 'pages/genealogy/g06-search-genealogies', 'pages/genealogy/g08-join-application', 'pages/genealogy/g09-my-applications', 'pages/genealogy/g10-application-review', 'pages/genealogy/g11-genealogy-settings', 'pages/genealogy/g12-generation-poems',
'pages/tree/t01-tree-overview', 'pages/tree/t03-member-profile', 'pages/tree/t04-add-relative', 'pages/tree/t05-edit-member', 'pages/tree/t06-edit-relationship', 'pages/tree/t07-member-directory', 'pages/tree/t08-member-states',
'pages/family/f01-family-feed', 'pages/family/f02-publish-feed', 'pages/family/f03-feed-detail', 'pages/family/f04-article-list', 'pages/family/f05-article-detail', 'pages/family/f06-article-editor', 'pages/family/f07-album-list', 'pages/family/f08-album-detail', 'pages/family/f09-media-upload', 'pages/family/f10-video-list',
'pages/records/r01-people-list', 'pages/records/r02-person-detail', 'pages/records/r03-gift-list', 'pages/records/r04-gift-editor', 'pages/records/r05-ritual-list', 'pages/records/r06-ritual-detail', 'pages/records/r07-ritual-editor', 'pages/records/r08-growth-journal', 'pages/records/r09-life-events', 'pages/records/r10-memo-list', 'pages/records/r11-merit-records',
'pages/notification/n01-message-center', 'pages/notification/n02-message-detail',
'pages/profile/m01-profile-home', 'pages/profile/m02-edit-profile', 'pages/profile/m03-security-settings', 'pages/profile/m04-change-password', 'pages/profile/m05-change-phone', 'pages/profile/m06-help-center', 'pages/profile/m07-feedback', 'pages/profile/m08-promotion', 'pages/profile/m09-vip-orders', 'pages/profile/m10-about-settings'
)
$pages = Get-Content -LiteralPath $pagesPath -Raw -Encoding utf8 | ConvertFrom-Json
$actualPaths = @($pages.pages.path)
if ($actualPaths.Count -ne 52) {
throw "Expected 52 active visual routes after the approved page-state merges and A06 archive, found $($actualPaths.Count)."
}
if (Compare-Object -ReferenceObject $expectedPages -DifferenceObject $actualPaths) {
throw 'pages.json routes do not match the 52-route active visual delivery contract.'
}
foreach ($page in $expectedPages) {
$pageFile = Join-Path $root ($page + '.vue')
if (-not (Test-Path -LiteralPath $pageFile)) {
throw "Missing planned page file: $page.vue"
}
$content = Get-Content -LiteralPath $pageFile -Raw -Encoding utf8
if ($content -notmatch [regex]::Escape($pageHeader)) {
throw "Missing Chinese page header: $page.vue"
}
}
Write-Output 'FULL-PAGE-VISUAL-CONTRACT PASS'
-41
View File
@@ -1,41 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
function Read-Page([string]$Path) {
Get-Content -LiteralPath (Join-Path $root $Path) -Raw -Encoding utf8
}
function Assert-Match {
param([string]$Content, [string]$Pattern, [string]$Message)
if ($Content -notmatch $Pattern) { throw $Message }
}
$pages = @{
G05 = Read-Page 'pages/genealogy/g05-genealogy-overview.vue'
G06 = Read-Page 'pages/genealogy/g06-search-genealogies.vue'
G09 = Read-Page 'pages/genealogy/g09-my-applications.vue'
G10 = Read-Page 'pages/genealogy/g10-application-review.vue'
G11 = Read-Page 'pages/genealogy/g11-genealogy-settings.vue'
G12 = Read-Page 'pages/genealogy/g12-generation-poems.vue'
}
foreach ($entry in $pages.GetEnumerator()) {
Assert-Match -Content $entry.Value -Pattern 'import AppLoading from [''"]@/components/AppLoading\.vue[''"]' -Message "$($entry.Key) must import AppLoading"
}
Assert-Match -Content $pages.G05 -Pattern 'v-else-if="overviewState === ''loading''"\s+class="overview-state overview-state--loading"' -Message 'G05 must retain its transparent framed loading branch'
Assert-Match -Content $pages.G05 -Pattern '(?s)<AppLoading\s+[^>]*description="[^"]+"[^>]*/>' -Message 'G05 must render AppLoading with a loading description'
Assert-Match -Content $pages.G06 -Pattern '<AppLoading\s+v-if="searchState === ''loading''"\s+variant="section"[^>]+description="[^"]+"\s*/>' -Message 'G06 search results must render the section AppLoading variant'
Assert-Match -Content $pages.G06 -Pattern 'query\?\.state\s*===\s*["'']loading["'']' -Message 'G06 must expose its native section loading state for visual review'
Assert-Match -Content $pages.G09 -Pattern '<AppLoading\s+v-else-if="applicationState === ''loading''"[^>]+description="[^"]+"\s*/>' -Message 'G09 must render a page AppLoading branch'
Assert-Match -Content $pages.G10 -Pattern '<AppLoading\s+v-else-if="reviewState === ''loading''"[^>]+description="[^"]+"\s*/>' -Message 'G10 must render a page AppLoading branch'
Assert-Match -Content $pages.G11 -Pattern '<AppLoading\s+v-if="settingsState === ''loading''"[^>]+description="[^"]+"\s*/>' -Message 'G11 must render a page AppLoading branch'
Assert-Match -Content $pages.G12 -Pattern '<AppLoading\s+v-if="poemState === ''loading''"[^>]+description="[^"]+"\s*/>' -Message 'G12 must render a page AppLoading branch'
Assert-Match -Content $pages.G10 -Pattern 'query\.state\s*===\s*["'']loading["'']' -Message 'G10 must preserve its loading presentation state'
Assert-Match -Content $pages.G11 -Pattern 'query\.state\s*===\s*["'']loading["'']\s*\?\s*["'']loading["'']' -Message 'G11 must preserve its loading presentation state'
Assert-Match -Content $pages.G12 -Pattern 'query\.state\s*===\s*["'']loading["'']\s*\?\s*["'']loading["'']' -Message 'G12 must preserve its loading presentation state'
if ($pages.G06 -match 'class="search-status search-loading"') { throw 'G06 must not retain its duplicated text-only search loader' }
Write-Output 'G-SERIES-APP-LOADING-CONTRACT PASS'
-28
View File
@@ -1,28 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$jsonPath = Join-Path $root 'APP.openapi.json'
$yamlPath = Join-Path $root 'APP.openapi.yaml'
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath $jsonPath | ConvertFrom-Json
$yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath $yamlPath
foreach ($path in @(
'/genealogy/app/genealogies',
'/genealogy/app/genealogies/{genealogyId}'
)) {
if (-not $document.paths.PSObject.Properties[$path]) {
throw "G-series OpenAPI path missing: $path"
}
if (-not $yaml.Contains(" $path`:")) {
throw "G-series YAML path missing: $path"
}
}
# G03 原子创建、共享访问预设与旧 DTO 删除只由 g03-bootstrap-openapi-contract.ps1 管理。
# G11 设置 PUT、dirty-only body、版本 CAS、响应与错误只由 g11-settings-openapi-contract.ps1 管理。
# 普通加入申请只由 join-application-openapi-contract.ps1 管理;邀请码直入只由 invite-ticket-openapi-contract.ps1 管理。
# G12 字辈聚合读写、词法行身份、版本 CAS、候选集合与旧批量入口删除只由
# g12-generation-poem-openapi-contract.ps1 管理。本通用门禁不得重新锁定 poemText、0/1 状态、
# integer/int64 poemId、preview、batch/save、management 或逐行写入口,避免形成相反合同 owner。
Write-Output 'G-SERIES-OPENAPI-CONTRACT PASS'
-35
View File
@@ -1,35 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
Add-Type -AssemblyName System.Drawing
$assets = @(
'static/assets/foundation/transparent/meta-location.png',
'static/assets/foundation/transparent/meta-member.png',
'static/assets/foundation/transparent/meta-admin.png',
'static/assets/modules/genealogy/transparent/current-seal-frame.png',
'static/assets/modules/genealogy/transparent/row-seal-frame.png',
'static/assets/modules/genealogy/transparent/create-cloud.png'
)
foreach ($asset in $assets) {
$bitmap = [System.Drawing.Bitmap]::FromFile((Join-Path $root $asset))
$width = [int]$bitmap.Width
$height = [int]$bitmap.Height
$corners = @(
$bitmap.GetPixel(0, 0).A,
$bitmap.GetPixel(($width - 1), 0).A,
$bitmap.GetPixel(0, ($height - 1)).A,
$bitmap.GetPixel(($width - 1), ($height - 1)).A
)
$visiblePixels = 0
for ($y = 0; $y -lt $height; $y++) {
for ($x = 0; $x -lt $width; $x++) {
if ($bitmap.GetPixel($x, $y).A -gt 24) { $visiblePixels++ }
}
}
$bitmap.Dispose()
if (($corners | Where-Object { $_ -ne 0 }).Count -ne 0) { throw "$asset has opaque outer corners." }
if ($visiblePixels -lt 80) { throw "$asset has no usable visible artwork." }
Write-Output "PASS $asset transparent corners and visible artwork"
}
-23
View File
@@ -1,23 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$page = [System.IO.File]::ReadAllText((Join-Path $root 'pages/genealogy/g01-my-genealogies.vue'), [System.Text.Encoding]::UTF8)
foreach ($expected in @(
'@include adaptive-genealogy-current-slip',
'@include adaptive-genealogy-list-card',
'@include adaptive-genealogy-state-panel',
'background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png") center / contain no-repeat',
'grid-template-columns: minmax(0, 1fr) 96rpx'
)) {
if (-not $page.Contains($expected)) { throw "G01 document-flow contract missing: $expected" }
}
foreach ($layer in @('.add-dialog-layer', '.genealogy-switcher-layer')) {
$escaped = [regex]::Escape($layer)
if ($page -notmatch "(?s)$escaped\s*\{[^}]*position:\s*fixed;") { throw "G01 required dialog layer is not fixed: $layer" }
}
if ([regex]::Matches($page, 'position\s*:').Count -ne 2) { throw 'G01 must keep only its two user-triggered dialog layers positioned' }
if ($page -match 'class="(?:current-frame|current-seal-frame|application-record__skin|error-panel__frame|empty-panel__frame|empty-seal__skin|empty-search-action__skin|empty-invite-action__skin|state-retry__skin)"') {
throw 'G01 decorative skins must be container backgrounds'
}
Write-Output 'G01-DOCUMENT-FLOW-CONTRACT PASS'
-125
View File
@@ -1,125 +0,0 @@
$ErrorActionPreference = 'Stop'
function Assert-Match {
param([string]$Content, [string]$Pattern, [string]$Message)
if ($Content -notmatch $Pattern) { throw $Message }
}
function Assert-NoCssSurface {
param([string]$Content, [string]$ClassName)
foreach ($property in @('border', 'border-radius')) {
$pattern = "(?s)\\.$ClassName\\s*\\{[^}]*\\b$property\\s*:"
if ($Content -match $pattern) {
throw "G01 empty state must not construct .$ClassName with CSS $property"
}
}
}
$root = Split-Path -Parent $PSScriptRoot
$pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding utf8 | ConvertFrom-Json
$paths = @($pages.pages.path)
$g01 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g01-my-genealogies.vue') -Raw -Encoding utf8
$runtimeSmoke = Get-Content -LiteralPath (Join-Path $root 'tests/g01-empty-state-runtime-smoke.js') -Raw -Encoding utf8
$framePath = Join-Path $root 'static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png'
$frameManifestPath = Join-Path $root 'design-pipeline/manifests/g01-state-frame-v3.json'
$frameMasterPath = Join-Path $root 'docs/design/assets/g01-state/masters/g01-empty-panel-master.png'
$pipelinePackage = Get-Content -LiteralPath (Join-Path $root 'design-pipeline/package.json') -Raw -Encoding utf8
$frameBuilderPath = Join-Path $root 'design-pipeline/scripts/build-raster-assets.mjs'
if (-not ($paths -contains 'pages/genealogy/g01-my-genealogies')) { throw 'G01 route missing' }
if ($paths -contains 'pages/genealogy/g02-empty-genealogies') { throw 'G02 route must be removed; empty state belongs to G01' }
foreach ($required in @(
'forceEmptyState',
'query?.state',
'empty-create-action',
'empty-search-action',
'empty-invite-action',
'empty-create-note',
'@include adaptive-genealogy-state-panel;',
'const createGenealogy = () =>',
'const hasGenealogies = computed('
)) {
if ($g01 -notmatch [regex]::Escape($required)) { throw "Missing G01 empty-state contract: $required" }
}
foreach ($required in @(
'class="genealogy-fixed-zone"',
'class="genealogy-list-scroll"',
'scroll-y',
':scroll-top="listScrollCommand"',
'@scroll="handleListScroll"',
'const isListLayout = computed(',
'const resetListScroll = async () =>'
)) {
if ($g01 -notmatch [regex]::Escape($required)) { throw "Missing G01 split-scroll contract: $required" }
}
Assert-Match -Content $g01 -Pattern '(?s)<view class="genealogy-fixed-zone">.*class="current-slip".*class="shortcut-grid".*class="section-divider".*</view>\s*<scroll-view[^>]*class="genealogy-list-scroll"[^>]*>.*class="genealogy-lower".*</scroll-view>' -Message 'G01 fixed and scrolling regions are not separated correctly'
Assert-Match -Content $g01 -Pattern '(?s)\.genealogy-index--split\s*\{[^}]*height:\s*100vh;[^}]*padding-bottom:\s*calc\(112rpx\s*\+\s*env\(safe-area-inset-bottom\)\)' -Message 'G01 split layout must reserve the fixed tabbar and safe area'
Assert-Match -Content $g01 -Pattern '(?s)\.genealogy-list-scroll\s*\{[^}]*height:\s*0;[^}]*flex:\s*1;' -Message 'G01 list scroll region must consume the remaining computed height'
Assert-Match -Content $g01 -Pattern '(?s)\.empty-panel\s*\{[^}]*@include\s+adaptive\.adaptive-genealogy-state-panel;' -Message 'G01 empty state must consume the shared adaptive state-panel profile'
Assert-Match -Content $g01 -Pattern '(?s)\.genealogy-empty-state\s*\{[^}]*min-height:\s*1120rpx;' -Message 'G01 empty state does not reserve the approved visibly fuller height'
Assert-Match -Content $g01 -Pattern '(?s)\.empty-panel\s*\{[^}]*min-height:\s*1120rpx;' -Message 'G01 empty panel does not retain the approved minimum height'
Assert-Match -Content $g01 -Pattern '(?s)\.empty-panel__content\s*\{[^}]*justify-content:\s*center;[^}]*padding:\s*50rpx 28rpx 46rpx;' -Message 'G01 empty panel content is not centered with the approved responsive padding'
Assert-Match -Content $g01 -Pattern '(?s)\.empty-seal\s*\{[^}]*width:\s*120rpx;[^}]*height:\s*184rpx;' -Message 'G01 empty seal does not use the approved prominent size'
Assert-Match -Content $g01 -Pattern '(?s)\.empty-title\s*\{[^}]*margin-top:\s*30rpx;[^}]*font-size:\s*clamp\(24px, 48rpx, 30px\);' -Message 'G01 empty title does not use the approved prominent size and spacing'
Assert-Match -Content $g01 -Pattern '(?s)\.empty-copy\s*\{[^}]*min-height:\s*84rpx;[^}]*margin-top:\s*18rpx;[^}]*font-size:\s*clamp\(16px, 28rpx, 20px\);' -Message 'G01 empty guidance does not use the approved prominent size and spacing'
Assert-Match -Content $g01 -Pattern '(?s)\.empty-search-action,\s*\.empty-invite-action\s*\{[^}]*width:\s*560rpx;[^}]*min-height:\s*124rpx;' -Message 'G01 empty actions do not retain the approved prominent minimum size'
Assert-Match -Content $g01 -Pattern '(?s)\.empty-search-action__copy,\s*\.empty-invite-action__copy\s*\{[^}]*font-size:\s*clamp\(19px, 34rpx, 24px\);' -Message 'G01 empty action labels do not use the approved prominent size'
Assert-Match -Content $g01 -Pattern '(?s)\.empty-create-action\s*\{[^}]*min-height:\s*76rpx;[^}]*margin-top:\s*14rpx;' -Message 'G01 empty create action does not use the approved prominent hit area and spacing'
Assert-Match -Content $g01 -Pattern '(?s)\.empty-create-action__copy\s*\{[^}]*font-size:\s*clamp\(17px, 31rpx, 22px\);' -Message 'G01 empty create action does not use the approved prominent size'
if ($g01 -match '(?s)\.empty-create-action__copy\s*\{[^}]*text-decoration:\s*underline') {
throw 'G01 empty create action must not render a text underline'
}
Assert-Match -Content $g01 -Pattern '(?s)\.empty-create-note\s*\{[^}]*font-size:\s*clamp\(16px, 26rpx, 20px\);[^}]*line-height:\s*max\(1.35em, clamp\(20px, 38rpx, 26px\)\);' -Message 'G01 empty-state create note does not use the approved readable size'
if ($g01 -match '(?s)<view v-else class="genealogy-empty-state">.*?<image[^>]+g01-empty-panel\.png') { throw 'G01 empty state must not retain the opaque panel background' }
foreach ($className in @('empty-panel', 'empty-create-action', 'empty-search-action')) {
Assert-NoCssSurface -Content $g01 -ClassName $className
}
if (-not (Test-Path -LiteralPath $framePath)) { throw 'G01 requires the extracted transparent empty-panel frame asset' }
if (-not (Test-Path -LiteralPath $frameManifestPath)) { throw 'G01 transparent frame requires its schema v3 owner' }
if (-not (Test-Path -LiteralPath $frameMasterPath)) { throw 'G01 transparent frame requires its traceable master asset' }
if (-not (Test-Path -LiteralPath $frameBuilderPath)) { throw 'G01 transparent empty-panel frame must remain reproducible from its builder' }
Assert-Match -Content $pipelinePackage -Pattern '"build:g01-state-frame"\s*:\s*"node scripts/build-raster-assets\.mjs design-pipeline/manifests/g01-state-frame-v3\.json"' -Message 'G01 transparent frame builder is not exposed by the design pipeline'
$frameManifest = Get-Content -LiteralPath $frameManifestPath -Raw -Encoding utf8 | ConvertFrom-Json
if ($frameManifest.kind -ne 'asset-build-manifest' -or $frameManifest.family -ne 'g01-state-frame-v3') {
throw 'G01 transparent frame manifest has the wrong owner identity'
}
if ($frameManifest.assets.Count -ne 1) { throw 'G01 transparent frame manifest must own exactly one output' }
$frameAsset = $frameManifest.assets[0]
if ($frameAsset.source -ne 'docs/design/assets/g01-state/masters/g01-empty-panel-master.png' -or
$frameAsset.output -ne 'static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png' -or
$frameAsset.processing.mode -ne 'warm-gold-frame-extract') {
throw 'G01 transparent frame manifest no longer owns the approved master, output and extraction algorithm'
}
$expectedFrameWidth = [int]$frameAsset.outputPixels.width
$expectedFrameHeight = [int]$frameAsset.outputPixels.height
Assert-Match -Content $runtimeSmoke -Pattern '\{\s*width:\s*412,\s*height:\s*1000\s*\}' -Message 'G01 runtime smoke must include the approved 412x1000 tall-screen stress viewport'
Add-Type -AssemblyName System.Drawing
$frame = [System.Drawing.Bitmap]::FromFile($framePath)
try {
if ($frame.Width -ne $expectedFrameWidth -or $frame.Height -ne $expectedFrameHeight) {
throw 'G01 transparent empty-panel frame no longer matches its schema v3 owner dimensions'
}
$lastX = $expectedFrameWidth - 1
$lastY = $expectedFrameHeight - 1
$centerX = [math]::Floor($expectedFrameWidth / 2)
$centerY = [math]::Floor($expectedFrameHeight / 2)
foreach ($point in @(@(0, 0), @($lastX, 0), @(0, $lastY), @($lastX, $lastY), @($centerX, $centerY))) {
if ($frame.GetPixel($point[0], $point[1]).A -ne 0) { throw 'G01 transparent empty-panel frame retains an opaque background pixel' }
}
$visible = 0
for ($y = 0; $y -lt $frame.Height; $y += 4) {
for ($x = 0; $x -lt $frame.Width; $x += 4) {
if ($frame.GetPixel($x, $y).A -gt 0) { $visible++ }
}
}
if ($visible -lt 1200 -or $visible -gt 12000) { throw "G01 transparent frame alpha coverage is implausible: $visible sampled pixels" }
} finally {
$frame.Dispose()
}
Write-Output 'G01-EMPTY-STATE-CONTRACT PASS'
-220
View File
@@ -1,220 +0,0 @@
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(origin))
if (!page) throw new Error('Chrome debugging has no localhost:5173 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 < 30; attempt += 1) {
try {
if (await valueOf(send, expression)) return
} catch (error) {
if (!String(error.message).includes('Inspected target navigated or closed')) throw error
}
await sleep(100)
}
throw new Error(message)
}
const origin = process.argv[2] || 'http://localhost:5173'
const g01Path = '/pages/genealogy/g01-my-genealogies'
let auditId = 0
const nextUrl = (query = '') => {
auditId += 1
return `${origin}/?g01Audit=${auditId}#${g01Path}${query}`
}
const openEmptyG01 = async (send) => {
const emptyUrl = nextUrl('?state=empty')
await send('Page.navigate', { url: emptyUrl })
await waitFor(send, "location.href.includes('g01-my-genealogies?state=empty')", 'Did not navigate to G01 empty state')
await waitFor(send, "Boolean(document.querySelector('.genealogy-empty-state .empty-create-action'))", 'G01 empty state did not render')
}
const run = async () => {
const { socket, send, exceptions } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
await openEmptyG01(send)
await valueOf(send, "document.querySelector('.empty-create-action')?.click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g03-create-genealogy')", 'G01 empty create action did not open G03')
await openEmptyG01(send)
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')
await waitFor(send, "Boolean(document.querySelector('.current-slip'))", 'Default G01 list did not render')
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 .app-button').length")) !== 3) {
throw new Error('G01 add dialog must keep search, invite, and create visible together')
}
await valueOf(send, "document.querySelector('.add-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: 640 }, { width: 360, height: 800 }, { width: 412, height: 915 }, { width: 412, height: 1000 }]) {
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}`)
const before = await valueOf(send, `(() => {
const host = document.querySelector('.genealogy-list-scroll')
if (!host) return null
const scrollNodes = host.querySelectorAll('.uni-scroll-view')
const scroller = scrollNodes[scrollNodes.length - 1] || host.shadowRoot?.querySelector('.uni-scroll-view') || host
const fixedSelectors = ['.page-header', '.current-slip', '.shortcut-grid', '.section-divider', '.app-tabbar']
return {
fixed: Object.fromEntries(fixedSelectors.map((selector) => [selector, document.querySelector(selector)?.getBoundingClientRect().top])),
lowerTop: document.querySelector('.section-heading')?.getBoundingClientRect().top,
clientHeight: scroller.clientHeight,
scrollHeight: scroller.scrollHeight
}
})()`)
if (!before) throw new Error(`G01 has no independent list scroller at ${size.width}x${size.height}`)
if (before.clientHeight <= 0 || before.scrollHeight <= before.clientHeight) {
throw new Error(`G01 independent list scroller has no usable range at ${size.width}x${size.height}: ${JSON.stringify(before)}`)
}
await valueOf(send, `(() => {
const host = document.querySelector('.genealogy-list-scroll')
const scrollNodes = host.querySelectorAll('.uni-scroll-view')
const scroller = scrollNodes[scrollNodes.length - 1] || host.shadowRoot?.querySelector('.uni-scroll-view') || host
scroller.scrollTop = Math.min(180, scroller.scrollHeight - scroller.clientHeight)
scroller.dispatchEvent(new Event('scroll', { bubbles: true }))
})()`)
await sleep(100)
const after = await valueOf(send, `(() => {
const fixedSelectors = ['.page-header', '.current-slip', '.shortcut-grid', '.section-divider', '.app-tabbar']
return {
fixed: Object.fromEntries(fixedSelectors.map((selector) => [selector, document.querySelector(selector)?.getBoundingClientRect().top])),
lowerTop: document.querySelector('.section-heading')?.getBoundingClientRect().top
}
})()`)
for (const selector of Object.keys(before.fixed)) {
if (Math.abs(after.fixed[selector] - before.fixed[selector]) > 1) {
throw new Error(`G01 fixed selector moved while list scrolled at ${size.width}x${size.height}: ${selector}`)
}
}
if (!(after.lowerTop < before.lowerTop - 2)) {
throw new Error(`G01 lower list did not move independently at ${size.width}x${size.height}`)
}
}
const readListScrollTop = `(() => {
const host = document.querySelector('.genealogy-list-scroll')
const scrollNodes = host.querySelectorAll('.uni-scroll-view')
const scroller = scrollNodes[scrollNodes.length - 1] || host.shadowRoot?.querySelector('.uni-scroll-view') || host
return scroller.scrollTop
})()`
await valueOf(send, `(() => {
const host = document.querySelector('.genealogy-list-scroll')
const scrollNodes = host.querySelectorAll('.uni-scroll-view')
const scroller = scrollNodes[scrollNodes.length - 1] || host.shadowRoot?.querySelector('.uni-scroll-view') || host
scroller.scrollTop = scroller.scrollHeight
})()`)
await sleep(100)
const endGeometry = await valueOf(send, `(() => {
const action = document.querySelector('.create-action').getBoundingClientRect()
const tabbar = document.querySelector('.app-tabbar').getBoundingClientRect()
return { actionTop: action.top, actionBottom: action.bottom, tabbarTop: tabbar.top }
})()`)
if (endGeometry.actionTop >= endGeometry.tabbarTop || endGeometry.actionBottom > endGeometry.tabbarTop + 1) {
throw new Error(`G01 add action is not fully reachable above the fixed tabbar: ${JSON.stringify(endGeometry)}`)
}
const scrollTopBeforeDialog = await valueOf(send, readListScrollTop)
if (scrollTopBeforeDialog <= 0) throw new Error('G01 list must be scrolled before testing position preservation')
await valueOf(send, "document.querySelector('.create-action').click()")
await waitFor(send, "Boolean(document.querySelector('.add-dialog-layer'))", 'G01 add dialog did not open over the independent list')
await valueOf(send, "document.querySelector('.add-dialog__close').click()")
await waitFor(send, "!document.querySelector('.add-dialog-layer')", 'G01 add dialog did not close over the independent list')
const scrollTopAfterDialog = await valueOf(send, readListScrollTop)
if (Math.abs(scrollTopAfterDialog - scrollTopBeforeDialog) > 1) {
throw new Error('G01 add dialog changed the independent list position')
}
await valueOf(send, "document.querySelector('.current-slip').click()")
await waitFor(send, "Boolean(document.querySelector('.genealogy-switcher-layer'))", 'G01 switcher did not open over the independent list')
await valueOf(send, "document.querySelectorAll('.switcher-item')[1].click()")
await waitFor(send, "document.querySelector('.current-name')?.textContent.includes('汤氏宗谱')", 'G01 switcher did not change the current genealogy after scrolling')
await waitFor(send, `${readListScrollTop} <= 1`, 'G01 independent list did not return to the top after switching genealogy')
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()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
-26
View File
@@ -1,26 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$g01 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g01-my-genealogies.vue') -Raw -Encoding utf8
function Assert-Match {
param([string]$Content, [string]$Pattern, [string]$Message)
if ($Content -notmatch $Pattern) { throw $Message }
}
$errorBlock = [regex]::Match($g01, '(?s)<view v-else-if="hasError" class="state-panel state-panel--error">.*?<template v-else-if="hasGenealogies">').Value
if (-not $errorBlock) { throw 'G01 error state block missing' }
foreach ($required in @('error-panel__seal', 'brand-seal.png', 'error-panel__divider', 'section-divider.png', 'state-retry', '@click="retryLoad"')) {
if ($errorBlock -notmatch [regex]::Escape($required)) { throw "G01 error state missing approved contract: $required" }
}
Assert-Match -Content $g01 -Pattern '(?s)\.state-panel--error\s*\{[^}]*@include\s+adaptive\.adaptive-genealogy-state-panel;' -Message 'G01 error state must consume the shared adaptive state-panel profile'
Assert-Match -Content $g01 -Pattern '(?s)\.state-retry\s*\{[^}]*background:\s*url\("/static/assets/foundation/transparent/a01-scroll-primary-v3\.png"\)\s*center\s*/\s*contain\s+no-repeat;' -Message 'G01 retry must use the approved scroll skin as a container background'
Assert-Match -Content $g01 -Pattern '(?s)\.state-panel--error\s*\{[^}]*min-height:\s*1120rpx;' -Message 'G01 error state must retain the approved minimum frame height'
Assert-Match -Content $g01 -Pattern '(?s)\.error-panel__seal\s*\{[^}]*width:\s*132rpx;[^}]*height:\s*136rpx;' -Message 'G01 error state seal must use the approved prominent size'
Assert-Match -Content $g01 -Pattern '(?s)\.state-panel--error\s+\.state-title\s*\{[^}]*font-size:\s*clamp\(22px, 42rpx, 28px\);' -Message 'G01 error title must use the approved hierarchy'
Assert-Match -Content $g01 -Pattern '(?s)\.state-panel--error\s+\.state-copy\s*\{[^}]*font-size:\s*clamp\(16px, 27rpx, 20px\);' -Message 'G01 error guidance must use the approved readable size'
Assert-Match -Content $g01 -Pattern '(?s)\.state-panel--error\s+\.state-retry\s*\{[^}]*width:\s*560rpx;[^}]*height:\s*124rpx;' -Message 'G01 retry action must match the approved primary action size'
if ($errorBlock -match 'modules/genealogy/opaque/g01-empty-panel\.png') { throw 'G01 error state must not retain the legacy opaque panel' }
Write-Output 'G01-ERROR-STATE-CONTRACT PASS'
-21
View File
@@ -1,21 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g01-my-genealogies.vue') -Raw -Encoding UTF8
$mock = Get-Content -LiteralPath (Join-Path $root 'data/mock.js') -Raw -Encoding UTF8
$api = Get-Content -LiteralPath (Join-Path $root 'utils/api.js') -Raw -Encoding UTF8
$context = Get-Content -LiteralPath (Join-Path $root 'utils/genealogy-context.js') -Raw -Encoding UTF8
foreach ($token in @('genealogyContext', 'setCurrentGenealogyId', 'reconcileCurrentGenealogyId', 'invalidateCurrentGenealogyId', 'isCurrentGenealogyInvalidated', 'reconcilePageGenealogyContext', 'item.accessRole === "owner"', 'item.accessRole === "member"', 'String(query?.genealogyId || "")', 'contextInvalidated', 'state-panel--context', ') || null,', '<button', ':aria-pressed=', 'appApi.getMyGenealogies', 'createRequestController', 'listRequestController.abort()', 'onUnload')) {
if (-not $page.Contains($token)) { throw "G01 context contract missing: $token" }
}
foreach ($token in @('CURRENT_GENEALOGY_INVALIDATED_KEY', 'normalizeGenealogyId', 'getCurrentGenealogyId: readCurrentGenealogyId', 'invalidateCurrentGenealogyId:', 'isCurrentGenealogyInvalidated:', 'new Set(normalizedIds).size !== normalizedIds.length', 'normalizedIds.includes(normalizedPreferredId)', 'normalizedIds.includes(storedId)', 'if (normalizedPreferredId)', 'if (storedId)')) {
if (-not $context.Contains($token)) { throw "Current genealogy owner contract missing: $token" }
}
if ($page.Contains('Number(query?.genealogyId)')) { throw 'G01 must not coerce a genealogy ID to Number' }
if ($page -match 'currentGenealogy\s*=\s*computed\([\s\S]*?\|\|\s*availableGenealogies\.value\[0\]') { throw 'G01 must not silently fall back to the first genealogy after permission loss' }
if ($page -notmatch '(?s)const retryLoad = \(\) => \{\s*loadGenealogies\(\)') { throw 'G01 retry must rerun the remote list owner instead of exposing a dead button' }
if ($page -match '@/data/mock\.js|getGenealogyFixtureAccess|listNotificationFixtures') { throw 'G01 remote page must not retain fixture data owners' }
foreach ($membership in @("membership: 'created'", "membership: 'joined'")) {
if (-not $mock.Contains($membership)) { throw "Genealogy mock ownership missing: $membership" }
}
if (-not $api.Contains('id: String(Date.now())')) { throw 'Mock genealogy creation must produce a lexical string ID' }
Write-Output 'G01-GENEALOGY-CONTEXT-CONTRACT PASS'
-20
View File
@@ -1,20 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$g01 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g01-my-genealogies.vue') -Raw -Encoding utf8
$appLoading = Get-Content -LiteralPath (Join-Path $root 'components/AppLoading.vue') -Raw -Encoding utf8
function Assert-Match {
param([string]$Content, [string]$Pattern, [string]$Message)
if ($Content -notmatch $Pattern) { throw $Message }
}
Assert-Match -Content $g01 -Pattern 'import AppLoading from "@/components/AppLoading\.vue";' -Message 'G01 loading state must import the global AppLoading component'
Assert-Match -Content $g01 -Pattern '(?s)<view v-if="isLoading" class="state-panel state-panel--loading">\s*<AppLoading\s+text="[^"]+"\s+description="[^"]+"\s*/>\s*</view>' -Message 'G01 loading state must pass its approved title and supporting copy directly to AppLoading'
Assert-Match -Content $appLoading -Pattern 'class="app-loading__seal"\s+src="/static/assets/foundation/transparent/brand-seal\.png"' -Message 'Global AppLoading must retain its approved red-gold seal asset'
Assert-Match -Content $appLoading -Pattern 'app-loading-seal-breathe' -Message 'Global AppLoading must retain its restrained seal breathing motion'
if ($g01 -match 'loading-seal') { throw 'G01 loading state must not retain the legacy hollow loading seal' }
if ($g01 -match 'state-copy--loading') { throw 'G01 loading state must not duplicate AppLoading description layout' }
Write-Output 'G01-LOADING-STATE-CONTRACT PASS'
-254
View File
@@ -1,254 +0,0 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const toDataModuleUrl = (source) =>
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const run = async () => {
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
const prelude = `
const currentUser = {};
const genealogies = [];
const publicGenealogies = [];
const treeMembers = [];
const notifications = [];
const joinApplications = [];
const listFamilyFeedFixtures = () => [];
const listFamilyArticleFixtures = () => [];
const listFamilyAlbumFixtures = () => [];
const listCeremonyFixtures = () => [];
const listGrowthRecordFixtures = () => [];
const listNotificationFixtures = () => [];
const runtimeConfig = {
mode: "remote",
baseUrl: "https://backend-api.ddxcjp.cn",
clientId: "client-1",
tenantId: "000000",
};
const hasRemoteConfig = () => true;
const resolveRuntimeMode = () => "remote";
const AUTH_TAC_SCENE = Object.freeze({});
const assertSmsCode = (value) => value;
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" });
const fromApiGenealogyAccess = () => null;
const session = {
getToken: () => "session-1",
saveToken() {},
};
`;
const requests = [];
let nextResponse;
globalThis.uni = {
request(options) {
requests.push(options);
queueMicrotask(() => options.success(nextResponse));
return { abort() {} };
},
};
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
nextResponse = {
statusCode: 200,
data: {
code: 200,
msg: "操作成功",
data: [
{
genealogyId: 900001001,
genealogyName: "汤氏家谱",
surname: "汤",
ancestralHall: "敦睦堂",
regionFullName: "河南省洛阳市",
memberCount: 158,
roleType: "OWNER",
canManage: true,
canEditContent: true,
},
{
genealogyId: 900001002,
genealogyName: "汤氏宗谱",
regionName: "山东省济宁市",
memberCount: 286,
roleType: "MEMBER",
canManage: false,
canEditContent: false,
},
],
},
};
const result = await appApi.getMyGenealogies();
assert.deepStrictEqual(result, [
{
id: "900001001",
name: "汤氏家谱",
surname: "汤",
hall: "敦睦堂",
location: "河南省洛阳市",
memberCount: 158,
accessRole: "owner",
canManage: true,
canEditContent: true,
},
{
id: "900001002",
name: "汤氏宗谱",
surname: "",
hall: "",
location: "山东省济宁市",
memberCount: 286,
accessRole: "member",
canManage: false,
canEditContent: false,
},
]);
assert.strictEqual(requests.length, 1);
assert.strictEqual(requests[0].url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/mine");
assert.strictEqual(requests[0].method, "GET");
assert.strictEqual(requests[0].header.clientid, "client-1");
assert.strictEqual(requests[0].header.Authorization, "Bearer session-1");
assert.strictEqual(requests[0].timeout, 15000);
nextResponse = {
statusCode: 200,
data: {
code: 200,
data: [{
genealogyId: 900001003,
genealogyName: "只读家谱",
memberCount: 1,
roleType: "OWNER",
canManage: false,
canEditContent: false,
}],
},
};
const explicitCapabilities = await appApi.getMyGenealogies();
assert.strictEqual(explicitCapabilities[0].accessRole, "member");
assert.strictEqual(explicitCapabilities[0].canEditContent, false);
for (const invalidItem of [
{
genealogyId: 900001004,
genealogyName: "缺少权限",
memberCount: 1,
canEditContent: false,
},
{
genealogyId: 900001005,
genealogyName: "错用人物数",
personCount: 9,
canManage: false,
canEditContent: false,
},
]) {
nextResponse = {
statusCode: 200,
data: { code: 200, data: [invalidItem] },
};
await assert.rejects(
appApi.getMyGenealogies(),
(error) => error?.code === "GENEALOGY_RESPONSE_INVALID",
);
}
nextResponse = {
statusCode: 200,
data: {
code: 200,
data: [
{
genealogyId: 900001006,
genealogyName: "重复一",
memberCount: 1,
canManage: false,
canEditContent: false,
},
{
genealogyId: 900001006,
genealogyName: "重复二",
memberCount: 2,
canManage: false,
canEditContent: false,
},
],
},
};
await assert.rejects(
appApi.getMyGenealogies(),
(error) => error?.code === "GENEALOGY_RESPONSE_INVALID",
);
nextResponse = {
statusCode: 200,
data: {
code: 200,
data: [{ genealogyId: 9007199254740992, genealogyName: "失真家谱" }],
},
};
await assert.rejects(
appApi.getMyGenealogies(),
(error) => error?.code === "GENEALOGY_RESPONSE_INVALID",
);
nextResponse = {
statusCode: 200,
data: { code: 200, data: 7 },
};
assert.strictEqual(await appApi.getUnreadNotificationCount(), 7);
assert.strictEqual(
requests.at(-1).url,
"https://backend-api.ddxcjp.cn/genealogy/app/notifications/unread-count",
);
nextResponse = {
statusCode: 200,
data: { code: 200, data: -1 },
};
await assert.rejects(
appApi.getUnreadNotificationCount(),
(error) => error?.code === "NOTIFICATION_COUNT_RESPONSE_INVALID",
);
const quota = {
createUsed: 1,
createLimit: 3,
createRemaining: 2,
canCreate: true,
joinUsed: 2,
joinLimit: 10,
joinRemaining: 8,
canJoin: true,
};
nextResponse = {
statusCode: 200,
data: { code: 200, data: quota },
};
assert.deepStrictEqual(await appApi.getGenealogyQuota(), quota);
assert.strictEqual(
requests.at(-1).url,
"https://backend-api.ddxcjp.cn/genealogy/app/genealogies/quota",
);
nextResponse = {
statusCode: 200,
data: { code: 200, data: { ...quota, canJoin: undefined } },
};
await assert.rejects(
appApi.getGenealogyQuota(),
(error) => error?.code === "GENEALOGY_QUOTA_RESPONSE_INVALID",
);
process.stdout.write("G01-MY-GENEALOGIES-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
-17
View File
@@ -1,17 +0,0 @@
$ErrorActionPreference = 'Stop'
$page = Get-Content -Raw 'pages/genealogy/g01-my-genealogies.vue'
function Assert-Contains([string]$Content, [string]$Pattern, [string]$Message) {
if (-not $Content.Contains($Pattern)) { throw $Message }
}
Assert-Contains $page 'v-for="item in visibleShortcuts"' 'G01 must render the membership-filtered shortcut list'
Assert-Contains $page '{{ currentRoleLabel }}' 'G01 must render the membership-derived role label'
Assert-Contains $page 'currentGenealogy.value?.accessRole === "owner"' 'G01 must consume the normalized remote role'
Assert-Contains $page 'item.key !== "applications"' 'G01 must remove application review for joined members'
$selectedBinding = ':selected="item.id === currentGenealogy.id"'
if (([regex]::Matches($page, [regex]::Escape($selectedBinding))).Count -ne 2) {
throw 'G01 created and joined groups must render the same current-genealogy state'
}
Write-Output 'G01-ROLE-SHORTCUT-CONTRACT PASS'
-309
View File
@@ -1,309 +0,0 @@
const origin = 'http://localhost:5173'
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 pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const projectPages = pages.filter((page) => page.type === 'page' && page.url.startsWith(origin))
if (projectPages.length !== 1) throw new Error(`Expected one project page, found ${projectPages.length}`)
const socket = new WebSocket(projectPages[0].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()
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
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
const requestId = id
const timer = setTimeout(() => {
pending.delete(requestId)
reject(new Error(`CDP request timed out: ${method}`))
}, 8000)
pending.set(requestId, {
resolve: (value) => { clearTimeout(timer); resolve(value) },
reject: (error) => { clearTimeout(timer); reject(error) }
})
socket.send(JSON.stringify({ id: requestId, method, params }))
})
return { projectPageCount: projectPages.length, socket, send }
}
const valueOf = async (send, expression) => {
const result = await send('Runtime.evaluate', { expression, returnByValue: true })
if (result.exceptionDetails) throw new Error(result.exceptionDetails.text || 'Runtime evaluation failed')
return result.result?.value
}
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(100)
}
throw new Error(message)
}
const setSize = async (send, width, height) => {
await send('Emulation.setDeviceMetricsOverride', {
width,
height,
deviceScaleFactor: 1,
mobile: true,
screenWidth: width,
screenHeight: height
})
await sleep(150)
}
let navigationId = 0
const openG01 = async (send) => {
navigationId += 1
await send('Page.navigate', { url: `${origin}/?g01SwitchAudit=${navigationId}#/pages/genealogy/g01-my-genealogies` })
await waitFor(send, "Boolean(document.querySelector('.current-slip'))", 'G01 did not render after navigation')
}
const openSwitcher = async (send) => {
if (!await valueOf(send, "Boolean(document.querySelector('.genealogy-switcher'))")) {
const clicked = await valueOf(send, `(() => {
const trigger = document.querySelector('.current-slip')
if (!trigger) return false
trigger.click()
return true
})()`)
if (!clicked) throw new Error('Could not find the switch genealogy trigger')
}
await waitFor(send, "Boolean(document.querySelector('.genealogy-switcher'))", 'Switcher did not open')
}
const clearClones = (send) => valueOf(send, `(() => {
document.querySelectorAll('[data-cdp-clone="1"]').forEach((node) => node.remove())
return true
})()`)
const addClonesToTotal = (send, total) => valueOf(send, `(() => {
document.querySelectorAll('[data-cdp-clone="1"]').forEach((node) => node.remove())
const list = document.querySelector('.genealogy-switcher__list')
const originals = Array.from(list?.querySelectorAll('.switcher-item') || [])
if (!list || originals.length !== 2) return false
for (let index = originals.length; index < ${total}; index += 1) {
const clone = originals[index % originals.length].cloneNode(true)
clone.dataset.cdpClone = '1'
clone.querySelector('.switcher-item__name').textContent = '压力测试家谱 ' + (index + 1)
clone.querySelector('.switcher-item__state').textContent = '选择'
clone.classList.remove('switcher-item--active')
list.appendChild(clone)
}
return true
})()`)
const getMetrics = (send) => valueOf(send, `(() => {
const dialog = document.querySelector('.genealogy-switcher')
const content = document.querySelector('.genealogy-switcher__content')
const title = document.querySelector('.dialog-title')
const close = document.querySelector('.genealogy-switcher__close')
const list = document.querySelector('.genealogy-switcher__list')
const items = Array.from(document.querySelectorAll('.genealogy-switcher__list .switcher-item'))
const rect = (node) => node ? { top: node.getBoundingClientRect().top, right: node.getBoundingClientRect().right, bottom: node.getBoundingClientRect().bottom, left: node.getBoundingClientRect().left, width: node.getBoundingClientRect().width, height: node.getBoundingClientRect().height } : null
const last = items.at(-1)
return {
viewport: { width: innerWidth, height: innerHeight },
dialog: rect(dialog),
content: rect(content),
title: rect(title),
close: rect(close),
list: rect(list),
itemCount: items.length,
cloneCount: document.querySelectorAll('[data-cdp-clone="1"]').length,
listClientHeight: list?.clientHeight || 0,
listScrollHeight: list?.scrollHeight || 0,
listScrollTop: list?.scrollTop || 0,
blankBelowLastItem: list && last ? list.getBoundingClientRect().bottom - last.getBoundingClientRect().bottom : null,
horizontalOverflow: document.documentElement.scrollWidth > innerWidth || document.body.scrollWidth > innerWidth,
addDialogVisible: Boolean(document.querySelector('.add-dialog')),
switcherVisible: Boolean(dialog),
borderImageSource: dialog ? getComputedStyle(dialog).borderImageSource : '',
borderImageSlice: dialog ? getComputedStyle(dialog).borderImageSlice : ''
}
})()`)
const assert = (condition, message) => {
if (!condition) throw new Error(message)
}
const run = async () => {
const { projectPageCount, socket, send } = await connect()
const runtimeErrors = []
const resourceErrors = []
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
if (message.method === 'Runtime.exceptionThrown') runtimeErrors.push(message.params.exceptionDetails?.text || 'runtime exception')
if (message.method === 'Network.responseReceived' && message.params.response.status >= 400) {
resourceErrors.push(`${message.params.response.status} ${message.params.response.url}`)
}
})
try {
await send('Page.enable')
await send('Runtime.enable')
await send('Network.enable')
await setSize(send, 412, 915)
await openG01(send)
await openSwitcher(send)
const sizeResults = []
for (const size of sizes) {
await setSize(send, size.width, size.height)
await openG01(send)
await clearClones(send)
await openSwitcher(send)
const metrics = await getMetrics(send)
const rpx = size.width / 750
assert(metrics.itemCount === 2, `${size.width}x${size.height}: expected two items`)
const expectedDialogHeight = (600 * rpx) + 2
assert(Math.abs(metrics.dialog.height - expectedDialogHeight) < 1, `${size.width}x${size.height}: dialog height ${metrics.dialog.height}px is not the expected ${expectedDialogHeight}px including its 1px border`)
assert(metrics.title.top > metrics.dialog.top + (78 * rpx), `${size.width}x${size.height}: title overlaps top decoration`)
assert(metrics.close.top >= metrics.dialog.top && metrics.close.right <= metrics.dialog.right + 1, `${size.width}x${size.height}: close control is clipped`)
assert(metrics.listScrollHeight <= metrics.listClientHeight + 1, `${size.width}x${size.height}: two-item list unexpectedly scrolls`)
assert(metrics.blankBelowLastItem <= (72 * rpx) + 2, `${size.width}x${size.height}: too much blank space below last item`)
assert(!metrics.horizontalOverflow, `${size.width}x${size.height}: horizontal overflow`)
sizeResults.push(metrics)
}
await setSize(send, 412, 915)
await openG01(send)
await openSwitcher(send)
assert(await addClonesToTotal(send, 6), 'Could not prepare six-item stress state')
await sleep(100)
const sixItems = await getMetrics(send)
assert(sixItems.itemCount === 6, 'Six-item state did not contain six items')
assert(sixItems.dialog.height > sizeResults.at(-1).dialog.height + 1, 'Six-item dialog did not grow')
assert(sixItems.dialog.height < 915 - ((120 * 412) / 750) - 1, 'Six-item dialog reached the maximum height too early')
assert(sixItems.listScrollHeight <= sixItems.listClientHeight + 1, 'Six-item list unexpectedly scrolls')
assert(await addClonesToTotal(send, 12), 'Could not prepare twelve-item stress state')
await sleep(100)
const twelveItemsTop = await getMetrics(send)
const expectedMaximumHeight = 915 - ((120 * 412) / 750)
assert(Math.abs(twelveItemsTop.dialog.height - expectedMaximumHeight) < 3, `Twelve-item dialog height ${twelveItemsTop.dialog.height}px did not stop at the expected ${expectedMaximumHeight}px safe maximum: ${JSON.stringify(twelveItemsTop)}`)
assert(twelveItemsTop.listScrollHeight > twelveItemsTop.listClientHeight + 1, 'Twelve-item list does not scroll')
assert(twelveItemsTop.listScrollTop === 0, 'Twelve-item list did not start at the top')
await valueOf(send, `(() => {
const list = document.querySelector('.genealogy-switcher__list')
list.scrollTop = list.scrollHeight
return true
})()`)
await sleep(50)
const twelveItemsBottom = await getMetrics(send)
assert(twelveItemsBottom.listScrollTop > 0, 'Twelve-item list did not scroll to the bottom')
assert(await addClonesToTotal(send, 50), 'Could not prepare fifty-item stress state')
await sleep(100)
const fiftyItemsTop = await getMetrics(send)
assert(fiftyItemsTop.itemCount === 50, 'Fifty-item state did not contain fifty items')
assert(Math.abs(fiftyItemsTop.dialog.height - expectedMaximumHeight) < 3, 'Fifty-item dialog escaped its safe maximum height')
assert(fiftyItemsTop.listScrollHeight > fiftyItemsTop.listClientHeight * 3, 'Fifty-item list does not own its long-data scrolling')
await valueOf(send, `(() => {
const list = document.querySelector('.genealogy-switcher__list')
list.scrollTop = list.scrollHeight
return true
})()`)
await sleep(50)
const fiftyItemsBottom = await getMetrics(send)
assert(fiftyItemsBottom.listScrollTop > 0, 'Fifty-item list did not reach its bottom data')
await clearClones(send)
await valueOf(send, "document.querySelector('.genealogy-switcher__content').click()")
assert(await valueOf(send, "Boolean(document.querySelector('.genealogy-switcher'))"), 'Inner click closed the switcher')
await valueOf(send, "document.querySelector('.genealogy-switcher__close').click()")
assert(!await valueOf(send, "Boolean(document.querySelector('.genealogy-switcher'))"), 'Close icon did not close the switcher')
await openSwitcher(send)
await valueOf(send, "document.querySelector('.genealogy-switcher-layer').click()")
assert(!await valueOf(send, "Boolean(document.querySelector('.genealogy-switcher'))"), 'Mask did not close the switcher')
await openSwitcher(send)
await valueOf(send, "document.querySelectorAll('.switcher-item')[1].click()")
await waitFor(send, "!document.querySelector('.genealogy-switcher')", 'Selecting the second genealogy did not close the switcher')
assert((await valueOf(send, "document.querySelector('.current-slip')?.textContent"))?.includes('山东'), 'Selecting the second genealogy did not update the current genealogy')
assert((await valueOf(send, "document.querySelector('.genealogy-card--current')?.textContent"))?.includes('汤氏宗谱'), 'Joined genealogy card did not expose the current visual state')
assert(
(await valueOf(send, "document.querySelector('.current-meta')?.textContent"))?.includes('成员'),
'Joined genealogy did not display the member role'
)
assert(
(await valueOf(send, "document.querySelectorAll('.shortcut-item').length")) === 3,
'Joined genealogy did not hide the application-review shortcut'
)
assert(
!(await valueOf(send, "document.querySelector('.shortcut-grid')?.textContent"))?.includes('申请审核'),
'Joined genealogy still exposed application review'
)
await openSwitcher(send)
await valueOf(send, "document.querySelectorAll('.switcher-item')[0].click()")
await waitFor(send, "!document.querySelector('.genealogy-switcher')", 'Restoring the first genealogy did not close the switcher')
assert(
(await valueOf(send, "document.querySelector('.current-meta')?.textContent"))?.includes('管理员'),
'Created genealogy did not restore the administrator role'
)
assert(
(await valueOf(send, "document.querySelectorAll('.shortcut-item').length")) === 4,
'Created genealogy did not restore all four shortcuts'
)
await openSwitcher(send)
await clearClones(send)
await valueOf(send, "document.querySelector('.genealogy-switcher__close').click()")
await valueOf(send, `(() => {
const cards = document.querySelectorAll('.genealogy-card')
if (cards.length < 2) return false
cards[0].click()
cards[1].click()
return true
})()`)
await waitFor(send, "location.href.includes('/pages/genealogy/g05-genealogy-overview?genealogyId=1001')", 'Rapid genealogy clicks did not keep the first accepted navigation target')
await valueOf(send, "document.querySelector('.header-back')?.click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g01-my-genealogies')", 'G05 did not return to the existing G01 instance')
assert((await valueOf(send, "document.querySelector('.current-slip')?.textContent"))?.includes('河南'), 'Rejected second navigation overwrote the visible current genealogy')
assert((await valueOf(send, "document.querySelector('.current-meta')?.textContent"))?.includes('管理员'), 'Rejected second navigation overwrote the persisted genealogy context')
await openSwitcher(send)
await clearClones(send)
const final = await getMetrics(send)
assert(final.viewport.width === 412 && final.viewport.height === 915, 'Final viewport is not 412x915')
assert(final.itemCount === 2 && final.cloneCount === 0, 'Final state contains temporary items')
assert(final.switcherVisible && !final.addDialogVisible, 'Final state does not show only the switcher')
assert(!final.horizontalOverflow, 'Final state has horizontal overflow')
assert(runtimeErrors.length === 0, `Runtime exceptions: ${runtimeErrors.join(' | ')}`)
assert(resourceErrors.length === 0, `Resource errors: ${resourceErrors.join(' | ')}`)
process.stdout.write(`${JSON.stringify({ projectPageCount, sizeResults, sixItems, twelveItemsTop, twelveItemsBottom, fiftyItemsTop, fiftyItemsBottom, final, runtimeErrors, resourceErrors }, null, 2)}\n`)
process.stdout.write('PASS G-01 switch dialog runtime smoke\n')
} finally {
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
-252
View File
@@ -1,252 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$requiredAssets = @(
'static/assets/foundation/transparent/brand-seal.png',
'static/assets/foundation/transparent/notice.png',
'static/assets/foundation/transparent/chevron-right.png',
'static/assets/foundation/transparent/meta-location.png',
'static/assets/foundation/transparent/meta-member.png',
'static/assets/foundation/transparent/meta-admin.png',
'static/assets/modules/genealogy/transparent/shortcut-tree.png',
'static/assets/modules/genealogy/transparent/shortcut-members.png',
'static/assets/modules/genealogy/transparent/shortcut-generation-poem.png',
'static/assets/modules/genealogy/transparent/shortcut-application.png',
'static/assets/modules/genealogy/transparent/current-seal-frame.png',
'static/assets/modules/genealogy/transparent/row-seal-frame.png',
'static/assets/modules/genealogy/transparent/add.png',
'static/assets/modules/genealogy/transparent/create-cloud.png',
'static/assets/foundation/transparent/tab-genealogy.png',
'static/assets/foundation/transparent/tab-genealogy-active.png',
'static/assets/foundation/transparent/tab-family.png',
'static/assets/foundation/transparent/tab-family-active.png',
'static/assets/foundation/transparent/tab-profile.png',
'static/assets/foundation/transparent/tab-profile-active.png',
'static/assets/modules/genealogy/transparent/current-slip-frame.png',
'static/assets/modules/genealogy/transparent/list-slip-frame.png',
'static/assets/modules/genealogy/transparent/g01-dialog-close.png',
'static/assets/modules/genealogy/transparent/g01-add-sheet-background-v3.png',
'static/assets/foundation/opaque/root-header-cinnabar.jpg',
'static/assets/modules/genealogy/opaque/genealogy-page-background-long.png',
'static/assets/modules/genealogy/transparent/section-divider.png'
)
foreach ($asset in $requiredAssets) {
if (-not (Test-Path -LiteralPath (Join-Path $root $asset))) {
throw "Missing G-01 visual asset: $asset"
}
}
$header = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'components/PageHeader.vue')
$tabbar = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'components/AppTabbar.vue')
if ($header -notmatch 'brand-seal\.png') { throw 'Header does not use the approved tight brand PNG.' }
if ($header -notmatch 'brand-seal\.png') { throw 'Header does not use the tight header Logo asset.' }
if ($header -notmatch 'notice\.png') { throw 'Header does not use the approved notice PNG.' }
if ($header -notmatch 'unreadCount\s*>\s*0') { throw 'Header badge is not conditional on unread count.' }
if ($header -notmatch 'class="header-hall"') { throw 'Header does not isolate the hall line-art as a low-contrast background layer.' }
if ($header -match 'background-image: url') { throw 'Header still applies hall line-art directly to the red header surface.' }
if ($header -notmatch 'opacity:\s*0?\.29') { throw 'Header hall line-art is not visible enough for the approved eave layer.' }
if ($header -notmatch 'root-header-cinnabar\.jpg') { throw 'Header does not use the approved cinnabar texture asset.' }
if ($header -notmatch 'height:\s*calc\(124rpx\s*\+\s*var\(--status-bar-height,\s*0px\)\)') { throw 'Header does not preserve the approved visual height above the system status bar.' }
if ($header -notmatch 'padding-top:\s*var\(--status-bar-height,\s*0px\)') { throw 'Header does not reserve the real system status-bar height.' }
if ($tabbar -match '/static/icons/tab-') { throw 'Tabbar still references rejected legacy tab assets.' }
if ($tabbar -notmatch 'safe-area-inset-bottom') { throw 'Tabbar has no Android safe-area padding.' }
if ($tabbar -notmatch 'font-size:\s*clamp\(17px, 30rpx, 22px\)') { throw 'Tabbar label size does not match the approved visual weight.' }
if ($tabbar -notmatch 'width:\s*64rpx') { throw 'Tabbar icon size does not match the approved visual weight.' }
$page = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages/genealogy/g01-my-genealogies.vue')
$profiles = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'styles/adaptive-frame-profiles.scss')
if ($page -match "from '@/utils/api\.js'") { throw 'G-01 visual phase must not call the remote API.' }
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" }
}
$closeCopy = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('5YWz6Zet'))
$cancelCopy = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('5Y+W5raI'))
$searchCopy = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('5pCc57Si5a626LCx'))
$inviteCopy = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('6YKA6K+356CB5Yqg5YWl'))
$continueCreateCopy = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('57un57ut5Yib5bu65a626LCx'))
$legacyCreateCopy = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('56Gu6K6k5rKh5pyJ546w5pyJ5a626LCx77yM57un57ut5Yib5bu6'))
$addMarkup = [regex]::Match($page, '(?s)<view\s+[^>]*v-if="addDialogVisible".*?<view\s+[^>]*v-if="switcherVisible"').Value
if (-not $addMarkup) { throw 'G-01 add sheet markup could not be isolated.' }
foreach ($token in @(
'class="add-dialog__body"',
'class="add-dialog__close-icon"',
'class="add-dialog__actions"',
'g01-dialog-close.png',
"aria-label=`"$closeCopy`""
)) {
if ($addMarkup -notmatch [regex]::Escape($token)) { throw "G-01 paper sheet is missing $token" }
}
if ($addMarkup -notmatch '(?s)<view class="add-dialog__body">\s*<view class="add-dialog__heading">.*?class="add-dialog__close".*?</view>\s*</view>\s*<view class="add-dialog__actions">') {
throw 'G-01 add sheet must keep the close control with the centered heading and actions body.'
}
if ($addMarkup -match 'add-dialog__paper|add-dialog__edge|page-paper\.jpg|a01-paper-transition-v1\.png') {
throw 'G-01 add sheet still assembles its background from separate paper and edge layers.'
}
if ($addMarkup -match 'a01-scroll-dialog-v3\.png') { throw 'G-01 add sheet still uses the rejected complete dialog frame.' }
foreach ($copy in @($closeCopy, $cancelCopy)) {
if ($addMarkup -match [regex]::Escape(">$copy</view>")) { throw 'G-01 add sheet still renders a text close control.' }
}
foreach ($copy in @($searchCopy, $inviteCopy, $continueCreateCopy)) {
$token = "label=`"$copy`""
if ($addMarkup -notmatch [regex]::Escape($token)) { throw "G-01 add sheet no longer reuses AppButton action $token" }
}
if ($addMarkup -match [regex]::Escape("label=`"$legacyCreateCopy`"")) {
throw 'G-01 add sheet still puts guidance copy inside the create button.'
}
if ($page -notmatch '(?s)\.add-dialog-layer\s*\{[^}]*align-items:\s*flex-end;[^}]*background:\s*rgba\(34,\s*20,\s*12,\s*0\.68\);') {
throw 'G-01 add sheet is not a bottom-aligned layer with the approved mask.'
}
if ($page -notmatch '(?s)\.add-dialog__close\s*\{[^}]*width:\s*80rpx;[^}]*height:\s*80rpx;') {
throw 'G-01 add sheet close control does not reserve the approved hit area.'
}
if ($page -notmatch '(?s)\.add-dialog\s*\{[^}]*min-height:\s*780rpx;[^}]*max-height:\s*calc\(100vh\s*-\s*80rpx\);') {
throw 'G-01 add sheet does not reach the approved arrow-aligned minimum height.'
}
if ($page -notmatch '(?s)\.add-dialog__content\s*\{[^}]*min-height:\s*780rpx;[^}]*max-height:\s*calc\(100vh\s*-\s*80rpx\);[^}]*padding:\s*96rpx\s+52rpx\s+calc\(96rpx\s*\+\s*env\(safe-area-inset-bottom\)\);') {
throw 'G-01 add sheet does not reserve the approved symmetric centering area.'
}
if ($page -notmatch '(?s)\.add-dialog__body\s*\{[^}]*margin:\s*auto\s+0;') {
throw 'G-01 add sheet body does not center safely with collapsible auto margins.'
}
if ($page -notmatch '(?s)\.add-dialog__heading\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s+96rpx;') {
throw 'G-01 add sheet heading does not reserve a document-flow close-control column.'
}
if ($page -match '(?s)\.add-dialog__heading\s*\{[^}]*top\s*:') {
throw 'G-01 add sheet heading must use real flow spacing instead of a visual offset.'
}
if ($page -notmatch '(?s)\.add-dialog__close\s*\{[^}]*grid-column:\s*2;[^}]*grid-row:\s*1\s*/\s*span\s+2;[^}]*margin-right:\s*-22rpx;') {
throw 'G-01 add sheet close control does not follow the heading grid.'
}
if ($page -notmatch '@include\s+adaptive-g01-add-sheet' -or $profiles -notmatch [regex]::Escape('/static/assets/modules/genealogy/transparent/g01-add-sheet-background-v3.png')) {
throw 'G-01 add sheet does not use the approved complete background asset.'
}
if ($profiles -notmatch '(?s)@mixin\s+adaptive-g01-add-sheet\s*\{[^}]*border-image-source:\s*url\("/static/assets/modules/genealogy/transparent/g01-add-sheet-background-v3\.png"\);[^}]*border-image-slice:\s*220\s+0\s+1\s+0\s+fill;[^}]*border-image-width:\s*118rpx\s+0\s+1rpx;') {
throw 'G-01 add sheet does not preserve the complete sheet top while stretching only the paper body.'
}
if ($page -notmatch '(?s)\.add-dialog__close-icon\s*\{[^}]*width:\s*80rpx;[^}]*height:\s*80rpx;') {
throw 'G-01 add sheet close icon canvas does not compensate for the asset transparent padding.'
}
if ($page -match '(?s)\.add-dialog\s+\.app-button\s*\{') { throw 'G-01 add sheet must not restyle the existing AppButton.' }
if ($page -notmatch '(?s)\.add-dialog__actions\s*\{[^}]*margin:\s*62rpx\s+-32rpx\s+0;') {
throw 'G-01 add sheet actions do not compensate for the existing button assets transparent side padding.'
}
if ($page -notmatch '(?s)\.add-dialog__actions\s*>\s*\.app-button\s*\{[^}]*width:\s*595rpx;[^}]*max-width:\s*100%;[^}]*min-height:\s*96rpx;') {
throw 'G-01 add sheet buttons do not keep the approved equal-width geometry.'
}
if ($page -notmatch '(?s)\.add-dialog__actions\s*\{[^}]*align-items:\s*center;') {
throw 'G-01 add sheet buttons are not centered after equal-width sizing.'
}
if ($page -notmatch '(?s)\.add-dialog__actions\s*>\s*\.app-button\s*\+\s*\.app-button\s*\{[^}]*margin-top:\s*24rpx;') {
throw 'G-01 add sheet buttons do not keep the approved 24rpx spacing.'
}
if ($page -notmatch '(?s)\.genealogy-switcher-layer\s*\{[^}]*align-items:\s*center;[^}]*padding:\s*40rpx;') {
throw 'G-01 switcher must remain a centered dialog.'
}
if ($page -match 'class="genealogy-switcher__skin"') {
throw 'G-01 switcher must not render the complete background as a fixed aspectFit image.'
}
if ($page -notmatch '(?s)\.genealogy-switcher\s*\{[^}]*@include\s+adaptive-g01-switcher;[^}]*min-height:\s*600rpx;[^}]*max-height:\s*calc\(100vh\s*-\s*120rpx\);' -or $profiles -notmatch '(?s)@mixin\s+adaptive-g01-switcher\s*\{[^}]*border-image-source:\s*url\("/static/assets/modules/auth/transparent/a01-scroll-dialog-v3\.png"\);[^}]*border-image-slice:\s*300\s+260\s+360\s+260\s+fill;[^}]*border-image-width:\s*110rpx\s+48rpx\s+132rpx\s+48rpx;') {
throw 'G-01 switcher does not use the approved stretchable complete background.'
}
if ($page -notmatch '(?s)\.genealogy-switcher__content\s*\{[^}]*min-height:\s*600rpx;[^}]*max-height:\s*calc\(100vh\s*-\s*120rpx\);[^}]*padding:\s*120rpx\s+58rpx\s+140rpx;') {
throw 'G-01 switcher content does not keep the approved decoration safe area.'
}
if ($page -notmatch 'class="genealogy-switcher__list"[^>]*scroll-y') {
throw 'G-01 switcher does not provide an independent scroll list.'
}
if ($page -notmatch '(?s)class="genealogy-switcher__close"[^>]*aria-label="\u5173\u95ed".*?g01-dialog-close\.png') {
throw 'G-01 switcher does not use the custom accessible close control.'
}
if ($page -match 'class="dialog-close"[^>]*@click="closeSwitcher"') {
throw 'G-01 switcher still exposes the obsolete bottom close copy.'
}
if ($page -notmatch 'import\s*\{[^}]*onBackPress[^}]*\}\s*from\s*"@dcloudio/uni-app"') {
throw 'G-01 add sheet does not import onBackPress.'
}
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" }
}
if ($page -notmatch '@include\s+adaptive-genealogy-current-slip' -or $profiles -notmatch 'current-slip-frame\.png') { throw 'G-01 does not consume the approved current genealogy frame profile.' }
foreach ($token in @('current-summary', 'current-meta-item', 'current-seal-frame', 'meta-location.png', 'meta-member.png', 'meta-admin.png', 'create-cloud.png')) {
if ($page -notmatch $token) { throw "G-01 current panel is missing $token" }
}
if ($page -notmatch '(?s)\.shortcut-icon\s*\{[^}]*width:\s*76rpx;[^}]*height:\s*76rpx;') { throw 'G-01 shortcut icons do not keep the compact readable size.' }
if ($page -notmatch '<GenealogyPageBackground\s*/>') { throw 'G-01 does not consume the shared genealogy background component.' }
if ($page -notmatch 'section-divider\.png') { throw 'G-01 does not consume the approved section divider asset.' }
$pageWithoutAddMarkup = $page.Replace($addMarkup, '')
foreach ($legacyBackground in @('page-paper.jpg', 'footer-mountain-bamboo.png', 'page-paper-texture', 'page-footer-landscape')) {
if ($pageWithoutAddMarkup -match [regex]::Escape($legacyBackground)) { throw "G-01 must replace the legacy layered page background: $legacyBackground" }
}
$backgroundComponent = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'components/GenealogyPageBackground.vue')
if ($backgroundComponent -notmatch 'mode="widthFix"') { throw 'G shared background must preserve the complete C artwork without horizontal cropping.' }
if ($backgroundComponent -match 'aspectFill|scaleToFill') { throw 'G shared background must not crop or stretch the selected C artwork.' }
if ($backgroundComponent -notmatch '(?s)\.genealogy-page-background\s*\{.*?position:\s*fixed;.*?inset:\s*0;.*?background:\s*#e7ded1;') { throw 'G shared background must extend the selected C artwork with the approved paper color.' }
if ($page -match 'background:\s*rgba\(255,\s*249,\s*238,\s*\.88\)') { throw 'G-01 current genealogy card retains a rectangular pale backing behind its transparent corners.' }
$card = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'components/GenealogyCard.vue')
if ($card -notmatch 'list-slip-frame\.png') { throw 'G-01 list rows do not use the approved slip frame asset.' }
if ($card -match 'background:\s*rgba\(255,\s*249,\s*238,\s*\.(9|96)\)') { throw 'G-01 list cards retain a rectangular pale backing behind their transparent corners.' }
foreach ($token in @('row-seal-frame.png', 'card-main', 'card-title-row', 'card-detail-row', 'card-updated')) {
if ($card -notmatch $token) { throw "G-01 list card is missing $token" }
}
if ($card -match 'background:\s*#b93b2e') { throw 'G-01 list seal is still a plain CSS red block.' }
foreach ($asset in @('meta-location.png', 'meta-member.png', 'meta-admin.png', 'current-seal-frame.png', 'row-seal-frame.png', 'create-cloud.png')) {
if ($page -notmatch [regex]::Escape($asset) -and $card -notmatch [regex]::Escape($asset)) {
throw "G-01 does not consume $asset"
}
}
$mock = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'data/mock.js')
$fixtures = @(
[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('bmFtZTogJ+axpOawj+WutuiwsSc=')),
[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('bG9jYXRpb246ICfmsrPljZfCt+a0m+mYsyc=')),
'memberCount: 158',
[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('bmFtZTogJ+axpOawj+Wul+iwsSc=')),
[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('bG9jYXRpb246ICflsbHkuJzCt+a1juWugSc=')),
'memberCount: 286'
)
foreach ($fixture in $fixtures) {
if (-not $mock.Contains($fixture)) { throw "G-01 visual fixture is missing $fixture" }
}
if ($page -notmatch 'current-info-divider') { throw 'G-01 current panel is missing the reference title-to-meta divider.' }
if ($card -match 'calendar-v1\.png') { throw 'G-01 list shows a calendar icon that does not exist in the reference.' }
if ($card -match 'genealogy\.surname') { throw 'G-01 list seal still shows a surname instead of the fixed 家谱 seal.' }
if ($card -match 'card-copy') { throw 'G-01 list still retains the superseded split content column.' }
if ($card -notmatch '(?s)\.card-title-row\s*\{.*?justify-content:\s*space-between') { throw 'G-01 list title, role, and chevron do not share the required first row.' }
if ($page -notmatch '(?s)\.current-switch-copy\s*\{[^}]*border-radius:\s*999rpx;[^}]*font-size:\s*clamp\(15px, 25rpx, 18px\)') { throw 'G-01 switch copy is not a compact visual action.' }
if ($page -notmatch '(?s)\.current-meta\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*minmax\(0, 1fr\)\s+auto;[^}]*font-size:\s*clamp\(15px, 25rpx, 18px\)') { throw 'G-01 current metadata is not split into readable rows.' }
if ($page -notmatch '(?s)\.current-meta-item--location\s*\{[^}]*grid-column:\s*1\s*/\s*-1;') { throw 'G-01 location does not have its own metadata row.' }
if ($page -notmatch '(?s)\.current-meta-icon\s*\{[^}]*width:\s*34rpx;[^}]*height:\s*34rpx;[^}]*opacity:\s*1;') { throw 'G-01 current genealogy metadata icons do not use the compact readable size.' }
if ($page -notmatch '(?s)@media screen and \(max-width:\s*340px\)[^{]*\{.*?\.current-meta-icon\s*\{[^}]*width:\s*32rpx;[^}]*height:\s*32rpx;') { throw 'G-01 compact viewport metadata icons do not preserve the approved 32rpx size.' }
if ($card -notmatch '(?s)\.card-meta-icon\s*\{[^}]*width:\s*38rpx;[^}]*height:\s*38rpx;[^}]*flex:\s*0 0 auto;[^}]*opacity:\s*1;[^}]*filter:\s*saturate\(1\.35\) brightness\(0\.82\) contrast\(1\.15\);') { throw 'G-01 list location and member icons do not use the balanced compact size and contrast.' }
if ($card -notmatch '(?s)\.genealogy-card\s*\{[^}]*grid-template-columns:\s*68rpx\s+minmax\(0, 1fr\);[^}]*min-height:\s*204rpx;') { throw 'G-01 list cards do not reserve the readable three-row layout.' }
if ($card -notmatch '(?s)\.card-detail-row\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*minmax\(0, 1fr\)\s+auto;') { throw 'G-01 list details are not arranged as a two-column grid.' }
if ($card -notmatch '(?s)<view class="card-detail-row">\s*<view class="card-meta-item card-meta-item--location">.*?</view>\s*<view class="card-meta-item card-meta-item--members">.*?</view>\s*<view class="card-trailing">') { throw 'G-01 list card must show location, member count, then role in the readable order.' }
if ($card -notmatch '(?s)\.card-meta-item--location\s*\{[^}]*grid-column:\s*1\s*/\s*-1;') { throw 'G-01 list location does not have its own full row.' }
if ($card -notmatch '(?s)\.card-trailing\s*\{[^}]*grid-column:\s*2;[^}]*flex-direction:\s*column;') { throw 'G-01 role and update date are not anchored to the member-count row.' }
if ($card -notmatch 'v-if="genealogy\.updatedAt"') { throw 'G-01 must not show an empty update-date label.' }
if ($card -notmatch '(?s)@media screen and \(max-width:\s*340px\)[^{]*\{.*?\.genealogy-card\s*\{[^}]*min-height:\s*176rpx;') { throw 'G-01 compact list cards do not reserve the readable layout.' }
if ($card -notmatch '(?s)\.card-meta\s*\{[^}]*font-size:\s*clamp\(15px, 24rpx, 18px\)') { throw 'G-01 list metadata does not use the approved readable size.' }
if ($card -notmatch '(?s)\.card-updated\s*\{[^}]*font-size:\s*clamp\(14px, 22rpx, 17px\)') { throw 'G-01 list update date does not use the secondary readable size.' }
if ($card -notmatch '(?s)\.card-role\s*\{[^}]*border-radius:\s*999rpx;[^}]*font-size:\s*clamp\(14px, 23rpx, 17px\)') { throw 'G-01 list role is not presented as a compact status tag.' }
if ($page -match '(?s)\.create-action\s*>\s*text\s*\{[^}]*white-space:\s*nowrap') { throw 'G-01 create action text must not use a fixed single-line capacity.' }
if ($page -notmatch '(?s)\.application-record\s*\+\s*\.application-record\s*\{[^}]*margin-top:\s*12rpx') { throw 'G-01 adjacent application cards must keep the approved 12rpx separation.' }
if ($page -notmatch '(?s)\.application-record__copy\s*\{[^}]*color:\s*#62584c;[^}]*font-size:\s*clamp\(16px, 28rpx, 20px\);[^}]*font-weight:\s*500;[^}]*line-height:\s*1\.4;') { throw 'G-01 application descriptions do not use the approved readable style.' }
if ($page -notmatch '(?s)\.application-record__status\s*\{[^}]*color:\s*#7f4f16;[^}]*font-size:\s*clamp\(16px, 27rpx, 20px\);[^}]*font-weight:\s*600;') { throw 'G-01 application statuses do not use the approved readable base style.' }
if ($page -notmatch '(?s)\.application-record__status--rejected\s*\{[^}]*color:\s*#a7160c;') { throw 'G-01 rejected status must preserve the approved semantic red.' }
if ($page -notmatch '(?s)\.application-record__status--muted\s*\{[^}]*color:\s*#62584c;') { throw 'G-01 muted status does not use the approved readable color.' }
$manifest = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'manifest.json')
if ($manifest -notmatch '"statusbar"\s*:\s*\{\s*"immersed"\s*:\s*"supportedDevice"\s*,\s*"style"\s*:\s*"light"\s*,\s*"background"\s*:\s*"#B52E22"') { throw 'Android status bar is not configured for the cinnabar root header.' }
Write-Output 'PASS G-01 visual contract'
@@ -1,9 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g03-create-genealogy.vue') -Raw -Encoding UTF8
foreach ($token in @('sexOptions', 'mode="selector"', 'mode="date"', 'ancestorForm', 'createLocalGenealogyPreview')) {
if ($page.Contains($token)) { throw "G03 must not retain uncommitted first-ancestor flow: $token" }
}
if (-not $page.Contains('class="create-card__note"')) { throw 'G03 must retain a create-result guidance note' }
Write-Output 'G03-ANCESTOR-SEMANTIC-FIELDS-CONTRACT PASS'
@@ -1,62 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$issues = New-Object System.Collections.Generic.List[string]
function Read-RequiredFile {
param([string]$RelativePath)
$path = Join-Path $root $RelativePath
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
$script:issues.Add("missing required G03 file: $RelativePath")
return ''
}
return Get-Content -Raw -Encoding UTF8 -LiteralPath $path
}
$page = Read-RequiredFile 'pages/genealogy/g03-create-genealogy.vue'
$api = Read-RequiredFile 'utils/api.js'
$flowContract = Read-RequiredFile 'tests/g03-create-flow-contract.ps1'
$flowRuntime = Read-RequiredFile 'tests/g03-create-flow-runtime-smoke.js'
foreach ($required in @(
'appApi.createGenealogy',
'finishPage("G01", {}, {',
'entityId: created.id',
'requestController: createController'
)) {
if (-not $page.Contains($required)) { $issues.Add("G03 real create page missing: $required") }
}
foreach ($required in @(
"url: '/genealogy/app/genealogies'",
'normalizeCreatedGenealogy',
'requestStrict({'
)) {
if (-not $api.Contains($required)) { $issues.Add("G03 real create API missing: $required") }
}
foreach ($required in @(
'G03-CREATE-FLOW-CONTRACT PASS',
'G03-CREATE-FLOW-RUNTIME-SMOKE PASS'
)) {
$source = if ($required -like '*RUNTIME*') { $flowRuntime } else { $flowContract }
if (-not $source.Contains($required)) { $issues.Add("G03 verification missing: $required") }
}
foreach ($forbidden in @(
'createLocalGenealogyPreview',
'updateLocalGenealogyPreview',
'removeLocalGenealogyPreview',
'local-created-',
'setTimeout(',
'genealogies.unshift(created)'
)) {
if ($page.Contains($forbidden) -or $api.Contains($forbidden)) {
$issues.Add("G03 must not retain a local preview path: $forbidden")
}
}
if ($issues.Count -gt 0) {
Write-Output 'G03-BOOTSTRAP-CLIENT-RELEASE BLOCKED'
foreach ($issue in $issues) { Write-Output "- $issue" }
exit 1
}
Write-Output 'G03-BOOTSTRAP-CLIENT-RELEASE PASS'
-32
View File
@@ -1,32 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$snapshotPath = Join-Path $root 'APP.openapi.json'
$issues = New-Object System.Collections.Generic.List[string]
if (-not (Test-Path -LiteralPath $snapshotPath -PathType Leaf)) {
$issues.Add('missing protected OpenAPI snapshot')
} else {
$snapshot = Get-Content -Raw -Encoding UTF8 -LiteralPath $snapshotPath | ConvertFrom-Json
$paths = $snapshot.paths.PSObject.Properties.Name
if ('/genealogy/region/search' -notin $paths) {
$issues.Add('protected snapshot does not contain the Apifox APP directory owner GET /genealogy/region/search')
}
foreach ($unsupportedPath in @(
'/genealogy/app/region/search',
'/genealogy/app/genealogy-bootstrap-operations/{operationKey}'
)) {
if ($unsupportedPath -in $paths) {
$issues.Add("protected snapshot retains an unsupported G03 owner: $unsupportedPath")
}
}
}
if ($issues.Count -gt 0) {
Write-Output 'G03-BOOTSTRAP-OPENAPI-CONTRACT BLOCKED'
foreach ($issue in $issues) { Write-Output "- $issue" }
Write-Output '- Apifox is the current business owner; the protected snapshot is supplementary only and cannot authorize an atomic bootstrap or result-query implementation.'
exit 1
}
Write-Output 'G03-BOOTSTRAP-OPENAPI-CONTRACT PASS'
-76
View File
@@ -1,76 +0,0 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const toDataModuleUrl = (source) =>
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const run = async () => {
// 该测试只替身网络层:验证真实创建路径发出的 wire,不产生远端写入。
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
const prelude = `
const currentUser = {};
const genealogies = [];
const publicGenealogies = [];
const treeMembers = [];
const notifications = [];
const joinApplications = [];
const listFamilyFeedFixtures = () => [];
const listFamilyArticleFixtures = () => [];
const listFamilyAlbumFixtures = () => [];
const listCeremonyFixtures = () => [];
const listGrowthRecordFixtures = () => [];
const listNotificationFixtures = () => [];
const runtimeConfig = { baseUrl: "https://backend-api.ddxcjp.cn", clientId: "client-1", tenantId: "000000" };
const hasRemoteConfig = () => true;
const resolveRuntimeMode = () => "remote";
const AUTH_TAC_SCENE = Object.freeze({});
const assertSmsCode = (value) => value;
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" });
const fromApiGenealogyAccess = () => null;
const session = { getToken: () => "session-1", saveToken() {} };
`;
const requests = [];
globalThis.uni = {
request(options) {
requests.push(options);
queueMicrotask(() => options.success({ statusCode: 200, data: { code: 200, data: { genealogyId: "1001" } } }));
return { abort() {} };
},
};
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
await appApi.createGenealogy({
genealogyName: "王氏家谱",
surname: "王",
regionCode: "110101",
coverOssId: "9007199254740993",
});
const request = requests.at(-1);
assert.strictEqual(request.method, "POST");
assert.strictEqual(request.url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies");
assert.deepStrictEqual(request.header, {
clientid: "client-1",
tenantId: "000000",
Authorization: "Bearer session-1",
});
assert.deepStrictEqual(request.data, {
genealogyName: "王氏家谱",
surname: "王",
regionCode: "110101",
coverOssId: "9007199254740993",
});
delete globalThis.uni;
process.stdout.write("G03-CREATE-API-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
-76
View File
@@ -1,76 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding utf8 | ConvertFrom-Json
$paths = @($pages.pages.path)
$g03 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g03-create-genealogy.vue') -Raw -Encoding utf8
$api = Get-Content -LiteralPath (Join-Path $root 'utils/api.js') -Raw -Encoding utf8
$g04 = Join-Path $root 'pages/genealogy/g04-first-ancestor.vue'
if (-not ($paths -contains 'pages/genealogy/g03-create-genealogy')) { throw 'G03 route missing' }
if ($paths -contains 'pages/genealogy/g04-first-ancestor') { throw 'G04 route must remain removed' }
if (Test-Path -LiteralPath $g04) { throw 'G04 page file must remain removed' }
foreach ($required in @(
'v-model="form.surname"',
'v-model="form.genealogyName"',
'getRegionChildren',
'selectedRegion',
'regionPickerOpen',
'picker-view',
'picker-view-column',
'handleRegionPickerChange',
'regionPickerIndicatorStyle',
'region-sheet__picker-view',
'confirmRegionSelection',
'regionPickerColumns',
'v-model="form.originPlace"',
'v-model="form.addressDetail"',
'v-model="form.intro"',
'pickAndUploadImage',
'coverOssId.value = receipt.ossId',
'coverOssId.value',
'GENEALOGY_ACCESS_PRESET_OPTIONS',
'toApiGenealogyAccess',
'appApi.createGenealogy',
'finishPage("G01", {}, {',
'operation: "genealogy-created"',
'createRequestController()',
'createController.abort()'
)) {
if (-not $g03.Contains($required)) { throw "G03 real-create contract missing: $required" }
}
if ($g03 -match 'v-model="(?:form\.)?regionCode"') { throw 'G03 must not expose a raw region code input' }
if ($g03 -match 'v-model="(?:form\.)?coverOssId"') { throw 'G03 must not expose a raw cover OSS ID input' }
if ($g03 -match '(?s)<AppDialog[^>]*:visible="regionPickerOpen"') { throw 'G03 region selector must not use the generic dialog card wall' }
if ($g03.Contains('继续选择')) { throw 'G03 region selector must not repeat a continuation label on every option' }
foreach ($forbidden in @(
'createLocalGenealogyPreview',
'updateLocalGenealogyPreview',
'removeLocalGenealogyPreview',
'local-created-',
'create-flow-panel',
'flow-success-dialog',
'录入首代人物',
'Date.now()',
'goRegionParent',
'region-picker__back',
'region-picker-sheet'
)) {
if ($g03.Contains($forbidden)) { throw "G03 must not retain speculative flow code: $forbidden" }
}
foreach ($required in @(
"url: '/genealogy/app/genealogies'",
"method: 'POST'",
'normalizeGenealogyCreatePayload',
'normalizeCreatedGenealogy',
'coverOssId',
'REMOTE_WRITE_REQUIRED'
)) {
if (-not $api.Contains($required)) { throw "G03 API contract missing: $required" }
}
if ($api.Contains('genealogies.unshift(created)')) { throw 'G03 API must not create a local preview record' }
Write-Output 'G03-CREATE-FLOW-CONTRACT PASS'
-113
View File
@@ -1,113 +0,0 @@
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
const cdpPort = process.env.CDP_PORT || "9222";
const connect = async () => {
const pages = await (await fetch(`http://127.0.0.1:${cdpPort}/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 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 origin = process.argv[2] || "http://localhost:5173";
const openG03 = async (send, suffix = "") => {
const url = `${origin}/?g03CreateAudit=${Date.now()}${suffix}#${"/pages/genealogy/g03-create-genealogy"}`;
await send("Page.navigate", { url });
await waitFor(send, `location.href === ${JSON.stringify(url)}`, "G03 navigation failed");
await waitFor(send, "Boolean(document.querySelector('.create-card'))", "G03 create form did not render");
};
const run = async () => {
const { socket, send, exceptions } = await connect();
try {
await send("Page.enable");
await send("Runtime.enable");
await openG03(send);
const text = await valueOf(send, "document.querySelector('.create-card')?.textContent || ''");
for (const required of ["立谱信息", "所在地区", "确认创建家谱"]) {
if (!text.includes(required)) throw new Error(`G03 create form missing: ${required}`);
}
const inputCount = await valueOf(send, "document.querySelectorAll('.create-card input').length");
if (inputCount !== 5) throw new Error(`G03 expected five text inputs plus region and upload selectors, got ${inputCount}`);
const regionSelector = await valueOf(send, "Boolean(document.querySelector('.field-row--selector'))");
if (!regionSelector) throw new Error("G03 region selector did not render");
const uploadControl = await valueOf(send, "Boolean(document.querySelector('.upload-button'))");
if (!uploadControl) throw new Error("G03 cover upload control did not render");
const introControl = await valueOf(send, "Boolean(document.querySelector('.create-card textarea'))");
if (!introControl) throw new Error("G03 optional intro field did not render");
const fakeControl = await valueOf(send, "Boolean(document.querySelector('.flow-success-dialog'))");
if (fakeControl) throw new Error("G03 must not expose the retired local bootstrap flow");
await valueOf(send, `(() => {
const input = document.querySelector('.create-card input');
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
setter.call(input, 'discard-check');
input.dispatchEvent(new Event('input', { bubbles: true }));
document.querySelector('.header-back')?.click();
return true;
})()`);
await waitFor(send, "Boolean(document.querySelector('.app-dialog-layer'))", "G03 dirty back did not require discard confirmation");
await valueOf(send, "document.querySelector('.app-dialog__actions .app-button:first-child')?.click()");
await waitFor(send, "!document.querySelector('.app-dialog-layer')", "G03 discard cancellation did not close the dialog");
if ((await valueOf(send, "document.querySelector('.create-card input')?.value")) !== "discard-check") {
throw new Error("G03 discard cancellation lost the draft");
}
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 openG03(send, `&width=${size.width}`);
const scrollWidth = await valueOf(send, "document.documentElement.scrollWidth");
if (scrollWidth > size.width + 1) throw new Error(`G03 create form 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();
}
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
-20
View File
@@ -1,20 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g03-create-genealogy.vue') -Raw -Encoding utf8
$style = [regex]::Match($page, '(?s)<style[^>]*>(.*?)</style>').Groups[1].Value
foreach ($required in @(
'@include adaptive-genealogy-state-panel;',
'.create-card',
'.field-row',
'.region-sheet',
'.access-rule__option--active',
'.create-card .app-button'
)) {
if ($page -notmatch [regex]::Escape($required)) { throw "G03 create form missing: $required" }
}
foreach ($forbidden in @('flow-header', 'root-header-cinnabar.jpg', 'duplicate-reminder', 'flow-success-dialog')) {
if ($page -match [regex]::Escape($forbidden)) { throw "G03 must not retain retired flow token: $forbidden" }
}
Write-Output 'G03-DOCUMENT-FLOW-CONTRACT PASS'
-19
View File
@@ -1,19 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$g03 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g03-create-genealogy.vue') -Raw -Encoding utf8
function Assert-Match {
param([string]$Content, [string]$Pattern, [string]$Message)
if ($Content -notmatch $Pattern) { throw $Message }
}
if ($g03 -match '<text>返回</text>') { throw 'G03 header must use the shared PageHeader back control' }
Assert-Match -Content $g03 -Pattern '(?s)<PageHeader\s+title="[^"]+"\s+custom-back\s+@back="backToGenealogies"\s*/>' -Message 'G03 must use PageHeader with its guarded back handler'
if ($g03 -match 'flow-header|flow-header__back|flow-header__title') { throw 'G03 must not retain a private header implementation' }
Assert-Match -Content $g03 -Pattern '(?s)\.create-card__note\s*\{[^}]*font-size:\s*clamp\(15px, 25rpx, 18px\);' -Message 'G03 guidance copy must use the approved readable size'
Assert-Match -Content $g03 -Pattern '(?s)\.access-rule__option\s*\{[^}]*font-size:\s*clamp\(15px, 25rpx, 18px\);' -Message 'G03 visibility options must use the approved readable size'
Assert-Match -Content $g03 -Pattern '(?s)\.field-error,\s*\.submit-error\s*\{[^}]*font-size:\s*clamp\(15px, 24rpx, 18px\);[^}]*line-height:\s*1\.5;' -Message 'G03 validation errors must use the approved readable size'
Assert-Match -Content $g03 -Pattern '(?s)\.create-card\s*\.app-button\s*\{[^}]*margin-top:\s*32rpx;' -Message 'G03 primary action spacing must remain stable'
Write-Output 'G03-VISUAL-STATES-CONTRACT PASS'
-18
View File
@@ -1,18 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$g05 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g05-genealogy-overview.vue') -Raw -Encoding utf8
function Assert-Match { param([string]$Pattern,[string]$Message); if ($g05 -notmatch $Pattern) { throw $Message } }
Assert-Match 'overview-surface--state' 'G05 system states must own an independent surface'
Assert-Match '(?s)\.overview-state\s*\{[^}]*@include\s+adaptive\.adaptive-genealogy-state-panel;' 'G05 system states must consume the shared adaptive state-panel profile'
Assert-Match '(?s)class="overview-state__seal".*?brand-seal\.png' 'G05 non-loading states must use the approved red-gold seal'
Assert-Match '(?s)\.overview-surface\s*\{[^}]*@include\s+adaptive\.adaptive-g05-overview-surface;' 'G05 ready surface must consume the shared adaptive overview profile'
Assert-Match '(?s)\.overview-state__seal\s*\{[^}]*width:\s*132rpx;[^}]*height:\s*136rpx;' 'G05 state seal must use the approved size'
Assert-Match 'class="overview-summary"' 'G05 member mode must fill the illustrated lower cells with real read-only membership information'
Assert-Match '(?s)\.overview-actions\s*\{[^}]*min-height:\s*386rpx;' 'G05 actions must retain a minimum two-row surface while allowing data growth'
Assert-Match '(?s)\.overview-public__details > view:nth-child\(5\)\s*\{[^}]*grid-column:\s*1 / -1;' 'G05 public fifth identity detail must span the full row'
Assert-Match '(?s)</view>\s*<view class="overview-public__notice">.*?publicDescription.*?</view>\s*<view\s+v-if="viewMode === ''public'' && publicActionLabel"\s+class="overview-public__action"' 'G05 public description must occupy the dedicated bottom note surface'
Assert-Match '(?s)\.overview-public__action\s*\{[^}]*min-height:\s*82rpx;' 'G05 public action must retain its visual minimum while allowing the public content to grow'
Write-Output 'G05-ALL-STATES-VISUAL-CONTRACT PASS'
-18
View File
@@ -1,18 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g05-genealogy-overview.vue') -Raw -Encoding UTF8
$mock = Get-Content -LiteralPath (Join-Path $root 'data/mock.js') -Raw -Encoding UTF8
foreach ($token in @('appApi.getOverview', 'createRequestController', 'isRequestCancelled', 'getGenealogyAccessPresetLabel', 'result.accessRole', 'overviewRequestController.abort()', 'onUnload')) {
if (-not $page.Contains($token)) { throw "G05 data ownership contract missing: $token" }
}
foreach ($token in @('export const publicGenealogies', 'export const createLocalGenealogyPreview', 'export const updateLocalGenealogyPreviewAncestor', 'export const findGenealogyFixture', 'export const getGenealogyFixtureAccess', 'fixture?.localPreview', "memberFixture?.membership === 'created'", "memberFixture?.membership === 'joined'")) {
if (-not $mock.Contains($token)) { throw "Shared genealogy fixture ownership missing: $token" }
}
foreach ($forbidden in @('overviewFixtures', 'query.genealogyName', 'query.role', 'query.mode')) {
if ($page.Contains($forbidden)) { throw "G05 retains duplicate or untrusted data owner: $forbidden" }
}
if ($page.Contains('getGenealogyVisibilityLabel') -or $page.Contains('genealogy.visibility')) { throw 'G05 retains the deleted visibility-only contract' }
if ($page -match '@/data/mock\.js|findGenealogyFixture|getGenealogyFixtureAccess') { throw 'G05 remote overview must not retain fixture owners' }
if ($mock -match "(?s)const localCreatedGenealogy = \{.*?membership:\s*'created'") { throw 'Local-created URL fixture must never grant owner membership' }
if ($page.Contains('class="overview-action-lock"')) { throw 'G05 ordinary members must not see inert management lock cards' }
Write-Output 'G05-DATA-OWNERSHIP-CONTRACT PASS'

Some files were not shown because too many files have changed in this diff Show More