session.mjs 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. import test from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import { createVideoLiveSession } from '../../src/modules/cockpit/services/videoLiveSession.js'
  4. const data = (code) => ({ deviceCode: code, sessionId: `session-${code}`, mode: 'LIVE', status: 'ACTIVE', media: { native: true, url: `https://media.invalid/${code}.mp4` } })
  5. const prepared = (code) => ({ deviceCode: code, sessionId: `session-${code}`, sessionToken: `owner-${code}`, mode: 'LIVE', status: 'PREPARED' })
  6. const stopped = (session) => ({ sessionId: session.sessionId, status: 'STOPPED' })
  7. test('simultaneous open commands never create a video for a superseded device', async () => {
  8. const requests = [], updates = []
  9. const live = createVideoLiveSession({
  10. prepare: async (code) => prepared(code),
  11. start: async (session) => { const code = typeof session === 'string' ? session : session.deviceCode; requests.push(code); return data(code) },
  12. heartbeat: async () => {}, stop: async (session) => stopped(session),
  13. }, (patch) => updates.push(patch))
  14. try {
  15. await Promise.all([live.start('A'), live.start('B')])
  16. assert.deepEqual(requests, ['B'])
  17. assert.equal(updates.some((patch) => patch.media?.url.endsWith('/A.mp4')), false)
  18. } finally { await live.dispose() }
  19. })
  20. test('closing in the same turn cancels an open before it can start server media', async () => {
  21. const requests = []
  22. const live = createVideoLiveSession({
  23. prepare: async (code) => prepared(code),
  24. start: async (session) => { const code = typeof session === 'string' ? session : session.deviceCode; requests.push(code); return data(code) },
  25. heartbeat: async () => {}, stop: async (session) => stopped(session),
  26. }, () => {})
  27. try {
  28. const opening = live.start('A')
  29. await live.stop()
  30. await opening
  31. assert.deepEqual(requests, [])
  32. } finally { await live.dispose() }
  33. })
  34. class Clock {
  35. now = 0; next = 1; jobs = new Map()
  36. setTimeout(fn, delay) { const id = this.next++; this.jobs.set(id, { fn, at: this.now + delay }); return id }
  37. clearTimeout(id) { this.jobs.delete(id) }
  38. setInterval(fn, delay) { const id = this.setTimeout(fn, delay); this.jobs.get(id).interval = delay; return id }
  39. clearInterval(id) { this.clearTimeout(id) }
  40. async advance(duration) {
  41. const end = this.now + duration
  42. while (true) {
  43. const due = Array.from(this.jobs).filter(([, job]) => job.at <= end).sort((a, b) => a[1].at - b[1].at)[0]
  44. if (!due) break
  45. const [id, job] = due; this.now = job.at
  46. if (job.interval) job.at += job.interval; else this.jobs.delete(id)
  47. job.fn(); await new Promise(setImmediate)
  48. }
  49. this.now = end; await new Promise(setImmediate)
  50. }
  51. }
  52. test('STARTING is polled using the prepared identity and is never mistaken for active media', async () => {
  53. const clock = new Clock(), updates = [], releases = [], statuses = []
  54. const live = createVideoLiveSession({
  55. prepare: async () => prepared('A'),
  56. start: async () => ({ sessionId: 'session-A', status: 'STARTING' }),
  57. status: async (session) => { statuses.push(session.sessionId); return data('A') },
  58. stop: async (session) => { releases.push(session.sessionId); return stopped(session) },
  59. heartbeat: async () => {},
  60. }, (patch) => updates.push(patch), clock)
  61. try {
  62. const opening = live.start('A'); await new Promise(setImmediate)
  63. assert.equal(updates.some((patch) => patch.media), false)
  64. await clock.advance(5000); await opening
  65. assert.deepEqual(statuses, ['session-A'])
  66. assert.equal(updates.some((patch) => patch.media?.url.endsWith('/A.mp4')), true)
  67. assert.deepEqual(releases, [])
  68. } finally { await live.dispose() }
  69. })
  70. test('accepted cancellation stays unconfirmed until status attests terminal cleanup', async () => {
  71. const clock = new Clock(), visible = {}, statusCalls = []
  72. const live = createVideoLiveSession({
  73. prepare: async () => prepared('A'), start: async () => data('A'), heartbeat: async () => {},
  74. stop: async () => ({ sessionId: 'session-A', status: 'CLEANUP_PENDING' }),
  75. status: async (session) => { statusCalls.push(session.sessionId); return { sessionId: 'session-A', status: 'STOPPED' } },
  76. }, (patch) => Object.assign(visible, patch), clock)
  77. try {
  78. await live.start('A'); live.ready(); await live.stop()
  79. assert.equal(visible.cleanupError.includes('未确认'), true)
  80. await clock.advance(5000)
  81. assert.deepEqual(statusCalls, ['session-A'])
  82. assert.equal(visible.cleanupError, '')
  83. assert.equal(visible.media, null)
  84. } finally { await live.dispose() }
  85. })
  86. test('a confirmed cancel permits the new device while the old live response is still in flight', async () => {
  87. const updates = [], starts = []
  88. let arrived, finishOld
  89. const entered = new Promise((resolve) => { arrived = resolve })
  90. const late = new Promise((resolve) => { finishOld = resolve })
  91. const live = createVideoLiveSession({
  92. prepare: async (code) => prepared(code),
  93. start: async (session) => { starts.push(session.deviceCode); if (session.deviceCode === 'A') { arrived(); return late }; return data('B') },
  94. stop: async (session) => stopped(session), heartbeat: async () => {},
  95. }, (patch) => updates.push(patch))
  96. const first = live.start('A')
  97. try {
  98. await entered
  99. await live.start('B')
  100. assert.deepEqual(starts, ['A', 'B'])
  101. finishOld(data('A')); await first
  102. const displayed = updates.filter((patch) => patch.media).map((patch) => patch.media.url)
  103. assert.deepEqual(displayed, ['https://media.invalid/B.mp4'])
  104. } finally { finishOld(data('A')); await first; await live.dispose() }
  105. })
  106. test('cancel releases the prepared identity while live is still pending and a late response cannot publish media', async () => {
  107. const releases = [], updates = []
  108. let started, respond
  109. const entered = new Promise((resolve) => { started = resolve })
  110. const delayed = new Promise((resolve) => { respond = resolve })
  111. const live = createVideoLiveSession({
  112. prepare: async (code) => prepared(code),
  113. start: async () => { started(); return delayed },
  114. stop: async (session) => { releases.push({ sessionId: session.sessionId, sessionToken: session.sessionToken }); return stopped(session) },
  115. heartbeat: async () => {},
  116. }, (patch) => updates.push(patch))
  117. const opening = live.start('A')
  118. try {
  119. await entered
  120. await live.stop()
  121. assert.deepEqual(releases, [{ sessionId: 'session-A', sessionToken: 'owner-A' }])
  122. respond(data('A')); await opening
  123. assert.equal(updates.some((patch) => patch.media), false)
  124. } finally { respond(data('A')); await opening; await live.dispose() }
  125. })