Skip to content

Commit b239a01

Browse files
fix(app-shell): Browse Marketplace reads an unreachable catalog as unreachable, its raw cause behind More (#26213)
Fixes #25891 Clause-②: no Metadata-protocol: none ## What changed The runtime's marketplace proxy answers `502 MARKETPLACE_PROXY_FAILED` only when its own fetch to the catalog throws (`packages/cloud-connection/src/marketplace-proxy-plugin.ts`). Upstream answers pass through with their own status and code. `marketplaceApi.ts` `call()` already stamps `code` on the error. The load-error panel in `MarketplacePage.tsx` never read it: it picked its class line by status alone. So a runtime that could not reach the catalog read "The marketplace catalog answered with an error (HTTP 502)", with the raw Node text `fetch failed` in full view. - `LoadFailure` now carries `code` and an `unreachable` class: no status (no server answered), or `code === 'MARKETPLACE_PROXY_FAILED'`. - The unreachable class reads `marketplace.load.unreachable`. Its raw transport text (`fetch failed`, `Failed to fetch`) sits behind a More / Less disclosure with a copy button, reusing the existing `refusalDetails.more/less/copy/copied` keys, the same shape `RefusalMessage` gives a refusal's detail. `RefusalMessage` itself is not reused because it splits a server text into lead + rest, and here the lead is our own sentence, not part of the text. - Answered failures (a 403, or a 502/503/504 relayed from upstream) keep `answeredError` with their status and the server's text in full view, as objectui#11688 made them. - The online / same-origin hint logic is unchanged. - No locale pack is edited: only existing keys are used. ## Tests `ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.loadFailureCause-11688.test.tsx`, real `call()` over a stubbed `fetch`: - flipped: the proxy's `502 MARKETPLACE_PROXY_FAILED` now reads `marketplace.load.unreachable`, never `answeredError`; `fetch failed` is not on screen until More is pressed, then it is, More reads Less, and the copy button is labelled `refusalDetails.copy`. The configured-plane hint is still drawn. - new controls: a relayed `503 SERVICE_UNAVAILABLE` and a `403` plain-text refusal both keep `answeredError`, their text in full view, and no disclosure. The 503 keeps the hint and the 403 does not. - the no-answer case (`TypeError: Failed to fetch`) also asserts the unreachable line and that the raw text is not in full view. Run at head `a7b293f0`, under the verify lock: `vitest run packages/app-shell/src/console/marketplace/__tests__/MarketplacePage*`: Test Files 9/9, Tests 67/67. The suites include `MarketplacePage.sameOriginHint-11726.test.tsx` and `MarketplacePage.loadingStates-12710.test.tsx` unchanged and green. `pnpm --filter @object-ui/app-shell run type-check` passed with exit 0 (both `tsc --noEmit` and `tsconfig.test.json`). `eslint` on the two changed files reported 0 errors and 3 warnings, all on pre-existing lines. Ablation, run at head `24315db2` via `scripts/ablation-replace.mjs`: `unreachable: status === undefined || code === PROXY_UNREACHABLE_CODE,` became `unreachable: status === undefined,`. The anchor count went from 1 to 0 and the blob from `f19da1750ab1` to `46ac6aa14f87`. The 11688 suite then had 1 failed and 9 passed; the failure was exactly the flipped 502 case. The tool then restored the file (blob equals HEAD, and `git diff HEAD` is empty). ## Gates Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `a7b293f0`: 35 commands, every one run, every one exit 0. `--ran` reconciliation: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero, exit codes recorded). `node scripts/check-sdui-manifest.mjs` passed with exit 0, and `scripts/sdui-manifest.*` is untouched. The roster gate flagged for `.changeset/`, `node scripts/check-changeset-fixed.mjs`, passed with exit 0. CI-owned families (the type-check lanes, Test UI, Lint UI) are left to this PR's check-runs. ## Acceptance notes - The card's literal copy names the catalog host. That needs a new structured wire field on the proxy's failure, plus new pack keys. It is out of this claim, as the claim's Premise records, and is a follow-up if wanted. Today no failure the proxy returns carries the upstream host in a field or a header, and the page never parses server prose for one (objectui#11726). - `MARKETPLACE_PROXY_FAILED` is compared as a string literal in app-shell. The spec's error-code ledger lists it, but there is no per-code exported constant to import. - Open PR #26123 edits other regions of `MarketplacePage.tsx` (an import near `middleTruncate.js`, a hook, `installLocal`). This diff stays out of those regions. --- _Generated by [Claude Code](https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
1 parent 0453fab commit b239a01

3 files changed

Lines changed: 161 additions & 20 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): Browse Marketplace says the catalog could not be reached when the runtime cannot reach it, instead of "the catalog answered with an error (HTTP 502)"
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **`@object-ui/app-shell`**: the runtime's marketplace proxy answers `502 MARKETPLACE_PROXY_FAILED` only when its own fetch to the catalog throws. The load-error panel picked its class line by status alone, so that answer read "The marketplace catalog answered with an error (HTTP 502)", with the raw transport text `fetch failed` in full view below it. The panel now reads the error code as well: that code, or no answer at all, reads "The marketplace catalog could not be reached.", and the raw text sits behind a **More** / **Less** disclosure with a copy button. A failure the catalog did answer, including a 502, 503 or 504 relayed from upstream, still names its HTTP status and shows the server's own text. The online hint is unchanged.
11+
12+
What to do: nothing.

