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");
+});