test(chrome-ext): on Firefox in Playwright (#1491)

This commit is contained in:
Elijah Potter 2025-07-04 12:13:17 -06:00 committed by GitHub
parent c87adcdc1a
commit 68b1201e92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 308 additions and 166 deletions

View file

@ -131,7 +131,28 @@ test-chrome-plugin: build-chrome-plugin
pnpm install
cd "{{justfile_directory()}}/packages/chrome-plugin"
pnpm playwright install
pnpm test
# For environments without displays like CI servers or containers
if [[ "$(uname)" == "Linux" ]] && [[ -z "$DISPLAY" ]]; then
xvfb-run --auto-servernum pnpm test --project chromium
else
pnpm test --project chromium
fi
test-firefox-plugin: build-firefox-plugin
#!/usr/bin/env bash
set -eo pipefail
pnpm install
cd "{{justfile_directory()}}/packages/chrome-plugin"
pnpm playwright install
# For environments without displays like CI servers or containers
if [[ "$(uname)" == "Linux" ]] && [[ -z "$DISPLAY" ]]; then
xvfb-run --auto-servernum pnpm test --project firefox
else
pnpm test --project firefox
fi
# Run VSCode plugin unit and integration tests.
test-vscode:

View file

@ -17,7 +17,7 @@
"fmt": "prettier --write '**/*.{svelte,ts,json,css,scss,md}'",
"zip-for-chrome": "TARGET_BROWSER=chrome npm run build && node src/zip.js harper-chrome-plugin.zip",
"zip-for-firefox": "TARGET_BROWSER=firefox npm run build && node src/zip.js harper-firefox-plugin.zip",
"test": "playwright test"
"test": "playwright test --headed"
},
"devDependencies": {
"@crxjs/vite-plugin": "^2.0.0-beta.26",
@ -34,6 +34,7 @@
"gulp": "^5.0.0",
"gulp-zip": "^6.0.0",
"http-server": "^14.1.1",
"playwright-webextext": "^0.0.4",
"prettier": "^3.1.0",
"prettier-plugin-svelte": "^3.2.6",
"rollup-plugin-copy": "^3.5.0",

View file

@ -30,5 +30,9 @@ export default defineConfig({
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
],
});

View file

