From f8ac8dc1d682208dc558a23a6041cce74718fa78 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 07:43:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=AF=B9=E6=AF=94=E6=94=B6=E5=B0=BE=EF=BC=9A=E5=9F=BA?= =?UTF-8?q?=E5=87=86=E9=82=A3=E4=B8=80=E6=AC=A1=E4=B8=8D=E5=9C=A8=E7=BB=88?= =?UTF-8?q?=E6=80=81=E6=88=96=E8=A2=AB=E5=88=A0=E6=97=B6=E4=B8=8D=E7=BB=99?= =?UTF-8?q?=E5=AF=B9=E6=AF=94=E3=80=81=E5=AF=B9=E6=AF=94=E5=B7=A5=E4=BB=B6?= =?UTF-8?q?=E8=AF=BB=E4=B8=8D=E5=87=BA=E6=9D=A5=E6=97=B6=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E7=85=A7=E5=B8=B8=E6=89=93=E5=BC=80=EF=BC=9B?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E8=84=9A=E6=9C=AC=E6=8C=89=E5=AE=9E=E9=99=85?= =?UTF-8?q?=E5=BA=95=E8=89=B2=E9=87=8F=E5=AF=B9=E6=AF=94=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 触发历史的 compare:以前只看这一次的运行在不在终态。基准那一次被强制删除、失败后被继续执行时,对比页比不了, 「对比上一次」链过去只会报错——基准一侧同样要在终态,否则给 null - 对比工件读不出来(权限、磁盘出错)时记一条警告、给 null:以前 OSError 直接抛出,整页触发历史回 500。被改过 (内容与哈希不符)的以前就给 null,这次补上用例 - check-schedules:页头的定时任务链接、触发历史的「对比上一次」改成量 WCAG 对比度(≥ 4.5),底色顺着祖先叠到 不透明为止。以前比的是「字色不等于页头的底色」,页头本身透明,那样比永远成立;深色也补上同样的检查。另加一项 署名的立即运行配长名字:署名照写、链接截断、页头不横向溢出 --- backend/app/api/schedules.py | 8 ++- backend/app/scheduler/auto_compare.py | 6 +- backend/tests/test_scheduler_compare.py | 55 +++++++++++++++--- scripts/check-schedules.mjs | 77 ++++++++++++++++++++----- 4 files changed, 122 insertions(+), 24 deletions(-) diff --git a/backend/app/api/schedules.py b/backend/app/api/schedules.py index b96d57b8..7931aaf3 100644 --- a/backend/app/api/schedules.py +++ b/backend/app/api/schedules.py @@ -282,7 +282,7 @@ async def _fire_outs(session: AsyncSession, fires: list[ScheduleFire]) -> list[d compare 是运行结束后和同一任务上一次的自动对比 {base_run_id, summary}(summary 同对比接口的计数),没有为 null。 运行又被继续执行、现在不在终态时 compare 也给 null:对比页只认结束并封存的运行,链过去只会被拒;再结束时 - 通知扫描会按新的封存重新比。工件读不出来、被改过的同样给 null。""" + 通知扫描会按新的封存重新比。基准那一次被删掉、被继续执行的,工件读不出来、被改过的,同样给 null。""" run_ids = [f.run_id for f in fires if f.run_id] statuses = dict((await session.execute(select(Run.id, Run.status).where(Run.id.in_(run_ids)))).all()) \ if run_ids else {} @@ -299,6 +299,12 @@ async def _fire_outs(session: AsyncSession, fires: list[ScheduleFire]) -> list[d compared = {f.id: f.compare_artifact for f in fires if f.compare_artifact and statuses.get(f.run_id) in auto_compare.TERMINAL} compares = await auto_compare.briefs(list(compared.values())) + # 基准一侧同样要在终态:被删掉(强制删除封存的正式运行)、失败后被继续执行的,对比页比不了 + base_ids = list({brief["base_run_id"] for brief in compares.values()}) + if base_ids: + bases = dict((await session.execute(select(Run.id, Run.status).where(Run.id.in_(base_ids)))).all()) + compares = {artifact: brief for artifact, brief in compares.items() + if bases.get(brief["base_run_id"]) in auto_compare.TERMINAL} return [{ "id": f.id, "fire_at": _iso(f.fire_at), diff --git a/backend/app/scheduler/auto_compare.py b/backend/app/scheduler/auto_compare.py index dc464b3f..90c15bc6 100644 --- a/backend/app/scheduler/auto_compare.py +++ b/backend/app/scheduler/auto_compare.py @@ -145,7 +145,8 @@ def _remember(artifact: str, brief: dict[str, Any]) -> None: def brief_of(artifact: str) -> dict[str, Any] | None: - """读一份对比工件的摘要。文件不在、内容和哈希对不上(另记一条警告)、形状不对的返回 None,不抛异常。""" + """读一份对比工件的摘要。文件不在、内容和哈希对不上、读不出来(后两种另记一条警告)、形状不对的返回 None, + 不抛异常:一份对比读不出来不能让整页触发历史打不开。""" with _BRIEFS_LOCK: known = _BRIEFS.get(artifact) if known is not None: @@ -155,6 +156,9 @@ def brief_of(artifact: str) -> dict[str, Any] | None: except ValueError: logger.warning("对比工件 %s… 内容与哈希不符,触发历史中不显示这次对比", artifact[:12]) return None + except OSError as e: # 权限、磁盘出错 + logger.warning("对比工件 %s… 读取失败(%s),触发历史中不显示这次对比", artifact[:12], type(e).__name__) + return None if brief is None: return None _remember(artifact, brief) diff --git a/backend/tests/test_scheduler_compare.py b/backend/tests/test_scheduler_compare.py index 480bfb53..d7f79238 100644 --- a/backend/tests/test_scheduler_compare.py +++ b/backend/tests/test_scheduler_compare.py @@ -25,6 +25,7 @@ import pytest from httpx import ASGITransport, AsyncClient from sqlalchemy import delete, select +from app.core import artifact_store from app.core.artifact_store import canonical_json, load, manifest_hash, put_json from app.core.crypto import encrypt from app.db.base import SessionLocal @@ -340,16 +341,56 @@ async def test_compare_is_hidden_once_the_run_is_running_again(client): assert item["compare"] == {"base_run_id": base_id, "summary": SUMMARY} -async def test_a_missing_artifact_reads_as_no_compare(client): - """工件文件不在了、被改过:接口给 null,不报错""" +async def test_compare_is_hidden_once_the_base_run_is_gone_or_running(client): + """基准那一次被删掉、或者被继续执行(不在终态):对比页比不了,链过去只会报错,接口同样给 null""" + sid, base_id, _, fire_id = await weekly_pair(url=None) + await webhook.notify_due() + assert (await fire_row(fire_id)).compare_artifact + + async def shown(): + items = (await client.get(f"/api/schedules/{sid}/fires")).json()["items"] + return {f["id"]: f for f in items}[fire_id]["compare"] + + assert (await shown())["base_run_id"] == base_id + await set_run(base_id, status="running") + assert await shown() is None + await set_run(base_id, status="succeeded") + assert (await shown())["base_run_id"] == base_id + # 封存过的正式运行要强制删除 + assert (await client.delete(f"/api/runs/{base_id}", params={"force": "true"})).status_code == 204 + assert await shown() is None + + +def unreadable(artifact_id): + raise PermissionError(13, "Permission denied") + + +@pytest.mark.parametrize("damage", ["missing", "tampered", "unreadable"]) +async def test_a_damaged_artifact_reads_as_no_compare(client, monkeypatch, caplog, damage): + """工件文件不在了、被改过、读不出来(权限、磁盘出错):接口给 null,触发历史照常打开,不回 500""" sid, _, _, fire_id = await weekly_pair(url=None) - async with SessionLocal() as session: - await session.execute(ScheduleFire.__table__.update().where(ScheduleFire.id == fire_id) - .values(compare_artifact="0" * 64)) - await session.commit() - resp = await client.get(f"/api/schedules/{sid}/fires") + await webhook.notify_due() + artifact = (await fire_row(fire_id)).compare_artifact + # 刚比完的摘要记在进程里,清掉才会去读盘 + auto_compare._BRIEFS.clear() + if damage == "missing": + async with SessionLocal() as session: + await session.execute(ScheduleFire.__table__.update().where(ScheduleFire.id == fire_id) + .values(compare_artifact="0" * 64)) + await session.commit() + elif damage == "tampered": + path = artifact_store._path_of(artifact) + text = path.read_text(encoding="utf-8") + assert '"data_only":2' in text + path.write_text(text.replace('"data_only":2', '"data_only":7'), encoding="utf-8") + else: + monkeypatch.setattr(artifact_store, "load", unreadable) + with caplog.at_level(logging.WARNING, logger="app.scheduler"): + resp = await client.get(f"/api/schedules/{sid}/fires") assert resp.status_code == 200 assert {f["id"]: f for f in resp.json()["items"]}[fire_id]["compare"] is None + if damage != "missing": + assert artifact[:12] in caplog.text # -------------------------------------------------------------------------- diff --git a/scripts/check-schedules.mjs b/scripts/check-schedules.mjs index d0ffbc2e..b720db49 100644 --- a/scripts/check-schedules.mjs +++ b/scripts/check-schedules.mjs @@ -671,6 +671,40 @@ const inViewX = (page, sel) => page.evaluate((s) => { const b = el.getBoundingClientRect() return b.left >= 0 && b.right <= innerWidth + 0.5 }, sel) +/** + * 元素的字和它实际落在上面的底色的对比度(WCAG 算法),{ratio, fg, bg},找不到元素为 null。底色顺着祖先往上找, + * 半透明的一层层叠上去,直到不透明的那层(都透明就按白底)。只比「字色和某个容器的底色不相等」不够:页头本身是 + * 透明的,那样比永远成立 + */ +const readable = (page, sel) => page.evaluate((s) => { + const el = document.querySelector(s) + if (!el) return null + const parse = (c) => { + const m = c.match(/color\(srgb ([\d.]+) ([\d.]+) ([\d.]+)(?: \/ ([\d.]+))?\)/) + if (m) return [m[1] * 255, m[2] * 255, m[3] * 255, m[4] == null ? 1 : Number(m[4])] + const n = c.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/) + return n ? [Number(n[1]), Number(n[2]), Number(n[3]), n[4] == null ? 1 : Number(n[4])] : null + } + const over = (top, under) => top.slice(0, 3).map((v, i) => v * top[3] + under[i] * (1 - top[3])) + const layers = [] + for (let n = el; n; n = n.parentElement) { + const c = parse(getComputedStyle(n).backgroundColor) + if (c && c[3] > 0) layers.push(c) + if (c && c[3] >= 1) break + } + let bg = [255, 255, 255] + for (const layer of layers.reverse()) bg = over(layer, bg) + const color = parse(getComputedStyle(el).color) + if (!color) return null + const fg = over(color, bg) + const lum = (rgb) => { + const [r, g, b] = rgb.map((v) => { const x = v / 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4 }) + return 0.2126 * r + 0.7152 * g + 0.0722 * b + } + const [hi, lo] = [lum(fg), lum(bg)].sort((a, b) => b - a) + return { ratio: Math.round(((hi + 0.05) / (lo + 0.05)) * 100) / 100, fg: getComputedStyle(el).color, + bg: `rgb(${bg.map(Math.round).join(', ')})` } +}, sel) // ---------------------------------------------------------------- 列表 @@ -1432,6 +1466,8 @@ await section('运行详情:页头的定时任务链接', async () => { check('键盘:Tab 到得了页头的定时任务链接', presses > 0, `按了 ${presses} 次`) const ring = await page.evaluate(() => getComputedStyle(document.activeElement).boxShadow) check('……聚焦时有焦点环', !!ring && ring !== 'none', ring) + const darkTone = await readable(page, '[data-run-detail] header [data-run-schedule-link]') + check('深色:页头的定时任务链接对比度 ≥ 4.5', !!darkTone && darkTone.ratio >= 4.5, JSON.stringify(darkTone)) await shot(page, 'run-detail-schedule-link-dark') await page.keyboard.press('Enter') check('……回车去那个定时任务', await until(() => new URL(page.url()).pathname === '/schedules/s-weekly'), page.url()) @@ -1450,13 +1486,10 @@ await section('运行详情:页头的定时任务链接', async () => { await shot(manual.page, 'run-detail-manual-fire-dark') const light = await open('/runs/run-sched-1', { state: freshState({ runs: [...RUNS, RUN_MANUAL_FIRE] }), theme: 'light' }) await light.page.locator('[data-run-detail="run-sched-1"] header [data-run-schedule-link]').waitFor() - const lightTone = await light.page.evaluate(() => { - const link = document.querySelector('[data-run-detail] header [data-run-schedule-link]') - const head = document.querySelector('[data-run-detail] header') - return { theme: document.documentElement.dataset.theme, fg: getComputedStyle(link).color, - bg: getComputedStyle(head).backgroundColor } - }) - check('浅色:页头的定时任务链接字和底色分得开', lightTone.theme === 'light' && lightTone.fg !== lightTone.bg, JSON.stringify(lightTone)) + const lightTheme = await light.page.evaluate(() => document.documentElement.dataset.theme) + const lightTone = await readable(light.page, '[data-run-detail] header [data-run-schedule-link]') + check('浅色:页头的定时任务链接对比度 ≥ 4.5', lightTheme === 'light' && !!lightTone && lightTone.ratio >= 4.5, + `${lightTheme} ${JSON.stringify(lightTone)}`) await shot(light.page, 'run-detail-schedule-link-light') const plain = await open('/runs/run-manual-1', { state: freshState({ runs: [...RUNS, RUN_MANUAL_FIRE] }) }) await plain.page.locator('[data-run-detail="run-manual-1"]').waitFor({ timeout: 10000 }) @@ -1480,12 +1513,30 @@ await section('运行详情:页头的定时任务链接', async () => { }) check('长名字:链接截断,不超出页头、页头不横向溢出', !!fit && fit.linkRight <= fit.headRight && fit.scroll <= 1 && fit.clipped, JSON.stringify(fit)) + // 署名的立即运行(署名加链接,这一行最挤)配长名字:链接照样截断,署名不被挤掉 + const crowdedState = freshState({ runs: [...RUNS, RUN_MANUAL_FIRE] }) + crowdedState.schedules = longName.schedules + const crowded = await open('/runs/run-sched-2', { state: crowdedState, width: 900 }) + await crowded.page.locator('[data-run-detail="run-sched-2"]').waitFor({ timeout: 10000 }) + const cLink = crowded.page.locator('[data-run-detail] header [data-run-schedule-link]') + await until(async () => (await cLink.innerText().catch(() => '')).includes('复盘')) + const crowd = await crowded.page.evaluate(() => { + const head = document.querySelector('[data-run-detail] header') + const link = head?.querySelector('[data-run-schedule-link]') + if (!head || !link) return null + const l = link.getBoundingClientRect() + return { headRight: Math.round(head.getBoundingClientRect().right), linkRight: Math.round(l.right), + linkWidth: Math.round(l.width), scroll: head.scrollWidth - head.clientWidth, + starter: head.innerText.includes('检查员甲 发起') } + }) + check('……署名的立即运行配长名字:署名照写,链接截断、看得到,页头不横向溢出', !!crowd && crowd.starter + && crowd.linkRight <= crowd.headRight && crowd.linkWidth >= 24 && crowd.scroll <= 1, JSON.stringify(crowd)) // 回车打开的定时任务页会请求预览(POST,但只读),不算写 const writesSeen = [...writes(sent, /./), ...writes(manual.sent, /./), ...writes(light.sent, /./), ...writes(plain.sent, /./), - ...writes(narrow.sent, /./)] + ...writes(narrow.sent, /./), ...writes(crowded.sent, /./)] .filter((x) => x.key !== 'POST /schedules/preview') check('只读:没有写请求', writesSeen.length === 0, writesSeen.map((x) => x.key).join('、')) - const all = [...errors, ...manual.errors, ...light.errors, ...plain.errors, ...narrow.errors] + const all = [...errors, ...manual.errors, ...light.errors, ...plain.errors, ...narrow.errors, ...crowded.errors] check('没有页面报错', !all.length, all.join(' | ')) }) @@ -1571,12 +1622,8 @@ await section('窄屏 390px 与浅色', async () => { await hist.page.locator('[data-fire-id]').first().waitFor() check(`${theme}:触发历史不横向溢出`, await overflow(hist.page) <= 1, `${await overflow(hist.page)}px`) check(`${theme}:「对比上一次」在屏幕里`, await inViewX(hist.page, '[data-fire-id="f00"] [data-fire-compare]')) - const cmp = await hist.page.evaluate(() => { - const link = document.querySelector('[data-fire-id="f00"] [data-fire-compare]') - const list = document.querySelector('[data-fire-list] ul') - return link && list && { fg: getComputedStyle(link).color, bg: getComputedStyle(list).backgroundColor } - }) - check(`${theme}:「对比上一次」的字和底色分得开`, !!cmp && cmp.fg !== cmp.bg, JSON.stringify(cmp)) + const cmp = await readable(hist.page, '[data-fire-id="f00"] [data-fire-compare]') + check(`${theme}:「对比上一次」对比度 ≥ 4.5`, !!cmp && cmp.ratio >= 4.5, JSON.stringify(cmp)) check(`${theme}:页头的开关、立即运行、删除在屏幕里`, await inViewX(hist.page, 'header [data-schedule-run]') && await inViewX(hist.page, 'header [data-schedule-toggle]') && await inViewX(hist.page, 'header [data-schedule-delete]')) await shot(hist.page, `history-390-${theme}`)