diff --git a/src/extensionsIntegrated/CustomSnippets/codeHintIntegration.js b/src/extensionsIntegrated/CustomSnippets/codeHintIntegration.js index 9d4d1ee5f..2159fe8d4 100644 --- a/src/extensionsIntegrated/CustomSnippets/codeHintIntegration.js +++ b/src/extensionsIntegrated/CustomSnippets/codeHintIntegration.js @@ -116,7 +116,7 @@ define(function (require, exports, module) { ); if (matchedSnippet) { // Get current editor from EditorManager since it's not passed - const editor = EditorManager.getFocusedEditor(); + const editor = EditorManager.getActiveEditor(); if (editor) { // to track the usage metrics @@ -154,4 +154,5 @@ define(function (require, exports, module) { } exports.init = init; + exports._CustomSnippetsHandler = CustomSnippetsHandler; // exposed for integration testing }); diff --git a/src/extensionsIntegrated/CustomSnippets/main.js b/src/extensionsIntegrated/CustomSnippets/main.js index 5e2b1b3d0..75a78c35d 100644 --- a/src/extensionsIntegrated/CustomSnippets/main.js +++ b/src/extensionsIntegrated/CustomSnippets/main.js @@ -267,7 +267,7 @@ define(function (require, exports, module) { CodeHintIntegration.init(); // load snippets from file storage - SnippetsState.loadSnippetsFromState() + const _snippetsLoadedPromise = SnippetsState.loadSnippetsFromState() .then(function () { // track boot-time snippet count (only if user has snippets) const snippetCount = Global.SnippetHintsList.length; @@ -281,5 +281,15 @@ define(function (require, exports, module) { }); SnippetCursorManager.registerHandlers(); + + // Expose modules for integration testing + if (brackets.test) { + brackets.test.CustomSnippetsGlobal = Global; + brackets.test.CustomSnippetsHelper = Helper; + brackets.test.CustomSnippetsCursorManager = SnippetCursorManager; + brackets.test.CustomSnippetsCodeHintHandler = CodeHintIntegration._CustomSnippetsHandler; + brackets.test.CustomSnippetsDriver = Driver; + brackets.test._customSnippetsLoadedPromise = _snippetsLoadedPromise; + } }); }); diff --git a/src/extensionsIntegrated/CustomSnippets/snippetCursorManager.js b/src/extensionsIntegrated/CustomSnippets/snippetCursorManager.js index b99455d5f..a7ce3de81 100644 --- a/src/extensionsIntegrated/CustomSnippets/snippetCursorManager.js +++ b/src/extensionsIntegrated/CustomSnippets/snippetCursorManager.js @@ -529,4 +529,6 @@ define(function (require, exports, module) { exports.handleCursorActivity = handleCursorActivity; exports.endSnippetSession = endSnippetSession; exports.registerHandlers = registerHandlers; + exports.navigateToNextTabStop = navigateToNextTabStop; // exposed for integration testing + exports.navigateToPreviousTabStop = navigateToPreviousTabStop; // exposed for integration testing }); diff --git a/test/UnitTestSuite.js b/test/UnitTestSuite.js index da2d38eed..c84fbec18 100644 --- a/test/UnitTestSuite.js +++ b/test/UnitTestSuite.js @@ -132,6 +132,8 @@ define(function (require, exports, module) { require("spec/Extn-CSSColorPreview-integ-test"); require("spec/Extn-CollapseFolders-integ-test"); require("spec/Extn-Tabbar-integ-test"); + require("spec/Extn-CustomSnippets-test"); + require("spec/Extn-CustomSnippets-integ-test"); // extension integration tests require("spec/Extn-CSSCodeHints-integ-test"); require("spec/Extn-HTMLCodeHints-Lint-integ-test"); diff --git a/test/spec/CustomSnippets-test-files/test.html b/test/spec/CustomSnippets-test-files/test.html new file mode 100644 index 000000000..4d2268652 --- /dev/null +++ b/test/spec/CustomSnippets-test-files/test.html @@ -0,0 +1,10 @@ + + + + + + + diff --git a/test/spec/CustomSnippets-test-files/test.js b/test/spec/CustomSnippets-test-files/test.js new file mode 100644 index 000000000..a1fa2f6b7 --- /dev/null +++ b/test/spec/CustomSnippets-test-files/test.js @@ -0,0 +1 @@ +// test file for custom snippets integration tests diff --git a/test/spec/CustomSnippets-test-files/test.py b/test/spec/CustomSnippets-test-files/test.py new file mode 100644 index 000000000..5731e64d3 --- /dev/null +++ b/test/spec/CustomSnippets-test-files/test.py @@ -0,0 +1 @@ +# test file for custom snippets integration tests diff --git a/test/spec/CustomSnippets-test-files/test.ts b/test/spec/CustomSnippets-test-files/test.ts new file mode 100644 index 000000000..a1fa2f6b7 --- /dev/null +++ b/test/spec/CustomSnippets-test-files/test.ts @@ -0,0 +1 @@ +// test file for custom snippets integration tests diff --git a/test/spec/Extn-CustomSnippets-integ-test.js b/test/spec/Extn-CustomSnippets-integ-test.js new file mode 100644 index 000000000..f7be579a0 --- /dev/null +++ b/test/spec/Extn-CustomSnippets-integ-test.js @@ -0,0 +1,1036 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/*global describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, awaitsForDone, awaitsFor */ + +define(function (require, exports, module) { + + const SpecRunnerUtils = require("spec/SpecRunnerUtils"); + + describe("integration:Custom Snippets Code Hints", function () { + + const testPath = SpecRunnerUtils.getTestPath("/spec/CustomSnippets-test-files"); + + let testWindow, + brackets, + $, + EditorManager, + CommandManager, + Commands, + FileViewController, + CustomSnippetsGlobal, + CustomSnippetsHelper, + CustomSnippetsCursorManager, + CustomSnippetsHandler; + + // Test snippets to inject for each test + const TEST_SNIPPETS = [ + { + abbreviation: "clg", + description: "Console log", + templateText: "console.log(${1});${0}", + fileExtension: ".js, .ts" + }, + { + abbreviation: "clgall", + description: "Console log for all files", + templateText: "console.log(${1});${0}", + fileExtension: "all" + }, + { + abbreviation: "fnn", + description: "Arrow function", + templateText: "const ${1} = (${2}) => {\n ${3}\n};${0}", + fileExtension: ".js, .ts" + }, + { + abbreviation: "pydef", + description: "Python function def", + templateText: "def ${1}(${2}):\n ${3}", + fileExtension: ".py" + }, + { + abbreviation: "divbox", + description: "HTML div box", + templateText: "
\n ${2}\n
${0}", + fileExtension: ".html" + }, + { + abbreviation: "notabs", + description: "Snippet without tab stops", + templateText: "no tabs here", + fileExtension: "all" + }, + { + abbreviation: "clgdup", + description: "Another clg variant", + templateText: "console.log('debug:', ${1});${0}", + fileExtension: ".js" + } + ]; + + let savedSnippetsList = []; + + beforeAll(async function () { + testWindow = await SpecRunnerUtils.createTestWindowAndRun(); + brackets = testWindow.brackets; + $ = testWindow.$; + EditorManager = brackets.test.EditorManager; + CommandManager = brackets.test.CommandManager; + Commands = brackets.test.Commands; + FileViewController = brackets.test.FileViewController; + + // Wait for Custom Snippets extension to be loaded + await awaitsFor(function () { + return brackets.test.CustomSnippetsGlobal !== undefined; + }, "Custom Snippets to be loaded", 10000); + + CustomSnippetsGlobal = brackets.test.CustomSnippetsGlobal; + CustomSnippetsHelper = brackets.test.CustomSnippetsHelper; + CustomSnippetsCursorManager = brackets.test.CustomSnippetsCursorManager; + CustomSnippetsHandler = brackets.test.CustomSnippetsCodeHintHandler; + + // Wait for snippets to finish loading from storage + await brackets.test._customSnippetsLoadedPromise; + + await SpecRunnerUtils.loadProjectInTestWindow(testPath); + }, 60000); + + afterAll(async function () { + testWindow = null; + brackets = null; + $ = null; + EditorManager = null; + CommandManager = null; + Commands = null; + FileViewController = null; + CustomSnippetsGlobal = null; + CustomSnippetsHelper = null; + CustomSnippetsCursorManager = null; + CustomSnippetsHandler = null; + await SpecRunnerUtils.closeTestWindow(); + }, 30000); + + function setupTestSnippets() { + savedSnippetsList = CustomSnippetsGlobal.SnippetHintsList.slice(); + CustomSnippetsGlobal.SnippetHintsList.length = 0; + TEST_SNIPPETS.forEach(function (snippet) { + CustomSnippetsGlobal.SnippetHintsList.push(Object.assign({}, snippet)); + }); + CustomSnippetsHelper.rebuildOptimizedStructures(); + } + + function restoreSnippets() { + CustomSnippetsGlobal.SnippetHintsList.length = 0; + savedSnippetsList.forEach(function (s) { + CustomSnippetsGlobal.SnippetHintsList.push(s); + }); + CustomSnippetsHelper.rebuildOptimizedStructures(); + savedSnippetsList = []; + } + + async function openFile(fileName) { + await awaitsForDone( + FileViewController.openAndSelectDocument( + testPath + "/" + fileName, + FileViewController.PROJECT_MANAGER + ), + "open file: " + fileName + ); + } + + async function closeAllFiles() { + await awaitsForDone( + CommandManager.execute(Commands.FILE_CLOSE_ALL, { _forceClose: true }), + "closing all files" + ); + } + + /** + * Open a file and clear its content for a clean test + */ + async function openCleanFile(fileName) { + await openFile(fileName); + const editor = EditorManager.getActiveEditor(); + editor.document.setText(""); + editor.setCursorPos({line: 0, ch: 0}); + return editor; + } + + /** + * Type text at the current cursor position + */ + function typeAtCursor(editor, text) { + const pos = editor.getCursorPos(); + editor.document.replaceRange(text, pos); + } + + // ================================================================ + // Test Suite: Hint Availability (hasHints) + // ================================================================ + describe("Hint Availability", function () { + + beforeEach(function () { + setupTestSnippets(); + }); + + afterEach(async function () { + if (CustomSnippetsCursorManager.isInSnippetSession()) { + CustomSnippetsCursorManager.endSnippetSession(); + } + restoreSnippets(); + await closeAllFiles(); + }); + + it("should have hints when exact snippet abbreviation is typed in JS file", async function () { + const editor = await openCleanFile("test.js"); + typeAtCursor(editor, "clg"); + + expect(CustomSnippetsHandler.hasHints(editor, "g")).toBeTrue(); + }); + + it("should NOT have hints when implicitChar is null (explicit invocation)", async function () { + const editor = await openCleanFile("test.js"); + typeAtCursor(editor, "clg"); + + expect(CustomSnippetsHandler.hasHints(editor, null)).toBeFalse(); + }); + + it("should NOT have hints for non-matching text", async function () { + const editor = await openCleanFile("test.js"); + typeAtCursor(editor, "xyzabc"); + + expect(CustomSnippetsHandler.hasHints(editor, "c")).toBeFalse(); + }); + + it("should NOT have hints when word before cursor is empty", async function () { + const editor = await openCleanFile("test.js"); + + expect(CustomSnippetsHandler.hasHints(editor, " ")).toBeFalse(); + }); + + it("should NOT have hints for partial match only (no exact abbreviation match)", async function () { + const editor = await openCleanFile("test.js"); + typeAtCursor(editor, "cl"); + + expect(CustomSnippetsHandler.hasHints(editor, "l")).toBeFalse(); + }); + + it("should have hints for case-insensitive abbreviation matching", async function () { + const editor = await openCleanFile("test.js"); + typeAtCursor(editor, "CLG"); + + expect(CustomSnippetsHandler.hasHints(editor, "G")).toBeTrue(); + }); + }); + + // ================================================================ + // Test Suite: Hint Results (getHints) + // ================================================================ + describe("Hint Results", function () { + + beforeEach(function () { + setupTestSnippets(); + }); + + afterEach(async function () { + if (CustomSnippetsCursorManager.isInSnippetSession()) { + CustomSnippetsCursorManager.endSnippetSession(); + } + restoreSnippets(); + await closeAllFiles(); + }); + + it("should return hint objects with data-isCustomSnippet attribute", async function () { + const editor = await openCleanFile("test.js"); + typeAtCursor(editor, "clg"); + + const result = CustomSnippetsHandler.getHints(editor, "g"); + expect(result).toBeTruthy(); + expect(result.hints).toBeTruthy(); + expect(result.hints.length).toBeGreaterThan(0); + + const firstHint = result.hints[0]; + expect(firstHint.attr("data-isCustomSnippet")).toBe("true"); + expect(firstHint.attr("data-val")).toBe("clg"); + }); + + it("should return hints with custom-snippets-hint CSS class", async function () { + const editor = await openCleanFile("test.js"); + typeAtCursor(editor, "clg"); + + const result = CustomSnippetsHandler.getHints(editor, "g"); + expect(result.hints[0].hasClass("custom-snippets-hint")).toBeTrue(); + }); + + it("should return exact match as first hint when partial matches also exist", async function () { + const editor = await openCleanFile("test.js"); + typeAtCursor(editor, "clg"); + + const result = CustomSnippetsHandler.getHints(editor, "g"); + expect(result).toBeTruthy(); + // "clg" matches: clg (exact), clgall (prefix), clgdup (prefix) + expect(result.hints.length).toBeGreaterThan(1); + expect(result.hints[0].attr("data-val")).toBe("clg"); + + const otherVals = result.hints.slice(1).map(function (h) { + return h.attr("data-val"); + }); + expect(otherVals).toContain("clgall"); + expect(otherVals).toContain("clgdup"); + }); + + it("should show snippet indicator label in hint items", async function () { + const editor = await openCleanFile("test.js"); + typeAtCursor(editor, "clg"); + + const result = CustomSnippetsHandler.getHints(editor, "g"); + const snippetLabel = result.hints[0].find(".custom-snippet-code-hint"); + expect(snippetLabel.length).toBe(1); + }); + + it("should show description in hint when description is provided", async function () { + const editor = await openCleanFile("test.js"); + typeAtCursor(editor, "clg"); + + const result = CustomSnippetsHandler.getHints(editor, "g"); + const descElem = result.hints[0].find(".snippet-description"); + expect(descElem.length).toBe(1); + expect(descElem.text()).toBe("Console log"); + }); + + it("should return selectInitial true in hint response", async function () { + const editor = await openCleanFile("test.js"); + typeAtCursor(editor, "clg"); + + const result = CustomSnippetsHandler.getHints(editor, "g"); + expect(result.selectInitial).toBeTrue(); + }); + + it("should return null when no exact abbreviation match exists", async function () { + const editor = await openCleanFile("test.js"); + typeAtCursor(editor, "xyz"); + + const result = CustomSnippetsHandler.getHints(editor, "z"); + expect(result).toBeNull(); + }); + + it("should highlight matching characters in hint text", async function () { + const editor = await openCleanFile("test.js"); + typeAtCursor(editor, "clg"); + + const result = CustomSnippetsHandler.getHints(editor, "g"); + const matchedSpans = result.hints[0].find(".matched-hint"); + expect(matchedSpans.length).toBeGreaterThan(0); + }); + }); + + // ================================================================ + // Test Suite: Language Filtering + // ================================================================ + describe("Language Filtering", function () { + + beforeEach(function () { + setupTestSnippets(); + }); + + afterEach(async function () { + if (CustomSnippetsCursorManager.isInSnippetSession()) { + CustomSnippetsCursorManager.endSnippetSession(); + } + restoreSnippets(); + await closeAllFiles(); + }); + + it("should show JS-scoped snippet in JS file", async function () { + const editor = await openCleanFile("test.js"); + typeAtCursor(editor, "clg"); + + expect(CustomSnippetsHandler.hasHints(editor, "g")).toBeTrue(); + + const result = CustomSnippetsHandler.getHints(editor, "g"); + const hintValues = result.hints.map(function (h) { return h.attr("data-val"); }); + expect(hintValues).toContain("clg"); + }); + + it("should NOT show JS-scoped snippet in HTML file outside script tag", async function () { + await openFile("test.html"); + const editor = EditorManager.getActiveEditor(); + // Line 3 is an empty line inside but outside