From 6403930c2ff590e4e50e0a5133222adce94c8cdd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 12:11:39 +0000 Subject: [PATCH 1/3] feat(app-shell,i18n): draw the DEPENDENCY_MISSING install refusal in the author's terms ADR-0141 D2 refuses an install whose declared dependencies the environment does not satisfy (422 DEPENDENCY_MISSING, details.unmet[]). The Console showed the envelope's English sentence. The package page's Install, the catalog page's organization install on a self-hosted runtime and Setup -> Packages -> Import now draw a heading plus one line per unmet entry from the refusal's own facts (dependency, range, found, installed version), in all ten locale packs. installLocal() and the Packages page's request helper carry the envelope's details (and code) on the error they throw. A refusal whose facts cannot be read in full keeps the server's message. Claude-Session: https://claude.ai/code/session_01NndsH9Qm7k6AS2i2fWbjNA Co-authored-by: Claude --- ...5792-console-dependency-missing-refusal.md | 16 ++ .../marketplace/MarketplacePackagePage.tsx | 10 +- .../console/marketplace/MarketplacePage.tsx | 8 +- ...ckagePage.dependencyMissing-25792.test.tsx | 142 +++++++++++++++++ ...orgInstallDependencyMissing-25792.test.tsx | 146 ++++++++++++++++++ .../__tests__/dependencyMissing-25792.test.ts | 80 ++++++++++ .../console/marketplace/dependencyMissing.ts | 104 +++++++++++++ .../src/console/marketplace/marketplaceApi.ts | 10 +- ...age.importDependencyMissing-25792.test.tsx | 114 ++++++++++++++ .../src/views/metadata-admin/PackagesPage.tsx | 22 ++- ui/packages/i18n/src/locales/ar.ts | 9 ++ ui/packages/i18n/src/locales/de.ts | 9 ++ ui/packages/i18n/src/locales/en.ts | 9 ++ ui/packages/i18n/src/locales/es.ts | 9 ++ ui/packages/i18n/src/locales/fr.ts | 9 ++ ui/packages/i18n/src/locales/ja.ts | 9 ++ ui/packages/i18n/src/locales/ko.ts | 9 ++ ui/packages/i18n/src/locales/pt.ts | 9 ++ ui/packages/i18n/src/locales/ru.ts | 9 ++ ui/packages/i18n/src/locales/zh.ts | 9 ++ 20 files changed, 733 insertions(+), 9 deletions(-) create mode 100644 .changeset/25792-console-dependency-missing-refusal.md create mode 100644 ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.dependencyMissing-25792.test.tsx create mode 100644 ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.orgInstallDependencyMissing-25792.test.tsx create mode 100644 ui/packages/app-shell/src/console/marketplace/__tests__/dependencyMissing-25792.test.ts create mode 100644 ui/packages/app-shell/src/console/marketplace/dependencyMissing.ts create mode 100644 ui/packages/app-shell/src/views/metadata-admin/PackagesPage.importDependencyMissing-25792.test.tsx diff --git a/.changeset/25792-console-dependency-missing-refusal.md b/.changeset/25792-console-dependency-missing-refusal.md new file mode 100644 index 00000000000..ee722996fd7 --- /dev/null +++ b/.changeset/25792-console-dependency-missing-refusal.md @@ -0,0 +1,16 @@ +--- +'@object-ui/app-shell': minor +'@object-ui/i18n': minor +--- + +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. diff --git a/ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx b/ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx index e8d061329e7..8db13123180 100644 --- a/ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx +++ b/ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx @@ -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'; @@ -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(); @@ -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'); diff --git a/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx b/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx index a65c3701b4b..8f182d19fa5 100644 --- a/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx +++ b/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx @@ -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'; /** @@ -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. @@ -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); } @@ -623,7 +627,7 @@ export function MarketplacePage() { {orgDeployNote} {orgMsg && ( -
+
{orgMsg.text}
)} diff --git a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.dependencyMissing-25792.test.tsx b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.dependencyMissing-25792.test.tsx new file mode 100644 index 00000000000..79da87e5cb2 --- /dev/null +++ b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.dependencyMissing-25792.test.tsx @@ -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>()), + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), +})); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, o?: Record) => { + 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: () =>
, +})); + +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(); + 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(); + }); +}); diff --git a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.orgInstallDependencyMissing-25792.test.tsx b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.orgInstallDependencyMissing-25792.test.tsx new file mode 100644 index 00000000000..5d728c34f52 --- /dev/null +++ b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.orgInstallDependencyMissing-25792.test.tsx @@ -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>()), + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), + useAuth: () => ({ activeOrganization: { id: 'org_1' } }), +})); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, o?: Record) => { + 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>()), + 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(); + 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(); + }); +}); diff --git a/ui/packages/app-shell/src/console/marketplace/__tests__/dependencyMissing-25792.test.ts b/ui/packages/app-shell/src/console/marketplace/__tests__/dependencyMissing-25792.test.ts new file mode 100644 index 00000000000..639bb518b51 --- /dev/null +++ b/ui/packages/app-shell/src/console/marketplace/__tests__/dependencyMissing-25792.test.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The `422 DEPENDENCY_MISSING` install refusal (ADR-0141 D2) read from its own + * facts (objectstack#25792 item 3): the reader, the text it draws, and the + * install call that carries the facts to the page. + * + * `t` echoes the KEY and the interpolated facts: the claim is WHICH line is + * drawn for each entry and that it carries the entry's own id, range and + * version — the words are each locale pack's. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { dependencyMissingText, readDependencyMissing } from '../dependencyMissing'; +import { installLocal } from '../marketplaceApi'; + +const t = (key: string, o?: Record) => + (o ? `${key}(${['packageId', 'dependency', 'range', 'installedVersion'].map((k) => String(o[k])).join('|')})` : key); + +/** The details the three install doors answer, as #25986 ships them. */ +const DETAILS = { + compatible: false, + issues: [ + { severity: 'error', category: 'dependency_missing', message: "Package 'com.acme.psa' depends on 'app.objectstack.hotcrm' 6.x, which is not installed." }, + { severity: 'error', category: 'dependency_missing', message: "Package 'com.acme.psa' depends on 'com.acme.billing' ^2.1.0, installed at 1.4.0." }, + ], + 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' }, + ], +}; + +describe('readDependencyMissing', () => { + it('reads one fact per unmet entry, in the refusal\'s order', () => { + expect(readDependencyMissing(DETAILS)).toEqual(DETAILS.unmet); + }); + + it('reads every `found` the doors answer, the declaration-level one without a dependency', () => { + const facts = readDependencyMissing({ unmet: [ + { packageId: 'p', dependency: 'd', range: 'latest', found: 'unreadable-range' }, + { packageId: 'p', dependency: 'd', range: '^6', found: 'unreadable-version', installedVersion: '6.1.0-beta.1' }, + { packageId: 'p', found: 'unreadable-declaration' }, + ] }); + expect(facts?.map((f) => f.found)).toEqual(['unreadable-range', 'unreadable-version', 'unreadable-declaration']); + }); + + it('draws nothing of its own from details it cannot read whole — the envelope message stands', () => { + expect(readDependencyMissing(undefined)).toBeNull(); + expect(readDependencyMissing({ issues: DETAILS.issues })).toBeNull(); + expect(readDependencyMissing({ unmet: [] })).toBeNull(); + // One entry with a `found` this reader does not know: not a shorter list. + expect(readDependencyMissing({ unmet: [DETAILS.unmet[0], { packageId: 'p', dependency: 'd', range: '1', found: 'disabled' }] })).toBeNull(); + expect(readDependencyMissing({ unmet: [{ packageId: 'p', range: '1', found: 'absent' }] })).toBeNull(); + }); +}); + +describe('dependencyMissingText', () => { + it('is the heading, then one line per entry built from that entry\'s facts', () => { + expect(dependencyMissingText(readDependencyMissing(DETAILS)!, t).split('\n')).toEqual([ + 'marketplace.install.dependencyMissing.heading', + '• marketplace.install.dependencyMissing.absent(com.acme.psa|app.objectstack.hotcrm|6.x|—)', + '• marketplace.install.dependencyMissing.outOfRange(com.acme.psa|com.acme.billing|^2.1.0|1.4.0)', + ]); + }); +}); + +describe('installLocal carries the refusal\'s facts', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('rejects with the envelope\'s code, status and details', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ + success: false, + error: { code: 'DEPENDENCY_MISSING', httpStatus: 422, message: 'Install refused: a declared dependency is not satisfied.', details: DETAILS }, + }), { status: 422, headers: { 'content-type': 'application/json' } }))); + const err = await installLocal({ packageId: 'pkg_psa' }).catch((e: unknown) => e) as Error & { code?: string; status?: number; details?: unknown }; + expect(err.code).toBe('DEPENDENCY_MISSING'); + expect(err.status).toBe(422); + expect(err.details).toEqual(DETAILS); + }); +}); diff --git a/ui/packages/app-shell/src/console/marketplace/dependencyMissing.ts b/ui/packages/app-shell/src/console/marketplace/dependencyMissing.ts new file mode 100644 index 00000000000..6ae0fdacdee --- /dev/null +++ b/ui/packages/app-shell/src/console/marketplace/dependencyMissing.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The install refusal `422 DEPENDENCY_MISSING` (ADR-0141 D2), in the author's + * terms — 「请先安装 HotCRM 6.x」 — built from the refusal's own facts. + * + * The three install doors refuse a package whose declared `dependencies` the + * environment does not satisfy with one envelope: + * + * { code: 'DEPENDENCY_MISSING', message, details: { compatible: false, issues, unmet } } + * + * `issues` is one `dependency_missing` issue per unmet entry, its message in + * the server's English; `unmet` carries the same entries as facts — the + * declaring `packageId`, the `dependency`, the declared `range`, what was + * `found` and the `installedVersion` when one is installed. The Console draws + * one line per entry from those facts in the reader's locale, under one + * heading, instead of the server's English sentence. ⛔ No rule of its own: a + * `found` value this reader does not know draws nothing of its own, and the + * envelope's message stands. + */ + +import { useCallback } from 'react'; +import { useObjectTranslation } from '@object-ui/i18n'; +import { errorCodeIs } from '@object-ui/types'; + +/** What the refusal says was found for one declared entry (`details.unmet[].found`). */ +export type DependencyMissingFound = + | 'absent' + | 'out-of-range' + | 'unreadable-range' + | 'unreadable-version' + | 'unreadable-declaration'; + +/** One unmet declared entry, as `details.unmet[]` carries it. */ +export interface DependencyMissingFact { + packageId: string; + found: DependencyMissingFound; + dependency?: string; + range?: string; + installedVersion?: string; +} + +const LINE_KEY: Record = { + absent: 'marketplace.install.dependencyMissing.absent', + 'out-of-range': 'marketplace.install.dependencyMissing.outOfRange', + 'unreadable-range': 'marketplace.install.dependencyMissing.unreadableRange', + 'unreadable-version': 'marketplace.install.dependencyMissing.unreadableVersion', + 'unreadable-declaration': 'marketplace.install.dependencyMissing.unreadableDeclaration', +}; + +const isText = (v: unknown): v is string => typeof v === 'string' && v !== ''; + +/** + * The refusal's facts, or `null` when `details` carries none this reader can + * draw — an older runtime, another refusal, or an entry with an unknown + * `found`. All or nothing: a list that dropped an entry would tell the author + * fewer things to install than the server refused for. + */ +export function readDependencyMissing(details: unknown): DependencyMissingFact[] | null { + const unmet = (details as { unmet?: unknown } | null | undefined)?.unmet; + if (!Array.isArray(unmet) || unmet.length === 0) return null; + const facts: DependencyMissingFact[] = []; + for (const entry of unmet) { + const e = entry as Record | null; + if (!e || !isText(e.packageId) || typeof e.found !== 'string' || !(e.found in LINE_KEY)) return null; + const found = e.found as DependencyMissingFound; + if (found !== 'unreadable-declaration' && (!isText(e.dependency) || typeof e.range !== 'string')) return null; + facts.push({ + packageId: e.packageId, + found, + ...(isText(e.dependency) ? { dependency: e.dependency } : {}), + ...(typeof e.range === 'string' ? { range: e.range } : {}), + ...(isText(e.installedVersion) ? { installedVersion: e.installedVersion } : {}), + }); + } + return facts; +} + +type Translate = (key: string, options?: Record) => string; + +/** The heading and one line per fact, newline-joined, in the translator's locale. */ +export function dependencyMissingText(facts: readonly DependencyMissingFact[], t: Translate): string { + const lines = facts.map((fact) => t(LINE_KEY[fact.found], { + packageId: fact.packageId, + dependency: fact.dependency ?? '', + range: fact.range ?? '', + installedVersion: fact.installedVersion ?? '—', + })); + return [t('marketplace.install.dependencyMissing.heading'), ...lines.map((line) => `• ${line}`)].join('\n'); +} + +/** + * The text for a caught install error: the refusal drawn from its facts when + * it is `DEPENDENCY_MISSING` and carries them (`err.details`), else `null` and + * the caller keeps its own message. + */ +export function useDependencyMissingText(): (err: unknown) => string | null { + const { t } = useObjectTranslation(); + return useCallback((err: unknown) => { + if (!errorCodeIs(err, 'DEPENDENCY_MISSING')) return null; + const facts = readDependencyMissing((err as { details?: unknown } | null | undefined)?.details); + return facts ? dependencyMissingText(facts, t as Translate) : null; + }, [t]); +} diff --git a/ui/packages/app-shell/src/console/marketplace/marketplaceApi.ts b/ui/packages/app-shell/src/console/marketplace/marketplaceApi.ts index dd8f1abfff8..abf3d59b91f 100644 --- a/ui/packages/app-shell/src/console/marketplace/marketplaceApi.ts +++ b/ui/packages/app-shell/src/console/marketplace/marketplaceApi.ts @@ -833,9 +833,10 @@ export async function listOrgPackages(): Promise { /** * `POST /install-local`. `installMode` is sent only when the caller names one * (ADR-0131 D6); absent, the door applies its own default. A refusal rejects - * with the envelope's own `message`, its `code` and `status` beside it — the - * install-mode refusals included (`INSTALL_MODE_NOT_PERMITTED`, - * `UNMANAGED_INSTALL_WALLED_POSTURE`, `RESOURCE_CONFLICT`). + * with the envelope's own `message`, its `code`, `status` and `details` beside + * it — the install-mode refusals included (`INSTALL_MODE_NOT_PERMITTED`, + * `UNMANAGED_INSTALL_WALLED_POSTURE`, `RESOURCE_CONFLICT`), and + * `DEPENDENCY_MISSING`, whose `details` the page draws. * * The first overload is the door's own rule for a request that names no mode: * it installs managed and answers the managed shape, as every install did @@ -873,6 +874,9 @@ export async function installLocal(input: { const err = new Error(typeof message === 'string' ? message : `${code}`); (err as any).code = code; (err as any).status = res.status; + // The refusal's facts ride along for a page that draws them — the + // `DEPENDENCY_MISSING` entries (ADR-0141 D2, `dependencyMissing.ts`). + if (payload?.error?.details !== undefined) (err as any).details = payload.error.details; throw err; } return (payload?.data ?? payload) as LocalInstallResult; diff --git a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.importDependencyMissing-25792.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.importDependencyMissing-25792.test.tsx new file mode 100644 index 00000000000..a510a587bdf --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.importDependencyMissing-25792.test.tsx @@ -0,0 +1,114 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Packages → Import 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). + * + * Import is `POST /api/v1/marketplace/install-local` with the file's manifest. + * The REAL locale packs are mounted (`I18nProvider`), so the zh case asserts + * the sentence an author reads, not a key. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { I18nProvider } from '@object-ui/i18n'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), +})); + +import { PackagesPage } from './PackagesPage'; +import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../runtime-config'; + +const reply = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + +const SERVER_MESSAGE = 'SERVER-ENGLISH sentinel: Install refused: a declared dependency is not satisfied in this environment.'; +const PSA = { id: 'com.acme.psa', name: 'PSA', version: '1.0.0', type: 'app', dependencies: { 'app.objectstack.hotcrm': '6.x', 'com.acme.billing': '^2.1.0' } }; + +async function serve(installAnswer: { status: number; body: unknown }) { + resetRuntimeConfigForTesting(); + vi.stubGlobal('fetch', vi.fn(async () => reply(200, { features: { installLocal: true } }))); + await initRuntimeConfig(); + 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/packages') return reply(200, { success: true, data: { packages: [] } }); + if (path === '/api/v1/cloud-connection/installed') return reply(404, {}); + if (path === '/api/v1/marketplace/install-local' && method === 'GET') return reply(200, { success: true, data: { items: [] } }); + if (path === '/api/v1/marketplace/install-local' && method === 'POST') return reply(installAnswer.status, installAnswer.body); + return reply(404, { success: false, error: { code: 'NOT_FOUND', message: `no route ${method} ${path}` } }); + })); +} + +const refusal = (details: unknown) => ({ + status: 422, + body: { success: false, error: { code: 'DEPENDENCY_MISSING', httpStatus: 422, message: SERVER_MESSAGE, details } }, +}); + +const 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' }, +]; + +async function importFile(lang: 'en' | 'zh') { + render( + + + + + , + ); + const input = await vi.waitFor(() => { + const el = document.querySelector('input[type="file"]'); + if (!el) throw new Error('no import input yet'); + return el as HTMLInputElement; + }); + const file = new File([JSON.stringify(PSA)], 'psa.json', { type: 'application/json' }); + fireEvent.change(input, { target: { files: [file] } }); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + resetRuntimeConfigForTesting(); +}); + +describe('Packages → Import draws DEPENDENCY_MISSING from its facts (objectstack#25792)', () => { + it('zh: 「请先安装 …」, one line per unmet entry, in place of the server\'s English sentence', async () => { + await serve(refusal({ compatible: false, issues: [], unmet: UNMET })); + await importFile('zh'); + + const note = await screen.findByText(/安装被拒绝/); + expect(note.textContent!.split('\n')).toEqual([ + '安装被拒绝:此软件包声明的依赖在当前环境中未满足,未安装任何内容。', + '• 请先安装 app.objectstack.hotcrm 6.x。', + '• 请先安装 com.acme.billing ^2.1.0(当前安装的是 1.4.0)。', + ]); + expect(screen.queryByText(SERVER_MESSAGE)).not.toBeInTheDocument(); + }); + + it('en: the same facts in English', async () => { + await serve(refusal({ compatible: false, issues: [], unmet: UNMET })); + await importFile('en'); + + const note = await screen.findByText(/Install refused/); + expect(note.textContent!.split('\n').slice(1)).toEqual([ + '• Install app.objectstack.hotcrm 6.x first.', + '• Install com.acme.billing ^2.1.0 first. Version 1.4.0 is installed.', + ]); + }); + + it('CONTROL: a refusal without facts it can read keeps the envelope\'s own message', async () => { + await serve(refusal({ compatible: false, issues: [] })); + await importFile('en'); + + // As this page has always drawn a refusal: the envelope's message, its code behind it. + expect(await screen.findByText(`${SERVER_MESSAGE} (DEPENDENCY_MISSING)`)).toBeInTheDocument(); + expect(screen.queryByText(/安装被拒绝|Install refused: a dependency this package declares/)).not.toBeInTheDocument(); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx index 8641a609f33..e5852c68b6e 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx @@ -89,6 +89,7 @@ import { useWorkspaceAdminStatus } from '@object-ui/auth'; import { getRuntimeConfig } from '../../runtime-config.js'; import { readMarketplaceInstalls, type LocalInstallEntry } from '../../console/marketplace/marketplaceApi.js'; import { MarketplaceNotLoaded, MarketplaceRowActions } from '../../console/marketplace/MarketplaceRowActions.js'; +import { useDependencyMissingText } from '../../console/marketplace/dependencyMissing.js'; import type { InstalledPackageAtEitherStage } from '@objectstack/spec/api-assembled'; import type { PackagePublishResult } from '@objectstack/spec/system'; import type { ReassignOrphanedMetadataResponse } from '@objectstack/spec/api'; @@ -204,7 +205,16 @@ async function apiJson(path: string, init?: RequestInit): Promise { payload?.error || payload?.message || `Request failed (${res.status})`; - throw new Error(typeof msg === 'string' ? msg : `Request failed (${res.status})`); + const err = new Error(typeof msg === 'string' ? msg : `Request failed (${res.status})`) as Error & { + code?: string; + details?: unknown; + }; + // ADR-0141 D2 — the envelope's `code` and `details` ride on the error, so a + // caller that draws a refusal from its facts (`DEPENDENCY_MISSING` at the + // import door) can; every other caller reads the message as before. + if (typeof payload?.error?.code === 'string') err.code = payload.error.code; + if (payload?.error?.details !== undefined) err.details = payload.error.details; + throw err; } // Runtime wraps successful payloads in { data, ... } or returns the object directly. return (payload?.data ?? payload) as T; @@ -1298,6 +1308,9 @@ export function PackageDetailSheet({ export function PackagesPage() { const locale = useMetadataLocale(); + // ADR-0141 D2 — the import door's `DEPENDENCY_MISSING` refusal, one line per + // unmet dependency, from the locale packs (the `engine.*` table is en/zh only). + const dependencyMissingText = useDependencyMissingText(); const { pathname } = useLocation(); // App base = path up to (and excluding) `/component/...`, so links to // `/apps/:app/metadata/...` work regardless of nesting. @@ -1488,7 +1501,10 @@ export function PackagesPage() { }); await load(); } catch (err: any) { - setImportMsg({ kind: 'err', text: err?.message ?? t('engine.packages.import.failed', locale) }); + setImportMsg({ + kind: 'err', + text: dependencyMissingText(err) ?? err?.message ?? t('engine.packages.import.failed', locale), + }); } finally { setImporting(false); } @@ -1571,7 +1587,7 @@ export function PackagesPage() { {importMsg && (
Date: Mon, 26 Oct 2026 12:26:40 +0000 Subject: [PATCH 2/3] chore(sdui): re-record the registry source digest after the locale-pack keys The ten @object-ui/i18n locale packs are registry sources, so the new marketplace.install.dependencyMissing keys move the digest that check-sdui-manifest compares. Regenerated with `node scripts/gen-sdui-manifest-node.mjs`: sdui.manifest.json is byte-identical; only the recorded digest changes. Claude-Session: https://claude.ai/code/session_01NndsH9Qm7k6AS2i2fWbjNA Co-authored-by: Claude --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 684a23dffc1..ea3c5138d05 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "0e127be18a1104d857822dc5c71a6380e4f00b0db729cd8b8a230a11c120fe78" + "registrySourcesSha256": "8a6535621f76346ffa501154867f3e72ed37063d88d811fc9827445a3b09d348" } } From f76c765089138a9a26838f26c3d7dde728faabfe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 12:58:26 +0000 Subject: [PATCH 3/3] chore(changeset): grade the dependency-missing rendering patch Under `Metadata-protocol: none` the entry is graded `patch` (Check Changeset). Claude-Session: https://claude.ai/code/session_01NndsH9Qm7k6AS2i2fWbjNA Co-authored-by: Claude --- .changeset/25792-console-dependency-missing-refusal.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/25792-console-dependency-missing-refusal.md b/.changeset/25792-console-dependency-missing-refusal.md index ee722996fd7..0866bc047a9 100644 --- a/.changeset/25792-console-dependency-missing-refusal.md +++ b/.changeset/25792-console-dependency-missing-refusal.md @@ -1,6 +1,6 @@ --- -'@object-ui/app-shell': minor -'@object-ui/i18n': minor +'@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