collavre 0.24.0 → 0.25.0
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.
- checksums.yaml +4 -4
- data/README.md +0 -1
- data/app/assets/stylesheets/collavre/comments_popup.css +165 -99
- data/app/assets/stylesheets/collavre/creatives.css +346 -37
- data/app/assets/stylesheets/collavre/desktop_setup.css +246 -0
- data/app/assets/stylesheets/collavre/gnb.css +64 -3
- data/app/assets/stylesheets/collavre/landing.css +108 -3
- data/app/assets/stylesheets/collavre/mention_menu.css +32 -0
- data/app/assets/stylesheets/collavre/popup.css +14 -0
- data/app/assets/stylesheets/collavre/print.css +0 -1
- data/app/assets/stylesheets/collavre/search_popup.css +1 -1
- data/app/assets/stylesheets/collavre/secret_fields.css +3 -0
- data/app/assets/stylesheets/collavre/workspace.css +307 -0
- data/app/channels/collavre/agent_channel.rb +14 -21
- data/app/channels/collavre/comments_presence_channel.rb +181 -21
- data/app/channels/collavre/inbox_badge_channel.rb +7 -1
- data/app/components/collavre/inbox/badge_component.rb +6 -13
- data/app/components/collavre/progress_filter_component.html.erb +2 -2
- data/app/components/collavre/progress_filter_component.rb +20 -0
- data/app/controllers/collavre/admin/settings_controller.rb +4 -1
- data/app/controllers/collavre/agent_connections_controller.rb +165 -0
- data/app/controllers/collavre/agent_gateways_controller.rb +132 -0
- data/app/controllers/collavre/agent_provisioning_controller.rb +79 -0
- data/app/controllers/collavre/api/v1/agents_controller.rb +45 -97
- data/app/controllers/collavre/application_controller.rb +13 -2
- data/app/controllers/collavre/comment_read_pointers_controller.rb +69 -40
- data/app/controllers/collavre/comments_controller.rb +45 -131
- data/app/controllers/collavre/concerns/shareable.rb +9 -3
- data/app/controllers/collavre/creatives_controller.rb +144 -11
- data/app/controllers/collavre/desktop_gateway_registrations_controller.rb +173 -0
- data/app/controllers/collavre/desktop_setup_controller.rb +119 -0
- data/app/controllers/collavre/desktop_sidecar_health_controller.rb +35 -0
- data/app/controllers/collavre/features_controller.rb +57 -0
- data/app/controllers/collavre/invites_controller.rb +12 -1
- data/app/controllers/collavre/llm_models_controller.rb +8 -0
- data/app/controllers/collavre/notices_controller.rb +20 -0
- data/app/controllers/collavre/tasks_controller.rb +40 -6
- data/app/controllers/collavre/topics_controller.rb +130 -53
- data/app/controllers/collavre/user_creative_preferences_controller.rb +214 -30
- data/app/controllers/concerns/collavre/comments/batch_operations.rb +15 -3
- data/app/controllers/concerns/collavre/comments/conversion.rb +1 -1
- data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +118 -8
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +1 -15
- data/app/controllers/concerns/collavre/users_controller/registration.rb +2 -2
- data/app/errors/collavre/turn_deadline_error.rb +20 -0
- data/app/helpers/collavre/application_helper.rb +38 -0
- data/app/helpers/collavre/creatives_helper.rb +56 -22
- data/app/helpers/collavre/navigation_helper.rb +12 -0
- data/app/javascript/collavre.js +1 -2
- data/app/javascript/components/InlineLexicalEditor.jsx +30 -21
- data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +32 -2
- data/app/javascript/components/__tests__/creative_tree_row_progress_toggle.test.js +185 -0
- data/app/javascript/components/creative_tree_row.js +62 -22
- data/app/javascript/controllers/__tests__/agent_connection_controller.test.js +195 -0
- data/app/javascript/controllers/__tests__/desktop_proxy_setup_controller.test.js +96 -0
- data/app/javascript/controllers/__tests__/index.test.js +42 -0
- data/app/javascript/controllers/__tests__/last_visited_creative_controller.test.js +103 -0
- data/app/javascript/controllers/__tests__/link_creative_controller.test.js +253 -2
- data/app/javascript/controllers/__tests__/progress_filter_controller.test.js +134 -0
- data/app/javascript/controllers/__tests__/search_popup_controller.test.js +310 -0
- data/app/javascript/controllers/__tests__/topic_list_controller.test.js +111 -0
- data/app/javascript/controllers/__tests__/workspace_tree_controller.test.js +1011 -0
- data/app/javascript/controllers/agent_connection_controller.js +357 -0
- data/app/javascript/controllers/agent_vendor_controller.js +23 -0
- data/app/javascript/controllers/comment_controller.js +3 -1
- data/app/javascript/controllers/comments/__tests__/feature_cards_controller.test.js +223 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_default_placeholder.test.js +83 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_draft.test.js +2069 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_ime_send.test.js +324 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +28 -2
- data/app/javascript/controllers/comments/__tests__/form_controller_search_submit.test.js +106 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft.test.js +134 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_creative_switch.test.js +166 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_failure.test.js +183 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_deep_link_topic.test.js +467 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_docked.test.js +115 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_prev_message.test.js +245 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_read_pointer.test.js +276 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_stream_archived.test.js +221 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_stream_search.test.js +104 -0
- data/app/javascript/controllers/comments/__tests__/placeholder_controller.test.js +79 -0
- data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +754 -1
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +717 -15
- data/app/javascript/controllers/comments/__tests__/prev_message_navigator.test.js +129 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_action_errors.test.js +184 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_archived.test.js +879 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_creation_container.test.js +154 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_creation_scroll.test.js +92 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +130 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_docked.test.js +32 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_last_topic_echo.test.js +1938 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_primary_agent.test.js +256 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_scroll.test.js +182 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_selection_race.test.js +981 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +313 -0
- data/app/javascript/controllers/comments/feature_cards_controller.js +83 -0
- data/app/javascript/controllers/comments/form_controller.js +795 -14
- data/app/javascript/controllers/comments/list_controller.js +338 -52
- data/app/javascript/controllers/comments/placeholder_controller.js +30 -0
- data/app/javascript/controllers/comments/popup_controller.js +425 -83
- data/app/javascript/controllers/comments/presence_controller.js +317 -55
- data/app/javascript/controllers/comments/prev_message_navigator.js +94 -0
- data/app/javascript/controllers/comments/topics_controller.js +1772 -100
- data/app/javascript/controllers/common_popup_controller.js +2 -2
- data/app/javascript/controllers/creatives/__tests__/select_mode_controller.test.js +139 -0
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +605 -0
- data/app/javascript/controllers/creatives/__tests__/write_access_request_controller.test.js +125 -0
- data/app/javascript/controllers/creatives/select_mode_controller.js +7 -2
- data/app/javascript/controllers/creatives/tree_controller.js +140 -31
- data/app/javascript/controllers/creatives/write_access_request_controller.js +58 -0
- data/app/javascript/controllers/desktop_proxy_setup_controller.js +51 -0
- data/app/javascript/controllers/gateway_check_controller.js +36 -0
- data/app/javascript/controllers/index.js +25 -1
- data/app/javascript/controllers/last_visited_creative_controller.js +58 -0
- data/app/javascript/controllers/link_creative_controller.js +89 -18
- data/app/javascript/controllers/progress_filter_controller.js +12 -22
- data/app/javascript/controllers/search_popup_controller.js +64 -46
- data/app/javascript/controllers/share_user_search_controller.js +2 -2
- data/app/javascript/controllers/topic_list_controller.js +44 -7
- data/app/javascript/controllers/workspace_tree_controller.js +662 -0
- data/app/javascript/creatives/__tests__/tree_renderer.test.js +80 -0
- data/app/javascript/creatives/drag_drop/__tests__/drop_completion_empty_state.test.js +98 -0
- data/app/javascript/creatives/drag_drop/__tests__/topic_move_released_agent.test.js +125 -0
- data/app/javascript/creatives/drag_drop/event_handlers.js +12 -0
- data/app/javascript/creatives/tree_renderer.js +65 -22
- data/app/javascript/lib/__tests__/chat_drafts.test.js +1458 -0
- data/app/javascript/lib/__tests__/common_popup_bounds.test.js +8 -3
- data/app/javascript/lib/__tests__/common_popup_placement.test.js +374 -0
- data/app/javascript/lib/__tests__/common_popup_reshow.test.js +131 -0
- data/app/javascript/lib/__tests__/creative_link_prefetch.test.js +46 -0
- data/app/javascript/lib/__tests__/last_visited_creative.test.js +87 -0
- data/app/javascript/lib/__tests__/skill_html_entities.test.js +23 -0
- data/app/javascript/lib/__tests__/turbo_stream_actions.test.js +190 -0
- data/app/javascript/lib/api/__tests__/creatives.test.js +68 -0
- data/app/javascript/lib/api/__tests__/csrf_fetch.test.js +27 -0
- data/app/javascript/lib/api/__tests__/topics_save_last_topic.test.js +112 -0
- data/app/javascript/lib/api/creatives.js +32 -13
- data/app/javascript/lib/api/csrf_fetch.js +6 -4
- data/app/javascript/lib/api/topics.js +63 -5
- data/app/javascript/lib/chat_drafts.js +786 -0
- data/app/javascript/lib/common_popup.js +164 -25
- data/app/javascript/lib/creative_link_prefetch.js +27 -0
- data/app/javascript/lib/last_visited_creative.js +97 -0
- data/app/javascript/lib/lexical/__tests__/creative_link_node.test.js +162 -0
- data/app/javascript/lib/lexical/__tests__/creative_link_trigger.test.js +133 -0
- data/app/javascript/lib/lexical/__tests__/link_toolbar.test.js +62 -0
- data/app/javascript/lib/lexical/creative_link_node.js +101 -0
- data/app/javascript/lib/lexical/creative_link_trigger.js +98 -0
- data/app/javascript/lib/lexical/link_toolbar.js +23 -0
- data/app/javascript/lib/turbo_stream_actions.js +23 -16
- data/app/javascript/lib/utils/__tests__/filter_navigation.test.js +210 -0
- data/app/javascript/lib/utils/__tests__/markdown.test.js +44 -0
- data/app/javascript/lib/utils/filter_navigation.js +100 -0
- data/app/javascript/modules/__tests__/command_menu_creative_picker.test.js +62 -0
- data/app/javascript/modules/__tests__/command_menu_typeahead.test.js +267 -0
- data/app/javascript/modules/__tests__/creative_link_picker.test.js +85 -0
- data/app/javascript/modules/__tests__/creative_row_editor_archive.test.js +391 -0
- data/app/javascript/modules/__tests__/creative_row_editor_delegated_clicks.test.js +158 -0
- data/app/javascript/modules/__tests__/creative_row_editor_empty_state_recovery.test.js +519 -0
- data/app/javascript/modules/__tests__/creative_row_editor_empty_state_removal.test.js +346 -0
- data/app/javascript/modules/__tests__/creative_row_editor_session.test.js +144 -0
- data/app/javascript/modules/__tests__/creative_tree_dom.test.js +30 -0
- data/app/javascript/modules/__tests__/creative_tree_empty_state.test.js +186 -0
- data/app/javascript/modules/__tests__/dom_listener_registry.test.js +67 -0
- data/app/javascript/modules/__tests__/support/inline_editor_dom.js +159 -0
- data/app/javascript/modules/command_args_form.js +0 -12
- data/app/javascript/modules/command_menu.js +80 -38
- data/app/javascript/modules/creative_link_picker.js +56 -0
- data/app/javascript/modules/creative_row_editor.js +446 -243
- data/app/javascript/modules/creative_row_editor_delegated_clicks.js +87 -0
- data/app/javascript/modules/creative_tree_dom.js +4 -0
- data/app/javascript/modules/creative_tree_empty_state.js +80 -0
- data/app/javascript/modules/dom_listener_registry.js +23 -0
- data/app/javascript/modules/mention_menu.js +2 -3
- data/app/javascript/utils/__tests__/caret_anchor.test.js +75 -0
- data/app/javascript/utils/__tests__/chevron_icons.test.js +15 -0
- data/app/javascript/utils/caret_position.js +10 -0
- data/app/javascript/utils/chevron_icons.js +7 -0
- data/app/jobs/collavre/ai_agent_job.rb +262 -72
- data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +11 -36
- data/app/jobs/collavre/comment_badges_broadcast_job.rb +28 -0
- data/app/jobs/collavre/comment_link_preview_job.rb +33 -0
- data/app/jobs/collavre/comment_notification_job.rb +14 -0
- data/app/jobs/collavre/comment_push_delivery_sweep_job.rb +17 -0
- data/app/jobs/collavre/compress_job.rb +33 -22
- data/app/jobs/collavre/creative_broadcast_job.rb +161 -48
- data/app/jobs/collavre/cron_action_job.rb +36 -18
- data/app/jobs/collavre/drop_trigger_job.rb +23 -9
- data/app/jobs/collavre/merge_comments_job.rb +33 -16
- data/app/jobs/collavre/push_notification_job.rb +62 -18
- data/app/jobs/collavre/restore_dropped_dispatches_job.rb +17 -0
- data/app/jobs/collavre/stuck_detector_job.rb +1 -1
- data/app/jobs/collavre/touch_creative_subtree_job.rb +14 -0
- data/app/models/collavre/agent_gateway.rb +200 -0
- data/app/models/collavre/agent_subscription.rb +4 -4
- data/app/models/collavre/agent_workspace.rb +210 -0
- data/app/models/collavre/comment/broadcastable.rb +22 -52
- data/app/models/collavre/comment/notifiable.rb +154 -77
- data/app/models/collavre/comment/topic_membership.rb +41 -0
- data/app/models/collavre/comment.rb +336 -31
- data/app/models/collavre/comment_notification_delivery.rb +61 -0
- data/app/models/collavre/comment_presence_store/cache_batches.rb +64 -0
- data/app/models/collavre/comment_presence_store.rb +272 -12
- data/app/models/collavre/comment_read_pointer.rb +9 -15
- data/app/models/collavre/creative/describable.rb +8 -2
- data/app/models/collavre/creative/permissible.rb +11 -0
- data/app/models/collavre/creative.rb +29 -4
- data/app/models/collavre/creative_share.rb +109 -4
- data/app/models/collavre/current.rb +1 -0
- data/app/models/collavre/llm_model.rb +62 -0
- data/app/models/collavre/system_setting.rb +12 -1
- data/app/models/collavre/task.rb +121 -2
- data/app/models/collavre/topic.rb +51 -0
- data/app/models/collavre/user.rb +101 -41
- data/app/models/collavre/user_creative_preference.rb +3 -1
- data/app/services/collavre/agent_provisioning/archive.rb +108 -0
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +16 -3
- data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +63 -6
- data/app/services/collavre/ai_agent/approval_handler.rb +12 -4
- data/app/services/collavre/ai_agent/claude_channel_adapter.rb +10 -5
- data/app/services/collavre/ai_agent/merged_trigger_comments.rb +164 -0
- data/app/services/collavre/ai_agent/message_builder.rb +103 -16
- data/app/services/collavre/ai_agent/session_provisioner.rb +3 -7
- data/app/services/collavre/ai_agent/task_claim_service.rb +29 -15
- data/app/services/collavre/ai_agent/task_reply_service.rb +67 -0
- data/app/services/collavre/ai_agent_service.rb +108 -20
- data/app/services/collavre/ai_client.rb +211 -21
- data/app/services/collavre/cli_proxy/client.rb +147 -0
- data/app/services/collavre/cli_proxy/endpoint_policy.rb +81 -0
- data/app/services/collavre/cli_proxy/identity.rb +47 -0
- data/app/services/collavre/cli_proxy/safe_net_http_adapter.rb +19 -0
- data/app/services/collavre/command_menu_service.rb +0 -10
- data/app/services/collavre/comment_move_service.rb +96 -7
- data/app/services/collavre/comment_snapshot_restore_service.rb +11 -5
- data/app/services/collavre/comments/action_executor.rb +48 -20
- data/app/services/collavre/comments/command_processor.rb +0 -1
- data/app/services/collavre/comments/list_response.rb +133 -0
- data/app/services/collavre/comments/read_pointer_writer.rb +210 -0
- data/app/services/collavre/comments/read_receipt_broadcaster.rb +157 -0
- data/app/services/collavre/comments/read_receipt_index.rb +152 -0
- data/app/services/collavre/comments/topic_command.rb +72 -1
- data/app/services/collavre/comments/topic_mutation.rb +20 -0
- data/app/services/collavre/creatives/breadcrumb_resolver.rb +1 -1
- data/app/services/collavre/creatives/comment_badge_index.rb +299 -27
- data/app/services/collavre/creatives/reorderer.rb +15 -3
- data/app/services/collavre/creatives/tree_builder.rb +1 -1
- data/app/services/collavre/creatives/tree_formatter.rb +5 -6
- data/app/services/collavre/creatives/workspace_path_resolver.rb +50 -0
- data/app/services/collavre/creatives/workspace_tree_builder.rb +92 -0
- data/app/services/collavre/crons/recurring_topic_tasks.rb +23 -0
- data/app/services/collavre/home_page_path.rb +55 -0
- data/app/services/collavre/html_text.rb +64 -0
- data/app/services/collavre/http_client.rb +11 -2
- data/app/services/collavre/inbox_reply_service.rb +1 -1
- data/app/services/collavre/markdown_converter.rb +13 -1
- data/app/services/collavre/orchestration/agent_orchestrator.rb +776 -81
- data/app/services/collavre/orchestration/arbiter.rb +48 -5
- data/app/services/collavre/orchestration/deferred_trigger_scope.rb +53 -0
- data/app/services/collavre/orchestration/delivery_record.rb +792 -0
- data/app/services/collavre/orchestration/loop_breaker.rb +10 -4
- data/app/services/collavre/orchestration/matcher.rb +132 -17
- data/app/services/collavre/orchestration/policy_resolver.rb +58 -10
- data/app/services/collavre/orchestration/scheduler.rb +10 -4
- data/app/services/collavre/orchestration/scheduling_hooks.rb +10 -0
- data/app/services/collavre/orchestration/selection.rb +43 -0
- data/app/services/collavre/orchestration/stuck_detector.rb +25 -112
- data/app/services/collavre/orchestration/task_coalescer.rb +355 -0
- data/app/services/collavre/orchestration/topic_slot.rb +73 -0
- data/app/services/collavre/system_events/context_builder.rb +60 -11
- data/app/services/collavre/system_events/dispatcher.rb +58 -12
- data/app/services/collavre/system_events/envelope.rb +73 -0
- data/app/services/collavre/system_events/vocabulary.rb +45 -0
- data/app/services/collavre/tools/creative_retrieval_service.rb +1 -1
- data/app/services/collavre/tools/cron_create_service.rb +32 -18
- data/app/services/collavre/tools/id_list.rb +38 -0
- data/app/services/collavre/tools/topic_authorizer.rb +61 -7
- data/app/services/collavre/tools/topic_branch_service.rb +85 -0
- data/app/services/collavre/tools/topic_create_service.rb +117 -0
- data/app/services/collavre/tools/topic_list_service.rb +101 -0
- data/app/services/collavre/tools/topic_message_create_service.rb +192 -0
- data/app/services/collavre/tools/topic_messages_service.rb +346 -0
- data/app/services/collavre/tools/topic_move_service.rb +108 -0
- data/app/services/collavre/tools/topic_selection.rb +59 -0
- data/app/services/collavre/tools/topic_update_service.rb +165 -0
- data/app/services/collavre/topic_branch_service.rb +39 -10
- data/app/services/collavre/topics/agent_resolver.rb +87 -0
- data/app/services/collavre/topics/char_budget.rb +70 -0
- data/app/services/collavre/topics/last_topic_preference_payload.rb +18 -0
- data/app/services/collavre/topics/message_page.rb +380 -0
- data/app/services/collavre/topics/message_page_markdown.rb +102 -0
- data/app/services/collavre/topics/message_scope.rb +61 -0
- data/app/services/collavre/topics/message_stats.rb +92 -0
- data/app/services/collavre/topics/move_blocker.rb +47 -0
- data/app/services/collavre/topics/next_name.rb +35 -0
- data/app/services/collavre/topics/pin_rejection.rb +25 -0
- data/app/services/collavre/topics/read_pointer_relocator.rb +71 -0
- data/app/services/collavre/topics/reserved_name.rb +18 -0
- data/app/services/collavre/topics/serializer.rb +92 -0
- data/app/services/collavre/topics/topic_destroy.rb +30 -0
- data/app/services/collavre/topics/topic_move.rb +80 -0
- data/app/services/collavre/topics/topic_move_effects.rb +35 -0
- data/app/services/collavre/topics/topic_mutation.rb +27 -0
- data/app/views/collavre/admin/integrations/_setting_row.html.erb +5 -8
- data/app/views/collavre/admin/settings/_system_tab.html.erb +7 -0
- data/app/views/collavre/agent_connections/show.html.erb +77 -0
- data/app/views/collavre/agent_gateways/_form.html.erb +71 -0
- data/app/views/collavre/agent_gateways/edit.html.erb +2 -0
- data/app/views/collavre/agent_gateways/index.html.erb +54 -0
- data/app/views/collavre/agent_gateways/new.html.erb +2 -0
- data/app/views/collavre/comments/_comments_popup.html.erb +32 -17
- data/app/views/collavre/comments/_empty_state.html.erb +33 -0
- data/app/views/collavre/comments/_empty_state_minimal.html.erb +7 -0
- data/app/views/collavre/comments/_feature_card.html.erb +35 -0
- data/app/views/collavre/comments/_list.html.erb +3 -2
- data/app/views/collavre/comments/_no_search_results.html.erb +3 -0
- data/app/views/collavre/creatives/_add_button.html.erb +17 -19
- data/app/views/collavre/creatives/_empty_state.html.erb +109 -0
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +5 -5
- data/app/views/collavre/creatives/_tree_loading_placeholder.html.erb +16 -0
- data/app/views/collavre/creatives/_workspace_tree.html.erb +33 -0
- data/app/views/collavre/creatives/index.html.erb +79 -18
- data/app/views/collavre/desktop_setup/show.html.erb +114 -0
- data/app/views/collavre/features/index.html.erb +42 -0
- data/app/views/collavre/features/show.html.erb +52 -0
- data/app/views/collavre/invitation_mailer/invite.html.erb +1 -1
- data/app/views/collavre/invitation_mailer/invite.text.erb +1 -1
- data/app/views/collavre/invites/show.html.erb +14 -1
- data/app/views/collavre/landing/show.html.erb +5 -1
- data/app/views/collavre/shared/_link_creative_modal.html.erb +4 -1
- data/app/views/collavre/shared/navigation/_help_button.html.erb +3 -1
- data/app/views/collavre/shared/navigation/_panels.html.erb +1 -8
- data/app/views/collavre/shared/navigation/_search_form.html.erb +13 -2
- data/app/views/collavre/user_themes/index.html.erb +1 -1
- data/app/views/collavre/users/_llm_fields.html.erb +65 -0
- data/app/views/collavre/users/_org_chart_node.html.erb +1 -1
- data/app/views/collavre/users/edit_ai.html.erb +33 -31
- data/app/views/collavre/users/new.html.erb +4 -2
- data/app/views/collavre/users/new_ai.html.erb +14 -32
- data/app/views/collavre/users/show.html.erb +13 -1
- data/app/views/layouts/collavre/landing.html.erb +9 -4
- data/config/initializers/ruby_llm_openai_reasoning_content.rb +77 -0
- data/config/locales/admin.en.yml +3 -1
- data/config/locales/admin.ko.yml +3 -1
- data/config/locales/agent_gateway.en.yml +217 -0
- data/config/locales/agent_gateway.ko.yml +217 -0
- data/config/locales/ai_agent.en.yml +0 -1
- data/config/locales/ai_agent.ko.yml +0 -1
- data/config/locales/comments.en.yml +72 -30
- data/config/locales/comments.ko.yml +72 -30
- data/config/locales/creatives.en.yml +32 -1
- data/config/locales/creatives.ko.yml +32 -1
- data/config/locales/features.en.yml +140 -0
- data/config/locales/features.ko.yml +140 -0
- data/config/locales/invites.en.yml +3 -0
- data/config/locales/invites.ko.yml +3 -0
- data/config/locales/landing.en.yml +2 -1
- data/config/locales/landing.ko.yml +2 -1
- data/config/locales/tools.en.yml +20 -0
- data/config/locales/tools.ko.yml +20 -0
- data/config/locales/users.en.yml +15 -14
- data/config/locales/users.ko.yml +14 -14
- data/config/routes.rb +45 -19
- data/db/migrate/20250911084338_backfill_creative_in_comment_inbox_items.rb +9 -1
- data/db/migrate/20260724000000_add_creative_id_index_to_comments.rb +34 -0
- data/db/migrate/20260724000001_add_creative_private_id_index_to_comments.rb +50 -0
- data/db/migrate/20260724090000_add_async_comment_notification_state.rb +64 -0
- data/db/migrate/20260727000000_add_waiting_notice_scope_to_comments.rb +30 -0
- data/db/migrate/20260727010000_add_waiting_notice_scope_to_tasks.rb +26 -0
- data/db/migrate/20260805000000_add_creative_workspace_enabled_to_users.rb +5 -0
- data/db/migrate/20260807050000_add_dismissed_notices_to_users.rb +5 -0
- data/db/migrate/20260809000000_change_creative_workspace_enabled_default_to_true.rb +18 -0
- data/db/migrate/20260809000001_create_llm_models.rb +64 -0
- data/db/migrate/20260809000002_create_agent_gateways_and_workspaces.rb +35 -0
- data/db/migrate/20260809000003_hash_agent_workspace_callback_tokens.rb +44 -0
- data/db/migrate/20260809000004_encrypt_agent_workspace_manifest_tokens.rb +59 -0
- data/db/migrate/20260810000000_split_agent_workspace_identity_axes.rb +58 -0
- data/db/migrate/20260810000001_seed_codex_custom_proxy_llm_models.rb +28 -0
- data/db/migrate/20260810010000_add_last_visited_creative_to_users.rb +8 -0
- data/db/migrate/20260810020000_add_last_visited_creative_at_to_users.rb +5 -0
- data/db/migrate/20260810030000_add_last_visited_creative_navigation_order_to_users.rb +6 -0
- data/db/migrate/20260810060000_add_last_visited_creative_issued_sequence_to_users.rb +5 -0
- data/db/migrate/20260810100000_add_desktop_managed_to_agent_gateways.rb +40 -0
- data/db/migrate/20260810100001_add_desktop_preset_adapter_to_users.rb +38 -0
- data/db/migrate/20260811000000_make_agent_gateway_completion_key_nullable.rb +7 -0
- data/db/migrate/20260812000000_remove_workflow_columns_from_tasks.rb +101 -0
- data/db/migrate/20260812000001_remove_android_devices.rb +24 -0
- data/db/migrate/20260812010000_drop_inbox_items.rb +22 -0
- data/db/migrate/20260813000000_clear_claude_channel_presence_routing_expressions.rb +16 -0
- data/db/migrate/20260813000001_add_last_topic_revision_to_user_creative_preferences.rb +5 -0
- data/db/migrate/20260813000002_add_last_topic_save_order_to_user_creative_preferences.rb +6 -0
- data/db/migrate/20260813000003_add_last_topic_save_sequences_to_user_creative_preferences.rb +5 -0
- data/db/migrate/20260813000004_add_last_topic_save_fences_to_user_creative_preferences.rb +6 -0
- data/db/migrate/20260814000000_add_topic_to_comment_read_pointers.rb +79 -0
- data/db/migrate/20260814000001_add_creative_topic_private_id_index_to_comments.rb +37 -0
- data/db/migrate/20260816000000_add_watermark_index_to_comment_read_pointers.rb +27 -0
- data/db/migrate/20260816000001_add_private_recipient_topic_index_to_comments.rb +48 -0
- data/db/migrate/20260820000000_add_topic_assigned_at_to_comments.rb +13 -0
- data/db/seeds.rb +1 -51
- data/lib/collavre/engine.rb +108 -1
- data/lib/collavre/feature_card_registry.rb +168 -0
- data/lib/collavre/hashed_access_token_lookup.rb +22 -0
- data/lib/collavre/sensitive_request_silencer.rb +23 -0
- data/lib/collavre/user_extensions.rb +0 -2
- data/lib/collavre/version.rb +1 -1
- data/lib/collavre.rb +1 -0
- data/skills/collavre/SKILL.md +165 -0
- data/skills/collavre/package.json +8 -0
- data/skills/collavre/references/tool-reference.md +347 -0
- data/skills/collavre/scripts/collavre +634 -0
- data/skills/collavre/scripts/html_entities.js +24 -0
- metadata +228 -30
- data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +0 -224
- data/app/controllers/collavre/api/v1/mobile/base_controller.rb +0 -95
- data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +0 -31
- data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +0 -25
- data/app/controllers/collavre/inbox_items_controller.rb +0 -70
- data/app/controllers/collavre/typo_corrections_controller.rb +0 -39
- data/app/javascript/lib/__tests__/typo_correction.test.js +0 -192
- data/app/javascript/lib/typo_correction.js +0 -146
- data/app/javascript/modules/__tests__/typo_corrector.test.js +0 -365
- data/app/javascript/modules/creative_guide.js +0 -67
- data/app/javascript/modules/typo_corrector.js +0 -534
- data/app/jobs/collavre/inbox_summary_job.rb +0 -24
- data/app/mailers/collavre/inbox_mailer.rb +0 -19
- data/app/models/collavre/inbox_item.rb +0 -89
- data/app/services/collavre/comments/concerns/workflow_support.rb +0 -115
- data/app/services/collavre/comments/work_command.rb +0 -161
- data/app/services/collavre/comments/workflow_executor.rb +0 -276
- data/app/services/collavre/gemini_parent_recommender.rb +0 -77
- data/app/services/collavre/mobile/event_summarizer.rb +0 -40
- data/app/services/collavre/typo_corrector.rb +0 -188
- data/app/views/collavre/inbox_items/_item.html.erb +0 -18
- data/app/views/collavre/inbox_items/_items.html.erb +0 -3
- data/app/views/collavre/inbox_items/_list.html.erb +0 -7
- data/app/views/collavre/inbox_items/index.html.erb +0 -1
- data/app/views/collavre/inbox_mailer/daily_summary.html.erb +0 -11
- data/app/views/collavre/inbox_mailer/daily_summary.text.erb +0 -8
- data/app/views/collavre/users/typo_correction.html.erb +0 -50
- data/config/locales/mobile.en.yml +0 -16
- data/config/locales/mobile.ko.yml +0 -16
|
@@ -0,0 +1,1938 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { jest } from '@jest/globals'
|
|
6
|
+
|
|
7
|
+
// The real helper resolves to response.ok — never throws, never undefined.
|
|
8
|
+
const saveLastTopic = jest.fn().mockResolvedValue(true)
|
|
9
|
+
|
|
10
|
+
jest.unstable_mockModule('../../../lib/api/topics', () => ({
|
|
11
|
+
fetchNextTopicName: jest.fn(),
|
|
12
|
+
createTopicWithComments: jest.fn(),
|
|
13
|
+
saveLastTopic,
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
// Captures the lifecycle callbacks the controller registers, so a test can fire
|
|
17
|
+
// the ones ActionCable fires on a dropped or refused connection.
|
|
18
|
+
let subscriptionCallbacks
|
|
19
|
+
const createSubscription = jest.fn((_identifier, callbacks) => {
|
|
20
|
+
subscriptionCallbacks = callbacks
|
|
21
|
+
return { unsubscribe: jest.fn() }
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
jest.unstable_mockModule('../../../services/cable', () => ({
|
|
25
|
+
createSubscription,
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
const { Application } = await import('@hotwired/stimulus')
|
|
29
|
+
const TopicsController = (await import('../topics_controller')).default
|
|
30
|
+
|
|
31
|
+
const TOPICS = [{ id: 1, name: 'Main' }, { id: 2, name: 'Alpha' }, { id: 3, name: 'Beta' }]
|
|
32
|
+
|
|
33
|
+
// update_last_topic broadcasts to every session of the current user, including
|
|
34
|
+
// the one that just saved, and the echo lands after the save that produced it —
|
|
35
|
+
// exactly the window a fast second pick falls into. Telling the echo apart from
|
|
36
|
+
// a sibling session's change is what the client_id on the broadcast is for; the
|
|
37
|
+
// topic id cannot do it, two sessions can pick the same topic at once.
|
|
38
|
+
describe('TopicsController vs. the echo of its own last_topic save', () => {
|
|
39
|
+
let application
|
|
40
|
+
let controller
|
|
41
|
+
let changeEvents
|
|
42
|
+
let originalFetch
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
originalFetch = global.fetch
|
|
46
|
+
window.sessionStorage.clear()
|
|
47
|
+
window.name = ''
|
|
48
|
+
document.body.innerHTML = `
|
|
49
|
+
<div id="topics" data-controller="comments--topics" data-topic-main-text="All Messages">
|
|
50
|
+
<div data-comments--topics-target="list"></div>
|
|
51
|
+
</div>
|
|
52
|
+
`
|
|
53
|
+
Element.prototype.scrollIntoView = jest.fn()
|
|
54
|
+
|
|
55
|
+
application = Application.start()
|
|
56
|
+
application.register('comments--topics', TopicsController)
|
|
57
|
+
|
|
58
|
+
changeEvents = []
|
|
59
|
+
document.addEventListener('comments--topics:change', (e) => changeEvents.push(e.detail))
|
|
60
|
+
|
|
61
|
+
return new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
|
|
62
|
+
controller = application.getControllerForElementAndIdentifier(
|
|
63
|
+
document.getElementById('topics'), 'comments--topics'
|
|
64
|
+
)
|
|
65
|
+
controller.creativeIdValue = '42'
|
|
66
|
+
controller.topics = TOPICS
|
|
67
|
+
controller.archivedTopics = []
|
|
68
|
+
controller.mainTopicId = '1'
|
|
69
|
+
controller.canManageTopics = true
|
|
70
|
+
controller.showingArchived = false
|
|
71
|
+
controller.serverLastTopicId = ''
|
|
72
|
+
controller.renderTopics(TOPICS, true)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
controller.cancelPendingSaveLastTopic()
|
|
78
|
+
window.sessionStorage.clear()
|
|
79
|
+
window.name = ''
|
|
80
|
+
global.fetch = originalFetch
|
|
81
|
+
document.body.innerHTML = ''
|
|
82
|
+
application.stop()
|
|
83
|
+
jest.clearAllMocks()
|
|
84
|
+
window.history.replaceState({}, '', '/')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// A broadcast from some other session of this user: it names a topic and
|
|
88
|
+
// nothing else. Without a client_id it cannot have come from this client.
|
|
89
|
+
const echo = (lastTopicId, clientId, lastTopicRevision) => controller.handleTopicMessage({
|
|
90
|
+
action: 'last_topic_changed',
|
|
91
|
+
last_topic_id: lastTopicId,
|
|
92
|
+
...(lastTopicRevision === undefined ? {} : { last_topic_revision: lastTopicRevision }),
|
|
93
|
+
...(clientId === undefined ? {} : { client_id: clientId }),
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// The echo of a save this client made: the server hands back the id that
|
|
97
|
+
// save was sent with, which is what makes it identifiable as ours.
|
|
98
|
+
const clientIdFor = (lastTopicId) => {
|
|
99
|
+
const call = saveLastTopic.mock.calls.find(c => String(c[1] ?? '') === String(lastTopicId ?? ''))
|
|
100
|
+
return call && call[2]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const selfEcho = (lastTopicId, lastTopicRevision) => echo(lastTopicId, clientIdFor(lastTopicId), lastTopicRevision)
|
|
104
|
+
|
|
105
|
+
test('keeps the save fence identity and sequence across controller replacement', () => {
|
|
106
|
+
const firstClientId = controller.newLastTopicSaveClientId()
|
|
107
|
+
const replacement = Object.create(TopicsController.prototype)
|
|
108
|
+
const secondClientId = replacement.newLastTopicSaveClientId()
|
|
109
|
+
|
|
110
|
+
const [firstSessionId, firstSequence] = firstClientId.split('.')
|
|
111
|
+
const [secondSessionId, secondSequence] = secondClientId.split('.')
|
|
112
|
+
expect(secondSessionId).toBe(firstSessionId)
|
|
113
|
+
expect(Number(secondSequence)).toBe(Number(firstSequence) + 1)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('gives a duplicated tab a new save fence identity', () => {
|
|
117
|
+
const firstClientId = controller.newLastTopicSaveClientId()
|
|
118
|
+
// A duplicated tab receives copied sessionStorage but starts with its own
|
|
119
|
+
// top-level browsing context, so it has no window.name from the source tab.
|
|
120
|
+
window.name = ''
|
|
121
|
+
const duplicate = Object.create(TopicsController.prototype)
|
|
122
|
+
const duplicateClientId = duplicate.newLastTopicSaveClientId()
|
|
123
|
+
|
|
124
|
+
expect(duplicateClientId.split('.')[0]).not.toBe(firstClientId.split('.')[0])
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('a linked-shell Turbo replacement ignores an orphaned self echo after a newer pick', async () => {
|
|
128
|
+
controller.knownEffectiveCreativeIds.set('42', '7')
|
|
129
|
+
controller.element.dataset.effectiveCreativeId = '7'
|
|
130
|
+
let resolveSave
|
|
131
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
132
|
+
const save = controller.flushSaveLastTopic('2')
|
|
133
|
+
await Promise.resolve()
|
|
134
|
+
const alphaClientId = clientIdFor('2')
|
|
135
|
+
|
|
136
|
+
document.getElementById('topics').remove()
|
|
137
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
138
|
+
document.body.innerHTML = `
|
|
139
|
+
<div id="replacement" data-controller="comments--topics" data-topic-main-text="All Messages">
|
|
140
|
+
<div data-comments--topics-target="list"></div>
|
|
141
|
+
</div>
|
|
142
|
+
`
|
|
143
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
144
|
+
const replacement = application.getControllerForElementAndIdentifier(
|
|
145
|
+
document.getElementById('replacement'), 'comments--topics'
|
|
146
|
+
)
|
|
147
|
+
replacement.creativeIdValue = '42'
|
|
148
|
+
replacement.knownEffectiveCreativeIds.set('42', '7')
|
|
149
|
+
replacement.element.dataset.effectiveCreativeId = '7'
|
|
150
|
+
replacement.topics = TOPICS
|
|
151
|
+
replacement.archivedTopics = []
|
|
152
|
+
replacement.mainTopicId = '1'
|
|
153
|
+
replacement.canManageTopics = true
|
|
154
|
+
replacement.showingArchived = false
|
|
155
|
+
replacement.serverLastTopicId = ''
|
|
156
|
+
replacement.renderTopics(TOPICS, true)
|
|
157
|
+
replacement.selectTopic('3')
|
|
158
|
+
|
|
159
|
+
replacement.handleTopicMessage({
|
|
160
|
+
action: 'last_topic_changed',
|
|
161
|
+
last_topic_id: '2',
|
|
162
|
+
client_id: alphaClientId,
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
expect(replacement.currentTopicId).toBe('3')
|
|
166
|
+
expect(replacement._pendingPick.topicId).toBe('3')
|
|
167
|
+
expect(saveLastTopic).toHaveBeenCalledTimes(1)
|
|
168
|
+
resolveSave(true)
|
|
169
|
+
await save
|
|
170
|
+
controller = replacement
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('a Turbo replacement applies an orphaned self echo without a newer pick', async () => {
|
|
174
|
+
let resolveSave
|
|
175
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
176
|
+
const save = controller.flushSaveLastTopic('2')
|
|
177
|
+
await Promise.resolve()
|
|
178
|
+
const alphaClientId = clientIdFor('2')
|
|
179
|
+
|
|
180
|
+
document.getElementById('topics').remove()
|
|
181
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
182
|
+
document.body.innerHTML = `
|
|
183
|
+
<div id="replacement" data-controller="comments--topics" data-topic-main-text="All Messages">
|
|
184
|
+
<div data-comments--topics-target="list"></div>
|
|
185
|
+
</div>
|
|
186
|
+
`
|
|
187
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
188
|
+
const replacement = application.getControllerForElementAndIdentifier(
|
|
189
|
+
document.getElementById('replacement'), 'comments--topics'
|
|
190
|
+
)
|
|
191
|
+
replacement.creativeIdValue = '42'
|
|
192
|
+
replacement.topics = TOPICS
|
|
193
|
+
replacement.archivedTopics = []
|
|
194
|
+
replacement.mainTopicId = '1'
|
|
195
|
+
replacement.canManageTopics = true
|
|
196
|
+
replacement.showingArchived = false
|
|
197
|
+
replacement.serverLastTopicId = ''
|
|
198
|
+
replacement.renderTopics(TOPICS, true)
|
|
199
|
+
|
|
200
|
+
replacement.handleTopicMessage({
|
|
201
|
+
action: 'last_topic_changed',
|
|
202
|
+
last_topic_id: '2',
|
|
203
|
+
client_id: alphaClientId,
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
expect(replacement.currentTopicId).toBe('2')
|
|
207
|
+
expect(replacement.serverLastTopicId).toBe('2')
|
|
208
|
+
expect(replacement._pendingPick).toBeUndefined()
|
|
209
|
+
resolveSave(true)
|
|
210
|
+
await save
|
|
211
|
+
controller = replacement
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test('ignores a sibling broadcast older than the observed revision', () => {
|
|
215
|
+
echo('3', 'sibling-newer', [5, 2])
|
|
216
|
+
echo('2', 'sibling-older', [5, 1])
|
|
217
|
+
|
|
218
|
+
expect(controller.serverLastTopicId).toBe('3')
|
|
219
|
+
expect(controller.currentTopicId).toBe('3')
|
|
220
|
+
expect(controller.highestLastTopicRevisions.get('42')).toEqual([5, 2])
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('does not recreate acknowledgement metadata when the echo arrives before the response', async () => {
|
|
224
|
+
let resolveSave
|
|
225
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
226
|
+
|
|
227
|
+
const save = controller.flushSaveLastTopic('2')
|
|
228
|
+
await Promise.resolve()
|
|
229
|
+
const clientId = clientIdFor('2')
|
|
230
|
+
|
|
231
|
+
echo('2', clientId)
|
|
232
|
+
expect(controller.pendingSelfEchoes).not.toContain(clientId)
|
|
233
|
+
|
|
234
|
+
resolveSave(true)
|
|
235
|
+
await save
|
|
236
|
+
|
|
237
|
+
expect(controller.pendingSelfEchoAcknowledgementVersions.has(clientId)).toBe(false)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('an early echo keeps its save order until an older load is processed', async () => {
|
|
241
|
+
let resolveSave
|
|
242
|
+
let resolveSnapshot
|
|
243
|
+
controller.lastKnownRemoteTopicId = '1'
|
|
244
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
245
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveSnapshot = resolve }))
|
|
246
|
+
|
|
247
|
+
const load = controller.loadTopics()
|
|
248
|
+
controller.selectTopic('2')
|
|
249
|
+
const save = controller.flushSaveLastTopic('2')
|
|
250
|
+
await Promise.resolve()
|
|
251
|
+
|
|
252
|
+
selfEcho('2')
|
|
253
|
+
resolveSave(true)
|
|
254
|
+
await save
|
|
255
|
+
resolveSnapshot({
|
|
256
|
+
ok: true,
|
|
257
|
+
json: async () => ({
|
|
258
|
+
topics: TOPICS,
|
|
259
|
+
archived_topics: [],
|
|
260
|
+
can_manage: true,
|
|
261
|
+
last_topic_id: '1',
|
|
262
|
+
main_topic_id: '1',
|
|
263
|
+
effective_creative_id: '42',
|
|
264
|
+
}),
|
|
265
|
+
})
|
|
266
|
+
await load
|
|
267
|
+
|
|
268
|
+
expect(controller.currentTopicId).toBe('2')
|
|
269
|
+
expect(controller.serverLastTopicId).toBe('2')
|
|
270
|
+
expect(controller.pendingSelfEchoAcknowledgementVersions.has(clientIdFor('2'))).toBe(false)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test('an early echo keeps its save order for a reopened load with a later request number', async () => {
|
|
274
|
+
let resolveSave
|
|
275
|
+
let resolveSnapshot
|
|
276
|
+
controller.lastKnownRemoteTopicId = '1'
|
|
277
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
278
|
+
|
|
279
|
+
controller.selectTopic('2')
|
|
280
|
+
const save = controller.flushSaveLastTopic('2')
|
|
281
|
+
await Promise.resolve()
|
|
282
|
+
|
|
283
|
+
controller.onPopupClosed()
|
|
284
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveSnapshot = resolve }))
|
|
285
|
+
const reopen = controller.onPopupOpened({ creativeId: '42' })
|
|
286
|
+
await Promise.resolve()
|
|
287
|
+
|
|
288
|
+
selfEcho('2')
|
|
289
|
+
resolveSave(true)
|
|
290
|
+
await save
|
|
291
|
+
resolveSnapshot({
|
|
292
|
+
ok: true,
|
|
293
|
+
json: async () => ({
|
|
294
|
+
topics: TOPICS,
|
|
295
|
+
archived_topics: [],
|
|
296
|
+
can_manage: true,
|
|
297
|
+
last_topic_id: '1',
|
|
298
|
+
main_topic_id: '1',
|
|
299
|
+
effective_creative_id: '42',
|
|
300
|
+
}),
|
|
301
|
+
})
|
|
302
|
+
await reopen
|
|
303
|
+
|
|
304
|
+
expect(controller.currentTopicId).toBe('2')
|
|
305
|
+
expect(controller.serverLastTopicId).toBe('2')
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
test('a closed save yields when its early echo precedes a newer reopen snapshot', async () => {
|
|
309
|
+
let resolveSave
|
|
310
|
+
let resolveSnapshot
|
|
311
|
+
controller.lastKnownRemoteTopicId = '1'
|
|
312
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
313
|
+
|
|
314
|
+
controller.selectTopic('2')
|
|
315
|
+
const save = controller.flushSaveLastTopic('2')
|
|
316
|
+
await Promise.resolve()
|
|
317
|
+
controller.onPopupClosed()
|
|
318
|
+
|
|
319
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveSnapshot = resolve }))
|
|
320
|
+
const reopen = controller.onPopupOpened({ creativeId: '42' })
|
|
321
|
+
await Promise.resolve()
|
|
322
|
+
|
|
323
|
+
selfEcho('2')
|
|
324
|
+
resolveSnapshot({
|
|
325
|
+
ok: true,
|
|
326
|
+
json: async () => ({
|
|
327
|
+
topics: TOPICS,
|
|
328
|
+
archived_topics: [],
|
|
329
|
+
can_manage: true,
|
|
330
|
+
last_topic_id: '3',
|
|
331
|
+
main_topic_id: '1',
|
|
332
|
+
effective_creative_id: '42',
|
|
333
|
+
}),
|
|
334
|
+
})
|
|
335
|
+
await reopen
|
|
336
|
+
|
|
337
|
+
expect(controller.currentTopicId).toBe('3')
|
|
338
|
+
resolveSave(true)
|
|
339
|
+
await save
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
test('a newer ABA reopen snapshot yields to the server revision, not its repeated topic id', async () => {
|
|
343
|
+
let resolveSave
|
|
344
|
+
let resolveSnapshot
|
|
345
|
+
controller.lastKnownRemoteTopicId = '1'
|
|
346
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
347
|
+
|
|
348
|
+
controller.selectTopic('2')
|
|
349
|
+
const save = controller.flushSaveLastTopic('2')
|
|
350
|
+
await Promise.resolve()
|
|
351
|
+
controller.onPopupClosed()
|
|
352
|
+
|
|
353
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveSnapshot = resolve }))
|
|
354
|
+
const reopen = controller.onPopupOpened({ creativeId: '42' })
|
|
355
|
+
await Promise.resolve()
|
|
356
|
+
|
|
357
|
+
// The server accepted Alpha, then a sibling session deliberately chose
|
|
358
|
+
// Main again. The values form an ABA sequence, but revision [5, 2] is
|
|
359
|
+
// newer than the self-echo's [5, 1].
|
|
360
|
+
selfEcho('2', [5, 1])
|
|
361
|
+
resolveSnapshot({
|
|
362
|
+
ok: true,
|
|
363
|
+
json: async () => ({
|
|
364
|
+
topics: TOPICS,
|
|
365
|
+
archived_topics: [],
|
|
366
|
+
can_manage: true,
|
|
367
|
+
last_topic_id: '1',
|
|
368
|
+
last_topic_revision: [5, 2],
|
|
369
|
+
main_topic_id: '1',
|
|
370
|
+
effective_creative_id: '42',
|
|
371
|
+
}),
|
|
372
|
+
})
|
|
373
|
+
await reopen
|
|
374
|
+
|
|
375
|
+
expect(controller.currentTopicId).toBe('1')
|
|
376
|
+
resolveSave(true)
|
|
377
|
+
await save
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
test('a PATCH acknowledgement orders a delayed ABA snapshot before its self-echo', async () => {
|
|
381
|
+
let resolveSave
|
|
382
|
+
let resolveSnapshot
|
|
383
|
+
controller.lastKnownRemoteTopicId = '1'
|
|
384
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
385
|
+
|
|
386
|
+
controller.selectTopic('2')
|
|
387
|
+
const save = controller.flushSaveLastTopic('2')
|
|
388
|
+
await Promise.resolve()
|
|
389
|
+
|
|
390
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveSnapshot = resolve }))
|
|
391
|
+
const load = controller.loadTopics()
|
|
392
|
+
await Promise.resolve()
|
|
393
|
+
|
|
394
|
+
// This client's Alpha commit is revision [5, 1]. Before its self-echo
|
|
395
|
+
// arrives, another session selects Main again at [5, 2]. The request
|
|
396
|
+
// response must preserve [5, 1] so the GET is not mistaken for the
|
|
397
|
+
// stale Main snapshot from before the local save.
|
|
398
|
+
resolveSave({ success: true, lastTopicRevision: [5, 1] })
|
|
399
|
+
await save
|
|
400
|
+
resolveSnapshot({
|
|
401
|
+
ok: true,
|
|
402
|
+
json: async () => ({
|
|
403
|
+
topics: TOPICS,
|
|
404
|
+
archived_topics: [],
|
|
405
|
+
can_manage: true,
|
|
406
|
+
last_topic_id: '1',
|
|
407
|
+
last_topic_revision: [5, 2],
|
|
408
|
+
main_topic_id: '1',
|
|
409
|
+
effective_creative_id: '42',
|
|
410
|
+
}),
|
|
411
|
+
})
|
|
412
|
+
await load
|
|
413
|
+
|
|
414
|
+
expect(controller.currentTopicId).toBe('1')
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
test('waits for a closed pending save before reconciling a revisioned ABA snapshot', async () => {
|
|
418
|
+
let resolveSave
|
|
419
|
+
controller.lastKnownRemoteTopicId = '1'
|
|
420
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
421
|
+
global.fetch = jest.fn()
|
|
422
|
+
.mockResolvedValueOnce({
|
|
423
|
+
ok: true,
|
|
424
|
+
json: async () => ({
|
|
425
|
+
topics: TOPICS,
|
|
426
|
+
archived_topics: [],
|
|
427
|
+
can_manage: true,
|
|
428
|
+
last_topic_id: '1',
|
|
429
|
+
last_topic_revision: [5, 2],
|
|
430
|
+
main_topic_id: '1',
|
|
431
|
+
effective_creative_id: '42',
|
|
432
|
+
}),
|
|
433
|
+
})
|
|
434
|
+
.mockResolvedValueOnce({
|
|
435
|
+
ok: true,
|
|
436
|
+
json: async () => ({
|
|
437
|
+
topics: TOPICS,
|
|
438
|
+
archived_topics: [],
|
|
439
|
+
can_manage: true,
|
|
440
|
+
last_topic_id: '1',
|
|
441
|
+
last_topic_revision: [5, 2],
|
|
442
|
+
main_topic_id: '1',
|
|
443
|
+
effective_creative_id: '42',
|
|
444
|
+
}),
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
controller.selectTopic('2')
|
|
448
|
+
const save = controller.flushSaveLastTopic('2')
|
|
449
|
+
await Promise.resolve()
|
|
450
|
+
controller.onPopupClosed()
|
|
451
|
+
await controller.onPopupOpened({ creativeId: '42' })
|
|
452
|
+
|
|
453
|
+
// Alpha's response and echo are both late, so the Main snapshot could
|
|
454
|
+
// either predate Alpha or be a sibling's later ABA write. Do not replay
|
|
455
|
+
// Alpha through that ambiguity.
|
|
456
|
+
expect(controller.currentTopicId).toBe('2')
|
|
457
|
+
expect(saveLastTopic).toHaveBeenCalledTimes(1)
|
|
458
|
+
|
|
459
|
+
resolveSave({ success: true, lastTopicRevision: [5, 1] })
|
|
460
|
+
await save
|
|
461
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
462
|
+
|
|
463
|
+
expect(global.fetch).toHaveBeenCalledTimes(2)
|
|
464
|
+
expect(controller.currentTopicId).toBe('1')
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
test('retries deferred reconciliation when a self-echo confirms an ambiguous save', async () => {
|
|
468
|
+
let rejectSave
|
|
469
|
+
controller.lastKnownRemoteTopicId = '1'
|
|
470
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectSave = reject }))
|
|
471
|
+
global.fetch = jest.fn()
|
|
472
|
+
.mockResolvedValueOnce({
|
|
473
|
+
ok: true,
|
|
474
|
+
json: async () => ({
|
|
475
|
+
topics: TOPICS,
|
|
476
|
+
archived_topics: [],
|
|
477
|
+
can_manage: true,
|
|
478
|
+
last_topic_id: '1',
|
|
479
|
+
last_topic_revision: [5, 2],
|
|
480
|
+
main_topic_id: '1',
|
|
481
|
+
effective_creative_id: '42',
|
|
482
|
+
}),
|
|
483
|
+
})
|
|
484
|
+
.mockResolvedValueOnce({
|
|
485
|
+
ok: true,
|
|
486
|
+
json: async () => ({
|
|
487
|
+
topics: TOPICS,
|
|
488
|
+
archived_topics: [],
|
|
489
|
+
can_manage: true,
|
|
490
|
+
last_topic_id: '1',
|
|
491
|
+
last_topic_revision: [5, 2],
|
|
492
|
+
main_topic_id: '1',
|
|
493
|
+
effective_creative_id: '42',
|
|
494
|
+
}),
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
controller.selectTopic('2')
|
|
498
|
+
const save = controller.flushSaveLastTopic('2')
|
|
499
|
+
await Promise.resolve()
|
|
500
|
+
controller.onPopupClosed()
|
|
501
|
+
await controller.onPopupOpened({ creativeId: '42' })
|
|
502
|
+
|
|
503
|
+
// A network failure does not prove the PATCH rolled back. Its identified
|
|
504
|
+
// echo confirms Alpha at [5, 1], so the deferred Main [5, 2] snapshot
|
|
505
|
+
// must be retried and win by revision.
|
|
506
|
+
rejectSave(new Error('network'))
|
|
507
|
+
await save
|
|
508
|
+
selfEcho('2', [5, 1])
|
|
509
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
510
|
+
|
|
511
|
+
expect(global.fetch).toHaveBeenCalledTimes(2)
|
|
512
|
+
expect(controller.currentTopicId).toBe('1')
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
test('retires an ambiguous save with no echo and retries deferred reconciliation', async () => {
|
|
516
|
+
let rejectSave
|
|
517
|
+
jest.useFakeTimers()
|
|
518
|
+
try {
|
|
519
|
+
controller.lastKnownRemoteTopicId = '1'
|
|
520
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectSave = reject }))
|
|
521
|
+
global.fetch = jest.fn()
|
|
522
|
+
.mockResolvedValueOnce({
|
|
523
|
+
ok: true,
|
|
524
|
+
json: async () => ({
|
|
525
|
+
topics: TOPICS,
|
|
526
|
+
archived_topics: [],
|
|
527
|
+
can_manage: true,
|
|
528
|
+
last_topic_id: '1',
|
|
529
|
+
last_topic_revision: [5, 2],
|
|
530
|
+
main_topic_id: '1',
|
|
531
|
+
effective_creative_id: '42',
|
|
532
|
+
}),
|
|
533
|
+
})
|
|
534
|
+
.mockResolvedValueOnce({
|
|
535
|
+
ok: true,
|
|
536
|
+
json: async () => ({
|
|
537
|
+
topics: TOPICS,
|
|
538
|
+
archived_topics: [],
|
|
539
|
+
can_manage: true,
|
|
540
|
+
last_topic_id: '1',
|
|
541
|
+
last_topic_revision: [5, 2],
|
|
542
|
+
main_topic_id: '1',
|
|
543
|
+
effective_creative_id: '42',
|
|
544
|
+
}),
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
controller.selectTopic('2')
|
|
548
|
+
const save = controller.flushSaveLastTopic('2')
|
|
549
|
+
await Promise.resolve()
|
|
550
|
+
controller.onPopupClosed()
|
|
551
|
+
await controller.onPopupOpened({ creativeId: '42' })
|
|
552
|
+
|
|
553
|
+
rejectSave(new Error('network'))
|
|
554
|
+
await save
|
|
555
|
+
expect(controller.pendingSelfEchoes).toContain(clientIdFor('2'))
|
|
556
|
+
|
|
557
|
+
await jest.advanceTimersByTimeAsync(5_000)
|
|
558
|
+
|
|
559
|
+
expect(controller.pendingSelfEchoes).toEqual([])
|
|
560
|
+
expect(global.fetch).toHaveBeenCalledTimes(2)
|
|
561
|
+
expect(controller.currentTopicId).toBe('1')
|
|
562
|
+
} finally {
|
|
563
|
+
jest.useRealTimers()
|
|
564
|
+
}
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
test('advances the serialized save queue when a request never settles', async () => {
|
|
568
|
+
jest.useFakeTimers()
|
|
569
|
+
try {
|
|
570
|
+
let firstSignal
|
|
571
|
+
saveLastTopic.mockImplementationOnce((_creativeId, _topicId, _clientId, signal) => {
|
|
572
|
+
firstSignal = signal
|
|
573
|
+
return new Promise((_resolve, reject) => {
|
|
574
|
+
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true })
|
|
575
|
+
})
|
|
576
|
+
})
|
|
577
|
+
const first = controller.flushSaveLastTopic('2')
|
|
578
|
+
const second = controller.flushSaveLastTopic('3')
|
|
579
|
+
await Promise.resolve()
|
|
580
|
+
|
|
581
|
+
expect(saveLastTopic).toHaveBeenCalledTimes(1)
|
|
582
|
+
await jest.advanceTimersByTimeAsync(5_000)
|
|
583
|
+
await Promise.all([first, second])
|
|
584
|
+
|
|
585
|
+
expect(firstSignal.aborted).toBe(true)
|
|
586
|
+
expect(saveLastTopic).toHaveBeenLastCalledWith('42', '3', expect.any(String), expect.any(AbortSignal))
|
|
587
|
+
} finally {
|
|
588
|
+
jest.useRealTimers()
|
|
589
|
+
}
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
test('ignores a late echo from a retired ambiguous save', async () => {
|
|
593
|
+
let rejectSave
|
|
594
|
+
jest.useFakeTimers()
|
|
595
|
+
try {
|
|
596
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectSave = reject }))
|
|
597
|
+
const save = controller.flushSaveLastTopic('2')
|
|
598
|
+
await Promise.resolve()
|
|
599
|
+
const clientId = clientIdFor('2')
|
|
600
|
+
rejectSave(new Error('network'))
|
|
601
|
+
await save
|
|
602
|
+
|
|
603
|
+
await jest.advanceTimersByTimeAsync(5_000)
|
|
604
|
+
controller.selectTopic('3')
|
|
605
|
+
echo('2', clientId)
|
|
606
|
+
|
|
607
|
+
expect(controller.currentTopicId).toBe('3')
|
|
608
|
+
expect(controller.serverLastTopicId).not.toBe('2')
|
|
609
|
+
} finally {
|
|
610
|
+
jest.useRealTimers()
|
|
611
|
+
}
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
test('compacts retired ambiguous save tombstones by tab-session sequence', () => {
|
|
615
|
+
const clientIds = Array.from({ length: 100 }, () => controller.newLastTopicSaveClientId())
|
|
616
|
+
for (const clientId of clientIds) {
|
|
617
|
+
controller.pendingSelfEchoes.push(clientId)
|
|
618
|
+
controller.pendingSelfEchoCreativeIds.set(clientId, '42')
|
|
619
|
+
controller.retirePendingSelfEcho(clientId)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const [sessionId, highestSequence] = clientIds.at(-1).split('.')
|
|
623
|
+
expect(controller.retiredSelfEchoSequenceHighWaters).toEqual(
|
|
624
|
+
new Map([[sessionId, Number(highestSequence)]])
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
controller.selectTopic('3')
|
|
628
|
+
echo('2', clientIds[0])
|
|
629
|
+
expect(controller.currentTopicId).toBe('3')
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
test('records the revision of a late echo from a retired ambiguous save', async () => {
|
|
633
|
+
let rejectSave
|
|
634
|
+
let resolveSnapshot
|
|
635
|
+
jest.useFakeTimers()
|
|
636
|
+
try {
|
|
637
|
+
controller.serverLastTopicId = '2'
|
|
638
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectSave = reject }))
|
|
639
|
+
const save = controller.flushSaveLastTopic('2')
|
|
640
|
+
await Promise.resolve()
|
|
641
|
+
const clientId = clientIdFor('2')
|
|
642
|
+
rejectSave(new Error('network'))
|
|
643
|
+
await save
|
|
644
|
+
await jest.advanceTimersByTimeAsync(5_000)
|
|
645
|
+
|
|
646
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveSnapshot = resolve }))
|
|
647
|
+
const load = controller.loadTopics()
|
|
648
|
+
await Promise.resolve()
|
|
649
|
+
echo('2', clientId, [5, 2])
|
|
650
|
+
resolveSnapshot({
|
|
651
|
+
ok: true,
|
|
652
|
+
json: async () => ({
|
|
653
|
+
topics: TOPICS,
|
|
654
|
+
archived_topics: [],
|
|
655
|
+
can_manage: true,
|
|
656
|
+
last_topic_id: '1',
|
|
657
|
+
last_topic_revision: [5, 1],
|
|
658
|
+
main_topic_id: '1',
|
|
659
|
+
effective_creative_id: '42',
|
|
660
|
+
}),
|
|
661
|
+
})
|
|
662
|
+
await load
|
|
663
|
+
|
|
664
|
+
expect(controller.highestLastTopicRevisions.get('42')).toEqual([5, 2])
|
|
665
|
+
expect(controller.serverLastTopicId).toBe('2')
|
|
666
|
+
} finally {
|
|
667
|
+
jest.useRealTimers()
|
|
668
|
+
}
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
test('a committed revision outranks a snapshot with a different predecessor topic', async () => {
|
|
672
|
+
let resolveSave
|
|
673
|
+
let resolveSnapshot
|
|
674
|
+
controller.lastKnownRemoteTopicId = '1'
|
|
675
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
676
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveSnapshot = resolve }))
|
|
677
|
+
|
|
678
|
+
controller.selectTopic('3')
|
|
679
|
+
const save = controller.flushSaveLastTopic('3')
|
|
680
|
+
await Promise.resolve()
|
|
681
|
+
const load = controller.loadTopics()
|
|
682
|
+
await Promise.resolve()
|
|
683
|
+
|
|
684
|
+
// Another session chose Alpha after this client observed Main, but before
|
|
685
|
+
// the local Beta save committed. The acknowledgement proves Beta's [5, 2]
|
|
686
|
+
// revision is newer than the GET's Alpha [5, 1] snapshot, even though
|
|
687
|
+
// Alpha is not Beta's predecessor topic.
|
|
688
|
+
resolveSave({ success: true, lastTopicRevision: [5, 2] })
|
|
689
|
+
await save
|
|
690
|
+
resolveSnapshot({
|
|
691
|
+
ok: true,
|
|
692
|
+
json: async () => ({
|
|
693
|
+
topics: TOPICS,
|
|
694
|
+
archived_topics: [],
|
|
695
|
+
can_manage: true,
|
|
696
|
+
last_topic_id: '2',
|
|
697
|
+
last_topic_revision: [5, 1],
|
|
698
|
+
main_topic_id: '1',
|
|
699
|
+
effective_creative_id: '42',
|
|
700
|
+
}),
|
|
701
|
+
})
|
|
702
|
+
await load
|
|
703
|
+
|
|
704
|
+
expect(controller.currentTopicId).toBe('3')
|
|
705
|
+
expect(controller.serverLastTopicId).toBe('3')
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
test('an early linked-shell echo advances the resolved stream baseline', async () => {
|
|
709
|
+
let resolveSave
|
|
710
|
+
let resolveSnapshot
|
|
711
|
+
controller.creativeIdValue = '77'
|
|
712
|
+
controller.element.dataset.effectiveCreativeId = '42'
|
|
713
|
+
controller.setLastKnownRemoteTopicId('42', '1')
|
|
714
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
715
|
+
|
|
716
|
+
controller.selectTopic('2')
|
|
717
|
+
const save = controller.flushSaveLastTopic('2')
|
|
718
|
+
await Promise.resolve()
|
|
719
|
+
controller.onPopupClosed()
|
|
720
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveSnapshot = resolve }))
|
|
721
|
+
const reopen = controller.onPopupOpened({ creativeId: '77' })
|
|
722
|
+
await Promise.resolve()
|
|
723
|
+
|
|
724
|
+
selfEcho('2', [5, 1])
|
|
725
|
+
expect(controller.lastKnownRemoteTopicIdFor('42')).toBe('2')
|
|
726
|
+
expect(controller.lastKnownRemoteTopicIdFor('77')).toBeUndefined()
|
|
727
|
+
|
|
728
|
+
resolveSnapshot({
|
|
729
|
+
ok: true,
|
|
730
|
+
json: async () => ({
|
|
731
|
+
topics: TOPICS,
|
|
732
|
+
archived_topics: [],
|
|
733
|
+
can_manage: true,
|
|
734
|
+
last_topic_id: '2',
|
|
735
|
+
last_topic_revision: [5, 1],
|
|
736
|
+
main_topic_id: '1',
|
|
737
|
+
effective_creative_id: '42',
|
|
738
|
+
}),
|
|
739
|
+
})
|
|
740
|
+
await reopen
|
|
741
|
+
resolveSave(true)
|
|
742
|
+
await save
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
// Pick Alpha, let its debounced save go out, then pick Beta before the echo
|
|
746
|
+
// for Alpha gets back. The echo names the topic the user has just left.
|
|
747
|
+
test('a pick made before the echo lands is not reverted by it', async () => {
|
|
748
|
+
controller.selectTopic('2')
|
|
749
|
+
await controller.flushSaveLastTopic('2')
|
|
750
|
+
|
|
751
|
+
controller.selectTopic('3')
|
|
752
|
+
selfEcho('2')
|
|
753
|
+
|
|
754
|
+
expect(controller.currentTopicId).toBe('3')
|
|
755
|
+
expect(changeEvents.at(-1).topicId).toBe('3')
|
|
756
|
+
expect(controller.listTarget.querySelector('.topic-tag[data-id="3"]').classList)
|
|
757
|
+
.toContain('active')
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
// ...and the revert must not be written back as the preference either.
|
|
761
|
+
test('the stale echo is not persisted over the pick', async () => {
|
|
762
|
+
controller.selectTopic('2')
|
|
763
|
+
await controller.flushSaveLastTopic('2')
|
|
764
|
+
|
|
765
|
+
controller.selectTopic('3')
|
|
766
|
+
selfEcho('2')
|
|
767
|
+
await controller.flushSaveLastTopic(controller.currentTopicId)
|
|
768
|
+
|
|
769
|
+
expect(saveLastTopic).toHaveBeenLastCalledWith('42', '3', expect.any(String), expect.anything())
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
// Same race for All Messages, which the strip renders as its own chip.
|
|
773
|
+
test('an All Messages pick is not reverted by the echo of the topic it left', async () => {
|
|
774
|
+
controller.selectTopic('2')
|
|
775
|
+
await controller.flushSaveLastTopic('2')
|
|
776
|
+
|
|
777
|
+
controller.selectTopic('')
|
|
778
|
+
selfEcho('2')
|
|
779
|
+
|
|
780
|
+
expect(controller.currentTopicId).toBe('')
|
|
781
|
+
})
|
|
782
|
+
|
|
783
|
+
// The point of the broadcast is cross-tab sync, and that has to keep working:
|
|
784
|
+
// a change this client did not make is real news, not an echo.
|
|
785
|
+
test('a change from another session is still applied', () => {
|
|
786
|
+
echo('3')
|
|
787
|
+
|
|
788
|
+
expect(controller.currentTopicId).toBe('3')
|
|
789
|
+
expect(changeEvents.at(-1).topicId).toBe('3')
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
test('another session is still applied after this one has saved something else', async () => {
|
|
793
|
+
controller.selectTopic('2')
|
|
794
|
+
await controller.flushSaveLastTopic('2')
|
|
795
|
+
|
|
796
|
+
echo('3')
|
|
797
|
+
|
|
798
|
+
expect(controller.currentTopicId).toBe('3')
|
|
799
|
+
})
|
|
800
|
+
|
|
801
|
+
// A rejected save broadcasts nothing — update_last_topic returns before the
|
|
802
|
+
// broadcast on a denied read or a topic outside the creative — so the client
|
|
803
|
+
// must not go on waiting for an echo that is never coming.
|
|
804
|
+
test('a save that did not land leaves no claim on a later echo', async () => {
|
|
805
|
+
saveLastTopic.mockResolvedValueOnce(false)
|
|
806
|
+
controller.selectTopic('2')
|
|
807
|
+
await controller.flushSaveLastTopic('2')
|
|
808
|
+
|
|
809
|
+
controller.selectTopic('3')
|
|
810
|
+
// Another session moves the preference to Alpha for real.
|
|
811
|
+
echo('2')
|
|
812
|
+
|
|
813
|
+
expect(controller.currentTopicId).toBe('2')
|
|
814
|
+
})
|
|
815
|
+
|
|
816
|
+
test('a stale rejection clears its pending pick before reconciliation', async () => {
|
|
817
|
+
saveLastTopic.mockResolvedValueOnce({
|
|
818
|
+
success: false,
|
|
819
|
+
staleLastTopicSave: true,
|
|
820
|
+
lastTopicRevision: [5, 2],
|
|
821
|
+
})
|
|
822
|
+
controller.deferredLastTopicReconciliations.add('42')
|
|
823
|
+
controller.loadTopics = jest.fn(() => {
|
|
824
|
+
expect(controller._pendingPick).toBeNull()
|
|
825
|
+
})
|
|
826
|
+
|
|
827
|
+
controller.selectTopic('2')
|
|
828
|
+
await controller.flushSaveLastTopic('2')
|
|
829
|
+
|
|
830
|
+
expect(controller._pendingPick).toBeNull()
|
|
831
|
+
expect(controller.highestLastTopicRevisions.get('42')).toEqual([5, 2])
|
|
832
|
+
expect(controller.loadTopics).toHaveBeenCalledTimes(1)
|
|
833
|
+
})
|
|
834
|
+
|
|
835
|
+
test('a stale rejection does not clear a later pick of the same topic', async () => {
|
|
836
|
+
let resolveSave
|
|
837
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
838
|
+
|
|
839
|
+
controller.selectTopic('2')
|
|
840
|
+
const save = controller.flushSaveLastTopic('2')
|
|
841
|
+
await Promise.resolve()
|
|
842
|
+
controller.selectTopic('2')
|
|
843
|
+
const laterPick = controller._pendingPick
|
|
844
|
+
|
|
845
|
+
resolveSave({ success: false, staleLastTopicSave: true })
|
|
846
|
+
await save
|
|
847
|
+
|
|
848
|
+
expect(controller._pendingPick).toBe(laterPick)
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
// The stream is per creative, so leaving it for another means the echoes it
|
|
852
|
+
// owed us are not coming. Nothing can settle those claims; they go with it.
|
|
853
|
+
test('leaving the stream drops outstanding claims', async () => {
|
|
854
|
+
controller.selectTopic('2')
|
|
855
|
+
await controller.flushSaveLastTopic('2')
|
|
856
|
+
|
|
857
|
+
controller.unsubscribe()
|
|
858
|
+
|
|
859
|
+
expect(controller.pendingSelfEchoes).toHaveLength(0)
|
|
860
|
+
controller.selectTopic('3')
|
|
861
|
+
echo('2')
|
|
862
|
+
expect(controller.currentTopicId).toBe('2')
|
|
863
|
+
})
|
|
864
|
+
|
|
865
|
+
test('closing and reopening the same creative keeps an in-flight claim', async () => {
|
|
866
|
+
let resolveSave
|
|
867
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
868
|
+
controller.selectTopic('2')
|
|
869
|
+
const save = controller.flushSaveLastTopic('2')
|
|
870
|
+
await Promise.resolve()
|
|
871
|
+
|
|
872
|
+
controller.onPopupClosed()
|
|
873
|
+
controller.loadTopics = jest.fn()
|
|
874
|
+
controller.onPopupOpened({ creativeId: '42' })
|
|
875
|
+
controller.selectTopic('3')
|
|
876
|
+
selfEcho('2')
|
|
877
|
+
|
|
878
|
+
expect(controller.currentTopicId).toBe('3')
|
|
879
|
+
resolveSave(true)
|
|
880
|
+
await save
|
|
881
|
+
})
|
|
882
|
+
|
|
883
|
+
test('a reopen does not restore a stale preference over its pending save', async () => {
|
|
884
|
+
let resolveSave
|
|
885
|
+
controller.lastKnownRemoteTopicId = '1'
|
|
886
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
887
|
+
controller.selectTopic('2')
|
|
888
|
+
const save = controller.flushSaveLastTopic('2')
|
|
889
|
+
await Promise.resolve()
|
|
890
|
+
|
|
891
|
+
controller.onPopupClosed()
|
|
892
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
893
|
+
ok: true,
|
|
894
|
+
json: async () => ({
|
|
895
|
+
topics: TOPICS,
|
|
896
|
+
archived_topics: [],
|
|
897
|
+
can_manage: true,
|
|
898
|
+
last_topic_id: '1',
|
|
899
|
+
main_topic_id: '1',
|
|
900
|
+
effective_creative_id: '42',
|
|
901
|
+
}),
|
|
902
|
+
})
|
|
903
|
+
await controller.onPopupOpened({ creativeId: '42' })
|
|
904
|
+
|
|
905
|
+
expect(controller.currentTopicId).toBe('2')
|
|
906
|
+
resolveSave(true)
|
|
907
|
+
await save
|
|
908
|
+
await controller.flushSaveLastTopic(controller.currentTopicId)
|
|
909
|
+
expect(saveLastTopic).toHaveBeenLastCalledWith('42', '2', expect.any(String), expect.anything())
|
|
910
|
+
|
|
911
|
+
selfEcho('2')
|
|
912
|
+
expect(controller.currentTopicId).toBe('2')
|
|
913
|
+
})
|
|
914
|
+
|
|
915
|
+
test('a stale reopen snapshot still yields to a save acknowledged after its load began', async () => {
|
|
916
|
+
let resolveSave
|
|
917
|
+
let resolveSnapshot
|
|
918
|
+
controller.lastKnownRemoteTopicId = '1'
|
|
919
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
920
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveSnapshot = resolve }))
|
|
921
|
+
|
|
922
|
+
controller.selectTopic('2')
|
|
923
|
+
const save = controller.flushSaveLastTopic('2')
|
|
924
|
+
await Promise.resolve()
|
|
925
|
+
controller.onPopupClosed()
|
|
926
|
+
const reopen = controller.onPopupOpened({ creativeId: '42' })
|
|
927
|
+
await Promise.resolve()
|
|
928
|
+
|
|
929
|
+
resolveSave(true)
|
|
930
|
+
await save
|
|
931
|
+
resolveSnapshot({
|
|
932
|
+
ok: true,
|
|
933
|
+
json: async () => ({
|
|
934
|
+
topics: TOPICS,
|
|
935
|
+
archived_topics: [],
|
|
936
|
+
can_manage: true,
|
|
937
|
+
last_topic_id: '1',
|
|
938
|
+
main_topic_id: '1',
|
|
939
|
+
effective_creative_id: '42',
|
|
940
|
+
}),
|
|
941
|
+
})
|
|
942
|
+
await reopen
|
|
943
|
+
|
|
944
|
+
expect(controller.currentTopicId).toBe('2')
|
|
945
|
+
expect(controller.serverLastTopicId).toBe('2')
|
|
946
|
+
})
|
|
947
|
+
|
|
948
|
+
test('a stale reopen snapshot yields to every save acknowledged after its load began', async () => {
|
|
949
|
+
let resolveAlphaSave
|
|
950
|
+
let resolveBetaSave
|
|
951
|
+
let resolveSnapshot
|
|
952
|
+
controller.lastKnownRemoteTopicId = '1'
|
|
953
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveAlphaSave = resolve }))
|
|
954
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveBetaSave = resolve }))
|
|
955
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveSnapshot = resolve }))
|
|
956
|
+
|
|
957
|
+
controller.selectTopic('2')
|
|
958
|
+
const alphaSave = controller.flushSaveLastTopic('2')
|
|
959
|
+
await Promise.resolve()
|
|
960
|
+
controller.selectTopic('3')
|
|
961
|
+
const betaSave = controller.flushSaveLastTopic('3')
|
|
962
|
+
controller.onPopupClosed()
|
|
963
|
+
const reopen = controller.onPopupOpened({ creativeId: '42' })
|
|
964
|
+
await Promise.resolve()
|
|
965
|
+
|
|
966
|
+
resolveAlphaSave(true)
|
|
967
|
+
await alphaSave
|
|
968
|
+
await Promise.resolve()
|
|
969
|
+
resolveBetaSave(true)
|
|
970
|
+
await betaSave
|
|
971
|
+
resolveSnapshot({
|
|
972
|
+
ok: true,
|
|
973
|
+
json: async () => ({
|
|
974
|
+
topics: TOPICS,
|
|
975
|
+
archived_topics: [],
|
|
976
|
+
can_manage: true,
|
|
977
|
+
last_topic_id: '1',
|
|
978
|
+
main_topic_id: '1',
|
|
979
|
+
effective_creative_id: '42',
|
|
980
|
+
}),
|
|
981
|
+
})
|
|
982
|
+
await reopen
|
|
983
|
+
|
|
984
|
+
expect(controller.currentTopicId).toBe('3')
|
|
985
|
+
expect(controller.serverLastTopicId).toBe('3')
|
|
986
|
+
})
|
|
987
|
+
|
|
988
|
+
test('a stale load does not roll back the baseline for a later closed save', async () => {
|
|
989
|
+
let resolveSnapshot
|
|
990
|
+
let resolveBetaSave
|
|
991
|
+
controller.lastKnownRemoteTopicId = '1'
|
|
992
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveSnapshot = resolve }))
|
|
993
|
+
|
|
994
|
+
const staleLoad = controller.loadTopics()
|
|
995
|
+
controller.selectTopic('2')
|
|
996
|
+
await controller.flushSaveLastTopic('2')
|
|
997
|
+
|
|
998
|
+
resolveSnapshot({
|
|
999
|
+
ok: true,
|
|
1000
|
+
json: async () => ({
|
|
1001
|
+
topics: TOPICS,
|
|
1002
|
+
archived_topics: [],
|
|
1003
|
+
can_manage: true,
|
|
1004
|
+
last_topic_id: '1',
|
|
1005
|
+
main_topic_id: '1',
|
|
1006
|
+
effective_creative_id: '42',
|
|
1007
|
+
}),
|
|
1008
|
+
})
|
|
1009
|
+
await staleLoad
|
|
1010
|
+
expect(controller.currentTopicId).toBe('2')
|
|
1011
|
+
expect(controller.lastKnownRemoteTopicId).toBe('2')
|
|
1012
|
+
|
|
1013
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveBetaSave = resolve }))
|
|
1014
|
+
controller.selectTopic('3')
|
|
1015
|
+
const betaSave = controller.flushSaveLastTopic('3')
|
|
1016
|
+
await Promise.resolve()
|
|
1017
|
+
controller.onPopupClosed()
|
|
1018
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
1019
|
+
ok: true,
|
|
1020
|
+
json: async () => ({
|
|
1021
|
+
topics: TOPICS,
|
|
1022
|
+
archived_topics: [],
|
|
1023
|
+
can_manage: true,
|
|
1024
|
+
last_topic_id: '2',
|
|
1025
|
+
main_topic_id: '1',
|
|
1026
|
+
effective_creative_id: '42',
|
|
1027
|
+
}),
|
|
1028
|
+
})
|
|
1029
|
+
await controller.onPopupOpened({ creativeId: '42' })
|
|
1030
|
+
|
|
1031
|
+
expect(controller.currentTopicId).toBe('3')
|
|
1032
|
+
resolveBetaSave(true)
|
|
1033
|
+
await betaSave
|
|
1034
|
+
})
|
|
1035
|
+
|
|
1036
|
+
test('a picked value keeps its in-flight snapshot as the baseline for a later closed save', async () => {
|
|
1037
|
+
let resolveInitialSnapshot
|
|
1038
|
+
let resolveSave
|
|
1039
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveInitialSnapshot = resolve }))
|
|
1040
|
+
|
|
1041
|
+
const initialLoad = controller.loadTopics()
|
|
1042
|
+
controller.selectTopic('2')
|
|
1043
|
+
resolveInitialSnapshot({
|
|
1044
|
+
ok: true,
|
|
1045
|
+
json: async () => ({
|
|
1046
|
+
topics: TOPICS,
|
|
1047
|
+
archived_topics: [],
|
|
1048
|
+
can_manage: true,
|
|
1049
|
+
last_topic_id: '1',
|
|
1050
|
+
main_topic_id: '1',
|
|
1051
|
+
effective_creative_id: '42',
|
|
1052
|
+
}),
|
|
1053
|
+
})
|
|
1054
|
+
await initialLoad
|
|
1055
|
+
|
|
1056
|
+
expect(controller.currentTopicId).toBe('2')
|
|
1057
|
+
expect(controller.lastKnownRemoteTopicId).toBe('1')
|
|
1058
|
+
|
|
1059
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
1060
|
+
const save = controller.flushSaveLastTopic('2')
|
|
1061
|
+
await Promise.resolve()
|
|
1062
|
+
controller.onPopupClosed()
|
|
1063
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
1064
|
+
ok: true,
|
|
1065
|
+
json: async () => ({
|
|
1066
|
+
topics: TOPICS,
|
|
1067
|
+
archived_topics: [],
|
|
1068
|
+
can_manage: true,
|
|
1069
|
+
last_topic_id: '1',
|
|
1070
|
+
main_topic_id: '1',
|
|
1071
|
+
effective_creative_id: '42',
|
|
1072
|
+
}),
|
|
1073
|
+
})
|
|
1074
|
+
await controller.onPopupOpened({ creativeId: '42' })
|
|
1075
|
+
|
|
1076
|
+
expect(controller.currentTopicId).toBe('2')
|
|
1077
|
+
resolveSave(true)
|
|
1078
|
+
await save
|
|
1079
|
+
})
|
|
1080
|
+
|
|
1081
|
+
test('a new creative records its own snapshot baseline before its first save', async () => {
|
|
1082
|
+
let resolveSnapshot
|
|
1083
|
+
controller.lastKnownRemoteTopicId = '3'
|
|
1084
|
+
controller.creativeIdValue = '99'
|
|
1085
|
+
controller._renderedCreativeId = '99'
|
|
1086
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveSnapshot = resolve }))
|
|
1087
|
+
|
|
1088
|
+
const load = controller.loadTopics()
|
|
1089
|
+
controller.selectTopic('2')
|
|
1090
|
+
resolveSnapshot({
|
|
1091
|
+
ok: true,
|
|
1092
|
+
json: async () => ({
|
|
1093
|
+
topics: TOPICS,
|
|
1094
|
+
archived_topics: [],
|
|
1095
|
+
can_manage: true,
|
|
1096
|
+
last_topic_id: '1',
|
|
1097
|
+
main_topic_id: '1',
|
|
1098
|
+
effective_creative_id: '99',
|
|
1099
|
+
}),
|
|
1100
|
+
})
|
|
1101
|
+
await load
|
|
1102
|
+
|
|
1103
|
+
expect(controller.lastKnownRemoteTopicId).toBe('1')
|
|
1104
|
+
await controller.flushSaveLastTopic('2')
|
|
1105
|
+
expect(controller.pendingSelfEchoPreviousTopicIds.get(clientIdFor('2'))).toBe('1')
|
|
1106
|
+
})
|
|
1107
|
+
|
|
1108
|
+
test('a queued save uses its preceding save as the stream baseline after another creative loads', async () => {
|
|
1109
|
+
let resolveFirstSave
|
|
1110
|
+
let resolveSecondSave
|
|
1111
|
+
controller.lastKnownRemoteTopicId = '1'
|
|
1112
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveFirstSave = resolve }))
|
|
1113
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSecondSave = resolve }))
|
|
1114
|
+
|
|
1115
|
+
const firstSave = controller.flushSaveLastTopic('2')
|
|
1116
|
+
await Promise.resolve()
|
|
1117
|
+
const queuedSave = controller.flushSaveLastTopic('3')
|
|
1118
|
+
|
|
1119
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
1120
|
+
ok: true,
|
|
1121
|
+
json: async () => ({
|
|
1122
|
+
topics: TOPICS,
|
|
1123
|
+
archived_topics: [],
|
|
1124
|
+
can_manage: true,
|
|
1125
|
+
last_topic_id: '3',
|
|
1126
|
+
main_topic_id: '1',
|
|
1127
|
+
effective_creative_id: '99',
|
|
1128
|
+
}),
|
|
1129
|
+
})
|
|
1130
|
+
await controller.onPopupOpened({ creativeId: '99' })
|
|
1131
|
+
|
|
1132
|
+
resolveFirstSave(true)
|
|
1133
|
+
await firstSave
|
|
1134
|
+
await Promise.resolve()
|
|
1135
|
+
|
|
1136
|
+
expect(controller.pendingSelfEchoPreviousTopicIds.get(clientIdFor('3'))).toBe('2')
|
|
1137
|
+
resolveSecondSave(true)
|
|
1138
|
+
await queuedSave
|
|
1139
|
+
})
|
|
1140
|
+
|
|
1141
|
+
test('a temporary creative switch keeps an in-flight save claim for its returning stream', async () => {
|
|
1142
|
+
let resolveSave
|
|
1143
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
1144
|
+
|
|
1145
|
+
const save = controller.flushSaveLastTopic('2')
|
|
1146
|
+
await Promise.resolve()
|
|
1147
|
+
const clientId = clientIdFor('2')
|
|
1148
|
+
|
|
1149
|
+
global.fetch = jest.fn()
|
|
1150
|
+
.mockResolvedValueOnce({
|
|
1151
|
+
ok: true,
|
|
1152
|
+
json: async () => ({
|
|
1153
|
+
topics: TOPICS,
|
|
1154
|
+
archived_topics: [],
|
|
1155
|
+
can_manage: true,
|
|
1156
|
+
last_topic_id: '1',
|
|
1157
|
+
main_topic_id: '1',
|
|
1158
|
+
effective_creative_id: '99',
|
|
1159
|
+
}),
|
|
1160
|
+
})
|
|
1161
|
+
.mockResolvedValueOnce({
|
|
1162
|
+
ok: true,
|
|
1163
|
+
json: async () => ({
|
|
1164
|
+
topics: TOPICS,
|
|
1165
|
+
archived_topics: [],
|
|
1166
|
+
can_manage: true,
|
|
1167
|
+
last_topic_id: '1',
|
|
1168
|
+
main_topic_id: '1',
|
|
1169
|
+
effective_creative_id: '42',
|
|
1170
|
+
}),
|
|
1171
|
+
})
|
|
1172
|
+
|
|
1173
|
+
await controller.onPopupOpened({ creativeId: '99' })
|
|
1174
|
+
await controller.onPopupOpened({ creativeId: '42' })
|
|
1175
|
+
controller.selectTopic('3')
|
|
1176
|
+
|
|
1177
|
+
selfEcho('2')
|
|
1178
|
+
|
|
1179
|
+
expect(controller.pendingSelfEchoes).not.toContain(clientId)
|
|
1180
|
+
expect(controller.currentTopicId).toBe('3')
|
|
1181
|
+
resolveSave(true)
|
|
1182
|
+
await save
|
|
1183
|
+
})
|
|
1184
|
+
|
|
1185
|
+
test('a reopen keeps a newer server preference after a closed save completed', async () => {
|
|
1186
|
+
let resolveSave
|
|
1187
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
1188
|
+
controller.selectTopic('2')
|
|
1189
|
+
const save = controller.flushSaveLastTopic('2')
|
|
1190
|
+
await Promise.resolve()
|
|
1191
|
+
|
|
1192
|
+
controller.onPopupClosed()
|
|
1193
|
+
resolveSave(true)
|
|
1194
|
+
await save
|
|
1195
|
+
|
|
1196
|
+
expect(controller.pendingSelfEchoes).toEqual([])
|
|
1197
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
1198
|
+
ok: true,
|
|
1199
|
+
json: async () => ({
|
|
1200
|
+
topics: TOPICS,
|
|
1201
|
+
archived_topics: [],
|
|
1202
|
+
can_manage: true,
|
|
1203
|
+
last_topic_id: '3',
|
|
1204
|
+
main_topic_id: '1',
|
|
1205
|
+
effective_creative_id: '42',
|
|
1206
|
+
}),
|
|
1207
|
+
})
|
|
1208
|
+
await controller.onPopupOpened({ creativeId: '42' })
|
|
1209
|
+
|
|
1210
|
+
expect(controller.currentTopicId).toBe('3')
|
|
1211
|
+
})
|
|
1212
|
+
|
|
1213
|
+
test('a reopened snapshot newer than a closed in-flight save is not overwritten', async () => {
|
|
1214
|
+
let resolveSave
|
|
1215
|
+
controller.lastKnownRemoteTopicId = '1'
|
|
1216
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
1217
|
+
controller.selectTopic('2')
|
|
1218
|
+
const save = controller.flushSaveLastTopic('2')
|
|
1219
|
+
await Promise.resolve()
|
|
1220
|
+
|
|
1221
|
+
controller.onPopupClosed()
|
|
1222
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
1223
|
+
ok: true,
|
|
1224
|
+
json: async () => ({
|
|
1225
|
+
topics: TOPICS,
|
|
1226
|
+
archived_topics: [],
|
|
1227
|
+
can_manage: true,
|
|
1228
|
+
last_topic_id: '3',
|
|
1229
|
+
main_topic_id: '1',
|
|
1230
|
+
effective_creative_id: '42',
|
|
1231
|
+
}),
|
|
1232
|
+
})
|
|
1233
|
+
await controller.onPopupOpened({ creativeId: '42' })
|
|
1234
|
+
|
|
1235
|
+
expect(controller.currentTopicId).toBe('3')
|
|
1236
|
+
resolveSave(true)
|
|
1237
|
+
await save
|
|
1238
|
+
expect(controller.pendingSelfEchoes).toHaveLength(1)
|
|
1239
|
+
})
|
|
1240
|
+
|
|
1241
|
+
test('a queued save claimed after closing yields to a newer reopen snapshot', async () => {
|
|
1242
|
+
let resolveFirst
|
|
1243
|
+
let resolveSecond
|
|
1244
|
+
controller.lastKnownRemoteTopicId = '1'
|
|
1245
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve }))
|
|
1246
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve }))
|
|
1247
|
+
|
|
1248
|
+
controller.selectTopic('2')
|
|
1249
|
+
const first = controller.flushSaveLastTopic('2')
|
|
1250
|
+
await Promise.resolve()
|
|
1251
|
+
controller.selectTopic('3')
|
|
1252
|
+
const second = controller.flushSaveLastTopic('3')
|
|
1253
|
+
|
|
1254
|
+
controller.onPopupClosed()
|
|
1255
|
+
resolveFirst(true)
|
|
1256
|
+
await first
|
|
1257
|
+
await Promise.resolve()
|
|
1258
|
+
|
|
1259
|
+
const queuedClientId = clientIdFor('3')
|
|
1260
|
+
expect(controller.possiblyMissedPendingSelfEchoes).toContain(queuedClientId)
|
|
1261
|
+
|
|
1262
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
1263
|
+
ok: true,
|
|
1264
|
+
json: async () => ({
|
|
1265
|
+
topics: TOPICS,
|
|
1266
|
+
archived_topics: [],
|
|
1267
|
+
can_manage: true,
|
|
1268
|
+
last_topic_id: '1',
|
|
1269
|
+
main_topic_id: '1',
|
|
1270
|
+
effective_creative_id: '42',
|
|
1271
|
+
}),
|
|
1272
|
+
})
|
|
1273
|
+
await controller.onPopupOpened({ creativeId: '42' })
|
|
1274
|
+
|
|
1275
|
+
expect(controller.currentTopicId).toBe('1')
|
|
1276
|
+
resolveSecond(true)
|
|
1277
|
+
await second
|
|
1278
|
+
})
|
|
1279
|
+
|
|
1280
|
+
test('a response after reopening keeps its claim until the delayed echo lands', async () => {
|
|
1281
|
+
let resolveSave
|
|
1282
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
1283
|
+
controller.selectTopic('2')
|
|
1284
|
+
const save = controller.flushSaveLastTopic('2')
|
|
1285
|
+
await Promise.resolve()
|
|
1286
|
+
|
|
1287
|
+
controller.onPopupClosed()
|
|
1288
|
+
controller.loadTopics = jest.fn()
|
|
1289
|
+
controller.onPopupOpened({ creativeId: '42' })
|
|
1290
|
+
controller.selectTopic('3')
|
|
1291
|
+
|
|
1292
|
+
resolveSave(true)
|
|
1293
|
+
await save
|
|
1294
|
+
expect(controller.pendingSelfEchoes).toHaveLength(1)
|
|
1295
|
+
|
|
1296
|
+
selfEcho('2')
|
|
1297
|
+
expect(controller.currentTopicId).toBe('3')
|
|
1298
|
+
})
|
|
1299
|
+
|
|
1300
|
+
test('a closed later save keeps the previous locally committed preference', async () => {
|
|
1301
|
+
controller.lastKnownRemoteTopicId = '1'
|
|
1302
|
+
controller.selectTopic('2')
|
|
1303
|
+
await controller.flushSaveLastTopic('2')
|
|
1304
|
+
expect(controller.lastKnownRemoteTopicId).toBe('2')
|
|
1305
|
+
selfEcho('2')
|
|
1306
|
+
|
|
1307
|
+
let resolveSave
|
|
1308
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
1309
|
+
controller.selectTopic('3')
|
|
1310
|
+
const save = controller.flushSaveLastTopic('3')
|
|
1311
|
+
await Promise.resolve()
|
|
1312
|
+
|
|
1313
|
+
controller.onPopupClosed()
|
|
1314
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
1315
|
+
ok: true,
|
|
1316
|
+
json: async () => ({
|
|
1317
|
+
topics: TOPICS,
|
|
1318
|
+
archived_topics: [],
|
|
1319
|
+
can_manage: true,
|
|
1320
|
+
last_topic_id: '2',
|
|
1321
|
+
main_topic_id: '1',
|
|
1322
|
+
effective_creative_id: '42',
|
|
1323
|
+
}),
|
|
1324
|
+
})
|
|
1325
|
+
await controller.onPopupOpened({ creativeId: '42' })
|
|
1326
|
+
|
|
1327
|
+
expect(controller.currentTopicId).toBe('3')
|
|
1328
|
+
resolveSave(true)
|
|
1329
|
+
await save
|
|
1330
|
+
})
|
|
1331
|
+
|
|
1332
|
+
test('reopening an origin after its linked shell keeps the shared stream claim', async () => {
|
|
1333
|
+
let resolveSave
|
|
1334
|
+
controller.creativeIdValue = '77'
|
|
1335
|
+
controller.element.dataset.effectiveCreativeId = '42'
|
|
1336
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
1337
|
+
controller.selectTopic('2')
|
|
1338
|
+
const save = controller.flushSaveLastTopic('2')
|
|
1339
|
+
await Promise.resolve()
|
|
1340
|
+
|
|
1341
|
+
controller.onPopupClosed()
|
|
1342
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
1343
|
+
ok: true,
|
|
1344
|
+
json: async () => ({
|
|
1345
|
+
topics: TOPICS,
|
|
1346
|
+
archived_topics: [],
|
|
1347
|
+
can_manage: true,
|
|
1348
|
+
last_topic_id: '2',
|
|
1349
|
+
main_topic_id: '1',
|
|
1350
|
+
effective_creative_id: '42',
|
|
1351
|
+
}),
|
|
1352
|
+
})
|
|
1353
|
+
await controller.onPopupOpened({ creativeId: '42' })
|
|
1354
|
+
controller.selectTopic('3')
|
|
1355
|
+
selfEcho('2')
|
|
1356
|
+
|
|
1357
|
+
expect(controller.currentTopicId).toBe('3')
|
|
1358
|
+
resolveSave(true)
|
|
1359
|
+
await save
|
|
1360
|
+
})
|
|
1361
|
+
|
|
1362
|
+
test('direct navigation from a linked shell to its origin keeps the shared stream claim', async () => {
|
|
1363
|
+
controller.creativeIdValue = '77'
|
|
1364
|
+
controller.element.dataset.effectiveCreativeId = '42'
|
|
1365
|
+
controller.subscribe()
|
|
1366
|
+
controller.selectTopic('2')
|
|
1367
|
+
await controller.flushSaveLastTopic('2')
|
|
1368
|
+
|
|
1369
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
1370
|
+
ok: true,
|
|
1371
|
+
json: async () => ({
|
|
1372
|
+
topics: TOPICS,
|
|
1373
|
+
archived_topics: [],
|
|
1374
|
+
can_manage: true,
|
|
1375
|
+
last_topic_id: '2',
|
|
1376
|
+
main_topic_id: '1',
|
|
1377
|
+
effective_creative_id: '42',
|
|
1378
|
+
}),
|
|
1379
|
+
})
|
|
1380
|
+
await controller.onPopupOpened({ creativeId: '42' })
|
|
1381
|
+
controller.selectTopic('3')
|
|
1382
|
+
selfEcho('2')
|
|
1383
|
+
|
|
1384
|
+
expect(controller.currentTopicId).toBe('3')
|
|
1385
|
+
})
|
|
1386
|
+
|
|
1387
|
+
test('closing releases an acknowledged linked-shell claim before reopening', async () => {
|
|
1388
|
+
controller.creativeIdValue = '77'
|
|
1389
|
+
controller.element.dataset.effectiveCreativeId = '42'
|
|
1390
|
+
controller.selectTopic('2')
|
|
1391
|
+
await controller.flushSaveLastTopic('2')
|
|
1392
|
+
const alphaClientId = clientIdFor('2')
|
|
1393
|
+
|
|
1394
|
+
expect(controller.pendingSelfEchoes).toContain(alphaClientId)
|
|
1395
|
+
expect(controller.acknowledgedPendingSelfEchoes).toContain(alphaClientId)
|
|
1396
|
+
|
|
1397
|
+
controller.onPopupClosed()
|
|
1398
|
+
|
|
1399
|
+
expect(controller.pendingSelfEchoes).not.toContain(alphaClientId)
|
|
1400
|
+
expect(controller.pendingSelfEchoCreativeIds.has(alphaClientId)).toBe(false)
|
|
1401
|
+
expect(controller.pendingSelfEchoAcknowledgementVersions.has(alphaClientId)).toBe(false)
|
|
1402
|
+
expect(controller.pendingSelfEchoRemoteRevisions.has(alphaClientId)).toBe(false)
|
|
1403
|
+
|
|
1404
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
1405
|
+
ok: true,
|
|
1406
|
+
json: async () => ({
|
|
1407
|
+
topics: TOPICS,
|
|
1408
|
+
archived_topics: [],
|
|
1409
|
+
can_manage: true,
|
|
1410
|
+
last_topic_id: '2',
|
|
1411
|
+
main_topic_id: '1',
|
|
1412
|
+
effective_creative_id: '42',
|
|
1413
|
+
}),
|
|
1414
|
+
})
|
|
1415
|
+
await controller.onPopupOpened({ creativeId: '77' })
|
|
1416
|
+
|
|
1417
|
+
expect(controller.pendingSelfEchoes).not.toContain(alphaClientId)
|
|
1418
|
+
})
|
|
1419
|
+
|
|
1420
|
+
test('a reopened linked shell keeps an in-flight claim before its replacement load resolves', async () => {
|
|
1421
|
+
controller.creativeIdValue = '77'
|
|
1422
|
+
global.fetch = jest.fn().mockResolvedValueOnce({
|
|
1423
|
+
ok: true,
|
|
1424
|
+
json: async () => ({
|
|
1425
|
+
topics: TOPICS,
|
|
1426
|
+
archived_topics: [],
|
|
1427
|
+
can_manage: true,
|
|
1428
|
+
last_topic_id: '1',
|
|
1429
|
+
main_topic_id: '1',
|
|
1430
|
+
effective_creative_id: '42',
|
|
1431
|
+
}),
|
|
1432
|
+
})
|
|
1433
|
+
await controller.loadTopics()
|
|
1434
|
+
|
|
1435
|
+
let resolveSave
|
|
1436
|
+
let resolveReopen
|
|
1437
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
1438
|
+
controller.selectTopic('2')
|
|
1439
|
+
const save = controller.flushSaveLastTopic('2')
|
|
1440
|
+
await Promise.resolve()
|
|
1441
|
+
const alphaClientId = clientIdFor('2')
|
|
1442
|
+
|
|
1443
|
+
controller.onPopupClosed()
|
|
1444
|
+
global.fetch.mockImplementationOnce(() => new Promise((resolve) => { resolveReopen = resolve }))
|
|
1445
|
+
const reopen = controller.onPopupOpened({ creativeId: '77' })
|
|
1446
|
+
await Promise.resolve()
|
|
1447
|
+
|
|
1448
|
+
resolveSave(true)
|
|
1449
|
+
await save
|
|
1450
|
+
expect(controller.pendingSelfEchoes).toContain(alphaClientId)
|
|
1451
|
+
|
|
1452
|
+
resolveReopen({
|
|
1453
|
+
ok: true,
|
|
1454
|
+
json: async () => ({
|
|
1455
|
+
topics: TOPICS,
|
|
1456
|
+
archived_topics: [],
|
|
1457
|
+
can_manage: true,
|
|
1458
|
+
last_topic_id: '2',
|
|
1459
|
+
main_topic_id: '1',
|
|
1460
|
+
effective_creative_id: '42',
|
|
1461
|
+
}),
|
|
1462
|
+
})
|
|
1463
|
+
await reopen
|
|
1464
|
+
|
|
1465
|
+
controller.selectTopic('3')
|
|
1466
|
+
selfEcho('2')
|
|
1467
|
+
|
|
1468
|
+
expect(controller.currentTopicId).toBe('3')
|
|
1469
|
+
})
|
|
1470
|
+
|
|
1471
|
+
test('keeps a claim while a first linked shell resolves its effective stream', async () => {
|
|
1472
|
+
let resolveSave
|
|
1473
|
+
let resolveOpen
|
|
1474
|
+
controller.creativeIdValue = '42'
|
|
1475
|
+
controller.element.dataset.effectiveCreativeId = '42'
|
|
1476
|
+
controller.subscribe()
|
|
1477
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
1478
|
+
const save = controller.flushSaveLastTopic('2')
|
|
1479
|
+
await Promise.resolve()
|
|
1480
|
+
const alphaClientId = clientIdFor('2')
|
|
1481
|
+
|
|
1482
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveOpen = resolve }))
|
|
1483
|
+
const open = controller.onPopupOpened({ creativeId: '77' })
|
|
1484
|
+
await Promise.resolve()
|
|
1485
|
+
resolveSave(true)
|
|
1486
|
+
await save
|
|
1487
|
+
|
|
1488
|
+
expect(controller.pendingSelfEchoes).toContain(alphaClientId)
|
|
1489
|
+
|
|
1490
|
+
resolveOpen({
|
|
1491
|
+
ok: true,
|
|
1492
|
+
json: async () => ({
|
|
1493
|
+
topics: TOPICS,
|
|
1494
|
+
archived_topics: [],
|
|
1495
|
+
can_manage: true,
|
|
1496
|
+
last_topic_id: '2',
|
|
1497
|
+
main_topic_id: '1',
|
|
1498
|
+
effective_creative_id: '42',
|
|
1499
|
+
}),
|
|
1500
|
+
})
|
|
1501
|
+
await open
|
|
1502
|
+
controller.selectTopic('3')
|
|
1503
|
+
echo('2', alphaClientId)
|
|
1504
|
+
|
|
1505
|
+
expect(controller.currentTopicId).toBe('3')
|
|
1506
|
+
})
|
|
1507
|
+
|
|
1508
|
+
test('releases an acknowledged claim after a first shell resolves to another stream', async () => {
|
|
1509
|
+
controller.creativeIdValue = '42'
|
|
1510
|
+
controller.element.dataset.effectiveCreativeId = '42'
|
|
1511
|
+
controller.subscribe()
|
|
1512
|
+
await controller.flushSaveLastTopic('2')
|
|
1513
|
+
const alphaClientId = clientIdFor('2')
|
|
1514
|
+
|
|
1515
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
1516
|
+
ok: true,
|
|
1517
|
+
json: async () => ({
|
|
1518
|
+
topics: TOPICS,
|
|
1519
|
+
archived_topics: [],
|
|
1520
|
+
can_manage: true,
|
|
1521
|
+
last_topic_id: '1',
|
|
1522
|
+
main_topic_id: '1',
|
|
1523
|
+
effective_creative_id: '88',
|
|
1524
|
+
}),
|
|
1525
|
+
})
|
|
1526
|
+
await controller.onPopupOpened({ creativeId: '77' })
|
|
1527
|
+
|
|
1528
|
+
expect(controller.pendingSelfEchoes).not.toContain(alphaClientId)
|
|
1529
|
+
})
|
|
1530
|
+
|
|
1531
|
+
test('a pre-resolution linked-shell save keeps its claim after resolving its origin', async () => {
|
|
1532
|
+
controller.creativeIdValue = '77'
|
|
1533
|
+
delete controller.element.dataset.effectiveCreativeId
|
|
1534
|
+
controller.selectTopic('2')
|
|
1535
|
+
await controller.flushSaveLastTopic('2')
|
|
1536
|
+
|
|
1537
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
1538
|
+
ok: true,
|
|
1539
|
+
json: async () => ({
|
|
1540
|
+
topics: TOPICS,
|
|
1541
|
+
archived_topics: [],
|
|
1542
|
+
can_manage: true,
|
|
1543
|
+
last_topic_id: '2',
|
|
1544
|
+
main_topic_id: '1',
|
|
1545
|
+
effective_creative_id: '42',
|
|
1546
|
+
}),
|
|
1547
|
+
})
|
|
1548
|
+
await controller.loadTopics()
|
|
1549
|
+
|
|
1550
|
+
expect(controller.pendingSelfEchoCreativeIds.get(clientIdFor('2'))).toBe('42')
|
|
1551
|
+
controller.selectTopic('3')
|
|
1552
|
+
selfEcho('2')
|
|
1553
|
+
|
|
1554
|
+
expect(controller.currentTopicId).toBe('3')
|
|
1555
|
+
})
|
|
1556
|
+
|
|
1557
|
+
test('a debounced linked-shell save retains its resolved stream after close and sibling reopen', async () => {
|
|
1558
|
+
let resolveSave
|
|
1559
|
+
jest.useFakeTimers()
|
|
1560
|
+
try {
|
|
1561
|
+
controller.creativeIdValue = '77'
|
|
1562
|
+
controller.element.dataset.effectiveCreativeId = '42'
|
|
1563
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
1564
|
+
|
|
1565
|
+
controller.selectTopic('2')
|
|
1566
|
+
controller.onPopupClosed()
|
|
1567
|
+
controller.loadTopics = jest.fn()
|
|
1568
|
+
controller.onPopupOpened({ creativeId: '88' })
|
|
1569
|
+
await jest.advanceTimersByTimeAsync(500)
|
|
1570
|
+
|
|
1571
|
+
const oldSaveClientId = clientIdFor('2')
|
|
1572
|
+
expect(saveLastTopic).toHaveBeenCalledWith('77', '2', oldSaveClientId, expect.anything())
|
|
1573
|
+
expect(controller.pendingSelfEchoCreativeIds.get(oldSaveClientId)).toBe('42')
|
|
1574
|
+
|
|
1575
|
+
controller.selectTopic('3')
|
|
1576
|
+
echo('2', oldSaveClientId)
|
|
1577
|
+
|
|
1578
|
+
expect(controller.currentTopicId).toBe('3')
|
|
1579
|
+
resolveSave(true)
|
|
1580
|
+
} finally {
|
|
1581
|
+
jest.useRealTimers()
|
|
1582
|
+
}
|
|
1583
|
+
})
|
|
1584
|
+
|
|
1585
|
+
test('a legacy migration echo is correlated with a later pick', async () => {
|
|
1586
|
+
localStorage.setItem('collavre_creative_42_last_topic', '2')
|
|
1587
|
+
controller.serverLastTopicId = ''
|
|
1588
|
+
|
|
1589
|
+
controller.migrateLocalStorage()
|
|
1590
|
+
await Promise.resolve()
|
|
1591
|
+
await Promise.resolve()
|
|
1592
|
+
const migrationClientId = clientIdFor('2')
|
|
1593
|
+
expect(migrationClientId).toEqual(expect.any(String))
|
|
1594
|
+
|
|
1595
|
+
controller.selectTopic('3')
|
|
1596
|
+
echo('2', migrationClientId)
|
|
1597
|
+
|
|
1598
|
+
expect(controller.currentTopicId).toBe('3')
|
|
1599
|
+
})
|
|
1600
|
+
|
|
1601
|
+
test('reopening a different creative retains unresolved claims from the one left', async () => {
|
|
1602
|
+
let resolveSave
|
|
1603
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
1604
|
+
controller.selectTopic('2')
|
|
1605
|
+
const save = controller.flushSaveLastTopic('2')
|
|
1606
|
+
await Promise.resolve()
|
|
1607
|
+
|
|
1608
|
+
controller.onPopupClosed()
|
|
1609
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
1610
|
+
ok: true,
|
|
1611
|
+
json: async () => ({
|
|
1612
|
+
topics: TOPICS,
|
|
1613
|
+
archived_topics: [],
|
|
1614
|
+
can_manage: true,
|
|
1615
|
+
last_topic_id: '1',
|
|
1616
|
+
main_topic_id: '1',
|
|
1617
|
+
effective_creative_id: '99',
|
|
1618
|
+
}),
|
|
1619
|
+
})
|
|
1620
|
+
await controller.onPopupOpened({ creativeId: '99' })
|
|
1621
|
+
|
|
1622
|
+
expect(controller.pendingSelfEchoes).toHaveLength(1)
|
|
1623
|
+
resolveSave(true)
|
|
1624
|
+
await save
|
|
1625
|
+
})
|
|
1626
|
+
|
|
1627
|
+
test('a completed save releases its claim after navigation subscribes to another stream', async () => {
|
|
1628
|
+
let resolveSave
|
|
1629
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
1630
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
1631
|
+
ok: true,
|
|
1632
|
+
json: async () => ({
|
|
1633
|
+
topics: TOPICS,
|
|
1634
|
+
archived_topics: [],
|
|
1635
|
+
can_manage: true,
|
|
1636
|
+
last_topic_id: '',
|
|
1637
|
+
main_topic_id: '1',
|
|
1638
|
+
effective_creative_id: '99',
|
|
1639
|
+
}),
|
|
1640
|
+
})
|
|
1641
|
+
|
|
1642
|
+
const save = controller.flushSaveLastTopic('2')
|
|
1643
|
+
await Promise.resolve()
|
|
1644
|
+
const alphaClientId = clientIdFor('2')
|
|
1645
|
+
|
|
1646
|
+
const openDifferentCreative = controller.onPopupOpened({ creativeId: '99' })
|
|
1647
|
+
resolveSave(true)
|
|
1648
|
+
await Promise.all([save, openDifferentCreative])
|
|
1649
|
+
|
|
1650
|
+
expect(controller.pendingSelfEchoes).not.toContain(alphaClientId)
|
|
1651
|
+
expect(controller.pendingSelfEchoCreativeIds.has(alphaClientId)).toBe(false)
|
|
1652
|
+
})
|
|
1653
|
+
|
|
1654
|
+
// unsubscribe() is the deliberate exit, and a refused subscription cannot
|
|
1655
|
+
// reconnect, so both discard their claims. A dropped connection can miss a
|
|
1656
|
+
// broadcast, so acknowledged claims become ignore-only tombstones instead.
|
|
1657
|
+
describe('claims against a subscription that goes away on its own', () => {
|
|
1658
|
+
// Delivered the way the real ones are, through the subscription itself.
|
|
1659
|
+
const deliver = (lastTopicId, clientId) => subscriptionCallbacks.received({
|
|
1660
|
+
action: 'last_topic_changed',
|
|
1661
|
+
last_topic_id: lastTopicId,
|
|
1662
|
+
...(clientId === undefined ? {} : { client_id: clientId }),
|
|
1663
|
+
})
|
|
1664
|
+
|
|
1665
|
+
beforeEach(() => {
|
|
1666
|
+
subscriptionCallbacks = undefined
|
|
1667
|
+
controller.subscribe()
|
|
1668
|
+
})
|
|
1669
|
+
|
|
1670
|
+
const dropAndReconnect = () => subscriptionCallbacks.disconnected()
|
|
1671
|
+
|
|
1672
|
+
test('a reconnectable disconnect keeps an in-flight claim for its delayed echo', async () => {
|
|
1673
|
+
let resolveSave
|
|
1674
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
1675
|
+
const save = controller.flushSaveLastTopic('2')
|
|
1676
|
+
await Promise.resolve()
|
|
1677
|
+
|
|
1678
|
+
dropAndReconnect()
|
|
1679
|
+
|
|
1680
|
+
expect(controller.pendingSelfEchoes).toEqual([clientIdFor('2')])
|
|
1681
|
+
// Reconnected, and the user has since picked Beta. The delayed echo of
|
|
1682
|
+
// the request made before the reconnect is still ours, not new state.
|
|
1683
|
+
controller.selectTopic('3')
|
|
1684
|
+
deliver('2', clientIdFor('2'))
|
|
1685
|
+
resolveSave(true)
|
|
1686
|
+
await save
|
|
1687
|
+
expect(controller.currentTopicId).toBe('3')
|
|
1688
|
+
})
|
|
1689
|
+
|
|
1690
|
+
test('retires an acknowledged claim when a disconnect can lose its echo', async () => {
|
|
1691
|
+
let resolveSave
|
|
1692
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
|
1693
|
+
const save = controller.flushSaveLastTopic('2')
|
|
1694
|
+
await Promise.resolve()
|
|
1695
|
+
const alphaClientId = clientIdFor('2')
|
|
1696
|
+
|
|
1697
|
+
dropAndReconnect()
|
|
1698
|
+
resolveSave(true)
|
|
1699
|
+
await save
|
|
1700
|
+
|
|
1701
|
+
expect(controller.pendingSelfEchoes).not.toContain(alphaClientId)
|
|
1702
|
+
expect(controller.pendingSelfEchoCreativeIds.has(alphaClientId)).toBe(false)
|
|
1703
|
+
expect(controller.isRetiredSelfEcho(alphaClientId)).toBe(true)
|
|
1704
|
+
controller.selectTopic('3')
|
|
1705
|
+
deliver('2', alphaClientId)
|
|
1706
|
+
expect(controller.currentTopicId).toBe('3')
|
|
1707
|
+
})
|
|
1708
|
+
|
|
1709
|
+
test('retires a claim already acknowledged when the connection drops', async () => {
|
|
1710
|
+
await controller.flushSaveLastTopic('2')
|
|
1711
|
+
const alphaClientId = clientIdFor('2')
|
|
1712
|
+
|
|
1713
|
+
dropAndReconnect()
|
|
1714
|
+
|
|
1715
|
+
expect(controller.pendingSelfEchoes).not.toContain(alphaClientId)
|
|
1716
|
+
expect(controller.pendingSelfEchoCreativeIds.has(alphaClientId)).toBe(false)
|
|
1717
|
+
expect(controller.isRetiredSelfEcho(alphaClientId)).toBe(true)
|
|
1718
|
+
})
|
|
1719
|
+
|
|
1720
|
+
test('a real update still applies after a reconnectable disconnect', async () => {
|
|
1721
|
+
controller.selectTopic('2')
|
|
1722
|
+
await controller.flushSaveLastTopic('2')
|
|
1723
|
+
|
|
1724
|
+
dropAndReconnect()
|
|
1725
|
+
|
|
1726
|
+
// Reconnected, and another session moves the preference to Alpha.
|
|
1727
|
+
controller.selectTopic('3')
|
|
1728
|
+
deliver('2')
|
|
1729
|
+
expect(controller.currentTopicId).toBe('2')
|
|
1730
|
+
})
|
|
1731
|
+
|
|
1732
|
+
// The same reasoning one step along the queue. A save waiting its turn is
|
|
1733
|
+
// sent after the consumer has reconnected, so its echo is not lost at all
|
|
1734
|
+
// — refusing it a claim on the strength of the disconnect would let its
|
|
1735
|
+
// own echo revert whatever the user picked while it was queued.
|
|
1736
|
+
test('a save queued across a reconnect still claims its echo', async () => {
|
|
1737
|
+
let resolveFirst
|
|
1738
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve }))
|
|
1739
|
+
const first = controller.flushSaveLastTopic('2')
|
|
1740
|
+
const second = controller.flushSaveLastTopic('3')
|
|
1741
|
+
await Promise.resolve()
|
|
1742
|
+
|
|
1743
|
+
dropAndReconnect()
|
|
1744
|
+
resolveFirst(true)
|
|
1745
|
+
await Promise.all([first, second])
|
|
1746
|
+
|
|
1747
|
+
controller.selectTopic('')
|
|
1748
|
+
deliver('3', clientIdFor('3'))
|
|
1749
|
+
|
|
1750
|
+
expect(controller.currentTopicId).toBe('')
|
|
1751
|
+
})
|
|
1752
|
+
|
|
1753
|
+
test('a refused subscription drops outstanding claims', async () => {
|
|
1754
|
+
controller.selectTopic('2')
|
|
1755
|
+
await controller.flushSaveLastTopic('2')
|
|
1756
|
+
|
|
1757
|
+
subscriptionCallbacks.rejected()
|
|
1758
|
+
|
|
1759
|
+
expect(controller.pendingSelfEchoes).toHaveLength(0)
|
|
1760
|
+
controller.selectTopic('3')
|
|
1761
|
+
deliver('2')
|
|
1762
|
+
expect(controller.currentTopicId).toBe('2')
|
|
1763
|
+
})
|
|
1764
|
+
|
|
1765
|
+
test('a refused different-creative subscription retains the original stream claim', async () => {
|
|
1766
|
+
controller.selectTopic('2')
|
|
1767
|
+
await controller.flushSaveLastTopic('2')
|
|
1768
|
+
const alphaClientId = clientIdFor('2')
|
|
1769
|
+
|
|
1770
|
+
controller.creativeIdValue = '99'
|
|
1771
|
+
controller.subscribe({ preservePendingSelfEchoes: true })
|
|
1772
|
+
subscriptionCallbacks.rejected()
|
|
1773
|
+
|
|
1774
|
+
expect(controller.pendingSelfEchoes).toContain(alphaClientId)
|
|
1775
|
+
controller.creativeIdValue = '42'
|
|
1776
|
+
controller.subscribe({ preservePendingSelfEchoes: true })
|
|
1777
|
+
controller.selectTopic('3')
|
|
1778
|
+
deliver('2', alphaClientId)
|
|
1779
|
+
|
|
1780
|
+
expect(controller.currentTopicId).toBe('3')
|
|
1781
|
+
})
|
|
1782
|
+
|
|
1783
|
+
// The claim is only dropped by those two — an ordinary echo on a healthy
|
|
1784
|
+
// subscription still settles the save that is waiting for it.
|
|
1785
|
+
test('an echo on a live subscription is still consumed', async () => {
|
|
1786
|
+
controller.selectTopic('2')
|
|
1787
|
+
await controller.flushSaveLastTopic('2')
|
|
1788
|
+
|
|
1789
|
+
controller.selectTopic('3')
|
|
1790
|
+
deliver('2', clientIdFor('2'))
|
|
1791
|
+
|
|
1792
|
+
expect(controller.currentTopicId).toBe('3')
|
|
1793
|
+
})
|
|
1794
|
+
})
|
|
1795
|
+
|
|
1796
|
+
// Two PATCHes in flight at once are answered — and broadcast — in whatever
|
|
1797
|
+
// order the server finishes them, which the head-of-queue check reads as
|
|
1798
|
+
// "not ours". The claim it skips is then owed a message that has already
|
|
1799
|
+
// been and gone, and it swallows the next real update naming that topic.
|
|
1800
|
+
// Sending one save at a time is what makes the queue order true.
|
|
1801
|
+
describe('a save made while another is still in flight', () => {
|
|
1802
|
+
let resolveFirst
|
|
1803
|
+
|
|
1804
|
+
// Both picks are made back to back, as a fast second click does. The await
|
|
1805
|
+
// only lets the head of the queue issue its request — resolveFirst is the
|
|
1806
|
+
// handle on it, and is only assigned once it has.
|
|
1807
|
+
const startTwoSaves = async () => {
|
|
1808
|
+
resolveFirst = null
|
|
1809
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve }))
|
|
1810
|
+
controller.selectTopic('2')
|
|
1811
|
+
const first = controller.flushSaveLastTopic('2')
|
|
1812
|
+
controller.selectTopic('3')
|
|
1813
|
+
const second = controller.flushSaveLastTopic('3')
|
|
1814
|
+
await Promise.resolve()
|
|
1815
|
+
expect(resolveFirst).toEqual(expect.any(Function))
|
|
1816
|
+
return [first, second]
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
test('waits for the one in flight instead of racing it', async () => {
|
|
1820
|
+
const pending = await startTwoSaves()
|
|
1821
|
+
|
|
1822
|
+
expect(saveLastTopic).toHaveBeenCalledTimes(1)
|
|
1823
|
+
expect(saveLastTopic).toHaveBeenLastCalledWith('42', '2', expect.any(String), expect.anything())
|
|
1824
|
+
|
|
1825
|
+
resolveFirst(true)
|
|
1826
|
+
await Promise.all(pending)
|
|
1827
|
+
|
|
1828
|
+
expect(saveLastTopic).toHaveBeenCalledTimes(2)
|
|
1829
|
+
expect(saveLastTopic).toHaveBeenLastCalledWith('42', '3', expect.any(String), expect.anything())
|
|
1830
|
+
})
|
|
1831
|
+
|
|
1832
|
+
// With the sends ordered, the echoes are too: the first save's broadcast is
|
|
1833
|
+
// emitted before the second save is even sent. Both are then claimed and
|
|
1834
|
+
// consumed in turn, and nothing is left to swallow a later update.
|
|
1835
|
+
test('leaves no claim behind once both echoes land', async () => {
|
|
1836
|
+
const pending = await startTwoSaves()
|
|
1837
|
+
selfEcho('2')
|
|
1838
|
+
resolveFirst(true)
|
|
1839
|
+
await Promise.all(pending)
|
|
1840
|
+
selfEcho('3')
|
|
1841
|
+
|
|
1842
|
+
// The user moves on, and another session picks Beta for real.
|
|
1843
|
+
controller.selectTopic('')
|
|
1844
|
+
echo('3')
|
|
1845
|
+
|
|
1846
|
+
expect(controller.currentTopicId).toBe('3')
|
|
1847
|
+
})
|
|
1848
|
+
|
|
1849
|
+
// An ambiguous delivery still has to hand the queue on, or every later
|
|
1850
|
+
// pick is stranded behind it. It keeps its claim, though: the server can
|
|
1851
|
+
// have accepted the PATCH and broadcast after fetch reports a failure.
|
|
1852
|
+
test('an ambiguous save does not strand the one behind it', async () => {
|
|
1853
|
+
let rejectFirst
|
|
1854
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectFirst = reject }))
|
|
1855
|
+
controller.selectTopic('2')
|
|
1856
|
+
const first = controller.flushSaveLastTopic('2')
|
|
1857
|
+
controller.selectTopic('3')
|
|
1858
|
+
const second = controller.flushSaveLastTopic('3')
|
|
1859
|
+
await Promise.resolve()
|
|
1860
|
+
|
|
1861
|
+
rejectFirst(new Error('network'))
|
|
1862
|
+
await Promise.all([first, second])
|
|
1863
|
+
|
|
1864
|
+
expect(saveLastTopic).toHaveBeenLastCalledWith('42', '3', expect.any(String), expect.anything())
|
|
1865
|
+
controller.selectTopic('')
|
|
1866
|
+
selfEcho('2')
|
|
1867
|
+
expect(controller.currentTopicId).toBe('')
|
|
1868
|
+
})
|
|
1869
|
+
})
|
|
1870
|
+
|
|
1871
|
+
// Topic equality is not sender identity. Two sessions of the same user can
|
|
1872
|
+
// save the same topic at the same time, and their broadcasts are then
|
|
1873
|
+
// indistinguishable by payload — so one claim gets settled by the wrong
|
|
1874
|
+
// message and the client's own echo comes back looking like news.
|
|
1875
|
+
describe('another session that named the same topic', () => {
|
|
1876
|
+
const raceOnAlpha = async () => {
|
|
1877
|
+
controller.selectTopic('2')
|
|
1878
|
+
await controller.flushSaveLastTopic('2')
|
|
1879
|
+
|
|
1880
|
+
// The other session had moved the preference to Alpha too, and its
|
|
1881
|
+
// broadcast is the first of the two to arrive.
|
|
1882
|
+
echo('2')
|
|
1883
|
+
// Ours has still not landed when the user picks Beta.
|
|
1884
|
+
controller.selectTopic('3')
|
|
1885
|
+
selfEcho('2')
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
test('its broadcast does not settle a claim of ours', async () => {
|
|
1889
|
+
await raceOnAlpha()
|
|
1890
|
+
|
|
1891
|
+
expect(controller.currentTopicId).toBe('3')
|
|
1892
|
+
expect(changeEvents.at(-1).topicId).toBe('3')
|
|
1893
|
+
})
|
|
1894
|
+
|
|
1895
|
+
test('our own late echo is not recorded over the newer pick', async () => {
|
|
1896
|
+
await raceOnAlpha()
|
|
1897
|
+
|
|
1898
|
+
expect(controller.serverLastTopicId).toBe('3')
|
|
1899
|
+
})
|
|
1900
|
+
})
|
|
1901
|
+
|
|
1902
|
+
// crypto.randomUUID is only defined in a secure context. Keep the fallback
|
|
1903
|
+
// cryptographically random too, because these IDs distinguish a tab's own
|
|
1904
|
+
// Action Cable echoes from those emitted by sibling tabs.
|
|
1905
|
+
test('ids are still unique per save without crypto.randomUUID', async () => {
|
|
1906
|
+
Object.defineProperty(global.crypto, 'randomUUID', { value: undefined, configurable: true })
|
|
1907
|
+
|
|
1908
|
+
try {
|
|
1909
|
+
await controller.flushSaveLastTopic('2')
|
|
1910
|
+
await controller.flushSaveLastTopic('3')
|
|
1911
|
+
} finally {
|
|
1912
|
+
delete global.crypto.randomUUID
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
const ids = saveLastTopic.mock.calls.map(c => c[2])
|
|
1916
|
+
expect(ids).toHaveLength(2)
|
|
1917
|
+
ids.forEach(id => expect(id).toMatch(/^save-/))
|
|
1918
|
+
expect(new Set(ids).size).toBe(2)
|
|
1919
|
+
})
|
|
1920
|
+
|
|
1921
|
+
// Claims are taken inside the queued callback, which runs whenever the save
|
|
1922
|
+
// ahead of it finishes — by then the subscription that would settle the claim
|
|
1923
|
+
// may already be gone, and clearing the queue at that moment cannot reach a
|
|
1924
|
+
// claim that does not exist yet.
|
|
1925
|
+
test('a save queued behind another leaves no claim once the stream is gone', async () => {
|
|
1926
|
+
let resolveFirst
|
|
1927
|
+
saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve }))
|
|
1928
|
+
const first = controller.flushSaveLastTopic('2')
|
|
1929
|
+
const second = controller.flushSaveLastTopic('3')
|
|
1930
|
+
await Promise.resolve()
|
|
1931
|
+
|
|
1932
|
+
controller.unsubscribe()
|
|
1933
|
+
resolveFirst(true)
|
|
1934
|
+
await Promise.all([first, second])
|
|
1935
|
+
|
|
1936
|
+
expect(controller.pendingSelfEchoes).toHaveLength(0)
|
|
1937
|
+
})
|
|
1938
|
+
})
|