Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/plans/connectors-page-signal-diff.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Rows are one line each. `dot` is the 8px mark before the name; between the dot a

Rules under the table:

- **{lead}** is `config.leadAgentUsername`; when unset the line reads `attention mode · escalations reach the channel`. Nobody invents an agent name.
- **{lead}** is `config.leadAgentUsername`; when unset the line reads `attention · escalations reach the channel` (meta wraps to a second line and never ellipsises — lily-shen, 2026-09-05). Nobody invents an agent name.
- **when** uses the row's `createdAt` (exists) with the word *started* for pending rows. `connected {rel}` needs `connectedAt`, which no row carries; until Kai stamps it at the Telegram enable and the Slack Confirm (two writes, same PR or a follow-on), connected rows read `added {rel}` from `createdAt`. `updatedAt` is not used — every relay touches it.
- **Selected row** ground `#f2f4f7`, name weight unchanged (display is already heavy). Default selection: the first row whose action is ink (needs the user), else the first connected row. Manage on an already-selected row deselects nothing; it just keeps the aside on that row.
- **iMessage** is on the artboard as a sample provider. It is not in the provider enum; it does not render.
Expand Down
294 changes: 282 additions & 12 deletions frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
// 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 { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import V2ConnectorsPage, { installableLifecyclePath } from '../components/V2ConnectorsPage';
import V2ConnectorsPage, { INSTALL_LOCK_TTL_MS, installableLifecyclePath } from '../components/V2ConnectorsPage';
import { AuthContext } from '../../context/AuthContext';

jest.mock('axios', () => {
Expand Down Expand Up @@ -150,7 +150,7 @@ describe('V2ConnectorsPage', () => {
));
});

it('uses the inline provider and pod form, excluding public pods', async () => {
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();
Expand All @@ -159,7 +159,7 @@ describe('V2ConnectorsPage', () => {
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).not.toContain('Town Square');
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(
Expand Down Expand Up @@ -244,8 +244,8 @@ describe('V2ConnectorsPage', () => {
axios.delete.mockResolvedValue({ data: { status: 'uninstalled' } });
const firstRender = renderPage();

fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' }));
fireEvent.click(screen.getByRole('button', { name: /Really disconnect/ }));
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(),
Expand All @@ -255,8 +255,8 @@ describe('V2ConnectorsPage', () => {
mockGets([{ ...connectors[1], installationId: undefined }]);
axios.patch.mockResolvedValue({ data: {} });
renderPage();
fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' }));
fireEvent.click(screen.getByRole('button', { name: /Really disconnect/ }));
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 },
Expand All @@ -269,8 +269,8 @@ describe('V2ConnectorsPage', () => {
axios.delete.mockResolvedValue({ data: { status: 'uninstalled' } });
renderPage();

fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' }));
fireEvent.click(screen.getByRole('button', { name: /Really disconnect/ }));
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(),
Expand Down Expand Up @@ -403,11 +403,281 @@ describe('V2ConnectorsPage', () => {
{},
expect.objectContaining({ withCredentials: true }),
));
fireEvent.click(screen.getByRole('button', { name: 'Disconnect' }));
expect(screen.getByRole('button', { name: /Really disconnect/ })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Remove' }));
expect(screen.getByRole('button', { name: /Really remove/ })).toBeInTheDocument();
});

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', () => {
const entry = (over = {}) => ({
installableId: 'telegram',
label: 'Telegram',
description: 'One Telegram chat, one pod.',
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 = []) => {
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: [
{ _id: 'p1', name: 'Rewire Live Demo', type: 'chat', members: [{ _id: 'b1', username: 'vale', isBot: true }] },
{ _id: 'p2', name: 'Ops', type: 'team', members: [] },
],
});
}
return Promise.resolve({ data: [] });
});
};

it('renders an unavailable provider without a control and an available one with Connect', async () => {
mockCatalog([
entry(),
entry({ installableId: 'slack', label: 'Slack', available: false, unavailableReason: 'not_configured' }),
]);
axios.post.mockResolvedValue({ data: { status: 'installing' } });
renderPage();

expect(await screen.findByText('Not enabled on this instance.')).toBeInTheDocument();
expect(screen.getByText('ask your operator')).toBeInTheDocument();
expect(screen.getByText('One Telegram chat, one pod.')).toBeInTheDocument();
expect(screen.getByText('not connected')).toBeInTheDocument();
expect(screen.queryByText('not_configured')).toBeNull();
const connects = screen.getAllByRole('button', { name: 'Connect' });
expect(connects).toHaveLength(1);
fireEvent.click(connects[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(),
));
});

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({
installation: { status: 'error', errorMessage: 'projection missing', boundPodId: 'p2', updatedAt: new Date().toISOString(), components: [] },
})]);
axios.post.mockResolvedValue({ data: { status: 'installing' } });
axios.delete.mockResolvedValue({ data: { status: 'uninstalled' } });
renderPage();

expect((await screen.findAllByText('projection missing')).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 ago')).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(),
));
});

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();
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);
});
});
});
8 changes: 7 additions & 1 deletion frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1361,9 +1361,15 @@ describe('v2 layout invariants (CSS rule presence)', () => {
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')).toContain('white-space: nowrap');
expect(ruleBody(v2, '.v2-connector-row__detail')).not.toContain('text-overflow');
expect(ruleBody(v2, '.v2-connector-row__detail')).not.toContain('nowrap');
expect(ruleBody(v2, '.v2-connector-row__dot--live, .v2-connector-row__dot--pending')).toContain('var(--v2-accent)');
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).not.toContain('var(--v2-success)');
expect(connectorCss).not.toContain('var(--v2-warning)');
Expand Down
Loading
Loading