定时任务自动对比收尾:基准那一次不在终态或被删时不给对比、对比工件读不出来时触发历史照常打开;检查脚本按实际底色量对比度

- 触发历史的 compare:以前只看这一次的运行在不在终态。基准那一次被强制删除、失败后被继续执行时,对比页比不了,
  「对比上一次」链过去只会报错——基准一侧同样要在终态,否则给 null
- 对比工件读不出来(权限、磁盘出错)时记一条警告、给 null:以前 OSError 直接抛出,整页触发历史回 500。被改过
  (内容与哈希不符)的以前就给 null,这次补上用例
- check-schedules:页头的定时任务链接、触发历史的「对比上一次」改成量 WCAG 对比度(≥ 4.5),底色顺着祖先叠到
  不透明为止。以前比的是「字色不等于页头的底色」,页头本身透明,那样比永远成立;深色也补上同样的检查。另加一项
  署名的立即运行配长名字:署名照写、链接截断、页头不横向溢出
This commit is contained in:
ThearchyHelios
2026-10-06 07:43:01 +08:00
parent 863b0b2894
commit f8ac8dc1d6
4 changed files with 122 additions and 24 deletions
+7 -1
View File
@@ -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),
+5 -1
View File
@@ -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)
+48 -7
View File
@@ -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
# --------------------------------------------------------------------------
+62 -15
View File
@@ -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}`)