diff --git a/.changes/20260718_cardano_wasm_demo_tests.yml b/.changes/20260718_cardano_wasm_demo_tests.yml new file mode 100644 index 0000000000..f80cfedd43 --- /dev/null +++ b/.changes/20260718_cardano_wasm_demo_tests.yml @@ -0,0 +1,9 @@ +project: cardano-wasm + +pr: 1318 + +kind: + - test + +description: | + Demo: elm-test suite and Node wasm API regression suite, both wired into the WASM CI workflow. diff --git a/.github/workflows/haskell-wasm.yml b/.github/workflows/haskell-wasm.yml index 9505acd1b0..e2f3c2cb11 100644 --- a/.github/workflows/haskell-wasm.yml +++ b/.github/workflows/haskell-wasm.yml @@ -236,9 +236,16 @@ jobs: - name: Build wallet demo run: | cd cardano-wasm/demo - elm-format --validate src/ + elm-format --validate src/ tests/ + elm-test ./build.sh --wasm-dir ../lib-wrapper --out dist + - name: Demo wasm API regression tests + run: | + cd cardano-wasm/demo/tests/wasm + npm install + node api-regression.mjs + - name: Upload wallet demo uses: actions/upload-artifact@v4 with: diff --git a/cardano-wasm/demo/.gitignore b/cardano-wasm/demo/.gitignore index 15b6cec46d..0db34f500e 100644 --- a/cardano-wasm/demo/.gitignore +++ b/cardano-wasm/demo/.gitignore @@ -1,2 +1,3 @@ dist/ elm-stuff/ +tests/wasm/node_modules/ diff --git a/cardano-wasm/demo/README.md b/cardano-wasm/demo/README.md index e69ab05bb6..ee850753cb 100644 --- a/cardano-wasm/demo/README.md +++ b/cardano-wasm/demo/README.md @@ -52,6 +52,7 @@ python3 -m http.server -d dist 8080 # any static server works ``` src/ the Elm application web/ page shell, CSS, and the JS glue that drives the cardano-wasm API +tests/ elm-test unit suite and the Node wasm API regression suite build.sh compiles the Elm app and assembles a servable directory ``` @@ -61,6 +62,9 @@ from Elm over HTTPS. ## Development -- Source is `elm-format`-canonical; CI validates it (`elm-format --validate src/`). -- CI builds the demo in the *Haskell CI (WASM)* workflow and the GitHub Pages workflow - publishes the result. +- Source is `elm-format`-canonical; CI validates it (`elm-format --validate src/ tests/`). +- Unit tests for the pure core (bech32, ada ↔ lovelace, balance arithmetic): `elm-test`. +- API regression tests against the built wasm library: see + [`tests/wasm/README.md`](tests/wasm/README.md). +- CI runs all of the above in the *Haskell CI (WASM)* workflow and the GitHub Pages + workflow publishes the built demo. diff --git a/cardano-wasm/demo/elm.json b/cardano-wasm/demo/elm.json index 3eb265e2ac..cf876b89ad 100644 --- a/cardano-wasm/demo/elm.json +++ b/cardano-wasm/demo/elm.json @@ -21,7 +21,11 @@ } }, "test-dependencies": { - "direct": {}, - "indirect": {} + "direct": { + "elm-explorations/test": "2.2.1" + }, + "indirect": { + "elm/random": "1.0.0" + } } } diff --git a/cardano-wasm/demo/tests/CoreTest.elm b/cardano-wasm/demo/tests/CoreTest.elm new file mode 100644 index 0000000000..94d8b08ecb --- /dev/null +++ b/cardano-wasm/demo/tests/CoreTest.elm @@ -0,0 +1,590 @@ +module CoreTest exposing (suite) + +{-| Pure-Elm unit tests for the demo's helper modules: Bech32, Format, the +pure parts of State (computeBalance, certificates, the generic list helpers), +the Blockfrost decoders, and the certificate JSON sent to cardano-wasm. + +Golden bech32 vectors are real preprod pools, verified against Koios. + +-} + +import Bech32 +import Blockfrost +import Bytes +import Bytes.Decode +import Expect +import Format +import Hex +import Json.Decode as D +import Json.Encode as E +import State +import Test exposing (Test, describe, test) +import Types exposing (Balance(..), CertAction(..), Loadable(..), Msg(..), Network(..), OutputAmount(..)) +import Update +import Wasm + + +suite : Test +suite = + describe "core helpers" + [ bech32Tests + , formatTests + , stateSelectionTests + , hexTests + , blockfrostTests + , computeBalanceTests + , certTests + , poolListTests + , listHelperTests + ] + + + +-- BECH32 + + +{-| ( bech32 pool id, expected hex ) — verified against Koios. +-} +poolVectors : List ( String, String ) +poolVectors = + [ ( "pool1547tew8vmuj0g6vj3k5jfddudextcw6hsk2hwgg6pkhk7lwphe6" + , "a57cbcb8ecdf24f469928da924b5bc6e4cbc3b57859577211a0daf6f" + ) + , ( "pool174mw7e20768e8vj4fn8y6p536n8rkzswsapwtwn354dckpjqzr8" + , "f576ef654ff68f93b2554cce4d0691d4ce3b0a0e8742e5ba71a55b8b" + ) + , ( "pool1z22x50lqsrwent6en0llzzs9e577rx7n3mv9kfw7udwa2rf42fa" + , "12946a3fe080dd99af599bfff10a05cd3de19bd38ed85b25dee35dd5" + ) + , ( "pool1wn6a6f23ctq06udwhw27ravdpd6zcr7jlut3yez0wzdackz3222" + , "74f5dd2551c2c0fd71aebb95e1f58d0b742c0fd2ff1712644f709bdc" + ) + ] + + +bech32Tests : Test +bech32Tests = + describe "Bech32.bech32ToHex" + [ describe "golden vectors (preprod pools)" + (List.map + (\( bech, hex ) -> + test bech <| + \_ -> Bech32.bech32ToHex bech |> Expect.equal (Just hex) + ) + poolVectors + ) + , test "uppercase input decodes the same (lowercased internally)" <| + \_ -> + Bech32.bech32ToHex + (String.toUpper "pool1547tew8vmuj0g6vj3k5jfddudextcw6hsk2hwgg6pkhk7lwphe6") + |> Expect.equal + (Just "a57cbcb8ecdf24f469928da924b5bc6e4cbc3b57859577211a0daf6f") + , test "empty string is rejected" <| + \_ -> Bech32.bech32ToHex "" |> Expect.equal Nothing + , test "\"hello world\" is rejected (no separator, chars outside charset)" <| + \_ -> Bech32.bech32ToHex "hello world" |> Expect.equal Nothing + , test "\"pool1\" is rejected (fewer than 6 data symbols)" <| + \_ -> Bech32.bech32ToHex "pool1" |> Expect.equal Nothing + , test "a one-character typo is rejected (the checksum is verified)" <| + \_ -> + Bech32.bech32ToHex "pool1447tew8vmuj0g6vj3k5jfddudextcw6hsk2hwgg6pkhk7lwphe6" + |> Expect.equal Nothing + , test "a valid checksum under another prefix is rejected (BIP-173 test vector)" <| + \_ -> + Bech32.bech32ToHex "abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw" + |> Expect.equal Nothing + , test "a valid 28-byte payload under another prefix is rejected" <| + \_ -> + Bech32.bech32ToHex "stake1547tew8vmuj0g6vj3k5jfddudextcw6hsk2hwgg6pkhk7sft67d" + |> Expect.equal Nothing + , test "a pool id whose payload is not 28 bytes is rejected" <| + \_ -> + Bech32.bech32ToHex "pool1547tew8vmuj0g6vj3k5jfddudextcw6hj5z3p9" + |> Expect.equal Nothing + , test "a non-zero padding bit is rejected (payload otherwise valid)" <| + \_ -> + Bech32.bech32ToHex "pool1547tew8vmuj0g6vj3k5jfddudextcw6hsk2hwgg6pkhklzc4zyg" + |> Expect.equal Nothing + , test "mixed case is rejected" <| + \_ -> + Bech32.bech32ToHex "Pool1547tew8vmuj0g6vj3k5jfddudextcw6hsk2hwgg6pkhk7lwphe6" + |> Expect.equal Nothing + ] + + + +-- FORMAT + + +formatTests : Test +formatTests = + describe "Format" + [ describe "adaToLovelace" + [ test "\"1.5\" parses to 1500000" <| + \_ -> Format.adaToLovelace "1.5" |> Expect.equal (Just 1500000) + , test "\"0\" parses to 0" <| + \_ -> Format.adaToLovelace "0" |> Expect.equal (Just 0) + , test "\" 2 \" is trimmed and parses to 2000000" <| + \_ -> Format.adaToLovelace " 2 " |> Expect.equal (Just 2000000) + , test "\"1,5\" (comma) is rejected" <| + \_ -> Format.adaToLovelace "1,5" |> Expect.equal Nothing + , test "\"abc\" is rejected" <| + \_ -> Format.adaToLovelace "abc" |> Expect.equal Nothing + , test "\"\" is rejected" <| + \_ -> Format.adaToLovelace "" |> Expect.equal Nothing + , test "one lovelace as decimals" <| + \_ -> Format.adaToLovelace "0.000001" |> Expect.equal (Just 1) + , test "more than six decimals is rejected" <| + \_ -> Format.adaToLovelace "1.2345678" |> Expect.equal Nothing + , test "scientific notation is rejected" <| + \_ -> Format.adaToLovelace "1e3" |> Expect.equal Nothing + , test "negative amounts are rejected" <| + \_ -> Format.adaToLovelace "-1.5" |> Expect.equal Nothing + ] + , describe "lovelaceToAda" + [ test "1500000 → \"1.5\"" <| + \_ -> Format.lovelaceToAda 1500000 |> Expect.equal "1.5" + , test "969750 → \"0.96975\"" <| + \_ -> Format.lovelaceToAda 969750 |> Expect.equal "0.96975" + , test "0 → \"0\"" <| + \_ -> Format.lovelaceToAda 0 |> Expect.equal "0" + , test "-1500000 → \"-1.5\"" <| + \_ -> Format.lovelaceToAda -1500000 |> Expect.equal "-1.5" + , test "1000000 → \"1\" (no trailing dot or zeros)" <| + \_ -> Format.lovelaceToAda 1000000 |> Expect.equal "1" + , test "whole part beyond 32 bits (Elm's // would wrap)" <| + \_ -> Format.lovelaceToAda 3000000000000000 |> Expect.equal "3000000000" + , test "first whole-ADA value past the 32-bit boundary" <| + \_ -> Format.lovelaceToAda 2147483648000000 |> Expect.equal "2147483648" + ] + , describe "amountError" + [ test "\"\" is not an error (empty is allowed)" <| + \_ -> Format.amountError "" |> Expect.equal False + , test "\"1.5\" is not an error" <| + \_ -> Format.amountError "1.5" |> Expect.equal False + , test "\"1,5\" is an error (unparseable)" <| + \_ -> Format.amountError "1,5" |> Expect.equal True + , test "\"0\" is an error (not positive)" <| + \_ -> Format.amountError "0" |> Expect.equal True + , test "\"-2\" is an error (negative)" <| + \_ -> Format.amountError "-2" |> Expect.equal True + ] + , describe "round-trip adaToLovelace ∘ lovelaceToAda" + (List.map + (\v -> + test (String.fromInt v ++ " lovelace survives the round-trip") <| + \_ -> + Format.adaToLovelace (Format.lovelaceToAda v) + |> Expect.equal (Just v) + ) + [ 1, 999999, 1000000, 969750, 123456789, 3000000000000000 ] + ) + ] + + + +-- STATE SELECTION & OUTPUTS + + +stateSelectionTests : Test +stateSelectionTests = + describe "State selection & outputs" + [ describe "selectedInputs" + [ test "an outpoint listed by duplicate wallets is counted once" <| + \_ -> + let + utxo = + { txId = "aa", txIx = 0, lovelace = 5000000, selected = True, hasAssets = False } + + mkWallet i = + { id = i + , alias = "w" ++ String.fromInt i + , address = "addr_test1dup" + , keys = { paymentVKey = "", paymentSKey = "", stakeVKey = "", stakeSKey = "", paymentKeyHash = "", stakeKeyHash = "" } + , utxos = Loaded { utxos = [ utxo ], truncated = False } + , expanded = False + , color = "#123456" + } + + model = + State.init { keyDeposit = 2000000, coinsPerUtxoByte = 4310 } |> Tuple.first + in + State.selectedInputs { model | wallets = [ mkWallet 1, mkWallet 2 ] } + |> List.map (\( w, u ) -> ( w.id, ( u.txId, u.txIx ) )) + |> Expect.equal [ ( 1, ( "aa", 0 ) ) ] + ] + , describe "outputsComplete: explicit outputs respect min-UTxO" + (let + baseModel = + State.init { keyDeposit = 2000000, coinsPerUtxoByte = 4310 } |> Tuple.first + + withAmount s = + { baseModel | outputs = [ { address = "addr_test1x", alias = "a", amount = Lovelace s } ] } + in + [ test "below min-UTxO is incomplete" <| + \_ -> State.outputsComplete (withAmount "0.5") |> Expect.equal False + , test "exactly min-UTxO (0.96975) is complete" <| + \_ -> State.outputsComplete (withAmount "0.96975") |> Expect.equal True + , test "a change-only draft stays complete" <| + \_ -> + State.outputsComplete + { baseModel | outputs = [ { address = "addr_test1x", alias = "a", amount = Change } ] } + |> Expect.equal True + , test "amountBelowMin flags parseable-but-small, not invalid" <| + \_ -> + ( State.amountBelowMin baseModel "0.5" + , State.amountBelowMin baseModel "abc" + ) + |> Expect.equal ( True, False ) + ] + ) + ] + + + +-- HEX + + +hexTests : Test +hexTests = + describe "Hex round-trip" + [ test "wasm-shaped hex survives hexToBytes ∘ bytesToHex" <| + \_ -> Hex.bytesToHex (bytesToList (Hex.hexToBytes "84a400818258")) |> Expect.equal "84a400818258" + , test "uppercase input decodes; re-encoding is lowercase" <| + \_ -> Hex.bytesToHex (bytesToList (Hex.hexToBytes "DEADBEEF")) |> Expect.equal "deadbeef" + , test "a trailing odd nibble is dropped (documented: wasm hex is always even)" <| + \_ -> Hex.bytesToHex (bytesToList (Hex.hexToBytes "abc")) |> Expect.equal "ab" + ] + + + +-- BLOCKFROST DECODERS + + +blockfrostTests : Test +blockfrostTests = + describe "Blockfrost decoders" + [ test "decodes lovelace and flags native tokens" <| + \_ -> + D.decodeString Blockfrost.utxosDecoder utxoBody + |> Expect.equal + (Ok + [ { txId = "abcd" + , txIx = 1 + , lovelace = 5000000 + , selected = False + , hasAssets = True + } + ] + ) + , test "a malformed lovelace quantity fails the decode (never a silent 0)" <| + \_ -> + D.decodeString Blockfrost.utxosDecoder + """[{"tx_hash":"ab","output_index":0,"amount":[{"unit":"lovelace","quantity":"12x"}]}]""" + |> okOrErr + |> Expect.equal "Err" + , test "a negative lovelace quantity fails the decode" <| + \_ -> + D.decodeString Blockfrost.utxosDecoder + """[{"tx_hash":"ab","output_index":0,"amount":[{"unit":"lovelace","quantity":"-5"}]}]""" + |> okOrErr + |> Expect.equal "Err" + , test "Blockfrost's own not-found body is recognised" <| + \_ -> + Blockfrost.isBlockfrostNotFound + """{"status_code":404,"error":"Not Found","message":"The requested component has not been found."}""" + |> Expect.equal True + , test "an arbitrary 404 page is not recognised as Blockfrost's not-found" <| + \_ -> + Blockfrost.isBlockfrostNotFound "404 not found" + |> Expect.equal False + , test "pools: ticker, hex and stats decode from a canonical row" <| + \_ -> + D.decodeString Blockfrost.poolsDecoder poolBody + |> Expect.equal + (Ok + [ { idBech32 = "pool1547tew8vmuj0g6vj3k5jfddudextcw6hsk2hwgg6pkhk7lwphe6" + , idHex = "a57cbcb8ecdf24f469928da924b5bc6e4cbc3b57859577211a0daf6f" + , ticker = Just "IOG1" + , liveStake = 123456789 + , saturation = 0.5 + } + ] + ) + , test "pools: null metadata means no ticker, not a failed page" <| + \_ -> + D.decodeString Blockfrost.poolsDecoder + """[{"pool_id":"pool1x","hex":"aa","metadata":null,"live_stake":"1","live_saturation":0.1}]""" + |> Result.map (List.map .ticker) + |> Expect.equal (Ok [ Nothing ]) + , test "pools: a missing hex fails the decode (it is load-bearing)" <| + \_ -> + D.decodeString Blockfrost.poolsDecoder + """[{"pool_id":"pool1x","metadata":null,"live_stake":"1","live_saturation":0.1}]""" + |> okOrErr + |> Expect.equal "Err" + ] + + + +-- TEST HELPERS & FIXTURES + + +bytesToList : Bytes.Bytes -> List Int +bytesToList bs = + Bytes.Decode.decode (byteListDecoder (Bytes.width bs)) bs |> Maybe.withDefault [] + + +byteListDecoder : Int -> Bytes.Decode.Decoder (List Int) +byteListDecoder n = + Bytes.Decode.loop ( n, [] ) + (\( k, acc ) -> + if k <= 0 then + Bytes.Decode.succeed (Bytes.Decode.Done (List.reverse acc)) + + else + Bytes.Decode.map (\b -> Bytes.Decode.Loop ( k - 1, b :: acc )) Bytes.Decode.unsignedInt8 + ) + + +okOrErr : Result e a -> String +okOrErr r = + case r of + Ok _ -> + "Ok" + + Err _ -> + "Err" + + +utxoBody : String +utxoBody = + """[{"tx_hash":"abcd","output_index":1,"amount":[{"unit":"lovelace","quantity":"5000000"},{"unit":"deadbeef646561646265656664656164","quantity":"999999999999999999999999"}]}]""" + + +{-| One canonical /pools/extended row (the fields the decoder does not read +included on purpose, as Blockfrost sends them). +-} +poolBody : String +poolBody = + """[{"pool_id":"pool1547tew8vmuj0g6vj3k5jfddudextcw6hsk2hwgg6pkhk7lwphe6","hex":"a57cbcb8ecdf24f469928da924b5bc6e4cbc3b57859577211a0daf6f","active_stake":"0","live_stake":"123456789","live_saturation":0.5,"blocks_minted":12,"margin_cost":0.05,"fixed_cost":"340000000","declared_pledge":"1000000","metadata":{"ticker":"IOG1","name":"Example","description":null,"homepage":null}}]""" + + + +-- COMPUTE BALANCE + + +{-| Shorthand: a transaction with no deposit and the given fee. +-} +bal : Int -> Int -> Int -> Balance +bal inputs outputs fee = + State.computeBalance minUtxo { inputs = inputs, outputs = outputs, deposit = 0, fee = fee } + + +minUtxo : Int +minUtxo = + State.adaOnlyMinUtxo { keyDeposit = 2000000, coinsPerUtxoByte = 4310 } + + +computeBalanceTests : Test +computeBalanceTests = + describe "State.computeBalance" + [ test "adaOnlyMinUtxo is 969750 (assumed by the cases below)" <| + \_ -> minUtxo |> Expect.equal 969750 + , test "inputs exactly cover outputs+fee → Balanced 0" <| + \_ -> bal 5000000 4800000 200000 |> Expect.equal (Balanced 0) + , test "short by 1 → Insufficient 1" <| + \_ -> bal 5000000 4800001 200000 |> Expect.equal (Insufficient 1) + , test "change of exactly minUtxo → Balanced minUtxo" <| + \_ -> bal (5000000 + minUtxo) 4800000 200000 |> Expect.equal (Balanced minUtxo) + , test "change of minUtxo - 1 → DustChange" <| + \_ -> + bal (5000000 + minUtxo - 1) 4800000 200000 + |> Expect.equal (DustChange (minUtxo - 1) minUtxo) + , test "change of 1 → DustChange 1" <| + \_ -> bal 5000001 4800000 200000 |> Expect.equal (DustChange 1 minUtxo) + , test "big change → Balanced" <| + \_ -> bal 100000000 4800000 200000 |> Expect.equal (Balanced 95000000) + , test "deposit counts against the change" <| + \_ -> + State.computeBalance minUtxo + { inputs = 5000000, outputs = 2000000, deposit = 2800000, fee = 200000 } + |> Expect.equal (Balanced 0) + ] + + + +-- CERTIFICATES + + +certTests : Test +certTests = + let + base = + State.init { keyDeposit = 2000000, coinsPerUtxoByte = 4310 } |> Tuple.first + + ref = + { bech32 = "pool1547tew8vmuj0g6vj3k5jfddudextcw6hsk2hwgg6pkhk7lwphe6" + , hex = "a57cbcb8ecdf24f469928da924b5bc6e4cbc3b57859577211a0daf6f" + , ticker = Nothing + } + + wallet = + { id = 1 + , alias = "w1" + , address = "addr_test1x" + , keys = { paymentVKey = "", paymentSKey = "", stakeVKey = "", stakeSKey = "", paymentKeyHash = "", stakeKeyHash = "5ca1ab1e" } + , utxos = NotAsked + , expanded = False + , color = "#123456" + } + + withCert action = + { base | wallets = [ wallet ], certs = [ { wallet = 1, action = action } ] } + in + describe "certificates" + [ describe "State.depositTotal signs" + [ test "Register adds the deposit" <| + \_ -> State.depositTotal (withCert Register) |> Expect.equal 2000000 + , test "Register + delegate adds the deposit once" <| + \_ -> State.depositTotal (withCert (RegisterAndDelegate ref)) |> Expect.equal 2000000 + , test "Delegate only is deposit-neutral" <| + \_ -> State.depositTotal (withCert (DelegateOnly ref)) |> Expect.equal 0 + , test "Unregister refunds the deposit" <| + \_ -> State.depositTotal (withCert Unregister) |> Expect.equal -2000000 + ] + , describe "State.walletCertAction menu codes" + (List.map + (\( action, code ) -> + test ("code \"" ++ code ++ "\"") <| + \_ -> State.walletCertAction 1 (withCert action) |> Expect.equal code + ) + [ ( Register, "reg" ) + , ( RegisterAndDelegate ref, "deleg" ) + , ( DelegateOnly ref, "delegonly" ) + , ( Unregister, "unreg" ) + ] + ++ [ test "reads the queried wallet's certificate, not the first" <| + \_ -> + let + model = + { base | certs = [ { wallet = 1, action = Register }, { wallet = 2, action = Unregister } ] } + in + ( State.walletCertAction 2 model, State.walletCertAction 3 model ) + |> Expect.equal ( "unreg", "" ) + ] + ) + , describe "Wasm.certJson" + [ test "register + delegate expands to two certificates, registration first" <| + \_ -> + Wasm.certJson (withCert (RegisterAndDelegate ref)) { wallet = 1, action = RegisterAndDelegate ref } + |> List.map (E.encode 0) + |> Expect.equal + [ """{"action":"register","stakeKeyHash":"5ca1ab1e","deposit":2000000}""" + , """{"action":"delegate","stakeKeyHash":"5ca1ab1e","poolId":"a57cbcb8ecdf24f469928da924b5bc6e4cbc3b57859577211a0daf6f"}""" + ] + , test "the delegation carries the hex pinned at pick time" <| + \_ -> + Wasm.certJson (withCert (DelegateOnly ref)) { wallet = 1, action = DelegateOnly ref } + |> List.map (E.encode 0) + |> List.map (D.decodeString (D.field "poolId" D.string)) + |> Expect.equal [ Ok ref.hex ] + , test "a bare registration emits exactly the registration certificate" <| + \_ -> + Wasm.certJson (withCert Register) { wallet = 1, action = Register } + |> List.map (E.encode 0) + |> Expect.equal + [ """{"action":"register","stakeKeyHash":"5ca1ab1e","deposit":2000000}""" ] + , test "an unregistration carries the refund equal to the deposit" <| + \_ -> + Wasm.certJson (withCert Unregister) { wallet = 1, action = Unregister } + |> List.map (E.encode 0) + |> Expect.equal + [ """{"action":"unregister","stakeKeyHash":"5ca1ab1e","deposit":2000000}""" ] + , test "a plain delegation sends no deposit field" <| + \_ -> + Wasm.certJson (withCert (DelegateOnly ref)) { wallet = 1, action = DelegateOnly ref } + |> List.map (E.encode 0) + |> List.map (D.decodeString (D.field "deposit" D.int) >> okOrErr) + |> Expect.equal [ "Err" ] + ] + ] + + + +-- POOL LIST + + +poolListTests : Test +poolListTests = + let + base = + State.init { keyDeposit = 2000000, coinsPerUtxoByte = 4310 } |> Tuple.first + in + describe "GotPools landing guard" + (let + page = + Ok { pools = [], hasMore = False } + + landedPools msg = + Update.update msg { base | pools = Loading } |> Tuple.first |> .pools + in + [ test "a reply for another network is dropped (init network is Preview)" <| + \_ -> landedPools (GotPools Preprod 1 page) |> Expect.equal Loading + , test "a reply for another page is dropped" <| + \_ -> landedPools (GotPools Preview 2 page) |> Expect.equal Loading + , test "the reply for the asked network and page lands" <| + \_ -> landedPools (GotPools Preview 1 page) |> Expect.equal (Loaded { pools = [], hasMore = False }) + , test "an error reply for the asked network and page lands as Failed" <| + \_ -> landedPools (GotPools Preview 1 (Err "boom")) |> Expect.equal (Failed "boom") + , test "clicking a page sets the stamp the reply is checked against" <| + \_ -> + let + after = + Update.update (ClickPoolPage 2) (State.setCurrentKey "k" base) |> Tuple.first + in + ( after.poolPage, after.pools ) |> Expect.equal ( 2, Loading ) + ] + ) + + + +-- LIST HELPERS + + +listHelperTests : Test +listHelperTests = + describe "State list helpers" + [ describe "removeAt" + [ test "index 0 removes the head" <| + \_ -> State.removeAt 0 [ 1, 2, 3 ] |> Expect.equal [ 2, 3 ] + , test "middle index" <| + \_ -> State.removeAt 1 [ 1, 2, 3 ] |> Expect.equal [ 1, 3 ] + , test "last index" <| + \_ -> State.removeAt 2 [ 1, 2, 3 ] |> Expect.equal [ 1, 2 ] + , test "out-of-range index leaves the list unchanged" <| + \_ -> State.removeAt 5 [ 1, 2, 3 ] |> Expect.equal [ 1, 2, 3 ] + , test "negative index leaves the list unchanged" <| + \_ -> State.removeAt -1 [ 1, 2, 3 ] |> Expect.equal [ 1, 2, 3 ] + ] + , describe "updateAt" + [ test "in-range index applies the function to that element only" <| + \_ -> State.updateAt 1 ((*) 10) [ 1, 2, 3 ] |> Expect.equal [ 1, 20, 3 ] + , test "out-of-range index leaves the list unchanged" <| + \_ -> State.updateAt 7 ((*) 10) [ 1, 2, 3 ] |> Expect.equal [ 1, 2, 3 ] + ] + , describe "distinct" + [ test "no duplicates: order preserved" <| + \_ -> State.distinct [ 3, 1, 2 ] |> Expect.equal [ 3, 1, 2 ] + , test "duplicates: keeps the first occurrence of each ([3,1,3,2,1] → [3,1,2])" <| + \_ -> State.distinct [ 3, 1, 3, 2, 1 ] |> Expect.equal [ 3, 1, 2 ] + , test "empty list" <| + \_ -> + let + empty : List Int + empty = + [] + in + State.distinct empty |> Expect.equal empty + ] + ] diff --git a/cardano-wasm/demo/tests/wasm/README.md b/cardano-wasm/demo/tests/wasm/README.md new file mode 100644 index 0000000000..7e3093f134 --- /dev/null +++ b/cardano-wasm/demo/tests/wasm/README.md @@ -0,0 +1,19 @@ +# cardano-wasm API regression suite + +Drives the real built wasm library (`../../../lib-wrapper/`) in Node and checks the +API surface the demo depends on: wallet generate/restore round-trip, `getTxId` +stability across witnesses, certificate round-trips (register / delegate / +unregister), `inspectAddress`, and a probe that notices when the `newConwayTx` +export gets fixed upstream. + +Build the wasm library first (see `../../../README.md`), then: + +```bash +npm install # fetches the WASI shim used to run the wasm under Node +node api-regression.mjs +``` + +Needs Node ≥ 22.7 (the wrapper is a plain `.js` ES module, resolved by Node's +syntax detection); the `nix develop .#demo` shell provides a suitable one. + +Prints one PASS/FAIL line per group; exits non-zero on failure. diff --git a/cardano-wasm/demo/tests/wasm/api-regression.mjs b/cardano-wasm/demo/tests/wasm/api-regression.mjs new file mode 100644 index 0000000000..40123bb1c9 --- /dev/null +++ b/cardano-wasm/demo/tests/wasm/api-regression.mjs @@ -0,0 +1,269 @@ +// Regression test suite for the built cardano-wasm library (../../../lib-wrapper). +// Run: npm install && node api-regression.mjs (from this directory; Node >= 22.7, e.g. from `nix develop .#demo`) + +import { readFile } from "node:fs/promises"; +import { createInitializer } from "../../../lib-wrapper/main.js"; +import { WASI } from "@bjorn3/browser_wasi_shim"; + +const DUMMY_TXID = + "be6efd42a3d7b9a00d09d77a5d41e55ceaf0bd093a8aa8a893ce70d9caafd978"; +const DUMMY_POOL = "0".repeat(56); +const HEX64 = /^[0-9a-f]{64}$/; + +// ---------------------------------------------------------------- harness + +const results = []; + +async function test(name, fn) { + try { + await fn(); + results.push({ name, ok: true }); + console.log(`PASS ${name}`); + } catch (e) { + results.push({ name, ok: false }); + console.log(`FAIL ${name}: ${e && e.message ? e.message : e}`); + } +} + +function assert(cond, msg) { + if (!cond) throw new Error(msg); +} + +// ------------------------------------------------------------------ setup + +const load = async (imp) => + WebAssembly.instantiate( + await readFile( + new URL("../../../lib-wrapper/cardano-wasm.wasm", import.meta.url) + ), + imp + ); + +const api = await createInitializer( + async () => new WASI([], [], [], { debug: false }), + load, + () => ({}) +)(); + +const { default: pparams } = await import("../../web/pparams.js"); + +// Builds a minimal tx (one input, one output to `addr`) ready for fee work. +async function baseTx(addr) { + const tx = await api.tx.newTx(); + tx.addTxInput(DUMMY_TXID, 0).addSimpleTxOut(addr, 5_000_000n); + return tx; +} + +// ------------------------------------------------------------ 1. generate + +let testnetWallet, mainnetWallet, testnetAddr, mainnetAddr; + +await test("generate", async () => { + testnetWallet = await api.wallet.testnet.generateStakeWallet(2); + testnetAddr = await testnetWallet.getAddressBech32(); + assert( + testnetAddr.startsWith("addr_test1"), + `testnet address should start with addr_test1, got: ${testnetAddr}` + ); + mainnetWallet = await api.wallet.mainnet.generateStakeWallet(); + mainnetAddr = await mainnetWallet.getAddressBech32(); + assert( + mainnetAddr.startsWith("addr1"), + `mainnet address should start with addr1, got: ${mainnetAddr}` + ); +}); + +// --------------------------------------------------- 2. restore round-trip + +await test("restore round-trip", async () => { + assert(testnetWallet, "prerequisite: generate must have succeeded"); + const paySkey = await testnetWallet.getBech32ForPaymentSigningKey(); + const stakeSkey = await testnetWallet.getBech32ForStakeSigningKey(); + const restored = await api.wallet.testnet.restoreStakeWalletFromSigningKeyBech32( + 2, + paySkey, + stakeSkey + ); + const restoredAddr = await restored.getAddressBech32(); + assert( + restoredAddr === testnetAddr, + `restored address differs: ${restoredAddr} !== ${testnetAddr}` + ); +}); + +// ----------------------------------------------------------- 3. getTxId + +await test("getTxId", async () => { + assert(testnetWallet, "prerequisite: generate must have succeeded"); + const paySkey = await testnetWallet.getBech32ForPaymentSigningKey(); + const stakeSkey = await testnetWallet.getBech32ForStakeSigningKey(); + + const tx = await baseTx(testnetAddr); + const fee = await tx.estimateMinFee(pparams, 1, 0, 0); + assert(typeof fee === "bigint" && fee > 0n, `bad fee: ${fee}`); + tx.setFee(fee); + + const unsignedId = await tx.getTxId(); + assert( + HEX64.test(unsignedId), + `unsigned txid not 64 lowercase hex: ${unsignedId}` + ); + + const signed = await tx.signWithPaymentKey(paySkey); + const signedId = await signed.getTxId(); + assert( + signedId === unsignedId, + `signed txid differs from unsigned: ${signedId} !== ${unsignedId}` + ); + + signed.alsoSignWithStakeKey(stakeSkey); + const twiceSignedId = await signed.getTxId(); + assert( + twiceSignedId === unsignedId, + `txid changed after stake signature: ${twiceSignedId} !== ${unsignedId}` + ); +}); + +// ------------------------------------------------------ 4. cert round-trips + +// One certificate kind, driven the way the demo drives it: every certificate +// carries the stake credential's witness (registration includes an explicit +// deposit, which requires the credential's witness just like delegation and +// unregistration do), so fees are estimated at payment + stake = 2 witnesses. +async function certRoundTrip(makeCert) { + assert(mainnetWallet, "prerequisite: generate must have succeeded"); + const stakeKeyHash = + await mainnetWallet.getBase16ForStakeVerificationKeyHash(); + const certHex = await makeCert(stakeKeyHash); + assert( + typeof certHex === "string" && certHex.length > 0, + `certificate hex empty or not a string: ${certHex}` + ); + assert( + certHex.includes(stakeKeyHash), + `certificate does not embed the stake key hash: ${certHex}` + ); + + const tx = await baseTx(mainnetAddr); + tx.appendCertificateToTx(certHex); + const feeOneWit = await tx.estimateMinFee(pparams, 1, 0, 0); + const fee = await tx.estimateMinFee(pparams, 2, 0, 0); + assert(typeof fee === "bigint" && fee > 0n, `bad fee: ${fee}`); + assert( + fee > feeOneWit, + `the stake witness should raise the fee: ${fee} <= ${feeOneWit}` + ); + const idBeforeFee = await tx.getTxId(); + tx.setFee(fee); + const idAfterFee = await tx.getTxId(); + assert( + idAfterFee !== idBeforeFee, + "setting the fee should change the (body-hash) txid" + ); + + const paySkey = await mainnetWallet.getBech32ForPaymentSigningKey(); + const stakeSkey = await mainnetWallet.getBech32ForStakeSigningKey(); + const signed = await tx.signWithPaymentKey(paySkey); + const cborOneSig = await signed.txToCbor(); + signed.alsoSignWithStakeKey(stakeSkey); + const cbor = await signed.txToCbor(); + assert( + typeof cbor === "string" && cbor.startsWith("84"), + `tx CBOR should start with 84, got: ${String(cbor).slice(0, 8)}...` + ); + assert( + cbor.includes(certHex), + "signed tx CBOR does not contain the certificate" + ); + assert( + cbor.length > cborOneSig.length, + "the stake signature should enlarge the witness set" + ); +} + +await test("cert round-trip: registration", () => + certRoundTrip((hash) => + api.certificate.mainnetEra.makeStakeAddressRegistrationCertificate( + hash, + 2_000_000n + ) + )); + +await test("cert round-trip: delegation", () => + certRoundTrip((hash) => + api.certificate.mainnetEra.makeStakeAddressStakeDelegationCertificate( + hash, + DUMMY_POOL + ) + )); + +await test("cert round-trip: unregistration", () => + certRoundTrip((hash) => + api.certificate.mainnetEra.makeStakeAddressUnregistrationCertificate( + hash, + 2_000_000n + ) + )); + +// ------------------------------------------------------- 5. inspectAddress + +await test("inspectAddress", async () => { + assert(testnetAddr && mainnetAddr, "prerequisite: generate must have succeeded"); + + const t = await api.inspectAddress(testnetAddr); + assert( + t && t.network === "testnet", + `expected {network:"testnet"}, got: ${JSON.stringify(t)}` + ); + + const m = await api.inspectAddress(mainnetAddr); + assert( + m && m.network === "mainnet", + `expected {network:"mainnet"}, got: ${JSON.stringify(m)}` + ); + + const garbage = await api.inspectAddress("hello world"); + assert(garbage === null, `garbage should be null, got: ${JSON.stringify(garbage)}`); + + const truncated = await api.inspectAddress( + testnetAddr.slice(0, testnetAddr.length - 10) + ); + assert( + truncated === null, + `truncated should be null, got: ${JSON.stringify(truncated)}` + ); + + const empty = await api.inspectAddress(""); + assert(empty === null, `empty string should be null, got: ${JSON.stringify(empty)}`); +}); + +// ------------------------------------- 6. newConwayTx known-broken probe + +await test("newConwayTx known-broken probe", async () => { + assert( + typeof api.tx.newConwayTx === "function", + "api.tx.newConwayTx is no longer advertised on the API" + ); + try { + await api.tx.newConwayTx(); + // Upstream fixed it: don't fail the suite, but make it visible. + console.log( + "NOTE newConwayTx now works upstream — the wasm export was added; " + + "consider testing it properly and removing this probe." + ); + } catch (e) { + const msg = e && e.message ? e.message : String(e); + assert( + msg.includes("is not a function"), + `expected the known missing-export error, got: ${msg}` + ); + } +}); + +// ---------------------------------------------------------------- summary + +const failed = results.filter((r) => !r.ok); +console.log( + `\n${results.length - failed.length}/${results.length} tests passed` +); +process.exit(failed.length === 0 ? 0 : 1); diff --git a/cardano-wasm/demo/tests/wasm/package-lock.json b/cardano-wasm/demo/tests/wasm/package-lock.json new file mode 100644 index 0000000000..39ea09642a --- /dev/null +++ b/cardano-wasm/demo/tests/wasm/package-lock.json @@ -0,0 +1,22 @@ +{ + "name": "cardano-wasm-demo-regression", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cardano-wasm-demo-regression", + "dependencies": { + "@bjorn3/browser_wasi_shim": "0.4.1" + }, + "engines": { + "node": ">=22.7" + } + }, + "node_modules/@bjorn3/browser_wasi_shim": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@bjorn3/browser_wasi_shim/-/browser_wasi_shim-0.4.1.tgz", + "integrity": "sha512-54kpBQX69TZ8I1zyDC8sziv/zPT1zoIadv3CmdIZNZ5WDF1houMjAzRZ3dwWvhXObiEBjOxXyS8Ja7vA0EfGEQ==", + "license": "MIT OR Apache-2.0" + } + } +} diff --git a/cardano-wasm/demo/tests/wasm/package.json b/cardano-wasm/demo/tests/wasm/package.json new file mode 100644 index 0000000000..e604415c20 --- /dev/null +++ b/cardano-wasm/demo/tests/wasm/package.json @@ -0,0 +1,12 @@ +{ + "name": "cardano-wasm-demo-regression", + "private": true, + "type": "module", + "engines": { + "node": ">=22.7" + }, + "description": "Node regression suite for the cardano-wasm JS API, driven by the demo", + "dependencies": { + "@bjorn3/browser_wasi_shim": "0.4.1" + } +}