Skip to content

Commit 0d93946

Browse files
authored
Merge pull request Sofie-Automation#1762 from SuperFlyTV/fix/property-access-error-during-rundown-deactivation
fix: property access error during rundown deactivation
2 parents 73d1391 + 58ef28f commit 0d93946

2 files changed

Lines changed: 161 additions & 5 deletions

File tree

meteor/server/api/deviceTriggers/StudioObserver.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -212,14 +212,16 @@ export class StudioObserver extends EventEmitter {
212212
this.nextProps = undefined
213213

214214
const { activePlaylistId, activationId } = this.currentProps
215+
const rundownContentChanged = this.#rundownContentChanged
216+
const pieceInstancesChanged = this.#pieceInstancesChanged
215217

216218
this.showStyleBaseId = showStyleBaseId
217219

218220
this.#rundownsLiveQuery = await RundownsObserver.create(activePlaylistId, async (rundownIds) => {
219221
logger.silly(`Creating new RundownContentObserver`)
220222

221223
const obs1 = await RundownContentObserver.create(activePlaylistId, showStyleBaseId, rundownIds, (cache) => {
222-
return this.#rundownContentChanged(showStyleBaseId, cache)
224+
return rundownContentChanged(showStyleBaseId, cache)
223225
})
224226

225227
return () => {
@@ -228,11 +230,9 @@ export class StudioObserver extends EventEmitter {
228230
})
229231

230232
this.#pieceInstancesLiveQuery = await PieceInstancesObserver.create(activationId, showStyleBaseId, (cache) => {
231-
const cleanupChanges = this.#pieceInstancesChanged(showStyleBaseId, cache)
233+
const cleanupChanges = pieceInstancesChanged(showStyleBaseId, cache)
232234

233-
return () => {
234-
cleanupChanges?.()
235-
}
235+
return () => cleanupChanges?.()
236236
})
237237

