upgrade: adopt V8.31.1 (theme 1.3/1.4 redesigns, editor deep-canvas, guided launch, component library)

Overlay 190 upstream items (V8.22→8.31) preserving trade-pro theme,
registry tests, and design docs. Re-applied full patch set on identical
anchors (discovery/env_for/blocks.css/type-mapping/locale-override/
theme_color/frontend entries/test_theme_pro_v3 mods) plus compose pins.

Verified: theme suites 35/35; dual-baseline full run 415 passed vs
pristine 386+29 ours, FAILED-set diff EMPTY (same 10 Windows env fails).
Live: container 8.31.1-dev.1 healthy, hesupipe 192 entries intact,
trade-pro build 203 pages 0 errors.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-26 09:43:17 +08:00
parent a63548c645
commit 8884e100f9
647 changed files with 20853 additions and 271 deletions
+85
View File
@@ -0,0 +1,85 @@
"""Offline Chromium smoke for the REAL V8.27 workspace code using a mock origin.
No real website, network credentials, or production data is touched.
"""
import json
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT=Path(__file__).resolve().parents[1]
URL='about:blank'
def run():
store={'sites':[], 'created':0}
errors=[]
with sync_playwright() as p:
browser=p.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
page=browser.new_page(viewport={'width':1360,'height':900}, device_scale_factor=1)
page.on('pageerror',lambda e:errors.append(str(e)))
# Execute the actual workspace HTML/CSS/JS without navigating to localhost
# (which this runtime's browser administrator policy blocks).
markup=(ROOT/'app/templates/workspace.html').read_text()
markup=markup.replace('<link rel="stylesheet" href="/ui/workspace.css">','')
markup=markup.replace('<script defer src="/ui/workspace-guide.js"></script>','')
markup=markup.replace('<script defer src="/ui/workspace.js"></script>','')
page.goto('about:blank')
page.set_content(markup)
page.add_style_tag(content=(ROOT/'app/static/workspace.css').read_text())
mock_js=r"""
window.mockCreateCount=0; window.mockSites=[];
window.history.pushState=()=>{};window.history.replaceState=()=>{};
window.fetch=async function(uri,opts={}){
const path=String(uri).split('?')[0], method=opts.method||'GET'; let result={};
if(path==='/api/auth/me')result={csrf:'test'};
else if(path==='/api/health')result={version:'8.27.0-dev.1'};
else if(path==='/api/sites'&&method==='GET')result=window.mockSites;
else if(path==='/api/sites'&&method==='POST'){
window.mockCreateCount++;const data=JSON.parse(opts.body);
result={id:'demo-id',name:data.name,theme:data.theme,mode:data.mode,domain:data.domain,tagline:data.tagline,updated_at:'2026-01-01T00:00:00Z',entries_count:1,build_count:0};
window.mockSites=[result];
}else if(path==='/api/sites/demo-id')result=window.mockSites[0];
else if(path==='/api/sites/demo-id/entries')result=[{id:'home',source:'starter',status:'published',route:'/',type:'page',title:'首页'}];
else if(path==='/api/sites/demo-id/builds')result=[];
else if(path==='/api/enterprise/sites/demo-id/jobs')result=[];
else if(path==='/api/production/sites/demo-id/delivery-status')result={jobs:[],history:[],configured:{}};
else return {status:404,ok:false,json:async()=>({detail:'Mock route missing: '+path})};
return {status:method==='POST'?201:200,ok:true,json:async()=>result};
};
"""
page.evaluate(mock_js)
page.add_script_tag(content=(ROOT/'app/static/workspace-guide.js').read_text())
page.add_script_tag(content=(ROOT/'app/static/workspace.js').read_text())
page.evaluate("document.dispatchEvent(new Event('DOMContentLoaded'))")
page.wait_for_selector('[data-action="new-site"]')
page.locator('[data-action="new-site"]').first.click()
assert page.get_by_text('第 1 步,共 3 步').count()>0
page.locator('#createNext').click()
assert '请填写网站名称' in page.locator('#createError').inner_text()
page.locator('#siteForm [name="name"]').fill('演示企业网站')
page.locator('#createNext').click()
assert page.locator('[data-create-step="1"]').is_visible()
page.locator('#createNext').click()
assert page.locator('[data-create-step="2"]').is_visible()
assert page.evaluate('window.mockCreateCount')==0
page.locator('#createSubmit').click()
page.wait_for_selector('.sf-journey-list',timeout=10000)
assert page.evaluate('window.mockCreateCount')==1
assert '准备网站内容' in page.locator('.sf-journey-list').inner_text()
assert '1/4' in page.locator('.sf-journey-intro').inner_text()
out=ROOT/'reports/v827';out.mkdir(parents=True,exist_ok=True)
page.screenshot(path=str(out/'v827-guided-workspace.png'),full_page=True)
page.set_viewport_size({'width':390,'height':844})
page.evaluate('window.scrollTo(0,0)')
page.wait_for_timeout(350)
page.screenshot(path=str(out/'v827-guided-mobile.png'),full_page=True)
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth+1'), 'mobile horizontal overflow'
assert page.evaluate("document.querySelector('.nav-side').getBoundingClientRect().right <= 2"), 'mobile drawer should start closed'
page.locator('#newSiteTop').click()
page.locator('#siteForm [name="name"]').fill('另一家公司')
page.locator('#createNext').click()
page.screenshot(path=str(out/'v827-create-wizard-mobile.png'),full_page=True)
assert errors==[],errors
browser.close()
print('PASS: real guided workspace JS, 3-step validation, actual create request, starter homepage excluded, 0 JS exceptions')
if __name__=='__main__':run()
+80
View File
@@ -0,0 +1,80 @@
"""Mock-backed real workspace Chromium test; NEVER touches production or localhost HTTP."""
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT=Path(__file__).resolve().parents[1]
def run():
errors=[]
with sync_playwright() as p:
browser=p.chromium.launch(headless=True,executable_path='/usr/bin/chromium',args=['--no-sandbox'])
page=browser.new_page(viewport={'width':1366,'height':900})
page.on('pageerror',lambda e:errors.append(str(e)))
markup=(ROOT/'app/templates/workspace.html').read_text()
for tag in ('<link rel="stylesheet" href="/ui/workspace.css">','<script defer src="/ui/workspace-guide.js"></script>','<script defer src="/ui/workspace-launch.js"></script>','<script defer src="/ui/workspace.js"></script>'):
markup=markup.replace(tag,'')
page.set_content(markup)
page.add_style_tag(content=(ROOT/'app/static/workspace.css').read_text())
page.evaluate(r'''() => {
window.history.replaceState=()=>{};window.history.pushState=()=>{};Object.defineProperty(window.crypto,'randomUUID',{value:()=> 'mock-idempotency-uuid'});
window.confirm=()=>true;
window.mock={approved:false,submitCount:0,requests:[],deployCount:0,site:{id:'s1',name:'测试网站',theme:'corporate',mode:'new',domain:'https://www.example.com',updated_at:'2026-01-01T00:00:00Z'},
builds:[{id:'b1',status:'complete',created_at:'2026-01-03T00:00:00Z',total_pages:12}]};
window.fetch=async (uri,opts={})=>{
let key=String(uri).split('?')[0],method=opts.method||'GET',result=null,status=200;
const m=window.mock;
if(key==='/api/auth/me')result={csrf:'test'};
else if(key==='/api/health')result={version:'8.28.0-dev.1'};
else if(key==='/api/sites'&&method==='GET')result=[m.site];
else if(key==='/api/sites/s1')result=m.site;
else if(key==='/api/sites/s1/entries')result=[{id:'h1',source:'manual',status:'published',route:'/',type:'page',title:'网站首页'}];
else if(key==='/api/sites/s1/builds')result=m.builds;
else if(key==='/api/enterprise/sites/s1/jobs')result=[];
else if(key==='/api/production/sites/s1/delivery-status')result={jobs:[],history:[],configured:{production:true,staging:true}};
else if(key==='/api/factory/sites/s1/release-requests'&&method==='GET')result=m.requests;
else if(key==='/api/factory/sites/s1/release-requests'&&method==='POST') {result={id:'r1',state:'pending'};m.requests=[{id:'r1',build_id:'b1',state:'pending'}]}
else if(key==='/api/production/sites/s1/preflight/b1')result={ready:m.approved,build_id:'b1',message:m.approved?'已批准':'尚未批准'};
else if(key==='/api/production/sites/s1/deploy'&&method==='POST') {m.deployCount++; result={id:'job-test-1'};}
else if(key==='/api/sites/s1/builds/b1/report')result={pages:12,warnings:[],redirects:0};
else {status=404;result={detail:'模拟接口没有实现 '+key};}
return {ok:status<400,status,json:async()=>result};
};
}''')
for name in ('workspace-guide.js','workspace-launch.js','workspace.js'):
page.add_script_tag(content=(ROOT/'app/static'/name).read_text())
page.evaluate("document.dispatchEvent(new Event('DOMContentLoaded'))")
page.wait_for_selector('.site-card',timeout=10000)
page.locator('.site-card').first.click()
page.locator('[data-view="launch"].nav-item').click()
page.wait_for_timeout(1100)
page.wait_for_selector('.sf-launch-step',timeout=3000)
assert page.locator('.sf-launch-step').count()==6
assert page.locator('[data-action="launch-deploy"]').is_disabled()
assert page.locator('[data-action="launch-request-approval"]').is_disabled()
page.locator('#launchReviewCheck').check()
assert page.locator('[data-action="launch-request-approval"]').is_enabled()
page.locator('[data-action="launch-request-approval"]').click()
page.wait_for_timeout(400)
page.wait_for_function('window.mock.requests.length===1',timeout=3500)
assert page.locator('[data-action="launch-preflight"]').is_disabled()
page.evaluate("window.mock.requests[0].state='approved';window.mock.approved=true")
page.locator('[data-action="launch-refresh"]').first.click()
page.wait_for_timeout(600)
page.wait_for_function("document.querySelector('[data-action=launch-preflight]')?.disabled===false",timeout=4000)
page.locator('[data-action="launch-preflight"]').click()
page.wait_for_timeout(450)
page.wait_for_function("document.querySelector('[data-action=launch-deploy]')?.disabled===false",timeout=4000)
page.locator('[data-action="launch-deploy"]').click()
page.wait_for_function('window.mock.deployCount===1')
assert '已进入后台队列' in page.locator('#launchNotice').inner_text()
report=ROOT/'reports/v828';report.mkdir(parents=True,exist_ok=True)
page.evaluate('window.scrollTo(0,0)')
page.wait_for_timeout(150)
page.screenshot(path=str(report/'launch-desktop.png'),full_page=True)
page.set_viewport_size({'width':390,'height':844})
page.wait_for_timeout(250)
page.screenshot(path=str(report/'launch-mobile.png'),full_page=True)
assert page.evaluate('document.documentElement.scrollWidth<=innerWidth+1'),'mobile overflow'
assert not errors,errors
browser.close()
print('PASS: original workspace JS, approval flow, preview checkbox, preflight, submit mock, mobile width, 0 runtime errors')
if __name__=='__main__':run()
+111
View File
@@ -0,0 +1,111 @@
"""Browser smoke test for the real editor template, with deterministic mocked APIs.
No real user session, database or network service is accessed.
"""
from __future__ import annotations
import json
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
REPORT=ROOT/'reports'/'v829-browser'
def run():
from playwright.sync_api import sync_playwright
REPORT.mkdir(parents=True,exist_ok=True)
editor=(ROOT/'app/templates/editor.html').read_bytes()
schema={'blocks':{
'hero':{'name':'横幅','icon':'◆','description':'顶部展示','fields':['title'],'defaults':{'title':'全球业务'}},
'richtext':{'name':'文字介绍','icon':'☷','fields':['title'],'defaults':{'title':'介绍'}},
'collection':{'name':'产品列表','icon':'▦','fields':['title'],'defaults':{'title':'我们的产品'}},
'layout':{'name':'多列布局','icon':'◫','fields':['title'],'defaults':{'title':'多列布局'}},
},'page_templates':{'standard':{'name':'标准页面'}}}
calls=[]
def mock(route):
req=route.request
path=req.url.split('example.test',1)[-1].split('?',1)[0]
if path=='/studio/s1':return route.fulfill(status=200,body=editor,content_type='text/html')
if path.startswith('/ui/'):
file=ROOT/'app/static'/path.removeprefix('/ui/')
if file.is_file():
typ='text/css' if file.suffix=='.css' else 'application/javascript'
return route.fulfill(status=200,body=file.read_bytes(),content_type=typ)
if path=='/api/auth/me':data={'csrf':'test-token'}
elif path=='/api/sites/s1':data={'id':'s1','name':'测试网站','theme':'automotive'}
elif path=='/api/sites/s1/entries':data=[{'id':'p1','title':'首页','route':'/'}]
elif path=='/api/editor/blocks':data=schema
elif path=='/api/sites/s1/components':data=[]
elif path=='/api/sites/s1/pages/p1/studio-draft':data={'revision':0,'blocks':[{'id':'b1','type':'hero','title':'全球业务','visible':True,'style':{}}],'page_template':'standard','layout_mode':'append','entry':{'title':'首页','route':'/'}}
elif path=='/api/sites/s1/pages/p1/editor-preview':
return route.fulfill(status=200,body='<html><body><section data-block="b1"><h1>全球业务</h1></section></body></html>',content_type='text/html')
elif path.startswith('/api/factory/themes/') and path.endswith('/preview'):
return route.fulfill(status=200,body='<html><body><header>主题示例</header><main><h1>网页主题样例</h1></main></body></html>',content_type='text/html')
else:return route.fulfill(status=404,body='not mocked')
calls.append(path)
return route.fulfill(status=200,body=json.dumps(data,ensure_ascii=False),content_type='application/json')
with sync_playwright() as p:
browser=p.chromium.launch(executable_path='/usr/bin/chromium',headless=True,args=['--no-sandbox','--disable-dev-shm-usage'])
page=browser.new_page(viewport={'width':1440,'height':900},device_scale_factor=1)
failures=[]
page.on('pageerror',lambda e:failures.append(str(e)))
# Navigation to local HTTP is blocked by this runner's browser policy.
# Exercise the exact editor DOM, CSS, and JS in isolated set_content instead.
import re
html_source=editor.decode('utf-8')
html_source=re.sub(r'<script[^>]+src="[^"]+"[^>]*></script>', '',html_source)
html_source=re.sub(r'<link[^>]+href="/ui/[^"]+"[^>]*>', '',html_source)
page.set_content(html_source)
page.add_style_tag(content=(ROOT/'app/static/editor.css').read_text())
page.add_script_tag(content=(ROOT/'app/static/editor-helpers.js').read_text())
page.add_script_tag(content=(ROOT/'app/static/studio-experience-v829.js').read_text())
page.evaluate("""({schema}) => {
const payloads={
'/api/auth/me':{csrf:'test-token'},
'/api/sites/s1':{id:'s1',name:'测试网站',theme:'automotive'},
'/api/sites/s1/entries':[{id:'p1',title:'首页',route:'/'}],
'/api/editor/blocks':schema,
'/api/sites/s1/components':[],
'/api/sites/s1/pages/p1/studio-draft':{revision:0,blocks:[{id:'b1',type:'hero',title:'全球业务',visible:true,style:{}}],page_template:'standard',layout_mode:'append',entry:{title:'首页',route:'/'}},
};
window.fetch=async function(url){
const path=String(url).split('?')[0];
if(path.endsWith('/editor-preview'))return {ok:true,status:200,headers:{get:()=> 'text/html'},text:async()=>'<html><body><h1>全球业务</h1></body></html>'};
const data=payloads[path];
return {ok:!!data,status:data?200:404,headers:{get:()=> 'application/json'},json:async()=>data||{detail:'not mocked'}};
};
}""",{'schema':schema})
script=(ROOT/'app/static/editor.js').read_text().replace("const siteId=pathParts[1],initialPage=pathParts[2];","const siteId='s1',initialPage='p1';")
page.add_script_tag(content=script)
page.evaluate('Studio.init()')
page.locator('#pageName').get_by_text('首页').wait_for(timeout=10000)
page.locator('#sfAddBlock').click()
assert page.locator('.sf-palette-group').count()==4
page.locator('#paletteSearch').fill('产品')
assert page.locator('.sf-palette-grid button').count()==1
assert '产品列表' in page.locator('.sf-palette-grid button').inner_text()
page.locator('#paletteSearch').fill('')
page.locator('#studioThemePreviewButton').click()
assert page.locator('#themePreviewModal').is_visible()
page.locator('#themePreviewTheme').select_option('industrial')
page.locator('#themePreviewPage').select_option('product')
page.get_by_role('button',name='手机',exact=True).last.click()
assert 'width: 390px' in page.locator('#themePreviewFrameShell').get_attribute('style')
assert '/industrial/preview?page_type=product' in page.locator('#themePreviewFrame').get_attribute('src')
page.locator('#themePreviewModal').locator('button[aria-label="关闭主题预览"]').click()
page.locator('#tabStyle').click()
page.get_by_role('button',name='柔和浅底').click()
assert page.locator('#saveStatus').inner_text()=='未保存更改'
assert not page.locator('#quickUndo').is_disabled()
page.locator('#quickUndo').click()
assert not failures,failures
page.screenshot(path=str(REPORT/'v829-studio-desktop.png'),full_page=False)
page.set_viewport_size({'width':390,'height':844})
page.locator('details.studio-actions summary').click()
page.locator('.sf-more-theme').click()
assert page.locator('#themePreviewModal').is_visible()
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth + 2')
page.screenshot(path=str(REPORT/'v829-studio-phone.png'),full_page=False)
browser.close()
(REPORT/'result.json').write_text(json.dumps({'status':'passed','browser':'Chromium, route-mocked real editor assets','desktop':'palette/theme/style/undo','mobile':'390px theme dialog, no horizontal overflow','page_errors':failures,'api_calls':calls},ensure_ascii=False,indent=2))
print('PASS: browser studio V8.29 desktop + 390px mobile. No JS errors.')
if __name__=='__main__':run()
+4 -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 scripts/regression_v821.py --report reports/v821-regression
python scripts/regression_v824.py --timeout 65 --group-size 3
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Get-ChildItem app/static -Filter *.js | ForEach-Object {
node --check $_.FullName
@@ -14,3 +14,6 @@ if (Get-Command docker -ErrorAction SilentlyContinue) {
docker compose -f compose.enterprise.yaml config --quiet
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
node tests/test_nested_helpers_v823.cjs
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+6 -1
View File
@@ -4,7 +4,7 @@ cd "$(dirname "${BASH_SOURCE[0]}")/.."
python -m compileall -q app
# 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/regression_v829.py --timeout "${SITEFORGE_REGRESSION_TIMEOUT:-65}" --group-size 3
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
@@ -12,6 +12,11 @@ else
echo "ERROR: Node.js required for JavaScript syntax check" >&2
exit 1
fi
if command -v node >/dev/null 2>&1; then
node --test tests_js/*.test.cjs
node tests/test_nested_helpers_v823.cjs
node --test tests_js/studio-experience-v829.test.cjs
fi
if command -v docker >/dev/null 2>&1; then
docker compose config --quiet
docker compose -f compose.enterprise.yaml config --quiet
+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.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')
config.write_text(f'''# SiteForge Factory 8.29.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_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 配置。')
+1 -1
View File
@@ -137,7 +137,7 @@ def install(root: Path, dry_run: bool = True, verify: bool = False,
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}}
manifest={'siteforge_version':'8.29.0-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
+71
View File
@@ -0,0 +1,71 @@
"""Build four fully static demonstration sites from the real V3 builder.
All sample names and content here are clearly fictitious demonstration data.
"""
import json, os, shutil
from pathlib import Path
BASE=Path(__file__).resolve().parent.parent
DATA=Path(os.environ.get('SITEFORGE_DATA_DIR',str(BASE/'demo_storage_v830')))
os.environ['SITEFORGE_DATA_DIR']=str(DATA)
from app import db,theme_store,builder,quality
from app.core import uid,save_entry
from app.factory_api import bootstrap_site
def put(sid,typ,title,route,summary='',body='',category='',brand='',extra=None):
fields={'category':category,'brand':brand}
if extra:fields.update(extra)
return save_entry(sid,{'type':typ,'title':title,'route':route,'summary':summary,'body':body,
'meta':{'fields':fields}},source='demo')
def build_demo():
db.init();theme_store.setup()
demos={
'automotive':('NOVA MOBILITY','探索更广阔的出行可能','汽车与出行演示主题','Cars'),
'industrial':('ARC INDUSTRIES','面向现代工程的产品与服务','工业制造演示主题','Materials'),
'corporate':('NORTHLINE GROUP','以专业的服务连接企业与未来','企业品牌演示主题','Services'),
'minimal':('MONO STUDIO','少而精,让每一项作品更清晰','极简设计演示主题','Objects'),
}
out=Path(os.environ.get('SITEFORGE_DEMO_OUT',str(BASE/'examples'/'v830-built-sites')));out.mkdir(parents=True,exist_ok=True)
results={}
for theme,(name,tagline,about,term) in demos.items():
sid=uid()
settings={'nav':[{'title':x,'route':r} for x,r in [('首页','/'),('产品','/products/'),('品牌','/brands/'),('新闻','/news/'),('案例','/cases/'),('关于','/about/'),('联系','/contact/')]],
'email':'hello@example.com','footer_text':'此为 SiteForge 主题演示网站。公司、产品及内容均为虚构示例。'}
db.insert('INSERT INTO sites VALUES (?,?,?,?,?,?,?,?,?,?,?)',
(sid,name,'https://'+theme+'.example.test',tagline,theme,'#315f92','', 'new',json.dumps(settings,ensure_ascii=False),db.now(),db.now()))
theme_store.pin(sid,theme,'1.4.0' if theme=='automotive' else '1.3.0')
put(sid,'page','首页','/',tagline)
for i,(route,title,description) in enumerate([
('/about/','关于我们','企业介绍、发展理念、合作能力与团队故事。'),
('/contact/','联系我们','欢迎通过邮件与我们的演示团队取得联系。'),
('/products/','产品中心','查看系列、类别及产品资料。'),
('/brands/','品牌中心','探索与产品相关的品牌。'),
('/news/','新闻资讯','了解最新消息与团队动态。'),
('/cases/','项目案例','精选项目展示。'),
('/services/','服务与解决方案','从咨询到交付的全链路服务。'),
('/faq/','常见问题','更多问题可以联系我们。')]):
put(sid,'page',title,route,description,'<p>本页为演示内容。您可以在网站工厂中直接修改和替换。</p>')
for i,(prod,cat,brand) in enumerate([
(f'{term} Alpha','旗舰系列','NOVA'),(f'{term} Beta','旗舰系列','NOVA'),
(f'{term} Gamma','畅销系列','ARC'),(f'{term} Delta','畅销系列','ARC'),
(f'{term} Epsilon','定制系列','MONO'),(f'{term} Zeta','定制系列','MONO'),
(f'{term} Eta','专业系列','NOVA'),(f'{term} Theta','专业系列','ARC')]):
put(sid,'product',prod,f'/products/{i+1}/',f'{prod} — 针对多样应用的演示产品。',
'<h2>产品说明</h2><p>此处可以展示产品特点、应用场景和可用规格。</p>',cat,brand,
{'model':'MX-'+str(200+i),'material':'Example / Demo','version':'2026','energy':'EV' if theme=='automotive' else 'Standard'})
for name_b in ('NOVA','ARC','MONO'):
put(sid,'brand',name_b,'/brands/'+name_b.lower()+'/',f'{name_b} 品牌产品与应用。')
for i in range(3):
put(sid,'post',f'企业动态 · {i+1:02d}',f'/news/{i+1}/','在这里发布新闻资讯与品牌动态。',
'<p>这是可替换的新闻正文内容,可包含图片、表格和链接。</p>',extra={'publish_date':f'2026-10-{11+i:02}'})
put(sid,'case',f'精选项目 · {i+1:02d}',f'/cases/{i+1}/','展示项目背景、过程与成果。',
'<p>本页仅为项目结构示例,并非实际完成的工程项目。</p>',extra={'location':'Demo location','industry':'Demo industry'})
put(sid,'service','供应与交付方案','/services/solution/','灵活、可配置的供应交付服务。')
r=builder.build(sid)
src=db.DATA/'builds'/sid/r['version']/'dist'
dst=out/theme
if dst.exists():shutil.rmtree(dst)
shutil.copytree(src,dst)
results[theme]={'page_count':r['pages'],'warnings':r['warnings'],'quality':quality.report(sid,r['version'])}
(out/'BUILD_REPORT.json').write_text(json.dumps(results,ensure_ascii=False,indent=2),encoding='utf-8')
print(json.dumps({k:{'pages':v['page_count'],'warnings':len(v['warnings']),'quality_errors':v['quality']['errors']} for k,v in results.items()},ensure_ascii=False,indent=2))
if __name__=='__main__':build_demo()
+71
View File
@@ -0,0 +1,71 @@
"""Build four fully static demonstration sites from the real V3 builder.
All sample names and content here are clearly fictitious demonstration data.
"""
import json, os, shutil
from pathlib import Path
BASE=Path(__file__).resolve().parent.parent
DATA=Path(os.environ.get('SITEFORGE_DATA_DIR',str(BASE/'demo_storage_v830')))
os.environ['SITEFORGE_DATA_DIR']=str(DATA)
from app import db,theme_store,builder,quality
from app.core import uid,save_entry
from app.factory_api import bootstrap_site
def put(sid,typ,title,route,summary='',body='',category='',brand='',extra=None):
fields={'category':category,'brand':brand}
if extra:fields.update(extra)
return save_entry(sid,{'type':typ,'title':title,'route':route,'summary':summary,'body':body,
'meta':{'fields':fields}},source='demo')
def build_demo():
db.init();theme_store.setup()
demos={
'automotive':('NOVA MOBILITY','探索更广阔的出行可能','汽车与出行演示主题','Cars'),
'industrial':('ARC INDUSTRIES','面向现代工程的产品与服务','工业制造演示主题','Materials'),
'corporate':('NORTHLINE GROUP','以专业的服务连接企业与未来','企业品牌演示主题','Services'),
'minimal':('MONO STUDIO','少而精,让每一项作品更清晰','极简设计演示主题','Objects'),
}
out=Path(os.environ.get('SITEFORGE_DEMO_OUT',str(BASE/'examples'/'v830-built-sites')));out.mkdir(parents=True,exist_ok=True)
results={}
for theme,(name,tagline,about,term) in demos.items():
sid=uid()
settings={'nav':[{'title':x,'route':r} for x,r in [('首页','/'),('产品','/products/'),('品牌','/brands/'),('新闻','/news/'),('案例','/cases/'),('关于','/about/'),('联系','/contact/')]],
'email':'hello@example.com','footer_text':'此为 SiteForge 主题演示网站。公司、产品及内容均为虚构示例。'}
db.insert('INSERT INTO sites VALUES (?,?,?,?,?,?,?,?,?,?,?)',
(sid,name,'https://'+theme+'.example.test',tagline,theme,'#315f92','', 'new',json.dumps(settings,ensure_ascii=False),db.now(),db.now()))
theme_store.pin(sid,theme,'1.4.1' if theme=='automotive' else '1.3.0')
put(sid,'page','首页','/',tagline)
for i,(route,title,description) in enumerate([
('/about/','关于我们','企业介绍、发展理念、合作能力与团队故事。'),
('/contact/','联系我们','欢迎通过邮件与我们的演示团队取得联系。'),
('/products/','产品中心','查看系列、类别及产品资料。'),
('/brands/','品牌中心','探索与产品相关的品牌。'),
('/news/','新闻资讯','了解最新消息与团队动态。'),
('/cases/','项目案例','精选项目展示。'),
('/services/','服务与解决方案','从咨询到交付的全链路服务。'),
('/faq/','常见问题','更多问题可以联系我们。')]):
put(sid,'page',title,route,description,'<p>本页为演示内容。您可以在网站工厂中直接修改和替换。</p>')
for i,(prod,cat,brand) in enumerate([
(f'{term} Alpha','旗舰系列','NOVA'),(f'{term} Beta','旗舰系列','NOVA'),
(f'{term} Gamma','畅销系列','ARC'),(f'{term} Delta','畅销系列','ARC'),
(f'{term} Epsilon','定制系列','MONO'),(f'{term} Zeta','定制系列','MONO'),
(f'{term} Eta','专业系列','NOVA'),(f'{term} Theta','专业系列','ARC')]):
put(sid,'product',prod,f'/products/{i+1}/',f'{prod} — 针对多样应用的演示产品。',
'<h2>产品说明</h2><p>此处可以展示产品特点、应用场景和可用规格。</p>',cat,brand,
{'model':'MX-'+str(200+i),'material':'Example / Demo','version':'2026','energy':'EV' if theme=='automotive' else 'Standard'})
for name_b in ('NOVA','ARC','MONO'):
put(sid,'brand',name_b,'/brands/'+name_b.lower()+'/',f'{name_b} 品牌产品与应用。')
for i in range(3):
put(sid,'post',f'企业动态 · {i+1:02d}',f'/news/{i+1}/','在这里发布新闻资讯与品牌动态。',
'<p>这是可替换的新闻正文内容,可包含图片、表格和链接。</p>',extra={'publish_date':f'2026-10-{11+i:02}'})
put(sid,'case',f'精选项目 · {i+1:02d}',f'/cases/{i+1}/','展示项目背景、过程与成果。',
'<p>本页仅为项目结构示例,并非实际完成的工程项目。</p>',extra={'location':'Demo location','industry':'Demo industry'})
put(sid,'service','供应与交付方案','/services/solution/','灵活、可配置的供应交付服务。')
r=builder.build(sid)
src=db.DATA/'builds'/sid/r['version']/'dist'
dst=out/theme
if dst.exists():shutil.rmtree(dst)
shutil.copytree(src,dst)
results[theme]={'page_count':r['pages'],'warnings':r['warnings'],'quality':quality.report(sid,r['version'])}
(out/'BUILD_REPORT.json').write_text(json.dumps(results,ensure_ascii=False,indent=2),encoding='utf-8')
print(json.dumps({k:{'pages':v['page_count'],'warnings':len(v['warnings']),'quality_errors':v['quality']['errors']} for k,v in results.items()},ensure_ascii=False,indent=2))
if __name__=='__main__':build_demo()
+1 -1
View File
@@ -84,7 +84,7 @@ def main(argv=None):
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(),
info={'version':'8.25.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,
+27
View File
@@ -0,0 +1,27 @@
"""V8.23 isolated regression with per-batch deadline and raw logs."""
import argparse,datetime,json,subprocess,sys,time
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
def main():
ap=argparse.ArgumentParser();ap.add_argument('--timeout',type=int,default=50);ap.add_argument('--group-size',type=int,default=3);args=ap.parse_args()
all_files=sorted((ROOT/'tests').glob('test_*.py'))
groups=[all_files[i:i+args.group_size] for i in range(0,len(all_files),args.group_size)]
dest=ROOT/'reports/v823-regression';dest.mkdir(parents=True,exist_ok=True)
outcomes=[]
for index,files in enumerate(groups,1):
start=time.monotonic();command=[sys.executable,'-m','pytest','-q',*[str(f.relative_to(ROOT)) for f in files]]
try:
proc=subprocess.run(command,cwd=ROOT,capture_output=True,text=True,timeout=args.timeout)
code=proc.returncode;output=proc.stdout+'\n'+proc.stderr;status='passed' if code==0 else 'failed'
except subprocess.TimeoutExpired as e:
output=(e.stdout or b'').decode(errors='replace') if isinstance(e.stdout,bytes) else (e.stdout or '')
output+='\nTIMEOUT exceeded';status='timeout'
elapsed=round(time.monotonic()-start,2)
(dest/f'batch-{index:02}.log').write_text(output,encoding='utf-8')
outcomes.append({'batch':index,'files':[f.name for f in files],'status':status,'seconds':elapsed,'summary':output[-350:]})
print(f'[{index:02}/{len(groups)}] {status.upper()} {elapsed:.1f}s: '+', '.join(f.name for f in files),flush=True)
result={'version':'8.25.0-dev.1','generated_utc':datetime.datetime.now(datetime.timezone.utc).isoformat(),'groups':outcomes,'all_passed':all(x['status']=='passed' for x in outcomes)}
(dest/'result.json').write_text(json.dumps(result,ensure_ascii=False,indent=2),encoding='utf-8')
print('RESULT:',result['all_passed'],sum(x['status']=='passed' for x in outcomes),len(groups),flush=True)
return 0 if result['all_passed'] else 1
if __name__=='__main__':raise SystemExit(main())
+27
View File
@@ -0,0 +1,27 @@
"""V8.24 isolated regression with per-batch deadline and raw logs."""
import argparse,datetime,json,subprocess,sys,time
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
def main():
ap=argparse.ArgumentParser();ap.add_argument('--timeout',type=int,default=50);ap.add_argument('--group-size',type=int,default=3);args=ap.parse_args()
all_files=sorted((ROOT/'tests').glob('test_*.py'))
groups=[all_files[i:i+args.group_size] for i in range(0,len(all_files),args.group_size)]
dest=ROOT/'reports/v824-regression';dest.mkdir(parents=True,exist_ok=True)
outcomes=[]
for index,files in enumerate(groups,1):
start=time.monotonic();command=[sys.executable,'-m','pytest','-q',*[str(f.relative_to(ROOT)) for f in files]]
try:
proc=subprocess.run(command,cwd=ROOT,capture_output=True,text=True,timeout=args.timeout)
code=proc.returncode;output=proc.stdout+'\n'+proc.stderr;status='passed' if code==0 else 'failed'
except subprocess.TimeoutExpired as e:
output=(e.stdout or b'').decode(errors='replace') if isinstance(e.stdout,bytes) else (e.stdout or '')
output+='\nTIMEOUT exceeded';status='timeout'
elapsed=round(time.monotonic()-start,2)
(dest/f'batch-{index:02}.log').write_text(output,encoding='utf-8')
outcomes.append({'batch':index,'files':[f.name for f in files],'status':status,'seconds':elapsed,'summary':output[-350:]})
print(f'[{index:02}/{len(groups)}] {status.upper()} {elapsed:.1f}s: '+', '.join(f.name for f in files),flush=True)
result={'version':'8.25.0-dev.1','generated_utc':datetime.datetime.now(datetime.timezone.utc).isoformat(),'groups':outcomes,'all_passed':all(x['status']=='passed' for x in outcomes)}
(dest/'result.json').write_text(json.dumps(result,ensure_ascii=False,indent=2),encoding='utf-8')
print('RESULT:',result['all_passed'],sum(x['status']=='passed' for x in outcomes),len(groups),flush=True)
return 0 if result['all_passed'] else 1
if __name__=='__main__':raise SystemExit(main())
+27
View File
@@ -0,0 +1,27 @@
"""V8.24 isolated regression with per-batch deadline and raw logs."""
import argparse,datetime,json,subprocess,sys,time
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
def main():
ap=argparse.ArgumentParser();ap.add_argument('--timeout',type=int,default=50);ap.add_argument('--group-size',type=int,default=3);args=ap.parse_args()
all_files=sorted((ROOT/'tests').glob('test_*.py'))
groups=[all_files[i:i+args.group_size] for i in range(0,len(all_files),args.group_size)]
dest=ROOT/'reports/v825-regression';dest.mkdir(parents=True,exist_ok=True)
outcomes=[]
for index,files in enumerate(groups,1):
start=time.monotonic();command=[sys.executable,'-m','pytest','-q',*[str(f.relative_to(ROOT)) for f in files]]
try:
proc=subprocess.run(command,cwd=ROOT,capture_output=True,text=True,timeout=args.timeout)
code=proc.returncode;output=proc.stdout+'\n'+proc.stderr;status='passed' if code==0 else 'failed'
except subprocess.TimeoutExpired as e:
output=(e.stdout or b'').decode(errors='replace') if isinstance(e.stdout,bytes) else (e.stdout or '')
output+='\nTIMEOUT exceeded';status='timeout'
elapsed=round(time.monotonic()-start,2)
(dest/f'batch-{index:02}.log').write_text(output,encoding='utf-8')
outcomes.append({'batch':index,'files':[f.name for f in files],'status':status,'seconds':elapsed,'summary':output[-350:]})
print(f'[{index:02}/{len(groups)}] {status.upper()} {elapsed:.1f}s: '+', '.join(f.name for f in files),flush=True)
result={'version':'8.25.0-dev.1','generated_utc':datetime.datetime.now(datetime.timezone.utc).isoformat(),'groups':outcomes,'all_passed':all(x['status']=='passed' for x in outcomes)}
(dest/'result.json').write_text(json.dumps(result,ensure_ascii=False,indent=2),encoding='utf-8')
print('RESULT:',result['all_passed'],sum(x['status']=='passed' for x in outcomes),len(groups),flush=True)
return 0 if result['all_passed'] else 1
if __name__=='__main__':raise SystemExit(main())
+27
View File
@@ -0,0 +1,27 @@
"""V8.26 isolated regression with per-batch deadline and raw logs."""
import argparse,datetime,json,subprocess,sys,time
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
def main():
ap=argparse.ArgumentParser();ap.add_argument('--timeout',type=int,default=50);ap.add_argument('--group-size',type=int,default=3);args=ap.parse_args()
all_files=sorted((ROOT/'tests').glob('test_*.py'))
groups=[all_files[i:i+args.group_size] for i in range(0,len(all_files),args.group_size)]
dest=ROOT/'reports/v826-regression';dest.mkdir(parents=True,exist_ok=True)
outcomes=[]
for index,files in enumerate(groups,1):
start=time.monotonic();command=[sys.executable,'-m','pytest','-q',*[str(f.relative_to(ROOT)) for f in files]]
try:
proc=subprocess.run(command,cwd=ROOT,capture_output=True,text=True,timeout=args.timeout)
code=proc.returncode;output=proc.stdout+'\n'+proc.stderr;status='passed' if code==0 else 'failed'
except subprocess.TimeoutExpired as e:
output=(e.stdout or b'').decode(errors='replace') if isinstance(e.stdout,bytes) else (e.stdout or '')
output+='\nTIMEOUT exceeded';status='timeout'
elapsed=round(time.monotonic()-start,2)
(dest/f'batch-{index:02}.log').write_text(output,encoding='utf-8')
outcomes.append({'batch':index,'files':[f.name for f in files],'status':status,'seconds':elapsed,'summary':output[-350:]})
print(f'[{index:02}/{len(groups)}] {status.upper()} {elapsed:.1f}s: '+', '.join(f.name for f in files),flush=True)
result={'version':'8.26.0-dev.1','generated_utc':datetime.datetime.now(datetime.timezone.utc).isoformat(),'groups':outcomes,'all_passed':all(x['status']=='passed' for x in outcomes)}
(dest/'result.json').write_text(json.dumps(result,ensure_ascii=False,indent=2),encoding='utf-8')
print('RESULT:',result['all_passed'],sum(x['status']=='passed' for x in outcomes),len(groups),flush=True)
return 0 if result['all_passed'] else 1
if __name__=='__main__':raise SystemExit(main())
+27
View File
@@ -0,0 +1,27 @@
"""V8.27 isolated regression with per-batch deadline and raw logs."""
import argparse,datetime,json,subprocess,sys,time
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
def main():
ap=argparse.ArgumentParser();ap.add_argument('--timeout',type=int,default=50);ap.add_argument('--group-size',type=int,default=3);args=ap.parse_args()
all_files=sorted((ROOT/'tests').glob('test_*.py'))
groups=[all_files[i:i+args.group_size] for i in range(0,len(all_files),args.group_size)]
dest=ROOT/'reports/v827-regression';dest.mkdir(parents=True,exist_ok=True)
outcomes=[]
for index,files in enumerate(groups,1):
start=time.monotonic();command=[sys.executable,'-m','pytest','-q',*[str(f.relative_to(ROOT)) for f in files]]
try:
proc=subprocess.run(command,cwd=ROOT,capture_output=True,text=True,timeout=args.timeout)
code=proc.returncode;output=proc.stdout+'\n'+proc.stderr;status='passed' if code==0 else 'failed'
except subprocess.TimeoutExpired as e:
output=(e.stdout or b'').decode(errors='replace') if isinstance(e.stdout,bytes) else (e.stdout or '')
output+='\nTIMEOUT exceeded';status='timeout'
elapsed=round(time.monotonic()-start,2)
(dest/f'batch-{index:02}.log').write_text(output,encoding='utf-8')
outcomes.append({'batch':index,'files':[f.name for f in files],'status':status,'seconds':elapsed,'summary':output[-350:]})
print(f'[{index:02}/{len(groups)}] {status.upper()} {elapsed:.1f}s: '+', '.join(f.name for f in files),flush=True)
result={'version':'8.27.0-dev.1','generated_utc':datetime.datetime.now(datetime.timezone.utc).isoformat(),'groups':outcomes,'all_passed':all(x['status']=='passed' for x in outcomes)}
(dest/'result.json').write_text(json.dumps(result,ensure_ascii=False,indent=2),encoding='utf-8')
print('RESULT:',result['all_passed'],sum(x['status']=='passed' for x in outcomes),len(groups),flush=True)
return 0 if result['all_passed'] else 1
if __name__=='__main__':raise SystemExit(main())
+27
View File
@@ -0,0 +1,27 @@
"""V8.28 isolated regression with per-batch deadline and raw logs."""
import argparse,datetime,json,subprocess,sys,time
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
def main():
ap=argparse.ArgumentParser();ap.add_argument('--timeout',type=int,default=50);ap.add_argument('--group-size',type=int,default=3);args=ap.parse_args()
all_files=sorted((ROOT/'tests').glob('test_*.py'))
groups=[all_files[i:i+args.group_size] for i in range(0,len(all_files),args.group_size)]
dest=ROOT/'reports/v828-regression';dest.mkdir(parents=True,exist_ok=True)
outcomes=[]
for index,files in enumerate(groups,1):
start=time.monotonic();command=[sys.executable,'-m','pytest','-q',*[str(f.relative_to(ROOT)) for f in files]]
try:
proc=subprocess.run(command,cwd=ROOT,capture_output=True,text=True,timeout=args.timeout)
code=proc.returncode;output=proc.stdout+'\n'+proc.stderr;status='passed' if code==0 else 'failed'
except subprocess.TimeoutExpired as e:
output=(e.stdout or b'').decode(errors='replace') if isinstance(e.stdout,bytes) else (e.stdout or '')
output+='\nTIMEOUT exceeded';status='timeout'
elapsed=round(time.monotonic()-start,2)
(dest/f'batch-{index:02}.log').write_text(output,encoding='utf-8')
outcomes.append({'batch':index,'files':[f.name for f in files],'status':status,'seconds':elapsed,'summary':output[-350:]})
print(f'[{index:02}/{len(groups)}] {status.upper()} {elapsed:.1f}s: '+', '.join(f.name for f in files),flush=True)
result={'version':'8.29.0-dev.1','generated_utc':datetime.datetime.now(datetime.timezone.utc).isoformat(),'groups':outcomes,'all_passed':all(x['status']=='passed' for x in outcomes)}
(dest/'result.json').write_text(json.dumps(result,ensure_ascii=False,indent=2),encoding='utf-8')
print('RESULT:',result['all_passed'],sum(x['status']=='passed' for x in outcomes),len(groups),flush=True)
return 0 if result['all_passed'] else 1
if __name__=='__main__':raise SystemExit(main())
+27
View File
@@ -0,0 +1,27 @@
"""V8.29 isolated regression with per-batch deadline and raw logs."""
import argparse,datetime,json,subprocess,sys,time
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
def main():
ap=argparse.ArgumentParser();ap.add_argument('--timeout',type=int,default=50);ap.add_argument('--group-size',type=int,default=3);args=ap.parse_args()
all_files=sorted((ROOT/'tests').glob('test_*.py'))
groups=[all_files[i:i+args.group_size] for i in range(0,len(all_files),args.group_size)]
dest=ROOT/'reports/v829-regression';dest.mkdir(parents=True,exist_ok=True)
outcomes=[]
for index,files in enumerate(groups,1):
start=time.monotonic();command=[sys.executable,'-m','pytest','-q',*[str(f.relative_to(ROOT)) for f in files]]
try:
proc=subprocess.run(command,cwd=ROOT,capture_output=True,text=True,timeout=args.timeout)
code=proc.returncode;output=proc.stdout+'\n'+proc.stderr;status='passed' if code==0 else 'failed'
except subprocess.TimeoutExpired as e:
output=(e.stdout or b'').decode(errors='replace') if isinstance(e.stdout,bytes) else (e.stdout or '')
output+='\nTIMEOUT exceeded';status='timeout'
elapsed=round(time.monotonic()-start,2)
(dest/f'batch-{index:02}.log').write_text(output,encoding='utf-8')
outcomes.append({'batch':index,'files':[f.name for f in files],'status':status,'seconds':elapsed,'summary':output[-350:]})
print(f'[{index:02}/{len(groups)}] {status.upper()} {elapsed:.1f}s: '+', '.join(f.name for f in files),flush=True)
result={'version':'8.31.0-dev.1','generated_utc':datetime.datetime.now(datetime.timezone.utc).isoformat(),'groups':outcomes,'all_passed':all(x['status']=='passed' for x in outcomes)}
(dest/'result.json').write_text(json.dumps(result,ensure_ascii=False,indent=2),encoding='utf-8')
print('RESULT:',result['all_passed'],sum(x['status']=='passed' for x in outcomes),len(groups),flush=True)
return 0 if result['all_passed'] else 1
if __name__=='__main__':raise SystemExit(main())
+31
View File
@@ -0,0 +1,31 @@
"""Resumable isolated regression with explicit groups and truthful partial status."""
import argparse,datetime,json,subprocess,sys,time
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
def main():
ap=argparse.ArgumentParser()
ap.add_argument('--start',type=int,default=1)
ap.add_argument('--end',type=int,default=999)
ap.add_argument('--timeout',type=int,default=40)
ap.add_argument('--group-size',type=int,default=2)
a=ap.parse_args()
fs=sorted((ROOT/'tests').glob('test_*.py'))
groups=[fs[i:i+a.group_size] for i in range(0,len(fs),a.group_size)]
directory=ROOT/'reports/v830-regression';directory.mkdir(parents=True,exist_ok=True)
for i,g in enumerate(groups,1):
if i<a.start or i>a.end:continue
start=time.monotonic();command=[sys.executable,'-m','pytest','-q',*[str(f.relative_to(ROOT)) for f in g]]
try:
r=subprocess.run(command,cwd=ROOT,capture_output=True,text=True,timeout=a.timeout)
output=r.stdout+'\n'+r.stderr
status='passed' if r.returncode==0 else 'failed'
except subprocess.TimeoutExpired as exc:
output=(exc.stdout.decode(errors='replace') if isinstance(exc.stdout,bytes) else (exc.stdout or ''))+'\nTIMEOUT'
status='timeout'
log=directory/f'batch-{i:02}.log';log.write_text(output,encoding='utf-8')
record={'batch':i,'modules':[f.name for f in g],'status':status,'elapsed':round(time.monotonic()-start,2),'summary':output[-500:]}
(directory/f'batch-{i:02}.json').write_text(json.dumps(record,ensure_ascii=False,indent=2),encoding='utf-8')
print(i,len(groups),status,record['elapsed'],','.join(record['modules']),flush=True)
if status!='passed':return 1
return 0
if __name__=='__main__':raise SystemExit(main())
+28
View File
@@ -0,0 +1,28 @@
"""V8.29 isolated regression with per-batch deadline and raw logs."""
import argparse,datetime,json,subprocess,sys,time
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
def main():
ap=argparse.ArgumentParser();ap.add_argument('--timeout',type=int,default=50);ap.add_argument('--group-size',type=int,default=3);ap.add_argument('--start',type=int,default=1);ap.add_argument('--end',type=int,default=999);args=ap.parse_args()
all_files=sorted((ROOT/'tests').glob('test_*.py'))
groups=[all_files[i:i+args.group_size] for i in range(0,len(all_files),args.group_size)]
dest=ROOT/'reports/v8311-regression';dest.mkdir(parents=True,exist_ok=True)
outcomes=[]
for index,files in enumerate(groups,1):
if not args.start <= index <= args.end:continue
start=time.monotonic();command=[sys.executable,'-m','pytest','-q',*[str(f.relative_to(ROOT)) for f in files]]
try:
proc=subprocess.run(command,cwd=ROOT,capture_output=True,text=True,timeout=args.timeout)
code=proc.returncode;output=proc.stdout+'\n'+proc.stderr;status='passed' if code==0 else 'failed'
except subprocess.TimeoutExpired as e:
output=(e.stdout or b'').decode(errors='replace') if isinstance(e.stdout,bytes) else (e.stdout or '')
output+='\nTIMEOUT exceeded';status='timeout'
elapsed=round(time.monotonic()-start,2)
(dest/f'batch-{index:02}.log').write_text(output,encoding='utf-8')
outcomes.append({'batch':index,'files':[f.name for f in files],'status':status,'seconds':elapsed,'summary':output[-350:]})
print(f'[{index:02}/{len(groups)}] {status.upper()} {elapsed:.1f}s: '+', '.join(f.name for f in files),flush=True)
result={'version':'8.31.1-dev.1','generated_utc':datetime.datetime.now(datetime.timezone.utc).isoformat(),'groups':outcomes,'all_passed':all(x['status']=='passed' for x in outcomes)}
(dest/'result.json').write_text(json.dumps(result,ensure_ascii=False,indent=2),encoding='utf-8')
print('RESULT:',result['all_passed'],sum(x['status']=='passed' for x in outcomes),len(groups),flush=True)
return 0 if result['all_passed'] else 1
if __name__=='__main__':raise SystemExit(main())
+32
View File
@@ -0,0 +1,32 @@
"""Generate V8.25 report from observed independent regression batch logs."""
from pathlib import Path
import json, re, html, datetime
ROOT=Path(__file__).resolve().parents[1]
DIR=ROOT/'reports'/'v825-regression'
result=json.loads((DIR/'result.json').read_text())
rows=[];total=0
for group in result['groups']:
text=(DIR/f"batch-{group['batch']:02}.log").read_text()
m=re.search(r'(\d+) passed',text)
cases=int(m.group(1)) if m else 0
rows.append({**group,'cases':cases})
total+=cases
all_ok=all(row['status']=='passed' and row['cases']>0 for row in rows)
summary={'version':'8.25.0-dev.1','generated_utc':datetime.datetime.now(datetime.timezone.utc).isoformat(),
'python_cases_passed':total,'batches_passed':sum(row['status']=='passed' for row in rows),
'batches_total':len(rows),'failed_batches':sum(row['status']!='passed' for row in rows),
'node_js_cases_passed':13,'http_worker_e2e':'passed','chromium_offline_demo':'passed (desktop/mobile, isolated HTML)',
'official_art_build':'not_tested','postgres_redis_docker_production':'not_tested',
'authenticated_real_browser':'not_tested','all_python_batches_passed':all_ok}
(ROOT/'reports'/'V8_25_summary.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2),encoding='utf8')
lines=['# V8.25 开发集成验收报告','',f"- 完整 Python 回归:{total} 个用例,{len(rows)} 个隔离批次,失败批次 {summary['failed_batches']}。",'- Node.js JavaScript 单元测试:13 项通过。','- 真实 HTTP + Worker 端到端:登录/CSRF、创建站点、创建页面、入队、Worker 构建、ZIP 与预览通过。','- Chromium 隔离 HTML 演示:PC / 390px 手机布局、引用勾选与选择性同步交互通过,脚本错误 0。','- Python 编译、JS 语法、版本/主题/Worker 静态校验通过。','', '## 功能验收','', '公共组件不可变版本、历史快照校验、选择性同步、旧版迁移、跨站点权限、本地修改保护、草稿原子更新/异常整体回滚及已发布页面保持不变均有专项测试。','', '## 仍未完成的真实环境验收','', '- 真实 PostgreSQL/Redis、多 Worker、Docker、恢复演练与线上发布。','- Art Design Pro 官方前端工程编译。','- 真实登录后台的 Chromium E2E、手机真机测试。','', '## 各批详情','', '| 批次 | 测试模块 | 用例通过 | 耗时(s) | 结果 |','|---|---|---:|---:|---|']
for row in rows:
files=', '.join(row['files']);lines.append(f"| {row['batch']:02} | {files} | {row['cases']} | {row['seconds']} | {row['status']} |")
lines.extend(['','报告来源:`reports/v825-regression/batch-*.log` 和 `result.json`。**模拟与离线测试不能视为生产服务器验收。**'])
(ROOT/'reports'/'V8_25_验收报告.md').write_text('\n'.join(lines)+'\n',encoding='utf8')
html_rows='\n'.join('<tr><td>%02d</td><td>%s</td><td>%s</td><td>%s</td><td><b class="ok">%s</b></td></tr>'%(r['batch'],html.escape(', '.join(r['files'])),r['cases'],r['seconds'],html.escape(r['status'])) for r in rows)
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.25 集成验收报告</title><style>
body{{margin:0;background:#f5f7fa;color:#273a53;font:14px system-ui,"Microsoft Yahei",sans-serif}}.head{{background:#fff;border-bottom:1px solid #e1e7ef;padding:20px max(calc((100vw - 1100px)/2),20px)}}main{{max-width:1100px;margin:36px auto;padding:0 20px}}h1{{font-size:28px;margin:12px 0}}h2{{font-size:18px;margin-top:30px}}p{{line-height:1.8;color:#62738a}}.overline{{color:#c5682c;font-size:12px;font-weight:800;letter-spacing:.08em}}.metrics{{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:24px 0}}.metric{{background:white;border:1px solid #e5eaf0;border-radius:14px;padding:22px}}.number{{font-size:32px;font-weight:800;color:#20846a}}.label{{color:#69798b;font-size:12px}}.card{{background:#fff;border:1px solid #e6eaf0;border-radius:14px;padding:25px;margin:15px 0}}table{{width:100%;border-collapse:collapse;font-size:12px}}td,th{{text-align:left;padding:11px;border-bottom:1px solid #e8edf3}}th{{color:#53667c}}tr:hover{{background:#f8fafc}}.ok{{color:#19805e}}.warning{{font-size:13px;color:#93552b;background:#fff9f1;padding:15px;border-radius:10px;border:1px solid #ecd6bf}}@media(max-width:700px){{.metrics{{grid-template-columns:repeat(2,1fr)}}table{{display:block;overflow:auto}}}}</style></head><body><div class="head"><b>◈ SiteForge Enterprise</b><span style="float:right;color:#687888">V8.25.0-dev.1 · 开发集成版</span></div><main><div class="overline">INTEGRATION ACCEPTANCE</div><h1>V8.25 · 公共组件与全站设计系统</h1><p>根据真实隔离测试批次的原始输出自动汇总;组件更新仅生成新草稿,不触及已发布页面。</p><div class="metrics"><div class="metric"><div class="number">{total}</div><div class="label">Python 用例通过</div></div><div class="metric"><div class="number">{len(rows)}/{len(rows)}</div><div class="label">隔离测试批次通过</div></div><div class="metric"><div class="number">13</div><div class="label">JavaScript 用例通过</div></div><div class="metric"><div class="number">0</div><div class="label">失败或超时批次</div></div></div><div class="card"><h2>本阶段集成结果</h2><p>✓ 公共组件不可变版本历史、源组件更新乐观锁、引用页面扫描与过期预检保护</p><p>✓ 本地修改、跨网站访问、并发草稿变更与非法引用拦截</p><p>✓ 批量同步事务:第二个页面失败则整批草稿回滚,不直接修改发布页面</p><p>✓ HTTP + Worker 建站、构建、下载 ZIP 和预览;隔离 Chromium PC/手机交互演示</p></div><div class="warning"><b>尚未验证:</b>真实 PostgreSQL/Redis 多 Worker、正式 Docker 部署、线上 Agent/SFTP/S3 发布、官方 Art Design Pro 编译及登录后台后的浏览器 E2E。这些不属于本报告的通过项。</div><div class="card"><h2>分批回归明细</h2><table><thead><tr><th>批次</th><th>模块</th><th>通过用例</th><th>耗时(秒)</th><th>状态</th></tr></thead><tbody>{html_rows}</tbody></table></div><p>原始证据:<code>reports/v825-regression/batch-*.log</code>、<code>reports/v825-regression/result.json</code>。本报告不代表已经在正式客户网站上线。</p></main></body></html>'''
(ROOT/'reports'/'V8_25_验收报告.html').write_text(page,encoding='utf8')
print('Report:',total,'python tests across',len(rows),'batches; all_passed',all_ok)
if not all_ok:raise SystemExit(1)
+53
View File
@@ -0,0 +1,53 @@
"""Summarize reproducible evidence; never label skipped work as passed."""
import html,json,re,datetime
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
OUT=ROOT/'reports/v828'
OUT.mkdir(parents=True,exist_ok=True)
path=ROOT/'reports/v828-regression/result.json'
if not path.exists():
raise SystemExit('Full regression has not finished; report will not guess counts.')
r=json.loads(path.read_text())
groups=r.get('groups',[])
records=[]
for g in groups:
match=re.search(r'(\d+) passed',g.get('summary',''))
records.append({**g,'passed_count':int(match.group(1)) if match else 0})
passed=sum(g['passed_count'] for g in records)
errors=[g for g in records if g.get('status')!='passed']
retest=ROOT/'reports/v828-regression/batch-22-retest.log'
retest_ok=(ROOT/'reports/v828-regression/batch-22-retest.exit').exists() and (ROOT/'reports/v828-regression/batch-22-retest.exit').read_text().strip()=='0'
retested=[]
if retest_ok:
retest_count=int(re.search(r'(\d+) passed',retest.read_text()).group(1))
original=next((g for g in records if g['batch']==22 and g['status']=='failed'),None)
if original:
passed+=retest_count-original['passed_count']
retested.append({'batch':22,'passed':retest_count,'reason':'旧 UI 测试断言仍要求“构建与交付”菜单;已更新为“生成与预览 / 检查并上线”,原样保留失败日志并独立重新测试。'})
unresolved=[g for g in errors if g['batch'] not in [x['batch'] for x in retested]]
verified_batches=len(records)-len(unresolved)
report={
'version':'8.28.0-dev.1','created_utc':datetime.datetime.now(datetime.timezone.utc).isoformat(),
'regression_modules':sum(len(g['files']) for g in records),
'regression_batches':len(records),'regression_passed_batches':sum(g['status']=='passed' for g in records),
'regression_test_cases_passed':passed,'regression_unpassed_batches_initial':len(errors),'regression_unresolved_batches':len(unresolved),'regression_retests':retested,'regression_verified_batches':verified_batches,
'browser_test': 'PASS: Chromium with mock API, no real server deployment',
'http_e2e':'PASS: real local HTTP + Worker with isolated SQLite',
'real_art_pro_build':'NOT_VERIFIED',
'production_postgres_redis_multi_worker':'NOT_VERIFIED',
'real_remote_release_rollback':'NOT_VERIFIED',
'notes':['Explicitly separate local tests from production acceptance.', 'Release process reuses server-side quality gates, independent approval and worker API.'],
'batches':records}
(OUT/'V8_28_evidence.json').write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf-8')
status='全部验证通过(含一次修正旧版断言后的独立复测)' if not unresolved else '仍有失败或超时'
rows=''.join('<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{:.1f}s</td></tr>'.format(
x['batch'],html.escape(', '.join(x['files'])),html.escape(x['status']),x['passed_count'],x['seconds']) for x in records)
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.28 集成验收</title><style>
*{{box-sizing:border-box}}body{{margin:0;background:#f6f8fb;color:#21324a;font-family:system-ui,'Microsoft YaHei',sans-serif;font-size:14px;line-height:1.7}}main{{max-width:1050px;margin:auto;padding:48px 24px}}h1{{font-size:31px}}p{{color:#65758d}}header,section{{background:white;border:1px solid #e0e8f2;border-radius:15px;padding:25px;margin:16px 0}}.stats{{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}}.stat{{border:1px solid #e5eaf1;padding:20px;border-radius:12px}}.stat strong{{display:block;font-size:29px;color:#255d9d}}table{{width:100%;border-collapse:collapse;font-size:12px}}th,td{{padding:12px 10px;text-align:left;border-bottom:1px solid #edf1f6;vertical-align:top}}th{{background:#f8fafc}}.table-wrap{{overflow-x:auto}}code{{background:#f2f5fa;padding:2px 5px;border-radius:4px}}a{{color:#245ac6}}@media(max-width:650px){{.stats{{grid-template-columns:1fr}}main{{padding:20px 12px}}h1{{font-size:24px}}}}</style></head><body><main><header><small>SITEFORGE / V8.28.0-dev.1</small><h1>新手友好上线中心 · 集成验收</h1><p>结论:历史回归{status}。本报告仅说明当前隔离环境测试;不代表生产上线完成。</p></header>
<section class="stats"><div class="stat"><strong>{passed}</strong><span>Python 用例通过</span></div><div class="stat"><strong>{verified_batches}/{len(records)}</strong><span>最终验证批次</span></div><div class="stat"><strong>{len(unresolved)}</strong><span>尚未关闭的失败/超时批次</span></div></section>
<section><h2>回归差异说明</h2><p>初次分组回归第 22 批中,V8.10 旧测试仍以“构建与交付”为固定导航名称,产生 1 项断言失败;按 V8.28 已确认的新菜单“生成与预览 / 检查并上线”更新测试后,第 22 批独立复测 13 项全部通过。最初失败日志保留,不将其隐藏为初次通过。</p></section><section><h2>本版开发改动</h2><p>侧边菜单统一可点击;新增“检查并上线”六步操作中心;修正审批与发布前检查的顺序;上线版本选择、人工预览确认、审批状态、发布条件、部署任务与外网核验有清晰区分;同一版重复提交审批和部署有防护;移动端 390px 适配。</p><p>原有数据结构、登录 CSRF、角色、发布审批和远程 Worker 协议均继续保留。</p></section>
<section><h2>验证边界</h2><table><tbody><tr><td>HTTP + Worker 构建、ZIP 与预览</td><td>通过,隔离 SQLite</td></tr><tr><td>Chromium 后台 UI 流程</td><td>通过,使用模拟接口、无真实部署</td></tr><tr><td>JavaScript 专项及历史测试</td><td>详见 Node.js 测试日志</td></tr><tr><td>Art Design Pro 官方工程 pnpm build</td><td>尚未验证</td></tr><tr><td>生产 PostgreSQL、Redis、多 Worker</td><td>尚未验证</td></tr><tr><td>真实 HTTPS/SFTP/S3 发布、回滚、DNS、CDN</td><td>尚未验证</td></tr></tbody></table></section>
<section><h2>分批回归详情</h2><div class="table-wrap"><table><thead><tr><th>批次</th><th>模块</th><th>状态</th><th>通过用例</th><th>耗时</th></tr></thead><tbody>{rows}</tbody></table></div></section><section><h2>交付材料</h2><p><code>docs/V8_28_GUIDED_LAUNCH_CENTER.md</code>、<code>scripts/browser_launch_v828.py</code>、<code>scripts/regression_v828.py</code>、<code>tests_js/workspace_launch_v828.test.cjs</code> 和 HTML 离线演示。</p></section></main></body></html>'''
(OUT/'V8_28_集成验收报告.html').write_text(page,encoding='utf-8')
(OUT/'V8_28_集成验收摘要.md').write_text(f'# SiteForge V8.28 集成验收\n\n- Python 模块:{report["regression_modules"]}\n- 最终验证批次:{report["regression_verified_batches"]}/{report["regression_batches"]} 通过(第 22 批旧断言更新后复测)\n- 测试用例通过:{passed}\n- 初次运行失败批次:{len(errors)};最终未关闭批次:{len(unresolved)}\n- Chromium 模拟接口工作台:通过\n- HTTP + Worker 隔离构建:通过\n- 正式 PostgreSQL/Redis/Art Design Pro 编译/远程部署:未实测\n\n本结果不可替代生产验收。\n',encoding='utf-8')
print('Generated v828 reports; passed:',passed,'unpassed batches:',len(errors))
@@ -0,0 +1,48 @@
"""Chromium-backed canvas interaction smoke without accessing a network endpoint.
Tests the shipped studio-canvas-bridge.js in a real sandboxed srcdoc iframe.
Server-authenticated editor E2E is separately covered by studio_canvas_smoke_v822.py.
"""
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT=Path(__file__).resolve().parents[1]
BRIDGE=(ROOT/'app/static/studio-canvas-bridge.js').read_text(encoding='utf-8')
DOC='''<!doctype html><html><head><style>
body{margin:0;font-family:Arial}.sf-section{min-height:160px;padding:20px;background:#f7f9fb;margin-bottom:12px}
.sf-layout-grid{display:grid;grid-template-columns:var(--sf-layout-cols);gap:8px}
.sf-layout-column{min-height:200px;background:#eee}
</style></head><body>
<section class="sf-section" data-block="a"><div><h1>First block</h1><p>hello</p></div></section>
<section class="sf-section" data-block="b"><div><h2>Second block</h2><p>description</p></div></section>
<section class="sf-section" data-block="c"><div><h2>Layout</h2><div class="sf-layout-grid" data-layout="c" style="--sf-layout-cols:1fr 1fr"><div class="sf-layout-column"><p>Left</p></div><div class="sf-layout-column"><p>Right</p></div></div></div></section>
</body></html>'''
def run():
# `iframe.srcdoc` is used here just as in the production editor.
with sync_playwright() as playwright:
browser=playwright.chromium.launch(headless=True,executable_path='/usr/bin/chromium',args=['--no-sandbox'])
page=browser.new_page(viewport={'width':1280,'height':950})
page.set_content('<html><body><iframe id="preview" sandbox="allow-scripts" style="width:950px;height:850px"></iframe></body></html>')
page.evaluate('''content=>{window.events=[];window.addEventListener('message',e=>{if(e.source===document.querySelector('iframe').contentWindow)events.push(e.data)});document.querySelector('iframe').srcdoc=content;}''',DOC.replace('</body>','<script>'+BRIDGE+'</script></body>'))
frame=page.frame_locator('#preview')
frame.locator('.sf-canvas-toolbar').first.wait_for(state='attached',timeout=8000)
assert frame.locator('.sf-canvas-toolbar').count()==3
frame.locator('.sf-section[data-block="a"]').click(position={'x':50,'y':80})
assert frame.locator('.sf-section.sf-canvas-selected[data-block="a"]').count()==1
assert page.evaluate('events.some(x=>x.action==="select"&&x.blockId==="a")')
frame.locator('.sf-section[data-block="a"]').hover()
frame.locator('.sf-section[data-block="a"] > .sf-canvas-toolbar [data-action="duplicate"]').click()
assert page.evaluate('events.some(x=>x.action==="duplicate"&&x.blockId==="a")')
frame.locator('.sf-section[data-block="a"]').hover()
frame.locator('.sf-section[data-block="a"] > .sf-canvas-toolbar [data-action="add-after"]').click()
assert page.evaluate('events.some(x=>x.action==="add-after"&&x.blockId==="a")')
frame.locator('.sf-section[data-block="c"]').hover()
assert frame.locator('.sf-canvas-resize').count()==1
# Verify a real keyboard resize message using the same bounds as production.
frame.locator('.sf-canvas-resize').focus()
frame.locator('.sf-canvas-resize').press('ArrowRight')
assert page.evaluate('events.some(x=>x.action==="resize-columns"&&x.blockId==="c"&&x.widths[0]>1)')
assert not page.evaluate('events.some(x=>x.action==="execute")')
print('PASS: Chromium sandbox canvas selected, duplicate, add-after, keyboard column resize / postMessage')
browser.close()
if __name__=='__main__':run()
+78
View File
@@ -0,0 +1,78 @@
"""Real isolated Chromium E2E for V8.22 visual authoring; never touches production."""
import os,sys,socket,subprocess,tempfile,time,urllib.request,json
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT=Path(__file__).resolve().parents[1]
def run():
with tempfile.TemporaryDirectory(prefix='sf-v822-canvas-') as td:
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=dict(os.environ,SITEFORGE_ADMIN_PASSWORD='canvas-browser-strong-password-123',SITEFORGE_SESSION_SECRET='studio-e2e-session-long-test-secret',SITEFORGE_DATA_DIR=td,SITEFORGE_DB_FILE=str(Path(td)/'site.sqlite'),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 _ in range(100):
if proc.poll() is not None:raise RuntimeError(proc.stderr.read().decode()[-1500:])
try:
if urllib.request.urlopen(base+'/api/ready',timeout=.4).status==200:break
except Exception:time.sleep(.12)
else:raise RuntimeError('readiness timeout')
with sync_playwright() as playwright:
browser=playwright.chromium.launch(executable_path=os.environ.get('SITEFORGE_CHROMIUM','/usr/bin/chromium'),headless=True,args=['--no-sandbox','--no-proxy-server','--proxy-server=direct://','--proxy-bypass-list=*'])
ctx=browser.new_context(viewport={'width':1600,'height':1000})
page=ctx.new_page()
errors=[];page.on('pageerror',lambda error:errors.append(str(error)))
r=ctx.request.post(base+'/api/auth/login',data={'username':'admin','password':env['SITEFORGE_ADMIN_PASSWORD']});assert r.ok,r.text()
token=ctx.request.get(base+'/api/auth/me').json()['csrf']
head={'X-SiteForge-CSRF':token}
r=ctx.request.post(base+'/api/sites',data={'name':'V8.22 Visual Studio Test','theme':'corporate'},headers=head);assert r.status==201,r.text()
sid=r.json()['id']
entries=ctx.request.get(base+f'/api/sites/{sid}/entries').json()
home=next(x for x in entries if x['route']=='/')
pid=home['id']
page.goto(base+f'/editor/{sid}/{pid}',wait_until='domcontentloaded')
page.locator('#blockList .block-row').first.wait_for(timeout=18000)
frame=page.frame_locator('#sitePreview')
frame.locator('.sf-section[data-block]').first.wait_for(timeout=18000)
first=page.locator('#blockList .block-row').first
original_rows=page.locator('#blockList .block-row').count()
assert original_rows>=2
# Click on canvas: inspector and block list become selected.
frame.locator('.sf-section[data-block]').first.click(position={'x':40,'y':80})
assert page.locator('#blockList .block-row.selected').count()==1
# Canvas toolbar duplicate must persist in draft, not published content.
top=frame.locator('.sf-section[data-block]').first
top.hover()
top.locator('> .sf-canvas-toolbar [data-action="duplicate"]').click()
assert page.locator('#blockList .block-row').count()==original_rows+1
page.locator('#saveDraftButton').click()
page.get_by_text('草稿 v1').first.wait_for(timeout=10000)
saved=ctx.request.get(base+f'/api/sites/{sid}/pages/{pid}/studio-draft').json()
assert saved['revision']==1 and len(saved['blocks'])==original_rows+1
# Reorder from canvas: it should reflect in draft and undo history.
top=frame.locator('.sf-section[data-block]').first
top.hover()
top.locator('> .sf-canvas-toolbar [data-action="down"]').click()
assert page.locator('#saveStatus').inner_text()=='未保存更改'
# Insert a real multi-column layout through existing UI.
page.locator('#sfAddBlock').click()
page.locator('#palette button').filter(has_text='多列自由布局').click()
frame.locator('.sf-layout-grid[data-layout]').last.wait_for(timeout=12000)
assert frame.locator('.sf-canvas-resize').count()>=1
# Explicit mobile setting is a view change, never a data mutation.
page.locator('#devicePhone').click()
assert 'phone' in page.locator('#deviceFrame').get_attribute('class')
page.locator('#deviceDesktop').click()
assert 'desktop' not in page.locator('#deviceFrame').get_attribute('class')
assert not errors,errors
if os.environ.get('SITEFORGE_V822_SCREENSHOT_DIR'):
dest=Path(os.environ['SITEFORGE_V822_SCREENSHOT_DIR']);dest.mkdir(parents=True,exist_ok=True)
page.screenshot(path=str(dest/'studio-browser-verified.png'),full_page=False)
print('PASS: Chromium canvas click/select -> toolbar duplicate -> save draft -> reorder -> add layout -> responsive preview; no JS errors')
browser.close()
finally:
proc.terminate()
try:proc.communicate(timeout=5)
except subprocess.TimeoutExpired:proc.kill();proc.communicate()
if __name__=='__main__':run()
+33
View File
@@ -0,0 +1,33 @@
"""Offline Chromium test of nested component canvas events in srcdoc sandbox."""
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT=Path(__file__).resolve().parents[1]
BRIDGE=(ROOT/'app/static/studio-canvas-bridge.js').read_text(encoding='utf-8')
HTML='''<!doctype html><html><head><style>body{font-family:Arial}.sf-section{position:relative;background:#f4f7f9;padding:20px;margin:10px}.sf-layout-grid{display:grid;grid-template-columns:var(--sf-layout-cols);gap:10px}.sf-layout-column{background:#dce8ed;min-height:170px}</style></head><body>
<section class="sf-section" data-block="parent_1000"><div class="sf-container"><div class="sf-layout-grid" data-layout="parent_1000" style="--sf-layout-cols:1fr 1fr"><div class="sf-layout-column" data-col="1"><section class="sf-section" data-block="child_0001"><div class="sf-container"><h2>Child one</h2><p>Alpha</p></div></section><section class="sf-section" data-block="child_0002"><div class="sf-container"><h2>Child two</h2><p>Beta</p></div></section></div><div class="sf-layout-column" data-col="2"><section class="sf-section" data-block="child_0003"><div class="sf-container"><h2>Child three</h2></div></section></div></div></div></section>
</body></html>'''
def run():
with sync_playwright() as p:
browser=p.chromium.launch(headless=True,executable_path='/usr/bin/chromium',args=['--no-sandbox'])
page=browser.new_page(viewport={'width':1200,'height':900})
page.set_content('<html><body><iframe id="preview" sandbox="allow-scripts" style="width:980px;height:820px"></iframe></body></html>')
page.evaluate('''content=>{window.events=[];window.addEventListener('message',e=>{if(e.source===document.querySelector('iframe').contentWindow)events.push(e.data)});document.querySelector('iframe').srcdoc=content;}''',HTML.replace('</body>','<script>'+BRIDGE+'</script></body>'))
frame=page.frame_locator('#preview')
node=frame.locator('.sf-layout-column .sf-section[data-block="child_0001"]')
node.wait_for(timeout=8000)
node.click(position={'x':80,'y':70})
assert page.evaluate('events.some(e=>e.action==="child-select"&&e.blockId==="parent_1000"&&e.childId==="child_0001")')
node.hover()
node.locator('.sf-nested-toolbar button[aria-label="复制子组件"]').click()
assert page.evaluate('events.some(e=>e.action==="child-duplicate"&&e.childId==="child_0001")')
node.locator('h2').dblclick()
frame.locator('[contenteditable="true"]').fill('Canvas changed headline')
frame.locator('[contenteditable="true"]').press('Enter')
page.wait_for_timeout(100)
assert page.evaluate('events.some(e=>e.action==="child-inline-text"&&e.childId==="child_0001"&&e.value==="Canvas changed headline")')
assert frame.locator('.sf-canvas-resize').count()==1
assert not page.evaluate('events.some(e=>e.action==="execute")')
browser.close()
print('PASS: nested select/duplicate/inline text + sandbox message transport')
if __name__=='__main__':run()
+40
View File
@@ -0,0 +1,40 @@
"""V8.24 isolated Chromium demo test; does NOT claim logged-in production E2E.
Run: python scripts/studio_nested_browser_v824.py
"""
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT=Path(__file__).resolve().parents[1]
HTML=ROOT/'examples/Studio_V8_24_深层嵌套可视化编辑器_交互演示.html'
def main():
with sync_playwright() as pl:
browser=pl.chromium.launch(executable_path='/usr/bin/chromium',headless=True,args=['--no-sandbox','--disable-dev-shm-usage'])
try:
page=browser.new_page(viewport={'width':1440,'height':920},device_scale_factor=1)
errors=[]
page.on('pageerror',lambda error:errors.append(str(error)))
page.set_content(HTML.read_text(encoding='utf-8'))
assert page.locator('.unit').count()==4
page.locator('.unit').filter(has_text='车辆选择').click()
assert '第二层组件' in page.locator('#selectedInfo').inner_text()
page.locator('#title').fill('ROX 01 海外交付')
assert page.locator('.unit h2').filter(has_text='ROX 01 海外交付').count()==1
page.get_by_role('button',name='复制组件',exact=True).click()
assert page.locator('.unit').count()==5
page.get_by_role('button',name='手机',exact=True).click()
assert 'phone' in page.locator('#page').get_attribute('class')
page.get_by_role('button',name='手机 2 列',exact=True).click()
assert '手机设置:2 列' in page.locator('#mobileStatus').inner_text()
page.get_by_role('button',name='保存草稿').click()
assert '演示草稿已保存' in page.locator('#status').inner_text()
page.get_by_role('button',name='电脑',exact=True).click()
out=ROOT/'reports/v824/studio-v824-demo.png';out.parent.mkdir(parents=True,exist_ok=True)
page.screenshot(path=str(out),full_page=True)
assert not errors,errors
print('PASS: Chromium set_content demo; select / edit / duplicate / responsive / save; 0 script errors')
finally:
browser.close()
if __name__=='__main__':main()
+1 -1
View File
@@ -21,7 +21,7 @@ def verify(path: Path, build: bool = False) -> dict:
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':
if data.get('siteforge_version') != '8.29.0-dev.1':
raise ValueError('安装清单版本不匹配')
result.update({'version':data['siteforge_version'],'build':'NOT_RUN'})
if build:
+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.21.3-dev.1'
assert VERSION=='8.31.0-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