‎ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx‎

Lines changed: 102 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,13 @@ import {
1818
Input,
1919
Button,
2020
Skeleton,
21+
Collapsible,
22+
CollapsibleContent,
23+
CollapsibleTrigger,
2124
} from '@object-ui/components';
22-
import { Package, Search, Store, AlertCircle, CheckCircle2, Settings, Loader2 } from 'lucide-react';
25+
import {
26+
Package, Search, Store, AlertCircle, CheckCircle2, Settings, Loader2, ChevronDown, Copy, Check,
27+
} from 'lucide-react';
2328
import { useWorkspaceAdminStatus } from '@object-ui/auth';
2429
import { useDisplayLocale, useObjectLabel, useObjectTranslation } from '@object-ui/i18n';
2530
import { useAdapter } from '@object-ui/react';
@@ -150,15 +155,23 @@ function useRelativeFormatter() {
150155
interface LoadFailure {
151156
message: string;
152157
showOnlineHint: boolean;
158+
/** The answered status, or `undefined` when no server answered (objectui#12710). */
159+
status: number | undefined;
160+
/** The answered error code `call()` stamps from the envelope, if any. */
161+
code: string | undefined;
153162
/**
154-
* The answered status, or `undefined` when no server answered (objectui#12710).
155-
* It names the failure's class on the page: "could not be reached" (a
156-
* transport failure: a network error, a CORS refusal, an abort) against
157-
* "answered with an error" (any HTTP status). A 502 from the runtime's proxy
158-
* is the second class with the online hint beside it: the runtime answered,
159-
* and what it said is that it could not reach the plane behind it.
163+
* The failure's class on the page (objectui#12710, #25891): "could not be
164+
* reached" against "answered with an error (HTTP n)". Unreachable is a
165+
* transport failure (a network error, a CORS refusal, an abort: no status),
166+
* or the runtime proxy's own `MARKETPLACE_PROXY_FAILED`, which it answers
167+
* only when its own fetch to the catalog threw: that 502 is the runtime
168+
* saying it reached nothing, not the catalog answering. A 502 / 503 / 504 the
169+
* upstream itself answered is relayed with the upstream's own code, and stays
170+
* an answer. The unreachable class's message is the raw transport text
171+
* ("fetch failed"), so the page keeps it behind More; an answer's message is
172+
* the server's own words and stays in full view (objectui#11688).
160173
*/
161-
status: number | undefined;
174+
unreachable: boolean;
162175
}
163176

164177
/**
@@ -168,14 +181,93 @@ interface LoadFailure {
168181
*/
169182
const UNREACHABLE_STATUSES: ReadonlySet<number> = new Set([502, 503, 504]);
170183

184+
/** The code the runtime's marketplace proxy answers when its upstream fetch throws. */
185+
const PROXY_UNREACHABLE_CODE = 'MARKETPLACE_PROXY_FAILED';
186+
171187
function toLoadFailure(e: unknown): LoadFailure {
172-
const err = e as { message?: unknown; status?: unknown } | null | undefined;
188+
const err = e as { message?: unknown; status?: unknown; code?: unknown } | null | undefined;
173189
const status = typeof err?.status === 'number' ? err.status : undefined;
190+
const code = typeof err?.code === 'string' ? err.code : undefined;
174191
return {
175192
message: typeof err?.message === 'string' ? err.message : String(e),
176193
showOnlineHint: status === undefined || UNREACHABLE_STATUSES.has(status),
177194
status,
195+
code,
196+
unreachable: status === undefined || code === PROXY_UNREACHABLE_CODE,
197+
};
198+
}
199+
200+
/**
201+
* The load failure's class line and its cause (#25891). An answer shows the
202+
* server's own words under its class line. An unreachable failure says so in
203+
* one plain line, and the raw transport text, written for a developer, sits
204+
* behind More, selectable and copyable, as `RefusalMessage` keeps a refusal's
205+
* detail (the same `refusalDetails` keys).
206+
*/
207+
function LoadFailureCause({ failure }: { failure: LoadFailure }) {
208+
const { t } = useObjectTranslation();
209+
const [open, setOpen] = useState(false);
210+
const [copied, setCopied] = useState(false);
211+
useEffect(() => {
212+
if (!copied) return;
213+
const id = setTimeout(() => setCopied(false), 1500);
214+
return () => clearTimeout(id);
215+
}, [copied]);
216+
217+
// The server's own text, as a text child: it is server-supplied data, so it
218+
// never reaches `dangerouslySetInnerHTML`.
219+
if (!failure.unreachable) {
220+
return (
221+
<>
222+
<div className="mt-1" data-testid="marketplace-load-class">
223+
{t('marketplace.load.answeredError', { status: failure.status })}
224+
</div>
225+
<div className="text-muted-foreground mt-1" data-testid="marketplace-load-cause">{failure.message}</div>
226+
</>
227+
);
228+
}
229+
230+
const copy = async () => {
231+
try {
232+
await navigator.clipboard.writeText(failure.message);
233+
setCopied(true);
234+
} catch {
235+
// Denied or unavailable: the text is on screen and selectable.
236+
}
178237
};
238+
239+
return (
240+
<Collapsible open={open} onOpenChange={setOpen} className="mt-1 min-w-0">
241+
<span data-testid="marketplace-load-class">{t('marketplace.load.unreachable')}</span>{' '}
242+
<CollapsibleTrigger
243+
className="group inline-flex items-center gap-0.5 whitespace-nowrap text-xs font-medium underline underline-offset-2 opacity-80 hover:opacity-100"
244+
data-testid="marketplace-load-more"
245+
>
246+
{open ? t('refusalDetails.less') : t('refusalDetails.more')}
247+
<ChevronDown className="size-3 transition-transform group-data-[state=open]:rotate-180" aria-hidden="true" />
248+
</CollapsibleTrigger>
249+
<CollapsibleContent>
250+
<div className="mt-2 flex items-start gap-1 rounded border border-destructive/30 bg-background/60 p-2">
251+
<pre
252+
data-testid="marketplace-load-cause"
253+
className="min-w-0 flex-1 max-h-48 overflow-auto whitespace-pre-wrap break-words font-mono text-xs select-text"
254+
>
255+
{failure.message}
256+
</pre>
257+
<button
258+
type="button"
259+
onClick={() => { void copy(); }}
260+
aria-label={copied ? t('refusalDetails.copied') : t('refusalDetails.copy')}
261+
title={copied ? t('refusalDetails.copied') : t('refusalDetails.copy')}
262+
className="shrink-0 rounded p-1 opacity-80 hover:bg-destructive/10 hover:opacity-100"
263+
data-testid="marketplace-load-copy"
264+
>
265+
{copied ? <Check className="size-3.5" aria-hidden="true" /> : <Copy className="size-3.5" aria-hidden="true" />}
266+
</button>
267+
</div>
268+
</CollapsibleContent>
269+
</Collapsible>
270+
);
179271
}
180272

181273
/**
@@ -717,14 +809,7 @@ export function MarketplacePage() {
717809
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0 text-destructive" aria-hidden="true" />
718810
<div className="flex-1">
719811
<div className="font-medium text-destructive">{t('marketplace.load.failed')}</div>
720-
<div className="mt-1" data-testid="marketplace-load-class">
721-
{error.status === undefined
722-
? t('marketplace.load.unreachable')
723-
: t('marketplace.load.answeredError', { status: error.status })}
724-
</div>
725-
{/* The server's own text, as a text child: it is server-supplied
726-
data, so it never reaches `dangerouslySetInnerHTML`. */}
727-
<div className="text-muted-foreground mt-1" data-testid="marketplace-load-cause">{error.message}</div>
812+
<LoadFailureCause failure={error} />
728813
{/* The hint names the control plane the SERVER said it uses, so it
729814
can never claim a default the operator overrode (objectui#5504).
730815
Under `''` it names none and says only that the catalog is

‎ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.loadFailureCause-11688.test.tsx‎

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141

4242
import '@testing-library/jest-dom/vitest';
4343
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
44-
import { render, screen } from '@testing-library/react';
44+
import { render, screen, fireEvent } from '@testing-library/react';
4545

4646
vi.mock('react-router-dom', () => ({
4747
useNavigate: () => vi.fn(),
@@ -184,19 +184,63 @@ describe('a failure whose cause IS reachability keeps the online hint (objectui#
184184
await screen.findByText('«marketplace.load.failed»');
185185
expect(screen.getByTestId('marketplace-load-hint')).toHaveTextContent('«marketplace.load.failedHintSameOrigin»');
186186
expect(screen.queryByText('«marketplace.noApprovedYet»')).toBeNull();
187+
// The browser's own transport text is a developer string: behind More (#25891).
188+
expect(screen.getByTestId('marketplace-load-class')).toHaveTextContent('«marketplace.load.unreachable»');
189+
expect(screen.queryByText('Failed to fetch')).toBeNull();
187190
});
188191

189-
it('the runtime proxy reporting its upstream unreachable (502) shows its message and names the plane', async () => {
192+
it('the runtime proxy reporting its upstream unreachable (502) reads as unreachable, its raw text behind More (#25891)', async () => {
190193
await bootOn('https://cloud.acme.internal');
191194
catalog = async () =>
192195
json({ success: false, error: { code: 'MARKETPLACE_PROXY_FAILED', message: 'fetch failed' } }, 502, 'Bad Gateway');
193196

194197
render(<MarketplacePage />);
195198

196-
expect(await screen.findByTestId('marketplace-load-cause')).toHaveTextContent('fetch failed');
199+
// The runtime reached nothing: the catalog did not "answer with an error".
200+
expect(await screen.findByTestId('marketplace-load-class')).toHaveTextContent('«marketplace.load.unreachable»');
201+
expect(screen.queryByText('«marketplace.load.answeredError»')).toBeNull();
197202
expect(screen.getByTestId('marketplace-load-hint')).toHaveTextContent(
198203
'«marketplace.load.failedHintConfigured:https://cloud.acme.internal»',
199204
);
205+
// "fetch failed" is not in full view until More is pressed.
206+
expect(screen.queryByText('fetch failed')).toBeNull();
207+
const more = screen.getByTestId('marketplace-load-more');
208+
expect(more).toHaveTextContent('«refusalDetails.more»');
209+
210+
fireEvent.click(more);
211+
212+
expect(screen.getByTestId('marketplace-load-cause')).toHaveTextContent('fetch failed');
213+
expect(more).toHaveTextContent('«refusalDetails.less»');
214+
expect(screen.getByTestId('marketplace-load-copy')).toHaveAttribute('aria-label', '«refusalDetails.copy»');
215+
});
216+
});
217+
218+
describe('controls: an answered failure keeps its class and its text in full view (#25891)', () => {
219+
it.each([
220+
[
221+
'a 503 the upstream answered, relayed by the proxy',
222+
async () =>
223+
json({ success: false, error: { code: 'SERVICE_UNAVAILABLE', message: 'catalog is down' } }, 503, 'Service Unavailable'),
224+
'catalog is down',
225+
true,
226+
],
227+
[
228+
'a 403 refusal',
229+
async () =>
230+
new Response(ALLOWLIST_TEXT, { status: 403, statusText: 'Forbidden', headers: { 'content-type': 'text/plain' } }),
231+
ALLOWLIST_TEXT,
232+
false,
233+
],
234+
] as const)('%s', async (_label, answer, cause, hinted) => {
235+
await bootOn('');
236+
catalog = answer;
237+
238+
render(<MarketplacePage />);
239+
240+
expect(await screen.findByTestId('marketplace-load-class')).toHaveTextContent('«marketplace.load.answeredError»');
241+
expect(screen.getByTestId('marketplace-load-cause')).toHaveTextContent(cause);
242+
expect(screen.queryByTestId('marketplace-load-more')).toBeNull();
243+
expect(screen.queryByTestId('marketplace-load-hint') !== null).toBe(hinted);
200244
});
201245
});
202246

0 commit comments

Comments
 (0)