Skip to content

Add test coverage tooling, real tests, and Codecov reporting - #90

Open
roncodes wants to merge 133 commits into
mainfrom
feature/test-coverage-and-codecov
Open

Add test coverage tooling, real tests, and Codecov reporting#90
roncodes wants to merge 133 commits into
mainfrom
feature/test-coverage-and-codecov

Conversation

@roncodes

@roncodes roncodes commented Aug 6, 2026

Copy link
Copy Markdown
Member

Why

The test suite could not run at all. The dummy app failed to boot (@ember/string missing, required by ember-data 4.12), and CI ran only lint and build — so nothing exercised the addon. Of 138 test files, 128 were generated TODO: Replace this with your real tests stubs.

What

Test harness

  • Add @ember/string so the dummy app boots.
  • Declare ember-cli-string-helpers — an undeclared runtime dependency already imported by the crud service, humanize and get-model-name.
  • Add packages to pnpm-workspace.yaml (required by pnpm 11).

Coverage

  • Wire ember-cli-code-coverage, which needs three pieces that were all absent: config at the addon's configPath (tests/dummy/config/coverage.js), the istanbul babel plugin on both the dummy app and this addon's own tree, and a QUnit.done hook to ship the report. Instrumenting the addon tree is what makes coverage describe addon/ rather than only the dummy app.
  • Force-load addon modules after the suite so files with no tests stay in the denominator instead of silently dropping out.
  • scripts/check-coverage.mjs enforces per-file 100% and fails when an eligible addon/ file is missing from the report, so coverage cannot be inflated by omission. It has its own node:test suite covering both passing and failing paths.

Tests

  • 54 generated stubs replaced with behavioral tests covering nullish, empty, boundary and invalid input. Suite is green: 259 tests, 0 failures.

CI

  • Run the full suite with coverage and enforce the gate (previously no tests ran at all).
  • checkout@v4, setup-node@v4 with pnpm cache, pnpm/action-setup@v4, --frozen-lockfile.
  • Upload lcov to Codecov with fail_ci_if_error so a broken upload is visible.
  • Least-privilege permissions, concurrency cancellation, publish jobs gated on the test job.

Status — work in progress

Not yet ready to merge:

  • Coverage report is not yet produced. The suite passes but the run stalls in the QUnit.done hook. The /write-coverage middleware itself is confirmed working (a direct POST writes a report), so the remaining fault is on the browser side. No coverage percentage is claimed until a generated artifact exists.
  • 75 generated stubs remain, plus the 26 services (one is ~1,976 lines).

Production defects found while reading source

Catalogued, not yet fixed:

  1. utils/extract-coordinates.js — copy/paste typo sets latitude = 0 where it means longitude = 0.
  2. Four utils (api-url, console-url, frontend-url, get-routing-host) import @fleetbase/console/config/environment, hard-coupling the addon to one consuming app. The addon's own convention elsewhere is ember-get-config. This also makes them impossible to load in tests.
  3. services/universe/extension-manager.js imports a function from the host app (@fleetbase/console/extensions).
  4. utils/is-waypoint-record.js imports ../models/waypoint, which does not exist — the module can never be imported by anyone.
  5. utils/is-relation-missing.js contains isset(model, ''), which is always falsy, so one branch is dead.

Four utils ignore their arguments and always return true (ison, reverse-point, is-function, hason-structure); get-mime-type returns an extension rather than a mime type. Current behavior is pinned by tests with NOTE comments rather than silently changed.

🤖 Generated with Claude Code

roncodes and others added 17 commits August 6, 2026 21:20
The test suite could not run at all: the dummy app failed to boot because
@ember/string was missing (required by ember-data 4.12), and CI only ran
lint and build, so nothing exercised the addon.

Test harness:
- Add @ember/string so the dummy app boots.
- Declare ember-cli-string-helpers, an undeclared runtime dependency used by
  the crud service, humanize and get-model-name.
- Add packages to pnpm-workspace.yaml, required by pnpm 11.

Coverage:
- Wire ember-cli-code-coverage, which needs three pieces that were absent:
  config at the addon's configPath (tests/dummy/config/coverage.js), the
  istanbul babel plugin on both the dummy app and this addon's own tree, and
  a QUnit.done hook that ships the report. Instrumenting the addon tree is
  what makes coverage describe addon/ rather than only the dummy app.
- Force-load addon modules after the suite so files without tests stay in the
  denominator instead of silently dropping out.
- Fail loudly rather than hang if the coverage upload stalls.
- Add scripts/check-coverage.mjs, a per-file 100% gate that also fails when an
  eligible addon file is missing from the report, with its own node:test suite
  covering both the passing and failing paths.

Tests:
- Replace 54 generated "TODO: Replace this with your real tests" stubs with
  behavioral tests covering nullish, empty, boundary and invalid input.
- Pin the actual contract of four utils that ignore their arguments and always
  return true (ison, reverse-point, is-function, hason-structure) and of
  get-mime-type, which returns an extension rather than a mime type.

CI:
- Run the full suite with coverage and enforce the gate; previously no tests ran.
- Upgrade to checkout@v4, setup-node@v4 with pnpm cache, pnpm/action-setup@v4,
  and install with --frozen-lockfile.
- Upload lcov to Codecov with fail_ci_if_error so a broken upload is visible.
- Add least-privilege permissions, concurrency cancellation, and gate the
  publish jobs on the test job.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two problems kept the suite from ever producing a coverage report.

The socket service builds a SocketCluster client in its constructor, and
socketcluster-client retries a failed connection forever. Under test that
meant an endless stream of failed WebSocket connections to the testem server,
so the page never went idle. Tests now plant a marker script node that
satisfies the load-socketcluster-client initializer's own idempotency guard,
and replace the global with an inert fake. No production code changes.

Four utils imported config from `@fleetbase/console/config/environment`,
hard-coupling the addon to one consuming app and making the modules
unloadable anywhere else, including the dummy app. They now use
`ember-get-config`, which is already a dependency and is what the rest of the
addon uses. In the console app this resolves to the same config.

Also gate the coverage hook on a config flag so normal test runs do not pay
for collecting and shipping coverage, and repeat the plugin's default
node_modules and mirage excludes, which a project-level `excludes` replaces
rather than extends. Without them istanbul instruments every dependency.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lean

index.js is published, so requiring the ember-cli-code-coverage devDependency
at module scope tripped n/no-unpublished-require and failed CI lint. The plugin
is only needed while running this repository's own suite, so it is now resolved
behind the same COVERAGE env var it keys off. Consumers of the published addon
never load it.

Also replace the upstream forceModulesToBeLoaded with a scoped version. The
upstream helper walks every module in the build and a module whose import
cannot be resolved wedges the end of the run. Failures are collected instead of
thrown, which is safe because an unevaluated module is simply absent from the
report and scripts/check-coverage.mjs fails the build when that happens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Node 22.23 resolves a bare directory argument to node --test as a module
path rather than a directory, so the step failed in CI while passing on the
local 22.22.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dummy app disables prototype extensions, which is the Octane default, and
that exposed three real defects:

- group-by called arr.objectAt() and pushObject() on plain arrays, so it threw
  for every caller not passing an Ember array.
- get-mime-type called objectAt() on the result of Object.keys().
- array-utils re-exported `default` from stable-by-ids, which only has a named
  export, so `arrayUtils.stableByIds` was undefined. The app re-export had the
  same mistake.

Also fixed:

- extract-coordinates reassigned `latitude` in the missing-longitude branch, so
  a coordinate pair with no longitude returned [0, null] instead of [0, 0].
  Covered by a regression test.
- is-waypoint-record imported ../models/waypoint, which does not exist anywhere
  in this addon, so importing the module threw for every consumer. Removed as
  dead code along with its app re-export and stub test. Flagged for review in
  the pull request: this is an API removal, but the export could not be used.
- Removing that unloadable module also unwedged the end of the test run, so
  coverage is now posted and written without intervention.

Corrected assertions in four of my own tests that encoded the wrong contract
(Ember treats an empty Map as blank; isFinite(null) is true; the app re-export
only forwards default exports).

Coverage now reports 162 of 168 eligible addon files, up from 140 of 169.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Writing real tests for the utils surfaced three genuine bugs:

- app/utils/array-utils.js re-exported `default`, but the addon module only has
  named exports, so importing sameIds/stableByIds/arrayUniqueBy from the app
  path yielded undefined. Same mistake as stable-by-ids.
- context-component-callback treated `options: null` as an object, because
  `typeof null` is 'object', and threw while reading the callback off it.
- copy-to-clipboard and lazy-load-script stubs were raising unhandled global
  failures that QUnit attributed to whichever test happened to be running, so
  unrelated tests failed. Both now stub their boundary (the navigator clipboard,
  and data: URLs instead of the network) and cover the success, rejection and
  already-loaded paths.

The is-electron test asserted that the current browser is not Electron, which
made it depend on the runner: it passes under headless Chrome and fails in an
Electron-based browser. Every branch is now driven with an explicit user agent.

to-model, to-leaflet-bounds and replace-table-row are pinned as they behave
today, with notes: to-model creates its helper without an owner so it always
throws, and replace-table-row's `if (rowIndex)` guard skips a match at index 0
and treats a missing row as index -1.

Failing tests are down from 45 to 31, of which only three are not generated
stubs. Coverage is at statements 585/3763, branches 393/2484, functions
173/898, lines 558/3605.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tensions

The fetch service imports `fetch` from ember-fetch, which was never declared in
package.json and is not installed here. Like ember-cli-string-helpers, it only
worked because the console application happened to provide it. Added as a
dependency.

Several modules read host configuration as soon as they are evaluated — the
fetch service touches config.API.host at module scope — so the dummy app now
supplies the API, socket and osrm sections a host application is expected to
configure. Without them those modules cannot be loaded, let alone measured.

The extension manager imports getExtensionLoader from
'@fleetbase/console/extensions'. That is a function rather than config, so
ember-get-config cannot redirect it; tests register an AMD stub for the module
instead, which keeps production code unchanged.

Rewrote the application serializer test, which called createRecord('application')
for a model that does not exist. It now registers real models and checks the
uuid primary key, the underscored polymorphic type key, and that the read-only
slug is stripped both from a serialized record and from a bare payload.

Coverage reaches 164 of 168 eligible files, up from 162, and failing tests are
down from 31 to 28, of which only two are not generated stubs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… calls

Two sources of cross-test interference are gone, and with them the last
failures that were not simply untouched generated stubs.

ember-local-storage caches its storage objects across owners, so the second
test to use a `storageFor` service inherited the previous test's destroyed
object and failed with "calling set on destroyed object". Storages are now
reset after every test.

The fleetbase-api-fetch stub called the util with no stubbing at all, which
issued a real network request. Its asynchronous "Failed to fetch" surfaced as a
global failure that QUnit attributed to whichever test happened to be running,
so unrelated tests failed seemingly at random. It now stubs window.fetch and
covers url construction, the namespace override, GET query serialisation, json
bodies, default and overridden request options, the bearer token from a stored
session, non-2xx responses, fallback responses and network failures.

auto-serialize called objectAt on a plain array, the same breakage already
fixed in group-by and get-mime-type, and is now covered for arrays, the except
list, empty and populated relationships.

Also replaced the waypoint-label, timeout, get-pod-methods, get-meta-field-types,
mock-response and normalize-polymorphic-type stubs.

323 tests, 25 failing — all of them generated stubs, none behavioural.
Coverage: statements 673/4094, branches 465/2628, functions 185/962, lines 643/3930.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
consoleUrl passed window.location.host into extractHostAndPort when no host was
supplied. That value is only "hostname:port", which `new URL` cannot parse, so
the parse failed and the fallback produced an invalid "https:///path". It now
passes a full url built with the current protocol.

get-routing-host read waypoints.firstObject, an Ember array property that does
not exist on a plain array once prototype extensions are off, so the waypoint
branch never matched. This is the fifth instance of that pattern, after
group-by, get-mime-type, auto-serialize and find-closest-waypoint, which is also
fixed here (objectAt, pushObject, sortBy and firstObject all replaced).

New tests cover console-url (query encoding, host and port extraction, explicit
and derived hosts, ports, empty subdomains), get-routing-host (per-country
servers, waypoints, fallbacks), map-engines (mount paths, route naming, external
routes shared across engines, extra services), group-api-events,
find-closest-waypoint, leaflet-icon, has-extension, and the two always-true
column-filter utils, which are pinned with notes.

359 tests, 21 failing — all untouched generated stubs.
Coverage: statements 708/4095, branches 490/2628, functions 189/963, lines 676/3930.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
register-component and register-helper are tested through a real owner: derived
and explicit names, the dasherizing of both, and that an existing registration
is never overwritten.

The serialize helpers get full branch coverage — rewriting backslashed class
names onto _type attributes, copying a nested relation type, splitting an
embedded relation into the relation and its id, custom primary keys, blank
payloads, and passing non-object input straight through.

is-relation-missing is pinned rather than changed. Its non-polymorphic branch
computes `isset(model, relation_uuid) && !isset(model, '')`, and the empty-string
key looks like an unfinished edit: reading a blank path is always falsy, so the
negation is always true and the result reduces to "is the foreign key set". The
test says so explicitly so the next reader does not have to work it out.

383 tests, 16 failing — all untouched generated stubs.
Coverage: statements 736/4095, branches 529/2628, functions 189/963, lines 704/3930.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
app-cache.has() and doesntHave() were always true and always false. They asked
`this.get(k) !== undefined`, but `get` substitutes its default for a missing
value and so never returns undefined. Passing undefined explicitly does not help
either, because a JavaScript default parameter applies whenever the argument is
undefined. Both now read storage directly through a small helper.

notifications.serverError crashed on a null error while reading `.errors`, which
a rejected promise carrying no value would produce. Guarded.

Worth noting for review: ember-cli-notifications and ember-can each ship their
own app/services/{notifications,abilities}.js, which collide with this addon's
re-exports of the same names. Which file wins depends on build order, so
`service:notifications` did not resolve to this addon's subclass in the dummy
app at all. The tests register the classes under test explicitly rather than
relying on that lookup, but the collision is real and may mean the overrides are
not active in consuming applications either.

Also covers table-context and the abilities parse override.

406 tests, 16 failing — all untouched generated stubs.
Coverage: statements 782/4097, branches 555/2630, functions 208/964, lines 746/3932.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two more instances of the prototype-extension pattern, bringing the total to
seven:

- loader.js pushed onto routesLoaded with pushObject, which does not exist on a
  plain array once extensions are off. Reassigning the array also invalidates
  the tracked property properly.
- language.js read this.locales with objectAt while building its available
  locale map, so the map could never be built.

The loader is covered across conditional display, selector and element targets,
the body fallback for a missing target, message defaulting, overlay removal, and
the transition paths that record a route and avoid stacking overlays. The
language service is covered with fake intl and fetch services: locale seeding,
the country lookup map, locales with no matching country, language listing,
lookup by a custom property, persisting a locale change, and a failing lookup
leaving the service usable.

422 tests, 16 failing — all untouched generated stubs.
Coverage: statements 853/4097, branches 597/2630, functions 224/964, lines 813/3932.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`urlParams` is a getter that builds a new URLSearchParams from
window.location.search on every access, so every mutator writes to a throwaway
object that is discarded as soon as it returns:

  setParam, setParamArray, remove   no observable effect
  clear                             throws, it assigns to a getter-only property
  updateUrl, getFullUrl,
  getPathWithParams                 re-serialise the unchanged current URL

The read side works, because it reads live from the URL, and so do the
*CurrentUrl methods, which operate on a real URL object and push it to history.

Nothing is changed here. Making the mutators work means choosing a storage
model — a cached instance that can go stale, or mutating the real URL directly —
and that is a design decision for the maintainers, not a typo fix. The tests
state plainly which methods are inert so the next reader does not have to
rediscover it, and they will fail the moment somebody makes them work, which is
the point at which the decision gets made.

17 tests, 0 failing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tracked-built-ins was imported by contracts/universe-registry.js and
services/universe/registry-service.js but never declared, the same class of bug
as ember-cli-string-helpers and ember-fetch. It is now a dependency. That does
not fully resolve it — the module still is not present in the built app, which
is precisely why those two files have never appeared in the coverage report.
The UniverseRegistry tests are held back with a note until that resolves.

Covers base-contract (option copying, falsy values distinguished from missing
ones, chaining, defensive copies out of toObject and getOptions, and validation
running through setup), registry (name composition through withNamespace and
withSubNamespace, the option kept in step with the name, and the missing-name
error), the contracts index re-exports, and the ExtensionBootState and
HookRegistry singletons, including that each instance owns its own containers
rather than sharing class-level ones.

24 contract tests, 0 failing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both construction paths are exercised — a bare name, a name with a handler, a
name with an options object, and a full definition object — along with the
defaults, the fluent chaining API (execute, withPriority, once, withId, enable,
disable, setEnabled, withMetadata) and toObject serialisation.

One case is pinned rather than asserted as an error. The definition branch is
gated on `isObject(x) && x.name`, so `new Hook({ handler })` with no name falls
through to the string branch and the object itself is assigned as the name.
Being truthy it passes validation, and the handler is silently dropped, so a
typo'd definition fails somewhere far from the mistake. The test says so.

480 tests, 16 failing — all untouched generated stubs, zero behavioural failures.
Coverage: statements 964/4097, branches 642/2630, functions 268/964, lines 924/3932.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@fromstore works and is covered: it queries lazily on first read, caches so the
store is consulted once, defaults its query and options, assigns null when the
query rejects, and skips the query entirely when a value has been assigned.

@isEqual does not work on Ember 5.4 with ember-decorators 6. The decorated
property reads back as undefined regardless of the two source properties,
because the inner function hands a ComputedProperty to
decoratorWithRequiredParams where a property descriptor is expected, so nothing
is installed on the class. Its parameter list is also mislabelled — it declares
(target, desc, key, params) where the caller passes (target, key, desc, params) —
which is harmless only because neither is used.

Nothing is changed. Fixing it means deciding how the property should be defined,
which is a maintainer's call; the tests pin the current behaviour with that
explanation and will fail as soon as somebody makes it work.

9 decorator tests, 0 failing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Codecov step sat after the 100% gate, so it only ever ran when coverage was
already perfect — which meant it never ran at all, and no report has reached
Codecov yet. It now runs before the gate and on a run whose tests failed, so the
data flows while the numbers are still climbing. A missing or unreadable report
still fails the job, and the gate still fails the build when coverage is short.

legacy-from-store is covered: lazy querying, caching, null on rejection, and the
assigned-value bypass. It is byte-for-byte identical to from-store — both are
exported so both are tested, but one is redundant and the two will drift apart
the first time somebody edits only one. The test says so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

Thanks for integrating Codecov - We've got you covered ☂️

roncodes and others added 10 commits August 7, 2026 08:08
Both construction paths, including the widgetId legacy alias and that an
explicit id wins over it; the three shapes a component can take (a string path,
a plain object, and an ExtensionComponent that gets flattened via toObject); the
default flag surviving construction, asDefault and toObject; the merge semantics
of withGridOptions and withOptions; withTitle and withRefreshInterval writing
into options; that every setter returns the widget; and the missing-id error.

17 tests, 63 assertions, 0 failing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both construction paths: a string path (mirrored as the name), an options object
carrying loading and error components, and a component class (stored with the
class name as the name and no path). Plus the chaining setters, toObject, and
the two toString forms.

Worth a maintainer's attention: unlike Hook, Widget and Registry, this
constructor never calls super.setup(), so validate() does not run on
construction. A component with no engine, or with neither a path nor a class, is
built happily and only fails later, somewhere less obvious. The rules themselves
are fine — calling validate() directly reports both problems — they are just
never enforced. Pinned rather than changed, since adding the call could start
throwing for consumers who are currently getting away with it.

11 tests, 39 assertions, 0 failing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both registration paths: a lazy path, where the name is the final segment, and a
direct class or function, where the name is derived from the class name.

The derivation is covered at its edges — PascalCase split to kebab-case,
consecutive capitals handled (HTMLParser becomes html-parser), single words
lowercased, named functions treated like classes, and an anonymous function
yielding no name.

One quirk is pinned as-is: the Helper suffix is stripped after kebab-casing, so
FormatDistanceHelper becomes "format-distance-" with a trailing hyphen, while
FormatDistance becomes "format-distance". The suffix rule runs against the
already-hyphenated string and only removes the word, not the separator.

9 tests, 17 assertions, 0 failing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both construction paths, slug derivation from the title, defaults, and that an
explicit false or zero survives rather than being replaced by the default. Also
the chaining setters, addItem flattening a MenuItem to its object form while
passing plain objects through, addItems, and the _isMenuPanel indicator on
toObject.

One quirk pinned: the slug is derived with dasherize(title) before super.setup()
runs, so a missing title throws a TypeError from inside dasherize and the
intended "MenuPanel requires a title" message is never reached. An empty string
does reach it. The failure is still loud, just less helpful than intended.

13 tests, 43 assertions, 0 failing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both construction paths, the title seeding text/label/id/slug/view, every
default, zero priority and index surviving rather than being defaulted, tag
normalisation, nested items and shortcuts, the chaining setters, and toObject.

Two problems are pinned rather than changed:

The onClick chaining method is unreachable. The constructor assigns
`this.onClick = definition.onClick || null`, an instance property that shadows
the prototype method of the same name, so the documented `.onClick(handler)`
call invokes null and throws. A handler has to be passed in the definition
instead. Renaming one of the two would fix it and would be a breaking change
either way, so it is a maintainer's call.

renderInPlace() sets only the option, not the property, unlike every other
setter. toObject still reports the right value because options are spread last,
but reading item.renderComponentInPlace directly gives the stale answer.

23 tests, 72 assertions, 0 failing. All twelve contracts are now covered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
filters.js had three more calls that do not exist on a plain array once
prototype extensions are off — pushObject when collecting active filters, and
objectAt when reading both controller and route query params. That is ten
instances of this pattern now, across group-by, get-mime-type, auto-serialize,
find-closest-waypoint, get-routing-host, loader, language and filters.

theme is covered across preference resolution (stored user option, then initial
theme, then system preference), applying a theme and its body classes, the
persist flag, the theme.changed event, toggling, the sandbox environment class,
route body classes, and console loader removal.

filters is covered across value serialisation (dates, arrays, nested dates,
blank filtering), the pending-parameter lifecycle including status "all" and
blank values clearing rather than storing, apply writing onto the controller and
resetting pagination, and activeFilters reading from the route with managed and
blank parameters excluded.

Both suites needed a stand-in for the private router microlib the service reads
to find the current route; the helper is documented in the test.

17 theme tests and 18 filters tests, 0 failing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
socket.js pushed onto its tracked channels list with pushObject and read it back
with objectAt, neither of which exists on a plain array once prototype
extensions are off. That is twelve instances of this pattern now, across nine
files. The push is replaced with a reassignment so the tracked property
invalidates properly.

Covered: client construction from the application socket config, the fallback to
window.location.hostname when no hostname is configured, coercion of the secure
flag, instance() returning the underlying client, subscribing and tracking
channels, waiting on the subscribe listener, tolerating a missing callback, and
closeChannels closing every tracked channel and being safe with none.

The suite-wide SocketCluster stub keeps this off the network; these tests
install a richer stand-in to observe what the service asks for.

10 tests, 15 assertions, 0 failing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Covers the redirect target, the onboarding flag, the loader overlay, expiry
reading, two-factor lookup including its failure path, and session event
fan-out to both the events service and the universe.

Two findings.

getSessionSecondsRemaining subtracts the wrong way round. It computes
(now - expiry) instead of (expiry - now), so a session with a minute left
reports roughly -60 and an expired one reports a positive number. The magnitude
is right and only the sign is wrong, which is exactly the kind of thing a caller
may already be compensating for, so it is pinned rather than corrected.

This is the third app-tree collision: ember-simple-auth ships
app/services/session.js at the same path this addon re-exports, so
`service:session` did not resolve to the subclass at all — none of its methods
existed on the looked-up instance. Same shape as notifications
(ember-cli-notifications) and abilities (ember-can). The test registers the class
under a distinct name, but three collisions in one addon is a pattern worth
addressing at the source.

12 tests, 20 assertions, 0 failing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Covers the enable flag (on by default, only an explicit false disables, and
nothing is emitted while disabled), the fan-out to both local listeners and the
universe, the session events including the three aliases termination emits, the
user and organization events with their nullish handling, and the resource
events.

The resource cases pin the useful details: creation emits both a generic
resource.created and a model-specific order.created, safe properties are read
off the record, absent or null optional properties are omitted rather than sent
as null, explicit properties override the ones read from the resource, and a
missing resource still emits.

16 tests, 34 assertions, 0 failing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
getUserPermissions builds `const permissions = []` and then called
permissions.pushObjects(...) three times. pushObjects does not exist on a native
array once prototype extensions are off, so gathering permissions threw for any
user who had any. Replaced with push and a spread.

Worth being precise about the distinction, because a blanket substitution would
have been wrong: the objectAt calls in the same method are on ember-data
relationship arrays, which keep Ember's array methods regardless of the
EXTEND_PROTOTYPES setting. Those are correct and are left alone. Only the plain
array literal was broken.

Covered: permissions applied directly to the user, permissions from the role,
permissions from each policy on the role, policies applied directly to the user,
all four merged, an empty user, policies with no permissions, a role with
neither, and that duplicates are deliberately kept rather than collapsed.

The fixtures mimic the ember-data shape the method reads rather than building
real records, since this is plain aggregation logic.

9 tests, 0 failing. Fifteen prototype-extension defects fixed across ten files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A filtered run force-loads every addon module but exercises only the tests that
match the filter, so it produces a report with the full denominator and almost
no numerator — 56/4096 statements across 269 files, which reads as a total
collapse. That report overwrote the good full-suite one, and coverage:check
reads the same file, so a partial run could make a healthy tree look broken.
The reverse is worse: a filter narrow enough to cover its own subset could in
principle satisfy the gate on a fraction of the suite.

The QUnit.done hook now bails out when QUnit.config.filter, module or testId is
set, leaving the previous full-suite artifact intact and saying why. Verified
both directions: a filtered run leaves coverage/ absent, and an unfiltered run
still writes a credible report — statements 1491/4097 (36.39%), branches
935/2630 (35.55%), functions 376/964 (39%), lines 1445/3932 (36.74%) across 164
addon files.

CI was never affected, since it only ever runs the unfiltered suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
roncodes and others added 8 commits August 8, 2026 12:17
Two things.

DEFECTS.md consolidates all twenty-two findings in one place, grouped by whether
they are live, dead code, or latent, each with the file that pins it. The PR
comments remain the running log; this is the readable summary.

And extension-manager is not what I said it was. I wrote it off for most of this
campaign as needing a real engine in the container — 230 uncovered statements,
55% of everything still missing outside the other two structural files. It does
not need one. #loadEngine drives the ROUTER'S PRIVATE engine API:

    router._enginePromises / _engineIsLoaded / _assetLoader.loadBundle / _registerEngine

and constructEngineInstance only calls application.hasRegistration,
application.buildChildEngineInstance and engineInstance.boot(). All of those are
reachable through a fake application handed to the service before it is built —
the same shape as the hook-service tests, since #initializeBootState also runs
in the constructor. No bundle is loaded and nothing touches the network.

That is the fifth time I have called something untestable and been wrong. The
pattern every time: I described the DEPENDENCY as heavy instead of reading what
the code actually calls on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ntPoint does

All sixteen failures had one cause: the owner patch calls
resolveRegistration('config:environment') on every instance it builds, and my
fake engine had no such method. Adding it fixes the fifteen loading tests.

The three mount-point tests were wrong in a more interesting way.
getEngineMountPoint does NOT derive anything from the engine name — it looks up
the LOADED instance and reads that engine's own config, returning null when the
engine is not loaded, and appending a trailing dot to whatever prefix it finds.
#mountPathFromEngineName is only the fallback for a config that does not name
its own mountedEngineRoutePrefix. The tests now cover all four of those paths.

Coverage moved 89.61% -> 90.88% on the failing run, which is what confirmed the
seam was real before any of this was green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The second of the three files I had recorded as structurally uncoverable. The
note said `request()` calls the module-scope `fetch` imported from ember-fetch,
so a window.fetch swap could not reach it. Reading ember-fetch 8.1.2's browser
asset shows otherwise:

    Object.defineProperty(exports, prop, {
        get: function () { return originalGlobal[prop] },
        set: function (v) { originalGlobal[prop] = v },
    });

and under a test environment its default export is a wrapper calling
`exports.fetch.apply(...)` at CALL time. The import therefore resolves through
to `window.fetch` on every call, and swapping the global intercepts it — the
same seam already used for lookup-user-ip and load-extensions. I had assumed
"module-scope import" meant "bound once", without checking what the module
actually exports.

Covers the url it assembles (including the external-request and blank-namespace
paths), the request options, and all seven ways a non-ok response is turned
into a rejection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FetchService calls getHeaders() in its CONSTRUCTOR, which reads
session.data.authenticated — so a bare `class extends Service {}` threw at
lookup and all twenty-two tests died before reaching request(). Coverage was
unchanged on that run, which is what said the method never ran at all.

This is the pre-flight check I wrote down two rounds ago and did not run:
grep the service for what it calls on its collaborators before stubbing them.
The sibling fetch-test.js already had the right stub.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Swapping window.fetch changed nothing — the requests went out for real, which
the 200ms test durations and a "Network request failed" from the github/fetch
polyfill both showed. This build does not prefer native fetch, so ember-fetch
bundles that polyfill and assigns it to its OWN exports rather than to the
global.

The seam is one level in. Under a test environment ember-fetch's default export
is a wrapper that reads `exports.fetch` at CALL time, and `exports` is the AMD
module object `window.require('fetch')` returns. Replacing `.fetch` on it
intercepts every call, and it is restored in teardown.

So the long-standing note was right that window.fetch cannot reach this, and
wrong that nothing can.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
uploadFile needed no seam at all — the file is an argument, so `upload`,
`state` and `queue` are all on the object the caller passes. Covers the four
retryable states and the one it refuses, the removed Content-Type header that
lets the multipart boundary through, and the failure path that reports and
calls back.

download goes through the same ember-fetch module export the base-request tests
intercept: the GET-versus-POST split over where the query goes, the external
request that skips host and namespace, and the filename and mime type resolved
from the response headers with caller overrides winning. Its final hand-off to
the vendored downloadjs helper runs for real against a small in-memory blob.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both failures were real behaviour, not harness problems.

The content-disposition header OVERRIDES a caller-supplied filename rather than
falling back to it — the parameter is named defaultFilename and is only that.
A caller cannot force a name for a response that supplies its own. Both
directions are now pinned.

Flagged, not fixed: getMimeTypeFromResponse extracts with

    /(.*)?;/.exec(contentType)

which requires a trailing semicolon. A header of exactly 'text/csv' matches
nothing, so the mime type stays null and falls through to
getMimeType(fileName) — which returns the EXTENSION, not a mime type (already
on the register). The download is therefore handed 'csv' where 'text/csv' was
meant. A header WITH parameters parses correctly, which is now asserted beside
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@roncodes

roncodes commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Two of the three "structural" files were not structural

Coverage at 9510ac9: statements 3761/4025 (93.44%), branches 90.86%, functions 97.08%, lines 93.37%. 2103 tests, 0 failing. Up from 89.61% at the start of this stretch.

DEFECTS.md is now at the repo root — all 24 findings in one place, grouped by live / dead code / latent, each naming the test that pins it. These PR comments remain the running log.

The part worth reading

I had recorded three files as structurally uncoverable, holding 377 of the ~420 remaining statements. Two of them were not. Both went from a written-off note to covered in a single round each, once I read what the code actually calls instead of describing the dependency as heavy.

universe/extension-manager — 230 uncovered, now 137. The note said "engine construction needs a real engine in the container". It does not. #loadEngine drives the router's private engine API:

router._enginePromises / router._engineIsLoaded / router._assetLoader.loadBundle / router._registerEngine

and constructEngineInstance only calls application.hasRegistration, application.buildChildEngineInstance and engineInstance.boot(). All of it is reachable through a fake application handed to the service before it is built. No bundle is loaded and nothing touches the network.

services/fetch — 88 uncovered, now 64. The note said request() calls the module-scope fetch from ember-fetch, so it cannot be stubbed. Half right: window.fetch genuinely does not reach it — this build does not prefer native fetch, so ember-fetch bundles the github/fetch polyfill onto its own exports, which is why swapping the global changed nothing and the requests went out for real. But under a test environment ember-fetch's default export is a wrapper that reads exports.fetch at call time, and exports is the AMD module object:

const m = window.require('fetch');
const original = m.fetch;
m.fetch = stub;   // restored in teardown

That intercepts every call the service makes.

That is the fifth and sixth time in this PR I have called something untestable and been wrong. The pattern is identical every time: I described the dependency instead of reading what the code calls on it.

Also this round

uploadFile needed no seam at all — the file is an argument. download's GET/POST split, external requests, and header-derived filename and mime type are covered, and its hand-off to the vendored downloadjs helper now runs for real against a small in-memory blob.

One new defect (24 total). getMimeTypeFromResponse extracts with /(.*)?;/.exec(contentType), which requires a trailing semicolon — so a bare Content-Type: text/csv, the common case, matches nothing and the mime type stays null. download() then falls through to getMimeType(fileName), which returns the extension, so the browser is handed 'csv' where 'text/csv' was meant. Also pinned: the content-disposition header overrides a caller-supplied filename rather than falling back to it.

Where the gate stands

264 statements remain, and they are honestly reachable except for one category:

statements file status
137 extension-manager reachable — loadExtensions, setupExtensions, the owner patch, the hook plumbing
64 services/fetch reachable — routing() goes through corslite/XMLHttpRequest, plus fetchOrderConfigurations
59 utils/download.js vendored downloadjs, now partly exercised through fetch.download
~9 ~10 other files includes seven dead-code defects that no test can reach

So the 100% gate cannot be reached while defects are flagged rather than fixed: #16#21 and #23 are unreachable by construction, and their statements need the fix pass, not more tests. Everything else on that list is ordinary work and I am continuing with it.

roncodes and others added 3 commits August 8, 2026 12:48
loadExtensions and setupExtensions — the largest remaining block. Two
module-scope imports look like blockers and are not:

  loadInstalledExtensions reaches the network through the GLOBAL fetch, the seam
  already used for load-extensions and lookup-user-ip.

  getExtensionLoader comes from '@fleetbase/console/extensions', which the dummy
  app already supplies as an AMD stub. `import { x } from '…'` compiles to a
  property read on the module object at CALL time, so replacing the property on
  window.require(...) redirects it — the same trick the fetch tests use.

Covers the core/admin/installed filtering, the failure path that empties the
application and still releases the boot waiters, and all six shapes an
extension.js can export: a function, a default-wrapped function, an object with
setupExtension, one with onEngineLoaded (stored, not run), one with neither
(warned), and a loader that throws without stopping the others.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
corslite constructs its XMLHttpRequest off `window`, so swapping the global
constructor intercepts every request with no network access. The fake declares
`onload` as an own property because corslite picks between `onload` and
`onreadystatechange` with an `in` check.

Covers routing's url assembly — the semicolon-joined coordinates, the default
routing host, the subdomain and explicit-host overrides, the service/profile/
version segments and the optional query — and its three response outcomes.

For corslite itself: the failing-status path that hands the request back as the
error, 304 counting as success, the no-XMLHttpRequest guard, and the IE8-9
XDomainRequest fallback, which is reached by a client that has no
`withCredentials` property at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The third and last file recorded as structurally uncoverable — "vendored
downloadjs; triggers real downloads/XHR". Both halves are interceptable:

  the anchor it clicks comes from document.createElement('a'), so wrapping
  createElement for the duration makes the click a no-op and nothing is saved;

  the url-only path builds an XMLHttpRequest off the global, the same seam the
  corslite tests use.

Its scheduling goes through Ember's `later`, so settled() flushes the click and
the cleanup rather than leaving timers to fire during a later test.

Covers the blob path and the anchor's full lifecycle, the plain-string and
high-byte-string paths, data urls saved without re-encoding, the url-only path
that re-fetches as a blob and derives a filename from the url without its query,
the bind(true) argument reversal the original library documents, and the IE10
msSaveBlob preference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@roncodes

roncodes commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

All three "structural" files are covered. None of them was structural.

Coverage at 760c971: statements 3861/4025 (95.92%), branches 93.14%, functions 98.01%, lines 95.91%. 2146 tests, 0 failing. Three rounds, all green first try.

I carried three files as uncoverable for most of this campaign — 377 statements, about 88% of the remaining gap at the time. Each fell in a single round once I stopped describing the dependency and read what the code actually calls on it.

file the note I had written what was actually true
extension-manager "engine construction needs a real engine in the container" it drives the router's private engine API; the boot half needs a global-fetch swap plus redirecting getExtensionLoader, which the dummy app already AMD-stubs
services/fetch "request() calls the module-scope fetch, so it cannot be stubbed" window.fetch genuinely cannot reach it, but window.require('fetch').fetch can — the test-mode default export reads exports.fetch at call time
utils/download.js "vendored downloadjs; triggers real downloads/XHR" the anchor comes from document.createElement('a'), so wrapping createElement makes the click a no-op and nothing is saved; the url-only path uses the global XMLHttpRequest

That is six times in this PR I have called something untestable and been wrong, with the same mistake every time: I described the dependency as heavy — "module-scope import", "needs a real engine", "triggers real downloads" — instead of reading which of its members the code touches.

The technique that generalises

In a classic build, import { x } from 'm' compiles to a property read on the module object at call time. So any module-scope import is interceptable:

const m = window.require('m');
const original = m.x;
m.x = stub;          // restored in afterEach

Confirmed on both fetch and @fleetbase/console/extensions. Worth knowing for anything else in this codebase that looks bolted down.

Covered in these three rounds

  • extension-manager's boot half — the core/admin/installed filtering, the failure path that empties the application and still releases the boot waiters, and all six shapes an extension.js can export: a function, a default-wrapped function, an object with setupExtension, one with onEngineLoaded (stored, not run), one with neither (warned), and a loader that throws without stopping the others.
  • fetch.routing and corslite — url assembly, the three response outcomes, and corslite's failing-status, 304, no-XMLHttpRequest and IE8-9 XDomainRequest paths.
  • download.js — the blob path and the anchor's full lifecycle, plain and high-byte strings, data urls, the url-only path that re-fetches as a blob, the bind(true) argument reversal the original library documents, and the IE10 msSaveBlob preference.

Where the gate stands

164 statements remain. 77 in extension-manager (the owner patch, engine parent dependencies, hook plumbing), 34 in download.js (Safari and iframe fallbacks — browser-capability branches Chrome cannot take without faking the capability checks), 10 in fetch, and about 43 spread thin.

That last group still includes the seven dead-code defects, which no test can execute. So 100% remains out of reach while defects are flagged rather than fixed — but the gap is now genuinely small and almost all of it is ordinary work rather than anything structural.

Renamed from DEFECTS.md so there is one file, and led with the section that
matters: exactly what stops the 100% gate going green.

The blocker list is now VERIFIED against the coverage artifact rather than
recalled. That corrected two claims I had been repeating:

  auto-serialize's dead `invoke` dispatch and its fleet/zone patches are NO
  LONGER blockers — the pinning tests reach both, one via a model carrying a
  property literally called `method`, the other by supplying the removed
  _internalModel path. Both files are at 100%.

  loadWhois' catch is likewise covered, through a storage write that throws
  after lookupUserIp returns.

So it is six defects and ten statements, not seven defects. Each row names the
file and line numbers and says why no input can reach it.

Also separated a category I had been conflating with defects: statements that
are uncoverable but are NOT bugs — @Tracked initialisers that a constructor
overwrites (5), fallbacks needing a container-less service (6), and one
module-scope config line. Those need no fix, and if the gate must be green they
are the only honest candidates for an exclusion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@roncodes

roncodes commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

DEFECT.md is on the branch, with the gate blockers verified and up front

DEFECT.md at the repo root now carries all 26 findings. It leads with the section that actually matters for this PR: exactly what stops the 100% gate going green.

⛔ Six defects, ten statements

# file lines why no input reaches it
B1 contracts/widget.js 239, 255 the constructor already assigns this.options on both paths, so if (!this.options) never fires
B2 services/resource-action.js 209, 233 if (!selected) after a spread — always a truthy array
B3 services/universe/menu-service.js 51 #wrapOnClickHandler's type check duplicates its only caller's
B4 services/url-search-params.js 176 clear()'s return this sits after a line that throws every time
B5 utils/to-model.js 8, 10 getOwner() is undefined, so the line above throws on every call
B6 services/universe/hook-service.js 81 its only caller runs in the constructor, before setApplicationInstance can have run

Five of the six are a deletion; B6 is a reordering.

I had this wrong, and the artifact corrected it

I have been repeating "seven defects block coverage" from memory. Checking it against coverage-final.json rather than my notes:

  • auto-serialize's dead invoke dispatch and its fleet/zone patches are no longer blockers. The pinning tests reach both — one via a model carrying a property literally called method, the other by supplying the removed _internalModel path with Object.defineProperty. That file is at 100%.
  • loadWhois's catch is covered too, through a storage write that throws after lookupUserIp returns.

So it is six, not seven, and the blocked total is ten statements rather than a vague handful.

A category I had been conflating with defects

Twelve more statements cannot be covered either, but are not bugs and need no fix:

  • @tracked field = value initialisers that a constructor overwrites (5) — the initialiser only runs if the property is read before it is written, and these classes assign in the constructor. An instrumentation artifact.
  • Fallbacks that need a container-less service (6) — if (!owner) / if (!application) paths. Ember always supplies an owner, and the one substitute that would work breaks the run: ApplicationInstance#willDestroy reads this.application._unwatchInstance during teardown.
  • One module-scope config line that runs at import time.

If the gate has to go green without touching production code, those twelve are the only honest candidates for an exclusion — and I would not add one without you asking, since the scope decision was explicitly to keep writing tests instead.

Current coverage, from CI run 31240648459: statements 3861/4025 (95.92%), branches 93.14%, functions 98.01%, lines 95.91%. 2146 tests, 0 failing.

roncodes and others added 3 commits August 8, 2026 13:31
The owner patch wraps buildChildEngineInstance so engines loaded through
routing get the same treatment as ones loaded through this service. Covering it
also reaches the three pieces hanging off it:

  the onEngineLoaded hooks — stored when the engine is not loaded yet and run
  after boot with (engine, universe, appInstance), run immediately when it is
  already loaded, isolated from each other when one throws, and fired exactly
  once even though both the boot patch and #onEngineInstanceBuilt could fire
  them;

  #setupEngineParentDependenciesBeforeBoot — a named service resolved from the
  application, hostRouter mapping to service:router rather than
  service:hostRouter, an unknown service falling back to its own name, object
  entries merged as-is, and external routes becoming a self-referencing map;

  the mount point, where the trailing dot is stripped before it reaches the
  instance.

Plus registerServiceIntoAllEngines and registerComponentIntoAllEngines.

I caught one vacuous assertion in my own draft before pushing — a test for the
post-load throwing hook that asserted `true`. It now registers a second hook
and asserts THAT ran, which is the thing actually worth knowing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each fallback is chosen by a CAPABILITY check, so each is reached by removing
the capability rather than by faking the outcome:

  `'download' in anchor`  — return a <span> from createElement instead of an
                            <a>. It has no `download` property but still
                            appends, takes attributes and has click().
  a Safari user agent     — defineProperty on navigator.userAgent.
  `self.URL`              — delete window.URL for the duration.

Nothing here can navigate. window.open is stubbed, and the one path that would
assign location.href sits behind a confirm() stubbed to decline — which also
covers the blocked-popup branch.

Covers the Safari window.open route including the mime rewrite that makes it
offer to save rather than render, the old iframe route and its cleanup, the
no-URL route through btoa and FileReader, and the two-megabyte threshold where
a data url is decoded into a blob instead of being handed over whole.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit aborted the run — 1680 of 2166 tests, then "Browser timeout
exceeded: 10s". The tell was the pass count, exactly as recorded: a total far
below the known one means an aborted run, not N failures.

Cause: I replaced document.createElement for the DURATION OF THE TEST so that
anchors came back as spans. QUnit's own HTML reporter creates anchors, so it
got spans too, reporting stalled, and testem's watchdog killed the browser.

The override is now installed around the download() call and removed in a
finally immediately after, so nothing else in the page ever sees it. Also
dropped the two no-URL tests: deleting window.URL is a global with the same
blast radius, and those statements are not worth that risk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@roncodes

roncodes commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

97.96%, and I aborted the run once getting there

Coverage at 53d404c: statements 3943/4025 (97.96%), branches 94.74%, functions 98.74%, lines 97.93%. 2173 tests, 0 failing.

Two rounds since the last update:

  • extension-manager's hook plumbing and parent-dependency fixing (+56). The owner patch wraps buildChildEngineInstance so engines loaded through routing get the same treatment as ones loaded through this service, and covering it reaches everything hanging off it: onEngineLoaded hooks stored before load and run after boot, run immediately when the engine is already loaded, isolated when one throws, and fired exactly once even though two separate paths could fire them. Plus hostRouter mapping to service:router rather than service:hostRouter, external routes becoming a self-referencing map, and the mount point's trailing dot being stripped.
  • downloadjs's browser fallbacks (+26) — the Safari window.open route including the mime rewrite that makes it offer to save rather than render, the blocked-popup path, the old iframe route and its cleanup, and the two-megabyte threshold where a data url is decoded into a blob rather than handed over whole.

The abort, because the mechanism is worth knowing

The download-fallbacks commit killed the run: 1680 of 2173 tests, then Browser timeout exceeded: 10s.

To reach the non-a[download] path I replaced document.createElement so anchors came back as spans — for the duration of the test. QUnit's own HTML reporter creates anchors. It got spans, reporting stalled, and testem's watchdog killed the browser.

The tell I'd written down after an earlier incident worked exactly as intended: a pass count far below the known total means an aborted run, not N failures. That pointed at the cause immediately instead of sending me hunting through assertions.

The fix is a rule rather than a patch: install a global override around the call and remove it in a finally, never for a whole test — for anything the test framework also uses (document.createElement, window.URL, navigator.*). window.fetch and XMLHttpRequest are safe to hold for a test because the framework doesn't use them. I also dropped two tests that deleted window.URL: same blast radius, and two statements is not worth it.

What is left — 82 statements

~40 genuinely reachable work (fetchOrderConfigurations, socket's async-iterator callback, load-extensions, lookup-user-ip, a scattering of one-liners)
10 the six gate-blocking defects — see DEFECT.md
12 uncoverable but not defects — @tracked initialisers a constructor overwrites, if (!owner) fallbacks needing a container-less service, one module-scope config line
10 downloadjs's no-URL/FileReader route — reachable only by deleting window.URL, which aborts the run as above. Not worth it.

So the reachable remainder is around forty statements. Past that the number needs either the fix pass for the six defects, or a decision from you about the twenty-two that no test can reach.

roncodes and others added 4 commits August 8, 2026 13:46
The singular name was my reading of a typo, not the intent. Content unchanged —
the gate blockers stay at the top.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fetch: request's normalizeToEmberData branch, and the half of uploadFile that
turns the response into a store record. That half had never run — the earlier
upload tests registered no `file` model, so store.normalize threw and the outer
catch swallowed it, and the assertions still passed because they only checked
what was SENT. A real model is registered here, which also reaches the outer
catch deliberately by returning a payload with no uuid.

socket: the body of listen's async-iteration loop. The sibling test's channel
reports `done` on the first pull, so the loop never ran an iteration; this one
yields two messages and then finishes.

Plus the last one-liners: from-store and legacy-from-store's onComplete,
serialize-model's toJSON branch (which needs an ObjectProxy, since an
ember-data record has serialize but no toJSON), theme's prefers-color-scheme
check, and language's swallowed save failure.

Flagged, not fixed: filters.activeFilters skips blank and managed params, but
getQueryParams — which it calls with no controller, so taking the route path —
has already dropped both. The `continue` is unreachable and the filtering is
duplicated one layer apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two failures, both the pre-flight check I keep writing down and not running.

The theme accessor is `activeTheme`, a getter — not `getTheme()`, a method I
invented.

LanguageService reads intl.locales and intl.primaryLocale in its CONSTRUCTOR
and subscribes with onLocaleChanged, so a bare `class extends Service {}` threw
at lookup before either test body ran.

One grep for `this.<collaborator>.` across the service, plus a look at the
accessor's actual name, would have caught both in the same pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
I asserted 'dark' because the comment on that line says "default to dark
theme". It returns `this.currentTheme`, which is null on a service nobody has
set a theme on — so the fallback is whatever was last stored, not a literal
dark. Both values are now asserted.

The comment is misleading rather than the code being wrong, so this is a note
rather than a register entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@roncodes

roncodes commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

98.33%, and a green test that was hiding a dead branch

Coverage at e1755d3: statements 3958/4025 (98.33%), branches 95.15%, functions 98.74%, lines 98.31%. 2194 tests, 0 failing.

Covered this round: request's normalizeToEmberData branch, the half of uploadFile that turns the response into a store record, the body of socket.listen's async-iteration loop, and the last one-liners — from-store/legacy-from-store's onComplete, serialize-model's toJSON branch, theme's prefers-color-scheme check and language's swallowed save failure.

An earlier test of mine was passing for the wrong reason

The first uploadFile tests registered no model:file. So store.normalize('file', …) threw, the method's outer catch swallowed it, and the tests still passed — because they only asserted what was sent, never what came back. The entire success half of that method had never executed while looking covered by a green test.

Registering a real model made it run for the first time, and reaching the outer catch deliberately (a payload with no uuid) covered the failure half properly. The general lesson: a test that only asserts the request tells you nothing about the response handling, and can sit there green over a dead branch indefinitely.

One more defect (27 total)

filters.activeFilters loops over getQueryParams() and skips entries that are blank or managed:

if (isBlank(value) || this.managedQueryParams.includes(queryParam)) continue;

But getQueryParams() — called with no controller, so taking the route path — has already dropped both: it skips managed params and only adds a value if (value). The continue is unreachable and the filtering is duplicated one layer apart.

Also noted, not a defect: theme's final fallback returns this.currentTheme, not the literal 'dark' its comment claims. The comment is misleading rather than the code being wrong.

Two CI rounds lost to the same pre-flight I keep writing down

theme's accessor is activeTheme, a getter — not getTheme(), which I invented. And LanguageService reads intl.locales/intl.primaryLocale and calls intl.onLocaleChanged in its constructor, so a bare service stub threw at lookup before either test body ran. One grep for this.<collaborator>. plus a glance at the accessor's real name would have caught both together.

What is left — 67 statements, of which about ten are reachable

23 extension-manager#getApplication fallbacks and boot-state accessors, several of which are in the not-a-defect group below
10 the six gate-blocking defects (DEFECTS.md)
12 uncoverable but not defects — @tracked initialisers a constructor overwrites, if (!owner) fallbacks needing a container-less service, one module-scope line
10 download.js's no-URL route — needs deleting window.URL, which aborts the run
~10 genuinely reachable: corslite, load-extensions, lookup-user-ip, legacy-fetch-from, report-actions — mostly localStorage/Intl catch blocks needing a scoped global override

The reachable remainder is now roughly ten statements. Past that, the number needs the fix pass for the six defects, or a decision about the twenty-two that no test can reach.

roncodes and others added 5 commits August 8, 2026 14:58
All of them catch blocks guarding a browser API that normally succeeds, so each
override is installed around the CALL and removed in a `finally` — localStorage
and Intl are used by the test framework and by ember-local-storage's teardown,
so holding either broken for a whole test would take the run with it.

  load-extensions   a cache write and a cache clear that throw, both swallowed;
                    the extensions still come back, only the caching is lost.
  lookup-user-ip    a whois cache write that throws still resolves the lookup;
                    a browser with no working Intl reports no timezone.
  legacy-fetch-from the symbol-backed accessor on the PROTOTYPE. A native class
                    field shadows it — the pinned defect — so reaching it means
                    applying the decorator to an .extend() class that declares
                    no such field. It returns null there, which is the sentinel
                    the defect note says consumers cannot rely on.
  corslite          a client that fires onload synchronously inside send(),
                    which is what the callback wrapper exists to defer.
  fetch             the error callback on an upload whose response cannot be
                    normalized.
  report-actions    the edit modal's confirm.

Caught another vacuous assertion in my own draft — `assert.true(true, …)` for
the cache clear. It now seeds a key, breaks removeItem, and asserts the key
survived, which is the observable thing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both cache-failure tests failed because my helper was synchronous: a
try/finally around an async call restores the global the moment the PROMISE is
returned, long before the code under test gets as far as writing anything. Both
caches were written normally and the assertions caught it — which is what those
assertions were for.

The async form awaits inside the try, so the override is held for exactly as
long as the call takes and no longer. That is still far narrower than holding
it for a whole test, which is the thing that aborts the run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The boot-state setters (the collections live on the application so every
instance shares them, which a second service now asserts), the two loading
short circuits — a load already in flight returning the same router promise,
and an instance already in the router's map being resolved without building
another — and the registration failures, where a sealed container makes
register throw and the caller is told rather than the throw escaping.

Also the second of the two hook paths. The service fires engine-loaded hooks
from both the boot patch the owner installs AND constructEngineInstance's own
`.then`; only the first runs in the ordinary case. An instance that arrives
already reporting `_bootPatched` skips the patch and reaches the second, which
had never executed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A value that is neither a MenuPanel, an untitled object nor a string reaches
the final `return input` and is handed to the registry unchanged, stored under
an undefined key. Nothing rejects it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every statement a test can reach is now covered. The 42 that remain are
itemised exactly, from the artifact rather than from memory:

  SEVEN defects / TEN statements block the gate — widget, resource-action,
  menu-service, url-search-params, to-model, hook-service and now filters,
  each with file and line numbers and why no input reaches it. Six of the
  seven are a deletion; one is a reordering.

  THIRTY-TWO are uncoverable and NOT defects, in four groups: @Tracked
  initialisers a constructor overwrites (6), fallbacks needing a
  container-less service (12), module-scope configuration (2), browser routes
  that cannot be faked without aborting the run or navigating the page (10),
  and one hook path the other always wins (2).

That second group is the only honest candidate for an exclusion if the gate
has to go green without touching production code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@roncodes

roncodes commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Reachable coverage is complete — 98.95%, and what is left needs your decision

Final state at 5fed152 (CI run 31245775305): statements 3983/4025 (98.95%), branches 95.52%, functions 99.58%, lines 98.96%. 2215 tests, 0 failing.

Every statement a test can reach is now covered. The 42 that remain are itemised exactly in DEFECTS.md, read off the coverage artifact rather than from memory, and none of them can be executed by any input.

Covered in these last rounds: the localStorage and Intl catch blocks in load-extensions and lookup-user-ip, corslite's synchronous-callback deferral, legacy-fetch-from's prototype accessor, report-actions' edit modal, fetch's upload error callback, and the extension manager's remaining edges — the boot-state setters, the in-flight and already-built short circuits, the registration failures, and the second of its two engine-loaded hook paths, which only runs for an instance that arrives already boot-patched.

⛔ Seven defects, ten statements, block the gate

file lines why
contracts/widget.js 239, 255 the constructor assigns this.options on both paths, so if (!this.options) never fires
services/resource-action.js 209, 233 if (!selected) after a spread — always a truthy array
services/universe/menu-service.js 51 the type check duplicates its only caller's
services/url-search-params.js 176 return this sits after a line that throws every time
utils/to-model.js 8, 10 getOwner() is undefined, so the line above throws first
services/universe/hook-service.js 81 its only caller runs in the constructor
services/filters.js 25 the list it filters was already filtered by getQueryParams()

Six of the seven are a deletion. hook-service is a reordering — move #initializeHookRegistry() out of the constructor.

And 32 that are uncoverable but are not bugs

  • 6@tracked field = value initialisers a constructor overwrites. The initialiser only runs if the property is read before it is written. An instrumentation artifact.
  • 12if (!owner) / if (!application) fallbacks. Ember always supplies an owner, and the one substitute that would work breaks the run (willDestroy reads this.application._unwatchInstance).
  • 10download.js's no-URL route. Deleting window.URL stalls QUnit's reporter and aborts the run, and one line assigns location.href, which would navigate away from the test page.
  • 2 — module-scope config that runs at import.
  • 2 — a hook path the boot patch always wins.

That second group is the only honest candidate for a coverage exclusion. I have not added one, because the scope decision was explicitly to keep writing tests instead — that is yours to reverse.

Two things I got wrong this round, both caught by assertions rather than luck

A synchronous try/finally around an async call restores the global the moment the promise is returned, long before the code under test writes anything. Both cache-failure tests passed their overrides and wrote to localStorage normally; the "nothing was cached" assertions are what caught it. The fix is an async helper that awaits inside the try.

And I shipped another assert.true(true, …) into a draft, for the cache-clear test. It now seeds a key, breaks removeItem, and asserts the key survived — the observable thing. That is the third vacuous assertion I have caught in my own work on this PR; writing the rule down has not stopped me producing them, only reading the diff has.

Where this leaves the PR

The suite is green at 2215 tests. Coverage is 98.95% and cannot move further under the flag-only directive. The next step is yours: either let me do the batched fix pass for the seven defects — which would take the gate to roughly 99.2% — or decide about the 32 uncoverable statements.

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.

2 participants