diff --git a/browser_tests/fixtures/ComfyPage.ts b/browser_tests/fixtures/ComfyPage.ts index 2d5df88b0..befc3cc0a 100644 --- a/browser_tests/fixtures/ComfyPage.ts +++ b/browser_tests/fixtures/ComfyPage.ts @@ -13,6 +13,7 @@ import { ComfyTemplates } from '../helpers/templates' import { ComfyMouse } from './ComfyMouse' import { VueNodeHelpers } from './VueNodeHelpers' import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox' +import { ComfyPropertiesPanel } from './components/ComfyPropertiesPanel' import { SettingDialog } from './components/SettingDialog' import { NodeLibrarySidebarTab, @@ -26,18 +27,6 @@ dotenv.config() type WorkspaceStore = ReturnType -class ComfyPropertiesPanel { - readonly root: Locator - readonly panelTitle: Locator - readonly searchBox: Locator - - constructor(readonly page: Page) { - this.root = page.getByTestId('properties-panel') - this.panelTitle = this.root.locator('h3') - this.searchBox = this.root.getByPlaceholder('Search...') - } -} - class ComfyMenu { private _nodeLibraryTab: NodeLibrarySidebarTab | null = null private _workflowsTab: WorkflowsSidebarTab | null = null @@ -170,6 +159,7 @@ export class ComfyPage { public readonly settingDialog: SettingDialog public readonly confirmDialog: ConfirmDialog public readonly vueNodes: VueNodeHelpers + public readonly propertiesPanel: ComfyPropertiesPanel /** Worker index to test user ID */ public readonly userIds: string[] = [] @@ -202,6 +192,7 @@ export class ComfyPage { this.settingDialog = new SettingDialog(page, this) this.confirmDialog = new ConfirmDialog(page) this.vueNodes = new VueNodeHelpers(page) + this.propertiesPanel = new ComfyPropertiesPanel(page) } convertLeafToContent(structure: FolderStructure): FolderStructure { diff --git a/browser_tests/fixtures/components/ComfyPropertiesPanel.ts b/browser_tests/fixtures/components/ComfyPropertiesPanel.ts new file mode 100644 index 000000000..60e58925b --- /dev/null +++ b/browser_tests/fixtures/components/ComfyPropertiesPanel.ts @@ -0,0 +1,84 @@ +import type { Locator, Page } from '@playwright/test' + +export class ComfyPropertiesPanel { + readonly root: Locator + readonly header: Locator + readonly panelTitle: Locator + readonly nodeTitleInput: Locator + readonly closeButton: Locator + readonly searchBox: Locator + readonly tabList: Locator + + constructor(readonly page: Page) { + this.root = page.getByTestId('properties-panel') + this.header = this.root.locator('section').first() + this.panelTitle = this.root.locator('h3') + this.nodeTitleInput = this.root.getByTestId('node-title-input') + this.closeButton = this.root.getByRole('button', { name: 'Close' }) + this.searchBox = this.root.getByPlaceholder('Search...') + this.tabList = this.root.locator('[role="tablist"]') + } + + getTab(tabName: string): Locator { + return this.tabList.getByRole('tab', { name: tabName }) + } + + async clickTab(tabName: string) { + await this.getTab(tabName).click() + } + + async close() { + await this.closeButton.click() + } + + async editTitle(newTitle: string) { + await this.panelTitle.click() + await this.nodeTitleInput.fill(newTitle) + await this.nodeTitleInput.press('Enter') + } + + async cancelTitleEdit() { + await this.panelTitle.click() + await this.nodeTitleInput.press('Escape') + } + + getNodeSection(nodeTitle: string): Locator { + return this.root.locator(`[data-testid="node-section-${nodeTitle}"]`) + } + + getAccordionItem(label: string): Locator { + return this.root.locator('.border-b', { hasText: label }) + } + + get globalSettingsSection() { + return { + nodes: this.getAccordionItem('Nodes'), + canvas: this.getAccordionItem('Canvas'), + connectionLinks: this.getAccordionItem('Connection Links'), + viewAllSettingsButton: this.root.getByRole('button', { + name: 'View all settings' + }) + } + } + + async toggleSwitch(switchLabel: string) { + const switchContainer = this.root.locator('label', { + hasText: switchLabel + }) + const toggle = switchContainer.locator('button[role="switch"]') + await toggle.click() + } + + async getSwitchState(switchLabel: string): Promise { + const switchContainer = this.root.locator('label', { + hasText: switchLabel + }) + const toggle = switchContainer.locator('button[role="switch"]') + const ariaChecked = await toggle.getAttribute('aria-checked') + return ariaChecked === 'true' + } + + get noResultsMessage(): Locator { + return this.root.getByText('No results', { exact: false }) + } +} diff --git a/browser_tests/tests/propertiesPanel/propertiesPanelBasic.spec.ts b/browser_tests/tests/propertiesPanel/propertiesPanelBasic.spec.ts new file mode 100644 index 000000000..75a4db419 --- /dev/null +++ b/browser_tests/tests/propertiesPanel/propertiesPanelBasic.spec.ts @@ -0,0 +1,153 @@ +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '../../fixtures/ComfyPage' + +test.describe('Properties panel basic functionality', { tag: ['@ui'] }, () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.actionbar.propertiesButton.click() + await expect(comfyPage.propertiesPanel.root).toBeVisible() + }) + + test.describe('Panel visibility and toggle', () => { + test('opens panel via actionbar button', async ({ comfyPage }) => { + const { propertiesPanel } = comfyPage + + await propertiesPanel.close() + await expect(propertiesPanel.root).not.toBeVisible() + + await comfyPage.actionbar.propertiesButton.click() + await expect(propertiesPanel.root).toBeVisible() + }) + + test('closes panel via close button', async ({ comfyPage }) => { + const { propertiesPanel } = comfyPage + + await expect(propertiesPanel.root).toBeVisible() + await propertiesPanel.closeButton.click() + await expect(propertiesPanel.root).not.toBeVisible() + }) + + test('persists open state after page reload', async ({ comfyPage }) => { + const { propertiesPanel } = comfyPage + + await expect(propertiesPanel.root).toBeVisible() + + await comfyPage.page.reload() + await comfyPage.setup() + + await expect(propertiesPanel.root).toBeVisible() + }) + }) + + test.describe('Workflow overview (no selection)', () => { + test('shows "Workflow Overview" title when nothing selected', async ({ + comfyPage + }) => { + const { propertiesPanel } = comfyPage + + await expect(propertiesPanel.panelTitle).toContainText( + 'Workflow Overview' + ) + }) + + test('shows Parameters, Nodes, and Global Settings tabs when nothing selected', async ({ + comfyPage + }) => { + const { propertiesPanel } = comfyPage + + await expect(propertiesPanel.getTab('Parameters')).toBeVisible() + await expect(propertiesPanel.getTab('Nodes')).toBeVisible() + await expect(propertiesPanel.getTab('Global Settings')).toBeVisible() + }) + + test('Nodes tab displays workflow nodes', async ({ comfyPage }) => { + const { propertiesPanel } = comfyPage + + await propertiesPanel.clickTab('Nodes') + await expect(propertiesPanel.root).toContainText('KSampler') + }) + }) + + test.describe('Single node selection', () => { + test('updates title to node name when single node selected', async ({ + comfyPage + }) => { + const { propertiesPanel } = comfyPage + + await comfyPage.selectNodes(['KSampler']) + + await expect(propertiesPanel.panelTitle).not.toContainText( + 'Workflow Overview' + ) + }) + + test('shows Parameters, Info, and Settings tabs for single node', async ({ + comfyPage + }) => { + const { propertiesPanel } = comfyPage + + await comfyPage.selectNodes(['KSampler']) + + await expect(propertiesPanel.getTab('Parameters')).toBeVisible() + await expect(propertiesPanel.getTab('Info')).toBeVisible() + await expect(propertiesPanel.getTab('Settings')).toBeVisible() + await expect(propertiesPanel.getTab('Nodes')).not.toBeVisible() + }) + + test('Info tab shows node documentation', async ({ comfyPage }) => { + const { propertiesPanel } = comfyPage + + await comfyPage.selectNodes(['KSampler']) + await propertiesPanel.clickTab('Info') + + await expect(propertiesPanel.root).toContainText('KSampler') + }) + + test('shows widget inputs in Parameters tab', async ({ comfyPage }) => { + const { propertiesPanel } = comfyPage + + await comfyPage.selectNodes(['KSampler']) + + await expect(propertiesPanel.root.getByText('seed')).toBeVisible() + await expect(propertiesPanel.root.getByText('steps')).toBeVisible() + await expect(propertiesPanel.root.getByText('cfg')).toBeVisible() + }) + }) + + test.describe('Multiple node selection', () => { + test('shows item count in title for multiple selections', async ({ + comfyPage + }) => { + const { propertiesPanel } = comfyPage + + await comfyPage.selectNodes(['KSampler', 'CLIP Text Encode (Prompt)']) + + await expect(propertiesPanel.panelTitle).toContainText('items selected') + }) + + test('shows Parameters and Settings tabs for multiple selection', async ({ + comfyPage + }) => { + const { propertiesPanel } = comfyPage + + await comfyPage.selectNodes(['KSampler', 'CLIP Text Encode (Prompt)']) + + await expect(propertiesPanel.getTab('Parameters')).toBeVisible() + await expect(propertiesPanel.getTab('Settings')).toBeVisible() + await expect(propertiesPanel.getTab('Info')).not.toBeVisible() + }) + + test('lists all selected nodes in Parameters tab', async ({ + comfyPage + }) => { + const { propertiesPanel } = comfyPage + + await comfyPage.selectNodes(['KSampler', 'CLIP Text Encode (Prompt)']) + + await expect(propertiesPanel.root.getByText('KSampler')).toBeVisible() + await expect( + propertiesPanel.root.getByText('CLIP Text Encode (Prompt)') + ).toBeVisible() + }) + }) +}) diff --git a/browser_tests/tests/propertiesPanel/propertiesPanelGlobalSettings.spec.ts b/browser_tests/tests/propertiesPanel/propertiesPanelGlobalSettings.spec.ts new file mode 100644 index 000000000..a9936a944 --- /dev/null +++ b/browser_tests/tests/propertiesPanel/propertiesPanelGlobalSettings.spec.ts @@ -0,0 +1,124 @@ +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '../../fixtures/ComfyPage' + +test.describe('Properties panel global settings', { tag: ['@ui'] }, () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.actionbar.propertiesButton.click() + await expect(comfyPage.propertiesPanel.root).toBeVisible() + await comfyPage.propertiesPanel.clickTab('Global Settings') + }) + + test.describe('Global settings sections', () => { + test('displays Nodes section', async ({ comfyPage }) => { + const { propertiesPanel } = comfyPage + + await expect(propertiesPanel.root.getByText('Nodes')).toBeVisible() + }) + + test('displays Canvas section', async ({ comfyPage }) => { + const { propertiesPanel } = comfyPage + + await expect(propertiesPanel.root.getByText('Canvas')).toBeVisible() + }) + + test('displays Connection Links section', async ({ comfyPage }) => { + const { propertiesPanel } = comfyPage + + await expect( + propertiesPanel.root.getByText('Connection Links') + ).toBeVisible() + }) + + test('has View all settings button', async ({ comfyPage }) => { + const { propertiesPanel } = comfyPage + + const viewAllButton = propertiesPanel.root.getByRole('button', { + name: /View all settings/i + }) + await expect(viewAllButton).toBeVisible() + }) + }) + + test.describe('Nodes section settings', () => { + test('can toggle Show advanced parameters', async ({ comfyPage }) => { + const { propertiesPanel } = comfyPage + + const toggle = propertiesPanel.root + .locator('label', { hasText: 'Show advanced parameters' }) + .locator('button[role="switch"]') + + await expect(toggle).toBeVisible() + + const initialState = await toggle.getAttribute('aria-checked') + await toggle.click() + + const newState = await toggle.getAttribute('aria-checked') + expect(newState).not.toBe(initialState) + }) + + test('can toggle Nodes 2.0 setting', async ({ comfyPage }) => { + const { propertiesPanel } = comfyPage + + const toggle = propertiesPanel.root + .locator('label', { hasText: 'Nodes 2.0' }) + .locator('button[role="switch"]') + + await expect(toggle).toBeVisible() + }) + }) + + test.describe('Canvas section settings', () => { + test('can adjust grid spacing', async ({ comfyPage }) => { + const { propertiesPanel } = comfyPage + + const gridSpacingLabel = propertiesPanel.root.getByText('Grid Spacing') + await expect(gridSpacingLabel).toBeVisible() + }) + + test('can toggle Snap nodes to grid', async ({ comfyPage }) => { + const { propertiesPanel } = comfyPage + + const toggle = propertiesPanel.root + .locator('label', { hasText: 'Snap nodes to grid' }) + .locator('button[role="switch"]') + + await expect(toggle).toBeVisible() + }) + }) + + test.describe('Connection Links section settings', () => { + test('has link shape selector', async ({ comfyPage }) => { + const { propertiesPanel } = comfyPage + + const linkShapeLabel = propertiesPanel.root.getByText('Link Shape') + await expect(linkShapeLabel).toBeVisible() + }) + + test('can toggle Show connected links', async ({ comfyPage }) => { + const { propertiesPanel } = comfyPage + + const toggle = propertiesPanel.root + .locator('label', { hasText: 'Show connected links' }) + .locator('button[role="switch"]') + + await expect(toggle).toBeVisible() + }) + }) + + test.describe('View all settings navigation', () => { + test('opens full settings dialog when clicking View all settings', async ({ + comfyPage + }) => { + const { propertiesPanel } = comfyPage + + const viewAllButton = propertiesPanel.root.getByRole('button', { + name: /View all settings/i + }) + + await viewAllButton.click() + + await expect(comfyPage.settingDialog.root).toBeVisible() + }) + }) +}) diff --git a/browser_tests/tests/propertiesPanel/propertiesPanelSearch.spec.ts b/browser_tests/tests/propertiesPanel/propertiesPanelSearch.spec.ts new file mode 100644 index 000000000..8e88aa679 --- /dev/null +++ b/browser_tests/tests/propertiesPanel/propertiesPanelSearch.spec.ts @@ -0,0 +1,73 @@ +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '../../fixtures/ComfyPage' + +test.describe('Properties panel search functionality', { tag: ['@ui'] }, () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.actionbar.propertiesButton.click() + await expect(comfyPage.propertiesPanel.root).toBeVisible() + }) + + test.describe('Search with multiple nodes selected', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.selectNodes(['KSampler', 'CLIP Text Encode (Prompt)']) + }) + + test('filters widgets by search query', async ({ comfyPage }) => { + const { propertiesPanel } = comfyPage + + await propertiesPanel.searchBox.fill('seed') + + await expect(propertiesPanel.root.getByText('KSampler')).toBeVisible() + await expect( + propertiesPanel.root.getByText('CLIP Text Encode (Prompt)') + ).not.toBeVisible() + }) + + test('shows all nodes when search is cleared', async ({ comfyPage }) => { + const { propertiesPanel } = comfyPage + + await propertiesPanel.searchBox.fill('seed') + await propertiesPanel.searchBox.fill('') + + await expect(propertiesPanel.root.getByText('KSampler')).toBeVisible() + await expect( + propertiesPanel.root.getByText('CLIP Text Encode (Prompt)') + ).toBeVisible() + }) + + test('shows no results message when search matches nothing', async ({ + comfyPage + }) => { + const { propertiesPanel } = comfyPage + + await propertiesPanel.searchBox.fill('nonexistent_widget_xyz') + + await expect(propertiesPanel.noResultsMessage).toBeVisible() + }) + }) + + test.describe('Search with single node selected', () => { + test('filters widgets within single node', async ({ comfyPage }) => { + const { propertiesPanel } = comfyPage + + await comfyPage.selectNodes(['KSampler']) + await propertiesPanel.searchBox.fill('cfg') + + await expect(propertiesPanel.root.getByText('cfg')).toBeVisible() + await expect( + propertiesPanel.root.getByText('seed', { exact: true }) + ).not.toBeVisible() + }) + }) + + test.describe('Search in workflow overview mode', () => { + test('Nodes tab has search functionality', async ({ comfyPage }) => { + const { propertiesPanel } = comfyPage + + await propertiesPanel.clickTab('Nodes') + + await expect(propertiesPanel.searchBox).toBeVisible() + }) + }) +}) diff --git a/browser_tests/tests/propertiesPanel/propertiesPanelTabs.spec.ts b/browser_tests/tests/propertiesPanel/propertiesPanelTabs.spec.ts new file mode 100644 index 000000000..80272ad0f --- /dev/null +++ b/browser_tests/tests/propertiesPanel/propertiesPanelTabs.spec.ts @@ -0,0 +1,114 @@ +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '../../fixtures/ComfyPage' + +test.describe('Properties panel tab navigation', { tag: ['@ui'] }, () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.actionbar.propertiesButton.click() + await expect(comfyPage.propertiesPanel.root).toBeVisible() + }) + + test.describe('Tab switching', () => { + test('switches between tabs in workflow overview mode', async ({ + comfyPage + }) => { + const { propertiesPanel } = comfyPage + + await expect(propertiesPanel.getTab('Parameters')).toBeVisible() + + await propertiesPanel.clickTab('Nodes') + await expect(propertiesPanel.root.getByText('KSampler')).toBeVisible() + + await propertiesPanel.clickTab('Global Settings') + await expect(propertiesPanel.root.getByText('Nodes')).toBeVisible() + await expect(propertiesPanel.root.getByText('Canvas')).toBeVisible() + }) + + test('switches between tabs in single node mode', async ({ comfyPage }) => { + const { propertiesPanel } = comfyPage + + await comfyPage.selectNodes(['KSampler']) + + await expect(propertiesPanel.getTab('Parameters')).toBeVisible() + await expect(propertiesPanel.getTab('Info')).toBeVisible() + await expect(propertiesPanel.getTab('Settings')).toBeVisible() + + await propertiesPanel.clickTab('Info') + await expect(propertiesPanel.root).toContainText('KSampler') + + await propertiesPanel.clickTab('Settings') + await expect( + propertiesPanel.root.getByText('Color').first() + ).toBeVisible() + + await propertiesPanel.clickTab('Parameters') + await expect(propertiesPanel.root.getByText('seed')).toBeVisible() + }) + }) + + test.describe('Tab availability based on selection', () => { + test('shows different tabs based on selection state', async ({ + comfyPage + }) => { + const { propertiesPanel } = comfyPage + + await expect(propertiesPanel.getTab('Parameters')).toBeVisible() + await expect(propertiesPanel.getTab('Nodes')).toBeVisible() + await expect(propertiesPanel.getTab('Global Settings')).toBeVisible() + await expect(propertiesPanel.getTab('Info')).not.toBeVisible() + + await comfyPage.selectNodes(['KSampler']) + await expect(propertiesPanel.getTab('Parameters')).toBeVisible() + await expect(propertiesPanel.getTab('Info')).toBeVisible() + await expect(propertiesPanel.getTab('Settings')).toBeVisible() + await expect(propertiesPanel.getTab('Nodes')).not.toBeVisible() + + await comfyPage.selectNodes(['KSampler', 'CLIP Text Encode (Prompt)']) + await expect(propertiesPanel.getTab('Parameters')).toBeVisible() + await expect(propertiesPanel.getTab('Settings')).toBeVisible() + await expect(propertiesPanel.getTab('Info')).not.toBeVisible() + await expect(propertiesPanel.getTab('Nodes')).not.toBeVisible() + }) + + test('first tab updates for multiple selection', async ({ comfyPage }) => { + const { propertiesPanel } = comfyPage + + await comfyPage.selectNodes(['KSampler']) + await expect(propertiesPanel.getTab('Parameters')).toBeVisible() + + await comfyPage.selectNodes(['KSampler', 'CLIP Text Encode (Prompt)']) + const firstTab = propertiesPanel.tabList.locator('[role="tab"]').first() + await expect(firstTab).toBeVisible() + }) + }) + + test.describe('Tab persistence', () => { + test('remembers active tab when selection changes', async ({ + comfyPage + }) => { + const { propertiesPanel } = comfyPage + + await comfyPage.selectNodes(['KSampler']) + await propertiesPanel.clickTab('Settings') + + await comfyPage.selectNodes(['CLIP Text Encode (Prompt)']) + + await expect(propertiesPanel.getTab('Settings')).toBeVisible() + }) + + test('falls back to default tab when current tab not available', async ({ + comfyPage + }) => { + const { propertiesPanel } = comfyPage + + await comfyPage.selectNodes(['KSampler']) + await propertiesPanel.clickTab('Info') + + await comfyPage.selectNodes(['KSampler', 'CLIP Text Encode (Prompt)']) + + await expect(propertiesPanel.getTab('Info')).not.toBeVisible() + const firstTab = propertiesPanel.tabList.locator('[role="tab"]').first() + await expect(firstTab).toHaveAttribute('aria-selected', 'true') + }) + }) +}) diff --git a/browser_tests/tests/propertiesPanel/propertiesPanelTitleEdit.spec.ts b/browser_tests/tests/propertiesPanel/propertiesPanelTitleEdit.spec.ts new file mode 100644 index 000000000..99268fc47 --- /dev/null +++ b/browser_tests/tests/propertiesPanel/propertiesPanelTitleEdit.spec.ts @@ -0,0 +1,91 @@ +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '../../fixtures/ComfyPage' + +test.describe('Properties panel title editing', { tag: ['@ui'] }, () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.actionbar.propertiesButton.click() + await expect(comfyPage.propertiesPanel.root).toBeVisible() + }) + + test.describe('Single node title editing', () => { + test('shows editable title for single node selection', async ({ + comfyPage + }) => { + const { propertiesPanel } = comfyPage + + await comfyPage.selectNodes(['KSampler']) + await propertiesPanel.panelTitle.click() + + await expect(propertiesPanel.nodeTitleInput).toBeVisible() + }) + + test('edits node title successfully', async ({ comfyPage }) => { + const { propertiesPanel } = comfyPage + + await comfyPage.selectNodes(['KSampler']) + + const newTitle = 'My Custom KSampler' + + await propertiesPanel.panelTitle.click() + await propertiesPanel.nodeTitleInput.fill(newTitle) + await propertiesPanel.nodeTitleInput.press('Enter') + + await expect(propertiesPanel.panelTitle).toContainText(newTitle) + + const renamedNodes = await comfyPage.getNodeRefsByTitle(newTitle) + expect(renamedNodes.length).toBeGreaterThan(0) + }) + + test('cancels title edit with Escape', async ({ comfyPage }) => { + const { propertiesPanel } = comfyPage + + await comfyPage.selectNodes(['KSampler']) + + const originalTitle = await propertiesPanel.panelTitle.innerText() + + await propertiesPanel.panelTitle.click() + await propertiesPanel.nodeTitleInput.fill('Should Not Be Saved') + await propertiesPanel.nodeTitleInput.press('Escape') + + await expect(propertiesPanel.panelTitle).toContainText(originalTitle) + }) + + test('does not save empty title', async ({ comfyPage }) => { + const { propertiesPanel } = comfyPage + + await comfyPage.selectNodes(['KSampler']) + + const originalTitle = await propertiesPanel.panelTitle.innerText() + + await propertiesPanel.panelTitle.click() + await propertiesPanel.nodeTitleInput.fill('') + await propertiesPanel.nodeTitleInput.press('Enter') + + await expect(propertiesPanel.panelTitle).toContainText(originalTitle) + }) + }) + + test.describe('Title editing restrictions', () => { + test('does not allow title editing for multiple selection', async ({ + comfyPage + }) => { + const { propertiesPanel } = comfyPage + + await comfyPage.selectNodes(['KSampler', 'CLIP Text Encode (Prompt)']) + await propertiesPanel.panelTitle.click() + + await expect(propertiesPanel.nodeTitleInput).not.toBeVisible() + }) + + test('does not allow title editing for workflow overview', async ({ + comfyPage + }) => { + const { propertiesPanel } = comfyPage + + await propertiesPanel.panelTitle.click() + + await expect(propertiesPanel.nodeTitleInput).not.toBeVisible() + }) + }) +})