소스 검색

chore: Hardened take flakes

jamesread 2 주 전
부모
커밋
5690ef521a

+ 4 - 3
frontend/js/websocket.js

@@ -42,7 +42,7 @@ export function connectEventStreamIfNeeded () {
     return
     return
   }
   }
 
 
-  if (window.websocketAvailable || reconnectTimer != null) {
+  if (connectionState.connected || reconnectTimer != null) {
     return
     return
   }
   }
 
 
@@ -54,6 +54,7 @@ export function initWebsocket () {
     window.addEventListener('EventOutputChunk', onOutputChunk)
     window.addEventListener('EventOutputChunk', onOutputChunk)
     window.addEventListener('EventExecutionStarted', onExecutionChanged)
     window.addEventListener('EventExecutionStarted', onExecutionChanged)
     window.addEventListener('EventExecutionFinished', onExecutionChanged)
     window.addEventListener('EventExecutionFinished', onExecutionChanged)
+    window.addEventListener('pagehide', stopEventStream)
     listenersInitialized = true
     listenersInitialized = true
   }
   }
 
 
@@ -67,7 +68,7 @@ export function requestReconnectNow () {
     return
     return
   }
   }
 
 
-  if (window.websocketAvailable) {
+  if (connectionState.connected) {
     return
     return
   }
   }
 
 
@@ -109,7 +110,7 @@ async function reconnectWebsocket () {
     return
     return
   }
   }
 
 
-  if (window.websocketAvailable) {
+  if (connectionState.connected) {
     return
     return
   }
   }
 
 

+ 7 - 2
frontend/resources/vue/ActionButton.vue

