Skip to content

Commit 48231b0

Browse files
feat(console): the install result says when a package was installed without a protocol compatibility check (#30460)
domain:ui#5 · os-sales · session_01L6bhgWZ1wrAZ2ocZovnByy <!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuQyjmnvpHKvXkkGLK7ES5aE)_ Fixes #30326 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:控制台三处安装结果(应用市场软件包详情页「安装」、应用市场目录的组织软件包安装、设置 → 软件包 → 导入)在安装成功的句子下多一行琥珀色提醒:该软件包未声明它支持的平台协议版本(或声明了无法识别的范围),因此安装时没有做兼容性检查,可能无法在当前平台版本上正常运行。按读者语言显示(10 种语言),点名软件包、安装时的平台协议版本,以及无法识别的范围。 - 为什么改:服务端那一半(PR #30409)已经在安装应答里带上 `data.protocolCheckSkipped`,但控制台只读成功字段,管理员看不到服务器日志,这条提醒被丢掉了。 - 风险与回滚:只在应答带这个字段时多一行字;检查过范围的安装一字不变;不认识的 reason 原样显示服务端英文句子;回滚即还原本 PR,无数据迁移。 - 先看:`ui/packages/app-shell/src/console/marketplace/protocolCheckSkipped.ts`(读字段、选词条)、`MarketplacePackagePage.tsx` 的 `doInstallLocal`(四个成功分支都带上提醒)、`ui/packages/i18n/src/locales/zh.ts` 的 `protocolCheckSkipped`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/console/marketplace/__tests__/protocolCheckSkipped-30326.test.ts packages/app-shell/src/views/metadata-admin/PackagesPage.importProtocolCheckSkipped-30326.test.tsx`,预期 8 个用例全绿(需先建 `@object-ui/app-shell` 的依赖闭包)。 - 需要你做的:无,席位落地。 ## What changes Card #30326, the **ui half**. The server half landed in PR #30409 (`60fe44fdc`): `POST /api/v1/marketplace/install-local` answers `data.protocolCheckSkipped` on a success answer when the ADR-0087 D1 handshake admitted the package without checking its range, and omits it otherwise. - **Reader.** New `console/marketplace/protocolCheckSkipped.ts`, modelled on `dependencyMissing.ts`: `protocolCheckSkippedText(answer, t)` draws one key per `reason` (`no-range`, `unparsed-range`) from the notice's facts (`packageId`, `protocolVersion`, and `requiredRange` for `unparsed-range`). A `reason` this Console does not know, or an `unparsed-range` without its range, draws the server's `message` as given. An answer without the field draws nothing. `useProtocolCheckSkippedText()` binds it to the current locale. - **The line.** New `ProtocolCheckSkippedNote.tsx`: an amber caution line with a warning icon, drawn inside the existing result note under its own sentence, since the install did succeed. - **The three surfaces.** - Package page Install (`MarketplacePackagePage.tsx`, `localResult`): every success answer carries it, managed loaded now, managed loaded at restart, unmanaged copy, and the "asked for a copy, installed managed" note. - Catalog org install (`MarketplacePage.tsx`, `orgMsg`), the `installLocal` path. - Setup → Packages → Import (`metadata-admin/PackagesPage.tsx`, `importMsg`). - **Types.** `LocalManagedInstallResult` and `LocalUnmanagedInstallResult` gain the optional `protocolCheckSkipped` (`marketplaceApi.ts`). - **i18n (the widening).** `marketplace.install.protocolCheckSkipped.noRange` and `.unparsedRange` in all 10 locale packs. Import reuses these through the shared hook, as it already does for `DEPENDENCY_MISSING`, so no `engine.packages.*` row is added. - **Changeset.** `.changeset/30326-install-result-protocol-check-skipped.md`, `@object-ui/app-shell` and `@object-ui/i18n` patch. Branch: the harness-assigned `claude/project-thread-sukiom`, not `claude/issue-30326-*`; the card is #30326. ## Tests Four new files, 14 cases: - `protocolCheckSkipped-30326.test.ts` (5): no-range facts; unparsed-range facts incl. the range; unknown reason draws the server message; unparsed-range without a range draws the server message; CONTROL: no field, null, undefined, nothing readable draw nothing. - `MarketplacePackagePage.protocolCheckSkipped-30326.test.tsx` (4): managed loaded, managed loaded at restart, unmanaged with an unreadable range; CONTROL: a checked install draws no line. The real `marketplaceApi` client, one stubbed `fetch`. - `MarketplacePage.orgInstallProtocolCheckSkipped-30326.test.tsx` (2): org install draws the line; CONTROL: none when checked. - `PackagesPage.importProtocolCheckSkipped-30326.test.tsx` (3): the REAL locale packs, zh no-range sentence and en unparsed-range sentence; CONTROL: none when checked. ## Acceptance notes - A standing protocol display on the package detail page is out of scope (claim): after install no read serves the field back, so there is nothing to draw it from outside the install answer. - The other install door, `POST /api/v1/packages`, answers no notice yet; that is #30412 (server). `os package install` does not print the notice either (noted by the server half's report). - Live screenshots cover the Import surface only: this rig has no cloud marketplace (`cloudUrl` empty), so the package page and the org catalog cannot install from a catalog here. Those two are covered by the tests above, on the wire shape the live server answered. ## Verification Gates and tests ran on `a58846c5`; the head adds an evidence commit and its removal, the tree is byte-identical (`git diff a58846c HEAD` is empty). Base `b6caa141`. <details><summary>Live rig and screenshots (zh-CN, 1440 wide)</summary> `examples/app-showcase` `objectstack dev --seed-admin --fresh` on a private port, the console via `vite` in `ui/apps/console` with `DEV_PROXY_TARGET`, Chromium, browser locale zh-CN. The live door, read back before any UI work: - `POST /api/v1/marketplace/install-local` with `{"manifest":{"id":"com.acme.probe","version":"1.0.0","type":"app"}}` answered `protocolCheckSkipped: {reason: "no-range", packageId: "com.acme.probe", protocolVersion: "18.0.0", message: "Package 'com.acme.probe' does not state which platform protocol it supports, ..."}`. - The same with `engines: {protocol: "whatever-1"}` answered `reason: "unparsed-range"`, `requiredRange: "whatever-1"`, `rangeSource: "engines.protocol"`. Before (the reader held to draw nothing with `scripts/ablation-replace.mjs --hold`, restored after, blob equals HEAD): only `已导入 "com.acme.legacy-before"。刷新应用切换器后即可使用。` ![before import](https://github.com/objectstack-ai/objectstack/blob/136df32ac33dff042165a9fd3c3edaa30fb90d88/evidence/30326/before-import-zh-1440.png?raw=true) After: the same sentence, then `com.acme.legacy-crm 未声明它支持的平台协议版本,因此已在平台协议 18.0.0 下安装,未做兼容性检查,可能无法在当前平台版本上正常运行。声明了所支持协议(engines.protocol)的软件包版本会在安装时接受检查。` ![after import](https://github.com/objectstack-ai/objectstack/blob/136df32ac33dff042165a9fd3c3edaa30fb90d88/evidence/30326/after-import-zh-1440.png?raw=true) </details> <details><summary>Tests, ablation, typecheck, lint</summary> - The four new files: Test Files 4 passed (4), Tests 14 passed (14), lock VERDICT command-exit 0. - `pnpm --dir ui exec vitest run packages/app-shell/src/console/marketplace/ packages/app-shell/src/views/metadata-admin/PackagesPage packages/i18n/`: Test Files 163 passed (163), Tests 2016 passed, 13 skipped, lock VERDICT command-exit 0. - Ablation (`scripts/ablation-replace.mjs`, anchor x1 to x0, blob 1700af1 to 8e4eb802): the reader's first guard replaced by an unconditional `return null`: 10 failed / 4 passed (the 4 CONTROL cases stay green). Restored, blob equals HEAD, `git diff HEAD` empty. - `pnpm --filter @object-ui/app-shell run type-check` and `pnpm --filter @object-ui/i18n run type-check` (`tsc --noEmit` and `tsc -p tsconfig.test.json`): lock VERDICT command-exit 0. - `pnpm --dir ui run check:spec-symbols`: exit 0 (0 untriaged collisions). - ui checks: `check:i18n-keys`, `check:i18n-drift`, `check:i18n-dead-keys`, `check:control-bytes`, `check:new-line-citations`, `check:icon-record-names`, `check:phantom-deps`: each exit 0. `check:eager-locale-catalogues`: exit 2, PREREQUISITE NOT MET (reads a built console bundle), NOT MEASURED locally; CI's run is the reading. - eslint on the six changed source files: 0 errors; warnings only on lines this PR does not change. </details> <details><summary>Derived gates (35/35 exit 0)</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 35 families at `a58846c5`; every one ran and exited 0. `--ran` reconciliation: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero, every line carries an exit code). `pnpm --dir ui exec vitest run scripts/`: Test Files 170 passed, 2 skipped; Tests 4985 passed, 2 skipped. CI runs the rest. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgWZ1wrAZ2ocZovnByy)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgWZ1wrAZ2ocZovnByy --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bc8945f commit 48231b0

21 files changed

Lines changed: 637 additions & 4 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(console): the install result says when a package was installed without a protocol compatibility check
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
- **What the administrator now reads.** `POST /api/v1/marketplace/install-local` answers `data.protocolCheckSkipped` when its protocol handshake admitted the package without checking it: the package declares no `engines.protocol` (`no-range`), or a range the runtime cannot read (`unparsed-range`). The three Console surfaces that show an install result now draw it as a caution line under the success sentence: the package page's Install result, the catalog's organization install message, and Setup → Packages → Import. The line names the package, the platform protocol it was installed under and, for `unparsed-range`, the range it declared, in the reader's locale. A `reason` this Console does not know shows the server's own `message`. An install whose range was checked shows nothing new.
12+
- **Types.** `LocalManagedInstallResult` and `LocalUnmanagedInstallResult` gain the optional `protocolCheckSkipped`.
13+
- **New strings.** `@object-ui/i18n` gains `marketplace.install.protocolCheckSkipped.noRange` and `.unparsedRange` in all 10 locales.

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

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ import {
8282
type CloudInstallationInfo,
8383
} from './marketplaceApi.js';
8484
import { useDependencyMissingText } from './dependencyMissing.js';
85+
import { useProtocolCheckSkippedText } from './protocolCheckSkipped.js';
86+
import { ProtocolCheckSkippedNote } from './ProtocolCheckSkippedNote.js';
8587
import { getCloudBase, getRuntimeConfig, isMarketplaceEnabled } from '../../runtime-config.js';
8688
import { emitMetadataRefresh } from '../../assistant/assistantBus.js';
8789
import { useMetadata } from '../../providers/MetadataProvider.js';
@@ -148,6 +150,7 @@ export function MarketplacePackagePage() {
148150
const { isAdmin, isResolved } = useWorkspaceAdminStatus();
149151
const { t, language } = useObjectTranslation();
150152
const dependencyMissingText = useDependencyMissingText();
153+
const protocolCheckSkippedText = useProtocolCheckSkippedText();
151154
// Dates and numbers on this surface read the display locale; a bare
152155
// `toLocale*()` call used the MACHINE's locale (objectui#9909).
153156
const displayLocale = useDisplayLocale();
@@ -267,6 +270,9 @@ export function MarketplacePackagePage() {
267270
message: string;
268271
unmanaged?: { strippedLocks: LocalInstallStrippedLock[] };
269272
credentials?: { packageId: string; note?: string };
273+
// objectstack#30326 — the install answer's `protocolCheckSkipped`, drawn:
274+
// installed, but its platform protocol range was not checked.
275+
protocolNote?: string;
270276
} | null>(null);
271277
// ADR-0131 D6 — the install modes the package's latest manifest declares,
272278
// read from the manifest install-local judges, and the operator's pick among
@@ -601,6 +607,10 @@ export function MarketplacePackagePage() {
601607
try {
602608
const result = await installLocal({ packageId, ...(installMode ? { installMode } : {}) });
603609
const name = data?.package ? localizePackage(data.package as any, language).displayName : result.manifestId;
610+
// objectstack#30326 — every success answer can say the package was
611+
// admitted with its protocol range unchecked; each note below carries it.
612+
const protocolNote = protocolCheckSkippedText(result) ?? undefined;
613+
const withProtocolNote = protocolNote ? { protocolNote } : {};
604614
// An unmanaged copy (ADR-0131 D6): the package's metadata is this
605615
// runtime's own from now on, in the base the answer names; the answer
606616
// lists every protection lock the copy dropped, with the package's reason.
@@ -614,6 +624,7 @@ export function MarketplacePackagePage() {
614624
ok: true,
615625
message: t('marketplace.install.unmanagedSuccess', { version: result.version, name, base: result.targetPackageId }),
616626
unmanaged: { strippedLocks: result.strippedLocks },
627+
...withProtocolNote,
617628
});
618629
emitMetadataRefresh();
619630
return;
@@ -629,8 +640,8 @@ export function MarketplacePackagePage() {
629640
// and the page says when the app will appear.
630641
if (!result.hotLoaded) {
631642
setLocalResult(ignoredCopy
632-
? { ok: false, message: t('marketplace.install.unmanagedIgnored', { name }) }
633-
: { ok: true, message: t('marketplace.install.localLoadsAtRestart', { version: result.version, name }) });
643+
? { ok: false, message: t('marketplace.install.unmanagedIgnored', { name }), ...withProtocolNote }
644+
: { ok: true, message: t('marketplace.install.localLoadsAtRestart', { version: result.version, name }), ...withProtocolNote });
634645
return;
635646
}
636647
try {
@@ -639,11 +650,12 @@ export function MarketplacePackagePage() {
639650
// Non-fatal: the install succeeded; the app list will refresh on next navigation.
640651
}
641652
setLocalResult(ignoredCopy
642-
? { ok: false, message: t('marketplace.install.unmanagedIgnored', { name }) }
653+
? { ok: false, message: t('marketplace.install.unmanagedIgnored', { name }), ...withProtocolNote }
643654
: {
644655
ok: true,
645656
message: t('marketplace.install.localSuccess', { version: result.version, name }),
646657
credentials: { packageId: result.manifestId, note: result.credentialNote },
658+
...withProtocolNote,
647659
});
648660
emitMetadataRefresh();
649661
} catch (e: any) {
@@ -996,6 +1008,7 @@ export function MarketplacePackagePage() {
9961008
{localResult.ok ? <CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" /> : <AlertCircle className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" />}
9971009
<div className="flex-1">
9981010
{localResult.message}
1011+
{localResult.protocolNote ? <ProtocolCheckSkippedNote text={localResult.protocolNote} /> : null}
9991012
{/* ADR-0131 D6 — each protection lock the unmanaged copy dropped, as
10001013
the answer states it: the item, the lock and the package's reason
10011014
verbatim, so the operator reads why the vendor locked an item

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ import { appRouteSegment, filterActiveApps } from '../../utils/index.js';
5252
import { waitForServedApp, type ServedApp } from './waitForServedApp.js';
5353
import { splitPackageSuffix } from './middleTruncate.js';
5454
import { useDependencyMissingText } from './dependencyMissing.js';
55+
import { useProtocolCheckSkippedText } from './protocolCheckSkipped.js';
56+
import { ProtocolCheckSkippedNote } from './ProtocolCheckSkippedNote.js';
5557
import { ConnectorCredentialsStep } from './ConnectorCredentialsStep.js';
5658
import { packagesHref } from '../../views/metadata-admin/packageSource.js';
5759

@@ -310,6 +312,7 @@ export function MarketplacePage() {
310312
const { isAdmin, isResolved } = useWorkspaceAdminStatus();
311313
const { t, language } = useObjectTranslation();
312314
const dependencyMissingText = useDependencyMissingText();
315+
const protocolCheckSkippedText = useProtocolCheckSkippedText();
313316
const { appLabel } = useServedAppLabel();
314317
// The adapter `MetadataProvider` reads through: the deploy wait asks the
315318
// same `GET /meta/app` the cache would, without the cache persisting it.
@@ -356,6 +359,8 @@ export function MarketplacePage() {
356359
ok: boolean;
357360
text: string;
358361
credentials?: { packageId: string; note?: string };
362+
// objectstack#30326 — the answer's `protocolCheckSkipped`, drawn.
363+
protocolNote?: string;
359364
} | null>(null);
360365
const [orgDeploy, setOrgDeploy] = useState<OrgDeployState | null>(null);
361366
const [installedIds, setInstalledIds] = useState<Set<string>>(new Set());
@@ -491,6 +496,8 @@ export function MarketplacePage() {
491496
try {
492497
if (getRuntimeConfig().features.installLocal) {
493498
const result = await installLocal({ packageId: pkg.manifest_id || pkg.id, versionId: 'latest' });
499+
// objectstack#30326 — installed, its platform protocol range unchecked.
500+
const protocolNote = protocolCheckSkippedText(result);
494501
// objectui#12098 — `hotLoaded: false` is the runtime's lenient path
495502
// (objectstack#22695): installed, loaded at its next restart. Nothing
496503
// of it is served until then, so the strip says so and nothing is
@@ -501,6 +508,7 @@ export function MarketplacePage() {
501508
? t('marketplace.org.installed', { defaultValue: `Installed ${pkg.display_name}`, name: pkg.display_name })
502509
: t('marketplace.install.localLoadsAtRestart', { version: result.version, name: pkg.display_name }),
503510
...(result.hotLoaded ? { credentials: { packageId: result.manifestId, note: result.credentialNote } } : {}),
511+
...(protocolNote ? { protocolNote } : {}),
504512
});
505513
await load();
506514
// On `hotLoaded` the runtime registered the package before it answered,
@@ -738,6 +746,7 @@ export function MarketplacePage() {
738746
{orgMsg && (
739747
<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'}`}>
740748
{orgMsg.text}
749+
{orgMsg.protocolNote ? <ProtocolCheckSkippedNote text={orgMsg.protocolNote} /> : null}
741750
{orgMsg.ok && orgMsg.credentials ? (
742751
<ConnectorCredentialsStep
743752
packageId={orgMsg.credentials.packageId}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { AlertTriangle } from 'lucide-react';
4+
5+
/**
6+
* The install result's caution line for an answer that carries
7+
* `protocolCheckSkipped` (objectstack#30326): drawn inside the install result
8+
* note, under its own sentence, in a caution colour of its own, because the
9+
* install succeeded and the note around it says so. `text` is the sentence
10+
* from {@link import('./protocolCheckSkipped.js').protocolCheckSkippedText}.
11+
*/
12+
export function ProtocolCheckSkippedNote({ text, className }: { text: string; className?: string }) {
13+
return (
14+
<p
15+
data-testid="protocol-check-skipped"
16+
className={`mt-2 flex items-start gap-1.5 whitespace-normal text-amber-700 dark:text-amber-400 ${className ?? ''}`}
17+
>
18+
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" />
19+
<span>{text}</span>
20+
</p>
21+
);
22+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The package page's Install result says when the install door admitted the
5+
* package WITHOUT checking its platform protocol range: the answer's
6+
* `data.protocolCheckSkipped` (objectstack#30326), drawn under the success
7+
* sentence. Managed (loaded now, loaded at restart) and unmanaged answers all
8+
* carry it.
9+
*
10+
* `marketplaceApi` is NOT mocked: one stubbed `fetch` answers by path, so the
11+
* notice the page draws is the one that crossed the wire. `t` echoes the KEY
12+
* and the notice's facts.
13+
*/
14+
15+
import '@testing-library/jest-dom/vitest';
16+
import { afterEach, describe, expect, it, vi } from 'vitest';
17+
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
18+
19+
vi.mock('react-router-dom', () => ({
20+
useNavigate: () => vi.fn(),
21+
useParams: () => ({ packageId: 'pkg_psa' }),
22+
}));
23+
24+
vi.mock('@object-ui/auth', async (importOriginal) => ({
25+
...(await importOriginal<Record<string, unknown>>()),
26+
useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
27+
}));
28+
29+
vi.mock('@object-ui/i18n', async (importOriginal) => ({
30+
...(await importOriginal<Record<string, unknown>>()),
31+
useObjectTranslation: () => ({
32+
t: (key: string, o?: Record<string, unknown>) => {
33+
const facts = ['packageId', 'protocolVersion', 'requiredRange'].filter((k) => o?.[k] !== undefined).map((k) => `${k}=${String(o?.[k])}`);
34+
return facts.length > 0 ? `«${key}|${facts.join('|')}»` : `«${key}»`;
35+
},
36+
language: 'en',
37+
}),
38+
}));
39+
40+
vi.mock('../../../assistant/assistantBus', () => ({ emitMetadataRefresh: () => {} }));
41+
vi.mock('../../../providers/MetadataProvider', () => ({ useMetadata: () => ({ refresh: async () => {} }) }));
42+
vi.mock('../../../components/SuggestedBindingsPanel', () => ({
43+
SuggestedBindingsPanel: () => <div data-testid="suggested-bindings" />,
44+
}));
45+
vi.mock('../ConnectorCredentialsStep', () => ({
46+
ConnectorCredentialsStep: () => <div data-testid="credential-step" />,
47+
}));
48+
49+
import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../../runtime-config';
50+
import { MarketplacePackagePage } from '../MarketplacePackagePage';
51+
52+
const INSTALL_PATH = '/api/v1/marketplace/install-local';
53+
const MANIFEST_PATH = '/api/v1/marketplace/packages/pkg_psa/versions/latest/manifest';
54+
const CATALOG_PACKAGE = {
55+
package: { id: 'pkg_psa', manifest_id: 'com.acme.psa', display_name: 'PSA', latest_version: { id: 'ver_1', version: '1.0.0' } },
56+
versions: [{ id: 'ver_1', version: '1.0.0' }],
57+
};
58+
const SERVER_MESSAGE = 'SERVER-ENGLISH sentinel: installed without a compatibility check.';
59+
const NO_RANGE = { reason: 'no-range', packageId: 'com.acme.psa', protocolVersion: '18.1.0', message: SERVER_MESSAGE };
60+
61+
const answer = (status: number, body: unknown) => ({
62+
ok: status >= 200 && status < 300,
63+
status,
64+
statusText: '',
65+
headers: new Headers({ 'content-type': 'application/json' }),
66+
json: async () => body,
67+
text: async () => JSON.stringify(body),
68+
});
69+
70+
async function boot(installData: Record<string, unknown>, installModes?: string[]) {
71+
resetRuntimeConfigForTesting();
72+
vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: { method?: string }) => {
73+
const path = new URL(String(input), 'http://console.test').pathname;
74+
const method = (init?.method ?? 'GET').toUpperCase();
75+
if (path === '/api/v1/runtime/config') {
76+
return answer(200, {
77+
cloudUrl: '', singleEnvironment: true,
78+
features: { installLocal: true, marketplace: true, aiStudio: true, autoPublishAiBuilds: true },
79+
branding: { productName: 'ObjectOS', productShortName: 'ObjectOS' },
80+
});
81+
}
82+
if (path === INSTALL_PATH && method === 'GET') return answer(200, { success: true, data: { items: [], total: 0 } });
83+
if (path === INSTALL_PATH && method === 'POST') return answer(200, { success: true, data: installData });
84+
if (path === MANIFEST_PATH) {
85+
return answer(200, {
86+
success: true,
87+
data: { manifest: { id: 'com.acme.psa', version: '1.0.0', type: 'app', ...(installModes ? { installModes } : {}) }, version: '1.0.0', version_id: 'ver_1' },
88+
});
89+
}
90+
if (path === '/api/v1/marketplace/packages/pkg_psa') return answer(200, { success: true, data: CATALOG_PACKAGE });
91+
return answer(404, { success: false, error: { code: 'NOT_FOUND', message: `no route ${method} ${path}` } });
92+
}));
93+
await initRuntimeConfig();
94+
}
95+
96+
async function install() {
97+
render(<MarketplacePackagePage />);
98+
const button = await screen.findByRole('button', { name: '«marketplace.action.install»' });
99+
await act(async () => {});
100+
fireEvent.click(button);
101+
}
102+
103+
const MANAGED = { manifestId: 'com.acme.psa', version: '1.0.0', versionId: 'ver_1', installedAt: '2026-11-05T00:00:00Z', upgradedFrom: null };
104+
105+
afterEach(() => {
106+
cleanup();
107+
vi.unstubAllGlobals();
108+
resetRuntimeConfigForTesting();
109+
});
110+
111+
describe('the package page\'s Install result draws protocolCheckSkipped (objectstack#30326)', () => {
112+
it('a managed install the kernel loaded: the success sentence, then the unchecked-protocol line', async () => {
113+
await boot({ ...MANAGED, hotLoaded: true, protocolCheckSkipped: NO_RANGE });
114+
await install();
115+
116+
expect(await screen.findByText('«marketplace.install.localSuccess»')).toBeInTheDocument();
117+
expect(screen.getByTestId('protocol-check-skipped')).toHaveTextContent(
118+
'«marketplace.install.protocolCheckSkipped.noRange|packageId=com.acme.psa|protocolVersion=18.1.0|requiredRange=»',
119+
);
120+
expect(screen.queryByText(SERVER_MESSAGE)).not.toBeInTheDocument();
121+
});
122+
123+
it('a managed install loaded at restart carries it too', async () => {
124+
await boot({ ...MANAGED, hotLoaded: false, protocolCheckSkipped: NO_RANGE });
125+
await install();
126+
127+
expect(await screen.findByText('«marketplace.install.localLoadsAtRestart»')).toBeInTheDocument();
128+
expect(screen.getByTestId('protocol-check-skipped')).toHaveTextContent('marketplace.install.protocolCheckSkipped.noRange');
129+
});
130+
131+
it('an unmanaged copy with an unreadable range: the range it declared', async () => {
132+
await boot({
133+
installMode: 'unmanaged', manifestId: 'com.acme.psa', version: '1.0.0', versionId: 'ver_1', installedAt: '2026-11-05T00:00:00Z',
134+
installedFrom: { packageId: 'com.acme.psa' }, targetPackageId: 'com.acme.psa_copy', namespace: 'psa', copiedCount: 0, copied: [], strippedLocks: [],
135+
protocolCheckSkipped: {
136+
reason: 'unparsed-range', packageId: 'com.acme.psa', protocolVersion: '18.1.0',
137+
requiredRange: 'latest', rangeSource: 'engines.protocol', message: SERVER_MESSAGE,
138+
},
139+
}, ['unmanaged']);
140+
await install();
141+
142+
expect(await screen.findByText('«marketplace.install.unmanagedSuccess»')).toBeInTheDocument();
143+
expect(screen.getByTestId('protocol-check-skipped')).toHaveTextContent(
144+
'«marketplace.install.protocolCheckSkipped.unparsedRange|packageId=com.acme.psa|protocolVersion=18.1.0|requiredRange=latest»',
145+
);
146+
});
147+
148+
it('CONTROL: an install whose range was checked draws no such line', async () => {
149+
await boot({ ...MANAGED, hotLoaded: true });
150+
await install();
151+
152+
expect(await screen.findByText('«marketplace.install.localSuccess»')).toBeInTheDocument();
153+
expect(screen.queryByTestId('protocol-check-skipped')).not.toBeInTheDocument();
154+
});
155+
});

0 commit comments

Comments
 (0)