diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml new file mode 100644 index 00000000..e77046f7 --- /dev/null +++ b/.github/workflows/frontend-tests.yml @@ -0,0 +1,66 @@ +name: Frontend Tests + +# Runs on every PR to main (and pushes to main) that touch the frontend, so a +# broken change is caught here instead of when a user opens Word. Also keeps the +# manual trigger for ad-hoc runs. +on: + push: + branches: + - main + paths: + - 'frontend/**' + - '.github/workflows/frontend-tests.yml' + pull_request: + branches: + - main + paths: + - 'frontend/**' + - '.github/workflows/frontend-tests.yml' + workflow_dispatch: + +defaults: + run: + working-directory: frontend + +jobs: + unit: + name: Unit tests (Vitest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v6 + with: + node-version: lts/* + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Install dependencies + run: npm ci + - name: Run unit tests + run: npm test + + e2e: + name: E2E tests (Playwright) + timeout-minutes: 60 + runs-on: ubuntu-latest + # Pinned to match the installed @playwright/test version (1.60.0); bump both + # together so the committed visual snapshots keep matching. + container: + image: mcr.microsoft.com/playwright:v1.60.0-noble + options: --user 1001 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v6 + with: + node-version: lts/* + - name: Install dependencies + run: npm ci + - name: Build Frontend + run: npm run build + - name: Run Playwright tests + run: npx playwright test + - uses: actions/upload-artifact@v7 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: frontend/playwright-report/ + retention-days: 30 diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml deleted file mode 100644 index a72202ba..00000000 --- a/.github/workflows/playwright.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Playwright Visual Regression Tests -on: - workflow_dispatch: # Manual trigger only - -defaults: - run: - working-directory: frontend - -jobs: - test: - timeout-minutes: 60 - runs-on: ubuntu-latest - container: - image: mcr.microsoft.com/playwright:v1.60.0-noble - options: --user 1001 - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v6 - with: - node-version: lts/* - - name: Install dependencies - run: npm ci - - name: Build Frontend - run: npm run build - - name: Run Playwright tests - run: npx playwright test - - uses: actions/upload-artifact@v7 - if: ${{ !cancelled() }} - with: - name: playwright-report - path: frontend/playwright-report/ - retention-days: 30 - diff --git a/frontend/src/api/__tests__/wordEditorAPI.test.ts b/frontend/src/api/__tests__/wordEditorAPI.test.ts new file mode 100644 index 00000000..e9e704c6 --- /dev/null +++ b/frontend/src/api/__tests__/wordEditorAPI.test.ts @@ -0,0 +1,179 @@ +// @vitest-environment node +// +// Unit tests for the Office.js / Word integration layer (src/api/wordEditorAPI.ts). +// +// The add-in talks to Word through the `Office` and `Word` globals that Office.js +// injects at runtime. We can't run Word here, so we stub those globals and verify +// our own logic: how getDocContext assembles ranges and normalizes line endings, +// how selectPhrase searches and selects, and that the selection-change handlers +// register with the right event type. This catches the class of breakage that the +// browser-only E2E specs (which run the standalone editor, not Word) can't see. +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { wordEditorAPI } from '../wordEditorAPI'; + +// `Office` / `Word` are ambient runtime globals; tests assign fakes to them. +const g = globalThis as unknown as { Office?: unknown; Word?: unknown }; + +afterEach(() => { + delete g.Office; + delete g.Word; +}); + +/** + * Stub `Word.run` with a fake RequestContext for getDocContext. The selection's + * start/end ranges expand to the before/after ranges, each carrying `.text` + * exactly as Word would populate after `context.sync()`. + */ +function stubWordForDocContext(parts: { + before: string; + selected: string; + after: string; +}) { + const beforeRange = { text: parts.before }; + const afterRange = { text: parts.after }; + const selection = { + text: parts.selected, + getRange: vi.fn((loc: string) => ({ + expandTo: vi.fn(() => (loc === 'Start' ? beforeRange : afterRange)), + })), + }; + const context = { + document: { + body: { getRange: vi.fn(() => ({})) }, + getSelection: vi.fn(() => selection), + }, + load: vi.fn(), + sync: vi.fn().mockResolvedValue(undefined), + }; + g.Word = { + run: vi.fn((cb: (c: typeof context) => Promise) => + cb(context), + ), + }; +} + +/** Stub `Word.run` for selectPhrase with a search returning `itemCount` hits. */ +function stubWordForSearch(itemCount: number) { + const selectSpy = vi.fn(); + const items = Array.from({ length: itemCount }, () => ({ + select: selectSpy, + })); + const searchSpy = vi.fn(() => ({ items })); + const context = { + document: { body: { search: searchSpy } }, + load: vi.fn(), + sync: vi.fn().mockResolvedValue(undefined), + }; + g.Word = { + run: vi.fn((cb: (c: typeof context) => Promise) => + cb(context), + ), + }; + return { selectSpy, searchSpy }; +} + +/** Stub the `Office.context.document` selection-change handler surface. */ +function stubOffice() { + const addHandlerAsync = vi.fn(); + const removeHandlerAsync = vi.fn(); + g.Office = { + context: { document: { addHandlerAsync, removeHandlerAsync } }, + EventType: { DocumentSelectionChanged: 'documentSelectionChanged' }, + }; + return { addHandlerAsync, removeHandlerAsync }; +} + +describe('wordEditorAPI.getDocContext', () => { + it('returns the before/selected/after text read from the Word ranges', async () => { + stubWordForDocContext({ + before: 'Hello ', + selected: 'beautiful', + after: ' world', + }); + + const result = await wordEditorAPI.getDocContext(); + + expect(result).toEqual({ + beforeCursor: 'Hello ', + selectedText: 'beautiful', + afterCursor: ' world', + }); + }); + + it('normalizes carriage returns to newlines in all three fields', async () => { + stubWordForDocContext({ + before: 'First\rSecond ', + selected: 'mid\rdle', + after: ' end\rtail', + }); + + const result = await wordEditorAPI.getDocContext(); + + expect(result).toEqual({ + beforeCursor: 'First\nSecond ', + selectedText: 'mid\ndle', + afterCursor: ' end\ntail', + }); + }); + + it('rejects when Word.run fails', async () => { + g.Word = { + run: vi.fn(() => Promise.reject(new Error('Word boom'))), + }; + + await expect(wordEditorAPI.getDocContext()).rejects.toThrow('Word boom'); + }); +}); + +describe('wordEditorAPI.selectPhrase', () => { + it('selects the first match and passes Word search options', async () => { + const { selectSpy, searchSpy } = stubWordForSearch(2); + + await expect( + wordEditorAPI.selectPhrase('find me'), + ).resolves.toBeUndefined(); + + expect(searchSpy).toHaveBeenCalledWith('find me', { + ignorePunct: true, + ignoreSpace: true, + matchCase: false, + matchWildcards: false, + }); + expect(selectSpy).toHaveBeenCalledTimes(1); + }); + + it('throws "Phrase not found" when there are no matches', async () => { + const { selectSpy } = stubWordForSearch(0); + + await expect(wordEditorAPI.selectPhrase('missing')).rejects.toThrow( + 'Phrase not found', + ); + expect(selectSpy).not.toHaveBeenCalled(); + }); +}); + +describe('wordEditorAPI selection-change handlers', () => { + it('registers the handler for the DocumentSelectionChanged event', () => { + const { addHandlerAsync } = stubOffice(); + const handler = vi.fn(); + + wordEditorAPI.addSelectionChangeHandler(handler); + + expect(addHandlerAsync).toHaveBeenCalledWith( + 'documentSelectionChanged', + handler, + ); + }); + + it('removes the handler for the DocumentSelectionChanged event', () => { + const { removeHandlerAsync } = stubOffice(); + const handler = vi.fn(); + + wordEditorAPI.removeSelectionChangeHandler(handler); + + expect(removeHandlerAsync).toHaveBeenCalledWith( + 'documentSelectionChanged', + handler, + ); + }); +}); diff --git a/frontend/tests/chat-revise-flows.spec.ts b/frontend/tests/chat-revise-flows.spec.ts new file mode 100644 index 00000000..c2763675 --- /dev/null +++ b/frontend/tests/chat-revise-flows.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from '@playwright/test'; +import { setupMockBackend } from './mockBackend'; + +// Mock-backed E2E for the Chat and Revise pages on the standalone editor. +// These exercise a full request/response round trip against the mocked +// OpenAI-compatible endpoint (see mockBackend.ts), so they need no real backend. +test.beforeEach(async ({ page }) => { + await setupMockBackend(page); + await page.goto('/editor.html?page=demo'); + // Draft is the default page — wait for it to confirm the app has loaded. + await expect(page.locator('button[aria-label="Examples"]')).toBeVisible({ + timeout: 15000, + }); +}); + +test('Chat: sending a message shows the user message and the assistant reply', async ({ + page, +}) => { + await page.locator('button', { hasText: 'Chat' }).click(); + + const input = page.locator('textarea[placeholder*="Ask"]'); + // Use a message that is NOT one of the suggestion chips, so the assertion + // can't accidentally match the welcome screen. + await input.fill('Is the tone consistent?'); + await page.locator('button[title="Send message"]').click(); + + // The user's message is echoed into the conversation. + await expect(page.getByText('Is the tone consistent?')).toBeVisible(); + // The mocked assistant reply streams in. + await expect( + page.getByText('This is a mock assistant reply about your document.'), + ).toBeVisible({ timeout: 5000 }); +}); + +test('Revise: running a selected feature shows a result', async ({ page }) => { + // Type something so Revise isn't in its empty-document state. + const editor = page.locator('[contenteditable="true"]'); + await editor.click(); + await editor.pressSequentially('Some text to analyze'); + + await page.locator('button', { hasText: 'Revise' }).click(); + + // Select a feature, then run it via the sticky footer button. + await page.locator('button', { hasText: 'Hierarchical Outline' }).click(); + await page.locator('button', { hasText: /^Run / }).click(); + + // The mocked visualization result is rendered. + await expect( + page.getByText('A mock structural observation about your document.'), + ).toBeVisible({ timeout: 5000 }); +}); diff --git a/frontend/tests/demo-page-visual.spec.ts b/frontend/tests/demo-page-visual.spec.ts index f6d1c143..e9387ec2 100644 --- a/frontend/tests/demo-page-visual.spec.ts +++ b/frontend/tests/demo-page-visual.spec.ts @@ -9,11 +9,7 @@ test('demo page - visual regression', async ({ page }) => { await expect(page.getByRole('banner')).toContainText('Thoughtful'); - // Allow a small pixel budget so sub-visible rendering noise (font antialiasing, - // subpixel shifts) doesn't fail the test. A real layout regression moves far - // more than this many pixels. await expect(page).toHaveScreenshot('demo-page.png', { fullPage: true, - maxDiffPixels: 100, }); }); \ No newline at end of file diff --git a/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-chromium-linux.png b/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-chromium-linux.png index e50eff4e..419ad1ff 100644 Binary files a/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-chromium-linux.png and b/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-chromium-linux.png differ diff --git a/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-firefox-linux.png b/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-firefox-linux.png index 3637d885..3d4692d9 100644 Binary files a/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-firefox-linux.png and b/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-firefox-linux.png differ diff --git a/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-webkit-linux.png b/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-webkit-linux.png index daf9596c..3d4692d9 100644 Binary files a/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-webkit-linux.png and b/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-webkit-linux.png differ diff --git a/frontend/tests/editor.spec.ts b/frontend/tests/editor.spec.ts index 12e83a73..50162f23 100644 --- a/frontend/tests/editor.spec.ts +++ b/frontend/tests/editor.spec.ts @@ -41,6 +41,77 @@ test('can switch to Chat page and see message input', async ({ page }) => { await expect(page.locator('textarea[placeholder*="Ask"]')).toBeVisible(); }); +test('Rewording without a selection prompts the user to select text', async ({ page }) => { + // Rewording short-circuits before any backend call when no text is selected + // (draft/index.tsx), so this needs no mock backend. + await page.locator('button[aria-label="Rewording"]').click(); + await expect( + page.getByText('Please select some text to get rewording suggestions'), + ).toBeVisible(); +}); + +test('Revise shows an empty-document message when nothing is written', async ({ page }) => { + // No text was typed in beforeEach, so the document is empty. + await page.locator('button', { hasText: 'Revise' }).click(); + await expect(page.getByText('The document seems to be empty')).toBeVisible(); +}); + +test('Chat send button is disabled until the input has text', async ({ page }) => { + await page.locator('button', { hasText: 'Chat' }).click(); + + const input = page.locator('textarea[placeholder*="Ask"]'); + const sendButton = page.locator('button[title="Send message"]'); + + await expect(input).toBeVisible(); + await expect(sendButton).toBeDisabled(); + + await input.fill('What is my main argument?'); + await expect(sendButton).toBeEnabled(); + + // Clearing the input disables the button again. + await input.fill(''); + await expect(sendButton).toBeDisabled(); +}); + +test('word count updates as text is typed', async ({ page }) => { + // Demo mode shows a "Words: N" counter next to the editor. + await expect(page.getByText('Words: 0')).toBeVisible(); + + const editor = page.locator('[contenteditable="true"]'); + await editor.click(); + await editor.pressSequentially('one two three'); + + await expect(page.getByText('Words: 3')).toBeVisible(); +}); + +test('Chat welcome screen shows the suggestion chips', async ({ page }) => { + await page.locator('button', { hasText: 'Chat' }).click(); + + for (const prompt of [ + 'What is my main argument?', + 'How can I improve clarity?', + 'Is my structure logical?', + 'What am I missing?', + ]) { + await expect(page.getByRole('button', { name: prompt })).toBeVisible(); + } +}); + +test('Revise Run button enables only after a feature is selected', async ({ page }) => { + // Revise needs a non-empty document to show its feature list. + const editor = page.locator('[contenteditable="true"]'); + await editor.click(); + await editor.pressSequentially('Some text to analyze'); + + await page.locator('button', { hasText: 'Revise' }).click(); + + const runButton = page.getByRole('button', { name: /Run/ }); + await expect(runButton).toBeDisabled(); + + await page.locator('button', { hasText: 'Hierarchical Outline' }).click(); + await expect(runButton).toBeEnabled(); +}); + test('can navigate between all three tabs', async ({ page }) => { // Start on Draft (default) await expect(page.locator('button[aria-label="Examples"]')).toBeVisible(); diff --git a/frontend/tests/mockBackend.ts b/frontend/tests/mockBackend.ts index f89ee76a..62bb7703 100644 --- a/frontend/tests/mockBackend.ts +++ b/frontend/tests/mockBackend.ts @@ -16,10 +16,18 @@ const RESULTS = { '- First reader perspective\n\n- Second reader perspective\n\n- Third reader perspective', proposal_advice: '- First piece of advice\n\n- Second piece of advice\n\n- Third piece of advice', + example_rewording: + '- First rewording option\n\n- Second rewording option\n\n- Third rewording option', + // Revise (visualization): includes a doctext link like the real responses do. + revise: + '- A mock structural observation about your document.\n\n- [opening line](doctext:Some%20text%20to%20analyze) could be expanded.', + // Chat assistant reply. + chat: 'This is a mock assistant reply about your document.', }; -// gtype is no longer sent in the request; infer it from distinctive prompt text -// (see the prompts in src/api/prompts.ts). +// gtype is no longer sent in the request; infer it from distinctive prompt text. +// Draft prompts live in src/api/prompts.ts; Chat and Revise build their own +// system/user messages in their page components. function resultForMessages(messages: { content: string }[]): string { const text = messages.map((m) => m.content).join('\n'); if (text.includes('inspiring and fresh possible next sentences')) @@ -28,6 +36,13 @@ function resultForMessages(messages: { content: string }[]): string { return RESULTS.analysis_readerPerspective; if (text.includes('directive (but not prescriptive) advice')) return RESULTS.proposal_advice; + if (text.includes('three alternative rewordings')) + return RESULTS.example_rewording; + // Revise wraps the document in tags (revise/index.tsx). + if (text.includes('')) return RESULTS.revise; + // Chat is identified by its system prompt (chat/index.tsx). + if (text.includes('Encourage the user towards critical thinking')) + return RESULTS.chat; return ''; }