From 11961e515f32dabb84d6fea56a6bebf3ca2ed421 Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Tue, 21 Apr 2026 12:09:05 -0400 Subject: [PATCH 01/22] feat(docs): live browser test runner at /tests (POC, observable only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dark-factory motivation: AI agents working on TKO should be able to navigate to `https://tko.io/tests` in any browser, watch the spec suite execute against real TKO + real DOM, and read pass/fail without cloning the repo or running Vitest locally. POC scope: `packages/observable/spec/**/*.ts` only. Verified locally with 113 passes, 0 failures, ~0.01s runtime. New pieces ---------- - `builds/knockout/helpers/browser-setup.js` — Mocha-oriented counterpart to the existing `vitest-setup.js`. Imports chai + sinon as ES modules (bundled by esbuild), exposes them as globals for spec compatibility, defines `isHappyDom = () => false` (real browser), and registers a Mocha `before()` hook that forces `ko.options.jsxCleanBatchSize = 0` so the 25ms JSX cleanup timer does not race test teardown. - `tko.io/scripts/bundle-tests.mjs` — esbuild bundler. Reads the spec glob list (SCOPE array), generates an in-memory entry via esbuild's `stdin` option (no `_entry.ts` file written to `public/`, which would otherwise be copied into `dist/tests/` at build time and served as a static asset), and emits `public/tests/bundle.js` as an IIFE with `globalName: 'tkoTests'`. chai/sinon/@tko/* are bundled; `mocha` is external so the page's ` + + + + + + + + + + + From 46c3cf4399e00acdb066f55b7c423381d5632f45 Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Tue, 21 Apr 2026 12:24:24 -0400 Subject: [PATCH 02/22] feat(docs): /tests now registers full 2669-test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope expanded from the observable-only POC to every package's specs plus the two bundled builds — `packages/*/spec/**/*.ts` + `builds/{reference,knockout}/spec/**/*.js`. Matches `ALL_SPECS` in `vitest.config.ts`. Blocker and fix --------------- The first attempt at full scope registered only 976 tests out of the ~2500 `it()` calls that exist in the tree. Root cause: a couple of specs under `builds/reference/spec/` import from `..` which resolves to `builds/reference/src/index.ts`, and that file references the compile-time constant `BUILD_VERSION`. `tools/ build.ts` injects it at build time with `--define:BUILD_VERSION ='"${version}"'`; Vitest configures the same define under the hood. esbuild in this bundler did not, so the first reference threw `BUILD_VERSION is not defined` at module-top, aborting the surrounding IIFE and dropping every subsequent spec's `describe`/`it` registration. Fix: `bundle-tests.mjs` now reads the root `package.json` version and passes `define: { BUILD_VERSION: JSON.stringify( version) }` to esbuild, mirroring `tools/build.ts`. After the fix: 2669 tests registered in 324 suites (was 976 tests, 109 suites). 2327 pass, 178 fail — investigating. Other changes in this commit ---------------------------- - `bundle-tests.mjs` — scope expanded; added `EXCLUDE` regex to drop `__screenshots__/`, `fixtures/`, and `helpers/` siblings that live under `spec/` directories but aren't themselves specs (the first expanded run tried to import Playwright screenshot PNGs as TypeScript). - `builds/knockout/helpers/browser-setup.js` - Imports `./mocha-test-helpers.js` so specs get the globals that module exposes (`prepareTestNode`, `restoreAfter`, the `globalThis.after` cleanup-stack override, `expectContainHtml`, etc.). Placed after `mocha.setup('bdd')` per its own `beforeEach(...)` at module-top — that call needs Mocha's BDD UI already active. - Added a Vitest-context shim for `it`. Several specs use `function (ctx: any) { if (isHappyDom()) return ctx.skip(...) }` which works under Vitest because `ctx` is the test context. Mocha sees arity 1 and waits for `done()`; the shim wraps `it` and `it.only` so 1-arg specs whose source uses `.skip(` and never `done(` get invoked with a stub ctx `{ skip: reason => this.skip(reason) }` and their arity is hidden from Mocha. Genuine Mocha done-callback specs unchanged. - `tko.io/src/pages/tests.astro` - `mocha.setup({ timeout: 10000 })` to match `testTimeout: 10000` in `vitest.config.ts`. Default 2000ms was too tight for async binding/component specs. - Pre-mocha error collector (`window.__tkoErrors`) so the next bundle-load crash (if any) is visible to a developer opening devtools instead of silently truncating the suite. Follow-ups ---------- - Classify the 178 remaining failures. Expect roughly three buckets: (a) test-context semantic gaps between Mocha and Vitest we haven't shimmed; (b) sensitivity to JSX/DOM timing that Vitest's browser mode smooths; (c) real behavior drift worth surfacing on the page for agents anyway. - Bundle cache-busting (3.1 MB at a stable URL is aggressively cached; stale bundle after a code change would show wrong pass/fail to a visitor). - Document `/tests` in `tko.io/public/agents/testing.md` once the failure list is under control. Adversarial pass: verified against the live running page — tree walk shows 324 suites / 2669 tests, the stats header matches (`passes: 2327, failures: 178, duration: 27.3s`), and the three remaining `__tkoErrors` entries are all async-rejection leakage from intentionally-failing component specs (`ERRORS_ON_PURPOSE2`, `Some error`, `bottomItem not found`) — not bundle-load crashes. Co-Authored-By: Claude Opus 4.7 (1M context) --- builds/knockout/helpers/browser-setup.js | 47 ++++++++++++++++++++++++ tko.io/scripts/bundle-tests.mjs | 29 ++++++++++++++- tko.io/src/pages/tests.astro | 13 ++++++- 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/builds/knockout/helpers/browser-setup.js b/builds/knockout/helpers/browser-setup.js index e04e19c3..9ba5c41e 100644 --- a/builds/knockout/helpers/browser-setup.js +++ b/builds/knockout/helpers/browser-setup.js @@ -30,8 +30,55 @@ globalThis.sinon = sinon // happy-dom run here. globalThis.isHappyDom = () => false +// Specs depend on helpers registered as globals by +// mocha-test-helpers.js — `prepareTestNode`, `restoreAfter`, +// `expectContainHtml`, etc. That module also overrides +// `globalThis.after` to a cleanup-stack pusher (not a Mocha +// suite hook) and wires root `beforeEach` / `afterEach` hooks +// that flush the cleanup stack. Must be imported after Mocha's +// `bdd` UI has been set up (the HTML page does +// `mocha.setup('bdd')` before loading the bundle), otherwise +// its `beforeEach(function () { … })` at module top throws. +import './mocha-test-helpers.js' + before(() => { if (globalThis.ko?.options) { globalThis.ko.options.jsxCleanBatchSize = 0 } }) + +// Vitest-style context-arg shim. +// +// Some specs are written for Vitest and take the test context as a +// single arg, e.g. `function (ctx: any) { if (isHappyDom()) return +// ctx.skip('...') }`. Mocha inspects `fn.length` to decide whether +// the test expects a `done` callback; a 1-arg function is treated as +// async-with-done, and since these specs never call done(), Mocha +// times them out (~10s each). +// +// Fix: wrap `it` so that 1-arg specs that look like ctx-style (use +// `.skip(...)` and never call `done(...)`) are invoked with a fake +// ctx `{ skip }` and the wrapper's arity is hidden from Mocha. +// Genuine Mocha done-callback specs (identified by a `done(` call +// in the source) pass through unchanged. +{ + 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 +} diff --git a/tko.io/scripts/bundle-tests.mjs b/tko.io/scripts/bundle-tests.mjs index 81ea291d..26ce21eb 100644 --- a/tko.io/scripts/bundle-tests.mjs +++ b/tko.io/scripts/bundle-tests.mjs @@ -30,13 +30,24 @@ const outputFile = path.join(outputDir, 'bundle.js') // import resolution to a directory that actually exists. const resolveDir = outputDir -// POC scope — expand as each package's specs are verified. -const SCOPE = ['packages/observable/spec/**/*.ts'] +// Full scope — every package's specs plus the two bundled builds. +// Matches the `ALL_SPECS` array in `vitest.config.ts` so the +// in-browser suite and the Vitest browser matrix stay aligned. +const SCOPE = [ + 'packages/*/spec/**/*.ts', + 'builds/reference/spec/**/*.js', + 'builds/knockout/spec/**/*.js' +] + +// Exclude non-spec siblings that end up under `spec/` directories +// — Playwright screenshots, helper modules, fixture data. +const EXCLUDE = /[\\/](__screenshots__|fixtures|helpers)[\\/]/ async function collectSpecs() { const specs = [] for (const pattern of SCOPE) { for await (const match of glob(pattern, { cwd: repoRoot })) { + if (EXCLUDE.test(match)) continue specs.push(path.join(repoRoot, match)) } } @@ -71,6 +82,17 @@ async function main() { const entryContents = renderEntry(specs) const tsconfig = path.join(repoRoot, 'tsconfig.json') + // Mirror the compile-time `--define:BUILD_VERSION='"..."'` that + // `tools/build.ts` sets for the knockout + reference builds. A + // handful of `builds/{knockout,reference}/spec/*.js` import from + // `..` (the build's own `index.ts`), which references + // `BUILD_VERSION` at module-top; without the define, evaluating + // that module throws `BUILD_VERSION is not defined` and the + // surrounding IIFE aborts — dropping every subsequently-imported + // spec from the suite. + const rootPkg = JSON.parse(await fs.readFile(path.join(repoRoot, 'package.json'), 'utf8')) + const buildVersion = rootPkg.version ?? '0.0.0-test' + await esbuild.build({ stdin: { contents: entryContents, @@ -84,6 +106,9 @@ async function main() { target: 'es2022', outfile: outputFile, tsconfig, + define: { + BUILD_VERSION: JSON.stringify(buildVersion) + }, // chai/sinon/mocha/@tko/* are all bundled so the page only needs // to script-tag the knockout global (/lib/ko.js) + the bundle. // mocha is an exception: mocha's HTML reporter must run as a diff --git a/tko.io/src/pages/tests.astro b/tko.io/src/pages/tests.astro index 12c0f4ff..832dd9e3 100644 --- a/tko.io/src/pages/tests.astro +++ b/tko.io/src/pages/tests.astro @@ -41,12 +41,23 @@
+ + +

TKO — Live Browser Tests

-

Real browser, real DOM, real TKO. Results update as specs finish.

+

Real browser, real DOM, real TKO. Every spec in the repo, runnable from a URL.

+
+ +
+ + + +
+ + + +
+ +
+ + + +
+ + +
+ +
+ +
Waiting for config…
+
- +
+ config pending +
+ + - - - + + const config = { mode, pkg, ver, suites, grep } + window.__tkoRunnerConfig = config - From 407d59fadf37be076abd9ea835e610d8c0804765 Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Tue, 21 Apr 2026 12:48:58 -0400 Subject: [PATCH 04/22] =?UTF-8?q?tests.astro:=20swap=20unpkg=20=E2=86=92?= =?UTF-8?q?=20jsdelivr=20for=20CDN-loaded=20scripts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unpkg is single-region (NY) with known caching + cold-start latency; jsdelivr is multi-CDN (Cloudflare + Fastly) with global POPs and better uptime. Same URL shape, pinned-version + `latest` semantics identical, zero API change. Also considered esm.run — pointless for this use case. TKO ships a pre-bundled IIFE `browser.min.js` at `dist/browser.min.js`; the on-the-fly ESM transform that esm.run provides would just parse + re-emit that IIFE. Swapped URLs: - Mocha browser build + CSS: `https://unpkg.com/mocha@10/...` → `https://cdn.jsdelivr.net/npm/mocha@10/...` - Versioned TKO builds (`build.knockout`, `build.reference`): `https://unpkg.com/@tko/build.${pkg}@${ver}/dist/browser.min.js` → `https://cdn.jsdelivr.net/npm/@tko/build.${pkg}@${ver}/dist/browser.min.js` Left alone: - `https://registry.npmjs.org/@tko/build.*` — authoritative registry metadata, jsdelivr doesn't proxy this endpoint. Verified locally: `/tests?mode=build&pkg=knockout` loads the jsdelivr-served bundle; `window.ko` wires up; stats `✓ 977 · ✖ 4 · ○ 21` match the prior unpkg run, confirming the swap is behaviorally a no-op. Co-Authored-By: Claude Opus 4.7 (1M context) --- tko.io/src/pages/tests.astro | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tko.io/src/pages/tests.astro b/tko.io/src/pages/tests.astro index ef2cbaf6..ef238ef6 100644 --- a/tko.io/src/pages/tests.astro +++ b/tko.io/src/pages/tests.astro @@ -13,7 +13,7 @@ // // URL state (all optional, all round-trip via the Run button): // ?mode=dev (default) in-tree build + both bundles -// ?mode=build load a @tko/build.* from unpkg + build bundle +// ?mode=build load a @tko/build.* from jsdelivr + build bundle // only; source bundle incompatible with a // non-dev ko (would re-bundle its own source // and collide on class identity) @@ -40,7 +40,7 @@ TKO · Browser Tests - + + + + + +
+
+ + + + + diff --git a/tko.io/scripts/bundle-tests.mjs b/tko.io/scripts/bundle-tests.mjs index 9cd14ae5..b028006e 100644 --- a/tko.io/scripts/bundle-tests.mjs +++ b/tko.io/scripts/bundle-tests.mjs @@ -1,23 +1,26 @@ -// Bundles TKO specs into two browser-loadable IIFEs so they can -// run under Mocha at /tests on tko.io. +// Bundles TKO specs for the in-browser `/tests` runner. // -// public/tests/build-bundle.js Specs from `builds/*/spec/` that -// reference `ko.*` globals only. -// Version-portable — runs against -// any `@tko/build.knockout` or -// `@tko/build.reference` loaded -// via - - + + From 54fdc4ad540a3602b2b71430cc305771e200dc6b Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Tue, 21 Apr 2026 15:30:05 -0400 Subject: [PATCH 10/22] tests: drop remaining iframe-runner failures from 48 toward ~1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four of the five buckets identified by the parallel subagent triage land here. Each root cause is genuinely distinct, so each fix is scoped to exactly its cohort. Bucket A (6 fails · "ko is not defined" at iframe import time) --------------------------------------------------------------- Six `builds/knockout/spec/*.js` files reference `ko.*` at module-top (template-engine registrations, option captures, jQuery-related setup). The auto-generated wrapper assigned `globalThis.ko = ko` as a top-level statement AFTER the spec imports, but ESM hoists imports over top-level code — so the spec module evaluated with `ko` still undefined and threw `ReferenceError` before any `describe` registered. Fix: pre-module ` + + + +