feat(settings): probe developer-tools links before offering them (DVT3, DVT4)

Deriving a URL proved that the relay URL parsed and nothing more. Every
link rendered on that basis, so a relay built before the reference shipped
— or one behind a proxy that does not forward /docs — sent the member to a
404 in their browser with no explanation. A 404 reached from a
documentation button reads as "the documentation is broken" when the truth
is "this relay is older than the documentation".

probe_relay_docs GETs /docs, /docs/openapi.json and /info concurrently and
returns a per-path outcome, never an error — the no-error contract
fetch_relay_mips already uses. Three outcomes rather than a boolean,
because the two failures need different sentences: a relay answering 404
is working correctly and merely predates the reference; a relay that does
not answer is a network problem. Collapsing them sends an operator to
debug the wrong thing.

Two further states deliberately disable nothing. While the probe is in
flight, and when the probe itself could not run, links stay exactly as
they were — "we are asking" and "we could not ask" are facts about this
build, not about the relay, and rendering either as a relay failure is the
lie useRelayMips already refuses. A slow probe must not make a working
relay feel broken.

DVT4: the panel now states its own coverage boundary. It names the three
documented groups, then names the surfaces that are served and
undocumented — the NIP-29 WebSocket API, operator API, invites,
moderation, workflow webhooks, Blossom media, git smart HTTP — and says
that their absence is a gap in the document rather than in the relay.
Widening the document is meridian-g0xo and stays open; this closes the
misreading, which was the whole of DVT4.

Developer tools moves alpha -> beta. DVT3 was the alpha blocker; DVT5
(mobile parity) keeps it short of stable.

10 unit tests over the state mapping, 4 over the status classification,
5 Playwright specs — one per state, plus the per-path case proving a row
reads its own path rather than the worst of them.

Signed-off-by: Joshua Belke <joshua@innovationhub-act.org>
This commit is contained in:
Josh Belke 2026-08-08 15:06:08 -04:00
commit 5efb1b10dd
11 changed files with 640 additions and 59 deletions

View file

@ -1,12 +1,10 @@
# Feature spec — Developer Tools
<!-- preview-contract: {"id":"developerTools","stage":"alpha","platforms":["desktop"]} -->
<!-- delivered-gaps: ["DVT1","DVT2"] -->
<!-- preview-contract: {"id":"developerTools","stage":"beta","platforms":["desktop"]} -->
<!-- delivered-gaps: ["DVT1","DVT2","DVT3","DVT4"] -->
<!-- current-gaps:start -->
| id | state | severity |
| --- | --- | --- |
| DVT3 | open | medium |
| DVT4 | open | low |
| DVT5 | partial | low |
<!-- current-gaps:end -->
@ -42,12 +40,40 @@ document rather than to a vendor's copy.
and the group keeps it from crowding a settings list organised around people
and relays.
## Delivered (2026-08-08)
- **DVT3 — every relay link is probed before it is offered.** `probe_relay_docs`
(`commands/workspace.rs`) GETs `/docs`, `/docs/openapi.json` and `/info`
concurrently and returns a per-path outcome, never an error — the same
no-error contract `fetch_relay_mips` already uses.
**Three outcomes, not a boolean**, because the two failures need different
sentences in front of an operator. A relay answering `404` is working
correctly and merely predates the reference, so the row says so; a relay that
does not answer at all is a network problem and says *that*. Collapsing them
would tell someone their documentation is broken when their relay is only
older than it — which is the exact misreading DVT3 named.
**Two more states never disable anything.** While the probe is in flight, and
when the probe itself could not run, links stay exactly as they were. "We are
asking" and "we could not ask" are facts about this build, not about the
relay, and rendering either as a relay failure is the lie `useRelayMips`
already refuses. Pinned by `docsProbe.test.mjs` (10 unit tests) and
`developer-tools.spec.ts` (5 specs, one per state plus the per-path case).
- **DVT4 — the panel states its own coverage boundary.** A "What the reference
covers" row names the three documented groups and then names, explicitly, the
surfaces that are *served and undocumented*: the NIP-29 WebSocket API, the
operator API, invites, moderation, workflow webhooks, Blossom media and git
smart HTTP — closing with the line that does the work, that their absence is
a gap in the document rather than in the relay. Widening the document itself
is `meridian-g0xo` and stays open; this closes the *misreading*, which was
the whole of DVT4.
## Gap Register
| # | Gap | Evidence | Severity |
| --- | --- | --- | --- |
| DVT3 | No reachability check. Every link renders as soon as a URL can be derived, whether or not anything answers there. A relay built before the reference shipped, or one behind a proxy that does not forward `/docs`, opens a 404 in the member's browser with no explanation. The control plane's own `relays/info` already models the honest alternative — `reachable: false` as a rendered state. | `DeveloperTools.tsx` gates on `docs`/`controlPlane` being non-null, never on a probe | medium |
| DVT4 | The relay reference is narrower than the relay. It covers the Nostr bridge, NIP-11 metadata and health; the operator API, invites, moderation, workflow secrets, Blossom media and git smart HTTP are served and undocumented. The page says the WebSocket is not covered but does not say which HTTP paths are also missing, so absence reads as non-existence. | `crates/meridian-relay/src/api/docs.rs` registers the bridge, metadata and health groups only | low |
| DVT5 | No mobile surface. The Flutter client connects to the same relays and has no equivalent section, so the contract is discoverable from one client of two. Partial rather than open because the references themselves are served by the relay and reachable from any browser — what is missing is the shortcut, not the document. | `platforms: ["desktop"]` in the contract above | low |
## Non-Goals
@ -62,6 +88,11 @@ document rather than to a vendor's copy.
## Graduation
Alpha until DVT3 closes. A link surface whose links can silently 404 is worse
than no link surface: it teaches a member that the documentation is broken when
the truth is that this relay predates it.
**Beta as of 2026-08-08.** DVT3 was the alpha blocker and is closed: no link is
offered without a probe behind it, and each of the four states renders its own
sentence rather than a shared shrug.
Beta rather than stable because DVT5 is open — the contract is shortcut-linked
from one client of two, and mobile parity is a graduation obligation under
[feature-preview-graduation.md](./feature-preview-graduation.md). Deciding it in
writing (delivered, or deferred with a reason) is what remains.

View file

@ -26,6 +26,7 @@ export default defineConfig({
"**/capability-packs.spec.ts",
"**/capability-packs-screenshots.spec.ts",
"**/apps-surface.spec.ts",
"**/video-wall.spec.ts",
"**/composer-budget.spec.ts",
"**/experiments-panel.spec.ts",
"**/agents-disabled-by-default.spec.ts",
@ -127,6 +128,7 @@ export default defineConfig({
"**/channel-sort.spec.ts",
"**/identity-lost.spec.ts",
"**/identity-git-settings.spec.ts",
"**/developer-tools.spec.ts",
"**/deep-link-invite.spec.ts",
"**/invite-link-copy.spec.ts",
"**/global-agent-config-screenshots.spec.ts",

View file

@ -202,6 +202,95 @@ pub async fn fetch_relay_mips(
})
}
/// What a single documentation path answered when probed.
///
/// Three outcomes rather than a boolean, because the two failures need
/// different sentences in front of an operator. A relay that answers `404` is
/// working correctly and simply predates the reference (or sits behind a proxy
/// that does not forward `/docs`); a relay that does not answer at all is a
/// network or deployment problem. Collapsing them would tell someone their
/// documentation is broken when their relay is merely older than it.
#[derive(Serialize, PartialEq, Eq, Debug, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum DocsPathOutcome {
/// The path answered 2xx. The link will open something.
Served,
/// The relay answered, but not with this document (4xx/5xx).
NotServed,
/// Nothing answered — transport error, timeout, or DNS failure.
Unreachable,
}
/// Classify an HTTP probe result into a [`DocsPathOutcome`].
///
/// Split out from the request so the mapping is testable without a server.
/// `None` means the request itself failed, which is [`DocsPathOutcome::Unreachable`]
/// — distinct from any status code, because a status code means something
/// answered.
fn classify_docs_status(status: Option<u16>) -> DocsPathOutcome {
match status {
None => DocsPathOutcome::Unreachable,
Some(code) if (200..300).contains(&code) => DocsPathOutcome::Served,
Some(_) => DocsPathOutcome::NotServed,
}
}
/// Per-path reachability of the relay's developer documentation.
#[derive(Serialize, PartialEq, Eq, Debug)]
pub struct RelayDocsProbe {
/// `/docs` — the rendered Scalar reference.
pub reference: DocsPathOutcome,
/// `/docs/openapi.json` — the raw specification.
pub spec: DocsPathOutcome,
/// `/info` — the NIP-11 document.
pub info: DocsPathOutcome,
}
/// Probe the documentation paths of the relay at `origin`.
///
/// Answers the one question the Developer tools panel could not: *will this
/// link open anything?* Deriving a URL proves only that the relay URL parsed.
/// Every link rendered as soon as a URL could be built, so a relay built before
/// the reference shipped sent the member to a 404 in their browser with no
/// explanation — which reads as "the documentation is broken" rather than
/// "this relay is older than the documentation".
///
/// Unauthenticated GETs against paths the relay serves publicly, with the same
/// no-error contract as [`fetch_relay_mips`]: a failure is a rendered state,
/// never a rejected promise.
#[tauri::command]
pub async fn probe_relay_docs(
origin: String,
state: State<'_, AppState>,
) -> Result<RelayDocsProbe, String> {
async fn probe(client: &reqwest::Client, url: String) -> DocsPathOutcome {
classify_docs_status(
client
.get(&url)
.send()
.await
.ok()
.map(|r| r.status().as_u16()),
)
}
let base = origin.trim_end_matches('/').to_owned();
let client = &state.http_client;
// Concurrent: three sequential timeouts against a dead host would keep the
// panel in "checking" for three times as long as it needs to be.
let (reference, spec, info) = tokio::join!(
probe(client, format!("{base}/docs")),
probe(client, format!("{base}/docs/openapi.json")),
probe(client, format!("{base}/info")),
);
Ok(RelayDocsProbe {
reference,
spec,
info,
})
}
#[derive(Serialize)]
pub struct ActiveWorkspaceInfo {
relay_url: String,
@ -466,4 +555,35 @@ mod tests {
};
assert_ne!(unknown, answered_none);
}
#[test]
fn a_served_document_is_only_a_2xx() {
assert_eq!(classify_docs_status(Some(200)), DocsPathOutcome::Served);
assert_eq!(classify_docs_status(Some(204)), DocsPathOutcome::Served);
assert_eq!(classify_docs_status(Some(299)), DocsPathOutcome::Served);
}
#[test]
fn an_answered_error_is_not_served_rather_than_unreachable() {
// The distinction the panel renders: a relay that 404s is working and
// simply predates the reference. Calling that "unreachable" would send
// an operator to debug their network.
assert_eq!(classify_docs_status(Some(404)), DocsPathOutcome::NotServed);
assert_eq!(classify_docs_status(Some(403)), DocsPathOutcome::NotServed);
assert_eq!(classify_docs_status(Some(500)), DocsPathOutcome::NotServed);
}
#[test]
fn only_a_failed_request_is_unreachable() {
assert_eq!(classify_docs_status(None), DocsPathOutcome::Unreachable);
}
#[test]
fn a_redirect_is_not_treated_as_served() {
// reqwest follows redirects by default, so a 3xx reaching this function
// means the redirect chain ended unresolved. Rendering that as "Served"
// would put a working button in front of a link that goes nowhere.
assert_eq!(classify_docs_status(Some(301)), DocsPathOutcome::NotServed);
assert_eq!(classify_docs_status(Some(302)), DocsPathOutcome::NotServed);
}
}

