Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
* Copyright 2026 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

const AGGREGATE_ENDPOINT = 'search/aggregate';
const HELPER_MODULE = 'searchAggregation';

/** Mirrors the resolver in openmetadata-performance.mjs. */
const findVariable = (sourceCode, identifier) => {
let scope = sourceCode.getScope(identifier);

while (scope) {
const variable = scope.set.get(identifier.name);

if (variable) {
return variable;
}

scope = scope.upper;
}

return null;
};

/**
* Source text of the matcher, following an identifier to every value assigned to
* it in this file — declaration or later assignment, at any scope. A matcher
* built in another module is out of reach, since ESLint sees one file at a time.
*/
const resolveMatcherText = (argument, sourceCode) => {
if (argument.type !== 'Identifier') {
return sourceCode.getText(argument);
}

const variable = findVariable(sourceCode, argument);
const assigned = [
...(variable?.defs ?? []).map((def) => def.node?.init),
...(variable?.references ?? [])
.filter((reference) => reference.writeExpr)
.map((reference) => reference.writeExpr),
].filter(Boolean);

return assigned.map((node) => sourceCode.getText(node)).join('\n');
};

/**
* A wait naming only the endpoint or the field matches both aggregations a
* dropdown fires — the one on open and the typed search — so it can resolve on
* the wrong one (#31859). `waitForAggregation` requires the value that tells
* them apart.
*/
const requireAggregationWaitHelper = {
meta: {
messages: {
rawAggregationWait:
'Use waitForAggregation from playwright/utils/searchAggregation instead of waiting on search/aggregate directly — a wait that names only the endpoint or field also matches the dropdown-open request and can resolve early.',
},
schema: [],
type: 'problem',
},
create(context) {
const { sourceCode } = context;

if (context.filename.includes(HELPER_MODULE)) {
return {};
}

return {
CallExpression(node) {
const isWaitForResponse =
node.callee.type === 'MemberExpression' &&
!node.callee.computed &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'waitForResponse';

if (!isWaitForResponse || node.arguments.length === 0) {
return;
}

// The matcher may be a string, template literal or URL predicate, so
// match on source text rather than evaluating each form. Quotes and
// concatenation come out first so a path split across literals still
// reads as one string.
const matcherText = resolveMatcherText(
node.arguments[0],
sourceCode
).replace(/['"`+\s]/g, '');

if (matcherText.includes(AGGREGATE_ENDPOINT)) {
context.report({ node, messageId: 'rawAggregationWait' });
}
},
};
},
};

export default {
rules: {
'require-aggregation-wait-helper': requireAggregationWaitHelper,
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*
* Copyright 2026 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import assert from 'node:assert/strict';
import test, { describe, it } from 'node:test';
import { RuleTester } from 'eslint';
import tseslint from 'typescript-eslint';

RuleTester.describe = describe;
RuleTester.it = it;

const playwrightPlugin = (await import('./openmetadata-playwright.mjs'))
.default;

test('exports the aggregation wait helper rule', () => {
assert.ok(playwrightPlugin.rules['require-aggregation-wait-helper']);
});

const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 'latest',
parser: tseslint.parser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
sourceType: 'module',
},
});

ruleTester.run(
'require-aggregation-wait-helper',
playwrightPlugin.rules['require-aggregation-wait-helper'],
{
valid: [
{
code: "const res = waitForAggregation(page, { field: 'domains.displayName.keyword', value: 'sales' });",
filename: 'playwright/e2e/Flow/Example.spec.ts',
},
{
code: "const res = page.waitForResponse('/api/v1/search/query?*deleted=true*');",
filename: 'playwright/e2e/Flow/Example.spec.ts',
},
{
code: "const res = page.waitForResponse((response) => response.url().includes('/api/v1/tables'));",
filename: 'playwright/e2e/Flow/Example.spec.ts',
},
{
// The helper itself owns the only raw wait on the endpoint.
code: "const res = page.waitForResponse((response) => response.url().includes('/api/v1/search/aggregate'));",
filename: 'playwright/utils/searchAggregation.ts',
},
{
code: `
const queryUrl = '/api/v1/search/query?*index=dataAsset*';
const res = page.waitForResponse(queryUrl);
`,
filename: 'playwright/e2e/Flow/Example.spec.ts',
},
],
invalid: [
{
code: "const res = page.waitForResponse('/api/v1/search/aggregate?*');",
errors: [{ messageId: 'rawAggregationWait' }],
filename: 'playwright/e2e/Flow/Example.spec.ts',
},
{
code: 'const res = page.waitForResponse(`/api/v1/search/aggregate?index=dataAsset&field=${field}*`);',
errors: [{ messageId: 'rawAggregationWait' }],
filename: 'playwright/e2e/Flow/Example.spec.ts',
},
{
code: "const res = page.waitForResponse((response) => response.url().includes('/api/v1/search/aggregate') && response.url().includes(field));",
errors: [{ messageId: 'rawAggregationWait' }],
filename: 'playwright/utils/glossary.ts',
},
{
// Hoisting the URL to a local const is the realistic accidental evasion.
code: `
const aggregateUrl = '/api/v1/search/aggregate?*';
const res = page.waitForResponse(aggregateUrl);
`,
errors: [{ messageId: 'rawAggregationWait' }],
filename: 'playwright/e2e/Flow/Example.spec.ts',
},
{
code: `
const aggregateUrl = \`/api/v1/search/aggregate?index=dataAsset&field=\${field}*\`;
const res = page.waitForResponse(aggregateUrl);
`,
errors: [{ messageId: 'rawAggregationWait' }],
filename: 'playwright/utils/explore.ts',
},
{
// Declared at module scope, used inside a test callback.
code: `
const aggregateUrl = '/api/v1/search/aggregate?*';
test('example', async ({ page }) => {
const res = page.waitForResponse(aggregateUrl);
});
`,
errors: [{ messageId: 'rawAggregationWait' }],
filename: 'playwright/e2e/Flow/Example.spec.ts',
},
{
// Assigned after declaration, so the variable has no initialiser.
code: `
let aggregateUrl;
aggregateUrl = '/api/v1/search/aggregate?*';
const res = page.waitForResponse(aggregateUrl);
`,
errors: [{ messageId: 'rawAggregationWait' }],
filename: 'playwright/e2e/Flow/Example.spec.ts',
},
{
// Path split across concatenated literals.
code: "const res = page.waitForResponse('/api/v1/search/' + 'aggregate?*');",
errors: [{ messageId: 'rawAggregationWait' }],
filename: 'playwright/e2e/Flow/Example.spec.ts',
},
],
}
);
10 changes: 10 additions & 0 deletions openmetadata-ui/src/main/resources/ui/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import jsoncParser from 'jsonc-eslint-parser';
import tseslint from 'typescript-eslint';
import openMetadataImports from './eslint-rules/openmetadata-imports.mjs';
import openMetadataPerformance from './eslint-rules/openmetadata-performance.mjs';
import openMetadataPlaywright from './eslint-rules/openmetadata-playwright.mjs';

export default [
// Base recommended configs
Expand Down Expand Up @@ -428,6 +429,7 @@ export default [
{
files: ['**/playwright/**/*.{js,jsx,ts,tsx}'],
plugins: {
'openmetadata-playwright': openMetadataPlaywright,
playwright,
},
rules: {
Expand Down Expand Up @@ -491,6 +493,14 @@ export default [
'playwright/no-page-pause': 'error',
'playwright/no-focused-test': 'error',

// A facet aggregation wait must name the value it is waiting for, not just
// the endpoint or field: a dropdown fires one aggregation when it opens and
// one per typed search, so a wait that names neither can resolve off the
// wrong one and run the test ahead of the request it queued (#31859). Warn
// rather than error while the remaining 27 call sites are migrated to
// playwright/utils/searchAggregation.ts.
'openmetadata-playwright/require-aggregation-wait-helper': 'warn',

// Playwright rules — aspirational (warn): existing violations to fix over time
'playwright/missing-playwright-await': 'warn',
'playwright/valid-expect': 'warn',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from '../../utils/entity';
import { clickUpdateButtonIfVisible } from '../../utils/explore';
import { getJsonTreeObject } from '../../utils/exploreDiscovery';
import { waitForAggregation } from '../../utils/searchAggregation';
import { sidebarClick } from '../../utils/sidebar';

// use the admin user to login
Expand Down Expand Up @@ -257,13 +258,11 @@ test.describe('Explore Assets Discovery', () => {

// The user should not be visible in the owners filter when the deleted switch is off
await page.click('[data-testid="search-dropdown-Owners"]');
// Match on the typed value: opening the dropdown fires its own aggregation
// for the same field, and a glob matching both races ahead of the search.
const searchResOwner = page.waitForResponse(
`/api/v1/search/aggregate?index=dataAsset&field=ownerDisplayName&value=*${encodeURIComponent(
user.responseData.displayName
)}*deleted=false*`
);
const searchResOwner = waitForAggregation(page, {
deleted: false,
field: 'ownerDisplayName',
value: user.responseData.displayName,
});

await page.fill(
'[data-testid="search-input"]',
Expand All @@ -284,11 +283,11 @@ test.describe('Explore Assets Discovery', () => {
// The domain should not be visible in the domains filter when the deleted switch is off
await page.click('[data-testid="search-dropdown-Domains"]');

const searchResDomain = page.waitForResponse(
`/api/v1/search/aggregate?index=dataAsset&field=domains.displayName.keyword&value=*${encodeURIComponent(
domain.responseData.displayName
)}*deleted=false*`
);
const searchResDomain = waitForAggregation(page, {
deleted: false,
field: 'domains.displayName.keyword',
value: domain.responseData.displayName,
});

await page.fill(
'[data-testid="search-input"]',
Expand Down Expand Up @@ -323,12 +322,11 @@ test.describe('Explore Assets Discovery', () => {
const ownerSearchText = user.responseData.displayName.toLowerCase();
await page.click('[data-testid="search-dropdown-Owners"]');

// Match on the typed value, not the dropdown's own initial aggregation.
const searchResOwner = page.waitForResponse(
`/api/v1/search/aggregate?index=dataAsset&field=ownerDisplayName&value=*${encodeURIComponent(
ownerSearchText
)}*deleted=true*`
);
const searchResOwner = waitForAggregation(page, {
deleted: true,
field: 'ownerDisplayName',
value: ownerSearchText,
});

await page.fill('[data-testid="search-input"]', ownerSearchText);
await searchResOwner;
Expand Down Expand Up @@ -364,11 +362,11 @@ test.describe('Explore Assets Discovery', () => {
const domainSearchText = domain.responseData.displayName.toLowerCase();
await page.click('[data-testid="search-dropdown-Domains"]');

const searchResDomain = page.waitForResponse(
`/api/v1/search/aggregate?index=dataAsset&field=domains.displayName.keyword&value=*${encodeURIComponent(
domainSearchText
)}*deleted=true*`
);
const searchResDomain = waitForAggregation(page, {
deleted: true,
field: 'domains.displayName.keyword',
value: domainSearchText,
});

await page.fill('[data-testid="search-input"]', domainSearchText);
await searchResDomain;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { TagClass } from '../../support/tag/TagClass';
import { createNewPage, redirectToHomePage } from '../../utils/common';
import { waitForAllLoadersToDisappear } from '../../utils/entity';
import { clickUpdateButtonIfVisible } from '../../utils/explore';
import { waitForAggregation } from '../../utils/searchAggregation';
import { sidebarClick } from '../../utils/sidebar';

test.use({ storageState: 'playwright/.auth/admin.json' });
Expand Down Expand Up @@ -78,11 +79,10 @@ const resolveCertificationOptionKey = async (
await menu.waitFor({ state: 'visible' });
}

const aggregateResponse = page.waitForResponse(
(response) =>
response.url().includes('/api/v1/search/aggregate') &&
response.url().includes(CERTIFICATION_FIELD)
);
const aggregateResponse = waitForAggregation(page, {
field: CERTIFICATION_FIELD,
value: searchText,
});
await menu.getByTestId('search-input').fill(searchText);
const body = await (await aggregateResponse).json();
const buckets: Array<{ key: string }> =
Expand Down
Loading
Loading