@ -1,8 +1,5 @@
import type { VNode } from 'virtual-dom';
import createElement from 'virtual-dom/create-element';
import diff from 'virtual-dom/diff';
import h from 'virtual-dom/h';
import patch from 'virtual-dom/patch';
import { type LintBox, isBoxInScreen } from './Box';
import RenderBox from './RenderBox';
import {
@ -29,40 +26,42 @@ export default class Highlights {
public renderLintBoxes(boxes: LintBox[]) {
// Sort the lint boxes based on their source, so we can render them all together.
const sourceToBoxes: Map<HTMLElement, LintBox[]> = new Map();
const sourceToBoxes: Map<HTMLElement, { boxes: LintBox[]; icr: DOMRect | null }> = new Map();
for (const box of boxes) {
let renderBox = this.renderBoxes.get(box.source);
if (renderBox == null) {
renderBox = new RenderBox(this.computeRenderTarget(box.source));
this.renderBoxes.set(box.source, renderBox);
}
const value = sourceToBoxes.get(box.source);
const icr = getInitialContainingRect(renderBox.getShadowHost());
if (value == null) {
sourceToBoxes.set(box.source, [box]);
sourceToBoxes.set(box.source, { boxes: [box], icr });
} else {
sourceToBoxes.set(box.source, [...value, box]);
sourceToBoxes.set(box.source, { boxes: [...value.boxes, box], icr });
}
}
const updated = new Set();
for (const [source, boxes] of sourceToBoxes.entries()) {
let renderBox = this.renderBoxes.get(source);
if (renderBox == null) {
renderBox = new RenderBox(this.computeRenderTarget(source));
this.renderBoxes.set(source, renderBox);
}
for (const [source, { boxes, icr }] of sourceToBoxes.entries()) {
const renderBox = this.renderBoxes.get(source)!;
const host = renderBox.getShadowHost();
host.id = 'harper-highlight-host';
const rect = getInitialContainingRect(renderBox.getShadowHost());
if (rect != null) {
if (icr != null) {
const hostStyle = host.style;
hostStyle.contain = 'layout';
hostStyle.position = 'fixed';
hostStyle.left = `${-rect.x}px`;
hostStyle.top = `${-rect.y}px`;
hostStyle.top = '0px';
hostStyle.left = '0px';
hostStyle.transform = `translate(${-icr.x}px, ${-icr.y}px)`;
hostStyle.width = '100vw';
hostStyle.height = '100vh';
hostStyle.zIndex = '100';
@ -101,8 +100,9 @@ export default class Highlights {
{
style: {
position: 'fixed',
left: `${box.x}px`,
top: `${box.y}px`,
left: '0px',
top: '0px',
transform: `translate(${box.x}px, ${box.y}px)`,
width: `${box.width}px`,
height: `${box.height}px`,
pointerEvents: 'none',

View file

@ -1,3 +1,4 @@
import type { Lint } from 'harper.js';
import { clone } from 'lodash-es';
import { isBoxInScreen } from './Box';
import Highlights from './Highlights';
@ -17,7 +18,9 @@ export default class LintFramework {
private popupHandler: PopupHandler;
private targets: Set<Node>;
private scrollableAncestors: Set<HTMLElement>;
private frameRequested = false;
private lintRequested = false;
private renderRequested = false;
private lastLints: { target: HTMLElement; lints: Lint[] }[] = [];
/** The function to be called to re-render the highlights. This is a variable because it is used to register/deregister event listeners. */
private updateEventCallback: () => void;
@ -27,6 +30,7 @@ export default class LintFramework {
this.popupHandler = new PopupHandler();
this.targets = new Set();
this.scrollableAncestors = new Set();
this.lastLints = [];
this.updateEventCallback = () => {
this.update();
@ -35,7 +39,7 @@ export default class LintFramework {
const timeoutCallback = () => {
this.update();
setTimeout(timeoutCallback, 1000);
setTimeout(timeoutCallback, 100);
};
timeoutCallback();
@ -57,12 +61,17 @@ export default class LintFramework {
}
async update() {
// To avoid multiple redundant calls to try running at the same time.
if (this.frameRequested) {
this.requestRender();
this.requestLintUpdate();
}
async requestLintUpdate() {
if (this.lintRequested) {
return;
}
this.frameRequested = true;
// Avoid duplicate requests in the queue
this.lintRequested = true;
const lintResults = await Promise.all(
this.onScreenTargets().map(async (target) => {
@ -85,15 +94,9 @@ export default class LintFramework {
}),
);
requestAnimationFrame(() => {
const boxes = lintResults.flatMap(({ target, lints }) =>
target ? lints.flatMap((l) => computeLintBoxes(target, l)) : [],
);
this.highlights.renderLintBoxes(boxes);
this.popupHandler.updateLintBoxes(boxes);
this.frameRequested = false;
});
this.lastLints = lintResults;
this.lintRequested = false;
this.requestRender();
}
public async addTarget(target: Node) {
@ -155,6 +158,24 @@ export default class LintFramework {
window.removeEventListener(event, this.updateEventCallback);
}
}
private requestRender() {
if (this.renderRequested) {
return;
}
this.renderRequested = true;
requestAnimationFrame(() => {
const boxes = this.lastLints.flatMap(({ target, lints }) =>
target ? lints.flatMap((l) => computeLintBoxes(target, l)) : [],
);
this.highlights.renderLintBoxes(boxes);
this.popupHandler.updateLintBoxes(boxes);
this.renderRequested = false;
});
}
}
/**

View file

@ -64,6 +64,11 @@ export default class TextFieldRange {
this.mirror!.style[prop] = computed[prop];
});
if (this.field instanceof HTMLTextAreaElement) {
this.mirror.style.overflowX = 'auto';
this.mirror.style.overflowY = 'auto';
}
// Compute the absolute position of the field.
const fieldRect = this.field.getBoundingClientRect();
const scrollTop = window.scrollY || document.documentElement.scrollTop;

View file

@ -17,54 +17,58 @@ function isFormEl(el: HTMLElement): el is HTMLTextAreaElement | HTMLInputElement
}
export default function computeLintBoxes(el: HTMLElement, lint: UnpackedLint): IgnorableLintBox[] {
let range: Range | TextFieldRange | null = null;
let text: string | null = null;
try {
let range: Range | TextFieldRange | null = null;
let text: string | null = null;
if (isFormEl(el)) {
range = new TextFieldRange(el, lint.span.start, lint.span.end);
text = el.value;
} else {
range = getRangeForTextSpan(el, lint.span as Span);
}
const targetRects = range.getClientRects();
const elBox = domRectToBox(range.getBoundingClientRect());
range.detach();
const boxes: IgnorableLintBox[] = [];
let source: HTMLElement | null = null;
if (el.tagName == undefined) {
source = el.parentElement;
} else {
source = el;
}
if (source == null) {
return [];
}
for (const targetRect of targetRects) {
if (!isBottomEdgeInBox(targetRect, elBox)) {
continue;
if (isFormEl(el)) {
range = new TextFieldRange(el, lint.span.start, lint.span.end);
text = el.value;
} else {
range = getRangeForTextSpan(el, lint.span as Span);
}
boxes.push({
x: targetRect.x,
y: targetRect.y,
width: targetRect.width,
height: targetRect.height,
lint,
source,
applySuggestion: (sug: UnpackedSuggestion) => {
replaceValue(el, applySuggestion(el.value ?? el.textContent, lint.span, sug));
},
ignoreLint: () => ProtocolClient.ignoreHash(lint.context_hash),
});
}
const targetRects = range.getClientRects();
const elBox = domRectToBox(range.getBoundingClientRect());
range.detach();
return boxes;
const boxes: IgnorableLintBox[] = [];
let source: HTMLElement | null = null;
if (el.tagName == undefined) {
source = el.parentElement;
} else {
source = el;
}
if (source == null) {
return [];
}
for (const targetRect of targetRects) {
if (!isBottomEdgeInBox(targetRect, elBox)) {
continue;
}
boxes.push({
x: targetRect.x,
y: targetRect.y,
width: targetRect.width,
height: targetRect.height,
lint,
source,
applySuggestion: (sug: UnpackedSuggestion) => {
replaceValue(el, applySuggestion(el.value ?? el.textContent, lint.span, sug));
},
ignoreLint: () => ProtocolClient.ignoreHash(lint.context_hash),
});
}
return boxes;
} catch (e) {
// If there's an error, it's likely because the element no longer exists
return [];
}
}
function replaceValue(el: HTMLElement, value: string) {

View file

@ -1,31 +1,5 @@
import path from 'path';
import { type BrowserContext, test as base, chromium } from '@playwright/test';
import { createFixture } from 'playwright-webextext';
export const test = base.extend<{
context: BrowserContext;
extensionId: string;
}>({
// biome-ignore lint/correctness/noEmptyPattern: it's by Playwright. Explanation not provided.
context: async ({}, use) => {
const pathToExtension = path.join(import.meta.dirname, '../build');
console.log(`Loading extension from ${pathToExtension}`);
const context = await chromium.launchPersistentContext('', {
channel: 'chromium',
args: [
`--disable-extensions-except=${pathToExtension}`,
`--load-extension=${pathToExtension}`,
],
});
await use(context);
await context.close();
},
extensionId: async ({ context }, use) => {
let [background] = context.serviceWorkers();
if (!background) background = await context.waitForEvent('serviceworker');
const extensionId = background.url().split('/')[2];
await use(extensionId);
},
});
export const expect = test.expect;
const pathToExtension = path.join(import.meta.dirname, '../build');
export const { test, expect } = createFixture(pathToExtension);

View file

@ -1,48 +1,18 @@
import type { Locator, Page } from '@playwright/test';
import type { Box } from '../src/Box';
import { expect, test } from './fixtures';
import { clickHarperHighlight, getHarperHighlights, replaceEditorContent } from './testUtils';
import { test } from './fixtures';
import {
assertHarperHighlightBoxes,
clickHarperHighlight,
getHarperHighlights,
getTextarea,
replaceEditorContent,
testBasicSuggestionTextarea,
testCanIgnoreTextareaSuggestion,
} from './testUtils';
const TEST_PAGE_URL = 'http://localhost:8081/github_textarea.html';
/** Grab the first `<textarea />` on a page. */
function getTextarea(page: Page): Locator {
return page.locator('textarea');
}
test('Can apply basic suggestion.', async ({ page }) => {
await page.goto(TEST_PAGE_URL);
const editor = getTextarea(page);
await replaceEditorContent(editor, 'This is an test');
await page.waitForTimeout(6000);
await clickHarperHighlight(page);
await page.getByTitle('Replace with "a"').click();
await page.waitForTimeout(3000);
expect(editor).toHaveValue('This is a test');
});
test('Can ignore suggestion.', async ({ page }) => {
await page.goto(TEST_PAGE_URL);
const editor = getTextarea(page);
await replaceEditorContent(editor, 'This is an test');
await page.waitForTimeout(6000);
await clickHarperHighlight(page);
await page.getByTitle('Ignore this lint').click();
await page.waitForTimeout(3000);
// Nothing should change.
expect(editor).toHaveValue('This is an test');
expect(await clickHarperHighlight(page)).toBe(false);
});
testBasicSuggestionTextarea(TEST_PAGE_URL);
testCanIgnoreTextareaSuggestion(TEST_PAGE_URL);
test('Wraps correctly', async ({ page }) => {
await page.goto(TEST_PAGE_URL);
@ -74,13 +44,3 @@ test('Scrolls correctly', async ({ page }) => {
await assertHarperHighlightBoxes(page, [{ width: 58.828125, x: 117.40625, y: 161, height: 18 }]);
});
async function assertHarperHighlightBoxes(page: Page, boxes: Box[]): Promise<void> {
const highlights = getHarperHighlights(page);
expect(await highlights.count()).toBe(boxes.length);
for (let i = 0; i < (await highlights.count()); i++) {
expect(await highlights.nth(i).boundingBox()).toStrictEqual(boxes[i]);
}
}

View file

@ -0,0 +1,6 @@
<!DOCTYPE html>
<html>
<body>
<textarea rows="5" cols="33"></textarea>
</body>
</html>

View file

@ -0,0 +1,53 @@
import { test } from './fixtures';
import {
assertHarperHighlightBoxes,
getTextarea,
replaceEditorContent,
testBasicSuggestionTextarea,
testCanIgnoreTextareaSuggestion,
} from './testUtils';
const TEST_PAGE_URL = 'http://localhost:8081/simple_textarea.html';
testBasicSuggestionTextarea(TEST_PAGE_URL);
testCanIgnoreTextareaSuggestion(TEST_PAGE_URL);
test('Wraps correctly', async ({ page }, testInfo) => {
await page.goto(TEST_PAGE_URL);
const editor = getTextarea(page);
await replaceEditorContent(
editor,
'This is a test of the Harper grammar checker, specifically if \nit is wrapped around a line weirdl y',
);
await page.waitForTimeout(6000);
if (testInfo.project.name == 'chromium') {
await assertHarperHighlightBoxes(page, [
{ height: 19, width: 24, x: 241.90625, y: 27 },
{ x: 233.90625, y: 44, width: 48, height: 19 },
{ x: 281.90625, y: 44, width: 8, height: 19 },
{ x: 10, y: 61, width: 8, height: 19 },
]);
} else {
await assertHarperHighlightBoxes(page, [
{ x: 218.8000030517578, y: 26, width: 21.600006103515625, height: 17 },
{ x: 10, y: 71, width: 57.599998474121094, height: 17 },
]);
}
});
test('Scrolls correctly', async ({ page }) => {
await page.goto(TEST_PAGE_URL);
const editor = getTextarea(page);
await replaceEditorContent(
editor,
'This is a test of the the Harper grammar checker, specifically if \n\n\n\n\n\n\n\n\n\n\n\n\nit scrolls beyo nd the height of the buffer.',
);
await page.waitForTimeout(6000);
await assertHarperHighlightBoxes(page, [{ height: 19, width: 56, x: 97.953125, y: 63 }]);
});

View file

@ -1,4 +1,6 @@
import type { Locator, Page } from '@playwright/test';
import type { Box } from '../src/Box';
import { expect, test } from './fixtures';
/** Locate the [`Slate`](https://www.slatejs.org/examples/richtext) editor on the page. */
export function getSlateEditor(page: Page): Locator {
@ -37,7 +39,7 @@ export async function clickHarperHighlight(page: Page): Promise<boolean> {
return false;
}
const box = await highlights.boundingBox();
const box = await highlights.first().boundingBox();
if (box == null) {
return false;
@ -50,3 +52,73 @@ export async function clickHarperHighlight(page: Page): Promise<boolean> {
await page.mouse.click(cx, cy);
return true;
}
/** Grab the first `<textarea />` on a page. */
export function getTextarea(page: Page): Locator {
return page.locator('textarea');
}
export async function testBasicSuggestionTextarea(testPageUrl: string) {
test('Can apply basic suggestion.', async ({ page }) => {
await page.goto(testPageUrl);
const editor = getTextarea(page);
await replaceEditorContent(editor, 'This is an test');
await page.waitForTimeout(6000);
await clickHarperHighlight(page);
await page.getByTitle('Replace with "a"').click();
await page.waitForTimeout(3000);
expect(editor).toHaveValue('This is a test');
});
}
export async function testCanIgnoreTextareaSuggestion(testPageUrl: string) {
test('Can ignore suggestion.', async ({ page }) => {
await page.goto(testPageUrl);
const editor = getTextarea(page);
await replaceEditorContent(editor, 'This is an test');
await page.waitForTimeout(6000);
await clickHarperHighlight(page);
await page.getByTitle('Ignore this lint').click();
await page.waitForTimeout(3000);
// Nothing should change.
expect(editor).toHaveValue('This is an test');
expect(await clickHarperHighlight(page)).toBe(false);
});
}
export async function assertHarperHighlightBoxes(page: Page, boxes: Box[]): Promise<void> {
const highlights = getHarperHighlights(page);
expect(await highlights.count()).toBe(boxes.length);
for (let i = 0; i < (await highlights.count()); i++) {
const box = await highlights.nth(i).boundingBox();
console.log(`Expected: ${JSON.stringify(boxes[i])}`);
console.log(`Got: ${JSON.stringify(box)}`);
assertBoxesClose(box, boxes[i]);
}
}
/** An assertion that checks to ensure that two boxes are _approximately_ equal.
* Leaves wiggle room for floating point error. */
export function assertBoxesClose(a: Box, b: Box) {
assertClose(a.x, b.x);
assertClose(a.y, b.y);
assertClose(a.width, b.width);
assertClose(a.height, b.height);
}
function assertClose(actual: number, expected: number) {
expect(Math.abs(actual - expected)).toBeLessThanOrEqual(9);
}

29
pnpm-lock.yaml generated
View file

@ -107,6 +107,9 @@ importers:
http-server:
specifier: ^14.1.1
version: 14.1.1
playwright-webextext:
specifier: ^0.0.4
version: 0.0.4(@playwright/test@1.52.0)(playwright@1.52.0)
prettier:
specifier: ^3.1.0
version: 3.5.3
@ -1320,6 +1323,7 @@ packages:
'@crxjs/vite-plugin@2.0.0-beta.32':
resolution: {integrity: sha512-FnEZFrmi4zWG+qPzz658riLIN6TTSOq/M8uEBcENUKoV/UOVUaPrTBjN3aTNOd+tMB7PlqYknKHZYddz/plRdQ==}
deprecated: Beta versions are no longer maintained. Please upgrade to the stable 2.0.0 release
'@csstools/css-parser-algorithms@3.0.4':
resolution: {integrity: sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==}
@ -8679,6 +8683,18 @@ packages:
engines: {node: '>=18'}
hasBin: true
playwright-webextext@0.0.4:
resolution: {integrity: sha512-B5uIZSRtH6wi5HPhEwbEkZxEoAY5bUKCjqVW2qCsAYWQTQZ4ULOfsyglI+gfiohxrAzHT+mtkZMXZvHTtunsDg==}
engines: {node: '20'}
peerDependencies:
'@playwright/test': '>=1.0.0'
playwright: '>=1.0.0'
peerDependenciesMeta:
'@playwright/test':
optional: true
playwright:
optional: true
playwright@1.52.0:
resolution: {integrity: sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==}
engines: {node: '>=18'}
@ -10245,8 +10261,8 @@ packages:
third-party-web@0.26.5:
resolution: {integrity: sha512-tDuKQJUTfjvi9Fcrs1s6YAQAB9mzhTSbBZMfNgtWNmJlHuoFeXO6dzBFdGeCWRvYL50jQGK0jPsBZYxqZQJ2SA==}
third-party-web@0.26.6:
resolution: {integrity: sha512-GsjP92xycMK8qLTcQCacgzvffYzEqe29wyz3zdKVXlfRD5Kz1NatCTOZEeDaSd6uCZXvGd2CNVtQ89RNIhJWvA==}
third-party-web@0.27.0:
resolution: {integrity: sha512-h0JYX+dO2Zr3abCQpS6/uFjujaOjA1DyDzGQ41+oFn9VW/ARiq9g5ln7qEP9+BTzDpOMyIfsfj4OvfgXAsMUSA==}
through@2.3.8:
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
@ -14023,7 +14039,7 @@ snapshots:
'@paulirish/trace_engine@0.0.44':
dependencies:
third-party-web: 0.26.6
third-party-web: 0.27.0
'@php-wasm/node-polyfills@0.6.16': {}
@ -22367,6 +22383,11 @@ snapshots:
playwright-core@1.52.0: {}
playwright-webextext@0.0.4(@playwright/test@1.52.0)(playwright@1.52.0):
optionalDependencies:
'@playwright/test': 1.52.0
playwright: 1.52.0
playwright@1.52.0:
dependencies:
playwright-core: 1.52.0
@ -24183,7 +24204,7 @@ snapshots:
third-party-web@0.26.5: {}
third-party-web@0.26.6: {}
third-party-web@0.27.0: {}
through@2.3.8: {}