Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/workflows/frontend-tests.yml
Original file line number Diff line number Diff line change
@@ -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:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
Comment on lines +27 to +41
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

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
Comment on lines +42 to +66
33 changes: 0 additions & 33 deletions .github/workflows/playwright.yml

This file was deleted.

179 changes: 179 additions & 0 deletions frontend/src/api/__tests__/wordEditorAPI.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>) =>
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<unknown>) =>
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,
);
});
});
51 changes: 51 additions & 0 deletions frontend/tests/chat-revise-flows.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
4 changes: 0 additions & 4 deletions frontend/tests/demo-page-visual.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading