diff --git a/.github/scripts/release-guard.cjs b/.github/scripts/release-guard.cjs new file mode 100644 index 0000000..ec466fa --- /dev/null +++ b/.github/scripts/release-guard.cjs @@ -0,0 +1,142 @@ +'use strict'; +// 仅用于本次已授权的 v1.14.1;失败或部分发布必须人工核查,绝不覆盖资产。 +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const crypto = require('node:crypto'); +const { execFileSync } = require('node:child_process'); +const VERSION = '1.14.1'; +const TAG = `v${VERSION}`; +const ASSETS = ['infinitytime-theme.zip', 'infinitytime-plugin.zip']; +function insist(ok, message) { if (!ok) throw new Error(message); } +function guardRun(event, repository, mainSha, run, workflow) { + insist(event.repository?.full_name === repository && event.repository?.fork === false, '仓库不匹配或为 fork'); + for (const r of [event.workflow_run, run]) { + insist(r && r.repository?.full_name === repository && r.head_repository?.full_name === repository, 'CI 来自其他仓库'); + insist(r.event === 'push' && r.head_branch === 'main' && r.status === 'completed' && r.conclusion === 'success', 'CI 必须是 main push 成功'); + insist(/^[a-f0-9]{40}$/.test(r.head_sha) && r.head_sha === mainSha, 'CI SHA 已过时或不合法'); + insist(r.name === 'CI' && r.path === '.github/workflows/ci.yml', 'CI 工作流不匹配'); + } + insist(run.id === event.workflow_run.id && run.workflow_id === event.workflow_run.workflow_id, 'CI run 标识不一致'); + insist(workflow.id === run.workflow_id && workflow.path === '.github/workflows/ci.yml' && workflow.state === 'active', 'CI workflow 标识不匹配'); + return mainSha; +} +function guardVersions(theme, plugin, release) { + insist(theme.match(/@version\s+([\d.]+)/)?.[1] === VERSION, '主题版本不是 1.14.1'); + insist(plugin.match(/const VERSION = '([\d.]+)'/)?.[1] === VERSION, '插件版本不是 1.14.1'); + insist(JSON.parse(release).tag_name === VERSION, '版本清单不是 1.14.1'); +} +function guardExisting(tagSha, release, sha, digests) { + if (!tagSha && !release) return 'create'; + insist(tagSha === sha, '已有 tag SHA 冲突或 release 缺 tag'); + insist(release && release.tag_name === TAG && !release.draft && !release.prerelease, '部分发布或 release 状态冲突,需人工核查'); + insist(release.assets?.length === ASSETS.length, '发布资产数量不完整或冲突'); + for (const name of ASSETS) { + const matches = release.assets.filter(a => a.name === name); + insist(matches.length === 1 && matches[0].state === 'uploaded' && matches[0].size > 0, '发布资产缺失或未完成'); + insist(matches[0].digest === `sha256:${digests[name]}`, `发布资产摘要冲突:${name}`); + } + return 'complete'; +} +function buildArchives(temp, dist) { + const digests = {}; + for (const [kind, asset] of [['themes', ASSETS[0]], ['plugins', ASSETS[1]]]) { + const prefix = `usr/${kind}/InfinityTime/`; + const files = execFileSync('git', ['ls-files', '-z', '--', prefix], { encoding: 'utf8' }).split('\0').filter(Boolean).sort(); + const stage = path.join(temp, kind); fs.mkdirSync(stage); + const names = []; + for (const file of files) { + const relative = file.slice(prefix.length); + if (path.basename(file) === '.DS_Store' || (kind === 'plugins' && relative.startsWith('data/'))) continue; + insist(fs.lstatSync(file).isFile() && !/[\r\n]/.test(file), '发布包包含非普通文件或异常路径'); + const name = `InfinityTime/${relative}`; + const destination = path.join(stage, name); + fs.mkdirSync(path.dirname(destination), { recursive: true }); fs.copyFileSync(file, destination); + fs.chmodSync(destination, 0o644); fs.utimesSync(destination, 946684800, 946684800); names.push(name); + } + insist(names.length > 0, '发布包为空'); + execFileSync('zip', ['-X', '-q', path.join(dist, asset), '-@'], { cwd: stage, input: names.join('\n') + '\n', env: { ...process.env, TZ: 'UTC' } }); + execFileSync('unzip', ['-t', path.join(dist, asset)]); + digests[asset] = crypto.createHash('sha256').update(fs.readFileSync(path.join(dist, asset))).digest('hex'); + } + return digests; +} +async function main() { + insist(process.env.GITHUB_EVENT_NAME === 'workflow_run', '仅允许 workflow_run'); + const repo = process.env.GITHUB_REPOSITORY; + const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')); + const token = process.env.GH_TOKEN; + insist(repo === 'Thregren/InfinityTime' && token, '缺少令牌或目标仓库错误'); + const root = `https://api.github.com/repos/${repo}`; + // 公开仓库 Actions 元数据匿名读取;不增加 actions 权限,限流/拒绝即失败。 + async function api(endpoint, { method = 'GET', body, optional = false, binary = false } = {}) { + const url = endpoint.startsWith('https://') ? endpoint : `${root}${endpoint}`; + insist(url.startsWith(`${root}/`) || url.startsWith(`https://uploads.github.com/repos/${repo}/`), '非预期 API 目标'); + const response = await fetch(url, { signal: AbortSignal.timeout(30000), method, headers: { ...(endpoint.startsWith('/actions/') && method === 'GET' ? {} : { Authorization: `Bearer ${token}` }), Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', ...(body ? { 'Content-Type': binary ? 'application/zip' : 'application/json' } : {}) }, body: body ? (binary ? body : JSON.stringify(body)) : undefined }); + if (optional && response.status === 404) return null; + insist(response.ok, `GitHub API ${method} ${endpoint}: ${response.status}`); + return response.json(); + } + const verifyDownloads = async release => { + for (const name of ASSETS) { + const asset = release.assets.find(item => item.name === name); + const expected = `https://github.com/${repo}/releases/download/${TAG}/${name}`; + insist(asset.browser_download_url === expected, '下载地址不是目标发布资产'); + // 公共下载不携带令牌;下载后的真实字节必须匹配本次确定性构建。 + const response = await fetch(expected, { signal: AbortSignal.timeout(30000) }); + insist(response.ok, `发布资产无法下载:${name} ${response.status}`); + const digest = crypto.createHash('sha256').update(Buffer.from(await response.arrayBuffer())).digest('hex'); + insist(digest === digests[name], `发布资产下载摘要冲突:${name}`); + } + }; + const latestMain = async () => (await api('/git/ref/heads/main')).object.sha; + const sha = await latestMain(); + const run = await api(`/actions/runs/${event.workflow_run.id}`); + const workflow = await api(`/actions/workflows/${run.workflow_id}`); + guardRun(event, repo, sha, run, workflow); + insist(execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() === sha, 'checkout SHA 不匹配'); + guardVersions(fs.readFileSync('usr/themes/InfinityTime/index.php', 'utf8'), fs.readFileSync('usr/plugins/InfinityTime/Plugin.php', 'utf8'), fs.readFileSync('usr/themes/InfinityTime/releases.json', 'utf8')); + // 只打包 git 跟踪的普通文件;固定时间与顺序,支持幂等摘要验证。 + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'infinitytime-release-')); + const dist = path.join(temp, 'dist'); fs.mkdirSync(dist); + const digests = {}; + try { + Object.assign(digests, buildArchives(temp, dist)); + const getTagSha = async () => { + const ref = await api(`/git/ref/tags/${TAG}`, { optional: true }); + if (!ref) return null; + let object = ref.object; + for (let depth = 0; object.type === 'tag' && depth < 5; depth++) object = (await api(`/git/tags/${object.sha}`)).object; + insist(object.type === 'commit', 'tag 不是 commit'); return object.sha; + }; + let release = await api(`/releases/tags/${TAG}`, { optional: true }); + const tagSha = await getTagSha(); + if (guardExisting(tagSha, release, sha, digests) === 'complete') { + await verifyDownloads(release); + console.log(`已完整发布且 SHA/两包摘要一致:${release.html_url}`); return; + } + const changelog = fs.readFileSync('usr/themes/InfinityTime/update.md', 'utf8'); + const notes = changelog.split(`## ${VERSION}\n`)[1]?.split(/^## /m)[0]?.trim(); + insist(notes, '缺少本版本 changelog'); + // 所有打包与只读验证完成后,再验证 main,随后才执行不可覆盖的创建操作。 + insist(await latestMain() === sha, '创建 tag 前 main 已改变'); + await api('/git/refs', { method: 'POST', body: { ref: `refs/tags/${TAG}`, sha } }); + release = await api('/releases', { method: 'POST', body: { tag_name: TAG, target_commitish: sha, name: `InfinityTime ${TAG}`, body: notes, draft: true, prerelease: false } }); + for (const name of ASSETS) { + const upload = `${release.upload_url.split('{')[0]}?name=${encodeURIComponent(name)}`; + const asset = await api(upload, { method: 'POST', binary: true, body: fs.readFileSync(path.join(dist, name)) }); + insist(asset.name === name && asset.state === 'uploaded' && asset.digest === `sha256:${digests[name]}`, '上传资产摘要校验失败'); + } + insist(await latestMain() === sha, '发布前 main 已改变,保留 draft 待核查'); + insist(await getTagSha() === sha, '发布前 tag SHA 改变'); + release = await api(`/releases/${release.id}`); + guardExisting(sha, { ...release, draft: false }, sha, digests); + await api(`/releases/${release.id}`, { method: 'PATCH', body: { draft: false } }); + release = await api(`/releases/${release.id}`); + guardExisting(await getTagSha(), release, sha, digests); + await verifyDownloads(release); + console.log(`发布成功:${release.html_url};SHA=${sha};两份 ZIP SHA-256 已验证`); + } finally { fs.rmSync(temp, { recursive: true, force: true }); } +} +module.exports = { guardRun, guardVersions, guardExisting, buildArchives, VERSION, TAG, ASSETS }; +if (require.main === module) main().catch(error => { console.error(error.message); process.exitCode = 1; }); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7a7259..7207189 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,13 +11,17 @@ permissions: jobs: lint: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.2', '8.4'] steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: - php-version: '8.2' - extensions: pdo_sqlite + php-version: ${{ matrix.php }} + extensions: pdo_sqlite, gd, exif, dom - uses: actions/setup-node@v4 with: @@ -39,6 +43,14 @@ jobs: php tests/repository.php php tests/migration.php php tests/schema-sqlite.php + php tests/field-sync.php + php tests/image-delete.php + php tests/media-lifetime.php + php tests/media-gd.php + php tests/maintenance-state.php + for mode in rebuild-sync-failure rebuild-success rebuild-batch rebuild-restart rebuild-resume rebuild-start-reuse rebuild-no-original rebuild-missing-original rebuild-conversion-failure stale-poll finished-poll delete-non-plugin delete-rollback lease-conflict; do + php tests/maintenance-panel.php "$mode" + done php tests/admin-workflow.php php tests/admin-panel.php for mode in get-maintenance post-no-token post-wrong-token post-array-token post-token-unavailable post-non-album post-foreign-album post-contributor-published; do @@ -48,6 +60,8 @@ jobs: php tests/upload-fallback.php php tests/theme-output.php node tests/admin-upload.cjs + node tests/maintenance-client.cjs + node --test tests/release-guards.cjs node tests/gallery.cjs node tests/gallery-loading.cjs node tests/gallery-navigation.cjs @@ -68,7 +82,13 @@ jobs: browser: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 12 + strategy: + fail-fast: false + matrix: + engine: [chromium, firefox, webkit] + env: + PLAYWRIGHT_BROWSER: ${{ matrix.engine }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -77,7 +97,7 @@ jobs: - name: Install isolated browser test dependencies run: | npm install --no-save --package-lock=false playwright@1.62.1 - npx playwright install --with-deps chromium + npx playwright install --with-deps ${{ matrix.engine }} - name: 图库基础浏览器回归 run: node tests/gallery-browser.cjs - name: 图库加载与历史导航回归 @@ -86,3 +106,67 @@ jobs: - name: 后台上传浏览器回归 if: ${{ !cancelled() }} run: node tests/admin-upload.cjs --browser + + typecho-integration: + runs-on: ubuntu-latest + timeout-minutes: 12 + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.2', '8.4'] + typecho: + - version: '1.2.1' + ref: '206880ba714d80bfb0638aeacb49c58b8fb1b327' + - version: '1.3.0' + ref: 'e9f23304bf20c0c355ef1df9634d42148cbc53b5' + database: [sqlite, mysql, pgsql] + services: + mysql: + image: mysql:8.0 + env: + MYSQL_DATABASE: infinitytime_test + MYSQL_ROOT_PASSWORD: integration-test-only + ports: ['3306:3306'] + options: >- + --health-cmd="mysqladmin ping -h localhost -pintegration-test-only" + --health-interval=10s --health-timeout=5s --health-retries=10 + postgres: + image: postgres:17 + env: + POSTGRES_DB: infinitytime_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: integration-test-only + ports: ['5432:5432'] + options: >- + --health-cmd="pg_isready -U postgres -d infinitytime_test" + --health-interval=10s --health-timeout=5s --health-retries=10 + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: typecho/typecho + ref: ${{ matrix.typecho.ref }} + path: .typecho-test + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: pdo_sqlite, sqlite3, pdo_mysql, mysqli, pdo_pgsql, pgsql + coverage: none + - name: PHP 代码与集成测试语法 + run: find usr tests -name '*.php' -print0 | xargs -0 -n1 php -l + - name: 真实 Typecho 数据库集成 + env: + TYPECHO_ROOT: ${{ github.workspace }}/.typecho-test + IT_DB_HOST: 127.0.0.1 + IT_DB_NAME: infinitytime_test + IT_DB_USER: ${{ matrix.database == 'mysql' && 'root' || 'postgres' }} + IT_DB_PASSWORD: integration-test-only + run: | + case '${{ matrix.database }}' in + sqlite) adapters='Pdo_SQLite SQLite' ;; + mysql) adapters='Pdo_Mysql Mysqli' ;; + pgsql) adapters='Pdo_Pgsql Pgsql' ;; + esac + for adapter in $adapters; do + php tests/typecho-integration.php "$adapter" + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3d1b3d4..2360d68 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,12 +4,20 @@ on: push: tags: - 'v*' + workflow_run: + workflows: [CI] + types: [completed] + branches: [main] permissions: contents: write jobs: release: + if: github.event_name == 'push' + concurrency: + group: release-${{ github.ref_name }} + cancel-in-progress: false runs-on: ubuntu-latest steps: - name: Checkout @@ -55,3 +63,69 @@ jobs: dist/infinitytime-theme.zip dist/infinitytime-plugin.zip body_path: dist/RELEASE_NOTES.md + + # 一次性授权范围:只允许目标仓库 main 的完整 CI 发布 1.14.1。 + release-1-14-1: + if: >- + github.event_name == 'workflow_run' && + github.repository == 'Thregren/InfinityTime' && + github.event.repository.fork == false && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_repository.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: release-v1.14.1 + cancel-in-progress: false + steps: + - name: 在 checkout 前验证可信 main 和 CI 身份 + id: trusted + env: + GH_TOKEN: ${{ github.token }} + run: | + node <<'NODE' + const fs = require('node:fs'); + (async () => { + const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')); + const repo = process.env.GITHUB_REPOSITORY; + const assert = (ok, message) => { if (!ok) throw new Error(message); }; + assert(repo === 'Thregren/InfinityTime' && event.repository.full_name === repo && event.repository.fork === false, '目标仓库不匹配'); + const get = async path => { + const response = await fetch(`https://api.github.com/repos/${repo}${path}`, { signal: AbortSignal.timeout(30000), headers: { ...(path.startsWith('/actions/') ? {} : { Authorization: `Bearer ${process.env.GH_TOKEN}` }), Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } }); + assert(response.ok, `GitHub API ${response.status}`); + return response.json(); + }; + const main = await get('/git/ref/heads/main'); + const sha = main.object.sha; + assert(main.object.type === 'commit' && /^[a-f0-9]{40}$/.test(sha), 'main SHA 无效'); + const id = event.workflow_run.id; + assert(Number.isSafeInteger(id) && id > 0, 'CI run ID 无效'); + const run = await get(`/actions/runs/${id}`); + for (const item of [event.workflow_run, run]) { + assert(item.repository.full_name === repo && item.head_repository.full_name === repo, 'CI 仓库不匹配'); + assert(item.event === 'push' && item.head_branch === 'main' && item.status === 'completed' && item.conclusion === 'success', 'CI 不满足发布条件'); + assert(item.head_sha === sha, 'CI 不是当前 main'); + assert(item.name === 'CI' && item.path === '.github/workflows/ci.yml', 'CI path 不匹配'); + } + assert(run.id === id && run.workflow_id === event.workflow_run.workflow_id, 'CI run 不匹配'); + const workflow = await get(`/actions/workflows/${run.workflow_id}`); + assert(workflow.id === run.workflow_id && workflow.path === '.github/workflows/ci.yml' && workflow.state === 'active', 'CI workflow 不匹配'); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `sha=${sha}\n`); + })().catch(error => { console.error(error.message); process.exitCode = 1; }); + NODE + + - name: Checkout 已验证的精确 main SHA + uses: actions/checkout@v4 + with: + ref: ${{ steps.trusted.outputs.sha }} + persist-credentials: false + + - name: 离线发布 guard 回归 + run: node --test tests/release-guards.cjs + + - name: 验证、打包并发布唯一授权版本 + env: + GH_TOKEN: ${{ github.token }} + run: node .github/scripts/release-guard.cjs diff --git a/README.md b/README.md index c4c3814..7b71bf2 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ ## 功能特性 +### v1.14.1 性能与可靠性加固 + +维护任务身份与原子检查点、聚合字段事务回滚、Mysqli 主库一致性修复、低内存孤儿引用扫描,并扩充官方 Typecho、多 PHP 和多数据库集成验证。1.14.0 的相册工作流与浏览功能全部保留。 + ### v1.14.0 相册工作流与浏览增强 - **先存草稿,再明确发布**:创建后保持草稿,逐张上传并显示「待上传 / 上传中 / 转换中 / 完成 / 失败」。只有点击发布才会公开;贡献者可管理自己的草稿,公开发布与已发布相册修改需要编辑者或管理员权限。 @@ -74,13 +78,13 @@ - **联系方式 / 联系我**:可视化选图标、增删/启停(AJAX)。 - **WebP 转换设置**:按 **通用保留原图 / 缩略图 / 普通图 / 全景图** 分组;宽度、**质量滑块**;转换工具状态;「恢复默认最佳设置」(AJAX)。 - **维护**:重建缩略图/全图、清理孤儿文件、重建尺寸字段、**清理非插件文章**;进度实时刷新,刷新页面后可继续未完成任务,并带并发锁。 -- **安全加固**:写操作带 CSRF token(含 GET 维护任务)、上传单文件大小/像素前置校验、「关于」HTML 危险标签/事件/协议清洗。 +- **安全加固**:所有写操作与维护任务仅接受带 CSRF token 的 POST、上传单文件大小/像素前置校验、「关于」HTML 危险标签/事件/协议清洗。 ## 技术栈 ### 开发验证 -在仓库根目录运行(测试环境使用 PHP 8.2+、DOM 扩展和 Node.js 20+): +在仓库根目录运行(测试环境使用 PHP 7.4+、DOM / GD / EXIF / PDO SQLite 扩展和 Node.js 20+): ```sh php tests/test.php @@ -88,28 +92,50 @@ php tests/sanitizer-fallback.php php tests/repository.php php tests/migration.php php tests/schema-sqlite.php +php tests/field-sync.php +php tests/image-delete.php +php tests/media-lifetime.php +php tests/media-gd.php +php tests/maintenance-state.php +php tests/maintenance-panel.php php tests/admin-workflow.php php tests/admin-panel.php php tests/gallery-discovery.php php tests/upload-fallback.php php tests/theme-output.php node tests/admin-upload.cjs +node tests/maintenance-client.cjs node tests/gallery.cjs node tests/gallery-loading.cjs node tests/gallery-navigation.cjs ``` -浏览器回归(Playwright 1.62.1 + Chromium;页面与资源请求全部由测试拦截,不访问线上站点): +浏览器回归(Playwright 1.62.1 + Chromium / Firefox / WebKit;页面与资源请求全部由测试拦截,不访问线上站点): ```sh npm install --no-save --package-lock=false playwright@1.62.1 -npx playwright install chromium +npx playwright install chromium firefox webkit node tests/gallery-browser.cjs node tests/gallery-loading-browser.cjs node tests/admin-upload.cjs --browser +# 将 PLAYWRIGHT_BROWSER 设为 firefox 或 webkit 可运行同一组其他引擎回归 ``` -CI 同时检查 PHP / JS 语法、版本一致性和更新记录。仓库测试使用隔离临时文件及 DB/编码器替身;后台脚本测试模拟 DOM,覆盖预览、拖拽、移除和无 `DataTransfer` 时的提交顺序。这些检查不能替代真实 Typecho 后台及图片编码器的端到端验证。 +CI 同时检查 PHP / JS 语法、版本一致性和更新记录,并在 PHP 7.4 / 8.2 / 8.4、Typecho 1.2.1 / 1.3.0、SQLite / MySQL 8 / PostgreSQL 17 矩阵中使用官方数据库层与 PDO/原生适配器执行集成测试。测试 Typecho 源码固定到官方提交,覆盖草稿、权限、重试、字段回滚、外层事务及旧库升级。数据库以外的 options/编码器边界仍使用隔离替身,不能冒充真实网站、生产主从延迟或完整图片编码器的端到端验证。 + +真实数据库测试仅在一次性测试库运行,例如:`TYPECHO_ROOT=/path/to/typecho php tests/typecho-integration.php Pdo_SQLite`。其他适配器为 `SQLite`、`Pdo_Mysql`、`Mysqli`、`Pdo_Pgsql`、`Pgsql`,连接参数使用 `IT_DB_HOST`、`IT_DB_PORT`、`IT_DB_NAME`、`IT_DB_USER`、`IT_DB_PASSWORD` 环境变量。测试会创建随机前缀的表并在结束时清理。 + +字段聚合同步使用主库保存点:失败保留完整旧字段,不提交或回滚调用方的其他事务。MySQL 的 `fields` 表必须使用 InnoDB(单图删除还检查图片表,整图集删除同时检查文章表);非事务引擎会在写入前明确拒绝,需要管理员先备份并自行迁移,本插件不会自动改动站点表引擎。 + +维护检查点有独立任务标识。未完成的同类任务继续原清单与进度,完成后再次开始会重新收集候选;迟到轮询不能覆盖新任务。磁盘写入失败会明确报错,重建字段失败计入失败数,可运行「重建尺寸字段」重试。首批已完成但响应丢失后,手动再次开始属于新任务,可能重复处理,但不会复用过期快照。 + +单图删除将图片行和聚合字段放在同一事务中;整图集删除将图片行、图集字段及文章记录放在同一事务中,确认自行提交后才清理无引用实体;调用方外层事务尚未结束时保留文件,由提交后的安全孤儿清理回收。数据库与文件系统没有跨系统原子提交:提交后崩溃、连接结果未知或清理失败可能留下孤儿,优先避免丢失可恢复文件。历史 URL 的查询参数、编码和点目录会保守纳入引用保护。 + +上传中只更新当前照片卡片,服务器确认成功且返回图片标识后才释放本地文件资源,失败项保留重试。200 张隔离 DOM/XHR 样本的创建节点数从 1,124,203 降至 8,403,全部成功后的 ObjectURL 从 200 降至 0;这仅是测试样本的 DOM/资源数据,完整队列统计与持久化仍有遍历,不代表全部算法线性或线上耗时承诺。 + +主图与缩略图使用同目录暂存文件,全部编码并校验后再依次替换;编码失败不会提前覆盖旧图。两个文件的 rename 不能形成跨文件原子事务,发布中途磁盘失败仍可能保留不同代但各自完整的输出,可重建恢复。WebP 编码单边上限为 16383px,超限会明确拒绝,不暗中改变用户的全景尺寸设置。AVIF 读取失败先尝试已有 WebP,仍失败才显示重试。 + +孤儿引用扫描仅读六个路径列,不缓存旧引用;每批删除前仍在媒体锁内核验。可运行 `php tests/orphan-performance.php` 在隔离 SQLite 中对比返回量和查询中位数,测试不使用机器相关的耗时通过阈值。 维护与上传现在通过同一上传根目录内的文件锁互斥,媒体处理串行执行,逐图队列在忙时保留状态并提示重试,不会并发启动多张转换。孤儿清理只处理至少一小时前的文件,恢复任务时重新核验数据库引用与文件身份;旧版任务快照会安全跳过,重新启动清理即可。文件锁需要底层文件系统支持可靠的 `flock`(多节点部署需共用支持锁的上传卷)。 @@ -206,3 +232,7 @@ git tag v1.7.0 && git push origin v1.7.0 > 若你基于本主题二次分发,请同时保留上述第三方项目的许可声明。 + +### 本次固定版本发布 + +1.14.1 使用额外的受控发布入口:只有本仓库 main 的完整 CI 成功且 SHA 仍为当前主线,才可创建 v1.14.1 与两份 ZIP。该入口固定版本,不能自动发布未来版本;已有完整发布只核验,不覆盖。匿名 CI 元数据查询、主线变化、版本不符或部分发布均会停止;任务失败后需核对 tag、draft 及资产状态。原手动 tag 发布流程保持。 diff --git a/tests/admin-upload.cjs b/tests/admin-upload.cjs index 0402ef8..0869bd7 100644 --- a/tests/admin-upload.cjs +++ b/tests/admin-upload.cjs @@ -5,8 +5,10 @@ const vm = require('node:vm'); const fs = require('node:fs'); const source = fs.readFileSync(require.resolve('../usr/plugins/InfinityTime/assets/admin.js'), 'utf8'); +let createdNodes = 0; class Element { constructor(tag = 'div') { + createdNodes++; this.tagName = tag.toUpperCase(); this.children = []; this.dataset = {}; this.style = {}; this.listeners = {}; this.attributes = {}; this.className = ''; this.value = ''; this.disabled = false; this.classList = { @@ -16,6 +18,8 @@ class Element { toggle: (name, force) => { const yes = force === undefined ? !this.classList.contains(name) : force; this.classList[yes ? 'add' : 'remove'](name); return yes; } }; } + set value(value) { this._value = value; if (value === '' && this.tagName === 'INPUT' && this.type === 'file') this.files = []; } + get value() { return this._value; } addEventListener(name, fn) { (this.listeners[name] ||= []).push(fn); } emit(name, extra = {}) { const event = { target: this, preventDefault() { this.defaultPrevented = true; }, stopPropagation() {}, ...extra }; @@ -75,7 +79,7 @@ class FormDataMock { const flush = async () => { for (let i = 0; i < 15; i++) await new Promise(resolve => setImmediate(resolve)); }; const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }; const response = (data, ok = true) => ({ ok, json: async () => data, text: async () => typeof data === 'string' ? data : JSON.stringify(data) }); -function harness({ storage = new Map(), fetchHandler, initialCid = '', initialTarget = null, canPublish = true } = {}) { +function harness({ storage = new Map(), fetchHandler, initialCid = '', initialTarget = null, canPublish = true, withDataTransfer = false } = {}) { const document = new Element('document'); const make = (tag, props, parent = document) => { const element = new Element(tag); Object.assign(element, props); parent.appendChild(element); return element; }; const container = make('div', { className: 'container typecho-page-main' }); @@ -125,6 +129,9 @@ function harness({ storage = new Map(), fetchHandler, initialCid = '', initialTa reply(data, status = 200) { if (data.ok && !data.replayed) serverCount++; this.status = status; this.responseText = JSON.stringify(data); this.onload(); } } }; + if (withDataTransfer) context.DataTransfer = class { + constructor() { this.files = []; this.items = { add: file => this.files.push(file) }; } + }; vm.runInNewContext(source, context); document.emit('DOMContentLoaded'); return { document, form, input, previews, target, summary, submit, retry, publish, fresh, cid, dropzone, append, details, thumbs, maintenance, maintenanceMsg, activeUrls, requests, uploads, storage, window, @@ -154,6 +161,7 @@ const drop = index => ({ dataTransfer: { getData() { return String(index); } } } h.previews.children[0].querySelector('.pp-up-tit').value = '首张标题'; h.previews.children[0].querySelector('.pp-up-tit').emit('input'); h.submitForm(); h.submitForm(); + assert.equal(h.input.files.length, 0, '上传队列接管文件后清空原生 FileList,避免成功文件继续被输入框持有'); assert.equal(h.requests.filter(r => r.body?.get('action') === 'create_draft').length, 1, '重复提交不会创建第二份草稿'); h.previews.children[0].emit('drop', drop(1)); h.previews.children[0].children[1].emit('click'); h.select([files[0]]); assert.deepEqual(h.previews.children.map(c => c.children[0].alt), ['c.jpg', 'b.jpg'], '处理期间不能更改队列'); @@ -168,14 +176,28 @@ const drop = index => ({ dataTransfer: { getData() { return String(index); } } } assert.equal(h.uploads[0].data.get('title'), '首张标题'); assert.equal(h.uploads[0].data.get('_'), 'secret-csrf'); assert.deepEqual(h.states(), ['uploading', 'queued']); + const beforeEmptyProgress = createdNodes; + h.uploads[0].upload.onprogress({ lengthComputable: true, loaded: 0, total: 0 }); + assert.equal(createdNodes, beforeEmptyProgress); + assert.equal(h.document.querySelector('#pp-upload-bar').style.width, '0%', '零字节进度不会产生 NaN%'); + const untouchedCard = h.previews.children[1]; h.uploads[0].upload.onprogress({ lengthComputable: true, loaded: 10, total: 10 }); + assert.equal(h.previews.children[1], untouchedCard, '当前项状态改变不会重建其他卡片'); + const convertedCard = h.previews.children[0], afterConversionNodes = createdNodes; + h.uploads[0].upload.onload(); h.uploads[0].upload.onload(); + assert.equal(h.previews.children[0], convertedCard, '上传完成事件不会重复刷新转换状态'); + assert.equal(createdNodes, afterConversionNodes); assert.deepEqual(h.states(), ['converting', 'queued'], '转换阶段具有独立状态'); h.uploads[0].reply({ ok: true, rowId: 100, cid: 42 }); await flush(); assert.equal(h.uploads.length, 2); assert.deepEqual(h.states(), ['done', 'uploading']); + assert.equal(h.activeUrls.size, 1, '已保存原图的 ObjectURL 立即释放,未完成项保留'); + assert.equal(h.previews.children[0].children[0].tagName, 'DIV', '成功项释放 File 引用后使用已保存占位卡片'); + assert.ok(h.previews.children[0].querySelector('.pp-up-saved')); const failedKey = h.uploads[1].data.get('item_key'); h.uploads[1].reply({ ok: false, retryable: true, msg: '正忙' }); await flush(); assert.deepEqual(h.states(), ['done', 'failed']); + assert.equal(h.activeUrls.size, 1, '失败项保留原文件预览以便直接重试'); assert.equal(h.publish.disabled, true, '部分失败时不能误发布'); assert.equal(h.requests.some(r => r.body?.get('action') === 'publish_album'), false, '保存不会隐式发布图集'); const listRead = h.requests.find(r => r.url.includes('job=albums_html')); @@ -186,6 +208,11 @@ const drop = index => ({ dataTransfer: { getData() { return String(index); } } } assert.equal(h.uploads[2].data.get('item_key'), failedKey, '重试保留原幂等键'); h.uploads[2].reply({ ok: true, rowId: 101, cid: 42 }); await flush(); assert.deepEqual(h.states(), ['done', 'done']); + assert.equal(h.activeUrls.size, 0, '重试成功后释放最后一份原图预览'); + h.select([files[1], files[2]]); + assert.equal(h.activeUrls.size, 0, '重选已保存文件不重新分配预览资源'); + assert.equal(h.previews.children.length, 2); + assert.equal(h.input.files.length, 0, '无 DataTransfer 的浏览器也不持有被忽略的已保存文件'); assert.equal(h.publish.disabled, false); h.publish.click(); h.publish.click(); await flush(); assert.equal(h.requests.filter(r => r.body?.get('action') === 'publish_album').length, 1); @@ -246,6 +273,67 @@ const drop = index => ({ dataTransfer: { getData() { return String(index); } } } assert.equal(contributor.fresh.disabled, false, '只读图集不会阻止用户新建草稿'); contributor.fresh.click(); assert.equal(contributor.input.disabled, false); + // 大队列只允许线性创建 DOM 节点;每个原文件仅上传一次。 + // 时间值受 DOM/XHR 替身和运行环境影响,不作为浏览器性能门槛。 + const costs = []; + for (const count of [10, 50, 100, 200]) { + const batch = harness(), baseline = createdNodes; + const batchFiles = Array.from({ length: count }, (_, i) => ({ name: 'photo-' + i + '.jpg', size: 20 * 1024 * 1024, lastModified: 1 })); + batch.select(batchFiles); batch.submitForm(); await flush(); + for (let i = 0; i < count; i++) { + assert.equal(batch.uploads.length, i + 1, '前一个请求结束前不会上传下一项'); + const xhr = batch.uploads[i]; + assert.equal(xhr.data.get('file'), batchFiles[i]); + xhr.upload.onprogress({ lengthComputable: true, loaded: 20, total: 20 }); + const convertedNodes = createdNodes; + xhr.upload.onload(); + assert.equal(createdNodes, convertedNodes, '100% progress 与 onload 不重复创建节点'); + xhr.reply({ ok: true, rowId: i + 1 }); await flush(); + assert.equal(batch.activeUrls.size, count - i - 1, '逐项释放资源,待上传文件保持可用'); + const settledNodes = createdNodes; + xhr.upload.onload(); xhr.onerror(); xhr.ontimeout(); + assert.equal(createdNodes, settledNodes, '已结算请求的迟到事件不改变队列或重复释放资源'); + } + assert.equal(batch.uploads.length, count); + assert.equal(new Set(batch.uploads.map(xhr => xhr.data.get('item_key'))).size, count, '每项保留独立幂等键'); + assert.equal(batch.states().filter(state => state === 'done').length, count); + assert.equal(batch.previews.querySelectorAll('.pp-up-saved').length, count); + assert.equal(batch.activeUrls.size, 0); + const nodes = createdNodes - baseline; + assert.ok(nodes <= 50 * count + 10, '节点数必须随队列线性增长:' + count + ' 张创建了 ' + nodes + ' 个节点'); + costs.push({ count, nodes }); + batch.fresh.click(); assert.equal(batch.activeUrls.size, 0, '清空成功队列不会再次回收 URL'); + } + assert.ok(costs[3].nodes <= costs[2].nodes * 2 + 10, '队列翻倍不能导致节点数平方增长'); + + // 支持 DataTransfer 的浏览器:重排/移除会同步 FileList,上传后清空但失败项仍能重试。 + const nativeInput = harness({ withDataTransfer: true }); + nativeInput.select(files); nativeInput.previews.children[0].emit('drop', drop(2)); + assert.deepEqual(Array.from(nativeInput.input.files, file => file.name), ['c.jpg', 'a.jpg', 'b.jpg']); + nativeInput.previews.children[1].children[1].click(); + assert.deepEqual(Array.from(nativeInput.input.files, file => file.name), ['c.jpg', 'b.jpg']); + nativeInput.submitForm(); await flush(); assert.equal(nativeInput.input.files.length, 0); + nativeInput.uploads[0].reply({ ok: true, rowId: 1 }); await flush(); + nativeInput.uploads[1].onerror(); await flush(); + assert.equal(nativeInput.activeUrls.size, 1); + const nativeFailedKey = nativeInput.uploads[1].data.get('item_key'); + nativeInput.retry.click(); await flush(); + assert.equal(nativeInput.uploads.length, 3); + assert.equal(nativeInput.uploads[2].data.get('file'), files[1]); + assert.equal(nativeInput.uploads[2].data.get('item_key'), nativeFailedKey); + nativeInput.uploads[2].reply({ ok: true, rowId: 2 }); await flush(); + assert.equal(nativeInput.activeUrls.size, 0); + nativeInput.select(files.slice(1)); + assert.equal(nativeInput.input.files.length, 0, '重选全部已保存文件也不会保留原生文件引用'); + assert.equal(nativeInput.activeUrls.size, 0); + + // 兼容旧响应:未提供 rowId 时,不激进释放无法确认为服务器记录的原文件。 + const legacyResponse = harness(); legacyResponse.select([files[0]]); legacyResponse.submitForm(); await flush(); + legacyResponse.uploads[0].reply({ ok: true }); await flush(); + assert.equal(legacyResponse.activeUrls.size, 1); + assert.equal(legacyResponse.previews.children[0].children[0].tagName, 'IMG'); + legacyResponse.fresh.click(); assert.equal(legacyResponse.activeUrls.size, 0); + // 图片详情加载失败可重试;维护操作仅使用带鉴权信息的 POST 请求。 let detailAttempts = 0; const lazy = harness({ fetchHandler(url) { if (url.includes('job=album_images')) return response('detail', ++detailAttempts > 1); } }); @@ -261,10 +349,13 @@ const drop = index => ({ dataTransfer: { getData() { return String(index); } } } console.log('后台串行队列、部分重试、幂等、恢复、追加、发布、分页、懒加载重试和 POST 维护测试通过'); })().catch(error => { console.error(error); process.exitCode = 1; }); -// 可选真实浏览器回归: CHROMIUM_PATH=/usr/bin/chromium node tests/admin-upload.cjs --browser +// 可选真实浏览器回归: PLAYWRIGHT_BROWSER=chromium|firefox|webkit node tests/admin-upload.cjs --browser +// 系统 Chromium 可额外指定 CHROMIUM_PATH=/usr/bin/chromium。 // 使用本地隔离 HTTP 服务;无需 PHP 服务器或真实账号。 async function browserRegression() { - const { chromium } = require('playwright'); + const browserName = process.env.PLAYWRIGHT_BROWSER || 'chromium'; + assert.ok(['chromium', 'firefox', 'webkit'].includes(browserName), '不支持的 PLAYWRIGHT_BROWSER: ' + browserName); + const browserType = require('playwright')[browserName]; const path = require('node:path'); const os = require('node:os'); const http = require('node:http'); @@ -288,8 +379,14 @@ async function browserRegression() { return result; }; try { - browser = await chromium.launch({ headless: true, ...(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}) }); + browser = await browserType.launch({ headless: true, ...(browserName === 'chromium' && process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}) }); const page = await browser.newPage({ viewport: { width: 1100, height: 850 } }); + await page.addInitScript(() => { + const active = new Set(), create = URL.createObjectURL.bind(URL), revoke = URL.revokeObjectURL.bind(URL); + window.uploadPreviewResources = active; + URL.createObjectURL = file => { const url = create(file); active.add(url); return url; }; + URL.revokeObjectURL = url => { active.delete(url); revoke(url); }; + }); const errors = [], calls = [], rows = new Map(); page.on('pageerror', error => errors.push(error.message)); page.on('dialog', dialog => dialog.accept()); @@ -348,6 +445,9 @@ async function browserRegression() { releaseFirst(); await page.waitForFunction(() => !document.querySelector('#pp-upload-submit').disabled && document.querySelector('.pp-up-failed')); assert.equal(rows.size, 1); assert.equal(maxActive, 1); + assert.equal(await page.evaluate(() => window.uploadPreviewResources.size), 1, '真实浏览器只保留失败项预览'); + assert.equal(await page.locator('.pp-up-done .pp-up-saved').count(), 1); + assert.equal(await page.locator('#pp-files-input').evaluate(input => input.files.length), 0, '原生 FileList 已清空'); assert.equal(published, false); assert.equal(await page.locator('#pp-upload-publish').isDisabled(), true); const failedKey = calls.find(call => call.data.file === 'b.png').data.item_key; @@ -359,6 +459,8 @@ async function browserRegression() { await page.locator('#pp-upload-retry').click(); await page.waitForFunction(() => document.querySelectorAll('.pp-up-done').length === 2 && !document.querySelector('#pp-upload-submit').disabled); assert.equal(rows.size, 2); + assert.equal(await page.evaluate(() => window.uploadPreviewResources.size), 0, '真实浏览器全部保存后无活跃 ObjectURL'); + assert.equal(await page.locator('.pp-up-done .pp-up-saved').count(), 2); const imageCalls = calls.filter(call => call.data.action === 'upload_image'); assert.deepEqual(imageCalls.map(call => call.data.file), ['a.png', 'b.png', 'b.png']); assert.equal(imageCalls[2].data.item_key, failedKey); @@ -374,7 +476,7 @@ async function browserRegression() { assert.equal(await page.locator('.pp-up-item').count(), 0); assert.equal(await page.locator('#pp-upload-cid').inputValue(), ''); assert.deepEqual(errors, []); - console.log('后台 Chromium 回归通过:串行转换、部分重试、刷新后重选、明确发布与移动布局'); + console.log('后台 ' + browserName + ' 回归通过:串行转换、部分重试、刷新后重选、明确发布与移动布局'); } finally { if (browser) await browser.close(); if (server) { diff --git a/tests/admin-workflow.php b/tests/admin-workflow.php index d472238..614f285 100644 --- a/tests/admin-workflow.php +++ b/tests/admin-workflow.php @@ -110,6 +110,7 @@ public static function syncPostFields($cid): void { use TypechoPlugin\InfinityTime\Lib\AdminRepository as Repository; use TypechoPlugin\InfinityTime\Lib\AdminWorkflow as Workflow; use TypechoPlugin\InfinityTime\Lib\ImageRepository as Images; + require_once __DIR__ . '/../usr/plugins/InfinityTime/Lib/Database.php'; require __DIR__ . '/../usr/plugins/InfinityTime/Lib/AdminSecurity.php'; require __DIR__ . '/../usr/plugins/InfinityTime/Lib/AdminRepository.php'; require __DIR__ . '/../usr/plugins/InfinityTime/Lib/AdminWorkflow.php'; diff --git a/tests/field-sync.php b/tests/field-sync.php new file mode 100644 index 0000000..46b2daa --- /dev/null +++ b/tests/field-sync.php @@ -0,0 +1,207 @@ +kind = $kind; $this->table = $table; $this->fields = $fields ?: ['*']; } + public function from($table): self { $this->table = $table; return $this; } + public function rows($rows): self { $this->rows = $rows; return $this; } + public function where($clause, ...$values): self { + foreach ($values as $value) { $clause = substr_replace($clause, $this->quoteValue($value), strpos($clause, '?'), 1); } + $this->where[] = '(' . $clause . ')'; return $this; + } + public function limit($limit): self { $this->limit = ' LIMIT ' . (int)$limit; return $this; } + public function order($column, $direction): self { $this->order .= ($this->order ? ',' : ' ORDER BY ') . '"' . $column . '" ' . $direction; return $this; } + public function quoteValue($value): string { $this->params[] = $value; return '#param:' . (count($this->params) - 1) . '#'; } + public function prepare(string $sql): string { + return preg_replace_callback('/#param:(\d+)#/', function ($match) { $value = $this->params[(int)$match[1]]; return $value === null ? 'NULL' : Db::get()->pdo->quote((string)$value); }, $sql); + } + public function __toString(): string { + $table = '"' . $this->table . '"'; + if ($this->kind === 'insert') { + $names = implode(',', array_map(static function ($name) { return '"' . $name . '"'; }, array_keys($this->rows))); + $values = implode(',', array_map(function ($value) { return $this->quoteValue($value); }, array_values($this->rows))); + $sql = "INSERT INTO $table ($names) VALUES ($values)"; + } elseif ($this->kind === 'update') { + $values = []; + foreach ($this->rows as $name => $value) { $values[] = '"' . $name . '" = ' . $this->quoteValue($value); } + $sql = "UPDATE $table SET " . implode(',', $values); + } elseif ($this->kind === 'delete') { $sql = "DELETE FROM $table"; } + else { $sql = 'SELECT ' . implode(',', $this->fields) . " FROM $table"; } + return $this->prepare($sql . ($this->where ? ' WHERE ' . implode(' AND ', $this->where) : '') . $this->order . $this->limit); + } + public function isInsert(): bool { return $this->kind === 'insert'; } + } + class Db { + public const SORT_ASC = 'ASC', WRITE = 2, READ = 1; + public static self $instance; + public \PDO $pdo; + public \PDO $replica; + public array $operations = []; + public string $adapter = 'Pdo_SQLite'; + public $beforeWrite = null; + public function __construct() { + $this->pdo = new \PDO('sqlite::memory:', null, null, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); + $this->replica = new \PDO('sqlite::memory:', null, null, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); + } + public static function get(): self { return self::$instance; } + public function getPrefix(): string { return 'sync_'; } + public function getAdapterName(): string { return $this->adapter; } + public function selectDb($op) { return $op === self::WRITE ? $this->pdo : $this->replica; } + public function select(...$fields): Query { return new Query('select', '', $fields); } + public function insert($table): Query { return new Query('insert', $table); } + public function delete($table): Query { return new Query('delete', $table); } + public function fetchRow($query) { return $this->query($query)->fetch(\PDO::FETCH_ASSOC); } + public function fetchAll($query): array { return $this->query($query)->fetchAll(\PDO::FETCH_ASSOC); } + public function query($query, $op = self::READ) { + if ($query instanceof \PDOStatement) { return $query; } + if ($query instanceof Query) { + // 真实 Db::query 覆盖传入的 WRITE;未编译的 SELECT 应故意去空副本失败。 + $op = preg_match('/^SELECT /', (string)$query) ? self::READ : self::WRITE; + } + $sql = (string)$query; + $this->operations[] = ['sql' => $sql, 'op' => $op, 'object' => is_object($query)]; + if ($this->beforeWrite && preg_match('/^(INSERT|DELETE)/', $sql)) { + $callback = $this->beforeWrite; + $this->beforeWrite = null; + $callback(); + } + return $this->selectDb($op)->query($sql); + } + } +} +namespace TypechoPlugin\InfinityTime { + class Plugin { + public static int $migrations = 0; + public static function migrateSchema(): void { self::$migrations++; throw new \RuntimeException('事务内禁止 DDL'); } + public static function log($message): void {} + } +} +namespace { + use TypechoPlugin\InfinityTime\Lib\ImageRepository as Repository; + require_once __DIR__ . '/../usr/plugins/InfinityTime/Lib/Database.php'; + require __DIR__ . '/../usr/plugins/InfinityTime/Lib/ImageRepository.php'; + $root = sys_get_temp_dir() . '/infinitytime-sync-' . bin2hex(random_bytes(8)); + mkdir($root, 0700); + define('__TYPECHO_ROOT_DIR__', $root); + define('__TYPECHO_UPLOAD_ROOT_DIR__', $root); + $db = \Typecho\Db::$instance = new \Typecho\Db(); + $pdo = $db->pdo; + $checks = 0; + function expect(bool $ok, string $message): void { + global $checks; + if (!$ok) { throw new \RuntimeException($message); } + $checks++; + } + function fields(): array { + global $pdo; + return $pdo->query('SELECT * FROM sync_fields ORDER BY cid,name')->fetchAll(\PDO::FETCH_ASSOC); + } + function failSync(): void { + $thrown = false; + try { Repository::syncPostFields(1); } + catch (\Throwable $e) { $thrown = strpos($e->getMessage(), 'injected failure') !== false; } + expect($thrown, '同步应传播中途写入错误'); + } + function failTrigger(bool $on, string $field = 'dims'): void { + global $pdo; + if ($on) { + $pdo->exec("CREATE TRIGGER fail_sync BEFORE INSERT ON sync_fields WHEN NEW.name = '$field' BEGIN SELECT RAISE(ABORT, 'injected failure'); END"); + } else { $pdo->exec('DROP TRIGGER fail_sync'); } + } + try { + $resource = $pdo->query('SELECT 1'); + expect(\TypechoPlugin\InfinityTime\Lib\Database::query($resource) === $resource, '已执行的 PDOStatement 句柄原样返回,不误调用 getAttribute(action)'); + $resource->closeCursor(); + $pdo->exec('CREATE TABLE sync_fields (cid INTEGER, name TEXT, type TEXT, str_value TEXT, int_value INTEGER DEFAULT 0, float_value REAL DEFAULT 0, PRIMARY KEY(cid,name))'); + $pdo->exec('CREATE TABLE sync_infinitytime_images (id INTEGER PRIMARY KEY, cid INTEGER, sort INTEGER, full TEXT, thumb TEXT, width INTEGER, height INTEGER, exif TEXT, created INTEGER)'); + $pdo->exec("INSERT INTO sync_infinitytime_images VALUES (1,1,0,'/new.webp','/new-thumb.webp',200,100,'{}',1704067200),(2,2,0,'/second.webp','/second-thumb.webp',100,100,'{}',1704067200)"); + foreach ([1,2] as $cid) { + foreach (['img','thumb','addresses','titles','descs','panos','dims','variants','exif','photo_ids','months','unrelated'] as $name) { + $q = $pdo->prepare('INSERT INTO sync_fields VALUES (?,?,?,?,?,?)'); + $q->execute([$cid,$name,'str','old-' . $name,11,1.25]); + } + } + $original = fields(); + failTrigger(true); + failSync(); + expect(fields() === $original, '独立事务中途失败必须完整保留所有旧字段及类型列'); + failTrigger(false); + $pdo->exec('BEGIN'); + $pdo->exec("UPDATE sync_fields SET str_value = 'caller-before' WHERE cid=1 AND name='unrelated'"); + $outer = fields(); + failTrigger(true); + failSync(); + expect(fields() === $outer, '嵌套失败不得回滚调用方此前的修改'); + failTrigger(false); + $pdo->exec("UPDATE sync_fields SET str_value = 'caller-after' WHERE cid=2 AND name='unrelated'"); + $pdo->exec('COMMIT'); + expect($pdo->query("SELECT str_value FROM sync_fields WHERE cid=2 AND name='unrelated'")->fetchColumn() === 'caller-after', '失败后外层事务应可继续并提交'); + + $beforeSuccess = fields(); + $pdo->exec('BEGIN'); + Repository::syncPostFields(1); + expect($pdo->query("SELECT str_value FROM sync_fields WHERE cid=1 AND name='img'")->fetchColumn() === '/new.webp', '同步使用主库图片行'); + $pdo->exec('ROLLBACK'); + expect(fields() === $beforeSuccess, '成功同步不得提前提交外层 BEGIN'); + + $pdo->exec('SAVEPOINT caller'); + Repository::syncPostFields(1); + Repository::syncPostFields(2); + $pdo->exec('ROLLBACK TO SAVEPOINT caller'); + $pdo->exec('RELEASE SAVEPOINT caller'); + expect(fields() === $beforeSuccess, '连续同步不能越过调用方 SAVEPOINT 边界'); + + $db->beforeWrite = static function (): void { + Repository::syncPostFields(2); + throw new \RuntimeException('injected failure'); + }; + failSync(); + expect(fields() === $beforeSuccess, '重入同步独立命名保存点,外层失败回滚内部成功同步'); + + Repository::syncPostFields(1); + $afterSuccess = fields(); + expect($afterSuccess !== $beforeSuccess, '独立同步成功应持久化'); + expect($pdo->query("SELECT str_value FROM sync_fields WHERE cid=1 AND name='dims'")->fetchColumn() === '["200x100"]', '字段内容与图片顺序一致'); + expect($pdo->query("SELECT str_value FROM sync_fields WHERE cid=1 AND name='unrelated'")->fetchColumn() === 'caller-before', '同步不改无关字段'); + $pdo->exec('ALTER TABLE sync_infinitytime_images ADD COLUMN title TEXT'); + $badTitle = $pdo->prepare('UPDATE sync_infinitytime_images SET title=? WHERE cid=1'); + $badTitle->execute(["bad-\xff"]); + $badJson = false; + try { Repository::syncPostFields(1); } catch (\JsonException $e) { $badJson = true; } + expect($badJson && fields() === $afterSuccess, 'JSON编码错误也须完整回滚,不能把false写入字段'); + $pdo->exec('DELETE FROM sync_infinitytime_images WHERE cid=1'); + Repository::syncPostFields(1); + expect(fields() === $afterSuccess, '历史字段无图片行时保持原样'); + failTrigger(true, 'thumb'); + $clearFailed = false; + try { Repository::syncPostFields(1, true); } catch (\Throwable $e) { $clearFailed = true; } + expect($clearFailed && fields() === $afterSuccess, '清空字段中途失败也须保留全部旧字段'); + failTrigger(false); + Repository::syncPostFields(1, true); + $empty = $pdo->query("SELECT name,str_value FROM sync_fields WHERE cid=1 AND name <> 'unrelated' ORDER BY name")->fetchAll(\PDO::FETCH_ASSOC); + expect($empty === [['name'=>'img','str_value'=>''],['name'=>'thumb','str_value'=>'']], '显式清空保留空图集标记并清除旧元数据'); + expect(\TypechoPlugin\InfinityTime\Plugin::$migrations === 0, '同步不得触发 schema DDL 或隐式提交'); + foreach ($db->operations as $operation) { + expect($operation['op'] === \Typecho\Db::WRITE && !$operation['object'], '同步的查询和事务语句全部在主库执行'); + } + $db->adapter = 'unknown'; + $beforeUnsupported = fields(); + $rejected = false; + try { Repository::syncPostFields(2); } catch (\RuntimeException $e) { $rejected = strpos($e->getMessage(), '不支持') !== false; } + expect($rejected && fields() === $beforeUnsupported, '未知适配器安全拒绝且不修改字段'); + echo "字段同步原子性、嵌套事务与主库路由检查通过($checks 项)\n"; + } finally { + Repository::unlockMedia(); + @unlink($root . '/.infinitytime-media.lock'); + @rmdir($root); + } +} diff --git a/tests/gallery-browser.cjs b/tests/gallery-browser.cjs index 6f36fbf..4dd5642 100644 --- a/tests/gallery-browser.cjs +++ b/tests/gallery-browser.cjs @@ -3,7 +3,9 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { chromium } = require('playwright'); +const playwright = require('playwright'); +const browserName = process.env.PLAYWRIGHT_BROWSER || 'chromium'; +assert.ok(['chromium', 'firefox', 'webkit'].includes(browserName), 'valid PLAYWRIGHT_BROWSER'); const theme = path.resolve(__dirname, '../usr/themes/InfinityTime'); const origin = 'https://gallery.test'; const url = name => origin + '/' + name + '.webp'; @@ -20,15 +22,24 @@ const fixture = `
- + ${['jquery.min', 'jquery.poptrox.min', 'browser.min', 'breakpoints.min', 'lightbox', 'main'].map(name => ``).join('')} `; const image = ''; (async () => { - const browser = await chromium.launch({ headless: true, ...(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}) }); + const browser = await playwright[browserName].launch({ headless: true, ...(browserName === 'chromium' && process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}) }); try { for (const viewport of [{ width: 1280, height: 900 }, { width: 390, height: 844 }]) { const context = await browser.newContext({ viewport }); + await context.addInitScript(() => { + // Isolate optional-API handling from a CI machine's actual GPU support. + window.ResizeObserver = undefined; + const original = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function(type, ...args) { + if (/webgl/.test(type)) return { getExtension() { return null; } }; + return original.call(this, type, ...args); + }; + }); const page = await context.newPage(); const errors = []; page.on('pageerror', err => errors.push(err.message)); @@ -99,7 +110,13 @@ const image = '