Clarify external-probe outage and buffering limits

Separate remote check execution from server-side alert delivery, explain indeterminate and bounded-buffer behaviour, and link independent watchdog coverage without a permanent Mobile or Relay promise. Preserve the existing probe and notification contracts and verify both shipped guides and their keyboard help paths.

Contract-Neutral: Existing help, its shipped mirrors and verification only; no runtime, entitlement, alert policy or API changes.
Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-04 07:21:34 +01:00
parent 903dc08d23
commit ad4fbff6be
6 changed files with 326 additions and 92 deletions
+40 -24
View File
@@ -577,30 +577,46 @@ alerts, and resource projections keep one canonical protocol value.
### External probes (Pro)
By default every availability check runs from the Pulse server itself, which
means a check cannot report the one failure that matters most: the site
running Pulse losing its connectivity. With the Pro `external_probe`
entitlement, a check can instead be assigned to any connected Pulse agent
(`probeAgentId` in the API, "Run from" in the UI) — for example an agent on a
cloud VM or at another site. The assigned agent receives the check through
its signed agent configuration, runs it on the configured interval, and
reports results back with its reports; results are only accepted from the
currently assigned agent. An assigned check is not also run locally. If no
report arrives for several intervals the check shows as indeterminate
("no recent report from probe agent"). Freshness uses the Pulse server's receipt
time, so clock drift on the probe cannot create or hide a disconnect. After at
least five minutes without a report, Pulse raises one warning per disconnected
probe through the normal notification routes, even when that probe owns several
checks. The incident is keyed to the probe agent rather than an arbitrary check,
so changing assignments does not reopen it. If the agent heartbeat is also
offline, the existing
host-offline alert owns the incident instead of producing a duplicate probe
warning. A paired Pulse Mobile client receives a privacy-safe
`external_probe_offline` Relay push linked to that canonical alert. If the whole
Pulse instance becomes unreachable, Relay's independent five-minute
instance-offline push covers the dark-site case. If the entitlement lapses, the
check automatically resumes running from the Pulse server. Checks without an
assignment are unaffected and remain available in every edition.
External probes change **where a check runs**, not where alerts are sent.
By default, checks run on the Pulse server. With the Pro `external_probe`
entitlement, assign a connected Pulse agent using **Run from** in the UI
(`probeAgentId` in the API). An agent on a cloud VM or at another site can
observe a service from outside its local network.
The assigned agent receives signed configuration, runs the check and reports
results back to Pulse. Only the currently assigned agent's results are accepted;
an assigned check is not also run locally. **The agent does not send
notifications directly.** The Pulse server must be running and able to reach
the notification destination to evaluate results and send alerts.
| Situation | What the check can tell you |
| --- | --- |
| Pulse is running and receives probe results | Target observations are evaluated through the normal alert and notification policies. |
| Pulse is running but probe results stop arriving | The check becomes indeterminate: "no recent report from probe agent". This is missing evidence, not proof the target is down. |
| Pulse is stopped or cannot reach its notification destination | An external agent cannot deliver Pulse alerts in its place. Use an independent external watchdog for this failure. |
The missing-report window is the longer of **five minutes or three check
intervals**, measured from server receipt time, not the agent's clock. During
normal server evaluation, eligible stale probes raise one warning per agent,
not per assigned check. Alert and connectivity policies still apply. If the
agent heartbeat is also offline, the host-offline alert owns the incident
instead of a duplicate probe warning. See the
[agent probe guide](UNIFIED_AGENT.md#external-probes-pro) for buffering limits.
For an outage of Pulse itself, configure an
[external watchdog](TROUBLESHOOTING.md#no-alert-when-pulse-power-or-internet-goes-down)
outside the power and network failure you need to detect, with an independently
reachable notification destination. Verify actual receipt in an authorised test
environment; a successful destination test does not prove outage coverage.
Existing paired Pulse Mobile/Relay users retain their current push path until
**31 March 2027**. Relay is no longer sold. Its instance-disconnect push does not
evaluate individual targets while Pulse is offline, and is not a permanent
substitute for the watchdog. See [Mobile retirement](RELAY.md).
If the entitlement lapses, the check resumes running from the Pulse server:
this changes its observation location and may change its result. Checks without
an agent assignment remain available in every edition.
### ICMP probe privileges
+33 -22
View File
@@ -305,40 +305,51 @@ storage nodes to avoid duplicate or ambiguously correlated readings.
With the Pro `external_probe` entitlement, availability checks configured in
Pulse can be assigned to run from a specific agent instead of the Pulse
server (Settings -> Monitoring -> Availability checks -> "Run from"). This is
how you monitor a site from the outside: deploy the agent on a machine
elsewhere — a cloud VM, a Docker host at another location — and assign checks
to it. Target failures are evaluated on the Pulse server through your normal
alert routes.
how you observe a service from outside its local network: deploy the agent on
a machine elsewhere — a cloud VM, a Docker host at another location — and
assign checks to it. **The agent does not send notifications directly.** The
Pulse server must be running and able to reach the notification destination;
target failures are evaluated there through your normal alert routes.
There is nothing to configure on the agent itself. Assignments arrive through
the agent's signed remote configuration, the agent runs each check on its
configured interval, and results are delivered with its regular reports.
Results survive temporary connectivity loss to the Pulse server in a bounded
in-memory queue; if the agent cannot deliver for several check intervals the
check shows as indeterminate in Pulse until reports resume. After the
five-minute minimum grace window, Pulse raises one
Results waiting for delivery use a bounded **in-memory** queue of up to 200
observations across the agent's checks. Oldest pending observations are dropped
when it fills, and an agent restart loses the queue; it is not a complete outage
record. If reports stop arriving, the check becomes indeterminate ("no recent
report from probe agent"). This is missing evidence, not proof the target is
down. The missing-report window is the
longer of **five minutes or three check intervals**. During normal server
evaluation, eligible stale probes raise one
`availability_probe_unavailable` warning per disconnected probe, regardless of
how many checks it owns. Pulse measures that reporting window from server receipt
time rather than the agent's clock, so clock skew cannot create or conceal the
disconnect. That warning uses the normal email, webhook, Apprise, and
recovery-notification pipeline. When Pulse Mobile is paired through Relay, Pulse
also sends a privacy-safe `external_probe_offline` push linked to the canonical
mobile attention item without exposing target names or addresses. The alert
identity belongs to the probe agent, so adding or removing an assigned check
does not resolve and reopen it.
recovery-notification pipeline, subject to alert and connectivity policies.
The alert identity belongs to the probe agent, so adding or removing an assigned
check does not resolve and reopen it.
When the host heartbeat itself is offline, Pulse keeps the existing
host-offline alert as the single canonical incident and suppresses the
probe-results warning. Assigned probe hosts still receive the external-probe
mobile push, but operators do not get two normal alerts for the same agent
failure.
probe-results warning, rather than producing two normal alerts for the same
agent failure.
This has a complementary dark-site path: if the entire Pulse instance or its
site goes offline, Pulse Relay independently sends its existing instance
offline push after five minutes. Together, probe-loss alerts while Pulse is
online and Relay's instance-loss alert while Pulse is dark ensure the
outside-monitoring path cannot disappear silently. Relay does not evaluate
individual target results while the Pulse server is offline.
If Pulse itself stops or loses outbound connectivity, the external agent cannot
deliver Pulse alerts in its place. Configure an
[external watchdog](TROUBLESHOOTING.md#no-alert-when-pulse-power-or-internet-goes-down)
outside the shared power/network failure, with an independently reachable
notification destination. Verify recipient delivery in an authorised test
environment, not by interrupting a production site.
Existing paired Pulse Mobile/Relay users retain their current push path until
**31 March 2027**. Relay is no longer sold. Its instance-disconnect push does not
evaluate individual targets while Pulse is offline, and is not a permanent
substitute for the watchdog. See [Mobile retirement](RELAY.md).
If the external-probe entitlement lapses, checks resume on the Pulse server,
not the agent. That changes the observation location and may change the result;
see the [configuration guide](CONFIGURATION.md#external-probes-pro).
The module appears as `availability` in the agent's module status when at
least one check is assigned.
@@ -0,0 +1,124 @@
// Render the existing probe help and its outage/retirement links with the real
// Docs page. No agent, destination, watchdog or Mobile operation is performed.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { chromium, webkit } = require('playwright');
(async () => {
const root = '/workspace/frontend-modern';
const artifacts = path.join(root, 'node_modules', 'probe-guidance-browser');
fs.mkdirSync(artifacts, { recursive: true });
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
root,
configFile: path.join(root, 'vite.config.ts'),
cacheDir: path.join(root, 'node_modules', '.vite-probe-guidance'),
server: { host: '127.0.0.1', port: 5253, strictPort: true },
optimizeDeps: { noDiscovery: true, include: [] },
});
const results = [];
let browser;
try {
await server.listen();
for (const [engine, width] of [['chromium', 1280], ['webkit', 390]]) {
browser = await { chromium, webkit }[engine].launch(engine === 'chromium'
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true });
const page = await browser.newPage({ viewport: { width, height: 1000 },
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) });
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text());
});
await page.goto('http://127.0.0.1:5253/browser-tests/docs-fragment-navigation.html?scenario=configuration',
{ waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.getByRole('heading', { name: 'External probes (Pro)', exact: true }).waitFor();
if (engine === 'webkit') await page.evaluate(() => document.documentElement.classList.add('dark'));
const table = page.getByRole('table').filter({ has: page.getByRole('columnheader',
{ name: 'Situation', exact: true }) });
assert.equal(await table.getByRole('row').count(), 4);
assert.ok(await table.evaluate((element) => {
const parent = element.parentElement;
return parent.dataset.docTableScroll !== undefined &&
getComputedStyle(parent).overflowX === 'auto' &&
[...element.querySelectorAll('thead th')].every((header) => header.scope === 'col');
}));
const assertBoundaries = async () => {
const body = (await page.locator('article').innerText()).replace(/\s+/g, ' ');
for (const text of ['The agent does not send notifications directly.',
'Pulse server must be running and able to reach the notification destination',
'not proof the target is down', 'five minutes or three check intervals',
'31 March 2027', 'Relay is no longer sold', 'not a permanent substitute']) {
assert.ok(body.includes(text), text);
}
assert.ok(!body.includes('cannot disappear silently'));
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
};
const screenshot = async (name, heading) => {
await page.getByRole('heading', { name: heading, exact: true })
.evaluate((element) => element.scrollIntoView({ block: 'start' }));
await page.evaluate(async () => {
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
await Promise.all(document.getAnimations().map((animation) => animation.finished.catch(() => {})));
});
await page.screenshot({ path: path.join(artifacts, `${engine}-${name}.png`) });
};
const follow = async (name, href, heading, id) => {
const link = page.getByRole('link', { name, exact: true });
assert.equal(await link.getAttribute('href'), href);
await link.focus();
await page.keyboard.press('Enter');
await page.waitForURL(`**${href}`);
await page.getByRole('heading', { name: heading, exact: true }).waitFor();
if (id) await page.waitForFunction((id) => document.activeElement?.id === id, id);
};
await assertBoundaries();
await screenshot('configuration', 'External probes (Pro)');
await follow('agent probe guide', '/docs/UNIFIED_AGENT#external-probes-pro',
'External Probes (Pro)', 'external-probes-pro');
await assertBoundaries();
const agentBody = (await page.locator('article').innerText()).replace(/\s+/g, ' ');
for (const text of ['up to 200 observations', 'Oldest pending observations are dropped',
'agent restart loses the queue', 'not a complete outage record']) assert.ok(agentBody.includes(text), text);
await screenshot('agent', 'External Probes (Pro)');
await follow('external watchdog', '/docs/TROUBLESHOOTING#no-alert-when-pulse-power-or-internet-goes-down',
'No alert when Pulse, power or internet goes down', 'no-alert-when-pulse-power-or-internet-goes-down');
assert.ok((await page.locator('article').innerText()).includes('period plus grace'));
await screenshot('watchdog', 'No alert when Pulse, power or internet goes down');
await page.goBack();
await page.getByRole('heading', { name: 'External Probes (Pro)', exact: true }).waitFor();
await follow('Mobile retirement', '/docs/RELAY', 'Relay / Pulse Mobile', '');
assert.ok((await page.locator('article').innerText()).includes('31 March 2027'));
await page.goBack();
await page.getByRole('heading', { name: 'External Probes (Pro)', exact: true }).waitFor();
await follow('configuration guide', '/docs/CONFIGURATION#external-probes-pro',
'External probes (Pro)', 'external-probes-pro');
await follow('external watchdog', '/docs/TROUBLESHOOTING#no-alert-when-pulse-power-or-internet-goes-down',
'No alert when Pulse, power or internet goes down', 'no-alert-when-pulse-power-or-internet-goes-down');
assert.deepEqual(errors, []);
results.push({ engine, version: browser.version(), width,
scope: engine === 'webkit' ? 'phone-emulated, not a native device' : 'desktop',
serverDependencyVisible: true, semanticContainedCoverageTable: true,
noDocumentOverflow: true, queueAndRetirementLimitsVisible: true,
keyboardAgentGuide: true, keyboardConfigurationGuide: true,
keyboardWatchdogLinks: 2, keyboardRetirementLink: true, errors });
await browser.close();
browser = undefined;
}
const files = ['public/docs/CONFIGURATION.md', 'public/docs/UNIFIED_AGENT.md',
'browser-tests/probe-guidance.cjs', 'browser-tests/docs-fragment-navigation.tsx',
'src/features/docs/docMarkdown.ts', 'src/pages/Docs.tsx'].map((file) => ({ file,
sha256: crypto.createHash('sha256').update(fs.readFileSync(path.join(root, file))).digest('hex') }));
const result = { result: 'passed', playwright: require('playwright/package.json').version,
scope: 'existing shipped Docs rendering and keyboard links only; no native outage or delivery acceptance',
files, results };
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n');
console.log(JSON.stringify(result));
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
+40 -24
View File
@@ -577,30 +577,46 @@ alerts, and resource projections keep one canonical protocol value.
### External probes (Pro)
By default every availability check runs from the Pulse server itself, which
means a check cannot report the one failure that matters most: the site
running Pulse losing its connectivity. With the Pro `external_probe`
entitlement, a check can instead be assigned to any connected Pulse agent
(`probeAgentId` in the API, "Run from" in the UI) — for example an agent on a
cloud VM or at another site. The assigned agent receives the check through
its signed agent configuration, runs it on the configured interval, and
reports results back with its reports; results are only accepted from the
currently assigned agent. An assigned check is not also run locally. If no
report arrives for several intervals the check shows as indeterminate
("no recent report from probe agent"). Freshness uses the Pulse server's receipt
time, so clock drift on the probe cannot create or hide a disconnect. After at
least five minutes without a report, Pulse raises one warning per disconnected
probe through the normal notification routes, even when that probe owns several
checks. The incident is keyed to the probe agent rather than an arbitrary check,
so changing assignments does not reopen it. If the agent heartbeat is also
offline, the existing
host-offline alert owns the incident instead of producing a duplicate probe
warning. A paired Pulse Mobile client receives a privacy-safe
`external_probe_offline` Relay push linked to that canonical alert. If the whole
Pulse instance becomes unreachable, Relay's independent five-minute
instance-offline push covers the dark-site case. If the entitlement lapses, the
check automatically resumes running from the Pulse server. Checks without an
assignment are unaffected and remain available in every edition.
External probes change **where a check runs**, not where alerts are sent.
By default, checks run on the Pulse server. With the Pro `external_probe`
entitlement, assign a connected Pulse agent using **Run from** in the UI
(`probeAgentId` in the API). An agent on a cloud VM or at another site can
observe a service from outside its local network.
The assigned agent receives signed configuration, runs the check and reports
results back to Pulse. Only the currently assigned agent's results are accepted;
an assigned check is not also run locally. **The agent does not send
notifications directly.** The Pulse server must be running and able to reach
the notification destination to evaluate results and send alerts.
| Situation | What the check can tell you |
| --- | --- |
| Pulse is running and receives probe results | Target observations are evaluated through the normal alert and notification policies. |
| Pulse is running but probe results stop arriving | The check becomes indeterminate: "no recent report from probe agent". This is missing evidence, not proof the target is down. |
| Pulse is stopped or cannot reach its notification destination | An external agent cannot deliver Pulse alerts in its place. Use an independent external watchdog for this failure. |
The missing-report window is the longer of **five minutes or three check
intervals**, measured from server receipt time, not the agent's clock. During
normal server evaluation, eligible stale probes raise one warning per agent,
not per assigned check. Alert and connectivity policies still apply. If the
agent heartbeat is also offline, the host-offline alert owns the incident
instead of a duplicate probe warning. See the
[agent probe guide](UNIFIED_AGENT.md#external-probes-pro) for buffering limits.
For an outage of Pulse itself, configure an
[external watchdog](TROUBLESHOOTING.md#no-alert-when-pulse-power-or-internet-goes-down)
outside the power and network failure you need to detect, with an independently
reachable notification destination. Verify actual receipt in an authorised test
environment; a successful destination test does not prove outage coverage.
Existing paired Pulse Mobile/Relay users retain their current push path until
**31 March 2027**. Relay is no longer sold. Its instance-disconnect push does not
evaluate individual targets while Pulse is offline, and is not a permanent
substitute for the watchdog. See [Mobile retirement](RELAY.md).
If the entitlement lapses, the check resumes running from the Pulse server:
this changes its observation location and may change its result. Checks without
an agent assignment remain available in every edition.
### ICMP probe privileges
+33 -22
View File
@@ -305,40 +305,51 @@ storage nodes to avoid duplicate or ambiguously correlated readings.
With the Pro `external_probe` entitlement, availability checks configured in
Pulse can be assigned to run from a specific agent instead of the Pulse
server (Settings -> Monitoring -> Availability checks -> "Run from"). This is
how you monitor a site from the outside: deploy the agent on a machine
elsewhere — a cloud VM, a Docker host at another location — and assign checks
to it. Target failures are evaluated on the Pulse server through your normal
alert routes.
how you observe a service from outside its local network: deploy the agent on
a machine elsewhere — a cloud VM, a Docker host at another location — and
assign checks to it. **The agent does not send notifications directly.** The
Pulse server must be running and able to reach the notification destination;
target failures are evaluated there through your normal alert routes.
There is nothing to configure on the agent itself. Assignments arrive through
the agent's signed remote configuration, the agent runs each check on its
configured interval, and results are delivered with its regular reports.
Results survive temporary connectivity loss to the Pulse server in a bounded
in-memory queue; if the agent cannot deliver for several check intervals the
check shows as indeterminate in Pulse until reports resume. After the
five-minute minimum grace window, Pulse raises one
Results waiting for delivery use a bounded **in-memory** queue of up to 200
observations across the agent's checks. Oldest pending observations are dropped
when it fills, and an agent restart loses the queue; it is not a complete outage
record. If reports stop arriving, the check becomes indeterminate ("no recent
report from probe agent"). This is missing evidence, not proof the target is
down. The missing-report window is the
longer of **five minutes or three check intervals**. During normal server
evaluation, eligible stale probes raise one
`availability_probe_unavailable` warning per disconnected probe, regardless of
how many checks it owns. Pulse measures that reporting window from server receipt
time rather than the agent's clock, so clock skew cannot create or conceal the
disconnect. That warning uses the normal email, webhook, Apprise, and
recovery-notification pipeline. When Pulse Mobile is paired through Relay, Pulse
also sends a privacy-safe `external_probe_offline` push linked to the canonical
mobile attention item without exposing target names or addresses. The alert
identity belongs to the probe agent, so adding or removing an assigned check
does not resolve and reopen it.
recovery-notification pipeline, subject to alert and connectivity policies.
The alert identity belongs to the probe agent, so adding or removing an assigned
check does not resolve and reopen it.
When the host heartbeat itself is offline, Pulse keeps the existing
host-offline alert as the single canonical incident and suppresses the
probe-results warning. Assigned probe hosts still receive the external-probe
mobile push, but operators do not get two normal alerts for the same agent
failure.
probe-results warning, rather than producing two normal alerts for the same
agent failure.
This has a complementary dark-site path: if the entire Pulse instance or its
site goes offline, Pulse Relay independently sends its existing instance
offline push after five minutes. Together, probe-loss alerts while Pulse is
online and Relay's instance-loss alert while Pulse is dark ensure the
outside-monitoring path cannot disappear silently. Relay does not evaluate
individual target results while the Pulse server is offline.
If Pulse itself stops or loses outbound connectivity, the external agent cannot
deliver Pulse alerts in its place. Configure an
[external watchdog](TROUBLESHOOTING.md#no-alert-when-pulse-power-or-internet-goes-down)
outside the shared power/network failure, with an independently reachable
notification destination. Verify recipient delivery in an authorised test
environment, not by interrupting a production site.
Existing paired Pulse Mobile/Relay users retain their current push path until
**31 March 2027**. Relay is no longer sold. Its instance-disconnect push does not
evaluate individual targets while Pulse is offline, and is not a permanent
substitute for the watchdog. See [Mobile retirement](RELAY.md).
If the external-probe entitlement lapses, checks resume on the Pulse server,
not the agent. That changes the observation location and may change the result;
see the [configuration guide](CONFIGURATION.md#external-probes-pro).
The module appears as `availability` in the agent's module status when at
least one check is assigned.
@@ -61,6 +61,62 @@ function getRuntimeSourceFiles(dir: string): string[] {
}
describe('docsLinks', () => {
const probeGuides = () => [
readFileSync(path.join(repoRoot, 'docs', 'CONFIGURATION.md'), 'utf8')
.split('### External probes (Pro)')[1].split('### ICMP probe privileges')[0],
readFileSync(path.join(repoRoot, 'docs', 'UNIFIED_AGENT.md'), 'utf8')
.split('## External Probes (Pro)')[1].split('## Custom metrics')[0],
].map((section) => section.replace(/\s+/g, ' '));
it('keeps external probes dependent on server-side alert delivery', () => {
for (const guide of probeGuides()) {
expect(guide).toContain('The agent does not send notifications directly.');
expect(guide).toContain('Pulse server must be running and able to reach the notification destination');
expect(guide).toContain('agent cannot deliver Pulse alerts in its place');
expect(guide).toContain('not proof');
expect(guide).not.toContain('cannot disappear silently');
}
});
it('explains the existing missing-report and bounded-buffer limits', () => {
for (const guide of probeGuides()) {
expect(guide).toContain('five minutes or three check intervals');
expect(guide).toContain('server receipt time');
expect(guide).toContain('host-offline alert');
expect(guide).toContain('policies');
expect(guide).toMatch(/changes (?:its|the) observation location/);
}
const agentGuide = probeGuides()[1];
expect(agentGuide).toContain('up to 200 observations');
expect(agentGuide).toContain('Oldest pending observations are dropped');
expect(agentGuide).toContain('agent restart loses the queue');
expect(agentGuide).toContain('not a complete outage record');
// These limits come from the current implementation, not a new policy.
const monitor = readFileSync(
path.join(repoRoot, 'internal', 'monitoring', 'availability_probe_agent.go'), 'utf8',
);
expect(monitor).toContain('availabilityProbeStaleFloor = 5 * time.Minute');
expect(monitor).toContain('target.EffectivePollIntervalSecs()) * 3 * time.Second');
const agent = readFileSync(
path.join(repoRoot, 'internal', 'hostagent', 'availability.go'), 'utf8',
);
expect(agent).toContain('availabilityPendingCapacity = 200');
});
it('links independent outage coverage without a permanent Mobile guarantee', () => {
for (const guide of probeGuides()) {
expect(guide).toContain('TROUBLESHOOTING.md#no-alert-when-pulse-power-or-internet-goes-down');
expect(guide).toContain('independently reachable notification destination');
expect(guide).toContain('authorised test environment');
expect(guide).toContain('Existing paired Pulse Mobile/Relay users');
expect(guide).toContain('31 March 2027');
expect(guide).toContain('Relay is no longer sold');
expect(guide).toContain('not a permanent substitute');
expect(guide).toContain('[Mobile retirement](RELAY.md)');
expect(guide).not.toContain('push covers the dark-site case');
}
});
it('separates server removal from persistent-data erasure', () => {
const installation = readFileSync(path.join(repoRoot, 'docs', 'INSTALL.md'), 'utf8');
const removal = installation.split('## 🗑️ Uninstall')[1];