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:
parent
65f53e480b
commit
5efb1b10dd
11 changed files with 640 additions and 59 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
86
desktop/src/features/settings/lib/docsProbe.test.mjs
Normal file
86
desktop/src/features/settings/lib/docsProbe.test.mjs
Normal 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);
|
||||
});
|
||||
97
desktop/src/features/settings/lib/docsProbe.ts
Normal file
97
desktop/src/features/settings/lib/docsProbe.ts
Normal 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.";
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
104
desktop/tests/e2e/developer-tools.spec.ts
Normal file
104
desktop/tests/e2e/developer-tools.spec.ts
Normal 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");
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue