Skip to content

Commit b2a2d34

Browse files
Brian M HuntBrian M Hunt
authored andcommitted
fix(tests): stagger iframe worker launch + richer import error
Reported: on production tko.io under Safari, one spec occasionally fails with the opaque WebKit message `Importing a module script failed.` — most often a spec that imports a large chunk graph (e.g. `builds/reference/spec/ bindingGlobalsBehavior.js`, which imports the full reference build). Chromium handles the same graph fine. The failure mode is known across Astro, SvelteKit, Nuxt, Immich, and tracks against upstream WebKit bug 242740. Two changes: 1. **Stagger worker launch** in tests.astro. With pool=4 every worker used to spawn its first iframe simultaneously, hitting the same shared module-graph chunks concurrently — that's what triggers the WebKit race. A 120ms per-worker offset spaces the first-round fetches (360ms total head-start for pool=4) and removes the contention without materially slowing the run (still ~5.2s end-to-end; was ~5.4s). 2. **Richer error on failure** in tests-frame.html. Keep the original dynamic `import()` (no retry) but in the catch, re-fetch the spec URL with `cache: 'no-store'` and report HTTP status, content-length, and content-type alongside the original WebKit message in both the in-frame err div and the `import-error` postMessage. Gives something actionable when the failure does surface.
1 parent 4e20972 commit b2a2d34

4 files changed

Lines changed: 104 additions & 9 deletions

File tree

tko.io/package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
"postinstall": "patch-package",
88
"prebuild": "bun ./scripts/generate-verified-behaviors.mjs && mkdir -p public/lib && cd ../builds/knockout && bun run build && cp dist/browser.min.js ../../tko.io/public/lib/ko.js && cd ../reference && bun run build && cp dist/browser.min.js ../../tko.io/public/lib/tko.js && cd ../../tko.io && bun ./scripts/bundle-tests.mjs",
99
"predev": "bun run prebuild",
10-
"dev": "ASTRO_TELEMETRY_DISABLED=1 astro dev",
11-
"build": "ASTRO_TELEMETRY_DISABLED=1 astro build --force",
12-
"preview": "ASTRO_TELEMETRY_DISABLED=1 astro preview",
13-
"check": "ASTRO_TELEMETRY_DISABLED=1 astro check"
10+
"dev": "ASTRO_TELEMETRY_DISABLED=1 bun --bun astro dev",
11+
"build": "ASTRO_TELEMETRY_DISABLED=1 bun --bun astro build --force",
12+
"preview": "ASTRO_TELEMETRY_DISABLED=1 bun --bun astro preview",
13+
"check": "ASTRO_TELEMETRY_DISABLED=1 bun --bun astro check"
1414
},
1515
"keywords": ["tko", "knockout", "documentation"],
1616
"author": "",

