test: check proofreading in the browser with a test checker plugin #1397

This commit is contained in:
Linus Rath
2026-11-02 20:07:55 +01:00
parent 43a18d2140
commit 39e137fd22
3 changed files with 272 additions and 0 deletions
@@ -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 () {},
});
};
@@ -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"
}
+223
View File
@@ -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 <worktree> 3105 <jmap-url> pr-1.0
* bash enterprise-rig/run-spec.sh <worktree> 3105 <jmap-url> 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<string, unknown>;
/** The policy this spec runs with: the original, with plugins on. */
let base: Record<string, unknown>;
async function pluginZip(): Promise<Buffer> {
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<void> {
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<string, unknown>): Promise<void> {
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<void> {
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<void> {
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<void> {
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();
});