feat: perm sync visibility (#10795)

This commit is contained in:
Evan Lohn authored and GitHub committed 2026-05-05 19:26:08 +00:00
1 parent a816c23908
commit 0ae2e1a940
7 files changed
+700 -170

No files matched your search

@@ -81,6 +81,7 @@ export default function TableHead({
return (
<th
{...thProps}
scope="col"
style={width != null ? { width } : undefined}
className={cn("table-head group", alignmentThClass[alignment])}
data-size={resolvedSize}
@@ -1,17 +1,21 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import {
createTableColumns,
MessageCard,
EmptyMessageCard,
Pagination,
Table,
Text,
} from "@opal/components";
import { Section } from "@/layouts/general-layouts";
import { localizeAndPrettify } from "@/lib/time";
import ExceptionTraceModal from "@/sections/modals/PreviewModal/ExceptionTraceModal";
import { PermissionSyncStatusBadge } from "./PermissionSyncStatusBadge";
import type { DocPermissionSyncAttemptSnapshot } from "./types";
const ERROR_MODAL_TITLE = "Document Permission Sync Error";
/**
* Renders one page of `DocPermissionSyncAttempt` rows for the
* connector-detail "Document Permissions" tab.
@@ -30,56 +34,101 @@ import type { DocPermissionSyncAttemptSnapshot } from "./types";
const tc = createTableColumns<DocPermissionSyncAttemptSnapshot>();
const COLUMNS = [
tc.column("time_started", {
header: "Time Started",
weight: 22,
enableSorting: false,
cell: (value) => (
<Text as="span" font="main-ui-body" color="text-04">
{value ? localizeAndPrettify(value) : "-"}
// Weights are TanStack-relative; they sum to 100 here purely for
// readability. `Time Started` is bumped to 28 so `localizeAndPrettify`
// (e.g. "5/3/2026, 12:00:00 PM") stays on a single line at standard
// 800px-wide admin layouts; the difference is taken from `Status` and
// `Docs Synced`, which both render short content.
function buildColumns(onErrorClick: (errorMessage: string) => void) {
return [
tc.column("time_started", {
header: "Time Started",
weight: 28,
enableSorting: false,
cell: (value) => (
<Text as="span" font="main-ui-body" color="text-04">
{value ? localizeAndPrettify(value) : "-"}
</Text>
),
}),
tc.column("status", {
header: "Status",
weight: 14,
enableSorting: false,
cell: (value, row) => (
<PermissionSyncStatusBadge
status={value}
errorMsg={row.error_message}
/>
),
}),
tc.column("total_docs_synced", {
header: "Docs Synced",
weight: 12,
enableSorting: false,
cell: (value) => (
<Text as="span" font="main-ui-body" color="text-04">
{String(value)}
</Text>
),
}),
tc.column("docs_with_permission_errors", {
header: "Permission Errors",
weight: 18,
enableSorting: false,
cell: (value) => (
<Text as="span" font="main-ui-body" color="text-04">
{String(value)}
</Text>
),
}),
tc.column("error_message", {
header: "Error Message",
weight: 28,
enableSorting: false,
cell: (value) => (
<ErrorMessageCell errorMessage={value} onErrorClick={onErrorClick} />
),
}),
];
}
interface ErrorMessageCellProps {
errorMessage: string | null;
onErrorClick: (errorMessage: string) => void;
}
/**
* Renders the truncated error message as a clickable button when a
* message is present, opening the `ExceptionTraceModal` on click.
* Mirrors the row-level click affordance in `IndexAttemptsTable`,
* scoped here to just the cell so the status-badge tooltip on the
* left stays its own independent affordance.
*/
function ErrorMessageCell({
errorMessage,
onErrorClick,
}: ErrorMessageCellProps) {
if (!errorMessage) {
return (
<Text as="span" font="secondary-body" color="text-03">
-
</Text>
),
}),
tc.column("status", {
header: "Status",
weight: 18,
enableSorting: false,
cell: (value, row) => (
<PermissionSyncStatusBadge status={value} errorMsg={row.error_message} />
),
}),
tc.column("total_docs_synced", {
header: "Docs Synced",
weight: 14,
enableSorting: false,
cell: (value) => (
<Text as="span" font="main-ui-body" color="text-04">
{String(value)}
</Text>
),
}),
tc.column("docs_with_permission_errors", {
header: "Permission Errors",
weight: 18,
enableSorting: false,
cell: (value) => (
<Text as="span" font="main-ui-body" color="text-04">
{String(value)}
</Text>
),
}),
tc.column("error_message", {
header: "Error Message",
weight: 28,
enableSorting: false,
cell: (value) => (
);
}
return (
<button
type="button"
onClick={() => onErrorClick(errorMessage)}
aria-label="View full error message"
className="text-left w-full cursor-pointer hover:underline"
>
<Text as="span" font="secondary-body" color="text-03" maxLines={2}>
{value ?? "-"}
{errorMessage}
</Text>
),
}),
];
</button>
);
}
export interface DocPermissionSyncAttemptsTableProps {
attempts: DocPermissionSyncAttemptSnapshot[];
@@ -95,38 +144,58 @@ export function DocPermissionSyncAttemptsTable({
totalPages,
onPageChange,
}: DocPermissionSyncAttemptsTableProps) {
const [openErrorMessage, setOpenErrorMessage] = useState<string | null>(null);
const handleErrorClick = useCallback(
(errorMessage: string) => setOpenErrorMessage(errorMessage),
[]
);
const columns = useMemo(
() => buildColumns(handleErrorClick),
[handleErrorClick]
);
if (!attempts.length) {
return (
<MessageCard
variant="info"
title="No document permission sync attempts scheduled yet"
<EmptyMessageCard
sizePreset="main-ui"
title="No document permission sync attempts yet"
description="Document-permission sync runs are scheduled in the background. They may take some time to appear — try refreshing in ~30 seconds."
/>
);
}
return (
<Section gap={0.75} alignItems="stretch" height="auto">
<Table
data={attempts}
columns={COLUMNS}
getRowId={(row) => String(row.id)}
/>
{totalPages > 1 && (
<Section
flexDirection="row"
justifyContent="center"
height="auto"
className="pt-1"
>
<Pagination
variant="list"
currentPage={currentPage}
totalPages={totalPages}
onChange={onPageChange}
/>
</Section>
<>
{openErrorMessage !== null && (
<ExceptionTraceModal
onOutsideClick={() => setOpenErrorMessage(null)}
exceptionTrace={openErrorMessage}
title={ERROR_MODAL_TITLE}
/>
)}
</Section>
<Section gap={0.75} alignItems="stretch" height="auto">
<Table
data={attempts}
columns={columns}
getRowId={(row) => String(row.id)}
/>
{totalPages > 1 && (
<Section
flexDirection="row"
justifyContent="center"
height="auto"
className="pt-1"
>
<Pagination
variant="list"
currentPage={currentPage}
totalPages={totalPages}
onChange={onPageChange}
/>
</Section>
)}
</Section>
</>
);
}
@@ -1,17 +1,21 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import {
createTableColumns,
MessageCard,
EmptyMessageCard,
Pagination,
Table,
Text,
} from "@opal/components";
import { Section } from "@/layouts/general-layouts";
import { localizeAndPrettify } from "@/lib/time";
import ExceptionTraceModal from "@/sections/modals/PreviewModal/ExceptionTraceModal";
import { PermissionSyncStatusBadge } from "./PermissionSyncStatusBadge";
import type { ExternalGroupSyncAttemptSnapshot } from "./types";
const ERROR_MODAL_TITLE = "Group Membership Sync Error";
/**
* Renders one page of `ExternalGroupPermissionSyncAttempt` rows for the
* connector-detail "Group Membership" tab.
@@ -35,66 +39,122 @@ import type { ExternalGroupSyncAttemptSnapshot } from "./types";
const tc = createTableColumns<ExternalGroupSyncAttemptSnapshot>();
const COLUMNS = [
tc.column("time_started", {
header: "Time Started",
weight: 20,
enableSorting: false,
cell: (value) => (
<Text as="span" font="main-ui-body" color="text-04">
{value ? localizeAndPrettify(value) : "-"}
// Headers are intentionally short ("Users", not "Users Processed").
// Opal's `TableHead` renders headers via `String(children)` (see
// `web/lib/opal/src/components/table/TableHead.tsx:96`), which kills any
// rich-content header (Tooltip + info icon, etc.) — so we lean on
// concise, contextually-clear labels instead. The tab itself is named
// "Group Membership", so "Users / Groups / Memberships" reads as
// "users seen / groups processed / memberships written" without
// further annotation.
//
// Weights are TanStack-relative; the per-column `minWidth =
// header.length * 8 + 40` floor means short labels are also necessary
// to actually achieve "skinnier" columns — e.g. "Users Processed"
// pins minWidth to 160px no matter the weight.
//
// `Time Started` is bumped to 26 so `localizeAndPrettify` (e.g.
// "5/3/2026, 12:00:00 PM") stays on a single line.
function buildColumns(onErrorClick: (errorMessage: string) => void) {
return [
tc.column("time_started", {
header: "Time Started",
weight: 26,
enableSorting: false,
cell: (value) => (
<Text as="span" font="main-ui-body" color="text-04">
{value ? localizeAndPrettify(value) : "-"}
</Text>
),
}),
tc.column("status", {
header: "Status",
weight: 14,
enableSorting: false,
cell: (value, row) => (
<PermissionSyncStatusBadge
status={value}
errorMsg={row.error_message}
/>
),
}),
tc.column("total_users_processed", {
header: "Users",
weight: 10,
enableSorting: false,
cell: (value) => (
<Text as="span" font="main-ui-body" color="text-04">
{String(value)}
</Text>
),
}),
tc.column("total_groups_processed", {
header: "Groups",
weight: 10,
enableSorting: false,
cell: (value) => (
<Text as="span" font="main-ui-body" color="text-04">
{String(value)}
</Text>
),
}),
tc.column("total_group_memberships_synced", {
header: "Memberships",
weight: 12,
enableSorting: false,
cell: (value) => (
<Text as="span" font="main-ui-body" color="text-04">
{String(value)}
</Text>
),
}),
tc.column("error_message", {
header: "Error Message",
weight: 28,
enableSorting: false,
cell: (value) => (
<ErrorMessageCell errorMessage={value} onErrorClick={onErrorClick} />
),
}),
];
}
interface ErrorMessageCellProps {
errorMessage: string | null;
onErrorClick: (errorMessage: string) => void;
}
/**
* Renders the truncated error message as a clickable button when a
* message is present, opening the `ExceptionTraceModal` on click.
* Mirrors the row-level click affordance in `IndexAttemptsTable`,
* scoped here to just the cell so the status-badge tooltip on the
* left stays its own independent affordance.
*/
function ErrorMessageCell({
errorMessage,
onErrorClick,
}: ErrorMessageCellProps) {
if (!errorMessage) {
return (
<Text as="span" font="secondary-body" color="text-03">
-
</Text>
),
}),
tc.column("status", {
header: "Status",
weight: 16,
enableSorting: false,
cell: (value, row) => (
<PermissionSyncStatusBadge status={value} errorMsg={row.error_message} />
),
}),
tc.column("total_users_processed", {
header: "Users Processed",
weight: 14,
enableSorting: false,
cell: (value) => (
<Text as="span" font="main-ui-body" color="text-04">
{String(value)}
</Text>
),
}),
tc.column("total_groups_processed", {
header: "Groups Processed",
weight: 14,
enableSorting: false,
cell: (value) => (
<Text as="span" font="main-ui-body" color="text-04">
{String(value)}
</Text>
),
}),
tc.column("total_group_memberships_synced", {
header: "Memberships Synced",
weight: 14,
enableSorting: false,
cell: (value) => (
<Text as="span" font="main-ui-body" color="text-04">
{String(value)}
</Text>
),
}),
tc.column("error_message", {
header: "Error Message",
weight: 22,
enableSorting: false,
cell: (value) => (
);
}
return (
<button
type="button"
onClick={() => onErrorClick(errorMessage)}
aria-label="View full error message"
className="text-left w-full cursor-pointer hover:underline"
>
<Text as="span" font="secondary-body" color="text-03" maxLines={2}>
{value ?? "-"}
{errorMessage}
</Text>
),
}),
];
</button>
);
}
export interface ExternalGroupSyncAttemptsTableProps {
attempts: ExternalGroupSyncAttemptSnapshot[];
@@ -110,38 +170,58 @@ export function ExternalGroupSyncAttemptsTable({
totalPages,
onPageChange,
}: ExternalGroupSyncAttemptsTableProps) {
const [openErrorMessage, setOpenErrorMessage] = useState<string | null>(null);
const handleErrorClick = useCallback(
(errorMessage: string) => setOpenErrorMessage(errorMessage),
[]
);
const columns = useMemo(
() => buildColumns(handleErrorClick),
[handleErrorClick]
);
if (!attempts.length) {
return (
<MessageCard
variant="info"
title="No group membership sync attempts scheduled yet"
<EmptyMessageCard
sizePreset="main-ui"
title="No group membership sync attempts yet"
description="Group-membership sync runs are scheduled in the background. They may take some time to appear — try refreshing in ~30 seconds."
/>
);
}
return (
<Section gap={0.75} alignItems="stretch" height="auto">
<Table
data={attempts}
columns={COLUMNS}
getRowId={(row) => String(row.id)}
/>
{totalPages > 1 && (
<Section
flexDirection="row"
justifyContent="center"
height="auto"
className="pt-1"
>
<Pagination
variant="list"
currentPage={currentPage}
totalPages={totalPages}
onChange={onPageChange}
/>
</Section>
<>
{openErrorMessage !== null && (
<ExceptionTraceModal
onOutsideClick={() => setOpenErrorMessage(null)}
exceptionTrace={openErrorMessage}
title={ERROR_MODAL_TITLE}
/>
)}
</Section>
<Section gap={0.75} alignItems="stretch" height="auto">
<Table
data={attempts}
columns={columns}
getRowId={(row) => String(row.id)}
/>
{totalPages > 1 && (
<Section
flexDirection="row"
justifyContent="center"
height="auto"
className="pt-1"
>
<Pagination
variant="list"
currentPage={currentPage}
totalPages={totalPages}
onChange={onPageChange}
/>
</Section>
)}
</Section>
</>
);
}
@@ -93,10 +93,10 @@ export function SyncAttemptsTabs({
<Tabs.List variant="contained">
<Tabs.Trigger value={SyncAttemptsTab.INDEXING}>Indexing</Tabs.Trigger>
<Tabs.Trigger value={SyncAttemptsTab.DOC_PERMISSIONS}>
Document Permissions
Document Permission Sync
</Tabs.Trigger>
<Tabs.Trigger value={SyncAttemptsTab.GROUP_MEMBERSHIP}>
Group Membership
Group Membership Sync
</Tabs.Trigger>
</Tabs.List>
@@ -228,6 +228,14 @@ function renderTabGate(
return null;
}
/**
* Reserves roughly the height of a full populated `ITEMS_PER_PAGE`
* results page (8 rows + header + pagination footer ≈ 32rem at the
* default Opal `Table` `size="lg"`). Without this the loading state
* collapses the tab body, the page shrinks, the user's scroll position
* jumps up, and when data lands they have to scroll back down to see
* the table — which is exactly what the previous spinner was causing.
*/
function SyncAttemptsTabSpinner() {
return (
<Section
@@ -235,7 +243,7 @@ function SyncAttemptsTabSpinner() {
justifyContent="center"
alignItems="center"
height="auto"
className="py-6"
className="min-h-[32rem]"
>
<SimpleLoader className="h-6 w-6" />
</Section>
+27 -12
View File
@@ -26,6 +26,8 @@ import {
import DeletionErrorStatus from "./DeletionErrorStatus";
import { IndexAttemptsTable } from "./IndexAttemptsTable";
import InlineFileManagement from "./InlineFileManagement";
import { SyncAttemptsTabs } from "./SyncAttemptsTabs";
import { Section } from "@/layouts/general-layouts";
import { buildCCPairInfoUrl, triggerIndexing } from "./lib";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
@@ -726,18 +728,31 @@ function Main({ ccPairId }: { ccPairId: number }) {
</>
)}
<Title size="md" className="mt-6 mb-2">
Indexing Attempts
</Title>
{indexAttempts && (
<IndexAttemptsTable
ccPair={ccPair}
indexAttempts={indexAttempts}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={goToPage}
/>
)}
{indexAttempts &&
(ccPair.access_type === "sync" ? (
<Section height="auto" alignItems="stretch" className="mt-6">
<SyncAttemptsTabs
ccPair={ccPair}
indexAttempts={indexAttempts}
indexCurrentPage={currentPage}
indexTotalPages={totalPages}
onIndexPageChange={goToPage}
/>
</Section>
) : (
<>
<Title size="md" className="mt-6 mb-2">
Indexing Attempts
</Title>
<IndexAttemptsTable
ccPair={ccPair}
indexAttempts={indexAttempts}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={goToPage}
/>
</>
))}
</div>
)}
</div>
@@ -8,19 +8,27 @@ interface ExceptionTraceModalProps {
onOutsideClick: () => void;
exceptionTrace: string;
language?: string;
/**
* Modal header. Defaults to "Full Exception Trace" — the original
* caller (`IndexAttemptsTable`) renders multi-line Python tracebacks.
* Pass a more specific label (e.g. "Sync Error") for shorter, less
* trace-shaped error messages.
*/
title?: string;
}
export default function ExceptionTraceModal({
onOutsideClick,
exceptionTrace,
language = "python",
title = "Full Exception Trace",
}: ExceptionTraceModalProps) {
return (
<Modal open onOpenChange={onOutsideClick}>
<Modal.Content width="full" height="full">
<Modal.Header
icon={SvgAlertTriangle}
title="Full Exception Trace"
title={title}
onClose={onOutsideClick}
height="fit"
/>
@@ -0,0 +1,349 @@
/**
* E2E coverage for the connector-detail "Advanced" → permission-sync tabs UX.
*
* Three scenarios:
*
* 1. **Non-sync connector** (real file cc-pair): the legacy "Indexing
* Attempts" title + table render exactly as before. No tab UI.
* 2. **Sync connector** (route-mocked, since spinning up a real
* permission-synced cc-pair would require source-specific OAuth /
* credentials we don't have in CI): all three tabs render, the
* Document Permissions tab loads its endpoint and shows the row, and
* the Group Membership tab shows the explicit "not applicable"
* message rather than a blank empty state.
* 3. **Failed-attempt error modal**: in both sync tables, a `failed` row
* surfaces the error message as a clickable button that opens
* `ExceptionTraceModal` with a tab-specific title and the full error
* text rendered in the body.
*
* The backend behavior (route correctness, `applicable` flag computation,
* source-wide attribution for cc-pair-agnostic sources) is covered by
* the external-dependency-unit suite at
* `backend/tests/external_dependency_unit/permission_sync/test_cc_pair_sync_attempts_routes.py`,
* landed in PR A. This spec deliberately scopes itself to the frontend
* rendering decisions PR B–D introduce.
*/
import { test, expect } from "@playwright/test";
import type { Page, Route } from "@playwright/test";
import { OnyxApiClient } from "@tests/e2e/utils/onyxApiClient";
const MOCK_SYNC_CC_PAIR_ID = 99999;
const MOCK_SOURCE = "google_drive";
const MOCK_DOC_ATTEMPT_ID = 5001;
const MOCK_GROUP_ATTEMPT_ID = 6001;
const MOCK_DOC_ERROR_MESSAGE =
"Traceback: doc permission sync failed because the upstream API returned 503";
const MOCK_GROUP_ERROR_MESSAGE =
"Traceback: group membership sync failed because the upstream API returned 502";
function jsonResponse(data: unknown, status = 200) {
return {
status,
contentType: "application/json",
body: JSON.stringify(data),
};
}
/**
* Minimal `CCPairFullInfo` shape needed for the page to render with
* `access_type === "sync"`. Fields not exercised by the assertions are
* filled with neutral defaults.
*/
function syncCCPairFixture() {
const now = new Date().toISOString();
return {
id: MOCK_SYNC_CC_PAIR_ID,
name: "Mock Sync Connector",
status: "ACTIVE",
in_repeated_error_state: false,
num_docs_indexed: 0,
connector: {
id: 12345,
name: "Mock Sync Connector",
source: MOCK_SOURCE,
input_type: "poll",
connector_specific_config: {},
refresh_freq: 600,
prune_freq: null,
indexing_start: null,
access_type: "sync",
credential_ids: [54321],
time_created: now,
time_updated: now,
},
credential: {
id: 54321,
name: "Mock Credential",
credential_json: {},
admin_public: true,
time_created: now,
time_updated: now,
source: MOCK_SOURCE,
user_id: null,
curator_public: true,
},
number_of_index_attempts: 0,
last_index_attempt_status: null,
latest_deletion_attempt: null,
access_type: "sync",
is_editable_for_current_user: true,
deletion_failure_message: null,
indexing: false,
creator: null,
creator_email: null,
last_indexed: null,
last_pruned: null,
last_full_permission_sync: null,
overall_indexing_speed: null,
latest_checkpoint_description: null,
last_permission_sync_attempt_status: null,
permission_syncing: false,
last_permission_sync_attempt_finished: null,
last_permission_sync_attempt_error_message: null,
};
}
interface SyncMockOptions {
/** Doc-permission attempts response. Must mirror `CCPairSyncAttemptsResponse`. */
docPermissions: {
applicable: boolean;
items: Array<Record<string, unknown>>;
total_items: number;
};
/** Group-membership attempts response. */
externalGroup: {
applicable: boolean;
items: Array<Record<string, unknown>>;
total_items: number;
};
}
/**
* Wires up route mocks for everything the connector-detail page fetches
* for `MOCK_SYNC_CC_PAIR_ID`. Other endpoints (auth, license, llm
* providers, etc.) are left untouched and hit the real backend.
*
* Routes are registered parent-first; Playwright runs them LIFO, so a
* request to `/cc-pair/99999/index-attempts?...` is handled by the
* `index-attempts` route, not the bare `/cc-pair/99999` route.
*/
async function mockSyncConnectorEndpoints(
page: Page,
{ docPermissions, externalGroup }: SyncMockOptions
): Promise<void> {
const base = `**/api/manage/admin/cc-pair/${MOCK_SYNC_CC_PAIR_ID}`;
await page.route(base, async (route) => {
if (route.request().method() === "GET") {
await route.fulfill(jsonResponse(syncCCPairFixture()));
return;
}
await route.continue();
});
await page.route(`${base}/index-attempts*`, async (route) => {
await route.fulfill(jsonResponse({ items: [], total_items: 0 }));
});
await page.route(`${base}/errors*`, async (route) => {
await route.fulfill(jsonResponse({ items: [], total_items: 0 }));
});
await page.route(
`${base}/permission-sync-attempts*`,
async (route: Route) => {
await route.fulfill(jsonResponse(docPermissions));
}
);
await page.route(
`${base}/external-group-sync-attempts*`,
async (route: Route) => {
await route.fulfill(jsonResponse(externalGroup));
}
);
}
test.describe("Permission sync tabs", () => {
test("non-sync connector: renders legacy Indexing Attempts table, no tabs", async ({
page,
}) => {
const apiClient = new OnyxApiClient(page.request);
const ccPairId = await apiClient.createFileConnector(
`E2E PermSyncTabs NonSync ${Date.now()}`
);
try {
await page.goto(`/admin/connector/${ccPairId}`);
await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Advanced" }).click();
await expect(
page.getByRole("heading", { name: "Indexing Attempts" })
).toBeVisible();
// No tab triggers should appear for a non-sync (file) connector.
await expect(page.getByRole("tab", { name: "Indexing" })).toHaveCount(0);
await expect(
page.getByRole("tab", { name: "Document Permission Sync" })
).toHaveCount(0);
await expect(
page.getByRole("tab", { name: "Group Membership Sync" })
).toHaveCount(0);
} finally {
await apiClient.deleteCCPair(ccPairId);
}
});
test("sync connector: renders all three tabs, doc tab shows rows, group tab shows not-applicable message", async ({
page,
}) => {
await mockSyncConnectorEndpoints(page, {
docPermissions: {
applicable: true,
items: [
{
id: MOCK_DOC_ATTEMPT_ID,
status: "success",
error_message: null,
total_docs_synced: 42,
docs_with_permission_errors: 0,
time_created: "2026-05-03T11:55:00Z",
time_started: "2026-05-03T12:00:00Z",
time_finished: "2026-05-03T12:01:30Z",
},
],
total_items: 1,
},
externalGroup: {
applicable: false,
items: [],
total_items: 0,
},
});
await page.goto(`/admin/connector/${MOCK_SYNC_CC_PAIR_ID}`);
await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Advanced" }).click();
const indexingTab = page.getByRole("tab", { name: "Indexing" });
const docPermissionsTab = page.getByRole("tab", {
name: "Document Permission Sync",
});
const groupMembershipTab = page.getByRole("tab", {
name: "Group Membership Sync",
});
await expect(indexingTab).toBeVisible();
await expect(docPermissionsTab).toBeVisible();
await expect(groupMembershipTab).toBeVisible();
// Indexing is the default — the legacy "Indexing Attempts" header
// does NOT render inside the tabbed flow; the tab triggers are the
// visual header now.
await expect(indexingTab).toHaveAttribute("data-state", "active");
await docPermissionsTab.click();
await expect(docPermissionsTab).toHaveAttribute("data-state", "active");
// The DocPermissionSyncAttemptsTable renders column headers when
// attempts.length > 0; the "Docs Synced" header is a stable signal
// that the table (not the empty/not-applicable card) is showing.
await expect(
page.getByRole("columnheader", { name: "Docs Synced" })
).toBeVisible();
await expect(
page.getByText("No document permission sync attempts yet")
).toHaveCount(0);
await groupMembershipTab.click();
await expect(groupMembershipTab).toHaveAttribute("data-state", "active");
// The explicit not-applicable message — distinct from the empty
// "no attempts scheduled yet" state on a fresh applicable tab.
await expect(
page.getByText(
"This connector does not use a separate group-membership syncing job."
)
).toBeVisible();
// And the table headers from DocPermissions should NOT bleed through —
// Radix's default Tabs.Content unmount confirms tabs are independent.
await expect(
page.getByRole("columnheader", { name: "Docs Synced" })
).toHaveCount(0);
});
test("sync connector: clicking a failed row's Error Message opens the trace modal in both sync tables", async ({
page,
}) => {
await mockSyncConnectorEndpoints(page, {
docPermissions: {
applicable: true,
items: [
{
id: MOCK_DOC_ATTEMPT_ID,
status: "failed",
error_message: MOCK_DOC_ERROR_MESSAGE,
total_docs_synced: 0,
docs_with_permission_errors: 0,
time_created: "2026-05-03T11:55:00Z",
time_started: "2026-05-03T12:00:00Z",
time_finished: "2026-05-03T12:01:30Z",
},
],
total_items: 1,
},
externalGroup: {
applicable: true,
items: [
{
id: MOCK_GROUP_ATTEMPT_ID,
status: "failed",
error_message: MOCK_GROUP_ERROR_MESSAGE,
total_users_processed: 0,
total_groups_processed: 0,
total_group_memberships_synced: 0,
time_created: "2026-05-03T11:55:00Z",
time_started: "2026-05-03T12:00:00Z",
time_finished: "2026-05-03T12:01:30Z",
},
],
total_items: 1,
},
});
await page.goto(`/admin/connector/${MOCK_SYNC_CC_PAIR_ID}`);
await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Advanced" }).click();
await page.getByRole("tab", { name: "Document Permission Sync" }).click();
// The error-message button is keyed on the cell's aria-label so the
// assertion stays robust if the truncated text changes.
await page.getByRole("button", { name: "View full error message" }).click();
const docModal = page.getByRole("dialog", {
name: "Document Permission Sync Error",
});
await expect(docModal).toBeVisible();
// `toContainText` concatenates text across descendants — needed
// because `CodePreview` runs the body through a syntax highlighter
// that splits each token into its own `<span>`.
await expect(docModal).toContainText(MOCK_DOC_ERROR_MESSAGE);
// Escape closes Radix dialogs; the listener restores focus to the
// trigger so re-entering the tab does not race with state cleanup.
await page.keyboard.press("Escape");
await expect(docModal).not.toBeVisible();
await page.getByRole("tab", { name: "Group Membership Sync" }).click();
await page.getByRole("button", { name: "View full error message" }).click();
const groupModal = page.getByRole("dialog", {
name: "Group Membership Sync Error",
});
await expect(groupModal).toBeVisible();
await expect(groupModal).toContainText(MOCK_GROUP_ERROR_MESSAGE);
});
});