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:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 配置。')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user