diff --git a/.gitignore b/.gitignore
index 27ae031..9e5e559 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,10 @@ pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
+# Testing
+test-results/
+playwright-report/
+
# Deployment
*.env
.azure
diff --git a/package-lock.json b/package-lock.json
index 7cb7381..3f3278a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"packages/*"
],
"devDependencies": {
+ "@playwright/test": "^1.54.1",
"concurrently": "^9.0.0",
"lint-staged": "^16.0.0",
"prettier": "^3.0.3",
@@ -2012,13 +2013,12 @@
}
},
"node_modules/@playwright/test": {
- "version": "1.52.0",
- "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz",
- "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==",
+ "version": "1.54.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.1.tgz",
+ "integrity": "sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
- "playwright": "1.52.0"
+ "playwright": "1.54.1"
},
"bin": {
"playwright": "cli.js"
@@ -9593,13 +9593,12 @@
}
},
"node_modules/playwright": {
- "version": "1.52.0",
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz",
- "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==",
+ "version": "1.54.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.1.tgz",
+ "integrity": "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
- "playwright-core": "1.52.0"
+ "playwright-core": "1.54.1"
},
"bin": {
"playwright": "cli.js"
@@ -9612,11 +9611,10 @@
}
},
"node_modules/playwright-core": {
- "version": "1.52.0",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz",
- "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==",
+ "version": "1.54.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz",
+ "integrity": "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==",
"license": "Apache-2.0",
- "peer": true,
"bin": {
"playwright-core": "cli.js"
},
diff --git a/package.json b/package.json
index 90012b5..89182dd 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,9 @@
"build:api": "npm run build --workspace=api",
"clean": "npm run clean --workspaces --if-present",
"upload:docs": "node scripts/upload-documents.js http://localhost:7071",
+ "test": "playwright test",
+ "test:headed": "playwright test --headed",
+ "test:ui": "playwright test --ui",
"lint": "xo",
"lint:fix": "xo --fix",
"format": "prettier --list-different --write .",
@@ -44,6 +47,7 @@
"packages/*"
],
"devDependencies": {
+ "@playwright/test": "^1.54.1",
"concurrently": "^9.0.0",
"lint-staged": "^16.0.0",
"prettier": "^3.0.3",
@@ -69,6 +73,25 @@
"envs": [
"node"
],
+ "overrides": [
+ {
+ "files": [
+ "test/**/*.ts",
+ "test/**/*.js",
+ "playwright.config.ts"
+ ],
+ "rules": {
+ "@typescript-eslint/no-unsafe-call": "off",
+ "@typescript-eslint/no-unsafe-assignment": "off",
+ "@typescript-eslint/no-unsafe-argument": "off",
+ "@typescript-eslint/no-unsafe-return": "off",
+ "@typescript-eslint/no-unsafe-member-access": "off",
+ "no-implicit-coercion": "off",
+ "unicorn/numeric-separators-style": "off",
+ "no-await-in-loop": "off"
+ }
+ }
+ ],
"rules": {
"@typescript-eslint/triple-slash-reference": "off",
"@typescript-eslint/naming-convention": "off",
diff --git a/playwright-report/index.html b/playwright-report/index.html
new file mode 100644
index 0000000..89623da
--- /dev/null
+++ b/playwright-report/index.html
@@ -0,0 +1,16736 @@
+
+
+
+
+
+
+ Playwright Test Report
+
+
+
+
+
+
+
+
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..083f083
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,78 @@
+import { defineConfig, devices } from '@playwright/test';
+
+/**
+ * @see https://playwright.dev/docs/test-configuration
+ */
+export default defineConfig({
+ testDir: './test/e2e',
+ /* Run tests in files in parallel */
+ fullyParallel: true,
+ /* Fail the build on CI if you accidentally left test.only in the source code. */
+ forbidOnly: Boolean(process.env.CI),
+ /* Retry on CI only */
+ retries: process.env.CI ? 2 : 0,
+ /* Opt out of parallel tests on CI. */
+ workers: process.env.CI ? 1 : undefined,
+ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
+ reporter: 'html',
+ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
+ use: {
+ /* Base URL to use in actions like `await page.goto('/')`. */
+ baseURL: 'http://localhost:8000',
+
+ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
+ trace: 'on-first-retry',
+ },
+
+ /* Configure projects for major browsers */
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+
+ {
+ name: 'firefox',
+ use: { ...devices['Desktop Firefox'] },
+ },
+
+ {
+ name: 'webkit',
+ use: { ...devices['Desktop Safari'] },
+ },
+
+ /* Test against mobile viewports. */
+ // {
+ // name: 'Mobile Chrome',
+ // use: { ...devices['Pixel 5'] },
+ // },
+ // {
+ // name: 'Mobile Safari',
+ // use: { ...devices['iPhone 12'] },
+ // },
+
+ /* Test against branded browsers. */
+ // {
+ // name: 'Microsoft Edge',
+ // use: { ...devices['Desktop Edge'], channel: 'msedge' },
+ // },
+ // {
+ // name: 'Google Chrome',
+ // use: { ...devices['Desktop Chrome'], channel: 'chrome' },
+ // },
+ ],
+
+ /* Run your local dev server before starting the tests */
+ webServer: [
+ {
+ command: 'npm run start:webapp',
+ url: 'http://localhost:8000',
+ reuseExistingServer: !process.env.CI,
+ },
+ {
+ command: 'npm run start:api',
+ url: 'http://localhost:7071',
+ reuseExistingServer: !process.env.CI,
+ },
+ ],
+});
diff --git a/test-results/.last-run.json b/test-results/.last-run.json
new file mode 100644
index 0000000..5fca3f8
--- /dev/null
+++ b/test-results/.last-run.json
@@ -0,0 +1,4 @@
+{
+ "status": "failed",
+ "failedTests": []
+}
\ No newline at end of file
diff --git a/test/e2e/.gitkeep b/test/e2e/.gitkeep
new file mode 100644
index 0000000..c0fc906
--- /dev/null
+++ b/test/e2e/.gitkeep
@@ -0,0 +1 @@
+# E2E Tests Directory
\ No newline at end of file
diff --git a/test/e2e/README.md b/test/e2e/README.md
new file mode 100644
index 0000000..41125d3
--- /dev/null
+++ b/test/e2e/README.md
@@ -0,0 +1,71 @@
+# E2E Testing with Playwright
+
+This directory contains end-to-end tests for the serverless chat application using Playwright.
+
+## Test Structure
+
+- `api-chat.spec.ts` - Tests for the chat streaming API endpoint
+- `api-documents.spec.ts` - Tests for document upload and retrieval APIs
+- `ui-chat.spec.ts` - Tests for the chat user interface
+- `full-workflow.spec.ts` - Complete end-to-end workflow tests
+
+## Running Tests
+
+### Prerequisites
+
+Make sure you have the development servers running:
+
+```bash
+# Terminal 1: Start the API (Azure Functions)
+npm run start:api
+
+# Terminal 2: Start the webapp
+npm run start:webapp
+```
+
+### Run Tests
+
+```bash
+# Run all tests
+npm test
+
+# Run tests in headed mode (see browser)
+npm run test:headed
+
+# Run tests with UI mode
+npm run test:ui
+
+# Install Playwright browsers (required first time)
+npx playwright install
+```
+
+### Test Configuration
+
+The tests are configured to:
+
+- Run against `http://localhost:8000` (webapp) and `http://localhost:7071` (API)
+- Use Chromium, Firefox, and WebKit browsers
+- Generate HTML reports
+- Automatically start dev servers before running tests
+
+## Test Coverage
+
+### API Tests
+
+- Document upload with PDF files
+- Document retrieval by filename
+- Chat streaming with proper session management
+- Error handling for invalid requests
+
+### UI Tests
+
+- Chat interface display and interactions
+- Message sending and receiving
+- Session persistence
+- Suggestion handling
+
+### End-to-End Workflows
+
+- Upload document → Chat about uploaded content
+- Multi-document upload and retrieval
+- Session continuity across multiple questions
diff --git a/test/e2e/api-chat.spec.ts b/test/e2e/api-chat.spec.ts
new file mode 100644
index 0000000..b8f6803
--- /dev/null
+++ b/test/e2e/api-chat.spec.ts
@@ -0,0 +1,127 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Chat API', () => {
+ const API_BASE_URL = 'http://localhost:7071/api';
+
+ test('should handle chat request and return streaming response', async ({ request }) => {
+ const chatRequest = {
+ messages: [
+ {
+ role: 'user',
+ content: 'Hello, can you help me with information about rental properties?',
+ },
+ ],
+ context: {
+ sessionId: 'test-session-' + Date.now(),
+ },
+ };
+
+ const response = await request.post(`${API_BASE_URL}/chats/stream`, {
+ data: chatRequest,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ expect(response.status()).toBe(200);
+ expect(response.headers()['content-type']).toBe('application/x-ndjson');
+ expect(response.headers()['transfer-encoding']).toBe('chunked');
+
+ // Read the streaming response
+ const responseText = await response.text();
+ expect(responseText.length).toBeGreaterThan(0);
+
+ // Parse NDJSON response lines
+ const lines = responseText.trim().split('\n');
+ expect(lines.length).toBeGreaterThan(0);
+
+ // Each line should be valid JSON
+ for (const line of lines) {
+ if (line.trim()) {
+ const chunk = JSON.parse(line);
+ expect(chunk).toHaveProperty('delta');
+ expect(chunk.delta).toHaveProperty('content');
+ expect(chunk.delta).toHaveProperty('role', 'assistant');
+ expect(chunk).toHaveProperty('context');
+ expect(chunk.context).toHaveProperty('sessionId');
+ }
+ }
+ });
+
+ test('should handle empty messages array', async ({ request }) => {
+ const chatRequest = {
+ messages: [],
+ context: {
+ sessionId: 'test-session-empty',
+ },
+ };
+
+ const response = await request.post(`${API_BASE_URL}/chats/stream`, {
+ data: chatRequest,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ expect(response.status()).toBe(400);
+ const responseBody = await response.json();
+ expect(responseBody).toHaveProperty('error');
+ expect(responseBody.error).toContain('messages');
+ });
+
+ test('should handle missing message content', async ({ request }) => {
+ const chatRequest = {
+ messages: [
+ {
+ role: 'user',
+ content: '',
+ },
+ ],
+ context: {
+ sessionId: 'test-session-no-content',
+ },
+ };
+
+ const response = await request.post(`${API_BASE_URL}/chats/stream`, {
+ data: chatRequest,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ expect(response.status()).toBe(400);
+ const responseBody = await response.json();
+ expect(responseBody).toHaveProperty('error');
+ expect(responseBody.error).toContain('messages');
+ });
+
+ test('should generate session ID when not provided', async ({ request }) => {
+ const chatRequest = {
+ messages: [
+ {
+ role: 'user',
+ content: 'Test message without session ID',
+ },
+ ],
+ };
+
+ const response = await request.post(`${API_BASE_URL}/chats/stream`, {
+ data: chatRequest,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ expect(response.status()).toBe(200);
+
+ const responseText = await response.text();
+ const lines = responseText.trim().split('\n');
+
+ if (lines.length > 0) {
+ const firstChunk = JSON.parse(lines[0]);
+ expect(firstChunk.context).toHaveProperty('sessionId');
+ expect(firstChunk.context.sessionId).toBeTruthy();
+ expect(firstChunk.context.sessionId.length).toBeGreaterThan(0);
+ }
+ });
+});
diff --git a/test/e2e/api-documents.spec.ts b/test/e2e/api-documents.spec.ts
new file mode 100644
index 0000000..168dc9f
--- /dev/null
+++ b/test/e2e/api-documents.spec.ts
@@ -0,0 +1,133 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Documents API', () => {
+ const API_BASE_URL = 'http://localhost:7071/api';
+
+ test('should upload a PDF document successfully', async ({ request }) => {
+ // Create a small test PDF file content (this is a minimal PDF structure)
+ const testPdfContent = Buffer.from(
+ [
+ '%PDF-1.4',
+ '1 0 obj',
+ '<<',
+ '/Type /Catalog',
+ '/Pages 2 0 R',
+ '>>',
+ 'endobj',
+ '2 0 obj',
+ '<<',
+ '/Type /Pages',
+ '/Kids [3 0 R]',
+ '/Count 1',
+ '>>',
+ 'endobj',
+ '3 0 obj',
+ '<<',
+ '/Type /Page',
+ '/Parent 2 0 R',
+ '/MediaBox [0 0 612 792]',
+ '>>',
+ 'endobj',
+ 'xref',
+ '0 4',
+ '0000000000 65535 f ',
+ '0000000009 00000 n ',
+ '0000000058 00000 n ',
+ '0000000115 00000 n ',
+ 'trailer',
+ '<<',
+ '/Size 4',
+ '/Root 1 0 R',
+ '>>',
+ 'startxref',
+ '174',
+ '%%EOF',
+ ].join('\n'),
+ );
+
+ // Upload the test PDF
+ const formData = new FormData();
+ const file = new File([testPdfContent], 'test-document.pdf', { type: 'application/pdf' });
+ formData.append('file', file);
+
+ const response = await request.post(`${API_BASE_URL}/documents`, {
+ multipart: {
+ file: {
+ name: 'test-document.pdf',
+ mimeType: 'application/pdf',
+ buffer: testPdfContent,
+ },
+ },
+ });
+
+ expect(response.status()).toBe(200);
+ const responseBody = await response.json();
+ expect(responseBody).toHaveProperty('message');
+ expect(responseBody.message).toContain('uploaded successfully');
+ });
+
+ test('should retrieve an uploaded document', async ({ request }) => {
+ // First upload a document
+ const testPdfContent = Buffer.from(
+ [
+ '%PDF-1.4',
+ '1 0 obj',
+ '<<',
+ '/Type /Catalog',
+ '/Pages 2 0 R',
+ '>>',
+ 'endobj',
+ 'xref',
+ '0 2',
+ '0000000000 65535 f ',
+ '0000000009 00000 n ',
+ 'trailer',
+ '<<',
+ '/Size 2',
+ '/Root 1 0 R',
+ '>>',
+ 'startxref',
+ '58',
+ '%%EOF',
+ ].join('\n'),
+ );
+
+ const fileName = 'test-retrieve.pdf';
+
+ // Upload the document first
+ await request.post(`${API_BASE_URL}/documents`, {
+ multipart: {
+ file: {
+ name: fileName,
+ mimeType: 'application/pdf',
+ buffer: testPdfContent,
+ },
+ },
+ });
+
+ // Then try to retrieve it
+ const getResponse = await request.get(`${API_BASE_URL}/documents/${fileName}`);
+
+ expect(getResponse.status()).toBe(200);
+ expect(getResponse.headers()['content-type']).toBe('application/pdf');
+
+ const retrievedContent = await getResponse.body();
+ expect(retrievedContent.length).toBeGreaterThan(0);
+ });
+
+ test('should return 404 for non-existent document', async ({ request }) => {
+ const response = await request.get(`${API_BASE_URL}/documents/non-existent-file.pdf`);
+ expect(response.status()).toBe(404);
+ });
+
+ test('should handle invalid file upload', async ({ request }) => {
+ const response = await request.post(`${API_BASE_URL}/documents`, {
+ data: {},
+ });
+
+ expect(response.status()).toBe(400);
+ const responseBody = await response.json();
+ expect(responseBody).toHaveProperty('error');
+ expect(responseBody.error).toContain('file');
+ });
+});
diff --git a/test/e2e/full-workflow.spec.ts b/test/e2e/full-workflow.spec.ts
new file mode 100644
index 0000000..d93ecea
--- /dev/null
+++ b/test/e2e/full-workflow.spec.ts
@@ -0,0 +1,241 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Full E2E Workflow', () => {
+ const API_BASE_URL = 'http://localhost:7071/api';
+
+ test('should upload document and then chat about it', async ({ page, request }) => {
+ // Step 1: Upload a test document via API
+ const testPdfContent = Buffer.from(
+ [
+ '%PDF-1.4',
+ '1 0 obj',
+ '<<',
+ '/Type /Catalog',
+ '/Pages 2 0 R',
+ '>>',
+ 'endobj',
+ '2 0 obj',
+ '<<',
+ '/Type /Pages',
+ '/Kids [3 0 R]',
+ '/Count 1',
+ '>>',
+ 'endobj',
+ '3 0 obj',
+ '<<',
+ '/Type /Page',
+ '/Parent 2 0 R',
+ '/MediaBox [0 0 612 792]',
+ '/Contents 4 0 R',
+ '>>',
+ 'endobj',
+ '4 0 obj',
+ '<<',
+ '/Length 44',
+ '>>',
+ 'stream',
+ 'BT',
+ '/F1 12 Tf',
+ '72 720 Td',
+ '(Rental Property Information) Tj',
+ 'ET',
+ 'endstream',
+ 'endobj',
+ 'xref',
+ '0 5',
+ '0000000000 65535 f ',
+ '0000000009 00000 n ',
+ '0000000058 00000 n ',
+ '0000000115 00000 n ',
+ '0000000179 00000 n ',
+ 'trailer',
+ '<<',
+ '/Size 5',
+ '/Root 1 0 R',
+ '>>',
+ 'startxref',
+ '267',
+ '%%EOF',
+ ].join('\n'),
+ );
+
+ const fileName = 'test-rental-info.pdf';
+
+ // Upload the document
+ const uploadResponse = await request.post(`${API_BASE_URL}/documents`, {
+ multipart: {
+ file: {
+ name: fileName,
+ mimeType: 'application/pdf',
+ buffer: testPdfContent,
+ },
+ },
+ });
+
+ expect(uploadResponse.status()).toBe(200);
+ const uploadResult = await uploadResponse.json();
+ expect(uploadResult.message).toContain('uploaded successfully');
+
+ // Step 2: Wait a moment for document to be indexed
+ await page.waitForTimeout(3000);
+
+ // Step 3: Navigate to chat UI
+ await page.goto('/');
+
+ // Wait for the chat component to load
+ await page.waitForTimeout(2000);
+
+ // Step 4: Ask a question about the uploaded document
+ const chatInput = page.locator('azc-chat').locator('textarea');
+ await expect(chatInput).toBeVisible({ timeout: 10_000 });
+
+ const question = 'What information do you have about rental properties?';
+ await chatInput.fill(question);
+
+ const sendButton = page.locator('azc-chat').locator('button[type="submit"]');
+ await sendButton.click();
+
+ // Step 5: Verify the question appears
+ await expect(page.locator('azc-chat').locator('.messages')).toContainText(question, { timeout: 10_000 });
+
+ // Step 6: Wait for and verify response
+ // Look for loading indicator first
+ await expect(page.locator('azc-chat').locator('.loading')).toBeVisible({ timeout: 5000 });
+
+ // Wait for response to complete (loading indicator should disappear)
+ await expect(page.locator('azc-chat').locator('.loading')).not.toBeVisible({ timeout: 30_000 });
+
+ // Check that we got some response from the assistant
+ const assistantMessages = page.locator('azc-chat').locator('.message.assistant');
+ await expect(assistantMessages).toHaveCount(1, { timeout: 10_000 });
+
+ // Step 7: Verify citation link works (if citations are present)
+ const citationLinks = page.locator('azc-chat').locator('a[href*="/api/documents/"]');
+ if ((await citationLinks.count()) > 0) {
+ const citationHref = await citationLinks.first().getAttribute('href');
+ expect(citationHref).toContain('/api/documents/');
+
+ // Test that citation link points to our uploaded document
+ if (citationHref) {
+ const citationResponse = await request.get(citationHref);
+ expect(citationResponse.status()).toBe(200);
+ expect(citationResponse.headers()['content-type']).toBe('application/pdf');
+ }
+ }
+ });
+
+ test('should handle multiple document uploads and retrieve specific documents', async ({ request }) => {
+ const documents = [
+ {
+ name: 'rental-policy.pdf',
+ content: 'Rental policy information about deposits and lease terms',
+ },
+ {
+ name: 'maintenance-guide.pdf',
+ content: 'Maintenance guide for tenants and property management',
+ },
+ ];
+
+ // Upload multiple documents
+ for (const document of documents) {
+ const testPdfContent = Buffer.from(
+ [
+ '%PDF-1.4',
+ '1 0 obj',
+ '<<',
+ '/Type /Catalog',
+ '/Pages 2 0 R',
+ '>>',
+ 'endobj',
+ '2 0 obj',
+ '<<',
+ '/Type /Pages',
+ '/Kids [3 0 R]',
+ '/Count 1',
+ '>>',
+ 'endobj',
+ '3 0 obj',
+ '<<',
+ '/Type /Page',
+ '/Parent 2 0 R',
+ '/MediaBox [0 0 612 792]',
+ '/Contents 4 0 R',
+ '>>',
+ 'endobj',
+ '4 0 obj',
+ '<<',
+ `/Length ${document.content.length + 20}`,
+ '>>',
+ 'stream',
+ 'BT',
+ '/F1 12 Tf',
+ '72 720 Td',
+ `(${document.content}) Tj`,
+ 'ET',
+ 'endstream',
+ 'endobj',
+ 'xref',
+ '0 5',
+ '0000000000 65535 f ',
+ '0000000009 00000 n ',
+ '0000000058 00000 n ',
+ '0000000115 00000 n ',
+ '0000000179 00000 n ',
+ 'trailer',
+ '<<',
+ '/Size 5',
+ '/Root 1 0 R',
+ '>>',
+ 'startxref',
+ '300',
+ '%%EOF',
+ ].join('\n'),
+ );
+
+ const response = await request.post(`${API_BASE_URL}/documents`, {
+ multipart: {
+ file: {
+ name: document.name,
+ mimeType: 'application/pdf',
+ buffer: testPdfContent,
+ },
+ },
+ });
+
+ expect(response.status()).toBe(200);
+ }
+
+ // Verify we can retrieve each document
+ for (const document of documents) {
+ const getResponse = await request.get(`${API_BASE_URL}/documents/${document.name}`);
+ expect(getResponse.status()).toBe(200);
+ expect(getResponse.headers()['content-type']).toBe('application/pdf');
+ }
+ });
+
+ test('should maintain chat session across multiple questions', async ({ page }) => {
+ // Navigate to chat
+ await page.goto('/');
+ await page.waitForTimeout(2000);
+
+ const chatInput = page.locator('azc-chat').locator('textarea');
+ const sendButton = page.locator('azc-chat').locator('button[type="submit"]');
+
+ // Send first question
+ await chatInput.fill('What are your rental policies?');
+ await sendButton.click();
+
+ // Wait for response
+ await expect(page.locator('azc-chat').locator('.message.user')).toHaveCount(1, { timeout: 10_000 });
+ await expect(page.locator('azc-chat').locator('.loading')).not.toBeVisible({ timeout: 30_000 });
+
+ // Send follow-up question
+ await chatInput.fill('What about maintenance requests?');
+ await sendButton.click();
+
+ // Verify both questions are in chat history
+ await expect(page.locator('azc-chat').locator('.message.user')).toHaveCount(2, { timeout: 10_000 });
+ await expect(page.locator('azc-chat').locator('.messages')).toContainText('rental policies');
+ await expect(page.locator('azc-chat').locator('.messages')).toContainText('maintenance requests');
+ });
+});
diff --git a/test/e2e/ui-chat.spec.ts b/test/e2e/ui-chat.spec.ts
new file mode 100644
index 0000000..cd8bbc2
--- /dev/null
+++ b/test/e2e/ui-chat.spec.ts
@@ -0,0 +1,133 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Chat UI E2E Tests', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/');
+ });
+
+ test('should display chat interface with initial elements', async ({ page }) => {
+ // Check for main navigation
+ await expect(page.locator('nav')).toContainText('AI Chat with Enterprise Data');
+
+ // Check for chat components
+ await expect(page.locator('azc-chat')).toBeVisible();
+ await expect(page.locator('azc-history')).toBeVisible();
+
+ // Check for chat input
+ await expect(page.locator('azc-chat').locator('.chat-input-container')).toBeVisible();
+ });
+
+ test('should display default prompt suggestions', async ({ page }) => {
+ // Wait for the component to load
+ await page.waitForTimeout(1000);
+
+ // Check for prompt suggestions
+ const suggestions = page.locator('azc-chat').locator('.suggestion');
+
+ // Should have some default suggestions
+ await expect(suggestions.first()).toBeVisible({ timeout: 10_000 });
+
+ // Check for expected suggestion content
+ await expect(page.locator('azc-chat')).toContainText('Ask anything or try an example');
+ });
+
+ test('should allow user to type and send a message', async ({ page }) => {
+ // Wait for component to be ready
+ await page.waitForTimeout(1000);
+
+ // Find the chat input
+ const chatInput = page.locator('azc-chat').locator('textarea');
+ await expect(chatInput).toBeVisible({ timeout: 10_000 });
+
+ // Type a message
+ const testMessage = 'Hello, can you help me with rental information?';
+ await chatInput.fill(testMessage);
+
+ // Send the message
+ const sendButton = page.locator('azc-chat').locator('button[type="submit"]');
+ await sendButton.click();
+
+ // Check that the message appears in the chat
+ await expect(page.locator('azc-chat').locator('.messages')).toContainText(testMessage, { timeout: 10_000 });
+
+ // Check for loading indicator
+ await expect(page.locator('azc-chat').locator('.loading')).toBeVisible({ timeout: 5000 });
+ });
+
+ test('should handle suggestion click', async ({ page }) => {
+ // Wait for component to load
+ await page.waitForTimeout(1000);
+
+ // Find and click the first suggestion
+ const firstSuggestion = page.locator('azc-chat').locator('.suggestion').first();
+ await expect(firstSuggestion).toBeVisible({ timeout: 10_000 });
+
+ const suggestionText = await firstSuggestion.textContent();
+ await firstSuggestion.click();
+
+ // Verify the suggestion text appears in the chat
+ await expect(page.locator('azc-chat').locator('.messages')).toContainText(suggestionText || '', {
+ timeout: 10_000,
+ });
+ });
+
+ test('should display chat history', async ({ page }) => {
+ // Check that history component exists
+ await expect(page.locator('azc-history')).toBeVisible();
+
+ // Send a message to create history
+ await page.waitForTimeout(1000);
+ const chatInput = page.locator('azc-chat').locator('textarea');
+ await expect(chatInput).toBeVisible({ timeout: 10_000 });
+
+ await chatInput.fill('Test message for history');
+ const sendButton = page.locator('azc-chat').locator('button[type="submit"]');
+ await sendButton.click();
+
+ // Wait for response and check history updates
+ await page.waitForTimeout(3000);
+
+ // History should show the conversation
+ await expect(page.locator('azc-history')).toBeVisible();
+ });
+
+ test('should generate unique user ID and persist in localStorage', async ({ page }) => {
+ // Check that a user ID is generated and stored
+ const userId = await page.evaluate(() => localStorage.getItem('userId'));
+ expect(userId).toBeTruthy();
+ expect(userId).toMatch(/^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i);
+
+ // Refresh the page and check that the same user ID is used
+ await page.reload();
+ const sameUserId = await page.evaluate(() => localStorage.getItem('userId'));
+ expect(sameUserId).toBe(userId);
+ });
+
+ test('should handle new chat functionality', async ({ page }) => {
+ // Send a message first
+ await page.waitForTimeout(1000);
+ const chatInput = page.locator('azc-chat').locator('textarea');
+ await expect(chatInput).toBeVisible({ timeout: 10_000 });
+
+ await chatInput.fill('First message');
+ const sendButton = page.locator('azc-chat').locator('button[type="submit"]');
+ await sendButton.click();
+
+ // Wait for message to appear
+ await expect(page.locator('azc-chat').locator('.messages')).toContainText('First message', { timeout: 10_000 });
+
+ // Look for new chat button and click it
+ const newChatButton = page
+ .locator('azc-chat')
+ .locator('button')
+ .filter({ hasText: /new chat/i });
+ if ((await newChatButton.count()) > 0) {
+ await newChatButton.click();
+
+ // Check that messages are cleared
+ await page.waitForTimeout(1000);
+ const messagesCount = await page.locator('azc-chat').locator('.message').count();
+ expect(messagesCount).toBe(0);
+ }
+ });
+});