Skip to content
Merged
16 changes: 16 additions & 0 deletions .changeset/25792-console-dependency-missing-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

feat(app-shell,i18n): the Console draws the install refusal `DEPENDENCY_MISSING` in the author's terms, one line per unmet dependency

Clause-②: no
Metadata-protocol: none

ADR-0141 D2 refuses to install a package whose declared `dependencies` the environment does not satisfy (`422 DEPENDENCY_MISSING`, `details.unmet[]` naming each entry's `dependency`, `range`, what was `found` and the `installedVersion`). The Console showed the envelope's English sentence, the same in every locale and without the ranges.

- **`@object-ui/app-shell`:** the three install surfaces that go through `install-local` (the package page's Install, the catalog page's organization install on a self-hosted runtime, and Setup → Packages → Import) draw the refusal from its facts: a heading saying nothing was installed, then one line per unmet entry, such as 「请先安装 app.objectstack.hotcrm 6.x。」 or "Install com.acme.billing ^2.1.0 first. Version 1.4.0 is installed." `installLocal()` now carries the envelope's `details` on the error it throws, and the Packages page's request helper carries `code` and `details`. A refusal whose facts the Console cannot read in full (an older runtime, or a `found` value it does not know) keeps the server's message.
- **`@object-ui/i18n`:** `marketplace.install.dependencyMissing.*` (a heading and one line for each `found` value) in all ten locale packs.

What to do: nothing.
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.sources/i18n.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"package":"ui/packages/i18n","sources":31,"sha256":"ae5a36bc2327b985cb909eed35e099d1b2d953b7c4cb7dda463e4d8d3ef5cc86","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
{"package":"ui/packages/i18n","sources":31,"sha256":"7e7907af7823796538ad5a70e56fb172dc533ec3988a2318c8aeddf7069af14a","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import {
type LocalInstallStrippedLock,
type CloudInstallationInfo,
} from './marketplaceApi.js';
import { useDependencyMissingText } from './dependencyMissing.js';
import { getCloudBase, getRuntimeConfig, isMarketplaceEnabled } from '../../runtime-config.js';
import { emitMetadataRefresh } from '../../assistant/assistantBus.js';
import { useMetadata } from '../../providers/MetadataProvider.js';
Expand Down Expand Up @@ -144,6 +145,7 @@ export function MarketplacePackagePage() {
const { packageId, appName } = useParams<{ packageId?: string; appName?: string }>();
const { isAdmin, isResolved } = useWorkspaceAdminStatus();
const { t, language } = useObjectTranslation();
const dependencyMissingText = useDependencyMissingText();
// Dates and numbers on this surface read the display locale; a bare
// `toLocale*()` call used the MACHINE's locale (objectui#9909).
const displayLocale = useDisplayLocale();
Expand Down Expand Up @@ -637,7 +639,13 @@ export function MarketplacePackagePage() {
// remedy, and the page has nothing to add to it.
const code = e?.code;
let msg = e?.message ?? String(e);
if (errorCodeIs(e, 'MANIFEST_CONFLICT')) {
// ADR-0141 D2 — an unsatisfied declared dependency, drawn from the
// refusal's facts in the reader's locale; without facts the envelope's
// message stands.
const dependencyMissing = dependencyMissingText(e);
if (dependencyMissing) {
msg = dependencyMissing;
} else if (errorCodeIs(e, 'MANIFEST_CONFLICT')) {
msg = t('marketplace.install.localManifestConflict', { message: msg });
} else if (errorCodeIs(e, 'UNAUTHENTICATED')) {
msg = t('marketplace.install.localUnauthorized');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { emitMetadataRefresh } from '../../assistant/assistantBus.js';
import { appRouteSegment, filterActiveApps, resolveKeyedI18nLabel } from '../../utils/index.js';
import { waitForServedApp, type ServedApp } from './waitForServedApp.js';
import { splitPackageSuffix } from './middleTruncate.js';
import { useDependencyMissingText } from './dependencyMissing.js';
import { packagesHref } from '../../views/metadata-admin/packageSource.js';

/**
Expand Down Expand Up @@ -214,6 +215,7 @@ export function MarketplacePage() {
const { appName } = useParams();
const { isAdmin, isResolved } = useWorkspaceAdminStatus();
const { t, language } = useObjectTranslation();
const dependencyMissingText = useDependencyMissingText();
const { appLabel } = useObjectLabel();
// The adapter `MetadataProvider` reads through: the deploy wait asks the
// same `GET /meta/app` the cache would, without the cache persisting it.
Expand Down Expand Up @@ -412,7 +414,9 @@ export function MarketplacePage() {
await load();
}
} catch (e: any) {
setOrgMsg({ ok: false, text: e?.message ?? String(e) });
// ADR-0141 D2 — a `DEPENDENCY_MISSING` refusal reads one line per unmet
// dependency, from its own facts; anything else keeps its message.
setOrgMsg({ ok: false, text: dependencyMissingText(e) ?? e?.message ?? String(e) });
} finally {
setOrgInstalling(null);
}
Expand Down Expand Up @@ -623,7 +627,7 @@ export function MarketplacePage() {
</div>
{orgDeployNote}
{orgMsg && (
<div className={`rounded-md border p-3 text-sm ${orgMsg.ok ? 'border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400' : 'border-destructive/30 bg-destructive/5 text-destructive'}`}>
<div className={`whitespace-pre-wrap rounded-md border p-3 text-sm ${orgMsg.ok ? 'border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400' : 'border-destructive/30 bg-destructive/5 text-destructive'}`}>
{orgMsg.text}
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The package page draws the `422 DEPENDENCY_MISSING` install refusal
* (ADR-0141 D2) in the author's terms — 「请先安装 HotCRM 6.x」 — one line per
* unmet entry, from the refusal's own facts (objectstack#25792 item 3).
*
* `marketplaceApi` is NOT mocked: one stubbed `fetch` answers by path, so the
* facts the page draws are the ones that crossed the wire. `t` echoes the KEY
* and the interpolated facts: the claim is WHICH line each entry gets and that
* it carries that entry's id, range and version.
*/

import '@testing-library/jest-dom/vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';

vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
useParams: () => ({ packageId: 'pkg_psa' }),
}));

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

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => ({
t: (key: string, o?: Record<string, unknown>) => {
const facts = ['dependency', 'range', 'installedVersion'].filter((k) => o?.[k] !== undefined).map((k) => `${k}=${String(o?.[k])}`);
return facts.length > 0 ? `«${key}|${facts.join('|')}»` : `«${key}»`;
},
language: 'en',
}),
}));

