Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
11961e5
feat(docs): live browser test runner at /tests (POC, observable only)
Apr 21, 2026
46c3cf4
feat(docs): /tests now registers full 2669-test suite
Apr 21, 2026
f0f0a0c
feat(docs): split test bundle, add version-switcher UI
Apr 21, 2026
407d59f
tests.astro: swap unpkg → jsdelivr for CDN-loaded scripts
Apr 21, 2026
587833d
docs: correct note — TKO does ship ESM
Apr 21, 2026
a81a7f7
fix(tests): single-module graph for source bundle
Apr 21, 2026
886457e
tests UI: restrict to build-mode, hide dev/source toggle
Apr 21, 2026
98ca441
tests: hygiene work from state-leak investigation
Apr 21, 2026
09806fa
feat(tests): iframe-per-spec runner with TKO-driven UI
Apr 21, 2026
54fdc4a
tests: drop remaining iframe-runner failures from 48 toward ~1
Apr 21, 2026
4fd954d
feat(tests): green source-mode suite (2708/0/42 in 11.9s)
Apr 21, 2026
f2a8f54
tests: parallel hidden + serial focus queues (simplify fixes)
Apr 21, 2026
585c328
tests: link /tests from top nav
Apr 21, 2026
0b5648d
tests: apply PR review (Codex/Copilot/CodeRabbit)
Apr 22, 2026
e7184b6
tests UI: fixed-bottom work area
Apr 22, 2026
0771ac8
tests UI: floating translucent work-area + brand polish
Apr 22, 2026
aa98b20
tests UI: TKO wordmark match landing brand (24px/600)
Apr 22, 2026
92c3fe8
tests UI: TKO wordmark uses Lobster to match landing
Apr 22, 2026
a66121f
tests: address CodeRabbit/Copilot PR follow-ups + dark-mode TKO
Apr 22, 2026
c5bd8d7
tests: tighten browser-setup.js comments
Apr 22, 2026
f68cc67
docs: add browser test runner plan + promote plans/ in AGENTS
Apr 22, 2026
a7b0a5f
docs: tighten AGENTS.md Plans + Before-you-start sections
Apr 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ Two things shape the coverage/safety bar here more than any specific rule:

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.

## Before you start

