From c7f3af48b4eb360195d2a0fbc560b987cc4dcafd Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Wed, 18 Dec 2024 18:20:00 +0100 Subject: [PATCH 1/7] Disallow editing test provider config from context menu --- .../src/components/TestProviderRender.tsx | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/code/addons/test/src/components/TestProviderRender.tsx b/code/addons/test/src/components/TestProviderRender.tsx index 29b0f950da8c..a4e0b5c73f8b 100644 --- a/code/addons/test/src/components/TestProviderRender.tsx +++ b/code/addons/test/src/components/TestProviderRender.tsx @@ -206,17 +206,19 @@ export const TestProviderRender: FC< - - {state.watchable && !entryId && ( + {!entryId && ( + + )} + {!entryId && state.watchable && ( + + )} {!entryId && state.watchable && ( - + + )} {state.runnable && ( <> {state.running && state.cancellable ? ( - + + ) : ( - + + )} )} diff --git a/code/core/src/manager/components/sidebar/LegacyRender.tsx b/code/core/src/manager/components/sidebar/LegacyRender.tsx index f8afa4317f7f..22b151c968ff 100644 --- a/code/core/src/manager/components/sidebar/LegacyRender.tsx +++ b/code/core/src/manager/components/sidebar/LegacyRender.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Button, ProgressSpinner } from '@storybook/core/components'; +import { Button, ProgressSpinner, TooltipNote, WithTooltip } from '@storybook/core/components'; import { styled } from '@storybook/core/theming'; import { EyeIcon, PlayHollowIcon, StopAltIcon } from '@storybook/icons'; @@ -61,46 +61,68 @@ export const LegacyRender = ({ ...state }: TestProviders[keyof TestProviders]) = {state.watchable && ( - + + )} {state.runnable && ( <> {state.running && state.cancellable ? ( - + + + + + ) : ( - + + )} )} diff --git a/code/core/src/manager/components/sidebar/TestingModule.tsx b/code/core/src/manager/components/sidebar/TestingModule.tsx index b66512007c08..9fe95426e597 100644 --- a/code/core/src/manager/components/sidebar/TestingModule.tsx +++ b/code/core/src/manager/components/sidebar/TestingModule.tsx @@ -274,21 +274,31 @@ export const TestingModule = ({ )} {hasTestProviders && ( - + } + trigger="hover" > - - + + + + )} {errorCount > 0 && ( From 6aabe377ad0345f9a25be7097c857ba383b3af18 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 20 Dec 2024 17:00:40 +0100 Subject: [PATCH 4/7] Refactor and improve title and description, and make pending status dot glow --- .../test/src/components/ContextMenuItem.tsx | 76 ------------------- .../test/src/components/Description.tsx | 21 +++-- .../src/components/TestProviderRender.tsx | 26 ++++--- .../src/components/TestStatusIcon.stories.tsx | 6 ++ .../test/src/components/TestStatusIcon.tsx | 8 +- code/addons/test/src/components/Title.tsx | 21 ----- .../components/sidebar/ContextMenu.tsx | 1 + 7 files changed, 44 insertions(+), 115 deletions(-) delete mode 100644 code/addons/test/src/components/ContextMenuItem.tsx delete mode 100644 code/addons/test/src/components/Title.tsx diff --git a/code/addons/test/src/components/ContextMenuItem.tsx b/code/addons/test/src/components/ContextMenuItem.tsx deleted file mode 100644 index 229a396b5388..000000000000 --- a/code/addons/test/src/components/ContextMenuItem.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React, { - type FC, - type SyntheticEvent, - useCallback, - useEffect, - useRef, - useState, -} from 'react'; - -import { Button, ListItem } from 'storybook/internal/components'; -import type { TestProviderConfig } from 'storybook/internal/core-events'; -import { useStorybookApi } from 'storybook/internal/manager-api'; -import { useTheme } from 'storybook/internal/theming'; -import { type API_HashEntry, type Addon_TestProviderState } from 'storybook/internal/types'; - -import { PlayHollowIcon, StopAltHollowIcon } from '@storybook/icons'; - -import { type Config, type Details, TEST_PROVIDER_ID } from '../constants'; -import { Description } from './Description'; -import { Title } from './Title'; - -export const ContextMenuItem: FC<{ - context: API_HashEntry; - state: TestProviderConfig & Addon_TestProviderState; -}> = ({ context, state }) => { - const api = useStorybookApi(); - const [isDisabled, setDisabled] = useState(false); - - const id = useRef(context.id); - id.current = context.id; - - const Icon = state.running ? StopAltHollowIcon : PlayHollowIcon; - - useEffect(() => { - setDisabled(false); - }, [state.running]); - - const onClick = useCallback( - (event: SyntheticEvent) => { - setDisabled(true); - event.stopPropagation(); - if (state.running) { - api.cancelTestProvider(TEST_PROVIDER_ID); - } else { - api.runTestProvider(TEST_PROVIDER_ID, { entryId: id.current }); - } - }, - [api, state.running] - ); - - const theme = useTheme(); - - return ( -
{ - // stopPropagation to prevent the parent from closing the context menu, which is the default behavior onClick - event.stopPropagation(); - }} - > - } - center={} - right={ - - } - /> -
- ); -}; diff --git a/code/addons/test/src/components/Description.tsx b/code/addons/test/src/components/Description.tsx index 58a80dbfdccc..3779ab641934 100644 --- a/code/addons/test/src/components/Description.tsx +++ b/code/addons/test/src/components/Description.tsx @@ -4,6 +4,7 @@ import { Link as LinkComponent } from 'storybook/internal/components'; import { type TestProviderConfig, type TestProviderState } from 'storybook/internal/core-events'; import { styled } from 'storybook/internal/theming'; +import type { TestResultResult } from '../node/reporter'; import { GlobalErrorContext } from './GlobalErrorModal'; import { RelativeTime } from './RelativeTime'; @@ -19,11 +20,13 @@ const PositiveText = styled.span(({ theme }) => ({ color: theme.color.positiveText, })); -interface DescriptionProps extends ComponentProps { +interface DescriptionProps extends Omit, 'results'> { state: TestProviderConfig & TestProviderState; + entryId?: string; + results?: TestResultResult[]; } -export function Description({ state, ...props }: DescriptionProps) { +export function Description({ state, entryId, results, ...props }: DescriptionProps) { const isMounted = React.useRef(false); const [isUpdated, setUpdated] = React.useState(false); const { setModalOpen } = React.useContext(GlobalErrorContext); @@ -48,15 +51,17 @@ export function Description({ state, ...props }: DescriptionProps) { description = state.progress ? `Testing... ${state.progress.numPassedTests}/${state.progress.numTotalTests}` : 'Starting...'; + } else if (entryId && results?.length) { + description = `Ran ${results.length} ${results.length === 1 ? 'test' : 'tests'}`; } else if (state.failed && !errorMessage) { description = 'Failed'; } else if (state.crashed || (state.failed && errorMessage)) { - description = ( - <> - setModalOpen(true)}> - {state.error?.name || 'View full error'} - - + description = setModalOpen ? ( + setModalOpen(true)}> + {state.error?.name || 'View full error'} + + ) : ( + state.error?.name || 'Failed' ); } else if (state.progress?.finishedAt) { description = ( diff --git a/code/addons/test/src/components/TestProviderRender.tsx b/code/addons/test/src/components/TestProviderRender.tsx index 139d114047bc..dd410ae22022 100644 --- a/code/addons/test/src/components/TestProviderRender.tsx +++ b/code/addons/test/src/components/TestProviderRender.tsx @@ -37,7 +37,6 @@ import { type Config, type Details, PANEL_ID } from '../constants'; import { type TestStatus } from '../node/reporter'; import { Description } from './Description'; import { TestStatusIcon } from './TestStatusIcon'; -import { Title } from './Title'; const Container = styled.div({ display: 'flex', @@ -58,6 +57,12 @@ const Info = styled.div({ minWidth: 0, }); +const Title = styled.div<{ crashed?: boolean }>(({ crashed, theme }) => ({ + fontSize: theme.typography.size.s1, + fontWeight: crashed ? 'bold' : 'normal', + color: crashed ? theme.color.negativeText : theme.color.defaultText, +})); + const Actions = styled.div({ display: 'flex', gap: 2, @@ -98,7 +103,7 @@ const statusMap: Record['statu warning: 'warning', passed: 'positive', skipped: 'unknown', - pending: 'unknown', + pending: 'pending', }; export const TestProviderRender: FC< @@ -191,11 +196,7 @@ export const TestProviderRender: FC< }) .sort((a, b) => statusOrder.indexOf(a.status) - statusOrder.indexOf(b.status)); - const status = state.running - ? 'unknown' - : state.failed - ? 'failed' - : (results[0]?.status ?? 'unknown'); + const status = results[0]?.status ?? (state.running ? 'pending' : 'unknown'); const openPanel = (id: string, panelId: string) => { api.selectStory(id); @@ -207,8 +208,15 @@ export const TestProviderRender: FC< - - <Description id="testing-module-description" state={state} /> + <Title id="testing-module-title" crashed={state.crashed}> + {state.crashed ? 'Local tests failed' : 'Run local tests'} + + diff --git a/code/addons/test/src/components/TestStatusIcon.stories.tsx b/code/addons/test/src/components/TestStatusIcon.stories.tsx index 3a38df50a801..e46d22e015d6 100644 --- a/code/addons/test/src/components/TestStatusIcon.stories.tsx +++ b/code/addons/test/src/components/TestStatusIcon.stories.tsx @@ -16,6 +16,12 @@ export const Unknown: Story = { }, }; +export const Pending: Story = { + args: { + status: 'pending', + }, +}; + export const Positive: Story = { args: { status: 'positive', diff --git a/code/addons/test/src/components/TestStatusIcon.tsx b/code/addons/test/src/components/TestStatusIcon.tsx index 7b201ce768c5..536b603dbfba 100644 --- a/code/addons/test/src/components/TestStatusIcon.tsx +++ b/code/addons/test/src/components/TestStatusIcon.tsx @@ -1,7 +1,7 @@ import { styled } from 'storybook/internal/theming'; export const TestStatusIcon = styled.div<{ - status: 'positive' | 'warning' | 'negative' | 'critical' | 'unknown'; + status: 'pending' | 'positive' | 'warning' | 'negative' | 'critical' | 'unknown'; percentage?: number; }>( ({ percentage }) => ({ @@ -13,6 +13,12 @@ export const TestStatusIcon = styled.div<{ : 'var(--status-color)', borderRadius: '50%', }), + ({ status, theme }) => + status === 'pending' && { + animation: `${theme.animation.glow} 1.5s ease-in-out infinite`, + '--status-color': theme.color.mediumdark, + '--status-background': `${theme.color.mediumdark}66`, + }, ({ status, theme }) => status === 'positive' && { '--status-color': theme.color.positive, diff --git a/code/addons/test/src/components/Title.tsx b/code/addons/test/src/components/Title.tsx deleted file mode 100644 index fecb454cffd8..000000000000 --- a/code/addons/test/src/components/Title.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React, { type ComponentProps } from 'react'; - -import { type TestProviderConfig, type TestProviderState } from 'storybook/internal/core-events'; -import { styled } from 'storybook/internal/theming'; - -const Wrapper = styled.div<{ crashed?: boolean }>(({ crashed, theme }) => ({ - fontSize: theme.typography.size.s1, - fontWeight: crashed ? 'bold' : 'normal', - color: crashed ? theme.color.negativeText : theme.color.defaultText, -})); - -export const Title = ({ - state, - ...props -}: { state: TestProviderConfig & TestProviderState } & ComponentProps) => { - return ( - - {state.crashed || state.failed ? 'Local tests failed' : 'Run local tests'} - - ); -}; diff --git a/code/core/src/manager/components/sidebar/ContextMenu.tsx b/code/core/src/manager/components/sidebar/ContextMenu.tsx index ac4aeb671d55..3392c7674eed 100644 --- a/code/core/src/manager/components/sidebar/ContextMenu.tsx +++ b/code/core/src/manager/components/sidebar/ContextMenu.tsx @@ -22,6 +22,7 @@ const empty = { const PositionedWithTooltip = styled(WithTooltip)({ position: 'absolute', right: 0, + zIndex: 1, }); const FloatingStatusButton = styled(StatusButton)({ From 55240ff73d7095ddf0e787fc969ed1d3229eb607 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Mon, 23 Dec 2024 15:43:04 +0100 Subject: [PATCH 5/7] Update tests --- .../components/TestProviderRender.stories.tsx | 2 +- .../react/e2e-tests/component-testing.spec.ts | 26 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/code/addons/test/src/components/TestProviderRender.stories.tsx b/code/addons/test/src/components/TestProviderRender.stories.tsx index 1189248f3d53..ebee7bf6a279 100644 --- a/code/addons/test/src/components/TestProviderRender.stories.tsx +++ b/code/addons/test/src/components/TestProviderRender.stories.tsx @@ -233,7 +233,7 @@ export const Editing: Story = { play: async ({ canvasElement }) => { const screen = within(canvasElement); - screen.getByLabelText(/Open settings/).click(); + screen.getByLabelText(/Show settings/).click(); }, }; diff --git a/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/component-testing.spec.ts b/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/component-testing.spec.ts index 9e88b7d0a579..ada0a6001721 100644 --- a/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/component-testing.spec.ts +++ b/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/component-testing.spec.ts @@ -137,7 +137,7 @@ test.describe("component testing", () => { await expect(testingModuleDescription).toContainText('Not run'); const runTestsButton = await page.getByLabel('Start Component Tests') - const watchModeButton = await page.getByLabel('Enable watch mode for Component tests') + const watchModeButton = await page.getByLabel('Enable watch mode') await expect(runTestsButton).toBeEnabled(); await expect(watchModeButton).toBeEnabled(); @@ -201,7 +201,7 @@ test.describe("component testing", () => { .getByRole("button", { name: "test" }); await expect(storyElement).toBeVisible({ timeout: 30000 }); - await page.getByLabel("Enable watch mode for Component tests").click(); + await page.getByLabel("Enable watch mode").click(); // We shouldn't have to do an arbitrary wait, but because there is no UI for loading state yet, we have to await page.waitForTimeout(8000); @@ -209,7 +209,7 @@ test.describe("component testing", () => { await page.waitForTimeout(500); // Cleanup, to ensure watch mode is disabled in the other tests - await page.getByLabel("Disable watch mode for Component tests").click(); + await page.getByLabel("Disable watch mode").click(); // Wait for test results to appear const errorFilter = page.getByLabel("Toggle errors"); @@ -265,10 +265,10 @@ test.describe("component testing", () => { await expect(page.getByLabel("Open coverage report")).toHaveCount(0); // Act - Enable coverage and run tests - await page.getByLabel("Open settings for Component tests").click(); + await page.getByLabel("Show settings").click(); await page.getByLabel("Coverage").click(); await expect(page.getByText("Settings updated")).toBeVisible({ timeout: 3000 }); - await page.getByLabel("Close settings for Component tests").click(); + await page.getByLabel("Hide settings").click(); // Wait for Vitest to have (re)started await page.waitForTimeout(2000); @@ -296,7 +296,7 @@ test.describe("component testing", () => { // Cleanup - Disable coverage again await page.goBack(); await expandButton.click(); - await page.getByLabel("Open settings for Component tests").click(); + await page.getByLabel("Show settings").click(); await page.getByLabel("Coverage").click(); await expect(page.getByText("Settings updated")).toBeVisible({ timeout: 3000 }); }); @@ -425,10 +425,10 @@ test.describe("component testing", () => { await expect(storyElement).toBeVisible({ timeout: 30000 }); // Act - Enable coverage - await page.getByLabel("Open settings for Component tests").click(); + await page.getByLabel("Show settings").click(); await page.getByLabel("Coverage").click(); await expect(page.getByText("Settings updated")).toBeVisible({ timeout: 3000 }); - await page.getByLabel("Close settings for Component tests").click(); + await page.getByLabel("Hide settings").click(); // Wait for Vitest to have (re)started await page.waitForTimeout(2000); @@ -437,20 +437,20 @@ test.describe("component testing", () => { await page.locator('[data-item-id="example-button--csf-3-primary"] div[data-testid="context-menu"] button').click(); const sidebarContextMenu = page.getByTestId('tooltip'); await sidebarContextMenu.getByLabel('Start Component tests').click(); - + // Arrange - Wait for test to finish and unfocus sidebar context menu await expect(sidebarContextMenu.locator('#testing-module-description')).toContainText('Ran 1 test', { timeout: 30000 }); await page.click('body'); - + // Assert - Coverage is not shown because Focused Tests shouldn't collect coverage await expect(page.getByLabel("Open coverage report")).not.toBeVisible(); - + // Act - Run ALL tests await page.getByLabel("Start Component tests").click(); - + // Arrange - Wait for tests to finish await expect(page.locator('#testing-module-description')).toContainText(/Ran \d{2,} tests/, { timeout: 30000 }); - + // Assert - Coverage percentage is now collected and shown because running all tests automatically re-enables coverage await expect(page.getByLabel("Open coverage report")).toBeVisible({ timeout: 30000 }); const sbPercentageText = await page.getByLabel(/percent coverage$/).textContent(); From a0702ec56490c71e6127028386fb9c173820e125 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Mon, 23 Dec 2024 16:29:39 +0100 Subject: [PATCH 6/7] Use fireEvent rather than userEvent --- .../components/sidebar/SidebarBottom.stories.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/code/core/src/manager/components/sidebar/SidebarBottom.stories.tsx b/code/core/src/manager/components/sidebar/SidebarBottom.stories.tsx index 22cf6e68ef96..d8d0936d5f27 100644 --- a/code/core/src/manager/components/sidebar/SidebarBottom.stories.tsx +++ b/code/core/src/manager/components/sidebar/SidebarBottom.stories.tsx @@ -1,11 +1,10 @@ -import React, { type FC, Fragment, useEffect, useState } from 'react'; +import React, { type FC, useEffect, useState } from 'react'; import { Addon_TypesEnum } from '@storybook/core/types'; import type { Meta, StoryObj } from '@storybook/react'; -import { expect, fn, waitFor, within } from '@storybook/test'; +import { expect, fireEvent, fn, waitFor, within } from '@storybook/test'; import { type API, ManagerContext } from '@storybook/core/manager-api'; -import { userEvent } from '@storybook/testing-library'; import { SidebarBottomBase } from './SidebarBottom'; @@ -156,18 +155,18 @@ export const DynamicHeight: StoryObj = { const screen = await within(canvasElement); const toggleButton = await screen.getByLabelText(/Expand/); - await userEvent.click(toggleButton); + await fireEvent.click(toggleButton); const content = await screen.findByText('CUSTOM CONTENT WITH DYNAMIC HEIGHT'); const collapse = await screen.getByTestId('collapse'); await expect(content).toBeVisible(); - await userEvent.click(toggleButton); + await fireEvent.click(toggleButton); await waitFor(() => expect(collapse.getBoundingClientRect()).toHaveProperty('height', 0)); - await userEvent.click(toggleButton); + await fireEvent.click(toggleButton); await waitFor(() => expect(collapse.getBoundingClientRect()).not.toHaveProperty('height', 0)); }, From b82d3b0eee82aaf52049e8155233dde78d9eb009 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Mon, 23 Dec 2024 16:56:03 +0100 Subject: [PATCH 7/7] Update E2E tests --- .../react/e2e-tests/component-testing.spec.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/component-testing.spec.ts b/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/component-testing.spec.ts index ada0a6001721..c9ddd5ceba4a 100644 --- a/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/component-testing.spec.ts +++ b/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/component-testing.spec.ts @@ -74,7 +74,7 @@ test.describe("component testing", () => { await expect(testingModuleDescription).toContainText('Not run'); - const runTestsButton = await page.getByLabel('Start Component tests') + const runTestsButton = await page.getByLabel('Start test run') await runTestsButton.click(); await expect(testingModuleDescription).toContainText('Testing', { timeout: 60000 }); @@ -136,7 +136,7 @@ test.describe("component testing", () => { await expect(testingModuleDescription).toContainText('Not run'); - const runTestsButton = await page.getByLabel('Start Component Tests') + const runTestsButton = await page.getByLabel('Start test run') const watchModeButton = await page.getByLabel('Enable watch mode') await expect(runTestsButton).toBeEnabled(); await expect(watchModeButton).toBeEnabled(); @@ -272,7 +272,7 @@ test.describe("component testing", () => { // Wait for Vitest to have (re)started await page.waitForTimeout(2000); - await page.getByLabel("Start Component tests").click(); + await page.getByLabel("Start test run").click(); // Assert - Coverage report is collected and shown await expect(page.getByLabel("Open coverage report")).toBeVisible({ timeout: 30000 }); @@ -324,7 +324,7 @@ test.describe("component testing", () => { await page.locator('[data-item-id="addons-group-test--expected-failure"]').hover(); await page.locator('[data-item-id="addons-group-test--expected-failure"] div[data-testid="context-menu"] button').click(); const sidebarContextMenu = page.getByTestId('tooltip'); - await sidebarContextMenu.getByLabel('Start Component tests').click(); + await sidebarContextMenu.getByLabel('Start test run').click(); // Assert - Only one test is running and reported await expect(sidebarContextMenu.locator('#testing-module-description')).toContainText('Ran 1 test', { timeout: 30000 }); @@ -356,7 +356,7 @@ test.describe("component testing", () => { await page.locator('[data-item-id="addons-group-test"]').hover(); await page.locator('[data-item-id="addons-group-test"] div[data-testid="context-menu"] button').click(); const sidebarContextMenu = page.getByTestId('tooltip'); - await sidebarContextMenu.getByLabel('Start Component tests').click(); + await sidebarContextMenu.getByLabel('Start test run').click(); // Assert - Tests are running and reported await expect(sidebarContextMenu.locator('#testing-module-description')).toContainText('Ran 8 tests', { timeout: 30000 }); @@ -392,7 +392,7 @@ test.describe("component testing", () => { await page.locator('[data-item-id="addons-group"]').hover(); await page.locator('[data-item-id="addons-group"] div[data-testid="context-menu"] button').click(); const sidebarContextMenu = page.getByTestId('tooltip'); - await sidebarContextMenu.getByLabel('Start Component tests').click(); + await sidebarContextMenu.getByLabel('Start test run').click(); // Assert - Tests are running and reported await expect(sidebarContextMenu.locator('#testing-module-description')).toContainText('Ran 10 test', { timeout: 30000 }); @@ -436,7 +436,7 @@ test.describe("component testing", () => { await page.locator('[data-item-id="example-button--csf-3-primary"]').hover(); await page.locator('[data-item-id="example-button--csf-3-primary"] div[data-testid="context-menu"] button').click(); const sidebarContextMenu = page.getByTestId('tooltip'); - await sidebarContextMenu.getByLabel('Start Component tests').click(); + await sidebarContextMenu.getByLabel('Start test run').click(); // Arrange - Wait for test to finish and unfocus sidebar context menu await expect(sidebarContextMenu.locator('#testing-module-description')).toContainText('Ran 1 test', { timeout: 30000 }); @@ -446,7 +446,7 @@ test.describe("component testing", () => { await expect(page.getByLabel("Open coverage report")).not.toBeVisible(); // Act - Run ALL tests - await page.getByLabel("Start Component tests").click(); + await page.getByLabel("Start test run").click(); // Arrange - Wait for tests to finish await expect(page.locator('#testing-module-description')).toContainText(/Ran \d{2,} tests/, { timeout: 30000 });