Skip to content

Commit 042efb7

Browse files
feat(console): a marketplace app's Install confirms what it adds before it installs (#30840)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoDxCGUhEdUjr6paznZqQ5b6)_ domain:ui#2 · os-tesla · session_01L9qibwKGwfa8bd6GQV8vHC Fixes #30325 Supersedes #30545 (same reviewed diff, resumed from `claude/project-thread-o1qmt2@816df85` on this thread's own branch; its strings now live in the lazy `marketplace` namespace). Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:市场应用详情页点「安装」(装进本运行时)不再立刻开装,先弹控制台自己的确认框,写明会加入几个对象、几个权限集、几条示例记录,以及装后会建议把哪个权限集授予所有已登录用户;确认框里再点「安装」照原样安装,「取消」什么都不装。示例数据仍默认加载。行为与 #30545 已过席位复核的版本完全相同。 - 为什么改:#30325 实测:HotCRM 一点安装就直接 处理中…,一次加了几十个对象、权限集和 369 条示例记录,管理员事先看不到。#30545 当时被首屏英文语言包的体积上限挡住;#30640 把市场文案搬进按需加载的 `marketplace` 文案包后,本 PR 把确认框的 11 条文案也放进那里,首屏语言包一个字节都不增加。 - 风险与回滚:只改前端;数字取自页面原本就在读的同一份版本 manifest(install-local 实际安装的也是它),不新增请求;读不到 manifest 时确认框如实说读不到,安装照常可用。首屏 `i18n-locale-en` 57,003 / 60,000 gz,与 main 相同,上限未动。回滚即还原本 PR。 - 先看:`ui/packages/app-shell/src/console/marketplace/installPreview.ts`(计数口径)、`MarketplacePackagePage.tsx` 的 `doInstallLocal` / `runInstallLocal` 与新 `AlertDialog`、`ui/packages/i18n/src/namespaces/marketplace/en.ts` 的 `install.preview` 段。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.installPreview-30325.test.tsx packages/app-shell/src/console/marketplace/__tests__/installPreview-30325.test.ts`,预期 12 个用例全绿。 - 需要你做的:无,席位落地。 - 席位意见:ui 席 2 对照 head `5d7d5c34` 复核:确认框行为与 #30545 已复核版本相同,计数只读页面原有的同一份 manifest,不新增请求;11 条文案进按需加载的 `marketplace` 文案包,首屏英文包 57,003 / 60,000 gz、上限未动。`marketplaceApi.ts` 与 `installPreview.ts` 不在 `@object-ui/app-shell` 入口导出,`marketplace` 命名空间只导出无键类型的目录,故 Clause-② no 成立,不欠契约复审。同意落地。 ## What changes The behaviour is the one PR #30545 carried and the seat reviewed on its diff; this PR resumes that branch at `816df8503f`, merges `origin/main` (which now holds PR #30640, the lazy `marketplace` namespace), and moves the confirm's strings into that namespace. Nothing else in the reviewed diff changed. - **The confirm.** The package page's local Install (`doInstallLocal`) now opens an `AlertDialog` instead of posting at once. It names the package, then lists **Objects / Permission sets / Sample records** as label and number, a note that sample records load with the app and can be cleared from this page afterwards, and one line per permission set the install will suggest granting to Everyone (nothing is granted until an administrator accepts). Its **Install** calls `runInstallLocal`, the unchanged install body (modes, result notes, refusals all as before); **Cancel** installs nothing. Reinstall gets the same confirm with a reinstall title. - **Where the counts come from** (`installPreview.ts`, a pure reader). The page already read `GET /api/v1/marketplace/packages/ID/versions/latest/manifest` for `installModes`; that body is what install-local fetches and installs. `marketplaceApi.ts` exposes the body read (`getServedPackageBody`) and the mode reader as a pure function (`declaredInstallModesOf`), and the page derives both the modes and the preview from the ONE read. Objects (list or name-keyed map), permission sets (only a LIST of named sets), sample records (top-level `data[]` seed datasets, "up to N" when a dataset is env- or locale-scoped), suggested grants (sets declaring `isDefault: true`, ADR-0090 D5), across every package body the artifact carries. - **Sample-record count, read one way with #30323.** Before install: the declared seed records above. After install: `sampleRecordsLoaded` reads the install answer's `seeded`. - **Unreadable manifest.** The confirm says what the package adds could not be read and the install result will say what loaded; its Install still installs. - **Strings, now lazy.** `marketplace.install.preview.*` (11 keys) sit in `ui/packages/i18n/src/namespaces/marketplace/LANG.ts` for all ten languages, inside each catalogue's `install` group right after `unmanagedIgnored`, text byte-identical to #30545's. The eager packs (`src/locales/LANG.ts`) are exactly `origin/main`'s. Studio's packages page does not read these keys (the confirm is drawn only by `MarketplacePackagePage`, which renders under `MarketplaceNamespaceGate`), so none belongs in the resident set; `marketplaceNamespaceBoundary.test.ts` holds that. `atMost` stays on the count-neutral list of `count-families-11445.test.ts`. - **Merge.** The only hand resolution in `MarketplacePackagePage.tsx` is the import block: main's multi-line `sampleDataNotice.js` import kept, the `installPreview.js` import added above it. - **Changeset.** `.changeset/30325-install-confirm-what-it-adds.md`, `@object-ui/app-shell` and `@object-ui/i18n` patch; its Strings line now names the namespace. Branch: the harness-assigned `claude/project-thread-7yy8nl`, not `claude/issue-30325-*`; the card is #30325 (claim 6677784267). ## Tests - `MarketplacePackagePage.installPreview-30325.test.tsx` (6 cases, real `marketplaceApi` over a stubbed `fetch`) and `installPreview-30325.test.ts` (6 cases), as in #30545, now resolving their strings through the namespace frame. - Existing local-install tests click the confirm after Install (`credentialStep-27238`, `dependencyMissing-25792`, `installMode-12288`, `protocolCheckSkipped-30326`, `sampleDataNotice-30323`, `servedTruth-12098`, `walledCatalogPackageRefusal-7611`). No assertion changed. The whole `console/marketplace/` directory, including the tests main added since #30545's base, passes on this head. - The positive control was #30545's (page restored to its base with the new tests kept: 6 failed / 6); the page source is unchanged here apart from the import merge, so it was not re-run. ## Acceptance notes - **Eager budget, no ceiling raise.** Console `CI=true vite build` on `ff5d4116cf`: `i18n-locale-en` reads 57,003 gzip bytes against the 60,000 ceiling (headroom 2,997), the same reading #30640 recorded for main; `PER_CHUNK_GZIP_CEILINGS` untouched. The confirm's strings ship in the lazy `i18n-ns-marketplace-LANG` chunks (the `en` one contains "Reading what this package adds"; the eager `i18n-locale-en` chunk does not). - **Live measurement.** Not re-run on this head; #30545's live run (real showcase stack with a stand-in control plane: the confirm read 3 / 2 / 13 and the install answer seeded 13) covers the unchanged behaviour. The string move is covered by the namespace frame tests, which render the page through the lazy catalogue. - Same class, not in this PR (from #30545, carried to the seat there): the catalog page's organization-package Install (`MarketplacePage.tsx` `doOrgInstall`) also posts install-local on one click with no confirm (read in code, not run). ## Verification All on head `5d7d5c349a` unless noted (`ff5d4116cf` differs from it only in the changeset text). <details><summary>Readings</summary> - `pnpm --dir ui exec vitest run packages/app-shell/src/console/marketplace/ packages/i18n/` (on `ff5d4116cf`, under the verify lock): 152 files passed, 1992 tests passed, 13 skipped; VERDICT command-exit 0. - `pnpm --dir ui check:eager-closure` (on `ff5d4116cf`): exit 0, `i18n-locale-en` 57,003 gz / 60,000. - `pnpm --dir ui run check:i18n-keys`, `check:i18n-dead-keys`, `check:eager-locale-catalogues`, `check:spec-symbols`, `check:control-bytes`, `check:new-line-citations`; `node scripts/check-i18n-en-drift.mjs --base origin/main` (from `ui/`): exit 0 each. - `pnpm --filter @object-ui/i18n run type-check`, `pnpm --filter @object-ui/app-shell run type-check` (after a declaration build of `@object-ui/app-shell^...`): exit 0 each. - eslint `--no-inline-config` on the 23 changed ui source and test files: 0 errors. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 35 derived families, each exit 0 (`pnpm --dir ui exec vitest run scripts/`: 170 files, 4979 tests passed); `--ran`: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN. - Change size: 24 files, +663 / -11. </details> <!-- signed: domain:ui#2 · os-tesla · session_01L9qibw7DekEe3wx5f3zphf --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c1f311f commit 042efb7

