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
4 changes: 4 additions & 0 deletions workspaces/boost/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,7 @@ dist-types

# Coverage
coverage

# Playwright
e2e-test-report/
test-results/
256 changes: 256 additions & 0 deletions workspaces/boost/e2e-tests/ai-catalog-browse.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
/*
* Copyright Red Hat, Inc.
*
* 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 { test, expect, type Page, type BrowserContext } from '@playwright/test';
import { runAccessibilityTests } from './utils/accessibility.js';
import { getTranslations, type BoostMessages } from './utils/translations.js';

test.describe.configure({ mode: 'serial' });

let page: Page;
let context: BrowserContext;
let t: BoostMessages;

// Total number of AI asset entities in fixtures/ai-catalog-fixtures.yaml
// (excludes Group entities used only as owners).
const FIXTURE_ASSET_COUNT = 7;

test.beforeAll(async ({ browser }) => {
context = await browser.newContext();
page = await context.newPage();
t = getTranslations();

// Land on the app and authenticate as guest
await page.goto('/');
await page.getByRole('button', { name: 'Enter' }).click();
});

test.afterAll(async () => {
await context.close();
});

// -----------------------------------------------------------------------
// 1. Browse page loads with fixture card grid
// -----------------------------------------------------------------------
test('browse page loads with fixture card grid', async () => {
await page.goto('/ai-catalog');

// Page title is visible
await expect(
page.getByRole('heading', { name: t.catalog.page.title }),
).toBeVisible();

// Toolbar shows the total count
await expect(
page.getByText(`${t.catalog.toolbar.allPrefix} (${FIXTURE_ASSET_COUNT})`),
).toBeVisible();

// Cards are rendered in the grid
const cards = page
.locator('[class*="card"]')
.filter({ has: page.locator('a') });
await expect(cards.first()).toBeVisible();
});

// -----------------------------------------------------------------------
// 2. Search filters cards, URL updates with ?q=
// -----------------------------------------------------------------------
test('search filters cards and URL updates with q param', async () => {
await page.goto('/ai-catalog');
await expect(
page.getByText(`${t.catalog.toolbar.allPrefix} (${FIXTURE_ASSET_COUNT})`),
).toBeVisible();

// Type a search query that matches only one fixture entity
const searchInput = page.getByPlaceholder(t.catalog.toolbar.search);
await searchInput.fill('Code Review Skill');

// Wait for filtered count
await expect(
page.getByText(`${t.catalog.toolbar.allPrefix} (1)`),
).toBeVisible();

// URL should contain q parameter
expect(page.url()).toContain('q=');
});

// -----------------------------------------------------------------------
// 3. Sidebar filter narrows results
// -----------------------------------------------------------------------
test('sidebar filter narrows results', async () => {
await page.goto('/ai-catalog');
await expect(
page.getByText(`${t.catalog.toolbar.allPrefix} (${FIXTURE_ASSET_COUNT})`),
).toBeVisible();

// Open the Type filter and select "Skill"
const typeFilter = page.getByLabel(t.catalog.filter.type);
await typeFilter.click();
await page.getByRole('option', { name: 'Skill' }).click();

// Close the dropdown by pressing Escape
await page.keyboard.press('Escape');

// Count should decrease — only code-review-skill is a skill
await expect(
page.getByText(`${t.catalog.toolbar.allPrefix} (1)`),
).toBeVisible();
});

// -----------------------------------------------------------------------
// 4. Multiple filters combine as AND
// -----------------------------------------------------------------------
test('multiple filters combine as AND', async () => {
await page.goto('/ai-catalog');
await expect(
page.getByText(`${t.catalog.toolbar.allPrefix} (${FIXTURE_ASSET_COUNT})`),
).toBeVisible();

// Select Owner = team-ai-platform (owns: code-review-skill, developer-assistant, docs-vector-store)
const ownerFilter = page.getByLabel(t.catalog.filter.owner);
await ownerFilter.click();
await page.getByRole('option', { name: 'team-ai-platform' }).click();
await page.keyboard.press('Escape');

await expect(
page.getByText(`${t.catalog.toolbar.allPrefix} (3)`),
).toBeVisible();

// Now also select Type = Skill — should narrow to code-review-skill only
const typeFilter = page.getByLabel(t.catalog.filter.type);
await typeFilter.click();
await page.getByRole('option', { name: 'Skill' }).click();
await page.keyboard.press('Escape');

await expect(
page.getByText(`${t.catalog.toolbar.allPrefix} (1)`),
).toBeVisible();
});

// -----------------------------------------------------------------------
// 5. Clear filters restores full grid
// -----------------------------------------------------------------------
test('clear filters restores full grid', async () => {
await page.goto('/ai-catalog');
await expect(
page.getByText(`${t.catalog.toolbar.allPrefix} (${FIXTURE_ASSET_COUNT})`),
).toBeVisible();

// Apply a search to narrow results
const searchInput = page.getByPlaceholder(t.catalog.toolbar.search);
await searchInput.fill('nonexistent-query-that-matches-nothing');

// Should show the empty filtered state
await expect(page.getByText(t.catalog.emptyFiltered.title)).toBeVisible();

// Click "Clear filters"
await page
.getByRole('button', { name: t.catalog.emptyFiltered.clearFilters })
.click();

// Full grid should be restored
await expect(
page.getByText(`${t.catalog.toolbar.allPrefix} (${FIXTURE_ASSET_COUNT})`),
).toBeVisible();
});

// -----------------------------------------------------------------------
// 6. Card click navigates to entity detail page
// -----------------------------------------------------------------------
test('card click navigates to entity detail page', async () => {
await page.goto('/ai-catalog');
await expect(
page.getByText(`${t.catalog.toolbar.allPrefix} (${FIXTURE_ASSET_COUNT})`),
).toBeVisible();

// Click the first card link — cards navigate to /catalog/<namespace>/<kind>/<name>
const firstCardLink = page.locator('a[href*="/catalog/"]').first();
await expect(firstCardLink).toBeVisible();
const href = await firstCardLink.getAttribute('href');
await firstCardLink.click();

// Should navigate to the entity detail page
await page.waitForURL('**/catalog/**');
expect(page.url()).toContain('/catalog/');
// The href we captured should match the current path
expect(page.url()).toContain(href!);
});

// -----------------------------------------------------------------------
// 7. Empty state on impossible filter combo
// -----------------------------------------------------------------------
test('empty state on impossible filter combo', async () => {
await page.goto('/ai-catalog');
await expect(
page.getByText(`${t.catalog.toolbar.allPrefix} (${FIXTURE_ASSET_COUNT})`),
).toBeVisible();

// Search for something that won't match any fixture
const searchInput = page.getByPlaceholder(t.catalog.toolbar.search);
await searchInput.fill('zzz-impossible-filter-no-match');

// The empty filtered state should appear
await expect(page.getByText(t.catalog.emptyFiltered.title)).toBeVisible();
await expect(
page.getByText(t.catalog.emptyFiltered.description),
).toBeVisible();
});

// -----------------------------------------------------------------------
// 8. Pagination controls work
// -----------------------------------------------------------------------
test('pagination controls are present', async () => {
await page.goto('/ai-catalog');
await expect(
page.getByText(`${t.catalog.toolbar.allPrefix} (${FIXTURE_ASSET_COUNT})`),
).toBeVisible();

// Pagination component should be visible (it renders even with few items)
// Look for the rows-per-page selector which is always present
const pagination = page.locator(
'[class*="pagination"], [class*="Pagination"]',
);
await expect(pagination.first()).toBeVisible();
});

// -----------------------------------------------------------------------
// 9. axe-core accessibility audit (unfiltered + filtered)
// -----------------------------------------------------------------------
test('accessibility audit on unfiltered browse page', async ({}, testInfo) => {
await page.goto('/ai-catalog');
await expect(
page.getByText(`${t.catalog.toolbar.allPrefix} (${FIXTURE_ASSET_COUNT})`),
).toBeVisible();

await runAccessibilityTests(page, testInfo, 'a11y-unfiltered.json');
});

test('accessibility audit on filtered browse page', async ({}, testInfo) => {
await page.goto('/ai-catalog');
await expect(
page.getByText(`${t.catalog.toolbar.allPrefix} (${FIXTURE_ASSET_COUNT})`),
).toBeVisible();

// Apply a search filter
const searchInput = page.getByPlaceholder(t.catalog.toolbar.search);
await searchInput.fill('granite');

await expect(
page.getByText(`${t.catalog.toolbar.allPrefix} (1)`),
).toBeVisible();

await runAccessibilityTests(page, testInfo, 'a11y-filtered.json');
});
38 changes: 38 additions & 0 deletions workspaces/boost/e2e-tests/utils/accessibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* Copyright Red Hat, Inc.
*
* 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 AxeBuilder from '@axe-core/playwright';
import { expect, Page, TestInfo } from '@playwright/test';

export async function runAccessibilityTests(
page: Page,
testInfo: TestInfo,
attachName = 'accessibility-scan-results.json',
) {
const accessibilityScanResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze();

await testInfo.attach(attachName, {
body: JSON.stringify(accessibilityScanResults, null, 2),
contentType: 'application/json',
});

expect(
accessibilityScanResults.violations,
'Accessibility violations found',
).toEqual([]);
}
26 changes: 26 additions & 0 deletions workspaces/boost/e2e-tests/utils/translations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* Copyright Red Hat, Inc.
*
* 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.
*/

