From 08c93f4aad35817574a98a3ba10e0fef06680c05 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 6 Oct 2026 11:43:33 -0400 Subject: [PATCH] Unify legacy overlay signing with the queued iframe consent bridge --- docs/post-1.9.0-progress-20261006.md | 41 ++++ .../src/components/AppLauncherOverlay.vue | 2 + .../src/stores/__tests__/appLauncher.test.ts | 85 +++++++ neode-ui/src/stores/appLauncher.ts | 229 +++--------------- tests/lifecycle/native-signer-concurrency.cjs | 29 ++- 5 files changed, 183 insertions(+), 203 deletions(-) diff --git a/docs/post-1.9.0-progress-20261006.md b/docs/post-1.9.0-progress-20261006.md index 2d1d915d..fc470252 100644 --- a/docs/post-1.9.0-progress-20261006.md +++ b/docs/post-1.9.0-progress-20261006.md @@ -635,3 +635,44 @@ Still track the separate legacy `stores/appLauncher.ts` signing handler, which has its own consent implementation; this deployment qualifies the AppSession / shared bridge path. Do not describe every possible app launcher or the physical companion grey-screen report as fully accepted from these checks. + +## IndeeHub durable publication and relay delivery checkpoint + +IndeeHub follow-up commits `c7cf672` and `46f128c` add a shared signed-offer +validator, authorized publication/database outbox transaction and bounded relay +worker. **53 backend tests pass**, including real disposable PostgreSQL and +WebSocket integration: concurrent publishers/workers, ownership/moderation and +registered-term checks, transaction rollback, lease recovery, lost relay ACK, +unchanged signed-event retry and exact acceptance. Backend build passes; **74 +frontend tests** passed after sharing the protocol validator. The disposable DB, +volume and private credentials were removed. No public announcements or wallet +payments were made. See the IndeeHub implementation document for exact logs. + +This is not a deployed IndeeHub source or a finished rental flow. Production +migration/scheduling, trusted node-media registration, authenticated Backstage +integration and settlement-backed FIPS playback remain open. Source inspection +also confirms the existing peer ecash path only creates its durable buyer record +after response headers: lost headers or interruption during minting can leave no +purchase record. It additionally falls back from Cashu to Fedimint after any +Cashu error. Complete purchase-intent/wallet-operation recovery and unambiguous +backend selection are required before reusing that path for rentals. Existing +successful two-node paid-file tests exercised cached delivery recovery, not that +initial mint/response-loss gap. No new paid test was performed here. + +## Legacy overlay signer candidate + +The legacy overlay now reuses `useNostrBridge` rather than maintaining another +single-promise consent implementation. The actual iframe window is registered +by the overlay; another same-origin window cannot drive the signer. Existing +URL-keyed identity selections and remembered consent remain compatible. Closing, +changing URL, replacing the iframe or disposing the store cancels pending work. +The approved completion animation remains unchanged. + +56 focused signer/launcher tests and typecheck pass. The full dashboard suite +passes **1,293 tests across 161 files**, with production build passing. Logs: +`/tmp/archy-legacy-signer-tests.log`, +`/tmp/archy-legacy-signer-full-ui-tests.log`, +`/tmp/archy-legacy-signer-typecheck.log`, +`/tmp/archy-legacy-signer-production-build.log`. +The served-browser harness now covers both AppSession and the legacy overlay at +390/1440px. Deployment/live results will be recorded separately below. diff --git a/neode-ui/src/components/AppLauncherOverlay.vue b/neode-ui/src/components/AppLauncherOverlay.vue index a0ac81a4..73bc97bc 100644 --- a/neode-ui/src/components/AppLauncherOverlay.vue +++ b/neode-ui/src/components/AppLauncherOverlay.vue @@ -229,6 +229,7 @@ interface PaymentRequest { const store = useAppLauncherStore() const closeBtnRef = ref(null) const iframeRef = ref(null) +watch(iframeRef, frame => store.setNostrFrame(frame?.contentWindow || null), { flush: 'post' }) const iframeRefreshKey = ref(0) const isRefreshing = ref(false) const iframeLoading = ref(true) @@ -682,6 +683,7 @@ onMounted(() => { }) onBeforeUnmount(() => { + store.setNostrFrame(null) clearTimers() stopProgress() window.removeEventListener('keydown', onKeyDown, true) diff --git a/neode-ui/src/stores/__tests__/appLauncher.test.ts b/neode-ui/src/stores/__tests__/appLauncher.test.ts index 7ea2ca43..950f67a9 100644 --- a/neode-ui/src/stores/__tests__/appLauncher.test.ts +++ b/neode-ui/src/stores/__tests__/appLauncher.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { setActivePinia, createPinia } from 'pinia' import { __setSignedCatalogForTests } from '@/views/discover/curatedApps' +import { nextTick } from 'vue' // The signed catalog's embedded manifests decide which ports the app gate // fronts (TLS on the same port) — prime the same shape the live catalog @@ -36,6 +37,90 @@ vi.stubGlobal('open', mockWindowOpen) import { useAppLauncherStore, senderMatchesApp } from '../appLauncher' import { useAppStore } from '../app' +describe('legacy overlay native signer lifecycle', () => { + let store: ReturnType + const appUrl = 'http://192.0.2.10:19999' + const identityKey = 'archipelago_app_identity_' + appUrl.replace(/[^a-z0-9]/gi, '_') + const frame = { postMessage: vi.fn() } as unknown as Window + function request(id: string, source = frame, origin = appUrl) { + const event = new MessageEvent('message', { origin, data: { type: 'nostr-request', id, + method: 'signEvent', params: { event: { kind: 27235, content: id } } } }) + Object.defineProperty(event, 'source', { value: source }) + window.dispatchEvent(event) + } + beforeEach(() => { + vi.useFakeTimers() + setActivePinia(createPinia()) + localStorage.clear() + vi.clearAllMocks() + mockRpcCall.mockResolvedValue({ id: 'signed-fixture' }) + Object.defineProperty(window, 'location', { value: { origin: 'http://192.0.2.10', protocol: 'http:', hostname: '192.0.2.10' }, configurable: true }) + Object.defineProperty(window, 'innerWidth', { value: 1024, configurable: true }) + localStorage.setItem(identityKey, JSON.stringify({ id: 'legacy-identity', name: 'Saved creator' })) + store = useAppLauncherStore() + store.open({ url: appUrl, title: 'Custom film app' }) + store.setNostrFrame(frame) + }) + afterEach(async () => { + store.close(); store.$dispose() + await vi.runAllTimersAsync() + vi.useRealTimers() + }) + it('queues simultaneous requests and preserves the saved identity and success animation', async () => { + request('first'); request('second') + expect(store.consentRequest?.content).toBe('first') + expect(store.consentRequest?.identityLabel).toBe('Saved creator') + store.approveConsent(false) + await vi.advanceTimersByTimeAsync(350) + expect(store.consentPhase).toBe('success') + expect(mockRpcCall).toHaveBeenCalledTimes(1) + expect(mockRpcCall).toHaveBeenCalledWith({ method: 'identity.nostr-sign', params: { + id: 'legacy-identity', event: { kind: 27235, content: 'first' } } }) + await vi.advanceTimersByTimeAsync(325) + expect(store.consentRequest?.content).toBe('second') + store.approveConsent(false) + await vi.advanceTimersByTimeAsync(675) + expect(frame.postMessage).toHaveBeenCalledTimes(2) + expect(frame.postMessage).toHaveBeenNthCalledWith(1, { type: 'nostr-response', id: 'first', result: { id: 'signed-fixture' } }, appUrl) + expect(frame.postMessage).toHaveBeenNthCalledWith(2, { type: 'nostr-response', id: 'second', result: { id: 'signed-fixture' } }, appUrl) + expect(store.showConsent).toBe(false) + }) + it('rejects a same-origin window that is not the actual app iframe', async () => { + const other = { postMessage: vi.fn() } as unknown as Window + request('forged-window', other) + await nextTick() + expect(store.showConsent).toBe(false) + expect(mockRpcCall).not.toHaveBeenCalled() + expect(other.postMessage).not.toHaveBeenCalled() + }) + it.each(['close', 'frame', 'url', 'dispose'])('settles all pending requests on %s without signing', async action => { + request('first'); request('second') + if (action === 'close') store.close() + if (action === 'frame') store.setNostrFrame(null) + if (action === 'url') store.url = 'http://192.0.2.10:19998' + if (action === 'dispose') store.$dispose() + await vi.runAllTimersAsync() + expect(mockRpcCall).not.toHaveBeenCalled() + expect(frame.postMessage).toHaveBeenCalledTimes(2) + for (const [response] of vi.mocked(frame.postMessage).mock.calls) expect(response).toHaveProperty('error') + }) + it('rejects identity changes before approval and can reopen after closing', async () => { + request('old-identity') + localStorage.setItem(identityKey, JSON.stringify({ id: 'another-identity', name: 'Other' })) + store.approveConsent(false) + await vi.runAllTimersAsync() + expect(mockRpcCall).not.toHaveBeenCalled() + store.close() + store.open({ url: appUrl, title: 'Custom film app' }) + store.setNostrFrame(frame) + request('reopened') + await vi.advanceTimersByTimeAsync(0) + store.approveConsent(false) + await vi.advanceTimersByTimeAsync(675) + expect(mockRpcCall).toHaveBeenCalledWith(expect.objectContaining({ params: expect.objectContaining({ id: 'another-identity' }) })) + }) +}); + describe('useAppLauncherStore', () => { beforeEach(() => { setActivePinia(createPinia()) diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index 59a46d1f..4a7cb722 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -1,6 +1,6 @@ import { appHasMediaBridge, ensureNodeAppAvailable, nodeAppIsAvailable } from '@/views/discover/curatedApps' import { defineStore } from 'pinia' -import { ref, watch } from 'vue' +import { ref, watch, onScopeDispose } from 'vue' import { rpcClient } from '@/api/rpc-client' import { recordAppLaunch } from '@/utils/appUsage' import { requestExternalOpen } from '@/api/remote-relay' @@ -13,11 +13,8 @@ import { useToast } from '@/composables/useToast' import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro' import type { AppCredential, AppCredentialsResponse } from '@/types/api' import { resolveAppCredentials } from '@/views/apps/appCredentials' -import { - consentKey, - hasRememberedConsent, - rememberConsent, -} from '@/views/appSession/nostrConsent' +import { useNostrBridge } from '@/views/appSession/useNostrBridge' +import type { SelectedIdentity } from '@/views/appSession/useAppIdentity' /** * Open a URL in a new browser tab — but if a companion (phone) is currently @@ -190,16 +187,6 @@ const PORT_TO_APP_ID: Record = { '50002': 'electrumx', } -export interface NostrConsentRequest { - appName: string - method: string - eventKind?: number - content?: string - identityLabel?: string - resolve: (remember: boolean) => void - reject: () => void -} - /** App identity (catalog icon + display name) for the companion's native * branded loader. Undefined when the app isn't in package-data. */ function launchMeta(appId: string): InAppLaunchMeta | undefined { @@ -215,10 +202,6 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { const isOpen = ref(false) const url = ref('') const title = ref('') - const consentRequest = ref(null) - const showConsent = ref(false) - const consentPhase = ref<'review' | 'signing' | 'success' | 'error'>('review') - const consentError = ref('') const credentialPrompt = ref({ show: false, loading: false, @@ -230,9 +213,6 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { }) let pendingCredentialLaunch: { appId: string; path?: string } | null = null let credentialGeneration = 0 - let consentApprovedAt = 0 - let consentGeneration = 0 - let approvedGeneration = 0 let previousActiveElement: HTMLElement | null = null /** Active app in the store-driven session (no route change) */ @@ -526,7 +506,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { } function close() { - if (showConsent.value) denyConsent() + bridge.cancelPending() const toRestore = previousActiveElement previousActiveElement = null isOpen.value = false @@ -542,176 +522,37 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { } } - function approveConsent(remember: boolean) { - if (consentRequest.value) { - consentRequest.value.resolve(remember) - } - consentApprovedAt = Date.now() - approvedGeneration = consentGeneration - consentPhase.value = 'signing' - } - - function denyConsent() { - consentGeneration += 1 - if (consentRequest.value) { - consentRequest.value.reject() - consentRequest.value = null - } - showConsent.value = false - consentPhase.value = 'review' - consentError.value = '' - } - - async function finishConsentSuccess() { - const generation = approvedGeneration - const remaining = Math.max(0, 350 - (Date.now() - consentApprovedAt)) - if (remaining) await new Promise(resolve => setTimeout(resolve, remaining)) - if (generation !== consentGeneration || !showConsent.value) return - consentPhase.value = 'success' - await new Promise(resolve => setTimeout(resolve, 325)) - if (generation !== consentGeneration) return - consentRequest.value = null - showConsent.value = false - consentPhase.value = 'review' - } - - function finishConsentError(error: unknown) { - consentError.value = error instanceof Error ? error.message : 'The node could not complete this request.' - consentPhase.value = 'error' - } - - function requestConsent(appName: string, method: string, eventKind?: number, content?: string, identityLabel?: string): Promise { - return new Promise((resolve, reject) => { - consentGeneration += 1 - consentRequest.value = { - appName, method, eventKind, content, identityLabel, - resolve, reject, - } - consentPhase.value = 'review' - consentError.value = '' - showConsent.value = true - }) - } - - // NIP-07 postMessage handler — responds to nostr-request from iframe apps - async function handleNostrRequest(event: MessageEvent) { - if (!event.data || event.data.type !== 'nostr-request') return - const { id, method, params } = event.data - const source = event.source as Window | null - if (!source) return - - // Only the app we actually opened may drive this bridge — see - // senderMatchesApp for why the scheme is deliberately not compared. - if (!senderMatchesApp(url.value, event.origin)) return - - const origin = event.origin - let prompted = false - const activeAppId = resolveAppIdFromUrl(url.value) || inferAppIdFromTitle(title.value) || 'unknown-app' - - // Check if app has a per-app identity stored (from identity picker) - const IDENTITY_KEY = 'archipelago_app_identity_' - const appKey = IDENTITY_KEY + (url.value || '').replace(/[^a-z0-9]/gi, '_') - let appIdentityId: string | null = null + // Reuse the same bounded queue and consent lifecycle as AppSession. Keep + // the legacy URL-based identity storage key so existing selections survive. + let nostrFrame: Window | null = null + function overlayIdentity(): SelectedIdentity | null { try { - const stored = localStorage.getItem(appKey) - if (stored) { - const parsed: unknown = JSON.parse(stored) - if (typeof parsed === 'object' && parsed !== null && 'id' in parsed) { - const idVal = (parsed as Record).id - appIdentityId = typeof idVal === 'string' ? idVal : null - } - } - } catch { /* ignore */ } - - // Every identity-sensitive method needs consent (or a remembered - // approval for this origin) — not just signEvent. getPublicKey - // deanonymizes; the decrypts turn the node into a decryption oracle. - const CONSENT_METHODS = new Set([ - 'getPublicKey', 'signEvent', - 'nip04.encrypt', 'nip04.decrypt', - 'nip44.encrypt', 'nip44.decrypt', - ]) - const scopedKey = consentKey(origin, activeAppId, appIdentityId || 'node-default', method) - const alreadyApproved = hasRememberedConsent(scopedKey) - if (CONSENT_METHODS.has(method) && !alreadyApproved) { - prompted = true - const eventKind = method === 'signEvent' ? (params?.event?.kind as number | undefined) : undefined - const content = method === 'signEvent' ? (params?.event?.content as string | undefined) : undefined - try { - const remember = await requestConsent( - title.value || 'App', method, eventKind, content, - appIdentityId || 'Node default identity', - ) - if (remember) rememberConsent(scopedKey) - } catch { - source.postMessage({ type: 'nostr-response', id, error: `User denied ${method} request` }, origin || '*') - return - } - } - - try { - let result: unknown - - if (method === 'getPublicKey') { - if (appIdentityId) { - // Use the app-specific identity's Nostr key - const res = await rpcClient.call<{ nostr_pubkey: string; nostr_npub: string; id: string; name: string; pubkey: string; did: string; is_default: boolean }>({ - method: 'identity.get', params: { id: appIdentityId } - }) - result = res.nostr_pubkey - } else { - const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'node.nostr-pubkey' }) - result = res.nostr_pubkey - } - } else if (method === 'signEvent') { - if (appIdentityId) { - // Sign with the app-specific identity's Nostr key - const res = await rpcClient.call({ - method: 'identity.nostr-sign', - params: { id: appIdentityId, event: params.event } - }) - result = res - } else { - const res = await rpcClient.call({ method: 'node.nostr-sign', params: { event: params.event } }) - result = res - } - } else if (method === 'getRelays') { - result = {} - } else if (method === 'nip04.encrypt') { - const res = await rpcClient.call<{ ciphertext: string }>({ - method: 'identity.nostr-encrypt-nip04', - params: { id: appIdentityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } - }) - result = res.ciphertext - } else if (method === 'nip04.decrypt') { - const res = await rpcClient.call<{ plaintext: string }>({ - method: 'identity.nostr-decrypt-nip04', - params: { id: appIdentityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } - }) - result = res.plaintext - } else if (method === 'nip44.encrypt') { - const res = await rpcClient.call<{ ciphertext: string }>({ - method: 'identity.nostr-encrypt-nip44', - params: { id: appIdentityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } - }) - result = res.ciphertext - } else if (method === 'nip44.decrypt') { - const res = await rpcClient.call<{ plaintext: string }>({ - method: 'identity.nostr-decrypt-nip44', - params: { id: appIdentityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } - }) - result = res.plaintext - } else { - throw new Error(`Unsupported NIP-07 method: ${method}`) - } - source.postMessage({ type: 'nostr-response', id, result }, origin || '*') - if (prompted) void finishConsentSuccess() - } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error' - source.postMessage({ type: 'nostr-response', id, error: message }, origin || '*') - if (prompted && showConsent.value) finishConsentError(err) - } + const key = 'archipelago_app_identity_' + url.value.replace(/[^a-z0-9]/gi, '_') + const stored = JSON.parse(localStorage.getItem(key) || 'null') + if (!stored || typeof stored.id !== 'string' || !stored.id) return null + return { ...stored, name: typeof stored.name === 'string' ? stored.name : stored.id } + } catch { return null } } + const bridge = useNostrBridge(overlayIdentity, { + appId: () => resolveAppIdFromUrl(url.value) || inferAppIdFromTitle(title.value) || 'unknown-app', + appName: () => title.value || 'App', + appUrl: () => url.value, + frameWindow: () => isOpen.value ? nostrFrame : null, + }) + const { handleNostrRequest, showConsent, consentRequest, consentPhase, + consentError, approveConsent, denyConsent } = bridge + + function setNostrFrame(frame: Window | null) { + if (frame === nostrFrame) return + bridge.cancelPending() + nostrFrame = frame + } + watch(url, () => bridge.cancelPending(), { flush: 'sync' }) + onScopeDispose(() => { + window.removeEventListener('message', handleNostrRequest) + bridge.dispose() + nostrFrame = null + }) // Listen for NIP-07 requests only while an app is open watch(isOpen, (open) => { @@ -719,8 +560,9 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { window.addEventListener('message', handleNostrRequest) } else { window.removeEventListener('message', handleNostrRequest) + bridge.cancelPending() } - }) + }, { flush: 'sync' }) return { isOpen, @@ -743,5 +585,6 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { consentError, approveConsent, denyConsent, + setNostrFrame, } }) diff --git a/tests/lifecycle/native-signer-concurrency.cjs b/tests/lifecycle/native-signer-concurrency.cjs index 88d708b3..f8b6b6a2 100644 --- a/tests/lifecycle/native-signer-concurrency.cjs +++ b/tests/lifecycle/native-signer-concurrency.cjs @@ -12,15 +12,20 @@ const {chromium,expect}=require(process.env.PLAYWRIGHT_MODULE || '@playwright/te if(!privateHost || !['http:','https:'].includes(url.protocol) || url.username || url.password)throw new Error('Refusing to send qualification cookies outside a private node'); const cookies=JSON.parse(fs.readFileSync(cookieFile,'utf8')); const browser=await chromium.connectOverCDP(process.env.BROWSER_CDP || 'http://127.0.0.1:32911'); - for(const width of [390,1440]){ + for(const mode of ['session','overlay']) for(const width of [390,1440]){ + const fixturePort=mode==='overlay'?19999:7778; + const fixtureUrl=new URL(origin); fixtureUrl.port=String(fixturePort); fixtureUrl.pathname='/'; + const overlayUrl=fixtureUrl.href; const context=await browser.newContext({viewport:{width,height:900},serviceWorkers:'block'}); try{ await context.addCookies(Object.entries(cookies).map(([name,value])=>({name,value,url:origin,httpOnly:name!=='csrf_token'}))); - await context.addInitScript(()=>{ + await context.addInitScript(({overlayUrl})=>{ localStorage.setItem('neode-auth','true'); localStorage.removeItem('archipelago_nostr_consent_v2'); - localStorage.setItem('archipelago_app_identity_indeedhub',JSON.stringify({id:'qualification-only',name:'Qualification identity',did:'did:key:qualification-only',pubkey:'a'.repeat(64),nostr_pubkey:'b'.repeat(64)})); - }); + const identity={id:'qualification-only',name:'Qualification identity',did:'did:key:qualification-only',pubkey:'a'.repeat(64),nostr_pubkey:'b'.repeat(64)}; + localStorage.setItem('archipelago_app_identity_indeedhub',JSON.stringify(identity)); + localStorage.setItem('archipelago_app_identity_'+overlayUrl.replace(/[^a-z0-9]/gi,'_'),JSON.stringify(identity)); + },{overlayUrl}); let signed=0, frameLoads=0; const signedContents=[]; await context.route('**/rpc/v1',async route=>{ let request;try{request=route.request().postDataJSON()}catch{return route.continue()} @@ -34,7 +39,7 @@ const {chromium,expect}=require(process.env.PLAYWRIGHT_MODULE || '@playwright/te } return route.continue(); }); - await context.route('**:7778/**',async route=>{ + await context.route('**:'+fixturePort+'/**',async route=>{ if(route.request().resourceType()!=='document')return route.abort(); frameLoads++; return route.fulfill({contentType:'text/html',body:`

