Preserve slow custom-app overlays through their loading deadline
This commit is contained in:
@@ -741,3 +741,11 @@ passed 1,299 tests but timed out in a Bitcoin modal case and then failed its nex
|
|||||||
case; all 20 targeted tests including both modal cases pass on rerun. A new full
|
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.
|
run with reduced concurrency and no simultaneous build is required and running.
|
||||||
No failing full run is recorded as passed.
|
No failing full run is recorded as passed.
|
||||||
|
|
||||||
|
The reduced-concurrency full rerun passed **1,301/1,301 tests in 163 files**
|
||||||
|
(`/tmp/archy-slow-app-launch-full-tests-final.log`). Review then found the same
|
||||||
|
iframe-removal defect in the custom-app overlay's 15-second timeout. It now keeps
|
||||||
|
the frame alive too, sharing the same small loading notice. Three additional
|
||||||
|
legacy-overlay tests pass, together with all nine frame tests; both launch paths
|
||||||
|
are included in the served-browser harness. The final build and deployment for
|
||||||
|
this extension are pending; physical companion acceptance is not implied.
|
||||||
|
|||||||
@@ -92,6 +92,9 @@
|
|||||||
@error="onIframeError"
|
@error="onIframeError"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AppSlowLoadNotice v-if="slowLoad && !iframeBlocked" :title="store.title || 'App'"
|
||||||
|
@refresh="refreshIframe" @dismiss-slow-load="slowLoad = false" />
|
||||||
|
|
||||||
<!-- Iframe blocked fallback -->
|
<!-- Iframe blocked fallback -->
|
||||||
<Transition name="content-fade">
|
<Transition name="content-fade">
|
||||||
<div v-if="iframeBlocked && !iframeLoading" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
<div v-if="iframeBlocked && !iframeLoading" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||||
@@ -210,6 +213,7 @@ import { useAppLauncherStore } from '@/stores/appLauncher'
|
|||||||
import NostrSignConsent from '@/components/NostrSignConsent.vue'
|
import NostrSignConsent from '@/components/NostrSignConsent.vue'
|
||||||
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
|
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
|
||||||
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
|
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
|
||||||
|
import AppSlowLoadNotice from '@/components/AppSlowLoadNotice.vue'
|
||||||
import PaymentSuccessPane, { type SuccessRow } from '@/components/PaymentSuccessPane.vue'
|
import PaymentSuccessPane, { type SuccessRow } from '@/components/PaymentSuccessPane.vue'
|
||||||
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
|
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
@@ -234,6 +238,7 @@ const iframeRefreshKey = ref(0)
|
|||||||
const isRefreshing = ref(false)
|
const isRefreshing = ref(false)
|
||||||
const iframeLoading = ref(true)
|
const iframeLoading = ref(true)
|
||||||
const iframeBlocked = ref(false)
|
const iframeBlocked = ref(false)
|
||||||
|
const slowLoad = ref(false)
|
||||||
|
|
||||||
// Best-guess icon for the loading screen — resolved from the /app/{id}/ path
|
// Best-guess icon for the loading screen — resolved from the /app/{id}/ path
|
||||||
// when present; AppLoadingScreen's <img> falls back to the default icon if the
|
// when present; AppLoadingScreen's <img> falls back to the default icon if the
|
||||||
@@ -366,12 +371,14 @@ function refreshIframe() {
|
|||||||
isRefreshing.value = true
|
isRefreshing.value = true
|
||||||
iframeLoading.value = true
|
iframeLoading.value = true
|
||||||
iframeBlocked.value = false
|
iframeBlocked.value = false
|
||||||
|
slowLoad.value = false
|
||||||
clearTimers()
|
clearTimers()
|
||||||
iframeRefreshKey.value++
|
iframeRefreshKey.value++
|
||||||
loadTimeoutId = setTimeout(() => {
|
loadTimeoutId = setTimeout(() => {
|
||||||
if (iframeLoading.value) {
|
if (iframeLoading.value) {
|
||||||
iframeLoading.value = false
|
iframeLoading.value = false
|
||||||
iframeBlocked.value = true
|
isRefreshing.value = false
|
||||||
|
slowLoad.value = true
|
||||||
}
|
}
|
||||||
}, 15000)
|
}, 15000)
|
||||||
}
|
}
|
||||||
@@ -400,6 +407,8 @@ function openInNewTabAndClose() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onIframeLoad() {
|
function onIframeLoad() {
|
||||||
|
slowLoad.value = false
|
||||||
|
iframeBlocked.value = false
|
||||||
injectScrollbarHideIfSameOrigin()
|
injectScrollbarHideIfSameOrigin()
|
||||||
isRefreshing.value = false
|
isRefreshing.value = false
|
||||||
iframeLoading.value = false
|
iframeLoading.value = false
|
||||||
@@ -413,6 +422,8 @@ function onIframeLoad() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onIframeError() {
|
function onIframeError() {
|
||||||
|
slowLoad.value = false
|
||||||
|
isRefreshing.value = false
|
||||||
clearTimers()
|
clearTimers()
|
||||||
iframeLoading.value = false
|
iframeLoading.value = false
|
||||||
iframeBlocked.value = true
|
iframeBlocked.value = true
|
||||||
@@ -654,12 +665,14 @@ watch(
|
|||||||
if (open) {
|
if (open) {
|
||||||
iframeLoading.value = true
|
iframeLoading.value = true
|
||||||
iframeBlocked.value = false
|
iframeBlocked.value = false
|
||||||
|
slowLoad.value = false
|
||||||
clearTimers()
|
clearTimers()
|
||||||
// Set max load timeout — if iframe never fires load, show fallback
|
// Keep a slow navigation alive; its eventual load clears this notice.
|
||||||
loadTimeoutId = setTimeout(() => {
|
loadTimeoutId = setTimeout(() => {
|
||||||
if (iframeLoading.value) {
|
if (iframeLoading.value) {
|
||||||
iframeLoading.value = false
|
iframeLoading.value = false
|
||||||
iframeBlocked.value = true
|
isRefreshing.value = false
|
||||||
|
slowLoad.value = true
|
||||||
}
|
}
|
||||||
}, 15000)
|
}, 15000)
|
||||||
closeBtnRef.value?.focus()
|
closeBtnRef.value?.focus()
|
||||||
@@ -667,6 +680,7 @@ watch(
|
|||||||
isRefreshing.value = false
|
isRefreshing.value = false
|
||||||
iframeLoading.value = true
|
iframeLoading.value = true
|
||||||
iframeBlocked.value = false
|
iframeBlocked.value = false
|
||||||
|
slowLoad.value = false
|
||||||
clearTimers()
|
clearTimers()
|
||||||
// Clear any pending payment when closing
|
// Clear any pending payment when closing
|
||||||
if (pendingPayment.value) {
|
if (pendingPayment.value) {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<template>
|
||||||
|
<div 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">{{ title }} 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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ title: string }>()
|
||||||
|
defineEmits<{ refresh: []; dismissSlowLoad: [] }>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import AppLauncherOverlay from '../AppLauncherOverlay.vue'
|
||||||
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
|
vi.mock('@/stores/appLauncher', async () => {
|
||||||
|
const { reactive } = await import('vue')
|
||||||
|
const state = reactive({ isOpen: false, url: '', title: 'Custom app', showConsent: false, setNostrFrame: vi.fn(), close: vi.fn(), consentPhase: 'review' })
|
||||||
|
return { useAppLauncherStore: () => state }
|
||||||
|
})
|
||||||
|
vi.mock('@/composables/useLightningRequired', () => ({ useLightningRequired: () => ({}) }))
|
||||||
|
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
|
||||||
|
describe('legacy custom app slow loading', () => {
|
||||||
|
let wrapper: VueWrapper
|
||||||
|
beforeEach(() => { vi.useFakeTimers(); const store = useAppLauncherStore(); store.isOpen = false; store.url = ''; localStorage.clear() })
|
||||||
|
afterEach(() => { wrapper?.unmount(); vi.useRealTimers() })
|
||||||
|
async function render() {
|
||||||
|
wrapper = mount(AppLauncherOverlay, { global: { stubs: { Teleport: false, Transition: false, AppLoadingScreen: true, NostrSignConsent: true, NostrIdentityPicker: true, PaymentSuccessPane: true } } })
|
||||||
|
const store = useAppLauncherStore(); store.url = 'https://custom-app.invalid/'; store.isOpen = true
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
function iframe() { return document.querySelector('iframe')! }
|
||||||
|
it('retains the frame after the deadline and recovers on its eventual load', async () => {
|
||||||
|
await render(); const original = iframe()
|
||||||
|
expect(original).not.toBeNull()
|
||||||
|
await vi.advanceTimersByTimeAsync(16000); await nextTick()
|
||||||
|
expect(iframe()).toBe(original)
|
||||||
|
expect(document.querySelector('[role="status"]')?.textContent).toContain('Custom app is taking longer')
|
||||||
|
original.dispatchEvent(new Event('load')); await nextTick()
|
||||||
|
expect(iframe()).toBe(original)
|
||||||
|
expect(document.querySelector('[role="status"]')).toBeNull()
|
||||||
|
})
|
||||||
|
it('allows explicit retry and dismissing the second slow-load notice without another navigation', async () => {
|
||||||
|
await render(); const original = iframe()
|
||||||
|
await vi.advanceTimersByTimeAsync(16000); await nextTick()
|
||||||
|
const retry = document.querySelector('[role="status"] button') as HTMLButtonElement
|
||||||
|
retry.click(); await nextTick()
|
||||||
|
const retried = iframe(); expect(retried).not.toBe(original)
|
||||||
|
await vi.advanceTimersByTimeAsync(16000); await nextTick()
|
||||||
|
expect(iframe()).toBe(retried)
|
||||||
|
;(document.querySelector('[aria-label="Dismiss loading notice"]') as HTMLButtonElement).click(); await nextTick()
|
||||||
|
expect(document.querySelector('[role="status"]')).toBeNull()
|
||||||
|
expect(iframe()).toBe(retried)
|
||||||
|
})
|
||||||
|
it('does not leave a loading notice after closing the app', async () => {
|
||||||
|
await render(); useAppLauncherStore().isOpen = false; await nextTick()
|
||||||
|
await vi.advanceTimersByTimeAsync(16000); await nextTick()
|
||||||
|
expect(document.querySelector('iframe')).toBeNull()
|
||||||
|
expect(document.querySelector('[role="status"]')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -61,14 +61,8 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="slowLoad && !iframeBlocked && !uiReadyBlocked" role="status"
|
<AppSlowLoadNotice v-if="slowLoad && !iframeBlocked && !uiReadyBlocked" :title="appTitle"
|
||||||
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">
|
@refresh="$emit('refresh')" @dismiss-slow-load="$emit('dismissSlowLoad')" />
|
||||||
<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
|
<!-- Iframe blocked fallback. Suppressed while the ElectrumX sync screen
|
||||||
(the "pre UI") is showing: a still-syncing Electrum server isn't
|
(the "pre UI") is showing: a still-syncing Electrum server isn't
|
||||||
@@ -129,6 +123,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import AppSlowLoadNotice from '@/components/AppSlowLoadNotice.vue'
|
||||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import type { ElectrsSyncStatus } from '@/composables/useElectrsSync'
|
import type { ElectrsSyncStatus } from '@/composables/useElectrsSync'
|
||||||
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
|
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ const { chromium, expect } = require(process.env.PLAYWRIGHT_MODULE || '@playwrig
|
|||||||
(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)));
|
(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');
|
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');
|
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 port = mode === 'session' ? 7778 : 19999;
|
||||||
|
const fixtureUrl = new URL(origin); fixtureUrl.port = String(port);
|
||||||
const context = await browser.newContext({ viewport: { width, height: 900 }, serviceWorkers: 'block' });
|
const context = await browser.newContext({ viewport: { width, height: 900 }, serviceWorkers: 'block' });
|
||||||
let releaseDocument;
|
let releaseDocument;
|
||||||
const heldDocument = new Promise(resolve => { releaseDocument = resolve; });
|
const heldDocument = new Promise(resolve => { releaseDocument = resolve; });
|
||||||
@@ -23,7 +25,7 @@ const { chromium, expect } = require(process.env.PLAYWRIGHT_MODULE || '@playwrig
|
|||||||
localStorage.setItem('neode-auth', 'true');
|
localStorage.setItem('neode-auth', 'true');
|
||||||
localStorage.setItem('lnd-seed-backup-prompt-snooze-until', String(Date.now() + 3600000));
|
localStorage.setItem('lnd-seed-backup-prompt-snooze-until', String(Date.now() + 3600000));
|
||||||
});
|
});
|
||||||
await context.route('**:7778/**', async route => {
|
await context.route('**:' + port + '/**', async route => {
|
||||||
if (route.request().resourceType() !== 'document') return route.abort();
|
if (route.request().resourceType() !== 'document') return route.abort();
|
||||||
documentRequests++;
|
documentRequests++;
|
||||||
await heldDocument;
|
await heldDocument;
|
||||||
@@ -36,8 +38,12 @@ const { chromium, expect } = require(process.env.PLAYWRIGHT_MODULE || '@playwrig
|
|||||||
return route.continue();
|
return route.continue();
|
||||||
});
|
});
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
await page.goto(origin + '/dashboard/app-session/indeedhub', { waitUntil: 'domcontentloaded' });
|
await page.goto(origin + (mode === 'session' ? '/dashboard/app-session/indeedhub' : '/dashboard/apps'), { waitUntil: 'domcontentloaded' });
|
||||||
const frame = page.locator('iframe[src*="7778"]');
|
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: 'Custom app' }), fixtureUrl.href);
|
||||||
|
}
|
||||||
|
const frame = page.locator('iframe[src*="' + port + '"]');
|
||||||
await expect(frame).toHaveCount(1, { timeout: 30000 });
|
await expect(frame).toHaveCount(1, { timeout: 30000 });
|
||||||
await frame.evaluate(el => { window.__qualificationFrame = el; });
|
await frame.evaluate(el => { window.__qualificationFrame = el; });
|
||||||
const notice = page.getByRole('status').filter({ hasText: 'is taking longer to load' });
|
const notice = page.getByRole('status').filter({ hasText: 'is taking longer to load' });
|
||||||
@@ -45,11 +51,11 @@ const { chromium, expect } = require(process.env.PLAYWRIGHT_MODULE || '@playwrig
|
|||||||
expect(await frame.evaluate(el => el === window.__qualificationFrame)).toBe(true);
|
expect(await frame.evaluate(el => el === window.__qualificationFrame)).toBe(true);
|
||||||
await expect(page.getByRole('button', { name: 'Close companion modal', exact: true })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: 'Close companion modal', exact: true })).toHaveCount(0);
|
||||||
releaseDocument();
|
releaseDocument();
|
||||||
await expect(page.frameLocator('iframe[src*="7778"]').locator('#ready')).toHaveText('Delayed app ready', { timeout: 15000 });
|
await expect(page.frameLocator('iframe[src*="' + port + '"]').locator('#ready')).toHaveText('Delayed app ready', { timeout: 15000 });
|
||||||
await expect(notice).toHaveCount(0);
|
await expect(notice).toHaveCount(0);
|
||||||
expect(await frame.evaluate(el => el === window.__qualificationFrame)).toBe(true);
|
expect(await frame.evaluate(el => el === window.__qualificationFrame)).toBe(true);
|
||||||
// Modify readiness only in this isolated browser's store, never the node.
|
// Modify readiness only in this isolated browser's store, never the node.
|
||||||
for (const ready of [false, true]) {
|
if (mode === 'session') for (const ready of [false, true]) {
|
||||||
await page.evaluate(ready => {
|
await page.evaluate(ready => {
|
||||||
const app = document.querySelector('#app').__vue_app__.config.globalProperties.$pinia._s.get('app');
|
const app = document.querySelector('#app').__vue_app__.config.globalProperties.$pinia._s.get('app');
|
||||||
app.data['package-data'].indeedhub['ui-ready'] = ready;
|
app.data['package-data'].indeedhub['ui-ready'] = ready;
|
||||||
@@ -59,7 +65,7 @@ const { chromium, expect } = require(process.env.PLAYWRIGHT_MODULE || '@playwrig
|
|||||||
expect(await frame.evaluate(el => el === window.__qualificationFrame)).toBe(true);
|
expect(await frame.evaluate(el => el === window.__qualificationFrame)).toBe(true);
|
||||||
}
|
}
|
||||||
expect(documentRequests).toBe(1);
|
expect(documentRequests).toBe(1);
|
||||||
console.log(JSON.stringify({ node, width, result: 'PASS', delayedBeyondDeadline: true, sameIframeRetained: true, readinessFlapRetained: true, companionOfferDeferred: true, documentRequests }));
|
console.log(JSON.stringify({ node, mode, width, result: 'PASS', delayedBeyondDeadline: true, sameIframeRetained: true, readinessFlapRetained: mode === 'session' ? true : 'not-applicable', companionOfferDeferred: true, documentRequests }));
|
||||||
} finally {
|
} finally {
|
||||||
releaseDocument();
|
releaseDocument();
|
||||||
await context.close();
|
await context.close();
|
||||||
|
|||||||
Reference in New Issue
Block a user