-
Notifications
You must be signed in to change notification settings - Fork 35
Live in-browser test runner at /tests #353
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 20 commits
11961e5
46c3cf4
f0f0a0c
407d59f
587833d
a81a7f7
886457e
98ca441
09806fa
54fdc4a
4fd954d
f2a8f54
585c328
0b5648d
e7184b6
0771ac8
aa98b20
92c3fe8
a66121f
c5bd8d7
f68cc67
a7b0a5f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,3 +4,4 @@ _site/ | |
| dist/ | ||
| package-lock.json | ||
| public/lib/ | ||
| public/tests/ | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,89 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <!doctype html> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <html lang="en"> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <head> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <meta charset="utf-8" /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <title>TKO Spec Frame</title> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <style> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| html, body { margin: 0; padding: 0; background: #0f1115; color: #e8e8e8; font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #mocha { padding: 0.5rem 0.75rem; } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #err { padding: 0.5rem 0.75rem; color: #ff8080; white-space: pre-wrap; } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </style> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/mocha@10/mocha.css" /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </head> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <body> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <!-- | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Per-spec iframe harness. The parent /tests page spawns one of | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| these per spec file in source/manifest.json and reads results | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| via postMessage. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Each iframe has its own `window`, so: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - Module graph is fresh (no class-identity collision across | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| specs). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - `globalThis.ko`, `options.filters`, DOM, timers, sinon | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| stubs, testNode — all isolated per spec. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - No spec can leak state into another. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| URL contract: `tests-frame.html?spec=<slug>` where <slug> is | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| the key of a spec module under /tests/source/. This iframe | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| first loads `/tests/source/setup.js` via a classic <script> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tag (inlined ko build + browser-setup globals); the inline | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| module script then dynamic-imports `/tests/source/<slug>.js`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| which contains only the spec under test. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| File lives at `public/tests-frame.html` (not | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| `public/tests/frame.html`) to avoid colliding with Astro's | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| `/tests/` page route — a nested public/tests/*.html 404s in | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| dev because the page route claims the whole prefix. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| --> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div id="mocha"></div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div id="err"></div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <!-- Pre-module bootstrap: `/tests/source/setup.js` is a | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| bundled IIFE that loads the knockout build from source | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| AND runs `browser-setup.js` (chai / sinon / filter | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| punctuation / mocha-test-helpers globals / jsxClean | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| hook). Must load BEFORE the spec module so | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| `globalThis.ko`, `prepareTestNode`, `restoreAfter`, etc. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| exist when the spec's top-level code evaluates. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Earlier iterations tried to embed setup as ESM imports | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| in the spec wrapper; esbuild's code-splitter hoisted | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| the spec's chunk above the ko/setup chunks and | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| `beforeEach(prepareTestNode)` at spec-top threw | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| `ReferenceError`. Moving setup into a classic <script> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tag sidesteps the issue: the IIFE runs synchronously | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| before the module loader even starts. --> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <!-- Load Mocha FIRST so `before` / `beforeEach` / `describe` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| globals exist when setup.js evaluates (browser-setup.js | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| calls `before(() => …)` at module-top). --> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <script src="https://cdn.jsdelivr.net/npm/mocha@10/mocha.js"></script> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <script> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| mocha.setup({ ui: 'bdd', reporter: 'html', timeout: 10000, cleanReferencesAfterRun: false }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </script> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <script src="/tests/source/setup.js"></script> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <script type="module"> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const qs = new URLSearchParams(location.search) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const slug = qs.get('spec') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!slug) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| document.getElementById('err').textContent = 'missing ?spec= query param' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await import(`/tests/source/${slug}.js`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const runner = mocha.run() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| runner.on('pass', t => parent.postMessage({ type: 'pass', slug, title: t.fullTitle(), duration: t.duration }, '*')) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| runner.on('fail', (t, err) => parent.postMessage({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type: 'fail', slug, title: t.fullTitle(), duration: t.duration, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| err: (err?.message || String(err)), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| stack: err?.stack | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, '*')) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| runner.on('pending', t => parent.postMessage({ type: 'pending', slug, title: t.fullTitle() }, '*')) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| runner.on('end', () => parent.postMessage({ type: 'end', slug, stats: runner.stats }, '*')) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| parent.postMessage({ type: 'start', slug, total: runner.total }, '*') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| document.getElementById('err').textContent = 'import failed: ' + (err?.message || err) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| parent.postMessage({ type: 'import-error', slug, err: err?.message || String(err) }, '*') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+73
to
+84
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Constrain The harness is intended to report only to the same-origin 🔒 Proposed tightening+ const post = message => parent.postMessage(message, location.origin)
const runner = mocha.run()
- runner.on('pass', t => parent.postMessage({ type: 'pass', slug, title: t.fullTitle(), duration: t.duration }, '*'))
- runner.on('fail', (t, err) => parent.postMessage({
+ runner.on('pass', t => post({ type: 'pass', slug, title: t.fullTitle(), duration: t.duration }))
+ runner.on('fail', (t, err) => post({
type: 'fail', slug, title: t.fullTitle(), duration: t.duration,
err: (err?.message || String(err)),
stack: err?.stack
- }, '*'))
- runner.on('pending', t => parent.postMessage({ type: 'pending', slug, title: t.fullTitle() }, '*'))
- runner.on('end', () => parent.postMessage({ type: 'end', slug, stats: runner.stats }, '*'))
- parent.postMessage({ type: 'start', slug, total: runner.total }, '*')
+ }))
+ runner.on('pending', t => post({ type: 'pending', slug, title: t.fullTitle() }))
+ runner.on('end', () => post({ type: 'end', slug, stats: runner.stats }))
+ post({ type: 'start', slug, total: runner.total })
} catch (err) {
document.getElementById('err').textContent = 'import failed: ' + (err?.message || err)
- parent.postMessage({ type: 'import-error', slug, err: err?.message || String(err) }, '*')
+ parent.postMessage({ type: 'import-error', slug, err: err?.message || String(err) }, location.origin)
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </script> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </body> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </html> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.