waiting

`}); }); const page=await context.newPage(); - await page.goto(origin+'/dashboard/app-session/indeedhub',{waitUntil:'domcontentloaded'}); + await page.goto(origin+(mode==='overlay'?'/dashboard/apps':'/dashboard/app-session/indeedhub'),{waitUntil:'domcontentloaded'}); + if(mode==='overlay'){ + await page.waitForFunction(()=>document.querySelector('#app')?.__vue_app__?.config.globalProperties.$pinia?._s.has('appLauncher')); + await page.evaluate(url=>document.querySelector('#app').__vue_app__.config.globalProperties.$pinia._s.get('appLauncher').open({url,title:'Qualification custom app'}),overlayUrl); + } const approve=page.getByRole('button',{name:'Approve',exact:true}); try { await expect(approve).toBeVisible({timeout:30000}); } catch(error) { - console.log(JSON.stringify({node,width,path:new URL(page.url()).pathname,headings:await page.locator('h1').allTextContents(),frames:await page.locator('iframe').evaluateAll(items=>items.map(item=>{const u=new URL(item.src);return {origin:u.origin,path:u.pathname}}))})); + console.log(JSON.stringify({node,mode,width,path:new URL(page.url()).pathname,headings:await page.locator('h1').allTextContents(),frames:await page.locator('iframe').evaluateAll(items=>items.map(item=>{const u=new URL(item.src);return {origin:u.origin,path:u.pathname}}))})); throw error; } await approve.click(); @@ -55,14 +64,14 @@ const {chromium,expect}=require(process.env.PLAYWRIGHT_MODULE || '@playwright/te await expect(approve).toBeVisible({timeout:10000}); await approve.click(); await expect.poll(()=>signed,{timeout:10000}).toBe(2); - const frame=page.frameLocator('iframe[src*="7778"]'); + const frame=page.frameLocator('iframe[src*="'+fixturePort+'"]'); await expect(frame.locator('#result')).toContainText('"id":"first","ok":true'); - try { await expect(frame.locator('#result')).toContainText('"id":"second","ok":true'); } catch(error) { console.log(JSON.stringify({node,width,frameLoads,signedContents})); throw error; } + try { await expect(frame.locator('#result')).toContainText('"id":"second","ok":true'); } catch(error) { console.log(JSON.stringify({node,mode,width,frameLoads,signedContents})); throw error; } await page.waitForTimeout(800); // allow the required 675ms completion presentation to finish await expect(approve).toHaveCount(0); expect(frameLoads).toBe(1); expect(signedContents).toEqual(['qualification-first','qualification-second']); - console.log(JSON.stringify({node,width,result:'PASS',scope:'served dashboard, concurrent iframe requests and consent responses; signing RPC isolated with fixtures; no real signing/payment'})); + console.log(JSON.stringify({node,mode,width,result:'PASS',scope:'served dashboard, concurrent iframe requests and consent responses; signing RPC isolated with fixtures; no real signing/payment'})); }finally{await context.close()} } process.exit(0);