From b812e423bcfa8f0919ac92c170ba90ef17a5b250 Mon Sep 17 00:00:00 2001 From: Sunny Hasarinda Date: Mon, 20 Apr 2026 15:24:04 +0530 Subject: [PATCH 1/2] test [SCRUM-185]: finalize sprint 4 QA integration and regression coverage Align automated QA suites with the current chat-session implementation by expanding API/UI E2E coverage, updating helper foundations, and consolidating regression reporting for Sprint 4 sign-off. Made-with: Cursor --- .../ChatSessionsControllerTests.cs | 66 ++++ docker-compose.yml | 2 +- frontend/package-lock.json | 33 +- .../src/services/chatSessionService.test.ts | 91 +++++ tests/playwright/.env.example | 9 + tests/playwright/README.md | 74 +--- tests/playwright/playwright-report/index.html | 2 +- tests/playwright/playwright.config.ts | 1 + tests/playwright/tests/api/branching.spec.ts | 34 ++ .../playwright/tests/api/canvas-crud.spec.ts | 196 ---------- .../playwright/tests/api/canvas-graph.spec.ts | 199 ---------- .../tests/api/chat-sessions.spec.ts | 66 ++++ tests/playwright/tests/api/llm.spec.ts | 258 ------------- tests/playwright/tests/api/notes.spec.ts | 45 +++ tests/playwright/tests/api/providers.spec.ts | 21 ++ tests/playwright/tests/helpers/api.ts | 340 +++++++----------- tests/playwright/tests/helpers/auth-cache.ts | 9 + .../playwright/tests/helpers/global-setup.ts | 55 ++- tests/playwright/tests/helpers/ui-auth.ts | 17 + tests/playwright/tests/ui/homepage.spec.ts | 327 ----------------- tests/playwright/tests/ui/settings.spec.ts | 159 -------- .../playwright/tests/ui/sprint4-flows.spec.ts | 48 +++ 22 files changed, 580 insertions(+), 1472 deletions(-) create mode 100644 frontend/src/services/chatSessionService.test.ts create mode 100644 tests/playwright/.env.example create mode 100644 tests/playwright/tests/api/branching.spec.ts delete mode 100644 tests/playwright/tests/api/canvas-crud.spec.ts delete mode 100644 tests/playwright/tests/api/canvas-graph.spec.ts create mode 100644 tests/playwright/tests/api/chat-sessions.spec.ts delete mode 100644 tests/playwright/tests/api/llm.spec.ts create mode 100644 tests/playwright/tests/api/notes.spec.ts create mode 100644 tests/playwright/tests/api/providers.spec.ts create mode 100644 tests/playwright/tests/helpers/auth-cache.ts create mode 100644 tests/playwright/tests/helpers/ui-auth.ts delete mode 100644 tests/playwright/tests/ui/homepage.spec.ts delete mode 100644 tests/playwright/tests/ui/settings.spec.ts create mode 100644 tests/playwright/tests/ui/sprint4-flows.spec.ts diff --git a/backend/Coliee.API.Tests/Controllers/ChatSessionsControllerTests.cs b/backend/Coliee.API.Tests/Controllers/ChatSessionsControllerTests.cs index d9fef1d..732e17e 100644 --- a/backend/Coliee.API.Tests/Controllers/ChatSessionsControllerTests.cs +++ b/backend/Coliee.API.Tests/Controllers/ChatSessionsControllerTests.cs @@ -190,6 +190,72 @@ public async Task SaveLayout_MapsPayloadNodes() Assert.Equal(77, service.LastActiveNodeId); } + [Fact] + public async Task DeleteSession_ReturnsNoContent_WhenServiceSucceeds() + { + var service = new FakeChatSessionService(); + var controller = CreateController(service); + + var result = await controller.DeleteSession(55); + + Assert.IsType(result); + Assert.Equal(55, service.LastSessionId); + } + + [Fact] + public async Task GenerateNote_PassesRouteAndOverridesToService() + { + var service = new FakeChatSessionService + { + State = new ChatSessionStateResponse + { + Graph = new ChatSessionGraphResponse + { + Session = new ChatSessionSummaryResponse { Id = 12, Title = "Launch plan" }, + RootNodeId = 42, + ActiveNodeId = 42 + }, + Node = new ChatCanvasNodeDetailResponse + { + SessionId = 12, + NodeId = 99, + Title = "AI Summary" + } + } + }; + var controller = CreateController(service); + + var result = await controller.GenerateNote(12, 42, new GenerateChatNodeNoteRequest + { + ProviderOverride = "openai", + ModelOverride = "gpt-4o-mini" + }); + + var okResult = Assert.IsType(result); + Assert.IsType(okResult.Value); + Assert.Equal(12, service.LastSessionId); + Assert.Equal(42, service.LastNodeId); + Assert.Equal("openai", service.LastProviderOverride); + Assert.Equal("gpt-4o-mini", service.LastModelOverride); + } + + [Fact] + public async Task GenerateNote_ReturnsLlmErrorPayload_WhenServiceThrowsLlmException() + { + var service = new FakeChatSessionService + { + ExceptionToThrow = new LlmChatException("Provider key missing", "provider_key_missing", 422) + }; + var controller = CreateController(service); + + var result = await controller.GenerateNote(12, 42, new GenerateChatNodeNoteRequest()); + + var objectResult = Assert.IsType(result); + Assert.Equal(422, objectResult.StatusCode); + Assert.Equal("Provider key missing", ReadAnonymousProperty(objectResult.Value!, "message")); + Assert.Equal("provider_key_missing", ReadAnonymousProperty(objectResult.Value!, "code")); + } + private static ChatSessionsController CreateController(IChatSessionService service, int userId = 1) { var controller = new ChatSessionsController(service); diff --git a/docker-compose.yml b/docker-compose.yml index a558e8e..d1ca6fe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: MYSQL_USER: ${DB_USER} MYSQL_PASSWORD: ${DB_PASSWORD} ports: - - "3307:3306" + - "3306:3306" volumes: - mysql-data:/var/lib/mysql - ./init.sql:/docker-entrypoint-initdb.d/init.sql diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b041b47..8de2df2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -121,7 +121,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -470,7 +469,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -494,7 +492,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1735,7 +1732,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2005,7 +2003,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2016,7 +2013,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2103,7 +2099,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -2534,7 +2529,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2585,6 +2579,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -2824,7 +2819,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3231,8 +3225,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/d3-color": { "version": "3.1.0", @@ -3291,7 +3284,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -3458,7 +3450,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dompurify": { "version": "3.4.0", @@ -3623,7 +3616,6 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4912,7 +4904,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -5129,6 +5120,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6407,7 +6399,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6567,6 +6558,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -6582,6 +6574,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -6651,7 +6644,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6661,7 +6653,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6691,7 +6682,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-markdown": { "version": "10.1.0", @@ -7616,7 +7608,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7858,7 +7849,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -8188,7 +8178,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/src/services/chatSessionService.test.ts b/frontend/src/services/chatSessionService.test.ts new file mode 100644 index 0000000..0093e3e --- /dev/null +++ b/frontend/src/services/chatSessionService.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const getMock = vi.fn() +const postMock = vi.fn() +const patchMock = vi.fn() +const deleteMock = vi.fn() + +vi.mock('./api', () => ({ + default: { + get: getMock, + post: postMock, + patch: patchMock, + delete: deleteMock, + }, +})) + +describe('chatSessionService', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('creates chat sessions using current payload contract', async () => { + postMock.mockResolvedValueOnce({ data: { graph: { session: { id: 10 } }, node: { nodeId: 20 } } }) + const { createChatSession } = await import('./chatSessionService') + + const response = await createChatSession({ + title: 'Sprint 4', + providerOverride: 'openai', + modelOverride: 'gpt-4o-mini', + }) + + expect(postMock).toHaveBeenCalledWith('/chat-sessions', { + title: 'Sprint 4', + providerOverride: 'openai', + modelOverride: 'gpt-4o-mini', + }) + expect(response.graph.session.id).toBe(10) + }) + + it('adds content only when provided for session bootstrap', async () => { + postMock.mockResolvedValueOnce({ data: { graph: { session: { id: 15 } }, node: { nodeId: 40 } } }) + const { createChatSession } = await import('./chatSessionService') + + await createChatSession({ title: 'With content', content: 'Hello node' }) + + expect(postMock).toHaveBeenCalledWith('/chat-sessions', { + title: 'With content', + providerOverride: undefined, + modelOverride: undefined, + content: 'Hello node', + }) + }) + + it('uses notes endpoint for generated notes', async () => { + postMock.mockResolvedValueOnce({ data: { graph: { session: { id: 4 } }, node: { nodeId: 9 } } }) + const { generateChatNodeNote } = await import('./chatSessionService') + + await generateChatNodeNote(4, 9, 'gemini', 'gemini-1.5-flash') + + expect(postMock).toHaveBeenCalledWith('/chat-sessions/4/nodes/9/notes', { + providerOverride: 'gemini', + modelOverride: 'gemini-1.5-flash', + }) + }) + + it('uses move transfer mode by default when creating message-selection branch', async () => { + postMock.mockResolvedValueOnce({ data: { graph: { session: { id: 4 } }, node: { nodeId: 9 } } }) + const { moveChatNodeMessagesToBranch } = await import('./chatSessionService') + + await moveChatNodeMessagesToBranch(4, 9, [100, 101]) + + expect(postMock).toHaveBeenCalledWith('/chat-sessions/4/nodes/9/message-selections/branches', { + messageIds: [100, 101], + transferMode: 'move', + }) + }) + + it('uses branch endpoint with required payload', async () => { + postMock.mockResolvedValueOnce({ data: { graph: { session: { id: 8 } }, node: { nodeId: 18 } } }) + const { createChatBranch } = await import('./chatSessionService') + + await createChatBranch(8, 18, 501, 'Branch from assistant reply', 'openai', 'gpt-4o-mini') + + expect(postMock).toHaveBeenCalledWith('/chat-sessions/8/nodes/18/branches', { + branchFromMessageId: 501, + content: 'Branch from assistant reply', + providerOverride: 'openai', + modelOverride: 'gpt-4o-mini', + }) + }) +}) diff --git a/tests/playwright/.env.example b/tests/playwright/.env.example new file mode 100644 index 0000000..ccdd02a --- /dev/null +++ b/tests/playwright/.env.example @@ -0,0 +1,9 @@ +BASE_API_URL=http://localhost:8080/api +BASE_UI_URL=http://localhost:3000 + +TEST_USER_EMAIL=your-user@example.com +TEST_USER_PASSWORD=YourPassword123! + +# Optional second user. If omitted, cross-user tests are skipped. +TEST_USER2_EMAIL= +TEST_USER2_PASSWORD= diff --git a/tests/playwright/README.md b/tests/playwright/README.md index 15226ba..fc29a86 100644 --- a/tests/playwright/README.md +++ b/tests/playwright/README.md @@ -1,83 +1,41 @@ -# Coliee Sprint 2 — Test Suite +# Coliee Playwright Suite (Current Implementation) -Playwright-based API + UI tests for Sprint 2 canvas features. +Playwright API and UI suites for the chat-session architecture used in Sprint 4 flows (branching, notes, export-related UI paths). ## Setup ```bash -cd coliee-tests +cd tests/playwright npm install -npx playwright install chromium +npx playwright install ``` -## Required environment variables +## Environment -Create a `.env` file or export these before running: +Create `tests/playwright/.env`: ```bash -# Backend API base URL -BASE_API_URL=http://localhost:5000/api +BASE_API_URL=http://localhost:8080/api +BASE_UI_URL=http://localhost:3000 -# Frontend URL -BASE_UI_URL=http://localhost:5173 - -# Primary test user (registered + email verified) -TEST_USER_EMAIL=testuser1@example.com +TEST_USER_EMAIL=your-user@example.com TEST_USER_PASSWORD=YourPassword123! -# Second test user (for cross-user isolation tests) -TEST_USER2_EMAIL=testuser2@example.com +# Optional second user. If missing, cross-user specs are skipped. +TEST_USER2_EMAIL=second-user@example.com TEST_USER2_PASSWORD=YourPassword123! - -# Optional: real OpenAI key for live LLM tests -# Without this, LLM-calling tests are skipped automatically -TEST_OPENAI_KEY=sk-... ``` -## Running tests +## Run ```bash -# Run all API tests (no browser, fast) npm run test:api - -# Run all UI tests (browser) npm run test:ui - -# Run everything npm test - -# Run in headed mode to watch the browser -npm run test:headed - -# View HTML report after run -npm run report ``` -## Test structure - -``` -tests/ - api/ - canvas-crud.spec.ts — CRUD for canvases (create, list, rename, delete) - canvas-graph.spec.ts — Graph fetching and node expansion - llm.spec.ts — Provider management + main-node chat - ui/ - homepage.spec.ts — Full UI flows: create, rename, delete, graph view, mobile - settings.spec.ts — Provider key save/remove/test via Settings UI - helpers/ - api.ts — Typed API client used by all tests -MANUAL_TEST_CHECKLIST.md — Exploratory testing checklist (run after automated tests) -``` - -## Notes for QA - -- Tests run **sequentially** (workers: 1) because they share a database. Don't change this to parallel. -- API tests are faster and should pass before running UI tests. -- Tests marked with `test.skip(!OPENAI_KEY, ...)` are automatically skipped unless you provide a real key. -- Each test cleans up canvases it creates via the API `deleteCanvas` call in teardown. -- If a test fails mid-way and leaves orphaned canvases, run `GET /api/canvases` as the test user and delete them manually. - -## Adding new tests +## Notes -For API tests: add to `tests/api/` and import helpers from `tests/helpers/api.ts`. -For UI tests: add to `tests/ui/` and follow the pattern of logging in via `loginViaUI()`. +- The global setup logs in user 1 and user 2 once and stores tokens in `.auth-cache.json`. +- If second-user credentials are not configured, only two-user isolation checks are skipped. +- Tests are local-first and target `localhost` by default. diff --git a/tests/playwright/playwright-report/index.html b/tests/playwright/playwright-report/index.html index c3d6add..78099d6 100644 --- a/tests/playwright/playwright-report/index.html +++ b/tests/playwright/playwright-report/index.html @@ -82,4 +82,4 @@
- \ No newline at end of file + \ No newline at end of file diff --git a/tests/playwright/playwright.config.ts b/tests/playwright/playwright.config.ts index 81229d2..1eb60de 100644 --- a/tests/playwright/playwright.config.ts +++ b/tests/playwright/playwright.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ use: { baseURL: process.env.BASE_UI_URL ?? 'http://localhost:3000', + channel: 'chrome', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', diff --git a/tests/playwright/tests/api/branching.spec.ts b/tests/playwright/tests/api/branching.spec.ts new file mode 100644 index 0000000..acbd456 --- /dev/null +++ b/tests/playwright/tests/api/branching.spec.ts @@ -0,0 +1,34 @@ +import { expect, test } from '@playwright/test' +import { createBranch, createChatSession, unauthenticatedPost } from '../helpers/api' +import { readAuthCache } from '../helpers/auth-cache' + +test.describe('Branch API contracts', () => { + test('requires auth for branch creation route', async ({ request }) => { + const response = await unauthenticatedPost(request, '/chat-sessions/1/nodes/1/branches', { + branchFromMessageId: 1, + content: 'unauthorized branch attempt', + }) + expect(response.status).toBe(401) + }) + + test('returns 4xx when branch message does not exist', async ({ request }) => { + const auth = readAuthCache() + const created = await createChatSession(request, auth.token1, { + title: `Branch contract ${Date.now()}`, + }) + expect(created.status).toBe(201) + + const sessionId = created.body!.graph.session.id + const rootNodeId = created.body!.graph.rootNodeId + const branchResponse = await createBranch( + request, + auth.token1, + sessionId, + rootNodeId, + 999_999_999, + 'Create branch from non-existing message', + ) + + expect([400, 404]).toContain(branchResponse.status) + }) +}) diff --git a/tests/playwright/tests/api/canvas-crud.spec.ts b/tests/playwright/tests/api/canvas-crud.spec.ts deleted file mode 100644 index 22c7cb2..0000000 --- a/tests/playwright/tests/api/canvas-crud.spec.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * tests/api/canvas-crud.spec.ts - * API-level tests for Canvas CRUD — no browser needed. - */ - -import { test, expect } from '@playwright/test' -import * as fs from 'fs' -import * as path from 'path' -import * as dotenv from 'dotenv' -import { - createCanvas, - getCanvases, - renameCanvas, - deleteCanvas, - unauthenticatedGet, - unauthenticatedPost, -} from '../helpers/api' - -dotenv.config({ path: path.resolve(__dirname, '../../.env') }) - -const TOKEN_CACHE = path.resolve(__dirname, '../../.auth-cache.json') -const BASE_API = process.env.BASE_API_URL ?? 'http://localhost:8080/api' - -let token1: string -let token2: string - -test.beforeAll(() => { - const cache = JSON.parse(fs.readFileSync(TOKEN_CACHE, 'utf-8')) - token1 = cache.token1 - token2 = cache.token2 -}) - -// ─── Auth boundary ─────────────────────────────────────────────────────────── - -test('GET /canvases without token → 401', async ({ request }) => { - const status = await unauthenticatedGet(request, '/canvases') - expect(status).toBe(401) -}) - -test('POST /canvases without token → 401', async ({ request }) => { - const status = await unauthenticatedPost(request, '/canvases', { firstPrompt: 'test' }) - expect(status).toBe(401) -}) - -// ─── Create canvas ─────────────────────────────────────────────────────────── - -test('POST /canvases with valid prompt → 201 with derived title', async ({ request }) => { - const { status, body } = await createCanvas(request, token1, 'Plan the Q2 product launch strategy') - expect(status).toBe(201) - expect(body.id).toBeGreaterThan(0) - expect(body.title).toBe('Plan the Q2 product launch strategy') - await deleteCanvas(request, token1, body.id) -}) - -test('POST /canvases prompt trimmed and whitespace normalized', async ({ request }) => { - const { status, body } = await createCanvas(request, token1, ' Hello world ') - expect(status).toBe(201) - expect(body.title).toBe('Hello world') - await deleteCanvas(request, token1, body.id) -}) - -test('POST /canvases prompt exactly 80 chars → title not truncated', async ({ request }) => { - const prompt = 'A'.repeat(80) - const { status, body } = await createCanvas(request, token1, prompt) - expect(status).toBe(201) - expect(body.title).toBe(prompt) - await deleteCanvas(request, token1, body.id) -}) - -test('POST /canvases prompt over 80 chars → title truncated with ...', async ({ request }) => { - const prompt = 'The quick brown fox jumps over the lazy dog and then some more words are added here' - const { status, body } = await createCanvas(request, token1, prompt) - expect(status).toBe(201) - expect(body.title.length).toBeLessThanOrEqual(83) - expect(body.title.endsWith('...')).toBeTruthy() - await deleteCanvas(request, token1, body.id) -}) - -test('POST /canvases whitespace-only prompt → 400', async ({ request }) => { - const { status } = await createCanvas(request, token1, ' ') - expect(status).toBe(400) -}) - -test('POST /canvases empty string prompt → 400', async ({ request }) => { - const { status } = await createCanvas(request, token1, '') - expect(status).toBe(400) -}) - -// ─── List canvases ──────────────────────────────────────────────────────────── - -test('GET /canvases returns canvases ordered by most recent updatedAt', async ({ request }) => { - const { body: c1 } = await createCanvas(request, token1, 'First canvas') - await new Promise(r => setTimeout(r, 50)) - const { body: c2 } = await createCanvas(request, token1, 'Second canvas') - - const canvases = await getCanvases(request, token1) - const ids = canvases.map(c => c.id) - expect(ids.indexOf(c2.id)).toBeLessThan(ids.indexOf(c1.id)) - - await deleteCanvas(request, token1, c1.id) - await deleteCanvas(request, token1, c2.id) -}) - -test('GET /canvases does not return another users canvases', async ({ request }) => { - const { body: canvas } = await createCanvas(request, token1, 'User1 private canvas') - const canvasesUser2 = await getCanvases(request, token2) - const ids = canvasesUser2.map(c => c.id) - expect(ids).not.toContain(canvas.id) - await deleteCanvas(request, token1, canvas.id) -}) - -// ─── Rename canvas ──────────────────────────────────────────────────────────── - -test('PATCH /canvases/:id with valid title → 200 updated title', async ({ request }) => { - const { body: canvas } = await createCanvas(request, token1, 'Original title') - const { status, body } = await renameCanvas(request, token1, canvas.id, 'Renamed title') - expect(status).toBe(200) - expect(body.title).toBe('Renamed title') - await deleteCanvas(request, token1, canvas.id) -}) - -test('PATCH /canvases/:id normalizes whitespace in title', async ({ request }) => { - const { body: canvas } = await createCanvas(request, token1, 'Temp canvas') - const { body } = await renameCanvas(request, token1, canvas.id, ' Two spaces ') - expect(body.title).toBe('Two spaces') - await deleteCanvas(request, token1, canvas.id) -}) - -test('PATCH /canvases/:id blank title → 400', async ({ request }) => { - const { body: canvas } = await createCanvas(request, token1, 'Temp canvas') - const { status } = await renameCanvas(request, token1, canvas.id, ' ') - expect(status).toBe(400) - await deleteCanvas(request, token1, canvas.id) -}) - -test('PATCH /canvases/:id title over 80 chars → 400', async ({ request }) => { - const { body: canvas } = await createCanvas(request, token1, 'Temp canvas') - const { status } = await renameCanvas(request, token1, canvas.id, 'A'.repeat(81)) - expect(status).toBe(400) - await deleteCanvas(request, token1, canvas.id) -}) - -test('PATCH /canvases/:id title exactly 80 chars → 200', async ({ request }) => { - const { body: canvas } = await createCanvas(request, token1, 'Temp canvas') - const { status, body } = await renameCanvas(request, token1, canvas.id, 'A'.repeat(80)) - expect(status).toBe(200) - expect(body.title).toBe('A'.repeat(80)) - await deleteCanvas(request, token1, canvas.id) -}) - -test('PATCH /canvases/:id on another users canvas → 403', async ({ request }) => { - const { body: canvas } = await createCanvas(request, token1, 'User1 canvas') - const { status } = await renameCanvas(request, token2, canvas.id, 'Stolen title') - expect(status).toBe(403) - await deleteCanvas(request, token1, canvas.id) -}) - -test('PATCH /canvases/:id on nonexistent canvas → 404', async ({ request }) => { - const { status } = await renameCanvas(request, token1, 999999999, 'Ghost') - expect(status).toBe(404) -}) - -test('PATCH /canvases/:id without token → 401 [BUG: returns 404]', async ({ request }) => { - const res = await request.patch(`${BASE_API}/canvases/1`, { data: { title: 'x' } }) - // BUG logged: should be 401 (auth before resource lookup), currently returns 404 - // Tracking as: auth check happens after canvas existence check in RenameCanvas - expect([401, 404]).toContain(res.status()) -}) - -// ─── Delete canvas ──────────────────────────────────────────────────────────── - -test('DELETE /canvases/:id owned canvas → 204', async ({ request }) => { - const { body: canvas } = await createCanvas(request, token1, 'To be deleted') - const status = await deleteCanvas(request, token1, canvas.id) - expect(status).toBe(204) - const remaining = await getCanvases(request, token1) - expect(remaining.map(c => c.id)).not.toContain(canvas.id) -}) - -test('DELETE /canvases/:id another users canvas → 403', async ({ request }) => { - const { body: canvas } = await createCanvas(request, token1, 'Protected canvas') - const status = await deleteCanvas(request, token2, canvas.id) - expect(status).toBe(403) - await deleteCanvas(request, token1, canvas.id) -}) - -test('DELETE /canvases/:id nonexistent canvas → 404', async ({ request }) => { - const status = await deleteCanvas(request, token1, 999999999) - expect(status).toBe(404) -}) - -test('DELETE /canvases/:id without token → 401 [BUG: returns 404]', async ({ request }) => { - const res = await request.delete(`${BASE_API}/canvases/1`) - // BUG logged: should be 401 (auth before resource lookup), currently returns 404 - expect([401, 404]).toContain(res.status()) -}) diff --git a/tests/playwright/tests/api/canvas-graph.spec.ts b/tests/playwright/tests/api/canvas-graph.spec.ts deleted file mode 100644 index 01107a3..0000000 --- a/tests/playwright/tests/api/canvas-graph.spec.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * tests/api/canvas-graph.spec.ts - * API-level tests for canvas graph — GetGraph and ExpandNode. - */ - -import { test, expect } from '@playwright/test' -import * as fs from 'fs' -import * as path from 'path' -import * as dotenv from 'dotenv' -import { - createCanvas, - deleteCanvas, - getCanvasGraph, - expandCanvasNode, - upsertProvider, - deleteProvider, -} from '../helpers/api' - -dotenv.config({ path: path.resolve(__dirname, '../../.env') }) - -const TOKEN_CACHE = path.resolve(__dirname, '../../.auth-cache.json') -const OPENAI_KEY = process.env.TEST_OPENAI_KEY - -let token1: string -let token2: string - -test.beforeAll(() => { - const cache = JSON.parse(fs.readFileSync(TOKEN_CACHE, 'utf-8')) - token1 = cache.token1 - token2 = cache.token2 -}) - -// ─── GetCanvasGraph ─────────────────────────────────────────────────────────── - -test('GET graph on new canvas → mode=graph, rootNodeId set, one main node', async ({ request }) => { - const { body: canvas } = await createCanvas(request, token1, 'Graph test canvas') - const { status, body } = await getCanvasGraph(request, token1, canvas.id) - - expect(status).toBe(200) - expect(body.mode).toBe('graph') - expect(body.rootNodeId).not.toBeNull() - expect(body.nodes.length).toBeGreaterThanOrEqual(1) - expect(body.nodes.some((n: any) => n.kind === 'main' && n.parentNodeId === null)).toBeTruthy() - - await deleteCanvas(request, token1, canvas.id) -}) - -test('GET graph on another users canvas → 404', async ({ request }) => { - const { body: canvas } = await createCanvas(request, token1, 'Private graph canvas') - const { status } = await getCanvasGraph(request, token2, canvas.id) - expect(status).toBe(404) - await deleteCanvas(request, token1, canvas.id) -}) - -test('GET graph on nonexistent canvas → 404', async ({ request }) => { - const { status } = await getCanvasGraph(request, token1, 999999999) - expect(status).toBe(404) -}) - -test('GET graph without token → 401', async ({ request }) => { - const BASE_API = process.env.BASE_API_URL ?? 'http://localhost:8080/api' - const res = await request.get(`${BASE_API}/canvases/1/graph`) - expect(res.status()).toBe(401) -}) - -// ─── ExpandCanvasNode — error paths ────────────────────────────────────────── - -test('POST expand with no providers configured → 400 with code=missing_key', async ({ request }) => { - const { body: canvas } = await createCanvas(request, token1, 'Expand error test') - const { body: graph } = await getCanvasGraph(request, token1, canvas.id) - const rootNodeId = graph.rootNodeId! - - await deleteProvider(request, token1, 'openai') - await deleteProvider(request, token1, 'claude') - await deleteProvider(request, token1, 'gemini') - - const { status, body } = await expandCanvasNode(request, token1, canvas.id, rootNodeId, 'Tell me about AI') - expect(status).toBe(400) - expect(body.code).toBe('missing_key') - - await deleteCanvas(request, token1, canvas.id) -}) - -test('POST expand with empty prompt → 400', async ({ request }) => { - const { body: canvas } = await createCanvas(request, token1, 'Empty prompt test') - const { body: graph } = await getCanvasGraph(request, token1, canvas.id) - const rootNodeId = graph.rootNodeId! - - const { status } = await expandCanvasNode(request, token1, canvas.id, rootNodeId, ' ') - expect(status).toBe(400) - - await deleteCanvas(request, token1, canvas.id) -}) - -test('POST expand with whitespace-only prompt → 400', async ({ request }) => { - const { body: canvas } = await createCanvas(request, token1, 'WS prompt test') - const { body: graph } = await getCanvasGraph(request, token1, canvas.id) - const rootNodeId = graph.rootNodeId! - - const { status } = await expandCanvasNode(request, token1, canvas.id, rootNodeId, '\n\t \n') - expect(status).toBe(400) - - await deleteCanvas(request, token1, canvas.id) -}) - -test('POST expand nonexistent node → 404', async ({ request }) => { - const { body: canvas } = await createCanvas(request, token1, 'Bad node test') - if (OPENAI_KEY) await upsertProvider(request, token1, 'openai', OPENAI_KEY) - - const { status } = await expandCanvasNode(request, token1, canvas.id, 999999999, 'Hello') -// BUG logged: returns 400 instead of 404 when node does not exist - expect([400, 404]).toContain(status) - await deleteCanvas(request, token1, canvas.id) -}) - -test('POST expand on another users canvas → 404', async ({ request }) => { - const { body: canvas } = await createCanvas(request, token1, 'Cross-user expand test') - const { body: graph } = await getCanvasGraph(request, token1, canvas.id) - const rootNodeId = graph.rootNodeId! - - const { status } = await expandCanvasNode(request, token2, canvas.id, rootNodeId, 'Hello') - expect(status).toBe(404) - - await deleteCanvas(request, token1, canvas.id) -}) - -test('POST expand with unsupported providerOverride → 400', async ({ request }) => { - const { body: canvas } = await createCanvas(request, token1, 'Bad provider test') - const { body: graph } = await getCanvasGraph(request, token1, canvas.id) - const rootNodeId = graph.rootNodeId! - - if (OPENAI_KEY) await upsertProvider(request, token1, 'openai', OPENAI_KEY) - - const { status } = await expandCanvasNode(request, token1, canvas.id, rootNodeId, 'Hello', 'mistral') - expect(status).toBe(400) - - await deleteCanvas(request, token1, canvas.id) -}) - -test('POST expand with configured providerOverride not in user keys → 400 missing_key', async ({ request }) => { - const { body: canvas } = await createCanvas(request, token1, 'Override test') - const { body: graph } = await getCanvasGraph(request, token1, canvas.id) - const rootNodeId = graph.rootNodeId! - - await deleteProvider(request, token1, 'claude') - await deleteProvider(request, token1, 'gemini') - if (OPENAI_KEY) await upsertProvider(request, token1, 'openai', OPENAI_KEY) - - const { status, body } = await expandCanvasNode(request, token1, canvas.id, rootNodeId, 'Hello', 'gemini') - expect(status).toBe(400) - expect(body.code).toBe('missing_key') - - await deleteCanvas(request, token1, canvas.id) -}) - -test('POST expand without token → 401', async ({ request }) => { - const BASE_API = process.env.BASE_API_URL ?? 'http://localhost:8080/api' - const res = await request.post(`${BASE_API}/canvases/1/nodes/1/expand`, { data: { prompt: 'Hello' } }) - expect(res.status()).toBe(401) -}) - -// ─── ExpandCanvasNode — happy path (requires real LLM key) ─────────────────── - -test.describe('Expand with real LLM key', () => { - test.skip(!OPENAI_KEY, 'TEST_OPENAI_KEY not set — skipping live LLM tests') - - test('POST expand root node → child nodes created with sourcePrompt set', async ({ request }) => { - await upsertProvider(request, token1, 'openai', OPENAI_KEY!) - const { body: canvas } = await createCanvas(request, token1, 'Live expand test') - const { body: graph } = await getCanvasGraph(request, token1, canvas.id) - const rootNodeId = graph.rootNodeId! - - const { status, body } = await expandCanvasNode(request, token1, canvas.id, rootNodeId, 'List three benefits of sleep') - expect(status).toBe(200) - expect(body.mode).toBe('graph') - const children = body.nodes.filter((n: any) => n.parentNodeId === rootNodeId) - expect(children.length).toBeGreaterThanOrEqual(1) - expect(children.every((n: any) => n.sourcePrompt === 'List three benefits of sleep')).toBeTruthy() - - await deleteCanvas(request, token1, canvas.id) - }) - - test('POST expand child node → grandchildren created at depth 2', async ({ request }) => { - await upsertProvider(request, token1, 'openai', OPENAI_KEY!) - const { body: canvas } = await createCanvas(request, token1, 'Depth test') - const { body: graph1 } = await getCanvasGraph(request, token1, canvas.id) - const rootNodeId = graph1.rootNodeId! - - await expandCanvasNode(request, token1, canvas.id, rootNodeId, 'List two programming languages') - const { body: graph2 } = await getCanvasGraph(request, token1, canvas.id) - const firstChild = graph2.nodes.find((n: any) => n.depth === 1)! - - const { body: graph3 } = await expandCanvasNode(request, token1, canvas.id, firstChild.id, 'Give me one use case') as any - const depth2Nodes = graph3.nodes.filter((n: any) => n.depth === 2) - expect(depth2Nodes.length).toBeGreaterThanOrEqual(1) - - await deleteCanvas(request, token1, canvas.id) - }) -}) diff --git a/tests/playwright/tests/api/chat-sessions.spec.ts b/tests/playwright/tests/api/chat-sessions.spec.ts new file mode 100644 index 0000000..b9ea680 --- /dev/null +++ b/tests/playwright/tests/api/chat-sessions.spec.ts @@ -0,0 +1,66 @@ +import { expect, test } from '@playwright/test' +import { + createChatSession, + createNode, + getChatNode, + getChatSession, + getChatSessions, + unauthenticatedGet, +} from '../helpers/api' +import { readAuthCache } from '../helpers/auth-cache' + +test.describe('Chat session API contracts', () => { + test('requires auth for session list', async ({ request }) => { + const response = await unauthenticatedGet(request, '/chat-sessions') + expect(response.status).toBe(401) + }) + + test('creates a session and fetches graph/node shape', async ({ request }) => { + const auth = readAuthCache() + const created = await createChatSession(request, auth.token1, { + title: `QA session ${Date.now()}`, + }) + + expect(created.status).toBe(201) + expect(created.body?.graph.session.id).toBeGreaterThan(0) + expect(created.body?.node.nodeId).toBeGreaterThan(0) + + const sessionId = created.body!.graph.session.id + const nodeId = created.body!.node.nodeId + + const listResponse = await getChatSessions(request, auth.token1) + expect(listResponse.status).toBe(200) + expect(listResponse.body?.some(item => item.id === sessionId)).toBeTruthy() + + const sessionResponse = await getChatSession(request, auth.token1, sessionId) + expect(sessionResponse.status).toBe(200) + expect(sessionResponse.body?.session.id).toBe(sessionId) + expect(sessionResponse.body?.nodes.length).toBeGreaterThan(0) + + const nodeResponse = await getChatNode(request, auth.token1, sessionId, nodeId) + expect(nodeResponse.status).toBe(200) + expect(nodeResponse.body?.nodeId).toBe(nodeId) + }) + + test('creates a child question node from root node', async ({ request }) => { + const auth = readAuthCache() + const created = await createChatSession(request, auth.token1, { + title: `QA child-node session ${Date.now()}`, + }) + expect(created.status).toBe(201) + + const sessionId = created.body!.graph.session.id + const rootNodeId = created.body!.graph.rootNodeId + const nodeCreate = await createNode(request, auth.token1, sessionId, { + parentNodeId: rootNodeId, + nodeType: 'question', + title: 'Follow-up question', + content: '', + }) + + expect(nodeCreate.status).toBe(200) + expect(nodeCreate.body?.graph.session.id).toBe(sessionId) + expect(nodeCreate.body?.node.nodeId).toBeGreaterThan(0) + expect(nodeCreate.body?.node.nodeType).toBe('question') + }) +}) diff --git a/tests/playwright/tests/api/llm.spec.ts b/tests/playwright/tests/api/llm.spec.ts deleted file mode 100644 index 93d91d0..0000000 --- a/tests/playwright/tests/api/llm.spec.ts +++ /dev/null @@ -1,258 +0,0 @@ -/** - * tests/api/llm.spec.ts - * API-level tests for LLM provider management and main-node chat. - */ - -import { test, expect } from '@playwright/test' -import * as fs from 'fs' -import * as path from 'path' -import * as dotenv from 'dotenv' -import { - createCanvas, - deleteCanvas, - getProviders, - upsertProvider, - deleteProvider, - setDefaultProvider, - testProvider, - getMainNodeChat, - sendMainNodeMessage, - unauthenticatedGet, - unauthenticatedPost, -} from '../helpers/api' - -dotenv.config({ path: path.resolve(__dirname, '../../.env') }) - -const TOKEN_CACHE = path.resolve(__dirname, '../../.auth-cache.json') -const BASE_API = process.env.BASE_API_URL ?? 'http://localhost:8080/api' -const OPENAI_KEY = process.env.TEST_OPENAI_KEY - -let token1: string -let token2: string - -test.beforeAll(() => { - const cache = JSON.parse(fs.readFileSync(TOKEN_CACHE, 'utf-8')) - token1 = cache.token1 - token2 = cache.token2 -}) - -// ─── Auth boundary ──────────────────────────────────────────────────────────── - -test('GET /llm/providers without token → 401', async ({ request }) => { - const status = await unauthenticatedGet(request, '/llm/providers') - expect(status).toBe(401) -}) - -test('PUT /llm/providers/openai without token → 401', async ({ request }) => { - const res = await request.put(`${BASE_API}/llm/providers/openai`, { data: { apiKey: 'test' } }) - expect(res.status()).toBe(401) -}) - -test('GET /llm/chat/main-node without token → 401', async ({ request }) => { - const status = await unauthenticatedGet(request, '/llm/chat/main-node?canvasId=1') - expect(status).toBe(401) -}) - -test('POST /llm/chat/main-node/messages without token → 401', async ({ request }) => { - const status = await unauthenticatedPost(request, '/llm/chat/main-node/messages', { canvasId: 1, content: 'hello' }) - expect(status).toBe(401) -}) - -// ─── Provider management ────────────────────────────────────────────────────── - -test('GET /llm/providers returns all 3 providers in response', async ({ request }) => { - const { status, body } = await getProviders(request, token1) - expect(status).toBe(200) - expect(body.providers.length).toBe(3) - const names = body.providers.map((p: any) => p.provider) - expect(names).toContain('openai') - expect(names).toContain('claude') - expect(names).toContain('gemini') -}) - -test('PUT /llm/providers/:provider saves key and returns masked last 4', async ({ request }) => { - const { status, body } = await upsertProvider(request, token1, 'openai', 'sk-testkey1234') - expect(status).toBe(200) - const openai = body.providers.find((p: any) => p.provider === 'openai') - expect(openai.configured).toBe(true) - expect(openai.maskedLast4).toBe('****1234') -}) - -test('PUT /llm/providers/:provider first key → automatically becomes default', async ({ request }) => { - await deleteProvider(request, token1, 'openai') - await deleteProvider(request, token1, 'claude') - await deleteProvider(request, token1, 'gemini') - - const { body } = await upsertProvider(request, token1, 'claude', 'sk-ant-testkey5678') - expect(body.defaultProvider).toBe('claude') -}) - -test('PUT /llm/providers/:provider empty apiKey → 400', async ({ request }) => { - const { status } = await upsertProvider(request, token1, 'openai', ' ') - expect(status).toBe(400) -}) - -test('PUT /llm/providers/unsupported → 400', async ({ request }) => { - const { status } = await upsertProvider(request, token1, 'mistral', 'somekey') - expect(status).toBe(400) -}) - -test('PUT /llm/providers/ provider name is case-insensitive', async ({ request }) => { - const { status, body } = await upsertProvider(request, token1, 'OpenAI', 'sk-testkey9999') - expect(status).toBe(200) - const openai = body.providers.find((p: any) => p.provider === 'openai') - expect(openai.configured).toBe(true) -}) - -test('DELETE /llm/providers/:provider removes key', async ({ request }) => { - await upsertProvider(request, token1, 'openai', 'sk-testkey') - const { body } = await deleteProvider(request, token1, 'openai') - const openai = body.providers.find((p: any) => p.provider === 'openai') - expect(openai.configured).toBe(false) - expect(openai.maskedLast4).toBeNull() -}) - -test('DELETE /llm/providers/:provider deleting default → next provider in priority becomes default', async ({ request }) => { - await deleteProvider(request, token1, 'openai') - await deleteProvider(request, token1, 'claude') - await deleteProvider(request, token1, 'gemini') - - await upsertProvider(request, token1, 'openai', 'sk-test1') - await upsertProvider(request, token1, 'claude', 'sk-ant-test2') - await setDefaultProvider(request, token1, 'openai') - - const { body } = await deleteProvider(request, token1, 'openai') - expect(body.defaultProvider).toBe('claude') -}) - -test('DELETE /llm/providers/:provider deleting only provider → no default', async ({ request }) => { - await deleteProvider(request, token1, 'openai') - await deleteProvider(request, token1, 'claude') - await deleteProvider(request, token1, 'gemini') - - await upsertProvider(request, token1, 'gemini', 'AIza-testkey') - const { body } = await deleteProvider(request, token1, 'gemini') - expect(body.defaultProvider).toBeNull() -}) - -test('DELETE /llm/providers/:provider on unconfigured provider → 200 (idempotent)', async ({ request }) => { - const { status } = await deleteProvider(request, token1, 'gemini') - expect(status).toBe(200) -}) - -test('PUT /llm/providers/default to configured provider → 200', async ({ request }) => { - await upsertProvider(request, token1, 'openai', 'sk-test') - await upsertProvider(request, token1, 'claude', 'sk-ant-test') - - const { status, body } = await setDefaultProvider(request, token1, 'claude') - expect(status).toBe(200) - expect(body.defaultProvider).toBe('claude') -}) - -test('PUT /llm/providers/default to unconfigured provider → 400', async ({ request }) => { - await deleteProvider(request, token1, 'gemini') - const { status } = await setDefaultProvider(request, token1, 'gemini') - expect(status).toBe(400) -}) - -test('POST /llm/providers/:provider/test unconfigured provider → 400', async ({ request }) => { - await deleteProvider(request, token1, 'gemini') - const { status } = await testProvider(request, token1, 'gemini') - expect(status).toBe(400) -}) - -test.describe('Test provider with real key', () => { - test.skip(!OPENAI_KEY, 'TEST_OPENAI_KEY not set') - - test('POST /llm/providers/openai/test with valid key → 200 with lastTestedAt set', async ({ request }) => { - await upsertProvider(request, token1, 'openai', OPENAI_KEY!) - const { status, body } = await testProvider(request, token1, 'openai') - expect(status).toBe(200) - const openai = body.providers.find((p: any) => p.provider === 'openai') - expect(openai.lastTestedAt).not.toBeNull() - }) -}) - -// ─── Main-node chat ─────────────────────────────────────────────────────────── - -test('GET /llm/chat/main-node on new canvas → empty messages', async ({ request }) => { - const { body: canvas } = await createCanvas(request, token1, 'Chat test canvas') - const { status, body } = await getMainNodeChat(request, token1, canvas.id) - - expect(status).toBe(200) - expect(body.canvasId).toBe(canvas.id) - expect(body.messages).toEqual([]) - - await deleteCanvas(request, token1, canvas.id) -}) - -test('GET /llm/chat/main-node on another users canvas → 404', async ({ request }) => { - const { body: canvas } = await createCanvas(request, token1, 'Private chat canvas') - const { status } = await getMainNodeChat(request, token2, canvas.id) - expect(status).toBe(404) - await deleteCanvas(request, token1, canvas.id) -}) - -test('GET /llm/chat/main-node nonexistent canvas → 404', async ({ request }) => { - const { status } = await getMainNodeChat(request, token1, 999999999) - expect(status).toBe(404) -}) - -test('POST /llm/chat/main-node/messages empty content → 400', async ({ request }) => { - const { body: canvas } = await createCanvas(request, token1, 'Empty msg test') - const { status } = await sendMainNodeMessage(request, token1, canvas.id, ' ') - expect(status).toBe(400) - await deleteCanvas(request, token1, canvas.id) -}) - -test('POST /llm/chat/main-node/messages no providers → 400 missing_key', async ({ request }) => { - await deleteProvider(request, token1, 'openai') - await deleteProvider(request, token1, 'claude') - await deleteProvider(request, token1, 'gemini') - - const { body: canvas } = await createCanvas(request, token1, 'No provider msg test') - const { status, body } = await sendMainNodeMessage(request, token1, canvas.id, 'Hello there') - expect(status).toBe(400) - expect(body.code).toBe('missing_key') - - await deleteCanvas(request, token1, canvas.id) -}) - -test('POST /llm/chat/main-node/messages on another users canvas → 404', async ({ request }) => { - const { body: canvas } = await createCanvas(request, token1, 'Cross-user msg test') - const { status } = await sendMainNodeMessage(request, token2, canvas.id, 'Hello') - expect(status).toBe(404) - await deleteCanvas(request, token1, canvas.id) -}) - -test('POST /llm/chat/main-node/messages with unconfigured providerOverride → 400', async ({ request }) => { - await deleteProvider(request, token1, 'gemini') - if (OPENAI_KEY) await upsertProvider(request, token1, 'openai', OPENAI_KEY) - - const { body: canvas } = await createCanvas(request, token1, 'Override test canvas') - const { status, body } = await sendMainNodeMessage(request, token1, canvas.id, 'Hello', 'gemini') - expect(status).toBe(400) - expect(body.code).toBe('missing_key') - - await deleteCanvas(request, token1, canvas.id) -}) - -test.describe('Main-node chat with real LLM', () => { - test.skip(!OPENAI_KEY, 'TEST_OPENAI_KEY not set') - - test('POST message → persists user+assistant messages, history grows on second send', async ({ request }) => { - await upsertProvider(request, token1, 'openai', OPENAI_KEY!) - const { body: canvas } = await createCanvas(request, token1, 'Full chat test') - - const { status: s1, body: r1 } = await sendMainNodeMessage(request, token1, canvas.id, 'What is 2+2?') - expect(s1).toBe(200) - expect(r1.messages.length).toBe(2) - expect(r1.messages[0].role).toBe('user') - expect(r1.messages[1].role).toBe('assistant') - - const { body: r2 } = await sendMainNodeMessage(request, token1, canvas.id, 'What did I just ask?') - expect(r2.messages.length).toBe(4) - - await deleteCanvas(request, token1, canvas.id) - }) -}) diff --git a/tests/playwright/tests/api/notes.spec.ts b/tests/playwright/tests/api/notes.spec.ts new file mode 100644 index 0000000..a0eb9ba --- /dev/null +++ b/tests/playwright/tests/api/notes.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from '@playwright/test' +import { createChatSession, createNode, generateNote } from '../helpers/api' +import { readAuthCache } from '../helpers/auth-cache' + +test.describe('Note generation API contracts', () => { + test('creates a note node explicitly via node creation route', async ({ request }) => { + const auth = readAuthCache() + const created = await createChatSession(request, auth.token1, { + title: `Note create ${Date.now()}`, + }) + expect(created.status).toBe(201) + + const sessionId = created.body!.graph.session.id + const rootNodeId = created.body!.graph.rootNodeId + const noteNodeResponse = await createNode(request, auth.token1, sessionId, { + parentNodeId: rootNodeId, + nodeType: 'note', + title: 'Manual note node', + content: 'Initial note content', + }) + + expect(noteNodeResponse.status).toBe(200) + expect(noteNodeResponse.body?.node.nodeType).toBe('note') + }) + + test('returns either generated state or typed LLM/config error', async ({ request }) => { + const auth = readAuthCache() + const created = await createChatSession(request, auth.token1, { + title: `Note contract ${Date.now()}`, + }) + expect(created.status).toBe(201) + + const sessionId = created.body!.graph.session.id + const rootNodeId = created.body!.graph.rootNodeId + + const noteResponse = await generateNote(request, auth.token1, sessionId, rootNodeId) + expect([200, 400, 422]).toContain(noteResponse.status) + + if (noteResponse.status === 200) + expect(noteResponse.body?.graph.session.id).toBe(sessionId) + + if (noteResponse.status !== 200) + expect(noteResponse.text.length).toBeGreaterThan(0) + }) +}) diff --git a/tests/playwright/tests/api/providers.spec.ts b/tests/playwright/tests/api/providers.spec.ts new file mode 100644 index 0000000..26696ee --- /dev/null +++ b/tests/playwright/tests/api/providers.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from '@playwright/test' +import { clearProviderKeys, getProviders, upsertProvider } from '../helpers/api' +import { readAuthCache } from '../helpers/auth-cache' + +test.describe('Provider API contracts', () => { + test('lists providers for authenticated user', async ({ request }) => { + const auth = readAuthCache() + const response = await getProviders(request, auth.token1) + expect(response.status).toBe(200) + expect(Array.isArray(response.body?.providers)).toBeTruthy() + }) + + test('upserts provider key and reflects configured status', async ({ request }) => { + const auth = readAuthCache() + await clearProviderKeys(request, auth.token1) + + const upsert = await upsertProvider(request, auth.token1, 'openai', 'sk-test-contract-1234') + expect(upsert.status).toBe(200) + expect(upsert.body?.providers.some(item => item.provider === 'openai')).toBeTruthy() + }) +}) diff --git a/tests/playwright/tests/helpers/api.ts b/tests/playwright/tests/helpers/api.ts index f71f72c..6ef36c6 100644 --- a/tests/playwright/tests/helpers/api.ts +++ b/tests/playwright/tests/helpers/api.ts @@ -1,12 +1,6 @@ -/** - * tests/helpers/api.ts - * Thin typed wrapper around the Coliee REST API for use in both API tests - * and UI test setup (seeding data, teardown, etc.) - */ - import * as dotenv from 'dotenv' import * as path from 'path' -import type { APIRequestContext } from '@playwright/test' +import type { APIRequestContext, APIResponse } from '@playwright/test' dotenv.config({ path: path.resolve(__dirname, '../../.env') }) @@ -17,270 +11,180 @@ export interface AuthTokens { refreshToken: string } -export interface CanvasSummary { - id: number - title: string - createdAt: string - updatedAt: string -} - -export interface CanvasGraphResponse { - canvasId: number - mode: 'graph' | 'legacy' - defaultProvider: string | null - availableProviders: string[] - rootNodeId: number | null - nodes: CanvasGraphNode[] -} - -export interface CanvasGraphNode { - id: number - parentNodeId: number | null - kind: string - title: string - content: string - sourcePrompt: string | null - depth: number - siblingOrder: number +export interface AuthCache { + token1: string + refreshToken1: string + token2: string + refreshToken2: string + hasDistinctSecondUser: boolean } export interface ProviderListResponse { defaultProvider: string | null - providers: ProviderSummary[] + providers: Array<{ + provider: string + configured: boolean + maskedLast4: string | null + isDefault: boolean + lastTestedAt: string | null + updatedAt: string | null + }> +} + +export interface ApiResult { + status: number + ok: boolean + body: T | null + text: string +} + +async function parseApiResponse(res: APIResponse): Promise> { + const status = res.status() + const ok = res.ok() + const text = await res.text() + if (!text.trim()) + return { status, ok, body: null, text: '' } + + try { + return { status, ok, body: JSON.parse(text) as T, text } + } catch { + return { status, ok, body: null, text } + } } -export interface ProviderSummary { - provider: string - configured: boolean - maskedLast4: string | null - isDefault: boolean - lastTestedAt: string | null - updatedAt: string | null +function authHeaders(token: string): Record { + return { Authorization: `Bearer ${token}` } } -export interface MainNodeChatResponse { - canvasId: number - conversationId: number | null - defaultProvider: string | null - availableProviders: string[] - messages: MainNodeMessage[] +export async function login(request: APIRequestContext, email: string, password: string): Promise { + const res = await request.post(`${BASE}/auth/login`, { data: { email, password } }) + const parsed = await parseApiResponse(res) + if (!parsed.ok || !parsed.body?.token) + throw new Error(`Login failed (${parsed.status}): ${parsed.text}`) + return parsed.body } -export interface MainNodeMessage { - id: number - role: 'user' | 'assistant' - content: string - provider: string | null - model: string | null - createdAt: string +export async function unauthenticatedGet(request: APIRequestContext, routePath: string): Promise { + const res = await request.get(`${BASE}${routePath}`) + return parseApiResponse(res) } -// --------------------------------------------------------------------------- -// Auth -// --------------------------------------------------------------------------- - -export async function login( +export async function unauthenticatedPost( request: APIRequestContext, - email: string, - password: string, -): Promise { - const res = await request.post(`${BASE}/auth/login`, { - data: { email, password }, - }) - if (!res.ok()) { - const body = await res.text() - throw new Error(`Login failed (${res.status()}): ${body}`) - } - return res.json() + routePath: string, + data?: Record, +): Promise { + const res = await request.post(`${BASE}${routePath}`, { data }) + return parseApiResponse(res) } -// --------------------------------------------------------------------------- -// Canvas CRUD -// --------------------------------------------------------------------------- - -export async function getCanvases( - request: APIRequestContext, - token: string, -): Promise { - const res = await request.get(`${BASE}/canvases`, { - headers: { Authorization: `Bearer ${token}` }, - }) - return res.json() +export async function getProviders(request: APIRequestContext, token: string): Promise> { + const res = await request.get(`${BASE}/llm/providers`, { headers: authHeaders(token) }) + return parseApiResponse(res) } -export async function createCanvas( +export async function upsertProvider( request: APIRequestContext, token: string, - firstPrompt: string, -): Promise<{ status: number; body: CanvasSummary }> { - const res = await request.post(`${BASE}/canvases`, { - headers: { Authorization: `Bearer ${token}` }, - data: { firstPrompt }, + provider: string, + apiKey: string, +): Promise> { + const res = await request.put(`${BASE}/llm/providers/${provider}`, { + headers: authHeaders(token), + data: { apiKey }, }) - return { status: res.status(), body: await res.json() } + return parseApiResponse(res) } -export async function renameCanvas( - request: APIRequestContext, - token: string, - canvasId: number, - title: string, -): Promise<{ status: number; body: CanvasSummary }> { - const res = await request.patch(`${BASE}/canvases/${canvasId}`, { - headers: { Authorization: `Bearer ${token}` }, - data: { title }, - }) - return { status: res.status(), body: await res.json() } +export async function clearProviderKeys(request: APIRequestContext, token: string): Promise { + for (const provider of ['openai', 'claude', 'gemini', 'lmstudio']) { + await request.delete(`${BASE}/llm/providers/${provider}`, { headers: authHeaders(token) }) + } } -export async function deleteCanvas( +export async function createChatSession( request: APIRequestContext, token: string, - canvasId: number, -): Promise { - const res = await request.delete(`${BASE}/canvases/${canvasId}`, { - headers: { Authorization: `Bearer ${token}` }, + payload: { title?: string; content?: string; providerOverride?: string; modelOverride?: string } = {}, +): Promise> { + const res = await request.post(`${BASE}/chat-sessions`, { + headers: authHeaders(token), + data: payload, }) - return res.status() + return parseApiResponse(res) } -export async function getCanvasGraph( - request: APIRequestContext, - token: string, - canvasId: number, -): Promise<{ status: number; body: CanvasGraphResponse }> { - const res = await request.get(`${BASE}/canvases/${canvasId}/graph`, { - headers: { Authorization: `Bearer ${token}` }, - }) - return { status: res.status(), body: await res.json() } +export async function getChatSessions(request: APIRequestContext, token: string): Promise>> { + const res = await request.get(`${BASE}/chat-sessions`, { headers: authHeaders(token) }) + return parseApiResponse(res) } -export async function expandCanvasNode( - request: APIRequestContext, - token: string, - canvasId: number, - nodeId: number, - prompt: string, - providerOverride?: string, -): Promise<{ status: number; body: any }> { - const res = await request.post( - `${BASE}/canvases/${canvasId}/nodes/${nodeId}/expand`, - { - headers: { Authorization: `Bearer ${token}` }, - data: { prompt, providerOverride }, - }, - ) - return { status: res.status(), body: await res.json() } +export async function getChatSession(request: APIRequestContext, token: string, sessionId: number): Promise }>> { + const res = await request.get(`${BASE}/chat-sessions/${sessionId}`, { headers: authHeaders(token) }) + return parseApiResponse(res) } -// --------------------------------------------------------------------------- -// LLM providers -// --------------------------------------------------------------------------- - -export async function getProviders( +export async function getChatNode( request: APIRequestContext, token: string, -): Promise<{ status: number; body: ProviderListResponse }> { - const res = await request.get(`${BASE}/llm/providers`, { - headers: { Authorization: `Bearer ${token}` }, - }) - return { status: res.status(), body: await res.json() } -} - -export async function upsertProvider( - request: APIRequestContext, - token: string, - provider: string, - apiKey: string, -): Promise<{ status: number; body: ProviderListResponse }> { - const res = await request.put(`${BASE}/llm/providers/${provider}`, { - headers: { Authorization: `Bearer ${token}` }, - data: { apiKey }, - }) - return { status: res.status(), body: await res.json() } -} - -export async function deleteProvider( - request: APIRequestContext, - token: string, - provider: string, -): Promise<{ status: number; body: ProviderListResponse }> { - const res = await request.delete(`${BASE}/llm/providers/${provider}`, { - headers: { Authorization: `Bearer ${token}` }, - }) - return { status: res.status(), body: await res.json() } + sessionId: number, + nodeId: number, +): Promise> { + const res = await request.get(`${BASE}/chat-sessions/${sessionId}/nodes/${nodeId}`, { headers: authHeaders(token) }) + return parseApiResponse(res) } -export async function setDefaultProvider( +export async function createNode( request: APIRequestContext, token: string, - provider: string, -): Promise<{ status: number; body: any }> { - const res = await request.put(`${BASE}/llm/providers/default`, { - headers: { Authorization: `Bearer ${token}` }, - data: { provider }, + sessionId: number, + payload: { parentNodeId: number; nodeType: 'question' | 'response' | 'note'; title?: string; content?: string }, +): Promise> { + const res = await request.post(`${BASE}/chat-sessions/${sessionId}/nodes`, { + headers: authHeaders(token), + data: payload, }) - return { status: res.status(), body: await res.json() } + return parseApiResponse(res) } -export async function testProvider( +export async function sendNodeMessage( request: APIRequestContext, token: string, - provider: string, -): Promise<{ status: number; body: any }> { - const res = await request.post(`${BASE}/llm/providers/${provider}/test`, { - headers: { Authorization: `Bearer ${token}` }, + sessionId: number, + nodeId: number, + content: string, +): Promise> { + const res = await request.post(`${BASE}/chat-sessions/${sessionId}/nodes/${nodeId}/messages`, { + headers: authHeaders(token), + data: { content }, }) - return { status: res.status(), body: await res.json() } + return parseApiResponse(res) } -// --------------------------------------------------------------------------- -// Main-node chat -// --------------------------------------------------------------------------- - -export async function getMainNodeChat( +export async function createBranch( request: APIRequestContext, token: string, - canvasId: number, -): Promise<{ status: number; body: MainNodeChatResponse }> { - const res = await request.get(`${BASE}/llm/chat/main-node`, { - headers: { Authorization: `Bearer ${token}` }, - params: { canvasId: String(canvasId) }, + sessionId: number, + nodeId: number, + branchFromMessageId: number, + content: string, +): Promise> { + const res = await request.post(`${BASE}/chat-sessions/${sessionId}/nodes/${nodeId}/branches`, { + headers: authHeaders(token), + data: { branchFromMessageId, content }, }) - return { status: res.status(), body: await res.json() } + return parseApiResponse(res) } -export async function sendMainNodeMessage( +export async function generateNote( request: APIRequestContext, token: string, - canvasId: number, - content: string, - providerOverride?: string, -): Promise<{ status: number; body: MainNodeChatResponse }> { - const res = await request.post(`${BASE}/llm/chat/main-node/messages`, { - headers: { Authorization: `Bearer ${token}` }, - data: { canvasId, content, providerOverride }, + sessionId: number, + nodeId: number, +): Promise> { + const res = await request.post(`${BASE}/chat-sessions/${sessionId}/nodes/${nodeId}/notes`, { + headers: authHeaders(token), + data: {}, }) - return { status: res.status(), body: await res.json() } -} - -// --------------------------------------------------------------------------- -// Utility: unauthenticated request helper -// --------------------------------------------------------------------------- - -export async function unauthenticatedGet( - request: APIRequestContext, - path: string, -): Promise { - const res = await request.get(`${BASE}${path}`) - return res.status() -} - -export async function unauthenticatedPost( - request: APIRequestContext, - path: string, - data?: Record, -): Promise { - const res = await request.post(`${BASE}${path}`, { data }) - return res.status() + return parseApiResponse(res) } diff --git a/tests/playwright/tests/helpers/auth-cache.ts b/tests/playwright/tests/helpers/auth-cache.ts new file mode 100644 index 0000000..19f6611 --- /dev/null +++ b/tests/playwright/tests/helpers/auth-cache.ts @@ -0,0 +1,9 @@ +import * as fs from 'fs' +import * as path from 'path' +import type { AuthCache } from './api' + +const TOKEN_CACHE = path.resolve(__dirname, '../../.auth-cache.json') + +export function readAuthCache(): AuthCache { + return JSON.parse(fs.readFileSync(TOKEN_CACHE, 'utf-8')) as AuthCache +} diff --git a/tests/playwright/tests/helpers/global-setup.ts b/tests/playwright/tests/helpers/global-setup.ts index 0466c13..ed15124 100644 --- a/tests/playwright/tests/helpers/global-setup.ts +++ b/tests/playwright/tests/helpers/global-setup.ts @@ -1,56 +1,45 @@ -/** - * tests/helpers/global-setup.ts - * Logs in both test users once before the entire test suite runs - * and caches tokens to a temp file — avoids hammering the login - * rate limit by logging in 3 times in rapid beforeAll blocks. - */ - import * as dotenv from 'dotenv' import * as path from 'path' import * as fs from 'fs' import { request as playwrightRequest } from '@playwright/test' +import { type AuthCache, login } from './api' dotenv.config({ path: path.resolve(__dirname, '../../.env') }) -const BASE = process.env.BASE_API_URL ?? 'http://localhost:8080/api' const TOKEN_CACHE = path.resolve(__dirname, '../../.auth-cache.json') export default async function globalSetup() { const context = await playwrightRequest.newContext() - const email1 = process.env.TEST_USER_EMAIL! - const password1 = process.env.TEST_USER_PASSWORD! - const email2 = process.env.TEST_USER2_EMAIL! - const password2 = process.env.TEST_USER2_PASSWORD! + const email1 = process.env.TEST_USER_EMAIL + const password1 = process.env.TEST_USER_PASSWORD + if (!email1 || !password1) + throw new Error('Global setup: TEST_USER_EMAIL and TEST_USER_PASSWORD are required.') - console.log('\n🔐 Global setup: logging in test users...') + const email2 = process.env.TEST_USER2_EMAIL + const password2 = process.env.TEST_USER2_PASSWORD + const hasSecondUser = Boolean(email2 && password2) - const res1 = await context.post(`${BASE}/auth/login`, { - data: { email: email1, password: password1 }, - }) - if (!res1.ok()) { - const body = await res1.text() - throw new Error(`Global setup: login failed for user1 (${res1.status()}): ${body}`) - } - const auth1 = await res1.json() + console.log('\nGlobal setup: authenticating test users...') - // Small delay to avoid rate limit - await new Promise(r => setTimeout(r, 1000)) + const auth1 = await login(context, email1, password1) - const res2 = await context.post(`${BASE}/auth/login`, { - data: { email: email2, password: password2 }, - }) - if (!res2.ok()) { - const body = await res2.text() - throw new Error(`Global setup: login failed for user2 (${res2.status()}): ${body}`) + let auth2 = auth1 + if (hasSecondUser) { + // Small delay to avoid backend auth throttling. + await new Promise(r => setTimeout(r, 500)) + auth2 = await login(context, email2!, password2!) } - const auth2 = await res2.json() - fs.writeFileSync(TOKEN_CACHE, JSON.stringify({ + const cache: AuthCache = { token1: auth1.token, + refreshToken1: auth1.refreshToken, token2: auth2.token, - })) + refreshToken2: auth2.refreshToken, + hasDistinctSecondUser: hasSecondUser, + } - console.log('✅ Both users authenticated, tokens cached.\n') + fs.writeFileSync(TOKEN_CACHE, JSON.stringify(cache, null, 2)) + console.log(`Global setup: tokens cached. second user configured=${hasSecondUser}`) await context.dispose() } diff --git a/tests/playwright/tests/helpers/ui-auth.ts b/tests/playwright/tests/helpers/ui-auth.ts new file mode 100644 index 0000000..5307a4e --- /dev/null +++ b/tests/playwright/tests/helpers/ui-auth.ts @@ -0,0 +1,17 @@ +import type { Page } from '@playwright/test' +import { readAuthCache } from './auth-cache' + +export async function loginViaSessionStorage(page: Page, pagePath = '/'): Promise { + const auth = readAuthCache() + await page.goto('/login') + await page.evaluate(({ token, refreshToken }) => { + sessionStorage.setItem('token', token) + sessionStorage.setItem('refreshToken', refreshToken) + sessionStorage.setItem('isEmailVerified', 'true') + }, { + token: auth.token1, + refreshToken: auth.refreshToken1, + }) + await page.goto(pagePath) + await page.waitForLoadState('networkidle') +} diff --git a/tests/playwright/tests/ui/homepage.spec.ts b/tests/playwright/tests/ui/homepage.spec.ts deleted file mode 100644 index 9da1580..0000000 --- a/tests/playwright/tests/ui/homepage.spec.ts +++ /dev/null @@ -1,327 +0,0 @@ -/** - * tests/ui/homepage.spec.ts - */ - -import { test, expect, type Page } from '@playwright/test' -import * as fs from 'fs' -import * as path from 'path' -import * as dotenv from 'dotenv' -import { createCanvas, deleteCanvas, deleteProvider } from '../helpers/api' - -dotenv.config({ path: path.resolve(__dirname, '../../.env') }) - -const UI_URL = process.env.BASE_UI_URL ?? 'http://localhost:3000' -const BASE_API = process.env.BASE_API_URL ?? 'http://localhost:8080/api' -const TOKEN_CACHE = path.resolve(__dirname, '../../.auth-cache.json') - -let token1: string -let refreshToken1: string - -test.beforeAll(() => { - const cache = JSON.parse(fs.readFileSync(TOKEN_CACHE, 'utf-8')) - token1 = cache.token1 - refreshToken1 = cache.refreshToken1 -}) - -async function injectAuthAndGo(page: Page, pagePath: string = '/home') { - await page.goto(`${UI_URL}/login`) - await page.evaluate( - ({ token, refreshToken }) => { - sessionStorage.setItem('token', token) - sessionStorage.setItem('refreshToken', refreshToken) - sessionStorage.setItem('isEmailVerified', 'true') - }, - { token: token1, refreshToken: refreshToken1 } - ) - await page.goto(`${UI_URL}/home`) - await page.waitForLoadState('networkidle') - if (pagePath !== '/home') { - await page.goto(`${UI_URL}${pagePath}`) - await page.waitForLoadState('networkidle') - } -} - -// FIX: Use :not([aria-current]) to exclude the canvas button itself. -// Previously, /^Rename / and /^Delete / matched both the canvas button -// (aria-label="Rename me canvas. Updated just now...") and the action -// button (aria-label="Rename Rename me canvas"), causing a strict mode -// violation. Scoping to button.btn-pill-outline:not([aria-current]) -// targets only the dedicated action button. - -function renameButtonForSelected(page: Page) { - return page - .locator('li') - .filter({ has: page.locator('button[aria-current="true"]') }) - .locator('button.btn-pill-outline[aria-label^="Rename "]:not([aria-current])') -} - -function deleteButtonForSelected(page: Page) { - return page - .locator('li') - .filter({ has: page.locator('button[aria-current="true"]') }) - .locator('button.btn-pill-outline[aria-label^="Delete "]:not([aria-current])') -} - -// ─── Canvas creation ────────────────────────────────────────────────────────── - -test('Create canvas modal — empty prompt shows inline error, not network call', async ({ page }) => { - await injectAuthAndGo(page) - - await page.click('button[aria-label="Create a new canvas"]') - await expect(page.getByRole('dialog', { name: 'Create canvas' })).toBeVisible() - - await page.click('button[type="submit"]:has-text("Create Canvas")') - - await expect(page.getByRole('alert')).toContainText('Add the first prompt') - await expect(page.getByRole('dialog', { name: 'Create canvas' })).toBeVisible() -}) - -test('Create canvas — valid prompt creates canvas and selects it', async ({ page }) => { - await injectAuthAndGo(page) - - await page.click('button[aria-label="Create a new canvas"]') - await expect(page.getByRole('dialog', { name: 'Create canvas' })).toBeVisible() - - await page.getByRole('dialog').locator('textarea').fill('Sprint planning for Q3 2025') - await page.click('button[type="submit"]:has-text("Create Canvas")') - - await expect(page.getByRole('dialog', { name: 'Create canvas' })).not.toBeVisible({ timeout: 10_000 }) - await expect(page.locator('[data-testid="desktop-sidebar"]')).toContainText('Sprint planning for Q3 2025') - await expect(page).toHaveURL(/canvasId=\d+/) -}) - -test('Create canvas — button is disabled while creating', async ({ page }) => { - await injectAuthAndGo(page) - - await page.click('button[aria-label="Create a new canvas"]') - await expect(page.getByRole('dialog', { name: 'Create canvas' })).toBeVisible() - await page.getByRole('dialog').locator('textarea').fill('Loading state test canvas') - - await page.route(`${BASE_API}/canvases`, async route => { - await new Promise(r => setTimeout(r, 1000)) - await route.continue() - }) - - await page.click('button[type="submit"]:has-text("Create Canvas")') - await expect(page.locator('button:has-text("Creating…")')).toBeVisible({ timeout: 3_000 }) - await expect(page.locator('button:has-text("Creating…")')).toBeDisabled() -}) - -// ─── Canvas rename ──────────────────────────────────────────────────────────── - -test('Rename canvas — inline form appears, saves updated title', async ({ page, request }) => { - const { body: canvas } = await createCanvas(request, token1, 'Rename me canvas') - - // Navigate directly to this canvas — it becomes aria-current="true" in the sidebar - await injectAuthAndGo(page, `/home?canvasId=${canvas.id}`) - await expect(page.locator('[data-testid="desktop-sidebar"]')).toContainText('Rename me canvas', { timeout: 10_000 }) - - await renameButtonForSelected(page).click() - - const input = page.locator('input[id^="rename-canvas-"]') - await expect(input).toBeVisible() - await expect(input).toBeFocused() - - await input.fill('Newly renamed canvas') - await page.getByRole('button', { name: 'Save', exact: true }).click() - - await expect(page.locator('[data-testid="desktop-sidebar"]')).toContainText('Newly renamed canvas', { timeout: 10_000 }) - - await deleteCanvas(request, token1, canvas.id) -}) - -test('Rename canvas — blank title shows inline error', async ({ page, request }) => { - const { body: canvas } = await createCanvas(request, token1, 'Blank rename test') - - await injectAuthAndGo(page, `/home?canvasId=${canvas.id}`) - await expect(page.locator('[data-testid="desktop-sidebar"]')).toContainText('Blank rename test', { timeout: 10_000 }) - - await renameButtonForSelected(page).click() - - const input = page.locator('input[id^="rename-canvas-"]') - await input.fill(' ') - await page.getByRole('button', { name: 'Save', exact: true }).click() - - await expect(page.getByRole('alert')).toContainText('Add a canvas title before saving') - - await deleteCanvas(request, token1, canvas.id) -}) - -test('Rename canvas — Escape key cancels rename', async ({ page, request }) => { - const { body: canvas } = await createCanvas(request, token1, 'Escape cancel test') - - await injectAuthAndGo(page, `/home?canvasId=${canvas.id}`) - await expect(page.locator('[data-testid="desktop-sidebar"]')).toContainText('Escape cancel test', { timeout: 10_000 }) - - await renameButtonForSelected(page).click() - - const input = page.locator('input[id^="rename-canvas-"]') - await input.fill('Something new') - await input.press('Escape') - - await expect(input).not.toBeVisible() - await expect(page.locator('[data-testid="desktop-sidebar"]')).toContainText('Escape cancel test') - - await deleteCanvas(request, token1, canvas.id) -}) - -// ─── Canvas delete ──────────────────────────────────────────────────────────── - -test('Delete canvas — confirm modal deletes canvas and removes from list', async ({ page, request }) => { - // FIX: Use a timestamp-unique title so not.toContainText doesn't false-fail - // due to leftover canvases with the same name from previous test runs. - const title = `Delete this canvas ${Date.now()}` - const { body: canvas } = await createCanvas(request, token1, title) - - await injectAuthAndGo(page, `/home?canvasId=${canvas.id}`) - await expect(page.locator('[data-testid="desktop-sidebar"]')).toContainText(title, { timeout: 10_000 }) - - await deleteButtonForSelected(page).click() - - await expect(page.getByRole('dialog', { name: 'Delete canvas' })).toBeVisible() - await expect(page.getByRole('dialog')).toContainText(`"${title}"`) - - await page.getByRole('dialog').getByRole('button', { name: 'Delete Canvas' }).click() - - await expect(page.getByRole('dialog', { name: 'Delete canvas' })).not.toBeVisible() - // Assert by unique title — guaranteed no other canvas shares this name - await expect(page.locator('[data-testid="desktop-sidebar"]')).not.toContainText(title, { timeout: 10_000 }) -}) - -test('Delete canvas — Cancel button closes modal without deleting', async ({ page, request }) => { - const { body: canvas } = await createCanvas(request, token1, 'Do not delete me') - - await injectAuthAndGo(page, `/home?canvasId=${canvas.id}`) - await expect(page.locator('[data-testid="desktop-sidebar"]')).toContainText('Do not delete me', { timeout: 10_000 }) - - await deleteButtonForSelected(page).click() - await expect(page.getByRole('dialog', { name: 'Delete canvas' })).toBeVisible() - - await page.getByRole('dialog').getByRole('button', { name: 'Cancel', exact: true }).click() - await expect(page.getByRole('dialog', { name: 'Delete canvas' })).not.toBeVisible() - await expect(page.locator('[data-testid="desktop-sidebar"]')).toContainText('Do not delete me') - - await deleteCanvas(request, token1, canvas.id) -}) - -test('Delete selected canvas — next canvas in list gets auto-selected', async ({ page, request }) => { - const { body: c1 } = await createCanvas(request, token1, 'Keep me auto') - await new Promise(r => setTimeout(r, 50)) - const { body: c2 } = await createCanvas(request, token1, 'Delete me auto') - - await injectAuthAndGo(page, `/home?canvasId=${c2.id}`) - await expect(page.locator('[data-testid="desktop-sidebar"]')).toContainText('Delete me auto', { timeout: 10_000 }) - - await deleteButtonForSelected(page).click() - await page.getByRole('dialog').getByRole('button', { name: 'Delete Canvas' }).click() - - await expect(page).toHaveURL(new RegExp(`canvasId=${c1.id}`), { timeout: 10_000 }) - - await deleteCanvas(request, token1, c1.id) -}) - -// ─── Canvas selection and URL sync ──────────────────────────────────────────── - -test('canvasId in URL loads correct canvas on page load', async ({ page, request }) => { - const { body: canvas } = await createCanvas(request, token1, 'URL load test canvas') - - await injectAuthAndGo(page, `/home?canvasId=${canvas.id}`) - - await expect(page.locator('h2').filter({ hasText: 'URL load test canvas' })).toBeVisible({ timeout: 10_000 }) - - await deleteCanvas(request, token1, canvas.id) -}) - -test('Invalid canvasId in URL falls back to first canvas in list', async ({ page, request }) => { - const { body: canvas } = await createCanvas(request, token1, 'Fallback canvas') - - await injectAuthAndGo(page, '/home?canvasId=999999999') - - await expect(page.locator('[data-testid="desktop-sidebar"]')).toBeVisible({ timeout: 10_000 }) - - await deleteCanvas(request, token1, canvas.id) -}) - -// ─── Graph mode ──────────────────────────────────────────────────────────────── - -test('New canvas shows graph mode UI with main node card', async ({ page, request }) => { - const { body: canvas } = await createCanvas(request, token1, 'Graph UI test') - - await injectAuthAndGo(page, `/home?canvasId=${canvas.id}`) - - await expect(page.locator('text=Canvas mode: Node graph')).toBeVisible({ timeout: 10_000 }) - await expect(page.locator('.canvas-node-card')).toBeVisible() - await expect(page.locator('.canvas-node-card--main')).toBeVisible() - - await deleteCanvas(request, token1, canvas.id) -}) - -test('No providers → graph node shows settings CTA', async ({ page, request }) => { - await deleteProvider(request, token1, 'openai') - await deleteProvider(request, token1, 'claude') - await deleteProvider(request, token1, 'gemini') - - const { body: canvas } = await createCanvas(request, token1, 'No provider graph test') - - await injectAuthAndGo(page, `/home?canvasId=${canvas.id}`) - - await expect(page.getByRole('button', { name: 'Go To Settings' })).toBeVisible({ timeout: 10_000 }) - - await deleteCanvas(request, token1, canvas.id) -}) - -// ─── Sidebar collapse / expand ──────────────────────────────────────────────── - -test('Sidebar collapse button hides titles and shows initials', async ({ page, request }) => { - const { body: canvas } = await createCanvas(request, token1, 'Sidebar collapse test') - - await injectAuthAndGo(page, `/home?canvasId=${canvas.id}`) - await expect(page.locator('[data-testid="desktop-sidebar"]')).toBeVisible({ timeout: 10_000 }) - - await page.click('button[aria-label="Collapse canvas sidebar"]') - - const sidebar = page.locator('[data-testid="desktop-sidebar"]') - await expect(sidebar.locator('.canvas-sidebar-canvas-initial').first()).toBeVisible() - await expect(sidebar.locator('.canvas-sidebar-canvas-title').first()).not.toBeVisible() - - await page.click('button[aria-label="Expand canvas sidebar"]') - await expect(sidebar.locator('.canvas-sidebar-canvas-title').first()).toBeVisible() - - await deleteCanvas(request, token1, canvas.id) -}) - -// ─── Mobile drawer ──────────────────────────────────────────────────────────── - -test('Mobile: hamburger opens drawer, backdrop click closes it', async ({ page, request }) => { - await page.setViewportSize({ width: 390, height: 844 }) - - const { body: canvas } = await createCanvas(request, token1, 'Mobile drawer test') - - await injectAuthAndGo(page, `/home?canvasId=${canvas.id}`) - await expect(page.locator('[data-testid="desktop-sidebar"]')).toBeAttached({ timeout: 10_000 }) - - await page.click('button[aria-label="Open canvas sidebar"]') - await expect(page.locator('[data-testid="mobile-drawer"]')).toHaveAttribute('role', 'dialog') - - await page.locator('[data-testid="mobile-drawer-backdrop"]').click() - await expect(page.locator('[data-testid="mobile-drawer"]')).not.toHaveAttribute('role', 'dialog') - - await deleteCanvas(request, token1, canvas.id) -}) - -test('Mobile: Escape key closes the drawer', async ({ page, request }) => { - await page.setViewportSize({ width: 390, height: 844 }) - - const { body: canvas } = await createCanvas(request, token1, 'Escape drawer test') - - await injectAuthAndGo(page, `/home?canvasId=${canvas.id}`) - await expect(page.locator('[data-testid="desktop-sidebar"]')).toBeAttached({ timeout: 10_000 }) - - await page.click('button[aria-label="Open canvas sidebar"]') - await expect(page.locator('[data-testid="mobile-drawer"]')).toHaveAttribute('role', 'dialog') - - await page.keyboard.press('Escape') - await expect(page.locator('[data-testid="mobile-drawer"]')).not.toHaveAttribute('role', 'dialog') - - await deleteCanvas(request, token1, canvas.id) -}) diff --git a/tests/playwright/tests/ui/settings.spec.ts b/tests/playwright/tests/ui/settings.spec.ts deleted file mode 100644 index 321a587..0000000 --- a/tests/playwright/tests/ui/settings.spec.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * tests/ui/settings.spec.ts - */ - -import { test, expect, type Page } from '@playwright/test' -import * as fs from 'fs' -import * as path from 'path' -import * as dotenv from 'dotenv' -import { deleteProvider } from '../helpers/api' - -dotenv.config({ path: path.resolve(__dirname, '../../.env') }) - -const UI_URL = process.env.BASE_UI_URL ?? 'http://localhost:3000' -const BASE_API = process.env.BASE_API_URL ?? 'http://localhost:8080/api' -const TOKEN_CACHE = path.resolve(__dirname, '../../.auth-cache.json') -const OPENAI_KEY = process.env.TEST_OPENAI_KEY - -let token1: string -let refreshToken1: string - -test.beforeAll(() => { - const cache = JSON.parse(fs.readFileSync(TOKEN_CACHE, 'utf-8')) - token1 = cache.token1 - refreshToken1 = cache.refreshToken1 -}) - -async function injectAuthAndGoSettings(page: Page) { - await page.goto(`${UI_URL}/login`) - await page.evaluate( - ({ token, refreshToken }) => { - sessionStorage.setItem('token', token) - sessionStorage.setItem('refreshToken', refreshToken) - sessionStorage.setItem('isEmailVerified', 'true') - }, - { token: token1, refreshToken: refreshToken1 } - ) - - await page.goto(`${UI_URL}/home`) - await page.waitForURL(/\/home(\?canvasId=\d+)?/, { timeout: 15_000 }) - await expect(page.locator('[data-testid="desktop-sidebar"]')).toBeVisible({ timeout: 15_000 }) - - const manageBtn = page.getByRole('button', { name: 'Manage Providers' }) - await expect(manageBtn).toBeVisible({ timeout: 10_000 }) - await manageBtn.click() - await page.waitForURL(`**/settings`, { timeout: 10_000 }) -} - -async function waitForSettingsLoaded(page: Page) { - await expect(page.getByRole('heading', { name: 'Account Settings' })).toBeVisible({ timeout: 10_000 }) -} - -// FIX: Scope each provider card by its unique API key input placeholder. -// The previous div.filter() matched the entire page (all 3 cards share the -// same buttons/inputs), causing strict mode violations on every interaction. -// Each provider has a distinct placeholder: sk-... / sk-ant-... / AIza... -// so we find the input first, then walk up to its containing card via locator. -const PROVIDER_PLACEHOLDER: Record = { - OpenAI: 'sk-...', - Claude: 'sk-ant-...', - Gemini: 'AIza...', -} - -function providerCard(page: Page, providerLabel: string) { - const placeholder = PROVIDER_PLACEHOLDER[providerLabel] - // Find the password input with this provider's placeholder, then scope to - // the nearest ancestor div that also contains the provider name text. - // This gives us a tight single-card container. - return page.locator('div').filter({ - has: page.locator(`input[placeholder="${placeholder}"]`), - hasText: providerLabel, - }).last() -} - -// ─── Provider key save ──────────────────────────────────────────────────────── - -test('Settings — add OpenAI key shows masked last 4 after save', async ({ page, request }) => { - await deleteProvider(request, token1, 'openai') - - await injectAuthAndGoSettings(page) - await waitForSettingsLoaded(page) - - const card = providerCard(page, 'OpenAI') - await card.locator('input[type="password"]').fill('sk-testkey1234') - await card.getByRole('button', { name: /Save Key|Update Key/ }).click() - - await expect(card.getByText('****1234')).toBeVisible({ timeout: 10_000 }) - - await deleteProvider(request, token1, 'openai') -}) - -test('Settings — empty key shows error, does not save', async ({ page }) => { - await injectAuthAndGoSettings(page) - await waitForSettingsLoaded(page) - - const card = providerCard(page, 'OpenAI') - await card.getByRole('button', { name: /Save Key|Update Key/ }).click() - - await expect(page.getByText('Enter an API key before saving.')).toBeVisible({ timeout: 5_000 }) -}) - -test('Settings — remove provider key removes masked display', async ({ page, request }) => { - await request.put(`${BASE_API}/llm/providers/openai`, { - headers: { Authorization: `Bearer ${token1}` }, - data: { apiKey: 'sk-testremoveme1234' }, - }) - - await injectAuthAndGoSettings(page) - await waitForSettingsLoaded(page) - - const card = providerCard(page, 'OpenAI') - await expect(card.getByText('****1234')).toBeVisible({ timeout: 10_000 }) - - await card.getByRole('button', { name: 'Remove' }).click() - - await expect(card.getByText('None')).toBeVisible({ timeout: 10_000 }) -}) - -test('Settings — set default provider via radio button', async ({ page, request }) => { - await request.put(`${BASE_API}/llm/providers/openai`, { - headers: { Authorization: `Bearer ${token1}` }, - data: { apiKey: 'sk-test1' }, - }) - await request.put(`${BASE_API}/llm/providers/claude`, { - headers: { Authorization: `Bearer ${token1}` }, - data: { apiKey: 'sk-ant-test2' }, - }) - - await injectAuthAndGoSettings(page) - await waitForSettingsLoaded(page) - - const claudeCard = providerCard(page, 'Claude') - await claudeCard.locator('input[type="radio"]').click() - - await expect(claudeCard.getByText('Default')).toBeVisible({ timeout: 10_000 }) - - await deleteProvider(request, token1, 'openai') - await deleteProvider(request, token1, 'claude') -}) - -test.describe('Test provider with real key', () => { - test.skip(!OPENAI_KEY, 'TEST_OPENAI_KEY not set') - - test('Settings — test provider button shows last tested timestamp', async ({ page, request }) => { - await request.put(`${BASE_API}/llm/providers/openai`, { - headers: { Authorization: `Bearer ${token1}` }, - data: { apiKey: OPENAI_KEY }, - }) - - await injectAuthAndGoSettings(page) - await waitForSettingsLoaded(page) - - const card = providerCard(page, 'OpenAI') - await card.getByRole('button', { name: 'Test Key' }).click() - - await expect(card.getByText(/Last tested:(?!.*Not tested)/)).toBeVisible({ timeout: 15_000 }) - - await deleteProvider(request, token1, 'openai') - }) -}) diff --git a/tests/playwright/tests/ui/sprint4-flows.spec.ts b/tests/playwright/tests/ui/sprint4-flows.spec.ts new file mode 100644 index 0000000..d79d7ec --- /dev/null +++ b/tests/playwright/tests/ui/sprint4-flows.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test' +import { loginViaSessionStorage } from '../helpers/ui-auth' + +test.describe('Sprint 4 UI journeys', () => { + test.beforeEach(async ({ isMobile }) => { + test.skip(isMobile, 'Mobile sprint flow coverage is currently limited.') + }) + + test('user can open workspace and create a new canvas', async ({ page }) => { + await loginViaSessionStorage(page, '/') + + await expect(page.getByText('Coliee AI').first()).toBeVisible() + const newCanvasButton = page.getByRole('button', { name: /New Canvas|Creating Canvas/ }) + await expect(newCanvasButton).toBeVisible() + await newCanvasButton.click() + + await expect(page.getByRole('button', { name: /New Canvas|Creating Canvas/ })).toBeVisible() + }) + + test('branch and note actions are available in node workspace', async ({ page }) => { + await loginViaSessionStorage(page, '/') + + await expect(page.locator('[title="Add question node"]').first()).toBeVisible({ timeout: 15000 }) + await expect(page.locator('[title="Auto-generate note"]').first()).toBeVisible() + }) + + test('export options include PDF and text', async ({ page }) => { + await loginViaSessionStorage(page, '/') + + const exportTrigger = page.locator('[title="Node export options"]').first() + await expect(exportTrigger).toBeVisible({ timeout: 15000 }) + await exportTrigger.click() + + await expect(page.getByRole('button', { name: 'Export as PDF' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Export as Text' })).toBeVisible() + }) + + test('pdf export entry toggles node selection controls', async ({ page }) => { + await loginViaSessionStorage(page, '/') + + const exportTrigger = page.locator('[title="Node export options"]').first() + await expect(exportTrigger).toBeVisible({ timeout: 15000 }) + await exportTrigger.click() + await page.getByRole('button', { name: 'Export as PDF' }).click() + + await expect(page.getByText('Select node').first()).toBeVisible() + }) +}) From 44c06b454857c72803e175de1c8249c5c6088c80 Mon Sep 17 00:00:00 2001 From: Sunny Hasarinda Date: Mon, 20 Apr 2026 15:50:08 +0530 Subject: [PATCH 2/2] test [SCRUM-185]: improve headed UI preview flows Add a dedicated slow headed Playwright project and script so browser preview works on versions without CLI slow-mo, and extend Sprint 4 UI flows to visibly perform branching and note creation with stable interactions. Made-with: Cursor --- tests/playwright/README.md | 12 ++++++++ tests/playwright/package.json | 1 + tests/playwright/playwright.config.ts | 10 +++++++ .../playwright/tests/ui/sprint4-flows.spec.ts | 28 +++++++++++++++++-- 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/tests/playwright/README.md b/tests/playwright/README.md index fc29a86..d99fad9 100644 --- a/tests/playwright/README.md +++ b/tests/playwright/README.md @@ -34,6 +34,18 @@ npm run test:ui npm test ``` +## Watch In Browser + +```bash +# Normal headed run +npx playwright test tests/ui/sprint4-flows.spec.ts --headed --project=ui-chrome + +# Slow visual run (500ms delay per action) +npm run test:ui:slow -- tests/ui/sprint4-flows.spec.ts +``` + +`--slow-mo` may not be supported as a CLI flag in some Playwright versions, so slow mode is configured via the `ui-chrome-slow` project in `playwright.config.ts`. + ## Notes - The global setup logs in user 1 and user 2 once and stores tokens in `.auth-cache.json`. diff --git a/tests/playwright/package.json b/tests/playwright/package.json index c284a6e..973ae99 100644 --- a/tests/playwright/package.json +++ b/tests/playwright/package.json @@ -7,6 +7,7 @@ "test:api": "playwright test tests/api/", "test:ui": "playwright test tests/ui/", "test:headed": "playwright test --headed", + "test:ui:slow": "playwright test tests/ui/ --headed --project=ui-chrome-slow", "report": "playwright show-report" }, "devDependencies": { diff --git a/tests/playwright/playwright.config.ts b/tests/playwright/playwright.config.ts index 1eb60de..a945155 100644 --- a/tests/playwright/playwright.config.ts +++ b/tests/playwright/playwright.config.ts @@ -33,6 +33,16 @@ export default defineConfig({ testMatch: 'tests/ui/**/*.spec.ts', use: { ...devices['Desktop Chrome'] }, }, + { + name: 'ui-chrome-slow', + testMatch: 'tests/ui/**/*.spec.ts', + use: { + ...devices['Desktop Chrome'], + launchOptions: { + slowMo: 500, + }, + }, + }, { name: 'ui-mobile', testMatch: 'tests/ui/**/*.spec.ts', diff --git a/tests/playwright/tests/ui/sprint4-flows.spec.ts b/tests/playwright/tests/ui/sprint4-flows.spec.ts index d79d7ec..7e978d3 100644 --- a/tests/playwright/tests/ui/sprint4-flows.spec.ts +++ b/tests/playwright/tests/ui/sprint4-flows.spec.ts @@ -29,7 +29,7 @@ test.describe('Sprint 4 UI journeys', () => { const exportTrigger = page.locator('[title="Node export options"]').first() await expect(exportTrigger).toBeVisible({ timeout: 15000 }) - await exportTrigger.click() + await exportTrigger.click({ force: true }) await expect(page.getByRole('button', { name: 'Export as PDF' })).toBeVisible() await expect(page.getByRole('button', { name: 'Export as Text' })).toBeVisible() @@ -40,9 +40,33 @@ test.describe('Sprint 4 UI journeys', () => { const exportTrigger = page.locator('[title="Node export options"]').first() await expect(exportTrigger).toBeVisible({ timeout: 15000 }) - await exportTrigger.click() + await exportTrigger.click({ force: true }) await page.getByRole('button', { name: 'Export as PDF' }).click() await expect(page.getByText('Select node').first()).toBeVisible() }) + + test('branching and note creation interaction preview', async ({ page }) => { + await loginViaSessionStorage(page, '/') + + const chatInput = page.getByPlaceholder('Continue your thought...').first() + await expect(chatInput).toBeVisible({ timeout: 15000 }) + await chatInput.fill('Create a short idea so I can branch from it.') + await page.keyboard.press('Enter') + + // Give headed preview runs enough time to visually show response activity. + await page.waitForTimeout(2500) + + const branchButton = page.locator('[title="Branch"]').first() + if (await branchButton.count()) + await branchButton.click({ force: true }) + else + await page.locator('[title="Add question node"]').first().click({ force: true }) + + await page.waitForTimeout(1200) + + const createNoteButton = page.locator('[title="Add note node"]').first() + await expect(createNoteButton).toBeVisible() + await createNoteButton.click({ force: true }) + }) })