Skip to content

Commit 78d4fe9

Browse files
feat(console): list the AI assistants you authorized and your API keys under Connect an Agent (#31054)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoJEZTjzqytK2VreAykNQuLz)_ Part of #30894 (part 1 of 2: the list. Part 2 adds the assistants' Revoke once the server's withdraw door, #31048, lands.) Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:「连接 AI 助手」页面在设置步骤下方新增「可以代表你操作的助手和密钥」卡片,列出本人已授权的 AI 助手(名称、授予的权限、授权时间、最近使用时间)和本人的 API 密钥(名称、前缀、创建时间、最近使用、已撤销/已过期),API 密钥可确认后撤销。 - 为什么改:OAuth 授权页承诺「可以随时在账户设置中撤销访问权限」,但此前没有任何页面能看到自己授权过哪些助手;该页面已在账户的 AI 助手分组下,这张列表就是那句承诺背后的页面。 - 风险与回滚:只读两条已上线的服务端接口,写操作只有 API 密钥撤销(沿用数据接口原有的 revoked 写法,先确认);助手行没有撤销按钮,等 #31048。回滚即还原本 PR,无数据迁移。 - 先看:ui/packages/app-shell/src/console/connect/AuthorizedAccessList.tsx(按本人 user_id 过滤密钥,平台管理员不过滤会看到所有人的密钥)、ConnectAgentWidget.tsx(只挂载列表并在创建密钥后通知刷新)、ui/packages/i18n/src/locales/en.ts(新增 connectAgent.access.*)。 - 两分钟验证:pnpm dev 后以任意用户登录,打开 账户 → 连接 AI 助手,页面底部应出现该卡片;创建一个 API 密钥后它立即出现在列表里,点「撤销」确认后显示「已撤销」。 - 需要你做的:无,席位落地。 ## What changes - **New `AuthorizedAccessList`** (`ui/packages/app-shell/src/console/connect/`), mounted under the setup steps of Connect an Agent, which already sits in Account's AI Assistant group (no nav change). - **Assistants** from `oauth.authorizedClients.list()` through the adapter's own client: the client's name (its client id beside an "Unnamed assistant" fallback when it declares none), each granted scope in the consent page's own words (the existing `oauth.consent.scope.*` keys; an unknown scope shows as unrecognised, never dropped), when authorized, and the last use as "in the hour from …" because the server records the hour start, or "Last use not recorded". The organization is named only when it is not the active one. **No Revoke on an assistant row**; one line says withdrawing from this page is not available yet. A `truncated` answer says only the newest are shown. - **API keys**: the adapter's `find('sys_api_key')` filtered to `user_id` = the signed-in person and selecting only `id, name, prefix, created_at, last_used_at, expires_at, revoked` (never the secret). Revoked and expired keys show their state and no Revoke. Revoke on an active key asks first, naming the key, then writes `revoked: true` through the adapter; the adapter's mutation event re-reads the list in place (no remount). - **`ConnectAgentWidget.tsx`**: mounts the list, and after a successful mint calls `notifyDataChanged({ objectName: 'sys_api_key' })` (the mint posts to `/api/v1/keys` outside the adapter) so the new key shows without a reload. No existing line or string is edited. - **Language packs**: 23 new `connectAgent.access.*` keys in all eleven packs; `zh-Hant` regenerated from `zh-Hans` with `gen:zh-hant`. - Changeset `.changeset/30894-authorized-access-list.md` (`@object-ui/app-shell`, `@object-ui/i18n`: patch). ## Tests `AuthorizedAccessList.test.tsx` (12 cases): assistants render name, consent-worded scopes, authorized and hour-coarse last use or not recorded; organization only when not the active one; nameless client shows its id; no button on any assistant row; truncated note. Keys: the read filters on the signed-in user and never selects the secret; revoked/expired state with no Revoke; Revoke asks first naming the key, Cancel writes nothing, confirm writes `revoked: true` and re-reads in place without re-reading assistants. Empty, failed (the error is named, not an empty list) and signed-out/no-adapter (renders and reads nothing) states. The widget's existing suite still passes with the list mounted. ## Verification <details><summary>Live reading on origin/main 115a43a (showcase, fresh, member and platform admin)</summary> - `GET /api/v1/auth/authorized-clients`: admin and member with no grants answer 200 `{clients: [], truncated: false}`. Member after granting Claude Desktop (one MCP call) and Cursor (never used): 200, newest first, Cursor `lastUsedAt: null`, Claude Desktop `lastUsedAt` = the hour start. With `?userId=x`: 400 VALIDATION_ERROR. Admin's own read lists only the admin's grants. - `GET /api/v1/auth/admin/authorized-clients?userId=`: member 403; platform admin over the member 200 with the member's rows; unknown id 404. - `sys_api_key` via the data API: member reads own rows only; the platform admin's unfiltered read answers every person's keys, `?user_id=` self answers its own (hence the filter). Using a key stamps `last_used_at`; `PATCH {revoked: true}` answers 200 and the key is then refused (403). - No server field the list needs is missing. </details> <details><summary>Console, live (Vite dev against that backend), head before the lint fix</summary> As the member: the card lists both assistants with their scopes and "Last used in the hour from Nov 6, 2026, 9:00 PM" / "Last use not recorded", and the member's keys. As the platform admin: only the admin's own keys; Revoke opened the confirm "Revoke the API key "admin ci key"?", confirming flipped that row to Revoked in place. </details> <details><summary>Local gates and tests, head e903d94</summary> - `pnpm --dir ui exec vitest run packages/app-shell/src/console/connect/`: 2 files, 24 tests passed. - `pnpm --filter @object-ui/app-shell type-check`: exit 0 (both `tsc` programs); `@object-ui/i18n` type-check exit 0 (on d06a9fc, packs unchanged since). - eslint on the two new files: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands` derived 38 commands; all exit 0 (`--ran` reconciliation: 38 derived, 38 run, 0 NOT-MEASURED, 0 UNRUN), including `node scripts/check-sdui-manifest.mjs` and `pnpm --dir ui exec vitest run scripts/` (170 files passed, 2 skipped). Ratchet families re-run on e903d94: `check:type-check-coverage`, `check:type-check-debt`, `check:nul-bytes`, `check:test-source-alias` exit 0. - `pnpm check:new-line-citations` (ui): 0 new citations. - NOT MEASURED locally: the `i18n-locale-en` eager-closure ceiling (needs the Console production build; CI's `Build & E2E UI` step `check:eager-closure` is the verdict). The ceiling is not changed. </details> ## Acceptance notes - **Assistant Revoke is part 2.** The withdraw door (`POST /oauth2/delete-consent`) has no ledgered route or client method yet (#31048), so no button is offered rather than one that leaves access in place; the card says withdrawing here is not available yet. Part 2 removes that line and adds the button, and closes the card. - **Administrator view over a managed person** (`listForUser`): Connect an Agent has no place where an administrator names a person, so it is left to part 2 as the claim allows. - **Shared file with #30898**: `ConnectAgentWidget.tsx` gets the mount, one import pair and the post-mint `notifyDataChanged`; no existing key in any pack is edited. Whichever lands second merges `main`. - A client registered twice shows as two rows (one consent each); that is the server's grant list as answered. - A revoked API key is refused with 403, not 401 (measured). Noted, not filed. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No spec change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; eslint on the new files passes (the repo-wide `pnpm lint` is CI's). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L9qibz28PqZhuQmVQEvmY2 --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibz28PqZhuQmVQEvmY2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 13227b5 commit 78d4fe9

17 files changed

Lines changed: 991 additions & 3 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(console): Connect an Agent lists the AI assistants you authorized and your API keys
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
- **`@object-ui/app-shell`**: under the setup steps on Connect an Agent (which sits in Account's AI Assistant group), a "What can act as you" card lists the assistants the signed-in person authorized on the OAuth consent page, read from `oauth.authorizedClients.list()`: the client's name (its client id beside a fallback when it declares none), each granted scope in the consent page's own words, when it was authorized, when it was last used (the server records the hour, so it reads "in the hour from …") or that no use is recorded, and the organization only when it is not the active one. Assistant rows carry no Revoke yet; the card says so. Below them, the person's own API keys (`sys_api_key` rows filtered to the signed-in user, so a platform administrator sees their own keys here too): name, prefix, created, last used or never used, and the revoked or expired state. An active key's Revoke asks first, naming the key, then writes `revoked: true`, which ends the key's access at once; the list re-reads in place, and a key minted on the same page appears without a reload.
12+
- **`@object-ui/i18n`**: the new `connectAgent.access.*` strings in all eleven packs. Scope wording reuses the existing `oauth.consent.scope.*` keys.
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The list under the Connect an Agent setup steps: the AI assistants the
5+
* signed-in person authorized, and their API keys.
6+
*
7+
* - §1 assistants come from `oauth.authorizedClients.list()`: name (or the
8+
* client id when the client declares none), the granted scopes in the
9+
* consent page's own words, when authorized, and "in the hour from" the
10+
* recorded last use or "not recorded"; the organization only when it is
11+
* not the active one; and no Revoke control on an assistant row;
12+
* - §2 keys are the person's OWN `sys_api_key` rows: the read filters on the
13+
* signed-in user (a platform administrator's unfiltered read answers every
14+
* person's keys) and never selects the secret; revoked and expired keys
15+
* carry their state and no Revoke;
16+
* - §3 Revoke on an active key asks first, naming the key; Cancel writes
17+
* nothing; confirming writes `revoked: true` and the list re-reads in place;
18+
* - §4 empty, failed and signed-out states.
19+
*
20+
* `t` echoes the key with any interpolation values appended, so assertions
21+
* read which string was asked for and with what.
22+
*/
23+
24+
import '@testing-library/jest-dom/vitest';
25+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
26+
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
27+
28+
vi.mock('@object-ui/i18n', async (importOriginal) => ({
29+
...(await importOriginal<Record<string, unknown>>()),
30+
useObjectTranslation: () => ({
31+
t: (key: string, opts?: Record<string, unknown>) => {
32+
const vals = opts ? Object.values(opts).filter((v) => typeof v === 'string' || typeof v === 'number') : [];
33+
return vals.length ? `«${key}|${vals.join('|')}»` : `«${key}»`;
34+
},
35+
language: 'en',
36+
}),
37+
}));
38+
39+
let adapter: unknown = null;
40+
vi.mock('@object-ui/react', async (importOriginal) => ({
41+
...(await importOriginal<Record<string, unknown>>()),
42+
useAdapter: () => adapter,
43+
}));
44+
45+
let authUser: { id: string } | null = { id: 'user_me' };
46+
vi.mock('@object-ui/auth', async (importOriginal) => ({
47+
...(await importOriginal<Record<string, unknown>>()),
48+
useAuth: () => ({
49+
user: authUser,
50+
activeOrganization: { id: 'org_here', name: 'Here Inc' },
51+
organizations: [
52+
{ id: 'org_here', name: 'Here Inc' },
53+
{ id: 'org_other', name: 'Other Ltd' },
54+
],
55+
}),
56+
}));
57+
58+
import { notifyDataChanged } from '@object-ui/react';
59+
import { AuthorizedAccessList } from './AuthorizedAccessList';
60+
61+
type Row = Record<string, unknown>;
62+
63+
const CLIENTS = [
64+
{
65+
id: 'consent_1',
66+
clientId: 'client_cursor',
67+
name: 'Cursor',
68+
scopes: ['data:read', 'offline_access'],
69+
organizationId: 'org_here',
70+
authorizedAt: '2026-11-06T21:12:21.007Z',
71+
lastUsedAt: null,
72+
},
73+
{
74+
id: 'consent_2',
75+
clientId: 'client_desktop',
76+
name: 'Claude Desktop',
77+
scopes: ['data:read', 'data:write', 'made:up'],
78+
organizationId: 'org_other',
79+
authorizedAt: '2026-11-06T21:12:20.843Z',
80+
lastUsedAt: '2026-11-06T21:00:00.000Z',
81+
},
82+
{
83+
id: 'consent_3',
84+
clientId: 'client_nameless',
85+
name: null,
86+
scopes: [],
87+
organizationId: null,
88+
authorizedAt: null,
89+
lastUsedAt: null,
90+
},
91+
];
92+
93+
const FAR_FUTURE = '2999-01-01T00:00:00.000Z';
94+
let keyRows: Row[];
95+
let listMock: ReturnType<typeof vi.fn>;
96+
let findMock: ReturnType<typeof vi.fn>;
97+
let updateMock: ReturnType<typeof vi.fn>;
98+
99+
beforeEach(() => {
100+
authUser = { id: 'user_me' };
101+
keyRows = [
102+
{ id: 'k_active', name: 'ci key', prefix: 'osk_abc', created_at: '2026-11-01T10:00:00.000Z', last_used_at: '2026-11-06T21:12:47.191Z', expires_at: FAR_FUTURE, revoked: false },
103+
{ id: 'k_unused', name: 'spare key', prefix: 'osk_def', created_at: '2026-11-02T10:00:00.000Z', last_used_at: null, expires_at: null, revoked: false },
104+
{ id: 'k_revoked', name: 'old key', prefix: 'osk_ghi', created_at: '2026-10-01T10:00:00.000Z', last_used_at: null, expires_at: FAR_FUTURE, revoked: true },
105+
{ id: 'k_expired', name: 'stale key', prefix: 'osk_jkl', created_at: '2026-01-01T10:00:00.000Z', last_used_at: null, expires_at: '2026-02-01T00:00:00.000Z', revoked: false },
106+
];
107+
listMock = vi.fn(async () => ({ clients: CLIENTS, truncated: false }));
108+
findMock = vi.fn(async () => ({ data: keyRows.map((r) => ({ ...r })) }));
109+
updateMock = vi.fn(async (resource: string, id: string, patch: Row) => {
110+
keyRows = keyRows.map((r) => (r.id === id ? { ...r, ...patch } : r));
111+
// What the console's mutation bridge does for every adapter write.
112+
notifyDataChanged({ objectName: resource, recordId: id });
113+
return keyRows.find((r) => r.id === id);
114+
});
115+
adapter = {
116+
getClient: () => ({ oauth: { authorizedClients: { list: listMock } } }),
117+
find: findMock,
118+
update: updateMock,
119+
};
120+
});
121+
122+
afterEach(() => {
123+
cleanup();
124+
});
125+
126+
async function renderSettled() {
127+
render(<AuthorizedAccessList />);
128+
await screen.findAllByTestId('authorized-assistant');
129+
await screen.findAllByTestId('api-key-row');
130+
}
131+
132+
function assistantRow(name: string): HTMLElement {
133+
const row = screen.getAllByTestId('authorized-assistant').find((el) => el.textContent?.includes(name));
134+
if (!row) throw new Error(`no assistant row for ${name}`);
135+
return row;
136+
}
137+
138+
function keyRow(name: string): HTMLElement {
139+
const row = screen.getAllByTestId('api-key-row').find((el) => el.textContent?.includes(name));
140+
if (!row) throw new Error(`no key row for ${name}`);
141+
return row;
142+
}
143+
144+
describe('§1 authorized assistants', () => {
145+
it('lists each grant with its name, scopes in consent words, when authorized and last used', async () => {
146+
await renderSettled();
147+
expect(listMock).toHaveBeenCalledTimes(1);
148+
expect(screen.getAllByTestId('authorized-assistant')).toHaveLength(3);
149+
150+
const desktop = assistantRow('Claude Desktop');
151+
expect(within(desktop).getByText(/«oauth\.consent\.scope\.dataRead»/)).toBeInTheDocument();
152+
expect(within(desktop).getByText(/«oauth\.consent\.scope\.dataWrite»/)).toBeInTheDocument();
153+
// A scope the page cannot name is shown as unrecognised, never dropped.
154+
expect(within(desktop).getByText('«oauth.consent.scope.unrecognised|made:up»')).toBeInTheDocument();
155+
expect(within(desktop).getByText(/^«connectAgent\.access\.authorized\|/)).toBeInTheDocument();
156+
// An hour-coarse use reads as "in the hour from", never as a moment.
157+
expect(within(desktop).getByText(/^«connectAgent\.access\.lastUsedHour\|/)).toBeInTheDocument();
158+
159+
const cursor = assistantRow('Cursor');
160+
expect(within(cursor).getByText('«connectAgent.access.notRecorded»')).toBeInTheDocument();
161+
expect(within(cursor).getByText('«oauth.consent.scope.offlineAccess»')).toBeInTheDocument();
162+
});
163+
164+
it('names the organization only when it is not the active one', async () => {
165+
await renderSettled();
166+
expect(within(assistantRow('Claude Desktop')).getByText('«connectAgent.access.inOrganization|Other Ltd»')).toBeInTheDocument();
167+
expect(within(assistantRow('Cursor')).queryByText(/inOrganization/)).toBeNull();
168+
});
169+
170+
it('shows the client id beside the fallback when the client declares no name', async () => {
171+
await renderSettled();
172+
const nameless = assistantRow('client_nameless');
173+
expect(within(nameless).getByText('«connectAgent.access.unnamed»')).toBeInTheDocument();
174+
});
175+
176+
it('offers no Revoke on any assistant row, and says withdrawing is not available here yet', async () => {
177+
await renderSettled();
178+
for (const row of screen.getAllByTestId('authorized-assistant')) {
179+
expect(within(row).queryByRole('button')).toBeNull();
180+
}
181+
expect(screen.getByText('«connectAgent.access.withdrawLater»')).toBeInTheDocument();
182+
expect(screen.queryByText(/connectAgent\.access\.truncated/)).toBeNull();
183+
});
184+
185+
it('says when the server answered only the newest grants', async () => {
186+
listMock.mockResolvedValueOnce({ clients: CLIENTS, truncated: true });
187+
await renderSettled();
188+
expect(screen.getByText('«connectAgent.access.truncated»')).toBeInTheDocument();
189+
});
190+
});
191+
192+
describe('§2 API keys', () => {
193+
it("reads only the signed-in person's own keys, and never the secret", async () => {
194+
await renderSettled();
195+
expect(findMock).toHaveBeenCalledTimes(1);
196+
const [resource, params] = findMock.mock.calls[0] as [string, Row];
197+
expect(resource).toBe('sys_api_key');
198+
expect(params.$filter).toEqual({ user_id: 'user_me' });
199+
expect(params.$select).toEqual(['id', 'name', 'prefix', 'created_at', 'last_used_at', 'expires_at', 'revoked']);
200+
expect(params.$select).not.toContain('key');
201+
expect(params.$select).not.toContain('key_hash');
202+
});
203+
204+
it('shows created, last used or never used, and the revoked and expired states', async () => {
205+
await renderSettled();
206+
expect(within(keyRow('ci key')).getByText(/^«connectAgent\.access\.keyLastUsed\|/)).toBeInTheDocument();
207+
expect(within(keyRow('ci key')).getByText(/^«connectAgent\.access\.created\|/)).toBeInTheDocument();
208+
expect(within(keyRow('spare key')).getByText('«connectAgent.access.neverUsed»')).toBeInTheDocument();
209+
expect(within(keyRow('old key')).getByText('«connectAgent.access.revoked»')).toBeInTheDocument();
210+
expect(within(keyRow('stale key')).getByText('«connectAgent.access.expired»')).toBeInTheDocument();
211+
212+
expect(within(keyRow('ci key')).getByRole('button', { name: '«connectAgent.access.revoke»' })).toBeInTheDocument();
213+
expect(within(keyRow('spare key')).getByRole('button', { name: '«connectAgent.access.revoke»' })).toBeInTheDocument();
214+
expect(within(keyRow('old key')).queryByRole('button')).toBeNull();
215+
expect(within(keyRow('stale key')).queryByRole('button')).toBeNull();
216+
});
217+
});
218+
219+
describe('§3 revoking a key', () => {
220+
it('asks first, naming the key; Cancel writes nothing', async () => {
221+
await renderSettled();
222+
fireEvent.click(within(keyRow('ci key')).getByRole('button', { name: '«connectAgent.access.revoke»' }));
223+
const dialog = await screen.findByRole('alertdialog');
224+
expect(within(dialog).getByText('«connectAgent.access.revokeTitle|ci key»')).toBeInTheDocument();
225+
fireEvent.click(within(dialog).getByRole('button', { name: '«common.cancel»' }));
226+
await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
227+
expect(updateMock).not.toHaveBeenCalled();
228+
});
229+
230+
it('confirming writes revoked: true and re-reads the list in place', async () => {
231+
await renderSettled();
232+
fireEvent.click(within(keyRow('ci key')).getByRole('button', { name: '«connectAgent.access.revoke»' }));
233+
const dialog = await screen.findByRole('alertdialog');
234+
fireEvent.click(within(dialog).getByRole('button', { name: '«connectAgent.access.revoke»' }));
235+
236+
await waitFor(() => expect(updateMock).toHaveBeenCalledWith('sys_api_key', 'k_active', { revoked: true }));
237+
await waitFor(() => expect(findMock).toHaveBeenCalledTimes(2));
238+
await waitFor(() =>
239+
expect(within(keyRow('ci key')).getByText('«connectAgent.access.revoked»')).toBeInTheDocument(),
240+
);
241+
expect(within(keyRow('ci key')).queryByRole('button')).toBeNull();
242+
// The assistants are not re-read by a key write.
243+
expect(listMock).toHaveBeenCalledTimes(1);
244+
});
245+
});
246+
247+
describe('§4 empty, failed and signed-out states', () => {
248+
it('says so when there is nothing to list', async () => {
249+
listMock.mockResolvedValueOnce({ clients: [], truncated: false });
250+
findMock.mockResolvedValueOnce({ data: [] });
251+
render(<AuthorizedAccessList />);
252+
expect(await screen.findByText('«connectAgent.access.assistantsEmpty»')).toBeInTheDocument();
253+
expect(await screen.findByText('«connectAgent.access.keysEmpty»')).toBeInTheDocument();
254+
});
255+
256+
it('names a failed read instead of showing an empty list', async () => {
257+
listMock.mockRejectedValueOnce(new Error('boom'));
258+
findMock.mockRejectedValueOnce(new Error('nope'));
259+
render(<AuthorizedAccessList />);
260+
expect(await screen.findByText('«connectAgent.access.loadFailed|boom»')).toBeInTheDocument();
261+
expect(await screen.findByText('«connectAgent.access.loadFailed|nope»')).toBeInTheDocument();
262+
expect(screen.queryByText('«connectAgent.access.assistantsEmpty»')).toBeNull();
263+
});
264+
265+
it('renders nothing and reads nothing without a signed-in person or an adapter', () => {
266+
authUser = null;
267+
const { container, unmount } = render(<AuthorizedAccessList />);
268+
expect(container).toBeEmptyDOMElement();
269+
unmount();
270+
authUser = { id: 'user_me' };
271+
adapter = null;
272+
const second = render(<AuthorizedAccessList />);
273+
expect(second.container).toBeEmptyDOMElement();
274+
expect(listMock).not.toHaveBeenCalled();
275+
expect(findMock).not.toHaveBeenCalled();
276+
});
277+
});

0 commit comments

Comments
 (0)