View file

@ -907,6 +907,7 @@ pub fn run() {
get_active_workspace,
fetch_workspace_icon,
fetch_relay_mips,
probe_relay_docs,
fetch_relay_feedback_policy,
fetch_join_policy,
set_prevent_sleep_active,

View file

@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { docsProbeSummary, docsRowState } from "./docsProbe.ts";
const probed = (overrides = {}) => ({
status: "probed",
probe: {
reference: "served",
spec: "served",
info: "served",
...overrides,
},
});
test("a served path is openable with nothing to explain", () => {
const row = docsRowState(probed(), "reference");
assert.equal(row.enabled, true);
assert.equal(row.reason, null);
});
test("a path the relay does not serve is disabled and says why", () => {
const row = docsRowState(probed({ reference: "not_served" }), "reference");
assert.equal(row.enabled, false);
assert.match(row.reason, /does not serve/);
// The blocker DVT3 named: the member must learn their relay predates the
// reference, not that the documentation is broken.
assert.match(row.reason, /built before the reference/);
});
test("an unreachable relay disables the link without blaming the document", () => {
const row = docsRowState(probed({ spec: "unreachable" }), "spec");
assert.equal(row.enabled, false);
assert.match(row.reason, /did not answer/);
assert.doesNotMatch(row.reason, /does not serve/);
});
test("each row reads its own path, not the worst of them", () => {
const state = probed({ reference: "not_served" });
assert.equal(docsRowState(state, "reference").enabled, false);
assert.equal(docsRowState(state, "spec").enabled, true);
assert.equal(docsRowState(state, "info").enabled, true);
});
test("links stay enabled while the probe is still running", () => {
// A slow probe must not make a working relay feel broken. Only a definite
// answer disables anything.
const row = docsRowState({ status: "checking" }, "reference");
assert.equal(row.enabled, true);
assert.equal(row.reason, null);
});
test("a probe that could not run leaves links exactly as they were", () => {
// "We could not ask" is not "the relay does not serve it". Rendering the
// former as the latter is the lie useRelayMips already refuses.
const row = docsRowState({ status: "unknown" }, "reference");
assert.equal(row.enabled, true);
assert.equal(row.reason, null);
});
test("the summary stays quiet when every document is served", () => {
assert.equal(docsProbeSummary(probed()), null);
});
test("the summary names a total failure differently from a partial one", () => {
const allDown = docsProbeSummary(
probed({
reference: "unreachable",
spec: "unreachable",
info: "unreachable",
}),
);
assert.match(allDown, /did not answer/);
const partial = docsProbeSummary(probed({ spec: "not_served" }));
assert.match(partial, /Some references/);
assert.notEqual(allDown, partial);
});
test("the summary says it is checking while the probe runs", () => {
assert.match(docsProbeSummary({ status: "checking" }), /Checking/);
});
test("an unknown probe adds no summary line at all", () => {
assert.equal(docsProbeSummary({ status: "unknown" }), null);
});

View file

@ -0,0 +1,97 @@
/**
* Reachability state for the relay's developer documentation links.
*
* Deriving a URL proves that the relay URL parsed, and nothing else. Every link
* in Developer tools used to render on that basis alone, so a relay built
* before the reference shipped — or one behind a proxy that does not forward
* `/docs` — sent the member to a 404 in their browser with no explanation. A
* 404 reached from a documentation button reads as "the documentation is
* broken", when the truth is "this relay is older than the documentation".
*
* Four states, because four different sentences belong in front of an operator.
* `checking` and `unknown` are deliberately distinct: one is "we are asking",
* the other is "we could not ask", and rendering either as a failure of the
* relay would be the same class of lie `useRelayMips` already refuses.
*/
/** What one documented path answered. Mirrors `DocsPathOutcome` in the relay commands. */
export type DocsPathOutcome = "served" | "not_served" | "unreachable";
/** Per-path probe result. Mirrors `RelayDocsProbe`. */
export type RelayDocsProbe = {
reference: DocsPathOutcome;
spec: DocsPathOutcome;
info: DocsPathOutcome;
};
/** The probe's lifecycle as the panel sees it. */
export type DocsProbeState =
| { status: "checking" }
| { status: "unknown" }
| { status: "probed"; probe: RelayDocsProbe };
/** Which documented path a row links to. */
export type DocsPathKey = keyof RelayDocsProbe;
export type DocsRowState = {
/** Whether the link may be opened. */
enabled: boolean;
/**
* Why the link is unopenable, or `null` when there is nothing to explain.
* Rendered next to the row — a disabled button with no reason is the silent
* empty result this panel exists to stop producing.
*/
reason: string | null;
};
/**
* Resolve one row's button state from the probe.
*
* While `checking`, the button stays **enabled**. A probe is not a
* precondition for a link that would have worked before this feature existed,
* and disabling during a slow probe would make a working relay feel broken.
* Only a definite answer disables anything.
*/
export function docsRowState(
state: DocsProbeState,
path: DocsPathKey,
): DocsRowState {
if (state.status === "checking" || state.status === "unknown") {
return { enabled: true, reason: null };
}
switch (state.probe[path]) {
case "served":
return { enabled: true, reason: null };
case "not_served":
return {
enabled: false,
reason:
"This relay does not serve this document. It was most likely built before the reference shipped, or sits behind a proxy that does not forward it.",
};
case "unreachable":
return {
enabled: false,
reason: "The relay did not answer, so this link cannot be checked.",
};
}
}
/**
* A single line summarising the probe for the group header, or `null` when
* there is nothing worth saying.
*
* Says nothing when everything is served — a banner confirming that links work
* is noise on the common path. It speaks up only when a link will not.
*/
export function docsProbeSummary(state: DocsProbeState): string | null {
if (state.status === "checking") return "Checking what this relay serves…";
if (state.status === "unknown") return null;
const outcomes = Object.values(state.probe);
if (outcomes.every((outcome) => outcome === "served")) return null;
if (outcomes.every((outcome) => outcome === "unreachable")) {
return "This relay did not answer. The links below cannot be checked.";
}
return "Some references are not served by this relay.";
}

View file

@ -4,6 +4,13 @@ import * as React from "react";
import { Button } from "@/shared/ui/button";
import {
type DocsPathKey,
type DocsProbeState,
docsProbeSummary,
docsRowState,
type RelayDocsProbe,
} from "../lib/docsProbe";
import { controlPlaneDocsUrls, relayDocsUrls } from "../lib/relayDocsUrl";
import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup";
import {
@ -26,7 +33,63 @@ import {
* The control-plane group is second and appears only when this build has one
* configured — it is a separate service on its own port, and a deployment
* without one must show nothing rather than a dead link.
*
* Each relay link is probed before it is offered. Deriving a URL proves the
* relay URL parsed and nothing more, so a relay predating the reference used to
* send the member to a 404 with no explanation — which reads as broken
* documentation rather than an older relay.
*/
/** One row of a documentation group. */
function DocsRow({
description,
onOpen,
probe,
path,
testId,
title,
actionLabel,
}: {
description: string;
onOpen: (() => void) | null;
probe: DocsProbeState;
path: DocsPathKey;
testId: string;
title: string;
actionLabel: string;
}) {
const state = docsRowState(probe, path);
const enabled = Boolean(onOpen) && state.enabled;
return (
<SettingsOptionRow>
<div className="min-w-0">
<p className="text-sm font-medium">{title}</p>
<p className="text-sm font-normal text-muted-foreground">
{description}
</p>
{state.reason ? (
<p
className="mt-1 text-sm font-normal text-muted-foreground"
data-testid={`${testId}-reason`}
>
{state.reason}
</p>
) : null}
</div>
<Button
data-testid={testId}
disabled={!enabled}
onClick={() => onOpen?.()}
size="sm"
variant="outline"
>
{actionLabel}
</Button>
</SettingsOptionRow>
);
}
export function DeveloperTools() {
const [relayUrl, setRelayUrl] = React.useState<string | undefined>(undefined);
const [resolved, setResolved] = React.useState(false);
@ -69,6 +132,35 @@ export function DeveloperTools() {
const docs = relayDocsUrls(relayUrl);
const controlPlane = controlPlaneDocsUrls(controlPlaneOrigin);
const [probe, setProbe] = React.useState<DocsProbeState>({
status: "checking",
});
const docsOrigin = docs?.origin;
React.useEffect(() => {
if (!docsOrigin) {
// Nothing to probe. Not "unreachable" — there is no relay to reach.
setProbe({ status: "unknown" });
return;
}
let cancelled = false;
setProbe({ status: "checking" });
invoke<RelayDocsProbe>("probe_relay_docs", { origin: docsOrigin })
.then((result) => {
if (!cancelled) setProbe({ status: "probed", probe: result });
})
.catch(() => {
// The native side did not answer. That is a fact about this build, not
// about the relay, so the links stay exactly as they were.
if (!cancelled) setProbe({ status: "unknown" });
});
return () => {
cancelled = true;
};
}, [docsOrigin]);
const probeSummary = docsProbeSummary(probe);
return (
<section className="min-w-0" data-testid="settings-developer-tools">
<SettingsSectionHeader
@ -89,62 +181,68 @@ export function DeveloperTools() {
id="developer-tools-relay"
title="Relay"
/>
{probeSummary ? (
<p
className="mb-2 text-sm font-normal text-muted-foreground"
data-testid="relay-docs-probe-summary"
>
{probeSummary}
</p>
) : null}
<SettingsOptionGroup>
<SettingsOptionRow>
<div className="min-w-0">
<p className="text-sm font-medium">API reference</p>
<p className="text-sm font-normal text-muted-foreground">
The relay's HTTP surface, rendered from its own OpenAPI
document. The primary API is NIP-29 over WebSocket and is not
covered there.
</p>
</div>
<Button
data-testid="relay-api-docs"
disabled={!docs}
onClick={() => docs && void openUrl(docs.reference)}
size="sm"
variant="outline"
>
Open docs
</Button>
</SettingsOptionRow>
<DocsRow
actionLabel="Open docs"
description="The relay's HTTP surface, rendered from its own OpenAPI document."
onOpen={docs ? () => void openUrl(docs.reference) : null}
path="reference"
probe={probe}
testId="relay-api-docs"
title="API reference"
/>
<SettingsOptionRow>
<div className="min-w-0">
<p className="text-sm font-medium">OpenAPI document</p>
<p className="text-sm font-normal text-muted-foreground">
The raw specification, for generating a client.
</p>
</div>
<Button
data-testid="relay-openapi-spec"
disabled={!docs}
onClick={() => docs && void openUrl(docs.spec)}
size="sm"
variant="outline"
>
View spec
</Button>
</SettingsOptionRow>
<DocsRow
actionLabel="View spec"
description="The raw specification, for generating a client."
onOpen={docs ? () => void openUrl(docs.spec) : null}
path="spec"
probe={probe}
testId="relay-openapi-spec"
title="OpenAPI document"
/>
<DocsRow
actionLabel="View NIP-11"
description="The NIP-11 document: supported NIPs, limits, and the MIPs this relay advertises."
onOpen={docs ? () => void openUrl(docs.info) : null}
path="info"
probe={probe}
testId="relay-nip11"
title="Relay information"
/>
{/* DVT4: the reference is narrower than the relay, and silence about
that makes absence read as non-existence. An integrator who
cannot find media upload in the document should learn it is
undocumented, not conclude the relay cannot do it. */}
<SettingsOptionRow>
<div className="min-w-0">
<p className="text-sm font-medium">Relay information</p>
<p className="text-sm font-medium">What the reference covers</p>
<p className="text-sm font-normal text-muted-foreground">
The NIP-11 document: supported NIPs, limits, and the MIPs this
relay advertises.
The Nostr bridge (<code>/events</code>, <code>/query</code>,{" "}
<code>/count</code>), NIP-11 metadata, and health probes.
</p>
<p
className="mt-1 text-sm font-normal text-muted-foreground"
data-testid="relay-docs-coverage-gaps"
>
Served but <strong>not</strong> described there: the primary
NIP-29 WebSocket API, the operator API, invites, moderation,
workflow webhooks, Blossom media, and git smart HTTP. Their
absence from the document is a gap in the document, not in the
relay.
</p>
</div>
<Button
data-testid="relay-nip11"
disabled={!docs}
onClick={() => docs && void openUrl(docs.info)}
size="sm"
variant="outline"
>
View NIP-11
</Button>
</SettingsOptionRow>
{resolved && !docs ? (

View file

@ -335,6 +335,19 @@ type E2eConfig = {
* that never answered — which must render as *unknown*, never as local.
*/
feedbackPolicy?: "local" | "disabled" | "unreachable";
/**
* Per-path outcome returned by `probe_relay_docs`. Defaults to a relay that
* serves all three documents. `"unavailable"` makes the invoke reject,
* modelling a native side that could not answer — which must leave the
* links exactly as they were rather than blaming the relay.
*/
relayDocsProbe?:
| {
reference: "served" | "not_served" | "unreachable";
spec: "served" | "not_served" | "unreachable";
info: "served" | "not_served" | "unreachable";
}
| "unavailable";
/** Delay EOSE for membership snapshots after delivering the event. */
relayMembershipEoseDelayMs?: number;
relayRole?: "owner" | "admin" | "member" | null;
@ -11854,6 +11867,22 @@ export function maybeInstallE2eTauriMocks() {
relay_url: DEFAULT_RELAY_WS_URL,
pubkey: MOCK_IDENTITY_PUBKEY,
};
case "probe_relay_docs": {
// Mirrors the Rust command: per-path outcomes, never an error. The
// default is the healthy relay every other spec assumes; a spec that
// wants an older relay sets `relayDocsProbe` to say so.
const configured = activeConfig?.mock?.relayDocsProbe;
if (configured === "unavailable") {
throw new Error("probe_relay_docs unavailable");
}
return (
configured ?? {
reference: "served",
spec: "served",
info: "served",
}
);
}
case "fetch_relay_feedback_policy": {
// Mirrors the Rust command: an unreachable relay reports
// `reachable: false` rather than erroring, so the client can tell

View file

@ -0,0 +1,104 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
/**
* DVT3: a documentation link used to render as soon as a URL could be derived,
* so a relay predating the reference sent the member to a 404 in their browser
* with no explanation. These specs pin the four states apart — the two failures
* need different sentences, and the two "no answer yet" cases must not disable
* anything at all.
*/
test("offers every reference a relay actually serves", async ({ page }) => {
await installMockBridge(page, {});
await page.goto("/");
await openSettings(page, "developer-tools");
await expect(page.getByTestId("relay-api-docs")).toBeEnabled();
await expect(page.getByTestId("relay-openapi-spec")).toBeEnabled();
await expect(page.getByTestId("relay-nip11")).toBeEnabled();
// Silence is the point on the healthy path: a banner confirming that links
// work is noise.
await expect(page.getByTestId("relay-docs-probe-summary")).toHaveCount(0);
await expect(page.getByTestId("relay-api-docs-reason")).toHaveCount(0);
});
test("disables a reference this relay does not serve and says it is the relay's age", async ({
page,
}) => {
await installMockBridge(page, {
relayDocsProbe: {
reference: "not_served",
spec: "not_served",
info: "served",
},
});
await page.goto("/");
await openSettings(page, "developer-tools");
await expect(page.getByTestId("relay-api-docs")).toBeDisabled();
await expect(page.getByTestId("relay-api-docs-reason")).toContainText(
"built before the reference",
);
// Per-path, not worst-of: NIP-11 is served here and must stay openable.
await expect(page.getByTestId("relay-nip11")).toBeEnabled();
await expect(page.getByTestId("relay-nip11-reason")).toHaveCount(0);
await expect(page.getByTestId("relay-docs-probe-summary")).toContainText(
"Some references",
);
});
test("a relay that never answered is not reported as missing its documents", async ({
page,
}) => {
await installMockBridge(page, {
relayDocsProbe: {
reference: "unreachable",
spec: "unreachable",
info: "unreachable",
},
});
await page.goto("/");
await openSettings(page, "developer-tools");
await expect(page.getByTestId("relay-docs-probe-summary")).toContainText(
"did not answer",
);
await expect(page.getByTestId("relay-api-docs-reason")).not.toContainText(
"does not serve",
);
});
test("a probe that could not run leaves every link exactly as it was", async ({
page,
}) => {
// "We could not ask" is a fact about this build, not about the relay.
// Disabling here would invent a relay failure out of a local one.
await installMockBridge(page, {
relayDocsProbe: "unavailable",
});
await page.goto("/");
await openSettings(page, "developer-tools");
await expect(page.getByTestId("relay-api-docs")).toBeEnabled();
await expect(page.getByTestId("relay-docs-probe-summary")).toHaveCount(0);
});
test("names the served surfaces the reference does not describe", async ({
page,
}) => {
// DVT4: absence from the document must not read as absence from the relay.
await installMockBridge(page, {});
await page.goto("/");
await openSettings(page, "developer-tools");
const gaps = page.getByTestId("relay-docs-coverage-gaps");
await expect(gaps).toContainText("git smart HTTP");
await expect(gaps).toContainText("Blossom media");
await expect(gaps).toContainText("gap in the document, not in the relay");
});

View file

@ -334,6 +334,19 @@ type MockBridgeOptions = {
* answered, which must render as *unknown* rather than as local.
*/
feedbackPolicy?: "local" | "disabled" | "unreachable";
/**
* Per-path outcome returned by `probe_relay_docs`. Defaults to a relay that
* serves all three documents. `"unavailable"` makes the invoke reject,
* modelling a native side that could not answer — which must leave the
* links exactly as they were rather than blaming the relay.
*/
relayDocsProbe?:
| {
reference: "served" | "not_served" | "unreachable";
spec: "served" | "not_served" | "unreachable";
info: "served" | "not_served" | "unreachable";
}
| "unavailable";
/**
* Active identity's role in the seeded `mockRelayMembers`. `null` removes
* the active identity from the membership list entirely (admin-path branch

View file

@ -36,8 +36,8 @@
{
"id": "developerTools",
"name": "Developer tools",
"description": "An Advanced settings section with links to the OpenAPI reference for the relay you are connected to and for this deployment's control plane.",
"stage": "alpha",
"description": "An Advanced settings section with links to the OpenAPI reference for the relay you are connected to and for this deployment's control plane. Each link is checked against the relay before it is offered.",
"stage": "beta",
"since": "2026-08-06",
"platforms": ["desktop"]
},