- Check [`plans/`](plans/) — significant changes need a plan before code (see [Plans](#plans)).
- `verified-behaviors.json` in a package is a contract; don't break it without a plan.
- `bun run verify` passes before every commit.

## Project Structure

Monorepo with Bun workspaces.
Expand Down Expand Up @@ -156,9 +162,15 @@ long-lived publish token.

## Plans

Significant changes should have a plan file in `plans/` before implementation
begins. Plans document the context, approach, and verification steps. Review
existing plans in that directory for format examples.
Significant changes need a plan in [`plans/`](plans/) before code. Plans
document context, approach, files touched, and verification. Match the shape
of existing plans.

**Write one for:** new pages/routes, new build or CI steps, new cross-package
concepts, refactors across 5+ files.

**Skip for:** bug fixes, single-file edits, doc tweaks, dep bumps, comment
cleanup, new tests in existing specs.

## Agent-First Documentation

Expand Down
117 changes: 117 additions & 0 deletions builds/knockout/helpers/browser-setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Setup for running TKO specs in a real browser under Mocha.
// Counterpart to vitest-setup.js. Loaded as the first import of
// the test bundle (tko.io/scripts/bundle-tests.mjs).
//
// Assumes `mocha.setup('bdd')` already ran (so `before` / `after`
// / `beforeEach` / `afterEach` are global) and `globalThis.ko`
// was set by the bundled IIFE in /tests/source/setup.js, which
// this module is imported from.

import * as chai from 'chai'
import sinon from 'sinon'
// Register punctuation filters on shared `@tko/utils` options so
// specs that construct Parsers directly (`new Parser().parse('x | tail')`)
// can resolve them. The builder registers the same filters at page
// startup but on a module-local options reference — not this one.
import { filters as punctuationFilters } from '@tko/filter.punches'
import { options as sharedOptions } from '@tko/utils'

globalThis.chai = chai
globalThis.expect = chai.expect
globalThis.sinon = sinon
globalThis.isHappyDom = () => false

sharedOptions.filters = Object.assign(sharedOptions.filters || {}, punctuationFilters)

// mocha-test-helpers wires root beforeEach/afterEach hooks, so it
// must be imported after `mocha.setup('bdd')` ran.
import './mocha-test-helpers.js'

// Disable the 25ms JSX cleanup timer so it can't race test teardown.
before(() => {
if (globalThis.ko?.options) {
globalThis.ko.options.jsxCleanBatchSize = 0
}
})

// Iframe focus-event polyfill.
//
// Chromium refuses to grant programmatic `iframe.contentWindow.focus()`
// true system focus from a parent that already holds focus — the
// iframe never passes `document.hasFocus() === true`, so `focusin`
// / `focusout` are suppressed when specs call `element.focus()`
// inside. The `hasfocus` binding observes those events (not
// `document.activeElement`), so without this patch those specs
// fail under both Playwright and a real Chrome tab.
//
// Wrap `focus`/`blur` to dispatch the missing events synchronously
// after the native call. If the browser DOES regain system focus
// and fires them too, observers see duplicates — harmless for
// these specs (state-checking, not call-count).
//
// Scope-guarded to iframes (`window.parent !== window`) so the
// parent page is never patched.
//
// Refs: https://github.com/jsdom/jsdom/pull/2996 (same shape as
// this wrap), https://html.spec.whatwg.org/multipage/interaction.html#focusing-elements.
if (window.parent !== window && !HTMLElement.prototype.__tkoFocusPatched) {
const HE = HTMLElement.prototype
HE.__tkoFocusPatched = true
const origFocus = HE.focus
const origBlur = HE.blur
HE.focus = function (...args) {
const wasActive = this.ownerDocument.activeElement
origFocus.apply(this, args)
if (this.ownerDocument.activeElement === this && wasActive !== this) {
this.dispatchEvent(new FocusEvent('focus', { bubbles: false, relatedTarget: wasActive }))
this.dispatchEvent(new FocusEvent('focusin', { bubbles: true, relatedTarget: wasActive }))
}
}
HE.blur = function (...args) {
const wasActive = this.ownerDocument.activeElement
origBlur.apply(this, args)
if (wasActive === this && this.ownerDocument.activeElement !== this) {
this.dispatchEvent(new FocusEvent('blur', { bubbles: false, relatedTarget: this.ownerDocument.activeElement }))
this.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: this.ownerDocument.activeElement }))
}
}
}

// Unscoped sinon fakes (`sinon.spy(obj,'m')`, `sinon.useFakeTimers()`)
// leak across specs if not restored, producing bogus call-count
// diffs or "Can't install fake timers twice". `sinon.restore()` is
// a no-op for sandbox-scoped fakes. Vitest isolates per-file so
// doesn't need this hook.
afterEach(() => {
if (globalThis.sinon?.restore) globalThis.sinon.restore()
})

