mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 00:13:09 +00:00
test: check proofreading in the browser with a test checker plugin #1397
This commit is contained in:
@@ -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"
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user