Skip to content

emergent_testing: one normalized result for both runners - #1741

Merged
sergey-shandar merged 4 commits into
mainfrom
claude/todo-unification-runners-7wvd1q
Aug 27, 2026
Merged

emergent_testing: one normalized result for both runners#1741
sergey-shandar merged 4 commits into
mainfrom
claude/todo-unification-runners-7wvd1q

Conversation

@sergey-shandar

Copy link
Copy Markdown
Contributor

Step 2 of emergent_testing/todo/share-browser-console-runner.md, after
#1738.

Why this and not sandbox

The plan had "one sandbox" second. That ordering was wrong and the step list
is corrected here. Sharing sandbox means merging the browser's ~150 lines of
Symbol.species machinery (runPromise, subscribe, speciesFails) with
fjs t, which has no equivalent — so it answers the cross-realm promise
question that todo/imports-promises-realms.md marks as open investigation.
That is a decision to take deliberately, not a side effect of moving code; it is
how the reverted attempt lost a defence nobody chose to lose. It is now step 3
and marked blocked-on-a-decision.

This step needs no such decision.

The change

TestResult and testResult normalize one leaf's outcome: its identity,
whether it passed, and how long it took.

The browser derived all three inline at four sites. fjs t derived them again
on its way to a printed line (s === 'ok' ? …). The throw expectation is now
applied through the same invert in both, so "did this leaf pass" is decided
in one place instead of three.

export const testResult = (file, path, { result: [s], duration }) => ({
    module: file,
    path: fmtPath(path),
    name: fmtImport(file, path),
    status: s === 'ok' ? 'passed' : 'failed',
    duration,
})

The browser's result type is now TestResult & { message?, stack? }.

What is deliberately not in it

A thrown value. Describing one needs the value; a serializable report cannot
carry one; and the two hosts describe it differently for reasons their hosts
impose — fjs t prints it to a terminal, the browser reads message/stack
for a wire. So the description stays with each host. That is the shape of an
extension point rather than an omission, and the type says so where a reader
will look.

What this revealed, recorded rather than fixed

With the status shared, two description differences became visible. Both are in
the todo, neither is touched here:

  • fjs t keeps no stack for a failure; the browser keeps one.
  • For a throw proof that returns cleanly, fjs t reports the returned value
    as the error while the browser reports the fixed string Expected the proof to throw. They agree on the status — which is what this step shares — and differ
    in the message.

One more thing worth knowing: testResult now sits inside fjs t's own
reporting path, so a defect in it can mislabel the very failures it causes. The
pass/fail counts come from the walk's state rather than from the reporter, so
they stay honest and the summary still reports the failures — but the per-test
labels do not. Noted in the todo.

Verification

npx tsc clean, fjs t 3477/3477, npm run cov 100% lines, branches and
functions. fjs t's output is byte-identical.

Five new proofs: testResult.passes / .fails / .namesTheLeaf for the rule,
and normalizedResultMatchesTheSharedOne /
expectedThrowStatusMatchesTheSharedOne comparing real browser results against
the shared function rather than against literals. Mutation-checked:

mutation failures
status forced to 'passed' 14
name mangled 8

Changelog:

  • emergent_testing: TestResult and testResult normalize one leaf's
    outcome — its identity, whether it passed once the throw expectation has
    been applied, and how long it took — so fjs t and the browser runner decide
    those the same way. The browser's result type is now TestResult plus its own
    message/stack; fjs t's output is unchanged

🤖 Generated with Claude Code

https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg


Generated by Claude Code

claude added 2 commits August 27, 2026 16:22
Second step of `todo/share-browser-console-runner.md`, taken before the
`sandbox` step it was originally sequenced after. Sharing `sandbox` means
merging the browser's ~150 lines of `Symbol.species` machinery with
`fjs t`, which has no equivalent -- that answers the cross-realm question
`todo/imports-promises-realms.md` marks as open investigation, and a port
is the wrong place to answer it. This step needs no such decision.

`TestResult` and `testResult` normalize what a leaf is called, whether it
passed, and how long it took. The browser derived all three inline at
four sites; `fjs t` derived them again on its way to a printed line. The
throw expectation is now applied through the same `invert` in both, so
"did this leaf pass" is decided in one place rather than three.

