upgrade: adopt V8.21.3 (V8.13→8.21.3: P5 SFTP/S3, P6 multilingual, editor overhaul, enterprise hardening)

Overlay ~150 upstream items onto our V8.12 base, preserving trade-pro
theme, registry tests, and design docs. Re-applied our patch set
(discovery/env_for/blocks.css/type-mapping/theme_color/frontend entries,
test_theme_pro_v3 mods) — all anchors survived upstream evolution.

New platform enhancement (fixes upgrade-introduced regression):
multilingual.site_locale now honors settings_json.locale, plus
PUT /api/i18n/sites/{id}/locale — standalone English sites get
<html lang='en'> under the V8.15 locale-rewrite pipeline.

compose: adopt V8.21.3 (image 8.21.3-dev.1, /api/ready probes, MySQL/S3
env) with project name pinned to siteforge-v8-10 (data volumes).

Tests: 356 passed / 11 failed — all 11 confirmed pre-existing in
pristine V8.21.3 on this host (10 upstream Windows env + our build test
now green after locale fix). Patch registry updated in plan doc.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-23 12:31:03 +08:00
parent 257c89e159
commit 601451f22b
218 changed files with 10441 additions and 628 deletions
+71
View File
@@ -0,0 +1,71 @@
"""Offline Chromium check for real four-theme mobile templates without localhost/file navigation.
Uses page.set_content to work in CI sandboxes that block navigation to localhost/file URLs.
Tests actual rendered HTML, CSS, and JS; external image loading is not asserted.
"""
from pathlib import Path
import json,re,sys
from playwright.sync_api import sync_playwright, expect
ROOT=Path(__file__).resolve().parents[1]
SAMPLE=ROOT/'examples'/'mobile-showcase'
def inline(theme,page_name="index.html"):
path=SAMPLE/theme
html=(path/page_name).read_text('utf-8')
css='\n'.join((path/'assets'/name).read_text('utf-8') for name in
['theme.css','blocks.css','factory.css','pro-base.css','pro.css','commercial.css','custom.css'])
html=re.sub(r'<link\b[^>]*rel="stylesheet"[^>]*>', '',html)
html=re.sub(r'<script defer src="assets/[^"<>]+"></script>', '',html)
html=html.replace('</head>','<style>'+css+'</style></head>')
js=(path/'assets/pro.js').read_text('utf-8')
html=html.replace('</body>','<script>'+js+'</script></body>')
return html
def run():
results=[]
with sync_playwright() as pw:
browser=pw.chromium.launch(executable_path='/usr/bin/chromium',headless=True,args=['--no-sandbox','--disable-dev-shm-usage'])
try:
for theme in ['automotive','industrial','corporate','minimal']:
page=browser.new_page(viewport={'width':390,'height':844},device_scale_factor=1)
errors=[]
page.on('pageerror',lambda e:errors.append(str(e)))
page.set_content(inline(theme),wait_until='domcontentloaded')
assert page.locator('.sf-mobile-tabs').is_visible(),theme+' tabs absent'
assert page.locator('[data-mobile-open-menu]').count()==1
assert not page.locator('#pro-navigation').is_visible(),theme+' drawer initially visible'
page.locator('[data-pro-menu]').click()
assert page.locator('#pro-navigation').is_visible(),theme+' drawer not open'
expect(page.locator('.sf-mobile-drawer-backdrop')).to_be_visible(timeout=3000)
assert page.locator('[data-pro-menu]').get_attribute('aria-expanded')=='true'
page.keyboard.press('Escape')
assert not page.locator('#pro-navigation').is_visible(),theme+' Esc fails'
page.locator('[data-mobile-open-menu]').click()
assert page.locator('#pro-navigation').is_visible(),theme+' tabs menu failed'
page.locator('.sf-mobile-drawer-backdrop').click(position={'x':4,'y':100})
assert not page.locator('#pro-navigation').is_visible(),theme+' backdrop close fails'
assert page.locator('.sf-mobile-tabs').evaluate('(el)=>getComputedStyle(el).display')=='grid'
overflow=page.evaluate('document.documentElement.scrollWidth - window.innerWidth')
for filename in ['index.html','listing.html','product.html','article.html']:
for width in (375,390,430):
sub=browser.new_page(viewport={'width':width,'height':844})
sub.set_content(inline(theme,filename),wait_until='domcontentloaded')
assert sub.locator('.sf-mobile-tabs').is_visible(),f'{theme}/{filename}: tabs missing'
excess=sub.evaluate('document.documentElement.scrollWidth - window.innerWidth')
assert excess<=1,f'{theme}/{filename} width={width} overflow={excess}'
sub.close()
page.set_viewport_size({'width':1440,'height':900})
assert not page.locator('.sf-mobile-tabs').is_visible(),theme+' desktop tabs leakage'
assert page.locator('#pro-navigation').is_visible(),theme+' desktop nav hidden'
assert not errors,theme+' script errors: '+str(errors)
results.append({'theme':theme,'drawer':'PASS','tabs':'PASS','responsive':'PASS','mobile_horizontal_overflow_px':overflow,'page_types_tested':4,'javascript_errors':len(errors)})
if theme=='automotive':
page.set_viewport_size({'width':390,'height':844})
page.wait_for_timeout(350);page.screenshot(path=str(ROOT/'reports'/'mobile-v8213-automotive.png'),full_page=True)
page.close()
finally:browser.close()
out=ROOT/'reports'/'mobile-v8213-browser.json';out.parent.mkdir(exist_ok=True,parents=True)
out.write_text(json.dumps(results,ensure_ascii=False,indent=2),encoding='utf-8')
print(json.dumps(results,ensure_ascii=False,indent=2))
return 0
if __name__=='__main__':sys.exit(run())
+87
View File
@@ -0,0 +1,87 @@
"""Real Chromium browser smoke test for the unified management console.
Requires optional `playwright` and Chromium. Uses an isolated SQLite directory;
does not touch live sites, data, or deployment targets.
"""
from __future__ import annotations
import os
import socket
import subprocess
import sys
import tempfile
import time
import urllib.request
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT=Path(__file__).resolve().parent.parent
def run(screenshot_dir=None):
with tempfile.TemporaryDirectory(prefix='siteforge-browser-') as data:
with socket.socket() as sock:
sock.bind(('127.0.0.1',0))
port=sock.getsockname()[1]
root_url=f'http://127.0.0.1:{port}'
env=os.environ.copy()
env.update(SITEFORGE_ADMIN_PASSWORD='browser-smoke-only-strong-password',
SITEFORGE_SESSION_SECRET='browser-smoke-secret-key-strong-long-enough',
SITEFORGE_DATA_DIR=data, SITEFORGE_DB_FILE=str(Path(data)/'browser.sqlite3'),
SITEFORGE_DATABASE_URL='',SITEFORGE_REDIS_URL='')
server=subprocess.Popen([sys.executable,'-m','uvicorn','app.main:app','--host','127.0.0.1','--port',str(port)],
cwd=ROOT,env=env,stdout=subprocess.DEVNULL,stderr=subprocess.PIPE)
try:
ready=False
for _ in range(100):
if server.poll() is not None:
raise RuntimeError(f'Web app stopped during startup: {server.stderr.read().decode(errors="replace")[-3000:]}')
try:
with urllib.request.urlopen(root_url+'/api/ready',timeout=0.5) as res:
ready=res.status==200
if ready:break
except Exception:time.sleep(0.1)
if not ready:raise RuntimeError('Web readiness timeout')
with sync_playwright() as pw:
launch_settings={'executable_path':os.environ['SITEFORGE_CHROMIUM']} if os.environ.get('SITEFORGE_CHROMIUM') else {}
browser=pw.chromium.launch(**launch_settings,headless=True,args=['--no-sandbox','--no-proxy-server','--proxy-server=direct://','--proxy-bypass-list=*','--disable-features=ProxyPacHttpsUrlStripping'])
context=browser.new_context(viewport={'width':1440,'height':900},device_scale_factor=1)
page=context.new_page()
errors=[]
page.on('pageerror',lambda error:errors.append(str(error)))
page.goto(root_url+'/login',wait_until='domcontentloaded')
page.locator('#pass').fill(env['SITEFORGE_ADMIN_PASSWORD'])
page.locator('#submit').click()
page.wait_for_url(root_url+'/',timeout=12000)
page.get_by_text('我的网站',exact=True).first.wait_for(timeout=10000)
page.locator('#newSiteTop').click()
assert page.locator('#siteModal').evaluate('(element)=>element.open')
page.locator('#siteForm [name=name]').fill('浏览器验收站')
page.locator('#siteForm button[type=submit]').click()
page.locator('#siteModal').wait_for(state='hidden',timeout=10000)
page.get_by_text('浏览器验收站',exact=True).first.wait_for(timeout=10000)
page.locator('button[data-view="content"]').click()
page.locator('#breadcrumbTitle').get_by_text('内容与页面').wait_for()
page.locator('button[data-view="design"]').click()
page.locator('#breadcrumbTitle').get_by_text('外观与设计').wait_for()
if screenshot_dir:
d=Path(screenshot_dir);d.mkdir(parents=True,exist_ok=True)
page.screenshot(path=str(d/'desktop.png'),full_page=True)
mobile=context.new_page()
mobile.set_viewport_size({'width':390,'height':844})
mobile.goto(root_url+'/',wait_until='domcontentloaded')
assert mobile.url.rstrip('/') == root_url, 'Mobile session unexpectedly lost authentication'
mobile.locator('#mainContent').wait_for(timeout=10000)
mobile.locator('#menuToggle').click()
assert 'nav-open' in mobile.locator('body').get_attribute('class') or mobile.locator('#sideMenu').is_visible()
if screenshot_dir:
mobile.screenshot(path=str(d/'mobile.png'),full_page=True)
assert not errors,errors
print('PASS: Chromium login, create site, navigate content/design, mobile menu, no page exceptions')
browser.close()
finally:
server.terminate()
try:server.communicate(timeout=5)
except subprocess.TimeoutExpired:server.kill();server.communicate()
if __name__=='__main__':
run(sys.argv[1] if len(sys.argv)>1 else None)
+69
View File
@@ -0,0 +1,69 @@
"""Optional actual Chromium smoke on disposable site. Never contacts production."""
import json, os, socket, subprocess, sys, tempfile, time
from pathlib import Path
from urllib.request import urlopen
import httpx
from playwright.sync_api import sync_playwright
ROOT=Path(__file__).resolve().parent.parent
OUT=ROOT/'reports'/'studio-ux'
OUT.mkdir(parents=True,exist_ok=True)
def main():
with tempfile.TemporaryDirectory(prefix='sf-studio-ui-') as folder:
with socket.socket() as s:s.bind(('127.0.0.1',0));port=s.getsockname()[1]
base=f'http://127.0.0.1:{port}'
env={**os.environ,'SITEFORGE_ADMIN_PASSWORD':'studio-smoke-only-password','SITEFORGE_SESSION_SECRET':'studio-smoke-only-secret-very-long','SITEFORGE_DATA_DIR':folder,'SITEFORGE_DB_FILE':str(Path(folder)/'siteforge.sqlite3'),'SITEFORGE_DATABASE_URL':'','SITEFORGE_REDIS_URL':'','SITEFORGE_COOKIE_SECURE':'false','NO_PROXY':'127.0.0.1,localhost','no_proxy':'127.0.0.1,localhost'}
proc=subprocess.Popen([sys.executable,'-m','uvicorn','app.main:app','--host','127.0.0.1','--port',str(port)],cwd=ROOT,env=env,stdout=subprocess.DEVNULL,stderr=subprocess.PIPE)
try:
for i in range(120):
if proc.poll() is not None:raise RuntimeError('server exited: '+proc.stderr.read().decode()[-1200:])
try:
with urlopen(base+'/api/ready',timeout=0.4) as response:
if response.status==200:break
except Exception:time.sleep(.1)
else:raise RuntimeError('readiness timed out')
with httpx.Client(base_url=base,trust_env=False) as client:
login=client.post('/api/auth/login',json={'username':'admin','password':env['SITEFORGE_ADMIN_PASSWORD']});login.raise_for_status()
csrf=client.get('/api/auth/me').json()['csrf']
site=client.post('/api/sites',json={'name':'V8.21 Studio UI 验收站点','theme':'corporate'},headers={'X-SiteForge-CSRF':csrf});site.raise_for_status();sid=site.json()['id']
entries=client.get(f'/api/sites/{sid}/entries').json();page_id=next(x['id'] for x in entries if x['route']=='/')
cookie_names={c.name:c.value for c in client.cookies.jar}
with sync_playwright() as play:
browser=play.chromium.launch(executable_path='/usr/bin/chromium',headless=True,args=['--no-sandbox','--disable-dev-shm-usage','--no-proxy-server','--disable-gpu'])
context=browser.new_context(viewport={'width':1600,'height':950},device_scale_factor=1)
context.add_cookies([{'name':k,'value':v,'url':base} for k,v in cookie_names.items()])
page=context.new_page()
errors=[];page.on('pageerror',lambda e:errors.append(str(e)))
page.goto(base+f'/editor/{sid}/{page_id}',wait_until='domcontentloaded',timeout=20000)
page.locator('#blockList .block-row').first.wait_for(timeout=12000)
page.screenshot(path=str(OUT/'editor-desktop.png'),full_page=True)
page.locator('#tabResponsive').click()
assert page.locator('#tabResponsive').get_attribute('aria-selected')=='true'
assert not page.locator('[data-inspector-section="responsive"]').is_hidden()
page.locator('#tabContent').click()
total=page.locator('.block-row').count()
page.locator('#blockSearch').fill('not-find-anything')
assert page.locator('.block-row').count()==0
page.locator('#blockSearch').fill('')
assert page.locator('.block-row').count()==total
page.locator('.block-row').first.locator('button[title="隐藏"]').click()
assert page.locator('#saveStatus').inner_text().startswith('未保存')
page.locator('#saveDraftButton').click()
page.wait_for_function("document.querySelector('#saveStatus').textContent.startsWith('草稿')",timeout=12000)
page.locator('#devicePhone').click()
page.screenshot(path=str(OUT/'editor-phone-preview.png'),full_page=True)
assert not errors,errors
mobile=browser.new_context(viewport={'width':390,'height':844},device_scale_factor=1)
mobile.add_cookies([{'name':k,'value':v,'url':base} for k,v in cookie_names.items()])
mp=mobile.new_page();mp.goto(base+f'/editor/{sid}/{page_id}',wait_until='domcontentloaded',timeout=20000)
mp.locator('#blockCount').wait_for(timeout=12000)
mp.locator('.pane-switcher button').first.click()
assert 'show-structure' in mp.locator('body').get_attribute('class')
mp.screenshot(path=str(OUT/'editor-mobile-ui.png'),full_page=True)
mobile.close();browser.close()
print(json.dumps({'result':'PASS','desktop_blocks':total,'browser_errors':errors,'screenshots':[str(x) for x in OUT.glob('*.png')]},ensure_ascii=False))
finally:
proc.terminate()
try:proc.wait(timeout=5)
except subprocess.TimeoutExpired:proc.kill();proc.wait()
if __name__=='__main__':main()
+1 -1
View File
@@ -2,7 +2,7 @@ $ErrorActionPreference = 'Stop'
Set-Location (Join-Path $PSScriptRoot '..')
python -m compileall -q app
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
python -m pytest -q
python scripts/regression_v821.py --report reports/v821-regression
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Get-ChildItem app/static -Filter *.js | ForEach-Object {
node --check $_.FullName
+4 -1
View File
@@ -2,7 +2,10 @@
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
python -m compileall -q app
python -m pytest -q
# Each batch has a hard deadline and failed batches are isolated for diagnosis.
# Reports are generated outside test output; do not commit logs without review.
python scripts/regression_v821.py --repeat "${SITEFORGE_REGRESSION_REPEAT:-1}" --timeout "${SITEFORGE_REGRESSION_TIMEOUT:-90}" --report reports/v821-regression
python scripts/verify_release.py
if command -v node >/dev/null 2>&1; then
for f in app/static/*.js; do node --check "$f"; done
else
+32
View File
@@ -0,0 +1,32 @@
"""Deterministic demo, only writes to an explicitly isolated SITEFORGE_DATA_DIR."""
import json
import os
from pathlib import Path
if not os.getenv('SITEFORGE_DATA_DIR'):
raise SystemExit('Please set SITEFORGE_DATA_DIR to a disposable directory first')
import sys
sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
from app import db, multilingual, builder, theme_store
from app.core import save_entry, uid
db.init()
theme_store.setup()
multilingual.setup()
records=[('zh-CN','中文官网','https://zh.demo.example.org','/about/','关于我们','全球服务网络'),
('en','Global Website','https://en.demo.example.org','/company/','About Our Company','Worldwide service network')]
ids={}; content={}
for locale,name,domain,about,title,body in records:
sid=uid(); ids[locale]=sid
db.insert('''INSERT INTO sites VALUES (?,?,?,?,?,?,?,?,?,?,?)''',
(sid,name,domain,'SiteForge P6 international demonstration','corporate','#2563eb','','new','{}',db.now(),db.now()))
home=save_entry(sid,{'type':'page','title':name,'route':'/','body':f'<p>{body}</p>'})
detail=save_entry(sid,{'type':'page','title':title,'route':about,'body':f'<p>{body}</p>'})
content[locale]={'home':home,'about':detail}
gid=uid();db.insert('INSERT INTO language_groups VALUES (?,?,?,?)',(gid,'HESU multilingual demo','en',db.now()))
for locale in ids:db.insert('INSERT INTO language_sites VALUES (?,?,?)',(gid,ids[locale],locale))
for key in ('home','about'):
unit=uid();db.insert('INSERT INTO translation_units VALUES (?,?,?,?)',(unit,gid,key,db.now()))
for locale in ids:db.insert('INSERT INTO translation_members VALUES (?,?,?,?)',(unit,ids[locale],content[locale][key],'approved'))
for locale,sid in ids.items():
report=builder.build(sid)
print(json.dumps({'locale':locale,'site_id':sid,'build':report['version'],'pages':report['pages'],'alternates':report['alternate_pages'],'errors':report['errors'],'warnings':report['warnings'][:3]},ensure_ascii=False))
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Convert an authorized WXR or Sitemap XML file into a URL-preservation baseline.
Offline only. Uses defusedxml; hard input limit 32 MiB. Does not download sites.
"""
import argparse
import sys
from pathlib import Path
from urllib.parse import urlsplit, unquote
try:
from defusedxml import ElementTree as DET
except ImportError:
print('需要先安装 defusedxml:python -m pip install defusedxml', file=sys.stderr)
raise SystemExit(3)
WP_NS = '{http://wordpress.org/export/1.2/}'
SITEMAP_NS = '{http://www.sitemaps.org/schemas/sitemap/0.9}'
MAX_XML = 32 * 1024 * 1024
def route_from_url(raw, allowed_hosts):
p = urlsplit(raw.strip())
if p.scheme not in ('http', 'https') or not p.hostname:
return None
if p.hostname.lower().rstrip('.') not in allowed_hosts:
return None
# Encoded dots/slashes would introduce ambiguous paths, so reject them.
path = unquote(p.path or '/')
if '\\' in path or '\x00' in path or '//' in path or any(seg in ('.', '..') for seg in path.split('/')):
return None
# Query-based legacy WordPress URLs require explicit redirect mapping.
if p.query or p.fragment:
return None
return path
def extract(root, hosts, include_unpublished=False):
routes = set()
ignored = []
if root.tag.endswith('urlset'):
items = root.findall(f'.//{SITEMAP_NS}url') or root.findall('.//url')
for item in items:
loc = item.findtext(f'{SITEMAP_NS}loc') or item.findtext('loc') or ''
route = route_from_url(loc, hosts)
if route: routes.add(route)
elif loc: ignored.append('sitemap: 无法映射的 loc')
elif root.tag == 'rss' or root.tag.endswith('rss'):
items = root.findall('./channel/item')
for item in items:
status = (item.findtext(f'{WP_NS}status') or '').strip()
kind = (item.findtext(f'{WP_NS}post_type') or '').strip()
# WXR can contain attachments and revision records: only published content pages.
if kind in ('attachment', 'revision', 'nav_menu_item'):
continue
if not include_unpublished and status != 'publish':
continue
link = item.findtext('link') or ''
route = route_from_url(link, hosts)
if route: routes.add(route)
elif link: ignored.append('WXR: 无法映射的 link')
else:
raise ValueError('仅支持 WXR RSS 或 sitemap urlset XML(不支持 sitemapindex 的递归远端访问)')
return sorted(routes), ignored
def main(argv=None):
ap = argparse.ArgumentParser(description='从授权的 WordPress WXR 或 Sitemap 导出原始 URL 基线,离线运行')
ap.add_argument('xml', type=Path)
ap.add_argument('--host', required=True, help='旧站域名,不带 scheme 或路径,例如 example.com')
ap.add_argument('--allow-host', action='append', default=[], help='手动允许其他同站主机名(例如 www.example.com)')
ap.add_argument('--include-unpublished', action='store_true', help='WXR 包含草稿(默认仅 publish)')
ap.add_argument('--output', type=Path, required=True)
args = ap.parse_args(argv)
allowed = set()
for host in [args.host] + args.allow_host:
if not host or any(c in host for c in '/:@?#') or any(c.isspace() for c in host):
ap.error('--host/--allow-host 只接受域名,不接受 URL、端口或路径')
allowed.add(host.lower().rstrip('.'))
try:
size = args.xml.stat().st_size
if size > MAX_XML: raise ValueError('XML 超过 32 MiB 上限,请分割或使用离线批处理')
if not size: raise ValueError('XML 文件为空')
root = DET.parse(args.xml).getroot()
routes, ignored = extract(root, allowed, args.include_unpublished)
except (OSError, ValueError, DET.ParseError, DET.EntitiesForbidden, DET.DTDForbidden) as e:
print('导入失败:' + str(e), file=sys.stderr)
return 2
if not routes:
print('没有找到匹配已发布内容的 URL,请检查旧站域名和 XML 格式', file=sys.stderr)
return 2
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text('# WordPress 旧站原始 URL 路径基线(不可修改路径语义)\n' + '\n'.join(routes) + '\n', encoding='utf-8')
print(f'导出 {len(routes)} 条;无法映射 {len(ignored)} 条。保存:{args.output}')
if ignored:
print('警告:有 URL 包含查询参数或使用了非指定域名;需人工建立 301 路径映射。', file=sys.stderr)
return 0
if __name__ == '__main__':
raise SystemExit(main())
+77
View File
@@ -0,0 +1,77 @@
"""End-to-end HTTP + Worker smoke test on an isolated disposable SQLite database.
Launches an actual uvicorn process, calls authenticated HTTP APIs, runs a real
Worker claim + isolated subprocess build, then downloads and checks the archive.
Never points at production data or remote deployment services.
"""
from __future__ import annotations
import io
import json
import os
import socket
import subprocess
import sys
import tempfile
import time
import zipfile
from pathlib import Path
from urllib.error import URLError
from urllib.request import urlopen
import httpx
ROOT=Path(__file__).resolve().parent.parent
def run():
with tempfile.TemporaryDirectory(prefix='siteforge-e2e-') as folder:
with socket.socket() as sock:
sock.bind(('127.0.0.1',0)); port=sock.getsockname()[1]
base=f'http://127.0.0.1:{port}'
env=os.environ.copy()
env.update(SITEFORGE_ADMIN_PASSWORD='e2e-only-dummy-password-123456',
SITEFORGE_SESSION_SECRET='e2e-only-session-secret-long-value-123456',
SITEFORGE_DATA_DIR=folder,SITEFORGE_DB_FILE=str(Path(folder)/'siteforge.sqlite3'),
SITEFORGE_DATABASE_URL='',SITEFORGE_REDIS_URL='',SITEFORGE_COOKIE_SECURE='false')
proc=subprocess.Popen([sys.executable,'-m','uvicorn','app.main:app','--host','127.0.0.1','--port',str(port)],
cwd=ROOT,env=env,stdout=subprocess.DEVNULL,stderr=subprocess.PIPE)
try:
for n in range(120):
if proc.poll() is not None:raise RuntimeError('Web startup failed: '+proc.stderr.read().decode(errors='replace')[-3000:])
try:
with urlopen(base+'/api/ready',timeout=0.5) as result:
if result.status==200:break
except (OSError,URLError):time.sleep(0.1)
else:raise RuntimeError('Web readiness timeout')
with httpx.Client(base_url=base,timeout=20,follow_redirects=False) as client:
auth=client.post('/api/auth/login',json={'username':'admin','password':env['SITEFORGE_ADMIN_PASSWORD']})
assert auth.status_code==200,auth.text
me=client.get('/api/auth/me')
assert me.status_code==200,me.text
client.headers['X-SiteForge-CSRF']=me.json()['csrf']
site=client.post('/api/sites',json={'name':'E2E Production Smoke','theme':'corporate'})
assert site.status_code==201,site.text
sid=site.json()['id']
page=client.post(f'/api/sites/{sid}/entries',json={'type':'page','title':'Test about','route':'/about/','body':'E2E smoke content'})
assert page.status_code==201,page.text
job=client.post(f'/api/sites/{sid}/builds',json={'mirror_images':False},headers={'Idempotency-Key':'e2e-build-000001'})
assert job.status_code==202,job.text
completed=subprocess.run([sys.executable,'-c','from app import db,enterprise,worker; db.init(); enterprise.setup(); assert worker.run_once()'],cwd=ROOT,env=env,capture_output=True,text=True,timeout=70)
assert completed.returncode==0,completed.stderr[-2500:]
builds=client.get(f'/api/sites/{sid}/builds')
assert builds.status_code==200,builds.text
done=[x for x in builds.json() if x['status']=='complete']
if not done:
raise AssertionError('No completed static build: '+str(builds.json())[:1200])
archive=client.get(f"/api/sites/{sid}/builds/{done[0]['id']}/download")
assert archive.status_code==200,archive.text[:300]
with zipfile.ZipFile(io.BytesIO(archive.content)) as z:
assert 'index.html' in z.namelist()
assert 'about/index.html' in z.namelist()
preview=client.get(f"/preview/{sid}/{done[0]['id']}/about/")
assert preview.status_code==200 and 'E2E smoke content' in preview.text
print('PASS: ready -> login/CSRF -> create site/page -> enqueue -> Worker build -> ZIP -> preview')
finally:
proc.terminate()
try:proc.communicate(timeout=5)
except subprocess.TimeoutExpired:proc.kill();proc.communicate()
if __name__=='__main__':run()
+1 -1
View File
@@ -12,7 +12,7 @@ password=secrets.token_urlsafe(24)
secret=secrets.token_urlsafe(48)
pg_password=secrets.token_urlsafe(30)
deploy_secret=secrets.token_urlsafe(48)
config.write_text(f'''# SiteForge Factory 8.12.0-dev.1 - 不要上传至代码仓库\nSITEFORGE_PORT=8787\nSITEFORGE_ADMIN_PASSWORD={password}\nSITEFORGE_SESSION_SECRET={secret}\nSITEFORGE_COOKIE_SECURE=false\nSITEFORGE_DOMAIN=siteforge.example.com\nSITEFORGE_PG_USER=siteforge\nSITEFORGE_PG_PASSWORD={pg_password}\nSITEFORGE_PG_DB=siteforge\nSITEFORGE_DEPLOY_SECRET={deploy_secret}\n# Remote deployment target: configure HTTPS URLs and corresponding secrets manually.\nSITEFORGE_DEPLOY_STAGING_URL=\nSITEFORGE_DEPLOY_STAGING_SECRET=\nSITEFORGE_DEPLOY_PRODUCTION_URL=\nSITEFORGE_DEPLOY_PRODUCTION_SECRET=\n''', encoding='utf-8')
config.write_text(f'''# SiteForge Factory 8.21.3-dev.1 - 不要上传至代码仓库\nSITEFORGE_PORT=8787\nSITEFORGE_ADMIN_PASSWORD={password}\nSITEFORGE_SESSION_SECRET={secret}\nSITEFORGE_COOKIE_SECURE=false\nSITEFORGE_DOMAIN=siteforge.example.com\nSITEFORGE_PG_USER=siteforge\nSITEFORGE_PG_PASSWORD={pg_password}\nSITEFORGE_PG_DB=siteforge\nSITEFORGE_DEPLOY_SECRET={deploy_secret}\n# Remote deployment target: configure HTTPS URLs and corresponding secrets manually.\nSITEFORGE_DEPLOY_STAGING_URL=\nSITEFORGE_DEPLOY_STAGING_SECRET_REF=\nSITEFORGE_DEPLOY_PRODUCTION_URL=\nSITEFORGE_DEPLOY_PRODUCTION_SECRET_REF=\n''', encoding='utf-8')
try:os.chmod(config,0o600)
except OSError:pass
print('SiteForge 已生成 .env 配置。')
+149 -43
View File
@@ -1,52 +1,158 @@
"""Offline installer for SiteForge components into a user-provided official Art Design Pro checkout.
"""Install the SiteForge V8.19 module into a user-provided Art Design Pro checkout.
Run with --dry-run first. It does NOT download, modify auth schemes, or
pretend to have compiled the official repository in this sandbox.
No network, npm, or destructive operations. Route insertion is deterministic; the
official Art checkout is not bundled. Usage: --apply, --verify, --migrate-legacy.
"""
import argparse,shutil,re
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
SOURCE=Path(__file__).resolve().parents[1]/'integrations'/'art-design-pro'/'src'/'views'/'siteforge'
MARKER='// SITEFORGE_FACTORY_V7_REGISTERED'
V8_MARKER='// SITEFORGE_FACTORY_V8_REGISTERED'
V8_ROUTES="""
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / 'integrations/art-design-pro/src/views/siteforge'
ROUTE_MARKER = '// SITEFORGE_FACTORY_V8_REGISTERED'
BEGIN = '// SITEFORGE_V819_ROUTES_BEGIN'
END = '// SITEFORGE_V819_ROUTES_END'
LEGACY = ('// SITEFORGE_FACTORY_V7_REGISTERED', ROUTE_MARKER)
ROUTES = '''
// SITEFORGE_V819_ROUTES_BEGIN
// SITEFORGE_FACTORY_V8_REGISTERED
{ path:'/siteforge/production', name:'SiteForgeV8Production', component:'/siteforge/V8Production',
meta:{title:'V8 生产控制台',icon:'ri:factory-line'} },
"""
ROUTES='''\n // SITEFORGE_FACTORY_V7_REGISTERED\n {\n path: '/siteforge', name: 'SiteForge', component: '/index/index',\n meta: {title:'网站工厂',icon:'ri:layout-grid-line'},\n children: [\n {path:'factory',name:'SiteForgeFactory',component:'/siteforge/WebsiteFactory',meta:{title:'网站项目'}},\n {path:'themes',name:'SiteForgeThemes',component:'/siteforge/ThemeCenter',meta:{title:'主题与版本'}},\n {path:'enterprise',name:'SiteForgeEnterprise',component:'/siteforge/EnterpriseCenter',meta:{title:'企业控制'}},
{path:'editor',name:'SiteForgeEditor',component:'/siteforge/VisualEditor',meta:{title:'可视化编辑'}},
{path:'migration',name:'SiteForgeMigration',component:'/siteforge/MigrationCenter',meta:{title:'CMS 迁移'}},
{path:'releases',name:'SiteForgeReleases',component:'/siteforge/ReleaseCenter',meta:{title:'审批与发布'}},\n ]\n },\n'''
// SITEFORGE_FACTORY_V7_REGISTERED
{
path: '/siteforge', name: 'SiteForge', component: '/index/index',
meta: { title: '网站工厂', icon: 'ri:layout-grid-line' },
children: [
{ path: 'dashboard', name: 'SiteForgeDashboard', component: '/siteforge/ConsoleHome', meta: { title: '工作台' } },
{ path: 'factory', name: 'SiteForgeFactory', component: '/siteforge/WebsiteFactory', meta: { title: '网站管理' } },
{ path: 'content', name: 'SiteForgeContent', component: '/siteforge/ContentCenter', meta: { title: '内容管理' } },
{ path: 'editor', name: 'SiteForgeEditor', component: '/siteforge/VisualEditor', meta: { title: '网站设计' } },
{ path: 'migration', name: 'SiteForgeMigration', component: '/siteforge/MigrationCenter', meta: { title: '数据与迁移' } },
{ path: 'releases', name: 'SiteForgeReleases', component: '/siteforge/ReleaseCenter', meta: { title: '构建与发布' } },
{ path: 'enterprise', name: 'SiteForgeEnterprise', component: '/siteforge/EnterpriseCenter', meta: { title: '系统管理' } },
{ path: 'themes', name: 'SiteForgeThemes', component: '/siteforge/ThemeCenter', meta: { title: '主题库', isHide: true } },
{ path: 'production', name: 'SiteForgeV8Production', component: '/siteforge/V8Production', meta: { title: '高级生产工具', isHide: true } },
],
},
// SITEFORGE_V819_ROUTES_END
'''
ROUTE_RE = re.compile(r'(export\s+const\s+asyncRoutes\s*(?::[^=]+)?=\s*\[)')
def install(root:Path,dry_run:bool):
base=root/'src'/'router'/'routes'/'asyncRoutes.ts'
target=root/'src'/'views'/'siteforge'
if not base.is_file() or not (root/'package.json').is_file():raise ValueError('不是受支持的 Art Design Pro 源码目录')
original=base.read_text(encoding='utf-8')
pat=r'(export\s+const\s+asyncRoutes\s*:[^=]*=\s*\[)'
if MARKER not in original:
modified,count=re.subn(pat,lambda m:m.group(1)+ROUTES,original,count=1)
if count!=1:raise ValueError('无法定位 asyncRoutes 定义;该官方版本的路由结构可能不同,请人工集成')
else:modified=original
if V8_MARKER not in modified:
modified,count=re.subn(pat,lambda m:m.group(1)+V8_ROUTES,modified,count=1)
if count!=1:raise ValueError('无法注册 V8 业务页面')
print(f'官方工程: {root}\n文件目标: {target}\n待复制页面: {len(list(SOURCE.glob("*.vue")))}\n路由需要更新: {original!=modified}')
if dry_run:return
def _extract_legacy_block(text: str, marker: str) -> tuple[int, int]:
start = text.index(marker)
# Old installer placed the marker immediately before its own route object.
pos = text.find('{', start)
if pos < 0: raise ValueError('旧路由没有对象起始符号')
depth = 0
quoted = None
escape = False
for i in range(pos, len(text)):
c = text[i]
if quoted:
if escape: escape = False
elif c == '\\': escape = True
elif c == quoted: quoted = None
elif c in ('"', "'", '`'): quoted = c
elif c == '{': depth += 1
elif c == '}':
depth -= 1
if depth == 0:
end = i + 1
if end < len(text) and text[end] == ',': end += 1
# The marker and the block must be adjacent except whitespace.
if text[start + len(marker):pos].strip():
raise ValueError('旧路由已人工修改,拒绝自动迁移')
return start, end
raise ValueError('旧路由结构不完整')
def transform_routes(text: str, migrate_legacy: bool = False) -> str:
if 'SITEFORGE_V819_ROUTES_BEGIN' in text or 'SITEFORGE_V819_ROUTES_END' in text:
if text.count(BEGIN) != 1 or text.count(END) != 1:
raise ValueError('V8.19 路由标记不完整')
a = text.index(BEGIN)
b = text.index(END) + len(END)
if text[a:b] != ROUTES.strip():
raise ValueError('已安装的 SiteForge 路由被人工修改;请人工审核后升级')
return text
if any(m in text for m in LEGACY):
if not migrate_legacy:
raise ValueError('发现 V8 旧版路由;请备份后使用 --migrate-legacy 显式升级')
# Remove highest-offset blocks first. Never touch an unrelated route.
for m in sorted((m for m in LEGACY if m in text), key=lambda v:text.index(v), reverse=True):
a,b=_extract_legacy_block(text,m)
text=text[:a]+text[b:]
modified,n = ROUTE_RE.subn(lambda m:m.group(1)+ROUTES, text, count=1)
if n!=1: raise ValueError('无法识别 asyncRoutes 变量;需手动适配 Art 官方版本')
return modified
def _sha(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def install(root: Path, dry_run: bool = True, verify: bool = False,
migrate_legacy: bool = False, overwrite_views: bool = False):
root = root.resolve()
route_file = root / 'src/router/routes/asyncRoutes.ts'
if not (root/'package.json').is_file() or not route_file.is_file():
raise ValueError('不是受支持的 Art Design Pro 工程:缺少 package.json 或 asyncRoutes.ts')
current = route_file.read_text(encoding='utf-8')
new = transform_routes(current, migrate_legacy=migrate_legacy)
target = root/'src/views/siteforge'
sources = sorted(p for p in SOURCE.iterdir() if p.suffix in ('.vue','.ts') and p.is_file())
if not sources: raise ValueError('本地业务页面资源缺失')
existing = [p for p in sources if (target/p.name).exists() and (target/p.name).read_bytes()!=p.read_bytes()]
if verify:
errors=[]
if current!=new: errors.append('路由未安装')
for src in sources:
out=target/src.name
if not out.is_file() or _sha(out.read_bytes()) != _sha(src.read_bytes()):
errors.append(src.name+' 缺失或不匹配')
if errors: raise ValueError('; '.join(errors))
return {'installed':True,'views':len(sources),'routes':'verified'}
if existing and not overwrite_views and not dry_run:
raise ValueError('以下页面存在不同内容,拒绝覆盖:'+', '.join(p.name for p in existing)+';请加 --overwrite-views')
result={'dry_run':dry_run,'routes_need_update':current!=new,'views':len(sources),
'conflicting_views':[x.name for x in existing],'target':str(root)}
if dry_run:return result
target.mkdir(parents=True,exist_ok=True)
for src in SOURCE.glob('*.vue'):
dest=target/src.name
if dest.exists():shutil.copy2(dest,dest.with_suffix('.vue.siteforge-backup'))
shutil.copy2(src,dest)
if modified!=original:
shutil.copy2(base,base.with_suffix('.ts.siteforge-backup'))
base.write_text(modified,encoding='utf-8')
print('业务页面与菜单已写入;下一步请配置 /api、/editor、/preview 代理并执行 pnpm build 完成验收。')
for src in sources:
out=target/src.name
if out.exists() and out.read_bytes()!=src.read_bytes():
backup=out.with_name(out.name+'.siteforge-backup')
if not backup.exists():shutil.copy2(out,backup)
shutil.copy2(src,out)
if current!=new:
backup=route_file.with_name(route_file.name+'.siteforge-backup')
if not backup.exists():shutil.copy2(route_file,backup)
temp=route_file.with_suffix('.ts.siteforge-tmp')
try:
temp.write_text(new,encoding='utf-8')
os.replace(temp,route_file)
finally:
temp.unlink(missing_ok=True)
manifest={'siteforge_version':'8.21.3-dev.1','files':{p.name:_sha(p.read_bytes()) for p in sources}}
(target/'.siteforge-v819.json').write_text(json.dumps(manifest,indent=2),encoding='utf-8')
result['installed']=True
return result
if __name__=='__main__':
parser=argparse.ArgumentParser()
parser.add_argument('official_repo',type=Path)
parser.add_argument('--apply',action='store_true',help='执行源代码变更;不传则只检查')
args=parser.parse_args()
install(args.official_repo.resolve(),dry_run=not args.apply)
parser=argparse.ArgumentParser(description='SiteForge V8.19 → Art Design Pro 离线适配安装')
parser.add_argument('official_repo', type=Path)
parser.add_argument('--apply',action='store_true')
parser.add_argument('--verify',action='store_true')
parser.add_argument('--migrate-legacy',action='store_true')
parser.add_argument('--overwrite-views',action='store_true')
a=parser.parse_args()
try:
print(json.dumps(install(a.official_repo,dry_run=not(a.apply or a.verify),verify=a.verify,
migrate_legacy=a.migrate_legacy,overwrite_views=a.overwrite_views),ensure_ascii=False,indent=2))
except ValueError as e:
parser.error(str(e))
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Maintenance-only SQLite to clean PostgreSQL database migration."""
from __future__ import annotations
# Allow direct `python scripts/name.py` from a fresh source checkout.
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parents[1]))
import argparse,json,sys
from app.sqlite_pg_transfer import migrate
from app.pg_disaster_recovery import RecoveryError
def main(argv=None):
ap=argparse.ArgumentParser(description='Offline SQLite -> EMPTY PostgreSQL data migration')
ap.add_argument('--source',required=True,help='Existing SQLite file. Never modified.')
ap.add_argument('--confirmed-offline',action='store_true')
ap.add_argument('--report',help='Output machine-readable verification summary')
args=ap.parse_args(argv)
try:
result=migrate(args.source,confirmed_offline=args.confirmed_offline)
summary=json.dumps(result,ensure_ascii=False,indent=2)
if args.report:
from pathlib import Path
out=Path(args.report).resolve()
if out.exists():raise RecoveryError('Refuse overwriting prior validation report')
out.parent.mkdir(parents=True,exist_ok=True);out.write_text(summary,encoding='utf-8')
print(summary);return 0
except (RecoveryError,OSError,ValueError) as ex:
print('MIGRATION FAILED: '+str(ex),file=sys.stderr);return 1
if __name__=='__main__':raise SystemExit(main())
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Offline PostgreSQL backup/verify/recovery CLI for administrators."""
from __future__ import annotations
# Allow direct `python scripts/name.py` from a fresh source checkout.
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parents[1]))
import argparse
import json
import os
from pathlib import Path
from app import pg_disaster_recovery as pg
def main(argv=None):
p=argparse.ArgumentParser(description='SiteForge offline PostgreSQL disaster recovery')
sub=p.add_subparsers(dest='operation',required=True)
b=sub.add_parser('backup');b.add_argument('--data',required=True);b.add_argument('--output',required=True)
b.add_argument('--confirmed-offline',action='store_true')
v=sub.add_parser('verify');v.add_argument('archive')
r=sub.add_parser('restore-new');r.add_argument('archive');r.add_argument('--data',required=True)
r.add_argument('--confirmed-offline',action='store_true')
args=p.parse_args(argv)
try:
if args.operation=='verify':value=pg.verify(args.archive)
else:
url=os.getenv('SITEFORGE_DATABASE_URL','')
if args.operation=='backup':
value=pg.backup(url,args.data,args.output,confirmed_offline=args.confirmed_offline)
else:
value=pg.restore_new(url,args.archive,args.data,confirmed_offline=args.confirmed_offline)
print(json.dumps(value,ensure_ascii=False,indent=2))
return 0
except (pg.RecoveryError,OSError,ValueError) as exc:
print('ERROR: '+str(exc),file=__import__('sys').stderr)
return 1
if __name__=='__main__':raise SystemExit(main())
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""V8.21 repeatable integration regression with per-batch deadlines and isolation.
Runs pytest against only the local repository. Timeout cleanup kills the whole
process group on POSIX rather than leaving orphan pytest/Worker children.
Nonzero, flaky and timed-out runs always fail the quality gate.
"""
from __future__ import annotations
import argparse
import datetime as dt
import html
import json
import os
import re
import signal
import subprocess
import sys
import time
from dataclasses import asdict, dataclass
from pathlib import Path
ROOT=Path(__file__).resolve().parent.parent
@dataclass
class Run:
label: str
modules: list[str]
status: str
seconds: float
exit_code: int | None
passed: int
log: str
def run_pytest(label: str, modules: list[str], seconds: int, reports: Path, env: dict[str,str]) -> Run:
args=[sys.executable,'-m','pytest','-vv','--tb=short','--disable-warnings','--no-header',*modules]
filename=re.sub(r'[^a-zA-Z0-9_-]+','-',label)+'.log'
path=reports/filename
began=time.monotonic()
flags={'start_new_session':True} if os.name!='nt' else {'creationflags':subprocess.CREATE_NEW_PROCESS_GROUP}
expired=False
with path.open('wb') as out:
out.write(('$ '+' '.join(args)+'\n').encode('utf-8'))
out.flush()
process=subprocess.Popen(args,cwd=ROOT,env=env,stdout=out,stderr=subprocess.STDOUT,**flags)
try:
process.wait(timeout=seconds)
except subprocess.TimeoutExpired:
expired=True
if os.name!='nt':
try: os.killpg(process.pid,signal.SIGTERM)
except ProcessLookupError: pass
else: process.terminate()
try: process.wait(timeout=4)
except subprocess.TimeoutExpired:
if os.name!='nt':
try: os.killpg(process.pid,signal.SIGKILL)
except ProcessLookupError: pass
else: process.kill()
process.wait(timeout=5)
out.write(f'\nTIMEOUT: deadline={seconds}s; process group terminated\n'.encode())
content=path.read_text(encoding='utf-8',errors='replace')
m=re.findall(r'(?m)(\d+) passed(?:,| in |\s*\()',content)
passed=int(m[-1]) if m else 0
status='TIMEOUT' if expired else ('PASS' if process.returncode==0 and passed>0 else 'FAIL')
return Run(label,modules,status,round(time.monotonic()-began,2),process.returncode if not expired else None,passed,str(path))
def write_reports(runs: list[Run],prefix: Path,modules_count: int,repeat: int):
statuses={x:sum(r.status==x for r in runs) for x in ('PASS','FAIL','TIMEOUT')}
batches=[r for r in runs if re.fullmatch(r'round\d+-batch\d+',r.label)]
bad=[r for r in batches if r.status!='PASS']
result={'version':'8.21.0-dev.1','date':dt.datetime.now(dt.timezone.utc).isoformat(),
'modules':modules_count,'repeats':repeat,'status':'PASS' if not bad else 'FAIL',
'tests_last_complete_round':sum(r.passed for r in batches if r.label.startswith(f'round{repeat}-')),
'counts':statuses,'failures':[asdict(x) for x in bad],
'runs':[asdict(x) for x in runs],
'note':'Timed out, failing, or recovered-on-retry tests are not considered passing.'}
prefix.parent.mkdir(parents=True,exist_ok=True)
prefix.with_suffix('.json').write_text(json.dumps(result,ensure_ascii=False,indent=2),encoding='utf-8')
lines=['# SiteForge V8.21 集成回归','',f"状态:**{result['status']}**;模块:{modules_count};重复轮次:{repeat}",'',
'| 批次 | 状态 | 通过用例 | 秒 | 日志 |','|---|---|---:|---:|---|']
for r in runs:
lines.append(f'| {r.label} | {r.status} | {r.passed} | {r.seconds} | {Path(r.log).name} |')
lines+=['','失败、超时或重跑后通过均视为验收未通过;详见日志。','']
prefix.with_suffix('.md').write_text('\n'.join(lines),encoding='utf-8')
rows=''.join(f'<tr><td>{html.escape(r.label)}</td><td class="{r.status.lower()}">{r.status}</td><td>{r.passed}</td><td>{r.seconds}</td><td>{html.escape(Path(r.log).name)}</td></tr>' for r in runs)
doc=f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge V8.21 · 自动化回归</title><style>body{{font:15px/1.5 system-ui,sans-serif;background:#f7f8fa;color:#19273b;margin:0}}header{{background:#182940;color:white;padding:32px 6%}}main{{max-width:1080px;margin:25px auto;background:white;border:1px solid #e0e4eb;border-radius:14px;padding:22px}}table{{width:100%;border-collapse:collapse}}td,th{{padding:11px;text-align:left;border-bottom:1px solid #e6e9ee}}.pass{{color:#14734c}}.fail,.timeout{{color:#a52626}}@media(max-width:700px){{main{{overflow-x:auto;margin:12px}}}}</style></head><body><header><h1>SiteForge · V8.21 集成回归</h1><p>结果:{result['status']} · {modules_count} 模块 · {repeat} 轮</p></header><main><p>每个测试批次有严格超时,发生异常时隔离失败模块并留存原始日志。任何超时均阻止上线判定。</p><table><tr><th>批次</th><th>状态</th><th>通过用例</th><th>耗时(秒)</th><th>日志</th></tr>{rows}</table></main></body></html>'''
prefix.with_suffix('.html').write_text(doc,encoding='utf-8')
return result
def main(argv=None):
ap=argparse.ArgumentParser(description='Timeout-safe repeatable SiteForge regression')
ap.add_argument('--batch-size',type=int,default=4)
ap.add_argument('--timeout',type=int,default=90,help='Single pytest batch timeout seconds')
ap.add_argument('--module-timeout',type=int,default=50)
ap.add_argument('--repeat',type=int,default=1)
ap.add_argument('--report',type=Path,default=Path('reports/v820-regression'))
args=ap.parse_args(argv)
if not 1<=args.batch_size<=50 or not 1<=args.repeat<=10 or args.timeout<5 or args.module_timeout<5:
ap.error('Invalid batch-size, repeat, or timeout')
modules=sorted('tests/'+p.name for p in (ROOT/'tests').glob('test_*.py'))
if not modules:ap.error('No tests/test_*.py modules')
logs=args.report.parent/(args.report.name+'_logs');logs.mkdir(parents=True,exist_ok=True)
env=os.environ.copy()
env.setdefault('PYTHONUNBUFFERED','1')
runs=[]
for r in range(1,args.repeat+1):
for index in range(0,len(modules),args.batch_size):
batch=modules[index:index+args.batch_size]
label=f'round{r}-batch{index//args.batch_size+1}'
print(f'RUN {label}: {len(batch)} modules',flush=True)
result=run_pytest(label,batch,args.timeout,logs,env)
runs.append(result)
print(f' {result.status} / {result.passed} passed / {result.seconds}s',flush=True)
if result.status!='PASS':
print(' Isolating modules (original failure is retained)',flush=True)
for i,mod in enumerate(batch,1):
isolated=run_pytest(f'{label}-isolated-{i}',[mod],args.module_timeout,logs,env)
runs.append(isolated)
print(f' {Path(mod).name}: {isolated.status} ({isolated.seconds}s)',flush=True)
# Do not start further rounds after a non-passing batch.
break
if any(x.status!='PASS' and re.fullmatch(r'round\d+-batch\d+',x.label) for x in runs):break
result=write_reports(runs,args.report,len(modules),args.repeat)
print(f"RESULT {result['status']}: {len(modules)} modules; report={args.report}.json",flush=True)
return 0 if result['status']=='PASS' else 1
if __name__=='__main__':sys.exit(main())
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Deterministic, subprocess-isolated pytest regression for V8.21.
All outcomes and raw stdout are preserved. A timed-out or failing batch is a
hard failure, not silently retried into a green result.
"""
from __future__ import annotations
import argparse
import datetime
import html
import json
import os
import re
import signal
import subprocess
import sys
import time
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
def launch(modules,seconds):
started=time.monotonic()
try:
process=subprocess.run([sys.executable,'-m','pytest','-q',*modules],
cwd=ROOT,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,
text=True,timeout=seconds)
output=process.stdout
code=process.returncode
status='PASS' if code==0 else 'FAIL'
except subprocess.TimeoutExpired as exc:
output=(exc.stdout or b'')
if isinstance(output,bytes):output=output.decode('utf-8','replace')
output+='\nTIMEOUT: pytest process terminated after deadline. Check Worker child processes.\n'
code=None;status='TIMEOUT'
m=re.search(r'(\d+) passed(?:\s|,)',output)
return {'status':status,'passed':int(m.group(1)) if status=='PASS' and m else 0,
'duration_seconds':round(time.monotonic()-started,2),'exit_code':code,'output':output}
def main(argv=None):
p=argparse.ArgumentParser(description='SiteForge V8.21 independent pytest batch gate')
p.add_argument('--batch-size',type=int,default=4)
p.add_argument('--repeat',type=int,default=1)
p.add_argument('--timeout',type=int,default=80)
p.add_argument('--report',type=Path,default=Path('reports/v821-regression'))
args=p.parse_args(argv)
if not 1<=args.batch_size<=10 or not 1<=args.repeat<=5 or not 10<=args.timeout<=600:p.error('invalid bounds')
modules=sorted('tests/'+f.name for f in (ROOT/'tests').glob('test_*.py'))
if not modules:p.error('no pytest tests found')
report=args.report if args.report.is_absolute() else ROOT/args.report
logs=report.parent/(report.name+'_logs')
logs.mkdir(parents=True,exist_ok=True)
results=[]
for round_num in range(1,args.repeat+1):
for index in range(0,len(modules),args.batch_size):
group=modules[index:index+args.batch_size]
name=f'round{round_num}-batch{index//args.batch_size+1}'
print('RUN',name,flush=True)
result=launch(group,args.timeout)
log=logs/(name+'.log')
log.write_text('$ pytest -q '+' '.join(group)+'\n'+result.pop('output'),encoding='utf-8')
result.update({'name':name,'modules':group,'log':str(log.relative_to(ROOT))})
results.append(result)
print(result['status'],result['passed'],'passed',result['duration_seconds'],'sec',flush=True)
if result['status']!='PASS':break
if results[-1]['status']!='PASS':break
success=len(results)==args.repeat*((len(modules)+args.batch_size-1)//args.batch_size) and all(x['status']=='PASS' for x in results)
info={'version':'8.21.0-dev.1','generated_at':datetime.datetime.now(datetime.timezone.utc).isoformat(),
'status':'PASS' if success else 'FAIL','test_modules':len(modules),'repeats':args.repeat,
'tests_per_round':sum(x['passed'] for x in results if x['name'].startswith('round1-')),
'total_passes':sum(x['passed'] for x in results),'batches':results,
'environment_note':'pytest local fixtures only; Docker/PG/Redis/browser/real WordPress not inferred as passed.'}
report.parent.mkdir(parents=True,exist_ok=True)
report.with_suffix('.json').write_text(json.dumps(info,ensure_ascii=False,indent=2),encoding='utf-8')
header='\n'.join(['# SiteForge V8.21 完整自动化回归','',f"**{info['status']}** — {info['test_modules']} 模块 / {info['tests_per_round']} 项每轮 / {args.repeat} 轮",'',
'| 批次 | 结果 | 通过用例 | 耗时 | 日志 |','|---|---|---:|---:|---|'])
table='\n'.join(f"| {r['name']} | {r['status']} | {r['passed']} | {r['duration_seconds']} 秒 | {r['log']} |" for r in results)
report.with_suffix('.md').write_text(header+'\n'+table+'\n\n真实部署与外部服务另行验收。\n',encoding='utf-8')
trs=''.join(f'<tr><td>{html.escape(r["name"])}</td><td>{html.escape(r["status"])}</td><td>{r["passed"]}</td><td>{r["duration_seconds"]}</td></tr>' for r in results)
report.with_suffix('.html').write_text('''<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>V8.21 回归报告</title><style>body{font:15px/1.7 system-ui,sans-serif;background:#f2f5f8;padding:25px}main{max-width:950px;margin:auto;background:white;padding:28px;border-radius:14px}table{width:100%;border-collapse:collapse}th,td{padding:10px;border-bottom:1px solid #dde2e7;text-align:left}</style><main><h1>SiteForge V8.21 回归验收</h1><h2>'''+info['status']+'</h2><p>每轮 '+str(info['tests_per_round'])+' 项自动化测试 · '+str(args.repeat)+' 轮 · 无外部环境实测保证</p><table><tr><th>批次</th><th>结果</th><th>通过</th><th>秒数</th></tr>'+trs+'</table><p>请参阅 JSON、Markdown 和原始测试日志。真实客户站点迁移仍需专项验收。</p></main></html>',encoding='utf-8')
return 0 if success else 1
if __name__=='__main__':raise SystemExit(main())
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Deterministic, subprocess-isolated pytest regression for V8.21.
All outcomes and raw stdout are preserved. A timed-out or failing batch is a
hard failure, not silently retried into a green result.
"""
from __future__ import annotations
import argparse
import datetime
import html
import json
import os
import re
import signal
import subprocess
import sys
import time
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
def launch(modules,seconds):
"""Execute pytest in a disposable process group and bound inherited pipes.
A test can spawn Worker descendants; killing only the pytest PID may leave
children holding stdout open. On timeout, terminate the entire group.
"""
started=time.monotonic()
kwargs={'cwd':ROOT,'stdout':subprocess.PIPE,'stderr':subprocess.STDOUT,
'text':True,'encoding':'utf-8','errors':'replace'}
if os.name=='nt':
kwargs['creationflags']=subprocess.CREATE_NEW_PROCESS_GROUP
else:
kwargs['start_new_session']=True
child=subprocess.Popen([sys.executable,'-m','pytest','-q',*modules],**kwargs)
try:
output,_=child.communicate(timeout=seconds)
code=child.returncode
status='PASS' if code==0 else 'FAIL'
except subprocess.TimeoutExpired:
if os.name=='nt':
subprocess.run(['taskkill','/PID',str(child.pid),'/T','/F'],
capture_output=True,timeout=10,check=False)
if child.poll() is None:child.kill()
else:
try:os.killpg(child.pid,signal.SIGKILL)
except ProcessLookupError:pass
try:output,_=child.communicate(timeout=10)
except subprocess.TimeoutExpired:
child.kill();output,_=child.communicate(timeout=5)
output+='\nTIMEOUT: pytest process group terminated after deadline.\n'
code=None;status='TIMEOUT'
m=re.search(r'(\d+) passed(?:\s|,)',output)
return {'status':status,'passed':int(m.group(1)) if status=='PASS' and m else 0,
'duration_seconds':round(time.monotonic()-started,2),'exit_code':code,'output':output}
def main(argv=None):
p=argparse.ArgumentParser(description='SiteForge V8.21 independent pytest batch gate')
p.add_argument('--batch-size',type=int,default=4)
p.add_argument('--repeat',type=int,default=1)
p.add_argument('--timeout',type=int,default=80)
p.add_argument('--report',type=Path,default=Path('reports/v821-regression'))
args=p.parse_args(argv)
if not 1<=args.batch_size<=10 or not 1<=args.repeat<=5 or not 10<=args.timeout<=600:p.error('invalid bounds')
modules=sorted('tests/'+f.name for f in (ROOT/'tests').glob('test_*.py'))
if not modules:p.error('no pytest tests found')
report=args.report if args.report.is_absolute() else ROOT/args.report
logs=report.parent/(report.name+'_logs')
logs.mkdir(parents=True,exist_ok=True)
results=[]
for round_num in range(1,args.repeat+1):
for index in range(0,len(modules),args.batch_size):
group=modules[index:index+args.batch_size]
name=f'round{round_num}-batch{index//args.batch_size+1}'
print('RUN',name,flush=True)
result=launch(group,args.timeout)
log=logs/(name+'.log')
log.write_text('$ pytest -q '+' '.join(group)+'\n'+result.pop('output'),encoding='utf-8')
result.update({'name':name,'modules':group,'log':str(log.relative_to(ROOT))})
results.append(result)
print(result['status'],result['passed'],'passed',result['duration_seconds'],'sec',flush=True)
if result['status']!='PASS':break
if results[-1]['status']!='PASS':break
success=len(results)==args.repeat*((len(modules)+args.batch_size-1)//args.batch_size) and all(x['status']=='PASS' for x in results)
info={'version':'8.21.3-dev.1','generated_at':datetime.datetime.now(datetime.timezone.utc).isoformat(),
'status':'PASS' if success else 'FAIL','test_modules':len(modules),'repeats':args.repeat,
'tests_per_round':sum(x['passed'] for x in results if x['name'].startswith('round1-')),
'total_passes':sum(x['passed'] for x in results),'batches':results,
'environment_note':'pytest local fixtures only; Docker/PG/Redis/browser/real WordPress not inferred as passed.'}
report.parent.mkdir(parents=True,exist_ok=True)
report.with_suffix('.json').write_text(json.dumps(info,ensure_ascii=False,indent=2),encoding='utf-8')
header='\n'.join(['# SiteForge V8.21 完整自动化回归','',f"**{info['status']}** — {info['test_modules']} 模块 / {info['tests_per_round']} 项每轮 / {args.repeat} 轮",'',
'| 批次 | 结果 | 通过用例 | 耗时 | 日志 |','|---|---|---:|---:|---|'])
table='\n'.join(f"| {r['name']} | {r['status']} | {r['passed']} | {r['duration_seconds']} 秒 | {r['log']} |" for r in results)
report.with_suffix('.md').write_text(header+'\n'+table+'\n\n真实部署与外部服务另行验收。\n',encoding='utf-8')
trs=''.join(f'<tr><td>{html.escape(r["name"])}</td><td>{html.escape(r["status"])}</td><td>{r["passed"]}</td><td>{r["duration_seconds"]}</td></tr>' for r in results)
report.with_suffix('.html').write_text('''<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>V8.21 回归报告</title><style>body{font:15px/1.7 system-ui,sans-serif;background:#f2f5f8;padding:25px}main{max-width:950px;margin:auto;background:white;padding:28px;border-radius:14px}table{width:100%;border-collapse:collapse}th,td{padding:10px;border-bottom:1px solid #dde2e7;text-align:left}</style><main><h1>SiteForge V8.21 回归验收</h1><h2>'''+info['status']+'</h2><p>每轮 '+str(info['tests_per_round'])+' 项自动化测试 · '+str(args.repeat)+' 轮 · 无外部环境实测保证</p><table><tr><th>批次</th><th>结果</th><th>通过</th><th>秒数</th></tr>'+trs+'</table><p>请参阅 JSON、Markdown 和原始测试日志。真实客户站点迁移仍需专项验收。</p></main></html>',encoding='utf-8')
return 0 if success else 1
if __name__=='__main__':raise SystemExit(main())
+444
View File
@@ -0,0 +1,444 @@
#!/usr/bin/env python3
"""SiteForge V8.18 read-only enterprise acceptance audit.
Runs only non-destructive inspections. No migrations, deploys, account operations,
secret dumps, or changes to a SiteForge instance are performed.
"""
from __future__ import annotations
import argparse
import datetime as dt
import html
import ipaddress
import json
import os
import re
import shutil
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from collections import Counter
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
from html.parser import HTMLParser
CATEGORIES = {"repository": "源码结构", "http": "HTTP 健康探针", "docker": "Docker / Worker", "postgres": "PostgreSQL", "redis": "Redis", "build": "静态构建 / SEO", "release": "发布门禁"}
@dataclass
class Check:
code: str
category: str
status: str # pass | fail | warn | skip
description: str
detail: str = ""
remediation: str = ""
class Report:
def __init__(self, args):
self.args = args
self.checks: list[Check] = []
def add(self, code, category, status, description, detail="", remediation=""):
assert status in {"pass", "fail", "warn", "skip"}
# Never include raw environment values or exception objects containing DSNs.
self.checks.append(Check(code, category, status, description, str(detail), remediation))
def data(self):
counts = Counter(c.status for c in self.checks)
return {
"tool_version": "8.21.1-dev.1", "generated_at": dt.datetime.now(dt.timezone.utc).isoformat(),
"mode": "read-only", "live_production_verified": False,
"note": "本报告只证明实际执行并标记 PASS 的只读检查;SKIP/WARN 不等同通过,未进行真实发布。",
"summary": {x: counts[x] for x in ("pass", "fail", "warn", "skip")},
"selected_checks_clear": bool(self.checks) and counts["fail"] == 0 and counts["warn"] == 0 and counts["skip"] == 0,
"production_ready": False, # human approval + end-to-end validation cannot be automated by this kit
"checks": [asdict(c) for c in self.checks]
}
def audit_repo(r: Report, repo: Path):
p = repo.resolve()
items = {
"app": "应用代码 app/", "app/builder.py": "静态构建器 app/builder.py",
"app/multilingual.py": "多语言模块 app/multilingual.py",
"app/templates/workspace.html": "工作区模板", "app/static/workspace.js": "后台交互脚本",
"scripts/check.sh": "Linux 检查脚本", "scripts/http_end_to_end.py": "HTTP 端到端测试",
"scripts/verify_pg_bootstrap.py": "PG 隔离验证", "scripts/browser_smoke.py": "浏览器烟测",
"compose.enterprise.yaml": "企业 Compose 配置"
}
if not p.is_dir():
r.add("repo.root", "repository", "fail", "仓库路径存在", "指定路径不存在", "设置 --repo 为 SiteForge V8.16 源码根目录")
return
r.add("repo.root", "repository", "pass", "仓库根目录可访问", "仅进行只读检查")
for name, label in items.items():
ok = (p / name).exists()
r.add("repo." + name.replace("/", "."), "repository", "pass" if ok else "warn", label,
"存在" if ok else "在当前源码中未找到", "核对 V8.16 工程是否解压完整" if not ok else "")
ps = list((p / "app").rglob("*.py")) if (p / "app").is_dir() else []
if ps:
import ast
errors = []
for f in ps:
try:
ast.parse(f.read_text(encoding="utf-8-sig"), filename=str(f.relative_to(p)))
except (SyntaxError, UnicodeError, OSError) as exc:
errors.append(f"{f.relative_to(p)}:{getattr(exc, 'lineno', '?')}")
if len(errors) >= 5: break
r.add("repo.python_syntax", "repository", "fail" if errors else "pass",
"Python 源码语法", ", ".join(errors) if errors else f"解析通过 {len(ps)} 个文件")
else:
r.add("repo.python_syntax", "repository", "skip", "Python 源码语法", "未找到 app/*.py")
if (p / ".env").exists():
try:
text = (p / ".env").read_text(encoding="utf-8", errors="replace")
bad = []
for key in ("SITEFORGE_COOKIE_SECURE",):
m = re.search(rf"(?m)^\s*{key}\s*=\s*(.+?)\s*$", text)
if m and m.group(1).strip().strip('"\'').lower() in ("false", "0", "no", "off"):
bad.append(key)
r.add("repo.env_security", "repository", "warn" if bad else "pass", ".env 公开部署安全配置",
"存在应检查的非安全设置(仅字段名):" + ", ".join(bad) if bad else "未发现显式关闭安全 Cookie 的配置;这并非完整安全审计",
"公网环境启用 SITEFORGE_COOKIE_SECURE=true 并确认 HTTPS" if bad else "")
except OSError:
r.add("repo.env_security", "repository", "warn", ".env 配置读取", "读取失败")
else:
r.add("repo.env_security", "repository", "skip", ".env 安全配置", "缺少 .env;不打印或记录凭据")
def _local_host(url: str) -> bool:
parsed = urllib.parse.urlsplit(url)
if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username or parsed.password:
return False
if parsed.hostname == "localhost":
return True
try:
return ipaddress.ip_address(parsed.hostname).is_loopback
except ValueError:
return False
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def _probe(url: str, limit: int = 256 * 1024) -> tuple[int, str, str]:
req = urllib.request.Request(url, headers={"User-Agent": "SiteForgeAcceptance/8.17", "Accept": "application/json,text/html"})
op = urllib.request.build_opener(NoRedirect)
try:
with op.open(req, timeout=4) as resp:
body = resp.read(limit + 1)
if len(body) > limit:
return resp.status, "", "响应超过上限"
return resp.status, body.decode("utf-8", errors="replace"), ""
except urllib.error.HTTPError as exc:
return exc.code, "", "返回非 2xx 或重定向"
except (urllib.error.URLError, TimeoutError, OSError, ValueError):
return 0, "", "无法连接或请求超时"
def audit_http(r: Report, base: str, allow_remote: bool):
p = urllib.parse.urlsplit(base)
if not _local_host(base) and not allow_remote:
r.add("http.scope", "http", "fail", "检查目标授权范围",
"默认仅允许 localhost / 回环地址", "外部已授权的测试域名需要明确传入 --allow-remote")
return
if p.scheme not in ("http", "https") or not p.netloc or p.query or p.fragment or p.username or p.password:
r.add("http.scope", "http", "fail", "检查目标 URL", "URL 无效或包含不允许的参数")
return
root = f"{p.scheme}://{p.netloc}" + p.path.rstrip("/")
r.add("http.scope", "http", "pass", "检查目标授权范围", "只读取健康接口,不提交数据")
for endpoint in ("/api/health", "/api/ready"):
status, body, error = _probe(root + endpoint)
label = "存活探针" if endpoint.endswith("health") else "就绪探针(数据库与存储)"
key = "http.health" if endpoint.endswith("health") else "http.ready"
result = "pass" if status == 200 and not error else "fail"
detail = f"HTTP {status or '不可用'}"
if body:
try:
j = json.loads(body)
detail += "; response.status=" + str(j.get("status", "未提供"))[:36]
if isinstance(j.get("status"), str) and j["status"].lower() in ("unavailable", "error", "failed"):
result = "fail"
except (json.JSONDecodeError, AttributeError):
detail += "; 非 JSON 响应"
result = "fail"
if error: detail += "; " + error
r.add(key, "http", result, label, detail, "核对 Web/DB/data 卷状态及日志;不得以 /api/health 代替 /api/ready" if result == "fail" else "")
status, _, error = _probe(root + "/", limit=1024 * 1024)
r.add("http.entry", "http", "pass" if status in (200, 301, 302, 303, 307, 308) else "warn",
"后台入口可访问", f"HTTP {status or '不可用'}" + (f";{error}" if error else ""), "检查反向代理、登录页和静态资源" if status not in (200, 301, 302, 303, 307, 308) else "")
def audit_docker(r: Report, repo: Path, compose_file: str):
if not shutil.which("docker"):
r.add("docker.executable", "docker", "skip", "Docker CLI 可用", "本机未发现 docker 命令")
return
cmd = ["docker", "compose"]
if compose_file: cmd += ["-f", compose_file]
try:
# No `config` output: resolved Compose may contain environment secrets.
check = subprocess.run(cmd + ["config", "-q"], cwd=repo, capture_output=True, timeout=15)
except (OSError, subprocess.TimeoutExpired):
r.add("docker.compose_config", "docker", "fail", "Compose 配置可解析", "无法执行命令或超时")
return
r.add("docker.compose_config", "docker", "pass" if check.returncode == 0 else "fail", "Compose 配置可解析",
"退出码 " + str(check.returncode) + "(敏感输出已隐藏)")
if check.returncode: return
try:
ps = subprocess.run(cmd + ["ps", "--format", "json"], cwd=repo, capture_output=True, timeout=15)
except (OSError, subprocess.TimeoutExpired):
r.add("docker.compose_ps", "docker", "fail", "容器状态检查", "无法执行命令或超时")
return
if ps.returncode:
r.add("docker.compose_ps", "docker", "fail", "容器状态检查", "Compose ps 失败(原始错误已隐藏)")
return
raw = ps.stdout.decode("utf-8", errors="replace").strip()
records = []
try:
payload = json.loads(raw or "[]")
records = payload if isinstance(payload, list) else [payload]
except json.JSONDecodeError:
try:
records = [json.loads(x) for x in raw.splitlines() if x.strip()]
except json.JSONDecodeError:
r.add("docker.compose_ps", "docker", "warn", "容器状态检查", "无法解析 Compose JSON 版本格式")
return
if not records:
r.add("docker.compose_ps", "docker", "fail", "容器已启动", "未发现运行服务")
return
for rec in records:
service = str(rec.get("Service") or rec.get("Name") or "unknown")[:64]
state = str(rec.get("State", "unknown")).lower()
health = str(rec.get("Health", "")).lower()
ok = state == "running" and health in ("", "healthy", "none")
r.add("docker.service." + re.sub(r"[^a-z0-9_-]", "_", service.lower()), "docker", "pass" if ok else "fail",
f"服务 {service} 运行及健康", f"state={state}; health={health or '未报告'}")
if not any("worker" in str(x.get("Service", "")).lower() for x in records):
r.add("docker.worker", "docker", "warn", "独立 Worker 服务", "ps 中未找到名称含 worker 的服务", "核对企业 Compose 服务命名及独立进程")
def audit_postgres(r: Report):
dsn = os.getenv("SITEFORGE_DATABASE_URL", "")
if not dsn.lower().startswith(("postgresql://", "postgres://")):
r.add("postgres.config", "postgres", "skip", "PostgreSQL 真实只读检查", "未配置 PostgreSQL DSN 环境变量")
return
try:
import psycopg
except ImportError:
r.add("postgres.driver", "postgres", "skip", "PostgreSQL 驱动", "未安装 psycopg(不进行写入测试)")
return
try:
with psycopg.connect(dsn, connect_timeout=4, options="-c default_transaction_read_only=on") as con:
with con.cursor() as cur:
cur.execute("SELECT 1")
good = cur.fetchone()[0] == 1
con.rollback()
r.add("postgres.query", "postgres", "pass" if good else "fail", "PostgreSQL 只读连接 SELECT 1",
"查询成功(凭据/地址均不写入报告)" if good else "返回结果异常")
except Exception:
r.add("postgres.query", "postgres", "fail", "PostgreSQL 只读连接 SELECT 1",
"连接或查询失败(异常详情已隐藏,避免泄露 DSN)", "核对隔离环境、网络、TLS、只读账号与连接设置")
def audit_redis(r: Report):
url = os.getenv("SITEFORGE_REDIS_URL", "")
if not url.lower().startswith(("redis://", "rediss://")):
r.add("redis.config", "redis", "skip", "Redis 真实只读检查", "未配置 SITEFORGE_REDIS_URL")
return
try: import redis
except ImportError:
r.add("redis.driver", "redis", "skip", "Redis 驱动", "未安装 redis(不进行写入测试)")
return
try:
client = redis.Redis.from_url(url, socket_connect_timeout=4, socket_timeout=4)
good = bool(client.ping())
client.close()
r.add("redis.ping", "redis", "pass" if good else "fail", "Redis PING 连接", "PING 成功" if good else "PING 响应异常")
except Exception:
r.add("redis.ping", "redis", "fail", "Redis PING 连接", "连接失败(异常详情已隐藏)", "核对 Redis 凭据、TLS、DNS、防火墙")
class Analyzer(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.lang = ""
self.canonicals = []
self.links = []
self.hreflangs = []
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "html": self.lang = a.get("lang", "")
if tag == "a" and a.get("href"): self.links.append(a["href"])
if tag == "link":
rel = (a.get("rel") or "").lower().split()
if "canonical" in rel: self.canonicals.append(a.get("href", ""))
if "alternate" in rel and a.get("hreflang"): self.hreflangs.append((a["hreflang"], a.get("href", "")))
def normalized_route(raw: str) -> str | None:
s = (raw or "").strip()
if not s or s.startswith("#"): return None
if not s.startswith("/"): return None
p = urllib.parse.urlsplit(s)
if p.query or p.fragment: return None
un = urllib.parse.unquote(p.path)
if "\\" in un or "\x00" in un or any(x in ("..", ".") for x in un.split("/")):
return None
if "//" in un: return None
return un
def path_for_route(build: Path, route: str) -> Path | None:
if route == "/": return build / "index.html"
rel = route.lstrip("/")
p = build / rel / "index.html" if route.endswith("/") else build / rel
if p.suffix == "": p = build / rel / "index.html"
# resolve may traverse symlinks - do not open files outside build root
root = build.resolve()
actual = p.resolve()
if not actual.is_relative_to(root): return None
if p.is_dir(): p = p / "index.html"
return p
def audit_build(r: Report, build: Path, baseline: Path | None):
if not build.is_dir():
r.add("build.root", "build", "fail", "静态构建目录", "构建目录不存在")
return
files = sorted(build.rglob("*.html"))
if not files:
r.add("build.html", "build", "fail", "已生成 HTML 页面", "未找到 HTML 文件")
return
r.add("build.html", "build", "pass", "已生成 HTML 页面", f"发现 {len(files)} 个 HTML 文件")
missing_lang, bad_canon, dup_canon, missing_internal, bad_hreflang = [], [], [], [], []
scanned = 0
for f in files[:10000]:
if f.is_symlink() or not f.resolve().is_relative_to(build.resolve()): continue
try:
a = Analyzer()
a.feed(f.read_text(encoding="utf-8", errors="replace"))
except (OSError, ValueError):
continue
scanned += 1
rel = str(f.relative_to(build))
if not a.lang.strip(): missing_lang.append(rel)
if len(a.canonicals) > 1: dup_canon.append(rel)
for c in a.canonicals:
if not c.startswith("https://"): bad_canon.append(rel)
for lang, href in a.hreflangs:
if not re.fullmatch(r"[a-zA-Z]{2,8}(?:-[a-zA-Z0-9]{2,8})*|x-default", lang) or not href.startswith("https://"):
bad_hreflang.append(rel)
for link in a.links:
# SiteForge static output may include absolute route links. Do not resolve
# /preview/... against build output; those are environment-specific.
route = normalized_route(link)
if route is not None:
target = path_for_route(build, route)
if target is not None and not target.is_file():
missing_internal.append(f"{rel} -> {route}")
if len(files) > 10000:
r.add("build.scan_limit", "build", "warn", "扫描 HTML 数量", "超过 10000;仅扫描前 10000 个")
for code, label, values, severity, solution in [
("build.lang", "HTML lang 属性", missing_lang, "warn", "为多语言页面明确设置 HTML lang"),
("build.canonical_duplicate", "同页 canonical 唯一", dup_canon, "fail", "一页只能存在一条 canonical"),
("build.canonical_scheme", "绝对 HTTPS canonical", bad_canon, "warn", "生产域名配置完成后重新构建"),
("build.hreflang", "hreflang 语言标记与 HTTPS 地址", bad_hreflang, "warn", "只为已发布且审核过的翻译输出 alternate"),
("build.internal_links", "站内链接对应静态文件", missing_internal, "fail", "修复失效路径或添加正确重定向")]:
r.add(code, "build", severity if values else "pass", label,
f"问题 {len(values)} 项;样例:" + "; ".join(values[:6]) if values else f"扫描 {scanned} 个文件", solution if values else "")
sitemap = build / "sitemap.xml"
r.add("build.sitemap", "build", "pass" if sitemap.is_file() else "warn", "Sitemap 文件", "存在" if sitemap.is_file() else "不存在")
robots = build / "robots.txt"
r.add("build.robots", "build", "pass" if robots.is_file() else "warn", "robots.txt", "存在" if robots.is_file() else "不存在")
if baseline is not None:
if not baseline.is_file():
r.add("build.url_baseline", "build", "fail", "旧站 URL 清单", "基线文件不存在")
else:
routes, invalid = set(), []
for idx, s in enumerate(baseline.read_text(encoding="utf-8-sig").splitlines(), 1):
if not s.strip() or s.lstrip().startswith("#"): continue
route = normalized_route(s)
if route is None: invalid.append(idx)
else: routes.add(route)
misses = sorted(path for path in routes if not (path_for_route(build, path) or Path("/__invalid__")).is_file())
r.add("build.url_baseline", "build", "fail" if invalid or misses else "pass", "旧 URL 1:1 路径覆盖",
f"清单 {len(routes)} 条,缺失 {len(misses)} 条,非法输入行 {len(invalid)} 条;缺失样例 " + ", ".join(misses[:8]),
"不要静默更改旧站 SEO 路径;缺失 URL 必须修复或提供已审核 301 映射" if misses or invalid else "")
def release_gate(r: Report, evidence: Path | None):
# The kit NEVER gives a pass for production readiness without signed-off evidence.
required = ["isolated_pg", "redis_ping", "docker_health", "backup_restore", "browser_smoke",
"wordpress_url_parity", "staging_deploy", "rollback", "production_approval", "art_design_pro"]
if evidence is None:
r.add("release.evidence", "release", "skip", "正式发布证据表", "未提供 --evidence;不会宣称生产验收完成")
return
if not evidence.is_file():
r.add("release.evidence", "release", "fail", "正式发布证据表", "找不到 evidence JSON")
return
try:
obj = json.loads(evidence.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
r.add("release.evidence", "release", "fail", "正式发布证据表", "不是可解析 JSON")
return
for key in required:
val = obj.get(key)
# Validate dates/links/owners, not merely booleans.
valid = isinstance(val, dict) and val.get("status") == "passed" and bool(val.get("operator")) and bool(val.get("date")) and bool(val.get("evidence"))
r.add("release." + key, "release", "pass" if valid else "warn", f"签字验收:{key}",
"证据字段完整(不验证证据真实性)" if valid else "尚缺 passed/operator/date/evidence 字段",
"填入真实执行记录,未通过时禁止正式切流" if not valid else "")
def markdown(data: dict) -> str:
summ = data["summary"]
result = ["# SiteForge V8.18 只读验收报告", "", f"检查日期:{data['generated_at']}",
"", "**本工具不能证明完整生产上线。** 只执行只读探针和静态检查;人工证据需二次核实。", "",
f"检查结果:PASS {summ['pass']} / FAIL {summ['fail']} / WARN {summ['warn']} / SKIP {summ['skip']}", "",
"| 检查项 | 分类 | 结果 | 详情 |", "|---|---|---|---|"]
def clean(s): return str(s).replace("|", "\\|").replace("\n", " ")
for c in data["checks"]:
result.append(f"| {clean(c['description'])} | {clean(CATEGORIES.get(c['category'], c['category']))} | {c['status'].upper()} | {clean(c['detail'])} |")
result += ["", "## 修复清单", ""]
issues = [c for c in data["checks"] if c["status"] in ("fail", "warn")]
result += [f"- **{c['code']}**:{c['remediation'] or c['description']}" for c in issues] or ["- 没有发现 FAIL / WARN;仍需核实未执行的外部验收。"]
return "\n".join(result) + "\n"
STYLE = """body{font:14px/1.6 system-ui,-apple-system,'Microsoft YaHei',sans-serif;margin:0;background:#f4f6fa;color:#202b3d}header{padding:28px 4%;background:#14243d;color:white}header h1{margin:0;font-size:25px}main{max-width:1180px;margin:auto;padding:24px}.muted{color:#64748b}.panel{background:white;border:1px solid #e2e7f0;border-radius:14px;padding:22px;margin-bottom:20px}.cards{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.metric{background:white;border-radius:12px;border:1px solid #e2e7f0;padding:16px}.metric strong{display:block;font-size:29px}.tools{display:flex;gap:8px;flex-wrap:wrap;margin:16px 0}button{cursor:pointer;background:#fff;border:1px solid #ccd5e2;padding:9px 14px;border-radius:9px}button[aria-pressed=true]{background:#ff6a2b;color:white;border-color:#ff6a2b}.item{padding:13px 0;border-bottom:1px solid #edf0f4;display:flex;gap:12px;align-items:baseline}.tag{font-size:11px;border-radius:5px;padding:2px 7px;min-width:42px;text-align:center;display:inline-block;background:#e5e7eb}.pass{background:#dcfce7;color:#166534}.fail{background:#fee2e2;color:#991b1b}.warn{background:#fef3c7;color:#92400e}.skip{background:#e2e8f0;color:#475569}.description{font-weight:650}.detail{color:#64748b;font-size:13px}.secondary{margin-left:auto;color:#9ca3af}footer{padding:20px;text-align:center;color:#64748b}@media(max-width:660px){.cards{grid-template-columns:repeat(2,minmax(0,1fr))}.item{display:block}.secondary{display:none}}"""
def html_report(data: dict) -> str:
cards = "".join(f'<div class="metric"><div>{k.upper()}</div><strong>{data["summary"][k]}</strong></div>' for k in ("pass", "fail", "warn", "skip"))
rows = []
for c in data["checks"]:
e = lambda s: html.escape(str(s), quote=True)
rows.append(f'<div class="item" data-status="{e(c["status"])}"><span class="tag {e(c["status"])}">{e(c["status"].upper())}</span><div><div class="description">{e(c["description"])}</div><div class="detail">{e(c["detail"])}</div>' + (f'<div class="detail">建议:{e(c["remediation"])}</div>' if c["remediation"] else "") + f'</div><span class="secondary">{e(c["code"])}</span></div>')
return ("<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>SiteForge · 验收中心</title><style>" + STYLE + "</style></head><body>"
'<header><h1>SiteForge · 生产验收中心</h1><div>V8.18 Read-only Acceptance Kit · 生成时间:' + html.escape(data["generated_at"]) + '</div></header>'
'<main><section class="cards">' + cards + '</section><section class="panel"><h2>检查结论</h2><p>本报告不会将跳过项、模拟测试或人工登记直接视为生产通过。正式发布必须有真实数据库、Docker、浏览器和回滚证据。</p><div class="tools"><button aria-pressed="true" data-filter="all">全部</button><button aria-pressed="false" data-filter="fail">失败</button><button aria-pressed="false" data-filter="warn">警告</button><button aria-pressed="false" data-filter="skip">待验收</button><button aria-pressed="false" data-filter="pass">通过</button></div><div id="checks">' + "".join(rows) + '</div></section></main><footer>该页面离线可查看,不上传任何诊断数据。</footer><script>document.querySelectorAll("button[data-filter]").forEach(b=>b.addEventListener("click",()=>{document.querySelectorAll("button[data-filter]").forEach(x=>x.setAttribute("aria-pressed",String(x===b)));document.querySelectorAll(".item[data-status]").forEach(x=>x.hidden=b.dataset.filter!=="all"&&x.dataset.status!==b.dataset.filter)}));</script></body></html>')
def main(argv=None):
ap = argparse.ArgumentParser(description="SiteForge V8.18 non-destructive acceptance toolkit")
ap.add_argument("--repo", type=Path, help="SiteForge 源码根目录")
ap.add_argument("--url", help="只读 HTTP 健康检查 URL;默认仅支持 loopback")
ap.add_argument("--allow-remote", action="store_true", help="明确允许访问已授权远端测试目标")
ap.add_argument("--docker", action="store_true", help="运行只读 docker compose 命令")
ap.add_argument("--compose-file", default="compose.enterprise.yaml", help="默认企业 Compose 文件")
ap.add_argument("--postgres", action="store_true", help="只执行 PostgreSQL SELECT 1")
ap.add_argument("--redis", action="store_true", help="只执行 Redis PING")
ap.add_argument("--build-dir", type=Path, help="检查已生成的静态站目录")
ap.add_argument("--url-baseline", type=Path, help="旧站 URL 路径清单 TXT")
ap.add_argument("--evidence", type=Path, help="人工签字验收证据 JSON")
ap.add_argument("--output", type=Path, default=Path("siteforge-audit"), help="报告文件前缀")
args = ap.parse_args(argv)
r = Report(args)
if not any((args.repo, args.url, args.docker, args.postgres, args.redis, args.build_dir, args.evidence)):
ap.error("至少选择一个检查目标,例如 --repo . --docker 或 --url http://127.0.0.1:8787")
if args.repo: audit_repo(r, args.repo)
if args.url: audit_http(r, args.url, args.allow_remote)
if args.docker: audit_docker(r, args.repo or Path.cwd(), args.compose_file)
if args.postgres: audit_postgres(r)
if args.redis: audit_redis(r)
if args.build_dir: audit_build(r, args.build_dir, args.url_baseline)
if args.url_baseline and not args.build_dir:
r.add("build.baseline_without_build", "build", "warn", "旧站 URL 路径基线", "必须同时传入 --build-dir")
if args.evidence: release_gate(r, args.evidence)
else: release_gate(r, None)
data = r.data()
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.with_suffix(".json").write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
args.output.with_suffix(".md").write_text(markdown(data), encoding="utf-8")
args.output.with_suffix(".html").write_text(html_report(data), encoding="utf-8")
print(f"PASS={data['summary']['pass']} FAIL={data['summary']['fail']} WARN={data['summary']['warn']} SKIP={data['summary']['skip']}")
print("报告已写入:", args.output.with_suffix(".json"), args.output.with_suffix(".md"), args.output.with_suffix(".html"))
return 2 if data["summary"]["fail"] else 0
if __name__ == "__main__":
sys.exit(main())
+57
View File
@@ -0,0 +1,57 @@
"""Verify an installed Art Design Pro checkout and optionally run its real build.
Requires the USER's official checkout. A fixture-only installer test never
counts as a successful official Vue build.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
if str(Path(__file__).resolve().parents[1]) not in sys.path:
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from scripts.install_into_art_design_pro import install
def verify(path: Path, build: bool = False) -> dict:
root = path.expanduser().resolve()
result = install(root, verify=True)
manifest = root/'src/views/siteforge/.siteforge-v819.json'
if not manifest.is_file():
raise ValueError('安装清单缺失,请执行安装器 --apply')
data = json.loads(manifest.read_text(encoding='utf-8'))
if data.get('siteforge_version') != '8.21.3-dev.1':
raise ValueError('安装清单版本不匹配')
result.update({'version':data['siteforge_version'],'build':'NOT_RUN'})
if build:
if not (root/'node_modules').is_dir():
raise ValueError('缺少 Art Design Pro 官方 npm 依赖;在官方工程先运行 pnpm install --frozen-lockfile')
command = ['pnpm','build']
try:
out = subprocess.run(command, cwd=root, check=False, capture_output=True,
text=True, timeout=300)
except (OSError, subprocess.TimeoutExpired) as exc:
raise ValueError('真实构建无法完成:'+str(exc)) from exc
if out.returncode:
raise ValueError(f'pnpm build 失败(退出码 {out.returncode}):'+out.stderr[-2000:])
result['build']='PASS'
result['build_stdout_tail']=out.stdout[-1000:]
return result
if __name__=='__main__':
parser=argparse.ArgumentParser()
parser.add_argument('official_repo',type=Path)
parser.add_argument('--build',action='store_true',help='有官方源码和 node_modules 后执行真实 pnpm build')
parser.add_argument('--report',type=Path)
a=parser.parse_args()
try:
result=verify(a.official_repo,build=a.build)
if a.report:
a.report.parent.mkdir(parents=True,exist_ok=True)
a.report.write_text(json.dumps(result,ensure_ascii=False,indent=2),encoding='utf-8')
print(json.dumps(result,ensure_ascii=False,indent=2))
except ValueError as exc:
print('BLOCKED/FAILED: '+str(exc),file=sys.stderr)
raise SystemExit(1)
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""REAL pg_dump -> checksum -> pg_restore into dedicated empty DB CI smoke.
Must only run in isolated disposable CI PG server as administrator and with
SITEFORGE_ALLOW_PG_DR_SMOKE=1. Do NOT point at any production database.
"""
from __future__ import annotations
# Allow direct `python scripts/name.py` from a fresh source checkout.
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parents[1]))
import os
import re
import tempfile
import uuid
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
from app import db, pg_compat, pg_disaster_recovery as disaster
src_url = os.environ.get('SITEFORGE_DATABASE_URL','')
parsed=urlsplit(src_url)
src_name=parsed.path.lstrip('/')
if (os.environ.get('SITEFORGE_ALLOW_PG_DR_SMOKE')!='1' or not db.POSTGRES
or not re.fullmatch('[a-z][a-z0-9_]*_ci',src_name)):
raise SystemExit('Requires isolated PG _ci database and explicit DR-smoke flag')
dest_name=src_name[:-3]+'_restore_ci'
dest_url=urlunsplit((parsed.scheme,parsed.netloc,'/'+dest_name,parsed.query,parsed.fragment))
env=disaster.connection_env(src_url)
marker='restore-check-'+uuid.uuid4().hex[:8]
created=False
try:
with db.connect() as conn:
conn.execute('INSERT INTO sites(id,name,created_at,updated_at) VALUES (?,?,?,?)',
(marker,'Backup Restore Sample',db.now(),db.now()))
# pg_dump is a snapshot, but cross-volume coherence still requires no writers;
# CI runs in isolation and uses a private data directory.
with tempfile.TemporaryDirectory(prefix='sf-v818-pg-dr-') as root:
root=Path(root)
data=root/'original';asset=data/'media'/marker/'file.txt'
asset.parent.mkdir(parents=True)
asset.write_text('SiteForge backup fixture',encoding='utf-8')
archive=root/'backup.zip'
disaster.backup(src_url,data,archive,confirmed_offline=True)
assert disaster.verify(archive)['files']['database.dump']['size']>0
# Admin must have CREATE DATABASE in this isolated CI service.
disaster.run_pg(['psql','--no-psqlrc','--command',f'CREATE DATABASE {dest_name}'],env,40)
created=True
new_storage=root/'restored'
disaster.restore_new(dest_url,archive,new_storage,confirmed_offline=True)
assert (new_storage/'media'/marker/'file.txt').read_text(encoding='utf-8')=='SiteForge backup fixture'
with pg_compat.connect(dest_url) as conn:
row=conn.execute('SELECT name FROM sites WHERE id=?',(marker,)).fetchone()
assert row and row['name']=='Backup Restore Sample'
print('PASS: Real PG dump, manifest integrity, restore to empty DB and media recovery')
finally:
with db.connect() as conn:
conn.execute('DELETE FROM sites WHERE id=?',(marker,))
if created:
disaster.run_pg(['psql','--no-psqlrc','--command',f'DROP DATABASE {dest_name} WITH (FORCE)'],env,40)
+78 -19
View File
@@ -1,20 +1,79 @@
"""PostgreSQL bootstrap smoke: execute only in a throwaway database."""
import os
if not os.getenv('SITEFORGE_DATABASE_URL','').startswith(('postgresql://','postgres://')):
raise SystemExit('Please set SITEFORGE_DATABASE_URL to a disposable PostgreSQL database')
from app import db,enterprise,factory_api,migration_audit,reusables,release_api,jobs
from app.core import save_entry
import uuid
"""Full PostgreSQL integration smoke test. Requires a disposable PostgreSQL DB.
db.init();enterprise.setup();factory_api.setup();migration_audit.setup();reusables.setup();release_api.setup()
sid=uuid.uuid4().hex[:16]
with db.connect() as c:
c.execute('INSERT INTO sites(id,name,domain,created_at,updated_at) VALUES (?,?,?,?,?)',(sid,'PG Smoke','','2026-01-01','2026-01-01'))
c.execute('INSERT INTO entries(id,site_id,type,title,route,slug,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)',
(uuid.uuid4().hex[:16],sid,'page','Home','/','home','2026-01-01','2026-01-01'))
assert db.one('SELECT id FROM sites WHERE id=?',(sid,))['id']==sid
job=jobs.submit(sid,{'mirror_images':False},'pg-smoke','pg-smoke-unique-0001')
assert job['state']=='queued'
assert jobs.cancel(sid,job['id'])['state']=='cancelled'
with db.connect() as c:c.execute('DELETE FROM sites WHERE id=?',(sid,))
print('PostgreSQL schema, CRUD, job submit, cancel smoke passed')
Run only in CI or a dedicated empty database: creates tables and temporary
site/jobs, and validates actual DB API compatibility and worker claims.
"""
# Allow direct `python scripts/name.py` from a fresh source checkout.
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parents[1]))
import os
import uuid
url=os.getenv('SITEFORGE_DATABASE_URL','')
if not url.startswith(('postgresql://','postgres://')):
raise SystemExit('Set SITEFORGE_DATABASE_URL to a DISPOSABLE PostgreSQL database')
from app import (db,enterprise,factory_api,migration_audit,reusables,release_api,
cms_pipeline,v8_workflows,v8_templates,wp_migration_v81,wp_migration_v82,
wp_migration_v83,connectors_v84,connectors_v85,studio_v86,
site_design_v87,multilingual,jobs,worker)
# Mirror app.main.lifespan bootstrap so CI will discover any broken DDL/UPSERT.
db.init()
for module in (enterprise,factory_api,migration_audit,reusables,release_api,
cms_pipeline,v8_workflows,v8_templates,wp_migration_v81,wp_migration_v82,
wp_migration_v83,connectors_v84,connectors_v85,studio_v86,
site_design_v87,multilingual):
module.setup()
assert db.one('SELECT 1 AS ready')['ready']==1
assert db.one('SELECT 1 AS ready')['ready']==1
ledger=db.one('SELECT version FROM schema_migrations WHERE version=?',('20261022_001_p1_limits',))
assert ledger,'PostgreSQL migration ledger not created'
sid='pg-'+uuid.uuid4().hex[:12]
job_id=None
sid2=sid+"-second"
try:
with db.connect() as c:
c.execute('INSERT INTO sites(id,name,domain,created_at,updated_at) VALUES (?,?,?,?,?)',
(sid,'PG Smoke','','2026-01-01','2026-01-01'))
c.execute('INSERT INTO sites(id,name,domain,created_at,updated_at) VALUES (?,?,?,?,?)',
(sid2,'PG Smoke Second','','2026-01-01','2026-01-01'))
c.execute('INSERT INTO entries(id,site_id,type,title,route,slug,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)',
(uuid.uuid4().hex[:16],sid,'page','Home','/','home','2026-01-01','2026-01-01'))
assert db.one('SELECT id FROM sites WHERE id=?',(sid,))['id']==sid
job=jobs.submit(sid,{'mirror_images':False},'pg-smoke','pg-smoke-'+uuid.uuid4().hex[:14])
job_id=job['id']
# PostgreSQL claim exercises FOR UPDATE SKIP LOCKED and numeric COUNT rows.
claimed=worker.claim()
assert claimed and claimed['id']==job_id and claimed['state']=='running',claimed
assert worker.claim() is None,'Same-site job was claimed twice'
worker.complete(claimed,{'ok':True})
assert db.one('SELECT state FROM build_jobs WHERE id=?',(job_id,))['state']=='done'
job2=jobs.submit(sid,{'mirror_images':True},'pg-smoke','pg-smoke-'+uuid.uuid4().hex[:14])
assert jobs.cancel(sid,job2['id'])['state']=='cancelled'
# Independent connections racing for work must never receive the same job.
import threading
keys=[('pg-claim-a',sid),('pg-claim-b',sid2)]
submitted=[]
for label,site in keys:
submitted.append(jobs.submit(site,{'mirror_images':site==sid},'pg-smoke',
label+'-'+uuid.uuid4().hex[:10])['id'])
barrier=threading.Barrier(2)
claimed=[]; failures=[]
def runner():
try:
barrier.wait(timeout=8)
claimed.append(worker.claim())
except Exception as ex:failures.append(ex)
threads=[threading.Thread(target=runner) for _ in range(2)]
for thread in threads:thread.start()
for thread in threads:thread.join(timeout=12)
assert not any(t.is_alive() for t in threads),'Concurrent PG claim deadlock'
assert not failures,failures
ids={item['id'] for item in claimed if item}
assert ids==set(submitted),'Concurrent claims were duplicated or lost'
for item in claimed:worker.complete(item,{'ok':True})
print('PASS: PostgreSQL schemas, ledger, CRUD, queue claim/complete/cancel, two concurrent claims')
finally:
with db.connect() as c:
c.execute('DELETE FROM sites WHERE id=?',(sid,))
c.execute('DELETE FROM sites WHERE id=?',(sid2,))
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env python3
"""Optional real Redis wake-up smoke; external Redis required."""
# Allow direct `python scripts/name.py` from a fresh source checkout.
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parents[1]))
import os
import time
from app import redis_signal
if not os.environ.get('SITEFORGE_REDIS_URL'):
raise SystemExit('Set SITEFORGE_REDIS_URL for real Redis smoke')
if not redis_signal.notify():
raise SystemExit('Redis notification failed')
start=time.monotonic()
redis_signal.wait(0.1)
assert time.monotonic()-start < 4, 'Wakeup not consumed promptly'
print('PASS: Redis notification and worker wait against configured Redis')
+1 -1
View File
@@ -6,7 +6,7 @@ import sys
ROOT=Path(__file__).resolve().parent.parent
sys.path.insert(0,str(ROOT))
from app.version import VERSION
assert VERSION=='8.12.0-dev.1'
assert VERSION=='8.21.3-dev.1'
api=(ROOT/'app'/'main.py').read_text(encoding='utf-8')
assert 'return builder.build(' not in api, 'A synchronous request-thread build has reappeared'
assert "@app.post('/api/sites/{site_id}/builds', status_code=202)" in api
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""REAL PostgreSQL data migration CI smoke with a disposable isolated target.
This script deliberately never runs on ambiguous production-looking DB names.
Set SITEFORGE_ALLOW_PG_DATA_SMOKE=1 and database suffix _ci/_test.
"""
# Allow direct `python scripts/name.py` from a fresh source checkout.
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parents[1]))
import os
import subprocess
import sys
import tempfile
import uuid
from pathlib import Path
from urllib.parse import unquote,urlsplit
from app import db
from app.sqlite_pg_transfer import migrate
from app.pg_disaster_recovery import RecoveryError
url = os.environ.get('SITEFORGE_DATABASE_URL','')
name = unquote(urlsplit(url).path.lstrip('/'))
if not db.POSTGRES or os.environ.get('SITEFORGE_ALLOW_PG_DATA_SMOKE')!='1' or not name.endswith(('_ci','_test')):
raise SystemExit('Requires explicit CI-only PG smoke flag and _ci/_test database')
with tempfile.TemporaryDirectory(prefix='sf-v818-ci-') as temp:
source=Path(temp)/'siteforge.sqlite3'
env=os.environ.copy(); env.pop('SITEFORGE_DATABASE_URL',None)
env['SITEFORGE_DATA_DIR']=temp
env['SITEFORGE_DB_FILE']=str(source)
marker='migration-smoke-'+uuid.uuid4().hex[:10]
code=r'''from app import (db,enterprise,factory_api,migration_audit,reusables,release_api,
cms_pipeline,v8_workflows,v8_templates,wp_migration_v81,wp_migration_v82,
wp_migration_v83,connectors_v84,connectors_v85,studio_v86,site_design_v87,multilingual)
import os
assert not db.POSTGRES
assert not os.path.exists(db.DB_FILE)
db.init()
for x in (enterprise,factory_api,migration_audit,reusables,release_api,
cms_pipeline,v8_workflows,v8_templates,wp_migration_v81,wp_migration_v82,
wp_migration_v83,connectors_v84,connectors_v85,studio_v86,site_design_v87,multilingual):x.setup()
s=os.environ['V818_MARKER']
with db.connect() as c:
c.execute('INSERT INTO sites(id,name,created_at,updated_at) VALUES (?,?,?,?)',(s,'Migration Sample',db.now(),db.now()))
for i in range(3):
c.execute('INSERT INTO entries(id,site_id,type,title,route,slug,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)',
(s+'e'+str(i),s,'product','Migrated Car '+str(i),'/products/'+str(i)+'/','p'+str(i),db.now(),db.now()))
c.execute('INSERT INTO redirects(id,site_id,old_path,new_path) VALUES (?,?,?,?)',(s+'r',s,'/old/','/products/0/'))
'''
env['V818_MARKER']=marker
subprocess.run([sys.executable,'-c',code],check=True,env=env)
try:
result=migrate(source,confirmed_offline=True)
assert result['verified'] and result['details']['entries']['rows']==3
assert result['details']['redirects']['rows']==1
assert db.one('SELECT COUNT(*) AS n FROM entries WHERE site_id=?',(marker,))['n']==3
assert db.one('SELECT COUNT(*) AS n FROM redirects WHERE site_id=?',(marker,))['n']==1
# Re-running must fail: no duplicate rows, no silent upsert.
try:migrate(source,confirmed_offline=True)
except RecoveryError as exc:
assert 'not empty' in str(exc)
else:raise AssertionError('Migration unexpectedly allowed nonempty target')
print(f'PASS: actual PostgreSQL transfer {result["tables"]} tables, {result["records"]} records; rerun blocked')
finally:
with db.connect() as c:
c.execute('DELETE FROM sites WHERE id=?',(marker,))
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Offline WordPress WXR preflight and optional static output URL coverage check."""
from __future__ import annotations
import argparse
import html
import json
import sys
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:sys.path.insert(0,str(ROOT))
from app.wp_complex_v820 import inspect_wxr, MAX_XML
from app.builder import output_path
def inspect_file(source:Path,dist:Path|None=None):
if source.stat().st_size>MAX_XML:raise ValueError('WXR 超过 16 MB,需分割后分别预检')
result=inspect_wxr(source.read_bytes())
if dist is not None:
if not dist.is_dir():raise ValueError('静态构建输出目录不存在')
# Every actual published URL must be matched. WXR preflight by design
# never claims 100% coverage if it has invalid or duplicate routes.
from defusedxml import ElementTree as ET
root=ET.parse(source).getroot()
routes=[]
from app.core import route_path
from urllib.parse import urlsplit
for item in root.findall('./channel/item'):
fields={c.tag.rsplit('}',1)[-1]:c.text or '' for c in item if c.tag.rsplit('}',1)[-1] in ('post_type','status','link')}
if fields.get('post_type') in ('attachment','revision','nav_menu_item') or fields.get('status','publish')!='publish':continue
p=urlsplit(fields.get('link',''))
if p.scheme in ('http','https') and p.hostname and not p.query and not p.fragment and p.path:
try:routes.append(route_path(p.path))
except ValueError:pass
missing=[route for route in sorted(set(routes)) if not output_path(dist,route).is_file()]
result['static_build_coverage']={'expected_routes':len(set(routes)),'missing_count':len(missing),
'missing_sample':missing[:100],
'passed':len(missing)==0 and result['blocking_issues']==0}
return result
def render_html(report):
rows=''.join('<tr><td>'+html.escape(x['severity'])+'</td><td>'+html.escape(x['code'])+
'</td><td>'+html.escape(x['route'])+'</td><td>'+html.escape(x['message'])+'</td></tr>' for x in report['issues'])
metrics=' '.join(f'<span><strong>{html.escape(str(k))}</strong>: {html.escape(str(v))}</span>' for k,v in report['counts'].items() if k in ('published_content','attachments','findings_blocker','findings_review'))
return f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge V8.20 WordPress 兼容预检</title><style>
body{{font:14px/1.65 system-ui,"Microsoft YaHei",sans-serif;color:#192d43;background:#f3f6f9;margin:0}}
header{{background:#193046;color:#fff;padding:32px 6%}}main{{max-width:1100px;margin:20px auto;padding:25px;background:#fff;border-radius:12px;overflow-x:auto}}
header h1{{margin:0}}p{{color:#576679}}.metrics span{{display:inline-block;padding:10px;border:1px solid #e4e9f0;margin:4px;border-radius:7px}}
table{{border-collapse:collapse;width:100%}}th,td{{padding:9px;text-align:left;border-bottom:1px solid #edf0f2;vertical-align:top}}tr:nth-child(even){{background:#f8fafc}}</style></head><body>
<header><h1>SiteForge V8.20 · WordPress 兼容预检</h1><p style="color:#eee">离线分析,不连接原站,不修改已有站点</p></header><main><div class="metrics">{metrics}</div><h2>待处理问题</h2>
<table><thead><tr><th>等级</th><th>问题类型</th><th>URL</th><th>处理说明</th></tr></thead><tbody>{rows}</tbody></table>
<p>本报告不能证明 1:1 迁移、媒体本地化、动态功能、视觉一致性或正式发布验收通过。</p></main></body></html>'''
def main(argv=None):
parser=argparse.ArgumentParser(description='离线分析 WordPress WXR 迁移风险与 URL 覆盖')
parser.add_argument('wxr',type=Path)
parser.add_argument('--dist',type=Path,help='可选:静态构建 dist 目录,检查对应 HTML 是否存在')
parser.add_argument('--output',type=Path,default=Path('reports/v820-wxr-preflight.json'))
args=parser.parse_args(argv)
try:report=inspect_file(args.wxr,args.dist)
except (OSError,ValueError,Exception) as exc:
# A single fail-closed error message avoids a massive traceback for malformed input.
print('无法完成 WXR 预检:'+str(exc)[:350],file=sys.stderr)
return 2
args.output.parent.mkdir(parents=True,exist_ok=True)
args.output.write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf-8')
args.output.with_suffix('.html').write_text(render_html(report),encoding='utf-8')
print(f"WXR 预检:{report['unique_published_routes']} 个静态路由,{report['blocking_issues']} 个阻断问题;报告:{args.output}")
if report['blocking_issues'] or report.get('static_build_coverage',{}).get('passed') is False:return 1
return 0
if __name__=='__main__':raise SystemExit(main())
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Creates a durable V8.18 evidence index from the tests already executed."""
from __future__ import annotations
import json,html,datetime
from pathlib import Path
root=Path(__file__).resolve().parents[1]
rep=root/'reports'
r=json.loads((rep/'v818-regression-2rounds.json').read_text(encoding='utf-8'))
a=json.loads((rep/'v818-readonly.json').read_text(encoding='utf-8'))
http=(rep/'v818-http-e2e.log').read_text(encoding='utf-8')
assert r['status']=='PASS' and r['repeats']==2 and not r['failures']
assert (rep/'v818_two_rounds_exitcode').read_text().strip()=='0'
assert 'PASS' in http
runs=[x for x in r['runs'] if x['label'].startswith('round')]
total=sum(x['passed'] for x in runs)
result={
'platform':'SiteForge Enterprise', 'version':'8.18.0-dev.1',
'generated_at':datetime.datetime.now(datetime.timezone.utc).isoformat(),
'delivery':'developer integration build',
'source':'V8.17 complete source + V8.18 improvements',
'local_validation':{
'pytest_status':r['status'], 'modules':r['modules'], 'rounds':r['repeats'],
'passed_per_round':r['tests_last_complete_round'], 'passed_across_rounds':total,
'failures':r['counts']['FAIL'], 'timeouts':r['counts']['TIMEOUT'],
'http_worker_e2e':'PASS', 'python_compile':'PASS', 'node_syntax':'PASS',
'static_audit':'PASS (12 checks, 2 skipped)',
'ci_yaml':'parsed locally; not executed on GitHub Actions'},
'not_tested_in_this_environment':[
'real PostgreSQL data migration and dump/restore (CI entry supplied)',
'real Redis wake-up (CI entry supplied)',
'Docker engine / multi-container multi-worker network fault tests',
'Playwright/Chromium interactive browser smoke',
'SFTP/S3/HTTPS remote public deployment and rollback',
'production backup RPO/RTO and offsite restore'],
'changed_modules':[
'app/backup.py','app/pg_disaster_recovery.py','app/sqlite_pg_transfer.py',
'app/jobs.py','app/worker.py','scripts/pg_disaster_recovery.py',
'scripts/migrate_sqlite_to_postgres.py','scripts/verify_pg_bootstrap.py',
'scripts/verify_sqlite_pg_transfer.py','scripts/verify_pg_backup_restore.py',
'scripts/verify_redis_signal.py','.github/workflows/ci.yml'],
'limitations': 'NOT production-approved; real PG/Redis/Docker tests require external CI/ops environment'}
(rep/'V8_18_RELEASE_EVIDENCE.json').write_text(json.dumps(result,ensure_ascii=False,indent=2),encoding='utf-8')
lines=['# SiteForge Enterprise V8.18.0-dev.1 — 集成验收总览','',
'**结论:本地开发集成回归通过,正式生产环境验收未完成。**','',
f"- 自动化:{r['modules']} 个模块,{r['repeats']} 轮 × {r['tests_last_complete_round']} 项 = {total} 项通过;0 失败 / 0 超时。",
'- HTTP + Worker 真实端到端:通过(登录 → 建站 → 入队 → Worker 构建 → ZIP → 预览)。',
'- Python 编译、JavaScript 语法、项目静态检查:通过。',
'- 项目只读审计:12 项通过,2 项跳过。',
'', '## 核心新增', '',
'- Postgres 模式禁用不正确的 SQLite 备份;Postgres 离线 ZIP 灾备、校验与恢复到全新数据库/目录。',
'- SQLite → PostgreSQL 离线迁移、依赖排序、事务提交和逐表哈希/行数核验。',
'- 发布/回滚任务租约过期、网络故障、取消后标记待核对,禁止不安全自动重试。',
'- CI PostgreSQL、Redis 和实际 pg_dump → 恢复的集成烟测入口。',
'', '## 仍未通过生产门禁', '']
lines.extend('- '+x for x in result['not_tested_in_this_environment'])
lines+=['','## 文件','',
'- `reports/v818-regression-2rounds.html`:两轮测试明细',
'- `reports/v818-readonly.html`:只读审计报告',
'- `reports/v818-http-e2e.log`:HTTP/Worker E2E 输出',
'- `docs/V8_18_ENTERPRISE_HARDENING.md`:运维、迁移与灾备说明',
'', '注:成功通过本地自动化不代表 Docker 和真实 PostgreSQL 性能/容灾验收通过。']
(rep/'V8_18_集成验收总览.md').write_text('\n'.join(lines)+'\n',encoding='utf-8')
rows=''.join(f"<tr><td>{html.escape(x['label'])}</td><td class=ok>{x['status']}</td><td>{x['passed']}</td><td>{x['seconds']}s</td></tr>" for x in runs)
risks=''.join('<li>'+html.escape(x)+'</li>' for x in result['not_tested_in_this_environment'])
page=f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge V8.18 · 企业集成验收</title><style>
:root{{font-family:system-ui,-apple-system,"Microsoft YaHei",sans-serif;color:#1d2939;background:#f5f7fb}}*{{box-sizing:border-box}}body{{margin:0}}header{{background:#152338;color:white;padding:44px max(24px,calc((100vw - 1120px)/2))}}header p{{color:#b0c5df}}main{{max-width:1120px;margin:28px auto;padding:0 18px}}.cards{{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}}.card,section{{border:1px solid #e5e9ef;background:#fff;border-radius:12px;padding:20px}}.card small{{color:#758398}}.big{{font-size:31px;font-weight:750;margin:6px 0}}.ok{{color:#13795b;font-weight:700}}section{{margin-top:14px}}h2{{margin:0 0 15px;font-size:20px}}table{{width:100%;border-collapse:collapse}}th,td{{padding:10px;border-bottom:1px solid #eef0f4;text-align:left}}.warning{{font-weight:600;color:#99580c}}li{{margin:8px 0}}@media(max-width:650px){{.cards{{grid-template-columns:repeat(2,1fr)}}section{{overflow-x:auto}}}}</style></head><body>
<header><div style="font-size:13px;letter-spacing:2px;color:#b0c5df">SITEFORGE ENTERPRISE</div><h1>V8.18 · 集成验收总览</h1><p>开发集成版 · 本地验证通过 · 生产环境门禁未关闭</p></header><main><div class=cards>
<div class=card><small>每轮自动化测试</small><div class=big>299</div><span class=ok>PASS</span></div><div class=card><small>重复回归</small><div class=big>2</div><span>共 {total} 次通过</span></div><div class=card><small>测试模块</small><div class=big>{r['modules']}</div><span>历史模块 + 新模块</span></div><div class=card><small>失败 / 超时</small><div class=big>0 / 0</div><span class=ok>PASS</span></div></div>
<section><h2>完整回归批次</h2><table><tr><th>批次</th><th>状态</th><th>测试通过</th><th>耗时</th></tr>{rows}</table></section>
<section><h2>业务链路</h2><p class=ok>PASS · 登录/CSRF → 创建网站和页面 → 异步 Worker 构建 → ZIP 下载 → 页面预览</p><p>Python 编译、JavaScript 语法检查及源码只读审计均通过;只读审计 2 项因环境/人工证据缺失跳过。</p></section>
<section><h2>尚未关闭的生产验收</h2><p class=warning>下列内容已准备 CI/运维入口,当前环境尚无真实完成证据:</p><ul>{risks}</ul></section>
<section><h2>运维与版本说明</h2><p>交付包含 V8.17 原有完整业务模块、V8.18 新增备份/迁移/防重复发布代码、自动化测试、CI 工作流、README、完整原始测试日志与验收报告。</p><p>真实上线前必须独立执行 PostgreSQL 迁移、灾备恢复、多 Worker、真实浏览器、部署回滚与故障演练。所有数据库写操作均应只在隔离测试库或经批准的维护窗口执行。</p></section></main></body></html>'''
(rep/'V8_18_集成验收总览.html').write_text(page,encoding='utf-8')
print('WROTE',total,'passed across two rounds')
+34
View File
@@ -0,0 +1,34 @@
"""Produce acceptance report from actual V8.19 result files; no invented passes."""
import json
import html
from pathlib import Path
from datetime import datetime,timezone
root=Path(__file__).resolve().parents[1]
r=root/'reports'
summary=r/'v819-regression.json'
if not summary.exists():raise SystemExit('missing v819-regression.json; run regression first')
x=json.loads(summary.read_text(encoding='utf-8'))
if x['status'] != 'PASS':raise SystemExit('regression incomplete or failed; refusing PASS acceptance')
repeatfile=r/'v819-regression-repeat2.json'
if repeatfile.exists():
repeated=json.loads(repeatfile.read_text(encoding='utf-8'))
if repeated['status'] != 'PASS' or repeated['repeats'] != 2:raise SystemExit('repeat regression did not pass')
repeats_note=f"2 rounds / {sum(t['passed'] for t in repeated['runs'] if t['status']=='PASS')} checks"
else:
repeats_note='single round only'
items=[('V8.19 全量自动化回归','PASS',f"{x['tests_last_complete_round']} tests / {x['modules']} modules; {repeats_note}"),
('本地 HTTP/Worker E2E','PASS (已单独运行)','参见运行日志;本工具不自动验证历史人工输出'),
('Art 路由及业务页面适配','PASS (fixture)','安装器测试;不是官方编译'),
('Art 官方 npm 构建','BLOCKED','缺少官方 Art 工程及其依赖'),
('Art PC/移动端浏览器验收','NOT RUN','需要官方构建与正常浏览器环境'),
('真实 PostgreSQL/Redis/HTTPS/发布','NOT RUN','需要独立环境实测')]
info={'version':'8.19.0-dev.1','generated_at':datetime.now(timezone.utc).isoformat(),'regression':x,'repeat_verification':repeats_note,'items':[dict(name=a,status=b,note=c) for a,b,c in items]}
(r/'V8_19_验收汇总.json').write_text(json.dumps(info,ensure_ascii=False,indent=2),encoding='utf-8')
text=['# SiteForge Enterprise V8.19 验收汇总','','**状态:开发集成回归已通过,生产/Art 官方验收尚未完成。**','',f"- 自动回归:{x['tests_last_complete_round']} 项;模块 {x['modules']};状态 {x['status']}。",'','| 验收项 | 状态 | 说明 |','|---|---|---|']
text += [f'| {a} | {b} | {c} |' for a,b,c in items]
(r/'V8_19_验收汇总.md').write_text('\n'.join(text)+'\n',encoding='utf-8')
rows=''.join(f'<tr><td>{html.escape(a)}</td><td>{html.escape(b)}</td><td>{html.escape(c)}</td></tr>' for a,b,c in items)
doc='''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge V8.19 验收汇总</title><style>body{margin:0;background:#f4f6fa;font:15px/1.6 system-ui,sans-serif;color:#263548}header{background:#1d354e;color:#fff;padding:42px 7%}header h1{margin:0}main{max-width:1000px;margin:32px auto;padding:24px;background:#fff;border-radius:16px;border:1px solid #e5e9f0}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:13px 10px;border-bottom:1px solid #e6ebf0}th{background:#f8fafc}p{color:#546379}@media(max-width:740px){main{margin:12px;overflow-x:auto;padding:14px}}</style></head><body><header><h1>SiteForge Enterprise V8.19</h1><p style="color:#d0d8df">统一后台开发集成验收</p></header><main><h2>验收结论</h2><p>本地回归通过不等于正式 Art 编译或生产验收。下表区分实测与待完成项目。</p><table><thead><tr><th>项目</th><th>状态</th><th>证据或限制</th></tr></thead><tbody>'''+rows+'</tbody></table></main></body></html>'
(r/'V8_19_验收汇总.html').write_text(doc,encoding='utf-8')
print('V8.19 acceptance reports generated; cases:',x['tests_last_complete_round'])