修改完成55%

This commit is contained in:
2026-07-15 08:31:21 +08:00
parent 8c98936da5
commit 241a7af54c
98 changed files with 3269 additions and 782 deletions
+1 -1
View File
@@ -8,6 +8,6 @@ $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 ($catalog -match '(?m)^\s*a03\s*:') { throw 'A-03 catalog entry must be deleted' }
if ($pages.pages.Count -ne 55) { throw "Expected 55 final routes after the approved G02-to-G01, G04-to-G03 and G07-to-G06 state merges, found $($pages.pages.Count)." }
if ($pages.pages.Count -ne 54) { throw "Expected 54 final routes after the approved G02-to-G01, G04-to-G03, G07-to-G06 and T02-to-T01 state merges, found $($pages.pages.Count)." }
Write-Output 'A03-ROUTE-REMOVAL-CONTRACT PASS'
+20 -1
View File
@@ -42,5 +42,24 @@ if ($captureScript -notmatch 'performance\.timeOrigin') {
if ($captureScript -notmatch 'await waitForFreshDocument\(send, documentTimeOrigin\)') {
throw 'Chrome capture must wait for a fresh document before checking the target selector'
}
if ($captureScript -notmatch "action !== 'fresh-navigation'") {
throw 'Chrome capture must support fresh-navigation for root pages whose tab lifecycle closes a reloaded CDP target'
}
foreach ($g06Action in @('g06-results', 'g06-empty')) {
if ($captureScript -notmatch [regex]::Escape($g06Action)) {
throw "Chrome capture must support the $g06Action visual state"
}
}
if ($captureScript -notmatch 'const prepareG06State = async \(send, action\) =>') {
throw 'Chrome capture must prepare G06 search states through the real input and action controls'
}
if ($captureScript -notmatch 'await prepareG06State\(send, action\)') {
throw 'Chrome capture must wait for the requested G06 state before taking a screenshot'
}
if ($captureScript -notmatch "item\.url\.startsWith\('http://localhost:5173'\)") {
throw 'Chrome capture must select the application page instead of a DevTools page containing localhost in its query'
}
if ($captureScript -match "item\.url\.includes\('localhost:5173'\)") {
throw 'Chrome capture must not use an ambiguous localhost substring when selecting its CDP target'
}
Write-Output 'CAPTURE-CHROME-PAGE-CONTRACT PASS'
+6 -6
View File
@@ -93,11 +93,10 @@ if ($applications -notmatch 'genealogyContext') {
}
$notifications = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages/notification/n01-message-center.vue')
foreach ($method in @('markNotificationRead', 'markAllNotificationsRead')) {
if ($notifications -notmatch [regex]::Escape($method)) {
throw "Notification page does not use $method"
}
foreach ($method in @('readNotice', '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', 'pages/family/f04-article-list', 'pages/family/f07-album-list', 'pages/records/r05-ritual-list', 'pages/records/r10-memo-list', 'pages/family/f02-publish-feed')) {
@@ -107,10 +106,11 @@ foreach ($token in @('genealogyContext', 'openSection', 'pages/family/f04-articl
}
$profile = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages/profile/m01-profile-home.vue')
foreach ($token in @('appApi', 'getProfile', 'profile.name')) {
foreach ($token in @('profile-state--ready', 'menuItems', 'toProfile')) {
if ($profile -notmatch [regex]::Escape($token)) {
throw "Missing dynamic profile behavior: $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'
+4 -4
View File
@@ -11,7 +11,7 @@ $pageHeader = ConvertFrom-Utf8Base64 '6aG16Z2i57yW5Y+377ya'
$expectedPages = @(
'pages/auth/a01-entry', 'pages/auth/a02-login', 'pages/auth/a04-register', 'pages/auth/a05-reset-password', 'pages/auth/a06-auth-status',
'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/t02-tree-states', '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/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',
@@ -20,11 +20,11 @@ $expectedPages = @(
$pages = Get-Content -LiteralPath $pagesPath -Raw -Encoding utf8 | ConvertFrom-Json
$actualPaths = @($pages.pages.path)
if ($actualPaths.Count -ne 55) {
throw "Expected 55 visual routes after the approved G02-to-G01, G04-to-G03 and G07-to-G06 state merges, found $($actualPaths.Count)."
if ($actualPaths.Count -ne 54) {
throw "Expected 54 visual routes after the approved G02-to-G01, G04-to-G03, G07-to-G06 and T02-to-T01 state merges, found $($actualPaths.Count)."
}
if (Compare-Object -ReferenceObject $expectedPages -DifferenceObject $actualPaths) {
throw 'pages.json routes do not match the 55-route visual delivery contract.'
throw 'pages.json routes do not match the 54-route visual delivery contract.'
}
foreach ($page in $expectedPages) {
+10 -7
View File
@@ -2,7 +2,7 @@ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.includes('localhost:5173'))
const page = pages.find((item) => item.type === 'page' && item.url.startsWith(origin))
if (!page) throw new Error('Chrome debugging has no localhost:5173 page')
const socket = new WebSocket(page.webSocketDebuggerUrl)
@@ -50,14 +50,17 @@ const waitFor = async (send, expression, message) => {
const origin = process.argv[2] || 'http://localhost:5173'
const g01Path = '/pages/genealogy/g01-my-genealogies'
const emptyUrl = `${origin}/#${g01Path}?state=empty`
const defaultUrl = `${origin}/#${g01Path}`
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 send('Page.reload')
await waitFor(send, "Boolean(document.querySelector('.genealogy-empty-state .empty-create-action'))", 'G01 empty state did not render after reload')
await waitFor(send, "Boolean(document.querySelector('.genealogy-empty-state .empty-create-action'))", 'G01 empty state did not render')
}
const run = async () => {
@@ -75,10 +78,10 @@ const run = async () => {
await valueOf(send, "document.querySelector('.empty-search-action')?.click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g06-search-genealogies')", 'G01 empty search action did not open G06')
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 send('Page.reload')
await waitFor(send, "Boolean(document.querySelector('.current-slip'))", 'Default G01 list did not render after reload')
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')
}
+5
View File
@@ -45,6 +45,11 @@ const waitFor = async (send, expression, message) => {
}
const setInput = async (send, index, value) => {
await waitFor(
send,
`Boolean(document.querySelectorAll('.flow-fields input')[${index}])`,
`G03 input ${index} did not render`
)
const expression = `(() => {
const input = document.querySelectorAll('.flow-fields input')[${index}]
if (!input) return false
+49
View File
@@ -0,0 +1,49 @@
$ErrorActionPreference = 'Stop'
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message }
}
function Assert-NoCssSurface {
param([string]$Content, [string]$ClassName)
foreach ($property in @('border', 'background', 'border-radius')) {
if ($Content -match "(?s)\\.$ClassName\\s*\\{[^}]*\\b$property\\s*:") {
throw "G05 must not construct .$ClassName with CSS $property"
}
}
}
$root = Split-Path -Parent $PSScriptRoot
$g01 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g01-my-genealogies.vue') -Raw -Encoding utf8
$g05 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g05-genealogy-overview.vue') -Raw -Encoding utf8
$asset = Join-Path $root 'static/assets/modules/genealogy/opaque/g05-overview-surface.png'
Assert-Contains $g01 'g05-genealogy-overview?genealogyId=' 'G01 must open G05 with the genealogyId query contract'
if ($g01 -match 'g05-genealogy-overview\?id=') { throw 'G01 must not retain the obsolete G05 id query key' }
foreach ($required in @(
"const overviewState = ref('loading')",
"query.genealogyId",
'overview-ready',
'overview-state--empty',
'overview-state--error',
'g05-overview-surface.png',
'/pages/genealogy/g03-create-genealogy?step=ancestor&genealogyId=',
'/pages/tree/t01-tree-overview?genealogyId=',
'/pages/genealogy/g10-application-review?genealogyId=',
'/pages/genealogy/g11-genealogy-settings?genealogyId=',
'/pages/genealogy/g12-generation-poems?genealogyId=',
'/pages/family/f01-family-feed'
)) {
Assert-Contains $g05 $required "Missing G05 overview contract: $required"
}
if ($g05 -match 'g04-first-ancestor') { throw 'G05 must not navigate to the removed G04 route' }
if ($g05 -match 'toJoinApplication') { throw 'G05 must not mislabel the applicant join form as an owner invitation action' }
foreach ($className in @('overview-surface', 'overview-action', 'overview-state-panel')) {
Assert-NoCssSurface $g05 $className
}
if (-not (Test-Path -LiteralPath $asset)) { throw 'G05 requires final opaque asset: g05-overview-surface.png' }
Write-Output 'G05-OVERVIEW-CONTRACT PASS'
+66
View File
@@ -0,0 +1,66 @@
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const origin = process.argv[2] || 'http://localhost:5173'
const route = '/pages/genealogy/g05-genealogy-overview'
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} 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()
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 < 40; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(100)
}
throw new Error(message)
}
const open = async (send, query, selector) => {
const url = `${origin}/#${route}${query}`
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, 'G05 navigation failed')
await send('Page.reload')
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `G05 ${query} did not render`)
}
const run = async () => {
const { socket, send } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
await open(send, '?state=empty', '.overview-state--empty')
await open(send, '?state=error&genealogyId=1001', '.overview-state--error')
await open(send, '?genealogyId=1001', '.overview-ready')
await valueOf(send, "document.querySelector('.overview-action--ancestor')?.click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g03-create-genealogy?step=ancestor&genealogyId=1001')", 'G05 ancestor action did not open the G03 ancestor step')
process.stdout.write('G05-OVERVIEW-RUNTIME-SMOKE PASS\n')
} finally {
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+58
View File
@@ -0,0 +1,58 @@
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
$root = Split-Path -Parent $PSScriptRoot
$screenshots = Join-Path $root 'docs/design/screens/runtime/2026-07-14'
$files = [ordered]@{
initial = Join-Path $screenshots 'G06-selected-strip-final-initial-recheck-360x800.png'
results = Join-Path $screenshots 'G06-selected-strip-final-results-360x800.png'
empty = Join-Path $screenshots 'G06-selected-strip-final-empty-360x800.png'
}
$images = @{}
try {
foreach ($state in $files.Keys) {
$file = $files[$state]
if (-not (Test-Path -LiteralPath $file)) {
throw "Missing G06 $state screenshot: $file"
}
$image = [System.Drawing.Bitmap]::new($file)
$images[$state] = $image
if ($image.Width -ne 360 -or $image.Height -ne 800) {
throw "G06 $state screenshot must be 360x800, got $($image.Width)x$($image.Height)"
}
foreach ($point in @(@(0, 0), @(359, 0), @(0, 799), @(359, 799))) {
if ($image.GetPixel($point[0], $point[1]).A -ne 255) {
throw "G06 $state screenshot must have an opaque viewport"
}
}
}
foreach ($state in @('results', 'empty')) {
$stableDifferenceCount = 0
$fullDifferenceCount = 0
for ($y = 0; $y -lt 800; $y++) {
for ($x = 0; $x -lt 360; $x++) {
if ($images.initial.GetPixel($x, $y).ToArgb() -ne $images[$state].GetPixel($x, $y).ToArgb()) {
$fullDifferenceCount++
if ($y -lt 150) { $stableDifferenceCount++ }
}
}
}
if ($stableDifferenceCount -ne 0) {
throw "G06 $state screenshot changed $stableDifferenceCount pixels in the stable header/title region"
}
if ($fullDifferenceCount -lt 100) {
throw "G06 $state screenshot does not visibly differ from the initial state"
}
}
}
finally {
foreach ($image in $images.Values) { $image.Dispose() }
}
Write-Output 'G06-SCREENSHOT-CONSISTENCY PASS'
+1 -1
View File
@@ -2,7 +2,7 @@ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.includes('localhost:5173'))
const page = pages.find((item) => item.type === 'page' && item.url.startsWith('http://localhost:5173'))
if (!page) throw new Error('Chrome debugging has no localhost:5173 page')
const socket = new WebSocket(page.webSocketDebuggerUrl)
@@ -0,0 +1,78 @@
$ErrorActionPreference = 'Stop'
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message }
}
function Assert-NoCssSurface {
param([string]$Content, [string]$ClassName)
foreach ($property in @('border', 'background', 'border-radius')) {
if ($Content -match "(?s)\\.$ClassName\\s*\\{[^}]*\\b$property\\s*:") {
throw "Application flow must not construct .$ClassName with CSS $property"
}
}
}
$root = Split-Path -Parent $PSScriptRoot
$g08 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g08-join-application.vue') -Raw -Encoding utf8
$g09 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g09-my-applications.vue') -Raw -Encoding utf8
$g10 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g10-application-review.vue') -Raw -Encoding utf8
$catalog = Get-Content -LiteralPath (Join-Path $root 'data/page-catalog.js') -Raw -Encoding utf8
foreach ($page in @($g08, $g09, $g10)) {
if ($page -match '<ModulePage') { throw 'G08-G10 must not retain the generic ModulePage shell' }
Assert-Contains $page 'page-paper.jpg' 'G08-G10 must use the accepted genealogy paper background'
if ($page -match "@/utils/api\.js|\bappApi\b") { throw 'G08-G10 design phase must not connect the API layer' }
}
foreach ($required in @(
"const joinState = ref('form')",
'join-state--form',
'join-state--success',
'join-state--error',
'g03-create-flow-panel.png',
'a01-primary-button.png',
'const genealogyPreview',
'query.genealogyId',
'/pages/genealogy/g09-my-applications'
)) { Assert-Contains $g08 $required "Missing G08 join contract: $required" }
foreach ($required in @(
"const applicationState = ref('loading')",
'application-state--list',
'application-state--empty',
'application-state--error',
'application-status-card.png',
"'PENDING'",
"'APPROVED'",
"'REJECTED'",
'const applicationSamples'
)) { Assert-Contains $g09 $required "Missing G09 application contract: $required" }
foreach ($required in @(
"const reviewState = ref('loading')",
'review-state--list',
'review-state--empty',
'review-state--error',
'application-record-card.png',
'application-status-card.png',
'a01-primary-button.png',
'a01-secondary-button.png',
'const reviewSamples',
'uni.showModal'
)) { Assert-Contains $g10 $required "Missing G10 review contract: $required" }
foreach ($className in @('join-panel', 'join-action', 'application-card', 'review-action')) {
foreach ($page in @($g08, $g09, $g10)) { Assert-NoCssSurface $page $className }
}
foreach ($entry in @('g07:', 'g08:', 'g09:')) {
if ($catalog -match [regex]::Escape($entry)) { throw "Page catalog must remove migrated generic entry: $entry" }
}
$asset = Join-Path $root 'static/assets/modules/genealogy/opaque/application-record-card.png'
if (-not (Test-Path -LiteralPath $asset)) { throw 'G08-G10 require application-record-card.png' }
$statusAsset = Join-Path $root 'static/assets/modules/genealogy/opaque/application-status-card.png'
if (-not (Test-Path -LiteralPath $statusAsset)) { throw 'G09 and application empty states require application-status-card.png' }
Write-Output 'G08-G10-APPLICATION-FLOW-CONTRACT PASS'
@@ -0,0 +1,79 @@
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
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} 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()
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 < 40; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(100)
}
throw new Error(message)
}
let auditId = 0
const open = async (send, route, query, selector) => {
auditId += 1
const url = `${origin}/?g0810Audit=${auditId}#${route}${query}`
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `${route} navigation failed`)
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `${route}${query} did not render`)
}
const run = async () => {
const { socket, send } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
await open(send, '/pages/genealogy/g08-join-application', '?genealogyId=1002', '.join-state--form')
await waitFor(send, "document.querySelectorAll('.join-field input').length >= 2", 'G08 real input controls did not render')
const prepared = await valueOf(send, `(() => {
const inputs = Array.from(document.querySelectorAll('.join-field input'))
if (inputs.length < 2) return false
inputs[0].value = '汤明远'
inputs[0].dispatchEvent(new Event('input', { bubbles: true }))
inputs[1].value = '汤正华堂侄'
inputs[1].dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector('.join-action')?.click()
return true
})()`)
if (!prepared) throw new Error('G08 form controls could not be prepared')
await waitFor(send, "Boolean(document.querySelector('.join-state--success'))", 'G08 real form submission did not reach the same-page success state')
await open(send, '/pages/genealogy/g09-my-applications', '', '.application-state--list')
await open(send, '/pages/genealogy/g09-my-applications', '?state=empty', '.application-state--empty')
await open(send, '/pages/genealogy/g10-application-review', '?genealogyId=1001', '.review-state--list')
await open(send, '/pages/genealogy/g10-application-review', '?state=empty&genealogyId=1001', '.review-state--empty')
process.stdout.write('G08-G10-APPLICATION-FLOW-RUNTIME-SMOKE PASS\n')
} finally {
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+63
View File
@@ -0,0 +1,63 @@
$ErrorActionPreference = 'Stop'
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message }
}
function Assert-NoCssSurface {
param([string]$Content, [string]$ClassName)
foreach ($property in @('border', 'background', 'border-radius')) {
if ($Content -match "(?s)\\.$ClassName\\s*\\{[^}]*\\b$property\\s*:") {
throw "G11-G12 must not construct .$ClassName with CSS $property"
}
}
}
$root = Split-Path -Parent $PSScriptRoot
$g11 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g11-genealogy-settings.vue') -Raw -Encoding utf8
$g12 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g12-generation-poems.vue') -Raw -Encoding utf8
$catalog = Get-Content -LiteralPath (Join-Path $root 'data/page-catalog.js') -Raw -Encoding utf8
foreach ($page in @($g11, $g12)) {
if ($page -match '<ModulePage') { throw 'G11-G12 must not retain the generic ModulePage shell' }
Assert-Contains $page 'page-paper.jpg' 'G11-G12 must use the accepted genealogy paper background'
Assert-Contains $page 'g03-create-flow-panel.png' 'G11-G12 forms must reuse the complete genealogy paper panel'
if ($page -match "@/utils/api\.js|\bappApi\b") { throw 'G11-G12 design phase must not connect the API layer' }
}
foreach ($required in @(
"const settingsState = ref('loading')",
'settings-state--form',
'settings-state--success',
'settings-state--error',
'g06-search-input-wide.png',
'a01-primary-button.png',
'const genealogyDraft',
'query.genealogyId',
"'MEMBER_ONLY'",
"'PUBLIC_APPLY'"
)) { Assert-Contains $g11 $required "Missing G11 settings contract: $required" }
foreach ($required in @(
"const poemState = ref('loading')",
'poem-state--list',
'poem-state--empty',
'poem-state--edit',
'poem-state--error',
'g06-search-input-wide.png',
'a01-primary-button.png',
'a01-secondary-button.png',
'const poemRows',
'stopMissingOldGeneration',
'query.genealogyId'
)) { Assert-Contains $g12 $required "Missing G12 generation-poem contract: $required" }
foreach ($className in @('settings-panel', 'settings-action', 'poem-row', 'poem-editor', 'poem-action')) {
foreach ($page in @($g11, $g12)) { Assert-NoCssSurface $page $className }
}
foreach ($entry in @('g11:', 'g12:')) {
if ($catalog -match [regex]::Escape($entry)) { throw "Page catalog must remove migrated generic entry: $entry" }
}
Write-Output 'G11-G12-SETTINGS-POEMS-CONTRACT PASS'
@@ -0,0 +1,65 @@
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
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} 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()
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 < 40; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(100)
}
throw new Error(message)
}
const open = async (send, route, query, selector) => {
const url = `${origin}/#${route}${query}`
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `${route} navigation failed`)
await send('Page.reload')
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `${route}${query} did not render`)
}
const run = async () => {
const { socket, send } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
await open(send, '/pages/genealogy/g11-genealogy-settings', '?genealogyId=1001', '.settings-state--form')
await open(send, '/pages/genealogy/g11-genealogy-settings', '?state=success&genealogyId=1001', '.settings-state--success')
await open(send, '/pages/genealogy/g12-generation-poems', '?genealogyId=1001', '.poem-state--list')
await open(send, '/pages/genealogy/g12-generation-poems', '?state=empty&genealogyId=1001', '.poem-state--empty')
await open(send, '/pages/genealogy/g12-generation-poems', '?state=edit&genealogyId=1001', '.poem-state--edit')
process.stdout.write('G11-G12-SETTINGS-POEMS-RUNTIME-SMOKE PASS\n')
} finally {
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+46
View File
@@ -0,0 +1,46 @@
$ErrorActionPreference = 'Stop'
$decode = {
param([string]$value)
[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($value))
}
$handoff = Get-Content -Raw -Encoding UTF8 (& $decode 'ZG9jcy/kuqTmjqXorrDlvZUubWQ=')
$manual = Get-Content -Raw -Encoding UTF8 (& $decode 'ZG9jcy9kZXNpZ24v6KeG6KeJ6K6+6K6h5Lqk5o6l5omL5YaMLm1k')
$planning = Get-Content -Raw -Encoding UTF8 (& $decode 'ZG9jcy/op4TliJIubWQ=')
foreach ($required in @(
(& $decode 'NTQg5p2h5pyA57uI6Lev55Sx'),
(& $decode 'NyDkuKrlt7LpqozmlLY='),
(& $decode 'NDcg5Liq5b6F5a6h5qC45YCZ6YCJ'),
'product-design:index',
'product-design:audit',
'imagegen',
'superpowers:test-driven-development',
'superpowers:systematic-debugging',
'superpowers:verification-before-completion',
(& $decode 'ZG9jcy9kZXNpZ24v6KeG6KeJ6K+B5o2u57Si5byVLm1k')
)) {
if ($handoff -notmatch [regex]::Escape($required)) {
throw "handoff missing: $required"
}
}
foreach ($stale in @(
(& $decode '5YWt5Liq5bey6aqM5pS2'),
(& $decode '57uT5p6c5oCB5ZKM5peg57uT5p6c5oCB55qE5ZCM54mI6YeN5paw5oiq5Zu+5bCa5pyq5a6M5oiQ'),
(& $decode '57uT5p6c5oCBL+aXoOe7k+aenOaAgeeahOWQjOeJiOaIquWbvuWSjOeUqOaIt+Wuoee+juehruiupOWwmuacquWujOaIkA=='),
(& $decode '5LiL5LiA6aG55oyJIEbjgIFS44CBTuOAgU0g5o6o6L+b')
)) {
if (($handoff + $manual) -match [regex]::Escape($stale)) {
throw "stale handoff text remains: $stale"
}
}
$approved = ([regex]::Matches($planning, '- \[x\]')).Count
$candidate = ([regex]::Matches($planning, '- \[~\]')).Count
if ($approved -ne 7 -or $candidate -ne 47) {
throw "planning counts changed: approved=$approved candidate=$candidate"
}
Write-Output 'HANDOFF-DOCUMENTATION-CONTRACT PASS'
+41
View File
@@ -0,0 +1,41 @@
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
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} 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 i = 0; i < 50; i += 1) { if (await valueOf(send, expression)) return; await sleep(100) } throw new Error(message) }
let auditId = 0
const open = async (send, route, query, selector) => {
auditId += 1
const url = `${origin}/?moduleAudit=${auditId}#${route}${query}`; 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)}))`, `State missing: ${route}${query}`)
}
const run = async () => {
const { socket, send } = await connect()
try {
await send('Page.enable'); await send('Runtime.enable')
await open(send, '/pages/family/f04-article-list', '', '.module-page--list.module-state--ready')
await open(send, '/pages/family/f06-article-editor', '', '.module-page--form.module-state--ready')
await open(send, '/pages/family/f03-feed-detail', '', '.module-page--detail.module-state--ready')
await open(send, '/pages/records/r08-growth-journal', '', '.module-page--timeline.module-state--ready')
await open(send, '/pages/profile/m03-security-settings', '', '.module-page--settings.module-state--ready')
await open(send, '/pages/family/f10-video-list', '', '.module-page--status.module-state--ready')
await open(send, '/pages/family/f04-article-list', '?state=empty', '.module-state--empty')
await open(send, '/pages/family/f06-article-editor', '?state=success', '.module-state--success')
await open(send, '/pages/profile/m03-security-settings', '?state=error', '.module-state--error')
process.stdout.write('MODULE-PAGE-RUNTIME-SMOKE PASS\n')
} finally { socket.close() }
}
run().catch((error) => { process.stderr.write(`${error.stack || error.message}\n`); process.exit(1) })
+20 -17
View File
@@ -2,30 +2,33 @@ $ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$componentPath = Join-Path $root 'components/ModulePage.vue'
if (-not (Test-Path -LiteralPath $componentPath)) {
throw 'Missing shared module page component'
}
if (-not (Test-Path -LiteralPath $componentPath)) { throw 'Missing shared module page component' }
$component = Get-Content -LiteralPath $componentPath -Raw -Encoding utf8
foreach ($anchor in @(
"const moduleState = ref('ready')",
'module-state--ready',
'module-state--empty',
'module-state--success',
'module-state--error',
'g03-create-flow-panel.png',
'g06-search-input-wide.png',
'application-status-card.png',
'a01-primary-button.png',
'background-image:',
'background-size: 100% 100%',
'.primary-action--compact'
'a01-secondary-button.png',
'query.state'
)) {
if ($component -notmatch [regex]::Escape($anchor)) {
throw "ModulePage is missing primary-action visual anchor: $anchor"
if ($component -notmatch [regex]::Escape($anchor)) { throw "ModulePage is missing high-spec anchor: $anchor" }
}
foreach ($className in @('module-panel', 'form-row', 'list-card', 'detail-card', 'timeline-row', 'settings-row', 'status-card', 'primary-action')) {
foreach ($property in @('border', 'background', 'border-radius', 'box-shadow', 'background-image')) {
if ($component -match "(?s)\.$className\s*\{[^}]*\b$property\s*:") {
throw "ModulePage must not construct .$className with CSS $property"
}
}
}
if ($component -match '(?s)\.primary-action\s*\{[^}]*\bborder\s*:') {
throw 'ModulePage primary actions must not redraw the final button border with CSS'
}
if ($component -match '(?s)\.primary-action\s*\{[^}]*\bbackground\s*:\s*\$brand-red') {
throw 'ModulePage primary actions must not redraw the final button surface with a plain CSS color'
}
if ($component -match '::before|::after') { throw 'ModulePage must not draw visible decorations with CSS pseudo-elements' }
Write-Output 'MODULE-PAGE-VISUAL-CONTRACT PASS'
@@ -0,0 +1,44 @@
$ErrorActionPreference = 'Stop'
$decode = {
param([string]$value)
[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($value))
}
$root = (Resolve-Path '.').Path
$requiredPaths = @(
(& $decode 'ZG9jcy9kZXNpZ24='),
(& $decode 'ZG9jcy9kZXNpZ24v6KeG6KeJ6K6+6K6h5Lqk5o6l5omL5YaMLm1k'),
(& $decode 'ZG9jcy9kZXNpZ24v6KeG6KeJ6K+B5o2u57Si5byVLm1k'),
(& $decode 'ZG9jcy9kZXNpZ24vRDFf5a6J5Y2T6KeG6KeJ6KeE6IyD5LiO6aG16Z2i5aOzLm1k'),
(& $decode 'ZG9jcy9kZXNpZ24vUDAwX+mhtemdoue7k+aehOS4jui1hOS6p+a4heWNlS5tZA==')
)
foreach ($path in $requiredPaths) {
if (-not (Test-Path $path)) { throw "required handoff path missing: $path" }
}
if (-not (Test-Path '.gitignore')) { throw '.gitignore is required' }
$ignore = Get-Content -Raw -Encoding UTF8 '.gitignore'
foreach ($pattern in @('/tmp/', '/unpackage/', '/docs/superpowers/specs/design/', '/tmp-g01-icon-audit.png')) {
if (-not $ignore.Contains($pattern)) { throw "missing ignore pattern: $pattern" }
}
if (Test-Path 'docs/superpowers/specs/design') {
throw 'misplaced design archive remains inside repository'
}
$screens = Get-ChildItem 'docs/design/screens' -File -Recurse
$screenBytes = ($screens | Measure-Object Length -Sum).Sum
if ($screens.Count -gt 35) { throw "too many handoff screenshots: $($screens.Count)" }
if ($screenBytes -gt 20MB) { throw "handoff screenshots exceed 20MB: $([math]::Round($screenBytes / 1MB, 2))MB" }
$indexPath = (& $decode 'ZG9jcy9kZXNpZ24v6KeG6KeJ6K+B5o2u57Si5byVLm1k')
$index = Get-Content -Raw -Encoding UTF8 $indexPath
$links = [regex]::Matches($index, '\[[^\]]+\]\(([^)]+)\)')
foreach ($link in $links) {
$target = Join-Path (Split-Path $indexPath) $link.Groups[1].Value
if (-not (Test-Path $target)) { throw "broken evidence link: $($link.Groups[1].Value)" }
}
Write-Output "REPOSITORY-HANDOFF-SIZE-CONTRACT PASS files=$($screens.Count) mb=$([math]::Round($screenBytes / 1MB, 2)) links=$($links.Count)"
+4
View File
@@ -0,0 +1,4 @@
const sleep=(ms)=>new Promise(r=>setTimeout(r,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(x=>x.type==='page'&&x.url.startsWith(`${origin}/`));if(!page)throw new Error('No application page');const socket=new WebSocket(page.webSocketDebuggerUrl);await new Promise((r,j)=>{socket.addEventListener('open',r,{once:true});socket.addEventListener('error',j,{once:true})});let id=0;const pending=new Map();socket.addEventListener('message',e=>{const m=JSON.parse(e.data),p=pending.get(m.id);if(!p)return;pending.delete(m.id);m.error?p.reject(new Error(m.error.message)):p.resolve(m.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(s,e)=>(await s('Runtime.evaluate',{expression:e,returnByValue:true})).result?.value;const waitFor=async(s,e,m)=>{for(let i=0;i<50;i+=1){if(await valueOf(s,e))return;await sleep(100)}throw new Error(m)};let auditId=0;const open=async(s,r,q,sel)=>{auditId+=1;const u=`${origin}/?audit=${auditId}#${r}${q}`;await s('Page.navigate',{url:u});await waitFor(s,`location.href === ${JSON.stringify(u)}`,'navigation failed');await waitFor(s,`Boolean(document.querySelector(${JSON.stringify(sel)}))`,`missing ${sel}`)};
const run=async()=>{const{socket,send}=await connect();try{await send('Page.enable');await send('Runtime.enable');await open(send,'/pages/family/f01-family-feed','?genealogyId=1001','.feed-state--list');await open(send,'/pages/family/f02-publish-feed','?genealogyId=1001','.publish-state--form');await open(send,'/pages/notification/n01-message-center','','.notice-state--list');await open(send,'/pages/profile/m01-profile-home','','.profile-state--ready');await open(send,'/pages/family/f01-family-feed','?state=empty&genealogyId=1001','.feed-state--empty');await open(send,'/pages/family/f02-publish-feed','?state=success&genealogyId=1001','.publish-state--success');await open(send,'/pages/notification/n01-message-center','?state=empty','.notice-state--empty');await open(send,'/pages/profile/m01-profile-home','?state=error','.profile-state--error');process.stdout.write('ROOT-PAGES-RUNTIME-SMOKE PASS\n')}finally{socket.close()}};run().catch(e=>{process.stderr.write(`${e.stack||e.message}\n`);process.exit(1)})
+11
View File
@@ -0,0 +1,11 @@
$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
$files=@('pages/family/f01-family-feed.vue','pages/family/f02-publish-feed.vue','pages/notification/n01-message-center.vue','pages/profile/m01-profile-home.vue')
$pages=@{}
foreach($file in $files){$content=Get-Content -LiteralPath(Join-Path $root $file)-Raw -Encoding utf8;if($content-match"@/utils/api\.js|\bappApi\b"){ throw "$file must not connect the API layer" };Assert-Contains $content 'page-paper.jpg' "$file must use page paper";$pages[$file]=$content}
foreach($required in @('feed-state--list','feed-state--empty','feed-state--error','application-status-card.png','/pages/family/f02-publish-feed')){Assert-Contains $pages['pages/family/f01-family-feed.vue'] $required "Missing F01 contract: $required"}
foreach($required in @('publish-state--form','publish-state--success','publish-state--error','g03-create-flow-panel.png','g06-search-input-wide.png','a01-primary-button.png')){Assert-Contains $pages['pages/family/f02-publish-feed.vue'] $required "Missing F02 contract: $required"}
foreach($required in @('notice-state--list','notice-state--empty','notice-state--error','application-status-card.png','a01-secondary-button.png','/pages/genealogy/g10-application-review')){Assert-Contains $pages['pages/notification/n01-message-center.vue'] $required "Missing N01 contract: $required"}
foreach($required in @('profile-state--ready','profile-state--error','g03-create-flow-panel.png','g06-search-input-wide.png','application-status-card.png','/pages/profile/m02-edit-profile')){Assert-Contains $pages['pages/profile/m01-profile-home.vue'] $required "Missing M01 contract: $required"}
Write-Output 'ROOT-PAGES-VISUAL-CONTRACT PASS'
+31 -17
View File
@@ -7,30 +7,44 @@ function Assert-Contains {
function Assert-NoCssSurface {
param([string]$Content, [string]$ClassName)
foreach ($property in @('border', 'background', 'border-radius')) {
if ($Content -match "(?s)\\.$ClassName\\s*\\{[^}]*\\b$property\\s*:") { throw "T01 must not construct .$ClassName with CSS $property" }
foreach ($property in @('border', 'background', 'border-radius', 'box-shadow')) {
if ($Content -match "(?s)\.$ClassName\s*\{[^}]*\b$property\s*:") {
throw "T01 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)
$t01 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding utf8
$t02 = Join-Path $root 'pages/tree/t02-tree-states.vue'
$pagePath = Join-Path $root 'pages/tree/t01-tree-overview.vue'
$t02Path = Join-Path $root 'pages/tree/t02-tree-states.vue'
$page = Get-Content -LiteralPath $pagePath -Raw -Encoding utf8
$pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding utf8
$catalog = Get-Content -LiteralPath (Join-Path $root 'data/page-catalog.js') -Raw -Encoding utf8
if (-not ($paths -contains 'pages/tree/t01-tree-overview')) { throw 'T01 route missing' }
if ($paths -contains 'pages/tree/t02-tree-states') { throw 'T02 route must be removed; tree states belong to T01' }
if (Test-Path -LiteralPath $t02) { throw 'T02 page file must be deleted; tree states belong to T01' }
if ($paths.Count -ne 54) { throw "Expected 54 routes after the approved T02-to-T01 state merge, found $($paths.Count)." }
if (Test-Path -LiteralPath $t02Path) { throw 'T02 duplicate route file must be removed' }
if ($pages -match 'pages/tree/t02-tree-states') { throw 'T02 duplicate route must be removed from pages.json' }
if ($catalog -match 't02:') { throw 'T02 duplicate catalog entry must be removed' }
if ($page -match "@/utils/api\.js|\bappApi\b") { throw 'T01 design phase must not connect the API layer' }
foreach ($required in @("const treeState = ref('loading')", "'tree'", "'landscape'", "'empty'", "'error'", 't01-tree-canvas.png', 't01-member-node.png', 't01-member-sheet.png', 'tree-state--landscape', 'tree-state--empty', 'tree-state--error', '/pages/tree/t03-member-profile?genealogyId=', '/pages/tree/t04-add-relative?genealogyId=')) {
Assert-Contains -Content $t01 -Expected $required -Message "Missing T01 tree-state contract: $required"
}
foreach ($required in @(
"const treeState = ref('loading')",
'tree-state--tree',
'tree-state--landscape',
'tree-state--empty',
'tree-state--error',
'tree-canvas--state',
'g03-create-flow-panel.png',
'g06-search-input-wide.png',
'application-status-card.png',
'/pages/tree/t03-member-profile',
'/pages/tree/t04-add-relative',
'/pages/tree/t06-edit-relationship',
'/pages/tree/t07-member-directory',
'query.genealogyId'
)) { Assert-Contains $page $required "Missing T01 contract: $required" }
foreach ($className in @('tree-canvas', 'member-node', 'member-sheet')) { Assert-NoCssSurface -Content $t01 -ClassName $className }
foreach ($asset in @('t01-tree-canvas.png', 't01-member-node.png', 't01-member-sheet.png')) {
if (-not (Test-Path -LiteralPath (Join-Path $root "static/assets/modules/tree/opaque/$asset"))) { throw "T01 requires final opaque asset: $asset" }
foreach ($className in @('tree-canvas', 'member-node', 'member-sheet', 'tree-state-card')) {
Assert-NoCssSurface $page $className
}
Write-Output 'T01-TREE-STATE-CONTRACT PASS'
+40 -18
View File
@@ -1,45 +1,67 @@
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const origin = process.argv[2] || 'http://localhost:5173'
const t01Path = '/pages/tree/t01-tree-overview'
const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.includes('localhost:5173'))
if (!page) throw new Error('Chrome debugging has no localhost:5173 page')
const page = pages.find((item) => item.type === 'page' && item.url.startsWith(`${origin}/`))
if (!page) throw new Error(`Chrome debugging has no ${origin} 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 }) })
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 })) })
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 < 40; attempt += 1) { if (await valueOf(send, expression)) return; await sleep(100) }
for (let attempt = 0; attempt < 50; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(100)
}
throw new Error(message)
}
const open = async (send, query, selector) => {
const url = `${origin}/#${t01Path}${query}`
const url = `${origin}/#/pages/tree/t01-tree-overview${query}`
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, 'T01 navigation failed')
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `T01 navigation failed: ${query}`)
await send('Page.reload')
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `T01 ${query} did not render`)
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `T01 state did not render: ${selector}`)
}
const run = async () => {
const { socket, send } = await connect()
try {
await send('Page.enable'); await send('Runtime.enable')
await open(send, '?state=landscape', '.tree-state--landscape')
await open(send, '?state=empty', '.tree-state--empty')
await open(send, '?state=error', '.tree-state--error')
await open(send, '?genealogyId=1001', '.tree-canvas .member-node')
await send('Page.enable')
await send('Runtime.enable')
await open(send, '?state=landscape&genealogyId=1001', '.tree-state--landscape')
await open(send, '?state=empty&genealogyId=1001', '.tree-state--empty')
await open(send, '?state=error&genealogyId=1001', '.tree-state--error')
await open(send, '?genealogyId=1001', '.tree-state--tree .member-node')
await valueOf(send, "document.querySelector('.member-node')?.click()")
await waitFor(send, "location.href.includes('/pages/tree/t03-member-profile?genealogyId=1001&personId=')", 'Selecting a T01 member did not open T03')
await valueOf(send, "document.querySelector('.sheet-member-action')?.click()")
await waitFor(send, "location.href.includes('/pages/tree/t03-member-profile?genealogyId=1001&personId=')", 'T01 selected member did not open T03')
process.stdout.write('T01-TREE-STATE-RUNTIME-SMOKE PASS\n')
} finally { socket.close() }
} finally {
socket.close()
}
}
run().catch((error) => { process.stderr.write(`${error.stack || error.message}\n`); process.exit(1) })
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+60
View File
@@ -0,0 +1,60 @@
$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
$paths = @(
'pages/tree/t03-member-profile.vue',
'pages/tree/t04-add-relative.vue',
'pages/tree/t05-edit-member.vue',
'pages/tree/t06-edit-relationship.vue',
'pages/tree/t07-member-directory.vue',
'pages/tree/t08-member-states.vue'
)
$pages = @{}
foreach ($path in $paths) {
$content = Get-Content -LiteralPath (Join-Path $root $path) -Raw -Encoding utf8
if ($content -match '<ModulePage') { throw "$path must not retain ModulePage" }
if ($content -match "@/utils/api\.js|\bappApi\b") { throw "$path must not connect the API layer" }
$pages[$path] = $content
}
foreach ($path in @('pages/tree/t03-member-profile.vue', 'pages/tree/t07-member-directory.vue', 'pages/tree/t08-member-states.vue')) {
Assert-Contains $pages[$path] 'page-paper.jpg' "$path must use the accepted paper background"
}
$t03 = $pages['pages/tree/t03-member-profile.vue']
foreach ($required in @('member-state--detail', 'member-state--error', 'g03-create-flow-panel.png', 'g06-search-input-wide.png', '/pages/tree/t05-edit-member', 'query.personId')) {
Assert-Contains $t03 $required "Missing T03 contract: $required"
}
$formComponentPath = Join-Path $root 'components/tree/TreeMemberForm.vue'
if (-not (Test-Path -LiteralPath $formComponentPath)) { throw 'T04-T06 require shared TreeMemberForm component' }
$form = Get-Content -LiteralPath $formComponentPath -Raw -Encoding utf8
if ($form -match "@/utils/api\.js|\bappApi\b") { throw 'TreeMemberForm must not connect the API layer' }
foreach ($required in @('form-state--form', 'form-state--success', 'form-state--conflict', 'form-state--error', 'g03-create-flow-panel.png', 'g06-search-input-wide.png', 'a01-primary-button.png', 'a01-secondary-button.png')) {
Assert-Contains $form $required "Missing shared form contract: $required"
}
foreach ($route in @('t04-add-relative.vue', 't05-edit-member.vue', 't06-edit-relationship.vue')) {
Assert-Contains $pages["pages/tree/$route"] 'TreeMemberForm' "$route must use TreeMemberForm"
}
$t07 = $pages['pages/tree/t07-member-directory.vue']
foreach ($required in @('directory-state--list', 'directory-state--empty', 'directory-state--error', 'application-status-card.png', 'g06-search-input-wide.png', '/pages/tree/t03-member-profile')) {
Assert-Contains $t07 $required "Missing T07 contract: $required"
}
$t08 = $pages['pages/tree/t08-member-states.vue']
foreach ($required in @('member-status--privacy', 'member-status--deceased', 'member-status--forbidden', 'application-status-card.png', 'query.state')) {
Assert-Contains $t08 $required "Missing T08 contract: $required"
}
$catalog = Get-Content -LiteralPath (Join-Path $root 'data/page-catalog.js') -Raw -Encoding utf8
foreach ($entry in @('t04:', 't05:', 't06:', 't07:', 't08:')) {
if ($catalog -match [regex]::Escape($entry)) { throw "Page catalog must remove migrated entry: $entry" }
}
Write-Output 'T03-T08-MEMBER-FLOW-CONTRACT PASS'
@@ -0,0 +1,63 @@
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
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} 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()
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(100)
}
throw new Error(message)
}
const open = async (send, route, query, selector) => {
const url = `${origin}/#${route}${query}`
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `${route} navigation failed`)
await send('Page.reload')
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `${route}${query} did not render`)
}
const run = async () => {
const { socket, send } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
await open(send, '/pages/tree/t03-member-profile', '?genealogyId=1001&personId=101', '.member-state--detail')
await open(send, '/pages/tree/t04-add-relative', '?genealogyId=1001&personId=101', '.form-state--form')
await open(send, '/pages/tree/t05-edit-member', '?state=success&genealogyId=1001&personId=101', '.form-state--success')
await open(send, '/pages/tree/t06-edit-relationship', '?state=conflict&genealogyId=1001', '.form-state--conflict')
await open(send, '/pages/tree/t07-member-directory', '?genealogyId=1001', '.directory-state--list')
await open(send, '/pages/tree/t07-member-directory', '?state=empty&genealogyId=1001', '.directory-state--empty')
await open(send, '/pages/tree/t08-member-states', '?state=privacy&genealogyId=1001', '.member-status--privacy')
await open(send, '/pages/tree/t08-member-states', '?state=deceased&genealogyId=1001', '.member-status--deceased')
await open(send, '/pages/tree/t08-member-states', '?state=forbidden&genealogyId=1001', '.member-status--forbidden')
process.stdout.write('T03-T08-MEMBER-FLOW-RUNTIME-SMOKE PASS\n')
} finally { socket.close() }
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})