diff --git a/changelog.d/1227-delivery-countdown.fixed.md b/changelog.d/1227-delivery-countdown.fixed.md new file mode 100644 index 000000000..ebf901d8c --- /dev/null +++ b/changelog.d/1227-delivery-countdown.fixed.md @@ -0,0 +1 @@ +The failure screen now counts down instead of freezing: `Retrying in 120 s` drops a second at a time while a retry is really scheduled, and a fresh or loading board starts no timer at all. The rate-limit and timeout hints also say who retries — `while this page is open it retries on its own` — because the host keeps no timer and a closed page retries nothing. ([#1227](https://github.com/ThinkFlowLab/FlyBot/issues/1227)) diff --git a/core/client.js b/core/client.js index fd4438ef9..498ff3707 100644 --- a/core/client.js +++ b/core/client.js @@ -1378,7 +1378,6 @@ var HOST_ZH = { "First load in progress, {seconds} s elapsed": "首次加载中,已 {seconds} 秒", "Showing the snapshot from {time} · refresh failed: {message}": "显示 {time} 的快照 · 刷新失败:{message}", "The first round reads the repository once; the columns appear as soon as it lands.": "首轮要把这个仓读一遍,列会在拿到的同时出现。", - "It retries on its own (10 s → 30 s → 2 min).": "会自动重试(10 秒 → 30 秒 → 2 分钟)。", "While this page is open it retries on its own; press Retry to try again now.": "页面开着时自动重试,也可点重试立即再试。", "Retrying in {seconds} s · press Retry to try again now": "{seconds} 秒后自动重试,也可点重试立即再试。", "Checks unknown": "检查结果未取到", @@ -1390,16 +1389,16 @@ var HOST_ZH = { Close: "关闭", "this repository": "这个仓", "Add a GITHUB_TOKEN in this instance's settings — one that can read {repo}.": "在这个实例的设置里填一个 GITHUB_TOKEN,要能读 {repo}。", - "GitHub is rate-limiting this instance; it retries on its own (10 s → 30 s → 2 min).": "GitHub 在限流这个实例,会自动重试(10 秒 → 30 秒 → 2 分钟)。", - "GitHub is rate-limiting this instance; it retries on its own in {seconds} s.": "GitHub 在限流这个实例,{seconds} 秒后自动重试。", - "GitHub did not answer in time; the next attempt is automatic.": "GitHub 没在时限内回应,下一次重试会自动进行。", + "GitHub is rate-limiting this instance; while this page is open it retries on its own (10 s → 30 s → 2 min).": "GitHub 在限流这个实例,页面开着时自动重试(10 秒 → 30 秒 → 2 分钟)。", + "GitHub is rate-limiting this instance; while this page is open it retries on its own in {seconds} s.": "GitHub 在限流这个实例,页面开着时 {seconds} 秒后自动重试。", + "GitHub did not answer in time; the next attempt happens while this page is open.": "GitHub 没在时限内回应,页面开着时下一次重试会自动进行。", "Give the token Actions: Read.": "去设置里给 token 勾 Actions: Read。", "Give the token Checks: Read.": "去设置里给 token 勾 Checks: Read。", "Give the token Issues: Read.": "去设置里给 token 勾 Issues: Read。", "Give the token Pull requests: Read.": "去设置里给 token 勾 Pull requests: Read。", "Check the repository name, and that the token can see {repo}.": "检查仓库名对不对、token 有没有 {repo} 的访问权。", "GitHub is not answering this instance right now; retry in a moment.": "GitHub 现在没回应这个实例,稍后重试。", - "The next attempt is automatic; the reason above names what failed.": "下一次重试会自动进行,上面那句话说明是哪一步失败了。", + "The next attempt happens while this page is open; the reason above names what failed.": "页面开着时下一次重试会自动进行,上面那句话说明是哪一步失败了。", "{part} was not fetched this time: {message}": "{part}这次没取到:{message}", "CI data": "CI 数据", "The rest of the board": "看板剩下的部分", @@ -8088,9 +8087,9 @@ function hintFor(error, { repo = null } = {}) { const target = repo ?? t("this repository"); if (kind === "no-token") return t("Add a GITHUB_TOKEN in this instance's settings — one that can read {repo}.", { repo: target }); if (kind === "rate-limit") { - return error.retryAfterMs === null || error.retryAfterMs === void 0 ? t("GitHub is rate-limiting this instance; it retries on its own (10 s → 30 s → 2 min).") : t("GitHub is rate-limiting this instance; it retries on its own in {seconds} s.", { seconds: secondsOf(error.retryAfterMs) }); + return error.retryAfterMs === null || error.retryAfterMs === void 0 ? t("GitHub is rate-limiting this instance; while this page is open it retries on its own (10 s → 30 s → 2 min).") : t("GitHub is rate-limiting this instance; while this page is open it retries on its own in {seconds} s.", { seconds: secondsOf(error.retryAfterMs) }); } - if (kind === "timeout") return t("GitHub did not answer in time; the next attempt is automatic."); + if (kind === "timeout") return t("GitHub did not answer in time; the next attempt happens while this page is open."); if (scope === "actions" && (kind === "not-found" || kind === "forbidden")) return t("Give the token Actions: Read."); if (scope === "checks" && (kind === "not-found" || kind === "forbidden")) return t("Give the token Checks: Read."); if ((scope === "comments" || scope === "issues") && (kind === "not-found" || kind === "forbidden")) return t("Give the token Issues: Read."); @@ -8099,7 +8098,7 @@ function hintFor(error, { repo = null } = {}) { return t("Check the repository name, and that the token can see {repo}.", { repo: target }); } if (kind === "server" || kind === "network") return t("GitHub is not answering this instance right now; retry in a moment."); - return t("The next attempt is automatic; the reason above names what failed."); + return t("The next attempt happens while this page is open; the reason above names what failed."); } function missingText(entry) { const status = entry?.status === null || entry?.status === void 0 ? "" : ` · ${entry.status}`; @@ -8320,7 +8319,16 @@ function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, onProbe = function DeliveryBoard({ view, snapshot, message = "", repo = null, onRetry = null, onProbe = null }) { const payload = view ?? (snapshot ? { ...snapshot, state: "fresh" } : { state: "idle" }); const state = payload.state ?? "fresh"; - const countdown = retryCountdown(payload.retryAt); + const retryAt = Number(payload.retryAt); + const ticking = (state === "failed" || state === "stale") && Number.isFinite(retryAt) && retryAt > 0; + const [now, setNow] = (0, import_react14.useState)(() => Date.now()); + (0, import_react14.useEffect)(() => { + if (!ticking) return void 0; + setNow(Date.now()); + const timer = setInterval(() => setNow(Date.now()), 1e3); + return () => clearInterval(timer); + }, [ticking, retryAt]); + const countdown = retryCountdown(payload.retryAt, now); if (state === "idle") { return (0, import_react14.createElement)( "div", diff --git a/core/src/client/delivery-view.js b/core/src/client/delivery-view.js index 4074a74a1..7c08b127a 100644 --- a/core/src/client/delivery-view.js +++ b/core/src/client/delivery-view.js @@ -86,11 +86,12 @@ export function hintFor(error, { repo = null } = {}) { if (kind === 'no-token') return t('Add a GITHUB_TOKEN in this instance\'s settings — one that can read {repo}.', { repo: target }) if (kind === 'rate-limit') { return error.retryAfterMs === null || error.retryAfterMs === undefined - // No number from GitHub means no number here: the backoff is a rule, not a measurement. - ? t('GitHub is rate-limiting this instance; it retries on its own (10 s → 30 s → 2 min).') - : t('GitHub is rate-limiting this instance; it retries on its own in {seconds} s.', { seconds: secondsOf(error.retryAfterMs) }) + // No number from GitHub means no number here: the backoff is a rule, not a measurement. The + // retrying is the open page's job — the host installs no timer (#1173) (#1227). + ? t('GitHub is rate-limiting this instance; while this page is open it retries on its own (10 s → 30 s → 2 min).') + : t('GitHub is rate-limiting this instance; while this page is open it retries on its own in {seconds} s.', { seconds: secondsOf(error.retryAfterMs) }) } - if (kind === 'timeout') return t('GitHub did not answer in time; the next attempt is automatic.') + if (kind === 'timeout') return t('GitHub did not answer in time; the next attempt happens while this page is open.') if (scope === 'actions' && (kind === 'not-found' || kind === 'forbidden')) return t('Give the token Actions: Read.') if (scope === 'checks' && (kind === 'not-found' || kind === 'forbidden')) return t('Give the token Checks: Read.') if ((scope === 'comments' || scope === 'issues') && (kind === 'not-found' || kind === 'forbidden')) return t('Give the token Issues: Read.') @@ -99,7 +100,7 @@ export function hintFor(error, { repo = null } = {}) { return t('Check the repository name, and that the token can see {repo}.', { repo: target }) } if (kind === 'server' || kind === 'network') return t('GitHub is not answering this instance right now; retry in a moment.') - return t('The next attempt is automatic; the reason above names what failed.') + return t('The next attempt happens while this page is open; the reason above names what failed.') } // A part that could not be read is named where it is missing, not hidden (#1211). @@ -297,7 +298,19 @@ export function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, on export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRetry = null, onProbe = null }) { const payload = view ?? (snapshot ? { ...snapshot, state: 'fresh' } : { state: 'idle' }) const state = payload.state ?? 'fresh' - const countdown = retryCountdown(payload.retryAt) + // The countdown promises a number that moves, so the screen redraws once a second while a retry + // is actually scheduled — and only then: a fresh or loading board must not tick (#1227). The + // timer lives in the browser, not in the host, which installs none (#1173). + const retryAt = Number(payload.retryAt) + const ticking = (state === 'failed' || state === 'stale') && Number.isFinite(retryAt) && retryAt > 0 + const [now, setNow] = useState(() => Date.now()) + useEffect(() => { + if (!ticking) return undefined + setNow(Date.now()) + const timer = setInterval(() => setNow(Date.now()), 1_000) + return () => clearInterval(timer) + }, [ticking, retryAt]) + const countdown = retryCountdown(payload.retryAt, now) // "No snapshot yet." belongs to a host that has never tried; every attempt has a better word. if (state === 'idle') { return h('div', { className: 'bt-del-body', 'data-view': 'delivery', 'data-state': state }, diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 389dc36c3..e0ba54978 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -1331,7 +1331,6 @@ const HOST_ZH = { 'First load in progress, {seconds} s elapsed': '首次加载中,已 {seconds} 秒', 'Showing the snapshot from {time} · refresh failed: {message}': '显示 {time} 的快照 · 刷新失败:{message}', 'The first round reads the repository once; the columns appear as soon as it lands.': '首轮要把这个仓读一遍,列会在拿到的同时出现。', - 'It retries on its own (10 s → 30 s → 2 min).': '会自动重试(10 秒 → 30 秒 → 2 分钟)。', 'While this page is open it retries on its own; press Retry to try again now.': '页面开着时自动重试,也可点重试立即再试。', 'Retrying in {seconds} s · press Retry to try again now': '{seconds} 秒后自动重试,也可点重试立即再试。', 'Checks unknown': '检查结果未取到', @@ -1343,16 +1342,16 @@ const HOST_ZH = { Close: '关闭', 'this repository': '这个仓', 'Add a GITHUB_TOKEN in this instance\'s settings — one that can read {repo}.': '在这个实例的设置里填一个 GITHUB_TOKEN,要能读 {repo}。', - 'GitHub is rate-limiting this instance; it retries on its own (10 s → 30 s → 2 min).': 'GitHub 在限流这个实例,会自动重试(10 秒 → 30 秒 → 2 分钟)。', - 'GitHub is rate-limiting this instance; it retries on its own in {seconds} s.': 'GitHub 在限流这个实例,{seconds} 秒后自动重试。', - 'GitHub did not answer in time; the next attempt is automatic.': 'GitHub 没在时限内回应,下一次重试会自动进行。', + 'GitHub is rate-limiting this instance; while this page is open it retries on its own (10 s → 30 s → 2 min).': 'GitHub 在限流这个实例,页面开着时自动重试(10 秒 → 30 秒 → 2 分钟)。', + 'GitHub is rate-limiting this instance; while this page is open it retries on its own in {seconds} s.': 'GitHub 在限流这个实例,页面开着时 {seconds} 秒后自动重试。', + 'GitHub did not answer in time; the next attempt happens while this page is open.': 'GitHub 没在时限内回应,页面开着时下一次重试会自动进行。', 'Give the token Actions: Read.': '去设置里给 token 勾 Actions: Read。', 'Give the token Checks: Read.': '去设置里给 token 勾 Checks: Read。', 'Give the token Issues: Read.': '去设置里给 token 勾 Issues: Read。', 'Give the token Pull requests: Read.': '去设置里给 token 勾 Pull requests: Read。', 'Check the repository name, and that the token can see {repo}.': '检查仓库名对不对、token 有没有 {repo} 的访问权。', 'GitHub is not answering this instance right now; retry in a moment.': 'GitHub 现在没回应这个实例,稍后重试。', - 'The next attempt is automatic; the reason above names what failed.': '下一次重试会自动进行,上面那句话说明是哪一步失败了。', + 'The next attempt happens while this page is open; the reason above names what failed.': '页面开着时下一次重试会自动进行,上面那句话说明是哪一步失败了。', '{part} was not fetched this time: {message}': '{part}这次没取到:{message}', 'CI data': 'CI 数据', 'The rest of the board': '看板剩下的部分', diff --git a/core/test/delivery-view.test.js b/core/test/delivery-view.test.js index e9ca78ddc..513b02855 100644 --- a/core/test/delivery-view.test.js +++ b/core/test/delivery-view.test.js @@ -1,13 +1,72 @@ // The delivery board view (#1173): five columns from one snapshot, a read-only card, and the // header numbers with the CI counted caveat. #1211 added the four states: loading with a counter, // failed with a reason and a retry, stale with the old board and the reason beside it, partial with -// the part that was not read named. The host module is tested in delivery-board.test.js. +// the part that was not read named. #1227 made the countdown move and the retry hints name their +// condition. The host module is tested in delivery-board.test.js. import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import { test } from 'node:test' import { fileURLToPath } from 'node:url' import { runInNewContext } from 'node:vm' import { build } from 'esbuild' +// A clock the new tests can move. `wall.t === null` means "use the real one", which is how every +// test written before #1227 keeps reading real time. +const wall = { t: null } +class FakeDate extends Date { + static now() { return wall.t ?? Date.now() } +} + +// The timers the component may start. Nothing but a component under test asks for one, and only the +// new countdown test moves them. +const timers = { + seq: 0, pending: new Map(), + setInterval(fn, ms) { const id = ++timers.seq; timers.pending.set(id, { fn, ms }); return id }, + clearInterval(id) { timers.pending.delete(id) }, + tick(ms) { wall.t = (wall.t ?? Date.now()) + ms; for (const timer of [...timers.pending.values()]) timer.fn() }, +} + +// A tiny React with real hook slots, so a re-render can be observed (#1227). A component called +// directly, as the older tests do, still gets the old no-op setter: nothing changes for them. +const live = { frame: null, frames: new Map(), frameIndex: 0, effects: [], dirty: false } +const reactStub = { + createElement: (type, props, ...children) => { + const kids = children.flat(Infinity) + if (typeof type !== 'function') return { type, props: props ?? {}, children: kids } + const key = live.frameIndex++ + let frame = live.frames.get(key) + if (!frame) { frame = { hooks: [], cleanups: [] }; live.frames.set(key, frame) } + const outer = live.frame + live.frame = frame + frame.i = 0 + try { return type({ ...(props ?? {}), children: kids }) } finally { live.frame = outer } + }, + useState(initial) { + const frame = live.frame + const value = typeof initial === 'function' ? initial() : initial + if (!frame) return [value, () => {}] + const index = frame.i++ + if (!(index in frame.hooks)) frame.hooks[index] = { value } + const slot = frame.hooks[index] + return [slot.value, next => { slot.value = typeof next === 'function' ? next(slot.value) : next; live.dirty = true }] + }, + useEffect(fn, deps) { + const frame = live.frame + if (!frame) return + const index = frame.i++ + const previous = frame.hooks[index] + const same = previous && deps && previous.deps && deps.length === previous.deps.length + && deps.every((dep, at) => Object.is(dep, previous.deps[at])) + if (same) return + frame.hooks[index] = { deps } + live.effects.push(() => { + if (typeof frame.cleanups[index] === 'function') frame.cleanups[index]() + const cleanup = fn() + frame.cleanups[index] = typeof cleanup === 'function' ? cleanup : null + }) + }, +} + const bundle = await build({ entryPoints: [fileURLToPath(new URL('../src/client/delivery-view.js', import.meta.url))], bundle: true, format: 'cjs', platform: 'node', write: false, @@ -15,20 +74,33 @@ const bundle = await build({ }) const module = { exports: {} } runInNewContext(bundle.outputFiles[0].text, { - module, exports: module.exports, setTimeout, clearTimeout, setInterval, clearInterval, Date, Promise, Number, Math, String, Array, Object, + module, exports: module.exports, setTimeout, clearTimeout, Date: FakeDate, Promise, Number, Math, String, Array, Object, + setInterval: timers.setInterval, clearInterval: timers.clearInterval, require: (name) => { if (name !== 'react') throw new Error(`no platform module ${name}`) - return { - createElement: (type, props, ...children) => { - const kids = children.flat(Infinity) - if (typeof type === 'function') return type({ ...(props ?? {}), children: kids }) - return { type, props: props ?? {}, children: kids } - }, - useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], - useEffect: () => {}, - } + return reactStub }, }) + +// Mount a component under that little React: render, run the effects, and render again while a +// setter fired during the pass marked the tree dirty. +const mount = (Component, props) => { + live.frames = new Map() + const draw = () => { + let out = null + for (let pass = 0; pass < 5; pass += 1) { + live.dirty = false + live.frameIndex = 0 + live.effects = [] + out = reactStub.createElement(Component, props) + for (const effect of live.effects) effect() + if (!live.dirty) break + } + return out + } + draw() + return { draw, rerender: draw } +} const { createPoller, DELIVERY_COLUMNS, DeliveryBoard, badgeLabel, duration, metricRows, pollDelay, retryCountdown, loadingText, staleText, hintFor, missingText, probeText, @@ -324,3 +396,50 @@ test('the countdown is the host retryAt, and it says so when there is none (#121 assert.match(text(withoutAt), /retries on its own/) assert.doesNotMatch(text(withoutAt), /Retrying in/) }) + +test('the failure countdown moves every second, and only while a retry is scheduled (#1227)', () => { + wall.t = Date.parse('2026-10-25T04:00:00Z') + timers.pending.clear() + const failed = { + state: 'failed', + error: { message: 'GitHub answered 503', kind: 'server', endpoint: '/repos/o/r', status: 503, at: null }, + retryAt: wall.t + 120_000, + } + const board = mount(DeliveryBoard, { view: failed, onRetry: () => {} }) + assert.match(text(board.draw()), /Retrying in 120 s/, 'the host gave two minutes, so the screen says 120') + timers.tick(1_000) + assert.match(text(board.draw()), /Retrying in 119 s/, 'the number moves when the second passes') + // One timer, one second: the countdown is a promise about the screen, not about the request. + assert.equal(timers.pending.size, 1, 'exactly one timer while a retry is scheduled') + assert.equal([...timers.pending.values()][0].ms, 1_000, 'the countdown timer is a one-second one') + + timers.tick(118_000) + assert.match(text(board.draw()), /Retrying in 1 s/, 'and it keeps moving down to the last second') + + // A screen with nothing scheduled gets no timer at all, so a fresh board does not tick. + timers.pending.clear() + const fresh = mount(DeliveryBoard, { view: { ...snapshot, state: 'fresh' } }) + fresh.draw() + assert.equal(timers.pending.size, 0, 'a fresh board does not tick') + wall.t = null +}) + +test('the rate-limit and timeout hints name the page as the condition (#1227)', () => { + const rateLimitNoNumber = hintFor({ kind: 'rate-limit', retryAfterMs: null }) + assert.match(rateLimitNoNumber, /while this page is open it retries on its own/) + const rateLimitNumber = hintFor({ kind: 'rate-limit', retryAfterMs: 30_000 }) + assert.match(rateLimitNumber, /while this page is open it retries on its own in 30 s/) + const timeout = hintFor({ kind: 'timeout' }) + assert.match(timeout, /the next attempt happens while this page is open/) + + // Every "retries on its own" the client can print says who is doing the retrying: the open page. + // Nobody else does — the host installs no timer (#1173). + for (const file of ['src/client/delivery-view.js', 'src/client/i18n.js']) { + const source = readFileSync(new URL(`../${file}`, import.meta.url), 'utf8') + for (const line of source.split('\n')) { + if (!/retries on its own|next attempt/.test(line)) continue + if (/^\s*\/\//.test(line)) continue + assert.match(line, /this page is open|页面开着时|页面开着/, `${file}: ${line.trim()}`) + } + } +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index a1d12dd9d..d95485954 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -38,7 +38,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/src/client/schedules.js` | Settings → Scheduled tasks, a Bot's card and the details line. Each row has **Edit** (pencil; the row turns into the same form that creates a task, filled in, with **Save changes**; an edited task keeps its time zone, a cron rule it cannot show is kept) and **Pause** / **Resume**; a paused row reads **Paused** (#354). A row with a recorded run adds one line under it: "Running since …", "Last run …", "Last run … · Stopped" or, in red, "Last run … · Failed: "; a failed run of a task that is not paused gets **Retry**, which runs the task once now (`actions.scheduleRetry`, #473). With Automation tasks off, the hint reads "Switch on Automation tasks on the Plugins page." and the button **Open Plugins** closes the Settings dialog and opens DSH's Plugins panel (`actions.selectPanel('plugins')`); the Harness settings dialog has no such switch (#378). The page opens with the team's **Quiet hours** card (#1166): **Off** with a **Turn on** button, or **On, 22:00–07:00** with the window's two time fields and **Save window** (the settings write refuses an invalid window, and the host's named refusal appears on the form's error line). While quiet hours are on and an `every`/`cron` task exists, the view's `quietUnchecked` flag draws one line saying such a task can still run inside the window: that flag is the only place the rule is computed — the page does not scan the task list itself (#1199) — and it asks for the view again right after the settings write, so the line does not wait for the 30-second poll. Never a silent claim of silence, and no suppressed count. Tests: `core/test/schedules-client.test.js`. | | `core/src/client/inbox.js` | Agent inbox, step 1 (#349): the **Inbox** row at the top of the sidebar (`InboxRow`, count badge = cards in *Needs you*) and the inbox dialog (`InboxDialog`, `ui.inbox`, `actions.openInbox` / `closeInbox`) with the groups **Needs you**, **In progress** and **Done** (`inboxModel`); failed turns, a *Latest* line and a per-card `N tool calls` count (#471); ask_user cards (a group's and the Bot's own chat) answered in the inbox (#552, #1140); a *Needs you* title and reason wrap in full so the ask can be read on the card (#1177); a result card accepted (**Accept**) or sent back with a note (**Send back** → the box → **Send it back**), with its `result.links` evidence and the producing Bot's model, and a sent-back card reading *Sent back: …* (#401). Tests: `core/test/inbox.test.js`. | | `core/src/client/turn-failures.js` | Dead-turn lines (#1044): a turn that died with no text draws one line above the composer (`flybot.session.dock`, `data-flybot=turn-failures`), newest last, at most 3, naming the provider error from the host's `ended` entry and the streak when it is above 1. Registered by `index.js` (`flybot-turn-failures`, order 20); `failureLines(snapshot, place, t)` lives in `core/src/shared/turn-failures.js` so the test reads it without React. It never posts, retries or counts as a reply. Tests: `core/test/dead-turn.test.js`. | -| `core/src/client/delivery-view.js` | The **Delivery** view of the task dialog (#1173, #1211): five columns (`raw`, `ready`, `working`, `review`, `done-today`) from one host snapshot, cards that link out, the verdict/CI badge, and the header numbers. A read is one of four states and each reads differently, never as a blank board: `loading` counts its own seconds (“首次加载中,已 N 秒”) and polls every 2 s, `fresh`/`stale` poll every 60 s and a stale board keeps its columns with “显示 03:12 的快照 · 刷新失败:…” beside the header, and `failed` names the endpoint, the advice and a **Retry** button. “还没有快照。” is reachable only from `idle`, a host that never tried. The header's **Diagnose** button prints the five `probeGitHub()` lines into a copyable block, so the user reads the same statuses `core/scripts/delivery-probe.mjs` prints. Read-only: it renders, it never writes. Tests: `core/test/delivery-view.test.js`, `core/test/kanban.test.js`. | +| `core/src/client/delivery-view.js` | The **Delivery** view of the task dialog (#1173, #1211): five columns (`raw`, `ready`, `working`, `review`, `done-today`) from one host snapshot, cards that link out, the verdict/CI badge, and the header numbers. A read is one of four states and each reads differently, never as a blank board: `loading` counts its own seconds (“首次加载中,已 N 秒”) and polls every 2 s, `fresh`/`stale` poll every 60 s and a stale board keeps its columns with “显示 03:12 的快照 · 刷新失败:…” beside the header, and `failed` names the endpoint, the advice and a **Retry** button whose countdown really counts: the screen redraws once a second while a retry is scheduled and not otherwise, and the rate-limit and timeout advice says `while this page is open it retries on its own`, because the host installs no timer (#1227). “还没有快照。” is reachable only from `idle`, a host that never tried. The header's **Diagnose** button prints the five `probeGitHub()` lines into a copyable block, so the user reads the same statuses `core/scripts/delivery-probe.mjs` prints. Read-only: it renders, it never writes. Tests: `core/test/delivery-view.test.js`, `core/test/kanban.test.js`. | | `core/src/client/kanban.js` | Per-Bot task board (#1058, #1099): the **Tasks** row under Inbox (`KanbanRow`, `ui.board`, `actions.openBoard` / `closeBoard`) and `KanbanDialog`. `boardRail` lists the viewer's Bots nested by `createdBy` and the groups they administer; `boardColumns` maps task status onto **Not started** / **In progress** / **Done**. Cards show id, priority, title, note, assignee and progress. The dialog has two tabs (#1173), **Tasks** and **Delivery**; the Delivery view is repository-wide, so its Bot rail is not rendered and it cannot be pointed at one Bot. In a group scope, `columnTree` nests subtasks under the overall task (`kind: 'group'`) when they share a column, and a subtask in another column says which overall task it is part of. That scope also says assigning, claiming, and progress live on the board (overall task, subtask, assignee, column, progress) and that @ only wakes someone. Drag or **Start** / **Complete** calls `actions.taskUpdate`; only the Bot's owner gets those actions. A group scope loads `tasks { roomId }`. Tests: `core/test/kanban.test.js`. | | `core/src/client/standing-approvals.js` | Standing tool approvals (#540): `StandingApprovalsCard`, the **Always allowed** card in Bot settings (the Bot's grants) and Group settings (the grants this person gave in the group), one row per kind with **Remove** (`actions.removeStandingApproval`). The approval cards (`member-status.js` `ApprovalCard`, the inbox card) add **Always allow for this Bot** and, for an ask from a group, **Always allow for this group** (`alwaysChoices`; never on a friend's Bot). Host side in [core-host](core-host.md#standing-tool-approvals-540). Tests: `core/test/member-status.test.js`, `core/test/standing-approvals.test.js`. | | `core/src/client/mobile.js` | Phone-width layout, step 1 (#362): `MOBILE_CSS` under `@media (max-width:640px)` for held-reply and floating approval buttons, the chat pointers and Agent inbox cards. Tests: `core/test/mobile-layout.test.js`. | diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index d5eb6e003..9414cc67f 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -1184,7 +1184,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'The admin must be a member of the group': '管理员必须是群成员', 'Unknown group chat': '找不到这个群聊', 'Hand the admin role to another member instead of leaving the group without one': '请把管理员交给其他成员,不要让群聊没有管理员', -@@ -905,6 +905,71 @@ const HOST_ZH = { +@@ -905,6 +905,70 @@ const HOST_ZH = { 'Write the entry to save.': '请写下要保存的内容。', 'This DeepSeek Harness has no folder chooser': '这个 DeepSeek Harness 没有文件夹选择窗口', 'This DeepSeek Harness cannot list folders': '这个 DeepSeek Harness 不能列出文件夹', @@ -1227,7 +1227,6 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + 'First load in progress, {seconds} s elapsed': '首次加载中,已 {seconds} 秒', + 'Showing the snapshot from {time} · refresh failed: {message}': '显示 {time} 的快照 · 刷新失败:{message}', + 'The first round reads the repository once; the columns appear as soon as it lands.': '首轮要把这个仓读一遍,列会在拿到的同时出现。', -+ 'It retries on its own (10 s → 30 s → 2 min).': '会自动重试(10 秒 → 30 秒 → 2 分钟)。', + 'While this page is open it retries on its own; press Retry to try again now.': '页面开着时自动重试,也可点重试立即再试。', + 'Retrying in {seconds} s · press Retry to try again now': '{seconds} 秒后自动重试,也可点重试立即再试。', + 'Checks unknown': '检查结果未取到', @@ -1239,16 +1238,16 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + Close: '关闭', + 'this repository': '这个仓', + 'Add a GITHUB_TOKEN in this instance\'s settings — one that can read {repo}.': '在这个实例的设置里填一个 GITHUB_TOKEN,要能读 {repo}。', -+ 'GitHub is rate-limiting this instance; it retries on its own (10 s → 30 s → 2 min).': 'GitHub 在限流这个实例,会自动重试(10 秒 → 30 秒 → 2 分钟)。', -+ 'GitHub is rate-limiting this instance; it retries on its own in {seconds} s.': 'GitHub 在限流这个实例,{seconds} 秒后自动重试。', -+ 'GitHub did not answer in time; the next attempt is automatic.': 'GitHub 没在时限内回应,下一次重试会自动进行。', ++ 'GitHub is rate-limiting this instance; while this page is open it retries on its own (10 s → 30 s → 2 min).': 'GitHub 在限流这个实例,页面开着时自动重试(10 秒 → 30 秒 → 2 分钟)。', ++ 'GitHub is rate-limiting this instance; while this page is open it retries on its own in {seconds} s.': 'GitHub 在限流这个实例,页面开着时 {seconds} 秒后自动重试。', ++ 'GitHub did not answer in time; the next attempt happens while this page is open.': 'GitHub 没在时限内回应,页面开着时下一次重试会自动进行。', + 'Give the token Actions: Read.': '去设置里给 token 勾 Actions: Read。', + 'Give the token Checks: Read.': '去设置里给 token 勾 Checks: Read。', + 'Give the token Issues: Read.': '去设置里给 token 勾 Issues: Read。', + 'Give the token Pull requests: Read.': '去设置里给 token 勾 Pull requests: Read。', + 'Check the repository name, and that the token can see {repo}.': '检查仓库名对不对、token 有没有 {repo} 的访问权。', + 'GitHub is not answering this instance right now; retry in a moment.': 'GitHub 现在没回应这个实例,稍后重试。', -+ 'The next attempt is automatic; the reason above names what failed.': '下一次重试会自动进行,上面那句话说明是哪一步失败了。', ++ 'The next attempt happens while this page is open; the reason above names what failed.': '页面开着时下一次重试会自动进行,上面那句话说明是哪一步失败了。', + '{part} was not fetched this time: {message}': '{part}这次没取到:{message}', + 'CI data': 'CI 数据', + 'The rest of the board': '看板剩下的部分', diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 156aec7d3..b8fee0a58 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -720,7 +720,7 @@ diff --git a/client.js b/client.js "The admin must be a member of the group": "管理员必须是群成员", "Unknown group chat": "找不到这个群聊", "Hand the admin role to another member instead of leaving the group without one": "请把管理员交给其他成员,不要让群聊没有管理员", -@@ -951,7 +951,72 @@ var HOST_ZH = { +@@ -951,7 +951,71 @@ var HOST_ZH = { "The model wrote nothing": "模型没有输出内容", "Write the entry to save.": "请写下要保存的内容。", "This DeepSeek Harness has no folder chooser": "这个 DeepSeek Harness 没有文件夹选择窗口", @@ -765,7 +765,6 @@ diff --git a/client.js b/client.js + "First load in progress, {seconds} s elapsed": "首次加载中,已 {seconds} 秒", + "Showing the snapshot from {time} · refresh failed: {message}": "显示 {time} 的快照 · 刷新失败:{message}", + "The first round reads the repository once; the columns appear as soon as it lands.": "首轮要把这个仓读一遍,列会在拿到的同时出现。", -+ "It retries on its own (10 s → 30 s → 2 min).": "会自动重试(10 秒 → 30 秒 → 2 分钟)。", + "While this page is open it retries on its own; press Retry to try again now.": "页面开着时自动重试,也可点重试立即再试。", + "Retrying in {seconds} s · press Retry to try again now": "{seconds} 秒后自动重试,也可点重试立即再试。", + "Checks unknown": "检查结果未取到", @@ -777,16 +776,16 @@ diff --git a/client.js b/client.js + Close: "关闭", + "this repository": "这个仓", + "Add a GITHUB_TOKEN in this instance's settings — one that can read {repo}.": "在这个实例的设置里填一个 GITHUB_TOKEN,要能读 {repo}。", -+ "GitHub is rate-limiting this instance; it retries on its own (10 s → 30 s → 2 min).": "GitHub 在限流这个实例,会自动重试(10 秒 → 30 秒 → 2 分钟)。", -+ "GitHub is rate-limiting this instance; it retries on its own in {seconds} s.": "GitHub 在限流这个实例,{seconds} 秒后自动重试。", -+ "GitHub did not answer in time; the next attempt is automatic.": "GitHub 没在时限内回应,下一次重试会自动进行。", ++ "GitHub is rate-limiting this instance; while this page is open it retries on its own (10 s → 30 s → 2 min).": "GitHub 在限流这个实例,页面开着时自动重试(10 秒 → 30 秒 → 2 分钟)。", ++ "GitHub is rate-limiting this instance; while this page is open it retries on its own in {seconds} s.": "GitHub 在限流这个实例,页面开着时 {seconds} 秒后自动重试。", ++ "GitHub did not answer in time; the next attempt happens while this page is open.": "GitHub 没在时限内回应,页面开着时下一次重试会自动进行。", + "Give the token Actions: Read.": "去设置里给 token 勾 Actions: Read。", + "Give the token Checks: Read.": "去设置里给 token 勾 Checks: Read。", + "Give the token Issues: Read.": "去设置里给 token 勾 Issues: Read。", + "Give the token Pull requests: Read.": "去设置里给 token 勾 Pull requests: Read。", + "Check the repository name, and that the token can see {repo}.": "检查仓库名对不对、token 有没有 {repo} 的访问权。", + "GitHub is not answering this instance right now; retry in a moment.": "GitHub 现在没回应这个实例,稍后重试。", -+ "The next attempt is automatic; the reason above names what failed.": "下一次重试会自动进行,上面那句话说明是哪一步失败了。", ++ "The next attempt happens while this page is open; the reason above names what failed.": "页面开着时下一次重试会自动进行,上面那句话说明是哪一步失败了。", + "{part} was not fetched this time: {message}": "{part}这次没取到:{message}", + "CI data": "CI 数据", + "The rest of the board": "看板剩下的部分", @@ -5512,7 +5511,7 @@ diff --git a/client.js b/client.js ) ) ), host); -@@ -4731,49 +4731,1936 @@ function showsDshRow(dsh, app) { +@@ -4731,49 +4731,1945 @@ function showsDshRow(dsh, app) { return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); } @@ -6513,9 +6512,9 @@ diff --git a/client.js b/client.js + const target = repo ?? t("this repository"); + if (kind === "no-token") return t("Add a GITHUB_TOKEN in this instance's settings — one that can read {repo}.", { repo: target }); + if (kind === "rate-limit") { -+ return error.retryAfterMs === null || error.retryAfterMs === void 0 ? t("GitHub is rate-limiting this instance; it retries on its own (10 s → 30 s → 2 min).") : t("GitHub is rate-limiting this instance; it retries on its own in {seconds} s.", { seconds: secondsOf(error.retryAfterMs) }); ++ return error.retryAfterMs === null || error.retryAfterMs === void 0 ? t("GitHub is rate-limiting this instance; while this page is open it retries on its own (10 s → 30 s → 2 min).") : t("GitHub is rate-limiting this instance; while this page is open it retries on its own in {seconds} s.", { seconds: secondsOf(error.retryAfterMs) }); + } -+ if (kind === "timeout") return t("GitHub did not answer in time; the next attempt is automatic."); ++ if (kind === "timeout") return t("GitHub did not answer in time; the next attempt happens while this page is open."); + if (scope === "actions" && (kind === "not-found" || kind === "forbidden")) return t("Give the token Actions: Read."); + if (scope === "checks" && (kind === "not-found" || kind === "forbidden")) return t("Give the token Checks: Read."); + if ((scope === "comments" || scope === "issues") && (kind === "not-found" || kind === "forbidden")) return t("Give the token Issues: Read."); @@ -6524,7 +6523,7 @@ diff --git a/client.js b/client.js + return t("Check the repository name, and that the token can see {repo}.", { repo: target }); + } + if (kind === "server" || kind === "network") return t("GitHub is not answering this instance right now; retry in a moment."); -+ return t("The next attempt is automatic; the reason above names what failed."); ++ return t("The next attempt happens while this page is open; the reason above names what failed."); +} +function missingText(entry) { + const status = entry?.status === null || entry?.status === void 0 ? "" : ` · ${entry.status}`; @@ -6745,7 +6744,16 @@ diff --git a/client.js b/client.js +function DeliveryBoard({ view, snapshot, message = "", repo = null, onRetry = null, onProbe = null }) { + const payload = view ?? (snapshot ? { ...snapshot, state: "fresh" } : { state: "idle" }); + const state = payload.state ?? "fresh"; -+ const countdown = retryCountdown(payload.retryAt); ++ const retryAt = Number(payload.retryAt); ++ const ticking = (state === "failed" || state === "stale") && Number.isFinite(retryAt) && retryAt > 0; ++ const [now, setNow] = (0, import_react14.useState)(() => Date.now()); ++ (0, import_react14.useEffect)(() => { ++ if (!ticking) return void 0; ++ setNow(Date.now()); ++ const timer = setInterval(() => setNow(Date.now()), 1e3); ++ return () => clearInterval(timer); ++ }, [ticking, retryAt]); ++ const countdown = retryCountdown(payload.retryAt, now); + if (state === "idle") { + return (0, import_react14.createElement)( + "div", diff --git a/patches/ds-bot/files/src/client/delivery-view.js b/patches/ds-bot/files/src/client/delivery-view.js index 4074a74a1..7c08b127a 100644 --- a/patches/ds-bot/files/src/client/delivery-view.js +++ b/patches/ds-bot/files/src/client/delivery-view.js @@ -86,11 +86,12 @@ export function hintFor(error, { repo = null } = {}) { if (kind === 'no-token') return t('Add a GITHUB_TOKEN in this instance\'s settings — one that can read {repo}.', { repo: target }) if (kind === 'rate-limit') { return error.retryAfterMs === null || error.retryAfterMs === undefined - // No number from GitHub means no number here: the backoff is a rule, not a measurement. - ? t('GitHub is rate-limiting this instance; it retries on its own (10 s → 30 s → 2 min).') - : t('GitHub is rate-limiting this instance; it retries on its own in {seconds} s.', { seconds: secondsOf(error.retryAfterMs) }) + // No number from GitHub means no number here: the backoff is a rule, not a measurement. The + // retrying is the open page's job — the host installs no timer (#1173) (#1227). + ? t('GitHub is rate-limiting this instance; while this page is open it retries on its own (10 s → 30 s → 2 min).') + : t('GitHub is rate-limiting this instance; while this page is open it retries on its own in {seconds} s.', { seconds: secondsOf(error.retryAfterMs) }) } - if (kind === 'timeout') return t('GitHub did not answer in time; the next attempt is automatic.') + if (kind === 'timeout') return t('GitHub did not answer in time; the next attempt happens while this page is open.') if (scope === 'actions' && (kind === 'not-found' || kind === 'forbidden')) return t('Give the token Actions: Read.') if (scope === 'checks' && (kind === 'not-found' || kind === 'forbidden')) return t('Give the token Checks: Read.') if ((scope === 'comments' || scope === 'issues') && (kind === 'not-found' || kind === 'forbidden')) return t('Give the token Issues: Read.') @@ -99,7 +100,7 @@ export function hintFor(error, { repo = null } = {}) { return t('Check the repository name, and that the token can see {repo}.', { repo: target }) } if (kind === 'server' || kind === 'network') return t('GitHub is not answering this instance right now; retry in a moment.') - return t('The next attempt is automatic; the reason above names what failed.') + return t('The next attempt happens while this page is open; the reason above names what failed.') } // A part that could not be read is named where it is missing, not hidden (#1211). @@ -297,7 +298,19 @@ export function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, on export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRetry = null, onProbe = null }) { const payload = view ?? (snapshot ? { ...snapshot, state: 'fresh' } : { state: 'idle' }) const state = payload.state ?? 'fresh' - const countdown = retryCountdown(payload.retryAt) + // The countdown promises a number that moves, so the screen redraws once a second while a retry + // is actually scheduled — and only then: a fresh or loading board must not tick (#1227). The + // timer lives in the browser, not in the host, which installs none (#1173). + const retryAt = Number(payload.retryAt) + const ticking = (state === 'failed' || state === 'stale') && Number.isFinite(retryAt) && retryAt > 0 + const [now, setNow] = useState(() => Date.now()) + useEffect(() => { + if (!ticking) return undefined + setNow(Date.now()) + const timer = setInterval(() => setNow(Date.now()), 1_000) + return () => clearInterval(timer) + }, [ticking, retryAt]) + const countdown = retryCountdown(payload.retryAt, now) // "No snapshot yet." belongs to a host that has never tried; every attempt has a better word. if (state === 'idle') { return h('div', { className: 'bt-del-body', 'data-view': 'delivery', 'data-state': state }, diff --git a/patches/ds-bot/files/test/delivery-view.test.js b/patches/ds-bot/files/test/delivery-view.test.js index e9ca78ddc..513b02855 100644 --- a/patches/ds-bot/files/test/delivery-view.test.js +++ b/patches/ds-bot/files/test/delivery-view.test.js @@ -1,13 +1,72 @@ // The delivery board view (#1173): five columns from one snapshot, a read-only card, and the // header numbers with the CI counted caveat. #1211 added the four states: loading with a counter, // failed with a reason and a retry, stale with the old board and the reason beside it, partial with -// the part that was not read named. The host module is tested in delivery-board.test.js. +// the part that was not read named. #1227 made the countdown move and the retry hints name their +// condition. The host module is tested in delivery-board.test.js. import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import { test } from 'node:test' import { fileURLToPath } from 'node:url' import { runInNewContext } from 'node:vm' import { build } from 'esbuild' +// A clock the new tests can move. `wall.t === null` means "use the real one", which is how every +// test written before #1227 keeps reading real time. +const wall = { t: null } +class FakeDate extends Date { + static now() { return wall.t ?? Date.now() } +} + +// The timers the component may start. Nothing but a component under test asks for one, and only the +// new countdown test moves them. +const timers = { + seq: 0, pending: new Map(), + setInterval(fn, ms) { const id = ++timers.seq; timers.pending.set(id, { fn, ms }); return id }, + clearInterval(id) { timers.pending.delete(id) }, + tick(ms) { wall.t = (wall.t ?? Date.now()) + ms; for (const timer of [...timers.pending.values()]) timer.fn() }, +} + +// A tiny React with real hook slots, so a re-render can be observed (#1227). A component called +// directly, as the older tests do, still gets the old no-op setter: nothing changes for them. +const live = { frame: null, frames: new Map(), frameIndex: 0, effects: [], dirty: false } +const reactStub = { + createElement: (type, props, ...children) => { + const kids = children.flat(Infinity) + if (typeof type !== 'function') return { type, props: props ?? {}, children: kids } + const key = live.frameIndex++ + let frame = live.frames.get(key) + if (!frame) { frame = { hooks: [], cleanups: [] }; live.frames.set(key, frame) } + const outer = live.frame + live.frame = frame + frame.i = 0 + try { return type({ ...(props ?? {}), children: kids }) } finally { live.frame = outer } + }, + useState(initial) { + const frame = live.frame + const value = typeof initial === 'function' ? initial() : initial + if (!frame) return [value, () => {}] + const index = frame.i++ + if (!(index in frame.hooks)) frame.hooks[index] = { value } + const slot = frame.hooks[index] + return [slot.value, next => { slot.value = typeof next === 'function' ? next(slot.value) : next; live.dirty = true }] + }, + useEffect(fn, deps) { + const frame = live.frame + if (!frame) return + const index = frame.i++ + const previous = frame.hooks[index] + const same = previous && deps && previous.deps && deps.length === previous.deps.length + && deps.every((dep, at) => Object.is(dep, previous.deps[at])) + if (same) return + frame.hooks[index] = { deps } + live.effects.push(() => { + if (typeof frame.cleanups[index] === 'function') frame.cleanups[index]() + const cleanup = fn() + frame.cleanups[index] = typeof cleanup === 'function' ? cleanup : null + }) + }, +} + const bundle = await build({ entryPoints: [fileURLToPath(new URL('../src/client/delivery-view.js', import.meta.url))], bundle: true, format: 'cjs', platform: 'node', write: false, @@ -15,20 +74,33 @@ const bundle = await build({ }) const module = { exports: {} } runInNewContext(bundle.outputFiles[0].text, { - module, exports: module.exports, setTimeout, clearTimeout, setInterval, clearInterval, Date, Promise, Number, Math, String, Array, Object, + module, exports: module.exports, setTimeout, clearTimeout, Date: FakeDate, Promise, Number, Math, String, Array, Object, + setInterval: timers.setInterval, clearInterval: timers.clearInterval, require: (name) => { if (name !== 'react') throw new Error(`no platform module ${name}`) - return { - createElement: (type, props, ...children) => { - const kids = children.flat(Infinity) - if (typeof type === 'function') return type({ ...(props ?? {}), children: kids }) - return { type, props: props ?? {}, children: kids } - }, - useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], - useEffect: () => {}, - } + return reactStub }, }) + +// Mount a component under that little React: render, run the effects, and render again while a +// setter fired during the pass marked the tree dirty. +const mount = (Component, props) => { + live.frames = new Map() + const draw = () => { + let out = null + for (let pass = 0; pass < 5; pass += 1) { + live.dirty = false + live.frameIndex = 0 + live.effects = [] + out = reactStub.createElement(Component, props) + for (const effect of live.effects) effect() + if (!live.dirty) break + } + return out + } + draw() + return { draw, rerender: draw } +} const { createPoller, DELIVERY_COLUMNS, DeliveryBoard, badgeLabel, duration, metricRows, pollDelay, retryCountdown, loadingText, staleText, hintFor, missingText, probeText, @@ -324,3 +396,50 @@ test('the countdown is the host retryAt, and it says so when there is none (#121 assert.match(text(withoutAt), /retries on its own/) assert.doesNotMatch(text(withoutAt), /Retrying in/) }) + +test('the failure countdown moves every second, and only while a retry is scheduled (#1227)', () => { + wall.t = Date.parse('2026-10-25T04:00:00Z') + timers.pending.clear() + const failed = { + state: 'failed', + error: { message: 'GitHub answered 503', kind: 'server', endpoint: '/repos/o/r', status: 503, at: null }, + retryAt: wall.t + 120_000, + } + const board = mount(DeliveryBoard, { view: failed, onRetry: () => {} }) + assert.match(text(board.draw()), /Retrying in 120 s/, 'the host gave two minutes, so the screen says 120') + timers.tick(1_000) + assert.match(text(board.draw()), /Retrying in 119 s/, 'the number moves when the second passes') + // One timer, one second: the countdown is a promise about the screen, not about the request. + assert.equal(timers.pending.size, 1, 'exactly one timer while a retry is scheduled') + assert.equal([...timers.pending.values()][0].ms, 1_000, 'the countdown timer is a one-second one') + + timers.tick(118_000) + assert.match(text(board.draw()), /Retrying in 1 s/, 'and it keeps moving down to the last second') + + // A screen with nothing scheduled gets no timer at all, so a fresh board does not tick. + timers.pending.clear() + const fresh = mount(DeliveryBoard, { view: { ...snapshot, state: 'fresh' } }) + fresh.draw() + assert.equal(timers.pending.size, 0, 'a fresh board does not tick') + wall.t = null +}) + +test('the rate-limit and timeout hints name the page as the condition (#1227)', () => { + const rateLimitNoNumber = hintFor({ kind: 'rate-limit', retryAfterMs: null }) + assert.match(rateLimitNoNumber, /while this page is open it retries on its own/) + const rateLimitNumber = hintFor({ kind: 'rate-limit', retryAfterMs: 30_000 }) + assert.match(rateLimitNumber, /while this page is open it retries on its own in 30 s/) + const timeout = hintFor({ kind: 'timeout' }) + assert.match(timeout, /the next attempt happens while this page is open/) + + // Every "retries on its own" the client can print says who is doing the retrying: the open page. + // Nobody else does — the host installs no timer (#1173). + for (const file of ['src/client/delivery-view.js', 'src/client/i18n.js']) { + const source = readFileSync(new URL(`../${file}`, import.meta.url), 'utf8') + for (const line of source.split('\n')) { + if (!/retries on its own|next attempt/.test(line)) continue + if (/^\s*\/\//.test(line)) continue + assert.match(line, /this page is open|页面开着时|页面开着/, `${file}: ${line.trim()}`) + } + } +})