diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6b24440b..47c9d49e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
# Changelog
+## 2.7.0
+
+_2 jul 2026_
+
+- Added `keepAlive` option to `src`-object to prevent a texture from being cleaned up by the renderer
+- Abstracted announcer (and speechSynthesis) to be platform provided (with speechSynthesis as the built-in default)
+- Added dedicated findByData methods to testing harness to select specific nodes in the tree
+- Improved test cases around frameTick cleanup
+
## 2.6.0
_25 jun 2026_
diff --git a/docs/essentials/displaying_images.md b/docs/essentials/displaying_images.md
index 41e1777b..34e5b3c8 100644
--- a/docs/essentials/displaying_images.md
+++ b/docs/essentials/displaying_images.md
@@ -19,6 +19,18 @@ It is recommended to give your Element a width (`w`) and a height (`h`) attribut
For the best performance, it's important to keep your source images as small as possible. If you're displaying an image at `200px x 200px`, make sure the image is exactly that size or _smaller_. The latter option may lead to some quality loss, but can positively impact the overall performance of your App.
+## Advanced src Attribute
+
+Besides passing a string to the `src` attribute, you can also pass an object with extra options.
+
+```xml
+
+```
+
+The `type` option can be used to explicitly set the image type. This is useful when the type can not be derived from the url or filename, or when you want to use a compressed texture format like `ktx`.
+
+The `keepAlive` option prevents the texture generated from the source image from being cleaned up. Use this for images that are reused often and should stay available in memory.
+
## Colorization
You also have the option to _colorize_ an image on the fly. Just add a `color` attribute to the Element with a `src` attribute. You can use a single color, or apply a gradient.
diff --git a/docs/essentials/settings.md b/docs/essentials/settings.md
index e52af70b..5451e501 100644
--- a/docs/essentials/settings.md
+++ b/docs/essentials/settings.md
@@ -109,7 +109,38 @@ Blits.Launch(App, 'app', {
})
```
-Common platform properties that can be overwritten are `input`, `viewport`, `dispatchEvent`, `localStorage`, `getCookie`, `setCookie`, `historyBack`, `screenHeight`, `hardwareConcurrency`, `userAgent`, `KeyboardEvent`, `isKeyboardEvent`, `createKeyboardEvent`, `SpeechSynthesisUtterance`, `speechSynthesis`, and `now`.
+Common platform properties that can be overwritten are `input`, `viewport`, `dispatchEvent`, `localStorage`, `getCookie`, `setCookie`, `historyBack`, `screenHeight`, `hardwareConcurrency`, `userAgent`, `KeyboardEvent`, `isKeyboardEvent`, `createKeyboardEvent`, `announcer`, and `now`.
+
+The `announcer` platform property can be used to provide a custom text-to-speech driver. When set, Blits keeps using the built-in announcer queue and calls the custom driver's `speak(options)` and `cancel()` methods instead of the default Web Speech implementation.
+
+```js
+Blits.Launch(App, 'app', {
+ announcer: true,
+ platform: (defaults) => ({
+ announcer: {
+ speak(options) {
+ return myPlatformSpeech.speak(options.message)
+ },
+ cancel() {
+ myPlatformSpeech.cancel()
+ },
+ },
+ }),
+})
+```
+
+For LG webOS apps, Blits provides a webOS announcer factory that calls the platform TTS service.
+
+```js
+import createAnnouncer from '@lightningjs/blits/platforms/webOS/announcer'
+
+Blits.Launch(App, 'app', {
+ announcer: true,
+ platform: () => ({
+ announcer: createAnnouncer(),
+ }),
+})
+```
The `rendererPlatform` setting is separate from `platform`. It is only passed to the renderer, and should be used for renderer specific platform configuration.
diff --git a/docs/testing/test-harness.md b/docs/testing/test-harness.md
index 365dc4d3..42f09837 100644
--- a/docs/testing/test-harness.md
+++ b/docs/testing/test-harness.md
@@ -46,6 +46,23 @@ The snapshot contains:
Nested Components are included in the `tree` as Component snapshots as well.
+## Finding Nodes by Inspector Data
+
+Use `findByData()` and `findAllByData()` to find nodes with matching `inspector-data`.
+
+```xml
+
+
+```
+
+```js
+const title = fixture.findByData('testId', 'menu-title')
+const items = fixture.findAllByData('role', 'menu-item')
+```
+
+`findByData(key, value)` returns the first matching node or `null`.
+`findAllByData(key, value)` returns all matching nodes, or an empty array when nothing matches.
+
## Updating Props and State
Props can be updated with `setProps()`.
diff --git a/index.d.ts b/index.d.ts
index ff65ca56..2bbeaefe 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -66,6 +66,28 @@ declare module '@lightningjs/blits' {
enableUtteranceKeepAlive?: boolean
}
+ export interface AnnouncerDriverOptions extends AnnouncerUtteranceOptions {
+ /**
+ * Message to be spoken by the platform announcer driver.
+ */
+ message: string | number,
+ /**
+ * Internal announcement id used by the Blits announcer queue.
+ */
+ id: number,
+ }
+
+ export interface AnnouncerDriver {
+ /**
+ * Speak one queued announcement.
+ */
+ speak(options: AnnouncerDriverOptions): Promise,
+ /**
+ * Stop the active platform announcement, when supported.
+ */
+ cancel(): void,
+ }
+
export interface AnnouncerUtterance extends Promise {
/**
* Removes a specific message from the announcement queue,
@@ -1070,13 +1092,9 @@ declare module '@lightningjs/blits' {
*/
createKeyboardEvent?: (type: string, init?: KeyboardEventInit | any) => any,
/**
- * SpeechSynthesisUtterance constructor used by the announcer.
- */
- SpeechSynthesisUtterance?: typeof SpeechSynthesisUtterance | any,
- /**
- * Speech synthesis API implementation used by the announcer.
+ * Platform-specific announcer driver used instead of the default Web Speech driver.
*/
- speechSynthesis?: SpeechSynthesis | any,
+ announcer?: AnnouncerDriver,
/**
* Monotonic timestamp provider used for input throttling.
*/
diff --git a/package-lock.json b/package-lock.json
index 68bc892f..1a0daa40 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@lightningjs/blits",
- "version": "2.6.0",
+ "version": "2.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@lightningjs/blits",
- "version": "2.6.0",
+ "version": "2.7.0",
"license": "Apache-2.0",
"dependencies": {
"@lightningjs/renderer": "^3.0.6",
diff --git a/package.json b/package.json
index 8f5203ff..3259cb75 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@lightningjs/blits",
- "version": "2.6.0",
+ "version": "2.7.0",
"description": "Blits: The Lightning 3 App Development Framework",
"bin": "bin/index.js",
"exports": {
@@ -32,6 +32,10 @@
"types": "./src/plugins/storage/storage.d.ts",
"default": "./src/plugins/storage/storage.js"
},
+ "./platforms/webOS/announcer": {
+ "types": "./src/platforms/webOS/announcer.d.ts",
+ "default": "./src/platforms/webOS/announcer.js"
+ },
"./testing": {
"types": "./src/testing/index.d.ts",
"default": "./src/testing/index.js"
diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js
index 622acea4..cb6f09dd 100644
--- a/src/announcer/announcer.js
+++ b/src/announcer/announcer.js
@@ -16,7 +16,6 @@
*/
import { Log } from '../lib/log.js'
-import speechSynthesis from './speechSynthesis.js'
import { platform } from '../platform.js'
let active = false
@@ -31,6 +30,20 @@ let globalDefaultOptions = {
enableUtteranceKeepAlive: !/android/i.test(platform.userAgent || ''),
}
+const getDriver = () => {
+ const driver = platform.announcer
+ if (driver && typeof driver.speak === 'function') {
+ return driver
+ }
+}
+
+const cancelDriver = () => {
+ const driver = getDriver()
+ if (driver && typeof driver.cancel === 'function') {
+ driver.cancel()
+ }
+}
+
const noopAnnouncement = {
then() {},
done() {},
@@ -84,9 +97,9 @@ const addToQueue = (message, politeness, delay = false, options = {}) => {
// augment the promise with a stop function
done.stop = () => {
if (id === currentId) {
- speechSynthesis.cancel()
+ cancelDriver()
isProcessing = false
- resolveFn('interupted')
+ resolveFn('interrupted')
}
}
@@ -133,7 +146,17 @@ const processQueue = async () => {
debounce = setTimeout(() => {
Log.debug(`Announcer - speaking: "${message}" (id: ${id})`)
- speechSynthesis
+ const driver = getDriver()
+ if (driver === undefined) {
+ currentId = null
+ currentResolveFn = null
+ isProcessing = false
+ resolveFn('unavailable')
+ processQueue()
+ return
+ }
+
+ driver
.speak({
message,
id,
@@ -180,8 +203,8 @@ const stop = () => {
// Clear debounce timer if speech hasn't started yet
clearDebounceTimer()
- // Always cancel speech synthesis to ensure clean state
- speechSynthesis.cancel()
+ // Always cancel the platform announcer to ensure clean state
+ cancelDriver()
// Store resolve function before resetting state
const resolveFn = currentResolveFn
diff --git a/src/announcer/announcer.test.js b/src/announcer/announcer.test.js
index f27a67c8..e272eeb7 100644
--- a/src/announcer/announcer.test.js
+++ b/src/announcer/announcer.test.js
@@ -17,7 +17,7 @@
import test from 'tape'
-// Mock speechSynthesis API before importing announcer (which imports speechSynthesis)
+// Mock Web Speech API for the default announcer driver
const mockUtterance = class {
constructor(text) {
this.text = text
@@ -62,16 +62,15 @@ const mockSpeechSynthesis = {
},
}
-// Setup mocks before module loads (speechSynthesis.js captures window.speechSynthesis at import time)
window.speechSynthesis = mockSpeechSynthesis
window.SpeechSynthesisUtterance = mockUtterance
globalThis.SpeechSynthesisUtterance = mockUtterance
-// Dynamic import to ensure mocks are set before module evaluation
const announcerModule = await import('./announcer.js')
const announcer = announcerModule.default
import { initLog } from '../lib/log.js'
+import { configurePlatform } from '../platform.js'
initLog()
announcer.enable()
@@ -229,7 +228,7 @@ test('Announcer stop interrupts processing', (assert) => {
// Due to timing, it may finish before stop is called
assert.ok(
status === 'interrupted' || status === 'unavailable' || status === 'finished',
- 'Stop interrupts (or unavailable if no speechSynthesis, or finished if completed)'
+ 'Stop interrupts (or unavailable if Web Speech is missing, or finished if completed)'
)
assert.end()
})
@@ -277,3 +276,65 @@ test('Announcer queue behavior', (assert) => {
assert.end()
})
})
+
+test('Announcer uses custom platform announcer driver', (assert) => {
+ announcer.stop()
+ announcer.clear()
+ announcer.enable()
+
+ const calls = []
+
+ configurePlatform((defaults) => ({
+ ...defaults,
+ announcer: {
+ speak(options) {
+ calls.push(options)
+ return Promise.resolve()
+ },
+ cancel() {},
+ },
+ }))
+
+ announcer.speak('custom driver message', 'off', { lang: 'nl-NL' }).then((status) => {
+ assert.equal(status, 'finished', 'custom driver announcement resolves')
+ assert.equal(calls.length, 1, 'custom driver speak is called once')
+ assert.equal(calls[0].message, 'custom driver message', 'custom driver receives message')
+ assert.equal(calls[0].lang, 'nl-NL', 'custom driver receives options')
+
+ configurePlatform(() => ({}))
+ assert.end()
+ })
+})
+
+test('Announcer stop uses custom platform announcer driver cancel', (assert) => {
+ announcer.stop()
+ announcer.clear()
+ announcer.enable()
+
+ let cancelCount = 0
+
+ configurePlatform((defaults) => ({
+ ...defaults,
+ announcer: {
+ speak() {
+ return new Promise(() => {})
+ },
+ cancel() {
+ cancelCount++
+ },
+ },
+ }))
+
+ const announcement = announcer.speak('custom driver stop')
+ announcement.then((status) => {
+ assert.equal(status, 'interrupted', 'announcement resolves as interrupted')
+ assert.equal(cancelCount, 1, 'custom driver cancel is called')
+
+ configurePlatform(() => ({}))
+ assert.end()
+ })
+
+ setTimeout(() => {
+ announcement.stop()
+ }, 400)
+})
diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js
index b734bb18..1bf203ad 100644
--- a/src/announcer/speechSynthesis.js
+++ b/src/announcer/speechSynthesis.js
@@ -16,11 +16,28 @@
*/
import { Log } from '../lib/log.js'
-import { platform } from '../platform.js'
const utterances = new Map() // id -> { utterance, timer, ignoreResume }
let initialized = false
+const globalScope = globalThis
+
+const getSpeechSynthesis = () => {
+ const windowRef = globalScope.window
+ const selfRef = globalScope.self || windowRef || globalScope
+
+ return (
+ globalScope.speechSynthesis ||
+ (selfRef && selfRef.speechSynthesis) ||
+ (windowRef && windowRef.speechSynthesis)
+ )
+}
+
+const getSpeechSynthesisUtterance = () => {
+ const windowRef = globalScope.window
+
+ return globalScope.SpeechSynthesisUtterance || (windowRef && windowRef.SpeechSynthesisUtterance)
+}
const clear = (id) => {
const state = utterances.get(id)
@@ -35,7 +52,7 @@ const clear = (id) => {
}
const startKeepAlive = (id) => {
- const syn = platform.speechSynthesis
+ const syn = getSpeechSynthesis()
const state = utterances.get(id)
// utterance status: utterance was removed (cancelled or finished)
@@ -85,7 +102,7 @@ const defaultUtteranceProps = {
}
const initialize = () => {
- const syn = platform.speechSynthesis
+ const syn = getSpeechSynthesis()
// syn api check: syn might not have getVoices method
if (!syn || typeof syn.getVoices !== 'function') {
initialized = false
@@ -99,7 +116,7 @@ const initialize = () => {
const waitForSynthReady = (timeoutMs = 2000, checkIntervalMs = 100) => {
return new Promise((resolve) => {
- const syn = platform.speechSynthesis
+ const syn = getSpeechSynthesis()
if (!syn) {
Log.debug('SpeechSynthesis - syn unavailable')
resolve()
@@ -137,7 +154,7 @@ const waitForSynthReady = (timeoutMs = 2000, checkIntervalMs = 100) => {
}
const speak = async (options) => {
- const syn = platform.speechSynthesis
+ const syn = getSpeechSynthesis()
// options check: missing required options
if (!options || !options.message) {
return Promise.reject({ error: 'Missing message' })
@@ -157,7 +174,7 @@ const speak = async (options) => {
// Wait for engine to be ready
await waitForSynthReady()
- const SpeechSynthesisUtterance = platform.SpeechSynthesisUtterance
+ const SpeechSynthesisUtterance = getSpeechSynthesisUtterance()
if (SpeechSynthesisUtterance === undefined) {
return Promise.reject({ error: 'unavailable' })
}
@@ -216,7 +233,7 @@ const speak = async (options) => {
export default {
speak(options) {
- const syn = platform.speechSynthesis
+ const syn = getSpeechSynthesis()
if (syn !== undefined) {
if (initialized === false) {
initialize()
@@ -228,7 +245,7 @@ export default {
}
},
cancel() {
- const syn = platform.speechSynthesis
+ const syn = getSpeechSynthesis()
if (syn !== undefined) {
// timers: clear all timers before cancelling
for (const id of utterances.keys()) {
diff --git a/src/announcer/speechSynthesis.test.js b/src/announcer/speechSynthesis.test.js
index c4e0246c..e1cdfaf4 100644
--- a/src/announcer/speechSynthesis.test.js
+++ b/src/announcer/speechSynthesis.test.js
@@ -62,13 +62,12 @@ const mockSpeechSynthesis = {
},
}
-// Setup mocks before module loads (speechSynthesis.js captures window.speechSynthesis at import time)
+// Setup Web Speech mocks for the default browser announcer driver
window.speechSynthesis = mockSpeechSynthesis
window.SpeechSynthesisUtterance = mockUtterance
globalThis.SpeechSynthesisUtterance = mockUtterance
const mockWindow = window
-// Dynamic import to ensure mocks are set before module evaluation
const speechSynthesisModule = await import('./speechSynthesis.js')
const speechSynthesis = speechSynthesisModule.default
@@ -277,14 +276,26 @@ test('speechSynthesis - multiple speak calls', (assert) => {
test('speechSynthesis - handle unavailable API', (assert) => {
assert.plan(1)
- // Temporarily remove speechSynthesis
const originalSyn = global.window.speechSynthesis
- global.window.speechSynthesis = undefined
+ const originalGlobalSyn = globalThis.speechSynthesis
- // Need to reimport to pick up the new window.speechSynthesis value
- // For this test, we'll just verify the behavior conceptually
- global.window.speechSynthesis = originalSyn
+ global.window.speechSynthesis = undefined
+ globalThis.speechSynthesis = undefined
- assert.pass('Should handle unavailable speechSynthesis API')
- assert.end()
+ speechSynthesis
+ .speak({
+ id: 'test-10',
+ message: 'Unavailable test',
+ })
+ .then(() => {
+ assert.fail('Promise should reject when speechSynthesis is unavailable')
+ })
+ .catch((e) => {
+ assert.equal(e.error, 'unavailable', 'Should handle unavailable speechSynthesis API')
+ })
+ .finally(() => {
+ global.window.speechSynthesis = originalSyn
+ globalThis.speechSynthesis = originalGlobalSyn
+ assert.end()
+ })
})
diff --git a/src/component.js b/src/component.js
index 71fbdd43..60648b20 100644
--- a/src/component.js
+++ b/src/component.js
@@ -38,6 +38,11 @@ import { plugins } from './plugin.js'
// object to store global components
let globalComponents
+let devMode = false
+
+export const setDevMode = (enabled) => {
+ devMode = enabled === true
+}
const required = (name) => {
throw new Error(`Parameter ${name} is required`)
@@ -493,7 +498,7 @@ const Component = (name = required('name'), config = required('config')) => {
// one time code generation (only if precompilation is turned off)
if (config.code === undefined) {
Log.debug(`Generating code for ${name} component`)
- config.code = codegenerator.call(config, parser(config.template, name))
+ config.code = codegenerator.call(config, parser(config.template, name), devMode)
}
// create an instance of the component, using base as the prototype (which contains Base)
diff --git a/src/component.test.js b/src/component.test.js
index 7b93b3cd..43091f63 100644
--- a/src/component.test.js
+++ b/src/component.test.js
@@ -30,6 +30,9 @@ initLog()
if (renderer && !renderer.on) {
renderer.on = () => {}
}
+if (renderer && !renderer.off) {
+ renderer.off = () => {}
+}
test('Type', (assert) => {
const expected = 'function'
@@ -532,6 +535,54 @@ test('Component - Warn when method name matches prop name', (assert) => {
assert.end()
})
+test('Component - Should unregister renderer hook listeners on destroy', (assert) => {
+ const offCapture = assert.capture(renderer, 'off', () => {})
+ const onCapture = assert.capture(renderer, 'on', () => {})
+ const config = {
+ code: {
+ render: () => {
+ return { elms: [{ node: { on: () => {} } }], cleanup: () => {} }
+ },
+ effects: [],
+ },
+ hooks: {
+ frameTick() {},
+ idle() {},
+ },
+ }
+
+ const holder = { destroy() {} }
+ const parent = { $focus() {} }
+ const foo = Component('Foo', config)({}, holder, parent, {})
+ const onCalls = onCapture()
+
+ foo.destroy()
+
+ const offCalls = offCapture()
+ const frameTickCb = onCalls.find((c) => c.args[0] === 'frameTick').args[1]
+ const idleCb = onCalls.find((c) => c.args[0] === 'idle').args[1]
+ const activeCb = onCalls.find((c) => c.args[0] === 'active').args[1]
+
+ assert.ok(
+ offCalls.some((c) => c.args[0] === 'frameTick' && c.args[1] === frameTickCb),
+ 'frameTick listener should be removed on destroy'
+ )
+ assert.ok(
+ offCalls.some((c) => c.args[0] === 'idle' && c.args[1] === idleCb),
+ 'idle listener should be removed on destroy'
+ )
+ assert.ok(
+ offCalls.some((c) => c.args[0] === 'active' && c.args[1] === activeCb),
+ 'active listener should be removed on destroy'
+ )
+ assert.equal(
+ foo[symbols.rendererEventListeners],
+ null,
+ 'rendererEventListeners should be cleared'
+ )
+ assert.end()
+})
+
test('Component - Should register renderer event listeners for hooks', (assert) => {
assert.capture(renderer, 'on', () => {})
const config = {
diff --git a/src/engines/L3/element.js b/src/engines/L3/element.js
index a45029bb..09ea15d5 100644
--- a/src/engines/L3/element.js
+++ b/src/engines/L3/element.js
@@ -202,6 +202,15 @@ const colorMap = {
right: 'colorRight',
}
+const setTextureOption = (target, key, value) => {
+ const opts =
+ target.props['textureOptions'] ||
+ (target.element.node && target.element.node.textureOptions) ||
+ {}
+ opts[key] = value
+ target.props['textureOptions'] = opts
+}
+
/**
* Default text settings for text nodes (initialized on first use).
* @type {object|null}
@@ -275,6 +284,9 @@ const propsTransformer = {
if (typeof v === 'object' || (isObjectString(v) === true && (v = parseToObject(v)))) {
this.props['src'] = v.src
this.props['imageType'] = v.type
+ if ('keepAlive' in v) {
+ setTextureOption(this, 'preventCleanup', v.keepAlive === true)
+ }
} else {
this.props['src'] = v
}
@@ -318,7 +330,7 @@ const propsTransformer = {
const resizeMode = {}
if (v === 'cover' || v === 'contain') {
- this.props['textureOptions'] = { resizeMode: { type: v } }
+ setTextureOption(this, 'resizeMode', { type: v })
return
}
@@ -333,7 +345,7 @@ const propsTransformer = {
resizeMode['clipX'] = 'x' in v.position === true ? v.position.x : null
resizeMode['clipY'] = 'y' in v.position === true ? v.position.y : null
}
- this.props['textureOptions'] = { resizeMode }
+ setTextureOption(this, 'resizeMode', resizeMode)
}
},
set rtt(v) {
diff --git a/src/engines/L3/element.test.js b/src/engines/L3/element.test.js
index 4afb2ad9..406072cc 100644
--- a/src/engines/L3/element.test.js
+++ b/src/engines/L3/element.test.js
@@ -255,7 +255,7 @@ test('Element - Set `color` property', (assert) => {
test('Element - Set `src` property', (assert) => {
assert.capture(renderer, 'createNode', () => new EventEmitter())
const el = createElement()
- const value = 'file://foo.html'
+ const value = 'assets/image.jpg'
el.set('src', value)
@@ -266,6 +266,50 @@ test('Element - Set `src` property', (assert) => {
assert.end()
})
+test('Element - Set `src` property with keepAlive option', (assert) => {
+ assert.capture(renderer, 'createNode', () => new EventEmitter())
+ const el = createElement()
+ const value = { src: 'assets/image.jpg', type: 'regular', keepAlive: true }
+
+ el.set('src', value)
+
+ assert.equal(el.node['src'], value.src, 'Node src parameter should be set')
+ assert.equal(el.node['imageType'], value.type, 'Node imageType parameter should be set')
+ assert.ok(el.node['textureOptions'] instanceof Object, 'textureOptions should be an object')
+ assert.equal(
+ el.node['textureOptions']['preventCleanup'],
+ true,
+ 'Node textureOptions.preventCleanup should be true'
+ )
+ assert.equal(
+ el.props.props['textureOptions']['preventCleanup'],
+ true,
+ 'Props textureOptions.preventCleanup should be true'
+ )
+ assert.equal(el.props.raw['src'], value, "Props' raw map entry should be added")
+ assert.end()
+})
+
+test('Element - Update `src.keepAlive` reactively', (assert) => {
+ assert.capture(renderer, 'createNode', () => new EventEmitter())
+ const el = createElement()
+
+ el.set('src', { src: 'assets/image.jpg', keepAlive: true })
+ el.set('src', { src: 'assets/image.jpg', keepAlive: false })
+
+ assert.equal(
+ el.node['textureOptions']['preventCleanup'],
+ false,
+ 'Node textureOptions.preventCleanup should be updated to false'
+ )
+ assert.equal(
+ el.props.props['textureOptions']['preventCleanup'],
+ false,
+ 'Props textureOptions.preventCleanup should be updated to false'
+ )
+ assert.end()
+})
+
test('Element - Set `texture` property', (assert) => {
assert.capture(renderer, 'createNode', () => new EventEmitter())
const el = createElement()
@@ -392,6 +436,46 @@ test('Element - Set `fit` property should not set not required keys', (assert) =
assert.end()
})
+test('Element - Set `src.keepAlive` then `fit` preserves both textureOptions', (assert) => {
+ assert.capture(renderer, 'createNode', () => new EventEmitter())
+ const el = createElement()
+
+ el.set('src', { src: 'assets/image.jpg', keepAlive: true })
+ el.set('fit', 'contain')
+
+ assert.equal(
+ el.node['textureOptions']['preventCleanup'],
+ true,
+ 'Node textureOptions.preventCleanup should be preserved'
+ )
+ assert.equal(
+ el.node['textureOptions']['resizeMode']['type'],
+ 'contain',
+ 'Node resizeMode.type should be set'
+ )
+ assert.end()
+})
+
+test('Element - Set `fit` then `src.keepAlive` preserves both textureOptions', (assert) => {
+ assert.capture(renderer, 'createNode', () => new EventEmitter())
+ const el = createElement()
+
+ el.set('fit', 'cover')
+ el.set('src', { src: 'assets/image.jpg', keepAlive: true })
+
+ assert.equal(
+ el.node['textureOptions']['resizeMode']['type'],
+ 'cover',
+ 'Node resizeMode.type should be preserved'
+ )
+ assert.equal(
+ el.node['textureOptions']['preventCleanup'],
+ true,
+ 'Node textureOptions.preventCleanup should be set'
+ )
+ assert.end()
+})
+
test('Element - Set `placement` property with value `center`', (assert) => {
assert.capture(renderer, 'createNode', () => new EventEmitter())
const el = createElement()
diff --git a/src/platform.js b/src/platform.js
index 9a0a493f..402cda32 100644
--- a/src/platform.js
+++ b/src/platform.js
@@ -15,6 +15,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
+import speechSynthesis from './announcer/speechSynthesis.js'
+
const globalScope = globalThis
const createKeyboardEventFactory = (KeyboardEventConstructor) => {
@@ -73,10 +75,7 @@ const browserPlatform = () => {
? (event) => event instanceof KeyboardEventConstructor
: undefined,
createKeyboardEvent,
- SpeechSynthesisUtterance:
- globalScope.SpeechSynthesisUtterance || (windowRef && windowRef.SpeechSynthesisUtterance),
- speechSynthesis:
- (selfRef && selfRef.speechSynthesis) || (windowRef && windowRef.speechSynthesis),
+ announcer: speechSynthesis,
now:
globalScope.performance && globalScope.performance.now !== undefined
? globalScope.performance.now.bind(globalScope.performance)
@@ -108,9 +107,7 @@ export const configurePlatform = (customPlatform) => {
platform.getCookie = platform.getCookie || basePlatform.getCookie
platform.setCookie = platform.setCookie || basePlatform.setCookie
platform.historyBack = platform.historyBack || basePlatform.historyBack
- platform.SpeechSynthesisUtterance =
- platform.SpeechSynthesisUtterance || basePlatform.SpeechSynthesisUtterance
- platform.speechSynthesis = platform.speechSynthesis || basePlatform.speechSynthesis
+ platform.announcer = platform.announcer || basePlatform.announcer
platform.now = platform.now || basePlatform.now
return platform
diff --git a/src/platform.test.js b/src/platform.test.js
index 26ef2421..13a5ab8e 100644
--- a/src/platform.test.js
+++ b/src/platform.test.js
@@ -17,6 +17,7 @@
import test from 'tape'
import { configurePlatform, platform as activePlatform } from './platform.js'
+import nodePlatform from './testing/nodePlatform.js'
test('Platform - configurePlatform merges custom references with browser defaults', (assert) => {
const customInput = {}
@@ -46,6 +47,31 @@ test('Platform - configurePlatform accepts a callback with browser defaults', (a
assert.end()
})
+test('Platform - default announcer driver is preserved', (assert) => {
+ const platform = configurePlatform(() => ({}))
+
+ assert.equal(typeof platform.announcer, 'object', 'default announcer driver is available')
+ assert.equal(typeof platform.announcer.speak, 'function', 'default announcer can speak')
+ assert.equal(typeof platform.announcer.cancel, 'function', 'default announcer can cancel')
+
+ configurePlatform(() => ({}))
+ assert.end()
+})
+
+test('Platform - node platform provides a noop announcer driver', (assert) => {
+ const platform = configurePlatform(() => nodePlatform())
+
+ assert.equal(typeof platform.announcer.speak, 'function', 'node announcer can speak')
+ assert.equal(typeof platform.announcer.cancel, 'function', 'node announcer can cancel')
+
+ platform.announcer.speak({ message: 'test', id: 1 }).then(() => {
+ assert.pass('node announcer speak resolves')
+ platform.announcer.cancel()
+ configurePlatform(() => ({}))
+ assert.end()
+ })
+})
+
test('Platform - custom KeyboardEvent constructor is used for keyboard helpers', (assert) => {
class CustomKeyboardEvent {
constructor(type, init = {}) {
diff --git a/src/platforms/webOS/announcer.d.ts b/src/platforms/webOS/announcer.d.ts
new file mode 100644
index 00000000..23b69436
--- /dev/null
+++ b/src/platforms/webOS/announcer.d.ts
@@ -0,0 +1,39 @@
+import type { AnnouncerDriver } from '@lightningjs/blits'
+
+export interface WebOSRequestOptions {
+ method: 'speak'
+ parameters: {
+ text: string
+ clear: boolean
+ }
+ onFailure(error: any): void
+}
+
+export type WebOSRequest = (uri: string, options: WebOSRequestOptions) => any
+
+export interface WebOSAnnouncerOptions {
+ /**
+ * webOS service request function. Defaults to `webOS.service.request` when available.
+ */
+ request?: WebOSRequest
+ /**
+ * webOS TTS service URI.
+ *
+ * @default 'luna://com.webos.service.tts'
+ */
+ uri?: string
+ /**
+ * Whether the platform TTS queue should be cleared before speaking.
+ *
+ * @default true
+ */
+ clear?: boolean
+ /**
+ * Optional failure callback for the webOS TTS request.
+ */
+ onFailure?: (error: any) => void
+}
+
+declare function createAnnouncer(options?: WebOSAnnouncerOptions): AnnouncerDriver
+
+export default createAnnouncer
diff --git a/src/platforms/webOS/announcer.js b/src/platforms/webOS/announcer.js
new file mode 100644
index 00000000..8130c447
--- /dev/null
+++ b/src/platforms/webOS/announcer.js
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2026 Comcast Cable Communications Management, LLC
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+const globalScope = globalThis
+const defaultUri = 'luna://com.webos.service.tts'
+
+const getDefaultRequest = () => {
+ const windowRef = globalScope.window
+ const webOS = globalScope.webOS || (windowRef && windowRef.webOS)
+
+ if (webOS && webOS.service && typeof webOS.service.request === 'function') {
+ return webOS.service.request.bind(webOS.service)
+ }
+}
+
+const createAnnouncer = (options = {}) => {
+ const request = options.request || getDefaultRequest()
+ const uri = options.uri || defaultUri
+ const clear = options.clear !== false
+
+ let currentRequest
+
+ return {
+ speak(announcement) {
+ if (request === undefined) {
+ return Promise.reject({ error: 'unavailable' })
+ }
+
+ if (!announcement || announcement.message === undefined || announcement.message === null) {
+ return Promise.reject({ error: 'Missing message' })
+ }
+
+ try {
+ currentRequest = request(uri, {
+ method: 'speak',
+ parameters: {
+ text: String(announcement.message),
+ clear,
+ },
+ onFailure(error) {
+ currentRequest = undefined
+ if (options.onFailure) {
+ options.onFailure(error)
+ }
+ },
+ })
+ } catch (error) {
+ currentRequest = undefined
+ return Promise.reject(error)
+ }
+
+ return Promise.resolve()
+ },
+ cancel() {
+ if (currentRequest && typeof currentRequest.cancel === 'function') {
+ currentRequest.cancel()
+ }
+ currentRequest = undefined
+ },
+ }
+}
+
+export default createAnnouncer
diff --git a/src/platforms/webOS/announcer.test.js b/src/platforms/webOS/announcer.test.js
new file mode 100644
index 00000000..b87c2248
--- /dev/null
+++ b/src/platforms/webOS/announcer.test.js
@@ -0,0 +1,129 @@
+/*
+ * Copyright 2026 Comcast Cable Communications Management, LLC
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import test from 'tape'
+import createAnnouncer from './announcer.js'
+
+test('webOS announcer speaks through webOS TTS service', (assert) => {
+ const calls = []
+ const announcer = createAnnouncer({
+ request(uri, options) {
+ calls.push({ uri, options })
+ return {}
+ },
+ })
+
+ announcer
+ .speak({ id: 1, message: 'Hello webOS' })
+ .then(() => {
+ assert.equal(calls.length, 1, 'one webOS service request is made')
+ assert.equal(calls[0].uri, 'luna://com.webos.service.tts', 'default TTS service is used')
+ assert.equal(calls[0].options.method, 'speak', 'speak method is used')
+ assert.deepEqual(
+ calls[0].options.parameters,
+ { text: 'Hello webOS', clear: true },
+ 'message text and clear flag are passed'
+ )
+ assert.end()
+ })
+ .catch((e) => {
+ assert.fail('Should not reject: ' + JSON.stringify(e))
+ assert.end()
+ })
+})
+
+test('webOS announcer supports custom URI and clear option', (assert) => {
+ const calls = []
+ const announcer = createAnnouncer({
+ uri: 'luna://custom.tts',
+ clear: false,
+ request(uri, options) {
+ calls.push({ uri, options })
+ return {}
+ },
+ })
+
+ announcer
+ .speak({ id: 2, message: 'Custom' })
+ .then(() => {
+ assert.equal(calls[0].uri, 'luna://custom.tts', 'custom URI is used')
+ assert.deepEqual(
+ calls[0].options.parameters,
+ { text: 'Custom', clear: false },
+ 'custom clear option is used'
+ )
+ assert.end()
+ })
+ .catch((e) => {
+ assert.fail('Should not reject: ' + JSON.stringify(e))
+ assert.end()
+ })
+})
+
+test('webOS announcer calls onFailure hook', (assert) => {
+ const expectedError = { errorText: 'TTS failed' }
+ const announcer = createAnnouncer({
+ request(uri, options) {
+ options.onFailure(expectedError)
+ return {}
+ },
+ onFailure(error) {
+ assert.equal(error, expectedError, 'failure error is forwarded')
+ assert.end()
+ },
+ })
+
+ announcer.speak({ id: 3, message: 'Failure' }).catch((e) => {
+ assert.fail('Should not reject from async onFailure: ' + JSON.stringify(e))
+ assert.end()
+ })
+})
+
+test('webOS announcer cancels active request handle', (assert) => {
+ const handle = {
+ canceled: false,
+ cancel() {
+ this.canceled = true
+ },
+ }
+ const announcer = createAnnouncer({
+ request() {
+ return handle
+ },
+ })
+
+ announcer.speak({ id: 4, message: 'Cancel me' }).then(() => {
+ announcer.cancel()
+ assert.equal(handle.canceled, true, 'active request is canceled')
+ assert.end()
+ })
+})
+
+test('webOS announcer rejects as unavailable without webOS request', (assert) => {
+ const announcer = createAnnouncer()
+
+ announcer
+ .speak({ id: 5, message: 'Unavailable' })
+ .then(() => {
+ assert.fail('Should reject when webOS request is missing')
+ assert.end()
+ })
+ .catch((e) => {
+ assert.equal(e.error, 'unavailable', 'unavailable error is returned')
+ assert.end()
+ })
+})
diff --git a/src/testing/index.d.ts b/src/testing/index.d.ts
index 3f9f9a38..c392e2ad 100644
--- a/src/testing/index.d.ts
+++ b/src/testing/index.d.ts
@@ -43,6 +43,8 @@ export interface RenderComponentFixture {
component: any
root: any
snapshot(): SnapshotNode | ComponentSnapshotNode
+ findByData(key: string, value: any): SnapshotNode | ComponentSnapshotNode | null
+ findAllByData(key: string, value: any): Array
setProps(props: Record): void
setState(state: Record): void
focus(event?: KeyboardEvent): Promise
diff --git a/src/testing/nodePlatform.js b/src/testing/nodePlatform.js
index bfb826d8..b9edf77d 100644
--- a/src/testing/nodePlatform.js
+++ b/src/testing/nodePlatform.js
@@ -95,19 +95,11 @@ const nodePlatform = () => {
KeyboardEvent: TestKeyboardEvent,
isKeyboardEvent: (event) => event instanceof TestKeyboardEvent,
createKeyboardEvent: (type, init = {}) => new TestKeyboardEvent(type, init),
- SpeechSynthesisUtterance: class TestSpeechSynthesisUtterance {
- constructor(text = '') {
- this.text = text
- }
- },
- speechSynthesis: {
- cancel() {},
- getVoices() {
- return []
+ announcer: {
+ speak() {
+ return Promise.resolve()
},
- pause() {},
- resume() {},
- speak() {},
+ cancel() {},
},
now: Date.now,
}
diff --git a/src/testing/renderComponent.js b/src/testing/renderComponent.js
index b0b585e5..5ffe39a7 100644
--- a/src/testing/renderComponent.js
+++ b/src/testing/renderComponent.js
@@ -17,12 +17,14 @@
import Settings from '../settings.js'
import { renderer, stage } from '../launch.js'
+import { setDevMode } from '../component.js'
import symbols from '../lib/symbols.js'
import { initLog } from '../lib/log.js'
import { getRaw } from '../lib/reactivity/reactive.js'
import Focus from '../focus/focus.js'
import { initKeyMap, keyMap } from '../application.js'
import { configurePlatform, platform } from '../platform.js'
+import { isObjectString, parseToObject } from '../lib/utils.js'
import nodePlatform from './nodePlatform.js'
let nodeId = 0
@@ -32,6 +34,7 @@ const renderComponent = (Component, options = {}) => {
const originalPlatform = platform
Settings.set(options.settings || {})
+ setDevMode(true)
configurePlatform(() => nodePlatform())
initLog()
initKeyMap()
@@ -58,6 +61,27 @@ const renderComponent = (Component, options = {}) => {
return componentSnapshot(component, holderMap)
}
+ const findAllByData = (key, value) => {
+ const matches = []
+ visitSnapshot(snapshot(), (node) => {
+ if (node.attributes && node.attributes.data && node.attributes.data[key] === value) {
+ matches.push(node)
+ }
+ })
+ return matches
+ }
+
+ const findByData = (key, value) => {
+ let match = null
+ visitSnapshot(snapshot(), (node) => {
+ if (node.attributes && node.attributes.data && node.attributes.data[key] === value) {
+ match = node
+ return true
+ }
+ })
+ return match
+ }
+
const setProps = (props = {}) => {
const keys = Object.keys(props)
for (let i = 0; i < keys.length; i++) {
@@ -107,6 +131,8 @@ const renderComponent = (Component, options = {}) => {
component,
root,
snapshot,
+ findByData,
+ findAllByData,
setProps,
setState,
focus,
@@ -289,10 +315,21 @@ const createElement = ({ parent } = {}) => {
}
},
set(key, value) {
+ if (key === 'inspector-data') {
+ this.setInspectorMetadata(value)
+ return
+ }
this.attributes[key] = value
this.node[key] = value
},
- setInspectorMetadata() {},
+ setInspectorMetadata(data) {
+ if (isObjectString(data) === true) {
+ data = parseToObject(data)
+ }
+ if (data === null || typeof data !== 'object') return
+ this.attributes.data = { ...(this.attributes.data || {}), ...data }
+ this.node.data = { ...(this.node.data || {}), ...data }
+ },
destroy() {
this.eol = true
this.children.length = 0
@@ -314,6 +351,19 @@ const appendToParent = (element, parent) => {
}
}
+const visitSnapshot = (node, visit) => {
+ if (node === undefined || node === null) return false
+ if (visit(node) === true) return true
+ if (node.type === 'Component') {
+ return visitSnapshot(node.tree, visit)
+ }
+ const children = node.children || []
+ for (let i = 0; i < children.length; i++) {
+ if (visitSnapshot(children[i], visit) === true) return true
+ }
+ return false
+}
+
const createKeyboardEvent = (key, init = {}) => {
const keyCode = init.keyCode || keyCodeFromKey(key)
return platform.createKeyboardEvent('keydown', {
diff --git a/src/testing/renderComponent.test.js b/src/testing/renderComponent.test.js
index 9a459df8..d5bfb0f1 100644
--- a/src/testing/renderComponent.test.js
+++ b/src/testing/renderComponent.test.js
@@ -191,6 +191,36 @@ test('renderComponent setState updates state and reactive attributes in snapshot
assert.end()
})
+test('renderComponent finds nodes by inspector data', (assert) => {
+ const Menu = Component('InspectableMenu', {
+ template: `
+
+
+
+
+
+ `,
+ })
+
+ const fixture = renderComponent(Menu)
+
+ assert.equal(
+ fixture.findByData('testId', 'menu-title').attributes.content,
+ 'Menu',
+ 'findByData should return the first node with matching inspector data'
+ )
+ assert.equal(
+ fixture.findAllByData('role', 'menu-item').length,
+ 2,
+ 'findAllByData should return all matching nodes'
+ )
+ assert.equal(fixture.findByData('testId', 'missing'), null, 'findByData should return null')
+ assert.deepEqual(fixture.findAllByData('role', 'missing'), [], 'findAllByData should return []')
+
+ fixture.destroy()
+ assert.end()
+})
+
test('renderComponent snapshots nested component attributes and props separately', (assert) => {
const Card = Component('Card', {
template: `
@@ -263,6 +293,9 @@ test('renderComponent snapshots nested component attributes and props separately
attributes: {
x: 100,
y: 20,
+ data: {
+ 'blits-componentType': 'Card',
+ },
},
props: {
title: 'Dune',