Files
goodbuddy/src/main/linux-desktop/portal-session.test.ts
T

367 lines
11 KiB
TypeScript

import {
afterEach,
describe,
expect,
it,
vi,
type Mock
} from 'vitest'
import {
PortalDesktopSession,
PortalSessionError,
validatePipeWireFrameMetadata,
type ClosableResource,
type PortalResponse,
type PortalTransport
} from './portal-session'
type MockPortal = {
transport: PortalTransport
pipeWireClose: Mock<() => void>
eisClose: Mock<() => void>
}
const mockPortal = (
responseOverrides: Record<string, Partial<PortalResponse>> = {}
): MockPortal => {
const pipeWireClose = vi.fn<() => void>()
const eisClose = vi.fn<() => void>()
const resource = (close: Mock<() => void>): ClosableResource => ({
close: () => close()
})
const transport: PortalTransport = {
createSession: vi.fn(async () => ({ requestHandle: 'create' })),
selectDevices: vi.fn(async () => ({ requestHandle: 'devices' })),
selectSources: vi.fn(async () => ({ requestHandle: 'sources' })),
start: vi.fn(async () => ({ requestHandle: 'start' })),
waitForResponse: vi.fn(async (requestHandle) => ({
requestHandle,
response: 0,
results:
requestHandle === 'create'
? { session_handle: 'private-session' }
: {},
...responseOverrides[requestHandle]
})),
openPipeWireRemote: vi.fn(async () => resource(pipeWireClose)),
connectEis: vi.fn(async () => resource(eisClose)),
closeRequest: vi.fn(async () => undefined),
closeSession: vi.fn(async () => undefined)
}
return { transport, pipeWireClose, eisClose }
}
afterEach(() => {
vi.useRealTimers()
})
describe('PortalDesktopSession', () => {
it('performs the portal protocol in order and cleans every resource on stop', async () => {
const mock = mockPortal()
const session = new PortalDesktopSession(mock.transport)
await session.open(
{ devices: true, sources: true, parentWindow: 'window-token' },
new AbortController().signal
)
expect(session.state).toBe('active')
expect(session.hasActiveConsent).toBe(true)
expect(mock.transport.selectDevices).toHaveBeenCalledWith(
'private-session',
expect.any(AbortSignal)
)
expect(mock.transport.selectSources).toHaveBeenCalled()
expect(mock.transport.start).toHaveBeenCalledWith(
'private-session',
'window-token',
expect.any(AbortSignal)
)
await session.stop()
expect(session.state).toBe('stopped')
expect(session.hasActiveConsent).toBe(false)
expect(mock.pipeWireClose).toHaveBeenCalledOnce()
expect(mock.eisClose).toHaveBeenCalledOnce()
expect(mock.transport.closeRequest).toHaveBeenCalledTimes(4)
expect(mock.transport.closeSession).toHaveBeenCalledWith('private-session')
})
it.each([
[1, 'cancelled'],
[2, 'denied']
] as const)('handles portal consent response %s as %s', async (code, reason) => {
const mock = mockPortal({
sources: { response: code }
})
const session = new PortalDesktopSession(mock.transport)
await expect(
session.open(
{ devices: false, sources: true },
new AbortController().signal
)
).rejects.toMatchObject({ reason })
expect(session.state).toBe('failed')
expect(mock.transport.closeRequest).toHaveBeenCalledWith('sources')
expect(mock.transport.closeSession).toHaveBeenCalledWith('private-session')
expect(mock.transport.openPipeWireRemote).not.toHaveBeenCalled()
})
it('rejects mismatched response handles and closes the request', async () => {
const mock = mockPortal({
create: { requestHandle: 'unrelated-response' }
})
const session = new PortalDesktopSession(mock.transport)
await expect(
session.open(
{ devices: true, sources: false },
new AbortController().signal
)
).rejects.toMatchObject({ reason: 'protocol' })
expect(mock.transport.closeRequest).toHaveBeenCalledWith('create')
})
it('aborts a pending request on timeout and performs cleanup', async () => {
vi.useFakeTimers()
const mock = mockPortal()
mock.transport.waitForResponse = vi.fn(
async (_handle, signal) =>
new Promise<PortalResponse>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), {
once: true
})
})
)
const session = new PortalDesktopSession(mock.transport)
const opening = session.open(
{ devices: true, sources: false, timeoutMs: 10 },
new AbortController().signal
)
const rejection = expect(opening).rejects.toMatchObject({
reason: 'timeout'
})
await vi.advanceTimersByTimeAsync(10)
await rejection
expect(mock.transport.closeRequest).toHaveBeenCalledWith('create')
})
it('cleans active descriptors and revokes consent on portal owner loss', async () => {
const mock = mockPortal()
const session = new PortalDesktopSession(mock.transport)
await session.open(
{ devices: true, sources: true },
new AbortController().signal
)
await session.portalOwnerLost()
expect(session.state).toBe('failed')
expect(session.hasActiveConsent).toBe(false)
expect(mock.pipeWireClose).toHaveBeenCalledOnce()
expect(mock.eisClose).toHaveBeenCalledOnce()
expect(mock.transport.closeSession).toHaveBeenCalledOnce()
})
it('stops an opening session without leaving requests or consent active', async () => {
const mock = mockPortal()
mock.transport.waitForResponse = vi.fn(
async (_handle, signal) =>
new Promise<PortalResponse>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), {
once: true
})
})
)
const session = new PortalDesktopSession(mock.transport)
const opening = session.open(
{ devices: true, sources: false },
new AbortController().signal
)
const rejection = expect(opening).rejects.toMatchObject({
reason: 'aborted'
})
await Promise.resolve()
await session.stop()
await rejection
expect(session.state).toBe('stopped')
expect(session.hasActiveConsent).toBe(false)
expect(mock.transport.closeRequest).toHaveBeenCalledWith('create')
})
it('prevents a late stopped open from mutating a reopened session', async () => {
const mock = mockPortal()
let resolveFirst:
| ((request: { requestHandle: string }) => void)
| undefined
const firstCreate = new Promise<{ requestHandle: string }>((resolve) => {
resolveFirst = resolve
})
let createCount = 0
mock.transport.createSession = vi.fn(async () => {
createCount += 1
if (createCount === 1) {
return firstCreate
}
return { requestHandle: 'create-second' }
})
mock.transport.waitForResponse = vi.fn(async (requestHandle) => ({
requestHandle,
response: 0,
results:
requestHandle === 'create-second'
? { session_handle: 'session-second' }
: {}
}))
const session = new PortalDesktopSession(mock.transport)
const firstOpening = session.open(
{ devices: true, sources: false },
new AbortController().signal
)
const firstRejection = expect(firstOpening).rejects.toMatchObject({
reason: 'aborted'
})
await vi.waitFor(() => {
expect(mock.transport.createSession).toHaveBeenCalledOnce()
})
await session.stop()
await firstRejection
await session.open(
{ devices: true, sources: false },
new AbortController().signal
)
expect(session.state).toBe('active')
resolveFirst?.({ requestHandle: 'late-first-create' })
await vi.waitFor(() => {
expect(mock.transport.closeRequest).toHaveBeenCalledWith(
'late-first-create'
)
})
expect(session.state).toBe('active')
expect(session.hasActiveConsent).toBe(true)
expect(mock.transport.closeSession).not.toHaveBeenCalledWith(
'session-second'
)
})
it.each(['stop', 'owner-loss'] as const)(
'bounds hung cleanup during %s',
async (lifecycle) => {
const mock = mockPortal()
const never = () => new Promise<void>(() => undefined)
mock.transport.closeRequest = vi.fn(never)
mock.transport.closeSession = vi.fn(never)
mock.transport.openPipeWireRemote = vi.fn(async () => ({
close: never
}))
const session = new PortalDesktopSession(mock.transport, {
cleanupTimeoutMs: 10
})
await session.open(
{ devices: false, sources: true },
new AbortController().signal
)
vi.useFakeTimers()
const cleanup =
lifecycle === 'stop'
? session.stop()
: session.portalOwnerLost()
await vi.advanceTimersByTimeAsync(10)
await expect(cleanup).resolves.toBeUndefined()
expect(session.state).toBe(
lifecycle === 'stop' ? 'stopped' : 'failed'
)
}
)
it('bounds hung cleanup after a failed open', async () => {
vi.useFakeTimers()
const mock = mockPortal({
sources: { response: 2 }
})
const never = () => new Promise<void>(() => undefined)
mock.transport.closeRequest = vi.fn(never)
mock.transport.closeSession = vi.fn(never)
const session = new PortalDesktopSession(mock.transport, {
cleanupTimeoutMs: 10
})
const opening = session.open(
{ devices: false, sources: true },
new AbortController().signal
)
const rejection = expect(opening).rejects.toMatchObject({
reason: 'denied'
})
await vi.waitFor(() => {
expect(mock.transport.closeSession).toHaveBeenCalled()
})
await vi.advanceTimersByTimeAsync(10)
await rejection
expect(session.state).toBe('failed')
})
it('requires at least one consented portal capability', async () => {
const session = new PortalDesktopSession(mockPortal().transport)
await expect(
session.open(
{ devices: false, sources: false },
new AbortController().signal
)
).rejects.toBeInstanceOf(PortalSessionError)
await expect(
session.open(
{ devices: true, sources: false, timeoutMs: 0 },
new AbortController().signal
)
).rejects.toMatchObject({ reason: 'protocol' })
})
})
describe('validatePipeWireFrameMetadata', () => {
const valid = {
width: 1920,
height: 1080,
stride: 7680,
planes: [{ offset: 0, stride: 7680, bytes: 8_294_400 }],
byteLength: 8_294_400,
fpsNumerator: 60,
fpsDenominator: 1
}
it('accepts bounded, internally consistent frame metadata', () => {
expect(validatePipeWireFrameMetadata(valid)).toEqual(valid)
})
it.each([
{ ...valid, width: 0 },
{ ...valid, height: 20_000 },
{ ...valid, stride: 100 },
{ ...valid, fpsDenominator: 0 },
{ ...valid, fpsNumerator: 241 },
{ ...valid, byteLength: 10 },
{
...valid,
planes: [{ offset: 1, stride: 7680, bytes: 8_294_399 }]
},
{
...valid,
planes: [
{ offset: 0, stride: 7680, bytes: 100 },
{ offset: 50, stride: 960, bytes: 8_294_350 }
]
}
])('rejects unsafe or inconsistent metadata', (metadata) => {
expect(() => validatePipeWireFrameMetadata(metadata)).toThrow()
})
})