// Vitest-style context-arg shim.
//
// Specs written `function (ctx) { if (isHappyDom()) return ctx.skip(…) }`
// look like Mocha done-callback specs (`fn.length === 1`) and time
// out after ~10s because they never call done. Wrap `it` to detect
// the ctx shape (uses `.skip(...)` and never calls `done(`) and
// invoke with a synthetic `{ skip }` while hiding arity from Mocha.
{
const wrap = orig =>
function (name, fn) {
if (typeof fn === 'function' && fn.length === 1) {
const src = fn.toString()
const ctxStyle = /\.skip\s*\(/.test(src) && !/\bdone\s*\(/.test(src)
if (ctxStyle) {
const wrapped = function () {
return fn.call(this, { skip: reason => this.skip(reason) })
}
Object.defineProperty(wrapped, 'length', { value: 0 })
return orig.call(this, name, wrapped)
}
}
return orig.apply(this, arguments)
}
const origIt = globalThis.it
const wrappedIt = wrap(origIt)
wrappedIt.only = wrap(origIt.only)
wrappedIt.skip = origIt.skip
globalThis.it = wrappedIt
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
11 changes: 8 additions & 3 deletions packages/binding.component/spec/componentBindingBehaviors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1273,7 +1273,12 @@ describe('Components: Component binding', function () {

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

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

if (isHappyDom()) return ctx.skip('happy-dom: innerText whitespace rendering differs from real browsers')
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')
})
Expand All @@ -1432,7 +1437,7 @@ describe('Components: Component binding', function () {

if (isHappyDom()) return ctx.skip('happy-dom: innerText whitespace rendering differs from real browsers')
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')
})
Expand Down
13 changes: 8 additions & 5 deletions packages/binding.foreach/spec/eachBehavior.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1010,6 +1010,9 @@ describe('observable array changes', function () {

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

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

list.remove('a')
list.push('a')
await new Promise(resolve => setTimeout(resolve, 1000))
await nextFrame()
assert.strictEqual(document.activeElement, document.body)
})

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

list.remove(o0)
list.push(o0)
await new Promise(resolve => setTimeout(resolve, 1000))
await nextFrame()
assert.strictEqual(document.activeElement, $target.find(':input')[2], 'o')
})

Expand All @@ -1067,7 +1070,7 @@ describe('focus', function () {
list.push(o0)
list.push('y')

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

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

await new Promise(resolve => setTimeout(resolve, 1000))
await nextFrame()
assert.strictEqual(document.activeElement, $target.find(':input')[2], 'o')
})
})
Expand Down
14 changes: 10 additions & 4 deletions packages/binding.if/spec/elseBehaviors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,19 @@ describe('else inside an if binding', function () {
})

describe('as <!-- else -->', function () {
// innerText preserves whitespace from the HTML source in real
// browsers (happy-dom normalizes it, which is why that path
// skips). The source here intentionally includes a space
// around `<!-- else -->` so the comment parses as a binding,
// so the selected branch keeps a trailing/leading space in its
// text node. Trim assertions instead of stripping the source.
it('is ignored when the condition is true', function (ctx: any) {
if (isHappyDom()) return ctx.skip('happy-dom: innerText whitespace rendering differs from real browsers')
testNode.innerHTML = "<i data-bind='if: x'>" + 'abc <!-- else --> def' + '</i>'
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 (ctx: any) {
Expand All @@ -47,7 +53,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 (ctx: any) {
Expand All @@ -57,9 +63,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')
})
})
})
Expand Down
15 changes: 14 additions & 1 deletion packages/builder/spec/builderBehaviors.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
import { VirtualProvider } from '@tko/provider.virtual'
import { bindings as ifBindings } from '@tko/binding.if'
import { options } from '@tko/utils'

import { Builder } from '../dist'

describe('Builder', () => {
it('creates a ko instance', () => {
it('creates a ko instance', function () {
// `new Builder({...})` mutates the shared `@tko/utils` options
// (filters + bindingProviderInstance). Under Vitest's per-file
// module isolation the mutation is invisible to later specs;
// under the browser /tests runner every spec shares module
// state, so an unrestored mutation here wipes `options.filters`
// (which at this point holds the punctuation filters from
// `@tko/filter.punches`) and breaks ~14 downstream filter-
// lookup tests.
// @ts-ignore — global helper from mocha-test-helpers.js
restoreAfter(options, 'filters')
// @ts-ignore — global helper from mocha-test-helpers.js
restoreAfter(options, 'bindingProviderInstance')
// We're just testing that the builder constructs, here.
const builder = new Builder({ filters: {}, provider: new VirtualProvider(), bindings: [ifBindings], options: {} })
})
Expand Down
10 changes: 10 additions & 0 deletions packages/utils.parser/spec/identifierBehaviors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,16 @@ describe('Identifier', function () {
})

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