24 files changed

Lines changed: 663 additions & 11 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): a marketplace app's Install confirms what it adds before it installs
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **The confirm.** Install on a marketplace package page (an install into this runtime) now opens a short confirm in the console's own dialog before anything is installed. It says how many objects, permission sets and sample records the package adds, and which permission sets it will suggest granting to all signed-in users (the Everyone position) once installed; nothing is granted until an administrator accepts. Its Install installs exactly as the button did before, so trying an app is still one more click; Cancel installs nothing. Sample data stays on.
12+
- **Where the counts come from.** The package body the install itself installs: the catalog's version manifest the page already reads for the package's install modes, so the confirm adds no request. Objects and permission sets are counted across every package body it carries; sample records are the records of its seed datasets, shown as "up to N" when a dataset is scoped to some environments or locales and may not load here. If the manifest cannot be read, the confirm says so and Install still works; the install result says what loaded.
13+
- **Strings.** `@object-ui/i18n` gains `marketplace.install.preview.*` (title, reinstallTitle, intro, reading, unreadable, objects, permissionSets, sampleRecords, atMost, sampleNote, suggestsEveryone) in all 10 languages, in the lazy `marketplace` namespace (`@object-ui/i18n/namespaces/marketplace`) that loads with the marketplace pages, so the eager language packs do not grow.

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

