Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit b67e1527a2
2068 changed files with 472303 additions and 0 deletions
@@ -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)
})
})