GeezerVison is showing github.com — fix: stabilize desktop tests and asynchronous editor updates · phcode-dev/phoenix@e00a5a5
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: .codex/config.toml +3Lines changed: 3 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -27,3 +27,6 @@ import(pathToFileURL(path.join(root, entry)).href).catch((error) => { | 27 | 27 | env_vars = ["PHOENIX_DESKTOP_PATH", "PHOENIX_MCP_WS_PORT"] | 28 | 28 | # Live-preview connection and execution can take longer than the default 60s. | 29 | 29 | tool_timeout_sec = 120 | | 30 | + | | 31 | +[mcp_servers.phoenix-builder.tools.get_phoenix_status] | | 32 | +approval_mode = "approve" |
Collapse file Copy file name to clipboardExpand all lines: serve-proxy.js +20-2Lines changed: 20 additions & 2 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -309,8 +309,26 @@ const server = http.createServer((req, res) => { | 309 | 309 | } | 310 | 310 | } | 311 | 311 |
| 312 | | - // Serve static files | 313 | | - let filePath = path.join(config.root, parsedUrl.pathname); | | 312 | + // Serve static files. url.parse leaves the pathname percent encoded, so a | | 313 | + // directory or file whose name contains a space was looked up on disk as | | 314 | + // "sub%20dir" and answered 404 even though it was right there. Decode before | | 315 | + // touching the filesystem - the traversal check below still runs on the | | 316 | + // result, which is what stops an encoded "%2e%2e" from buying anything. | | 317 | + let decodedPathname; | | 318 | + try { | | 319 | + decodedPathname = decodeURIComponent(parsedUrl.pathname); | | 320 | + } catch (e) { | | 321 | + // A malformed escape is a bad request, not a missing file. | | 322 | + res.writeHead(400, { 'Content-Type': 'text/plain' }); | | 323 | + res.end('Bad Request'); | | 324 | + return; | | 325 | + } | | 326 | + if (decodedPathname.indexOf('\0') !== -1) { | | 327 | + res.writeHead(400, { 'Content-Type': 'text/plain' }); | | 328 | + res.end('Bad Request'); | | 329 | + return; | | 330 | + } | | 331 | + let filePath = path.join(config.root, decodedPathname); | 314 | 332 |
| 315 | 333 | // Security: prevent directory traversal | 316 | 334 | const normalizedPath = path.normalize(filePath); | |
Collapse file Copy file name to clipboardExpand all lines: src-node/index.js +51-6Lines changed: 51 additions & 6 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -169,17 +169,62 @@ rl.on('close', () => { | 169 | 169 | process.exit(1); | 170 | 170 | }); | 171 | 171 |
| | 172 | +// Opt-in watchdog for test processes; disabled by default. node-loader.js arms | | 173 | +// it for Phoenix.isTestWindow, and SpecRunnerUtils refreshes the runner and its | | 174 | +// test window. Normal editor sessions never send setIdleExit. | | 175 | +// | | 176 | +// Tauri owns this process and holds stdin open after the spawning page goes away. | | 177 | +// A terminate command sent during beforeunload can be lost, leaving Node and its | | 178 | +// LSP servers running. Test runs accept timeout-based cleanup for these orphans. | | 179 | +// Keep this disabled in normal editor sessions: sleep, page suspension or long | | 180 | +// pauses can stop heartbeats even though the page still exists. Expiry can then | | 181 | +// trigger a Node crash dialog after wake. Earlier heartbeat/socket orphan checks | | 182 | +// were removed for sleep-related crashes in a1e660cb5 and ba5800d93. | | 183 | +let _idleExitMs = 0; | | 184 | +let _idleExitTimer = null; | | 185 | + | | 186 | +function _shutdown(reason) { | | 187 | + lmdb.dumpDBToFileAndCloseDB() | | 188 | + .catch(console.error) | | 189 | + .finally(() => { | | 190 | + console.log(reason); | | 191 | + process.exit(0); | | 192 | + }); | | 193 | +} | | 194 | + | | 195 | +function _refreshIdleExit() { | | 196 | + if (!_idleExitMs) { | | 197 | + return; | | 198 | + } | | 199 | + if (_idleExitTimer) { | | 200 | + clearTimeout(_idleExitTimer); | | 201 | + } | | 202 | + _idleExitTimer = setTimeout(() => { | | 203 | + _shutdown(`No command for ${_idleExitMs}ms, the page that spawned us is gone.`); | | 204 | + }, _idleExitMs); | | 205 | + // never let this timer alone hold the process up | | 206 | + if (_idleExitTimer.unref) { | | 207 | + _idleExitTimer.unref(); | | 208 | + } | | 209 | +} | | 210 | + | 172 | 211 | function processCommand(line) { | 173 | 212 | try{ | 174 | 213 | let jsonCmd = JSON.parse(line); | | 214 | + // any command at all is proof the page is still there | | 215 | + _refreshIdleExit(); | 175 | 216 | switch (jsonCmd.commandCode) { | | 217 | + case "setIdleExit": | | 218 | + _idleExitMs = Number(jsonCmd.commandData) || 0; | | 219 | + if (!_idleExitMs && _idleExitTimer) { | | 220 | + clearTimeout(_idleExitTimer); | | 221 | + _idleExitTimer = null; | | 222 | + } | | 223 | + _refreshIdleExit(); | | 224 | + _sendResponse(_idleExitMs, jsonCmd.commandID); | | 225 | + return; | 176 | 226 | case "terminate": | 177 | | - lmdb.dumpDBToFileAndCloseDB() | 178 | | - .catch(console.error) | 179 | | - .finally(()=>{ | 180 | | - console.log("Node terminated by phcode."); | 181 | | - process.exit(0); | 182 | | - }); | | 227 | + _shutdown("Node terminated by phcode."); | 183 | 228 | return; | 184 | 229 | case "ping": _sendResponse("pong", jsonCmd.commandID); return; | 185 | 230 | case "setDebugMode": | |
Collapse file Copy file name to clipboardExpand all lines: src/JSUtils/ScopeManager.js +26-10Lines changed: 26 additions & 10 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -110,20 +110,22 @@ define(function (require, exports, module) { | 110 | 110 |
| 111 | 111 | /** | 112 | 112 | * Init preferences from a file in the project root or builtin | 113 | | - * defaults if no file is found; | | 113 | + * defaults if no file is found. Ignore callbacks from a previous project. | 114 | 114 | * @private | 115 | 115 | * @param {string=} projectRootPath - new project root path. Only needed | 116 | 116 | * for unit tests. | 117 | 117 | */ | 118 | 118 | function initPreferences(projectRootPath) { | 119 | 119 |
| 120 | | - // Reject the old preferences if they have not completed. | 121 | | - if (deferredPreferences && deferredPreferences.state() === "pending") { | 122 | | - deferredPreferences.reject(); | | 120 | + const previousRequest = deferredPreferences; | | 121 | + const request = $.Deferred(); | | 122 | + deferredPreferences = request; | | 123 | + // Cancelling the old request can release queued editor changes. Those | | 124 | + // changes must already see the new project's preference request. | | 125 | + if (previousRequest && previousRequest.state() === "pending") { | | 126 | + previousRequest.reject(); | 123 | 127 | } | 124 | | - | 125 | | - deferredPreferences = $.Deferred(); | 126 | | - var pr = ProjectManager.getProjectRoot(); | | 128 | + const pr = ProjectManager.getProjectRoot(); | 127 | 129 |
| 128 | 130 | // Open preferences relative to the project root | 129 | 131 | // Normally there is a project root, but for unit tests we need to | @@ -133,15 +135,22 @@ define(function (require, exports, module) { | 133 | 135 | } else if (!projectRootPath) { | 134 | 136 | console.log("initPreferences: projectRootPath has no value. Using Defaults."); | 135 | 137 | preferences = new Preferences(); | | 138 | + request.resolve(); | 136 | 139 | return; | 137 | 140 | } | 138 | 141 |
| 139 | 142 | var path = projectRootPath + Preferences.FILE_NAME; | 140 | 143 |
| 141 | 144 | preferences = new Preferences(); | 142 | 145 | FileSystem.resolve(path, function (err, file) { | | 146 | + if (deferredPreferences !== request) { | | 147 | + return; | | 148 | + } | 143 | 149 | if (!err) { | 144 | 150 | FileUtils.readAsText(file).done(function (text) { | | 151 | + if (deferredPreferences !== request) { | | 152 | + return; | | 153 | + } | 145 | 154 | var configObj = null; | 146 | 155 | try { | 147 | 156 | configObj = JSON.parse(text); | @@ -154,13 +163,16 @@ define(function (require, exports, module) { | 154 | 163 | } | 155 | 164 | } | 156 | 165 | preferences = new Preferences(configObj); | 157 | | - deferredPreferences.resolve(); | | 166 | + request.resolve(); | 158 | 167 | }).fail(function (error) { | | 168 | + if (deferredPreferences !== request) { | | 169 | + return; | | 170 | + } | 159 | 171 | preferences = new Preferences(); | 160 | | - deferredPreferences.resolve(); | | 172 | + request.resolve(); | 161 | 173 | }); | 162 | 174 | } else { | 163 | | - deferredPreferences.resolve(); | | 175 | + request.resolve(); | 164 | 176 | } | 165 | 177 | }); | 166 | 178 | } | @@ -1299,6 +1311,10 @@ define(function (require, exports, module) { | 1299 | 1311 | }); | 1300 | 1312 | }); | 1301 | 1313 | }); | | 1314 | + }).fail(function () { | | 1315 | + // A project switch cancelled these preferences. Release this | | 1316 | + // initialization so the next editor change can initialize Tern. | | 1317 | + addFilesDeferred.resolveWith(null); | 1302 | 1318 | }); | 1303 | 1319 | } | 1304 | 1320 |
| |
Collapse file Copy file name to clipboardExpand all lines: src/editor/MediaViewer.js +8-1Lines changed: 8 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -83,7 +83,14 @@ define(function (require, exports, module) { | 83 | 83 | */ | 84 | 84 | function _mediaStreamURL(file) { | 85 | 85 | const platformPath = Phoenix.fs.getTauriPlatformPath(file.fullPath); | 86 | | - return window.PhNodeEngine.mediaURL + | | 86 | + // The file name rides in the path, before the query that actually names the | | 87 | + // file, purely so the URL ends in the right extension. WebKit picks its | | 88 | + // media engine for WebM off the URL extension rather than the Content-Type, | | 89 | + // so without this a .webm plays as "format not supported" on the Mac | | 90 | + // desktop app while the very same bytes play from a blob. mp4 is content | | 91 | + // sniffed and so never showed the problem. Node ignores this segment - it | | 92 | + // matches the route by prefix and reads platformPath from the query. | | 93 | + return window.PhNodeEngine.mediaURL + "/" + encodeURIComponent(file.name) + | 87 | 94 | "?platformPath=" + encodeURIComponent(platformPath); | 88 | 95 | } | 89 | 96 |
| |
Collapse file Copy file name to clipboardExpand all lines: src/extensions/default/DocCommentHints/integration-tests.js +6-1Lines changed: 6 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -81,7 +81,12 @@ define(function (require, exports, module) { | 81 | 81 | await awaitsForDone(SpecRunnerUtils.openProjectFiles([tc.file]), "open " + tc.file); | 82 | 82 | const editor = EditorManager.getActiveEditor(); | 83 | 83 | editor.setCursorPos(tc.line, tc.ch); | 84 | | - CommandManager.execute(Commands.SHOW_CODE_HINTS); | | 84 | + // Hand the editor to the command. Left to itself it asks for the | | 85 | + // focused editor, and an editor only counts as focused once the | | 86 | + // browser has dispatched focus to it - which it does not do while the | | 87 | + // test window is behind another app. Then no session starts, no popup | | 88 | + // opens, and every case here times out together. | | 89 | + CommandManager.execute(Commands.SHOW_CODE_HINTS, editor); | 85 | 90 |
| 86 | 91 | // 1) the code-hints popup appears with our hint | 87 | 92 | await awaitsFor(function () { return $docHint().length > 0; }, | |
Collapse file Copy file name to clipboardExpand all lines: src/extensions/default/HTMLCodeHints/integ-tests.js +35-12Lines changed: 35 additions & 12 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -88,15 +88,23 @@ define(function (require, exports, module) { | 88 | 88 | const selected = ProjectManager.getSelectedItem(); | 89 | 89 | expect(selected.fullPath).toBe(testPath + "/jumpToDef.html"); | 90 | 90 |
| 91 | | - let editor = EditorManager.getActiveEditor(); | 92 | | - editor.setCursorPos({ line: 5, ch: 6 }); | | 91 | + const hostEditor = EditorManager.getActiveEditor(); | | 92 | + hostEditor.setCursorPos({ line: 5, ch: 6 }); | 93 | 93 |
| 94 | 94 | await awaitsForDone(CommandManager.execute(Commands.NAVIGATE_JUMPTO_DEFINITION), | 95 | 95 | "jump to def on div"); | 96 | 96 |
| 97 | | - editor = EditorManager.getFocusedInlineEditor(); | 98 | | - expect(editor.document.file.fullPath.endsWith("LiveDevelopment-MultiBrowser-test-files/simpleShared.css")) | 99 | | - .toBeTrue(); | | 97 | + // Ask the host for its inline editors rather than for the focused one: | | 98 | + // an inline editor only holds focus while the window does, so the old | | 99 | + // check came back null whenever the test window was not the OS front | | 100 | + // window, without the inline editor being any less open. | | 101 | + let inlineEditor; | | 102 | + await awaitsFor(function () { | | 103 | + inlineEditor = EditorManager.getInlineEditors(hostEditor)[0]; | | 104 | + return !!inlineEditor; | | 105 | + }, "inline editor to open on the definition"); | | 106 | + expect(inlineEditor.document.file.fullPath | | 107 | + .endsWith("LiveDevelopment-MultiBrowser-test-files/simpleShared.css")).toBeTrue(); | 100 | 108 | await closeSession(); | 101 | 109 | }); | 102 | 110 |
| @@ -105,14 +113,22 @@ define(function (require, exports, module) { | 105 | 113 | const selected = ProjectManager.getSelectedItem(); | 106 | 114 | expect(selected.fullPath).toBe(testPath + "/jumpToDef.html"); | 107 | 115 |
| 108 | | - let editor = EditorManager.getActiveEditor(); | 109 | | - editor.setCursorPos({ line: 6, ch: 23 }); | | 116 | + const hostEditor = EditorManager.getActiveEditor(); | | 117 | + hostEditor.setCursorPos({ line: 6, ch: 23 }); | 110 | 118 |
| 111 | 119 | await awaitsForDone(CommandManager.execute(Commands.NAVIGATE_JUMPTO_DEFINITION), | 112 | 120 | "jump to def on div"); | 113 | 121 |
| 114 | | - editor = EditorManager.getFocusedInlineEditor(); | 115 | | - expect(editor.document.file.fullPath.endsWith("LiveDevelopment-MultiBrowser-test-files/sub/test.css")) | | 122 | + // Ask the host for its inline editors rather than for the focused one: | | 123 | + // an inline editor only holds focus while the window does, so the old | | 124 | + // check came back null whenever the test window was not the OS front | | 125 | + // window, without the inline editor being any less open. | | 126 | + let inlineEditor; | | 127 | + await awaitsFor(function () { | | 128 | + inlineEditor = EditorManager.getInlineEditors(hostEditor)[0]; | | 129 | + return !!inlineEditor; | | 130 | + }, "inline editor to open on the definition"); | | 131 | + expect(inlineEditor.document.file.fullPath.endsWith("LiveDevelopment-MultiBrowser-test-files/sub/test.css")) | 116 | 132 | .toBeTrue(); | 117 | 133 | await closeSession(); | 118 | 134 | }); | @@ -202,9 +218,11 @@ define(function (require, exports, module) { | 202 | 218 | let editor = EditorManager.getActiveEditor(); | 203 | 219 | editor.setCursorPos(cursor); | 204 | 220 |
| 205 | | - await awaitsForDone(CommandManager.execute(Commands.SHOW_CODE_HINTS), | 206 | | - "show code hints"); | 207 | | - | | 221 | + // The index has to hold the classes before hints are asked for, not after. | | 222 | + // Showing hints is a one-shot request: made while the index is still | | 223 | + // catching up, the provider has no class hints to offer, no menu opens, | | 224 | + // and nothing asks again once the index is ready - so this passed only | | 225 | + // when the index happened to be warm, and timed out under a full run. | 208 | 226 | await awaitsFor(async function () { | 209 | 227 | for(let hint of expectedSomeHintsArray){ | 210 | 228 | const allSelectors = await CSSUtils.getAllCssSelectorsInProject(); | @@ -215,6 +233,11 @@ define(function (require, exports, module) { | 215 | 233 | return true; | 216 | 234 | }, "CSSUtils project selectors to be updated"); | 217 | 235 |
| | 236 | + // With the editor passed in, the session does not depend on it holding | | 237 | + // focus, which it does not while the test window is in the background. | | 238 | + await awaitsForDone(CommandManager.execute(Commands.SHOW_CODE_HINTS, editor), | | 239 | + "show code hints"); | | 240 | + | 218 | 241 | await awaitsFor(function () { | 219 | 242 | return $(".codehint-menu").is(":visible"); | 220 | 243 | }, "codehints to be shown"); | |
Collapse file Copy file name to clipboardExpand all lines: src/extensions/default/QuickView/unittests.js +11-1Lines changed: 11 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -197,7 +197,17 @@ define(function (require, exports, module) { | 197 | 197 | let quickViewSwatch = popoverInfo.content.find("#quick-view-color-swatch"); | 198 | 198 | expect(quickViewSwatch.attr("data-for-test")).toBe(color); | 199 | 199 | quickViewSwatch.click(); | 200 | | - expect(EditorManager.getFocusedInlineWidget()._color).toBe(color); | | 200 | + // Find the color editor among the host's inline widgets rather than as | | 201 | + // the focused one: an inline widget only holds focus while the window | | 202 | + // does, so the old check came back null when the test window was in | | 203 | + // the background, with the color editor open all the same. | | 204 | + let colorWidget; | | 205 | + await awaitsFor(function () { | | 206 | + colorWidget = EditorManager.getActiveEditor().getInlineWidgets() | | 207 | + .find(function (widget) { return widget._color; }); | | 208 | + return !!colorWidget; | | 209 | + }, "inline color editor to open"); | | 210 | + expect(colorWidget._color).toBe(color); | 201 | 211 | }); | 202 | 212 |
| 203 | 213 | describe("JavaScript file", function () { | |
Collapse file Copy file name to clipboardExpand all lines: src/extensions/default/TypeScriptSupport/unittests.js +21-12Lines changed: 21 additions & 12 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -431,19 +431,21 @@ define(function (require, exports, module) { | 431 | 431 | ExtensionLoader.getRequireContextForExtension("JavaScriptCodeHints")(["main"], resolve, reject); | 432 | 432 | }); | 433 | 433 | let hintText = ""; | 434 | | - await awaitsFor(function () { | | 434 | + await awaitsFor(async function () { | 435 | 435 | if (!jsCodeHints.jsHintProvider.hasHints(editor, null)) { | 436 | 436 | return false; // Tern session/worker may still be starting up | 437 | 437 | } | 438 | | - const response = jsCodeHints.jsHintProvider.getHints(null); | 439 | | - if (!response || typeof response.done !== "function") { | 440 | | - return hintText.indexOf("push") !== -1; // sync response already captured below | | 438 | + // The provider returns a Deferred for fresh hints and an object for | | 439 | + // cached hints. Await either result before checking this request. | | 440 | + let result; | | 441 | + try { | | 442 | + result = await jsCodeHints.jsHintProvider.getHints(null); | | 443 | + } catch (err) { | | 444 | + return false; // A project/session change can cancel a pending request. | 441 | 445 | } | 442 | | - response.done(function (result) { | 443 | | - hintText = ((result && result.hints) || []).map(function (h) { | 444 | | - return $(h).text(); | 445 | | - }).join("|"); | 446 | | - }); | | 446 | + hintText = ((result && result.hints) || []).map(function (h) { | | 447 | + return $(h).text(); | | 448 | + }).join("|"); | 447 | 449 | return hintText.indexOf("push") !== -1; | 448 | 450 | }, "Tern Array-member completions at arr. inside the <script>", 30000, 500); | 449 | 451 | expect(hintText).toContain("push"); | @@ -917,10 +919,17 @@ define(function (require, exports, module) { | 917 | 919 | await awaitsFor(function () { | 918 | 920 | return panelText().includes("never read") && panelText().includes("deprecated"); | 919 | 921 | }, "unused + deprecated hint rows in the problems panel", 30000); | 920 | | - // the marked text carries the tag styles on top of the info squiggle | | 922 | + // the marked text carries the tag styles on top of the info squiggle. | | 923 | + // Read the marks off the editor rather than looking for their spans in | | 924 | + // the page: CodeMirror only renders spans for lines it has painted, and | | 925 | + // late in a full run it had not, so this timed out with the marks - and | | 926 | + // the panel rows the waits above had already seen - all in place. | 921 | 927 | await awaitsFor(function () { | 922 | | - return $(".editor-text-fragment-unnecessary").length > 0 && | 923 | | - $(".editor-text-fragment-deprecated").length > 0; | | 928 | + const classes = editor.findMarks({line: 0, ch: 0}, {line: editor.lineCount(), ch: 0}) | | 929 | + .map(function (mark) { return mark.className || ""; }) | | 930 | + .join(" "); | | 931 | + return classes.includes("editor-text-fragment-unnecessary") && | | 932 | + classes.includes("editor-text-fragment-deprecated"); | 924 | 933 | }, "faded + strikethrough text marks in the editor", 30000); | 925 | 934 | }, 90000); | 926 | 935 |
| |
|