Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ node_modules
.env
.env.*
!.env.example
playwright-report/
test-results/
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
12 changes: 11 additions & 1 deletion src/ai/prompts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ChatMessage } from "../lib/types";
import { getCompletionPrefix } from "./text";

function formatChatHistory(history: ChatMessage[]) {
return history
Expand Down Expand Up @@ -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.
<prefix>${getCompletionPrefix(before)}</prefix>

Text before cursor:
"""${before}"""`;
Expand Down
5 changes: 5 additions & 0 deletions src/ai/text.ts
Original file line number Diff line number Diff line change
@@ -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))}`;
Expand Down
33 changes: 10 additions & 23 deletions src/tiptap/ghostCompletion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GhostCompletionState>("draftsideGhostCompletion");

Expand Down Expand Up @@ -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;

Expand All @@ -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;
}
72 changes: 72 additions & 0 deletions tests/ghost-completion.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Loading