@@ -261,10 +261,15 @@ async function startAction(actionArgs) {
   requestReconnectNow()
   requestReconnectNow()
 
 
   try {
   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) {
 	if (!connectionState.connected) {
-	  await pollExecutionUntilDone(startActionArgs.uniqueTrackingId)
+	  await pollExecutionUntilDone(trackingId)
 	}
 	}
   } catch (err) {
   } catch (err) {
 	console.error('Failed to start action:', err)
 	console.error('Failed to start action:', err)

+ 4 - 3
frontend/resources/vue/Dashboard.vue

@@ -61,7 +61,6 @@ import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
 import { useRouter } from 'vue-router'
 import { useRouter } from 'vue-router'
 import { HugeiconsIcon } from '@hugeicons/vue'
 import { HugeiconsIcon } from '@hugeicons/vue'
 import { Loading03Icon, ArrowLeftIcon } from '@hugeicons/core-free-icons'
 import { Loading03Icon, ArrowLeftIcon } from '@hugeicons/core-free-icons'
-import { requestReconnectNow } from '../../js/websocket.js'
 
 
 const props = defineProps({
 const props = defineProps({
     title: {
     title: {
@@ -107,8 +106,6 @@ function goBack() {
 }
 }
 
 
 async function getDashboard() {
 async function getDashboard() {
-    requestReconnectNow()
-
     let title = props.title
     let title = props.title
 
 
     // If no specific title was provided or it's the placeholder 'default',
     // If no specific title was provided or it's the placeholder 'default',
@@ -167,6 +164,8 @@ async function getDashboard() {
 }
 }
 
 
 function waitForInitAndLoadDashboard() {
 function waitForInitAndLoadDashboard() {
+    document.body.removeAttribute('loaded-dashboard')
+
     if (loadingTimer) {
     if (loadingTimer) {
         clearInterval(loadingTimer)
         clearInterval(loadingTimer)
         loadingTimer = null
         loadingTimer = null
@@ -226,6 +225,8 @@ watch(
 )
 )
 
 
 onUnmounted(() => {
 onUnmounted(() => {
+    document.body.removeAttribute('loaded-dashboard')
+
     // Clean up the timers when component is unmounted
     // Clean up the timers when component is unmounted
     if (loadingTimer) {
     if (loadingTimer) {
         clearInterval(loadingTimer)
         clearInterval(loadingTimer)

+ 15 - 13
frontend/resources/vue/router.js

@@ -1,5 +1,7 @@
 import { createRouter, createWebHistory } from 'vue-router'
 import { createRouter, createWebHistory } from 'vue-router'
 
 
+import Dashboard from './Dashboard.vue'
+
 import { Wrench01Icon } from '@hugeicons/core-free-icons'
 import { Wrench01Icon } from '@hugeicons/core-free-icons'
 import { LeftToRightListDashIcon } from '@hugeicons/core-free-icons'
 import { LeftToRightListDashIcon } from '@hugeicons/core-free-icons'
 import { CellsIcon } from '@hugeicons/core-free-icons'
 import { CellsIcon } from '@hugeicons/core-free-icons'
@@ -9,13 +11,13 @@ const routes = [
   {
   {
     path: '/',
     path: '/',
     name: 'Actions',
     name: 'Actions',
-    component: () => import('./Dashboard.vue'),
+    component: Dashboard,
     meta: { title: 'Actions', icon: DashboardSquare01Icon }
     meta: { title: 'Actions', icon: DashboardSquare01Icon }
   },
   },
   {
   {
     path: '/dashboards/:title/:entityType?/:entityKey?',
     path: '/dashboards/:title/:entityType?/:entityKey?',
     name: 'Dashboard',
     name: 'Dashboard',
-    component: () => import('./Dashboard.vue'),
+    component: Dashboard,
     props: true,
     props: true,
     meta: { title: 'Dashboard' }
     meta: { title: 'Dashboard' }
   },
   },
@@ -30,7 +32,7 @@ const routes = [
     path: '/logs',
     path: '/logs',
     name: 'Logs',
     name: 'Logs',
     component: () => import('./views/LogsListView.vue'),
     component: () => import('./views/LogsListView.vue'),
-    meta: { 
+    meta: {
       title: 'Logs',
       title: 'Logs',
       icon: LeftToRightListDashIcon
       icon: LeftToRightListDashIcon
     }
     }
@@ -39,7 +41,7 @@ const routes = [
     path: '/logs/calendar',
     path: '/logs/calendar',
     name: 'LogsCalendar',
     name: 'LogsCalendar',
     component: () => import('./views/LogsCalendarView.vue'),
     component: () => import('./views/LogsCalendarView.vue'),
-    meta: { 
+    meta: {
       title: 'Logs Calendar',
       title: 'Logs Calendar',
       breadcrumb: [
       breadcrumb: [
         { name: "Logs", href: "/logs" },
         { name: "Logs", href: "/logs" },
@@ -51,7 +53,7 @@ const routes = [
     path: '/entities',
     path: '/entities',
     name: 'Entities',
     name: 'Entities',
     component: () => import('./views/EntitiesView.vue'),
     component: () => import('./views/EntitiesView.vue'),
-    meta: { 
+    meta: {
       title: 'Entities',
       title: 'Entities',
       icon: CellsIcon
       icon: CellsIcon
     }
     }
@@ -61,8 +63,8 @@ const routes = [
     name: 'EntityDetails',
     name: 'EntityDetails',
     component: () => import('./views/EntityDetailsView.vue'),
     component: () => import('./views/EntityDetailsView.vue'),
     props: true,
     props: true,
-    meta: { 
-      title: 'OliveTin - Entity Details', 
+    meta: {
+      title: 'OliveTin - Entity Details',
       breadcrumb: [
       breadcrumb: [
         { name: "Entities", href: "/entities" },
         { name: "Entities", href: "/entities" },
         { name: "Entity Details" }
         { name: "Entity Details" }
@@ -74,8 +76,8 @@ const routes = [
     name: 'Execution',
     name: 'Execution',
     component: () => import('./views/ExecutionView.vue'),
     component: () => import('./views/ExecutionView.vue'),
     props: true,
     props: true,
-    meta: { 
-      title: 'Execution', 
+    meta: {
+      title: 'Execution',
       breadcrumb: [
       breadcrumb: [
         { name: "Logs", href: "/logs" },
         { name: "Logs", href: "/logs" },
         { name: "Execution" },
         { name: "Execution" },
@@ -87,7 +89,7 @@ const routes = [
     name: 'ActionDetails',
     name: 'ActionDetails',
     component: () => import('./views/ActionDetailsView.vue'),
     component: () => import('./views/ActionDetailsView.vue'),
     props: true,
     props: true,
-    meta: { 
+    meta: {
       title: 'Action Details',
       title: 'Action Details',
       breadcrumb: [
       breadcrumb: [
         { name: "Actions", href: "/" },
         { name: "Actions", href: "/" },
@@ -112,7 +114,7 @@ const routes = [
     path: '/diagnostics',
     path: '/diagnostics',
     name: 'Diagnostics',
     name: 'Diagnostics',
     component: () => import('./views/DiagnosticsView.vue'),
     component: () => import('./views/DiagnosticsView.vue'),
-    meta: { 
+    meta: {
       title: 'Diagnostics',
       title: 'Diagnostics',
       icon: Wrench01Icon
       icon: Wrench01Icon
     }
     }
@@ -163,7 +165,7 @@ router.beforeEach((to, from, next) => {
 router.beforeEach((to, from, next) => {
 router.beforeEach((to, from, next) => {
   // Check if user is authenticated for protected routes
   // Check if user is authenticated for protected routes
   const isAuthenticated = window.isAuthenticated || true // Default to true for now
   const isAuthenticated = window.isAuthenticated || true // Default to true for now
-  
+
   if (to.meta.requiresAuth && !isAuthenticated) {
   if (to.meta.requiresAuth && !isAuthenticated) {
     next('/login')
     next('/login')
   } else {
   } else {
@@ -171,4 +173,4 @@ router.beforeEach((to, from, next) => {
   }
   }
 })
 })
 
 
-export default router 
+export default router

+ 53 - 12
integration-tests/lib/elements.js

@@ -3,6 +3,8 @@ import fs from 'fs'
 import { expect } from 'chai'
 import { expect } from 'chai'
 import { Condition } from 'selenium-webdriver'
 import { Condition } from 'selenium-webdriver'
 
 
+export const DEFAULT_UI_WAIT_MS = 3000
+
 export async function getActionButtons () {
 export async function getActionButtons () {
   // Currently, only the active dashboard's contents are rendered,
   // Currently, only the active dashboard's contents are rendered,
   // so we don't need to scope the selector by dashboard title.
   // so we don't need to scope the selector by dashboard title.
@@ -10,11 +12,11 @@ export async function getActionButtons () {
 }
 }
 
 
 export async function getExecutionDialogOutput() {
 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' })
       const dialog = await webdriver.findElement({ id: 'execution-results-popup' })
       return await dialog.isDisplayed()
       return await dialog.isDisplayed()
     }));
     }));
-    
+
     const ret = await webdriver.executeScript('return window.logEntries.get(window.executionDialog.executionTrackingId).output')
     const ret = await webdriver.executeScript('return window.logEntries.get(window.executionDialog.executionTrackingId).output')
 
 
     return ret
     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 body = await webdriver.findElement(By.tagName('body'))
     const attr = await body.getAttribute('loaded-dashboard')
     const attr = await body.getAttribute('loaded-dashboard')
 
 
     console.log('loaded-dashboard: ', attr)
     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
       return false
     }
     }
-  }))
+  }), timeoutMs)
+}
+
+export async function getRootAndWait() {
+  await webdriver.get(runner.baseUrl())
+  await waitForDashboardLoaded()
 }
 }
 
 
 export async function closeSidebar() {
 export async function closeSidebar() {
@@ -82,7 +123,7 @@ export async function closeSidebar() {
       console.log('Sidebar closed, left is: *' + left, left === neededLeft ? ' (as expected)' : '')
       console.log('Sidebar closed, left is: *' + left, left === neededLeft ? ' (as expected)' : '')
       return left === neededLeft
       return left === neededLeft
     }
     }
-  }), 10000); // Wait up to 10 seconds for the sidebar to close
+  }), DEFAULT_UI_WAIT_MS)
 }
 }
 
 
 export async function openSidebar() {
 export async function openSidebar() {
@@ -103,7 +144,7 @@ export async function openSidebar() {
       console.log('Sidebar opened, left is: ', left)
       console.log('Sidebar opened, left is: ', left)
       return true
       return true
     }
     }
-  }));
+  }), DEFAULT_UI_WAIT_MS)
 }
 }
 
 
 export async function getNavigationLinks() {
 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)
       console.log('Waiting for domStatus text to be: ', expected, ', it is currently: ', actual)
       return false
       return false
     }
     }
-  }))
+  }), DEFAULT_UI_WAIT_MS)
 }
 }
 
 
 export async function findExecutionDialog (webdriver) {
 export async function findExecutionDialog (webdriver) {

+ 18 - 4
integration-tests/runner.mjs

@@ -33,6 +33,10 @@ class OliveTinTestRunner {
 
 
 class OliveTinTestRunnerStartLocalProcess extends OliveTinTestRunner {
 class OliveTinTestRunnerStartLocalProcess extends OliveTinTestRunner {
   async start (cfg) {
   async start (cfg) {
+    if (this.ot != null && this.ot.exitCode == null) {
+      await this.stop()
+    }
+
     let stdout = ""
     let stdout = ""
     let stderr = ""
     let stderr = ""
 
 
@@ -94,13 +98,23 @@ class OliveTinTestRunnerStartLocalProcess extends OliveTinTestRunner {
   }
   }
 
 
   async stop () {
   async stop () {
-    if ((await this.ot.exitCode) != null) {
-      console.log("      OliveTin local process tried stop(), but it already exited with code", this.ot.exitCode)
+    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 {
     } else {
-      await this.ot.kill()
-      console.log("      OliveTin local process killed")
+      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') {
     if (process.env.CI === 'true') {
       // GitHub runners seem to need a bit more time to clean up
       // GitHub runners seem to need a bit more time to clean up
       await new Promise((res) => setTimeout(res, 3000))
       await new Promise((res) => setTimeout(res, 3000))

+ 8 - 50
integration-tests/tests/checkbox/checkbox.mjs

@@ -2,10 +2,14 @@ import { describe, it, before, after } from 'mocha'
 import { expect } from 'chai'
 import { expect } from 'chai'
 import { By, Condition } from 'selenium-webdriver'
 import { By, Condition } from 'selenium-webdriver'
 import {
 import {
+  DEFAULT_UI_WAIT_MS,
   getRootAndWait,
   getRootAndWait,
   getActionButton,
   getActionButton,
   takeScreenshotOnFailure,
   takeScreenshotOnFailure,
   getTerminalBuffer,
   getTerminalBuffer,
+  waitForArgumentFormPage,
+  waitForLogsPage,
+  waitForExecutionComplete,
 } from '../../lib/elements.js'
 } from '../../lib/elements.js'
 
 
 async function openCheckboxArgumentForm() {
 async function openCheckboxArgumentForm() {
@@ -13,13 +17,7 @@ async function openCheckboxArgumentForm() {
   const btn = await getActionButton(webdriver, 'Test checkbox argument')
   const btn = await getActionButton(webdriver, 'Test checkbox argument')
   await btn.click()
   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() {
 async function getCheckboxInput() {
@@ -31,42 +29,6 @@ async function submitCheckboxForm() {
   await submitButton.click()
   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) {
 async function waitForTerminalOutput(expectedValue) {
   await webdriver.wait(
   await webdriver.wait(
     new Condition(`wait for checkbox value ${expectedValue} in output`, async () => {
     new Condition(`wait for checkbox value ${expectedValue} in output`, async () => {
@@ -77,18 +39,18 @@ async function waitForTerminalOutput(expectedValue) {
         if (!terminalReady) {
         if (!terminalReady) {
           return false
           return false
         }
         }
-        
+
         const output = await getTerminalBuffer()
         const output = await getTerminalBuffer()
         if (!output) {
         if (!output) {
           return false
           return false
         }
         }
-        
+
         return output.trim().includes(`Checkbox value: ${expectedValue}`)
         return output.trim().includes(`Checkbox value: ${expectedValue}`)
       } catch (e) {
       } catch (e) {
         return false
         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 () {
   it('Checkbox argument submits 0 by default when unchecked', async function () {
-    this.timeout(15000)
     await openCheckboxArgumentForm()
     await openCheckboxArgumentForm()
 
 
     const checkboxInput = await getCheckboxInput()
     const checkboxInput = await getCheckboxInput()
@@ -131,7 +92,6 @@ describe('config: checkbox', function () {
   })
   })
 
 
   it('Checkbox argument can be toggled and submitted', async function () {
   it('Checkbox argument can be toggled and submitted', async function () {
-    this.timeout(15000)
     await openCheckboxArgumentForm()
     await openCheckboxArgumentForm()
 
 
     const checkboxInput = await getCheckboxInput()
     const checkboxInput = await getCheckboxInput()
@@ -146,5 +106,3 @@ describe('config: checkbox', function () {
     await waitForTerminalOutput('1')
     await waitForTerminalOutput('1')
   })
   })
 })
 })
-
-

+ 7 - 27
integration-tests/tests/datetime/datetime.mjs

@@ -1,10 +1,12 @@
 import { describe, it, before, after } from 'mocha'
 import { describe, it, before, after } from 'mocha'
 import { expect } from 'chai'
 import { expect } from 'chai'
-import { By, Condition } from 'selenium-webdriver'
+import { By } from 'selenium-webdriver'
 import {
 import {
   getRootAndWait,
   getRootAndWait,
   getActionButton,
   getActionButton,
   takeScreenshotOnFailure,
   takeScreenshotOnFailure,
+  waitForArgumentFormPage,
+  waitForLogsPage,
 } from '../../lib/elements.js'
 } from '../../lib/elements.js'
 
 
 describe('config: datetime', function () {
 describe('config: datetime', function () {
@@ -27,14 +29,7 @@ describe('config: datetime', function () {
 
 
     await btn.click()
     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
     // Find the datetime input field
     const datetimeInput = await webdriver.findElement(By.id('datetime'))
     const datetimeInput = await webdriver.findElement(By.id('datetime'))
@@ -59,14 +54,7 @@ describe('config: datetime', function () {
 
 
     await btn.click()
     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
     // Find the datetime input field
     const datetimeInput = await webdriver.findElement(By.id('datetime'))
     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)
     // Set a datetime value (format: YYYY-MM-DDTHH:mm)
     // datetime-local returns values without seconds, backend will add :00
     // datetime-local returns values without seconds, backend will add :00
     const testDateTime = '2023-12-25T15:30'
     const testDateTime = '2023-12-25T15:30'
-    
+
     // Use JavaScript to set the value directly (more reliable for datetime-local inputs)
     // Use JavaScript to set the value directly (more reliable for datetime-local inputs)
     await webdriver.executeScript(
     await webdriver.executeScript(
       'arguments[0].value = arguments[1]',
       'arguments[0].value = arguments[1]',
@@ -101,18 +89,10 @@ describe('config: datetime', function () {
     )
     )
     await submitButton.click()
     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)
     // Verify we're on the logs page (action was executed)
     const url = await webdriver.getCurrentUrl()
     const url = await webdriver.getCurrentUrl()
     expect(url).to.include('/logs/')
     expect(url).to.include('/logs/')
   })
   })
 })
 })
-

+ 8 - 8
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)
       // Accept either decoded or encoded version (component should decode, but handle both)
       return attr === 'LightDashboard'
       return attr === 'LightDashboard'
-    }), 10000)
+    }), 3000)
 
 
     // Verify we got the correct dashboard (prefer decoded, but accept encoded)
     // Verify we got the correct dashboard (prefer decoded, but accept encoded)
     const body = await webdriver.findElement(By.tagName('body'))
     const body = await webdriver.findElement(By.tagName('body'))
@@ -60,7 +60,7 @@ describe('config: enabledExpression', function () {
     // Debug: Check what's on the page
     // Debug: Check what's on the page
     const dashboardRows = await webdriver.findElements(By.css('.dashboard-row'))
     const dashboardRows = await webdriver.findElements(By.css('.dashboard-row'))
     console.log(`Found ${dashboardRows.length} dashboard rows`)
     console.log(`Found ${dashboardRows.length} dashboard rows`)
-    
+
     for (let i = 0; i < dashboardRows.length; i++) {
     for (let i = 0; i < dashboardRows.length; i++) {
       const row = dashboardRows[i]
       const row = dashboardRows[i]
       const h2Elements = await row.findElements(By.css('h2'))
       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
     // Bedroom Light (powered_on: true) - Turn Off should be enabled, Turn On disabled
     let turnOnButton = null
     let turnOnButton = null
     let turnOffButton = null
     let turnOffButton = null
-    
+
     for (const row of dashboardRows) {
     for (const row of dashboardRows) {
       // Get the fieldset in this row
       // Get the fieldset in this row
       const fieldsets = await row.findElements(By.css('fieldset'))
       const fieldsets = await row.findElements(By.css('fieldset'))
       if (fieldsets.length === 0) continue
       if (fieldsets.length === 0) continue
-      
+
       const buttons = await fieldsets[0].findElements(By.css('.action-button button'))
       const buttons = await fieldsets[0].findElements(By.css('.action-button button'))
-      
+
       // Check each button to identify which entity this row represents
       // Check each button to identify which entity this row represents
       for (const btn of buttons) {
       for (const btn of buttons) {
         const title = await btn.getAttribute('title')
         const title = await btn.getAttribute('title')
         const disabled = await btn.getAttribute('disabled')
         const disabled = await btn.getAttribute('disabled')
         const isEnabled = disabled === null
         const isEnabled = disabled === null
-        
+
         if (title === 'Turn On Light' && isEnabled) {
         if (title === 'Turn On Light' && isEnabled) {
           // This is the Living Room Light row (Turn On is enabled because powered_on: false)
           // This is the Living Room Light row (Turn On is enabled because powered_on: false)
           turnOnButton = btn
           turnOnButton = btn
         }
         }
-        
+
         if (title === 'Turn Off Light' && isEnabled) {
         if (title === 'Turn Off Light' && isEnabled) {
           // This is the Bedroom Light row (Turn Off is enabled because powered_on: true)
           // This is the Bedroom Light row (Turn Off is enabled because powered_on: true)
           turnOffButton = btn
           turnOffButton = btn
@@ -127,7 +127,7 @@ describe('config: enabledExpression', function () {
     await webdriver.get(runner.baseUrl())
     await webdriver.get(runner.baseUrl())
 
 
     // Wait for action buttons
     // 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
     // Find "Always Enabled Action" button
     const actionButtons = await webdriver.findElements(By.css('.action-button button'))
     const actionButtons = await webdriver.findElements(By.css('.action-button button'))

+ 4 - 4
integration-tests/tests/entities/entities.js

@@ -1,15 +1,15 @@
 import { describe, it, before, after } from 'mocha'
 import { describe, it, before, after } from 'mocha'
 import { expect } from 'chai'
 import { expect } from 'chai'
-import { By, until } from 'selenium-webdriver'
-import { 
-  getRootAndWait, 
-  takeScreenshot,
+import { By } from 'selenium-webdriver'
+import {
+  getRootAndWait,
   takeScreenshotOnFailure,
   takeScreenshotOnFailure,
 } from '../../lib/elements.js'
 } from '../../lib/elements.js'
 
 
 describe('config: entities', function () {
 describe('config: entities', function () {
   before(async function () {
   before(async function () {
     await runner.start('entities')
     await runner.start('entities')
+    await getRootAndWait()
   })
   })
 
 
   after(async () => {
   after(async () => {

+ 7 - 14
integration-tests/tests/entityFilesWithLongIntsUseStandardForm/entityFilesWithLongIntsUseStandardForm.js

@@ -1,16 +1,19 @@
 // Issue: https://github.com/OliveTin/OliveTin/issues/616
 // Issue: https://github.com/OliveTin/OliveTin/issues/616
 import { describe, it, before, after } from 'mocha'
 import { describe, it, before, after } from 'mocha'
 import { expect } from 'chai'
 import { expect } from 'chai'
-import { By, until, Condition } from 'selenium-webdriver'
+import { By } from 'selenium-webdriver'
 import {
 import {
   getRootAndWait,
   getRootAndWait,
   getActionButtons,
   getActionButtons,
   takeScreenshotOnFailure,
   takeScreenshotOnFailure,
+  waitForLogsPage,
+  waitForExecutionComplete,
 } from '../../lib/elements.js'
 } from '../../lib/elements.js'
 
 
-describe('config: entities', function () {
+describe('config: entityFilesWithLongIntsUseStandardForm', function () {
   before(async function () {
   before(async function () {
     await runner.start('entityFilesWithLongIntsUseStandardForm')
     await runner.start('entityFilesWithLongIntsUseStandardForm')
+    await getRootAndWait()
   })
   })
 
 
   after(async () => {
   after(async () => {
@@ -34,19 +37,9 @@ describe('config: entities', function () {
     expect(await buttonInt10.getAttribute('title')).to.be.equal('Test me INT with 10 numbers')
     expect(await buttonInt10.getAttribute('title')).to.be.equal('Test me INT with 10 numbers')
     await buttonInt10.click()
     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)
-
-    // 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)
+    await waitForLogsPage()
+    await waitForExecutionComplete()
 
 
-    // Check that the execution completed successfully by looking at the status
     const statusElement = await webdriver.findElement(By.id('execution-dialog-status'))
     const statusElement = await webdriver.findElement(By.id('execution-dialog-status'))
     const statusText = await statusElement.getText()
     const statusText = await statusElement.getText()
 
 

+ 42 - 88
integration-tests/tests/suggestionsBrowserKey/suggestionsBrowserKey.mjs

@@ -2,32 +2,54 @@ import { describe, it, before, after } from 'mocha'
 import { expect } from 'chai'
 import { expect } from 'chai'
 import { By, Condition } from 'selenium-webdriver'
 import { By, Condition } from 'selenium-webdriver'
 import {
 import {
+  DEFAULT_UI_WAIT_MS,
   getRootAndWait,
   getRootAndWait,
   getActionButton,
   getActionButton,
   takeScreenshotOnFailure,
   takeScreenshotOnFailure,
+  waitForDashboardLoaded,
+  waitForLogsPage,
+  waitForArgumentFormPage,
+  waitForArgumentFormReady,
+  waitForExecutionComplete,
 } from '../../lib/elements.js'
 } 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()
   await getRootAndWait()
+}
+
+async function openArgumentForm() {
+  await ensureOnDashboard()
   const btn = await getActionButton(webdriver, 'Test suggestionsBrowserKey')
   const btn = await getActionButton(webdriver, 'Test suggestionsBrowserKey')
   await btn.click()
   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() {
 async function getTestInput() {
@@ -47,41 +69,6 @@ async function submitForm() {
   await submitButton.click()
   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) {
 async function getLocalStorageItem(key) {
   return await webdriver.executeScript(`return localStorage.getItem('${key}')`)
   return await webdriver.executeScript(`return localStorage.getItem('${key}')`)
 }
 }
@@ -93,6 +80,7 @@ async function clearLocalStorage() {
 describe('config: suggestionsBrowserKey', function () {
 describe('config: suggestionsBrowserKey', function () {
   before(async function () {
   before(async function () {
     await runner.start('suggestionsBrowserKey')
     await runner.start('suggestionsBrowserKey')
+    await getRootAndWait()
   })
   })
 
 
   after(async () => {
   after(async () => {
@@ -122,11 +110,7 @@ describe('config: suggestionsBrowserKey', function () {
   })
   })
 
 
   it('Submitting form saves value to localStorage', async function () {
   it('Submitting form saves value to localStorage', async function () {
-    this.timeout(15000)
-
-    // Clear localStorage first
     await clearLocalStorage()
     await clearLocalStorage()
-
     await openArgumentForm()
     await openArgumentForm()
 
 
     const input = await getTestInput()
     const input = await getTestInput()
@@ -140,7 +124,6 @@ describe('config: suggestionsBrowserKey', function () {
     await waitForLogsPage()
     await waitForLogsPage()
     await waitForExecutionComplete()
     await waitForExecutionComplete()
 
 
-    // Verify value was saved to localStorage
     const stored = await getLocalStorageItem('olivetin-suggestions-test-suggestions-key')
     const stored = await getLocalStorageItem('olivetin-suggestions-test-suggestions-key')
     expect(stored).to.not.be.null
     expect(stored).to.not.be.null
 
 
@@ -150,26 +133,20 @@ describe('config: suggestionsBrowserKey', function () {
   })
   })
 
 
   it('Previously saved values appear in datalist', async function () {
   it('Previously saved values appear in datalist', async function () {
-    this.timeout(15000)
-
-    // First, save a value to localStorage
     const testValue = 'savedsuggestion456'
     const testValue = 'savedsuggestion456'
     await webdriver.executeScript(`
     await webdriver.executeScript(`
       const key = 'olivetin-suggestions-test-suggestions-key';
       const key = 'olivetin-suggestions-test-suggestions-key';
       localStorage.setItem(key, JSON.stringify(['${testValue}']));
       localStorage.setItem(key, JSON.stringify(['${testValue}']));
     `)
     `)
 
 
-    // Open the form
     await openArgumentForm()
     await openArgumentForm()
 
 
-    // Check that datalist exists and contains the saved value
     const datalist = await webdriver.findElement(By.id('testInput-choices'))
     const datalist = await webdriver.findElement(By.id('testInput-choices'))
     expect(datalist).to.not.be.null
     expect(datalist).to.not.be.null
 
 
     const options = await getDatalistOptions()
     const options = await getDatalistOptions()
     expect(options.length).to.be.greaterThan(0)
     expect(options.length).to.be.greaterThan(0)
 
 
-    // Check if the saved value appears in the datalist
     let foundValue = false
     let foundValue = false
     for (const option of options) {
     for (const option of options) {
       const value = await option.getAttribute('value')
       const value = await option.getAttribute('value')
@@ -182,12 +159,8 @@ describe('config: suggestionsBrowserKey', function () {
   })
   })
 
 
   it('Multiple submissions accumulate suggestions', async function () {
   it('Multiple submissions accumulate suggestions', async function () {
-    this.timeout(20000)
-
-    // Clear localStorage first
     await clearLocalStorage()
     await clearLocalStorage()
 
 
-    // Submit first value
     await openArgumentForm()
     await openArgumentForm()
     const input1 = await getTestInput()
     const input1 = await getTestInput()
     await input1.clear()
     await input1.clear()
@@ -196,7 +169,6 @@ describe('config: suggestionsBrowserKey', function () {
     await waitForLogsPage()
     await waitForLogsPage()
     await waitForExecutionComplete()
     await waitForExecutionComplete()
 
 
-    // Submit second value
     await openArgumentForm()
     await openArgumentForm()
     const input2 = await getTestInput()
     const input2 = await getTestInput()
     await input2.clear()
     await input2.clear()
@@ -205,7 +177,6 @@ describe('config: suggestionsBrowserKey', function () {
     await waitForLogsPage()
     await waitForLogsPage()
     await waitForExecutionComplete()
     await waitForExecutionComplete()
 
 
-    // Verify both values are in localStorage
     const stored = await getLocalStorageItem('olivetin-suggestions-test-suggestions-key')
     const stored = await getLocalStorageItem('olivetin-suggestions-test-suggestions-key')
     expect(stored).to.not.be.null
     expect(stored).to.not.be.null
 
 
@@ -213,43 +184,33 @@ describe('config: suggestionsBrowserKey', function () {
     expect(suggestions).to.be.an('array')
     expect(suggestions).to.be.an('array')
     expect(suggestions).to.include('firstvalue')
     expect(suggestions).to.include('firstvalue')
     expect(suggestions).to.include('secondvalue')
     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 () {
   it('Empty values are not saved to localStorage', async function () {
-    this.timeout(15000)
-
-    // Clear localStorage first
     await clearLocalStorage()
     await clearLocalStorage()
-
     await openArgumentForm()
     await openArgumentForm()
 
 
     const input = await getTestInput()
     const input = await getTestInput()
-    // Leave input empty (or clear it if it has a default)
     await input.clear()
     await input.clear()
 
 
     await submitForm()
     await submitForm()
     await waitForLogsPage()
     await waitForLogsPage()
     await waitForExecutionComplete()
     await waitForExecutionComplete()
 
 
-    // Verify empty value was not saved - localStorage should be null or empty-equivalent
     const stored = await getLocalStorageItem('olivetin-suggestions-test-suggestions-key')
     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) {
     if (stored !== null) {
       const suggestions = JSON.parse(stored)
       const suggestions = JSON.parse(stored)
       expect(suggestions).to.be.an('array')
       expect(suggestions).to.be.an('array')
       expect(suggestions).to.have.length(0)
       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 () {
   it('Suggestions are shared across inputs with the same suggestionsBrowserKey', async function () {
-    this.timeout(20000)
+    this.timeout(12000)
 
 
-    // Clear localStorage first
     await clearLocalStorage()
     await clearLocalStorage()
 
 
-    // Submit a value using the first input
     await openArgumentForm()
     await openArgumentForm()
     const input1 = await getTestInput()
     const input1 = await getTestInput()
     await input1.clear()
     await input1.clear()
@@ -258,10 +219,8 @@ describe('config: suggestionsBrowserKey', function () {
     await waitForLogsPage()
     await waitForLogsPage()
     await waitForExecutionComplete()
     await waitForExecutionComplete()
 
 
-    // Open the form again and verify the value appears in both datalists
     await openArgumentForm()
     await openArgumentForm()
 
 
-    // Check first input's datalist
     const datalist1 = await webdriver.findElement(By.id('testInput-choices'))
     const datalist1 = await webdriver.findElement(By.id('testInput-choices'))
     expect(datalist1).to.not.be.null
     expect(datalist1).to.not.be.null
     const options1 = await getDatalistOptions('testInput')
     const options1 = await getDatalistOptions('testInput')
@@ -275,7 +234,6 @@ describe('config: suggestionsBrowserKey', function () {
     }
     }
     expect(foundInInput1).to.be.true
     expect(foundInInput1).to.be.true
 
 
-    // Check second input's datalist
     const datalist2 = await webdriver.findElement(By.id('testInput2-choices'))
     const datalist2 = await webdriver.findElement(By.id('testInput2-choices'))
     expect(datalist2).to.not.be.null
     expect(datalist2).to.not.be.null
     const options2 = await getDatalistOptions('testInput2')
     const options2 = await getDatalistOptions('testInput2')
@@ -289,7 +247,6 @@ describe('config: suggestionsBrowserKey', function () {
     }
     }
     expect(foundInInput2).to.be.true
     expect(foundInInput2).to.be.true
 
 
-    // Now submit a value using the second input
     const input2 = await getTestInput2()
     const input2 = await getTestInput2()
     await input2.clear()
     await input2.clear()
     await input2.sendKeys('sharedfrominput2')
     await input2.sendKeys('sharedfrominput2')
@@ -297,10 +254,8 @@ describe('config: suggestionsBrowserKey', function () {
     await waitForLogsPage()
     await waitForLogsPage()
     await waitForExecutionComplete()
     await waitForExecutionComplete()
 
 
-    // Verify both values appear in both datalists
     await openArgumentForm()
     await openArgumentForm()
 
 
-    // Check that both values are in the first input's datalist
     const options1After = await getDatalistOptions('testInput')
     const options1After = await getDatalistOptions('testInput')
     let foundValue1 = false
     let foundValue1 = false
     let foundValue2 = false
     let foundValue2 = false
@@ -316,7 +271,6 @@ describe('config: suggestionsBrowserKey', function () {
     expect(foundValue1).to.be.true
     expect(foundValue1).to.be.true
     expect(foundValue2).to.be.true
     expect(foundValue2).to.be.true
 
 
-    // Check that both values are in the second input's datalist
     const options2After = await getDatalistOptions('testInput2')
     const options2After = await getDatalistOptions('testInput2')
     foundValue1 = false
     foundValue1 = false
     foundValue2 = false
     foundValue2 = false

+ 6 - 22
integration-tests/tests/xtermLinkHandling/xtermLinkHandling.mjs

@@ -2,9 +2,12 @@ import { describe, it, before, after } from 'mocha'
 import { expect } from 'chai'
 import { expect } from 'chai'
 import { By, Condition } from 'selenium-webdriver'
 import { By, Condition } from 'selenium-webdriver'
 import {
 import {
+  DEFAULT_UI_WAIT_MS,
   getRootAndWait,
   getRootAndWait,
   takeScreenshotOnFailure,
   takeScreenshotOnFailure,
   getTerminalBuffer,
   getTerminalBuffer,
+  waitForLogsPage,
+  waitForExecutionComplete,
 } from '../../lib/elements.js'
 } from '../../lib/elements.js'
 
 
 describe('config: xtermLinkHandling', function () {
 describe('config: xtermLinkHandling', function () {
@@ -26,32 +29,13 @@ describe('config: xtermLinkHandling', function () {
     await webdriver.wait(new Condition('wait for Echo URL button', async () => {
     await webdriver.wait(new Condition('wait for Echo URL button', async () => {
       const btns = await webdriver.findElements(By.css('[title="Echo URL"]'))
       const btns = await webdriver.findElements(By.css('[title="Echo URL"]'))
       return btns.length === 1
       return btns.length === 1
-    }), 10000)
+    }), DEFAULT_UI_WAIT_MS)
 
 
     const echoUrlButton = await webdriver.findElement(By.css('[title="Echo URL"]'))
     const echoUrlButton = await webdriver.findElement(By.css('[title="Echo URL"]'))
     await echoUrlButton.click()
     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()
     const bufferText = await getTerminalBuffer()
     expect(bufferText).to.not.be.null
     expect(bufferText).to.not.be.null