anear-js-api 1.2.1 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -101,7 +101,7 @@ class ApiService {
101
101
  }
102
102
 
103
103
  prepareGetRequest(resource, params={}) {
104
- const urlString = this.idParamUrlString(resource, params)
104
+ const urlString = this.idParamUrlString(resource, params)
105
105
  return new fetch.Request(
106
106
  urlString, {
107
107
  method: 'GET',
@@ -97,6 +97,14 @@ class AnearEvent extends JsonApiResource {
97
97
  this.send({ type: "BOOT_PARTICIPANT", data: { participantId, reason } })
98
98
  }
99
99
 
100
+ cancelAllParticipantTimeouts() {
101
+ // Cancel all active participant-level timeouts. This sends CANCEL_TIMEOUT
102
+ // to all APMs, which will benignly clear any active timeout timers.
103
+ // Useful for scenarios where a game event (e.g., LIAR call) should immediately
104
+ // cancel all individual participant timeouts.
105
+ this.send({ type: "CANCEL_ALL_PARTICIPANT_TIMEOUTS" })
106
+ }
107
+
100
108
  render(viewPath, displayType, appContext, event, timeout = null, props = {}) {
101
109
  // Explicit render method for guaranteed rendering control
102
110
  // This complements the meta: {} approach for when you need explicit control
@@ -162,7 +162,12 @@ const AnearEventMachineContext = (
162
162
  spectatorsDisplayChannel: null, // display all spectators
163
163
  participants: {},
164
164
  participantMachines: {},
165
- participantsActionTimeout: null
165
+ participantsActionTimeout: null,
166
+ suppressParticipantTimeouts: false, // Flag to suppress PARTICIPANT_TIMEOUT events (cleared when new timers are set up via render)
167
+ eachParticipantCancelActionType: null, // ACTION type that triggers cancellation for eachParticipant timeouts
168
+ pendingCancelConfirmations: null, // Set of participantIds waiting for TIMER_CANCELED confirmation
169
+ pendingCancelAction: null, // ACTION to forward after cancellation completes: { participantId, appEventName, payload }
170
+ consecutiveTimeoutCounts: {} // { participantId: count } tracking consecutive timeouts per participant for dead-man switch
166
171
  })
167
172
 
168
173
  const ActiveEventGlobalEvents = {
@@ -274,7 +279,7 @@ const ActiveEventStatesConfig = {
274
279
  ],
275
280
  on: {
276
281
  ACTION: {
277
- actions: ['processParticipantAction']
282
+ actions: ['resetParticipantTimeoutCount', 'processParticipantAction']
278
283
  },
279
284
  RENDER_DISPLAY: {
280
285
  target: '#createdRendering'
@@ -378,6 +383,7 @@ const ActiveEventStatesConfig = {
378
383
  input: ({ context, event }) => ({ context, event }),
379
384
  onDone: {
380
385
  target: 'notifyingPausedRenderComplete',
386
+ actions: ['clearParticipantTimeoutSuppression']
381
387
  // v5 note: internal transitions are the default (v4 had `internal: true`)
382
388
  },
383
389
  onError: {
@@ -416,6 +422,7 @@ const ActiveEventStatesConfig = {
416
422
  input: ({ context, event }) => ({ context, event }),
417
423
  onDone: {
418
424
  target: 'notifyingRenderComplete',
425
+ actions: ['clearParticipantTimeoutSuppression']
419
426
  // v5 note: internal transitions are the default (v4 had `internal: true`)
420
427
  },
421
428
  onError: {
@@ -441,7 +448,7 @@ const ActiveEventStatesConfig = {
441
448
  initial: 'transitioning',
442
449
  on: {
443
450
  ACTION: {
444
- actions: ['processParticipantAction']
451
+ actions: ['resetParticipantTimeoutCount', 'processParticipantAction']
445
452
  },
446
453
  RENDER_DISPLAY: {
447
454
  target: '#announceRendering'
@@ -484,6 +491,9 @@ const ActiveEventStatesConfig = {
484
491
  BOOT_PARTICIPANT: {
485
492
  actions: 'sendBootEventToParticipantMachine'
486
493
  },
494
+ CANCEL_ALL_PARTICIPANT_TIMEOUTS: {
495
+ actions: 'cancelAllParticipantTimeouts'
496
+ },
487
497
  SPECTATOR_ENTER: {
488
498
  actions: 'sendSpectatorEnterToAppEventMachine'
489
499
  }
@@ -527,6 +537,7 @@ const ActiveEventStatesConfig = {
527
537
  input: ({ context, event }) => ({ context, event }),
528
538
  onDone: {
529
539
  target: 'notifyingRenderComplete',
540
+ actions: ['clearParticipantTimeoutSuppression']
530
541
  // v5 note: internal transitions are the default (v4 had `internal: true`)
531
542
  },
532
543
  onError: {
@@ -616,6 +627,14 @@ const ActiveEventStatesConfig = {
616
627
  },
617
628
  BOOT_PARTICIPANT: {
618
629
  actions: 'sendBootEventToParticipantMachine'
630
+ },
631
+ CANCEL_ALL_PARTICIPANT_TIMEOUTS: {
632
+ actions: 'cancelAllParticipantTimeouts'
633
+ },
634
+ // Handle PARTICIPANT_TIMEOUT even when in liveRendering state
635
+ // (events bubble up from child states, so we need to handle them here)
636
+ PARTICIPANT_TIMEOUT: {
637
+ actions: ['trackParticipantTimeout', 'processParticipantTimeout']
619
638
  }
620
639
  },
621
640
  states: {
@@ -634,14 +653,49 @@ const ActiveEventStatesConfig = {
634
653
  waitingForActions: {
635
654
  id: 'waitingForActions',
636
655
  entry: () => logger.debug('[AEM] live state...waiting for actions'),
656
+ initial: 'idle',
657
+ states: {
658
+ idle: {},
659
+ waitingForCancelConfirmations: {
660
+ entry: ['sendCancelTimeoutToAllAPMs', 'setupPendingCancelConfirmations'],
661
+ on: {
662
+ TIMER_CANCELED: {
663
+ actions: ['removeFromPendingCancelConfirmations']
664
+ },
665
+ PARTICIPANT_EXIT: {
666
+ actions: ['removeFromPendingCancelConfirmations']
667
+ },
668
+ // Drop only ACTION and PARTICIPANT_TIMEOUT events during cancellation wait
669
+ // All other events (presence events, etc.) will bubble up to parent handlers
670
+ ACTION: {
671
+ actions: ['dropEventDuringCancellation']
672
+ },
673
+ PARTICIPANT_TIMEOUT: {
674
+ actions: ['dropEventDuringCancellation']
675
+ }
676
+ },
677
+ always: {
678
+ guard: 'allCancelConfirmationsReceived',
679
+ actions: ['forwardPendingCancelAction', 'clearCancelState'],
680
+ target: '#waitingForActions.idle'
681
+ }
682
+ }
683
+ },
637
684
  on: {
638
685
  ACTION: {
639
686
  actions: ['processParticipantAction']
640
687
  },
641
688
  // Forward per-participant timeouts (from APM) to the AppM
642
689
  PARTICIPANT_TIMEOUT: {
643
- actions: ['processParticipantTimeout'],
690
+ actions: ['trackParticipantTimeout', 'processParticipantTimeout'],
644
691
  // v5 note: internal transitions are the default (v4 had `internal: true`)
692
+ },
693
+ TRIGGER_CANCEL_SEQUENCE: {
694
+ target: '.waitingForCancelConfirmations'
695
+ },
696
+ TIMER_CANCELED: {
697
+ // Ignore if not in cancellation state (defensive)
698
+ actions: []
645
699
  }
646
700
  }
647
701
  },
@@ -654,10 +708,11 @@ const ActiveEventStatesConfig = {
654
708
  {
655
709
  guard: 'isParticipantsTimeoutActive',
656
710
  target: 'notifyingRenderCompleteWithTimeout',
657
- actions: 'setupParticipantsTimeout'
711
+ actions: ['setupParticipantsTimeout', 'setupCancelActionType', 'clearParticipantTimeoutSuppression']
658
712
  },
659
713
  {
660
714
  target: 'notifyingRenderComplete',
715
+ actions: ['setupCancelActionType', 'clearParticipantTimeoutSuppression']
661
716
  // v5 note: internal transitions are the default (v4 had `internal: true`)
662
717
  }
663
718
  ],
@@ -693,13 +748,70 @@ const ActiveEventStatesConfig = {
693
748
  target: 'handleParticipantsTimeout'
694
749
  }
695
750
  },
751
+ initial: 'waiting',
752
+ states: {
753
+ waiting: {
754
+ // The actual waiting state. Timer continues running when we transition to .rendering.
755
+ },
756
+ rendering: {
757
+ invoke: {
758
+ src: 'renderDisplay',
759
+ input: ({ context, event }) => ({ context, event }),
760
+ onDone: [
761
+ {
762
+ guard: 'isParticipantsTimeoutActive',
763
+ target: 'notifyingRenderComplete',
764
+ actions: ['setupCancelActionType', 'clearParticipantTimeoutSuppression']
765
+ // Note: We do NOT call setupParticipantsTimeout here because we're already in waitAllParticipantsResponse
766
+ // and the timer is still running from the parent state (preserved by nested state pattern).
767
+ },
768
+ {
769
+ target: '#waitingForActions',
770
+ actions: ['setupCancelActionType', 'clearParticipantTimeoutSuppression', 'notifyAppMachineRendered']
771
+ }
772
+ ],
773
+ onError: {
774
+ target: '#activeEvent.failure'
775
+ }
776
+ }
777
+ },
778
+ notifyingRenderComplete: {
779
+ after: {
780
+ timeoutRendered: {
781
+ // Target sibling state (stays within parent, timer continues)
782
+ target: 'waiting',
783
+ actions: ['notifyAppMachineRendered']
784
+ }
785
+ }
786
+ }
787
+ },
696
788
  on: {
697
- ACTION: {
698
- actions: ['processAndForwardAction', 'processParticipantResponse'],
699
- // v5 note: internal transitions are the default (v4 had `internal: true`)
789
+ ACTION: [
790
+ {
791
+ // Check if this is a cancel ACTION for allParticipants timeout
792
+ guard: ({ context, event }) => {
793
+ const { cancelActionType } = context.participantsActionTimeout || {}
794
+ if (!cancelActionType) return false
795
+ const eventMessagePayload = JSON.parse(event.data.payload)
796
+ const [appEventName] = Object.entries(eventMessagePayload)[0]
797
+ return cancelActionType === appEventName
798
+ },
799
+ actions: ['storeCancelActionForAllParticipants'],
800
+ target: 'handleParticipantsTimeoutCanceled'
801
+ },
802
+ {
803
+ actions: ['resetParticipantTimeoutCount', 'processAndForwardAction', 'processParticipantResponse'],
804
+ // v5 note: internal transitions are the default (v4 had `internal: true`)
805
+ }
806
+ ],
807
+ CANCEL_ALL_PARTICIPANT_TIMEOUTS: {
808
+ actions: ['cancelAllParticipantTimeouts'],
809
+ target: 'handleParticipantsTimeoutCanceled'
700
810
  },
701
811
  RENDER_DISPLAY: {
702
- target: '#liveRendering'
812
+ // Re-render without exiting waitAllParticipantsResponse.
813
+ // This preserves the parent's after: timer because we stay within the parent state.
814
+ target: '.rendering'
703
815
  },
704
816
  PARTICIPANT_LEAVE: [
705
817
  {
@@ -720,6 +832,10 @@ const ActiveEventStatesConfig = {
720
832
  entry: ['sendActionsTimeoutToAppM', 'clearParticipantsTimeout'],
721
833
  always: 'waitingForActions'
722
834
  },
835
+ handleParticipantsTimeoutCanceled: {
836
+ entry: ['sendActionsTimeoutToAppMCanceled', 'clearParticipantsTimeout', 'forwardPendingCancelActionIfExists'],
837
+ always: 'waitingForActions'
838
+ },
723
839
  participantEntering: {
724
840
  // a PARTICIPANT_ENTER received from a new user JOIN click. Unless already exists,
725
841
  // create an AnearParticipantMachine instance.
@@ -1591,6 +1707,114 @@ const AnearEventMachineFunctions = ({
1591
1707
  sendParticipantExitEvents: ({ context }) => {
1592
1708
  Object.values(context.participantMachines).forEach(pm => pm.send({ type: 'PARTICIPANT_EXIT' }))
1593
1709
  },
1710
+ cancelAllParticipantTimeouts: assign(({ context }) => {
1711
+ // Send CANCEL_TIMEOUT to all participant machines to cancel any active
1712
+ // participant-level timeouts. This is useful when a game event (e.g., LIAR call)
1713
+ // should immediately cancel all individual participant timeouts.
1714
+ // APMs that are not in waitParticipantResponse state will benignly ignore this event.
1715
+ logger.info(`[AEM] Cancelling all participant timeouts (manual) for ${Object.keys(context.participantMachines).length} participants`)
1716
+ Object.values(context.participantMachines).forEach(pm => pm.send({ type: 'CANCEL_TIMEOUT' }))
1717
+
1718
+ // Note: If we're in waitAllParticipantsResponse state, the nested state's handler
1719
+ // will also handle this event and cancel the allParticipants timeout.
1720
+
1721
+ // Set suppression flag to ignore stale PARTICIPANT_TIMEOUT events from cancelled timers.
1722
+ // This flag will be cleared when new timers are set up (via render completion).
1723
+ return {
1724
+ suppressParticipantTimeouts: true
1725
+ }
1726
+ }),
1727
+ sendCancelTimeoutToAllAPMs: ({ context }) => {
1728
+ const participantCount = Object.keys(context.participantMachines).length
1729
+ logger.info(`[AEM] Sending CANCEL_TIMEOUT to all ${participantCount} APMs for declarative cancel ACTION`)
1730
+ Object.values(context.participantMachines).forEach(pm => {
1731
+ pm.send({ type: 'CANCEL_TIMEOUT' })
1732
+ })
1733
+ },
1734
+ setupPendingCancelConfirmations: assign(({ context, event }) => {
1735
+ // Get all active participants with timers (participants in waitParticipantResponse state)
1736
+ const activeParticipantIds = getActiveParticipantIds(context)
1737
+ const pendingConfirmations = new Set(activeParticipantIds)
1738
+
1739
+ logger.info(`[AEM] Starting cancellation sequence for ACTION ${event.data.appEventName}. Waiting for ${pendingConfirmations.size} APM confirmations: ${[...pendingConfirmations].join(', ')}`)
1740
+
1741
+ return {
1742
+ pendingCancelConfirmations: pendingConfirmations,
1743
+ pendingCancelAction: {
1744
+ participantId: event.data.participantId,
1745
+ appEventName: event.data.appEventName,
1746
+ payload: event.data.payload
1747
+ },
1748
+ suppressParticipantTimeouts: true // Drop PARTICIPANT_TIMEOUT events
1749
+ }
1750
+ }),
1751
+ removeFromPendingCancelConfirmations: assign(({ context, event }) => {
1752
+ const participantId = event.participantId || event.data?.id
1753
+ if (!context.pendingCancelConfirmations || !participantId) return {}
1754
+
1755
+ const newPending = new Set(context.pendingCancelConfirmations)
1756
+ newPending.delete(participantId)
1757
+ const remaining = newPending.size
1758
+
1759
+ logger.debug(`[AEM] TIMER_CANCELED received from ${participantId}. ${remaining} confirmations remaining.`)
1760
+
1761
+ return {
1762
+ pendingCancelConfirmations: newPending
1763
+ }
1764
+ }),
1765
+ forwardPendingCancelAction: ({ context }) => {
1766
+ if (!context.pendingCancelAction) {
1767
+ logger.warn(`[AEM] forwardPendingCancelAction called but pendingCancelAction is null`)
1768
+ return
1769
+ }
1770
+
1771
+ const { participantId, appEventName, payload } = context.pendingCancelAction
1772
+
1773
+ const actionEventPayload = {
1774
+ participantId,
1775
+ payload
1776
+ }
1777
+
1778
+ logger.info(`[AEM] All cancellation confirmations received. Forwarding cancel ACTION ${appEventName} from ${participantId} to AppM and APM`)
1779
+
1780
+ // Forward to AppM
1781
+ context.appEventMachine.send({ type: appEventName, ...actionEventPayload })
1782
+
1783
+ // Forward to APM
1784
+ const participantMachine = context.participantMachines[participantId]
1785
+ if (participantMachine) {
1786
+ participantMachine.send({ type: 'ACTION', ...actionEventPayload })
1787
+ } else {
1788
+ logger.warn(`[AEM] Participant machine not found for ${participantId} when forwarding cancel ACTION`)
1789
+ }
1790
+ },
1791
+ clearCancelState: assign({
1792
+ pendingCancelConfirmations: null,
1793
+ pendingCancelAction: null,
1794
+ suppressParticipantTimeouts: false
1795
+ }),
1796
+ dropEventDuringCancellation: ({ context, event }) => {
1797
+ // Drop/ignore ACTION and PARTICIPANT_TIMEOUT events received during cancellation wait
1798
+ // All other events (presence events, etc.) are not handled here and will bubble up to parent handlers
1799
+ logger.debug(`[AEM] Dropping ${event.type} event during cancellation wait`)
1800
+ },
1801
+ clearParticipantTimeoutSuppression: assign({
1802
+ suppressParticipantTimeouts: false
1803
+ }),
1804
+ setupCancelActionType: assign(({ context, event }) => {
1805
+ const cancelActionType =
1806
+ (event && event.output && event.output.cancelActionType) ||
1807
+ (event && event.data && event.data.cancelActionType) ||
1808
+ null
1809
+
1810
+ if (cancelActionType) {
1811
+ logger.debug(`[AEM] Setting up cancel action type for eachParticipant timeout: ${cancelActionType}`)
1812
+ }
1813
+
1814
+ return {
1815
+ eachParticipantCancelActionType: cancelActionType
1816
+ }
1817
+ }),
1594
1818
  updateParticipantPresence: ({ context, event }) => {
1595
1819
  const participantId = event.data.id;
1596
1820
  const participant = context.participants[participantId];
@@ -1613,7 +1837,18 @@ const AnearEventMachineFunctions = ({
1613
1837
  const eventName = getPresenceEventName(participant, 'UPDATE');
1614
1838
  context.appEventMachine.send({ type: eventName, data: event.data });
1615
1839
  },
1616
- processParticipantAction: ({ context, event }) => {
1840
+ resetParticipantTimeoutCount: assign(({ context, event }) => {
1841
+ const participantId = event.data.participantId
1842
+ // Reset consecutive timeout count when participant sends an action
1843
+ if (context.consecutiveTimeoutCounts[participantId]) {
1844
+ logger.debug(`[AEM] Resetting timeout count for participant ${participantId} (action received)`)
1845
+ const newCounts = { ...context.consecutiveTimeoutCounts }
1846
+ delete newCounts[participantId]
1847
+ return { consecutiveTimeoutCounts: newCounts }
1848
+ }
1849
+ return {}
1850
+ }),
1851
+ processParticipantAction: ({ context, event, self }) => {
1617
1852
  // event.data.participantId,
1618
1853
  // event.data.payload: {"appEventMachineACTION": {action event keys and values}}
1619
1854
  // e.g. {"MOVE":{"x":1, "y":2}}
@@ -1623,7 +1858,25 @@ const AnearEventMachineFunctions = ({
1623
1858
  const [appEventName, payload] = Object.entries(eventMessagePayload)[0]
1624
1859
 
1625
1860
  logger.debug(`[AEM] got Event ${appEventName} from payload from participant ${participantId}`)
1861
+ logger.debug(`[AEM] eachParticipantCancelActionType is: ${context.eachParticipantCancelActionType}`)
1862
+
1863
+ // Check if this ACTION should trigger cancellation for eachParticipant timeout
1864
+ if (context.eachParticipantCancelActionType === appEventName) {
1865
+ logger.info(`[AEM] Intercepting cancel ACTION ${appEventName} from ${participantId} (eachParticipant timeout cancellation)`)
1866
+ // Trigger cancellation sequence via self-send
1867
+ self.send({
1868
+ type: 'TRIGGER_CANCEL_SEQUENCE',
1869
+ data: {
1870
+ participantId,
1871
+ appEventName,
1872
+ payload,
1873
+ event // Store original event for reference
1874
+ }
1875
+ })
1876
+ return // Don't forward yet, wait for cancellation to complete
1877
+ }
1626
1878
 
1879
+ // Normal flow: forward immediately
1627
1880
  const actionEventPayload = {
1628
1881
  participantId,
1629
1882
  payload
@@ -1643,6 +1896,9 @@ const AnearEventMachineFunctions = ({
1643
1896
  // This assumes the current participant IS a non-responder.
1644
1897
  const isFinalAction = nonResponders.size === 1 && nonResponders.has(participantId);
1645
1898
 
1899
+ if (isFinalAction) {
1900
+ logger.debug(`[AEM] Final ACTION received from ${participantId}, allParticipants timeout cancelled (all responded)`)
1901
+ }
1646
1902
  logger.info(`[AEM] Participants FINAL ACTION is ${isFinalAction}`)
1647
1903
 
1648
1904
  // Forward to AppM with the finalAction flag
@@ -1662,10 +1918,61 @@ const AnearEventMachineFunctions = ({
1662
1918
  participantMachine.send({ type: 'ACTION', ...apm_Payload });
1663
1919
  }
1664
1920
  },
1665
- processParticipantTimeout: ({ context, event }) =>
1666
- context.appEventMachine.send({ type: 'PARTICIPANT_TIMEOUT', participantId: event.participantId }),
1921
+ storeCancelActionForAllParticipants: assign(({ context, event }) => {
1922
+ const participantId = event.data.participantId
1923
+ const eventMessagePayload = JSON.parse(event.data.payload)
1924
+ const [appEventName, payload] = Object.entries(eventMessagePayload)[0]
1925
+
1926
+ logger.info(`[AEM] Intercepting cancel ACTION ${appEventName} for allParticipants timeout from ${participantId}`)
1927
+
1928
+ return {
1929
+ pendingCancelAction: {
1930
+ participantId,
1931
+ appEventName,
1932
+ payload
1933
+ }
1934
+ }
1935
+ }),
1936
+ trackParticipantTimeout: assign(({ context, event }) => {
1937
+ const participantId = event.participantId
1938
+ const currentCount = context.consecutiveTimeoutCounts[participantId] || 0
1939
+ const newCount = currentCount + 1
1940
+
1941
+ logger.debug(`[AEM] Participant ${participantId} timeout count: ${currentCount} -> ${newCount}`)
1942
+
1943
+ return {
1944
+ consecutiveTimeoutCounts: {
1945
+ ...context.consecutiveTimeoutCounts,
1946
+ [participantId]: newCount
1947
+ }
1948
+ }
1949
+ }),
1950
+ processParticipantTimeout: ({ context, event, self }) => {
1951
+ // Suppress PARTICIPANT_TIMEOUT events after cancelAllParticipantTimeouts() until new timers
1952
+ // are set up via render. This handles race conditions where an APM's timer fires just
1953
+ // before receiving CANCEL_TIMEOUT. The flag is cleared when renders complete (new timers set up).
1954
+ if (context.suppressParticipantTimeouts) {
1955
+ logger.debug(`[AEM] Suppressing PARTICIPANT_TIMEOUT from ${event.participantId} (cancelled timers, waiting for new render)`)
1956
+ return
1957
+ }
1958
+
1959
+ const participantId = event.participantId
1960
+ const timeoutCount = context.consecutiveTimeoutCounts[participantId] || 0
1961
+ const maxConsecutiveTimeouts = C.DEAD_MAN_SWITCH.MAX_CONSECUTIVE_PARTICIPANT_TIMEOUTS
1962
+
1963
+ // Check dead-man switch: if participant has exceeded consecutive timeout threshold, cancel event
1964
+ if (timeoutCount >= maxConsecutiveTimeouts) {
1965
+ logger.warn(`[AEM] Dead-man switch triggered: Participant ${participantId} has ${timeoutCount} consecutive timeouts (threshold: ${maxConsecutiveTimeouts}). Auto-canceling event.`)
1966
+ self.send({ type: 'CANCEL' })
1967
+ return
1968
+ }
1969
+
1970
+ context.appEventMachine.send({ type: 'PARTICIPANT_TIMEOUT', participantId })
1971
+ },
1667
1972
  setupParticipantsTimeout: assign(({ context, event }) => {
1668
- // Only set up a new timeout if one is provided by the display render workflow.
1973
+ // Only set up a new timeout if one is provided by the display render workflow
1974
+ // AND we don't already have an active timeout (i.e., we're entering from waitingForActions,
1975
+ // not re-rendering during waitAllParticipantsResponse).
1669
1976
  //
1670
1977
  // v5 note: `renderDisplay` is implemented as a Promise actor (fromPromise).
1671
1978
  // The actor's resolved value is delivered on the done event as `event.output`
@@ -1676,21 +1983,29 @@ const AnearEventMachineFunctions = ({
1676
1983
  (event && event.data && event.data.participantsTimeout) ||
1677
1984
  null
1678
1985
 
1679
- // This prevents overwriting an existing timeout during a simple re-render.
1680
- if (participantsTimeout) {
1986
+ // Only set up a NEW timeout if one is provided and we don't already have an active timeout.
1987
+ // If we're re-rendering during waitAllParticipantsResponse, the timer is still running
1988
+ // from the parent state (preserved by nested state pattern), so we don't overwrite it.
1989
+ if (participantsTimeout && !context.participantsActionTimeout) {
1681
1990
  const timeoutMsecs = participantsTimeout.msecs
1682
1991
  const allParticipantIds = getActiveParticipantIds(context)
1683
- logger.debug(`[AEM] Starting participants action timeout for ${timeoutMsecs}ms. Responders: ${allParticipantIds.join(', ')}`)
1992
+ const cancelActionType =
1993
+ (event && event.output && event.output.cancelActionType) ||
1994
+ (event && event.data && event.data.cancelActionType) ||
1995
+ null
1996
+
1997
+ logger.debug(`[AEM] Setting up allParticipants timeout: ${timeoutMsecs}ms for ${allParticipantIds.length} participants: ${allParticipantIds.join(', ')}${cancelActionType ? `, cancel: ${cancelActionType}` : ''}`)
1684
1998
 
1685
1999
  return {
1686
2000
  participantsActionTimeout: {
1687
2001
  msecs: timeoutMsecs,
1688
2002
  startedAt: Date.now(),
1689
- nonResponders: new Set(allParticipantIds)
2003
+ nonResponders: new Set(allParticipantIds),
2004
+ cancelActionType: cancelActionType
1690
2005
  }
1691
2006
  }
1692
2007
  }
1693
- // If no timeout data, return empty object to not change context
2008
+ // If no timeout data or timeout already exists, return empty object to not change context
1694
2009
  return {};
1695
2010
  }),
1696
2011
  processParticipantResponse: assign(({ context, event }) => {
@@ -1698,6 +2013,9 @@ const AnearEventMachineFunctions = ({
1698
2013
  const { nonResponders, ...rest } = context.participantsActionTimeout
1699
2014
  const newNonResponders = new Set(nonResponders)
1700
2015
  newNonResponders.delete(participantId)
2016
+ const remainingCount = newNonResponders.size
2017
+
2018
+ logger.debug(`[AEM] Participant ${participantId} responded, removing from allParticipants timeout. ${remainingCount} participants remaining.`)
1701
2019
 
1702
2020
  return {
1703
2021
  participantsActionTimeout: {
@@ -1726,15 +2044,53 @@ const AnearEventMachineFunctions = ({
1726
2044
  }
1727
2045
  };
1728
2046
  }),
1729
- sendActionsTimeoutToAppM: ({ context }) => {
2047
+ sendActionsTimeoutToAppM: ({ context, self }) => {
1730
2048
  const { nonResponders, msecs } = context.participantsActionTimeout
1731
2049
  const nonResponderIds = [...nonResponders]
1732
- logger.info(`[AEM] Participants action timed out. Non-responders: ${nonResponderIds.join(', ')}`)
2050
+ const allActiveParticipantIds = getActiveParticipantIds(context)
2051
+
2052
+ logger.info(`[AEM] allParticipants timeout expired after ${msecs}ms. Non-responders: ${nonResponderIds.join(', ')}`)
2053
+
2054
+ // Dead-man switch: if all participants timed out, auto-cancel the event
2055
+ if (nonResponders.size === allActiveParticipantIds.length && allActiveParticipantIds.length > 0) {
2056
+ logger.warn(`[AEM] Dead-man switch triggered: All ${allActiveParticipantIds.length} participants timed out in allParticipants timeout. Auto-canceling event.`)
2057
+ self.send({ type: 'CANCEL' })
2058
+ return
2059
+ }
1733
2060
 
1734
2061
  if (context.appEventMachine) {
1735
2062
  context.appEventMachine.send({ type: 'ACTIONS_TIMEOUT', timeout: msecs, nonResponderIds })
1736
2063
  }
1737
2064
  },
2065
+ sendActionsTimeoutToAppMCanceled: ({ context }) => {
2066
+ const { nonResponders, msecs } = context.participantsActionTimeout
2067
+ const nonResponderIds = [...nonResponders]
2068
+ logger.info(`[AEM] allParticipants timeout cancelled after ${msecs}ms. Non-responders at cancellation: ${nonResponderIds.join(', ')}`)
2069
+
2070
+ if (context.appEventMachine) {
2071
+ context.appEventMachine.send({ type: 'ACTIONS_TIMEOUT', timeout: msecs, nonResponderIds, canceled: true })
2072
+ }
2073
+ },
2074
+ forwardPendingCancelActionIfExists: ({ context }) => {
2075
+ if (!context.pendingCancelAction) return // No cancel ACTION to forward
2076
+
2077
+ const { participantId, appEventName, payload } = context.pendingCancelAction
2078
+ const actionEventPayload = { participantId, payload }
2079
+
2080
+ // Forward to AppM
2081
+ context.appEventMachine.send({ type: appEventName, ...actionEventPayload })
2082
+
2083
+ // Forward to APM
2084
+ const participantMachine = context.participantMachines[participantId]
2085
+ if (participantMachine) {
2086
+ participantMachine.send({ type: 'ACTION', ...actionEventPayload })
2087
+ }
2088
+
2089
+ logger.info(`[AEM] Forwarded cancel ACTION ${appEventName} from ${participantId} to AppM and APM after allParticipants cancellation`)
2090
+
2091
+ // Clear the pending action
2092
+ context.pendingCancelAction = null
2093
+ },
1738
2094
  clearParticipantsTimeout: assign({
1739
2095
  participantsActionTimeout: null
1740
2096
  }),
@@ -1744,6 +2100,9 @@ const AnearEventMachineFunctions = ({
1744
2100
 
1745
2101
  logger.debug(`[AEM] cleaning up exiting participant ${participantId}`)
1746
2102
 
2103
+ // Clean up timeout counts for exiting participant
2104
+ const { [participantId]: removedTimeoutCount, ...remainingTimeoutCounts } = context.consecutiveTimeoutCounts
2105
+
1747
2106
  if (participant) {
1748
2107
  const isOpenHouse = context.anearEvent.openHouse || false
1749
2108
 
@@ -1766,7 +2125,8 @@ const AnearEventMachineFunctions = ({
1766
2125
  ...context.participants,
1767
2126
  [participantId]: updatedParticipant
1768
2127
  },
1769
- participantMachines: remainingMachines
2128
+ participantMachines: remainingMachines,
2129
+ consecutiveTimeoutCounts: remainingTimeoutCounts
1770
2130
  }
1771
2131
  } else {
1772
2132
  // For non-open-house events: remove both participant and machine (existing behavior)
@@ -1782,11 +2142,14 @@ const AnearEventMachineFunctions = ({
1782
2142
 
1783
2143
  return {
1784
2144
  participants: remainingParticipants,
1785
- participantMachines: remainingMachines
2145
+ participantMachines: remainingMachines,
2146
+ consecutiveTimeoutCounts: remainingTimeoutCounts
1786
2147
  }
1787
2148
  }
1788
2149
  } else {
1789
- return {}
2150
+ return {
2151
+ consecutiveTimeoutCounts: remainingTimeoutCounts
2152
+ }
1790
2153
  }
1791
2154
  }),
1792
2155
  notifyCoreServiceMachineExit: ({ context }) => {
@@ -1985,8 +2348,9 @@ const AnearEventMachineFunctions = ({
1985
2348
  renderDisplay: fromPromise(async ({ input }) => {
1986
2349
  const { context, event } = input
1987
2350
  const displayEventProcessor = new DisplayEventProcessor(context)
2351
+ const cancelActionType = event.cancelActionType || null
1988
2352
 
1989
- return await displayEventProcessor.processAndPublish(event.displayEvents)
2353
+ return await displayEventProcessor.processAndPublish(event.displayEvents, cancelActionType)
1990
2354
  }),
1991
2355
  getAttachedCreatorOrHost: fromPromise(async ({ input }) => {
1992
2356
  const { context } = input
@@ -2162,6 +2526,9 @@ const AnearEventMachineFunctions = ({
2162
2526
  },
2163
2527
  allParticipantsResponded: ({ context }) => {
2164
2528
  return context.participantsActionTimeout && context.participantsActionTimeout.nonResponders.size === 0
2529
+ },
2530
+ allCancelConfirmationsReceived: ({ context }) => {
2531
+ return !context.pendingCancelConfirmations || context.pendingCancelConfirmations.size === 0
2165
2532
  }
2166
2533
  },
2167
2534
  delays: {
@@ -161,6 +161,7 @@ const AnearParticipantMachineConfig = participantId => ({
161
161
  initial: 'idle',
162
162
  states: {
163
163
  idle: {
164
+ entry: ['ensureTimerCleared'],
164
165
  on: {
165
166
  RENDER_DISPLAY: {
166
167
  target: '#renderDisplay'
@@ -168,6 +169,12 @@ const AnearParticipantMachineConfig = participantId => ({
168
169
  PRIVATE_DISPLAY: {
169
170
  target: '#renderDisplay'
170
171
  },
172
+ CANCEL_TIMEOUT: {
173
+ // Acknowledge CANCEL_TIMEOUT even when idle (no active timer).
174
+ // This ensures the AEM's cancellation sequence completes for all participants.
175
+ actions: ['sendTimerCanceled'],
176
+ // v5 note: internal transitions are the default (v4 had `internal: true`)
177
+ },
171
178
  PARTICIPANT_DISCONNECT: {
172
179
  target: 'waitReconnect'
173
180
  },
@@ -235,8 +242,7 @@ const AnearParticipantMachineConfig = participantId => ({
235
242
  onDone: [
236
243
  { guard: 'hasActionTimeout', actions: 'updateActionTimeout', target: 'waitParticipantResponse' },
237
244
  // If this display does not configure a timeout, explicitly clear any
238
- // previously-running action timeout. Otherwise, a stale actionTimeoutStart
239
- // from an older prompt can be "resumed" later and immediately fire.
245
+ // previously-running action timeout.
240
246
  { actions: 'nullActionTimeout', target: 'idle' }
241
247
  ],
242
248
  onError: {
@@ -251,6 +257,7 @@ const AnearParticipantMachineConfig = participantId => ({
251
257
  }
252
258
  },
253
259
  waitParticipantResponse: {
260
+ entry: 'logWaitParticipantResponseEntry',
254
261
  always: {
255
262
  guard: 'isTimeoutImmediate',
256
263
  actions: 'logImmediateTimeout',
@@ -262,16 +269,41 @@ const AnearParticipantMachineConfig = participantId => ({
262
269
  target: '#participantTimedOut'
263
270
  }
264
271
  },
272
+ initial: 'waiting',
273
+ states: {
274
+ waiting: {
275
+ // The actual waiting state. Timer continues running when we transition to .rendering.
276
+ },
277
+ rendering: {
278
+ invoke: {
279
+ src: 'publishPrivateDisplay',
280
+ input: ({ context, event }) => ({ context, event }),
281
+ onDone: {
282
+ // Transition back to waiting - timer continues because we never exited waitParticipantResponse
283
+ // Target sibling state (stays within parent, timer continues)
284
+ target: 'waiting'
285
+ },
286
+ onError: {
287
+ target: '#error'
288
+ }
289
+ },
290
+ on: {
291
+ PARTICIPANT_EXIT: {
292
+ actions: 'logExit',
293
+ target: '#cleanupAndExit'
294
+ }
295
+ }
296
+ }
297
+ },
265
298
  on: {
266
299
  RENDER_DISPLAY: {
267
- // Re-render the current prompt for this participant (e.g. after reconnect)
268
- // without resetting the original timeout start. The updateActionTimeout
269
- // action will recompute the remaining time based on the original
270
- // actionTimeoutStart so the participant does not get extra time.
271
- target: '#renderDisplay'
300
+ // Re-render the current prompt without exiting waitParticipantResponse.
301
+ // This preserves the parent's after: timer because we stay within the parent state.
302
+ target: '.rendering'
272
303
  },
273
304
  PRIVATE_DISPLAY: {
274
- target: '#renderDisplay'
305
+ // Same as RENDER_DISPLAY - re-render without interrupting timer
306
+ target: '.rendering'
275
307
  },
276
308
  PARTICIPANT_EXIT: {
277
309
  actions: 'logExit',
@@ -285,6 +317,14 @@ const AnearParticipantMachineConfig = participantId => ({
285
317
  ],
286
318
  target: 'idle'
287
319
  },
320
+ CANCEL_TIMEOUT: {
321
+ // Cancel the active timeout and transition to idle. This is used
322
+ // when the AppM wants to immediately cancel all participant timeouts
323
+ // (e.g., when a LIAR call interrupts the wait state).
324
+ // Always send TIMER_CANCELED to acknowledge, even if no timer was active.
325
+ actions: ['nullActionTimeout', 'sendTimerCanceled'],
326
+ target: 'idle'
327
+ },
288
328
  PARTICIPANT_DISCONNECT: {
289
329
  actions: 'updateRemainingTimeoutOnDisconnect',
290
330
  target: 'waitReconnect'
@@ -297,7 +337,7 @@ const AnearParticipantMachineConfig = participantId => ({
297
337
  },
298
338
  participantTimedOut: {
299
339
  id: 'participantTimedOut',
300
- entry: ['sendTimeoutEvents', 'nullActionTimeout'],
340
+ entry: ['logParticipantTimedOut', 'sendTimeoutEvents', 'nullActionTimeout'],
301
341
  always: 'idle'
302
342
  },
303
343
  cleanupAndExit: {
@@ -412,6 +452,7 @@ const AnearParticipantMachineFunctions = {
412
452
  }
413
453
  }),
414
454
  sendTimeoutEvents: ({ context }) => {
455
+ logger.info(`[APM] Timer expired for ${context.anearParticipant.id}, sending PARTICIPANT_TIMEOUT to AEM`)
415
456
  // AEM currently expects `event.participantId` (not nested under `data`).
416
457
  context.anearEvent.send({
417
458
  type: 'PARTICIPANT_TIMEOUT',
@@ -419,6 +460,13 @@ const AnearParticipantMachineFunctions = {
419
460
  })
420
461
  if (context.appParticipantMachine) context.appParticipantMachine.send({ type: 'PARTICIPANT_TIMEOUT' })
421
462
  },
463
+ sendTimerCanceled: ({ context }) => {
464
+ logger.debug(`[APM] Sending TIMER_CANCELED to AEM for ${context.anearParticipant.id}`)
465
+ context.anearEvent.send({
466
+ type: 'TIMER_CANCELED',
467
+ participantId: context.anearParticipant.id
468
+ })
469
+ },
422
470
  sendActionToAppParticipantMachine: ({ context, event }) => {
423
471
  if (context.appParticipantMachine) context.appParticipantMachine.send(event)
424
472
  },
@@ -429,34 +477,41 @@ const AnearParticipantMachineFunctions = {
429
477
  const timeoutFromEvent = event?.output?.timeout ?? event?.data?.timeout
430
478
  const now = CurrentDateTimestamp()
431
479
 
432
- // If we already have a running timer (e.g. after a disconnect/reconnect
433
- // cycle or a re-render), treat actionTimeoutMsecs as the remaining
434
- // duration for this leg and subtract only the time since the last
435
- // (re)start. This avoids double-subtracting elapsed time across
436
- // multiple renders.
437
- if (context.actionTimeoutStart) {
438
- const elapsed = now - context.actionTimeoutStart
439
- const base = context.actionTimeoutMsecs != null ? context.actionTimeoutMsecs : timeoutFromEvent
440
- const remaining = base - elapsed
441
- const remainingMsecs = remaining > 0 ? remaining : 0
442
-
443
- logger.debug(`[APM] Resuming timer for ${context.anearParticipant.id} with ${remainingMsecs}ms remaining`)
444
- return {
445
- actionTimeoutMsecs: remainingMsecs,
446
- actionTimeoutStart: now
447
- }
480
+ // Start a new timer. This is only called when transitioning from idle to waitParticipantResponse.
481
+ // When RENDER_DISPLAY arrives during waitParticipantResponse, we transition to the nested
482
+ // .rendering state, which preserves the parent's after: timer (no resume needed).
483
+ const existingMsecs = context.actionTimeoutMsecs
484
+ const existingStart = context.actionTimeoutStart
485
+ if (existingMsecs != null || existingStart != null) {
486
+ logger.debug(`[APM] WARNING: Starting new timer for ${context.anearParticipant.id} but existing timer state found: msecs=${existingMsecs}, start=${existingStart}`)
448
487
  }
449
-
450
- // First time this participant sees this prompt: start a fresh timer.
451
488
  logger.debug(`[APM] Starting new timer for ${context.anearParticipant.id} with ${timeoutFromEvent}ms`)
452
489
  return {
453
490
  actionTimeoutMsecs: timeoutFromEvent,
454
491
  actionTimeoutStart: now
455
492
  }
456
493
  }),
457
- nullActionTimeout: assign({
458
- actionTimeoutMsecs: (_args) => null,
459
- actionTimeoutStart: (_args) => null
494
+ nullActionTimeout: assign(({ context }) => {
495
+ if (context.actionTimeoutMsecs != null || context.actionTimeoutStart != null) {
496
+ logger.debug(`[APM] Cancelling timer for ${context.anearParticipant.id} (ACTION received or timeout cleared)`)
497
+ }
498
+ return {
499
+ actionTimeoutMsecs: null,
500
+ actionTimeoutStart: null
501
+ }
502
+ }),
503
+ ensureTimerCleared: assign(({ context }) => {
504
+ // Defensive: Ensure timer state is cleared when entering idle state.
505
+ // This prevents stale timer state from affecting new displays, especially
506
+ // if a RENDER_DISPLAY arrives before timeout cleanup completes.
507
+ if (context.actionTimeoutMsecs != null || context.actionTimeoutStart != null) {
508
+ logger.debug(`[APM] Clearing stale timer state for ${context.anearParticipant.id} on idle entry (msecs=${context.actionTimeoutMsecs}, start=${context.actionTimeoutStart})`)
509
+ return {
510
+ actionTimeoutMsecs: null,
511
+ actionTimeoutStart: null
512
+ }
513
+ }
514
+ return {}
460
515
  }),
461
516
  updateRemainingTimeoutOnDisconnect: assign(({ context }) => {
462
517
  if (context.actionTimeoutStart) {
@@ -492,6 +547,8 @@ const AnearParticipantMachineFunctions = {
492
547
  },
493
548
  logAttached: ({ context }) => logger.debug(`[APM] Got ATTACHED for privateChannel for ${context.anearParticipant.id}`),
494
549
  logLive: ({ context }) => logger.debug(`[APM] Participant ${context.anearParticipant.id} is LIVE!`),
550
+ logWaitParticipantResponseEntry: ({ context }) => logger.debug(`[APM] ENTERING waitParticipantResponse state for ${context.anearParticipant.id} - timer will be (re)started if context provides timeout`),
551
+ logParticipantTimedOut: ({ context }) => logger.debug(`[APM] ENTERING participantTimedOut state for ${context.anearParticipant.id} - timeout occurred, clearing timer and sending PARTICIPANT_TIMEOUT`),
495
552
  logExit: (_args) => logger.debug('[APM] got PARTICIPANT_EXIT. Exiting...'),
496
553
  logDisconnected: ({ context }) => logger.info(`[APM] Participant ${context.anearParticipant.id} has DISCONNECTED`),
497
554
  logActionTimeoutWhileDisconnected: ({ context }) => logger.info(`[APM] Participant ${context.anearParticipant.id} timed out on action while disconnected.`),
@@ -94,6 +94,7 @@ const AppMachineTransition = (anearEvent) => {
94
94
  const appStateName = _stringifiedState(value)
95
95
 
96
96
  const displayEvents = []
97
+ let cancelActionType = null // Track cancel property from meta configuration
97
98
 
98
99
  // Process all meta objects to handle unpredictable AppM structures
99
100
  metaObjects.forEach(meta => {
@@ -112,6 +113,11 @@ const AppMachineTransition = (anearEvent) => {
112
113
  const { viewPath, props } = _extractViewAndProps(meta.allParticipants)
113
114
  timeoutFn = RenderContextBuilder.buildTimeoutFn(viewer, meta.allParticipants.timeout)
114
115
 
116
+ // Extract cancel property if present
117
+ if (meta.allParticipants.cancel && !cancelActionType) {
118
+ cancelActionType = meta.allParticipants.cancel
119
+ }
120
+
115
121
  displayEvent = RenderContextBuilder.buildDisplayEvent(
116
122
  viewPath,
117
123
  RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, viewer, timeoutFn),
@@ -127,7 +133,8 @@ const AppMachineTransition = (anearEvent) => {
127
133
  // Check if participant is a function (new selective rendering format)
128
134
  viewer = 'eachParticipant'
129
135
  if (typeof meta.eachParticipant === 'function') {
130
- // New selective participant rendering
136
+ // New selective participant rendering - cancel property is not supported in function format
137
+ // (would need to be in the object format, see legacy handling below)
131
138
  const participantRenderFunc = meta.eachParticipant
132
139
  const participantDisplays = participantRenderFunc(appContext, event)
133
140
 
@@ -165,6 +172,11 @@ const AppMachineTransition = (anearEvent) => {
165
172
  const { viewPath, props } = _extractViewAndProps(meta.eachParticipant)
166
173
  const timeoutFn = RenderContextBuilder.buildTimeoutFn('participant', meta.eachParticipant.timeout)
167
174
 
175
+ // Extract cancel property if present (for legacy eachParticipant format)
176
+ if (meta.eachParticipant.cancel && !cancelActionType) {
177
+ cancelActionType = meta.eachParticipant.cancel
178
+ }
179
+
168
180
  const renderContext = RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, 'eachParticipant', timeoutFn)
169
181
  displayEvent = RenderContextBuilder.buildDisplayEvent(
170
182
  viewPath,
@@ -213,7 +225,15 @@ const AppMachineTransition = (anearEvent) => {
213
225
 
214
226
  if (displayEvents.length > 0) {
215
227
  logger.debug(`[AppMachineTransition] sending RENDER_DISPLAY with ${displayEvents.length} displayEvents`)
216
- anearEvent.send({ type: 'RENDER_DISPLAY', displayEvents })
228
+ const renderDisplayPayload = {
229
+ type: 'RENDER_DISPLAY',
230
+ displayEvents
231
+ }
232
+ // Include cancel property only if defined
233
+ if (cancelActionType) {
234
+ renderDisplayPayload.cancelActionType = cancelActionType
235
+ }
236
+ anearEvent.send(renderDisplayPayload)
217
237
  }
218
238
  }
219
239
 
@@ -16,6 +16,10 @@ module.exports = {
16
16
  RECONNECT: 30 * 1000, // 30 seconds
17
17
  BOOT_EXIT: 2 * 1000 // 5 seconds
18
18
  },
19
+ DEAD_MAN_SWITCH: {
20
+ // Maximum consecutive timeouts per participant before auto-canceling event
21
+ MAX_CONSECUTIVE_PARTICIPANT_TIMEOUTS: 4
22
+ },
19
23
  EventStates: {
20
24
  CREATED: 'created',
21
25
  STARTED: 'started',
@@ -56,7 +56,7 @@ class DisplayEventProcessor {
56
56
  this.participantsIndex = this._buildParticipantsIndex(anearEventMachineContext.participants)
57
57
  }
58
58
 
59
- processAndPublish(displayEvents) {
59
+ processAndPublish(displayEvents, cancelActionType = null) {
60
60
  let participantsTimeout = null
61
61
 
62
62
  // Pre-compute allParticipants timeout for this render batch so other viewers
@@ -88,7 +88,7 @@ class DisplayEventProcessor {
88
88
  })
89
89
 
90
90
  return Promise.all(publishPromises).then(() => {
91
- return { participantsTimeout }
91
+ return { participantsTimeout, cancelActionType }
92
92
  })
93
93
  }
94
94
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anear-js-api",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "Javascript Developer API for Anear Apps",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {
@@ -19,7 +19,7 @@ test('constructor', () => {
19
19
  test('attributes by index', () => {
20
20
  const tq = new TriviaQuestions(json1)
21
21
  let attr = tq.attributes(0)
22
-
22
+
23
23
  expect(attr['question-text']).toBe("How much is 1 + 15?")
24
24
  expect(attr.category).toBe("math")
25
25