Skip to content

Commit 9982045

Browse files
authored
refactor: Re-write playwright tests using best practices + add eslint config (#1508)
Fixes: HDX-3075 * Refactors to using Page model * Extracts common interactions into components * Re-writes tests to conform to new model * Adds eslint plugin for playwright best practices * Fixes bad lints Note: The best practice is to not use `.waitForLoadState('networkidle')` however there are several instances where components are re-rendered completely due to underlying db queries. This causes flakiness in the tests. We will re-evaluate the best solution for this in a future ticket and remove the `networkidle` from the eslint ignore list.
1 parent 4c7cf80 commit 9982045

32 files changed

+2560
-1217
lines changed

packages/api/.env.e2e

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ OPAMP_PORT=24320
1515

1616
# Auto-create default connections and sources for new teams
1717
# Uses public demo ClickHouse instance with pre-populated data
18-
DEFAULT_CONNECTIONS='[{"name":"Demo ClickHouse","host":"https://sql-clickhouse.clickhouse.com","username":"otel_demo","password":""}]'
19-
DEFAULT_SOURCES='[{"from":{"databaseName":"otel_v2","tableName":"otel_logs"},"kind":"log","timestampValueExpression":"TimestampTime","name":"Demo Logs","displayedTimestampValueExpression":"Timestamp","implicitColumnExpression":"Body","serviceNameExpression":"ServiceName","eventAttributesExpression":"LogAttributes","resourceAttributesExpression":"ResourceAttributes","defaultTableSelectExpression":"Timestamp,ServiceName,SeverityText,Body","severityTextExpression":"SeverityText","traceIdExpression":"TraceId","spanIdExpression":"SpanId","connection":"Demo ClickHouse","traceSourceId":"Traces","metricSourceId":"Demo Metrics"},{"from":{"databaseName":"otel_v2","tableName":"otel_traces"},"kind":"trace","timestampValueExpression":"Timestamp","name":"Demo Traces","displayedTimestampValueExpression":"Timestamp","implicitColumnExpression":"SpanName","serviceNameExpression":"ServiceName","eventAttributesExpression":"SpanAttributes","resourceAttributesExpression":"ResourceAttributes","defaultTableSelectExpression":"Timestamp,ServiceName,StatusCode,round(Duration/1e6),SpanName","traceIdExpression":"TraceId","spanIdExpression":"SpanId","durationExpression":"Duration","durationPrecision":9,"parentSpanIdExpression":"ParentSpanId","spanNameExpression":"SpanName","spanKindExpression":"SpanKind","statusCodeExpression":"StatusCode","statusMessageExpression":"StatusMessage","connection":"Demo ClickHouse","logSourceId":"Demo Logs","metricSourceId":"Demo Metrics"},{"from":{"databaseName":"otel_v2","tableName":""},"kind":"metric","timestampValueExpression":"TimeUnix","name":"Demo Metrics","resourceAttributesExpression":"ResourceAttributes","serviceNameExpression":"ServiceName","metricTables":{"gauge":"otel_metrics_gauge","histogram":"otel_metrics_histogram","sum":"otel_metrics_sum","summary":"otel_metrics_summary","exponential histogram":"otel_metrics_exponential_histogram"},"connection":"Demo ClickHouse","logSourceId":"Demo Logs","traceSourceId":"Demo Traces"}]'
18+
DEFAULT_CONNECTIONS='[{"name":"local","host":"https://sql-clickhouse.clickhouse.com","username":"otel_demo","password":""}]'
19+
DEFAULT_SOURCES='[{"kind":"log","name":"Demo Logs","connection":"local","from":{"databaseName":"otel_v2","tableName":"otel_logs"},"timestampValueExpression":"TimestampTime","defaultTableSelectExpression":"Timestamp, ServiceName, SeverityText, Body","serviceNameExpression":"ServiceName","severityTextExpression":"SeverityText","eventAttributesExpression":"LogAttributes","resourceAttributesExpression":"ResourceAttributes","traceIdExpression":"TraceId","spanIdExpression":"SpanId","implicitColumnExpression":"Body","displayedTimestampValueExpression":"Timestamp","sessionSourceId":"Demo Sessions","traceSourceId":"Demo Traces","metricSourceId":"Demo Metrics"},{"kind":"trace","name":"Demo Traces","connection":"local","from":{"databaseName":"otel_v2","tableName":"otel_traces"},"timestampValueExpression":"Timestamp","defaultTableSelectExpression":"Timestamp, ServiceName, StatusCode, round(Duration / 1e6), SpanName","serviceNameExpression":"ServiceName","eventAttributesExpression":"SpanAttributes","resourceAttributesExpression":"ResourceAttributes","traceIdExpression":"TraceId","spanIdExpression":"SpanId","implicitColumnExpression":"SpanName","durationExpression":"Duration","durationPrecision":9,"parentSpanIdExpression":"ParentSpanId","spanKindExpression":"SpanKind","spanNameExpression":"SpanName","logSourceId":"Demo Logs","statusCodeExpression":"StatusCode","statusMessageExpression":"StatusMessage","spanEventsValueExpression":"Events","metricSourceId":"Demo Metrics","sessionSourceId":"Demo Sessions"},{"kind":"metric","name":"Demo Metrics","connection":"local","from":{"databaseName":"otel_v2","tableName":""},"timestampValueExpression":"TimeUnix","serviceNameExpression":"ServiceName","metricTables":{"gauge":"otel_metrics_gauge","histogram":"otel_metrics_histogram","sum":"otel_metrics_sum","summary":"otel_metrics_summary","exponential histogram":"otel_metrics_exponential_histogram"},"resourceAttributesExpression":"ResourceAttributes","logSourceId":"Demo Logs"},{"kind":"session","name":"Demo Sessions","connection":"local","from":{"databaseName":"otel_v2","tableName":"hyperdx_sessions"},"timestampValueExpression":"TimestampTime","defaultTableSelectExpression":"Timestamp, ServiceName, Body","serviceNameExpression":"ServiceName","severityTextExpression":"SeverityText","eventAttributesExpression":"LogAttributes","resourceAttributesExpression":"ResourceAttributes","traceSourceId":"Demo Traces","traceIdExpression":"TraceId","spanIdExpression":"SpanId","implicitColumnExpression":"Body"},{"kind":"trace","name":"ClickPy Traces","connection":"local","from":{"databaseName":"otel_clickpy","tableName":"otel_traces"},"timestampValueExpression":"Timestamp","defaultTableSelectExpression":"Timestamp, ServiceName, StatusCode, round(Duration / 1e6), SpanName","serviceNameExpression":"ServiceName","eventAttributesExpression":"SpanAttributes","resourceAttributesExpression":"ResourceAttributes","traceIdExpression":"TraceId","spanIdExpression":"SpanId","implicitColumnExpression":"SpanName","durationExpression":"Duration","durationPrecision":9,"parentSpanIdExpression":"ParentSpanId","spanKindExpression":"SpanKind","spanNameExpression":"SpanName","statusCodeExpression":"StatusCode","statusMessageExpression":"StatusMessage","spanEventsValueExpression":"Events","highlightedTraceAttributeExpressions":[{"sqlExpression":"if((SpanAttributes['http.route']) LIKE '%dashboard%', concat('https://clickpy.clickhouse.com', path(SpanAttributes['http.target'])), '')","alias":"clickpy_link"}],"sessionSourceId":"ClickPy Sessions"},{"kind":"session","name":"ClickPy Sessions","connection":"local","from":{"databaseName":"otel_clickpy","tableName":"hyperdx_sessions"},"timestampValueExpression":"TimestampTime","defaultTableSelectExpression":"Timestamp, ServiceName, Body","serviceNameExpression":"ServiceName","severityTextExpression":"SeverityText","eventAttributesExpression":"LogAttributes","resourceAttributesExpression":"ResourceAttributes","traceSourceId":"ClickPy Traces","traceIdExpression":"TraceId","spanIdExpression":"SpanId","implicitColumnExpression":"Body"}]'
20+

packages/app/eslint.config.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import reactHooksPlugin from 'eslint-plugin-react-hooks';
77
import prettierConfig from 'eslint-config-prettier';
88
import simpleImportSort from 'eslint-plugin-simple-import-sort';
99
import prettierPlugin from 'eslint-plugin-prettier/recommended';
10+
import playwrightPlugin from 'eslint-plugin-playwright';
1011

1112
export default [
1213
js.configs.recommended,
@@ -116,11 +117,14 @@ export default [
116117
},
117118
{
118119
files: ['tests/e2e/**/*.{ts,js}'],
120+
...playwrightPlugin.configs['flat/recommended'],
119121
rules: {
122+
...playwrightPlugin.configs['flat/recommended'].rules,
120123
'no-console': 'off',
121124
'no-empty': 'off',
122125
'@typescript-eslint/no-explicit-any': 'off',
123126
'@next/next/no-html-link-for-pages': 'off',
127+
'playwright/no-networkidle': 'off', // temporary until we have a better way to deal with react re-renders
124128
},
125129
},
126130
...storybook.configs['flat/recommended'],

packages/app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@
134134
"@types/react-table": "^7.7.14",
135135
"@types/sqlstring": "^2.3.2",
136136
"eslint-config-next": "^16.0.10",
137+
"eslint-plugin-playwright": "^2.4.0",
137138
"eslint-plugin-storybook": "10.1.4",
138139
"identity-obj-proxy": "^3.0.0",
139140
"jest": "^30.2.0",

packages/app/src/components/DBSearchPageFilters.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ export const FilterCheckbox = ({
255255
</div>
256256
{pinned && (
257257
<Center me="1px">
258-
<IconPinFilled size={12} />
258+
<IconPinFilled size={12} data-testid={`filter-pin-${label}-pinned`} />
259259
</Center>
260260
)}
261261
</div>
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/**
2+
* ChartEditorComponent - Reusable component for chart/tile editor
3+
* Used for creating and configuring dashboard tiles and chart explorer
4+
*/
5+
import { Locator, Page } from '@playwright/test';
6+
7+
export class ChartEditorComponent {
8+
readonly page: Page;
9+
private readonly chartNameInput: Locator;
10+
private readonly sourceSelector: Locator;
11+
private readonly metricSelector: Locator;
12+
private readonly runQueryButton: Locator;
13+
private readonly saveButton: Locator;
14+
15+
constructor(page: Page) {
16+
this.page = page;
17+
this.chartNameInput = page.getByTestId('chart-name-input');
18+
this.sourceSelector = page.getByTestId('source-selector');
19+
this.metricSelector = page.getByTestId('metric-name-selector');
20+
this.runQueryButton = page.getByTestId('chart-run-query-button');
21+
this.saveButton = page.getByTestId('chart-save-button');
22+
}
23+
24+
/**
25+
* Set chart name
26+
*/
27+
async setChartName(name: string) {
28+
await this.chartNameInput.fill(name);
29+
}
30+
31+
/**
32+
* Select a data source
33+
*/
34+
async selectSource(sourceName: string) {
35+
await this.sourceSelector.click();
36+
// Use getByRole for more reliable selection
37+
const sourceOption = this.page.getByRole('option', { name: sourceName });
38+
await sourceOption.click({ timeout: 5000 });
39+
}
40+
41+
/**
42+
* Select a metric by name
43+
*/
44+
async selectMetric(metricName: string, metricValue?: string) {
45+
// Wait for metric selector to be visible
46+
await this.metricSelector.waitFor({ state: 'visible', timeout: 5000 });
47+
48+
// Click to open dropdown
49+
await this.metricSelector.click();
50+
51+
// Type to filter
52+
await this.metricSelector.fill(metricName);
53+
54+
// If a specific metric value is provided, wait for and click it
55+
if (metricValue) {
56+
// Use attribute selector for combobox options
57+
const targetMetricOption = this.page.locator(
58+
`[data-combobox-option="true"][value="${metricValue}"]`,
59+
);
60+
await targetMetricOption.waitFor({ state: 'visible', timeout: 5000 });
61+
await targetMetricOption.click({ timeout: 5000 });
62+
} else {
63+
// Otherwise just press Enter to select the first match
64+
await this.page.keyboard.press('Enter');
65+
}
66+
}
67+
68+
/**
69+
* Run the query and wait for it to complete
70+
*/
71+
async runQuery() {
72+
await this.runQueryButton.click();
73+
}
74+
75+
/**
76+
* Save the chart/tile and wait for modal to close
77+
*/
78+
async save() {
79+
await this.saveButton.click();
80+
// Wait for save button to disappear (modal closes)
81+
await this.saveButton.waitFor({ state: 'hidden', timeout: 2000 });
82+
}
83+
84+
/**
85+
* Wait for chart editor data to load (sources, metrics, etc.)
86+
*/
87+
async waitForDataToLoad() {
88+
await this.runQueryButton.waitFor({ state: 'visible', timeout: 2000 });
89+
await this.page.waitForLoadState('networkidle');
90+
}
91+
92+
/**
93+
* Complete workflow: create a basic chart with name and save
94+
*/
95+
async createBasicChart(name: string) {
96+
// Wait for data sources to load before interacting
97+
await this.waitForDataToLoad();
98+
await this.setChartName(name);
99+
await this.runQuery();
100+
await this.save();
101+
}
102+
103+
/**
104+
* Complete workflow: create a chart with specific source and metric
105+
*/
106+
async createChartWithMetric(
107+
chartName: string,
108+
sourceName: string,
109+
metricName: string,
110+
metricValue?: string,
111+
) {
112+
// Wait for data sources to load before interacting
113+
await this.waitForDataToLoad();
114+
await this.selectSource(sourceName);
115+
await this.selectMetric(metricName, metricValue);
116+
await this.runQuery();
117+
await this.save();
118+
}
119+
120+
// Getters for assertions
121+
122+
get nameInput() {
123+
return this.chartNameInput;
124+
}
125+
126+
get source() {
127+
return this.sourceSelector;
128+
}
129+
130+
get metric() {
131+
return this.metricSelector;
132+
}
133+
134+
get runButton() {
135+
return this.runQueryButton;
136+
}
137+
138+
get saveBtn() {
139+
return this.saveButton;
140+
}
141+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/**
2+
* FilterComponent - Reusable component for search filters
3+
* Used for applying, excluding, pinning, and searching filters
4+
*/
5+
import { Locator, Page } from '@playwright/test';
6+
7+
export class FilterComponent {
8+
readonly page: Page;
9+
10+
constructor(page: Page) {
11+
this.page = page;
12+
}
13+
14+
/**
15+
* Get filter group by name
16+
* @param filterName - e.g., 'SeverityText', 'ServiceName'
17+
*/
18+
getFilterGroup(filterName: string) {
19+
return this.page.getByTestId(`filter-group-${filterName}`);
20+
}
21+
22+
/**
23+
* Get filter group control (clickable header)
24+
*/
25+
getFilterGroupControl(index?: number) {
26+
const controls = this.page.getByTestId('filter-group-control');
27+
return index !== undefined ? controls.nth(index) : controls;
28+
}
29+
30+
/**
31+
* Open/expand a filter group
32+
*/
33+
async openFilterGroup(filterName: string) {
34+
await this.getFilterGroup(filterName).click();
35+
}
36+
37+
/**
38+
* Get checkbox for a specific filter value
39+
* @param valueName - e.g., 'info', 'error', 'debug'
40+
*/
41+
getFilterCheckbox(valueName: string) {
42+
return this.page.getByTestId(`filter-checkbox-${valueName}`);
43+
}
44+
45+
/**
46+
* Get checkbox input element
47+
*/
48+
getFilterCheckboxInput(valueName: string) {
49+
return this.page.getByTestId(`filter-checkbox-input-${valueName}`);
50+
}
51+
52+
/**
53+
* Apply/select a filter value
54+
*/
55+
async applyFilter(valueName: string) {
56+
const checkbox = this.getFilterCheckbox(valueName);
57+
await checkbox.click();
58+
}
59+
60+
/**
61+
* Exclude a filter value (invert the filter)
62+
*/
63+
async excludeFilter(valueName: string) {
64+
const filterCheckbox = this.getFilterCheckbox(valueName);
65+
await filterCheckbox.hover();
66+
67+
const excludeButton = this.page.getByTestId(`filter-exclude-${valueName}`);
68+
await excludeButton.first().click();
69+
}
70+
71+
/**
72+
* Pin a filter value to persist it
73+
*/
74+
async pinFilter(valueName: string) {
75+
const filterCheckbox = this.getFilterCheckbox(valueName);
76+
await filterCheckbox.hover();
77+
78+
const pinButton = this.page.getByTestId(`filter-pin-${valueName}`);
79+
await pinButton.click();
80+
}
81+
82+
/**
83+
* Clear/unselect a filter
84+
*/
85+
async clearFilter(valueName: string) {
86+
const input = this.getFilterCheckboxInput(valueName);
87+
const checkbox = this.getFilterCheckbox(valueName);
88+
await checkbox.click();
89+
await input.click();
90+
}
91+
92+
/**
93+
* Get filter search input
94+
*/
95+
getFilterSearchInput(filterName: string) {
96+
return this.page.getByTestId(`filter-search-${filterName}`);
97+
}
98+
99+
/**
100+
* Search within a filter's values
101+
*/
102+
async searchFilterValues(filterName: string, searchText: string) {
103+
const searchInput = this.getFilterSearchInput(filterName);
104+
await searchInput.fill(searchText);
105+
}
106+
107+
/**
108+
* Clear filter search
109+
*/
110+
async clearFilterSearch(filterName: string) {
111+
const searchInput = this.getFilterSearchInput(filterName);
112+
await searchInput.clear();
113+
}
114+
115+
/**
116+
* Find and expand first filter group that has a search input (>5 values)
117+
* Returns the filter name if found, null otherwise
118+
*/
119+
async findFilterWithSearch(skipNames: string[] = []): Promise<string | null> {
120+
const filterControls = this.getFilterGroupControl();
121+
const count = await filterControls.count();
122+
123+
for (let i = 0; i < Math.min(count, 5); i++) {
124+
const filter = filterControls.nth(i);
125+
const filterText = (await filter.textContent()) || '';
126+
const filterName = filterText.trim().replace(/\s*\(\d+\)\s*$/, '');
127+
128+
// Skip filters in the skip list
129+
if (skipNames.some(skip => filterName.toLowerCase().includes(skip))) {
130+
continue;
131+
}
132+
133+
// Expand the filter
134+
await filter.click();
135+
136+
// Check if search input appears
137+
const searchInput = this.getFilterSearchInput(filterName);
138+
139+
try {
140+
await searchInput.waitFor({ state: 'visible', timeout: 1000 });
141+
// Search input is visible, return this filter name
142+
return filterName;
143+
} catch (e) {
144+
// Search input not visible, collapse and try next
145+
await filter.click();
146+
}
147+
}
148+
149+
return null;
150+
}
151+
152+
/**
153+
* Check if filter checkbox is indeterminate (excluded state)
154+
*/
155+
async isFilterExcluded(valueName: string): Promise<boolean> {
156+
const input = this.getFilterCheckboxInput(valueName);
157+
const indeterminate = await input.getAttribute('data-indeterminate');
158+
return indeterminate === 'true';
159+
}
160+
161+
/**
162+
* Get all filter values for a specific filter group
163+
*/
164+
getFilterValues(filterGroupName: string) {
165+
return this.page.getByTestId(`filter-checkbox-${filterGroupName}`);
166+
}
167+
}

0 commit comments

Comments
 (0)