diff --git a/.changeset/25792-console-dependency-missing-refusal.md b/.changeset/25792-console-dependency-missing-refusal.md new file mode 100644 index 00000000000..0866bc047a9 --- /dev/null +++ b/.changeset/25792-console-dependency-missing-refusal.md @@ -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. diff --git a/scripts/sdui-manifest.sources/i18n.json b/scripts/sdui-manifest.sources/i18n.json index 6fe92078785..9acdc472ef8 100644 --- a/scripts/sdui-manifest.sources/i18n.json +++ b/scripts/sdui-manifest.sources/i18n.json @@ -1 +1 @@ -{"package":"ui/packages/i18n","sources":31,"sha256":"ae5a36bc2327b985cb909eed35e099d1b2d953b7c4cb7dda463e4d8d3ef5cc86","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/i18n","sources":31,"sha256":"7e7907af7823796538ad5a70e56fb172dc533ec3988a2318c8aeddf7069af14a","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} 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 && (