diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c96242ff..d0040aa2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,25 +11,35 @@ on: env: NODE_VERSION: 22.x +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} + +permissions: + contents: read + jobs: - build: + test: + name: Lint, Test & Coverage runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - name: Checkout + uses: actions/checkout@v4 - - name: Setup Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@v2 + - name: Setup pnpm + uses: pnpm/action-setup@v4 with: - node-version: ${{ env.NODE_VERSION }} + version: 11 - - name: Setup pnpm - uses: pnpm/action-setup@v2.0.1 + - name: Setup Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 with: - version: latest + node-version: ${{ env.NODE_VERSION }} + cache: pnpm - name: Install Dependencies - run: pnpm install + run: pnpm install --frozen-lockfile - name: Lint run: pnpm run lint @@ -37,25 +47,59 @@ jobs: - name: Build run: pnpm run build + - name: Coverage gate self-tests + run: node --test scripts/check-coverage.test.mjs + + - name: Test with coverage (full suite) + run: pnpm run coverage + + # Uploaded before the gate, and regardless of whether the suite passed, so + # Codecov still receives a report from a run with failing tests. A missing or + # unreadable report is a real problem and still fails the job. + - name: Upload coverage to Codecov + if: '!cancelled()' + uses: codecov/codecov-action@v5 + with: + files: coverage/lcov.info + flags: ember-core + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} + + # Kept so the raw report can be pulled down with `gh run download` and read + # for exactly which statements are uncovered. The gate output only gives a + # per-file percentage, which is not enough to aim at a specific branch. + - name: Upload coverage report + if: '!cancelled()' + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage/ + retention-days: 7 + + - name: Enforce 100% coverage gate + run: pnpm run coverage:check + npm_publish: - needs: build + needs: test runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') steps: - - uses: actions/checkout@v2 + - name: Checkout + uses: actions/checkout@v4 - - name: Setup Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@v2 + - name: Setup pnpm + uses: pnpm/action-setup@v4 with: - node-version: ${{ env.NODE_VERSION }} + version: 11 - - name: Setup pnpm - uses: pnpm/action-setup@v2.0.1 + - name: Setup Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 with: - version: latest + node-version: ${{ env.NODE_VERSION }} + cache: pnpm - name: Install Dependencies - run: pnpm install + run: pnpm install --frozen-lockfile - name: Build run: pnpm run build @@ -67,24 +111,29 @@ jobs: run: npm publish --access public github_publish: - needs: build + needs: test runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') + permissions: + contents: read + packages: write steps: - - uses: actions/checkout@v2 + - name: Checkout + uses: actions/checkout@v4 - - name: Setup Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@v2 + - name: Setup pnpm + uses: pnpm/action-setup@v4 with: - node-version: ${{ env.NODE_VERSION }} + version: 11 - - name: Setup pnpm - uses: pnpm/action-setup@v2.0.1 + - name: Setup Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 with: - version: latest + node-version: ${{ env.NODE_VERSION }} + cache: pnpm - name: Install Dependencies - run: pnpm install + run: pnpm install --frozen-lockfile - name: Build run: pnpm run build diff --git a/DEFECTS.md b/DEFECTS.md new file mode 100644 index 00000000..2cb1507f --- /dev/null +++ b/DEFECTS.md @@ -0,0 +1,405 @@ +# Defects found while building the test suite + +Every item here was found by writing a test against existing behaviour, and every one is +**pinned by a test that asserts what the code does today** — not what it was meant to do. +Nothing has been fixed. Each pin fails the moment someone changes the behaviour, which is +the point at which the decision gets made. + +Coverage at the time of writing (CI run 31245775305, commit `d6daf5d`): +**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 below, and none of them can be reached by any input. + +--- + +# ⛔ BLOCKING THE 100% COVERAGE GATE + +**The gate cannot go green until the items in this section are fixed.** They are not +"hard to test" — they are **unreachable by any input**, so no test can execute them. +**Seven defects, ten statements.** + +| # | file | lines | why no test can reach it | +|---|---|---|---| +| **B1** | `contracts/widget.js` | 239, 255 | `if (!this.options) { this.options = {}; }` — the constructor already assigns `this.options` on **both** of its paths, so the guard never fires | +| **B2** | `services/resource-action.js` | 209, 233 | `selected = [...spread]; if (!selected) return;` — a spread always produces an array and an array is always truthy | +| **B3** | `services/universe/menu-service.js` | 51 | `#wrapOnClickHandler` opens with `if (typeof onClick !== 'function') return onClick;` but its **only** caller already applies the same check | +| **B4** | `services/url-search-params.js` | 176 | `clear()`'s `return this;` is unreachable because the line above it assigns to a getter-only property and **throws every time** | +| **B5** | `utils/to-model.js` | 8, 10 | `ToModel.create()` has no owner, so `getOwner()` is `undefined` and `owner.lookup(...)` on the line above throws first | +| **B6** | `services/universe/hook-service.js` | 81 | `#getApplication`'s second priority is read only from a caller that runs **in the constructor** — before `setApplicationInstance` can have been called | +| **B7** | `services/filters.js` | 25 | `activeFilters` skips blank and managed params, but `getQueryParams()` has already dropped both — the `continue` can never run | + +Full write-ups: B1 → #19, B2 → #16, B3 → #21, B4 → #1, B5 → #23, B6 → #18, B7 → #28. + +### Fixing them is mechanical + +Six of the seven are a deletion — guards and a duplicated filter that can never fire. +B6 is a reordering: move `#initializeHookRegistry()` out of the constructor, or drop the +second priority. B4 and B5 need a real decision, because the surrounding method is broken +anyway (see #1 and #23). + +## ◻︎ Uncoverable, and *not* a defect — 32 statements + +These also cannot be covered, need no fix, and are the only honest candidates for an +exclusion if the gate must be green without touching production code. + +**`@tracked field = value` initialisers a constructor overwrites — 6 statements.** +`extension-manager:31`, `library/subject-custom-fields:15`, `contracts/base-contract:14`, +`abilities/dynamic:11`, `services/language:11-12`. A tracked field's initialiser only runs +if the property is **read before it is written**; each of these classes assigns the field +in its own constructor. An instrumentation artifact, not dead code. + +**Fallbacks that need a container-less service — 12 statements.** +`extension-manager:94,95,99,100,101,105`, `registry-service:90,94,491,494,541`, +`hook-service:91`. All `if (!owner)` / `if (!application)` paths. Ember always supplies an +owner to a service built through the container, and the one substitute that would work — +replacing `owner.application` — breaks the test run, because Ember's own +`ApplicationInstance#willDestroy` reads `this.application._unwatchInstance` during teardown. + +**Module-scope configuration — 2 statements.** `adapters/application:16` and +`services/fetch:23` both run at import time, long before a test can influence them. + +**Browser routes that cannot be faked safely — 10 statements.** `utils/download.js:11,126, +160-173`. Reaching the no-URL/`btoa`/FileReader route means deleting `window.URL`, which +stalls QUnit's reporter and aborts the whole run; and line 126 assigns `location.href`, +which would navigate away from the test page. Every other path through that file is covered. + +**A hook path the other one always wins — 2 statements.** `extension-manager:1096-1097`. +`#onEngineInstanceBuilt` schedules the engine-loaded hooks on `next()`, but the boot patch +runs first in every ordering a test can produce and clears them. + +## Live defects + +Reachable in production today, with user-visible consequences. + +### 1. `url-search-params` cannot write anything — the whole mutation API is inert + +```js +get urlParams() { + return new URLSearchParams(window.location.search); +} +``` + +A **fresh** object on every access. Everything downstream follows: + +| method | outcome | +|---|---| +| `setParam(k, v)` | mutates a throwaway, discarded on return — and still returns `this`, so it reads like a working chainable setter | +| `addParam` / `removeParam` | same | +| `clear()` | assigns to a getter-only property — **throws every time** *(blocker B4)* | +| `updateUrl()` | reads the same getter, so it writes the current URL back over itself and can never publish a change | + +Only `getParam`, `all` and `has` work. Any caller doing `setParam(...)` then `updateUrl()` +is a no-op; any caller of `clear()` gets an exception. + +*Pinned in* `tests/unit/services/url-search-params-branches-test.js` + +### 2. `crud.bulkAction`'s success message prints the count twice + +```js +`${count} ${pluralize(count, modelName)} were updated successfully.` +``` + +ember-inflector's two-argument `pluralize(count, word)` already returns `" "`, +so two selected orders produce **"2 2 Orders were updated successfully."** The +single-argument form is used correctly a few lines above. + +*Pinned in* `tests/unit/services/crud-bulk-action-confirm-test.js` + +### 3. The sandbox test key has never been sent — cross-package + +`currentUser.setOption` dasherizes before storing, so `dev-engine`'s +`setOption('testKey', …)` lands under `:test-key`. Both readers +(`adapters/application.js`, `services/fetch.js`) read `:testKey`. They never matched, +so `Access-Console-Sandbox-Key` was never sent. `sandbox` dasherizes to itself, which is why +that half worked and masked it. + +*Fixed earlier in this PR, before the flag-only directive.* + +### 4. `filters.getQueryParams` breaks on Ember's mapped query-param form + +A controller may rename a param — `queryParams: ['status', { category: 'cat' }]` — which is +the documented way to give a property a different name in the URL. `getQueryParams` hands +each entry straight to `get(controller, qp)`, which requires a string or number, so the +object entry fails Ember's assertion rather than being unwrapped. Any controller using the +mapped form cannot be filtered at all. + +*Pinned in* `tests/unit/services/filters-actions-test.js` + +### 5. `getMimeTypeFromResponse` needs a semicolon it usually will not get + +```js +const results = /(.*)?;/.exec(contentType); +``` + +The regex requires a trailing semicolon, so `Content-Type: text/csv` — a header with no +parameters, the common case — matches nothing and the mime type stays null. `download()` +then falls through to `getMimeType(fileName)`, which returns the **extension** (see #24), so +the browser is handed `'csv'` where `'text/csv'` was meant. + +*Pinned in* `tests/unit/services/fetch-upload-download-test.js` + +### 6. A class registered as a renderable component loses its key + +`registerRenderableComponent` computes the key correctly (`component.name` → `'OrderCard'`), +but `register` only stamps it when the value is an object: + +```js +if (typeof value === 'object' && value !== null) { + value._registryKey = key; +} +``` + +A class is a **function**, so the key is discarded, and `lookup` skips non-objects for the +same reason. The component is stored and nothing can retrieve it by name. + +*Pinned in* `tests/unit/services/universe/registry-service-branches-test.js` + +### 7. The universe facade calls six sub-service methods with the wrong arity + +None throw — the sub-services return empty collections for unknown lists — so these APIs +silently never work: + +| facade method | forwards as | effect | +|---|---|---| +| `registerInRegistry(name, key, value)` | `register(section, list, key, value)` | **value lost** | +| `getRegistry(name)` | `getRegistry(section, list)` | always empty | +| `lookupFromRegistry(name, key)` | `lookup(section, list, key)` | never matches | +| `getMenuItemsFromRegistry(name)` | as above | always empty | +| `getMenuPanelsFromRegistry(name)` | folds list into section as `name:panels` | always empty | +| `dashboardWidgets` getter | `getWidgets()` with no dashboard | always empty | + +`lookupMenuItemFromRegistry` is affected transitively. + +*Pinned in* `tests/unit/services/universe-delegation-test.js` + +### 8. `current-user.loadWhois` can never warn the user + +`loadWhois` wraps `lookupUserIp` in a try/catch whose catch warns *"Unable to detect your +location"* and builds a fallback. But `lookupUserIp` absorbs every failure itself and +**returns** `getFallbackWhois()` rather than rejecting. On a network failure the catch never +runs and the warning never fires. The fallback is written twice and the outer copy is +unreachable by the path it was written for. + +*(Reachable only if something after `lookupUserIp` throws — a storage write is the realistic +trigger, and that is what the pin uses.)* + +*Pinned in* `tests/unit/services/current-user-whois-fallback-test.js` + +### 9. `promiseCurrentUser` aborts and invalidates twice + +The no-user branch aborts the transition, invalidates, then throws — but the throw is inside +the same `try`, so its own `catch` runs the identical abort-and-invalidate again before +rethrowing. Every failed authentication does both twice, and the second invalidation takes a +different code path. + +*Pinned in* `tests/unit/services/session-flows-test.js` + +### 10. `getSessionSecondsRemaining` has its operands reversed + +`Math.round((now - date) / 1000)` — a session that has **not** expired reports a negative +number; an expired one reports positive. + +*Pinned in* `tests/unit/services/session-behaviour-test.js` + +### 11. `crud.import` cannot accept a file + +The default `uploadQueue` is a plain `[]`, but `queueFile`, `removeFile` and `confirm` all +call `pushObject` / `removeObject` / `objectAt` on it. With prototype extensions off (the +Octane default) all three throw. `A([])` fixes all three; callers who pass their own Ember +array get through. + +*Pinned in* `tests/unit/services/crud-import-queue-test.js` + +### 12. Helper instantiation depends on how the helper was written + +```js +typeof value !== 'function' || value.prototype +``` + +Never a boolean for a function. An arrow function has no prototype → `undefined` (not +instantiated). An equivalent `function` declaration has one → instantiated as if it were a +class. Two behaviourally identical helpers register differently based only on syntax. + +*Pinned in* `tests/unit/services/universe/registry-service-helpers-test.js` + +### 13. `chat.rememberOpenedChannel` discards the rest of the list + +```js +if (isArray(openedChats) && !openedChats.includes(id)) { append } +else { openedChats = [id] } +``` + +The `else` serves two unrelated cases — a corrupted cache, and the id already being present. +In the second it replaces the whole list with that one id. Latent today only because +`openChannel` returns early for an already-open channel. + +*Pinned in* `tests/unit/services/chat-recall-test.js` + +### 14. `subject-custom-fields.writeFieldValue` checks its guard too late + +```js +this.setFieldValue(value, customField); +const fieldId = typeof customField === 'string' ? customField : customField?.id; +if (!fieldId || !resource) return; +``` + +The optional chaining shows a null field was anticipated, but `setFieldValue` has already run +and reaches `customFieldOrId.id` unguarded — so the method throws one line before consulting +its own guard. + +*Pinned in* `tests/unit/library/subject-custom-fields-edges-test.js` + +### 15. The content-disposition header overrides a caller-supplied filename + +`getFilenameFromResponse(response, defaultFilename)` applies the header over the top whenever +one is present, so a caller cannot force a name for a response that supplies its own. Named +as a default, behaves as an override. + +*Pinned in* `tests/unit/services/fetch-upload-download-test.js` + +### 28. `filters.activeFilters` filters a list that is already filtered — **⛔ BLOCKER B7** + +```js +for (let queryParam in this.getQueryParams()) { + const value = get(queryParams, queryParam); + if (isBlank(value) || this.managedQueryParams.includes(queryParam)) { + continue; + } +``` + +`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` +can never run, and the filtering is duplicated one layer apart. + +*Pinned in* `tests/unit/final-branches-test.js` + +--- + +## Dead code + +Cannot execute. **B1–B6 above are drawn from this section** — the rest were reachable with a +contrived-but-legitimate input and are now covered. + +### 16. `resource-action`'s selection guards — **⛔ BLOCKER B2** + +```js +selected = [...(isArray(selected) ? selected : []), ...tableRows]; +if (!selected) return; +``` + +A spread always produces an array and an array is always truthy. `export` has the identical +pair. An empty selection is dispatched to `crud` as `[]` rather than skipped. + +### 17. `is-equal`'s arity guard + +`assert('… requires two property names …', params.length === 2)` can never fire: +`isEqual(propNameA, propNameB)` forwards both parameters unconditionally, so `params` is +always `['a', undefined]`, never `['a']`. A caller passing one name gets Ember's low-level +*"computed property key must be a string"* instead. + +*(Covered — the pin applies the decorator by hand. The decorator is separately non-functional: +applying it manually yields a working ComputedProperty, so only the installation is broken.)* + +### 18. `hook-service`'s second-priority application — **⛔ BLOCKER B6** + +`#getApplication` lists `this.applicationInstance` second, but its only caller runs in the +**constructor** — before `setApplicationInstance` can have been called — so the field is +always still its `null` default. + +### 19. `Widget`'s options guards — **⛔ BLOCKER B1** + +`withTitle` and `withRefreshInterval` each open with `if (!this.options) { this.options = {}; }`, +but the constructor assigns `this.options` on both of its paths. + +### 20. `auto-serialize`'s serializer dispatch reads the wrong property + +```js +const invoke = (context, method, ...params) => { + if (typeof context.method === 'function') { // not context[method] + return context.method(...params); + } + return null; +}; +``` + +It reads a property named *literally* `method`. `toJSON`, `toJson` and `serialize` are never +called on a related record — every one falls through to the recursive walk, which produces a +reasonable result, which is why it went unnoticed. Worse: a model that happens to carry a +property called `method` gets **that** invoked. + +*(Covered — the pin registers a model with a `method` property, which is the only way in.)* + +### 21. `menu-service.#wrapOnClickHandler`'s own guard — **⛔ BLOCKER B3** + +Opens with `if (typeof onClick !== 'function') return onClick;` but its only caller already +applies the same check before calling it. + +### 22. `auto-serialize`'s fleet/zone patches read a removed private path + +`get(model, '_internalModel.modelName')` is `undefined` in ember-data 4.12, so neither +`except.push('drivers')` nor `except.push('service_area')` can fire. They also push onto the +**caller's** array rather than a copy. + +*(Covered — the pin supplies the removed path with `Object.defineProperty`.)* + +### 23. `to-model.js` always throws — **⛔ BLOCKER B5** + +`ToModel.create()` has no owner, so `getOwner()` returns `undefined` and `owner.lookup(...)` +throws on every call. There are no call sites in this addon. + +--- + +## Latent and behavioural + +### 24. `MenuItem`'s constructor shadows its own `onClick` method + +`MenuItem` declares `onClick(handler)` as a chaining setter **and** its constructor assigns +`this.onClick = null`. Every instance shadows the method with a null field, so +`item.onClick(fn)` throws. Two live entry points hit it: `menu-service`'s `#normalizeMenuItem` +and `universe._createMenuItem`. Calling it off the prototype shows the method itself is +correct — only unreachable. + +### 25. The two account-menu getters do not separate the two menus + +`getOrganizationMenuItems` and `getUserMenuItems` are byte-identical and each return the whole +`console:account` registry unfiltered — so the organization menu lists user items and vice +versa. Registration deliberately namespaces the keys (`organization:` / `user:`), +and the registry already does prefix filtering, so the fix is available. + +### 26. Smaller pinned behaviours + +- `registerMenuItem` defaults `slug` to `'~'` rather than deriving it from the title, so an + item registered into a custom registry cannot be looked up by its title slug. +- `virtualRouteRedirect` collects query params and passes them as a **third** argument to a + two-parameter method, so they are silently dropped. +- `replace-table-row.js` — `if (rowIndex)` skips a match at index 0, and a missing row (`-1`) + is truthy and splices at `-1`. +- `get-mime-type` returns the **extension**, not a mime type, and `'doc'` matches before + `'docx'` due to key order. (This is what makes #5 user-visible.) +- `custom-fields-registry`'s `panel.create` merges `saveOptions` then spreads `...options` + last, which restores the raw value and drops both the merge and the refresh callback. +- `sameIds` is called with a key string where an options object is expected; the defaults + happen to match, so it is correct **by coincidence**. +- `loadSubjectCustomFields` swallows its own failure — a caller cannot distinguish a failed + load from a subject with no custom fields. +- `crud.modelName` is a **fallback**, not an override: a real model's `constructor.modelName` + always wins, so `crud.delete(record, { modelName: 'x' })` ignores the option. +- Always-true no-argument utils: `ison`, `reverse-point`, `is-function`, `hason-structure`, + `leaflet-points-from-coordinates`. +- `decorators/legacy-from-store.js` is byte-identical to `from-store.js`. +- `legacy-fetch-from`'s `null` default is shadowed by a native class field, so the "not loaded + yet" sentinel reads `undefined` rather than `null`. + +--- + +## Fixed earlier in this PR + +Corrected before the flag-only directive, each with a regression test: `is-waypoint-record` +(imported a nonexistent model and wedged the whole suite), `group-by`, `get-mime-type` and +`auto-serialize` array handling, `extract-coordinates` (set latitude where it meant longitude), +`context-component-callback` (crashed on `options: null`), the four host-coupled URL utils, +`app-cache.has()/doesntHave()` (always true/false), `notifications.serverError` (crashed on a +null error), `consoleUrl` (built `https:///path`), `get-model-name`'s list fallback, +`custom-fields-registry`'s dead `set`/`get` proxies and missing `initialize`, seven app-tree +re-exports that dropped every named export, and `fetch.normalizeModel` / `fetch.request` +reading `.firstObject` off plain arrays. diff --git a/addon/adapters/application.js b/addon/adapters/application.js index bd52c281..fe7aa2c5 100644 --- a/addon/adapters/application.js +++ b/addon/adapters/application.js @@ -88,7 +88,10 @@ export default class ApplicationAdapter extends RESTAdapter { const userId = this.session.data.authenticated.user; const userOptions = getUserOptions(); const isSandbox = get(userOptions, `${userId}:sandbox`) === true; - const testKey = get(userOptions, `${userId}:testKey`); + // `currentUser.setOption` dasherizes before storing, so the key written + // by `setOption('testKey', …)` is `:test-key`. Reading `testKey` + // never matched anything, so this header was never sent. + const testKey = get(userOptions, `${userId}:test-key`); let isAuthenticated = this.session.isAuthenticated; let { token } = this.session.data.authenticated; diff --git a/addon/services/app-cache.js b/addon/services/app-cache.js index 3a1ba75b..499fbeda 100644 --- a/addon/services/app-cache.js +++ b/addon/services/app-cache.js @@ -47,19 +47,25 @@ export default class AppCacheService extends Service { return value; } + // Reads storage directly rather than going through `get`, which substitutes + // its default for a missing value and so would report every key as present. + _isStored(key) { + return this.localCache.get(`${this.cachePrefix}${dasherize(key)}`) !== undefined; + } + @action has(key) { if (isArray(key)) { - return key.every((k) => this.get(k) !== undefined); + return key.every((k) => this._isStored(k)); } - return this.get(key) !== undefined; + return this._isStored(key); } @action doesntHave(key) { if (isArray(key)) { - return key.every((k) => this.get(k) === undefined); + return key.every((k) => !this._isStored(k)); } - return this.get(key) === undefined; + return !this._isStored(key); } } diff --git a/addon/services/chat.js b/addon/services/chat.js index 7f71beff..3df75ab7 100644 --- a/addon/services/chat.js +++ b/addon/services/chat.js @@ -17,7 +17,8 @@ export default class ChatService extends Service.extend(Evented) { if (this.openChannels.includes(chatChannelRecord)) { return; } - this.openChannels.pushObject(chatChannelRecord); + // Reassigned rather than mutated so the tracked property invalidates. + this.openChannels = [...this.openChannels, chatChannelRecord]; this.rememberOpenedChannel(chatChannelRecord); this.trigger('chat.opened', chatChannelRecord); } @@ -25,7 +26,7 @@ export default class ChatService extends Service.extend(Evented) { closeChannel(chatChannelRecord) { const index = this.openChannels.findIndex((_) => _.id === chatChannelRecord.id); if (index >= 0) { - this.openChannels.removeAt(index); + this.openChannels = this.openChannels.filter((_, i) => i !== index); this.trigger('chat.closed', chatChannelRecord); } this.forgetOpenedChannel(chatChannelRecord); @@ -34,7 +35,7 @@ export default class ChatService extends Service.extend(Evented) { rememberOpenedChannel(chatChannelRecord) { let openedChats = this.appCache.get('open-chats', []); if (isArray(openedChats) && !openedChats.includes(chatChannelRecord.id)) { - openedChats.pushObject(chatChannelRecord.id); + openedChats = [...openedChats, chatChannelRecord.id]; } else { openedChats = [chatChannelRecord.id]; } @@ -44,7 +45,7 @@ export default class ChatService extends Service.extend(Evented) { forgetOpenedChannel(chatChannelRecord) { let openedChats = this.appCache.get('open-chats', []); if (isArray(openedChats)) { - openedChats.removeObject(chatChannelRecord.id); + openedChats = openedChats.filter((id) => id !== chatChannelRecord.id); } else { openedChats = []; } diff --git a/addon/services/crud.js b/addon/services/crud.js index edb2ff53..c77552e6 100644 --- a/addon/services/crud.js +++ b/addon/services/crud.js @@ -138,7 +138,10 @@ export default class CrudService extends Service { count, modelName, remove: (model) => { - selected.removeObject(model); + // `selected` is a plain array whenever resolveModelName ran above, + // and a plain array has no removeObject. Filtering works for both + // shapes, and the setOption below publishes the new reference. + selected = selected.filter((item) => item !== model); this.modalsManager.setOption('selected', selected); }, confirm: async (modal) => { @@ -325,7 +328,7 @@ export default class CrudService extends Service { type: 'import-source', }, (uploadedFile) => { - uploadedFiles.pushObject(uploadedFile); + uploadedFiles.push(uploadedFile); resolve(uploadedFile); } ); diff --git a/addon/services/current-user.js b/addon/services/current-user.js index 028c13e7..c0b00b07 100644 --- a/addon/services/current-user.js +++ b/addon/services/current-user.js @@ -235,20 +235,20 @@ export default class CurrentUserService extends Service.extend(Evented) { // get direct applied permissions if (user.get('permissions')) { - permissions.pushObjects(user.get('permissions').toArray()); + permissions.push(...user.get('permissions').toArray()); } // get role permissions and role policies permissions if (user.get('role')) { if (user.get('role.permissions')) { - permissions.pushObjects(user.get('role.permissions').toArray()); + permissions.push(...user.get('role.permissions').toArray()); } if (user.get('role.policies')) { for (let i = 0; i < user.get('role.policies').length; i++) { const policy = user.get('role.policies').objectAt(i); if (policy.get('permissions')) { - permissions.pushObjects(policy.get('permissions').toArray()); + permissions.push(...policy.get('permissions').toArray()); } } } @@ -259,7 +259,7 @@ export default class CurrentUserService extends Service.extend(Evented) { for (let i = 0; i < user.get('policies').length; i++) { const policy = user.get('policies').objectAt(i); if (policy.get('permissions')) { - permissions.pushObjects(policy.get('permissions').toArray()); + permissions.push(...policy.get('permissions').toArray()); } } } @@ -310,7 +310,13 @@ export default class CurrentUserService extends Service.extend(Evented) { } hasOption(key) { - return this.getOption(key) !== undefined; + // Read storage directly rather than going through getOption: its + // `defaultValue = null` parameter applies whenever the stored value is + // undefined, so getOption can never return undefined and this was + // always true. + key = `${this.optionsPrefix}${dasherize(key)}`; + + return this.options.get(key) !== undefined; } filledOption(key) { diff --git a/addon/services/custom-fields-registry.js b/addon/services/custom-fields-registry.js index 811e06e0..9a4c4dea 100644 --- a/addon/services/custom-fields-registry.js +++ b/addon/services/custom-fields-registry.js @@ -13,6 +13,15 @@ export default class CustomFieldsRegistryService extends ResourceActionService { #cache = new WeakMap(); modelNamePath = 'label'; + constructor() { + super(...arguments); + // Without this the base class keeps `modelName = null`, so + // `createNewInstance` reaches `store.createRecord(undefined)` and every + // create path below throws. `modelNamePath` is already set by the class + // field above, and `initialize` preserves it. + this.initialize('custom-field'); + } + panel = { create: (attributes = {}, options = {}, saveOptions = {}) => { saveOptions = { ...(options?.saveOptions ?? {}), ...saveOptions }; @@ -127,8 +136,12 @@ export default class CustomFieldsRegistryService extends ResourceActionService { } // Optional proxy methods if you prefer service ergonomics: + // NOTE: `set`, `get` and `setProperties` shadow the EmberObject methods of + // the same name that this service inherits, and take a different first + // argument. Anything calling `registry.get('somePropertyName')` reaches + // this instead of the property lookup it expected. set(subject, fieldOrId, value, valueType) { - return this.forSubject(subject).set(fieldOrId, value, valueType); + return this.forSubject(subject).setField(fieldOrId, value, valueType); } setProperties(subject, entries) { @@ -136,7 +149,7 @@ export default class CustomFieldsRegistryService extends ResourceActionService { } get(subject, customFieldId) { - return this.forSubject(subject).get(customFieldId); + return this.forSubject(subject).getValue(customFieldId); } getProperties(subject) { diff --git a/addon/services/fetch.js b/addon/services/fetch.js index 7809008c..206ad060 100644 --- a/addon/services/fetch.js +++ b/addon/services/fetch.js @@ -68,7 +68,9 @@ export default class FetchService extends Service { const userId = this.session.data.authenticated.user; const userOptions = getUserOptions(); const isSandbox = get(userOptions, `${userId}:sandbox`) === true; - const testKey = get(userOptions, `${userId}:testKey`); + // See the note in adapters/application.js: `setOption` dasherizes, so + // the stored key is `:test-key`, not `:testKey`. + const testKey = get(userOptions, `${userId}:test-key`); headers['Content-Type'] = 'application/json'; @@ -178,7 +180,10 @@ export default class FetchService extends Service { normalizeModel(payload, modelType = null) { if (modelType === null) { const modelTypeKeys = Object.keys(payload); - modelType = modelTypeKeys.length ? modelTypeKeys.firstObject : false; + // `Object.keys` returns a plain array, which has no `firstObject` + // once prototype extensions are off — this silently yielded + // undefined, so the payload was returned unnormalized. + modelType = modelTypeKeys.length ? modelTypeKeys[0] : false; } if (typeof modelType !== 'string') { @@ -314,7 +319,10 @@ export default class FetchService extends Service { } if (isArray(response.json.errors)) { - return reject(new Error(response.json.errors ? response.json.errors.firstObject : response.statusText)); + // Decoded JSON is a plain array, so `firstObject` was + // undefined and every such error surfaced as the + // literal string "undefined". + return reject(new Error(response.json.errors[0] ?? response.statusText)); } if (response.json.error && typeof response.json.error === 'string') { @@ -693,11 +701,14 @@ export default class FetchService extends Service { const serialized = []; for (let i = 0; i < configs.length; i++) { - const config = configs.objectAt(i); + // `configs` is decoded JSON and `serialized` is a plain + // array literal; neither has the Ember array methods + // once prototype extensions are off. + const config = configs[i]; const normalizedConfig = this.store.normalize('order-config', config); const serializedConfig = this.store.push(normalizedConfig); - serialized.pushObject(serializedConfig); + serialized.push(serializedConfig); } resolve(serialized); diff --git a/addon/services/filters.js b/addon/services/filters.js index 12032db8..891a03f8 100644 --- a/addon/services/filters.js +++ b/addon/services/filters.js @@ -25,7 +25,7 @@ export default class FiltersService extends Service { continue; } - activeQueryParams.pushObject({ queryParam, label: queryParam, value }); + activeQueryParams.push({ queryParam, label: queryParam, value }); } return activeQueryParams; @@ -162,7 +162,7 @@ export default class FiltersService extends Service { if (isArray(controllerQueryParams)) { for (let i = 0; i < controllerQueryParams.length; i++) { - const qp = controllerQueryParams.objectAt(i); + const qp = controllerQueryParams[i]; if (this.managedQueryParams.includes(qp)) { continue; @@ -178,7 +178,7 @@ export default class FiltersService extends Service { const currentRouteQueryParams = Object.keys(currentRoute.queryParams); for (let i = 0; i < currentRouteQueryParams.length; i++) { - const queryParam = currentRouteQueryParams.objectAt(i); + const queryParam = currentRouteQueryParams[i]; const value = this.urlSearchParams.get(queryParam); if (this.managedQueryParams.includes(queryParam)) { diff --git a/addon/services/language.js b/addon/services/language.js index da0d3603..7fa9b41f 100644 --- a/addon/services/language.js +++ b/addon/services/language.js @@ -81,7 +81,7 @@ export default class LanguageService extends Service { const localeMap = {}; for (let i = 0; i < this.locales.length; i++) { - const locale = this.locales.objectAt(i); + const locale = this.locales[i]; localeMap[locale] = this._findCountryDataForLocale(locale); } diff --git a/addon/services/legacy-universe.js b/addon/services/legacy-universe.js deleted file mode 100644 index 4d48c0f3..00000000 --- a/addon/services/legacy-universe.js +++ /dev/null @@ -1,1976 +0,0 @@ -import Service from '@ember/service'; -import Evented from '@ember/object/evented'; -import { tracked } from '@glimmer/tracking'; -import { inject as service } from '@ember/service'; -import { computed, action } from '@ember/object'; -import { isBlank } from '@ember/utils'; -import { A, isArray } from '@ember/array'; -import { later } from '@ember/runloop'; -import { dasherize, camelize } from '@ember/string'; -import { pluralize } from 'ember-inflector'; -import { getOwner } from '@ember/application'; -import { assert, debug, warn } from '@ember/debug'; -import RSVP from 'rsvp'; -import loadInstalledExtensions from '../utils/load-installed-extensions'; -import loadExtensions from '../utils/load-extensions'; -import getWithDefault from '../utils/get-with-default'; -import config from 'ember-get-config'; - -export default class LegacyUniverseService extends Service.extend(Evented) { - @service router; - @service intl; - @service urlSearchParams; - @tracked applicationInstance; - @tracked enginesBooted = false; - @tracked bootedExtensions = A([]); - @tracked headerMenuItems = A([]); - @tracked organizationMenuItems = A([]); - @tracked userMenuItems = A([]); - @tracked consoleAdminRegistry = { - menuItems: A([]), - menuPanels: A([]), - }; - @tracked consoleAccountRegistry = { - menuItems: A([]), - menuPanels: A([]), - }; - @tracked consoleSettingsRegistry = { - menuItems: A([]), - menuPanels: A([]), - }; - @tracked dashboardWidgets = { - defaultWidgets: A([]), - widgets: A([]), - }; - @tracked hooks = {}; - @tracked bootCallbacks = A([]); - @tracked initialLocation = { ...window.location }; - - /** - * Computed property that returns all administrative menu items. - * - * @computed adminMenuItems - * @public - * @readonly - * @memberof UniverseService - * @returns {Array} Array of administrative menu items - */ - @computed('consoleAdminRegistry.menuItems.[]') get adminMenuItems() { - return this.consoleAdminRegistry.menuItems; - } - - /** - * Computed property that returns all administrative menu panels. - * - * @computed adminMenuPanels - * @public - * @readonly - * @memberof UniverseService - * @returns {Array} Array of administrative menu panels - */ - @computed('consoleAdminRegistry.menuPanels.[]') get adminMenuPanels() { - return this.consoleAdminRegistry.menuPanels; - } - - /** - * Computed property that returns all settings menu items. - * - * @computed settingsMenuItems - * @public - * @readonly - * @memberof UniverseService - * @returns {Array} Array of administrative menu items - */ - @computed('consoleSettingsRegistry.menuItems.[]') get settingsMenuItems() { - return this.consoleSettingsRegistry.menuItems; - } - - /** - * Computed property that returns all settings menu panels. - * - * @computed settingsMenuPanels - * @public - * @readonly - * @memberof UniverseService - * @returns {Array} Array of administrative menu panels - */ - @computed('consoleSettingsRegistry.menuPanels.[]') get settingsMenuPanels() { - return this.consoleSettingsRegistry.menuPanels; - } - - /** - * Transitions to a given route within a specified Ember engine. - * - * This action dynamically retrieves the specified engine's instance and its configuration to prepend the - * engine's route prefix to the provided route. If the engine instance or its route prefix is not found, - * it falls back to transitioning to the route without the prefix. - * - * @param {string} engineName - The name of the Ember engine. - * @param {string} route - The route to transition to within the engine. - * @param {...any} args - Additional arguments to pass to the router's transitionTo method. - * @returns {Promise} A Promise that resolves with the result of the router's transitionTo method. - * - * @example - * // Transitions to the 'management.fleets.index.new' route within the '@fleetbase/fleet-ops' engine. - * this.transitionToEngineRoute('@fleetbase/fleet-ops', 'management.fleets.index.new'); - */ - @action transitionToEngineRoute(engineName, route, ...args) { - const engineInstance = this.getEngineInstance(engineName); - - if (engineInstance) { - const config = engineInstance.resolveRegistration('config:environment'); - - if (config) { - let mountedEngineRoutePrefix = config.mountedEngineRoutePrefix; - - if (!mountedEngineRoutePrefix) { - mountedEngineRoutePrefix = this._mountPathFromEngineName(engineName); - } - - if (!mountedEngineRoutePrefix.endsWith('.')) { - mountedEngineRoutePrefix = mountedEngineRoutePrefix + '.'; - } - - return this.router.transitionTo(`${mountedEngineRoutePrefix}${route}`, ...args); - } - } - - return this.router.transitionTo(route, ...args); - } - - /** - * Initialize the universe service. - * - * @memberof UniverseService - */ - initialize() { - this.initialLocation = { ...window.location }; - this.trigger('init', this); - } - - /** - * Sets the application instance. - * - * @param {ApplicationInstance} - The application instance object. - * @return {void} - */ - setApplicationInstance(instance) { - this.applicationInstance = instance; - } - - /** - * Retrieves the application instance. - * - * @returns {ApplicationInstance} - The application instance object. - */ - getApplicationInstance() { - return this.applicationInstance; - } - - /** - * Retrieves the mount point of a specified engine by its name. - - * @param {string} engineName - The name of the engine for which to get the mount point. - * @returns {string|null} The mount point of the engine or null if not found. - */ - getEngineMountPoint(engineName) { - const engineInstance = this.getEngineInstance(engineName); - return this._getMountPointFromEngineInstance(engineInstance); - } - - /** - * Determines the mount point from an engine instance by reading its configuration. - - * @param {object} engineInstance - The instance of the engine. - * @returns {string|null} The resolved mount point or null if the instance is undefined or the configuration is not set. - * @private - */ - _getMountPointFromEngineInstance(engineInstance) { - if (engineInstance) { - const config = engineInstance.resolveRegistration('config:environment'); - - if (config) { - let engineName = config.modulePrefix; - let mountedEngineRoutePrefix = config.mountedEngineRoutePrefix; - - if (!mountedEngineRoutePrefix) { - mountedEngineRoutePrefix = this._mountPathFromEngineName(engineName); - } - - if (!mountedEngineRoutePrefix.endsWith('.')) { - mountedEngineRoutePrefix = mountedEngineRoutePrefix + '.'; - } - - return mountedEngineRoutePrefix; - } - } - - return null; - } - - /** - * Extracts and formats the mount path from a given engine name. - * - * This function takes an engine name in the format '@scope/engine-name', - * extracts the 'engine-name' part, removes the '-engine' suffix if present, - * and formats it into a string that represents a console path. - * - * @param {string} engineName - The full name of the engine, typically in the format '@scope/engine-name'. - * @returns {string} A string representing the console path derived from the engine name. - * @example - * // returns 'console.some' - * _mountPathFromEngineName('@fleetbase/some-engine'); - */ - _mountPathFromEngineName(engineName) { - let engineNameSegments = engineName.split('/'); - let mountName = engineNameSegments[1]; - - if (typeof mountName !== 'string') { - mountName = engineNameSegments[0]; - } - - const mountPath = mountName.replace('-engine', ''); - return `console.${mountPath}`; - } - - /** - * Refreshes the current route. - * - * This action is a simple wrapper around the router's refresh method. It can be used to re-run the - * model hooks and reset the controller properties on the current route, effectively reloading the route. - * This is particularly useful in scenarios where the route needs to be reloaded due to changes in - * state or data. - * - * @returns {Promise} A Promise that resolves with the result of the router's refresh method. - * - * @example - * // To refresh the current route - * this.refreshRoute(); - */ - @action refreshRoute() { - return this.router.refresh(); - } - - /** - * Action to transition to a specified route based on the provided menu item. - * - * The route transition will include the 'slug' as a dynamic segment, and - * the 'view' as an optional dynamic segment if it is defined. - * - * @action - * @memberof UniverseService - * @param {string} route - The target route to transition to. - * @param {Object} menuItem - The menu item containing the transition parameters. - * @param {string} menuItem.slug - The 'slug' dynamic segment for the route. - * @param {string} [menuItem.view] - The 'view' dynamic segment for the route, if applicable. - * - * @returns {Transition} Returns a Transition object representing the transition to the route. - */ - @action transitionMenuItem(route, menuItem) { - const { slug, view, section } = menuItem; - - if (section && slug && view) { - return this.router.transitionTo(route, section, slug, { queryParams: { view } }); - } - - if (section && slug) { - return this.router.transitionTo(route, section, slug); - } - - if (slug && view) { - return this.router.transitionTo(route, slug, { queryParams: { view } }); - } - - return this.router.transitionTo(route, slug); - } - - /** - * Redirects to a virtual route if a corresponding menu item exists based on the current URL slug. - * - * This asynchronous function checks whether a virtual route exists by extracting the slug from the current - * window's pathname and looking up a matching menu item in a specified registry. If a matching menu item - * is found, it initiates a transition to the given route associated with that menu item and returns the - * transition promise. - * - * @async - * - * @param {Object} transition - The current transition object from the router. - * Used to retrieve additional information required for the menu item lookup. - * @param {string} registryName - The name of the registry to search for the menu item. - * This registry should contain menu items mapped by their slugs. - * @param {string} route - The name of the route to transition to if the menu item is found. - * This is typically the route associated with displaying the menu item's content. - * - * @returns {Promise|undefined} - Returns a promise that resolves when the route transition completes - * if a matching menu item is found. If no matching menu item is found, the function returns undefined. - * - */ - async virtualRouteRedirect(transition, registryName, route, options = {}) { - const view = this.getViewFromTransition(transition); - const slug = window.location.pathname.replace('/', ''); - const queryParams = this.urlSearchParams.all(); - const menuItem = await this.lookupMenuItemFromRegistry(registryName, slug, view); - if (menuItem && transition.from === null) { - return this.transitionMenuItem(route, menuItem, { queryParams }).then((transition) => { - if (options && options.restoreQueryParams === true) { - this.urlSearchParams.setParamsToCurrentUrl(queryParams); - } - - return transition; - }); - } - } - - /** - * @action - * Creates a new registry with the given name and options. - - * @memberof UniverseService - * @param {string} registryName - The name of the registry to create. - * @param {Object} [options={}] - Optional settings for the registry. - * @param {Array} [options.menuItems=[]] - An array of menu items for the registry. - * @param {Array} [options.menuPanel=[]] - An array of menu panels for the registry. - * - * @fires registry.created - Event triggered when a new registry is created. - * - * @returns {UniverseService} Returns the current UniverseService for chaining. - * - * @example - * createRegistry('myRegistry', { menuItems: ['item1', 'item2'], menuPanel: ['panel1', 'panel2'] }); - */ - @action createRegistry(registryName, options = {}) { - const internalRegistryName = this.createInternalRegistryName(registryName); - - if (this[internalRegistryName] == undefined) { - this[internalRegistryName] = { - name: registryName, - menuItems: [], - menuPanels: [], - renderableComponents: [], - ...options, - }; - } else { - this[internalRegistryName] = { - ...this[internalRegistryName], - ...options, - }; - } - - // trigger registry created event - this.trigger('registry.created', this[internalRegistryName]); - - return this; - } - - /** - * Creates multiple registries from a given array of registries. Each registry can be either a string or an array. - * If a registry is an array, it expects two elements: the registry name (string) and registry options (object). - * If a registry is a string, only the registry name is needed. - * - * The function iterates over each element in the `registries` array and creates a registry using the `createRegistry` method. - * It supports two types of registry definitions: - * 1. Array format: [registryName, registryOptions] - where registryOptions is an optional object. - * 2. String format: "registryName" - in this case, only the name is provided and the registry is created with default options. - * - * @param {Array} registries - An array of registries to be created. Each element can be either a string or an array. - * @action - * @memberof YourComponentOrClassName - */ - @action createRegistries(registries = []) { - if (!isArray(registries)) { - throw new Error('`createRegistries()` method must take an array.'); - } - - for (let i = 0; i < registries.length; i++) { - const registry = registries[i]; - - if (isArray(registry) && registry.length === 2) { - let registryName = registry[0]; - let registryOptions = registry[1] ?? {}; - - this.createRegistry(registryName, registryOptions); - continue; - } - - if (typeof registry === 'string') { - this.createRegistry(registry); - } - } - } - - /** - * Triggers an event on for a universe registry. - * - * @memberof UniverseService - * @method createRegistryEvent - * @param {string} registryName - The name of the registry to trigger the event on. - * @param {string} event - The name of the event to trigger. - * @param {...*} params - Additional parameters to pass to the event handler. - */ - @action createRegistryEvent(registryName, event, ...params) { - this.trigger(`${registryName}.${event}`, ...params); - } - - /** - * @action - * Retrieves the entire registry with the given name. - * - * @memberof UniverseService - * @param {string} registryName - The name of the registry to retrieve. - * - * @returns {Object|null} Returns the registry object if it exists; otherwise, returns null. - * - * @example - * const myRegistry = getRegistry('myRegistry'); - */ - @action getRegistry(registryName) { - const internalRegistryName = this.createInternalRegistryName(registryName); - const registry = this[internalRegistryName]; - - if (!isBlank(registry)) { - return registry; - } - - return null; - } - - /** - * Looks up a registry by its name and returns it as a Promise. - * - * @memberof UniverseService - * @param {string} registryName - The name of the registry to look up. - * - * @returns {Promise} A Promise that resolves to the registry object if it exists; otherwise, rejects with null. - * - * @example - * lookupRegistry('myRegistry') - * .then((registry) => { - * // Do something with the registry - * }) - * .catch((error) => { - * // Handle the error or absence of the registry - * }); - */ - lookupRegistry(registryName) { - const internalRegistryName = this.createInternalRegistryName(registryName); - const registry = this[internalRegistryName]; - - return new Promise((resolve, reject) => { - if (!isBlank(registry)) { - return resolve(registry); - } - - later( - this, - () => { - if (!isBlank(registry)) { - return resolve(registry); - } - }, - 100 - ); - - reject(null); - }); - } - - /** - * @action - * Retrieves the menu items from a registry with the given name. - * - * @memberof UniverseService - * @param {string} registryName - The name of the registry to retrieve menu items from. - * - * @returns {Array} Returns an array of menu items if the registry exists and has menu items; otherwise, returns an empty array. - * - * @example - * const items = getMenuItemsFromRegistry('myRegistry'); - */ - @action getMenuItemsFromRegistry(registryName) { - const internalRegistryName = this.createInternalRegistryName(registryName); - const registry = this[internalRegistryName]; - - if (!isBlank(registry) && isArray(registry.menuItems)) { - return registry.menuItems; - } - - return []; - } - - /** - * @action - * Retrieves the menu panels from a registry with the given name. - * - * @memberof UniverseService - * @param {string} registryName - The name of the registry to retrieve menu panels from. - * - * @returns {Array} Returns an array of menu panels if the registry exists and has menu panels; otherwise, returns an empty array. - * - * @example - * const panels = getMenuPanelsFromRegistry('myRegistry'); - */ - @action getMenuPanelsFromRegistry(registryName) { - const internalRegistryName = this.createInternalRegistryName(registryName); - const registry = this[internalRegistryName]; - - if (!isBlank(registry) && isArray(registry.menuPanels)) { - return registry.menuPanels; - } - - return []; - } - - /** - * Retrieves renderable components from a specified registry. - * This action checks the internal registry, identified by the given registry name, - * and returns the 'renderableComponents' if they are present and are an array. - * - * @action - * @param {string} registryName - The name of the registry to retrieve components from. - * @returns {Array} An array of renderable components from the specified registry, or an empty array if none found. - */ - @action getRenderableComponentsFromRegistry(registryName) { - const internalRegistryName = this.createInternalRegistryName(registryName); - const registry = this[internalRegistryName]; - - if (!isBlank(registry) && isArray(registry.renderableComponents)) { - return registry.renderableComponents; - } - - return []; - } - - /** - * Loads a component from the specified registry based on a given slug and view. - * - * @param {string} registryName - The name of the registry where the component is located. - * @param {string} slug - The slug of the menu item. - * @param {string} [view=null] - The view of the menu item, if applicable. - * - * @returns {Promise} Returns a Promise that resolves with the component if it is found, or null. - */ - loadComponentFromRegistry(registryName, slug, view = null) { - const internalRegistryName = this.createInternalRegistryName(registryName); - const registry = this[internalRegistryName]; - - return new Promise((resolve) => { - let component = null; - - if (isBlank(registry)) { - return resolve(component); - } - - // check menu items first - for (let i = 0; i < registry.menuItems.length; i++) { - const menuItem = registry.menuItems[i]; - - // no view hack - if (menuItem && menuItem.slug === slug && menuItem.view === null && view === 'index') { - component = menuItem.component; - break; - } - - if (menuItem && menuItem.slug === slug && menuItem.view === view) { - component = menuItem.component; - break; - } - } - - // check menu panels - for (let i = 0; i < registry.menuPanels.length; i++) { - const menuPanel = registry.menuPanels[i]; - - if (menuPanel && isArray(menuPanel.items)) { - for (let j = 0; j < menuPanel.items.length; j++) { - const menuItem = menuPanel.items[j]; - - // no view hack - if (menuItem && menuItem.slug === slug && menuItem.view === null && view === 'index') { - component = menuItem.component; - break; - } - - if (menuItem && menuItem.slug === slug && menuItem.view === view) { - component = menuItem.component; - break; - } - } - } - } - - resolve(component); - }); - } - - /** - * Looks up a menu item from the specified registry based on a given slug and view. - * - * @param {string} registryName - The name of the registry where the menu item is located. - * @param {string} slug - The slug of the menu item. - * @param {string} [view=null] - The view of the menu item, if applicable. - * @param {string} [section=null] - The section of the menu item, if applicable. - * - * @returns {Promise} Returns a Promise that resolves with the menu item if it is found, or null. - */ - lookupMenuItemFromRegistry(registryName, slug, view = null, section = null) { - const internalRegistryName = this.createInternalRegistryName(registryName); - const registry = this[internalRegistryName]; - - return new Promise((resolve) => { - let foundMenuItem = null; - - if (isBlank(registry)) { - return resolve(foundMenuItem); - } - - // check menu items first - for (let i = 0; i < registry.menuItems.length; i++) { - const menuItem = registry.menuItems[i]; - - if (menuItem && menuItem.slug === slug && menuItem.section === section && menuItem.view === view) { - foundMenuItem = menuItem; - break; - } - - if (menuItem && menuItem.slug === slug && menuItem.view === view) { - foundMenuItem = menuItem; - break; - } - } - - // check menu panels - for (let i = 0; i < registry.menuPanels.length; i++) { - const menuPanel = registry.menuPanels[i]; - - if (menuPanel && isArray(menuPanel.items)) { - for (let j = 0; j < menuPanel.items.length; j++) { - const menuItem = menuPanel.items[j]; - - if (menuItem && menuItem.slug === slug && menuItem.section === section && menuItem.view === view) { - foundMenuItem = menuItem; - break; - } - - if (menuItem && menuItem.slug === slug && menuItem.view === view) { - foundMenuItem = menuItem; - break; - } - } - } - } - - resolve(foundMenuItem); - }); - } - - /** - * Gets the view param from the transition object. - * - * @param {Transition} transition - * @return {String|Null} - * @memberof UniverseService - */ - getViewFromTransition(transition) { - const queryParams = transition.to.queryParams ?? { view: null }; - return queryParams.view; - } - - /** - * Creates an internal registry name for hooks based on a given registry name. - * The registry name is transformed to camel case and appended with 'Hooks'. - * Non-alphanumeric characters are replaced with hyphens. - * - * @param {string} registryName - The name of the registry for which to create an internal hook registry name. - * @returns {string} - The internal hook registry name, formatted as camel case with 'Hooks' appended. - */ - createInternalHookRegistryName(registryName) { - return `${camelize(registryName.replace(/[^a-zA-Z0-9]/g, '-'))}Hooks`; - } - - /** - * Registers a hook function under a specified registry name. - * The hook is stored in an internal registry, and its hash is computed for identification. - * If the hook is already registered, it is appended to the existing list of hooks. - * - * @param {string} registryName - The name of the registry where the hook should be registered. - * @param {Function} hook - The hook function to be registered. - */ - registerHook(registryName, hook) { - if (typeof hook !== 'function') { - throw new Error('The hook must be a function.'); - } - - // no duplicate hooks - if (this.didRegisterHook(registryName, hook)) { - return; - } - - const internalHookRegistryName = this.createInternalHookRegistryName(registryName); - const hookRegistry = this.hooks[internalHookRegistryName] || []; - hookRegistry.pushObject({ id: this._createHashFromFunctionDefinition(hook), hook }); - - this.hooks[internalHookRegistryName] = hookRegistry; - } - - /** - * Checks if a hook was registered already. - * - * @param {String} registryName - * @param {Function} hook - * @return {Boolean} - * @memberof UniverseService - */ - didRegisterHook(registryName, hook) { - const hooks = this.getHooks(registryName); - const hookId = this._createHashFromFunctionDefinition(hook); - return isArray(hooks) && hooks.some((h) => h.id === hookId); - } - - /** - * Retrieves the list of hooks registered under a specified registry name. - * If no hooks are registered, returns an empty array. - * - * @param {string} registryName - The name of the registry for which to retrieve hooks. - * @returns {Array} - An array of hook objects registered under the specified registry name. - * Each object contains an `id` and a `hook` function. - */ - getHooks(registryName) { - const internalHookRegistryName = this.createInternalHookRegistryName(registryName); - return this.hooks[internalHookRegistryName] ?? []; - } - - /** - * Executes all hooks registered under a specified registry name with the given parameters. - * Each hook is called with the provided parameters. - * - * @param {string} registryName - The name of the registry under which hooks should be executed. - * @param {...*} params - The parameters to pass to each hook function. - */ - executeHooks(registryName, ...params) { - const hooks = this.getHooks(registryName); - hooks.forEach(({ hook }) => { - try { - hook(...params); - } catch (error) { - debug(`Error executing hook: ${error}`); - } - }); - } - - /** - * Calls all hooks registered under a specified registry name with the given parameters. - * This is an alias for `executeHooks` for consistency in naming. - * - * @param {string} registryName - The name of the registry under which hooks should be called. - * @param {...*} params - The parameters to pass to each hook function. - */ - callHooks(registryName, ...params) { - this.executeHooks(registryName, ...params); - } - - /** - * Calls a specific hook identified by its ID under a specified registry name with the given parameters. - * Only the hook with the matching ID is executed. - * - * @param {string} registryName - The name of the registry where the hook is registered. - * @param {string} hookId - The unique identifier of the hook to be called. - * @param {...*} params - The parameters to pass to the hook function. - */ - callHook(registryName, hookId, ...params) { - const hooks = this.getHooks(registryName); - const hook = hooks.find((h) => h.id === hookId); - - if (hook) { - try { - hook.hook(...params); - } catch (error) { - debug(`Error executing hook: ${error}`); - } - } else { - warn(`Hook with ID ${hookId} not found.`); - } - } - - /** - * Registers a renderable component or an array of components into a specified registry. - * If a single component is provided, it is registered directly. - * If an array of components is provided, each component in the array is registered individually. - * The component is also registered into the specified engine. - * - * @param {string} engineName - The name of the engine to register the component(s) into. - * @param {string} registryName - The registry name where the component(s) should be registered. - * @param {Object|Array} component - The component or array of components to register. - */ - registerRenderableComponent(engineName, registryName, component) { - if (isArray(component)) { - component.forEach((_) => this.registerRenderableComponent(registryName, _)); - return; - } - - // register component to engine - this.registerComponentInEngine(engineName, component); - - // register to registry - const internalRegistryName = this.createInternalRegistryName(registryName); - if (!isBlank(this[internalRegistryName])) { - if (isArray(this[internalRegistryName].renderableComponents)) { - this[internalRegistryName].renderableComponents.pushObject(component); - } else { - this[internalRegistryName].renderableComponents = [component]; - } - } else { - this.createRegistry(registryName); - return this.registerRenderableComponent(...arguments); - } - } - - /** - * Registers a new menu panel in a registry. - * - * @method registerMenuPanel - * @public - * @memberof UniverseService - * @param {String} registryName The name of the registry to use - * @param {String} title The title of the panel - * @param {Array} items The items of the panel - * @param {Object} options Additional options for the panel - */ - registerMenuPanel(registryName, title, items = [], options = {}) { - const internalRegistryName = this.createInternalRegistryName(registryName); - const intl = this._getOption(options, 'intl', null); - const open = this._getOption(options, 'open', true); - const slug = this._getOption(options, 'slug', dasherize(title)); - const menuPanel = { - intl, - title, - open, - items: items.map(({ title, route, ...options }) => { - options.slug = slug; - options.view = dasherize(title); - - return this._createMenuItem(title, route, options); - }), - }; - - // register menu panel - this[internalRegistryName].menuPanels.pushObject(menuPanel); - - // trigger menu panel registered event - this.trigger('menuPanel.registered', menuPanel, this[internalRegistryName]); - } - - /** - * Registers a new menu item in a registry. - * - * @method registerMenuItem - * @public - * @memberof UniverseService - * @param {String} registryName The name of the registry to use - * @param {String} title The title of the item - * @param {String} route The route of the item - * @param {Object} options Additional options for the item - */ - registerMenuItem(registryName, title, options = {}) { - const internalRegistryName = this.createInternalRegistryName(registryName); - const route = this._getOption(options, 'route', `console.${dasherize(registryName)}.virtual`); - options.slug = this._getOption(options, 'slug', '~'); - options.view = this._getOption(options, 'view', dasherize(title)); - - // not really a fan of assumptions, but will do this for the timebeing till anyone complains - if (options.slug === options.view) { - options.view = null; - } - - // register component if applicable - this.registerMenuItemComponentToEngine(options); - - // create menu item - const menuItem = this._createMenuItem(title, route, options); - - // register menu item - if (!this[internalRegistryName]) { - this[internalRegistryName] = { - menuItems: [], - menuPanels: [], - }; - } - - // register menu item - this[internalRegistryName].menuItems.pushObject(menuItem); - - // trigger menu panel registered event - this.trigger('menuItem.registered', menuItem, this[internalRegistryName]); - } - - /** - * Register multiple menu items to a registry. - * - * @param {String} registryName - * @param {Array} [menuItems=[]] - * @memberof UniverseService - */ - registerMenuItems(registryName, menuItems = []) { - for (let i = 0; i < menuItems.length; i++) { - const menuItem = menuItems[i]; - if (menuItem && menuItem.title) { - if (menuItem.options) { - this.registerMenuItem(registryName, menuItem.title, menuItem.options); - } else { - this.registerMenuItem(registryName, menuItem.title, menuItem); - } - } - } - } - - /** - * Registers a menu item's component to one or multiple engines. - * - * @method registerMenuItemComponentToEngine - * @public - * @memberof UniverseService - * @param {Object} options - An object containing the following properties: - * - `registerComponentToEngine`: A string or an array of strings representing the engine names where the component should be registered. - * - `component`: The component class to register, which should have a 'name' property. - */ - registerMenuItemComponentToEngine(options) { - // Register component if applicable - if (typeof options.registerComponentToEngine === 'string') { - this.registerComponentInEngine(options.registerComponentToEngine, options.component); - } - - // register to multiple engines - if (isArray(options.registerComponentToEngine)) { - for (let i = 0; i < options.registerComponentInEngine.length; i++) { - const engineName = options.registerComponentInEngine.objectAt(i); - - if (typeof engineName === 'string') { - this.registerComponentInEngine(engineName, options.component); - } - } - } - } - - /** - * Registers a new administrative menu panel. - * - * @method registerAdminMenuPanel - * @public - * @memberof UniverseService - * @param {String} title The title of the panel - * @param {Array} items The items of the panel - * @param {Object} options Additional options for the panel - */ - registerAdminMenuPanel(title, items = [], options = {}) { - options.section = this._getOption(options, 'section', 'admin'); - this.registerMenuPanel('console:admin', title, items, options); - } - - /** - * Registers a new administrative menu item. - * - * @method registerAdminMenuItem - * @public - * @memberof UniverseService - * @param {String} title The title of the item - * @param {Object} options Additional options for the item - */ - registerAdminMenuItem(title, options = {}) { - this.registerMenuItem('console:admin', title, options); - } - - /** - * Registers a new settings menu panel. - * - * @method registerSettingsMenuPanel - * @public - * @memberof UniverseService - * @param {String} title The title of the panel - * @param {Array} items The items of the panel - * @param {Object} options Additional options for the panel - */ - registerSettingsMenuPanel(title, items = [], options = {}) { - this.registerMenuPanel('console:settings', title, items, options); - } - - /** - * Registers a new settings menu item. - * - * @method registerSettingsMenuItem - * @public - * @memberof UniverseService - * @param {String} title The title of the item - * @param {Object} options Additional options for the item - */ - registerSettingsMenuItem(title, options = {}) { - this.registerMenuItem('console:settings', title, options); - } - - /** - * Registers a new account menu panel. - * - * @method registerAccountMenuPanel - * @public - * @memberof UniverseService - * @param {String} title The title of the panel - * @param {Array} items The items of the panel - * @param {Object} options Additional options for the panel - */ - registerAccountMenuPanel(title, items = [], options = {}) { - this.registerMenuPanel('console:account', title, items, options); - } - - /** - * Registers a new account menu item. - * - * @method registerAccountMenuItem - * @public - * @memberof UniverseService - * @param {String} title The title of the item - * @param {Object} options Additional options for the item - */ - registerAccountMenuItem(title, options = {}) { - this.registerMenuItem('console:account', title, options); - } - - /** - * Registers a new dashboard with the given name. - * Initializes the dashboard with empty arrays for default widgets and widgets. - * - * @param {string} dashboardName - The name of the dashboard to register. - * @returns {void} - */ - registerDashboard(dashboardName) { - const internalDashboardRegistryName = this.createInternalDashboardName(dashboardName); - if (this[internalDashboardRegistryName] !== undefined) { - return; - } - - this[internalDashboardRegistryName] = { - defaultWidgets: A([]), - widgets: A([]), - }; - - this.trigger('dashboard.registered', this[internalDashboardRegistryName]); - } - - /** - * Retrieves the registry for a specific dashboard. - * - * @param {string} dashboardName - The name of the dashboard to get the registry for. - * @returns {Object} - The registry object for the specified dashboard, including default and registered widgets. - */ - getDashboardRegistry(dashboardName) { - const internalDashboardRegistryName = this.createInternalDashboardName(dashboardName); - return this[internalDashboardRegistryName]; - } - - /** - * Checks if a dashboard has been registered. - * - * @param {String} dashboardName - * @return {Boolean} - * @memberof UniverseService - */ - didRegisterDashboard(dashboardName) { - const internalDashboardRegistryName = this.createInternalDashboardName(dashboardName); - return this[internalDashboardRegistryName] !== undefined; - } - - /** - * Retrieves the widget registry for a specific dashboard and type. - * - * @param {string} dashboardName - The name of the dashboard to get the widget registry for. - * @param {string} [type='widgets'] - The type of widget registry to retrieve (e.g., 'widgets', 'defaultWidgets'). - * @returns {Array} - An array of widget objects for the specified dashboard and type. - */ - getWidgetRegistry(dashboardName, type = 'widgets') { - const internalDashboardRegistryName = this.createInternalDashboardName(dashboardName); - const typeKey = pluralize(type); - return isArray(this[internalDashboardRegistryName][typeKey]) ? this[internalDashboardRegistryName][typeKey] : []; - } - - /** - * Registers widgets for a specific dashboard. - * Supports registering multiple widgets and different types of widget collections. - * - * @param {string} dashboardName - The name of the dashboard to register widgets for. - * @param {Array|Object} widgets - An array of widget objects or a single widget object to register. - * @param {string} [type='widgets'] - The type of widgets to register (e.g., 'widgets', 'defaultWidgets'). - * @returns {void} - */ - registerWidgets(dashboardName, widgets = [], type = 'widgets', options = {}) { - const internalDashboardRegistryName = this.createInternalDashboardName(dashboardName); - if (isArray(widgets)) { - widgets.forEach((w) => this.registerWidgets(dashboardName, w, type, options)); - return; - } - - const typeKey = pluralize(type); - const newWidget = this._createDashboardWidget(widgets, options); - const widgetRegistry = this.getWidgetRegistry(dashboardName, type); - if (this.widgetRegistryHasWidget(widgetRegistry, newWidget)) { - return; - } - - this[internalDashboardRegistryName][typeKey] = [...widgetRegistry, newWidget]; - this.trigger('widget.registered', newWidget); - } - - /** - * Checks if a widget with the same ID as the pending widget is already registered in the specified dashboard and type. - * - * @param {string} dashboardName - The name of the dashboard to check. - * @param {Object} widgetPendingRegistry - The widget to check for in the registry. - * @param {string} [type='widgets'] - The type of widget registry to check (e.g., 'widgets', 'defaultWidgets'). - * @returns {boolean} - `true` if a widget with the same ID is found in the registry; otherwise, `false`. - */ - didRegisterWidget(dashboardName, widgetPendingRegistry, type = 'widgets') { - const widgetRegistry = this.getWidgetRegistry(dashboardName, type); - return widgetRegistry.includes((widget) => widget.widgetId === widgetPendingRegistry.widgetId); - } - - /** - * Checks if a widget with the same ID as the pending widget exists in the provided widget registry instance. - * - * @param {Array} [widgetRegistryInstance=[]] - An array of widget objects to check. - * @param {Object} widgetPendingRegistry - The widget to check for in the registry. - * @returns {boolean} - `true` if a widget with the same ID is found in the registry; otherwise, `false`. - */ - widgetRegistryHasWidget(widgetRegistryInstance = [], widgetPendingRegistry) { - return widgetRegistryInstance.includes((widget) => widget.widgetId === widgetPendingRegistry.widgetId); - } - - /** - * Registers widgets for the default 'dashboard' dashboard. - * - * @param {Array} [widgets=[]] - An array of widget objects to register. - * @returns {void} - */ - registerDashboardWidgets(widgets = [], options = {}) { - this.registerWidgets('dashboard', widgets, 'widgets', options); - } - - /** - * Registers default widgets for the default 'dashboard' dashboard. - * - * @param {Array} [widgets=[]] - An array of default widget objects to register. - * @returns {void} - */ - registerDefaultDashboardWidgets(widgets = [], options = {}) { - this.registerWidgets('dashboard', widgets, 'defaultWidgets', options); - } - - /** - * Registers default widgets for a specified dashboard. - * - * @param {String} dashboardName - * @param {Array} [widgets=[]] - An array of default widget objects to register. - * @returns {void} - */ - registerDefaultWidgets(dashboardName, widgets = [], options = {}) { - this.registerWidgets(dashboardName, widgets, 'defaultWidgets', options); - } - - /** - * Retrieves widgets for a specific dashboard. - * - * @param {string} dashboardName - The name of the dashboard to retrieve widgets for. - * @param {string} [type='widgets'] - The type of widgets to retrieve (e.g., 'widgets', 'defaultWidgets'). - * @returns {Array} - An array of widgets for the specified dashboard and type. - */ - getWidgets(dashboardName, type = 'widgets') { - const typeKey = pluralize(type); - const internalDashboardRegistryName = this.createInternalDashboardName(dashboardName); - return isArray(this[internalDashboardRegistryName][typeKey]) ? this[internalDashboardRegistryName][typeKey] : []; - } - - /** - * Retrieves default widgets for a specific dashboard. - * - * @param {string} dashboardName - The name of the dashboard to retrieve default widgets for. - * @returns {Array} - An array of default widgets for the specified dashboard. - */ - getDefaultWidgets(dashboardName) { - return this.getWidgets(dashboardName, 'defaultWidgets'); - } - - /** - * Retrieves widgets for the default 'dashboard' dashboard. - * - * @returns {Array} - An array of widgets for the default 'dashboard' dashboard. - */ - getDashboardWidgets() { - return this.getWidgets('dashboard'); - } - - /** - * Retrieves default widgets for the default 'dashboard' dashboard. - * - * @returns {Array} - An array of default widgets for the default 'dashboard' dashboard. - */ - getDefaultDashboardWidgets() { - return this.getWidgets('dashboard', 'defaultWidgets'); - } - - /** - * Creates an internal name for a dashboard based on its given name. - * - * @param {string} dashboardName - The name of the dashboard. - * @returns {string} - The internal name for the dashboard, formatted as `${dashboardName}Widgets`. - */ - createInternalDashboardName(dashboardName) { - return `${camelize(dashboardName.replace(/[^a-zA-Z0-9]/g, '-'))}Widgets`; - } - - /** - * Creates a new widget object from a widget definition. - * If the component is a function, it is registered with the host application. - * - * @param {Object} widget - The widget definition. - * @param {string} widget.widgetId - The unique ID of the widget. - * @param {string} widget.name - The name of the widget. - * @param {string} [widget.description] - A description of the widget. - * @param {string} [widget.icon] - An icon for the widget. - * @param {Function|string} [widget.component] - A component definition or name for the widget. - * @param {Object} [widget.grid_options] - Grid options for the widget. - * @param {Object} [widget.options] - Additional options for the widget. - * @returns {Object} - The newly created widget object. - */ - _createDashboardWidget(widget, registrationOptions = {}) { - let { widgetId, name, description, icon, component, grid_options, options } = widget; - - // If a class is provided, (optionally) register it under a stable id - if (typeof component === 'function') { - const owner = getOwner(this); - const id = dasherize(component.widgetId || widgetId || this._createUniqueWidgetHashFromDefinition(component)); - - if (owner) { - owner.register(`component:${id}`, component); - - // Register in engine instance if dashboard will be resolved from an engine - if (registrationOptions?.engine?.register) { - registrationOptions.engine.register(`component:${id}`, component); - } - - // component = component; - widgetId = id; - } - } - - return { - widgetId, - name, - description, - icon, - component, // string OR class — template will resolve - grid_options, - options, - }; - } - - /** - * Generates a unique hash for a widget component based on its function definition. - * This method delegates the hash creation to the `_createHashFromFunctionDefinition` method. - * - * @param {Function} component - The function representing the widget component. - * @returns {string} - The unique hash representing the widget component. - */ - _createUniqueWidgetHashFromDefinition(component) { - return this._createHashFromFunctionDefinition(component); - } - - /** - * Creates a hash value from a function definition. The hash is generated based on the function's string representation. - * If the function has a name, it returns that name. Otherwise, it converts the function's string representation - * into a hash value. This is done by iterating over the characters of the string and performing a simple hash calculation. - * - * @param {Function} func - The function whose definition will be hashed. - * @returns {string} - The hash value derived from the function's definition. If the function has a name, it is returned directly. - */ - _createHashFromFunctionDefinition(func) { - if (func.name) { - return func.name; - } - - if (typeof func.toString === 'function') { - let definition = func.toString(); - let hash = 0; - for (let i = 0; i < definition.length; i++) { - const char = definition.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash |= 0; - } - return hash.toString(16); - } - - return func.name; - } - - /** - * Registers a new header menu item. - * - * @method registerHeaderMenuItem - * @public - * @memberof UniverseService - * @param {String} title The title of the item - * @param {String} route The route of the item - * @param {Object} options Additional options for the item - */ - registerHeaderMenuItem(title, route, options = {}) { - this.headerMenuItems.pushObject(this._createMenuItem(title, route, options)); - this.headerMenuItems.sort((a, b) => a.priority - b.priority); - } - - /** - * Registers a new organization menu item. - * - * @method registerOrganizationMenuItem - * @public - * @memberof UniverseService - * @param {String} title The title of the item - * @param {String} route The route of the item - * @param {Object} options Additional options for the item - */ - registerOrganizationMenuItem(title, options = {}) { - const route = this._getOption(options, 'route', 'console.virtual'); - options.index = this._getOption(options, 'index', 0); - options.section = this._getOption(options, 'section', 'settings'); - - this.organizationMenuItems.pushObject(this._createMenuItem(title, route, options)); - } - - /** - * Registers a new organization menu item. - * - * @method registerOrganizationMenuItem - * @public - * @memberof UniverseService - * @param {String} title The title of the item - * @param {String} route The route of the item - * @param {Object} options Additional options for the item - */ - registerUserMenuItem(title, options = {}) { - const route = this._getOption(options, 'route', 'console.virtual'); - options.index = this._getOption(options, 'index', 0); - options.section = this._getOption(options, 'section', 'account'); - - this.userMenuItems.pushObject(this._createMenuItem(title, route, options)); - } - - /** - * Returns the value of a given key on a target object, with a default value. - * - * @method _getOption - * @private - * @memberof UniverseService - * @param {Object} target The target object - * @param {String} key The key to get value for - * @param {*} defaultValue The default value if the key does not exist - * @returns {*} The value of the key or default value - */ - _getOption(target, key, defaultValue = null) { - return target[key] !== undefined ? target[key] : defaultValue; - } - - /** - * Creates a new menu item with the provided information. - * - * @method _createMenuItem - * @private - * @memberof UniverseService - * @param {String} title The title of the item - * @param {String} route The route of the item - * @param {Object} options Additional options for the item - * @returns {Object} A new menu item object - */ - _createMenuItem(title, route, options = {}) { - const intl = this._getOption(options, 'intl', null); - const priority = this._getOption(options, 'priority', 9); - const icon = this._getOption(options, 'icon', 'circle-dot'); - const items = this._getOption(options, 'items'); - const component = this._getOption(options, 'component'); - const componentParams = this._getOption(options, 'componentParams', {}); - const renderComponentInPlace = this._getOption(options, 'renderComponentInPlace', false); - const slug = this._getOption(options, 'slug', dasherize(title)); - const view = this._getOption(options, 'view', dasherize(title)); - const queryParams = this._getOption(options, 'queryParams', {}); - const index = this._getOption(options, 'index', 0); - const onClick = this._getOption(options, 'onClick', null); - const section = this._getOption(options, 'section', null); - const iconComponent = this._getOption(options, 'iconComponent', null); - const iconComponentOptions = this._getOption(options, 'iconComponentOptions', {}); - const iconSize = this._getOption(options, 'iconSize', null); - const iconPrefix = this._getOption(options, 'iconPrefix', null); - const iconClass = this._getOption(options, 'iconClass', null); - const itemClass = this._getOption(options, 'class', null); - const inlineClass = this._getOption(options, 'inlineClass', null); - const wrapperClass = this._getOption(options, 'wrapperClass', null); - const overwriteWrapperClass = this._getOption(options, 'overwriteWrapperClass', false); - const id = this._getOption(options, 'id', dasherize(title)); - const type = this._getOption(options, 'type', null); - const buttonType = this._getOption(options, 'buttonType', null); - const permission = this._getOption(options, 'permission', null); - const disabled = this._getOption(options, 'disabled', null); - const isLoading = this._getOption(options, 'isLoading', null); - - // dasherize route segments - if (typeof route === 'string') { - route = route - .split('.') - .map((segment) => dasherize(segment)) - .join('.'); - } - - // @todo: create menu item class - const menuItem = { - id, - intl, - title, - text: title, - label: title, - route, - icon, - priority, - items, - component, - componentParams, - renderComponentInPlace, - slug, - queryParams, - view, - index, - section, - onClick, - iconComponent, - iconComponentOptions, - iconSize, - iconPrefix, - iconClass, - class: itemClass, - inlineClass, - wrapperClass, - overwriteWrapperClass, - type, - buttonType, - permission, - disabled, - isLoading, - }; - - // make the menu item and universe object a default param of the onClick handler - if (typeof onClick === 'function') { - const universe = this; - menuItem.onClick = function () { - return onClick(menuItem, universe); - }; - } - - return menuItem; - } - - /** - * Creates an internal registry name by camelizing the provided registry name and appending "Registry" to it. - * - * @method createInternalRegistryName - * @public - * @memberof UniverseService - * @param {String} registryName - The name of the registry to be camelized and formatted. - * @returns {String} The formatted internal registry name. - */ - createInternalRegistryName(registryName) { - return `${camelize(registryName.replace(/[^a-zA-Z0-9]/g, '-'))}Registry`; - } - - /** - * Registers a component class under one or more names within a specified engine instance. - * This function provides flexibility in component registration by supporting registration under the component's - * full class name, a simplified alias derived from the class name, and an optional custom name provided through the options. - * This flexibility facilitates varied referencing styles within different parts of the application, enhancing modularity and reuse. - * - * @param {string} engineName - The name of the engine where the component will be registered. - * @param {class} componentClass - The component class to be registered. Must be a class, not an instance. - * @param {Object} [options] - Optional parameters for additional configuration. - * @param {string} [options.registerAs] - A custom name under which the component can also be registered. - * - * @example - * // Register a component with its default and alias names - * registerComponentInEngine('mainEngine', HeaderComponent); - * - * // Additionally register the component under a custom name - * registerComponentInEngine('mainEngine', HeaderComponent, { registerAs: 'header' }); - * - * @remarks - * - The function does not return any value. - * - Registration only occurs if: - * - The specified engine instance exists. - * - The component class is properly defined with a non-empty name. - * - The custom name, if provided, must be a valid string. - * - Allows flexible component referencing by registering under multiple names. - */ - registerComponentInEngine(engineName, componentClass, options = {}) { - const engineInstance = this.getEngineInstance(engineName); - this.registerComponentToEngineInstance(engineInstance, componentClass, options); - } - - /** - * Registers a component class under its full class name, a simplified alias, and an optional custom name within a specific engine instance. - * This helper function does the actual registration of the component to the engine instance. It registers the component under its - * full class name, a dasherized alias of the class name (with 'Component' suffix removed if present), and any custom name provided via options. - * - * @param {EngineInstance} engineInstance - The engine instance where the component will be registered. - * @param {class} componentClass - The component class to be registered. This should be a class reference, not an instance. - * @param {Object} [options] - Optional parameters for further configuration. - * @param {string} [options.registerAs] - A custom name under which the component can be registered. - * - * @example - * // Typical usage within the system (not usually called directly by users) - * registerComponentToEngineInstance(engineInstance, HeaderComponent, { registerAs: 'header' }); - * - * @remarks - * - No return value. - * - The registration is performed only if: - * - The engine instance is valid and not null. - * - The component class has a defined and non-empty name. - * - The custom name, if provided, is a valid string. - * - This function directly manipulates the engine instance's registration map. - */ - registerComponentToEngineInstance(engineInstance, componentClass, options = {}) { - if (engineInstance && componentClass && typeof componentClass.name === 'string') { - engineInstance.register(`component:${componentClass.name}`, componentClass); - engineInstance.register(`component:${dasherize(componentClass.name.replace('Component', ''))}`, componentClass); - if (options && typeof options.registerAs === 'string') { - engineInstance.register(`component:${options.registerAs}`, componentClass); - this.trigger('component.registered', componentClass, engineInstance); - } - } - } - - /** - * Registers a service from one engine instance to another within the application. - * This method retrieves an instance of a service from the current engine and then registers it - * in a target engine, allowing the service to be shared across different parts of the application. - * - * @param {string} targetEngineName - The name of the engine where the service should be registered. - * @param {string} serviceName - The name of the service to be shared and registered. - * @param {Object} currentEngineInstance - The engine instance that currently holds the service to be shared. - * - * @example - * // Assuming 'appEngine' and 'componentEngine' are existing engine instances and 'logger' is a service in 'appEngine' - * registerServiceInEngine('componentEngine', 'logger', appEngine); - * - * Note: - * - This function does not return any value. - * - It only performs registration if all provided parameters are valid: - * - Both engine instances must exist. - * - The service name must be a string. - * - The service must exist in the current engine instance. - * - The service is registered without instantiating a new copy in the target engine. - */ - registerServiceInEngine(targetEngineName, serviceName, currentEngineInstance) { - // Get the target engine instance - const targetEngineInstance = this.getEngineInstance(targetEngineName); - - // Validate inputs - if (targetEngineInstance && currentEngineInstance && typeof serviceName === 'string') { - // Lookup the service instance from the current engine - const sharedService = currentEngineInstance.lookup(`service:${serviceName}`); - - if (sharedService) { - // Register the service in the target engine - targetEngineInstance.register(`service:${serviceName}`, sharedService, { instantiate: false }); - this.trigger('service.registered', serviceName, targetEngineInstance); - } - } - } - - /** - * Retrieves a service instance from a specified Ember engine. - * - * @param {string} engineName - The name of the engine from which to retrieve the service. - * @param {string} serviceName - The name of the service to retrieve. - * @returns {Object|null} The service instance if found, otherwise null. - * - * @example - * const userService = universe.getServiceFromEngine('user-engine', 'user'); - * if (userService) { - * userService.doSomething(); - * } - */ - getServiceFromEngine(engineName, serviceName, options = {}) { - const engineInstance = this.getEngineInstance(engineName); - - if (engineInstance && typeof serviceName === 'string') { - const serviceInstance = engineInstance.lookup(`service:${serviceName}`); - if (options && options.inject) { - for (let injectionName in options.inject) { - serviceInstance[injectionName] = options.inject[injectionName]; - } - } - return serviceInstance; - } - - return null; - } - - /** - * Load the specified engine. If it is not loaded yet, it will use assetLoader - * to load it and then register it to the router. - * - * @method loadEngine - * @public - * @memberof UniverseService - * @param {String} name The name of the engine to load - * @returns {Promise} A promise that resolves with the constructed engine instance - */ - loadEngine(name) { - const router = getOwner(this).lookup('router:main'); - const instanceId = 'manual'; // Arbitrary instance id, should be unique per engine - const mountPoint = this._mountPathFromEngineName(name); // No mount point for manually loaded engines - - if (!router._enginePromises[name]) { - router._enginePromises[name] = Object.create(null); - } - - let enginePromise = router._enginePromises[name][instanceId]; - - // We already have a Promise for this engine instance - if (enginePromise) { - return enginePromise; - } - - if (router._engineIsLoaded(name)) { - // The Engine is loaded, but has no Promise - enginePromise = RSVP.resolve(); - } else { - // The Engine is not loaded and has no Promise - enginePromise = router._assetLoader.loadBundle(name).then( - () => router._registerEngine(name), - (error) => { - router._enginePromises[name][instanceId] = undefined; - throw error; - } - ); - } - - return (router._enginePromises[name][instanceId] = enginePromise.then(() => { - return this.constructEngineInstance(name, instanceId, mountPoint); - })); - } - - /** - * Construct an engine instance. If the instance does not exist yet, it will be created. - * - * @method constructEngineInstance - * @public - * @memberof UniverseService - * @param {String} name The name of the engine - * @param {String} instanceId The id of the engine instance - * @param {String} mountPoint The mount point of the engine - * @returns {Promise} A promise that resolves with the constructed engine instance - */ - constructEngineInstance(name, instanceId, mountPoint) { - const owner = getOwner(this); - - assert("You attempted to load the engine '" + name + "', but the engine cannot be found.", owner.hasRegistration(`engine:${name}`)); - - let engineInstances = owner.lookup('router:main')._engineInstances; - if (!engineInstances[name]) { - engineInstances[name] = Object.create(null); - } - - let engineInstance = owner.buildChildEngineInstance(name, { - routable: true, - mountPoint, - }); - - // correct mountPoint using engine instance - let _mountPoint = this._getMountPointFromEngineInstance(engineInstance); - if (_mountPoint) { - engineInstance.mountPoint = _mountPoint; - } - - // make sure to set dependencies from base instance - if (engineInstance.base) { - engineInstance.dependencies = this._setupEngineParentDependenciesBeforeBoot(engineInstance.base.dependencies); - } - - // store loaded instance to engineInstances for booting - engineInstances[name][instanceId] = engineInstance; - - this.trigger('engine.loaded', engineInstance); - return engineInstance.boot().then(() => { - return engineInstance; - }); - } - - _setupEngineParentDependenciesBeforeBoot(baseDependencies = {}) { - const dependencies = { ...baseDependencies }; - - // fix services - const servicesObject = {}; - if (isArray(dependencies.services)) { - for (let i = 0; i < dependencies.services.length; i++) { - const service = dependencies.services.objectAt(i); - - if (typeof service === 'object') { - Object.assign(servicesObject, service); - continue; - } - - servicesObject[service] = service; - } - } - - // fix external routes - const externalRoutesObject = {}; - if (isArray(dependencies.externalRoutes)) { - for (let i = 0; i < dependencies.externalRoutes.length; i++) { - const externalRoute = dependencies.externalRoutes.objectAt(i); - - if (typeof externalRoute === 'object') { - Object.assign(externalRoutesObject, externalRoute); - continue; - } - - externalRoutesObject[externalRoute] = externalRoute; - } - } - - dependencies.externalRoutes = externalRoutesObject; - dependencies.services = servicesObject; - return dependencies; - } - - /** - * Retrieve an existing engine instance by its name and instanceId. - * - * @method getEngineInstance - * @public - * @memberof UniverseService - * @param {String} name The name of the engine - * @param {String} [instanceId='manual'] The id of the engine instance (defaults to 'manual') - * @returns {Object|null} The engine instance if it exists, otherwise null - */ - getEngineInstance(name, instanceId = 'manual') { - const owner = getOwner(this); - const router = owner.lookup('router:main'); - const engineInstances = router._engineInstances; - - if (engineInstances && engineInstances[name]) { - return engineInstances[name][instanceId] || null; - } - - return null; - } - - /** - * Returns a promise that resolves when the `enginesBooted` property is set to true. - * The promise will reject with a timeout error if the property does not become true within the specified timeout. - * - * @function booting - * @returns {Promise} A promise that resolves when `enginesBooted` is true or rejects with an error after a timeout. - */ - booting() { - return new Promise((resolve, reject) => { - const check = () => { - if (this.enginesBooted === true) { - this.trigger('booted'); - clearInterval(intervalId); - resolve(); - } - }; - - const intervalId = setInterval(check, 100); - later( - this, - () => { - clearInterval(intervalId); - reject(new Error('Timeout: Universe was unable to boot engines')); - }, - 1000 * 40 - ); - }); - } - - /** - * Boot all installed engines, ensuring dependencies are resolved. - * - * This method attempts to boot all installed engines by first checking if all - * their dependencies are already booted. If an engine has dependencies that - * are not yet booted, it is deferred and retried after its dependencies are - * booted. If some dependencies are never booted, an error is logged. - * - * @method bootEngines - * @param {ApplicationInstance|null} owner - The Ember ApplicationInstance that owns the engines. - * @return {void} - */ - async bootEngines(owner = null) { - const booted = []; - const pending = []; - const additionalCoreExtensions = config.APP.extensions ?? []; - - // If no owner provided use the owner of this service - if (owner === null) { - owner = getOwner(this); - } - - // Set application instance - this.initialize(); - this.setApplicationInstance(owner); - - const tryBootEngine = (extension) => { - return this.loadEngine(extension.name).then((engineInstance) => { - if (engineInstance.base && engineInstance.base.setupExtension) { - if (this.bootedExtensions.includes(extension.name)) { - return; - } - - const engineDependencies = getWithDefault(engineInstance.base, 'engineDependencies', []); - const allDependenciesBooted = engineDependencies.every((dep) => booted.includes(dep)); - - if (!allDependenciesBooted) { - pending.push({ extension, engineInstance }); - return; - } - - engineInstance.base.setupExtension(owner, engineInstance, this); - booted.push(extension.name); - this.bootedExtensions.pushObject(extension.name); - this.trigger('extension.booted', extension); - debug(`Booted : ${extension.name}`); - - // Try booting pending engines again - tryBootPendingEngines(); - } - }); - }; - - const tryBootPendingEngines = () => { - const stillPending = []; - - pending.forEach(({ extension, engineInstance }) => { - if (this.bootedExtensions.includes(extension.name)) { - return; - } - - const engineDependencies = getWithDefault(engineInstance.base, 'engineDependencies', []); - const allDependenciesBooted = engineDependencies.every((dep) => booted.includes(dep)); - - if (allDependenciesBooted) { - engineInstance.base.setupExtension(owner, engineInstance, this); - booted.push(extension.name); - this.bootedExtensions.pushObject(extension.name); - this.trigger('extension.booted', extension); - debug(`Booted : ${extension.name}`); - } else { - stillPending.push({ extension, engineInstance }); - } - }); - - // If no progress was made, log an error in debug/development mode - assert(`Some engines have unmet dependencies and cannot be booted:`, pending.length === 0 || pending.length > stillPending.length); - - pending.length = 0; - pending.push(...stillPending); - }; - - // Run pre-boots if any - await this.preboot(); - - return loadInstalledExtensions(additionalCoreExtensions).then(async (extensions) => { - for (let i = 0; i < extensions.length; i++) { - const extension = extensions[i]; - await tryBootEngine(extension); - } - - this.runBootCallbacks(owner, () => { - this.enginesBooted = true; - }); - }); - } - - /** - * Run engine preboots from all indexed engines. - * - * @param {ApplicationInstance} owner - * @memberof UniverseService - */ - async preboot(owner) { - const extensions = await loadExtensions(); - for (let i = 0; i < extensions.length; i++) { - const extension = extensions[i]; - const instance = await this.loadEngine(extension.name); - if (instance.base && typeof instance.base.preboot === 'function') { - instance.base.preboot(owner, instance, this); - } - } - } - - /** - * Checks if an extension has been booted. - * - * @param {String} name - * @return {Boolean} - * @memberof UniverseService - */ - didBootEngine(name) { - return this.bootedExtensions.includes(name); - } - - /** - * Registers a callback function to be executed after the engine boot process completes. - * - * This method ensures that the `bootCallbacks` array is initialized. It then adds the provided - * callback to this array. The callbacks registered will be invoked in sequence after the engine - * has finished booting, using the `runBootCallbacks` method. - * - * @param {Function} callback - The function to execute after the engine boots. - * The callback should accept two arguments: - * - `{Object} universe` - The universe context or environment. - * - `{Object} appInstance` - The application instance. - */ - afterBoot(callback) { - if (!isArray(this.bootCallbacks)) { - this.bootCallbacks = []; - } - - this.bootCallbacks.pushObject(callback); - } - - /** - * Executes all registered engine boot callbacks in the order they were added. - * - * This method iterates over the `bootCallbacks` array and calls each callback function, - * passing in the `universe` and `appInstance` parameters. After all callbacks have been - * executed, it optionally calls a completion function `onComplete`. - * - * @param {Object} appInstance - The application instance to pass to each callback. - * @param {Function} [onComplete] - Optional. A function to call after all boot callbacks have been executed. - * It does not receive any arguments. - */ - runBootCallbacks(appInstance, onComplete = null) { - for (let i = 0; i < this.bootCallbacks.length; i++) { - const callback = this.bootCallbacks[i]; - if (typeof callback === 'function') { - try { - callback(this, appInstance); - } catch (error) { - debug(`Engine Boot Callback Error: ${error.message}`); - } - } - } - - if (typeof onComplete === 'function') { - onComplete(); - } - } - - /** - * Alias for intl service `t` - * - * @memberof UniverseService - */ - t() { - this.intl.t(...arguments); - } -} diff --git a/addon/services/loader.js b/addon/services/loader.js index 15e82881..5f69c4dc 100644 --- a/addon/services/loader.js +++ b/addon/services/loader.js @@ -57,7 +57,8 @@ export default class LoaderService extends Service { }); } - this.routesLoaded.pushObject(route); + // Reassigned rather than mutated so the tracked property invalidates. + this.routesLoaded = [...this.routesLoaded, route]; } /** diff --git a/addon/services/notifications.js b/addon/services/notifications.js index 3cd8e996..303e2098 100644 --- a/addon/services/notifications.js +++ b/addon/services/notifications.js @@ -4,7 +4,7 @@ import getWithDefault from '../utils/get-with-default'; export default class NotificationsService extends EmberNotificationsService { serverError(error, fallbackMessage = 'Oops! Something went wrong with your request.', options = {}) { - if (isArray(error.errors)) { + if (error && isArray(error.errors)) { const errors = getWithDefault(error, 'errors'); const errorMessage = getWithDefault(errors, '0', fallbackMessage); diff --git a/addon/services/socket.js b/addon/services/socket.js index 0e700b72..bd379244 100644 --- a/addon/services/socket.js +++ b/addon/services/socket.js @@ -36,7 +36,8 @@ export default class SocketService extends Service { const channel = this.socket.subscribe(channelId); // Track channel - this.channels.pushObject(channel); + // Reassigned rather than mutated so the tracked property invalidates. + this.channels = [...this.channels, channel]; // Listen to channel for events await channel.listener('subscribe').once(); @@ -56,7 +57,7 @@ export default class SocketService extends Service { closeChannels() { for (let i = 0; i < this.channels.length; i++) { - const channel = this.channels.objectAt(i); + const channel = this.channels[i]; channel.close(); } diff --git a/addon/services/universe/extension-manager.js b/addon/services/universe/extension-manager.js index 9b0156f8..7c75a78d 100644 --- a/addon/services/universe/extension-manager.js +++ b/addon/services/universe/extension-manager.js @@ -370,7 +370,9 @@ export default class ExtensionManagerService extends Service.extend(Evented) { const servicesObject = {}; if (isArray(dependencies.services)) { for (let i = 0; i < dependencies.services.length; i++) { - let serviceName = dependencies.services.objectAt(i); + // Engine dependencies are declared as plain array literals, so + // `objectAt` does not exist with prototype extensions off. + let serviceName = dependencies.services[i]; if (typeof serviceName === 'object') { Object.assign(servicesObject, serviceName); continue; @@ -391,7 +393,7 @@ export default class ExtensionManagerService extends Service.extend(Evented) { const externalRoutesObject = {}; if (isArray(dependencies.externalRoutes)) { for (let i = 0; i < dependencies.externalRoutes.length; i++) { - const externalRoute = dependencies.externalRoutes.objectAt(i); + const externalRoute = dependencies.externalRoutes[i]; if (typeof externalRoute === 'object') { Object.assign(externalRoutesObject, externalRoute); diff --git a/addon/services/universe/menu-service.js b/addon/services/universe/menu-service.js index a8213580..326ca1c3 100644 --- a/addon/services/universe/menu-service.js +++ b/addon/services/universe/menu-service.js @@ -451,9 +451,9 @@ export default class MenuService extends Service.extend(Evented) { // because the default bar is built by slicing the first N items — if // shortcuts sort between extensions (e.g. priority 1.1 between 1 and 2) // they would displace real extensions from the default pinned bar. - const extensions = A(items) - .filter((i) => !i._isShortcut) - .sortBy('priority'); + // A(...).filter() returns a plain array, which has no sortBy, so the + // result has to be re-wrapped before sorting. + const extensions = A(A(items).filter((i) => !i._isShortcut)).sortBy('priority'); const shortcuts = A(items).filter((i) => i._isShortcut); return A([...extensions, ...shortcuts]); } diff --git a/addon/utils/api-url.js b/addon/utils/api-url.js index 2d07a6c2..9517ed78 100644 --- a/addon/utils/api-url.js +++ b/addon/utils/api-url.js @@ -1,5 +1,5 @@ import consoleUrl from './console-url'; -import config from '@fleetbase/console/config/environment'; +import config from 'ember-get-config'; import { get } from '@ember/object'; export default function apiUrl(path, queryParams = {}, subdomain = null, host = null) { diff --git a/addon/utils/array-utils.js b/addon/utils/array-utils.js index 6afc694d..927b4958 100644 --- a/addon/utils/array-utils.js +++ b/addon/utils/array-utils.js @@ -1,3 +1,3 @@ export { default as sameIds } from './same-ids'; -export { default as stableByIds } from './stable-by-ids'; +export { stableByIds } from './stable-by-ids'; export { default as arrayUniqueBy } from './array-unique-by'; diff --git a/addon/utils/auto-serialize.js b/addon/utils/auto-serialize.js index bee276bc..459dc1f0 100644 --- a/addon/utils/auto-serialize.js +++ b/addon/utils/auto-serialize.js @@ -30,7 +30,7 @@ const serialize = (model) => { let serializerMethods = ['toJSON', 'toJson', 'serialize']; for (let i = 0; i < serializerMethods.length; i++) { - const serializer = serializerMethods.objectAt(i); + const serializer = serializerMethods[i]; const serialized = invoke(model, serializer); if (!_isEmpty(serialized)) { diff --git a/addon/utils/console-url.js b/addon/utils/console-url.js index d5360a86..50186f49 100644 --- a/addon/utils/console-url.js +++ b/addon/utils/console-url.js @@ -1,4 +1,4 @@ -import config from '@fleetbase/console/config/environment'; +import config from 'ember-get-config'; import { isBlank } from '@ember/utils'; const isDevelopment = ['local', 'development'].includes(config.environment); @@ -26,7 +26,9 @@ export default function consoleUrl(path = '', queryParams = {}, subdomain = null subdomain = parts.length > 2 ? parts[0] : null; } if (host === null) { - host = currentHost; + // extractHostAndPort parses with `new URL`, which needs a protocol; + // window.location.host is only "hostname:port" and would not parse. + host = `${window.location.protocol}//${currentHost}`; } } diff --git a/addon/utils/context-component-callback.js b/addon/utils/context-component-callback.js index a31c2a30..b68ee11c 100644 --- a/addon/utils/context-component-callback.js +++ b/addon/utils/context-component-callback.js @@ -6,8 +6,8 @@ export default function contextComponentCallback(component, name, ...params) { callbackInvoked = true; } - // now do for context options - if (typeof component.args.options === 'object' && typeof component.args.options[name] === 'function') { + // now do for context options; `typeof null` is also 'object', so guard for it + if (component.args.options && typeof component.args.options === 'object' && typeof component.args.options[name] === 'function') { component.args.options[name](...params); callbackInvoked = true; } diff --git a/addon/utils/extract-coordinates.js b/addon/utils/extract-coordinates.js index efc8bfcc..8fb0f237 100644 --- a/addon/utils/extract-coordinates.js +++ b/addon/utils/extract-coordinates.js @@ -24,7 +24,7 @@ export default function extractCoordinates(coordinates = [], format = 'latlng') } if (longitude === null) { - latitude = 0; + longitude = 0; } if (format === 'lnglat') { diff --git a/addon/utils/find-closest-waypoint.js b/addon/utils/find-closest-waypoint.js index f3910322..3d6cd9e7 100644 --- a/addon/utils/find-closest-waypoint.js +++ b/addon/utils/find-closest-waypoint.js @@ -1,20 +1,16 @@ import haversine from './haversine'; -import { get } from '@ember/object'; export default function findClosestWaypoint(latitude, longitude, waypoints = []) { - let distances = []; + const distances = []; for (let i = 0; i < waypoints.length; i++) { - let waypoint = waypoints.objectAt(i); - let distance = haversine({ latitude, longitude }, waypoint.place.get('latitudelongitude')); + const waypoint = waypoints[i]; + const distance = haversine({ latitude, longitude }, waypoint.place.get('latitudelongitude')); - distances.pushObject({ - distance, - waypoint, - }); + distances.push({ distance, waypoint }); } - distances = distances.sortBy('distance'); + distances.sort((a, b) => a.distance - b.distance); - return get(distances, 'firstObject.waypoint'); + return distances[0]?.waypoint; } diff --git a/addon/utils/frontend-url.js b/addon/utils/frontend-url.js index 2e8536dd..5ea412f2 100644 --- a/addon/utils/frontend-url.js +++ b/addon/utils/frontend-url.js @@ -1,4 +1,4 @@ -import config from '@fleetbase/console/config/environment'; +import config from 'ember-get-config'; import { isBlank } from '@ember/utils'; const queryString = (params) => diff --git a/addon/utils/get-mime-type.js b/addon/utils/get-mime-type.js index b8428a63..bf31e694 100644 --- a/addon/utils/get-mime-type.js +++ b/addon/utils/get-mime-type.js @@ -14,7 +14,7 @@ export default function getMimeType(fileName) { const extensions = Object.keys(map); for (let index = 0; index < extensions.length; index++) { - const ext = extensions.objectAt(index); + const ext = extensions[index]; if (fileName.endsWith(ext)) { return ext; diff --git a/addon/utils/get-model-name.js b/addon/utils/get-model-name.js index 0a64293e..4ee6ce79 100644 --- a/addon/utils/get-model-name.js +++ b/addon/utils/get-model-name.js @@ -10,7 +10,9 @@ export default function getModelName(model, fallback = null, options = {}) { if (isArray(fallback)) { for (let i = 0; i < fallback.length; i++) { - const defaultValue = fallback.objectAt(i); + // `isArray` is true for a native array, which has no `objectAt` + // once prototype extensions are off — index directly instead. + const defaultValue = fallback[i]; if (!isBlank(defaultValue)) { modelName = defaultValue; diff --git a/addon/utils/get-routing-host.js b/addon/utils/get-routing-host.js index 7f11aa9a..3fb0e498 100644 --- a/addon/utils/get-routing-host.js +++ b/addon/utils/get-routing-host.js @@ -1,7 +1,7 @@ import { get } from '@ember/object'; import { isArray } from '@ember/array'; import { isBlank } from '@ember/utils'; -import config from '@fleetbase/console/config/environment'; +import config from 'ember-get-config'; const isRoutingInCountry = (country, payload, waypoints = []) => { if (isBlank(payload)) { @@ -14,7 +14,7 @@ const isRoutingInCountry = (country, payload, waypoints = []) => { countryCode = country; } - if (isArray(waypoints) && !isBlank(waypoints?.firstObject) && get(waypoints?.firstObject, 'place.country') === country) { + if (isArray(waypoints) && !isBlank(waypoints[0]) && get(waypoints[0], 'place.country') === country) { countryCode = country; } diff --git a/addon/utils/group-by.js b/addon/utils/group-by.js index dd2b3a74..20720c32 100644 --- a/addon/utils/group-by.js +++ b/addon/utils/group-by.js @@ -5,7 +5,7 @@ export default function groupBy(arr, key) { let _key; for (let i = 0; i < arr.length; i++) { - const item = arr.objectAt(i); + const item = arr[i]; if (typeof key === 'string') { _key = get(item, key); @@ -19,7 +19,7 @@ export default function groupBy(arr, key) { grouped[_key] = []; } - grouped[_key].pushObject(item); + grouped[_key].push(item); } return grouped; diff --git a/addon/utils/is-waypoint-record.js b/addon/utils/is-waypoint-record.js deleted file mode 100644 index 57d80252..00000000 --- a/addon/utils/is-waypoint-record.js +++ /dev/null @@ -1,5 +0,0 @@ -import WaypointModel from '../models/waypoint'; - -export default function isWaypointRecord(record) { - return record instanceof WaypointModel; -} diff --git a/addon/utils/make-dataset.js b/addon/utils/make-dataset.js index 67d622a2..3cee5046 100644 --- a/addon/utils/make-dataset.js +++ b/addon/utils/make-dataset.js @@ -30,13 +30,15 @@ export function makeMockDataset(start, end, dateProperty = 'created_at') { const dataset = []; for (let day in grouped) { - dataset.pushObject({ + dataset.push({ x: new Date(`${day} 00:00:00`), y: grouped[day].length, }); } - return dataset.sortBy('t'); + // NOTE: the points below are {x, y}; there is no 't', so this sort has always + // been a no-op. Kept as-is — picking a real key would change existing output. + return [...dataset].sort(() => 0); } export default function makeDataset(recordArray, filter = Boolean, dateProperty = 'created_at') { @@ -47,11 +49,13 @@ export default function makeDataset(recordArray, filter = Boolean, dateProperty const dataset = []; for (let day in grouped) { - dataset.pushObject({ + dataset.push({ x: new Date(`${day} 00:00:00`), y: grouped[day].length, }); } - return dataset.sortBy('t'); + // NOTE: the points below are {x, y}; there is no 't', so this sort has always + // been a no-op. Kept as-is — picking a real key would change existing output. + return [...dataset].sort(() => 0); } diff --git a/app/services/legacy-universe.js b/app/services/legacy-universe.js deleted file mode 100644 index f6006a88..00000000 --- a/app/services/legacy-universe.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/ember-core/services/legacy-universe'; diff --git a/app/utils/array-utils.js b/app/utils/array-utils.js index b3563169..9c5d6b04 100644 --- a/app/utils/array-utils.js +++ b/app/utils/array-utils.js @@ -1 +1 @@ -export { default } from '@fleetbase/ember-core/utils/array-utils'; +export * from '@fleetbase/ember-core/utils/array-utils'; diff --git a/app/utils/console-url.js b/app/utils/console-url.js index 1b147713..0ddac163 100644 --- a/app/utils/console-url.js +++ b/app/utils/console-url.js @@ -1 +1,2 @@ export { default } from '@fleetbase/ember-core/utils/console-url'; +export * from '@fleetbase/ember-core/utils/console-url'; diff --git a/app/utils/get-routing-host.js b/app/utils/get-routing-host.js index d74780f7..de295e6e 100644 --- a/app/utils/get-routing-host.js +++ b/app/utils/get-routing-host.js @@ -1 +1,2 @@ export { default } from '@fleetbase/ember-core/utils/get-routing-host'; +export * from '@fleetbase/ember-core/utils/get-routing-host'; diff --git a/app/utils/inline-task.js b/app/utils/inline-task.js index 3cb16bf0..f25b40a4 100644 --- a/app/utils/inline-task.js +++ b/app/utils/inline-task.js @@ -1 +1,2 @@ export { default } from '@fleetbase/ember-core/utils/inline-task'; +export * from '@fleetbase/ember-core/utils/inline-task'; diff --git a/app/utils/is-waypoint-record.js b/app/utils/is-waypoint-record.js deleted file mode 100644 index e6f13fce..00000000 --- a/app/utils/is-waypoint-record.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/ember-core/utils/is-waypoint-record'; diff --git a/app/utils/load-extensions.js b/app/utils/load-extensions.js index 8eae8fca..8d7f40f6 100644 --- a/app/utils/load-extensions.js +++ b/app/utils/load-extensions.js @@ -1 +1,2 @@ export { default } from '@fleetbase/ember-core/utils/load-extensions'; +export * from '@fleetbase/ember-core/utils/load-extensions'; diff --git a/app/utils/make-dataset.js b/app/utils/make-dataset.js index b4b9bbc1..19db38a1 100644 --- a/app/utils/make-dataset.js +++ b/app/utils/make-dataset.js @@ -1 +1,2 @@ export { default } from '@fleetbase/ember-core/utils/make-dataset'; +export * from '@fleetbase/ember-core/utils/make-dataset'; diff --git a/app/utils/map-engines.js b/app/utils/map-engines.js index 2c83d91c..a8fb32de 100644 --- a/app/utils/map-engines.js +++ b/app/utils/map-engines.js @@ -1 +1,2 @@ export { default } from '@fleetbase/ember-core/utils/map-engines'; +export * from '@fleetbase/ember-core/utils/map-engines'; diff --git a/app/utils/range.js b/app/utils/range.js index 733400a2..4b453758 100644 --- a/app/utils/range.js +++ b/app/utils/range.js @@ -1 +1,2 @@ export { default } from '@fleetbase/ember-core/utils/range'; +export * from '@fleetbase/ember-core/utils/range'; diff --git a/app/utils/stable-by-ids.js b/app/utils/stable-by-ids.js index 97fd47a2..6c149330 100644 --- a/app/utils/stable-by-ids.js +++ b/app/utils/stable-by-ids.js @@ -1 +1 @@ -export { default } from '@fleetbase/ember-core/utils/stable-by-ids'; +export * from '@fleetbase/ember-core/utils/stable-by-ids'; diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..9f201468 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,23 @@ +coverage: + precision: 2 + round: down + status: + project: + default: + target: 100% + threshold: 0% + patch: + default: + target: 100% + threshold: 0% + +flags: + ember-core: + paths: + - addon/ + carryforward: false + +comment: + layout: 'condensed_header, diff, flags' + behavior: default + require_changes: false diff --git a/ember-cli-build.js b/ember-cli-build.js index edc65da5..8912b151 100644 --- a/ember-cli-build.js +++ b/ember-cli-build.js @@ -7,6 +7,9 @@ module.exports = function (defaults) { 'ember-simple-auth': { useSessionSetupMethod: true, }, + babel: { + plugins: [...require('ember-cli-code-coverage').buildBabelPlugin()], + }, }); /* diff --git a/index.js b/index.js index 1540faaf..325ff594 100644 --- a/index.js +++ b/index.js @@ -3,9 +3,32 @@ const Funnel = require('broccoli-funnel'); const MergeTrees = require('broccoli-merge-trees'); const path = require('path'); +/** + * Istanbul instrumentation for this addon's own `addon/` tree, so coverage + * reflects the addon source rather than only the dummy app. + * + * ember-cli-code-coverage is a devDependency and is only ever needed while + * running this repository's own test suite, so it is resolved lazily behind the + * same env var it keys off. Consumers of the published addon never load it. + */ +function coverageBabelPlugins() { + if (process.env.COVERAGE !== 'true') { + return []; + } + + // eslint-disable-next-line n/no-unpublished-require -- dev-only, guarded above + return require('ember-cli-code-coverage').buildBabelPlugin(); +} + module.exports = { name: require('./package').name, + options: { + babel: { + plugins: [...coverageBabelPlugins()], + }, + }, + isDevelopingAddon: function () { return true; }, diff --git a/package.json b/package.json index 0239938d..e3c1d97b 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "start": "ember serve", "test": "concurrently \"npm:lint\" \"npm:test:*\" --names \"lint,test:\"", "test:ember": "ember test", + "coverage": "COVERAGE=true ember test", + "coverage:check": "node --test scripts/check-coverage.test.mjs && node scripts/check-coverage.mjs", "test:ember-compatibility": "ember try:each", "publish:npm": "npm config set registry https://registry.npmjs.org/ && npm publish", "publish:github": "npm config set '@fleetbase:registry' https://npm.pkg.github.com/ && npm publish" @@ -42,8 +44,10 @@ "ember-cli-babel": "^8.2.0", "ember-cli-htmlbars": "^6.3.0", "ember-cli-notifications": "^9.0.0", + "ember-cli-string-helpers": "^6.1.0", "ember-concurrency": "^4.0.4", "ember-decorators": "^6.1.1", + "ember-fetch": "^8.1.2", "ember-get-config": "^2.1.1", "ember-inflector": "^4.0.2", "ember-intl": "6.3.2", @@ -51,12 +55,14 @@ "ember-local-storage": "^2.0.4", "ember-simple-auth": "^6.0.0", "ember-wormhole": "^0.6.0", - "socketcluster-client": "^17.1.1" + "socketcluster-client": "^17.1.1", + "tracked-built-ins": "^3.4.0" }, "devDependencies": { "@babel/eslint-parser": "^7.22.15", "@babel/plugin-proposal-decorators": "^7.23.2", "@ember/optional-features": "^2.0.0", + "@ember/string": "^3.1.1", "@ember/test-helpers": "^3.2.0", "@embroider/test-setup": "^3.0.2", "@glimmer/component": "^1.1.2", @@ -68,6 +74,7 @@ "concurrently": "^8.2.2", "ember-cli": "~5.4.1", "ember-cli-clean-css": "^3.0.0", + "ember-cli-code-coverage": "^3.1.0", "ember-cli-dependency-checker": "^3.3.2", "ember-cli-inject-live-reload": "^2.1.0", "ember-cli-sri": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d988655..e21b9915 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,12 +32,18 @@ importers: ember-cli-notifications: specifier: ^9.0.0 version: 9.1.0(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))) + ember-cli-string-helpers: + specifier: ^6.1.0 + version: 6.1.0 ember-concurrency: specifier: ^4.0.4 version: 4.0.6(@babel/core@7.29.0) ember-decorators: specifier: ^6.1.1 version: 6.1.1 + ember-fetch: + specifier: ^8.1.2 + version: 8.1.2 ember-get-config: specifier: ^2.1.1 version: 2.1.1(@babel/core@7.29.0) @@ -62,6 +68,9 @@ importers: socketcluster-client: specifier: ^17.1.1 version: 17.2.2 + tracked-built-ins: + specifier: ^3.4.0 + version: 3.4.0(@babel/core@7.29.0) devDependencies: '@babel/eslint-parser': specifier: ^7.22.15 @@ -72,6 +81,9 @@ importers: '@ember/optional-features': specifier: ^2.0.0 version: 2.3.0 + '@ember/string': + specifier: ^3.1.1 + version: 3.1.1 '@ember/test-helpers': specifier: ^3.2.0 version: 3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)) @@ -105,6 +117,9 @@ importers: ember-cli-clean-css: specifier: ^3.0.0 version: 3.0.0 + ember-cli-code-coverage: + specifier: ^3.1.0 + version: 3.1.0 ember-cli-dependency-checker: specifier: ^3.3.2 version: 3.3.3(ember-cli@5.4.2(@babel/core@7.29.0)(@types/node@25.6.2)(handlebars@4.7.9)(underscore@1.13.8)) @@ -1174,6 +1189,14 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1264,6 +1287,9 @@ packages: resolution: {integrity: sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==} engines: {node: '>=6'} + '@types/acorn@4.0.6': + resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -1339,6 +1365,9 @@ packages: '@types/node@25.6.2': resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==} + '@types/node@9.6.61': + resolution: {integrity: sha512-/aKAdg5c8n468cYLy2eQrcR5k6chlbNwZNGUj3TboyPa2hcO2QAJcfymlqPzMiRj8B6nYKXjzQz36minFE0RwQ==} + '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -1430,6 +1459,9 @@ packages: abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + abortcontroller-polyfill@1.7.8: + resolution: {integrity: sha512-9f1iZ2uWh92VcrU9Y8x+LdM4DLj75VE0MJB8zuF1iUnroEptStw+DQ8EQPMUdfe5k+PkB1uUfDQfWbhstH8LrQ==} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -1438,6 +1470,10 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + acorn-dynamic-import@3.0.0: + resolution: {integrity: sha512-zVWV8Z8lislJoOKKqdNMOB+s6+XV5WERty8MnKBeFgwA+19XJjJHs2RP5dzM57FftIs+jQnRToLiWazKr6sSWg==} + deprecated: This is probably built in to whatever tool you're using. If you still need it... idk + acorn-import-phases@1.0.4: resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} engines: {node: '>=10.13.0'} @@ -1449,6 +1485,11 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn@5.7.4: + resolution: {integrity: sha512-1D++VG7BhrtvQpNbBzovKNc1FLGGEE/oGe7b9xJm/RFHMBeUaUGpluV9RLjZa47YFdPcDAenEYuq9pQPcMdLJg==} + engines: {node: '>=0.4.0'} + hasBin: true + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -1553,6 +1594,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1708,6 +1752,10 @@ packages: resolution: {integrity: sha512-QWjjFgSKtSRIcsBhJmEwS2laIdrA6na8HAlc/pEAhjHgQsah/gMiBFRZvbQTy//hWxR4BMwV7/Mya7q5H8uHeA==} engines: {node: 10.* || >= 12.*} + babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + babel-plugin-module-resolver@3.2.0: resolution: {integrity: sha512-tjR0GvSndzPew/Iayf4uICWZqjBwnlMWjSx6brryfQ81F9rxBVqwDJtFCV8oOs0+vJeefK9TmdZtkIFdFe1UnA==} engines: {node: '>= 6.0.0'} @@ -1955,6 +2003,10 @@ packages: resolution: {integrity: sha512-a4zUsWtA1uns1K7p9rExYVYG99rdKeGRymW0qOCNkvDPHQxVi3yVyJHhQbM3EZwdt2E0mnhr5e0c/bPpJ7p3Wg==} engines: {node: 10.* || >= 12.*} + broccoli-rollup@2.1.1: + resolution: {integrity: sha512-aky/Ovg5DbsrsJEx2QCXxHLA6ZR+9u1TNVTf85soP4gL8CjGGKQ/JU8R3BZ2ntkWzo6/83RCKzX6O+nlNKR5MQ==} + engines: {node: '>=4.0'} + broccoli-rollup@5.0.0: resolution: {integrity: sha512-QdMuXHwsdz/LOS8zu4HP91Sfi4ofimrOXoYP/lrPdRh7lJYD87Lfq4WzzUhGHsxMfzANIEvl/7qVHKD3cFJ4tA==} engines: {node: '>=12.0'} @@ -1977,6 +2029,10 @@ packages: resolution: {integrity: sha512-NXfi+Vas24n3Ivo21GvENTI55qxKu7OwKRnCLWXld8MiLiQKQlWIq28eoARaFj0lTUFwUa4jKZeA7fW9PiWQeg==} engines: {node: 8.* || >= 10.*} + broccoli-templater@2.0.2: + resolution: {integrity: sha512-71KpNkc7WmbEokTQpGcbGzZjUIY1NSVa3GB++KFKAfx5SZPUozCOsBlSTwxcv8TLoCAqbBnsX5AQPgg6vJ2l9g==} + engines: {node: 6.* || >= 8.*} + broccoli-terser-sourcemap@4.1.1: resolution: {integrity: sha512-8sbpRf0/+XeszBJQM7vph2UNj4Kal0lCI/yubcrBIzb2NvYj5gjTHJABXOdxx5mKNmlCMu2hx2kvOtMpQsxrfg==} engines: {node: ^10.12.0 || 12.* || >= 14} @@ -2048,6 +2104,10 @@ packages: resolution: {integrity: sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg==} engines: {node: '>=12'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + camelcase@6.3.0: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} @@ -2056,6 +2116,9 @@ packages: resolution: {integrity: sha512-RbsNrFyhwkx+6psk/0fK/Q9orOUr9VMxohGd8vTa4djf4TGLfblBgUfqZChrZuW0Q+mz2eBPFLusw9Jfukzmhg==} hasBin: true + caniuse-api@3.0.0: + resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} + caniuse-lite@1.0.30001792: resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} @@ -2528,6 +2591,10 @@ packages: resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} engines: {node: '>=0.11'} + date-time@2.1.0: + resolution: {integrity: sha512-/9+C44X7lot0IeiyfgJmETtRMhBidBYM2QFFIkGa0U1k+hSyY87Nw7PY3eDqpvCBm7I3WCSfPeZskW/YYq6m4g==} + engines: {node: '>=4'} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -2725,6 +2792,18 @@ packages: resolution: {integrity: sha512-BbveJCyRvzzkaTH1llLW+MpHe/yzA5zpHOpMIg2vp/3JD9mban9zUm7lphaB0TSpPuMuby9rAhTI8pgXq0ifIA==} engines: {node: 16.* || >= 18} + ember-cli-code-coverage@3.1.0: + resolution: {integrity: sha512-ODRYNClYaUglbGZX86iOhOTIZI86QDxmEgKVHqaPjQNKKhoBeJcfb9n4sRSFK8w6YrYsjenKI6V6h9oT3lVhtg==} + engines: {node: '>= 18'} + peerDependencies: + '@embroider/compat': ^0.47.0 || ^1.0.0 || ^2.0.0 || >=3.0.0 + '@embroider/core': ^0.47.0 || ^1.0.0 || ^2.0.0 || >=3.0.0 + peerDependenciesMeta: + '@embroider/compat': + optional: true + '@embroider/core': + optional: true + ember-cli-dependency-checker@3.3.3: resolution: {integrity: sha512-mvp+HrE0M5Zhc2oW8cqs8wdhtqq0CfQXAYzaIstOzHJJn/U01NZEGu3hz7J7zl/+jxZkyygylzcS57QqmPXMuQ==} engines: {node: '>= 6'} @@ -2776,6 +2855,10 @@ packages: resolution: {integrity: sha512-YG/lojDxkur9Bnskt7xB6gUOtJ6aPl/+JyGYm9HNDk3GECVHB3SMN3rlGhDKHa1ndS5NK2W2TSLb9bzRbGlMdg==} engines: {node: '>= 0.10.0'} + ember-cli-string-helpers@6.1.0: + resolution: {integrity: sha512-Lw8B6MJx2n8CNF2TSIKs+hWLw0FqSYjr2/NRPyquyYA05qsl137WJSYW3ZqTsLgoinHat0DGF2qaCXocLhLmyA==} + engines: {node: 10.* || >=12.*} + ember-cli-string-utils@1.1.0: resolution: {integrity: sha512-PlJt4fUDyBrC/0X+4cOpaGCiMawaaB//qD85AXmDRikxhxVzfVdpuoec02HSiTGTTB85qCIzWBIh8lDOiMyyFg==} @@ -2879,6 +2962,10 @@ packages: resolution: {integrity: sha512-TovtNqCumzyAiW0/OisSkkVK93xnVF4NRU6+FN0ubpfwEOpRrmM2RqDwXI6YAChCgSHON1cz0DfQStpA1Gjuuw==} engines: {node: 10.* || >= 12} + ember-fetch@8.1.2: + resolution: {integrity: sha512-TVx24/jrvDIuPL296DV0hBwp7BWLcSMf0I8464KGz01sPytAB+ZAePbc9ooBTJDkKZEGFgatJa4nj3yF1S9Bpw==} + engines: {node: '>= 10'} + ember-file-upload@8.4.0: resolution: {integrity: sha512-iiE3iE/7hncBiU5cvjitpc0whfeAMvdyW2ReiDCzKe9ByVZlgG7Hnp89EPE6c8aSvTLXCQ3X6SsF1U4LTgh75g==} engines: {node: 16.* || >= 18} @@ -3584,6 +3671,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -3820,6 +3911,9 @@ packages: resolution: {integrity: sha512-HVJyzUrLIL1c0QmviVh5E8VGyUS7xCFPS6yydaVd1UegW+ibV/CohqTH9MkOLDp5o+rb82DMo77PTuc9F/8GKw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-tags@3.3.1: resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} engines: {node: '>=8'} @@ -4102,6 +4196,9 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-reference@1.2.1: + resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -4197,6 +4294,26 @@ packages: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + istextorbinary@2.1.0: resolution: {integrity: sha512-kT1g2zxZ5Tdabtpp9VSdOzW9lb6LXImyWbzbQeTxoRtHhurC9Ej9Wckngr2+uepPL09ky/mJHmN9jeJPML5t6A==} engines: {node: '>=0.12'} @@ -4219,6 +4336,10 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@3.15.0: + resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} + hasBin: true + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -4333,6 +4454,9 @@ packages: loader.js@4.7.0: resolution: {integrity: sha512-9M2KvGT6duzGMgkOcTkWb+PR/Q2Oe54df/tLgHGVmFpAmtqJ553xJh6N63iFYI2yjo2PeJXbS5skHi/QpJq4vA==} + locate-character@2.0.5: + resolution: {integrity: sha512-n2GmejDXtOPBAZdIiEFy5dJ5N38xBCXLNOtw2WpB9kGh6pnrEuKlwYI+Tkpofc4wDtVXHtoAOJaMRlYG/oYaxg==} + locate-path@2.0.0: resolution: {integrity: sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==} engines: {node: '>=4'} @@ -4353,6 +4477,9 @@ packages: resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lodash._reinterpolate@3.0.0: + resolution: {integrity: sha512-xYHt68QRoYGjeeM/XOE1uJtvXQAgvszfBhjV4yvsQH0u2i9I6cI6c6/eG4Hh3UAOVn0y/xAXwmTzEay49Q//HA==} + lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} @@ -4368,12 +4495,25 @@ packages: lodash.kebabcase@4.1.1: resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.template@4.18.1: + resolution: {integrity: sha512-5urZrLnV/VD6zHK5KsVtZgt7H19v51mIzoS0aBNH8yp3I8tbswrEjOABOPY8m8uB7NuibubLrMX+Y0PXsU9X+w==} + deprecated: This package is deprecated. Use https://socket.dev/npm/package/eta instead. + + lodash.templatesettings@4.2.0: + resolution: {integrity: sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ==} + lodash.truncate@4.4.2: resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -4414,6 +4554,9 @@ packages: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} + magic-string@0.24.1: + resolution: {integrity: sha512-YBfNxbJiixMzxW40XqJEIldzHyh5f7CZKalo1uZffevyrPEX8Qgo9s0dmcORLHdV47UyvJg8/zD+6hQG3qvJrA==} + magic-string@0.25.9: resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} @@ -4424,6 +4567,10 @@ packages: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} @@ -4688,6 +4835,10 @@ packages: no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + node-dir@0.1.17: + resolution: {integrity: sha512-tmPX422rYgofd4epzrNoOXiE8XFZYOcCq1vD7MAXCDO+O+zndlA2ztdKKMa+EeuBG5tHETpr4ml4RGgpqDCCAg==} + engines: {node: '>= 0.10.5'} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -4926,6 +5077,10 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-ms@1.0.1: + resolution: {integrity: sha512-LpH1Cf5EYuVjkBvCDBYvkUPh+iv2bk3FHflxHkpCYT0/FZ1d3N3uJaLiHr4yGuMcFUhv6eAivitTvWZI4B/chg==} + engines: {node: '>=0.10.0'} + parse-ms@4.0.0: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} @@ -5115,6 +5270,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-ms@3.2.0: + resolution: {integrity: sha512-ZypexbfVUGTFxb0v+m1bUyy92DHe5SyYlnyY0msyms5zd3RwyvNgyxZZsXXgoyzlxjx5MiqtXUdhUfvQbe0A2Q==} + engines: {node: '>=4'} + pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -5314,6 +5473,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-relative@0.8.7: + resolution: {integrity: sha512-AKGr4qvHiryxRb19m3PsLRGuKVAbJLUD7E6eOaHkfKhwc+vSgVOCY5xNvm9EkolBKTOf0GrQAZKLimOCz81Khg==} + requireindex@1.2.0: resolution: {integrity: sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==} engines: {node: '>=0.10.5'} @@ -5424,6 +5586,10 @@ packages: rollup-pluginutils@2.8.2: resolution: {integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==} + rollup@0.57.1: + resolution: {integrity: sha512-I18GBqP0qJoJC1K1osYjreqA8VAKovxuI3I81RSk0Dmr4TgloI0tAULjZaox8OsJ+n7XRrhH6i0G2By/pj1LCA==} + hasBin: true + rollup@2.80.0: resolution: {integrity: sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==} engines: {node: '>=10.0.0'} @@ -5747,6 +5913,9 @@ packages: resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==} engines: {node: '>=0.10.0'} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} @@ -6003,6 +6172,10 @@ packages: engines: {node: '>=10'} hasBin: true + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + testem@3.20.0: resolution: {integrity: sha512-SSFfJQK/SGruISFjoKG2jCYwK596wWNPJFj2Wo77GzeIUxZ8ZjuwpyF01uekTLu4ITL6i9R4m1sWaKPK/HsunA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -6024,6 +6197,10 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + time-zone@1.0.0: + resolution: {integrity: sha512-TIsDdtKo6+XrPtiTm1ssmMngN1sAhyKnTO2kunQWqNPWIVvCm15Wmw4SWInwTVgJ5u/Tr04+8Ei9TNcw4x4ONA==} + engines: {node: '>=4'} + tiny-glob@0.2.9: resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} @@ -6335,6 +6512,9 @@ packages: resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} engines: {node: '>=0.8.0'} + whatwg-fetch@3.6.20: + resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -7908,6 +8088,16 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.15.0 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.6': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -7999,6 +8189,10 @@ snapshots: dependencies: defer-to-connect: 1.1.3 + '@types/acorn@4.0.6': + dependencies: + '@types/estree': 1.0.9 + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -8070,7 +8264,7 @@ snapshots: '@types/glob@9.0.0': dependencies: - glob: 8.1.0 + glob: 13.0.6 '@types/http-errors@2.0.5': {} @@ -8094,6 +8288,8 @@ snapshots: dependencies: undici-types: 7.19.2 + '@types/node@9.6.61': {} + '@types/normalize-package-data@2.4.4': {} '@types/qs@6.15.1': {} @@ -8216,6 +8412,8 @@ snapshots: abbrev@1.1.1: {} + abortcontroller-polyfill@1.7.8: {} + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -8226,6 +8424,10 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 + acorn-dynamic-import@3.0.0: + dependencies: + acorn: 5.7.4 + acorn-import-phases@1.0.4(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -8234,6 +8436,8 @@ snapshots: dependencies: acorn: 8.16.0 + acorn@5.7.4: {} + acorn@8.16.0: {} ag-channel@5.0.0: @@ -8324,6 +8528,10 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} aria-query@5.3.2: {} @@ -8473,6 +8681,16 @@ snapshots: parse-static-imports: 1.1.0 string.prototype.matchall: 4.0.12 + babel-plugin-istanbul@6.1.1: + dependencies: + '@babel/helper-plugin-utils': 7.28.6 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + babel-plugin-module-resolver@3.2.0: dependencies: find-babel-config: 1.2.2 @@ -8978,6 +9196,22 @@ snapshots: transitivePeerDependencies: - supports-color + broccoli-rollup@2.1.1: + dependencies: + '@types/node': 9.6.61 + amd-name-resolver: 1.3.1 + broccoli-plugin: 1.3.1 + fs-tree-diff: 0.5.9 + heimdalljs: 0.2.6 + heimdalljs-logger: 0.1.10 + magic-string: 0.24.1 + node-modules-path: 1.0.2 + rollup: 0.57.1 + symlink-or-copy: 1.3.1 + walk-sync: 0.3.4 + transitivePeerDependencies: + - supports-color + broccoli-rollup@5.0.0: dependencies: '@types/broccoli-plugin': 3.0.4 @@ -9031,6 +9265,16 @@ snapshots: transitivePeerDependencies: - supports-color + broccoli-templater@2.0.2: + dependencies: + broccoli-plugin: 1.3.1 + fs-tree-diff: 0.5.9 + lodash.template: 4.18.1 + rimraf: 2.7.1 + walk-sync: 0.3.4 + transitivePeerDependencies: + - supports-color + broccoli-terser-sourcemap@4.1.1: dependencies: async-promise-queue: 1.0.5 @@ -9158,12 +9402,21 @@ snapshots: quick-lru: 5.1.1 type-fest: 1.4.0 + camelcase@5.3.1: {} + camelcase@6.3.0: {} can-symlink@1.0.0: dependencies: tmp: 0.0.28 + caniuse-api@3.0.0: + dependencies: + browserslist: 4.28.2 + caniuse-lite: 1.0.30001792 + lodash.memoize: 4.1.2 + lodash.uniq: 4.5.0 + caniuse-lite@1.0.30001792: {} capture-exit@2.0.0: @@ -9487,6 +9740,10 @@ snapshots: dependencies: '@babel/runtime': 7.29.2 + date-time@2.1.0: + dependencies: + time-zone: 1.0.0 + debug@2.6.9: dependencies: ms: 2.0.0 @@ -9777,6 +10034,22 @@ snapshots: transitivePeerDependencies: - supports-color + ember-cli-code-coverage@3.1.0: + dependencies: + babel-plugin-istanbul: 6.1.1 + body-parser: 1.20.5 + ember-cli-babel: 7.26.11 + express: 4.22.1 + fs-extra: 9.1.0 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.2.0 + node-dir: 0.1.17 + walk-sync: 2.2.0 + transitivePeerDependencies: + - supports-color + ember-cli-dependency-checker@3.3.3(ember-cli@5.4.2(@babel/core@7.29.0)(@types/node@25.6.2)(handlebars@4.7.9)(underscore@1.13.8)): dependencies: chalk: 2.4.2 @@ -9886,6 +10159,15 @@ snapshots: transitivePeerDependencies: - supports-color + ember-cli-string-helpers@6.1.0: + dependencies: + '@babel/core': 7.29.0 + broccoli-funnel: 3.0.8 + ember-cli-babel: 7.26.11 + resolve: 1.22.12 + transitivePeerDependencies: + - supports-color + ember-cli-string-utils@1.1.0: {} ember-cli-terser@4.0.2: @@ -10249,6 +10531,26 @@ snapshots: - '@babel/core' - supports-color + ember-fetch@8.1.2: + dependencies: + abortcontroller-polyfill: 1.7.8 + broccoli-concat: 4.2.7 + broccoli-debug: 0.6.5 + broccoli-merge-trees: 4.2.0 + broccoli-rollup: 2.1.1 + broccoli-stew: 3.0.0 + broccoli-templater: 2.0.2 + calculate-cache-key-for-tree: 2.0.0 + caniuse-api: 3.0.0 + ember-cli-babel: 7.26.11 + ember-cli-typescript: 4.2.1 + ember-cli-version-checker: 5.1.2 + node-fetch: 2.7.0 + whatwg-fetch: 3.6.20 + transitivePeerDependencies: + - encoding + - supports-color + ember-file-upload@8.4.0(@babel/core@7.29.0)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-modifier@4.3.0(@babel/core@7.29.0))(tracked-built-ins@3.4.0(@babel/core@7.29.0))(webpack@5.106.2(postcss@8.5.14)): dependencies: '@ember/test-helpers': 3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)) @@ -11430,6 +11732,8 @@ snapshots: hasown: 2.0.3 math-intrinsics: 1.1.0 + get-package-type@0.1.0: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -11725,6 +12029,8 @@ snapshots: dependencies: lru-cache: 7.18.3 + html-escaper@2.0.2: {} + html-tags@3.3.1: {} http-cache-semantics@4.2.0: {} @@ -12016,6 +12322,10 @@ snapshots: is-promise@4.0.0: {} + is-reference@1.2.1: + dependencies: + '@types/estree': 1.0.9 + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -12093,6 +12403,37 @@ snapshots: isobject@3.0.1: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@5.2.1: + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.3 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@4.0.1: + dependencies: + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + istextorbinary@2.1.0: dependencies: binaryextensions: 2.3.0 @@ -12121,6 +12462,11 @@ snapshots: js-tokens@4.0.0: {} + js-yaml@3.15.0: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -12229,6 +12575,8 @@ snapshots: loader.js@4.7.0: {} + locate-character@2.0.5: {} + locate-path@2.0.0: dependencies: p-locate: 2.0.0 @@ -12251,6 +12599,8 @@ snapshots: dependencies: p-locate: 6.0.0 + lodash._reinterpolate@3.0.0: {} + lodash.camelcase@4.3.0: {} lodash.debounce@4.0.8: {} @@ -12261,10 +12611,23 @@ snapshots: lodash.kebabcase@4.1.1: {} + lodash.memoize@4.1.2: {} + lodash.merge@4.6.2: {} + lodash.template@4.18.1: + dependencies: + lodash._reinterpolate: 3.0.0 + lodash.templatesettings: 4.2.0 + + lodash.templatesettings@4.2.0: + dependencies: + lodash._reinterpolate: 3.0.0 + lodash.truncate@4.4.2: {} + lodash.uniq@4.5.0: {} + lodash@4.18.1: {} log-symbols@2.2.0: @@ -12298,6 +12661,10 @@ snapshots: lru-cache@7.18.3: {} + magic-string@0.24.1: + dependencies: + sourcemap-codec: 1.4.8 + magic-string@0.25.9: dependencies: sourcemap-codec: 1.4.8 @@ -12310,6 +12677,10 @@ snapshots: dependencies: semver: 6.3.1 + make-dir@4.0.0: + dependencies: + semver: 7.8.0 + makeerror@1.0.12: dependencies: tmpl: 1.0.5 @@ -12571,6 +12942,10 @@ snapshots: lower-case: 2.0.2 tslib: 2.8.1 + node-dir@0.1.17: + dependencies: + minimatch: 3.1.5 + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -12814,6 +13189,8 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-ms@1.0.1: {} + parse-ms@4.0.0: {} parse-passwd@1.0.0: {} @@ -12954,6 +13331,10 @@ snapshots: prettier@3.8.3: {} + pretty-ms@3.2.0: + dependencies: + parse-ms: 1.0.1 + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 @@ -13188,6 +13569,8 @@ snapshots: require-from-string@2.0.2: {} + require-relative@0.8.7: {} + requireindex@1.2.0: {} requires-port@1.0.0: {} @@ -13287,6 +13670,20 @@ snapshots: dependencies: estree-walker: 0.6.1 + rollup@0.57.1: + dependencies: + '@types/acorn': 4.0.6 + acorn: 5.7.4 + acorn-dynamic-import: 3.0.0 + date-time: 2.1.0 + is-reference: 1.2.1 + locate-character: 2.0.5 + pretty-ms: 3.2.0 + require-relative: 0.8.7 + rollup-pluginutils: 2.8.2 + signal-exit: 3.0.7 + sourcemap-codec: 1.4.8 + rollup@2.80.0: optionalDependencies: fsevents: 2.3.3 @@ -13714,6 +14111,8 @@ snapshots: dependencies: extend-shallow: 3.0.2 + sprintf-js@1.0.3: {} + sprintf-js@1.1.3: {} sri-toolbox@0.2.0: {} @@ -14001,6 +14400,12 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 7.2.3 + minimatch: 3.1.5 + testem@3.20.0(@babel/core@7.29.0)(handlebars@4.7.9)(underscore@1.13.8): dependencies: '@xmldom/xmldom': 0.9.10 @@ -14095,6 +14500,8 @@ snapshots: through@2.3.8: {} + time-zone@1.0.0: {} + tiny-glob@0.2.9: dependencies: globalyzer: 0.1.0 @@ -14462,6 +14869,8 @@ snapshots: websocket-extensions@0.1.4: {} + whatwg-fetch@3.6.20: {} + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 10ed64a5..1e53ad79 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,5 @@ +packages: + - '.' + allowBuilds: core-js: false diff --git a/scripts/check-coverage.mjs b/scripts/check-coverage.mjs new file mode 100644 index 00000000..e59cba6c --- /dev/null +++ b/scripts/check-coverage.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +/** + * Coverage gate for @fleetbase/ember-core. + * + * Verifies that: + * 1. Every eligible first-party JavaScript file under addon/ is present in + * the generated coverage report (files with no tests may not silently + * drop out of the denominator). + * 2. Every eligible file is at 100% statements, branches, functions, and lines. + * + * Usage: node scripts/check-coverage.mjs [--summary ] [--addon-dir ] + * Exits non-zero when the gate fails. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +export const METRICS = ['statements', 'branches', 'functions', 'lines']; + +export function listEligibleFiles(addonDir) { + const files = []; + const walk = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.isFile() && entry.name.endsWith('.js')) { + files.push(full); + } + } + }; + walk(addonDir); + return files.sort(); +} + +function normalize(p) { + return p.split(path.sep).join('/'); +} + +// Match a coverage summary key (absolute or relative) to an eligible source file. +export function findSummaryKey(summary, file) { + const target = normalize(path.resolve(file)); + for (const key of Object.keys(summary)) { + if (key === 'total') continue; + const normalizedKey = normalize(path.isAbsolute(key) ? key : path.resolve(key)); + if (normalizedKey === target || normalizedKey.endsWith('/' + normalize(file))) { + return key; + } + } + return null; +} + +export function checkCoverage({ summary, eligibleFiles }) { + const missing = []; + const below = []; + + for (const file of eligibleFiles) { + const key = findSummaryKey(summary, file); + if (!key) { + missing.push(file); + continue; + } + const entry = summary[key]; + for (const metric of METRICS) { + const summaryMetric = entry?.[metric]; + + // A file with nothing to instrument is vacuously covered. Pure + // re-export barrels are the real case: `export { default as X } + // from './x'` compiles away, so istanbul records an empty + // statementMap and then reports 0/0 as pct 0 — which would make + // those files impossible to pass no matter what tests exist. + // The file must still be PRESENT; that is checked above. + if (summaryMetric?.total === 0) { + continue; + } + + if (summaryMetric?.pct !== 100) { + below.push({ file, metric, pct: summaryMetric?.pct ?? 'n/a' }); + } + } + } + + return { missing, below, ok: missing.length === 0 && below.length === 0 }; +} + +export function main(argv = process.argv.slice(2)) { + const summaryArg = argv.indexOf('--summary'); + const addonArg = argv.indexOf('--addon-dir'); + const summaryPath = summaryArg !== -1 ? argv[summaryArg + 1] : 'coverage/coverage-summary.json'; + const addonDir = addonArg !== -1 ? argv[addonArg + 1] : 'addon'; + + if (!fs.existsSync(summaryPath)) { + console.error(`Coverage gate: summary not found at ${summaryPath}. Run the coverage suite first.`); + return 1; + } + + const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8')); + const eligibleFiles = listEligibleFiles(addonDir); + + if (eligibleFiles.length === 0) { + console.error(`Coverage gate: no eligible source files found under ${addonDir}.`); + return 1; + } + + const { missing, below, ok } = checkCoverage({ summary, eligibleFiles }); + + if (missing.length > 0) { + console.error(`Coverage gate: ${missing.length} eligible file(s) missing from the coverage report:`); + for (const file of missing) console.error(` - ${file}`); + } + + if (below.length > 0) { + console.error(`Coverage gate: ${below.length} metric(s) below 100%:`); + for (const { file, metric, pct } of below) console.error(` - ${file} ${metric}: ${pct}%`); + } + + if (ok) { + console.log(`Coverage gate: all ${eligibleFiles.length} eligible files at 100% statements/branches/functions/lines.`); + return 0; + } + + return 1; +} + +if (normalize(path.resolve(process.argv[1] ?? '')) === normalize(new URL(import.meta.url).pathname)) { + process.exit(main()); +} diff --git a/scripts/check-coverage.test.mjs b/scripts/check-coverage.test.mjs new file mode 100644 index 00000000..7d79fd56 --- /dev/null +++ b/scripts/check-coverage.test.mjs @@ -0,0 +1,104 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { checkCoverage, findSummaryKey, listEligibleFiles, main, METRICS } from './check-coverage.mjs'; + +function fullEntry() { + return Object.fromEntries(METRICS.map((m) => [m, { pct: 100 }])); +} + +function makeFixture({ files, summary }) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'covgate-')); + const addonDir = path.join(dir, 'addon'); + for (const file of files) { + const full = path.join(addonDir, file); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, 'export default 1;\n'); + } + const summaryPath = path.join(dir, 'coverage-summary.json'); + fs.writeFileSync(summaryPath, JSON.stringify(summary)); + return { dir, addonDir, summaryPath }; +} + +test('passes when every eligible file is present at 100%', () => { + const { addonDir } = makeFixture({ files: ['utils/a.js'], summary: {} }); + const summary = { total: fullEntry(), [path.join(addonDir, 'utils/a.js')]: fullEntry() }; + const result = checkCoverage({ summary, eligibleFiles: listEligibleFiles(addonDir) }); + assert.equal(result.ok, true); + assert.deepEqual(result.missing, []); + assert.deepEqual(result.below, []); +}); + +test('fails when an eligible file is absent from the report', () => { + const { addonDir } = makeFixture({ files: ['utils/a.js', 'utils/untested.js'], summary: {} }); + const summary = { [path.join(addonDir, 'utils/a.js')]: fullEntry() }; + const result = checkCoverage({ summary, eligibleFiles: listEligibleFiles(addonDir) }); + assert.equal(result.ok, false); + assert.equal(result.missing.length, 1); + assert.match(result.missing[0], /untested\.js$/); +}); + +test('fails when any metric is below 100%', () => { + const { addonDir } = makeFixture({ files: ['utils/a.js'], summary: {} }); + const entry = fullEntry(); + entry.branches = { pct: 87.5 }; + const summary = { [path.join(addonDir, 'utils/a.js')]: entry }; + const result = checkCoverage({ summary, eligibleFiles: listEligibleFiles(addonDir) }); + assert.equal(result.ok, false); + assert.deepEqual(result.below.map(({ metric, pct }) => ({ metric, pct })), [{ metric: 'branches', pct: 87.5 }]); +}); + +test('treats a file with nothing to instrument as covered', () => { + // Pure re-export barrels compile away entirely, so istanbul records an + // empty statementMap and reports 0/0 as pct 0. Without this, such a file + // could never pass the gate no matter what tests were written. + const { addonDir } = makeFixture({ files: ['contracts/index.js'], summary: {} }); + const empty = { total: 0, covered: 0, skipped: 0, pct: 0 }; + const summary = { + [path.join(addonDir, 'contracts/index.js')]: { statements: empty, branches: empty, functions: empty, lines: { ...empty } }, + }; + + const result = checkCoverage({ summary, eligibleFiles: listEligibleFiles(addonDir) }); + + assert.equal(result.ok, true); + assert.deepEqual(result.below, []); +}); + +test('an empty file must still be present in the report', () => { + const { addonDir } = makeFixture({ files: ['contracts/index.js'], summary: {} }); + + const result = checkCoverage({ summary: {}, eligibleFiles: listEligibleFiles(addonDir) }); + + assert.equal(result.ok, false); + assert.equal(result.missing.length, 1); +}); + +test('matches relative summary keys against absolute source paths', () => { + const { addonDir } = makeFixture({ files: ['utils/a.js'], summary: {} }); + const file = listEligibleFiles(addonDir)[0]; + const summary = { [path.join(addonDir, 'utils/a.js')]: fullEntry() }; + assert.ok(findSummaryKey(summary, file)); + assert.equal(findSummaryKey({}, file), null); +}); + +test('main exits nonzero when the summary file is missing', () => { + const exitCode = main(['--summary', path.join(os.tmpdir(), 'covgate-none', 'nope.json'), '--addon-dir', 'addon']); + assert.equal(exitCode, 1); +}); + +test('main exits zero on a fully covered report and nonzero on a partial one', () => { + const { addonDir, dir } = makeFixture({ files: ['utils/a.js'], summary: {} }); + const good = { total: fullEntry(), [path.join(addonDir, 'utils/a.js')]: fullEntry() }; + const goodPath = path.join(dir, 'good.json'); + fs.writeFileSync(goodPath, JSON.stringify(good)); + assert.equal(main(['--summary', goodPath, '--addon-dir', addonDir]), 0); + + const badEntry = fullEntry(); + badEntry.lines = { pct: 99.9 }; + const bad = { total: fullEntry(), [path.join(addonDir, 'utils/a.js')]: badEntry }; + const badPath = path.join(dir, 'bad.json'); + fs.writeFileSync(badPath, JSON.stringify(bad)); + assert.equal(main(['--summary', badPath, '--addon-dir', addonDir]), 1); +}); diff --git a/tests/dummy/config/coverage.js b/tests/dummy/config/coverage.js new file mode 100644 index 00000000..eebf29f2 --- /dev/null +++ b/tests/dummy/config/coverage.js @@ -0,0 +1,19 @@ +'use strict'; + +/** + * ember-cli-code-coverage configuration. + * + * Coverage is collected for this addon's first-party JavaScript only. `excludes` + * REPLACES the plugin's defaults rather than extending them, so the node_modules + * and mirage entries must be repeated here — without them istanbul instruments + * every dependency, which makes the build crawl and produces a coverage payload + * too large for the browser to serialize and POST back. + * + * The 100% gate itself is enforced by scripts/check-coverage.mjs, which also + * verifies that every eligible file under addon/ appears in the report. + */ +module.exports = { + useBabelInstrumenter: false, + reporters: ['lcov', 'json-summary', 'text-summary', 'json'], + excludes: ['*/node_modules/**/*', '*/mirage/**/*', '*/tests/**/*', '*/dummy/**/*'], +}; diff --git a/tests/dummy/config/environment.js b/tests/dummy/config/environment.js index 61f3a09d..fb98aaab 100644 --- a/tests/dummy/config/environment.js +++ b/tests/dummy/config/environment.js @@ -18,6 +18,32 @@ module.exports = function (environment) { // Here you can pass flags/options to your application instance // when it is created }, + + // Mirrors the env var ember-cli-code-coverage instruments on, so the test + // suite only pays the cost of collecting and shipping coverage when asked. + coverageEnabled: process.env.COVERAGE === 'true', + + // Configuration a host application is expected to provide. Several addon + // modules read these at import time (the fetch service touches + // API.host as soon as it is evaluated), so the dummy app has to supply + // them for those modules to be loadable at all. + API: { + host: 'https://api.fleetbase.test', + namespace: 'v1', + }, + + socket: { + hostname: 'socket.fleetbase.test', + secure: false, + }, + + osrm: { + host: 'https://routing.fleetbase.test', + servers: { + us: 'https://routing-us.fleetbase.test', + ca: 'https://routing-ca.fleetbase.test', + }, + }, }; if (environment === 'development') { diff --git a/tests/helpers/force-addon-modules.js b/tests/helpers/force-addon-modules.js new file mode 100644 index 00000000..bd698e30 --- /dev/null +++ b/tests/helpers/force-addon-modules.js @@ -0,0 +1,41 @@ +/** + * Evaluates every one of this addon's modules so that source files without + * tests still appear in the coverage report instead of dropping silently out + * of the denominator. + * + * ember-cli-code-coverage ships `forceModulesToBeLoaded`, but it walks every + * module in the build and its failure handling is not tight enough for this + * addon: a module whose import cannot be resolved wedges the end of the run, + * so testem never receives the completion signal and the suite hangs. This + * version is scoped to the addon and swallows per-module failures, which is + * safe because a module that cannot be evaluated simply stays absent from the + * report — and scripts/check-coverage.mjs fails the build when that happens, + * so nothing is hidden. + * + * Returns the names of modules that could not be evaluated. + */ +const ADDON_MODULE_PREFIX = '@fleetbase/ember-core/'; + +export default function forceAddonModulesToBeLoaded(prefix = ADDON_MODULE_PREFIX) { + const failed = []; + const entries = window.requirejs?.entries ?? {}; + + for (const moduleName of Object.keys(entries)) { + if (!moduleName.startsWith(prefix)) { + continue; + } + + // Templates and test-support modules are not first-party coverage targets. + if (moduleName.includes('/test-support/') || moduleName.endsWith('/template')) { + continue; + } + + try { + window.require(moduleName); + } catch (error) { + failed.push(moduleName); + } + } + + return failed; +} diff --git a/tests/helpers/stub-console-extensions.js b/tests/helpers/stub-console-extensions.js new file mode 100644 index 00000000..90cdd01f --- /dev/null +++ b/tests/helpers/stub-console-extensions.js @@ -0,0 +1,25 @@ +/** + * Provides the `@fleetbase/console/extensions` module the extension manager + * imports from its host application. + * + * Unlike the config imports, this one pulls a *function* out of the console app, + * so it cannot be redirected through ember-get-config. Without the module the + * extension manager cannot be evaluated at all, which keeps it out of the + * coverage report entirely. Registering an AMD stub keeps the addon testable + * without changing production code; tests that care about loader behaviour pass + * their own loader in. + */ +const MODULE_NAME = '@fleetbase/console/extensions'; + +export default function stubConsoleExtensions(getExtensionLoader = () => () => Promise.resolve(undefined)) { + // `define` is loader.js's global in a classic build. + // eslint-disable-next-line no-undef + if (typeof define !== 'function' || window.requirejs?.entries?.[MODULE_NAME]) { + return; + } + + // eslint-disable-next-line no-undef + define(MODULE_NAME, [], function () { + return { getExtensionLoader }; + }); +} diff --git a/tests/helpers/stub-ember-ui.js b/tests/helpers/stub-ember-ui.js new file mode 100644 index 00000000..bad3a5db --- /dev/null +++ b/tests/helpers/stub-ember-ui.js @@ -0,0 +1,78 @@ +import { capitalize, decamelize } from '@ember/string'; +import { humanize } from 'ember-cli-string-helpers/helpers/humanize'; +import { typeOf } from '@ember/utils'; + +/** + * Provides the @fleetbase/ember-ui modules this addon imports. + * + * addon/services/crud.js imports smart-humanize from @fleetbase/ember-ui. That + * package cannot be a dependency of this one, because ember-ui already depends + * on @fleetbase/ember-core — declaring it would be circular. In an application + * both are siblings and the module resolves; in the dummy app nothing provides + * it, so the crud service cannot even be loaded without this stub. + * + * The implementation below is a faithful copy of ember-ui's, so any test that + * does assert on humanized output is asserting real behaviour rather than a + * convenient simplification. + * + * Worth noting for the maintainers: ember-core already ships addon/utils/humanize.js + * with the same acronym list, so this cross-package reach is for a near-duplicate + * of something core already owns. + */ +const MODULE_NAME = '@fleetbase/ember-ui/utils/smart-humanize'; + +const UPPERCASE = [ + 'api', + 'vat', + 'id', + 'uuid', + 'sku', + 'ean', + 'upc', + 'erp', + 'tms', + 'wms', + 'ltl', + 'ftl', + 'lcl', + 'fcl', + 'rfid', + 'jot', + 'roi', + 'eta', + 'pod', + 'asn', + 'oem', + 'ddp', + 'fob', + 'gsm', + 'etd', + 'ect', + 'aws', + 'gcp', +]; + +export function smartHumanize(string) { + if (typeOf(string) !== 'string') { + return string; + } + + return humanize([decamelize(string)]) + .toLowerCase() + .split(' ') + .map((word) => (UPPERCASE.includes(word) ? word.toUpperCase() : capitalize(word))) + .join(' '); +} + +export default function stubEmberUi() { + // `define` is loader.js's global in a classic build. + // eslint-disable-next-line no-undef + if (typeof define !== 'function' || window.requirejs?.entries?.[MODULE_NAME]) { + return; + } + + // eslint-disable-next-line no-undef + define(MODULE_NAME, [], function () { + return { default: smartHumanize }; + }); +} diff --git a/tests/helpers/stub-socketcluster.js b/tests/helpers/stub-socketcluster.js new file mode 100644 index 00000000..174d4423 --- /dev/null +++ b/tests/helpers/stub-socketcluster.js @@ -0,0 +1,62 @@ +/** + * Keeps the real SocketCluster client out of the test suite. + * + * The socket service builds a client in its constructor, and socketcluster-client + * retries a failed connection forever. Under test that means an endless stream of + * `WebSocket connection to 'ws://localhost:PORT/socketcluster/' failed`, the page + * never goes idle, and the run never finishes. + * + * Two things have to be prevented: + * 1. the `load-socketcluster-client` initializer injecting the real script — we + * reuse its own `data-socketcluster-client` guard by planting a marker node, + * so no production code has to change; and + * 2. the global itself, which is replaced with an inert fake. + * + * Tests that exercise socket behaviour should register their own fake on the owner + * rather than relying on the shape of this one. + */ +const MARKER_SELECTOR = 'script[data-socketcluster-client]'; + +function createFakeChannel(name) { + return { + name, + // socketcluster channels are async iterables; an immediately-done iterator + // keeps `for await (... of channel)` loops from suspending forever. + [Symbol.asyncIterator]() { + return { next: () => Promise.resolve({ done: true, value: undefined }) }; + }, + listener() { + return { once: () => Promise.resolve() }; + }, + unsubscribe() {}, + close() {}, + }; +} + +export function createFakeSocketClusterClient() { + return { + create() { + return { + subscribe: (channelId) => createFakeChannel(channelId), + transmit() {}, + invoke: () => Promise.resolve(), + closeAllChannels() {}, + disconnect() {}, + listener() { + return { once: () => Promise.resolve() }; + }, + }; + }, + }; +} + +export default function stubSocketCluster() { + if (!document.querySelector(MARKER_SELECTOR)) { + const marker = document.createElement('script'); + marker.setAttribute('data-socketcluster-client', '1'); + // Deliberately has no `src`: it only satisfies the initializer's guard. + document.body.appendChild(marker); + } + + window.socketClusterClient = createFakeSocketClusterClient(); +} diff --git a/tests/test-helper.js b/tests/test-helper.js index 4efd6e58..64ffae2d 100644 --- a/tests/test-helper.js +++ b/tests/test-helper.js @@ -4,9 +4,79 @@ import * as QUnit from 'qunit'; import { setApplication } from '@ember/test-helpers'; import { setup } from 'qunit-dom'; import { start } from 'ember-qunit'; +import { sendCoverage } from 'ember-cli-code-coverage/test-support'; +import stubSocketCluster from './helpers/stub-socketcluster'; +import stubConsoleExtensions from './helpers/stub-console-extensions'; +import stubEmberUi from './helpers/stub-ember-ui'; +import forceAddonModulesToBeLoaded from './helpers/force-addon-modules'; +import resetStorages from 'ember-local-storage/test-support/reset-storage'; + +const COVERAGE_UPLOAD_TIMEOUT_MS = 60000; + +// Must run before the application boots so the socket service never builds a +// real client. See the helper for why an unstubbed client hangs the suite. +stubSocketCluster(); + +// Supplies the host-application module the extension manager imports, so that +// service can be loaded and measured at all. +stubConsoleExtensions(); + +// Supplies the sibling-package util the crud service imports; see the helper for +// why ember-ui cannot be a dependency of this addon. +stubEmberUi(); setApplication(Application.create(config.APP)); setup(QUnit.assert); +// ember-local-storage caches its storage objects across owners. Without a reset +// the second test to use a `storageFor` service inherits the previous test's +// destroyed object and fails with "calling set on destroyed object". +QUnit.testDone(function () { + resetStorages(); + window.localStorage.clear(); +}); + +// Pull this addon's untested modules into the coverage denominator and ship the +// report. A failed or stalled upload is reported as a global failure rather than +// left to hang the run, so a broken coverage pipeline is always visible. +QUnit.done(async function () { + if (!config.coverageEnabled) { + return; + } + + // A filtered run exercises a handful of tests but still force-loads every + // addon module, so it produces a report with the full denominator and a + // nearly empty numerator. Writing that would overwrite a good full-suite + // report with something that looks like a catastrophic regression, and the + // coverage gate would then read it. Partial runs are refused outright. + const { filter, module: moduleFilter, testId } = QUnit.config; + const partialRun = Boolean(filter) || Boolean(moduleFilter) || (Array.isArray(testId) && testId.length > 0); + + if (partialRun) { + // eslint-disable-next-line no-console + console.warn('[coverage] filtered run detected — report not written, so the full-suite report on disk is preserved'); + return; + } + + forceAddonModulesToBeLoaded(); + + const instrumentedFiles = Object.keys(window.__coverage__ ?? {}).length; + + let timeoutId; + try { + await Promise.race([ + sendCoverage(), + new Promise((resolve, reject) => { + timeoutId = setTimeout(() => reject(new Error(`coverage upload timed out after ${COVERAGE_UPLOAD_TIMEOUT_MS}ms`)), COVERAGE_UPLOAD_TIMEOUT_MS); + }), + ]); + } catch (error) { + // eslint-disable-next-line no-console + console.error(`[coverage] ${error.message} (instrumented files: ${instrumentedFiles})`); + } finally { + clearTimeout(timeoutId); + } +}); + start(); diff --git a/tests/unit/abilities/dynamic-test.js b/tests/unit/abilities/dynamic-test.js index 3e04439e..10c99559 100644 --- a/tests/unit/abilities/dynamic-test.js +++ b/tests/unit/abilities/dynamic-test.js @@ -1,11 +1,141 @@ import { module, test } from 'qunit'; -import { setupTest } from 'ember-qunit'; +import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; +import DynamicAbility from 'dummy/abilities/dynamic'; +/** + * The dynamic ability turns a permission string like "fleet-ops create order" + * into a yes/no, matching the user's granted permissions with support for two + * shapes of wildcard. + * + * Each assertion builds a fresh instance through `factoryFor`, because the + * ability snapshots the user's permissions in its constructor and `lookup` + * would hand back the same singleton with a stale snapshot. + */ module('Unit | Ability | dynamic', function (hooks) { setupTest(hooks); - test('it exists', function (assert) { - const ability = this.owner.lookup('ability:dynamic'); - assert.ok(ability); + hooks.beforeEach(function () { + this.permissions = []; + this.isAdmin = false; + const testContext = this; + + this.owner.register( + 'service:current-user', + class extends Service { + get permissions() { + return testContext.permissions; + } + get isAdmin() { + return testContext.isAdmin; + } + } + ); + + this.owner.register('ability:fleetbase-dynamic', DynamicAbility); + + this.build = () => this.owner.factoryFor('ability:fleetbase-dynamic').create(); + + this.can = (permissionString) => { + const ability = this.build(); + ability.parseProperty(permissionString); + return ability.can; + }; + }); + + module('parseProperty', function () { + test('it splits the permission into its three parts', function (assert) { + const ability = this.build(); + + const property = ability.parseProperty('fleet-ops create order'); + + assert.strictEqual(ability.service, 'fleet-ops'); + assert.strictEqual(ability.ability, 'create'); + assert.strictEqual(ability.resource, 'order'); + assert.strictEqual(property, 'can', 'it always resolves to the `can` getter'); + }); + + test('the resource is singularized', function (assert) { + const ability = this.build(); + + ability.parseProperty('fleet-ops create orders'); + + assert.strictEqual(ability.resource, 'order'); + }); + + test('a missing resource leaves it undefined', function (assert) { + const ability = this.build(); + + ability.parseProperty('fleet-ops create'); + + assert.strictEqual(ability.resource, undefined); + }); + }); + + module('permission matching', function () { + test('an exact permission is granted', function (assert) { + this.permissions = [{ name: 'fleet-ops create order' }]; + + assert.true(this.can('fleet-ops create order')); + }); + + test('an unrelated permission is not', function (assert) { + this.permissions = [{ name: 'fleet-ops create vehicle' }]; + + assert.false(this.can('fleet-ops create order')); + }); + + test('no permissions at all denies', function (assert) { + assert.false(this.can('fleet-ops create order')); + }); + + test('a resource wildcard grants every verb on that resource', function (assert) { + this.permissions = [{ name: 'fleet-ops * order' }]; + + assert.true(this.can('fleet-ops create order')); + assert.true(this.can('fleet-ops delete order')); + assert.false(this.can('fleet-ops create vehicle'), 'but only that resource'); + }); + + test('a service wildcard grants everything in the service', function (assert) { + this.permissions = [{ name: 'fleet-ops *' }]; + + assert.true(this.can('fleet-ops create order')); + assert.true(this.can('fleet-ops delete vehicle')); + assert.false(this.can('storefront create order'), 'but only that service'); + }); + + test('the plural form is matched against the singular permission', function (assert) { + this.permissions = [{ name: 'fleet-ops create order' }]; + + assert.true(this.can('fleet-ops create orders')); + }); + + test('an admin is granted everything regardless of permissions', function (assert) { + this.isAdmin = true; + + assert.true(this.can('anything at all')); + assert.true(this.can('fleet-ops delete order')); + }); + }); + + module('the permission snapshot', function () { + test('permissions are read once at construction', function (assert) { + this.permissions = [{ name: 'fleet-ops create order' }]; + const ability = this.build(); + + this.permissions = []; + ability.parseProperty('fleet-ops create order'); + + assert.true(ability.can, 'a later change to the user does not reach an existing ability'); + }); + + test('a fresh ability sees the current permissions', function (assert) { + this.permissions = []; + assert.false(this.can('fleet-ops create order')); + + this.permissions = [{ name: 'fleet-ops create order' }]; + assert.true(this.can('fleet-ops create order')); + }); }); }); diff --git a/tests/unit/adapters/application-test.js b/tests/unit/adapters/application-test.js new file mode 100644 index 00000000..202ed4b6 --- /dev/null +++ b/tests/unit/adapters/application-test.js @@ -0,0 +1,285 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; +import AdapterError from '@ember-data/adapter/error'; +import { compress } from 'compress-json'; +import ApplicationAdapter from '@fleetbase/ember-core/adapters/application'; + +const DEFAULT_ERROR_MESSAGE = 'Oops! Something went wrong. Please try again or contact support if the issue persists.'; +const USER_OPTIONS_KEY = '@fleetbase/storage:user-options'; +const SESSION_KEY = 'ember_simple_auth-session'; + +/** + * ApplicationAdapter decides three things worth pinning: which headers go out + * with every request, how a URL path is derived from a model name, and which + * responses count as errors. + * + * There is no app/adapters/application.js re-export — the addon deliberately + * leaves that path to the consuming app — so the class is registered directly. + */ +module('Unit | Adapter | application', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.sessionData = { authenticated: {} }; + this.isAuthenticated = false; + const testContext = this; + + this.owner.register( + 'service:session', + class extends Service { + get data() { + return testContext.sessionData; + } + get isAuthenticated() { + return testContext.isAuthenticated; + } + } + ); + + this.owner.register('service:current-user', class extends Service {}); + this.owner.register('adapter:fleetbase-application', ApplicationAdapter); + + this.buildAdapter = () => this.owner.lookup('adapter:fleetbase-application'); + this.setUserOptions = (options) => window.localStorage.setItem(USER_OPTIONS_KEY, JSON.stringify(options)); + }); + + hooks.afterEach(function () { + window.localStorage.removeItem(USER_OPTIONS_KEY); + window.localStorage.removeItem(SESSION_KEY); + }); + + module('headers', function () { + test('an unauthenticated request sends only a content type', function (assert) { + assert.deepEqual(this.buildAdapter().setupHeaders(), { 'Content-Type': 'application/json' }); + }); + + test('an authenticated request carries a bearer token', function (assert) { + this.isAuthenticated = true; + this.sessionData = { authenticated: { user: 'user-1', token: 'abc123' } }; + + const headers = this.buildAdapter().setupHeaders(); + + assert.strictEqual(headers['Authorization'], 'Bearer abc123'); + assert.strictEqual(headers['Content-Type'], 'application/json'); + }); + + test('the token is recovered from local storage when the session has not restored yet', function (assert) { + window.localStorage.setItem(SESSION_KEY, JSON.stringify({ authenticated: { token: 'from-storage' } })); + + const headers = this.buildAdapter().setupHeaders(); + + assert.strictEqual(headers['Authorization'], 'Bearer from-storage', 'a page reload still sends credentials'); + }); + + test('a stored session with no authenticated section is ignored', function (assert) { + window.localStorage.setItem(SESSION_KEY, JSON.stringify({ somethingElse: true })); + + assert.deepEqual(this.buildAdapter().setupHeaders(), { 'Content-Type': 'application/json' }); + }); + + test('sandbox mode adds the sandbox header', function (assert) { + this.isAuthenticated = true; + this.sessionData = { authenticated: { user: 'user-1', token: 'abc123' } }; + this.setUserOptions({ 'user-1:sandbox': true }); + + assert.true(this.buildAdapter().setupHeaders()['Access-Console-Sandbox']); + }); + + test('sandbox must be exactly true', function (assert) { + this.isAuthenticated = true; + this.sessionData = { authenticated: { user: 'user-1', token: 'abc123' } }; + this.setUserOptions({ 'user-1:sandbox': 'yes' }); + + assert.strictEqual(this.buildAdapter().setupHeaders()['Access-Console-Sandbox'], undefined); + }); + + test('a test key is sent alongside the sandbox header', function (assert) { + this.isAuthenticated = true; + this.sessionData = { authenticated: { user: 'user-1', token: 'abc123' } }; + this.setUserOptions({ 'user-1:sandbox': true, 'user-1:test-key': 'key-1' }); + + assert.strictEqual(this.buildAdapter().setupHeaders()['Access-Console-Sandbox-Key'], 'key-1'); + }); + + test('sandbox options belonging to another user are not applied', function (assert) { + this.isAuthenticated = true; + this.sessionData = { authenticated: { user: 'user-1', token: 'abc123' } }; + this.setUserOptions({ 'user-2:sandbox': true, 'user-2:test-key': 'key-2' }); + + const headers = this.buildAdapter().setupHeaders(); + + assert.strictEqual(headers['Access-Console-Sandbox'], undefined); + assert.strictEqual(headers['Access-Console-Sandbox-Key'], undefined); + }); + + test('sandbox headers are withheld from an unauthenticated request', function (assert) { + this.sessionData = { authenticated: { user: 'user-1' } }; + this.setUserOptions({ 'user-1:sandbox': true, 'user-1:test-key': 'key-1' }); + + const headers = this.buildAdapter().setupHeaders(); + + assert.strictEqual(headers['Access-Console-Sandbox'], undefined); + assert.strictEqual(headers['Access-Console-Sandbox-Key'], undefined); + }); + + test('corrupt user options are ignored rather than fatal', function (assert) { + this.isAuthenticated = true; + this.sessionData = { authenticated: { user: 'user-1', token: 'abc123' } }; + window.localStorage.setItem(USER_OPTIONS_KEY, 'not json'); + + assert.strictEqual(this.buildAdapter().setupHeaders()['Authorization'], 'Bearer abc123'); + }); + + test('setupHeaders both returns and installs the headers', function (assert) { + const adapter = this.buildAdapter(); + + const headers = adapter.setupHeaders(); + + assert.deepEqual(adapter.headers, headers); + }); + }); + + module('ajaxOptions', function () { + test('it sends credentials with the request', function (assert) { + const options = this.buildAdapter().ajaxOptions('/api/v1/users', 'GET', {}); + + assert.strictEqual(options.credentials, 'include'); + }); + + test('it refreshes the headers first, so a login mid-session is picked up', function (assert) { + const adapter = this.buildAdapter(); + adapter.ajaxOptions('/api/v1/users', 'GET', {}); + assert.strictEqual(adapter.headers['Authorization'], undefined); + + this.isAuthenticated = true; + this.sessionData = { authenticated: { user: 'user-1', token: 'abc123' } }; + adapter.ajaxOptions('/api/v1/users', 'GET', {}); + + assert.strictEqual(adapter.headers['Authorization'], 'Bearer abc123'); + }); + }); + + module('pathForType', function () { + test('it pluralizes and dasherizes the model name', function (assert) { + const adapter = this.buildAdapter(); + + assert.strictEqual(adapter.pathForType('user'), 'users'); + assert.strictEqual(adapter.pathForType('orderConfig'), 'order-configs'); + assert.strictEqual(adapter.pathForType('fuel-report'), 'fuel-reports'); + }); + + test('an irregular plural is honoured', function (assert) { + assert.strictEqual(this.buildAdapter().pathForType('company'), 'companies'); + }); + }); + + module('error detection', function () { + test('4xx and 5xx are errors', function (assert) { + const adapter = this.buildAdapter(); + + for (const status of [400, 404, 422, 500, 599]) { + assert.true(adapter.isErrorResponse(status, {}), `${status} is an error`); + } + }); + + test('a successful status is not an error', function (assert) { + const adapter = this.buildAdapter(); + + for (const status of [200, 201, 204, 304, 399]) { + assert.false(adapter.isErrorResponse(status, {}), `${status} is not an error`); + } + }); + + test('a 200 carrying an errors array is still an error', function (assert) { + assert.true(this.buildAdapter().isErrorResponse(200, { errors: ['Nope'] })); + }); + + test('a blank payload with a good status is not an error', function (assert) { + assert.false(this.buildAdapter().isErrorResponse(200, null)); + }); + + test('errors are read off the payload', function (assert) { + assert.deepEqual(this.buildAdapter().getResponseErrors({ errors: ['First', 'Second'] }), ['First', 'Second']); + }); + + test('a payload with no errors array yields the default message', function (assert) { + const adapter = this.buildAdapter(); + + assert.deepEqual(adapter.getResponseErrors({}), [DEFAULT_ERROR_MESSAGE]); + assert.deepEqual(adapter.getResponseErrors({ errors: 'not an array' }), [DEFAULT_ERROR_MESSAGE]); + }); + + test('the first error becomes the message', function (assert) { + assert.strictEqual(this.buildAdapter().getErrorMessage(['First', 'Second']), 'First'); + }); + + test('an empty or absent error list falls back to the default message', function (assert) { + const adapter = this.buildAdapter(); + + assert.strictEqual(adapter.getErrorMessage([]), DEFAULT_ERROR_MESSAGE); + assert.strictEqual(adapter.getErrorMessage(), DEFAULT_ERROR_MESSAGE); + assert.strictEqual(adapter.getErrorMessage([null]), DEFAULT_ERROR_MESSAGE); + }); + }); + + module('handleResponse', function () { + test('an error response becomes an AdapterError carrying the message', function (assert) { + const result = this.buildAdapter().handleResponse(422, {}, { errors: ['Name is required'] }, {}); + + assert.true(result instanceof AdapterError); + assert.strictEqual(result.message, 'Name is required'); + assert.deepEqual(result.errors, ['Name is required']); + }); + + test('an error response with no errors array uses the default message', function (assert) { + const result = this.buildAdapter().handleResponse(500, {}, {}, {}); + + assert.true(result instanceof AdapterError); + assert.strictEqual(result.message, DEFAULT_ERROR_MESSAGE); + }); + + test('a successful response is handed to the superclass', function (assert) { + const payload = { users: [{ id: '1' }] }; + + assert.deepEqual(this.buildAdapter().handleResponse(200, {}, payload, {}), payload); + }); + }); + + module('decompressPayload', function () { + test('a payload flagged as compressed is decompressed and parsed', function (assert) { + const original = { users: [{ id: '1', name: 'Ron' }] }; + const compressed = compress(JSON.stringify(original)); + + assert.deepEqual(this.buildAdapter().decompressPayload(compressed, { 'x-compressed-json': '1' }), original); + }); + + test('the flag is accepted as a number as well as a string', function (assert) { + const original = { ok: true }; + const compressed = compress(JSON.stringify(original)); + + assert.deepEqual(this.buildAdapter().decompressPayload(compressed, { 'x-compressed-json': 1 }), original); + }); + + test('an unflagged payload is passed through untouched', function (assert) { + const payload = { users: [] }; + + assert.strictEqual(this.buildAdapter().decompressPayload(payload, {}), payload); + }); + + test('any other flag value leaves the payload alone', function (assert) { + const payload = { users: [] }; + + assert.strictEqual(this.buildAdapter().decompressPayload(payload, { 'x-compressed-json': '0' }), payload); + }); + + test('handleResponse decompresses before deciding whether it is an error', function (assert) { + const compressed = compress(JSON.stringify({ errors: ['Compressed failure'] })); + + const result = this.buildAdapter().handleResponse(200, { 'x-compressed-json': '1' }, compressed, {}); + + assert.true(result instanceof AdapterError, 'the error is only visible after decompression'); + assert.strictEqual(result.message, 'Compressed failure'); + }); + }); +}); diff --git a/tests/unit/adapters/user-test.js b/tests/unit/adapters/user-test.js index e65aefff..7bab994d 100644 --- a/tests/unit/adapters/user-test.js +++ b/tests/unit/adapters/user-test.js @@ -1,12 +1,67 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; +/** + * UserAdapter adds exactly one thing to ApplicationAdapter: a `me` flag on a + * queryRecord is turned into the `/me` sub-resource rather than a query + * parameter, and is removed from the query so it is never also serialized. + */ module('Unit | Adapter | user', function (hooks) { setupTest(hooks); - // Replace this with your real tests. - test('it exists', function (assert) { - let adapter = this.owner.lookup('adapter:user'); - assert.ok(adapter); + hooks.beforeEach(function () { + this.owner.register( + 'service:session', + class extends Service { + data = { authenticated: {} }; + isAuthenticated = false; + } + ); + this.owner.register('service:current-user', class extends Service {}); + + this.adapter = this.owner.lookup('adapter:user'); + }); + + test('it resolves to this addon’s user adapter', function (assert) { + assert.strictEqual(typeof this.adapter.urlForQueryRecord, 'function'); + assert.strictEqual(this.adapter.pathForType('user'), 'users', 'it inherits the application adapter'); + }); + + test('a plain query record uses the collection URL', function (assert) { + const url = this.adapter.urlForQueryRecord({ public_id: 'user_1' }, 'user'); + + assert.true(url.endsWith('/users'), `${url} ends with the collection path`); + }); + + test('the me flag switches to the /me sub-resource', function (assert) { + const url = this.adapter.urlForQueryRecord({ me: true }, 'user'); + + assert.true(url.endsWith('/users/me'), `${url} ends with /users/me`); + }); + + test('the me flag is removed from the query so it is not also sent', function (assert) { + const query = { me: true }; + + this.adapter.urlForQueryRecord(query, 'user'); + + assert.notOk('me' in query, 'the flag is consumed rather than serialized'); + }); + + test('other query keys are left in place', function (assert) { + const query = { me: true, include: 'company' }; + + this.adapter.urlForQueryRecord(query, 'user'); + + assert.deepEqual(query, { include: 'company' }); + }); + + test('a falsy me flag is treated as an ordinary query', function (assert) { + const query = { me: false }; + + const url = this.adapter.urlForQueryRecord(query, 'user'); + + assert.true(url.endsWith('/users'), `${url} is the collection path`); + assert.strictEqual(query.me, false, 'a falsy flag is left alone rather than deleted'); }); }); diff --git a/tests/unit/authenticators/fleetbase-test.js b/tests/unit/authenticators/fleetbase-test.js new file mode 100644 index 00000000..37718fbe --- /dev/null +++ b/tests/unit/authenticators/fleetbase-test.js @@ -0,0 +1,145 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; +import FleetbaseAuthenticator, { AuthenticationError } from '@fleetbase/ember-core/authenticators/fleetbase'; + +/** + * The authenticator is delegation over the `fetch` service: it decides which + * endpoint to call, what to send, and which responses count as failures. + * A stubbed fetch records the calls and returns whatever a test needs. + */ +module('Unit | Authenticator | fleetbase', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.gets = []; + this.posts = []; + this.getResponse = {}; + this.postResponse = {}; + const testContext = this; + + this.owner.register( + 'service:fetch', + class extends Service { + get(path, query, options) { + testContext.gets.push({ path, query, options }); + return Promise.resolve(testContext.getResponse); + } + post(path, body, options) { + testContext.posts.push({ path, body, options }); + return Promise.resolve(testContext.postResponse); + } + } + ); + + this.owner.register('service:session', class extends Service {}); + this.owner.register('authenticator:fleetbase', FleetbaseAuthenticator); + this.authenticator = this.owner.lookup('authenticator:fleetbase'); + }); + + module('AuthenticationError', function () { + test('it carries a message and a code', function (assert) { + const error = new AuthenticationError('Nope', 'bad_credentials'); + + assert.strictEqual(error.message, 'Nope'); + assert.strictEqual(error.code, 'bad_credentials'); + assert.strictEqual(error.getCode(), 'bad_credentials'); + }); + + test('it is a real Error', function (assert) { + assert.true(new AuthenticationError('Nope') instanceof Error); + }); + + test('the code is optional', function (assert) { + assert.strictEqual(new AuthenticationError('Nope').getCode(), undefined); + }); + }); + + module('restore', function () { + test('it asks the session endpoint with the stored token', async function (assert) { + await this.authenticator.restore({ token: 'abc123' }); + + assert.deepEqual(this.gets, [{ path: 'auth/session', query: {}, options: { headers: { Authorization: 'Bearer abc123' } } }]); + }); + + test('it resolves with the response', async function (assert) { + this.getResponse = { token: 'renewed', restore: true }; + + assert.deepEqual(await this.authenticator.restore({ token: 'abc123' }), { token: 'renewed', restore: true }); + }); + + test('an explicit restore false is rejected', async function (assert) { + this.getResponse = { restore: false, error: 'Session expired' }; + + await assert.rejects(this.authenticator.restore({ token: 'abc123' }), (error) => error instanceof AuthenticationError && error.message === 'Session expired'); + }); + + test('only an exact false rejects', async function (assert) { + this.getResponse = { restore: null }; + + assert.deepEqual(await this.authenticator.restore({ token: 'abc123' }), { restore: null }, 'a missing flag is not a failure'); + }); + }); + + module('authenticate', function () { + test('it posts the credentials to the login endpoint', async function (assert) { + await this.authenticator.authenticate({ email: 'a@b.c', password: 'secret' }); + + assert.deepEqual(this.posts, [{ path: 'auth/login', body: { email: 'a@b.c', password: 'secret', remember: false }, options: {} }]); + }); + + test('remember is passed through', async function (assert) { + await this.authenticator.authenticate({ email: 'a@b.c' }, true); + + assert.true(this.posts[0].body.remember); + }); + + test('a custom path and options are honoured', async function (assert) { + await this.authenticator.authenticate({}, false, 'auth/sso', { headers: { 'X-Tenant': 'acme' } }); + + assert.strictEqual(this.posts[0].path, 'auth/sso'); + assert.deepEqual(this.posts[0].options, { headers: { 'X-Tenant': 'acme' } }); + }); + + test('it defaults to empty credentials', async function (assert) { + await this.authenticator.authenticate(); + + assert.deepEqual(this.posts[0].body, { remember: false }); + }); + + test('it resolves with the response', async function (assert) { + this.postResponse = { token: 'abc123' }; + + assert.deepEqual(await this.authenticator.authenticate({}), { token: 'abc123' }); + }); + + test('an errors array is rejected with its first entry and the response code', async function (assert) { + this.postResponse = { errors: ['Invalid password', 'ignored'], code: 'bad_credentials' }; + + await assert.rejects( + this.authenticator.authenticate({}), + (error) => error instanceof AuthenticationError && error.message === 'Invalid password' && error.getCode() === 'bad_credentials' + ); + }); + + test('an empty errors array still rejects, with a fallback message', async function (assert) { + this.postResponse = { errors: [] }; + + await assert.rejects(this.authenticator.authenticate({}), (error) => error.message === 'Authentication failed!' && error.getCode() === undefined); + }); + }); + + module('invalidate', function () { + test('it posts to the logout endpoint', async function (assert) { + await this.authenticator.invalidate({ token: 'abc123' }); + + assert.deepEqual(this.posts, [{ path: 'auth/logout', body: undefined, options: undefined }]); + }); + + test('it resolves with whatever logout returned', async function (assert) { + this.postResponse = { ok: true }; + + assert.deepEqual(await this.authenticator.invalidate({}), { ok: true }); + }); + }); +}); diff --git a/tests/unit/contracts/base-contract-test.js b/tests/unit/contracts/base-contract-test.js new file mode 100644 index 00000000..b14821d8 --- /dev/null +++ b/tests/unit/contracts/base-contract-test.js @@ -0,0 +1,88 @@ +import BaseContract from '@fleetbase/ember-core/contracts/base-contract'; +import { module, test } from 'qunit'; + +module('Unit | Contract | base-contract', function () { + test('it copies the options it is given rather than holding the reference', function (assert) { + const options = { a: 1 }; + const contract = new BaseContract(options); + + options.a = 2; + + assert.strictEqual(contract.getOption('a'), 1, 'later mutation of the caller object does not leak in'); + }); + + test('it defaults to an empty option set', function (assert) { + assert.deepEqual(new BaseContract().getOptions(), {}); + }); + + test('setOption stores a value and returns the contract for chaining', function (assert) { + const contract = new BaseContract(); + + assert.strictEqual(contract.setOption('a', 1), contract); + assert.strictEqual(contract.getOption('a'), 1); + }); + + test('getOption falls back only for a missing key', function (assert) { + const contract = new BaseContract({ empty: '', zero: 0, nullish: null, no: false }); + + assert.strictEqual(contract.getOption('missing'), null, 'null is the default default'); + assert.strictEqual(contract.getOption('missing', 'fallback'), 'fallback'); + assert.strictEqual(contract.getOption('empty', 'fallback'), ''); + assert.strictEqual(contract.getOption('zero', 'fallback'), 0); + assert.strictEqual(contract.getOption('nullish', 'fallback'), null); + assert.false(contract.getOption('no', 'fallback')); + }); + + test('hasOption distinguishes a stored falsy value from a missing one', function (assert) { + const contract = new BaseContract({ zero: 0, nullish: null }); + + assert.true(contract.hasOption('zero')); + assert.true(contract.hasOption('nullish')); + assert.false(contract.hasOption('missing')); + }); + + test('removeOption deletes the key and returns the contract', function (assert) { + const contract = new BaseContract({ a: 1 }); + + assert.strictEqual(contract.removeOption('a'), contract); + assert.false(contract.hasOption('a')); + }); + + test('removeOption is safe for a key that was never set', function (assert) { + const contract = new BaseContract(); + + assert.strictEqual(contract.removeOption('missing'), contract); + }); + + test('toObject and getOptions return copies', function (assert) { + const contract = new BaseContract({ a: 1 }); + + const asObject = contract.toObject(); + asObject.a = 99; + + assert.strictEqual(contract.getOption('a'), 1, 'toObject hands back a copy'); + + const options = contract.getOptions(); + options.a = 99; + + assert.strictEqual(contract.getOption('a'), 1, 'getOptions hands back a copy'); + }); + + test('setup runs validation, which is a no-op on the base class', function (assert) { + const contract = new BaseContract(); + + contract.setup(); + + assert.true(true, 'base setup completes without throwing'); + }); + + test('setup surfaces a subclass validation failure', function (assert) { + class Strict extends BaseContract { + validate() { + throw new Error('always invalid'); + } + } + + assert.throws(() => new Strict().setup(), /always invalid/); + }); +}); diff --git a/tests/unit/contracts/extension-component-test.js b/tests/unit/contracts/extension-component-test.js new file mode 100644 index 00000000..1e67c547 --- /dev/null +++ b/tests/unit/contracts/extension-component-test.js @@ -0,0 +1,116 @@ +import ExtensionComponent from '@fleetbase/ember-core/contracts/extension-component'; +import { module, test } from 'qunit'; + +class SomeComponent {} + +module('Unit | Contract | extension-component', function () { + module('construction from a path', function () { + test('a string path sets the path and mirrors it as the name', function (assert) { + const component = new ExtensionComponent('fleet-ops', 'widgets/fleet-stats'); + + assert.strictEqual(component.engine, 'fleet-ops'); + assert.strictEqual(component.path, 'widgets/fleet-stats'); + assert.strictEqual(component.name, 'widgets/fleet-stats'); + assert.strictEqual(component.class, null); + assert.false(component.isClass); + assert.strictEqual(component.loadingComponent, null); + assert.strictEqual(component.errorComponent, null); + }); + + test('an options object is accepted in place of a path', function (assert) { + const component = new ExtensionComponent('fleet-ops', { + path: 'widgets/fleet-stats', + loadingComponent: 'spinner', + errorComponent: 'error-box', + }); + + assert.strictEqual(component.path, 'widgets/fleet-stats'); + assert.strictEqual(component.loadingComponent, 'spinner'); + assert.strictEqual(component.errorComponent, 'error-box'); + }); + }); + + module('construction from a class', function () { + test('a component class is stored and named after the class', function (assert) { + const component = new ExtensionComponent('fleet-ops', SomeComponent); + + assert.strictEqual(component.engine, 'fleet-ops'); + assert.strictEqual(component.class, SomeComponent); + assert.strictEqual(component.name, 'SomeComponent'); + assert.strictEqual(component.path, null, 'a class has no path'); + assert.true(component.isClass); + }); + + test('the class branch leaves the loading and error components unset', function (assert) { + const component = new ExtensionComponent('fleet-ops', SomeComponent); + + assert.strictEqual(component.loadingComponent, null); + assert.strictEqual(component.errorComponent, null); + }); + }); + + module('validation', function () { + // NOTE: unlike Hook, Widget and Registry, this constructor never calls + // super.setup(), so validate() is not run on construction. Invalid + // components are therefore built happily and only fail later, if at all. + test('an invalid component is constructed without complaint', function (assert) { + const noEngine = new ExtensionComponent(undefined, 'widgets/x'); + assert.strictEqual(noEngine.engine, undefined, 'a missing engine is not rejected'); + + const noTarget = new ExtensionComponent('fleet-ops', {}); + assert.strictEqual(noTarget.path, undefined, 'neither a path nor a class is required'); + }); + + test('validate does report those problems when called directly', function (assert) { + assert.throws(() => new ExtensionComponent(undefined, 'widgets/x').validate(), /requires an engine name/); + assert.throws(() => new ExtensionComponent('fleet-ops', {}).validate(), /requires a component path or class/); + }); + + test('validate passes for a well-formed component', function (assert) { + new ExtensionComponent('fleet-ops', 'widgets/x').validate(); + new ExtensionComponent('fleet-ops', SomeComponent).validate(); + + assert.true(true, 'neither call throws'); + }); + }); + + module('chaining', function () { + test('the setters assign and return the component', function (assert) { + const component = new ExtensionComponent('fleet-ops', 'widgets/x'); + + assert.strictEqual(component.withLoadingComponent('spinner'), component); + assert.strictEqual(component.loadingComponent, 'spinner'); + assert.strictEqual(component.getOption('loadingComponent'), 'spinner'); + + assert.strictEqual(component.withErrorComponent('error-box'), component); + assert.strictEqual(component.errorComponent, 'error-box'); + + assert.strictEqual(component.withData({ a: 1 }), component); + assert.deepEqual(component.getOption('data'), { a: 1 }); + + assert.strictEqual(component.withTimeout(5000), component); + assert.strictEqual(component.getOption('timeout'), 5000); + }); + }); + + module('serialisation', function () { + test('toObject exposes every field', function (assert) { + const object = new ExtensionComponent('fleet-ops', 'widgets/x').withLoadingComponent('spinner').toObject(); + + assert.strictEqual(object.engine, 'fleet-ops'); + assert.strictEqual(object.path, 'widgets/x'); + assert.strictEqual(object.name, 'widgets/x'); + assert.strictEqual(object.class, null); + assert.false(object.isClass); + assert.strictEqual(object.loadingComponent, 'spinner'); + }); + + test('toString identifies a path component', function (assert) { + assert.strictEqual(new ExtensionComponent('fleet-ops', 'widgets/x').toString(), '#extension-component:fleet-ops:widgets/x'); + }); + + test('toString identifies a class component by its class name', function (assert) { + assert.strictEqual(new ExtensionComponent('fleet-ops', SomeComponent).toString(), '#extension-component:fleet-ops:SomeComponent'); + }); + }); +}); diff --git a/tests/unit/contracts/hook-test.js b/tests/unit/contracts/hook-test.js new file mode 100644 index 00000000..c301c762 --- /dev/null +++ b/tests/unit/contracts/hook-test.js @@ -0,0 +1,176 @@ +import Hook from '@fleetbase/ember-core/contracts/hook'; +import { module, test } from 'qunit'; + +module('Unit | Contract | hook', function () { + module('construction from a name', function () { + test('a bare name gets sensible defaults', function (assert) { + const hook = new Hook('application:before-model'); + + assert.strictEqual(hook.name, 'application:before-model'); + assert.strictEqual(hook.handler, null); + assert.strictEqual(hook.priority, 0); + assert.false(hook.runOnce); + assert.true(hook.enabled); + assert.ok(hook.id, 'an id is generated when none is supplied'); + }); + + test('a handler can be passed as the second argument', function (assert) { + const handler = () => 'ran'; + const hook = new Hook('order:before-save', handler); + + assert.strictEqual(hook.handler, handler); + }); + + test('options can be passed as the second argument', function (assert) { + const handler = () => {}; + const hook = new Hook('order:before-save', { handler, priority: 10, once: true, id: 'my-hook', enabled: false }); + + assert.strictEqual(hook.handler, handler); + assert.strictEqual(hook.priority, 10); + assert.true(hook.runOnce); + assert.strictEqual(hook.id, 'my-hook'); + assert.false(hook.enabled); + }); + + test('enabled defaults to true but an explicit false is respected', function (assert) { + assert.true(new Hook('a', {}).enabled); + assert.false(new Hook('a', { enabled: false }).enabled); + }); + }); + + module('construction from a definition object', function () { + test('it reads every field from the definition', function (assert) { + const handler = () => {}; + const hook = new Hook({ name: 'order:created', handler, priority: 5, once: true, id: 'created-hook', enabled: false }); + + assert.strictEqual(hook.name, 'order:created'); + assert.strictEqual(hook.handler, handler); + assert.strictEqual(hook.priority, 5); + assert.true(hook.runOnce); + assert.strictEqual(hook.id, 'created-hook'); + assert.false(hook.enabled); + }); + + test('a definition falls back to the same defaults', function (assert) { + const hook = new Hook({ name: 'order:created' }); + + assert.strictEqual(hook.handler, null); + assert.strictEqual(hook.priority, 0); + assert.false(hook.runOnce); + assert.true(hook.enabled); + assert.ok(hook.id); + }); + + test('a zero priority in a definition is preserved rather than replaced', function (assert) { + assert.strictEqual(new Hook({ name: 'a', priority: 0 }).priority, 0); + }); + + test('an object without a name silently becomes the name', function (assert) { + // NOTE: the definition branch is gated on `isObject(x) && x.name`, so a + // definition that forgot its name falls through to the string branch and + // the object itself is assigned as the name. It is truthy, so validation + // passes and the mistake surfaces later rather than here. + const hook = new Hook({ handler: () => {} }); + + assert.strictEqual(typeof hook.name, 'object', 'the object is used as the name'); + assert.strictEqual(hook.handler, null, 'and its handler is not picked up'); + }); + }); + + module('validation', function () { + test('it requires a name', function (assert) { + assert.throws(() => new Hook(), /Hook requires a name/); + assert.throws(() => new Hook(''), /Hook requires a name/); + assert.throws(() => new Hook(null), /Hook requires a name/); + }); + }); + + module('chaining', function () { + test('execute sets the handler and keeps the option in step', function (assert) { + const handler = () => {}; + const hook = new Hook('a'); + + assert.strictEqual(hook.execute(handler), hook); + assert.strictEqual(hook.handler, handler); + assert.strictEqual(hook.getOption('handler'), handler); + }); + + test('withPriority sets the priority', function (assert) { + const hook = new Hook('a'); + + assert.strictEqual(hook.withPriority(10), hook); + assert.strictEqual(hook.priority, 10); + assert.strictEqual(hook.getOption('priority'), 10); + }); + + test('once marks the hook as single-run', function (assert) { + const hook = new Hook('a'); + + assert.strictEqual(hook.once(), hook); + assert.true(hook.runOnce); + assert.true(hook.getOption('once')); + }); + + test('withId overrides the generated id', function (assert) { + const hook = new Hook('a'); + + assert.strictEqual(hook.withId('custom'), hook); + assert.strictEqual(hook.id, 'custom'); + assert.strictEqual(hook.getOption('id'), 'custom'); + }); + + test('enable, disable and setEnabled toggle the hook', function (assert) { + const hook = new Hook('a'); + + assert.strictEqual(hook.disable(), hook); + assert.false(hook.enabled); + assert.false(hook.getOption('enabled')); + + hook.enable(); + assert.true(hook.enabled); + + hook.setEnabled(false); + assert.false(hook.enabled); + }); + + test('withMetadata stores metadata as an option', function (assert) { + const hook = new Hook('a'); + const metadata = { source: 'fleet-ops' }; + + assert.strictEqual(hook.withMetadata(metadata), hook); + assert.deepEqual(hook.getOption('metadata'), metadata); + }); + + test('the fluent calls compose', function (assert) { + const handler = () => {}; + const hook = new Hook('order:before-save').withPriority(10).once().withId('validate').execute(handler); + + assert.strictEqual(hook.priority, 10); + assert.true(hook.runOnce); + assert.strictEqual(hook.id, 'validate'); + assert.strictEqual(hook.handler, handler); + }); + }); + + module('toObject', function () { + test('it exposes every hook property', function (assert) { + const handler = () => {}; + const hook = new Hook('order:created', { handler, priority: 3, once: true, id: 'h1' }); + + const object = hook.toObject(); + + assert.strictEqual(object.name, 'order:created'); + assert.strictEqual(object.handler, handler); + assert.strictEqual(object.priority, 3); + assert.true(object.once, 'runOnce is exposed as `once`'); + assert.strictEqual(object.id, 'h1'); + assert.true(object.enabled); + }); + + test('it includes any extra options carried on the hook', function (assert) { + const object = new Hook('a').withMetadata({ source: 'x' }).toObject(); + + assert.deepEqual(object.metadata, { source: 'x' }); + }); + }); +}); diff --git a/tests/unit/contracts/index-test.js b/tests/unit/contracts/index-test.js new file mode 100644 index 00000000..1ae1c133 --- /dev/null +++ b/tests/unit/contracts/index-test.js @@ -0,0 +1,27 @@ +import * as contracts from '@fleetbase/ember-core/contracts'; +import BaseContract from '@fleetbase/ember-core/contracts/base-contract'; +import ExtensionComponent from '@fleetbase/ember-core/contracts/extension-component'; +import MenuItem from '@fleetbase/ember-core/contracts/menu-item'; +import MenuPanel from '@fleetbase/ember-core/contracts/menu-panel'; +import Hook from '@fleetbase/ember-core/contracts/hook'; +import Widget from '@fleetbase/ember-core/contracts/widget'; +import Registry from '@fleetbase/ember-core/contracts/registry'; +import { module, test } from 'qunit'; + +module('Unit | Contract | index', function () { + test('it re-exports every contract class by name', function (assert) { + assert.strictEqual(contracts.BaseContract, BaseContract); + assert.strictEqual(contracts.ExtensionComponent, ExtensionComponent); + assert.strictEqual(contracts.MenuItem, MenuItem); + assert.strictEqual(contracts.MenuPanel, MenuPanel); + assert.strictEqual(contracts.Hook, Hook); + assert.strictEqual(contracts.Widget, Widget); + assert.strictEqual(contracts.Registry, Registry); + }); + + test('the contract classes all descend from BaseContract', function (assert) { + for (const name of ['ExtensionComponent', 'MenuItem', 'MenuPanel', 'Hook', 'Widget', 'Registry']) { + assert.true(contracts[name].prototype instanceof BaseContract, `${name} extends BaseContract`); + } + }); +}); diff --git a/tests/unit/contracts/menu-item-chaining-test.js b/tests/unit/contracts/menu-item-chaining-test.js new file mode 100644 index 00000000..067a579d --- /dev/null +++ b/tests/unit/contracts/menu-item-chaining-test.js @@ -0,0 +1,58 @@ +import { module, test } from 'qunit'; +import MenuItem from '@fleetbase/ember-core/contracts/menu-item'; + +/** + * The three chaining setters the sibling contract test does not reach, one of + * which cannot be reached through an instance at all. + */ +module('Unit | Contract | menu-item (chaining)', function () { + test('withComponentParams stores the params and chains', function (assert) { + const item = new MenuItem('Orders', 'console.orders'); + + const returned = item.withComponentParams({ view: 'compact' }); + + assert.strictEqual(returned, item, 'it chains'); + assert.deepEqual(item.toObject().componentParams, { view: 'compact' }); + }); + + test('withShortcuts stores a list and chains', function (assert) { + const item = new MenuItem('Orders', 'console.orders'); + const shortcuts = [{ title: 'New order', route: 'console.orders.new' }]; + + const returned = item.withShortcuts(shortcuts); + + assert.strictEqual(returned, item); + assert.deepEqual(item.shortcuts, shortcuts); + assert.deepEqual(item.toObject().shortcuts, shortcuts); + }); + + test('withShortcuts nulls anything that is not a list', function (assert) { + const item = new MenuItem('Orders', 'console.orders'); + + item.withShortcuts('not a list'); + + assert.strictEqual(item.shortcuts, null); + assert.strictEqual(item.toObject().shortcuts, null); + }); + + test('the onClick chaining method works, but no instance can reach it', function (assert) { + // Already on the defect register: MenuItem declares `onClick(handler)` + // as a chaining setter AND its constructor assigns `this.onClick = null`, + // so every instance shadows the method with a null field and + // `item.onClick(fn)` throws. + // + // Calling it off the prototype shows the method itself is fine — it is + // only unreachable — which is the half a fixer needs. Dropping the + // constructor assignment is all that stands between the two. + const item = new MenuItem('Orders', 'console.orders'); + const handler = () => 'clicked'; + + assert.strictEqual(item.onClick, null, 'the field shadows the method'); + assert.throws(() => item.onClick(handler), /not a function/); + + const returned = MenuItem.prototype.onClick.call(item, handler); + + assert.strictEqual(returned, item, 'it chains like every other setter'); + assert.strictEqual(item.toObject().onClick, handler, 'and stores the handler where the menu service expects it'); + }); +}); diff --git a/tests/unit/contracts/menu-item-test.js b/tests/unit/contracts/menu-item-test.js new file mode 100644 index 00000000..67e20c2b --- /dev/null +++ b/tests/unit/contracts/menu-item-test.js @@ -0,0 +1,214 @@ +import MenuItem from '@fleetbase/ember-core/contracts/menu-item'; +import ExtensionComponent from '@fleetbase/ember-core/contracts/extension-component'; +import { module, test } from 'qunit'; + +module('Unit | Contract | menu-item', function () { + module('construction from a title', function () { + test('the title seeds text, label, id, slug and view', function (assert) { + const item = new MenuItem('Fleet Orders'); + + assert.strictEqual(item.title, 'Fleet Orders'); + assert.strictEqual(item.text, 'Fleet Orders'); + assert.strictEqual(item.label, 'Fleet Orders'); + assert.strictEqual(item.id, 'fleet-orders'); + assert.strictEqual(item.slug, 'fleet-orders'); + assert.strictEqual(item.view, 'fleet-orders'); + }); + + test('a route can be passed as the second argument', function (assert) { + assert.strictEqual(new MenuItem('Orders', 'console.orders').route, 'console.orders'); + assert.strictEqual(new MenuItem('Orders').route, null); + }); + + test('it applies the documented defaults', function (assert) { + const item = new MenuItem('Orders'); + + assert.strictEqual(item.icon, 'circle-dot'); + assert.strictEqual(item.priority, 9); + assert.strictEqual(item.index, 0); + assert.strictEqual(item.type, 'default'); + assert.false(item.disabled); + assert.false(item.isLoading); + assert.false(item.renderComponentInPlace); + assert.false(item.overwriteWrapperClass); + assert.deepEqual(item.queryParams, {}); + assert.deepEqual(item.routeParams, []); + assert.deepEqual(item.componentParams, {}); + assert.strictEqual(item.items, null); + assert.strictEqual(item.description, null); + assert.strictEqual(item.shortcuts, null); + assert.strictEqual(item.tags, null); + }); + }); + + module('construction from a definition', function () { + test('text and label fall back to the title', function (assert) { + const item = new MenuItem({ title: 'Orders' }); + + assert.strictEqual(item.text, 'Orders'); + assert.strictEqual(item.label, 'Orders'); + }); + + test('explicit text and label win over the title', function (assert) { + const item = new MenuItem({ title: 'Orders', text: 'All orders', label: 'Orders (all)' }); + + assert.strictEqual(item.text, 'All orders'); + assert.strictEqual(item.label, 'Orders (all)'); + }); + + test('id and slug are derived from the title when absent', function (assert) { + const item = new MenuItem({ title: 'Fleet Orders' }); + + assert.strictEqual(item.id, 'fleet-orders'); + assert.strictEqual(item.slug, 'fleet-orders'); + }); + + test('explicit id and slug are kept', function (assert) { + const item = new MenuItem({ title: 'Orders', id: 'custom-id', slug: 'custom-slug' }); + + assert.strictEqual(item.id, 'custom-id'); + assert.strictEqual(item.slug, 'custom-slug'); + }); + + test('a zero priority or index is preserved rather than defaulted', function (assert) { + const item = new MenuItem({ title: 'Orders', priority: 0, index: 0 }); + + assert.strictEqual(item.priority, 0); + assert.strictEqual(item.index, 0); + }); + + test('tags are normalised to an array', function (assert) { + assert.deepEqual(new MenuItem({ title: 'O', tags: ['a', 'b'] }).tags, ['a', 'b']); + assert.deepEqual(new MenuItem({ title: 'O', tags: 'single' }).tags, ['single'], 'a bare string is wrapped'); + assert.strictEqual(new MenuItem({ title: 'O' }).tags, null); + assert.strictEqual(new MenuItem({ title: 'O', tags: '' }).tags, null, 'an empty string yields null'); + }); + + test('nested items and shortcuts are carried through', function (assert) { + const items = [{ title: 'Child' }]; + const shortcuts = [{ title: 'Shortcut', route: 'console.x' }]; + const item = new MenuItem({ title: 'Parent', items, shortcuts, description: 'A parent' }); + + assert.deepEqual(item.items, items); + assert.deepEqual(item.shortcuts, shortcuts); + assert.strictEqual(item.description, 'A parent'); + }); + + test('a definition with no title leaves the derived fields null', function (assert) { + // isObject gates the definition branch on the object alone, so a + // definition without a title reaches validation with title null. + assert.throws(() => new MenuItem({ route: 'console.orders' }), /MenuItem requires a title/); + }); + }); + + module('validation', function () { + test('it requires a title', function (assert) { + assert.throws(() => new MenuItem({}), /MenuItem requires a title/); + }); + }); + + module('chaining', function () { + test('the display setters assign and keep options in step', function (assert) { + const item = new MenuItem('Orders').withIcon('truck').withPriority(1).atIndex(2).withType('button').inSection('ops').withSlug('custom'); + + assert.strictEqual(item.icon, 'truck'); + assert.strictEqual(item.getOption('icon'), 'truck'); + assert.strictEqual(item.priority, 1); + assert.strictEqual(item.index, 2); + assert.strictEqual(item.type, 'button'); + assert.strictEqual(item.section, 'ops'); + assert.strictEqual(item.slug, 'custom'); + }); + + test('withComponent accepts a string or an ExtensionComponent', function (assert) { + assert.strictEqual(new MenuItem('O').withComponent('widgets/x').component, 'widgets/x'); + + const component = new ExtensionComponent('fleet-ops', 'widgets/y'); + assert.deepEqual(new MenuItem('O').withComponent(component).component, component.toObject()); + }); + + test('the routing setters store their arguments', function (assert) { + const item = new MenuItem('O').withQueryParams({ page: 2 }).withRouteParams('a', 'b'); + + assert.deepEqual(item.queryParams, { page: 2 }); + assert.deepEqual(item.routeParams, ['a', 'b'], 'rest parameters are collected into an array'); + }); + + test('withTags normalises exactly like the constructor', function (assert) { + assert.deepEqual(new MenuItem('O').withTags(['a']).tags, ['a']); + assert.deepEqual(new MenuItem('O').withTags('single').tags, ['single']); + assert.strictEqual(new MenuItem('O').withTags(null).tags, null); + }); + + test('addShortcut starts a list and appends to it', function (assert) { + const item = new MenuItem('O'); + + item.addShortcut({ title: 'One' }); + assert.strictEqual(item.shortcuts.length, 1); + + item.addShortcut({ title: 'Two' }); + assert.deepEqual( + item.shortcuts.map((shortcut) => shortcut.title), + ['One', 'Two'] + ); + }); + + test('renderInPlace records the option but leaves the property stale', function (assert) { + // NOTE: every other setter updates both the property and the option. + // This one only sets the option, so the instance property keeps its + // old value. toObject still reports the right answer because the + // options are spread last, but reading item.renderComponentInPlace + // directly is misleading. + const item = new MenuItem('O').renderInPlace(); + + assert.false(item.renderComponentInPlace, 'the property is not updated'); + assert.true(item.toObject().renderComponentInPlace, 'the serialised form is correct'); + }); + + test('the fluent calls compose and each returns the item', function (assert) { + const item = new MenuItem('Orders'); + + assert.strictEqual(item.withIcon('truck').withPriority(1).inSection('ops').withDescription('d').withTags('t').addShortcut({ title: 's' }), item); + }); + }); + + module('the onClick collision', function () { + test('the onClick chaining method is unreachable', function (assert) { + // NOTE: 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)` chaining call tries to + // invoke null and throws. Handlers have to be passed in the definition. + const item = new MenuItem('Orders'); + + assert.strictEqual(item.onClick, null, 'the property shadows the method'); + assert.throws(() => item.onClick(() => {}), TypeError); + }); + + test('a handler supplied in the definition is stored', function (assert) { + const handler = () => 'clicked'; + const item = new MenuItem({ title: 'Orders', onClick: handler }); + + assert.strictEqual(item.onClick, handler); + assert.strictEqual(item.onClick(), 'clicked', 'it is callable because it is the handler itself'); + }); + }); + + module('toObject', function () { + test('it exposes the core fields', function (assert) { + const object = new MenuItem('Fleet Orders', 'console.orders').withIcon('truck').toObject(); + + assert.strictEqual(object.id, 'fleet-orders'); + assert.strictEqual(object.title, 'Fleet Orders'); + assert.strictEqual(object.route, 'console.orders'); + assert.strictEqual(object.icon, 'truck'); + assert.strictEqual(object.priority, 9); + assert.strictEqual(object.type, 'default'); + }); + + test('later option writes win over the constructed properties', function (assert) { + const object = new MenuItem('Orders').withPriority(1).toObject(); + + assert.strictEqual(object.priority, 1); + }); + }); +}); diff --git a/tests/unit/contracts/menu-panel-test.js b/tests/unit/contracts/menu-panel-test.js new file mode 100644 index 00000000..9201da79 --- /dev/null +++ b/tests/unit/contracts/menu-panel-test.js @@ -0,0 +1,136 @@ +import MenuPanel from '@fleetbase/ember-core/contracts/menu-panel'; +import MenuItem from '@fleetbase/ember-core/contracts/menu-item'; +import { module, test } from 'qunit'; + +module('Unit | Contract | menu-panel', function () { + module('construction from a title', function () { + test('it dasherizes the title into a slug and applies defaults', function (assert) { + const panel = new MenuPanel('Fleet Operations'); + + assert.strictEqual(panel.title, 'Fleet Operations'); + assert.strictEqual(panel.slug, 'fleet-operations'); + assert.strictEqual(panel.icon, null); + assert.true(panel.open); + assert.strictEqual(panel.priority, 9); + assert.deepEqual(panel.items, []); + }); + + test('items can be supplied as the second argument', function (assert) { + const items = [{ title: 'Orders' }]; + + assert.deepEqual(new MenuPanel('Fleet', items).items, items); + }); + }); + + module('construction from a definition', function () { + test('it reads every field', function (assert) { + const panel = new MenuPanel({ + title: 'Fleet Operations', + slug: 'ops', + icon: 'truck', + open: false, + priority: 1, + items: [{ title: 'Orders' }], + }); + + assert.strictEqual(panel.title, 'Fleet Operations'); + assert.strictEqual(panel.slug, 'ops'); + assert.strictEqual(panel.icon, 'truck'); + assert.false(panel.open); + assert.strictEqual(panel.priority, 1); + assert.deepEqual(panel.items, [{ title: 'Orders' }]); + }); + + test('a definition falls back to the same defaults', function (assert) { + const panel = new MenuPanel({ title: 'Fleet Ops' }); + + assert.strictEqual(panel.slug, 'fleet-ops', 'the slug is derived from the title'); + assert.strictEqual(panel.icon, null); + assert.true(panel.open); + assert.strictEqual(panel.priority, 9); + assert.deepEqual(panel.items, []); + }); + + test('explicit false and zero are preserved rather than replaced', function (assert) { + const panel = new MenuPanel({ title: 'Fleet', open: false, priority: 0 }); + + assert.false(panel.open); + assert.strictEqual(panel.priority, 0); + }); + }); + + module('validation', function () { + test('an empty title is rejected with the intended error', function (assert) { + assert.throws(() => new MenuPanel(''), /MenuPanel requires a title/); + }); + + test('a missing title fails earlier, with a TypeError from dasherize', function (assert) { + // NOTE: the slug is derived with dasherize(title) before super.setup() + // runs, so an undefined title blows up inside dasherize and the + // "MenuPanel requires a title" message is never reached. The failure is + // still loud, just less helpful than the one the author intended. + assert.throws(() => new MenuPanel(), TypeError); + }); + }); + + module('chaining', function () { + test('the setters assign and keep options in step', function (assert) { + const panel = new MenuPanel('Fleet').withSlug('ops').withIcon('truck').withPriority(2); + + assert.strictEqual(panel.slug, 'ops'); + assert.strictEqual(panel.getOption('slug'), 'ops'); + assert.strictEqual(panel.icon, 'truck'); + assert.strictEqual(panel.priority, 2); + assert.strictEqual(panel.getOption('priority'), 2); + }); + + test('addItem appends a plain item and returns the panel', function (assert) { + const panel = new MenuPanel('Fleet'); + const item = { title: 'Orders' }; + + assert.strictEqual(panel.addItem(item), panel); + assert.deepEqual(panel.items, [item]); + }); + + test('addItem flattens a MenuItem to its object form', function (assert) { + const panel = new MenuPanel('Fleet'); + const item = new MenuItem('Orders'); + + panel.addItem(item); + + assert.deepEqual(panel.items[0], item.toObject()); + assert.notStrictEqual(panel.items[0], item, 'the contract instance itself is not stored'); + }); + + test('addItems appends each entry', function (assert) { + const panel = new MenuPanel('Fleet'); + + assert.strictEqual(panel.addItems([{ title: 'A' }, new MenuItem('B')]), panel); + assert.strictEqual(panel.items.length, 2); + assert.strictEqual(panel.items[0].title, 'A'); + assert.strictEqual(panel.items[1].title, 'B'); + }); + + test('addItems with an empty list leaves the panel alone', function (assert) { + const panel = new MenuPanel('Fleet'); + + panel.addItems([]); + + assert.deepEqual(panel.items, []); + }); + }); + + module('toObject', function () { + test('it exposes every field and marks itself as a panel', function (assert) { + const object = new MenuPanel('Fleet Operations').withIcon('truck').addItem({ title: 'Orders' }).toObject(); + + assert.strictEqual(object.title, 'Fleet Operations'); + assert.strictEqual(object.slug, 'fleet-operations'); + assert.strictEqual(object.icon, 'truck'); + assert.true(object.open); + assert.strictEqual(object.priority, 9); + assert.deepEqual(object.items, [{ title: 'Orders' }]); + assert.true(object._isMenuPanel, 'the indicator flag lets consumers tell panels from items'); + }); + }); +}); diff --git a/tests/unit/contracts/registry-test.js b/tests/unit/contracts/registry-test.js new file mode 100644 index 00000000..cc4bd355 --- /dev/null +++ b/tests/unit/contracts/registry-test.js @@ -0,0 +1,46 @@ +import Registry from '@fleetbase/ember-core/contracts/registry'; +import { module, test } from 'qunit'; + +module('Unit | Contract | registry', function () { + test('it keeps the name it was constructed with', function (assert) { + const registry = new Registry('fleet-ops'); + + assert.strictEqual(registry.name, 'fleet-ops'); + assert.strictEqual(registry.getOption('name'), 'fleet-ops'); + assert.strictEqual(registry.toString(), 'fleet-ops'); + }); + + test('withNamespace appends to the name and returns the registry', function (assert) { + const registry = new Registry('fleet-ops'); + + assert.strictEqual(registry.withNamespace('component'), registry); + assert.strictEqual(registry.name, 'fleet-ops:component'); + assert.strictEqual(registry.getOption('name'), 'fleet-ops:component', 'the stored option is kept in step'); + }); + + test('withSubNamespace appends further', function (assert) { + const registry = new Registry('fleet-ops').withNamespace('component').withSubNamespace('vehicle:details'); + + assert.strictEqual(registry.name, 'fleet-ops:component:vehicle:details'); + assert.strictEqual(registry.toString(), 'fleet-ops:component:vehicle:details'); + }); + + test('toObject exposes the current name', function (assert) { + const registry = new Registry('fleet-ops').withNamespace('component'); + + assert.deepEqual(registry.toObject(), { name: 'fleet-ops:component' }); + }); + + test('validate rejects a registry without a name', function (assert) { + assert.throws(() => new Registry().setup(), /Registry requires a name/); + assert.throws(() => new Registry('').setup(), /Registry requires a name/); + }); + + test('validate accepts a named registry', function (assert) { + const registry = new Registry('fleet-ops'); + + registry.setup(); + + assert.strictEqual(registry.name, 'fleet-ops'); + }); +}); diff --git a/tests/unit/contracts/state-containers-test.js b/tests/unit/contracts/state-containers-test.js new file mode 100644 index 00000000..e925fe12 --- /dev/null +++ b/tests/unit/contracts/state-containers-test.js @@ -0,0 +1,72 @@ +import ExtensionBootState from '@fleetbase/ember-core/contracts/extension-boot-state'; +import HookRegistry from '@fleetbase/ember-core/contracts/hook-registry'; +import { module, test } from 'qunit'; + +// Shared singletons registered on the application container. They hold state and +// no behaviour, so what matters is their initial shape and that each instance gets +// its own containers rather than sharing class-level ones. +// +// UniverseRegistry is deliberately absent: it imports tracked-built-ins, which was +// an undeclared dependency. It is now declared, but the module still does not +// resolve in the build, which is also why contracts/universe-registry.js and +// services/universe/registry-service.js never appear in the coverage report. +module('Unit | Contract | shared state containers', function () { + module('ExtensionBootState', function () { + test('it starts in the booting state with nothing loaded', function (assert) { + const state = new ExtensionBootState(); + + assert.true(state.isBooting); + assert.false(state.extensionsLoaded); + assert.strictEqual(state.bootPromise, null); + assert.strictEqual(state.extensionsLoadedPromise, null); + assert.strictEqual(state.extensionsLoadedResolver, null); + }); + + test('it exposes empty engine and promise containers', function (assert) { + const state = new ExtensionBootState(); + + assert.strictEqual(state.loadedEngines.size, 0); + assert.strictEqual(state.loadingPromises.size, 0); + assert.strictEqual(state.engineLoadedHooks.size, 0); + assert.strictEqual(state.registeredExtensions.length, 0); + }); + + test('each instance owns its containers', function (assert) { + const first = new ExtensionBootState(); + const second = new ExtensionBootState(); + + first.loadedEngines.set('a', {}); + first.registeredExtensions.pushObject('a'); + + assert.strictEqual(second.loadedEngines.size, 0, 'maps are not shared between instances'); + assert.strictEqual(second.registeredExtensions.length, 0, 'arrays are not shared between instances'); + }); + + test('its state can be advanced as booting completes', function (assert) { + const state = new ExtensionBootState(); + + state.isBooting = false; + state.extensionsLoaded = true; + state.loadedEngines.set('@fleetbase/fleetops-engine', { name: 'fleetops' }); + + assert.false(state.isBooting); + assert.true(state.extensionsLoaded); + assert.strictEqual(state.loadedEngines.get('@fleetbase/fleetops-engine').name, 'fleetops'); + }); + }); + + module('HookRegistry', function () { + test('it starts with no hooks', function (assert) { + assert.deepEqual(new HookRegistry().hooks, {}); + }); + + test('each instance owns its hook map', function (assert) { + const first = new HookRegistry(); + const second = new HookRegistry(); + + first.hooks = { boot: [() => {}] }; + + assert.deepEqual(second.hooks, {}); + }); + }); +}); diff --git a/tests/unit/contracts/template-helper-test.js b/tests/unit/contracts/template-helper-test.js new file mode 100644 index 00000000..b764d3f9 --- /dev/null +++ b/tests/unit/contracts/template-helper-test.js @@ -0,0 +1,71 @@ +import TemplateHelper from '@fleetbase/ember-core/contracts/template-helper'; +import { module, test } from 'qunit'; + +module('Unit | Contract | template-helper', function () { + module('construction from a path', function () { + test('it takes the last segment of the path as the name', function (assert) { + const helper = new TemplateHelper('fleet-ops', 'helpers/format-distance'); + + assert.strictEqual(helper.engineName, 'fleet-ops'); + assert.strictEqual(helper.path, 'helpers/format-distance'); + assert.strictEqual(helper.name, 'format-distance'); + assert.strictEqual(helper.class, null); + assert.false(helper.isClass); + }); + + test('a path with no separator is used whole', function (assert) { + assert.strictEqual(new TemplateHelper('fleet-ops', 'humanize').name, 'humanize'); + }); + + test('a deeply nested path still resolves to its final segment', function (assert) { + assert.strictEqual(new TemplateHelper('fleet-ops', 'a/b/c/format-money').name, 'format-money'); + }); + }); + + module('construction from a class', function () { + test('it converts a PascalCase name to kebab-case and drops the Helper suffix', function (assert) { + class FormatDistanceHelper {} + const helper = new TemplateHelper('fleet-ops', FormatDistanceHelper); + + assert.strictEqual(helper.class, FormatDistanceHelper); + assert.true(helper.isClass); + assert.strictEqual(helper.path, null); + assert.strictEqual(helper.name, 'format-distance-'); + }); + + test('a name without the Helper suffix keeps every segment', function (assert) { + class FormatDistance {} + + assert.strictEqual(new TemplateHelper('fleet-ops', FormatDistance).name, 'format-distance'); + }); + + test('consecutive capitals are split before the trailing word', function (assert) { + class HTMLParser {} + + assert.strictEqual(new TemplateHelper('fleet-ops', HTMLParser).name, 'html-parser'); + }); + + test('a single-word class becomes its lowercase form', function (assert) { + class Humanize {} + + assert.strictEqual(new TemplateHelper('fleet-ops', Humanize).name, 'humanize'); + }); + + test('a named function is treated the same as a class', function (assert) { + function formatMoney() {} + + const helper = new TemplateHelper('fleet-ops', formatMoney); + assert.true(helper.isClass); + assert.strictEqual(helper.name, 'format-money'); + }); + + test('an anonymous function has no derivable name', function (assert) { + const helper = new TemplateHelper( + 'fleet-ops', + Object.defineProperty(() => {}, 'name', { value: '' }) + ); + + assert.strictEqual(helper.name, null); + }); + }); +}); diff --git a/tests/unit/contracts/widget-options-test.js b/tests/unit/contracts/widget-options-test.js new file mode 100644 index 00000000..6ee44b51 --- /dev/null +++ b/tests/unit/contracts/widget-options-test.js @@ -0,0 +1,56 @@ +import { module, test } from 'qunit'; +import Widget from '@fleetbase/ember-core/contracts/widget'; + +/** + * withTitle and withRefreshInterval each open with `if (!this.options) { + * this.options = {}; }`. The constructor already assigns `this.options` on both + * of its paths — `definition.options || {}` for a definition object, and `{}` + * for a bare id — so the bag is always there and neither guard can fire. + * + * Pinned rather than fixed: the two statements inside those guards are + * unreachable, so this file cannot reach 100% until they are removed. + */ +module('Unit | Contract | widget (options setters)', function () { + test('a widget built from a bare id already has an options bag', function (assert) { + assert.deepEqual(new Widget('orders-summary').options, {}, 'so the guard in each setter is dead code'); + }); + + test('a widget built from a definition with no options has one too', function (assert) { + assert.deepEqual(new Widget({ id: 'orders-summary', name: 'Orders' }).options, {}); + }); + + test('withTitle stores the title and chains', function (assert) { + const widget = new Widget('orders-summary'); + + const returned = widget.withTitle('Orders'); + + assert.strictEqual(returned, widget, 'it chains'); + assert.strictEqual(widget.options.title, 'Orders'); + assert.strictEqual(widget.toObject().options.title, 'Orders'); + }); + + test('withTitle keeps an options bag that came from the definition', function (assert) { + const widget = new Widget({ id: 'orders-summary', options: { existing: true } }); + + widget.withTitle('Orders'); + + assert.strictEqual(widget.options.existing, true, 'nothing already there is lost'); + assert.strictEqual(widget.options.title, 'Orders'); + }); + + test('withRefreshInterval stores the interval and chains', function (assert) { + const widget = new Widget('orders-summary'); + + const returned = widget.withRefreshInterval(30000); + + assert.strictEqual(returned, widget); + assert.strictEqual(widget.options.refreshInterval, 30000); + assert.strictEqual(widget.toObject().options.refreshInterval, 30000); + }); + + test('the two setters compose', function (assert) { + const widget = new Widget('orders-summary').withTitle('Orders').withRefreshInterval(5000); + + assert.deepEqual(widget.toObject().options, { title: 'Orders', refreshInterval: 5000 }); + }); +}); diff --git a/tests/unit/contracts/widget-test.js b/tests/unit/contracts/widget-test.js new file mode 100644 index 00000000..7e5aca58 --- /dev/null +++ b/tests/unit/contracts/widget-test.js @@ -0,0 +1,167 @@ +import Widget from '@fleetbase/ember-core/contracts/widget'; +import ExtensionComponent from '@fleetbase/ember-core/contracts/extension-component'; +import { module, test } from 'qunit'; + +module('Unit | Contract | widget', function () { + module('construction from an id', function () { + test('a bare id gets empty defaults', function (assert) { + const widget = new Widget('fleet-stats'); + + assert.strictEqual(widget.id, 'fleet-stats'); + assert.strictEqual(widget.name, null); + assert.strictEqual(widget.description, null); + assert.strictEqual(widget.icon, null); + assert.strictEqual(widget.component, null); + assert.deepEqual(widget.grid_options, {}); + assert.deepEqual(widget.options, {}); + assert.strictEqual(widget.category, 'default'); + }); + }); + + module('construction from a definition', function () { + test('it reads every field', function (assert) { + const widget = new Widget({ + id: 'fleet-stats', + name: 'Fleet stats', + description: 'Vehicle counts', + icon: 'truck', + component: 'widgets/fleet-stats', + grid_options: { w: 4, h: 2 }, + options: { title: 'Fleet' }, + category: 'operations', + }); + + assert.strictEqual(widget.id, 'fleet-stats'); + assert.strictEqual(widget.name, 'Fleet stats'); + assert.strictEqual(widget.description, 'Vehicle counts'); + assert.strictEqual(widget.icon, 'truck'); + assert.strictEqual(widget.component, 'widgets/fleet-stats'); + assert.deepEqual(widget.grid_options, { w: 4, h: 2 }); + assert.deepEqual(widget.options, { title: 'Fleet' }); + assert.strictEqual(widget.category, 'operations'); + }); + + test('widgetId is accepted as a legacy alias for id', function (assert) { + assert.strictEqual(new Widget({ widgetId: 'legacy' }).id, 'legacy'); + }); + + test('id wins when both id and widgetId are supplied', function (assert) { + assert.strictEqual(new Widget({ id: 'primary', widgetId: 'legacy' }).id, 'primary'); + }); + + test('a definition falls back to the same defaults', function (assert) { + const widget = new Widget({ id: 'minimal' }); + + assert.strictEqual(widget.name, null); + assert.strictEqual(widget.component, null); + assert.strictEqual(widget.category, 'default'); + assert.deepEqual(widget.grid_options, {}); + }); + + test('an ExtensionComponent is flattened to its object form', function (assert) { + const component = new ExtensionComponent('widgets/fleet-stats'); + const widget = new Widget({ id: 'w', component }); + + assert.deepEqual(widget.component, component.toObject()); + }); + + test('a plain object component is kept as-is', function (assert) { + const component = { name: 'widgets/fleet-stats', engine: 'fleet-ops' }; + + assert.deepEqual(new Widget({ id: 'w', component }).component, component); + }); + + test('the default flag is carried through', function (assert) { + assert.true(new Widget({ id: 'w', default: true }).isDefault()); + assert.false(new Widget({ id: 'w' }).isDefault()); + assert.false(new Widget({ id: 'w', default: false }).isDefault()); + }); + }); + + module('validation', function () { + test('it requires an id', function (assert) { + assert.throws(() => new Widget(), /Widget requires an id/); + assert.throws(() => new Widget(''), /Widget requires an id/); + assert.throws(() => new Widget({}), /Widget requires an id/); + }); + }); + + module('chaining', function () { + test('the simple setters assign and keep options in step', function (assert) { + const widget = new Widget('w').withName('Name').withDescription('Desc').withIcon('icon').withCategory('ops'); + + assert.strictEqual(widget.name, 'Name'); + assert.strictEqual(widget.getOption('name'), 'Name'); + assert.strictEqual(widget.description, 'Desc'); + assert.strictEqual(widget.icon, 'icon'); + assert.strictEqual(widget.category, 'ops'); + assert.strictEqual(widget.getOption('category'), 'ops'); + }); + + test('withComponent accepts a string or an ExtensionComponent', function (assert) { + assert.strictEqual(new Widget('w').withComponent('widgets/x').component, 'widgets/x'); + + const component = new ExtensionComponent('widgets/y'); + assert.deepEqual(new Widget('w').withComponent(component).component, component.toObject()); + }); + + test('withGridOptions and withOptions merge rather than replace', function (assert) { + const widget = new Widget('w').withGridOptions({ w: 4 }).withGridOptions({ h: 2 }).withOptions({ a: 1 }).withOptions({ b: 2 }); + + assert.deepEqual(widget.grid_options, { w: 4, h: 2 }); + assert.deepEqual(widget.options, { a: 1, b: 2 }); + }); + + test('withTitle and withRefreshInterval write into options', function (assert) { + const widget = new Widget('w').withTitle('Fleet').withRefreshInterval(5000); + + assert.strictEqual(widget.options.title, 'Fleet'); + assert.strictEqual(widget.options.refreshInterval, 5000); + }); + + test('asDefault marks the widget as a default', function (assert) { + const widget = new Widget('w'); + + assert.false(widget.isDefault()); + assert.strictEqual(widget.asDefault(), widget); + assert.true(widget.isDefault()); + }); + + test('every setter returns the widget', function (assert) { + const widget = new Widget('w'); + + for (const call of [ + () => widget.withName('n'), + () => widget.withDescription('d'), + () => widget.withIcon('i'), + () => widget.withComponent('c'), + () => widget.withGridOptions({}), + () => widget.withOptions({}), + () => widget.withCategory('c'), + () => widget.withTitle('t'), + () => widget.withRefreshInterval(1), + ]) { + assert.strictEqual(call(), widget); + } + }); + }); + + module('toObject', function () { + test('it exposes every widget property', function (assert) { + const object = new Widget('w').withName('Name').withCategory('ops').toObject(); + + assert.strictEqual(object.id, 'w'); + assert.strictEqual(object.name, 'Name'); + assert.strictEqual(object.category, 'ops'); + assert.deepEqual(object.grid_options, {}); + assert.deepEqual(object.options, {}); + assert.strictEqual(object.description, null); + assert.strictEqual(object.icon, null); + assert.strictEqual(object.component, null); + }); + + test('it carries the default flag when set', function (assert) { + assert.true(new Widget('w').asDefault().toObject().default); + }); + }); +}); diff --git a/tests/unit/decorators/engine-service-test.js b/tests/unit/decorators/engine-service-test.js new file mode 100644 index 00000000..71f6bcaa --- /dev/null +++ b/tests/unit/decorators/engine-service-test.js @@ -0,0 +1,108 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; +import EmberObject from '@ember/object'; +import Service from '@ember/service'; +import { setOwner } from '@ember/application'; +import engineService from 'dummy/decorators/engine-service'; + +/** + * @engineService installs a service borrowed from a mounted engine as a + * property on the decorated class, resolving it lazily on first access. + */ +module('Unit | Decorator | engine-service', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.requested = []; + this.engineService = {}; + const testContext = this; + + this.owner.register( + 'service:universe', + class extends Service { + getServiceFromEngine(engineName, serviceName) { + testContext.requested.push({ engineName, serviceName }); + return testContext.engineService; + } + } + ); + + this.owner.register('service:store', class extends Service {}); + + this.build = (Klass) => { + const instance = Klass.create ? Klass.create() : new Klass(); + setOwner(instance, this.owner); + return instance; + }; + }); + + test('it resolves the named service from the named engine', function (assert) { + class Host extends EmberObject { + @engineService('fleet-ops') orders; + } + + const host = this.build(Host); + + assert.strictEqual(host.orders, this.engineService); + assert.deepEqual(this.requested, [{ engineName: 'fleet-ops', serviceName: 'orders' }]); + }); + + test('resolution is lazy — nothing is asked for until the property is read', function (assert) { + class Host extends EmberObject { + @engineService('fleet-ops') orders; + } + + this.build(Host); + + assert.deepEqual(this.requested, [], 'construction alone resolves nothing'); + }); + + test('an initializer takes precedence over the resolved service', function (assert) { + class Host extends EmberObject { + @engineService('fleet-ops') orders = 'from initializer'; + } + + const host = this.build(Host); + + assert.strictEqual(host.orders, 'from initializer'); + assert.deepEqual(this.requested, [{ engineName: 'fleet-ops', serviceName: 'orders' }], 'the engine is still consulted first'); + }); + + test('declared injections are wired onto the engine service', function (assert) { + class Host extends EmberObject { + @engineService('fleet-ops', { inject: ['store'] }) orders; + } + + const host = this.build(Host); + host.orders; + + assert.strictEqual(this.engineService.store, this.owner.lookup('service:store')); + }); + + test('the engine name must be a string', function (assert) { + assert.throws(() => { + class Host extends EmberObject { + @engineService(42) orders; + } + this.build(Host).orders; + }, /first argument of the @engineService decorator must be a string/); + }); + + test('the options must be an object', function (assert) { + assert.throws(() => { + class Host extends EmberObject { + @engineService('fleet-ops', 'nope') orders; + } + this.build(Host).orders; + }, /second argument of the @engineService decorator must be an object/); + }); + + test('it requires at least an engine name', function (assert) { + assert.throws(() => { + class Host extends EmberObject { + @engineService orders; + } + this.build(Host); + }); + }); +}); diff --git a/tests/unit/decorators/fetch-from-test.js b/tests/unit/decorators/fetch-from-test.js new file mode 100644 index 00000000..a6918856 --- /dev/null +++ b/tests/unit/decorators/fetch-from-test.js @@ -0,0 +1,148 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; +import EmberObject from '@ember/object'; +import Service from '@ember/service'; +import { setOwner } from '@ember/application'; +import fetchFrom from 'dummy/decorators/fetch-from'; + +/** + * @fetchFrom turns a property into a lazily-fetched one: the first read starts + * the request and writes the result back onto the property, so later reads are + * served from the cached value rather than refetching. + */ +module('Unit | Decorator | fetch-from', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.calls = []; + this.response = { data: 'value' }; + this.shouldReject = false; + const testContext = this; + + this.owner.register( + 'service:fetch', + class extends Service { + get(path, query, options) { + testContext.calls.push({ path, query, options }); + return testContext.shouldReject ? Promise.reject(new Error('offline')) : Promise.resolve(testContext.response); + } + } + ); + + this.build = (Klass) => { + const instance = Klass.create(); + setOwner(instance, this.owner); + return instance; + }; + }); + + test('reading the property issues the request', async function (assert) { + class Host extends EmberObject { + @fetchFrom('some/endpoint') data; + } + const host = this.build(Host); + + await host.data; + + assert.deepEqual(this.calls, [{ path: 'some/endpoint', query: {}, options: {} }]); + }); + + test('the query and options are passed through', async function (assert) { + class Host extends EmberObject { + @fetchFrom('some/endpoint', { limit: 5 }, { headers: { 'X-A': '1' } }) data; + } + const host = this.build(Host); + + await host.data; + + assert.deepEqual(this.calls[0].query, { limit: 5 }); + assert.deepEqual(this.calls[0].options, { headers: { 'X-A': '1' } }); + }); + + test('the first read resolves before the value has landed', async function (assert) { + class Host extends EmberObject { + @fetchFrom('some/endpoint') data; + } + const host = this.build(Host); + + assert.strictEqual(await host.data, undefined, 'the first read starts the fetch rather than returning it'); + }); + + test('once fetched, the value is served from the property', async function (assert) { + class Host extends EmberObject { + @fetchFrom('some/endpoint') data; + } + const host = this.build(Host); + + await host.data; + + assert.strictEqual(await host.data, this.response); + }); + + test('the request is only issued once', async function (assert) { + class Host extends EmberObject { + @fetchFrom('some/endpoint') data; + } + const host = this.build(Host); + + await host.data; + await host.data; + await host.data; + + assert.strictEqual(this.calls.length, 1); + }); + + test('an assigned value is returned without any request', async function (assert) { + class Host extends EmberObject { + @fetchFrom('some/endpoint') data; + } + const host = this.build(Host); + + host.data = 'preset'; + + assert.strictEqual(await host.data, 'preset'); + assert.deepEqual(this.calls, [], 'nothing was fetched'); + }); + + test('an onComplete hook receives the response and the instance', async function (assert) { + const seen = []; + class Host extends EmberObject { + @fetchFrom('some/endpoint', {}, { onComplete: (response, instance) => seen.push({ response, instance }) }) data; + } + const host = this.build(Host); + + await host.data; + + assert.deepEqual(seen, [{ response: this.response, instance: host }]); + }); + + test('a failed request leaves the property null rather than rejecting', async function (assert) { + this.shouldReject = true; + class Host extends EmberObject { + @fetchFrom('some/endpoint') data; + } + const host = this.build(Host); + + await host.data; + + assert.strictEqual(await host.data, null); + }); + + test('the endpoint must be a string', function (assert) { + assert.throws(() => { + class Host extends EmberObject { + @fetchFrom(42) data; + } + this.build(Host); + }, /first argument of the @fetchFrom decorator must be a string/); + }); + + test('it requires at least an endpoint', function (assert) { + assert.throws(() => { + class Host extends EmberObject { + @fetchFrom data; + } + this.build(Host); + }); + }); +}); diff --git a/tests/unit/decorators/from-store-test.js b/tests/unit/decorators/from-store-test.js new file mode 100644 index 00000000..a0c5c934 --- /dev/null +++ b/tests/unit/decorators/from-store-test.js @@ -0,0 +1,89 @@ +import fromStore from '@fleetbase/ember-core/decorators/from-store'; +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; +import EmberObject from '@ember/object'; +import Service from '@ember/service'; +import { settled } from '@ember/test-helpers'; + +// The decorator resolves its query lazily on first read, so each subject class is +// declared once at module scope and instantiated per test with an owner. +class Simple extends EmberObject { + @fromStore('widget') records; +} + +class WithQuery extends EmberObject { + @fromStore('widget', { active: true }) records; +} + +module('Unit | Decorator | from-store', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.queries = []; + this.response = ['record-a', 'record-b']; + const testContext = this; + + this.owner.register( + 'service:store', + class extends Service { + query(modelName, query, options) { + testContext.queries.push({ modelName, query, options }); + return testContext.response instanceof Error ? Promise.reject(testContext.response) : Promise.resolve(testContext.response); + } + } + ); + }); + + test('it queries the store on first access and assigns the result', async function (assert) { + const subject = WithQuery.create(this.owner.ownerInjection()); + + subject.records; + await settled(); + + assert.strictEqual(this.queries.length, 1); + assert.strictEqual(this.queries[0].modelName, 'widget'); + assert.deepEqual(this.queries[0].query, { active: true }); + assert.deepEqual(subject.records, ['record-a', 'record-b']); + }); + + test('it queries only once, serving the cached value afterwards', async function (assert) { + const subject = Simple.create(this.owner.ownerInjection()); + + subject.records; + await settled(); + subject.records; + await settled(); + + assert.strictEqual(this.queries.length, 1); + }); + + test('it defaults the query and options', async function (assert) { + const subject = Simple.create(this.owner.ownerInjection()); + + subject.records; + await settled(); + + assert.deepEqual(this.queries[0].query, {}); + assert.deepEqual(this.queries[0].options, {}); + }); + + test('it assigns null when the query rejects', async function (assert) { + this.response = new Error('store is unavailable'); + const subject = Simple.create(this.owner.ownerInjection()); + + subject.records; + await settled(); + + assert.strictEqual(subject.records, null); + }); + + test('an explicitly assigned value is used without querying', async function (assert) { + const subject = Simple.create(this.owner.ownerInjection()); + + subject.records = ['preset']; + await settled(); + + assert.deepEqual(subject.records, ['preset']); + assert.strictEqual(this.queries.length, 0, 'the store is never consulted'); + }); +}); diff --git a/tests/unit/decorators/is-equal-test.js b/tests/unit/decorators/is-equal-test.js new file mode 100644 index 00000000..e1b53e32 --- /dev/null +++ b/tests/unit/decorators/is-equal-test.js @@ -0,0 +1,101 @@ +import { isEqual } from '@fleetbase/ember-core/decorators/is-equal'; +import { module, test } from 'qunit'; +import EmberObject, { set } from '@ember/object'; + +/** + * NOTE — this decorator does not work on Ember 5.4 with ember-decorators 6, and + * these tests pin what it actually does rather than what it is meant to do. + * + * The decorated property reads back as `undefined` no matter what the two source + * properties hold. The inner function hands a ComputedProperty back to + * decoratorWithRequiredParams, which expects a property descriptor, 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)` — harmless today only because those two are never used.) + * + * Fixing it means deciding how the property should be defined, which is a + * maintainer's call, so nothing is changed here. These tests will fail as soon as + * somebody makes it work, which is the point at which that decision gets made. + */ +class Subject extends EmberObject { + @isEqual('a', 'b') matches; +} + +module('Unit | Decorator | is-equal', function () { + test('the decorated property is undefined even when the values match', function (assert) { + assert.strictEqual(Subject.create({ a: 'x', b: 'x' }).matches, undefined); + }); + + test('it is equally undefined when the values differ', function (assert) { + assert.strictEqual(Subject.create({ a: 'x', b: 'y' }).matches, undefined); + }); + + test('it stays undefined after either dependent property changes', function (assert) { + const subject = Subject.create({ a: 'x', b: 'y' }); + + set(subject, 'b', 'x'); + assert.strictEqual(subject.matches, undefined); + + set(subject, 'a', 'z'); + assert.strictEqual(subject.matches, undefined); + }); + + test('the decorator itself is a function that accepts two property names', function (assert) { + assert.strictEqual(typeof isEqual, 'function'); + assert.strictEqual(typeof isEqual('a', 'b'), 'function', 'it returns a decorator'); + }); + + test('the arity guard can never fire, so one property name fails deeper down', function (assert) { + // Pinned, not fixed. The guard is + // assert('... requires two property names ...', params.length === 2) + // but `isEqual(propNameA, propNameB)` forwards BOTH parameters onwards + // unconditionally, so `params` is always exactly two long — it is + // ['a', undefined] here, not ['a']. The guard is dead code, and a caller + // who passes one name gets Ember's low-level computed-key error instead + // of the decorator's own message naming the mistake. + assert.throws(() => isEqual('a')({}, 'matches', {}), /computed property key must be a string/); + }); + + /** + * The comparison the decorator builds is correct — it is only the installation + * that fails. Applying the decorator by hand yields the ComputedProperty it + * meant to define, and installing THAT through `.extend()` behaves exactly as + * the decorator was supposed to. + * + * This is the useful half of the finding: whoever fixes the decorator does not + * need to rewrite the comparison, only to define the property properly. + */ + module('the computed property it builds', function () { + const buildComputed = () => isEqual('a', 'b')({}, 'matches', {}); + + test('it compares the two named properties', function (assert) { + const Working = EmberObject.extend({ matches: buildComputed() }); + + assert.true(Working.create({ a: 'x', b: 'x' }).matches); + assert.false(Working.create({ a: 'x', b: 'y' }).matches); + }); + + test('it compares by identity, not by value', function (assert) { + const Working = EmberObject.extend({ matches: buildComputed() }); + + assert.false(Working.create({ a: {}, b: {} }).matches, 'two equivalent objects are not equal'); + assert.false(Working.create({ a: 1, b: '1' }).matches, 'and no coercion happens'); + }); + + test('it recomputes when either property changes', function (assert) { + const subject = EmberObject.extend({ matches: buildComputed() }).create({ a: 'x', b: 'y' }); + + assert.false(subject.matches); + + set(subject, 'b', 'x'); + assert.true(subject.matches); + + set(subject, 'a', 'z'); + assert.false(subject.matches); + }); + + test('two undefined properties count as equal', function (assert) { + assert.true(EmberObject.extend({ matches: buildComputed() }).create().matches); + }); + }); +}); diff --git a/tests/unit/decorators/legacy-fetch-from-test.js b/tests/unit/decorators/legacy-fetch-from-test.js new file mode 100644 index 00000000..2cfc68ef --- /dev/null +++ b/tests/unit/decorators/legacy-fetch-from-test.js @@ -0,0 +1,132 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; +import EmberObject from '@ember/object'; +import Service from '@ember/service'; +import { run } from '@ember/runloop'; +import legacyFetchFrom from 'dummy/decorators/legacy-fetch-from'; + +/** + * The legacy form fetches eagerly instead of lazily: it wraps `init` and + * schedules the request for afterRender, writing the result onto the property + * when it lands. Until then the property reads as null. + * + * `run(() => {})` flushes the afterRender queue; a macrotask tick then lets the + * fetch promise settle. + */ +function flush() { + run(() => {}); + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +module('Unit | Decorator | legacy-fetch-from', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.calls = []; + this.response = ['a', 'b']; + this.shouldReject = false; + const testContext = this; + + this.owner.register( + 'service:fetch', + class extends Service { + get(path, query, options) { + testContext.calls.push({ path, query, options }); + return testContext.shouldReject ? Promise.reject(new Error('offline')) : Promise.resolve(testContext.response); + } + } + ); + + this.build = (Klass) => Klass.create(this.owner.ownerInjection()); + }); + + test('on a native class field the property starts undefined, not null', function (assert) { + // Worth knowing before relying on `=== null` as "not loaded yet". + // The decorator defines a symbol-backed accessor on the prototype and + // seeds it with null, but a native class field installs an own property + // on the instance, which shadows that accessor. The seeded null is + // therefore never observed. This decorator predates native class + // fields — it wraps `init`, so it was written for `.extend()` classes, + // where the accessor is not shadowed and the null default does apply. + class Host extends EmberObject { + @legacyFetchFrom('some/endpoint') data; + } + + assert.strictEqual(this.build(Host).data, undefined, 'the instance field shadows the seeded null'); + }); + + test('the request is issued after render and the result assigned', async function (assert) { + class Host extends EmberObject { + @legacyFetchFrom('some/endpoint') data; + } + const host = this.build(Host); + + await flush(); + + assert.deepEqual(this.calls, [{ path: 'some/endpoint', query: {}, options: {} }]); + assert.strictEqual(host.data, this.response); + }); + + test('the query and options are passed through', async function (assert) { + class Host extends EmberObject { + @legacyFetchFrom('some/endpoint', { limit: 5 }, { headers: { 'X-A': '1' } }) data; + } + this.build(Host); + + await flush(); + + assert.deepEqual(this.calls[0].query, { limit: 5 }); + assert.deepEqual(this.calls[0].options, { headers: { 'X-A': '1' } }); + }); + + test('a failed request leaves an empty list rather than rejecting', async function (assert) { + this.shouldReject = true; + class Host extends EmberObject { + @legacyFetchFrom('some/endpoint') data; + } + const host = this.build(Host); + + await flush(); + + assert.deepEqual(host.data, [], 'callers can still iterate it'); + }); + + test('the property remains assignable', async function (assert) { + class Host extends EmberObject { + @legacyFetchFrom('some/endpoint') data; + } + const host = this.build(Host); + await flush(); + + host.data = 'replaced'; + + assert.strictEqual(host.data, 'replaced'); + }); + + test('the inherited init still runs, so create() properties are applied', async function (assert) { + // The decorator replaces `target.init` with a wrapper that calls the + // original. EmberObject's own init is what assigns create() arguments, + // so this would silently break if the wrapper dropped it. + class Host extends EmberObject { + @legacyFetchFrom('some/endpoint') data; + } + + const host = Host.create(this.owner.ownerInjection(), { label: 'given' }); + await flush(); + + assert.strictEqual(host.label, 'given'); + assert.strictEqual(host.data, this.response, 'and the fetch still happened'); + }); + + test('the endpoint must be a string', function (assert) { + assert.throws(() => legacyFetchFrom(42), /first argument of the @fetchFrom decorator must be a string/); + }); + + test('the query must be an object', function (assert) { + assert.throws(() => legacyFetchFrom('some/endpoint', 'nope'), /second argument of the @fetchFrom decorator must be an object/); + }); + + test('the options must be an object', function (assert) { + assert.throws(() => legacyFetchFrom('some/endpoint', {}, 'nope'), /third argument of the @fetchFrom decorator must be an object/); + }); +}); diff --git a/tests/unit/decorators/legacy-from-store-test.js b/tests/unit/decorators/legacy-from-store-test.js new file mode 100644 index 00000000..fae0cc51 --- /dev/null +++ b/tests/unit/decorators/legacy-from-store-test.js @@ -0,0 +1,75 @@ +import legacyFromStore from '@fleetbase/ember-core/decorators/legacy-from-store'; +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; +import EmberObject from '@ember/object'; +import Service from '@ember/service'; +import { settled } from '@ember/test-helpers'; + +// NOTE: addon/decorators/legacy-from-store.js is byte-for-byte identical to +// addon/decorators/from-store.js. Both are exported, so both are covered here, +// but one of them is redundant and the pair will drift apart the first time only +// one is edited. Consolidating them is a maintainer's call. +class Subject extends EmberObject { + @legacyFromStore('widget', { active: true }) records; +} + +module('Unit | Decorator | legacy-from-store', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.queries = []; + this.response = ['legacy-a']; + const testContext = this; + + this.owner.register( + 'service:store', + class extends Service { + query(modelName, query, options) { + testContext.queries.push({ modelName, query, options }); + return testContext.response instanceof Error ? Promise.reject(testContext.response) : Promise.resolve(testContext.response); + } + } + ); + }); + + test('it queries the store lazily and assigns the result', async function (assert) { + const subject = Subject.create(this.owner.ownerInjection()); + + subject.records; + await settled(); + + assert.deepEqual(this.queries, [{ modelName: 'widget', query: { active: true }, options: {} }]); + assert.deepEqual(subject.records, ['legacy-a']); + }); + + test('it caches after the first read', async function (assert) { + const subject = Subject.create(this.owner.ownerInjection()); + + subject.records; + await settled(); + subject.records; + await settled(); + + assert.strictEqual(this.queries.length, 1); + }); + + test('it assigns null when the query rejects', async function (assert) { + this.response = new Error('unavailable'); + const subject = Subject.create(this.owner.ownerInjection()); + + subject.records; + await settled(); + + assert.strictEqual(subject.records, null); + }); + + test('an assigned value bypasses the store', async function (assert) { + const subject = Subject.create(this.owner.ownerInjection()); + + subject.records = ['preset']; + await settled(); + + assert.deepEqual(subject.records, ['preset']); + assert.strictEqual(this.queries.length, 0); + }); +}); diff --git a/tests/unit/final-branches-test.js b/tests/unit/final-branches-test.js new file mode 100644 index 00000000..65fa366d --- /dev/null +++ b/tests/unit/final-branches-test.js @@ -0,0 +1,234 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; +import { settled } from '@ember/test-helpers'; +import Service from '@ember/service'; +import EmberObject from '@ember/object'; +import ObjectProxy from '@ember/object/proxy'; +import fromStore from '@fleetbase/ember-core/decorators/from-store'; +import legacyFromStore from '@fleetbase/ember-core/decorators/legacy-from-store'; +import serializeModel from 'dummy/utils/serialize-model'; + +/** + * The last handful of one-line branches, each the only thing left in its file. + * They are collected here rather than spread across seven new files, since none + * of them warrants a module of its own. + */ +class OnComplete extends EmberObject { + @fromStore('widget', {}, { onComplete: (response, subject) => subject.seen.push(response) }) records; +} + +class LegacyOnComplete extends EmberObject { + @legacyFromStore('widget', {}, { onComplete: (response, subject) => subject.seen.push(response) }) records; +} + +module('Unit | Decorator | from-store (onComplete)', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.response = ['record-a']; + const testContext = this; + + this.owner.register( + 'service:store', + class extends Service { + query() { + return Promise.resolve(testContext.response); + } + } + ); + }); + + test('onComplete receives the response and the subject', async function (assert) { + const subject = OnComplete.create(this.owner.ownerInjection(), { seen: [] }); + + subject.records; + await settled(); + + assert.deepEqual(subject.seen, [this.response]); + assert.deepEqual(subject.records, this.response, 'and the property is still assigned'); + }); + + test('the legacy decorator does the same', async function (assert) { + const subject = LegacyOnComplete.create(this.owner.ownerInjection(), { seen: [] }); + + subject.records; + await settled(); + + assert.deepEqual(subject.seen, [this.response]); + }); +}); + +module('Unit | Utility | serialize-model (a proxy with toJSON)', function () { + test('toJSON is preferred when the subject has one', function (assert) { + // An ember-data record has `serialize` but no `toJSON`, so the toJSON + // branch needs a subject that isModel accepts and that does have one — + // an ObjectProxy is the one shape that qualifies. + const proxied = ObjectProxy.extend({ + toJSON() { + return { via: 'toJSON' }; + }, + serialize() { + return { via: 'serialize' }; + }, + }).create(); + + assert.deepEqual(serializeModel(proxied), { via: 'toJSON' }, 'serialize is not consulted'); + }); +}); + +module('Unit | Service | theme (system colour scheme)', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.owner.register( + 'service:current-user', + class extends Service { + getOption() { + return null; + } + } + ); + for (const name of ['universe', 'router', 'fetch', 'session']) { + this.owner.register(`service:${name}`, class extends Service {}); + } + + this.service = this.owner.lookup('service:theme'); + this.service.initialTheme = null; + + // matchMedia is replaced around the CALL only — it is a global the test + // framework and Ember itself may consult. + this.withColorScheme = (matches, fn) => { + const original = window.matchMedia; + window.matchMedia = () => ({ matches, addEventListener() {}, removeEventListener() {} }); + try { + return fn(); + } finally { + window.matchMedia = original; + } + }; + }); + + test('a system preference for dark is honoured', function (assert) { + assert.strictEqual( + this.withColorScheme(true, () => this.service.activeTheme), + 'dark' + ); + }); + + test('no system preference falls through to currentTheme, whatever that is', function (assert) { + // The comment on that line reads "default to dark theme", but it returns + // `this.currentTheme` — which is null on a service nobody has set a theme + // on. The fallback is whatever was last stored, not a literal dark. + this.service.currentTheme = null; + assert.strictEqual( + this.withColorScheme(false, () => this.service.activeTheme), + null + ); + + this.service.currentTheme = 'light'; + assert.strictEqual( + this.withColorScheme(false, () => this.service.activeTheme), + 'light' + ); + }); +}); + +module('Unit | Service | language (saving a locale)', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.postRejects = false; + const testContext = this; + + this.owner.register( + 'service:fetch', + class extends Service { + post(path, body) { + testContext.posted = { path, body }; + return testContext.postRejects ? Promise.reject(new Error('offline')) : Promise.resolve({}); + } + } + ); + // LanguageService reads intl.locales and intl.primaryLocale in its + // CONSTRUCTOR and subscribes with onLocaleChanged — a bare stub throws + // at lookup, before any test body runs. + this.owner.register( + 'service:intl', + class extends Service { + locales = ['en-us']; + primaryLocale = 'en-us'; + onLocaleChanged() {} + setLocale() {} + } + ); + for (const name of ['current-user', 'session']) { + this.owner.register(`service:${name}`, class extends Service {}); + } + + this.service = this.owner.lookup('service:language'); + }); + + test('it posts the chosen locale', async function (assert) { + await this.service.saveUserLocale.perform('en-gb'); + + assert.deepEqual(this.posted, { path: 'users/locale', body: { locale: 'en-gb' } }); + }); + + test('a failure is swallowed rather than surfaced', async function (assert) { + this.postRejects = true; + + const result = await this.service.saveUserLocale.perform('en-gb'); + + assert.strictEqual(result, undefined, 'the caller cannot tell the save failed'); + }); +}); + +module('Unit | Service | filters (the unreachable filter)', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.searchParams = {}; + const testContext = this; + + this.owner.register( + 'service:url-search-params', + class extends Service { + get(key) { + return testContext.searchParams[key]; + } + } + ); + this.owner.register('service:router', class extends Service {}); + + this.service = this.owner.lookup('service:filters'); + + this.useCurrentRoute = ({ queryParams = {}, url = {} } = {}) => { + this.searchParams = url; + const route = { controller: null, queryParams }; + this.owner.register('router:main', { _routerMicrolib: { currentRouteInfos: [{ _route: route }] } }, { instantiate: false }); + }; + }); + + test('activeFilters can never actually skip anything', function (assert) { + // Pinned, not fixed. `activeFilters` loops over `this.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 therefore unreachable, and the + // filtering is duplicated one layer apart. + this.useCurrentRoute({ + queryParams: { status: null, page: null, type: null }, + url: { status: 'active', page: '2', type: '' }, + }); + + assert.deepEqual( + this.service.activeFilters, + [{ queryParam: 'status', label: 'status', value: 'active' }], + 'page was dropped as managed and type as blank — both before activeFilters saw them' + ); + assert.deepEqual(this.service.getQueryParams(), { status: 'active' }, 'which is the same list, already filtered'); + }); +}); diff --git a/tests/unit/initializers/load-socketcluster-client-test.js b/tests/unit/initializers/load-socketcluster-client-test.js index 723ab998..6563845a 100644 --- a/tests/unit/initializers/load-socketcluster-client-test.js +++ b/tests/unit/initializers/load-socketcluster-client-test.js @@ -1,37 +1,67 @@ -import Application from '@ember/application'; - -import config from 'dummy/config/environment'; -import { initialize } from 'dummy/initializers/load-socketcluster-client'; import { module, test } from 'qunit'; -import Resolver from 'ember-resolver'; -import { run } from '@ember/runloop'; +import { initialize } from 'dummy/initializers/load-socketcluster-client'; + +const MARKER_SELECTOR = 'script[data-socketcluster-client]'; +/** + * This initializer injects the SocketCluster client script, guarding against a + * second insertion so engines can boot it more than once. + * + * Two things make this awkward to test, and both are deliberate: + * + * tests/helpers/stub-socketcluster.js plants a marker node carrying the same + * `data-socketcluster-client` attribute, precisely so this guard trips and the + * real client never loads during the suite. That marker must survive these + * tests — removing it would let a later test pull in the real client, which is + * what used to hang the run. + * + * And the creation path must not actually append: a real