chore: Hardened take flakes

This commit is contained in:
jamesread 2026-06-15 23:40:18 +01:00
parent 1499d5bdf5
commit 5690ef521a
13 changed files with 184 additions and 251 deletions

View File

@ -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
} }

View File

@ -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)

View File

@ -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)

View File

@ -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: { meta: {
title: 'OliveTin - Entity Details', 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: { meta: {
title: 'Execution', 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

View File

@ -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() { export async function waitForDashboardLoaded(timeoutMs = DEFAULT_UI_WAIT_MS) {
await webdriver.get(runner.baseUrl()) await webdriver.wait(new Condition('wait for loaded-dashboard', async function () {
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 attr != null && attr !== ''
return true }), timeoutMs)
} else { }
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) {

View File

@ -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) { if (this.ot == null) {
console.log(" OliveTin local process tried stop(), but it already exited with code", this.ot.exitCode) return
} else {
await this.ot.kill()
console.log(" OliveTin local process killed")
} }
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') { 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))

View File

@ -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( await waitForArgumentFormPage()
new Condition('wait for argument form page', async () => {
const url = await webdriver.getCurrentUrl()
return url.includes('/actionBinding/') && url.includes('/argumentForm')
}),
5000
)
} }
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')
}) })
}) })

View File

@ -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 waitForArgumentFormPage()
await webdriver.wait(
new Condition('wait for argument form page', async () => {
const url = await webdriver.getCurrentUrl()
return url.includes('/actionBinding/') && url.includes('/argumentForm')
}),
8000
)
// 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 waitForArgumentFormPage()
await webdriver.wait(
new Condition('wait for argument form page', async () => {
const url = await webdriver.getCurrentUrl()
return url.includes('/actionBinding/') && url.includes('/argumentForm')
}),
8000
)
// 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 waitForLogsPage()
await webdriver.wait(
new Condition('wait for logs page', async () => {
const url = await webdriver.getCurrentUrl()
return url.includes('/logs/')
}),
8000
)
// 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/')
}) })
}) })

View File

@ -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'))

View File

@ -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 { By } from 'selenium-webdriver'
import { import {
getRootAndWait, getRootAndWait,
takeScreenshot,
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 () => {

View File

@ -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 waitForLogsPage()
await webdriver.wait(new Condition('wait for execution view', async () => { await waitForExecutionComplete()
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)
// 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()

View File

@ -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( await waitForArgumentFormPage()
new Condition('wait for argument form page', async () => { await waitForArgumentFormReady()
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
)
} }
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

View File

@ -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 () => { await waitForLogsPage()
const url = await webdriver.getCurrentUrl() await waitForExecutionComplete()
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() const bufferText = await getTerminalBuffer()
expect(bufferText).to.not.be.null expect(bufferText).to.not.be.null