fix: allow explicit renewal of expired rentals
This commit is contained in:
@@ -144,6 +144,8 @@ impl RpcHandler {
|
||||
struct RentalParams {
|
||||
#[serde(default)]
|
||||
retry_preparation: bool,
|
||||
#[serde(default)]
|
||||
renew_expired: bool,
|
||||
seller_did: String,
|
||||
content_id: String,
|
||||
expected_sha256: String,
|
||||
@@ -209,6 +211,46 @@ impl RpcHandler {
|
||||
price_sats: params.expected_price_sats,
|
||||
viewing_seconds: params.expected_viewing_seconds,
|
||||
};
|
||||
if params.renew_expired {
|
||||
let journal = Journal::open(&self.config.data_dir).await?;
|
||||
let matching = journal
|
||||
.find_buyers(&buyer, transport.seller_did(), ¶ms.content_id)
|
||||
.await?;
|
||||
let settled: Vec<_> = matching
|
||||
.into_iter()
|
||||
.filter(|record| record.phase == crate::content_purchase::BuyerPhase::ReceiptSaved)
|
||||
.collect();
|
||||
anyhow::ensure!(
|
||||
settled.len() <= 1,
|
||||
"Multiple original rentals require recovery"
|
||||
);
|
||||
if let Some(record) = settled.into_iter().next() {
|
||||
let capability = record
|
||||
.receipt()
|
||||
.context("Original rental receipt is missing")?
|
||||
.capability
|
||||
.clone();
|
||||
let original_duration = journal
|
||||
.protocol_envelope("buyer", &record.contract.id)
|
||||
.await?
|
||||
.context("Original rental terms are missing")?
|
||||
.offer
|
||||
.viewing_seconds
|
||||
.context("Original purchase is not a timed rental")?;
|
||||
let (started, expires) = transport
|
||||
.expired_rental_window(&record.contract, &capability, original_duration)
|
||||
.await?;
|
||||
journal
|
||||
.record_rental_expiry(
|
||||
&record.contract,
|
||||
started,
|
||||
expires,
|
||||
original_duration,
|
||||
u64::try_from(chrono::Utc::now().timestamp()).unwrap_or(0),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
match caller::purchase_bound(
|
||||
&self.config.data_dir,
|
||||
&buyer,
|
||||
|
||||
@@ -1167,6 +1167,40 @@ impl Journal {
|
||||
}
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
/// Retire a settled timed-rental receipt only after the authenticated
|
||||
/// seller has reported its immutable viewing window as expired. This is a
|
||||
/// lifecycle transition, not evidence that a permanent copy was cached:
|
||||
/// it merely permits a later, explicitly approved rental operation for the
|
||||
/// same registered title. Incomplete and still-active purchases must remain
|
||||
/// recoverable under their original UUID.
|
||||
pub async fn record_rental_expiry(
|
||||
&self,
|
||||
contract: &Contract,
|
||||
started_at: u64,
|
||||
expires_at: u64,
|
||||
viewing_seconds: u64,
|
||||
now: u64,
|
||||
) -> Result<BuyerRecord> {
|
||||
let mut record = self.bound_buyer(contract).await?;
|
||||
anyhow::ensure!(
|
||||
contract.content_id.starts_with("registered_")
|
||||
&& record.phase == BuyerPhase::ReceiptSaved
|
||||
&& record.receipt.is_some(),
|
||||
"Only a settled timed rental can be retired"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
viewing_seconds > 0
|
||||
&& started_at > 0
|
||||
&& started_at.checked_add(viewing_seconds) == Some(expires_at)
|
||||
&& now >= expires_at,
|
||||
"Original rental viewing period has not ended"
|
||||
);
|
||||
record.phase = BuyerPhase::Delivered;
|
||||
record.validate()?;
|
||||
self.write("buyer", &contract.id, &record).await?;
|
||||
Ok(record)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1365,6 +1399,49 @@ mod tests {
|
||||
assert!(journal.buyer(&expired.id).await.unwrap().is_none());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn only_an_expired_settled_rental_can_be_retired_for_another_period() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut rental = contract();
|
||||
rental.content_id = "registered_film-1".into();
|
||||
let journal = Journal::open(root.path()).await.unwrap();
|
||||
journal.prepare_buyer(&rental, 1100).await.unwrap();
|
||||
let seller = journal.prepare_seller(&rental, 1100).await.unwrap();
|
||||
journal
|
||||
.record_acceptance(&rental, &seller.acceptance().unwrap(), &rental.seller_did)
|
||||
.await
|
||||
.unwrap();
|
||||
let encoded = token(&rental, "rental-expiry");
|
||||
journal.record_token(&rental, &encoded).await.unwrap();
|
||||
journal
|
||||
.record_incoming_token(&rental, &encoded)
|
||||
.await
|
||||
.unwrap();
|
||||
journal
|
||||
.record_settlement(&rental, rental.minimum_net_sats)
|
||||
.await
|
||||
.unwrap();
|
||||
let receipt = journal.issue_receipt(&rental).await.unwrap();
|
||||
journal.record_receipt(&rental, &receipt).await.unwrap();
|
||||
|
||||
assert!(journal
|
||||
.record_rental_expiry(&rental, 2000, 5600, 3600, 5599)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
journal.buyer(&rental.id).await.unwrap().unwrap().phase,
|
||||
BuyerPhase::ReceiptSaved
|
||||
);
|
||||
let retired = journal
|
||||
.record_rental_expiry(&rental, 2000, 5600, 3600, 5600)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(retired.phase, BuyerPhase::Delivered);
|
||||
|
||||
let mut next = rental.clone();
|
||||
next.id = uuid::Uuid::new_v4().to_string();
|
||||
journal.prepare_buyer(&next, 1500).await.unwrap();
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn damaged_records_and_nonregular_targets_are_preserved() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let contract = contract();
|
||||
|
||||
@@ -206,7 +206,10 @@ pub(crate) async fn purchase_bound(
|
||||
.await?;
|
||||
let unresolved: Vec<_> = matching
|
||||
.into_iter()
|
||||
.filter(|record| record.phase != BuyerPhase::Cancelled)
|
||||
.filter(|record| {
|
||||
record.phase != BuyerPhase::Cancelled
|
||||
&& !(expected.is_some() && record.phase == BuyerPhase::Delivered)
|
||||
})
|
||||
.collect();
|
||||
anyhow::ensure!(
|
||||
unresolved.len() <= 1,
|
||||
|
||||
@@ -39,6 +39,60 @@ impl FipsPurchaseTransport {
|
||||
self.retry_preparation = retry;
|
||||
self
|
||||
}
|
||||
|
||||
/// Ask the authenticated seller for the original rental window without
|
||||
/// starting or extending it. The capability never crosses into the app.
|
||||
pub async fn expired_rental_window(
|
||||
&self,
|
||||
contract: &crate::content_purchase::Contract,
|
||||
capability: &str,
|
||||
viewing_seconds: u64,
|
||||
) -> Result<(u64, u64)> {
|
||||
let route = format!(
|
||||
"/content/{}/rental/{}/prepare",
|
||||
contract.content_id, contract.id
|
||||
);
|
||||
let body = serde_json::json!({"capability":capability,"ready_id":null,"retry":false});
|
||||
let (mut response, _) =
|
||||
crate::fips::dial::PeerRequest::new(Some(&self.fips_npub), &self.onion, &route)
|
||||
.require_fips()
|
||||
.single_delivery()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.send_rental_control_json(&self.data_dir, &self.seller_did, &body)
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
response.status().is_success(),
|
||||
"Original rental status is unavailable"
|
||||
);
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await? {
|
||||
anyhow::ensure!(
|
||||
bytes
|
||||
.len()
|
||||
.checked_add(chunk.len())
|
||||
.is_some_and(|n| n <= 16 * 1024),
|
||||
"Rental status response is too large"
|
||||
);
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_slice(&bytes)?;
|
||||
anyhow::ensure!(
|
||||
value["state"] == "expired",
|
||||
"Original rental viewing period is still available"
|
||||
);
|
||||
let started = value["started_at"]
|
||||
.as_u64()
|
||||
.context("Missing rental start")?;
|
||||
let expires = value["expires_at"]
|
||||
.as_u64()
|
||||
.context("Missing rental expiry")?;
|
||||
anyhow::ensure!(
|
||||
value["viewing_seconds"].as_u64() == Some(viewing_seconds)
|
||||
&& started.checked_add(viewing_seconds) == Some(expires),
|
||||
"Seller changed the original rental window"
|
||||
);
|
||||
Ok((started, expires))
|
||||
}
|
||||
async fn post<B: Serialize + Sync, R: DeserializeOwned>(
|
||||
&self,
|
||||
route: &str,
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# Release OTA and ISO from latest `main`
|
||||
|
||||
This is the operator handoff for producing a release from whatever commit is
|
||||
the reviewed tip of `main` at the time. A release is not ready merely because
|
||||
the artifacts build. Every active regression, live-node acceptance, mirror and
|
||||
publication gate below must pass.
|
||||
|
||||
## 1. Freeze an exact reviewed source revision
|
||||
|
||||
Use a fresh clone or clean worktree. Do not build from an UAT node or a dirty
|
||||
tree.
|
||||
|
||||
```bash
|
||||
git switch main
|
||||
git pull --ff-only ngit main
|
||||
git status --short
|
||||
git rev-parse HEAD
|
||||
python3 scripts/check-git-mirrors.py --local --all
|
||||
```
|
||||
|
||||
`git status --short` must be empty. The full mirror audit must show matching
|
||||
advertised branches/tags on ngit and Gitea. Inventory and resolve any unrelated
|
||||
drift; never force-push, delete refs, or rewrite published history merely to
|
||||
make the check green. Record the accepted ngit proposal and merge commit in the
|
||||
release acceptance ledger.
|
||||
|
||||
Read and retain every unchecked item in:
|
||||
|
||||
- `docs/post-1.8.22-regressions-20261001.md`
|
||||
- the current release acceptance ledger and UAT checklist
|
||||
|
||||
Run the source gates and the documented live-node matrix. On a node with
|
||||
installed apps, backend tests must run only through the isolation wrapper:
|
||||
|
||||
```bash
|
||||
scripts/test-backend-isolated.sh
|
||||
tests/release/run.sh
|
||||
python3 scripts/check-app-catalog-drift.py --release --strict
|
||||
```
|
||||
|
||||
Keep source/unit results separate from actual-node acceptance. In particular,
|
||||
prove that paid-file recovery never makes a second payment and that app cleanup
|
||||
preserves wallets, persistent data, and uninstall decisions.
|
||||
|
||||
## 2. Prepare and sign the OTA release
|
||||
|
||||
Choose a new SemVer that has never been published. Do not move or replace an
|
||||
existing release tag. Curate the new top entry in `CHANGELOG.md`, then preview:
|
||||
|
||||
```bash
|
||||
release_version=X.Y.Z-alpha
|
||||
scripts/create-release.sh "$release_version" --dry-run
|
||||
```
|
||||
|
||||
When all gates and review are complete, run the real preparation from clean
|
||||
`main` in an interactive terminal:
|
||||
|
||||
```bash
|
||||
scripts/create-release.sh "$release_version"
|
||||
```
|
||||
|
||||
The script builds the backend, frontend, AIUI and radio tools, creates a signed
|
||||
pending manifest and staged OTA artifacts, commits the release preparation, and
|
||||
creates annotated tag `v$release_version`. During the signing prompt, paste the
|
||||
24-word release master mnemonic **once**, press Enter, then Ctrl-D on the next
|
||||
line. Never put the mnemonic in a command, file, chat, log, or Git. Do not use
|
||||
`RELEASE_MASTER_MNEMONIC` for the normal operator ceremony.
|
||||
|
||||
If an already-prepared pending manifest needs signing on the offline/operator
|
||||
terminal, first ensure the release binary was built from the exact frozen
|
||||
commit, then run:
|
||||
|
||||
```bash
|
||||
bash scripts/sign-manifest.sh \
|
||||
"releases/pending/v${release_version}/manifest.json"
|
||||
```
|
||||
|
||||
The script cryptographically verifies the result against the public release
|
||||
root pinned in the binary. A failed verification is a hard stop.
|
||||
|
||||
Review the preparation commit, tag target, staged artifacts and signature. Push
|
||||
the exact reviewed commit/tag to both publication mirrors according to the
|
||||
ngit-first workflow; do not create independent merges on each platform.
|
||||
|
||||
## 3. Build and sign the installer ISO
|
||||
|
||||
The ISO builder requires clean `main`, matching versions, the annotated tag and
|
||||
the signed live manifest. After OTA publication has safely promoted the live
|
||||
manifest (next section), return to the exact tagged clean tree and run:
|
||||
|
||||
```bash
|
||||
scripts/build-iso-release.sh
|
||||
```
|
||||
|
||||
For a normal release, do **not** pass `--skip-gates` or `--no-qemu`. The command
|
||||
runs the release harness, strict catalog check, artifact checks, ISO build,
|
||||
mount-level smoke test and headless QEMU boot. Keep its final PASS summary as
|
||||
release evidence.
|
||||
|
||||
Sign the generated ISO checksum document in the operator ceremony:
|
||||
|
||||
```bash
|
||||
iso_path=/absolute/path/to/archipelago-${release_version}.iso
|
||||
bash scripts/sign-iso-checksums.sh "$iso_path"
|
||||
core/target/release/archipelago ceremony verify \
|
||||
"$iso_path.sha256.json"
|
||||
sha256sum -c "$iso_path.sha256"
|
||||
```
|
||||
|
||||
The signing script again expects one mnemonic paste, Enter, then Ctrl-D. It
|
||||
does not place the mnemonic on disk.
|
||||
|
||||
## 4. Publish without exposing incomplete updates
|
||||
|
||||
Configure the Gitea remote with a public HTTPS URL and keep its credentials in
|
||||
Git's credential helper, never in the remote URL. Then run:
|
||||
|
||||
```bash
|
||||
scripts/publish-release-assets.sh "$release_version" gitea-vps2
|
||||
```
|
||||
|
||||
This script deliberately pushes the tag first, creates the release, uploads and
|
||||
byte-verifies the OTA assets, and pushes the manifest-bearing `main` last. When
|
||||
the signed ISO files exist, the same command attaches and verifies them. Never
|
||||
manually publish `releases/manifest.json` before its referenced assets are
|
||||
downloadable and verified.
|
||||
|
||||
After publishing, mirror the exact accepted `main` commit and annotated tag to
|
||||
ngit and Gitea, then verify both:
|
||||
|
||||
```bash
|
||||
python3 scripts/check-git-mirrors.py --local \
|
||||
--ref "refs/tags/v${release_version}"
|
||||
python3 scripts/check-git-mirrors.py --local --all
|
||||
scripts/check-release-assets.sh releases/manifest.json
|
||||
```
|
||||
|
||||
A failed push, missing ref, tag-object mismatch, unavailable mirror, incomplete
|
||||
asset, or mismatched byte hash blocks publication. A main-only check is not full
|
||||
historical mirror parity.
|
||||
|
||||
## 5. Canary and final acceptance
|
||||
|
||||
Before announcing the release:
|
||||
|
||||
1. Apply OTA to one non-critical canary from the signed manifest.
|
||||
2. Reboot it and verify management health, signer/native identity, installed and
|
||||
deliberately removed apps, wallet state, persistent app data, terminal,
|
||||
networking/FIPS, and the active regression checklist.
|
||||
3. Exercise rollback/recovery without changing wallets or app data.
|
||||
4. Boot the ISO in QEMU and on representative physical hardware; perform a clean
|
||||
install and an upgrade-path check, then repeat the same acceptance matrix.
|
||||
5. Verify public release downloads and both Git mirrors once more. Record exact
|
||||
commit, annotated tag object, artifact hashes, signer verification, node
|
||||
evidence, ngit proposal/merge, known deferrals and operator acceptance in the
|
||||
release ledger.
|
||||
|
||||
Do not call the release complete if any required live acceptance, signature,
|
||||
mirror, asset, OTA reboot/rollback, or ISO boot/install result is missing.
|
||||
@@ -33,6 +33,15 @@ describe('native rental confirmation', () => {
|
||||
expect(calls[1]![0].params.max_wallet_debit).toBe(10); expect(f.bridge.request.value).toBeNull()
|
||||
expect(f.child.postMessage.mock.lastCall![0].result).toEqual({playbackProtocol:2,handle:'d'.repeat(64),expires_at:null,operation_id:quote.operation_id}); expect(rpc.call.mock.calls.some(([v])=>v.method==='content.playback-start')).toBe(false)
|
||||
})
|
||||
it('forwards an explicit expired-period renewal without changing its reviewed terms', async () => {
|
||||
const f=fixture()
|
||||
await f.bridge.handle({data:{type:'archipelago-rental-request',playbackProtocol:2,id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',offer,renewExpired:true},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
|
||||
await f.bridge.review()
|
||||
const call=rpc.call.mock.calls.find(([v])=>v.method==='content.rental-purchase')![0]
|
||||
expect(call.params.renew_expired).toBe(true)
|
||||
expect(call.params.consent).toBeUndefined()
|
||||
expect(call.params).toMatchObject({content_id:offer.terms.contentId,expected_price_sats:offer.terms.priceSats,expected_viewing_seconds:offer.terms.viewingSeconds})
|
||||
})
|
||||
it('does not dispatch after closing during installation validation', async()=>{
|
||||
const f=fixture(); await f.send(); let release!:(value:unknown)=>void
|
||||
rpc.call.mockImplementationOnce(()=>new Promise(resolve=>{release=resolve}))
|
||||
|
||||
@@ -5,7 +5,7 @@ import { installedOriginMatches } from './useMediaRegistrationBridge'
|
||||
interface FrameContext { appId: () => string; appUrl: () => string; frameWindow: () => Window | null; consentBusy?: () => boolean }
|
||||
export interface RentalOffer { title: string; terms: { nodeDid: string; contentId: string; sha256: string; priceSats: number; viewingSeconds: number } }
|
||||
interface Quote { network: 'mainnet' | 'testnet'; mint_url: string; state: string; operation_id: string; envelope_sha256: string; wallet_debit_sats: number; gross_token_sats: number; seller_net_sats: number; expires_at: number; seller_onion: string }
|
||||
interface Pending { source: Window; origin: string; id: string; offer: RentalOffer; quote?: Quote }
|
||||
interface Pending { source: Window; origin: string; id: string; offer: RentalOffer; renewExpired: boolean; quote?: Quote }
|
||||
type PlaybackAction = 'status' | 'prepare' | 'start'
|
||||
interface PlaybackBinding { duration?: number; startedAt?: number; expiresAt?: number }
|
||||
const readyPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
@@ -99,6 +99,7 @@ export function useRentalPurchaseBridge(context: FrameContext) {
|
||||
result = await rpcClient.call<Quote & { completed_bytes?: number; total_bytes?: number }>({ method: 'content.rental-purchase', params: {
|
||||
seller_did: terms.nodeDid, content_id: terms.contentId, expected_sha256: terms.sha256,
|
||||
expected_price_sats: terms.priceSats, expected_viewing_seconds: terms.viewingSeconds,
|
||||
renew_expired: item.renewExpired,
|
||||
...(!confirm && attempt === 0 ? { retry_preparation: true } : {}),
|
||||
max_wallet_debit: confirm ? item.quote!.wallet_debit_sats : Number.MAX_SAFE_INTEGER,
|
||||
...(confirm ? { consent: { operation_id: item.quote!.operation_id,
|
||||
@@ -192,7 +193,8 @@ export function useRentalPurchaseBridge(context: FrameContext) {
|
||||
|| !Number.isSafeInteger(terms.viewingSeconds) || terms.viewingSeconds < 1) {
|
||||
(event.source as Window).postMessage({ type: 'archipelago-rental-response', id, error: 'Invalid rental terms.' }, event.origin); return
|
||||
}
|
||||
const item: Pending = { source: event.source as Window, origin: event.origin, id, offer: structuredClone(offer) }
|
||||
if (event.data.renewExpired !== undefined && typeof event.data.renewExpired !== 'boolean') return
|
||||
const item: Pending = { source: event.source as Window, origin: event.origin, id, offer: structuredClone(offer), renewExpired: event.data.renewExpired === true }
|
||||
pending = item
|
||||
try { await installed(item); if (current(item)) { request.value = item.offer; phase.value = 'review' } }
|
||||
catch (cause) { reply(item, undefined, String(cause)); if (pending === item) pending = null }
|
||||
|
||||
@@ -176,18 +176,19 @@
|
||||
Find Nodes
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex gap-2 sm:contents">
|
||||
<div class="grid grid-cols-2 gap-2 sm:contents">
|
||||
<button @click="router.push({ name: 'federation', query: { view: 'map' } })" class="mobile-card-action glass-button rounded-lg flex-1 w-full px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11">
|
||||
Map
|
||||
</button>
|
||||
<button
|
||||
@click="refreshActiveTab"
|
||||
:disabled="loadingPeers || loadingRequests"
|
||||
class="mobile-card-action glass-button rounded-lg min-h-11 w-11 flex items-center justify-center text-white/80 hover:text-white disabled:opacity-50"
|
||||
class="mobile-card-action glass-button rounded-lg min-h-11 w-full sm:w-11 flex items-center justify-center text-xs sm:text-sm font-medium text-white/80 hover:text-white disabled:opacity-50"
|
||||
:aria-label="loadingPeers || loadingRequests ? 'Refreshing connected nodes' : 'Refresh connected nodes'"
|
||||
title="Refresh connected nodes"
|
||||
>
|
||||
<svg class="w-4 h-4" :class="{ 'animate-spin': loadingPeers || loadingRequests }" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 11a8 8 0 10-2.4 6M20 4v7h-7" /></svg>
|
||||
<span class="sm:hidden">{{ loadingPeers || loadingRequests ? 'Refreshing…' : 'Refresh' }}</span>
|
||||
<svg class="hidden sm:block w-4 h-4" :class="{ 'animate-spin': loadingPeers || loadingRequests }" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 11a8 8 0 10-2.4 6M20 4v7h-7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -48,21 +48,23 @@ describe('Web5ConnectedNodes', () => {
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it('puts the three federation destinations and icon refresh in the bottom bar', async () => {
|
||||
it('uses full-width paired mobile rows and a text refresh control', async () => {
|
||||
const wrapper = mount(Web5ConnectedNodes)
|
||||
const nav = wrapper.get('nav[aria-label="Node connections"]')
|
||||
expect(nav.text()).toContain('My connections')
|
||||
expect(nav.text()).toContain('Find Nodes')
|
||||
expect(nav.text()).toContain('Map')
|
||||
expect(nav.text()).not.toContain('Network Map')
|
||||
const mobilePrimaryRow = nav.get('div.grid.grid-cols-2')
|
||||
expect(mobilePrimaryRow.findAll('button')).toHaveLength(2)
|
||||
expect(mobilePrimaryRow.findAll('button').every(button => button.classes().includes('w-full'))).toBe(true)
|
||||
const mobileRows = nav.findAll('div.grid.grid-cols-2')
|
||||
expect(mobileRows).toHaveLength(2)
|
||||
expect(mobileRows.every(row => row.findAll('button').length === 2)).toBe(true)
|
||||
expect(mobileRows.flatMap(row => row.findAll('button')).every(button => button.classes().includes('w-full'))).toBe(true)
|
||||
await nav.findAll('button')[0]!.trigger('click')
|
||||
await nav.findAll('button')[1]!.trigger('click')
|
||||
await nav.findAll('button')[2]!.trigger('click')
|
||||
expect(pushRoute.mock.calls.map(([route]) => route.query.view)).toEqual(['connected', 'discover', 'map'])
|
||||
expect(nav.findAll('button')[3]!.attributes('aria-label')).toBe('Refresh connected nodes')
|
||||
expect(nav.findAll('button')[3]!.text()).toBe('Refresh')
|
||||
})
|
||||
|
||||
it('shows a loading state for empty trusted nodes while peers are loading', async () => {
|
||||
|
||||
Reference in New Issue
Block a user