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/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|$)/); } /** 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/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/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/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/settings/settings-sections.spec.ts b/e2e/tests/settings/settings-sections.spec.ts new file mode 100644 index 00000000..fb9a9a8d --- /dev/null +++ b/e2e/tests/settings/settings-sections.spec.ts @@ -0,0 +1,171 @@ +/** + * 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. + // `window` here is the Playwright Page, so use the bare browser global + const readStorage = () => + window.evaluate(() => ({ ...localStorage }) as Record); + 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(); + }); +}); 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); + }); +});