From 7051aad5995ec771314128437d4ce7d525de0d83 Mon Sep 17 00:00:00 2001 From: jamesread Date: Sun, 1 Mar 2026 23:17:44 +0000 Subject: [PATCH] feat: Clickable links in outout (#900) --- frontend/js/OutputTerminal.js | 14 +++- frontend/package-lock.json | 7 ++ frontend/package.json | 3 +- .../tests/xtermLinkHandling/config.yaml | 13 ++++ .../xtermLinkHandling/xtermLinkHandling.mjs | 69 +++++++++++++++++++ 5 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 integration-tests/tests/xtermLinkHandling/config.yaml create mode 100644 integration-tests/tests/xtermLinkHandling/xtermLinkHandling.mjs diff --git a/frontend/js/OutputTerminal.js b/frontend/js/OutputTerminal.js index 1526318..ebda77b 100644 --- a/frontend/js/OutputTerminal.js +++ b/frontend/js/OutputTerminal.js @@ -1,5 +1,6 @@ import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' +import { WebLinksAddon } from '@xterm/addon-web-links' import { Mutex } from './Mutex.js' /** @@ -18,13 +19,24 @@ export class OutputTerminal { constructor (executionTrackingId) { this.executionTrackingId = executionTrackingId this.writeMutex = new Mutex() + const linkHandler = { + activate (event, text, _range) { + event.preventDefault() + window.open(text, '_blank') + } + } + this.terminal = new Terminal({ - convertEol: true + convertEol: true, + linkHandler }) const fitAddon = new FitAddon() this.terminal.loadAddon(fitAddon) this.terminal.fit = fitAddon + + this.terminal.loadAddon(new WebLinksAddon((event, uri) => linkHandler.activate(event, uri))) + this.linkHandlerConfigured = true } async write (out, then) { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3bc98c8..47b2ac6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "@hugeicons/vue": "^1.0.4", "@vitejs/plugin-vue": "^6.0.4", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "iconify-icon": "^3.0.2", "picocrank": "^1.14.0", @@ -1570,6 +1571,12 @@ "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", "license": "MIT" }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" + }, "node_modules/@xterm/xterm": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index a64d473..a08f799 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,13 +28,14 @@ "@hugeicons/vue": "^1.0.4", "@vitejs/plugin-vue": "^6.0.4", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "iconify-icon": "^3.0.2", "picocrank": "^1.14.0", "standard": "^17.1.2", "unplugin-vue-components": "^31.0.0", "vite": "^7.3.1", - "vue": "^3.5.29", + "vue": "^3.5.29", "vue-i18n": "^11.2.8", "vue-router": "^5.0.3" } diff --git a/integration-tests/tests/xtermLinkHandling/config.yaml b/integration-tests/tests/xtermLinkHandling/config.yaml new file mode 100644 index 0000000..f70848d --- /dev/null +++ b/integration-tests/tests/xtermLinkHandling/config.yaml @@ -0,0 +1,13 @@ +# +# Integration Test Config: xterm link handling +# + +listenAddressSingleHTTPFrontend: 0.0.0.0:1337 + +logLevel: "DEBUG" +checkForUpdates: false + +actions: + - title: Echo URL + shell: echo "See https://example.com for more info" + popupOnStart: execution-dialog-stdout-only diff --git a/integration-tests/tests/xtermLinkHandling/xtermLinkHandling.mjs b/integration-tests/tests/xtermLinkHandling/xtermLinkHandling.mjs new file mode 100644 index 0000000..849f5a0 --- /dev/null +++ b/integration-tests/tests/xtermLinkHandling/xtermLinkHandling.mjs @@ -0,0 +1,69 @@ +import { describe, it, before, after } from 'mocha' +import { expect } from 'chai' +import { By, Condition } from 'selenium-webdriver' +import { + getRootAndWait, + takeScreenshotOnFailure, + getTerminalBuffer, +} from '../../lib/elements.js' + +describe('config: xtermLinkHandling', function () { + before(async function () { + await runner.start('xtermLinkHandling') + }) + + after(async () => { + await runner.stop() + }) + + afterEach(function () { + takeScreenshotOnFailure(this.currentTest, webdriver) + }) + + it('xterm output shows URL and link handling is configured', async function () { + await getRootAndWait() + + await webdriver.wait(new Condition('wait for Echo URL button', async () => { + const btns = await webdriver.findElements(By.css('[title="Echo URL"]')) + return btns.length === 1 + }), 10000) + + const echoUrlButton = await webdriver.findElement(By.css('[title="Echo URL"]')) + await echoUrlButton.click() + + await webdriver.wait(new Condition('wait for execution view', async () => { + const url = await webdriver.getCurrentUrl() + return url.includes('/logs/') && !url.endsWith('/logs') + }), 10000) + + await webdriver.wait(new Condition('wait for execution status', async () => { + const statusElements = await webdriver.findElements(By.id('execution-dialog-status')) + return statusElements.length > 0 + }), 5000) + + await webdriver.wait(new Condition('wait for execution to finish', async () => { + try { + const statusElement = await webdriver.findElement(By.id('execution-dialog-status')) + const statusText = await statusElement.getText() + return !statusText.includes('Executing') + } catch (e) { + return false + } + }), 5000) + + await webdriver.sleep(500) + + const bufferText = await getTerminalBuffer() + expect(bufferText).to.not.be.null + expect(bufferText).to.include('https://example.com') + + const linkHandlerSet = await webdriver.executeScript(` + try { + return !!(window.terminal && window.terminal.linkHandlerConfigured === true) + } catch (e) { + return false + } + `) + expect(linkHandlerSet).to.equal(true) + }) +})