diff --git a/core/http/react-ui/e2e/addons-busy-owner.spec.js b/core/http/react-ui/e2e/addons-busy-owner.spec.js new file mode 100644 index 0000000000..344f7acd2c --- /dev/null +++ b/core/http/react-ui/e2e/addons-busy-owner.spec.js @@ -0,0 +1,132 @@ +import { test, expect } from '@playwright/test' +import { ADMIN, app, installedAddon, sessionFor, mockAddons, fail } from './addons-fixtures.js' + +// The owner is busy with an upgrade. The routes are stand-ins +// (addons-fixtures.js); the owner's own behaviour is covered by the Go specs +// "reads during a long upgrade" and "apps list while the owner is busy". + +const shots = process.env.BUSY_SHOTS +const theme = process.env.BUSY_THEME +async function shoot(page, name) { + if (!shots) return + await page.emulateMedia({ reducedMotion: 'reduce' }) + await page.waitForTimeout(400) + await page.screenshot({ path: `${shots}/${name}${process.env.BUSY_SUFFIX || ''}.png`, fullPage: false }) +} + +const adminOf = (id, running = true) => ({ canStart: !running, canStop: running, managementUrl: `/app/addons?manage=${id}` }) +const notes = (over = {}) => app({ id: 'notes', name: 'notes', title: 'Notes', icon: undefined, mainPage: undefined, admin: adminOf('notes'), ...over }) +const sandbox = (over = {}) => app({ id: 'sandbox', name: 'sandbox', title: 'Sandbox', description: 'Run commands in a guest.', icon: undefined, mainPage: undefined, admin: adminOf('sandbox'), generation: 5, version: '2.1.0', ...over }) +const tables = (over = {}) => app({ id: 'tables', name: 'tables', title: 'Tables', description: 'Spreadsheets.', icon: undefined, mainPage: undefined, admin: adminOf('tables'), generation: 2, version: '0.9.0', ...over }) +const upgrading = (phase, since = '2026-11-04T17:18:00Z') => ({ kind: 'upgrade', phase, since }) +const tile = (page, id) => page.locator(`[data-testid="addon-tile"][data-app="${id}"]`) +const body = (payload) => ({ status: 200, contentType: 'application/json', body: JSON.stringify(payload) }) + +async function signIn(page) { + if (theme) await page.addInitScript((value) => { try { localStorage.setItem('localai-theme', value) } catch { /* private window */ } }, theme) + await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN) })) +} + +test.describe('Add-ons: the owner is busy', () => { + test('an error on the first read is not "no add-ons": the page says the server is busy and a retry loads the list', async ({ page }) => { + await signIn(page) + let busy = true + await mockAddons(page, { + apps: [notes(), tables()], + installed: [installedAddon({ id: 'notes' }), installedAddon({ id: 'tables', title: 'Tables' })], + appsList: () => (busy ? fail(503, 'owner_busy', { message: 'busy', code: 503 }) : null), + }) + await page.goto('/app/addons') + const error = page.getByTestId('addons-load-error') + await expect(error).toBeVisible() + await expect(error).toContainText('did not answer in time') + await expect(page.getByText('No add-ons yet')).toHaveCount(0) + await expect(page.getByTestId('addon-tile')).toHaveCount(0) + await shoot(page, 'busy-first-load') + busy = false + await error.getByRole('button').click() + await expect(tile(page, 'notes')).toBeVisible() + await expect(tile(page, 'tables')).toBeVisible() + await expect(page.getByTestId('addons-load-error')).toHaveCount(0) + }) + + test('a plain 500 is an error too, never the empty state', async ({ page }) => { + await signIn(page) + await mockAddons(page, { apps: [notes()], appsList: () => fail(500, 'server_error') }) + await page.goto('/app/addons') + await expect(page.getByTestId('addons-load-error')).toBeVisible() + await expect(page.getByText('No add-ons yet')).toHaveCount(0) + }) + + test('a stale list from the server keeps the rows, names what is upgrading and shows the phase of each row', async ({ page }) => { + await signIn(page) + await mockAddons(page, { + apps: [notes(), sandbox(), tables()], + installed: [installedAddon({ id: 'notes' })], + appsList: () => body({ + apps: [notes({ operation: upgrading('downloading') }), sandbox({ operation: upgrading('starting') }), tables()], + stale: true, staleReason: 'owner_busy', staleSince: '2026-11-04T17:18:30Z', + }), + }) + await page.goto('/app/addons') + const banner = page.getByTestId('addons-stale') + await expect(banner).toBeVisible() + await expect(banner).toContainText('The add-on server is busy (upgrading Notes, Sandbox)') + await expect(banner).toContainText('Showing the last known state.') + await expect(tile(page, 'tables')).toBeVisible() + await expect(tile(page, 'notes').getByTestId('operation-chip')).toContainText('Upgrading… · downloading and checking') + await expect(tile(page, 'sandbox').getByTestId('operation-chip')).toContainText('Upgrading… · starting') + await expect(tile(page, 'tables').getByTestId('operation-chip')).toHaveCount(0) + await expect(page.getByText('No add-ons yet')).toHaveCount(0) + await shoot(page, 'busy-stale-list') + }) + + test('a live answer with an upgrade in flight shows the rows as upgrading and no banner', async ({ page }) => { + await signIn(page) + await mockAddons(page, { apps: [notes({ operation: upgrading('backing-up') }), tables()] }) + await page.goto('/app/addons') + await expect(tile(page, 'notes').getByTestId('operation-chip')).toContainText('backing up data') + await expect(page.getByTestId('addons-stale')).toHaveCount(0) + }) + + test('a refresh while the server is busy shows the last list this tab got, marked as last known, and recovers by itself', async ({ page }) => { + await signIn(page) + let busy = false + await mockAddons(page, { + apps: [notes(), tables()], + appsList: () => (busy ? fail(503, 'owner_busy', { message: 'busy', code: 503 }) : null), + }) + await page.goto('/app/addons') + await expect(tile(page, 'notes')).toBeVisible() + busy = true + await page.reload() + const banner = page.getByTestId('addons-stale') + await expect(banner).toBeVisible() + await expect(banner).toContainText('Showing the last known state.') + await expect(tile(page, 'notes')).toBeVisible() + await expect(tile(page, 'tables')).toBeVisible() + await expect(page.getByTestId('addons-load-error')).toHaveCount(0) + await expect(page.getByText('No add-ons yet')).toHaveCount(0) + await shoot(page, 'busy-last-known') + busy = false + await banner.getByTestId('addons-stale-retry').click() + await expect(page.getByTestId('addons-stale')).toHaveCount(0) + await expect(tile(page, 'notes')).toBeVisible() + }) + + test('an empty list the owner answered is the only empty state', async ({ page }) => { + await signIn(page) + await mockAddons(page, { apps: [] }) + await page.goto('/app/addons') + await expect(page.getByText('No add-ons yet').first()).toBeVisible() + await expect(page.getByTestId('addons-load-error')).toHaveCount(0) + }) + + test('a stale copy of an empty list is not the empty state either', async ({ page }) => { + await signIn(page) + await mockAddons(page, { apps: [], appsList: () => body({ apps: [], stale: true, staleReason: 'owner_busy' }) }) + await page.goto('/app/addons') + await expect(page.getByTestId('addons-load-error')).toBeVisible() + await expect(page.getByText('No add-ons yet')).toHaveCount(0) + }) +}) diff --git a/core/http/react-ui/public/locales/de/addons.json b/core/http/react-ui/public/locales/de/addons.json index b8bb98595e..33a8eba1c2 100644 --- a/core/http/react-ui/public/locales/de/addons.json +++ b/core/http/react-ui/public/locales/de/addons.json @@ -66,6 +66,13 @@ "loadError": { "title": "Add-ons sind gerade nicht verfügbar.", "paused": "Add-on-Seiten und -Werkzeuge pausieren, bis er wieder da ist." + }, + "busy": { + "upgrading": "Der Add-on-Server ist beschäftigt (aktualisiert {{names}})", + "generic": "Der Add-on-Server ist beschäftigt", + "unreachable": "Der Add-on-Server ist nicht erreichbar", + "reason": "Es wird der zuletzt bekannte Stand gezeigt. Die Seite fragt selbstständig erneut.", + "retry": "Jetzt erneut versuchen" } }, "browse": { @@ -138,7 +145,8 @@ "invalid": "Der Server konnte diese Anfrage nicht annehmen.", "network": "Der Server ist nicht erreichbar. Prüfe deine Verbindung.", "unavailable": "Add-ons sind gerade nicht verfügbar. Versuche es gleich noch einmal.", - "unknownOutcome": "Das Ergebnis konnte nicht bestätigt werden. Prüfe den Status, bevor du es erneut versuchst." + "unknownOutcome": "Das Ergebnis konnte nicht bestätigt werden. Prüfe den Status, bevor du es erneut versuchst.", + "busy": "Der Add-on-Server ist beschäftigt und hat nicht rechtzeitig geantwortet. Versuche es gleich noch einmal." }, "installed": { "disabled": "Aus", @@ -935,5 +943,25 @@ "enableFailedTitle": "{{name}} konnte nicht eingeschaltet werden", "incompatibleTitle": "{{name}} lässt sich nicht einschalten: nicht mit diesem Server kompatibel", "noUpgrade": "Es ist noch keine neuere Version gelistet. Lass es aus oder entferne es." + }, + "operation": { + "kind": { + "install": "Wird installiert…", + "upgrade": "Wird aktualisiert…", + "uninstall": "Wird entfernt…", + "enable": "Wird gestartet…", + "disable": "Wird gestoppt…", + "working": "In Arbeit…" + }, + "label": "In Arbeit: {{operation}}", + "phase": { + "queued": "wartet", + "downloading": "Download und Prüfung", + "backing-up": "Datensicherung", + "installing": "Installation", + "migrating": "Datenmigration", + "starting": "Start", + "working": "in Arbeit" + } } } diff --git a/core/http/react-ui/public/locales/en/addons.json b/core/http/react-ui/public/locales/en/addons.json index 68113b45f4..460c75326d 100644 --- a/core/http/react-ui/public/locales/en/addons.json +++ b/core/http/react-ui/public/locales/en/addons.json @@ -66,6 +66,13 @@ "loadError": { "title": "Add-ons are not available right now.", "paused": "Add-on pages and tools are paused until it is back." + }, + "busy": { + "upgrading": "The add-on server is busy (upgrading {{names}})", + "generic": "The add-on server is busy", + "unreachable": "The add-on server cannot be reached", + "reason": "Showing the last known state. This page asks again by itself.", + "retry": "Try again now" } }, "browse": { @@ -138,7 +145,8 @@ "invalid": "The server could not accept this request.", "network": "The server could not be reached. Check your connection.", "unavailable": "Add-ons are not available right now. Try again in a moment.", - "unknownOutcome": "The result could not be confirmed. Check the status before you try again." + "unknownOutcome": "The result could not be confirmed. Check the status before you try again.", + "busy": "The add-on server is busy and did not answer in time. Try again in a moment." }, "installed": { "disabled": "Off", @@ -935,5 +943,25 @@ "enableFailedTitle": "Could not turn on {{name}}", "incompatibleTitle": "Cannot turn on {{name}}: not compatible with this server", "noUpgrade": "No newer version is listed yet. Keep it off, or remove it." + }, + "operation": { + "kind": { + "install": "Installing…", + "upgrade": "Upgrading…", + "uninstall": "Removing…", + "enable": "Starting…", + "disable": "Stopping…", + "working": "Working…" + }, + "label": "In progress: {{operation}}", + "phase": { + "queued": "waiting", + "downloading": "downloading and checking", + "backing-up": "backing up data", + "installing": "installing", + "migrating": "migrating data", + "starting": "starting", + "working": "working" + } } } diff --git a/core/http/react-ui/public/locales/es/addons.json b/core/http/react-ui/public/locales/es/addons.json index d935d9e56a..f940a8ab0a 100644 --- a/core/http/react-ui/public/locales/es/addons.json +++ b/core/http/react-ui/public/locales/es/addons.json @@ -66,6 +66,13 @@ "loadError": { "title": "Los complementos no están disponibles ahora.", "paused": "Las páginas y herramientas de los complementos quedan en pausa hasta que vuelva." + }, + "busy": { + "upgrading": "El servidor de complementos está ocupado (actualizando {{names}})", + "generic": "El servidor de complementos está ocupado", + "unreachable": "No se puede acceder al servidor de complementos", + "reason": "Se muestra el último estado conocido. La página lo intenta de nuevo sola.", + "retry": "Reintentar ahora" } }, "browse": { @@ -138,7 +145,8 @@ "invalid": "El servidor no pudo aceptar la solicitud.", "network": "No se pudo contactar con el servidor. Revisa tu conexión.", "unavailable": "Los complementos no están disponibles ahora. Inténtalo de nuevo en un momento.", - "unknownOutcome": "No se pudo confirmar el resultado. Comprueba el estado antes de volver a intentarlo." + "unknownOutcome": "No se pudo confirmar el resultado. Comprueba el estado antes de volver a intentarlo.", + "busy": "El servidor de complementos está ocupado y no respondió a tiempo. Inténtalo de nuevo en un momento." }, "installed": { "disabled": "Desactivado", @@ -935,5 +943,25 @@ "enableFailedTitle": "No se pudo activar {{name}}", "incompatibleTitle": "No se puede activar {{name}}: no es compatible con este servidor", "noUpgrade": "Todavía no hay una versión más reciente en la lista. Déjalo desactivado o quítalo." + }, + "operation": { + "kind": { + "install": "Instalando…", + "upgrade": "Actualizando…", + "uninstall": "Quitando…", + "enable": "Iniciando…", + "disable": "Deteniendo…", + "working": "Trabajando…" + }, + "label": "En curso: {{operation}}", + "phase": { + "queued": "en espera", + "downloading": "descarga y comprobación", + "backing-up": "copia de los datos", + "installing": "instalación", + "migrating": "migración de datos", + "starting": "inicio", + "working": "en curso" + } } } diff --git a/core/http/react-ui/public/locales/fr/addons.json b/core/http/react-ui/public/locales/fr/addons.json index 91e6c1bc86..992614ee89 100644 --- a/core/http/react-ui/public/locales/fr/addons.json +++ b/core/http/react-ui/public/locales/fr/addons.json @@ -68,6 +68,13 @@ "loadError": { "title": "Les add-ons ne sont pas disponibles pour le moment.", "paused": "Les pages et outils des add-ons sont en pause jusqu’à son retour." + }, + "busy": { + "upgrading": "Le serveur de modules est occupé (mise à jour de {{names}})", + "generic": "Le serveur de modules est occupé", + "unreachable": "Le serveur de modules est injoignable", + "reason": "Dernier état connu affiché. La page réessaie toute seule.", + "retry": "Réessayer maintenant" } }, "browse": { @@ -141,7 +148,8 @@ "invalid": "Le serveur n'a pas pu accepter cette demande.", "network": "Le serveur est injoignable. Vérifiez votre connexion.", "unavailable": "Les modules ne sont pas disponibles pour le moment. Réessayez dans un instant.", - "unknownOutcome": "Le résultat n'a pas pu être confirmé. Vérifiez l'état avant de réessayer." + "unknownOutcome": "Le résultat n'a pas pu être confirmé. Vérifiez l'état avant de réessayer.", + "busy": "Le serveur de modules est occupé et n'a pas répondu à temps. Réessayez dans un instant." }, "installed": { "disabled": "Désactivé", @@ -939,5 +947,25 @@ "enableFailedTitle": "Impossible d'activer {{name}}", "incompatibleTitle": "Impossible d'activer {{name}} : incompatible avec ce serveur", "noUpgrade": "Aucune version plus récente n'est encore listée. Laissez-le désactivé ou supprimez-le." + }, + "operation": { + "kind": { + "install": "Installation…", + "upgrade": "Mise à jour…", + "uninstall": "Suppression…", + "enable": "Démarrage…", + "disable": "Arrêt…", + "working": "En cours…" + }, + "label": "En cours : {{operation}}", + "phase": { + "queued": "en attente", + "downloading": "téléchargement et vérification", + "backing-up": "sauvegarde des données", + "installing": "installation", + "migrating": "migration des données", + "starting": "démarrage", + "working": "en cours" + } } } diff --git a/core/http/react-ui/public/locales/id/addons.json b/core/http/react-ui/public/locales/id/addons.json index 8188c29f91..28816dcaa3 100644 --- a/core/http/react-ui/public/locales/id/addons.json +++ b/core/http/react-ui/public/locales/id/addons.json @@ -66,6 +66,13 @@ "loadError": { "title": "Add-on tidak tersedia saat ini.", "paused": "Halaman dan alat add-on dijeda sampai layanan kembali." + }, + "busy": { + "upgrading": "Server add-on sedang sibuk (memperbarui {{names}})", + "generic": "Server add-on sedang sibuk", + "unreachable": "Server add-on tidak dapat dijangkau", + "reason": "Menampilkan status terakhir yang diketahui. Halaman ini mencoba lagi sendiri.", + "retry": "Coba lagi sekarang" } }, "browse": { @@ -138,7 +145,8 @@ "invalid": "Server tidak dapat menerima permintaan ini.", "network": "Server tidak dapat dijangkau. Periksa koneksi Anda.", "unavailable": "Add-on tidak tersedia saat ini. Coba lagi sebentar lagi.", - "unknownOutcome": "Hasil tidak dapat dikonfirmasi. Periksa statusnya sebelum mencoba lagi." + "unknownOutcome": "Hasil tidak dapat dikonfirmasi. Periksa statusnya sebelum mencoba lagi.", + "busy": "Server add-on sedang sibuk dan tidak menjawab tepat waktu. Coba lagi sebentar lagi." }, "installed": { "disabled": "Nonaktif", @@ -935,5 +943,25 @@ "enableFailedTitle": "{{name}} tidak dapat diaktifkan", "incompatibleTitle": "{{name}} tidak dapat diaktifkan: tidak kompatibel dengan server ini", "noUpgrade": "Belum ada versi yang lebih baru di daftar. Biarkan mati, atau hapus." + }, + "operation": { + "kind": { + "install": "Memasang…", + "upgrade": "Memperbarui…", + "uninstall": "Menghapus…", + "enable": "Memulai…", + "disable": "Menghentikan…", + "working": "Bekerja…" + }, + "label": "Sedang berjalan: {{operation}}", + "phase": { + "queued": "menunggu", + "downloading": "mengunduh dan memeriksa", + "backing-up": "mencadangkan data", + "installing": "memasang", + "migrating": "memigrasi data", + "starting": "memulai", + "working": "bekerja" + } } } diff --git a/core/http/react-ui/public/locales/it/addons.json b/core/http/react-ui/public/locales/it/addons.json index d91b53e6a7..37b7b1159c 100644 --- a/core/http/react-ui/public/locales/it/addons.json +++ b/core/http/react-ui/public/locales/it/addons.json @@ -66,6 +66,13 @@ "loadError": { "title": "I componenti aggiuntivi non sono disponibili ora.", "paused": "Pagine e strumenti dei componenti sono in pausa finché non torna." + }, + "busy": { + "upgrading": "Il server dei componenti è occupato (aggiorna {{names}})", + "generic": "Il server dei componenti è occupato", + "unreachable": "Il server dei componenti non è raggiungibile", + "reason": "Mostro l'ultimo stato noto. La pagina riprova da sola.", + "retry": "Riprova ora" } }, "browse": { @@ -138,7 +145,8 @@ "invalid": "Il server non ha potuto accettare la richiesta.", "network": "Impossibile raggiungere il server. Controlla la connessione.", "unavailable": "I componenti non sono disponibili al momento. Riprova tra poco.", - "unknownOutcome": "Non è stato possibile confermare il risultato. Controlla lo stato prima di riprovare." + "unknownOutcome": "Non è stato possibile confermare il risultato. Controlla lo stato prima di riprovare.", + "busy": "Il server dei componenti è occupato e non ha risposto in tempo. Riprova tra poco." }, "installed": { "disabled": "Disattivato", @@ -935,5 +943,25 @@ "enableFailedTitle": "Impossibile attivare {{name}}", "incompatibleTitle": "Impossibile attivare {{name}}: non compatibile con questo server", "noUpgrade": "Non è ancora elencata una versione più recente. Lascialo disattivato o rimuovilo." + }, + "operation": { + "kind": { + "install": "Installazione…", + "upgrade": "Aggiornamento…", + "uninstall": "Rimozione…", + "enable": "Avvio…", + "disable": "Arresto…", + "working": "In corso…" + }, + "label": "In corso: {{operation}}", + "phase": { + "queued": "in attesa", + "downloading": "download e verifica", + "backing-up": "copia dei dati", + "installing": "installazione", + "migrating": "migrazione dei dati", + "starting": "avvio", + "working": "in corso" + } } } diff --git a/core/http/react-ui/public/locales/ko/addons.json b/core/http/react-ui/public/locales/ko/addons.json index 7258c3fd3e..de1c5573e7 100644 --- a/core/http/react-ui/public/locales/ko/addons.json +++ b/core/http/react-ui/public/locales/ko/addons.json @@ -66,6 +66,13 @@ "loadError": { "title": "지금은 애드온을 사용할 수 없습니다.", "paused": "서비스가 돌아올 때까지 애드온 페이지와 도구는 일시 중지됩니다." + }, + "busy": { + "upgrading": "애드온 서버가 바쁩니다({{names}} 업그레이드 중)", + "generic": "애드온 서버가 바쁩니다", + "unreachable": "애드온 서버에 연결할 수 없습니다", + "reason": "마지막으로 알려진 상태를 표시합니다. 이 페이지는 스스로 다시 시도합니다.", + "retry": "지금 다시 시도" } }, "browse": { @@ -138,7 +145,8 @@ "invalid": "서버가 이 요청을 받아들일 수 없습니다.", "network": "서버에 연결할 수 없습니다. 연결을 확인하세요.", "unavailable": "지금은 애드온을 사용할 수 없습니다. 잠시 후 다시 시도하세요.", - "unknownOutcome": "결과를 확인할 수 없습니다. 다시 시도하기 전에 상태를 확인하세요." + "unknownOutcome": "결과를 확인할 수 없습니다. 다시 시도하기 전에 상태를 확인하세요.", + "busy": "애드온 서버가 바빠서 제때 응답하지 못했습니다. 잠시 후 다시 시도하세요." }, "installed": { "disabled": "꺼짐", @@ -935,5 +943,25 @@ "enableFailedTitle": "{{name}}을(를) 켤 수 없습니다", "incompatibleTitle": "{{name}}을(를) 켤 수 없습니다: 이 서버와 호환되지 않습니다", "noUpgrade": "아직 더 새로운 버전이 목록에 없습니다. 꺼 두거나 제거하세요." + }, + "operation": { + "kind": { + "install": "설치 중…", + "upgrade": "업그레이드 중…", + "uninstall": "제거 중…", + "enable": "시작 중…", + "disable": "중지 중…", + "working": "작업 중…" + }, + "label": "진행 중: {{operation}}", + "phase": { + "queued": "대기 중", + "downloading": "다운로드 및 확인", + "backing-up": "데이터 백업", + "installing": "설치", + "migrating": "데이터 마이그레이션", + "starting": "시작", + "working": "작업 중" + } } } diff --git a/core/http/react-ui/public/locales/pt-BR/addons.json b/core/http/react-ui/public/locales/pt-BR/addons.json index 22dfa21f95..221ffe3588 100644 --- a/core/http/react-ui/public/locales/pt-BR/addons.json +++ b/core/http/react-ui/public/locales/pt-BR/addons.json @@ -66,6 +66,13 @@ "loadError": { "title": "Os complementos não estão disponíveis agora.", "paused": "Páginas e ferramentas dos complementos ficam pausadas até ele voltar." + }, + "busy": { + "upgrading": "O servidor de complementos está ocupado (atualizando {{names}})", + "generic": "O servidor de complementos está ocupado", + "unreachable": "Não foi possível alcançar o servidor de complementos", + "reason": "Mostrando o último estado conhecido. A página tenta de novo sozinha.", + "retry": "Tentar agora" } }, "browse": { @@ -138,7 +145,8 @@ "invalid": "O servidor não pôde aceitar esta solicitação.", "network": "Não foi possível alcançar o servidor. Verifique sua conexão.", "unavailable": "Os complementos não estão disponíveis agora. Tente de novo em instantes.", - "unknownOutcome": "Não foi possível confirmar o resultado. Verifique o estado antes de tentar de novo." + "unknownOutcome": "Não foi possível confirmar o resultado. Verifique o estado antes de tentar de novo.", + "busy": "O servidor de complementos está ocupado e não respondeu a tempo. Tente de novo em instantes." }, "installed": { "disabled": "Desativado", @@ -935,5 +943,25 @@ "enableFailedTitle": "Não foi possível ativar {{name}}", "incompatibleTitle": "Não é possível ativar {{name}}: incompatível com este servidor", "noUpgrade": "Ainda não há uma versão mais nova na lista. Deixe-o desativado ou remova-o." + }, + "operation": { + "kind": { + "install": "Instalando…", + "upgrade": "Atualizando…", + "uninstall": "Removendo…", + "enable": "Iniciando…", + "disable": "Parando…", + "working": "Trabalhando…" + }, + "label": "Em andamento: {{operation}}", + "phase": { + "queued": "aguardando", + "downloading": "baixando e verificando", + "backing-up": "copiando os dados", + "installing": "instalando", + "migrating": "migrando os dados", + "starting": "iniciando", + "working": "em andamento" + } } } diff --git a/core/http/react-ui/public/locales/zh-CN/addons.json b/core/http/react-ui/public/locales/zh-CN/addons.json index bbf554d64f..b134bebc88 100644 --- a/core/http/react-ui/public/locales/zh-CN/addons.json +++ b/core/http/react-ui/public/locales/zh-CN/addons.json @@ -66,6 +66,13 @@ "loadError": { "title": "附加组件暂时不可用。", "paused": "在它恢复之前,附加组件的页面和工具会暂停。" + }, + "busy": { + "upgrading": "附加组件服务器正忙(正在升级 {{names}})", + "generic": "附加组件服务器正忙", + "unreachable": "无法连接附加组件服务器", + "reason": "显示的是最近一次已知的状态。本页会自动重试。", + "retry": "立即重试" } }, "browse": { @@ -138,7 +145,8 @@ "invalid": "服务器无法接受此请求。", "network": "无法连接服务器,请检查网络。", "unavailable": "附加组件暂时不可用,请稍后重试。", - "unknownOutcome": "无法确认结果。请先检查状态,再重试。" + "unknownOutcome": "无法确认结果。请先检查状态,再重试。", + "busy": "附加组件服务器正忙,未能及时响应。请稍后再试。" }, "installed": { "disabled": "已关闭", @@ -935,5 +943,25 @@ "enableFailedTitle": "无法打开 {{name}}", "incompatibleTitle": "无法打开 {{name}}:与此服务器不兼容", "noUpgrade": "目前还没有列出更新的版本。请保持关闭,或将其移除。" + }, + "operation": { + "kind": { + "install": "正在安装…", + "upgrade": "正在升级…", + "uninstall": "正在移除…", + "enable": "正在启动…", + "disable": "正在停止…", + "working": "处理中…" + }, + "label": "进行中:{{operation}}", + "phase": { + "queued": "等待中", + "downloading": "下载并校验", + "backing-up": "备份数据", + "installing": "安装", + "migrating": "迁移数据", + "starting": "启动", + "working": "处理中" + } } } diff --git a/core/http/react-ui/src/components/addons/AddonBits.jsx b/core/http/react-ui/src/components/addons/AddonBits.jsx index 9fde40ba97..b937e50408 100644 --- a/core/http/react-ui/src/components/addons/AddonBits.jsx +++ b/core/http/react-ui/src/components/addons/AddonBits.jsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next' import Icon from '../Icon' import StatusChip from '../ui/StatusChip' import Button from '../ui/Button' -import { addedByEntry, hueOf, monogram, stateChip, versionLabel } from './addonState' +import { addedByEntry, hueOf, monogram, operationOf, stateChip, versionLabel } from './addonState' import { installSteps } from './installSteps' import './addons.css' @@ -22,6 +22,21 @@ export function StateChip({ app, className = '' }) { ) } +// The operation the owner runs on an add-on now ("Upgrading… Downloading and +// checking"). It is separate from the process state: the old generation can +// keep running while the next one is fetched and checked. +export function OperationChip({ app, className = '' }) { + const { t } = useTranslation('addons') + const op = operationOf(app) + if (!op) return null + const text = `${t(`operation.kind.${op.kind}`)} · ${t(`operation.phase.${op.phase}`)}` + return ( + + + + ) +} + // The mark of an update the owner's check found. It is a separate chip from the // process state: an add-on can be running and have an update pending. With the // version known (administrators), the chip names it. diff --git a/core/http/react-ui/src/components/addons/AddonRow.jsx b/core/http/react-ui/src/components/addons/AddonRow.jsx index 3f4364543d..242514bc43 100644 --- a/core/http/react-ui/src/components/addons/AddonRow.jsx +++ b/core/http/react-ui/src/components/addons/AddonRow.jsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import Icon from '../Icon' import Button from '../ui/Button' import { apiUrl } from '../../utils/basePath' -import { AppIcon, ApprovalChip, BuiltinIcon, StateChip, UpdateChip, VersionText } from './AddonBits' +import { AppIcon, ApprovalChip, BuiltinIcon, OperationChip, StateChip, UpdateChip, VersionText } from './AddonBits' import AddonMenu from './AddonMenu' import { isLocalFile } from './fileInstall' import { addedByAddon, stateChip } from './addonState' @@ -37,7 +37,8 @@ export default function AddonRow({ tile, pinned, selected = false, attention = ' {title} {app && } - {app && !waitingApproval && (app.update === 'update-pending' || updateVersion) && } + {app && } + {app && !app.operation && !waitingApproval && (app.update === 'update-pending' || updateVersion) && } {app && waitingApproval && } {tile.description && {tile.description}} diff --git a/core/http/react-ui/src/components/addons/AppsView.jsx b/core/http/react-ui/src/components/addons/AppsView.jsx index 43b23fba88..a6f5acf19b 100644 --- a/core/http/react-ui/src/components/addons/AppsView.jsx +++ b/core/http/react-ui/src/components/addons/AppsView.jsx @@ -2,7 +2,7 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { useOutletContext, useSearchParams } from 'react-router-dom' import { useTranslation } from 'react-i18next' -import { Button, Callout, EmptyState, LoadError, SearchField, Segmented, SkeletonRows } from '../ui' +import { Button, Callout, EmptyState, LoadError, Notice, SearchField, Segmented, SkeletonRows } from '../ui' import { newOperationId, pollDelay } from '../catalog/catalogState' import { RegistryContext } from '../../addons/registryContext' import { navigationItems } from '../../addons/registryState' @@ -27,7 +27,7 @@ import { useBulkUpgrade } from '../../hooks/useBulkUpgrade' import { updateView, upgradeAvailability } from './updateState' import { upgradeFailureWords } from './updateReview' import FirstRun from './FirstRun' -import { addedByAddon, attentionOf, filterByState, listState, sortByAttention, STATE_FILTERS } from './addonState' +import { addedByAddon, attentionOf, filterByState, listState, loadLastKnown, saveLastKnown, sortByAttention, staleKind, STATE_FILTERS, upgradingTitles } from './addonState' import { describeError, describeOutcome } from './errors' import ApproveUpgradeDialog, { WaitingBanner } from './ApproveUpgradeDialog' import { pendingApprovals } from './hostGrantsState' @@ -38,9 +38,14 @@ import { useAddonActions } from './useAddonActions' // cadence, slower in a hidden tab and backing off while the owner is // unreachable. Start and stop are offered to administrators and the server // refuses everyone else whatever this view shows. +// The tab keeps the last list it got for this person. A page loaded while the +// owner is busy has nothing in memory, and an empty page would read as "no +// add-ons"; the stored copy stands in for the list, marked stale. +const browserStore = () => { try { return globalThis.sessionStorage || null } catch { return null } } + export default function AppsView({ api, adapter, onSummary, grants }) { const { t, i18n } = useTranslation('addons') - const [storedState, setState] = useState({ status: 'loading', apps: [], error: null, stale: false }) + const [storedState, setState] = useState({ status: 'loading', apps: [], error: null, stale: false, staleReason: '' }) const [reload, setReload] = useState(0) const reloadRef = useRef(() => {}) const { hasFeature, isAdmin, user, sessionEpoch } = useAuth() @@ -52,7 +57,7 @@ export default function AppsView({ api, adapter, onSummary, grants }) { const actor = JSON.stringify([user?.id, sessionEpoch, isAdmin, canList]) const scope = useRef(actor) scope.current = actor - const state = storedState.actor === actor ? storedState : { status: 'loading', apps: [], error: null, stale: false } + const state = storedState.actor === actor ? storedState : { status: 'loading', apps: [], error: null, stale: false, staleReason: '' } const ingest = registry?.ingest // Poll with a timer chain: one request at a time, aborted on unmount. A @@ -71,17 +76,31 @@ export default function AppsView({ api, adapter, onSummary, grants }) { if (inflight) { queued = true; return } inflight = true try { - const apps = await api.apps.list(ctl.signal) - failures = 0 + const read = await api.apps.read(ctl.signal) + const apps = read.apps + // The server answered with its last copy: the owner did not. Ask + // again with the same backoff as for a failed read. + failures = read.stale ? failures + 1 : 0 if (alive && current()) { - setState({ actor, status: 'ready', apps, error: null, stale: false }) - ingest?.(apps) + setState({ actor, status: 'ready', apps, error: null, stale: read.stale, staleReason: read.staleReason }) + if (!read.stale) { + ingest?.(apps) + const store = browserStore() + if (store) saveLastKnown(store, actor, apps) + } } } catch (error) { if (!alive || !current() || (error && error.name === 'AbortError')) return failures += 1 - // Keep what was on screen and say it may be out of date. - setState(s => ({ actor, status: s.actor === actor && s.status === 'ready' ? 'ready' : 'error', apps: s.actor === actor ? s.apps : [], error, stale: true })) + // Keep what was on screen and say it may be out of date. A page that + // has nothing yet takes the copy this tab stored, so a refresh during + // an upgrade does not show an empty page. + const store = browserStore() + const remembered = store ? loadLastKnown(store, actor) : null + setState(s => { + const kept = s.actor === actor && s.status === 'ready' ? s.apps : remembered + return { actor, status: kept ? 'ready' : 'error', apps: kept || [], error, stale: true, staleReason: '' } + }) } finally { inflight = false } @@ -242,7 +261,7 @@ export default function AppsView({ api, adapter, onSummary, grants }) { }, }) - const firstRunNow = state.status === 'ready' && listState({ apps: state.apps, shownCount: tiles.length, query: '', isAdmin, canList }) === 'first-run' + const firstRunNow = state.status === 'ready' && !state.stale && listState({ apps: state.apps, shownCount: tiles.length, query: '', isAdmin, canList }) === 'first-run' useEffect(() => { if (!onSummary) return onSummary(state.status === 'ready' ? { count: tiles.length, firstRun: firstRunNow } : null) @@ -287,17 +306,20 @@ export default function AppsView({ api, adapter, onSummary, grants }) { if (state.status === 'loading') { return } - if (state.status === 'error') { - const kind = state.error && state.error.kind + // An error, or a stale copy of an empty list, is never the empty state: the + // page only says "no add-ons" when the owner answered with an empty list. + if (state.status === 'error' || (state.stale && state.apps.length === 0)) { + const loadError = state.error || { kind: 'busy' } + const kind = loadError.kind return ( { setState({ status: 'loading', apps: [], error: null, stale: false }); setReload(n => n + 1) }} + onRetry={() => { setState({ status: 'loading', apps: [], error: null, stale: false, staleReason: '' }); setReload(n => n + 1) }} > {[ - describeError(state.error, t), + describeError(loadError, t), kind !== 'auth' && kind !== 'denied' && kind !== 'invalid' ? t('apps.loadError.paused') : '', t('common:ui.loadError.nothingChanged'), ].filter(Boolean).join(' ')} @@ -315,9 +337,7 @@ export default function AppsView({ api, adapter, onSummary, grants }) { const showAttention = !firstRun && (attention.failed.length > 0 || attention.updates.length > 0 || approvals.length > 0) return (
- {state.stale && ( - {t('apps.stale')} - )} + {state.stale && reloadRef.current()} />} {Object.entries(actions.state).filter(([key, r]) => key.startsWith('uninstall:') && r.outcome && r.outcome !== 'completed' && !actions.unknown[key.split(':')[1]]).map(([key, r]) => ( {describeOutcome(r.outcome, t, r.error)} ))} @@ -430,7 +450,7 @@ export default function AppsView({ api, adapter, onSummary, grants }) { isAdmin={isAdmin} upgrade={upgrade} variant="secondary" - working={Boolean(actions.unknown[tile.id]) || upgrade.working(tile.id)} + working={Boolean(actions.unknown[tile.id]) || upgrade.working(tile.id) || Boolean(tile.app.operation)} /> ) : null} /> @@ -510,3 +530,26 @@ export default function AppsView({ api, adapter, onSummary, grants }) {
) } + +// The banner over a list that may be out of date. It says why, names the +// add-ons the owner is upgrading when the last list knew of it, and offers a +// retry now; the page also asks again by itself, slower after each failure. +function StaleNotice({ state, onRetry }) { + const { t } = useTranslation('addons') + const kind = staleKind(state) + const names = upgradingTitles(state.apps) + const title = kind === 'unreachable' + ? t('apps.busy.unreachable') + : names.length > 0 ? t('apps.busy.upgrading', { names: names.join(', ') }) : t('apps.busy.generic') + return ( + + ) +} diff --git a/core/http/react-ui/src/components/addons/addonState.js b/core/http/react-ui/src/components/addons/addonState.js index 4e0128c7f4..8572d976cc 100644 --- a/core/http/react-ui/src/components/addons/addonState.js +++ b/core/http/react-ui/src/components/addons/addonState.js @@ -254,3 +254,62 @@ export function skillsOf(app) { const list = Array.isArray(app?.skills) ? app.skills : [] return list.filter(s => s && typeof s.name === 'string').map(s => ({ name: s.name, replaces: s.shadows === s.name })) } + +// ---- Operations on the owner ---------------------------------------------- + +const OPERATION_KINDS = new Set(['install', 'upgrade', 'uninstall', 'enable', 'disable']) +const OPERATION_PHASES = new Set(['queued', 'downloading', 'backing-up', 'installing', 'migrating', 'starting', 'working']) + +// The operation the owner runs on an add-on now, as the list reports it. A kind +// or phase this page does not know is shown as plain work, never dropped: +// the add-on is still being changed. +export function operationOf(app) { + const op = app && typeof app === 'object' ? app.operation : null + if (!op || typeof op !== 'object') return null + return { + kind: OPERATION_KINDS.has(op.kind) ? op.kind : 'working', + phase: OPERATION_PHASES.has(op.phase) ? op.phase : 'working', + since: typeof op.since === 'string' ? op.since : '', + } +} + +// The titles of the add-ons the owner is upgrading, for the busy banner. +export function upgradingTitles(apps) { + return (Array.isArray(apps) ? apps : []) + .filter(a => a && a.operation && a.operation.kind === 'upgrade') + .map(a => a.title || a.id) +} + +// Why the list on screen may be out of date: 'busy' (the owner did not answer +// in time or the server sent its last copy), 'unreachable' or '' when it is +// current. +export function staleKind({ error, stale, staleReason }) { + if (!stale) return '' + if (error && error.kind === 'busy') return 'busy' + if (staleReason === 'owner_busy') return 'busy' + if (error || staleReason === 'owner_unavailable') return 'unreachable' + return 'busy' +} + +// ---- The last list a browser got ------------------------------------------- + +const LAST_KEY = 'localai.addons.lastKnownApps.v1' + +// A page loaded while the owner is busy has no list in memory. The last list +// this browser tab got for the same person stands in for it, marked stale. +// Storage can be absent or full: every call is allowed to do nothing. +export function saveLastKnown(storage, actor, apps) { + try { + storage.setItem(LAST_KEY, JSON.stringify({ actor, apps, at: Date.now() })) + } catch { /* the page works without it */ } +} + +export function loadLastKnown(storage, actor, maxAgeMs = 24 * 3600 * 1000, now = Date.now()) { + try { + const raw = JSON.parse(storage.getItem(LAST_KEY) || 'null') + if (!raw || raw.actor !== actor || !Array.isArray(raw.apps) || !Number.isFinite(raw.at) || now - raw.at > maxAgeMs) return null + return raw.apps + } catch { + return null + } +} diff --git a/core/http/react-ui/src/components/addons/errors.js b/core/http/react-ui/src/components/addons/errors.js index c3b26f81e4..5da91bf46a 100644 --- a/core/http/react-ui/src/components/addons/errors.js +++ b/core/http/react-ui/src/components/addons/errors.js @@ -21,6 +21,7 @@ export function describeError(error, t) { case 'invalid': return serverReason(error) || t('errors.invalid') case 'unknown_outcome': return t('errors.unknownOutcome') case 'network': return t('errors.network') + case 'busy': return t('errors.busy') default: return t('errors.unavailable') } } diff --git a/core/http/react-ui/src/components/catalog/catalogState.js b/core/http/react-ui/src/components/catalog/catalogState.js index d037ea1476..e2add705c1 100644 --- a/core/http/react-ui/src/components/catalog/catalogState.js +++ b/core/http/react-ui/src/components/catalog/catalogState.js @@ -102,6 +102,9 @@ export function classifyResponse(status, body) { if (status === 503 && type === 'outcome_unknown') { return { kind: 'unknown_outcome', status, message, operationId: typeof err.operationId === 'string' ? err.operationId : '' } } + // The owner is up but did not answer in time (an upgrade runs on it). The + // page keeps what it shows and asks again; this is never "no add-ons". + if (status === 503 && type === 'owner_busy') return { kind: 'busy', status, message, type } return { kind: 'unavailable', status, message } } diff --git a/core/http/react-ui/src/utils/addonsApi.js b/core/http/react-ui/src/utils/addonsApi.js index 311eeb51d7..6369ce5b95 100644 --- a/core/http/react-ui/src/utils/addonsApi.js +++ b/core/http/react-ui/src/utils/addonsApi.js @@ -115,8 +115,19 @@ export function createAddonsApi({ fetch: fetchImpl, base = p => p } = {}) { dispatchFile, apps: { async list(signal) { + return (await api.apps.read(signal)).apps + }, + // The list with the marks the server puts on it: stale is true when the + // server answers with the last list it got from the owner (the owner is + // busy or not reachable), so a page can say so and keep showing it. + async read(signal) { const out = await call('/api/addons/apps', { signal }) - return Array.isArray(out?.apps) ? out.apps : [] + return { + apps: Array.isArray(out?.apps) ? out.apps : [], + stale: out?.stale === true, + staleReason: typeof out?.staleReason === 'string' ? out.staleReason : '', + staleSince: typeof out?.staleSince === 'string' ? out.staleSince : '', + } }, async start(id, { operationId, generation }, signal) { const out = await call(`/api/addons/apps/${encodeURIComponent(id)}/start`, { method: 'POST', body: { operationId, generation }, signal }) diff --git a/core/http/react-ui/tests/unit/addon-session.test.mjs b/core/http/react-ui/tests/unit/addon-session.test.mjs index 359485e730..44dc854b2c 100644 --- a/core/http/react-ui/tests/unit/addon-session.test.mjs +++ b/core/http/react-ui/tests/unit/addon-session.test.mjs @@ -72,7 +72,7 @@ for (const switchUser of [false, true]) test(`installed records reject late admi let auth = { user: { id: 'admin' }, isAdmin: true, sessionEpoch: 1, hasFeature: () => true } const pending = deferred() let reads = 0 - const api = { apps: { list: async () => [{ id: 'notes', generation: 1 }] }, installed: () => { reads++; return pending.promise } } + const api = { apps: { read: async () => ({ apps: [{ id: 'notes', generation: 1 }], stale: false, staleReason: '' }) }, installed: () => { reads++; return pending.promise } } const text = readFileSync(new URL('../../src/components/addons/AppsView.jsx', import.meta.url), 'utf8') // Full Apps body/effects; JSX is compiled, leaf hooks are external boundaries. const names = [...text.matchAll(/^import \{ ([^}]+) \} from/gm)].flatMap(m => m[1].split(',').map(n => n.trim())) diff --git a/core/http/react-ui/tests/unit/addonhomecommands.test.mjs b/core/http/react-ui/tests/unit/addonhomecommands.test.mjs index 3b7c7fe304..fd89679a59 100644 --- a/core/http/react-ui/tests/unit/addonhomecommands.test.mjs +++ b/core/http/react-ui/tests/unit/addonhomecommands.test.mjs @@ -257,7 +257,7 @@ for (const accountSwitch of [false, true]) test(`Apps registry: late list cannot let registry = r.render() let auth = { user:{id:'alice'}, sessionEpoch:1, isAdmin:true, hasFeature:() => true } const reads = [] - const api = { apps:{ list:() => { const d = deferred(); reads.push(d); return d.promise } } } + const api = { apps:{ read:() => { const d = deferred(); reads.push(d); return d.promise.then(apps => ({ apps, stale:false, staleReason:'' })) } } } const h = hookHarness('../../src/components/addons/AppsView.jsx', 'AppsView', { useAuth:() => auth, useContext:() => registry, RegistryContext:{}, useTranslation:() => ({t:x=>x,i18n:{}}), FEATURE_ADDONS:'addons', pollDelay:() => 1000, setTimeout:()=>1, clearTimeout:()=>{}, diff --git a/core/http/react-ui/tests/unit/addons.test.mjs b/core/http/react-ui/tests/unit/addons.test.mjs index 6eba3f7d8a..03467963f3 100644 --- a/core/http/react-ui/tests/unit/addons.test.mjs +++ b/core/http/react-ui/tests/unit/addons.test.mjs @@ -13,6 +13,7 @@ import { resolveTab, visibleTabs, resolveManage, stateChip, appActions, localizedText, toolSummary, shortDigest, updateStatus, formatBytes, rangeText, parseEntryKey, addedByAddon, addedByEntry, screenshotsOf, listState, featuredEntries, attentionOf, sortByAttention, filterByState, hueOf, monogram, versionLabel, skillShadowNotes, skillsOf, + operationOf, upgradingTitles, staleKind, saveLastKnown, loadLastKnown, } from '../../src/components/addons/addonState.js' import { createAddonsApi, createAddonsCatalogAdapter, AddonsError } from '../../src/utils/addonsApi.js' import { runLifecycle, settleOperation, unavailableWait } from '../../src/components/addons/lifecycleFlow.js' @@ -615,3 +616,48 @@ test('addons: the details list the skills of an add-on and which replace a built [{ name: 'xlsx', replaces: true }, { name: 'office-tips', replaces: false }]) assert.deepEqual(skillsOf({}), []) }) + +test('addons: a busy owner is its own kind of answer, and the list read carries the stale mark', async () => { + assert.equal(classifyResponse(503, { error: { type: 'owner_busy', message: 'busy' } }).kind, 'busy') + assert.equal(classifyResponse(503, { error: { type: 'owner_unavailable' } }).kind, 'unavailable') + const busy = new AddonsError(classifyResponse(503, { error: { type: 'owner_busy' } })) + assert.match(describeError(busy, key => key), /errors\.busy/) + const api = createAddonsApi({ fetch: async () => json(200, { apps: [{ id: 'notes' }], stale: true, staleReason: 'owner_busy', staleSince: '2026-11-04T17:18:00Z' }) }) + assert.deepEqual(await api.apps.read(), { apps: [{ id: 'notes' }], stale: true, staleReason: 'owner_busy', staleSince: '2026-11-04T17:18:00Z' }) + assert.deepEqual((await api.apps.list()).map(a => a.id), ['notes']) + const plain = createAddonsApi({ fetch: async () => json(200, { apps: [] }) }) + assert.equal((await plain.apps.read()).stale, false) +}) + +test('addons: an error is never an empty list, and the page names what the owner is upgrading', () => { + const apps = [ + { id: 'notes', title: 'Notes', operation: { kind: 'upgrade', phase: 'downloading', since: '2026-11-04T17:18:00Z' } }, + { id: 'sandbox', title: 'Sandbox', operation: { kind: 'upgrade', phase: 'starting' } }, + { id: 'plain', title: 'Plain' }, + ] + assert.deepEqual(upgradingTitles(apps), ['Notes', 'Sandbox']) + assert.deepEqual(upgradingTitles(null), []) + assert.deepEqual(operationOf(apps[0]), { kind: 'upgrade', phase: 'downloading', since: '2026-11-04T17:18:00Z' }) + assert.equal(operationOf(apps[2]), null) + // A kind or phase this page does not know is still work in progress. + assert.deepEqual(operationOf({ operation: { kind: 'defrag', phase: 'later' } }), { kind: 'working', phase: 'working', since: '' }) + assert.equal(staleKind({ stale: false, error: new Error('x') }), '') + assert.equal(staleKind({ stale: true, error: { kind: 'busy' } }), 'busy') + assert.equal(staleKind({ stale: true, staleReason: 'owner_busy' }), 'busy') + assert.equal(staleKind({ stale: true, error: { kind: 'network' } }), 'unreachable') + assert.equal(staleKind({ stale: true, staleReason: 'owner_unavailable' }), 'unreachable') +}) + +test('addons: the last list of a tab stands in for a page loaded while the owner is busy', () => { + const data = new Map() + const store = { getItem: k => (data.has(k) ? data.get(k) : null), setItem: (k, v) => { data.set(k, v) } } + assert.equal(loadLastKnown(store, 'a'), null) + saveLastKnown(store, 'a', [{ id: 'notes' }]) + assert.deepEqual(loadLastKnown(store, 'a'), [{ id: 'notes' }]) + assert.equal(loadLastKnown(store, 'someone-else'), null) + assert.equal(loadLastKnown(store, 'a', 1000, Date.now() + 5000), null) + // Storage that throws is the same as no storage. + const broken = { getItem() { throw new Error('blocked') }, setItem() { throw new Error('full') } } + saveLastKnown(broken, 'a', []) + assert.equal(loadLastKnown(broken, 'a'), null) +})