tko.io/public/tests-frame.html

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,43 @@
6464
<script type="module">
6565
const qs = new URLSearchParams(location.search)
6666
const slug = qs.get('spec')
67+
const specUrl = `/tests/source/${slug}.js`
68+
69+
// On failure the opaque WebKit "Importing a module script
70+
// failed." message carries no URL or status. Refetch the
71+
// spec URL in the catch and surface HTTP status + size so
72+
// the parent can show something useful. The parent prefetches
73+
// every shared chunk into the browser HTTP cache before
74+
// spawning iframes to avoid the race that produces the opaque
75+
// error in the first place; see source-mode chunk prefetch in
76+
// tests.astro.
77+
//
78+
// Refs:
79+
// https://github.com/withastro/astro/issues/14775
80+
// https://github.com/sveltejs/kit/issues/5208
81+
// https://github.com/nuxt/nuxt/issues/33478
82+
// https://bugs.webkit.org/show_bug.cgi?id=242740
83+
async function diagnose(err) {
84+
try {
85+
const res = await fetch(specUrl, { cache: 'no-store' })
86+
const status = res.statusText ? `${res.status} ${res.statusText}` : `${res.status}`
87+
const size = res.headers.get('content-length') ?? '?'
88+
const type = res.headers.get('content-type') || 'unknown'
89+
// `res` only covers the top-level spec URL. If this HTTP
90+
// probe reports 2xx, the failure was most likely in a
91+
// transitive chunk of the module graph, not this file.
92+
const note = res.ok ? ' (top-level OK — failure likely in a transitive chunk)' : ''
93+
return `import failed at ${specUrl} [HTTP ${status}, ${size}B, ${type}]${note}: ${err?.message || err}`
94+
} catch (fetchErr) {
95+
return `import failed at ${specUrl} (fetch also failed: ${fetchErr?.message || fetchErr}): ${err?.message || err}`
96+
}
97+
}
98+
6799
if (!slug) {
68100
document.getElementById('err').textContent = 'missing ?spec= query param'
69101
} else {
70102
try {
71-
await import(`/tests/source/${slug}.js`)
103+
await import(specUrl)
72104
const runner = mocha.run()
73105
runner.on('pass', t => parent.postMessage({ type: 'pass', slug, title: t.fullTitle(), duration: t.duration }, '*'))
74106
runner.on('fail', (t, err) => parent.postMessage({
@@ -80,8 +112,9 @@
80112
runner.on('end', () => parent.postMessage({ type: 'end', slug, stats: runner.stats }, '*'))
81113
parent.postMessage({ type: 'start', slug, total: runner.total }, '*')
82114
} catch (err) {
83-
document.getElementById('err').textContent = 'import failed: ' + (err?.message || err)
84-
parent.postMessage({ type: 'import-error', slug, err: err?.message || String(err) }, '*')
115+
const detail = await diagnose(err)
116+
document.getElementById('err').textContent = detail
117+
parent.postMessage({ type: 'import-error', slug, err: detail }, '*')
85118
}
86119
}
87120
</script>

tko.io/scripts/bundle-tests.mjs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,9 +275,21 @@ async function buildSourceBundles({ buildVersion, tsconfig, alias }) {
275275
logLevel: 'warning'
276276
})
277277

278+
// Enumerate the shared chunk files esbuild emitted alongside
279+
// the per-spec bundles. The parent page prefetches these once
280+
// before spawning iframes so the browser HTTP cache is warm —
281+
// otherwise multiple iframes race to fetch the same chunks and
282+
// WebKit occasionally reports the opaque "Importing a module
283+
// script failed." error. See tko.io/src/pages/tests.astro.
284+
const chunks = []
285+
for await (const name of new Bun.Glob('chunk-*.js').scan({ cwd: sourceOutputDir })) {
286+
chunks.push(name)
287+
}
288+
chunks.sort()
289+
278290
await fs.writeFile(
279291
path.join(sourceOutputDir, 'manifest.json'),
280-
JSON.stringify({ specs: manifest }, null, 2),
292+
JSON.stringify({ specs: manifest, chunks }, null, 2),
281293
'utf8'
282294
)
283295

tko.io/src/pages/tests.astro

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,27 @@
11
---
2+
import { fileURLToPath } from 'node:url'
3+
4+
// Read the source-mode chunk list at SSR time so the HTML ships
5+
// with <link rel="modulepreload"> for every shared chunk already
6+
// in <head>. The browser starts warming the HTTP cache during
7+
// initial parse — well before any JS runs — which side-steps the
8+
// concurrent-fetch race that makes WebKit occasionally fail spec
9+
// iframes with the opaque "Importing a module script failed."
10+
// error. `prebuild` always runs bundle-tests.mjs before Astro
11+
// builds the site, so manifest.json is guaranteed to exist; the
12+
// catch is only for the very first `bun run dev` invocation.
13+
// Scripts (and Astro under `bun --bun`) run on Bun, so use
14+
// Bun.file rather than node:fs.
15+
let sourceChunks: string[] = []
16+
try {
17+
const manifestPath = fileURLToPath(new URL('../../public/tests/source/manifest.json', import.meta.url))
18+
const manifest = await Bun.file(manifestPath).json()
19+
sourceChunks = manifest.chunks ?? []
20+
} catch {
21+
// bundle hasn't run yet — runSourceMode's runtime fallback
22+
// still injects missing <link> tags client-side.
23+
}
24+
225
// TKO Browser Test Runner — written in TKO itself.
326
//
427
// Two modes:
@@ -32,6 +55,8 @@
3255
<title>TKO · Browser Tests</title>
3356
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Lobster&display=swap" />
3457
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/mocha@10/mocha.css" />
58+
{sourceChunks.map(name => <link rel="modulepreload" href={`/tests/source/${name}`} />)}
59+
<link rel="preload" as="script" href="/tests/source/setup.js" />
3560
<style>
3661
:root {
3762
--bg: #0b0d11;
@@ -520,6 +545,28 @@
520545
const res = await fetch('/tests/source/manifest.json')
521546
const manifest = await res.json()
522547
let specs = manifest.specs
548+
549+
// Chunks are normally preloaded via <link rel="modulepreload">
550+
// rendered in the page's <head> at SSR time (see frontmatter).
551+
// If the rendered page predates the current bundle (e.g.
552+
// Astro SSR'd before bundle-tests.mjs ran, or manifest has
553+
// new chunks), inject any missing <link>s now so the browser
554+
// warms the HTTP cache before iframes race on it.
555+
const head = document.head
556+
const existing = new Set(
557+
[...head.querySelectorAll('link[rel=modulepreload]')].map(l => l.getAttribute('href'))
558+
)
559+
const missing = (manifest.chunks || [])
560+
.map(name => `/tests/source/${name}`)
561+
.filter(href => !existing.has(href))
562+
await Promise.all(missing.map(href => new Promise(resolve => {
563+
const link = document.createElement('link')
564+
link.rel = 'modulepreload'
565+
link.href = href
566+
link.addEventListener('load', resolve, { once: true })
567+
link.addEventListener('error', resolve, { once: true })
568+
head.appendChild(link)
569+
})))
523570
const grep = page.grep().trim()
524571
if (grep) {
525572
// Invalid regex patterns (e.g. bare `(`) throw from the
@@ -632,7 +679,10 @@
632679
})
633680
}
634681

635-
// Phase 1: parallel hidden specs.
682+
// Phase 1: parallel hidden specs. The chunk prefetch
683+
// above warmed the HTTP cache, so iframes can safely race
684+
// on dynamic import() without tripping WebKit's
685+
// dependency-tree fetcher.
636686
async function hiddenWorker() {
637687
while (hiddenQueue.length) {
638688
const spec = hiddenQueue.shift()

0 commit comments

Comments
 (0)