Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/scripts/release-guard.cjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
'use strict';
// 仅用于本次已授权的 v1.14.2;失败或部分发布必须人工核查,绝不覆盖资产。
// 仅用于本次已授权的 v1.15.0;失败或部分发布必须人工核查,绝不覆盖资产。
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.2';
const VERSION = '1.15.0';
const TAG = `v${VERSION}`;
const ASSETS = ['infinitytime-theme.zip', 'infinitytime-plugin.zip'];
function insist(ok, message) { if (!ok) throw new Error(message); }
Expand All @@ -22,9 +22,9 @@ function guardRun(event, repository, mainSha, run, workflow) {
return mainSha;
}
function guardVersions(theme, plugin, release) {
insist(theme.match(/@version\s+([\d.]+)/)?.[1] === VERSION, '主题版本不是 1.14.2');
insist(plugin.match(/const VERSION = '([\d.]+)'/)?.[1] === VERSION, '插件版本不是 1.14.2');
insist(JSON.parse(release).tag_name === VERSION, '版本清单不是 1.14.2');
insist(theme.match(/@version\s+([\d.]+)/)?.[1] === VERSION, '主题版本不是 1.15.0');
insist(plugin.match(/const VERSION = '([\d.]+)'/)?.[1] === VERSION, '插件版本不是 1.15.0');
insist(JSON.parse(release).tag_name === VERSION, '版本清单不是 1.15.0');
}
function guardExisting(tagSha, release, sha, digests) {
if (!tagSha && !release) return 'create';
Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,14 @@ jobs:
php tests/schema-sqlite.php
php tests/field-sync.php
php tests/image-delete.php
php tests/media-version.php
php tests/media-lifetime.php
php tests/media-gd.php
php tests/media-command.php
php tests/media-temp.php
php tests/health-diagnostics.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
for mode in scan-failure retry-image retry-fields retry-mixed retry-resync retry-repeat retry-finished retry-stale retry-empty retry-unfinished retry-other-job errors-page errors-stale resync-failure 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
Expand All @@ -65,6 +67,7 @@ jobs:
node tests/maintenance-client.cjs
node --test tests/release-guards.cjs
node tests/gallery.cjs
node tests/data-saver.cjs
node tests/gallery-loading.cjs
node tests/gallery-navigation.cjs

Expand Down Expand Up @@ -108,6 +111,9 @@ jobs:
- name: 后台上传浏览器回归
if: ${{ !cancelled() }}
run: node tests/admin-upload.cjs --browser
- name: 维护失败恢复浏览器回归
if: ${{ !cancelled() }}
run: node tests/maintenance-browser.cjs

typecho-integration:
runs-on: ubuntu-latest
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ jobs:
dist/infinitytime-plugin.zip
body_path: dist/RELEASE_NOTES.md

# 一次性授权范围:只允许目标仓库 main 的完整 CI 发布 1.14.2。
release-1-14-2:
# 一次性授权范围:只允许目标仓库 main 的完整 CI 发布 1.15.0。
release-1-15-0:
if: >-
github.event_name == 'workflow_run' &&
github.repository == 'Thregren/InfinityTime' &&
Expand All @@ -77,7 +77,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
concurrency:
group: release-v1.14.2
group: release-v1.15.0
cancel-in-progress: false
steps:
- name: 在 checkout 前验证可信 main 和 CI 身份
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@

## 功能特性

### v1.15.0 维护恢复与省流浏览

- 维护失败明细按图片或相册保留,支持每页50项查看与仅重试失败项。编码失败和字段同步失败分开恢复;旧任务未保存的明细不会被虚构。
- 照片加载可跟随系统省流建议或手动切换。省流关闭自动翻页与邻图预取,仍可主动看图和加载更多;隐藏页面暂停额外加载并释放预取引用。
- 重建后公开派生图带新的缓存标识,稳定照片ID与数据库物理路径保持不变。CDN必须将查询参数纳入缓存键;已打开页面仍需刷新获取新URL。
- 管理员可查看只读健康检查和隔离恢复演练步骤。它不会自动修改目录、表引擎、服务器保护规则或生产数据;发现编码器不代表该工具已通过真实图片解码验证。

### v1.14.2 异常恢复与编辑一致性加固

分页自动滚动与主动调用共享同一请求结果;无 AbortController 或网络实现忽略 abort 时,超时/取消也会结束等待并隔离迟到响应。后台编辑字段、图集标题、图片元数据和排序使用同一主库保存点协议,字段失败不会留下半套新值,外层事务保持由调用方决定提交。本版在完整 CI 与发布校验通过后提供主题和插件更新包;不会自动部署线上站点。
Expand Down Expand Up @@ -243,4 +250,4 @@ git tag v1.7.0 && git push origin v1.7.0

### 本次固定版本发布

1.14.2 使用额外的受控发布入口:只有本仓库 main 的完整 CI 成功且 SHA 仍为当前主线,才可创建 v1.14.2 与两份 ZIP。该入口固定版本,不能自动发布未来版本;已有完整发布只核验,不覆盖。匿名 CI 元数据查询、主线变化、版本不符或部分发布均会停止;任务失败后需核对 tag、draft 及资产状态。原手动 tag 发布流程保持。
1.15.0 使用额外的受控发布入口:只有本仓库 main 的完整 CI 成功且 SHA 仍为当前主线,才可创建 v1.15.0 与两份 ZIP。该入口固定版本,不能自动发布未来版本;已有完整发布只核验,不覆盖。匿名 CI 元数据查询、主线变化、版本不符或部分发布均会停止;任务失败后需核对 tag、draft 及资产状态。原手动 tag 发布流程保持。
1 change: 1 addition & 0 deletions tests/admin-panel.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ function _t($message): string { return $message; }
throw new RuntimeException('分页、明确发布或原生上传幂等字段缺失');
}
if (strpos($html, 'data-loaded="1"') !== false) { throw new RuntimeException('摘要首屏不应加载图片详情'); }
if (strpos($html, '只读健康检查') === false || strpos($html, '在隔离环境做一次恢复演练') === false) { throw new RuntimeException('管理员健康检查与恢复步骤缺失'); }
echo '后台模板:' . count($forms[1]) . " 个原生表单令牌、分页、草稿与按需详情检查通过\n";
} finally {
foreach (['header.php', 'menu.php', 'footer.php'] as $name) { unlink($adminDir . '/' . $name); }
Expand Down
1 change: 1 addition & 0 deletions tests/admin-workflow.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ public static function opt($name, $default) { return $default; }
namespace TypechoPlugin\InfinityTime\Lib {
function is_uploaded_file($path): bool { return is_file($path); }
class ImageRepository {
public static function uploadRoot(): string { return sys_get_temp_dir(); }
public static bool $locked = false;
public static bool $busy = false;
public static bool $failSync = false;
Expand Down
39 changes: 39 additions & 0 deletions tests/data-saver.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const source = fs.readFileSync(require.resolve('../usr/themes/InfinityTime/assets/js/data-saver.js'), 'utf8');
function run(saved, network, blocked = false) {
const listeners = {}, networkListeners = {}, changes = [];
const control = { value: '', parentNode: { hidden: true }, addEventListener(type, fn) { this[type] = fn; } };
const status = {};
const document = { hidden: false, getElementById: id => id.endsWith('-status') ? status : control };
const connection = network && { ...network, addEventListener(type, fn) { networkListeners[type] = fn; } };
const localStorage = { getItem() { if (blocked) throw Error('blocked'); return saved; }, setItem(key, value) { if (blocked) throw Error('blocked'); saved = value; } };
const window = { addEventListener(type, fn) { listeners[type] = fn; }, dispatchEvent(event) { changes.push(event.detail); } };
vm.runInNewContext(source, { document, window, navigator: { connection }, localStorage, CustomEvent: class { constructor(type, options) { this.detail = options.detail; } } });
return { window, document, control, status, changes, connection, listeners, networkListeners, select(value) { control.value = value; control.change(); } };
}
for (const network of [null, {}, { saveData: false, effectiveType: '4g' }]) {
const h = run(null, network);
assert.equal(h.window.InfinityDataSaver.isEnabled(), false);
assert.equal(h.control.parentNode.hidden, false);
h.select('on'); assert.equal(h.window.InfinityDataSaver.allowBackground(), false);
h.select('off'); assert.equal(h.window.InfinityDataSaver.allowBackground(), true);
h.document.hidden = true; assert.equal(h.window.InfinityDataSaver.allowBackground(), false);
}
for (const network of [{ saveData: true }, { effectiveType: '2g' }, { effectiveType: 'slow-2g' }]) {
const h = run('auto', network);
assert.equal(h.window.InfinityDataSaver.isEnabled(), true);
h.select('off'); assert.equal(h.window.InfinityDataSaver.isEnabled(), false, '显式正常模式覆盖系统省流');
h.select('auto'); assert.equal(h.window.InfinityDataSaver.isEnabled(), true);
}
const blocked = run('off', { saveData: true }, true);
blocked.select('off'); assert.equal(blocked.window.InfinityDataSaver.isEnabled(), false, '存储拒绝不影响当前选择');
const h = run('on', {});
h.listeners.storage({ key: 'unrelated', newValue: 'off' }); assert.equal(h.window.InfinityDataSaver.isEnabled(), true);
h.listeners.storage({ key: 'InfinityTime.dataSaver', newValue: 'off' }); assert.equal(h.window.InfinityDataSaver.isEnabled(), false);
h.connection.saveData = true; h.select('auto'); h.networkListeners.change(); assert.equal(h.window.InfinityDataSaver.isEnabled(), true);
h.listeners.storage({ key: null, newValue: null }); assert.equal(h.control.value, 'auto');
assert.equal(run('invalid', {}).control.value, 'auto');
console.log('省流策略:系统建议、显式覆盖、存储拒绝、跨页同步和后台暂停通过');
46 changes: 39 additions & 7 deletions tests/gallery-loading-browser.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ function card(id, cid, images, ids, options = {}) {
data-exif='[]' data-descs='[]' data-addresses='[]'><img class="my-photo" src="${previews[0]}" alt="Album ${id}"></a><h2>Album ${id}</h2></article>`;
}
function pageHtml(cards, next = '/page2', navigation = false) {
return `<!doctype html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="/assets/css/main.css"><link rel="stylesheet" href="/assets/css/gallery.css"><style>#main{display:block;min-height:2000px}.thumb{max-width:240px}</style></head><body><header id="header"></header><div id="wrapper"><main id="main"><div id="waterfall"><h2 class="gallery-month-label" id="month-2026-10">2026-10</h2>${cards}</div><nav id="gallery-pagination"><a rel="next" href="${next}">下一页</a></nav><div id="load-more" data-next-url="${next}" data-page="1" data-total-pages="2"></div></main></div>
${['init', 'jquery.min', 'jquery.poptrox.min', 'browser.min', 'breakpoints.min', 'lightbox', 'main'].concat(navigation ? ['gallery-navigation'] : []).map(n => `<script src="/assets/js/${n}.js"></script>`).join('')}</body></html>`;
return `<!doctype html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="/assets/css/main.css"><link rel="stylesheet" href="/assets/css/gallery.css"><style>#main{display:block;min-height:2000px}.thumb{max-width:240px}</style></head><body><header id="header"></header><div id="wrapper"><main id="main"><label hidden><select id="gallery-data-saver"><option value="auto">auto</option><option value="on">on</option><option value="off">off</option></select><span id="gallery-data-saver-status"></span></label><div id="waterfall"><h2 class="gallery-month-label" id="month-2026-10">2026-10</h2>${cards}</div><nav id="gallery-pagination"><a rel="next" href="${next}">下一页</a></nav><div id="load-more" data-next-url="${next}" data-page="1" data-total-pages="2"></div></main></div>
${['data-saver', 'init', 'jquery.min', 'jquery.poptrox.min', 'browser.min', 'breakpoints.min', 'lightbox', 'main'].concat(navigation ? ['gallery-navigation'] : []).map(n => `<script src="/assets/js/${n}.js"></script>`).join('')}</body></html>`;
}
const initial = card('a', 1, [url('a'), url('b'), url('pano'), url('slow')], ['p-1', 'p-2', 'p-3', 'p-4'], {
variants: [variants('a'), variants('b'), variants('pano')], panos: [0, 0, 1, 0]
Expand Down Expand Up @@ -100,10 +100,20 @@ const pixel = '<svg xmlns="http://www.w3.org/2000/svg" width="640" height="400">
assert.equal(await page.evaluate(target => InfinityGallery.open(target), { albumId, photoId }), true);
}
async function ready(name) {
await page.waitForFunction(source => {
const p = document.querySelector('.poptrox-popup'), img = p && p.querySelector('.pic img');
return img && img.getAttribute('src') === source && img.complete && img.naturalWidth && !p.classList.contains('loading') && img.style.opacity === '1';
}, url(name));
try {
await page.waitForFunction(source => {
const p = document.querySelector('.poptrox-popup'), img = p && p.querySelector('.pic img');
return img && img.getAttribute('src') === source && img.complete && img.naturalWidth && !p.classList.contains('loading') && img.style.opacity === '1';
}, url(name));
} catch (error) {
console.error('图片就绪超时诊断', await page.evaluate(() => {
const p = document.querySelector('.poptrox-popup'), img = p && p.querySelector('.pic img');
return { hidden: document.hidden, src: img && img.getAttribute('src'), selected: img && img.currentSrc,
complete: img && img.complete, width: img && img.naturalWidth, opacity: img && img.style.opacity,
popup: p && p.className, switching: p && p.__switching, events: window.__galleryEvents.slice(-8) };
}));
throw error;
}
await page.waitForTimeout(500);
}
async function close() {
Expand Down Expand Up @@ -289,9 +299,31 @@ const pixel = '<svg xmlns="http://www.w3.org/2000/svg" width="640" height="400">
await page.waitForFunction(() => !new URL(location.href).searchParams.has('photo'));
await page.waitForFunction(() => getComputedStyle(document.querySelector('.poptrox-overlay')).display === 'none');
await page.goto(origin + '/?album=1&photo=p-5'); await ready('other-month');
await page.keyboard.press('Escape');
// 关闭直接分享链接会真正重载相册页,不能只等 replaceState 改了地址。
await Promise.all([page.waitForEvent('load'), page.keyboard.press('Escape')]);
await page.waitForFunction(() => !new URL(location.href).searchParams.has('photo'));
assert.equal(new URL(page.url()).searchParams.get('album'), '1');
// 省流模式在真实浏览器中禁止自动分页和相邻大图预取,主动操作仍能成功。
await page.goto(origin);
await page.selectOption('#gallery-data-saver', 'on');
await page.reload();
assert.equal(await page.locator('#gallery-data-saver').inputValue(), 'on', '刷新保留显式省流选择');
const pageRequestsBefore = requests.filter(path => path === '/page2').length;
await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); window.dispatchEvent(new Event('scroll')); });
await page.waitForTimeout(150);
assert.equal(requests.filter(path => path === '/page2').length, pageRequestsBefore, '省流禁止自动下一页');
assert.equal(await page.evaluate(() => InfinityWaterfall.loadMore()), true, '省流允许手动加载下一页');
await page.evaluate(html => {
document.querySelector('#waterfall').insertAdjacentHTML('beforeend', html);
window.__rebindPoptrox();
}, card('saving', 99, [url('saving-current'), url('saving-next')], ['p-991', 'p-992']));
await open('99', 'p-991'); await ready('saving-current');
await page.waitForTimeout(150);
assert.equal(requests.some(path => path.includes('saving-next')), false, '省流不预取邻图');
await page.keyboard.press('ArrowRight'); await ready('saving-next');
await close();
await page.selectOption('#gallery-data-saver', 'off');
assert.equal(await page.evaluate(() => InfinityDataSaver.isEnabled()), false, '可以恢复正常模式');
assert.deepEqual(errors, []);
await context.close();
console.log(`图册加载、降级和重试回归测试通过:${browserName} ${viewport.width}px`);
Expand Down
25 changes: 25 additions & 0 deletions tests/health-diagnostics.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php
require __DIR__ . '/../usr/plugins/InfinityTime/Lib/HealthDiagnostics.php';
use TypechoPlugin\InfinityTime\Lib\HealthDiagnostics;
final class HealthDb {
public function getAdapterName(): string { return 'Pdo_SQLite'; }
}
$dir = sys_get_temp_dir() . '/infinity-health-' . bin2hex(random_bytes(6));
mkdir($dir, 0700);
try {
$before = scandir($dir);
$report = HealthDiagnostics::collect(new HealthDb(), $dir, ['imagick' => false]);
if (count($report) !== 8 || scandir($dir) !== $before) { throw new RuntimeException('自检条目或只读约束失败'); }
$byName = array_column($report, null, 'name');
if ($byName['私有原图保护']['status'] !== 'manual' || $byName['数据库事务']['status'] !== 'manual' || $byName['HEIC/AVIF 工具']['status'] !== 'warning') { throw new RuntimeException('未知结果不能冒充验证通过'); }
$missing = HealthDiagnostics::collect(new HealthDb(), $dir . '/missing', ['heif' => '/private/tool']);
if ($missing[0]['status'] !== 'warning' || $missing[1]['status'] !== 'manual') { throw new RuntimeException('缺失目录需清楚报告'); }
if (strpos(json_encode($missing), '/private/tool') !== false || strpos(json_encode($report), $dir) !== false) { throw new RuntimeException('诊断不得暴露服务器路径'); }
if (is_dir($dir . '/missing')) { throw new RuntimeException('不能自动创建目录'); }
define('__TYPECHO_ADMIN__', true);
$user = new class { public function pass($role, $return): bool { return false; } };
ob_start();
include __DIR__ . '/../usr/plugins/InfinityTime/health-panel.php';
if (ob_get_clean() !== '') { throw new RuntimeException('非管理员不得读取健康报告'); }
echo "只读健康检查:目录/磁盘/工具/手工核验边界及无写入检查通过\n";
} finally { rmdir($dir); }
Loading
Loading