From 39e137fd224f49e76d70e301add0ee55850a849d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:07:55 +0100 Subject: [PATCH] test: check proofreading in the browser with a test checker plugin #1397 --- .../fixtures/proofreading-checker/index.js | 38 +++ .../proofreading-checker/manifest.json | 11 + integration/tests/23-proofreading.spec.ts | 223 ++++++++++++++++++ 3 files changed, 272 insertions(+) create mode 100644 integration/fixtures/proofreading-checker/index.js create mode 100644 integration/fixtures/proofreading-checker/manifest.json create mode 100644 integration/tests/23-proofreading.spec.ts diff --git a/integration/fixtures/proofreading-checker/index.js b/integration/fixtures/proofreading-checker/index.js new file mode 100644 index 000000000..b6c2dd276 --- /dev/null +++ b/integration/fixtures/proofreading-checker/index.js @@ -0,0 +1,38 @@ +// A text checker for integration/tests/23-proofreading.spec.ts: answers from +// two fixed rules, in the plugin frame, so the spec tests the host's side +// (underlines, popup, list, keyboard, plain text, policy) without a server. + +var RULES = [ + { find: /\bdem( Brief)\b/g, length: 3, kind: 'grammar', message: 'Use the accusative here: "den Brief".', shortMessage: 'Case', replacements: ['den'], ruleId: 'DEM_DEN', ruleLabel: 'dem/den' }, + { find: /\bteh\b/g, length: 3, kind: 'spelling', message: 'Possible spelling mistake.', replacements: ['the', 'ten'], ruleId: 'TYPO', ruleLabel: 'Typos' }, +]; + +function check(request) { + var out = []; + request.segments.forEach(function (segment) { + RULES.forEach(function (rule) { + rule.find.lastIndex = 0; + var m; + while ((m = rule.find.exec(segment.text))) { + out.push({ + segmentId: segment.id, offset: m.index, length: rule.length, kind: rule.kind, + message: rule.message, shortMessage: rule.shortMessage, replacements: rule.replacements, + ruleId: rule.ruleId, ruleLabel: rule.ruleLabel, + }); + } + }); + }); + return out; +} + +exports.activate = function (api) { + return api.composer.registerChecker({ + id: 'mock', + label: 'Test checker', + kinds: ['spelling', 'grammar'], + debounceMs: 300, + warnBeforeSend: true, + check: check, + onAction: function () {}, + }); +}; diff --git a/integration/fixtures/proofreading-checker/manifest.json b/integration/fixtures/proofreading-checker/manifest.json new file mode 100644 index 000000000..52d1a8f50 --- /dev/null +++ b/integration/fixtures/proofreading-checker/manifest.json @@ -0,0 +1,11 @@ +{ + "id": "it-proofreading-checker", + "name": "Test checker", + "version": "1.0.0", + "author": "Bulwark integration tests", + "description": "Flags \"dem Brief\" and \"teh\" without sending anything anywhere. For the proofreading integration spec only.", + "type": "hook", + "permissions": ["compose:check"], + "entrypoint": "index.js", + "minAppVersion": "1.19.0" +} diff --git a/integration/tests/23-proofreading.spec.ts b/integration/tests/23-proofreading.spec.ts new file mode 100644 index 000000000..126fc80d0 --- /dev/null +++ b/integration/tests/23-proofreading.spec.ts @@ -0,0 +1,223 @@ +import { test, expect, type Browser, type BrowserContext, type Page } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import JSZip from 'jszip'; +import { ENT, adminContext, getPolicy, putPolicy, requireEntEnv, shot, userPage } from './helpers/ent'; + +/** + * Proofreading (#1397) through the real UI, with a checker plugin that + * answers from two fixed rules (integration/fixtures/proofreading-checker), + * so this tests the host: underlines, the popup, the keyboard, the issue + * list, plain text, the send warning and the admin's "required". + * + * The rig, from one shell (no extra variables: the checker needs no proxy): + * BUILD=1 bash enterprise-rig/start-prod.sh 3105 pr-1.0 + * bash enterprise-rig/run-spec.sh 3105 1.0 integration/tests/23-proofreading.spec.ts + */ + +test.describe.configure({ mode: 'serial' }); +test.setTimeout(180_000); + +const ID = 'it-proofreading-checker'; +const FIXTURE = path.join(__dirname, '..', 'fixtures', 'proofreading-checker'); +const GERMAN = 'Ich habe gestern dem Brief geschrieben.'; + +let original: Record; +/** The policy this spec runs with: the original, with plugins on. */ +let base: Record; + +async function pluginZip(): Promise { + const zip = new JSZip(); + for (const file of ['manifest.json', 'index.js']) zip.file(file, readFileSync(path.join(FIXTURE, file))); + return zip.generateAsync({ type: 'nodebuffer' }); +} + +async function install(admin: BrowserContext): Promise { + await admin.request.delete('/api/admin/plugins', { data: { id: ID } }); + const res = await admin.request.post('/api/admin/plugins', { + multipart: { file: { name: `${ID}.zip`, mimeType: 'application/zip', buffer: await pluginZip() } }, + }); + expect(res.ok(), await res.text()).toBe(true); + const on = await admin.request.patch('/api/admin/plugins', { data: { id: ID, enabled: true, forceEnabled: true } }); + expect(on.ok(), await on.text()).toBe(true); +} + +async function setPolicy(browser: Browser, patch: Record): Promise { + const admin = await adminContext(browser); + await putPolicy(admin, { ...base, ...patch }); + await admin.close(); +} + +const editor = (page: Page) => page.locator('[data-testid="email-composer"] [contenteditable="true"]').first(); +const status = (page: Page) => page.getByTestId('proofreading-status').first(); +const popup = (page: Page) => page.getByTestId('proofreading-popup'); +const underline = (page: Page, word: string) => page.locator('[data-text-issue]', { hasText: word }).first(); + +async function openComposer(page: Page, text: string): Promise { + await page.keyboard.press('c'); + await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15_000 }); + await page.locator('[data-testid="composer-subject"]').first().fill(`Proofreading ${Date.now().toString(36)}`); + await editor(page).click(); + await page.keyboard.type(text); +} + +/** Proofreading on for this user: through the status control, as a user would. */ +async function turnOn(page: Page): Promise { + await expect(status(page)).toBeVisible({ timeout: 30_000 }); + if ((await status(page).textContent())?.includes('off')) { + await status(page).click(); + await page.getByTestId('proofreading-turn-on').click(); + await page.keyboard.press('Escape'); + } +} + +async function closeComposer(page: Page): Promise { + await page.getByTestId('composer-discard').first().click().catch(() => {}); + const confirm = page.getByRole('alertdialog'); + if (await confirm.isVisible().catch(() => false)) await confirm.getByRole('button').last().click(); +} + +test.beforeAll(async ({ browser }) => { + requireEntEnv(); + const admin = await adminContext(browser); + original = await getPolicy(admin); + // A fresh rig has plugins turned off. + base = { ...original, features: { ...(original.features as object), pluginsEnabled: true } }; + await putPolicy(admin, base); + await install(admin); + await admin.close(); +}); + +test.afterAll(async ({ browser }) => { + const admin = await adminContext(browser); + await putPolicy(admin, original); + await admin.request.delete('/api/admin/plugins', { data: { id: ID } }); + await admin.close(); +}); + +test('underlines a mistake, fixes it with the mouse, and Ctrl+Z takes it back', async ({ browser }) => { + const page = await userPage(browser, ENT.alice); + await openComposer(page, GERMAN); + await turnOn(page); + // Turned on from the status control, which said what is sent where: no second notice. + await expect(page.getByTestId('proofreading-notice')).toHaveCount(0); + + const dem = underline(page, 'dem'); + await expect(dem).toBeVisible({ timeout: 15_000 }); + await expect(dem).toHaveClass(/text-issue-grammar/); + // The browser's own spell check steps back while the checker checks spelling. + await expect(editor(page)).toHaveAttribute('spellcheck', 'false'); + await expect(status(page)).toContainText('1 issue'); + + await dem.click(); + await expect(popup(page)).toBeVisible(); + await expect(popup(page)).toContainText('Use the accusative'); + await shot(page, 'proofreading-popup-light'); + await popup(page).getByRole('button', { name: 'Replace with den' }).click(); + await expect(editor(page)).toContainText('Ich habe gestern den Brief geschrieben.'); + await expect(popup(page)).toBeHidden(); + + await page.keyboard.press('Control+z'); + await expect(editor(page)).toContainText('Ich habe gestern dem Brief geschrieben.'); + await closeComposer(page); + await page.context().close(); +}); + +test('fixes a mistake with the keyboard only', async ({ browser }) => { + const page = await userPage(browser, ENT.alice); + await openComposer(page, 'Thanks for teh offer.'); + await turnOn(page); + await expect(underline(page, 'teh')).toBeVisible({ timeout: 15_000 }); + await editor(page).press('Control+Home'); + await page.keyboard.press("Control+'"); + await expect(popup(page)).toBeVisible(); + // The first suggestion has the focus; Enter takes it. + await expect(popup(page).getByRole('button', { name: 'Replace with the' })).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(editor(page)).toContainText('Thanks for the offer.'); + await expect(editor(page)).toBeFocused(); + // Escape closes the popup without closing the composer. + await page.keyboard.type(' And teh rest.'); + await expect(underline(page, 'teh')).toBeVisible({ timeout: 15_000 }); + await page.keyboard.press("Control+'"); + await expect(popup(page)).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(popup(page)).toBeHidden(); + await expect(page.locator('[data-testid="email-composer"]')).toBeVisible(); + await closeComposer(page); + await page.context().close(); +}); + +test('a right-click on an underline opens its popup, and the issue list jumps to the text', async ({ browser }) => { + const page = await userPage(browser, ENT.alice); + await openComposer(page, GERMAN); + await turnOn(page); + await expect(underline(page, 'dem')).toBeVisible({ timeout: 15_000 }); + await underline(page, 'dem').click({ button: 'right' }); + await expect(popup(page)).toBeVisible(); + await page.keyboard.press('Escape'); + await status(page).click(); + await expect(page.getByTestId('proofreading-panel')).toBeVisible(); + await shot(page, 'proofreading-list-light'); + await page.getByTestId('proofreading-issue-list').getByRole('button').first().click(); + await expect(popup(page)).toBeVisible(); + await closeComposer(page); + await page.context().close(); +}); + +test('asks before sending with open issues when the checker wants that', async ({ browser }) => { + const page = await userPage(browser, ENT.alice); + await openComposer(page, 'See teh file.'); + const to = page.locator('[data-testid="composer-to"] input').first(); + await to.click(); + await to.fill(ENT.bob); + await to.press('Enter'); + await turnOn(page); + await expect(underline(page, 'teh')).toBeVisible({ timeout: 15_000 }); + await page.locator('[data-testid="composer-subject"]').first().focus(); + await page.keyboard.press('Control+Enter'); + const dialog = page.getByRole('alertdialog'); + await expect(dialog).toContainText('Send with open issues?'); + await dialog.getByRole('button', { name: 'Review' }).click(); + await expect(popup(page)).toBeVisible(); + await page.keyboard.press('Escape'); + await closeComposer(page); + await page.context().close(); +}); + +test('in plain text, lists the issues and fixes them there', async ({ browser }) => { + const page = await userPage(browser, ENT.alice); + await page.keyboard.press('c'); + await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15_000 }); + await page.getByTestId('composer-options-menu').click(); + await page.getByTestId('composer-format-toggle').click(); + await page.keyboard.press('Escape'); + const textarea = page.locator('[data-testid="email-composer"] textarea').first(); + await textarea.click(); + await page.keyboard.type(GERMAN); + await turnOn(page); + await expect(status(page)).toContainText('1 issue', { timeout: 15_000 }); + await status(page).click(); + await shot(page, 'proofreading-plain-text-light'); + await page.getByTestId('proofreading-issue-list').getByRole('button', { name: 'Replace with den' }).click(); + await expect(textarea).toHaveValue(/Ich habe gestern den Brief geschrieben\./); + await closeComposer(page); + await page.context().close(); +}); + +test('required by the organisation: no switch, a notice in its words, and still on after the user turned it off', async ({ browser }) => { + // The user's own choice is off. + const page = await userPage(browser, ENT.carol); + await setPolicy(browser, { restrictions: { ...(base.restrictions as object), proofreading: { locked: true, value: true } } }); + await page.reload(); + await openComposer(page, GERMAN); + await expect(page.getByTestId('proofreading-notice')).toContainText('Your organisation turned on proofreading'); + await expect(underline(page, 'dem')).toBeVisible({ timeout: 15_000 }); + await status(page).click(); + await expect(page.getByTestId('proofreading-panel')).toContainText('Required by your organisation'); + await expect(page.getByTestId('proofreading-composer-switch')).toHaveCount(0); + await shot(page, 'proofreading-required-light'); + await closeComposer(page); + await setPolicy(browser, {}); + await page.context().close(); +});