From 5690ef521aac61f6b70df0db17be9979a13d8494 Mon Sep 17 00:00:00 2001 From: jamesread Date: Mon, 15 Jun 2026 23:40:18 +0100 Subject: [PATCH] chore: Hardened take flakes --- frontend/js/websocket.js | 7 +- frontend/resources/vue/ActionButton.vue | 9 +- frontend/resources/vue/Dashboard.vue | 7 +- frontend/resources/vue/router.js | 28 ++-- integration-tests/lib/elements.js | 65 +++++++-- integration-tests/runner.mjs | 24 +++- integration-tests/tests/checkbox/checkbox.mjs | 58 ++------ integration-tests/tests/datetime/datetime.mjs | 34 +---- .../enabledExpression/enabledExpression.mjs | 16 +-- integration-tests/tests/entities/entities.js | 8 +- .../entityFilesWithLongIntsUseStandardForm.js | 21 +-- .../suggestionsBrowserKey.mjs | 130 ++++++------------ .../xtermLinkHandling/xtermLinkHandling.mjs | 28 +--- 13 files changed, 184 insertions(+), 251 deletions(-) diff --git a/frontend/js/websocket.js b/frontend/js/websocket.js index 15912c9..6583e56 100644 --- a/frontend/js/websocket.js +++ b/frontend/js/websocket.js @@ -42,7 +42,7 @@ export function connectEventStreamIfNeeded () { return } - if (window.websocketAvailable || reconnectTimer != null) { + if (connectionState.connected || reconnectTimer != null) { return } @@ -54,6 +54,7 @@ export function initWebsocket () { window.addEventListener('EventOutputChunk', onOutputChunk) window.addEventListener('EventExecutionStarted', onExecutionChanged) window.addEventListener('EventExecutionFinished', onExecutionChanged) + window.addEventListener('pagehide', stopEventStream) listenersInitialized = true } @@ -67,7 +68,7 @@ export function requestReconnectNow () { return } - if (window.websocketAvailable) { + if (connectionState.connected) { return } @@ -109,7 +110,7 @@ async function reconnectWebsocket () { return } - if (window.websocketAvailable) { + if (connectionState.connected) { return } diff --git a/frontend/resources/vue/ActionButton.vue b/frontend/resources/vue/ActionButton.vue index 56335e2..dfd4ab0 100644 --- a/frontend/resources/vue/ActionButton.vue +++ b/frontend/resources/vue/ActionButton.vue @@ -261,10 +261,15 @@ async function startAction(actionArgs) { requestReconnectNow() try { - await window.client.startAction(startActionArgs) + const response = await window.client.startAction(startActionArgs) + const trackingId = response.executionTrackingId || startActionArgs.uniqueTrackingId + + if (popupOnStart.value && popupOnStart.value.includes('execution-dialog')) { + router.push(`/logs/${trackingId}`) + } if (!connectionState.connected) { - await pollExecutionUntilDone(startActionArgs.uniqueTrackingId) + await pollExecutionUntilDone(trackingId) } } catch (err) { console.error('Failed to start action:', err) diff --git a/frontend/resources/vue/Dashboard.vue b/frontend/resources/vue/Dashboard.vue index d09c333..9a13834 100644 --- a/frontend/resources/vue/Dashboard.vue +++ b/frontend/resources/vue/Dashboard.vue @@ -61,7 +61,6 @@ import { onMounted, onUnmounted, ref, computed, watch } from 'vue' import { useRouter } from 'vue-router' import { HugeiconsIcon } from '@hugeicons/vue' import { Loading03Icon, ArrowLeftIcon } from '@hugeicons/core-free-icons' -import { requestReconnectNow } from '../../js/websocket.js' const props = defineProps({ title: { @@ -107,8 +106,6 @@ function goBack() { } async function getDashboard() { - requestReconnectNow() - let title = props.title // If no specific title was provided or it's the placeholder 'default', @@ -167,6 +164,8 @@ async function getDashboard() { } function waitForInitAndLoadDashboard() { + document.body.removeAttribute('loaded-dashboard') + if (loadingTimer) { clearInterval(loadingTimer) loadingTimer = null @@ -226,6 +225,8 @@ watch( ) onUnmounted(() => { + document.body.removeAttribute('loaded-dashboard') + // Clean up the timers when component is unmounted if (loadingTimer) { clearInterval(loadingTimer) diff --git a/frontend/resources/vue/router.js b/frontend/resources/vue/router.js index 289a88f..d41cecf 100644 --- a/frontend/resources/vue/router.js +++ b/frontend/resources/vue/router.js @@ -1,5 +1,7 @@ import { createRouter, createWebHistory } from 'vue-router' +import Dashboard from './Dashboard.vue' + import { Wrench01Icon } from '@hugeicons/core-free-icons' import { LeftToRightListDashIcon } from '@hugeicons/core-free-icons' import { CellsIcon } from '@hugeicons/core-free-icons' @@ -9,13 +11,13 @@ const routes = [ { path: '/', name: 'Actions', - component: () => import('./Dashboard.vue'), + component: Dashboard, meta: { title: 'Actions', icon: DashboardSquare01Icon } }, { path: '/dashboards/:title/:entityType?/:entityKey?', name: 'Dashboard', - component: () => import('./Dashboard.vue'), + component: Dashboard, props: true, meta: { title: 'Dashboard' } }, @@ -30,7 +32,7 @@ const routes = [ path: '/logs', name: 'Logs', component: () => import('./views/LogsListView.vue'), - meta: { + meta: { title: 'Logs', icon: LeftToRightListDashIcon } @@ -39,7 +41,7 @@ const routes = [ path: '/logs/calendar', name: 'LogsCalendar', component: () => import('./views/LogsCalendarView.vue'), - meta: { + meta: { title: 'Logs Calendar', breadcrumb: [ { name: "Logs", href: "/logs" }, @@ -51,7 +53,7 @@ const routes = [ path: '/entities', name: 'Entities', component: () => import('./views/EntitiesView.vue'), - meta: { + meta: { title: 'Entities', icon: CellsIcon } @@ -61,8 +63,8 @@ const routes = [ name: 'EntityDetails', component: () => import('./views/EntityDetailsView.vue'), props: true, - meta: { - title: 'OliveTin - Entity Details', + meta: { + title: 'OliveTin - Entity Details', breadcrumb: [ { name: "Entities", href: "/entities" }, { name: "Entity Details" } @@ -74,8 +76,8 @@ const routes = [ name: 'Execution', component: () => import('./views/ExecutionView.vue'), props: true, - meta: { - title: 'Execution', + meta: { + title: 'Execution', breadcrumb: [ { name: "Logs", href: "/logs" }, { name: "Execution" }, @@ -87,7 +89,7 @@ const routes = [ name: 'ActionDetails', component: () => import('./views/ActionDetailsView.vue'), props: true, - meta: { + meta: { title: 'Action Details', breadcrumb: [ { name: "Actions", href: "/" }, @@ -112,7 +114,7 @@ const routes = [ path: '/diagnostics', name: 'Diagnostics', component: () => import('./views/DiagnosticsView.vue'), - meta: { + meta: { title: 'Diagnostics', icon: Wrench01Icon } @@ -163,7 +165,7 @@ router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => { // Check if user is authenticated for protected routes const isAuthenticated = window.isAuthenticated || true // Default to true for now - + if (to.meta.requiresAuth && !isAuthenticated) { next('/login') } else { @@ -171,4 +173,4 @@ router.beforeEach((to, from, next) => { } }) -export default router +export default router diff --git a/integration-tests/lib/elements.js b/integration-tests/lib/elements.js index b003bab..31c2cc6 100644 --- a/integration-tests/lib/elements.js +++ b/integration-tests/lib/elements.js @@ -3,6 +3,8 @@ import fs from 'fs' import { expect } from 'chai' import { Condition } from 'selenium-webdriver' +export const DEFAULT_UI_WAIT_MS = 3000 + export async function getActionButtons () { // Currently, only the active dashboard's contents are rendered, // so we don't need to scope the selector by dashboard title. @@ -10,11 +12,11 @@ export async function getActionButtons () { } export async function getExecutionDialogOutput() { - await webdriver.wait(new Condition('Dialog with long int is visible', async () => { + await webdriver.wait(new Condition('Dialog with long int is visible', async () => { const dialog = await webdriver.findElement({ id: 'execution-results-popup' }) return await dialog.isDisplayed() })); - + const ret = await webdriver.executeScript('return window.logEntries.get(window.executionDialog.executionTrackingId).output') return ret @@ -46,20 +48,59 @@ export function takeScreenshot (webdriver, title) { }) } -export async function getRootAndWait() { - await webdriver.get(runner.baseUrl()) - await webdriver.wait(new Condition('wait for loaded-dashboard', async function() { +export async function waitForDashboardLoaded(timeoutMs = DEFAULT_UI_WAIT_MS) { + await webdriver.wait(new Condition('wait for loaded-dashboard', async function () { const body = await webdriver.findElement(By.tagName('body')) const attr = await body.getAttribute('loaded-dashboard') console.log('loaded-dashboard: ', attr) - if (attr) { - return true - } else { + return attr != null && attr !== '' + }), timeoutMs) +} + +export async function waitForLogsPage(timeoutMs = DEFAULT_UI_WAIT_MS) { + await webdriver.wait(new Condition('wait for logs page', async () => { + const url = await webdriver.getCurrentUrl() + return url.includes('/logs/') && !url.endsWith('/logs') + }), timeoutMs) +} + +export async function waitForArgumentFormPage(timeoutMs = DEFAULT_UI_WAIT_MS) { + await webdriver.wait(new Condition('wait for argument form page', async () => { + const url = await webdriver.getCurrentUrl() + return url.includes('/actionBinding/') && url.includes('/argumentForm') + }), timeoutMs) +} + +export async function waitForArgumentFormReady(timeoutMs = DEFAULT_UI_WAIT_MS) { + await webdriver.wait(new Condition('wait for argument form ready', async () => { + const body = await webdriver.findElement(By.tagName('body')) + const attr = await body.getAttribute('loaded-argument-form') + return attr != null && attr !== '' + }), timeoutMs) +} + +export async function waitForExecutionComplete(timeoutMs = DEFAULT_UI_WAIT_MS) { + await webdriver.wait(new Condition('wait for execution status', async () => { + const statusElements = await webdriver.findElements(By.id('execution-dialog-status')) + return statusElements.length > 0 + }), timeoutMs) + + 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('Still running') + } catch (e) { return false } - })) + }), timeoutMs) +} + +export async function getRootAndWait() { + await webdriver.get(runner.baseUrl()) + await waitForDashboardLoaded() } export async function closeSidebar() { @@ -82,7 +123,7 @@ export async function closeSidebar() { console.log('Sidebar closed, left is: *' + left, left === neededLeft ? ' (as expected)' : '') return left === neededLeft } - }), 10000); // Wait up to 10 seconds for the sidebar to close + }), DEFAULT_UI_WAIT_MS) } export async function openSidebar() { @@ -103,7 +144,7 @@ export async function openSidebar() { console.log('Sidebar opened, left is: ', left) return true } - })); + }), DEFAULT_UI_WAIT_MS) } export async function getNavigationLinks() { @@ -123,7 +164,7 @@ export async function requireExecutionDialogStatus (webdriver, expected) { console.log('Waiting for domStatus text to be: ', expected, ', it is currently: ', actual) return false } - })) + }), DEFAULT_UI_WAIT_MS) } export async function findExecutionDialog (webdriver) { diff --git a/integration-tests/runner.mjs b/integration-tests/runner.mjs index 5e0aba0..14e067f 100644 --- a/integration-tests/runner.mjs +++ b/integration-tests/runner.mjs @@ -33,6 +33,10 @@ class OliveTinTestRunner { class OliveTinTestRunnerStartLocalProcess extends OliveTinTestRunner { async start (cfg) { + if (this.ot != null && this.ot.exitCode == null) { + await this.stop() + } + let stdout = "" let stderr = "" @@ -94,13 +98,23 @@ class OliveTinTestRunnerStartLocalProcess extends OliveTinTestRunner { } async stop () { - if ((await this.ot.exitCode) != null) { - console.log(" OliveTin local process tried stop(), but it already exited with code", this.ot.exitCode) - } else { - await this.ot.kill() - console.log(" OliveTin local process killed") + if (this.ot == null) { + return } + if (this.ot.exitCode != null) { + console.log(' OliveTin local process tried stop(), but it already exited with code', this.ot.exitCode) + } else { + const closed = new Promise((resolve) => { + this.ot.once('close', resolve) + }) + this.ot.kill('SIGTERM') + await closed + console.log(' OliveTin local process killed') + } + + this.ot = null + if (process.env.CI === 'true') { // GitHub runners seem to need a bit more time to clean up await new Promise((res) => setTimeout(res, 3000)) diff --git a/integration-tests/tests/checkbox/checkbox.mjs b/integration-tests/tests/checkbox/checkbox.mjs index 1dcdc78..f883095 100644 --- a/integration-tests/tests/checkbox/checkbox.mjs +++ b/integration-tests/tests/checkbox/checkbox.mjs @@ -2,10 +2,14 @@ import { describe, it, before, after } from 'mocha' import { expect } from 'chai' import { By, Condition } from 'selenium-webdriver' import { + DEFAULT_UI_WAIT_MS, getRootAndWait, getActionButton, takeScreenshotOnFailure, getTerminalBuffer, + waitForArgumentFormPage, + waitForLogsPage, + waitForExecutionComplete, } from '../../lib/elements.js' async function openCheckboxArgumentForm() { @@ -13,13 +17,7 @@ async function openCheckboxArgumentForm() { const btn = await getActionButton(webdriver, 'Test checkbox argument') await btn.click() - await webdriver.wait( - new Condition('wait for argument form page', async () => { - const url = await webdriver.getCurrentUrl() - return url.includes('/actionBinding/') && url.includes('/argumentForm') - }), - 5000 - ) + await waitForArgumentFormPage() } async function getCheckboxInput() { @@ -31,42 +29,6 @@ async function submitCheckboxForm() { await submitButton.click() } -async function waitForLogsPage() { - await webdriver.wait( - new Condition('wait for logs page', async () => { - const url = await webdriver.getCurrentUrl() - return url.includes('/logs/') && !url.endsWith('/logs') - }), - 5000 - ) -} - -async function waitForExecutionComplete() { - 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 - ) - - // Small delay to allow terminal to write output - await webdriver.sleep(500) -} - async function waitForTerminalOutput(expectedValue) { await webdriver.wait( new Condition(`wait for checkbox value ${expectedValue} in output`, async () => { @@ -77,18 +39,18 @@ async function waitForTerminalOutput(expectedValue) { if (!terminalReady) { return false } - + const output = await getTerminalBuffer() if (!output) { return false } - + return output.trim().includes(`Checkbox value: ${expectedValue}`) } catch (e) { return false } }), - 5000 + DEFAULT_UI_WAIT_MS ) } @@ -118,7 +80,6 @@ describe('config: checkbox', function () { }) it('Checkbox argument submits 0 by default when unchecked', async function () { - this.timeout(15000) await openCheckboxArgumentForm() const checkboxInput = await getCheckboxInput() @@ -131,7 +92,6 @@ describe('config: checkbox', function () { }) it('Checkbox argument can be toggled and submitted', async function () { - this.timeout(15000) await openCheckboxArgumentForm() const checkboxInput = await getCheckboxInput() @@ -146,5 +106,3 @@ describe('config: checkbox', function () { await waitForTerminalOutput('1') }) }) - - diff --git a/integration-tests/tests/datetime/datetime.mjs b/integration-tests/tests/datetime/datetime.mjs index d748d7f..9fdbdee 100644 --- a/integration-tests/tests/datetime/datetime.mjs +++ b/integration-tests/tests/datetime/datetime.mjs @@ -1,10 +1,12 @@ import { describe, it, before, after } from 'mocha' import { expect } from 'chai' -import { By, Condition } from 'selenium-webdriver' +import { By } from 'selenium-webdriver' import { getRootAndWait, getActionButton, takeScreenshotOnFailure, + waitForArgumentFormPage, + waitForLogsPage, } from '../../lib/elements.js' describe('config: datetime', function () { @@ -27,14 +29,7 @@ describe('config: datetime', function () { await btn.click() - // Wait for navigation to argument form page - await webdriver.wait( - new Condition('wait for argument form page', async () => { - const url = await webdriver.getCurrentUrl() - return url.includes('/actionBinding/') && url.includes('/argumentForm') - }), - 8000 - ) + await waitForArgumentFormPage() // Find the datetime input field const datetimeInput = await webdriver.findElement(By.id('datetime')) @@ -59,14 +54,7 @@ describe('config: datetime', function () { await btn.click() - // Wait for navigation to argument form page - await webdriver.wait( - new Condition('wait for argument form page', async () => { - const url = await webdriver.getCurrentUrl() - return url.includes('/actionBinding/') && url.includes('/argumentForm') - }), - 8000 - ) + await waitForArgumentFormPage() // Find the datetime input field const datetimeInput = await webdriver.findElement(By.id('datetime')) @@ -74,7 +62,7 @@ describe('config: datetime', function () { // Set a datetime value (format: YYYY-MM-DDTHH:mm) // datetime-local returns values without seconds, backend will add :00 const testDateTime = '2023-12-25T15:30' - + // Use JavaScript to set the value directly (more reliable for datetime-local inputs) await webdriver.executeScript( 'arguments[0].value = arguments[1]', @@ -101,18 +89,10 @@ describe('config: datetime', function () { ) await submitButton.click() - // Wait for navigation to logs page - await webdriver.wait( - new Condition('wait for logs page', async () => { - const url = await webdriver.getCurrentUrl() - return url.includes('/logs/') - }), - 8000 - ) + await waitForLogsPage() // Verify we're on the logs page (action was executed) const url = await webdriver.getCurrentUrl() expect(url).to.include('/logs/') }) }) - diff --git a/integration-tests/tests/enabledExpression/enabledExpression.mjs b/integration-tests/tests/enabledExpression/enabledExpression.mjs index 98f32c0..8945940 100644 --- a/integration-tests/tests/enabledExpression/enabledExpression.mjs +++ b/integration-tests/tests/enabledExpression/enabledExpression.mjs @@ -44,7 +44,7 @@ describe('config: enabledExpression', function () { } // Accept either decoded or encoded version (component should decode, but handle both) return attr === 'LightDashboard' - }), 10000) + }), 3000) // Verify we got the correct dashboard (prefer decoded, but accept encoded) const body = await webdriver.findElement(By.tagName('body')) @@ -60,7 +60,7 @@ describe('config: enabledExpression', function () { // Debug: Check what's on the page const dashboardRows = await webdriver.findElements(By.css('.dashboard-row')) console.log(`Found ${dashboardRows.length} dashboard rows`) - + for (let i = 0; i < dashboardRows.length; i++) { const row = dashboardRows[i] const h2Elements = await row.findElements(By.css('h2')) @@ -82,25 +82,25 @@ describe('config: enabledExpression', function () { // Bedroom Light (powered_on: true) - Turn Off should be enabled, Turn On disabled let turnOnButton = null let turnOffButton = null - + for (const row of dashboardRows) { // Get the fieldset in this row const fieldsets = await row.findElements(By.css('fieldset')) if (fieldsets.length === 0) continue - + const buttons = await fieldsets[0].findElements(By.css('.action-button button')) - + // Check each button to identify which entity this row represents for (const btn of buttons) { const title = await btn.getAttribute('title') const disabled = await btn.getAttribute('disabled') const isEnabled = disabled === null - + if (title === 'Turn On Light' && isEnabled) { // This is the Living Room Light row (Turn On is enabled because powered_on: false) turnOnButton = btn } - + if (title === 'Turn Off Light' && isEnabled) { // This is the Bedroom Light row (Turn Off is enabled because powered_on: true) turnOffButton = btn @@ -127,7 +127,7 @@ describe('config: enabledExpression', function () { await webdriver.get(runner.baseUrl()) // Wait for action buttons - await webdriver.wait(until.elementLocated(By.css('.action-button')), 10000) + await webdriver.wait(until.elementLocated(By.css('.action-button')), 3000) // Find "Always Enabled Action" button const actionButtons = await webdriver.findElements(By.css('.action-button button')) diff --git a/integration-tests/tests/entities/entities.js b/integration-tests/tests/entities/entities.js index 43ebc81..f2e2673 100644 --- a/integration-tests/tests/entities/entities.js +++ b/integration-tests/tests/entities/entities.js @@ -1,15 +1,15 @@ import { describe, it, before, after } from 'mocha' import { expect } from 'chai' -import { By, until } from 'selenium-webdriver' -import { - getRootAndWait, - takeScreenshot, +import { By } from 'selenium-webdriver' +import { + getRootAndWait, takeScreenshotOnFailure, } from '../../lib/elements.js' describe('config: entities', function () { before(async function () { await runner.start('entities') + await getRootAndWait() }) after(async () => { diff --git a/integration-tests/tests/entityFilesWithLongIntsUseStandardForm/entityFilesWithLongIntsUseStandardForm.js b/integration-tests/tests/entityFilesWithLongIntsUseStandardForm/entityFilesWithLongIntsUseStandardForm.js index cc3a320..3849299 100644 --- a/integration-tests/tests/entityFilesWithLongIntsUseStandardForm/entityFilesWithLongIntsUseStandardForm.js +++ b/integration-tests/tests/entityFilesWithLongIntsUseStandardForm/entityFilesWithLongIntsUseStandardForm.js @@ -1,16 +1,19 @@ // Issue: https://github.com/OliveTin/OliveTin/issues/616 import { describe, it, before, after } from 'mocha' import { expect } from 'chai' -import { By, until, Condition } from 'selenium-webdriver' +import { By } from 'selenium-webdriver' import { getRootAndWait, getActionButtons, takeScreenshotOnFailure, + waitForLogsPage, + waitForExecutionComplete, } from '../../lib/elements.js' -describe('config: entities', function () { +describe('config: entityFilesWithLongIntsUseStandardForm', function () { before(async function () { await runner.start('entityFilesWithLongIntsUseStandardForm') + await getRootAndWait() }) after(async () => { @@ -34,19 +37,9 @@ describe('config: entities', function () { expect(await buttonInt10.getAttribute('title')).to.be.equal('Test me INT with 10 numbers') await buttonInt10.click() - // Wait for navigation to execution view - await webdriver.wait(new Condition('wait for execution view', async () => { - const url = await webdriver.getCurrentUrl() - return url.includes('/logs/') && !url.endsWith('/logs') - }), 10000) + await waitForLogsPage() + await waitForExecutionComplete() - // Wait for execution to complete - look for the execution status - await webdriver.wait(new Condition('wait for execution status', async () => { - const statusElement = await webdriver.findElements(By.id('execution-dialog-status')) - return statusElement.length > 0 - }), 15000) - - // Check that the execution completed successfully by looking at the status const statusElement = await webdriver.findElement(By.id('execution-dialog-status')) const statusText = await statusElement.getText() diff --git a/integration-tests/tests/suggestionsBrowserKey/suggestionsBrowserKey.mjs b/integration-tests/tests/suggestionsBrowserKey/suggestionsBrowserKey.mjs index c7f435d..beffc98 100644 --- a/integration-tests/tests/suggestionsBrowserKey/suggestionsBrowserKey.mjs +++ b/integration-tests/tests/suggestionsBrowserKey/suggestionsBrowserKey.mjs @@ -2,32 +2,54 @@ import { describe, it, before, after } from 'mocha' import { expect } from 'chai' import { By, Condition } from 'selenium-webdriver' import { + DEFAULT_UI_WAIT_MS, getRootAndWait, getActionButton, takeScreenshotOnFailure, + waitForDashboardLoaded, + waitForLogsPage, + waitForArgumentFormPage, + waitForArgumentFormReady, + waitForExecutionComplete, } from '../../lib/elements.js' -async function openArgumentForm() { +async function ensureOnDashboard() { + let url = await webdriver.getCurrentUrl() + + if (url.includes('/logs/')) { + const backButton = await webdriver.findElement(By.css('button[title="Go back"]')) + await backButton.click() + await webdriver.wait( + new Condition('wait for argument form after logs back', async () => { + const currentUrl = await webdriver.getCurrentUrl() + return currentUrl.includes('/argumentForm') + }), + DEFAULT_UI_WAIT_MS + ) + url = await webdriver.getCurrentUrl() + } + + if (url.includes('/argumentForm')) { + const cancelButton = await webdriver.findElement(By.css('button[name="cancel"]')) + await cancelButton.click() + await waitForDashboardLoaded() + } + + const actionButtons = await webdriver.findElements(By.css('[title="Test suggestionsBrowserKey"]')) + if (actionButtons.length === 1) { + return + } + await getRootAndWait() +} + +async function openArgumentForm() { + await ensureOnDashboard() const btn = await getActionButton(webdriver, 'Test suggestionsBrowserKey') await btn.click() - await webdriver.wait( - new Condition('wait for argument form page', async () => { - const url = await webdriver.getCurrentUrl() - return url.includes('/actionBinding/') && url.includes('/argumentForm') - }), - 5000 - ) - - await webdriver.wait( - new Condition('wait for argument form ready', async () => { - const body = await webdriver.findElement(By.css('body')) - const attr = await body.getAttribute('loaded-argument-form') - return attr != null && attr !== '' - }), - 5000 - ) + await waitForArgumentFormPage() + await waitForArgumentFormReady() } async function getTestInput() { @@ -47,41 +69,6 @@ async function submitForm() { await submitButton.click() } -async function waitForLogsPage() { - await webdriver.wait( - new Condition('wait for logs page', async () => { - const url = await webdriver.getCurrentUrl() - return url.includes('/logs/') && !url.endsWith('/logs') - }), - 15000 - ) -} - -async function waitForExecutionComplete() { - 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('Still running') - } catch (e) { - return false - } - }), - 5000 - ) - - await webdriver.sleep(500) -} - async function getLocalStorageItem(key) { return await webdriver.executeScript(`return localStorage.getItem('${key}')`) } @@ -93,6 +80,7 @@ async function clearLocalStorage() { describe('config: suggestionsBrowserKey', function () { before(async function () { await runner.start('suggestionsBrowserKey') + await getRootAndWait() }) after(async () => { @@ -122,11 +110,7 @@ describe('config: suggestionsBrowserKey', function () { }) it('Submitting form saves value to localStorage', async function () { - this.timeout(15000) - - // Clear localStorage first await clearLocalStorage() - await openArgumentForm() const input = await getTestInput() @@ -140,7 +124,6 @@ describe('config: suggestionsBrowserKey', function () { await waitForLogsPage() await waitForExecutionComplete() - // Verify value was saved to localStorage const stored = await getLocalStorageItem('olivetin-suggestions-test-suggestions-key') expect(stored).to.not.be.null @@ -150,26 +133,20 @@ describe('config: suggestionsBrowserKey', function () { }) it('Previously saved values appear in datalist', async function () { - this.timeout(15000) - - // First, save a value to localStorage const testValue = 'savedsuggestion456' await webdriver.executeScript(` const key = 'olivetin-suggestions-test-suggestions-key'; localStorage.setItem(key, JSON.stringify(['${testValue}'])); `) - // Open the form await openArgumentForm() - // Check that datalist exists and contains the saved value const datalist = await webdriver.findElement(By.id('testInput-choices')) expect(datalist).to.not.be.null const options = await getDatalistOptions() expect(options.length).to.be.greaterThan(0) - // Check if the saved value appears in the datalist let foundValue = false for (const option of options) { const value = await option.getAttribute('value') @@ -182,12 +159,8 @@ describe('config: suggestionsBrowserKey', function () { }) it('Multiple submissions accumulate suggestions', async function () { - this.timeout(20000) - - // Clear localStorage first await clearLocalStorage() - // Submit first value await openArgumentForm() const input1 = await getTestInput() await input1.clear() @@ -196,7 +169,6 @@ describe('config: suggestionsBrowserKey', function () { await waitForLogsPage() await waitForExecutionComplete() - // Submit second value await openArgumentForm() const input2 = await getTestInput() await input2.clear() @@ -205,7 +177,6 @@ describe('config: suggestionsBrowserKey', function () { await waitForLogsPage() await waitForExecutionComplete() - // Verify both values are in localStorage const stored = await getLocalStorageItem('olivetin-suggestions-test-suggestions-key') expect(stored).to.not.be.null @@ -213,43 +184,33 @@ describe('config: suggestionsBrowserKey', function () { expect(suggestions).to.be.an('array') expect(suggestions).to.include('firstvalue') expect(suggestions).to.include('secondvalue') - expect(suggestions[0]).to.equal('secondvalue') // Most recent should be first + expect(suggestions[0]).to.equal('secondvalue') }) it('Empty values are not saved to localStorage', async function () { - this.timeout(15000) - - // Clear localStorage first await clearLocalStorage() - await openArgumentForm() const input = await getTestInput() - // Leave input empty (or clear it if it has a default) await input.clear() await submitForm() await waitForLogsPage() await waitForExecutionComplete() - // Verify empty value was not saved - localStorage should be null or empty-equivalent const stored = await getLocalStorageItem('olivetin-suggestions-test-suggestions-key') - // Should be null OR empty JSON array string ("[]") OR parse to empty array if (stored !== null) { const suggestions = JSON.parse(stored) expect(suggestions).to.be.an('array') expect(suggestions).to.have.length(0) } - // If stored is null, that's also acceptable - no assertion needed }) it('Suggestions are shared across inputs with the same suggestionsBrowserKey', async function () { - this.timeout(20000) + this.timeout(12000) - // Clear localStorage first await clearLocalStorage() - // Submit a value using the first input await openArgumentForm() const input1 = await getTestInput() await input1.clear() @@ -258,10 +219,8 @@ describe('config: suggestionsBrowserKey', function () { await waitForLogsPage() await waitForExecutionComplete() - // Open the form again and verify the value appears in both datalists await openArgumentForm() - // Check first input's datalist const datalist1 = await webdriver.findElement(By.id('testInput-choices')) expect(datalist1).to.not.be.null const options1 = await getDatalistOptions('testInput') @@ -275,7 +234,6 @@ describe('config: suggestionsBrowserKey', function () { } expect(foundInInput1).to.be.true - // Check second input's datalist const datalist2 = await webdriver.findElement(By.id('testInput2-choices')) expect(datalist2).to.not.be.null const options2 = await getDatalistOptions('testInput2') @@ -289,7 +247,6 @@ describe('config: suggestionsBrowserKey', function () { } expect(foundInInput2).to.be.true - // Now submit a value using the second input const input2 = await getTestInput2() await input2.clear() await input2.sendKeys('sharedfrominput2') @@ -297,10 +254,8 @@ describe('config: suggestionsBrowserKey', function () { await waitForLogsPage() await waitForExecutionComplete() - // Verify both values appear in both datalists await openArgumentForm() - // Check that both values are in the first input's datalist const options1After = await getDatalistOptions('testInput') let foundValue1 = false let foundValue2 = false @@ -316,7 +271,6 @@ describe('config: suggestionsBrowserKey', function () { expect(foundValue1).to.be.true expect(foundValue2).to.be.true - // Check that both values are in the second input's datalist const options2After = await getDatalistOptions('testInput2') foundValue1 = false foundValue2 = false diff --git a/integration-tests/tests/xtermLinkHandling/xtermLinkHandling.mjs b/integration-tests/tests/xtermLinkHandling/xtermLinkHandling.mjs index 849f5a0..15f30c1 100644 --- a/integration-tests/tests/xtermLinkHandling/xtermLinkHandling.mjs +++ b/integration-tests/tests/xtermLinkHandling/xtermLinkHandling.mjs @@ -2,9 +2,12 @@ import { describe, it, before, after } from 'mocha' import { expect } from 'chai' import { By, Condition } from 'selenium-webdriver' import { + DEFAULT_UI_WAIT_MS, getRootAndWait, takeScreenshotOnFailure, getTerminalBuffer, + waitForLogsPage, + waitForExecutionComplete, } from '../../lib/elements.js' describe('config: xtermLinkHandling', function () { @@ -26,32 +29,13 @@ describe('config: xtermLinkHandling', function () { 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) + }), DEFAULT_UI_WAIT_MS) 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) + await waitForLogsPage() + await waitForExecutionComplete() const bufferText = await getTerminalBuffer() expect(bufferText).to.not.be.null