⚠️ CORRECTED before any work started — my original prescription was impossible, and I found it while implementing it.
The first version of this ticket proposed a gate in the neuralLink fixture teardown and claimed it "would have turned #18546 red in CI exactly as configured today."That is false.fixtures.mjs:48 hard-requires NEO_AGENTOS_RUNTIME_ROOT for the neuralLink fixture, and the four CI-selected Workstation specs contain zeroneuralLink references — they take {page} or {neo, page}. A gate living in that fixture can never run in CI.
I had measured that those four specs are selected and that they boot the Workstation. I never checked whether they could use the fixture I was putting the gate in. The measurements below all hold; the prescription built on them did not.
The problem statement survives and is in fact worse than originally stated — see The real constraint. The fix below replaces the original, and the original ACs are struck.
Context
#18551 broke every Workstation viewport vdom sync on dev and shipped with 24/24 CI checks green. @tobiu found it by opening the app; he then asked whether we need more unit or e2e tests.
We do not, at least not first. #18546 had a dedicated arm on each half of the change, and the Workstation has 34 e2e specs. The regression was invisible for reasons no additional spec of the same kind would fix.
The Problem
(1) The failure is caught, so nothing can fail on it.src/mixin/VdomLifecycle.mjs:378 swallows a failed update into a log:
That is deliberate and the comment explains why. The consequence is that no pageerror fires and no promise rejects unhandled.
(2) The log lands where no CI-runnable test can read it.src/worker/App.mjs:397 wraps console[log|warn|error|info] and :445 hooks globalThis.onerror. Both route exclusively to Neo.ai?.Client — the Neural Link bridge. original.apply(console, args) still fires, but that writes to the SharedWorker's own console context, which the page never sees.
(3) Most of the tier does not run in CI.NEO_AGENTOS_RUNTIME_ROOT is set in no workflow, and playwright.config.e2e.mjs:80 applies testIgnore: externalBrainTestIgnore. Measured with --list under a CI-shaped environment:
count
e2e spec files in the repo
106
selected
20 (62 tests)
Workstation specs in the repo
34
Workstation selected
4
Known and documented — .github/workflows/test-e2e.yml:281 states "Specs removed by testIgnore are dropped without warning."
(4) Nothing asserts the worker console is clean. Across 106 e2e spec files, exactly one asserts the blanket form: e2e/rendering/ContextMenuNL.spec.mjs:204, expect(await app.getConsoleLogs('error')).toEqual([]). Separately, of the 47page.on('pageerror', …) handler sites, 23 are pure prints that write to stdout and fail nothing.
The real constraint
The four checks that decide the shape:
app.getConsoleLogs() lives on
the neuralLink fixture (fixtures.mjs:660)
neuralLink requires
NEO_AGENTOS_RUNTIME_ROOT, absolute, or it throws (fixtures.mjs:48)
So the ability to observe App Worker errors is coupled to the private Brain checkout, and everything that runs in CI is on the other side of that boundary. This is not a missing assertion; it is a missing channel. Adding assertions anywhere cannot fix it.
The Fix
Give App Worker errors a main-thread path, so observing them needs no Brain.
The worker already has a message channel to Main. In development only, and for error level only, forward the intercepted console output and the onerror payload to the main thread, which re-emits it there. page.on('console') and page.on('pageerror') then see it — both available to every spec, with no fixture dependency and no Brain checkout.
This is worth doing on its own merits, independent of testing: a Neo app's errors currently vanish into a SharedWorker console that a developer has to know to go looking for. Making them surface in the page console is what every non-worker framework already gives you.
The gate then becomes a per-test check in the shared fixture on page, which all 20 CI-selected specs already take.
Deliberately not chosen: attaching a CDP session to the shared_worker target from the harness. It needs no engine change, but it is Chromium-only harness plumbing that helps no human developer, and it leaves the underlying invisibility in place.
Acceptance Criteria
AC-1 — App Worker error output reaches the main thread in development. Forwarded from both interception points (worker/App.mjs:397 console wrap and :445onerror), carrying message and stack. Positive control: an error raised in the worker appears in the page console; a log-level call does not.
AC-2 — no behaviour change outside development. Positive control: with the environment set to production, the forward does not run and no channel traffic is produced.
AC-3 — the gate lives in the shared fixture on page, so it applies to every spec including the 20 CI-selected ones, with no per-spec assertion and no neuralLink dependency.
AC-4 — red-first against the real regression, under today's CI selection. On a base carrying #18551's defect, at least one currently-CI-selected Workstation spec fails through the gate. dev carries that defect until #18552 merges, so this control is a live regression rather than a synthetic one — capture it before that lands.
AC-5 — the noise floor is measured and published before the default is chosen. Per-spec error inventory across the CI-selected set, in the PR body. Zero floor ⇒ gate defaults on. Otherwise it lands with a named allowlist, each entry citing spec and error, and the allowlist is this ticket's residual.
AC-6 — opt-out is per-test and reasoned. A spec waives the gate only by naming the expected error. Positive control: a test that deliberately triggers a worker error passes with the waiver and fails without it.
AC-7 — no print-only error handler survives in the CI-selected set. The 23 console.error('BROWSER JS ERROR:', …) handlers become assertions or are deleted, scoped to the 20 selected files. A handler that logs and cannot fail is a false guard — the same defect class that let #18551 through.
Struck: the original ACs, which assumed a fixture the CI-selected specs cannot use
AC-1 — the noise floor is measured and published before the default is chosen. (survives as AC-5)
AC-2 — the gate lives in the neuralLink fixture teardown.Impossible: that fixture throws without NEO_AGENTOS_RUNTIME_ROOT, which CI never sets.
AC-3 — red-first through the neuralLink gate. (survives as AC-4, through the page channel instead)
AC-4 — opt-out is per-test and reasoned. (survives as AC-6)
AC-5 — no print-only error handler survives. (survives as AC-7)
Out of Scope
Making the Brain-gated 86 run in CI. Needs the private neo-agent-brain checkout on the runner — infra and credentials, a different owner. Recorded as the reason the fix must not depend on neuralLink, and deliberately not bundled. Note this ticket now reduces the pressure on it: the worker console stops being Brain-only.
Changing executeVdomUpdate's catch. Logging rather than throwing is the documented intent. The gap is that nothing can read the log.
Forwarding log/warn/info levels. Error only; the rest is volume with no gate attached.
VDom.syncVdomState:451 mapping before filtering removeDom. Recorded on #18551; unchanged.
The unit tier's fixture-vs-runtime problem.#18551's post-mortem found two false guards built on config objects rather than materialised components (@neo-opus-grace: "calls production code ≠ exercises the runtime object"). Wants a skill, not a harness change.
Avoided Traps
"Add more Workstation e2e specs." There are 34; 30 do not run in CI, and the 4 that do were green through the whole defect.
Asserting on a specific error string. What the Workstation specs already do ('Dock projection failed'), and why an unrelated error passed. A gate must be blanket or it only catches errors someone already predicted.
Putting the gate where the capability already exists. The original error in this ticket. getConsoleLogs was the obvious home precisely because it already worked — in the half of the tier that never runs.
Decision Record impact
none. Adds a development-only diagnostic channel and a test-harness gate; changes no runtime contract.
Related
#18551 / #18552 — the regression that motivated this, and the fix
#18545 / #18546 — where both false guards were introduced
@neo-opus-vega's defect-note on the CI-shaped tier reporting window chrome as 0x0 — likely the same configuration surfacing differently
Reproduce the selection: npx playwright test -c test/playwright/playwright.config.e2e.mjs --list with NEO_AGENTOS_RUNTIME_ROOT unset → 62 tests in 20 files
Anchors: src/worker/App.mjs:397 + :445 (both Brain-only sinks), test/playwright/fixtures.mjs:48 (the hard requirement), :660 (the Brain-gated capability), src/mixin/VdomLifecycle.mjs:378 (why nothing throws)
tobiu referenced in commit d080a73 - "App Worker errors reach the main thread (#18556) (#18557) on Sep 9, 2026, 10:20 PM
tobiu referenced in commit baed5db - "test(e2e): a worker or page error nobody named fails its test, and the four it caught in tearOutMatrix are fixed (#18554) (#18716) on Sep 15, 2026, 10:11 AM
Context
#18551 broke every Workstation viewport vdom sync on
devand shipped with 24/24 CI checks green. @tobiu found it by opening the app; he then asked whether we need more unit or e2e tests.We do not, at least not first. #18546 had a dedicated arm on each half of the change, and the Workstation has 34 e2e specs. The regression was invisible for reasons no additional spec of the same kind would fix.
The Problem
(1) The failure is caught, so nothing can fail on it.
src/mixin/VdomLifecycle.mjs:378swallows a failed update into a log:VDomUpdate.hasPromiseCallbacks(me.id) || console.error('vdom update failed', me.id, err);That is deliberate and the comment explains why. The consequence is that no
pageerrorfires and no promise rejects unhandled.(2) The log lands where no CI-runnable test can read it.
src/worker/App.mjs:397wrapsconsole[log|warn|error|info]and:445hooksglobalThis.onerror. Both route exclusively toNeo.ai?.Client— the Neural Link bridge.original.apply(console, args)still fires, but that writes to the SharedWorker's own console context, which the page never sees.(3) Most of the tier does not run in CI.
NEO_AGENTOS_RUNTIME_ROOTis set in no workflow, andplaywright.config.e2e.mjs:80appliestestIgnore: externalBrainTestIgnore. Measured with--listunder a CI-shaped environment:Known and documented —
.github/workflows/test-e2e.yml:281states "Specs removed by testIgnore are dropped without warning."(4) Nothing asserts the worker console is clean. Across 106 e2e spec files, exactly one asserts the blanket form:
e2e/rendering/ContextMenuNL.spec.mjs:204,expect(await app.getConsoleLogs('error')).toEqual([]). Separately, of the 47page.on('pageerror', …)handler sites, 23 are pure prints that write to stdout and fail nothing.The real constraint
The four checks that decide the shape:
app.getConsoleLogs()lives onneuralLinkfixture (fixtures.mjs:660)neuralLinkrequiresNEO_AGENTOS_RUNTIME_ROOT, absolute, or it throws (fixtures.mjs:48)ContextMenuNL— the one blanket assertion — takes{page, neuralLink}→ not selected in CI{page}/{neo, page}→ cannot reachgetConsoleLogsSo the ability to observe App Worker errors is coupled to the private Brain checkout, and everything that runs in CI is on the other side of that boundary. This is not a missing assertion; it is a missing channel. Adding assertions anywhere cannot fix it.
The Fix
Give App Worker errors a main-thread path, so observing them needs no Brain.
The worker already has a message channel to Main. In
developmentonly, and forerrorlevel only, forward the intercepted console output and theonerrorpayload to the main thread, which re-emits it there.page.on('console')andpage.on('pageerror')then see it — both available to every spec, with no fixture dependency and no Brain checkout.This is worth doing on its own merits, independent of testing: a Neo app's errors currently vanish into a SharedWorker console that a developer has to know to go looking for. Making them surface in the page console is what every non-worker framework already gives you.
The gate then becomes a per-test check in the shared fixture on
page, which all 20 CI-selected specs already take.Deliberately not chosen: attaching a CDP session to the
shared_workertarget from the harness. It needs no engine change, but it is Chromium-only harness plumbing that helps no human developer, and it leaves the underlying invisibility in place.Acceptance Criteria
erroroutput reaches the main thread indevelopment. Forwarded from both interception points (worker/App.mjs:397console wrap and:445onerror), carrying message and stack. Positive control: an error raised in the worker appears in the page console; alog-level call does not.development. Positive control: with the environment set toproduction, the forward does not run and no channel traffic is produced.page, so it applies to every spec including the 20 CI-selected ones, with no per-spec assertion and noneuralLinkdependency.devcarries that defect until #18552 merges, so this control is a live regression rather than a synthetic one — capture it before that lands.errorinventory across the CI-selected set, in the PR body. Zero floor ⇒ gate defaults on. Otherwise it lands with a named allowlist, each entry citing spec and error, and the allowlist is this ticket's residual.console.error('BROWSER JS ERROR:', …)handlers become assertions or are deleted, scoped to the 20 selected files. A handler that logs and cannot fail is a false guard — the same defect class that let #18551 through.Struck: the original ACs, which assumed a fixture the CI-selected specs cannot use
AC-1 — the noise floor is measured and published before the default is chosen.(survives as AC-5)AC-2 — the gate lives in theImpossible: that fixture throws withoutneuralLinkfixture teardown.NEO_AGENTOS_RUNTIME_ROOT, which CI never sets.AC-3 — red-first through the(survives as AC-4, through the page channel instead)neuralLinkgate.AC-4 — opt-out is per-test and reasoned.(survives as AC-6)AC-5 — no print-only error handler survives.(survives as AC-7)Out of Scope
neo-agent-braincheckout on the runner — infra and credentials, a different owner. Recorded as the reason the fix must not depend onneuralLink, and deliberately not bundled. Note this ticket now reduces the pressure on it: the worker console stops being Brain-only.executeVdomUpdate's catch. Logging rather than throwing is the documented intent. The gap is that nothing can read the log.log/warn/infolevels. Error only; the rest is volume with no gate attached.VDom.syncVdomState:451mapping before filteringremoveDom. Recorded on #18551; unchanged.Avoided Traps
'Dock projection failed'), and why an unrelated error passed. A gate must be blanket or it only catches errors someone already predicted.getConsoleLogswas the obvious home precisely because it already worked — in the half of the tier that never runs.Decision Record impact
none. Adds a development-only diagnostic channel and a test-harness gate; changes no runtime contract.Related
defect-noteon the CI-shaped tier reporting window chrome as 0x0 — likely the same configuration surfacing differentlyHandoff Retrieval Hints
query_raw_memories: "app worker console error main thread forward playwright gate brain-gated fixture"npx playwright test -c test/playwright/playwright.config.e2e.mjs --listwithNEO_AGENTOS_RUNTIME_ROOTunset → 62 tests in 20 filessrc/worker/App.mjs:397+:445(both Brain-only sinks),test/playwright/fixtures.mjs:48(the hard requirement),:660(the Brain-gated capability),src/mixin/VdomLifecycle.mjs:378(why nothing throws)Origin Session ID: 0dd7d644-f279-4b53-a515-522346f5f28f