Skip to content

Commit 5e879ea

Browse files
feat(app-shell): each read-only base card on the Studio landing says how many items are customized and switched off (#28634)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb98azEtvfFx249GAG12Q2Q9)_ Fixes #28566 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么**:Studio 首页「已安装(只读)」区的每张基础包卡片多一行「已定制 n 项,已停用 m 项」(英文 "n customized, m switched off")。数字来自服务器的 `GET /api/v1/packages/:id/customizations`,每张卡读一次;n = 服务器给的覆盖数 + 扩展数,m = 服务器给的停用数。有定制包(`BASE.custom`)时「已定制」链到它的 Studio,「已停用」链到基础包的自动化页。 - **为什么改**:管理员升级前、或平时想一眼看出改过哪些已安装应用、停用了什么(ADR-0146 D3)。之前首页卡片上没有任何计数,全局视图只有 Setup 的 Packaged automation 页,且只覆盖流程和动作。 - **风险与回滚**:低。只读展示一次已有接口;读取中不显示数字,读取失败显示灰色「无法读取定制」(悬停看服务器原话),绝不显示 0;可写包和定制包本身不画这一行、也不发请求。回滚即还原本 PR。注意:#28622 记录该接口在 main 上尚未挂载,挂载前这一行在真实环境里会显示「无法读取定制」。 - **先看**:`ui/packages/app-shell/src/views/studio-design/packageCustomizations-io.ts`(读取与按 spec 校验)、`BuilderLanding.tsx` 的 `renderCustomizationLine`、`BuilderLanding.customizationCounts-28566.test.tsx`。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/BuilderLanding.customizationCounts-28566.test.tsx`,预期 1 个文件、6 个测试全绿。 - **需要你做的**:无,席位落地。 ## What changes - **A small reader beside `packages-io`.** `packageCustomizations-io.ts` exports `fetchPackageCustomizationCounts(baseId)`. It sends `GET /api/v1/packages/:id/customizations` and judges the dispatcher envelope's `data` by `GetPackageCustomizationsResponseSchema` from `@objectstack/spec/api`. It returns `customized` = served `counts.overrides` + served `counts.extensions`, and `switchedOff` = served `counts.switchedOff`. It counts nothing itself. A failed read rejects with the server's sentence through `readEnvelopeFailureText`, the rule every package reader in that folder uses, else `HTTP STATUS`. An answer the schema refuses rejects too, so no number is ever read out of an off-contract body. A pair id (`isCustomizationPackage`) is refused before any request, because the route refuses it by shape. - **The landing line.** `BuilderLanding` reads once per read-only base card drawn, after the package list answers. The effect is keyed on the joined target ids, a primitive (ui/AGENTS.md #10). The page does not wait for these reads. The line has three states: - pending: "Reading customizations…" with no number; - failed: a muted "Couldn’t read customizations", with the server's sentence as its `title`; - read: "n customized, m switched off". When the package list holds `BASE.custom` (from `customizationPackageId`), "n customized" links to `/studio/BASE.custom/data`. Otherwise it is plain text. "m switched off" links to `/studio/BASE/automations`, the pillar where the switch lives. - **No line on a writable package or on a pair.** A pair served read-only is still a pair, so it gets no line either. - **Strings.** Seven `engine.studio.landing.customizations.*` keys go into `@object-ui/i18n`'s studio namespace, in `en` and `zh`. The zh line reads 「已定制 n 项,已停用 m 项」, as the card asks. The other languages fall back to `en`, as the whole namespace does by design. Placeholders are `customized`, `switchedOff` and `pair`, single-brace `tFormat` tokens. There is no `count` token, so the i18next count-family gate does not apply. ## Tests `BuilderLanding.customizationCounts-28566.test.tsx` runs the real landing and the real reader under a real router. Only the package list and `fetch` are stubbed, with answers shaped by the route's schema inside the dispatcher envelope. The six cases are the card's five plus one: - 3 overrides, 1 extension and 2 switched off read "4 customized, 2 switched off". The pair link goes to `/studio/hotcrm.custom/data` and the switched-off link to `/studio/hotcrm/automations`. One request is sent, for the base only. - A base with no pair reads "0 customized, 0 switched off" with no pair link. - A failed read (503 with an error envelope) shows the failed state, no digit, and the server's sentence in the title. - An answer the schema refuses (no `counts`) shows the failed state with no digit. This is the extra case. - A writable package, a writable pair and a read-only pair draw no line and send no request. - The landing paints while both reads stay pending, and both lines show no number. Four existing landing tests (`accessibleNames-12510`, `orgScope-11553`, `recentPackages-11863`, `setupExit-12513`) draw a read-only card. The new read reached a real socket from them, and the ui network-escape guard refused it. They now mock the reader with a pending promise, the way they already mock `packages-io`. No assertion changed. ## Acceptance notes - **#28622: the route is not mounted on `main` yet.** The seat reports that `GET /api/v1/packages/:id/customizations` is in the route ledger but answers nothing at runtime. This PR builds against the declared schema with stubbed answers, as the card's Tests ask. Until #28622 lands, a live landing shows the failed state ("Couldn’t read customizations"), never a number. The seat holds landing until then. No server code is touched here. - **Transport.** The reader uses `fetch` the way its neighbours in `packages-io.ts` do, so the landing uses one transport with one failure-text rule. `@objectstack/client`'s `packages.customizations(id)` exists. Moving every package reader in this folder onto the client would be a change of its own. - **One read per card.** The route has no batched form, so the landing sends one read per read-only base drawn. There is no cap beyond the cards on the page. ## Verification <details><summary>New test file · 6/6</summary> `pnpm --dir ui exec vitest run --maxWorkers=2 packages/app-shell/src/views/studio-design/BuilderLanding.customizationCounts-28566.test.tsx` under `os-verify-lock`: Test Files 1 passed (1), Tests 6 passed (6). </details> <details><summary>Ablations · two red, one green explained</summary> All three ran through `scripts/ablation-replace.mjs` against the committed fix. The tree was restored after each run, and `git diff HEAD` was empty. 1. `customized: counts.overrides` (the extensions term dropped): 1 failed, the "4 customized, 2 switched off" case. 2. The landing's pair filter dropped alone: 6/6 green. The reader's own pair guard still refuses the id before a request, and the line renderer draws nothing for a pair. 3. The reader's pair guard and the landing's pair filter dropped together: 1 failed, the "no line and no request for a writable package or a pair" case. </details> <details><summary>Every test naming the landing or the reader · 22 files</summary> `git grep -l "BuilderLanding\|packageCustomizations-io"` over ui test files (22 files): Test Files 22 passed (22), Tests 120 passed (120). Before the four stubs: 4 failed, all with the network-escape refusal. The tests that reach the landing through routes (`registerStudioComponents.builderLazy-11798`, `StudioRoute.landingI18n`, `AppContent.studioRootLanding-12113`, `UnifiedSidebar.studioNoHome-12113`): 4/4 files, 16/16 tests. This is a declared narrowing. CI's Test UI runs the rest of app-shell. </details> <details><summary>Type check and i18n</summary> - `pnpm --filter @object-ui/app-shell run type-check`: exit 0, after `turbo run build --filter='@object-ui/app-shell^...'` (64 tasks). - `pnpm --dir ui exec vitest run packages/i18n/`: Test Files 94 passed (94), Tests 1438 passed, 13 skipped. This includes the studio namespace parity test. </details> <details><summary>Derived gates · 34 of 34 exit 0 at 7b29406</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 34 commands against merge base `0e78922ee`. All 34 ran and exited 0. That count includes `check-sdui-manifest.mjs` and its `--self-test`, `check:type-check-debt` under the lock, `check:nul-bytes`, `check:cross-package-test-inputs`, `check:test-source-alias` and `pnpm --dir ui exec vitest run scripts/` (Test Files 169 passed, 2 skipped; Tests 4900 passed). `dispatch-gates --ran` reconciled 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. `pnpm --dir ui run check:new-line-citations` found 0 new citations. The head then took one merge of `main` (`d77dc07a`, two `ui/e2e/live` files, no overlap with this diff). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jRL4qcnwHDD4M1e4wx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6b9c1ca commit 5e879ea

10 files changed

Lines changed: 450 additions & 3 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(app-shell): each read-only base's card on the Studio landing says "n customized, m switched off"
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
Before an upgrade, or just to check, an administrator wants to see which installed apps they changed and what they switched off. Studio's landing now says it on each read-only base's card, for example "4 customized, 2 switched off" (zh 「已定制 4 项,已停用 2 项」).
12+
13+
- **The numbers are the server's.** Each card reads `GET /api/v1/packages/:id/customizations` once. "Customized" is the served overrides plus the served extensions of the base's `<base>.custom` pair, and "switched off" is the served switched-off count. The answer is judged by `GetPackageCustomizationsResponseSchema`. An answer that schema refuses counts as a failed read.
14+
- **Zero only when the server answered.** While the read is pending, the line shows no number. A failed read shows a muted "Couldn't read customizations", with the server's sentence as its title, never "0".
15+
- **Links.** When the package list holds the base's pair, "n customized" opens the pair in Studio (`/studio/<base>.custom/data`). "m switched off" opens the base's Automations pillar.
16+
- **No line on a writable package or on a pair**, and a pair id is never sent: the route refuses one.
17+
- **The landing paints first.** The counts arrive after it.
18+
- **New strings.** `@object-ui/i18n`'s studio namespace gains seven `engine.studio.landing.customizations.*` keys in `en` and `zh`. The other languages fall back to `en`, as the whole namespace does. They interpolate `customized`, `switchedOff` and `pair`.

‎ui/packages/app-shell/src/views/studio-design/BuilderLanding.accessibleNames-12510.test.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ vi.mock('./packages-io', async (importOriginal) => {
2727

2828
// The create dialog is closed throughout; its own names are pinned beside it.
2929
vi.mock('../metadata-admin/PackageFormDialog', () => ({ PackageFormDialog: () => null }));
30+
// A read-only base's customization read (ADR-0146 D3) is not under test here:
31+
// it stays pending, so no request leaves the file.
32+
vi.mock('./packageCustomizations-io', () => ({
33+
fetchPackageCustomizationCounts: vi.fn(() => new Promise<never>(() => {})),
34+
}));
3035

3136
import { BuilderLanding } from './BuilderLanding';
3237
import { RecentItemsProvider } from '../../context/RecentItemsProvider';
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0146 D3 — each read-only base's Studio landing card says what this
5+
* deployment changed on it, "n customized, m switched off", from the server's
6+
* per-package customization read (`GET /api/v1/packages/:id/customizations`).
7+
*
8+
* The real `BuilderLanding` and the real reader (`packageCustomizations-io`)
9+
* run under a real router; only the package list and the wire are stubbed.
10+
* The stubbed answers are shaped by the route's declaration,
11+
* `GetPackageCustomizationsResponseSchema`, inside the dispatcher envelope.
12+
*/
13+
14+
import '@testing-library/jest-dom/vitest';
15+
import * as React from 'react';
16+
import { afterEach, describe, expect, it, vi } from 'vitest';
17+
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
18+
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
19+
import { t, tFormat } from '../metadata-admin/i18n';
20+
import { resetRuntimeConfigForTesting } from '../../runtime-config';
21+
22+
const packages = vi.hoisted(() => ({ list: [] as Array<Record<string, unknown>> }));
23+
24+
vi.mock('./packages-io', async (importOriginal) => {
25+
const mod = await importOriginal<typeof import('./packages-io')>();
26+
return { ...mod, fetchPackages: vi.fn(async () => packages.list) };
27+
});
28+
29+
// The create dialog is closed throughout; its form stack is not under test.
30+
vi.mock('../metadata-admin/PackageFormDialog', () => ({ PackageFormDialog: () => null }));
31+
32+
import { BuilderLanding } from './BuilderLanding';
33+
34+
const HOTCRM = { id: 'hotcrm', name: 'HotCRM', writable: false, namespace: 'hotcrm' };
35+
const HOTCRM_PAIR = { id: 'hotcrm.custom', name: 'HotCRM customizations', writable: true, namespace: 'hotcrm_custom' };
36+
const SHOWCASE = { id: 'com.example.showcase', name: 'Showcase', writable: false, namespace: 'showcase' };
37+
const WRITABLE = { id: 'com.acme.app', name: 'Acme', writable: true, namespace: 'acme' };
38+
/** A pair served read-only (code-shipped): still a pair, so still no line. */
39+
const READONLY_PAIR = { id: 'com.example.showcase.custom', name: 'Showcase pair', writable: false, namespace: 'showcase_custom' };
40+
41+
const CUSTOMIZATIONS_RE = /^\/api\/v1\/packages\/([^/]+)\/customizations$/;
42+
43+
/** A served answer for `id`: `overrides` overrides, `extensions` field members, `switchedOff` flows. */
44+
function served(id: string, overrides: number, extensions: number, switchedOff: number) {
45+
const data = {
46+
packageId: id,
47+
overrides: Array.from({ length: overrides }, (_, i) => ({ type: 'view', name: `view_${i}` })),
48+
extensions: Array.from({ length: extensions }, (_, i) => ({ object: 'account', kind: 'field' as const, name: `field_${i}` })),
49+
switchedOff: Array.from({ length: switchedOff }, (_, i) => ({ type: 'flow', name: `flow_${i}` })),
50+
counts: { overrides, extensions, switchedOff },
51+
};
52+
return { status: 200, body: { success: true, data } };
53+
}
54+
55+
type Answer = { status: number; body: unknown } | 'never';
56+
57+
/**
58+
* Stub the wire: each customization request is answered from `answers` by
59+
* base id; every request URL is recorded.
60+
*/
61+
function stubWire(answers: Record<string, Answer>) {
62+
const requested: string[] = [];
63+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
64+
const url = String(input);
65+
requested.push(url);
66+
const m = CUSTOMIZATIONS_RE.exec(url);
67+
const answer = m ? answers[decodeURIComponent(m[1])] : undefined;
68+
if (answer === 'never') return new Promise<Response>(() => {});
69+
if (!answer) throw new Error(`unexpected request ${url}`);
70+
return {
71+
ok: answer.status >= 200 && answer.status < 300,
72+
status: answer.status,
73+
json: async () => answer.body,
74+
text: async () => JSON.stringify(answer.body),
75+
} as Response;
76+
});
77+
vi.stubGlobal('fetch', fetchMock);
78+
return { requested };
79+
}
80+
81+
const customizationRequests = (requested: string[]) => requested.filter((u) => CUSTOMIZATIONS_RE.test(u));
82+
83+
function LocationProbe() {
84+
const { pathname } = useLocation();
85+
return <div data-testid="location">{pathname}</div>;
86+
}
87+
88+
function renderLanding() {
89+
return render(
90+
<MemoryRouter initialEntries={['/studio']}>
91+
<LocationProbe />
92+
<Routes>
93+
<Route path="/studio" element={<BuilderLanding />} />
94+
<Route path="/studio/:packageId/:tab" element={<div data-testid="pillar-builder" />} />
95+
</Routes>
96+
</MemoryRouter>,
97+
);
98+
}
99+
100+
/** The card a package renders in: the element holding both its name and its links. */
101+
function cardOf(name: string): HTMLElement {
102+
const card = screen.getByText(name).closest('.rounded-lg.border');
103+
if (!(card instanceof HTMLElement)) throw new Error(`no card found for ${name}`);
104+
return card;
105+
}
106+
107+
const customizedText = (n: number) => tFormat('engine.studio.landing.customizations.customized', 'en-US', { customized: n });
108+
const switchedOffText = (n: number) => tFormat('engine.studio.landing.customizations.switchedOff', 'en-US', { switchedOff: n });
109+
110+
afterEach(() => {
111+
cleanup();
112+
packages.list = [];
113+
vi.unstubAllGlobals();
114+
resetRuntimeConfigForTesting();
115+
});
116+
117+
describe('Studio landing: a read-only base says "n customized, m switched off" (ADR-0146 D3)', () => {
118+
it('reads 3 overrides + 1 extension and 2 switched off as "4 customized, 2 switched off", linking the pair and the Automations pillar', async () => {
119+
packages.list = [HOTCRM_PAIR, HOTCRM];
120+
const { requested } = stubWire({ hotcrm: served('hotcrm', 3, 1, 2) });
121+
renderLanding();
122+
123+
const line = await within(await waitFor(() => cardOf('HotCRM'))).findByTestId('studio-landing-customizations');
124+
await waitFor(() => expect(line).toHaveAttribute('data-state', 'read'));
125+
expect(line).toHaveTextContent('4 customized, 2 switched off');
126+
127+
const pairLink = within(line).getByTestId('studio-landing-customizations-pair');
128+
expect(pairLink).toHaveTextContent(customizedText(4));
129+
expect(pairLink).toHaveAttribute('href', '/studio/hotcrm.custom/data');
130+
const offLink = within(line).getByTestId('studio-landing-customizations-switched-off');
131+
expect(offLink).toHaveTextContent(switchedOffText(2));
132+
expect(offLink).toHaveAttribute('href', '/studio/hotcrm/automations');
133+
134+
fireEvent.click(pairLink);
135+
await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/studio/hotcrm.custom/data'));
136+
// One read, for the base; never for its pair.
137+
expect(customizationRequests(requested)).toEqual(['/api/v1/packages/hotcrm/customizations']);
138+
});
139+
140+
it('reads a base with no pair as "0 customized, 0 switched off", with no pair link', async () => {
141+
packages.list = [SHOWCASE];
142+
stubWire({ 'com.example.showcase': served('com.example.showcase', 0, 0, 0) });
143+
renderLanding();
144+
145+
const line = await within(await waitFor(() => cardOf('Showcase'))).findByTestId('studio-landing-customizations');
146+
await waitFor(() => expect(line).toHaveAttribute('data-state', 'read'));
147+
expect(line).toHaveTextContent('0 customized, 0 switched off');
148+
expect(within(line).queryByTestId('studio-landing-customizations-pair')).not.toBeInTheDocument();
149+
expect(within(line).getByText(customizedText(0))).not.toHaveAttribute('href');
150+
expect(within(line).getByTestId('studio-landing-customizations-switched-off')).toHaveAttribute(
151+
'href',
152+
'/studio/com.example.showcase/automations',
153+
);
154+
});
155+
156+
it('shows no number on a failed read: a muted "couldn’t read" with the server’s sentence as its title', async () => {
157+
packages.list = [SHOWCASE];
158+
const sentence = 'Metadata service not available';
159+
stubWire({ 'com.example.showcase': { status: 503, body: { success: false, error: { message: sentence } } } });
160+
renderLanding();
161+
162+
const line = await within(await waitFor(() => cardOf('Showcase'))).findByTestId('studio-landing-customizations');
163+
await waitFor(() => expect(line).toHaveAttribute('data-state', 'failed'));
164+
expect(line).toHaveTextContent(t('engine.studio.landing.customizations.failed', 'en-US'));
165+
expect(line).toHaveAttribute('title', expect.stringContaining(sentence));
166+
expect(line.textContent).not.toMatch(/\d/);
167+
});
168+
169+
it('shows no number when the answer is one the route’s declaration refuses', async () => {
170+
packages.list = [SHOWCASE];
171+
// `counts` missing: off-contract, so no count is read out of it.
172+
stubWire({
173+
'com.example.showcase': { status: 200, body: { success: true, data: { packageId: 'com.example.showcase', overrides: [] } } },
174+
});
175+
renderLanding();
176+
177+
const line = await within(await waitFor(() => cardOf('Showcase'))).findByTestId('studio-landing-customizations');
178+
await waitFor(() => expect(line).toHaveAttribute('data-state', 'failed'));
179+
expect(line.textContent).not.toMatch(/\d/);
180+
});
181+
182+
it('draws no line and sends no request for a writable package or a pair', async () => {
183+
packages.list = [WRITABLE, HOTCRM_PAIR, READONLY_PAIR, SHOWCASE];
184+
const { requested } = stubWire({ 'com.example.showcase': served('com.example.showcase', 1, 0, 0) });
185+
renderLanding();
186+
187+
await screen.findByText('Showcase pair');
188+
await waitFor(() =>
189+
expect(within(cardOf('Showcase')).getByTestId('studio-landing-customizations')).toHaveAttribute('data-state', 'read'),
190+
);
191+
expect(within(cardOf('Acme')).queryByTestId('studio-landing-customizations')).not.toBeInTheDocument();
192+
expect(within(cardOf('HotCRM customizations')).queryByTestId('studio-landing-customizations')).not.toBeInTheDocument();
193+
expect(within(cardOf('Showcase pair')).queryByTestId('studio-landing-customizations')).not.toBeInTheDocument();
194+
expect(customizationRequests(requested)).toEqual(['/api/v1/packages/com.example.showcase/customizations']);
195+
});
196+
197+
it('paints the landing before the counts arrive, with no number on the line', async () => {
198+
packages.list = [HOTCRM, SHOWCASE];
199+
const { requested } = stubWire({ hotcrm: 'never', 'com.example.showcase': 'never' });
200+
renderLanding();
201+
202+
await screen.findByText('HotCRM');
203+
expect(screen.getByText('Showcase')).toBeInTheDocument();
204+
await waitFor(() => expect(customizationRequests(requested)).toHaveLength(2));
205+
for (const name of ['HotCRM', 'Showcase']) {
206+
const line = within(cardOf(name)).getByTestId('studio-landing-customizations');
207+
expect(line).toHaveAttribute('data-state', 'loading');
208+
expect(line.textContent).not.toMatch(/\d/);
209+
}
210+
});
211+
});

‎ui/packages/app-shell/src/views/studio-design/BuilderLanding.orgScope-11553.test.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ vi.mock('./packages-io', async (importOriginal) => {
2525

2626
// The create dialog is closed throughout; its form stack is not under test.
2727
vi.mock('../metadata-admin/PackageFormDialog', () => ({ PackageFormDialog: () => null }));
28+
// A read-only base's customization read (ADR-0146 D3) is not under test here:
29+
// it stays pending, so no request leaves the file.
30+
vi.mock('./packageCustomizations-io', () => ({
31+
fetchPackageCustomizationCounts: vi.fn(() => new Promise<never>(() => {})),
32+
}));
2833

2934
import { BuilderLanding } from './BuilderLanding';
3035

‎ui/packages/app-shell/src/views/studio-design/BuilderLanding.recentPackages-11863.test.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ vi.mock('./packages-io', async (importOriginal) => {
4646

4747
// The create dialog is closed throughout; its form stack is not under test.
4848
vi.mock('../metadata-admin/PackageFormDialog', () => ({ PackageFormDialog: () => null }));
49+
// A read-only base's customization read (ADR-0146 D3) is not under test here:
50+
// it stays pending, so no request leaves the file.
51+
vi.mock('./packageCustomizations-io', () => ({
52+
fetchPackageCustomizationCounts: vi.fn(() => new Promise<never>(() => {})),
53+
}));
4954

5055
import { BuilderLanding } from './BuilderLanding';
5156
import { RecentItemsProvider } from '../../context/RecentItemsProvider';

‎ui/packages/app-shell/src/views/studio-design/BuilderLanding.setupExit-12513.test.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ vi.mock('./packages-io', async (importOriginal) => {
2121

2222
// The create dialog is closed throughout; its form stack is not under test.
2323
vi.mock('../metadata-admin/PackageFormDialog', () => ({ PackageFormDialog: () => null }));
24+
// A read-only base's customization read (ADR-0146 D3) is not under test here:
25+
// it stays pending, so no request leaves the file.
26+
vi.mock('./packageCustomizations-io', () => ({
27+
fetchPackageCustomizationCounts: vi.fn(() => new Promise<never>(() => {})),
28+
}));
2429

2530
import { BuilderLanding, packagedAutomationPath } from './BuilderLanding';
2631

0 commit comments

Comments
 (0)