Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getAppUsage, recordAppLaunch } from '../appUsage'
|
||||
|
||||
describe('appUsage', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('starts empty when no usage has been recorded', () => {
|
||||
expect(getAppUsage()).toEqual({})
|
||||
})
|
||||
|
||||
it('records launch count and latest launch time', () => {
|
||||
recordAppLaunch('filebrowser', 1000)
|
||||
recordAppLaunch('filebrowser', 2000)
|
||||
|
||||
expect(getAppUsage()).toEqual({
|
||||
filebrowser: {
|
||||
count: 2,
|
||||
lastLaunchedAt: 2000,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores corrupt stored usage', () => {
|
||||
localStorage.setItem('archipelago-app-usage', 'not-json')
|
||||
|
||||
expect(getAppUsage()).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { explainReceiveAddressFailure } from '../bitcoinReceive'
|
||||
|
||||
describe('explainReceiveAddressFailure', () => {
|
||||
it('explains locked wallet failures', () => {
|
||||
expect(explainReceiveAddressFailure(new Error('wallet locked'))).toContain('wallet is locked')
|
||||
})
|
||||
|
||||
it('explains sync failures', () => {
|
||||
expect(explainReceiveAddressFailure(new Error('chain backend is still syncing'))).toContain('still syncing')
|
||||
})
|
||||
|
||||
it('explains empty address responses', () => {
|
||||
expect(explainReceiveAddressFailure(new Error('LND did not return a Bitcoin address'))).toContain('did not return an address')
|
||||
})
|
||||
|
||||
it('explains lnd transport failures', () => {
|
||||
expect(explainReceiveAddressFailure(new Error('LND REST connection failed'))).toContain('not responding cleanly')
|
||||
})
|
||||
|
||||
it('keeps bitcoin address generation failures visible', () => {
|
||||
expect(explainReceiveAddressFailure(new Error('Bitcoin address generation failed: LND is not ready'))).toContain('Bitcoin address generation failed')
|
||||
})
|
||||
|
||||
describe('structured reason codes (backend [CODE] token)', () => {
|
||||
it('maps an unreachable REST endpoint to a starting/recovering message — NOT locked (.228 regression)', () => {
|
||||
const msg = explainReceiveAddressFailure(
|
||||
new Error('Bitcoin address unavailable [LND_REST_UNREACHABLE]: service not reachable'),
|
||||
)
|
||||
expect(msg).toContain('starting up or recovering')
|
||||
expect(msg.toLowerCase()).not.toContain('locked')
|
||||
})
|
||||
|
||||
it('maps a genuinely locked wallet to the locked message', () => {
|
||||
expect(
|
||||
explainReceiveAddressFailure(new Error('Bitcoin address unavailable [LND_WALLET_LOCKED]: locked')),
|
||||
).toContain('locked')
|
||||
})
|
||||
|
||||
it('maps an uninitialized wallet to the setup message', () => {
|
||||
expect(
|
||||
explainReceiveAddressFailure(new Error('Bitcoin address unavailable [LND_WALLET_UNINITIALIZED]: x')),
|
||||
).toContain('has not been set up')
|
||||
})
|
||||
|
||||
it('maps a syncing wallet to the syncing message', () => {
|
||||
expect(
|
||||
explainReceiveAddressFailure(new Error('Bitcoin address unavailable [LND_SYNCING]: x')),
|
||||
).toContain('syncing')
|
||||
})
|
||||
|
||||
it('prefers the code over substrings even when the detail text is misleading', () => {
|
||||
// Detail mentions neither "locked" nor "unlock"; code must still win.
|
||||
const msg = explainReceiveAddressFailure(
|
||||
new Error('Bitcoin address unavailable [LND_REST_UNREACHABLE]: wallet service down'),
|
||||
)
|
||||
expect(msg).toContain('starting up or recovering')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { fetchGitHubAppInfo, fetchMultipleAppInfo } from '../githubAppInfo'
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
describe('fetchGitHubAppInfo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns empty object for invalid repo URL', async () => {
|
||||
const result = await fetchGitHubAppInfo('not-a-url', 'test')
|
||||
expect(result).toEqual({})
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns empty object when repo API returns non-OK', async () => {
|
||||
// Start9 repo check
|
||||
mockFetch.mockResolvedValueOnce({ ok: false })
|
||||
// Original repo fetch fails
|
||||
mockFetch.mockResolvedValueOnce({ ok: false, status: 404 })
|
||||
|
||||
const result = await fetchGitHubAppInfo('https://github.com/owner/repo', 'app')
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('fetches repo info successfully', async () => {
|
||||
// Start9 wrapper check — not found
|
||||
mockFetch.mockResolvedValueOnce({ ok: false })
|
||||
// Repo API call
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
description: 'A Bitcoin node',
|
||||
homepage: 'https://bitcoin.org',
|
||||
html_url: 'https://github.com/owner/repo',
|
||||
}),
|
||||
})
|
||||
// README fetch
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ content: btoa('# README') }),
|
||||
})
|
||||
// Icon path checks — all fail
|
||||
for (let i = 0; i < 6; i++) {
|
||||
mockFetch.mockResolvedValueOnce({ ok: false })
|
||||
}
|
||||
// Releases check — no icon
|
||||
mockFetch.mockResolvedValueOnce({ ok: false })
|
||||
// Raw icon URL HEAD checks — all fail
|
||||
for (let i = 0; i < 6; i++) {
|
||||
mockFetch.mockResolvedValueOnce({ ok: false })
|
||||
}
|
||||
|
||||
const result = await fetchGitHubAppInfo('https://github.com/owner/repo', 'app')
|
||||
expect(result.description).toBe('A Bitcoin node')
|
||||
expect(result.readme).toBe('# README')
|
||||
expect(result.homepage).toBe('https://bitcoin.org')
|
||||
})
|
||||
|
||||
it('finds icon from repository contents', async () => {
|
||||
// Start9 wrapper — not found
|
||||
mockFetch.mockResolvedValueOnce({ ok: false })
|
||||
// Repo API
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ description: '', html_url: 'https://github.com/o/r' }),
|
||||
})
|
||||
// README
|
||||
mockFetch.mockResolvedValueOnce({ ok: false })
|
||||
// First icon path (icon.png) — found!
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ download_url: 'https://raw.github.com/o/r/main/icon.png' }),
|
||||
})
|
||||
|
||||
const result = await fetchGitHubAppInfo('https://github.com/o/r', 'test')
|
||||
expect(result.icon).toBe('https://raw.github.com/o/r/main/icon.png')
|
||||
})
|
||||
|
||||
it('tries Start9Labs wrapper repo first', async () => {
|
||||
// Start9 wrapper check — found!
|
||||
mockFetch.mockResolvedValueOnce({ ok: true })
|
||||
// Now fetches Start9Labs/app-startos repo
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
description: 'Start9 wrapper',
|
||||
html_url: 'https://github.com/Start9Labs/bitcoin-startos',
|
||||
}),
|
||||
})
|
||||
// README
|
||||
mockFetch.mockResolvedValueOnce({ ok: false })
|
||||
// Icon paths — all fail
|
||||
for (let i = 0; i < 6; i++) {
|
||||
mockFetch.mockResolvedValueOnce({ ok: false })
|
||||
}
|
||||
// Releases
|
||||
mockFetch.mockResolvedValueOnce({ ok: false })
|
||||
// Raw URLs
|
||||
for (let i = 0; i < 6; i++) {
|
||||
mockFetch.mockResolvedValueOnce({ ok: false })
|
||||
}
|
||||
|
||||
const result = await fetchGitHubAppInfo('https://github.com/bitcoin/bitcoin', 'bitcoin')
|
||||
expect(result.description).toBe('Start9 wrapper')
|
||||
})
|
||||
|
||||
it('handles fetch errors gracefully', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('Network error'))
|
||||
const result = await fetchGitHubAppInfo('https://github.com/owner/repo', 'app')
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchMultipleAppInfo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
it('returns empty record for empty input', async () => {
|
||||
const result = await fetchMultipleAppInfo([])
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('fetches info for multiple apps', async () => {
|
||||
// Each app triggers multiple fetch calls, but they all return non-matching URLs
|
||||
mockFetch.mockResolvedValue({ ok: false })
|
||||
|
||||
const apps = [
|
||||
{ id: 'app1', 'wrapper-repo': 'not-a-github-url' },
|
||||
{ id: 'app2', 'wrapper-repo': 'also-invalid' },
|
||||
]
|
||||
const result = await fetchMultipleAppInfo(apps)
|
||||
expect(result.app1).toEqual({})
|
||||
expect(result.app2).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldShowIntroSplash } from '../introSplash'
|
||||
|
||||
describe('shouldShowIntroSplash', () => {
|
||||
it('skips intro on an already-onboarded node even without a browser intro flag', () => {
|
||||
expect(shouldShowIntroSplash({
|
||||
seenIntro: false,
|
||||
routePath: '/',
|
||||
fromBoot: false,
|
||||
onboardingComplete: true,
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('shows intro for a fresh root visit when onboarding is not complete', () => {
|
||||
expect(shouldShowIntroSplash({
|
||||
seenIntro: false,
|
||||
routePath: '/',
|
||||
fromBoot: false,
|
||||
onboardingComplete: false,
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('does not interrupt direct routes', () => {
|
||||
expect(shouldShowIntroSplash({
|
||||
seenIntro: false,
|
||||
routePath: '/dashboard/web5',
|
||||
fromBoot: false,
|
||||
onboardingComplete: null,
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('an explicit replay request overrides every suppression rule', () => {
|
||||
expect(shouldShowIntroSplash({
|
||||
seenIntro: true,
|
||||
routePath: '/',
|
||||
fromBoot: false,
|
||||
onboardingComplete: true,
|
||||
replayRequested: true,
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('a confirmed-fresh node plays the intro despite a stale per-origin seenIntro flag (reinstall / DHCP-recycled IP)', () => {
|
||||
expect(shouldShowIntroSplash({
|
||||
seenIntro: true,
|
||||
routePath: '/',
|
||||
fromBoot: false,
|
||||
onboardingComplete: false,
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('a confirmed-fresh node plays the intro on the boot-screen handoff too', () => {
|
||||
expect(shouldShowIntroSplash({
|
||||
seenIntro: true,
|
||||
routePath: '/login',
|
||||
fromBoot: true,
|
||||
onboardingComplete: false,
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('stale seenIntro still suppresses when the backend answer is unknown', () => {
|
||||
expect(shouldShowIntroSplash({
|
||||
seenIntro: true,
|
||||
routePath: '/',
|
||||
fromBoot: false,
|
||||
onboardingComplete: null,
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('fresh node on a deep route without boot handoff stays suppressed', () => {
|
||||
expect(shouldShowIntroSplash({
|
||||
seenIntro: false,
|
||||
routePath: '/onboarding/seed',
|
||||
fromBoot: false,
|
||||
onboardingComplete: false,
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('boot dev mode never root-boots into the intro', () => {
|
||||
expect(shouldShowIntroSplash({
|
||||
seenIntro: false,
|
||||
routePath: '/',
|
||||
fromBoot: false,
|
||||
devMode: 'boot',
|
||||
onboardingComplete: false,
|
||||
})).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { isCompanionApp } from '../openExternal'
|
||||
|
||||
// isCompanionApp() is the single companion-detection source used to skip the
|
||||
// demo intro (App.vue + RootRedirect.vue) and by appLauncher — it must be true
|
||||
// iff the native shell injected window.ArchipelagoNative with openInApp.
|
||||
|
||||
type TestWindow = Window & { ArchipelagoNative?: unknown }
|
||||
const w = window as TestWindow
|
||||
|
||||
afterEach(() => {
|
||||
delete w.ArchipelagoNative
|
||||
})
|
||||
|
||||
describe('isCompanionApp', () => {
|
||||
it('is false in a plain browser/PWA (no bridge injected)', () => {
|
||||
expect(isCompanionApp()).toBe(false)
|
||||
})
|
||||
|
||||
it('is true when the native shell injected the bridge with openInApp', () => {
|
||||
w.ArchipelagoNative = { openInApp: () => {} }
|
||||
expect(isCompanionApp()).toBe(true)
|
||||
})
|
||||
|
||||
it('is false when the bridge exists but lacks a callable openInApp', () => {
|
||||
w.ArchipelagoNative = { openExternal: () => {} }
|
||||
expect(isCompanionApp()).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user