A thrown *value* is deliberately not in the record. Describing one needs
the value, a serializable report cannot carry one, and the two hosts
describe it differently for good reasons -- `fjs t` prints it to a
terminal, the browser reads `message`/`stack` for a wire. So the
description stays with each host, which is the shape of an extension
point rather than an omission, and the type says so.

Two differences that shows up are recorded in the todo, not fixed here:
`fjs t` keeps no stack for a failure, and for a `throw` proof that
returns cleanly the two report different messages while agreeing on the
status.

`fjs t`'s output is byte-identical. Proofs: `testResult.*` for the rule
itself, and `normalizedResultMatchesTheSharedOne` /
`expectedThrowStatusMatchesTheSharedOne` comparing real browser results
against the shared function rather than against literals. Both mutants
are killed -- forcing every status to `passed` fails 14, mangling the
name fails 8.

Changelog:
- `emergent_testing`: `TestResult` and `testResult` normalize one leaf's
  outcome -- its identity, whether it passed once the `throw` expectation
  has been applied, and how long it took -- so `fjs t` and the browser
  runner decide those the same way. The browser's result type is now
  `TestResult` plus its own `message`/`stack`; `fjs t`'s output is
  unchanged
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for security reviews. Please try again later.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cb8913d331

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

* todo tracks rather than in this change.
*
* @typedef {{ readonly module: string, readonly path: string, readonly name: string, readonly status: string, readonly duration: number, readonly message?: string, readonly stack?: string }} _BrowserTestResult
* @typedef {TestResult & { readonly message?: string, readonly stack?: string }} _BrowserTestResult

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep module failures outside the leaf-result type

When a source fails to link or its proof export cannot be enumerated, moduleFailure still inserts an infrastructure/module-level record into BrowserTestReport.results, but this typedef now promises that every entry is a TestResult—documented as a leaf whose name comes from fmtImport and whose duration is that leaf's runtime. Those records instead use the raw source as name and, during loading failures, the whole loading duration, so consumers cannot safely rely on the new public invariants. Model module failures as a separate union variant rather than intersecting every browser result with TestResult.

AGENTS.md reference: AGENTS.md:L23-L29

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right about the defect, and it is one this branch introduced — but I have fixed the documentation rather than the shape, because the shape is a decision that is not mine to take here.

The over-promise is real and worse than one field. I wrote that name is "the identity fmtImport gives the leaf" and path is "the key chain within that module's proof export", while moduleFailure produces a TestResult whose name is the raw source, whose path is empty, and whose duration is whatever was being measured when the failure happened. Three fields meant less than the comment claimed, and the claim is new in this branch. TestResult now says what is actually true: name is what ran, not which leaf ran; path is empty when the outcome is not a leaf's; duration is the leaf's execution or whatever the runner was measuring.

On the union itself: a module failure is in results deliberately, and the existing comment above the rejection path says why — a report whose totals disagreed with its results would tell an automated consumer the suite was empty rather than broken. Splitting it into a variant changes the public report and that totals story, so it is a report-shape change, not a typing cleanup. This PR shares one decision (what a leaf's status and identity are) and deliberately touches nothing else; the last attempt at this issue was reverted for bundling exactly this kind of adjacent improvement into a port.

So it is recorded instead. The report shape now has three questions open at once — whether path survives now that name exists, whether a report declares the root its module keys are relative to, and whether a module-level failure belongs in a variant of its own — and todo/share-browser-console-runner.md now says they should be settled together. Each is small alone; answering one without the others is how a report shape ends up carrying three half-decisions.


Generated by Claude Code

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
functionalscript 94a1f00 Commit Preview URL

Branch Preview URL
Aug 27 2026, 05:41 PM

The new type's documentation over-promised. It described `name` as the
identity `fmtImport` gives a leaf and `path` as the key chain within a
module's `proof` export, while `moduleFailure` -- a source that will not
link, or a `proof` export that cannot be enumerated -- produces a
`TestResult` whose name is the raw source, whose path is empty and whose
duration is whatever was being measured when it failed. Three fields
meant less than the comment claimed, and the claim was written in this
branch.

Fixes the documentation rather than the shape. A module-level failure is
counted deliberately: a report whose totals disagreed with its `results`
would tell an automated consumer the suite was empty rather than broken.
Turning it into a separate variant changes the public report and the
totals story, which is a report-shape decision -- and the report shape now
has three of those open at once (`path`'s redundancy, which root a report
declares, and this), which the todo now says should be settled together.

No behaviour change; the suite and `fjs t`'s output are unchanged.

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. Verified at cb8913d33: npm test 3477/3477 exit 0, tsc --noEmit exit 0 (main 8609666ea: 3472/3472). Despite -49 lines, the leaf diff is +5 and 0 removed — nothing lost.

Both runners really do produce the shared shape rather than converting at a boundary: defaultReporter.result calls testResult(file, path, r) in module.f.mjs, and browser.mjs's runOneleaf() calls the same imported testResult. Mutation-proven both ways — having the browser drift (name: 'WRONG-NAME-' + t.module) is KILLED by normalizedResultMatchesTheSharedOne and nameMatchesTheConsoleRunner, and forcing testResult status to 'passed' is KILLED with 14 failures, reproducing the table in the PR body independently.

Given the #1737 revert I checked this is not the same machinery returning: no effects/common, no second interpreter, no shared skeleton — runModuleMap predates the revert. This is a narrower mechanism, one shared normalization function.

types.ts is purely additive (TestStatus, TestResult), so not marking it breaking is right; Changelog: is literal and byte-identical to the entry file; and share-browser-console-runner.md frames the reverted attempt as history rather than live code, with only step 2 checked.

One caveat worth recording, and it is the PR's own documented one rather than a defect: under the status-forcing mutant the per-line terminal output still prints ok, because the label derives from testResult.status while the aggregate count and exit code come from the raw sandbox tuple in runModule's one(). The gate is sound; only the per-line label would mislead.

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval still stands. Verified at 39c9c9d6b: npm test 3477/3477 exit 0, tsc --noEmit exit 0 (main 8609666ea: 3472/3472). browser.mjs +8 is comment-only, and types.ts +25/-3 changes no field and narrows no union — doc rewording plus a new block on duration, so still not breaking, and the changelog still matches the body verbatim.

I tested the new promise rather than reading it: driving runBrowserProofs with a proof export whose getter throws on enumeration, using a source with separators and spaces (a/nested::name with spaces.mjs), gives exactly {module: <source>, path: '', name: <source>, status: 'failed'} — the documented "named by whatever it does know, which for a module is its source" and "path empty when the outcome is not a leaf's" both hold.

One note for a future reader, pre-existing and not caused by this diff: the two runners are not symmetric for non-leaf faults. The same unenumerable proof export that the browser reports as a TestResult makes fjs t throw synchronously, and a genuinely unimportable module aborts the whole console run (exit 1, no TestResults emitted at all, including for modules that would have passed). The new doc is correctly hedged — "a runner may also report…" — but a consumer reading only the TestResult doc could reasonably infer a symmetry that does not exist. Worth a sentence naming the console runner's behaviour, whenever that area is next touched.

`TestResult.name` documents that a runner may report an outcome with no
leaf behind it, which is true of the browser and reads as though it were
true of both. It is not. Measured with two modules in the tree and only
the first hostile: the browser records one failed result and runs the
rest, while `fjs t` exits on an uncaught throw and the second module's
passing proofs are never reported at all.

A consumer reading only the type could reasonably infer a symmetry that
does not exist, so the type now says which runner tolerates this and
points at `todo/hostile-proof-values.md`, where closing the gap needs an
operation the shared traversal does not have. That todo gains the
measurement.

Comment-only; no field changes, no behaviour change.

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval still stands, and the asymmetry is now stated accurately — I re-measured rather than reading it. With two synthetic modules, a hostile-getter root plus a passing sibling: fjs t throws uncaught in collectTestsObject.entries, prints a raw stack, emits zero TestResults and no summary, and the sibling's passing proofs are never touched; runBrowserProofs on the same tree gives {tests: 2, passed: 1, failed: 1}. That matches the new text in both places, including the part that is easy to leave out — that the console run takes down modules that would have passed.

Placement is right too: the types.ts sentence stands on its own for a consumer importing by path (it names the mechanism, not just the fact), and hostile-proof-values.md keeps Status: open and frames the paragraph as measured evidence for open work rather than an accepted end state.

Gates at 94a1f0003: npm test 3477/3477 exit 0, tsc --noEmit exit 0 (main 8609666ea: 3472/3472). The types.ts +9 is entirely inside the existing JSDoc block — no signature or type changed — so the changelog is unaffected and still matches the body item-for-item.

@sergey-shandar
sergey-shandar added this pull request to the merge queue Aug 27, 2026
Merged via the queue into main with commit 438dd85 Aug 27, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants