mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-19 09:45:07 +00:00
feat+test+docs: D71+D72+D73 — olp-plugin/ (OpenClaw /olp Telegram+Discord) + docs/integrations/*.md + README cross-refs (#44)
Final Phase 4 substantive D-day group. 3 D-days bundled per Iron Rule 11 IDR (plugin consumes existing endpoints; integration docs reference plugin + olp CLI + olp-connect together; README index links all). After this PR merges, Phase 4 has shipped all 5 D-day groups (D60 charter + port / D61-D63 SSE heartbeat+ring+/status / D64-D67 olp CLI+doctor / D68-D70 olp-connect+anonymous-key+ADR0011 / D71-D73 plugin+docs). The v0.4.0 close PR is maintainer-triggered per CLAUDE.md release_kit overlay. ## D71 — olp-plugin/ (OpenClaw gateway plugin) Port OCP ocp-plugin/index.js (311 lines) → OLP olp-plugin/index.js (482 lines) as the /olp Telegram+Discord slash command, but MINUS mutations (no /olp keys keygen, no /olp keys revoke, no /olp restart, no /olp logs — all of these require SSH out of chat for security). Plugin shape: - olp-plugin/index.js — registers /olp command via OpenClaw api.registerCommand - olp-plugin/openclaw.plugin.json — manifest, apiKey REQUIRED, proxyUrl default http://127.0.0.1:4567 (matches D60) - olp-plugin/package.json — minimal: name/version/type:module + OpenClaw discovery block - olp-plugin/README.md — install + configure + use docs; documents the "no mutations from chat" security stance and the dedicated-bot-key pattern (don't share maintainer's personal key with the bot) Subcommand parity with olp CLI (D64-D67): - /olp status → GET /v0/management/status (owner-only) - /olp health → GET /health (public-ok) - /olp usage → GET /v0/management/dashboard-data (owner-only) - /olp models → GET /v1/models (public-ok) - /olp cache → GET /cache/stats (owner-only) - /olp providers → local cross-ref (public-ok) - /olp chain show [<model>] → local (public-ok, advisory if no FS access — defer to ssh + olp chain show) - /olp doctor → informational (HTTP doctor endpoint deferred; advisory to ssh + olp doctor for live use) - /olp help → usage text Port resolution: OLP_PROXY_URL env → OLP_PORT env → plugin config proxyUrl → http://127.0.0.1:4567. Output: Telegram/Discord monospace code block with status icons (🟢🟡🔴). Long responses truncated for the 4096-char message limit. No npm deps. OpenClaw provides Telegram/Discord transport; plugin uses fetch + node builtins only. ## D72 — docs/integrations/*.md (6 IDE pages + index) Per the Phase 4 brainstorm prior-art survey + ADR 0010 § Out-of-scope posture for Claude Code: - continue.md ✅ — config.yaml (NOT config.json); apiBase; requestOptions.headers - cline.md ✅ — "OpenAI Compatible" provider; Cline #7128 base-URL UI bug warning - cursor.md ⚠️ — known base-URL fragility; only enable models OLP serves - aider.md ✅ — OPENAI_API_BASE env + openai/ prefix; .env support - claude-code.md ❌ — explicitly NOT supported per ADR 0010 § /v1/messages defer rationale; recommended alternative: Cline + OLP - openclaw.md ✅ — install olp-plugin via CLI or symlink; configure apiKey; restart gateway Each ~60-120 lines: status / quick setup / known issues / OLP-specific notes / test-it command. docs/integrations/README.md is the index. ## D73 — README cross-references - New § "IDE Setup" links to docs/integrations/README.md - New § "Telegram / Discord Usage" — install + configure + restart + use - Quick Start mentions olp-connect <ip> as family-onboarding command - package.json `files` field extended to include olp-plugin/ so the published tarball ships the plugin ## Test count 672 → 696 (+24 D71-D73 tests in Suite 35: helpers / formatters / dispatch / error paths). All 696 pass locally. ## Scope discipline - server.mjs UNTOUCHED (plugin consumes EXISTING endpoints) - No new npm deps (no Telegram or Discord SDK — OpenClaw provides transport) - No /v1/messages (out of Phase 4 per ADR 0010) - No CHANGELOG / package.json version bump (Phase 4 close handles versioning; only package.json `files` extended for olp-plugin/ publication) ## Implementor flagged for reviewer 1. /olp doctor returns SSH advisory (no HTTP doctor endpoint yet). When future phase exposes /v0/management/doctor, swap advisory branch for real fetchJSON + fmtDoctor (already implemented + tested). 2. /olp providers + chain show have no FS access (plugin runs in OpenClaw gateway process); registry read via lazy-imported models-registry.json from repo root. For live enabled-state visibility users still need /olp status (owner-tier) or ssh + olp providers / olp chain show. 3. No live-server wire test in Suite 35 — existing Suites 31/32 already cover the integration path against the same endpoints; mock-fetch in Suite 35 is sufficient signal for the plugin layer. ## Authority - ADR 0010 § Phase 4 D-day plan D71-D73 line - OCP ocp-plugin/index.js (port reference) - ADR 0010 § Out-of-Phase-4-scope (claude-code.md ❌ rationale) - 2026-05-26 brainstorm (Top OCP inheritance candidates + prior-art survey IDE-specific quirks for cline/cursor/continue docs) Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -14394,3 +14394,400 @@ describe('Suite 34 — D68-D70 /health.anonymousKey + plaintext_advertise (ADR 0
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Suite 35: D71 olp-plugin/ smoke tests (ADR 0010 § Phase 4 D71-D73) ─────
|
||||
//
|
||||
// The plugin's default export takes an OpenClaw `api` object and registers a
|
||||
// command — we can't fully unit-test the registration without mocking the
|
||||
// gateway. Instead we test the exported helpers + dispatcher with the global
|
||||
// fetch swapped out for a mock. These cover:
|
||||
//
|
||||
// 35a — mono() wrapping for chat surfaces
|
||||
// 35b — bar() boundary behaviour (clamping + width)
|
||||
// 35c — resolveProxyUrl precedence (env > env-port > config > default)
|
||||
// 35d — truncateForChat truncation behaviour
|
||||
// 35e — fmtStatus / fmtHealth / fmtUsage / fmtModels / fmtCache /
|
||||
// fmtProviders / fmtChainShow / fmtDoctor render expected fields
|
||||
// 35f — dispatch() unknown subcommand → help message
|
||||
// 35g — dispatch() routes status/health/usage/models/cache to the right URL
|
||||
// with Authorization: Bearer header
|
||||
// 35h — dispatch() doctor returns the "use SSH" advisory (HTTP doctor
|
||||
// endpoint not yet shipped)
|
||||
// 35i — dispatch() catches fetch errors and emits "OLP error: ..."
|
||||
|
||||
import {
|
||||
mono as plugMono,
|
||||
bar as plugBar,
|
||||
statusIcon as plugStatusIcon,
|
||||
truncateForChat as plugTruncate,
|
||||
resolveProxyUrl as plugResolveProxyUrl,
|
||||
fmtStatus as plugFmtStatus,
|
||||
fmtHealth as plugFmtHealth,
|
||||
fmtUsage as plugFmtUsage,
|
||||
fmtModels as plugFmtModels,
|
||||
fmtCache as plugFmtCache,
|
||||
fmtProviders as plugFmtProviders,
|
||||
fmtChainShow as plugFmtChainShow,
|
||||
fmtDoctor as plugFmtDoctor,
|
||||
cmdHelp as plugCmdHelp,
|
||||
dispatch as plugDispatch,
|
||||
} from './olp-plugin/index.js';
|
||||
|
||||
describe('Suite 35 — D71 olp-plugin/ smoke tests (ADR 0010 § Phase 4 D71-D73)', () => {
|
||||
|
||||
// ── 35a — mono() ────────────────────────────────────────────────────────
|
||||
|
||||
it('35a — mono() wraps text in triple-backtick code block', () => {
|
||||
const out = plugMono('hello\nworld');
|
||||
assert.equal(out, '```\nhello\nworld\n```');
|
||||
});
|
||||
|
||||
// ── 35b — bar() ─────────────────────────────────────────────────────────
|
||||
|
||||
it('35b — bar() clamps pct ∈ [0,1] and respects width', () => {
|
||||
assert.equal(plugBar(0, 8), '░░░░░░░░');
|
||||
assert.equal(plugBar(1, 8), '████████');
|
||||
assert.equal(plugBar(0.5, 8), '████░░░░');
|
||||
assert.equal(plugBar(-1, 8), '░░░░░░░░', 'negative pct clamps to 0');
|
||||
assert.equal(plugBar(2, 8), '████████', 'pct > 1 clamps to 1');
|
||||
assert.equal(plugBar(NaN, 8), '░░░░░░░░', 'NaN treated as 0');
|
||||
assert.equal(plugBar(0.5).length, 16, 'default width = 16');
|
||||
});
|
||||
|
||||
it('35b-icon — statusIcon() maps ok/degraded/fail to expected glyphs', () => {
|
||||
assert.equal(plugStatusIcon('ok'), '🟢');
|
||||
assert.equal(plugStatusIcon(true), '🟢');
|
||||
assert.equal(plugStatusIcon('degraded'), '🟡');
|
||||
assert.equal(plugStatusIcon('warn'), '🟡');
|
||||
assert.equal(plugStatusIcon('fail'), '🔴');
|
||||
assert.equal(plugStatusIcon(false), '🔴');
|
||||
});
|
||||
|
||||
// ── 35c — resolveProxyUrl precedence ────────────────────────────────────
|
||||
|
||||
it('35c — resolveProxyUrl precedence: OLP_PROXY_URL > OLP_PORT > config.proxyUrl > default', () => {
|
||||
// 1. Default fallback
|
||||
assert.equal(plugResolveProxyUrl({ env: {}, config: {} }), 'http://127.0.0.1:4567');
|
||||
// 2. config.proxyUrl wins over default
|
||||
assert.equal(
|
||||
plugResolveProxyUrl({ env: {}, config: { proxyUrl: 'http://10.0.0.5:9999' } }),
|
||||
'http://10.0.0.5:9999',
|
||||
);
|
||||
// 3. OLP_PORT wins over config.proxyUrl
|
||||
assert.equal(
|
||||
plugResolveProxyUrl({ env: { OLP_PORT: '8000' }, config: { proxyUrl: 'http://config:1' } }),
|
||||
'http://127.0.0.1:8000',
|
||||
);
|
||||
// 4. OLP_PROXY_URL wins over OLP_PORT + config
|
||||
assert.equal(
|
||||
plugResolveProxyUrl({
|
||||
env: { OLP_PROXY_URL: 'http://env-url:1234', OLP_PORT: '8000' },
|
||||
config: { proxyUrl: 'http://config:1' },
|
||||
}),
|
||||
'http://env-url:1234',
|
||||
);
|
||||
});
|
||||
|
||||
// ── 35d — truncateForChat ───────────────────────────────────────────────
|
||||
|
||||
it('35d — truncateForChat passes short text untouched + truncates long text with marker', () => {
|
||||
const short = 'hello world';
|
||||
assert.equal(plugTruncate(short), short);
|
||||
const long = 'A'.repeat(5000);
|
||||
const truncated = plugTruncate(long, 1000);
|
||||
assert.ok(truncated.length <= 1000);
|
||||
assert.match(truncated, /truncated, use SSH for full/);
|
||||
assert.ok(truncated.startsWith('AAA'));
|
||||
});
|
||||
|
||||
// ── 35e — Formatters ────────────────────────────────────────────────────
|
||||
|
||||
it('35e-1 — fmtStatus() renders version + uptime + provider list + recent errors', () => {
|
||||
const body = {
|
||||
ok: true,
|
||||
version: '0.4.0-phase4',
|
||||
uptime_human: '1h 2m 3s',
|
||||
providers: {
|
||||
enabled: 2,
|
||||
available: 3,
|
||||
status: {
|
||||
anthropic: { ok: true, activeSpawns: 0 },
|
||||
openai: { ok: false, error: 'CLI not found', activeSpawns: 0 },
|
||||
},
|
||||
},
|
||||
stats: { total_requests: 42, active_requests: 1, cache: { hits: 10, misses: 5, size: 7 } },
|
||||
recent_errors: [{ time: '2026-05-26T11:22:33Z', provider: 'openai', message: 'spawn EACCES' }],
|
||||
};
|
||||
const out = plugFmtStatus(body);
|
||||
assert.match(out, /0\.4\.0-phase4/);
|
||||
assert.match(out, /1h 2m 3s/);
|
||||
assert.match(out, /Providers: 2 enabled \/ 3 available/);
|
||||
assert.match(out, /anthropic/);
|
||||
assert.match(out, /openai.*CLI not found/);
|
||||
assert.match(out, /Requests: 42 total/);
|
||||
assert.match(out, /Cache: 10 hit \/ 5 miss/);
|
||||
assert.match(out, /Recent errors \(1\)/);
|
||||
assert.match(out, /spawn EACCES/);
|
||||
});
|
||||
|
||||
it('35e-2 — fmtHealth() renders ok + version + provider list', () => {
|
||||
const body = {
|
||||
ok: true,
|
||||
version: '0.4.0-phase4',
|
||||
uptime_human: '5m',
|
||||
providers: { anthropic: { ok: true }, openai: { ok: false } },
|
||||
};
|
||||
const out = plugFmtHealth(body);
|
||||
assert.match(out, /Status: ok/);
|
||||
assert.match(out, /v0\.4\.0-phase4/);
|
||||
assert.match(out, /anthropic/);
|
||||
assert.match(out, /openai/);
|
||||
});
|
||||
|
||||
it('35e-3 — fmtUsage() renders 24h window + per-provider quota + top fallback chains', () => {
|
||||
const body = {
|
||||
cache_hit_24h: 0.42,
|
||||
quota: [
|
||||
{ name: 'anthropic', percent_used: 33 },
|
||||
{ name: 'mistral', percent_used: null },
|
||||
],
|
||||
top_fallback_chains_24h: [
|
||||
{ count: 5, chain: ['anthropic', 'openai'] },
|
||||
],
|
||||
};
|
||||
const out = plugFmtUsage(body);
|
||||
assert.match(out, /Cache hit \(24h\): 42\.0%/);
|
||||
assert.match(out, /anthropic.*33%/);
|
||||
assert.match(out, /mistral.*no quota api/);
|
||||
assert.match(out, /5 {2}anthropic → openai/);
|
||||
});
|
||||
|
||||
it('35e-4 — fmtModels() handles empty + populated', () => {
|
||||
assert.equal(plugFmtModels({ data: [] }), 'No models.');
|
||||
const out = plugFmtModels({
|
||||
data: [
|
||||
{ id: 'claude-sonnet-4-5', owned_by: 'anthropic' },
|
||||
{ id: 'gpt-5', owned_by: 'openai' },
|
||||
],
|
||||
});
|
||||
assert.match(out, /Models \(2\)/);
|
||||
assert.match(out, /claude-sonnet-4-5\s+\(anthropic\)/);
|
||||
assert.match(out, /gpt-5\s+\(openai\)/);
|
||||
});
|
||||
|
||||
it('35e-5 — fmtCache() renders hit/miss/size/inflight', () => {
|
||||
const out = plugFmtCache({ size: 12, hits: 100, misses: 25, inflightCount: 2, evictions: 1 });
|
||||
assert.match(out, /Entries: {8}12/);
|
||||
assert.match(out, /Hits: {11}100/);
|
||||
assert.match(out, /Misses: {9}25/);
|
||||
assert.match(out, /Inflight: {7}2/);
|
||||
assert.match(out, /Evictions: {6}1/);
|
||||
});
|
||||
|
||||
it('35e-6 — fmtProviders() lists registry providers + enabled flags', () => {
|
||||
const registry = {
|
||||
providers: {
|
||||
anthropic: { tier: 'D', models: ['m1', 'm2'] },
|
||||
openai: { tier: 'D', models: ['m3'], candidate: true },
|
||||
},
|
||||
};
|
||||
const out = plugFmtProviders(registry, { anthropic: true });
|
||||
assert.match(out, /anthropic\s+enabled/);
|
||||
assert.match(out, /openai\s+disabled.*\(candidate\)/);
|
||||
});
|
||||
|
||||
it('35e-7 — fmtChainShow() handles empty / all / single-target', () => {
|
||||
assert.equal(plugFmtChainShow({}), 'No chains configured.');
|
||||
const chains = {
|
||||
'claude-sonnet-4-5': [{ provider: 'anthropic', model: 'claude-sonnet-4-5' }],
|
||||
'gpt-5': ['openai:gpt-5'],
|
||||
};
|
||||
const all = plugFmtChainShow(chains);
|
||||
assert.match(all, /claude-sonnet-4-5/);
|
||||
assert.match(all, /gpt-5/);
|
||||
const one = plugFmtChainShow(chains, 'gpt-5');
|
||||
assert.match(one, /gpt-5:/);
|
||||
assert.match(one, /→ openai:gpt-5/);
|
||||
const missing = plugFmtChainShow(chains, 'no-such-model');
|
||||
assert.match(missing, /not in routing\.chains/);
|
||||
});
|
||||
|
||||
it('35e-8 — fmtDoctor() renders summary + checks + next_action', () => {
|
||||
const body = {
|
||||
summary: '2 OK, 1 WARN',
|
||||
checks: [
|
||||
{ id: 'server.running', status: 'ok', message: 'reachable' },
|
||||
{ id: 'auth.owner_key_exists', status: 'warn', message: 'no owner key configured' },
|
||||
],
|
||||
fail_count: 0, warn_count: 1, ok_count: 1,
|
||||
kind: 'fix_config',
|
||||
next_action: {
|
||||
ai_executable: ['npx olp-keys keygen --owner'],
|
||||
human_required: ['capture the printed token'],
|
||||
},
|
||||
};
|
||||
const out = plugFmtDoctor(body);
|
||||
assert.match(out, /2 OK, 1 WARN/);
|
||||
assert.match(out, /server\.running/);
|
||||
assert.match(out, /auth\.owner_key_exists/);
|
||||
assert.match(out, /fail=0 warn=1 ok=1/);
|
||||
assert.match(out, /kind=fix_config/);
|
||||
assert.match(out, /npx olp-keys keygen --owner/);
|
||||
assert.match(out, /capture the printed token/);
|
||||
});
|
||||
|
||||
// ── 35f — dispatch: unknown / help / "" ──────────────────────────────────
|
||||
|
||||
it('35f-1 — dispatch("") returns cmdHelp() body', async () => {
|
||||
const r = await plugDispatch('', { proxyUrl: 'http://x', registry: { providers: {} } });
|
||||
assert.equal(r.text, plugCmdHelp());
|
||||
});
|
||||
|
||||
it('35f-2 — dispatch("frobnicate") returns "Unknown subcommand:" + help', async () => {
|
||||
const r = await plugDispatch('frobnicate', { proxyUrl: 'http://x', registry: { providers: {} } });
|
||||
assert.match(r.text, /Unknown subcommand: frobnicate/);
|
||||
assert.match(r.text, /OLP Commands/);
|
||||
});
|
||||
|
||||
it('35f-3 — dispatch("help") returns cmdHelp() with read-only disclaimer', async () => {
|
||||
const r = await plugDispatch('help', { proxyUrl: 'http://x', registry: { providers: {} } });
|
||||
assert.match(r.text, /OLP Commands/);
|
||||
assert.match(r.text, /Mutating commands.*NOT/);
|
||||
});
|
||||
|
||||
// ── 35g — dispatch: routes status/health/usage/models/cache ──────────────
|
||||
|
||||
it('35g — dispatch routes each subcommand to the right URL with Authorization header', async () => {
|
||||
const calls = [];
|
||||
const mockFetch = async (url, opts) => {
|
||||
calls.push({ url, headers: opts?.headers ?? {} });
|
||||
// Per-route response stubs
|
||||
if (url.endsWith('/v0/management/status')) {
|
||||
return { ok: true, status: 200, json: async () => ({
|
||||
ok: true, version: '0.4.0', uptime_human: '1m',
|
||||
providers: { enabled: 0, available: 3, status: {} },
|
||||
stats: { total_requests: 0, active_requests: 0 },
|
||||
recent_errors: [],
|
||||
}) };
|
||||
}
|
||||
if (url.endsWith('/health')) {
|
||||
return { ok: true, status: 200, json: async () => ({ ok: true, version: '0.4.0' }) };
|
||||
}
|
||||
if (url.endsWith('/v0/management/dashboard-data')) {
|
||||
return { ok: true, status: 200, json: async () => ({ cache_hit_24h: 0.5 }) };
|
||||
}
|
||||
if (url.endsWith('/v1/models')) {
|
||||
return { ok: true, status: 200, json: async () => ({ data: [{ id: 'm1' }] }) };
|
||||
}
|
||||
if (url.endsWith('/cache/stats')) {
|
||||
return { ok: true, status: 200, json: async () => ({ size: 0, hits: 0, misses: 0, inflightCount: 0 }) };
|
||||
}
|
||||
throw new Error(`unmocked url: ${url}`);
|
||||
};
|
||||
|
||||
const opts = {
|
||||
proxyUrl: 'http://test:9999',
|
||||
apiKey: 'olp_TESTKEY',
|
||||
registry: { providers: { anthropic: { tier: 'D', models: [] } } },
|
||||
fetchFn: mockFetch,
|
||||
};
|
||||
|
||||
for (const sub of ['status', 'health', 'usage', 'models', 'cache']) {
|
||||
calls.length = 0;
|
||||
const r = await plugDispatch(sub, opts);
|
||||
assert.equal(calls.length, 1, `${sub} should make exactly 1 HTTP call`);
|
||||
assert.equal(calls[0].headers.Authorization, 'Bearer olp_TESTKEY', `${sub} must include Bearer header`);
|
||||
assert.ok(r.text.length > 0, `${sub} should produce non-empty output`);
|
||||
assert.ok(!/^OLP error/.test(r.text), `${sub} should not error; got: ${r.text}`);
|
||||
}
|
||||
|
||||
// URL routing per subcommand
|
||||
calls.length = 0;
|
||||
await plugDispatch('status', opts);
|
||||
assert.equal(calls[0].url, 'http://test:9999/v0/management/status');
|
||||
|
||||
calls.length = 0;
|
||||
await plugDispatch('usage', opts);
|
||||
assert.equal(calls[0].url, 'http://test:9999/v0/management/dashboard-data');
|
||||
});
|
||||
|
||||
it('35g-providers — dispatch("providers") uses registry locally without HTTP', async () => {
|
||||
let calls = 0;
|
||||
const r = await plugDispatch('providers', {
|
||||
proxyUrl: 'http://test:9999',
|
||||
registry: { providers: { anthropic: { tier: 'D', models: [] }, openai: { tier: 'D', models: [] } } },
|
||||
fetchFn: async () => { calls++; return { ok: true, status: 200, json: async () => ({}) }; },
|
||||
});
|
||||
assert.equal(calls, 0, 'providers must NOT make an HTTP call');
|
||||
assert.match(r.text, /anthropic/);
|
||||
assert.match(r.text, /openai/);
|
||||
});
|
||||
|
||||
it('35g-chain — dispatch("chain show <model>") uses local chainsLocal arg', async () => {
|
||||
const r = await plugDispatch('chain show foo-model', {
|
||||
proxyUrl: 'http://test:9999',
|
||||
registry: { providers: {} },
|
||||
chainsLocal: { 'foo-model': [{ provider: 'anthropic', model: 'foo' }] },
|
||||
fetchFn: async () => { throw new Error('should not fetch'); },
|
||||
});
|
||||
assert.match(r.text, /foo-model:/);
|
||||
assert.match(r.text, /→.*anthropic/);
|
||||
});
|
||||
|
||||
it('35g-chain-usage — dispatch("chain bogus") prints usage hint', async () => {
|
||||
const r = await plugDispatch('chain bogus', {
|
||||
proxyUrl: 'http://test:9999',
|
||||
registry: { providers: {} },
|
||||
fetchFn: async () => { throw new Error('should not fetch'); },
|
||||
});
|
||||
assert.match(r.text, /Usage: \/olp chain show \[model\]/);
|
||||
});
|
||||
|
||||
// ── 35h — dispatch: doctor advisory ─────────────────────────────────────
|
||||
|
||||
it('35h — dispatch("doctor") returns the "use SSH" advisory (HTTP endpoint not yet shipped)', async () => {
|
||||
let calls = 0;
|
||||
const r = await plugDispatch('doctor', {
|
||||
proxyUrl: 'http://test:9999',
|
||||
registry: { providers: {} },
|
||||
fetchFn: async () => { calls++; return { ok: true, status: 200, json: async () => ({}) }; },
|
||||
});
|
||||
assert.equal(calls, 0, 'doctor must NOT make an HTTP call yet (Phase 4 surface is local only)');
|
||||
assert.match(r.text, /not yet wired through HTTP/);
|
||||
assert.match(r.text, /Run `olp doctor` over SSH/);
|
||||
});
|
||||
|
||||
// ── 35i — dispatch: error catching ──────────────────────────────────────
|
||||
|
||||
it('35i — dispatch catches fetch errors and emits "OLP error: ..."', async () => {
|
||||
const r = await plugDispatch('status', {
|
||||
proxyUrl: 'http://test:9999',
|
||||
apiKey: 'olp_X',
|
||||
registry: { providers: {} },
|
||||
fetchFn: async () => { throw new Error('ECONNREFUSED'); },
|
||||
});
|
||||
assert.match(r.text, /^OLP error: ECONNREFUSED/);
|
||||
});
|
||||
|
||||
it('35i-401 — dispatch surfaces 401 unauthorized with helpful hint', async () => {
|
||||
const r = await plugDispatch('status', {
|
||||
proxyUrl: 'http://test:9999',
|
||||
apiKey: 'olp_BAD',
|
||||
registry: { providers: {} },
|
||||
fetchFn: async () => ({ ok: false, status: 401, statusText: 'Unauthorized', json: async () => ({}) }),
|
||||
});
|
||||
assert.match(r.text, /OLP error.*401 unauthorized/);
|
||||
assert.match(r.text, /owner-tier/);
|
||||
});
|
||||
|
||||
it('35i-403 — dispatch surfaces 403 forbidden with helpful hint', async () => {
|
||||
const r = await plugDispatch('cache', {
|
||||
proxyUrl: 'http://test:9999',
|
||||
apiKey: 'olp_GUEST',
|
||||
registry: { providers: {} },
|
||||
fetchFn: async () => ({ ok: false, status: 403, statusText: 'Forbidden', json: async () => ({}) }),
|
||||
});
|
||||
assert.match(r.text, /OLP error.*403 forbidden/);
|
||||
assert.match(r.text, /not owner-tier/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user