Keep slow app sessions alive and defer automatic companion prompts
This commit is contained in:
@@ -708,3 +708,36 @@ RPC tests and the complete isolated suite (**1,749 pass, zero fail, five existin
|
||||
ignores**) pass. No real payment or live wallet mutation was used. This is not yet
|
||||
in the running backend. Durable pre-mint purchase journaling and seller receipt
|
||||
recovery remain open in [the recovery follow-up](paid-content-recovery-followup.md).
|
||||
|
||||
## Payment selection deployed; slow-app launch regression under qualification
|
||||
|
||||
The production backend at `a3f0bf0a` built successfully and is deployed on dev
|
||||
and Yaya. SHA256 `2c881f68592f7a395cc53c641cc936217426893a7f9f994253026ce36cda79f3`.
|
||||
Both nodes pass health and authenticated RPC; persistent session keys, UI and
|
||||
all app container identities/start times remained unchanged. Rollback paths are
|
||||
recorded in the private artifact receipt and `/tmp/archy-payment-selection-{dev,yaya}-deploy.log`.
|
||||
No payment was made for this deployment. This closes backend-selection rollout,
|
||||
not the durable initial-payment recovery gate.
|
||||
|
||||
Actual Yaya mobile-width IndeeHub native login returned session201 and profile200,
|
||||
and retained login after reload (`/tmp/archy-indeehub-live-login.log`). A broader
|
||||
attempt then failed on dev/Yaya: the 12-second AppSession load timeout destroys
|
||||
the iframe, and the automatic companion introduction can cover login. These
|
||||
failed runs are retained, not counted as acceptance. Source now preserves a
|
||||
slow iframe, offers a compact dismissible notice, retains a loaded app through
|
||||
transient readiness failure, and defers automatic companion prompts while an
|
||||
app is active. Four parent lifecycle tests and the frame/intro tests pass;
|
||||
full-suite/build and actual served-browser checks are still in progress.
|
||||
|
||||
V4V preparation on Yaya is complete without replacing the original Portainer
|
||||
app: consistent private backup of 28 data files and 170 media files, independently
|
||||
populated managed volumes, preserved existing password hash and pinned managed
|
||||
image pulled. The original container and volumes remain the rollback path. The
|
||||
single-node catalog awaits the operator root signature at
|
||||
`/tmp/archy-yaya-v4v-catalog.json`; no managed app installation is claimed yet.
|
||||
|
||||
Launcher qualification update: production build passes. The first full UI run
|
||||
passed 1,299 tests but timed out in a Bitcoin modal case and then failed its next
|
||||
case; all 20 targeted tests including both modal cases pass on rerun. A new full
|
||||
run with reduced concurrency and no simultaneous build is required and running.
|
||||
No failing full run is recorded as passed.
|
||||
|
||||
@@ -153,6 +153,8 @@ import { useLoginTransitionStore } from '@/stores/loginTransition'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const props = defineProps<{ deferAutomatic?: boolean }>()
|
||||
|
||||
const STORAGE_KEY = 'neode_companion_intro_seen'
|
||||
// Absolute URL so the QR works when scanned by a phone (a relative path has no
|
||||
// host to resolve). Points at the companion APK on the release server's https
|
||||
@@ -200,6 +202,16 @@ const BASE_DELAY_MS = 5000
|
||||
const POST_INTRO_GRACE_MS = 2000
|
||||
|
||||
let calmTicker: ReturnType<typeof setInterval> | null = null
|
||||
let baseDelay: ReturnType<typeof setTimeout> | null = null
|
||||
let disposed = false
|
||||
let automaticDisplay = false
|
||||
|
||||
function clearAutomaticTimers() {
|
||||
if (baseDelay) clearTimeout(baseDelay)
|
||||
if (calmTicker) clearInterval(calmTicker)
|
||||
baseDelay = null
|
||||
calmTicker = null
|
||||
}
|
||||
|
||||
// Running inside the companion app's own WebView (it injects the JS bridge —
|
||||
// detected with the canonical helper, not a raw window check, so the gate
|
||||
@@ -213,9 +225,10 @@ onMounted(async () => {
|
||||
// The prompt is remembered per APK build, not forever. A browser that saw
|
||||
// 0.5.28 should be told once when this node begins serving 0.5.32.
|
||||
await loadCompanionRelease()
|
||||
if (disposed) return
|
||||
try {
|
||||
if (localStorage.getItem(STORAGE_KEY) !== companionReleaseMarker(companionVersion.value)) {
|
||||
setTimeout(maybeShow, BASE_DELAY_MS)
|
||||
baseDelay = setTimeout(maybeShow, BASE_DELAY_MS)
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
@@ -223,18 +236,21 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (calmTicker) clearInterval(calmTicker)
|
||||
disposed = true
|
||||
clearAutomaticTimers()
|
||||
})
|
||||
|
||||
function maybeShow() {
|
||||
if (disposed) return
|
||||
clearAutomaticTimers()
|
||||
// Show only after the scene has been CONTINUOUSLY calm (no reveal
|
||||
// cinematic) for the full grace window. The previous point-in-time check
|
||||
// raced a slow-starting reveal — on a cold cache the entrance video can
|
||||
// begin buffering after the 5s base delay, so the flag was still false
|
||||
// when sampled and the popup cut straight into the cinematic.
|
||||
let calmSince = loginTransition.introCinematicPlaying ? null : Date.now()
|
||||
let calmSince = loginTransition.introCinematicPlaying || props.deferAutomatic ? null : Date.now()
|
||||
calmTicker = setInterval(() => {
|
||||
if (loginTransition.introCinematicPlaying) {
|
||||
if (loginTransition.introCinematicPlaying || props.deferAutomatic) {
|
||||
calmSince = null
|
||||
return
|
||||
}
|
||||
@@ -242,11 +258,21 @@ function maybeShow() {
|
||||
if (Date.now() - calmSince >= POST_INTRO_GRACE_MS) {
|
||||
if (calmTicker) clearInterval(calmTicker)
|
||||
calmTicker = null
|
||||
automaticDisplay = true
|
||||
visible.value = true
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
|
||||
// A route change can open an app after the automatic offer was displayed.
|
||||
// Defer that offer until the app closes; never cover its identity/consent UI.
|
||||
watch(() => props.deferAutomatic, active => {
|
||||
if (active && visible.value && automaticDisplay) {
|
||||
visible.value = false
|
||||
maybeShow()
|
||||
}
|
||||
})
|
||||
|
||||
// Manual open (App Store banner etc.) — ignores the once-per-browser gate.
|
||||
// The trigger itself is already a no-op inside the companion (useCompanionIntro),
|
||||
// and this watcher refuses to open there too, so no caller can ever pop the
|
||||
@@ -255,10 +281,8 @@ watch(companionIntroRequested, (requested) => {
|
||||
if (!requested) return
|
||||
companionIntroRequested.value = false
|
||||
if (IN_COMPANION_APP) return
|
||||
if (calmTicker) {
|
||||
clearInterval(calmTicker)
|
||||
calmTicker = null
|
||||
}
|
||||
clearAutomaticTimers()
|
||||
automaticDisplay = false
|
||||
step.value = 'download'
|
||||
visible.value = true
|
||||
})
|
||||
@@ -449,6 +473,7 @@ function showDownloadScreen() {
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
clearAutomaticTimers()
|
||||
visible.value = false
|
||||
step.value = 'download'
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import CompanionIntroOverlay from '../CompanionIntroOverlay.vue'
|
||||
import { companionIntroRequested } from '@/composables/useCompanionIntro'
|
||||
vi.mock('qrcode', () => ({ toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,fixture') }))
|
||||
vi.mock('@/utils/openExternal', () => ({ isCompanionApp: () => false }))
|
||||
vi.mock('@/composables/useDemoIntro', () => ({ IS_DEMO: false, DEMO_PASSWORD: '' }))
|
||||
vi.mock('@/composables/useCompanionRelease', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return { companionRelease: ref({ versionName: 'fixture', versionCode: 1 }), companionReleaseMarker: () => 'fixture', loadCompanionRelease: vi.fn().mockResolvedValue(undefined) }
|
||||
})
|
||||
vi.mock('@/stores/loginTransition', () => ({ useLoginTransitionStore: () => ({ introCinematicPlaying: false }) }))
|
||||
vi.mock('@/stores/server', () => ({ useServerStore: () => ({}) }))
|
||||
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
|
||||
describe('automatic companion offer during app use', () => {
|
||||
let wrapper: VueWrapper
|
||||
beforeEach(() => { vi.useFakeTimers(); localStorage.clear(); companionIntroRequested.value = false })
|
||||
afterEach(() => { wrapper?.unmount(); vi.useRealTimers() })
|
||||
function render(deferAutomatic = false) { wrapper = mount(CompanionIntroOverlay, { props: { deferAutomatic }, global: { stubs: { Teleport: true, Transition: false } } }) }
|
||||
it('waits until the app closes before showing its automatic offer', async () => {
|
||||
render(true); await nextTick(); await vi.advanceTimersByTimeAsync(10000)
|
||||
expect(wrapper.find('[aria-label="Close companion modal"]').exists()).toBe(false)
|
||||
await wrapper.setProps({ deferAutomatic: false }); await vi.advanceTimersByTimeAsync(2500)
|
||||
expect(wrapper.find('[aria-label="Close companion modal"]').exists()).toBe(true)
|
||||
await wrapper.setProps({ deferAutomatic: true }); await vi.advanceTimersByTimeAsync(3000)
|
||||
expect(wrapper.find('[aria-label="Close companion modal"]').exists()).toBe(false)
|
||||
})
|
||||
it('honors manual opening while an app is active, without reopening after dismissal', async () => {
|
||||
render(true); await nextTick()
|
||||
companionIntroRequested.value = true; await nextTick()
|
||||
await wrapper.get('[aria-label="Close companion modal"]').trigger('click')
|
||||
await vi.advanceTimersByTimeAsync(12000)
|
||||
expect(wrapper.find('[aria-label="Close companion modal"]').exists()).toBe(false)
|
||||
})
|
||||
it('clears deferred timers when the dashboard is unmounted', async () => {
|
||||
render(true); await nextTick(); await vi.advanceTimersByTimeAsync(1000)
|
||||
wrapper.unmount(); await vi.advanceTimersByTimeAsync(15000)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -39,7 +39,8 @@
|
||||
:must-open-new-tab="mustOpenNewTab"
|
||||
:auto-retry-count="autoRetryCount"
|
||||
:refresh-key="refreshKey"
|
||||
:ui-ready-blocked="packageEntry?.['ui-ready'] === false"
|
||||
:ui-ready-blocked="uiReadyBlocked"
|
||||
:slow-load="slowLoad"
|
||||
:blocked-reason="blockedReason"
|
||||
:blocked-title="blockedTitle"
|
||||
:warming-up="warmingUp"
|
||||
@@ -48,6 +49,7 @@
|
||||
@iframe-error="onError"
|
||||
@refresh="refresh"
|
||||
@open-new-tab-and-back="openNewTabAndBack"
|
||||
@dismiss-slow-load="slowLoad = false"
|
||||
/>
|
||||
|
||||
<!-- Mobile: gamepad for botfights (with utility buttons), browser bar for everything else -->
|
||||
@@ -162,6 +164,8 @@ const frameRef = ref<InstanceType<typeof AppSessionFrame> | null>(null)
|
||||
const loading = ref(true)
|
||||
const isRefreshing = ref(false)
|
||||
const iframeBlocked = ref(false)
|
||||
const slowLoad = ref(false)
|
||||
const loadedAppUrl = ref('')
|
||||
const refreshKey = ref(0)
|
||||
const showIdentityPicker = ref(false)
|
||||
const autoRetryCount = ref(0)
|
||||
@@ -224,7 +228,7 @@ const mustOpenNewTab = computed(() =>
|
||||
(IS_DEMO && isDemoExternal(appId.value))
|
||||
)
|
||||
|
||||
// The auto-tab detector: the load-timeout marked the frame blocked, the
|
||||
// The auto-tab detector: an actual frame error marked the frame blocked, the
|
||||
// warming-up retry loop has given up, and the backend says the app is
|
||||
// actually RUNNING — that combination is the embed-refusal signature (a
|
||||
// down app is "warming up" or shows a blocked reason instead). Remember it
|
||||
@@ -270,6 +274,12 @@ const appUrl = computed(() => {
|
||||
return canonicalAppUrl(resolveAppUrl(appId.value, deepPath, runtimeUrl))
|
||||
})
|
||||
|
||||
// Readiness protects a first load. A transient background probe must not tear
|
||||
// down an already loaded login, upload or player and discard its session state.
|
||||
const uiReadyBlocked = computed(() =>
|
||||
packageEntry.value?.['ui-ready'] === false && loadedAppUrl.value !== appUrl.value
|
||||
)
|
||||
|
||||
function closeRouteSession() {
|
||||
const fallback = route.query.returnTo
|
||||
const fallbackPath = typeof fallback === 'string' && fallback.startsWith('/dashboard')
|
||||
@@ -292,7 +302,11 @@ const nostrBridge = useNostrBridge(identity.getStoredIdentity, {
|
||||
})
|
||||
|
||||
// An actual destination change invalidates consent queued for the previous app page.
|
||||
watch(appUrl, () => nostrBridge.cancelPending())
|
||||
watch(appUrl, () => {
|
||||
loadedAppUrl.value = ''
|
||||
slowLoad.value = false
|
||||
nostrBridge.cancelPending()
|
||||
})
|
||||
|
||||
// --- Display mode ---
|
||||
|
||||
@@ -385,13 +399,13 @@ const panelClasses = computed(() => {
|
||||
|
||||
// A cold/restarting upstream is held outside the iframe. Start one fresh
|
||||
// load when the scanner observes HTTP readiness; no manual refresh required.
|
||||
watch(() => packageEntry.value?.['ui-ready'], (ready, previous) => {
|
||||
if (ready === false) {
|
||||
watch(uiReadyBlocked, (blocked, previous) => {
|
||||
if (blocked) {
|
||||
if (loadTimeoutId) clearTimeout(loadTimeoutId)
|
||||
if (autoRetryId) clearTimeout(autoRetryId)
|
||||
if (iframeCheckId) clearTimeout(iframeCheckId)
|
||||
loading.value = false
|
||||
} else if (previous === false && ready === true) {
|
||||
} else if (previous === true && !blocked) {
|
||||
autoRetryCount.value = 0
|
||||
refresh()
|
||||
}
|
||||
@@ -400,6 +414,9 @@ watch(() => packageEntry.value?.['ui-ready'], (ready, previous) => {
|
||||
// --- Lifecycle handlers ---
|
||||
|
||||
function onLoad() {
|
||||
loadedAppUrl.value = appUrl.value
|
||||
slowLoad.value = false
|
||||
iframeBlocked.value = false
|
||||
mediaBridge.connect()
|
||||
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
|
||||
if (autoRetryId) { clearTimeout(autoRetryId); autoRetryId = null }
|
||||
@@ -432,6 +449,8 @@ function onLoad() {
|
||||
}
|
||||
|
||||
function onError() {
|
||||
loadedAppUrl.value = ''
|
||||
slowLoad.value = false
|
||||
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
|
||||
loading.value = false
|
||||
isRefreshing.value = false
|
||||
@@ -446,6 +465,8 @@ function onError() {
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
loadedAppUrl.value = ''
|
||||
slowLoad.value = false
|
||||
if (autoRetryId) { clearTimeout(autoRetryId); autoRetryId = null }
|
||||
isRefreshing.value = true
|
||||
loading.value = true
|
||||
@@ -456,23 +477,26 @@ function refresh() {
|
||||
|
||||
function startLoadTimeout() {
|
||||
if (loadTimeoutId) clearTimeout(loadTimeoutId)
|
||||
if (packageEntry.value?.['ui-ready'] === false) return
|
||||
if (uiReadyBlocked.value) return
|
||||
loadTimeoutId = setTimeout(() => {
|
||||
if (loading.value) {
|
||||
loading.value = false
|
||||
iframeBlocked.value = true
|
||||
isRefreshing.value = false
|
||||
// Removing the iframe here aborted slow navigation before its load event
|
||||
// could recover it. Keep the actual frame and offer non-blocking feedback.
|
||||
slowLoad.value = true
|
||||
}
|
||||
}, 12000)
|
||||
}
|
||||
|
||||
function openNewTabAndBack() {
|
||||
if (packageEntry.value?.['ui-ready'] === false) return
|
||||
if (uiReadyBlocked.value) return
|
||||
if (appUrl.value) openExternalUrl(appUrl.value)
|
||||
closeSession()
|
||||
}
|
||||
|
||||
function openNewTab() {
|
||||
if (packageEntry.value?.['ui-ready'] === false) return
|
||||
if (uiReadyBlocked.value) return
|
||||
if (appUrl.value) openExternalUrl(appUrl.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
<HealthNotifications />
|
||||
|
||||
<!-- First-use companion intro overlay -->
|
||||
<CompanionIntroOverlay />
|
||||
<CompanionIntroOverlay :defer-automatic="!!appLauncher.panelAppId || appLauncher.isOpen || route.name === 'app-session'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppSession from '../AppSession.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { appId: 'indeedhub' }, query: {}, fullPath: '/dashboard/app-session/indeedhub' }), useRouter: () => ({ replace: vi.fn(() => Promise.resolve()), push: vi.fn(() => Promise.resolve()) }) }))
|
||||
vi.mock('@/stores/appLauncher', () => ({ useAppLauncherStore: () => ({ panelAppId: null }) }))
|
||||
vi.mock('@/stores/app', async () => {
|
||||
const { reactive } = await import('vue')
|
||||
const state = reactive({ data: { 'package-data': {} } })
|
||||
return { useAppStore: () => state }
|
||||
})
|
||||
vi.mock('@/stores/screensaver', () => ({ useScreensaverStore: () => ({ suppress: vi.fn(), resume: vi.fn() }) }))
|
||||
vi.mock('../appSession/useAppIdentity', () => ({ useAppIdentity: () => ({ onIdentitySelected: vi.fn(), onIframeLoadIdentity: vi.fn(), handleIdentityRequest: vi.fn(), getStoredIdentity: () => null, cancelIdentitySelection: vi.fn() }) }))
|
||||
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
|
||||
describe('AppSession slow navigation and readiness changes', () => {
|
||||
let wrapper: VueWrapper
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers(); setActivePinia(createPinia()); localStorage.clear()
|
||||
Object.defineProperty(window, 'innerWidth', { value: 390, configurable: true })
|
||||
Object.defineProperty(window, 'location', { value: { hostname: '192.0.2.10', origin: 'http://192.0.2.10', protocol: 'http:' }, configurable: true })
|
||||
useAppStore().data = { 'package-data': { indeedhub: { state: 'running', 'ui-ready': true, manifest: { id: 'indeedhub', title: 'IndeeHub' } } } } as never
|
||||
})
|
||||
afterEach(() => { wrapper?.unmount(); vi.useRealTimers() })
|
||||
function render() {
|
||||
// Keep real Teleport: its test stub freezes the dynamic slot props here.
|
||||
// Control frame load events separately; AppSessionFrame tests cover its DOM.
|
||||
wrapper = mount(AppSession, { global: { stubs: { Teleport: false, AppSessionHeader: true, AppSessionFrame: { name: 'AppSessionFrame', props: ['appUrl','loading','iframeBlocked','slowLoad','uiReadyBlocked','refreshKey'], emits: ['iframeLoad','iframeError','refresh'], template: '<div><iframe v-if="!iframeBlocked && !uiReadyBlocked" :key="refreshKey" /></div>' }, NostrIdentityPicker: true, NostrSignConsent: true, MobileGamepad: true, AppLoadingScreen: true } } })
|
||||
return wrapper.getComponent({ name: 'AppSessionFrame' })
|
||||
}
|
||||
function readiness(value: boolean) { useAppStore().data!['package-data'].indeedhub!['ui-ready'] = value }
|
||||
it('keeps a slow iframe alive past the deadline and accepts its eventual load', async () => {
|
||||
const frame = render(), original = frame.get('iframe').element
|
||||
await vi.advanceTimersByTimeAsync(13000); await nextTick()
|
||||
expect(frame.props('iframeBlocked')).toBe(false)
|
||||
expect(frame.props('slowLoad')).toBe(true)
|
||||
expect(frame.get('iframe').element).toBe(original)
|
||||
frame.vm.$emit('iframeLoad'); await nextTick()
|
||||
expect(frame.props('slowLoad')).toBe(false)
|
||||
expect(frame.props('loading')).toBe(false)
|
||||
expect(frame.get('iframe').element).toBe(original)
|
||||
})
|
||||
it('retains a loaded app and frame key through a transient readiness failure', async () => {
|
||||
const frame = render(); frame.vm.$emit('iframeLoad'); await nextTick()
|
||||
const original = frame.get('iframe').element, key = frame.props('refreshKey')
|
||||
readiness(false); await nextTick()
|
||||
expect(frame.props('uiReadyBlocked')).toBe(false)
|
||||
expect(frame.get('iframe').element).toBe(original)
|
||||
readiness(true); await nextTick()
|
||||
expect(frame.get('iframe').element).toBe(original)
|
||||
expect(frame.props('refreshKey')).toBe(key)
|
||||
})
|
||||
it('still gates a cold app, then starts it once readiness arrives', async () => {
|
||||
readiness(false); const frame = render()
|
||||
expect(frame.find('iframe').exists()).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(13000); await nextTick()
|
||||
expect(frame.props('slowLoad')).toBe(false)
|
||||
readiness(true); await nextTick()
|
||||
expect(frame.find('iframe').exists()).toBe(true)
|
||||
expect(frame.props('loading')).toBe(true)
|
||||
})
|
||||
it('an explicit refresh rechecks readiness instead of bypassing a cold app', async () => {
|
||||
const frame = render(); frame.vm.$emit('iframeLoad'); await nextTick()
|
||||
readiness(false); await nextTick()
|
||||
frame.vm.$emit('refresh'); await nextTick()
|
||||
expect(frame.props('uiReadyBlocked')).toBe(true)
|
||||
expect(frame.find('iframe').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -61,6 +61,15 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="slowLoad && !iframeBlocked && !uiReadyBlocked" role="status"
|
||||
class="absolute top-3 left-3 right-3 z-10 flex items-center gap-2 rounded-lg border border-white/10 bg-black/80 px-3 py-2 text-xs text-white/75 backdrop-blur-md">
|
||||
<span class="min-w-0 flex-1">{{ appTitle }} is taking longer to load. You can keep waiting.</span>
|
||||
<button type="button" class="shrink-0 rounded px-2 py-1 text-white hover:bg-white/10" @click="$emit('refresh')">Retry</button>
|
||||
<button type="button" class="shrink-0 rounded p-1 text-white/60 hover:bg-white/10" aria-label="Dismiss loading notice" @click="$emit('dismissSlowLoad')">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-width="2" d="m6 6 12 12M18 6 6 18" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Iframe blocked fallback. Suppressed while the ElectrumX sync screen
|
||||
(the "pre UI") is showing: a still-syncing Electrum server isn't
|
||||
reachable yet, so the "App not reachable / retry" overlay would just
|
||||
@@ -136,6 +145,7 @@ const props = defineProps<{
|
||||
autoRetryCount: number
|
||||
refreshKey: number
|
||||
uiReadyBlocked?: boolean
|
||||
slowLoad?: boolean
|
||||
blockedReason?: string
|
||||
blockedTitle?: string
|
||||
// True while the container is up but its probe hasn't answered yet and the
|
||||
@@ -151,6 +161,7 @@ const emit = defineEmits<{
|
||||
iframeError: []
|
||||
refresh: []
|
||||
openNewTabAndBack: []
|
||||
dismissSlowLoad: []
|
||||
}>()
|
||||
|
||||
const iframeRef = ref<HTMLIFrameElement | null>(null)
|
||||
|
||||
@@ -68,6 +68,19 @@ describe('AppSessionFrame warm-up state', () => {
|
||||
})
|
||||
|
||||
describe('HTTP readiness gate', () => {
|
||||
it('keeps the same iframe while reporting a slow load and lets the user dismiss the notice', async () => {
|
||||
const frame = mountFrame({ iframeBlocked: false, slowLoad: false });
|
||||
const original = frame.get('iframe').element;
|
||||
await frame.setProps({ slowLoad: true });
|
||||
expect(frame.get('iframe').element).toBe(original);
|
||||
expect(frame.get('[role="status"]').text()).toContain('Bitcoin is taking longer to load');
|
||||
await frame.get('[aria-label="Dismiss loading notice"]').trigger('click');
|
||||
expect(frame.emitted('dismissSlowLoad')).toHaveLength(1);
|
||||
await frame.setProps({ slowLoad: false });
|
||||
expect(frame.get('iframe').element).toBe(original);
|
||||
expect(frame.find('[role="status"]').exists()).toBe(false);
|
||||
frame.unmount();
|
||||
})
|
||||
it('does not show a missing-configuration error during initial installation', () => {
|
||||
const frame = mountFrame({ appUrl: '', uiReadyBlocked: true, blockedReason: 'Waiting for the app to be ready…' })
|
||||
expect(frame.text()).not.toContain('App not configured')
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Real served dashboard, isolated delayed app document; no app mutation or signing.
|
||||
const fs = require('fs');
|
||||
const { chromium, expect } = require(process.env.PLAYWRIGHT_MODULE || '@playwright/test');
|
||||
(async () => {
|
||||
const origin = process.env.QUALIFICATION_ORIGIN;
|
||||
const cookieFile = process.env.QUALIFICATION_COOKIES;
|
||||
const node = process.env.QUALIFICATION_LABEL || 'node';
|
||||
if (!origin || !cookieFile) throw new Error('Set private node origin and cookie file');
|
||||
const url = new URL(origin), bytes = url.hostname.split('.').map(Number);
|
||||
const privateHost = url.hostname === 'localhost' || (bytes.length === 4 && bytes.every(n => Number.isInteger(n) && n >= 0 && n <= 255) &&
|
||||
(bytes[0] === 127 || bytes[0] === 10 || (bytes[0] === 192 && bytes[1] === 168) || (bytes[0] === 172 && bytes[1] >= 16 && bytes[1] <= 31) || (bytes[0] === 100 && bytes[1] >= 64 && bytes[1] <= 127)));
|
||||
if (!privateHost || !['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('Refusing non-private node origin');
|
||||
const browser = await chromium.connectOverCDP(process.env.BROWSER_CDP || 'http://127.0.0.1:32911');
|
||||
for (const width of [390, 1440]) {
|
||||
const context = await browser.newContext({ viewport: { width, height: 900 }, serviceWorkers: 'block' });
|
||||
let releaseDocument;
|
||||
const heldDocument = new Promise(resolve => { releaseDocument = resolve; });
|
||||
let documentRequests = 0;
|
||||
try {
|
||||
const cookies = JSON.parse(fs.readFileSync(cookieFile, 'utf8'));
|
||||
await context.addCookies(Object.entries(cookies).map(([name, value]) => ({ name, value, url: origin, httpOnly: name !== 'csrf_token' })));
|
||||
await context.addInitScript(() => {
|
||||
localStorage.setItem('neode-auth', 'true');
|
||||
localStorage.setItem('lnd-seed-backup-prompt-snooze-until', String(Date.now() + 3600000));
|
||||
});
|
||||
await context.route('**:7778/**', async route => {
|
||||
if (route.request().resourceType() !== 'document') return route.abort();
|
||||
documentRequests++;
|
||||
await heldDocument;
|
||||
await route.fulfill({ contentType: 'text/html', body: '<!doctype html><html><body><h1 id="ready">Delayed app ready</h1></body></html>' });
|
||||
});
|
||||
// Explicitly deny signing in this loading-only fixture.
|
||||
await context.route('**/rpc/v1', async route => {
|
||||
let r; try { r = route.request().postDataJSON(); } catch { return route.continue(); }
|
||||
if (/nostr-sign|identity.sign|\.(pay|send|spend|melt|withdraw)(-|$)/.test(r?.method || '')) return route.abort();
|
||||
return route.continue();
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(origin + '/dashboard/app-session/indeedhub', { waitUntil: 'domcontentloaded' });
|
||||
const frame = page.locator('iframe[src*="7778"]');
|
||||
await expect(frame).toHaveCount(1, { timeout: 30000 });
|
||||
await frame.evaluate(el => { window.__qualificationFrame = el; });
|
||||
const notice = page.getByRole('status').filter({ hasText: 'is taking longer to load' });
|
||||
await expect(notice).toBeVisible({ timeout: 20000 });
|
||||
expect(await frame.evaluate(el => el === window.__qualificationFrame)).toBe(true);
|
||||
await expect(page.getByRole('button', { name: 'Close companion modal', exact: true })).toHaveCount(0);
|
||||
releaseDocument();
|
||||
await expect(page.frameLocator('iframe[src*="7778"]').locator('#ready')).toHaveText('Delayed app ready', { timeout: 15000 });
|
||||
await expect(notice).toHaveCount(0);
|
||||
expect(await frame.evaluate(el => el === window.__qualificationFrame)).toBe(true);
|
||||
// Modify readiness only in this isolated browser's store, never the node.
|
||||
for (const ready of [false, true]) {
|
||||
await page.evaluate(ready => {
|
||||
const app = document.querySelector('#app').__vue_app__.config.globalProperties.$pinia._s.get('app');
|
||||
app.data['package-data'].indeedhub['ui-ready'] = ready;
|
||||
}, ready);
|
||||
await page.waitForTimeout(300);
|
||||
await expect(frame).toHaveCount(1);
|
||||
expect(await frame.evaluate(el => el === window.__qualificationFrame)).toBe(true);
|
||||
}
|
||||
expect(documentRequests).toBe(1);
|
||||
console.log(JSON.stringify({ node, width, result: 'PASS', delayedBeyondDeadline: true, sameIframeRetained: true, readinessFlapRetained: true, companionOfferDeferred: true, documentRequests }));
|
||||
} finally {
|
||||
releaseDocument();
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
process.exit(0);
|
||||
})().catch(e => { console.error(String(e.message).split('Call log:')[0]); process.exit(1); });
|
||||
@@ -0,0 +1,62 @@
|
||||
// Real, operator-authorized local IndeeHub login. Only NIP-98 session signatures are allowed.
|
||||
// Uses isolated browser contexts, does not publish events or perform wallet operations.
|
||||
const fs=require('fs');const {chromium,expect}=require(process.env.PLAYWRIGHT_MODULE || '@playwright/test');
|
||||
(async()=>{
|
||||
if(process.env.ALLOW_REAL_AUTH_SIGNING!=='1')throw new Error('Set ALLOW_REAL_AUTH_SIGNING=1 only for an authorized real IndeeHub login check');
|
||||
const origin=process.env.QUALIFICATION_ORIGIN,cookieFile=process.env.QUALIFICATION_COOKIES,node=process.env.QUALIFICATION_LABEL||'node';
|
||||
if(!origin||!cookieFile)throw new Error('Set private node origin and cookie file');
|
||||
const u=new URL(origin),octets=u.hostname.split('.').map(Number);
|
||||
const privateHost=u.hostname==='localhost'||(octets.length===4&&octets.every(n=>Number.isInteger(n)&&n>=0&&n<=255)&&(octets[0]===127||octets[0]===10||(octets[0]===192&&octets[1]===168)||(octets[0]===172&&octets[1]>=16&&octets[1]<=31)||(octets[0]===100&&octets[1]>=64&&octets[1]<=127)));
|
||||
if(!privateHost||!['http:','https:'].includes(u.protocol)||u.username||u.password||u.pathname!=='/'||u.search||u.hash)throw new Error('Refusing non-private node origin');
|
||||
u.port='7778';const appOrigin=u.origin;
|
||||
const b=await chromium.connectOverCDP(process.env.BROWSER_CDP||'http://127.0.0.1:32911');
|
||||
for(const width of [390,1440]){
|
||||
const c=await b.newContext({viewport:{width,height:900},serviceWorkers:'block'});
|
||||
let page;
|
||||
const proof={node,width,stage:'setup',realLogin:true,sessionResponses:[],profileResponses:[],signedAuthRequests:0,blockedUnexpectedRequest:false};
|
||||
try{
|
||||
const cookies=JSON.parse(fs.readFileSync(cookieFile,'utf8'));await c.addCookies(Object.entries(cookies).map(([name,value])=>({name,value,url:origin,httpOnly:name!=='csrf_token'})));
|
||||
await c.addInitScript(()=>{localStorage.setItem('neode-auth','true');localStorage.setItem('lnd-seed-backup-prompt-snooze-until',String(Date.now()+3600000));sessionStorage.setItem('indeedhub_splash_shown','true');});
|
||||
await c.route('**/rpc/v1',async route=>{
|
||||
let r;try{r=route.request().postDataJSON()}catch{return route.continue()}
|
||||
if(['identity.nostr-sign','node.nostr-sign'].includes(r?.method)){
|
||||
const e=r.params?.event;let safe=false;try{const u=new URL(e.tags.find(t=>t[0]==='u')[1]);safe=e.kind===27235&&u.origin===appOrigin&&u.pathname==='/api/auth/nostr/session'&&e.tags.some(t=>t[0]==='method'&&t[1]==='POST')}catch{}
|
||||
if(!safe){proof.blockedUnexpectedRequest=true;return route.abort()}
|
||||
proof.signedAuthRequests++;
|
||||
} else if(r?.method==='identity.sign') {
|
||||
if(typeof r.params?.message!=='string'||!/^archipelago-identity:\d+$/.test(r.params.message)){proof.blockedUnexpectedRequest=true;return route.abort()}
|
||||
} else if(/nostr-(encrypt|decrypt)|\.(pay|send|spend|melt|withdraw)(-|$)/.test(r?.method||'')){
|
||||
proof.blockedUnexpectedRequest=true;return route.abort();
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
const p=await c.newPage();page=p;p.on('response',r=>{const u=new URL(r.url());if(u.origin!==appOrigin)return;if(u.pathname.endsWith('/auth/nostr/session'))proof.sessionResponses.push(r.status());if(u.pathname.endsWith('/auth/me'))proof.profileResponses.push(r.status())});
|
||||
await p.goto(origin+'/dashboard/app-session/indeedhub',{waitUntil:'domcontentloaded'});
|
||||
proof.stage='identity selection';
|
||||
const authenticate=p.getByRole('button',{name:'Authenticate',exact:true});await expect(authenticate).toBeEnabled({timeout:30000});await authenticate.click();
|
||||
proof.stage='open app sign-in';
|
||||
const frame=p.frameLocator('iframe[src*="7778"]');
|
||||
const signIn=frame.getByRole('button',{name:'Sign In',exact:true});await expect(signIn).toBeVisible({timeout:30000});await signIn.click();
|
||||
proof.stage='choose native signer';
|
||||
const extension=frame.getByRole('button',{name:'Extension',exact:true});
|
||||
try{await expect(extension).toBeVisible({timeout:10000});await extension.click()}catch(e){console.log(JSON.stringify({...proof,stage:'auth choices',buttons:await frame.getByRole('button').allTextContents()}));throw e}
|
||||
proof.stage='approve login consent';
|
||||
const deadline=Date.now()+45000;let approved=0;
|
||||
while(Date.now()<deadline){
|
||||
if(proof.blockedUnexpectedRequest)throw new Error('An unexpected signing/payment request was blocked');
|
||||
if(proof.sessionResponses.some(s=>s===200||s===201)&&proof.profileResponses.includes(200))break;
|
||||
const approve=p.getByRole('button',{name:'Approve',exact:true});
|
||||
if(await approve.isVisible()){if(++approved>6)throw new Error('Unexpected repeated consent');await approve.click();await p.waitForTimeout(800)}else await p.waitForTimeout(250);
|
||||
}
|
||||
expect(proof.signedAuthRequests).toBeGreaterThan(0);expect(proof.sessionResponses.some(s=>s===200||s===201)).toBe(true);expect(proof.profileResponses).toContain(200);
|
||||
await expect(signIn).toHaveCount(0,{timeout:10000});
|
||||
proof.stage='reload authenticated app';
|
||||
const profilesBeforeReload=proof.profileResponses.filter(s=>s===200).length;
|
||||
await p.reload({waitUntil:'domcontentloaded'});
|
||||
await expect.poll(()=>proof.profileResponses.filter(s=>s===200).length,{timeout:30000}).toBeGreaterThan(profilesBeforeReload);
|
||||
await expect(frame.getByRole('button',{name:'Sign In',exact:true})).toHaveCount(0,{timeout:10000});
|
||||
proof.reloadRetainedLogin=true;proof.result='PASS';console.log(JSON.stringify(proof));
|
||||
}catch(e){console.log(JSON.stringify({...proof,result:'FAIL',path:page?new URL(page.url()).pathname:null,buttons:page?await page.getByRole('button').allTextContents():[]}));if(page)await page.screenshot({path:'/tmp/archy-indeehub-login-'+node+'-'+width+'-failure.png'});throw e}finally{await c.close()}
|
||||
}
|
||||
process.exit(0)
|
||||
})().catch(e=>{console.error(String(e.message).split('Call log:')[0]);process.exit(1)});
|
||||
Reference in New Issue
Block a user