diff --git a/.github/workflows/bridge-maintenance-checks.yml b/.github/workflows/bridge-maintenance-checks.yml index f7c711a..7b7fce2 100644 --- a/.github/workflows/bridge-maintenance-checks.yml +++ b/.github/workflows/bridge-maintenance-checks.yml @@ -4,6 +4,7 @@ on: pull_request: branches: - fix/bridge-033-status-refresh + - fix/desktop-widget-column-fit permissions: contents: read @@ -25,6 +26,15 @@ jobs: run: find wordpress-plugins/calorieapp-identity-bridge -name '*.php' -type f -print0 | xargs -0 -n1 php -l - name: Exercise WordPress page rendering run: php tools/tests/wordpress_site_session_markup.test.php + - name: Preserve non-page responses while correcting donation footers + run: php tools/tests/wordpress_legal_footer_post.test.php + - name: Return successful donation forms to a reloadable page + run: php tools/tests/wordpress_donation_return.test.php + - name: Check the CalorieApp footer and public market data + run: | + php tools/tests/wordpress_page_ending.test.php + node --test tools/tests/wordpress_page_ending.test.mjs + node --test tools/tests/wordpress_embed_loading.test.mjs - name: Exercise the preserved login and site-wide session controls run: node --test tools/tests/calorieapp_embed_readiness.test.mjs tools/tests/wordpress_site_session.test.mjs - name: Check responsive header correction lifecycle diff --git a/contracts/ecosystem/xrpl-dex-assets.v1.json b/contracts/ecosystem/xrpl-dex-assets.v1.json new file mode 100644 index 0000000..bb6580c --- /dev/null +++ b/contracts/ecosystem/xrpl-dex-assets.v1.json @@ -0,0 +1,54 @@ +{ + "schema_version": 1, + "network": "xrpl-mainnet", + "status": "design-only", + "assets": { + "XRP": { + "kind": "native", + "currency": "XRP" + }, + "CAL": { + "kind": "issued", + "currency": "43616C6F72696500000000000000000000000000", + "display_code": "CAL", + "issuer": "rNqGa93B8ewQP9mUwpwqA19SApbf62U7PY" + }, + "RLUSD": { + "kind": "issued", + "currency": "524C555344000000000000000000000000000000", + "display_code": "RLUSD", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De" + } + }, + "pairs": [ + { + "id": "CAL_XRP", + "base": "CAL", + "quote": "XRP", + "directions": ["buy_cal", "sell_cal"], + "amm_account": "rhDx4NgeWz6jzs4TwiwYDmpeF2maJFZBtr", + "verification": "validated-ledger" + }, + { + "id": "CAL_RLUSD", + "base": "CAL", + "quote": "RLUSD", + "directions": ["buy_cal", "sell_cal"], + "amm_account": "rPN26geWz6jzs4TwiwYDmpeF2maJFZBtr", + "verification": "validated-ledger" + }, + { + "id": "XRP_RLUSD", + "base": "XRP", + "quote": "RLUSD", + "directions": ["both"], + "routing_only": true, + "verification": "asset-identities-only" + } + ], + "excluded_labels_pending_issuer_review": [ + "CAL/EUR", + "CAL/USD", + "BTC/CAL" + ] +} diff --git a/contracts/identity-bridge/v1/code-provenance.json b/contracts/identity-bridge/v1/code-provenance.json index 8e63215..a43b1da 100644 --- a/contracts/identity-bridge/v1/code-provenance.json +++ b/contracts/identity-bridge/v1/code-provenance.json @@ -145,6 +145,22 @@ "source_file": "calorieappdraft3.ai", "adaptation": "Original phone-mark vector paths and colours retained; surrounding artboard and border omitted." }, + { + "path": "assets/calorieapp-page-ending.css", + "origin_class": "project-repository-material", + "repository_first_add_commit": "pending-current-change", + "declared_distribution_licence": "GPL-2.0-or-later", + "clearance_status": "contributor-source-and-similarity-review-pending", + "source": "CalorieToken/CalorieApp@4baea3acaeaa14dacc602ae8d6154ab4c7e97836; footer and public XPMarket components only; session/layout controllers excluded" + }, + { + "path": "assets/calorieapp-page-ending.js", + "origin_class": "project-repository-material", + "repository_first_add_commit": "pending-current-change", + "declared_distribution_licence": "GPL-2.0-or-later", + "clearance_status": "contributor-source-and-similarity-review-pending", + "source": "CalorieToken/CalorieApp@4baea3acaeaa14dacc602ae8d6154ab4c7e97836; footer and public XPMarket components only; session/layout controllers excluded" + }, { "path": "assets/calorieapp-site-layout.css", "origin_class": "project-repository-material", @@ -223,6 +239,22 @@ "declared_distribution_licence": "GPL-2.0-or-later", "clearance_status": "contributor-and-source-declaration-pending" }, + { + "path": "includes/class-calorieapp-identity-bridge-market-widget.php", + "origin_class": "project-repository-material", + "repository_first_add_commit": "pending-current-change", + "declared_distribution_licence": "GPL-2.0-or-later", + "clearance_status": "contributor-source-and-similarity-review-pending", + "source": "CalorieToken/CalorieApp@4baea3acaeaa14dacc602ae8d6154ab4c7e97836; footer and public XPMarket components only; session/layout controllers excluded" + }, + { + "path": "includes/class-calorieapp-identity-bridge-page-ending.php", + "origin_class": "project-repository-material", + "repository_first_add_commit": "pending-current-change", + "declared_distribution_licence": "GPL-2.0-or-later", + "clearance_status": "contributor-source-and-similarity-review-pending", + "source": "CalorieToken/CalorieApp@4baea3acaeaa14dacc602ae8d6154ab4c7e97836; footer and public XPMarket components only; session/layout controllers excluded" + }, { "path": "includes/class-calorieapp-identity-bridge-rest.php", "origin_class": "project-repository-material", diff --git a/docs/STEP_3_CYCLE_1.md b/docs/STEP_3_CYCLE_1.md new file mode 100644 index 0000000..63c406b --- /dev/null +++ b/docs/STEP_3_CYCLE_1.md @@ -0,0 +1,83 @@ +# Step 3 — first bundled implementation candidate + +Candidate: Identity Bridge **0.3.27**, based on the uninstalled 0.3.26 work in +PR #133. Keep its base `fix/desktop-widget-column-fit` (#131), ultimately the +accepted maintenance line. Never merge that maintenance history into app main. +The user requested fewer cycles and plugin uploads after supplying 99 mobile +screenshots, including blog detail links and public usecase pages. + +## Included website work + +- Retain the prepared CAL market sizing, compact original shortcut artwork, + Down control on long pages and passive embedded-app startup cover from 0.3.26. +- Apply shared header rules to the public XUMM shortcode immediately, including + the narrow Richlist header; initialize a card inserted after DOM readiness once. +- Wrap Richlist tables for horizontal scrolling without changing any rows, + wallet addresses, sorting or destination links. The wrapper is keyboard reachable. +- Keep blog slider pagination in one horizontally scrollable row. All native + slider controls and the separate blog/article links remain available. +- Refine mobile open-price donation fields/buttons, cart table wrapping and + the checkout terms area's text size. Rules target the actual Brizy/WooCommerce + classes found in the public HTML and preserve the existing design language. +- Extend the existing footer-label correction to full product/cart HTML after + POST. Checkout, AJAX, REST, admin, JSON and fragment responses are excluded. +- Finish a successful open-price donation submission with a GET of the same + product. Existing redirects and the WooCommerce cart-redirect setting win. + Prices, quantities, notices, order state and gateway behavior remain owned + by WooCommerce and the installed donation/payment plugins. + +## Evidence and limits + +Public product HTML posts `wcj_open_price` to the donation product itself. +[WooCommerce 11.0.1's form handler](https://github.com/woocommerce/woocommerce/blob/11.0.1/plugins/woocommerce/includes/class-wc-form-handler.php) +invokes `woocommerce_add_to_cart_redirect` only after a successful add with no +error notices. Without an explicit URL or its optional cart redirect, it can +render the POST response directly. The narrow bridge callback supplies the +same-product GET destination at that success boundary. This addresses a known +resubmission path consistent with the screenshot's `ERR_CACHE_MISS`; it is not +an end-to-end proof of the user's particular browser history. + +The public Xumm payment JavaScript file contained no cancellation handler. +No live checkout was submitted and no payment request was signed, declined or +cancelled in this work. The faded checkout on return therefore remains an +explicit live acceptance item, not a claimed gateway fix. + +The header and layout changes are supported by screenshot and public HTML +inspection. Automated DOM fixtures are not browser rendering. Browser tab +listing failed before inspection, so mobile/desktop appearance remains pending. +The startup cover applies only to the embedded frontend; the separate top-level +Render startup page on the accepted login route remains outside this change. + +## Separate CalorieApp update + +PR #132 against app main retains the prepared product-local portion controls +and CalorieApp wallpaper and adds a bounded food-search deadline correction. +The existing backend may spend 10 seconds on its primary request, 15 on its +fallback, plus queue time, while the old proxy stopped at 18 seconds and the +browser at 20. Search now has a 45-second proxy limit and 50-second browser limit. +Existing provider attempt/rate limits and every identity deadline are preserved. +There are no extra automatic search retries. The captured first failure has no +HTTP trace; the timing defect is demonstrated, its occurrence in that capture +is an inference. App deployment is separate from installing this ZIP. + +## Focused acceptance after review + +1. On a narrow phone, check Home, Richlist, one blog detail and one usecase: + header stays readable, menu is usable, table scrolls, dots do not fill rows, + CAL card is sized correctly and shortcut controls reach the expected place. +2. Enter a small donation, add it once, then use Back/Refresh from the resulting + page. Confirm the cart amount/quantity do not increase. Review checkout terms; + return after closing/declining an unsigned request and check checkout recovers. + Do not treat Close as proof of rejection or an unsigned request as payment. +3. After inactivity, search once for Magnum. Observe completion or a useful + bounded failure. Select a result near the top of a long list; its portion + controls and feedback should stay beside that same product. +4. Repeat the accepted joint login/logout once and check a representative + desktop width. Confirm the original page/browser and both sessions behave + as before. + +The source, behavior and package gates run in the existing PR workflows. +Keep the current installed package available for rollback. This candidate does +not publish prepared page copy, translate content, merge PRs or deploy services. +The second cycle handles the full page/article/usecase content inventory and +remaining source-backed content/asset refinements. diff --git a/docs/STEP_3_DEX_SCOPE.md b/docs/STEP_3_DEX_SCOPE.md new file mode 100644 index 0000000..fb78c3f --- /dev/null +++ b/docs/STEP_3_DEX_SCOPE.md @@ -0,0 +1,87 @@ +# Step 3 DEX scope and pair policy + +Status: design and verified-asset registry prepared; production transaction +submission remains disabled pending legal scope and a separate security review. + +## Initial pair set + +The first-party selector may show only assets identified by both currency code +and issuer in `contracts/ecosystem/xrpl-dex-assets.v1.json`. + +| Pair | User actions | Role | +| --- | --- | --- | +| CAL/XRP | Buy CAL and sell CAL | Primary market | +| CAL/RLUSD | Buy CAL and sell CAL | Primary market | +| XRP/RLUSD | Exchange in either direction | Routing/on-ramp bridge only | + +The CAL/XRP and CAL/RLUSD AMMs were read from a validated XRPL mainnet ledger on +2026-09-08. The application must re-read validated ledger state for every quote; +the recorded pool accounts are identifiers, not a liquidity or price promise. + +CAL/EUR, CAL/USD and BTC/CAL labels were observed in a third-party wallet view, +but are excluded until each issued asset's exact issuer, operator, redemption +claim and current pool state have been reviewed. A familiar ticker or icon is +not sufficient identification on XRPL. + +## User flow + +1. Choose **Buy CAL** or **Sell CAL** before choosing an amount. +2. Select XRP or RLUSD as the other asset. +3. If CAL is to be received, check for the exact CAL trustline and offer the + existing Xaman TrustSet flow when it is missing. +4. Request a fresh, validated-ledger quote. Show the route, expected amount, + minimum received or maximum spent, price impact, trading fee and network fee. +5. Stop when issuer identity, ledger freshness, liquidity or price-impact policy + fails. Never silently fall back to a same-ticker asset from another issuer. +6. Create a bounded transaction request and let the user review and sign it in + Xaman. The website never receives a seed or private key. +7. Report success only after the transaction is validated; otherwise show a + retry-safe failure state without automatic resubmission. + +The initial implementation should use immediate-or-cancel behavior and must not +create resting orders. It must not trade automatically, custody assets, promise +execution, or charge an undisclosed routing fee. + +## Liquidity and conflicts + +Pool participation by a developer, operator, treasury or other project-related +wallet does not make a pool official and does not guarantee price stability, +redemption or continued liquidity. The public interface should use a general, +accurate disclosure: + +> Liquidity may be supplied by project-related or affiliated wallets as well as +> independent participants. Availability, price and execution are not guaranteed. + +Personal wallet addresses and individual balances are not part of the interface +by default. Any legally required conflict disclosure should identify the +relationship and material risk without unnecessarily publishing a natural +person's complete position history. + +Project and personal liquidity activity should remain separately accounted for. +Do not generate artificial volume, coordinate trades to support a quoted price, +or market a displayed APR as expected return. Keep dated, reproducible ledger +snapshots for material project-wallet disclosures. + +## Release gates + +- Legal review classifies the proposed interaction under MiCA and confirms which + regions, disclosures and service relationships are permitted. +- Xaman/API terms are reviewed for transaction-signing use beyond authentication. +- Quote construction, issuer pinning, slippage bounds, idempotency and validated + result handling receive tests and independent security review. +- Mobile buy and sell paths are tested with a dedicated low-value test wallet. +- Production thresholds are configured centrally and fail closed; they are not + inferred from a screenshot or hard-coded marketing figures. + +Until all gates pass, the website may explain the verified pairs and link to an +external market, but must not present its own live mainnet trade button. + +## Regulatory references + +- ESMA MiCA Article 3 definitions: +- ESMA MiCA Article 59 authorisation: +- ESMA MiCA Article 7 marketing communications: +- AFM crypto-party supervision: +- XRPL decentralized exchange: +- Ripple RLUSD token addresses: +- Xaman regional XRP buying options: diff --git a/docs/STEP_3_PRESENTATION_RELEASE.md b/docs/STEP_3_PRESENTATION_RELEASE.md new file mode 100644 index 0000000..87c8c14 --- /dev/null +++ b/docs/STEP_3_PRESENTATION_RELEASE.md @@ -0,0 +1,37 @@ +# Step 3 presentation release + +Candidate: Identity Bridge 0.3.30. + +## Included presentation changes + +- Existing Brizy footers remain stored in place. Singular pages missing a CAL + market card receive one candidate which is deduplicated against an existing + XPMarket/legacy card and positioned before the existing legal footer. +- The Trustline page reuses the installed XUMM Login plugin's existing Xaman + TrustSet route. No Xaman credential or signing implementation is duplicated. +- The Richlist highlights only the row already marked for the authenticated + visitor and adds an accessible jump control. Logged-out visitors receive no + inferred position. +- Contact, donation and Complianz consent controls receive scoped responsive + presentation without changing their submission or consent behavior. +- How to buy becomes a wallet, regional XRP funding, CAL trustline, then buy or + sell path. Unreliable generic DEX links are removed. The issuer-pinned XPMarket + destination remains, and CAL/XRP plus CAL/RLUSD are identified as validated + XRPL AMMs. First-party mainnet signing stays labelled release-gated. + +The display changes are reversible by disabling the companion plugin. They do +not mutate stored Brizy content, create a DEX transaction, publish personal +wallet ownership, or merge the application branch. + +## Third-party mark added in 0.3.28+ + +`assets/calorieapp-site-polish.js` contains the monochrome X logo path from X's +official brand toolkit solely to identify CalorieToken's X profile. The mark is +not project-owned and is not licensed under the plugin's software licence. + +- Source: +- Asset: +- Retrieved: 2026-09-08 + +The original vector geometry is preserved; `currentColor` retains the site's +monochrome icon colour. diff --git a/docs/STEP_3_SESSION_NAVIGATION.md b/docs/STEP_3_SESSION_NAVIGATION.md index fdc209f..81e329a 100644 --- a/docs/STEP_3_SESSION_NAVIGATION.md +++ b/docs/STEP_3_SESSION_NAVIGATION.md @@ -12,7 +12,28 @@ - Prepare the app's matching label and Open Food Facts contribution footer in a separate main-based PR: https://github.com/CalorieToken/CalorieApp/pull/129. -## Maintenance boundary +## Consistency follow-up (0.3.25) + +The original 0.3.22 navigation only modified controls already present at its +first initialization. Version 0.3.25 also detects late Brizy controls, handles +responsive visibility and provides hidden fallback slots that appear only when +a corresponding visible control is missing. Home is omitted on Home; App is +omitted on CalorieApp. Duplicate current/legacy app links are suppressed. + +The new slots use the recorded Home stylesheet geometry: 30% fixed wrappers, +right 81px and bottom 12/66px on desktop, 48px icons; on mobile, 32px icons and +the original -8/25px slots. A viewport guard keeps the new icons inside narrow +screens. Back to top uses the existing project glyph and 120/60px slot. Existing +visible controls retain their Brizy wrappers, placement and icon size. The +provided fallback bar is inert and hidden until the site controller has verified +same-origin navigation settings. Scroll-to-top respects reduced motion. + +The CAL card renderer is available on all public pages. It replaces the old +market node, so late legacy callbacks cannot overwrite the new widget, and +shares one request across cards. The new app footer remains page-specific. +Account, login, logout and header-layout controllers are unchanged from 0.3.24. + +## Original 0.3.22 maintenance boundary Plugin 0.3.22 starts at installed maintenance merge `6f754c837cf80b05422d347b5a43c512aa3c9dea` (0.3.21). This branch must not be merged @@ -21,12 +42,11 @@ startup URL and session validation are preserved. The site-session script moves the same button/status nodes after the embed controller has captured them; it does not register another logout handler for the embedded page. -The navigation script targets existing Brizy icon links in fixed containers. -It does not rewrite inline exchange content, the header logo, other floating -destinations, or create a new floating bar where one is absent. The live Home +The original 0.3.22 navigation script targeted existing Brizy icon links in fixed containers. +It did not create a floating bar where absent. The controls did not rewrite +inline exchange content, the header logo or other floating destinations. Home and FAQ pages have the older exchange shortcut; the current CalorieApp page -has no such Brizy shortcuts. Its current-page rule is ready for any matching -shortcuts subsequently added to that template. +had no such Brizy shortcuts; the 0.3.25 follow-up above fills that gap. ## Logo source @@ -56,11 +76,11 @@ Run: node --test tools/tests/calorieapp_embed_readiness.test.mjs tools/tests/wordpress_site_session.test.mjs tools/tests/wordpress_site_layout.test.mjs tools/tests/wordpress_site_navigation.test.mjs php tools/tests/wordpress_site_session_markup.test.php python -m unittest tools.tests.test_build_wordpress_plugin_release -python tools/build_wordpress_plugin_release.py --expected-version 0.3.22 +python tools/build_wordpress_plugin_release.py --expected-version 0.3.25 ``` CI also lints plugin PHP. The original vector extraction is rendered and visually inspected locally. No candidate is installed by this PR. Native desktop/mobile rendering of the candidate follows the user's installation approval; synthetic DOM checks are not a live browser rendering claim. -The previously observed small-desktop card clipping remains a separate followup. +The small-desktop card correction already installed in 0.3.23 remains unchanged. diff --git a/tools/tests/test_xrpl_dex_asset_registry.py b/tools/tests/test_xrpl_dex_asset_registry.py new file mode 100644 index 0000000..f687e8f --- /dev/null +++ b/tools/tests/test_xrpl_dex_asset_registry.py @@ -0,0 +1,57 @@ +import json +from pathlib import Path + + +REGISTRY_PATH = ( + Path(__file__).resolve().parents[2] + / "contracts" + / "ecosystem" + / "xrpl-dex-assets.v1.json" +) + + +def load_registry() -> dict: + return json.loads(REGISTRY_PATH.read_text(encoding="utf-8")) + + +def test_registry_is_non_executing_and_pins_issued_assets() -> None: + registry = load_registry() + + assert registry["network"] == "xrpl-mainnet" + assert registry["status"] == "design-only" + assert registry["assets"]["CAL"] == { + "kind": "issued", + "currency": "43616C6F72696500000000000000000000000000", + "display_code": "CAL", + "issuer": "rNqGa93B8ewQP9mUwpwqA19SApbf62U7PY", + } + assert registry["assets"]["RLUSD"] == { + "kind": "issued", + "currency": "524C555344000000000000000000000000000000", + "display_code": "RLUSD", + "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", + } + + +def test_primary_pairs_support_buying_and_selling_cal() -> None: + registry = load_registry() + pairs = {pair["id"]: pair for pair in registry["pairs"]} + + assert set(pairs) == {"CAL_XRP", "CAL_RLUSD", "XRP_RLUSD"} + for pair_id in ("CAL_XRP", "CAL_RLUSD"): + assert pairs[pair_id]["directions"] == ["buy_cal", "sell_cal"] + assert pairs[pair_id]["verification"] == "validated-ledger" + assert pairs[pair_id]["amm_account"].startswith("r") + assert pairs["XRP_RLUSD"]["routing_only"] is True + + +def test_unverified_labels_are_not_selectable_pairs() -> None: + registry = load_registry() + selectable = {pair["id"] for pair in registry["pairs"]} + + assert selectable.isdisjoint({"CAL_EUR", "CAL_USD", "BTC_CAL"}) + assert registry["excluded_labels_pending_issuer_review"] == [ + "CAL/EUR", + "CAL/USD", + "BTC/CAL", + ] diff --git a/tools/tests/wordpress_donation_return.test.php b/tools/tests/wordpress_donation_return.test.php new file mode 100644 index 0000000..3d99b1c --- /dev/null +++ b/tools/tests/wordpress_donation_return.test.php @@ -0,0 +1,42 @@ +slug = $slug; } + public function get_slug(): string { return $this->slug; } + public function get_id(): int { return 1761; } +} +require __DIR__ . '/../../wordpress-plugins/calorieapp-identity-bridge/includes/class-calorieapp-identity-bridge.php'; +use CalorieApp\IdentityBridge\Plugin; + +function check(bool $condition, string $message): void { + if (!$condition) throw new RuntimeException($message); +} +$_SERVER['REQUEST_METHOD'] = 'POST'; +$_POST = ['wcj_open_price' => '0.01', 'quantity' => '1', 'add-to-cart' => '1761']; +$before = $_POST; +$donation = new WC_Product(); +check(Plugin::donation_return_url(false, $donation) === get_permalink(1761), 'Use a normal product URL after a successful donation POST.'); +check($_POST === $before, 'Preserve the amount, quantity and original submitted fields.'); +$existing = 'https://calorietoken.net/index.php/cart/'; +check(Plugin::donation_return_url($existing, $donation) === $existing, 'Respect an existing redirect.'); +foreach ([['admin' => true], ['ajax' => true], ['cart_redirect' => 'yes']] as $flags) { + $state = $flags; + check(Plugin::donation_return_url(false, $donation) === false, 'Leave admin, AJAX and configured WooCommerce cart redirects alone.'); +} +$state = []; +foreach ([null, new stdClass(), new WC_Product('merchandise')] as $product) { + check(Plugin::donation_return_url(false, $product) === false, 'Only the donation product qualifies.'); +} +$_SERVER['REQUEST_METHOD'] = 'GET'; +check(Plugin::donation_return_url(false, $donation) === false, 'Do not redirect ordinary product views.'); +$_SERVER['REQUEST_METHOD'] = 'POST'; +$_POST = []; +check(Plugin::donation_return_url(false, $donation) === false, 'Leave unrelated POST handlers alone.'); +echo "Donation return URL and preserved WooCommerce behavior passed.\n"; diff --git a/tools/tests/wordpress_embed_loading.test.mjs b/tools/tests/wordpress_embed_loading.test.mjs new file mode 100644 index 0000000..068e9e1 --- /dev/null +++ b/tools/tests/wordpress_embed_loading.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import test from 'node:test'; +import vm from 'node:vm'; +const source=readFileSync(new URL('../../wordpress-plugins/calorieapp-identity-bridge/assets/calorieapp-page-ending.js',import.meta.url),'utf8'); +const appOrigin='https://app.calorietoken.net'; +function element(attrs={}){return {attrs,hidden:false,textContent:'',events:new Map(), + getAttribute(n){return this.attrs[n]??null;},setAttribute(n,v){this.attrs[n]=String(v);},removeAttribute(n){delete this.attrs[n];}, + addEventListener(n,fn){this.events.set(n,fn);} +};} +function harness({frameOrigin=appOrigin,locale='en'}={}){ + const stage=element({'data-calorieapp-frame-loading':'1','aria-busy':'true'}); + const frame=element({src:frameOrigin+'?embedded=1&locale='+locale});frame.contentWindow={}; + let reloads=0;const set=frame.setAttribute;frame.setAttribute=function(n,v){if(n==='src')reloads++;set.call(this,n,v);}; + const mask=element({'data-slow-message':'Still starting. Wait or try again.'}); + const message=element();message.textContent='Your food log will appear here.'; + const actions=element();actions.hidden=true; + const retry=element(),reveal=element(); + const children=new Map([ + ['[data-calorieapp-frame-stage]',stage],['.calorieapp-embed-frame',frame],['[data-calorieapp-embed-loading]',mask], + ['[data-calorieapp-loading-message]',message],['[data-calorieapp-loading-actions]',actions], + ['[data-calorieapp-loading-retry]',retry],['[data-calorieapp-loading-reveal]',reveal] + ]); + const root=element({'data-app-origin':appOrigin,'data-locale':locale}); + root.querySelector=mask.querySelector=s=>children.get(s)||null; + const events=new Map(),timers=new Map();let timerId=0; + const document={body:{},readyState:'complete',querySelectorAll(s){return s==='[data-calorieapp-embed]'?[root]:[];}}; + const window={ + addEventListener(n,fn){if(!events.has(n))events.set(n,[]);events.get(n).push(fn);}, + setTimeout(fn,ms){timers.set(++timerId,{fn,ms});return timerId;},clearTimeout(id){timers.delete(id);}, + fetch(){throw new Error('The loader must not send authentication or readiness requests.');} + }; + vm.runInNewContext(source,{document,window,URL,Intl}); + return {stage,frame,mask,message,actions,retry,reveal,timers,reloads:()=>reloads, + messageEvent(data={type:'calorieapp:bridge:initialized',locale},origin=appOrigin,sender=frame.contentWindow){for(const fn of events.get('message')||[])fn({origin,source:sender,data});}, + slow(){for(const {fn,ms} of [...timers.values()]){assert.equal(ms,45000);fn();}}, + frameLoad(){frame.events.get('load')?.();} + }; +} +test('provider iframe load does not uncover the app before its existing handshake',()=>{ + const h=harness();h.frameLoad();assert.equal(h.mask.hidden,false);assert.equal(h.stage.getAttribute('data-calorieapp-frame-loading'),'1'); + h.messageEvent();assert.equal(h.mask.hidden,true);assert.equal(h.stage.getAttribute('data-calorieapp-frame-loading'),null); + assert.equal(h.stage.getAttribute('aria-busy'),'false');assert.equal(h.timers.size,0);assert.equal(h.reloads(),0); +}); +test('foreign origins, other frames, wrong locales and unrelated messages cannot dismiss the loader',()=>{ + const h=harness(); + h.messageEvent(undefined,'https://untrusted.example');h.messageEvent(undefined,appOrigin,{}); + h.messageEvent({type:'calorieapp:bridge:initialized',locale:'nl'}); + h.messageEvent({type:'calorieapp:bridge:ready',locale:'en'}); + h.messageEvent({type:'calorieapp:login:complete',locale:'en'}); + assert.equal(h.mask.hidden,false);h.messageEvent();assert.equal(h.mask.hidden,true); +}); +test('a slow start offers manual choices without automatic reloads or authentication requests',()=>{ + const h=harness();h.slow();assert.equal(h.actions.hidden,false);assert.match(h.message.textContent,/Still starting/);assert.equal(h.reloads(),0); + h.retry.events.get('click')();assert.equal(h.reloads(),1);assert.equal(h.actions.hidden,true);assert.equal(h.mask.hidden,false); + h.messageEvent();h.retry.events.get('click')();assert.equal(h.reloads(),1,'No retry can reset a ready app.'); +}); +test('the user can reveal a stalled frame and later readiness remains harmless',()=>{ + const h=harness();h.slow();h.reveal.events.get('click')();assert.equal(h.mask.hidden,true);assert.equal(h.reloads(),0); + h.messageEvent();assert.equal(h.mask.hidden,true); +}); +test('mismatched frame origin is never initialized by the loader',()=>{ + const h=harness({frameOrigin:'https://other.example'});assert.equal(h.mask.getAttribute('data-loading-ready'),null);assert.equal(h.timers.size,0); +}); diff --git a/tools/tests/wordpress_legal_footer_post.test.php b/tools/tests/wordpress_legal_footer_post.test.php new file mode 100644 index 0000000..8b1fd5d --- /dev/null +++ b/tools/tests/wordpress_legal_footer_post.test.php @@ -0,0 +1,44 @@ +start_output_buffer(); + echo $body; + if (ob_get_level() > $level) ob_end_flush(); + return (string) ob_get_clean(); +} +$body = '
' + . '
Chamber of Commerce KVK: 84216352 / © 2023 Calorie Token
'; +foreach (['cart', 'product'] as $page) { + $result = response('POST', [$page => true], $body); + check(str_contains($result, 'Operator: ICTHendrikse · KVK 73774693'), 'Correct the footer after a donation form POST.'); + check(str_contains($result, ''), 'Preserve all form data.'); +} +foreach ([[], ['checkout' => true], ['cart' => true, 'ajax' => true], ['product' => true, 'admin' => true]] as $flags) { + check(response('POST', $flags, $body) === $body, 'Do not rewrite checkout, AJAX, admin or other POST handlers.'); +} +foreach (['GET', 'HEAD'] as $method) { + check(!str_contains(response($method, [], $body), '84216352'), 'Retain existing GET/HEAD footer correction.'); +} +foreach (['', '{"detail":"© 2023 Calorie Token"}', '
© 2023 Calorie Token
'] as $payload) { + check(response('POST', ['product' => true], $payload) === $payload, 'Leave redirect bodies, JSON and fragments untouched.'); +} +echo "Donation POST footer and preserved response boundaries passed.\n"; diff --git a/tools/tests/wordpress_page_ending.test.mjs b/tools/tests/wordpress_page_ending.test.mjs new file mode 100644 index 0000000..d2135f0 --- /dev/null +++ b/tools/tests/wordpress_page_ending.test.mjs @@ -0,0 +1,212 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import vm from 'node:vm'; + +const source = readFileSync(new URL('../../wordpress-plugins/calorieapp-identity-bridge/assets/calorieapp-page-ending.js', import.meta.url), 'utf8'); +const tokenUrl = 'https://xpmarket.com/token/Calorie-rNqGa93B8ewQP9mUwpwqA19SApbf62U7PY'; +function element() { + return { + attrs: {}, textContent: '', hidden: false, children: new Map(), listeners: {}, + classList: { values: new Set(), add(name) { this.values.add(name); }, contains(name) { return this.values.has(name); } }, + closest(selector) { return this.ancestors?.[selector] || null; }, + matches(selector) { return selector.split(',').some(part => this.classList.contains(part.trim().replace(/^\./, ''))); }, + cloneNode() { + const node = this; + return { textContent: this.outsideText || '', querySelectorAll() { return []; }, + querySelector() { return node.outsideControl ? {} : null; } }; + }, + setAttribute(name, value) { this.attrs[name] = String(value); }, + getAttribute(name) { return this.attrs[name] ?? null; }, + querySelector(selector) { return this.children.get(selector) ?? null; }, + addEventListener(name, fn) { this.listeners[name] = fn; }, + set innerHTML(html) { + this.markup = html; + for (const match of html.matchAll(/<[a-z]+\b[^>]*class="([^"]+)"[^>]*>/g)) { + const child = element(); + child.hidden = /\bhidden\b/.test(match[0]); + for (const name of match[1].split(' ')) this.children.set('.' + name, child); + } + }, + }; +} +const good = () => ({ success: true, data: { + code: 'Calorie', issuer: 'rNqGa93B8ewQP9mUwpwqA19SApbf62U7PY', + title: 'Calorie Token', logo: 'https://xpcdn.xpmarket.com/storage/logo/calorie.webp', + price_usd: 0.00000007, price_xrp: 0.00000005, market_cap_usd: 4000, rank: 300, holders: 14000, +} }); +async function settle() { for (let i = 0; i < 10; i++) await Promise.resolve(); } +function run({ response = { ok: true, json: async () => good() }, fetchError, stalled = false, reduced = false, empty = false, endpoint = true, legacy = false, mixedHost = false, mixedColumn = false } = {}) { + const widget = element(); + const widgets = [widget]; + const oldWidget = widget; + const wrapper = element(); + const host = element(); + const inner = element(); + const columnItems = element(); + const column = element(); + const row = element(); + const container = element(); + host.classList.add('brz-wp-shortcode'); wrapper.classList.add('brz-wrapper'); + columnItems.classList.add('brz-column__items'); column.classList.add('brz-columns'); + row.classList.add('brz-row'); container.classList.add('brz-container'); + inner.parentElement = host; host.parentElement = wrapper; wrapper.parentElement = columnItems; + columnItems.parentElement = column; column.parentElement = row; row.parentElement = container; + host.ancestors = { '.brz-wrapper': wrapper }; + if (mixedHost) host.outsideControl = true; + if (mixedColumn) columnItems.outsideText = 'Existing page heading'; + function attachLegacy(node) { + node.nodeType = 1; + node.classList.add('livecoinwatch-widget-1'); + node.ancestors = { '.brz-wp-shortcode': host }; + node.parentElement = inner; + node.matches = () => true; + node.replaceWith = function (replacement) { + replacement.ancestors = this.ancestors; + replacement.parentElement = this.parentElement; + widgets[widgets.indexOf(this)] = replacement; + this.detached = true; + }; + } + if (legacy) attachLegacy(widget); + const track = element(); + track.clientWidth = 400; + track.children.set('a', { getBoundingClientRect: () => ({ width: 200 }) }); + track.moves = []; + track.scrollBy = (options) => track.moves.push(options); + const carousel = element(); + carousel.children.set('.calorieapp-shared-social-track', track); + const buttons = [-1, 1].map((direction) => { + const button = element(); button.setAttribute('data-calorieapp-carousel-direction', direction); return button; + }); + carousel.querySelectorAll = () => buttons; + const requests = []; + const timers = new Map(); + let ready, mutation; + const events = new Map(), frames = []; + const document = { + body: {}, + createElement() { return element(); }, + readyState: 'loading', + addEventListener(name, fn) { if (name === 'DOMContentLoaded') ready = fn; }, + querySelector() { return null; }, + querySelectorAll(selector) { + if (empty) return []; + if (selector === '[data-calorieapp-embed]') return []; + return selector.includes('xpmarket-widget') ? widgets : [carousel]; + }, + }; + const window = { + calorieappPageEnding: endpoint ? { xpMarketWidgetUrl: 'https://calorietoken.net/wp-json/calorieapp/v1/xpmarket-widget', xpMarketTokenUrl: tokenUrl } : {}, + fetch: async (url, options) => { + requests.push({ url, options }); + if (fetchError) throw fetchError; + if (stalled) return new Promise((resolve, reject) => options.signal.addEventListener('abort', () => reject(new Error('aborted')))); + return response; + }, + AbortController, + setTimeout(fn) { timers.set(1, fn); return 1; }, + clearTimeout(id) { timers.delete(id); }, + matchMedia() { return { matches: reduced }; }, + addEventListener(name, fn) { events.set(name, fn); }, + requestAnimationFrame(fn) { frames.push(fn); }, + MutationObserver: class { + constructor(fn) { mutation = fn; } + observe() {} + }, + }; + vm.runInNewContext(source, { window, document, URL, Intl }); + ready(); + return { get widget() { return widgets[0]; }, widgets, oldWidget, host, wrapper, inner, columnItems, column, row, container, buttons, track, requests, timers, ready, + event(name) { events.get(name)?.(); }, + addLate() { + const late = element(); attachLegacy(late); widgets.push(late); + mutation([{ addedNodes: [late] }]); + while (frames.length) frames.shift()(); + return late; + }, + }; +} + +test('public CAL data is shown beside an always-available XPMarket destination', async () => { + const h = run(); await settle(); + assert.equal(h.widget.getAttribute('data-state'), 'ready'); + assert.equal(h.widget.querySelector('.calorieapp-xpmarket-price').textContent, '$0.00000007'); + assert.equal(h.widget.querySelector('.calorieapp-xpmarket-link').getAttribute('href'), tokenUrl); + assert.equal(h.widget.querySelector('.calorieapp-xpmarket-state').textContent, 'XPMarket data'); + assert.equal(h.requests[0].options.credentials, 'omit'); + assert.equal(h.timers.size, 0); + h.ready(); await settle(); + assert.equal(h.requests.length, 1, 'Repeat initialization reuses the request.'); +}); +test('failed and incomplete feeds keep the fallback link without inventing prices', async () => { + for (const options of [ + { response: { ok: false } }, { fetchError: new Error('offline') }, + { response: { ok: true, json: async () => ({ success: false }) } }, { endpoint: false }, + ]) { + const h = run(options); await settle(); + assert.equal(h.widget.getAttribute('data-state'), 'fallback'); + assert.equal(h.widget.querySelector('.calorieapp-xpmarket-state').textContent, 'Open XPMarket'); + assert.equal(h.widget.querySelector('.calorieapp-xpmarket-link').getAttribute('href'), tokenUrl); + assert.equal(h.widget.querySelector('.calorieapp-xpmarket-price').textContent, ''); + } +}); +test('unexpected token data is rejected before filling the card', async () => { + const data = good(); data.data.issuer = 'different-token'; + const h = run({ response: { ok: true, json: async () => data } }); await settle(); + assert.equal(h.widget.getAttribute('data-state'), 'fallback'); +}); +test('a stalled market request is abortable and does not leave a permanent loading state', async () => { + const h = run({ stalled: true }); + h.timers.get(1)(); + assert.equal(h.requests[0].options.signal.aborted, true); + await settle(); + assert.equal(h.widget.getAttribute('data-state'), 'fallback'); +}); +test('social arrows move one item and respect reduced motion', () => { + const h = run({ reduced: true }); + h.buttons[0].listeners.click(); h.buttons[1].listeners.click(); + assert.equal(h.track.moves[0].left, -200); + assert.equal(h.track.moves[1].left, 200); + assert.equal(h.track.moves[1].behavior, 'auto'); +}); +test('pages without market cards do not request market data', () => { + const h = run({ empty: true }); + assert.equal(h.requests.length, 0); +}); + +test('legacy website cards use the same CAL renderer and keep their original slot', async () => { + const h = run({ legacy: true }); await settle(); + assert.equal(h.oldWidget.detached, true); + assert.equal(h.widget.getAttribute('data-state'), 'ready'); + assert.equal(h.widget.querySelector('.calorieapp-xpmarket-price').textContent, '$0.00000007'); + assert.equal(h.host.classList.contains('calorieapp-xpmarket-host'), true); + for (const node of [h.host, h.wrapper, h.columnItems, h.column, h.row]) { + assert.equal(node.classList.contains('calorieapp-xpmarket-layout'), true, 'Normalize the dedicated market column, not only its shortcode.'); + } + assert.equal(h.container.classList.contains('calorieapp-xpmarket-layout'), false, 'Preserve the page section container.'); + h.oldWidget.innerHTML = 'Late old provider callback'; + assert.equal(h.widget.querySelector('.legacy'), null, 'An old provider callback cannot overwrite the replacement.'); +}); +test('a mixed market/account shortcode keeps its account layout', async () => { + const h = run({ legacy: true, mixedHost: true }); await settle(); + assert.equal(h.widget.getAttribute('data-state'), 'ready'); + assert.equal(h.host.classList.contains('calorieapp-xpmarket-host'), false); + assert.equal(h.wrapper.classList.contains('calorieapp-xpmarket-layout'), false); +}); +test('market-only sizing stops before an ancestor with ordinary page content', async () => { + const h = run({legacy: true, mixedColumn: true}); await settle(); + assert.equal(h.wrapper.classList.contains('calorieapp-xpmarket-layout'), true); + for (const node of [h.columnItems, h.column, h.row]) assert.equal(node.classList.contains('calorieapp-xpmarket-layout'), false); +}); +test('late-loaded market cards share the first request without duplicating existing cards', async () => { + const h = run(); await settle(); + const first = h.widget; + const oldLate = h.addLate(); await settle(); + assert.equal(oldLate.detached, true); + assert.equal(h.widgets.length, 2); + assert.equal(h.widgets[0], first); + assert.equal(h.widgets[1].getAttribute('data-state'), 'ready'); + h.event('load'); h.event('pageshow'); await settle(); + assert.equal(h.requests.length, 1); +}); diff --git a/tools/tests/wordpress_page_ending.test.php b/tools/tests/wordpress_page_ending.test.php new file mode 100644 index 0000000..608b710 --- /dev/null +++ b/tools/tests/wordpress_page_ending.test.php @@ -0,0 +1,147 @@ +headers[$name] = $value; } +} +function check($condition, $message): void { if (!$condition) throw new RuntimeException($message); } +function render($object): string { ob_start(); $object->render(); return (string) ob_get_clean(); } +$dir = dirname(__DIR__, 2) . '/wordpress-plugins/calorieapp-identity-bridge/includes/'; +require $dir . 'class-calorieapp-identity-bridge-market-widget.php'; +require $dir . 'class-calorieapp-identity-bridge-page-ending.php'; +use CalorieApp\IdentityBridge\PageEnding; +use CalorieApp\IdentityBridge\MarketWidget; + +$ending = new PageEnding(); +$ending->register_hooks(); +check(isset($actions['wp_footer'][5]), 'Render the ending before footer scripts.'); +$ending->enqueue_assets(); +check(count($styles) === 2 && count($scripts) === 2, 'The independent page-ending and presentation assets are queued.'); +check($scripts['calorieapp-identity-bridge-page-ending'][1] === [], 'Do not load the authentication controller as a dependency.'); +$html = render($ending); +check(substr_count($html, '