Skip to content

Commit fe8b3c0

Browse files
authored
Merge pull request #353 from knockout/feat/browser-test-runner
Live in-browser test runner at /tests
2 parents 5657f94 + a7b0a5f commit fe8b3c0

14 files changed

Lines changed: 1365 additions & 17 deletions

File tree

AGENTS.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ Two things shape the coverage/safety bar here more than any specific rule:
1717

1818
Together: coverage and signal are expensive to lose and cheap to keep. When a change trades either away, say so explicitly and justify the delta.
1919

20+
## Before you start
21+
22+
- Check [`plans/`](plans/) — significant changes need a plan before code (see [Plans](#plans)).
23+
- `verified-behaviors.json` in a package is a contract; don't break it without a plan.
24+
- `bun run verify` passes before every commit.
25+
2026
## Project Structure
2127

2228
Monorepo with Bun workspaces.
@@ -156,9 +162,15 @@ long-lived publish token.
156162

157163
## Plans
158164

159-
Significant changes should have a plan file in `plans/` before implementation
160-
begins. Plans document the context, approach, and verification steps. Review
161-
existing plans in that directory for format examples.
165+
Significant changes need a plan in [`plans/`](plans/) before code. Plans
166+
document context, approach, files touched, and verification. Match the shape
167+
of existing plans.
168+
169+
**Write one for:** new pages/routes, new build or CI steps, new cross-package
170+
concepts, refactors across 5+ files.
171+
172+
**Skip for:** bug fixes, single-file edits, doc tweaks, dep bumps, comment
173+
cleanup, new tests in existing specs.
162174

163175
## Agent-First Documentation
164176

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Setup for running TKO specs in a real browser under Mocha.
2+
// Counterpart to vitest-setup.js. Loaded as the first import of
3+
// the test bundle (tko.io/scripts/bundle-tests.mjs).
4+
//
5+
// Assumes `mocha.setup('bdd')` already ran (so `before` / `after`
6+
// / `beforeEach` / `afterEach` are global) and `globalThis.ko`
7+
// was set by the bundled IIFE in /tests/source/setup.js, which
8+
// this module is imported from.
9+
10+
import * as chai from 'chai'
11+
import sinon from 'sinon'
12+
// Register punctuation filters on shared `@tko/utils` options so
13+
// specs that construct Parsers directly (`new Parser().parse('x | tail')`)
14+
// can resolve them. The builder registers the same filters at page
15+
// startup but on a module-local options reference — not this one.
16+
import { filters as punctuationFilters } from '@tko/filter.punches'
17+
import { options as sharedOptions } from '@tko/utils'
18+
19+
globalThis.chai = chai
20+
globalThis.expect = chai.expect
21+
globalThis.sinon = sinon
22+
globalThis.isHappyDom = () => false
23+
24+
sharedOptions.filters = Object.assign(sharedOptions.filters || {}, punctuationFilters)
25+
26+
// mocha-test-helpers wires root beforeEach/afterEach hooks, so it
27+
// must be imported after `mocha.setup('bdd')` ran.
28+
import './mocha-test-helpers.js'
29+
30+
// Disable the 25ms JSX cleanup timer so it can't race test teardown.
31+
before(() => {
32+
if (globalThis.ko?.options) {
33+
globalThis.ko.options.jsxCleanBatchSize = 0
34+
}
35+
})
36+
37+
// Iframe focus-event polyfill.
38+
//
39+
// Chromium refuses to grant programmatic `iframe.contentWindow.focus()`
40+
// true system focus from a parent that already holds focus — the
41+
// iframe never passes `document.hasFocus() === true`, so `focusin`
42+
// / `focusout` are suppressed when specs call `element.focus()`
43+
// inside. The `hasfocus` binding observes those events (not
44+
// `document.activeElement`), so without this patch those specs
45+
// fail under both Playwright and a real Chrome tab.
46+
//
47+
// Wrap `focus`/`blur` to dispatch the missing events synchronously
48+
// after the native call. If the browser DOES regain system focus
49+
// and fires them too, observers see duplicates — harmless for
50+
// these specs (state-checking, not call-count).
51+
//
52+
// Scope-guarded to iframes (`window.parent !== window`) so the
53+
// parent page is never patched.
54+
//
55+
// Refs: https://github.com/jsdom/jsdom/pull/2996 (same shape as
56+
// this wrap), https://html.spec.whatwg.org/multipage/interaction.html#focusing-elements.
57+
if (window.parent !== window && !HTMLElement.prototype.__tkoFocusPatched) {
58+
const HE = HTMLElement.prototype
59+
HE.__tkoFocusPatched = true
60+
const origFocus = HE.focus
61+
const origBlur = HE.blur
62+
HE.focus = function (...args) {
63+
const wasActive = this.ownerDocument.activeElement
64+
origFocus.apply(this, args)
65+
if (this.ownerDocument.activeElement === this && wasActive !== this) {
66+
this.dispatchEvent(new FocusEvent('focus', { bubbles: false, relatedTarget: wasActive }))
67+
this.dispatchEvent(new FocusEvent('focusin', { bubbles: true, relatedTarget: wasActive }))
68+
}
69+
}
70+
HE.blur = function (...args) {
71+
const wasActive = this.ownerDocument.activeElement
72+
origBlur.apply(this, args)
73+
if (wasActive === this && this.ownerDocument.activeElement !== this) {
74+
this.dispatchEvent(new FocusEvent('blur', { bubbles: false, relatedTarget: this.ownerDocument.activeElement }))
75+
this.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: this.ownerDocument.activeElement }))
76+
}
77+
}
78+
}
79+
80+
// Unscoped sinon fakes (`sinon.spy(obj,'m')`, `sinon.useFakeTimers()`)
81+
// leak across specs if not restored, producing bogus call-count
82+
// diffs or "Can't install fake timers twice". `sinon.restore()` is
83+
// a no-op for sandbox-scoped fakes. Vitest isolates per-file so
84+
// doesn't need this hook.
85+
afterEach(() => {
86+
if (globalThis.sinon?.restore) globalThis.sinon.restore()
87+
})
88+
89+
// Vitest-style context-arg shim.
90+
//
91+
// Specs written `function (ctx) { if (isHappyDom()) return ctx.skip(…) }`
92+
// look like Mocha done-callback specs (`fn.length === 1`) and time
93+
// out after ~10s because they never call done. Wrap `it` to detect
94+
// the ctx shape (uses `.skip(...)` and never calls `done(`) and
95+
// invoke with a synthetic `{ skip }` while hiding arity from Mocha.
96+
{
97+
const wrap = orig =>
98+
function (name, fn) {
99+
if (typeof fn === 'function' && fn.length === 1) {
100+
const src = fn.toString()
101+
const ctxStyle = /\.skip\s*\(/.test(src) && !/\bdone\s*\(/.test(src)
102+
if (ctxStyle) {
103+
const wrapped = function () {
104+
return fn.call(this, { skip: reason => this.skip(reason) })
105+
}
106+
Object.defineProperty(wrapped, 'length', { value: 0 })
107+
return orig.call(this, name, wrapped)
108+
}
109+
}
110+
return orig.apply(this, arguments)
111+
}
112+
const origIt = globalThis.it
113+
const wrappedIt = wrap(origIt)
114+
wrappedIt.only = wrap(origIt.only)
115+
wrappedIt.skip = origIt.skip
116+
globalThis.it = wrappedIt
117+
}

packages/binding.component/spec/componentBindingBehaviors.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1273,7 +1273,12 @@ describe('Components: Component binding', function () {
12731273

12741274
if (isHappyDom()) return ctx.skip('happy-dom: innerText whitespace rendering differs from real browsers')
12751275
applyBindings(outerViewModel, testNode)
1276-
expect((testNode.children[0] as HTMLInputElement).innerText.trim()).to.deep.equal(`beep / beep`)
1276+
// `.trim()` alone strips only leading/trailing; real browsers
1277+
// preserve internal newlines + indentation from the template
1278+
// source between slotted nodes. Collapse to single spaces.
1279+
expect((testNode.children[0] as HTMLInputElement).innerText.replace(/\s+/g, ' ').trim()).to.deep.equal(
1280+
`beep / beep`
1281+
)
12771282
})
12781283

12791284
it('inserts into nested elements', function () {
@@ -1410,7 +1415,7 @@ describe('Components: Component binding', function () {
14101415

14111416
if (isHappyDom()) return ctx.skip('happy-dom: innerText whitespace rendering differs from real browsers')
14121417
applyBindings(outerViewModel, testNode)
1413-
expect((testNode.children[0] as HTMLElement).innerText.trim()).to.deep.equal(`A. B. C.`)
1418+
expect((testNode.children[0] as HTMLElement).innerText.replace(/\s+/g, ' ').trim()).to.deep.equal(`A. B. C.`)
14141419
const em = testNode.children[0].children[0].children[0]
14151420
expect(em.tagName).to.deep.equal('EM')
14161421
})
@@ -1432,7 +1437,7 @@ describe('Components: Component binding', function () {
14321437

14331438
if (isHappyDom()) return ctx.skip('happy-dom: innerText whitespace rendering differs from real browsers')
14341439
applyBindings(outerViewModel, testNode)
1435-
expect((testNode.children[0] as HTMLElement).innerText.trim()).to.deep.equal(`B. C. E.`)
1440+
expect((testNode.children[0] as HTMLElement).innerText.replace(/\s+/g, ' ').trim()).to.deep.equal(`B. C. E.`)
14361441
const em = testNode.children[0].children[0].children[0]
14371442
expect(em.tagName).to.deep.equal('EM')
14381443
})

packages/binding.foreach/spec/eachBehavior.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,6 +1010,9 @@ describe('observable array changes', function () {
10101010

10111011
describe('focus', function () {
10121012
let $target
1013+
// foreach schedules processQueue via requestAnimationFrame when setSync(false).
1014+
// Waiting one frame is enough to flush the DOM re-order + focus-preservation pass.
1015+
const nextFrame = () => new Promise(resolve => requestAnimationFrame(() => resolve(null)))
10131016
beforeEach(function () {
10141017
$target = $("<div data-bind='foreach: $data'>" + '<input />' + '</div>').appendTo(document.body)
10151018
ForEachBinding.setSync(false)
@@ -1023,7 +1026,7 @@ describe('focus', function () {
10231026
const list = ['a', 'b', 'c']
10241027
$target.find(':input').focus()
10251028
applyBindings(list, $target[0])
1026-
await new Promise(resolve => setTimeout(resolve, 1000))
1029+
await nextFrame()
10271030
assert.strictEqual(document.activeElement, document.body)
10281031
})
10291032

@@ -1036,7 +1039,7 @@ describe('focus', function () {
10361039

10371040
list.remove('a')
10381041
list.push('a')
1039-
await new Promise(resolve => setTimeout(resolve, 1000))
1042+
await nextFrame()
10401043
assert.strictEqual(document.activeElement, document.body)
10411044
})
10421045

@@ -1050,7 +1053,7 @@ describe('focus', function () {
10501053

10511054
list.remove(o0)
10521055
list.push(o0)
1053-
await new Promise(resolve => setTimeout(resolve, 1000))
1056+
await nextFrame()
10541057
assert.strictEqual(document.activeElement, $target.find(':input')[2], 'o')
10551058
})
10561059

@@ -1067,7 +1070,7 @@ describe('focus', function () {
10671070
list.push(o0)
10681071
list.push('y')
10691072

1070-
await new Promise(resolve => setTimeout(resolve, 1000))
1073+
await nextFrame()
10711074
assert.strictEqual(document.activeElement, $target.find(':input')[3], 'o')
10721075
})
10731076

@@ -1083,7 +1086,7 @@ describe('focus', function () {
10831086
list.push(o0) // focused
10841087
list.push(o0)
10851088

1086-
await new Promise(resolve => setTimeout(resolve, 1000))
1089+
await nextFrame()
10871090
assert.strictEqual(document.activeElement, $target.find(':input')[2], 'o')
10881091
})
10891092
})

packages/binding.if/spec/elseBehaviors.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,19 @@ describe('else inside an if binding', function () {
3232
})
3333

3434
describe('as <!-- else -->', function () {
35+
// innerText preserves whitespace from the HTML source in real
36+
// browsers (happy-dom normalizes it, which is why that path
37+
// skips). The source here intentionally includes a space
38+
// around `<!-- else -->` so the comment parses as a binding,
39+
// so the selected branch keeps a trailing/leading space in its
40+
// text node. Trim assertions instead of stripping the source.
3541
it('is ignored when the condition is true', function (ctx: any) {
3642
if (isHappyDom()) return ctx.skip('happy-dom: innerText whitespace rendering differs from real browsers')
3743
testNode.innerHTML = "<i data-bind='if: x'>" + 'abc <!-- else --> def' + '</i>'
3844
expect(testNode.childNodes[0].childNodes.length).to.equal(3)
3945
applyBindings({ x: true }, testNode)
4046
expect(testNode.childNodes[0].childNodes.length).to.equal(1)
41-
expect(testNode.innerText).to.equal('abc')
47+
expect(testNode.innerText.trim()).to.equal('abc')
4248
})
4349

4450
it('shows the else-block when the condition is false', function (ctx: any) {
@@ -47,7 +53,7 @@ describe('else inside an if binding', function () {
4753
expect(testNode.childNodes[0].childNodes.length).to.equal(3)
4854
applyBindings({ x: false }, testNode)
4955
expect(testNode.childNodes[0].childNodes.length).to.equal(1)
50-
expect(testNode.innerText).to.equal('def')
56+
expect(testNode.innerText.trim()).to.equal('def')
5157
})
5258

5359
it('toggles between if/else on condition change', function (ctx: any) {
@@ -57,9 +63,9 @@ describe('else inside an if binding', function () {
5763
expect(testNode.childNodes[0].childNodes.length).to.equal(3)
5864
applyBindings({ x: x }, testNode)
5965
expect(testNode.childNodes[0].childNodes.length).to.equal(1)
60-
expect(testNode.innerText).to.equal('def')
66+
expect(testNode.innerText.trim()).to.equal('def')
6167
x(true)
62-
expect(testNode.innerText).to.equal('abc')
68+
expect(testNode.innerText.trim()).to.equal('abc')
6369
})
6470
})
6571
})

packages/builder/spec/builderBehaviors.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,23 @@
11
import { VirtualProvider } from '@tko/provider.virtual'
22
import { bindings as ifBindings } from '@tko/binding.if'
3+
import { options } from '@tko/utils'
34

45
import { Builder } from '../dist'
56

67
describe('Builder', () => {
7-
it('creates a ko instance', () => {
8+
it('creates a ko instance', function () {
9+
// `new Builder({...})` mutates the shared `@tko/utils` options
10+
// (filters + bindingProviderInstance). Under Vitest's per-file
11+
// module isolation the mutation is invisible to later specs;
12+
// under the browser /tests runner every spec shares module
13+
// state, so an unrestored mutation here wipes `options.filters`
14+
// (which at this point holds the punctuation filters from
15+
// `@tko/filter.punches`) and breaks ~14 downstream filter-
16+
// lookup tests.
17+
// @ts-ignore — global helper from mocha-test-helpers.js
18+
restoreAfter(options, 'filters')
19+
// @ts-ignore — global helper from mocha-test-helpers.js
20+
restoreAfter(options, 'bindingProviderInstance')
821
// We're just testing that the builder constructs, here.
922
const builder = new Builder({ filters: {}, provider: new VirtualProvider(), bindings: [ifBindings], options: {} })
1023
})

packages/utils.parser/spec/identifierBehaviors.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,16 @@ describe('Identifier', function () {
173173
})
174174

175175
it('sets `this` of a top-level item to $data', function () {
176+
// `restoreAfter` is a global cleanup-stack helper from
177+
// builds/knockout/helpers/mocha-test-helpers.js. Vitest runs
178+
// each spec file in isolated module state so this mutation is
179+
// invisible to later specs; in the browser /tests runner all
180+
// specs share one module graph, so without this restore the
181+
// mutated `bindingGlobals` leaks into every subsequent spec
182+
// that resolves `$parent` / `$customProp` / … against the
183+
// global lookup, breaking 31 unrelated tests.
184+
// @ts-ignore — global helper from mocha-test-helpers.js
185+
restoreAfter(options, 'bindingGlobals')
176186
options.bindingGlobals = Object.create({ Ramanujan: '1729' })
177187
const div = document.createElement('div'),
178188
context = {

0 commit comments

Comments
 (0)