diff --git a/.gitignore b/.gitignore index 2ce58ee1..35a70d87 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,9 @@ lerna-debug.log # For vim *.swp -public \ No newline at end of file +public + +# Playwright +playwright-report/ +test-results/ +blob-report/ \ No newline at end of file diff --git a/e2e/basic-chatbot.spec.ts b/e2e/basic-chatbot.spec.ts new file mode 100644 index 00000000..b2f49940 --- /dev/null +++ b/e2e/basic-chatbot.spec.ts @@ -0,0 +1,323 @@ +import { test, expect, type Page } from '@playwright/test'; + +const DEMO_URL = '/extensions/chatbot/overview/demo/basic-chatbot'; + +test.beforeEach(async ({ page }) => { + await page.goto(DEMO_URL); + await expect(page.getByRole('region', { name: 'Chatbot' })).toBeVisible(); +}); + +test.describe('Basic Chatbot - Page Load', () => { + test('chatbot is visible on page load', async ({ page }) => { + const chatbot = page.getByRole('region', { name: 'Chatbot' }); + await expect(chatbot).toBeVisible(); + }); + + test('displays welcome heading', async ({ page }) => { + await expect( + page.getByRole('heading', { name: /Hi, ChatBot User/i }) + ).toBeVisible(); + }); + + test('displays footnote disclaimer', async ({ page }) => { + await expect( + page.getByText('Always review AI-generated content prior to use.') + ).toBeVisible(); + }); + + test('toggle button shows expanded state', async ({ page }) => { + const toggle = page.getByRole('button', { name: 'Chatbot toggle' }); + await expect(toggle).toBeVisible(); + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + }); +}); + +test.describe('Basic Chatbot - Welcome Prompts', () => { + test('displays "Set up account" prompt card', async ({ page }) => { + await expect(page.getByRole('button', { name: 'Set up account' })).toBeVisible(); + await expect( + page.getByText('Choose the necessary settings and preferences for your account.') + ).toBeVisible(); + }); + + test('displays "Troubleshoot issue" prompt card', async ({ page }) => { + await expect(page.getByRole('button', { name: 'Troubleshoot issue' })).toBeVisible(); + await expect( + page.getByText('Find documentation and instructions to resolve your issue.') + ).toBeVisible(); + }); +}); + +test.describe('Basic Chatbot - Pre-loaded Messages', () => { + test('displays initial user message', async ({ page }) => { + const userMessage = page.getByRole('region', { name: /Message from user/i }); + await expect(userMessage).toBeVisible(); + await expect(userMessage).toContainText( + 'Hello, can you give me an example of what you can do?' + ); + }); + + test('displays initial bot response with rich content', async ({ page }) => { + const botMessage = page.getByRole('region', { name: /Message from bot/i }); + await expect(botMessage).toBeVisible(); + await expect(botMessage).toContainText('strong importance'); + }); + + test('bot response includes code blocks', async ({ page }) => { + const botMessage = page.getByRole('region', { name: /Message from bot/i }); + await expect(botMessage.getByText('yaml', { exact: true })).toBeVisible(); + await expect(botMessage.getByText('js', { exact: true })).toBeVisible(); + }); + + test('bot response has copy code buttons', async ({ page }) => { + const copyButtons = page.getByRole('button', { name: 'Copy code' }); + await expect(copyButtons).toHaveCount(2); + }); +}); + +test.describe('Basic Chatbot - Response Actions', () => { + test('bot message displays all action buttons', async ({ page }) => { + await expect(page.getByRole('button', { name: 'Good response' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Bad response' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Copy', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Download' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Listen' })).toBeVisible(); + }); + + test('can click "Good response" action', async ({ page }) => { + const goodBtn = page.getByRole('button', { name: 'Good response' }); + await goodBtn.click(); + // Button should remain visible after click (toggled state) + await expect(goodBtn).toBeVisible(); + }); + + test('can click "Bad response" action', async ({ page }) => { + const badBtn = page.getByRole('button', { name: 'Bad response' }); + await badBtn.click(); + await expect(badBtn).toBeVisible(); + }); +}); + +test.describe('Basic Chatbot - Send Message', () => { + test('message bar is visible with placeholder', async ({ page }) => { + const textbox = page.getByRole('textbox', { name: 'Send a message...' }); + await expect(textbox).toBeVisible(); + await expect(textbox).toHaveAttribute('placeholder', 'Send a message...'); + }); + + test('send button appears when text is entered', async ({ page }) => { + const textbox = page.getByRole('textbox', { name: 'Send a message...' }); + await textbox.fill('Test message'); + await expect(page.getByRole('button', { name: 'Send' })).toBeVisible(); + }); + + test('send button disappears when text is cleared', async ({ page }) => { + const textbox = page.getByRole('textbox', { name: 'Send a message...' }); + await textbox.fill('Test message'); + await expect(page.getByRole('button', { name: 'Send' })).toBeVisible(); + await textbox.fill(''); + await expect(page.getByRole('button', { name: 'Send' })).not.toBeVisible(); + }); + + test('can send a message by clicking Send button', async ({ page }) => { + const textbox = page.getByRole('textbox', { name: 'Send a message...' }); + await textbox.fill('What is PatternFly?'); + await page.getByRole('button', { name: 'Send' }).click(); + + // New user message should appear + const messages = page.getByRole('region', { name: /Message from user/i }); + await expect(messages).toHaveCount(2); + await expect(messages.last()).toContainText('What is PatternFly?'); + + // Textbox should be cleared after sending + await expect(textbox).toHaveValue(''); + }); + + test('can send a message by pressing Enter', async ({ page }) => { + const textbox = page.getByRole('textbox', { name: 'Send a message...' }); + await textbox.fill('Hello from keyboard'); + await textbox.press('Enter'); + + const messages = page.getByRole('region', { name: /Message from user/i }); + await expect(messages).toHaveCount(2); + await expect(messages.last()).toContainText('Hello from keyboard'); + }); + + test('Shift+Enter adds a newline instead of sending', async ({ page }) => { + const textbox = page.getByRole('textbox', { name: 'Send a message...' }); + await textbox.fill('Line one'); + await textbox.press('Shift+Enter'); + await textbox.pressSequentially('Line two'); + + // Message should not have been sent (still only 1 user message region) + const messages = page.getByRole('region', { name: /Message from user/i }); + await expect(messages).toHaveCount(1); + }); + + test('bot responds after sending a message (simulated API delay)', async ({ page }) => { + const textbox = page.getByRole('textbox', { name: 'Send a message...' }); + await textbox.fill('Tell me something'); + await page.getByRole('button', { name: 'Send' }).click(); + + // Wait for bot response to finish loading (~5s simulated delay) + await expect( + page.getByRole('button', { name: 'Good response' }).last() + ).toBeVisible({ timeout: 10_000 }); + + // Should now have 2 bot messages (initial + new response) + const botMessages = page.getByRole('region', { name: /Message from bot/i }); + await expect(botMessages).toHaveCount(2); + }); + + test('attach button is visible in message bar', async ({ page }) => { + await expect(page.getByRole('button', { name: 'Attach' })).toBeVisible(); + }); +}); + +test.describe('Basic Chatbot - Toggle Visibility', () => { + test('can hide chatbot by clicking toggle', async ({ page }) => { + const toggle = page.getByRole('button', { name: 'Chatbot toggle' }); + await toggle.click(); + + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + await expect(page.getByRole('region', { name: 'Chatbot' })).not.toBeVisible(); + }); + + test('can show chatbot again after hiding', async ({ page }) => { + const toggle = page.getByRole('button', { name: 'Chatbot toggle' }); + + await toggle.click(); + await expect(page.getByRole('region', { name: 'Chatbot' })).not.toBeVisible(); + + await toggle.click(); + await expect(page.getByRole('region', { name: 'Chatbot' })).toBeVisible(); + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + }); +}); + +test.describe('Basic Chatbot - Header Controls', () => { + test('model selector shows "Granite 7B" by default', async ({ page }) => { + const modelSelector = page.getByRole('button', { name: 'Select model' }); + await expect(modelSelector).toBeVisible(); + await expect(modelSelector).toContainText('Granite 7B'); + }); + + test('can open model selector dropdown and see options', async ({ page }) => { + await page.getByRole('button', { name: 'Select model' }).click(); + await expect(page.getByText('Llama 3.0')).toBeVisible(); + await expect(page.getByText('Mistral 3B')).toBeVisible(); + }); + + test('can switch to a different model', async ({ page }) => { + await page.getByRole('button', { name: 'Select model' }).click(); + await page.getByText('Llama 3.0').click(); + + const modelSelector = page.getByRole('button', { name: /Select model/i }); + await expect(modelSelector).toContainText('Llama 3.0'); + }); + + test('options dropdown opens with display mode choices', async ({ page }) => { + await page.getByRole('button', { name: 'Chatbot options' }).click(); + + await expect(page.getByText('Overlay')).toBeVisible(); + await expect(page.getByText('Dock to window')).toBeVisible(); + await expect(page.getByText('Fullscreen')).toBeVisible(); + }); + + test('can switch display mode to fullscreen', async ({ page }) => { + await page.getByRole('button', { name: 'Chatbot options' }).click(); + await page.getByText('Fullscreen').click(); + + const chatbot = page.locator('.pf-chatbot--fullscreen'); + await expect(chatbot).toBeVisible(); + }); + + test('can switch display mode to docked', async ({ page }) => { + await page.getByRole('button', { name: 'Chatbot options' }).click(); + await page.getByText('Dock to window').click(); + + const chatbot = page.locator('.pf-chatbot--docked'); + await expect(chatbot).toBeVisible(); + }); +}); + +test.describe('Basic Chatbot - Chat History Drawer', () => { + test('can open chat history drawer', async ({ page }) => { + await page.getByRole('button', { name: 'Chat history drawer' }).click(); + const drawer = page.getByRole('dialog'); + await expect(drawer).toBeVisible(); + await expect(drawer.getByRole('heading', { name: 'Chat history' })).toBeVisible(); + await expect( + drawer.getByRole('textbox', { name: 'Search previous conversations' }) + ).toBeVisible(); + }); + + test('"New chat" button is visible in history drawer', async ({ page }) => { + await page.getByRole('button', { name: 'Chat history drawer' }).click(); + await expect(page.getByRole('dialog').getByRole('button', { name: 'New chat' })).toBeVisible(); + }); + + test('displays conversation history grouped by date', async ({ page }) => { + await page.getByRole('button', { name: 'Chat history drawer' }).click(); + const drawer = page.getByRole('dialog'); + await expect(drawer.getByRole('heading', { name: 'Today' })).toBeVisible(); + await expect(drawer.getByRole('heading', { name: 'This month' })).toBeVisible(); + }); + + test('can search in chat history', async ({ page }) => { + await page.getByRole('button', { name: 'Chat history drawer' }).click(); + const drawer = page.getByRole('dialog'); + const searchBox = drawer.getByRole('textbox', { + name: 'Search previous conversations', + }); + await searchBox.fill('nonexistent query'); + await expect(drawer.getByText('No results found')).toBeVisible(); + }); + + test('can close drawer with close button', async ({ page }) => { + await page.getByRole('button', { name: 'Chat history drawer' }).click(); + const drawer = page.getByRole('dialog'); + await expect(drawer).toBeVisible(); + + await drawer.getByRole('button', { name: 'Close drawer panel' }).click(); + await expect(drawer).not.toBeVisible(); + }); + + test('"New chat" clears messages and closes drawer', async ({ page }) => { + await page.getByRole('button', { name: 'Chat history drawer' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'New chat' }).click(); + + // Drawer should close + await expect(page.getByRole('dialog')).not.toBeVisible(); + + // Messages should be cleared — only welcome prompt visible + await expect( + page.getByRole('region', { name: /Message from user/i }) + ).toHaveCount(0); + }); +}); + +test.describe('Basic Chatbot - Accessibility', () => { + test('skip-to-chatbot link is present', async ({ page }) => { + await expect(page.getByRole('link', { name: 'Skip to chatbot' })).toBeAttached(); + }); + + test('message log region has correct aria label', async ({ page }) => { + await expect( + page.getByRole('region', { name: 'Scrollable message log' }) + ).toBeVisible(); + }); + + test('chatbot container is a landmark region', async ({ page }) => { + const chatbot = page.getByRole('region', { name: 'Chatbot' }); + await expect(chatbot).toBeVisible(); + }); + + test('messages have descriptive aria labels with timestamps', async ({ page }) => { + const userMessage = page.getByRole('region', { name: /Message from user - /i }); + await expect(userMessage).toBeVisible(); + + const botMessage = page.getByRole('region', { name: /Message from bot - /i }); + await expect(botMessage).toBeVisible(); + }); +}); diff --git a/e2e/chatbot.spec.ts b/e2e/chatbot.spec.ts new file mode 100644 index 00000000..e5fafee8 --- /dev/null +++ b/e2e/chatbot.spec.ts @@ -0,0 +1,22 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Chatbot docs site', () => { + test('homepage loads successfully', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle(/PatternFly/i); + }); + + test('navigates to chatbot extension page', async ({ page }) => { + await page.goto('/extensions/chatbot/'); + await expect(page.locator('h1')).toBeVisible(); + }); + + test('chatbot component renders in example', async ({ page }) => { + await page.goto('/extensions/chatbot/'); + + const exampleSection = page.locator('.ws-example'); + if (await exampleSection.count() > 0) { + await expect(exampleSection.first()).toBeVisible(); + } + }); +}); diff --git a/package-lock.json b/package-lock.json index 6e4aabaf..39badf69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@patternfly/patternfly": "^6.6.0", "@patternfly/react-icons": "^6.6.0", "@patternfly/react-table": "^6.6.0", + "@playwright/test": "^1.62.1", "@swc/core": "1.3.96", "@testing-library/dom": "^9.3.4", "@testing-library/jest-dom": "^6.4.2", @@ -5036,6 +5037,22 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -21209,6 +21226,38 @@ "node": ">=16.0.0" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "dev": true, diff --git a/package.json b/package.json index f7dcdacd..1b6080d8 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,10 @@ "lint:fix": "npm run lint:js:fix && npm run lint:md:fix", "test": "TZ=EST jest packages --passWithNoTests", "test:a11y": "npm run test:a11y -w @patternfly/chatbot", - "serve:a11y": "npm run serve:a11y -w @patternfly/chatbot" + "serve:a11y": "npm run serve:a11y -w @patternfly/chatbot", + "playwright:test": "playwright test", + "playwright:test:ui": "playwright test --ui", + "playwright:report": "playwright show-report" }, "devDependencies": { "@babel/core": "^7.24.0", @@ -43,6 +46,7 @@ "@patternfly/patternfly": "^6.6.0", "@patternfly/react-icons": "^6.6.0", "@patternfly/react-table": "^6.6.0", + "@playwright/test": "^1.62.1", "@swc/core": "1.3.96", "@testing-library/dom": "^9.3.4", "@testing-library/jest-dom": "^6.4.2", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..97efea83 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,30 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [['html', { open: 'never' }]], + + use: { + baseURL: 'http://localhost:8006', + trace: process.env.CI ? 'off' : 'on-first-retry', + screenshot: 'only-on-failure', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + webServer: { + command: process.env.CI ? 'npm run serve:docs' : 'npm run start', + url: 'http://localhost:8006', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +});