Compare commits
6 Commits
43a417b8d7
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
ed9d78315b
|
|||
|
eb74a5e6d1
|
|||
|
9054821e52
|
|||
| 51a9c8ad6f | |||
|
349f0e5eea
|
|||
|
c74d70040b
|
@@ -22,8 +22,7 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 22
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -34,16 +33,11 @@ jobs:
|
|||||||
name: "Test"
|
name: "Test"
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: cypress/browsers:node16.18.0-chrome107-ff106-edge
|
image: cypress/browsers:node-22.21.0-chrome-141.0.7390.107-1-ff-144.0-edge-141.0.3537.92-1
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 16
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Unhosted Ember.js Web UI for [Kosmos Kredits](https://wiki.kosmos.org/Kredits)
|
|||||||
- **ethers 5** + **@kredits/contracts** for on-chain reads/writes
|
- **ethers 5** + **@kredits/contracts** for on-chain reads/writes
|
||||||
- **ember-concurrency** for async sync tasks; **Ember Services** for state (no Redux, no Ember Data)
|
- **ember-concurrency** for async sync tasks; **Ember Services** for state (no Redux, no Ember Data)
|
||||||
- **QUnit** + qunit-dom + fetch-mock for tests
|
- **QUnit** + qunit-dom + fetch-mock for tests
|
||||||
- **Node 16** (`.nvmrc`); `>= 14` in engines
|
- **Node 22** (`.nvmrc`); `>= 22` in engines
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
@@ -22,8 +22,7 @@ Unhosted Ember.js Web UI for [Kosmos Kredits](https://wiki.kosmos.org/Kredits)
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Dev server (RSK testnet) | `npm start` |
|
| Dev server (RSK testnet) | `npm start` |
|
||||||
| Dev against local Hardhat devchain | `npm run start:local` |
|
| Dev against local Hardhat devchain | `npm run start:local` |
|
||||||
| Full lint + test pipeline (CI) | `npm test` |
|
| Ember/QUnit suite (all tests) | `ember test` |
|
||||||
| Ember/QUnit suite only | `npm run test:ember` |
|
|
||||||
| Interactive test runner | `ember test --server` |
|
| Interactive test runner | `ember test --server` |
|
||||||
| All linters | `npm run lint` |
|
| All linters | `npm run lint` |
|
||||||
| Lint JS (autofix) | `npm run lint:js -- --fix` |
|
| Lint JS (autofix) | `npm run lint:js -- --fix` |
|
||||||
@@ -91,7 +90,7 @@ Read in `config/environment.js` (lines ~102–110). No `.env.example` (`.gitigno
|
|||||||
|
|
||||||
- **Framework**: QUnit via `ember-qunit` + `qunit-dom`; `@ember/test-helpers` (`render`, `click`, `fillIn`).
|
- **Framework**: QUnit via `ember-qunit` + `qunit-dom`; `@ember/test-helpers` (`render`, `click`, `fillIn`).
|
||||||
- **HTTP mocking**: `fetch-mock` — `tests/test-helper.js` mocks Bitstamp ticker endpoints globally.
|
- **HTTP mocking**: `fetch-mock` — `tests/test-helper.js` mocks Bitstamp ticker endpoints globally.
|
||||||
- **Runner**: headless Chrome (`testem.js`); CI uses Node 16 via `.github/workflows/ci.yml`.
|
- **Runner**: headless Chrome (`testem.js`); CI uses Node 22 via `.gitea/workflows/ci.yaml`.
|
||||||
- **File patterns**:
|
- **File patterns**:
|
||||||
- `tests/unit/<type>/<name>-test.js` — unit tests for services, controllers, routes, models, helpers, utils.
|
- `tests/unit/<type>/<name>-test.js` — unit tests for services, controllers, routes, models, helpers, utils.
|
||||||
- `tests/integration/components/<name>/component-test.js` — component rendering tests (PODS layout).
|
- `tests/integration/components/<name>/component-test.js` — component rendering tests (PODS layout).
|
||||||
@@ -102,6 +101,7 @@ Read in `config/environment.js` (lines ~102–110). No `.env.example` (`.gitigno
|
|||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- **Mid-migration codebase**: classic and Octane component styles coexist. Linters warn (not error) on legacy patterns; `.template-lintrc.js` keeps a `pending` list of templates still using `{{action}}`.
|
- **Mid-migration codebase**: classic and Octane component styles coexist. Linters warn (not error) on legacy patterns; `.template-lintrc.js` keeps a `pending` list of templates still using `{{action}}`.
|
||||||
|
- **OpenSSL on Node 17+**: `ember-auto-import@1` bundles webpack 4, which hashes with MD4 — dropped by OpenSSL 3 in Node 17+. The `npm` scripts (`start`, `test`, `build`, `build-prod`, `start:local`) prepend `NODE_OPTIONS=--openssl-legacy-provider` to work around this. Direct `npx ember ...` invocations must set the flag manually. Remove once ember-auto-import is upgraded to v2+.
|
||||||
- **`release/` is committed build output** — don't hand-edit; regenerated by `npm run build-prod` (auto-staged on `npm version`).
|
- **`release/` is committed build output** — don't hand-edit; regenerated by `npm run build-prod` (auto-staged on `npm version`).
|
||||||
- **Deployment is `git push 5apps master`** (5apps static hosting) — pushing to `master` triggers a deploy. Be deliberate about commits/pushes.
|
- **Deployment is `git push 5apps master`** (5apps static hosting) — pushing to `master` triggers a deploy. Be deliberate about commits/pushes.
|
||||||
- **No `.env.example`** — set `WEB3_*` vars inline (see `npm run start:local` for the pattern).
|
- **No `.env.example`** — set `WEB3_*` vars inline (see `npm run start:local` for the pattern).
|
||||||
|
|||||||
@@ -19,8 +19,17 @@ export default Component.extend({
|
|||||||
src: alias('avatarURL'),
|
src: alias('avatarURL'),
|
||||||
title: alias('contributor.name'),
|
title: alias('contributor.name'),
|
||||||
|
|
||||||
avatarURL: computed('contributor.github_uid', 'size', function() {
|
// Re-compute whenever the contributor reference itself changes (covers a
|
||||||
const github_uid = this.contributor.github_uid;
|
// previously-undefined contributor being lazily loaded later), as well as
|
||||||
|
// the github_uid property when one is present.
|
||||||
|
avatarURL: computed('contributor', 'contributor.github_uid', 'size', function() {
|
||||||
|
const contributor = this.contributor;
|
||||||
|
|
||||||
|
if (!contributor) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const github_uid = contributor.github_uid;
|
||||||
|
|
||||||
if (github_uid) {
|
if (github_uid) {
|
||||||
return `https://avatars2.githubusercontent.com/u/${github_uid}?v=3&s=${SIZES[this.size]}`;
|
return `https://avatars2.githubusercontent.com/u/${github_uid}?v=3&s=${SIZES[this.size]}`;
|
||||||
|
|||||||
+37
-2
@@ -36,6 +36,11 @@ export default Service.extend({
|
|||||||
reimbursements: null,
|
reimbursements: null,
|
||||||
githubAccessToken: null,
|
githubAccessToken: null,
|
||||||
|
|
||||||
|
// IDs of contributors currently being lazy-fetched (see ensureContributorLoaded),
|
||||||
|
// used to avoid spawning duplicate concurrent requests when many contributions
|
||||||
|
// for the same missing contributor are loaded in a tight loop.
|
||||||
|
contributorsFetching: null,
|
||||||
|
|
||||||
currentUserIsContributor: notEmpty('currentUser'),
|
currentUserIsContributor: notEmpty('currentUser'),
|
||||||
currentUserIsCore: alias('currentUser.isCore'),
|
currentUserIsCore: alias('currentUser.isCore'),
|
||||||
hasAccounts: notEmpty('currentUserAccounts'),
|
hasAccounts: notEmpty('currentUserAccounts'),
|
||||||
@@ -56,6 +61,7 @@ export default Service.extend({
|
|||||||
this.set('contributors', []);
|
this.set('contributors', []);
|
||||||
this.set('contributions', []);
|
this.set('contributions', []);
|
||||||
this.set('reimbursements', []);
|
this.set('reimbursements', []);
|
||||||
|
this.set('contributorsFetching', new Set());
|
||||||
|
|
||||||
if (window.ethereum) {
|
if (window.ethereum) {
|
||||||
window.ethereum.on('chainChanged', this.handleUserChainChanged);
|
window.ethereum.on('chainChanged', this.handleUserChainChanged);
|
||||||
@@ -187,10 +193,17 @@ export default Service.extend({
|
|||||||
kreditsByContributor: computed('contributionsUnconfirmed.@each.vetoed', 'contributors.[]', function() {
|
kreditsByContributor: computed('contributionsUnconfirmed.@each.vetoed', 'contributors.[]', function() {
|
||||||
const contributionsUnconfirmed = this.contributionsUnconfirmed.filterBy('vetoed', false);
|
const contributionsUnconfirmed = this.contributionsUnconfirmed.filterBy('vetoed', false);
|
||||||
const contributionsGrouped = groupBy(contributionsUnconfirmed, 'contributorId');
|
const contributionsGrouped = groupBy(contributionsUnconfirmed, 'contributorId');
|
||||||
const contributorsWithUnconfirmed = contributionsGrouped.map(c => c.value);
|
|
||||||
|
// Drop groups whose contributor isn't loaded yet (e.g. an IPFS profile
|
||||||
|
// failed to deserialize). They'll be re-included once the contributor
|
||||||
|
// gets lazily fetched via loadContributionFromData.
|
||||||
|
const contributionsGroupedKnown = contributionsGrouped.filter(c => {
|
||||||
|
return this.contributors.findBy('id', c.value);
|
||||||
|
});
|
||||||
|
const contributorsWithUnconfirmed = contributionsGroupedKnown.map(c => c.value);
|
||||||
const contributorsWithOnlyConfirmed = this.contributors.reject(c => contributorsWithUnconfirmed.includes(c.id))
|
const contributorsWithOnlyConfirmed = this.contributors.reject(c => contributorsWithUnconfirmed.includes(c.id))
|
||||||
|
|
||||||
const kreditsByContributor = contributionsGrouped.map(c => {
|
const kreditsByContributor = contributionsGroupedKnown.map(c => {
|
||||||
const amountUnconfirmed = c.items.mapBy('amount').reduce((a, b) => a + b);
|
const amountUnconfirmed = c.items.mapBy('amount').reduce((a, b) => a + b);
|
||||||
const contributor = this.contributors.findBy('id', c.value);
|
const contributor = this.contributors.findBy('id', c.value);
|
||||||
|
|
||||||
@@ -302,6 +315,26 @@ export default Service.extend({
|
|||||||
.then(data => this.loadContributorFromData(data))
|
.then(data => this.loadContributorFromData(data))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// When a contribution/reimbursement references a contributor that isn't in
|
||||||
|
// the local collection yet (e.g. its IPFS profile failed to deserialize on a
|
||||||
|
// previous attempt, or it was added out-of-band), lazily fetch it in the
|
||||||
|
// background. Fire-and-forget: never blocks the calling loop iteration, and
|
||||||
|
// any rejection is logged so it doesn't break the run loop. The successful
|
||||||
|
// arrival of the contributor will retrigger dependent computeds
|
||||||
|
// (kreditsByContributor, UserAvatar) via the `contributors.[]` dependency.
|
||||||
|
ensureContributorLoaded (id) {
|
||||||
|
if (!id) return;
|
||||||
|
if (this.contributors.findBy('id', id)) return;
|
||||||
|
if (this.contributorsFetching.has(id)) return;
|
||||||
|
|
||||||
|
console.debug(`[kredits] Contributor ${id} not loaded yet; fetching in background`);
|
||||||
|
this.contributorsFetching.add(id);
|
||||||
|
this.fetchContributor(id)
|
||||||
|
.then(() => console.debug(`[kredits] Lazy-loaded contributor ${id}`))
|
||||||
|
.catch(err => console.warn(`[kredits] Failed to lazy-load contributor ${id}:`, err))
|
||||||
|
.finally(() => this.contributorsFetching.delete(id));
|
||||||
|
},
|
||||||
|
|
||||||
fetchContributors () {
|
fetchContributors () {
|
||||||
console.debug(`[kredits] Fetching all contributors from the network`);
|
console.debug(`[kredits] Fetching all contributors from the network`);
|
||||||
return this.kredits.Contributor.all()
|
return this.kredits.Contributor.all()
|
||||||
@@ -385,6 +418,7 @@ export default Service.extend({
|
|||||||
const loadedContribution = this.contributions.findBy('id', contribution.id);
|
const loadedContribution = this.contributions.findBy('id', contribution.id);
|
||||||
if (loadedContribution) { this.contributions.removeObject(loadedContribution); }
|
if (loadedContribution) { this.contributions.removeObject(loadedContribution); }
|
||||||
this.contributions.pushObject(contribution);
|
this.contributions.pushObject(contribution);
|
||||||
|
this.ensureContributorLoaded(data.contributorId);
|
||||||
return contribution;
|
return contribution;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -620,6 +654,7 @@ export default Service.extend({
|
|||||||
obj.set('contributor', this.contributors.findBy('id', data.recipientId));
|
obj.set('contributor', this.contributors.findBy('id', data.recipientId));
|
||||||
this.removeObjectFromCollectionIfLoaded('reimbursements', obj.id);
|
this.removeObjectFromCollectionIfLoaded('reimbursements', obj.id);
|
||||||
this.reimbursements.pushObject(obj);
|
this.reimbursements.pushObject(obj);
|
||||||
|
this.ensureContributorLoaded(data.recipientId);
|
||||||
return obj;
|
return obj;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -14,11 +14,11 @@
|
|||||||
"lint": "npm-run-all --aggregate-output --continue-on-error --parallel lint:*",
|
"lint": "npm-run-all --aggregate-output --continue-on-error --parallel lint:*",
|
||||||
"lint:hbs": "ember-template-lint .",
|
"lint:hbs": "ember-template-lint .",
|
||||||
"lint:js": "eslint .",
|
"lint:js": "eslint .",
|
||||||
"start": "ember serve",
|
"start": "NODE_OPTIONS=--openssl-legacy-provider ember serve",
|
||||||
"test": "ember test",
|
"test": "NODE_OPTIONS=--openssl-legacy-provider ember test",
|
||||||
"start:local": "WEB3_PROVIDER_URL=http://localhost:8545 WEB3_CHAIN_ID=1337 WEB3_NETWORK_NAME='Hardhat Devchain' ember serve",
|
"start:local": "WEB3_PROVIDER_URL=http://localhost:8545 WEB3_CHAIN_ID=1337 WEB3_NETWORK_NAME='Hardhat Devchain' NODE_OPTIONS=--openssl-legacy-provider ember serve",
|
||||||
"build": "ember build --environment=production",
|
"build": "NODE_OPTIONS=--openssl-legacy-provider ember build --environment=production",
|
||||||
"build-prod": "rm -rf release/* && ember build -prod --output-path release",
|
"build-prod": "rm -rf release/* && NODE_OPTIONS=--openssl-legacy-provider ember build -prod --output-path release",
|
||||||
"preversion": "npm test",
|
"preversion": "npm test",
|
||||||
"version": "npm run build-prod && git add release/",
|
"version": "npm run build-prod && git add release/",
|
||||||
"deploy": "git push 5apps master"
|
"deploy": "git push 5apps master"
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
"web3-utils": "^1.7.3"
|
"web3-utils": "^1.7.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14"
|
"node": ">= 22"
|
||||||
},
|
},
|
||||||
"ember": {
|
"ember": {
|
||||||
"edition": "octane"
|
"edition": "octane"
|
||||||
|
|||||||
@@ -37,4 +37,20 @@ module('Integration | Component | user-avatar', function(hooks) {
|
|||||||
`https://avatars2.githubusercontent.com/u/318?v=3&s=512`);
|
`https://avatars2.githubusercontent.com/u/318?v=3&s=512`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('renders empty src when contributor is undefined', async function(assert) {
|
||||||
|
this.set('contributor', undefined);
|
||||||
|
await render(hbs`{{user-avatar contributor=contributor}}`);
|
||||||
|
|
||||||
|
assert.dom(this.element).hasText('');
|
||||||
|
assert.equal(this.element.querySelector('img').getAttribute('src'), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders empty src when contributor has no github_uid', async function(assert) {
|
||||||
|
const noGithub = { name: 'Mystery', github_uid: null };
|
||||||
|
this.set('contributor', noGithub);
|
||||||
|
await render(hbs`{{user-avatar contributor=contributor}}`);
|
||||||
|
|
||||||
|
assert.equal(this.element.querySelector('img').getAttribute('src'), '');
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { module, test } from 'qunit';
|
|||||||
import { setupTest } from 'ember-qunit';
|
import { setupTest } from 'ember-qunit';
|
||||||
import contributors from '../../fixtures/contributors';
|
import contributors from '../../fixtures/contributors';
|
||||||
import contributions from '../../fixtures/contributions';
|
import contributions from '../../fixtures/contributions';
|
||||||
|
import ContributionModel from 'kredits-web/models/contribution';
|
||||||
|
import processContributionData from 'kredits-web/utils/process-contribution-data';
|
||||||
|
|
||||||
module('Unit | Service | kredits', function(hooks) {
|
module('Unit | Service | kredits', function(hooks) {
|
||||||
setupTest(hooks);
|
setupTest(hooks);
|
||||||
@@ -57,4 +59,33 @@ module('Unit | Service | kredits', function(hooks) {
|
|||||||
assert.ok(service.contributorsSorted instanceof Array, 'is an array');
|
assert.ok(service.contributorsSorted instanceof Array, 'is an array');
|
||||||
assert.equal(service.contributorsSorted[1].name, 'Manuel', 'sorts by name');
|
assert.equal(service.contributorsSorted[1].name, 'Manuel', 'sorts by name');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('#kreditsByContributor ignores unconfirmed contributions for contributors that are not loaded', function(assert) {
|
||||||
|
let service = this.owner.lookup('service:kredits');
|
||||||
|
service.set('contributors', contributors);
|
||||||
|
|
||||||
|
// Contribution referencing a contributor that is not in the contributors
|
||||||
|
// collection (e.g. its IPFS profile failed to deserialize). The dashboard
|
||||||
|
// must not throw when computing the toplist.
|
||||||
|
const orphanAttrs = { id: 99, contributorId: 999, confirmedAt: 2000, claimed: false, vetoed: false, amount: 7000, kind: 'dev' };
|
||||||
|
const orphan = ContributionModel.create(processContributionData(orphanAttrs));
|
||||||
|
orphan.set('contributor', undefined);
|
||||||
|
|
||||||
|
service.set('contributions', [...contributions, orphan]);
|
||||||
|
service.set('currentBlock', 1023);
|
||||||
|
|
||||||
|
let kreditsByContributor;
|
||||||
|
try {
|
||||||
|
kreditsByContributor = service.kreditsByContributor;
|
||||||
|
} catch (e) {
|
||||||
|
assert.ok(false, `did not expect kreditsByContributor to throw: ${e.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(kreditsByContributor, 'computes without throwing');
|
||||||
|
assert.notOk(kreditsByContributor.findBy('contributor', undefined),
|
||||||
|
'no entry for the missing contributor');
|
||||||
|
assert.notOk(kreditsByContributor.find(k => k.contributor && k.contributor.id === 999),
|
||||||
|
'orphan contributor id is absent from the toplist');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user