feat: Composio Gmail component and AuthInput feature (#7364)
* old composio Gmail component * Update gmail_composio.py * [autofix.ci] apply automated fixes * Removed input types from secret input * Changed starter projects * Update gmail_composio.py * composio base * [autofix.ci] apply automated fixes * updated composio with multi output * [autofix.ci] apply automated fixes * fix lint errors * [autofix.ci] apply automated fixes * Added sortableList and connect to frontend types constant * Added AuthInput to backend and frontend constant * Added auth input to InputTypes and added show = false by default * fix: Update Composio icon (#7407) fix: update Composio icon dimensions and simplify SVG structure * Fix amber color * Fix button and voice assistant button to use correct design and colors * Fixed button design to include bg * remove bg definition from voice assistant * Added auth input to composio base * Added helper text to sortable list * Add unlink icon * Add node connection button * Changed to isPolling * [autofix.ci] apply automated fixes * Added auth tooltip * Added auth tooltip to mixinn * Add auth mixin to input * update the field visibility * Fixed disconnect * Update composio_base.py * Updated node status to show correct statuses * Added handling for API errors and disconnections * limit to dataframe output * add basic tests for base and gmail component * fix lint errors * 📝 (test files): Remove unnecessary blank lines to improve code readability and consistency. * Add result_field to GMAIL_FETCH_EMAILS action and change how result key is used * fix: Add validation for result structure in ComposioGmailAPIComponent * fix: Ensure result is a list of dicts before converting to DataFrame in ComposioBaseComponent * feat: Introduce get_result_field option for Gmail actions to control result retrieval behavior * Fixed status not updating in real time * Added default API value to Composio * Made sortableList only be openable if no helper text is present * fix: Update validation logic in ComposioGmailAPIComponent to incorporate get_result_field option for improved result handling * Fixed bug where pre-filled Global Variable didn't trigger login * refactor: Remove commented-out output definitions in ComposioBaseComponent for cleaner code * refactor: Clean up ComposioGmailAPIComponent by removing outdated comments for improved readability * ✨ (NodeStatus/index.tsx): refactor getConnectionButtonClasses and getConnectionIconClasses functions to improve code readability and maintainability * ♻️ (NodeStatus/index.tsx): refactor getConnectionButtonClasses and getConnectionIconClasses functions to use arrow function syntax for better readability and maintainability * 🔧 (NodeStatus/index.tsx): define constants POLLING_TIMEOUT and POLLING_INTERVAL for better readability and maintainability * ✨ (ListSelectionComponent): Add dataTestId prop to ListItem component for better testing 📝 (NodeStatus): Refactor data-testid value to be dynamically generated based on node status 📝 (searchBarComponent): Add data-testid attribute to search input for testing purposes 📝 (sortableListComponent): Add data-testid attribute to button for opening list selection ♻️ (utils.ts): Add testIdCase function to convert string to snake_case for test ids 📝 (composio.spec.ts): Add various test cases for interacting with composio component * ✨ (test_gmail.py): add MagicMock import to fix missing dependency for testing 🔧 (test_gmail.py): refactor execute_action method to return a structure compatible with component's logic ♻️ (test_gmail.py): refactor _build_wrapper method to return a mock for the toolset ✨ (test_gmail.py): add patching for _actions_data to ensure correct structure for GMAIL_FETCH_EMAILS 🔧 (test_gmail.py): refactor execute_action method to return mock data for testing as_dataframe method 🔧 (test_gmail.py): refactor as_dataframe method to handle mock email data and verify DataFrame content 🔧 (test_gmail.py): refactor execute_action method to return mock data for testing update_build_config method 🔧 (secretKeyModal/index.tsx): remove unused imports and clean up the file structure * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Lucas Oliveira <lucas.edu.oli@hotmail.com> Co-authored-by: Deon Sanchez <69873175+deon-sanchez@users.noreply.github.com> Co-authored-by: Lucas Oliveira <62335616+lucaseduoli@users.noreply.github.com> Co-authored-by: cristhianzl <cristhian.lousa@gmail.com> Co-authored-by: Gabriel Luiz Freitas Almeida <gabriel@langflow.org>
This commit is contained in:
parent
f9a7c9bcef
commit
8b4cf7b1db
18 changed files with 1363 additions and 14 deletions
|
|
@ -3,7 +3,7 @@ import ShadTooltip from "@/components/common/shadTooltipComponent";
|
|||
import SearchBarComponent from "@/components/core/parameterRenderComponent/components/searchBarComponent";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog-with-no-close";
|
||||
import { cn } from "@/utils/utils";
|
||||
import { cn, testIdCase } from "@/utils/utils";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
// Update interface with better types
|
||||
|
|
@ -27,6 +27,7 @@ const ListItem = ({
|
|||
onMouseLeave,
|
||||
isFocused,
|
||||
isKeyboardNavActive,
|
||||
dataTestId,
|
||||
}: {
|
||||
item: any;
|
||||
isSelected: boolean;
|
||||
|
|
@ -36,6 +37,7 @@ const ListItem = ({
|
|||
onMouseLeave: () => void;
|
||||
isFocused: boolean;
|
||||
isKeyboardNavActive: boolean;
|
||||
dataTestId: string;
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const itemRef = useRef<HTMLButtonElement>(null);
|
||||
|
|
@ -58,6 +60,7 @@ const ListItem = ({
|
|||
<Button
|
||||
ref={itemRef}
|
||||
key={item.id}
|
||||
data-testid={dataTestId}
|
||||
unstyled
|
||||
size="sm"
|
||||
className={cn(
|
||||
|
|
@ -308,6 +311,7 @@ const ListSelectionComponent = ({
|
|||
}}
|
||||
isFocused={focusedIndex === index}
|
||||
isKeyboardNavActive={isKeyboardNavActive}
|
||||
dataTestId={`list_item_${testIdCase(item.name)}`}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
import { getSpecificClassFromBuildStatus } from "@/CustomNodes/helpers/get-class-from-build-status";
|
||||
import useIconStatus from "@/CustomNodes/hooks/use-icons-status";
|
||||
import useUpdateValidationStatus from "@/CustomNodes/hooks/use-update-validation-status";
|
||||
import useValidationStatusString from "@/CustomNodes/hooks/use-validation-status-string";
|
||||
import ShadTooltip from "@/components/common/shadTooltipComponent";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ICON_STROKE_WIDTH } from "@/constants/constants";
|
||||
import { BuildStatus, EventDeliveryType } from "@/constants/enums";
|
||||
import { useGetConfig } from "@/controllers/API/queries/config/use-get-config";
|
||||
import { usePostTemplateValue } from "@/controllers/API/queries/nodes/use-post-template-value";
|
||||
import { track } from "@/customization/utils/analytics";
|
||||
import { getSpecificClassFromBuildStatus } from "@/CustomNodes/helpers/get-class-from-build-status";
|
||||
import { mutateTemplate } from "@/CustomNodes/helpers/mutate-template";
|
||||
import useIconStatus from "@/CustomNodes/hooks/use-icons-status";
|
||||
import useUpdateValidationStatus from "@/CustomNodes/hooks/use-update-validation-status";
|
||||
import useValidationStatusString from "@/CustomNodes/hooks/use-validation-status-string";
|
||||
import useAlertStore from "@/stores/alertStore";
|
||||
import { useDarkStore } from "@/stores/darkStore";
|
||||
import useFlowStore from "@/stores/flowStore";
|
||||
import { useShortcutsStore } from "@/stores/shortcuts";
|
||||
|
|
@ -24,6 +27,9 @@ import IconComponent from "../../../../components/common/genericIconComponent";
|
|||
import BuildStatusDisplay from "./components/build-status-display";
|
||||
import { normalizeTimeString } from "./utils/format-run-time";
|
||||
|
||||
const POLLING_TIMEOUT = 21000;
|
||||
const POLLING_INTERVAL = 3000;
|
||||
|
||||
export default function NodeStatus({
|
||||
nodeId,
|
||||
display_name,
|
||||
|
|
@ -57,6 +63,17 @@ export default function NodeStatus({
|
|||
const [validationString, setValidationString] = useState<string>("");
|
||||
const [validationStatus, setValidationStatus] =
|
||||
useState<VertexBuildTypeAPI | null>(null);
|
||||
const [isPolling, setIsPolling] = useState(false);
|
||||
|
||||
const nodeAuth = Object.values(data.node?.template ?? {}).find(
|
||||
(value) => value.type === "auth",
|
||||
);
|
||||
|
||||
const connectionLink = nodeAuth?.value;
|
||||
const isAuthenticated = nodeAuth?.value === "validated";
|
||||
|
||||
const pollingInterval = useRef<NodeJS.Timeout | null>(null);
|
||||
const pollingTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const conditionSuccess =
|
||||
buildStatus === BuildStatus.BUILT ||
|
||||
|
|
@ -71,11 +88,82 @@ export default function NodeStatus({
|
|||
const setNode = useFlowStore((state) => state.setNode);
|
||||
const version = useDarkStore((state) => state.version);
|
||||
const config = useGetConfig();
|
||||
const setErrorData = useAlertStore((state) => state.setErrorData);
|
||||
|
||||
const postTemplateValue = usePostTemplateValue({
|
||||
parameterId: nodeAuth?.name ?? "auth",
|
||||
nodeId: nodeId,
|
||||
node: data.node,
|
||||
});
|
||||
|
||||
const shouldStreamEvents = () => {
|
||||
// Get from useGetConfig store
|
||||
return config.data?.event_delivery === EventDeliveryType.STREAMING;
|
||||
};
|
||||
|
||||
// Start polling when connection is initiated
|
||||
const startPolling = () => {
|
||||
window.open(connectionLink, "_blank");
|
||||
stopPolling();
|
||||
|
||||
setIsPolling(true);
|
||||
|
||||
pollingInterval.current = setInterval(() => {
|
||||
mutateTemplate(
|
||||
{ validate: data.node?.template?.auth?.value || "" },
|
||||
data.node,
|
||||
(newNode) => {
|
||||
setNode(nodeId, (old) => ({
|
||||
...old,
|
||||
data: { ...old.data, node: newNode },
|
||||
}));
|
||||
},
|
||||
postTemplateValue,
|
||||
setErrorData,
|
||||
nodeAuth?.name ?? "auth_link",
|
||||
() => {},
|
||||
data.node.tool_mode,
|
||||
);
|
||||
}, POLLING_INTERVAL);
|
||||
|
||||
pollingTimeout.current = setTimeout(() => {
|
||||
stopPolling();
|
||||
}, POLLING_TIMEOUT);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
stopPolling();
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const handleDisconnect = () => {
|
||||
setIsPolling(true);
|
||||
mutateTemplate(
|
||||
"disconnect",
|
||||
data.node,
|
||||
(newNode) => {
|
||||
setNode(nodeId, (old) => ({
|
||||
...old,
|
||||
data: { ...old.data, node: newNode },
|
||||
}));
|
||||
},
|
||||
postTemplateValue,
|
||||
setErrorData,
|
||||
nodeAuth?.name ?? "auth_link",
|
||||
() => {
|
||||
setIsPolling(false);
|
||||
},
|
||||
data.node.tool_mode,
|
||||
);
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
setIsPolling(false);
|
||||
|
||||
if (pollingInterval.current) clearInterval(pollingInterval.current);
|
||||
if (pollingTimeout.current) clearTimeout(pollingTimeout.current);
|
||||
};
|
||||
|
||||
function handlePlayWShortcut() {
|
||||
if (buildStatus === BuildStatus.BUILDING || isBuilding || !selected) return;
|
||||
setValidationStatus(null);
|
||||
|
|
@ -107,6 +195,7 @@ export default function NodeStatus({
|
|||
: "";
|
||||
return cn(frozen ? frozenClass : className, updateClass);
|
||||
};
|
||||
|
||||
const getNodeBorderClassName = (
|
||||
selected: boolean | undefined,
|
||||
buildStatus: BuildStatus | undefined,
|
||||
|
|
@ -181,7 +270,6 @@ export default function NodeStatus({
|
|||
: "Loader2"
|
||||
: "Play";
|
||||
|
||||
// Keep the existing icon classes
|
||||
const iconClasses = cn(
|
||||
"play-button-icon",
|
||||
isHovered ? "text-foreground" : "text-placeholder-foreground",
|
||||
|
|
@ -196,9 +284,69 @@ export default function NodeStatus({
|
|||
return "Run component";
|
||||
};
|
||||
|
||||
const handleClickConnect = () => {
|
||||
if (connectionLink === "error") return;
|
||||
if (isAuthenticated) {
|
||||
handleDisconnect();
|
||||
} else {
|
||||
startPolling();
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectionButtonClasses: (
|
||||
connectionLink: string,
|
||||
isAuthenticated: boolean,
|
||||
isPolling: boolean,
|
||||
) => string = (
|
||||
connectionLink: string,
|
||||
isAuthenticated: boolean,
|
||||
isPolling: boolean,
|
||||
): string => {
|
||||
return cn(
|
||||
"nodrag button-run-bg hit-area-icon group relative h-5 w-5 rounded-sm border border-accent-amber-foreground transition-colors hover:bg-accent-amber",
|
||||
connectionLink === "error"
|
||||
? "border-destructive text-destructive"
|
||||
: isAuthenticated && !isPolling
|
||||
? "border-accent-emerald-foreground hover:border-accent-amber-foreground"
|
||||
: "",
|
||||
connectionLink === "" && "cursor-not-allowed opacity-50",
|
||||
);
|
||||
};
|
||||
|
||||
const getConnectionIconClasses: (
|
||||
connectionLink: string,
|
||||
isAuthenticated: boolean,
|
||||
isPolling: boolean,
|
||||
) => string = (
|
||||
connectionLink: string,
|
||||
isAuthenticated: boolean,
|
||||
isPolling: boolean,
|
||||
): string => {
|
||||
return cn(
|
||||
"h-3 w-3 transition-opacity",
|
||||
connectionLink === "error"
|
||||
? "text-destructive"
|
||||
: isAuthenticated && !isPolling
|
||||
? "text-accent-emerald-foreground"
|
||||
: "text-accent-amber-foreground",
|
||||
isPolling && "animate-spin",
|
||||
isAuthenticated && !isPolling ? "group-hover:opacity-0" : "",
|
||||
);
|
||||
};
|
||||
|
||||
const getDataTestId = () => {
|
||||
if (isAuthenticated && !isPolling) {
|
||||
return `button_connected_${display_name.toLowerCase()}`;
|
||||
}
|
||||
if (connectionLink === "error") {
|
||||
return `button_error_${display_name.toLowerCase()}`;
|
||||
}
|
||||
return `button_disconnected_${display_name.toLowerCase()}`;
|
||||
};
|
||||
|
||||
return showNode ? (
|
||||
<>
|
||||
<div className="flex flex-shrink-0 items-center">
|
||||
<div className="flex flex-shrink-0 items-center gap-1">
|
||||
<div className="flex items-center gap-2 self-center">
|
||||
<ShadTooltip
|
||||
styleClasses={cn(
|
||||
|
|
@ -242,6 +390,55 @@ export default function NodeStatus({
|
|||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{nodeAuth && (
|
||||
<ShadTooltip content={nodeAuth.auth_tooltip || "Connect"}>
|
||||
<div>
|
||||
{showNode && (
|
||||
<Button
|
||||
unstyled
|
||||
disabled={connectionLink === "" || connectionLink === "error"}
|
||||
className={getConnectionButtonClasses(
|
||||
connectionLink,
|
||||
isAuthenticated,
|
||||
isPolling,
|
||||
)}
|
||||
onClick={handleClickConnect}
|
||||
data-testid={getDataTestId()}
|
||||
>
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<IconComponent
|
||||
name={
|
||||
isPolling
|
||||
? "Loader2"
|
||||
: isAuthenticated
|
||||
? "Link"
|
||||
: "AlertTriangle"
|
||||
}
|
||||
className={getConnectionIconClasses(
|
||||
connectionLink,
|
||||
isAuthenticated,
|
||||
isPolling,
|
||||
)}
|
||||
strokeWidth={ICON_STROKE_WIDTH}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<IconComponent
|
||||
name={"Unlink"}
|
||||
className={cn(
|
||||
"h-3 w-3 text-accent-amber-foreground opacity-0 transition-opacity",
|
||||
isAuthenticated && !isPolling
|
||||
? "group-hover:opacity-100"
|
||||
: "",
|
||||
)}
|
||||
strokeWidth={ICON_STROKE_WIDTH}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</ShadTooltip>
|
||||
)}
|
||||
<ShadTooltip content={getTooltipContent()}>
|
||||
<div
|
||||
ref={divRef}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ const SearchBarComponent = ({
|
|||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
inputClassName="border-none focus:ring-0"
|
||||
data-testid="search_bar_input"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import ForwardedIconComponent from "@/components/common/genericIconComponent";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import ListSelectionComponent from "@/CustomNodes/GenericNode/components/ListSelectionComponent";
|
||||
import { cn } from "@/utils/utils";
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ReactSortable } from "react-sortablejs";
|
||||
import { InputProps } from "../../types";
|
||||
import HelperTextComponent from "../helperTextComponent";
|
||||
|
|
@ -118,8 +118,23 @@ const SortableListComponent = ({
|
|||
}, []);
|
||||
|
||||
const handleOpenListSelectionDialog = useCallback(() => {
|
||||
setOpen(true);
|
||||
}, []);
|
||||
if (helperText) {
|
||||
setShowHelperText(true);
|
||||
} else {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [helperText]);
|
||||
|
||||
const [showHelperText, setShowHelperText] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!helperText) {
|
||||
setShowHelperText(false);
|
||||
}
|
||||
if (helperText && open) {
|
||||
setOpen(false);
|
||||
}
|
||||
}, [helperText, open]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
|
|
@ -131,6 +146,7 @@ const SortableListComponent = ({
|
|||
role="combobox"
|
||||
onClick={handleOpenListSelectionDialog}
|
||||
className="dropdown-component-outline input-edit-node w-full py-2"
|
||||
data-testid="button_open_list_selection"
|
||||
>
|
||||
<div className={cn("flex items-center text-sm font-semibold")}>
|
||||
{placeholder}
|
||||
|
|
@ -159,7 +175,7 @@ const SortableListComponent = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{helperText && (
|
||||
{helperText && showHelperText && (
|
||||
<div className="pt-2">
|
||||
<HelperTextComponent
|
||||
helperText={helperText}
|
||||
|
|
|
|||
|
|
@ -657,6 +657,7 @@ export const LANGFLOW_SUPPORTED_TYPES = new Set([
|
|||
"tab",
|
||||
"sortableList",
|
||||
"connect",
|
||||
"auth",
|
||||
]);
|
||||
|
||||
export const FLEX_VIEW_TYPES = ["bool"];
|
||||
|
|
|
|||
|
|
@ -240,6 +240,7 @@ import {
|
|||
Type,
|
||||
Undo,
|
||||
Ungroup,
|
||||
UnlinkIcon,
|
||||
Unplug,
|
||||
Upload,
|
||||
User,
|
||||
|
|
@ -709,6 +710,7 @@ export const nodeIconsLucide: iconsType = {
|
|||
EverNoteLoader: EvernoteIcon,
|
||||
FacebookChatLoader: FBIcon,
|
||||
FirecrawlCrawlApi: FirecrawlIcon,
|
||||
Unlink: UnlinkIcon,
|
||||
FirecrawlScrapeApi: FirecrawlIcon,
|
||||
FirecrawlMapApi: FirecrawlIcon,
|
||||
FirecrawlExtractApi: FirecrawlIcon,
|
||||
|
|
|
|||
|
|
@ -855,3 +855,13 @@ export function setCookie(
|
|||
|
||||
document.cookie = cookieString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a string to snake_case
|
||||
* Example: "New York" becomes "new_york"
|
||||
* @param {string} str - The string to convert
|
||||
* @returns {string} The snake_case string
|
||||
*/
|
||||
export function testIdCase(str: string): string {
|
||||
return str.toLowerCase().replace(/\s+/g, "_");
|
||||
}
|
||||
|
|
|
|||
65
src/frontend/tests/core/features/composio.spec.ts
Normal file
65
src/frontend/tests/core/features/composio.spec.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
import { adjustScreenView } from "../../utils/adjust-screen-view";
|
||||
import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
|
||||
import { removeOldApiKeys } from "../../utils/remove-old-api-keys";
|
||||
|
||||
test(
|
||||
"user should be able to interact with composio component",
|
||||
{ tag: ["@release", "@workspace", "@api"] },
|
||||
async ({ page, context }) => {
|
||||
test.skip(
|
||||
!process?.env?.COMPOSIO_API_KEY,
|
||||
"COMPOSIO_API_KEY required to run this test",
|
||||
);
|
||||
|
||||
await awaitBootstrapTest(page);
|
||||
|
||||
await page.waitForSelector('[data-testid="blank-flow"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await page.getByTestId("blank-flow").click();
|
||||
await page.waitForSelector('[data-testid="sidebar-search-input"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await page.getByTestId("sidebar-search-input").click();
|
||||
await page.getByTestId("sidebar-search-input").fill("gmail");
|
||||
|
||||
await page
|
||||
.getByTestId("composioGmail")
|
||||
.hover()
|
||||
.then(async (): Promise<void> => {
|
||||
await page.getByTestId("add-component-button-gmail").click();
|
||||
});
|
||||
|
||||
await removeOldApiKeys(page);
|
||||
|
||||
await page
|
||||
.getByTestId("popover-anchor-input-api_key")
|
||||
.fill(process.env.COMPOSIO_API_KEY!);
|
||||
|
||||
await page.waitForSelector('[data-testid="button_connected_gmail"]', {
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
await page.getByTestId("button_open_list_selection").click();
|
||||
|
||||
await page.getByTestId("search_bar_input").fill("fetch emails");
|
||||
|
||||
await page.getByTestId(`list_item_fetch_emails`).click();
|
||||
|
||||
await page.getByTestId("int_int_max_results").fill("10");
|
||||
|
||||
await page.getByTestId("button_run_gmail").click();
|
||||
|
||||
await page.waitForSelector("text=built successfully", {
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await page.getByTestId("output-inspection-dataframe-gmailapi").click();
|
||||
|
||||
const colNumber: number = await page.getByRole("gridcell").count();
|
||||
expect(colNumber).toBeGreaterThan(9);
|
||||
},
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue