Skip to content

Remove duplicated error handling and collapse the crux/crux-history near-duplicate - #15

Merged
Hugoer merged 15 commits into
mainfrom
refactor/dedupe-error-handling-and-crux-client
Sep 3, 2026
Merged

Remove duplicated error handling and collapse the crux/crux-history near-duplicate#15
Hugoer merged 15 commits into
mainfrom
refactor/dedupe-error-handling-and-crux-client

Conversation

@Hugoer

@Hugoer Hugoer commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Closes #11.

Structural cleanup, no behaviour changes. Seven commits, each independently revertable.

No behaviour change

Every commit here is intended to be observationally identical. Where that is easy to prove,
it is proven below rather than asserted.

withCatch was dead for six of seven actions. Each action ended with a catch that logged
Error: ${err.message} and exited 1, and was also wrapped in withCatch, which does
byte-identically the same thing — so the inner handler could never behave differently from
the outer one. Removed from all seven.

Proof this one is mechanical: git diff -w on commit 1 contains only the seven deleted
try/catch blocks and nothing else. Spot-checked at runtime that --runs=0, --runs with
--reuse-browser, and a missing clean input all still print the same message and exit 1.

One clean implementation instead of two. The wizard's cleanAction and the subcommand's
inline action were the same seven lines twice, differing only in where the input came from.
cleanAction now takes it as an optional argument and prompts only when absent.

crux and crux-history collapsed into one client. The two modules held four functions
each differing only in endpoint, results directory, error label, and collectionPeriod vs
collectionPeriods. Origin normalisation, 404-as-no-data, retry, rate limiting and file
naming were duplicated line for line — which is why the fix in #9 had to be written twice.
createCruxClient in the new lib/crux-client.js owns that logic once.

Free-rider cleanups. LabReport.timing marked optional (stripJsonProps deletes it by
default, so the published .d.ts promised a field the default path removes); each run's
performance score computed once in buildRunSummary instead of three times; the
results.push that an aborting plan throws away removed; over-long JSDoc lines expanded.

On the crux refactor

It is not a line-count win. 365 lines became 355. The win is that roughly 250 lines of
duplicated logic became 185 lines that exist once, so the next fix to that path lands in
one place instead of two.

The typed wrappers in crux.js / crux-history.js are not ceremony. The factory builds
its record with a computed [periodKey], which erases the concrete return type to
{ [x: string]: any }. I checked the generated .d.ts before committing: without the
wrappers the published types degrade. With them, runCruxAudit still returns
Promise<CruxReport> and runCruxHistoryAudit still returns Promise<CruxHistoryReport>,
and the erased shape stays confined to the internal crux-client.d.ts.

The options types are now named (CruxAuditOptions, CruxRunOptions, CruxBatchOptions)
rather than repeated inline. Same shapes — and the regenerated crux/crux-history types
pick up the statusCode argument on onProgress that the checked-in .d.ts was missing,
the same drift class as the runBatch one corrected in #13.

lib/crux-client.js is registered in tsconfig.types.json and package.json exports per
the project checklist.

Verification

  • npm run lint0 problems, down from 6 warnings on main. The over-long JSDoc lines
    in crux.js, crux-history.js and lab.js are gone.
  • npm test — 544 passed, unchanged. The crux suite (43 tests) passes untouched, which is
    the point: the tests describe the public contract and none of them needed editing.
  • npm run generate-types — committed; re-running leaves the tree clean.
  • Export surface byte-identical on ./crux, ./crux-history and through lib/index.js.
  • Written JSON key order unchanged: source, scope, formFactor, url, <period>, extractedAt, metrics, key.
  • Live API smoke test against https://www.google.com, both endpoints, exit 0:
crux         keys: source,scope,formFactor,url,collectionPeriod,extractedAt,metrics,key
crux-history keys: source,scope,formFactor,url,collectionPeriods,extractedAt,metrics,key

12 metrics on each, 25 collection periods on the history record.

Deliberately out of scope

The --concurrency=abc validation inconsistency (silently falls back to 5, while --runs=abc
throws). It is a behaviour change and does not belong in a no-behaviour-change PR.

Every action ended with a catch that logged `Error: ${err.message}` and exited 1, and was
also wrapped in withCatch, which does byte-identically the same thing. The inner handler
could never run differently from the outer one, so it was dead weight in seven functions.

Removed from labAction, psiAction, cruxAction, cruxHistoryAction, sitemapAction, linksAction
and wizardMode. withCatch is now the single error policy. labAction's own process.exit(1) for
the failed-run list stays — that is a non-throwing path and withCatch never saw it.

Purely mechanical: `git diff -w` on this commit contains only the seven deleted try/catch
blocks and nothing else. Verified that --runs=0, --runs with --reuse-browser, and a missing
clean input still print the same message and exit 1.
…ommand

The wizard's cleanAction and the clean subcommand's inline action were the same seven lines
twice, differing only in where the input came from.

cleanAction now takes the input as an optional argument and prompts only when it is absent,
so the subcommand passes it straight through and the wizard keeps asking. Verified both
paths: the subcommand cleans a directory and exits 0, a missing input still exits 1.
…lient

crux.js and crux-history.js held four functions each that differed only in the endpoint, the
results directory, the label used in error messages, and whether a record carries
collectionPeriod or collectionPeriods. Origin normalisation, treating 404 as no-data, retry,
rate limiting and file naming were duplicated line for line — which is why the runtimeError
and no-data fix in #9 had to be written twice, once per module.

createCruxClient in the new lib/crux-client.js now owns that logic once. crux.js and
crux-history.js are configuration plus typed wrappers.

The wrappers are not ceremony. The factory assembles its record with a computed [periodKey],
which erases the concrete return type to `{ [x: string]: any }`. Restating the signature in
each module keeps the published types describing a CrUX record: runCruxAudit still returns
Promise<CruxReport> and runCruxHistoryAudit still returns Promise<CruxHistoryReport>. The
erased shape stays confined to the internal crux-client.d.ts.

Not a line-count win — 365 lines becomes 355. The win is that ~250 lines of duplicated logic
became ~185 lines that exist once, so the next fix to this path lands in one place.

Verified: export surface byte-identical on both modules and through lib/index.js; written
JSON key order unchanged (source, scope, formFactor, url, <period>, extractedAt, metrics,
key); all 43 crux tests pass untouched. lib/crux-client.js registered in tsconfig.types.json
and package.json exports per the project checklist.
…efault

The LabReport typedef declared `timing` as a required property, but runLabAudit strips both
`timing` and `i18n` unless stripJsonProps is explicitly false. The published .d.ts therefore
promised consumers a field that the default path deletes.
buildRunSummary called performanceScore on every successful run three times over — once for
`scores`, once for the `scored` filter, and once again for `scoredValues`. The score is now
computed once and the two derived arrays filter over it.

Both filters use the same predicate in the same order, so `scored` and `scoredValues` remain
index-aligned and medianIndex still addresses both.
When continueOnError is false, runLabPlan pushed the failure onto both `group` and `results`
and then threw on the next line. The rejection discards both arrays, so neither push could
ever be observed. Moved the throw ahead of them.

No behaviour change: the hook call and the continueOnError path are untouched, and the
onRunError hook was already skipped on an aborting run.
The LighthouseAudit and LighthouseCategory typedefs and runLabAudit's options parameter were
single lines of 213, 200+ and 216 characters. Rewritten as @Property blocks, with the options
object promoted to a named LabAuditOptions typedef.

Clears the last two max-len warnings; combined with the crux refactor, eslint now reports 0
problems across the repo, down from 6 warnings on main.
The Structure block listed 9 of 15 lib modules; clean.js, clean-cmd.js, index.js, logger.js
and strip-props.js were already absent and crux-client.js would have made six. Now complete,
with a note on why crux.js and crux-history.js keep typed wrappers over the factory.

Adds two rules. The first covers examples/ and the README: examples depend on the repo via
file:.. so they run against the working tree, but npm test does not touch them — they need
network and an API key — so nothing catches a break automatically. A change to an exported
name, signature, return shape or CLI behaviour has to be reflected there and the affected
examples run by hand. It also records that sitemap, links and clean have no example and no
failure path is covered, so a green run is not full coverage.

