diff --git a/src/index.html b/src/index.html index 136ec1f456..a804594e2d 100644 --- a/src/index.html +++ b/src/index.html @@ -173,8 +173,10 @@ // this means that we are loaded in an iframe in the specrunner. Electron doesnt expose APIs // in its iframes, so we directly use the top most windows electron api objects for tests to run properly. console.warn("Phoenix is loaded in iframe, attempting to use electron APIs from window.top"); + window.__ELECTRON__ = true; window.electronAppAPI = window.top.window.electronAppAPI; window.electronFSAPI = window.top.window.electronFSAPI; + window.electronAPI = window.top.window.electronAPI; } if(window.__TAURI__ || window.__ELECTRON__) { window.__IS_NATIVE_SHELL__ = true; diff --git a/src/phoenix/shell.js b/src/phoenix/shell.js index f6796f5122..c9e3b4ed0c 100644 --- a/src/phoenix/shell.js +++ b/src/phoenix/shell.js @@ -66,6 +66,25 @@ async function _getTauriWindowLabel(prefix) { throw new Error("Could not get a free window label to create tauri window"); } +/** + * Opens a URL in a new Phoenix window. Works across all platforms (Tauri, Electron, browser). + * + * @param {string} url - The URL to open in the new window + * @param {Object} [options] - Window configuration options + * @param {string} [options.windowTitle] - Title for the window (defaults to label or URL) + * @param {boolean} [options.fullscreen] - Whether to open in fullscreen mode + * @param {boolean} [options.resizable=true] - Whether the window is resizable + * @param {number} [options.height=900] - Window height in pixels + * @param {number} [options.minHeight=600] - Minimum window height in pixels + * @param {number} [options.width=1366] - Window width in pixels + * @param {number} [options.minWidth=800] - Minimum window width in pixels + * @param {boolean} [options.acceptFirstMouse=true] - (Tauri only) Accept first mouse click + * @param {boolean} [options.preferTabs] - (Browser only) Prefer opening in a new tab + * @param {string} [options._prefixPvt] - Internal: window label prefix + * @returns {Promise<{label: string, isNativeWindow: boolean}>} Window object with `label` and `isNativeWindow` properties. + * - In Tauri/Electron: `{ label: string, isNativeWindow: true }` (Tauri returns WebviewWindow instance with these props) + * - In browser: Returns window.open() result with `isNativeWindow: false` + */ async function openURLInPhoenixWindow(url, { windowTitle, fullscreen, resizable, height, minHeight, width, minWidth, acceptFirstMouse, preferTabs, _prefixPvt = PHOENIX_EXTENSION_WINDOW_PREFIX @@ -242,6 +261,27 @@ Phoenix.app = { return window.electronAPI.focusWindow(); } }, + /** + * Closes a window by its label. Returns true if window was found and closed, false otherwise. + * @param {string} label - The window label to close + * @return {Promise} + */ + closeWindowByLabel: async function (label) { + if(!Phoenix.isNativeApp){ + throw new Error("closeWindowByLabel is not supported in browsers"); + } + if(window.__TAURI__){ + const win = window.__TAURI__.window.WebviewWindow.getByLabel(label); + if(win){ + await win.close(); + return true; + } + return false; + } else if(window.__ELECTRON__){ + return window.electronAPI.closeWindowByLabel(label); + } + return false; + }, /** * Gets the commandline argument in desktop builds and null in browser builds. * Will always return CLI of the current process only. @@ -689,7 +729,72 @@ Phoenix.app = { getTimeSinceStartup: function () { return Date.now() - Phoenix.startTime; // milliseconds elapsed since app start }, - language: navigator.language + language: navigator.language, + /** + * Broadcast an event to all windows (excludes sender). + * @param {string} eventName - Name of the event + * @param {*} payload - Event data + * @returns {Promise} + */ + emitToAllWindows: async function (eventName, payload) { + if (!Phoenix.isNativeApp) { + throw new Error("emitToAllWindows is not supported in browsers"); + } + if (window.__TAURI__) { + return window.__TAURI__.event.emit(eventName, payload); + } + if (window.__ELECTRON__) { + return window.electronAPI.emitToAllWindows(eventName, payload); + } + }, + /** + * Send an event to a specific window by label. + * @param {string} targetLabel - Window label to send to + * @param {string} eventName - Name of the event + * @param {*} payload - Event data + * @returns {Promise} True if window found and event sent + */ + emitToWindow: async function (targetLabel, eventName, payload) { + if (!Phoenix.isNativeApp) { + throw new Error("emitToWindow is not supported in browsers"); + } + if (window.__TAURI__) { + // Tauri doesn't have direct window-to-window emit, use global emit + // The listener filters by source if needed + return window.__TAURI__.event.emit(eventName, payload); + } + if (window.__ELECTRON__) { + return window.electronAPI.emitToWindow(targetLabel, eventName, payload); + } + return false; + }, + /** + * Listen for events from other windows. + * @param {string} eventName - Name of the event to listen for + * @param {Function} callback - Called with (payload) when event received + * @returns {Function} Unlisten function to remove the listener + */ + onWindowEvent: function (eventName, callback) { + if (!Phoenix.isNativeApp) { + throw new Error("onWindowEvent is not supported in browsers"); + } + if (window.__TAURI__) { + let unlisten = null; + window.__TAURI__.event.listen(eventName, (event) => { + callback(event.payload); + }).then(fn => { unlisten = fn; }); + // Return a function that will unlisten when called + return () => { + if (unlisten) { + unlisten(); + } + }; + } + if (window.__ELECTRON__) { + return window.electronAPI.onWindowEvent(eventName, callback); + } + return () => {}; // No-op for unsupported platforms + } }; if(!window.appshell){ diff --git a/test/SpecRunner.html b/test/SpecRunner.html index 9d910e9beb..1665f48c24 100644 --- a/test/SpecRunner.html +++ b/test/SpecRunner.html @@ -207,6 +207,17 @@ }); } setupElectronBootVars(); + // F12 to toggle dev tools in Electron + document.addEventListener('keydown', function(e) { + if (e.key === 'F12') { + e.preventDefault(); + window.electronAPI.toggleDevTools(); + } + if (e.key === 'F5') { + e.preventDefault(); + location.reload(); + } + }); } diff --git a/test/SpecRunner.js b/test/SpecRunner.js index 2fa1677ed1..0c17e0ace0 100644 --- a/test/SpecRunner.js +++ b/test/SpecRunner.js @@ -44,7 +44,8 @@ require.config({ "thirdparty/preact": "preact-compat", "thirdparty/preact-test-utils": "preact-test-utils" } - } + }, + locale: "en" // force English (US) for consistent test strings }); window.logger = { diff --git a/test/UnitTestReporter.js b/test/UnitTestReporter.js index 995b623066..9894b543d4 100644 --- a/test/UnitTestReporter.js +++ b/test/UnitTestReporter.js @@ -65,18 +65,38 @@ define(function (require, exports, module) { return ''; } + function hasCliFlag(args, flagName, shortFlag) { + return args.some(arg => + arg === `--${flagName}` || + arg.startsWith(`--${flagName}=`) || + (shortFlag && arg === `-${shortFlag}`) + ); + } + function quitIfNeeded(exitStatus) { - if(!window.__TAURI__){ + const isTauri = !!window.__TAURI__; + const isElectron = !!window.electronAppAPI?.isElectron; + + if (!isTauri && !isElectron) { return; } + const WAIT_TIME_TO_COMPLETE_TEST_LOGGING_SEC = 10; console.log("Scheduled Quit in Seconds: ", WAIT_TIME_TO_COMPLETE_TEST_LOGGING_SEC); - setTimeout(()=>{ - window.__TAURI__.cli.getMatches().then(matches=>{ - if(matches && matches.args["quit-when-done"] && matches.args["quit-when-done"].occurrences) { - window.__TAURI__.process.exit(exitStatus); - } - }); + setTimeout(() => { + if (isTauri) { + window.__TAURI__.cli.getMatches().then(matches => { + if (matches && matches.args["quit-when-done"] && matches.args["quit-when-done"].occurrences) { + window.__TAURI__.process.exit(exitStatus); + } + }); + } else if (isElectron) { + window.electronAppAPI.getCliArgs().then(args => { + if (hasCliFlag(args, 'quit-when-done', 'q')) { + window.electronAppAPI.quitApp(exitStatus); + } + }); + } }, WAIT_TIME_TO_COMPLETE_TEST_LOGGING_SEC * 1000); } diff --git a/test/UnitTestSuite.js b/test/UnitTestSuite.js index e3f9a3e524..f2dd6c5905 100644 --- a/test/UnitTestSuite.js +++ b/test/UnitTestSuite.js @@ -22,7 +22,7 @@ define(function (require, exports, module) { require("spec/Phoenix-platform-test"); - require("spec/Tauri-platform-test"); + require("test/spec/Native-platform-test"); require("spec/trust-ring-test"); require("spec/utframework-suite-test"); require("spec/Async-test"); diff --git a/test/index-dist-test.html b/test/index-dist-test.html index b0390820bb..63dbe9ba79 100644 --- a/test/index-dist-test.html +++ b/test/index-dist-test.html @@ -4,20 +4,48 @@ Starting tests... diff --git a/test/spec/ExtensionInstallation-test.js b/test/spec/ExtensionInstallation-test.js index 8c91e42ca9..b8d2120ba8 100644 --- a/test/spec/ExtensionInstallation-test.js +++ b/test/spec/ExtensionInstallation-test.js @@ -31,7 +31,8 @@ define(function (require, exports, module) { const testFilePath = SpecRunnerUtils.getTestPath("/spec/extension-test-files"); - const tempDirectory = window.__TAURI__ ? Phoenix.VFS.getTauriAssetServeDir() + "tests": SpecRunnerUtils.getTempDirectory(); + const tempDirectory = Phoenix.isNativeApp ? + Phoenix.VFS.getTauriAssetServeDir() + "tests": SpecRunnerUtils.getTempDirectory(); const extensionsRoot = tempDirectory + "/extensions"; const basicValidSrc = testFilePath + "/basic-valid-extension.zip", @@ -79,7 +80,7 @@ define(function (require, exports, module) { beforeAll(async function () { await SpecRunnerUtils.ensureExistsDirAsync(tempDirectory); - if(window.__TAURI__){ + if(Phoenix.isNativeApp){ basicValid = tempDirectory + "/basic-valid-extension.zip"; missingNameVersion = tempDirectory + "/missing-name-version.zip"; missingNameVersion = tempDirectory + "/missing-name-version.zip"; diff --git a/test/spec/ExtensionLoader-test.js b/test/spec/ExtensionLoader-test.js index 0b06581e3d..291b3efedc 100644 --- a/test/spec/ExtensionLoader-test.js +++ b/test/spec/ExtensionLoader-test.js @@ -32,7 +32,7 @@ define(function (require, exports, module) { SpecRunnerUtils = require("spec/SpecRunnerUtils"); const testPathSrc = SpecRunnerUtils.getTestPath("/spec/ExtensionLoader-test-files"); - const testPath = window.__TAURI__ ? Phoenix.VFS.getTauriAssetServeDir() + "tests": SpecRunnerUtils.getTempDirectory(); + const testPath = Phoenix.isNativeApp ? Phoenix.VFS.getTauriAssetServeDir() + "tests": SpecRunnerUtils.getTempDirectory(); describe("ExtensionLoader", function () { diff --git a/test/spec/ExtensionManager-test.js b/test/spec/ExtensionManager-test.js index 722982895a..2571c33285 100644 --- a/test/spec/ExtensionManager-test.js +++ b/test/spec/ExtensionManager-test.js @@ -49,7 +49,7 @@ define(function (require, exports, module) { mockExtensionList = require("text!spec/ExtensionManager-test-files/mockExtensionList.json"), mockRegistry; - const testPath = window.__TAURI__ ? + const testPath = Phoenix.isNativeApp ? Phoenix.VFS.getTauriAssetServeDir() + "tests" : SpecRunnerUtils.getTempDirectory(); const testSrc = SpecRunnerUtils.getTestPath("/spec/ExtensionManager-test-files"); @@ -808,6 +808,12 @@ define(function (require, exports, module) { }); }); + afterEach(function () { + // Clean up any lingering dialogs + Dialogs.cancelModalDialogIfOpen("install-extension-dialog"); + $(".modal-wrapper").remove(); + }); + it("should set flag to keep local files for new installs", async function () { var filename = "/path/to/downloaded/file.zip", file = FileSystem.getFileForPath(filename), diff --git a/test/spec/LowLevelFileIO-test.js b/test/spec/LowLevelFileIO-test.js index 5fe7063fd2..3ec05e1cfc 100644 --- a/test/spec/LowLevelFileIO-test.js +++ b/test/spec/LowLevelFileIO-test.js @@ -797,7 +797,7 @@ define(function (require, exports, module) { describe("specialDirectories", function () { it("should have an application support directory", async function () { // these tests are here as these are absolute unchanging dir convention used by phoenix. - if(window.__TAURI__){ + if(Phoenix.isNativeApp){ const appSupportDIR = window.fs.getTauriVirtualPath(window._tauriBootVars.appLocalDir); expect(brackets.app.getApplicationSupportDirectory().startsWith("/tauri/")).toBeTrue(); expect(brackets.app.getApplicationSupportDirectory()).toBe(appSupportDIR); @@ -807,7 +807,7 @@ define(function (require, exports, module) { }); it("should have a user documents directory", function () { // these tests are here as these are absolute unchanging dir convention used by phoenix. - if(window.__TAURI__){ + if(Phoenix.isNativeApp){ const documentsDIR = window.fs.getTauriVirtualPath(window._tauriBootVars.documentDir); expect(brackets.app.getUserDocumentsDirectory().startsWith("/tauri/")).toBeTrue(); expect(brackets.app.getUserDocumentsDirectory()).toBe(documentsDIR); @@ -817,7 +817,7 @@ define(function (require, exports, module) { }); it("should have a user projects directory", function () { // these tests are here as these are absolute unchanging dir convention used by phoenix. - if(window.__TAURI__){ + if(Phoenix.isNativeApp){ const documentsDIR = window.fs.getTauriVirtualPath(window._tauriBootVars.documentDir); const appName = window._tauriBootVars.appname; const userProjectsDir = `${documentsDIR}${appName}/`; @@ -829,7 +829,7 @@ define(function (require, exports, module) { }); it("should have a temp directory", function () { // these tests are here as these are absolute unchanging dir convention used by phoenix. - if(window.__TAURI__){ + if(Phoenix.isNativeApp){ let tempDIR = window.fs.getTauriVirtualPath(window._tauriBootVars.tempDir); if(!tempDIR.endsWith("/")){ tempDIR = `${tempDIR}/`; @@ -844,7 +844,7 @@ define(function (require, exports, module) { }); it("should have extensions directory", function () { // these tests are here as these are absolute unchanging dir convention used by phoenix. - if(window.__TAURI__){ + if(Phoenix.isNativeApp){ const appSupportDIR = window.fs.getTauriVirtualPath(window._tauriBootVars.appLocalDir); const extensionsDir = `${appSupportDIR}assets/extensions/`; expect(brackets.app.getExtensionsDirectory().startsWith("/tauri/")).toBeTrue(); @@ -855,7 +855,7 @@ define(function (require, exports, module) { }); it("should get virtual serving directory from virtual serving URL in browser", async function () { - if(window.__TAURI__){ + if(Phoenix.isNativeApp){ return; } expect(brackets.VFS.getPathForVirtualServingURL(`${window.fsServerUrl}blinker`)).toBe("/blinker"); @@ -866,7 +866,7 @@ define(function (require, exports, module) { }); it("should not get virtual serving directory from virtual serving URL in tauri", async function () { - if(!window.__TAURI__){ + if(!Phoenix.isNativeApp){ return; } expect(window.fsServerUrl).not.toBeDefined(); diff --git a/test/spec/Native-platform-test.js b/test/spec/Native-platform-test.js new file mode 100644 index 0000000000..0e4ca767c3 --- /dev/null +++ b/test/spec/Native-platform-test.js @@ -0,0 +1,579 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * Original work Copyright (c) 2013 - 2021 Adobe Systems Incorporated. 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, beforeEach, afterEach, fs, path, jasmine, expectAsync*/ + +define(function (require, exports, module) { + // Platform detection + const isElectron = !!window.__ELECTRON__; + const isTauri = !!window.__TAURI__; + + if (!isElectron && !isTauri) { + return; + } + + const SpecRunnerUtils = require("spec/SpecRunnerUtils"); + + // Platform abstraction helpers - same tests, different API calls + const platform = { + name: isElectron ? 'Electron' : 'Tauri', + + // Path APIs + appLocalDataDir: () => isElectron + ? window.electronFSAPI.appLocalDataDir() + : window.__TAURI__.path.appLocalDataDir(), + + documentDir: () => isElectron + ? window._tauriBootVars.documentDir // Same source for both + : window._tauriBootVars.documentDir, + + // Asset URL conversion + convertToAssetURL: (platformPath) => isElectron + ? window.electronAPI.convertToAssetURL(platformPath) + : window.__TAURI__.tauri.convertFileSrc(platformPath), + + // Credential APIs + storeCredential: (scopeName, secretVal) => isElectron + ? window.electronAPI.storeCredential(scopeName, secretVal) + : window.__TAURI__.invoke("store_credential", { scopeName, secretVal }), + + getCredential: (scopeName) => isElectron + ? window.electronAPI.getCredential(scopeName) + : window.__TAURI__.invoke("get_credential", { scopeName }), + + deleteCredential: (scopeName) => isElectron + ? window.electronAPI.deleteCredential(scopeName) + : window.__TAURI__.invoke("delete_credential", { scopeName }), + + // Trust ring APIs + trustWindowAesKey: (keyIv) => isElectron + ? window.electronAPI.trustWindowAesKey(keyIv.key, keyIv.iv) + : window.__TAURI__.tauri.invoke("trust_window_aes_key", keyIv), + + removeTrustWindowAesKey: (keyIv) => isElectron + ? window.electronAPI.removeTrustWindowAesKey(keyIv.key, keyIv.iv) + : window.__TAURI__.tauri.invoke("remove_trust_window_aes_key", keyIv), + + // Window management - returns platform-specific window object + // For window spawning tests, we use a helper HTML file + getTestHtmlPath: () => isElectron + ? 'spec/native-platform-electron-test.html' + : 'spec/native-platform-tauri-test.html', + + // Close window by label (uses platform-agnostic Phoenix.app API) + closeWindow: (windowObj) => Phoenix.app.closeWindowByLabel(windowObj.label) + }; + + describe(`unit: ${platform.name} Platform Tests`, function () { + + beforeEach(async function () { + + }); + + afterEach(async function () { + + }); + + describe("asset url tests", function () { + it("Should be able to fetch files in {appLocalData}/assets folder", async function () { + const appLocalData = fs.getTauriVirtualPath(await platform.appLocalDataDir()); + expect(await SpecRunnerUtils.pathExists(appLocalData, true)).toBeTrue(); + expect(appLocalData.split("/")[1]).toEql("tauri"); // should be /tauri/applocaldata/path + + // now write a test html file to the assets folder + const assetHTMLPath = `${appLocalData}/assets/a9322657236.html`; + const assetHtmlText = "Hello world random37834324"; + await SpecRunnerUtils.ensureExistsDirAsync(path.dirname(assetHTMLPath)); + await SpecRunnerUtils.createTextFileAsync(assetHTMLPath, assetHtmlText); + + const appLocalDataPlatformPath = fs.getTauriPlatformPath(assetHTMLPath); + const appLocalDataURL = platform.convertToAssetURL(appLocalDataPlatformPath); + + const fetchedData = await ((await fetch(appLocalDataURL)).text()); + expect(fetchedData).toEqual(assetHtmlText); + + // delete test file + await SpecRunnerUtils.deletePathAsync(assetHTMLPath); + }); + + async function testAssetNotAccessibleFolder(platformPath) { + const assets = fs.getTauriVirtualPath(platformPath); + expect(assets.split("/")[1]).toEql("tauri"); // should be /tauri/applocaldata/path + + // now write a test html file to the assets folder + const assetHTMLPath = `${assets}/a9322657236.html`; + const assetHtmlText = "Hello world random37834324"; + await SpecRunnerUtils.createTextFileAsync(assetHTMLPath, assetHtmlText); + + const appLocalDataPlatformPath = fs.getTauriPlatformPath(assetHTMLPath); + const appLocalDataURL = platform.convertToAssetURL(appLocalDataPlatformPath); + + // Tauri throws an error, Electron returns 403 response + let accessDenied = false; + try { + const response = await fetch(appLocalDataURL); + // Electron returns 403 for unauthorized access + if (!response.ok) { + accessDenied = true; + } + } catch (e) { + // Tauri throws an error + accessDenied = true; + } + expect(accessDenied).withContext("Asset URL should not be accessible outside assets folder").toBeTrue(); + + // delete test file + await SpecRunnerUtils.deletePathAsync(assetHTMLPath); + } + + it("Should not be able to fetch files in documents folder", async function () { + // unfortunately for tests, this is set to appdata/testDocuments. + // we cant set this to await window.__TAURI__.path.documentDir() as in github actions, + // the user documents directory is not defined in rust and throws. + await testAssetNotAccessibleFolder(platform.documentDir()); + }); + + it("Should not be able to fetch files in appLocalData folder", async function () { + await testAssetNotAccessibleFolder(await platform.appLocalDataDir()); + }); + + // Electron-specific security test: verify asset:// URLs don't have API access + if (isElectron) { + it("Should NOT have electronAPI access from asset:// protocol", async function () { + // This test verifies that content loaded from asset:// URLs is sandboxed + // and does not have access to Electron APIs (matching Tauri's security posture) + const appLocalData = fs.getTauriVirtualPath(await platform.appLocalDataDir()); + const securityTestPath = `${appLocalData}/assets/security-test-${Date.now()}.html`; + const SECURITY_TEST_KEY = 'ELECTRON_ASSET_SECURITY_TEST_' + Date.now(); + + // Create the security test HTML in assets folder + // This script will try to use electronAPI if available and report back + const securityTestHtml = ` +Security Test`; + + await SpecRunnerUtils.ensureExistsDirAsync(path.dirname(securityTestPath)); + await SpecRunnerUtils.createTextFileAsync(securityTestPath, securityTestHtml); + + // Get the asset:// URL for the test file + const platformPath = fs.getTauriPlatformPath(securityTestPath); + const assetURL = platform.convertToAssetURL(platformPath); + + // Clear any previous test result + await window.electronAPI.putItem(SECURITY_TEST_KEY, null); + + // Try to open a window with the asset:// URL + let windowLabel = null; + try { + windowLabel = await window.electronAPI.createPhoenixWindow(assetURL, { + windowTitle: 'Security Test', + width: 400, + height: 300, + isExtension: true + }); + } catch (e) { + // If window creation fails for asset:// URLs, that's acceptable security behavior + console.log("Window creation blocked for asset:// URL (expected):", e.message); + } + + if (windowLabel) { + // Window was created - wait for it to load and potentially try to use APIs + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Check if the sandboxed window was able to use electronAPI + const items = await window.electronAPI.getAllItems(); + const testResult = items[SECURITY_TEST_KEY]; + + if (testResult) { + const parsed = JSON.parse(testResult); + // SECURITY CHECK: If we got a result, verify no API access was possible + // If any of these are true, it's a security vulnerability + expect(parsed.hasElectronAPI).withContext( + "SECURITY VIOLATION: asset:// window has electronAPI access" + ).toBeFalse(); + expect(parsed.hasElectronFSAPI).withContext( + "SECURITY VIOLATION: asset:// window has electronFSAPI access" + ).toBeFalse(); + expect(parsed.hasElectronAppAPI).withContext( + "SECURITY VIOLATION: asset:// window has electronAppAPI access" + ).toBeFalse(); + } + // If no result was stored, the window couldn't access APIs - test passes + + // Close the security test window by its label + try { + await Phoenix.app.closeWindowByLabel(windowLabel); + } catch (e) { + console.warn("Could not close security test window:", e); + } + } + + // Cleanup + await SpecRunnerUtils.deletePathAsync(securityTestPath); + }); + } + + function createWebView() { + return new Promise((resolve, reject) => { + let currentURL = new URL(location.href); + let pathParts = currentURL.pathname.split('/'); + pathParts[pathParts.length - 1] = platform.getTestHtmlPath(); + currentURL.pathname = pathParts.join('/'); + + let newURL = currentURL.href; + + // Use unified event API for both platforms + let nativeWindow = null; + let eventReceived = false; + + const tryResolve = () => { + if (nativeWindow && eventReceived) { + resolve(nativeWindow); + } + }; + + const unlisten = Phoenix.app.onWindowEvent('PLATFORM_API_WORKING', () => { + unlisten(); + eventReceived = true; + tryResolve(); + }); + + Phoenix.app.openURLInPhoenixWindow(newURL) + .then(win => { + expect(win.label.startsWith("extn-")).toBeTrue(); + expect(win.isNativeWindow).toBeTrue(); + nativeWindow = win; + tryResolve(); + }).catch(err => { + unlisten(); + reject(err); + }); + }); + } + + it("Should be able to spawn windows", async function () { + const nativeWindow = await createWebView(); + await platform.closeWindow(nativeWindow); + }); + + it("Should be able to get process ID", async function () { + const processID = await Phoenix.app.getProcessID(); + expect(processID).toEqual(jasmine.any(Number)); + }); + + const maxWindows = 25; + it(`Should be able to spawn ${maxWindows} windows`, async function () { + const nativeWindows = []; + for (let i = 0; i < maxWindows; i++) { + nativeWindows.push(await createWebView()); + } + for (let i = 0; i < maxWindows; i++) { + await platform.closeWindow(nativeWindows[i]); + } + // Wait for windows to close + await new Promise(resolve => setTimeout(resolve, 1000)); + }, 120000); + }); + + describe("Inter-window Event API Tests", function () { + // Note: emitToAllWindows excludes the sender, so we test cross-window communication + // using spawned windows that emit PLATFORM_API_WORKING event + + it("Should receive events from spawned windows using unified API", async function () { + let eventReceived = false; + let receivedPayload = null; + const unlisten = Phoenix.app.onWindowEvent('PLATFORM_API_WORKING', (payload) => { + eventReceived = true; + receivedPayload = payload; + }); + + // Small delay for listener registration (Tauri's listen is async) + await new Promise(resolve => setTimeout(resolve, 100)); + + let currentURL = new URL(location.href); + let pathParts = currentURL.pathname.split('/'); + pathParts[pathParts.length - 1] = platform.getTestHtmlPath(); + currentURL.pathname = pathParts.join('/'); + + const win = await Phoenix.app.openURLInPhoenixWindow(currentURL.href); + expect(win.label.startsWith("extn-")).toBeTrue(); + + // Wait for the spawned window to emit the event + await new Promise(resolve => setTimeout(resolve, 1000)); + + expect(eventReceived).toBeTrue(); + expect(receivedPayload).toBeDefined(); + + unlisten(); + await platform.closeWindow(win); + }); + + it("Should unlisten properly and not receive events after unlisten", async function () { + let callCount = 0; + const unlisten = Phoenix.app.onWindowEvent('PLATFORM_API_WORKING', () => { + callCount++; + }); + + // Small delay for listener registration + await new Promise(resolve => setTimeout(resolve, 100)); + + // Spawn first window - should receive event + let currentURL = new URL(location.href); + let pathParts = currentURL.pathname.split('/'); + pathParts[pathParts.length - 1] = platform.getTestHtmlPath(); + currentURL.pathname = pathParts.join('/'); + + const win1 = await Phoenix.app.openURLInPhoenixWindow(currentURL.href); + await new Promise(resolve => setTimeout(resolve, 1000)); + expect(callCount).toBeGreaterThanOrEqual(1); + const countAfterFirst = callCount; + + // Unlisten + unlisten(); + await new Promise(resolve => setTimeout(resolve, 100)); + + // Spawn second window - should NOT receive event + const win2 = await Phoenix.app.openURLInPhoenixWindow(currentURL.href); + await new Promise(resolve => setTimeout(resolve, 1000)); + expect(callCount).toEqual(countAfterFirst); // Count should not increase + + await platform.closeWindow(win1); + await platform.closeWindow(win2); + }); + + it("Should not throw when emitting events", async function () { + // Basic sanity test that emit APIs don't throw + await expectAsync( + Phoenix.app.emitToAllWindows('TEST_EVENT', { test: true }) + ).toBeResolved(); + + await expectAsync( + Phoenix.app.emitToWindow('nonexistent-window', 'TEST_EVENT', { test: true }) + ).toBeResolved(); + }); + + it("Should return unlisten function from onWindowEvent", function () { + const unlisten = Phoenix.app.onWindowEvent('TEST_EVENT', () => {}); + expect(typeof unlisten).toEqual('function'); + unlisten(); // Should not throw + }); + }); + + describe("Credentials OTP API Tests", function () { + const scopeName = "testScope"; + const trustRing = window.specRunnerTestKernalModeTrust; + const TEST_TRUST_KEY_NAME = "testTrustKey"; + + function decryptCreds(creds) { + return trustRing.AESDecryptString(creds, trustRing.aesKeys.key, trustRing.aesKeys.iv); + } + + beforeEach(async function () { + // Cleanup before running tests + await platform.deleteCredential(scopeName).catch(() => {}); + }); + + afterEach(async function () { + // Cleanup after tests + await platform.deleteCredential(scopeName).catch(() => {}); + }); + + if(Phoenix.isTestWindowGitHubActions && Phoenix.platform === "linux"){ + // Credentials test doesn't work in GitHub actions in linux desktop as the runner cant reach key ring. + it("Should not run in github actions in linux desktop", async function () { + expect(1).toEqual(1); + }); + return; + } + + describe("Credential Storage & OTP Generation", function () { + it("Should store credentials successfully", async function () { + const randomUUID = crypto.randomUUID(); + await expectAsync( + platform.storeCredential(scopeName, randomUUID) + ).toBeResolved(); + }); + + it("Should get credentials as encrypted string", async function () { + const randomUUID = crypto.randomUUID(); + await platform.storeCredential(scopeName, randomUUID); + + const response = await platform.getCredential(scopeName); + expect(response).toBeDefined(); + expect(response).not.toEqual(randomUUID); + }); + + it("Should retrieve and decrypt set credentials with kernal mode keys", async function () { + const randomUUID = crypto.randomUUID(); + await platform.storeCredential(scopeName, randomUUID); + + const creds = await platform.getCredential(scopeName); + expect(creds).toBeDefined(); + const decryptedString = await decryptCreds(creds); + expect(decryptedString).toEqual(randomUUID); + }); + + it("Should return null if credentials do not exist", async function () { + const response = await platform.getCredential(scopeName); + expect(response).toBeNull(); + }); + + it("Should delete stored credentials", async function () { + const randomUUID = crypto.randomUUID(); + await platform.storeCredential(scopeName, randomUUID); + + // Ensure credential exists + let creds = await platform.getCredential(scopeName); + expect(creds).toBeDefined(); + + // Delete credential + await expectAsync( + platform.deleteCredential(scopeName) + ).toBeResolved(); + + // Ensure credential is deleted + creds = await platform.getCredential(scopeName); + expect(creds).toBeNull(); + }); + + it("Should handle deletion of non-existent credentials gracefully", async function () { + let error; + try { + await platform.deleteCredential(scopeName); + } catch (err) { + error = err; + } + + // The test should fail if no error was thrown + expect(error).toBeDefined(); + + // Check for OS-specific error messages + const expectedErrors = [ + "No matching entry found in secure storage", // Common error on Linux/macOS + "The specified item could not be found in the keychain", // macOS Keychain + "Element not found" // Windows Credential Manager + ]; + + const isExpectedError = expectedErrors.some(msg => error.toString().includes(msg)); + expect(isExpectedError).toBeTrue(); + }); + + it("Should overwrite existing credentials when storing with the same scope", async function () { + const oldUUID = crypto.randomUUID(); + await platform.storeCredential(scopeName, oldUUID); + + let creds = await platform.getCredential(scopeName); + expect(creds).toBeDefined(); + let response = await decryptCreds(creds); + expect(response).toEqual(oldUUID); + + // Store new credentials with the same scope + const newUUID = crypto.randomUUID(); + await platform.storeCredential(scopeName, newUUID); + + creds = await platform.getCredential(scopeName); + expect(creds).toBeDefined(); + response = await decryptCreds(creds); + expect(response).toEqual(newUUID); + }); + + // trustRing.getCredential and set tests + async function setSomeKey() { + const randomCred = crypto.randomUUID(); + await trustRing.setCredential(TEST_TRUST_KEY_NAME, randomCred); + const savedCred = await trustRing.getCredential(TEST_TRUST_KEY_NAME); + expect(savedCred).toEqual(randomCred); + return savedCred; + } + + it("Should get and set API key in kernal mode trust ring", async function () { + await setSomeKey(); + }); + + it("Should get and set empty string API key in kernal mode trust ring", async function () { + const randomCred = ""; + await trustRing.setCredential(TEST_TRUST_KEY_NAME, randomCred); + const savedCred = await trustRing.getCredential(TEST_TRUST_KEY_NAME); + expect(savedCred).toEqual(randomCred); + }); + + it("Should remove API key in kernal mode trust ring work as expected", async function () { + await setSomeKey(); + await trustRing.removeCredential(TEST_TRUST_KEY_NAME); + const cred = await trustRing.getCredential(TEST_TRUST_KEY_NAME); + expect(cred).toBeNull(); + }); + + // trust key management + it("Should not be able to set trust key if one is already set", async function () { + const kv = trustRing.generateRandomKeyAndIV(); + let error; + try { + await platform.trustWindowAesKey(kv); + } catch (err) { + error = err; + } + expect(error.toString()).toContain("Trust has already been established for this window."); + }); + + it("Should be able to remove trust key with key and iv", async function () { + await platform.removeTrustWindowAesKey(trustRing.aesKeys); + let error; + try { + await platform.removeTrustWindowAesKey(trustRing.aesKeys); + } catch (err) { + error = err; + } + expect(error.toString()).toContain("No trust association found for this window."); + // reinstate trust + await platform.trustWindowAesKey(trustRing.aesKeys); + }); + + it("Should getCredential not work without trust", async function () { + await setSomeKey(); + await platform.removeTrustWindowAesKey(trustRing.aesKeys); + let error; + try { + await trustRing.getCredential(TEST_TRUST_KEY_NAME); + } catch (err) { + error = err; + } + expect(error.toString()).toContain("Trust needs to be first established"); + // reinstate trust + await platform.trustWindowAesKey(trustRing.aesKeys); + }); + }); + }); + }); +}); diff --git a/test/spec/Storage-integ-test.js b/test/spec/Storage-integ-test.js index 925a65cc25..2d18cbb673 100644 --- a/test/spec/Storage-integ-test.js +++ b/test/spec/Storage-integ-test.js @@ -122,7 +122,7 @@ define(function (require, exports, module) { expect(val).toEql(expectedValue); }); - it("Should be able to create lmdb dumps in tauri", async function () { + it("Should be able to create lmdb dumps in native app", async function () { if(!Phoenix.isNativeApp){ return; } @@ -137,7 +137,14 @@ define(function (require, exports, module) { }); const dumpFileLocation = await window.storageNodeConnector.execPeer("dumpDBToFile"); - const dumpFileText = await window.__TAURI__.fs.readTextFile(dumpFileLocation); + let dumpFileText; + if (window.__TAURI__) { + dumpFileText = await window.__TAURI__.fs.readTextFile(dumpFileLocation); + } else if (window.__ELECTRON__) { + const data = await window.electronFSAPI.fsReadFile(dumpFileLocation); + const decoder = new TextDecoder("utf-8"); + dumpFileText = decoder.decode(data); + } const dumpObj = JSON.parse(dumpFileText); expect(dumpObj[key]).toEql(expectedValue); }); diff --git a/test/spec/Tauri-platform-test.js b/test/spec/Tauri-platform-test.js deleted file mode 100644 index f7cd733506..0000000000 --- a/test/spec/Tauri-platform-test.js +++ /dev/null @@ -1,325 +0,0 @@ -/* - * GNU AGPL-3.0 License - * - * Copyright (c) 2021 - present core.ai . All rights reserved. - * Original work Copyright (c) 2013 - 2021 Adobe Systems Incorporated. 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, beforeEach, afterEach, fs, path, jasmine, expectAsync*/ - -define(function (require, exports, module) { - if(!window.__TAURI__) { - return; - } - - const SpecRunnerUtils = require("spec/SpecRunnerUtils"); - - describe("unit: Tauri Platform Tests", function () { - - beforeEach(async function () { - - }); - - afterEach(async function () { - - }); - - describe("asset url tests", function () { - it("Should be able to fetch files in {appLocalData}/assets folder", async function () { - const appLocalData = fs.getTauriVirtualPath(await window.__TAURI__.path.appLocalDataDir()); - expect(await SpecRunnerUtils.pathExists(appLocalData, true)).toBeTrue(); - expect(appLocalData.split("/")[1]).toEql("tauri"); // should be /tauri/applocaldata/path - - // now write a test html file to the assets folder - const assetHTMLPath = `${appLocalData}/assets/a9322657236.html`; - const assetHtmlText = "Hello world random37834324"; - await SpecRunnerUtils.ensureExistsDirAsync(path.dirname(assetHTMLPath)); - await SpecRunnerUtils.createTextFileAsync(assetHTMLPath, assetHtmlText); - - const appLocalDataPlatformPath = fs.getTauriPlatformPath(assetHTMLPath); - const appLocalDataURL = window.__TAURI__.tauri.convertFileSrc(appLocalDataPlatformPath); - - const fetchedData = await ((await fetch(appLocalDataURL)).text()); - expect(fetchedData).toEqual(assetHtmlText); - - // delete test file - await SpecRunnerUtils.deletePathAsync(assetHTMLPath); - }); - - async function testAssetNotAccessibleFolder(platformPath) { - const assets = fs.getTauriVirtualPath(platformPath); - expect(assets.split("/")[1]).toEql("tauri"); // should be /tauri/applocaldata/path - - // now write a test html file to the assets folder - const assetHTMLPath = `${assets}/a9322657236.html`; - const assetHtmlText = "Hello world random37834324"; - await SpecRunnerUtils.createTextFileAsync(assetHTMLPath, assetHtmlText); - - const appLocalDataPlatformPath = fs.getTauriPlatformPath(assetHTMLPath); - const appLocalDataURL = window.__TAURI__.tauri.convertFileSrc(appLocalDataPlatformPath); - - let err; - try{ - await fetch(appLocalDataURL); - } catch (e) { - err = e; - } - expect(err).toBeDefined(); - - // delete test file - await SpecRunnerUtils.deletePathAsync(assetHTMLPath); - } - - it("Should not be able to fetch files in documents folder", async function () { - // unfortunately for tests, this is set to appdata/testDocuments. - // we cant set this to await window.__TAURI__.path.documentDir() as in github actions, - // the user documents directory is not defined in rust and throws. - await testAssetNotAccessibleFolder(window._tauriBootVars.documentDir); - }); - - it("Should not be able to fetch files in appLocalData folder", async function () { - await testAssetNotAccessibleFolder(await window.__TAURI__.path.appLocalDataDir()); - }); - - function createWebView() { - return new Promise((resolve, reject)=>{ - let currentURL = new URL(location.href); - let pathParts = currentURL.pathname.split('/'); - pathParts[pathParts.length - 1] = 'spec/Tauri-platform-test.html'; - currentURL.pathname = pathParts.join('/'); - - let newURL = currentURL.href; - Phoenix.app.openURLInPhoenixWindow(newURL) - .then(tauriWindow =>{ - expect(tauriWindow.label.startsWith("extn-")).toBeTrue(); - tauriWindow.listen('TAURI_API_WORKING', function () { - resolve(tauriWindow); - }); - }).catch(reject); - }); - - } - - it("Should be able to spawn tauri windows", async function () { - const tauriWindow = await createWebView(); - await tauriWindow.close(); - }); - - it("Should be able to get process ID", async function () { - const processID = await Phoenix.app.getProcessID(); - expect(processID).toEqual(jasmine.any(Number)); - }); - - const maxWindows = 25; - it(`Should be able to spawn ${maxWindows} tauri windows`, async function () { - const tauriWindows = []; - for(let i=0; i {}); - }); - - afterEach(async function () { - // Cleanup after tests - await window.__TAURI__.invoke("delete_credential", { scopeName }).catch(() => {}); - }); - - if(Phoenix.isTestWindowGitHubActions && Phoenix.platform === "linux"){ - // Credentials test doesn't work in GitHub actions in linux desktop as the runner cant reach key ring. - it("Should not run in github actions in linux desktop", async function () { - expect(1).toEqual(1); - }); - return; - } - - describe("Credential Storage & OTP Generation", function () { - it("Should store credentials successfully", async function () { - const randomUUID = crypto.randomUUID(); - await expectAsync( - window.__TAURI__.invoke("store_credential", { scopeName, secretVal: randomUUID }) - ).toBeResolved(); - }); - - it("Should get credentials as encrypted string", async function () { - const randomUUID = crypto.randomUUID(); - await window.__TAURI__.invoke("store_credential", { scopeName, secretVal: randomUUID }); - - const response = await window.__TAURI__.invoke("get_credential", { scopeName }); - expect(response).toBeDefined(); - expect(response).not.toEqual(randomUUID); - }); - - it("Should retrieve and decrypt set credentials with kernal mode keys", async function () { - const randomUUID = crypto.randomUUID(); - await window.__TAURI__.invoke("store_credential", { scopeName, secretVal: randomUUID }); - - const creds = await window.__TAURI__.invoke("get_credential", { scopeName }); - expect(creds).toBeDefined(); - const decryptedString = await decryptCreds(creds); - expect(decryptedString).toEqual(randomUUID); - }); - - it("Should return an error if credentials do not exist", async function () { - const response = await window.__TAURI__.invoke("get_credential", { scopeName }); - expect(response).toBeNull(); - }); - - it("Should delete stored credentials", async function () { - const randomUUID = crypto.randomUUID(); - await window.__TAURI__.invoke("store_credential", { scopeName, secretVal: randomUUID }); - - // Ensure credential exists - let creds = await window.__TAURI__.invoke("get_credential", { scopeName }); - expect(creds).toBeDefined(); - - // Delete credential - await expectAsync( - window.__TAURI__.invoke("delete_credential", { scopeName }) - ).toBeResolved(); - - // Ensure credential is deleted - creds = await window.__TAURI__.invoke("get_credential", { scopeName }); - expect(creds).toBeNull(); - }); - - it("Should handle deletion of non-existent credentials gracefully", async function () { - let error; - try { - await window.__TAURI__.invoke("delete_credential", { scopeName }); - } catch (err) { - error = err; - } - - // The test should fail if no error was thrown - expect(error).toBeDefined(); - - // Check for OS-specific error messages - const expectedErrors = [ - "No matching entry found in secure storage", // Common error on Linux/macOS - "The specified item could not be found in the keychain", // macOS Keychain - "Element not found" // Windows Credential Manager - ]; - - const isExpectedError = expectedErrors.some(msg => error.includes(msg)); - expect(isExpectedError).toBeTrue(); - }); - - it("Should overwrite existing credentials when storing with the same scope", async function () { - const oldUUID = crypto.randomUUID(); - await window.__TAURI__.invoke("store_credential", { scopeName, secretVal: oldUUID }); - - let creds = await window.__TAURI__.invoke("get_credential", { scopeName }); - expect(creds).toBeDefined(); - let response = await decryptCreds(creds); - expect(response).toEqual(oldUUID); - - // Store new credentials with the same scope - const newUUID = crypto.randomUUID(); - await window.__TAURI__.invoke("store_credential", { scopeName, secretVal: newUUID }); - - creds = await window.__TAURI__.invoke("get_credential", { scopeName }); - expect(creds).toBeDefined(); - response = await decryptCreds(creds); - expect(response).toEqual(newUUID); - }); - - // trustRing.getCredential and set tests - async function setSomeKey() { - const randomCred = crypto.randomUUID(); - await trustRing.setCredential(TEST_TRUST_KEY_NAME, randomCred); - const savedCred = await trustRing.getCredential(TEST_TRUST_KEY_NAME); - expect(savedCred).toEqual(randomCred); - return savedCred; - } - - it("Should get and set API key in kernal mode trust ring", async function () { - await setSomeKey(); - }); - - it("Should get and set empty string API key in kernal mode trust ring", async function () { - const randomCred = ""; - await trustRing.setCredential(TEST_TRUST_KEY_NAME, randomCred); - const savedCred = await trustRing.getCredential(TEST_TRUST_KEY_NAME); - expect(savedCred).toEqual(randomCred); - }); - - it("Should remove API key in kernal mode trust ring work as expected", async function () { - await setSomeKey(); - await trustRing.removeCredential(TEST_TRUST_KEY_NAME); - const cred = await trustRing.getCredential(TEST_TRUST_KEY_NAME); - expect(cred).toBeNull(); - }); - - // trust key management - it("Should not be able to set trust key if one is already set", async function () { - const kv = trustRing.generateRandomKeyAndIV(); - let error; - try { - await window.__TAURI__.tauri.invoke("trust_window_aes_key", kv); - } catch (err) { - error = err; - } - expect(error).toContain("Trust has already been established for this window."); - }); - - it("Should be able to remove trust key with key and iv", async function () { - await window.__TAURI__.tauri.invoke("remove_trust_window_aes_key", trustRing.aesKeys); - let error; - try { - await window.__TAURI__.tauri.invoke("remove_trust_window_aes_key", trustRing.aesKeys); - } catch (err) { - error = err; - } - expect(error).toContain("No trust association found for this window."); - // reinstate trust - await window.__TAURI__.tauri.invoke("trust_window_aes_key", trustRing.aesKeys); - }); - - it("Should getCredential not work without trust", async function () { - await setSomeKey(); - await window.__TAURI__.tauri.invoke("remove_trust_window_aes_key", trustRing.aesKeys); - let error; - try { - await trustRing.getCredential(TEST_TRUST_KEY_NAME); - } catch (err) { - error = err; - } - expect(error).toContain("Trust needs to be first established"); - // reinstate trust - await window.__TAURI__.tauri.invoke("trust_window_aes_key", trustRing.aesKeys); - }); - }); - }); - }); -}); diff --git a/test/spec/native-platform-electron-test.html b/test/spec/native-platform-electron-test.html new file mode 100644 index 0000000000..1c24add2c1 --- /dev/null +++ b/test/spec/native-platform-electron-test.html @@ -0,0 +1,25 @@ + + + + + Test electron apis accessible + + + +sending event with electron api... + + diff --git a/test/spec/Tauri-platform-test.html b/test/spec/native-platform-tauri-test.html similarity index 55% rename from test/spec/Tauri-platform-test.html rename to test/spec/native-platform-tauri-test.html index 14732a90e3..55035470a5 100644 --- a/test/spec/Tauri-platform-test.html +++ b/test/spec/native-platform-tauri-test.html @@ -4,7 +4,8 @@ Test tauri apis accessible