diff --git a/frontend/package.json b/frontend/package.json index e7c75e1d..c6f77dbd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,7 @@ "stylelint-config-standard": "^40.0.0" }, "scripts": { - "test": "node --test resources/vue/components/*.test.mjs resources/vue/utils/*.test.mjs resources/vue/stores/*.test.mjs" + "test": "node --test resources/vue/components/*.test.mjs resources/vue/utils/*.test.mjs resources/vue/stores/*.test.mjs ../integration-tests/runner.test.mjs" }, "author": "", "parcelIgnore": [ diff --git a/frontend/resources/vue/App.vue b/frontend/resources/vue/App.vue index 2c68658b..898f626e 100644 --- a/frontend/resources/vue/App.vue +++ b/frontend/resources/vue/App.vue @@ -340,7 +340,9 @@ async function updateHeaderFromInit () { loadCustomJsIfEnabled() renderNavigation() - await applyTheme() + applyTheme().catch((err) => { + console.warn('Failed to load theme CSS:', err) + }) if (loginRequired.value) { connectEventStreamIfNeeded() diff --git a/frontend/resources/vue/utils/themeLoader.js b/frontend/resources/vue/utils/themeLoader.js index a93788ef..063f2119 100644 --- a/frontend/resources/vue/utils/themeLoader.js +++ b/frontend/resources/vue/utils/themeLoader.js @@ -1,16 +1,6 @@ const DEFAULT_THEME_FETCH_TIMEOUT_MS = 5000 -async function fetchWithTimeout (url, options, timeoutMs) { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), timeoutMs) - try { - return await fetch(url, { ...options, signal: controller.signal }) - } finally { - clearTimeout(timeoutId) - } -} - -export async function applyThemeStyles (themePreference = '', timeoutMs = DEFAULT_THEME_FETCH_TIMEOUT_MS) { +export async function applyThemeStyles (themePreference = '') { let themeStyle = document.getElementById('theme-style') if (!themeStyle) { @@ -24,7 +14,10 @@ export async function applyThemeStyles (themePreference = '', timeoutMs = DEFAUL ? `/custom-webui/themes/${encodeURIComponent(themePreference)}/theme.css` : '/theme.css' - const response = await fetchWithTimeout(themeUrl, { cache: 'no-store' }, timeoutMs) + const response = await fetch(themeUrl, { + cache: 'no-store', + signal: AbortSignal.timeout(DEFAULT_THEME_FETCH_TIMEOUT_MS) + }) if (!response.ok) { throw new Error(`theme fetch failed: ${response.status}`) } diff --git a/frontend/resources/vue/utils/themeLoader.test.mjs b/frontend/resources/vue/utils/themeLoader.test.mjs new file mode 100644 index 00000000..f7471af5 --- /dev/null +++ b/frontend/resources/vue/utils/themeLoader.test.mjs @@ -0,0 +1,57 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +function installThemeDom () { + const styleEl = { textContent: '' } + const body = { attributes: {}, setAttribute (name, value) { this.attributes[name] = value } } + + globalThis.document = { + getElementById: (id) => (id === 'theme-style' ? styleEl : null), + createElement: () => styleEl, + head: { appendChild: () => {} }, + body + } + + return { styleEl, body } +} + +test('applyThemeStyles times out when the response body stalls', async (t) => { + const originalFetch = globalThis.fetch + const originalDocument = globalThis.document + const originalAbortSignal = globalThis.AbortSignal + + t.after(() => { + globalThis.fetch = originalFetch + globalThis.document = originalDocument + globalThis.AbortSignal = originalAbortSignal + }) + + installThemeDom() + + globalThis.AbortSignal = { + timeout () { + const controller = new AbortController() + setTimeout(() => controller.abort(), 50) + return controller.signal + } + } + + globalThis.fetch = (_url, options) => Promise.resolve({ + ok: true, + status: 200, + text () { + return new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => { + reject(new DOMException('The operation was aborted.', 'AbortError')) + }) + }) + } + }) + + const { applyThemeStyles } = await import('./themeLoader.js') + + await assert.rejects( + () => applyThemeStyles(''), + (err) => err.name === 'AbortError' || err.name === 'TimeoutError' + ) +}) diff --git a/integration-tests/runner.test.mjs b/integration-tests/runner.test.mjs new file mode 100644 index 00000000..dab83d2a --- /dev/null +++ b/integration-tests/runner.test.mjs @@ -0,0 +1,38 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import getRunner from './runner.mjs' + +test('OliveTinTestRunnerVm.start advances pageGeneration', async (t) => { + const originalRunner = process.env.OLIVETIN_TEST_RUNNER + const originalIp = process.env.IP + const originalPort = process.env.PORT + + t.after(() => { + if (originalRunner === undefined) { + delete process.env.OLIVETIN_TEST_RUNNER + } else { + process.env.OLIVETIN_TEST_RUNNER = originalRunner + } + if (originalIp === undefined) { + delete process.env.IP + } else { + process.env.IP = originalIp + } + if (originalPort === undefined) { + delete process.env.PORT + } else { + process.env.PORT = originalPort + } + }) + + process.env.OLIVETIN_TEST_RUNNER = 'vm' + process.env.IP = '127.0.0.1' + process.env.PORT = '1337' + + const runner = getRunner() + const before = runner.pageGeneration + + await runner.start('pageGenerationFirst') + + assert.equal(runner.pageGeneration, before + 1) +}) diff --git a/integration-tests/tests/pageGeneration/pageGeneration.mjs b/integration-tests/tests/pageGeneration/pageGeneration.mjs new file mode 100644 index 00000000..f738aeb6 --- /dev/null +++ b/integration-tests/tests/pageGeneration/pageGeneration.mjs @@ -0,0 +1,34 @@ +import { describe, it, after, afterEach } from 'mocha' +import { expect } from 'chai' +import { + getRootAndWait, + takeScreenshotOnFailure, +} from '../../lib/elements.js' + +describe('runner: pageGeneration', function () { + after(async () => { + await runner.stop() + }) + + afterEach(function () { + takeScreenshotOnFailure(this.currentTest, webdriver) + }) + + it('reloads the dashboard after sequential config changes', async function () { + await runner.start('pageGenerationFirst') + await getRootAndWait() + + let loadedDashboard = await webdriver.executeScript( + 'return document.body.getAttribute("loaded-dashboard")' + ) + expect(loadedDashboard).to.equal('Page Generation First') + + await runner.start('pageGenerationSecond') + await getRootAndWait() + + loadedDashboard = await webdriver.executeScript( + 'return document.body.getAttribute("loaded-dashboard")' + ) + expect(loadedDashboard).to.equal('Page Generation Second') + }) +}) diff --git a/integration-tests/tests/pageGenerationFirst/config.yaml b/integration-tests/tests/pageGenerationFirst/config.yaml new file mode 100644 index 00000000..b49c863b --- /dev/null +++ b/integration-tests/tests/pageGenerationFirst/config.yaml @@ -0,0 +1,13 @@ +listenAddressSingleHTTPFrontend: 0.0.0.0:1337 + +logLevel: "DEBUG" +checkForUpdates: false + +actions: + - title: page-gen-first-action + shell: echo first + +dashboards: + - title: Page Generation First + contents: + - title: page-gen-first-action diff --git a/integration-tests/tests/pageGenerationSecond/config.yaml b/integration-tests/tests/pageGenerationSecond/config.yaml new file mode 100644 index 00000000..0f44f1a9 --- /dev/null +++ b/integration-tests/tests/pageGenerationSecond/config.yaml @@ -0,0 +1,13 @@ +listenAddressSingleHTTPFrontend: 0.0.0.0:1337 + +logLevel: "DEBUG" +checkForUpdates: false + +actions: + - title: page-gen-second-action + shell: echo second + +dashboards: + - title: Page Generation Second + contents: + - title: page-gen-second-action