diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js
index 4294b18489..c1b0d2597a 100644
--- a/frontend/.eslintrc.js
+++ b/frontend/.eslintrc.js
@@ -69,8 +69,13 @@ module.exports = {
// Connectors + Tools (TASK-164) — the channels list and the grants list
// rendered English defaults under zh-CN: neither file was in this manifest,
// so translationKeys.test.ts never saw the keys they were using.
- 'src/v2/components/V2ConnectorsPage.tsx',
- 'src/v2/components/V2ConnectorTools.tsx',
+ // Moved into src/v2/connectors by the tile-grid rebuild (TASK-733).
+ 'src/v2/connectors/ChannelPanel.tsx',
+ 'src/v2/connectors/ConnectorDrawer.tsx',
+ 'src/v2/connectors/ConnectorPopover.tsx',
+ 'src/v2/connectors/ConnectorTile.tsx',
+ 'src/v2/connectors/ConnectorsPage.tsx',
+ 'src/v2/connectors/ToolPanels.tsx',
'src/v2/utils/localizeRelativeTime.ts',
],
rules: {
diff --git a/frontend/src/i18n/__tests__/translationKeys.test.ts b/frontend/src/i18n/__tests__/translationKeys.test.ts
index aa7dd1e3ae..6c46cb8608 100644
--- a/frontend/src/i18n/__tests__/translationKeys.test.ts
+++ b/frontend/src/i18n/__tests__/translationKeys.test.ts
@@ -44,7 +44,7 @@ describe('i18n migration manifest', () => {
);
// Catalog description maps are locale-only: English renders the server's
- // description, and a locale without a map falls back to it. See V2ConnectorsPage.
+ // description, and a locale without a map falls back to it. See ConnectorsPage.
const LOCALE_ONLY_PREFIX = 'connectors.catalogDescriptions.';
it('keeps every registered language base key path identical', () => {
diff --git a/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts b/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts
index a4e9ef7157..3b74aa6297 100644
--- a/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts
+++ b/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts
@@ -17,7 +17,7 @@ import path from 'path';
// be decided from this catalog: ``{{agents}}`` is a count in `yourTeam.head.meta_other`
// and a name list joined with ' and ' in `inspector.workspace.nothingWorking_other`.
// A first cut of the sweep declared `agents` a numeral by name and closed
-// 「Scout 可以使用」 in the Tools panel; `V2ConnectorTools.test.tsx` caught it. So the
+// 「Scout 可以使用」 in the Tools panel; `ConnectorsPage.tools.test.tsx` caught it. So the
// default is opaque, the numeral names are declared here, and the coverage test
// below makes a new interpolation fail rather than inherit whichever silence looks
// convenient.
@@ -57,7 +57,14 @@ const OPAQUE_PLACEHOLDERS = [
// The exception runs the safe way: a name that is usually opaque and is a number
// here, named by key. `agents` is a count in yourTeam.head.meta (counts.agents) and
// a name list in the inspector, so it cannot be declared for the catalog as a whole.
-const NUMERAL_BY_KEY: Array<[string, string]> = [['yourTeam.head.meta_other', 'agents']];
+// `on`, `available` and `n` are counts in the Connectors page strings (the tile
+// grid meta line and the "+N off" toggle), but opaque elsewhere.
+const NUMERAL_BY_KEY: Array<[string, string]> = [
+ ['yourTeam.head.meta_other', 'agents'],
+ ['connectors.meta', 'on'],
+ ['connectors.meta', 'available'],
+ ['connectors.offCount', 'n'],
+];
// A digit is this rule's numeral only when it stands alone, and that is a question
// about the TOKEN the digit sits inside, so it has two halves. `Apache-2.0 许可` and
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index dc348e8b37..e9362b64f7 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -2272,12 +2272,16 @@
}
},
"connectors": {
- "about": "About connectors",
- "counts": {
- "connected_one": "{{count}} connected",
- "connected_other": "{{count}} connected",
- "needsYou_one": "{{count}} needs you",
- "needsYou_other": "{{count}} need you"
+ "search": "Search",
+ "filterLabel": "Show",
+ "channelsGroup": "Channels",
+ "addChannelTile": "Channel",
+ "meta": "{{on}} on · {{available}} available",
+ "offCount": "+{{n}} off",
+ "filter": {
+ "all": "All",
+ "channels": "Channels",
+ "tools": "Tools"
},
"inactiveFold": "inactive · {{n}}",
"loading": "Loading connectors…",
@@ -2327,6 +2331,7 @@
"availableTelegram": "one message",
"cancel": "Cancel",
"channelDetails": "Channel details",
+ "closeDetails": "Close",
"channelSees": "What the channel sees",
"channels": "Your channels",
"codeError": "Could not create a new code.",
@@ -2335,7 +2340,6 @@
"connectChannel": "Connect a channel",
"copied": "Copied",
"copyCommand": "Copy command",
- "description": "Connect a channel to a pod. Messages arrive there as you; choose what reaches the channel.",
"errorLine": "The connection dropped.",
"errorReconnect": "reconnect to resume",
"gateLead": "Lead agent for {{pod}}",
@@ -2480,6 +2484,9 @@
"loadFailed": "Could not load artifacts."
},
"tools": {
+ "granted": "Granted",
+ "beta": "beta",
+ "adminOnce": "An admin installs this once",
"emptyAudienceNotice": "Select at least one agent to grant access.",
"makePodInviteOnly": "Make this pod invite-only",
"podInviteOnlyNow": "This pod is invite-only. Select Grant to continue.",
@@ -2528,7 +2535,6 @@
"grantDetails": "Grant details",
"grantError": "Could not grant it.",
"grantedByOn": "Granted by {{member}} {{rel}}.",
- "grantedCount": "{{count}} granted",
"grantedOn": "Granted {{rel}}.",
"grantedToPod": "granted to {{pod}}",
"grantedToPodBy": "granted to {{pod}} by {{member}}",
@@ -2537,14 +2543,12 @@
"installationId": "installation ID",
"installingGitHubApp": "Installing…",
"keep": "Keep it",
- "loading": "Loading tools…",
"manage": "Manage",
"mayUse": "{{agents}} may use it",
"mode": "what it may do",
"modeRead": "read",
"modeWrite": "read and write",
"modeWriteConfirm": "read and write, ask first",
- "moreCount": "{{count}} more",
"existingConnection": "existing connection",
"noLongerOffered": "This connector is no longer offered here.",
"removeConnection": "Remove connection",
@@ -2584,7 +2588,6 @@
"revokeOld": "Revoke the old grant",
"revokedByLine": "revoked by {{member}} {{rel}}",
"revokedLine": "revoked {{rel}}",
- "search": "Search tools",
"segAll": "All",
"segGranted": "Granted",
"segNotYet": "Not yet",
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index ea11b5b13a..8d80f7210c 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -2262,12 +2262,16 @@
}
},
"connectors": {
- "about": "关于连接器",
- "counts": {
- "connected_one": "已连接{{count}}个频道",
- "connected_other": "已连接{{count}}个频道",
- "needsYou_one": "{{count}}项需要你处理",
- "needsYou_other": "{{count}}项需要你处理"
+ "search": "搜索",
+ "filterLabel": "显示",
+ "channelsGroup": "频道",
+ "addChannelTile": "频道",
+ "meta": "已开启{{on}}个 · 可用{{available}}个",
+ "offCount": "另有{{n}}个未启用",
+ "filter": {
+ "all": "全部",
+ "channels": "频道",
+ "tools": "工具"
},
"inactiveFold": "非活跃 · {{n}}",
"loading": "正在加载连接器…",
@@ -2317,6 +2321,7 @@
"availableTelegram": "发一条消息",
"cancel": "取消",
"channelDetails": "频道详情",
+ "closeDetails": "关闭",
"channelSees": "频道可见内容",
"channels": "你的频道",
"codeError": "无法生成新的启用码。",
@@ -2325,7 +2330,6 @@
"connectChannel": "连接频道",
"copied": "已复制",
"copyCommand": "复制命令",
- "description": "将频道连接到 Pod。消息会以你的身份进入 Pod;你可以选择哪些内容回传到频道。",
"errorLine": "连接已断开。",
"errorReconnect": "重新连接以恢复",
"gateLead": "{{pod}} 的主导智能体",
@@ -2483,6 +2487,9 @@
"loadFailed": "无法加载产物。"
},
"tools": {
+ "granted": "已授权",
+ "beta": "测试版",
+ "adminOnce": "需管理员安装一次",
"emptyAudienceNotice": "请至少选择一个智能体以授予访问权限。",
"makePodInviteOnly": "将此 Pod 设为仅限邀请",
"podInviteOnlyNow": "此 Pod 现已设为仅限邀请。再次选择“授权”继续。",
@@ -2531,7 +2538,6 @@
"grantDetails": "授权详情",
"grantError": "无法授权。",
"grantedByOn": "{{rel}}由 {{member}} 授权。",
- "grantedCount": "已授权{{count}}个",
"grantedOn": "{{rel}}授权。",
"grantedToPod": "授权给 {{pod}}",
"grantedToPodBy": "由 {{member}} 授权给 {{pod}}",
@@ -2540,14 +2546,12 @@
"installationId": "安装 ID",
"installingGitHubApp": "安装中…",
"keep": "保留",
- "loading": "正在加载工具…",
"manage": "管理",
"mayUse": "{{agents}} 可以使用",
"mode": "可执行的操作",
"modeRead": "读取",
"modeWrite": "读取和写入",
"modeWriteConfirm": "读取和写入,写入前需确认",
- "moreCount": "另有{{count}}个",
"existingConnection": "现有连接",
"noLongerOffered": "此连接器已不在本实例提供。",
"removeConnection": "移除连接",
@@ -2587,7 +2591,6 @@
"revokeOld": "撤销旧授权",
"revokedByLine": "{{rel}}由 {{member}} 撤销",
"revokedLine": "{{rel}}撤销",
- "search": "搜索工具",
"segAll": "全部",
"segGranted": "已授权",
"segNotYet": "尚未授权",
diff --git a/frontend/src/v2/V2App.tsx b/frontend/src/v2/V2App.tsx
index a3a5119d81..25e1819d7f 100644
--- a/frontend/src/v2/V2App.tsx
+++ b/frontend/src/v2/V2App.tsx
@@ -38,7 +38,7 @@ const V2AgentSettingsPage = React.lazy(() => import('./agents/V2AgentSettingsPag
const UserProfile = React.lazy(() => import('../components/UserProfile'));
const V2AgentBYO = React.lazy(() => import('./components/V2AgentBYO'));
const V2ConnectPage = React.lazy(() => import('./components/V2ConnectPage'));
-const V2ConnectorsPage = React.lazy(() => import('./components/V2ConnectorsPage'));
+const ConnectorsPage = React.lazy(() => import('./connectors/ConnectorsPage'));
const V2PodBoard = React.lazy(() => import('./components/V2PodBoard'));
const V2RunTrace = React.lazy(() => import('./components/V2RunTrace'));
const V2ActivityPage = React.lazy(() => import('./components/V2ActivityPage'));
@@ -301,10 +301,11 @@ const V2App: React.FC = () => {
path="connectors"
element={feature(
'Connectors',
- ,
+ ,
false,
// The Signal artboard owns this page's display-sized head.
false,
+ 'v2-feature--connectors',
)}
/>
require('../__testkit__/axiosMock').axiosFactory())`
+// cannot loop back through the modules that import axios.
+export const axiosFactory = () => {
+ const mock = {
+ get: jest.fn(),
+ post: jest.fn(),
+ patch: jest.fn(),
+ delete: jest.fn(),
+ defaults: { baseURL: '', headers: { common: {} } },
+ interceptors: {
+ request: { use: jest.fn(), eject: jest.fn() },
+ response: { use: jest.fn(), eject: jest.fn() },
+ },
+ };
+ return { __esModule: true, default: mock, ...mock };
+};
diff --git a/frontend/src/v2/__testkit__/connectorsKit.tsx b/frontend/src/v2/__testkit__/connectorsKit.tsx
new file mode 100644
index 0000000000..9c5d3bf2f5
--- /dev/null
+++ b/frontend/src/v2/__testkit__/connectorsKit.tsx
@@ -0,0 +1,141 @@
+// @ts-nocheck
+// Shared harness for the Connectors page suites (ConnectorsPage.channels / .tools /
+// .layout). It is not a test file: it lives outside `__tests__` so jest does not
+// collect it. A suite still declares the `jest.mock('axios', ...)` itself (babel-jest
+// only hoists the call in the file that makes it), with `axiosMock.ts` as the factory.
+import React from 'react';
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import ConnectorsPage from '../connectors/ConnectorsPage';
+import { AuthContext } from '../../context/AuthContext';
+
+export const axios = jest.requireMock('axios').default;
+
+export const authValue = {
+ currentUser: { _id: 'u1', username: 'sam' },
+ user: { _id: 'u1', username: 'sam' },
+ token: 'user-jwt',
+ loading: false,
+ error: null,
+ isAuthenticated: true,
+ register: jest.fn(),
+ login: jest.fn(),
+ logout: jest.fn(),
+ updateProfile: jest.fn(),
+};
+
+export const asUser = (user) => ({ ...authValue, currentUser: user, user });
+export const adminAuth = (over = {}) => asUser({ ...authValue.user, role: 'admin', ...over });
+
+export const emptyCounts = { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 };
+
+/**
+ * One GET router for every read the page makes. Anything not given answers the
+ * empty shape of its route, so a suite states only what it is about.
+ *
+ * connectors /api/integrations/user/all
+ * installables /api/installables (channels and tools share it); `null` rejects
+ * the read, and leaving it out answers `[]` (no catalogue)
+ * hidden hiddenToolConnections on the same response
+ * pods /api/pods (an Error rejects, 'pending' never settles)
+ * grants /api/grants
+ * seats { [podId]: seat[] } for /api/registry/pods/:id/agents
+ * trails { [grantId]: trail } for /api/grants/:id/calls
+ * route (url, config) => Promise | undefined, consulted first
+ */
+export const mockApi = ({
+ connectors = [],
+ installables,
+ hidden,
+ pods = [],
+ grants = [],
+ seats = {},
+ trails = {},
+ route,
+} = {}) => {
+ axios.get.mockImplementation((url, config) => {
+ const routed = route ? route(url, config) : undefined;
+ if (routed !== undefined) return routed;
+ if (url === '/api/integrations/user/all') return Promise.resolve({ data: connectors });
+ if (url === '/api/installables') {
+ if (installables === null) return Promise.reject(new Error('catalogue unavailable'));
+ if (installables === undefined) return Promise.resolve({ data: [] });
+ return Promise.resolve({ data: { installables, ...(hidden ? { hiddenToolConnections: hidden } : {}) } });
+ }
+ if (url === '/api/pods') {
+ if (pods === 'pending') return new Promise(() => {});
+ if (pods instanceof Error) return Promise.reject(pods);
+ return Promise.resolve({ data: pods });
+ }
+ if (url === '/api/grants') return Promise.resolve({ data: { grants } });
+ const seatRoute = /^\/api\/registry\/pods\/([^/]+)\/agents$/.exec(url);
+ if (seatRoute) return Promise.resolve({ data: { agents: seats[seatRoute[1]] || [] } });
+ const callsRoute = /^\/api\/grants\/([^/]+)\/calls$/.exec(url);
+ if (callsRoute) {
+ return Promise.resolve({ data: trails[callsRoute[1]] || { grantId: callsRoute[1], calls: [], counts: emptyCounts } });
+ }
+ return Promise.resolve({ data: [] });
+ });
+};
+
+export const renderPage = ({ auth = authValue, entries } = {}) => render(
+
+
+
+
+ ,
+);
+
+export const renderWithRoutes = (routes, { auth = authValue, entries = ['/v2/connectors'] } = {}) => render(
+
+ {routes}
+ ,
+);
+
+// ---- the tile surface ----
+// A tile's accessible name is its dot's words plus its label, so a tile is found by
+// the title it carries (the connector's label), never by role name.
+export const tileNode = (name) => document.querySelector(`button.v2-cx-tile[title="${name}"]`);
+export const findTile = async (name) => {
+ await waitFor(() => expect(tileNode(name)).not.toBeNull());
+ return tileNode(name);
+};
+export const clickTile = async (name) => {
+ const node = await findTile(name);
+ fireEvent.click(node);
+ return node;
+};
+export const findDrawer = (name) => screen.findByRole('region', { name });
+export const findPopover = (name) => screen.findByRole('dialog', { name });
+
+/** Opens the drawer of a connected tile and returns it. */
+export const openDrawer = async (name) => {
+ await clickTile(name);
+ return findDrawer(name);
+};
+/** Opens the popover of an unconnected tile and returns it. */
+export const openPopover = async (name) => {
+ await clickTile(name);
+ return findPopover(name);
+};
+
+/** Opens a tool's drawer, then the grant row's selection to read its aside. */
+export const openGrant = async (toolName, rowName) => {
+ const drawer = await openDrawer(toolName);
+ fireEvent.click(within(drawer).getByRole('button', { name: rowName }));
+ const aside = await within(drawer).findByRole('complementary', { name: 'Grant details' });
+ return { drawer, aside };
+};
+
+/** The "+N off" button of a group ("Channels" or "Tools"), clicked open. */
+export const expandOff = async (group) => {
+ const section = await screen.findByRole('region', { name: group });
+ fireEvent.click(await within(section).findByRole('button', { name: /^\+\d+ off$/ }));
+ return section;
+};
+
+/** Waits for the page's first read to settle (the loading line is gone). */
+export const pageReady = async () => {
+ await screen.findByRole('heading', { name: 'Connectors' });
+ await waitFor(() => expect(screen.queryByText('Loading connectors…')).toBeNull());
+};
diff --git a/frontend/src/v2/__tests__/ConnectorsPage.channels.test.tsx b/frontend/src/v2/__tests__/ConnectorsPage.channels.test.tsx
new file mode 100644
index 0000000000..2f71ccd3a3
--- /dev/null
+++ b/frontend/src/v2/__tests__/ConnectorsPage.channels.test.tsx
@@ -0,0 +1,1499 @@
+// @ts-nocheck
+// Connectors page, channel half: every behaviour the old row page held for channels
+// (Phase 1 verbs, Slack authorize/confirm, install lock, gates, catalogue states),
+// reached now through a tile, its popover and the right drawer.
+import React from 'react';
+import { act, fireEvent, screen, waitFor, within } from '@testing-library/react';
+import { Route, Routes } from 'react-router-dom';
+import { PlatformGlyph } from '../icons/platforms';
+import { INSTALL_LOCK_TTL_MS, installableLifecyclePath } from '../connectors/channelModel';
+import en from '../../i18n/locales/en.json';
+import i18n, { i18nReady } from '../../i18n';
+import {
+ axios, authValue, mockApi, renderPage, renderWithRoutes, pageReady, tileNode, findTile, clickTile,
+ openDrawer, openPopover, findDrawer, expandOff,
+} from '../__testkit__/connectorsKit';
+
+jest.mock('axios', () => require('../__testkit__/axiosMock').axiosFactory());
+
+const connectors = [
+ {
+ _id: 'i-pending',
+ installationId: 'install-telegram-u1',
+ type: 'telegram',
+ status: 'pending',
+ createdAt: new Date(Date.now() - 60_000).toISOString(),
+ config: { connectCode: 'abc123', connectCodeExpiresAt: new Date(Date.now() + 5 * 60_000).toISOString() },
+ podId: { _id: 'p1', name: 'Rewire Live Demo' },
+ },
+ {
+ _id: 'i-live',
+ installationId: 'install-slack-u1',
+ type: 'slack',
+ status: 'connected',
+ createdAt: new Date(Date.now() - 3_600_000).toISOString(),
+ updatedAt: new Date().toISOString(),
+ config: { chatTitle: 'Rewire crew', liveRelay: false },
+ podId: { _id: 'p2', name: 'Ops' },
+ },
+];
+
+const roomPods = [
+ { _id: 'p1', name: 'Rewire Live Demo', type: 'chat' },
+ { _id: 'pub', name: 'Town Square', type: 'community' },
+];
+
+// The page reads connectors, the catalogue and pods itself; a suite names only what
+// it is about.
+const mockGets = (list = connectors, installables = undefined, pods = roomPods) => (
+ mockApi({ connectors: list, installables, pods })
+);
+
+const hostedLinearCatalogEntry = (connectionId = 'i-linear') => ({
+ installableId: 'linear',
+ list: 'tools',
+ label: 'Linear',
+ description: 'Read Linear issues and projects.',
+ connectionType: 'hosted-mcp',
+ entryId: 'linear',
+ available: true,
+ tools: [],
+ connections: [{ connectionId, owner: 'sam', repo: '' }],
+ installation: null,
+ integration: null,
+});
+
+const slackPending = (config = {}, over = {}) => ({
+ _id: 'i-slack-pending',
+ installationId: 'install-slack-u1',
+ type: 'slack',
+ status: 'pending',
+ config,
+ podId: { _id: 'p1', name: 'Rewire Live Demo' },
+ ...over,
+});
+const pendingBind = { pendingBind: { teamName: 'Commonly HQ', slackUserName: 'sam' } };
+
+// The reconciler's own constant — the reason a person reads when the channel
+// record behind a connector is gone (TASK-131 copy pass).
+const REASON_CHANNEL_GONE = "This connector's channel is gone. Retry to rebuild it.";
+
+// The page-level slot a refusal must NOT land in when a drawer asked for it.
+const pageError = () => document.querySelector('.v2-cx-error');
+// A tile's corner dot carries the row's sentence as its accessible name.
+const dot = (name) => screen.getByRole('img', { name });
+
+describe('Connectors page: channels', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.spyOn(window, 'open').mockReturnValue(null);
+ window.history.replaceState({}, '', '/v2/connectors');
+ });
+
+ afterEach(() => jest.restoreAllMocks());
+
+ it('a linked channel tile carries its state sentence, and its drawer says what the channel sees', async () => {
+ mockGets([connectors[1]]);
+ renderPage();
+ await findTile('Slack');
+ expect(dot('Rewire crew · linked to Ops')).toBeInTheDocument();
+ const drawer = await openDrawer('Slack');
+ expect(within(drawer).getByText('What the channel sees')).toBeInTheDocument();
+ expect(within(drawer).getByText('Messages stay in the pod.')).toBeInTheDocument();
+ });
+
+ // TASK-162: en.json is where the copy lives, so that is where the rule is asserted.
+ it('TASK-162: the relay-mode lines never repeat the mode word they sit beside', () => {
+ const kickerByRow: Array<[keyof typeof en.connectors, string]> = [
+ ['rowAttention', 'attention'],
+ ['rowMirror', 'mirror'],
+ ['rowRelayOff', 'relay off'],
+ ];
+ for (const [key, modeWord] of kickerByRow) {
+ expect(en.connectors[key]).toBeTruthy();
+ expect(en.connectors[key].startsWith(modeWord)).toBe(false);
+ }
+ });
+
+ it('TASK-338: with nothing connected there is no unbuilt-provider placeholder and no not-yet or SOON copy', async () => {
+ mockGets([]);
+ renderPage();
+ await pageReady();
+ expect(screen.queryByText(/not yet/i)).toBeNull();
+ expect(screen.queryByText(/Tell us which channel/)).toBeNull();
+ expect(screen.queryByText('SOON')).toBeNull();
+ expect(document.querySelector('.v2-connector-row__names')).toBeNull();
+ });
+
+ it('TASK-131: relative ages advance in place, and a returning tab re-reads, without a reload', async () => {
+ // Fake the clock so the minute tick is deterministic. The gate line's age only
+ // moves if the page re-renders it, which is the defect being pinned.
+ jest.useFakeTimers();
+ try {
+ const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString();
+ mockGets([{
+ ...connectors[1],
+ podId: 'p1',
+ config: { chatTitle: 'Rewire crew', liveRelay: true, gates: { p1: { enabled: true, since: fiveMinutesAgo } } },
+ }], undefined, [{ _id: 'p1', name: 'Rewire Live Demo', type: 'chat', members: [] }]);
+ renderPage();
+
+ fireEvent.click(await findTile('Slack'));
+ expect(await screen.findByText(/^since 5m/)).toBeInTheDocument();
+
+ await act(async () => { jest.advanceTimersByTime(2 * 60_000); });
+ expect(screen.getByText(/^since 7m/)).toBeInTheDocument();
+
+ const reads = () => axios.get.mock.calls.filter(([url]) => url === '/api/integrations/user/all').length;
+ const before = reads();
+ document.dispatchEvent(new Event('visibilitychange'));
+ await waitFor(() => expect(reads()).toBeGreaterThan(before));
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('shows both channels as tiles and opens the pending one with its code and copy button', async () => {
+ mockGets();
+ renderPage();
+
+ await findTile('Telegram');
+ expect(dot('Send /commonly-enable in your Telegram chat.')).toBeInTheDocument();
+ expect(dot('Rewire crew · linked to Ops')).toBeInTheDocument();
+ expect(screen.getByRole('region', { name: 'Channels' }).querySelectorAll('.v2-cx-tile')).toHaveLength(2);
+ expect(document.querySelector('.v2-connector-row__names')).toBeNull();
+ const drawer = await openDrawer('Telegram');
+ expect(within(drawer).getByText('/commonly-enable abc1 23')).toBeInTheDocument();
+ expect(within(drawer).getByRole('button', { name: 'Copy command' })).toBeInTheDocument();
+ });
+
+ // TASK-156. A linked Slack stores its workspace in `teamName` (slackOAuthService)
+ // and never in `chatTitle`, so the sentence read "Slack · linked to Ops" beside
+ // the word Slack. The workspace name belongs in the sentence; the tile's name
+ // stays the platform.
+ describe('the tile names the Slack workspace (TASK-156)', () => {
+ const slackRow = (config, podId = { _id: 'p2', name: 'Ops' }) => ({
+ _id: 'i-slack',
+ installationId: 'install-slack-u1',
+ type: 'slack',
+ status: 'connected',
+ createdAt: new Date(Date.now() - 3_600_000).toISOString(),
+ updatedAt: new Date().toISOString(),
+ config,
+ podId,
+ });
+
+ it('reads the teamName when the link carries no chatTitle', async () => {
+ mockGets([slackRow({ teamName: 'Acme', liveRelay: false })]);
+ renderPage();
+
+ await findTile('Slack');
+ expect(dot('Acme · linked to Ops')).toBeInTheDocument();
+ expect(screen.queryByRole('img', { name: 'Slack · linked to Ops' })).toBeNull();
+ });
+
+ it('reads the teamName on the not-linked variant too', async () => {
+ mockGets([slackRow({ teamName: 'Acme', liveRelay: false }, null)]);
+ renderPage();
+
+ await findTile('Slack');
+ expect(dot('Acme · not linked to a pod')).toBeInTheDocument();
+ });
+
+ it('keeps chatTitle winning when the link carries both', async () => {
+ mockGets([slackRow({ chatTitle: 'Rewire crew', teamName: 'Acme', liveRelay: false })]);
+ renderPage();
+
+ await findTile('Slack');
+ expect(dot('Rewire crew · linked to Ops')).toBeInTheDocument();
+ expect(screen.queryByRole('img', { name: 'Acme · linked to Ops' })).toBeNull();
+ });
+ });
+
+ it('offers a new code in the drawer when the Telegram code has expired', async () => {
+ mockGets([{ ...connectors[0], config: { connectCode: 'abc123' } }]);
+ axios.post.mockResolvedValue({ data: {} });
+ renderPage();
+
+ await findTile('Telegram');
+ expect(dot('The enable code expired.')).toBeInTheDocument();
+ const drawer = await openDrawer('Telegram');
+ expect(within(drawer).getByText('The enable code expired.')).toBeInTheDocument();
+ expect(screen.queryByText('/commonly-enable abc1 23')).toBeNull();
+ fireEvent.click(within(drawer).getByRole('button', { name: 'New code' }));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
+ '/api/integrations/i-pending/connect-code',
+ {},
+ expect.anything(),
+ ));
+ });
+
+ it('opens a connected channel and keeps relay and mode PATCHes in its drawer', async () => {
+ mockGets();
+ axios.patch.mockResolvedValue({ data: {} });
+ renderPage();
+
+ const drawer = await openDrawer('Slack');
+ fireEvent.click(within(drawer).getByRole('checkbox', { name: 'Relay' }));
+ await waitFor(() => expect(axios.patch).toHaveBeenCalledWith(
+ '/api/integrations/i-live',
+ { config: { liveRelay: true } },
+ expect.anything(),
+ ));
+ });
+
+ it('PATCHes the mirror mode from the channel drawer', async () => {
+ mockGets([{
+ ...connectors[1],
+ config: { chatTitle: 'Rewire crew', liveRelay: true, relayAllAgentMessages: false },
+ }]);
+ axios.patch.mockResolvedValue({ data: {} });
+ renderPage();
+
+ const drawer = await openDrawer('Slack');
+ expect(within(drawer).getByRole('button', { name: 'Attention' })).toBeDisabled();
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Mirror' }));
+ await waitFor(() => expect(axios.patch).toHaveBeenCalledWith(
+ '/api/integrations/i-live',
+ { config: { relayAllAgentMessages: true } },
+ expect.anything(),
+ ));
+ });
+
+ it('uses the add drawer with every pod the user is in, and installs through the lifecycle verb', async () => {
+ mockGets([]);
+ axios.post.mockResolvedValue({ data: { integration: { _id: 'new' } } });
+ renderPage();
+
+ fireEvent.click(await findTile('Channel'));
+ const drawer = await findDrawer('Connect a channel');
+ const picker = within(drawer).getByLabelText('Pod to bridge');
+ const options = Array.from(picker.querySelectorAll('option')).map((option) => option.textContent);
+ expect(options).toContain('Rewire Live Demo');
+ expect(options).toContain('Town Square');
+ expect(picker.closest('.v2-cx-drawer')).not.toBeNull();
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Connect' }));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
+ '/api/installables/telegram/install',
+ { podId: 'p1' },
+ expect.anything(),
+ ));
+ });
+
+ it('selects Slack in the add drawer and installs through its lifecycle verb', async () => {
+ mockGets([]);
+ axios.post.mockResolvedValue({ data: { integration: { _id: 'slack-new' } } });
+ renderPage();
+
+ fireEvent.click(await findTile('Channel'));
+ const drawer = await findDrawer('Connect a channel');
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Slack' }));
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Connect' }));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
+ '/api/installables/slack/install',
+ { podId: 'p1' },
+ expect.anything(),
+ ));
+ });
+
+ // Each install refusal keeps the form where it is, with the sentence at the page.
+ const openAddAndConnect = async () => {
+ fireEvent.click(await findTile('Channel'));
+ const drawer = await findDrawer('Connect a channel');
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Connect' }));
+ return drawer;
+ };
+
+ it('keeps the form open while an install claim is still in progress', async () => {
+ mockGets([]);
+ axios.post.mockResolvedValue({ data: { status: 'installing' } });
+ renderPage();
+
+ const drawer = await openAddAndConnect();
+ expect(await screen.findByText('Still setting up — try again in a moment.')).toBeInTheDocument();
+ expect(within(drawer).getByLabelText('Pod to bridge')).toBeInTheDocument();
+ });
+
+ it('names the pending pod returned with an install response', async () => {
+ mockGets([]);
+ axios.post.mockResolvedValue({ data: { status: 'installing', boundPodId: 'p1' } });
+ renderPage();
+
+ const drawer = await openAddAndConnect();
+ expect(await screen.findByText('Still setting up for Rewire Live Demo — try again in a moment.')).toBeInTheDocument();
+ expect(within(drawer).getByLabelText('Pod to bridge')).toBeInTheDocument();
+ });
+
+ it('keeps the form open when the server reports an install lock', async () => {
+ mockGets([]);
+ axios.post.mockRejectedValue({ response: { status: 409, data: { code: 'install_in_progress', boundPodId: 'p1' } } });
+ renderPage();
+
+ const drawer = await openAddAndConnect();
+ expect(await screen.findByText('Still setting up for Rewire Live Demo — try again in a moment.')).toBeInTheDocument();
+ expect(within(drawer).getByLabelText('Pod to bridge')).toBeInTheDocument();
+ });
+
+ it('keeps the form open when an install lock does not name its pod', async () => {
+ mockGets([]);
+ axios.post.mockRejectedValue({ response: { status: 409, data: { code: 'install_in_progress' } } });
+ renderPage();
+
+ const drawer = await openAddAndConnect();
+ expect(await screen.findByText('Still setting up — try again in a moment.')).toBeInTheDocument();
+ expect(within(drawer).getByLabelText('Pod to bridge')).toBeInTheDocument();
+ });
+
+ it('names the existing pod when the installable is already bound elsewhere', async () => {
+ mockGets([]);
+ axios.post.mockRejectedValue({ response: { status: 409, data: { code: 'already_installed', boundPodId: 'p1' } } });
+ renderPage();
+
+ const drawer = await openAddAndConnect();
+ const refusal = await screen.findByText(/bound to Rewire Live Demo/);
+ expect(refusal).toBeInTheDocument();
+ // TASK-154 item 6: the old copy ('Remove it to bind a different pod') taught the
+ // one-pod model. The refusal is about the bind, and other pods reach the channel
+ // through the switches. TASK-173: it names the label the user can actually look
+ // for, and the phrase is longer than the section title so a page that merely
+ // renders the section cannot satisfy it.
+ expect(screen.getByText(/through "Pods that reach this channel"/)).toBeInTheDocument();
+ expect(screen.queryByText(/gate switches/)).toBeNull();
+ // "Channel details" is the panel's `aria-label` and never visible text, so
+ // naming it in the copy sends a sighted user after a string the page does not
+ // render.
+ expect(refusal.textContent).not.toMatch(/Channel details/);
+ expect(screen.queryByText(/Remove it to bind/)).toBeNull();
+ expect(within(drawer).getByLabelText('Pod to bridge')).toBeInTheDocument();
+ });
+
+ it('disconnects installable and legacy channels through their respective verbs', async () => {
+ mockGets([connectors[1]]);
+ axios.delete.mockResolvedValue({ data: { status: 'uninstalled' } });
+ const firstRender = renderPage();
+
+ let drawer = await openDrawer('Slack');
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Remove' }));
+ fireEvent.click(within(drawer).getByRole('button', { name: /Really remove/ }));
+ await waitFor(() => expect(axios.delete).toHaveBeenCalledWith(
+ '/api/installables/slack/install',
+ expect.anything(),
+ ));
+
+ firstRender.unmount();
+ mockGets([{ ...connectors[1], installationId: undefined }]);
+ axios.patch.mockResolvedValue({ data: {} });
+ renderPage();
+ drawer = await openDrawer('Slack');
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Remove' }));
+ fireEvent.click(within(drawer).getByRole('button', { name: /Really remove/ }));
+ await waitFor(() => expect(axios.patch).toHaveBeenCalledWith(
+ '/api/integrations/i-live',
+ { isActive: false },
+ expect.anything(),
+ ));
+ });
+
+ it('disconnects an installable Telegram connector through its lifecycle verb', async () => {
+ mockGets([{ ...connectors[1], installationId: 'install-telegram-u1', type: 'telegram' }]);
+ axios.delete.mockResolvedValue({ data: { status: 'uninstalled' } });
+ renderPage();
+
+ const drawer = await openDrawer('Telegram');
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Remove' }));
+ fireEvent.click(within(drawer).getByRole('button', { name: /Really remove/ }));
+ await waitFor(() => expect(axios.delete).toHaveBeenCalledWith(
+ '/api/installables/telegram/install',
+ expect.anything(),
+ ));
+ });
+
+ it('does not offer another add control once both supported providers are present', async () => {
+ mockGets();
+ renderPage();
+
+ await findTile('Telegram');
+ expect(tileNode('Channel')).toBeNull();
+ });
+
+ it('requests the credentialed Slack authorization URL and drops the opener', async () => {
+ const slackWindow = { location: { assign: jest.fn() }, close: jest.fn(), opener: window };
+ jest.mocked(window.open).mockReturnValue(slackWindow as unknown as Window);
+ mockGets([{
+ _id: 'i-slack-authorize', installationId: 'install-slack-u1', type: 'slack', status: 'pending',
+ config: { connectCode: 'a'.repeat(32), connectCodeExpiresAt: new Date(Date.now() + 60_000).toISOString() },
+ podId: { _id: 'p1', name: 'Rewire Live Demo' },
+ }]);
+ axios.post.mockResolvedValue({ data: { authorizeUrl: 'https://slack.test/oauth?state=secret' } });
+ renderPage();
+
+ const drawer = await openDrawer('Slack');
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Authorize in Slack' }));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
+ '/api/installables/slack/authorize-url',
+ {},
+ expect.objectContaining({ withCredentials: true }),
+ ));
+ expect(slackWindow.location.assign).toHaveBeenCalledWith('https://slack.test/oauth?state=secret');
+ expect(slackWindow.opener).toBeNull();
+ });
+
+ const slackAuthorize = () => ({
+ _id: 'i-slack-authorize', installationId: 'install-slack-u1', type: 'slack', status: 'pending',
+ config: {}, podId: { _id: 'p1', name: 'Rewire Live Demo' },
+ });
+
+ it('keeps an un-coded Slack authorization failure generic — the fallback (Row C control)', async () => {
+ mockGets([slackAuthorize()]);
+ axios.post.mockRejectedValue({ response: { status: 503 } });
+ renderPage();
+
+ const drawer = await openDrawer('Slack');
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Authorize in Slack' }));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
+ '/api/installables/slack/authorize-url',
+ {},
+ expect.objectContaining({ withCredentials: true }),
+ ));
+ // A refusal with no body still gets the generic sentence — the fallback the
+ // named codes must not have replaced.
+ const shown = await screen.findAllByText('Could not begin Slack authorization. Try again in a moment.');
+ expect(shown.length).toBeGreaterThan(0);
+ });
+
+ it('names a refused Slack authorize in the drawer that asked, not at the page foot (Row C)', async () => {
+ mockGets([slackAuthorize()]);
+ axios.post.mockRejectedValue({
+ response: { status: 409, data: { code: 'slack_already_authorized', error: 'Upstream wording that a known code must not surface.' } },
+ });
+ renderPage();
+
+ const drawer = await openDrawer('Slack');
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Authorize in Slack' }));
+ await waitFor(() => expect(axios.post).toHaveBeenCalled());
+
+ // The code's own copy, not the server sentence the same body also carries.
+ expect(await within(drawer).findByRole('alert')).toHaveTextContent('Slack is already awaiting confirmation or connected.');
+ expect(within(drawer).queryByText(/Upstream wording/)).toBeNull();
+ // Placement is the defect (Row C): the page-level slot used to be the only one.
+ expect(pageError()).toBeNull();
+ });
+
+ it('uses the server sentence when the refusal code is not one the page knows (Row C)', async () => {
+ mockGets([slackAuthorize()]);
+ axios.post.mockRejectedValue({
+ response: { status: 503, data: { code: 'slack_upstream_unavailable', error: 'Slack refused the handshake.' } },
+ });
+ renderPage();
+
+ const drawer = await openDrawer('Slack');
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Authorize in Slack' }));
+ await waitFor(() => expect(within(drawer).getByRole('alert')).toHaveTextContent('Slack refused the handshake.'));
+ });
+
+ it('names a failed Slack confirm in the drawer, not in the page slot (Row C)', async () => {
+ mockGets([slackPending(pendingBind)]);
+ axios.post.mockRejectedValue({
+ response: { status: 409, data: { code: 'slack_bind_expired', error: 'Upstream wording that a known code must not surface.' } },
+ });
+ renderPage();
+
+ const drawer = await openDrawer('Slack');
+ expect(within(drawer).getByText('Commonly HQ wants to connect as @sam.')).toBeInTheDocument();
+ expect(within(drawer).getByText('No agent is in this pod yet. Messages will reach the pod; choose a lead later.')).toBeInTheDocument();
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Confirm connection' }));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/installables/slack/confirm', {}, expect.anything()));
+
+ expect(await within(drawer).findByRole('alert')).toHaveTextContent('Slack authorization expired. Start again.');
+ expect(pageError()).toBeNull();
+ });
+
+ it('confirms a Slack connection from the drawer', async () => {
+ mockGets([slackPending(pendingBind)]);
+ axios.post.mockResolvedValue({ data: { status: 'connected' } });
+ renderPage();
+
+ const drawer = await openDrawer('Slack');
+ expect(within(drawer).getByText('Commonly HQ wants to connect as @sam.')).toBeInTheDocument();
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Confirm connection' }));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
+ '/api/installables/slack/confirm',
+ {},
+ expect.anything(),
+ ));
+ });
+
+ it('requires a pod agent at Slack bind and sends the selected lead', async () => {
+ mockGets([slackPending(pendingBind)], undefined, [{
+ _id: 'p1', name: 'Rewire Live Demo', type: 'chat',
+ members: [{ _id: 'agent-1', username: 'kai', isBot: true }],
+ }]);
+ axios.post.mockResolvedValue({ data: { status: 'connected' } });
+ renderPage();
+
+ const drawer = await openDrawer('Slack');
+ const confirm = within(drawer).getByRole('button', { name: 'Confirm connection' });
+ expect(confirm).toBeDisabled();
+ fireEvent.click(confirm);
+ expect(axios.post).not.toHaveBeenCalledWith('/api/installables/slack/confirm', expect.anything(), expect.anything());
+ fireEvent.change(within(drawer).getByLabelText('Agent for plain messages'), { target: { value: 'kai' } });
+ expect(confirm).toBeEnabled();
+ fireEvent.click(confirm);
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
+ '/api/installables/slack/confirm', { leadAgentUsername: 'kai' }, expect.anything(),
+ ));
+ });
+
+ it('does not treat a failed pod read as an empty pod at Slack confirmation', async () => {
+ mockGets([slackPending(pendingBind)], undefined, new Error('pods unavailable'));
+ renderPage();
+
+ const drawer = await openDrawer('Slack');
+ expect(within(drawer).getByText('Commonly HQ wants to connect as @sam.')).toBeInTheDocument();
+ expect(within(drawer).queryByText('No agent is in this pod yet. Messages will reach the pod; choose a lead later.')).toBeNull();
+ expect(within(drawer).getByRole('button', { name: 'Confirm connection' })).toBeDisabled();
+ });
+
+ it('keeps rejection reachable from the Slack drawer', async () => {
+ mockGets([slackPending(pendingBind)]);
+ axios.post.mockResolvedValue({ data: { status: 'rejected' } });
+ renderPage();
+
+ const drawer = await openDrawer('Slack');
+ fireEvent.click(within(drawer).getByRole('button', { name: 'This is not me' }));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
+ '/api/installables/slack/reject',
+ {},
+ expect.anything(),
+ ));
+ });
+
+ it('consumes an opaque Slack callback result without rendering its code', async () => {
+ window.history.replaceState({}, '', '/v2/connectors?slack=error&code=invalid_state');
+ mockGets([]);
+ renderPage();
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('Slack authorization didn’t complete — try again.');
+ expect(window.location.search).toBe('');
+ expect(screen.queryByText('invalid_state')).toBeNull();
+ });
+
+ it('shows a fixed unrequested-scope notice and consumes hostile callback query values', async () => {
+ window.history.replaceState(
+ {},
+ '',
+ '/v2/connectors?hostedMcp=error&code=unrequested_scope&extraScope=%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E&extraScope=spoofed',
+ );
+ mockGets([]);
+ renderPage();
+
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ "The provider granted access Commonly didn’t ask for, so the connection wasn’t saved.",
+ );
+ expect(window.location.search).toBe('');
+ expect(screen.queryByText(/img|spoofed/i)).toBeNull();
+ expect(screen.queryByText('unrequested_scope')).toBeNull();
+ });
+
+ it.each([
+ ['unknown_entry', 'hostedMcpUnknownEntry'],
+ ['invalid_state', 'hostedMcpInvalidState'],
+ ['state_expired', 'hostedMcpInvalidState'],
+ ['state_consumed', 'hostedMcpInvalidState'],
+ ['browser_mismatch', 'hostedMcpInvalidState'],
+ ['issuer_mismatch', 'hostedMcpInvalidState'],
+ ['consent_denied', 'hostedMcpConsentDenied'],
+ ['vendor_error', 'hostedMcpVendorError'],
+ ['rate_limited', 'hostedMcpRateLimited'],
+ ['issuer_unreachable', 'hostedMcpIssuerUnavailable'],
+ ['issuer_metadata_incomplete', 'hostedMcpIssuerUnavailable'],
+ ['client_not_configured', 'hostedMcpClientSetup'],
+ ['client_registration_required', 'hostedMcpClientSetup'],
+ ['client_registration_unsupported', 'hostedMcpClientSetup'],
+ ['client_registration_refused', 'hostedMcpClientSetup'],
+ ['client_auth_unsupported', 'hostedMcpClientSetup'],
+ ['client_auth_refused', 'hostedMcpClientSetup'],
+ ['authorization_parameter_conflict', 'hostedMcpClientSetup'],
+ ['exchange_refused', 'hostedMcpExchangeRefused'],
+ ['exchange_unreachable', 'hostedMcpExchangeUnreachable'],
+ ['exchange_incomplete', 'hostedMcpExchangeIncomplete'],
+ ['exchange_invalid_scope', 'hostedMcpExchangeIncomplete'],
+ ['unrequested_scope', 'hostedMcpUnrequestedScope'],
+ ['authentication_required', 'hostedMcpAuthenticationRequired'],
+ ])('shows a fixed notice for hosted-MCP code %s and consumes the query', async (code, noticeKey) => {
+ window.history.replaceState({}, '', `/v2/connectors?hostedMcp=error&code=${code}&error_description=spoofed`);
+ mockGets([]);
+ renderPage();
+
+ expect(await screen.findByRole('alert')).toHaveTextContent(en.connectors[noticeKey]);
+ expect(window.location.search).toBe('');
+ expect(screen.queryByText(/spoofed/)).toBeNull();
+ });
+
+ it('reloads connectors after a successful Slack callback and clears its query', async () => {
+ window.history.replaceState({}, '', '/v2/connectors?slack=pending');
+ mockGets([]);
+ renderPage();
+
+ await waitFor(() => expect(
+ axios.get.mock.calls.filter(([url]) => url === '/api/integrations/user/all').length,
+ ).toBeGreaterThanOrEqual(2));
+ expect(window.location.search).toBe('');
+ });
+
+ // The manual-revoke notice rides the tool's reveal (its popover while nothing is
+ // granted), so the person opens the Linear tile to read it.
+ const connectedHosted = (id, entryId, revokePage) => ([{
+ _id: id, type: 'hosted-mcp', status: 'connected', config: { entryId, revokePage },
+ }]);
+ const hostedEntry = (id, installableId, label) => ({
+ ...hostedLinearCatalogEntry(id), installableId, label, entryId: installableId,
+ });
+ const callbackUrl = (entryId, revokePage) => (
+ `/v2/connectors?hostedMcp=connected&entryId=${entryId}&revokeAt=${encodeURIComponent(revokePage)}`
+ );
+
+ it('shows the server-owned provider revoke page after a hosted-MCP reconnect needs manual withdrawal', async () => {
+ const revokePage = 'https://linear.app/settings/security';
+ window.history.replaceState({}, '', callbackUrl('linear', revokePage));
+ mockGets(connectedHosted('i-linear', 'linear', revokePage), [hostedLinearCatalogEntry()]);
+ renderPage();
+
+ const popover = await openPopover('Linear');
+ const reviewLink = await within(popover).findByRole('link', { name: "Open Linear's connected apps" });
+ expect(reviewLink).toHaveAttribute('href', revokePage);
+ expect(reviewLink).toHaveAttribute('target', '_blank');
+ expect(reviewLink).toHaveAttribute('rel', 'noopener noreferrer');
+ const notice = within(popover).getByRole('status');
+ expect(notice).toHaveTextContent(
+ 'Your previous Linear account may still have an MCP grant connected. Switch to it in Linear, then revoke the MCP grant under Linear\'s connected apps.',
+ );
+ expect(window.location.search).toBe('');
+ });
+
+ it('the manual-revoke notice tells the person to switch to the account they connected before', async () => {
+ const revokePage = 'https://linear.app/settings/security';
+ window.history.replaceState({}, '', callbackUrl('linear', revokePage));
+ mockGets(connectedHosted('i-linear', 'linear', revokePage), [hostedLinearCatalogEntry()]);
+ await i18nReady;
+ await act(async () => { await i18n.changeLanguage('zh-CN'); });
+ try {
+ renderPage();
+
+ const popover = await openPopover('Linear');
+ const reviewLink = await within(popover).findByRole('link', { name: '打开 Linear 的已连接应用' });
+ expect(reviewLink).toHaveAttribute('href', revokePage);
+ expect(within(popover).getByRole('status')).toHaveTextContent(
+ '你之前的 Linear 账号可能仍连着一个 MCP 授权。请在 Linear 切换到那个账号,再在其已连接应用中撤销该 MCP 授权。',
+ );
+ expect(window.location.search).toBe('');
+ } finally {
+ await act(async () => { await i18n.changeLanguage('en'); });
+ }
+ });
+
+ it('warns that removing Supabase authorization disconnects everyone in the organization', async () => {
+ const revokePage = 'https://supabase.com/dashboard/org/_/apps';
+ window.history.replaceState({}, '', callbackUrl('supabase', revokePage));
+ mockGets(connectedHosted('i-supabase', 'supabase', revokePage), [hostedEntry('i-supabase', 'supabase', 'Supabase')]);
+ renderPage();
+
+ const popover = await openPopover('Supabase');
+ const reviewLink = await within(popover).findByRole('link', { name: 'Open Supabase Authorized apps' });
+ expect(reviewLink).toHaveAttribute('href', revokePage);
+ expect(within(popover).getByRole('status')).toHaveTextContent(
+ 'Removing Commonly under Authorized apps disconnects everyone who connected that organization.',
+ );
+ expect(within(popover).getByRole('status')).not.toHaveTextContent('remove Commonly there');
+ });
+
+ it('warns that revoking the Sentry MCP app ends every client that server holds, and does not name Commonly', async () => {
+ const revokePage = 'https://sentry.io/settings/account/api/authorizations/';
+ window.history.replaceState({}, '', callbackUrl('sentry', revokePage));
+ mockGets(connectedHosted('i-sentry', 'sentry', revokePage), [hostedEntry('i-sentry', 'sentry', 'Sentry')]);
+ renderPage();
+
+ const popover = await openPopover('Sentry');
+ const reviewLink = await within(popover).findByRole('link', { name: "Open Sentry's connected apps" });
+ expect(reviewLink).toHaveAttribute('href', revokePage);
+ expect(within(popover).getByRole('status')).toHaveTextContent(
+ 'Revoking it ends every client that server holds for the account, not only Commonly.',
+ );
+ expect(within(popover).getByRole('status')).not.toHaveTextContent('remove Commonly there');
+ });
+
+ it('keeps the generic manual-revoke copy for an entry without its own', async () => {
+ const revokePage = 'https://airtable.com/account/security';
+ window.history.replaceState({}, '', callbackUrl('airtable', revokePage));
+ mockGets(connectedHosted('i-airtable', 'airtable', revokePage), [hostedEntry('i-airtable', 'airtable', 'Airtable')]);
+ renderPage();
+
+ const popover = await openPopover('Airtable');
+ expect(await within(popover).findByRole('link', { name: "Open Airtable's connected apps" })).toHaveAttribute('href', revokePage);
+ expect(within(popover).getByRole('status')).toHaveTextContent(
+ 'Your previous Airtable account may still have Commonly connected. Switch to it on Airtable, then remove Commonly there.',
+ );
+ });
+
+ it('does not turn a callback query into a provider link unless it matches the connection', async () => {
+ window.history.replaceState(
+ {},
+ '',
+ '/v2/connectors?hostedMcp=connected&entryId=linear&revokeAt=https%3A%2F%2Fevil.example%2F',
+ );
+ mockGets(connectedHosted('i-linear', 'linear', 'https://linear.app/settings/security'), [hostedLinearCatalogEntry()]);
+ renderPage();
+
+ const popover = await openPopover('Linear');
+ expect(within(popover).getByText('Read Linear issues and projects.')).toBeInTheDocument();
+ await waitFor(() => expect(window.location.search).toBe(''));
+ expect(screen.queryByRole('link', { name: "Open Linear's connected apps" })).toBeNull();
+ });
+
+ it('shows the error reconnect action and retains the separate removal action', async () => {
+ mockGets([{
+ _id: 'i-slack-error', installationId: 'install-slack-u1', type: 'slack', status: 'error',
+ config: { chatTitle: 'Commonly HQ', liveRelay: true },
+ podId: { _id: 'p1', name: 'Rewire Live Demo' },
+ }]);
+ axios.post.mockRejectedValue({ response: { status: 503 } });
+ renderPage();
+
+ const drawer = await openDrawer('Slack');
+ expect(within(drawer).getByText('The connection dropped.')).toBeInTheDocument();
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Authorize in Slack' }));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
+ '/api/installables/slack/authorize-url',
+ {},
+ expect.objectContaining({ withCredentials: true }),
+ ));
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Remove' }));
+ expect(within(drawer).getByRole('button', { name: /Really remove/ })).toBeInTheDocument();
+ });
+
+ it('names the reason a connector needs attention when the server sent one', async () => {
+ // The sentence is the only place a person sees why relaying stopped. A classified
+ // permanent failure writes `errorMessage`; the generic sentence is the fallback
+ // for everything else, not a replacement for it.
+ const reason = 'Telegram stopped delivering: the bot was blocked or removed from this chat.';
+ mockGets([{
+ _id: 'i-tg-error', installationId: 'install-tg-u1', type: 'telegram', status: 'error', errorMessage: reason,
+ // The flag is what makes the message readable to a person: it is set by
+ // connectorDeliveryFailureService, the writer that owns the pair.
+ errorMessageUserFacing: true,
+ config: { chatTitle: 'Ops chat', liveRelay: true },
+ podId: { _id: 'p1', name: 'Rewire Live Demo' },
+ }]);
+ renderPage();
+
+ await findTile('Telegram');
+ expect(dot(reason)).toBeInTheDocument();
+ const drawer = await openDrawer('Telegram');
+ expect(within(drawer).getByText(reason)).toBeInTheDocument();
+ expect(screen.queryByText('The connection dropped.')).toBeNull();
+ });
+
+ it('does not print a provider error text for a channel, only a message written for a person', async () => {
+ // `Integration.errorMessage` has an older writer: externalFeedService copies a
+ // provider's response, or a raw exception message, into it for the `x` and
+ // `instagram` rows. Those rows reach this same error branch, so a value in the
+ // field is not permission to render it — `errorMessageUserFacing` is, and it is
+ // absent here. The drawer still shows the generic sentence.
+ const raw = 'connect ECONNREFUSED 10.4.4.7:443';
+ mockGets([{
+ _id: 'i-x-error', installationId: 'install-x-u1', type: 'x', status: 'error', errorMessage: raw,
+ config: { chatTitle: 'Feed' },
+ podId: { _id: 'p1', name: 'Rewire Live Demo' },
+ }]);
+ renderPage();
+
+ const drawer = await openDrawer('X');
+ expect(within(drawer).getByText('The connection dropped.')).toBeInTheDocument();
+ expect(screen.queryByText(raw)).toBeNull();
+ expect(screen.queryByRole('img', { name: raw })).toBeNull();
+ });
+
+ it('derives the installable lifecycle target from the connector type', () => {
+ expect(installableLifecyclePath('slack')).toBe('/api/installables/slack/install');
+ });
+
+ // D8 Phase 2: tiles keyed by the capability catalog (D1/D2), the panel's gate
+ // list (D4), and the not-linked state (#1551).
+ describe('catalog tiles', () => {
+ const entry = (over = {}) => ({
+ installableId: 'telegram',
+ label: 'Telegram',
+ // Mirror of the provider manifest's copy, which the server sends as the
+ // entry's description. The source pin is backend manifestReadiness.
+ description: 'Link your Telegram chat to Commonly — every pod you turn on reaches it.',
+ available: true,
+ installation: null,
+ integration: null,
+ ...over,
+ });
+ const liveIntegration = (over = {}) => ({
+ _id: 'i-live',
+ installationId: 'install-slack-u1',
+ type: 'slack',
+ status: 'connected',
+ scope: 'user',
+ isActive: true,
+ createdAt: new Date(Date.now() - 3_600_000).toISOString(),
+ updatedAt: new Date().toISOString(),
+ config: { chatTitle: 'Rewire crew', liveRelay: true, gates: { p1: { enabled: true, since: '2026-09-01T00:00:00.000Z' } } },
+ podId: 'p1',
+ ...over,
+ });
+ const slackEntry = (over = {}) => entry({
+ installableId: 'slack',
+ label: 'Slack',
+ installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] },
+ integration: liveIntegration(),
+ ...over,
+ });
+ const mockCatalog = (installables, list = [], pods = [
+ { _id: 'p1', name: 'Rewire Live Demo', type: 'chat', members: [{ _id: 'b1', username: 'vale', isBot: true }] },
+ { _id: 'p2', name: 'Ops', type: 'team', members: [] },
+ ]) => mockApi({ connectors: list, installables, pods });
+
+ // TASK-155. The walk found that adding a channel with a live channel open left
+ // the panel on that channel: the form rendered beside it instead, so the picker
+ // appeared somewhere other than the panel the page uses for the thing you are doing.
+ it('TASK-155: adding a channel moves the drawer to the picker', async () => {
+ mockCatalog([entry(), slackEntry({ installation: { status: 'active', boundPodId: 'p1' } })]);
+ renderPage();
+ const slack = await openDrawer('Slack');
+ expect(within(slack).getByText('What the channel sees')).toBeInTheDocument();
+
+ const popover = await openPopover('Telegram');
+ fireEvent.click(within(popover).getByRole('button', { name: 'Add' }));
+
+ const form = await findDrawer('Connect a channel');
+ expect(within(form).getByLabelText('Pod to bridge')).toBeInTheDocument();
+ expect(screen.queryByText('What the channel sees')).toBeNull();
+ // One form, not two.
+ expect(screen.getAllByLabelText('Pod to bridge')).toHaveLength(1);
+ });
+
+ it('TASK-155: the add tile moves the drawer while a channel is open', async () => {
+ mockCatalog([entry(), slackEntry({ installation: { status: 'active', boundPodId: 'p1' } })]);
+ renderPage();
+ await openDrawer('Slack');
+
+ fireEvent.click(await findTile('Channel'));
+
+ const form = await findDrawer('Connect a channel');
+ expect(within(form).getByLabelText('Pod to bridge')).toBeInTheDocument();
+ expect(screen.getAllByLabelText('Pod to bridge')).toHaveLength(1);
+ expect(screen.queryByText('What the channel sees')).toBeNull();
+ });
+
+ it('TASK-155: opening a channel while the form is open closes the form', async () => {
+ mockCatalog([entry(), slackEntry({ installation: { status: 'active', boundPodId: 'p1' } })]);
+ renderPage();
+ fireEvent.click(await findTile('Channel'));
+ expect(within(await findDrawer('Connect a channel')).getByLabelText('Pod to bridge')).toBeInTheDocument();
+
+ fireEvent.click(await findTile('Slack'));
+
+ // The form wins the drawer while it is open, so the tile click has to close
+ // it — otherwise this is the walk's symptom pointed the other way
+ // (connector-ops 74623 item 1).
+ expect(within(await findDrawer('Slack')).getByText('What the channel sees')).toBeInTheDocument();
+ expect(screen.queryByLabelText('Pod to bridge')).toBeNull();
+ });
+
+ it('TASK-155: the picker takes focus when the form opens', async () => {
+ mockCatalog([entry()]);
+ renderPage();
+
+ fireEvent.click(await findTile('Channel'));
+
+ // At 390 the panel is below the fold of the page's scroller; focus is what
+ // scrolls the picker into view (connector-ops 74623 item 2).
+ expect(await screen.findByLabelText('Pod to bridge')).toHaveFocus();
+ });
+
+ it('TASK-155: the picker defaults to a room, never a personal pod', async () => {
+ mockCatalog([entry()], [], [
+ { _id: 'p-scout', name: 'Scout (Default)', type: 'agent-room', members: [] },
+ { _id: 'p1', name: 'Rewire Live Demo', type: 'chat', members: [] },
+ { _id: 'p2', name: 'Ops', type: 'team', members: [] },
+ ]);
+ renderPage();
+
+ fireEvent.click(await findTile('Channel'));
+
+ const picker = (await screen.findByLabelText('Pod to bridge')) as HTMLSelectElement;
+ expect(picker.value).toBe('p1');
+ // A DM stays selectable: it is a legal target, just never the default.
+ expect(Array.from(picker.options).map((option) => option.textContent)).toContain('Scout (Default)');
+ });
+
+ it('counts connected channels in the page head, not catalogue options', async () => {
+ mockCatalog([entry(), slackEntry({ installation: { status: 'active', boundPodId: 'p1' } })]);
+ renderPage();
+
+ await findTile('Slack');
+ expect(document.querySelector('.v2-cx-head__meta')).toHaveTextContent('1 on · 1 available');
+ });
+
+ it('counts nothing as on when the catalogue has no live channels', async () => {
+ mockCatalog([entry(), entry({ installableId: 'discord', label: 'Discord' })]);
+ renderPage();
+
+ await findTile('Telegram');
+ expect(document.querySelector('.v2-cx-head__meta')).toHaveTextContent('0 on · 2 available');
+ });
+
+ it('TASK-131: a catalog failure prints the generic sentence, not the raw exception that wrote the row', async () => {
+ // The catalogue half reads `InstallableInstallation.errorMessage`, whose
+ // projection-failure writer stores `error.message` verbatim. Same rule as the
+ // pod-scoped half: a value in the field is not permission to render it, and
+ // the flag is absent on this fixture.
+ const raw = 'connect ECONNREFUSED 10.4.4.7:443';
+ mockCatalog([entry({ installation: { status: 'error', errorMessage: raw } })]);
+ renderPage();
+
+ const drawer = await openDrawer('Telegram');
+ expect(within(drawer).getByText('Setup didn’t finish.')).toBeInTheDocument();
+ expect(screen.queryByText(raw)).toBeNull();
+ });
+
+ it('TASK-131: a catalog message written for a person still renders verbatim', async () => {
+ // The reconciler's own reasons are written for the person reading this tile,
+ // and the flag the builder sets beside them is what keeps them readable once
+ // the generic sentence became the fallback.
+ mockCatalog([entry({ installation: { status: 'error', errorMessage: REASON_CHANNEL_GONE, errorMessageUserFacing: true } })]);
+ renderPage();
+
+ const drawer = await openDrawer('Telegram');
+ expect(within(drawer).getByText(REASON_CHANNEL_GONE)).toBeInTheDocument();
+ expect(screen.queryByText('Setup didn’t finish.')).toBeNull();
+ });
+
+ // Tools plan (option A, two lists): a tool Installable shares the catalogue
+ // response but belongs to the Tools group, never to Channels.
+ it('a tool Installable in the catalogue never renders as a channel tile', async () => {
+ mockCatalog([
+ {
+ installableId: 'github',
+ list: 'tools',
+ label: 'GitHub',
+ description: 'Issues and pull requests.',
+ available: true,
+ broker: { id: 'commonly-grant-broker' },
+ tools: [{ name: 'github.list_issues', requiredWriteMode: 'read', irreversible: false }],
+ connections: [],
+ installation: null,
+ integration: null,
+ },
+ entry({ list: 'channels' }),
+ ]);
+ renderPage();
+
+ await findTile('Telegram');
+ const channels = screen.getByRole('region', { name: 'Channels' });
+ const tools = screen.getByRole('region', { name: 'Tools' });
+ expect(channels.querySelector('button.v2-cx-tile[title="GitHub"]')).toBeNull();
+ expect(tools.querySelector('button.v2-cx-tile[title="GitHub"]')).not.toBeNull();
+ expect(channels.querySelector('button.v2-cx-tile[title="Telegram"]')).not.toBeNull();
+ expect(tools.querySelector('button.v2-cx-tile[title="Telegram"]')).toBeNull();
+ });
+
+ it('keeps tool-only connections out of Channels and refuses an unknown type as a tile title', async () => {
+ mockCatalog([entry()], [
+ {
+ // Real hosted-MCP rows have no installationId; filter them by type.
+ _id: 'i-hosted-linear', type: 'hosted-mcp', status: 'connected',
+ config: { entryId: 'linear' },
+ },
+ {
+ _id: 'i-github-app', installationId: 'github-install', type: 'github-app', status: 'connected',
+ },
+ {
+ _id: 'i-unknown-channel', type: 'custom-messaging-platform', status: 'connected',
+ },
+ ]);
+ renderPage();
+
+ await findTile('Telegram');
+ const channels = screen.getByRole('region', { name: 'Channels' });
+ const titles = Array.from(channels.querySelectorAll('button.v2-cx-tile')).map((tile) => tile.getAttribute('title'));
+ // Telegram, and the add tile for the one provider still to connect. No tile
+ // for a hosted tool, the GitHub App or an unmapped type.
+ expect(titles).toEqual(['Telegram', 'Channel']);
+ for (const text of ['hosted-mcp', 'github-app', 'Hosted tool', 'GitHub App', 'custom-messaging-platform', 'Custom Messaging Platform']) {
+ expect(screen.queryByText(text)).toBeNull();
+ expect(screen.queryByTitle(text)).toBeNull();
+ }
+ });
+
+ // TASK-024. Discord is a shipping connector (routes/discord.ts: install link,
+ // callback, binding, uninstall) that read as "we don't build this" because its
+ // manifest declared no readiness(), which is what the catalog filters on. Once
+ // it declares one the catalog owns every claim about it: configured -> a
+ // connectable tile, not configured -> the not-enabled tile that already exists
+ // for slack.
+ const glyphPath = (type: string): string | null => {
+ const { container } = renderPlatformGlyph(type);
+ return container.querySelector('svg path')?.getAttribute('d') || null;
+ };
+ const renderPlatformGlyph = (type: string) => {
+ // eslint-disable-next-line global-require
+ const { render } = require('@testing-library/react');
+ return render();
+ };
+
+ it('TASK-338: not-offered channels fold under "+N off", below the live tiles, collapsed', async () => {
+ mockCatalog([
+ entry({ installableId: 'slack', label: 'Slack', available: false, unavailableReason: 'not_configured' }),
+ entry({
+ installableId: 'telegram',
+ label: 'Telegram',
+ available: true,
+ installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] },
+ integration: liveIntegration({ _id: 'i-tg', installationId: 'install-telegram-u1', type: 'telegram' }),
+ }),
+ ]);
+ renderPage();
+ await findTile('Telegram');
+ const channels = screen.getByRole('region', { name: 'Channels' });
+ const off = within(channels).getByRole('button', { name: '+1 off' });
+ expect(off).toHaveAttribute('aria-expanded', 'false');
+ expect(tileNode('Slack')).toBeNull();
+ expect(channels.querySelector('button.v2-cx-tile')).toHaveAttribute('title', 'Telegram');
+ fireEvent.click(off);
+ expect(off).toHaveAttribute('aria-expanded', 'true');
+ expect(tileNode('Slack')).toHaveClass('v2-cx-tile--dim');
+ });
+
+ it('renders the zh-CN catalog copy for a channel and the server text for an unmapped one', async () => {
+ mockCatalog([
+ entry({ list: 'channels' }),
+ entry({ installableId: 'acme-chat', label: 'Acme Chat', description: 'Acme chat server copy.' }),
+ ]);
+ await i18nReady;
+ await act(async () => { await i18n.changeLanguage('zh-CN'); });
+ try {
+ renderPage();
+ const telegram = await openPopover('Telegram');
+ expect(within(telegram).getByText('把你的 Telegram 聊天接入 Commonly,你开启的每个 Pod 都能把消息发到这里。')).toBeInTheDocument();
+ // `Acme Chat` has no mapped label, so it is not a Channels tile; the unmapped
+ // description is the server's own and is not asserted through a tile here.
+ } finally {
+ await act(async () => { await i18n.changeLanguage('en'); });
+ }
+ });
+
+ it('describes Discord only through the catalog, never as a provider we have not built', async () => {
+ mockCatalog([
+ entry({ installableId: 'discord', label: 'Discord', available: false, unavailableReason: 'not_configured' }),
+ ]);
+ renderPage();
+
+ await pageReady();
+ await expandOff('Channels');
+ const tile = await findTile('Discord');
+ expect(tile).toHaveClass('v2-cx-tile--dim');
+ expect(tile.textContent).toContain('Discord');
+ expect(tile.querySelector('svg')).not.toBeNull();
+ const popover = await openPopover('Discord');
+ expect(within(popover).getByText('ask your operator')).toBeInTheDocument();
+ expect(screen.queryByText(/Tell us which channel/)).toBeNull();
+ });
+
+ it('offers a rostered provider with Add, and an unmapped one inherits no onboarding sentence', async () => {
+ // Slack's line is in the map; Discord's is not. The point of the map is that
+ // Discord renders NO sentence rather than Slack's.
+ mockCatalog([
+ entry({ installableId: 'discord', label: 'Discord', available: true, offered: true }),
+ entry({ installableId: 'slack', label: 'Slack', available: true, offered: true }),
+ ]);
+ renderPage();
+
+ const discord = await openPopover('Discord');
+ expect(discord.textContent).not.toContain('one click in your workspace');
+ expect(discord.textContent).not.toContain('one message');
+ expect(within(discord).getByRole('button', { name: 'Add' })).toBeInTheDocument();
+
+ const slack = await openPopover('Slack');
+ expect(slack.textContent).toContain('one click in your workspace');
+
+ expect(screen.queryByText(/Tell us which channel/)).toBeNull();
+ });
+
+ it('does not list a provider this instance does not offer (D215)', async () => {
+ // Discord on commonly.me: usable keys, no builtin Installable row, never
+ // walked. A stranger must not see it, not even behind "+N off".
+ mockCatalog([
+ entry(),
+ entry({ installableId: 'discord', label: 'Discord', available: true, offered: false }),
+ ]);
+ renderPage();
+
+ await findTile('Telegram');
+ // The off button only exists when a dead tile does, so its absence is the
+ // assertion; a collapsed group would hide a listed Discord from the query.
+ expect(screen.queryByRole('button', { name: /^\+\d+ off$/ })).toBeNull();
+ expect(tileNode('Discord')).toBeNull();
+ });
+
+ it('keeps an installed but no-longer-offered provider as not connectable, with no Add anywhere', async () => {
+ // The ruled middle state, reachable only by someone who installed it before
+ // the roster row went away: it states the state and offers no action. Before
+ // #1826 the row rendered an Add whose click ended in 404 installable_not_found.
+ mockCatalog([
+ entry({ installableId: 'discord', label: 'Discord', available: true, offered: false, installation: { status: 'active', boundPodId: 'p1' } }),
+ ]);
+ renderPage();
+
+ await pageReady();
+ await expandOff('Channels');
+ const tile = await findTile('Discord');
+ expect(tile).toHaveClass('v2-cx-tile--dim');
+ expect(tile.querySelector('svg')).not.toBeNull();
+ fireEvent.click(tile);
+ const drawer = await findDrawer('Discord');
+ expect(within(drawer).getByText('Unavailable here.')).toBeInTheDocument();
+ // No Add and no connect verb: an offered provider is the only thing that may
+ // produce them, and there is none in this catalog.
+ expect(within(drawer).queryByRole('button', { name: /^(Add|Connect|Retry)$/ })).toBeNull();
+ expect(screen.queryByRole('button', { name: 'Add' })).toBeNull();
+ expect(tileNode('Channel')).toBeNull();
+ expect(screen.queryByText(/Tell us which channel/)).toBeNull();
+ });
+
+ it('keeps a no-longer-offered provider listed when the person has only an integration row', async () => {
+ // The third arm of the D215 filter: a channel connected before the roster row
+ // went away can leave an integration with no installation. Dropping it from
+ // the list would hide a live connection from its owner.
+ mockCatalog([
+ entry({ installableId: 'discord', label: 'Discord', available: true, offered: false, integration: liveIntegration() }),
+ ]);
+ renderPage();
+
+ await pageReady();
+ await expandOff('Channels');
+ expect(await findTile('Discord')).toHaveClass('v2-cx-tile--dim');
+ });
+
+ it('renders an unavailable provider with its blocker and an available one with Add', async () => {
+ mockCatalog([
+ entry(),
+ entry({ installableId: 'slack', label: 'Slack', available: false, unavailableReason: 'not_configured' }),
+ ]);
+ axios.post.mockResolvedValue({ data: { status: 'installing' } });
+ renderPage();
+
+ await pageReady();
+ await expandOff('Channels');
+ const slack = await openPopover('Slack');
+ // Each unconnectable provider gets its own state line; an offered provider is
+ // the only thing that may draw Add.
+ expect(within(slack).getByText('ask your operator')).toBeInTheDocument();
+ expect(within(slack).getByText('Link your Telegram chat to Commonly — every pod you turn on reaches it.')).toBeInTheDocument();
+ expect(screen.queryByText('not_configured')).toBeNull();
+ const ask = within(slack).getByRole('link', { name: 'Ask' });
+ expect(ask).toHaveAttribute('href', 'https://github.com/Team-Commonly/commonly/issues/new?title=Connector%20request');
+ expect(ask).toHaveClass('v2-connector-row__action--secondary');
+ expect(within(slack).queryByRole('button', { name: 'Add' })).toBeNull();
+
+ const telegram = await openPopover('Telegram');
+ const choosePod = within(telegram).getAllByRole('button', { name: 'Add' });
+ expect(choosePod).toHaveLength(1);
+ fireEvent.click(choosePod[0]);
+ const form = await findDrawer('Connect a channel');
+ expect(within(form).getByRole('button', { name: 'Telegram' })).toHaveClass('v2-connectors__provider--selected');
+ expect(within(form).queryByRole('button', { name: 'Slack' })).toBeNull();
+ fireEvent.click(form.querySelector('.v2-connectors__create'));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
+ '/api/installables/telegram/install',
+ { podId: 'p1' },
+ expect.anything(),
+ ));
+ });
+
+ it('labels the popover step separately from the final connect action', async () => {
+ mockCatalog([entry()]);
+ renderPage();
+
+ const popover = await openPopover('Telegram');
+ const choosePod = within(popover).getByRole('button', { name: 'Add' });
+ expect(screen.queryByRole('button', { name: 'Connect' })).toBeNull();
+
+ fireEvent.click(choosePod);
+ const form = await findDrawer('Connect a channel');
+ expect(within(form).getByLabelText('Pod to bridge')).toBeInTheDocument();
+ expect(within(form).getByRole('button', { name: 'Connect' })).toBeInTheDocument();
+ });
+
+ it('gives a zero-pod owner a path to create a pod before connecting', async () => {
+ mockCatalog([entry()], [], []);
+ renderWithRoutes(
+
+ } />
+ pods list} />
+ ,
+ );
+
+ fireEvent.click(await findTile('Channel'));
+ const drawer = await findDrawer('Connect a channel');
+ expect(within(drawer).queryByLabelText('Pod to bridge')).toBeNull();
+ expect(within(drawer).queryByRole('group', { name: 'Channel provider' })).toBeNull();
+ expect(within(drawer).getByText('No pod yet. Create one, then come back to connect a channel.')).toBeInTheDocument();
+ expect(screen.queryByText('Choose a channel and the pod it should join.')).toBeNull();
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Create a pod' }));
+ expect(await screen.findByText('pods list')).toBeInTheDocument();
+ });
+
+ it('keeps the existing picker visible while pod membership is still loading', async () => {
+ mockCatalog([entry()], [], 'pending');
+ renderPage();
+
+ fireEvent.click(await findTile('Channel'));
+ const drawer = await findDrawer('Connect a channel');
+ expect(within(drawer).getByRole('group', { name: 'Channel provider' })).toBeInTheDocument();
+ expect(within(drawer).getByLabelText('Pod to bridge')).toBeInTheDocument();
+ expect(within(drawer).queryByText('No pod yet. Create one, then come back to connect a channel.')).toBeNull();
+ expect(within(drawer).queryByRole('button', { name: 'Create a pod' })).toBeNull();
+ });
+
+ it('keeps the existing picker visible when pod membership cannot be read', async () => {
+ mockCatalog([entry()], [], new Error('pods unavailable'));
+ renderPage();
+
+ fireEvent.click(await findTile('Channel'));
+ const drawer = await findDrawer('Connect a channel');
+ expect(within(drawer).getByRole('group', { name: 'Channel provider' })).toBeInTheDocument();
+ expect(within(drawer).getByLabelText('Pod to bridge')).toBeInTheDocument();
+ expect(within(drawer).queryByText('No pod yet. Create one, then come back to connect a channel.')).toBeNull();
+ expect(within(drawer).queryByRole('button', { name: 'Create a pod' })).toBeNull();
+ });
+
+ it('shows Setting up… without a control while the claim is fresh, and Cancel once it is stale', async () => {
+ mockCatalog([entry({ installation: { status: 'installing', claimedAt: new Date().toISOString(), components: [] } })]);
+ const fresh = renderPage();
+ let drawer = await openDrawer('Telegram');
+ expect(within(drawer).getByText('Setting up…')).toBeInTheDocument();
+ expect(within(drawer).queryByRole('button', { name: 'Cancel' })).toBeNull();
+ fresh.unmount();
+
+ mockCatalog([entry({
+ installation: { status: 'installing', claimedAt: new Date(Date.now() - INSTALL_LOCK_TTL_MS - 1000).toISOString(), components: [] },
+ })]);
+ axios.delete.mockResolvedValue({ data: { status: 'uninstalling' } });
+ renderPage();
+ drawer = await openDrawer('Telegram');
+ expect(within(drawer).getByText('Setup is taking longer than it should.')).toBeInTheDocument();
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Cancel' }));
+ await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/installables/telegram/install', expect.anything()));
+ });
+
+ it('offers Retry on an error parent, posting the bound pod, and Remove in the drawer', async () => {
+ mockCatalog([entry({
+ // The reconciler's own reason, and since TASK-131 it travels with the flag
+ // that says so: the line renders a message only when a writer declared it
+ // was written for a person.
+ installation: {
+ status: 'error',
+ errorMessage: REASON_CHANNEL_GONE,
+ errorMessageUserFacing: true,
+ boundPodId: 'p2',
+ updatedAt: new Date().toISOString(),
+ components: [],
+ },
+ })]);
+ axios.post.mockResolvedValue({ data: { status: 'installing' } });
+ axios.delete.mockResolvedValue({ data: { status: 'uninstalled' } });
+ renderPage();
+
+ const drawer = await openDrawer('Telegram');
+ expect(within(drawer).getByText(REASON_CHANNEL_GONE)).toBeInTheDocument();
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Retry' }));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
+ '/api/installables/telegram/install',
+ { podId: 'p2' },
+ expect.anything(),
+ ));
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Remove' }));
+ fireEvent.click(within(drawer).getByRole('button', { name: /Really remove/ }));
+ await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/installables/telegram/install', expect.anything()));
+ });
+
+ it('offers Retry remove once an uninstalling claim is stale', async () => {
+ mockCatalog([entry({
+ installation: { status: 'uninstalling', claimedAt: new Date(Date.now() - INSTALL_LOCK_TTL_MS - 1000).toISOString(), components: [] },
+ })]);
+ axios.delete.mockResolvedValue({ data: { status: 'uninstalling' } });
+ renderPage();
+
+ const drawer = await openDrawer('Telegram');
+ expect(within(drawer).getByText('Removal is taking longer than it should.')).toBeInTheDocument();
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Retry remove' }));
+ await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/installables/telegram/install', expect.anything()));
+ });
+
+ it('shows a paused parent with its reason and no owner control', async () => {
+ mockCatalog([slackEntry({
+ installation: { status: 'paused', updatedAt: new Date().toISOString(), components: [] },
+ integration: liveIntegration({ config: { chatTitle: 'Rewire crew', liveRelay: true, adminPause: { reason: 'Spam report under review.', at: new Date(Date.now() - 120_000).toISOString() } } }),
+ })]);
+ renderPage();
+
+ const drawer = await openDrawer('Slack');
+ expect(within(drawer).getByText('Paused by an administrator. Spam report under review.')).toBeInTheDocument();
+ expect(within(drawer).getByText('Only an administrator can resume it. Your relay, mode and pods are kept.')).toBeInTheDocument();
+ expect(within(drawer).queryByRole('button', { name: 'Remove' })).toBeNull();
+ expect(within(drawer).queryByRole('button', { name: 'Retry' })).toBeNull();
+ expect(within(drawer).queryByRole('checkbox', { name: 'Relay' })).toBeNull();
+ });
+
+ it('treats a missing projection under an active parent as a Retry state', async () => {
+ mockCatalog([entry({ installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] }, integration: null })]);
+ axios.post.mockResolvedValue({ data: { status: 'installing' } });
+ renderPage();
+
+ const drawer = await openDrawer('Telegram');
+ expect(within(drawer).getByText('The channel record is gone.')).toBeInTheDocument();
+ expect(within(drawer).getByRole('button', { name: 'Retry' })).toBeInTheDocument();
+ });
+
+ it('renders the not-linked channel with its pod list open and Make active', async () => {
+ mockCatalog([slackEntry({
+ integration: liveIntegration({ podId: null, config: { chatTitle: 'Rewire crew', liveRelay: true, gates: {} } }),
+ })]);
+ axios.patch.mockResolvedValue({ data: {} });
+ renderPage();
+
+ await findTile('Slack');
+ expect(dot('Rewire crew · not linked to a pod')).toBeInTheDocument();
+ const drawer = await openDrawer('Slack');
+ // An unlinked channel has no active pod, so the list opens already expanded.
+ expect(within(drawer).getByText('Pick where your messages go')).toBeInTheDocument();
+ expect(within(drawer).queryByText('active')).toBeNull();
+ const makeActive = within(drawer).getAllByRole('button', { name: 'Make active' });
+ expect(makeActive).toHaveLength(2);
+ fireEvent.click(makeActive[1]);
+ await waitFor(() => expect(axios.patch).toHaveBeenCalledWith(
+ '/api/integrations/i-live',
+ { podId: 'p2' },
+ expect.anything(),
+ ));
+ });
+
+ // TASK-164: the gate line was the last raw English relative time on this page —
+ // `since ${relativeTime(...)}` built its own sentence and no key could reach it.
+ it('the gate line reads in zh-CN, not English (TASK-164)', async () => {
+ mockCatalog([slackEntry()]);
+ await i18nReady;
+ await act(async () => { await i18n.changeLanguage('zh-CN'); });
+ try {
+ renderPage();
+ await openDrawer('Slack');
+ const line = await screen.findByText(/前起$/);
+ expect(line.textContent).toMatch(/^(刚刚|\d+(分钟|小时|天)前)起$/);
+ expect(screen.queryByText(/^since /)).toBeNull();
+ } finally {
+ await act(async () => { await i18n.changeLanguage('en'); });
+ }
+ });
+
+ it('lists every pod with its gate, marks the active pod, and writes the whole gates map on a switch', async () => {
+ mockCatalog([slackEntry()]);
+ axios.patch.mockResolvedValue({ data: {} });
+ renderPage();
+
+ await findTile('Slack');
+ expect(dot('Rewire crew · linked to Rewire Live Demo')).toBeInTheDocument();
+ const drawer = await openDrawer('Slack');
+ expect(within(drawer).getByText('Pods that reach this channel')).toBeInTheDocument();
+ // The anchors, measured rather than assumed: the panel that holds that
+ // section is reachable by its ACCESSIBLE NAME and never as text. A copy that
+ // names it sends a sighted user hunting, which is the defect TASK-173 was
+ // opened for.
+ expect(within(drawer).getByLabelText('Channel details')).toBeInTheDocument();
+ expect(screen.queryAllByText(/^Channel details$/)).toHaveLength(0);
+ expect(within(drawer).getByText('active')).toBeInTheDocument();
+ expect(within(drawer).getByText('off')).toBeInTheDocument();
+ expect(within(drawer).getByText(/^since /)).toBeInTheDocument();
+ fireEvent.click(within(drawer).getByRole('switch', { name: 'Relay Ops' }));
+ await waitFor(() => expect(axios.patch).toHaveBeenCalledWith(
+ '/api/integrations/i-live',
+ { config: { gates: {
+ p1: { enabled: true, since: '2026-09-01T00:00:00.000Z' },
+ p2: { enabled: true, since: expect.any(String) },
+ } } },
+ expect.anything(),
+ ));
+ });
+
+ it('drops a gate for a pod the owner has left before writing the map', async () => {
+ mockCatalog([slackEntry({
+ integration: liveIntegration({
+ config: { chatTitle: 'Rewire crew', liveRelay: true, gates: {
+ p1: { enabled: true, since: '2026-09-01T00:00:00.000Z' },
+ pLeft: { enabled: true, since: '2026-08-01T00:00:00.000Z' },
+ } },
+ }),
+ })]);
+ axios.patch.mockResolvedValue({ data: {} });
+ renderPage();
+
+ const drawer = await openDrawer('Slack');
+ fireEvent.click(within(drawer).getByRole('switch', { name: 'Relay Ops' }));
+ await waitFor(() => expect(axios.patch).toHaveBeenCalledWith(
+ '/api/integrations/i-live',
+ { config: { gates: {
+ p1: { enabled: true, since: '2026-09-01T00:00:00.000Z' },
+ p2: { enabled: true, since: expect.any(String) },
+ } } },
+ expect.anything(),
+ ));
+ });
+
+ it('keeps mode and lead overrides behind the pod name and makes another pod active', async () => {
+ mockCatalog([slackEntry()]);
+ axios.patch.mockResolvedValue({ data: {} });
+ renderPage();
+
+ const drawer = await openDrawer('Slack');
+ expect(within(drawer).queryByRole('button', { name: 'Make active' })).toBeNull();
+ fireEvent.click(within(drawer).getByRole('button', { name: /Ops/ }));
+ const more = within(drawer.querySelector('.v2-connector-gate__more'));
+ fireEvent.click(more.getByRole('button', { name: 'Mirror' }));
+ await waitFor(() => expect(axios.patch).toHaveBeenCalledWith(
+ '/api/integrations/i-live',
+ { config: { gates: {
+ p1: { enabled: true, since: '2026-09-01T00:00:00.000Z' },
+ p2: { enabled: false, mode: 'mirror', since: expect.any(String) },
+ } } },
+ expect.anything(),
+ ));
+ const makeActive = within(drawer).getByRole('button', { name: 'Make active' });
+ await waitFor(() => expect(makeActive).not.toBeDisabled());
+ fireEvent.click(makeActive);
+ await waitFor(() => expect(axios.patch).toHaveBeenCalledWith(
+ '/api/integrations/i-live',
+ { podId: 'p2' },
+ expect.anything(),
+ ));
+ });
+
+ it('keys an installable tile by the catalog, not by its legacy listing', async () => {
+ mockCatalog([slackEntry()], [{ ...connectors[1] }]);
+ renderPage();
+
+ await findTile('Slack');
+ expect(document.querySelectorAll('button.v2-cx-tile[title="Slack"]')).toHaveLength(1);
+ });
+ });
+});
+
+// The zero-pod case needs a route table around the page.
+// eslint-disable-next-line react/display-name
+const ConnectorsPageForRoutes = () => {
+ // eslint-disable-next-line global-require
+ const Page = require('../connectors/ConnectorsPage').default;
+ return ;
+};
diff --git a/frontend/src/v2/__tests__/ConnectorsPage.layout.test.tsx b/frontend/src/v2/__tests__/ConnectorsPage.layout.test.tsx
new file mode 100644
index 0000000000..75be094fb6
--- /dev/null
+++ b/frontend/src/v2/__tests__/ConnectorsPage.layout.test.tsx
@@ -0,0 +1,330 @@
+// @ts-nocheck
+// Connectors page, the surface itself (TASK-733): the logo tile grid, the filter and
+// search, "+N off", the popover that reveals an unconnected tile, the drawer that
+// manages a connected one, and the add tile. Behaviour of what is inside them lives
+// in ConnectorsPage.channels / .tools.
+import React from 'react';
+import { fireEvent, screen, waitFor, within } from '@testing-library/react';
+import {
+ axios, mockApi, renderPage, tileNode, findTile, clickTile, findDrawer, findPopover, expandOff, pageReady,
+} from '../__testkit__/connectorsKit';
+
+jest.mock('axios', () => require('../__testkit__/axiosMock').axiosFactory());
+
+const pods = [{ _id: 'p1', name: 'Rewire Live Demo', type: 'chat', members: [] }];
+const channel = (installableId, label, over = {}) => ({
+ installableId, label, description: `${label} copy.`, available: true, offered: true, installation: null, integration: null, ...over,
+});
+const liveSlack = channel('slack', 'Slack', {
+ installation: { status: 'active', updatedAt: new Date().toISOString(), boundPodId: 'p1', components: [] },
+ integration: {
+ _id: 'i-live', installationId: 'install-slack-u1', type: 'slack', status: 'connected', scope: 'user', isActive: true,
+ createdAt: new Date(Date.now() - 3_600_000).toISOString(), updatedAt: new Date().toISOString(),
+ config: { chatTitle: 'Rewire crew', liveRelay: true, gates: {} }, podId: 'p1',
+ },
+});
+const tool = (installableId, label, over = {}) => ({
+ installableId, list: 'tools', label, description: `${label} tool copy.`, available: true,
+ tools: [{ name: `${installableId}.read`, requiredWriteMode: 'read', irreversible: false }],
+ connections: [{ connectionId: `c-${installableId}`, owner: 'o', repo: 'r' }], installation: null, integration: null, ...over,
+});
+
+const mockCatalog = (installables, extra = {}) => mockApi({ installables, pods, ...extra });
+const grid = (group) => within(screen.getByRole('region', { name: group })).queryAllByRole('button')
+ .filter((node) => node.classList.contains('v2-cx-tile'));
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ window.history.replaceState({}, '', '/v2/connectors');
+});
+
+describe('Connectors page: the tile surface', () => {
+ it('renders one logo tile per catalogue entry, grouped under Channels and Tools', async () => {
+ mockCatalog([channel('telegram', 'Telegram'), liveSlack, tool('github', 'GitHub'), tool('linear', 'Linear')]);
+ renderPage();
+
+ await findTile('GitHub');
+ expect(screen.getByRole('heading', { name: 'Connectors' })).toBeInTheDocument();
+ const channelTitles = grid('Channels').map((node) => node.getAttribute('title'));
+ expect(channelTitles).toEqual(expect.arrayContaining(['Telegram', 'Slack']));
+ expect(grid('Tools').map((node) => node.getAttribute('title'))).toEqual(['GitHub', 'Linear']);
+ // The logo carries the name: every tile holds an svg and its label, and no
+ // description is printed on the grid itself.
+ for (const node of [tileNode('Telegram'), tileNode('GitHub')]) {
+ expect(node.querySelector('svg')).not.toBeNull();
+ expect(node.querySelector('.v2-cx-tile__name')).toHaveTextContent(node.getAttribute('title'));
+ }
+ expect(screen.queryByText('Telegram copy.')).toBeNull();
+ expect(screen.queryByText('GitHub tool copy.')).toBeNull();
+ // Counts: Slack is on; Telegram, GitHub, Linear are offered.
+ expect(document.querySelector('.v2-cx-head__meta')).toHaveTextContent('1 on · 3 available');
+ });
+
+ it('the filter segment hides a group, and All brings it back', async () => {
+ mockCatalog([channel('telegram', 'Telegram'), tool('github', 'GitHub')]);
+ renderPage();
+ await findTile('GitHub');
+
+ const filter = screen.getByRole('group', { name: 'Show' });
+ const all = within(filter).getByRole('button', { name: 'All' });
+ expect(all).toHaveAttribute('aria-pressed', 'true');
+
+ fireEvent.click(within(filter).getByRole('button', { name: 'Tools' }));
+ expect(screen.queryByRole('region', { name: 'Channels' })).toBeNull();
+ expect(screen.getByRole('region', { name: 'Tools' })).toBeInTheDocument();
+ expect(within(filter).getByRole('button', { name: 'Tools' })).toHaveAttribute('aria-pressed', 'true');
+ expect(tileNode('Telegram')).toBeNull();
+
+ fireEvent.click(within(filter).getByRole('button', { name: 'Channels' }));
+ expect(screen.queryByRole('region', { name: 'Tools' })).toBeNull();
+ expect(tileNode('Telegram')).not.toBeNull();
+ expect(tileNode('GitHub')).toBeNull();
+
+ fireEvent.click(all);
+ expect(screen.getByRole('region', { name: 'Channels' })).toBeInTheDocument();
+ expect(screen.getByRole('region', { name: 'Tools' })).toBeInTheDocument();
+ });
+
+ it('search filters tiles in both groups, drops the add tile, and says when nothing matches', async () => {
+ mockCatalog([channel('telegram', 'Telegram'), liveSlack, tool('github', 'GitHub'), tool('linear', 'Linear')]);
+ renderPage();
+ await findTile('GitHub');
+ expect(tileNode('Channel')).not.toBeNull();
+
+ const search = screen.getByRole('searchbox', { name: 'Search' });
+ fireEvent.change(search, { target: { value: 'tele' } });
+ expect(tileNode('Telegram')).not.toBeNull();
+ for (const name of ['Slack', 'GitHub', 'Linear']) expect(tileNode(name)).toBeNull();
+ // Searching is for finding what exists, so the add tile steps aside.
+ expect(tileNode('Channel')).toBeNull();
+
+ fireEvent.change(search, { target: { value: 'linear' } });
+ expect(tileNode('Linear')).not.toBeNull();
+ expect(tileNode('Telegram')).toBeNull();
+
+ fireEvent.change(search, { target: { value: 'zzz' } });
+ expect(screen.getByText('Nothing matches.')).toBeInTheDocument();
+
+ fireEvent.change(search, { target: { value: '' } });
+ expect(tileNode('Telegram')).not.toBeNull();
+ expect(tileNode('Channel')).not.toBeNull();
+ });
+
+ it('"+N off" expands the not-offered tiles, dimmed, and collapses them again', async () => {
+ mockCatalog([
+ channel('telegram', 'Telegram'),
+ channel('discord', 'Discord', { available: false, unavailableReason: 'not_configured' }),
+ tool('github', 'GitHub'),
+ tool('sentry', 'Sentry', { available: false, connections: [] }),
+ ]);
+ renderPage();
+ await findTile('GitHub');
+
+ // One off per group, collapsed: the dead tiles are not in the grid yet.
+ const offButtons = screen.getAllByRole('button', { name: '+1 off' });
+ expect(offButtons).toHaveLength(2);
+ for (const button of offButtons) expect(button).toHaveAttribute('aria-expanded', 'false');
+ expect(tileNode('Discord')).toBeNull();
+ expect(tileNode('Sentry')).toBeNull();
+
+ await expandOff('Channels');
+ expect(tileNode('Discord')).toHaveClass('v2-cx-tile--dim');
+ expect(tileNode('Sentry')).toBeNull();
+ // The head counts what is on or offered, never what is off.
+ expect(document.querySelector('.v2-cx-head__meta')).toHaveTextContent('0 on · 2 available');
+
+ await expandOff('Tools');
+ expect(tileNode('Sentry')).toHaveClass('v2-cx-tile--dim');
+
+ const channelsOff = within(screen.getByRole('region', { name: 'Channels' })).getByRole('button', { name: '+1 off' });
+ expect(channelsOff).toHaveAttribute('aria-expanded', 'true');
+ fireEvent.click(channelsOff);
+ expect(tileNode('Discord')).toBeNull();
+ });
+
+ it('an unconnected tile opens a popover with the act; Escape closes it and returns focus to the tile', async () => {
+ mockCatalog([channel('telegram', 'Telegram'), tool('github', 'GitHub')]);
+ renderPage();
+
+ const tile = await findTile('Telegram');
+ expect(tile).toHaveAttribute('aria-expanded', 'false');
+ fireEvent.click(tile);
+ const popover = await findPopover('Telegram');
+ expect(tile).toHaveAttribute('aria-expanded', 'true');
+ expect(within(popover).getByText('Telegram copy.')).toBeInTheDocument();
+ const act = within(popover).getByRole('button', { name: 'Add' });
+ // Focus moves into the popover on open.
+ await waitFor(() => expect(act).toHaveFocus());
+ expect(screen.queryByRole('region', { name: 'Telegram' })).toBeNull();
+
+ fireEvent.keyDown(document, { key: 'Escape' });
+ await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Telegram' })).toBeNull());
+ expect(tile).toHaveFocus();
+ expect(tile).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('an unconnected tool tile reveals its description, the first tool names as chips, and the act', async () => {
+ const names = ['a.one', 'b.two', 'c.three', 'd.four', 'e.five'];
+ mockCatalog([tool('github', 'GitHub', { tools: names.map((name) => ({ name, requiredWriteMode: 'read', irreversible: false })) })], { grants: [] });
+ renderPage();
+
+ await clickTile('GitHub');
+ const popover = await findPopover('GitHub');
+ expect(within(popover).getByText('GitHub tool copy.')).toBeInTheDocument();
+ for (const name of names.slice(0, 3)) expect(within(popover).getByText(name)).toBeInTheDocument();
+ expect(within(popover).queryByText('d.four')).toBeNull();
+ expect(within(popover).getByText('+2')).toBeInTheDocument();
+ expect(within(popover).getByRole('button', { name: 'Add' })).toBeInTheDocument();
+ });
+
+ it('a connected tile opens a drawer with its panel, and the close button closes it', async () => {
+ mockCatalog([liveSlack, channel('telegram', 'Telegram')]);
+ renderPage();
+
+ const tile = await findTile('Slack');
+ expect(tile).toHaveAttribute('aria-pressed', 'false');
+ fireEvent.click(tile);
+ const drawer = await findDrawer('Slack');
+ expect(tile).toHaveAttribute('aria-pressed', 'true');
+ expect(within(drawer).getByRole('heading', { name: 'Slack' })).toBeInTheDocument();
+ expect(within(drawer).getByText('What the channel sees')).toBeInTheDocument();
+ // A connected tile opens a manage drawer, never the reveal popover.
+ expect(screen.queryByRole('dialog', { name: 'Slack' })).toBeNull();
+
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }));
+ expect(screen.queryByRole('region', { name: 'Slack' })).toBeNull();
+ expect(tile).toHaveAttribute('aria-pressed', 'false');
+ });
+
+ it('a second tile replaces the open drawer, and Escape closes the drawer', async () => {
+ mockCatalog([liveSlack, channel('telegram', 'Telegram', {
+ installation: { status: 'active', updatedAt: new Date().toISOString(), boundPodId: 'p1', components: [] },
+ integration: { ...liveSlack.integration, _id: 'i-tg', installationId: 'install-telegram-u1', type: 'telegram' },
+ })]);
+ renderPage();
+
+ await clickTile('Slack');
+ await findDrawer('Slack');
+ await clickTile('Telegram');
+ await findDrawer('Telegram');
+ expect(screen.queryByRole('region', { name: 'Slack' })).toBeNull();
+
+ fireEvent.keyDown(document, { key: 'Escape' });
+ await waitFor(() => expect(screen.queryByRole('region', { name: 'Telegram' })).toBeNull());
+ });
+
+ it('the add tile opens the add form in the drawer, and Close dismisses it', async () => {
+ mockCatalog([channel('telegram', 'Telegram'), channel('slack', 'Slack')]);
+ renderPage();
+
+ const add = await findTile('Channel');
+ expect(add).toHaveClass('v2-cx-tile--add');
+ fireEvent.click(add);
+ const drawer = await findDrawer('Connect a channel');
+ expect(within(drawer).getByText('Next step')).toBeInTheDocument();
+ expect(within(drawer).getByRole('group', { name: 'Channel provider' })).toBeInTheDocument();
+ expect(within(drawer).getByLabelText('Pod to bridge')).toBeInTheDocument();
+ expect(within(drawer).getByRole('button', { name: 'Connect' })).toBeInTheDocument();
+
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }));
+ expect(screen.queryByRole('region', { name: 'Connect a channel' })).toBeNull();
+ });
+
+ it('a tile mark says what it means: the dot is named, a locked tool has its sentence, a beta tool its word', async () => {
+ mockCatalog([
+ liveSlack,
+ tool('github', 'GitHub', { connections: [] }),
+ tool('atlassian', 'Atlassian', { entryId: 'atlassian', connectionType: 'hosted-mcp' }),
+ ]);
+ renderPage();
+
+ await findTile('Slack');
+ expect(within(tileNode('Slack')).getByRole('img', { name: /Rewire crew · linked to Rewire Live Demo/ })).toBeInTheDocument();
+ expect(within(tileNode('GitHub')).getByRole('img', { name: 'An admin installs this once' })).toBeInTheDocument();
+ const beta = tileNode('Atlassian').querySelector('.v2-cx-tile__beta');
+ expect(beta).toHaveTextContent('beta');
+ expect(beta).toHaveAttribute('title', 'read in testing');
+ });
+
+ it('shows the page-level error when the first read fails', async () => {
+ mockApi({ pods });
+ axios.get.mockImplementation((url) => (url === '/api/integrations/user/all'
+ ? Promise.reject(new Error('down'))
+ : Promise.resolve({ data: [] })));
+ renderPage();
+
+ await pageReady();
+ expect(screen.getByRole('alert')).toHaveClass('v2-cx-error');
+ });
+ it('keeps a live grant listed when its catalogue entry is gone, so it can still be revoked', async () => {
+ const iso = (ms) => new Date(Date.now() + ms).toISOString();
+ const orphan = {
+ grantId: 'grant_orphan', installationId: 'inst-x', target: { kind: 'pod', id: 'p1' }, tools: ['retired.read'],
+ writeMode: 'read', budget: null, effectiveAudience: [], expiresAt: iso(86_400_000), revokedAt: null, revokedBy: null,
+ parentGrantId: null, rootGrantId: null, createdAt: iso(-3_600_000), grantedBy: 'u1',
+ };
+ mockCatalog([tool('github', 'GitHub')], { grants: [orphan] });
+ renderPage();
+
+ await findTile('GitHub');
+ const row = await screen.findByRole('button', { name: /View Unknown connector/ });
+ fireEvent.click(row);
+ expect(await findDrawer(/Unknown connector/)).toBeInTheDocument();
+ expect(await screen.findByRole('button', { name: /^Revoke/ })).toBeInTheDocument();
+ });
+
+ it('closes the reveal when its act opens a drawer', async () => {
+ mockCatalog([tool('github', 'GitHub')]);
+ renderPage();
+
+ await clickTile('GitHub');
+ const pop = await findPopover('GitHub');
+ fireEvent.click(within(pop).getByRole('button', { name: 'Add' }));
+ await findDrawer('GitHub');
+ expect(screen.queryByRole('dialog', { name: 'GitHub' })).toBeNull();
+
+ // And it stays closed once the drawer closes; it does not come back.
+ fireEvent.click(screen.getByRole('button', { name: 'Close' }));
+ await waitFor(() => expect(screen.queryByRole('region', { name: 'GitHub' })).toBeNull());
+ expect(screen.queryByRole('dialog', { name: 'GitHub' })).toBeNull();
+ });
+
+ it('does not refetch in a loop while the pod list cannot load', async () => {
+ mockCatalog([tool('github', 'GitHub')], { pods: new Error('pods down') });
+ renderPage();
+
+ await findTile('GitHub');
+ await new Promise((resolve) => { setTimeout(resolve, 300); });
+ const grantCalls = axios.get.mock.calls.filter(([url]) => url === '/api/grants').length;
+ expect(grantCalls).toBeLessThanOrEqual(2);
+ });
+
+ it('keeps the header count steady while searching', async () => {
+ mockCatalog([channel('telegram', 'Telegram'), liveSlack, tool('github', 'GitHub'), tool('linear', 'Linear')]);
+ renderPage();
+
+ await findTile('GitHub');
+ const meta = () => document.querySelector('.v2-cx-head__meta');
+ expect(meta()).toHaveTextContent('1 on · 3 available');
+ fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'lin' } });
+ await findTile('Linear');
+ expect(meta()).toHaveTextContent('1 on · 3 available');
+ });
+
+ it('gives an installed provider this instance no longer offers the reveal, not an empty drawer', async () => {
+ mockCatalog([
+ channel('discord', 'Discord', {
+ available: false,
+ installation: { status: 'active', updatedAt: new Date().toISOString(), boundPodId: null, components: [] },
+ }),
+ tool('github', 'GitHub'),
+ ]);
+ renderPage();
+
+ await findTile('GitHub');
+ await expandOff('Channels');
+ fireEvent.click(await findTile('Discord'));
+ expect(await findPopover('Discord')).toBeInTheDocument();
+ expect(screen.queryByRole('region', { name: 'Discord' })).toBeNull();
+ });
+});
diff --git a/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx b/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx
new file mode 100644
index 0000000000..2bddc2c0b2
--- /dev/null
+++ b/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx
@@ -0,0 +1,1301 @@
+// @ts-nocheck
+// Connectors page, tools half: grants and their trail, the Add / Change access form,
+// hosted connect and disconnect, the GitHub App setup. A tool tile with a grant (or a
+// hidden connection) opens its drawer; one without opens a popover with the act.
+// Nothing here draws a control the server does not enforce.
+import React from 'react';
+import { act, fireEvent, screen, waitFor, within } from '@testing-library/react';
+import i18n, { i18nReady } from '../../i18n';
+import {
+ axios, authValue, adminAuth, asUser, mockApi, renderPage, tileNode, findTile, findDrawer, findPopover,
+ expandOff, emptyCounts,
+} from '../__testkit__/connectorsKit';
+
+jest.mock('axios', () => require('../__testkit__/axiosMock').axiosFactory());
+
+const NOW = Date.now();
+const iso = (ms) => new Date(NOW + ms).toISOString();
+
+const scout = { name: 'scout', displayName: 'Scout', userId: 'a1' };
+const pods = [
+ { _id: 'p1', name: 'Launch pod', type: 'chat', members: [{ _id: 'u1', username: 'sam' }, { _id: 'a1', username: 'scout' }] },
+ { _id: 'p2', name: 'Ops', type: 'chat', members: [{ _id: 'u1', username: 'sam' }, { _id: 'a1', username: 'scout' }] },
+];
+const grantLive = {
+ grantId: 'grant_live', installationId: 'inst-1', target: { kind: 'pod', id: 'p1' }, tools: ['github.list_issues', 'github.comment_on_issue'],
+ writeMode: 'write-with-confirm', budget: { calls: 50, windowMs: 3600000 }, effectiveAudience: ['a1'], expiresAt: iso(6 * 86400000),
+ revokedAt: null, revokedBy: null, parentGrantId: null, rootGrantId: null, createdAt: iso(-3600000), grantedBy: 'u1',
+};
+const grantRevoked = { ...grantLive, grantId: 'grant_gone', target: { kind: 'pod', id: 'p2' }, writeMode: 'read', effectiveAudience: ['a1'], revokedAt: iso(-600000), revokedBy: 'u1', createdAt: iso(-86400000) };
+const trail = {
+ grantId: 'grant_live',
+ calls: [
+ { callId: 'c1', agentUserId: 'a1', tool: 'github.list_issues', outcome: 'ok', reason: null, approvalId: null, argsDigest: 'a'.repeat(64), at: iso(-120000), durationMs: 80 },
+ { callId: 'c2', agentUserId: 'a1', tool: 'github.comment_on_issue', outcome: 'refused', reason: 'not_in_audience', approvalId: null, argsDigest: 'b'.repeat(64), at: iso(-300000), durationMs: null },
+ { callId: 'c3', agentUserId: 'a1', tool: 'github.comment_on_issue', outcome: 'pending_approval', reason: null, approvalId: 'appr-1', argsDigest: 'c'.repeat(64), at: iso(-400000), durationMs: null },
+ // C4-11: the ask was answered and the call ran as the row above it; the
+ // server reports the parked row as `superseded`, never as still awaiting.
+ { callId: 'c4', agentUserId: 'a1', tool: 'github.close_issue', outcome: 'superseded', reason: null, approvalId: 'appr-2', argsDigest: 'd'.repeat(64), at: iso(-500000), durationMs: null },
+ ],
+ counts: { total: 3, ok: 1, refused: 1, pending_approval: 1, failed: 0 },
+};
+
+const githubEntry = {
+ installableId: 'github', list: 'tools', label: 'GitHub', description: 'Issues and pull requests.', available: true,
+ broker: { id: 'commonly-grant-broker' },
+ tools: [
+ { name: 'github.list_issues', requiredWriteMode: 'read', irreversible: false },
+ { name: 'github.comment_on_issue', requiredWriteMode: 'write', irreversible: true },
+ { name: 'github.close_issue', requiredWriteMode: 'write', irreversible: true },
+ ],
+ connections: [{ connectionId: 'conn-1', owner: 'Team-Commonly', repo: 'commonly' }],
+};
+const linearEntry = {
+ installableId: 'linear', list: 'tools', label: 'Linear', description: 'Issues in Linear.', available: true,
+ connectionType: 'hosted-mcp', entryId: 'linear',
+ broker: { id: 'commonly-grant-broker' },
+ tools: [{ name: 'linear.list_issues', requiredWriteMode: 'read', irreversible: false }],
+ connections: [{ connectionId: 'conn-2', owner: 'Team-Commonly', repo: 'linear' }],
+};
+const grantLinear = { ...grantLive, grantId: 'grant_lin', installationId: 'inst-2', writeMode: 'read', tools: ['linear.list_issues'] };
+
+// The tools hook reads the catalogue first and the grants a beat later, so a tile
+// exists before the page knows whether it has a grant. Wait that beat out before a
+// click: a granted tool opens a drawer, an ungranted one a popover.
+const settle = () => act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
+const openTool = async (name) => {
+ await findTile(name);
+ await settle();
+ fireEvent.click(tileNode(name));
+ return findDrawer(name);
+};
+const openToolPopover = async (name) => {
+ await findTile(name);
+ await settle();
+ fireEvent.click(tileNode(name));
+ return findPopover(name);
+};
+const openRow = async (toolName, rowName) => {
+ const drawer = await openTool(toolName);
+ fireEvent.click(within(drawer).getByRole('button', { name: rowName }));
+ const aside = await within(drawer).findByRole('complementary', { name: 'Grant details' });
+ return { drawer, aside };
+};
+const grantReads = () => axios.get.mock.calls.filter(([url]) => url === '/api/grants');
+const meta = () => document.querySelector('.v2-cx-head__meta');
+
+// Defaults mirror the old suite: one GitHub entry, a live grant in Launch pod and a
+// revoked one in Ops, one agent (Scout) in each pod.
+const mockTools = ({
+ entries = [githubEntry],
+ grants = [grantLive, grantRevoked],
+ podList = pods,
+ seats = { p1: [scout], p2: [scout] },
+ trails = { grant_live: trail },
+ hidden,
+ route,
+} = {}) => mockApi({ installables: entries, grants, pods: podList, seats, trails, hidden, route });
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ window.history.replaceState({}, '', '/v2/connectors');
+ mockTools();
+ axios.post.mockResolvedValue({ data: { grantId: 'grant_live', revoked: 1 } });
+});
+
+afterEach(() => jest.restoreAllMocks());
+
+describe('Connectors page: tools', () => {
+ it('Atlassian shows its read-in-testing state on a tile mark and on an existing grant', async () => {
+ const atlassian = {
+ ...linearEntry, installableId: 'atlassian', entryId: 'atlassian', label: 'Atlassian',
+ tools: [{ name: 'atlassian.get_accessible_atlassian_resources', requiredWriteMode: 'read', irreversible: false }],
+ };
+ const grant = { ...grantLinear, grantId: 'grant_atl', tools: ['atlassian.get_accessible_atlassian_resources'] };
+ mockTools({ entries: [atlassian], grants: [grant] });
+ renderPage();
+
+ const tile = await findTile('Atlassian');
+ const beta = tile.querySelector('.v2-cx-tile__beta');
+ expect(beta).toHaveTextContent('beta');
+ expect(beta).toHaveAttribute('title', 'read in testing');
+ const drawer = await openTool('Atlassian');
+ expect(within(drawer).getByText(/read in testing/)).toBeInTheDocument();
+ });
+
+ it('one owner grant read shows the 21st pod and keeps revoked and expired labels', async () => {
+ const manyPods = Array.from({ length: 21 }, (_, index) => ({
+ _id: `p${index + 1}`, name: `Pod ${index + 1}`, type: 'chat', members: [{ _id: 'u1', username: 'sam' }],
+ }));
+ const inLastPod = { ...grantLive, grantId: 'grant_21', target: { kind: 'pod', id: 'p21' } };
+ const revoked = { ...inLastPod, grantId: 'grant_21_revoked', revokedAt: iso(-600000), revokedBy: 'u1' };
+ const expired = { ...inLastPod, grantId: 'grant_21_expired', expiresAt: iso(-600000) };
+ mockTools({ grants: [inLastPod, revoked, expired], podList: manyPods, seats: {}, trails: {} });
+ renderPage();
+
+ const drawer = await openTool('GitHub');
+ expect(await within(drawer).findAllByRole('button', { name: 'View GitHub in Pod 21' })).toHaveLength(3);
+ expect(within(drawer).getByText(/revoked by sam 10m/)).toBeInTheDocument();
+ expect(within(drawer).getByText(/expired 10m/)).toBeInTheDocument();
+ fireEvent.click(within(drawer).getAllByRole('button', { name: 'Grant again' })[0]);
+ expect(await screen.findByRole('option', { name: 'Pod 21' })).toBeInTheDocument();
+ // The grant list is one read for the owner, never one per pod. The hook reads once
+ // before the pod list arrives and once after, so two is the ceiling for 21 pods.
+ const reads = grantReads();
+ expect(reads.length).toBeLessThanOrEqual(2);
+ for (const read of reads) expect(read[1]).toMatchObject({ params: { connectionOwner: 'me' } });
+ expect(axios.get.mock.calls.some(([url]) => /\/api\/pods\/.*\/grants/.test(url))).toBe(false);
+ });
+
+ it('a sole join-policy refusal offers the owner an in-place fix and then allows retry', async () => {
+ mockTools({ podList: [pods[0], { ...pods[1], createdBy: { _id: 'u1' } }] });
+ axios.post.mockRejectedValueOnce({ response: { data: {
+ error: 'outsider_grant_pod_joinable', message: 'Agents can self-install into this pod. Make it invite-only to use this connector.',
+ } } }).mockResolvedValueOnce({ data: { grantId: 'grant_new' } });
+ axios.patch.mockResolvedValue({ data: { id: 'p2', joinPolicy: 'invite-only' } });
+ renderPage();
+
+ const drawer = await openTool('GitHub');
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Grant again' }));
+ const aside = await screen.findByRole('complementary', { name: 'Add GitHub' });
+ fireEvent.click(within(aside).getByRole('button', { name: 'Grant' }));
+ fireEvent.click(await within(aside).findByRole('button', { name: 'Make this pod invite-only' }));
+ await waitFor(() => expect(axios.patch).toHaveBeenCalledWith('/api/pods/p2/join-policy', { joinPolicy: 'invite-only' }, expect.anything()));
+ expect(await within(aside).findByText('This pod is invite-only. Select Grant to continue.')).toBeInTheDocument();
+ fireEvent.click(within(aside).getByRole('button', { name: 'Grant' }));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(2));
+ });
+
+ it('the in-place fix is not offered to a non-owner', async () => {
+ axios.post.mockRejectedValueOnce({ response: { data: {
+ error: 'outsider_grant_pod_joinable', message: 'Agents can self-install into this pod. Make it invite-only to use this connector.',
+ } } });
+ renderPage();
+
+ const drawer = await openTool('GitHub');
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Grant again' }));
+ const aside = await screen.findByRole('complementary', { name: 'Add GitHub' });
+ fireEvent.click(within(aside).getByRole('button', { name: 'Grant' }));
+ await within(aside).findByText('Agents can self-install into this pod. Make it invite-only to use this connector.');
+ expect(within(aside).queryByRole('button', { name: 'Make this pod invite-only' })).not.toBeInTheDocument();
+ });
+
+ it('a bridged-pod refusal never offers the join-policy fix', async () => {
+ mockTools({ podList: [pods[0], { ...pods[1], createdBy: 'u1' }] });
+ axios.post.mockRejectedValueOnce({ response: { data: {
+ error: 'outsider_grant_pod_bridged', message: 'Use a separate unbridged pod for this connector.',
+ } } });
+ renderPage();
+
+ const drawer = await openTool('GitHub');
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Grant again' }));
+ const aside = await screen.findByRole('complementary', { name: 'Add GitHub' });
+ fireEvent.click(within(aside).getByRole('button', { name: 'Grant' }));
+ await within(aside).findByText('Use a separate unbridged pod for this connector.');
+ expect(within(aside).queryByRole('button', { name: 'Make this pod invite-only' })).not.toBeInTheDocument();
+ });
+
+ it('TASK-131: a grant age advances in place, and a returning tab re-reads, without a reload', async () => {
+ jest.useFakeTimers();
+ try {
+ renderPage();
+ // The Granted dot only exists once the grants have been read.
+ await screen.findByRole('img', { name: 'Granted' });
+ fireEvent.click(tileNode('GitHub'));
+ expect(await screen.findByText(/granted 1h$/)).toBeInTheDocument();
+
+ await act(async () => { jest.advanceTimersByTime(60 * 60_000); });
+ expect(screen.getByText(/granted 2h$/)).toBeInTheDocument();
+
+ const before = grantReads().length;
+ document.dispatchEvent(new Event('visibilitychange'));
+ await waitFor(() => expect(grantReads().length).toBeGreaterThan(before));
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('TASK-131: a grant expiring with the page open takes its tool off the count and the tile on the same tick', async () => {
+ jest.useFakeTimers();
+ try {
+ // Live for one more minute, so the only thing that changes is the clock.
+ mockTools({ grants: [{ ...grantLive, grantId: 'grant_soon', expiresAt: iso(60_000) }], seats: {}, trails: {} });
+ renderPage();
+ await screen.findByRole('img', { name: 'Granted' });
+ await waitFor(() => expect(meta()).toHaveTextContent('1 on · 0 available'));
+
+ await act(async () => { jest.advanceTimersByTime(2 * 60_000); });
+
+ // The count always followed the clock; the tile did not, so the two disagreed
+ // on screen until a reload.
+ expect(meta()).toHaveTextContent('0 on · 1 available');
+ expect(screen.queryByRole('img', { name: 'Granted' })).toBeNull();
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('rows carry the states table: a live grant pulses when used in the last 10 minutes, a revoked one goes hollow', async () => {
+ renderPage();
+ await findTile('GitHub');
+ await waitFor(() => expect(meta()).toHaveTextContent('1 on · 0 available'));
+ expect(screen.getByRole('img', { name: 'Granted' })).toBeInTheDocument();
+ const drawer = await openTool('GitHub');
+ const live = within(drawer).getByRole('button', { name: 'View GitHub in Launch pod' });
+ expect(within(live).getByText('Launch pod')).toBeInTheDocument();
+ expect(within(live).getByText('sam')).toBeInTheDocument();
+ // Direction A: the audience is the sentence; the write mode is the glyph (label) and rides the 390 kicker as words.
+ expect(within(live).getByText('Scout may use it')).toBeInTheDocument();
+ expect(within(live).getByRole('img', { name: /asks|write/ })).toBeInTheDocument();
+ expect(live.querySelector('.v2-connector-row__kicker-mode')).toHaveTextContent('every write asks first');
+ expect(within(live).getByText(/granted 1h$/)).toBeInTheDocument();
+ const liveDot = live.querySelector('.v2-connector-row__dot');
+ expect(liveDot).toHaveClass('v2-connector-row__dot--live');
+ await waitFor(() => expect(liveDot).toHaveClass('v2-connector-row__dot--pulse'));
+ const gone = within(drawer).getByRole('button', { name: 'View GitHub in Ops' });
+ expect(within(gone).getByText('revoked by sam 10m ago')).toBeInTheDocument();
+ expect(gone.closest('.v2-connector-row')).toHaveClass('v2-connector-row--dead');
+ expect(gone.querySelector('.v2-connector-row__dot')).toHaveClass('v2-connector-row__dot--empty');
+ // The ended grant sits under its own heading, below the live one.
+ expect(within(drawer).getByText('inactive · 1')).toBeInTheDocument();
+ // The entry is granted, so the drawer offers no Add.
+ expect(within(drawer).queryByRole('button', { name: 'Add' })).not.toBeInTheDocument();
+ expect(within(drawer).getAllByRole('button', { name: 'Manage' })).toHaveLength(1);
+ expect(within(drawer).getByRole('button', { name: 'Grant again' })).toBeInTheDocument();
+ // Direction A: Manage is the gear (word in title + aria-label), and the age rides the kicker.
+ for (const manage of within(drawer).getAllByRole('button', { name: 'Manage' })) {
+ expect(manage).toHaveClass('v2-connector-row__action--icon');
+ expect(manage.textContent).toBe('');
+ }
+ expect(document.querySelector('.v2-connector-row__when')).toBeNull();
+ expect(document.querySelector('.v2-connector-row__kicker')?.textContent).toMatch(/ · granted /);
+ });
+
+ // TASK-649: one timestamp pair, two ages. A grant made 16 minutes before its
+ // revoke and read 41h later must say the same day count for both lines, not
+ // "granted 1d" beside "revoked 2d ago".
+ it('TASK-649: the grant age and the revoke age round the same way on one row', async () => {
+ const createdAt = iso(-(41 * 3600_000 + 24 * 60_000));
+ const revokedAt = iso(-(41 * 3600_000 + 8 * 60_000));
+ mockTools({ grants: [{ ...grantRevoked, createdAt, revokedAt, revokedBy: 'u1' }] });
+ renderPage();
+
+ const drawer = await openTool('GitHub');
+ const gone = await within(drawer).findByRole('button', { name: 'View GitHub in Ops' });
+ expect(within(gone).getByText(/granted 2d$/)).toBeInTheDocument();
+ expect(within(gone).getByText('revoked by sam 2d ago')).toBeInTheDocument();
+ });
+
+ // TASK-649: the same rule holds below a day.
+ it.each([
+ [5 * 60_000 + 40_000, 'granted 6m'],
+ [100 * 60_000, 'granted 2h'],
+ ])('TASK-649: a grant aged %p ms reads "%s" on its row', async (ageMs, expected) => {
+ mockTools({ grants: [{ ...grantRevoked, createdAt: iso(-ageMs), revokedAt: null, revokedBy: null }] });
+ renderPage();
+
+ const drawer = await openTool('GitHub');
+ const row = await within(drawer).findByRole('button', { name: 'View GitHub in Ops' });
+ expect(within(row).getByText(new RegExp(`${expected}$`))).toBeInTheDocument();
+ });
+
+ it('TASK-164: the grant, budget and trail times read in zh-CN, not English', async () => {
+ await i18nReady;
+ await act(async () => { await i18n.changeLanguage('zh-CN'); });
+ try {
+ renderPage();
+ const drawer = await openTool('GitHub');
+ fireEvent.click(drawer.querySelector('.v2-connector-row__selection'));
+ const aside = await screen.findByRole('complementary');
+ expect(within(aside).getByText(/前由 sam 授权。/)).toBeInTheDocument();
+ expect(within(aside).getByText(/后结束。/)).toBeInTheDocument();
+ // The TASK-180 ruling names this key explicitly: 「每{{window}}可调用{{calls}}次」.
+ expect(within(aside).getByText(/每1小时可调用50次/)).toBeInTheDocument();
+ await waitFor(() => expect(within(aside).getAllByRole('listitem')).toHaveLength(4));
+ const lines = within(aside).getAllByRole('listitem');
+ expect(lines.map((line) => line.textContent)).toEqual([
+ 'Scout · github.list_issues · 成功2分钟前',
+ 'Scout · github.comment_on_issue · 已拒绝5分钟前',
+ 'Scout · github.comment_on_issue · 等待人工确认7分钟前',
+ 'Scout · github.close_issue · 已回答8分钟前',
+ ]);
+ // The whole aside: an English relative time inside an otherwise-Chinese line is the regression.
+ expect(aside.textContent).not.toMatch(/\bago\b|from now|\bm ago\b/);
+ } finally {
+ await act(async () => { await i18n.changeLanguage('en'); });
+ }
+ });
+
+ it('TASK-179: the grant line is one sentence per language, and an empty audience is its own sentence', async () => {
+ const emptyAudience = { ...grantLive, grantId: 'grant_empty', target: { kind: 'pod', id: 'p2' }, effectiveAudience: [] };
+ mockTools({ grants: [grantLive, emptyAudience], seats: { p1: [scout], p2: [] } });
+ renderPage();
+
+ const drawer = await openTool('GitHub');
+ const live = await within(drawer).findByRole('button', { name: 'View GitHub in Launch pod' });
+ const ops = await within(drawer).findByRole('button', { name: 'View GitHub in Ops' });
+ // One key holds both names, so the language orders them.
+ expect(live.querySelector('strong')?.textContent).toBe('granted to Launch pod by sam');
+ expect(within(ops).getByText('nobody may use it')).toBeInTheDocument();
+
+ fireEvent.click(live);
+ await screen.findByRole('complementary', { name: 'Grant details' });
+ await act(async () => { await i18n.changeLanguage('zh-CN'); });
+ try {
+ // The accessible name is translated too, so locate rows by what they say.
+ const rows = () => Array.from(document.querySelectorAll('.v2-connector-row'));
+ const rowSaying = (text) => rows().find((row) => row.textContent?.includes(text));
+ expect(rowSaying('Launch pod').querySelector('strong')?.textContent).toBe('由 sam 授权给 Launch pod');
+ expect(rowSaying('Launch pod').textContent).toContain('Scout 可以使用');
+ expect(rowSaying('Ops').textContent).toContain('没有智能体可以使用');
+ // The separator key is zh-empty: two sentences that each end in 。 join with nothing.
+ const aside = document.querySelector('.v2-connectors__aside');
+ expect(within(aside).getByText(/授权。/).textContent).toBe('1小时前由 sam 授权。6天后结束。');
+ } finally {
+ await act(async () => { await i18n.changeLanguage('en'); });
+ }
+ });
+
+ it('TASK-179: a seat grant names the seat in its sentence and the pod in the row it sits in', async () => {
+ // The sentence's subject and the row's location are different nouns, so they
+ // are two expressions, and this test fails if they are ever re-merged.
+ const seatGrant = { ...grantLive, grantId: 'grant_seat', target: { kind: 'seat', id: 'a1' }, effectiveAudience: ['a1'] };
+ mockTools({ grants: [seatGrant], seats: { p1: [], p2: [{ name: 'reed', displayName: 'Reed', userId: 'a1' }] } });
+ renderPage();
+
+ const drawer = await openTool('GitHub');
+ // Found by the pod's name: if the seat leaks back into the accessible name
+ // this query returns nothing, which is the fix's own control.
+ const row = await within(drawer).findByRole('button', { name: 'View GitHub in Ops' });
+ expect(row.querySelector('strong')?.textContent).toBe('granted to Reed by sam');
+ expect(row.querySelector('.v2-connector-row__kicker')?.textContent).toMatch(/^Ops · granted /);
+ });
+
+ it('the aside reads the grant and the trail: agents, allow-list under its mode, what asks first, three counts, outcomes as words', async () => {
+ renderPage();
+ const { aside } = await openRow('GitHub', 'View GitHub in Launch pod');
+ expect(within(aside).getByRole('heading', { name: 'GitHub · Launch pod' })).toBeInTheDocument();
+ expect(within(aside).getByText(/Granted by sam 1h ago\. Ends 6d from now\./)).toBeInTheDocument();
+ expect(within(aside).getByText('agents allowed').nextElementSibling).toHaveTextContent('Scout');
+ expect(within(aside).getByText('read and write, ask first').nextElementSibling).toHaveTextContent('github.list_issues');
+ expect(within(aside).getByText('asks a person first').nextElementSibling).toHaveTextContent('every write asks first');
+ expect(within(aside).getByText('budget').nextElementSibling).toHaveTextContent('50 calls per 1h');
+ await waitFor(() => expect(within(aside).getByText('calls').previousElementSibling).toHaveTextContent('3'));
+ expect(within(aside).getByText('refused').previousElementSibling).toHaveTextContent('1');
+ expect(within(aside).getByText('awaiting a person').previousElementSibling).toHaveTextContent('1');
+ const lines = within(aside).getAllByRole('listitem');
+ expect(lines.map((line) => line.textContent)).toEqual([
+ 'Scout · github.list_issues · ok2m ago',
+ 'Scout · github.comment_on_issue · refused5m ago',
+ 'Scout · github.comment_on_issue · awaiting a person7m ago',
+ 'Scout · github.close_issue · answered8m ago',
+ ]);
+ // A superseded request is not a call: the counts still say 3 while the trail
+ // lists its 4 rows, and only the genuinely open one is counted as awaiting.
+ expect(lines.map((line) => line.textContent).filter((text) => text.includes('awaiting a person'))).toHaveLength(1);
+ // `answered` covers a resolution that was declined or expired as well as one
+ // that ran, so its mark says SETTLED without saying approved: shield plus a short bar.
+ const answeredLine = lines.find((line) => line.textContent?.includes('answered'));
+ expect(answeredLine?.querySelectorAll('svg path')).toHaveLength(2);
+ expect(Array.from(answeredLine?.querySelectorAll('svg path') || []).map((path) => path.getAttribute('d'))).toEqual([
+ 'M12 3 4 6v6c0 5 3.4 8.4 8 9 4.6-.6 8-4 8-9V6z',
+ 'M9 12h6',
+ ]);
+ // The arguments never reach the page: only the digest does, and it is not rendered.
+ expect(aside.textContent).not.toContain('a'.repeat(64));
+ expect(axios.get).toHaveBeenCalledWith('/api/grants/grant_live/calls', expect.objectContaining({ headers: expect.any(Object) }));
+ });
+
+ it('TASK-050: a human member in the audience is named, not called "an agent"', async () => {
+ mockTools({ grants: [{ ...grantLive, effectiveAudience: ['u1', 'a1'] }], seats: { p1: [scout] } });
+ renderPage();
+
+ const drawer = await openTool('GitHub');
+ const live = await within(drawer).findByRole('button', { name: 'View GitHub in Launch pod' });
+ expect(within(live).getByText('sam, Scout may use it')).toBeInTheDocument();
+ expect(live.querySelector('.v2-connector-row__kicker-mode')).toHaveTextContent('every write asks first');
+ expect(screen.queryByText(/an agent/)).not.toBeInTheDocument();
+
+ fireEvent.click(live);
+ const aside = await screen.findByRole('complementary', { name: 'Grant details' });
+ expect(within(aside).getByText('agents allowed').nextElementSibling).toHaveTextContent('sam, Scout');
+ });
+
+ it('TASK-624: a per-user bot member with no roster row is named by its curated label, not its instance id', async () => {
+ mockTools({
+ grants: [grantLive],
+ seats: {},
+ podList: [{
+ _id: 'p1', name: 'Launch pod', type: 'chat',
+ members: [
+ { _id: 'u1', username: 'sam' },
+ { _id: 'a1', username: 'scout-uf2554d19c9', isBot: true, botMetadata: { displayName: 'Scout', instanceId: 'uf2554d19c9' } },
+ ],
+ }],
+ });
+ renderPage();
+
+ const drawer = await openTool('GitHub');
+ const live = await within(drawer).findByRole('button', { name: 'View GitHub in Launch pod' });
+ expect(within(live).getByText('Scout may use it')).toBeInTheDocument();
+ expect(screen.queryByText(/uf2554d19c9/)).not.toBeInTheDocument();
+ });
+
+ it('TASK-624: a bot member with a blank displayName falls back to its non-default instanceId, then to the default username', async () => {
+ mockTools({
+ grants: [{ ...grantLive, effectiveAudience: ['a1', 'a2'] }],
+ seats: {},
+ podList: [{
+ _id: 'p1', name: 'Launch pod', type: 'chat',
+ members: [
+ { _id: 'u1', username: 'sam' },
+ { _id: 'a1', username: 'scout-uf2554d19c9', isBot: true, botMetadata: { displayName: ' ', instanceId: 'uf2554d19c9' } },
+ { _id: 'a2', username: 'guide', isBot: true, botMetadata: { displayName: '', instanceId: 'default' } },
+ ],
+ }],
+ });
+ renderPage();
+
+ const drawer = await openTool('GitHub');
+ const live = await within(drawer).findByRole('button', { name: 'View GitHub in Launch pod' });
+ expect(within(live).getByText('uf2554d19c9, guide may use it')).toBeInTheDocument();
+ });
+
+ it('Revoke is two-click and posts the revoke verb, then reloads; a dead grant offers no Revoke', async () => {
+ renderPage();
+ const { drawer, aside } = await openRow('GitHub', 'View GitHub in Launch pod');
+ expect(axios.post).not.toHaveBeenCalled();
+ const before = grantReads().length;
+ fireEvent.click(within(aside).getByRole('button', { name: 'Revoke' }));
+ expect(axios.post).not.toHaveBeenCalled();
+ fireEvent.click(within(aside).getByRole('button', { name: 'Yes, revoke it' }));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/grants/grant_live/revoke', undefined, expect.any(Object)));
+ await waitFor(() => expect(grantReads().length).toBeGreaterThan(before));
+
+ fireEvent.click(within(drawer).getByRole('button', { name: 'View GitHub in Ops' }));
+ const gone = await screen.findByRole('complementary', { name: 'Grant details' });
+ expect(within(gone).queryByRole('button', { name: 'Revoke' })).not.toBeInTheDocument();
+ expect(within(gone).getByText(/Revoked by sam 10m ago\./)).toBeInTheDocument();
+ await waitFor(() => expect(within(gone).getByText('No calls yet.')).toBeInTheDocument());
+ });
+
+ // Atlassian walk, launch day: the card kept saying "granted" after Revoke until a
+ // reload. A confirmed revoke is final, so a stale or failed re-read cannot put the
+ // card back.
+ it('after Revoke the grant card says revoked in place, even when the re-read is stale or fails', async () => {
+ let staleServed = false;
+ mockTools({
+ grants: [grantLive],
+ trails: {},
+ route: (url) => {
+ if (url !== '/api/grants') return undefined;
+ if (axios.post.mock.calls.length === 0) return Promise.resolve({ data: { grants: [grantLive] } });
+ if (!staleServed) {
+ staleServed = true; // stale: the server read lags the revoke
+ return Promise.resolve({ data: { grants: [grantLive] } });
+ }
+ return Promise.reject(new Error('network')); // then a failed read
+ },
+ });
+ axios.post.mockResolvedValue({ data: { revoked: 1 } });
+ renderPage();
+
+ const { drawer, aside } = await openRow('GitHub', 'View GitHub in Launch pod');
+ expect(within(aside).getByText(/Granted by sam .*Ends /)).toBeInTheDocument();
+ fireEvent.click(within(aside).getByRole('button', { name: 'Revoke' }));
+ fireEvent.click(within(aside).getByRole('button', { name: 'Yes, revoke it' }));
+ await waitFor(() => expect(staleServed).toBe(true));
+ // A tab refocus re-reads, and that read fails.
+ const reads = grantReads().length;
+ fireEvent(document, new Event('visibilitychange'));
+ await waitFor(() => expect(grantReads().length).toBeGreaterThan(reads));
+ const card = await screen.findByRole('complementary', { name: 'Grant details' });
+ await waitFor(() => expect(within(card).getByText(/Revoked by sam just now\./)).toBeInTheDocument());
+ expect(within(card).queryByRole('button', { name: 'Revoke' })).not.toBeInTheDocument();
+ // The row went hollow with it, and the list did not blank on the failed read.
+ expect(within(drawer).getByRole('button', { name: 'View GitHub in Launch pod' })).toBeInTheDocument();
+ expect(within(drawer).getByText(/revoked by sam/)).toBeInTheDocument();
+ });
+
+ // Atlassian walk, launch day: only a connection that is no longer offered got
+ // "Remove connection", so nobody could disconnect Atlassian from the page.
+ describe('Disconnect on a live connection of an offered hosted connector', () => {
+ const atlassian = {
+ ...linearEntry, installableId: 'atlassian', entryId: 'atlassian', label: 'Atlassian',
+ tools: [{ name: 'atlassian.search', requiredWriteMode: 'read', irreversible: false }],
+ connections: [{ connectionId: 'conn-atl', owner: 'me', repo: 'atlassian' }],
+ };
+ const grantAtl = { ...grantLinear, grantId: 'grant_atl', tools: ['atlassian.search'] };
+ const catalogReads = () => axios.get.mock.calls.filter(([u]) => u === '/api/installables');
+
+ it('an ungranted connection offers Disconnect, two clicks, and says so afterwards', async () => {
+ mockTools({ entries: [atlassian], grants: [] });
+ axios.delete.mockResolvedValue({ data: { message: 'Integration deleted successfully' } });
+ renderPage();
+
+ const popover = await openToolPopover('Atlassian');
+ fireEvent.click(within(popover).getByRole('button', { name: 'Disconnect Atlassian' }));
+ expect(axios.delete).not.toHaveBeenCalled();
+ const reads = catalogReads().length;
+ fireEvent.click(within(popover).getByRole('button', { name: 'Yes, disconnect' }));
+ await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/integrations/conn-atl', expect.any(Object)));
+ expect(await screen.findByText('Disconnected Atlassian.')).toBeInTheDocument();
+ await waitFor(() => expect(catalogReads().length).toBeGreaterThan(reads));
+ });
+
+ it('a stale catalogue read after Disconnect does not bring the connection back', async () => {
+ mockTools({ entries: [atlassian], grants: [] }); // every read still lists the connection
+ axios.delete.mockResolvedValue({ data: { message: 'ok' } });
+ renderPage();
+
+ const popover = await openToolPopover('Atlassian');
+ fireEvent.click(within(popover).getByRole('button', { name: 'Disconnect Atlassian' }));
+ const reads = catalogReads().length;
+ fireEvent.click(within(popover).getByRole('button', { name: 'Yes, disconnect' }));
+ expect(await screen.findByText('Disconnected Atlassian.')).toBeInTheDocument();
+ // The re-read that follows still lists the connection; the page does not.
+ await waitFor(() => expect(catalogReads().length).toBeGreaterThan(reads));
+ expect(screen.queryByRole('button', { name: /Disconnect Atlassian|Yes, disconnect/ })).not.toBeInTheDocument();
+ expect(within(popover).queryByRole('button', { name: 'Add' })).not.toBeInTheDocument();
+ });
+
+ it('a granted connection offers it in the grant card, and a provider page from the server is linked', async () => {
+ mockTools({ entries: [atlassian], grants: [grantAtl] });
+ axios.delete.mockResolvedValue({ data: { message: 'ok', revokeAt: 'https://id.atlassian.com/manage-profile/apps' } });
+ renderPage();
+
+ const { aside } = await openRow('Atlassian', 'View Atlassian in Launch pod');
+ fireEvent.click(within(aside).getByRole('button', { name: 'Disconnect Atlassian' }));
+ fireEvent.click(within(aside).getByRole('button', { name: 'Yes, disconnect' }));
+ await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/integrations/conn-atl', expect.any(Object)));
+ const link = await screen.findByRole('link', { name: 'Open provider settings' });
+ expect(link).toHaveAttribute('href', 'https://id.atlassian.com/manage-profile/apps');
+ });
+
+ it('a network failure says so and keeps the connection', async () => {
+ mockTools({ entries: [atlassian], grants: [] });
+ axios.delete.mockRejectedValue(new Error('network'));
+ renderPage();
+
+ const popover = await openToolPopover('Atlassian');
+ fireEvent.click(within(popover).getByRole('button', { name: 'Disconnect Atlassian' }));
+ fireEvent.click(within(popover).getByRole('button', { name: 'Yes, disconnect' }));
+ expect(await screen.findByText('Could not disconnect Atlassian.')).toBeInTheDocument();
+ expect(within(popover).getByRole('button', { name: 'Yes, disconnect' })).toBeInTheDocument();
+ });
+
+ // A 502 is the server's partial removal: grants already revoked, row disconnected,
+ // only the provider's revoke failed, and the body names the page to visit.
+ it('a 502 partial removal shows the grant revoked and links the provider page', async () => {
+ mockTools({ entries: [atlassian], grants: [grantAtl] });
+ axios.delete.mockRejectedValue({ response: { status: 502, data: { code: 'provider_revoke_failed', revokeAt: 'https://id.atlassian.com/manage-profile/apps' } } });
+ renderPage();
+
+ const { aside } = await openRow('Atlassian', 'View Atlassian in Launch pod');
+ fireEvent.click(within(aside).getByRole('button', { name: 'Disconnect Atlassian' }));
+ fireEvent.click(within(aside).getByRole('button', { name: 'Yes, disconnect' }));
+ expect(await screen.findByRole('link', { name: 'Open provider settings' })).toHaveAttribute('href', 'https://id.atlassian.com/manage-profile/apps');
+ const card = await screen.findByRole('complementary', { name: 'Grant details' });
+ expect(within(card).getByText(/Revoked by sam/)).toBeInTheDocument();
+ expect(within(card).queryByRole('button', { name: 'Revoke' })).not.toBeInTheDocument();
+ });
+
+ // A 502 from the ingress while a pod restarts never reached the route: nothing was revoked.
+ it('a bare 502 (no route code) leaves the grant live and says the disconnect failed', async () => {
+ mockTools({ entries: [atlassian], grants: [grantAtl] });
+ axios.delete.mockRejectedValue({ response: { status: 502, data: 'Bad gateway' } });
+ renderPage();
+
+ const { aside } = await openRow('Atlassian', 'View Atlassian in Launch pod');
+ fireEvent.click(within(aside).getByRole('button', { name: 'Disconnect Atlassian' }));
+ fireEvent.click(within(aside).getByRole('button', { name: 'Yes, disconnect' }));
+ expect(await within(aside).findByText('Could not disconnect Atlassian.')).toBeInTheDocument();
+ expect(within(aside).getByRole('button', { name: 'Revoke' })).toBeInTheDocument();
+ expect(within(aside).queryByText(/Revoked by/)).not.toBeInTheDocument();
+ });
+
+ it('two connections of one entry offer no Disconnect, since a grant does not name its connection', async () => {
+ const two = { ...atlassian, connections: [...atlassian.connections, { connectionId: 'conn-atl-2', owner: 'me', repo: 'atlassian' }] };
+ mockTools({ entries: [two], grants: [] });
+ renderPage();
+
+ const popover = await openToolPopover('Atlassian');
+ expect(within(popover).getByRole('button', { name: 'Add' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /Disconnect/ })).not.toBeInTheDocument();
+ });
+
+ it('GitHub (the instance App connection) never offers Disconnect', async () => {
+ renderPage();
+ await openTool('GitHub');
+ expect(screen.queryByRole('button', { name: /Disconnect/ })).not.toBeInTheDocument();
+ });
+ });
+
+ it('a never-granted tool opens a popover that draws the offer, and Add mints the grant exactly as the server takes it, never a brokerId', async () => {
+ mockTools({
+ grants: [],
+ seats: { p1: [scout, { name: 'hosted-smoke', userId: 'x9', internal: true }], p2: [] },
+ });
+ axios.post.mockResolvedValue({ data: { grantId: 'grant_new' } });
+ renderPage();
+
+ const popover = await openToolPopover('GitHub');
+ await waitFor(() => expect(meta()).toHaveTextContent('0 on · 1 available'));
+ expect(within(popover).getByText('Issues and pull requests.')).toBeInTheDocument();
+ expect(within(popover).getByText('read, or read and write')).toBeInTheDocument();
+ expect(screen.queryByRole('img', { name: 'Granted' })).toBeNull();
+ fireEvent.click(within(popover).getByRole('button', { name: 'Add' }));
+ const form = await screen.findByRole('complementary', { name: 'Add GitHub' });
+ // Default: read, every non-internal seat in the room, 7 days; under read only read tools are on the list.
+ expect(within(form).getByRole('button', { name: 'read' })).toHaveAttribute('aria-pressed', 'true');
+ expect(within(form).getByText('github.list_issues')).toBeInTheDocument();
+ expect(within(form).queryByText('github.comment_on_issue')).toBeNull();
+ expect(within(form).getByText('nothing asks first')).toBeInTheDocument();
+ expect(within(form).getByRole('checkbox', { name: 'Scout' })).toBeChecked();
+ fireEvent.click(within(form).getByRole('button', { name: 'read and write' }));
+ expect(within(form).getByText('github.comment_on_issue')).toBeInTheDocument();
+ expect(within(form).getByText('github.comment_on_issue, github.close_issue ask first')).toBeInTheDocument();
+ fireEvent.change(within(form).getByRole('combobox', { name: 'ends' }), { target: { value: '30' } });
+ const before = grantReads().length;
+ fireEvent.click(within(form).getByRole('button', { name: 'Grant' }));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(1));
+ const [url, body] = axios.post.mock.calls[0];
+ expect(url).toBe('/api/grants');
+ expect(body).toMatchObject({ connectionId: 'conn-1', target: { kind: 'pod', id: 'p1' }, writeMode: 'write', audience: ['a1'], tools: ['github.list_issues', 'github.comment_on_issue', 'github.close_issue'] });
+ // Both are the server's: the broker from the catalogue, the installation from the Connection (#1677).
+ expect(body).not.toHaveProperty('brokerId');
+ expect(body).not.toHaveProperty('installationId');
+ expect(Object.keys(body).sort()).toEqual(['audience', 'connectionId', 'expiresAt', 'target', 'tools', 'writeMode']);
+ const days = (new Date(body.expiresAt).getTime() - Date.now()) / 86400000;
+ expect(days).toBeGreaterThan(29.9);
+ expect(days).toBeLessThan(30.1);
+ await waitFor(() => expect(grantReads().length).toBeGreaterThan(before));
+ });
+
+ it('an ended grant alone still draws the offer in its drawer, with Add and the modes', async () => {
+ mockTools({ grants: [grantRevoked] });
+ renderPage();
+
+ const drawer = await openTool('GitHub');
+ await waitFor(() => expect(meta()).toHaveTextContent('0 on · 1 available'));
+ expect(within(drawer).getByText('read, or read and write')).toBeInTheDocument();
+ expect(within(drawer).getByRole('button', { name: 'Add' })).toBeInTheDocument();
+ expect(within(drawer).getByText('inactive · 1')).toBeInTheDocument();
+ expect(screen.queryByRole('img', { name: 'Granted' })).toBeNull();
+ });
+
+ it('a tool without a Connection offers no Add, and a lock tile says an admin installs it once', async () => {
+ mockTools({ entries: [{ ...githubEntry, connections: [] }], grants: [] });
+ renderPage();
+
+ await findTile('GitHub');
+ expect(screen.getByRole('img', { name: 'An admin installs this once' })).toBeInTheDocument();
+ const popover = await openToolPopover('GitHub');
+ expect(within(popover).getByText('install the GitHub App first · an admin does this once')).toBeInTheDocument();
+ expect(within(popover).queryByRole('button', { name: 'Add' })).toBeNull();
+ expect(within(popover).queryByRole('button', { name: 'Install GitHub App' })).toBeNull();
+ });
+
+ describe('the GitHub App setup (admin)', () => {
+ const noConnection = { ...githubEntry, connections: [] };
+ const fillSetup = (form) => {
+ fireEvent.change(within(form).getByRole('textbox', { name: 'installation ID' }), { target: { value: 'install-42' } });
+ fireEvent.change(within(form).getByRole('textbox', { name: 'owner' }), { target: { value: 'Team-Commonly' } });
+ fireEvent.change(within(form).getByRole('textbox', { name: 'repository' }), { target: { value: 'commonly' } });
+ };
+
+ it('an admin can install the GitHub App from the no-connection tile', async () => {
+ mockTools({ entries: [noConnection], grants: [] });
+ axios.post.mockResolvedValue({ data: { integration: { installationId: 'install-42', createdBy: 'u1' } } });
+ renderPage({ auth: adminAuth() });
+
+ const popover = await openToolPopover('GitHub');
+ fireEvent.click(within(popover).getByRole('button', { name: 'Install GitHub App' }));
+ const form = await screen.findByRole('complementary', { name: 'Install GitHub App' });
+ fillSetup(form);
+ fireEvent.click(within(form).getByRole('button', { name: 'Install GitHub App' }));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
+ '/api/integrations/github-app',
+ { installationId: 'install-42', owner: 'Team-Commonly', repo: 'commonly' },
+ expect.any(Object),
+ ));
+ await waitFor(() => expect(screen.queryByRole('complementary', { name: 'Install GitHub App' })).toBeNull());
+ });
+
+ it('an admin is told when another administrator already owns the GitHub App installation', async () => {
+ mockTools({ entries: [noConnection], grants: [] });
+ axios.post.mockResolvedValue({ data: { integration: { installationId: 'install-42', createdBy: 'other-admin' } } });
+ renderPage({ auth: adminAuth({ _id: 'u2' }) });
+
+ const popover = await openToolPopover('GitHub');
+ fireEvent.click(within(popover).getByRole('button', { name: 'Install GitHub App' }));
+ const form = await screen.findByRole('complementary', { name: 'Install GitHub App' });
+ fillSetup(form);
+ fireEvent.click(within(form).getByRole('button', { name: 'Install GitHub App' }));
+
+ await waitFor(() => expect(within(form).getByRole('alert')).toHaveTextContent('This GitHub App is already installed by another administrator.'));
+ expect(screen.getByRole('complementary', { name: 'Install GitHub App' })).toBeInTheDocument();
+ });
+ });
+
+ it('an unavailable tool folds under "+N off" and offers the same Ask affordance as unavailable channels', async () => {
+ mockTools({ entries: [{ ...githubEntry, available: false, connections: [] }], grants: [] });
+ renderPage();
+
+ await screen.findByRole('region', { name: 'Tools' });
+ await settle();
+ expect(tileNode('GitHub')).toBeNull();
+ await expandOff('Tools');
+ expect(tileNode('GitHub')).toHaveClass('v2-cx-tile--dim');
+ const popover = await openToolPopover('GitHub');
+ const ask = within(popover).getByRole('link', { name: 'Ask' });
+ expect(screen.queryByText('not enabled on this instance · ask your operator')).not.toBeInTheDocument();
+ expect(ask).toHaveAttribute('href', 'https://github.com/Team-Commonly/commonly/issues/new?title=Connector%20request');
+ });
+
+ it('Change access mints a new grant then revokes the old one; Grant again on a dead grant only mints', async () => {
+ axios.post.mockResolvedValue({ data: { grantId: 'grant_new' } });
+ renderPage();
+
+ const { drawer, aside } = await openRow('GitHub', 'View GitHub in Launch pod');
+ // Under `write` the aside names the irreversible tools from the catalogue.
+ expect(within(aside).getByText('asks a person first').nextElementSibling).toHaveTextContent('every write asks first');
+ fireEvent.click(within(aside).getByRole('button', { name: 'Change access' }));
+ const form = await screen.findByRole('complementary', { name: 'Change access' });
+ expect(within(form).getByRole('button', { name: 'read and write, ask first' })).toHaveAttribute('aria-pressed', 'true');
+ expect(within(form).getByRole('checkbox', { name: 'Scout' })).toBeChecked();
+ fireEvent.click(within(form).getByRole('button', { name: 'Grant' }));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(2));
+ expect(axios.post.mock.calls[0][0]).toBe('/api/grants');
+ expect(axios.post.mock.calls[1][0]).toBe('/api/grants/grant_live/revoke');
+
+ axios.post.mockClear();
+ // The first grant is revoked too now (the page keeps a confirmed revoke), so pick the Ops row's own button.
+ const body = await screen.findByRole('region', { name: 'GitHub' });
+ fireEvent.click(within(within(body).getByRole('button', { name: 'View GitHub in Ops' }).closest('article')).getByRole('button', { name: 'Grant again' }));
+ const again = await screen.findByRole('complementary', { name: 'Add GitHub' });
+ expect(within(again).getByRole('heading', { name: 'GitHub · Ops' })).toBeInTheDocument();
+ fireEvent.click(within(again).getByRole('button', { name: 'Grant' }));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(1));
+ expect(axios.post.mock.calls[0][0]).toBe('/api/grants');
+ expect(drawer).toBeDefined();
+ });
+
+ describe('a member who did not grant it', () => {
+ const member = asUser({ _id: 'u2', username: 'rae' });
+
+ it('Revoke renders only for the granter', async () => {
+ renderPage({ auth: member });
+ const { aside } = await openRow('GitHub', 'View GitHub in Launch pod');
+ expect(within(aside).getByText(/Granted by sam/)).toBeInTheDocument();
+ expect(within(aside).queryByRole('button', { name: 'Revoke' })).toBeNull();
+ expect(within(aside).queryByRole('button', { name: 'Change access' })).toBeNull();
+ // The trail is the room's: a member still reads it.
+ await waitFor(() => expect(within(aside).getByText('calls').previousElementSibling).toHaveTextContent('3'));
+ });
+
+ it('Grant again renders only for the granter', async () => {
+ renderPage({ auth: member });
+ const drawer = await openTool('GitHub');
+ await within(drawer).findByRole('button', { name: 'View GitHub in Ops' });
+ // The dead row offers a member Manage (read the grant), never the mint.
+ expect(within(drawer).queryByRole('button', { name: 'Grant again' })).toBeNull();
+ expect(within(drawer).getAllByRole('button', { name: 'Manage' })).toHaveLength(2);
+ });
+ });
+
+ it('Change access keeps the minted grant when the revoke fails and retries only the revoke', async () => {
+ axios.post.mockImplementation((url) => (url === '/api/grants'
+ ? Promise.resolve({ data: { grantId: 'grant_new' } })
+ : Promise.reject(new Error('revoke down'))));
+ renderPage();
+
+ const { aside: first } = await openRow('GitHub', 'View GitHub in Launch pod');
+ fireEvent.click(within(first).getByRole('button', { name: 'Change access' }));
+ fireEvent.click(within(await screen.findByRole('complementary', { name: 'Change access' })).getByRole('button', { name: 'Grant' }));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(2));
+ const aside = await screen.findByRole('complementary', { name: 'Grant details' });
+ expect(await within(aside).findByText('The new grant is live. This old one still is too — its revoke did not go through.')).toBeInTheDocument();
+ expect(within(aside).queryByRole('button', { name: 'Revoke' })).toBeNull();
+ axios.post.mockResolvedValue({ data: { grantId: 'grant_live', revoked: 1 } });
+ fireEvent.click(within(aside).getByRole('button', { name: 'Revoke the old grant' }));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(3));
+ // Only the revoke went out again, no third mint.
+ expect(axios.post.mock.calls.map(([u]) => u)).toEqual(['/api/grants', '/api/grants/grant_live/revoke', '/api/grants/grant_live/revoke']);
+ });
+
+ describe('TASK-133: accounting with two entries', () => {
+ const dots = () => screen.queryAllByRole('img', { name: 'Granted' });
+
+ it('a live grant on one entry leaves the other entry still to grant', async () => {
+ mockTools({ entries: [githubEntry, linearEntry], grants: [grantLinear, grantRevoked] });
+ renderPage();
+
+ // One entry granted, so one is still available, and the entry the grant names
+ // is the one that carries the Granted dot (label and glyph from its own entry).
+ await screen.findByRole('img', { name: 'Granted' });
+ await waitFor(() => expect(meta()).toHaveTextContent('1 on · 1 available'));
+ expect(dots()).toHaveLength(1);
+ expect(tileNode('Linear').querySelector('.v2-cx-tile__dot')).not.toBeNull();
+ expect(tileNode('GitHub').querySelector('.v2-cx-tile__dot')).toBeNull();
+
+ const linear = await openTool('Linear');
+ expect(within(linear).getByRole('button', { name: 'View Linear in Launch pod' })).toBeInTheDocument();
+ expect(within(linear).queryByRole('button', { name: /^View GitHub/ })).not.toBeInTheDocument();
+
+ const github = await openTool('GitHub');
+ // Its only grant is the ended one, so the drawer carries the offer beside it.
+ expect(within(github).queryByRole('button', { name: 'View GitHub in Launch pod' })).not.toBeInTheDocument();
+ expect(within(github).getByRole('button', { name: 'View GitHub in Ops' })).toBeInTheDocument();
+ expect(within(github).getByRole('button', { name: 'Add' })).toBeInTheDocument();
+ });
+
+ it('the granted entry itself leaves the offer, the other one stays', async () => {
+ mockTools({ entries: [githubEntry, linearEntry], grants: [grantLive, grantRevoked] });
+ renderPage();
+
+ await screen.findByRole('img', { name: 'Granted' });
+ expect(tileNode('GitHub').querySelector('.v2-cx-tile__dot')).not.toBeNull();
+ expect(tileNode('Linear').querySelector('.v2-cx-tile__dot')).toBeNull();
+ const github = await openTool('GitHub');
+ expect(within(github).queryByRole('button', { name: 'Add' })).not.toBeInTheDocument();
+ const linear = await openToolPopover('Linear');
+ expect(linear.textContent).toContain('Issues in Linear.');
+ expect(linear.textContent).not.toContain('GitHub');
+ });
+
+ it('with every entry granted the head carries no available count', async () => {
+ mockTools({ entries: [githubEntry, linearEntry], grants: [grantLive, grantLinear, grantRevoked] });
+ renderPage();
+
+ await waitFor(() => expect(dots()).toHaveLength(2));
+ expect(meta()).toHaveTextContent('2 on · 0 available');
+ });
+ });
+
+ it('Linear exposes only its pinned read mode in its offer and Add form', async () => {
+ mockTools({ entries: [githubEntry, linearEntry], grants: [grantLive] });
+ renderPage();
+
+ const popover = await openToolPopover('Linear');
+ expect(popover).toHaveTextContent('read');
+ expect(popover).not.toHaveTextContent('read, or read and write');
+
+ fireEvent.click(within(popover).getByRole('button', { name: 'Add' }));
+ const form = await screen.findByRole('complementary', { name: 'Add Linear' });
+ const modeGroup = within(form).getByRole('group', { name: 'what it may do' });
+ expect(within(modeGroup).getAllByRole('button')).toHaveLength(1);
+ expect(within(modeGroup).getByRole('button', { name: 'read' })).toHaveAttribute('aria-pressed', 'true');
+ });
+
+ it('search filters tiles by tool name, and says so when nothing matches', async () => {
+ renderPage();
+ await findTile('GitHub');
+ fireEvent.change(screen.getByRole('searchbox', { name: 'Search' }), { target: { value: 'comment' } });
+ expect(tileNode('GitHub')).not.toBeNull();
+ fireEvent.change(screen.getByRole('searchbox', { name: 'Search' }), { target: { value: 'zzz' } });
+ expect(tileNode('GitHub')).toBeNull();
+ expect(screen.getByText('Nothing matches.')).toBeInTheDocument();
+ });
+
+ it('with no grants and no catalogue the Tools group holds no tile and no row', async () => {
+ mockApi({ installables: [], grants: [], pods });
+ renderPage();
+
+ const tools = await screen.findByRole('region', { name: 'Tools' });
+ await settle();
+ expect(tools.querySelector('.v2-cx-tile')).toBeNull();
+ expect(document.querySelector('.v2-connector-row')).toBeNull();
+ expect(meta()).toHaveTextContent('0 on · 0 available');
+ });
+
+ /**
+ * Scope §4, step 7's named test: "the page's call to start is credentialed".
+ *
+ * The start route binds a connect to the browser that began it with an httpOnly
+ * cookie on its OWN response, and the API is a different origin from the app.
+ * Without `withCredentials` the browser drops the `Set-Cookie`, so a person
+ * consents at the vendor and comes back to a row that is still not connected,
+ * every callback refusing `invalid_state`. A backend suite sets the cookie itself
+ * and cannot witness this flag; this arm is the one that can.
+ */
+ it('step 7: the page\'s call to start a hosted connect is credentialed', async () => {
+ const hosted = { ...linearEntry, description: 'Issues and projects in Linear.', connections: [] };
+ mockTools({ entries: [hosted], grants: [] });
+ axios.post.mockResolvedValue({ data: { authorizeUrl: 'https://mcp.linear.app/authorize?state=s' } });
+ const opened = { opener: null, location: { assign: jest.fn() }, close: jest.fn() };
+ jest.spyOn(window, 'open').mockReturnValue(opened);
+ renderPage();
+
+ const popover = await openToolPopover('Linear');
+ fireEvent.click(within(popover).getByRole('button', { name: 'Connect' }));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
+ '/api/integrations/connect/hosted-mcp/linear/start',
+ {},
+ expect.objectContaining({ withCredentials: true }),
+ ));
+ expect(opened.location.assign).toHaveBeenCalledWith('https://mcp.linear.app/authorize?state=s');
+ });
+
+ it('a hosted connector shows vendor setup guidance before connect', async () => {
+ mockTools({ entries: [{ ...linearEntry, connections: [], connectNotice: 'Ask your admin to allow this domain.' }], grants: [] });
+ renderPage();
+
+ const popover = await openToolPopover('Linear');
+ expect(within(popover).getByText('Ask your admin to allow this domain.')).toBeInTheDocument();
+ expect(within(popover).getByRole('button', { name: 'Connect' })).toBeInTheDocument();
+ });
+
+ // TASK-290: GitHub's App user token reaches only the repositories the App is
+ // installed on, so the page that picks them sits beside Connect and stays after
+ // connecting, while the person decides what to grant.
+ it('a hosted connector links to where the person chooses its reach, before and after connecting', async () => {
+ const accessPage = 'https://github.com/apps/commonly-connect/installations/new';
+ mockTools({ entries: [{ ...linearEntry, connections: [], accessPage }], grants: [] });
+ const first = renderPage();
+ const popover = await openToolPopover('Linear');
+ const before = within(popover).getByRole('link', { name: 'Choose what Linear can reach' });
+ expect(before).toHaveAttribute('href', accessPage);
+ expect(before).toHaveAttribute('target', '_blank');
+ expect(before).toHaveAttribute('rel', 'noopener noreferrer');
+ expect(within(popover).getByRole('button', { name: 'Connect' })).toBeInTheDocument();
+ first.unmount();
+
+ mockTools({ entries: [{ ...linearEntry, accessPage }], grants: [] });
+ renderPage();
+ const after = await openToolPopover('Linear');
+ expect(within(after).getByRole('link', { name: 'Choose what Linear can reach' })).toHaveAttribute('href', accessPage);
+ expect(within(after).queryByRole('button', { name: 'Connect' })).toBeNull();
+ });
+
+ // A live grant takes the entry off the offer, so the page moves to the grant's
+ // details. Only the granter, who owns the connection, is sent there.
+ it('a live hosted grant keeps the reach link on its details for the granter only', async () => {
+ const accessPage = 'https://github.com/apps/commonly-connect/installations/new';
+ const entry = { ...linearEntry, accessPage };
+ mockTools({ entries: [entry], grants: [grantLinear] });
+ const first = renderPage();
+ const { aside } = await openRow('Linear', 'View Linear in Launch pod');
+ expect(within(aside).getByRole('link', { name: 'Choose what Linear can reach' })).toHaveAttribute('href', accessPage);
+ first.unmount();
+
+ mockTools({ entries: [entry], grants: [{ ...grantLinear, grantedBy: 'u2' }] });
+ renderPage();
+ const { aside: other } = await openRow('Linear', 'View Linear in Launch pod');
+ expect(within(other).queryByRole('link', { name: /can reach/ })).toBeNull();
+ });
+
+ it('a hosted connector without an access page offers no reach link', async () => {
+ mockTools({ entries: [{ ...linearEntry, connections: [] }], grants: [] });
+ renderPage();
+
+ const popover = await openToolPopover('Linear');
+ expect(within(popover).getByRole('button', { name: 'Connect' })).toBeInTheDocument();
+ expect(screen.queryByRole('link', { name: /can reach/ })).toBeNull();
+ });
+
+ it('a hidden hosted entry has no offering while its existing connection can be removed', async () => {
+ mockTools({
+ entries: [],
+ grants: [],
+ hidden: [{ connectionId: 'conn-hidden', entryId: 'airtable', label: 'Airtable', status: 'connected', revokePage: 'https://airtable.com/account' }],
+ });
+ axios.delete.mockResolvedValue({ data: { message: 'removed' } });
+ renderPage();
+
+ await settle();
+ await expandOff('Tools');
+ expect(await screen.findByText('Airtable')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Connect' })).toBeNull();
+ expect(screen.queryByRole('button', { name: 'Add' })).toBeNull();
+ fireEvent.click(screen.getByRole('button', { name: 'Remove connection' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Yes, remove' }));
+ await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/integrations/conn-hidden', expect.anything()));
+ expect(await screen.findByText(/Connection removed. Review its access at Airtable/)).toBeInTheDocument();
+ });
+
+ it('a hosted connect start error uses fixed copy instead of response text', async () => {
+ mockTools({ entries: [{ ...linearEntry, connections: [] }], grants: [] });
+ axios.post.mockRejectedValue({
+ response: { data: { error: 'client_not_configured', message: 'spoofed vendor text' } },
+ });
+ const opened = { opener: null, location: { assign: jest.fn() }, close: jest.fn() };
+ jest.spyOn(window, 'open').mockReturnValue(opened);
+ renderPage();
+
+ const popover = await openToolPopover('Linear');
+ fireEvent.click(within(popover).getByRole('button', { name: 'Connect' }));
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ 'This connector’s sign-in setup needs attention. Ask an administrator to check it.',
+ );
+ expect(screen.queryByText(/spoofed vendor text/)).toBeNull();
+ expect(opened.close).toHaveBeenCalled();
+ });
+
+ it.each([400, 403])('a hosted grant HTTP %s stays open with the server refusal', async (status) => {
+ mockTools({ entries: [linearEntry], grants: [] });
+ axios.post.mockRejectedValue({ response: { status, data: {
+ error: 'invalid_target', message: 'The selected target is unavailable.',
+ } } });
+ renderPage();
+
+ const popover = await openToolPopover('Linear');
+ fireEvent.click(within(popover).getByRole('button', { name: 'Add' }));
+ const form = await screen.findByRole('complementary', { name: 'Add Linear' });
+ fireEvent.click(within(form).getByRole('button', { name: 'Grant' }));
+ expect(await within(form).findByRole('alert')).toHaveTextContent('The selected target is unavailable.');
+ expect(screen.getByRole('complementary', { name: 'Add Linear' })).toBeInTheDocument();
+ expect(axios.post).toHaveBeenCalledWith('/api/grants', expect.objectContaining({
+ connectionId: 'conn-2', target: { kind: 'pod', id: 'p1' }, audience: ['a1'],
+ }), expect.any(Object));
+ });
+
+ it.each([undefined, {}, { error: 'invalid_target' }, 'fallback', { grantId: '' }, { grantId: ' ' }])(
+ 'an unconfirmed mint response keeps the form and old grant: %p', async (body) => {
+ axios.post.mockResolvedValue({ data: body });
+ renderPage();
+
+ const { aside } = await openRow('GitHub', 'View GitHub in Launch pod');
+ fireEvent.click(within(aside).getByRole('button', { name: 'Change access' }));
+ const form = await screen.findByRole('complementary', { name: 'Change access' });
+ fireEvent.click(within(form).getByRole('button', { name: 'Grant' }));
+ expect(await within(form).findByRole('alert')).toHaveTextContent('Could not grant it.');
+ expect(screen.getByRole('complementary', { name: 'Change access' })).toBeInTheDocument();
+ expect(axios.post.mock.calls.map(([url]) => url)).toEqual(['/api/grants']);
+ },
+ );
+
+ it.each(['unavailable', 'removed'])('a catalogue refresh (%s) cannot hide a refused hosted grant draft', async (refresh) => {
+ mockTools({ entries: [linearEntry], grants: [] });
+ const message = 'Agents can self-install into this pod. Make it invite-only to use this connector.';
+ axios.post.mockRejectedValue({ response: { status: 403, data: { error: 'outsider_grant_pod_joinable', message } } });
+ renderPage();
+
+ const popover = await openToolPopover('Linear');
+ fireEvent.click(within(popover).getByRole('button', { name: 'Add' }));
+ const form = await screen.findByRole('complementary', { name: 'Add Linear' });
+ fireEvent.click(within(form).getByRole('button', { name: 'Grant' }));
+ expect(await within(form).findByRole('alert')).toHaveTextContent(message);
+ const previousGet = axios.get.getMockImplementation();
+ axios.get.mockImplementation((url, ...args) => (url === '/api/installables'
+ ? (refresh === 'unavailable' ? Promise.reject(new Error('offline')) : Promise.resolve({ data: { installables: [] } }))
+ : previousGet(url, ...args)));
+ const reads = axios.get.mock.calls.length;
+ await act(async () => { document.dispatchEvent(new Event('visibilitychange')); });
+ await waitFor(() => expect(axios.get.mock.calls.length).toBeGreaterThan(reads));
+ expect(screen.getByRole('complementary', { name: 'Add Linear' })).toBeInTheDocument();
+ expect(screen.getByRole('alert')).toHaveTextContent(message);
+ await waitFor(() => expect(meta()).toHaveTextContent('0 on'));
+ });
+
+ it('hosted grant picker sends the selected pod ID and keeps the agent User ID in audience', async () => {
+ mockTools({
+ entries: [linearEntry],
+ grants: [],
+ seats: { p1: [scout], p2: [{ name: 'reed', userId: 'a2' }] },
+ });
+ renderPage();
+
+ const popover = await openToolPopover('Linear');
+ fireEvent.click(within(popover).getByRole('button', { name: 'Add' }));
+ const form = await screen.findByRole('complementary', { name: 'Add Linear' });
+ fireEvent.change(within(form).getByRole('combobox', { name: 'pod' }), { target: { value: 'p2' } });
+ fireEvent.click(within(form).getByRole('button', { name: 'Grant' }));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/grants', expect.objectContaining({
+ target: { kind: 'pod', id: 'p2' }, audience: ['a2'],
+ }), expect.anything()));
+ });
+
+ it('the dialog cannot submit an empty audience and re-enables grant when an agent is selected', async () => {
+ mockTools({ entries: [linearEntry], grants: [] });
+ renderPage();
+
+ const popover = await openToolPopover('Linear');
+ fireEvent.click(within(popover).getByRole('button', { name: 'Add' }));
+ const form = await screen.findByRole('complementary', { name: 'Add Linear' });
+ const submit = within(form).getByRole('button', { name: 'Grant' });
+ expect(submit).toBeEnabled();
+ fireEvent.click(within(form).getByRole('checkbox', { name: 'Scout' }));
+ expect(within(form).getByRole('status')).toHaveTextContent('Select at least one agent to grant access.');
+ expect(submit).toBeDisabled();
+ fireEvent.click(submit);
+ expect(axios.post).not.toHaveBeenCalled();
+ fireEvent.click(within(form).getByRole('checkbox', { name: 'Scout' }));
+ expect(submit).toBeEnabled();
+ fireEvent.click(submit);
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/grants', expect.objectContaining({ audience: ['a1'] }), expect.anything()));
+ });
+
+ it('Grant again on an existing empty-audience row requires an explicit new selection', async () => {
+ mockTools({ entries: [linearEntry], grants: [{ ...grantRevoked, tools: ['linear.list_issues'], effectiveAudience: [] }] });
+ renderPage();
+
+ const drawer = await openTool('Linear');
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Grant again' }));
+ const form = await screen.findByRole('complementary', { name: 'Add Linear' });
+ const agent = within(form).getByRole('checkbox', { name: 'Scout' });
+ expect(agent).not.toBeChecked();
+ const submit = within(form).getByRole('button', { name: 'Grant' });
+ expect(submit).toBeDisabled();
+ fireEvent.click(submit);
+ expect(axios.post).not.toHaveBeenCalled();
+ fireEvent.click(agent);
+ fireEvent.click(submit);
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/grants', expect.objectContaining({ audience: ['a1'], target: { kind: 'pod', id: 'p2' } }), expect.anything()));
+ });
+
+ it.each([
+ { label: 'human-only', audience: ['u1'], selected: false },
+ { label: 'mixed human and agent', audience: ['u1', 'a1'], selected: true },
+ ])('Grant again restores only current agents from a $label legacy audience', async ({ audience, selected }) => {
+ mockTools({ grants: [{ ...grantRevoked, effectiveAudience: audience }] });
+ renderPage();
+
+ const drawer = await openTool('GitHub');
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Grant again' }));
+ const form = await screen.findByRole('complementary', { name: 'Add GitHub' });
+ const scoutBox = within(form).getByRole('checkbox', { name: 'Scout' });
+ const submit = within(form).getByRole('button', { name: 'Grant' });
+ expect(scoutBox.checked).toBe(selected);
+ if (!selected) {
+ expect(submit).toBeDisabled();
+ expect(within(form).getByText('Select at least one agent to grant access.')).toBeInTheDocument();
+ fireEvent.click(submit);
+ expect(axios.post).not.toHaveBeenCalled();
+ fireEvent.click(scoutBox);
+ }
+ expect(submit).toBeEnabled();
+ fireEvent.click(submit);
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/grants', expect.objectContaining({
+ target: { kind: 'pod', id: 'p2' }, audience: ['a1'],
+ }), expect.anything()));
+ });
+
+ it('a tool popover shows the zh-CN catalog copy, and an unmapped entry keeps the server text', async () => {
+ const linear = {
+ installableId: 'linear', list: 'tools', label: 'Linear', description: 'Read Linear issues and projects.',
+ connectionType: 'hosted-mcp', entryId: 'linear', available: true, tools: [], connections: [], installation: null, integration: null,
+ };
+ mockTools({
+ entries: [linear, { ...linear, installableId: 'acme-tools', label: 'Acme', description: 'Acme server copy.', entryId: 'acme' }],
+ grants: [],
+ });
+ await i18nReady;
+ await act(async () => { await i18n.changeLanguage('zh-CN'); });
+ try {
+ renderPage();
+ const mapped = await openToolPopover('Linear');
+ expect(within(mapped).getByText('你自己的 Linear 工作空间中的议题、项目、周期和发布。')).toBeInTheDocument();
+ const unmapped = await openToolPopover('Acme');
+ expect(within(unmapped).getByText('Acme server copy.')).toBeInTheDocument();
+ } finally {
+ await act(async () => { await i18n.changeLanguage('en'); });
+ }
+ });
+
+ it('the open grant form reads in zh-CN, not English', async () => {
+ await i18nReady;
+ await act(async () => { await i18n.changeLanguage('zh-CN'); });
+ try {
+ mockTools({ grants: [grantRevoked], trails: {} });
+ renderPage();
+ const drawer = await openTool('GitHub');
+ fireEvent.click(await within(drawer).findByRole('button', { name: /^添加$|^Add$/ }));
+ const form = await screen.findByRole('complementary');
+ expect(within(form).getByText('无需确认')).toBeInTheDocument();
+ expect(within(form).queryByText('nothing asks first')).toBeNull();
+ expect(within(form).queryByText('read and write, ask first')).toBeNull();
+ // No English button left in the zh-CN form.
+ expect(within(form).queryByRole('button', { name: 'Grant' })).toBeNull();
+ } finally {
+ await act(async () => { await i18n.changeLanguage('en'); });
+ }
+ });
+
+ // The old inactive fold (retired fold tests): the roster of ended grants no
+ // longer hides behind a closed fold. Never-granted entries are tiles, and the ended
+ // grants of one entry sit under an "inactive · N" heading in that entry's drawer.
+ describe('ended grants and never-granted offers', () => {
+ const base = {
+ grantId: 'g', installationId: 'inst-github', target: { kind: 'pod', id: 'p1' }, tools: ['github.list_issues'],
+ writeMode: 'read', budget: null, effectiveAudience: [], expiresAt: iso(86_400_000),
+ revokedAt: null, revokedBy: null, parentGrantId: null, rootGrantId: null, createdAt: iso(-86_400_000), grantedBy: 'u1',
+ };
+ const grant = (id, revokedAt) => ({ ...base, grantId: id, revokedAt, revokedBy: revokedAt ? 'u1' : null });
+ const expired = (id) => ({ ...base, grantId: id, expiresAt: iso(-86_400_000) });
+ const yesterday = iso(-86_400_000);
+ const offers = ['GitHub', 'Linear', 'Atlassian', 'Airtable', 'Sentry'].map((label) => ({
+ ...githubEntry, installableId: label.toLowerCase(), label, entryId: label.toLowerCase(),
+ tools: [{ name: `${label.toLowerCase()}.list_issues`, requiredWriteMode: 'read', irreversible: false }],
+ }));
+ const onePod = [{ _id: 'p1', name: 'Launch pod', type: 'chat', members: [{ _id: 'u1', username: 'sam' }] }];
+ const toolTiles = () => Array.from(screen.getByRole('region', { name: 'Tools' }).querySelectorAll('.v2-cx-tile__name')).map((node) => node.textContent);
+
+ it('five never-granted catalogue offers are five tiles, with no inactive heading', async () => {
+ mockTools({ entries: offers, grants: [], podList: onePod, seats: {} });
+ renderPage();
+
+ await findTile('Sentry');
+ expect(toolTiles()).toEqual(['GitHub', 'Linear', 'Atlassian', 'Airtable', 'Sentry']);
+ await waitFor(() => expect(meta()).toHaveTextContent('0 on · 5 available'));
+ expect(screen.queryByText(/inactive ·/)).not.toBeInTheDocument();
+ expect(screen.queryByText('Nothing matches.')).not.toBeInTheDocument();
+ });
+
+ it('eight ended grants stay out of the five offer tiles and list under one heading in their entry drawer', async () => {
+ const grants = [
+ ...Array.from({ length: 4 }, (_, i) => grant(`revoked-${i}`, yesterday)),
+ ...Array.from({ length: 4 }, (_, i) => expired(`expired-${i}`)),
+ ];
+ mockTools({ entries: offers, grants, podList: onePod, seats: {} });
+ renderPage();
+
+ await findTile('Sentry');
+ await settle();
+ expect(toolTiles()).toEqual(['GitHub', 'Linear', 'Atlassian', 'Airtable', 'Sentry']);
+ expect(document.querySelectorAll('.v2-connector-row')).toHaveLength(0);
+ const drawer = await openTool('GitHub');
+ expect(within(drawer).getByText('inactive · 8')).toBeInTheDocument();
+ expect(drawer.querySelectorAll('.v2-connector-row')).toHaveLength(8);
+ });
+
+ it('two live grants come first and nine ended ones follow under "inactive · 9"', async () => {
+ const grants = [
+ grant('live-1', null), grant('live-2', null),
+ ...Array.from({ length: 4 }, (_, i) => grant(`revoked-${i}`, yesterday)),
+ ...Array.from({ length: 5 }, (_, i) => expired(`expired-${i}`)),
+ ];
+ mockTools({ grants, entries: [githubEntry], podList: onePod, seats: {} });
+ renderPage();
+
+ const drawer = await openTool('GitHub');
+ const rows = Array.from(drawer.querySelectorAll('.v2-connector-row'));
+ expect(rows).toHaveLength(11);
+ expect(rows.slice(0, 2).every((row) => !row.classList.contains('v2-connector-row--dead'))).toBe(true);
+ expect(rows.slice(2).every((row) => row.classList.contains('v2-connector-row--dead'))).toBe(true);
+ expect(within(drawer).getByText('inactive · 9')).toBeInTheDocument();
+ expect(screen.queryByText(/not enabled|not yet|coming/i)).not.toBeInTheDocument();
+ });
+
+ it('search keeps a matching offer and drops the hidden connection that does not match', async () => {
+ const linear = {
+ ...githubEntry, installableId: 'linear', connectionType: 'hosted-mcp', entryId: 'linear', label: 'Linear', description: 'Linear issues',
+ tools: [{ name: 'linear.list_issues', requiredWriteMode: 'read', irreversible: false }],
+ };
+ mockTools({
+ entries: [githubEntry, linear],
+ grants: [grant('live', null), grant('revoked', yesterday)],
+ hidden: [{ connectionId: 'hidden-1', entryId: 'atlassian', label: 'Atlassian', status: 'inactive', revokePage: null }],
+ podList: onePod,
+ seats: {},
+ });
+ renderPage();
+
+ expect(await screen.findByRole('button', { name: '+1 off' })).toBeInTheDocument();
+ fireEvent.change(screen.getByRole('searchbox', { name: 'Search' }), { target: { value: 'Linear' } });
+ expect(screen.queryByRole('button', { name: /^\+\d+ off$/ })).not.toBeInTheDocument();
+ expect(toolTiles()).toEqual(['Linear']);
+ expect(document.body).not.toHaveTextContent('Atlassian');
+ });
+ });
+});
diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.fold.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.fold.test.tsx
deleted file mode 100644
index b85f038367..0000000000
--- a/frontend/src/v2/__tests__/V2ConnectorTools.fold.test.tsx
+++ /dev/null
@@ -1,132 +0,0 @@
-import React from 'react';
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { MemoryRouter } from 'react-router-dom';
-import V2ConnectorTools from '../components/V2ConnectorTools';
-import { AuthContext } from '../../context/AuthContext';
-import i18n, { i18nReady } from '../../i18n';
-
-jest.mock('axios', () => {
- const mock = {
- get: jest.fn(), post: jest.fn(), patch: jest.fn(), delete: jest.fn(),
- defaults: { baseURL: '', headers: { common: {} } },
- interceptors: { request: { use: jest.fn(), eject: jest.fn() }, response: { use: jest.fn(), eject: jest.fn() } },
- };
- return { __esModule: true, default: mock, ...mock };
-});
-const axios = jest.requireMock('axios').default;
-
-const auth = {
- currentUser: { _id: 'u1', username: 'sam', email: 'sam@example.test' }, user: { _id: 'u1', username: 'sam', email: 'sam@example.test' }, token: 'user-jwt',
- loading: false, error: null, isAuthenticated: true, register: jest.fn(), login: jest.fn(), signInWithToken: jest.fn(), logout: jest.fn(), updateProfile: jest.fn(),
-};
-const tomorrow = new Date(Date.now() + 86_400_000).toISOString();
-const yesterday = new Date(Date.now() - 86_400_000).toISOString();
-const grant = (id: string, revokedAt: string | null) => ({
- grantId: id, installationId: 'inst-github', target: { kind: 'pod', id: 'p1' }, tools: ['github.list_issues'],
- writeMode: 'read', budget: null, effectiveAudience: [], expiresAt: tomorrow,
- revokedAt, revokedBy: revokedAt ? 'u1' : null, parentGrantId: null, rootGrantId: null,
- createdAt: yesterday, grantedBy: 'u1',
-});
-const catalog = [{ installableId: 'github', list: 'tools', label: 'GitHub', description: 'Issues', available: true,
- tools: [{ name: 'github.list_issues', requiredWriteMode: 'read', irreversible: false }], connections: [] }];
-const offers = ['GitHub', 'Linear', 'Atlassian', 'Airtable', 'Sentry'].map((label) => ({
- ...catalog[0], installableId: label.toLowerCase(), label,
- tools: [{ name: `${label.toLowerCase()}.list_issues`, requiredWriteMode: 'read', irreversible: false }],
-}));
-
-const mockTools = (entries: typeof catalog, grants: ReturnType[]) => {
- axios.get.mockImplementation((url: string) => {
- if (url === '/api/installables') return Promise.resolve({ data: { installables: entries } });
- if (url === '/api/grants') return Promise.resolve({ data: { grants } });
- if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } });
- if (url.includes('/calls')) return Promise.resolve({ data: { calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } });
- return Promise.reject(new Error(`unmocked ${url}`));
- });
-};
-
-const renderTools = () => render(
-
-);
-
-beforeAll(async () => { await i18nReady; await i18n.changeLanguage('en'); });
-
-test('five never-granted catalogue offers are visible with no inactive fold', async () => {
- mockTools(offers, []);
- const { container } = renderTools();
-
- await waitFor(() => expect(container.querySelectorAll('.v2-connector-row')).toHaveLength(5));
- expect(screen.getByText('0 granted · 5 more')).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: /inactive ·/ })).not.toBeInTheDocument();
- expect([...container.querySelectorAll('.v2-connector-row__name')].map((row) => row.textContent))
- .toEqual(['GitHub', 'Linear', 'Atlassian', 'Airtable', 'Sentry']);
- expect(screen.queryByText('Nothing matches.')).not.toBeInTheDocument();
-});
-
-test('eight ended grants fold below five never-granted offers', async () => {
- const grants = [
- ...Array.from({ length: 4 }, (_, i) => grant(`revoked-${i}`, yesterday)),
- ...Array.from({ length: 4 }, (_, i) => ({ ...grant(`expired-${i}`, null), expiresAt: yesterday })),
- ];
- mockTools(offers, grants);
- const { container } = renderTools();
-
- const fold = await screen.findByRole('button', { name: 'inactive · 8' });
- expect(fold).toHaveAttribute('aria-expanded', 'false');
- expect(container.querySelectorAll('.v2-connector-row')).toHaveLength(5);
- expect([...container.querySelectorAll('.v2-connector-row__name')].map((row) => row.textContent))
- .toEqual(['GitHub', 'Linear', 'Atlassian', 'Airtable', 'Sentry']);
- fireEvent.click(fold);
- expect(container.querySelectorAll('.v2-connector-row')).toHaveLength(13);
-});
-
-test('two live grants stay visible and nine revoked or expired grants start in one closed fold', async () => {
- const grants = [grant('live-1', null), grant('live-2', null),
- ...Array.from({ length: 4 }, (_, i) => grant(`revoked-${i}`, yesterday)),
- ...Array.from({ length: 5 }, (_, i) => ({ ...grant(`expired-${i}`, null), expiresAt: yesterday }))];
- axios.get.mockImplementation((url: string) => {
- if (url === '/api/installables') return Promise.resolve({ data: { installables: catalog } });
- if (url === '/api/grants') return Promise.resolve({ data: { grants } });
- if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } });
- if (url.includes('/calls')) return Promise.resolve({ data: { calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } });
- return Promise.reject(new Error(`unmocked ${url}`));
- });
-
- const { container } = render(
-
- );
-
- const fold = await screen.findByRole('button', { name: 'inactive · 9' });
- expect(fold).toHaveAttribute('aria-expanded', 'false');
- expect(container.querySelectorAll('.v2-connector-row')).toHaveLength(2);
- expect(screen.queryByText(/not enabled|not yet|coming/i)).not.toBeInTheDocument();
-
- fireEvent.click(fold);
- await waitFor(() => expect(container.querySelectorAll('.v2-connector-row')).toHaveLength(11));
- expect(fold).toHaveAttribute('aria-expanded', 'true');
-});
-
-test('search keeps a matching catalogue offer visible and folds only ended connections', async () => {
- const entries = [catalog[0], { ...catalog[0], installableId: 'linear', connectionType: 'hosted-mcp', entryId: 'linear', label: 'Linear', description: 'Linear issues',
- tools: [{ name: 'linear.list_issues', requiredWriteMode: 'read', irreversible: false }] }];
- axios.get.mockImplementation((url: string) => {
- if (url === '/api/installables') return Promise.resolve({ data: { installables: entries, hiddenToolConnections: [
- { connectionId: 'hidden-1', entryId: 'atlassian', label: 'Atlassian', status: 'inactive', revokePage: null },
- ] } });
- if (url === '/api/grants') return Promise.resolve({ data: { grants: [grant('live', null), grant('revoked', yesterday)] } });
- if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } });
- if (url.includes('/calls')) return Promise.resolve({ data: { calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } });
- return Promise.reject(new Error(`unmocked ${url}`));
- });
-
- const { container } = render(
-
- );
- expect(await screen.findByRole('button', { name: 'inactive · 2' })).toBeInTheDocument();
-
- fireEvent.change(screen.getByRole('searchbox', { name: 'Search tools' }), { target: { value: 'Linear' } });
- expect(screen.queryByRole('button', { name: /inactive ·/ })).not.toBeInTheDocument();
- expect(container.querySelectorAll('.v2-connector-row')).toHaveLength(1);
- expect(container.querySelector('.v2-connector-row')).toHaveTextContent('Linear');
- expect(container).not.toHaveTextContent('Atlassian');
- expect(container.querySelector('.v2-connector-row__name')).toHaveTextContent('Linear');
-});
diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx
deleted file mode 100644
index 50024d7a78..0000000000
--- a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx
+++ /dev/null
@@ -1,1236 +0,0 @@
-// @ts-nocheck
-// Tools list (tools plan §6): rows from the projected grants, the aside from the
-// grant, the trail and its three counts from the broker's rows. Nothing here
-// draws a control the server does not enforce.
-import React from 'react';
-import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
-import { MemoryRouter } from 'react-router-dom';
-import V2ConnectorTools from '../components/V2ConnectorTools';
-import { AuthContext } from '../../context/AuthContext';
-import i18n, { i18nReady } from '../../i18n';
-
-// These existing cases exercise grant actions and row copy with the roster
-// expanded. The collapsed interaction is covered by V2ConnectorTools.fold.
-jest.mock('../components/V2InactiveFold', () => ({
- __esModule: true,
- default: ({ children }) => <>{children}>,
-}));
-
-jest.mock('axios', () => {
- const mock = {
- get: jest.fn(), post: jest.fn(), patch: jest.fn(), delete: jest.fn(),
- defaults: { baseURL: '', headers: { common: {} } },
- interceptors: { request: { use: jest.fn(), eject: jest.fn() }, response: { use: jest.fn(), eject: jest.fn() } },
- };
- return { __esModule: true, default: mock, ...mock };
-});
-const axios = jest.requireMock('axios').default;
-
-const authValue = {
- currentUser: { _id: 'u1', username: 'sam' }, user: { _id: 'u1', username: 'sam' }, token: 'user-jwt',
- loading: false, error: null, isAuthenticated: true, register: jest.fn(), login: jest.fn(), logout: jest.fn(), updateProfile: jest.fn(),
-};
-
-const NOW = Date.now();
-const iso = (ms) => new Date(NOW + ms).toISOString();
-const pods = [
- { _id: 'p1', name: 'Launch pod', members: [{ _id: 'u1', username: 'sam' }, { _id: 'a1', username: 'scout' }] },
- { _id: 'p2', name: 'Ops', members: [{ _id: 'u1', username: 'sam' }, { _id: 'a1', username: 'scout' }] },
-];
-const grantLive = {
- grantId: 'grant_live', installationId: 'inst-1', target: { kind: 'pod', id: 'p1' }, tools: ['github.list_issues', 'github.comment_on_issue'],
- writeMode: 'write-with-confirm', budget: { calls: 50, windowMs: 3600000 }, effectiveAudience: ['a1'], expiresAt: iso(6 * 86400000),
- revokedAt: null, revokedBy: null, parentGrantId: null, rootGrantId: null, createdAt: iso(-3600000), grantedBy: 'u1',
-};
-const grantRevoked = { ...grantLive, grantId: 'grant_gone', target: { kind: 'pod', id: 'p2' }, writeMode: 'read', effectiveAudience: ['a1'], revokedAt: iso(-600000), revokedBy: 'u1', createdAt: iso(-86400000) };
-const trail = {
- grantId: 'grant_live',
- calls: [
- { callId: 'c1', agentUserId: 'a1', tool: 'github.list_issues', outcome: 'ok', reason: null, approvalId: null, argsDigest: 'a'.repeat(64), at: iso(-120000), durationMs: 80 },
- { callId: 'c2', agentUserId: 'a1', tool: 'github.comment_on_issue', outcome: 'refused', reason: 'not_in_audience', approvalId: null, argsDigest: 'b'.repeat(64), at: iso(-300000), durationMs: null },
- { callId: 'c3', agentUserId: 'a1', tool: 'github.comment_on_issue', outcome: 'pending_approval', reason: null, approvalId: 'appr-1', argsDigest: 'c'.repeat(64), at: iso(-400000), durationMs: null },
- // C4-11: the ask was answered and the call ran as the row above it; the
- // server reports the parked row as `superseded`, never as still awaiting.
- { callId: 'c4', agentUserId: 'a1', tool: 'github.close_issue', outcome: 'superseded', reason: null, approvalId: 'appr-2', argsDigest: 'd'.repeat(64), at: iso(-500000), durationMs: null },
- ],
- counts: { total: 3, ok: 1, refused: 1, pending_approval: 1, failed: 0 },
-};
-
-const githubEntry = {
- installableId: 'github', list: 'tools', label: 'GitHub', description: 'Issues and pull requests.', available: true,
- broker: { id: 'commonly-grant-broker' },
- tools: [
- { name: 'github.list_issues', requiredWriteMode: 'read', irreversible: false },
- { name: 'github.comment_on_issue', requiredWriteMode: 'write', irreversible: true },
- { name: 'github.close_issue', requiredWriteMode: 'write', irreversible: true },
- ],
- connections: [{ connectionId: 'conn-1', owner: 'Team-Commonly', repo: 'commonly' }],
-};
-
-const linearEntry = {
- installableId: 'linear', list: 'tools', label: 'Linear', description: 'Issues in Linear.', available: true,
- connectionType: 'hosted-mcp', entryId: 'linear',
- broker: { id: 'commonly-grant-broker' },
- tools: [{ name: 'linear.list_issues', requiredWriteMode: 'read', irreversible: false }],
- connections: [{ connectionId: 'conn-2', owner: 'Team-Commonly', repo: 'linear' }],
-};
-const grantLinear = { ...grantLive, grantId: 'grant_lin', installationId: 'inst-2', writeMode: 'read', tools: ['linear.list_issues'] };
-
-test('Atlassian shows its read-in-testing state on an existing grant', async () => {
- const atlassian = { ...linearEntry, installableId: 'atlassian', entryId: 'atlassian', label: 'Atlassian',
- tools: [{ name: 'atlassian.get_accessible_atlassian_resources', requiredWriteMode: 'read', irreversible: false }] };
- const grant = { ...grantLinear, grantId: 'grant_atl', tools: ['atlassian.get_accessible_atlassian_resources'] };
- mockTwoEntries({ catalog: [atlassian], grants: { p1: [grant], p2: [] } });
- render();
- expect(await screen.findByText(/read in testing/)).toBeInTheDocument();
-});
-
-// Two tools entries and one grant: the shape the page has to survive once a
-// second tool Installable ships. The old single-entry accounting hid the entry
-// it did not cover (and the header still subtracted exactly one).
-const mockTwoEntries = ({ catalog = [githubEntry, linearEntry], grants = { p1: [grantLinear], p2: [grantRevoked] } } = {}) => {
- axios.get.mockImplementation((url) => {
- if (url === '/api/installables') return Promise.resolve({ data: { installables: catalog } });
- if (url === '/api/grants') return Promise.resolve({ data: { grants: Object.values(grants).flat() } });
- if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [{ name: 'scout', displayName: 'Scout', userId: 'a1' }] } });
- if (url === '/api/registry/pods/p2/agents') return Promise.resolve({ data: { agents: [{ name: 'scout', displayName: 'Scout', userId: 'a1' }] } });
- if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'g', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } });
- return Promise.reject(new Error(`unmocked ${url}`));
- });
-};
-
-const mockApi = (catalog = []) => {
- axios.get.mockImplementation((url) => {
- if (url === '/api/installables') return Promise.resolve({ data: { installables: [{ installableId: 'telegram', list: 'channels' }, ...catalog] } });
- if (url === '/api/grants') return Promise.resolve({ data: { grants: [grantLive, grantRevoked] } });
- if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [{ name: 'scout', displayName: 'Scout', userId: 'a1' }] } });
- if (url === '/api/registry/pods/p2/agents') return Promise.resolve({ data: { agents: [{ name: 'scout', displayName: 'Scout', userId: 'a1' }] } });
- if (url === '/api/grants/grant_live/calls') return Promise.resolve({ data: trail });
- if (url === '/api/grants/grant_gone/calls') return Promise.resolve({ data: { grantId: 'grant_gone', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } });
- return Promise.reject(new Error(`unmocked ${url}`));
- });
- axios.post.mockResolvedValue({ data: { grantId: 'grant_live', revoked: 1 } });
-};
-
-const renderTools = (props = {}) => render(
-
-
- ,
-);
-
-beforeEach(() => { jest.clearAllMocks(); mockApi([githubEntry]); });
-
-test('one owner grant read shows the 21st pod and keeps revoked and expired labels', async () => {
- const manyPods = Array.from({ length: 21 }, (_, index) => ({
- _id: `p${index + 1}`, name: `Pod ${index + 1}`, members: [{ _id: 'u1', username: 'sam' }],
- }));
- const inLastPod = { ...grantLive, grantId: 'grant_21', target: { kind: 'pod', id: 'p21' } };
- const revoked = { ...inLastPod, grantId: 'grant_21_revoked', revokedAt: iso(-600000), revokedBy: 'u1' };
- const expired = { ...inLastPod, grantId: 'grant_21_expired', expiresAt: iso(-600000) };
- axios.get.mockImplementation((url) => {
- if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } });
- if (url === '/api/grants') return Promise.resolve({ data: { grants: [inLastPod, revoked, expired] } });
- if (url.includes('/registry/pods/')) return Promise.resolve({ data: { agents: [] } });
- if (url.includes('/calls')) return Promise.resolve({ data: { calls: [], counts: {} } });
- return Promise.reject(new Error(`unmocked ${url}`));
- });
- renderTools({ pods: manyPods });
-
- expect(await screen.findAllByRole('button', { name: 'View GitHub in Pod 21' })).toHaveLength(3);
- expect(screen.getByText(/revoked by sam 10m/)).toBeInTheDocument();
- expect(screen.getByText(/expired 10m/)).toBeInTheDocument();
- fireEvent.click(screen.getAllByRole('button', { name: 'Grant again' })[0]);
- expect(await screen.findByRole('option', { name: 'Pod 21' })).toBeInTheDocument();
- const grantReads = axios.get.mock.calls.filter(([url]) => url === '/api/grants');
- expect(grantReads).toHaveLength(1);
- expect(grantReads[0][1]).toMatchObject({ params: { connectionOwner: 'me' } });
- expect(axios.get.mock.calls.some(([url]) => /\/api\/pods\/.*\/grants/.test(url))).toBe(false);
-});
-
-test('a sole join-policy refusal offers the owner an in-place fix and then allows retry', async () => {
- axios.post.mockRejectedValueOnce({ response: { data: {
- error: 'outsider_grant_pod_joinable', message: 'Agents can self-install into this pod. Make it invite-only to use this connector.',
- } } }).mockResolvedValueOnce({ data: { grantId: 'grant_new' } });
- axios.patch.mockResolvedValue({ data: { id: 'p2', joinPolicy: 'invite-only' } });
- renderTools({ pods: [pods[0], { ...pods[1], createdBy: { _id: 'u1' } }] });
- fireEvent.click(await screen.findByRole('button', { name: 'Grant again' }));
- const aside = await screen.findByRole('complementary', { name: 'Add GitHub' });
- fireEvent.click(within(aside).getByRole('button', { name: 'Grant' }));
- fireEvent.click(await within(aside).findByRole('button', { name: 'Make this pod invite-only' }));
- await waitFor(() => expect(axios.patch).toHaveBeenCalledWith('/api/pods/p2/join-policy', { joinPolicy: 'invite-only' }, expect.anything()));
- expect(await within(aside).findByText('This pod is invite-only. Select Grant to continue.')).toBeInTheDocument();
- fireEvent.click(within(aside).getByRole('button', { name: 'Grant' }));
- await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(2));
-});
-
-test('the in-place fix is not offered to a non-owner', async () => {
- axios.post.mockRejectedValueOnce({ response: { data: {
- error: 'outsider_grant_pod_joinable', message: 'Agents can self-install into this pod. Make it invite-only to use this connector.',
- } } });
- renderTools();
- fireEvent.click(await screen.findByRole('button', { name: 'Grant again' }));
- const aside = await screen.findByRole('complementary', { name: 'Add GitHub' });
- fireEvent.click(within(aside).getByRole('button', { name: 'Grant' }));
- await within(aside).findByText('Agents can self-install into this pod. Make it invite-only to use this connector.');
- expect(within(aside).queryByRole('button', { name: 'Make this pod invite-only' })).not.toBeInTheDocument();
-});
-
-test('a bridged-pod refusal never offers the join-policy fix', async () => {
- axios.post.mockRejectedValueOnce({ response: { data: {
- error: 'outsider_grant_pod_bridged', message: 'Use a separate unbridged pod for this connector.',
- } } });
- renderTools({ pods: [pods[0], { ...pods[1], createdBy: 'u1' }] });
- fireEvent.click(await screen.findByRole('button', { name: 'Grant again' }));
- const aside = await screen.findByRole('complementary', { name: 'Add GitHub' });
- fireEvent.click(within(aside).getByRole('button', { name: 'Grant' }));
- await within(aside).findByText('Use a separate unbridged pod for this connector.');
- expect(within(aside).queryByRole('button', { name: 'Make this pod invite-only' })).not.toBeInTheDocument();
-});
-
-test('TASK-131: a grant age advances in place, and a returning tab re-reads, without a reload', async () => {
- jest.useFakeTimers();
- try {
- renderTools();
- expect(await screen.findByText(/granted 1h$/)).toBeInTheDocument();
-
- await act(async () => { jest.advanceTimersByTime(60 * 60_000); });
- expect(screen.getByText(/granted 2h$/)).toBeInTheDocument();
-
- const reads = () => axios.get.mock.calls.filter(([url]) => url === '/api/grants').length;
- const before = reads();
- document.dispatchEvent(new Event('visibilitychange'));
- await waitFor(() => expect(reads()).toBeGreaterThan(before));
- } finally {
- jest.useRealTimers();
- }
-});
-
-test('TASK-131: a grant expiring with the page open moves its tool back to Not yet on the same tick as the count', async () => {
- jest.useFakeTimers();
- try {
- // Live for one more minute, so the only thing that changes is the clock.
- const expiring = { ...grantLive, grantId: 'grant_soon', expiresAt: iso(60_000) };
- axios.get.mockImplementation((url) => {
- if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } });
- if (url === '/api/grants') return Promise.resolve({ data: { grants: [expiring] } });
- if (url.includes('/registry/pods/')) return Promise.resolve({ data: { agents: [] } });
- if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'grant_soon', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } });
- return Promise.reject(new Error(`unmocked ${url}`));
- });
- renderTools();
- expect(await screen.findByText('1 granted')).toBeInTheDocument();
- expect(screen.queryByText('not granted')).not.toBeInTheDocument();
-
- await act(async () => { jest.advanceTimersByTime(2 * 60_000); });
-
- // The count always followed the clock; the list did not, so the two
- // disagreed on screen until a reload.
- expect(screen.getByText('0 granted · 1 more')).toBeInTheDocument();
- expect(screen.getByText('not granted')).toBeInTheDocument();
- } finally {
- jest.useRealTimers();
- }
-});
-
-test('rows carry the states table: a live grant pulses when used in the last 10 minutes, a revoked one goes hollow', async () => {
- renderTools();
- expect(await screen.findByRole('heading', { name: 'Tools' })).toBeInTheDocument();
- expect(screen.getByText('1 granted')).toBeInTheDocument();
- const live = screen.getByRole('button', { name: 'View GitHub in Launch pod' });
- expect(within(live).getByText('Launch pod')).toBeInTheDocument();
- expect(within(live).getByText('sam')).toBeInTheDocument();
- // Direction A: the audience is the sentence; the write mode is the glyph (label) and rides the 390 kicker as words.
- expect(within(live).getByText('Scout may use it')).toBeInTheDocument();
- expect(within(live).getByRole('img', { name: /asks|write/ })).toBeInTheDocument();
- expect(live.querySelector('.v2-connector-row__kicker-mode')).toHaveTextContent('every write asks first');
- expect(within(live).getByText(/granted 1h$/)).toBeInTheDocument();
- const liveDot = live.querySelector('.v2-connector-row__dot');
- expect(liveDot).toHaveClass('v2-connector-row__dot--live');
- await waitFor(() => expect(liveDot).toHaveClass('v2-connector-row__dot--pulse'));
- const gone = screen.getByRole('button', { name: 'View GitHub in Ops' });
- expect(within(gone).getByText('revoked by sam 10m ago')).toBeInTheDocument();
- expect(gone.closest('.v2-connector-row')).toHaveClass('v2-connector-row--dead');
- expect(gone.querySelector('.v2-connector-row__dot')).toHaveClass('v2-connector-row__dot--empty');
- // Every catalogue entry is granted, so there is no not-yet row or Add action.
- expect(screen.queryByText('not granted')).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Add' })).not.toBeInTheDocument();
- expect(screen.getAllByRole('button', { name: 'Manage' })).toHaveLength(1);
- expect(screen.getByRole('button', { name: 'Grant again' })).toBeInTheDocument();
- // Direction A: Manage is the gear (word in title + aria-label), and the age rides the kicker.
- for (const manage of screen.getAllByRole('button', { name: 'Manage' })) {
- expect(manage).toHaveClass('v2-connector-row__action--icon');
- expect(manage.textContent).toBe('');
- }
- expect(document.querySelector('.v2-connector-row__when')).toBeNull();
- expect(document.querySelector('.v2-connector-row__kicker')?.textContent).toMatch(/ · granted /);
-});
-
-// TASK-164: every {{rel}} in these lines used to be filled with an English
-// relative time, so a zh grant read "Granted by sam 1小时前." worst of both.
-// TASK-649: one timestamp pair, two ages. A grant made 16 minutes before its
-// revoke and read 41h later must say the same day count for both lines, not
-// "granted 1d" beside "revoked 2d ago".
-test('TASK-649: the grant age and the revoke age round the same way on one row', async () => {
- const createdAt = iso(-(41 * 3600_000 + 24 * 60_000));
- const revokedAt = iso(-(41 * 3600_000 + 8 * 60_000));
- const grant = { ...grantRevoked, createdAt, revokedAt, revokedBy: 'u1' };
- mockTwoEntries({ grants: { p1: [], p2: [grant] } });
- renderTools();
- const gone = await screen.findByRole('button', { name: 'View GitHub in Ops' });
- expect(within(gone).getByText(/granted 2d$/)).toBeInTheDocument();
- expect(within(gone).getByText('revoked by sam 2d ago')).toBeInTheDocument();
-});
-
-// TASK-649: the same rule holds below a day. A grant's own age must round the
-// way the revoke line rounds, at the minute and hour boundaries too.
-test.each([
- [5 * 60_000 + 40_000, 'granted 6m'],
- [100 * 60_000, 'granted 2h'],
-])('TASK-649: a grant aged %p ms reads "%s" on its row', async (ageMs, expected) => {
- const grant = { ...grantRevoked, createdAt: iso(-ageMs), revokedAt: null, revokedBy: null };
- mockTwoEntries({ grants: { p1: [], p2: [grant] } });
- renderTools();
- const row = await screen.findByRole('button', { name: 'View GitHub in Ops' });
- expect(within(row).getByText(new RegExp(`${expected}$`))).toBeInTheDocument();
-});
-
-test('TASK-164: the grant, budget and trail times read in zh-CN, not English', async () => {
- await i18nReady;
- await act(async () => { await i18n.changeLanguage('zh-CN'); });
- try {
- const { container } = renderTools();
- await screen.findByText(/^Tools$|^工具$/);
- fireEvent.click(container.querySelector('.v2-connector-row__selection') as HTMLElement);
- const aside = await screen.findByRole('complementary');
- expect(within(aside).getByText(/前由 sam 授权。/)).toBeInTheDocument();
- expect(within(aside).getByText(/后结束。/)).toBeInTheDocument();
- // The TASK-180 ruling names this key explicitly — 「每{{window}}可调用
- // {{calls}}次」 — so the budget line loses both of its spaces. This assertion
- // pinned the old spacing, which is why it had to move with the ruling.
- expect(within(aside).getByText(/每1小时可调用50次/)).toBeInTheDocument();
- await waitFor(() => expect(within(aside).getAllByRole('listitem')).toHaveLength(4));
- const lines = within(aside).getAllByRole('listitem');
- expect(lines.map((line) => line.textContent)).toEqual([
- 'Scout · github.list_issues · 成功2分钟前',
- 'Scout · github.comment_on_issue · 已拒绝5分钟前',
- 'Scout · github.comment_on_issue · 等待人工确认7分钟前',
- 'Scout · github.close_issue · 已回答8分钟前',
- ]);
- // The whole aside, not just the four line keys: this is the regression the
- // gate caught — an English relative time inside an otherwise-Chinese line.
- expect(aside.textContent).not.toMatch(/\bago\b|from now|\bm ago\b/);
- } finally {
- await act(async () => { await i18n.changeLanguage('en'); });
- }
-});
-
-test('TASK-179: the grant line is one sentence per language, and an empty audience is its own sentence', async () => {
- // ux-lead's render at 3d6e1763 read the old three-piece composition as
- // 「授权给 Growth 授权者 sam」, and the empty audience as 「没有智能体 可以使用」.
- const emptyAudience = { ...grantLive, grantId: 'grant_empty', target: { kind: 'pod', id: 'p2' }, effectiveAudience: [] };
- axios.get.mockImplementation((url) => {
- if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } });
- if (url === '/api/grants') return Promise.resolve({ data: { grants: [grantLive, emptyAudience] } });
- if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [{ name: 'scout', displayName: 'Scout', userId: 'a1' }] } });
- if (url === '/api/registry/pods/p2/agents') return Promise.resolve({ data: { agents: [] } });
- if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'g', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } });
- return Promise.reject(new Error(`unmocked ${url}`));
- });
- renderTools();
-
- const live = await screen.findByRole('button', { name: 'View GitHub in Launch pod' });
- const ops = await screen.findByRole('button', { name: 'View GitHub in Ops' });
- // One key holds both names, so the language orders them. en is unchanged from
- // the three-piece version; zh puts the granter first, as the ruling requires.
- expect(live.querySelector('strong')?.textContent).toBe('granted to Launch pod by sam');
- expect(within(ops).getByText('nobody may use it')).toBeInTheDocument();
-
- fireEvent.click(live);
- await screen.findByRole('complementary', { name: 'Grant details' });
- await act(async () => { await i18n.changeLanguage('zh-CN'); });
- try {
- // The accessible name is translated too, so locate rows by what they say.
- const rows = () => Array.from(document.querySelectorAll('.v2-connector-row')) as HTMLElement[];
- const rowSaying = (text: string) => rows().find((row) => row.textContent?.includes(text)) as HTMLElement;
- expect(rowSaying('Launch pod').querySelector('strong')?.textContent).toBe('由 sam 授权给 Launch pod');
- // The audience keeps its space before a Latin name, and loses it when there
- // is no audience at all — which is why the empty case is a separate key. This
- // is also the assertion that caught the first cut of TASK-180, where `agents`
- // was declared a numeral by name and closed the space before the audience.
- expect(rowSaying('Launch pod').textContent).toContain('Scout 可以使用');
- expect(rowSaying('Ops').textContent).toContain('没有智能体可以使用');
- // The separator key is zh-empty: two sentences that each end in 。 join with
- // nothing, where the English pair needs the space.
- const aside = document.querySelector('.v2-connectors__aside') as HTMLElement;
- expect(within(aside).getByText(/授权。/).textContent).toBe('1小时前由 sam 授权。6天后结束。');
- } finally {
- await act(async () => { await i18n.changeLanguage('en'); });
- }
-});
-
-test('TASK-179: a seat grant names the seat in its sentence and the pod in the row it sits in', async () => {
- // ux-lead's gate at 07873c19: one shared label put the seat's name where the
- // row's location belongs, announcing a seat grant under Ops as 「View GitHub in
- // Reed」. The sentence's subject and the row's location are different nouns, so
- // they are two expressions, and this test fails if they are ever re-merged.
- const seatGrant = { ...grantLive, grantId: 'grant_seat', target: { kind: 'seat', id: 'a1' }, effectiveAudience: ['a1'] };
- axios.get.mockImplementation((url) => {
- if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } });
- if (url === '/api/grants') return Promise.resolve({ data: { grants: [seatGrant] } });
- if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } });
- if (url === '/api/registry/pods/p2/agents') return Promise.resolve({ data: { agents: [{ name: 'reed', displayName: 'Reed', userId: 'a1' }] } });
- if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'g', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } });
- return Promise.reject(new Error(`unmocked ${url}`));
- });
- renderTools();
-
- // Found by the pod's name: if the seat leaks back into the accessible name
- // this query returns nothing, which is the fix's own control.
- const row = await screen.findByRole('button', { name: 'View GitHub in Ops' });
- expect(row.querySelector('strong')?.textContent).toBe('granted to Reed by sam');
- expect(row.querySelector('.v2-connector-row__kicker')?.textContent).toMatch(/^Ops · granted /);
-});
-
-test('the aside reads the grant and the trail: agents, allow-list under its mode, what asks first, three counts, outcomes as words', async () => {
- renderTools();
- fireEvent.click(await screen.findByRole('button', { name: 'View GitHub in Launch pod' }));
- const aside = await screen.findByRole('complementary', { name: 'Grant details' });
- expect(within(aside).getByRole('heading', { name: 'GitHub · Launch pod' })).toBeInTheDocument();
- expect(within(aside).getByText(/Granted by sam 1h ago\. Ends 6d from now\./)).toBeInTheDocument();
- expect(within(aside).getByText('agents allowed').nextElementSibling).toHaveTextContent('Scout');
- expect(within(aside).getByText('read and write, ask first').nextElementSibling).toHaveTextContent('github.list_issues');
- expect(within(aside).getByText('asks a person first').nextElementSibling).toHaveTextContent('every write asks first');
- expect(within(aside).getByText('budget').nextElementSibling).toHaveTextContent('50 calls per 1h');
- await waitFor(() => expect(within(aside).getByText('calls').previousElementSibling).toHaveTextContent('3'));
- expect(within(aside).getByText('refused').previousElementSibling).toHaveTextContent('1');
- expect(within(aside).getByText('awaiting a person').previousElementSibling).toHaveTextContent('1');
- const lines = within(aside).getAllByRole('listitem');
- expect(lines.map((line) => line.textContent)).toEqual([
- 'Scout · github.list_issues · ok2m ago',
- 'Scout · github.comment_on_issue · refused5m ago',
- 'Scout · github.comment_on_issue · awaiting a person7m ago',
- 'Scout · github.close_issue · answered8m ago',
- ]);
- // A superseded request is not a call: the counts (which the server derives)
- // still say 3 while the trail lists its 4 rows, and only the genuinely open
- // one is counted as awaiting a person.
- expect(lines.map((line) => line.textContent).filter((text) => text.includes('awaiting a person'))).toHaveLength(1);
- // `answered` covers a resolution that was declined or expired as
- // well as one that ran, so its mark must say SETTLED without saying approved —
- // the sibling row beside it is the one that carries the verdict. Shield plus a
- // short bar; the shield-and-check it shipped with read as "approved" next to a
- // row that says `refused`.
- const answeredLine = lines.find((line) => line.textContent?.includes('answered'));
- expect(answeredLine?.querySelectorAll('svg path')).toHaveLength(2);
- expect(Array.from(answeredLine?.querySelectorAll('svg path') || []).map((path) => path.getAttribute('d'))).toEqual([
- 'M12 3 4 6v6c0 5 3.4 8.4 8 9 4.6-.6 8-4 8-9V6z',
- 'M9 12h6',
- ]);
- // The arguments never reach the page: only the digest does, and it is not rendered.
- expect(aside.textContent).not.toContain('a'.repeat(64));
- expect(axios.get).toHaveBeenCalledWith('/api/grants/grant_live/calls', expect.objectContaining({ headers: expect.any(Object) }));
-});
-
-test('TASK-050: a human member in the audience is named, not called "an agent"', async () => {
- // A pod grant's audience is a snapshot of the room's members, so a human id
- // is in it; `seats` holds agents only, and the label for anything else said
- // "an agent" — two humans read as "an agent, an agent, Scout".
- const withPeople = { ...grantLive, effectiveAudience: ['u1', 'a1'] };
- axios.get.mockImplementation((url) => {
- if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } });
- if (url === '/api/grants') return Promise.resolve({ data: { grants: [withPeople] } });
- if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [{ name: 'scout', displayName: 'Scout', userId: 'a1' }] } });
- if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'grant_live', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } });
- return Promise.resolve({ data: {} });
- });
- renderTools();
- const live = await screen.findByRole('button', { name: 'View GitHub in Launch pod' });
- expect(within(live).getByText('sam, Scout may use it')).toBeInTheDocument();
- expect(live.querySelector('.v2-connector-row__kicker-mode')).toHaveTextContent('every write asks first');
- expect(screen.queryByText(/an agent/)).not.toBeInTheDocument();
-
- fireEvent.click(live);
- const aside = await screen.findByRole('complementary', { name: 'Grant details' });
- expect(within(aside).getByText('agents allowed').nextElementSibling).toHaveTextContent('sam, Scout');
-});
-
-test('TASK-624: a per-user bot member with no roster row is named by its curated label, not its instance id', async () => {
- // A per-user Scout is a pod member with no AgentInstallation row in the roster, so the
- // audience id resolves through the pod member. Its username is the raw instance id.
- const perUserPods = [{
- _id: 'p1', name: 'Launch pod',
- members: [
- { _id: 'u1', username: 'sam' },
- { _id: 'a1', username: 'scout-uf2554d19c9', isBot: true, botMetadata: { displayName: 'Scout', instanceId: 'uf2554d19c9' } },
- ],
- }];
- axios.get.mockImplementation((url) => {
- if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } });
- if (url === '/api/grants') return Promise.resolve({ data: { grants: [grantLive] } });
- if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } });
- if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'grant_live', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } });
- return Promise.resolve({ data: {} });
- });
- renderTools({ pods: perUserPods });
- const live = await screen.findByRole('button', { name: 'View GitHub in Launch pod' });
- expect(within(live).getByText('Scout may use it')).toBeInTheDocument();
- expect(screen.queryByText(/uf2554d19c9/)).not.toBeInTheDocument();
-});
-
-test('TASK-624: a bot member with a blank displayName falls back to its non-default instanceId, then to the default username', async () => {
- const fallbackPods = [{
- _id: 'p1', name: 'Launch pod',
- members: [
- { _id: 'u1', username: 'sam' },
- { _id: 'a1', username: 'scout-uf2554d19c9', isBot: true, botMetadata: { displayName: ' ', instanceId: 'uf2554d19c9' } },
- { _id: 'a2', username: 'guide', isBot: true, botMetadata: { displayName: '', instanceId: 'default' } },
- ],
- }];
- axios.get.mockImplementation((url) => {
- if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } });
- if (url === '/api/grants') return Promise.resolve({ data: { grants: [{ ...grantLive, effectiveAudience: ['a1', 'a2'] }] } });
- if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } });
- if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'grant_live', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } });
- return Promise.resolve({ data: {} });
- });
- renderTools({ pods: fallbackPods });
- const live = await screen.findByRole('button', { name: 'View GitHub in Launch pod' });
- expect(within(live).getByText('uf2554d19c9, guide may use it')).toBeInTheDocument();
-});
-
-test('Revoke is two-click and posts the revoke verb, then reloads; a dead grant offers no Revoke', async () => {
- renderTools();
- fireEvent.click(await screen.findByRole('button', { name: 'View GitHub in Launch pod' }));
- const aside = await screen.findByRole('complementary', { name: 'Grant details' });
- expect(axios.post).not.toHaveBeenCalled();
- fireEvent.click(within(aside).getByRole('button', { name: 'Revoke' }));
- expect(axios.post).not.toHaveBeenCalled();
- fireEvent.click(within(aside).getByRole('button', { name: 'Yes, revoke it' }));
- await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/grants/grant_live/revoke', undefined, expect.any(Object)));
- await waitFor(() => expect(axios.get.mock.calls.filter(([url]) => url === '/api/grants')).toHaveLength(2));
-
- fireEvent.click(screen.getByRole('button', { name: 'View GitHub in Ops' }));
- const gone = await screen.findByRole('complementary', { name: 'Grant details' });
- expect(within(gone).queryByRole('button', { name: 'Revoke' })).not.toBeInTheDocument();
- expect(within(gone).getByText(/Revoked by sam 10m ago\./)).toBeInTheDocument();
- await waitFor(() => expect(within(gone).getByText('No calls yet.')).toBeInTheDocument());
-});
-
-// Atlassian walk, launch day: the card kept saying "granted" after Revoke until a reload.
-// The page now treats a confirmed revoke as final, so a stale or failed re-read
-// cannot put the card back.
-test('after Revoke the grant card says revoked in place, even when the re-read is stale or fails', async () => {
- let reads = 0;
- axios.get.mockImplementation((url) => {
- if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } });
- if (url === '/api/grants') {
- reads += 1;
- if (reads === 1) return Promise.resolve({ data: { grants: [grantLive] } });
- if (reads === 2) return Promise.resolve({ data: { grants: [grantLive] } }); // stale: server read lags the revoke
- return Promise.reject(new Error('network')); // then a failed read
- }
- if (url.includes('/registry/pods/')) return Promise.resolve({ data: { agents: [{ name: 'scout', displayName: 'Scout', userId: 'a1' }] } });
- if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'grant_live', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } });
- return Promise.reject(new Error(`unmocked ${url}`));
- });
- axios.post.mockResolvedValue({ data: { revoked: 1 } });
- renderTools();
- fireEvent.click(await screen.findByRole('button', { name: 'View GitHub in Launch pod' }));
- const aside = await screen.findByRole('complementary', { name: 'Grant details' });
- expect(within(aside).getByText(/Granted by sam .*Ends /)).toBeInTheDocument();
- fireEvent.click(within(aside).getByRole('button', { name: 'Revoke' }));
- fireEvent.click(within(aside).getByRole('button', { name: 'Yes, revoke it' }));
- await waitFor(() => expect(reads).toBeGreaterThanOrEqual(2));
- // A tab refocus re-reads, and that read fails.
- fireEvent(document, new Event('visibilitychange'));
- await waitFor(() => expect(reads).toBeGreaterThanOrEqual(3));
- const card = await screen.findByRole('complementary', { name: 'Grant details' });
- await waitFor(() => expect(within(card).getByText(/Revoked by sam just now\./)).toBeInTheDocument());
- expect(within(card).queryByRole('button', { name: 'Revoke' })).not.toBeInTheDocument();
- // The row went hollow with it, and the list did not blank on the failed read.
- expect(screen.getByRole('button', { name: 'View GitHub in Launch pod' })).toBeInTheDocument();
- expect(screen.getByText(/revoked by sam/)).toBeInTheDocument();
-});
-
-// Atlassian walk, launch day: only a connection that is no longer offered got
-// "Remove connection", so nobody could disconnect Atlassian from the page.
-describe('Disconnect on a live connection of an offered hosted connector', () => {
- const atlassian = { ...linearEntry, installableId: 'atlassian', entryId: 'atlassian', label: 'Atlassian',
- tools: [{ name: 'atlassian.search', requiredWriteMode: 'read', irreversible: false }],
- connections: [{ connectionId: 'conn-atl', owner: 'me', repo: 'atlassian' }] };
- const grantAtl = { ...grantLinear, grantId: 'grant_atl', tools: ['atlassian.search'] };
-
- test('an ungranted connection offers Disconnect, two clicks, and says so afterwards', async () => {
- mockTwoEntries({ catalog: [atlassian], grants: { p1: [], p2: [] } });
- axios.delete.mockResolvedValue({ data: { message: 'Integration deleted successfully' } });
- renderTools();
- fireEvent.click(await screen.findByRole('button', { name: 'Disconnect Atlassian' }));
- expect(axios.delete).not.toHaveBeenCalled();
- fireEvent.click(screen.getByRole('button', { name: 'Yes, disconnect' }));
- await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/integrations/conn-atl', expect.any(Object)));
- expect(await screen.findByText('Disconnected Atlassian.')).toBeInTheDocument();
- await waitFor(() => expect(axios.get.mock.calls.filter(([u]) => u === '/api/installables')).toHaveLength(2));
- });
-
- test('a stale catalogue read after Disconnect does not bring the connection back', async () => {
- mockTwoEntries({ catalog: [atlassian], grants: { p1: [], p2: [] } }); // every read still lists the connection
- axios.delete.mockResolvedValue({ data: { message: 'ok' } });
- renderTools();
- fireEvent.click(await screen.findByRole('button', { name: 'Disconnect Atlassian' }));
- fireEvent.click(screen.getByRole('button', { name: 'Yes, disconnect' }));
- expect(await screen.findByText('Disconnected Atlassian.')).toBeInTheDocument();
- await waitFor(() => expect(axios.get.mock.calls.filter(([u]) => u === '/api/installables')).toHaveLength(2));
- expect(screen.queryByRole('button', { name: /Disconnect Atlassian|Yes, disconnect/ })).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Add' })).not.toBeInTheDocument();
- });
-
- test('a granted connection offers it in the grant card, and a provider page from the server is linked', async () => {
- mockTwoEntries({ catalog: [atlassian], grants: { p1: [grantAtl], p2: [] } });
- axios.delete.mockResolvedValue({ data: { message: 'ok', revokeAt: 'https://id.atlassian.com/manage-profile/apps' } });
- renderTools();
- fireEvent.click(await screen.findByRole('button', { name: 'View Atlassian in Launch pod' }));
- const aside = await screen.findByRole('complementary', { name: 'Grant details' });
- fireEvent.click(within(aside).getByRole('button', { name: 'Disconnect Atlassian' }));
- fireEvent.click(within(aside).getByRole('button', { name: 'Yes, disconnect' }));
- await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/integrations/conn-atl', expect.any(Object)));
- const link = await screen.findByRole('link', { name: 'Open provider settings' });
- expect(link).toHaveAttribute('href', 'https://id.atlassian.com/manage-profile/apps');
- });
-
- test('a network failure says so and keeps the connection', async () => {
- mockTwoEntries({ catalog: [atlassian], grants: { p1: [], p2: [] } });
- axios.delete.mockRejectedValue(new Error('network'));
- renderTools();
- fireEvent.click(await screen.findByRole('button', { name: 'Disconnect Atlassian' }));
- fireEvent.click(screen.getByRole('button', { name: 'Yes, disconnect' }));
- expect(await screen.findByText('Could not disconnect Atlassian.')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Yes, disconnect' })).toBeInTheDocument();
- });
-
- // A 502 is the server's partial removal: grants already revoked, row disconnected,
- // only the provider's revoke failed, and the body names the page to visit.
- test('a 502 partial removal shows the grant revoked and links the provider page', async () => {
- mockTwoEntries({ catalog: [atlassian], grants: { p1: [grantAtl], p2: [] } });
- axios.delete.mockRejectedValue({ response: { status: 502, data: { code: 'provider_revoke_failed', revokeAt: 'https://id.atlassian.com/manage-profile/apps' } } });
- renderTools();
- fireEvent.click(await screen.findByRole('button', { name: 'View Atlassian in Launch pod' }));
- const aside = await screen.findByRole('complementary', { name: 'Grant details' });
- fireEvent.click(within(aside).getByRole('button', { name: 'Disconnect Atlassian' }));
- fireEvent.click(within(aside).getByRole('button', { name: 'Yes, disconnect' }));
- expect(await screen.findByRole('link', { name: 'Open provider settings' })).toHaveAttribute('href', 'https://id.atlassian.com/manage-profile/apps');
- const card = await screen.findByRole('complementary', { name: 'Grant details' });
- expect(within(card).getByText(/Revoked by sam/)).toBeInTheDocument();
- expect(within(card).queryByRole('button', { name: 'Revoke' })).not.toBeInTheDocument();
- });
-
- // A 502 from the ingress while a pod restarts never reached the route: nothing was revoked.
- test('a bare 502 (no route code) leaves the grant live and says the disconnect failed', async () => {
- mockTwoEntries({ catalog: [atlassian], grants: { p1: [grantAtl], p2: [] } });
- axios.delete.mockRejectedValue({ response: { status: 502, data: 'Bad gateway' } });
- renderTools();
- fireEvent.click(await screen.findByRole('button', { name: 'View Atlassian in Launch pod' }));
- const aside = await screen.findByRole('complementary', { name: 'Grant details' });
- fireEvent.click(within(aside).getByRole('button', { name: 'Disconnect Atlassian' }));
- fireEvent.click(within(aside).getByRole('button', { name: 'Yes, disconnect' }));
- expect(await within(aside).findByText('Could not disconnect Atlassian.')).toBeInTheDocument();
- expect(within(aside).getByRole('button', { name: 'Revoke' })).toBeInTheDocument();
- expect(within(aside).queryByText(/Revoked by/)).not.toBeInTheDocument();
- });
-
- test('two connections of one entry offer no Disconnect, since a grant does not name its connection', async () => {
- const two = { ...atlassian, connections: [...atlassian.connections, { connectionId: 'conn-atl-2', owner: 'me', repo: 'atlassian' }] };
- mockTwoEntries({ catalog: [two], grants: { p1: [], p2: [] } });
- renderTools();
- await screen.findByText('Atlassian');
- expect(screen.queryByRole('button', { name: /Disconnect/ })).not.toBeInTheDocument();
- });
-
- test('GitHub (the instance App connection) never offers Disconnect', async () => {
- renderTools();
- await screen.findByRole('button', { name: 'View GitHub in Launch pod' });
- expect(screen.queryByRole('button', { name: /Disconnect/ })).not.toBeInTheDocument();
- });
-});
-
-test('the catalogue draws the not-yet row, and Add mints the grant exactly as the server takes it — never a brokerId', async () => {
- mockApi([githubEntry]);
- // No live grant anywhere → a not-yet row; the two dead/live fixtures are replaced by a revoked one only.
- axios.get.mockImplementation((url) => {
- if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } });
- if (url === '/api/grants') return Promise.resolve({ data: { grants: [grantRevoked] } });
- if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [{ name: 'scout', displayName: 'Scout', userId: 'a1' }, { name: 'hosted-smoke', userId: 'x9', internal: true }] } });
- if (url.includes('/registry/pods/')) return Promise.resolve({ data: { agents: [] } });
- if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'g', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } });
- return Promise.reject(new Error(`unmocked ${url}`));
- });
- axios.post.mockResolvedValue({ data: { grantId: 'grant_new' } });
- renderTools();
- expect(await screen.findByText('0 granted · 1 more')).toBeInTheDocument();
- expect(screen.getByText('Issues and pull requests.')).toBeInTheDocument();
- expect(screen.getByText('read, or read and write')).toBeInTheDocument();
- expect(screen.getByText('not granted')).toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: 'Add' }));
- const form = await screen.findByRole('complementary', { name: 'Add GitHub' });
- // Default: read, every non-internal seat in the room, 7 days; under read only read tools are on the list.
- expect(within(form).getByRole('button', { name: 'read' })).toHaveAttribute('aria-pressed', 'true');
- expect(within(form).getByText('github.list_issues')).toBeInTheDocument();
- expect(within(form).queryByText('github.comment_on_issue')).toBeNull();
- expect(within(form).getByText('nothing asks first')).toBeInTheDocument();
- expect(within(form).getByRole('checkbox', { name: 'Scout' })).toBeChecked();
- fireEvent.click(within(form).getByRole('button', { name: 'read and write' }));
- expect(within(form).getByText('github.comment_on_issue')).toBeInTheDocument();
- expect(within(form).getByText('github.comment_on_issue, github.close_issue ask first')).toBeInTheDocument();
- fireEvent.change(within(form).getByRole('combobox', { name: 'ends' }), { target: { value: '30' } });
- fireEvent.click(within(form).getByRole('button', { name: 'Grant' }));
- await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(1));
- const [url, body] = axios.post.mock.calls[0];
- expect(url).toBe('/api/grants');
- expect(body).toMatchObject({ connectionId: 'conn-1', target: { kind: 'pod', id: 'p1' }, writeMode: 'write', audience: ['a1'], tools: ['github.list_issues', 'github.comment_on_issue', 'github.close_issue'] });
- // Both are the server's: the broker from the catalogue, the installation from the Connection (#1677).
- expect(body).not.toHaveProperty('brokerId');
- expect(body).not.toHaveProperty('installationId');
- expect(Object.keys(body).sort()).toEqual(['audience', 'connectionId', 'expiresAt', 'target', 'tools', 'writeMode']);
- const days = (new Date(body.expiresAt).getTime() - Date.now()) / 86400000;
- expect(days).toBeGreaterThan(29.9);
- expect(days).toBeLessThan(30.1);
- await waitFor(() => expect(axios.get.mock.calls.filter(([u]) => u === '/api/grants')).toHaveLength(2));
-});
-
-test('a not-yet row without a Connection or on an unconfigured instance offers no Add', async () => {
- mockApi([{ ...githubEntry, connections: [] }]);
- axios.get.mockImplementation((url) => {
- if (url === '/api/installables') return Promise.resolve({ data: { installables: [{ ...githubEntry, connections: [] }] } });
- if (url.endsWith('/grants')) return Promise.resolve({ data: { grants: [] } });
- return Promise.resolve({ data: { agents: [] } });
- });
- renderTools();
- expect(await screen.findByText('install the GitHub App first · an admin does this once')).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Add' })).toBeNull();
- expect(screen.queryByRole('button', { name: 'Install GitHub App' })).toBeNull();
-});
-
-test('an admin can install the GitHub App from the no-connection Tools row', async () => {
- const noConnection = { ...githubEntry, connections: [] };
- axios.get.mockImplementation((url) => {
- if (url === '/api/installables') return Promise.resolve({ data: { installables: [noConnection] } });
- if (url.endsWith('/grants')) return Promise.resolve({ data: { grants: [] } });
- if (url.includes('/registry/pods/')) return Promise.resolve({ data: { agents: [] } });
- return Promise.reject(new Error(`unmocked ${url}`));
- });
- axios.post.mockResolvedValue({ data: { integration: { installationId: 'install-42', createdBy: 'u1' } } });
- const admin = {
- ...authValue,
- currentUser: { ...authValue.currentUser, role: 'admin' },
- user: { ...authValue.user, role: 'admin' },
- };
- render();
-
- fireEvent.click(await screen.findByRole('button', { name: 'Install GitHub App' }));
- const form = await screen.findByRole('complementary', { name: 'Install GitHub App' });
- fireEvent.change(within(form).getByRole('textbox', { name: 'installation ID' }), { target: { value: 'install-42' } });
- fireEvent.change(within(form).getByRole('textbox', { name: 'owner' }), { target: { value: 'Team-Commonly' } });
- fireEvent.change(within(form).getByRole('textbox', { name: 'repository' }), { target: { value: 'commonly' } });
- fireEvent.click(within(form).getByRole('button', { name: 'Install GitHub App' }));
- await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
- '/api/integrations/github-app',
- { installationId: 'install-42', owner: 'Team-Commonly', repo: 'commonly' },
- expect.any(Object),
- ));
- await waitFor(() => expect(screen.queryByRole('complementary', { name: 'Install GitHub App' })).toBeNull());
-});
-
-test('an admin is told when another administrator already owns the GitHub App installation', async () => {
- const noConnection = { ...githubEntry, connections: [] };
- axios.get.mockImplementation((url) => {
- if (url === '/api/installables') return Promise.resolve({ data: { installables: [noConnection] } });
- if (url.endsWith('/grants')) return Promise.resolve({ data: { grants: [] } });
- if (url.includes('/registry/pods/')) return Promise.resolve({ data: { agents: [] } });
- return Promise.reject(new Error(`unmocked ${url}`));
- });
- axios.post.mockResolvedValue({ data: { integration: { installationId: 'install-42', createdBy: 'other-admin' } } });
- const admin = {
- ...authValue,
- currentUser: { ...authValue.currentUser, _id: 'u2', role: 'admin' },
- user: { ...authValue.user, _id: 'u2', role: 'admin' },
- };
- render();
-
- fireEvent.click(await screen.findByRole('button', { name: 'Install GitHub App' }));
- const form = await screen.findByRole('complementary', { name: 'Install GitHub App' });
- fireEvent.change(within(form).getByRole('textbox', { name: 'installation ID' }), { target: { value: 'install-42' } });
- fireEvent.change(within(form).getByRole('textbox', { name: 'owner' }), { target: { value: 'Team-Commonly' } });
- fireEvent.change(within(form).getByRole('textbox', { name: 'repository' }), { target: { value: 'commonly' } });
- fireEvent.click(within(form).getByRole('button', { name: 'Install GitHub App' }));
-
- await waitFor(() => expect(within(form).getByRole('alert')).toHaveTextContent('This GitHub App is already installed by another administrator.'));
- expect(screen.getByRole('complementary', { name: 'Install GitHub App' })).toBeInTheDocument();
-});
-
-test('an unavailable not-yet tool row offers the same Ask affordance as unavailable channels', async () => {
- const unavailable = { ...githubEntry, available: false, connections: [] };
- axios.get.mockImplementation((url) => {
- if (url === '/api/installables') return Promise.resolve({ data: { installables: [unavailable] } });
- if (url.endsWith('/grants')) return Promise.resolve({ data: { grants: [] } });
- if (url.includes('/registry/pods/')) return Promise.resolve({ data: { agents: [] } });
- return Promise.reject(new Error(`unmocked ${url}`));
- });
- renderTools();
- const ask = await screen.findByRole('link', { name: 'Ask' });
- expect(screen.queryByText('not enabled on this instance · ask your operator')).not.toBeInTheDocument();
- expect(ask).toHaveAttribute('href', 'https://github.com/Team-Commonly/commonly/issues/new?title=Connector%20request');
- expect(ask.closest('.v2-connector-row')).toHaveClass('v2-connector-row--not-enabled');
-});
-
-test('Change access mints a new grant then revokes the old one; Grant again on a dead grant only mints', async () => {
- mockApi([githubEntry]);
- axios.post.mockResolvedValue({ data: { grantId: 'grant_new' } });
- renderTools();
- fireEvent.click(await screen.findByRole('button', { name: 'View GitHub in Launch pod' }));
- const aside = await screen.findByRole('complementary', { name: 'Grant details' });
- // Under `write` the aside names the irreversible tools from the catalogue.
- expect(within(aside).getByText('asks a person first').nextElementSibling).toHaveTextContent('every write asks first');
- fireEvent.click(within(aside).getByRole('button', { name: 'Change access' }));
- const form = await screen.findByRole('complementary', { name: 'Change access' });
- expect(within(form).getByRole('button', { name: 'read and write, ask first' })).toHaveAttribute('aria-pressed', 'true');
- expect(within(form).getByRole('checkbox', { name: 'Scout' })).toBeChecked();
- fireEvent.click(within(form).getByRole('button', { name: 'Grant' }));
- await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(2));
- expect(axios.post.mock.calls[0][0]).toBe('/api/grants');
- expect(axios.post.mock.calls[1][0]).toBe('/api/grants/grant_live/revoke');
-
- axios.post.mockClear();
- // The first grant is revoked too now (the page keeps a confirmed revoke), so pick the Ops row's own button.
- fireEvent.click(within(screen.getByRole('button', { name: 'View GitHub in Ops' }).closest('article')).getByRole('button', { name: 'Grant again' }));
- const again = await screen.findByRole('complementary', { name: 'Add GitHub' });
- expect(within(again).getByRole('heading', { name: 'GitHub · Ops' })).toBeInTheDocument();
- fireEvent.click(within(again).getByRole('button', { name: 'Grant' }));
- await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(1));
- expect(axios.post.mock.calls[0][0]).toBe('/api/grants');
-});
-
-test('Revoke renders only for the granter', async () => {
- mockApi([githubEntry]);
- const member = { ...authValue, currentUser: { _id: 'u2', username: 'rae' }, user: { _id: 'u2', username: 'rae' } };
- render();
- fireEvent.click(await screen.findByRole('button', { name: 'View GitHub in Launch pod' }));
- const aside = await screen.findByRole('complementary', { name: 'Grant details' });
- expect(within(aside).getByText(/Granted by sam/)).toBeInTheDocument();
- expect(within(aside).queryByRole('button', { name: 'Revoke' })).toBeNull();
- expect(within(aside).queryByRole('button', { name: 'Change access' })).toBeNull();
- // The trail is the room's: a member still reads it.
- await waitFor(() => expect(within(aside).getByText('calls').previousElementSibling).toHaveTextContent('3'));
-});
-
-test('Grant again renders only for the granter', async () => {
- mockApi([githubEntry]);
- const member = { ...authValue, currentUser: { _id: 'u2', username: 'rae' }, user: { _id: 'u2', username: 'rae' } };
- render();
- await screen.findByRole('button', { name: 'View GitHub in Ops' });
- // The dead row offers a member Manage (read the grant), never the mint.
- expect(screen.queryByRole('button', { name: 'Grant again' })).toBeNull();
- expect(screen.getAllByRole('button', { name: 'Manage' })).toHaveLength(2);
-});
-
-test('Change access keeps the minted grant when the revoke fails and retries only the revoke', async () => {
- mockApi([githubEntry]);
- axios.post.mockImplementation((url) => (url === '/api/grants'
- ? Promise.resolve({ data: { grantId: 'grant_new' } })
- : Promise.reject(new Error('revoke down'))));
- renderTools();
- fireEvent.click(await screen.findByRole('button', { name: 'View GitHub in Launch pod' }));
- fireEvent.click(within(await screen.findByRole('complementary', { name: 'Grant details' })).getByRole('button', { name: 'Change access' }));
- fireEvent.click(within(await screen.findByRole('complementary', { name: 'Change access' })).getByRole('button', { name: 'Grant' }));
- await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(2));
- const aside = await screen.findByRole('complementary', { name: 'Grant details' });
- expect(await within(aside).findByText('The new grant is live. This old one still is too — its revoke did not go through.')).toBeInTheDocument();
- expect(within(aside).queryByRole('button', { name: 'Revoke' })).toBeNull();
- axios.post.mockResolvedValue({ data: { grantId: 'grant_live', revoked: 1 } });
- fireEvent.click(within(aside).getByRole('button', { name: 'Revoke the old grant' }));
- await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(3));
- // Only the revoke went out again — no third mint.
- expect(axios.post.mock.calls.map(([u]) => u)).toEqual(['/api/grants', '/api/grants/grant_live/revoke', '/api/grants/grant_live/revoke']);
-});
-
-test('TASK-133: a live grant on one entry leaves the other entry under Not yet', async () => {
- mockTwoEntries();
- const { container } = renderTools();
- expect(await screen.findByRole('heading', { name: 'Tools' })).toBeInTheDocument();
- // One entry granted, so exactly one is still to grant — and the entry the
- // grant names is the one that leaves the list (label and glyph from its own entry).
- expect(screen.getByText('1 granted · 1 more')).toBeInTheDocument();
- expect(await screen.findByRole('button', { name: 'View Linear in Launch pod' })).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'View GitHub in Launch pod' })).not.toBeInTheDocument();
- const notYet = container.querySelectorAll('.v2-connector-row--not-yet');
- expect(notYet).toHaveLength(1);
- expect(notYet[0].textContent).toContain('GitHub');
- expect(notYet[0].textContent).toContain('Issues and pull requests.');
-});
-
-test('TASK-133: the granted entry itself leaves Not yet, the other one stays', async () => {
- mockTwoEntries({ grants: { p1: [grantLive], p2: [grantRevoked] } });
- const { container } = renderTools();
- expect(await screen.findByRole('button', { name: 'View GitHub in Launch pod' })).toBeInTheDocument();
- const notYet = container.querySelectorAll('.v2-connector-row--not-yet');
- expect(notYet).toHaveLength(1);
- expect(notYet[0].textContent).toContain('Linear');
- expect(notYet[0].textContent).not.toContain('GitHub');
-});
-
-test('TASK-133: with every entry granted the header carries no "more"', async () => {
- mockTwoEntries({ grants: { p1: [grantLive, grantLinear], p2: [grantRevoked] } });
- const { container } = renderTools();
- expect(await screen.findByText('2 granted')).toBeInTheDocument();
- expect(screen.queryByText(/· \d+ more/)).not.toBeInTheDocument();
- expect(container.querySelectorAll('.v2-connector-row--not-yet')).toHaveLength(0);
-});
-
-test('a hosted grant whose entry is missing stays unknown and never falls back to GitHub', async () => {
- const orphanedLinearGrant = { ...grantLinear, grantId: 'grant_orphan', writeMode: 'write', tools: ['linear.list_issues'] };
- mockTwoEntries({ catalog: [githubEntry], grants: { p1: [orphanedLinearGrant], p2: [] } });
- renderTools();
-
- const row = await screen.findByRole('button', { name: 'View Unknown connector in Launch pod' });
- expect(row).toBeInTheDocument();
- expect(row).not.toHaveTextContent('GitHub');
- expect(row).toHaveTextContent('write policy unavailable');
- expect(row.querySelector('.v2-connector-row__glyph svg')).toBeNull();
-});
-
-test('Linear exposes only its pinned read mode in the not-yet row and Add form', async () => {
- mockTwoEntries({ grants: { p1: [grantLive], p2: [] } });
- const { container } = renderTools();
- await screen.findByText('1 granted · 1 more');
-
- const linearRow = Array.from(container.querySelectorAll('.v2-connector-row--not-yet'))
- .find((candidate) => candidate.textContent.includes('Linear'));
- expect(linearRow).toBeTruthy();
- expect(linearRow).toHaveTextContent('read');
- expect(linearRow).not.toHaveTextContent('read, or read and write');
-
- fireEvent.click(within(linearRow).getByRole('button', { name: 'Add' }));
- const form = await screen.findByRole('complementary', { name: 'Add Linear' });
- const modeGroup = within(form).getByRole('group', { name: 'what it may do' });
- expect(within(modeGroup).getAllByRole('button')).toHaveLength(1);
- expect(within(modeGroup).getByRole('button', { name: 'read' })).toHaveAttribute('aria-pressed', 'true');
-});
-
-test('search filters by tool name; with no grants and no catalogue it renders nothing', async () => {
- renderTools();
- await screen.findByRole('heading', { name: 'Tools' });
- fireEvent.change(screen.getByRole('searchbox', { name: 'Search tools' }), { target: { value: 'comment' } });
- expect(screen.getAllByRole('button', { name: /^View GitHub/ })).toHaveLength(2);
- fireEvent.change(screen.getByRole('searchbox', { name: 'Search tools' }), { target: { value: 'zzz' } });
- expect(screen.getByText('Nothing matches.')).toBeInTheDocument();
-
- axios.get.mockImplementation((url) => Promise.resolve({ data: url.includes('/grants') ? { grants: [] } : { agents: [] } }));
- const { container } = render(
- ,
- );
- await waitFor(() => expect(container.textContent).not.toContain('Loading tools'));
- expect(container.querySelector('.v2-tools')).toBeNull();
-});
-
-/**
- * Scope §4, step 7's named test: "the page's call to start is credentialed".
- *
- * The start route binds a connect to the browser that began it with an
- * httpOnly cookie on its OWN response, and the API is a different origin from
- * the app. Without `withCredentials` the browser drops the `Set-Cookie`, so a
- * person consents at the vendor and comes back to a row that is still not
- * connected, every callback refusing `invalid_state`. A backend suite sets the
- * cookie itself and cannot witness this flag; this arm is the one that can.
- */
-test('step 7: the page\'s call to start a hosted connect is credentialed', async () => {
- const hosted = {
- installableId: 'linear', list: 'tools', label: 'Linear', description: 'Issues and projects in Linear.',
- connectionType: 'hosted-mcp', entryId: 'linear', available: true,
- broker: { id: 'commonly-grant-broker' },
- tools: [{ name: 'linear.list_issues', requiredWriteMode: 'read', irreversible: false }],
- connections: [],
- };
- mockApi([hosted]);
- axios.post.mockResolvedValue({ data: { authorizeUrl: 'https://mcp.linear.app/authorize?state=s' } });
- const opened = { opener: null, location: { assign: jest.fn() }, close: jest.fn() };
- const open = jest.spyOn(window, 'open').mockReturnValue(opened as never);
- try {
- renderTools();
- const connect = await screen.findByRole('button', { name: 'Connect' });
- fireEvent.click(connect);
- await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
- '/api/integrations/connect/hosted-mcp/linear/start',
- {},
- expect.objectContaining({ withCredentials: true }),
- ));
- expect(opened.location.assign).toHaveBeenCalledWith('https://mcp.linear.app/authorize?state=s');
- } finally {
- open.mockRestore();
- }
-});
-
-test('a hosted connector shows vendor setup guidance before connect', async () => {
- mockApi([{ ...linearEntry, connections: [], connectNotice: 'Ask your admin to allow this domain.' }]);
- renderTools();
- expect(await screen.findByText('Ask your admin to allow this domain.')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument();
-});
-
-// TASK-290: GitHub's App user token reaches only the repositories the App is
-// installed on, so the page that picks them sits beside Connect and stays
-// after connecting, while the person decides what to grant.
-test('a hosted connector links to where the person chooses its reach, before and after connecting', async () => {
- const accessPage = 'https://github.com/apps/commonly-connect/installations/new';
- mockApi([{ ...linearEntry, connections: [], accessPage }]);
- const first = renderTools();
- const before = await screen.findByRole('link', { name: 'Choose what Linear can reach' });
- expect(before).toHaveAttribute('href', accessPage);
- expect(before).toHaveAttribute('target', '_blank');
- expect(before).toHaveAttribute('rel', 'noopener noreferrer');
- expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument();
- first.unmount();
-
- mockApi([{ ...linearEntry, accessPage }]);
- renderTools();
- expect(await screen.findByRole('link', { name: 'Choose what Linear can reach' })).toHaveAttribute('href', accessPage);
- expect(screen.queryByRole('button', { name: 'Connect' })).toBeNull();
-});
-
-// A live grant takes the entry off the offer list, so the page moves to the
-// grant's details. Only the granter, who owns the connection, is sent there.
-test('a live hosted grant keeps the reach link on its details for the granter only', async () => {
- const accessPage = 'https://github.com/apps/commonly-connect/installations/new';
- const entry = { ...linearEntry, accessPage };
- mockTwoEntries({ catalog: [entry], grants: { p1: [grantLinear], p2: [] } });
- const first = renderTools();
- fireEvent.click(await screen.findByRole('button', { name: 'View Linear in Launch pod' }));
- const aside = await screen.findByRole('complementary', { name: 'Grant details' });
- expect(within(aside).getByRole('link', { name: 'Choose what Linear can reach' })).toHaveAttribute('href', accessPage);
- first.unmount();
-
- mockTwoEntries({ catalog: [entry], grants: { p1: [{ ...grantLinear, grantedBy: 'u2' }], p2: [] } });
- renderTools();
- fireEvent.click(await screen.findByRole('button', { name: 'View Linear in Launch pod' }));
- const other = await screen.findByRole('complementary', { name: 'Grant details' });
- expect(within(other).queryByRole('link', { name: /can reach/ })).toBeNull();
-});
-
-test('a hosted connector without an access page offers no reach link', async () => {
- mockApi([{ ...linearEntry, connections: [] }]);
- renderTools();
- expect(await screen.findByRole('button', { name: 'Connect' })).toBeInTheDocument();
- expect(screen.queryByRole('link', { name: /can reach/ })).toBeNull();
-});
-
-test('a hidden hosted entry has no offering while its existing connection can be removed', async () => {
- mockApi([]);
- const prior = axios.get.getMockImplementation();
- axios.get.mockImplementation((url, config) => {
- if (url === '/api/installables') return Promise.resolve({ data: {
- installables: [],
- hiddenToolConnections: [{ connectionId: 'conn-hidden', entryId: 'airtable', label: 'Airtable', status: 'connected', revokePage: 'https://airtable.com/account' }],
- } });
- return prior(url, config);
- });
- axios.delete.mockResolvedValue({ data: { message: 'removed' } });
- renderTools();
- expect(await screen.findByText('Airtable')).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Connect' })).toBeNull();
- expect(screen.queryByRole('button', { name: 'Add' })).toBeNull();
- fireEvent.click(screen.getByRole('button', { name: 'Remove connection' }));
- fireEvent.click(screen.getByRole('button', { name: 'Yes, remove' }));
- await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/integrations/conn-hidden', expect.anything()));
- expect(await screen.findByText(/Connection removed. Review its access at Airtable/)).toBeInTheDocument();
-});
-
-test('a hosted connect start error uses fixed copy instead of response text', async () => {
- mockApi([{ ...linearEntry, connections: [] }]);
- axios.post.mockRejectedValue({
- response: { data: { error: 'client_not_configured', message: 'spoofed vendor text' } },
- });
- const opened = { opener: null, location: { assign: jest.fn() }, close: jest.fn() };
- const open = jest.spyOn(window, 'open').mockReturnValue(opened as never);
- try {
- renderTools();
- fireEvent.click(await screen.findByRole('button', { name: 'Connect' }));
- expect(await screen.findByRole('alert')).toHaveTextContent(
- 'This connector’s sign-in setup needs attention. Ask an administrator to check it.',
- );
- expect(screen.queryByText(/spoofed vendor text/)).toBeNull();
- expect(opened.close).toHaveBeenCalled();
- } finally {
- open.mockRestore();
- }
-});
-
-test.each([400, 403])('a hosted grant HTTP %s stays open with the server refusal', async (status) => {
- mockTwoEntries({ catalog: [linearEntry], grants: { p1: [], p2: [] } });
- axios.post.mockRejectedValue({ response: { status, data: {
- error: 'invalid_target', message: 'The selected target is unavailable.',
- } } });
- renderTools();
- fireEvent.click(await screen.findByRole('button', { name: 'Add', exact: true }));
- const form = await screen.findByRole('complementary', { name: 'Add Linear' });
- fireEvent.click(within(form).getByRole('button', { name: 'Grant' }));
- expect(await within(form).findByRole('alert')).toHaveTextContent('The selected target is unavailable.');
- expect(screen.getByRole('complementary', { name: 'Add Linear' })).toBeInTheDocument();
- expect(axios.post).toHaveBeenCalledWith('/api/grants', expect.objectContaining({
- connectionId: 'conn-2', target: { kind: 'pod', id: 'p1' }, audience: ['a1'],
- }), expect.any(Object));
-});
-
-test.each([undefined, {}, { error: 'invalid_target' }, 'fallback', { grantId: '' }, { grantId: ' ' }])(
- 'an unconfirmed mint response keeps the form and old grant: %p', async (body) => {
- mockApi([githubEntry]);
- axios.post.mockResolvedValue({ data: body });
- renderTools();
- fireEvent.click(await screen.findByRole('button', { name: 'View GitHub in Launch pod' }));
- fireEvent.click(within(await screen.findByRole('complementary', { name: 'Grant details' }))
- .getByRole('button', { name: 'Change access' }));
- const form = await screen.findByRole('complementary', { name: 'Change access' });
- fireEvent.click(within(form).getByRole('button', { name: 'Grant' }));
- expect(await within(form).findByRole('alert')).toHaveTextContent('Could not grant it.');
- expect(screen.getByRole('complementary', { name: 'Change access' })).toBeInTheDocument();
- expect(axios.post.mock.calls.map(([url]) => url)).toEqual(['/api/grants']);
- },
-);
-
-
-test.each(['unavailable', 'removed'])('a catalogue refresh (%s) cannot hide a refused hosted grant draft', async (refresh) => {
- mockTwoEntries({ catalog: [linearEntry], grants: { p1: [], p2: [] } });
- const message = 'Agents can self-install into this pod. Make it invite-only to use this connector.';
- axios.post.mockRejectedValue({ response: { status: 403, data: { error: 'outsider_grant_pod_joinable', message } } });
- renderTools();
- fireEvent.click(await screen.findByRole('button', { name: 'Add' }));
- const form = await screen.findByRole('complementary', { name: 'Add Linear' });
- fireEvent.click(within(form).getByRole('button', { name: 'Grant' }));
- expect(await within(form).findByRole('alert')).toHaveTextContent(message);
- const previousGet = axios.get.getMockImplementation();
- axios.get.mockImplementation((url, ...args) => url === '/api/installables'
- ? (refresh === 'unavailable' ? Promise.reject(new Error('offline')) : Promise.resolve({ data: { installables: [] } }))
- : previousGet(url, ...args));
- const reads = axios.get.mock.calls.length;
- await act(async () => { document.dispatchEvent(new Event('visibilitychange')); });
- await waitFor(() => expect(axios.get.mock.calls.length).toBeGreaterThan(reads));
- expect(screen.getByRole('complementary', { name: 'Add Linear' })).toBeInTheDocument();
- expect(screen.getByRole('alert')).toHaveTextContent(message);
- expect(screen.getByText('0 granted')).toBeInTheDocument();
-});
-
-test('hosted grant picker sends the selected pod ID and keeps the agent User ID in audience', async () => {
- mockTwoEntries({ catalog: [linearEntry], grants: { p1: [], p2: [] } });
- const previousGet = axios.get.getMockImplementation();
- axios.get.mockImplementation((url, ...args) => url === '/api/registry/pods/p2/agents'
- ? Promise.resolve({ data: { agents: [{ name: 'reed', userId: 'a2' }] } })
- : previousGet(url, ...args));
- renderTools();
- fireEvent.click(await screen.findByRole('button', { name: 'Add' }));
- const form = await screen.findByRole('complementary', { name: 'Add Linear' });
- fireEvent.change(within(form).getByRole('combobox', { name: 'pod' }), { target: { value: 'p2' } });
- fireEvent.click(within(form).getByRole('button', { name: 'Grant' }));
- await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/grants', expect.objectContaining({
- target: { kind: 'pod', id: 'p2' }, audience: ['a2'],
- }), expect.anything()));
-});
-
-
-test('the dialog cannot submit an empty audience and re-enables grant when an agent is selected', async () => {
- mockTwoEntries({ catalog: [linearEntry], grants: { p1: [], p2: [] } });
- renderTools();
- fireEvent.click(await screen.findByRole('button', { name: 'Add' }));
- const form = await screen.findByRole('complementary', { name: 'Add Linear' });
- const submit = within(form).getByRole('button', { name: 'Grant' });
- expect(submit).toBeEnabled();
- fireEvent.click(within(form).getByRole('checkbox', { name: 'Scout' }));
- expect(within(form).getByRole('status')).toHaveTextContent('Select at least one agent to grant access.');
- expect(submit).toBeDisabled();
- fireEvent.click(submit);
- expect(axios.post).not.toHaveBeenCalled();
- fireEvent.click(within(form).getByRole('checkbox', { name: 'Scout' }));
- expect(submit).toBeEnabled();
- fireEvent.click(submit);
- await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/grants', expect.objectContaining({ audience: ['a1'] }), expect.anything()));
-});
-
-
-test('Grant again on an existing empty-audience row requires an explicit new selection', async () => {
- mockTwoEntries({ catalog: [linearEntry], grants: { p1: [], p2: [{ ...grantRevoked, tools: ['linear.list_issues'], effectiveAudience: [] }] } });
- renderTools();
- fireEvent.click(await screen.findByRole('button', { name: 'Grant again' }));
- const form = await screen.findByRole('complementary', { name: 'Add Linear' });
- const agent = within(form).getByRole('checkbox', { name: 'Scout' });
- expect(agent).not.toBeChecked();
- const submit = within(form).getByRole('button', { name: 'Grant' });
- expect(submit).toBeDisabled();
- fireEvent.click(submit);
- expect(axios.post).not.toHaveBeenCalled();
- fireEvent.click(agent);
- fireEvent.click(submit);
- await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/grants', expect.objectContaining({ audience: ['a1'], target: { kind: 'pod', id: 'p2' } }), expect.anything()));
-});
-
-
-test.each([
- { label: 'human-only', audience: ['u1'], selected: false },
- { label: 'mixed human and agent', audience: ['u1', 'a1'], selected: true },
-])('Grant again restores only current agents from a $label legacy audience', async ({ audience, selected }) => {
- mockTwoEntries({ catalog: [githubEntry], grants: { p1: [], p2: [{ ...grantRevoked, effectiveAudience: audience }] } });
- renderTools();
- fireEvent.click(await screen.findByRole('button', { name: 'Grant again' }));
- const form = await screen.findByRole('complementary', { name: 'Add GitHub' });
- const scout = within(form).getByRole('checkbox', { name: 'Scout' });
- const submit = within(form).getByRole('button', { name: 'Grant' });
- expect(scout.checked).toBe(selected);
- if (!selected) {
- expect(submit).toBeDisabled();
- expect(within(form).getByText('Select at least one agent to grant access.')).toBeInTheDocument();
- fireEvent.click(submit);
- expect(axios.post).not.toHaveBeenCalled();
- fireEvent.click(scout);
- }
- expect(submit).toBeEnabled();
- fireEvent.click(submit);
- await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/grants', expect.objectContaining({
- target: { kind: 'pod', id: 'p2' }, audience: ['a1'],
- }), expect.anything()));
-});
-
-test('a Tools row shows the zh-CN catalog copy, and an unmapped entry keeps the server text', async () => {
- const linear = {
- installableId: 'linear', list: 'tools', label: 'Linear', description: 'Read Linear issues and projects.',
- connectionType: 'hosted-mcp', entryId: 'linear', available: true, tools: [], connections: [], installation: null, integration: null,
- };
- mockApi([linear, { ...linear, installableId: 'acme-tools', label: 'Acme', description: 'Acme server copy.', entryId: 'acme' }]);
- await i18nReady;
- await act(async () => { await i18n.changeLanguage('zh-CN'); });
- try {
- renderTools();
- expect(await screen.findByText('你自己的 Linear 工作空间中的议题、项目、周期和发布。')).toBeInTheDocument();
- expect(screen.getByText('Acme server copy.')).toBeInTheDocument();
- } finally {
- await act(async () => { await i18n.changeLanguage('en'); });
- }
-});
-
-test('the open grant form reads in zh-CN, not English', async () => {
- await i18nReady;
- await act(async () => { await i18n.changeLanguage('zh-CN'); });
- try {
- axios.get.mockImplementation((url) => {
- if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } });
- if (url === '/api/grants') return Promise.resolve({ data: { grants: [grantRevoked] } });
- if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [{ name: 'scout', displayName: 'Scout', userId: 'a1' }] } });
- if (url.includes('/registry/pods/')) return Promise.resolve({ data: { agents: [] } });
- if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'g', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } });
- return Promise.reject(new Error(`unmocked ${url}`));
- });
- renderTools();
- fireEvent.click(await screen.findByRole('button', { name: /^添加$|^Add$/ }));
- const form = await screen.findByRole('complementary');
- expect(within(form).getByText('无需确认')).toBeInTheDocument();
- expect(within(form).queryByText('nothing asks first')).toBeNull();
- expect(within(form).queryByText('read and write, ask first')).toBeNull();
- // No English button left in the zh-CN form.
- expect(within(form).queryByRole('button', { name: 'Grant' })).toBeNull();
- } finally {
- await act(async () => { await i18n.changeLanguage('en'); });
- }
-});
diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx
deleted file mode 100644
index 9210f8ec0e..0000000000
--- a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx
+++ /dev/null
@@ -1,1734 +0,0 @@
-// @ts-nocheck
-// Signal Connectors page: rows preserve the Phase 1 verbs while the selected
-// channel owns code, confirmation, relay controls, and disconnect in its aside.
-import React from 'react';
-import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
-import { MemoryRouter, Route, Routes } from 'react-router-dom';
-import V2ConnectorsPage, { INSTALL_LOCK_TTL_MS, installableLifecyclePath } from '../components/V2ConnectorsPage';
-import { PlatformGlyph } from '../icons/platforms';
-import { AuthContext } from '../../context/AuthContext';
-import en from '../../i18n/locales/en.json';
-import i18n, { i18nReady } from '../../i18n';
-
-jest.mock('axios', () => {
- const mock = {
- get: jest.fn(),
- post: jest.fn(),
- patch: jest.fn(),
- delete: jest.fn(),
- defaults: { baseURL: '', headers: { common: {} } },
- interceptors: {
- request: { use: jest.fn(), eject: jest.fn() },
- response: { use: jest.fn(), eject: jest.fn() },
- },
- };
- return { __esModule: true, default: mock, ...mock };
-});
-
-const axios = jest.requireMock('axios').default;
-
-const authValue = {
- currentUser: { _id: 'u1', username: 'sam' },
- user: { _id: 'u1', username: 'sam' },
- token: 'user-jwt',
- loading: false,
- error: null,
- isAuthenticated: true,
- register: jest.fn(),
- login: jest.fn(),
- logout: jest.fn(),
- updateProfile: jest.fn(),
-};
-
-const connectors = [
- {
- _id: 'i-pending',
- installationId: 'install-telegram-u1',
- type: 'telegram',
- status: 'pending',
- createdAt: new Date(Date.now() - 60_000).toISOString(),
- config: { connectCode: 'abc123', connectCodeExpiresAt: new Date(Date.now() + 5 * 60_000).toISOString() },
- podId: { _id: 'p1', name: 'Rewire Live Demo' },
- },
- {
- _id: 'i-live',
- installationId: 'install-slack-u1',
- type: 'slack',
- status: 'connected',
- createdAt: new Date(Date.now() - 3_600_000).toISOString(),
- updatedAt: new Date().toISOString(),
- config: { chatTitle: 'Rewire crew', liveRelay: false },
- podId: { _id: 'p2', name: 'Ops' },
- },
-];
-
-const mockGets = (list = connectors, installables = null) => {
- axios.get.mockImplementation((url) => {
- if (url === '/api/integrations/user/all') return Promise.resolve({ data: list });
- if (url === '/api/installables' && installables) return Promise.resolve({ data: { installables } });
- if (url === '/api/pods') {
- return Promise.resolve({
- data: [
- { _id: 'p1', name: 'Rewire Live Demo', type: 'chat' },
- { _id: 'pub', name: 'Town Square', type: 'community' },
- ],
- });
- }
- return Promise.resolve({ data: [] });
- });
-};
-
-const hostedLinearCatalogEntry = (connectionId = 'i-linear') => ({
- installableId: 'linear',
- list: 'tools',
- label: 'Linear',
- description: 'Read Linear issues and projects.',
- connectionType: 'hosted-mcp',
- entryId: 'linear',
- available: true,
- tools: [],
- connections: [{ connectionId, owner: 'sam', repo: '' }],
- installation: null,
- integration: null,
-});
-
-const renderPage = () => render(
-
-
-
-
- ,
-);
-
-// The reconciler's own constant — the reason a person reads when the channel
-// record behind a connector is gone. It replaced the old 'projection missing'
-// wording in the TASK-131 copy pass.
-const REASON_CHANNEL_GONE = "This connector's channel is gone. Retry to rebuild it.";
-
-describe('V2ConnectorsPage', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- jest.spyOn(window, 'open').mockReturnValue(null);
- window.history.replaceState({}, '', '/v2/connectors');
- });
-
- afterEach(() => jest.restoreAllMocks());
-
- it('Direction A: a linked row carries a mono kicker, a relay mark with its sentence in the label, and Manage as the gear', async () => {
- mockGets([connectors[1]]);
- renderPage();
- const manage = await screen.findByRole('button', { name: 'Manage' });
- expect(manage).toHaveClass('v2-connector-row__action--icon');
- expect(manage).toHaveAttribute('title', 'Manage');
- expect(manage.textContent).toBe('');
- expect(manage.querySelector('svg')).not.toBeNull();
- const row = manage.closest('.v2-connector-row');
- expect(row?.querySelector('.v2-connector-row__kicker')?.textContent).toMatch(/ · added /);
- const mark = row?.querySelector('.v2-connector-row__mark');
- expect(mark).not.toBeNull();
- expect(mark).toHaveAttribute('role', 'img');
- // TASK-162 (3): the glyph carries the MODE WORD (what the mark means); the
- // consequence is the visible words beside it, so line 3 is never a bare mark.
- expect(mark?.getAttribute('aria-label')).toMatch(/^(attention|mirror|relay off)$/);
- expect(mark?.getAttribute('title')).toBe(mark?.getAttribute('aria-label'));
- expect(row?.querySelector('.v2-connector-row__mark-text')?.textContent).toBe('messages stay in the pod');
- // The mode word is NOT repeated on line 3: at ≤760 the kicker carries it, so
- // 'relay off · messages stay in the pod' said it twice (TASK-162 UX gate).
- expect(row?.querySelector('.v2-connector-row__mark-text')?.textContent).not.toMatch(/^(attention|mirror|relay off) ·/);
- expect(row?.querySelector('.v2-connector-row__kicker-mode')?.textContent).toMatch(/attention|mirror|relay off/);
- expect(row?.querySelector('.v2-connector-row__when')).toBeNull();
- });
-
- // TASK-177. The mark and the Tools glyph agree at 14px through TWO independent
- // literals in two files: `size={14}` at the mark's call site, and
- // V2ConnectorTools' own local `G` (`:157`, `width="14" height="14"`) — it does
- // not import `MarkGlyph`, and `icons/glyphs.tsx`'s `G` defaults to 16.
- // So nothing shared carries the agreement and nothing tested it: drop the prop
- // and the mark renders 16 while Tools stays 14, silently desyncing the two rows
- // v2.css claims cannot drift. And a `14` asserted in one file would have passed
- // through every misreading of this chain, so what is pinned is the AGREEMENT:
- // whichever number the design lands on, both glyphs must carry it. The absolute
- // size stays the gate's decision, not this test's.
- it('TASK-177: the connector mark and the Tools mode glyph render at the same size', async () => {
- const toolsEntry = {
- installableId: 'github', list: 'tools', label: 'GitHub', description: 'Issues and pull requests.', available: true,
- broker: { id: 'commonly-grant-broker' },
- tools: [{ name: 'github.list_issues', requiredWriteMode: 'read', irreversible: false }],
- connections: [],
- };
- const grant = {
- grantId: 'grant_live', installationId: 'inst-1', target: { kind: 'pod', id: 'p1' }, tools: ['github.list_issues'],
- writeMode: 'read', budget: { calls: 50, windowMs: 3600000 }, effectiveAudience: [],
- expiresAt: new Date(Date.now() + 6 * 86400000).toISOString(), revokedAt: null, revokedBy: null,
- parentGrantId: null, rootGrantId: null, createdAt: new Date().toISOString(), grantedBy: 'u1',
- };
- axios.get.mockImplementation((url) => {
- if (url === '/api/integrations/user/all') return Promise.resolve({ data: [connectors[1]] });
- if (url === '/api/pods') return Promise.resolve({ data: [{ _id: 'p1', name: 'Launch pod', type: 'chat' }] });
- if (url === '/api/installables') return Promise.resolve({ data: { installables: [toolsEntry] } });
- if (url === '/api/grants') return Promise.resolve({ data: { grants: [grant] } });
- if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } });
- if (url.includes('/calls')) {
- return Promise.resolve({ data: { grantId: 'grant_live', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } });
- }
- return Promise.resolve({ data: [] });
- });
- const { container } = renderPage();
- // Both glyphs have to actually BE there, or the comparison would pass on two
- // absent nodes — the vacuity the same-size claim is easiest to fake.
- const markSvg = await waitFor(() => {
- expect(container.querySelector('.v2-connector-row__mark svg')).not.toBeNull();
- return container.querySelector('.v2-connector-row__mark svg') as SVGElement;
- });
- const modeSvg = await waitFor(() => {
- expect(container.querySelector('.v2-tools__mode svg')).not.toBeNull();
- return container.querySelector('.v2-tools__mode svg') as SVGElement;
- });
- // Positive control: these attributes are the instrument, so prove they carry a
- // value at all (an empty string would make the equality below meaningless).
- // Both attributes, both glyphs: controlling only `width` left the height
- // comparison able to pass on null === null — strip `height` from *both* `G`
- // components and a width-only control stays green (sprint-review's gate).
- expect(markSvg.getAttribute('width')).toMatch(/^\d+$/);
- expect(markSvg.getAttribute('height')).toMatch(/^\d+$/);
- expect(modeSvg.getAttribute('width')).toMatch(/^\d+$/);
- expect(modeSvg.getAttribute('height')).toMatch(/^\d+$/);
- expect(markSvg.getAttribute('width')).toBe(modeSvg.getAttribute('width'));
- expect(markSvg.getAttribute('height')).toBe(modeSvg.getAttribute('height'));
- });
-
- it('TASK-162: line 3 never repeats the mode word the kicker already carries', async () => {
- // The prefixes lived in the component's defaultValue, which is exactly where
- // they could not be guarded: once the key exists in the catalog the catalog
- // wins, so reverting the defaultValue changes nothing rendered. The copy now
- // lives in en.json, so that is where the rule is asserted.
- const kickerByRow: Array<[keyof typeof en.connectors, string]> = [
- ['rowAttention', 'attention'],
- ['rowMirror', 'mirror'],
- ['rowRelayOff', 'relay off'],
- ];
- for (const [key, modeWord] of kickerByRow) {
- expect(en.connectors[key]).toBeTruthy();
- expect(en.connectors[key].startsWith(modeWord)).toBe(false);
- }
- });
-
- // TASK-338: dead rows sit under the shared "inactive · N" fold, collapsed.
- const openInactiveFold = async () => {
- fireEvent.click(await screen.findByRole('button', { name: /^inactive · \d+$/ }));
- };
-
- it('TASK-338: the unbuilt-provider placeholder is gone, and no not-yet copy renders', async () => {
- mockGets([]);
- const { container } = renderPage();
- await waitFor(() => expect(container.querySelector('.v2-connectors__rows')).not.toBeNull());
- expect(container.querySelector('.v2-connector-row__names')).toBeNull();
- expect(screen.queryByText(/not yet/i)).toBeNull();
- expect(screen.queryByText(/Tell us which channel/)).toBeNull();
- });
-
- it('TASK-131: relative ages advance in place, and a returning tab re-reads, without a reload', async () => {
- // Fake the clock so the minute tick is deterministic. The fixture's age
- // only moves if the page re-renders it, which is the defect being pinned.
- jest.useFakeTimers();
- try {
- const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString();
- mockGets([{ ...connectors[1], createdAt: fiveMinutesAgo, updatedAt: fiveMinutesAgo }]);
- renderPage();
-
- await screen.findByText(/added 5m$/);
-
- await act(async () => { jest.advanceTimersByTime(2 * 60_000); });
- expect(screen.getByText(/added 7m$/)).toBeInTheDocument();
-
- const reads = () => axios.get.mock.calls.filter(([url]) => url === '/api/integrations/user/all').length;
- const before = reads();
- document.dispatchEvent(new Event('visibilitychange'));
- await waitFor(() => expect(reads()).toBeGreaterThan(before));
- } finally {
- jest.useRealTimers();
- }
- });
-
- it('renders the Signal row list and opens the pending channel in the selected aside', async () => {
- mockGets();
- const { container } = renderPage();
-
- await screen.findByRole('button', { name: 'View Telegram' });
- expect(screen.getByText('Send /commonly-enable in your Telegram chat.')).toBeInTheDocument();
- expect(screen.getByText('Code expires in 5 min')).toBeInTheDocument();
- expect(screen.getByText('Rewire crew · linked to Ops')).toBeInTheDocument();
- // TASK-338: the unbuilt-provider placeholder row is gone from the list.
- expect(container.querySelector('.v2-connector-row__names')).toBeNull();
- expect(screen.getByText('/commonly-enable abc1 23')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Copy command' })).toBeInTheDocument();
- expect(container.querySelectorAll('.v2-connector-row__glyph')).toHaveLength(2);
- });
-
- // TASK-156. A linked Slack stores its workspace in `teamName`
- // (slackOAuthService) and never in `chatTitle`, so the row read "Slack ·
- // linked to Ops" beside the word Slack. The workspace name belongs on the
- // detail line, not in the name slot — the name stays the platform.
- describe('the row names the Slack workspace (TASK-156)', () => {
- const slackRow = (config, podId = { _id: 'p2', name: 'Ops' }) => ({
- _id: 'i-slack',
- installationId: 'install-slack-u1',
- type: 'slack',
- status: 'connected',
- createdAt: new Date(Date.now() - 3_600_000).toISOString(),
- updatedAt: new Date().toISOString(),
- config,
- podId,
- });
-
- it('reads the teamName when the link carries no chatTitle', async () => {
- mockGets([slackRow({ teamName: 'Acme', liveRelay: false })]);
- renderPage();
-
- expect(await screen.findByText('Acme · linked to Ops')).toBeInTheDocument();
- expect(screen.queryByText('Slack · linked to Ops')).toBeNull();
- const row = screen.getByText('Acme · linked to Ops').closest('.v2-connector-row');
- expect(row?.querySelector('.v2-connector-row__name')?.textContent).toContain('Slack');
- });
-
- it('reads the teamName on the not-linked variant too', async () => {
- mockGets([slackRow({ teamName: 'Acme', liveRelay: false }, null)]);
- renderPage();
-
- expect(await screen.findByText('Acme · not linked to a pod')).toBeInTheDocument();
- });
-
- it('keeps chatTitle winning when the link carries both', async () => {
- mockGets([slackRow({ chatTitle: 'Rewire crew', teamName: 'Acme', liveRelay: false })]);
- renderPage();
-
- expect(await screen.findByText('Rewire crew · linked to Ops')).toBeInTheDocument();
- expect(screen.queryByText('Acme · linked to Ops')).toBeNull();
- });
- });
-
- it('offers a new code from the row and aside when the Telegram code has expired', async () => {
- mockGets([{ ...connectors[0], config: { connectCode: 'abc123' } }]);
- axios.post.mockResolvedValue({ data: {} });
- renderPage();
-
- expect((await screen.findAllByText('The enable code expired.')).length).toBeGreaterThan(0);
- expect(screen.queryByText('/commonly-enable abc1 23')).toBeNull();
- fireEvent.click(screen.getAllByRole('button', { name: 'New code' })[0]);
- await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
- '/api/integrations/i-pending/connect-code',
- {},
- expect.anything(),
- ));
- });
-
- it('selects a connected channel and keeps relay and mode PATCHes in its aside', async () => {
- mockGets();
- axios.patch.mockResolvedValue({ data: {} });
- renderPage();
-
- fireEvent.click((await screen.findAllByRole('button', { name: 'View Slack' }))[0]);
- fireEvent.click(screen.getByRole('checkbox', { name: 'Relay' }));
- await waitFor(() => expect(axios.patch).toHaveBeenCalledWith(
- '/api/integrations/i-live',
- { config: { liveRelay: true } },
- expect.anything(),
- ));
- });
-
- it('PATCHes the mirror mode from the selected channel aside', async () => {
- mockGets([{
- ...connectors[1],
- config: { chatTitle: 'Rewire crew', liveRelay: true, relayAllAgentMessages: false },
- }]);
- axios.patch.mockResolvedValue({ data: {} });
- renderPage();
-
- await screen.findByRole('button', { name: 'Attention' });
- fireEvent.click(screen.getByRole('button', { name: 'Mirror' }));
- await waitFor(() => expect(axios.patch).toHaveBeenCalledWith(
- '/api/integrations/i-live',
- { config: { relayAllAgentMessages: true } },
- expect.anything(),
- ));
- });
-
- it('uses the inline provider and pod form with every pod the user is in', async () => {
- mockGets([]);
- axios.post.mockResolvedValue({ data: { integration: { _id: 'new' } } });
- renderPage();
-
- fireEvent.click(await screen.findByRole('button', { name: 'Connect a channel' }));
- const picker = screen.getByLabelText('Pod to bridge');
- const options = Array.from(picker.querySelectorAll('option')).map((option) => option.textContent);
- expect(options).toContain('Rewire Live Demo');
- expect(options).toContain('Town Square');
- expect(picker.closest('.v2-connectors__aside')).not.toBeNull();
- fireEvent.click(screen.getByRole('button', { name: 'Connect' }));
- await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
- '/api/installables/telegram/install',
- { podId: 'p1' },
- expect.anything(),
- ));
- });
-
- it('selects Slack in the inline provider form and installs through its lifecycle verb', async () => {
- mockGets([]);
- axios.post.mockResolvedValue({ data: { integration: { _id: 'slack-new' } } });
- renderPage();
-
- fireEvent.click(await screen.findByRole('button', { name: 'Connect a channel' }));
- fireEvent.click(screen.getByRole('button', { name: 'Slack' }));
- fireEvent.click(screen.getByRole('button', { name: 'Connect' }));
- await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
- '/api/installables/slack/install',
- { podId: 'p1' },
- expect.anything(),
- ));
- });
-
- it('keeps the form open while an install claim is still in progress', async () => {
- mockGets([]);
- axios.post.mockResolvedValue({ data: { status: 'installing' } });
- renderPage();
-
- fireEvent.click(await screen.findByRole('button', { name: 'Connect a channel' }));
- fireEvent.click(screen.getByRole('button', { name: 'Connect' }));
- expect(await screen.findByText('Still setting up — try again in a moment.')).toBeInTheDocument();
- expect(screen.getByLabelText('Pod to bridge')).toBeInTheDocument();
- });
-
- it('names the pending pod returned with an install response', async () => {
- mockGets([]);
- axios.post.mockResolvedValue({ data: { status: 'installing', boundPodId: 'p1' } });
- renderPage();
-
- fireEvent.click(await screen.findByRole('button', { name: 'Connect a channel' }));
- fireEvent.click(screen.getByRole('button', { name: 'Connect' }));
- expect(await screen.findByText('Still setting up for Rewire Live Demo — try again in a moment.')).toBeInTheDocument();
- expect(screen.getByLabelText('Pod to bridge')).toBeInTheDocument();
- });
-
- it('keeps the form open when the server reports an install lock', async () => {
- mockGets([]);
- axios.post.mockRejectedValue({ response: { status: 409, data: { code: 'install_in_progress', boundPodId: 'p1' } } });
- renderPage();
-
- fireEvent.click(await screen.findByRole('button', { name: 'Connect a channel' }));
- fireEvent.click(screen.getByRole('button', { name: 'Connect' }));
- expect(await screen.findByText('Still setting up for Rewire Live Demo — try again in a moment.')).toBeInTheDocument();
- expect(screen.getByLabelText('Pod to bridge')).toBeInTheDocument();
- });
-
- it('keeps the form open when an install lock does not name its pod', async () => {
- mockGets([]);
- axios.post.mockRejectedValue({ response: { status: 409, data: { code: 'install_in_progress' } } });
- renderPage();
-
- fireEvent.click(await screen.findByRole('button', { name: 'Connect a channel' }));
- fireEvent.click(screen.getByRole('button', { name: 'Connect' }));
- expect(await screen.findByText('Still setting up — try again in a moment.')).toBeInTheDocument();
- expect(screen.getByLabelText('Pod to bridge')).toBeInTheDocument();
- });
-
- it('names the existing pod when the installable is already bound elsewhere', async () => {
- mockGets([]);
- axios.post.mockRejectedValue({ response: { status: 409, data: { code: 'already_installed', boundPodId: 'p1' } } });
- renderPage();
-
- fireEvent.click(await screen.findByRole('button', { name: 'Connect a channel' }));
- fireEvent.click(screen.getByRole('button', { name: 'Connect' }));
- const refusal = await screen.findByText(/bound to Rewire Live Demo/);
- expect(refusal).toBeInTheDocument();
- // TASK-154 item 6: the old copy ('Remove it to bind a different
- // pod') taught the one-pod model. The refusal is about the bind, and other
- // pods reach the channel through the switches. TASK-173: the arm used to
- // pin the word 'gate switches', which is this file's internal name and no
- // string the page renders — so the witnessed wording went stale in the
- // same direction the copy did. It now matches the label the user can
- // actually look for, and the phrase is longer than the section title so a
- // page that merely renders the section cannot satisfy it.
- expect(screen.getByText(/through "Pods that reach this channel"/)).toBeInTheDocument();
- expect(screen.queryByText(/gate switches/)).toBeNull();
- // The verdict that narrowed this twice: "Channel details" is the aside's
- // `aria-label` (:1220, :1286) and never visible text, so naming it in the
- // copy sends a sighted user after a string the page does not render. That
- // the phrase is an attribute and not text is witnessed where the aside is
- // actually rendered (the gates scenario below); here the copy is checked,
- // which is the half that can be checked in this state.
- expect(refusal.textContent).not.toMatch(/Channel details/);
- expect(screen.queryByText(/Remove it to bind/)).toBeNull();
- expect(screen.getByLabelText('Pod to bridge')).toBeInTheDocument();
- });
-
- it('disconnects installable and legacy rows through their respective verbs', async () => {
- mockGets([connectors[1]]);
- axios.delete.mockResolvedValue({ data: { status: 'uninstalled' } });
- const firstRender = renderPage();
-
- fireEvent.click(await screen.findByRole('button', { name: 'Remove' }));
- fireEvent.click(screen.getByRole('button', { name: /Really remove/ }));
- await waitFor(() => expect(axios.delete).toHaveBeenCalledWith(
- '/api/installables/slack/install',
- expect.anything(),
- ));
-
- firstRender.unmount();
- mockGets([{ ...connectors[1], installationId: undefined }]);
- axios.patch.mockResolvedValue({ data: {} });
- renderPage();
- fireEvent.click(await screen.findByRole('button', { name: 'Remove' }));
- fireEvent.click(screen.getByRole('button', { name: /Really remove/ }));
- await waitFor(() => expect(axios.patch).toHaveBeenCalledWith(
- '/api/integrations/i-live',
- { isActive: false },
- expect.anything(),
- ));
- });
-
- it('disconnects an installable Telegram connector through its lifecycle verb', async () => {
- mockGets([{ ...connectors[1], installationId: 'install-telegram-u1', type: 'telegram' }]);
- axios.delete.mockResolvedValue({ data: { status: 'uninstalled' } });
- renderPage();
-
- fireEvent.click(await screen.findByRole('button', { name: 'Remove' }));
- fireEvent.click(screen.getByRole('button', { name: /Really remove/ }));
- await waitFor(() => expect(axios.delete).toHaveBeenCalledWith(
- '/api/installables/telegram/install',
- expect.anything(),
- ));
- });
-
- it('removes the retired SOON tiles and the not-yet placeholder copy', async () => {
- mockGets([]);
- renderPage();
-
- await screen.findByRole('button', { name: /^inactive · \d+$/ }).catch(() => null);
- expect(screen.queryByText(/Tell us which channel/)).toBeNull();
- expect(screen.queryByText('SOON')).toBeNull();
- });
-
- it('does not offer another install control once both supported providers are present', async () => {
- mockGets();
- renderPage();
-
- await screen.findByRole('button', { name: 'View Telegram' });
- expect(screen.queryByRole('button', { name: 'Connect a channel' })).toBeNull();
- });
-
- it('requests the credentialed Slack authorization URL and drops the opener', async () => {
- const slackWindow = { location: { assign: jest.fn() }, close: jest.fn(), opener: window };
- jest.mocked(window.open).mockReturnValue(slackWindow as unknown as Window);
- mockGets([{
- _id: 'i-slack-authorize', installationId: 'install-slack-u1', type: 'slack', status: 'pending',
- config: { connectCode: 'a'.repeat(32), connectCodeExpiresAt: new Date(Date.now() + 60_000).toISOString() },
- podId: { _id: 'p1', name: 'Rewire Live Demo' },
- }]);
- axios.post.mockResolvedValue({ data: { authorizeUrl: 'https://slack.test/oauth?state=secret' } });
- renderPage();
-
- fireEvent.click((await screen.findAllByRole('button', { name: 'Authorize in Slack' }))[0]);
- await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
- '/api/installables/slack/authorize-url',
- {},
- expect.objectContaining({ withCredentials: true }),
- ));
- expect(slackWindow.location.assign).toHaveBeenCalledWith('https://slack.test/oauth?state=secret');
- expect(slackWindow.opener).toBeNull();
- });
-
- it('keeps an un-coded Slack authorization failure generic — the fallback, row and aside (Row C control)', async () => {
- mockGets([{
- _id: 'i-slack-authorize', installationId: 'install-slack-u1', type: 'slack', status: 'pending',
- config: {}, podId: { _id: 'p1', name: 'Rewire Live Demo' },
- }]);
- axios.post.mockRejectedValue({ response: { status: 503 } });
- renderPage();
-
- fireEvent.click((await screen.findAllByRole('button', { name: 'Authorize in Slack' }))[0]);
- await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
- '/api/installables/slack/authorize-url',
- {},
- expect.objectContaining({ withCredentials: true }),
- ));
- // A refusal with no body still gets the generic sentence — the fallback the
- // named codes must not have replaced.
- const shown = await screen.findAllByText('Could not begin Slack authorization. Try again in a moment.');
- expect(shown.length).toBeGreaterThan(0);
- });
-
- it('names a refused Slack authorize on the row that asked, not only at the page foot (Row C)', async () => {
- mockGets([{
- _id: 'i-slack-authorize', installationId: 'install-slack-u1', type: 'slack', status: 'pending',
- config: {}, podId: { _id: 'p1', name: 'Rewire Live Demo' },
- }]);
- axios.post.mockRejectedValue({
- response: { status: 409, data: { code: 'slack_already_authorized', error: 'Upstream wording that a known code must not surface.' } },
- });
- renderPage();
-
- fireEvent.click((await screen.findAllByRole('button', { name: 'Authorize in Slack' }))[0]);
- await waitFor(() => expect(axios.post).toHaveBeenCalled());
-
- const row = (await screen.findByRole('button', { name: 'View Slack' })).closest('article') as HTMLElement;
- expect(row).not.toBeNull();
- // The code's own copy, not the server sentence the same body also carries.
- expect(within(row).getByRole('alert')).toHaveTextContent('Slack is already awaiting confirmation or connected.');
- expect(within(row).queryByText(/Upstream wording/)).toBeNull();
- // ...and beside the button in the selected row's aside.
- const aside = document.querySelector('.v2-connectors__aside') as HTMLElement;
- expect(within(aside).getByRole('alert')).toHaveTextContent('Slack is already awaiting confirmation or connected.');
- // Placement is the defect (Row C): the page-level slot used to be the only one.
- expect(document.querySelector('.v2-connectors__error')).toBeNull();
- });
-
- it('uses the server sentence when the refusal code is not one the page knows (Row C)', async () => {
- mockGets([{
- _id: 'i-slack-authorize', installationId: 'install-slack-u1', type: 'slack', status: 'pending',
- config: {}, podId: { _id: 'p1', name: 'Rewire Live Demo' },
- }]);
- axios.post.mockRejectedValue({
- response: { status: 503, data: { code: 'slack_upstream_unavailable', error: 'Slack refused the handshake.' } },
- });
- renderPage();
-
- fireEvent.click((await screen.findAllByRole('button', { name: 'Authorize in Slack' }))[0]);
- const row = (await screen.findByRole('button', { name: 'View Slack' })).closest('article') as HTMLElement;
- await waitFor(() => expect(within(row).getByRole('alert')).toHaveTextContent('Slack refused the handshake.'));
- });
-
- it('names a failed Slack confirm on the row, not in the page slot (Row C)', async () => {
- mockGets([{
- _id: 'i-slack-pending', installationId: 'install-slack-u1', type: 'slack', status: 'pending',
- config: { pendingBind: { teamName: 'Commonly HQ', slackUserName: 'sam' } },
- podId: { _id: 'p1', name: 'Rewire Live Demo' },
- }]);
- axios.post.mockRejectedValue({
- response: { status: 409, data: { code: 'slack_bind_expired', error: 'Upstream wording that a known code must not surface.' } },
- });
- renderPage();
-
- expect(await screen.findByText('Commonly HQ wants to connect as @sam.')).toBeInTheDocument();
- expect(screen.getByText('No agent is in this pod yet. Messages will reach the pod; choose a lead later.')).toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: 'Confirm connection' }));
- await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/installables/slack/confirm', {}, expect.anything()));
-
- const row = (await screen.findByRole('button', { name: 'View Slack' })).closest('article') as HTMLElement;
- expect(within(row).getByRole('alert')).toHaveTextContent('Slack authorization expired. Start again.');
- expect(document.querySelector('.v2-connectors__error')).toBeNull();
- });
-
- it('moves Slack confirmation and rejection to the selected aside', async () => {
- mockGets([{
- _id: 'i-slack-pending', installationId: 'install-slack-u1', type: 'slack', status: 'pending',
- config: { pendingBind: { teamName: 'Commonly HQ', slackUserName: 'sam' } },
- podId: { _id: 'p1', name: 'Rewire Live Demo' },
- }]);
- axios.post.mockResolvedValue({ data: { status: 'connected' } });
- renderPage();
-
- expect(await screen.findByText('Commonly HQ wants to connect as @sam.')).toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: 'Confirm connection' }));
- await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
- '/api/installables/slack/confirm',
- {},
- expect.anything(),
- ));
- });
-
- it('requires a pod agent at Slack bind and sends the selected lead', async () => {
- mockGets([{
- _id: 'i-slack-pending', installationId: 'install-slack-u1', type: 'slack', status: 'pending',
- config: { pendingBind: { teamName: 'Commonly HQ', slackUserName: 'sam' } },
- podId: { _id: 'p1', name: 'Rewire Live Demo' },
- }]);
- const priorGet = axios.get.getMockImplementation();
- axios.get.mockImplementation((url) => url === '/api/pods'
- ? Promise.resolve({ data: [{
- _id: 'p1', name: 'Rewire Live Demo', type: 'chat',
- members: [{ _id: 'agent-1', username: 'kai', isBot: true }],
- }] })
- : priorGet(url));
- axios.post.mockResolvedValue({ data: { status: 'connected' } });
- renderPage();
-
- const confirm = await screen.findByRole('button', { name: 'Confirm connection' });
- expect(confirm).toBeDisabled();
- const row = (await screen.findByRole('button', { name: 'View Slack' })).closest('article') as HTMLElement;
- fireEvent.click(within(row).getByRole('button', { name: 'Confirm' }));
- expect(axios.post).not.toHaveBeenCalledWith('/api/installables/slack/confirm', expect.anything(), expect.anything());
- fireEvent.change(screen.getByLabelText('Agent for plain messages'), { target: { value: 'kai' } });
- expect(confirm).toBeEnabled();
- fireEvent.click(confirm);
- await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
- '/api/installables/slack/confirm', { leadAgentUsername: 'kai' }, expect.anything(),
- ));
- });
-
- it('does not treat a failed pod read as an empty pod at Slack confirmation', async () => {
- mockGets([{
- _id: 'i-slack-pending', installationId: 'install-slack-u1', type: 'slack', status: 'pending',
- config: { pendingBind: { teamName: 'Commonly HQ', slackUserName: 'sam' } },
- podId: { _id: 'p1', name: 'Rewire Live Demo' },
- }]);
- const priorGet = axios.get.getMockImplementation();
- axios.get.mockImplementation((url) => url === '/api/pods'
- ? Promise.reject(new Error('pods unavailable')) : priorGet(url));
- renderPage();
-
- expect(await screen.findByText('Commonly HQ wants to connect as @sam.')).toBeInTheDocument();
- expect(screen.queryByText('No agent is in this pod yet. Messages will reach the pod; choose a lead later.')).toBeNull();
- expect(screen.getByRole('button', { name: 'Confirm connection' })).toBeDisabled();
- });
-
- it('keeps rejection reachable from the selected Slack aside', async () => {
- mockGets([{
- _id: 'i-slack-pending', installationId: 'install-slack-u1', type: 'slack', status: 'pending',
- config: { pendingBind: { teamName: 'Commonly HQ', slackUserName: 'sam' } },
- podId: { _id: 'p1', name: 'Rewire Live Demo' },
- }]);
- axios.post.mockResolvedValue({ data: { status: 'rejected' } });
- renderPage();
-
- fireEvent.click(await screen.findByRole('button', { name: 'This is not me' }));
- await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
- '/api/installables/slack/reject',
- {},
- expect.anything(),
- ));
- });
-
- it('consumes an opaque Slack callback result without rendering its code', async () => {
- window.history.replaceState({}, '', '/v2/connectors?slack=error&code=invalid_state');
- mockGets([]);
- renderPage();
-
- expect(await screen.findByRole('alert')).toHaveTextContent('Slack authorization didn’t complete — try again.');
- expect(window.location.search).toBe('');
- expect(screen.queryByText('invalid_state')).toBeNull();
- });
-
- it('shows a fixed unrequested-scope notice and consumes hostile callback query values', async () => {
- window.history.replaceState(
- {},
- '',
- '/v2/connectors?hostedMcp=error&code=unrequested_scope&extraScope=%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E&extraScope=spoofed',
- );
- mockGets([]);
- renderPage();
-
- expect(await screen.findByRole('alert')).toHaveTextContent(
- "The provider granted access Commonly didn’t ask for, so the connection wasn’t saved.",
- );
- expect(window.location.search).toBe('');
- expect(screen.queryByText(/img|spoofed/i)).toBeNull();
- expect(screen.queryByText('unrequested_scope')).toBeNull();
- });
-
- it.each([
- ['unknown_entry', 'hostedMcpUnknownEntry'],
- ['invalid_state', 'hostedMcpInvalidState'],
- ['state_expired', 'hostedMcpInvalidState'],
- ['state_consumed', 'hostedMcpInvalidState'],
- ['browser_mismatch', 'hostedMcpInvalidState'],
- ['issuer_mismatch', 'hostedMcpInvalidState'],
- ['consent_denied', 'hostedMcpConsentDenied'],
- ['vendor_error', 'hostedMcpVendorError'],
- ['rate_limited', 'hostedMcpRateLimited'],
- ['issuer_unreachable', 'hostedMcpIssuerUnavailable'],
- ['issuer_metadata_incomplete', 'hostedMcpIssuerUnavailable'],
- ['client_not_configured', 'hostedMcpClientSetup'],
- ['client_registration_required', 'hostedMcpClientSetup'],
- ['client_registration_unsupported', 'hostedMcpClientSetup'],
- ['client_registration_refused', 'hostedMcpClientSetup'],
- ['client_auth_unsupported', 'hostedMcpClientSetup'],
- ['client_auth_refused', 'hostedMcpClientSetup'],
- ['authorization_parameter_conflict', 'hostedMcpClientSetup'],
- ['exchange_refused', 'hostedMcpExchangeRefused'],
- ['exchange_unreachable', 'hostedMcpExchangeUnreachable'],
- ['exchange_incomplete', 'hostedMcpExchangeIncomplete'],
- ['exchange_invalid_scope', 'hostedMcpExchangeIncomplete'],
- ['unrequested_scope', 'hostedMcpUnrequestedScope'],
- ['authentication_required', 'hostedMcpAuthenticationRequired'],
- ])('shows a fixed notice for hosted-MCP code %s and consumes the query', async (code, noticeKey) => {
- window.history.replaceState({}, '', `/v2/connectors?hostedMcp=error&code=${code}&error_description=spoofed`);
- mockGets([]);
- renderPage();
-
- expect(await screen.findByRole('alert')).toHaveTextContent(en.connectors[noticeKey]);
- expect(window.location.search).toBe('');
- expect(screen.queryByText(/spoofed/)).toBeNull();
- });
-
- it('reloads connectors after a successful Slack callback and clears its query', async () => {
- window.history.replaceState({}, '', '/v2/connectors?slack=pending');
- mockGets([]);
- renderPage();
-
- await waitFor(() => expect(
- axios.get.mock.calls.filter(([url]) => url === '/api/integrations/user/all').length,
- ).toBeGreaterThanOrEqual(2));
- expect(window.location.search).toBe('');
- });
-
- it('shows the server-owned provider revoke page after a hosted-MCP reconnect needs manual withdrawal', async () => {
- const revokePage = 'https://linear.app/settings/security';
- window.history.replaceState(
- {},
- '',
- `/v2/connectors?hostedMcp=connected&entryId=linear&revokeAt=${encodeURIComponent(revokePage)}`,
- );
- mockGets([{
- _id: 'i-linear', type: 'hosted-mcp', status: 'connected',
- config: { entryId: 'linear', revokePage },
- }], [hostedLinearCatalogEntry()]);
- renderPage();
-
- const reviewLink = await screen.findByRole('link', { name: "Open Linear's connected apps" });
- expect(reviewLink).toHaveAttribute('href', revokePage);
- expect(reviewLink).toHaveAttribute('target', '_blank');
- expect(reviewLink).toHaveAttribute('rel', 'noopener noreferrer');
- const notice = screen.getByRole('status');
- expect(notice).toHaveTextContent(
- 'Your previous Linear account may still have an MCP grant connected. Switch to it in Linear, then revoke the MCP grant under Linear\'s connected apps.',
- );
- expect(notice.closest('.v2-connector-row')).toHaveTextContent('Linear');
- expect(window.location.search).toBe('');
- });
-
- it('the manual-revoke notice tells the person to switch to the account they connected before', async () => {
- const revokePage = 'https://linear.app/settings/security';
- window.history.replaceState(
- {},
- '',
- `/v2/connectors?hostedMcp=connected&entryId=linear&revokeAt=${encodeURIComponent(revokePage)}`,
- );
- mockGets([{
- _id: 'i-linear', type: 'hosted-mcp', status: 'connected',
- config: { entryId: 'linear', revokePage },
- }], [hostedLinearCatalogEntry()]);
- await i18nReady;
- await act(async () => { await i18n.changeLanguage('zh-CN'); });
- try {
- renderPage();
-
- const reviewLink = await screen.findByRole('link', { name: '打开 Linear 的已连接应用' });
- expect(reviewLink).toHaveAttribute('href', revokePage);
- const notice = screen.getByRole('status');
- expect(notice).toHaveTextContent(
- '你之前的 Linear 账号可能仍连着一个 MCP 授权。请在 Linear 切换到那个账号,再在其已连接应用中撤销该 MCP 授权。',
- );
- expect(notice.closest('.v2-connector-row')).toHaveTextContent('Linear');
- expect(window.location.search).toBe('');
- } finally {
- await act(async () => { await i18n.changeLanguage('en'); });
- }
- });
-
- it('warns that removing Supabase authorization disconnects everyone in the organization', async () => {
- const revokePage = 'https://supabase.com/dashboard/org/_/apps';
- window.history.replaceState(
- {},
- '',
- `/v2/connectors?hostedMcp=connected&entryId=supabase&revokeAt=${encodeURIComponent(revokePage)}`,
- );
- mockGets([{
- _id: 'i-supabase', type: 'hosted-mcp', status: 'connected',
- config: { entryId: 'supabase', revokePage },
- }], [{
- ...hostedLinearCatalogEntry('i-supabase'),
- installableId: 'supabase', label: 'Supabase', entryId: 'supabase',
- }]);
- renderPage();
-
- const reviewLink = await screen.findByRole('link', { name: 'Open Supabase Authorized apps' });
- expect(reviewLink).toHaveAttribute('href', revokePage);
- expect(screen.getByRole('status')).toHaveTextContent(
- 'Removing Commonly under Authorized apps disconnects everyone who connected that organization.',
- );
- expect(screen.getByRole('status')).not.toHaveTextContent('remove Commonly there');
- });
-
- it('warns that revoking the Sentry MCP app ends every client that server holds, and does not name Commonly', async () => {
- const revokePage = 'https://sentry.io/settings/account/api/authorizations/';
- window.history.replaceState(
- {},
- '',
- `/v2/connectors?hostedMcp=connected&entryId=sentry&revokeAt=${encodeURIComponent(revokePage)}`,
- );
- mockGets([{
- _id: 'i-sentry', type: 'hosted-mcp', status: 'connected',
- config: { entryId: 'sentry', revokePage },
- }], [{
- ...hostedLinearCatalogEntry('i-sentry'),
- installableId: 'sentry', label: 'Sentry', entryId: 'sentry',
- }]);
- renderPage();
-
- const reviewLink = await screen.findByRole('link', { name: "Open Sentry's connected apps" });
- expect(reviewLink).toHaveAttribute('href', revokePage);
- expect(screen.getByRole('status')).toHaveTextContent(
- "Revoking it ends every client that server holds for the account, not only Commonly.",
- );
- expect(screen.getByRole('status')).not.toHaveTextContent('remove Commonly there');
- });
-
- it('keeps the generic manual-revoke copy for an entry without its own', async () => {
- const revokePage = 'https://airtable.com/account/security';
- window.history.replaceState(
- {},
- '',
- `/v2/connectors?hostedMcp=connected&entryId=airtable&revokeAt=${encodeURIComponent(revokePage)}`,
- );
- mockGets([{
- _id: 'i-airtable', type: 'hosted-mcp', status: 'connected',
- config: { entryId: 'airtable', revokePage },
- }], [{
- ...hostedLinearCatalogEntry('i-airtable'),
- installableId: 'airtable', label: 'Airtable', entryId: 'airtable',
- }]);
- renderPage();
-
- expect(await screen.findByRole('link', { name: "Open Airtable's connected apps" })).toHaveAttribute('href', revokePage);
- expect(screen.getByRole('status')).toHaveTextContent(
- 'Your previous Airtable account may still have Commonly connected. Switch to it on Airtable, then remove Commonly there.',
- );
- });
-
- it('does not turn a callback query into a provider link unless it matches the row', async () => {
- window.history.replaceState(
- {},
- '',
- '/v2/connectors?hostedMcp=connected&entryId=linear&revokeAt=https%3A%2F%2Fevil.example%2F',
- );
- mockGets([{
- _id: 'i-linear', type: 'hosted-mcp', status: 'connected',
- config: { entryId: 'linear', revokePage: 'https://linear.app/settings/security' },
- }], [hostedLinearCatalogEntry()]);
- renderPage();
-
- const linearDescription = await screen.findByText('Read Linear issues and projects.');
- expect(linearDescription.closest('.v2-connector-row')).toBeInTheDocument();
- await waitFor(() => expect(window.location.search).toBe(''));
- expect(screen.queryByRole('link', { name: "Open Linear's connected apps" })).toBeNull();
- });
-
- it('shows the error reconnect action and retains the separate removal action', async () => {
- mockGets([{
- _id: 'i-slack-error', installationId: 'install-slack-u1', type: 'slack', status: 'error',
- config: { chatTitle: 'Commonly HQ', liveRelay: true },
- podId: { _id: 'p1', name: 'Rewire Live Demo' },
- }]);
- axios.post.mockRejectedValue({ response: { status: 503 } });
- renderPage();
-
- expect((await screen.findAllByText('The connection dropped.')).length).toBeGreaterThan(0);
- fireEvent.click((await screen.findAllByRole('button', { name: 'Authorize in Slack' }))[0]);
- await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
- '/api/installables/slack/authorize-url',
- {},
- expect.objectContaining({ withCredentials: true }),
- ));
- fireEvent.click(screen.getByRole('button', { name: 'Remove' }));
- expect(screen.getByRole('button', { name: /Really remove/ })).toBeInTheDocument();
- });
-
- it('names the reason a connector needs attention when the server sent one', async () => {
- // The row's line is the only place a person sees why relaying stopped. A
- // classified permanent failure writes `errorMessage`; the generic sentence
- // is the fallback for everything else, not a replacement for it.
- const reason = 'Telegram stopped delivering: the bot was blocked or removed from this chat.';
- mockGets([{
- _id: 'i-tg-error', installationId: 'install-tg-u1', type: 'telegram', status: 'error', errorMessage: reason,
- // The flag is what makes the message readable to a person: it is set by
- // connectorDeliveryFailureService, the writer that owns the pair.
- errorMessageUserFacing: true,
- config: { chatTitle: 'Ops chat', liveRelay: true },
- podId: { _id: 'p1', name: 'Rewire Live Demo' },
- }]);
- renderPage();
-
- expect((await screen.findAllByText(reason)).length).toBeGreaterThan(0);
- expect(screen.queryByText('The connection dropped.')).toBeNull();
- });
-
- it('does not print a provider error text in a connector row, only a message written for a person', async () => {
- // `Integration.errorMessage` has an older writer: externalFeedService copies
- // a provider's response, or a raw exception message, into it for the `x` and
- // `instagram` rows. Those rows reach this same error branch, so a value in
- // the field is not permission to render it — `errorMessageUserFacing` is,
- // and it is absent here. The row still shows the generic sentence.
- const raw = 'connect ECONNREFUSED 10.4.4.7:443';
- mockGets([{
- _id: 'i-x-error', installationId: 'install-x-u1', type: 'x', status: 'error', errorMessage: raw,
- config: { chatTitle: 'Feed' },
- podId: { _id: 'p1', name: 'Rewire Live Demo' },
- }]);
- renderPage();
-
- expect((await screen.findAllByText('The connection dropped.')).length).toBeGreaterThan(0);
- expect(screen.queryByText(raw)).toBeNull();
- });
-
- it('derives the installable lifecycle target from the connector row type', () => {
- expect(installableLifecyclePath('slack')).toBe('/api/installables/slack/install');
- });
- // D8 Phase 2: rows keyed by the capability catalog (D1/D2), the aside's
- // gate list (D4), and the not-linked row (#1551).
- describe('catalog rows', () => {
- // TASK-155. The walk found that adding a channel with a live row selected
- // left the panel on that row: the row's detail kept the aside and the form
- // was rendered beside the list instead, so the picker appeared somewhere
- // other than the panel the page uses for the thing you are doing.
- const asidePanel = () => document.querySelector('.v2-connectors__aside') as HTMLElement;
-
- it('TASK-155: adding a channel moves the panel to the picker', async () => {
- mockCatalog([
- entry(),
- entry({ installableId: 'slack', label: 'Slack', installation: { status: 'active', boundPodId: 'p1' }, integration: liveIntegration() }),
- ]);
- renderPage();
- fireEvent.click(await screen.findByRole('button', { name: 'View Slack' }));
- expect(within(asidePanel()).getByText('What the channel sees')).toBeInTheDocument();
-
- fireEvent.click(screen.getByRole('button', { name: 'Add' }));
-
- expect(within(asidePanel()).getByLabelText('Pod to bridge')).toBeInTheDocument();
- expect(within(asidePanel()).queryByText('What the channel sees')).toBeNull();
- // One form, not two: the left-column copy the walk could not see is gone.
- expect(screen.getAllByLabelText('Pod to bridge')).toHaveLength(1);
- });
-
- it('TASK-155: "Connect a channel" moves the panel while a row is selected', async () => {
- mockCatalog([
- entry(),
- entry({ installableId: 'slack', label: 'Slack', installation: { status: 'active', boundPodId: 'p1' }, integration: liveIntegration() }),
- ]);
- renderPage();
- fireEvent.click(await screen.findByRole('button', { name: 'View Slack' }));
-
- fireEvent.click(screen.getByRole('button', { name: 'Connect a channel' }));
-
- expect(within(asidePanel()).getByLabelText('Pod to bridge')).toBeInTheDocument();
- expect(screen.getAllByLabelText('Pod to bridge')).toHaveLength(1);
- });
-
- it('TASK-155: selecting a row while the form is open closes the form', async () => {
- mockCatalog([
- entry(),
- entry({ installableId: 'slack', label: 'Slack', installation: { status: 'active', boundPodId: 'p1' }, integration: liveIntegration() }),
- ]);
- renderPage();
- fireEvent.click(await screen.findByRole('button', { name: 'Add' }));
- expect(within(asidePanel()).getByLabelText('Pod to bridge')).toBeInTheDocument();
-
- fireEvent.click(screen.getByRole('button', { name: 'View Slack' }));
-
- // The form wins the aside while it is open, so the row click has to close
- // it — otherwise this is the walk's symptom pointed the other way
- // (connector-ops 74623 item 1).
- expect(within(asidePanel()).getByText('What the channel sees')).toBeInTheDocument();
- expect(screen.queryByLabelText('Pod to bridge')).toBeNull();
- });
-
- it('TASK-155: the picker takes focus when the form opens', async () => {
- mockCatalog([entry()]);
- renderPage();
-
- fireEvent.click(await screen.findByRole('button', { name: 'Connect a channel' }));
-
- // At 390 the panel is below the fold of the page's scroller; focus is
- // what scrolls the picker into view (connector-ops 74623 item 2).
- expect(screen.getByLabelText('Pod to bridge')).toHaveFocus();
- });
-
- it('TASK-155: the picker defaults to a room, never a personal pod', async () => {
- mockCatalog([entry()], [], [
- { _id: 'p-scout', name: 'Scout (Default)', type: 'agent-room', members: [] },
- { _id: 'p1', name: 'Rewire Live Demo', type: 'chat', members: [] },
- { _id: 'p2', name: 'Ops', type: 'team', members: [] },
- ]);
- renderPage();
-
- fireEvent.click(await screen.findByRole('button', { name: 'Connect a channel' }));
-
- const picker = screen.getByLabelText('Pod to bridge') as HTMLSelectElement;
- expect(picker.value).toBe('p1');
- // A DM stays selectable: it is a legal target, just never the default.
- expect(Array.from(picker.options).map((option) => option.textContent)).toContain('Scout (Default)');
- });
-
- const entry = (over = {}) => ({
- installableId: 'telegram',
- label: 'Telegram',
- // Mirror of the provider manifest's copy, which the server sends as the
- // entry's description. The source pin is backend manifestReadiness.
- description: 'Link your Telegram chat to Commonly — every pod you turn on reaches it.',
- available: true,
- installation: null,
- integration: null,
- ...over,
- });
- const liveIntegration = (over = {}) => ({
- _id: 'i-live',
- installationId: 'install-slack-u1',
- type: 'slack',
- status: 'connected',
- scope: 'user',
- isActive: true,
- createdAt: new Date(Date.now() - 3_600_000).toISOString(),
- updatedAt: new Date().toISOString(),
- config: { chatTitle: 'Rewire crew', liveRelay: true, gates: { p1: { enabled: true, since: '2026-09-01T00:00:00.000Z' } } },
- podId: 'p1',
- ...over,
- });
- const mockCatalog = (installables, list = [], pods = [
- { _id: 'p1', name: 'Rewire Live Demo', type: 'chat', members: [{ _id: 'b1', username: 'vale', isBot: true }] },
- { _id: 'p2', name: 'Ops', type: 'team', members: [] },
- ]) => {
- axios.get.mockImplementation((url) => {
- if (url === '/api/integrations/user/all') return Promise.resolve({ data: list });
- if (url === '/api/installables') return Promise.resolve({ data: { installables } });
- if (url === '/api/pods') return Promise.resolve({ data: pods });
- return Promise.resolve({ data: [] });
- });
- };
-
- it('counts connected channels in the page head, not catalogue options', async () => {
- mockCatalog([
- entry(),
- entry({
- installableId: 'slack', label: 'Slack',
- installation: { status: 'active', boundPodId: 'p1' },
- integration: liveIntegration(),
- }),
- ]);
- const { container } = renderPage();
-
- await waitFor(() => expect(container.querySelector('.v2-connectors__header .v2-page-head__meta')?.textContent).toBe('1 connected'));
- });
-
- it('omits the connected count when the catalogue has no live channels', async () => {
- mockCatalog([entry(), entry({ installableId: 'discord', label: 'Discord' })]);
- const { container } = renderPage();
-
- await screen.findByRole('button', { name: 'View Telegram' });
- expect(container.querySelector('.v2-connectors__header .v2-page-head__meta')).toBeNull();
- });
-
- it('TASK-131: a catalog failure prints the generic sentence, not the raw exception that wrote the row', async () => {
- // The catalogue half reads `InstallableInstallation.errorMessage`, whose
- // projection-failure writer stores `error.message` verbatim. Same rule as
- // the pod-scoped half: a value in the field is not permission to render
- // it, and the flag is absent on this fixture.
- const raw = 'connect ECONNREFUSED 10.4.4.7:443';
- mockCatalog([
- entry({ installation: { status: 'error', errorMessage: raw } }),
- ]);
- renderPage();
-
- expect((await screen.findAllByText('Setup didn’t finish.')).length).toBeGreaterThan(0);
- expect(screen.queryByText(raw)).toBeNull();
- });
-
- it('TASK-131: a catalog message written for a person still renders verbatim', async () => {
- // The reconciler's own reasons are written for the person reading this row,
- // and the flag the builder sets beside them is what keeps them readable once
- // the generic sentence became the fallback.
- const reason = REASON_CHANNEL_GONE;
- mockCatalog([
- entry({ installation: { status: 'error', errorMessage: reason, errorMessageUserFacing: true } }),
- ]);
- renderPage();
-
- expect((await screen.findAllByText(reason)).length).toBeGreaterThan(0);
- expect(screen.queryByText('Setup didn’t finish.')).toBeNull();
- });
-
- // Tools plan (option A, two lists): a tool Installable shares the
- // catalogue response but belongs to the Tools page, never to this one.
- it('a tool Installable in the catalogue never renders as a channel row', async () => {
- mockCatalog([
- {
- installableId: 'github',
- list: 'tools',
- label: 'GitHub',
- description: 'Issues and pull requests.',
- available: true,
- broker: { id: 'commonly-grant-broker' },
- tools: [{ name: 'github.list_issues', requiredWriteMode: 'read', irreversible: false }],
- connections: [],
- installation: null,
- integration: null,
- },
- entry({ list: 'channels' }),
- ]);
- renderPage();
-
- expect(await screen.findByText('Link your Telegram chat to Commonly — every pod you turn on reaches it.')).toBeInTheDocument();
- expect(screen.queryByText('GitHub')).toBeNull();
- expect(screen.queryByText('Issues and pull requests.')).toBeNull();
- expect(screen.queryByRole('button', { name: 'View GitHub' })).toBeNull();
- expect(screen.getAllByRole('button', { name: 'Add' })).toHaveLength(1);
- });
-
- it('keeps tool-only connections out of Channels and refuses an unknown type as a row title', async () => {
- mockCatalog([entry()], [
- {
- // Real hosted-MCP rows have no installationId; filter them by type.
- _id: 'i-hosted-linear', type: 'hosted-mcp', status: 'connected',
- config: { entryId: 'linear' },
- },
- {
- _id: 'i-github-app', installationId: 'github-install', type: 'github-app', status: 'connected',
- },
- {
- _id: 'i-unknown-channel', type: 'custom-messaging-platform', status: 'connected',
- },
- ]);
- renderPage();
-
- await waitFor(() => expect(screen.getByRole('button', { name: 'View Telegram' })).toBeInTheDocument());
- expect(screen.queryByRole('button', { name: /hosted-mcp|github-app/i })).toBeNull();
- expect(screen.queryByRole('button', { name: 'View Custom Messaging Platform' })).toBeNull();
- expect(screen.queryByText('hosted-mcp')).toBeNull();
- expect(screen.queryByText('github-app')).toBeNull();
- expect(screen.queryByText('Hosted tool')).toBeNull();
- expect(screen.queryByText('GitHub App')).toBeNull();
- expect(screen.queryByText('custom-messaging-platform')).toBeNull();
- });
-
- // TASK-024. Discord is a shipping connector (routes/discord.ts: install
- // link, callback, binding, uninstall) that read as "we don't build this"
- // because its manifest declared no readiness(), which is what the catalog
- // filters on. Once it declares one the catalog owns every claim about it:
- // configured -> a connectable row, not configured -> the not-enabled row
- // that already exists for slack. The not-yet row must stop covering it.
- const glyphPath = (type: string): string | null => {
- const { container } = render();
- return container.querySelector('svg path')?.getAttribute('d') || null;
- };
-
- it('TASK-338: dead rows collapse under one inactive fold below the live rows', async () => {
- mockCatalog([
- entry({ installableId: 'slack', label: 'Slack', available: false, unavailableReason: 'not_configured' }),
- entry({
- installableId: 'telegram',
- label: 'Telegram',
- available: true,
- installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] },
- integration: liveIntegration({ _id: 'i-tg', installationId: 'install-telegram-u1', type: 'telegram' }),
- }),
- ]);
- const { container } = renderPage();
- const fold = await screen.findByRole('button', { name: 'inactive · 1' });
- expect(fold).toHaveAttribute('aria-expanded', 'false');
- const rows = container.querySelector('.v2-connectors__rows') as HTMLElement;
- expect(rows.firstElementChild).toHaveTextContent('Telegram');
- expect(screen.queryByText('Not offered on this instance.')).toBeNull();
- fireEvent.click(fold);
- expect(screen.getByText('Not offered on this instance.')).toBeInTheDocument();
- });
-
- it('renders the zh-CN catalog copy for a channel row and the server text for an unmapped one', async () => {
- mockCatalog([
- entry({ list: 'channels' }),
- entry({ installableId: 'acme-chat', label: 'Acme Chat', description: 'Acme chat server copy.' }),
- ]);
- await i18nReady;
- await act(async () => { await i18n.changeLanguage('zh-CN'); });
- try {
- renderPage();
- expect(await screen.findByText('把你的 Telegram 聊天接入 Commonly,你开启的每个 Pod 都能把消息发到这里。')).toBeInTheDocument();
- expect(screen.getByText('Acme chat server copy.')).toBeInTheDocument();
- } finally {
- await act(async () => { await i18n.changeLanguage('en'); });
- }
- });
-
- it('describes Discord only through the catalog, never as a provider we have not built', async () => {
- mockCatalog([
- entry({ installableId: 'discord', label: 'Discord', available: false, unavailableReason: 'not_configured' }),
- ]);
- renderPage();
-
- await openInactiveFold();
- const notEnabled = (await screen.findByText('Not offered on this instance.')).closest('.v2-connector-row');
- expect(notEnabled).toHaveClass('v2-connector-row--not-enabled');
- expect(notEnabled?.textContent).toContain('Discord');
- expect(notEnabled?.querySelector('svg path')?.getAttribute('d')).toBe(glyphPath('discord'));
- expect(screen.queryByText(/Tell us which channel/)).toBeNull();
- });
-
- it('offers a rostered provider with Add, and an unmapped one inherits no onboarding sentence', async () => {
- // Slack's line is in the map; Discord's is not. The point of the map is
- // that Discord renders NO sentence rather than Slack's.
- mockCatalog([
- entry({ installableId: 'discord', label: 'Discord', available: true, offered: true }),
- entry({ installableId: 'slack', label: 'Slack', available: true, offered: true }),
- ]);
- renderPage();
-
- const discord = (await screen.findByText('Discord')).closest('.v2-connector-row') as HTMLElement;
- expect(discord.textContent).not.toContain('one click in your workspace');
- expect(discord.textContent).not.toContain('one message');
- expect(within(discord).getByRole('button', { name: 'Add' })).toBeInTheDocument();
-
- const slack = screen.getByText('Slack').closest('.v2-connector-row') as HTMLElement;
- expect(slack.textContent).toContain('one click in your workspace');
-
- expect(screen.queryByText(/Tell us which channel/)).toBeNull();
- });
-
- it('does not list a provider this instance does not offer (D215)', async () => {
- // Discord on commonly.me: usable keys, no builtin Installable row, never
- // walked. A stranger must not see it, not even in the inactive fold.
- mockCatalog([
- entry(),
- entry({ installableId: 'discord', label: 'Discord', available: true, offered: false }),
- ]);
- renderPage();
-
- await screen.findByText('Telegram');
- // The fold only exists when a dead row does, so its absence is the
- // assertion; a collapsed fold would hide a listed Discord from queryByText.
- expect(screen.queryByRole('button', { name: /^inactive · \d+$/ })).toBeNull();
- expect(screen.queryByText('Discord')).toBeNull();
- });
-
- it('keeps an installed but no-longer-offered provider as not connectable, with no Add anywhere', async () => {
- // The ruled middle state, now reachable only by someone who installed it
- // before the roster row went away: the row states the state and offers no
- // action. Before #1826 the row rendered an Add whose click ended in 404
- // installable_not_found.
- mockCatalog([
- entry({ installableId: 'discord', label: 'Discord', available: true, offered: false, installation: { status: 'active', boundPodId: 'p1' } }),
- ]);
- renderPage();
-
- await openInactiveFold();
- const roster = (await screen.findByText('Unavailable here.')).closest('.v2-connector-row') as HTMLElement;
- expect(roster).toHaveClass('v2-connector-row--not-enabled');
- expect(roster.textContent).toContain('Discord');
- expect(roster.querySelector('svg path')?.getAttribute('d')).toBe(glyphPath('discord'));
- expect(within(roster).queryByRole('button', { name: 'Add' })).toBeNull();
- // The only action left is the Ask link the not-enabled row shares; there is
- // no Add and no connect verb. `row.notEnabled` drives that link, so both
- // no-action catalog states carry it.
- const actions = roster.querySelectorAll('.v2-connector-row__action');
- expect(actions).toHaveLength(1);
- expect(actions[0].textContent).toBe('Ask');
-
- // The eyebrow must not contradict the line under it. `notEnabled` drives
- // this row's class and Ask link, so it also drove the kicker: the keys-set
- // render read "not enabled" directly above "Not connectable yet." — asking
- // an operator for credentials the instance already has (#1826).
- // TASK-338: a dead row carries no state word in its kicker; the fold and
- // the line say it.
- expect(roster.querySelector('.v2-connector-row__kicker')).toBeEmptyDOMElement();
-
- // The Add verb itself must be gone, not merely unused: an offered provider
- // is the only thing that may produce it, and there is none in this catalog.
- expect(screen.queryByRole('button', { name: 'Add' })).toBeNull();
- expect(screen.queryByRole('button', { name: 'Connect a channel' })).toBeNull();
- expect(screen.queryByText(/Tell us which channel/)).toBeNull();
- });
-
- it('keeps a no-longer-offered provider listed when the person has only an integration row', async () => {
- // The third arm of the D215 filter: a channel connected before the roster
- // row went away can leave an integration with no installation. Dropping it
- // from the list would hide a live connection from its owner.
- mockCatalog([
- entry({ installableId: 'discord', label: 'Discord', available: true, offered: false, integration: liveIntegration() }),
- ]);
- renderPage();
-
- await openInactiveFold();
- const row = (await screen.findByText('Unavailable here.')).closest('.v2-connector-row') as HTMLElement;
- expect(row.textContent).toContain('Discord');
- });
-
- it('renders an unavailable provider with a state line and an available one with Add', async () => {
- mockCatalog([
- entry(),
- entry({ installableId: 'slack', label: 'Slack', available: false, unavailableReason: 'not_configured' }),
- ]);
- axios.post.mockResolvedValue({ data: { status: 'installing' } });
- renderPage();
-
- await openInactiveFold();
- expect(await screen.findByText('Not offered on this instance.')).toBeInTheDocument();
- // This commit's own copy expectations are NOT merged: they asserted the
- // one-pod Telegram sentence TASK-154 reversed, and the absence of the
- // "ask your operator" detail — which #1557 landed AFTER the 09-04 ruling
- // this commit cites, and which main's tests assert is present. What
- // survives from it is the CODE (an offered provider is the only thing that
- // may draw Add; each unconnectable provider gets its own state line),
- // which is what these assertions exercise.
- expect(screen.getByText('ask your operator')).toBeInTheDocument();
- expect(screen.getByText('Link your Telegram chat to Commonly — every pod you turn on reaches it.')).toBeInTheDocument();
- expect(screen.getByText(/not connected/)).toBeInTheDocument();
- expect(screen.queryByText('not_configured')).toBeNull();
- const ask = screen.getAllByRole('link', { name: 'Ask' }).find((link) => link.closest('.v2-connector-row')?.classList.contains('v2-connector-row--not-enabled'));
- expect(ask).toBeDefined();
- expect(ask).toHaveAttribute('href', 'https://github.com/Team-Commonly/commonly/issues/new?title=Connector%20request');
- expect(ask).toHaveClass('v2-connector-row__action--secondary');
- expect(ask.closest('.v2-connector-row')).toHaveClass('v2-connector-row--not-enabled');
- expect(ask.closest('.v2-connector-row')?.querySelector('.v2-connector-row__detail')).toHaveTextContent('ask your operator');
- const choosePod = screen.getAllByRole('button', { name: 'Add' });
- expect(choosePod).toHaveLength(1);
- fireEvent.click(choosePod[0]);
- expect(screen.getByRole('button', { name: 'Telegram' })).toHaveClass('v2-connectors__provider--selected');
- expect(screen.queryByRole('button', { name: 'Slack' })).toBeNull();
- fireEvent.click(document.querySelector('.v2-connectors__create'));
- await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
- '/api/installables/telegram/install',
- { podId: 'p1' },
- expect.anything(),
- ));
- });
-
- // TASK-140: a not-enabled row has no connection at all, so its pod slot
- // said `no pod` — false about a row whose blocker is the instance itself,
- // and it reads as a missing pod rather than a disabled provider.
- it('TASK-140: a not-enabled row names its blocker in the kicker, never "no pod"', async () => {
- mockCatalog([
- entry({ installableId: 'slack', label: 'Slack', available: false, unavailableReason: 'not_configured' }),
- entry({
- installableId: 'telegram',
- label: 'Telegram',
- available: true,
- installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] },
- integration: liveIntegration({ _id: 'i-tg', installationId: 'install-telegram-u1', type: 'telegram' }),
- }),
- ]);
- renderPage();
-
- await openInactiveFold();
- const ask = (await screen.findAllByRole('link', { name: 'Ask' }))
- .find((link) => link.closest('.v2-connector-row')?.classList.contains('v2-connector-row--not-enabled'));
- expect(ask).toBeDefined();
- // TASK-338: the blocker is the line; the kicker carries no state word.
- expect(ask?.closest('.v2-connector-row')?.querySelector('.v2-connector-row__kicker')).toBeEmptyDOMElement();
- expect(ask?.closest('.v2-connector-row')).toHaveTextContent('Not offered on this instance.');
- expect(screen.queryByText('no pod')).toBeNull();
-
- // Positive control: the row that DOES carry a connection still names its
- // pod, so the assertion above cannot pass by every kicker reading alike.
- const linked = (await screen.findAllByRole('button', { name: 'View Telegram' }))[0];
- expect(linked.querySelector('.v2-connector-row__kicker')?.textContent).toMatch(/^Rewire Live Demo · /);
- });
-
- it('labels the row step separately from the final connect action', async () => {
- mockCatalog([entry()]);
- renderPage();
-
- const choosePod = await screen.findByRole('button', { name: 'Add' });
- expect(screen.queryByRole('button', { name: 'Connect' })).toBeNull();
-
- fireEvent.click(choosePod);
- expect(screen.getByLabelText('Pod to bridge')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument();
- });
-
- it('gives a zero-pod owner a path to create a pod before connecting', async () => {
- axios.get.mockImplementation((url) => {
- if (url === '/api/integrations/user/all') return Promise.resolve({ data: [] });
- if (url === '/api/installables') return Promise.resolve({ data: { installables: [entry()] } });
- if (url === '/api/pods') return Promise.resolve({ data: [] });
- return Promise.resolve({ data: [] });
- });
- render(
-
-
-
- } />
- pods list} />
-
-
- ,
- );
-
- fireEvent.click(await screen.findByRole('button', { name: 'Connect a channel' }));
- expect(screen.queryByLabelText('Pod to bridge')).toBeNull();
- expect(screen.queryByRole('group', { name: 'Channel provider' })).toBeNull();
- expect(screen.getByText('No pod yet. Create one, then come back to connect a channel.')).toBeInTheDocument();
- expect(screen.queryByText('Choose a channel and the pod it should join.')).toBeNull();
- fireEvent.click(screen.getByRole('button', { name: 'Create a pod' }));
- expect(await screen.findByText('pods list')).toBeInTheDocument();
- });
-
- it('keeps the existing picker visible while pod membership is still loading', async () => {
- axios.get.mockImplementation((url) => {
- if (url === '/api/integrations/user/all') return Promise.resolve({ data: [] });
- if (url === '/api/installables') return Promise.resolve({ data: { installables: [entry()] } });
- if (url === '/api/pods') return new Promise(() => {});
- return Promise.resolve({ data: [] });
- });
- renderPage();
-
- fireEvent.click(await screen.findByRole('button', { name: 'Connect a channel' }));
- expect(screen.getByRole('group', { name: 'Channel provider' })).toBeInTheDocument();
- expect(screen.getByLabelText('Pod to bridge')).toBeInTheDocument();
- expect(screen.queryByText('No pod yet. Create one, then come back to connect a channel.')).toBeNull();
- expect(screen.queryByRole('button', { name: 'Create a pod' })).toBeNull();
- });
-
- it('keeps the existing picker visible when pod membership cannot be read', async () => {
- axios.get.mockImplementation((url) => {
- if (url === '/api/integrations/user/all') return Promise.resolve({ data: [] });
- if (url === '/api/installables') return Promise.resolve({ data: { installables: [entry()] } });
- if (url === '/api/pods') return Promise.reject(new Error('pods unavailable'));
- return Promise.resolve({ data: [] });
- });
- renderPage();
-
- fireEvent.click(await screen.findByRole('button', { name: 'Connect a channel' }));
- expect(screen.getByRole('group', { name: 'Channel provider' })).toBeInTheDocument();
- expect(screen.getByLabelText('Pod to bridge')).toBeInTheDocument();
- expect(screen.queryByText('No pod yet. Create one, then come back to connect a channel.')).toBeNull();
- expect(screen.queryByRole('button', { name: 'Create a pod' })).toBeNull();
- });
-
- it('shows Setting up… without a control while the claim is fresh, and Cancel once it is stale', async () => {
- mockCatalog([entry({ installation: { status: 'installing', claimedAt: new Date().toISOString(), components: [] } })]);
- const fresh = renderPage();
- expect(await screen.findByText('Setting up…')).toBeInTheDocument();
- expect(screen.getByText('waiting for the server')).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull();
- fresh.unmount();
-
- mockCatalog([entry({
- installation: { status: 'installing', claimedAt: new Date(Date.now() - INSTALL_LOCK_TTL_MS - 1000).toISOString(), components: [] },
- })]);
- axios.delete.mockResolvedValue({ data: { status: 'uninstalling' } });
- renderPage();
- expect(await screen.findByText('Setup is taking longer than it should.')).toBeInTheDocument();
- fireEvent.click(screen.getAllByRole('button', { name: 'Cancel' })[0]);
- await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/installables/telegram/install', expect.anything()));
- });
-
- it('offers Retry on an error parent, posting the bound pod, and Remove in the aside', async () => {
- mockCatalog([entry({
- // The reconciler's own reason, and since TASK-131 it travels with the flag
- // that says so: the row's line renders a message only when a writer
- // declared it was written for a person.
- installation: {
- status: 'error',
- errorMessage: REASON_CHANNEL_GONE,
- errorMessageUserFacing: true,
- boundPodId: 'p2',
- updatedAt: new Date().toISOString(),
- components: [],
- },
- })]);
- axios.post.mockResolvedValue({ data: { status: 'installing' } });
- axios.delete.mockResolvedValue({ data: { status: 'uninstalled' } });
- renderPage();
-
- expect((await screen.findAllByText(REASON_CHANNEL_GONE)).length).toBeGreaterThan(0);
- expect(screen.getByText('retry, or remove it')).toBeInTheDocument();
- fireEvent.click(screen.getAllByRole('button', { name: 'Retry' })[0]);
- await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
- '/api/installables/telegram/install',
- { podId: 'p2' },
- expect.anything(),
- ));
- fireEvent.click(screen.getByRole('button', { name: 'Remove' }));
- fireEvent.click(screen.getByRole('button', { name: /Really remove/ }));
- await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/installables/telegram/install', expect.anything()));
- });
-
- it('offers Retry remove once an uninstalling claim is stale', async () => {
- mockCatalog([entry({
- installation: { status: 'uninstalling', claimedAt: new Date(Date.now() - INSTALL_LOCK_TTL_MS - 1000).toISOString(), components: [] },
- })]);
- axios.delete.mockResolvedValue({ data: { status: 'uninstalling' } });
- renderPage();
-
- expect(await screen.findByText('Removal is taking longer than it should.')).toBeInTheDocument();
- fireEvent.click(screen.getAllByRole('button', { name: 'Retry remove' })[0]);
- await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/installables/telegram/install', expect.anything()));
- });
-
- it('shows a paused parent with its reason and no owner control', async () => {
- mockCatalog([entry({
- installableId: 'slack',
- label: 'Slack',
- installation: { status: 'paused', updatedAt: new Date().toISOString(), components: [] },
- integration: liveIntegration({ config: { chatTitle: 'Rewire crew', liveRelay: true, adminPause: { reason: 'Spam report under review.', at: new Date(Date.now() - 120_000).toISOString() } } }),
- })]);
- renderPage();
-
- expect((await screen.findAllByText('Paused by an administrator. Spam report under review.')).length).toBeGreaterThan(0);
- expect(screen.getByText(/paused 2m$/)).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Remove' })).toBeNull();
- expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull();
- expect(screen.queryByRole('checkbox', { name: 'Relay' })).toBeNull();
- });
-
- it('treats a missing projection under an active parent as a Retry row', async () => {
- mockCatalog([entry({ installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] }, integration: null })]);
- axios.post.mockResolvedValue({ data: { status: 'installing' } });
- renderPage();
-
- expect((await screen.findAllByText('The channel record is gone.')).length).toBeGreaterThan(0);
- expect(screen.getByText('retry rebuilds it')).toBeInTheDocument();
- expect(screen.getAllByRole('button', { name: 'Retry' }).length).toBeGreaterThan(0);
- });
-
- it('renders the not-linked row and expands the pod list with Make active', async () => {
- mockCatalog([entry({
- installableId: 'slack',
- label: 'Slack',
- installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] },
- integration: liveIntegration({ podId: null, config: { chatTitle: 'Rewire crew', liveRelay: true, gates: {} } }),
- })]);
- axios.patch.mockResolvedValue({ data: {} });
- renderPage();
-
- expect(await screen.findByText('Rewire crew · not linked to a pod')).toBeInTheDocument();
- expect(screen.getByText('your messages have nowhere to go')).toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: 'Pick a pod' }));
- expect(screen.getByText('Pick where your messages go')).toBeInTheDocument();
- expect(screen.queryByText('active')).toBeNull();
- const makeActive = screen.getAllByRole('button', { name: 'Make active' });
- expect(makeActive).toHaveLength(2);
- fireEvent.click(makeActive[1]);
- await waitFor(() => expect(axios.patch).toHaveBeenCalledWith(
- '/api/integrations/i-live',
- { podId: 'p2' },
- expect.anything(),
- ));
- });
-
- // TASK-164: the gate line was the last raw English relative time on this page —
- // `since ${relativeTime(...)}` built its own sentence and no key could reach it.
- it('the gate line reads in zh-CN, not English (TASK-164)', async () => {
- mockCatalog([entry({
- installableId: 'slack',
- label: 'Slack',
- installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] },
- integration: liveIntegration(),
- })]);
- await i18nReady;
- await act(async () => { await i18n.changeLanguage('zh-CN'); });
- try {
- renderPage();
- const line = await screen.findByText(/前起$/);
- expect(line.textContent).toMatch(/^(刚刚|\d+(分钟|小时|天)前)起$/);
- expect(screen.queryByText(/^since /)).toBeNull();
- } finally {
- await act(async () => { await i18n.changeLanguage('en'); });
- }
- });
-
- it('lists every pod with its gate, marks the active pod, and writes the whole gates map on a switch', async () => {
- mockCatalog([entry({
- installableId: 'slack',
- label: 'Slack',
- installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] },
- integration: liveIntegration(),
- })]);
- axios.patch.mockResolvedValue({ data: {} });
- renderPage();
-
- expect(await screen.findByText('Rewire crew · linked to Rewire Live Demo')).toBeInTheDocument();
- expect(screen.getByText('Pods that reach this channel')).toBeInTheDocument();
- // The anchors, measured rather than assumed: the aside that
- // holds that section is reachable by its ACCESSIBLE NAME and never as
- // text. A copy that names it sends a sighted user hunting, which is the
- // defect TASK-173 was opened for — so the distinction is witnessed here,
- // where the aside renders, and the copy is checked against it above.
- expect(screen.getByLabelText('Channel details')).toBeInTheDocument();
- expect(screen.queryAllByText(/^Channel details$/)).toHaveLength(0);
- expect(screen.getByText('active')).toBeInTheDocument();
- expect(screen.getByText('off')).toBeInTheDocument();
- expect(screen.getByText(/^since /)).toBeInTheDocument();
- fireEvent.click(screen.getByRole('switch', { name: 'Relay Ops' }));
- await waitFor(() => expect(axios.patch).toHaveBeenCalledWith(
- '/api/integrations/i-live',
- { config: { gates: {
- p1: { enabled: true, since: '2026-09-01T00:00:00.000Z' },
- p2: { enabled: true, since: expect.any(String) },
- } } },
- expect.anything(),
- ));
- });
-
- it('drops a gate for a pod the owner has left before writing the map', async () => {
- mockCatalog([entry({
- installableId: 'slack',
- label: 'Slack',
- installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] },
- integration: liveIntegration({
- config: { chatTitle: 'Rewire crew', liveRelay: true, gates: {
- p1: { enabled: true, since: '2026-09-01T00:00:00.000Z' },
- pLeft: { enabled: true, since: '2026-08-01T00:00:00.000Z' },
- } },
- }),
- })]);
- axios.patch.mockResolvedValue({ data: {} });
- renderPage();
-
- await screen.findByText('Pods that reach this channel');
- fireEvent.click(screen.getByRole('switch', { name: 'Relay Ops' }));
- await waitFor(() => expect(axios.patch).toHaveBeenCalledWith(
- '/api/integrations/i-live',
- { config: { gates: {
- p1: { enabled: true, since: '2026-09-01T00:00:00.000Z' },
- p2: { enabled: true, since: expect.any(String) },
- } } },
- expect.anything(),
- ));
- });
-
- it('keeps mode and lead overrides behind the pod name and makes another pod active', async () => {
- mockCatalog([entry({
- installableId: 'slack',
- label: 'Slack',
- installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] },
- integration: liveIntegration(),
- })]);
- axios.patch.mockResolvedValue({ data: {} });
- renderPage();
-
- await screen.findByText('Pods that reach this channel');
- expect(screen.queryByRole('button', { name: 'Make active' })).toBeNull();
- fireEvent.click(screen.getByRole('button', { name: /Ops/ }));
- const more = within(document.querySelector('.v2-connector-gate__more'));
- fireEvent.click(more.getByRole('button', { name: 'Mirror' }));
- await waitFor(() => expect(axios.patch).toHaveBeenCalledWith(
- '/api/integrations/i-live',
- { config: { gates: {
- p1: { enabled: true, since: '2026-09-01T00:00:00.000Z' },
- p2: { enabled: false, mode: 'mirror', since: expect.any(String) },
- } } },
- expect.anything(),
- ));
- const makeActive = screen.getByRole('button', { name: 'Make active' });
- await waitFor(() => expect(makeActive).not.toBeDisabled());
- fireEvent.click(makeActive);
- await waitFor(() => expect(axios.patch).toHaveBeenCalledWith(
- '/api/integrations/i-live',
- { podId: 'p2' },
- expect.anything(),
- ));
- });
-
- it('keys an installable row by the catalog, not by its legacy listing', async () => {
- mockCatalog(
- [entry({
- installableId: 'slack',
- label: 'Slack',
- installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] },
- integration: liveIntegration(),
- })],
- [{ ...connectors[1] }],
- );
- renderPage();
-
- expect(await screen.findAllByRole('button', { name: 'View Slack' })).toHaveLength(1);
- });
- });
-});
diff --git a/frontend/src/v2/__tests__/V2InactiveFold.test.tsx b/frontend/src/v2/__tests__/V2InactiveFold.test.tsx
deleted file mode 100644
index 76bc621aff..0000000000
--- a/frontend/src/v2/__tests__/V2InactiveFold.test.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import React from 'react';
-import { act, fireEvent, render, screen } from '@testing-library/react';
-import i18n, { i18nReady } from '../../i18n';
-import V2InactiveFold from '../components/V2InactiveFold';
-
-beforeAll(async () => { await i18nReady; });
-afterEach(async () => { await act(async () => { await i18n.changeLanguage('en'); }); });
-
-test('inactive rows are hidden until the shared count fold opens', async () => {
- await act(async () => { await i18n.changeLanguage('en'); });
- render(Revoked grantExpired grant);
-
- const toggle = screen.getByRole('button', { name: 'inactive · 2' });
- expect(toggle).toHaveAttribute('aria-expanded', 'false');
- expect(screen.queryByText('Revoked grant')).not.toBeInTheDocument();
-
- fireEvent.click(toggle);
- expect(toggle).toHaveAttribute('aria-expanded', 'true');
- expect(screen.getByText('Revoked grant')).toBeInTheDocument();
- expect(screen.getByText('Expired grant')).toBeInTheDocument();
-
- fireEvent.click(toggle);
- expect(screen.queryByText('Revoked grant')).not.toBeInTheDocument();
-});
-
-test('the count label follows the Chinese locale', async () => {
- await act(async () => { await i18n.changeLanguage('zh-CN'); });
- render(Inactive item);
- expect(screen.getByRole('button', { name: '非活跃 · 9' })).toBeInTheDocument();
-});
diff --git a/frontend/src/v2/__tests__/v2-copy-budget.test.tsx b/frontend/src/v2/__tests__/v2-copy-budget.test.tsx
index 33d0f12f25..12beb26e6f 100644
--- a/frontend/src/v2/__tests__/v2-copy-budget.test.tsx
+++ b/frontend/src/v2/__tests__/v2-copy-budget.test.tsx
@@ -14,7 +14,13 @@ describe('page head copy budget', () => {
expect(app).not.toMatch(/description=\{description\}|description: string/);
expect(source('components/V2AgentBYO.tsx')).not.toMatch(/eyebrow=|description=/);
expect(source('components/V2ArtifactsPage.tsx')).not.toContain('v2-artifacts__meta');
- for (const file of ['components/V2ActivityPage.tsx', 'components/V2ConnectorsPage.tsx', 'components/V2Thread.tsx']) {
+ for (const file of [
+ 'components/V2ActivityPage.tsx',
+ 'connectors/ConnectorsPage.tsx',
+ 'connectors/ChannelPanel.tsx',
+ 'connectors/ToolPanels.tsx',
+ 'components/V2Thread.tsx',
+ ]) {
expect(source(file)).not.toMatch(/v2-activity__subtitle|v2-connectors__header p|
\{pod\.description\}<\/p>/);
}
});
diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
index 25ec5d2fb6..0362bdb316 100644
--- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
+++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
@@ -2345,10 +2345,10 @@ describe('v2 layout invariants (CSS rule presence)', () => {
it('nothing in v2.css hides the connector row\'s reason, at 390 or anywhere', () => {
// Show is the default. This rule used to hide __detail for every state except
// two enumerated ones, so the not-yet row that IS available
- // (V2ConnectorTools.tsx:562) lost "install the GitHub App first" /
+ // (ToolPanels) lost "install the GitHub App first" /
// "read, or read and write" and a stranger saw a "not granted" kicker with
// nothing under it. The two reason strings are already render-asserted in
- // V2ConnectorTools.test.tsx:280/:317 — this is the half jsdom cannot see.
+ // ConnectorsPage.tools.test.tsx — this is the half jsdom cannot see.
// If one row ever needs the detail hidden on phones, add that selector to the
// list below on purpose rather than re-introducing an exception chain.
const NAMED_DETAIL_HIDE_CASES: string[] = [];
@@ -2365,7 +2365,6 @@ describe('v2 layout invariants (CSS rule presence)', () => {
// below vacuous: the sheet was read, it carries the class, and it carries the
// not-yet row's details wrapper.
expect(v2).toContain('.v2-connector-row__detail');
- expect(v2).toContain('.v2-connector-row--not-yet .v2-connector-row__details');
// Every hiding mechanism a declaration can use, not just the one that
// caused this. `display:none` was the defect; `visibility:hidden` is the
// same loss through a different property, and neither hides the rule from a
@@ -2382,122 +2381,39 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(hidingRules).toEqual([]);
});
- it('Signal connectors pin the row grid, aside, colour grammar, and phone collapse', () => {
- // Direction A (2026-09-19): three tracks — the age moved into the kicker.
- expect(ruleBody(v2, '.v2-connector-row')).toContain('grid-template-columns: 140px minmax(150px, 1fr) 120px');
- expect(ruleBody(v2, '.v2-root button.v2-connector-row__selection')).toContain('grid-template-columns: 140px minmax(150px, 1fr)');
- // TASK-162 (2): the name track ends exactly where the details track begins, so
- // a name that fills it touches the sentence. The gutter has to be on BOTH row
- // grids — the article's and the selection button's inner one.
- expect(ruleBody(v2, '.v2-connector-row')).toContain('column-gap: 12px');
- expect(ruleBody(v2, '.v2-root button.v2-connector-row__selection')).toContain('column-gap: 12px');
+ it('Signal connectors pin the drawer row, aside, colour grammar, and phone collapse', () => {
// TASK-162 (1): the ink hover must NOT claim a secondary action, or a hovered
// gear is ink behind an ink glyph. Asserted on the SELECTOR rather than on a
// computed style: jsdom resolves no :hover rules at all, so the cascade this
- // bug lived in cannot be measured in-suite — only the shape that fixes it can.
+ // bug lived in cannot be measured in-suite, only the shape that fixes it can.
const inkHoverChunks = [...v2.matchAll(/([^{}]+)\{([^{}]*)\}/g)]
.filter(([, , body]) => body.includes('var(--v2-ink-hover)'))
.flatMap(([, selector]) => selector.split(','))
.map((selector) => selector.trim())
.filter((selector) => selector.includes('.v2-connector-row__action:hover'));
- // Split per SELECTOR, not per rule: both variants share one declaration block,
- // so a rule-level check is satisfied by whichever selector kept the exclusion
- // and reads green with the button — the gear that actually broke — still
- // claiming secondary. Two separate assertions, one per line.
+ // Split per SELECTOR, not per rule: both variants share one declaration block.
expect(inkHoverChunks).toHaveLength(2);
expect(inkHoverChunks.find((selector) => selector.includes('button.v2-connector-row__action:hover'))).toContain(':not(:disabled)');
for (const selector of inkHoverChunks) {
expect(selector).toContain(':not(.v2-connector-row__action--secondary)');
}
- // TASK-162 (3): line 3 is the consequence as TEXT beside the mark (the mode
- // word moved onto the mark itself) and the not-yet names stack above 760.
- expect(ruleBody(v2, '.v2-connector-row__names')).toContain('flex-direction: column');
- // …and the mark SHARES the Tools glyph's rule rather than carrying a second
- // rule of its own. The two rows had drifted 16 vs 14, gap 8 vs 6, secondary vs
- // tertiary; a rule of its own can always drift again, so the guard counts the
- // rules that mention the mark: the shared one and the ≤760 hide, nothing else.
- const stripComments = (css: string): string => css.replace(/\/\*[\s\S]*?\*\//g, '');
- const markRuleChunks = [...stripComments(v2).matchAll(/([^{}]+)\{([^{}]*)\}/g)]
- .filter(([, selector]) => selector.includes('.v2-connector-row__mark'));
- expect(markRuleChunks).toHaveLength(2);
- const sharedMarkRule = markRuleChunks.find(([, selector]) => selector.includes('.v2-tools__mode'));
- expect(sharedMarkRule?.[2]).toContain('display: inline-flex');
- expect(sharedMarkRule?.[2]).toContain('vertical-align: -2px');
- expect(sharedMarkRule?.[2]).toContain('color: var(--v2-text-tertiary)');
- // Rule 3: the kicker is mono 11; rule 1 (as revised by the TASK-162 gate): the mode word is hidden until 760 and the mark's own 16px rule is GONE — the shared rule carries no dimensions, so the size is the glyph's `size` prop alone, pinned same-size against the Tools glyph in V2ConnectorsPage.test.tsx (TASK-177); rule 2: the gear is 32 (44 on the phone).
+ // Rule 3: the kicker is mono 11; the mode word is hidden until 760; the gear is 32.
expect(ruleBody(v2, '.v2-connector-row__kicker')).toContain('var(--v2-font-mono)');
expect(ruleBody(v2, '.v2-connector-row__kicker')).toContain('font-size: 11px');
expect(ruleBody(v2, '.v2-connector-row__kicker-mode')).toContain('display: none');
- expect(v2).not.toContain('width: 16px; height: 16px; color: var(--v2-text-secondary)');
expect(ruleBody(v2, '.v2-root button.v2-connector-row__action--icon')).toContain('width: 32px');
- const phone760 = v2.slice(v2.indexOf('@media (max-width: 760px) {\n .v2-connectors {'));
- expect(phone760).toContain('.v2-connector-row__kicker-mode { display: inline; }');
- expect(phone760).toContain('.v2-connector-row__names { display: block; }');
- expect(phone760).toContain('.v2-connector-row__name-sep { display: inline; }');
- expect(phone760).toContain('.v2-root button.v2-connector-row__action--icon { width: 44px; min-height: 44px;');
- // TASK-140: the aside's Revoke ✕ carries `--secondary` as well as `--icon`,
- // and `--secondary` sets `min-height: 36px`. Because min-height beats height,
- // a composed rule that pins `height: 32px` alone still measures 32×36 — the
- // first cut of this fix did exactly that and the gate caught it (#1799, 1440).
- // The assertion below is on `min-height` for that reason: this guard would
- // otherwise have been green while the box was still 36 tall.
+ // TASK-140: the aside's Revoke carries `--secondary` as well as `--icon`, and
+ // `--secondary` sets `min-height: 36px`. min-height beats height, so the
+ // composed rule must pin min-height too or the box measures 32x36.
const asideIcon = ruleBody(v2, '.v2-root button.v2-connector-aside__secondary.v2-connector-aside__icon');
expect(asideIcon).toContain('width: 32px');
expect(asideIcon).toContain('height: 32px');
expect(asideIcon).toContain('min-height: 32px');
- expect(phone760).toContain('.v2-root button.v2-connector-aside__secondary.v2-connector-aside__icon { width: 44px; height: 44px; min-height: 44px; }');
- expect(phone760).toContain('.v2-connector-row { min-height: 72px; }');
- // The gear shares row 1 with the name (the selection button is pinned to row 1 too), and it is declared AFTER the older act placement so the cascade keeps it.
- expect(phone760).toContain('.v2-root button.v2-connector-row__selection { grid-row: 1; }');
- expect(phone760.indexOf('.v2-root button.v2-connector-row__action--icon { width: 44px')).toBeGreaterThan(phone760.indexOf('.v2-root a.v2-connector-row__action { grid-column: 2; grid-row: 1; z-index: 1; }'));
- expect(ruleBody(v2, '.v2-connectors__content')).toContain('grid-template-columns: minmax(min-content, 1fr) minmax(240px, 400px)');
+ expect(v2).toContain('.v2-root button.v2-connector-aside__secondary.v2-connector-aside__icon { width: 44px; height: 44px; min-height: 44px; }');
expect(ruleBody(v2, '.v2-connector-row__details')).toContain('padding-right: 8px');
- const connectors = ruleBody(v2, '.v2-connectors');
- // TASK-156, inverting #1547: a list card is as tall as its rows. The page
- // scrolls; no card holds empty space. ux-lead measured 564px of card around
- // 262px of rows at 1200, and a Tools card of 718px around one 86px row with
- // the GitHub grant open.
- expect(connectors).not.toContain('min-height');
- expect(connectors).not.toContain('max-width');
- const connectorsContent = ruleBody(v2, '.v2-connectors__content');
- expect(connectorsContent).toContain('align-items: start');
- expect(connectorsContent).not.toContain('flex: 1');
- expect(ruleBody(v2, '.v2-connectors__rows')).not.toContain('flex: 1');
- // sprint-review's #1869 note: ruleBody reads the FIRST line-start match, so a
- // second `.v2-connectors { min-height: … }` written later in the sheet passes
- // every assertion above while the browser paints the stretch again — equal
- // specificity, later rule wins. Scan EVERY declaration block by selector, so
- // position cannot hide one. Matching is on the last compound's class tokens
- // (so `.v2-root .v2-connectors` counts, and `.v2-connectors__content` does
- // not); a rule scoped some other way is still not covered.
- const layoutBlocks = (className: string) =>
- Array.from(v2.matchAll(/([^{}]+)\{([^{}]*)\}/g))
- .filter(([, sels]) =>
- sels
- .split(',')
- .some((sel) => (sel.trim().split(/\s+/).pop() ?? '').split('.').includes(className)),
- )
- .map(([, , body]) => body);
- // Non-vacuous: each selector's scan must still find the rule it is about.
- for (const cls of ['v2-connectors', 'v2-connectors__content', 'v2-connectors__rows']) {
- expect(layoutBlocks(cls).length).toBeGreaterThan(0);
- }
- // A list card is as tall as its rows wherever the rule is written.
- expect(layoutBlocks('v2-connectors').filter((b) => b.includes('min-height'))).toEqual([]);
- expect(layoutBlocks('v2-connectors__content').filter((b) => b.includes('flex: 1'))).toEqual([]);
- expect(layoutBlocks('v2-connectors__rows').filter((b) => b.includes('flex: 1'))).toEqual([]);
- // TASK-677 tile grid: the channel rows tile at 150px minimum, the fold spans
- // the grid, and the icon act is centred. jsdom cannot see the cascade, so the
- // 760px overrides are guarded by presence here.
- // ruleBody takes the first line-start match, which is the base rule, so scan
- // every block for the tile declarations instead.
- expect(layoutBlocks('v2-connectors__rows').some((b) => b.includes('repeat(auto-fill, minmax(150px, 1fr))'))).toBe(true);
- expect(layoutBlocks('v2-connectors__inactive-fold').some((b) => b.includes('grid-column: 1 / -1'))).toBe(true);
- expect(layoutBlocks('v2-connector-row__action--icon').some((b) => b.includes('justify-self: center'))).toBe(true);
expect(ruleBody(v2, '.v2-page-head__meta')).toContain('var(--v2-font-mono)');
expect(ruleBody(v2, '.v2-connector-row__glyph')).toContain('width: 20px');
expect(ruleBody(v2, '.v2-connector-row__glyph')).toContain('color: inherit');
- expect(ruleBody(v2, '.v2-connector-row--not-yet .v2-connector-row__glyph')).toContain('color: var(--v2-text-placeholder)');
expect(ruleBody(v2, '.v2-connector-row__detail')).not.toContain('text-overflow');
expect(ruleBody(v2, '.v2-connector-row__detail')).not.toContain('nowrap');
// Line 1 and the gate's pod name wrap too; the 390 clips were the defect (64144).
@@ -2510,40 +2426,82 @@ describe('v2 layout invariants (CSS rule presence)', () => {
// The state dot is a mark: ink filled / hollow / grey, never cobalt, never a status colour.
expect(ruleBody(v2, '.v2-connector-row__dot--live')).toContain('var(--v2-ink)');
expect(ruleBody(v2, '.v2-connector-row__dot--live')).not.toContain('var(--v2-accent)');
- expect(ruleBody(v2, '.v2-connector-row__dot--pending')).toContain('transparent');
- expect(ruleBody(v2, '.v2-connector-row__dot--idle')).toContain('var(--v2-border-soft)');
expect(ruleBody(v2, '.v2-connector-gate__mark')).toContain('width: 4px');
expect(ruleBody(v2, '.v2-connector-gate__mark')).toContain('var(--v2-ink)');
expect(ruleBody(v2, '.v2-connector-gate__switch')).toContain('accent-color: var(--v2-ink)');
expect(ruleBody(v2, '.v2-connector-gate__switch')).not.toContain('border-radius');
expect(ruleBody(v2, '.v2-connector-gate__tag')).toContain('var(--v2-font-mono)');
const connectorCss = v2.slice(v2.indexOf('/* ── Connectors'), v2.indexOf('/* Activity queue'));
+ expect(connectorCss.length).toBeGreaterThan(1000);
expect(connectorCss).not.toContain('var(--v2-success)');
expect(connectorCss).not.toContain('var(--v2-warning)');
expect(connectorCss).not.toContain('var(--v2-danger)');
- // Tools (plan §6): the list reuses the connector row grid, and a trail line is muted mono —
- // an outcome is a word, never a coloured one.
+ // Tools: a trail line is muted mono, an outcome is a word, never a coloured one.
expect(ruleBody(v2, '.v2-tools__trail-line')).toContain('var(--v2-text-muted)');
expect(ruleBody(v2, '.v2-tools__trail-line')).toContain('var(--v2-font-mono)');
expect(ruleBody(v2, '.v2-tools__count strong')).toContain('font-size: 22px');
expect(ruleBody(v2, '.v2-tools__count span')).toContain('font-size: 11px');
- expect(connectorCss).toMatch(/@media \(max-width: 1010px\) \{[\s\S]*?\.v2-connectors__content \{ grid-template-columns: minmax\(0, 1fr\); gap: 24px;/);
- expect(connectorCss).toMatch(/@media \(max-width: 760px\) \{[\s\S]*?\.v2-connector-row \{ grid-template-columns: minmax\(0, 1fr\) auto;/);
- expect(connectorCss).toMatch(/@media \(max-width: 760px\) \{[\s\S]*?\.v2-connectors__content \{ grid-template-columns: minmax\(0, 1fr\);/);
- expect(connectorCss).toMatch(/@media \(max-width: 760px\) \{[\s\S]*?\.v2-connectors \{ gap: 24px; margin: -12px -18px 0;/);
- // The Tools list opt-out is dead once nothing stretches. Matched as a regex on
- // the Connectors section, not through ruleBody: with the rule gone ruleBody
- // returns '' and would have passed this assertion without reading anything.
expect(connectorCss).not.toMatch(/\.v2-tools \{[^}]*flex: none/);
- expect(connectorCss).toMatch(/\.v2-root button\.v2-connector-row__selection \{ grid-column: 1 \/ -1;/);
- expect(connectorCss).toMatch(/\.v2-root button\.v2-connector-row__selection \.v2-connector-row__details \{ grid-column: 1 \/ -1; grid-row: 2;/);
- // The phone block used to hide __detail behind an exception list — whose
- // presence was pinned HERE, which is why the shape survived until a third
- // state fell outside it (eng lead 73726). The inverted shape (show, hide no
- // row) is pinned by the test above this one; the enumerating rule is gone.
+ // The phone block keeps the row's reason rather than carrying an exception chain.
expect(connectorCss).not.toContain('v2-connector-row--dead):not(.v2-connector-row--not-enabled) .v2-connector-row__detail');
});
+ describe('connectors.css (tile grid, popover, drawer)', () => {
+ const cx = fs.readFileSync(path.join(__dirname, '../connectors/connectors.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
+ // The text of every `@media (max-width: N)` block, brace-balanced, joined.
+ const mediaBlock = (px: number): string => {
+ const marker = `@media (max-width: ${px}px)`;
+ const blocks: string[] = [];
+ for (let from = cx.indexOf(marker); from > -1; from = cx.indexOf(marker, from + 1)) {
+ const open = cx.indexOf('{', from);
+ let depth = 0;
+ for (let i = open; i < cx.length; i += 1) {
+ if (cx[i] === '{') depth += 1;
+ if (cx[i] === '}') { depth -= 1; if (depth === 0) { blocks.push(cx.slice(open + 1, i)); break; } }
+ }
+ }
+ expect(blocks.length).toBeGreaterThan(0);
+ return blocks.join('\n');
+ };
+ const baseBody = (selector: string): string => {
+ const outside = cx.replace(/@media[^{]*\{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/g, '');
+ const m = outside.match(new RegExp(`(?:^|\\})\\s*${selector.replace(/[.\\-]/g, '\\$&')}\\s*\\{([^}]*)\\}`));
+ return m ? m[1] : '';
+ };
+
+ it('the page body hands the full height to the tile grid with no padding', () => {
+ expect(cx).toMatch(/\.v2-feature--connectors > \.v2-feature__body \{[^}]*padding: 0;/);
+ });
+
+ it('the grid is six columns, three on a phone', () => {
+ expect(baseBody('.v2-cx-grid')).toContain('grid-template-columns: repeat(6, minmax(0, 1fr))');
+ expect(mediaBlock(640)).toMatch(/\.v2-cx-grid \{[^}]*grid-template-columns: repeat\(3, minmax\(0, 1fr\)\)/);
+ });
+
+ it('the popover becomes a sheet above the tab bar at 760', () => {
+ const pop = mediaBlock(760).match(/\.v2-cx-pop \{([^}]*)\}/)?.[1] ?? '';
+ expect(pop).toContain('position: fixed');
+ expect(pop).toMatch(/bottom: calc\(84px/);
+ expect(pop).toContain('top: auto !important');
+ // the popover's inline top and left must lose to the sheet, or it floats off-screen
+ expect(pop).toContain('left: 12px !important');
+ expect(pop).toMatch(/max-height: calc\(100vh/);
+ });
+
+ it('colours come from the Signal tokens, so dark and auto themes follow the shell', () => {
+ expect(cx).toContain('--cx-key-a: var(--v2-key-a)');
+ expect(cx).toContain('--cx-pop-shadow: var(--v2-shadow-pop)');
+ expect(cx).not.toContain("data-theme='dark'");
+ });
+
+ it('the drawer is fixed over the page at 760, clear of the tab bar', () => {
+ const drawer = mediaBlock(760).match(/\.v2-cx-drawer \{([^}]*)\}/)?.[1] ?? '';
+ expect(drawer).toContain('position: fixed');
+ expect(drawer).toMatch(/inset: 0 0 calc\(72px/);
+ expect(baseBody('.v2-cx-drawer')).toContain('position: absolute');
+ });
+ });
+
it('connector refusal links keep the in-text link treatment in the parsed stylesheet', () => {
const style = document.createElement('style');
style.textContent = v2;
@@ -2830,7 +2788,7 @@ describe('v2 layout invariants (CSS rule presence)', () => {
// And the surfaces the composer does not need stay OUT of it.
for (const heavy of [
"'./components/V2PodBoard'",
- "'./components/V2ConnectorsPage'",
+ "'./connectors/ConnectorsPage'",
"'./components/V2ActivityPage'",
"'./components/V2ArtifactsPage'",
"'../components/ChatRoom'",
@@ -3146,7 +3104,7 @@ describe('the landing hero demo (TASK-147)', () => {
});
test('a row refusal takes a line of its own, under the row that raised it (Row C)', () => {
- // A refused Slack authorize now renders inside its row (V2ConnectorsPage).
+ // A refused Slack authorize now renders inside its row (ConnectorsPage).
// jsdom cannot see this: the row is a 140px / minmax(150px, 1fr) / 120px
// grid, so without the span the message would be squeezed into the name or
// act track instead of reading as one line — the TASK-029 failure mode.
@@ -5001,17 +4959,6 @@ describe('BYO command boxes wrap at spaces', () => {
});
});
-describe('connector inactive fold layout', () => {
- const v2 = read('../v2.css');
- test('the toggle stays a full-width row with a visible keyboard focus state', () => {
- const rule = ruleBody(v2, '.v2-root button.v2-connectors__inactive-toggle');
- expect(rule).toContain('width: 100%');
- expect(rule).toContain('min-height: 44px');
- expect(ruleBody(v2, '.v2-root button.v2-connectors__inactive-toggle:focus-visible'))
- .toContain('box-shadow: inset 0 0 0 2px var(--v2-accent)');
- });
-});
-
describe('GitHub gig card fits the chat column', () => {
const css = read('../v2.css');
test('bounds card width, wraps long repository text, and releases the avatar inset on phones', () => {
diff --git a/frontend/src/v2/components/V2ConnectorTools.tsx b/frontend/src/v2/components/V2ConnectorTools.tsx
deleted file mode 100644
index c8d848d67f..0000000000
--- a/frontend/src/v2/components/V2ConnectorTools.tsx
+++ /dev/null
@@ -1,1256 +0,0 @@
-// Tools — the second list on the Connectors page (tools plan §6, option A).
-// Same container grammar as the channels: dot, 20px glyph,
-// display name, two-line middle, mono when, one action. It renders only what
-// the server counts: a row per RoomGrant the person can see, a not-yet row per
-// tool Installable the catalogue returns (#1670), the aside from the projected
-// grant read, the trail from ToolCall rows the broker wrote. The Add form posts
-// the mint exactly as the server takes it — never a brokerId.
-//
-// Direction A: categories are glyphs — a trail outcome and a
-// grant's mode carry a mark with the word in `title`; the deciding act (Add,
-// Grant, Manage on a row with one act) keeps its word.
-
-import React, { useCallback, useEffect, useMemo, useState } from 'react';
-import { Trans, useTranslation } from 'react-i18next';
-import { useV2Api } from '../hooks/useV2Api';
-import { useRelativeNow } from '../hooks/useRelativeNow';
-import { localizeRelativeTime, localizeWindow, relativeParts } from '../utils/localizeRelativeTime';
-import { useAuth } from '../../context/AuthContext';
-import { V2Pod } from '../hooks/useV2Pods';
-import { PlatformGlyph } from '../icons/platforms';
-import { ActGlyph } from '../icons/glyphs';
-import { hostedMcpErrorNotice } from './hostedMcpErrorNotice';
-import { catalogDescription } from '../utils/connectorCatalogDescription';
-import { safeProviderRevokePage } from '../utils/safeProviderRevokePage';
-import V2InactiveFold from './V2InactiveFold';
-
-export type GrantWriteMode = 'read' | 'write' | 'write-with-confirm';
-
-export interface ToolGrant {
- grantId: string;
- installationId: string;
- target: { kind: 'pod' | 'seat'; id: string };
- tools: string[];
- writeMode: GrantWriteMode;
- budget: { calls?: number; windowMs?: number } | null;
- effectiveAudience: string[];
- expiresAt: string;
- revokedAt: string | null;
- revokedBy: string | null;
- parentGrantId: string | null;
- rootGrantId: string | null;
- createdAt: string;
- grantedBy: string | null;
-}
-
-/** `superseded` is a DERIVED read-only value the server reports for a parked
- * request whose approval has since resolved: the call itself is the row beside
- * it (ok / refused / failed), and this row is only the ask. It is never stored,
- * so it is never what a write sends. */
-export type ToolOutcome = 'ok' | 'refused' | 'pending_approval' | 'failed' | 'superseded';
-
-export interface ToolCallLine {
- callId: string;
- agentUserId: string;
- tool: string;
- outcome: ToolOutcome;
- reason: string | null;
- approvalId: string | null;
- argsDigest: string;
- at: string | null;
- durationMs: number | null;
-}
-
-export interface ToolTrail {
- grantId: string;
- calls: ToolCallLine[];
- counts: { total: number; ok: number; refused: number; pending_approval: number; failed: number };
-}
-
-/** A tool Installable as GET /api/installables lists it (list: 'tools', #1670). */
-export interface ToolCatalogEntry {
- installableId: string;
- list?: 'channels' | 'tools';
- label: string;
- description: string;
- connectNotice?: string;
- /**
- * Where the person chooses, at the vendor, what the connection can reach
- * (GitHub: the App's install page, which picks the repositories).
- */
- accessPage?: string;
- /**
- * The connection type this row's tools run through, and — on a hosted entry —
- * the catalogue entry the start route takes. `entryId` is absent on
- * `github-app`, whose connections are not per entry.
- */
- connectionType?: 'github-app' | 'hosted-mcp';
- entryId?: string;
- available: boolean;
- unavailableReason?: string;
- broker?: { id: string };
- tools: Array<{ name: string; description?: string; requiredWriteMode: GrantWriteMode; irreversible: boolean }>;
- connections: Array<{ connectionId: string; owner: string; repo: string }>;
-}
-
-interface HiddenToolConnection {
- connectionId: string;
- entryId: string;
- label: string;
- status: string;
- revokePage: string | null;
-}
-
-export interface HostedMcpManualRevokeNotice {
- connectionId: string;
- entryId: string;
- provider: string;
- revokePage: string;
-}
-
-interface PodSeat { userId: string | null; displayName?: string; name: string; internal?: boolean }
-
-interface Props {
- pods: V2Pod[];
- manualRevokeNotice?: HostedMcpManualRevokeNotice | null;
-}
-
-interface DraftGrant {
- entry: ToolCatalogEntry;
- podId: string;
- connectionId: string;
- writeMode: GrantWriteMode;
- audience: string[];
- expiryDays: 7 | 30 | 90;
- /** Change access: the grant this one replaces (revoked after the mint). */
- replaces: string | null;
-}
-
-interface GithubAppSetup {
- installationId: string;
- owner: string;
- repo: string;
-}
-
-interface GithubAppIntegrationResponse {
- integration?: {
- createdBy?: string | { _id?: string } | null;
- };
-}
-
-const USED_RECENTLY_MS = 10 * 60 * 1000;
-const MODE_RANK: Record = { read: 0, 'write-with-confirm': 1, write: 2 };
-// The two option sets the aside renders as buttons. They live here, not inline in the
-// JSX, because eslint's i18next/no-literal-string (jsx-only) reads an array literal in
-// a JSX expression as copy — and these are ids, not words (TASK-164).
-const WRITE_MODES: GrantWriteMode[] = ['read', 'write-with-confirm', 'write'];
-
-/**
- * The age as a unit-suffixed number ("23d", "5m", "just now"). Rounds through
- * relativeParts, the same rule as the "revoked … ago" line, so one timestamp
- * pair never reads "1d" in one place and "2d" in the other (TASK-649).
- */
-export const shortAge = (date: string | null | undefined, now: number, t: (key: string, options?: Record) => string): string => {
- const parts = relativeParts(date, { includeFuture: false, now });
- if (parts.kind !== 'rel') return t('time.age.justNow', { defaultValue: 'just now' });
- if (parts.unit === 'minute') return t('time.age.minutes', { defaultValue: '{{n}}m', n: parts.count });
- if (parts.unit === 'hour') return t('time.age.hours', { defaultValue: '{{n}}h', n: parts.count });
- return t('time.age.days', { defaultValue: '{{n}}d', n: parts.count });
-};
-
-// Both surfaces used to carry their own copy of this grammar; the locale family
-// lives in one place now (TASK-164). Re-exported so the module's surface is
-// unchanged for anything importing it from here.
-export { localizeRelativeTime, relativeTime } from '../utils/localizeRelativeTime';
-
-const isExpired = (grant: ToolGrant, now = Date.now()): boolean => new Date(grant.expiresAt).getTime() <= now;
-// Callers that decide what a render SHOWS pass that render's `now`; the read
-// path (`load`) leaves the default, because there the question is what is
-// live at the moment of the read.
-const isDead = (grant: ToolGrant, now = Date.now()): boolean => Boolean(grant.revokedAt) || isExpired(grant, now);
-
-// A catalogue entry is an Installable; a grant carries tool names and an
-// `installationId` that names an install, not a catalogue id. The names are the
-// only key both sides share, so an entry is the one whose tools the grant names.
-const entryCovers = (entry: ToolCatalogEntry, grant: ToolGrant): boolean => (
- (grant.tools || []).some((name) => (entry.tools || []).some((tool) => tool.name === name))
-);
-// A grant that names no tool of this entry grants nothing on it — that is what
-// the enforcement path does (roomGrantService refuses any tool the grant does
-// not list), so the page must not read such an entry as granted.
-const entryIsGranted = (entry: ToolCatalogEntry, grants: ToolGrant[], now: number): boolean => (
- grants.some((grant) => !isDead(grant, now) && entryCovers(entry, grant))
-);
-
-const G: React.FC<{ children: React.ReactNode }> = ({ children }) => (
-
-);
-/** Outcome marks: a category, so a glyph; the word stays in the log line and the title.
- *
- * The `superseded` mark is a shield with a SHORT BAR, not the shield-and-check it
- * first shipped with: the resolution it stands for may be an
- * approval, a decline or an expiry, and the row beside it carries the verdict —
- * a check read as "approved" next to a sibling that says `refused`. The bar says
- * settled without saying how. */
-const OutcomeGlyph: React.FC<{ outcome: ToolOutcome }> = ({ outcome }) => {
- if (outcome === 'ok') return ;
- if (outcome === 'refused') return ;
- if (outcome === 'pending_approval') return ;
- if (outcome === 'superseded') return ;
- return ;
-};
-/** Mode marks: eye for read, pen for write, shield for write that asks first. */
-const ModeGlyph: React.FC<{ mode: GrantWriteMode }> = ({ mode }) => {
- if (mode === 'read') return ;
- if (mode === 'write') return ;
- return ;
-};
-
-const PER_ENTRY_REVOKE_COPY = new Set(['supabase', 'linear', 'sentry']);
-
-const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) => {
- const { t, i18n } = useTranslation();
- const api = useV2Api();
- // Revoke and Change access are the granter's: the route 403s anyone else.
- const { currentUser } = useAuth();
- const viewerId = currentUser?._id ? String(currentUser._id) : '';
- const isGranter = (grant: ToolGrant): boolean => Boolean(viewerId) && grant.grantedBy === viewerId;
- const [rawGrants, setGrants] = useState(null);
- const [rawCatalog, setCatalog] = useState([]);
- // A connection the server confirmed removed stays removed on this page, as a stale catalogue read could bring it back.
- const [disconnectedHere, setDisconnectedHere] = useState([]);
- const [hiddenConnections, setHiddenConnections] = useState([]);
- const [seats, setSeats] = useState>({});
- const [lastUse, setLastUse] = useState>({});
- const [trail, setTrail] = useState(null);
- const [trailError, setTrailError] = useState(false);
- const [selectedId, setSelectedId] = useState(null);
- const [draft, setDraft] = useState(null);
- const [githubAppSetup, setGithubAppSetup] = useState(null);
- // Change access minted the new grant but the revoke of the old one failed: only the revoke is retried.
- const [staleAfterChange, setStaleAfterChange] = useState<{ oldGrantId: string; newGrantId: string } | null>(null);
- const [confirmRevoke, setConfirmRevoke] = useState(null);
- const [confirmDisconnect, setConfirmDisconnect] = useState(null);
- // A revoke is irreversible, so once the server has said yes the grant stays
- // revoked on this page whatever a later read returns. Without this, the card
- // kept saying "granted" when the reload came back stale or failed.
- const [revokedHere, setRevokedHere] = useState>({});
- const [confirmHiddenRemoval, setConfirmHiddenRemoval] = useState(null);
- const [removedHiddenNotice, setRemovedHiddenNotice] = useState<{ label: string; revokePage: string | null } | null>(null);
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState(null);
- const [joinPolicyFixPodId, setJoinPolicyFixPodId] = useState(null);
- const [policyFixedPodId, setPolicyFixedPodId] = useState(null);
- const [query, setQuery] = useState('');
- const catalog = useMemo(() => (disconnectedHere.length === 0 ? rawCatalog : rawCatalog.map((entry) => ({
- ...entry,
- connections: entry.connections.filter((connection) => !disconnectedHere.includes(connection.connectionId)),
- }))), [rawCatalog, disconnectedHere]);
- const grants = useMemo(() => (rawGrants === null ? null : rawGrants.map((grant) => (
- revokedHere[grant.grantId] && !grant.revokedAt
- ? { ...grant, revokedAt: revokedHere[grant.grantId], revokedBy: grant.revokedBy ?? (viewerId || null) }
- : grant
- ))), [rawGrants, revokedHere, viewerId]);
- // Grants and trail rows carry relative ages; they must advance while the
- // page sits open and be re-read when the tab comes back (TASK-131).
- const now = useRelativeNow();
-
- const isAdmin = currentUser?.role === 'admin';
-
- const podIds = useMemo(() => pods.map((pod) => String(pod._id)), [pods]);
-
- const load = useCallback(async () => {
- const catalogRes = await api.get<{ installables?: ToolCatalogEntry[]; hiddenToolConnections?: HiddenToolConnection[] }>('/api/installables').catch(() => null);
- setCatalog((catalogRes?.installables || []).filter((entry) => entry.list === 'tools'));
- setHiddenConnections(catalogRes?.hiddenToolConnections || []);
- const [grantRes, seatResults] = await Promise.all([
- api.get<{ grants: ToolGrant[] }>('/api/grants', { params: { connectionOwner: 'me' } }).catch(() => null),
- Promise.all(podIds.map(async (podId) => {
- const res = await api.get<{ agents?: PodSeat[] }>(`/api/registry/pods/${podId}/agents`).catch(() => null);
- return [podId, (res?.agents ?? []).filter((seat) => !seat.internal)] as const;
- })),
- ]);
- const ownedGrants = grantRes?.grants || [];
- // A failed read must not blank a list the person is looking at.
- if (grantRes) setGrants(ownedGrants); else setGrants((current) => current ?? []);
- setSeats(Object.fromEntries(seatResults));
- // "used in the last 10 min" reads the newest trail row's `at` (§6).
- const uses = await Promise.all(ownedGrants.filter((grant) => !isDead(grant)).map(async (grant) => {
- const res = await api.get(`/api/grants/${grant.grantId}/calls`, { params: { limit: 1 } }).catch(() => null);
- return [grant.grantId, res?.calls?.[0]?.at ?? null] as const;
- }));
- setLastUse(Object.fromEntries(uses));
- }, [api, podIds]);
-
- useEffect(() => { void load(); }, [load]);
-
- useEffect(() => {
- const onVisibility = () => { if (document.visibilityState === 'visible') void load(); };
- document.addEventListener('visibilitychange', onVisibility);
- return () => document.removeEventListener('visibilitychange', onVisibility);
- }, [load]);
-
- useEffect(() => {
- if (!selectedId) { setTrail(null); return undefined; }
- let cancelled = false;
- setTrail(null);
- setTrailError(false);
- api.get(`/api/grants/${selectedId}/calls`)
- .then((res) => { if (!cancelled) setTrail(res); })
- .catch(() => { if (!cancelled) setTrailError(true); });
- return () => { cancelled = true; };
- }, [api, selectedId]);
-
- // The entry a grant belongs to is the one whose namespaced tools it carries.
- // If the catalogue no longer has that entry, keep the row visibly unknown;
- // assigning it to GitHub would misstate who owns the grant and its policy.
- const entryFor = (grant: ToolGrant): ToolCatalogEntry | null => (
- catalog.find((entry) => entryCovers(entry, grant)) || null
- );
- const noticeMatchesEntry = (entry: ToolCatalogEntry | null): boolean => Boolean(
- manualRevokeNotice
- && entry?.entryId === manualRevokeNotice.entryId
- && entry.connections.some((connection) => connection.connectionId === manualRevokeNotice.connectionId),
- );
- const renderManualRevokeNotice = (entry: ToolCatalogEntry | null) => {
- if (!manualRevokeNotice || !noticeMatchesEntry(entry)) return null;
- // Per-entry copy names only what that vendor's page lists; entries without it fall back to the generic line.
- const context = PER_ENTRY_REVOKE_COPY.has(manualRevokeNotice.entryId) ? manualRevokeNotice.entryId : undefined;
- return (
-