Compare commits

...
2 Commits
Author SHA1 Message Date
fam 41114eb0ad 静态站点改用就绪后切换的滚动发布,避免停机等待
verify / verify (push) Canceled after 0s
2026-09-21 12:16:31 +01:00
fam acfa02f96d 补齐浏览器回归和发布指纹,更新应用访问说明 2026-09-21 12:12:25 +01:00
12 changed files with 119 additions and 9 deletions
+2
View File
@@ -5,3 +5,5 @@ __pycache__/
!*.example !*.example
test-results/ test-results/
themes/ themes/
node_modules/
playwright-report/
+4 -1
View File
@@ -1,7 +1,7 @@
SHELL := /bin/bash SHELL := /bin/bash
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
PYTHON := .venv/bin/python PYTHON := .venv/bin/python
.PHONY: help install build test serve deploy .PHONY: help install build test test-browser serve deploy
help: help:
@echo 'make install | build | test | serve | deploy' @echo 'make install | build | test | serve | deploy'
@echo '文章: content/posts/*.md;应用: apps.json;样式: assets/site.css' @echo '文章: content/posts/*.md;应用: apps.json;样式: assets/site.css'
@@ -14,6 +14,9 @@ build: install
$(PYTHON) build.py $(PYTHON) build.py
test: install test: install
$(PYTHON) -m unittest discover -s tests -v $(PYTHON) -m unittest discover -s tests -v
test-browser: build
npm ci
npm run test:browser
serve: build serve: build
$(PYTHON) -m http.server 4176 --bind 127.0.0.1 --directory public $(PYTHON) -m http.server 4176 --bind 127.0.0.1 --directory public
deploy: test deploy: test
+1
View File
@@ -5,6 +5,7 @@
## 开发与发布 ## 开发与发布
源码:~/projects/fzc-src/blog(本机);生产:ssh fzc。服务器只接收构建产物。 源码:~/projects/fzc-src/blog(本机);生产:ssh fzc。服务器只接收构建产物。
构建要求 Python 3.10+ 和 uv;浏览器回归使用 Playwright,可通过 npm ci 安装。
- make install:安装固定版本构建依赖。 - make install:安装固定版本构建依赖。
- make test:文章完整性、站内链接、RSS、应用清单测试。 - make test:文章完整性、站内链接、RSS、应用清单测试。
+2 -2
View File
@@ -2,7 +2,7 @@
{"name":"Music Lab","subtitle":"随身的音乐练习室","description":"找熟悉的旋律,收藏曲谱,用节拍器与鼓机陪你练习。","url":"https://music.famzheng.me/","category":"音乐","icon":"music","featured":true}, {"name":"Music Lab","subtitle":"随身的音乐练习室","description":"找熟悉的旋律,收藏曲谱,用节拍器与鼓机陪你练习。","url":"https://music.famzheng.me/","category":"音乐","icon":"music","featured":true},
{"name":"Hedgehog","subtitle":"一桌人,一起玩","description":"从扫码入座开始,让小骰子成为聚会里的伙伴。","url":"https://hh.famzheng.me/","category":"互动","icon":"dice","featured":true,"access":"内测"}, {"name":"Hedgehog","subtitle":"一桌人,一起玩","description":"从扫码入座开始,让小骰子成为聚会里的伙伴。","url":"https://hh.famzheng.me/","category":"互动","icon":"dice","featured":true,"access":"内测"},
{"name":"在场","subtitle":"把冷场交给骰子","description":"划一下换个性格,拍一拍聊两句。一个有声音的互动实验。","url":"https://dice.famzheng.me/","category":"互动","icon":"spark","featured":true,"access":"内测"}, {"name":"在场","subtitle":"把冷场交给骰子","description":"划一下换个性格,拍一拍聊两句。一个有声音的互动实验。","url":"https://dice.famzheng.me/","category":"互动","icon":"spark","featured":true,"access":"内测"},
{"name":"Read","description":"把文档带进浏览器,留一段专心阅读的时间。","url":"https://read.famzheng.me/","category":"工具","icon":"book"}, {"name":"Read","description":"把文档带进浏览器,留一段专心阅读的时间。","url":"https://read.famzheng.me/","category":"工具","icon":"book","access":"私人"},
{"name":"Track","description":"每日轨迹与日记,把走过的路留作自己的记录。","url":"https://track.famzheng.me/","category":"生活","icon":"route","access":"私人"}, {"name":"Track","description":"每日轨迹与日记,把走过的路留作自己的记录。","url":"https://track.famzheng.me/","category":"生活","icon":"route","access":"私人"},
{"name":"Notes","description":"随手记下念头,整理值得留下的片段。","url":"https://notes.famzheng.me/","category":"工具","icon":"note"}, {"name":"Notes","description":"随手记下念头,整理值得留下的片段。","url":"https://notes.famzheng.me/","category":"工具","icon":"note"},
{"name":"Write","description":"一个浏览器里的写作空间。","url":"https://write.famzheng.me/","category":"工具","icon":"note"}, {"name":"Write","description":"一个浏览器里的写作空间。","url":"https://write.famzheng.me/","category":"工具","icon":"note"},
@@ -10,7 +10,7 @@
{"name":"Riff","description":"围绕音乐的小站,继续探索旋律与节奏。","url":"https://riff.famzheng.me/","category":"音乐","icon":"music"}, {"name":"Riff","description":"围绕音乐的小站,继续探索旋律与节奏。","url":"https://riff.famzheng.me/","category":"音乐","icon":"music"},
{"name":"Partiverse","description":"多人小游戏的实验场。","url":"https://party.famzheng.me/","category":"互动","icon":"dice"}, {"name":"Partiverse","description":"多人小游戏的实验场。","url":"https://party.famzheng.me/","category":"互动","icon":"dice"},
{"name":"Werewolf","description":"狼人杀,让推理与演技在桌边碰面。","url":"https://werewolf.famzheng.me/","category":"互动","icon":"spark"}, {"name":"Werewolf","description":"狼人杀,让推理与演技在桌边碰面。","url":"https://werewolf.famzheng.me/","category":"互动","icon":"spark"},
{"name":"Cube","description":"浏览器里的立方体实验。","url":"https://cube.famzheng.me/","category":"互动","icon":"cube"}, {"name":"Cube","description":"小应用平台,把常用的工具和玩具收在一起。","url":"https://cube.famzheng.me/","category":"工具","icon":"cube"},
{"name":"WebGL","description":"光、空间和图形,直接在浏览器里玩。","url":"https://webgl.famzheng.me/","category":"实验","icon":"cube"}, {"name":"WebGL","description":"光、空间和图形,直接在浏览器里玩。","url":"https://webgl.famzheng.me/","category":"实验","icon":"cube"},
{"name":"Code Space","description":"换个视角,走进一座由代码组成的三维空间。","url":"/demos/code-space-browser.html","category":"实验","icon":"cube"}, {"name":"Code Space","description":"换个视角,走进一座由代码组成的三维空间。","url":"/demos/code-space-browser.html","category":"实验","icon":"cube"},
{"name":"SimpleASM","description":"一个探索汇编与底层计算的小工具。","url":"https://asm.famzheng.me/","category":"实验","icon":"code"}, {"name":"SimpleASM","description":"一个探索汇编与底层计算的小工具。","url":"https://asm.famzheng.me/","category":"实验","icon":"code"},
+6 -1
View File
@@ -128,7 +128,12 @@ def build():
write('sitemap.xml', ET.tostring(site,encoding='unicode')) write('sitemap.xml', ET.tostring(site,encoding='unicode'))
write('robots.txt', f'User-agent: *\nAllow: /\nSitemap: {BASE}/sitemap.xml\n') write('robots.txt', f'User-agent: *\nAllow: /\nSitemap: {BASE}/sitemap.xml\n')
write('index.json', json.dumps([{'title':p['title'],'permalink':BASE+p['url'],'summary':p.get('summary',''),'content':p['raw']} for p in posts],ensure_ascii=False)) write('index.json', json.dumps([{'title':p['title'],'permalink':BASE+p['url'],'summary':p.get('summary',''),'content':p['raw']} for p in posts],ensure_ascii=False))
write('release.json', json.dumps({'site':'famzheng.me','posts':len(posts),'apps':len(apps),'content_sha256':hashlib.sha256(''.join(p['raw'] for p in posts).encode()).hexdigest()})) inputs = [ROOT/'build.py', ROOT/'apps.json', ROOT/'requirements.txt'] + sorted((ROOT/'assets').rglob('*')) + sorted((ROOT/'content').rglob('*')) + sorted((ROOT/'static').rglob('*'))
digest = hashlib.sha256()
for path in inputs:
if path.is_file():
digest.update(str(path.relative_to(ROOT)).encode()); digest.update(path.read_bytes())
write('release.json', json.dumps({'site':'famzheng.me','posts':len(posts),'apps':len(apps),'build_sha256':digest.hexdigest()}))
print(f'Built {len(posts)} posts and {len(apps)} applications → {OUT}') print(f'Built {len(posts)} posts and {len(apps)} applications → {OUT}')
if __name__ == '__main__': build() if __name__ == '__main__': build()
+1 -1
View File
@@ -10,4 +10,4 @@ showToc: false
**找到我:** **找到我:**
- [GitHub](https://github.com/famz) - [GitHub](https://github.com/famz)
- [Gitea](https://git.famzheng.com) - [Gitea](https://git.euphon.cloud/)
+1 -1
View File
@@ -30,7 +30,7 @@ def main():
ssh('mkdir','-p',target) ssh('mkdir','-p',target)
run('rsync','-az','--chmod=D755,F644',str(ROOT/'public')+'/',f'{HOST}:{target}/') run('rsync','-az','--chmod=D755,F644',str(ROOT/'public')+'/',f'{HOST}:{target}/')
def point(path): def point(path):
patch={'spec':{'template':{'spec':{'volumes':[{'name':'site','hostPath':{'path':path,'type':'Directory'}}]}}}} patch={'spec':{'strategy':{'type':'RollingUpdate','rollingUpdate':{'maxUnavailable':0,'maxSurge':1}},'template':{'spec':{'volumes':[{'name':'site','hostPath':{'path':path,'type':'Directory'}}]}}}}
kube('patch','deployment','hugo','--type=strategic','-p',json.dumps(patch)) kube('patch','deployment','hugo','--type=strategic','-p',json.dumps(patch))
kube('rollout','status','deployment/hugo','--timeout=120s') kube('rollout','status','deployment/hugo','--timeout=120s')
try: try:
+71
View File
@@ -0,0 +1,71 @@
{
"name": "blog",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"@playwright/test": "1.61.1"
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+1
View File
@@ -0,0 +1 @@
{"private":true,"type":"module","scripts":{"test:browser":"playwright test"},"devDependencies":{"@playwright/test":"1.61.1"}}
+2
View File
@@ -0,0 +1,2 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({testDir:'tests/browser',workers:1,webServer:{command:'make serve',url:'http://127.0.0.1:4176',reuseExistingServer:true},use:{baseURL:'http://127.0.0.1:4176'},projects:[{name:'desktop',use:{viewport:{width:1440,height:1000}}},{name:'mobile',use:{viewport:{width:390,height:844}}},{name:'small',use:{viewport:{width:320,height:700}}}]});
+24
View File
@@ -0,0 +1,24 @@
import {test,expect} from '@playwright/test';
test('应用导航、搜索、旧文章与手机布局', async ({page})=>{
const errors=[]; page.on('pageerror',e=>errors.push(e.message));
await page.goto('/');
await expect(page.locator('#app-grid .app-card')).toHaveCount(23);
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true);
await page.getByRole('button',{name:'音乐',exact:true}).click();
await expect(page.locator('#app-grid .app-card:visible')).toHaveCount(3);
await page.getByRole('button',{name:'全部',exact:true}).click();
await page.getByRole('searchbox').fill('hedgehog');
await expect(page.locator('#app-grid .app-card:visible')).toHaveCount(1);
await page.getByRole('searchbox').fill('nothing-to-match');
await expect(page.locator('#empty')).toBeVisible();
await page.goto('/posts/claude-code-goal/');
await expect(page.locator('h1')).toContainText('Claude Code');
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true);
expect(errors).toEqual([]);
});
test('没有 JavaScript 仍能访问所有入口',async({browser})=>{
const context=await browser.newContext({javaScriptEnabled:false});
const page=await context.newPage();await page.goto('http://127.0.0.1:4176/');
await expect(page.locator('#app-grid .app-card:visible')).toHaveCount(23);
await context.close();
});
+4 -3
View File
@@ -38,11 +38,12 @@ class SiteTest(unittest.TestCase):
self.assertNotIn('PaperMod', (build.OUT/'index.html').read_text()) self.assertNotIn('PaperMod', (build.OUT/'index.html').read_text())
self.assertNotIn('Hugo', (build.OUT/'index.html').read_text()) self.assertNotIn('Hugo', (build.OUT/'index.html').read_text())
def test_feeds(self): def test_feeds(self):
self.assertEqual(len(ET.parse(build.OUT/'index.xml').findall('./channel/item')), 11) count = sum(not build.load_post(p).get('draft') for p in (build.ROOT/'content/posts').glob('*.md'))
self.assertEqual(len(ET.parse(build.OUT/'sitemap.xml').getroot()), 14) self.assertEqual(len(ET.parse(build.OUT/'index.xml').findall('./channel/item')), count)
self.assertEqual(len(ET.parse(build.OUT/'sitemap.xml').getroot()), count + 3)
def test_app_registry(self): def test_app_registry(self):
apps = json.loads((build.ROOT/'apps.json').read_text()) apps = json.loads((build.ROOT/'apps.json').read_text())
self.assertEqual(len(apps), 23) self.assertGreaterEqual(len(apps), 23)
self.assertTrue(all(not urlsplit(a['url']).query and not urlsplit(a['url']).fragment for a in apps)) self.assertTrue(all(not urlsplit(a['url']).query and not urlsplit(a['url']).fragment for a in apps))
self.assertTrue(all(a['name'] and a['description'] and a['icon'] in build.ICONS for a in apps)) self.assertTrue(all(a['name'] and a['description'] and a['icon'] in build.ICONS for a in apps))