// These translation files are not exported by the package, so relative imports are necessary for e2e tests
/* eslint-disable @backstage/no-relative-monorepo-imports */
import { boostMessages } from '../../plugins/boost/src/translations/ref.js';
/* eslint-enable @backstage/no-relative-monorepo-imports */

export type BoostMessages = typeof boostMessages;

export function getTranslations(): BoostMessages {
return boostMessages;
}
3 changes: 3 additions & 0 deletions workspaces/boost/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"clean": "backstage-cli repo clean",
"test": "backstage-cli repo test",
"test:all": "yarn prettier:check && yarn lint:all && backstage-cli repo test --coverage",
"test:e2e": "playwright test",
"fix": "backstage-cli repo fix",
"lint": "backstage-cli repo lint --since origin/main",
"lint:all": "backstage-cli repo lint",
Expand All @@ -41,11 +42,13 @@
"devDependencies": {
"@backstage/cli": "^0.36.3",
"@backstage/cli-defaults": "^0.1.0",
"@axe-core/playwright": "^4.10.0",
"@backstage/e2e-test-utils": "^0.1.2",
"@backstage/repo-tools": "^0.17.3",
"@changesets/cli": "^2.27.1",
"@fission-ai/openspec": "^1.4.1",
"@jest/environment-jsdom-abstract": "^30.3.0",
"@playwright/test": "1.61.1",
"@types/jest": "^30.0.0",
"jest": "^30.3.0",
"jsdom": "^27.1.0",
Expand Down
Loading
Loading