mirror of
https://github.com/Automattic/harper.git
synced 2025-07-07 21:15:01 +00:00
test(chrome-ext): on Firefox in Playwright (#1491)
This commit is contained in:
parent
c87adcdc1a
commit
68b1201e92
13 changed files with 308 additions and 166 deletions
23
justfile
23
justfile
|
@ -131,7 +131,28 @@ test-chrome-plugin: build-chrome-plugin
|
||||||
pnpm install
|
pnpm install
|
||||||
cd "{{justfile_directory()}}/packages/chrome-plugin"
|
cd "{{justfile_directory()}}/packages/chrome-plugin"
|
||||||
pnpm playwright install
|
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.
|
# Run VSCode plugin unit and integration tests.
|
||||||
test-vscode:
|
test-vscode:
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
"fmt": "prettier --write '**/*.{svelte,ts,json,css,scss,md}'",
|
"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-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",
|
"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": {
|
"devDependencies": {
|
||||||
"@crxjs/vite-plugin": "^2.0.0-beta.26",
|
"@crxjs/vite-plugin": "^2.0.0-beta.26",
|
||||||
|
@ -34,6 +34,7 @@
|
||||||
"gulp": "^5.0.0",
|
"gulp": "^5.0.0",
|
||||||
"gulp-zip": "^6.0.0",
|
"gulp-zip": "^6.0.0",
|
||||||
"http-server": "^14.1.1",
|
"http-server": "^14.1.1",
|
||||||
|
"playwright-webextext": "^0.0.4",
|
||||||
"prettier": "^3.1.0",
|
"prettier": "^3.1.0",
|
||||||
"prettier-plugin-svelte": "^3.2.6",
|
"prettier-plugin-svelte": "^3.2.6",
|
||||||
"rollup-plugin-copy": "^3.5.0",
|
"rollup-plugin-copy": "^3.5.0",
|
||||||
|
|
|
@ -30,5 +30,9 @@ export default defineConfig({
|
||||||
name: 'chromium',
|
name: 'chromium',
|
||||||
use: { ...devices['Desktop Chrome'] },
|
use: { ...devices['Desktop Chrome'] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'firefox',
|
||||||
|
use: { ...devices['Desktop Firefox'] },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,8 +1,5 @@
|
||||||
import type { VNode } from 'virtual-dom';
|
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 h from 'virtual-dom/h';
|
||||||
import patch from 'virtual-dom/patch';
|
|
||||||
import { type LintBox, isBoxInScreen } from './Box';
|
import { type LintBox, isBoxInScreen } from './Box';
|
||||||
import RenderBox from './RenderBox';
|
import RenderBox from './RenderBox';
|
||||||
import {
|
import {
|
||||||
|
@ -29,40 +26,42 @@ export default class Highlights {
|
||||||
|
|
||||||
public renderLintBoxes(boxes: LintBox[]) {
|
public renderLintBoxes(boxes: LintBox[]) {
|
||||||
// Sort the lint boxes based on their source, so we can render them all together.
|
// 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) {
|
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 value = sourceToBoxes.get(box.source);
|
||||||
|
const icr = getInitialContainingRect(renderBox.getShadowHost());
|
||||||
|
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
sourceToBoxes.set(box.source, [box]);
|
sourceToBoxes.set(box.source, { boxes: [box], icr });
|
||||||
} else {
|
} else {
|
||||||
sourceToBoxes.set(box.source, [...value, box]);
|
sourceToBoxes.set(box.source, { boxes: [...value.boxes, box], icr });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = new Set();
|
const updated = new Set();
|
||||||
|
|
||||||
for (const [source, boxes] of sourceToBoxes.entries()) {
|
for (const [source, { boxes, icr }] of sourceToBoxes.entries()) {
|
||||||
let renderBox = this.renderBoxes.get(source);
|
const renderBox = this.renderBoxes.get(source)!;
|
||||||
|
|
||||||
if (renderBox == null) {
|
|
||||||
renderBox = new RenderBox(this.computeRenderTarget(source));
|
|
||||||
this.renderBoxes.set(source, renderBox);
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = renderBox.getShadowHost();
|
const host = renderBox.getShadowHost();
|
||||||
host.id = 'harper-highlight-host';
|
host.id = 'harper-highlight-host';
|
||||||
|
|
||||||
const rect = getInitialContainingRect(renderBox.getShadowHost());
|
if (icr != null) {
|
||||||
|
|
||||||
if (rect != null) {
|
|
||||||
const hostStyle = host.style;
|
const hostStyle = host.style;
|
||||||
|
|
||||||
hostStyle.contain = 'layout';
|
hostStyle.contain = 'layout';
|
||||||
hostStyle.position = 'fixed';
|
hostStyle.position = 'fixed';
|
||||||
hostStyle.left = `${-rect.x}px`;
|
hostStyle.top = '0px';
|
||||||
hostStyle.top = `${-rect.y}px`;
|
hostStyle.left = '0px';
|
||||||
|
hostStyle.transform = `translate(${-icr.x}px, ${-icr.y}px)`;
|
||||||
hostStyle.width = '100vw';
|
hostStyle.width = '100vw';
|
||||||
hostStyle.height = '100vh';
|
hostStyle.height = '100vh';
|
||||||
hostStyle.zIndex = '100';
|
hostStyle.zIndex = '100';
|
||||||
|
@ -101,8 +100,9 @@ export default class Highlights {
|
||||||
{
|
{
|
||||||
style: {
|
style: {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
left: `${box.x}px`,
|
left: '0px',
|
||||||
top: `${box.y}px`,
|
top: '0px',
|
||||||
|
transform: `translate(${box.x}px, ${box.y}px)`,
|
||||||
width: `${box.width}px`,
|
width: `${box.width}px`,
|
||||||
height: `${box.height}px`,
|
height: `${box.height}px`,
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { Lint } from 'harper.js';
|
||||||
import { clone } from 'lodash-es';
|
import { clone } from 'lodash-es';
|
||||||
import { isBoxInScreen } from './Box';
|
import { isBoxInScreen } from './Box';
|
||||||
import Highlights from './Highlights';
|
import Highlights from './Highlights';
|
||||||
|
@ -17,7 +18,9 @@ export default class LintFramework {
|
||||||
private popupHandler: PopupHandler;
|
private popupHandler: PopupHandler;
|
||||||
private targets: Set<Node>;
|
private targets: Set<Node>;
|
||||||
private scrollableAncestors: Set<HTMLElement>;
|
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. */
|
/** 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;
|
private updateEventCallback: () => void;
|
||||||
|
@ -27,6 +30,7 @@ export default class LintFramework {
|
||||||
this.popupHandler = new PopupHandler();
|
this.popupHandler = new PopupHandler();
|
||||||
this.targets = new Set();
|
this.targets = new Set();
|
||||||
this.scrollableAncestors = new Set();
|
this.scrollableAncestors = new Set();
|
||||||
|
this.lastLints = [];
|
||||||
|
|
||||||
this.updateEventCallback = () => {
|
this.updateEventCallback = () => {
|
||||||
this.update();
|
this.update();
|
||||||
|
@ -35,7 +39,7 @@ export default class LintFramework {
|
||||||
const timeoutCallback = () => {
|
const timeoutCallback = () => {
|
||||||
this.update();
|
this.update();
|
||||||
|
|
||||||
setTimeout(timeoutCallback, 1000);
|
setTimeout(timeoutCallback, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
timeoutCallback();
|
timeoutCallback();
|
||||||
|
@ -57,12 +61,17 @@ export default class LintFramework {
|
||||||
}
|
}
|
||||||
|
|
||||||
async update() {
|
async update() {
|
||||||
// To avoid multiple redundant calls to try running at the same time.
|
this.requestRender();
|
||||||
if (this.frameRequested) {
|
this.requestLintUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestLintUpdate() {
|
||||||
|
if (this.lintRequested) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.frameRequested = true;
|
// Avoid duplicate requests in the queue
|
||||||
|
this.lintRequested = true;
|
||||||
|
|
||||||
const lintResults = await Promise.all(
|
const lintResults = await Promise.all(
|
||||||
this.onScreenTargets().map(async (target) => {
|
this.onScreenTargets().map(async (target) => {
|
||||||
|
@ -85,15 +94,9 @@ export default class LintFramework {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
this.lastLints = lintResults;
|
||||||
const boxes = lintResults.flatMap(({ target, lints }) =>
|
this.lintRequested = false;
|
||||||
target ? lints.flatMap((l) => computeLintBoxes(target, l)) : [],
|
this.requestRender();
|
||||||
);
|
|
||||||
this.highlights.renderLintBoxes(boxes);
|
|
||||||
this.popupHandler.updateLintBoxes(boxes);
|
|
||||||
|
|
||||||
this.frameRequested = false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async addTarget(target: Node) {
|
public async addTarget(target: Node) {
|
||||||
|
@ -155,6 +158,24 @@ export default class LintFramework {
|
||||||
window.removeEventListener(event, this.updateEventCallback);
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -64,6 +64,11 @@ export default class TextFieldRange {
|
||||||
this.mirror!.style[prop] = computed[prop];
|
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.
|
// Compute the absolute position of the field.
|
||||||
const fieldRect = this.field.getBoundingClientRect();
|
const fieldRect = this.field.getBoundingClientRect();
|
||||||
const scrollTop = window.scrollY || document.documentElement.scrollTop;
|
const scrollTop = window.scrollY || document.documentElement.scrollTop;
|
||||||
|
|
|
@ -17,54 +17,58 @@ function isFormEl(el: HTMLElement): el is HTMLTextAreaElement | HTMLInputElement
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function computeLintBoxes(el: HTMLElement, lint: UnpackedLint): IgnorableLintBox[] {
|
export default function computeLintBoxes(el: HTMLElement, lint: UnpackedLint): IgnorableLintBox[] {
|
||||||
let range: Range | TextFieldRange | null = null;
|
try {
|
||||||
let text: string | null = null;
|
let range: Range | TextFieldRange | null = null;
|
||||||
|
let text: string | null = null;
|
||||||
|
|
||||||
if (isFormEl(el)) {
|
if (isFormEl(el)) {
|
||||||
range = new TextFieldRange(el, lint.span.start, lint.span.end);
|
range = new TextFieldRange(el, lint.span.start, lint.span.end);
|
||||||
text = el.value;
|
text = el.value;
|
||||||
} else {
|
} else {
|
||||||
range = getRangeForTextSpan(el, lint.span as Span);
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
boxes.push({
|
const targetRects = range.getClientRects();
|
||||||
x: targetRect.x,
|
const elBox = domRectToBox(range.getBoundingClientRect());
|
||||||
y: targetRect.y,
|
range.detach();
|
||||||
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;
|
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) {
|
function replaceValue(el: HTMLElement, value: string) {
|
||||||
|
|
|
@ -1,31 +1,5 @@
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { type BrowserContext, test as base, chromium } from '@playwright/test';
|
import { createFixture } from 'playwright-webextext';
|
||||||
|
|
||||||
export const test = base.extend<{
|
const pathToExtension = path.join(import.meta.dirname, '../build');
|
||||||
context: BrowserContext;
|
export const { test, expect } = createFixture(pathToExtension);
|
||||||
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;
|
|
||||||
|
|
|
@ -1,48 +1,18 @@
|
||||||
import type { Locator, Page } from '@playwright/test';
|
import { test } from './fixtures';
|
||||||
import type { Box } from '../src/Box';
|
import {
|
||||||
import { expect, test } from './fixtures';
|
assertHarperHighlightBoxes,
|
||||||
import { clickHarperHighlight, getHarperHighlights, replaceEditorContent } from './testUtils';
|
clickHarperHighlight,
|
||||||
|
getHarperHighlights,
|
||||||
|
getTextarea,
|
||||||
|
replaceEditorContent,
|
||||||
|
testBasicSuggestionTextarea,
|
||||||
|
testCanIgnoreTextareaSuggestion,
|
||||||
|
} from './testUtils';
|
||||||
|
|
||||||
const TEST_PAGE_URL = 'http://localhost:8081/github_textarea.html';
|
const TEST_PAGE_URL = 'http://localhost:8081/github_textarea.html';
|
||||||
|
|
||||||
/** Grab the first `<textarea />` on a page. */
|
testBasicSuggestionTextarea(TEST_PAGE_URL);
|
||||||
function getTextarea(page: Page): Locator {
|
testCanIgnoreTextareaSuggestion(TEST_PAGE_URL);
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Wraps correctly', async ({ page }) => {
|
test('Wraps correctly', async ({ page }) => {
|
||||||
await page.goto(TEST_PAGE_URL);
|
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 }]);
|
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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
6
packages/chrome-plugin/tests/pages/simple_textarea.html
Normal file
6
packages/chrome-plugin/tests/pages/simple_textarea.html
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<textarea rows="5" cols="33"></textarea>
|
||||||
|
</body>
|
||||||
|
</html>
|
53
packages/chrome-plugin/tests/simple_textarea.spec.ts
Normal file
53
packages/chrome-plugin/tests/simple_textarea.spec.ts
Normal 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 }]);
|
||||||
|
});
|
|
@ -1,4 +1,6 @@
|
||||||
import type { Locator, Page } from '@playwright/test';
|
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. */
|
/** Locate the [`Slate`](https://www.slatejs.org/examples/richtext) editor on the page. */
|
||||||
export function getSlateEditor(page: Page): Locator {
|
export function getSlateEditor(page: Page): Locator {
|
||||||
|
@ -37,7 +39,7 @@ export async function clickHarperHighlight(page: Page): Promise<boolean> {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const box = await highlights.boundingBox();
|
const box = await highlights.first().boundingBox();
|
||||||
|
|
||||||
if (box == null) {
|
if (box == null) {
|
||||||
return false;
|
return false;
|
||||||
|
@ -50,3 +52,73 @@ export async function clickHarperHighlight(page: Page): Promise<boolean> {
|
||||||
await page.mouse.click(cx, cy);
|
await page.mouse.click(cx, cy);
|
||||||
return true;
|
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
29
pnpm-lock.yaml
generated
|
@ -107,6 +107,9 @@ importers:
|
||||||
http-server:
|
http-server:
|
||||||
specifier: ^14.1.1
|
specifier: ^14.1.1
|
||||||
version: 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:
|
prettier:
|
||||||
specifier: ^3.1.0
|
specifier: ^3.1.0
|
||||||
version: 3.5.3
|
version: 3.5.3
|
||||||
|
@ -1320,6 +1323,7 @@ packages:
|
||||||
|
|
||||||
'@crxjs/vite-plugin@2.0.0-beta.32':
|
'@crxjs/vite-plugin@2.0.0-beta.32':
|
||||||
resolution: {integrity: sha512-FnEZFrmi4zWG+qPzz658riLIN6TTSOq/M8uEBcENUKoV/UOVUaPrTBjN3aTNOd+tMB7PlqYknKHZYddz/plRdQ==}
|
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':
|
'@csstools/css-parser-algorithms@3.0.4':
|
||||||
resolution: {integrity: sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==}
|
resolution: {integrity: sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==}
|
||||||
|
@ -8679,6 +8683,18 @@ packages:
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
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:
|
playwright@1.52.0:
|
||||||
resolution: {integrity: sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==}
|
resolution: {integrity: sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
@ -10245,8 +10261,8 @@ packages:
|
||||||
third-party-web@0.26.5:
|
third-party-web@0.26.5:
|
||||||
resolution: {integrity: sha512-tDuKQJUTfjvi9Fcrs1s6YAQAB9mzhTSbBZMfNgtWNmJlHuoFeXO6dzBFdGeCWRvYL50jQGK0jPsBZYxqZQJ2SA==}
|
resolution: {integrity: sha512-tDuKQJUTfjvi9Fcrs1s6YAQAB9mzhTSbBZMfNgtWNmJlHuoFeXO6dzBFdGeCWRvYL50jQGK0jPsBZYxqZQJ2SA==}
|
||||||
|
|
||||||
third-party-web@0.26.6:
|
third-party-web@0.27.0:
|
||||||
resolution: {integrity: sha512-GsjP92xycMK8qLTcQCacgzvffYzEqe29wyz3zdKVXlfRD5Kz1NatCTOZEeDaSd6uCZXvGd2CNVtQ89RNIhJWvA==}
|
resolution: {integrity: sha512-h0JYX+dO2Zr3abCQpS6/uFjujaOjA1DyDzGQ41+oFn9VW/ARiq9g5ln7qEP9+BTzDpOMyIfsfj4OvfgXAsMUSA==}
|
||||||
|
|
||||||
through@2.3.8:
|
through@2.3.8:
|
||||||
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
|
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
|
||||||
|
@ -14023,7 +14039,7 @@ snapshots:
|
||||||
|
|
||||||
'@paulirish/trace_engine@0.0.44':
|
'@paulirish/trace_engine@0.0.44':
|
||||||
dependencies:
|
dependencies:
|
||||||
third-party-web: 0.26.6
|
third-party-web: 0.27.0
|
||||||
|
|
||||||
'@php-wasm/node-polyfills@0.6.16': {}
|
'@php-wasm/node-polyfills@0.6.16': {}
|
||||||
|
|
||||||
|
@ -22367,6 +22383,11 @@ snapshots:
|
||||||
|
|
||||||
playwright-core@1.52.0: {}
|
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:
|
playwright@1.52.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
playwright-core: 1.52.0
|
playwright-core: 1.52.0
|
||||||
|
@ -24183,7 +24204,7 @@ snapshots:
|
||||||
|
|
||||||
third-party-web@0.26.5: {}
|
third-party-web@0.26.5: {}
|
||||||
|
|
||||||
third-party-web@0.26.6: {}
|
third-party-web@0.27.0: {}
|
||||||
|
|
||||||
through@2.3.8: {}
|
through@2.3.8: {}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue