emergent_testing: name a browser test the way fjs t does - #1738
Conversation
First step of `todo/share-browser-console-runner.md`, which is now
sequenced into seven of them, one per pull request. The reverted attempt
did the whole issue at once, and that is why an argument about scheduling
became an argument about the port.
The page named a test `${module} ${path}` -- `./a.f.mjs .x` -- where the
console runner prints `import("./a.f.mjs").proof.x()`. Both already share
`collectTests` and `fmtPath`, so the traversal agreed while the two
reports could not be compared, which is the visible half of the runners
having drifted apart.
Results now carry a `name` built by `fmtImport`, the function `fjs t`
formats its own result lines with, and the page renders that.
`nameMatchesTheConsoleRunner` asserts against the function rather than
against a spelling, so the two cannot drift again without failing.
`name` is a field rather than something the renderer derives, because
`module` and `path` do not always recombine into one: a module that
cannot be enumerated and a proof exported as a bare function both carry
an empty `path`, and only the code that produced the result knows which
it had. A module-level failure is named by its source, which is what is
known about it. `path` stays for the consumers that read it, and is now
redundant for every leaf -- recorded in the todo as part of the report
shape rather than changed here.
Changelog:
- `emergent_testing`: the browser report's results carry a `name` -- the
test identity `fjs t` prints, built by the same `fmtImport` function --
and the page renders it, so both runners spell a test identically
|
You have reached your Codex usage limits for security reviews. Please try again later. |
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
functionalscript | dd3866a | Commit Preview URL Branch Preview URL |
Aug 27 2026, 03:13 PM |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 03b3b06cf7
ℹ️ 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".
| /** @type {(module: string, path: readonly (string | null)[], throws: boolean, fn: () => unknown, result: (result: _BrowserTestResult) => void) => Promise<readonly _BrowserTestResult[]>} */ | ||
| const runOne = (module, path, throws, fn, result) => { | ||
| const start = performance.now() | ||
| const name = fmtImport(module, path) |
There was a problem hiding this comment.
Canonicalize module paths before formatting names
When fjs t is run from a supported subtree, loadModuleMap makes its module keys relative to that working directory, whereas the browser manifest always supplies repository-root-relative sources. For example, the same leaf can therefore be named import("./module.f.mjs")... by the CLI and import("./fjs/types/foo/module.f.mjs")... by the browser. Passing the raw module to fmtImport means the new field does not provide the promised cross-runner identity in this supported context, so report comparison, deduplication, or filtering still treats the same test as different; normalize both runners to a common module identifier before formatting.
AGENTS.md reference: AGENTS.md:L60-L63
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
The observation is right and the diagnosis is not — normalizing inside fmtImport's callers would be the wrong fix.
Reproduced. From the repository root:
import("./fjs/types/list/proof.f.mjs").proof.cycle[0](): ok
and with INIT_CWD=fjs/types/list:
import("./proof.f.mjs").proof.cycle[0](): ok
But note what that comparison is between: both lines are fjs t. This is the CLI differing from itself across two invocations, not the CLI differing from the browser. loadModuleMap strips the INIT_CWD prefix deliberately — loadModuleMapStripsInitCwdPrefix in fjs/dev/module.f.mjs pins it — because a subtree run reports a subtree, and naming those leaves by a path the reader is not standing in would be the surprising behaviour.
So a name embeds a module key, and a module key is relative to the root a run was given. Given the same key, the two runners produce the same name, which is exactly what this PR changes and what nameMatchesTheConsoleRunner pins. Given different roots they produce different keys — and would still do so after any normalization that did not also invent a canonical root, which is the part that cannot be assumed: the browser application root is not the repository root in the design emergent_testing/todo/browser-testing.md describes, so "canonicalize to the repository" would be picking one host's root and calling it universal.
What is left of the finding is real and worth stating: two reports only compare when their roots agree, and nothing in the report declares its root. That is a report-shape question, not a naming one, and it now sits with the other open report-shape item (path, which this PR makes redundant) in emergent_testing/todo/share-browser-console-runner.md. Fixing it here would widen the PR past the one thing it does.
The PR body's "both runners spell a test identically" should be read as "for the same module key"; I have not changed the code.
Generated by Claude Code
o2alexanderfedin
left a comment
There was a problem hiding this comment.
Approved. Verified at 03b3b06cf: npm test 3472/3472 exit 0, tsc --noEmit exit 0 (main 12106a3b0: 3470/3470). Branch is current — main is already an ancestor, so the merge result is the head itself. The name comes from the same fmtImport the console runner uses (browser.mjs:203) rather than a re-derived spelling, and it is pinned: appending 'X' to the constructed name is KILLED — 3471/3472, failing exactly nameMatchesTheConsoleRunner(). Leaf-name diff against main is exactly the two new leaves and nothing dropped. Changelog: is literal and matches the entry file item-for-item.
…elative to
`directory-index-pages.md`: the generated site is one page, while the
repository is a tree of directories that each hold a module, its types,
its proofs and its todos -- none of it reachable from the site. A
generated `index.html` beside every `module.f.mjs` catalogues its files,
subdirectories, `todo/` issues and local proofs, and runs those proofs
through the existing browser runner with the manifest narrowed to that
directory. It is not a second runner, and it is a second consumer of the
traversal the website program already performs.
Also records, in `share-browser-console-runner.md`, that a test name
embeds a module key and a module key is relative to the root a run was
given: `fjs t` in `fjs/types/list` names a leaf
`import("./proof.f.mjs")...` where the same leaf from the repository root
is `import("./fjs/types/list/proof.f.mjs")...`. That is `fjs t` differing
from itself across roots rather than the two runners differing, and it is
deliberate -- but two reports only compare when their roots agree, so
which root a report declares belongs to the report-shape decision.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a9f175cb25
ℹ️ 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".
| item.setAttribute('data-status', result.status) | ||
| const detail = result.status === 'failed' ? `: ${result.message}\n${result.stack}` : '' | ||
| item.textContent = `${result.status === 'passed' ? 'PASS' : 'FAIL'} ${result.module} ${result.path} (${result.duration.toFixed(1)} ms)${detail}` | ||
| item.textContent = `${result.status === 'passed' ? 'PASS' : 'FAIL'} ${result.name} (${result.duration.toFixed(1)} ms)${detail}` |
There was a problem hiding this comment.
Preserve reports that predate the
name field
When the exported renderBrowserReport is called with a report created by an earlier version—or by an existing controller using the previous { module, path, ... } result shape—result.name is absent, so every row now renders as PASS undefined ... rather than displaying its identity. Either retain the old module/path rendering as a fallback or explicitly treat and document this as a breaking API change.
AGENTS.md reference: AGENTS.md:L91-L98
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and taken as the second of the two options you named: documented as a breaking API change rather than given a fallback.
changelog/unreleased/1738.md now reads:
BREAKING CHANGES:
emergent_testing: a browser test result gains a requiredname…renderBrowserReportreadsname, so a report built by hand against the previous{ module, path, … }shape rendersundefinedin place of every identity; reports produced byrunBrowserProofs,startBrowserTestsandstartBrowserTestSourcescarry the field and are unaffected.moduleandpathare unchanged
The fallback is the option I deliberately did not take. Retaining ${module} ${path} when name is absent would keep a second spelling of a test name alive inside the renderer, which is the exact thing this change exists to remove — a page that names a test one way for fresh reports and another way for older ones has two identities again, just conditionally. Every report the module produces carries the field, so no in-repository caller is affected, and the PR body now states the reasoning where a reader will look for it.
Generated by Claude Code
`renderBrowserReport` is exported and now reads `result.name`, so a
report assembled by hand against the previous `{ module, path, ... }`
result shape renders `undefined` where its identity should be. The field
is required rather than optional on purpose -- a fallback to the old
`${module} ${path}` spelling would keep two spellings of a test name
alive, which is what this change exists to remove -- so the honest
description is a breaking one.
Reports produced by `runBrowserProofs`, `startBrowserTests` and
`startBrowserTestSources` all carry the field, so no in-repository caller
is affected.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dd3866aeea
ℹ️ 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".
| /** @type {(module: string, path: readonly (string | null)[], throws: boolean, fn: () => unknown, result: (result: _BrowserTestResult) => void) => Promise<readonly _BrowserTestResult[]>} */ | ||
| const runOne = (module, path, throws, fn, result) => { | ||
| const start = performance.now() | ||
| const name = fmtImport(module, path) |
There was a problem hiding this comment.
Guard test-name formatting from proof-side mutation
When a proof or embedding page replaces JSON.stringify before a nested proof is scheduled, this synchronous fmtImport call throws before runOne installs its rejection handlers. Because fmtImport uses JSON.stringify, the exception rejects Promise.all and the entire runBrowserProofs call instead of producing a failed result, potentially leaving the page in running; the parent version resolves normally for the same identifier-only proof tree. Capture a safe formatter or handle name construction inside the protected execution path so this change does not introduce that regression.
AGENTS.md reference: AGENTS.md:L103-L106
Useful? React with 👍 / 👎.
o2alexanderfedin
left a comment
There was a problem hiding this comment.
Approval still stands. Verified at dd3866aee: npm test 3472/3472 exit 0, tsc --noEmit exit 0 (main 12106a3b0: 3470/3470). The delta touches zero .mjs/.ts files — only the changelog entry and two todo documents — so the naming feature I approved is untouched by construction.
The BREAKING marker is right, not over-broad: name is a required field on _BrowserTestResult (browser.mjs:77), which is the JSDoc type of the exported renderBrowserReport, and renderResult at :453 reads result.name with no fallback — a hand-built old-shape report prints undefined. package.json has no exports map, only a files allowlist, so consumers reach it by path. Changelog: is byte-identical to the entry file.
directory-index-pages.md is Status: open with everything unchecked and describes only future work; both claims I spot-checked hold (the generated site is a single writeFile('index.html', …) at website/module.f.mjs:64, and the discovery walk already visits every directory at browser-prepare.mjs:20), and all five links resolve. The additions to share-browser-console-runner.md and generate-website.md are notes, not shipped-work claims — worth stating explicitly since that file was restored by the #1737 revert: the note cites loadModuleMapStripsInitCwdPrefix (fjs/dev/module.f.mjs:181), which is unrelated to the reverted shared-runner code.
Step 1 of
emergent_testing/todo/share-browser-console-runner.md, which this PRalso sequences into seven steps, one per pull request.
Why this first
The reverted attempt did the whole issue at once — 2646 insertions and 1408
deletions across 35 files — and that is why its arguments could not be
separated: a question about scheduling became a question about the port. This is
the smallest piece of the issue that stands on its own, and it happens to be its
most visible symptom.
The change
Both runners already share
collectTestsandfmtPath, so the traversal agreedwhile the two reports could not be compared:
fjs timport("./a.f.mjs").proof.x(): ok, 0.3 msPASS ./a.f.mjs .x (0.3 ms)PASS import("./a.f.mjs").proof.x() (0.3 ms)Results now carry a
namebuilt byfmtImport— the functionfjs tformatsits own result lines with — and the page renders it.
nameMatchesTheConsoleRunnerasserts against that function rather than against a spelling, so the two cannot
drift again without a proof failing.
nameis a field rather than something the renderer derives, becausemoduleand
pathdo not always recombine into one: a module that cannot be enumeratedand a proof exported as a bare function both carry an empty
path, and only thecode that produced the result knows which it had. A module-level failure is
named by its source, which is what is known about it —
unreadableModuleIsNamedByItsSourcecovers that.
pathstays for the consumers that read it. It is now redundant withnameforevery leaf, and that is recorded in the todo as part of the report-shape
decision rather than changed here.
Why the field is required
renderBrowserReportis exported and readsname, so a report assembled by handagainst the previous
{ module, path, ... }shape rendersundefinedin placeof each identity. That is a breaking change and the changelog says so.
Making
nameoptional with a fallback to${module} ${path}was thealternative, and it is the wrong one: it would keep a second spelling of a test
name alive inside the renderer, which is precisely what this change exists to
remove. Every report the module itself produces —
runBrowserProofs,startBrowserTests,startBrowserTestSources— carries the field, so noin-repository caller is affected.
Notes on review
A module key is relative to the root a run was given:
fjs tinvoked infjs/types/listnames a leafimport("./proof.f.mjs")...where the same leaffrom the repository root is
import("./fjs/types/list/proof.f.mjs").... That isfjs tdiffering from itself across roots —loadModuleMapStripsInitCwdPrefixpins it deliberately — not the two runners differing. Given the same key the two
agree, which is what this PR establishes. What is worth keeping from that
observation is that nothing in a report declares its root, so two reports only
compare when their roots agree; that is a report-shape question and is recorded
with
pathin the todo rather than widening this PR.The remaining steps
Recorded in the todo, each reviewable without the next: one
sandbox; commoneffects; a browser interpreter; one reporter; one skeleton; the layout move.
Steps 2 and 6 are the ones that change behaviour, so they are the ones to keep
smallest.
Also here
Two todos requested alongside this step, neither touching code:
fjs/website/todo/directory-index-pages.md— generate anindex.htmlbesideevery
module.f.mjs, cataloguing that directory's files, subdirectories,todo/issues and local proofs, and running those proofs through the existingbrowser runner with a narrowed manifest rather than a second runner.
share-browser-console-runner.md.Verification
npx tscclean,fjs t3472/3472,npm run covat 100% lines, branches andfunctions. Two new proofs, both of which fail without the change.
Changelog:
emergent_testing: a browser test result gains arequired
name— the test identityfjs tprints, built by the samefmtImportfunction — and the page renders it, so both runners spell a testidentically.
renderBrowserReportreadsname, so a report built by handagainst the previous
{ module, path, ... }shape rendersundefinedinplace of every identity; reports produced by
runBrowserProofs,startBrowserTestsandstartBrowserTestSourcescarry the field and areunaffected.
moduleandpathare unchanged🤖 Generated with Claude Code
https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg