diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87b143d..bea466f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,5 +26,11 @@ jobs: - name: Typecheck run: npm run typecheck + - name: Install test browser + run: npx playwright install --with-deps chromium + + - name: Browser regression tests + run: npm run test:e2e + - name: Build run: npm run build diff --git a/.gitignore b/.gitignore index 1fb96bc..87d6da1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ node_modules .env .env.* !.env.example +playwright-report/ +test-results/ diff --git a/README.md b/README.md index 93c2ceb..d321de0 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,15 @@ npm run dev The dev server runs at `http://localhost:4321`. +## Browser regression tests + +```bash +npx playwright install chromium +npm run test:e2e +``` + +The tests run the real editor in Chromium with deterministic responses at the built-in AI API boundary. They exercise ghost text, Tab acceptance, dismissal, and saved draft persistence without requiring a Gemini Nano download. Use `PLAYWRIGHT_BASE_URL` to test an already running development or production build, or `PLAYWRIGHT_PORT` to change the test server port. + ## Production build ```bash diff --git a/package-lock.json b/package-lock.json index aa54565..37bbd07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "vaul": "^1.1.2" }, "devDependencies": { + "@playwright/test": "^1.63.0", "@tailwindcss/vite": "^4.2.4", "@types/dom-chromium-ai": "^0.0.16", "@types/react": "^18.3.28", @@ -1688,6 +1689,22 @@ "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", "license": "MIT" }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -6589,6 +6606,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.12", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", diff --git a/package.json b/package.json index 0ea3253..13f8480 100644 --- a/package.json +++ b/package.json @@ -39,9 +39,11 @@ "astro": "astro", "generate-types": "wrangler types", "typecheck": "tsc --noEmit", + "test:e2e": "playwright test", "deploy": "npm run build && wrangler deploy" }, "devDependencies": { + "@playwright/test": "^1.63.0", "@tailwindcss/vite": "^4.2.4", "@types/dom-chromium-ai": "^0.0.16", "@types/react": "^18.3.28", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..89f424e --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "@playwright/test"; + +const port = process.env.PLAYWRIGHT_PORT ?? "55081"; +const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`; + +export default defineConfig({ + testDir: "./tests", + fullyParallel: true, + workers: 2, + use: { + baseURL, + browserName: "chromium", + viewport: { width: 1440, height: 900 }, + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + webServer: process.env.PLAYWRIGHT_BASE_URL ? undefined : { + command: `npx astro dev --host 127.0.0.1 --port ${port}`, + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/src/ai/prompts.ts b/src/ai/prompts.ts index 5a74e23..cb76a0a 100644 --- a/src/ai/prompts.ts +++ b/src/ai/prompts.ts @@ -1,4 +1,5 @@ import type { ChatMessage } from "../lib/types"; +import { getCompletionPrefix } from "./text"; function formatChatHistory(history: ChatMessage[]) { return history @@ -68,7 +69,16 @@ Draft: } export function buildCompletionPrompt(before: string) { - return `You are an inline autocomplete engine for a private writing editor. Continue only the unfinished sentence at the cursor. Return only the words that should be inserted after the cursor. Do not repeat already-written text. No quotes, markdown, JSON, labels, or commentary. Keep it subtle: 3 to 10 words, at most one short clause. + return `You are an inline autocomplete engine for a private writing editor. Continue only the unfinished sentence at the cursor. Return the completed sentence, starting with the exact unchanged prefix below, then add 3 to 10 words, at most one short clause. The cursor can be inside a word: finish that word without inserting a space. If the last word is already complete, separate the next word with a space. Preserve all existing spaces. No surrounding quotes, markdown, JSON, labels, or commentary. + +Examples: +Prefix: "The quick brow" +Response: The quick brown fox jumps over the lazy dog. +Prefix: "The quick brown" +Response: The quick brown fox jumps over the lazy dog. + +Required prefix: copy the text between the markers exactly, including spaces, then continue it. Do not include the markers or add quotation marks around the prefix. +${getCompletionPrefix(before)} Text before cursor: """${before}"""`; diff --git a/src/ai/text.ts b/src/ai/text.ts index ff141db..e0e3282 100644 --- a/src/ai/text.ts +++ b/src/ai/text.ts @@ -1,5 +1,10 @@ import { MAX_MODEL_CHARS } from "./constants"; +export function getCompletionPrefix(before: string) { + // Keep cursor whitespace: it distinguishes a finished word from a partial one. + return before.split(/(?<=[.!?])\s+/u).pop()?.trimStart() ?? before; +} + export function truncateForModel(text: string, limit = MAX_MODEL_CHARS) { if (text.length <= limit) return text; return `${text.slice(0, Math.floor(limit * 0.55))}\n\n[...]\n\n${text.slice(-Math.floor(limit * 0.4))}`; diff --git a/src/tiptap/ghostCompletion.ts b/src/tiptap/ghostCompletion.ts index 7f18700..6461e7f 100644 --- a/src/tiptap/ghostCompletion.ts +++ b/src/tiptap/ghostCompletion.ts @@ -4,7 +4,7 @@ import { Decoration, DecorationSet } from "@tiptap/pm/view"; import type { CompletionContext, GhostCompletionState } from "../lib/types"; import { countWords } from "../lib/session"; import { stripJsonFences } from "../lib/json"; -import { truncateForModel } from "../ai/text"; +import { getCompletionPrefix, truncateForModel } from "../ai/text"; export const ghostCompletionKey = new PluginKey("draftsideGhostCompletion"); @@ -95,7 +95,7 @@ export function getCompletionContext(editor: Editor): CompletionContext | null { if (trimmed.length < 12 || countWords(trimmed) < 3) return null; if (/[.!?]$/.test(trimmed)) return null; - const fragment = trimmed.split(/(?<=[.!?])\s+/u).pop()?.trim() ?? trimmed; + const fragment = getCompletionPrefix(compactBefore); if (fragment.length < 8 || countWords(fragment) < 2) return null; if (/^[\W_]+$/u.test(fragment)) return null; @@ -108,30 +108,17 @@ export function getCompletionContext(editor: Editor): CompletionContext | null { } export function cleanGhostCompletion(input: string, context: CompletionContext) { - let completion = stripJsonFences(input) - .replace(/^["'“”]+|["'“”]+$/g, "") - .replace(/\s+/g, " ") - .trim(); + const response = stripJsonFences(input).replace(/\s+/g, " "); + // The echoed prefix anchors the insertion boundary. Reject a rewritten or + // missing prefix instead of guessing whether the next token needs a space. + if (!response.startsWith(context.fragment)) return ""; + let completion = response.slice(context.fragment.length).trimEnd(); - if (!completion) return ""; - - const fragment = context.fragment.trim(); - if (completion.toLocaleLowerCase().startsWith(fragment.toLocaleLowerCase())) { - completion = completion.slice(fragment.length).trimStart(); - } - - completion = completion.replace(/^[….\s]+/, "").trim(); const sentenceEnd = completion.search(/[.!?](?:\s|$)/); - if (sentenceEnd > 0) completion = completion.slice(0, sentenceEnd + 1).trim(); - - const words = completion.split(/\s+/).filter(Boolean); - if (words.length > 12) completion = words.slice(0, 12).join(" "); - if (!completion || completion === fragment) return ""; + if (sentenceEnd >= 0) completion = completion.slice(0, sentenceEnd + 1).trimEnd(); - const previousCharacter = context.fingerprint.trimEnd().slice(-1); - if (completion && !/^[,.;:!?)]/.test(completion) && previousCharacter && !/[\s([{/"'“‘-]/.test(previousCharacter)) { - completion = ` ${completion}`; - } + const words = [...completion.matchAll(/\S+/g)]; + if (words.length > 12) completion = completion.slice(0, words[12].index).trimEnd(); return completion.length > 96 ? completion.slice(0, 96).replace(/\s+\S*$/, "") : completion; } diff --git a/tests/ghost-completion.spec.ts b/tests/ghost-completion.spec.ts new file mode 100644 index 0000000..0285614 --- /dev/null +++ b/tests/ghost-completion.spec.ts @@ -0,0 +1,72 @@ +import { expect, test, type Page } from "@playwright/test"; + +// Only the model response is controlled: the real editor, debounce, cleanup, +// ghost decoration, Tab handler, and IndexedDB persistence run in Chromium. +async function openEditor(page: Page, response: string) { + await page.addInitScript((text) => { + localStorage.setItem("draftside.onboarded", JSON.stringify({ at: Date.now(), hadApi: true })); + localStorage.setItem("draftside.uiPrefs", JSON.stringify({ liveAnalysis: false })); + class TestLanguageModel { + static async availability() { return "available"; } + static async params() { return {}; } + static async create() { return new TestLanguageModel(); } + async clone() { return new TestLanguageModel(); } + async prompt() { return text; } + destroy() {} + } + Object.defineProperty(globalThis, "LanguageModel", { configurable: true, value: TestLanguageModel }); + }, response); + await page.goto("/write"); + const editor = page.getByLabel("Draftside editor", { exact: true }); + await expect(editor).toBeVisible(); + return editor; +} + +const cases = [ + { name: "partial word", before: "The quick brow", response: "The quick brown fox jumps over the lazy dog.", after: "The quick brown fox jumps over the lazy dog." }, + { name: "complete word", before: "The quick brown", response: "The quick brown fox jumps over the lazy dog.", after: "The quick brown fox jumps over the lazy dog." }, + { name: "existing space", before: "The quick brown ", response: "The quick brown fox jumps over the lazy dog.", after: "The quick brown fox jumps over the lazy dog." }, + { name: "consecutive existing spaces", before: "The quick brown ", response: "The quick brown fox jumps.", after: "The quick brown fox jumps." }, + { name: "apostrophe suffix", before: "I think it isn", response: "I think it isn't finished yet.", after: "I think it isn't finished yet." }, + { name: "punctuation", before: "Here is the clause", response: "Here is the clause, with more detail.", after: "Here is the clause, with more detail." }, + { name: "sentence-ending punctuation", before: "Here is the clause", response: "Here is the clause. Another sentence.", after: "Here is the clause." }, + { name: "word-limited suggestion", before: "For this exercise we need", response: "For this exercise we need one two three four five six seven eight nine ten eleven twelve thirteen fourteen", after: "For this exercise we need one two three four five six seven eight nine ten eleven twelve" }, + { name: "multiple sentences in context", before: "It was sunny. The quick brow", response: "The quick brown fox jumps.", after: "It was sunny. The quick brown fox jumps." }, +]; + +for (const { name, before, response, after } of cases) { + test(`Tab accepts ${name} without corrupting the cursor boundary`, async ({ page }, testInfo) => { + const editor = await openEditor(page, response); + await editor.fill(before); + const ghost = editor.locator(".ProseMirror-widget"); + await expect(ghost).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath("ghost.png") }); + await editor.press("Tab"); + // Read textContent exactly: toHaveText's whitespace normalization would + // conceal the duplicate-space regression. + await expect.poll(() => editor.textContent()).toBe(after); + await expect(ghost).toHaveCount(0); + const savedTitle = after.slice(0, 72).replace(/\s+/g, " "); + await expect(page.getByLabel("Writing sessions").getByRole("button", { name: new RegExp(`^${savedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`) })).toBeVisible(); + await page.reload(); + await expect.poll(() => editor.textContent()).toBe(after); + await page.screenshot({ path: testInfo.outputPath("accepted.png") }); + }); +} + +test("a rewritten cursor prefix is not inserted into the draft", async ({ page }) => { + const editor = await openEditor(page, "A quick brown fox jumps."); + await editor.fill("The quick brow"); + await page.waitForTimeout(1800); + await expect(editor.locator(".ProseMirror-widget")).toHaveCount(0); + expect(await editor.textContent()).toBe("The quick brow"); +}); + +test("Escape dismisses a suggestion without changing the partial word", async ({ page }) => { + const editor = await openEditor(page, "The quick brown fox jumps."); + await editor.fill("The quick brow"); + await expect(editor.locator(".ProseMirror-widget")).toBeVisible(); + await editor.press("Escape"); + await expect(editor.locator(".ProseMirror-widget")).toHaveCount(0); + expect(await editor.textContent()).toBe("The quick brow"); +});