vi.mock('../../../assistant/assistantBus', () => ({ emitMetadataRefresh: () => {} }));
vi.mock('../../../providers/MetadataProvider', () => ({ useMetadata: () => ({ refresh: async () => {} }) }));
vi.mock('../../../components/SuggestedBindingsPanel', () => ({
SuggestedBindingsPanel: () => <div data-testid="suggested-bindings" />,
}));

import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../../runtime-config';
import { MarketplacePackagePage } from '../MarketplacePackagePage';

const INSTALL_PATH = '/api/v1/marketplace/install-local';
const MANIFEST_PATH = '/api/v1/marketplace/packages/pkg_psa/versions/latest/manifest';
const CATALOG_PACKAGE = {
package: { id: 'pkg_psa', manifest_id: 'com.acme.psa', display_name: 'PSA', latest_version: { id: 'ver_1', version: '1.0.0' } },
versions: [{ id: 'ver_1', version: '1.0.0' }],
};
const SERVER_MESSAGE = 'SERVER-ENGLISH sentinel: Install refused: a declared dependency is not satisfied in this environment.';

let installBody: unknown;

const answer = (status: number, body: unknown) => ({
ok: status >= 200 && status < 300,
status,
statusText: '',
headers: new Headers({ 'content-type': 'application/json' }),
json: async () => body,
text: async () => JSON.stringify(body),
});

async function boot() {
resetRuntimeConfigForTesting();
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();
if (path === '/api/v1/runtime/config') {
return answer(200, {
cloudUrl: '', singleEnvironment: true,
features: { installLocal: true, marketplace: true, aiStudio: true, autoPublishAiBuilds: true },
branding: { productName: 'ObjectOS', productShortName: 'ObjectOS' },
});
}
if (path === INSTALL_PATH && method === 'GET') return answer(200, { success: true, data: { items: [], total: 0 } });
if (path === INSTALL_PATH && method === 'POST') return answer(422, installBody);
if (path === MANIFEST_PATH) {
return answer(200, { success: true, data: { manifest: { id: 'com.acme.psa', version: '1.0.0', type: 'app' }, version: '1.0.0', version_id: 'ver_1' } });
}
if (path === '/api/v1/marketplace/packages/pkg_psa') return answer(200, { success: true, data: CATALOG_PACKAGE });
return answer(404, { success: false, error: { code: 'NOT_FOUND', message: `no route ${method} ${path}` } });
}));
await initRuntimeConfig();
}

async function install() {
render(<MarketplacePackagePage />);
const button = await screen.findByRole('button', { name: '«marketplace.action.install»' });
await act(async () => {});
fireEvent.click(button);
}

const refusal = (details: unknown) => ({
success: false,
error: { code: 'DEPENDENCY_MISSING', httpStatus: 422, message: SERVER_MESSAGE, details },
});

beforeEach(() => { installBody = undefined; });
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
resetRuntimeConfigForTesting();
});

describe('the package page draws DEPENDENCY_MISSING from its facts (objectstack#25792)', () => {
it('one line per unmet entry under the heading, in place of the server\'s English sentence', async () => {
installBody = refusal({
compatible: false,
issues: [
{ severity: 'error', category: 'dependency_missing', message: 'not installed' },
{ severity: 'error', category: 'dependency_missing', message: 'out of range' },
],
unmet: [
{ packageId: 'com.acme.psa', dependency: 'app.objectstack.hotcrm', range: '6.x', found: 'absent' },
{ packageId: 'com.acme.psa', dependency: 'com.acme.billing', range: '^2.1.0', found: 'out-of-range', installedVersion: '1.4.0' },
],
});
await boot();
await install();

const note = await screen.findByText(/marketplace\.install\.dependencyMissing\.heading/);
expect(note.textContent!.split('\n')).toEqual([
'«marketplace.install.dependencyMissing.heading»',
'• «marketplace.install.dependencyMissing.absent|dependency=app.objectstack.hotcrm|range=6.x|installedVersion=—»',
'• «marketplace.install.dependencyMissing.outOfRange|dependency=com.acme.billing|range=^2.1.0|installedVersion=1.4.0»',
]);
expect(screen.queryByText(SERVER_MESSAGE)).not.toBeInTheDocument();
});

it('CONTROL: a refusal without facts it can read keeps the envelope\'s own message', async () => {
installBody = refusal({ compatible: false, issues: [] });
await boot();
await install();

expect(await screen.findByText(SERVER_MESSAGE)).toBeInTheDocument();
expect(screen.queryByText(/dependencyMissing/)).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The catalog page's org install on a self-hosted runtime (`installLocal`)
* draws the `422 DEPENDENCY_MISSING` refusal (ADR-0141 D2) in the author's
* terms, one line per unmet entry, from the refusal's own facts
* (objectstack#25792 item 3).
*
* The catalog lists are stubbed; `installLocal` is the REAL client, so the
* facts the strip draws are the ones that crossed the wire. `t` echoes the KEY
* and the interpolated facts: the claim is WHICH line each entry gets and that
* it carries that entry's id, range and version.
*/

import '@testing-library/jest-dom/vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';

vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
useParams: () => ({}),
}));

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

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => ({
t: (key: string, o?: Record<string, unknown>) => {
const facts = ['dependency', 'range', 'installedVersion'].filter((k) => o?.[k] !== undefined).map((k) => `${k}=${String(o?.[k])}`);
return facts.length > 0 ? `«${key}|${facts.join('|')}»` : `«${key}»`;
},
language: 'en',
}),
}));

