Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/11645-installed-not-loaded.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@object-ui/app-shell': minor
'@object-ui/i18n': minor
---

Installed Apps now reads a package the runtime refused to load at startup as "Not loaded", says why, and keeps its Uninstall (objectui#11645).

After a restart whose startup check refused a package built for another platform protocol, the install-local listing still lists the package, so it can be uninstalled or replaced. Since `@objectstack/*` 17.7.0 the listing marks such an entry with `notLoaded: { code, requiredRange }` in place of `withSampleData`. Installed Apps drew every listed entry as installed. Now:

- the row carries a "Not loaded" badge beside its version, and one sentence naming the reason: for `OS_PROTOCOL_INCOMPATIBLE`, that the package targets protocol `requiredRange`, which this runtime does not support. A refusal code the console has no sentence for still reads "Not loaded" and names the code;
- Uninstall stays on the row. Its confirm and its result no longer say that the package stays loaded until the next restart;
- the package's Details page no longer offers re-seed or purge of sample data for such an entry, since both act on objects the runtime never registered. Uninstall and Reinstall stay.

A loaded entry renders as before.

The language packs gain `marketplace.notLoaded.badge`, `marketplace.notLoaded.protocolIncompatible`, `marketplace.notLoaded.otherReason`, `marketplace.uninstall.confirmNotLoaded` and `marketplace.uninstall.successNotLoaded`, in all ten languages.
31 changes: 29 additions & 2 deletions packages/app-shell/src/console/marketplace/InstalledListWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
listInstalledPackages,
uninstallLocal,
type LocalInstallEntry,
type LocalInstallNotLoaded,
} from './marketplaceApi.js';
import { MarketplaceAccessDenied } from './MarketplaceAccessDenied.js';

