You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

560 lines
17 KiB

import { afterEach, describe, expect, it, vi } from 'vitest'
import {
filterLiveActivityItemsByReachableMedia,
liveActivityAddressFromEvent,
liveEventInlinePlaybackFromEvent,
liveEventZapStreamWatchUrl,
parseLiveActivityEvent,
preferredLiveJoinUrlForEvent,
resolveParentSpacesForLiveActivities
} from './live-activities'
import { isZapStreamWatchPageUrl } from '@/lib/url'
import { nip19, type Event } from 'nostr-tools'
const base = (kind: number, tags: string[][], pubkey = 'a'.repeat(64)): Event =>
({
kind,
pubkey,
content: '',
tags,
id: 'b'.repeat(64),
sig: 'c'.repeat(128),
created_at: 1_700_000_000
}) as Event
afterEach(() => {
vi.unstubAllGlobals()
})
describe('liveActivityAddressFromEvent', () => {
it('returns kind:pubkey:d for 30311 with d tag', () => {
const pk = 'a'.repeat(64)
const ev = base(30311, [
['d', 'my-stream'],
['status', 'live'],
['title', 'X']
], pk)
expect(liveActivityAddressFromEvent(ev)).toBe(`30311:${pk}:my-stream`)
expect(parseLiveActivityEvent(ev, new Set())?.address).toBe(`30311:${pk}:my-stream`)
})
it('matches parseLiveActivityEvent address when d tag has surrounding spaces (raw d)', () => {
const pk = 'a'.repeat(64)
const ev = base(
30312,
[
['d', ' spaced-room '],
['room', 'R'],
['status', 'open'],
['service', 'https://meet.example/r/abc']
],
pk
)
const addr = liveActivityAddressFromEvent(ev)
const parsed = parseLiveActivityEvent(ev, new Set())
expect(parsed).not.toBeNull()
expect(addr).toBe(parsed!.address)
expect(addr).toBe(`30312:${pk}: spaced-room `)
})
it('returns null without d tag', () => {
const ev = base(30311, [['status', 'live']])
expect(liveActivityAddressFromEvent(ev)).toBeNull()
})
})
describe('filterLiveActivityItemsByReachableMedia', () => {
it('removes 30311 when HLS manifest responds 204', async () => {
const pk = 'a'.repeat(64)
const ev = base(30311, [
['d', 's1'],
['status', 'live'],
['title', 'X'],
['streaming', 'https://example.com/live.m3u8']
], pk)
const item = parseLiveActivityEvent(ev, new Set())
expect(item).not.toBeNull()
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 204,
text: async () => ''
})
)
const out = await filterLiveActivityItemsByReachableMedia([item!])
expect(out).toHaveLength(0)
})
it('keeps 30311 when HLS manifest has body', async () => {
const pk = 'a'.repeat(64)
const ev = base(30311, [
['d', 's1'],
['status', 'live'],
['title', 'X'],
['streaming', 'https://example.com/live.m3u8']
], pk)
const item = parseLiveActivityEvent(ev, new Set())
expect(item).not.toBeNull()
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
text: async () => '#EXTM3U\n'
})
)
const out = await filterLiveActivityItemsByReachableMedia([item!])
expect(out).toHaveLength(1)
})
it('does not fetch for 30312 items', async () => {
const ev = base(30312, [
['d', 'room-1'],
['room', 'Hall'],
['status', 'open'],
['service', 'https://meet.example.com/r/abc']
])
const item = parseLiveActivityEvent(ev, new Set())
expect(item).not.toBeNull()
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const out = await filterLiveActivityItemsByReachableMedia([item!])
expect(fetchMock).not.toHaveBeenCalled()
expect(out).toHaveLength(1)
})
})
describe('parseLiveActivityEvent (NIP-53)', () => {
it('accepts 30312 meeting space when status is open (not live)', () => {
const ev = base(30312, [
['d', 'room-1'],
['room', 'Main Hall'],
['status', 'open'],
['service', 'https://meet.example.com/r/abc']
])
const item = parseLiveActivityEvent(ev, new Set())
expect(item).not.toBeNull()
expect(item?.title).toBe('Main Hall')
expect(item?.joinUrl).toBe('https://meet.example.com/r/abc')
})
it('rejects 30312 when status is closed', () => {
const ev = base(30312, [
['d', 'room-1'],
['room', 'X'],
['status', 'closed'],
['service', 'https://meet.example.com/r/abc']
])
expect(parseLiveActivityEvent(ev, new Set())).toBeNull()
})
it('requires status live for 30311', () => {
const ev = base(30311, [
['d', 's1'],
['status', 'planned'],
['streaming', 'https://x/stream.m3u8']
])
expect(parseLiveActivityEvent(ev, new Set())).toBeNull()
})
it('excludes 30311 when ends is in the past (even if status is still live)', () => {
const nowSec = 1_700_000_000
const pk = 'a'.repeat(64)
const ev = base(
30311,
[
['d', 's1'],
['status', 'live'],
['ends', String(nowSec - 60)]
],
pk
)
expect(parseLiveActivityEvent(ev, new Set(), new Map(), nowSec)).toBeNull()
})
it('keeps 30311 when ends is in the future', () => {
const nowSec = 1_700_000_000
const pk = 'a'.repeat(64)
const ev = base(
30311,
[
['d', 's1'],
['status', 'live'],
['ends', String(nowSec + 3600)]
],
pk
)
expect(parseLiveActivityEvent(ev, new Set(), new Map(), nowSec)).not.toBeNull()
})
it('excludes 30311 when status is ended', () => {
const ev = base(30311, [
['d', 's1'],
['status', 'ended'],
['streaming', 'https://example.com/x.m3u8']
])
expect(parseLiveActivityEvent(ev, new Set())).toBeNull()
})
it('30311 Nostr Nests LiveKit uses nostrnests.com naddr, not zap.stream', () => {
const pk = 'f'.repeat(64)
const ev = base(
30311,
[
['d', 'eaf66800-fdaa-4796-b755-e34cec4fd485'],
['status', 'live'],
['title', 'test'],
['service', 'https://nostrnests.com'],
['streaming', 'wss+livekit://nostrnests.com:443']
],
pk
)
const naddr = nip19.naddrEncode({
kind: 30311,
pubkey: pk,
identifier: 'eaf66800-fdaa-4796-b755-e34cec4fd485'
})
const join = `https://nostrnests.com/${naddr}`
expect(parseLiveActivityEvent(ev, new Set())?.joinUrl).toBe(join)
expect(preferredLiveJoinUrlForEvent(ev)).toBe(join)
})
it('30311 Nostr Nests LiveKit does not use zap.stream for inline playback when only LiveKit tags', () => {
const pk = 'f'.repeat(64)
const ev = base(
30311,
[
['d', 'eaf66800-fdaa-4796-b755-e34cec4fd485'],
['status', 'live'],
['service', 'https://nostrnests.com'],
['streaming', 'wss+livekit://nostrnests.com:443']
],
pk
)
expect(liveEventInlinePlaybackFromEvent(ev)).toBeNull()
})
it('accepts 30311 when status is LIVE (case-insensitive)', () => {
const pk = 'a'.repeat(64)
const ev = base(
30311,
[
['d', 's1'],
['status', 'LIVE'],
['streaming', 'https://example.com/live/stream.m3u8']
],
pk
)
expect(parseLiveActivityEvent(ev, new Set())).not.toBeNull()
})
it('uses zap.stream naddr page for 30311 when streaming is only HLS manifest', () => {
const pk = 'a'.repeat(64)
const ev = base(
30311,
[
['d', 's1'],
['status', 'live'],
['streaming', 'https://example.com/live/stream.m3u8']
],
pk
)
const item = parseLiveActivityEvent(ev, new Set())
expect(item?.joinUrl).toMatch(/^https:\/\/zap\.stream\/naddr1/)
})
it('30311 prefers canonical zap.stream URL over legacy https service', () => {
const pk = 'c'.repeat(64)
const ev = base(
30311,
[
['d', 'my-stream'],
['status', 'live'],
['service', 'https://legacy.example.com/watch/old']
],
pk
)
const naddr = nip19.naddrEncode({ kind: 30311, pubkey: pk, identifier: 'my-stream' })
expect(parseLiveActivityEvent(ev, new Set())?.joinUrl).toBe(`https://zap.stream/${naddr}`)
})
it('30311 Corny Chat uses tagged HTTPS room URL for join (open in browser)', () => {
const pk = 'd'.repeat(64)
const dVal = '1700000000123'
const ev = base(
30311,
[
['d', dVal],
['status', 'live'],
['L', 'com.cornychat'],
['l', 'cornychat.com', 'com.cornychat'],
['r', 'https://cornychat.com/myroom'],
['service', 'https://cornychat.com/myroom'],
['streaming', 'https://cornychat.com/myroom'],
['relays', 'wss://nos.lol']
],
pk
)
expect(parseLiveActivityEvent(ev, new Set())?.joinUrl).toBe('https://cornychat.com/myroom')
expect(preferredLiveJoinUrlForEvent(ev)).toBe('https://cornychat.com/myroom')
})
it('30311 Corny Chat resolves jam host when audiospace `l` label comes before hostname `l`', () => {
const pk = 'd'.repeat(64)
const ev = base(
30311,
[
['d', '1'],
['status', 'live'],
['L', 'com.cornychat'],
['l', 'audiospace', 'com.cornychat'],
['l', 'cornychat.com', 'com.cornychat'],
['r', 'https://cornychat.com/moooooonboi']
],
pk
)
expect(preferredLiveJoinUrlForEvent(ev)).toBe('https://cornychat.com/moooooonboi')
})
it('30311 Corny Chat falls back to zap.stream when `l` host disagrees with `r`', () => {
const pk = 'e'.repeat(64)
const ev = base(
30311,
[
['d', 'x'],
['status', 'live'],
['L', 'com.cornychat'],
['l', 'cornychat.com', 'com.cornychat'],
['r', 'https://other.example/room'],
['service', 'https://other.example/room']
],
pk
)
const naddr = nip19.naddrEncode({ kind: 30311, pubkey: pk, identifier: 'x' })
expect(preferredLiveJoinUrlForEvent(ev)).toBe(`https://zap.stream/${naddr}`)
})
it('30313 inherits join URL from parent 30312 via `a` tag', () => {
const spacePk = 'f'.repeat(64)
const parentAddr = `30312:${spacePk}:conf-room`
const parent = base(
30312,
[
['d', 'conf-room'],
['room', 'Conference'],
['status', 'open'],
['service', 'https://meet.example.com/space/xyz']
],
spacePk
)
const meeting = base(30313, [
['d', 'annual-2025'],
['a', parentAddr, 'wss://relay.example.com'],
['title', 'Annual Meeting'],
['status', 'live'],
['starts', '1700000000']
])
const map = new Map<string, Event>([[parentAddr, parent]])
const item = parseLiveActivityEvent(meeting, new Set(), map)
expect(item).not.toBeNull()
expect(item?.joinUrl).toBe('https://meet.example.com/space/xyz')
expect(item?.title).toBe('Annual Meeting')
})
})
describe('isZapStreamWatchPageUrl', () => {
it('matches zap.stream naddr watch paths only', () => {
expect(isZapStreamWatchPageUrl('https://zap.stream/naddr1qvzqqqqqqqqqqqq')).toBe(true)
expect(isZapStreamWatchPageUrl('https://www.zap.stream/naddr1qabc')).toBe(true)
expect(isZapStreamWatchPageUrl('https://zap.stream/')).toBe(false)
expect(isZapStreamWatchPageUrl('https://zap.stream/about')).toBe(false)
expect(isZapStreamWatchPageUrl('https://evil.test/naddr1qxxx')).toBe(false)
})
})
describe('liveEventZapStreamWatchUrl', () => {
it('returns zap.stream naddr only for kind 30311 with d tag', () => {
const pk = 'a'.repeat(64)
const ev = base(30311, [['d', 'sid'], ['status', 'live']], pk)
const naddr = nip19.naddrEncode({ kind: 30311, pubkey: pk, identifier: 'sid' })
expect(liveEventZapStreamWatchUrl(ev)).toBe(`https://zap.stream/${naddr}`)
expect(liveEventZapStreamWatchUrl(base(1, [['d', 'x']]))).toBeUndefined()
})
})
describe('liveEventInlinePlaybackFromEvent', () => {
it('prefers MP3 r tag over HLS streaming', () => {
const ev = base(30311, [
['d', 'chill'],
['streaming', 'https://cdn.example/hls/chill/index.m3u8'],
['r', 'https://stream.example/listen/chill/radio.mp3']
])
expect(liveEventInlinePlaybackFromEvent(ev)).toEqual({
src: 'https://stream.example/listen/chill/radio.mp3',
mode: 'audio'
})
})
it('falls back to HLS streaming when no direct audio URL', () => {
const ev = base(30311, [
['d', 'chill'],
['streaming', 'https://cdn.example/hls/chill/index.m3u8']
])
expect(liveEventInlinePlaybackFromEvent(ev)).toEqual({
src: 'https://cdn.example/hls/chill/index.m3u8',
mode: 'video'
})
})
it('picks first playable streaming URL when multiple streaming tags exist', () => {
const ev = base(30311, [
['d', 'chill'],
['streaming', 'moq://relay.example:1443/'],
['streaming', 'https://session.example/api/not-a-manifest'],
['streaming', 'https://cdn.example/hls/chill/index.m3u8']
])
expect(liveEventInlinePlaybackFromEvent(ev)).toEqual({
src: 'https://cdn.example/hls/chill/index.m3u8',
mode: 'video'
})
})
it('returns null for non-30311', () => {
expect(liveEventInlinePlaybackFromEvent(base(1, [['d', 'x']]))).toBeNull()
})
it('30311 Corny room-page tags use zap.stream naddr for inline playback', () => {
const pk = 'd'.repeat(64)
const dVal = '1700000000123'
const ev = base(
30311,
[
['d', dVal],
['status', 'live'],
['L', 'com.cornychat'],
['l', 'cornychat.com', 'com.cornychat'],
['r', 'https://cornychat.com/myroom'],
['streaming', 'https://cornychat.com/myroom'],
['relays', 'wss://nos.lol']
],
pk
)
const naddr = nip19.naddrEncode({
kind: 30311,
pubkey: pk,
identifier: dVal,
relays: ['wss://nos.lol']
})
expect(liveEventInlinePlaybackFromEvent(ev)).toEqual({
src: `https://zap.stream/${naddr}`,
mode: 'video'
})
})
})
describe('preferredLiveJoinUrlForEvent (Nostr Nests & Corny Chat)', () => {
it('30312 Nostr Nests: prefers nostrnests.com naddr over MoQ streaming URL', () => {
const pk = 'a'.repeat(64)
const ev = base(30312, [
['d', 'nest-room-1'],
['title', 'Jam session'],
['summary', ''],
['streaming', 'https://moq.nostrnests.com'],
['auth', 'https://moq-auth.nostrnests.com'],
['status', 'open'],
['starts', '1700000000'],
['relays', 'wss://nos.lol']
])
const naddr = nip19.naddrEncode({
kind: 30312,
pubkey: pk,
identifier: 'nest-room-1',
relays: ['wss://nos.lol']
})
expect(preferredLiveJoinUrlForEvent(ev)).toBe(`https://nostrnests.com/${naddr}`)
})
it('30312 Nests fork: rejects docker-internal LiveKit host (no public join URL)', () => {
const pk = '3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24'
const ev = base(
30312,
[
['d', 'c69dfcf4-627c-4227-bc73-c6fa47f99a13'],
['room', 'TEST'],
['status', 'open'],
['service', 'https://nestsdev.derekross.me/api/v1/nests'],
['streaming', 'wss+livekit://livekit:7880'],
['client', 'nestsdev.derekross.me']
],
pk
)
expect(preferredLiveJoinUrlForEvent(ev)).toBeUndefined()
expect(parseLiveActivityEvent(ev, new Set())).toBeNull()
})
it('30312 Nests fork: prefers web origin /naddr when LiveKit host matches the tagged instance', () => {
const pk = 'a'.repeat(64)
const ev = base(
30312,
[
['d', 'room-d'],
['room', 'Fork room'],
['status', 'open'],
['client', 'fork.example.com'],
['service', 'https://fork.example.com/api/v1/nests'],
['streaming', 'wss+livekit://media.fork.example.com:7880']
],
pk
)
const naddr = nip19.naddrEncode({
kind: 30312,
pubkey: pk,
identifier: 'room-d'
})
expect(preferredLiveJoinUrlForEvent(ev)).toBe(`https://fork.example.com/${naddr}`)
expect(parseLiveActivityEvent(ev, new Set())?.joinUrl).toBe(`https://fork.example.com/${naddr}`)
})
it('Corny Chat kind 1: prefers r over service when they differ', () => {
const ev = base(1, [
['L', 'com.cornychat'],
['audioserver', 'cornychat.com'],
['r', 'https://cornychat.com/room-a'],
['service', 'https://cornychat.com/room-b'],
['streaming', 'https://cornychat.com/room-b']
])
expect(preferredLiveJoinUrlForEvent(ev)).toBe('https://cornychat.com/room-a')
})
})
describe('resolveParentSpacesForLiveActivities', () => {
it('fetches 30312 when 30313 references parent but has no URL', async () => {
const spacePk = 'e'.repeat(64)
const parentAddr = `30312:${spacePk}:hall`
const meeting = base(30313, [
['d', 'm1'],
['a', parentAddr],
['title', 'Town hall'],
['status', 'live'],
['starts', '1700000000']
])
const parent = base(
30312,
[
['d', 'hall'],
['room', 'Main'],
['status', 'open'],
['service', 'https://join.example/hall']
],
spacePk
)
const fetchEvents = vi.fn().mockResolvedValue([parent])
const map = await resolveParentSpacesForLiveActivities([meeting], ['wss://r.test'], fetchEvents)
expect(fetchEvents).toHaveBeenCalledTimes(1)
expect(map.get(parentAddr)?.kind).toBe(30312)
expect(map.get(parentAddr)?.pubkey).toBe(spacePk)
})
})