diff --git a/.changeset/12097-org-install-wait-served.md b/.changeset/12097-org-install-wait-served.md new file mode 100644 index 0000000000..ea7f9d3f9c --- /dev/null +++ b/.changeset/12097-org-install-wait-served.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +--- + +An org install from the Marketplace catalog's "Your organization" strip, on a cloud-managed environment, now waits until the environment serves the installed package's app before it refreshes the metadata cache (objectui#12097). The package page has done this since objectui#12087. + +A cloud install answers once the control plane has written the installation. The environment's runtime then rebuilds its kernel and keeps serving the kernel from before the install until the rebuild lands (about 60 s, measured on staging for objectui#12087). The catalog page sent its metadata-refresh signal the moment the install answered. Every mounted metadata cache re-read its types on that signal and kept what it read, which was the pre-install app list, so the strip said "Installed" and the app never appeared in that tab. + +- **Wait.** The catalog page now runs the same wait as the package page (`waitForServedApp`), on the row's `manifest_id`: it reads `GET /meta/app` past the cache, at once and then every 5 s for up to 5 minutes, until an app's `_packageId` is that manifest id. Only then does it send the refresh signal. +- **Say what is happening.** In the strip, above its package cards and under the installed package's name, it shows the package page's three states in the package page's words: "Deploying the app…" while it waits; that the app is ready, with "Open {name}", once it is served; and, when the wait expires, that the install was recorded but the app has not appeared yet, with "Check again". It no longer says "Installed {name}" before the app is served, and on expiry it refreshes nothing. +- An install-local runtime is unchanged: it registers the package before it answers, so the strip says "Installed {name}" and refreshes at once, as before. + +No language-pack key is added: the three states reuse the package page's `marketplace.install` keys (`deploying`, `deployed`, `deployTimeout`, `openApp`, `checkAgain`). No export, prop or type on the `@object-ui/app-shell` entry changes. diff --git a/packages/app-shell/src/console/marketplace/MarketplacePage.tsx b/packages/app-shell/src/console/marketplace/MarketplacePage.tsx index 0e29f945a4..b3073eed03 100644 --- a/packages/app-shell/src/console/marketplace/MarketplacePage.tsx +++ b/packages/app-shell/src/console/marketplace/MarketplacePage.tsx @@ -19,9 +19,10 @@ import { Button, Skeleton, } from '@object-ui/components'; -import { Package, Search, RefreshCcw, Store, AlertCircle, CheckCircle2, Settings } from 'lucide-react'; +import { Package, Search, RefreshCcw, Store, AlertCircle, CheckCircle2, Settings, Loader2 } from 'lucide-react'; import { useWorkspaceAdminStatus } from '@object-ui/auth'; -import { useDisplayLocale, useObjectTranslation } from '@object-ui/i18n'; +import { useDisplayLocale, useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; +import { useAdapter } from '@object-ui/react'; import { PackageIcon } from './PackageIcon.js'; import { MarketplaceAccessDenied } from './MarketplaceAccessDenied.js'; import { MarketplaceResolving } from './MarketplaceResolving.js'; @@ -40,6 +41,8 @@ import { } from './marketplaceApi.js'; import { getCloudBase, getRuntimeConfig, isMarketplaceEnabled } from '../../runtime-config.js'; import { emitMetadataRefresh } from '../../assistant/assistantBus.js'; +import { appRouteSegment, filterActiveApps, resolveKeyedI18nLabel } from '../../utils/index.js'; +import { waitForServedApp, type ServedApp } from './waitForServedApp.js'; /** * Format a published-at timestamp as a localized relative string. @@ -107,11 +110,29 @@ function toLoadFailure(e: unknown): LoadFailure { }; } +/** + * objectui#12097 — an org install on a cloud-managed environment, from the + * install answering until the runtime serves the package's app, or the wait + * expires: the three states the package page draws for the same install + * (objectui#12087), in the same words. The strip lists several packages and + * this status sits above them all, so it names its package (`name`, the row's + * display name). `served` carries the first app of the package a user can open, + * or `null` when every one is inactive or hidden. + */ +type OrgDeployState = + | { phase: 'waiting'; manifestId: string; name: string } + | { phase: 'served'; manifestId: string; name: string; app: ServedApp | null } + | { phase: 'timeout'; manifestId: string; name: string }; + export function MarketplacePage() { const navigate = useNavigate(); const { appName } = useParams(); const { isAdmin, isResolved } = useWorkspaceAdminStatus(); const { t, language } = useObjectTranslation(); + 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. + const adapter = useAdapter(); const formatRelative = useRelativeFormatter(); const basePath = appName ? `/apps/${appName}` : ''; // The runtime's own answer, read once per render. `false` means this @@ -137,6 +158,7 @@ export function MarketplacePage() { const [orgItems, setOrgItems] = useState([]); const [orgInstalling, setOrgInstalling] = useState(null); const [orgMsg, setOrgMsg] = useState<{ ok: boolean; text: string } | null>(null); + const [orgDeploy, setOrgDeploy] = useState(null); const [installedIds, setInstalledIds] = useState>(new Set()); const installedByManifestId = useMemo(() => { @@ -190,6 +212,44 @@ export function MarketplacePage() { void load(); }, [marketplaceEnabled]); + /** + * objectui#12097 — wait until this environment serves the installed org + * package's app, and only then pulse the metadata refresh. + * + * The pulse makes every mounted `MetadataProvider` re-read its loaded types + * and persist what it reads (the tab's sessionStorage seed). A pulse that + * lands while the runtime still serves the pre-install kernel fixes the + * pre-install `app` list in place: the install "succeeded" and the app never + * appeared. `waitForServedApp` (objectui#12087) reads `GET /meta/app` past + * the cache instead, and the pulse waits for it. On expiry nothing is + * refreshed, and the strip says the app has not appeared rather than that + * the install succeeded. + * + * One status for the whole strip: a later install's status replaces an + * earlier one's, and an earlier wait that settles late never overwrites it. + * Its pulse still goes out, because its app IS served. + */ + const awaitServedOrgInstall = async (manifestId: string, name: string) => { + const settle = (next: OrgDeployState) => + setOrgDeploy((cur) => (cur?.manifestId === manifestId ? next : cur)); + setOrgDeploy({ phase: 'waiting', manifestId, name }); + const wait = await waitForServedApp({ + manifestId, + // The SDK's declared list answer (`GetMetaItemsResponse`: `{ type, + // items }`), read the way the package page reads it. + readApps: async () => { + if (!adapter) throw new Error('No data adapter to read the app list through'); + return (await adapter.getClient().meta.getItems('app')).items; + }, + }); + if (!wait.served) { + settle({ phase: 'timeout', manifestId, name }); + return; + } + emitMetadataRefresh(); + settle({ phase: 'served', manifestId, name, app: filterActiveApps(wait.apps)[0] ?? null }); + }; + // Install an org package into the current environment (ADR-0007 step ②). // // Two deployment shapes, two semantically-correct routes: @@ -207,15 +267,23 @@ export function MarketplacePage() { try { if (getRuntimeConfig().features.installLocal) { await installLocal({ packageId: pkg.manifest_id || pkg.id, versionId: 'latest' }); + setOrgMsg({ ok: true, text: t('marketplace.org.installed', { defaultValue: `Installed ${pkg.display_name}`, name: pkg.display_name }) }); + await load(); + // The runtime hot-registered the package before it answered, so the + // live registry already holds it. Pulse every mounted MetadataProvider + // so the new app appears in the nav (and its objects/views resolve) + // without a manual browser refresh. + emitMetadataRefresh(); } else { await installPackage({ packageId: pkg.id, environmentId: '', seedSampleData: true }); + // objectui#12097 — this answer lands while the runtime still serves the + // PRE-install kernel, so nothing is refreshed here: the wait refreshes + // once the package's app is served. Not awaited, and not cancelled on + // unmount, as on the package page: an operator who leaves this page + // while the app deploys still gets the refreshed cache when it lands. + void awaitServedOrgInstall(pkg.manifest_id, pkg.display_name); + await load(); } - setOrgMsg({ ok: true, text: t('marketplace.org.installed', { defaultValue: `Installed ${pkg.display_name}`, name: pkg.display_name }) }); - await load(); - // The install merged a package into the live registry. Pulse every - // mounted MetadataProvider so the new app appears in the nav (and its - // objects/views resolve) without a manual browser refresh. - emitMetadataRefresh(); } catch (e: any) { setOrgMsg({ ok: false, text: e?.message ?? String(e) }); } finally { @@ -280,6 +348,60 @@ export function MarketplacePage() { if (!isAdmin) return ; + // objectui#12097 — the org install's deploy status, drawn as the package + // page draws it (objectui#12087), under the package's name. + const orgDeployApp = orgDeploy?.phase === 'served' ? orgDeploy.app : null; + const orgDeployNote = orgDeploy && ( +
+ {orgDeploy.phase === 'waiting' + ?
+ ); + return (
@@ -383,6 +505,7 @@ export function MarketplacePage() { {orgItems.length}
+ {orgDeployNote} {orgMsg && (
{orgMsg.text} diff --git a/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.orgInstallWaitServed-12097.test.tsx b/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.orgInstallWaitServed-12097.test.tsx new file mode 100644 index 0000000000..054cc0afa6 --- /dev/null +++ b/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.orgInstallWaitServed-12097.test.tsx @@ -0,0 +1,345 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The catalog page's org install, on a cloud-managed environment, waits until + * the runtime serves the installed package's app before it pulses the metadata + * refresh (objectui#12097, the sibling of objectui#12087). + * + * ## The defect + * + * `doOrgInstall`'s cloud branch sent `emitMetadataRefresh()` the moment + * `installPackage` answered. A cloud install answers once the control plane has + * written the installation; the environment's runtime then rebuilds its kernel + * and, until the rebuild lands, keeps serving the PRE-install kernel (measured + * ~60 s on staging, objectui#12087). The pulse makes every mounted + * `MetadataProvider` re-read its loaded types and persist them, so it read, and + * kept, the pre-install `app` list. objectui#12096 fixed the package page; this + * page did not call `waitForServedApp`. + * + * ## The harness: a virtual clock + * + * The REAL `waitForServedApp`, with its REAL interval and bound + * (`SERVED_APP_POLL_INTERVAL_MS`, `SERVED_APP_WAIT_CAP_MS`): only its `now` and + * `sleep` are swapped for a virtual clock, and each sleep is a gate the test + * opens one interval at a time. The runtime is a fake adapter whose + * `GET /meta/app` answers the pre-install list until `REBUILD_MS` of virtual + * time has passed since the install, then the post-install one. The REAL + * `MetadataProvider` and the REAL assistant bus are mounted, so "no refresh" is + * asserted on the pulses the bus carried and on what the provider actually + * wrote to the `app` session seed, not on a spy of the page's own calls. + * + * ## The cases + * + * - the stale window: no pulse and no pre-install seed write while the stale + * list is served; one pulse, and 「打开 」, once the app is served; + * - expiry: the honest timeout, no pulse, no success, nothing persisted, and + * "Check again" starts another wait; + * - CONTROL: the `installLocal` branch (the runtime hot-registers before it + * answers) refreshes at once, as before, and starts no wait. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup, act, within } from '@testing-library/react'; + +const navigate = vi.fn(); +vi.mock('react-router-dom', () => ({ + useNavigate: () => navigate, + useParams: () => ({}), +})); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), + useAuth: () => ({ activeOrganization: { id: 'org_1' } }), +})); + +// `t` echoes the KEY (and the one interpolated name), as the sibling suites do: +// the claim is WHICH sentence the page picks. +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, opts?: { name?: string }) => (opts?.name ? `«${key}:${opts.name}»` : `«${key}»`), + language: 'en', + }), +})); + +const PKG = 'com.acme.crm'; +/** A "Your organization" row as the control plane's `GET /cloud/org-packages` builds it. */ +const ORG_ROW = { + id: 'pkg_1', + manifest_id: PKG, + display_name: 'Acme CRM', + description: 'A CRM.', + category: null, + icon_url: null, + visibility: 'org', + latest_version: '1.0.0', +}; +const PRE = [{ name: 'setup', label: 'Setup', _packageId: 'com.objectstack.setup' }]; +const POST = [...PRE, { name: 'crm_enterprise', label: 'Acme CRM', _packageId: PKG }]; + +/** The virtual clock the wait reads, and the sleeps it is parked on. */ +const clock = { now: 0, pending: [] as Array<() => void>, sleeps: [] as number[] }; + +/** + * The runtime. A cloud install starts a rebuild that serves the pre-install + * kernel for `rebuildMs` of virtual time; an install-local hot-registers before + * it answers. + */ +const REBUILD_MS = 60_000; +const server = { installedAt: null as number | null, rebuildMs: REBUILD_MS, hotRegistered: false }; +const serving = () => + server.hotRegistered || (server.installedAt !== null && clock.now - server.installedAt >= server.rebuildMs); + +const installs: Array<{ route: 'cloud' | 'local'; input: Record }> = []; +vi.mock('../marketplaceApi', () => ({ + listMarketplacePackages: async () => ({ items: [] }), + listLocalInstalls: async () => + server.hotRegistered + ? [{ packageId: PKG, versionId: 'v1', manifestId: PKG, version: '1.0.0', installedAt: '2026-10-11T00:00:00.000Z' }] + : [], + listOrgPackages: async () => ({ connected: true, items: [ORG_ROW] }), + listInstalledPackages: async () => ({ + connected: true, + items: + server.installedAt === null + ? [] + : [{ packageId: PKG, versionId: 'v1', manifestId: PKG, version: '1.0.0', installedAt: '2026-10-11T00:00:00.000Z' }], + }), + installPackage: async (input: Record) => { + installs.push({ route: 'cloud', input }); + server.installedAt = clock.now; + return { installation: { id: 'inst_1', environment_id: 'env_1', package_id: 'pkg_1', version: '1.0.0' } }; + }, + installLocal: async (input: Record) => { + installs.push({ route: 'local', input }); + server.hotRegistered = true; + return { manifestId: PKG, version: '1.0.0' }; + }, +})); + +/** Every wait the page starts: its manifest id, and whether it kept the real interval and bound. */ +const waits: Array<{ manifestId: string; defaults: boolean }> = []; +vi.mock('../waitForServedApp', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + waitForServedApp: (o: Parameters[0]) => { + waits.push({ manifestId: o.manifestId, defaults: o.intervalMs === undefined && o.capMs === undefined }); + return actual.waitForServedApp({ + ...o, + now: () => clock.now, + sleep: (ms: number) => + new Promise((resolve) => { + clock.sleeps.push(ms); + clock.pending.push(() => { + clock.now += ms; + resolve(); + }); + }), + }); + }, + }; +}); + +import { AdapterCtx, useMetadata } from '@object-ui/react'; +import { ActiveOrganizationStorage } from '@object-ui/auth'; +import { MetadataProvider } from '../../../providers/MetadataProvider'; +import { subscribeMetadataRefresh } from '../../../assistant/assistantBus'; +import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../../runtime-config'; +import { SERVED_APP_POLL_INTERVAL_MS, SERVED_APP_WAIT_CAP_MS } from '../waitForServedApp'; +import { MarketplacePage } from '../MarketplacePage'; + +function makeAdapter() { + return { + clearCache: vi.fn(), + getClient: () => ({ + meta: { + getItems: async (type: string) => ({ type, items: type === 'app' ? (serving() ? POST : PRE) : [] }), + getItem: async () => ({ item: null }), + }, + }), + } as unknown as Parameters[0]['adapter']; +} + +/** Every pulse the bus carried: when, and whether the runtime served the app by then. */ +let pulses: Array<{ at: number; served: boolean }> = []; +let unsubscribe: () => void = () => {}; + +/** Every write of the `app` seed: whether it came after the install, and whether it carried the app. */ +let seedWrites: Array<{ afterInstall: boolean; hasApp: boolean }> = []; +const realSetItem = Storage.prototype.setItem; +const staleSeedWrites = () => seedWrites.filter((w) => w.afterInstall && !w.hasApp); + +function AppsProbe() { + const { apps } = useMetadata(); + return {apps.map((a: { name: string }) => a.name).join(',')}; +} + +async function bootConsole(features: { installLocal: boolean }) { + resetRuntimeConfigForTesting(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + cloudUrl: 'https://cloud.example', + singleEnvironment: true, + defaultEnvironmentId: 'env_1', + features: { installLocal: features.installLocal, marketplace: true, aiStudio: true, autoPublishAiBuilds: true }, + branding: { productName: 'ObjectOS', productShortName: 'ObjectOS' }, + }), + })), + ); + await initRuntimeConfig(); +} + +async function renderPageAndInstall() { + const adapter = makeAdapter(); + render( + + + + + + , + ); + await waitFor(() => expect(screen.getByTestId('apps').textContent).toBe('setup')); + const card = await screen.findByTestId(`org-card-${PKG}`); + fireEvent.click(within(card).getByRole('button', { name: '«marketplace.org.install»' })); + // The install answered and the page re-read its lists: the card is badged. + await within(card).findByText('«marketplace.org.installedBadge»'); +} + +/** Let one poll interval pass on the virtual clock: open the wait's one pending sleep. */ +async function elapseInterval() { + await waitFor(() => expect(clock.pending).toHaveLength(1)); + await act(async () => { + clock.pending.shift()!(); + }); +} + +beforeEach(() => { + clock.now = 0; + clock.pending.length = 0; + clock.sleeps.length = 0; + server.installedAt = null; + server.rebuildMs = REBUILD_MS; + server.hotRegistered = false; + installs.length = 0; + waits.length = 0; + navigate.mockReset(); + pulses = []; + seedWrites = []; + sessionStorage.clear(); + ActiveOrganizationStorage.set('org_1'); + unsubscribe = subscribeMetadataRefresh(() => pulses.push({ at: clock.now, served: serving() })); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (this: Storage, key: string, value: string) { + if (key.startsWith('objectui:metadata:app:')) { + const items = JSON.parse(value) as Array<{ _packageId?: string }>; + seedWrites.push({ + afterInstall: server.installedAt !== null || server.hotRegistered, + hasApp: items.some((a) => a._packageId === PKG), + }); + } + return realSetItem.call(this, key, value); + }); +}); + +afterEach(() => { + unsubscribe(); + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + resetRuntimeConfigForTesting(); + sessionStorage.clear(); +}); + +describe('MarketplacePage org install waits for the installed app to be served (objectui#12097)', () => { + it('sends no refresh while the stale list is served, and one once the app is served', async () => { + await bootConsole({ installLocal: false }); + await renderPageAndInstall(); + + // The install answered inside the rebuild window: nothing is refreshed yet. + expect(installs).toEqual([{ route: 'cloud', input: { packageId: 'pkg_1', environmentId: '', seedSampleData: true } }]); + expect(pulses).toEqual([]); + expect(staleSeedWrites()).toEqual([]); + expect(waits).toEqual([{ manifestId: PKG, defaults: true }]); + const status = screen.getByTestId('org-deploy-status'); + expect(status).toHaveTextContent('Acme CRM'); + expect(status).toHaveTextContent('«marketplace.install.deploying»'); + // No success is claimed before the app is served. + expect(screen.queryByText('«marketplace.org.installed:Acme CRM»')).not.toBeInTheDocument(); + + // The rebuild serves the stale list for REBUILD_MS: one read per interval, + // and after each one the wait sleeps again with nothing refreshed. + const staleIntervals = REBUILD_MS / SERVED_APP_POLL_INTERVAL_MS - 1; + for (let i = 0; i < staleIntervals; i++) { + await elapseInterval(); + await waitFor(() => expect(clock.pending).toHaveLength(1)); + expect(pulses).toEqual([]); + expect(staleSeedWrites()).toEqual([]); + expect(screen.getByTestId('org-deploy-status')).toHaveTextContent('«marketplace.install.deploying»'); + } + + // The interval at which the rebuild lands: the app is served, then refreshed. + await elapseInterval(); + const open = await screen.findByRole('button', { name: '«marketplace.install.openApp:Acme CRM»' }); + expect(screen.getByTestId('org-deploy-status')).toHaveTextContent('«marketplace.install.deployed»'); + expect(pulses).toEqual([{ at: REBUILD_MS, served: true }]); + await waitFor(() => expect(screen.getByTestId('apps').textContent).toBe('setup,crm_enterprise')); + expect(staleSeedWrites()).toEqual([]); + expect(seedWrites.some((w) => w.afterInstall && w.hasApp)).toBe(true); + expect(clock.sleeps).toEqual(Array(REBUILD_MS / SERVED_APP_POLL_INTERVAL_MS).fill(SERVED_APP_POLL_INTERVAL_MS)); + + fireEvent.click(open); + expect(navigate).toHaveBeenCalledWith(`/apps/${PKG}`); + }); + + it('on expiry says the app has not appeared, refreshes nothing, and "Check again" waits again', async () => { + server.rebuildMs = Number.POSITIVE_INFINITY; + await bootConsole({ installLocal: false }); + await renderPageAndInstall(); + + // Every interval of the bound passes with the stale list served. + for (let i = 0; i < SERVED_APP_WAIT_CAP_MS / SERVED_APP_POLL_INTERVAL_MS; i++) await elapseInterval(); + const status = await screen.findByTestId('org-deploy-status'); + await waitFor(() => expect(status).toHaveTextContent('«marketplace.install.deployTimeout»')); + expect(status).toHaveTextContent('Acme CRM'); + expect(clock.now).toBe(SERVED_APP_WAIT_CAP_MS); + expect(clock.pending).toEqual([]); + expect(pulses).toEqual([]); + expect(seedWrites.filter((w) => w.afterInstall)).toEqual([]); + expect(screen.getByTestId('apps').textContent).toBe('setup'); + expect(screen.queryByText('«marketplace.install.deployed»')).not.toBeInTheDocument(); + expect(screen.queryByText('«marketplace.org.installed:Acme CRM»')).not.toBeInTheDocument(); + + // The rebuild lands; asking again finds the app on its first read. + server.rebuildMs = 0; + fireEvent.click(within(status).getByRole('button', { name: '«marketplace.install.checkAgain»' })); + expect(await screen.findByRole('button', { name: '«marketplace.install.openApp:Acme CRM»' })).toBeInTheDocument(); + expect(waits).toEqual([ + { manifestId: PKG, defaults: true }, + { manifestId: PKG, defaults: true }, + ]); + expect(pulses).toEqual([{ at: SERVED_APP_WAIT_CAP_MS, served: true }]); + await waitFor(() => expect(screen.getByTestId('apps').textContent).toBe('setup,crm_enterprise')); + expect(staleSeedWrites()).toEqual([]); + }); + + it('CONTROL: the installLocal branch refreshes at once, as before, and starts no wait', async () => { + await bootConsole({ installLocal: true }); + await renderPageAndInstall(); + + expect(installs).toEqual([{ route: 'local', input: { packageId: PKG, versionId: 'latest' } }]); + await waitFor(() => expect(pulses).toEqual([{ at: 0, served: true }])); + expect(screen.getByText('«marketplace.org.installed:Acme CRM»')).toBeInTheDocument(); + expect(waits).toEqual([]); + expect(clock.sleeps).toEqual([]); + expect(screen.queryByTestId('org-deploy-status')).not.toBeInTheDocument(); + await waitFor(() => expect(screen.getByTestId('apps').textContent).toBe('setup,crm_enterprise')); + expect(staleSeedWrites()).toEqual([]); + }); +});