diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..996da1a --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# Default reviewer for all changes in this repository. +# See https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners +* @mordamax diff --git a/CHANGELOG.md b/CHANGELOG.md index 455c3b8..7c5b79c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 0.9.0 + +### Changed + +- **Upstream `@novasamatech/*` → `^0.8.0`** ([triangle-js-sdks#179](https://github.com/paritytech/triangle-js-sdks/pull/179)). v0.8 is **wire-incompatible** with v0.7 — a test host built on this release will only talk to products on `@novasamatech/host-api@^0.8.0`. Upgrade your product side in lockstep. Most products don't need code changes if they use `createPapiProvider` for chain access and `@novasamatech/product-react-renderer` for custom chat. The product-side breaking points are documented in [the v0.8 migration guide](https://github.com/paritytech/triangle-js-sdks/blob/release/0.8/docs/migration/v0.8.md): theme subscription struct, `OptionBool` encoding fix (signing + custom renderer), and a handful of variant renames. + +### Breaking changes + +- **Theme subscription** delivers the new `{ name, variant }` struct instead of a flat `'light' | 'dark'`. `setTheme('light' | 'dark')` keeps working as a shorthand (mapped to `{ name: { tag: 'Default', value: undefined }, variant: 'Light' | 'Dark' }`) and now also accepts the full struct so tests can drive custom-named themes (e.g. `setTheme({ name: { tag: 'Custom', value: 'midnight' }, variant: 'Dark' })`). `getTheme()` returns the struct — read `theme.variant` for the previous light/dark value. +- **`AllocatableResource` variant rename**: `BulletInAllowance` → `BulletinAllowance`. Affects tests that hand-build resource-allocation requests. + +### Added + +- **`PaymentLogEntry.purse`** records the optional purse selector from RFC-0017 — `into` on top-ups, `from` on payment requests. Undefined means the product targeted the main purse. +- **`Theme` and `ThemeInput` types** exported from the package root so tests can type their theme assertions. + +### Internal + +- New integration coverage: default + custom-theme struct round-trip, and a purse-selector assertion on the payment log. +- Test product (`test/test-product.ts`) updated for the new theme payload shape and gained a `paymentSmokeWithPurse` helper. + ## 0.8.6 ### Fixed diff --git a/README.md b/README.md index e71dbb9..6eee103 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Lightweight test host for E2E testing embedded Polkadot dapps that use the Spektr host-container protocol (`@novasamatech/host-container`). +> **Upstream contract:** `0.9.x` tracks `@novasamatech/host-api`, `host-container`, and `host-api-wrapper` at `^0.8.0`. v0.8 is wire-incompatible with v0.7 — your product side must be on the same major as the test host. + ## Why Products built with `@novasamatech/host-api-wrapper` (formerly `@novasamatech/product-sdk`) run inside an iframe and communicate with the host via `postMessage`. The SDK injects `window.injectedWeb3.spektr` only when it detects a real parent frame running `@novasamatech/host-container`. @@ -268,6 +270,22 @@ createTestHostFixture({ > > Unmapped identities fall back to production-style derivation (`//Bob//dotnsId/index`). If `accounts: []` (unsigned host), unmapped `getProductAccount` / `getProductAccountAlias` calls return `err(RequestCredentialsErr.NotConnected)`, matching `polkadot-desktop`. Pre-mapped entries in `productAccounts` are still served. +### Theme control + +The host delivers `host_theme_subscribe` as a `{ name, variant }` struct (upstream v0.8). `setTheme('light' | 'dark')` is a shorthand that maps to `{ name: { tag: 'Default', value: undefined }, variant: 'Light' | 'Dark' }`; pass the full struct to exercise custom-named theme branches: + +```ts +await testHost.setTheme('dark'); // shorthand → Default / Dark +await testHost.setTheme({ + name: { tag: 'Custom', value: 'midnight' }, + variant: 'Dark', +}); + +const theme = await testHost.getTheme(); +// theme.variant: 'Light' | 'Dark' +// theme.name.tag: 'Default' | 'Custom' +``` + ### Built-in chains | Chain | Export | diff --git a/forum-post.md b/forum-post.md index c4bdb7a..af8ed4d 100644 --- a/forum-post.md +++ b/forum-post.md @@ -1,3 +1,56 @@ +# host-api-test-sdk 0.9.0 + +Tracks upstream `@novasamatech/*@^0.8.0` ([triangle-js-sdks#179](https://github.com/paritytech/triangle-js-sdks/pull/179)). v0.8 is **wire-incompatible** with v0.7 — there is no compatibility shim, so your product side must be on `@novasamatech/host-api@^0.8.0` too. The [v0.8 migration guide](https://github.com/paritytech/triangle-js-sdks/blob/release/0.8/docs/migration/v0.8.md) lists all the product-side touchpoints; most products that use `createPapiProvider` for chain access and `@novasamatech/product-react-renderer` for custom chat don't need code changes. + +## What changed on our side + +### Theme subscription is a struct now + +The host now delivers a `Theme` struct on `host_theme_subscribe` instead of the flat `'light' | 'dark'` enum: + +```ts +type Theme = { + name: { tag: 'Default'; value: undefined } | { tag: 'Custom'; value: string }; + variant: 'Light' | 'Dark'; +}; +``` + +`setTheme('light' | 'dark')` keeps working as a shorthand — it maps to `{ name: { tag: 'Default', value: undefined }, variant: 'Light' | 'Dark' }`. New: you can pass the full struct to test product branches that read `theme.name`: + +```ts +await testHost.setTheme({ + name: { tag: 'Custom', value: 'midnight' }, + variant: 'Dark', +}); +``` + +`getTheme()` returns the struct — use `theme.variant` where you previously had `'light'/'dark'`. + +### Payment log records the purse selector + +Upstream v0.8 added an optional purse selector to `topUp` (`into`) and `requestPayment` (`from`) per RFC-0017. The test host now surfaces the selector on `PaymentLogEntry.purse`: + +```ts +await testHost.getPaymentLog(); +// → [{ type: 'top-up', amount: 1000n, purse: 7, ... }, +// { type: 'request', amount: 500n, purse: 7, ... }] +``` + +Calls that omit the selector still target the main purse and the log entry's `purse` is `undefined`. + +### Variant rename: `BulletInAllowance` → `BulletinAllowance` + +If you hand-build resource-allocation requests in a test, rename the tag. Products going through the wrapper need no change. + +## What you need to do + +1. Upgrade to `0.9.0` and bump your product's `@novasamatech/host-api` (and related) to `^0.8.0` at the same time. +2. If you call `subscribeTheme(cb)` in your product or `getTheme()` in tests, switch to reading `theme.variant` (note the capitalization: `'Light' | 'Dark'`). Or branch on `theme.name.tag === 'Custom'` if you support custom themes. +3. Grep tests for `BulletInAllowance` and rename to `BulletinAllowance`. +4. Re-verify any custom signing flows (`withSignedTransaction`) and custom chat renderers — the upstream `OptionBool` encoding fix flips `true`/`false` against older peers. The test SDK rides through the upstream fix transparently; you should not need to change code, just re-run your suite. + +--- + # host-api-test-sdk 0.4.0 ## Product account mapping diff --git a/package.json b/package.json index 8b56c06..f6b821f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@parity/host-api-test-sdk", - "version": "0.8.6", + "version": "0.9.0", "description": "Lightweight test host for Spektr product E2E testing — embeds dapps with auto-signing dev accounts, no Docker needed", "license": "MIT", "repository": { @@ -42,11 +42,11 @@ } }, "dependencies": { - "@novasamatech/host-api": "^0.7.9" + "@novasamatech/host-api": "^0.8.0" }, "devDependencies": { - "@novasamatech/host-api-wrapper": "^0.7.9", - "@novasamatech/host-container": "^0.7.9", + "@novasamatech/host-api-wrapper": "^0.8.0", + "@novasamatech/host-container": "^0.8.0", "@polkadot/keyring": "^14.0.0", "@polkadot/types": "^16.0.0", "@polkadot/util": "^14.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb1749c..a78e563 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,18 +9,18 @@ importers: .: dependencies: '@novasamatech/host-api': - specifier: ^0.7.9 - version: 0.7.9 + specifier: ^0.8.0 + version: 0.8.0 '@playwright/test': specifier: '>=1.0.0' version: 1.58.2 devDependencies: '@novasamatech/host-api-wrapper': - specifier: ^0.7.9 - version: 0.7.9(@polkadot/api@16.5.6)(@polkadot/util@14.0.1)(esbuild@0.25.12)(rxjs@7.8.2) + specifier: ^0.8.0 + version: 0.8.0(@polkadot/api@16.5.6)(@polkadot/util@14.0.1)(esbuild@0.25.12)(rxjs@7.8.2) '@novasamatech/host-container': - specifier: ^0.7.9 - version: 0.7.9(esbuild@0.25.12)(rxjs@7.8.2) + specifier: ^0.8.0 + version: 0.8.0(esbuild@0.25.12)(rxjs@7.8.2) '@polkadot/keyring': specifier: ^14.0.0 version: 14.0.1(@polkadot/util-crypto@14.0.1(@polkadot/util@14.0.1))(@polkadot/util@14.0.1) @@ -232,17 +232,17 @@ packages: resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} engines: {node: '>= 20.19.0'} - '@novasamatech/host-api-wrapper@0.7.9': - resolution: {integrity: sha512-Z1bAUz1bPRiwCxrzktI6pd7ne+p33aH1ffzItRlBheWTTWaRRgSFD9COtUYj9yb1KHGNXjj3fNWxoY2DR1/CMg==} + '@novasamatech/host-api-wrapper@0.8.0': + resolution: {integrity: sha512-Y2WfXTop7FdyHG6J0X6PDu+2xYHWFuvBa0sIwuMcx6fzRdv/sOAL83hGArKvvHU1Qrh6vFuxhhCzVGy0O1acdw==} - '@novasamatech/host-api@0.7.9': - resolution: {integrity: sha512-3/dOennEXjLvDCj8S4F+siwhUAHPK4PwY29+W1L8BhzNZ/CRcoAMZ959C39X3bhGIy1Wn93L6SrJz6zo2sH7FA==} + '@novasamatech/host-api@0.8.0': + resolution: {integrity: sha512-3l2IxdQ5n0XoMZCk5KOP1oeLVL9+8GsXt/Ew8PhTeWCs1Pjne4NVDtDVGLzOMtapDx/D9ARO7DsbTbOihCYT4Q==} - '@novasamatech/host-container@0.7.9': - resolution: {integrity: sha512-31oG29RX72+/VoNo3aUzeMLFHf/T5MtrSvSrXEwDt3SF69irE6IxixEdHZUMeJCZj7OBcsCQIPytWoUa/klUFA==} + '@novasamatech/host-container@0.8.0': + resolution: {integrity: sha512-WWSSON1VPOu+nOMoIrfE6O1aldEcR5cmTBDH7NuITVq+pRXS6zmv8V3jrX31pUPBdvHQ1u3rPf/+U6vCNlgbAw==} - '@novasamatech/scale@0.7.9': - resolution: {integrity: sha512-tQimsOkz6zYNInBfHwOVMWyZkUH14WxJ3ezcTpMjMLm/r4x4niUqMWZqWiuqW8kYG4wRFtEBeynIVqknB84NqQ==} + '@novasamatech/scale@0.8.0': + resolution: {integrity: sha512-FKeS98MTESWvybJmE6zIG7+nD0BF/SthsKb6Rc1VhMUAUWHKiRLvF9z7DdIAroLiHotKcpJds4dix2Utu90LVQ==} '@playwright/test@1.58.2': resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} @@ -941,8 +941,8 @@ packages: resolution: {integrity: sha512-Jd0fILWG44a9luj8v5kED4WI+zfkkgwKyRQKItTtlPfEsh7Lznfi1kr8/iZ+XAIss4Qq5GqRB0qtWbaz9ceO/A==} engines: {node: ^18.0.0 || >=20.0.0} - nanoid@5.1.9: - resolution: {integrity: sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw==} + nanoid@5.1.11: + resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==} engines: {node: ^18 || >=20} hasBin: true @@ -1314,9 +1314,9 @@ snapshots: '@noble/hashes@2.2.0': {} - '@novasamatech/host-api-wrapper@0.7.9(@polkadot/api@16.5.6)(@polkadot/util@14.0.1)(esbuild@0.25.12)(rxjs@7.8.2)': + '@novasamatech/host-api-wrapper@0.8.0(@polkadot/api@16.5.6)(@polkadot/util@14.0.1)(esbuild@0.25.12)(rxjs@7.8.2)': dependencies: - '@novasamatech/host-api': 0.7.9 + '@novasamatech/host-api': 0.8.0 '@polkadot-api/json-rpc-provider-proxy': 0.4.0 '@polkadot-api/substrate-bindings': 0.20.2 '@polkadot/extension-inject': 0.63.1(@polkadot/api@16.5.6)(@polkadot/util@14.0.1) @@ -1331,21 +1331,21 @@ snapshots: - supports-color - utf-8-validate - '@novasamatech/host-api@0.7.9': + '@novasamatech/host-api@0.8.0': dependencies: - '@novasamatech/scale': 0.7.9 + '@novasamatech/scale': 0.8.0 nanoevents: 9.1.0 - nanoid: 5.1.9 + nanoid: 5.1.11 neverthrow: 8.2.0 scale-ts: 1.6.1 - '@novasamatech/host-container@0.7.9(esbuild@0.25.12)(rxjs@7.8.2)': + '@novasamatech/host-container@0.8.0(esbuild@0.25.12)(rxjs@7.8.2)': dependencies: '@noble/hashes': 2.2.0 - '@novasamatech/host-api': 0.7.9 + '@novasamatech/host-api': 0.8.0 '@polkadot-api/substrate-client': 0.7.0 nanoevents: 9.1.0 - nanoid: 5.1.9 + nanoid: 5.1.11 neverthrow: 8.2.0 polkadot-api: 2.0.2(esbuild@0.25.12)(rxjs@7.8.2) transitivePeerDependencies: @@ -1355,7 +1355,7 @@ snapshots: - supports-color - utf-8-validate - '@novasamatech/scale@0.7.9': + '@novasamatech/scale@0.8.0': dependencies: '@polkadot-api/utils': 0.4.0 scale-ts: 1.6.1 @@ -1836,7 +1836,7 @@ snapshots: '@noble/hashes': 1.8.0 '@polkadot/networks': 14.0.3 '@polkadot/util': 14.0.3 - '@polkadot/wasm-crypto': 7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.1))) + '@polkadot/wasm-crypto': 7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@14.0.1)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.1))) '@polkadot/wasm-util': 7.5.4(@polkadot/util@14.0.3) '@polkadot/x-bigint': 14.0.3 '@polkadot/x-randomvalues': 14.0.3(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.1)) @@ -1878,11 +1878,11 @@ snapshots: '@polkadot/x-randomvalues': 14.0.3(@polkadot/util@14.0.1)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.1)) tslib: 2.8.1 - '@polkadot/wasm-bridge@7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.1)))': + '@polkadot/wasm-bridge@7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@14.0.1)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.1)))': dependencies: '@polkadot/util': 14.0.3 '@polkadot/wasm-util': 7.5.4(@polkadot/util@14.0.3) - '@polkadot/x-randomvalues': 14.0.3(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.1)) + '@polkadot/x-randomvalues': 14.0.3(@polkadot/util@14.0.1)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.1)) tslib: 2.8.1 '@polkadot/wasm-crypto-asmjs@7.5.4(@polkadot/util@14.0.1)': @@ -1915,14 +1915,14 @@ snapshots: '@polkadot/x-randomvalues': 14.0.3(@polkadot/util@14.0.1)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.1)) tslib: 2.8.1 - '@polkadot/wasm-crypto-init@7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.1)))': + '@polkadot/wasm-crypto-init@7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@14.0.1)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.1)))': dependencies: '@polkadot/util': 14.0.3 - '@polkadot/wasm-bridge': 7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.1))) + '@polkadot/wasm-bridge': 7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@14.0.1)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.1))) '@polkadot/wasm-crypto-asmjs': 7.5.4(@polkadot/util@14.0.3) '@polkadot/wasm-crypto-wasm': 7.5.4(@polkadot/util@14.0.3) '@polkadot/wasm-util': 7.5.4(@polkadot/util@14.0.3) - '@polkadot/x-randomvalues': 14.0.3(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.1)) + '@polkadot/x-randomvalues': 14.0.3(@polkadot/util@14.0.1)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.1)) tslib: 2.8.1 '@polkadot/wasm-crypto-wasm@7.5.4(@polkadot/util@14.0.1)': @@ -1959,15 +1959,15 @@ snapshots: '@polkadot/x-randomvalues': 14.0.3(@polkadot/util@14.0.1)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.1)) tslib: 2.8.1 - '@polkadot/wasm-crypto@7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.1)))': + '@polkadot/wasm-crypto@7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@14.0.1)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.1)))': dependencies: '@polkadot/util': 14.0.3 - '@polkadot/wasm-bridge': 7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.1))) + '@polkadot/wasm-bridge': 7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@14.0.1)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.1))) '@polkadot/wasm-crypto-asmjs': 7.5.4(@polkadot/util@14.0.3) - '@polkadot/wasm-crypto-init': 7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.1))) + '@polkadot/wasm-crypto-init': 7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@14.0.1)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.1))) '@polkadot/wasm-crypto-wasm': 7.5.4(@polkadot/util@14.0.3) '@polkadot/wasm-util': 7.5.4(@polkadot/util@14.0.3) - '@polkadot/x-randomvalues': 14.0.3(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.1)) + '@polkadot/x-randomvalues': 14.0.3(@polkadot/util@14.0.1)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.1)) tslib: 2.8.1 '@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.1)': @@ -2353,7 +2353,7 @@ snapshots: nanoevents@9.1.0: {} - nanoid@5.1.9: {} + nanoid@5.1.11: {} neverthrow@8.2.0: optionalDependencies: diff --git a/src/browser/host-runtime.ts b/src/browser/host-runtime.ts index e36a659..2331018 100644 --- a/src/browser/host-runtime.ts +++ b/src/browser/host-runtime.ts @@ -52,6 +52,8 @@ import type { SigningLogEntry, StatementSubmissionLogEntry, TestHostAPI, + Theme, + ThemeInput, } from "../types.js"; // ── Types ────────────────────────────────────────────────────────── @@ -117,9 +119,21 @@ const paymentStatuses = new Map(); const paymentStatusSubscribers = new Map void>>(); let paymentCounter = 0; let nextNotificationId = 1; -let currentTheme: "light" | "dark" = "light"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const themeSubscribers = new Set<(theme: any) => void>(); +let currentTheme: Theme = { + name: { tag: "Default", value: undefined }, + variant: "Light", +}; +const themeSubscribers = new Set<(theme: Theme) => void>(); + +function normalizeTheme(input: ThemeInput): Theme { + if (input === "light" || input === "dark") { + return { + name: { tag: "Default", value: undefined }, + variant: input === "light" ? "Light" : "Dark", + }; + } + return input; +} let loginBehavior: LoginBehavior = "success"; let isAuthenticated = false; let permissionBehavior: PermissionBehavior = "approve-all"; @@ -1092,6 +1106,7 @@ function setupContainer( type: "top-up", amount: params.amount, source: params.source, + purse: params.into, timestamp: Date.now(), }); paymentBalance += params.amount; @@ -1116,6 +1131,7 @@ function setupContainer( amount: params.amount, destination: params.destination, paymentId, + purse: params.from, timestamp: Date.now(), }); @@ -1373,10 +1389,10 @@ async function init(): Promise { return currentTheme; }, - setTheme(theme: "light" | "dark") { - currentTheme = theme; + setTheme(theme: ThemeInput) { + currentTheme = normalizeTheme(theme); for (const sub of themeSubscribers) { - sub(theme); + sub(currentTheme); } }, diff --git a/src/index.ts b/src/index.ts index b595d8a..5c11c98 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,4 +28,6 @@ export type { SigningLogEntry, TestHostAPI, TestHostServer, + Theme, + ThemeInput, } from './types.js'; diff --git a/src/playwright/fixture.ts b/src/playwright/fixture.ts index 2860741..3bd777b 100644 --- a/src/playwright/fixture.ts +++ b/src/playwright/fixture.ts @@ -1,7 +1,7 @@ import type { Page, FrameLocator } from '@playwright/test'; import { createTestHostServer } from '../server.js'; import { DEFAULT_CHAIN } from '../chains.js'; -import type { ChatBot, ChatMessageLogEntry, ChatRoom, CreateTestHostOptions, DevAccountName, HexString, LoginBehavior, NavigationLogEntry, NotificationLogEntry, PaymentLogEntry, PermissionBehavior, PermissionLogEntry, PreimageEntry, SigningLogEntry, StatementSubmissionLogEntry, TestHostAPI } from '../types.js'; +import type { ChatBot, ChatMessageLogEntry, ChatRoom, CreateTestHostOptions, DevAccountName, HexString, LoginBehavior, NavigationLogEntry, NotificationLogEntry, PaymentLogEntry, PermissionBehavior, PermissionLogEntry, PreimageEntry, SigningLogEntry, StatementSubmissionLogEntry, TestHostAPI, Theme, ThemeInput } from '../types.js'; export interface TestHost { /** The host page (contains the iframe) */ @@ -88,11 +88,19 @@ export interface TestHost { /** Clear all statements */ clearStatements(): Promise; - /** Get the current theme */ - getTheme(): Promise<'light' | 'dark'>; + /** + * Get the current theme as the upstream struct (`{ name, variant }`). + * Use `theme.variant` for the light/dark sub-mode (`'Light' | 'Dark'`). + */ + getTheme(): Promise; - /** Set the theme and notify subscribers */ - setTheme(theme: 'light' | 'dark'): Promise; + /** + * Set the theme and notify subscribers. + * + * Accepts `'light' | 'dark'` (mapped to the host's `Default` theme with + * the matching variant) or the full `{ name, variant }` struct. + */ + setTheme(theme: ThemeInput): Promise; /** Set how the host responds to login requests */ setLoginBehavior(behavior: LoginBehavior): Promise; @@ -269,7 +277,7 @@ export function createTestHostFixture(defaults: TestHostFixtureOptions) { return page.evaluate(() => window.__TEST_HOST__.getTheme()); }, - async setTheme(theme: 'light' | 'dark') { + async setTheme(theme: ThemeInput) { await page.evaluate((t) => window.__TEST_HOST__.setTheme(t), theme); }, diff --git a/src/types.ts b/src/types.ts index a93c254..f298f65 100644 --- a/src/types.ts +++ b/src/types.ts @@ -143,9 +143,30 @@ export interface PaymentLogEntry { source?: unknown; destination?: unknown; paymentId?: string; + /** + * Optional purse selector (RFC-0017). For `'top-up'` this is the `into` purse + * the funds were added to; for `'request'` it's the `from` purse the funds + * came out of. Undefined means the product targeted the main purse. + */ + purse?: number; timestamp: number; } +/** + * Host theme (host_theme_subscribe payload, upstream 0.8). + * + * `name` selects the active theme — `Default` for the host's built-in, + * `Custom` for a named host-specific theme. `variant` is the light/dark + * sub-mode (note the capitalization is `'Light' | 'Dark'`, upstream-aligned). + */ +export type Theme = { + name: { tag: 'Default'; value: undefined } | { tag: 'Custom'; value: string }; + variant: 'Light' | 'Dark'; +}; + +/** Shorthand inputs accepted by `setTheme` — `'light' | 'dark'` map to `{ name: Default, variant: Light/Dark }`. */ +export type ThemeInput = 'light' | 'dark' | Theme; + /** * Controls how the test host responds to remote permission requests. * - `'approve-all'` — auto-approve every request (default) @@ -231,10 +252,21 @@ export interface TestHostAPI { clearStatements(): void; // ── Theme ────────────────────────────────────────────────── - /** Get the current theme. */ - getTheme(): 'light' | 'dark'; - /** Set the theme and notify subscribers. */ - setTheme(theme: 'light' | 'dark'): void; + /** + * Get the current theme as the upstream struct + * (`{ name: { tag, value }, variant }`). Use `theme.variant` for the + * light/dark sub-mode (note the capitalization: `'Light' | 'Dark'`). + */ + getTheme(): Theme; + /** + * Set the theme and notify subscribers. + * + * Accepts either a string shorthand (`'light' | 'dark'` — mapped to the + * host's `Default` theme with the matching variant) or the full + * `{ name, variant }` struct (e.g. to test product branches that read + * `theme.name`). + */ + setTheme(theme: ThemeInput): void; // ── Login / auth ─────────────────────────────────────────── /** Set how the host responds to login requests (RFC-0009). */ diff --git a/test/integration.spec.ts b/test/integration.spec.ts index 699f7fc..390a425 100644 --- a/test/integration.spec.ts +++ b/test/integration.spec.ts @@ -1061,16 +1061,52 @@ test.describe('Theme', () => { }); await page.waitForTimeout(100); - const themes = await product.evaluate(() => window.__TEST_PRODUCT__.getReceivedThemes()); + const themes = await product.evaluate(() => window.__TEST_PRODUCT__.getReceivedThemes()) as Array<{ name: { tag: string; value?: string }; variant: 'Light' | 'Dark' }>; expect(themes.length).toBeGreaterThanOrEqual(1); - expect(themes[0]).toBe('light'); + expect(themes[0]).toEqual({ name: { tag: 'Default', value: undefined }, variant: 'Light' }); - // Host changes theme + // Host changes theme using the shorthand await page.evaluate(() => window.__TEST_HOST__.setTheme('dark')); await page.waitForTimeout(100); - const updated = await product.evaluate(() => window.__TEST_PRODUCT__.getReceivedThemes()); - expect(updated).toContain('dark'); + const updated = await product.evaluate(() => window.__TEST_PRODUCT__.getReceivedThemes()) as Array<{ name: { tag: string; value?: string }; variant: 'Light' | 'Dark' }>; + expect(updated.at(-1)).toEqual({ name: { tag: 'Default', value: undefined }, variant: 'Dark' }); + + await product.evaluate(() => (window as any).__themeSub.unsubscribe()); + } finally { + await host.close(); + } + }); + + test('theme subscribe delivers a custom-named theme struct', async ({ page }) => { + const host = await createTestHostServer({ + productUrl: productServer.url, + accounts: ['alice'], + }); + + try { + const product = await loadHostAndProduct(page, host.url, productServer.url); + + // Drive a non-default theme from the host before the product subscribes. + await page.evaluate(() => window.__TEST_HOST__.setTheme({ + name: { tag: 'Custom', value: 'midnight' }, + variant: 'Dark', + })); + + await product.evaluate(() => { + (window as any).__themeSub = window.__TEST_PRODUCT__.subscribeTheme(); + }); + await page.waitForTimeout(100); + + const themes = await product.evaluate(() => window.__TEST_PRODUCT__.getReceivedThemes()) as Array<{ name: { tag: string; value?: string }; variant: 'Light' | 'Dark' }>; + expect(themes.at(-1)).toEqual({ + name: { tag: 'Custom', value: 'midnight' }, + variant: 'Dark', + }); + + // getTheme also returns the struct on the host side. + const current = await page.evaluate(() => window.__TEST_HOST__.getTheme()); + expect(current).toEqual({ name: { tag: 'Custom', value: 'midnight' }, variant: 'Dark' }); await product.evaluate(() => (window as any).__themeSub.unsubscribe()); } finally { @@ -1221,7 +1257,7 @@ test.describe('Resource allocation', () => { const result = await product.evaluate(() => window.__TEST_PRODUCT__.requestResourceAllocation([ { tag: 'StatementStoreAllowance', value: undefined }, - { tag: 'BulletInAllowance', value: undefined }, + { tag: 'BulletinAllowance', value: undefined }, { tag: 'SmartContractAllowance', value: 0 }, { tag: 'AutoSigning', value: undefined }, ])); @@ -1450,6 +1486,36 @@ test.describe('Payments', () => { expect(log[1].type).toBe('request'); expect(log[1].amount).toBe(500n); expect(log[1].paymentId).toBe(result.paymentId); + // Default (no purse selector) → purse is undefined in the log. + expect(log[0].purse).toBeUndefined(); + expect(log[1].purse).toBeUndefined(); + } finally { + await host.close(); + } + }); + + test('purse selector is recorded on top-up and request', async ({ page }) => { + const host = await createTestHostServer({ + productUrl: productServer.url, + accounts: ['alice'], + }); + + try { + const product = await loadHostAndProduct(page, host.url, productServer.url); + + const dest = '0x' + 'bb'.repeat(32); + const result = await product.evaluate( + ({ d, p }: { d: string; p: number }) => window.__TEST_PRODUCT__.paymentSmokeWithPurse(d, p), + { d: dest, p: 7 }, + ); + expect(result.ok).toBe(true); + + const log = await page.evaluate(() => window.__TEST_HOST__.getPaymentLog()); + expect(log).toHaveLength(2); + expect(log[0].type).toBe('top-up'); + expect(log[0].purse).toBe(7); + expect(log[1].type).toBe('request'); + expect(log[1].purse).toBe(7); } finally { await host.close(); } diff --git a/test/test-product.ts b/test/test-product.ts index 1d9f6a0..4fd33f2 100644 --- a/test/test-product.ts +++ b/test/test-product.ts @@ -64,7 +64,7 @@ declare global { clearReceivedStatements(): void; // v0.7+ additions subscribeTheme(): { unsubscribe(): void }; - getReceivedThemes(): string[]; + getReceivedThemes(): unknown[]; deriveEntropy(keyHex: string): Promise; requestLogin(reason?: string): Promise; getUserId(): Promise; @@ -76,6 +76,7 @@ declare global { createTransaction(dotnsId: string, index: number): Promise; createTransactionLegacy(publicKeyHex: string): Promise; paymentSmoke(destinationHex: string): Promise; + paymentSmokeWithPurse(destinationHex: string, purse: number): Promise; statementCreateProofAuthorized(dataHex: string): Promise; signRawProduct(dotnsId: string, index: number, payloadHex: string): Promise; subscribeBalance(): { unsubscribe(): void }; @@ -89,7 +90,7 @@ declare global { const receivedChatActions: unknown[] = []; const receivedStatements: unknown[] = []; -const receivedThemes: string[] = []; +const receivedThemes: unknown[] = []; const receivedBalances: string[] = []; // bigint serialised as string const receivedStatuses: Array<{ type: string; reason?: string }> = []; @@ -424,7 +425,7 @@ async function init() { subscribeTheme() { const sub = hostApi.themeSubscribe(enumValue('v1', undefined), (payload: unknown) => { const p = payload as { tag?: string; value?: unknown }; - if (p?.tag === 'v1') receivedThemes.push(String(p.value)); + if (p?.tag === 'v1') receivedThemes.push(p.value); }); return { unsubscribe() { sub.unsubscribe(); } }; }, @@ -578,6 +579,17 @@ async function init() { } }, + // Same as paymentSmoke but targets an explicit purse on both legs (RFC-0017). + async paymentSmokeWithPurse(destinationHex: string, purse: number) { + try { + await paymentManager.topUp(1000n, { type: 'productAccount', derivationIndex: 0 }, purse); + const req = await paymentManager.requestPayment(500n, hexToU8a(destinationHex), purse); + return { ok: true, paymentId: req.id }; + } catch (err) { + return { ok: false, error: extractError(err) }; + } + }, + // ── Statement store proof (authorized — host allowance slot) ───── async statementCreateProofAuthorized(dataHex: string) { try {