#!/usr/bin/env node /** * ObsiGate — Forge autocomplete tests (BUG-055). * * The Forge editor (`frontend/editor-poc.html`) shares its pure completion * helpers with CodeMirror through `frontend/js/autocomplete.js`. This suite * pins the behaviour that fixes the « Tab adds a stray space » bug: * * - getWordFragment / findWordCompletions — document word completion * - normalizeGhost — AI inline prediction cleanup * - chooseTabAction — single action per Tab press * * Usage: node tests/frontend/forge-completion.test.mjs */ import { strict as assert } from "node:assert"; import { fileURLToPath, pathToFileURL } from "node:url"; import path from "node:path"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const REPO_ROOT = path.resolve(__dirname, "..", ".."); const mod = await import( pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "autocomplete.js")).href ); const { isWordChar, getWordFragment, findWordCompletions, normalizeGhost, chooseTabAction, } = mod; let testCount = 0; let failCount = 0; function test(name, fn) { testCount++; try { fn(); console.log(` ✓ ${name}`); } catch (err) { failCount++; console.error(` ✗ ${name}\n ${err.message}`); process.exitCode = 1; } } // ── isWordChar ───────────────────────────────────────────────────────────── test("isWordChar accepts letters, digits, _ - . /", () => { for (const ch of ["a", "Z", "0", "_", "-", ".", "/"]) { assert.equal(isWordChar(ch), true, `expected ${JSON.stringify(ch)} to be a word char`); } }); test("isWordChar rejects spaces and punctuation", () => { for (const ch of [" ", "\n", "!", "@", "#", "(", "\t"]) { assert.equal(isWordChar(ch), false, `expected ${JSON.stringify(ch)} not to be a word char`); } assert.equal(isWordChar(""), false); assert.equal(isWordChar(undefined), false); }); // ── getWordFragment ──────────────────────────────────────────────────────── test("getWordFragment returns the fragment before the cursor", () => { const text = "hello wor"; assert.deepEqual(getWordFragment(text, text.length), { start: 6, fragment: "wor" }); }); test("getWordFragment stops at non-word characters", () => { const text = "a (tabl"; assert.deepEqual(getWordFragment(text, text.length), { start: 3, fragment: "tabl" }); }); test("getWordFragment returns an empty fragment at a word boundary", () => { const text = "hello "; assert.deepEqual(getWordFragment(text, text.length), { start: 6, fragment: "" }); }); test("getWordFragment handles a mid-document cursor", () => { const text = "one two three"; // cursor right after "tw" assert.deepEqual(getWordFragment(text, 6), { start: 4, fragment: "tw" }); }); // ── findWordCompletions ──────────────────────────────────────────────────── test("findWordCompletions returns prefix matches from the document", () => { const text = "table tableau tab\n"; const cursor = text.length; assert.deepEqual(findWordCompletions(text, cursor, "tab", 8), ["table", "tableau"]); }); test("findWordCompletions ignores the occurrence being typed", () => { const text = "table and tab"; const cursor = text.length; // typing the trailing "tab" assert.deepEqual(findWordCompletions(text, cursor, "tab", 8), ["table"]); }); test("findWordCompletions is case-insensitive and unique", () => { const text = "Table TABLE table\n"; const cursor = text.length; assert.deepEqual(findWordCompletions(text, cursor, "tab", 8), ["Table"]); }); test("findWordCompletions completes file-like tokens", () => { const text = "docs/guide.md docs/guide\n"; const cursor = text.length; assert.deepEqual(findWordCompletions(text, cursor, "docs/", 8), ["docs/guide.md", "docs/guide"]); }); test("findWordCompletions honours the limit", () => { const text = "abc abd abe abf abg\n"; const cursor = text.length; assert.equal(findWordCompletions(text, cursor, "ab", 3).length, 3); }); test("findWordCompletions needs at least two characters", () => { const text = "apple apricot\n"; assert.deepEqual(findWordCompletions(text, text.length, "a", 8), []); assert.deepEqual(findWordCompletions(text, text.length, "", 8), []); }); // ── normalizeGhost ───────────────────────────────────────────────────────── test("normalizeGhost strips an echoed prefix (mid-word)", () => { assert.equal(normalizeGhost("familial", "famil", true), "ial"); }); test("normalizeGhost keeps only the first token for a word completion", () => { // A word completion must never introduce a space. assert.equal(normalizeGhost("familial and more", "famil", true), "ial"); }); test("normalizeGhost tolerates a leading space from the model", () => { assert.equal(normalizeGhost(" monde", "bonjour", false), "monde"); }); test("normalizeGhost strips a repeated full context", () => { assert.equal(normalizeGhost("Bonjour le monde", "Bonjour", false), " le monde".trim()); }); test("normalizeGhost returns an empty string for empty input", () => { assert.equal(normalizeGhost("", "abc", true), ""); assert.equal(normalizeGhost(null, "abc", true), ""); assert.equal(normalizeGhost(" ", "abc", false), ""); }); test("normalizeGhost keeps a phrase continuation intact", () => { assert.equal(normalizeGhost("la suite de la phrase.", "Voici", false), "la suite de la phrase."); }); // ── chooseTabAction ──────────────────────────────────────────────────────── test("chooseTabAction prioritises the open list", () => { assert.equal(chooseTabAction({ dropdownOpen: true, ghost: true }), "dropdown"); }); test("chooseTabAction accepts a ghost prediction before word completion", () => { assert.equal(chooseTabAction({ ghost: true, candidates: ["table"] }), "ghost"); }); test("chooseTabAction inserts a single word match", () => { assert.equal(chooseTabAction({ candidates: ["table"] }), "word"); }); test("chooseTabAction opens the list for several matches", () => { assert.equal(chooseTabAction({ candidates: ["table", "tableau"] }), "word-list"); }); test("chooseTabAction indents when nothing matches", () => { assert.equal(chooseTabAction({ candidates: [] }), "indent"); assert.equal(chooseTabAction({}), "indent"); }); test("chooseTabAction dedents on Shift+Tab", () => { assert.equal(chooseTabAction({ shiftKey: true }), "dedent"); assert.equal(chooseTabAction({ shiftKey: true, candidates: ["table"] }), "dedent"); }); test("chooseTabAction indents when a selection exists", () => { assert.equal(chooseTabAction({ hasSelection: true, candidates: ["table"] }), "indent"); }); // ── Static wiring of frontend/editor-poc.html ────────────────────────────── import { readFileSync } from "node:fs"; const FORGE_HTML = readFileSync( path.join(REPO_ROOT, "frontend", "editor-poc.html"), "utf-8", ); test("Forge uses the shared completion helpers", () => { for (const call of ["ac.getWordFragment(", "ac.findWordCompletions(", "ac.chooseTabAction("]) { assert.ok(FORGE_HTML.includes(call), `editor-poc.html must call ${call}`); } }); test("Forge has a single indentation call site (no double Tab handling)", () => { const indentCalls = FORGE_HTML.match(/indentLine\(\);/g) || []; assert.equal(indentCalls.length, 1, "indentLine() must only be called from the unified Tab handler"); }); test("Forge ghost text no longer mirrors the whole document", () => { assert.ok(!FORGE_HTML.includes("ghostOverlay.innerHTML"), "the mirror overlay must be gone"); }); test("Forge ignores its own autosave reload while the buffer is dirty (BUG-055)", () => { assert.ok( FORGE_HTML.includes("if (!e.data.force && isDirty) return;"), "parent-reload must not clobber unsaved local edits", ); }); test("Forge cancels a pending ghost request when the prediction is accepted", () => { const fn = FORGE_HTML.match(/function acceptGhost\(\) \{([\s\S]*?)\n \}/); assert.ok(fn, "acceptGhost not found"); assert.match(fn[1], /clearTimeout\(_ghostTimer\)/); }); test("Forge autosave keeps the buffer dirty if edits arrive during the request", () => { const fn = FORGE_HTML.match(/function autoSave\(\) \{([\s\S]*?)\n \}/); assert.ok(fn, "autoSave not found"); assert.match(fn[1], /if \(val\(\) !== content\) \{ scheduleAutoSave\(\); return; \}/); }); test("Forge leaves fullscreen before opening the shared assistant", () => { const fn = FORGE_HTML.match(/function openAssistant\(\) \{([\s\S]*?)\n \}/); assert.ok(fn, "openAssistant not found"); assert.match(fn[1], /document\.fullscreenElement/); assert.match(fn[1], /document\.exitFullscreen\(\)/); assert.match(fn[1], /postMessage\(\{ type: 'forge-open-ai' \}, '\*'\)/); }); // ── Summary ──────────────────────────────────────────────────────────────── console.log(`\n${testCount} passed, ${failCount} failed`); process.exit(failCount > 0 ? 1 : 0);