feat(frontend): the app says what it is running, and says so honestly when it cannot find out (#3329)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 31s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 38s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 31s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 38s
#3127 checklist 12, plus rule 27 — a capability with no surface the operator can touch is not shipped. The step was planned on the premise that nothing read `/api/version`. Two things did, and the state was worse than nothing: - `App.vue` fetched it, wrote `version` into a ref initialised to the literal `"dev"`, and swallowed the error. An instance that could not answer rendered EXACTLY what a healthy local build renders. That is checklist 12's named failure — a blank standing in for `unknown` — in the one readout whose whole job is to say what is running, and it would have made #3298's debugging session no cheaper. - `SettingsView.vue` fetched the same endpoint again on every mount and wrote the result into a local ref no template ever read. A duplicate request whose answer was discarded. So this is not "add a readout"; it is "make the existing one honest, and give it the three fields nobody could see." The readout — Settings → Config, first section, beside the other "what is this instance doing" facts. Three states kept apart, because collapsing any two of them is the defect: not asked yet (tab unopened) nothing answered the values, each ABSENT field as "unknown" the fetch itself failed its own message, with a retry `version` and `channel` prominent, `commit` in full with a copy button so it can be pasted into a `:sha` lookup (rule 145 — the registry's identity and the artifact's own must be checkable against each other), `build` kept because its ABSENCE is the diagnostic part: no ordering key means this build is not in any update order, which is what a local or hand-built image looks like. Absence, not falsiness. The payload omits what it does not know rather than sending `""` or `0` (see `build_version_payload`), so the renderer uses `??` throughout — `build` is a number and `0` is a legitimate ordering key, which `||` would report as unknown. `tests/test_version_readout.py` pins that operator specifically, along with the "no plausible default" property, because `||` is the form a person reaches for by habit. Rule 156 — the fetch carries a deadline. This readout is consulted when an instance is misbehaving, which is exactly when it may never answer; without one the surface sits on "still loading" forever, which is the same blank arrived at from the other direction. `apiGet` gains an OPT-IN `timeoutMs` rather than a default, so no existing call site's behaviour moves. Every other call in the client still has no deadline — reported separately, not fixed here. No frontend test runner exists, so verification is the typecheck lane plus four source-inspection guards in the unit lane, each pinning one property. Also folded in: `plugin/README.md` now leads with the mint script and offers `make` second, since `make` is not installed on every workstation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN
This commit is contained in:
+19
-6
@@ -7,12 +7,20 @@ import { useTheme } from "@/composables/useTheme";
|
|||||||
import { useShortcuts } from "@/composables/useShortcuts";
|
import { useShortcuts } from "@/composables/useShortcuts";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useSettingsStore } from "@/stores/settings";
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
import { apiGet, apiPut } from "@/api/client";
|
import { apiPut } from "@/api/client";
|
||||||
|
import { fetchVersion } from "@/api/version";
|
||||||
|
|
||||||
useTheme();
|
useTheme();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const appVersion = ref("dev");
|
// THREE states, not two (#3127 checklist 12). `null` is "not answered yet" and
|
||||||
|
// renders nothing; a string renders; `appVersionFailed` renders its own thing.
|
||||||
|
// This used to default to the literal "dev" and swallow the error, which meant
|
||||||
|
// an instance that could not answer was indistinguishable from a local build
|
||||||
|
// that genuinely reports "dev" — a blank standing in for `unknown`, in the one
|
||||||
|
// readout whose whole job is to say what is running.
|
||||||
|
const appVersion = ref<string | null>(null);
|
||||||
|
const appVersionFailed = ref(false);
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts();
|
const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts();
|
||||||
@@ -119,10 +127,12 @@ onMounted(async () => {
|
|||||||
startAppServices();
|
startAppServices();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await apiGet<{ version: string }>("/api/version");
|
appVersion.value = (await fetchVersion()).version;
|
||||||
appVersion.value = data.version;
|
|
||||||
} catch {
|
} catch {
|
||||||
// silent — version display is non-critical
|
// Not silent any more: the footer says it could not find out, rather than
|
||||||
|
// showing a version it never received. The full readout (version, channel,
|
||||||
|
// commit, build) lives in Settings → Config.
|
||||||
|
appVersionFailed.value = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -151,7 +161,10 @@ onUnmounted(() => {
|
|||||||
<div id="main-content" class="app-content">
|
<div id="main-content" class="app-content">
|
||||||
<router-view />
|
<router-view />
|
||||||
</div>
|
</div>
|
||||||
<footer class="app-footer">v{{ appVersion }}</footer>
|
<footer class="app-footer">
|
||||||
|
<span v-if="appVersion">v{{ appVersion }}</span>
|
||||||
|
<span v-else-if="appVersionFailed">version unknown</span>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Keyboard shortcuts overlay -->
|
<!-- Keyboard shortcuts overlay -->
|
||||||
|
|||||||
@@ -52,8 +52,25 @@ export function apiErrorMessage(e: unknown, fallback: string): string {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiGet<T>(path: string): Promise<T> {
|
/**
|
||||||
const res = await fetch(path);
|
* A GET, optionally with a deadline.
|
||||||
|
*
|
||||||
|
* `timeoutMs` is OPT-IN rather than defaulted, deliberately. Every existing
|
||||||
|
* caller was written against a `fetch` that waits as long as the browser will,
|
||||||
|
* and handing them all a deadline in one change would alter behaviour at every
|
||||||
|
* call site at once, including ones nobody has looked at. New callers should
|
||||||
|
* pass one.
|
||||||
|
*
|
||||||
|
* Why a caller should want it: a wait with no deadline cannot report that it
|
||||||
|
* failed. It can only stay pending — which is indistinguishable, to anything
|
||||||
|
* rendering it, from "still loading". A surface that has to tell those two
|
||||||
|
* apart needs the request to give up on its own.
|
||||||
|
*/
|
||||||
|
export async function apiGet<T>(path: string, opts?: { timeoutMs?: number }): Promise<T> {
|
||||||
|
const res = await fetch(
|
||||||
|
path,
|
||||||
|
opts?.timeoutMs ? { signal: AbortSignal.timeout(opts.timeoutMs) } : undefined,
|
||||||
|
);
|
||||||
return handleResponse<T>(res, path);
|
return handleResponse<T>(res, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { apiGet } from "./client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What `/api/version` answers — the client's half of `build_version_payload`
|
||||||
|
* (`src/scribe/routes/api.py`), which is where the reasoning for the shape is
|
||||||
|
* written down.
|
||||||
|
*
|
||||||
|
* EVERY FIELD BUT `version` IS OPTIONAL, and an absent one means "this build
|
||||||
|
* does not know", not "empty". A local build has no ordering key and no
|
||||||
|
* channel, and the server says so by omitting the keys rather than sending
|
||||||
|
* `""` — emitting a placeholder would let it claim a position in an update
|
||||||
|
* order it is not part of.
|
||||||
|
*
|
||||||
|
* So a renderer must read ABSENCE, never falsiness. `build` is a number and
|
||||||
|
* `0` is a legitimate ordering key, so `v.build || "unknown"` would report a
|
||||||
|
* real value as unknown; `v.build ?? "unknown"` is the correct form.
|
||||||
|
*/
|
||||||
|
export interface VersionPayload {
|
||||||
|
/** The NAME — `YYYY.MM.DD.HHMM` from commit time. Answers "is this the same code?" */
|
||||||
|
version: string;
|
||||||
|
/** The ORDERING KEY — minutes since 2020-01-01, from build time. Absent on a local build. */
|
||||||
|
build?: number;
|
||||||
|
/** `dev` / `main` / a tag. Its own field, never folded into the name. */
|
||||||
|
channel?: string;
|
||||||
|
/** The commit the artifact was published under, so its claim can be checked against the registry. */
|
||||||
|
commit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The readout exists to answer "what is running?" during an incident, which is
|
||||||
|
* exactly when the server may be the thing that is unwell. Without a deadline
|
||||||
|
* a failing instance leaves the request pending forever and the surface sits
|
||||||
|
* on "still loading" — a blank standing in for `unknown`, which is the failure
|
||||||
|
* mode #3127 checklist 12 names by hand. Eight seconds is long enough for a
|
||||||
|
* slow-but-alive instance and short enough that a person watching it learns
|
||||||
|
* something.
|
||||||
|
*/
|
||||||
|
const VERSION_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
|
export function fetchVersion(): Promise<VersionPayload> {
|
||||||
|
return apiGet<VersionPayload>("/api/version", { timeoutMs: VERSION_TIMEOUT_MS });
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import type { User } from "@/types/auth";
|
|||||||
import PaginationBar from "@/components/PaginationBar.vue";
|
import PaginationBar from "@/components/PaginationBar.vue";
|
||||||
import TagInput from "@/components/TagInput.vue";
|
import TagInput from "@/components/TagInput.vue";
|
||||||
import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
|
import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
|
||||||
|
import { fetchVersion, type VersionPayload } from "@/api/version";
|
||||||
|
|
||||||
const store = useSettingsStore();
|
const store = useSettingsStore();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
@@ -187,7 +188,37 @@ const changingPassword = ref(false);
|
|||||||
const invalidatingSessions = ref(false);
|
const invalidatingSessions = ref(false);
|
||||||
const exporting = ref(false);
|
const exporting = ref(false);
|
||||||
const restoring = ref(false);
|
const restoring = ref(false);
|
||||||
const appVersion = ref('dev');
|
// ── What's running (#3127 checklist 12) ─────────────────────────────────
|
||||||
|
// Three states kept apart, because collapsing any two of them is the defect
|
||||||
|
// this readout exists to remove: `null` + no error = not asked yet (the Config
|
||||||
|
// tab has not been opened); a payload = answered, with each ABSENT field shown
|
||||||
|
// as "unknown"; `versionError` = the fetch itself failed, which is its own
|
||||||
|
// thing and must never render as a blank or as a plausible-looking value.
|
||||||
|
const versionInfo = ref<VersionPayload | null>(null);
|
||||||
|
const versionLoading = ref(false);
|
||||||
|
const versionError = ref("");
|
||||||
|
const commitCopied = ref(false);
|
||||||
|
|
||||||
|
async function loadVersionPanel() {
|
||||||
|
if (versionLoading.value) return;
|
||||||
|
versionLoading.value = true;
|
||||||
|
versionError.value = "";
|
||||||
|
try {
|
||||||
|
versionInfo.value = await fetchVersion();
|
||||||
|
} catch (e) {
|
||||||
|
versionInfo.value = null;
|
||||||
|
versionError.value = apiErrorMessage(e, "Could not reach the instance to ask what it is running.");
|
||||||
|
} finally {
|
||||||
|
versionLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyCommit() {
|
||||||
|
if (!versionInfo.value?.commit) return;
|
||||||
|
await copyToClipboard(versionInfo.value.commit);
|
||||||
|
commitCopied.value = true;
|
||||||
|
setTimeout(() => { commitCopied.value = false; }, 2000);
|
||||||
|
}
|
||||||
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
|
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
|
||||||
@@ -201,6 +232,7 @@ function _loadTabContent(tab: string) {
|
|||||||
else if (tab === "logs") loadLogsPanel();
|
else if (tab === "logs") loadLogsPanel();
|
||||||
else if (tab === "groups") loadGroupsPanel();
|
else if (tab === "groups") loadGroupsPanel();
|
||||||
else if (tab === "areas") canonStore.fetchCatalog(true);
|
else if (tab === "areas") canonStore.fetchCatalog(true);
|
||||||
|
else if (tab === "config" && !versionInfo.value) loadVersionPanel();
|
||||||
}
|
}
|
||||||
if (tab === "apikeys") { fetchApiKeys(); }
|
if (tab === "apikeys") { fetchApiKeys(); }
|
||||||
}
|
}
|
||||||
@@ -554,10 +586,6 @@ function toggleProfileWorkDay(day: string) {
|
|||||||
function emptyTagsFetch(): Promise<string[]> { return Promise.resolve([]) }
|
function emptyTagsFetch(): Promise<string[]> { return Promise.resolve([]) }
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
|
||||||
const v = await apiGet<{ version: string }>('/api/version')
|
|
||||||
appVersion.value = v.version
|
|
||||||
} catch { /* non-critical */ }
|
|
||||||
await store.fetchSettings();
|
await store.fetchSettings();
|
||||||
newEmail.value = authStore.user?.email ?? "";
|
newEmail.value = authStore.user?.email ?? "";
|
||||||
|
|
||||||
@@ -2109,6 +2137,48 @@ async function deleteUser(userId: number) {
|
|||||||
<!-- ── Admin ── -->
|
<!-- ── Admin ── -->
|
||||||
<div v-if="authStore.isAdmin" v-show="activeTab === 'config'" class="settings-grid">
|
<div v-if="authStore.isAdmin" v-show="activeTab === 'config'" class="settings-grid">
|
||||||
|
|
||||||
|
<section class="settings-section full-width">
|
||||||
|
<h2>What's running</h2>
|
||||||
|
<p class="section-desc">
|
||||||
|
The build serving this page. Paste the commit into a <code>:sha</code> image
|
||||||
|
lookup to check the registry and the app agree about what was published.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="versionLoading" class="state-msg">Reading the ledger…</div>
|
||||||
|
<div v-else-if="versionError" class="error-msg">
|
||||||
|
{{ versionError }}
|
||||||
|
<button class="btn-ghost btn-compact version-retry" @click="loadVersionPanel">Try again</button>
|
||||||
|
</div>
|
||||||
|
<dl v-else-if="versionInfo" class="version-grid">
|
||||||
|
<dt>Version</dt>
|
||||||
|
<dd class="version-value">{{ versionInfo.version }}</dd>
|
||||||
|
|
||||||
|
<dt>Channel</dt>
|
||||||
|
<dd :class="versionInfo.channel === undefined ? 'version-unknown' : 'version-value'">
|
||||||
|
{{ versionInfo.channel ?? "unknown" }}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Commit</dt>
|
||||||
|
<dd v-if="versionInfo.commit" class="version-value version-commit">
|
||||||
|
<span class="version-sha">{{ versionInfo.commit }}</span>
|
||||||
|
<button class="btn-ghost btn-compact" @click="copyCommit">
|
||||||
|
{{ commitCopied ? "Copied" : "Copy" }}
|
||||||
|
</button>
|
||||||
|
</dd>
|
||||||
|
<dd v-else class="version-unknown">unknown</dd>
|
||||||
|
|
||||||
|
<dt>Build</dt>
|
||||||
|
<!-- The ordering key, kept because its ABSENCE is the diagnostic one:
|
||||||
|
no key means this build is not part of any update order, which is
|
||||||
|
what a local or hand-built image looks like. `??` not `||` — 0 is
|
||||||
|
a legitimate key. -->
|
||||||
|
<dd :class="versionInfo.build === undefined ? 'version-unknown' : 'version-value'">
|
||||||
|
{{ versionInfo.build ?? "unknown" }}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
<div v-else class="empty-msg">Nothing asked yet.</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="settings-section full-width">
|
<section class="settings-section full-width">
|
||||||
<h2>Application URL</h2>
|
<h2>Application URL</h2>
|
||||||
<p class="section-desc">
|
<p class="section-desc">
|
||||||
@@ -2768,6 +2838,45 @@ async function deleteUser(userId: number) {
|
|||||||
letter-spacing: 0.07em;
|
letter-spacing: 0.07em;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
|
/* What's running — a definition list of instance facts. Spacing/geometry only;
|
||||||
|
colour and type come from the tokens. */
|
||||||
|
.version-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: 0.4rem 1rem;
|
||||||
|
margin: 0;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
.version-grid dt {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
}
|
||||||
|
.version-grid dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: var(--fs-font-mono);
|
||||||
|
color: var(--fs-text-primary);
|
||||||
|
}
|
||||||
|
/* An absent field reads as absent — never as a blank, and never styled to look
|
||||||
|
like a value it does not have (#3127 checklist 12). */
|
||||||
|
.version-grid dd.version-unknown {
|
||||||
|
font-family: inherit;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
}
|
||||||
|
.version-commit {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.version-sha {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.version-retry {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.section-desc {
|
.section-desc {
|
||||||
margin: 0 0 1rem;
|
margin: 0 0 1rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
|||||||
+3
-3
@@ -79,9 +79,9 @@ On install you'll be asked for:
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- **Do not hand-edit `version` in `.claude-plugin/plugin.json`.** It is minted
|
- **Do not hand-edit `version` in `.claude-plugin/plugin.json`.** It is minted
|
||||||
from the clock — run `make mint-plugin` (or `python3
|
from the clock — run `python3 scripts/mint_plugin_version.py` (or `make
|
||||||
scripts/mint_plugin_version.py`) after changing anything under `plugin/`, and
|
mint-plugin`, where `make` is installed) after changing anything under
|
||||||
commit the result. The installer decides whether to refresh the cache it
|
`plugin/`, and commit the result. The installer decides whether to refresh the cache it
|
||||||
executes from by comparing that string, so content that ships without a new
|
executes from by comparing that string, so content that ships without a new
|
||||||
version reaches the repo and stops there (#2209). CI fails the lane if you
|
version reaches the repo and stops there (#2209). CI fails the lane if you
|
||||||
forget.
|
forget.
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""The app must SAY what it is running, and must not lie when it cannot find out.
|
||||||
|
|
||||||
|
There is no frontend test runner in this repo, so these are source-inspection
|
||||||
|
guards in the unit lane — the same idiom `check_plugin.py` uses on the hook
|
||||||
|
shells. They are deliberately few and deliberately about ONE property each,
|
||||||
|
because a grep-shaped test that asserts a whole file's contents fails on every
|
||||||
|
refactor and gets deleted.
|
||||||
|
|
||||||
|
WHY THIS FILE EXISTS. #3298: with a deploy misbehaving, nothing on the instance
|
||||||
|
could say which commit was serving it, and the one endpoint whose job that is
|
||||||
|
answered with the name of a branch. The value was fixed then. This is the other
|
||||||
|
half — the value reaching a person — and #3127 checklist 12 is specific about
|
||||||
|
the way it goes wrong: *never let a blank stand in for `unknown`*. A readout
|
||||||
|
that renders a plausible value it never received is worse than one that renders
|
||||||
|
nothing, because it ends the investigation instead of starting it.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
FRONTEND = Path(__file__).resolve().parents[1] / "frontend" / "src"
|
||||||
|
|
||||||
|
|
||||||
|
def test_something_actually_reads_the_version_endpoint():
|
||||||
|
"""The endpoint is not enough; something must ask it.
|
||||||
|
|
||||||
|
`/api/version` answered correctly for weeks with no caller — an endpoint
|
||||||
|
reachable only by someone who already knew to curl it. Rule 27: a
|
||||||
|
capability with no surface the operator can touch is not shipped.
|
||||||
|
"""
|
||||||
|
hits = [p for p in FRONTEND.rglob("*.ts") if "/api/version" in p.read_text()]
|
||||||
|
assert hits, "nothing under frontend/src fetches /api/version"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_footer_does_not_default_to_a_plausible_version():
|
||||||
|
"""The regression this readout was built to remove.
|
||||||
|
|
||||||
|
`appVersion` used to start life as the literal `"dev"` and the fetch
|
||||||
|
swallowed its own failure, so an instance that could not answer rendered
|
||||||
|
exactly what a healthy local build renders. Two very different states, one
|
||||||
|
string, and no way to tell them apart from the page.
|
||||||
|
|
||||||
|
Pinned as "the ref does not start at a version-shaped literal" rather than
|
||||||
|
as an exact initialiser, so a later refactor can change how the state is
|
||||||
|
held without failing here — what must not come back is the plausible
|
||||||
|
default.
|
||||||
|
"""
|
||||||
|
app = (FRONTEND / "App.vue").read_text()
|
||||||
|
match = re.search(r"const appVersion = ref[^;]*;", app)
|
||||||
|
assert match, "App.vue no longer declares appVersion — update this guard"
|
||||||
|
decl = match.group(0)
|
||||||
|
assert '"dev"' not in decl and "'dev'" not in decl, (
|
||||||
|
f"appVersion defaults to a version-shaped literal: {decl}\n"
|
||||||
|
"A failed fetch would render as a real-looking version (#3127 "
|
||||||
|
"checklist 12). Start from a not-answered-yet value instead."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_optional_version_fields_are_read_by_absence_not_falsiness():
|
||||||
|
"""`build` is a number and 0 is a legitimate ordering key.
|
||||||
|
|
||||||
|
The payload omits what it does not know rather than sending `""` or `0`, so
|
||||||
|
the renderer's job is to distinguish ABSENT from present. `||` cannot: it
|
||||||
|
would report a real `build` of 0 as unknown, and it is the form a person
|
||||||
|
reaches for by habit. `??` is the correct one, which is why this pins the
|
||||||
|
operator rather than the rendered output.
|
||||||
|
"""
|
||||||
|
view = (FRONTEND / "views" / "SettingsView.vue").read_text()
|
||||||
|
for field in ("channel", "build"):
|
||||||
|
assert f'versionInfo.{field} ?? "unknown"' in view, (
|
||||||
|
f"the {field} readout must use `?? \"unknown\"`, never `|| \"unknown\"` — "
|
||||||
|
"an absent field and a falsy one are different answers"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_version_request_carries_a_deadline():
|
||||||
|
"""Rule 156. A wait with no deadline cannot report that it failed.
|
||||||
|
|
||||||
|
This readout is consulted when an instance is misbehaving, which is exactly
|
||||||
|
when it may never answer. Without a deadline the surface sits on "still
|
||||||
|
loading" forever — the blank standing in for `unknown` again, arrived at
|
||||||
|
from the other direction.
|
||||||
|
"""
|
||||||
|
src = (FRONTEND / "api" / "version.ts").read_text()
|
||||||
|
assert "timeoutMs" in src, "the version fetch must pass a deadline"
|
||||||
Reference in New Issue
Block a user