const PKG = 'com.acme.psa';
const ORG_ROW = {
id: 'pkg_psa',
manifest_id: PKG,
display_name: 'Acme PSA',
description: 'A PSA.',
category: null,
icon_url: null,
visibility: 'org',
latest_version: '1.0.0',
};

vi.mock('../marketplaceApi', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
listMarketplacePackages: async () => ({ items: [] }),
listLocalInstalls: async () => [],
listOrgPackages: async () => ({ connected: true, items: [ORG_ROW] }),
listInstalledPackages: async () => ({ connected: true, items: [] }),
}));

import { ActiveOrganizationStorage } from '@object-ui/auth';
import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../../runtime-config';
import { MarketplacePage } from '../MarketplacePage';

const INSTALL_PATH = '/api/v1/marketplace/install-local';
const SERVER_MESSAGE = 'SERVER-ENGLISH sentinel: Install refused: a declared dependency is not satisfied in this environment.';

const answer = (status: number, body: unknown) => ({
ok: status >= 200 && status < 300,
status,
statusText: '',
headers: new Headers({ 'content-type': 'application/json' }),
json: async () => body,
text: async () => JSON.stringify(body),
});

async function boot(installBody: unknown) {
resetRuntimeConfigForTesting();
ActiveOrganizationStorage.set('org_1');
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();
if (path === INSTALL_PATH && method === 'POST') return answer(422, installBody);
if (path === '/api/v1/runtime/config') {
return answer(200, {
cloudUrl: 'https://cloud.example', singleEnvironment: true, defaultEnvironmentId: 'env_1',
features: { installLocal: true, marketplace: true, aiStudio: true, autoPublishAiBuilds: true },
branding: { productName: 'ObjectOS', productShortName: 'ObjectOS' },
});
}
return answer(404, { success: false, error: { code: 'NOT_FOUND', message: `no route ${method} ${path}` } });
}));
await initRuntimeConfig();
}

async function installFromOrgCard() {
render(<MarketplacePage />);
const card = await screen.findByTestId(`org-card-${PKG}`);
fireEvent.click(within(card).getByRole('button', { name: '«marketplace.org.install»' }));
}

const refusal = (details: unknown) => ({
success: false,
error: { code: 'DEPENDENCY_MISSING', httpStatus: 422, message: SERVER_MESSAGE, details },
});

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

describe('the catalog page draws DEPENDENCY_MISSING from its facts (objectstack#25792)', () => {
it('one line per unmet entry under the heading, in place of the server\'s English sentence', async () => {
await boot(refusal({
compatible: false,
issues: [
{ severity: 'error', category: 'dependency_missing', message: 'not installed' },
{ severity: 'error', category: 'dependency_missing', message: 'version unreadable' },
],
unmet: [
{ packageId: PKG, dependency: 'app.objectstack.hotcrm', range: '6.x', found: 'absent' },
{ packageId: PKG, dependency: 'com.acme.billing', range: '^2.1.0', found: 'unreadable-version', installedVersion: 'nightly' },
],
}));
await installFromOrgCard();

const strip = await screen.findByText(/marketplace\.install\.dependencyMissing\.heading/);
expect(strip.textContent!.split('\n')).toEqual([
'«marketplace.install.dependencyMissing.heading»',
'• «marketplace.install.dependencyMissing.absent|dependency=app.objectstack.hotcrm|range=6.x|installedVersion=—»',
'• «marketplace.install.dependencyMissing.unreadableVersion|dependency=com.acme.billing|range=^2.1.0|installedVersion=nightly»',
]);
// The lines stay lines on screen.
expect(strip).toHaveClass('whitespace-pre-wrap');
expect(screen.queryByText(SERVER_MESSAGE)).not.toBeInTheDocument();
});

it('CONTROL: another refusal keeps the envelope\'s own message', async () => {
await boot({ success: false, error: { code: 'MANIFEST_CONFLICT', httpStatus: 409, message: SERVER_MESSAGE } });
await installFromOrgCard();

expect(await screen.findByText(SERVER_MESSAGE)).toBeInTheDocument();
expect(screen.queryByText(/dependencyMissing/)).not.toBeInTheDocument();
});
});
Loading
Loading