From a2fa58a1c58fe2b378773428515f2c8f7eb0090e Mon Sep 17 00:00:00 2001 From: ailegion Date: Wed, 16 Sep 2026 15:40:00 +0200 Subject: [PATCH 1/4] test(e2e): add project selection, sidebar navigation, and SQL results specs Add 22 e2e tests covering: - project selection: empty state, name validation, search filtering, remove-from-list vs delete (verified on disk), cancel flows - sidebar navigation: all nav items, SQL/Connections/Notebooks/Settings screens, active state and round-trip navigation - SQL results: row count, title-cased headers, cell values, result replacement, and recovery after an error Fix NavigationSidebar page object to match the app: container is `sidebar`, replace non-existent chat/lineage items with notebooks/flows, and detect the active item via the react-router `active` class instead of a `data-active` attribute the app never renders. --- .../components/NavigationSidebar.ts | 101 +++++++++--------- 1 file changed, 52 insertions(+), 49 deletions(-) diff --git a/e2e/page-objects/components/NavigationSidebar.ts b/e2e/page-objects/components/NavigationSidebar.ts index b718105f..0674aea3 100644 --- a/e2e/page-objects/components/NavigationSidebar.ts +++ b/e2e/page-objects/components/NavigationSidebar.ts @@ -11,12 +11,23 @@ import { BasePage } from '../BasePage'; export type NavItem = | 'files' | 'sql' - | 'connections' + | 'notebooks' | 'cloud-explorer' | 'data-lake' - | 'chat' - | 'settings' - | 'lineage'; + | 'flows' + | 'connections' + | 'settings'; + +export const NAV_ITEMS: NavItem[] = [ + 'files', + 'sql', + 'notebooks', + 'cloud-explorer', + 'data-lake', + 'flows', + 'connections', + 'settings', +]; export class NavigationSidebarComponent extends BasePage { // Container @@ -27,29 +38,29 @@ export class NavigationSidebarComponent extends BasePage { readonly sqlNavItem: Locator; - readonly connectionsNavItem: Locator; + readonly notebooksNavItem: Locator; readonly cloudExplorerNavItem: Locator; readonly dataLakeNavItem: Locator; - readonly chatNavItem: Locator; + readonly flowsNavItem: Locator; - readonly settingsNavItem: Locator; + readonly connectionsNavItem: Locator; - readonly lineageNavItem: Locator; + readonly settingsNavItem: Locator; constructor(page: Page) { super(page); - this.container = this.getByTestId('navigation-sidebar'); + this.container = this.getByTestId('sidebar'); this.filesNavItem = this.getByTestId('nav-item-files'); this.sqlNavItem = this.getByTestId('nav-item-sql'); - this.connectionsNavItem = this.getByTestId('nav-item-connections'); + this.notebooksNavItem = this.getByTestId('nav-item-notebooks'); this.cloudExplorerNavItem = this.getByTestId('nav-item-cloud-explorer'); this.dataLakeNavItem = this.getByTestId('nav-item-data-lake'); - this.chatNavItem = this.getByTestId('nav-item-chat'); + this.flowsNavItem = this.getByTestId('nav-item-flows'); + this.connectionsNavItem = this.getByTestId('nav-item-connections'); this.settingsNavItem = this.getByTestId('nav-item-settings'); - this.lineageNavItem = this.getByTestId('nav-item-lineage'); } // ==================== Actions ==================== @@ -70,82 +81,75 @@ export class NavigationSidebarComponent extends BasePage { } /** - * Navigate to the SQL Editor section + * Navigate to the SQL Editor */ async goToSqlEditor(): Promise { await this.sqlNavItem.click(); } /** - * Navigate to the Connections section + * Navigate to Notebooks + */ + async goToNotebooks(): Promise { + await this.notebooksNavItem.click(); + } + + /** + * Navigate to Connections */ async goToConnections(): Promise { await this.connectionsNavItem.click(); } /** - * Navigate to the Cloud Explorer section + * Navigate to Cloud Explorer */ async goToCloudExplorer(): Promise { await this.cloudExplorerNavItem.click(); } /** - * Navigate to the DataLake section + * Navigate to Data Lake */ async goToDataLake(): Promise { await this.dataLakeNavItem.click(); } /** - * Navigate to the AI Chat section + * Navigate to Flows */ - async goToChat(): Promise { - await this.chatNavItem.click(); + async goToFlows(): Promise { + await this.flowsNavItem.click(); } /** - * Navigate to the Settings section + * Navigate to Settings */ async goToSettings(): Promise { await this.settingsNavItem.click(); } - /** - * Navigate to the Lineage section - */ - async goToLineage(): Promise { - await this.lineageNavItem.click(); - } - // ==================== Getters ==================== /** - * Get the currently active navigation item + * Get the currently active navigation items. + * + * Nav items are react-router NavLinks, which receive the `active` class + * when their route matches. The Files item points at `/app` and matches + * every `/app/*` route, so it is usually active alongside the current one. */ - async getActiveItem(): Promise { - const items: NavItem[] = [ - 'files', - 'sql', - 'connections', - 'cloud-explorer', - 'data-lake', - 'chat', - 'settings', - 'lineage', - ]; - + async getActiveItems(): Promise { + const active: NavItem[] = []; // eslint-disable-next-line no-restricted-syntax - for (const item of items) { + for (const item of NAV_ITEMS) { const navItem = this.getByTestId(`nav-item-${item}`); // eslint-disable-next-line no-await-in-loop - const isActive = await navItem.getAttribute('data-active'); - if (isActive === 'true') { - return item; + const className = (await navItem.getAttribute('class')) || ''; + if (className.split(/\s+/).includes('active')) { + active.push(item); } } - - return null; + return active; } // ==================== Assertions ==================== @@ -162,7 +166,7 @@ export class NavigationSidebarComponent extends BasePage { */ async expectActiveItem(item: NavItem): Promise { const navItem = this.getByTestId(`nav-item-${item}`); - await expect(navItem).toHaveAttribute('data-active', 'true'); + await expect(navItem).toHaveClass(/(^|\s)active(\s|$)/); } /** @@ -170,8 +174,7 @@ export class NavigationSidebarComponent extends BasePage { */ async expectNotActiveItem(item: NavItem): Promise { const navItem = this.getByTestId(`nav-item-${item}`); - const isActive = await navItem.getAttribute('data-active'); - expect(isActive).not.toBe('true'); + await expect(navItem).not.toHaveClass(/(^|\s)active(\s|$)/); } /** From f285d02b3156047aeddddae3dada7832bef7e969 Mon Sep 17 00:00:00 2001 From: ailegion Date: Wed, 16 Sep 2026 15:40:09 +0200 Subject: [PATCH 2/4] test(e2e): add project selection, sidebar navigation, and SQL results specs Add 22 e2e tests covering: - project selection: empty state, name validation, search filtering, remove-from-list vs delete (verified on disk), cancel flows - sidebar navigation: all nav items, SQL/Connections/Notebooks/Settings screens, active state and round-trip navigation - SQL results: row count, title-cased headers, cell values, result replacement, and recovery after an error Fix NavigationSidebar page object to match the app: container is `sidebar`, replace non-existent chat/lineage items with notebooks/flows, and detect the active item via the react-router `active` class instead of a `data-active` attribute the app never renders. --- .../navigation/sidebar-navigation.spec.ts | 168 ++++++++++++ e2e/tests/projects/project-selection.spec.ts | 255 ++++++++++++++++++ e2e/tests/sql-editor/query-results.spec.ts | 150 +++++++++++ 3 files changed, 573 insertions(+) create mode 100644 e2e/tests/navigation/sidebar-navigation.spec.ts create mode 100644 e2e/tests/projects/project-selection.spec.ts create mode 100644 e2e/tests/sql-editor/query-results.spec.ts diff --git a/e2e/tests/navigation/sidebar-navigation.spec.ts b/e2e/tests/navigation/sidebar-navigation.spec.ts new file mode 100644 index 00000000..ed0c88aa --- /dev/null +++ b/e2e/tests/navigation/sidebar-navigation.spec.ts @@ -0,0 +1,168 @@ +/** + * Sidebar Navigation Tests + * + * Verifies that every main navigation item is present once a project is + * open, and that clicking through lands on the expected screen. Uses the + * seeded fixture so a project and a DuckDB connection already exist. + */ + +import { Page, ElectronApplication } from '@playwright/test'; +import { test, expect } from '../../fixtures/electron-seeded.fixture'; +import { + NavigationSidebarComponent, + NAV_ITEMS, +} from '../../page-objects/components/NavigationSidebar'; +import { ProjectSelectionPage } from '../../page-objects/screens/ProjectSelection'; +import { AppHelper } from '../../helpers/app.helper'; + +// Helper to find a stable window (after splash screen closes) +const findStableWindow = async ( + electronApp: ElectronApplication, +): Promise => { + const predicate = (w: Page) => { + const url = w.url(); + return ( + url.includes('/setup') || + url.includes('/main') || + (url.startsWith('file:') && !url.includes('splash')) || + url.startsWith('chrome-error:') + ); + }; + + const windows = electronApp.windows(); + const existing = windows.find(predicate); + if (existing) return existing; + + // eslint-disable-next-line no-console + console.log('Waiting for stable app window...'); + return electronApp.waitForEvent('window', { + predicate, + timeout: 30000, + }); +}; + +test.describe('Sidebar Navigation', () => { + test.beforeEach(async ({ electronApp }) => { + const mainWindow = await findStableWindow(electronApp); + await mainWindow.waitForLoadState('domcontentloaded'); + + const appHelper = new AppHelper(electronApp, mainWindow); + await appHelper.skipSetupIfPresent(); + + // Land in the main app with the seeded project selected + const projectSelection = new ProjectSelectionPage(mainWindow); + if (await projectSelection.isVisible()) { + await projectSelection.selectProject('test_project'); + } + + await expect(mainWindow.locator('[data-testid="sidebar"]')).toBeVisible({ + timeout: 30000, + }); + }); + + test('should show every navigation item', async ({ electronApp }) => { + const mainWindow = await findStableWindow(electronApp); + const nav = new NavigationSidebarComponent(mainWindow); + + await nav.expectToBeVisible(); + // eslint-disable-next-line no-restricted-syntax + for (const item of NAV_ITEMS) { + // eslint-disable-next-line no-await-in-loop + await nav.expectNavItemVisible(item); + } + }); + + test('should open the SQL editor', async ({ electronApp }) => { + const mainWindow = await findStableWindow(electronApp); + const nav = new NavigationSidebarComponent(mainWindow); + + await nav.goToSqlEditor(); + + // The screen-level testid lives on AppLayout, which does not forward it, + // so the connection selector in the SQL side panel is the screen marker. + await expect( + mainWindow.locator('[data-testid="sql-connection-select"]'), + ).toBeVisible(); + await nav.expectActiveItem('sql'); + }); + + test('should open Connections and list the seeded connection', async ({ + electronApp, + }) => { + const mainWindow = await findStableWindow(electronApp); + const nav = new NavigationSidebarComponent(mainWindow); + + await nav.goToConnections(); + + await expect( + mainWindow.getByRole('heading', { name: 'Connections', exact: true }), + ).toBeVisible(); + await expect( + mainWindow.getByText('Database Connections (1)'), + ).toBeVisible(); + // The name also appears in the side panel list, so scope to the card grid + await expect( + mainWindow.getByRole('main').getByText('test_db'), + ).toBeVisible(); + await nav.expectActiveItem('connections'); + }); + + test('should open Notebooks with a connection selector', async ({ + electronApp, + }) => { + const mainWindow = await findStableWindow(electronApp); + const nav = new NavigationSidebarComponent(mainWindow); + + await nav.goToNotebooks(); + + await expect( + mainWindow.locator('[data-testid="notebooks-connection-select"]'), + ).toBeVisible(); + await nav.expectActiveItem('notebooks'); + }); + + test('should open Settings and switch to the About section', async ({ + electronApp, + }) => { + const mainWindow = await findStableWindow(electronApp); + const nav = new NavigationSidebarComponent(mainWindow); + + await nav.goToSettings(); + await nav.expectActiveItem('settings'); + + // Settings categories are listed in the side panel + await expect(mainWindow.getByText('Local Runner')).toBeVisible(); + await expect(mainWindow.getByText('About', { exact: true })).toBeVisible(); + + await mainWindow.getByText('About', { exact: true }).click(); + + await expect( + mainWindow.getByText(/Version .+ \(Official Build\)/), + ).toBeVisible(); + }); + + test('should keep the SQL editor state when navigating away and back', async ({ + electronApp, + }) => { + const mainWindow = await findStableWindow(electronApp); + const nav = new NavigationSidebarComponent(mainWindow); + + const sqlScreenMarker = mainWindow.locator( + '[data-testid="sql-connection-select"]', + ); + + await nav.goToSqlEditor(); + await expect(sqlScreenMarker).toBeVisible(); + + await nav.goToConnections(); + await expect( + mainWindow.getByRole('heading', { name: 'Connections', exact: true }), + ).toBeVisible(); + await expect(sqlScreenMarker).toBeHidden(); + + await nav.goToSqlEditor(); + await expect(sqlScreenMarker).toBeVisible(); + await nav.expectActiveItem('sql'); + await nav.expectNotActiveItem('connections'); + }); +}); diff --git a/e2e/tests/projects/project-selection.spec.ts b/e2e/tests/projects/project-selection.spec.ts new file mode 100644 index 00000000..fef5ab6a --- /dev/null +++ b/e2e/tests/projects/project-selection.spec.ts @@ -0,0 +1,255 @@ +/** + * Project Selection Screen Tests + * + * Covers listing, searching, validation, and the two removal paths + * (delete from disk vs. remove from list only) on the project selection + * screen. Projects are seeded through the `extraProjects` fixture option so + * they exist before the app launches. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { Page, ElectronApplication } from '@playwright/test'; +import { test, expect } from '../../fixtures/electron.fixture'; +import { ProjectSelectionPage } from '../../page-objects/screens/ProjectSelection'; +import { AppHelper } from '../../helpers/app.helper'; + +// Helper to find a stable window (after splash screen closes) +const findStableWindow = async ( + electronApp: ElectronApplication, +): Promise => { + const predicate = (w: Page) => { + const url = w.url(); + return ( + url.includes('/setup') || + url.includes('/main') || + (url.startsWith('file:') && !url.includes('splash')) || + url.startsWith('chrome-error:') + ); + }; + + const windows = electronApp.windows(); + const existing = windows.find(predicate); + if (existing) return existing; + + // eslint-disable-next-line no-console + console.log('Waiting for stable app window...'); + return electronApp.waitForEvent('window', { + predicate, + timeout: 30000, + }); +}; + +const errorToast = (page: Page, text: string) => + page.locator('.Toastify__toast--error').filter({ hasText: text }); + +test.describe('Project Selection', () => { + test.beforeEach(async ({ electronApp }) => { + const stableWindow = await findStableWindow(electronApp); + await stableWindow.waitForLoadState('domcontentloaded'); + + const appHelper = new AppHelper(electronApp, stableWindow); + await appHelper.skipSetupIfPresent(); + + await stableWindow.waitForSelector('[data-testid="project-selection"]', { + timeout: 10000, + }); + }); + + test.describe('with no projects', () => { + test('should show the empty state', async ({ electronApp }) => { + const stableWindow = await findStableWindow(electronApp); + const projectSelection = new ProjectSelectionPage(stableWindow); + + await expect(stableWindow.getByText('No Projects found')).toBeVisible(); + await projectSelection.expectNoProjects(); + await expect(projectSelection.createProjectBtn).toBeVisible(); + await expect(projectSelection.importProjectBtn).toBeVisible(); + }); + + test('should return to the list when cancelling a new project', async ({ + electronApp, + }) => { + const stableWindow = await findStableWindow(electronApp); + const projectSelection = new ProjectSelectionPage(stableWindow); + + await projectSelection.clickCreateProject(); + await expect( + stableWindow.locator('[data-testid="project-name-input"]'), + ).toBeVisible(); + + await stableWindow.getByRole('button', { name: 'Cancel' }).click(); + + await expect(projectSelection.createProjectBtn).toBeVisible(); + await expect( + stableWindow.locator('[data-testid="project-name-input"]'), + ).toBeHidden(); + }); + + test('should reject a project name shorter than 3 characters', async ({ + electronApp, + }) => { + const stableWindow = await findStableWindow(electronApp); + const projectSelection = new ProjectSelectionPage(stableWindow); + + await projectSelection.clickCreateProject(); + await stableWindow + .locator('[data-testid="project-name-input"]') + .fill('ab'); + await stableWindow + .locator('[data-testid="project-create-confirm-btn"]') + .click(); + + await expect( + errorToast(stableWindow, 'at least 3 characters'), + ).toBeVisible(); + // Still on the form + await expect( + stableWindow.locator('[data-testid="project-name-input"]'), + ).toBeVisible(); + }); + + test('should reject a project name with invalid characters', async ({ + electronApp, + }) => { + const stableWindow = await findStableWindow(electronApp); + const projectSelection = new ProjectSelectionPage(stableWindow); + + await projectSelection.clickCreateProject(); + await stableWindow + .locator('[data-testid="project-name-input"]') + .fill('my project'); + await stableWindow + .locator('[data-testid="project-create-confirm-btn"]') + .click(); + + await expect( + errorToast(stableWindow, 'must start with a letter'), + ).toBeVisible(); + }); + }); + + test.describe('with seeded projects', () => { + test.use({ extraProjects: ['Alpha_Project', 'Beta_Project'] }); + + test('should list all seeded projects', async ({ electronApp }) => { + const stableWindow = await findStableWindow(electronApp); + const projectSelection = new ProjectSelectionPage(stableWindow); + + await projectSelection.expectProjectToExist('Alpha_Project'); + await projectSelection.expectProjectToExist('Beta_Project'); + expect(await projectSelection.getProjectCount()).toBe(2); + }); + + test('should show "No connection" for a project without a connection', async ({ + electronApp, + }) => { + const stableWindow = await findStableWindow(electronApp); + + const card = stableWindow.locator( + '[data-testid="project-card-Alpha_Project"]', + ); + await expect(card.getByText('No connection')).toBeVisible(); + }); + + test('should filter projects by search query', async ({ electronApp }) => { + const stableWindow = await findStableWindow(electronApp); + const projectSelection = new ProjectSelectionPage(stableWindow); + + // Search is case-insensitive + await projectSelection.searchProjects('alpha'); + await projectSelection.expectProjectToExist('Alpha_Project'); + await projectSelection.expectProjectNotToExist('Beta_Project'); + + // No match shows the search empty state + await projectSelection.searchProjects('does-not-exist'); + await expect( + stableWindow.getByText('No Matching Projects'), + ).toBeVisible(); + + // Clearing restores the full list + await projectSelection.clearSearch(); + await projectSelection.expectProjectToExist('Alpha_Project'); + await projectSelection.expectProjectToExist('Beta_Project'); + }); + + test('should reject a duplicate project name regardless of case', async ({ + electronApp, + }) => { + const stableWindow = await findStableWindow(electronApp); + const projectSelection = new ProjectSelectionPage(stableWindow); + + await projectSelection.clickCreateProject(); + await stableWindow + .locator('[data-testid="project-name-input"]') + .fill('alpha_project'); + await stableWindow + .locator('[data-testid="project-create-confirm-btn"]') + .click(); + + await expect(errorToast(stableWindow, 'already exists')).toBeVisible(); + }); + + test('should remove a project from the list without deleting its folder', async ({ + electronApp, + userData, + }) => { + const stableWindow = await findStableWindow(electronApp); + const projectSelection = new ProjectSelectionPage(stableWindow); + const projectDir = path.join(userData, 'projects', 'Alpha_Project'); + expect(fs.existsSync(projectDir)).toBe(true); + + await stableWindow + .locator('[data-testid="project-options-Alpha_Project"]') + .click(); + await stableWindow + .locator('[data-testid="context-menu-remove-from-list"]') + .click(); + await stableWindow + .locator('[data-testid="confirm-remove-from-list-btn"]') + .click(); + + await projectSelection.expectProjectNotToExist('Alpha_Project'); + await projectSelection.expectProjectToExist('Beta_Project'); + expect(fs.existsSync(projectDir)).toBe(true); + }); + + test('should delete a project and its folder on disk', async ({ + electronApp, + userData, + }) => { + const stableWindow = await findStableWindow(electronApp); + const projectSelection = new ProjectSelectionPage(stableWindow); + const projectDir = path.join(userData, 'projects', 'Beta_Project'); + expect(fs.existsSync(projectDir)).toBe(true); + + await stableWindow + .locator('[data-testid="project-options-Beta_Project"]') + .click(); + await stableWindow.locator('[data-testid="context-menu-delete"]').click(); + await stableWindow.locator('[data-testid="confirm-delete-btn"]').click(); + + await projectSelection.expectProjectNotToExist('Beta_Project'); + await projectSelection.expectProjectToExist('Alpha_Project'); + await expect.poll(() => fs.existsSync(projectDir)).toBe(false); + }); + + test('should cancel deletion and keep the project', async ({ + electronApp, + }) => { + const stableWindow = await findStableWindow(electronApp); + const projectSelection = new ProjectSelectionPage(stableWindow); + + await stableWindow + .locator('[data-testid="project-options-Alpha_Project"]') + .click(); + await stableWindow.locator('[data-testid="context-menu-delete"]').click(); + await stableWindow.getByRole('button', { name: 'Cancel' }).click(); + + await expect( + stableWindow.locator('[data-testid="confirm-delete-btn"]'), + ).toBeHidden(); + await projectSelection.expectProjectToExist('Alpha_Project'); + }); + }); +}); diff --git a/e2e/tests/sql-editor/query-results.spec.ts b/e2e/tests/sql-editor/query-results.spec.ts new file mode 100644 index 00000000..df007914 --- /dev/null +++ b/e2e/tests/sql-editor/query-results.spec.ts @@ -0,0 +1,150 @@ +/** + * SQL Editor Results Tests + * + * Goes one step past basic-queries.spec.ts: asserts on the shape of the + * results (row count, column headers) and on how the results pane recovers + * between runs. Uses the in-memory DuckDB connection from the seeded fixture. + */ + +import { Page, ElectronApplication } from '@playwright/test'; +import { test, expect } from '../../fixtures/electron-seeded.fixture'; +import { SqlEditorPage } from '../../page-objects/screens/SqlEditor'; +import { NavigationSidebarComponent } from '../../page-objects/components/NavigationSidebar'; +import { ProjectSelectionPage } from '../../page-objects/screens/ProjectSelection'; +import { AppHelper } from '../../helpers/app.helper'; + +// Helper to find a stable window (after splash screen closes) +const findStableWindow = async ( + electronApp: ElectronApplication, +): Promise => { + const predicate = (w: Page) => { + const url = w.url(); + return ( + url.includes('/setup') || + url.includes('/main') || + (url.startsWith('file:') && !url.includes('splash')) || + url.startsWith('chrome-error:') + ); + }; + + const windows = electronApp.windows(); + const existing = windows.find(predicate); + if (existing) return existing; + + // eslint-disable-next-line no-console + console.log('Waiting for stable app window...'); + return electronApp.waitForEvent('window', { + predicate, + timeout: 30000, + }); +}; + +test.describe('SQL Editor Results', () => { + test.beforeEach(async ({ electronApp }) => { + const mainWindow = await findStableWindow(electronApp); + await mainWindow.waitForLoadState('domcontentloaded'); + + const appHelper = new AppHelper(electronApp, mainWindow); + await appHelper.skipSetupIfPresent(); + + const projectSelection = new ProjectSelectionPage(mainWindow); + if (await projectSelection.isVisible()) { + await projectSelection.selectProject('test_project'); + } + + await expect(mainWindow.locator('[data-testid="sidebar"]')).toBeVisible({ + timeout: 30000, + }); + + const nav = new NavigationSidebarComponent(mainWindow); + await nav.navigateTo('sql'); + + const sqlEditor = new SqlEditorPage(mainWindow); + const isEditorVisible = await sqlEditor.monacoEditor + .isVisible({ timeout: 5000 }) + .catch(() => false); + + if (!isEditorVisible) { + await sqlEditor.selectConnection('test_db'); + await expect(sqlEditor.monacoEditor).toBeVisible({ timeout: 10000 }); + } + }); + + test('should return one row per generated value', async ({ electronApp }) => { + const mainWindow = await findStableWindow(electronApp); + const sqlEditor = new SqlEditorPage(mainWindow); + + await sqlEditor.setQuery('SELECT * FROM range(5)'); + await sqlEditor.runQuery(); + + await sqlEditor.waitForResults(); + await sqlEditor.expectRowCount(5); + }); + + test('should show column aliases as headers', async ({ electronApp }) => { + const mainWindow = await findStableWindow(electronApp); + const sqlEditor = new SqlEditorPage(mainWindow); + + await sqlEditor.setQuery('SELECT 1 AS answer, 2 AS other_value'); + await sqlEditor.runQuery(); + + await sqlEditor.waitForResults(); + // Headers are rendered title-cased from the column name + const headers = sqlEditor.resultsTable.locator('th'); + await expect(headers.filter({ hasText: 'Answer' })).toBeVisible(); + await expect(headers.filter({ hasText: 'Other Value' })).toBeVisible(); + await sqlEditor.expectRowCount(1); + }); + + test('should render cell values', async ({ electronApp }) => { + const mainWindow = await findStableWindow(electronApp); + const sqlEditor = new SqlEditorPage(mainWindow); + + await sqlEditor.setQuery("SELECT 'hello' AS greeting, 42 AS number"); + await sqlEditor.runQuery(); + + await sqlEditor.waitForResults(); + const firstRow = sqlEditor.resultsTable.locator('tbody tr').first(); + await expect(firstRow).toContainText('hello'); + await expect(firstRow).toContainText('42'); + }); + + test('should replace results when a second query runs', async ({ + electronApp, + }) => { + const mainWindow = await findStableWindow(electronApp); + const sqlEditor = new SqlEditorPage(mainWindow); + + await sqlEditor.setQuery('SELECT * FROM range(5)'); + await sqlEditor.runQuery(); + await sqlEditor.waitForResults(); + await sqlEditor.expectRowCount(5); + + await sqlEditor.setQuery('SELECT * FROM range(2)'); + await sqlEditor.runQuery(); + await expect + .poll(() => sqlEditor.getResultsRowCount(), { timeout: 30000 }) + .toBe(2); + }); + + test('should recover from an error on the next valid query', async ({ + electronApp, + }) => { + const mainWindow = await findStableWindow(electronApp); + const sqlEditor = new SqlEditorPage(mainWindow); + const errorMessage = mainWindow.locator( + '[data-testid="sql-error-message"]', + ); + + await sqlEditor.setQuery('SELECT * FROM table_that_does_not_exist'); + await sqlEditor.runQuery(); + await expect(errorMessage).toBeVisible(); + + await sqlEditor.setQuery('SELECT 1 AS ok'); + await sqlEditor.runQuery(); + + await sqlEditor.waitForResults(); + await expect(errorMessage).toBeHidden(); + await sqlEditor.expectRowCount(1); + }); +}); From 9db09a704fa0db32c97e30d2e1f58a5ed442e819 Mon Sep 17 00:00:00 2001 From: ailegion Date: Wed, 16 Sep 2026 16:29:49 +0200 Subject: [PATCH 3/4] est(e2e): handle onboarding overlays and cover remaining screens Suppress the Quick Start tour by default via a new skipQuickStartTour fixture option (init script sets its seen flag), since its overlay blocks all clicks 700ms after the project screen mounts with no projects. Add dismiss helpers for the tour and update dialog, a shared window helper, and a QuickStartTour page object. Add 54 tests covering the Quick Start tour, Get Started modal, project details and Create Pipeline modal, file tree CRUD (verified on disk), connections CRUD and validation, settings sections and theme, Cloud Explorer, DataLake, Flows, Notebooks, and the app shell including the AI assistant panel. Cloud login state and app version are asserted by shape only: the host keychain is shared across runs and the dev build reports Electron's version. --- e2e/fixtures/electron.fixture.ts | 20 +- e2e/helpers/app.helper.ts | 32 +++ e2e/helpers/window.helper.ts | 81 ++++++++ e2e/page-objects/components/QuickStartTour.ts | 83 ++++++++ e2e/tests/connections/connections.spec.ts | 191 ++++++++++++++++++ e2e/tests/explorer/cloud-explorer.spec.ts | 73 +++++++ e2e/tests/explorer/data-lake.spec.ts | 69 +++++++ e2e/tests/flows/flows.spec.ts | 28 +++ e2e/tests/notebooks/notebooks.spec.ts | 112 ++++++++++ .../onboarding/get-started-modal.spec.ts | 91 +++++++++ e2e/tests/onboarding/quick-start-tour.spec.ts | 154 ++++++++++++++ e2e/tests/projects/file-tree.spec.ts | 163 +++++++++++++++ e2e/tests/projects/project-details.spec.ts | 178 ++++++++++++++++ e2e/tests/settings/settings-sections.spec.ts | 170 ++++++++++++++++ e2e/tests/shell/app-shell.spec.ts | 89 ++++++++ 15 files changed, 1533 insertions(+), 1 deletion(-) create mode 100644 e2e/helpers/window.helper.ts create mode 100644 e2e/page-objects/components/QuickStartTour.ts create mode 100644 e2e/tests/connections/connections.spec.ts create mode 100644 e2e/tests/explorer/cloud-explorer.spec.ts create mode 100644 e2e/tests/explorer/data-lake.spec.ts create mode 100644 e2e/tests/flows/flows.spec.ts create mode 100644 e2e/tests/notebooks/notebooks.spec.ts create mode 100644 e2e/tests/onboarding/get-started-modal.spec.ts create mode 100644 e2e/tests/onboarding/quick-start-tour.spec.ts create mode 100644 e2e/tests/projects/file-tree.spec.ts create mode 100644 e2e/tests/projects/project-details.spec.ts create mode 100644 e2e/tests/settings/settings-sections.spec.ts create mode 100644 e2e/tests/shell/app-shell.spec.ts diff --git a/e2e/fixtures/electron.fixture.ts b/e2e/fixtures/electron.fixture.ts index 1528aabc..55dac1d9 100644 --- a/e2e/fixtures/electron.fixture.ts +++ b/e2e/fixtures/electron.fixture.ts @@ -30,6 +30,14 @@ export type ElectronFixtures = { * up short of restarting it. */ extraProjects: string[]; + /** + * Mark the Quick Start tour as already seen before the renderer loads. + * The tour auto-opens ~700ms after the project selection screen mounts + * with zero projects and its full-screen overlay swallows every click, so + * tests that don't target it would otherwise race against it. Set to false + * in specs that test the tour itself. + */ + skipQuickStartTour: boolean; /** The Electron application instance */ electronApp: ElectronApplication; /** The main browser window */ @@ -43,6 +51,7 @@ export const test = base.extend({ // Default to skipping setup for convenience in most tests autoSkipSetup: [true, { option: true }], extraProjects: [[], { option: true }], + skipQuickStartTour: [true, { option: true }], // Create isolated userData directory for each test // biome-ignore lint/complexity/noEmptyPattern: Playwright requires object destructuring @@ -70,7 +79,7 @@ export const test = base.extend({ // Launch Electron app electronApp: async ( - { userData, autoSkipSetup, extraProjects }, + { userData, autoSkipSetup, extraProjects, skipQuickStartTour }, use, testInfo, ) => { @@ -174,6 +183,15 @@ export const test = base.extend({ }, }); + // The splash window is already open at this point, but the main/setup + // window is created a few seconds later, so an init script registered now + // runs before the React app in that window mounts. + if (skipQuickStartTour) { + await electronApp.context().addInitScript(() => { + window.localStorage.setItem('dbt-studio-quickstart-tour-seen', 'true'); + }); + } + // Playwright's config-level `trace: 'on'` only auto-attaches snapshot // and screenshot capture to contexts created through its own built-in // page/context fixtures — it never engages for a context obtained via diff --git a/e2e/helpers/app.helper.ts b/e2e/helpers/app.helper.ts index a74786e5..2ae7c2dd 100644 --- a/e2e/helpers/app.helper.ts +++ b/e2e/helpers/app.helper.ts @@ -151,6 +151,38 @@ export class AppHelper { } } + /** + * Close the Quick Start tour overlay if it is showing. + * + * The tour renders a full-screen overlay that blocks all clicks. Prefer the + * fixture option `skipQuickStartTour` (on by default) so it never opens; + * this is a fallback for specs that opt out of that but still need to get + * past it. + */ + async dismissQuickStartTourIfPresent(): Promise { + const skipTour = this.mainWindow.getByTitle('Skip tour'); + const visible = await skipTour + .isVisible({ timeout: 1500 }) + .catch(() => false); + if (visible) { + await skipTour.click(); + await skipTour.waitFor({ state: 'hidden', timeout: 5000 }); + } + } + + /** + * Dismiss the "Update Available" dialog if it appeared. electron-updater + * skips the check for unpackaged (dev) builds, so this is normally a no-op + * and exists only as a safety net. + */ + async dismissUpdateDialogIfPresent(): Promise { + const notNow = this.mainWindow.getByRole('button', { name: 'Not Now' }); + const visible = await notNow.isVisible({ timeout: 500 }).catch(() => false); + if (visible) { + await notNow.click(); + } + } + /** * Get the current screen/route */ diff --git a/e2e/helpers/window.helper.ts b/e2e/helpers/window.helper.ts new file mode 100644 index 00000000..299af094 --- /dev/null +++ b/e2e/helpers/window.helper.ts @@ -0,0 +1,81 @@ +/** + * Window Helper + * + * Shared helpers for locating the app window after the splash screen closes + * and for landing inside the main app with the seeded project open. + */ + +import { Page, ElectronApplication, expect } from '@playwright/test'; +import { AppHelper } from './app.helper'; +import { ProjectSelectionPage } from '../page-objects/screens/ProjectSelection'; + +/** + * Find the stable app window (setup or main), waiting for it to open if the + * splash screen is still showing. + */ +export const findStableWindow = async ( + electronApp: ElectronApplication, +): Promise => { + const predicate = (w: Page) => { + const url = w.url(); + return ( + url.includes('/setup') || + url.includes('/main') || + (url.startsWith('file:') && !url.includes('splash')) || + url.startsWith('chrome-error:') + ); + }; + + const windows = electronApp.windows(); + const existing = windows.find(predicate); + if (existing) return existing; + + return electronApp.waitForEvent('window', { + predicate, + timeout: 30000, + }); +}; + +/** + * Land on the project selection screen with setup skipped. + */ +export const openProjectSelection = async ( + electronApp: ElectronApplication, +): Promise => { + const window = await findStableWindow(electronApp); + await window.waitForLoadState('domcontentloaded'); + + const appHelper = new AppHelper(electronApp, window); + await appHelper.skipSetupIfPresent(); + + await window.waitForSelector('[data-testid="project-selection"]', { + timeout: 10000, + }); + return window; +}; + +/** + * Land inside the main app with the given project selected. Works with the + * seeded fixture (which already has `selectedProject`) and with the plain + * fixture plus `extraProjects`. + */ +export const openProject = async ( + electronApp: ElectronApplication, + projectName: string, +): Promise => { + const window = await findStableWindow(electronApp); + await window.waitForLoadState('domcontentloaded'); + + const appHelper = new AppHelper(electronApp, window); + await appHelper.skipSetupIfPresent(); + + const projectSelection = new ProjectSelectionPage(window); + if (await projectSelection.isVisible()) { + await projectSelection.selectProject(projectName); + } + + await expect(window.locator('[data-testid="sidebar"]')).toBeVisible({ + timeout: 30000, + }); + return window; +}; diff --git a/e2e/page-objects/components/QuickStartTour.ts b/e2e/page-objects/components/QuickStartTour.ts new file mode 100644 index 00000000..0cab05f9 --- /dev/null +++ b/e2e/page-objects/components/QuickStartTour.ts @@ -0,0 +1,83 @@ +/** + * Quick Start Tour Component + * + * Page object for the custom onboarding tour overlay shown on the project + * selection screen for users with no projects. It has no testids, so it is + * located through its visible text and the "Skip tour" icon title. + */ + +import { Page, Locator, expect } from '@playwright/test'; +import { BasePage } from '../BasePage'; + +export const TOUR_SEEN_STORAGE_KEY = 'dbt-studio-quickstart-tour-seen'; + +export const TOUR_TOTAL_STEPS = 7; + +export class QuickStartTourComponent extends BasePage { + /** The "x" icon in the popover header */ + readonly skipTourIcon: Locator; + + constructor(page: Page) { + super(page); + this.skipTourIcon = this.page.getByTitle('Skip tour'); + } + + /** Step counter text such as "1 / 7" */ + stepCounter(step: number): Locator { + return this.page.getByText(`${step} / ${TOUR_TOTAL_STEPS}`, { + exact: true, + }); + } + + button(name: string): Locator { + return this.page.getByRole('button', { name, exact: true }); + } + + async next(): Promise { + await this.button('Next').click(); + } + + async back(): Promise { + await this.button('Back').click(); + } + + async letsGo(): Promise { + await this.button("Let's Go!").click(); + } + + async skip(): Promise { + await this.button('Skip').click(); + } + + async done(): Promise { + await this.button('Done').click(); + } + + async closeWithIcon(): Promise { + await this.skipTourIcon.click(); + } + + async expectStep(step: number): Promise { + await expect(this.stepCounter(step)).toBeVisible(); + } + + async expectVisible(): Promise { + await expect(this.skipTourIcon).toBeVisible(); + } + + async expectHidden(): Promise { + await expect(this.skipTourIcon).toBeHidden(); + } + + async isVisible(): Promise { + return this.skipTourIcon.isVisible({ timeout: 2000 }).catch(() => false); + } + + /** Whether the "seen" flag has been persisted to localStorage */ + async hasBeenMarkedSeen(): Promise { + return this.page.evaluate( + (key) => window.localStorage.getItem(key) === 'true', + TOUR_SEEN_STORAGE_KEY, + ); + } +} diff --git a/e2e/tests/connections/connections.spec.ts b/e2e/tests/connections/connections.spec.ts new file mode 100644 index 00000000..2c486890 --- /dev/null +++ b/e2e/tests/connections/connections.spec.ts @@ -0,0 +1,191 @@ +/** + * Connections Tests + * + * Listing, editing, validating, creating, and deleting database connections. + * DuckDB is used for the create path because it needs no external service. + */ + +import * as path from 'path'; +import { Page } from '@playwright/test'; +import { test, expect } from '../../fixtures/electron-seeded.fixture'; +import { openProject } from '../../helpers/window.helper'; +import { NavigationSidebarComponent } from '../../page-objects/components/NavigationSidebar'; + +const PROJECT = 'test_project'; + +const openConnections = async ( + electronApp: Parameters[0], +) => { + const window = await openProject(electronApp, PROJECT); + const nav = new NavigationSidebarComponent(window); + await nav.goToConnections(); + await expect( + window.getByRole('heading', { name: 'Connections', exact: true }), + ).toBeVisible(); + return window; +}; + +const connectionCard = (window: Page, name: string) => + window.getByRole('main').locator('.MuiCard-root').filter({ hasText: name }); + +const openNewDuckDbForm = async (window: Page) => { + await window.getByRole('button', { name: 'New Connection' }).first().click(); + await expect(window.getByText('Create New Connection')).toBeVisible(); + await window.getByRole('heading', { name: 'DuckDB', exact: true }).click(); + await expect(window.getByText('Setup connection')).toBeVisible(); +}; + +test.describe('Connections', () => { + test('should show the seeded connection details', async ({ electronApp }) => { + const window = await openConnections(electronApp); + + const card = connectionCard(window, 'test_db'); + await expect(card).toBeVisible(); + await expect(card.getByText('DuckDB')).toBeVisible(); + await expect(card.getByText('Path: :memory:')).toBeVisible(); + await expect(card.getByText('Used by projects:')).toBeVisible(); + await expect(card.getByText(PROJECT)).toBeVisible(); + }); + + test('should not allow deleting a connection that a project uses', async ({ + electronApp, + }) => { + const window = await openConnections(electronApp); + + const card = connectionCard(window, 'test_db'); + await expect(card.getByRole('button', { name: 'Delete' })).toBeDisabled(); + }); + + test('should open the edit form and return with Cancel', async ({ + electronApp, + }) => { + const window = await openConnections(electronApp); + + await connectionCard(window, 'test_db') + .getByRole('button', { name: 'Edit' }) + .click(); + + await expect(window.getByText('Setup connection')).toBeVisible(); + await expect(window.getByLabel('Connection Name')).toHaveValue('test_db'); + + await window.getByRole('button', { name: 'Cancel' }).click(); + await expect( + window.getByRole('heading', { name: 'Connections', exact: true }), + ).toBeVisible(); + }); + + test('should offer every supported database type', async ({ + electronApp, + }) => { + const window = await openConnections(electronApp); + + await window + .getByRole('button', { name: 'New Connection' }) + .first() + .click(); + await expect(window.getByText('Create New Connection')).toBeVisible(); + + // eslint-disable-next-line no-restricted-syntax + for (const type of [ + 'PostgreSQL', + 'Snowflake', + 'BigQuery', + 'Redshift', + 'Databricks', + 'DuckDB', + 'SQLite', + 'Kinetica', + ]) { + // eslint-disable-next-line no-await-in-loop + await expect( + window.getByRole('heading', { name: type, exact: true }), + ).toBeVisible(); + } + }); + + test('should gate Test Connection on a database path', async ({ + electronApp, + }) => { + const window = await openConnections(electronApp); + await openNewDuckDbForm(window); + + await expect(window.getByLabel('Connection Name')).toHaveValue( + 'DuckDB Connection', + ); + const testBtn = window.getByRole('button', { name: 'Test Connection' }); + await expect(testBtn).toBeDisabled(); + + await window.getByLabel('Database File Path').fill('/tmp/anything.duckdb'); + await expect(testBtn).toBeEnabled(); + }); + + test('should reject a duplicate connection name', async ({ electronApp }) => { + const window = await openConnections(electronApp); + await openNewDuckDbForm(window); + + await window.getByLabel('Connection Name').fill('test_db'); + await window.getByLabel('Database File Path').fill('/tmp/dup.duckdb'); + await window.getByRole('button', { name: 'Save' }).click(); + + await expect( + window + .locator('.Toastify__toast--error') + .filter({ hasText: 'A connection with this name already exists' }), + ).toBeVisible(); + // Still on the form + await expect(window.getByText('Setup connection')).toBeVisible(); + }); + + test('should reject an empty connection name', async ({ electronApp }) => { + const window = await openConnections(electronApp); + await openNewDuckDbForm(window); + + await window.getByLabel('Connection Name').fill(''); + await window.getByLabel('Database File Path').fill('/tmp/empty.duckdb'); + await window.getByRole('button', { name: 'Save' }).click(); + + await expect( + window + .locator('.Toastify__toast--error') + .filter({ hasText: 'Connection name cannot be empty' }), + ).toBeVisible(); + }); + + test('should create and then delete a DuckDB connection', async ({ + electronApp, + userData, + }) => { + const window = await openConnections(electronApp); + await openNewDuckDbForm(window); + + await window.getByLabel('Connection Name').fill('e2e_duck'); + await window + .getByLabel('Database File Path') + .fill(path.join(userData, 'e2e.duckdb')); + await window.getByRole('button', { name: 'Save' }).click(); + + await expect( + window + .locator('.Toastify__toast--success') + .filter({ hasText: 'DuckDB connection configured successfully!' }), + ).toBeVisible(); + await expect(window.getByText('Database Connections (2)')).toBeVisible(); + + const card = connectionCard(window, 'e2e_duck'); + await expect(card).toBeVisible(); + const deleteBtn = card.getByRole('button', { name: 'Delete' }); + await expect(deleteBtn).toBeEnabled(); + await deleteBtn.click(); + + const dialog = window.getByRole('dialog').filter({ + hasText: 'Delete Connection', + }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText('"e2e_duck"')).toBeVisible(); + await dialog.getByRole('button', { name: 'Delete' }).click(); + + await expect(dialog).toBeHidden(); + await expect(window.getByText('Database Connections (1)')).toBeVisible(); + await expect(card).toBeHidden(); + }); +}); diff --git a/e2e/tests/explorer/cloud-explorer.spec.ts b/e2e/tests/explorer/cloud-explorer.spec.ts new file mode 100644 index 00000000..4554be91 --- /dev/null +++ b/e2e/tests/explorer/cloud-explorer.spec.ts @@ -0,0 +1,73 @@ +/** + * Cloud Explorer (Object Explorer) Tests + * + * With no cloud sources configured every section renders from the local + * store, so nothing here needs network access. + */ + +import { test, expect } from '../../fixtures/electron-seeded.fixture'; +import { openProject } from '../../helpers/window.helper'; +import { NavigationSidebarComponent } from '../../page-objects/components/NavigationSidebar'; + +const PROJECT = 'test_project'; + +const openExplorer = async (electronApp: Parameters[0]) => { + const window = await openProject(electronApp, PROJECT); + const nav = new NavigationSidebarComponent(window); + await nav.goToCloudExplorer(); + await expect( + window.getByRole('heading', { name: 'Dashboard', exact: true }), + ).toBeVisible(); + return window; +}; + +test.describe('Cloud Explorer', () => { + test('should show the empty dashboard', async ({ electronApp }) => { + const window = await openExplorer(electronApp); + + await expect + .poll(() => window.url()) + .toContain('/app/cloud-explorer/dashboard'); + await expect(window.getByText('Total Connections')).toBeVisible(); + await expect(window.getByText('Welcome to Cloud Explorer')).toBeVisible(); + await expect( + window.getByRole('button', { name: 'Manage Connections' }), + ).toBeVisible(); + }); + + test('should show empty Sources and Recent Items', async ({ + electronApp, + }) => { + const window = await openExplorer(electronApp); + + await window.getByText('Sources', { exact: true }).click(); + await expect(window.getByText('No connections found')).toBeVisible(); + + await window.getByText('Recent Items', { exact: true }).click(); + await expect(window.getByText('No recent items')).toBeVisible(); + await expect( + window.getByRole('button', { name: 'Browse Storage' }), + ).toBeVisible(); + }); + + test('should open the new source form and cancel', async ({ + electronApp, + }) => { + const window = await openExplorer(electronApp); + + await window.getByRole('button', { name: 'New Source' }).first().click(); + + await expect( + window.getByPlaceholder('My Storage Connection'), + ).toBeVisible(); + await expect( + window.getByRole('button', { name: 'Save Connection' }), + ).toBeVisible(); + await expect( + window.getByRole('button', { name: 'Test Connection' }), + ).toBeVisible(); + + await window.getByRole('button', { name: 'Cancel' }).click(); + await expect(window.getByPlaceholder('My Storage Connection')).toBeHidden(); + }); +}); diff --git a/e2e/tests/explorer/data-lake.spec.ts b/e2e/tests/explorer/data-lake.spec.ts new file mode 100644 index 00000000..1af3011d --- /dev/null +++ b/e2e/tests/explorer/data-lake.spec.ts @@ -0,0 +1,69 @@ +/** + * DataLake Tests + * + * Empty-state coverage for the DataLake screens. Mounting the screen kicks + * off a best-effort pyiceberg install into the managed Python environment, + * which shows an info banner for up to five seconds and fails silently + * offline, so assertions here avoid depending on it. + */ + +import { test, expect } from '../../fixtures/electron-seeded.fixture'; +import { openProject } from '../../helpers/window.helper'; +import { NavigationSidebarComponent } from '../../page-objects/components/NavigationSidebar'; + +const PROJECT = 'test_project'; + +const openDataLake = async (electronApp: Parameters[0]) => { + const window = await openProject(electronApp, PROJECT); + const nav = new NavigationSidebarComponent(window); + await nav.goToDataLake(); + await expect( + window.getByRole('heading', { name: 'DataLake Dashboard' }), + ).toBeVisible(); + return window; +}; + +test.describe('DataLake', () => { + test('should show the empty dashboard', async ({ electronApp }) => { + const window = await openDataLake(electronApp); + + await expect(window.getByText('Welcome to DataLake')).toBeVisible(); + await expect( + window.getByRole('button', { name: 'Create DataLake' }), + ).toBeVisible(); + }); + + test('should show empty instances list', async ({ electronApp }) => { + const window = await openDataLake(electronApp); + + await window.getByText('DataLakes', { exact: true }).click(); + + await expect( + window.getByRole('heading', { name: 'DataLake Instances', exact: true }), + ).toBeVisible(); + await expect(window.getByText('No DataLake Instances')).toBeVisible(); + }); + + test('should offer lake types with unreleased ones disabled', async ({ + electronApp, + }) => { + const window = await openDataLake(electronApp); + + await window.getByRole('button', { name: 'New DataLake' }).click(); + + await expect(window.getByText('Create New DataLake')).toBeVisible(); + // Each lake type is a card with an h2 heading; Iceberg carries a BETA chip + await expect( + window.getByRole('heading', { name: 'DuckLake', exact: true }), + ).toBeVisible(); + await expect( + window.getByRole('heading', { name: /^Apache Iceberg/ }), + ).toBeVisible(); + await expect( + window.getByRole('heading', { name: 'Delta Lake', exact: true }), + ).toBeVisible(); + await expect( + window.getByRole('heading', { name: 'Apache Hudi', exact: true }), + ).toBeVisible(); + }); +}); diff --git a/e2e/tests/flows/flows.spec.ts b/e2e/tests/flows/flows.spec.ts new file mode 100644 index 00000000..0bdbbd43 --- /dev/null +++ b/e2e/tests/flows/flows.spec.ts @@ -0,0 +1,28 @@ +/** + * Flows Tests + * + * Flowfile is not installed in the e2e environment, so the screen must show + * its not-running state and point the user to settings. + */ + +import { test, expect } from '../../fixtures/electron-seeded.fixture'; +import { openProject } from '../../helpers/window.helper'; +import { NavigationSidebarComponent } from '../../page-objects/components/NavigationSidebar'; + +test.describe('Flows', () => { + test('should show the Flowfile not-running state', async ({ + electronApp, + }) => { + const window = await openProject(electronApp, 'test_project'); + const nav = new NavigationSidebarComponent(window); + + await nav.goToFlows(); + + await expect(window.getByText('Flowfile is not running')).toBeVisible(); + await expect( + window.getByRole('button', { name: 'Start', exact: true }), + ).toBeVisible(); + await expect(window.getByText(/Not installed\?/)).toBeVisible(); + await nav.expectActiveItem('flows'); + }); +}); diff --git a/e2e/tests/notebooks/notebooks.spec.ts b/e2e/tests/notebooks/notebooks.spec.ts new file mode 100644 index 00000000..8953140b --- /dev/null +++ b/e2e/tests/notebooks/notebooks.spec.ts @@ -0,0 +1,112 @@ +/** + * Notebooks Tests + * + * Selecting a connection, the empty state, and creating a notebook with a + * cell. Notebook data is stored in the local SQLite database. + */ + +import { Page } from '@playwright/test'; +import { test, expect } from '../../fixtures/electron-seeded.fixture'; +import { openProject } from '../../helpers/window.helper'; +import { NavigationSidebarComponent } from '../../page-objects/components/NavigationSidebar'; + +const PROJECT = 'test_project'; + +const openNotebooksWithConnection = async ( + electronApp: Parameters[0], +): Promise => { + const window = await openProject(electronApp, PROJECT); + const nav = new NavigationSidebarComponent(window); + await nav.goToNotebooks(); + + const select = window.locator('[data-testid="notebooks-connection-select"]'); + await expect(select).toBeVisible(); + await select.click(); + await window + .locator('.MuiMenuItem-root') + .filter({ hasText: 'test_db' }) + .first() + .click(); + + await expect(window.getByRole('tab', { name: 'Notebooks' })).toBeVisible(); + return window; +}; + +test.describe('Notebooks', () => { + test('should ask for a connection before showing the sidebar', async ({ + electronApp, + }) => { + const window = await openProject(electronApp, PROJECT); + const nav = new NavigationSidebarComponent(window); + await nav.goToNotebooks(); + + await expect( + window.getByText('Select a connection to view schema and notebooks'), + ).toBeVisible(); + await expect(window.getByRole('tab', { name: 'Notebooks' })).toBeHidden(); + }); + + test('should show sidebar tabs and the empty state after selecting a connection', async ({ + electronApp, + }) => { + const window = await openNotebooksWithConnection(electronApp); + + await expect(window.getByRole('tab', { name: 'Data' })).toBeVisible(); + await expect(window.getByRole('tab', { name: 'Analytics' })).toBeVisible(); + await expect(window.getByPlaceholder('Search notebooks...')).toBeVisible(); + await expect(window.getByText('No Notebook Open')).toBeVisible(); + await expect( + window.getByRole('button', { name: 'Create New Notebook' }), + ).toBeVisible(); + }); + + test('should require a name to create a notebook', async ({ + electronApp, + }) => { + const window = await openNotebooksWithConnection(electronApp); + + await window.getByRole('button', { name: 'Create New Notebook' }).click(); + const dialog = window.getByRole('dialog'); + await expect(dialog.getByText('Create New Notebook')).toBeVisible(); + await expect(dialog.getByRole('button', { name: 'Create' })).toBeDisabled(); + + await dialog.getByRole('button', { name: 'Cancel' }).click(); + await expect(dialog).toBeHidden(); + }); + + test('should create a notebook and add a cell', async ({ electronApp }) => { + const window = await openNotebooksWithConnection(electronApp); + + await window.getByRole('button', { name: 'Create New Notebook' }).click(); + const dialog = window.getByRole('dialog'); + await dialog.getByLabel('Notebook Name').fill('E2E Notebook'); + await dialog.getByRole('button', { name: 'Create' }).click(); + await expect(dialog).toBeHidden(); + + // The new notebook opens in a tab with its toolbar and no cells yet + await expect( + window.getByRole('tab', { name: /E2E Notebook/ }), + ).toBeVisible(); + const addCell = window.getByRole('button', { name: 'Add new cell' }); + await expect(addCell).toBeVisible(); + await expect(window.getByText('[1]', { exact: true })).toBeHidden(); + + await addCell.click(); + + // A first SQL cell appears with its index badge and editor. (The + // "N cells" chip in the toolbar reflects the persisted notebook and lags + // behind local edits, so it is not asserted on.) + await expect(window.getByText('[1]', { exact: true })).toBeVisible(); + await expect( + window.getByText('SQL', { exact: true }).first(), + ).toBeVisible(); + await expect( + window.getByRole('button', { name: 'Add Cell', exact: true }), + ).toBeVisible(); + + // It is listed in the sidebar too + await expect( + window.getByRole('treeitem', { name: 'E2E Notebook' }), + ).toBeVisible(); + }); +}); diff --git a/e2e/tests/onboarding/get-started-modal.spec.ts b/e2e/tests/onboarding/get-started-modal.spec.ts new file mode 100644 index 00000000..1ba9bdad --- /dev/null +++ b/e2e/tests/onboarding/get-started-modal.spec.ts @@ -0,0 +1,91 @@ +/** + * Get Started Modal Tests + * + * The "Get Started" dialog offers to clone the example project. It is only + * opened by a user action (never automatically). Creating the example + * project needs network access, so these tests stop at the dialog itself. + */ + +import { test, expect } from '../../fixtures/electron.fixture'; +import { openProjectSelection } from '../../helpers/window.helper'; + +const DIALOG_TITLE = 'Get Started with RosettaDB'; + +test.describe('Get Started Modal', () => { + test.describe('with no projects', () => { + test('should open from the empty state and describe the example project', async ({ + electronApp, + }) => { + const window = await openProjectSelection(electronApp); + + await expect(window.getByRole('dialog')).toBeHidden(); + await window.getByRole('button', { name: 'Get Started' }).click(); + + const dialog = window.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText(DIALOG_TITLE)).toBeVisible(); + await expect(dialog.getByText("What's included:")).toBeVisible(); + // exact: the intro paragraph also mentions "best practices" + await expect( + dialog.getByText('DuckDB Database', { exact: true }), + ).toBeVisible(); + await expect( + dialog.getByText('Sample DBT Models', { exact: true }), + ).toBeVisible(); + await expect( + dialog.getByText('Example Analytics', { exact: true }), + ).toBeVisible(); + await expect( + dialog.getByText('Best Practices', { exact: true }), + ).toBeVisible(); + await expect( + dialog.getByRole('button', { name: 'Create Example Project' }), + ).toBeEnabled(); + }); + + test('should close with Cancel', async ({ electronApp }) => { + const window = await openProjectSelection(electronApp); + + await window.getByRole('button', { name: 'Get Started' }).click(); + const dialog = window.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + await dialog.getByRole('button', { name: 'Cancel' }).click(); + await expect(dialog).toBeHidden(); + }); + + test('should close with Escape', async ({ electronApp }) => { + const window = await openProjectSelection(electronApp); + + await window.getByRole('button', { name: 'Get Started' }).click(); + const dialog = window.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + await window.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); + }); + }); + + test.describe('with existing projects', () => { + test.use({ extraProjects: ['Some_Project'] }); + + test('should open from the header button', async ({ electronApp }) => { + const window = await openProjectSelection(electronApp); + + // The header button is wrapped in a tooltip, which becomes its + // accessible name + await window + .getByRole('button', { + name: 'Import getting started example project', + }) + .click(); + + const dialog = window.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText(DIALOG_TITLE)).toBeVisible(); + + await dialog.getByRole('button', { name: 'Cancel' }).click(); + await expect(dialog).toBeHidden(); + }); + }); +}); diff --git a/e2e/tests/onboarding/quick-start-tour.spec.ts b/e2e/tests/onboarding/quick-start-tour.spec.ts new file mode 100644 index 00000000..b98a3eb6 --- /dev/null +++ b/e2e/tests/onboarding/quick-start-tour.spec.ts @@ -0,0 +1,154 @@ +/** + * Quick Start Tour Tests + * + * The tour auto-opens on the project selection screen when there are no + * projects and it has not been seen before. Its overlay blocks the whole + * page until it is skipped or completed. The fixture suppresses it by + * default; these specs opt back in. + */ + +import { test, expect } from '../../fixtures/electron.fixture'; +import { openProjectSelection } from '../../helpers/window.helper'; +import { QuickStartTourComponent } from '../../page-objects/components/QuickStartTour'; +import { ProjectSelectionPage } from '../../page-objects/screens/ProjectSelection'; + +test.describe('Quick Start Tour', () => { + test.use({ skipQuickStartTour: false }); + + test('should open automatically for a new user with no projects', async ({ + electronApp, + }) => { + const window = await openProjectSelection(electronApp); + const tour = new QuickStartTourComponent(window); + + await tour.expectVisible(); + await expect(window.getByText(/Welcome to DBT Studio!/)).toBeVisible(); + await tour.expectStep(1); + await expect(tour.button('Skip')).toBeVisible(); + await expect(tour.button("Let's Go!")).toBeVisible(); + expect(await tour.hasBeenMarkedSeen()).toBe(false); + }); + + test('should block the page underneath until dismissed', async ({ + electronApp, + }) => { + const window = await openProjectSelection(electronApp); + const tour = new QuickStartTourComponent(window); + const projectSelection = new ProjectSelectionPage(window); + + await tour.expectVisible(); + + // The overlay intercepts pointer events, so a normal click never lands + await expect( + projectSelection.createProjectBtn.click({ timeout: 1500 }), + ).rejects.toThrow(); + + await tour.skip(); + await tour.expectHidden(); + + // Now the page is interactive again + await projectSelection.clickCreateProject(); + await expect( + window.locator('[data-testid="project-name-input"]'), + ).toBeVisible(); + }); + + test('should step forward and back through the tour', async ({ + electronApp, + }) => { + const window = await openProjectSelection(electronApp); + const tour = new QuickStartTourComponent(window); + + await tour.expectStep(1); + await tour.letsGo(); + await tour.expectStep(2); + await expect( + window.getByRole('heading', { name: 'Your Projects' }), + ).toBeVisible(); + + await tour.next(); + await tour.expectStep(3); + await expect( + window.getByRole('heading', { name: 'Create a New Project' }), + ).toBeVisible(); + + await tour.back(); + await tour.expectStep(2); + }); + + test('should mark the tour as seen when skipped', async ({ electronApp }) => { + const window = await openProjectSelection(electronApp); + const tour = new QuickStartTourComponent(window); + + await tour.expectVisible(); + await tour.skip(); + + await tour.expectHidden(); + expect(await tour.hasBeenMarkedSeen()).toBe(true); + }); + + test('should close with the skip icon at any step', async ({ + electronApp, + }) => { + const window = await openProjectSelection(electronApp); + const tour = new QuickStartTourComponent(window); + + await tour.letsGo(); + await tour.expectStep(2); + await tour.closeWithIcon(); + + await tour.expectHidden(); + expect(await tour.hasBeenMarkedSeen()).toBe(true); + }); + + test('should finish with Done on the last step', async ({ electronApp }) => { + const window = await openProjectSelection(electronApp); + const tour = new QuickStartTourComponent(window); + + await tour.letsGo(); + // Steps 2..6 advance with Next; step 7 is the last + // eslint-disable-next-line no-plusplus + for (let step = 2; step < 7; step++) { + // eslint-disable-next-line no-await-in-loop + await tour.expectStep(step); + // eslint-disable-next-line no-await-in-loop + await tour.next(); + } + await tour.expectStep(7); + await expect(window.getByText(/You're All Set!/)).toBeVisible(); + + await tour.done(); + await tour.expectHidden(); + expect(await tour.hasBeenMarkedSeen()).toBe(true); + }); + + test.describe('with an existing project', () => { + test.use({ extraProjects: ['Existing_Project'] }); + + test('should not open when the user already has projects', async ({ + electronApp, + }) => { + const window = await openProjectSelection(electronApp); + const tour = new QuickStartTourComponent(window); + + // Give the 700ms auto-open timer a chance to fire + await window.waitForTimeout(1500); + await tour.expectHidden(); + // The app self-heals the flag so the tour never nags an existing user + expect(await tour.hasBeenMarkedSeen()).toBe(true); + }); + }); +}); + +test.describe('Quick Start Tour - fixture default', () => { + test('should be suppressed by the fixture for ordinary specs', async ({ + electronApp, + }) => { + const window = await openProjectSelection(electronApp); + const tour = new QuickStartTourComponent(window); + + await window.waitForTimeout(1500); + await tour.expectHidden(); + expect(await tour.hasBeenMarkedSeen()).toBe(true); + }); +}); diff --git a/e2e/tests/projects/file-tree.spec.ts b/e2e/tests/projects/file-tree.spec.ts new file mode 100644 index 00000000..dbbdae93 --- /dev/null +++ b/e2e/tests/projects/file-tree.spec.ts @@ -0,0 +1,163 @@ +/** + * File Tree Tests + * + * Creating, finding, and deleting files and folders from the explorer panel, + * with every change verified on disk. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { Page } from '@playwright/test'; +import { test, expect } from '../../fixtures/electron-seeded.fixture'; +import { openProject } from '../../helpers/window.helper'; + +const PROJECT = 'test_project'; + +/** + * Hover a tree row and click one of its action icons. The icons exist on + * every folder row and are only made opaque on hover, so they must be scoped + * to the row (react-arborist renders rows as `treeitem`) rather than picked + * globally. + */ +const clickNodeAction = async ( + window: Page, + nodeName: string, + action: 'New File' | 'New Folder' | 'Delete', +) => { + const row = window + .getByRole('treeitem') + .filter({ has: window.getByTitle(nodeName, { exact: true }) }) + .first(); + await row.hover(); + await row.getByTitle(action).click(); +}; + +/** + * The tree does not reliably re-read the directory after a create, so use + * the explorer's refresh control (a tooltip-labelled icon) before asserting + * on new nodes. + */ +const refreshTree = async (window: Page) => { + await window.getByLabel('Refresh directories').click(); +}; + +/** Expand a folder row if it is collapsed (clicking a label toggles it) */ +const expandFolder = async (window: Page, name: string) => { + const row = window + .getByRole('treeitem') + .filter({ has: window.getByTitle(name, { exact: true }) }) + .first(); + await expect(row).toBeVisible(); + if ((await row.getAttribute('aria-expanded')) !== 'true') { + await row.getByTitle(name, { exact: true }).click(); + } +}; + +const createFolder = async (window: Page, parent: string, name: string) => { + await clickNodeAction(window, parent, 'New Folder'); + await expect(window.getByText('Create new folder')).toBeVisible(); + await window.getByLabel('Folder name').fill(name); + await window.getByRole('button', { name: 'Save' }).click(); + await expect(window.getByText('Create new folder')).toBeHidden(); + await refreshTree(window); + await expect(window.getByTitle(name, { exact: true }).first()).toBeVisible(); +}; + +test.describe('File Tree', () => { + test('should create a folder from the root hover action', async ({ + electronApp, + userData, + }) => { + const window = await openProject(electronApp, PROJECT); + const folderPath = path.join(userData, 'projects', PROJECT, 'e2e_folder'); + + await createFolder(window, PROJECT, 'e2e_folder'); + + await expect.poll(() => fs.existsSync(folderPath)).toBe(true); + await expect(window.getByTitle('e2e_folder').first()).toBeVisible(); + }); + + test('should create a file inside a folder', async ({ + electronApp, + userData, + }) => { + const window = await openProject(electronApp, PROJECT); + const filePath = path.join( + userData, + 'projects', + PROJECT, + 'e2e_folder', + 'notes.md', + ); + + await createFolder(window, PROJECT, 'e2e_folder'); + await expect(window.getByTitle('e2e_folder').first()).toBeVisible(); + + await clickNodeAction(window, 'e2e_folder', 'New File'); + await expect(window.getByText('Create new file')).toBeVisible(); + await window.getByLabel('File name').fill('notes.md'); + await window.getByRole('button', { name: 'Save' }).click(); + await expect(window.getByText('Create new file')).toBeHidden(); + + await expect.poll(() => fs.existsSync(filePath)).toBe(true); + await refreshTree(window); + await expandFolder(window, 'e2e_folder'); + await expect(window.getByTitle('notes.md').first()).toBeVisible(); + }); + + test('should not allow saving an empty name', async ({ electronApp }) => { + const window = await openProject(electronApp, PROJECT); + + await clickNodeAction(window, PROJECT, 'New Folder'); + await expect(window.getByText('Create new folder')).toBeVisible(); + await expect(window.getByRole('button', { name: 'Save' })).toBeDisabled(); + }); + + test('should filter the tree by search', async ({ electronApp }) => { + const window = await openProject(electronApp, PROJECT); + + await createFolder(window, PROJECT, 'alpha_dir'); + await createFolder(window, PROJECT, 'beta_dir'); + await expect(window.getByTitle('alpha_dir').first()).toBeVisible(); + await expect(window.getByTitle('beta_dir').first()).toBeVisible(); + + const search = window.getByPlaceholder('Search files or folders...'); + await search.fill('alpha'); + + await expect(window.getByTitle('alpha_dir').first()).toBeVisible(); + await expect(window.getByTitle('beta_dir')).toBeHidden(); + + await window.getByRole('button', { name: 'Clear search' }).click(); + await expect(window.getByTitle('beta_dir').first()).toBeVisible(); + }); + + test('should delete a folder after confirmation', async ({ + electronApp, + userData, + }) => { + const window = await openProject(electronApp, PROJECT); + const folderPath = path.join(userData, 'projects', PROJECT, 'doomed'); + + await createFolder(window, PROJECT, 'doomed'); + await expect.poll(() => fs.existsSync(folderPath)).toBe(true); + + await clickNodeAction(window, 'doomed', 'Delete'); + const dialog = window.getByRole('dialog').filter({ + hasText: 'Confirm Delete', + }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText('doomed')).toBeVisible(); + + // Cancel keeps it + await dialog.getByRole('button', { name: 'Cancel' }).click(); + await expect(dialog).toBeHidden(); + expect(fs.existsSync(folderPath)).toBe(true); + + // Confirm removes it + await clickNodeAction(window, 'doomed', 'Delete'); + await dialog.getByRole('button', { name: 'Delete' }).click(); + await expect(dialog).toBeHidden(); + await expect.poll(() => fs.existsSync(folderPath)).toBe(false); + await expect(window.getByTitle('doomed')).toBeHidden(); + }); +}); diff --git a/e2e/tests/projects/project-details.spec.ts b/e2e/tests/projects/project-details.spec.ts new file mode 100644 index 00000000..7cc727da --- /dev/null +++ b/e2e/tests/projects/project-details.spec.ts @@ -0,0 +1,178 @@ +/** + * Project Details Screen Tests + * + * The /app route once a project is open: side panel tabs, the empty editor, + * the file tree root, and the Create Pipeline modal (blank template only, as + * community templates need GitHub). + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { Page } from '@playwright/test'; +import { test, expect } from '../../fixtures/electron-seeded.fixture'; +import { openProject } from '../../helpers/window.helper'; + +const PROJECT = 'test_project'; + +const openCreatePipelineModal = async (window: Page) => { + // The sidebar button is wrapped in a tooltip, which becomes its name + await window + .getByRole('button', { + name: /Create a pipeline to run jobs on the cloud|Create Pipeline/, + }) + .first() + .click(); + const dialog = window.getByRole('dialog'); + await expect(dialog).toBeVisible(); + return dialog; +}; + +test.describe('Project Details', () => { + test('should show the explorer panel tabs and the empty editor', async ({ + electronApp, + }) => { + const window = await openProject(electronApp, PROJECT); + + await expect(window.getByRole('tab', { name: 'Explorer' })).toBeVisible(); + await expect(window.getByRole('tab', { name: 'Search' })).toBeVisible(); + await expect(window.getByRole('tab', { name: /Git/ })).toBeVisible(); + await expect(window.getByRole('tab', { name: 'Database' })).toBeVisible(); + + await expect( + window.getByText('Please select a file from the explorer on the left!'), + ).toBeVisible(); + }); + + test('should list the project folder in the file tree', async ({ + electronApp, + }) => { + const window = await openProject(electronApp, PROJECT); + + await expect(window.getByTitle(PROJECT).first()).toBeVisible(); + await expect(window.getByTitle('rosetta').first()).toBeVisible(); + await expect( + window.getByPlaceholder('Search files or folders...'), + ).toBeVisible(); + }); + + test('should show the Database tab state for the linked connection', async ({ + electronApp, + }) => { + const window = await openProject(electronApp, PROJECT); + + await window.getByRole('tab', { name: 'Database' }).click(); + + await expect(window.getByText('Active Connection')).toBeVisible(); + await expect(window.getByText('test_db')).toBeVisible(); + await expect(window.getByRole('button', { name: 'Edit' })).toBeVisible(); + await expect(window.getByRole('button', { name: 'Remove' })).toBeVisible(); + await expect( + window.getByRole('button', { name: 'Change Connection' }), + ).toBeVisible(); + }); + + test.describe('Create Pipeline modal', () => { + test('should list the built-in templates and gate Next on a selection', async ({ + electronApp, + }) => { + const window = await openProject(electronApp, PROJECT); + const dialog = await openCreatePipelineModal(window); + + await expect(dialog.getByText('Create Pipeline')).toBeVisible(); + await expect(dialog.getByText('Create Blank')).toBeVisible(); + // exact: the file path caption also contains "generic" + await expect(dialog.getByText('Generic', { exact: true })).toBeVisible(); + await expect(dialog.getByText('Browse Templates')).toBeVisible(); + + const next = dialog.getByRole('button', { name: 'Next' }); + await expect(next).toBeDisabled(); + + await dialog.getByText('Generic', { exact: true }).click(); + await expect(next).toBeEnabled(); + + await dialog.getByRole('button', { name: 'Cancel' }).click(); + await expect(dialog).toBeHidden(); + }); + + test('should create a blank pipeline file on disk', async ({ + electronApp, + userData, + }) => { + const window = await openProject(electronApp, PROJECT); + const pipelineFile = path.join( + userData, + 'projects', + PROJECT, + 'rosetta', + 'pipelines', + 'pipeline-blank.yml', + ); + expect(fs.existsSync(pipelineFile)).toBe(false); + + const dialog = await openCreatePipelineModal(window); + await dialog.getByText('Create Blank').click(); + await dialog.getByRole('button', { name: 'Next' }).click(); + + await expect(dialog.getByText('Choose Location')).toBeVisible(); + await expect( + dialog.getByText('Pipeline will be created at:'), + ).toBeVisible(); + await dialog + .getByRole('button', { name: 'Create Pipeline', exact: true }) + .click(); + + await expect( + window + .locator('.Toastify__toast--success') + .filter({ hasText: 'Pipeline created successfully.' }), + ).toBeVisible(); + await expect(dialog).toBeHidden(); + await expect.poll(() => fs.existsSync(pipelineFile)).toBe(true); + expect(fs.readFileSync(pipelineFile, 'utf8')).toContain( + 'name: "Pipeline"', + ); + + // The new file shows up in the tree + await expect( + window.getByText('pipeline-blank.yml').first(), + ).toBeVisible(); + }); + + test('should warn before overriding an existing pipeline', async ({ + electronApp, + userData, + }) => { + const window = await openProject(electronApp, PROJECT); + const pipelinesDir = path.join( + userData, + 'projects', + PROJECT, + 'rosetta', + 'pipelines', + ); + const pipelineFile = path.join(pipelinesDir, 'pipeline-blank.yml'); + + // Seed an existing pipeline with the same file name + fs.mkdirSync(pipelinesDir, { recursive: true }); + fs.writeFileSync(pipelineFile, 'name: "Existing"\njobs: []\n'); + + const dialog = await openCreatePipelineModal(window); + await dialog.getByText('Create Blank').click(); + await dialog.getByRole('button', { name: 'Next' }).click(); + await dialog + .getByRole('button', { name: 'Create Pipeline', exact: true }) + .click(); + + await expect(window.getByText('Pipeline Already Exists')).toBeVisible(); + + // Cancelling keeps the original content + await window + .getByRole('dialog') + .filter({ hasText: 'Pipeline Already Exists' }) + .getByRole('button', { name: 'Cancel' }) + .click(); + await expect(window.getByText('Pipeline Already Exists')).toBeHidden(); + expect(fs.readFileSync(pipelineFile, 'utf8')).toContain('Existing'); + }); + }); +}); diff --git a/e2e/tests/settings/settings-sections.spec.ts b/e2e/tests/settings/settings-sections.spec.ts new file mode 100644 index 00000000..f79dbd41 --- /dev/null +++ b/e2e/tests/settings/settings-sections.spec.ts @@ -0,0 +1,170 @@ +/** + * Settings Sections Tests + * + * Walks the settings side panel and checks each offline-safe section renders + * its expected content. Sections that hit the network on mount (Local Runner, + * dbt Core, Python, Rosetta CLI, Flowfile) and Keystore (macOS keychain) are + * intentionally not opened here. + */ + +import { Page } from '@playwright/test'; +import { test, expect } from '../../fixtures/electron-seeded.fixture'; +import { openProject } from '../../helpers/window.helper'; +import { NavigationSidebarComponent } from '../../page-objects/components/NavigationSidebar'; + +const PROJECT = 'test_project'; + +const openSettings = async (electronApp: Parameters[0]) => { + const window = await openProject(electronApp, PROJECT); + const nav = new NavigationSidebarComponent(window); + await nav.goToSettings(); + await expect(window.getByLabel('Projects Directory')).toBeVisible(); + return window; +}; + +const openSection = async (window: Page, label: string) => { + await window.getByText(label, { exact: true }).click(); +}; + +test.describe('Settings', () => { + test('should land on General with the projects directory', async ({ + electronApp, + userData, + }) => { + const window = await openSettings(electronApp); + + await expect.poll(() => window.url()).toContain('/app/settings/general'); + await expect(window.getByLabel('Projects Directory')).toHaveValue( + /projects$/, + ); + expect(userData.length).toBeGreaterThan(0); + await expect(window.getByText('Current Installation')).toBeVisible(); + await expect( + window.getByRole('button', { name: 'Check for Updates' }), + ).toBeVisible(); + }); + + test('should list every settings category', async ({ electronApp }) => { + const window = await openSettings(electronApp); + + // eslint-disable-next-line no-restricted-syntax + for (const label of [ + 'AI Settings', + 'Rosetta Cloud', + 'Keystore', + 'Task Manager', + 'Backup & Restore', + 'dbt™ Core', + 'Python', + 'Rosetta CLI', + 'DuckDB', + 'Flowfile', + 'Local Runner', + 'Documentation', + 'About', + ]) { + // eslint-disable-next-line no-await-in-loop + await expect(window.getByText(label, { exact: true })).toBeVisible(); + } + }); + + test('should show the empty Task Manager', async ({ electronApp }) => { + const window = await openSettings(electronApp); + await openSection(window, 'Task Manager'); + + await expect(window.getByText('No tasks currently running.')).toBeVisible(); + await expect(window.getByText('No completed tasks yet.')).toBeVisible(); + }); + + test('should show Backup & Restore actions', async ({ electronApp }) => { + const window = await openSettings(electronApp); + await openSection(window, 'Backup & Restore'); + + await expect(window.getByText('Export Backup')).toBeVisible(); + await expect( + window.getByRole('button', { name: 'Export ZIP' }), + ).toBeVisible(); + await expect(window.getByText('Import Backup')).toBeVisible(); + await expect( + window.getByRole('button', { name: 'Select Backup File' }), + ).toBeVisible(); + // Nothing selected yet, so import cannot run + await expect( + window.getByRole('button', { name: 'Import', exact: true }), + ).toBeDisabled(); + }); + + test('should show About with the app version and factory reset', async ({ + electronApp, + }) => { + const window = await openSettings(electronApp); + await openSection(window, 'About'); + + // In the unpackaged dev build app.getVersion() reports Electron's own + // version, while the renderer shows the package version, so match shape + await expect( + window.getByText(/^Version \d+\.\d+\.\d+.* \(Official Build\)$/), + ).toBeVisible(); + await expect( + window.getByRole('button', { name: 'Reset Factory Settings' }), + ).toBeVisible(); + }); + + test('should show AI Settings with no providers configured', async ({ + electronApp, + }) => { + const window = await openSettings(electronApp); + await openSection(window, 'AI Settings'); + + await expect(window.getByText('No AI Providers Configured')).toBeVisible(); + await expect( + window.getByRole('button', { name: 'Add Your First Provider' }), + ).toBeVisible(); + await expect(window.getByRole('tab', { name: 'Providers' })).toBeVisible(); + await expect( + window.getByRole('tab', { name: 'MCP Servers' }), + ).toBeVisible(); + }); + + test('should show the Rosetta Cloud section', async ({ electronApp }) => { + const window = await openSettings(electronApp); + await openSection(window, 'Rosetta Cloud'); + + // The connected/not-connected state depends on the host keychain (an + // API key stored there is visible to every test run), so only the + // always-present card heading is asserted. + await expect( + window.getByRole('heading', { name: 'Rosetta Cloud', exact: true }), + ).toBeVisible(); + await expect(window.getByText('Cloud Dashboard Connection')).toBeVisible(); + }); + + test('should switch the theme and persist it', async ({ electronApp }) => { + const window = await openSettings(electronApp); + + // Snapshot every localStorage entry so the theme key can be found by what + // changes, rather than by guessing MUI's storage key name. + const readStorage = () => + window.evaluate(() => ({ ...window.localStorage })); + const changedEntries = ( + before: Record, + after: Record, + ) => + Object.entries(after) + .filter(([key, value]) => before[key] !== value) + .map(([, value]) => value) + .join(' '); + + const initial = await readStorage(); + await window.getByRole('button', { name: 'Dark', exact: true }).click(); + await expect + .poll(async () => changedEntries(initial, await readStorage())) + .toContain('dark'); + + const afterDark = await readStorage(); + await window.getByRole('button', { name: 'Light', exact: true }).click(); + await expect + .poll(async () => changedEntries(afterDark, await readStorage())) + .toContain('light'); + }); +}); diff --git a/e2e/tests/shell/app-shell.spec.ts b/e2e/tests/shell/app-shell.spec.ts new file mode 100644 index 00000000..c1233ce5 --- /dev/null +++ b/e2e/tests/shell/app-shell.spec.ts @@ -0,0 +1,89 @@ +/** + * App Shell Tests + * + * The chrome around every screen: the top menu's project switcher and cloud + * login button, the status bar, and the AI assistant panel (without a + * configured provider). + */ + +import { test, expect } from '../../fixtures/electron-seeded.fixture'; +import { openProject } from '../../helpers/window.helper'; + +const PROJECT = 'test_project'; + +test.describe('App Shell', () => { + test('should show the selected project in the top menu', async ({ + electronApp, + }) => { + const window = await openProject(electronApp, PROJECT); + + await expect( + window.getByRole('button', { name: new RegExp(PROJECT) }).first(), + ).toBeVisible(); + // The dbt actions split button is only shown once a project is selected + await expect( + window.getByRole('button', { name: 'Project', exact: true }), + ).toBeVisible(); + // Note: the cloud login/dashboard button is not asserted because its + // state comes from the host keychain, which is shared across test runs. + }); + + test('should navigate to all projects from the project switcher', async ({ + electronApp, + }) => { + const window = await openProject(electronApp, PROJECT); + + await window + .getByRole('button', { name: new RegExp(PROJECT) }) + .first() + .click(); + await window.getByRole('menuitem', { name: 'All Projects' }).click(); + + await expect( + window.locator('[data-testid="project-selection"]'), + ).toBeVisible(); + await expect( + window.locator(`[data-testid="project-card-${PROJECT}"]`), + ).toBeVisible(); + }); + + test('should show version information in the status bar', async ({ + electronApp, + }) => { + const window = await openProject(electronApp, PROJECT); + + // The status bar shows the package version (app.getVersion() reports + // Electron's version in the unpackaged dev build), so match the shape + await expect(window.getByText(/^v\d+\.\d+\.\d+/)).toBeVisible(); + await expect(window.getByText(/Rosetta:\s*0\.0\.0-test/)).toBeVisible(); + await expect(window.getByText(/dbt:/)).toBeVisible(); + await expect(window.getByText(/Python:/)).toBeVisible(); + }); + + test('should open the AI assistant and point to provider settings', async ({ + electronApp, + }) => { + const window = await openProject(electronApp, PROJECT); + + await window.getByRole('button', { name: 'AI Assistant (beta)' }).click(); + + // First open asks about agent memory. The dialog is rendered inline in + // the chat pane (no portal), and MUI marks that subtree aria-hidden while + // it is open, so role-based queries cannot see its buttons; match by text. + await expect(window.getByText('AI Agent Memory')).toBeVisible(); + const keepOff = window.getByText('Keep memory off', { exact: true }); + await expect(keepOff).toBeVisible(); + await keepOff.click(); + await expect(window.getByText('AI Agent Memory')).toBeHidden(); + + await expect(window.getByText('No AI providers configured')).toBeVisible(); + await window + .getByRole('button', { name: 'Open AI Provider Settings' }) + .click(); + + await expect + .poll(() => window.url()) + .toContain('/app/settings/ai-providers'); + await expect(window.getByText('No AI Providers Configured')).toBeVisible(); + }); +}); From c2e5fdea0a2070652dd1923b7934b5aa82043fa2 Mon Sep 17 00:00:00 2001 From: ailegion Date: Wed, 16 Sep 2026 16:32:13 +0200 Subject: [PATCH 4/4] fix: e2e local storage typscript complaint --- e2e/tests/settings/settings-sections.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e/tests/settings/settings-sections.spec.ts b/e2e/tests/settings/settings-sections.spec.ts index f79dbd41..fb9a9a8d 100644 --- a/e2e/tests/settings/settings-sections.spec.ts +++ b/e2e/tests/settings/settings-sections.spec.ts @@ -144,8 +144,9 @@ test.describe('Settings', () => { // Snapshot every localStorage entry so the theme key can be found by what // changes, rather than by guessing MUI's storage key name. + // `window` here is the Playwright Page, so use the bare browser global const readStorage = () => - window.evaluate(() => ({ ...window.localStorage })); + window.evaluate(() => ({ ...localStorage }) as Record); const changedEntries = ( before: Record, after: Record,