Expand Down Expand Up @@ -75,8 +76,23 @@ export function InstalledList() {

useEffect(() => { void load(); }, []);

// objectui#11645 — the reason a `notLoaded` entry was refused, in plain words.
// A code the console has no sentence for still reads as not loaded, naming
// the code, rather than hiding the row the operator needs to uninstall.
const notLoadedReason = (marker: LocalInstallNotLoaded): string =>
marker.code === 'OS_PROTOCOL_INCOMPATIBLE'
? t('marketplace.notLoaded.protocolIncompatible', { requiredRange: marker.requiredRange })
: t('marketplace.notLoaded.otherReason', { code: marker.code });

const doUninstall = async (entry: LocalInstallEntry) => {
if (!confirm(t('marketplace.uninstall.confirm', { manifestId: entry.manifestId, version: entry.version }))) {
// A package the runtime did not load leaves nothing in the running kernel,
// so its confirm and its result drop the "stays loaded until restart"
// caveat that a loaded package's texts carry, which would contradict the
// row's own "Not loaded" (objectui#11645).
const question = entry.notLoaded
? t('marketplace.uninstall.confirmNotLoaded', { manifestId: entry.manifestId, version: entry.version })
: t('marketplace.uninstall.confirm', { manifestId: entry.manifestId, version: entry.version });
if (!confirm(question)) {
return;
}
setWorking(entry.manifestId);
Expand All @@ -85,7 +101,9 @@ export function InstalledList() {
await uninstallLocal(entry.manifestId);
setResult({
ok: true,
message: t('marketplace.uninstall.successInList', { manifestId: entry.manifestId }),
message: entry.notLoaded
? t('marketplace.uninstall.successNotLoaded', { manifestId: entry.manifestId })
: t('marketplace.uninstall.successInList', { manifestId: entry.manifestId }),
});
await load();
} catch (e: any) {
Expand Down Expand Up @@ -141,12 +159,21 @@ export function InstalledList() {
<CardTitle className="text-base truncate flex items-center gap-2">
{entry.manifestId}
<Badge variant="outline">{t('marketplace.versionBadge', { version: entry.version })}</Badge>
{entry.notLoaded && (
<Badge variant="destructive">{t('marketplace.notLoaded.badge')}</Badge>
)}
</CardTitle>
<div className="text-xs text-muted-foreground mt-1 flex flex-wrap gap-x-3 gap-y-1">
<span>{t('marketplace.installedAt', { when: new Date(entry.installedAt).toLocaleString(displayLocale) })}</span>
{entry.installedBy && <span>{t('marketplace.installedBy', { user: entry.installedBy })}</span>}
<span>{t('marketplace.installedPackageId')} <code className="font-mono">{entry.packageId}</code></span>
</div>
{entry.notLoaded && (
<p className="text-xs text-destructive mt-1.5 flex items-start gap-1.5">
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" aria-hidden="true" />
<span>{notLoadedReason(entry.notLoaded)}</span>
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -584,24 +584,34 @@ export function MarketplacePackagePage() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onSelect={doReseedLocalSampleData} disabled={sampleDataBusy !== null}>
{sampleDataBusy === 'reseed'
? <Loader2 className="h-4 w-4 mr-2 animate-spin" aria-hidden="true" />
: <Database className="h-4 w-4 mr-2" aria-hidden="true" />}
{localInstall.withSampleData
? t('marketplace.detail.reseedAgain')
: t('marketplace.detail.addSampleData')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={doPurgeLocalSampleData}
disabled={sampleDataBusy !== null || !localInstall.withSampleData}
className="text-destructive focus:text-destructive"
>
{sampleDataBusy === 'purge'
? <Loader2 className="h-4 w-4 mr-2 animate-spin" aria-hidden="true" />
: <Trash2 className="h-4 w-4 mr-2" aria-hidden="true" />}
{t('marketplace.detail.purgeSampleData')}
</DropdownMenuItem>
{/* objectui#11645 — re-seed and purge write and read the package's own
objects, which a package this runtime refused to load at startup
(`notLoaded`) never registered, so neither is offered for it. The
listing omits `withSampleData` for such an entry, and its absence
used to read as "Add sample data". Uninstall stays: it is how the
operator removes the refused entry. */}
{!localInstall.notLoaded && (
<>
<DropdownMenuItem onSelect={doReseedLocalSampleData} disabled={sampleDataBusy !== null}>
{sampleDataBusy === 'reseed'
? <Loader2 className="h-4 w-4 mr-2 animate-spin" aria-hidden="true" />
: <Database className="h-4 w-4 mr-2" aria-hidden="true" />}
{localInstall.withSampleData
? t('marketplace.detail.reseedAgain')
: t('marketplace.detail.addSampleData')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={doPurgeLocalSampleData}
disabled={sampleDataBusy !== null || !localInstall.withSampleData}
className="text-destructive focus:text-destructive"
>
{sampleDataBusy === 'purge'
? <Loader2 className="h-4 w-4 mr-2 animate-spin" aria-hidden="true" />
: <Trash2 className="h-4 w-4 mr-2" aria-hidden="true" />}
{t('marketplace.detail.purgeSampleData')}
</DropdownMenuItem>
</>
)}
<DropdownMenuItem onSelect={doUninstallLocal} disabled={installingLocal} className="text-destructive focus:text-destructive">
<Trash2 className="h-4 w-4 mr-2" aria-hidden="true" />
{t('marketplace.detail.uninstallFromRuntime')}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Installed Apps reads an entry the runtime refused to load as NOT LOADED, names
* the reason, and keeps Uninstall on it (objectui#11645).
*
* ## The defect
*
* After a restart whose startup rehydrate refused a protocol-incompatible
* package, `GET /api/v1/marketplace/install-local` still lists it (so DELETE and
* a compatible re-install stay reachable), and since objectstack#21822 (PR
* objectstack#21833, `@objectstack/*` 17.7.0) it marks the entry with
* `notLoaded: { code, requiredRange }` IN PLACE of `withSampleData`.
* `InstalledList` drew every listed entry as installed, so the console said
* "installed" for a package the runtime holds none of.
*
* ## Why the cases drive the WIRE
*
* `marketplaceApi` is NOT mocked. One stubbed `fetch` answers by path with the
* listing body the server landed, verbatim in shape, over a stateful ledger, so
* "Uninstall works" means a DELETE left for that manifest id and the re-read
* listing no longer carries the row — the card's own acceptance.
*
* ## What the cases assert about text
*
* The rendered line is compared with the PACK's value for its key, read from
* the pack and interpolated here, never with a sentence copied into this file:
* the claim is which key renders, with which values, through the real
* `I18nProvider`, in `en` and in `zh`. Wording stays the translators'.
*/

import '@testing-library/jest-dom/vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { I18nProvider } from '@object-ui/i18n';
import { en, zh } from '@object-ui/i18n/locales';

vi.mock('react-router-dom', () => {
const navigate = () => {};
const params = {};
return { useNavigate: () => navigate, useParams: () => params };
});

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
}));

import { InstalledList } from '../InstalledListWidget';

type Pack = typeof en;
const EN = { defaultLanguage: 'en', detectBrowserLanguage: false } as const;
const ZH = { defaultLanguage: 'zh', detectBrowserLanguage: false } as const;

/** `{{name}}` interpolation, as i18next applies it to these keys (no escaping: the console sets `escapeValue: false`). */
const fill = (template: string, values: Record<string, string>) =>
template.replace(/\{\{(\w+)\}\}/g, (_, k: string) => values[k] ?? `{{${k}}}`);

/**
* The refused entry exactly as objectstack PR #21833's wire shape gives it to an
* operator (`manage_metadata`): `notLoaded` closed to `code` + `requiredRange`,
* and NO `withSampleData` key — omitted, not `false`.
*/
const REFUSED = {
packageId: 'com.example.crm',
versionId: 'ver_crm_3',
manifestId: 'com.example.crm',
version: '3.2.0',
installedAt: '2026-10-05T09:00:00.000Z',
notLoaded: { code: 'OS_PROTOCOL_INCOMPATIBLE', requiredRange: '^16' },
installedBy: 'admin@objectos.ai',
};

/** A loaded entry, byte-identical to the listing before #21833: `withSampleData`, no `notLoaded` key. */
const LOADED = {
packageId: 'com.example.todo',
versionId: 'ver_todo_1',
manifestId: 'com.example.todo',
version: '1.0.0',
installedAt: '2026-10-05T09:00:00.000Z',
withSampleData: true,
installedBy: 'admin@objectos.ai',
};

/** The same refused entry as a narrowed caller (no `manage_metadata`) reads it: no `installedBy`, marker kept. */
const { installedBy: _operatorOnly, ...REFUSED_NARROWED } = REFUSED;

interface WireCall { path: string; method: string }
let calls: WireCall[] = [];
let ledger: Array<Record<string, unknown>> = [];
let confirmed: string[] = [];

const answer = (status: number, body: unknown) => ({
ok: status >= 200 && status < 300,
status,
statusText: '',
json: async () => body,
});

/** Serve the install-local doors over `entries`, statefully: DELETE removes the entry, as the server's ledger does. */
function serve(entries: Array<Record<string, unknown>>) {
ledger = entries.map((e) => ({ ...e }));
calls = [];
vi.stubGlobal(
'fetch',
vi.fn(async (input: unknown, init?: { method?: string }) => {
const path = new URL(String(input), 'http://console.test').pathname;
const method = (init?.method ?? 'GET').toUpperCase();
calls.push({ path, method });
// Not cloud-bound: the widget falls back to the local listing.
if (path === '/api/v1/cloud-connection/installed') return answer(404, {});
if (path === '/api/v1/marketplace/install-local' && method === 'GET') {
return answer(200, { success: true, data: { items: ledger, total: ledger.length } });
}
const del = /^\/api\/v1\/marketplace\/install-local\/([^/]+)$/.exec(path);
if (del && method === 'DELETE') {
const manifestId = decodeURIComponent(del[1]);
ledger = ledger.filter((e) => e.manifestId !== manifestId);
return answer(200, { success: true, data: { manifestId, cleanups: [] } });
}
return answer(404, { success: false, error: { code: 'NOT_FOUND', message: `no route ${method} ${path}` } });
}),
);
}

function renderList(config: typeof EN | typeof ZH) {
return render(
<I18nProvider config={config} persistLanguage={false}>
<InstalledList />
</I18nProvider>,
);
}

/** A row's title: the `CardTitle` naming `manifestId` (the row's `<code>` also prints the package id, so the selector is what tells them apart). */
const TITLE = { selector: 'div.font-semibold' } as const;

/** The card whose title names `manifestId`. */
async function card(manifestId: string): Promise<HTMLElement> {
const title = await screen.findByText(manifestId, TITLE);
const el = title.closest('div.rounded-lg');
if (!el) throw new Error(`no card around ${manifestId}`);
return el as HTMLElement;
}

beforeEach(() => {
confirmed = [];
vi.stubGlobal('confirm', (question: string) => {
confirmed.push(question);
return true;
});
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

describe.each([
['en', EN, en as Pack],
['zh', ZH, zh as unknown as Pack],
])('InstalledList — a refused entry reads not loaded, with its reason (objectui#11645) [%s]', (_lang, config, pack) => {
const badge = pack.marketplace.notLoaded.badge;
const reason = fill(pack.marketplace.notLoaded.protocolIncompatible, { requiredRange: '^16' });

it('the operator\'s refused entry carries the not-loaded badge and the protocol reason, naming the range', async () => {
serve([REFUSED, LOADED]);
renderList(config);

const row = await card('com.example.crm');
expect(within(row).getByText(badge)).toBeInTheDocument();
expect(within(row).getByText(reason)).toBeInTheDocument();
// Uninstall is still offered on the row, and enabled.
expect(within(row).getByRole('button', { name: pack.marketplace.action.uninstall })).toBeEnabled();
});

it('a narrowed caller\'s entry (no `installedBy`) still reads not loaded, with its reason', async () => {
serve([REFUSED_NARROWED]);
renderList(config);

const row = await card('com.example.crm');
expect(within(row).getByText(badge)).toBeInTheDocument();
expect(within(row).getByText(reason)).toBeInTheDocument();
// Lit control: the operator-only line is the thing absent, not the row.
expect(within(row).queryByText(fill(pack.marketplace.installedBy, { user: 'admin@objectos.ai' }))).toBeNull();
});

it('a loaded entry carries neither the badge nor a reason', async () => {
// A preservation pin: it holds on both sides of the fix. The cases above
// are what show these queries find the badge and the reason when present.
serve([REFUSED, LOADED]);
renderList(config);

const row = await card('com.example.todo');
expect(within(row).queryByText(badge)).toBeNull();
// The static head of either reason sentence (the text before its placeholder).
for (const template of [pack.marketplace.notLoaded.protocolIncompatible, pack.marketplace.notLoaded.otherReason]) {
const head = template.split('{{')[0];
expect(head.length).toBeGreaterThan(3);
expect(row.textContent).not.toContain(head);
}
expect(within(row).getByRole('button', { name: pack.marketplace.action.uninstall })).toBeEnabled();
});
});

describe('InstalledList — Uninstall works from the not-loaded row (objectui#11645)', () => {
it('confirms in not-loaded terms, issues DELETE for the manifest id, and the re-read listing drops the row', async () => {
serve([REFUSED, LOADED]);
renderList(EN);

const row = await card('com.example.crm');
fireEvent.click(within(row).getByRole('button', { name: en.marketplace.action.uninstall }));

await waitFor(() =>
expect(calls).toContainEqual({ path: '/api/v1/marketplace/install-local/com.example.crm', method: 'DELETE' }),
);
expect(confirmed).toEqual([
fill(en.marketplace.uninstall.confirmNotLoaded, { manifestId: 'com.example.crm', version: '3.2.0' }),
]);
expect(
await screen.findByText(fill(en.marketplace.uninstall.successNotLoaded, { manifestId: 'com.example.crm' })),
).toBeInTheDocument();
// The listing was read again after the DELETE, and no longer has the row.
await waitFor(() => expect(screen.queryByText('com.example.crm', TITLE)).toBeNull());
expect(calls.filter((c) => c.path === '/api/v1/marketplace/install-local' && c.method === 'GET')).toHaveLength(2);
expect(screen.getByText('com.example.todo', TITLE)).toBeInTheDocument();
});

it('control: a loaded row keeps the loaded package\'s confirm and result', async () => {
serve([REFUSED, LOADED]);
renderList(EN);

const row = await card('com.example.todo');
fireEvent.click(within(row).getByRole('button', { name: en.marketplace.action.uninstall }));

await waitFor(() =>
expect(calls).toContainEqual({ path: '/api/v1/marketplace/install-local/com.example.todo', method: 'DELETE' }),
);
expect(confirmed).toEqual([
fill(en.marketplace.uninstall.confirm, { manifestId: 'com.example.todo', version: '1.0.0' }),
]);
expect(
await screen.findByText(fill(en.marketplace.uninstall.successInList, { manifestId: 'com.example.todo' })),
).toBeInTheDocument();
});
});

describe('InstalledList — a refusal code the console has no sentence for (objectui#11645)', () => {
it('still reads not loaded, names the code, and keeps the row and its Uninstall', async () => {
serve([{ ...REFUSED, notLoaded: { code: 'OS_SOME_LATER_REFUSAL', requiredRange: '^18' } }]);
renderList(EN);

const row = await card('com.example.crm');
expect(within(row).getByText(en.marketplace.notLoaded.badge)).toBeInTheDocument();
expect(within(row).getByText(fill(en.marketplace.notLoaded.otherReason, { code: 'OS_SOME_LATER_REFUSAL' }))).toBeInTheDocument();
// The protocol sentence is the one code's, never a guess for another.
expect(row.textContent).not.toContain('^18');
expect(within(row).getByRole('button', { name: en.marketplace.action.uninstall })).toBeEnabled();
});
});
Loading
Loading