The second says a new lib module belongs in the Structure block as well as in
tsconfig.types.json and package.json. All three drift silently.
…rmats

Two real gaps found auditing README.md against the code.

The sitemap section never documented the same-origin restriction on child sitemaps that
shipped in #13. It is user-visible — an index listing a www. variant of its own domain now
skips those children with a warning — and it went undocumented precisely because the export
surface did not change, so nothing prompted a doc edit. Entity decoding is documented
alongside it, since it is the reason a sitemap URL with a query string is now usable in
--urls-file.

CLAUDE.md's Output block claimed HHMM for psi, crux, crux-history, links and sitemap, and
omitted the profile from the lab clean filename. formatDate() has always emitted HHMMSS;
README had it right and CLAUDE.md had drifted. Both now agree with the code.

The examples/README rule is rewritten around what actually went wrong: it now names the
three places README goes stale, calls out the per-command behaviour section as the one that
gets missed, and records that CLAUDE.md itself duplicates facts that live in code — when
they disagree, the code wins.
Adding ./crux-client to package.json exports followed the letter of the "new lib modules"
rule, but it was the wrong call: crux-client is an implementation detail of crux and
crux-history, and a subpath entry is a semver commitment to a factory nobody should import.

Removed from exports. It stays in tsconfig.types.json, which is not optional — crux.d.ts and
crux-history.d.ts reference CruxFormFactor from it, so consumers of web-perf-cli/crux need
that declaration to resolve.

Verified against a package consumer: web-perf-cli/crux, /crux-history and the root still
import, /crux-client now fails with ERR_PACKAGE_PATH_NOT_EXPORTED, and a strict tsc build
under node16 resolution still types CruxReport, CruxHistoryReport and the CruxFormFactor
union that comes from the private module.

The rule is rewritten to separate the two steps. tsconfig include is unconditional, because
a private module still needs a .d.ts when published types point at it. package.json exports
is a judgement call that decides whether a module is public at all, and internal plumbing
stays out of it and out of the README API table.
…constant is live

createCruxClient hard-coded CRUX_MAX_REQUESTS_PER_SECOND, which left crux-history's exported
CRUX_HISTORY_MAX_REQUESTS_PER_SECOND declared, published through lib/index.js and the types,
and read by nothing. Both values are 2.5 today so no run behaved differently, but editing the
history constant would have changed nothing — the endpoints could diverge and the module
would keep firing at the crux rate.

maxRequestsPerSecond is now part of CruxClientConfig, defaulting to CRUX_MAX_REQUESTS_PER_SECOND,
and each module passes the constant it publishes.

Verified by stubbing runBatch: a client configured at 1.25 hands 1.25 to the rate limiter.
…tate its methods

Two related gaps from reviewing the factory.

The option typedefs lived in crux.js, so crux-history.d.ts resolved CruxAuditOptions,
CruxRunOptions and CruxBatchOptions through the crux subpath — one consumer module's public
contract depending on its sibling's. They now sit in crux-client.js alongside CruxFormFactor,
which is where the shared behaviour already is, and both modules re-declare them from there.

The four factory methods carried no @PARAM, so tsc inferred their options from the defaulted
properties only: formFactor vanished from runAudit's type, onNoData from run's, onProgress
from both batch methods, and formFactors was typed string[] rather than CruxFormFactor[].
They are now annotated with the shared typedefs and the emitted declarations carry the real
shapes. The record return type is still erased by the computed [periodKey]; that is what the
typed wrappers in crux.js and crux-history.js exist to restate.

Public crux/crux-history types re-checked against a consumer project under node16 resolution.
The refactor moved every real behaviour into createCruxClient but left the configuration
itself untested. crux.test.js and crux-history.test.js only reach the client transitively, so
swapping endpoint, command, dataLabel or periodKey between the two modules wrote the wrong
record into the wrong directory with all 544 tests still green.

Adds lib/crux-client.test.js: 13 tests driving the factory through a synthetic config, plus 4
asserting that crux and crux-history are wired to different endpoints, directories, labels and
period keys.

Confirmed load-bearing by mutation: each of the four config values was swapped in
crux-history.js in turn, and each swap fails exactly one test.

  periodKey plural->singular  -> 1 failed | 16 passed
  results dir -> crux         -> 1 failed | 16 passed
  dataLabel -> CrUX           -> 1 failed | 16 passed
  endpoint -> queryRecord     -> 1 failed | 16 passed
Unifying the two clean implementations moved `require('../lib/prompts')` to the top of
cleanAction, putting it on the `web-perf clean <path>` path as well. The subcommand declares
`<input>` as required, so commander rejects the call before the action runs and only the
wizard ever reaches promptClean — the require was unreachable work that also pulled in crux,
crux-client, psi and profiles.

Moved into the branch that needs it, matching the per-branch lazy requires the rest of this
file and lib/lab.js already use. Verified: a non-interactive clean run now loads none of those
four modules, and a missing input still exits 1.
The comment claimed "both filters run over the same predicate in the same order", but they
are different predicates over different arrays — `succeeded.filter((_, i) => scores[i] !== null)`
and `scores.filter((score) => score !== null)`.

The invariant is real and the code is correct; the stated reason was not. What actually holds
it up is that `scores` is index-parallel to `succeeded` and both filters select on the same
null test while preserving order. Reworded to say that, and to name the consequence of
breaking it: medianRun and medianOutputPath silently point at the wrong run.
@Hugoer

Hugoer commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

Review findings addressed in five commits. Attribution was checked against main for each, since not all of them belonged to this PR.

Fixed here (introduced by this PR):

  • bef5d95CRUX_HISTORY_MAX_REQUESTS_PER_SECOND had become inert: the factory hard-coded the crux rate, so the exported history constant was read by nothing. maxRequestsPerSecond is now part of CruxClientConfig and each module passes its own. Verified by stubbing runBatch — a client configured at 1.25 hands 1.25 to the rate limiter.
  • f3067b6 — the shared option typedefs moved from crux.js into crux-client.js (so crux-history.d.ts no longer resolves its public contract through its sibling), and the four factory methods gained @param, which had been dropping formFactor, onNoData and onProgress from the emitted declarations.
  • 1d28113 — added lib/crux-client.test.js. Confirmed load-bearing by mutation: swapping each of endpoint, command, dataLabel and periodKey in crux-history.js fails exactly one test each.
  • f3a7d76cleanAction no longer requires lib/prompts on the non-interactive path. Verified: a plain web-perf clean <path> now loads none of prompts/crux/psi/profiles.
  • 6b252f0 — corrected the variance.js comment, which claimed the two filters share a predicate. They don't; alignment holds because scores is index-parallel to succeeded.

Moved to #16 (pre-existing, not this PR): the LabPlanOptions / LabReport / onSummary type gaps. git diff main...HEAD -- lib/lab.js touches none of those typedefs, and folding them in would have grown a no-behaviour-change refactor into unrelated type work. Each is recorded there with its exact tsc error.

One finding was incorrect. It reported that the form-factor arrays became a shared mutable instance in this PR — "previously each module owned its own array literal". They did not:

main:   crux.DEFAULT_CRUX_FORM_FACTORS === cruxHistory.DEFAULT_CRUX_FORM_FACTORS  ->  true
branch: crux.DEFAULT_CRUX_FORM_FACTORS === cruxHistory.DEFAULT_CRUX_FORM_FACTORS  ->  true

crux-history.js line 3 on main already imported and re-exported crux's arrays. The mutation hazard is real and worth fixing, so it is filed as item 4 of #16 — but it is not a regression from this branch.

Verification: lint 0 problems, 561 tests passing (up from 544), types regenerated with no drift, crux/crux-history examples re-run green against the working tree, and the public crux/crux-history types re-checked against a consumer project under node16 resolution.

@Hugoer
Hugoer merged commit a7626d6 into main Sep 3, 2026
5 checks passed
@Hugoer
Hugoer deleted the refactor/dedupe-error-handling-and-crux-client branch September 3, 2026 11:30
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.

Remove duplicated error handling and collapse the crux/crux-history near-duplicate

1 participant