From fb84374e3296ffb6c335571d30bba2d5d4385e01 Mon Sep 17 00:00:00 2001 From: Rok Date: Tue, 28 Oct 2025 20:41:40 +0100 Subject: [PATCH 01/14] Use get_transactions instead of icrc3_get_blocks --- src/backend/env/canisters.rs | 56 ++++++++++++++++- src/backend/env/tip.rs | 119 +++++++++++------------------------ 2 files changed, 90 insertions(+), 85 deletions(-) diff --git a/src/backend/env/canisters.rs b/src/backend/env/canisters.rs index 0064d5e8..97615187 100644 --- a/src/backend/env/canisters.rs +++ b/src/backend/env/canisters.rs @@ -6,8 +6,7 @@ use super::{ }; use crate::{env::NeuronId, id, mutate, read}; use candid::{ - utils::{ArgumentDecoder, ArgumentEncoder}, - CandidType, Principal, + utils::{ArgumentDecoder, ArgumentEncoder}, CandidType, Nat, Principal }; use ic_cdk::api::{ self, @@ -25,6 +24,7 @@ use ic_ledger_types::{Tokens, MAINNET_GOVERNANCE_CANISTER_ID}; use ic_xrc_types::{Asset, GetExchangeRateRequest, GetExchangeRateResult}; use icrc_ledger_types::icrc3::blocks::{GetBlocksRequest, GetBlocksResult}; use serde::{Deserialize, Serialize}; +use serde_bytes::ByteBuf; use std::cell::RefCell; use std::collections::HashMap; @@ -38,6 +38,43 @@ thread_local! { static UPGRADE_TIMESTAMP: RefCell = Default::default(); } +#[derive(CandidType, Clone, Serialize, Deserialize, Debug)] +pub struct GetTransactionsArgs { + pub start: Nat, + pub length: Nat, +} + +#[derive(CandidType, Clone, Serialize, Deserialize, Debug)] +pub struct IcrcAccount { + pub owner: Principal, + pub subaccount: Option<[u8; 32]>, +} + +#[derive( + Serialize, Deserialize, CandidType, Clone, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Default, +)] +#[serde(transparent)] +pub struct IcrcMemo(pub ByteBuf); + +#[derive(CandidType, Clone, Serialize, Deserialize, Debug)] +pub struct IcrcTransfer { + pub amount: Nat, + pub from: IcrcAccount, + pub to: IcrcAccount, + pub memo: Option, + pub fee: Option, +} + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub struct IcrcTransactionRecord { + pub transfer: Option, +} + +#[derive(CandidType, Clone, Serialize, Deserialize, Debug)] +pub struct GetTransactionsResult { + pub transactions: Vec, +} + // Panics if an upgrade was initiated within the last 5 minutes. If something goes wrong // and the canister was not upgraded (and hence the timer was not reset), after 5 minutes // we start ignoring the timestamp. @@ -367,3 +404,18 @@ pub async fn top_up() { } } } + +pub async fn get_transactions( + canister_id: Principal, + args: GetTransactionsArgs, +) -> Result { + let (response,): (GetTransactionsResult,) = + call_canister(canister_id, "get_transactions", (args,)) + .await + .map_err(|e| { + ic_cdk::println!("Failed to call ledger: {:?}", e); + format!("Failed to call ledger: {:?}", e) + })?; + Ok(response) +} + diff --git a/src/backend/env/tip.rs b/src/backend/env/tip.rs index e09e82b5..9d201066 100644 --- a/src/backend/env/tip.rs +++ b/src/backend/env/tip.rs @@ -1,8 +1,6 @@ use candid::Nat; -use icrc_ledger_types::{ - icrc::generic_value::ICRC3Value, - icrc3::blocks::{GetBlocksRequest, ICRC3GenericBlock}, -}; + +use crate::env::canisters::GetTransactionsArgs; use super::{token::Memo, *}; @@ -110,87 +108,42 @@ async fn try_tip( caller: Principal, start_index: u64, ) -> Result { - let response = canisters::get_icrc3_get_blocks( - canister_id, - GetBlocksRequest { - start: Nat::from(start_index), - length: Nat::from(1_u64), - }, - ) - .await?; - - let Some(block_with_id) = response.blocks.first() else { - return Err(format!("transaction not found at index {}", start_index)); + let args = GetTransactionsArgs { + start: Nat::from(start_index), + length: Nat::from(1_u128), }; - - let (amount, from, to, memo) = convert_icrc3_block_to_transfer(&block_with_id.block)?; - - if from.owner != caller { - return Err("you are not the transaction initiator".into()); - } - - mutate(|state| { - create_post_tip( - state, - post_id, - canister_id, - amount, - memo, - to.owner, - from.owner, - start_index, - ) - }) -} - -pub fn convert_icrc3_block_to_transfer( - block: &ICRC3GenericBlock, -) -> Result<(u128, Account, Account, Memo), String> { - let block_map = match block { - ICRC3Value::Map(map) => map, - _ => return Err("block is not a map".into()), - }; - - let tx = match block_map.get("tx") { - Some(ICRC3Value::Map(m)) => m, - _ => return Err("tx is not a map".into()), - }; - - let memo = match block_map.get("memo").or(tx.get("memo")) { - Some(ICRC3Value::Blob(m)) => m.clone().to_vec(), - _ => return Err("memo not found".into()), - }; - - let amount = match tx.get("amt") { - Some(ICRC3Value::Nat(a)) => u128::try_from(&a.0).ok().ok_or("amount not found")?, - _ => return Err("amount not found".into()), - }; - - let from = match tx.get("from") { - Some(ICRC3Value::Array(from_array)) => match from_array.first() { - Some(ICRC3Value::Blob(blob)) => Principal::from_slice(blob), - _ => return Err("from value not found".into()), - }, - _ => return Err("from value not found".into()), - }; - - let to = tx - .get("to") - .and_then(|icrc3_value| match icrc3_value { - ICRC3Value::Array(from_array) => { - if let Some(value) = from_array.first() { - return match value { - ICRC3Value::Blob(blob) => Some(Principal::from_slice(blob)), - _ => None, - }; + let response = canisters::get_transactions(canister_id, args).await; + if let Some(transaction) = response + .expect("Failed to retrive transactions") + .transactions + .first() + { + match &transaction.transfer { + Some(transfer) => mutate(|state| { + let amount = u128::try_from(&transfer.amount.0).expect("Wrong amount"); + let memo = transfer.memo.as_ref().unwrap().0.to_vec(); + if transfer.from.owner != caller { + return Err("caller not transaction sender".into()); } - None - } - _ => None, - }) - .ok_or("to not found")?; - - Ok((amount, account(from), account(to), memo)) + create_post_tip( + state, + post_id, + canister_id, + amount, + memo, + transfer.to.owner, + transfer.from.owner, + start_index, + ) + }), + None => Err("Transaction is not a transfer!".into()), + } + } else { + Err(format!( + "We could not find transaction at index {}", + start_index + )) + } } #[cfg(test)] From 44aff78c1d959c18288f9cdceba4b34f388b8075 Mon Sep 17 00:00:00 2001 From: Rok Date: Tue, 28 Oct 2025 20:51:04 +0100 Subject: [PATCH 02/14] Lint fix --- src/backend/env/canisters.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/env/canisters.rs b/src/backend/env/canisters.rs index 97615187..799f4198 100644 --- a/src/backend/env/canisters.rs +++ b/src/backend/env/canisters.rs @@ -6,7 +6,8 @@ use super::{ }; use crate::{env::NeuronId, id, mutate, read}; use candid::{ - utils::{ArgumentDecoder, ArgumentEncoder}, CandidType, Nat, Principal + utils::{ArgumentDecoder, ArgumentEncoder}, + CandidType, Nat, Principal, }; use ic_cdk::api::{ self, @@ -418,4 +419,3 @@ pub async fn get_transactions( })?; Ok(response) } - From 20a6fd7ca8cd5e4ef4d67d6dd1352107f949158e Mon Sep 17 00:00:00 2001 From: Anonymous Date: Sun, 26 Oct 2025 23:21:37 +0100 Subject: [PATCH 03/14] repair test --- e2e/test2.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/test2.spec.ts b/e2e/test2.spec.ts index e4d4789d..5a8a78ba 100644 --- a/e2e/test2.spec.ts +++ b/e2e/test2.spec.ts @@ -124,7 +124,7 @@ test.describe("Regular users flow", () => { await page.waitForLoadState("networkidle"); await expect( page.locator("article", { - hasText: /Edit: this is a post-scriptum/, + hasText: /Hello world!.*Edit: this is a post-scriptum/, }), ).toBeVisible(); await expect( From f5e49aed87b62154a5e1a2becfd550953d093b9c Mon Sep 17 00:00:00 2001 From: Anonymous Date: Mon, 27 Oct 2025 08:17:24 +0100 Subject: [PATCH 04/14] backend API improvement --- src/backend/env/mod.rs | 32 ++++++++++++++++++++++++++------ src/backend/env/user.rs | 28 +++++++++++++++++++++++----- src/backend/updates.rs | 21 ++++++++++++++++++--- src/frontend/src/feed.tsx | 22 ++++++++++++++++++---- 4 files changed, 85 insertions(+), 18 deletions(-) diff --git a/src/backend/env/mod.rs b/src/backend/env/mod.rs index 5aa810e9..3f2e13f1 100644 --- a/src/backend/env/mod.rs +++ b/src/backend/env/mod.rs @@ -3828,6 +3828,7 @@ pub(crate) mod tests { let user = state.users.get_mut(&user_id).unwrap(); assert!( user.toggle_following_feed(vec!["post".to_owned(), "tags".to_owned()].as_slice()) + .unwrap() ); // make sure the feed still contains the same post @@ -3898,7 +3899,7 @@ pub(crate) mod tests { // now we follow a feed "post" let user = state.users.get_mut(&user_id).unwrap(); let tags: Vec<_> = vec!["post".to_string()].into_iter().collect(); - assert!(user.toggle_following_feed(&tags)); + assert!(user.toggle_following_feed(&tags).unwrap()); // make sure the feed contains the new post let feed = state .users @@ -3914,7 +3915,7 @@ pub(crate) mod tests { // Make sure we can unsubscribe and the feed gets back to 2 posts let user = state.users.get_mut(&user_id).unwrap(); - assert!(!user.toggle_following_feed(&tags)); + assert!(!user.toggle_following_feed(&tags).unwrap()); let feed = state .users .get(&user_id) @@ -4232,10 +4233,29 @@ pub(crate) mod tests { .collect(); let tags2: Vec<_> = vec!["tag1".to_owned()].into_iter().collect(); let user = state.users.get_mut(&id).unwrap(); - assert!(user.toggle_following_feed(&tags)); - assert!(user.toggle_following_feed(&tags2)); - assert!(!user.toggle_following_feed(&tags)); - assert!(!user.toggle_following_feed(&tags2)); + assert!(user.toggle_following_feed(&tags).unwrap()); + assert!(user.toggle_following_feed(&tags2).unwrap()); + assert!(!user.toggle_following_feed(&tags).unwrap()); + assert!(!user.toggle_following_feed(&tags2).unwrap()); + }) + } + + #[test] + fn test_toggle_following_feed_validation_failure() { + mutate(|state| { + state.init(); + let p = pr(0); + let id = create_user(state, p); + let user = state.users.get_mut(&id).unwrap(); + + let tag1 = "a".repeat(500); + user.toggle_following_feed(std::slice::from_ref(&tag1)) + .unwrap(); + + let tag2 = "b".repeat(500); + let result = user.toggle_following_feed(std::slice::from_ref(&tag2)); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "feed size limit exceeded"); }) } diff --git a/src/backend/env/user.rs b/src/backend/env/user.rs index ebc06afa..290a2404 100644 --- a/src/backend/env/user.rs +++ b/src/backend/env/user.rs @@ -384,21 +384,39 @@ impl User { }); } - pub fn toggle_following_feed(&mut self, tags: &[String]) -> bool { - if tags.iter().map(|tag| tag.len()).sum::() >= 50 { - return false; + pub fn toggle_following_feed(&mut self, tags: &[String]) -> Result { + const MAX_QUERY_SIZE: usize = 1000; + + let tags_size = tags.iter().map(|tag| tag.len()).sum::(); + if tags_size > MAX_QUERY_SIZE { + return Err("feed size limit exceeded".to_string()); } let tags = tags.iter().map(|tag| tag.to_lowercase()).collect(); if let Some(i) = covered_by_feeds(&self.feeds, &tags, true) { self.feeds.remove(i); - return false; + return Ok(false); } + + if self + .feeds + .iter() + .flat_map(|feed| feed.iter().map(|tag| tag.len())) + .sum::() + + tags_size + >= MAX_QUERY_SIZE + { + return Err("feed size limit exceeded".to_string()); + } + + // Remove tags from filters that are now included in the feed. if let Some(tag) = tags.first() { self.filters.tags.remove(tag); } + self.feeds.push(tags.into_iter().collect()); - true + + Ok(true) } pub fn personal_feed<'a>( diff --git a/src/backend/updates.rs b/src/backend/updates.rs index d59e2574..d1e244da 100644 --- a/src/backend/updates.rs +++ b/src/backend/updates.rs @@ -109,7 +109,22 @@ fn post_upgrade() { } #[allow(clippy::all)] -fn sync_post_upgrade_fixtures() {} +fn sync_post_upgrade_fixtures() { + mutate(|state| { + for u in state.users.values_mut() { + // Clear feeds if they exceed 1000 chars in total + if u.feeds + .iter() + .flat_map(|feed| feed.iter()) + .map(|tag| tag.len()) + .sum::() + >= 1000 + { + u.feeds.clear(); + } + } + }) +} #[allow(clippy::all)] async fn async_post_upgrade_fixtures() {} @@ -581,8 +596,8 @@ fn toggle_following_feed() { reply( state .principal_to_user_mut(caller(state)) - .map(|user| user.toggle_following_feed(&tags)) - .unwrap_or_default(), + .ok_or_else(|| "user not found".to_string()) + .and_then(|user| user.toggle_following_feed(&tags)), ) }) } diff --git a/src/frontend/src/feed.tsx b/src/frontend/src/feed.tsx index 0fa53010..f5117953 100644 --- a/src/frontend/src/feed.tsx +++ b/src/frontend/src/feed.tsx @@ -1,6 +1,12 @@ import * as React from "react"; -import { currentRealm, domain, HeadBar, setTitle } from "./common"; -import { ToggleButton } from "./common"; +import { + currentRealm, + domain, + HeadBar, + setTitle, + showPopUp, + ToggleButton, +} from "./common"; import { PostFeed } from "./post_feed"; import { PostId } from "./types"; @@ -79,8 +85,16 @@ const FeedExtender = ({ currState={() => contains(user.feeds, filter)} toggler={() => window.api - .call("toggle_following_feed", filter) - .then(window.reloadUser) + .call< + { Ok: boolean } | { Err: string } + >("toggle_following_feed", filter) + .then((result) => { + if (result && "Err" in result) { + showPopUp("error", result.Err); + return; + } + window.reloadUser(); + }) } /> {filter.length == 1 && ( From a1cd04356a275b2fd1e8af912713151a1ea53205 Mon Sep 17 00:00:00 2001 From: X Date: Mon, 27 Oct 2025 11:09:45 +0100 Subject: [PATCH 05/14] better agents.md --- AGENTS.md | 441 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 407 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7e4d73a2..60a08ff8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,47 +1,420 @@ # Taggr Agent Guide +This guide provides essential information for AI agents working with the Taggr codebase. Taggr is a fully decentralized social network running on the Internet Computer, combining features of forums and blogs with token-based governance and revenue sharing. + +## Project Overview + +**Architecture**: Internet Computer canister smart contracts (Rust backend) + React frontend (TypeScript) + +**Key Concepts**: + +- Decentralized social media platform owned by token holders +- Credit-based usage system with revenue sharing +- Token governance via proposals +- Realm-based sub-communities +- Multi-domain deployment with configurable moderation + +**Technology Stack**: + +- Backend: Rust (edition 2018, toolchain 1.90.0) compiled to WebAssembly +- Frontend: React 19 + TypeScript 5.8 built with Webpack 5 +- Testing: Cargo tests + Playwright E2E tests +- Deployment: dfx (IC SDK version 0.29.1) + ## Build Commands -- `make start` - Start local DFX replica -- `make local_deploy` - Deploy to local replica with dev features -- `make dev_build` - Build backend and bucket with dev features -- `make local_reinstall` - Reinstall local deployment -- `make build` - Production build (backend + frontend) -- `npm run build` - Build frontend only -- `make fe` - Production frontend build -- `FEATURES=dev ./build.sh taggr` - Build backend with dev features -- `make cycles` - Fabricate cycles for local canisters +### Local Development + +- `make start` - Start local DFX replica (binds to 127.0.0.1:8080) +- `npm start` - Start frontend dev server on port 9090 with hot reload +- `make dev_build` - Build backend and bucket canisters with dev features +- `make local_deploy` - Deploy to local replica with FEATURES=dev +- `make local_reinstall` - Reinstall deployment (includes `make fe`) +- `make cycles` - Fabricate cycles for local canisters (requires local-minter identity) + +### Production Builds + +- `make build` - Full production build (frontend + backend + bucket) +- `npm run build` - Build frontend only (auto-gzips .js files) +- `make fe` - Production frontend build (calls `npm run build --quiet`) +- `./build.sh taggr` - Build taggr canister without features +- `./build.sh bucket` - Build bucket canister +- `FEATURES=dev ./build.sh taggr` - Build with dev features enabled +- `FEATURES=staging ./build.sh taggr` - Build with staging features + +### E2E Testing Builds + +- `make e2e_build` - Build for E2E tests (production mode, local network) +- `NODE_ENV=production DFX_NETWORK=local npm run build` - Manual E2E frontend build + +### Release & Verification + +- `make release` - Build release in Docker container, outputs to `release-artifacts/` +- `make hashes` - Display git commit hash and wasm binary SHA256 hash +- `make backup DIR=/path/to/backup` - Create state backup ## Test Commands -- `make test` - Run all tests (builds, lints, Rust tests, E2E tests) -- `cargo test -- --test-threads 1` - Run Rust tests single-threaded -- `npm run test:e2e` - Run Playwright E2E tests -- `make e2e_test` - Full E2E test setup and execution -- `npm run test:e2e -- --ui` - Run E2E tests with UI -- `npm run test:e2e -- --trace on` - Run E2E tests with trace +### Full Test Suite + +- `make test` - Run complete test suite: + 1. `make e2e_build` - Production build for local network + 2. `make local_deploy` - Deploy to local replica + 3. `cargo clippy --tests --benches -- -D clippy::all` - Lint + 4. `cargo test -- --test-threads 1` - Rust unit tests + 5. `npm run test:e2e` - E2E tests + +### Rust Tests + +- `cargo test -- --test-threads 1` - Run Rust tests (must be single-threaded) +- `cargo test` - Run without thread restriction (may cause issues) + +### E2E Tests (Playwright) + +- `make e2e_test` - Full E2E setup and run (includes `npm run install:e2e`) +- `npm run test:e2e` - Run E2E tests (requires prior setup) +- `npm run test:e2e -- --ui` - Run with Playwright UI for debugging +- `npm run test:e2e -- --trace on` - Run with trace collection +- `npm run test:e2e -- test1.spec.ts --trace on --repeat-each 10` - Flakiness testing +- `npm run test:e2e -- --timeout 10000` - Run with custom timeout (10s) +- `npm run install:e2e` - Install Playwright Chromium with dependencies + +### E2E Test Files + +Located in `e2e/`: + +- `test0.spec.ts` through `test4.spec.ts` - Test suites +- `setup.ts` - Global E2E setup (referenced in playwright.config.ts) +- `command.ts` - Helper commands +- `playwright.config.ts` - Playwright configuration -## Lint/Format Commands +## Lint & Format Commands -- `npm run format` - Format TypeScript/JS with Prettier -- `npm run format:check` - Check formatting -- `cargo clippy --tests --benches -- -D clippy::all` - Rust linting +### TypeScript/Frontend + +- `npm run format` - Format all files with Prettier +- `npm run format:check` - Check formatting without changes +- Pre-commit hook: Uses Prettier v3.0.0 (configured in `.pre-commit-config.yaml`) + +### Rust/Backend + +- `cargo clippy --tests --benches -- -D clippy::all` - Lint with all warnings as errors - `cargo fmt` - Format Rust code +- `cargo fmt --all -- --check` - Check formatting (CI) + +### CI Checks + +See `.github/workflows/lint-and-test.yml` for complete CI pipeline ## Code Style Guidelines -- **Rust**: 4-space indentation, snake_case, explicit error handling -- **TypeScript**: Prettier defaults, camelCase, strict typing -- **Imports**: Group stdlib, external, internal imports -- **Error handling**: Use Result/Option, avoid unwrap() in production code -- **Naming**: Descriptive names, avoid abbreviations - -## Key Files - -- `src/backend/lib.rs` - Backend main entry point -- `src/backend/env/mod.rs` - Backend environment and state -- `src/frontend/src/types.tsx` - TypeScript types -- `src/frontend/src/theme.ts` - Theme definitions -- `e2e/*.spec.ts` - E2E tests -- `Makefile` - Common workflows -- `docs/LOCAL_DEVELOPMENT.md` - Development setup guide +### Rust Backend + +- **Indentation**: 4 spaces (see `.editorconfig`) +- **Edition**: 2018 +- **Naming**: snake_case for functions/variables, PascalCase for types +- **Error handling**: Use `Result` and `Option`, avoid `unwrap()` in production +- **Imports**: Group in order: stdlib, external crates, internal modules +- **Features**: Use `#[cfg(feature = "dev")]` for development-only code +- **Comments**: Avoid unless complex logic requires explanation +- **Dependencies**: Check `src/backend/Cargo.toml` and `src/bucket/Cargo.toml` + +### TypeScript/React Frontend + +- **Indentation**: 4 spaces for .ts/.tsx, 2 spaces for .yml/.yaml +- **Naming**: camelCase for functions/variables, PascalCase for components/types +- **Typing**: Strict typing, define types in `src/frontend/src/types.tsx` +- **Imports**: Group stdlib → external packages → internal modules +- **Comments**: Avoid unless necessary +- **React version**: 19.0.0 (latest features available) +- **Build**: Webpack with code splitting (vendors, react, dfinity, app-components chunks) + +### General + +- **Line endings**: LF (Unix-style) +- **Encoding**: UTF-8 +- **Final newline**: Required +- **Trailing whitespace**: Remove + +## Project Structure + +### Backend Canisters + +``` +src/backend/ Main Taggr canister +├── env/ State management modules (~4510 lines in mod.rs) +│ ├── auction.rs Token auction logic +│ ├── bitcoin.rs Bitcoin integration +│ ├── canisters.rs Multi-canister management +│ ├── config.rs Configuration constants +│ ├── delegations.rs Identity delegation +│ ├── domains.rs Domain management +│ ├── features.rs Feature flags +│ ├── invite.rs Invitation system +│ ├── invoices.rs Payment invoicing +│ ├── memory.rs Stable memory +│ ├── mod.rs Core state & environment +│ ├── nns_proposals.rs NNS governance +│ ├── pfp.rs Profile pictures +│ ├── post_iterators.rs Post iteration logic +│ ├── post.rs Post data structures +│ ├── proposals.rs DAO proposals +│ ├── realms.rs Realm management +│ ├── reports.rs User reporting +│ ├── search.rs Search functionality +│ ├── storage.rs Data storage +│ ├── tip.rs Tipping system +│ ├── token.rs Token economics +│ └── user.rs User management +├── assets.rs Asset serving +├── dev_helpers.rs Development utilities +├── http.rs HTTP interface +├── lib.rs Main entry point (~92 lines) +├── metadata.rs Canister metadata +├── queries.rs Query methods +├── taggr.did Candid interface +└── updates.rs Update methods + +src/bucket/ Storage bucket canister +├── src/ +│ ├── lib.rs Bucket main +│ └── url.rs URL handling +└── Cargo.toml Bucket dependencies +``` + +### Frontend + +``` +src/frontend/ +├── assets/ Static assets +│ ├── apple-touch-icon.png +│ ├── favicon.ico +│ ├── font-bold.woff2 +│ ├── font-regular.woff2 +│ ├── logo.min.svg +│ ├── logo.png +│ ├── manifest.json +│ ├── social-image.jpg +│ └── WHITEPAPER.md Full platform documentation +└── src/ React application (~478 lines in index.tsx) + ├── api.ts Backend API calls + ├── authentication.tsx Auth logic + ├── common.tsx Common components + ├── content.tsx Content rendering + ├── dashboard.tsx System dashboard + ├── delegation.tsx Delegation UI + ├── distribution.tsx Token distribution + ├── domains.tsx Domain management UI + ├── env.ts Environment config + ├── feed.tsx Main feed + ├── form.tsx Form components + ├── header.tsx Header component + ├── icons.tsx Icon definitions + ├── image_preview.ts Image handling + ├── inbox.tsx User inbox + ├── index.html HTML template + ├── index.tsx App entry point + ├── invites.tsx Invitations UI + ├── journal.tsx User journal + ├── landing.tsx Landing page + ├── links.tsx Link handling + ├── markdown.tsx Markdown rendering + ├── new.tsx New post UI + ├── poll.tsx Poll component + ├── post_feed.tsx Post feed logic + ├── post.tsx Post component + ├── profile.tsx User profile + ├── proposals.tsx Governance UI + ├── realms.tsx Realms UI + ├── recovery.tsx Account recovery + ├── roadmap.tsx Roadmap display + ├── search.tsx Search interface + ├── settings.tsx User settings + ├── style.css Global styles + ├── theme.ts Theme system + ├── thread.tsx Thread view + ├── token-select.tsx Token selector + ├── tokens_wallet.tsx Wallet UI + ├── tokens.tsx Token management + ├── types.tsx TypeScript types + ├── user_resolve.tsx User resolution + ├── wallet.tsx Wallet logic + ├── welcome.tsx Welcome screen + └── whitepaper.tsx Whitepaper viewer +``` + +### Configuration & Tooling + +``` +.github/workflows/ CI/CD pipelines +├── docker-image.yml Docker build workflow +├── e2e-tests.yml E2E testing workflow +└── lint-and-test.yml Main CI workflow + +Root configuration: +├── dfx.json DFX canister config (v0.29.1) +├── Cargo.toml Workspace config +├── package.json NPM scripts & dependencies +├── webpack.config.js Frontend build config +├── playwright.config.ts E2E test config +├── tsconfig.json TypeScript config +├── rust-toolchain.toml Rust version (1.90.0) +├── .editorconfig Editor settings +├── .pre-commit-config.yaml Prettier hook +├── Makefile Common tasks +├── build.sh Canister build script +├── release.sh Release script +└── backup.sh Backup script +``` + +## Key Files & Entry Points + +### Backend + +- `src/backend/lib.rs` (92 lines) - Canister entry point, defines query/update methods +- `src/backend/env/mod.rs` (4510 lines) - Core state management, largest file +- `src/backend/taggr.did` - Candid interface definition +- `src/backend/queries.rs` - Read-only query implementations +- `src/backend/updates.rs` - State-changing update implementations + +### Frontend + +- `src/frontend/src/index.tsx` (478 lines) - React app entry point +- `src/frontend/src/types.tsx` - All TypeScript type definitions +- `src/frontend/src/theme.ts` - Theme configuration +- `src/frontend/src/api.ts` - Backend communication layer +- `src/frontend/src/env.ts` - Environment variables + +### Documentation + +- `README.md` - Upgrade verification & release process +- `docs/LOCAL_DEVELOPMENT.md` - Complete local setup guide +- `src/frontend/assets/WHITEPAPER.md` - Full platform documentation +- `Makefile` - All available commands with targets + +### Configuration + +- `dfx.json` - Canister definitions and network config +- `Cargo.toml` - Workspace with release optimizations (LTO, opt-level=2) +- `package.json` - NPM scripts and dependencies +- `webpack.config.js` - Frontend build with chunking strategy + +## Development Workflow + +### Initial Setup + +1. Install dependencies: NodeJS, Rust, ic-wasm, Docker, Git +2. Install DFX: `DFX_VERSION=$(cat dfx.json | jq -r .dfx) sh -ci "$(curl -fsSL https://internetcomputer.org/install.sh)"` +3. Install NPM packages: `npm ci` +4. Configure local network in `~/.config/dfx/networks.json` (port 8080) + +### Local Development + +1. Start DFX: `make start` or `dfx start --clean --background` +2. Deploy: `make local_deploy && make dev_build && make local_reinstall` +3. Add cycles: `make cycles` +4. Optional: Install NNS canisters with `dfx extension install nns && dfx nns install` +5. Frontend dev server: `npm start` (port 9090, proxies /api to DFX) + +### Testing Workflow + +1. Build for tests: `make e2e_build` +2. Deploy: `make local_deploy` +3. Run Rust tests: `cargo test -- --test-threads 1` +4. Run E2E tests: `npm run test:e2e -- --ui` +5. Format check: `npm run format:check && cargo fmt --all -- --check` + +### Release Process + +1. Commit changes and create git tag +2. Build: `make release` (uses Docker/Podman) +3. Verify: Compare output hash with proposal +4. Artifacts in `release-artifacts/taggr.wasm.gz` + +## Important Notes + +### Feature Flags + +- `dev` - Development features (used in local builds) +- `staging` - Staging environment features +- Applied via: `FEATURES=dev ./build.sh taggr` + +### Network Configuration + +- `local` - Development (127.0.0.1:8080) +- `staging` - Staging network (icp-api.io) +- `staging2` - Alternative staging +- `ic` - Production mainnet + +### Build Artifacts + +- Wasm files: `target/wasm32-unknown-unknown/release/*.wasm.gz` +- Frontend: `dist/frontend/` (contains gzipped .js files) +- Release: `release-artifacts/taggr.wasm.gz` + +### Testing Constraints + +- Rust tests MUST run with `--test-threads 1` +- E2E tests run with 1 worker (configured in playwright.config.ts) +- Ubuntu for E2E must be LTS version +- Production build required for E2E to avoid binary size limits + +### Dependencies to Check Before Adding + +Always verify existing dependencies before introducing new ones: + +- Backend: `src/backend/Cargo.toml`, `src/bucket/Cargo.toml` +- Frontend: `package.json` +- Check neighbor files for patterns and existing library usage + +## Common Issues & Solutions + +### Build Issues + +- **macOS llvm-ar error**: Uses `brew --prefix llvm` for AR/CC in `build.sh` +- **Binary size limits**: Use production builds (`NODE_ENV=production`) +- **Gzip failures**: Check target/wasm32-unknown-unknown/release/ for .wasm files + +### Test Issues + +- **Rust test failures**: Always use `--test-threads 1` +- **E2E timeouts**: Adjust with `--timeout` flag or in playwright.config.ts +- **Selector issues**: Use `--ui` mode to debug test steps + +### Deployment Issues + +- **Port conflicts**: DFX requires 8080, webpack dev uses 9090 +- **Cycle issues**: Run `make cycles` with local-minter identity +- **NNS not available**: Install with `dfx extension install nns && dfx nns install` + +## Token Economics & Governance + +### Key Constants (from whitepaper) + +- Maximum supply: See `$maximum_supply` in config +- Credits per XDR: See `$credits_per_xdr` in config +- Post cost: `$post_cost` per KB +- Proposal escrow: `$proposal_escrow_amount_usd` USD in tokens +- Approval threshold: `$proposal_approval_threshold%` for proposals +- Stalwart percentage: Top `$stalwart_percentage%` of token holders + +### User Actions & Costs + +All costs in credits: + +- Post/comment: `$post_cost` per KB +- Reactions: 2-11 credits depending on emoji +- Hashtags: `T * followers(T)` per tag T +- Polls: `$poll_cost` +- Realm creation: `$realm_cost` + +### Bot Integration + +Bots can call `add_post` method via Candid: + +``` +"add_post": (text, vec record { text; blob }, opt nat64, opt text) -> (variant { Ok: nat64; Err: text }); +``` + +Parameters: body text, images (ID + blob), parent post ID, realm name +Limit: Images must be < `$max_blob_size_bytes`, message < 2MB From 88c7fd77143ee28793cdf2e0cc4660a0919977e3 Mon Sep 17 00:00:00 2001 From: X Date: Mon, 27 Oct 2025 18:45:20 +0100 Subject: [PATCH 06/14] styling --- src/frontend/src/style.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/frontend/src/style.css b/src/frontend/src/style.css index 12b15e56..64827a3f 100644 --- a/src/frontend/src/style.css +++ b/src/frontend/src/style.css @@ -1070,6 +1070,10 @@ label svg { justify-self: left; } + .banner { + border-radius: 0; + } + .prime img { border-radius: 0; } From a81a31ae1954d884d1c30ca83ab4538dae5fc6d4 Mon Sep 17 00:00:00 2001 From: X Date: Sun, 2 Nov 2025 12:09:59 +0100 Subject: [PATCH 07/14] simplify --- src/backend/env/canisters.rs | 65 ++++-------------------------------- src/backend/env/tip.rs | 57 ++++++++++++++----------------- 2 files changed, 31 insertions(+), 91 deletions(-) diff --git a/src/backend/env/canisters.rs b/src/backend/env/canisters.rs index 799f4198..bb55c1c2 100644 --- a/src/backend/env/canisters.rs +++ b/src/backend/env/canisters.rs @@ -7,7 +7,7 @@ use super::{ use crate::{env::NeuronId, id, mutate, read}; use candid::{ utils::{ArgumentDecoder, ArgumentEncoder}, - CandidType, Nat, Principal, + CandidType, Principal, }; use ic_cdk::api::{ self, @@ -23,9 +23,8 @@ use ic_cdk::api::{ use ic_cdk::{api::call::call_raw, notify}; use ic_ledger_types::{Tokens, MAINNET_GOVERNANCE_CANISTER_ID}; use ic_xrc_types::{Asset, GetExchangeRateRequest, GetExchangeRateResult}; -use icrc_ledger_types::icrc3::blocks::{GetBlocksRequest, GetBlocksResult}; +use icrc_ledger_types::icrc3::transactions::{GetTransactionsRequest, GetTransactionsResponse}; use serde::{Deserialize, Serialize}; -use serde_bytes::ByteBuf; use std::cell::RefCell; use std::collections::HashMap; @@ -39,43 +38,6 @@ thread_local! { static UPGRADE_TIMESTAMP: RefCell = Default::default(); } -#[derive(CandidType, Clone, Serialize, Deserialize, Debug)] -pub struct GetTransactionsArgs { - pub start: Nat, - pub length: Nat, -} - -#[derive(CandidType, Clone, Serialize, Deserialize, Debug)] -pub struct IcrcAccount { - pub owner: Principal, - pub subaccount: Option<[u8; 32]>, -} - -#[derive( - Serialize, Deserialize, CandidType, Clone, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Default, -)] -#[serde(transparent)] -pub struct IcrcMemo(pub ByteBuf); - -#[derive(CandidType, Clone, Serialize, Deserialize, Debug)] -pub struct IcrcTransfer { - pub amount: Nat, - pub from: IcrcAccount, - pub to: IcrcAccount, - pub memo: Option, - pub fee: Option, -} - -#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] -pub struct IcrcTransactionRecord { - pub transfer: Option, -} - -#[derive(CandidType, Clone, Serialize, Deserialize, Debug)] -pub struct GetTransactionsResult { - pub transactions: Vec, -} - // Panics if an upgrade was initiated within the last 5 minutes. If something goes wrong // and the canister was not upgraded (and hence the timer was not reset), after 5 minutes // we start ignoring the timestamp. @@ -286,18 +248,6 @@ pub async fn coins_for_one_xdr(coin: &str) -> Result { .map(|result| result.rate / 10) } -pub async fn get_icrc3_get_blocks( - canister_id: Principal, - args: GetBlocksRequest, -) -> Result { - let vec_args = vec![args]; - let (response,): (GetBlocksResult,) = - call_canister(canister_id, "icrc3_get_blocks", (vec_args,)) - .await - .map_err(|e| format!("Failed to call icrc3_get_blocks: {:?}", e))?; - Ok(response) -} - pub async fn call_canister_raw(id: Principal, method: &str, args: &[u8]) -> CallResult> { open_call(method); let result = call_raw(id, method, args, 0).await; @@ -408,14 +358,11 @@ pub async fn top_up() { pub async fn get_transactions( canister_id: Principal, - args: GetTransactionsArgs, -) -> Result { - let (response,): (GetTransactionsResult,) = + args: GetTransactionsRequest, +) -> Result { + let (response,): (GetTransactionsResponse,) = call_canister(canister_id, "get_transactions", (args,)) .await - .map_err(|e| { - ic_cdk::println!("Failed to call ledger: {:?}", e); - format!("Failed to call ledger: {:?}", e) - })?; + .map_err(|e| format!("failed to call ledger: {:?}", e))?; Ok(response) } diff --git a/src/backend/env/tip.rs b/src/backend/env/tip.rs index 9d201066..ce66ba35 100644 --- a/src/backend/env/tip.rs +++ b/src/backend/env/tip.rs @@ -1,6 +1,5 @@ use candid::Nat; - -use crate::env::canisters::GetTransactionsArgs; +use icrc_ledger_types::icrc3::transactions::GetTransactionsRequest; use super::{token::Memo, *}; @@ -108,42 +107,36 @@ async fn try_tip( caller: Principal, start_index: u64, ) -> Result { - let args = GetTransactionsArgs { + let args = GetTransactionsRequest { start: Nat::from(start_index), length: Nat::from(1_u128), }; - let response = canisters::get_transactions(canister_id, args).await; - if let Some(transaction) = response - .expect("Failed to retrive transactions") + let response = canisters::get_transactions(canister_id, args).await?; + let Some(transfer) = response .transactions .first() - { - match &transaction.transfer { - Some(transfer) => mutate(|state| { - let amount = u128::try_from(&transfer.amount.0).expect("Wrong amount"); - let memo = transfer.memo.as_ref().unwrap().0.to_vec(); - if transfer.from.owner != caller { - return Err("caller not transaction sender".into()); - } - create_post_tip( - state, - post_id, - canister_id, - amount, - memo, - transfer.to.owner, - transfer.from.owner, - start_index, - ) - }), - None => Err("Transaction is not a transfer!".into()), + .and_then(|tx| tx.transfer.as_ref()) + else { + return Err(format!("no transfer transaction at index {}", start_index)); + }; + + mutate(|state| { + let amount = u128::try_from(&transfer.amount.0).expect("Wrong amount"); + let memo = transfer.memo.as_ref().unwrap().0.to_vec(); + if transfer.from.owner != caller { + return Err("caller is not transaction sender".into()); } - } else { - Err(format!( - "We could not find transaction at index {}", - start_index - )) - } + create_post_tip( + state, + post_id, + canister_id, + amount, + memo, + transfer.to.owner, + transfer.from.owner, + start_index, + ) + }) } #[cfg(test)] From d9e8e19603dfda54bd2050afcd4eda5b0316dbca Mon Sep 17 00:00:00 2001 From: X Date: Mon, 27 Oct 2025 19:19:08 +0100 Subject: [PATCH 08/14] wip: fix e2e tests --- e2e/command.ts | 2 +- e2e/helpers.ts | 298 ++++++++++++++++++++++++++++++++++ e2e/setup.ts | 5 +- e2e/test1.spec.ts | 303 +++++++++++++++++++++-------------- e2e/test2.spec.ts | 129 ++++++++------- e2e/test3.spec.ts | 76 ++++----- e2e/test4.spec.ts | 225 +++++++++++++++++--------- playwright.config.ts | 19 ++- src/backend/dev_helpers.rs | 7 +- src/backend/env/mod.rs | 7 +- src/frontend/src/invites.tsx | 29 ++-- src/frontend/src/post.tsx | 1 - src/frontend/src/types.tsx | 6 +- 13 files changed, 783 insertions(+), 324 deletions(-) create mode 100644 e2e/helpers.ts diff --git a/e2e/command.ts b/e2e/command.ts index 1807c352..2c3f0871 100644 --- a/e2e/command.ts +++ b/e2e/command.ts @@ -1,6 +1,6 @@ import { execSync } from "node:child_process"; -export const transferICP = (acc, amount) => { +export const transferICP = (acc: string, amount: number | string) => { const cmd = `dfx --identity local-minter ledger transfer --amount ${amount} --memo 0 ${acc}`; exec(cmd); }; diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 00000000..cbd03c86 --- /dev/null +++ b/e2e/helpers.ts @@ -0,0 +1,298 @@ +import { Page, Dialog } from "@playwright/test"; + +export type DialogHandler = (dialog: Dialog) => Promise; + +export async function handleDialog( + page: Page, + expectedMessagePattern: string | RegExp, + response: string | ((message: string) => string), + triggerAction: () => Promise, +): Promise { + const dialogPromise = new Promise((resolve, reject) => { + const handler = async (dialog: Dialog) => { + try { + const message = dialog.message(); + const matches = + typeof expectedMessagePattern === "string" + ? message.includes(expectedMessagePattern) + : expectedMessagePattern.test(message); + + if (matches) { + const responseText = + typeof response === "function" + ? response(message) + : response; + await dialog.accept(responseText); + resolve(); + } else { + await dialog.dismiss(); + reject( + new Error( + `Unexpected dialog message: "${message}". Expected pattern: ${expectedMessagePattern}`, + ), + ); + } + } catch (error) { + reject(error); + } + }; + + page.once("dialog", handler); + }); + + await triggerAction(); + await dialogPromise; +} + +export async function handleDialogSequence( + page: Page, + dialogs: Array<{ + expectedPattern: string | RegExp; + response: string | ((message: string) => string); + }>, + triggerAction: () => Promise, +): Promise { + let dialogIndex = 0; + const resolvers: Array<() => void> = []; + const rejectors: Array<(error: Error) => void> = []; + + const dialogPromises = dialogs.map( + () => + new Promise((resolve, reject) => { + resolvers.push(resolve); + rejectors.push(reject); + }), + ); + + const handler = async (dialog: Dialog) => { + try { + if (dialogIndex >= dialogs.length) { + await dialog.dismiss(); + rejectors[0]?.( + new Error(`Unexpected extra dialog: ${dialog.message()}`), + ); + return; + } + + const { expectedPattern, response } = dialogs[dialogIndex]; + const message = dialog.message(); + const matches = + typeof expectedPattern === "string" + ? message.includes(expectedPattern) + : expectedPattern.test(message); + + if (matches) { + const responseText = + typeof response === "function" + ? response(message) + : response; + await dialog.accept(responseText); + resolvers[dialogIndex]?.(); + dialogIndex++; + } else { + await dialog.dismiss(); + rejectors[dialogIndex]?.( + new Error( + `Dialog ${dialogIndex}: Unexpected message "${message}". Expected pattern: ${expectedPattern}`, + ), + ); + } + } catch (error) { + rejectors[dialogIndex]?.( + error instanceof Error ? error : new Error(String(error)), + ); + } + }; + + page.on("dialog", handler); + + try { + await triggerAction(); + await Promise.all(dialogPromises); + } finally { + page.removeListener("dialog", handler); + } +} + +export async function retryOperation( + operation: () => Promise, + options: { + maxAttempts?: number; + initialDelay?: number; + maxDelay?: number; + backoffMultiplier?: number; + shouldRetry?: (error: Error, attempt: number) => boolean; + } = {}, +): Promise { + const { + maxAttempts = 3, + initialDelay = 1000, + maxDelay = 10000, + backoffMultiplier = 2, + shouldRetry = () => true, + } = options; + + let lastError: Error; + let delay = initialDelay; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await operation(); + } catch (error) { + lastError = + error instanceof Error ? error : new Error(String(error)); + + if (attempt === maxAttempts || !shouldRetry(lastError, attempt)) { + throw lastError; + } + + await new Promise((resolve) => setTimeout(resolve, delay)); + delay = Math.min(delay * backoffMultiplier, maxDelay); + } + } + + throw lastError!; +} + +export async function waitForCondition( + checkCondition: () => Promise, + options: { + timeout?: number; + interval?: number; + errorMessage?: string; + } = {}, +): Promise { + const { + timeout = 30000, + interval = 500, + errorMessage = "Condition not met within timeout", + } = options; + + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + if (await checkCondition()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } + + throw new Error(errorMessage); +} + +export async function waitForBackendOperation( + page: Page, + options: { + timeout?: number; + } = {}, +): Promise { + const { timeout = 5000 } = options; + + await page.waitForLoadState("networkidle", { timeout }); + await page.waitForTimeout(100); +} + +export async function createAuctionBid( + page: Page, + icpPerToken: string, + tokenAmount: string, + transferICPFn: (address: string, amount: number) => void, + icpAddress: string, +): Promise { + await page.goto("/#/tokens"); + await page.waitForLoadState("networkidle"); + await page.getByPlaceholder("ICP per 1 TAGGR").fill(icpPerToken); + await page.getByPlaceholder("Number of TAGGR tokens").fill(tokenAmount); + transferICPFn( + icpAddress, + parseFloat(icpPerToken) * parseFloat(tokenAmount), + ); + await page + .getByRole("button", { name: `BID FOR ${tokenAmount} TAGGR` }) + .click(); + await page.waitForLoadState("networkidle"); + await page.getByText("Current Bids").waitFor({ state: "visible" }); +} + +export async function safeClick( + page: Page, + selector: string, + options: { + timeout?: number; + waitForNavigation?: boolean; + } = {}, +): Promise { + const { timeout = 10000, waitForNavigation = false } = options; + + const element = page.locator(selector); + await element.waitFor({ state: "visible", timeout }); + await element.waitFor({ state: "attached", timeout }); + + if (waitForNavigation) { + await Promise.all([ + page.waitForLoadState("networkidle"), + element.click(), + ]); + } else { + await element.click(); + } +} + +export async function fillAndSubmit( + page: Page, + selector: string, + value: string, + submitSelector: string, +): Promise { + await page.locator(selector).waitFor({ state: "visible" }); + await page.locator(selector).fill(value); + await page.locator(submitSelector).click(); +} + +export async function waitForTextContent( + page: Page, + selector: string, + expectedText: string | RegExp, + options: { + timeout?: number; + } = {}, +): Promise { + const { timeout = 10000 } = options; + + await page.waitForFunction( + ({ sel, expected }) => { + const element = document.querySelector(sel); + if (!element) return false; + const text = element.textContent || ""; + return typeof expected === "string" + ? text.includes(expected) + : new RegExp(expected).test(text); + }, + { sel: selector, expected: expectedText.toString() }, + { timeout }, + ); +} + +export async function pollForCondition( + checkCondition: () => Promise, + options: { + maxAttempts?: number; + interval?: number; + errorMessage?: string; + } = {}, +): Promise { + const { + maxAttempts = 20, + interval = 2000, + errorMessage = "Condition not met after polling", + } = options; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (await checkCondition()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } + + throw new Error(errorMessage); +} diff --git a/e2e/setup.ts b/e2e/setup.ts index 02ac8b4d..3d8eefa1 100644 --- a/e2e/setup.ts +++ b/e2e/setup.ts @@ -5,10 +5,13 @@ export const canisterId = "uxrrr-q7777-77774-qaaaq-cai"; export default async function setup(): Promise { console.debug("Global setup routine"); - exec("dfx canister update-settings taggr --add-controller " + canisterId); exec(`dfx canister call taggr reset '("${canisterId}")'`); + exec("dfx canister update-settings taggr --add-controller " + canisterId); const webServerPort = exec("dfx info webserver-port"); const baseURL = `http://${canisterId}.localhost:${webServerPort}`; process.env["BASE_URL"] = baseURL; + + // add a timeout to allow canister to reset + await new Promise((resolve) => setTimeout(resolve, 4000)); } diff --git a/e2e/test1.spec.ts b/e2e/test1.spec.ts index 97bdcd82..cc733e5c 100644 --- a/e2e/test1.spec.ts +++ b/e2e/test1.spec.ts @@ -4,41 +4,39 @@ import { exec, mkPwd, transferICP } from "./command"; import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { canisterId } from "./setup"; +import { + handleDialog, + handleDialogSequence, + waitForBackendOperation, + pollForCondition, + createAuctionBid, +} from "./helpers"; test.describe.configure({ mode: "serial" }); const executeTransfer = async (page: Page, btn: Locator, amount = "5") => { - // Q1 - enter the principal receiver - await new Promise(async (resolve) => { - page.once("dialog", async (dialog) => { - if (dialog.message().includes("Enter the recipient principal")) { - await dialog.accept("6qfxa-ryaaa-aaaai-qbhsq-cai"); - resolve(null); - } - }); - // Click button after listener is setup - await btn.click(); - }); - - // Q2 - enter the amount - await new Promise((resolve) => { - page.once("dialog", async (dialog) => { - if (dialog.message().includes("Enter the amount")) { - await dialog.accept(amount); - resolve(null); - } - }); - }); - - // Q3 - confirm receiver and amount - await new Promise((resolve) => { - page.once("dialog", async (dialog) => { - await dialog.accept(); - await page.waitForLoadState("networkidle"); - await page.waitForTimeout(1000); - resolve(null); - }); - }); + await handleDialogSequence( + page, + [ + { + expectedPattern: "Enter the recipient principal", + response: "6qfxa-ryaaa-aaaai-qbhsq-cai", + }, + { + expectedPattern: "Enter the amount", + response: amount, + }, + { + expectedPattern: /./, + response: "", + }, + ], + async () => { + await btn.click(); + }, + ); + + await waitForBackendOperation(page); }; test.describe("Upgrades & token transfer flow", () => { @@ -72,9 +70,11 @@ test.describe("Upgrades & token transfer flow", () => { await page .getByRole("button", { name: "MINT CREDITS WITH ICP" }) .click(); + await waitForBackendOperation(page); await page.getByRole("button", { name: "CREATE USER" }).click(); await page.getByPlaceholder("alphanumeric").fill("eve"); await page.getByRole("button", { name: "SAVE" }).click(); + await waitForBackendOperation(page); exec("dfx canister call taggr make_stalwart '(\"eve\")'"); }); @@ -106,37 +106,55 @@ test.describe("Upgrades & token transfer flow", () => { await page.getByRole("button", { name: "CONTINUE" }).click(); await page.getByPlaceholder("alphanumeric").fill("pete"); await page.getByRole("button", { name: "SAVE" }).click(); + await waitForBackendOperation(page); - await page - .locator(".feed_item", { hasText: "Message from Eve" }) - .getByTestId("reaction-picker") - .click(); - // React with a star - await page.locator('button[title="Reward points: 10"]').first().click(); + const feedItem = page.locator(".feed_item", { + hasText: "Message from Eve", + }); + await feedItem.waitFor({ state: "visible" }); + await feedItem.getByTestId("reaction-picker").click(); + const rewardButton = page + .locator('button[title="Reward points: 10"]') + .first(); + await rewardButton.waitFor({ state: "visible" }); + await rewardButton.click(); + await waitForBackendOperation(page); }); test("Create an auction bid, trigger minting", async ({}) => { - await page.goto("/#/tokens"); - await page.waitForLoadState("networkidle"); - await page.getByPlaceholder("ICP per 1 TAGGR").fill("0.01"); - await page.getByPlaceholder("Number of TAGGR tokens").fill("15"); - transferICP( + await createAuctionBid( + page, + "0.01", + "15", + transferICP, "aa2ff83cb95478c005b5d108b050bdbf148e3b404f1a0d82173dd779ad70c355", - 0.15, ); - await page.getByRole("button", { name: "BID FOR 15 TAGGR" }).click(); - await page.waitForTimeout(1000); exec("dfx canister call taggr weekly_chores"); - await page.waitForTimeout(1500); }); test("Wallet", async () => { - // Test the wallet functionality + await pollForCondition( + async () => { + await page.goto("/"); + await page.waitForLoadState("networkidle"); + await page.getByTestId("toggle-user-section").click(); + const balance = await page + .getByTestId("token-balance") + .textContent(); + return balance === "15"; + }, + { + maxAttempts: 15, + interval: 1000, + errorMessage: + "Token balance did not update to 15 after minting", + }, + ); + await page.goto("/"); await page.waitForLoadState("networkidle"); await page.getByTestId("toggle-user-section").click(); - await expect(page.getByTestId("token-balance")).toHaveText("15"); await executeTransfer(page, page.getByTestId("tokens-transfer-button")); @@ -161,13 +179,11 @@ test.describe("Upgrades & token transfer flow", () => { .fill(mkPwd("eve")); await page.getByRole("button", { name: "CONTINUE" }).click(); - // Make sure the recovery page is visible await expect( page.getByRole("heading", { name: "RECOVERY" }), ).toBeVisible(); await expect(page.getByText("Binary set: false")).toBeVisible(); - // Upload the binary const binaryPath = resolve( __dirname, "..", @@ -197,60 +213,75 @@ test.describe("Upgrades & token transfer flow", () => { expect(dialog2.message().includes("Done")).toBe(true); await dialog2.accept(); - let retries = 10; - while (retries > 0) { - await page.waitForTimeout(2000); - await page.reload(); - await page.waitForLoadState("networkidle"); - const statusText = await page - .getByTestId("status") - .textContent({ timeout: 5000 }) - .catch(() => null); - if (statusText?.includes("Binary set: true")) { - break; - } - retries--; - } + await pollForCondition( + async () => { + await page.reload(); + await page.waitForLoadState("networkidle"); + const statusText = await page + .getByTestId("status") + .textContent({ timeout: 5000 }) + .catch(() => null); + return statusText?.includes("Binary set: true") || false; + }, + { + maxAttempts: 15, + interval: 2000, + errorMessage: "Binary was not set after polling", + }, + ); await expect( page.getByTestId("status").filter({ hasText: "Binary set: true" }), - ).toBeVisible({ timeout: 15000 }); + ).toBeVisible(); - // Vote for the release const buildHash = await hashFile(binaryPath); await page.getByTestId("hash-input").fill(buildHash); await page.getByRole("button", { name: "SUBMIT HASH" }).click(); + await waitForBackendOperation(page); await expect(page.getByText("votes: 100%")).toBeVisible(); await expect( page.getByRole("heading", { name: "Supporters" }), ).toBeVisible(); exec("dfx canister call taggr chores"); + await waitForBackendOperation(page, { timeout: 10000 }); + await page.waitForTimeout(4000); }); test("Verify recovery upgrade", async () => { - await page.waitForTimeout(6000); await page.goto("/#/dashboard"); await page.waitForLoadState("networkidle"); await page.getByRole("button", { name: "TECHNICAL" }).click(); - await expect( - page.getByText("Executing the canister upgrade"), - ).toBeVisible(); - await expect(page.getByText("Upgrade succeeded")).toBeVisible(); + await waitForBackendOperation(page); + + await pollForCondition( + async () => { + const executingCount = await page + .getByText("Executing the canister upgrade") + .count(); + const successCount = await page + .getByText("Upgrade succeeded") + .count(); + return executingCount === 1 && successCount === 1; + }, + { + maxAttempts: 20, + interval: 1000, + errorMessage: "Recovery upgrade did not succeed within timeout", + }, + ); }); test("Regular proposal", async () => { await page.goto("/#/proposals"); await page.waitForLoadState("networkidle"); - // Create a regular proposal await expect( page.getByRole("heading", { name: "PROPOSALS" }), ).toBeVisible(); await page.getByTestId("proposals-burger-button").click(); await page.locator("textarea").fill("A regular upgrade"); - // Upload the binary const binaryPath = resolve( __dirname, "..", @@ -267,37 +298,73 @@ test.describe("Upgrades & token transfer flow", () => { const buildHash = await hashFile(binaryPath); await fileChooser.setFiles([binaryPath]); - // Wait for async proposal validation - await page.waitForTimeout(2000); + + await pollForCondition( + async () => { + const commitField = await page + .locator("div") + .filter({ hasText: /^GIT COMMIT$/ }) + .getByRole("textbox") + .isVisible() + .catch(() => false); + return commitField; + }, + { + maxAttempts: 10, + interval: 500, + errorMessage: "Proposal validation did not complete", + }, + ); + await page .locator("div") .filter({ hasText: /^GIT COMMIT$/ }) .getByRole("textbox") .fill("coffeecoffeecoffee"); + + await page.waitForTimeout(1000); + await page.getByRole("button", { name: "SUBMIT" }).click(); + await waitForBackendOperation(page); await expect(page.getByText(/STATUS.*OPEN/)).toBeVisible(); await expect(page.getByText("TYPE: RELEASE")).toBeVisible(); - page.on("dialog", async (dialog) => { - if (dialog.message().includes("Please enter the build hash")) { - await dialog.accept(buildHash); - } - }); + await handleDialog( + page, + "Please enter the build hash", + buildHash, + async () => { + await page.getByRole("button", { name: "ACCEPT" }).click(); + }, + ); + await waitForBackendOperation(page); - await page.getByRole("button", { name: "ACCEPT" }).click(); await expect(page.getByText(/STATUS.*EXECUTED/)).toBeVisible(); exec("dfx canister call taggr chores"); + await waitForBackendOperation(page, { timeout: 5000 }); await page.locator("#logo").click(); + await page.waitForLoadState("networkidle"); }); test("Verify regular upgrade", async () => { - await page.waitForTimeout(10000); - await page.goto("/#/dashboard"); - await page.waitForURL(/dashboard/); - await page.waitForLoadState("networkidle"); - await page.waitForTimeout(2000); - await page.getByRole("button", { name: "TECHNICAL" }).click(); + await pollForCondition( + async () => { + await page.goto("/#/dashboard"); + await page.waitForURL(/dashboard/); + await page.waitForLoadState("networkidle"); + await page.getByRole("button", { name: "TECHNICAL" }).click(); + const count = await page + .locator("p", { hasText: /Upgrade succeeded/ }) + .count(); + return count === 2; + }, + { + maxAttempts: 15, + interval: 1000, + errorMessage: "Did not find 2 upgrade succeeded messages", + }, + ); expect( await page.locator("p", { hasText: /Upgrade succeeded/ }).count(), @@ -306,51 +373,47 @@ test.describe("Upgrades & token transfer flow", () => { test.describe("IC TOKENS", () => { test("Add - input", async () => { - // Enable in settings await page.goto("/#/settings"); + await page.waitForLoadState("networkidle"); const icrcWalletEnableSelect = page.getByTestId("ic-wallet-select"); await expect(icrcWalletEnableSelect).toBeVisible(); await icrcWalletEnableSelect.selectOption("YES"); - await icrcWalletEnableSelect.selectOption("YES"); // Select twice due to a bug in UI + await icrcWalletEnableSelect.selectOption("YES"); await page.getByRole("button", { name: "SAVE" }).click(); - await page.waitForTimeout(1000); + await waitForBackendOperation(page); await page.reload(); await page.waitForLoadState("networkidle"); - // Test the wallet functionality await page.goto("/"); await page.waitForLoadState("networkidle"); await page.getByTestId("toggle-user-section").click(); - await expect(page.getByTestId("token-balance")).toHaveText("9.9"); // Starting balance + await expect(page.getByTestId("token-balance")).toHaveText("9.9"); const tokenListLocator = page.locator( 'div[data-testid="ic-tokens-div"]', - ); // IC tokens list locator - expect(tokenListLocator).toBeVisible(); - - const addTokenDialog = new Promise((resolve, _reject) => { - page.once("dialog", async (dialog) => { - if (dialog.message().includes("ICRC canister id")) { - await dialog.accept(canisterId); // Add Taggr token - resolve(null); - } - }); - }); + ); + await expect(tokenListLocator).toBeVisible(); const addTokenBtn = tokenListLocator.locator( 'button[title="Add token"]', ); await expect(addTokenBtn).toBeEnabled(); - await addTokenBtn.click(); - // Add token and see it in the list - await addTokenDialog; + await handleDialog( + page, + "ICRC canister id", + canisterId, + async () => { + await addTokenBtn.click(); + }, + ); + await waitForBackendOperation(page); await expect(page.getByTestId(`${canisterId}-balance`)).toHaveText( "9.90", - ); // Token added to the list + ); }); test("Send", async () => { @@ -371,44 +434,36 @@ test.describe("Upgrades & token transfer flow", () => { ); const tokenSendBtn = page.getByTestId(`${canisterId}-send`); - await executeTransfer(page, tokenSendBtn, "4.70"); // Send all remaining balance + await executeTransfer(page, tokenSendBtn, "4.70"); await expect(page.getByTestId(`${canisterId}-balance`)).toHaveText( "0.00", - ); // 0 balance + ); - // Hide zeros await expect(tokenHideZeros).toBeVisible(); await tokenHideZeros.click(); + await waitForBackendOperation(page, { timeout: 2000 }); - // Token removed from the list await expect( page.getByTestId(`${canisterId}-send`), ).not.toBeVisible(); - await tokenHideZeros.click(); // Add zeros back to the list + await tokenHideZeros.click(); + await waitForBackendOperation(page, { timeout: 2000 }); await expect(page.getByTestId(`${canisterId}-send`)).toBeVisible(); }); test("Remove", async () => { const tokenRemoveBtn = page.getByTestId(`${canisterId}-remove`); - await expect(tokenRemoveBtn).toBeVisible(); - const removeTokenDialog = new Promise((resolve, _reject) => { - page.once("dialog", async (dialog) => { - if (dialog.message().includes("Remove TAGGR")) { - await dialog.accept(); // Confirm remove token - await page.waitForTimeout(1000); - resolve(null); - } - }); + await handleDialog(page, "Remove TAGGR", "", async () => { + await tokenRemoveBtn.click(); }); + await waitForBackendOperation(page); - await tokenRemoveBtn.click(); // Remove token - await removeTokenDialog; await expect( page.getByTestId(`${canisterId}-remove`), - ).not.toBeVisible(); // Row removed + ).not.toBeVisible(); }); }); }); diff --git a/e2e/test2.spec.ts b/e2e/test2.spec.ts index 5a8a78ba..7c760974 100644 --- a/e2e/test2.spec.ts +++ b/e2e/test2.spec.ts @@ -1,6 +1,11 @@ import { test, expect, Page } from "@playwright/test"; import { resolve } from "node:path"; import { mkPwd, transferICP } from "./command"; +import { + handleDialog, + handleDialogSequence, + waitForBackendOperation, +} from "./helpers"; test.describe.configure({ mode: "serial" }); @@ -42,6 +47,7 @@ test.describe("Regular users flow", () => { .getByPlaceholder("tell us what we should know about you") .fill("I am a #Taggr fan"); await page.getByRole("button", { name: "SAVE" }).click(); + await waitForBackendOperation(page); await expect(page).toHaveTitle("TAGGR"); await page.goto("/#/inbox"); @@ -53,13 +59,12 @@ test.describe("Regular users flow", () => { page.getByText("Use #Taggr as your personal blog"), ).toBeVisible(); - // Logout await page.getByTestId("toggle-user-section").click(); await page.getByRole("link", { name: /.*SIGN OUT.*/ }).click(); + await page.waitForLoadState("networkidle"); }); test("Login and post", async () => { - // Login flow await page.getByRole("button", { name: "SIGN IN" }).click(); await page.getByRole("button", { name: "SEED PHRASE" }).click(); await page @@ -71,8 +76,8 @@ test.describe("Regular users flow", () => { const profileButton = page.getByRole("link", { name: /.*ALICE.*/ }); await expect(profileButton).toBeVisible(); - // Open our own profile and make sure it works await profileButton.click(); + await page.waitForLoadState("networkidle"); await expect( page.getByRole("heading", { name: "Alice" }), ).toBeVisible(); @@ -80,7 +85,6 @@ test.describe("Regular users flow", () => { page.locator("p", { hasText: /I am a #Taggr fan/ }), ).toBeVisible(); - // Create a post await page.getByRole("button", { name: "POST" }).click(); await page.locator("textarea").fill("Hello world!"); const imagePath = resolve( @@ -98,8 +102,8 @@ test.describe("Regular users flow", () => { await fileChooser.setFiles([imagePath]); await page.getByRole("button", { name: "SUBMIT" }).click(); await page.waitForURL(/#\/post\//); + await page.waitForLoadState("networkidle"); - // Make sure the post loads await expect( page.locator("article", { hasText: /Hello world/ }), ).toBeVisible(); @@ -107,25 +111,27 @@ test.describe("Regular users flow", () => { page.getByRole("img", { name: "512x512, 2kb" }), ).toBeVisible(); - // Edit the post await page.getByTestId("post-info-toggle").click(); - await page.locator("button[title=Edit]").click(); - await page.waitForTimeout(1000); + const editButton = page.locator("button[title=Edit]"); + await editButton.waitFor({ state: "visible" }); + await editButton.click(); + await page.locator("textarea").waitFor({ state: "visible" }); const value = await page.locator("textarea").inputValue(); await page .locator("textarea") .fill(value + "\n\n**Edit:** this is a post-scriptum"); await page.getByRole("button", { name: "SUBMIT" }).click(); await page.waitForURL(/#\/post\//); + await waitForBackendOperation(page); await expect(page.getByText("post-scriptum")).toBeVisible(); - // Make sure the post is visible on the front page too await page.goto("/"); await page.waitForLoadState("networkidle"); + + const article = page.locator("article", { hasText: "Hello world!" }); + await expect(article).toBeVisible(); await expect( - page.locator("article", { - hasText: /Hello world!.*Edit: this is a post-scriptum/, - }), + article.getByText(/Edit:.*this is a post-scriptum/), ).toBeVisible(); await expect( page.getByRole("img", { name: "512x512, 2kb" }), @@ -133,56 +139,61 @@ test.describe("Regular users flow", () => { }); test("Wallet", async () => { - // Test the wallet functionality await page.getByTestId("toggle-user-section").click(); - // Let's mint cycles await expect(page.getByTestId("credits-balance")).toHaveText("976"); - page.on("dialog", async (dialog) => { - if ( - dialog - .message() - .includes("Enter the number of 1000s of credits to mint") - ) { - await dialog.accept("2"); - } - }); - await page.getByRole("button", { name: "MINT" }).click(); + + await handleDialog( + page, + "Enter the number of 1000s of credits to mint", + "2", + async () => { + await page.getByRole("button", { name: "MINT" }).click(); + }, + ); + await waitForBackendOperation(page); await expect(page.getByTestId("credits-balance")).toHaveText("2,976"); - // Let's transfer some ICP const icpBalance = parseFloat( await page.getByTestId("icp-balance").textContent(), ); - const transferExecuted = new Promise(async (resolve, _reject) => { - page.on("dialog", async (dialog) => { - if ( - dialog - .message() - .includes( - "Enter the recipient principal or ICP account address", - ) - ) { - await dialog.accept("6qfxa-ryaaa-aaaai-qbhsq-cai"); - } else if ( - dialog - .message() - .includes("Enter the amount (fee: 0.00010000 ICP)") - ) { - const transferAmount = (icpBalance / 2).toString(); - await dialog.accept(transferAmount); - } else if (dialog.message().includes("You are transferring")) { - await dialog.accept(); - await page.waitForLoadState("networkidle"); - await page.waitForTimeout(3000); - resolve(null); - } - }); - await page.getByTestId("icp-transfer-button").click(); - }); - - await transferExecuted; + const transferAmount = (icpBalance / 2).toString(); + + await handleDialogSequence( + page, + [ + { + expectedPattern: + "Enter the recipient principal or ICP account address", + response: "6qfxa-ryaaa-aaaai-qbhsq-cai", + }, + { + expectedPattern: "Enter the amount (fee: 0.00010000 ICP)", + response: transferAmount, + }, + { + expectedPattern: "You are transferring", + response: "", + }, + ], + async () => { + await page.getByTestId("icp-transfer-button").click(); + }, + ); + + await page.waitForFunction( + (oldBalance) => { + const elem = document.querySelector( + '[data-testid="icp-balance"]', + ); + if (!elem) return false; + const currentBalance = parseFloat(elem.textContent || "0"); + return currentBalance < oldBalance; + }, + icpBalance, + { timeout: 10000 }, + ); const newBalance = parseFloat( await page.getByTestId("icp-balance").textContent(), @@ -198,6 +209,7 @@ test.describe("Regular users flow", () => { await page.getByPlaceholder("alphanumeric").fill("WONDERLAND"); await page.getByTestId("realm-textarea").fill("Alice in wonderland"); await page.getByRole("button", { name: "CREATE" }).click(); + await waitForBackendOperation(page); // Make sure we're in the realm await page.getByTestId("realm-burger-button").click(); @@ -207,25 +219,31 @@ test.describe("Regular users flow", () => { await page.getByRole("button", { name: "POST" }).click(); await page.locator("#form_undefined_3").fill("Hello from Alice!"); await page.getByRole("button", { name: "SUBMIT" }).click(); + await waitForBackendOperation(page); // Make sure the post is visible on the front page and is labeled with realm tag await page.locator("#logo").click(); + await waitForBackendOperation(page); await expect( page.locator("article", { hasText: "Hello from Alice!" }), ).toBeVisible(); await expect( page.locator('[class="realm_span realm_tag"]').first(), ).toHaveText("WONDERLAND"); + await page.goto("#/home"); }); test("Invites", async () => { - // Now we can create a new realm await page.goto("/#/invites"); await page.waitForLoadState("networkidle"); + await page.waitForURL(/.*invites.*/, { timeout: 5000 }); + await page.getByRole("button", { name: "CREATE" }).click(); inviteLink = await page.getByText(/.*#\/welcome.*/).textContent(); + await page.getByTestId("toggle-user-section").click(); await page.getByRole("link", { name: /.*SIGN OUT.*/ }).click(); + await page.waitForLoadState("networkidle"); }); test("Registration by invite", async () => { @@ -245,6 +263,7 @@ test.describe("Regular users flow", () => { .fill("Alice invited me"); await page.getByRole("button", { name: "SAVE" }).click(); await page.waitForURL(/\//); + await waitForBackendOperation(page); }); test("Interacting with posts", async () => { @@ -283,7 +302,7 @@ test.describe("Regular users flow", () => { .first(), ).toHaveText("⭐️1"); await page.locator("#logo").click(); - await page.waitForTimeout(2000); + await waitForBackendOperation(page); }); test("User profile", async () => { diff --git a/e2e/test3.spec.ts b/e2e/test3.spec.ts index 331727ca..fe5c8645 100644 --- a/e2e/test3.spec.ts +++ b/e2e/test3.spec.ts @@ -1,6 +1,6 @@ import { test, expect, Page } from "@playwright/test"; -import { resolve } from "node:path"; import { exec, mkPwd, transferICP } from "./command"; +import { handleDialog, waitForBackendOperation } from "./helpers"; test.describe.configure({ mode: "serial" }); @@ -40,24 +40,23 @@ test.describe("Regular users flow, part two", () => { .getByPlaceholder("tell us what we should know about you") .fill("I am John"); await page.getByRole("button", { name: "SAVE" }).click(); + await waitForBackendOperation(page); await expect(page).toHaveTitle("TAGGR"); }); test("Create a post with poll", async () => { - // Create a post await page.getByRole("button", { name: "POST" }).click(); await page.locator("textarea").fill("Poll from John"); await page.getByTestId("poll-button").click(); await page.getByTestId("poll-editor").fill("YES\nNO\nCOMMENTS"); await page.getByRole("button", { name: "SUBMIT" }).click(); await page.waitForURL(/#\/post\//); + await page.waitForLoadState("networkidle"); - // Make sure the post loads await expect( page.locator("article", { hasText: /Poll from John/ }), ).toBeVisible(); - // Make sure the post is visible on the front page too await page.goto("/"); await page.waitForLoadState("networkidle"); await expect( @@ -66,20 +65,20 @@ test.describe("Regular users flow, part two", () => { }), ).toBeVisible(); - // Vote on poll const feedItem = page.locator(".feed_item", { hasText: /Poll/ }); await feedItem.locator("input[type=radio]").first().click(); await feedItem .getByRole("button", { name: "SUBMIT", exact: true }) .click(); + await waitForBackendOperation(page); await expect(feedItem).toHaveText(/100%/); - // Revote await feedItem.getByRole("link", { name: /CHANGE VOTE/ }).click(); await feedItem.locator("input[type=radio]").nth(1).click(); await feedItem .getByRole("button", { name: "SUBMIT ANONYMOUSLY" }) .click(); + await waitForBackendOperation(page); await expect(feedItem).toHaveText(/100%/); await expect(feedItem).toHaveText(/N\/A/); }); @@ -87,16 +86,17 @@ test.describe("Regular users flow, part two", () => { test("Repost the poll", async () => { await page.goto("/"); await page.waitForLoadState("networkidle"); - // Repost the poll const feedItem = page.locator(".feed_item", { hasText: /Poll/ }); await feedItem.getByTestId("post-info-toggle").click(); - await feedItem.locator("button[title=Repost]").click(); + const repostButton = feedItem.locator("button[title=Repost]"); + await repostButton.waitFor({ state: "visible" }); + await repostButton.click(); await page.waitForURL(/#\/new/); await page.locator("textarea").fill("Repost of the poll"); await page.getByRole("button", { name: "SUBMIT" }).click(); await page.waitForURL(/#\/post\//); + await waitForBackendOperation(page); - // Make sure the post is visible on the front page too await page.goto("/"); await page.waitForLoadState("networkidle"); await expect( @@ -113,10 +113,10 @@ test.describe("Regular users flow, part two", () => { test.describe("Tips", () => { test('Logout and login with "eye" user', async () => { - // Logout and register "eye" user await page.getByTestId("toggle-user-section").click(); await expect(page.locator(`a[title="SIGN OUT"]`)).toBeVisible(); await page.locator(`a[title="SIGN OUT"]`).click(); + await page.waitForLoadState("networkidle"); await expect(page).toHaveTitle("TAGGR"); @@ -126,7 +126,7 @@ test.describe("Regular users flow, part two", () => { .getByPlaceholder("Enter your seed phrase...") .fill(mkPwd("eye")); await page.getByRole("button", { name: "CONTINUE" }).click(); - await page.waitForTimeout(1000); + await page.waitForLoadState("networkidle"); await page .getByPlaceholder("Enter your seed phrase...") .fill(mkPwd("eye")); @@ -145,6 +145,7 @@ test.describe("Regular users flow, part two", () => { value, ); await page.getByRole("button", { name: "CHECK BALANCE" }).click(); + await waitForBackendOperation(page); await page.getByRole("button", { name: "CREATE USER" }).click(); await page.getByPlaceholder("alphanumeric").fill("one"); @@ -152,11 +153,11 @@ test.describe("Regular users flow, part two", () => { .getByPlaceholder("tell us what we should know about you") .fill("I am one"); await page.getByRole("button", { name: "SAVE" }).click(); + await waitForBackendOperation(page); await expect(page).toHaveTitle("TAGGR"); }); test("Find post and tip it", async () => { - // Mint 5 Taggr to tipper "eye" exec( `dfx canister call taggr mint_tokens '("jpyii-f2pki-kh72w-7dnbq-4j7h7-yly5o-k3lik-zgk3g-wnfwo-2w6jd-5ae", 500 : nat64)'`, ); @@ -166,7 +167,6 @@ test.describe("Regular users flow, part two", () => { await expect(page.getByTestId("token-balance")).toHaveText("5"); await page.getByTestId("toggle-user-section").click(); - // Find post with Poll from John const post = page .getByTestId("post-body") .filter({ @@ -174,40 +174,40 @@ test.describe("Regular users flow, part two", () => { }) .last(); await expect(post).toBeVisible(); - // Click post menu const menuBTN = post.locator(`button[title="Menu"]`); await expect(menuBTN).toBeVisible(); await menuBTN.click(); - // Click tip button const postMenu = post.getByTestId("post-menu"); await expect(postMenu).toBeVisible(); await postMenu.locator(`button[title="Tip"]`).click(); - // Wait for custom popup and send 1 Taggr const popup = page.getByTestId("popup"); await expect(popup).toBeVisible(); await expect(popup).toHaveText(/Tip john with.*/); - await popup.locator("input").fill("1"); // Send 1 Taggr to john - - // Confirm receiver and amount - await new Promise(async (resolve) => { - page.once("dialog", async (dialog) => { - await dialog.accept(); - await page.waitForLoadState("networkidle"); - await page.waitForTimeout(1000); - resolve(null); - }); + await popup.locator("input").fill("1"); + + await handleDialog(page, /./, "", async () => { await popup.getByText("SEND").click(); }); + await waitForBackendOperation(page); - // Check balance await page.goto("/"); await page.waitForLoadState("networkidle"); await page.getByTestId("toggle-user-section").click(); + + await page.waitForFunction( + () => { + const elem = document.querySelector( + '[data-testid="token-balance"]', + ); + return elem?.textContent === "4"; + }, + { timeout: 10000 }, + ); + await expect(page.getByTestId("token-balance")).toHaveText("4"); }); test("Find post click tip but cancel it", async () => { - // Find post with Poll from John const post = page .getByTestId("post-body") .filter({ @@ -215,33 +215,25 @@ test.describe("Regular users flow, part two", () => { }) .last(); await expect(post).toBeVisible(); - // Click post menu const menuBTN = post.locator(`button[title="Menu"]`); await expect(menuBTN).toBeVisible(); await menuBTN.click(); - // Click tip button const postMenu = post.getByTestId("post-menu"); await expect(postMenu).toBeVisible(); await postMenu.locator(`button[title="Tip"]`).click(); - // Wait for custom popup and send 1 Taggr const popup = page.getByTestId("popup"); await expect(popup).toBeVisible(); - await popup.locator("input").fill("1"); // Send 1 Taggr to john - // Dismiss - const promise = new Promise(async (resolve) => { - page.once("dialog", async (dialog) => { - await dialog.dismiss(); - resolve(null); - }); - await popup.getByText("SEND").click(); + await popup.locator("input").fill("1"); + + page.once("dialog", async (dialog) => { + await dialog.dismiss(); }); - await promise; + await popup.getByText("SEND").click(); - // Check balance await page.goto("/"); await page.waitForLoadState("networkidle"); await page.getByTestId("toggle-user-section").click(); - await expect(page.getByTestId("token-balance")).toHaveText("4"); // Canceled + await expect(page.getByTestId("token-balance")).toHaveText("4"); }); }); }); diff --git a/e2e/test4.spec.ts b/e2e/test4.spec.ts index 04192350..7f122060 100644 --- a/e2e/test4.spec.ts +++ b/e2e/test4.spec.ts @@ -1,5 +1,12 @@ import { test, expect, Page } from "@playwright/test"; import { exec, mkPwd, transferICP } from "./command"; +import { + handleDialog, + handleDialogSequence, + waitForBackendOperation, + pollForCondition, + createAuctionBid, +} from "./helpers"; test.describe.configure({ mode: "serial" }); @@ -35,16 +42,19 @@ test.describe("Report and transfer to user", () => { await page.getByRole("button", { name: "CREATE USER" }).click(); await page.getByPlaceholder("alphanumeric").fill("joe"); await page.getByRole("button", { name: "SAVE" }).click(); + await waitForBackendOperation(page); }); test("Create two invites", async () => { await page.goto("/"); await page.waitForLoadState("networkidle"); - // Create an invite await page.goto("/#/invites"); await page.waitForLoadState("networkidle"); await page.getByRole("button", { name: "CREATE" }).click(); + await waitForBackendOperation(page); + await page.waitForURL(/#\/invites/); await page.getByRole("button", { name: "CREATE" }).click(); + await waitForBackendOperation(page); inviteLink1 = await page .getByText(/.*#\/welcome.*/) .first() @@ -68,13 +78,15 @@ test.describe("Report and transfer to user", () => { await page.getByRole("button", { name: "CONTINUE" }).click(); await page.getByPlaceholder("alphanumeric").fill("jane"); await page.getByRole("button", { name: "SAVE" }).click(); + await waitForBackendOperation(page); - // Create a new post await page.locator("#logo").click(); + await page.waitForLoadState("networkidle"); await page.getByRole("button", { name: "POST" }).click(); await page.locator("textarea").fill("Good stuff"); await page.getByRole("button", { name: "SUBMIT" }).click(); await page.waitForURL(/#\/post\//); + await waitForBackendOperation(page); }); test("Registration by invite 2 and create a post", async ({ page }) => { @@ -90,30 +102,46 @@ test.describe("Report and transfer to user", () => { await page.getByRole("button", { name: "CONTINUE" }).click(); await page.getByPlaceholder("alphanumeric").fill("kyle"); await page.getByRole("button", { name: "SAVE" }).click(); + await waitForBackendOperation(page); - // Create a new post await page.locator("#logo").click(); + await page.waitForLoadState("networkidle"); await page.getByRole("button", { name: "POST" }).click(); await page.locator("textarea").fill("Illigal stuff"); await page.getByRole("button", { name: "SUBMIT" }).click(); await page.waitForURL(/#\/post\//); + await waitForBackendOperation(page); }); test("Mint credits and send to user", async () => { await page.goto("/"); await page.waitForLoadState("networkidle"); await page.getByTestId("toggle-user-section").click(); - page.on("dialog", async (dialog) => { - if ( - dialog - .message() - .includes("Enter the number of 1000s of credits to mint") - ) { - await dialog.accept("1"); - } - }); - await page.getByRole("button", { name: "MINT" }).click(); - await page.waitForTimeout(10000); + + await handleDialog( + page, + "Enter the number of 1000s of credits to mint", + "1", + async () => { + await page.getByRole("button", { name: "MINT" }).click(); + }, + ); + + await pollForCondition( + async () => { + const creditsText = await page + .getByTestId("credits-balance") + .textContent(); + const creditsBalance = Number(creditsText.replace(",", "")); + return creditsBalance >= 1900; + }, + { + maxAttempts: 20, + interval: 1000, + errorMessage: "Credits balance did not update", + }, + ); + const creditsBalance = Number( (await page.getByTestId("credits-balance").textContent()).replace( ",", @@ -125,14 +153,23 @@ test.describe("Report and transfer to user", () => { await page.goto("/#/user/jane"); await page.waitForLoadState("networkidle"); await page.getByTestId("profile-burger-menu").click(); - page.on("dialog", async (dialog) => { + + let dialogCount = 0; + const dialogHandler = async (dialog: any) => { if (dialog.message().includes("Enter the amount")) { await dialog.accept("1600"); + dialogCount++; } else if (dialog.message().includes("You are transferring")) { await dialog.accept(); + dialogCount++; } - }); + }; + + page.on("dialog", dialogHandler); await page.getByRole("button", { name: "SEND CREDITS" }).click(); + await waitForBackendOperation(page, { timeout: 5000 }); + page.removeListener("dialog", dialogHandler); + await expect(page.locator("div:has-text('CREDITS') > code")).toHaveText( /1,6\d\d/, ); @@ -149,30 +186,30 @@ test.describe("Report and transfer to user", () => { .getByPlaceholder("Enter your seed phrase...") .fill(mkPwd("jane")); await page.getByRole("button", { name: "CONTINUE" }).click(); - await page.waitForTimeout(1000); + await page.waitForLoadState("networkidle"); + await page.waitForURL(/#\//); await page.goto("/#/settings"); await page.waitForLoadState("networkidle"); + await page.getByTestId("mode-selector").waitFor({ state: "visible" }); await page.getByTestId("mode-selector").selectOption("Mining"); await page.getByRole("button", { name: "SAVE" }).click(); + await waitForBackendOperation(page); - await page.goto("/#/tokens"); - await page.waitForLoadState("networkidle"); - await page.getByPlaceholder("ICP per 1 TAGGR").fill("0.01"); - await page.getByPlaceholder("Number of TAGGR tokens").fill("15"); - transferICP( + await createAuctionBid( + page, + "0.01", + "15", + transferICP, "12f7ce64042b48e49f6c502c002035acfb3e037cb057ec184f88c04d45e8c03b", - 0.15, ); - await page.getByRole("button", { name: "BID FOR 15 TAGGR" }).click(); - await page.waitForTimeout(1000); exec("dfx canister call taggr weekly_chores"); - await page.waitForTimeout(1500); + exec("dfx canister call taggr make_stalwart '(\"joe\")'"); + await page.waitForTimeout(3000); }); test("Report user", async ({ page }) => { - exec("dfx canister call taggr make_stalwart '(\"joe\")'"); await page.goto("/"); await page.waitForLoadState("networkidle"); await page.getByRole("button", { name: "SIGN IN" }).click(); @@ -181,43 +218,67 @@ test.describe("Report and transfer to user", () => { .getByPlaceholder("Enter your seed phrase...") .fill(mkPwd("jane")); await page.getByRole("button", { name: "CONTINUE" }).click(); - await page.waitForTimeout(1000); + await page.waitForLoadState("networkidle"); await page.goto("/#/user/kyle"); await page.waitForLoadState("networkidle"); await page.getByTestId("profile-burger-menu").click(); - const reporting = new Promise((resolve, reject) => { - page.on("dialog", async (dialog) => { - if ( - dialog - .message() - .includes("You are reporting this user to stalwarts") - ) { - await dialog.accept("mfer"); - resolve(null); - } else { - reject("unexpected error: " + dialog.message()); - } - }); - }); - await page.getByRole("button", { name: "REPORT" }).click(); - await reporting; - await page.waitForTimeout(1000); + await page + .getByRole("button", { name: "REPORT" }) + .waitFor({ state: "visible" }); + + await handleDialog( + page, + "You are reporting this user to stalwarts", + "mfer", + async () => { + await page.getByRole("button", { name: "REPORT" }).click(); + }, + ); + await waitForBackendOperation(page); }); test("Confirm the report", async () => { await page.goto("/#/inbox"); - await page.waitForLoadState("networkidle"); - await page.waitForTimeout(2000); - await page.reload(); - await page.waitForLoadState("networkidle"); + await pollForCondition( + async () => { + await page.reload(); + await page.waitForLoadState("networkidle"); + return await page + .getByText("reported") + .isVisible() + .catch(() => false); + }, + { + maxAttempts: 15, + interval: 300, + errorMessage: "Report notification not found in inbox", + }, + ); + await expect(page.getByText("reported")).toBeVisible(); await page.goto("/#/user/kyle"); await page.waitForLoadState("networkidle"); await page.getByRole("button", { name: "✅ AGREE" }).click(); - await page.waitForTimeout(2000); - await page.reload(); - await page.waitForLoadState("networkidle"); + await waitForBackendOperation(page); + + await pollForCondition( + async () => { + await page.reload(); + await page.waitForLoadState("networkidle"); + const rewardsText = await page + .locator("div:has-text('REWARDS') > code") + .textContent() + .catch(() => ""); + return rewardsText === "-1,000"; + }, + { + maxAttempts: 15, + interval: 300, + errorMessage: "Rewards penalty not applied", + }, + ); + await expect(page.locator("div:has-text('REWARDS') > code")).toHaveText( "-1,000", ); @@ -232,33 +293,49 @@ test.describe("Report and transfer to user", () => { .getByPlaceholder("Enter your seed phrase...") .fill(mkPwd("jane")); await page.getByRole("button", { name: "CONTINUE" }).click(); - await page.waitForTimeout(1000); + await page.waitForLoadState("networkidle"); await page.getByTestId("toggle-user-section").click(); await expect(page.getByTestId("token-balance")).toHaveText("15"); - const transferExecuted = new Promise(async (resolve, _reject) => { - page.on("dialog", async (dialog) => { - if ( - dialog.message().includes("Enter the recipient principal") - ) { - // Joe's principal - await dialog.accept( + await handleDialogSequence( + page, + [ + { + expectedPattern: "Enter the recipient principal", + response: "evuet-jp2tc-7uwe3-dpgmg-xxr4f-duv55-36d7t-i5nxm-vgc33-cddq3-wae", - ); - } else if (dialog.message().includes("Enter the amount")) { - await dialog.accept("5"); - } else if (dialog.message().includes("You are transferring")) { - await dialog.accept(); - await page.waitForLoadState("networkidle"); - await page.waitForTimeout(3000); - resolve(null); - } - }); - await page.getByTestId("tokens-transfer-button").click(); - }); - - await transferExecuted; + }, + { + expectedPattern: "Enter the amount", + response: "5", + }, + { + expectedPattern: "You are transferring", + response: "", + }, + ], + async () => { + await page.getByTestId("tokens-transfer-button").click(); + }, + ); + + await pollForCondition( + async () => { + await page.reload(); + await page.waitForLoadState("networkidle"); + await page.getByTestId("toggle-user-section").click(); + const balance = await page + .getByTestId("token-balance") + .textContent(); + return balance === "9.9"; + }, + { + maxAttempts: 15, + interval: 300, + errorMessage: "Token balance did not update to 9.9", + }, + ); await page.goto("/#/user/joe"); await page.waitForLoadState("networkidle"); diff --git a/playwright.config.ts b/playwright.config.ts index 808e9773..296620f0 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -4,22 +4,33 @@ export default defineConfig({ testDir: "./e2e", fullyParallel: true, forbidOnly: !!process.env.CI, - retries: 0, + retries: process.env.CI ? 2 : 0, workers: 1, reporter: [ ["list", { printSteps: true }], ["html", { open: "never" }], + ["json", { outputFile: "test-results/results.json" }], ], use: { - trace: "on-first-retry", + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", baseURL: process.env["BASE_URL"], + actionTimeout: 15000, }, projects: [ { name: "chromium", - use: { ...devices["Desktop Chrome"] }, + use: { + ...devices["Desktop Chrome"], + viewport: { width: 1280, height: 720 }, + }, }, ], - expect: { timeout: 10000 }, + expect: { + timeout: 15000, + toHaveScreenshot: { maxDiffPixels: 100 }, + }, globalSetup: require.resolve("./e2e/setup"), + outputDir: "test-results", }); diff --git a/src/backend/dev_helpers.rs b/src/backend/dev_helpers.rs index c51fd48b..1b5d1c1b 100644 --- a/src/backend/dev_helpers.rs +++ b/src/backend/dev_helpers.rs @@ -21,11 +21,8 @@ async fn reset(canister_id: String) { crate::domains::DomainConfig::default(), ); state.memory.init(); - // as expected in E2E tests - { - state.auction.amount = CONFIG.weekly_auction_size_tokens_min; - state.auction.last_auction_price_e8s = 15000000; - } + state.auction.amount = CONFIG.weekly_auction_size_tokens_min; + state.auction.last_auction_price_e8s = 15000000; cell.replace(state); }); set_timer(Duration::from_millis(0), || { diff --git a/src/backend/env/mod.rs b/src/backend/env/mod.rs index 3f2e13f1..e66343fd 100644 --- a/src/backend/env/mod.rs +++ b/src/backend/env/mod.rs @@ -3826,10 +3826,9 @@ pub(crate) mod tests { // now we follow a feed #post+#tags let user = state.users.get_mut(&user_id).unwrap(); - assert!( - user.toggle_following_feed(vec!["post".to_owned(), "tags".to_owned()].as_slice()) - .unwrap() - ); + assert!(user + .toggle_following_feed(vec!["post".to_owned(), "tags".to_owned()].as_slice()) + .unwrap()); // make sure the feed still contains the same post let feed = state diff --git a/src/frontend/src/invites.tsx b/src/frontend/src/invites.tsx index 45709357..0fab1fe3 100644 --- a/src/frontend/src/invites.tsx +++ b/src/frontend/src/invites.tsx @@ -226,10 +226,12 @@ export const Invites = () => {
- Credits: + + Credits: + updateInvite( @@ -241,13 +243,15 @@ export const Invites = () => { />
- + Credits Per User: - {" "} + {credits_per_user}
- Realm: + + Realm: + { />
- Users: + + Users: +
{
- Invite Code: + + Invite Code: +
- URL: + + URL: +
diff --git a/src/frontend/src/post.tsx b/src/frontend/src/post.tsx index cc4acd4b..e5e11b93 100644 --- a/src/frontend/src/post.tsx +++ b/src/frontend/src/post.tsx @@ -1492,7 +1492,6 @@ const TippingPopup = ({ } setTippingAmount(amount); }} - onFocus={(e) => e.target.select()} /> { - if (!("WhiteListedRealms" in cfg.sub_config)) return null; + if (!cfg || !("WhiteListedRealms" in cfg.sub_config)) return null; const whiteList = cfg.sub_config.WhiteListedRealms; return whiteList.length == 1 ? whiteList[0] : null; }; export const getDefaultRealm = (cfg: DomainConfig) => { - if (!("WhiteListedRealms" in cfg.sub_config)) return null; + if (!cfg || !("WhiteListedRealms" in cfg.sub_config)) return null; return cfg.sub_config.WhiteListedRealms[0]; }; export const getJournal = (cfg: DomainConfig) => { - if (!("Journal" in cfg.sub_config)) return null; + if (!cfg || !("Journal" in cfg.sub_config)) return null; return cfg.sub_config.Journal; }; From 2318c0fad33d56efa7d6e4beaf4abcfb5276d611 Mon Sep 17 00:00:00 2001 From: X Date: Sun, 2 Nov 2025 12:48:04 +0100 Subject: [PATCH 09/14] upgrade ic-wasm --- .ic-wasm-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ic-wasm-version b/.ic-wasm-version index 965065db..e3e18070 100644 --- a/.ic-wasm-version +++ b/.ic-wasm-version @@ -1 +1 @@ -0.9.3 +0.9.8 From 21b154c53edfdebfe8a56edb5a374af9d1364014 Mon Sep 17 00:00:00 2001 From: X Date: Fri, 7 Nov 2025 20:27:29 +0100 Subject: [PATCH 10/14] wallet fixes --- src/backend/env/tip.rs | 2 +- src/frontend/src/post.tsx | 2 - src/frontend/src/token-select.tsx | 33 ++++------------ src/frontend/src/tokens_wallet.tsx | 60 ++++++++++++++---------------- 4 files changed, 35 insertions(+), 62 deletions(-) diff --git a/src/backend/env/tip.rs b/src/backend/env/tip.rs index ce66ba35..cb04989f 100644 --- a/src/backend/env/tip.rs +++ b/src/backend/env/tip.rs @@ -91,7 +91,7 @@ pub async fn add_tip( let sender_id = state.principal_to_user(caller).expect("user not found").id; state.charge( sender_id, - CONFIG.tipping_cost * 50, + CONFIG.tipping_cost * 2, format!("failed external tipping for post {}", post_id), ) })?; diff --git a/src/frontend/src/post.tsx b/src/frontend/src/post.tsx index e5e11b93..21e5014a 100644 --- a/src/frontend/src/post.tsx +++ b/src/frontend/src/post.tsx @@ -1482,8 +1482,6 @@ const TippingPopup = ({

{ const amount = Number(e.target.value); diff --git a/src/frontend/src/token-select.tsx b/src/frontend/src/token-select.tsx index 529d93aa..4c2495f8 100755 --- a/src/frontend/src/token-select.tsx +++ b/src/frontend/src/token-select.tsx @@ -20,13 +20,7 @@ export const TokenSelect = ({ const [selectedValue, setSelectedValue] = React.useState( selectedCanisterId || "", ); - const [defaultCanisters, setDefaultCanisters] = React.useState< - Array<[string, Icrc1Canister]> - >([]); - const [mainCanisters, setMainCanisters] = React.useState< - Array<[string, Icrc1Canister]> - >([]); - const [userCanisters, setUserCanisters] = React.useState< + const [userTokens, setUserTokens] = React.useState< Array<[string, Icrc1Canister]> >([]); @@ -40,21 +34,19 @@ export const TokenSelect = ({ const loadData = () => { const canistersMap = new Map(canisters); // Add ICP or Taggr - const mainCanisters: Array<[string, Icrc1Canister]> = []; + const mainTokens: Array<[string, Icrc1Canister]> = []; const nativeCanister = canistersMap.get(CANISTER_ID); if (nativeCanister) { - mainCanisters.push([CANISTER_ID, nativeCanister]); + mainTokens.push([CANISTER_ID, nativeCanister]); } const icpCanister = canistersMap.get(ICP_LEDGER); if (icpCanister) { - mainCanisters.push([ICP_LEDGER, icpCanister]); + mainTokens.push([ICP_LEDGER, icpCanister]); } - setMainCanisters(mainCanisters); - const userTokens = window.user?.wallet_tokens || []; - setUserCanisters( + setUserTokens( userTokens .filter((id) => canistersMap.has(id)) .map((canisterId) => [ @@ -62,14 +54,6 @@ export const TokenSelect = ({ canistersMap.get(canisterId) as Icrc1Canister, ]), ); - - setDefaultCanisters( - canisters.filter( - ([canisterId]) => - ![CANISTER_ID, ICP_LEDGER].includes(canisterId) && - !userTokens.includes(canisterId), - ), - ); }; React.useEffect(() => { @@ -108,11 +92,8 @@ export const TokenSelect = ({ SELECT TOKEN )} - {mainCanisters.length > 0 && renderOptions(mainCanisters, "Main")} - {userCanisters.length > 0 && - renderOptions(userCanisters, "Your Tokens")} - {defaultCanisters.length > 0 && - renderOptions(defaultCanisters, "Tokens")} + {userTokens.length > 0 && + renderOptions(userTokens, "Tipping Tokens")} ); }; diff --git a/src/frontend/src/tokens_wallet.tsx b/src/frontend/src/tokens_wallet.tsx index 720539cd..1c8c28ec 100755 --- a/src/frontend/src/tokens_wallet.tsx +++ b/src/frontend/src/tokens_wallet.tsx @@ -20,9 +20,9 @@ export const Icrc1TokensWallet = () => { const user = window.user; const userWalletFiltersKey = `user:${user?.id}:wallet-filters`; - const [icrc1Canisters, setIcrc1Canisters] = React.useState< - Array<[string, Icrc1Canister]> - >([]); + const [tokens, setTokens] = React.useState>( + [], + ); const [canisterBalances, setCanisterBalances] = React.useState<{ [key: string]: string | number; }>({}); @@ -38,7 +38,6 @@ export const Icrc1TokensWallet = () => { getLocalFilters()?.hideZeroBalance || false, ); const [disabled, setDisabled] = React.useState(true); - const [initLoading, setInitLoading] = React.useState(true); /** Load balances of user canisters in small batches to avoid spikes */ const loadBalances = async (canisterIds: string[]) => { @@ -78,8 +77,8 @@ export const Icrc1TokensWallet = () => { user?.wallet_tokens || [], ); const balances = await loadBalances([...canisters.keys()]); - setIcrc1Canisters( - filterAndSortCanisters( + setTokens( + filterTokens( [...canisters.entries()], balances, hideZeroBalance, @@ -95,7 +94,9 @@ export const Icrc1TokensWallet = () => { const initialTokensData = await getCanistersMetaData([ ...currentUserTokens, ]); - setIcrc1Canisters([...initialTokensData.entries()]); + setTokens( + filterTokens([...initialTokensData.entries()], {}, hideZeroBalance), + ); void loadBalances([...currentUserTokens]); // Automatic token discovery @@ -113,12 +114,14 @@ export const Icrc1TokensWallet = () => { const allUserTokensData = await getCanistersMetaData([ ...allUserTokens, ]); - setIcrc1Canisters([...allUserTokensData.entries()]); + setTokens( + filterTokens([...allUserTokensData.entries()], {}, hideZeroBalance), + ); const balances = await loadBalances([...allUserTokens]); - setIcrc1Canisters( - filterAndSortCanisters( + setTokens( + filterTokens( [...allUserTokensData.entries()], balances, hideZeroBalance, @@ -138,7 +141,6 @@ export const Icrc1TokensWallet = () => { initialLoad().finally(() => { loading = false; setDisabled(false); - setInitLoading(false); }); } }, []); @@ -175,9 +177,9 @@ export const Icrc1TokensWallet = () => { cacheLocalStorage(getUserCanisterKey(canisterId), meta); const balances = await loadBalances([canisterId]); - setIcrc1Canisters( - filterAndSortCanisters( - [...icrc1Canisters, [canisterId, meta]], + setTokens( + filterTokens( + [...tokens, [canisterId, meta]], balances, hideZeroBalance, ), @@ -228,9 +230,7 @@ export const Icrc1TokensWallet = () => { localStorage.removeItem(getUserCanisterKey(canisterId)); - setIcrc1Canisters( - icrc1Canisters.filter(([id]) => id !== canisterId), - ); + setTokens(tokens.filter(([id]) => id !== canisterId)); } catch (error: any) { showPopUp( "error", @@ -247,9 +247,8 @@ export const Icrc1TokensWallet = () => { className="vcentered bottom_spaced" data-testid="ic-tokens-div" > -

IC TOKENS

+

ICRC TOKENS

- {initLoading && } { user?.wallet_tokens || [], )), ]; - const filteredCanisters = filterAndSortCanisters( + const filteredCanisters = filterTokens( canisters, canisterBalances, !hideZeroBalance, @@ -274,11 +273,11 @@ export const Icrc1TokensWallet = () => { JSON.stringify(filters), ); setHideZeroBalance(!hideZeroBalance); - setIcrc1Canisters(filteredCanisters); + setTokens(filteredCanisters); }} />
- {icrc1Canisters.length > 0 && ( + {tokens.length > 0 && (
- {icrc1Canisters.map(([canisterId, info]) => ( + {tokens.map(([canisterId, info]) => (
{ ); }; -const filterAndSortCanisters = ( +const filterTokens = ( canisters: Array<[string, Icrc1Canister]>, balances: Record, hideZeroBalance: boolean, @@ -378,12 +377,7 @@ const filterAndSortCanisters = ( ([canisterId]) => +balances[canisterId] > 0, ); } - // Sort by name and then balance - return filteredCanisters - .sort((a, b) => a[1].symbol.localeCompare(b[1].symbol)) - .sort( - (a, b) => - +balances[b[0]] / Math.pow(10, b[1].decimals) - - +balances[a[0]] / Math.pow(10, a[1].decimals), - ); + return filteredCanisters.sort((a, b) => + a[1].symbol.localeCompare(b[1].symbol), + ); }; From 6641cd651c8b8155cb50a0407d9b22e7830c4d46 Mon Sep 17 00:00:00 2001 From: X Date: Mon, 3 Nov 2025 18:09:28 +0100 Subject: [PATCH 11/14] bot protection --- src/backend/env/config.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/backend/env/config.rs b/src/backend/env/config.rs index aaab6550..3511cb47 100644 --- a/src/backend/env/config.rs +++ b/src/backend/env/config.rs @@ -245,8 +245,16 @@ pub const CONFIG: &Config = &Config { max_bucket_size: 1024 * 1024 * 1024 * 96, // 96Gb + #[cfg(not(test))] + max_posts_per_day: 15, + #[cfg(not(test))] + max_comments_per_hour: 30, + + #[cfg(test)] max_posts_per_day: 100, + #[cfg(test)] max_comments_per_hour: 100, + excess_penalty: 5, feed_page_size: 30, From 854f51a206b1b89f6bba118d336d0dd08b995140 Mon Sep 17 00:00:00 2001 From: X Date: Fri, 7 Nov 2025 20:51:47 +0100 Subject: [PATCH 12/14] fix e2e tests --- e2e/test1.spec.ts | 3 +++ e2e/test4.spec.ts | 8 ++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/e2e/test1.spec.ts b/e2e/test1.spec.ts index cc733e5c..e7678984 100644 --- a/e2e/test1.spec.ts +++ b/e2e/test1.spec.ts @@ -280,6 +280,9 @@ test.describe("Upgrades & token transfer flow", () => { page.getByRole("heading", { name: "PROPOSALS" }), ).toBeVisible(); await page.getByTestId("proposals-burger-button").click(); + await page + .locator("textarea") + .waitFor({ state: "attached", timeout: 2000 }); await page.locator("textarea").fill("A regular upgrade"); const binaryPath = resolve( diff --git a/e2e/test4.spec.ts b/e2e/test4.spec.ts index 7f122060..660a1c74 100644 --- a/e2e/test4.spec.ts +++ b/e2e/test4.spec.ts @@ -187,6 +187,7 @@ test.describe("Report and transfer to user", () => { .fill(mkPwd("jane")); await page.getByRole("button", { name: "CONTINUE" }).click(); await page.waitForLoadState("networkidle"); + await page.reload(); await page.waitForURL(/#\//); await page.goto("/#/settings"); @@ -218,13 +219,16 @@ test.describe("Report and transfer to user", () => { .getByPlaceholder("Enter your seed phrase...") .fill(mkPwd("jane")); await page.getByRole("button", { name: "CONTINUE" }).click(); - await page.waitForLoadState("networkidle"); await page.goto("/#/user/kyle"); + await page.reload(); await page.waitForLoadState("networkidle"); + await page + .getByTestId("profile-burger-menu") + .waitFor({ state: "visible", timeout: 2000 }); await page.getByTestId("profile-burger-menu").click(); await page .getByRole("button", { name: "REPORT" }) - .waitFor({ state: "visible" }); + .waitFor({ state: "visible", timeout: 2000 }); await handleDialog( page, From 9485be571b2e53913dd820f14794233eccd296da Mon Sep 17 00:00:00 2001 From: X Date: Fri, 7 Nov 2025 21:27:14 +0100 Subject: [PATCH 13/14] styling --- src/frontend/src/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/src/style.css b/src/frontend/src/style.css index 64827a3f..87ef4fb9 100644 --- a/src/frontend/src/style.css +++ b/src/frontend/src/style.css @@ -321,7 +321,7 @@ article { .stands_out { background: $light_background; border-radius: 10px; - border-bottom: 2px solid $dark_background; + border-bottom: 1px solid $dark_background; padding: 0.5em; margin-bottom: 1em; } From a9618ca0720103f589477e17f4133a5bd9290103 Mon Sep 17 00:00:00 2001 From: X Date: Fri, 7 Nov 2025 21:39:39 +0100 Subject: [PATCH 14/14] fix tests --- e2e/helpers.ts | 8 ++--- e2e/test0.spec.ts | 5 ++-- e2e/test1.spec.ts | 68 ++++++++++++++++++++---------------------- e2e/test2.spec.ts | 46 ++++++++++++++-------------- e2e/test3.spec.ts | 36 +++++++++++----------- e2e/test4.spec.ts | 76 ++++++++++++++++++++++------------------------- 6 files changed, 116 insertions(+), 123 deletions(-) diff --git a/e2e/helpers.ts b/e2e/helpers.ts index cbd03c86..bd3c27d5 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -180,7 +180,7 @@ export async function waitForCondition( throw new Error(errorMessage); } -export async function waitForBackendOperation( +export async function waitForUILoading( page: Page, options: { timeout?: number; @@ -189,7 +189,7 @@ export async function waitForBackendOperation( const { timeout = 5000 } = options; await page.waitForLoadState("networkidle", { timeout }); - await page.waitForTimeout(100); + await page.waitForTimeout(500); } export async function createAuctionBid( @@ -200,7 +200,7 @@ export async function createAuctionBid( icpAddress: string, ): Promise { await page.goto("/#/tokens"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByPlaceholder("ICP per 1 TAGGR").fill(icpPerToken); await page.getByPlaceholder("Number of TAGGR tokens").fill(tokenAmount); transferICPFn( @@ -210,7 +210,7 @@ export async function createAuctionBid( await page .getByRole("button", { name: `BID FOR ${tokenAmount} TAGGR` }) .click(); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByText("Current Bids").waitFor({ state: "visible" }); } diff --git a/e2e/test0.spec.ts b/e2e/test0.spec.ts index a2baf1a5..7edcec07 100644 --- a/e2e/test0.spec.ts +++ b/e2e/test0.spec.ts @@ -1,8 +1,9 @@ +import { waitForUILoading } from "./helpers"; import { test, expect } from "@playwright/test"; test("Sanity check", async ({ page }) => { await page.goto("/"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await expect( page.getByRole("heading", { name: "WELCOME ABOARD" }), @@ -15,7 +16,7 @@ test("Sanity check", async ({ page }) => { test("Important links work", async ({ page }) => { await page.goto("/"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByRole("link", { name: "WHITE PAPER" }).click(); await expect( diff --git a/e2e/test1.spec.ts b/e2e/test1.spec.ts index e7678984..e0a1c81f 100644 --- a/e2e/test1.spec.ts +++ b/e2e/test1.spec.ts @@ -1,3 +1,4 @@ +import { waitForUILoading } from "./helpers"; import { test, expect, Page, Locator } from "@playwright/test"; import { resolve } from "node:path"; import { exec, mkPwd, transferICP } from "./command"; @@ -7,7 +8,6 @@ import { canisterId } from "./setup"; import { handleDialog, handleDialogSequence, - waitForBackendOperation, pollForCondition, createAuctionBid, } from "./helpers"; @@ -36,7 +36,7 @@ const executeTransfer = async (page: Page, btn: Locator, amount = "5") => { }, ); - await waitForBackendOperation(page); + await waitForUILoading(page); }; test.describe("Upgrades & token transfer flow", () => { @@ -49,7 +49,7 @@ test.describe("Upgrades & token transfer flow", () => { test("Registration", async () => { await page.goto("/"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); // Registration flow await page.getByRole("button", { name: "SIGN UP" }).click(); await page.getByRole("button", { name: "SEED PHRASE" }).click(); @@ -70,17 +70,17 @@ test.describe("Upgrades & token transfer flow", () => { await page .getByRole("button", { name: "MINT CREDITS WITH ICP" }) .click(); - await waitForBackendOperation(page); + await waitForUILoading(page); await page.getByRole("button", { name: "CREATE USER" }).click(); await page.getByPlaceholder("alphanumeric").fill("eve"); await page.getByRole("button", { name: "SAVE" }).click(); - await waitForBackendOperation(page); + await waitForUILoading(page); exec("dfx canister call taggr make_stalwart '(\"eve\")'"); }); test("Create a post and an invite", async () => { await page.goto("/"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); // Create a post await page.getByRole("button", { name: "POST" }).click(); await page.locator("textarea").fill("Message from Eve"); @@ -95,7 +95,7 @@ test.describe("Upgrades & token transfer flow", () => { test("Registration by invite and rewarding a post", async ({ page }) => { await page.goto(inviteLink); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByRole("button", { name: "SEED PHRASE" }).click(); await page .getByPlaceholder("Enter your seed phrase...") @@ -106,7 +106,7 @@ test.describe("Upgrades & token transfer flow", () => { await page.getByRole("button", { name: "CONTINUE" }).click(); await page.getByPlaceholder("alphanumeric").fill("pete"); await page.getByRole("button", { name: "SAVE" }).click(); - await waitForBackendOperation(page); + await waitForUILoading(page); const feedItem = page.locator(".feed_item", { hasText: "Message from Eve", @@ -118,7 +118,7 @@ test.describe("Upgrades & token transfer flow", () => { .first(); await rewardButton.waitFor({ state: "visible" }); await rewardButton.click(); - await waitForBackendOperation(page); + await waitForUILoading(page); }); test("Create an auction bid, trigger minting", async ({}) => { @@ -137,7 +137,7 @@ test.describe("Upgrades & token transfer flow", () => { await pollForCondition( async () => { await page.goto("/"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByTestId("toggle-user-section").click(); const balance = await page .getByTestId("token-balance") @@ -153,7 +153,7 @@ test.describe("Upgrades & token transfer flow", () => { ); await page.goto("/"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByTestId("toggle-user-section").click(); await expect(page.getByTestId("token-balance")).toHaveText("15"); @@ -172,7 +172,7 @@ test.describe("Upgrades & token transfer flow", () => { test("Recovery proposal", async ({ page }) => { await page.goto("/#/recovery"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByRole("button", { name: "SEED PHRASE" }).click(); await page .getByPlaceholder("Enter your seed phrase...") @@ -216,7 +216,7 @@ test.describe("Upgrades & token transfer flow", () => { await pollForCondition( async () => { await page.reload(); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); const statusText = await page .getByTestId("status") .textContent({ timeout: 5000 }) @@ -237,22 +237,22 @@ test.describe("Upgrades & token transfer flow", () => { const buildHash = await hashFile(binaryPath); await page.getByTestId("hash-input").fill(buildHash); await page.getByRole("button", { name: "SUBMIT HASH" }).click(); - await waitForBackendOperation(page); + await waitForUILoading(page); await expect(page.getByText("votes: 100%")).toBeVisible(); await expect( page.getByRole("heading", { name: "Supporters" }), ).toBeVisible(); exec("dfx canister call taggr chores"); - await waitForBackendOperation(page, { timeout: 10000 }); + await waitForUILoading(page, { timeout: 10000 }); await page.waitForTimeout(4000); }); test("Verify recovery upgrade", async () => { await page.goto("/#/dashboard"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByRole("button", { name: "TECHNICAL" }).click(); - await waitForBackendOperation(page); + await waitForUILoading(page); await pollForCondition( async () => { @@ -274,15 +274,12 @@ test.describe("Upgrades & token transfer flow", () => { test("Regular proposal", async () => { await page.goto("/#/proposals"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await expect( page.getByRole("heading", { name: "PROPOSALS" }), ).toBeVisible(); await page.getByTestId("proposals-burger-button").click(); - await page - .locator("textarea") - .waitFor({ state: "attached", timeout: 2000 }); await page.locator("textarea").fill("A regular upgrade"); const binaryPath = resolve( @@ -328,7 +325,7 @@ test.describe("Upgrades & token transfer flow", () => { await page.waitForTimeout(1000); await page.getByRole("button", { name: "SUBMIT" }).click(); - await waitForBackendOperation(page); + await waitForUILoading(page); await expect(page.getByText(/STATUS.*OPEN/)).toBeVisible(); await expect(page.getByText("TYPE: RELEASE")).toBeVisible(); @@ -340,22 +337,23 @@ test.describe("Upgrades & token transfer flow", () => { await page.getByRole("button", { name: "ACCEPT" }).click(); }, ); - await waitForBackendOperation(page); + await waitForUILoading(page); await expect(page.getByText(/STATUS.*EXECUTED/)).toBeVisible(); exec("dfx canister call taggr chores"); - await waitForBackendOperation(page, { timeout: 5000 }); + await waitForUILoading(page, { timeout: 5000 }); await page.locator("#logo").click(); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); }); test("Verify regular upgrade", async () => { await pollForCondition( async () => { await page.goto("/#/dashboard"); + await page.reload(); await page.waitForURL(/dashboard/); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByRole("button", { name: "TECHNICAL" }).click(); const count = await page .locator("p", { hasText: /Upgrade succeeded/ }) @@ -364,7 +362,7 @@ test.describe("Upgrades & token transfer flow", () => { }, { maxAttempts: 15, - interval: 1000, + interval: 500, errorMessage: "Did not find 2 upgrade succeeded messages", }, ); @@ -377,19 +375,19 @@ test.describe("Upgrades & token transfer flow", () => { test.describe("IC TOKENS", () => { test("Add - input", async () => { await page.goto("/#/settings"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); const icrcWalletEnableSelect = page.getByTestId("ic-wallet-select"); await expect(icrcWalletEnableSelect).toBeVisible(); await icrcWalletEnableSelect.selectOption("YES"); await icrcWalletEnableSelect.selectOption("YES"); await page.getByRole("button", { name: "SAVE" }).click(); - await waitForBackendOperation(page); + await waitForUILoading(page); await page.reload(); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.goto("/"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByTestId("toggle-user-section").click(); await expect(page.getByTestId("token-balance")).toHaveText("9.9"); @@ -412,7 +410,7 @@ test.describe("Upgrades & token transfer flow", () => { await addTokenBtn.click(); }, ); - await waitForBackendOperation(page); + await waitForUILoading(page); await expect(page.getByTestId(`${canisterId}-balance`)).toHaveText( "9.90", @@ -444,14 +442,14 @@ test.describe("Upgrades & token transfer flow", () => { await expect(tokenHideZeros).toBeVisible(); await tokenHideZeros.click(); - await waitForBackendOperation(page, { timeout: 2000 }); + await waitForUILoading(page, { timeout: 2000 }); await expect( page.getByTestId(`${canisterId}-send`), ).not.toBeVisible(); await tokenHideZeros.click(); - await waitForBackendOperation(page, { timeout: 2000 }); + await waitForUILoading(page, { timeout: 2000 }); await expect(page.getByTestId(`${canisterId}-send`)).toBeVisible(); }); @@ -462,7 +460,7 @@ test.describe("Upgrades & token transfer flow", () => { await handleDialog(page, "Remove TAGGR", "", async () => { await tokenRemoveBtn.click(); }); - await waitForBackendOperation(page); + await waitForUILoading(page); await expect( page.getByTestId(`${canisterId}-remove`), diff --git a/e2e/test2.spec.ts b/e2e/test2.spec.ts index 7c760974..eac6bb92 100644 --- a/e2e/test2.spec.ts +++ b/e2e/test2.spec.ts @@ -1,11 +1,8 @@ +import { waitForUILoading } from "./helpers"; import { test, expect, Page } from "@playwright/test"; import { resolve } from "node:path"; import { mkPwd, transferICP } from "./command"; -import { - handleDialog, - handleDialogSequence, - waitForBackendOperation, -} from "./helpers"; +import { handleDialog, handleDialogSequence } from "./helpers"; test.describe.configure({ mode: "serial" }); @@ -19,7 +16,7 @@ test.describe("Regular users flow", () => { test("Registration", async () => { await page.goto("/"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); // Registration flow await page.getByRole("button", { name: "SIGN UP" }).click(); @@ -47,11 +44,11 @@ test.describe("Regular users flow", () => { .getByPlaceholder("tell us what we should know about you") .fill("I am a #Taggr fan"); await page.getByRole("button", { name: "SAVE" }).click(); - await waitForBackendOperation(page); + await waitForUILoading(page); await expect(page).toHaveTitle("TAGGR"); await page.goto("/#/inbox"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await expect( page.getByRole("heading", { name: "INBOX" }), ).toBeVisible(); @@ -61,7 +58,7 @@ test.describe("Regular users flow", () => { await page.getByTestId("toggle-user-section").click(); await page.getByRole("link", { name: /.*SIGN OUT.*/ }).click(); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); }); test("Login and post", async () => { @@ -71,13 +68,13 @@ test.describe("Regular users flow", () => { .getByPlaceholder("Enter your seed phrase...") .fill(mkPwd("alice")); await page.getByRole("button", { name: "CONTINUE" }).click(); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByTestId("toggle-user-section").click(); const profileButton = page.getByRole("link", { name: /.*ALICE.*/ }); await expect(profileButton).toBeVisible(); await profileButton.click(); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await expect( page.getByRole("heading", { name: "Alice" }), ).toBeVisible(); @@ -102,7 +99,7 @@ test.describe("Regular users flow", () => { await fileChooser.setFiles([imagePath]); await page.getByRole("button", { name: "SUBMIT" }).click(); await page.waitForURL(/#\/post\//); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await expect( page.locator("article", { hasText: /Hello world/ }), @@ -122,11 +119,11 @@ test.describe("Regular users flow", () => { .fill(value + "\n\n**Edit:** this is a post-scriptum"); await page.getByRole("button", { name: "SUBMIT" }).click(); await page.waitForURL(/#\/post\//); - await waitForBackendOperation(page); + await waitForUILoading(page); await expect(page.getByText("post-scriptum")).toBeVisible(); await page.goto("/"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); const article = page.locator("article", { hasText: "Hello world!" }); await expect(article).toBeVisible(); @@ -151,7 +148,7 @@ test.describe("Regular users flow", () => { await page.getByRole("button", { name: "MINT" }).click(); }, ); - await waitForBackendOperation(page); + await waitForUILoading(page); await expect(page.getByTestId("credits-balance")).toHaveText("2,976"); const icpBalance = parseFloat( @@ -204,12 +201,12 @@ test.describe("Regular users flow", () => { test("Realms", async () => { // Now we can create a new realm await page.goto("/#/realms"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByRole("button", { name: "CREATE" }).click(); await page.getByPlaceholder("alphanumeric").fill("WONDERLAND"); await page.getByTestId("realm-textarea").fill("Alice in wonderland"); await page.getByRole("button", { name: "CREATE" }).click(); - await waitForBackendOperation(page); + await waitForUILoading(page); // Make sure we're in the realm await page.getByTestId("realm-burger-button").click(); @@ -219,11 +216,11 @@ test.describe("Regular users flow", () => { await page.getByRole("button", { name: "POST" }).click(); await page.locator("#form_undefined_3").fill("Hello from Alice!"); await page.getByRole("button", { name: "SUBMIT" }).click(); - await waitForBackendOperation(page); + await waitForUILoading(page); // Make sure the post is visible on the front page and is labeled with realm tag await page.locator("#logo").click(); - await waitForBackendOperation(page); + await waitForUILoading(page); await expect( page.locator("article", { hasText: "Hello from Alice!" }), ).toBeVisible(); @@ -235,7 +232,8 @@ test.describe("Regular users flow", () => { test("Invites", async () => { await page.goto("/#/invites"); - await page.waitForLoadState("networkidle"); + await page.reload(); + await waitForUILoading(page); await page.waitForURL(/.*invites.*/, { timeout: 5000 }); await page.getByRole("button", { name: "CREATE" }).click(); @@ -243,12 +241,12 @@ test.describe("Regular users flow", () => { await page.getByTestId("toggle-user-section").click(); await page.getByRole("link", { name: /.*SIGN OUT.*/ }).click(); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); }); test("Registration by invite", async () => { await page.goto(inviteLink); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByRole("button", { name: "SEED PHRASE" }).click(); await page .getByPlaceholder("Enter your seed phrase...") @@ -263,7 +261,7 @@ test.describe("Regular users flow", () => { .fill("Alice invited me"); await page.getByRole("button", { name: "SAVE" }).click(); await page.waitForURL(/\//); - await waitForBackendOperation(page); + await waitForUILoading(page); }); test("Interacting with posts", async () => { @@ -302,7 +300,7 @@ test.describe("Regular users flow", () => { .first(), ).toHaveText("⭐️1"); await page.locator("#logo").click(); - await waitForBackendOperation(page); + await waitForUILoading(page); }); test("User profile", async () => { diff --git a/e2e/test3.spec.ts b/e2e/test3.spec.ts index fe5c8645..32be00e8 100644 --- a/e2e/test3.spec.ts +++ b/e2e/test3.spec.ts @@ -1,6 +1,6 @@ +import { waitForUILoading, handleDialog } from "./helpers"; import { test, expect, Page } from "@playwright/test"; import { exec, mkPwd, transferICP } from "./command"; -import { handleDialog, waitForBackendOperation } from "./helpers"; test.describe.configure({ mode: "serial" }); @@ -13,7 +13,7 @@ test.describe("Regular users flow, part two", () => { test("Registration", async () => { await page.goto("/"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); // Registration flow await page.getByRole("button", { name: "SIGN UP" }).click(); @@ -40,7 +40,7 @@ test.describe("Regular users flow, part two", () => { .getByPlaceholder("tell us what we should know about you") .fill("I am John"); await page.getByRole("button", { name: "SAVE" }).click(); - await waitForBackendOperation(page); + await waitForUILoading(page); await expect(page).toHaveTitle("TAGGR"); }); @@ -51,14 +51,14 @@ test.describe("Regular users flow, part two", () => { await page.getByTestId("poll-editor").fill("YES\nNO\nCOMMENTS"); await page.getByRole("button", { name: "SUBMIT" }).click(); await page.waitForURL(/#\/post\//); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await expect( page.locator("article", { hasText: /Poll from John/ }), ).toBeVisible(); await page.goto("/"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await expect( page.locator("article", { hasText: /Poll from John/, @@ -70,7 +70,7 @@ test.describe("Regular users flow, part two", () => { await feedItem .getByRole("button", { name: "SUBMIT", exact: true }) .click(); - await waitForBackendOperation(page); + await waitForUILoading(page); await expect(feedItem).toHaveText(/100%/); await feedItem.getByRole("link", { name: /CHANGE VOTE/ }).click(); @@ -78,14 +78,14 @@ test.describe("Regular users flow, part two", () => { await feedItem .getByRole("button", { name: "SUBMIT ANONYMOUSLY" }) .click(); - await waitForBackendOperation(page); + await waitForUILoading(page); await expect(feedItem).toHaveText(/100%/); await expect(feedItem).toHaveText(/N\/A/); }); test("Repost the poll", async () => { await page.goto("/"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); const feedItem = page.locator(".feed_item", { hasText: /Poll/ }); await feedItem.getByTestId("post-info-toggle").click(); const repostButton = feedItem.locator("button[title=Repost]"); @@ -95,10 +95,10 @@ test.describe("Regular users flow, part two", () => { await page.locator("textarea").fill("Repost of the poll"); await page.getByRole("button", { name: "SUBMIT" }).click(); await page.waitForURL(/#\/post\//); - await waitForBackendOperation(page); + await waitForUILoading(page); await page.goto("/"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await expect( page.locator("article", { hasText: /Repost of the poll/, @@ -116,7 +116,7 @@ test.describe("Regular users flow, part two", () => { await page.getByTestId("toggle-user-section").click(); await expect(page.locator(`a[title="SIGN OUT"]`)).toBeVisible(); await page.locator(`a[title="SIGN OUT"]`).click(); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await expect(page).toHaveTitle("TAGGR"); @@ -126,7 +126,7 @@ test.describe("Regular users flow, part two", () => { .getByPlaceholder("Enter your seed phrase...") .fill(mkPwd("eye")); await page.getByRole("button", { name: "CONTINUE" }).click(); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page .getByPlaceholder("Enter your seed phrase...") .fill(mkPwd("eye")); @@ -145,7 +145,7 @@ test.describe("Regular users flow, part two", () => { value, ); await page.getByRole("button", { name: "CHECK BALANCE" }).click(); - await waitForBackendOperation(page); + await waitForUILoading(page); await page.getByRole("button", { name: "CREATE USER" }).click(); await page.getByPlaceholder("alphanumeric").fill("one"); @@ -153,7 +153,7 @@ test.describe("Regular users flow, part two", () => { .getByPlaceholder("tell us what we should know about you") .fill("I am one"); await page.getByRole("button", { name: "SAVE" }).click(); - await waitForBackendOperation(page); + await waitForUILoading(page); await expect(page).toHaveTitle("TAGGR"); }); @@ -162,7 +162,7 @@ test.describe("Regular users flow, part two", () => { `dfx canister call taggr mint_tokens '("jpyii-f2pki-kh72w-7dnbq-4j7h7-yly5o-k3lik-zgk3g-wnfwo-2w6jd-5ae", 500 : nat64)'`, ); await page.goto("/"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByTestId("toggle-user-section").click(); await expect(page.getByTestId("token-balance")).toHaveText("5"); await page.getByTestId("toggle-user-section").click(); @@ -188,10 +188,10 @@ test.describe("Regular users flow, part two", () => { await handleDialog(page, /./, "", async () => { await popup.getByText("SEND").click(); }); - await waitForBackendOperation(page); + await waitForUILoading(page); await page.goto("/"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByTestId("toggle-user-section").click(); await page.waitForFunction( @@ -231,7 +231,7 @@ test.describe("Regular users flow, part two", () => { await popup.getByText("SEND").click(); await page.goto("/"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByTestId("toggle-user-section").click(); await expect(page.getByTestId("token-balance")).toHaveText("4"); }); diff --git a/e2e/test4.spec.ts b/e2e/test4.spec.ts index 660a1c74..bd68c1ed 100644 --- a/e2e/test4.spec.ts +++ b/e2e/test4.spec.ts @@ -1,12 +1,12 @@ -import { test, expect, Page } from "@playwright/test"; -import { exec, mkPwd, transferICP } from "./command"; import { + waitForUILoading, handleDialog, handleDialogSequence, - waitForBackendOperation, pollForCondition, createAuctionBid, } from "./helpers"; +import { test, expect, Page } from "@playwright/test"; +import { exec, mkPwd, transferICP } from "./command"; test.describe.configure({ mode: "serial" }); @@ -21,7 +21,7 @@ test.describe("Report and transfer to user", () => { test("Registration", async () => { await page.goto("/"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); // Registration flow await page.getByRole("button", { name: "SIGN UP" }).click(); await page.getByRole("button", { name: "SEED PHRASE" }).click(); @@ -42,19 +42,19 @@ test.describe("Report and transfer to user", () => { await page.getByRole("button", { name: "CREATE USER" }).click(); await page.getByPlaceholder("alphanumeric").fill("joe"); await page.getByRole("button", { name: "SAVE" }).click(); - await waitForBackendOperation(page); + await waitForUILoading(page); }); test("Create two invites", async () => { await page.goto("/"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.goto("/#/invites"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByRole("button", { name: "CREATE" }).click(); - await waitForBackendOperation(page); + await waitForUILoading(page); await page.waitForURL(/#\/invites/); await page.getByRole("button", { name: "CREATE" }).click(); - await waitForBackendOperation(page); + await waitForUILoading(page); inviteLink1 = await page .getByText(/.*#\/welcome.*/) .first() @@ -67,7 +67,7 @@ test.describe("Report and transfer to user", () => { test("Registration by invite 1 and create a post", async ({ page }) => { await page.goto(inviteLink1); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByRole("button", { name: "SEED PHRASE" }).click(); await page .getByPlaceholder("Enter your seed phrase...") @@ -78,20 +78,20 @@ test.describe("Report and transfer to user", () => { await page.getByRole("button", { name: "CONTINUE" }).click(); await page.getByPlaceholder("alphanumeric").fill("jane"); await page.getByRole("button", { name: "SAVE" }).click(); - await waitForBackendOperation(page); + await waitForUILoading(page); await page.locator("#logo").click(); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByRole("button", { name: "POST" }).click(); await page.locator("textarea").fill("Good stuff"); await page.getByRole("button", { name: "SUBMIT" }).click(); await page.waitForURL(/#\/post\//); - await waitForBackendOperation(page); + await waitForUILoading(page); }); test("Registration by invite 2 and create a post", async ({ page }) => { await page.goto(inviteLink2); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByRole("button", { name: "SEED PHRASE" }).click(); await page .getByPlaceholder("Enter your seed phrase...") @@ -102,20 +102,20 @@ test.describe("Report and transfer to user", () => { await page.getByRole("button", { name: "CONTINUE" }).click(); await page.getByPlaceholder("alphanumeric").fill("kyle"); await page.getByRole("button", { name: "SAVE" }).click(); - await waitForBackendOperation(page); + await waitForUILoading(page); await page.locator("#logo").click(); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByRole("button", { name: "POST" }).click(); await page.locator("textarea").fill("Illigal stuff"); await page.getByRole("button", { name: "SUBMIT" }).click(); await page.waitForURL(/#\/post\//); - await waitForBackendOperation(page); + await waitForUILoading(page); }); test("Mint credits and send to user", async () => { await page.goto("/"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByTestId("toggle-user-section").click(); await handleDialog( @@ -151,7 +151,7 @@ test.describe("Report and transfer to user", () => { expect(creditsBalance).toBeGreaterThanOrEqual(1900); await page.goto("/#/user/jane"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByTestId("profile-burger-menu").click(); let dialogCount = 0; @@ -167,7 +167,7 @@ test.describe("Report and transfer to user", () => { page.on("dialog", dialogHandler); await page.getByRole("button", { name: "SEND CREDITS" }).click(); - await waitForBackendOperation(page, { timeout: 5000 }); + await waitForUILoading(page, { timeout: 5000 }); page.removeListener("dialog", dialogHandler); await expect(page.locator("div:has-text('CREDITS') > code")).toHaveText( @@ -179,23 +179,22 @@ test.describe("Report and transfer to user", () => { page, }) => { await page.goto("/"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByRole("button", { name: "SIGN IN" }).click(); await page.getByRole("button", { name: "SEED PHRASE" }).click(); await page .getByPlaceholder("Enter your seed phrase...") .fill(mkPwd("jane")); await page.getByRole("button", { name: "CONTINUE" }).click(); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.reload(); await page.waitForURL(/#\//); await page.goto("/#/settings"); - await page.waitForLoadState("networkidle"); - await page.getByTestId("mode-selector").waitFor({ state: "visible" }); + await waitForUILoading(page); await page.getByTestId("mode-selector").selectOption("Mining"); await page.getByRole("button", { name: "SAVE" }).click(); - await waitForBackendOperation(page); + await waitForUILoading(page); await createAuctionBid( page, @@ -212,7 +211,7 @@ test.describe("Report and transfer to user", () => { test("Report user", async ({ page }) => { await page.goto("/"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByRole("button", { name: "SIGN IN" }).click(); await page.getByRole("button", { name: "SEED PHRASE" }).click(); await page @@ -221,14 +220,11 @@ test.describe("Report and transfer to user", () => { await page.getByRole("button", { name: "CONTINUE" }).click(); await page.goto("/#/user/kyle"); await page.reload(); - await page.waitForLoadState("networkidle"); - await page - .getByTestId("profile-burger-menu") - .waitFor({ state: "visible", timeout: 2000 }); + await waitForUILoading(page); await page.getByTestId("profile-burger-menu").click(); await page .getByRole("button", { name: "REPORT" }) - .waitFor({ state: "visible", timeout: 2000 }); + .waitFor({ state: "visible" }); await handleDialog( page, @@ -238,7 +234,7 @@ test.describe("Report and transfer to user", () => { await page.getByRole("button", { name: "REPORT" }).click(); }, ); - await waitForBackendOperation(page); + await waitForUILoading(page); }); test("Confirm the report", async () => { @@ -246,7 +242,7 @@ test.describe("Report and transfer to user", () => { await pollForCondition( async () => { await page.reload(); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); return await page .getByText("reported") .isVisible() @@ -262,14 +258,14 @@ test.describe("Report and transfer to user", () => { await expect(page.getByText("reported")).toBeVisible(); await page.goto("/#/user/kyle"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByRole("button", { name: "✅ AGREE" }).click(); - await waitForBackendOperation(page); + await waitForUILoading(page); await pollForCondition( async () => { await page.reload(); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); const rewardsText = await page .locator("div:has-text('REWARDS') > code") .textContent() @@ -290,14 +286,14 @@ test.describe("Report and transfer to user", () => { test("Token transfer to user", async ({ page }) => { await page.goto("/"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByRole("button", { name: "SIGN IN" }).click(); await page.getByRole("button", { name: "SEED PHRASE" }).click(); await page .getByPlaceholder("Enter your seed phrase...") .fill(mkPwd("jane")); await page.getByRole("button", { name: "CONTINUE" }).click(); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByTestId("toggle-user-section").click(); await expect(page.getByTestId("token-balance")).toHaveText("15"); @@ -327,7 +323,7 @@ test.describe("Report and transfer to user", () => { await pollForCondition( async () => { await page.reload(); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await page.getByTestId("toggle-user-section").click(); const balance = await page .getByTestId("token-balance") @@ -342,7 +338,7 @@ test.describe("Report and transfer to user", () => { ); await page.goto("/#/user/joe"); - await page.waitForLoadState("networkidle"); + await waitForUILoading(page); await expect( page.locator("div.db_cell:has-text('TOKENS') > a"), ).toHaveText("5");