Lines changed: 87 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ import { localizePackage } from './usePackageL10n.js';
6868
import { isNewerVersion } from './versionPrecedence.js';
6969
import {
7070
getMarketplacePackage,
71-
getDeclaredInstallModes,
71+
getServedPackageBody,
72+
declaredInstallModesOf,
7273
installPackage,
7374
installLocal,
7475
uninstallLocal,
@@ -93,6 +94,7 @@ import {
9394
import { useDependencyMissingText } from './dependencyMissing.js';
9495
import { useProtocolCheckSkippedText } from './protocolCheckSkipped.js';
9596
import { ProtocolCheckSkippedNote } from './ProtocolCheckSkippedNote.js';
97+
import { installPreviewOf, type InstallPreview } from './installPreview.js';
9698
import {
9799
forgetSampleData,
98100
noteServedSampleCount,
@@ -325,6 +327,12 @@ function MarketplacePackagePageBody() {
325327
// to another package never offers or sends the last one's.
326328
const [modesRead, setModesRead] = useState<{ packageId: string; modes: readonly LocalInstallMode[] | undefined } | null>(null);
327329
const [modePick, setModePick] = useState<{ packageId: string; mode: LocalInstallMode } | null>(null);
330+
// objectstack#30325 — what the install will add, read from the same manifest
331+
// answer as the modes, kept with its package. `preview: null` = the manifest
332+
// could not be read or is not a package body; no entry for this package yet
333+
// = still reading. The confirm in front of a local Install draws it.
334+
const [previewRead, setPreviewRead] = useState<{ packageId: string; preview: InstallPreview | null } | null>(null);
335+
const [confirmLocalOpen, setConfirmLocalOpen] = useState(false);
328336
// `undefined` until read, when the manifest declares none, or when it could
329337
// not be read: then no choice is drawn and no mode is sent, so the door's own
330338
// default and answer decide.
@@ -375,10 +383,15 @@ function MarketplacePackagePageBody() {
375383
let cancelled = false;
376384
(async () => {
377385
try {
378-
const modes = await getDeclaredInstallModes(packageId);
379-
if (!cancelled) setModesRead({ packageId, modes });
386+
// One read serves both: the modes it declares, and what it adds.
387+
const body = await getServedPackageBody(packageId);
388+
if (cancelled) return;
389+
setModesRead({ packageId, modes: declaredInstallModesOf(body) });
390+
setPreviewRead({ packageId, preview: installPreviewOf(body) });
380391
} catch {
381392
// No manifest to read: no choice and no mode, and the door decides.
393+
// The confirm says it could not read what the install adds.
394+
if (!cancelled) setPreviewRead({ packageId, preview: null });
382395
}
383396
})();
384397
return () => { cancelled = true; };
@@ -641,13 +654,24 @@ function MarketplacePackagePageBody() {
641654
}
642655
};
643656

657+
/**
658+
* The local Install button: opens the confirm that says what the install
659+
* adds (objectstack#30325). Its Install is the one click that installs, so
660+
* the install itself is unchanged and stays one click away.
661+
*/
662+
const doInstallLocal = () => {
663+
if (!packageId) return;
664+
if (offeredInstallModes && !requestedInstallMode) return;
665+
setConfirmLocalOpen(true);
666+
};
667+
644668
/**
645669
* Install this package into the LOCAL runtime kernel (not a cloud env).
646-
* Single-target operation — no picker, no dialog. Same-origin POST so
670+
* Single-target operation — no environment picker. Same-origin POST so
647671
* cloud session is not required. The local AuthPlugin session is what
648672
* the backend validates.
649673
*/
650-
const doInstallLocal = async () => {
674+
const runInstallLocal = async () => {
651675
if (!packageId) return;
652676
// ADR-0131 D6 — the mode is sent only when the manifest declares
653677
// `installModes`: its one mode, or the operator's pick among several.
@@ -1359,6 +1383,7 @@ function MarketplacePackagePageBody() {
13591383
? t('marketplace.install.mode.required')
13601384
: undefined;
13611385
const installModeReasonId = 'install-mode-required-reason';
1386+
const localPreviewRead = previewRead && previewRead.packageId === packageId ? previewRead : null;
13621387
const primaryAction = supportsLocal
13631388
? {
13641389
label: installingLocal
@@ -1641,6 +1666,63 @@ function MarketplacePackagePageBody() {
16411666
</div>
16421667
</div>
16431668

1669+
{/* objectstack#30325 — the confirm in front of a local Install: what the
1670+
install adds, read from the manifest it installs, and the grants it
1671+
will suggest. Its Install runs the install as the button did. */}
1672+
<AlertDialog open={confirmLocalOpen} onOpenChange={setConfirmLocalOpen}>
1673+
<AlertDialogContent data-testid="install-preview-dialog">
1674+
<AlertDialogHeader>
1675+
<AlertDialogTitle>
1676+
{localInstall
1677+
? t('marketplace.install.preview.reinstallTitle', { name: loc.displayName })
1678+
: t('marketplace.install.preview.title', { name: loc.displayName })}
1679+
</AlertDialogTitle>
1680+
<AlertDialogDescription>
1681+
{!localPreviewRead
1682+
? t('marketplace.install.preview.reading')
1683+
: localPreviewRead.preview
1684+
? t('marketplace.install.preview.intro')
1685+
: t('marketplace.install.preview.unreadable')}
1686+
</AlertDialogDescription>
1687+
</AlertDialogHeader>
1688+
{localPreviewRead?.preview && (
1689+
<div className="space-y-3 text-sm" data-testid="install-preview">
1690+
<dl className="grid grid-cols-[auto_auto] justify-start gap-x-6 gap-y-1">
1691+
<dt className="text-muted-foreground">{t('marketplace.install.preview.objects')}</dt>
1692+
<dd className="font-medium tabular-nums" data-testid="install-preview-objects">{localPreviewRead.preview.objects}</dd>
1693+
<dt className="text-muted-foreground">{t('marketplace.install.preview.permissionSets')}</dt>
1694+
<dd className="font-medium tabular-nums" data-testid="install-preview-permission-sets">{localPreviewRead.preview.permissionSets}</dd>
1695+
<dt className="text-muted-foreground">{t('marketplace.install.preview.sampleRecords')}</dt>
1696+
<dd className="font-medium tabular-nums" data-testid="install-preview-sample-records">
1697+
{localPreviewRead.preview.sampleRecordsAtMost
1698+
? t('marketplace.install.preview.atMost', { count: localPreviewRead.preview.sampleRecords })
1699+
: localPreviewRead.preview.sampleRecords}
1700+
</dd>
1701+
</dl>
1702+
{localPreviewRead.preview.sampleRecords > 0 && (
1703+
<p className="text-muted-foreground">{t('marketplace.install.preview.sampleNote')}</p>
1704+
)}
1705+
{localPreviewRead.preview.suggestedGrants.length > 0 && (
1706+
<ul className="space-y-1" data-testid="install-preview-suggestions">
1707+
{localPreviewRead.preview.suggestedGrants.map((set) => (
1708+
<li key={set}>{t('marketplace.install.preview.suggestsEveryone', { set })}</li>
1709+
))}
1710+
</ul>
1711+
)}
1712+
</div>
1713+
)}
1714+
<AlertDialogFooter>
1715+
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
1716+
<AlertDialogAction
1717+
data-testid="install-preview-confirm"
1718+
onClick={() => { void runInstallLocal(); }}
1719+
>
1720+
{localInstall ? t('marketplace.action.reinstall') : t('marketplace.action.install')}
1721+
</AlertDialogAction>
1722+
</AlertDialogFooter>
1723+
</AlertDialogContent>
1724+
</AlertDialog>
1725+
16441726
<Dialog open={installOpen} onOpenChange={(o) => { setInstallOpen(o); if (!o) { setInstallResult(null); setAcknowledged(false); } }}>
16451727
<DialogContent
16461728
onCloseAutoFocus={(e) => {

‎ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.credentialStep-27238.test.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@ async function install() {
118118
const button = await screen.findByRole('button', { name: '«marketplace.action.install»' });
119119
await act(async () => {});
120120
fireEvent.click(button);
121+
// objectstack#30325 — Install opens the confirm; its Install installs.
122+
fireEvent.click(await screen.findByTestId('install-preview-confirm'));
121123
}
122124

123125
beforeEach(() => {

‎ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.dependencyMissing-25792.test.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ async function install() {
9292
const button = await screen.findByRole('button', { name: '«marketplace.action.install»' });
9393
await act(async () => {});
9494
fireEvent.click(button);
95+
// objectstack#30325 — Install opens the confirm; its Install installs.
96+
fireEvent.click(await screen.findByTestId('install-preview-confirm'));
9597
}
9698

9799
const refusal = (details: unknown) => ({

‎ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.installMode-12288.test.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,8 @@ const radio = (mode: 'managed' | 'unmanaged') =>
186186

187187
async function install() {
188188
fireEvent.click(installButton());
189+
// objectstack#30325 — Install opens the confirm; its Install installs.
190+
fireEvent.click(await screen.findByTestId('install-preview-confirm'));
189191
await waitFor(() => expect(posts).toHaveLength(1));
190192
}
191193

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The package page's local Install opens a confirm that says what the install
5+
* adds before anything is installed (objectstack#30325): how many objects,
6+
* permission sets and sample records, and which permission sets it will
7+
* suggest granting to Everyone. Its Install is the one click that installs.
8+
*
9+
* The counts come from the manifest answer the page already reads for the
10+
* install modes (`GET /api/v1/marketplace/packages/:id/versions/latest/manifest`,
11+
* the body install-local installs): one read serves both.
12+
*
13+
* `marketplaceApi` is NOT mocked: one stubbed `fetch` answers by path, so the
14+
* requests counted are the ones the page puts on the wire. `t` echoes the KEY
15+
* and the interpolated name, count and set.
16+
*/
17+
18+
import '@testing-library/jest-dom/vitest';
19+
import { afterEach, describe, expect, it, vi } from 'vitest';
20+
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
21+
22+
vi.mock('react-router-dom', () => ({
23+
useNavigate: () => vi.fn(),
24+
useParams: () => ({ packageId: 'pkg_crm' }),
25+
}));
26+
27+
vi.mock('@object-ui/auth', async (importOriginal) => ({
28+
...(await importOriginal<Record<string, unknown>>()),
29+
useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
30+
}));
31+
32+
vi.mock('@object-ui/i18n', async (importOriginal) => ({
33+
...(await importOriginal<Record<string, unknown>>()),
34+
useObjectTranslation: () => ({
35+
t: (key: string, o?: Record<string, unknown>) => {
36+
const facts = ['name', 'count', 'set'].filter((k) => o?.[k] !== undefined).map((k) => `${k}=${String(o?.[k])}`);
37+
return facts.length > 0 ? `«${key}|${facts.join('|')}»` : `«${key}»`;
38+
},
39+
language: 'en',
40+
}),
41+
}));
42+
43+
vi.mock('../../../assistant/assistantBus', () => ({ emitMetadataRefresh: () => {} }));
44+
vi.mock('../../../providers/MetadataProvider', () => ({ useMetadata: () => ({ refresh: async () => {} }) }));
45+
vi.mock('../../../components/SuggestedBindingsPanel', () => ({
46+
SuggestedBindingsPanel: () => <div data-testid="suggested-bindings" />,
47+
}));
48+
vi.mock('../ConnectorCredentialsStep', () => ({
49+
ConnectorCredentialsStep: () => <div data-testid="credential-step" />,
50+
}));
51+
52+
import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../../runtime-config';
53+
import { MarketplacePackagePage } from '../MarketplacePackagePage';
54+
55+
const PKG = 'app.acme.crm';
56+
const INSTALL_PATH = '/api/v1/marketplace/install-local';
57+
const MANIFEST_PATH = '/api/v1/marketplace/packages/pkg_crm/versions/latest/manifest';
58+
const CATALOG_PACKAGE = {
59+
package: { id: 'pkg_crm', manifest_id: PKG, display_name: 'Acme CRM', latest_version: { id: 'ver_6', version: '6.0.0' } },
60+
versions: [{ id: 'ver_6', version: '6.0.0' }],
61+
};
62+
const records = (n: number) => Array.from({ length: n }, (_, i) => ({ name: `r${i}` }));
63+
64+
/** A flattened marketplace bundle, the shape a published app's version serves. */
65+
const BUNDLE = {
66+
manifest: { id: PKG, version: '6.0.0', type: 'app', name: 'Acme CRM' },
67+
objects: [{ name: 'crm_account' }, { name: 'crm_contact' }, { name: 'crm_lead' }],
68+
permissions: [{ name: 'crm_user', isDefault: true }, { name: 'crm_admin' }],
69+
data: [
70+
{ object: 'crm_account', externalId: 'name', records: records(5) },
71+
{ object: 'crm_contact', externalId: 'email', records: records(4) },
72+
],
73+
};
74+
75+
const MANAGED = { manifestId: PKG, version: '6.0.0', versionId: 'ver_6', installedAt: '2026-11-05T00:00:00Z', upgradedFrom: null, hotLoaded: true };
76+
77+
const answer = (status: number, body: unknown) => ({
78+
ok: status >= 200 && status < 300,
79+
status,
80+
statusText: '',
81+
headers: new Headers({ 'content-type': 'application/json' }),
82+
json: async () => body,
83+
text: async () => JSON.stringify(body),
84+
});
85+
86+
let calls: string[] = [];
87+
88+
async function boot(manifest: { status: number; body?: unknown }) {
89+
calls = [];
90+
resetRuntimeConfigForTesting();
91+
vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: { method?: string }) => {
92+
const path = new URL(String(input), 'http://console.test').pathname;
93+
const method = (init?.method ?? 'GET').toUpperCase();
94+
calls.push(`${method} ${path}`);
95+
if (path === '/api/v1/runtime/config') {
96+
return answer(200, {
97+
cloudUrl: '', singleEnvironment: true,
98+
features: { installLocal: true, marketplace: true, aiStudio: true, autoPublishAiBuilds: true },
99+
branding: { productName: 'ObjectOS', productShortName: 'ObjectOS' },
100+
});
101+
}
102+
if (path === INSTALL_PATH && method === 'GET') return answer(200, { success: true, data: { items: [], total: 0 } });
103+
if (path === INSTALL_PATH && method === 'POST') return answer(200, { success: true, data: MANAGED });
104+
if (path === MANIFEST_PATH) {
105+
return manifest.status === 200
106+
? answer(200, { success: true, data: { manifest: manifest.body, version: '6.0.0', version_id: 'ver_6' } })
107+
: answer(manifest.status, { success: false, error: { code: 'NOT_FOUND', message: 'no manifest' } });
108+
}
109+
if (path === '/api/v1/marketplace/packages/pkg_crm') return answer(200, { success: true, data: CATALOG_PACKAGE });
110+
return answer(404, { success: false, error: { code: 'NOT_FOUND', message: `no route ${method} ${path}` } });
111+
}));
112+
await initRuntimeConfig();
113+
}
114+
115+
const posts = () => calls.filter((c) => c === `POST ${INSTALL_PATH}`);
116+
117+
/** Render, settle the manifest read, and click the page's Install. */
118+
async function openConfirm() {
119+
render(<MarketplacePackagePage />);
120+
const button = await screen.findByRole('button', { name: '«marketplace.action.install»' });
121+
await waitFor(() => expect(calls).toContain(`GET ${MANIFEST_PATH}`));
122+
await act(async () => {});
123+
fireEvent.click(button);
124+
return screen.findByTestId('install-preview-dialog');
125+
}
126+
127+
afterEach(() => {
128+
cleanup();
129+
vi.unstubAllGlobals();
130+
resetRuntimeConfigForTesting();
131+
});
132+
133+
describe('the local Install confirms what it adds first (objectstack#30325)', () => {
134+
it('Install opens the confirm with the counts and the suggested grant, and installs nothing yet', async () => {
135+
await boot({ status: 200, body: BUNDLE });
136+
const dialog = await openConfirm();
137+
138+
expect(within(dialog).getByText('«marketplace.install.preview.title|name=Acme CRM»')).toBeInTheDocument();
139+
expect(within(dialog).getByText('«marketplace.install.preview.intro»')).toBeInTheDocument();
140+
expect(within(dialog).getByTestId('install-preview-objects')).toHaveTextContent('3');
141+
expect(within(dialog).getByTestId('install-preview-permission-sets')).toHaveTextContent('2');
142+
expect(within(dialog).getByTestId('install-preview-sample-records')).toHaveTextContent('9');
143+
expect(within(dialog).getByText('«marketplace.install.preview.sampleNote»')).toBeInTheDocument();
144+
expect(within(dialog).getByTestId('install-preview-suggestions')).toHaveTextContent(
145+
'«marketplace.install.preview.suggestsEveryone|set=crm_user»',
146+
);
147+
expect(posts()).toHaveLength(0);
148+
// One manifest read served the modes and the counts.
149+
expect(calls.filter((c) => c === `GET ${MANIFEST_PATH}`)).toHaveLength(1);
150+
});
151+
152+
it('its Install installs once, as the button did, and the result is drawn', async () => {
153+
await boot({ status: 200, body: BUNDLE });
154+
await openConfirm();
155+
fireEvent.click(screen.getByTestId('install-preview-confirm'));
156+
157+
expect(await screen.findByText('«marketplace.install.localSuccess|name=Acme CRM»')).toBeInTheDocument();
158+
expect(posts()).toHaveLength(1);
159+
expect(screen.queryByTestId('install-preview-dialog')).not.toBeInTheDocument();
160+
});
161+
162+
it('Cancel installs nothing', async () => {
163+
await boot({ status: 200, body: BUNDLE });
164+
const dialog = await openConfirm();
165+
fireEvent.click(within(dialog).getByRole('button', { name: '«common.cancel»' }));
166+
167+
await waitFor(() => expect(screen.queryByTestId('install-preview-dialog')).not.toBeInTheDocument());
168+
await act(async () => {});
169+
expect(posts()).toHaveLength(0);
170+
});
171+
172+
it('a scoped sample dataset reads as an upper bound', async () => {
173+
await boot({ status: 200, body: { ...BUNDLE, data: [{ object: 'crm_account', locale: ['zh-CN'], records: records(6) }] } });
174+
const dialog = await openConfirm();
175+
176+
expect(within(dialog).getByTestId('install-preview-sample-records')).toHaveTextContent('«marketplace.install.preview.atMost|count=6»');
177+
});
178+
179+
it('no sample records and no default set: no sample note and no suggestion line', async () => {
180+
await boot({ status: 200, body: { manifest: BUNDLE.manifest, objects: BUNDLE.objects, permissions: [{ name: 'crm_admin' }] } });
181+
const dialog = await openConfirm();
182+
183+
expect(within(dialog).getByTestId('install-preview-sample-records')).toHaveTextContent('0');
184+
expect(within(dialog).queryByText('«marketplace.install.preview.sampleNote»')).not.toBeInTheDocument();
185+
expect(within(dialog).queryByTestId('install-preview-suggestions')).not.toBeInTheDocument();
186+
});
187+
188+
it('a manifest that cannot be read: the confirm says so, and its Install still installs', async () => {
189+
await boot({ status: 404 });
190+
const dialog = await openConfirm();
191+
192+
expect(within(dialog).getByText('«marketplace.install.preview.unreadable»')).toBeInTheDocument();
193+
expect(within(dialog).queryByTestId('install-preview')).not.toBeInTheDocument();
194+
fireEvent.click(within(dialog).getByTestId('install-preview-confirm'));
195+
await waitFor(() => expect(posts()).toHaveLength(1));
196+
});
197+
});

0 commit comments

Comments
 (0)