From 6aa242cb2b0cd612e0460e227e18e86ceb965df1 Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Fri, 17 Apr 2026 15:05:14 -0400 Subject: [PATCH 1/8] Add happy-dom test project alongside the browser matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a `cli-happy-dom` vitest project that runs the full spec suite under happy-dom, expanding coverage to non-browser runtimes (SSR / TUI / headless contexts). The authoritative real-browser matrix (chromium / firefox / webkit) is untouched and still runs every spec. Real fixes surfaced by the exercise: - Modernize `triggerEvent` to prefer `new MouseEvent/KeyboardEvent/Event(...)` over the deprecated `document.createEvent` / `initEvent` path. Synthetic clicks now toggle checkbox `.checked` in happy-dom as they do natively. - Drop legacy `event.cancelBubble = true` from the event handler — readonly in happy-dom and redundant with the `stopPropagation()` that follows. - Harden `dummyTemplateEngine` eval against bundler-renamed closure vars by passing dependencies explicitly via `new Function(...)`. - Use `Object.prototype.toString.call(node)` instead of `'' + node` in `ComponentProvider` for unknown-element detection. - Replace legacy `window.testDivTemplate` named-access with the existing `ensureNodeExistsAndIsEmpty` helper in the template spec. Where divergences are genuine env gaps (e.g. `', 'my-textarea-elem') }) @@ -306,7 +307,8 @@ describe('Components: Default loader', function () { return testTemplateFromElement('', /* elementId */ null) }) - it('Can be configured as a ', /* elementId */ null) }) diff --git a/builds/knockout/spec/defaultBindings/attrBehaviors.js b/builds/knockout/spec/defaultBindings/attrBehaviors.js index 2c5a0263..6469f782 100644 --- a/builds/knockout/spec/defaultBindings/attrBehaviors.js +++ b/builds/knockout/spec/defaultBindings/attrBehaviors.js @@ -9,7 +9,8 @@ describe('Binding: Attr', function () { expect(testNode.childNodes[0].getAttribute('second-attribute')).to.deep.equal('true') }) - it('Should be able to set namespaced attribute values', function () { + // happy-dom gap: Element.lookupNamespaceURI not implemented. + it.skipIf(isHappyDom())('Should be able to set namespaced attribute values', function () { var model = { myValue: 'first value' } testNode.innerHTML = [ '', diff --git a/builds/knockout/spec/defaultBindings/optionsBehaviors.js b/builds/knockout/spec/defaultBindings/optionsBehaviors.js index a19d5888..c5629cf3 100644 --- a/builds/knockout/spec/defaultBindings/optionsBehaviors.js +++ b/builds/knockout/spec/defaultBindings/optionsBehaviors.js @@ -127,7 +127,8 @@ describe('Binding: Options', function () { expectHaveSelectedValues(testNode.childNodes[0], [4]) }) - it('Should select caption by default and retain selection when adding multiple items', function () { + // happy-dom gap:
", @@ -135,7 +137,8 @@ describe('onError handler', function () { expect(windowOnErrorCount).to.equal(1) }) - it('passes through the error instance', async function () { + // happy-dom gap: errors thrown from setTimeout callbacks bypass window.onerror. + it.skipIf(isHappyDom())('passes through the error instance', async function () { var expectedInstance ko.tasks.schedule(function () { expectedInstance = new Error('Some error') diff --git a/packages/binding.component/spec/componentBindingBehaviors.ts b/packages/binding.component/spec/componentBindingBehaviors.ts index 8b39cccb..d4454aee 100644 --- a/packages/binding.component/spec/componentBindingBehaviors.ts +++ b/packages/binding.component/spec/componentBindingBehaviors.ts @@ -1271,7 +1271,7 @@ describe('Components: Component binding', function () { ViewModel.register('test-component') applyBindings(outerViewModel, testNode) - expect((testNode.children[0] as HTMLInputElement).innerText.trim()).to.deep.equal(`beep / beep`) + expect((testNode.children[0] as HTMLInputElement).innerText.replace(/\s+/g, ' ').trim()).to.deep.equal(`beep / beep`) }) it('inserts into nested elements', function () { @@ -1405,7 +1405,7 @@ describe('Components: Component binding', function () { ViewModel.register('test-component') applyBindings(outerViewModel, testNode) - expect((testNode.children[0] as HTMLElement).innerText.trim()).to.deep.equal(`A. B. C.`) + expect((testNode.children[0] as HTMLElement).innerText.replace(/\s+/g, ' ').trim()).to.deep.equal(`A. B. C.`) const em = testNode.children[0].children[0].children[0] expect(em.tagName).to.deep.equal('EM') }) @@ -1426,7 +1426,7 @@ describe('Components: Component binding', function () { ViewModel.register('test-component') applyBindings(outerViewModel, testNode) - expect((testNode.children[0] as HTMLElement).innerText.trim()).to.deep.equal(`B. C. E.`) + expect((testNode.children[0] as HTMLElement).innerText.replace(/\s+/g, ' ').trim()).to.deep.equal(`B. C. E.`) const em = testNode.children[0].children[0].children[0] expect(em.tagName).to.deep.equal('EM') }) diff --git a/packages/binding.core/spec/attrBehaviors.ts b/packages/binding.core/spec/attrBehaviors.ts index 7810dcea..68d895b2 100644 --- a/packages/binding.core/spec/attrBehaviors.ts +++ b/packages/binding.core/spec/attrBehaviors.ts @@ -12,6 +12,7 @@ import { options } from '@tko/utils' import * as coreBindings from '../dist' import { prepareTestNode } from '../../utils/helpers/mocha-test-helpers' +import { isHappyDom } from '../../utils/helpers/test-env' describe('Binding: Attr', function () { let testNode: HTMLElement @@ -33,7 +34,9 @@ describe('Binding: Attr', function () { expect(testNode.children[0].getAttribute('second-attribute')).to.equal('true') }) - it('Should be able to set namespaced attribute values', function () { + // happy-dom gap: Element.lookupNamespaceURI is not implemented, which the + // attr binding calls to resolve "xlink:" prefixes on SVG nodes. + it.skipIf(isHappyDom())('Should be able to set namespaced attribute values', function () { const model = { myValue: 'first value' } testNode.innerHTML = [ '', diff --git a/packages/binding.core/spec/eventBehaviors.ts b/packages/binding.core/spec/eventBehaviors.ts index d3968e0a..c4576456 100644 --- a/packages/binding.core/spec/eventBehaviors.ts +++ b/packages/binding.core/spec/eventBehaviors.ts @@ -14,6 +14,7 @@ import { options } from '@tko/utils' import { bindings as coreBindings } from '../dist' import { prepareTestNode } from '../../utils/helpers/mocha-test-helpers' +import { isHappyDom } from '../../utils/helpers/test-env' describe('Binding: Event', function () { let testNode: HTMLElement diff --git a/packages/binding.core/spec/optionsBehaviors.ts b/packages/binding.core/spec/optionsBehaviors.ts index 89d7fe8d..f4059f32 100644 --- a/packages/binding.core/spec/optionsBehaviors.ts +++ b/packages/binding.core/spec/optionsBehaviors.ts @@ -13,6 +13,7 @@ import { bindings as coreBindings } from '../dist' import type { ObservableArray } from '@tko/observable' import { expectContainText, nodeText, prepareTestNode } from '../../utils/helpers/mocha-test-helpers' +import { isHappyDom } from '../../utils/helpers/test-env' function expectArrayEqual(actual: Array, expected: Array) { expect(actual.length).to.equal(expected.length) @@ -183,7 +184,9 @@ describe('Binding: Options', function () { expectHaveSelectedValues(testNode.childNodes[0], [4]) }) - it('Should select caption by default and retain selection when adding multiple items', function () { + // happy-dom gap:
" + '' + '
').appendTo(document.body) diff --git a/packages/binding.if/spec/elseBehaviors.ts b/packages/binding.if/spec/elseBehaviors.ts index 5fcccd20..ab325a41 100644 --- a/packages/binding.if/spec/elseBehaviors.ts +++ b/packages/binding.if/spec/elseBehaviors.ts @@ -36,7 +36,7 @@ describe('else inside an if binding', function () { expect(testNode.childNodes[0].childNodes.length).to.equal(3) applyBindings({ x: true }, testNode) expect(testNode.childNodes[0].childNodes.length).to.equal(1) - expect(testNode.innerText).to.equal('abc') + expect(testNode.innerText.trim()).to.equal('abc') }) it('shows the else-block when the condition is false', function () { @@ -44,7 +44,7 @@ describe('else inside an if binding', function () { expect(testNode.childNodes[0].childNodes.length).to.equal(3) applyBindings({ x: false }, testNode) expect(testNode.childNodes[0].childNodes.length).to.equal(1) - expect(testNode.innerText).to.equal('def') + expect(testNode.innerText.trim()).to.equal('def') }) it('toggles between if/else on condition change', function () { @@ -53,9 +53,9 @@ describe('else inside an if binding', function () { expect(testNode.childNodes[0].childNodes.length).to.equal(3) applyBindings({ x: x }, testNode) expect(testNode.childNodes[0].childNodes.length).to.equal(1) - expect(testNode.innerText).to.equal('def') + expect(testNode.innerText.trim()).to.equal('def') x(true) - expect(testNode.innerText).to.equal('abc') + expect(testNode.innerText.trim()).to.equal('abc') }) }) }) diff --git a/packages/binding.template/helpers/dummyTemplateEngine.ts b/packages/binding.template/helpers/dummyTemplateEngine.ts index 8e45eb41..231d9221 100644 --- a/packages/binding.template/helpers/dummyTemplateEngine.ts +++ b/packages/binding.template/helpers/dummyTemplateEngine.ts @@ -50,12 +50,7 @@ export function dummyTemplateEngine(templates?) { let result data = data || {} - // Builders (e.g. rollup) mangle `data` to e.g. `data$$1`. - // This workaround works as long as nomangle$data doesn't - // appear anywhere not in tests. const nomangle$data: any = data - ;(window as any).__prevent_tree_shaking__ = nomangle$data - delete (window as any).__prevent_tree_shaking__ rt_options.templateRenderingVariablesInScope = rt_options.templateRenderingVariablesInScope || {} @@ -66,10 +61,21 @@ export function dummyTemplateEngine(templates?) { return renderTemplate(templateName, data, rt_options) }) + // Bundlers can rename the closure-captured `unwrap` import (e.g. esbuild's + // ESM transform), breaking `eval(script)` that references it by name. Pass + // dependencies explicitly via `new Function` parameters so the evaluator + // survives any module transform. Scripts come in two flavors — a bare + // expression (returnable) or a statement list (no implicit return) — so + // try the expression form first and fall back on syntax error. const evalHandler = function (match, script) { - void unwrap // keep in scope for eval'd template expressions try { - const evalResult = eval(script) + let fn: Function + try { + fn = new Function('unwrap', 'nomangle$data', 'rt_options', 'bindingContext', `return (${script})`) + } catch { + fn = new Function('unwrap', 'nomangle$data', 'rt_options', 'bindingContext', script) + } + const evalResult = fn(unwrap, nomangle$data, rt_options, bindingContext) return evalResult === null || evalResult === undefined ? '' : evalResult.toString() } catch (ex: any) { throw new Error('Error evaluating script: [js: ' + script + ']\n\nException: ' + ex.toString(), { cause: ex }) diff --git a/packages/binding.template/spec/nativeTemplateEngineBehaviors.ts b/packages/binding.template/spec/nativeTemplateEngineBehaviors.ts index afac54ca..f704b312 100644 --- a/packages/binding.template/spec/nativeTemplateEngineBehaviors.ts +++ b/packages/binding.template/spec/nativeTemplateEngineBehaviors.ts @@ -212,12 +212,12 @@ describe('Native template engine', function () { }) it('with no content should be rejected', function () { - const anyWindow = window as any - anyWindow.testDivTemplate.innerHTML = "
" + const testDivTemplate = ensureNodeExistsAndIsEmpty('testDivTemplate') + testDivTemplate.innerHTML = "
" const viewModel = { someItem: { val: 'abc' } } expect(function () { - applyBindings(viewModel, anyWindow.testDivTemplate) + applyBindings(viewModel, testDivTemplate) }).to.throw(/no template content/) }) }) diff --git a/packages/provider.component/src/ComponentProvider.ts b/packages/provider.component/src/ComponentProvider.ts index 5ce60209..fe496cc2 100644 --- a/packages/provider.component/src/ComponentProvider.ts +++ b/packages/provider.component/src/ComponentProvider.ts @@ -59,7 +59,7 @@ export default class ComponentProvider extends Provider { const tagName = tagNameLower(node) if (registry.isRegistered(tagName)) { const hasDash = tagName.includes('-') - const isUnknownEntity = '' + node === '[object HTMLUnknownElement]' + const isUnknownEntity = Object.prototype.toString.call(node) === '[object HTMLUnknownElement]' if (hasDash || isUnknownEntity) { return tagName } diff --git a/packages/utils.component/spec/defaultLoaderBehaviors.ts b/packages/utils.component/spec/defaultLoaderBehaviors.ts index 6cf24538..934ab8f4 100644 --- a/packages/utils.component/spec/defaultLoaderBehaviors.ts +++ b/packages/utils.component/spec/defaultLoaderBehaviors.ts @@ -5,6 +5,7 @@ import components from '../dist' import { expect } from 'chai' import sinon from 'sinon' import { expectContainText, restoreAfter, useMockForTasks } from '../../utils/helpers/mocha-test-helpers' +import { isHappyDom } from '../../utils/helpers/test-env' describe('Components: Default loader', function () { const testComponentName = 'test-component' @@ -279,7 +280,9 @@ describe('Components: Default loader', function () { testTemplateFromElement('', 'my-script-elem') }) - it('Can be configured as the ID of a ', 'my-textarea-elem') }) @@ -298,7 +301,8 @@ describe('Components: Default loader', function () { testTemplateFromElement('', null) }) - it('Can be configured as a ', null) }) diff --git a/packages/utils/helpers/test-env.ts b/packages/utils/helpers/test-env.ts new file mode 100644 index 00000000..48bc8f49 --- /dev/null +++ b/packages/utils/helpers/test-env.ts @@ -0,0 +1,23 @@ +// Detectors for the vitest environment a test is running under. +// Used with `it.skipIf(isHappyDom())` / `describe.skipIf(isHappyDom())` +// to document known-divergent behavior rather than silently excluding it. +// Each skip should come with a short comment and (when applicable) a link +// to the upstream tracker. + +export function isHappyDom(): boolean { + return typeof navigator !== 'undefined' && /HappyDOM/i.test(navigator.userAgent ?? '') +} + +export function isRealBrowser(): boolean { + return typeof window !== 'undefined' && + !isHappyDom() && + typeof (window as any).PlaywrightTestingLibrary !== 'undefined' + ? true + : typeof navigator !== 'undefined' && + /Chrome|Firefox|Safari|WebKit/i.test(navigator.userAgent ?? '') && + !isHappyDom() +} + +export function isNode(): boolean { + return typeof document === 'undefined' +} diff --git a/packages/utils/spec/utilsDomBehaviors.ts b/packages/utils/spec/utilsDomBehaviors.ts index 48d818e8..98f4176f 100644 --- a/packages/utils/spec/utilsDomBehaviors.ts +++ b/packages/utils/spec/utilsDomBehaviors.ts @@ -5,6 +5,7 @@ import { registerEventHandler, virtualElements } from '../dist' import options from '../dist/options' import type { KnockoutInstance } from '@tko/builder' import { prepareTestNode, restoreAfter } from '../helpers/mocha-test-helpers' +import { isHappyDom } from '../helpers/test-env' const ko: KnockoutInstance = globalThis.ko || {} ko.utils = utils @@ -169,7 +170,9 @@ describe('selectExtensions', () => { testNode = prepareTestNode() }) - it('should use loose equality for select value', () => { + // happy-dom gap: `selected` attribute on an diff --git a/packages/utils/src/dom/event.ts b/packages/utils/src/dom/event.ts index fcd8b69a..f1bc5435 100644 --- a/packages/utils/src/dom/event.ts +++ b/packages/utils/src/dom/event.ts @@ -75,34 +75,27 @@ export function triggerEvent(element: Element, eventType: string): void { if (!options.useOnlyNativeEvents && options.jQuery && !useClickWorkaround) { options.jQuery(element).trigger(eventType) - } else if (typeof document.createEvent === 'function') { - if (typeof element.dispatchEvent === 'function') { - const eventCategory = knownEventTypesByEventName[eventType] || 'HTMLEvents' - const event = document.createEvent(eventCategory) - ;(event as any).initEvent( - eventType, - true, - true, - options.global, - 0, - 0, - 0, - 0, - 0, - false, - false, - false, - false, - 0, - element - ) - element.dispatchEvent(event) - } else { - throw new Error("The supplied element doesn't support dispatchEvent") + return + } + + if (typeof element.dispatchEvent !== 'function') { + if (useClickWorkaround && hasClick(element)) { + element.click() + return } - } else if (useClickWorkaround && hasClick(element)) { - element.click() + throw new Error("The supplied element doesn't support dispatchEvent") + } + + const eventCategory = knownEventTypesByEventName[eventType] || 'HTMLEvents' + const view = options.global as Window | undefined + const init = { bubbles: true, cancelable: true, view } + let event: Event + if (eventCategory === 'MouseEvents' && typeof MouseEvent === 'function') { + event = new MouseEvent(eventType, init) + } else if (eventCategory === 'UIEvents' && typeof KeyboardEvent === 'function') { + event = new KeyboardEvent(eventType, init) } else { - throw new Error("Browser doesn't support triggering events") + event = new Event(eventType, init) } + element.dispatchEvent(event) } diff --git a/vitest.config.ts b/vitest.config.ts index f4964199..9a396211 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,12 +5,49 @@ const browsers = ((globalThis as any).process?.env?.VITEST_BROWSERS || 'chromium .split(',') .map((b: string) => ({ browser: b.trim() })) +const ALL_SPECS = ['packages/*/spec/**/*.ts', 'builds/reference/spec/**/*.js', 'builds/knockout/spec/**/*.js'] + export default defineConfig({ test: { - include: ['packages/*/spec/**/*.ts', 'builds/reference/spec/**/*.js', 'builds/knockout/spec/**/*.js'], - setupFiles: ['builds/knockout/helpers/vitest-setup.js'], - browser: { enabled: true, provider: playwright(), headless: true, instances: browsers }, + testTimeout: 10000, globals: true, - testTimeout: 10000 + projects: [ + // Authoritative real-browser matrix — UNCHANGED. Always runs every spec. + { + test: { + name: 'browser', + include: ALL_SPECS, + setupFiles: ['builds/knockout/helpers/vitest-setup.js'], + browser: { enabled: true, provider: playwright(), headless: true, instances: browsers }, + globals: true, + testTimeout: 10000 + } + }, + // EXPERIMENTAL: additive CLI coverage — Bun runtime, no DOM. + // We run the suite via `bunx vitest`, so the runtime is already Bun. + // `environment: 'node'` just tells vitest "don't provide a DOM" — + // Bun provides the node-compatible globals natively. Intent: prove + // the reactive primitives run in server-side contexts (Bun CLIs, + // TUIs, daemons). + { + test: { + name: 'cli-bun', + include: ALL_SPECS, + environment: 'node', + globals: true + } + }, + // EXPERIMENTAL: additive CLI coverage — happy-dom. + // Intent: prove the binding engine works in a JS DOM (SSR, headless, TUI adapters). + { + test: { + name: 'cli-happy-dom', + include: ALL_SPECS, + environment: 'happy-dom', + setupFiles: ['builds/knockout/helpers/vitest-setup.js'], + globals: true + } + } + ] } }) From 3aea21c88a329241db605646e269d7f4463a8a0c Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Fri, 17 Apr 2026 15:47:57 -0400 Subject: [PATCH 2/8] Address adversarial-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore `relatedTarget: element` on synthetic MouseEvents; the previous modernization dropped it but the legacy `initEvent(...)` call had passed it as the last arg, and consumers read `event.relatedTarget` on mouseover / mouseout / mouseenter / mouseleave. - Narrow the foreach `focus` skip from `describe.skipIf` to per-test `it.skipIf` on the four tests with genuine happy-dom gaps. The first test (`does not preserve the target on apply bindings`) passes in happy-dom — the describe-level skip was silently removing verified coverage. - Add a changeset covering the `triggerEvent`, `eventHandler`, and `ComponentProvider` behavior deltas from the previous commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/modernize-trigger-event.md | 23 +++++++++++++++++++ packages/binding.foreach/spec/eachBehavior.ts | 14 +++++------ packages/utils/src/dom/event.ts | 10 ++++---- 3 files changed, 36 insertions(+), 11 deletions(-) create mode 100644 .changeset/modernize-trigger-event.md diff --git a/.changeset/modernize-trigger-event.md b/.changeset/modernize-trigger-event.md new file mode 100644 index 00000000..594fb790 --- /dev/null +++ b/.changeset/modernize-trigger-event.md @@ -0,0 +1,23 @@ +--- +"@tko/utils": patch +"@tko/binding.core": patch +"@tko/provider.component": patch +--- + +Modernize synthetic event construction + +`triggerEvent` (exported from `@tko/utils`) now builds synthetic events using +`new MouseEvent`/`KeyboardEvent`/`Event` constructors instead of the +deprecated `document.createEvent('HTMLEvents')` + `initEvent(...)` path. This +restores native side-effects in modern DOM implementations (e.g. synthetic +clicks toggle checkbox `.checked` in happy-dom) without changing behavior in +real browsers. `relatedTarget` is still set to the target element for mouse +events to match the previous init-event argument list. + +`@tko/binding.core` event handler no longer assigns the legacy +`event.cancelBubble = true` before calling `event.stopPropagation()` — the +assignment is redundant on modern events and readonly on some implementations. + +`@tko/provider.component` now uses `Object.prototype.toString.call(node)` to +detect `HTMLUnknownElement` rather than `'' + node`, which is immune to +user-land `toString` overrides on custom elements. diff --git a/packages/binding.foreach/spec/eachBehavior.ts b/packages/binding.foreach/spec/eachBehavior.ts index d8c1cd91..a0f8f69a 100644 --- a/packages/binding.foreach/spec/eachBehavior.ts +++ b/packages/binding.foreach/spec/eachBehavior.ts @@ -1008,9 +1008,7 @@ describe('observable array changes', function () { }) }) -// happy-dom gap: focus()/document.activeElement behavior differs from real browsers, -// particularly around focus preservation across DOM reordering. -describe.skipIf(isHappyDom())('focus', function () { +describe('focus', function () { let $target beforeEach(function () { $target = $("
" + '' + '
').appendTo(document.body) @@ -1029,7 +1027,9 @@ describe.skipIf(isHappyDom())('focus', function () { assert.strictEqual(document.activeElement, document.body) }) - it('does not preserves primitive targets when re-ordering', async function () { + // happy-dom gap below: focus preservation across foreach re-ordering relies on + // focus()/activeElement semantics that diverge from real browsers. + it.skipIf(isHappyDom())('does not preserves primitive targets when re-ordering', async function () { const list = observableArray(['a', 'b', 'c']) applyBindings(list, $target[0]) $target.find(':input').first().focus() @@ -1041,7 +1041,7 @@ describe.skipIf(isHappyDom())('focus', function () { assert.strictEqual(document.activeElement, document.body) }) - it('preserves objects when re-ordering', async function () { + it.skipIf(isHappyDom())('preserves objects when re-ordering', async function () { const o0 = {} const list = observableArray([o0, 'b', 'c']) applyBindings(list, $target[0]) @@ -1054,7 +1054,7 @@ describe.skipIf(isHappyDom())('focus', function () { assert.strictEqual(document.activeElement, $target.find(':input')[2], 'o') }) - it('preserves objects when re-ordering multiple identical', async function () { + it.skipIf(isHappyDom())('preserves objects when re-ordering multiple identical', async function () { const o0 = {} const list = observableArray([o0, 'b', 'c']) applyBindings(list, $target[0]) @@ -1070,7 +1070,7 @@ describe.skipIf(isHappyDom())('focus', function () { assert.strictEqual(document.activeElement, $target.find(':input')[3], 'o') }) - it('preserves objects when re-ordering multiple identical, alt', async function () { + it.skipIf(isHappyDom())('preserves objects when re-ordering multiple identical, alt', async function () { const o0 = {} const list = observableArray([o0, 'b', 'c']) applyBindings(list, $target[0]) diff --git a/packages/utils/src/dom/event.ts b/packages/utils/src/dom/event.ts index f1bc5435..c00e1b0d 100644 --- a/packages/utils/src/dom/event.ts +++ b/packages/utils/src/dom/event.ts @@ -88,14 +88,16 @@ export function triggerEvent(element: Element, eventType: string): void { const eventCategory = knownEventTypesByEventName[eventType] || 'HTMLEvents' const view = options.global as Window | undefined - const init = { bubbles: true, cancelable: true, view } let event: Event if (eventCategory === 'MouseEvents' && typeof MouseEvent === 'function') { - event = new MouseEvent(eventType, init) + // Preserve the legacy initEvent(...) behavior of passing the element itself + // as relatedTarget — handlers for mouseover/mouseout/mouseenter/mouseleave + // observe event.relatedTarget. + event = new MouseEvent(eventType, { bubbles: true, cancelable: true, view, relatedTarget: element }) } else if (eventCategory === 'UIEvents' && typeof KeyboardEvent === 'function') { - event = new KeyboardEvent(eventType, init) + event = new KeyboardEvent(eventType, { bubbles: true, cancelable: true, view }) } else { - event = new Event(eventType, init) + event = new Event(eventType, { bubbles: true, cancelable: true }) } element.dispatchEvent(event) } From ebbfc8b034969ecd61ea37dd3b061e5e07086f09 Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Sat, 18 Apr 2026 16:53:21 -0400 Subject: [PATCH 3/8] Address PR #333 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the `cli-bun` vitest project. Running `bunx vitest run` without `--project` runs every configured project, which means the default `bun run test` and both CI workflows (main-build.yml, test-headless.yml) would execute the broken cli-bun config. Restoring it properly requires a per-package runtime declaration (e.g. `"tko": { "runtime": "dom" | "universal" }`) so the runner can include only DOM-free packages — that's a separate follow-up branch. - Remove the unused `isHappyDom` import from eventBehaviors.ts. The source fix to `event.cancelBubble` removed the need to skip the bubble tests; the import was left behind. - Rewrite `it.skipIf(isHappyDom())` / `describe.skipIf(isHappyDom())` in TypeScript spec files as `;(isHappyDom() ? it.skip : it)(...)`. The repo types test globals via `@types/mocha`, which doesn't declare `skipIf` on `TestFunction`, so `bunx tsc` rejected the shorter form. The ternary uses only Mocha-native `it` and `it.skip` and makes the selection explicit at the call site. JS build specs keep `it.skipIf(...)` — no typechecker to satisfy there. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/binding.core/spec/attrBehaviors.ts | 2 +- packages/binding.core/spec/eventBehaviors.ts | 1 - packages/binding.core/spec/optionsBehaviors.ts | 6 +++--- packages/binding.core/spec/valueBehaviors.ts | 2 +- packages/binding.foreach/spec/eachBehavior.ts | 8 ++++---- .../spec/defaultLoaderBehaviors.ts | 4 ++-- packages/utils/spec/utilsDomBehaviors.ts | 2 +- vitest.config.ts | 16 +--------------- 8 files changed, 13 insertions(+), 28 deletions(-) diff --git a/packages/binding.core/spec/attrBehaviors.ts b/packages/binding.core/spec/attrBehaviors.ts index 68d895b2..9b7a1225 100644 --- a/packages/binding.core/spec/attrBehaviors.ts +++ b/packages/binding.core/spec/attrBehaviors.ts @@ -36,7 +36,7 @@ describe('Binding: Attr', function () { // happy-dom gap: Element.lookupNamespaceURI is not implemented, which the // attr binding calls to resolve "xlink:" prefixes on SVG nodes. - it.skipIf(isHappyDom())('Should be able to set namespaced attribute values', function () { + ;(isHappyDom() ? it.skip : it)('Should be able to set namespaced attribute values', function () { const model = { myValue: 'first value' } testNode.innerHTML = [ '', diff --git a/packages/binding.core/spec/eventBehaviors.ts b/packages/binding.core/spec/eventBehaviors.ts index c4576456..d3968e0a 100644 --- a/packages/binding.core/spec/eventBehaviors.ts +++ b/packages/binding.core/spec/eventBehaviors.ts @@ -14,7 +14,6 @@ import { options } from '@tko/utils' import { bindings as coreBindings } from '../dist' import { prepareTestNode } from '../../utils/helpers/mocha-test-helpers' -import { isHappyDom } from '../../utils/helpers/test-env' describe('Binding: Event', function () { let testNode: HTMLElement diff --git a/packages/binding.core/spec/optionsBehaviors.ts b/packages/binding.core/spec/optionsBehaviors.ts index f4059f32..304874ef 100644 --- a/packages/binding.core/spec/optionsBehaviors.ts +++ b/packages/binding.core/spec/optionsBehaviors.ts @@ -186,7 +186,7 @@ describe('Binding: Options', function () { // happy-dom gap:
", @@ -138,7 +138,7 @@ describe('onError handler', function () { }) // happy-dom gap: errors thrown from setTimeout callbacks bypass window.onerror. - it.skipIf(isHappyDom())('passes through the error instance', async function () { + itBrowserOnly('passes through the error instance', async function () { var expectedInstance ko.tasks.schedule(function () { expectedInstance = new Error('Some error') diff --git a/packages/binding.core/spec/attrBehaviors.ts b/packages/binding.core/spec/attrBehaviors.ts index 9b7a1225..4e0895c9 100644 --- a/packages/binding.core/spec/attrBehaviors.ts +++ b/packages/binding.core/spec/attrBehaviors.ts @@ -12,7 +12,7 @@ import { options } from '@tko/utils' import * as coreBindings from '../dist' import { prepareTestNode } from '../../utils/helpers/mocha-test-helpers' -import { isHappyDom } from '../../utils/helpers/test-env' +import { itBrowserOnly } from '../../utils/helpers/test-env' describe('Binding: Attr', function () { let testNode: HTMLElement @@ -36,7 +36,7 @@ describe('Binding: Attr', function () { // happy-dom gap: Element.lookupNamespaceURI is not implemented, which the // attr binding calls to resolve "xlink:" prefixes on SVG nodes. - ;(isHappyDom() ? it.skip : it)('Should be able to set namespaced attribute values', function () { + itBrowserOnly('Should be able to set namespaced attribute values', function () { const model = { myValue: 'first value' } testNode.innerHTML = [ '', diff --git a/packages/binding.core/spec/optionsBehaviors.ts b/packages/binding.core/spec/optionsBehaviors.ts index 18d8944e..a3619f5e 100644 --- a/packages/binding.core/spec/optionsBehaviors.ts +++ b/packages/binding.core/spec/optionsBehaviors.ts @@ -13,7 +13,7 @@ import { bindings as coreBindings } from '../dist' import type { ObservableArray } from '@tko/observable' import { expectContainText, nodeText, prepareTestNode } from '../../utils/helpers/mocha-test-helpers' -import { isHappyDom } from '../../utils/helpers/test-env' +import { itBrowserOnly } from '../../utils/helpers/test-env' function expectArrayEqual(actual: Array, expected: Array) { expect(actual.length).to.equal(expected.length) @@ -186,55 +186,50 @@ describe('Binding: Options', function () { // happy-dom gap: B. @@ -1406,13 +1408,14 @@ describe('Components: Component binding', function () { } ViewModel.register('test-component') + if (!isRealBrowser()) return ctx.skip('happy-dom: innerText whitespace rendering differs from real browsers') applyBindings(outerViewModel, testNode) - expect((testNode.children[0] as HTMLElement).innerText.replace(/\s+/g, ' ').trim()).to.deep.equal(`A. B. C.`) + expect((testNode.children[0] as HTMLElement).innerText.trim()).to.deep.equal(`A. B. C.`) const em = testNode.children[0].children[0].children[0] expect(em.tagName).to.deep.equal('EM') }) - it('inserts multiple nodes from a