238238
if (this.#disposed) {
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { protectString } from '@sofie-automation/corelib/dist/protectedString'
2+
import {
3+
RundownId,
4+
RundownPlaylistActivationId,
5+
RundownPlaylistId,
6+
ShowStyleBaseId,
7+
StudioId,
8+
} from '@sofie-automation/corelib/dist/dataModel/Ids'
9+
10+
import type { ContentCache as RundownContentCache } from '../reactiveContentCache'
11+
import type { ContentCache as PieceInstancesContentCache } from '../reactiveContentCacheForPieceInstances'
12+
import { runAllTimers } from '../../../../__mocks__/helpers/jest'
13+
14+
type OnChangedRundown = (cache: RundownContentCache) => () => void
15+
type OnChangedPieceInstances = (cache: PieceInstancesContentCache) => () => void
16+
17+
let capturedRundownContentOnChanged: OnChangedRundown | undefined
18+
let capturedPieceInstancesOnChanged: OnChangedPieceInstances | undefined
19+
20+
jest.mock('../../../publications/lib/observerChain', () => {
21+
const fakeHandle = { stop: jest.fn() }
22+
const chain: any = {
23+
next: jest.fn(() => chain),
24+
end: jest.fn(() => fakeHandle),
25+
}
26+
return {
27+
observerChain: jest.fn(() => chain),
28+
}
29+
})
30+
31+
jest.mock('../RundownsObserver', () => {
32+
return {
33+
RundownsObserver: {
34+
create: jest.fn(
35+
async (_playlistId: RundownPlaylistId, onChanged: (ids: RundownId[]) => Promise<() => void>) => {
36+
// Immediately drive the callback once, to emulate initial observer execution
37+
await onChanged([protectString<RundownId>('r0')])
38+
return { stop: jest.fn() }
39+
}
40+
),
41+
},
42+
}
43+
})
44+
45+
jest.mock('../RundownContentObserver', () => {
46+
return {
47+
RundownContentObserver: {
48+
create: jest.fn(
49+
async (
50+
_playlistId: RundownPlaylistId,
51+
_showStyleBaseId: ShowStyleBaseId,
52+
_rundownIds: RundownId[],
53+
onChanged: OnChangedRundown
54+
) => {
55+
capturedRundownContentOnChanged = onChanged
56+
return { stop: jest.fn() }
57+
}
58+
),
59+
},
60+
}
61+
})
62+
63+
jest.mock('../PieceInstancesObserver', () => {
64+
return {
65+
PieceInstancesObserver: {
66+
create: jest.fn(
67+
async (
68+
_activationId: RundownPlaylistActivationId,
69+
_showStyleBaseId: ShowStyleBaseId,
70+
onChanged: OnChangedPieceInstances
71+
) => {
72+
capturedPieceInstancesOnChanged = onChanged
73+
return { stop: jest.fn() }
74+
}
75+
),
76+
},
77+
}
78+
})
79+
80+
describe('StudioObserver', () => {
81+
beforeEach(() => {
82+
jest.useFakeTimers()
83+
capturedRundownContentOnChanged = undefined
84+
capturedPieceInstancesOnChanged = undefined
85+
})
86+
87+
test('rundown deactivation regression: observer callbacks must not depend on `this` (private fields)', async () => {
88+
// Import after mocks are in place
89+
const { StudioObserver } = await import('../StudioObserver')
90+
91+
const studioId = protectString<StudioId>('studio0')
92+
const playlistId = protectString<RundownPlaylistId>('playlist0')
93+
const activationId = protectString<RundownPlaylistActivationId>('activation0')
94+
const rundownId = protectString<RundownId>('rundown0')
95+
const showStyleBaseId = protectString<ShowStyleBaseId>('showStyleBase0')
96+
97+
const rundownCleanup = jest.fn()
98+
const pieceCleanup = jest.fn()
99+
100+
const onRundownContentChanged = jest.fn(
101+
(_ssbId: ShowStyleBaseId, _cache: RundownContentCache) => rundownCleanup
102+
)
103+
const onPieceInstancesChanged = jest.fn(
104+
(_ssbId: ShowStyleBaseId, _cache: PieceInstancesContentCache) => pieceCleanup
105+
)
106+
107+
const observer = new StudioObserver(studioId, onRundownContentChanged, onPieceInstancesChanged)
108+
109+
// Prime state so updateShowStyle goes down the creation path
110+
;(observer as any).nextProps = {
111+
activePlaylistId: playlistId,
112+
activationId,
113+
currentRundownId: rundownId,
114+
}
115+
116+
const state = {
117+
currentRundown: { _id: rundownId, showStyleBaseId },
118+
showStyleBase: { _id: showStyleBaseId },
119+
}
120+
121+
// Trigger the debounced execution
122+
const ps: Promise<void> = (observer as any).updateShowStyle.call(state)
123+
124+
// Flush debounce timers and any queued promises
125+
await jest.advanceTimersByTimeAsync(25)
126+
await runAllTimers()
127+
await ps
128+
129+
// Ensure we captured callbacks from the two observers
130+
expect(capturedRundownContentOnChanged).toBeTruthy()
131+
expect(capturedPieceInstancesOnChanged).toBeTruthy()
132+
133+
const mockRundownCache = {} as any as RundownContentCache
134+
const mockPieceInstancesCache = {} as any as PieceInstancesContentCache
135+
136+
// Regression: invoke callbacks without a bound `this` (simulates lost context)
137+
expect(() => capturedRundownContentOnChanged!(mockRundownCache)).not.toThrow()
138+
expect(() => capturedPieceInstancesOnChanged!(mockPieceInstancesCache)).not.toThrow()
139+
140+
// They should return cleanup fns
141+
const cleanup1 = capturedRundownContentOnChanged!(mockRundownCache)
142+
const cleanup2 = capturedPieceInstancesOnChanged!(mockPieceInstancesCache)
143+
expect(typeof cleanup1).toBe('function')
144+
expect(typeof cleanup2).toBe('function')
145+
146+
// Ensure our handlers were called with expected args
147+
expect(onRundownContentChanged).toHaveBeenCalledWith(showStyleBaseId, mockRundownCache)
148+
expect(onPieceInstancesChanged).toHaveBeenCalledWith(showStyleBaseId, mockPieceInstancesCache)
149+
150+
// Ensure returned cleanup fns are callable
151+
expect(() => cleanup1()).not.toThrow()
152+
expect(() => cleanup2()).not.toThrow()
153+
154+
observer.stop()
155+
})
156+
})

0 commit comments

Comments
 (0)