diff --git a/src/extensions/default/DebugCommands/MacroRunner.js b/src/extensions/default/DebugCommands/MacroRunner.js index 0ae5f27a77..3dd68a1c5e 100644 --- a/src/extensions/default/DebugCommands/MacroRunner.js +++ b/src/extensions/default/DebugCommands/MacroRunner.js @@ -51,6 +51,7 @@ define(function (require, exports, module) { Commands = brackets.getModule("command/Commands"), PreferencesManager = brackets.getModule("preferences/PreferencesManager"), Editor = brackets.getModule("editor/Editor"), + Dialogs = brackets.getModule("widgets/Dialogs"), _ = brackets.getModule("thirdparty/lodash"), ProjectManager = brackets.getModule("project/ProjectManager"); @@ -312,6 +313,12 @@ define(function (require, exports, module) { } } + function validateNotEqual(obj1, obj2) { + if(_.isEqual(obj1, obj2)){ + throw new Error(`validateEqual: expected ${JSON.stringify(obj1)} to NOT equal ${JSON.stringify(obj2)}`); + } + } + /** * validates if the given mark type is present in the specified selections * @param {string} markType @@ -343,8 +350,8 @@ define(function (require, exports, module) { return jsPromise(CommandManager.execute(Commands.FILE_CLOSE_ALL, { _forceClose: true })); } - function execCommand(commandID, args) { - return jsPromise(CommandManager.execute(commandID, args)); + function execCommand(commandID, arg) { + return jsPromise(CommandManager.execute(commandID, arg)); } function undo() { @@ -383,9 +390,236 @@ define(function (require, exports, module) { } }; + /** + * Waits for a polling function to succeed or until a timeout is reached. + * The polling function is periodically invoked to check for success, and + * the function rejects with a timeout message if the timeout duration elapses. + * + * @param {function} pollFn - A function that returns `true` or a promise resolving to `true`/`false` + * to indicate success and stop waiting. + * The function will be called repeatedly until it succeeds or times out. + * @param {string|function} _timeoutMessageOrMessageFn - A helpful string message or an async function + * that returns a string message to reject with in case of timeout. + * Example: + * - String: "Condition not met within the allowed time." + * - Function: `async () => "Timeout while waiting for the process to complete."` + * @param {number} [timeoutms=2000] - The maximum time to wait in milliseconds before timing out. Defaults to 2 seconds. + * @param {number} [pollInterval=10] - The interval in milliseconds at which `pollFn` is invoked. Defaults to 10ms. + * @returns {Promise} A promise that resolves when `pollFn` succeeds or rejects with a timeout message. + * + * @throws {Error} If `timeoutms` or `pollInterval` is not a number. + * + * @example + * // Example 1: Using a string as the timeout message + * awaitsFor( + * () => document.getElementById("element") !== null, + * "Element did not appear within the allowed time.", + * 5000, + * 100 + * ).then(() => { + * console.log("Element appeared!"); + * }).catch(err => { + * console.error(err.message); + * }); + * + * @example + * // Example 2: Using a function as the timeout message + * awaitsFor( + * () => document.getElementById("element") !== null, + * async () => { + * const el = document.getElementById("element"); + * return `expected ${el} to be null`; + * }, + * 10000, + * 500 + * ).then(() => { + * console.log("Element appeared!"); + * }).catch(err => { + * console.error(err.message); + * }); + */ + function awaitsFor(pollFn, _timeoutMessageOrMessageFn, timeoutms = 2000, pollInterval = 10){ + if(typeof _timeoutMessageOrMessageFn === "number"){ + timeoutms = _timeoutMessageOrMessageFn; + pollInterval = timeoutms; + } + if(!(typeof timeoutms === "number" && typeof pollInterval === "number")){ + throw new Error("awaitsFor: invalid parameters when awaiting for " + _timeoutMessageOrMessageFn); + } + + async function _getExpectMessage(_timeoutMessageOrMessageFn) { + try{ + if(typeof _timeoutMessageOrMessageFn === "function") { + _timeoutMessageOrMessageFn = _timeoutMessageOrMessageFn(); + if(_timeoutMessageOrMessageFn instanceof Promise){ + _timeoutMessageOrMessageFn = await _timeoutMessageOrMessageFn; + } + } + } catch (e) { + _timeoutMessageOrMessageFn = "Error executing expected message function:" + e.stack; + } + return _timeoutMessageOrMessageFn; + } + + function _timeoutPromise(promise, ms) { + const timeout = new Promise((_, reject) => { + setTimeout(async () => { + _timeoutMessageOrMessageFn = await _getExpectMessage(_timeoutMessageOrMessageFn); + reject(new Error(_timeoutMessageOrMessageFn || `Promise timed out after ${ms}ms`)); + }, ms); + }); + + return Promise.race([promise, timeout]); + } + + return new Promise((resolve, reject)=>{ + let startTime = Date.now(), + lapsedTime; + async function pollingFn() { + try{ + let result = pollFn(); + + // If pollFn returns a promise, await it + if (Object.prototype.toString.call(result) === "[object Promise]") { + // we cant simply check for result instanceof Promise as the Promise may be returned from + // an iframe and iframe has a different instance of Promise than this js context. + result = await _timeoutPromise(result, timeoutms); + } + + if (result) { + resolve(); + return; + } + lapsedTime = Date.now() - startTime; + if(lapsedTime>timeoutms){ + _timeoutMessageOrMessageFn = await _getExpectMessage(_timeoutMessageOrMessageFn); + reject("awaitsFor timed out waiting for - " + _timeoutMessageOrMessageFn); + return; + } + setTimeout(pollingFn, pollInterval); + } catch (e) { + reject(e); + } + } + pollingFn(); + }); + } + + async function waitForModalDialog(dialogClass, friendlyName, timeout = 2000) { + dialogClass = dialogClass || ""; + friendlyName = friendlyName || dialogClass || "Modal Dialog"; + await awaitsFor(()=>{ + let $dlg = $(`.modal.instance${dialogClass}`); + return $dlg.length >= 1; + }, `Waiting for Modal Dialog to show ${friendlyName}`, timeout); + } + + async function waitForModalDialogClosed(dialogClass, friendlyName, timeout = 2000) { + dialogClass = dialogClass || ""; + friendlyName = friendlyName || dialogClass || "Modal Dialog"; + await awaitsFor(()=>{ + let $dlg = $(`.modal.instance${dialogClass}`); + return $dlg.length === 0; + }, `Waiting for Modal Dialog to not there ${friendlyName}`, timeout); + } + + /** Clicks on a button within a specified dialog. + * This function identifies a dialog using its class and locates a button either by its selector or button ID. + * Validation to ensure the dialog and button exist and that the button is enabled before attempting to click. + * + * @param {string} selectorOrButtonID - The selector or button ID to identify the button to be clicked. + * Example (as selector): ".my-button-class". + * Example (as button ID): "ok". + * @param {string} dialogClass - The class of the dialog (optional). If omitted, defaults to an empty string. + * Example: "my-dialog-class". + * @param {boolean} isButtonID - If `true`, `selectorOrButtonid` is treated as a button ID. + * If `false`, it is treated as a jQuery selector. Default is `false`. + * + * @throws {Error} Throws an error if: + * - The specified dialog does not exist. + * - Multiple buttons match the given selector or ID. + * - No button matches the given selector or ID. + * - The button is disabled and cannot be clicked. + * + */ + function _clickDialogButtonWithSelector(selectorOrButtonID, dialogClass, isButtonID) { + dialogClass = dialogClass || ""; + const $dlg = $(`.modal.instance${dialogClass}`); + + if(!$dlg.length){ + throw new Error(`No such dialog present: "${dialogClass}"`); + } + + const $button = isButtonID ? + $dlg.find(".dialog-button[data-button-id='" + selectorOrButtonID + "']") : + $dlg.find(selectorOrButtonID); + if($button.length > 1){ + throw new Error(`Multiple button in dialog "${selectorOrButtonID}"`); + } else if(!$button.length){ + throw new Error(`No such button in dialog "${selectorOrButtonID}"`); + } + + if($button.prop("disabled")) { + throw new Error(`Cannot click, button is disabled. "${selectorOrButtonID}"`); + } + + $button.click(); + } + + /** + * Clicks on a button within a specified dialog using its button ID. + * + * @param {string} buttonID - The unique ID of the button to be clicked. usually One of the + * __PR.Dialogs.DIALOG_BTN_* symbolic constants or a custom id. You can find the button + * id in the dialog by inspecting the button and checking its `data-button-id` attribute + * Example: __PR.Dialogs.DIALOG_BTN_OK. + * @param {string} [dialogClass] - The class of the dialog containing the button. Optional, if only one dialog + * is present, you can omit this. + * Example: "my-dialog-class". + * @throws {Error} Throws an error if: + * - The specified dialog does not exist. + * - No button matches the given button ID. + * - Multiple buttons match the given button ID. + * - The button is disabled and cannot be clicked. + * + * @example + * // Example: Click a button by its ID + * __PR.clickDialogButtonID(__PR.Dialogs.DIALOG_BTN_OK, "my-dialog-class"); + * __PR.clickDialogButtonID(__PR.Dialogs.DIALOG_BTN_OK); // if only 1 dialog is present, can omit the dialog class + * __PR.clickDialogButtonID("customBtnID", "my-dialog-class"); + */ + function clickDialogButtonID(buttonID, dialogClass) { + _clickDialogButtonWithSelector(buttonID, dialogClass, true); + } + + /** + * Clicks on a button within a specified dialog using a selector. + * + * @param {string} buttonSelector - A jQuery selector to identify the button to be clicked. + * Example: ".showImageBtn". + * @param {string} [dialogClass] - The class of the dialog containing the button. Optional, if only one dialog + * is present, you can omit this. + * Example: "my-dialog-class". + * @throws {Error} Throws an error if: + * - The specified dialog does not exist. + * - No button matches the given selector. + * - Multiple buttons match the given selector. + * - The button is disabled and cannot be clicked. + * + * @example + * // Example: Click a button using a selector + * __PR.clickDialogButton(".showImageBtn", "my-dialog-class"); + * __PR.clickDialogButton(".showImageBtn"); // if only 1 dialog is present, can omit the dialog class + */ + function clickDialogButton(buttonSelector, dialogClass) { + _clickDialogButtonWithSelector(buttonSelector, dialogClass, false); + } + const __PR= { openFile, setCursors, expectCursorsToBe, keydown, typeAtCursor, validateText, validateAllMarks, validateMarks, - closeFile, closeAll, undo, redo, setPreference, getPreference, validateEqual, EDITING + closeFile, closeAll, undo, redo, setPreference, getPreference, validateEqual, validateNotEqual, execCommand, + awaitsFor, waitForModalDialog, waitForModalDialogClosed, clickDialogButtonID, clickDialogButton, + EDITING, $, Commands, Dialogs }; async function runMacro(macroText) { diff --git a/src/main.js b/src/main.js index 5420371a94..2c98caf5af 100644 --- a/src/main.js +++ b/src/main.js @@ -59,6 +59,17 @@ window.catchToNull = function (promise, logError) { }); }; +/** + * global util to wait for a period of time + * @param waitTimeMs - max time to wait for in ms. + * @returns {Promise} + */ +window.delay = function (waitTimeMs){ + return new Promise((resolve)=>{ + setTimeout(resolve, waitTimeMs); + }); +}; + // splash screen updates for initial install which could take time, or slow networks. let trackedScriptCount = 0; function _setSplashScreenStatusUpdate(message1, message2) { diff --git a/test/SpecRunner.js b/test/SpecRunner.js index da5e79679a..271784d88c 100644 --- a/test/SpecRunner.js +++ b/test/SpecRunner.js @@ -191,6 +191,7 @@ function awaits(waitTimeMs){ window.jsPromise = jsPromise; window.awaitsFor = awaitsFor; window.awaits = awaits; +window.delay = awaits; /** * A safe way to return null on promise fail. This will never reject or throw. * @param promise diff --git a/test/spec/Generic-integ-test.js b/test/spec/Generic-integ-test.js index 86b598a462..e29d267580 100644 --- a/test/spec/Generic-integ-test.js +++ b/test/spec/Generic-integ-test.js @@ -30,6 +30,7 @@ define(function (require, exports, module) { MainViewManager, // loaded from brackets.test CommandManager, Commands, + __PR, SpecRunnerUtils = require("spec/SpecRunnerUtils"); @@ -48,6 +49,7 @@ define(function (require, exports, module) { MainViewManager = testWindow.brackets.test.MainViewManager; CommandManager = testWindow.brackets.test.CommandManager; Commands = testWindow.brackets.test.Commands; + __PR = testWindow.__PR; }, 30000); afterAll(async function () { @@ -58,6 +60,7 @@ define(function (require, exports, module) { MainViewManager = null; CommandManager = null; Commands = null; + __PR = null; await SpecRunnerUtils.closeTestWindow(); }, 30000); @@ -135,6 +138,58 @@ define(function (require, exports, module) { }); }); + describe("Theme settings", function () { + let currentProjectPath; + beforeAll(async function () { + currentProjectPath = await SpecRunnerUtils.getTestPath("/spec/EditorOptionHandlers-test-files"); + await SpecRunnerUtils.loadProjectInTestWindow(currentProjectPath); + await _openProjectFile("test.html"); + }); + + it("should preview line height changes on slider input and restore original on cancel", async function () { + await __PR.execCommand(__PR.Commands.CMD_THEMES_OPEN_SETTINGS); + await __PR.waitForModalDialog(".themeSettings"); + const currentLineHeight = getComputedStyle(__PR.$(".CodeMirror-scroll")[0]).lineHeight; + __PR.$('.fontLineHeightSlider').val(2).trigger('input'); + let newLineHeight = getComputedStyle(__PR.$(".CodeMirror-scroll")[0]).lineHeight; + __PR.validateNotEqual(currentLineHeight, newLineHeight); + + __PR.clickDialogButtonID(__PR.Dialogs.DIALOG_BTN_CANCEL); + await __PR.waitForModalDialogClosed(".themeSettings"); + await __PR.awaitsFor(()=>{ + const lineHeight = getComputedStyle(__PR.$(".CodeMirror-scroll")[0]).lineHeight; + return currentLineHeight === lineHeight; + }, "Waiting for font size to be restored on cancel"); + }); + + it("should save and apply line height changes", async function () { + await __PR.execCommand(__PR.Commands.CMD_THEMES_OPEN_SETTINGS); + await __PR.waitForModalDialog(".themeSettings"); + const originalLineHeight = getComputedStyle(__PR.$(".CodeMirror-scroll")[0]).lineHeight; + const originalVal = __PR.$('.fontLineHeightSlider').val(); + __PR.$('.fontLineHeightSlider').val(2).trigger('input'); + let newLineHeight = getComputedStyle(__PR.$(".CodeMirror-scroll")[0]).lineHeight; + __PR.validateNotEqual(originalLineHeight, newLineHeight); + __PR.clickDialogButtonID("save"); + await __PR.waitForModalDialogClosed(".themeSettings"); + + // now open theme settings again and restore old line height + await __PR.execCommand(__PR.Commands.CMD_THEMES_OPEN_SETTINGS); + await __PR.waitForModalDialog(".themeSettings"); + __PR.validateEqual(__PR.$('.fontLineHeightSlider').val(), "2"); + __PR.$('.fontLineHeightSlider').val(originalVal).trigger('input'); + newLineHeight = getComputedStyle(__PR.$(".CodeMirror-scroll")[0]).lineHeight; + __PR.validateEqual(originalLineHeight, newLineHeight); + __PR.clickDialogButtonID("save"); + await __PR.waitForModalDialogClosed(".themeSettings"); + + await __PR.awaitsFor(()=>{ + newLineHeight = getComputedStyle(__PR.$(".CodeMirror-scroll")[0]).lineHeight; + return originalLineHeight === newLineHeight; + }, "Waiting for font size to be restored on cancel"); + }); + }); + describe("reopen closed files test", function () { let currentProjectPath; beforeAll(async function () {