diff --git a/agents.md b/agents.md index 819ac89..4e3defd 100644 --- a/agents.md +++ b/agents.md @@ -180,7 +180,7 @@ tests/scripts/windows-installer.sh 假应用的安装程序:所选目录原 tests/scripts/types.sh packaging/types.tsv 的文件类型在 Linux 桌面项、Windows 安装程序与 macOS 文档类型里一致,从不设为默认 tests/scripts/layout.sh 真窗口:六个应用在 640、800、1100 宽下无元素越出窗口左右,留截图(需辅助功能与屏幕录制权限) tests/scripts/ooxml.py <用例> <文件> 不经 LibreOffice 读 DOCX、XLSX、PPTX 并按自算的预期核对(往返用例调用) -cd site && pnpm deploy 官网构建并经 wrangler 发到 Cloudflare Workers(静态资源,另有 `site/worker` 只答 `/release.json`:GitHub API 的最新版本与全部发布的下载总数(不计更新清单 `.json`),缓存 5 分钟;下载链接指向 `releases/latest`),地址 https://ely-office.zhangyanghaha0407.workers.dev +cd site && pnpm deploy 官网构建并经 wrangler 发到 Cloudflare Workers(静态资源,另有 `site/worker` 只答 `/release.json`:GitHub API 的最新版本与全部发布的下载总数,加上 Gitea 上迁移前的下载(Gitea 不通时只计 GitHub;都不计更新清单 `.json`),缓存 5 分钟;下载链接指向 `releases/latest`),地址 https://ely-office.zhangyanghaha0407.workers.dev cd site && pnpm test && pnpm e2e 官网单测(几何、道路连通)与 Playwright 端到端(悬停、聚焦、按压、命中归属) cd site/video/scenes && npm run verify 发布视频:群像与离场两个渲染器在切场处屏幕角点一致,离场全程各屏播自己的录屏帧(真页面渲染) cargo check --workspace diff --git a/site/test/worker.test.ts b/site/test/worker.test.ts new file mode 100644 index 0000000..ffc3327 --- /dev/null +++ b/site/test/worker.test.ts @@ -0,0 +1,63 @@ +import { afterEach, test } from "node:test"; +import assert from "node:assert/strict"; +import worker from "../worker/index.ts"; + +const real = globalThis.fetch; +afterEach(() => { + globalThis.fetch = real; +}); + +const github = { + tag_name: "v0.1.9", + assets: [ + { name: "ElyDoc-0.1.9-arm64.dmg", download_count: 3 }, + { name: "ely-doc.json", download_count: 50 }, + ], +}; +const gitea = [ + { tag_name: "v0.1.8", assets: [{ name: "ElyDoc-0.1.8-arm64.dmg", download_count: 40 }, { name: "ely-doc.json", download_count: 900 }] }, + { tag_name: "v0.1.7", assets: [{ name: "ElyDoc-0.1.7-arm64.dmg", download_count: 7 }] }, +]; + +/** Answers GitHub with `github` and Gitea with `old`. */ +function hosts(old: () => Response) { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith("https://git.zacharyzhang.com/")) return old(); + if (url.endsWith("/releases/latest")) return Response.json(github); + return Response.json([github]); + }) as typeof fetch; +} + +async function answer() { + const warn = console.warn; + console.warn = () => {}; + try { + const res = await worker.fetch(new Request("https://office.elygpui.com/release.json"), {}); + assert.equal(res.status, 200); + return await res.json(); + } finally { + console.warn = warn; + } +} + +test("downloads add Gitea's history to GitHub's, without manifests", async () => { + hosts(() => Response.json(gitea)); + assert.deepEqual(await answer(), { version: "0.1.9", downloads: 3 + 40 + 7 }); +}); + +test("an unreachable, failing or odd Gitea leaves GitHub's count", async () => { + const cases: (() => Response)[] = [ + () => { + throw new TypeError("fetch failed"); + }, + () => new Response("down", { status: 502 }), + () => new Response("", { status: 200 }), + () => Response.json({ message: "not found" }), + () => Response.json([{ tag_name: "v0.1.8", assets: [{ name: "ElyDoc.dmg" }] }]), + ]; + for (const old of cases) { + hosts(old); + assert.deepEqual(await answer(), { version: "0.1.9", downloads: 3 }); + } +}); diff --git a/site/worker/index.ts b/site/worker/index.ts index 4c8fbc7..7b55004 100644 --- a/site/worker/index.ts +++ b/site/worker/index.ts @@ -1,6 +1,11 @@ // Answers /release.json with the newest release and downloads. const REPO = "https://api.github.com/repos/ELY-Open-Source/ElyOffice"; +// The old home keeps the downloads made before the move. +const GITEA = "https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/ElyOffice"; const PAGE = 100; +const GITEA_PAGE = 50; +// A slow Gitea must not hold up the page. +const GITEA_WAIT = 5000; type Cached = RequestInit & { cf: { cacheTtl: number; cacheEverything: boolean } }; type Asset = { name: string; download_count: number }; @@ -8,6 +13,8 @@ type Release = { tag_name: string; assets: Asset[] }; // A token lifts GitHub's limit for the shared egress addresses: `wrangler secret put GITHUB_TOKEN`. type Env = { GITHUB_TOKEN?: string }; +const cf = { cacheTtl: 300, cacheEverything: true }; + async function read(env: Env, path: string): Promise { const headers: Record = { accept: "application/vnd.github+json", @@ -15,12 +22,23 @@ async function read(env: Env, path: string): Promise { "x-github-api-version": "2022-11-28", }; if (env.GITHUB_TOKEN) headers.authorization = `Bearer ${env.GITHUB_TOKEN}`; - const init: Cached = { headers, cf: { cacheTtl: 300, cacheEverything: true } }; + const init: Cached = { headers, cf }; const res = await fetch(`${REPO}${path}`, init); if (!res.ok) throw new Error(`GitHub answered ${res.status} for ${path}`); return (await res.json()) as T; } +async function readGitea(path: string): Promise { + const init: Cached = { + headers: { accept: "application/json", "user-agent": "ely-office-site" }, + cf, + signal: AbortSignal.timeout(GITEA_WAIT), + }; + const res = await fetch(`${GITEA}${path}`, init); + if (!res.ok) throw new Error(`Gitea answered ${res.status} for ${path}`); + return (await res.json()) as T; +} + /** The newest release's version, without the v. */ async function version(env: Env): Promise { const { tag_name: tag } = await read(env, "/releases/latest"); @@ -28,26 +46,50 @@ async function version(env: Env): Promise { return tag.slice(1); } -/** Downloads of every release; update manifests are checks, not downloads. */ +/** Downloads of `releases`; update manifests are checks, not downloads. */ +function counted(releases: Release[]): number { + let total = 0; + for (const { assets } of releases) { + for (const a of assets) { + if (!Number.isInteger(a.download_count)) throw new Error(`${a.name} has no download count`); + if (!a.name.endsWith(".json")) total += a.download_count; + } + } + return total; +} + +/** Downloads of every GitHub release. */ async function downloads(env: Env): Promise { let total = 0; for (let page = 1; ; page++) { const releases = await read(env, `/releases?per_page=${PAGE}&page=${page}`); - for (const { assets } of releases) { - for (const a of assets) { - if (!Number.isInteger(a.download_count)) throw new Error(`${a.name} has no download count`); - if (!a.name.endsWith(".json")) total += a.download_count; - } - } + total += counted(releases); if (releases.length < PAGE) return total; } } +/** Downloads of every Gitea release; 0 when Gitea is down, slow or odd. */ +async function giteaDownloads(): Promise { + try { + let total = 0; + for (let page = 1; ; page++) { + const releases = await readGitea(`/releases?limit=${GITEA_PAGE}&page=${page}`); + if (!Array.isArray(releases)) throw new Error("Gitea listed no releases"); + total += counted(releases); + if (releases.length < GITEA_PAGE) return total; + } + } catch (e) { + // The page shows GitHub's count alone. + console.warn(`no Gitea downloads: ${e}`); + return 0; + } +} + export default { async fetch(_request: Request, env: Env): Promise { try { - const [v, n] = await Promise.all([version(env), downloads(env)]); - return Response.json({ version: v, downloads: n }, { headers: { "cache-control": "public, max-age=300" } }); + const [v, n, old] = await Promise.all([version(env), downloads(env), giteaDownloads()]); + return Response.json({ version: v, downloads: n + old }, { headers: { "cache-control": "public, max-age=300" } }); } catch (e) { return new Response(String(e), { status: 502 }); }