Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion e2e/fixtures/electron.fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -43,6 +51,7 @@ export const test = base.extend<ElectronFixtures>({
// 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
Expand Down Expand Up @@ -70,7 +79,7 @@ export const test = base.extend<ElectronFixtures>({

// Launch Electron app
electronApp: async (
{ userData, autoSkipSetup, extraProjects },
{ userData, autoSkipSetup, extraProjects, skipQuickStartTour },
use,
testInfo,
) => {
Expand Down Expand Up @@ -174,6 +183,15 @@ export const test = base.extend<ElectronFixtures>({
},
});

// 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
Expand Down
32 changes: 32 additions & 0 deletions e2e/helpers/app.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<void> {
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
*/
Expand Down
81 changes: 81 additions & 0 deletions e2e/helpers/window.helper.ts
Original file line number Diff line number Diff line change
@@ -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<Page> => {
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<Page> => {
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<Page> => {
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;
};
101 changes: 52 additions & 49 deletions e2e/page-objects/components/NavigationSidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ====================
Expand All @@ -70,82 +81,75 @@ export class NavigationSidebarComponent extends BasePage {
}

/**
* Navigate to the SQL Editor section
* Navigate to the SQL Editor
*/
async goToSqlEditor(): Promise<void> {
await this.sqlNavItem.click();
}

/**
* Navigate to the Connections section
* Navigate to Notebooks
*/
async goToNotebooks(): Promise<void> {
await this.notebooksNavItem.click();
}

/**
* Navigate to Connections
*/
async goToConnections(): Promise<void> {
await this.connectionsNavItem.click();
}

/**
* Navigate to the Cloud Explorer section
* Navigate to Cloud Explorer
*/
async goToCloudExplorer(): Promise<void> {
await this.cloudExplorerNavItem.click();
}

/**
* Navigate to the DataLake section
* Navigate to Data Lake
*/
async goToDataLake(): Promise<void> {
await this.dataLakeNavItem.click();
}

/**
* Navigate to the AI Chat section
* Navigate to Flows
*/
async goToChat(): Promise<void> {
await this.chatNavItem.click();
async goToFlows(): Promise<void> {
await this.flowsNavItem.click();
}

/**
* Navigate to the Settings section
* Navigate to Settings
*/
async goToSettings(): Promise<void> {
await this.settingsNavItem.click();
}

/**
* Navigate to the Lineage section
*/
async goToLineage(): Promise<void> {
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<NavItem | null> {
const items: NavItem[] = [
'files',
'sql',
'connections',
'cloud-explorer',
'data-lake',
'chat',
'settings',
'lineage',
];

async getActiveItems(): Promise<NavItem[]> {
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 ====================
Expand All @@ -162,16 +166,15 @@ export class NavigationSidebarComponent extends BasePage {
*/
async expectActiveItem(item: NavItem): Promise<void> {
const navItem = this.getByTestId(`nav-item-${item}`);
await expect(navItem).toHaveAttribute('data-active', 'true');
await expect(navItem).toHaveClass(/(^|\s)active(\s|$)/);
}

/**
* Expect a specific navigation item to NOT be active
*/
async expectNotActiveItem(item: NavItem): Promise<void> {
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|$)/);
}

/**
Expand Down
Loading
Loading