@oneuptime/common 14.0.2 → 14.0.5
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.
- package/Models/DatabaseModels/Alert.ts +55 -0
- package/Models/DatabaseModels/AlertFeed.ts +2 -0
- package/Models/DatabaseModels/DatabaseServer.ts +1650 -0
- package/Models/DatabaseModels/DatabaseServerEndpoint.ts +540 -0
- package/Models/DatabaseModels/DatabaseServerFeed.ts +625 -0
- package/Models/DatabaseModels/DatabaseServerLabelRule.ts +514 -0
- package/Models/DatabaseModels/DatabaseServerOwnerRule.ts +596 -0
- package/Models/DatabaseModels/DatabaseServerOwnerTeam.ts +491 -0
- package/Models/DatabaseModels/DatabaseServerOwnerUser.ts +490 -0
- package/Models/DatabaseModels/Incident.ts +55 -0
- package/Models/DatabaseModels/IncidentAlert.ts +540 -0
- package/Models/DatabaseModels/IncidentFeed.ts +2 -0
- package/Models/DatabaseModels/Index.ts +18 -0
- package/Models/DatabaseModels/NetworkDeviceDiscoveryScan.ts +26 -0
- package/Models/DatabaseModels/Project.ts +60 -0
- package/Models/DatabaseModels/RumApplication.ts +52 -1
- package/Models/DatabaseModels/ScheduledMaintenance.ts +55 -0
- package/Models/DatabaseModels/TelemetryException.ts +2 -0
- package/Models/DatabaseModels/UserProjectSsoConsent.ts +146 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.ts +28 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.ts +89 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1795000000000-AddDatabaseServerTables.ts +673 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1795100000000-AddUserProjectSsoConsent.ts +51 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
- package/Server/Middleware/MultipartFormData.ts +1 -1
- package/Server/Middleware/TelemetryIngest.ts +15 -8
- package/Server/Services/AlertReminderRuleService.ts +100 -24
- package/Server/Services/DatabaseServerEndpointService.ts +895 -0
- package/Server/Services/DatabaseServerFeedService.ts +128 -0
- package/Server/Services/DatabaseServerLabelRuleEngineService.ts +340 -0
- package/Server/Services/DatabaseServerLabelRuleService.ts +14 -0
- package/Server/Services/DatabaseServerOwnerRuleEngineService.ts +425 -0
- package/Server/Services/DatabaseServerOwnerRuleService.ts +14 -0
- package/Server/Services/DatabaseServerOwnerTeamService.ts +180 -0
- package/Server/Services/DatabaseServerOwnerUserService.ts +173 -0
- package/Server/Services/DatabaseServerService.ts +4546 -0
- package/Server/Services/DatabaseService.ts +14 -8
- package/Server/Services/DockerResourceService.ts +43 -2
- package/Server/Services/IncidentAlertService.ts +1811 -0
- package/Server/Services/IncidentReminderRuleService.ts +103 -23
- package/Server/Services/IncidentService.ts +210 -10
- package/Server/Services/IncidentStateTimelineService.ts +24 -0
- package/Server/Services/Index.ts +18 -0
- package/Server/Services/InventoryItemService.ts +113 -5
- package/Server/Services/IoTDeviceService.ts +145 -37
- package/Server/Services/OpenTelemetryIngestService.ts +19 -0
- package/Server/Services/PodmanResourceService.ts +43 -2
- package/Server/Services/ScheduledMaintenanceReminderRuleService.ts +107 -23
- package/Server/Services/ServiceService.ts +1 -1
- package/Server/Services/TelemetryUsageBillingService.ts +29 -2
- package/Server/Services/UserProjectSsoConsentService.ts +74 -0
- package/Server/Services/UserService.ts +14 -1
- package/Server/Types/Database/Permissions/OwnerTableRegistry.ts +18 -0
- package/Server/Types/Database/QueryHelper.ts +30 -1
- package/Server/Utils/AI/Toolbox/ResourceTools.ts +69 -1
- package/Server/Utils/Database/AffectedResourceRelations.ts +6 -0
- package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +188 -6
- package/Server/Utils/Monitor/MonitorAlert.ts +28 -1
- package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +2262 -755
- package/Server/Utils/Monitor/MonitorIncident.ts +28 -1
- package/Server/Utils/Monitor/MonitorMaintenanceSuppression.ts +268 -6
- package/Server/Utils/Monitor/MonitorResource.ts +47 -11
- package/Server/Utils/Monitor/MonitorResourceContext.ts +1 -0
- package/Server/Utils/Monitor/MonitorStepResourceIdentity.ts +239 -21
- package/Server/Utils/Monitor/PerSeriesResolutionRootCause.ts +43 -0
- package/Server/Utils/Monitor/SeriesResourceLabels.ts +291 -0
- package/Server/Utils/Monitor/SeriesResourceLinker.ts +111 -12
- package/Server/Utils/Rules/RuleRun/RuleRunRegistry.ts +25 -0
- package/Server/Utils/SessionReplay/SessionReplayGateCache.ts +24 -2
- package/Server/Utils/StartServer.ts +26 -6
- package/Server/Utils/TeamMember/ProjectLeaveResourceCleanup.ts +2 -0
- package/Server/Utils/Telemetry/DatabaseEndpointDiscovery.ts +688 -0
- package/Server/Utils/Telemetry/EntityRegistry.ts +5 -1
- package/Server/Utils/Telemetry/LogExceptionExtractor.ts +18 -1
- package/Server/Utils/Telemetry/ResourceEntityFilter.ts +175 -12
- package/Server/Utils/Telemetry/ResourceFacetResolver.ts +12 -0
- package/Server/Utils/Telemetry/ServiceDependencyDiscovery.ts +606 -36
- package/Server/Utils/Telemetry/Telemetry.ts +16 -0
- package/Server/Utils/Telemetry/TelemetryEntity.ts +22 -22
- package/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.ts +16 -10
- package/Tests/App/Accounts/SignedInRedirect.test.tsx +241 -0
- package/Tests/App/AdminDashboard/AdminHeaderSmallScreens.test.tsx +221 -4
- package/Tests/App/Dashboard/AIInsightsResourcePageContext.test.ts +42 -0
- package/Tests/App/Dashboard/AffectedResourcesDisplay.test.tsx +100 -0
- package/Tests/App/Dashboard/AffectedResourcesPicker.test.tsx +86 -0
- package/Tests/App/Dashboard/AffectedResourcesPickerHiddenTypes.test.tsx +2 -0
- package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +19 -0
- package/Tests/App/Dashboard/BreadcrumbCoverage.test.tsx +14 -0
- package/Tests/App/Dashboard/BulkIncidentLinkActions.test.tsx +869 -0
- package/Tests/App/Dashboard/CephMetricTooltips.test.tsx +1777 -0
- package/Tests/App/Dashboard/CloudFleetSummary.test.ts +8 -0
- package/Tests/App/Dashboard/CloudMetricTooltips.test.tsx +887 -0
- package/Tests/App/Dashboard/ContainerHostMetricTooltips.test.tsx +1309 -0
- package/Tests/App/Dashboard/CorrelateFilterBuilder.test.tsx +26 -1
- package/Tests/App/Dashboard/DashboardAppSsoError.test.tsx +501 -0
- package/Tests/App/Dashboard/DashboardHeaderRequests.test.tsx +64 -1
- package/Tests/App/Dashboard/DashboardHeaderSmallScreens.test.tsx +178 -18
- package/Tests/App/Dashboard/DashboardMasterPageSsoRedirect.test.tsx +444 -0
- package/Tests/App/Dashboard/DashboardNavBarForeignHiddenRule.test.tsx +573 -0
- package/Tests/App/Dashboard/DatabaseChartAxes.test.tsx +431 -0
- package/Tests/App/Dashboard/DatabaseContainerDetailBadge.test.tsx +249 -0
- package/Tests/App/Dashboard/DatabaseDashboardE2EFixes.test.ts +681 -0
- package/Tests/App/Dashboard/DatabaseDocumentationMarkdown.test.ts +1801 -0
- package/Tests/App/Dashboard/DatabaseFleetSummaryRefresh.test.tsx +280 -0
- package/Tests/App/Dashboard/DatabaseFormHelpers.test.tsx +321 -0
- package/Tests/App/Dashboard/DatabaseMetricMonitorLink.test.tsx +795 -0
- package/Tests/App/Dashboard/DatabaseOverviewHeader.test.tsx +580 -0
- package/Tests/App/Dashboard/DatabaseRecommendationsEngineMetrics.test.ts +270 -0
- package/Tests/App/Dashboard/DatabaseServerComponents.test.tsx +1486 -0
- package/Tests/App/Dashboard/DatabaseServerEngineMetricsFilter.test.ts +223 -0
- package/Tests/App/Dashboard/DatabaseServerPresentation.test.ts +435 -0
- package/Tests/App/Dashboard/DatabaseServerSummary.test.ts +186 -0
- package/Tests/App/Dashboard/DatabaseServerTelemetryQueries.test.ts +1553 -0
- package/Tests/App/Dashboard/DatabaseTableTruncation.test.tsx +326 -0
- package/Tests/App/Dashboard/DatabaseTelemetryScope.test.ts +512 -0
- package/Tests/App/Dashboard/DatabaseTelemetryTabs.test.tsx +774 -0
- package/Tests/App/Dashboard/DatabaseViewLayout.test.tsx +233 -0
- package/Tests/App/Dashboard/DatabaseWorkloadBadge.test.tsx +972 -0
- package/Tests/App/Dashboard/DiscoveryReviewInventoryRefresh.test.tsx +5 -0
- package/Tests/App/Dashboard/DiscoveryReviewUnnamedHostHint.test.tsx +1061 -0
- package/Tests/App/Dashboard/DiscoveryScanLivePage.test.tsx +39 -0
- package/Tests/App/Dashboard/DockerSwarmClustersListTooltips.test.tsx +414 -0
- package/Tests/App/Dashboard/DockerSwarmMetricTooltips.test.ts +796 -0
- package/Tests/App/Dashboard/DockerSwarmOverviewTooltips.test.tsx +747 -0
- package/Tests/App/Dashboard/EventOverlayScope.test.ts +147 -0
- package/Tests/App/Dashboard/EventOverlayScopeHook.test.tsx +1 -0
- package/Tests/App/Dashboard/ExceptionMonitorEnvironmentFilter.test.tsx +38 -18
- package/Tests/App/Dashboard/HostDetailViewTooltips.test.tsx +725 -0
- package/Tests/App/Dashboard/HostListColumnTooltips.test.tsx +472 -0
- package/Tests/App/Dashboard/HostMetricTooltips.test.ts +709 -0
- package/Tests/App/Dashboard/HostOverviewTooltips.test.tsx +698 -0
- package/Tests/App/Dashboard/HostTooltipHarness.ts +124 -0
- package/Tests/App/Dashboard/HostsListTooltips.test.tsx +395 -0
- package/Tests/App/Dashboard/IncidentAlertLinkDialog.test.tsx +691 -0
- package/Tests/App/Dashboard/IncidentAlertLinkHelpers.test.ts +458 -0
- package/Tests/App/Dashboard/IncidentAlertLinkPages.test.tsx +828 -0
- package/Tests/App/Dashboard/IncidentAlertLinkSideMenus.test.tsx +231 -0
- package/Tests/App/Dashboard/IncidentCreateFromAlerts.test.tsx +689 -0
- package/Tests/App/Dashboard/IoTMetricTooltips.test.tsx +1123 -0
- package/Tests/App/Dashboard/KubernetesClusterMetricTooltips.test.ts +837 -0
- package/Tests/App/Dashboard/KubernetesClusterOverviewTooltips.test.tsx +699 -0
- package/Tests/App/Dashboard/KubernetesContainersTabTooltips.test.tsx +320 -0
- package/Tests/App/Dashboard/KubernetesCostTooltips.test.tsx +502 -0
- package/Tests/App/Dashboard/KubernetesInsightsTooltips.test.tsx +222 -0
- package/Tests/App/Dashboard/KubernetesResourceMetricTooltips.test.ts +561 -0
- package/Tests/App/Dashboard/KubernetesResourcePagesTooltips.test.tsx +1168 -0
- package/Tests/App/Dashboard/LinkIncidentAlertModal.test.tsx +154 -0
- package/Tests/App/Dashboard/LogsResourceFacetChips.test.tsx +1 -0
- package/Tests/App/Dashboard/MetricDescriptionRules.ts +170 -0
- package/Tests/App/Dashboard/MetricDescriptionsCatalog.test.ts +206 -0
- package/Tests/App/Dashboard/MetricDescriptionsConsistency.test.ts +275 -0
- package/Tests/App/Dashboard/MetricsViewerDrillDown.test.tsx +310 -0
- package/Tests/App/Dashboard/MetricsViewerEntityScopedRows.test.tsx +495 -0
- package/Tests/App/Dashboard/MonitorCreateFromMonitorBackedDevice.test.tsx +132 -1
- package/Tests/App/Dashboard/NavigationSearchKeywords.test.tsx +5 -0
- package/Tests/App/Dashboard/NetworkDeviceTooltips.test.tsx +852 -0
- package/Tests/App/Dashboard/NetworkDiagnosticsTooltips.test.tsx +556 -0
- package/Tests/App/Dashboard/NetworkMetricTooltips.test.ts +1348 -0
- package/Tests/App/Dashboard/NetworkSiteTooltips.test.tsx +679 -0
- package/Tests/App/Dashboard/ProductOrientation.test.tsx +4 -3
- package/Tests/App/Dashboard/ProxmoxMetricTooltips.test.ts +592 -0
- package/Tests/App/Dashboard/ProxmoxOverviewTooltips.test.tsx +1002 -0
- package/Tests/App/Dashboard/ProxmoxResourcePageTooltips.test.tsx +923 -0
- package/Tests/App/Dashboard/RecommendationsLoadedContext.test.tsx +349 -0
- package/Tests/App/Dashboard/ResourceArchiveSettings.test.tsx +62 -0
- package/Tests/App/Dashboard/ResourceFeedPageWiring.test.ts +8 -1
- package/Tests/App/Dashboard/ResourceFeedPagesFilterSort.test.tsx +14 -0
- package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +1066 -0
- package/Tests/App/Dashboard/RumOverviewPage.test.tsx +1195 -0
- package/Tests/App/Dashboard/ScheduledMaintenanceOverviewPage.test.tsx +2 -0
- package/Tests/App/Dashboard/ServerlessMetricTooltips.test.tsx +474 -0
- package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +1085 -0
- package/Tests/App/Dashboard/ServiceMetricTooltips.test.tsx +772 -0
- package/Tests/App/Dashboard/SessionReplayAuditTable.test.tsx +56 -1
- package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +1207 -0
- package/Tests/App/Dashboard/TelemetryResourceRetentionSettings.test.tsx +10 -0
- package/Tests/App/Dashboard/TelemetryResourceTabTooltips.test.tsx +437 -0
- package/Tests/App/Dashboard/TracesResourceFacets.test.tsx +2 -1
- package/Tests/App/Dashboard/VMwareMetricTooltips.test.ts +801 -0
- package/Tests/App/Dashboard/VMwareOverviewTooltips.test.tsx +601 -0
- package/Tests/App/Dashboard/VMwareResourcePagesTooltips.test.tsx +646 -0
- package/Tests/App/Dashboard/VMwareTooltipHarness.ts +145 -0
- package/Tests/App/StatusPage/StatusPageHeaderForeignHiddenRule.test.tsx +284 -0
- package/Tests/ClassStrings.ts +178 -0
- package/Tests/Fixtures/MobilePasskeyUser.json +1 -2
- package/Tests/ForeignHiddenRuleGuard.ts +2152 -0
- package/Tests/Models/DatabaseModels/DatabaseServerModels.test.ts +2250 -0
- package/Tests/Models/DatabaseModels/IncidentAlertModel.test.ts +554 -0
- package/Tests/Models/DatabaseModels/ResourceFeedModels.test.ts +24 -5
- package/Tests/Models/DatabaseModels/SessionReplayModels.test.ts +117 -0
- package/Tests/Models/DatabaseModels/StaticRuleCriteriaInheritance.test.ts +4 -2
- package/Tests/RenderedMarkup.ts +518 -0
- package/Tests/ResponsiveVisibility.test.ts +490 -4
- package/Tests/ResponsiveVisibility.ts +505 -52
- package/Tests/Server/Infrastructure/Postgres/AddConfigurableRuleCriteriaMigration.test.ts +3 -0
- package/Tests/Server/Infrastructure/Postgres/AddDatabaseServerTablesMigration.test.ts +541 -0
- package/Tests/Server/Infrastructure/Postgres/AddIncidentAlertMigration.test.ts +341 -0
- package/Tests/Server/Infrastructure/Postgres/AddSessionReplaySameOriginTracePropagationMigration.test.ts +247 -0
- package/Tests/Server/Infrastructure/Postgres/AddUserProjectSsoConsentMigration.test.ts +234 -0
- package/Tests/Server/Services/ContainerSnapshotUpsertPostgres.test.ts +319 -0
- package/Tests/Server/Services/CrossProjectRelationWriteGuard.test.ts +13 -0
- package/Tests/Server/Services/DatabaseServerEndpointService.test.ts +2033 -0
- package/Tests/Server/Services/DatabaseServerFeedAndOwnerServices.test.ts +529 -0
- package/Tests/Server/Services/DatabaseServerLifecycle.test.ts +3302 -0
- package/Tests/Server/Services/DatabaseServerRegistries.test.ts +603 -0
- package/Tests/Server/Services/DatabaseServerRuleEngines.test.ts +754 -0
- package/Tests/Server/Services/DatabaseServerService.test.ts +3558 -0
- package/Tests/Server/Services/DatabaseServerSqlPostgres.test.ts +2045 -0
- package/Tests/Server/Services/DatabaseServiceUpdateColumnsIfUnlockedPostgres.test.ts +362 -0
- package/Tests/Server/Services/DatabaseServiceUpdateColumnsIfUnlockedReply.test.ts +210 -0
- package/Tests/Server/Services/HookFreeWriteLengthClamp.test.ts +18 -0
- package/Tests/Server/Services/IncidentAlertPostgres.test.ts +903 -0
- package/Tests/Server/Services/IncidentAlertRegistration.test.ts +96 -0
- package/Tests/Server/Services/IncidentAlertService.test.ts +2225 -0
- package/Tests/Server/Services/IncidentAlertSloLinkSurvivesEdit.test.ts +2 -0
- package/Tests/Server/Services/IncidentAlertStateCascade.test.ts +970 -0
- package/Tests/Server/Services/IncidentCreateFromAlerts.test.ts +1284 -0
- package/Tests/Server/Services/IncomingCallPolicyEscalationRuleTarget.test.ts +225 -0
- package/Tests/Server/Services/InventoryItemDisplayName.test.ts +280 -2
- package/Tests/Server/Services/InventoryUpsertSchemaParity.test.ts +8 -2
- package/Tests/Server/Services/IoTDeviceLatestMetricsPostgres.test.ts +689 -0
- package/Tests/Server/Services/IoTDeviceService.test.ts +379 -0
- package/Tests/Server/Services/LatestMetricsValuesShape.test.ts +360 -0
- package/Tests/Server/Services/LlmModelPriceService.test.ts +402 -0
- package/Tests/Server/Services/OpenTelemetryResourceRetentionMemo.test.ts +7 -1
- package/Tests/Server/Services/OwnerRowUniqueness.test.ts +61 -9
- package/Tests/Server/Services/ReminderRuleLookupPostgres.test.ts +2250 -0
- package/Tests/Server/Services/ReminderRuleRefreshOpenSubjectsPaging.test.ts +653 -0
- package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +68 -20
- package/Tests/Server/Services/StaticRuleCriteriaRuntimeCoverage.test.ts +3 -3
- package/Tests/Server/Services/TelemetryResourceFacetCounting.test.ts +9 -0
- package/Tests/Server/Services/UserProjectSsoConsentPostgres.test.ts +153 -0
- package/Tests/Server/Services/UserProjectSsoConsentService.test.ts +192 -0
- package/Tests/Server/Services/UserServiceFirstMasterAdminElection.test.ts +71 -0
- package/Tests/Server/Services/UserSignupIdTakeoverPostgres.test.ts +238 -0
- package/Tests/Server/Services/WorkspaceNotificationSummaryHelpers.test.ts +592 -0
- package/Tests/Server/Types/Billing/PayAsYouGoMeteredAuthorization.test.ts +3 -0
- package/Tests/Server/Types/Database/EventOverlayScopePostgres.test.ts +1 -0
- package/Tests/Server/Types/Database/FeedEventTypeFilterQuery.test.ts +11 -0
- package/Tests/Server/Types/Database/Permissions/InfrastructureOwnershipScope.test.ts +8 -0
- package/Tests/Server/Types/Database/QueryHelperBooleanForCriteriaBackedRule.test.ts +1094 -0
- package/Tests/Server/Utils/AI/ResourceTools.test.ts +134 -0
- package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.test.ts +41 -0
- package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteriaAnomalyUnits.test.ts +279 -9
- package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteriaDisplayContext.test.ts +933 -0
- package/Tests/Server/Utils/Monitor/DatabaseMaintenanceSuppression.test.ts +582 -0
- package/Tests/Server/Utils/Monitor/DatabaseServerMonitorLinking.test.ts +753 -0
- package/Tests/Server/Utils/Monitor/MonitorActionTimestamps.test.ts +1 -0
- package/Tests/Server/Utils/Monitor/MonitorAlertCriteriaReferences.test.ts +1 -0
- package/Tests/Server/Utils/Monitor/MonitorAlertResourceLinking.test.ts +119 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluator.test.ts +60 -25
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorAffectedResourceEdgeCases.test.ts +1719 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorKubernetesAffectedResources.test.ts +1447 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorKubernetesRatioEmail.test.ts +352 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorPlatformMetricUnits.test.ts +717 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorPlatformRawScan.test.ts +2125 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorPlatformSeriesAffectedResources.test.ts +1288 -0
- package/Tests/Server/Utils/Monitor/MonitorDependencySuppressionCreatorSkip.test.ts +1 -0
- package/Tests/Server/Utils/Monitor/MonitorIncidentCriteriaReferences.test.ts +1 -0
- package/Tests/Server/Utils/Monitor/MonitorIncidentMemberRoleMembership.test.ts +1 -0
- package/Tests/Server/Utils/Monitor/MonitorIncidentResourceLinking.test.ts +88 -0
- package/Tests/Server/Utils/Monitor/MonitorMaintenanceSuppression.test.ts +205 -0
- package/Tests/Server/Utils/Monitor/MonitorResourceContext.test.ts +62 -0
- package/Tests/Server/Utils/Monitor/MonitorResourceDatabaseMaintenance.test.ts +803 -0
- package/Tests/Server/Utils/Monitor/MonitorStepResourceIdentity.test.ts +133 -3
- package/Tests/Server/Utils/Monitor/MonitorSummaryPersistence.test.ts +1 -0
- package/Tests/Server/Utils/Monitor/PerSeriesRecoveryResolution.test.ts +472 -1
- package/Tests/Server/Utils/Monitor/PerSeriesSecondEntityAlerting.test.ts +1 -0
- package/Tests/Server/Utils/Monitor/SeriesResourceLinker.test.ts +236 -2
- package/Tests/Server/Utils/SeriesResourceLabels.test.ts +70 -0
- package/Tests/Server/Utils/SessionReplay/SessionReplayGateCachePolicy.test.ts +103 -0
- package/Tests/Server/Utils/StartServerProtobufContentType.test.ts +54 -0
- package/Tests/Server/Utils/Telemetry/DatabaseEndpointDiscovery.test.ts +1895 -0
- package/Tests/Server/Utils/Telemetry/EntityRegistry.test.ts +7 -0
- package/Tests/Server/Utils/Telemetry/LogExceptionExtractorSeverityGate.test.ts +90 -0
- package/Tests/Server/Utils/Telemetry/ResourceEntityFilter.test.ts +570 -0
- package/Tests/Server/Utils/Telemetry/ResourceFacetResolver.test.ts +97 -2
- package/Tests/Server/Utils/Telemetry/ServiceDependencyDiscovery.test.ts +1337 -0
- package/Tests/Server/Utils/Telemetry/TelemetryEntity.test.ts +295 -0
- package/Tests/Types/AI/AIResourceContext.test.ts +30 -0
- package/Tests/Types/DatabaseServer/DatabaseContainerClassifier.test.ts +3644 -0
- package/Tests/Types/DatabaseServer/DatabaseContainerCommand.test.ts +340 -0
- package/Tests/Types/DatabaseServer/DatabaseContainerCommandCorpus.ts +826 -0
- package/Tests/Types/DatabaseServer/DatabaseContainerCommandPostgres.test.ts +150 -0
- package/Tests/Types/DatabaseServer/DatabaseEndpoint.test.ts +2657 -0
- package/Tests/Types/DatabaseServer/DatabaseServerDiscoverySource.test.ts +64 -0
- package/Tests/Types/DatabaseServer/DatabaseServerEntityKeys.test.ts +952 -0
- package/Tests/Types/DatabaseServer/DatabaseServerMetricCatalog.test.ts +1161 -0
- package/Tests/Types/DatabaseServer/DatabaseSystem.test.ts +1514 -0
- package/Tests/Types/DatabaseServer/DatabaseTelemetryResolver.test.ts +1558 -0
- package/Tests/Types/Monitor/CephAlertTemplates.test.ts +255 -6
- package/Tests/Types/Monitor/DatabaseAlertTemplates.test.ts +2254 -0
- package/Tests/Types/Monitor/KubernetesAlertTemplates.test.ts +298 -0
- package/Tests/Types/Monitor/KubernetesMetricCatalog.test.ts +271 -0
- package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCatalog.test.ts +253 -9
- package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCoverage.test.ts +13 -5
- package/Tests/Types/Monitor/Recommendation/MonitorRecommendationNotificationMode.test.ts +4 -2
- package/Tests/Types/Monitor/Recommendation/MonitorRecommendationUtil.test.ts +111 -2
- package/Tests/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.test.ts +61 -0
- package/Tests/Types/Monitor/SeriesContext/SeriesLabelDisplay.test.ts +114 -0
- package/Tests/Types/Monitor/Utils/KubernetesAgentEmittedMetrics.ts +181 -0
- package/Tests/Types/NetworkDevice/DiscoveredHostNamingStatus.test.ts +401 -0
- package/Tests/Types/Rum/WebVitals.test.ts +173 -0
- package/Tests/Types/Telemetry/ResourceEntityFacet.test.ts +4 -1
- package/Tests/Types/Telemetry/ResourceFacetCatalog.test.ts +24 -2
- package/Tests/UI/Components/Card.test.tsx +93 -2
- package/Tests/UI/Components/FeedOptions.test.ts +6 -0
- package/Tests/UI/Components/FeedOptionsButton.test.tsx +8 -1
- package/Tests/UI/Components/FieldLabelInfoCardTooltip.test.tsx +413 -0
- package/Tests/UI/Components/ForeignHiddenRuleResilience.test.tsx +1147 -0
- package/Tests/UI/Components/HeaderRightRail.test.tsx +41 -1
- package/Tests/UI/Components/InfoTooltip.test.tsx +242 -0
- package/Tests/UI/Components/LogsEntityNames.test.ts +16 -1
- package/Tests/UI/Components/LogsFacetSidebarHiddenFacets.test.tsx +7 -3
- package/Tests/UI/Components/ModelTable/BaseModelTableBulkDelete.test.tsx +207 -3
- package/Tests/UI/Components/ModelTable/BaseModelTableBulkDeleteVerb.test.tsx +367 -0
- package/Tests/UI/Components/ModelTable/BaseModelTableCreateButtonTitle.test.tsx +275 -0
- package/Tests/UI/Components/ResourceFacetConfigs.test.ts +2 -1
- package/Tests/UI/Components/RuleCriteria/RuleCriteriaModelTable.test.tsx +3 -3
- package/Tests/UI/Components/SrOnlyForeignRule.test.tsx +994 -0
- package/Tests/UI/Components/StatusPage/ResourceListSelection.test.tsx +34 -1
- package/Tests/UI/Components/TableHeaderTooltip.test.tsx +662 -0
- package/Tests/UI/Components/TelemetryFacetSidebarHiddenFacets.test.tsx +4 -1
- package/Tests/UI/ForeignHiddenRuleGuard.test.ts +1951 -0
- package/Tests/UI/Rum/PrivacySummaryCard.test.tsx +166 -9
- package/Tests/UI/Rum/RecordingHealthTooltips.test.tsx +447 -0
- package/Tests/UI/Rum/ReplayCorrelationPanel.test.tsx +164 -0
- package/Tests/UI/Rum/ReplayFrameCapture.test.ts +6750 -0
- package/Tests/UI/Rum/ReplayPlayerChrome.test.tsx +19 -1
- package/Tests/UI/Rum/ReplayRail.test.tsx +723 -12
- package/Tests/UI/Rum/ReplayRailDetail.test.tsx +86 -0
- package/Tests/UI/Rum/ReplayScreenshot.test.ts +2124 -0
- package/Tests/UI/Rum/ReplayScreenshotActions.test.tsx +3288 -0
- package/Tests/UI/Rum/ReplayStage.test.tsx +62 -0
- package/Tests/UI/Rum/ReplayStageOverlays.test.tsx +983 -2
- package/Tests/UI/Rum/ReplayTabSwitcher.test.tsx +19 -1
- package/Tests/UI/Rum/SessionReplaySettingsPolicyLoading.test.tsx +149 -0
- package/Tests/UI/Rum/SessionReplaySetupGuide.test.tsx +107 -6
- package/Tests/UI/Rum/SessionReplayUsersTableTooltips.test.tsx +330 -0
- package/Tests/UI/Rum/UserFlowTooltips.test.tsx +886 -0
- package/Tests/UI/TailwindPlayCdnForeignHiddenRule.test.ts +1005 -0
- package/Tests/UI/ThemeGroupHoverReveal.test.ts +847 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +80 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +23 -0
- package/Tests/UI/Utils/ModelAPISsoRedirect.test.ts +127 -0
- package/Tests/UI/Utils/Navigation.test.ts +102 -0
- package/Tests/UI/Utils/PermissionGate.test.ts +76 -0
- package/Tests/UI/Utils/Telemetry/TelemetryEntityNames.test.ts +23 -0
- package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +696 -0
- package/Tests/Utils/LabelRuleImportExport.test.ts +5 -0
- package/Tests/Utils/MetricUnitUtil.test.ts +227 -2
- package/Tests/Utils/Monitor/MetricValueFormatter.test.ts +288 -2
- package/Tests/Utils/Monitor/PlatformResourceIdentityAndUnits.test.ts +1559 -0
- package/Tests/Utils/NetworkDiscovery/DiscoveredHostNamingDiagnosis.test.ts +2546 -0
- package/Tests/Utils/NetworkDiscovery/DiscoveredHostUtil.test.ts +315 -0
- package/Tests/Utils/NetworkDiscovery/DiscoveryReverseDnsChain.test.ts +336 -1
- package/Tests/Utils/NetworkDiscovery/NetbiosNameUtil.test.ts +244 -0
- package/Tests/Utils/Rum/SessionTraceState.test.ts +239 -0
- package/Tests/Utils/ValueFormatter.test.ts +50 -0
- package/Tests/tsconfig.json +16 -0
- package/Types/AI/AIResourceContext.ts +7 -0
- package/Types/Dashboard/DashboardTemplates.ts +2 -1
- package/Types/DatabaseServer/DatabaseConnectionSpan.ts +77 -0
- package/Types/DatabaseServer/DatabaseContainerClassifier.ts +2068 -0
- package/Types/DatabaseServer/DatabaseContainerCommand.ts +801 -0
- package/Types/DatabaseServer/DatabaseEndpoint.ts +1943 -0
- package/Types/DatabaseServer/DatabaseServerDiscoverySource.ts +68 -0
- package/Types/DatabaseServer/DatabaseServerMetricCatalog.ts +966 -0
- package/Types/DatabaseServer/DatabaseSystem.ts +2124 -0
- package/Types/DatabaseServer/DatabaseTelemetryResolver.ts +555 -0
- package/Types/Email/EmailTemplateType.ts +1 -0
- package/Types/Incident/IncidentAlertLink.ts +38 -0
- package/Types/Monitor/CephAlertTemplates.ts +18 -0
- package/Types/Monitor/DatabaseAlertTemplates.ts +2020 -0
- package/Types/Monitor/KubernetesAlertTemplates.ts +64 -24
- package/Types/Monitor/KubernetesMetricCatalog.ts +118 -19
- package/Types/Monitor/MetricMonitor/MetricCriteriaContext.ts +14 -0
- package/Types/Monitor/MetricMonitor/MetricMonitorResponse.ts +93 -10
- package/Types/Monitor/Recommendation/MonitorRecommendationCatalog.ts +109 -8
- package/Types/Monitor/Recommendation/MonitorRecommendationTypes.ts +36 -7
- package/Types/Monitor/Recommendation/MonitorRecommendationUtil.ts +43 -2
- package/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.ts +41 -6
- package/Types/Monitor/SeriesContext/SeriesLabelDisplay.ts +26 -0
- package/Types/NetworkDevice/DiscoveredHostNamingStatus.ts +147 -0
- package/Types/Permission.ts +315 -0
- package/Types/Probe/ProbeApiIngestResponse.ts +8 -0
- package/Types/Rules/RuleCriteriaFieldRegistry.ts +10 -0
- package/Types/Rules/RuleRun.ts +12 -0
- package/Types/Rum/SessionReplay.ts +25 -6
- package/Types/Rum/WebVitals.ts +52 -0
- package/Types/Telemetry/EntityType.ts +23 -0
- package/Types/Telemetry/ResourceFacetCatalog.ts +14 -0
- package/Types/Telemetry/ServiceType.ts +8 -0
- package/UI/Components/Banner/Banner.tsx +1 -1
- package/UI/Components/Card/Card.tsx +1 -1
- package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +2 -2
- package/UI/Components/Charts/Line/LineChart.tsx +7 -0
- package/UI/Components/CommandPalette/CommandPalette.tsx +1 -1
- package/UI/Components/Date/StartAndEndDate.tsx +5 -5
- package/UI/Components/Detail/FieldLabel.tsx +8 -0
- package/UI/Components/Diagram/HorizontalStepChain.tsx +1 -1
- package/UI/Components/Feed/FeedOptionsButton.tsx +1 -1
- package/UI/Components/Forms/BasicForm.tsx +1 -1
- package/UI/Components/Header/Header.tsx +1 -1
- package/UI/Components/Header/HeaderIconDropdownButton.tsx +1 -1
- package/UI/Components/HeaderAlert/HeaderAlert.tsx +1 -1
- package/UI/Components/InfoCard/InfoCard.tsx +47 -1
- package/UI/Components/LogsViewer/components/FacetValueRow.tsx +1 -1
- package/UI/Components/LogsViewer/components/LogsFacetSidebar.tsx +1 -1
- package/UI/Components/Modal/ModalFooter.tsx +1 -1
- package/UI/Components/ModelDetail/CardModelDetail.tsx +2 -2
- package/UI/Components/ModelTable/BaseModelTable.tsx +99 -21
- package/UI/Components/ModelTable/Column.ts +5 -0
- package/UI/Components/Navbar/NavBar.tsx +9 -2
- package/UI/Components/Navbar/NavBarMenuModal.tsx +2 -2
- package/UI/Components/Page/Page.tsx +1 -1
- package/UI/Components/Pagination/Pagination.tsx +3 -3
- package/UI/Components/ProgressButtons/ProgressButtonItem.tsx +1 -1
- package/UI/Components/SideMenu/SideMenu.tsx +4 -1
- package/UI/Components/StatusPage/ResourceGroupNavigator.tsx +4 -2
- package/UI/Components/StatusPage/ResourceList.tsx +6 -6
- package/UI/Components/Table/TableHeader.tsx +47 -2
- package/UI/Components/Table/Types/Column.ts +7 -0
- package/UI/Components/TelemetryViewer/components/TelemetryFacetSidebar.tsx +1 -1
- package/UI/Components/TelemetryViewer/components/TelemetryFacetValueRow.tsx +1 -1
- package/UI/Components/Tooltip/InfoTooltip.tsx +78 -0
- package/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.tsx +1 -1
- package/UI/Components/Workflow/ComponentsModal.tsx +2 -2
- package/UI/Components/Workflow/StepTraceViewer.tsx +1 -1
- package/UI/Styles/Theme.css +8 -1
- package/UI/Utils/Navigation.ts +89 -42
- package/UI/Utils/PermissionGate.ts +11 -2
- package/UI/Utils/Telemetry/TelemetryEntityNames.ts +7 -0
- package/UI/Utils/TelemetryService.ts +4 -3
- package/Utils/Incident/IncidentFromAlerts.ts +516 -0
- package/Utils/LabelRuleImportExport.ts +2 -0
- package/Utils/MetricUnitUtil.ts +103 -11
- package/Utils/Monitor/MetricValueFormatter.ts +66 -13
- package/Utils/Monitor/PlatformMetricUnitUtil.ts +215 -0
- package/Utils/Monitor/PlatformResourceIdentity.ts +514 -0
- package/Utils/NetworkDiscovery/DiscoveredHostNamingDiagnosis.ts +425 -0
- package/Utils/NetworkDiscovery/DiscoveredHostUtil.ts +32 -0
- package/Utils/NetworkDiscovery/NetbiosNameUtil.ts +73 -0
- package/Utils/Rum/SessionTraceState.ts +212 -0
- package/Utils/Telemetry/DatabaseServerEntityKeys.ts +275 -0
- package/Utils/Telemetry/EntityKey.ts +176 -0
- package/Utils/ValueFormatter.ts +16 -0
- package/build/dist/Models/DatabaseModels/Alert.js +54 -0
- package/build/dist/Models/DatabaseModels/Alert.js.map +1 -1
- package/build/dist/Models/DatabaseModels/AlertFeed.js +2 -0
- package/build/dist/Models/DatabaseModels/AlertFeed.js.map +1 -1
- package/build/dist/Models/DatabaseModels/DatabaseServer.js +1683 -0
- package/build/dist/Models/DatabaseModels/DatabaseServer.js.map +1 -0
- package/build/dist/Models/DatabaseModels/DatabaseServerEndpoint.js +559 -0
- package/build/dist/Models/DatabaseModels/DatabaseServerEndpoint.js.map +1 -0
- package/build/dist/Models/DatabaseModels/DatabaseServerFeed.js +648 -0
- package/build/dist/Models/DatabaseModels/DatabaseServerFeed.js.map +1 -0
- package/build/dist/Models/DatabaseModels/DatabaseServerLabelRule.js +522 -0
- package/build/dist/Models/DatabaseModels/DatabaseServerLabelRule.js.map +1 -0
- package/build/dist/Models/DatabaseModels/DatabaseServerOwnerRule.js +603 -0
- package/build/dist/Models/DatabaseModels/DatabaseServerOwnerRule.js.map +1 -0
- package/build/dist/Models/DatabaseModels/DatabaseServerOwnerTeam.js +506 -0
- package/build/dist/Models/DatabaseModels/DatabaseServerOwnerTeam.js.map +1 -0
- package/build/dist/Models/DatabaseModels/DatabaseServerOwnerUser.js +505 -0
- package/build/dist/Models/DatabaseModels/DatabaseServerOwnerUser.js.map +1 -0
- package/build/dist/Models/DatabaseModels/Incident.js +54 -0
- package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentAlert.js +553 -0
- package/build/dist/Models/DatabaseModels/IncidentAlert.js.map +1 -0
- package/build/dist/Models/DatabaseModels/IncidentFeed.js +2 -0
- package/build/dist/Models/DatabaseModels/IncidentFeed.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Index.js +18 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Project.js +62 -0
- package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
- package/build/dist/Models/DatabaseModels/RumApplication.js +53 -1
- package/build/dist/Models/DatabaseModels/RumApplication.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js +54 -0
- package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js.map +1 -1
- package/build/dist/Models/DatabaseModels/TelemetryException.js +2 -0
- package/build/dist/Models/DatabaseModels/TelemetryException.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserProjectSsoConsent.js +162 -0
- package/build/dist/Models/DatabaseModels/UserProjectSsoConsent.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js +20 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js +42 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795000000000-AddDatabaseServerTables.js +240 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795000000000-AddDatabaseServerTables.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795100000000-AddUserProjectSsoConsent.js +26 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795100000000-AddUserProjectSsoConsent.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Middleware/MultipartFormData.js +1 -1
- package/build/dist/Server/Middleware/TelemetryIngest.js +15 -8
- package/build/dist/Server/Middleware/TelemetryIngest.js.map +1 -1
- package/build/dist/Server/Services/AlertReminderRuleService.js +72 -23
- package/build/dist/Server/Services/AlertReminderRuleService.js.map +1 -1
- package/build/dist/Server/Services/DatabaseServerEndpointService.js +694 -0
- package/build/dist/Server/Services/DatabaseServerEndpointService.js.map +1 -0
- package/build/dist/Server/Services/DatabaseServerFeedService.js +110 -0
- package/build/dist/Server/Services/DatabaseServerFeedService.js.map +1 -0
- package/build/dist/Server/Services/DatabaseServerLabelRuleEngineService.js +272 -0
- package/build/dist/Server/Services/DatabaseServerLabelRuleEngineService.js.map +1 -0
- package/build/dist/Server/Services/DatabaseServerLabelRuleService.js +13 -0
- package/build/dist/Server/Services/DatabaseServerLabelRuleService.js.map +1 -0
- package/build/dist/Server/Services/DatabaseServerOwnerRuleEngineService.js +340 -0
- package/build/dist/Server/Services/DatabaseServerOwnerRuleEngineService.js.map +1 -0
- package/build/dist/Server/Services/DatabaseServerOwnerRuleService.js +13 -0
- package/build/dist/Server/Services/DatabaseServerOwnerRuleService.js.map +1 -0
- package/build/dist/Server/Services/DatabaseServerOwnerTeamService.js +160 -0
- package/build/dist/Server/Services/DatabaseServerOwnerTeamService.js.map +1 -0
- package/build/dist/Server/Services/DatabaseServerOwnerUserService.js +153 -0
- package/build/dist/Server/Services/DatabaseServerOwnerUserService.js.map +1 -0
- package/build/dist/Server/Services/DatabaseServerService.js +3272 -0
- package/build/dist/Server/Services/DatabaseServerService.js.map +1 -0
- package/build/dist/Server/Services/DatabaseService.js +8 -4
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/DockerResourceService.js +30 -3
- package/build/dist/Server/Services/DockerResourceService.js.map +1 -1
- package/build/dist/Server/Services/IncidentAlertService.js +1370 -0
- package/build/dist/Server/Services/IncidentAlertService.js.map +1 -0
- package/build/dist/Server/Services/IncidentReminderRuleService.js +73 -23
- package/build/dist/Server/Services/IncidentReminderRuleService.js.map +1 -1
- package/build/dist/Server/Services/IncidentService.js +164 -12
- package/build/dist/Server/Services/IncidentService.js.map +1 -1
- package/build/dist/Server/Services/IncidentStateTimelineService.js +21 -0
- package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
- package/build/dist/Server/Services/Index.js +18 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/InventoryItemService.js +88 -5
- package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
- package/build/dist/Server/Services/IoTDeviceService.js +111 -24
- package/build/dist/Server/Services/IoTDeviceService.js.map +1 -1
- package/build/dist/Server/Services/OpenTelemetryIngestService.js +24 -9
- package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
- package/build/dist/Server/Services/PodmanResourceService.js +30 -3
- package/build/dist/Server/Services/PodmanResourceService.js.map +1 -1
- package/build/dist/Server/Services/ScheduledMaintenanceReminderRuleService.js +75 -23
- package/build/dist/Server/Services/ScheduledMaintenanceReminderRuleService.js.map +1 -1
- package/build/dist/Server/Services/ServiceService.js +1 -1
- package/build/dist/Server/Services/ServiceService.js.map +1 -1
- package/build/dist/Server/Services/TelemetryUsageBillingService.js +23 -2
- package/build/dist/Server/Services/TelemetryUsageBillingService.js.map +1 -1
- package/build/dist/Server/Services/UserProjectSsoConsentService.js +79 -0
- package/build/dist/Server/Services/UserProjectSsoConsentService.js.map +1 -0
- package/build/dist/Server/Services/UserService.js +13 -1
- package/build/dist/Server/Services/UserService.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/OwnerTableRegistry.js +18 -0
- package/build/dist/Server/Types/Database/Permissions/OwnerTableRegistry.js.map +1 -1
- package/build/dist/Server/Types/Database/QueryHelper.js +26 -1
- package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js +46 -0
- package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js.map +1 -1
- package/build/dist/Server/Utils/Database/AffectedResourceRelations.js +6 -0
- package/build/dist/Server/Utils/Database/AffectedResourceRelations.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +134 -13
- package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorAlert.js +22 -6
- package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +1489 -524
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorIncident.js +22 -6
- package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js +196 -6
- package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorResource.js +36 -9
- package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorResourceContext.js +1 -0
- package/build/dist/Server/Utils/Monitor/MonitorResourceContext.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorStepResourceIdentity.js +175 -17
- package/build/dist/Server/Utils/Monitor/MonitorStepResourceIdentity.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/PerSeriesResolutionRootCause.js +29 -0
- package/build/dist/Server/Utils/Monitor/PerSeriesResolutionRootCause.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js +211 -0
- package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js +82 -10
- package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js.map +1 -1
- package/build/dist/Server/Utils/Rules/RuleRun/RuleRunRegistry.js +25 -0
- package/build/dist/Server/Utils/Rules/RuleRun/RuleRunRegistry.js.map +1 -1
- package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js +8 -0
- package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js.map +1 -1
- package/build/dist/Server/Utils/StartServer.js +20 -4
- package/build/dist/Server/Utils/StartServer.js.map +1 -1
- package/build/dist/Server/Utils/TeamMember/ProjectLeaveResourceCleanup.js +2 -0
- package/build/dist/Server/Utils/TeamMember/ProjectLeaveResourceCleanup.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/DatabaseEndpointDiscovery.js +442 -0
- package/build/dist/Server/Utils/Telemetry/DatabaseEndpointDiscovery.js.map +1 -0
- package/build/dist/Server/Utils/Telemetry/EntityRegistry.js +5 -1
- package/build/dist/Server/Utils/Telemetry/EntityRegistry.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/LogExceptionExtractor.js +15 -1
- package/build/dist/Server/Utils/Telemetry/LogExceptionExtractor.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js +121 -5
- package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/ResourceFacetResolver.js +12 -0
- package/build/dist/Server/Utils/Telemetry/ResourceFacetResolver.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/ServiceDependencyDiscovery.js +438 -36
- package/build/dist/Server/Utils/Telemetry/ServiceDependencyDiscovery.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/Telemetry.js +12 -0
- package/build/dist/Server/Utils/Telemetry/Telemetry.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js +17 -18
- package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.js +16 -10
- package/build/dist/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.js.map +1 -1
- package/build/dist/Types/AI/AIResourceContext.js +7 -0
- package/build/dist/Types/AI/AIResourceContext.js.map +1 -1
- package/build/dist/Types/Dashboard/DashboardTemplates.js +2 -1
- package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
- package/build/dist/Types/DatabaseServer/DatabaseConnectionSpan.js +71 -0
- package/build/dist/Types/DatabaseServer/DatabaseConnectionSpan.js.map +1 -0
- package/build/dist/Types/DatabaseServer/DatabaseContainerClassifier.js +1462 -0
- package/build/dist/Types/DatabaseServer/DatabaseContainerClassifier.js.map +1 -0
- package/build/dist/Types/DatabaseServer/DatabaseContainerCommand.js +691 -0
- package/build/dist/Types/DatabaseServer/DatabaseContainerCommand.js.map +1 -0
- package/build/dist/Types/DatabaseServer/DatabaseEndpoint.js +1407 -0
- package/build/dist/Types/DatabaseServer/DatabaseEndpoint.js.map +1 -0
- package/build/dist/Types/DatabaseServer/DatabaseServerDiscoverySource.js +60 -0
- package/build/dist/Types/DatabaseServer/DatabaseServerDiscoverySource.js.map +1 -0
- package/build/dist/Types/DatabaseServer/DatabaseServerMetricCatalog.js +804 -0
- package/build/dist/Types/DatabaseServer/DatabaseServerMetricCatalog.js.map +1 -0
- package/build/dist/Types/DatabaseServer/DatabaseSystem.js +1860 -0
- package/build/dist/Types/DatabaseServer/DatabaseSystem.js.map +1 -0
- package/build/dist/Types/DatabaseServer/DatabaseTelemetryResolver.js +406 -0
- package/build/dist/Types/DatabaseServer/DatabaseTelemetryResolver.js.map +1 -0
- package/build/dist/Types/Email/EmailTemplateType.js +1 -0
- package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
- package/build/dist/Types/Incident/IncidentAlertLink.js +34 -0
- package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -0
- package/build/dist/Types/Monitor/CephAlertTemplates.js +18 -0
- package/build/dist/Types/Monitor/CephAlertTemplates.js.map +1 -1
- package/build/dist/Types/Monitor/DatabaseAlertTemplates.js +1518 -0
- package/build/dist/Types/Monitor/DatabaseAlertTemplates.js.map +1 -0
- package/build/dist/Types/Monitor/KubernetesAlertTemplates.js +57 -24
- package/build/dist/Types/Monitor/KubernetesAlertTemplates.js.map +1 -1
- package/build/dist/Types/Monitor/KubernetesMetricCatalog.js +104 -18
- package/build/dist/Types/Monitor/KubernetesMetricCatalog.js.map +1 -1
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js +80 -3
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js.map +1 -1
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js +10 -3
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js.map +1 -1
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationUtil.js +29 -0
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationUtil.js.map +1 -1
- package/build/dist/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.js +21 -6
- package/build/dist/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.js.map +1 -1
- package/build/dist/Types/Monitor/SeriesContext/SeriesLabelDisplay.js +25 -0
- package/build/dist/Types/Monitor/SeriesContext/SeriesLabelDisplay.js.map +1 -1
- package/build/dist/Types/NetworkDevice/DiscoveredHostNamingStatus.js +136 -0
- package/build/dist/Types/NetworkDevice/DiscoveredHostNamingStatus.js.map +1 -0
- package/build/dist/Types/Permission.js +276 -0
- package/build/dist/Types/Permission.js.map +1 -1
- package/build/dist/Types/Rules/RuleCriteriaFieldRegistry.js +10 -0
- package/build/dist/Types/Rules/RuleCriteriaFieldRegistry.js.map +1 -1
- package/build/dist/Types/Rules/RuleRun.js +4 -0
- package/build/dist/Types/Rules/RuleRun.js.map +1 -1
- package/build/dist/Types/Rum/WebVitals.js +25 -0
- package/build/dist/Types/Rum/WebVitals.js.map +1 -1
- package/build/dist/Types/Telemetry/EntityType.js +23 -0
- package/build/dist/Types/Telemetry/EntityType.js.map +1 -1
- package/build/dist/Types/Telemetry/ResourceFacetCatalog.js +14 -0
- package/build/dist/Types/Telemetry/ResourceFacetCatalog.js.map +1 -1
- package/build/dist/Types/Telemetry/ServiceType.js +8 -0
- package/build/dist/Types/Telemetry/ServiceType.js.map +1 -1
- package/build/dist/UI/Components/Banner/Banner.js +1 -1
- package/build/dist/UI/Components/Banner/Banner.js.map +1 -1
- package/build/dist/UI/Components/Card/Card.js +1 -1
- package/build/dist/UI/Components/Card/Card.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +2 -2
- package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js +1 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
- package/build/dist/UI/Components/CommandPalette/CommandPalette.js +1 -1
- package/build/dist/UI/Components/CommandPalette/CommandPalette.js.map +1 -1
- package/build/dist/UI/Components/Date/StartAndEndDate.js +5 -5
- package/build/dist/UI/Components/Date/StartAndEndDate.js.map +1 -1
- package/build/dist/UI/Components/Detail/FieldLabel.js +2 -0
- package/build/dist/UI/Components/Detail/FieldLabel.js.map +1 -1
- package/build/dist/UI/Components/Diagram/HorizontalStepChain.js +1 -1
- package/build/dist/UI/Components/Diagram/HorizontalStepChain.js.map +1 -1
- package/build/dist/UI/Components/Feed/FeedOptionsButton.js +1 -1
- package/build/dist/UI/Components/Feed/FeedOptionsButton.js.map +1 -1
- package/build/dist/UI/Components/Forms/BasicForm.js +1 -1
- package/build/dist/UI/Components/Forms/BasicForm.js.map +1 -1
- package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js +1 -1
- package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js.map +1 -1
- package/build/dist/UI/Components/HeaderAlert/HeaderAlert.js +1 -1
- package/build/dist/UI/Components/HeaderAlert/HeaderAlert.js.map +1 -1
- package/build/dist/UI/Components/InfoCard/InfoCard.js +22 -1
- package/build/dist/UI/Components/InfoCard/InfoCard.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/FacetValueRow.js +1 -1
- package/build/dist/UI/Components/LogsViewer/components/FacetValueRow.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js.map +1 -1
- package/build/dist/UI/Components/Modal/ModalFooter.js +1 -1
- package/build/dist/UI/Components/Modal/ModalFooter.js.map +1 -1
- package/build/dist/UI/Components/ModelDetail/CardModelDetail.js +2 -2
- package/build/dist/UI/Components/ModelDetail/CardModelDetail.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js +70 -18
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
- package/build/dist/UI/Components/Navbar/NavBar.js +9 -2
- package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
- package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +2 -2
- package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
- package/build/dist/UI/Components/Page/Page.js +1 -1
- package/build/dist/UI/Components/Page/Page.js.map +1 -1
- package/build/dist/UI/Components/Pagination/Pagination.js +3 -3
- package/build/dist/UI/Components/Pagination/Pagination.js.map +1 -1
- package/build/dist/UI/Components/ProgressButtons/ProgressButtonItem.js +1 -1
- package/build/dist/UI/Components/ProgressButtons/ProgressButtonItem.js.map +1 -1
- package/build/dist/UI/Components/SideMenu/SideMenu.js +4 -1
- package/build/dist/UI/Components/SideMenu/SideMenu.js.map +1 -1
- package/build/dist/UI/Components/StatusPage/ResourceGroupNavigator.js +3 -1
- package/build/dist/UI/Components/StatusPage/ResourceGroupNavigator.js.map +1 -1
- package/build/dist/UI/Components/StatusPage/ResourceList.js +6 -6
- package/build/dist/UI/Components/StatusPage/ResourceList.js.map +1 -1
- package/build/dist/UI/Components/Table/TableHeader.js +18 -2
- package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetSidebar.js +1 -1
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetSidebar.js.map +1 -1
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetValueRow.js +1 -1
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetValueRow.js.map +1 -1
- package/build/dist/UI/Components/Tooltip/InfoTooltip.js +28 -0
- package/build/dist/UI/Components/Tooltip/InfoTooltip.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js +1 -1
- package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ComponentsModal.js +2 -2
- package/build/dist/UI/Components/Workflow/ComponentsModal.js.map +1 -1
- package/build/dist/UI/Components/Workflow/StepTraceViewer.js +1 -1
- package/build/dist/UI/Components/Workflow/StepTraceViewer.js.map +1 -1
- package/build/dist/UI/Utils/Navigation.js +66 -33
- package/build/dist/UI/Utils/Navigation.js.map +1 -1
- package/build/dist/UI/Utils/PermissionGate.js +4 -2
- package/build/dist/UI/Utils/PermissionGate.js.map +1 -1
- package/build/dist/UI/Utils/Telemetry/TelemetryEntityNames.js +7 -0
- package/build/dist/UI/Utils/Telemetry/TelemetryEntityNames.js.map +1 -1
- package/build/dist/UI/Utils/TelemetryService.js +3 -2
- package/build/dist/UI/Utils/TelemetryService.js.map +1 -1
- package/build/dist/Utils/Incident/IncidentFromAlerts.js +306 -0
- package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -0
- package/build/dist/Utils/LabelRuleImportExport.js +2 -0
- package/build/dist/Utils/LabelRuleImportExport.js.map +1 -1
- package/build/dist/Utils/MetricUnitUtil.js +77 -13
- package/build/dist/Utils/MetricUnitUtil.js.map +1 -1
- package/build/dist/Utils/Monitor/MetricValueFormatter.js +58 -13
- package/build/dist/Utils/Monitor/MetricValueFormatter.js.map +1 -1
- package/build/dist/Utils/Monitor/PlatformMetricUnitUtil.js +161 -0
- package/build/dist/Utils/Monitor/PlatformMetricUnitUtil.js.map +1 -0
- package/build/dist/Utils/Monitor/PlatformResourceIdentity.js +323 -0
- package/build/dist/Utils/Monitor/PlatformResourceIdentity.js.map +1 -0
- package/build/dist/Utils/NetworkDiscovery/DiscoveredHostNamingDiagnosis.js +270 -0
- package/build/dist/Utils/NetworkDiscovery/DiscoveredHostNamingDiagnosis.js.map +1 -0
- package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js +23 -0
- package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js.map +1 -1
- package/build/dist/Utils/NetworkDiscovery/NetbiosNameUtil.js +61 -0
- package/build/dist/Utils/NetworkDiscovery/NetbiosNameUtil.js.map +1 -1
- package/build/dist/Utils/Rum/SessionTraceState.js +154 -0
- package/build/dist/Utils/Rum/SessionTraceState.js.map +1 -0
- package/build/dist/Utils/Telemetry/DatabaseServerEntityKeys.js +178 -0
- package/build/dist/Utils/Telemetry/DatabaseServerEntityKeys.js.map +1 -0
- package/build/dist/Utils/Telemetry/EntityKey.js +133 -0
- package/build/dist/Utils/Telemetry/EntityKey.js.map +1 -1
- package/build/dist/Utils/ValueFormatter.js +15 -0
- package/build/dist/Utils/ValueFormatter.js.map +1 -1
- package/package.json +1 -1
- package/tsconfig.tests.json +7 -5
|
@@ -0,0 +1,3288 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
2
|
+
import {
|
|
3
|
+
act,
|
|
4
|
+
cleanup,
|
|
5
|
+
fireEvent,
|
|
6
|
+
render,
|
|
7
|
+
screen,
|
|
8
|
+
within,
|
|
9
|
+
} from "@testing-library/react";
|
|
10
|
+
/*
|
|
11
|
+
* The Dashboard has its own copy of react; Common's jest moduleNameMapper
|
|
12
|
+
* pins react and react-dom to this project's single copy for every
|
|
13
|
+
* importer (see the note at the top of ReplayStage.test.tsx).
|
|
14
|
+
*/
|
|
15
|
+
import * as React from "react";
|
|
16
|
+
import {
|
|
17
|
+
afterEach,
|
|
18
|
+
beforeEach,
|
|
19
|
+
describe,
|
|
20
|
+
expect,
|
|
21
|
+
it,
|
|
22
|
+
jest,
|
|
23
|
+
} from "@jest/globals";
|
|
24
|
+
import getJestMockFunction, { MockFunction } from "../../MockType";
|
|
25
|
+
import ReplayScreenshotActions, {
|
|
26
|
+
REPLAY_SCREENSHOT_COMPACT_STAGE_PX,
|
|
27
|
+
REPLAY_SCREENSHOT_DONE_MS,
|
|
28
|
+
REPLAY_SCREENSHOT_FLASH_MS,
|
|
29
|
+
REPLAY_SCREENSHOT_PREVIEW_MS,
|
|
30
|
+
ReplayScreenshotActionsProps,
|
|
31
|
+
ReplayScreenshotFailureCopy,
|
|
32
|
+
describeReplayScreenshotFailure,
|
|
33
|
+
formatReplayScreenshotSize,
|
|
34
|
+
getReplayScreenshotDockPositionClassName,
|
|
35
|
+
} from "../../../../App/FeatureSet/Dashboard/src/Components/SessionReplay/ReplayScreenshotActions";
|
|
36
|
+
import {
|
|
37
|
+
ReplayFrameCaptureError,
|
|
38
|
+
ReplayFrameCaptureFailure,
|
|
39
|
+
} from "../../../../App/FeatureSet/Dashboard/src/Components/SessionReplay/ReplayFrameCapture";
|
|
40
|
+
import {
|
|
41
|
+
REPLAY_SCREENSHOT_URL_TTL_MS,
|
|
42
|
+
ReplayClipboardFailure,
|
|
43
|
+
ReplayScreenshot,
|
|
44
|
+
ReplayScreenshotClipboardError,
|
|
45
|
+
} from "../../../../App/FeatureSet/Dashboard/src/Components/SessionReplay/ReplayScreenshot";
|
|
46
|
+
|
|
47
|
+
/*
|
|
48
|
+
* The paused stage's screenshot dock. Pinned: the "Screenshot of this
|
|
49
|
+
* frame" group with Copy image and Download, and a copy title that says
|
|
50
|
+
* why copying is unavailable on this page; the Safari rule - the
|
|
51
|
+
* clipboard is handed the PENDING image synchronously inside the click,
|
|
52
|
+
* before the capture lands; the busy and done button states and the one
|
|
53
|
+
* capture at a time guard; the success card (a thumbnail from an object
|
|
54
|
+
* URL, the size or the file name, the live-region words, the camera
|
|
55
|
+
* flash) that leaves by itself and never leaks its object URL; failure
|
|
56
|
+
* cards whose way out matches the failure - "Download instead" saves the
|
|
57
|
+
* PNG a refused clipboard left behind without drawing it twice, "Try
|
|
58
|
+
* again" re-runs a capture that could not be drawn; a new frame
|
|
59
|
+
* (frameKey) forgets both the card and the PNG; and a capture that lands
|
|
60
|
+
* after the dock unmounted (the viewer pressed Play) touches nothing.
|
|
61
|
+
*
|
|
62
|
+
* And the keyboard never loses its place: a busy button is marked
|
|
63
|
+
* aria-disabled but stays focusable, a card that goes hands focus back to
|
|
64
|
+
* the dock button it was about, and the confirmation does not leave from
|
|
65
|
+
* under a pointer or a keyboard that is on it. On a short stage (a phone)
|
|
66
|
+
* the confirmation is a pill beside the dock instead of a thumbnail card.
|
|
67
|
+
*
|
|
68
|
+
* The component's seams stand in for the capture, the clipboard and the
|
|
69
|
+
* download; the "default wiring" block drives the real ReplayScreenshot
|
|
70
|
+
* helpers against a fake async Clipboard API and a spied anchor instead.
|
|
71
|
+
* Fake timers drive requestAnimationFrame too, so the entrance and the
|
|
72
|
+
* flash are stepped a frame at a time.
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
const FILE_NAME: string = "session-replay-3f9a2c1b-0m41.2s.png";
|
|
76
|
+
const FRAME_MS: number = 16;
|
|
77
|
+
|
|
78
|
+
const COPY_TITLE_SUPPORTED: string =
|
|
79
|
+
"Copy this frame to the clipboard as a PNG";
|
|
80
|
+
const COPY_TITLE_INSECURE: string =
|
|
81
|
+
"Copying images needs a secure (https) page - use Download";
|
|
82
|
+
const COPY_TITLE_UNSUPPORTED: string =
|
|
83
|
+
"This browser can't copy images - use Download";
|
|
84
|
+
|
|
85
|
+
function makeScreenshot(
|
|
86
|
+
overrides?: Partial<ReplayScreenshot>,
|
|
87
|
+
): ReplayScreenshot {
|
|
88
|
+
return {
|
|
89
|
+
blob: new Blob(["png-bytes"], { type: "image/png" }),
|
|
90
|
+
fileName: FILE_NAME,
|
|
91
|
+
width: 1440,
|
|
92
|
+
height: 900,
|
|
93
|
+
...overrides,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface Deferred<T> {
|
|
98
|
+
promise: Promise<T>;
|
|
99
|
+
resolve: (value: T) => void;
|
|
100
|
+
reject: (error: unknown) => void;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function makeDeferred<T>(): Deferred<T> {
|
|
104
|
+
let resolvePromise: (value: T) => void = (_value: T): void => {
|
|
105
|
+
/* Replaced by the executor below. */
|
|
106
|
+
};
|
|
107
|
+
let rejectPromise: (error: unknown) => void = (_error: unknown): void => {
|
|
108
|
+
/* Replaced by the executor below. */
|
|
109
|
+
};
|
|
110
|
+
const promise: Promise<T> = new Promise<T>(
|
|
111
|
+
(resolve: (value: T) => void, reject: (error: unknown) => void): void => {
|
|
112
|
+
resolvePromise = resolve;
|
|
113
|
+
rejectPromise = reject;
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
promise: promise,
|
|
119
|
+
resolve: (value: T): void => {
|
|
120
|
+
resolvePromise(value);
|
|
121
|
+
},
|
|
122
|
+
reject: (error: unknown): void => {
|
|
123
|
+
rejectPromise(error);
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function ignoreRejection(): void {
|
|
129
|
+
/* The test reads this outcome elsewhere. */
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface ActionMocks {
|
|
133
|
+
onCapture: MockFunction;
|
|
134
|
+
copyToClipboard: MockFunction;
|
|
135
|
+
download: MockFunction;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/*
|
|
139
|
+
* A capture that resolves with the screenshot, a clipboard that takes the
|
|
140
|
+
* image once it lands (as a browser does) and a download that works.
|
|
141
|
+
*/
|
|
142
|
+
function makeMocks(screenshot?: ReplayScreenshot): ActionMocks {
|
|
143
|
+
const shot: ReplayScreenshot = screenshot ?? makeScreenshot();
|
|
144
|
+
const onCapture: MockFunction = getJestMockFunction();
|
|
145
|
+
const copyToClipboard: MockFunction = getJestMockFunction();
|
|
146
|
+
const download: MockFunction = getJestMockFunction();
|
|
147
|
+
|
|
148
|
+
onCapture.mockImplementation((): Promise<ReplayScreenshot> => {
|
|
149
|
+
return Promise.resolve(shot);
|
|
150
|
+
});
|
|
151
|
+
copyToClipboard.mockImplementation(
|
|
152
|
+
async (image: Promise<Blob>): Promise<void> => {
|
|
153
|
+
await image;
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
onCapture: onCapture,
|
|
159
|
+
copyToClipboard: copyToClipboard,
|
|
160
|
+
download: download,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function makeProps(
|
|
165
|
+
mocks: ActionMocks,
|
|
166
|
+
overrides?: Partial<ReplayScreenshotActionsProps>,
|
|
167
|
+
): ReplayScreenshotActionsProps {
|
|
168
|
+
return {
|
|
169
|
+
onCapture: mocks.onCapture,
|
|
170
|
+
copyToClipboard: mocks.copyToClipboard,
|
|
171
|
+
download: mocks.download,
|
|
172
|
+
clipboardSupport: "supported",
|
|
173
|
+
fit: "contain",
|
|
174
|
+
frameKey: "1:41200",
|
|
175
|
+
...overrides,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function renderActions(
|
|
180
|
+
mocks: ActionMocks,
|
|
181
|
+
overrides?: Partial<ReplayScreenshotActionsProps>,
|
|
182
|
+
): ReturnType<typeof render> {
|
|
183
|
+
return render(<ReplayScreenshotActions {...makeProps(mocks, overrides)} />);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function rerenderActions(
|
|
187
|
+
view: ReturnType<typeof render>,
|
|
188
|
+
mocks: ActionMocks,
|
|
189
|
+
overrides?: Partial<ReplayScreenshotActionsProps>,
|
|
190
|
+
): void {
|
|
191
|
+
view.rerender(<ReplayScreenshotActions {...makeProps(mocks, overrides)} />);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function copyButton(): HTMLElement {
|
|
195
|
+
return screen.getByTestId("replay-screenshot-copy");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function downloadButton(): HTMLElement {
|
|
199
|
+
return screen.getByTestId("replay-screenshot-download");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function statusText(): string {
|
|
203
|
+
return screen.getByTestId("replay-screenshot-status").textContent ?? "";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function errorCard(): HTMLElement {
|
|
207
|
+
return screen.getByTestId("replay-screenshot-error");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function queryErrorCard(): HTMLElement | null {
|
|
211
|
+
return screen.queryByTestId("replay-screenshot-error");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function previewCard(): HTMLElement {
|
|
215
|
+
return screen.getByTestId("replay-screenshot-preview");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function queryPreviewCard(): HTMLElement | null {
|
|
219
|
+
return screen.queryByTestId("replay-screenshot-preview");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function recoverButton(): HTMLElement {
|
|
223
|
+
return screen.getByTestId("replay-screenshot-recover");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function advance(ms: number): void {
|
|
227
|
+
act((): void => {
|
|
228
|
+
jest.advanceTimersByTime(ms);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function nextFrame(): void {
|
|
233
|
+
advance(FRAME_MS);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function flushMicrotasks(): Promise<void> {
|
|
237
|
+
for (let tick: number = 0; tick < 25; tick++) {
|
|
238
|
+
await Promise.resolve();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* Runs `run` (a resolve, a reject) and lets every promise chain settle. */
|
|
243
|
+
async function settle(run?: () => void): Promise<void> {
|
|
244
|
+
await act(async (): Promise<void> => {
|
|
245
|
+
if (run) {
|
|
246
|
+
run();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
await flushMicrotasks();
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/* Clicks a button and lets the capture it started finish. */
|
|
254
|
+
async function clickAndSettle(button: HTMLElement): Promise<void> {
|
|
255
|
+
fireEvent.click(button);
|
|
256
|
+
await settle();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const DOCK_ACTIONS: Array<"copy" | "download"> = ["copy", "download"];
|
|
260
|
+
|
|
261
|
+
function dockButton(action: "copy" | "download"): HTMLElement {
|
|
262
|
+
return action === "copy" ? copyButton() : downloadButton();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/* Moves the keyboard focus the way Tab would (focus, blur and their bubbling twins). */
|
|
266
|
+
function focusElement(element: HTMLElement): void {
|
|
267
|
+
act((): void => {
|
|
268
|
+
element.focus();
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/*
|
|
273
|
+
* jsdom does no layout: give the dock's column - which spans the stage
|
|
274
|
+
* from top to bottom - the height of the stage it sits in.
|
|
275
|
+
*/
|
|
276
|
+
function setStageHeight(height: number): void {
|
|
277
|
+
Object.defineProperty(
|
|
278
|
+
screen.getByTestId("replay-screenshot"),
|
|
279
|
+
"clientHeight",
|
|
280
|
+
{
|
|
281
|
+
configurable: true,
|
|
282
|
+
get: (): number => {
|
|
283
|
+
return height;
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/* A capture that stays pending until the test settles it. */
|
|
290
|
+
function holdCapture(mocks: ActionMocks): Deferred<ReplayScreenshot> {
|
|
291
|
+
const capture: Deferred<ReplayScreenshot> = makeDeferred<ReplayScreenshot>();
|
|
292
|
+
|
|
293
|
+
mocks.onCapture.mockImplementation((): Promise<ReplayScreenshot> => {
|
|
294
|
+
return capture.promise;
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
return capture;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/* The next capture fails to draw; the ones after it work. */
|
|
301
|
+
function failNextCapture(mocks: ActionMocks): void {
|
|
302
|
+
mocks.onCapture.mockImplementationOnce((): Promise<ReplayScreenshot> => {
|
|
303
|
+
return Promise.reject(
|
|
304
|
+
new ReplayFrameCaptureError("render-failed", "capture"),
|
|
305
|
+
);
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/* A clipboard that takes the drawn image and then refuses it. */
|
|
310
|
+
function refuseClipboard(mocks: ActionMocks): void {
|
|
311
|
+
mocks.copyToClipboard.mockImplementation(
|
|
312
|
+
async (image: Promise<Blob>): Promise<void> => {
|
|
313
|
+
await image;
|
|
314
|
+
|
|
315
|
+
throw new ReplayScreenshotClipboardError("denied", "refused");
|
|
316
|
+
},
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
interface ConsoleSpy {
|
|
321
|
+
mock: { calls: Array<Array<unknown>> };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
class FakeClipboardItem {
|
|
325
|
+
public readonly items: Record<string, Promise<Blob> | Blob>;
|
|
326
|
+
|
|
327
|
+
public constructor(items: Record<string, Promise<Blob> | Blob>) {
|
|
328
|
+
this.items = items;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function installClipboard(input: {
|
|
333
|
+
write: MockFunction | null;
|
|
334
|
+
isSecureContext: boolean;
|
|
335
|
+
}): void {
|
|
336
|
+
Object.defineProperty(window, "isSecureContext", {
|
|
337
|
+
value: input.isSecureContext,
|
|
338
|
+
configurable: true,
|
|
339
|
+
writable: true,
|
|
340
|
+
});
|
|
341
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
342
|
+
value: input.write ? { write: input.write } : undefined,
|
|
343
|
+
configurable: true,
|
|
344
|
+
writable: true,
|
|
345
|
+
});
|
|
346
|
+
Object.defineProperty(window, "ClipboardItem", {
|
|
347
|
+
value: FakeClipboardItem,
|
|
348
|
+
configurable: true,
|
|
349
|
+
writable: true,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function namedError(name: string, message: string): Error {
|
|
354
|
+
const error: Error = new Error(message);
|
|
355
|
+
|
|
356
|
+
error.name = name;
|
|
357
|
+
|
|
358
|
+
return error;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
let createObjectURL: MockFunction;
|
|
362
|
+
let revokeObjectURL: MockFunction;
|
|
363
|
+
|
|
364
|
+
beforeEach((): void => {
|
|
365
|
+
jest.useFakeTimers();
|
|
366
|
+
|
|
367
|
+
let created: number = 0;
|
|
368
|
+
|
|
369
|
+
createObjectURL = getJestMockFunction();
|
|
370
|
+
createObjectURL.mockImplementation((): string => {
|
|
371
|
+
created += 1;
|
|
372
|
+
|
|
373
|
+
return `blob:replay/${created}`;
|
|
374
|
+
});
|
|
375
|
+
revokeObjectURL = getJestMockFunction();
|
|
376
|
+
|
|
377
|
+
Object.defineProperty(URL, "createObjectURL", {
|
|
378
|
+
value: createObjectURL,
|
|
379
|
+
configurable: true,
|
|
380
|
+
writable: true,
|
|
381
|
+
});
|
|
382
|
+
Object.defineProperty(URL, "revokeObjectURL", {
|
|
383
|
+
value: revokeObjectURL,
|
|
384
|
+
configurable: true,
|
|
385
|
+
writable: true,
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
afterEach((): void => {
|
|
390
|
+
/* Unmounted while the fakes are still installed. */
|
|
391
|
+
cleanup();
|
|
392
|
+
jest.useRealTimers();
|
|
393
|
+
jest.restoreAllMocks();
|
|
394
|
+
Reflect.deleteProperty(URL, "createObjectURL");
|
|
395
|
+
Reflect.deleteProperty(URL, "revokeObjectURL");
|
|
396
|
+
Reflect.deleteProperty(window, "isSecureContext");
|
|
397
|
+
Reflect.deleteProperty(window, "ClipboardItem");
|
|
398
|
+
Reflect.deleteProperty(navigator, "clipboard");
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
describe("describeReplayScreenshotFailure", () => {
|
|
402
|
+
interface FailureCase<Reason> {
|
|
403
|
+
reason: Reason;
|
|
404
|
+
title: string;
|
|
405
|
+
description: string;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const clipboardCases: Array<FailureCase<ReplayClipboardFailure>> = [
|
|
409
|
+
{
|
|
410
|
+
reason: "insecure-context",
|
|
411
|
+
title: "Copying images needs HTTPS",
|
|
412
|
+
description:
|
|
413
|
+
"Browsers only let a secure (https) page put images on the clipboard. Download the PNG instead.",
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
reason: "unsupported",
|
|
417
|
+
title: "This browser can't copy images",
|
|
418
|
+
description:
|
|
419
|
+
"It has no image clipboard. Download the PNG instead - it is the same picture.",
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
reason: "denied",
|
|
423
|
+
title: "Clipboard access was blocked",
|
|
424
|
+
description:
|
|
425
|
+
"Allow clipboard access for this site in the browser, or download the PNG instead.",
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
reason: "failed",
|
|
429
|
+
title: "Couldn't copy the image",
|
|
430
|
+
description: "The clipboard did not take it. Download the PNG instead.",
|
|
431
|
+
},
|
|
432
|
+
];
|
|
433
|
+
|
|
434
|
+
const captureCases: Array<FailureCase<ReplayFrameCaptureFailure>> = [
|
|
435
|
+
{
|
|
436
|
+
reason: "no-document",
|
|
437
|
+
title: "The frame isn't ready yet",
|
|
438
|
+
description:
|
|
439
|
+
"The replay has not finished drawing this moment. Give it a second and try again.",
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
reason: "empty-viewport",
|
|
443
|
+
title: "The frame isn't ready yet",
|
|
444
|
+
description:
|
|
445
|
+
"The replay has not finished drawing this moment. Give it a second and try again.",
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
reason: "encode-failed",
|
|
449
|
+
title: "Couldn't save this frame",
|
|
450
|
+
description: "The browser would not export the picture as a PNG.",
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
reason: "render-failed",
|
|
454
|
+
title: "Couldn't capture this frame",
|
|
455
|
+
description: "The browser could not draw this recorded page as an image.",
|
|
456
|
+
},
|
|
457
|
+
];
|
|
458
|
+
|
|
459
|
+
clipboardCases.forEach(
|
|
460
|
+
(testCase: FailureCase<ReplayClipboardFailure>): void => {
|
|
461
|
+
it(`offers the download when the clipboard fails with ${testCase.reason}`, () => {
|
|
462
|
+
const copy: ReplayScreenshotFailureCopy =
|
|
463
|
+
describeReplayScreenshotFailure(
|
|
464
|
+
new ReplayScreenshotClipboardError(testCase.reason, "message"),
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
expect(copy).toEqual({
|
|
468
|
+
title: testCase.title,
|
|
469
|
+
description: testCase.description,
|
|
470
|
+
recovery: "download",
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
},
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
captureCases.forEach(
|
|
477
|
+
(testCase: FailureCase<ReplayFrameCaptureFailure>): void => {
|
|
478
|
+
it(`offers a retry when the capture fails with ${testCase.reason}`, () => {
|
|
479
|
+
const copy: ReplayScreenshotFailureCopy =
|
|
480
|
+
describeReplayScreenshotFailure(
|
|
481
|
+
new ReplayFrameCaptureError(testCase.reason, "message"),
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
expect(copy).toEqual({
|
|
485
|
+
title: testCase.title,
|
|
486
|
+
description: testCase.description,
|
|
487
|
+
recovery: "retry",
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
},
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
it("treats an unknown clipboard reason as a copy that did not take", () => {
|
|
494
|
+
expect(
|
|
495
|
+
describeReplayScreenshotFailure(
|
|
496
|
+
new ReplayScreenshotClipboardError(
|
|
497
|
+
"quota" as ReplayClipboardFailure,
|
|
498
|
+
"message",
|
|
499
|
+
),
|
|
500
|
+
),
|
|
501
|
+
).toEqual({
|
|
502
|
+
title: "Couldn't copy the image",
|
|
503
|
+
description: "The clipboard did not take it. Download the PNG instead.",
|
|
504
|
+
recovery: "download",
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("treats an unknown capture reason as a frame that could not be drawn", () => {
|
|
509
|
+
expect(
|
|
510
|
+
describeReplayScreenshotFailure(
|
|
511
|
+
new ReplayFrameCaptureError(
|
|
512
|
+
"tainted" as ReplayFrameCaptureFailure,
|
|
513
|
+
"message",
|
|
514
|
+
),
|
|
515
|
+
),
|
|
516
|
+
).toEqual({
|
|
517
|
+
title: "Couldn't capture this frame",
|
|
518
|
+
description: "The browser could not draw this recorded page as an image.",
|
|
519
|
+
recovery: "retry",
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("falls back to a generic retry for anything that is not a screenshot error", () => {
|
|
524
|
+
const generic: ReplayScreenshotFailureCopy = {
|
|
525
|
+
title: "Couldn't capture this frame",
|
|
526
|
+
description: "Something went wrong while drawing the picture.",
|
|
527
|
+
recovery: "retry",
|
|
528
|
+
};
|
|
529
|
+
const lookalike: Error = namedError(
|
|
530
|
+
"ReplayScreenshotClipboardError",
|
|
531
|
+
"denied",
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
expect(describeReplayScreenshotFailure(new Error("boom"))).toEqual(generic);
|
|
535
|
+
expect(describeReplayScreenshotFailure("boom")).toEqual(generic);
|
|
536
|
+
expect(describeReplayScreenshotFailure(null)).toEqual(generic);
|
|
537
|
+
expect(describeReplayScreenshotFailure(undefined)).toEqual(generic);
|
|
538
|
+
expect(describeReplayScreenshotFailure({ reason: "denied" })).toEqual(
|
|
539
|
+
generic,
|
|
540
|
+
);
|
|
541
|
+
expect(describeReplayScreenshotFailure(lookalike)).toEqual(generic);
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
describe("getReplayScreenshotDockPositionClassName", () => {
|
|
546
|
+
it("keeps the dock clear of the scrollbars each fit can show", () => {
|
|
547
|
+
expect(getReplayScreenshotDockPositionClassName("contain")).toBe(
|
|
548
|
+
"bottom-3 right-3",
|
|
549
|
+
);
|
|
550
|
+
expect(getReplayScreenshotDockPositionClassName("width")).toBe(
|
|
551
|
+
"bottom-3 right-6",
|
|
552
|
+
);
|
|
553
|
+
expect(getReplayScreenshotDockPositionClassName("actual")).toBe(
|
|
554
|
+
"bottom-6 right-6",
|
|
555
|
+
);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it("uses the contain corner when no fit is known", () => {
|
|
559
|
+
expect(getReplayScreenshotDockPositionClassName(undefined)).toBe(
|
|
560
|
+
"bottom-3 right-3",
|
|
561
|
+
);
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
describe("formatReplayScreenshotSize", () => {
|
|
566
|
+
it("writes the size with a multiplication sign", () => {
|
|
567
|
+
expect(formatReplayScreenshotSize(1440, 900)).toBe("1440 × 900");
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it("rounds both sides to whole pixels", () => {
|
|
571
|
+
expect(formatReplayScreenshotSize(1439.6, 899.5)).toBe("1440 × 900");
|
|
572
|
+
expect(formatReplayScreenshotSize(1280.4, 719.49)).toBe("1280 × 719");
|
|
573
|
+
expect(formatReplayScreenshotSize(390.5, 844.2)).toBe("391 × 844");
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
describe("ReplayScreenshotActions", () => {
|
|
578
|
+
describe("the dock", () => {
|
|
579
|
+
it("offers Copy image and Download in a labelled group", () => {
|
|
580
|
+
renderActions(makeMocks());
|
|
581
|
+
|
|
582
|
+
const group: HTMLElement = screen.getByRole("group", {
|
|
583
|
+
name: "Screenshot of this frame",
|
|
584
|
+
});
|
|
585
|
+
const copy: HTMLElement = within(group).getByRole("button", {
|
|
586
|
+
name: "Copy image",
|
|
587
|
+
});
|
|
588
|
+
const download: HTMLElement = within(group).getByRole("button", {
|
|
589
|
+
name: "Download",
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
expect(copy).toBe(copyButton());
|
|
593
|
+
expect(download).toBe(downloadButton());
|
|
594
|
+
expect(copy).toHaveAttribute("title", COPY_TITLE_SUPPORTED);
|
|
595
|
+
expect(download).toHaveAttribute("title", "Download this frame as a PNG");
|
|
596
|
+
expect(copy).toHaveAttribute("type", "button");
|
|
597
|
+
expect(download).toHaveAttribute("type", "button");
|
|
598
|
+
expect(copy).toHaveTextContent("Copy image");
|
|
599
|
+
expect(download).toHaveTextContent("Download");
|
|
600
|
+
expect(copy).toBeEnabled();
|
|
601
|
+
expect(download).toBeEnabled();
|
|
602
|
+
expect(copy).toHaveAttribute("data-state", "idle");
|
|
603
|
+
expect(download).toHaveAttribute("data-state", "idle");
|
|
604
|
+
expect(copy).not.toHaveAttribute("aria-busy");
|
|
605
|
+
expect(download).not.toHaveAttribute("aria-busy");
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it("says in the copy title why copying is unavailable", () => {
|
|
609
|
+
const mocks: ActionMocks = makeMocks();
|
|
610
|
+
const view: ReturnType<typeof render> = renderActions(mocks, {
|
|
611
|
+
clipboardSupport: "insecure-context",
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
expect(copyButton()).toHaveAttribute("title", COPY_TITLE_INSECURE);
|
|
615
|
+
|
|
616
|
+
rerenderActions(view, mocks, { clipboardSupport: "unsupported" });
|
|
617
|
+
|
|
618
|
+
expect(copyButton()).toHaveAttribute("title", COPY_TITLE_UNSUPPORTED);
|
|
619
|
+
/* Still offered: pressing it explains, and points at Download. */
|
|
620
|
+
expect(copyButton()).toBeEnabled();
|
|
621
|
+
expect(downloadButton()).toHaveAttribute(
|
|
622
|
+
"title",
|
|
623
|
+
"Download this frame as a PNG",
|
|
624
|
+
);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it("reads the browser's clipboard support when none is passed", () => {
|
|
628
|
+
const mocks: ActionMocks = makeMocks();
|
|
629
|
+
const write: MockFunction = getJestMockFunction();
|
|
630
|
+
|
|
631
|
+
const plain: ReturnType<typeof render> = renderActions(mocks, {
|
|
632
|
+
clipboardSupport: undefined,
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
/* jsdom: no navigator.clipboard, no ClipboardItem. */
|
|
636
|
+
expect(copyButton()).toHaveAttribute("title", COPY_TITLE_UNSUPPORTED);
|
|
637
|
+
plain.unmount();
|
|
638
|
+
|
|
639
|
+
installClipboard({ write: write, isSecureContext: false });
|
|
640
|
+
|
|
641
|
+
const insecure: ReturnType<typeof render> = renderActions(mocks, {
|
|
642
|
+
clipboardSupport: undefined,
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
expect(copyButton()).toHaveAttribute("title", COPY_TITLE_INSECURE);
|
|
646
|
+
insecure.unmount();
|
|
647
|
+
|
|
648
|
+
installClipboard({ write: write, isSecureContext: true });
|
|
649
|
+
renderActions(mocks, { clipboardSupport: undefined });
|
|
650
|
+
|
|
651
|
+
expect(copyButton()).toHaveAttribute("title", COPY_TITLE_SUPPORTED);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("fades the dock in on the next animation frame", () => {
|
|
655
|
+
renderActions(makeMocks());
|
|
656
|
+
|
|
657
|
+
const group: HTMLElement = screen.getByTestId(
|
|
658
|
+
"replay-screenshot-actions",
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
expect(group).toHaveClass("translate-y-1", "opacity-0");
|
|
662
|
+
expect(group).not.toHaveClass("opacity-100");
|
|
663
|
+
|
|
664
|
+
nextFrame();
|
|
665
|
+
|
|
666
|
+
expect(group).toHaveClass("translate-y-0", "opacity-100");
|
|
667
|
+
expect(group).not.toHaveClass("opacity-0");
|
|
668
|
+
expect(group).not.toHaveClass("translate-y-1");
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("sits clear of the scrollbars of the current fit", () => {
|
|
672
|
+
const mocks: ActionMocks = makeMocks();
|
|
673
|
+
const view: ReturnType<typeof render> = renderActions(mocks, {
|
|
674
|
+
fit: "contain",
|
|
675
|
+
});
|
|
676
|
+
const root: HTMLElement = screen.getByTestId("replay-screenshot");
|
|
677
|
+
|
|
678
|
+
expect(root).toHaveClass("absolute", "bottom-3", "right-3");
|
|
679
|
+
|
|
680
|
+
rerenderActions(view, mocks, { fit: "width" });
|
|
681
|
+
expect(root).toHaveClass("bottom-3", "right-6");
|
|
682
|
+
expect(root).not.toHaveClass("right-3");
|
|
683
|
+
|
|
684
|
+
rerenderActions(view, mocks, { fit: "actual" });
|
|
685
|
+
expect(root).toHaveClass("bottom-6", "right-6");
|
|
686
|
+
expect(root).not.toHaveClass("bottom-3");
|
|
687
|
+
|
|
688
|
+
rerenderActions(view, mocks, { fit: undefined });
|
|
689
|
+
expect(root).toHaveClass("bottom-3", "right-3");
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it("lets clicks through to the stage everywhere but the buttons", () => {
|
|
693
|
+
renderActions(makeMocks());
|
|
694
|
+
|
|
695
|
+
expect(screen.getByTestId("replay-screenshot")).toHaveClass(
|
|
696
|
+
"pointer-events-none",
|
|
697
|
+
);
|
|
698
|
+
expect(screen.getByTestId("replay-screenshot-actions")).toHaveClass(
|
|
699
|
+
"pointer-events-auto",
|
|
700
|
+
);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it("shows no card, flash or announcement before anything is captured", () => {
|
|
704
|
+
const mocks: ActionMocks = makeMocks();
|
|
705
|
+
|
|
706
|
+
renderActions(mocks);
|
|
707
|
+
nextFrame();
|
|
708
|
+
|
|
709
|
+
expect(queryErrorCard()).not.toBeInTheDocument();
|
|
710
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
711
|
+
expect(
|
|
712
|
+
screen.queryByTestId("replay-screenshot-flash"),
|
|
713
|
+
).not.toBeInTheDocument();
|
|
714
|
+
expect(statusText()).toBe("");
|
|
715
|
+
expect(screen.getByTestId("replay-screenshot-status")).toHaveAttribute(
|
|
716
|
+
"aria-live",
|
|
717
|
+
"polite",
|
|
718
|
+
);
|
|
719
|
+
expect(screen.getByRole("status")).toHaveClass("sr-only");
|
|
720
|
+
expect(mocks.onCapture).not.toHaveBeenCalled();
|
|
721
|
+
expect(createObjectURL).not.toHaveBeenCalled();
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
describe("copy image", () => {
|
|
726
|
+
it("hands the clipboard the pending image synchronously inside the click", async () => {
|
|
727
|
+
const mocks: ActionMocks = makeMocks();
|
|
728
|
+
const capture: Deferred<ReplayScreenshot> =
|
|
729
|
+
makeDeferred<ReplayScreenshot>();
|
|
730
|
+
const shot: ReplayScreenshot = makeScreenshot();
|
|
731
|
+
|
|
732
|
+
mocks.onCapture.mockImplementation((): Promise<ReplayScreenshot> => {
|
|
733
|
+
return capture.promise;
|
|
734
|
+
});
|
|
735
|
+
renderActions(mocks);
|
|
736
|
+
|
|
737
|
+
fireEvent.click(copyButton());
|
|
738
|
+
|
|
739
|
+
/* Nothing has been awaited: the capture is still running. */
|
|
740
|
+
expect(mocks.onCapture).toHaveBeenCalledTimes(1);
|
|
741
|
+
expect(mocks.copyToClipboard).toHaveBeenCalledTimes(1);
|
|
742
|
+
expect(mocks.onCapture.mock.invocationCallOrder[0]).toBeLessThan(
|
|
743
|
+
mocks.copyToClipboard.mock.invocationCallOrder[0] as number,
|
|
744
|
+
);
|
|
745
|
+
|
|
746
|
+
const image: unknown = mocks.copyToClipboard.mock.calls[0]?.[0];
|
|
747
|
+
|
|
748
|
+
expect(image).toBeInstanceOf(Promise);
|
|
749
|
+
|
|
750
|
+
await settle((): void => {
|
|
751
|
+
capture.resolve(shot);
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
/* The clipboard gets the PNG itself, not the screenshot record. */
|
|
755
|
+
await expect(image as Promise<Blob>).resolves.toBe(shot.blob);
|
|
756
|
+
expect(mocks.copyToClipboard).toHaveBeenCalledTimes(1);
|
|
757
|
+
expect(mocks.download).not.toHaveBeenCalled();
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
it("spins the pressed button and marks both unavailable while the frame is drawn, without taking focus away", async () => {
|
|
761
|
+
const mocks: ActionMocks = makeMocks();
|
|
762
|
+
const capture: Deferred<ReplayScreenshot> =
|
|
763
|
+
makeDeferred<ReplayScreenshot>();
|
|
764
|
+
|
|
765
|
+
mocks.onCapture.mockImplementation((): Promise<ReplayScreenshot> => {
|
|
766
|
+
return capture.promise;
|
|
767
|
+
});
|
|
768
|
+
renderActions(mocks);
|
|
769
|
+
|
|
770
|
+
copyButton().focus();
|
|
771
|
+
fireEvent.click(copyButton());
|
|
772
|
+
|
|
773
|
+
/*
|
|
774
|
+
* aria-disabled, not disabled: a disabled button drops the keyboard
|
|
775
|
+
* focus to <body>, where the next Space plays the replay.
|
|
776
|
+
*/
|
|
777
|
+
expect(copyButton()).toBeEnabled();
|
|
778
|
+
expect(copyButton()).toHaveAttribute("aria-disabled", "true");
|
|
779
|
+
expect(downloadButton()).toHaveAttribute("aria-disabled", "true");
|
|
780
|
+
expect(copyButton()).toHaveFocus();
|
|
781
|
+
expect(copyButton()).toHaveAttribute("aria-busy", "true");
|
|
782
|
+
expect(copyButton()).toHaveAttribute("data-state", "busy");
|
|
783
|
+
expect(copyButton().querySelector(".animate-spin")).not.toBeNull();
|
|
784
|
+
expect(downloadButton()).not.toHaveAttribute("aria-busy");
|
|
785
|
+
expect(downloadButton()).toHaveAttribute("data-state", "idle");
|
|
786
|
+
expect(downloadButton().querySelector(".animate-spin")).toBeNull();
|
|
787
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
788
|
+
expect(
|
|
789
|
+
screen.queryByTestId("replay-screenshot-flash"),
|
|
790
|
+
).not.toBeInTheDocument();
|
|
791
|
+
expect(statusText()).toBe("");
|
|
792
|
+
|
|
793
|
+
await settle((): void => {
|
|
794
|
+
capture.resolve(makeScreenshot());
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
expect(copyButton()).not.toHaveAttribute("aria-disabled");
|
|
798
|
+
expect(downloadButton()).not.toHaveAttribute("aria-disabled");
|
|
799
|
+
expect(copyButton()).not.toHaveAttribute("aria-busy");
|
|
800
|
+
expect(copyButton()).toHaveFocus();
|
|
801
|
+
expect(copyButton().querySelector(".animate-spin")).toBeNull();
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it("waits for the clipboard write as well as the capture", async () => {
|
|
805
|
+
const mocks: ActionMocks = makeMocks();
|
|
806
|
+
const write: Deferred<void> = makeDeferred<void>();
|
|
807
|
+
|
|
808
|
+
mocks.copyToClipboard.mockImplementation(
|
|
809
|
+
(image: Promise<Blob>): Promise<void> => {
|
|
810
|
+
image.catch(ignoreRejection);
|
|
811
|
+
|
|
812
|
+
return write.promise;
|
|
813
|
+
},
|
|
814
|
+
);
|
|
815
|
+
renderActions(mocks);
|
|
816
|
+
|
|
817
|
+
await clickAndSettle(copyButton());
|
|
818
|
+
|
|
819
|
+
/* The frame is drawn; the clipboard has not answered yet. */
|
|
820
|
+
expect(copyButton()).toHaveAttribute("data-state", "busy");
|
|
821
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
822
|
+
|
|
823
|
+
await settle((): void => {
|
|
824
|
+
write.resolve();
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
expect(copyButton()).toHaveAttribute("data-state", "done");
|
|
828
|
+
expect(previewCard()).toHaveAttribute("data-action", "copy");
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
it("confirms the copy with a thumbnail, the size and an announcement", async () => {
|
|
832
|
+
const shot: ReplayScreenshot = makeScreenshot();
|
|
833
|
+
const mocks: ActionMocks = makeMocks(shot);
|
|
834
|
+
|
|
835
|
+
renderActions(mocks);
|
|
836
|
+
nextFrame();
|
|
837
|
+
|
|
838
|
+
await clickAndSettle(copyButton());
|
|
839
|
+
|
|
840
|
+
expect(copyButton()).toHaveAttribute("data-state", "done");
|
|
841
|
+
expect(copyButton()).not.toHaveAttribute("aria-busy");
|
|
842
|
+
expect(copyButton().querySelector(".text-emerald-300")).not.toBeNull();
|
|
843
|
+
expect(downloadButton()).toHaveAttribute("data-state", "idle");
|
|
844
|
+
|
|
845
|
+
const card: HTMLElement = previewCard();
|
|
846
|
+
|
|
847
|
+
expect(card).toHaveAttribute("data-action", "copy");
|
|
848
|
+
expect(card).toHaveTextContent("Copied to clipboard");
|
|
849
|
+
|
|
850
|
+
const detail: HTMLElement = screen.getByTestId(
|
|
851
|
+
"replay-screenshot-preview-detail",
|
|
852
|
+
);
|
|
853
|
+
|
|
854
|
+
expect(detail).toHaveTextContent("1440 × 900 PNG");
|
|
855
|
+
expect(detail).toHaveAttribute("title", FILE_NAME);
|
|
856
|
+
|
|
857
|
+
const thumbnail: HTMLImageElement = screen.getByTestId(
|
|
858
|
+
"replay-screenshot-thumbnail",
|
|
859
|
+
) as HTMLImageElement;
|
|
860
|
+
|
|
861
|
+
expect(createObjectURL).toHaveBeenCalledTimes(1);
|
|
862
|
+
expect(createObjectURL).toHaveBeenCalledWith(shot.blob);
|
|
863
|
+
expect(thumbnail).toHaveAttribute("src", "blob:replay/1");
|
|
864
|
+
expect(thumbnail).toHaveAttribute("alt", "The captured frame");
|
|
865
|
+
expect(thumbnail.style.aspectRatio).toBe("1440 / 900");
|
|
866
|
+
|
|
867
|
+
expect(statusText()).toBe("Screenshot copied to the clipboard.");
|
|
868
|
+
expect(screen.getByTestId("replay-screenshot-flash")).toBeInTheDocument();
|
|
869
|
+
expect(queryErrorCard()).not.toBeInTheDocument();
|
|
870
|
+
expect(revokeObjectURL).not.toHaveBeenCalled();
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
it("flashes the stage at full brightness, then fades the flash out", async () => {
|
|
874
|
+
renderActions(makeMocks());
|
|
875
|
+
nextFrame();
|
|
876
|
+
|
|
877
|
+
await clickAndSettle(copyButton());
|
|
878
|
+
|
|
879
|
+
const flash: HTMLElement = screen.getByTestId("replay-screenshot-flash");
|
|
880
|
+
|
|
881
|
+
expect(flash).toHaveAttribute("aria-hidden", "true");
|
|
882
|
+
expect(flash).toHaveClass("opacity-60", "motion-reduce:hidden");
|
|
883
|
+
expect(flash).not.toHaveClass("transition-opacity");
|
|
884
|
+
|
|
885
|
+
/* Held for a painted frame: one frame is not enough to fade from. */
|
|
886
|
+
nextFrame();
|
|
887
|
+
expect(screen.getByTestId("replay-screenshot-flash")).toHaveClass(
|
|
888
|
+
"opacity-60",
|
|
889
|
+
);
|
|
890
|
+
|
|
891
|
+
nextFrame();
|
|
892
|
+
expect(screen.getByTestId("replay-screenshot-flash")).toHaveClass(
|
|
893
|
+
"opacity-0",
|
|
894
|
+
"transition-opacity",
|
|
895
|
+
);
|
|
896
|
+
expect(screen.getByTestId("replay-screenshot-flash")).not.toHaveClass(
|
|
897
|
+
"opacity-60",
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
advance(REPLAY_SCREENSHOT_FLASH_MS - 1);
|
|
901
|
+
expect(screen.getByTestId("replay-screenshot-flash")).toBeInTheDocument();
|
|
902
|
+
|
|
903
|
+
advance(1);
|
|
904
|
+
expect(
|
|
905
|
+
screen.queryByTestId("replay-screenshot-flash"),
|
|
906
|
+
).not.toBeInTheDocument();
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
it("puts the button back to idle after the done interval", async () => {
|
|
910
|
+
renderActions(makeMocks());
|
|
911
|
+
|
|
912
|
+
await clickAndSettle(copyButton());
|
|
913
|
+
|
|
914
|
+
advance(REPLAY_SCREENSHOT_DONE_MS - 1);
|
|
915
|
+
expect(copyButton()).toHaveAttribute("data-state", "done");
|
|
916
|
+
|
|
917
|
+
advance(1);
|
|
918
|
+
expect(copyButton()).toHaveAttribute("data-state", "idle");
|
|
919
|
+
expect(copyButton().querySelector(".text-emerald-300")).toBeNull();
|
|
920
|
+
/* The card outlives the check mark. */
|
|
921
|
+
expect(previewCard()).toBeInTheDocument();
|
|
922
|
+
expect(statusText()).toBe("Screenshot copied to the clipboard.");
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
it("takes the card away after the preview interval and revokes its thumbnail", async () => {
|
|
926
|
+
renderActions(makeMocks());
|
|
927
|
+
|
|
928
|
+
await clickAndSettle(copyButton());
|
|
929
|
+
|
|
930
|
+
advance(REPLAY_SCREENSHOT_PREVIEW_MS - 1);
|
|
931
|
+
expect(previewCard()).toBeInTheDocument();
|
|
932
|
+
expect(revokeObjectURL).not.toHaveBeenCalled();
|
|
933
|
+
|
|
934
|
+
advance(1);
|
|
935
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
936
|
+
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
|
|
937
|
+
expect(revokeObjectURL).toHaveBeenCalledWith("blob:replay/1");
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
it("rounds a fractional recorded viewport on the card", async () => {
|
|
941
|
+
renderActions(
|
|
942
|
+
makeMocks(makeScreenshot({ width: 1439.6, height: 899.5 })),
|
|
943
|
+
);
|
|
944
|
+
|
|
945
|
+
await clickAndSettle(copyButton());
|
|
946
|
+
|
|
947
|
+
expect(
|
|
948
|
+
screen.getByTestId("replay-screenshot-preview-detail"),
|
|
949
|
+
).toHaveTextContent("1440 × 900 PNG");
|
|
950
|
+
expect(
|
|
951
|
+
(screen.getByTestId("replay-screenshot-thumbnail") as HTMLImageElement)
|
|
952
|
+
.style.aspectRatio,
|
|
953
|
+
).toBe("1439.6 / 899.5");
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
it("confirms without a thumbnail when the browser has no object URLs", async () => {
|
|
957
|
+
Reflect.deleteProperty(URL, "createObjectURL");
|
|
958
|
+
renderActions(makeMocks());
|
|
959
|
+
|
|
960
|
+
await clickAndSettle(copyButton());
|
|
961
|
+
|
|
962
|
+
expect(previewCard()).toHaveTextContent("Copied to clipboard");
|
|
963
|
+
expect(
|
|
964
|
+
screen.queryByTestId("replay-screenshot-thumbnail"),
|
|
965
|
+
).not.toBeInTheDocument();
|
|
966
|
+
|
|
967
|
+
advance(REPLAY_SCREENSHOT_PREVIEW_MS);
|
|
968
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
969
|
+
expect(revokeObjectURL).not.toHaveBeenCalled();
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
it("confirms without a thumbnail when making the object URL throws", async () => {
|
|
973
|
+
createObjectURL.mockImplementation((): string => {
|
|
974
|
+
throw new Error("Blob URLs are disabled");
|
|
975
|
+
});
|
|
976
|
+
renderActions(makeMocks());
|
|
977
|
+
|
|
978
|
+
await clickAndSettle(copyButton());
|
|
979
|
+
|
|
980
|
+
expect(previewCard()).toHaveAttribute("data-action", "copy");
|
|
981
|
+
expect(
|
|
982
|
+
screen.queryByTestId("replay-screenshot-thumbnail"),
|
|
983
|
+
).not.toBeInTheDocument();
|
|
984
|
+
expect(statusText()).toBe("Screenshot copied to the clipboard.");
|
|
985
|
+
});
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
describe("download", () => {
|
|
989
|
+
it("saves the captured screenshot and names the file on the card", async () => {
|
|
990
|
+
const shot: ReplayScreenshot = makeScreenshot();
|
|
991
|
+
const mocks: ActionMocks = makeMocks(shot);
|
|
992
|
+
|
|
993
|
+
renderActions(mocks);
|
|
994
|
+
|
|
995
|
+
fireEvent.click(downloadButton());
|
|
996
|
+
/* Saved once the frame is drawn, not before. */
|
|
997
|
+
expect(mocks.download).not.toHaveBeenCalled();
|
|
998
|
+
|
|
999
|
+
await settle();
|
|
1000
|
+
|
|
1001
|
+
expect(mocks.onCapture).toHaveBeenCalledTimes(1);
|
|
1002
|
+
expect(mocks.download).toHaveBeenCalledTimes(1);
|
|
1003
|
+
expect(mocks.download.mock.calls[0]?.[0]).toBe(shot);
|
|
1004
|
+
expect(mocks.copyToClipboard).not.toHaveBeenCalled();
|
|
1005
|
+
|
|
1006
|
+
expect(downloadButton()).toHaveAttribute("data-state", "done");
|
|
1007
|
+
expect(copyButton()).toHaveAttribute("data-state", "idle");
|
|
1008
|
+
|
|
1009
|
+
const card: HTMLElement = previewCard();
|
|
1010
|
+
|
|
1011
|
+
expect(card).toHaveAttribute("data-action", "download");
|
|
1012
|
+
expect(card).toHaveTextContent("Screenshot downloaded");
|
|
1013
|
+
expect(card).not.toHaveTextContent("Copied to clipboard");
|
|
1014
|
+
|
|
1015
|
+
const detail: HTMLElement = screen.getByTestId(
|
|
1016
|
+
"replay-screenshot-preview-detail",
|
|
1017
|
+
);
|
|
1018
|
+
|
|
1019
|
+
expect(detail).toHaveTextContent(FILE_NAME);
|
|
1020
|
+
expect(detail).toHaveAttribute("title", FILE_NAME);
|
|
1021
|
+
expect(detail).not.toHaveTextContent("PNG");
|
|
1022
|
+
expect(screen.getByTestId("replay-screenshot-thumbnail")).toHaveAttribute(
|
|
1023
|
+
"src",
|
|
1024
|
+
"blob:replay/1",
|
|
1025
|
+
);
|
|
1026
|
+
expect(screen.getByTestId("replay-screenshot-flash")).toBeInTheDocument();
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
it("announces the file name", async () => {
|
|
1030
|
+
renderActions(makeMocks());
|
|
1031
|
+
|
|
1032
|
+
await clickAndSettle(downloadButton());
|
|
1033
|
+
|
|
1034
|
+
expect(statusText()).toBe(`Screenshot downloaded as ${FILE_NAME}.`);
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
it("spins the Download button while the frame is drawn", async () => {
|
|
1038
|
+
const mocks: ActionMocks = makeMocks();
|
|
1039
|
+
const capture: Deferred<ReplayScreenshot> =
|
|
1040
|
+
makeDeferred<ReplayScreenshot>();
|
|
1041
|
+
|
|
1042
|
+
mocks.onCapture.mockImplementation((): Promise<ReplayScreenshot> => {
|
|
1043
|
+
return capture.promise;
|
|
1044
|
+
});
|
|
1045
|
+
renderActions(mocks);
|
|
1046
|
+
|
|
1047
|
+
fireEvent.click(downloadButton());
|
|
1048
|
+
|
|
1049
|
+
expect(downloadButton()).toHaveAttribute("data-state", "busy");
|
|
1050
|
+
expect(downloadButton()).toHaveAttribute("aria-busy", "true");
|
|
1051
|
+
expect(downloadButton()).toHaveAttribute("aria-disabled", "true");
|
|
1052
|
+
expect(copyButton()).toHaveAttribute("aria-disabled", "true");
|
|
1053
|
+
expect(copyButton()).toHaveAttribute("data-state", "idle");
|
|
1054
|
+
expect(copyButton()).not.toHaveAttribute("aria-busy");
|
|
1055
|
+
|
|
1056
|
+
await settle((): void => {
|
|
1057
|
+
capture.resolve(makeScreenshot());
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
expect(mocks.download).toHaveBeenCalledTimes(1);
|
|
1061
|
+
expect(downloadButton()).toHaveAttribute("data-state", "done");
|
|
1062
|
+
expect(downloadButton()).toBeEnabled();
|
|
1063
|
+
|
|
1064
|
+
advance(REPLAY_SCREENSHOT_DONE_MS);
|
|
1065
|
+
expect(downloadButton()).toHaveAttribute("data-state", "idle");
|
|
1066
|
+
});
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
describe("one capture at a time", () => {
|
|
1070
|
+
it("ignores a second click on the same button in the same frame", async () => {
|
|
1071
|
+
const mocks: ActionMocks = makeMocks();
|
|
1072
|
+
|
|
1073
|
+
renderActions(mocks);
|
|
1074
|
+
|
|
1075
|
+
/* Both clicks land before React re-renders the busy state. */
|
|
1076
|
+
act((): void => {
|
|
1077
|
+
fireEvent.click(copyButton());
|
|
1078
|
+
/* Only the busy guard stands in the way. */
|
|
1079
|
+
expect(copyButton()).toBeEnabled();
|
|
1080
|
+
fireEvent.click(copyButton());
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
await settle();
|
|
1084
|
+
|
|
1085
|
+
expect(mocks.onCapture).toHaveBeenCalledTimes(1);
|
|
1086
|
+
expect(mocks.copyToClipboard).toHaveBeenCalledTimes(1);
|
|
1087
|
+
expect(createObjectURL).toHaveBeenCalledTimes(1);
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
it("ignores the other button while a capture is running", async () => {
|
|
1091
|
+
const mocks: ActionMocks = makeMocks();
|
|
1092
|
+
|
|
1093
|
+
renderActions(mocks);
|
|
1094
|
+
|
|
1095
|
+
act((): void => {
|
|
1096
|
+
fireEvent.click(downloadButton());
|
|
1097
|
+
expect(copyButton()).toBeEnabled();
|
|
1098
|
+
fireEvent.click(copyButton());
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
await settle();
|
|
1102
|
+
|
|
1103
|
+
expect(mocks.onCapture).toHaveBeenCalledTimes(1);
|
|
1104
|
+
expect(mocks.copyToClipboard).not.toHaveBeenCalled();
|
|
1105
|
+
expect(mocks.download).toHaveBeenCalledTimes(1);
|
|
1106
|
+
expect(previewCard()).toHaveAttribute("data-action", "download");
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
it("does not start a capture from a button marked busy", async () => {
|
|
1110
|
+
const mocks: ActionMocks = makeMocks();
|
|
1111
|
+
const capture: Deferred<ReplayScreenshot> =
|
|
1112
|
+
makeDeferred<ReplayScreenshot>();
|
|
1113
|
+
|
|
1114
|
+
mocks.onCapture.mockImplementation((): Promise<ReplayScreenshot> => {
|
|
1115
|
+
return capture.promise;
|
|
1116
|
+
});
|
|
1117
|
+
renderActions(mocks);
|
|
1118
|
+
|
|
1119
|
+
fireEvent.click(copyButton());
|
|
1120
|
+
fireEvent.click(downloadButton());
|
|
1121
|
+
fireEvent.click(copyButton());
|
|
1122
|
+
|
|
1123
|
+
expect(mocks.onCapture).toHaveBeenCalledTimes(1);
|
|
1124
|
+
expect(mocks.copyToClipboard).toHaveBeenCalledTimes(1);
|
|
1125
|
+
|
|
1126
|
+
await settle((): void => {
|
|
1127
|
+
capture.resolve(makeScreenshot());
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
expect(mocks.download).not.toHaveBeenCalled();
|
|
1131
|
+
expect(previewCard()).toHaveAttribute("data-action", "copy");
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
it("accepts the next capture as soon as the last one finished", async () => {
|
|
1135
|
+
const mocks: ActionMocks = makeMocks();
|
|
1136
|
+
|
|
1137
|
+
renderActions(mocks);
|
|
1138
|
+
|
|
1139
|
+
await clickAndSettle(copyButton());
|
|
1140
|
+
await clickAndSettle(downloadButton());
|
|
1141
|
+
await clickAndSettle(copyButton());
|
|
1142
|
+
|
|
1143
|
+
expect(mocks.onCapture).toHaveBeenCalledTimes(3);
|
|
1144
|
+
expect(mocks.copyToClipboard).toHaveBeenCalledTimes(2);
|
|
1145
|
+
expect(mocks.download).toHaveBeenCalledTimes(1);
|
|
1146
|
+
});
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
describe("when the clipboard cannot take images", () => {
|
|
1150
|
+
it("says so at once, without capturing, and offers the download", () => {
|
|
1151
|
+
const mocks: ActionMocks = makeMocks();
|
|
1152
|
+
|
|
1153
|
+
renderActions(mocks, { clipboardSupport: "unsupported" });
|
|
1154
|
+
|
|
1155
|
+
fireEvent.click(copyButton());
|
|
1156
|
+
|
|
1157
|
+
const card: HTMLElement = screen.getByRole("alert");
|
|
1158
|
+
|
|
1159
|
+
expect(card).toBe(errorCard());
|
|
1160
|
+
expect(card).toHaveAttribute("data-recovery", "download");
|
|
1161
|
+
expect(card).toHaveTextContent("This browser can't copy images");
|
|
1162
|
+
expect(card).toHaveTextContent(
|
|
1163
|
+
"It has no image clipboard. Download the PNG instead - it is the same picture.",
|
|
1164
|
+
);
|
|
1165
|
+
expect(recoverButton()).toHaveTextContent("Download instead");
|
|
1166
|
+
expect(mocks.onCapture).not.toHaveBeenCalled();
|
|
1167
|
+
expect(mocks.copyToClipboard).not.toHaveBeenCalled();
|
|
1168
|
+
expect(mocks.download).not.toHaveBeenCalled();
|
|
1169
|
+
|
|
1170
|
+
/* Nothing is running, so nothing is busy. */
|
|
1171
|
+
expect(copyButton()).toBeEnabled();
|
|
1172
|
+
expect(downloadButton()).toBeEnabled();
|
|
1173
|
+
expect(copyButton()).toHaveAttribute("data-state", "idle");
|
|
1174
|
+
expect(statusText()).toBe("");
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
it("says HTTPS is needed on an insecure page", () => {
|
|
1178
|
+
const mocks: ActionMocks = makeMocks();
|
|
1179
|
+
|
|
1180
|
+
renderActions(mocks, { clipboardSupport: "insecure-context" });
|
|
1181
|
+
|
|
1182
|
+
fireEvent.click(copyButton());
|
|
1183
|
+
|
|
1184
|
+
expect(errorCard()).toHaveAttribute("data-recovery", "download");
|
|
1185
|
+
expect(errorCard()).toHaveTextContent("Copying images needs HTTPS");
|
|
1186
|
+
expect(errorCard()).toHaveTextContent(
|
|
1187
|
+
"Browsers only let a secure (https) page put images on the clipboard.",
|
|
1188
|
+
);
|
|
1189
|
+
expect(mocks.onCapture).not.toHaveBeenCalled();
|
|
1190
|
+
expect(mocks.copyToClipboard).not.toHaveBeenCalled();
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
it("captures and saves the frame from Download instead", async () => {
|
|
1194
|
+
const shot: ReplayScreenshot = makeScreenshot();
|
|
1195
|
+
const mocks: ActionMocks = makeMocks(shot);
|
|
1196
|
+
const capture: Deferred<ReplayScreenshot> =
|
|
1197
|
+
makeDeferred<ReplayScreenshot>();
|
|
1198
|
+
|
|
1199
|
+
mocks.onCapture.mockImplementation((): Promise<ReplayScreenshot> => {
|
|
1200
|
+
return capture.promise;
|
|
1201
|
+
});
|
|
1202
|
+
renderActions(mocks, { clipboardSupport: "unsupported" });
|
|
1203
|
+
|
|
1204
|
+
fireEvent.click(copyButton());
|
|
1205
|
+
fireEvent.click(recoverButton());
|
|
1206
|
+
|
|
1207
|
+
/* The card goes while the frame is drawn. */
|
|
1208
|
+
expect(queryErrorCard()).not.toBeInTheDocument();
|
|
1209
|
+
expect(mocks.onCapture).toHaveBeenCalledTimes(1);
|
|
1210
|
+
expect(downloadButton()).toHaveAttribute("data-state", "busy");
|
|
1211
|
+
|
|
1212
|
+
await settle((): void => {
|
|
1213
|
+
capture.resolve(shot);
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
expect(mocks.download).toHaveBeenCalledTimes(1);
|
|
1217
|
+
expect(mocks.download.mock.calls[0]?.[0]).toBe(shot);
|
|
1218
|
+
expect(mocks.copyToClipboard).not.toHaveBeenCalled();
|
|
1219
|
+
expect(previewCard()).toHaveAttribute("data-action", "download");
|
|
1220
|
+
expect(statusText()).toBe(`Screenshot downloaded as ${FILE_NAME}.`);
|
|
1221
|
+
});
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
describe("when the clipboard refuses the image", () => {
|
|
1225
|
+
function refuseAfterCapture(
|
|
1226
|
+
mocks: ActionMocks,
|
|
1227
|
+
reason: ReplayClipboardFailure,
|
|
1228
|
+
): void {
|
|
1229
|
+
mocks.copyToClipboard.mockImplementation(
|
|
1230
|
+
async (image: Promise<Blob>): Promise<void> => {
|
|
1231
|
+
await image;
|
|
1232
|
+
|
|
1233
|
+
throw new ReplayScreenshotClipboardError(reason, "refused");
|
|
1234
|
+
},
|
|
1235
|
+
);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
it("offers to save the PNG that was already drawn, without drawing it again", async () => {
|
|
1239
|
+
const shot: ReplayScreenshot = makeScreenshot();
|
|
1240
|
+
const mocks: ActionMocks = makeMocks(shot);
|
|
1241
|
+
|
|
1242
|
+
refuseAfterCapture(mocks, "denied");
|
|
1243
|
+
renderActions(mocks);
|
|
1244
|
+
|
|
1245
|
+
await clickAndSettle(copyButton());
|
|
1246
|
+
|
|
1247
|
+
expect(errorCard()).toHaveAttribute("data-recovery", "download");
|
|
1248
|
+
expect(errorCard()).toHaveTextContent("Clipboard access was blocked");
|
|
1249
|
+
expect(errorCard()).toHaveTextContent(
|
|
1250
|
+
"Allow clipboard access for this site in the browser, or download the PNG instead.",
|
|
1251
|
+
);
|
|
1252
|
+
expect(copyButton()).toHaveAttribute("data-state", "idle");
|
|
1253
|
+
expect(copyButton()).toBeEnabled();
|
|
1254
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
1255
|
+
expect(statusText()).toBe("");
|
|
1256
|
+
expect(createObjectURL).not.toHaveBeenCalled();
|
|
1257
|
+
|
|
1258
|
+
fireEvent.click(recoverButton());
|
|
1259
|
+
|
|
1260
|
+
/* Saved inside this click - no capture to wait for. */
|
|
1261
|
+
expect(mocks.download).toHaveBeenCalledTimes(1);
|
|
1262
|
+
expect(mocks.download.mock.calls[0]?.[0]).toBe(shot);
|
|
1263
|
+
expect(mocks.onCapture).toHaveBeenCalledTimes(1);
|
|
1264
|
+
expect(queryErrorCard()).not.toBeInTheDocument();
|
|
1265
|
+
expect(previewCard()).toHaveAttribute("data-action", "download");
|
|
1266
|
+
expect(downloadButton()).toHaveAttribute("data-state", "done");
|
|
1267
|
+
expect(statusText()).toBe(`Screenshot downloaded as ${FILE_NAME}.`);
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
const refusalTitles: Array<{
|
|
1271
|
+
reason: ReplayClipboardFailure;
|
|
1272
|
+
title: string;
|
|
1273
|
+
}> = [
|
|
1274
|
+
{ reason: "denied", title: "Clipboard access was blocked" },
|
|
1275
|
+
{ reason: "failed", title: "Couldn't copy the image" },
|
|
1276
|
+
{ reason: "unsupported", title: "This browser can't copy images" },
|
|
1277
|
+
{ reason: "insecure-context", title: "Copying images needs HTTPS" },
|
|
1278
|
+
];
|
|
1279
|
+
|
|
1280
|
+
refusalTitles.forEach(
|
|
1281
|
+
(testCase: { reason: ReplayClipboardFailure; title: string }): void => {
|
|
1282
|
+
it(`names a ${testCase.reason} clipboard and offers the download`, async () => {
|
|
1283
|
+
const mocks: ActionMocks = makeMocks();
|
|
1284
|
+
|
|
1285
|
+
refuseAfterCapture(mocks, testCase.reason);
|
|
1286
|
+
renderActions(mocks);
|
|
1287
|
+
|
|
1288
|
+
await clickAndSettle(copyButton());
|
|
1289
|
+
|
|
1290
|
+
expect(errorCard()).toHaveTextContent(testCase.title);
|
|
1291
|
+
expect(errorCard()).toHaveAttribute("data-recovery", "download");
|
|
1292
|
+
expect(recoverButton()).toHaveTextContent("Download instead");
|
|
1293
|
+
});
|
|
1294
|
+
},
|
|
1295
|
+
);
|
|
1296
|
+
|
|
1297
|
+
it("draws the frame afresh from Download instead when the clipboard failed before it was drawn", async () => {
|
|
1298
|
+
const second: ReplayScreenshot = makeScreenshot({
|
|
1299
|
+
fileName: "session-replay-3f9a2c1b-0m41.3s.png",
|
|
1300
|
+
});
|
|
1301
|
+
const mocks: ActionMocks = makeMocks(second);
|
|
1302
|
+
const firstCapture: Deferred<ReplayScreenshot> =
|
|
1303
|
+
makeDeferred<ReplayScreenshot>();
|
|
1304
|
+
|
|
1305
|
+
mocks.onCapture.mockImplementationOnce((): Promise<ReplayScreenshot> => {
|
|
1306
|
+
return firstCapture.promise;
|
|
1307
|
+
});
|
|
1308
|
+
mocks.copyToClipboard.mockImplementation(
|
|
1309
|
+
(image: Promise<Blob>): Promise<void> => {
|
|
1310
|
+
image.catch(ignoreRejection);
|
|
1311
|
+
|
|
1312
|
+
return Promise.reject(
|
|
1313
|
+
new ReplayScreenshotClipboardError("failed", "no clipboard"),
|
|
1314
|
+
);
|
|
1315
|
+
},
|
|
1316
|
+
);
|
|
1317
|
+
renderActions(mocks);
|
|
1318
|
+
|
|
1319
|
+
await clickAndSettle(copyButton());
|
|
1320
|
+
|
|
1321
|
+
expect(errorCard()).toHaveTextContent("Couldn't copy the image");
|
|
1322
|
+
|
|
1323
|
+
await clickAndSettle(recoverButton());
|
|
1324
|
+
|
|
1325
|
+
expect(mocks.onCapture).toHaveBeenCalledTimes(2);
|
|
1326
|
+
expect(mocks.download).toHaveBeenCalledTimes(1);
|
|
1327
|
+
expect(mocks.download.mock.calls[0]?.[0]).toBe(second);
|
|
1328
|
+
expect(previewCard()).toHaveTextContent(
|
|
1329
|
+
"session-replay-3f9a2c1b-0m41.3s.png",
|
|
1330
|
+
);
|
|
1331
|
+
});
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
describe("when the frame cannot be captured", () => {
|
|
1335
|
+
const captureFailures: Array<{
|
|
1336
|
+
reason: ReplayFrameCaptureFailure;
|
|
1337
|
+
title: string;
|
|
1338
|
+
description: string;
|
|
1339
|
+
}> = [
|
|
1340
|
+
{
|
|
1341
|
+
reason: "render-failed",
|
|
1342
|
+
title: "Couldn't capture this frame",
|
|
1343
|
+
description:
|
|
1344
|
+
"The browser could not draw this recorded page as an image.",
|
|
1345
|
+
},
|
|
1346
|
+
{
|
|
1347
|
+
reason: "no-document",
|
|
1348
|
+
title: "The frame isn't ready yet",
|
|
1349
|
+
description: "Give it a second and try again.",
|
|
1350
|
+
},
|
|
1351
|
+
{
|
|
1352
|
+
reason: "empty-viewport",
|
|
1353
|
+
title: "The frame isn't ready yet",
|
|
1354
|
+
description: "Give it a second and try again.",
|
|
1355
|
+
},
|
|
1356
|
+
{
|
|
1357
|
+
reason: "encode-failed",
|
|
1358
|
+
title: "Couldn't save this frame",
|
|
1359
|
+
description: "The browser would not export the picture as a PNG.",
|
|
1360
|
+
},
|
|
1361
|
+
];
|
|
1362
|
+
|
|
1363
|
+
const actions: Array<"copy" | "download"> = ["copy", "download"];
|
|
1364
|
+
|
|
1365
|
+
actions.forEach((action: "copy" | "download"): void => {
|
|
1366
|
+
captureFailures.forEach(
|
|
1367
|
+
(testCase: {
|
|
1368
|
+
reason: ReplayFrameCaptureFailure;
|
|
1369
|
+
title: string;
|
|
1370
|
+
description: string;
|
|
1371
|
+
}): void => {
|
|
1372
|
+
it(`says what went wrong when a ${action}'s capture fails with ${testCase.reason}`, async () => {
|
|
1373
|
+
const mocks: ActionMocks = makeMocks();
|
|
1374
|
+
|
|
1375
|
+
mocks.onCapture.mockImplementation(
|
|
1376
|
+
(): Promise<ReplayScreenshot> => {
|
|
1377
|
+
return Promise.reject(
|
|
1378
|
+
new ReplayFrameCaptureError(testCase.reason, "capture"),
|
|
1379
|
+
);
|
|
1380
|
+
},
|
|
1381
|
+
);
|
|
1382
|
+
renderActions(mocks);
|
|
1383
|
+
|
|
1384
|
+
await clickAndSettle(
|
|
1385
|
+
action === "copy" ? copyButton() : downloadButton(),
|
|
1386
|
+
);
|
|
1387
|
+
|
|
1388
|
+
const card: HTMLElement = screen.getByRole("alert");
|
|
1389
|
+
|
|
1390
|
+
expect(card).toHaveAttribute("data-recovery", "retry");
|
|
1391
|
+
expect(card).toHaveTextContent(testCase.title);
|
|
1392
|
+
expect(card).toHaveTextContent(testCase.description);
|
|
1393
|
+
expect(recoverButton()).toHaveTextContent("Try again");
|
|
1394
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
1395
|
+
expect(mocks.download).not.toHaveBeenCalled();
|
|
1396
|
+
expect(copyButton()).toHaveAttribute("data-state", "idle");
|
|
1397
|
+
expect(downloadButton()).toHaveAttribute("data-state", "idle");
|
|
1398
|
+
expect(copyButton()).toBeEnabled();
|
|
1399
|
+
expect(downloadButton()).toBeEnabled();
|
|
1400
|
+
expect(statusText()).toBe("");
|
|
1401
|
+
expect(createObjectURL).not.toHaveBeenCalled();
|
|
1402
|
+
expect(
|
|
1403
|
+
screen.queryByTestId("replay-screenshot-flash"),
|
|
1404
|
+
).not.toBeInTheDocument();
|
|
1405
|
+
});
|
|
1406
|
+
},
|
|
1407
|
+
);
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
it("copies again from Try again", async () => {
|
|
1411
|
+
const mocks: ActionMocks = makeMocks();
|
|
1412
|
+
|
|
1413
|
+
mocks.onCapture.mockImplementationOnce((): Promise<ReplayScreenshot> => {
|
|
1414
|
+
return Promise.reject(
|
|
1415
|
+
new ReplayFrameCaptureError("render-failed", "capture"),
|
|
1416
|
+
);
|
|
1417
|
+
});
|
|
1418
|
+
renderActions(mocks);
|
|
1419
|
+
|
|
1420
|
+
await clickAndSettle(copyButton());
|
|
1421
|
+
|
|
1422
|
+
expect(errorCard()).toHaveAttribute("data-recovery", "retry");
|
|
1423
|
+
|
|
1424
|
+
await clickAndSettle(recoverButton());
|
|
1425
|
+
|
|
1426
|
+
expect(mocks.onCapture).toHaveBeenCalledTimes(2);
|
|
1427
|
+
expect(mocks.copyToClipboard).toHaveBeenCalledTimes(2);
|
|
1428
|
+
expect(mocks.download).not.toHaveBeenCalled();
|
|
1429
|
+
expect(queryErrorCard()).not.toBeInTheDocument();
|
|
1430
|
+
expect(previewCard()).toHaveAttribute("data-action", "copy");
|
|
1431
|
+
expect(statusText()).toBe("Screenshot copied to the clipboard.");
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
it("downloads again from Try again", async () => {
|
|
1435
|
+
const shot: ReplayScreenshot = makeScreenshot();
|
|
1436
|
+
const mocks: ActionMocks = makeMocks(shot);
|
|
1437
|
+
|
|
1438
|
+
mocks.onCapture.mockImplementationOnce((): Promise<ReplayScreenshot> => {
|
|
1439
|
+
return Promise.reject(
|
|
1440
|
+
new ReplayFrameCaptureError("no-document", "capture"),
|
|
1441
|
+
);
|
|
1442
|
+
});
|
|
1443
|
+
renderActions(mocks);
|
|
1444
|
+
|
|
1445
|
+
await clickAndSettle(downloadButton());
|
|
1446
|
+
|
|
1447
|
+
expect(errorCard()).toHaveTextContent("The frame isn't ready yet");
|
|
1448
|
+
|
|
1449
|
+
await clickAndSettle(recoverButton());
|
|
1450
|
+
|
|
1451
|
+
expect(mocks.onCapture).toHaveBeenCalledTimes(2);
|
|
1452
|
+
expect(mocks.copyToClipboard).not.toHaveBeenCalled();
|
|
1453
|
+
expect(mocks.download).toHaveBeenCalledTimes(1);
|
|
1454
|
+
expect(mocks.download.mock.calls[0]?.[0]).toBe(shot);
|
|
1455
|
+
expect(previewCard()).toHaveAttribute("data-action", "download");
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
it("reports a capture that throws instead of rejecting", async () => {
|
|
1459
|
+
const mocks: ActionMocks = makeMocks();
|
|
1460
|
+
|
|
1461
|
+
mocks.onCapture.mockImplementation((): Promise<ReplayScreenshot> => {
|
|
1462
|
+
throw new Error("replayer is gone");
|
|
1463
|
+
});
|
|
1464
|
+
renderActions(mocks);
|
|
1465
|
+
|
|
1466
|
+
await clickAndSettle(downloadButton());
|
|
1467
|
+
|
|
1468
|
+
expect(errorCard()).toHaveAttribute("data-recovery", "retry");
|
|
1469
|
+
expect(errorCard()).toHaveTextContent("Couldn't capture this frame");
|
|
1470
|
+
expect(errorCard()).toHaveTextContent(
|
|
1471
|
+
"Something went wrong while drawing the picture.",
|
|
1472
|
+
);
|
|
1473
|
+
expect(downloadButton()).toBeEnabled();
|
|
1474
|
+
|
|
1475
|
+
await clickAndSettle(copyButton());
|
|
1476
|
+
|
|
1477
|
+
/* The clipboard is still handed the (rejected) image in the click. */
|
|
1478
|
+
expect(mocks.copyToClipboard).toHaveBeenCalledTimes(1);
|
|
1479
|
+
expect(errorCard()).toHaveTextContent("Couldn't capture this frame");
|
|
1480
|
+
expect(copyButton()).toBeEnabled();
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
it("fails the copy when the capture fails after the clipboard accepted the promise", async () => {
|
|
1484
|
+
const mocks: ActionMocks = makeMocks();
|
|
1485
|
+
|
|
1486
|
+
mocks.onCapture.mockImplementation((): Promise<ReplayScreenshot> => {
|
|
1487
|
+
return Promise.reject(
|
|
1488
|
+
new ReplayFrameCaptureError("encode-failed", "capture"),
|
|
1489
|
+
);
|
|
1490
|
+
});
|
|
1491
|
+
mocks.copyToClipboard.mockImplementation(
|
|
1492
|
+
(image: Promise<Blob>): Promise<void> => {
|
|
1493
|
+
image.catch(ignoreRejection);
|
|
1494
|
+
|
|
1495
|
+
return Promise.resolve();
|
|
1496
|
+
},
|
|
1497
|
+
);
|
|
1498
|
+
renderActions(mocks);
|
|
1499
|
+
|
|
1500
|
+
await clickAndSettle(copyButton());
|
|
1501
|
+
|
|
1502
|
+
expect(errorCard()).toHaveTextContent("Couldn't save this frame");
|
|
1503
|
+
expect(errorCard()).toHaveAttribute("data-recovery", "retry");
|
|
1504
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
1505
|
+
expect(statusText()).toBe("");
|
|
1506
|
+
});
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
describe("when the download fails", () => {
|
|
1510
|
+
it("shows a retry card when saving the file throws", async () => {
|
|
1511
|
+
const mocks: ActionMocks = makeMocks();
|
|
1512
|
+
|
|
1513
|
+
mocks.download.mockImplementationOnce((): void => {
|
|
1514
|
+
throw new Error("Downloads are blocked");
|
|
1515
|
+
});
|
|
1516
|
+
renderActions(mocks);
|
|
1517
|
+
|
|
1518
|
+
await clickAndSettle(downloadButton());
|
|
1519
|
+
|
|
1520
|
+
expect(errorCard()).toHaveAttribute("data-recovery", "retry");
|
|
1521
|
+
expect(errorCard()).toHaveTextContent("Couldn't capture this frame");
|
|
1522
|
+
expect(downloadButton()).toHaveAttribute("data-state", "idle");
|
|
1523
|
+
expect(downloadButton()).toBeEnabled();
|
|
1524
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
1525
|
+
expect(createObjectURL).not.toHaveBeenCalled();
|
|
1526
|
+
expect(statusText()).toBe("");
|
|
1527
|
+
|
|
1528
|
+
await clickAndSettle(recoverButton());
|
|
1529
|
+
|
|
1530
|
+
expect(mocks.onCapture).toHaveBeenCalledTimes(2);
|
|
1531
|
+
expect(mocks.download).toHaveBeenCalledTimes(2);
|
|
1532
|
+
expect(previewCard()).toHaveAttribute("data-action", "download");
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
it("shows a retry card when Download instead throws", async () => {
|
|
1536
|
+
const mocks: ActionMocks = makeMocks();
|
|
1537
|
+
|
|
1538
|
+
mocks.copyToClipboard.mockImplementation(
|
|
1539
|
+
async (image: Promise<Blob>): Promise<void> => {
|
|
1540
|
+
await image;
|
|
1541
|
+
|
|
1542
|
+
throw new ReplayScreenshotClipboardError("denied", "refused");
|
|
1543
|
+
},
|
|
1544
|
+
);
|
|
1545
|
+
mocks.download.mockImplementationOnce((): void => {
|
|
1546
|
+
throw new Error("Downloads are blocked");
|
|
1547
|
+
});
|
|
1548
|
+
renderActions(mocks);
|
|
1549
|
+
|
|
1550
|
+
await clickAndSettle(copyButton());
|
|
1551
|
+
|
|
1552
|
+
fireEvent.click(recoverButton());
|
|
1553
|
+
|
|
1554
|
+
expect(errorCard()).toHaveAttribute("data-recovery", "retry");
|
|
1555
|
+
expect(errorCard()).toHaveTextContent("Couldn't capture this frame");
|
|
1556
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
1557
|
+
|
|
1558
|
+
/* The retry is the download's: it draws and saves afresh. */
|
|
1559
|
+
await clickAndSettle(recoverButton());
|
|
1560
|
+
|
|
1561
|
+
expect(mocks.onCapture).toHaveBeenCalledTimes(2);
|
|
1562
|
+
expect(mocks.copyToClipboard).toHaveBeenCalledTimes(1);
|
|
1563
|
+
expect(mocks.download).toHaveBeenCalledTimes(2);
|
|
1564
|
+
expect(previewCard()).toHaveAttribute("data-action", "download");
|
|
1565
|
+
});
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
describe("cards", () => {
|
|
1569
|
+
it("takes the error card away while the next capture runs", async () => {
|
|
1570
|
+
const mocks: ActionMocks = makeMocks();
|
|
1571
|
+
const capture: Deferred<ReplayScreenshot> =
|
|
1572
|
+
makeDeferred<ReplayScreenshot>();
|
|
1573
|
+
|
|
1574
|
+
mocks.onCapture.mockImplementationOnce((): Promise<ReplayScreenshot> => {
|
|
1575
|
+
return Promise.reject(
|
|
1576
|
+
new ReplayFrameCaptureError("render-failed", "capture"),
|
|
1577
|
+
);
|
|
1578
|
+
});
|
|
1579
|
+
renderActions(mocks);
|
|
1580
|
+
|
|
1581
|
+
await clickAndSettle(downloadButton());
|
|
1582
|
+
expect(errorCard()).toBeInTheDocument();
|
|
1583
|
+
|
|
1584
|
+
mocks.onCapture.mockImplementationOnce((): Promise<ReplayScreenshot> => {
|
|
1585
|
+
return capture.promise;
|
|
1586
|
+
});
|
|
1587
|
+
fireEvent.click(downloadButton());
|
|
1588
|
+
|
|
1589
|
+
expect(queryErrorCard()).not.toBeInTheDocument();
|
|
1590
|
+
expect(downloadButton()).toHaveAttribute("data-state", "busy");
|
|
1591
|
+
|
|
1592
|
+
await settle((): void => {
|
|
1593
|
+
capture.resolve(makeScreenshot());
|
|
1594
|
+
});
|
|
1595
|
+
|
|
1596
|
+
expect(previewCard()).toBeInTheDocument();
|
|
1597
|
+
});
|
|
1598
|
+
|
|
1599
|
+
it("replaces the success card with a failure and revokes its thumbnail", async () => {
|
|
1600
|
+
const mocks: ActionMocks = makeMocks();
|
|
1601
|
+
|
|
1602
|
+
renderActions(mocks);
|
|
1603
|
+
|
|
1604
|
+
await clickAndSettle(copyButton());
|
|
1605
|
+
expect(previewCard()).toBeInTheDocument();
|
|
1606
|
+
expect(copyButton()).toHaveAttribute("data-state", "done");
|
|
1607
|
+
|
|
1608
|
+
mocks.onCapture.mockImplementationOnce((): Promise<ReplayScreenshot> => {
|
|
1609
|
+
return Promise.reject(
|
|
1610
|
+
new ReplayFrameCaptureError("render-failed", "capture"),
|
|
1611
|
+
);
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
await clickAndSettle(downloadButton());
|
|
1615
|
+
|
|
1616
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
1617
|
+
expect(errorCard()).toBeInTheDocument();
|
|
1618
|
+
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
|
|
1619
|
+
expect(revokeObjectURL).toHaveBeenCalledWith("blob:replay/1");
|
|
1620
|
+
/* The copy's check mark does not survive a failure either. */
|
|
1621
|
+
expect(copyButton()).toHaveAttribute("data-state", "idle");
|
|
1622
|
+
expect(statusText()).toBe("");
|
|
1623
|
+
|
|
1624
|
+
/* The old card's timer is gone: it does not revoke a second time. */
|
|
1625
|
+
advance(REPLAY_SCREENSHOT_PREVIEW_MS);
|
|
1626
|
+
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
|
|
1627
|
+
expect(errorCard()).toBeInTheDocument();
|
|
1628
|
+
});
|
|
1629
|
+
|
|
1630
|
+
it("replaces the previous success card, revokes its thumbnail and restarts the timers", async () => {
|
|
1631
|
+
const first: ReplayScreenshot = makeScreenshot();
|
|
1632
|
+
const second: ReplayScreenshot = makeScreenshot({
|
|
1633
|
+
fileName: "session-replay-3f9a2c1b-0m41.3s.png",
|
|
1634
|
+
});
|
|
1635
|
+
const mocks: ActionMocks = makeMocks(first);
|
|
1636
|
+
|
|
1637
|
+
mocks.onCapture
|
|
1638
|
+
.mockImplementationOnce((): Promise<ReplayScreenshot> => {
|
|
1639
|
+
return Promise.resolve(first);
|
|
1640
|
+
})
|
|
1641
|
+
.mockImplementationOnce((): Promise<ReplayScreenshot> => {
|
|
1642
|
+
return Promise.resolve(second);
|
|
1643
|
+
});
|
|
1644
|
+
renderActions(mocks);
|
|
1645
|
+
|
|
1646
|
+
await clickAndSettle(copyButton());
|
|
1647
|
+
expect(screen.getByTestId("replay-screenshot-thumbnail")).toHaveAttribute(
|
|
1648
|
+
"src",
|
|
1649
|
+
"blob:replay/1",
|
|
1650
|
+
);
|
|
1651
|
+
|
|
1652
|
+
advance(REPLAY_SCREENSHOT_DONE_MS / 2);
|
|
1653
|
+
|
|
1654
|
+
await clickAndSettle(downloadButton());
|
|
1655
|
+
|
|
1656
|
+
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
|
|
1657
|
+
expect(revokeObjectURL).toHaveBeenCalledWith("blob:replay/1");
|
|
1658
|
+
expect(screen.getAllByTestId("replay-screenshot-preview")).toHaveLength(
|
|
1659
|
+
1,
|
|
1660
|
+
);
|
|
1661
|
+
expect(previewCard()).toHaveAttribute("data-action", "download");
|
|
1662
|
+
expect(previewCard()).toHaveTextContent(
|
|
1663
|
+
"session-replay-3f9a2c1b-0m41.3s.png",
|
|
1664
|
+
);
|
|
1665
|
+
expect(screen.getByTestId("replay-screenshot-thumbnail")).toHaveAttribute(
|
|
1666
|
+
"src",
|
|
1667
|
+
"blob:replay/2",
|
|
1668
|
+
);
|
|
1669
|
+
expect(copyButton()).toHaveAttribute("data-state", "idle");
|
|
1670
|
+
expect(downloadButton()).toHaveAttribute("data-state", "done");
|
|
1671
|
+
|
|
1672
|
+
/* The first capture's timers would have fired inside this window. */
|
|
1673
|
+
advance(REPLAY_SCREENSHOT_DONE_MS - 1);
|
|
1674
|
+
expect(downloadButton()).toHaveAttribute("data-state", "done");
|
|
1675
|
+
|
|
1676
|
+
advance(REPLAY_SCREENSHOT_PREVIEW_MS - REPLAY_SCREENSHOT_DONE_MS);
|
|
1677
|
+
expect(previewCard()).toBeInTheDocument();
|
|
1678
|
+
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
|
|
1679
|
+
|
|
1680
|
+
advance(1);
|
|
1681
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
1682
|
+
expect(revokeObjectURL).toHaveBeenCalledTimes(2);
|
|
1683
|
+
expect(revokeObjectURL).toHaveBeenLastCalledWith("blob:replay/2");
|
|
1684
|
+
});
|
|
1685
|
+
|
|
1686
|
+
it("dismisses the success card and revokes its thumbnail", async () => {
|
|
1687
|
+
renderActions(makeMocks());
|
|
1688
|
+
|
|
1689
|
+
await clickAndSettle(copyButton());
|
|
1690
|
+
|
|
1691
|
+
const dismiss: HTMLElement = within(previewCard()).getByRole("button", {
|
|
1692
|
+
name: "Dismiss",
|
|
1693
|
+
});
|
|
1694
|
+
|
|
1695
|
+
expect(dismiss).toBe(
|
|
1696
|
+
screen.getByTestId("replay-screenshot-preview-dismiss"),
|
|
1697
|
+
);
|
|
1698
|
+
|
|
1699
|
+
fireEvent.click(dismiss);
|
|
1700
|
+
|
|
1701
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
1702
|
+
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
|
|
1703
|
+
expect(revokeObjectURL).toHaveBeenCalledWith("blob:replay/1");
|
|
1704
|
+
|
|
1705
|
+
/* Its timer went with it. */
|
|
1706
|
+
advance(REPLAY_SCREENSHOT_PREVIEW_MS);
|
|
1707
|
+
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
|
|
1708
|
+
});
|
|
1709
|
+
|
|
1710
|
+
it("survives a thumbnail URL that is already gone", async () => {
|
|
1711
|
+
revokeObjectURL.mockImplementation((): void => {
|
|
1712
|
+
throw new Error("Already revoked");
|
|
1713
|
+
});
|
|
1714
|
+
renderActions(makeMocks());
|
|
1715
|
+
|
|
1716
|
+
await clickAndSettle(copyButton());
|
|
1717
|
+
|
|
1718
|
+
fireEvent.click(screen.getByTestId("replay-screenshot-preview-dismiss"));
|
|
1719
|
+
|
|
1720
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
1721
|
+
expect(copyButton()).toBeEnabled();
|
|
1722
|
+
});
|
|
1723
|
+
|
|
1724
|
+
it("dismisses the error card", async () => {
|
|
1725
|
+
const mocks: ActionMocks = makeMocks();
|
|
1726
|
+
|
|
1727
|
+
renderActions(mocks, { clipboardSupport: "unsupported" });
|
|
1728
|
+
|
|
1729
|
+
fireEvent.click(copyButton());
|
|
1730
|
+
|
|
1731
|
+
const dismiss: HTMLElement = within(errorCard()).getByRole("button", {
|
|
1732
|
+
name: "Dismiss",
|
|
1733
|
+
});
|
|
1734
|
+
|
|
1735
|
+
expect(dismiss).toBe(
|
|
1736
|
+
screen.getByTestId("replay-screenshot-error-dismiss"),
|
|
1737
|
+
);
|
|
1738
|
+
|
|
1739
|
+
fireEvent.click(dismiss);
|
|
1740
|
+
|
|
1741
|
+
expect(queryErrorCard()).not.toBeInTheDocument();
|
|
1742
|
+
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
1743
|
+
expect(mocks.onCapture).not.toHaveBeenCalled();
|
|
1744
|
+
|
|
1745
|
+
await clickAndSettle(downloadButton());
|
|
1746
|
+
|
|
1747
|
+
expect(previewCard()).toHaveAttribute("data-action", "download");
|
|
1748
|
+
});
|
|
1749
|
+
|
|
1750
|
+
it("shows the error card instead of a success card, never both", async () => {
|
|
1751
|
+
const mocks: ActionMocks = makeMocks();
|
|
1752
|
+
const view: ReturnType<typeof render> = renderActions(mocks);
|
|
1753
|
+
|
|
1754
|
+
await clickAndSettle(downloadButton());
|
|
1755
|
+
expect(previewCard()).toBeInTheDocument();
|
|
1756
|
+
|
|
1757
|
+
rerenderActions(view, mocks, { clipboardSupport: "unsupported" });
|
|
1758
|
+
fireEvent.click(copyButton());
|
|
1759
|
+
|
|
1760
|
+
expect(errorCard()).toBeInTheDocument();
|
|
1761
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
1762
|
+
});
|
|
1763
|
+
});
|
|
1764
|
+
|
|
1765
|
+
describe("a new frame", () => {
|
|
1766
|
+
function refuseDenied(mocks: ActionMocks): void {
|
|
1767
|
+
mocks.copyToClipboard.mockImplementation(
|
|
1768
|
+
async (image: Promise<Blob>): Promise<void> => {
|
|
1769
|
+
await image;
|
|
1770
|
+
|
|
1771
|
+
throw new ReplayScreenshotClipboardError("denied", "refused");
|
|
1772
|
+
},
|
|
1773
|
+
);
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
it("takes the error card away when the frame changes", async () => {
|
|
1777
|
+
const mocks: ActionMocks = makeMocks();
|
|
1778
|
+
|
|
1779
|
+
refuseDenied(mocks);
|
|
1780
|
+
|
|
1781
|
+
const view: ReturnType<typeof render> = renderActions(mocks, {
|
|
1782
|
+
frameKey: "1:41200",
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
await clickAndSettle(copyButton());
|
|
1786
|
+
expect(errorCard()).toBeInTheDocument();
|
|
1787
|
+
|
|
1788
|
+
rerenderActions(view, mocks, { frameKey: "1:52000" });
|
|
1789
|
+
|
|
1790
|
+
expect(queryErrorCard()).not.toBeInTheDocument();
|
|
1791
|
+
expect(copyButton()).toBeEnabled();
|
|
1792
|
+
});
|
|
1793
|
+
|
|
1794
|
+
it("keeps the error card while the frame stays the same", async () => {
|
|
1795
|
+
const mocks: ActionMocks = makeMocks();
|
|
1796
|
+
|
|
1797
|
+
refuseDenied(mocks);
|
|
1798
|
+
|
|
1799
|
+
const view: ReturnType<typeof render> = renderActions(mocks, {
|
|
1800
|
+
frameKey: "1:41200",
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
await clickAndSettle(copyButton());
|
|
1804
|
+
|
|
1805
|
+
rerenderActions(view, mocks, { frameKey: "1:41200", fit: "width" });
|
|
1806
|
+
|
|
1807
|
+
expect(errorCard()).toHaveTextContent("Clipboard access was blocked");
|
|
1808
|
+
});
|
|
1809
|
+
|
|
1810
|
+
it("forgets the PNG it drew, so Download instead draws the new frame", async () => {
|
|
1811
|
+
const first: ReplayScreenshot = makeScreenshot();
|
|
1812
|
+
const second: ReplayScreenshot = makeScreenshot({
|
|
1813
|
+
fileName: "session-replay-3f9a2c1b-0m52.0s.png",
|
|
1814
|
+
});
|
|
1815
|
+
const mocks: ActionMocks = makeMocks();
|
|
1816
|
+
|
|
1817
|
+
mocks.onCapture
|
|
1818
|
+
.mockImplementationOnce((): Promise<ReplayScreenshot> => {
|
|
1819
|
+
return Promise.resolve(first);
|
|
1820
|
+
})
|
|
1821
|
+
.mockImplementationOnce((): Promise<ReplayScreenshot> => {
|
|
1822
|
+
return Promise.resolve(second);
|
|
1823
|
+
});
|
|
1824
|
+
refuseDenied(mocks);
|
|
1825
|
+
|
|
1826
|
+
const view: ReturnType<typeof render> = renderActions(mocks, {
|
|
1827
|
+
frameKey: "1:41200",
|
|
1828
|
+
});
|
|
1829
|
+
|
|
1830
|
+
await clickAndSettle(copyButton());
|
|
1831
|
+
|
|
1832
|
+
/* A seek while paused; the clipboard is now known to be unusable. */
|
|
1833
|
+
rerenderActions(view, mocks, { frameKey: "1:52000" });
|
|
1834
|
+
rerenderActions(view, mocks, {
|
|
1835
|
+
frameKey: "1:52000",
|
|
1836
|
+
clipboardSupport: "unsupported",
|
|
1837
|
+
});
|
|
1838
|
+
fireEvent.click(copyButton());
|
|
1839
|
+
|
|
1840
|
+
expect(errorCard()).toHaveAttribute("data-recovery", "download");
|
|
1841
|
+
|
|
1842
|
+
await clickAndSettle(recoverButton());
|
|
1843
|
+
|
|
1844
|
+
expect(mocks.onCapture).toHaveBeenCalledTimes(2);
|
|
1845
|
+
expect(mocks.download).toHaveBeenCalledTimes(1);
|
|
1846
|
+
expect(mocks.download.mock.calls[0]?.[0]).toBe(second);
|
|
1847
|
+
expect(previewCard()).toHaveTextContent(
|
|
1848
|
+
"session-replay-3f9a2c1b-0m52.0s.png",
|
|
1849
|
+
);
|
|
1850
|
+
});
|
|
1851
|
+
|
|
1852
|
+
it("keeps the PNG it drew for Download instead while the frame stays the same", async () => {
|
|
1853
|
+
const first: ReplayScreenshot = makeScreenshot();
|
|
1854
|
+
const mocks: ActionMocks = makeMocks(first);
|
|
1855
|
+
|
|
1856
|
+
refuseDenied(mocks);
|
|
1857
|
+
|
|
1858
|
+
const view: ReturnType<typeof render> = renderActions(mocks, {
|
|
1859
|
+
frameKey: "1:41200",
|
|
1860
|
+
});
|
|
1861
|
+
|
|
1862
|
+
await clickAndSettle(copyButton());
|
|
1863
|
+
|
|
1864
|
+
rerenderActions(view, mocks, {
|
|
1865
|
+
frameKey: "1:41200",
|
|
1866
|
+
clipboardSupport: "unsupported",
|
|
1867
|
+
});
|
|
1868
|
+
fireEvent.click(copyButton());
|
|
1869
|
+
fireEvent.click(recoverButton());
|
|
1870
|
+
|
|
1871
|
+
expect(mocks.onCapture).toHaveBeenCalledTimes(1);
|
|
1872
|
+
expect(mocks.download).toHaveBeenCalledTimes(1);
|
|
1873
|
+
expect(mocks.download.mock.calls[0]?.[0]).toBe(first);
|
|
1874
|
+
});
|
|
1875
|
+
|
|
1876
|
+
it("forgets the PNG when a new capture starts", async () => {
|
|
1877
|
+
const first: ReplayScreenshot = makeScreenshot();
|
|
1878
|
+
const mocks: ActionMocks = makeMocks(first);
|
|
1879
|
+
const failedCopy: Deferred<ReplayScreenshot> =
|
|
1880
|
+
makeDeferred<ReplayScreenshot>();
|
|
1881
|
+
|
|
1882
|
+
refuseDenied(mocks);
|
|
1883
|
+
renderActions(mocks);
|
|
1884
|
+
|
|
1885
|
+
await clickAndSettle(copyButton());
|
|
1886
|
+
expect(errorCard()).toBeInTheDocument();
|
|
1887
|
+
|
|
1888
|
+
/*
|
|
1889
|
+
* A second copy whose clipboard fails before its frame is drawn: the
|
|
1890
|
+
* first PNG must not stand in for a frame that was never captured.
|
|
1891
|
+
*/
|
|
1892
|
+
mocks.onCapture.mockImplementationOnce((): Promise<ReplayScreenshot> => {
|
|
1893
|
+
return failedCopy.promise;
|
|
1894
|
+
});
|
|
1895
|
+
mocks.copyToClipboard.mockImplementationOnce(
|
|
1896
|
+
(image: Promise<Blob>): Promise<void> => {
|
|
1897
|
+
image.catch(ignoreRejection);
|
|
1898
|
+
|
|
1899
|
+
return Promise.reject(
|
|
1900
|
+
new ReplayScreenshotClipboardError("failed", "no clipboard"),
|
|
1901
|
+
);
|
|
1902
|
+
},
|
|
1903
|
+
);
|
|
1904
|
+
|
|
1905
|
+
await clickAndSettle(copyButton());
|
|
1906
|
+
expect(errorCard()).toHaveTextContent("Couldn't copy the image");
|
|
1907
|
+
|
|
1908
|
+
await clickAndSettle(recoverButton());
|
|
1909
|
+
|
|
1910
|
+
expect(mocks.onCapture).toHaveBeenCalledTimes(3);
|
|
1911
|
+
expect(mocks.download).toHaveBeenCalledTimes(1);
|
|
1912
|
+
});
|
|
1913
|
+
|
|
1914
|
+
it("never offers a PNG of the previous frame whose capture lands after a seek", async () => {
|
|
1915
|
+
const frameOne: ReplayScreenshot = makeScreenshot({
|
|
1916
|
+
fileName: "session-replay-3f9a2c1b-0m41.2s.png",
|
|
1917
|
+
});
|
|
1918
|
+
const frameTwo: ReplayScreenshot = makeScreenshot({
|
|
1919
|
+
fileName: "session-replay-3f9a2c1b-0m52.0s.png",
|
|
1920
|
+
});
|
|
1921
|
+
/* Any capture drawn from here on is of the second frame. */
|
|
1922
|
+
const mocks: ActionMocks = makeMocks(frameTwo);
|
|
1923
|
+
const captureOne: Deferred<ReplayScreenshot> =
|
|
1924
|
+
makeDeferred<ReplayScreenshot>();
|
|
1925
|
+
const captureTwo: Deferred<ReplayScreenshot> =
|
|
1926
|
+
makeDeferred<ReplayScreenshot>();
|
|
1927
|
+
|
|
1928
|
+
mocks.onCapture
|
|
1929
|
+
.mockImplementationOnce((): Promise<ReplayScreenshot> => {
|
|
1930
|
+
return captureOne.promise;
|
|
1931
|
+
})
|
|
1932
|
+
.mockImplementationOnce((): Promise<ReplayScreenshot> => {
|
|
1933
|
+
return captureTwo.promise;
|
|
1934
|
+
});
|
|
1935
|
+
/*
|
|
1936
|
+
* A clipboard that refuses at once - a browser whose ClipboardItem
|
|
1937
|
+
* throws on a promise - so each copy fails while its frame is still
|
|
1938
|
+
* being drawn, and the buttons are free again.
|
|
1939
|
+
*/
|
|
1940
|
+
mocks.copyToClipboard.mockImplementation(
|
|
1941
|
+
(image: Promise<Blob>): Promise<void> => {
|
|
1942
|
+
image.catch(ignoreRejection);
|
|
1943
|
+
|
|
1944
|
+
return Promise.reject(
|
|
1945
|
+
new ReplayScreenshotClipboardError("failed", "no clipboard"),
|
|
1946
|
+
);
|
|
1947
|
+
},
|
|
1948
|
+
);
|
|
1949
|
+
|
|
1950
|
+
const view: ReturnType<typeof render> = renderActions(mocks, {
|
|
1951
|
+
frameKey: "1:41200",
|
|
1952
|
+
});
|
|
1953
|
+
|
|
1954
|
+
await clickAndSettle(copyButton());
|
|
1955
|
+
expect(errorCard()).toHaveTextContent("Couldn't copy the image");
|
|
1956
|
+
|
|
1957
|
+
/* A seek while paused, then a copy of the new frame. */
|
|
1958
|
+
rerenderActions(view, mocks, { frameKey: "1:52000" });
|
|
1959
|
+
await clickAndSettle(copyButton());
|
|
1960
|
+
expect(errorCard()).toHaveTextContent("Couldn't copy the image");
|
|
1961
|
+
|
|
1962
|
+
/* The first frame's capture finally lands. */
|
|
1963
|
+
await settle((): void => {
|
|
1964
|
+
captureOne.resolve(frameOne);
|
|
1965
|
+
});
|
|
1966
|
+
|
|
1967
|
+
await clickAndSettle(recoverButton());
|
|
1968
|
+
await settle((): void => {
|
|
1969
|
+
captureTwo.resolve(frameTwo);
|
|
1970
|
+
});
|
|
1971
|
+
|
|
1972
|
+
expect(mocks.download).toHaveBeenCalledTimes(1);
|
|
1973
|
+
expect(mocks.download).not.toHaveBeenCalledWith(frameOne);
|
|
1974
|
+
expect(
|
|
1975
|
+
(mocks.download.mock.calls[0]?.[0] as ReplayScreenshot | undefined)
|
|
1976
|
+
?.fileName,
|
|
1977
|
+
).toBe("session-replay-3f9a2c1b-0m52.0s.png");
|
|
1978
|
+
});
|
|
1979
|
+
});
|
|
1980
|
+
|
|
1981
|
+
describe("unmounting", () => {
|
|
1982
|
+
it("still saves a download that lands after the dock unmounted, and touches nothing else", async () => {
|
|
1983
|
+
const shot: ReplayScreenshot = makeScreenshot();
|
|
1984
|
+
const mocks: ActionMocks = makeMocks(shot);
|
|
1985
|
+
const capture: Deferred<ReplayScreenshot> =
|
|
1986
|
+
makeDeferred<ReplayScreenshot>();
|
|
1987
|
+
|
|
1988
|
+
mocks.onCapture.mockImplementation((): Promise<ReplayScreenshot> => {
|
|
1989
|
+
return capture.promise;
|
|
1990
|
+
});
|
|
1991
|
+
|
|
1992
|
+
const view: ReturnType<typeof render> = renderActions(mocks);
|
|
1993
|
+
|
|
1994
|
+
fireEvent.click(downloadButton());
|
|
1995
|
+
view.unmount();
|
|
1996
|
+
|
|
1997
|
+
/* Watched from here: only what lands after the unmount counts. */
|
|
1998
|
+
const consoleError: ConsoleSpy = jest.spyOn(
|
|
1999
|
+
console,
|
|
2000
|
+
"error",
|
|
2001
|
+
) as unknown as ConsoleSpy;
|
|
2002
|
+
|
|
2003
|
+
/* Outside act on purpose: a state update would warn. */
|
|
2004
|
+
capture.resolve(shot);
|
|
2005
|
+
await flushMicrotasks();
|
|
2006
|
+
|
|
2007
|
+
expect(mocks.download).toHaveBeenCalledTimes(1);
|
|
2008
|
+
expect(mocks.download.mock.calls[0]?.[0]).toBe(shot);
|
|
2009
|
+
expect(createObjectURL).not.toHaveBeenCalled();
|
|
2010
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
2011
|
+
expect(view.container).toBeEmptyDOMElement();
|
|
2012
|
+
expect(consoleError.mock.calls).toHaveLength(0);
|
|
2013
|
+
});
|
|
2014
|
+
|
|
2015
|
+
it("ignores a copy that lands after the dock unmounted", async () => {
|
|
2016
|
+
const shot: ReplayScreenshot = makeScreenshot();
|
|
2017
|
+
const mocks: ActionMocks = makeMocks(shot);
|
|
2018
|
+
const capture: Deferred<ReplayScreenshot> =
|
|
2019
|
+
makeDeferred<ReplayScreenshot>();
|
|
2020
|
+
|
|
2021
|
+
mocks.onCapture.mockImplementation((): Promise<ReplayScreenshot> => {
|
|
2022
|
+
return capture.promise;
|
|
2023
|
+
});
|
|
2024
|
+
|
|
2025
|
+
const view: ReturnType<typeof render> = renderActions(mocks);
|
|
2026
|
+
|
|
2027
|
+
fireEvent.click(copyButton());
|
|
2028
|
+
|
|
2029
|
+
const image: Promise<Blob> = mocks.copyToClipboard.mock
|
|
2030
|
+
.calls[0]?.[0] as Promise<Blob>;
|
|
2031
|
+
|
|
2032
|
+
view.unmount();
|
|
2033
|
+
|
|
2034
|
+
/* Watched from here: only what lands after the unmount counts. */
|
|
2035
|
+
const consoleError: ConsoleSpy = jest.spyOn(
|
|
2036
|
+
console,
|
|
2037
|
+
"error",
|
|
2038
|
+
) as unknown as ConsoleSpy;
|
|
2039
|
+
|
|
2040
|
+
capture.resolve(shot);
|
|
2041
|
+
await flushMicrotasks();
|
|
2042
|
+
|
|
2043
|
+
/* The clipboard write it started still gets its PNG. */
|
|
2044
|
+
await expect(image).resolves.toBe(shot.blob);
|
|
2045
|
+
expect(createObjectURL).not.toHaveBeenCalled();
|
|
2046
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
2047
|
+
expect(consoleError.mock.calls).toHaveLength(0);
|
|
2048
|
+
});
|
|
2049
|
+
|
|
2050
|
+
it("ignores a failure that lands after the dock unmounted", async () => {
|
|
2051
|
+
const mocks: ActionMocks = makeMocks();
|
|
2052
|
+
const capture: Deferred<ReplayScreenshot> =
|
|
2053
|
+
makeDeferred<ReplayScreenshot>();
|
|
2054
|
+
|
|
2055
|
+
mocks.onCapture.mockImplementation((): Promise<ReplayScreenshot> => {
|
|
2056
|
+
return capture.promise;
|
|
2057
|
+
});
|
|
2058
|
+
|
|
2059
|
+
const view: ReturnType<typeof render> = renderActions(mocks);
|
|
2060
|
+
|
|
2061
|
+
fireEvent.click(copyButton());
|
|
2062
|
+
view.unmount();
|
|
2063
|
+
|
|
2064
|
+
/* Watched from here: only what lands after the unmount counts. */
|
|
2065
|
+
const consoleError: ConsoleSpy = jest.spyOn(
|
|
2066
|
+
console,
|
|
2067
|
+
"error",
|
|
2068
|
+
) as unknown as ConsoleSpy;
|
|
2069
|
+
|
|
2070
|
+
capture.reject(new ReplayFrameCaptureError("render-failed", "capture"));
|
|
2071
|
+
await flushMicrotasks();
|
|
2072
|
+
|
|
2073
|
+
expect(view.container).toBeEmptyDOMElement();
|
|
2074
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
2075
|
+
expect(consoleError.mock.calls).toHaveLength(0);
|
|
2076
|
+
});
|
|
2077
|
+
|
|
2078
|
+
it("lets a capture that lands after a remount leave the new dock alone", async () => {
|
|
2079
|
+
const mocks: ActionMocks = makeMocks();
|
|
2080
|
+
const capture: Deferred<ReplayScreenshot> =
|
|
2081
|
+
makeDeferred<ReplayScreenshot>();
|
|
2082
|
+
|
|
2083
|
+
mocks.onCapture.mockImplementationOnce((): Promise<ReplayScreenshot> => {
|
|
2084
|
+
return capture.promise;
|
|
2085
|
+
});
|
|
2086
|
+
|
|
2087
|
+
const first: ReturnType<typeof render> = renderActions(mocks);
|
|
2088
|
+
|
|
2089
|
+
fireEvent.click(downloadButton());
|
|
2090
|
+
first.unmount();
|
|
2091
|
+
|
|
2092
|
+
/* Paused again: a fresh dock. */
|
|
2093
|
+
renderActions(mocks);
|
|
2094
|
+
|
|
2095
|
+
await settle((): void => {
|
|
2096
|
+
capture.resolve(makeScreenshot());
|
|
2097
|
+
});
|
|
2098
|
+
|
|
2099
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
2100
|
+
expect(downloadButton()).toHaveAttribute("data-state", "idle");
|
|
2101
|
+
expect(downloadButton()).toBeEnabled();
|
|
2102
|
+
expect(statusText()).toBe("");
|
|
2103
|
+
});
|
|
2104
|
+
|
|
2105
|
+
it("revokes the thumbnail and clears its timers when it unmounts", async () => {
|
|
2106
|
+
const view: ReturnType<typeof render> = renderActions(makeMocks());
|
|
2107
|
+
|
|
2108
|
+
await clickAndSettle(copyButton());
|
|
2109
|
+
expect(previewCard()).toBeInTheDocument();
|
|
2110
|
+
expect(jest.getTimerCount()).toBeGreaterThan(0);
|
|
2111
|
+
|
|
2112
|
+
view.unmount();
|
|
2113
|
+
|
|
2114
|
+
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
|
|
2115
|
+
expect(revokeObjectURL).toHaveBeenCalledWith("blob:replay/1");
|
|
2116
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
2117
|
+
});
|
|
2118
|
+
|
|
2119
|
+
it("cancels the entrance frame when it unmounts before it", () => {
|
|
2120
|
+
const view: ReturnType<typeof render> = renderActions(makeMocks());
|
|
2121
|
+
|
|
2122
|
+
view.unmount();
|
|
2123
|
+
|
|
2124
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
2125
|
+
expect(revokeObjectURL).not.toHaveBeenCalled();
|
|
2126
|
+
});
|
|
2127
|
+
});
|
|
2128
|
+
|
|
2129
|
+
describe("default wiring", () => {
|
|
2130
|
+
it("writes a ClipboardItem holding the pending PNG through the async Clipboard API", async () => {
|
|
2131
|
+
const shot: ReplayScreenshot = makeScreenshot();
|
|
2132
|
+
const mocks: ActionMocks = makeMocks(shot);
|
|
2133
|
+
const capture: Deferred<ReplayScreenshot> =
|
|
2134
|
+
makeDeferred<ReplayScreenshot>();
|
|
2135
|
+
const write: MockFunction = getJestMockFunction();
|
|
2136
|
+
|
|
2137
|
+
write.mockImplementation(
|
|
2138
|
+
async (items: Array<FakeClipboardItem>): Promise<void> => {
|
|
2139
|
+
await items[0]?.items["image/png"];
|
|
2140
|
+
},
|
|
2141
|
+
);
|
|
2142
|
+
installClipboard({ write: write, isSecureContext: true });
|
|
2143
|
+
mocks.onCapture.mockImplementation((): Promise<ReplayScreenshot> => {
|
|
2144
|
+
return capture.promise;
|
|
2145
|
+
});
|
|
2146
|
+
renderActions(mocks, {
|
|
2147
|
+
clipboardSupport: undefined,
|
|
2148
|
+
copyToClipboard: undefined,
|
|
2149
|
+
});
|
|
2150
|
+
|
|
2151
|
+
expect(copyButton()).toHaveAttribute("title", COPY_TITLE_SUPPORTED);
|
|
2152
|
+
|
|
2153
|
+
fireEvent.click(copyButton());
|
|
2154
|
+
|
|
2155
|
+
/* Inside the click, with the capture still running. */
|
|
2156
|
+
expect(write).toHaveBeenCalledTimes(1);
|
|
2157
|
+
|
|
2158
|
+
const items: Array<FakeClipboardItem> = write.mock
|
|
2159
|
+
.calls[0]?.[0] as Array<FakeClipboardItem>;
|
|
2160
|
+
|
|
2161
|
+
expect(items).toHaveLength(1);
|
|
2162
|
+
expect(items[0]).toBeInstanceOf(FakeClipboardItem);
|
|
2163
|
+
expect(Object.keys(items[0]?.items ?? {})).toEqual(["image/png"]);
|
|
2164
|
+
|
|
2165
|
+
const png: unknown = items[0]?.items["image/png"];
|
|
2166
|
+
|
|
2167
|
+
expect(png).toBeInstanceOf(Promise);
|
|
2168
|
+
|
|
2169
|
+
await settle((): void => {
|
|
2170
|
+
capture.resolve(shot);
|
|
2171
|
+
});
|
|
2172
|
+
|
|
2173
|
+
await expect(png as Promise<Blob>).resolves.toBe(shot.blob);
|
|
2174
|
+
expect(previewCard()).toHaveAttribute("data-action", "copy");
|
|
2175
|
+
expect(statusText()).toBe("Screenshot copied to the clipboard.");
|
|
2176
|
+
});
|
|
2177
|
+
|
|
2178
|
+
it("names a clipboard the browser blocked and saves the drawn PNG instead", async () => {
|
|
2179
|
+
const shot: ReplayScreenshot = makeScreenshot();
|
|
2180
|
+
const mocks: ActionMocks = makeMocks(shot);
|
|
2181
|
+
const write: MockFunction = getJestMockFunction();
|
|
2182
|
+
|
|
2183
|
+
write.mockImplementation(
|
|
2184
|
+
async (items: Array<FakeClipboardItem>): Promise<void> => {
|
|
2185
|
+
await items[0]?.items["image/png"];
|
|
2186
|
+
|
|
2187
|
+
throw namedError("NotAllowedError", "Write permission denied.");
|
|
2188
|
+
},
|
|
2189
|
+
);
|
|
2190
|
+
installClipboard({ write: write, isSecureContext: true });
|
|
2191
|
+
renderActions(mocks, {
|
|
2192
|
+
clipboardSupport: undefined,
|
|
2193
|
+
copyToClipboard: undefined,
|
|
2194
|
+
});
|
|
2195
|
+
|
|
2196
|
+
await clickAndSettle(copyButton());
|
|
2197
|
+
|
|
2198
|
+
expect(errorCard()).toHaveTextContent("Clipboard access was blocked");
|
|
2199
|
+
expect(errorCard()).toHaveAttribute("data-recovery", "download");
|
|
2200
|
+
|
|
2201
|
+
fireEvent.click(recoverButton());
|
|
2202
|
+
|
|
2203
|
+
expect(mocks.onCapture).toHaveBeenCalledTimes(1);
|
|
2204
|
+
expect(mocks.download).toHaveBeenCalledTimes(1);
|
|
2205
|
+
expect(mocks.download.mock.calls[0]?.[0]).toBe(shot);
|
|
2206
|
+
});
|
|
2207
|
+
|
|
2208
|
+
it("reports the capture's failure rather than the clipboard's", async () => {
|
|
2209
|
+
const mocks: ActionMocks = makeMocks();
|
|
2210
|
+
const write: MockFunction = getJestMockFunction();
|
|
2211
|
+
|
|
2212
|
+
write.mockImplementation(
|
|
2213
|
+
async (items: Array<FakeClipboardItem>): Promise<void> => {
|
|
2214
|
+
try {
|
|
2215
|
+
await items[0]?.items["image/png"];
|
|
2216
|
+
} catch {
|
|
2217
|
+
throw namedError("DataError", "The image promise rejected.");
|
|
2218
|
+
}
|
|
2219
|
+
},
|
|
2220
|
+
);
|
|
2221
|
+
installClipboard({ write: write, isSecureContext: true });
|
|
2222
|
+
mocks.onCapture.mockImplementation((): Promise<ReplayScreenshot> => {
|
|
2223
|
+
return Promise.reject(
|
|
2224
|
+
new ReplayFrameCaptureError("render-failed", "capture"),
|
|
2225
|
+
);
|
|
2226
|
+
});
|
|
2227
|
+
renderActions(mocks, {
|
|
2228
|
+
clipboardSupport: undefined,
|
|
2229
|
+
copyToClipboard: undefined,
|
|
2230
|
+
});
|
|
2231
|
+
|
|
2232
|
+
await clickAndSettle(copyButton());
|
|
2233
|
+
|
|
2234
|
+
expect(errorCard()).toHaveTextContent("Couldn't capture this frame");
|
|
2235
|
+
expect(errorCard()).toHaveAttribute("data-recovery", "retry");
|
|
2236
|
+
});
|
|
2237
|
+
|
|
2238
|
+
it("says HTTPS is needed on an insecure page, without touching the clipboard", () => {
|
|
2239
|
+
const mocks: ActionMocks = makeMocks();
|
|
2240
|
+
const write: MockFunction = getJestMockFunction();
|
|
2241
|
+
|
|
2242
|
+
installClipboard({ write: write, isSecureContext: false });
|
|
2243
|
+
renderActions(mocks, {
|
|
2244
|
+
clipboardSupport: undefined,
|
|
2245
|
+
copyToClipboard: undefined,
|
|
2246
|
+
});
|
|
2247
|
+
|
|
2248
|
+
fireEvent.click(copyButton());
|
|
2249
|
+
|
|
2250
|
+
expect(errorCard()).toHaveTextContent("Copying images needs HTTPS");
|
|
2251
|
+
expect(write).not.toHaveBeenCalled();
|
|
2252
|
+
expect(mocks.onCapture).not.toHaveBeenCalled();
|
|
2253
|
+
});
|
|
2254
|
+
|
|
2255
|
+
it("saves the PNG through a hidden download link and releases its URL later", async () => {
|
|
2256
|
+
const shot: ReplayScreenshot = makeScreenshot();
|
|
2257
|
+
const mocks: ActionMocks = makeMocks(shot);
|
|
2258
|
+
const clicked: Array<{
|
|
2259
|
+
href: string | null;
|
|
2260
|
+
download: string;
|
|
2261
|
+
rel: string;
|
|
2262
|
+
display: string;
|
|
2263
|
+
isConnected: boolean;
|
|
2264
|
+
}> = [];
|
|
2265
|
+
|
|
2266
|
+
jest
|
|
2267
|
+
.spyOn(HTMLAnchorElement.prototype, "click")
|
|
2268
|
+
.mockImplementation((): void => {
|
|
2269
|
+
const anchor: HTMLAnchorElement | null =
|
|
2270
|
+
document.querySelector<HTMLAnchorElement>("a[download]");
|
|
2271
|
+
|
|
2272
|
+
if (anchor) {
|
|
2273
|
+
clicked.push({
|
|
2274
|
+
href: anchor.getAttribute("href"),
|
|
2275
|
+
download: anchor.download,
|
|
2276
|
+
rel: anchor.rel,
|
|
2277
|
+
display: anchor.style.display,
|
|
2278
|
+
isConnected: anchor.isConnected,
|
|
2279
|
+
});
|
|
2280
|
+
}
|
|
2281
|
+
});
|
|
2282
|
+
renderActions(mocks, { download: undefined });
|
|
2283
|
+
|
|
2284
|
+
await clickAndSettle(downloadButton());
|
|
2285
|
+
|
|
2286
|
+
expect(clicked).toEqual([
|
|
2287
|
+
{
|
|
2288
|
+
href: "blob:replay/1",
|
|
2289
|
+
download: FILE_NAME,
|
|
2290
|
+
rel: "noopener",
|
|
2291
|
+
display: "none",
|
|
2292
|
+
isConnected: true,
|
|
2293
|
+
},
|
|
2294
|
+
]);
|
|
2295
|
+
expect(document.querySelector("a[download]")).toBeNull();
|
|
2296
|
+
expect(createObjectURL).toHaveBeenCalledTimes(2);
|
|
2297
|
+
expect(createObjectURL).toHaveBeenNthCalledWith(1, shot.blob);
|
|
2298
|
+
expect(screen.getByTestId("replay-screenshot-thumbnail")).toHaveAttribute(
|
|
2299
|
+
"src",
|
|
2300
|
+
"blob:replay/2",
|
|
2301
|
+
);
|
|
2302
|
+
expect(previewCard()).toHaveAttribute("data-action", "download");
|
|
2303
|
+
|
|
2304
|
+
advance(REPLAY_SCREENSHOT_URL_TTL_MS - 1);
|
|
2305
|
+
expect(revokeObjectURL).not.toHaveBeenCalledWith("blob:replay/1");
|
|
2306
|
+
|
|
2307
|
+
advance(1);
|
|
2308
|
+
expect(revokeObjectURL).toHaveBeenCalledWith("blob:replay/1");
|
|
2309
|
+
});
|
|
2310
|
+
});
|
|
2311
|
+
|
|
2312
|
+
describe("keyboard focus on the dock", () => {
|
|
2313
|
+
it("never disables a button natively, only marks it busy", async () => {
|
|
2314
|
+
const mocks: ActionMocks = makeMocks();
|
|
2315
|
+
const capture: Deferred<ReplayScreenshot> = holdCapture(mocks);
|
|
2316
|
+
|
|
2317
|
+
renderActions(mocks);
|
|
2318
|
+
|
|
2319
|
+
const buttons: Array<HTMLElement> = [copyButton(), downloadButton()];
|
|
2320
|
+
|
|
2321
|
+
buttons.forEach((button: HTMLElement): void => {
|
|
2322
|
+
expect(button).not.toHaveAttribute("disabled");
|
|
2323
|
+
expect(button).not.toHaveAttribute("aria-disabled");
|
|
2324
|
+
expect(button).not.toHaveClass("cursor-progress");
|
|
2325
|
+
});
|
|
2326
|
+
|
|
2327
|
+
fireEvent.click(downloadButton());
|
|
2328
|
+
|
|
2329
|
+
/*
|
|
2330
|
+
* A disabled button would drop the focus to <body>, where the next
|
|
2331
|
+
* Space is the player's play/pause.
|
|
2332
|
+
*/
|
|
2333
|
+
buttons.forEach((button: HTMLElement): void => {
|
|
2334
|
+
expect(button).not.toHaveAttribute("disabled");
|
|
2335
|
+
expect(button).toBeEnabled();
|
|
2336
|
+
expect(button).toHaveAttribute("aria-disabled", "true");
|
|
2337
|
+
expect(button).toHaveClass("cursor-progress");
|
|
2338
|
+
expect(button.tabIndex).toBe(0);
|
|
2339
|
+
});
|
|
2340
|
+
|
|
2341
|
+
/* Both stay reachable from the keyboard while the frame is drawn. */
|
|
2342
|
+
focusElement(copyButton());
|
|
2343
|
+
expect(copyButton()).toHaveFocus();
|
|
2344
|
+
focusElement(downloadButton());
|
|
2345
|
+
expect(downloadButton()).toHaveFocus();
|
|
2346
|
+
|
|
2347
|
+
await settle((): void => {
|
|
2348
|
+
capture.resolve(makeScreenshot());
|
|
2349
|
+
});
|
|
2350
|
+
|
|
2351
|
+
buttons.forEach((button: HTMLElement): void => {
|
|
2352
|
+
expect(button).not.toHaveAttribute("disabled");
|
|
2353
|
+
expect(button).not.toHaveAttribute("aria-disabled");
|
|
2354
|
+
expect(button).not.toHaveClass("cursor-progress");
|
|
2355
|
+
});
|
|
2356
|
+
expect(downloadButton()).toHaveFocus();
|
|
2357
|
+
});
|
|
2358
|
+
|
|
2359
|
+
it("keeps focus on a pressed Download while it is busy, when it lands and after its card goes", async () => {
|
|
2360
|
+
const mocks: ActionMocks = makeMocks();
|
|
2361
|
+
const capture: Deferred<ReplayScreenshot> = holdCapture(mocks);
|
|
2362
|
+
|
|
2363
|
+
renderActions(mocks);
|
|
2364
|
+
focusElement(downloadButton());
|
|
2365
|
+
fireEvent.click(downloadButton());
|
|
2366
|
+
|
|
2367
|
+
expect(downloadButton()).toHaveAttribute("aria-disabled", "true");
|
|
2368
|
+
expect(downloadButton()).toHaveAttribute("data-state", "busy");
|
|
2369
|
+
expect(downloadButton()).toHaveFocus();
|
|
2370
|
+
|
|
2371
|
+
await settle((): void => {
|
|
2372
|
+
capture.resolve(makeScreenshot());
|
|
2373
|
+
});
|
|
2374
|
+
|
|
2375
|
+
expect(mocks.download).toHaveBeenCalledTimes(1);
|
|
2376
|
+
expect(downloadButton()).toHaveAttribute("data-state", "done");
|
|
2377
|
+
expect(downloadButton()).toHaveFocus();
|
|
2378
|
+
|
|
2379
|
+
advance(REPLAY_SCREENSHOT_DONE_MS);
|
|
2380
|
+
expect(downloadButton()).toHaveAttribute("data-state", "idle");
|
|
2381
|
+
expect(downloadButton()).toHaveFocus();
|
|
2382
|
+
|
|
2383
|
+
advance(REPLAY_SCREENSHOT_PREVIEW_MS);
|
|
2384
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
2385
|
+
expect(downloadButton()).toHaveFocus();
|
|
2386
|
+
});
|
|
2387
|
+
|
|
2388
|
+
it("does nothing when a busy button is pressed, and leaves focus where it is", async () => {
|
|
2389
|
+
const mocks: ActionMocks = makeMocks();
|
|
2390
|
+
const capture: Deferred<ReplayScreenshot> = holdCapture(mocks);
|
|
2391
|
+
|
|
2392
|
+
renderActions(mocks);
|
|
2393
|
+
focusElement(copyButton());
|
|
2394
|
+
fireEvent.click(copyButton());
|
|
2395
|
+
|
|
2396
|
+
/* Tabbed on to Download, which is marked busy, and pressed it. */
|
|
2397
|
+
focusElement(downloadButton());
|
|
2398
|
+
fireEvent.click(downloadButton());
|
|
2399
|
+
|
|
2400
|
+
expect(mocks.onCapture).toHaveBeenCalledTimes(1);
|
|
2401
|
+
expect(mocks.copyToClipboard).toHaveBeenCalledTimes(1);
|
|
2402
|
+
expect(mocks.download).not.toHaveBeenCalled();
|
|
2403
|
+
expect(downloadButton()).toHaveAttribute("data-state", "idle");
|
|
2404
|
+
expect(downloadButton()).not.toHaveAttribute("aria-busy");
|
|
2405
|
+
expect(downloadButton()).toHaveFocus();
|
|
2406
|
+
|
|
2407
|
+
/* Nor does pressing the busy button itself again. */
|
|
2408
|
+
fireEvent.click(copyButton());
|
|
2409
|
+
|
|
2410
|
+
expect(mocks.onCapture).toHaveBeenCalledTimes(1);
|
|
2411
|
+
expect(mocks.copyToClipboard).toHaveBeenCalledTimes(1);
|
|
2412
|
+
expect(copyButton()).toHaveAttribute("data-state", "busy");
|
|
2413
|
+
expect(downloadButton()).toHaveFocus();
|
|
2414
|
+
|
|
2415
|
+
await settle((): void => {
|
|
2416
|
+
capture.resolve(makeScreenshot());
|
|
2417
|
+
});
|
|
2418
|
+
|
|
2419
|
+
expect(mocks.download).not.toHaveBeenCalled();
|
|
2420
|
+
expect(previewCard()).toHaveAttribute("data-action", "copy");
|
|
2421
|
+
/* The copy's success does not pull focus back to Copy image. */
|
|
2422
|
+
expect(downloadButton()).toHaveFocus();
|
|
2423
|
+
});
|
|
2424
|
+
|
|
2425
|
+
DOCK_ACTIONS.forEach((action: "copy" | "download"): void => {
|
|
2426
|
+
it(`keeps focus on the pressed ${action} button when its capture fails`, async () => {
|
|
2427
|
+
const mocks: ActionMocks = makeMocks();
|
|
2428
|
+
|
|
2429
|
+
failNextCapture(mocks);
|
|
2430
|
+
renderActions(mocks);
|
|
2431
|
+
focusElement(dockButton(action));
|
|
2432
|
+
|
|
2433
|
+
await clickAndSettle(dockButton(action));
|
|
2434
|
+
|
|
2435
|
+
expect(errorCard()).toHaveAttribute("data-recovery", "retry");
|
|
2436
|
+
expect(dockButton(action)).toHaveFocus();
|
|
2437
|
+
expect(dockButton(action)).not.toHaveAttribute("aria-disabled");
|
|
2438
|
+
expect(dockButton(action)).toHaveAttribute("data-state", "idle");
|
|
2439
|
+
});
|
|
2440
|
+
});
|
|
2441
|
+
|
|
2442
|
+
it("keeps focus on Copy image when the clipboard refuses the image", async () => {
|
|
2443
|
+
const mocks: ActionMocks = makeMocks();
|
|
2444
|
+
|
|
2445
|
+
refuseClipboard(mocks);
|
|
2446
|
+
renderActions(mocks);
|
|
2447
|
+
focusElement(copyButton());
|
|
2448
|
+
|
|
2449
|
+
await clickAndSettle(copyButton());
|
|
2450
|
+
|
|
2451
|
+
expect(errorCard()).toHaveAttribute("data-recovery", "download");
|
|
2452
|
+
expect(copyButton()).toHaveFocus();
|
|
2453
|
+
});
|
|
2454
|
+
|
|
2455
|
+
it("keeps focus on Copy image when the browser has no image clipboard", () => {
|
|
2456
|
+
renderActions(makeMocks(), { clipboardSupport: "unsupported" });
|
|
2457
|
+
focusElement(copyButton());
|
|
2458
|
+
|
|
2459
|
+
fireEvent.click(copyButton());
|
|
2460
|
+
|
|
2461
|
+
expect(errorCard()).toHaveAttribute("data-recovery", "download");
|
|
2462
|
+
expect(copyButton()).toHaveFocus();
|
|
2463
|
+
});
|
|
2464
|
+
});
|
|
2465
|
+
|
|
2466
|
+
describe("handing focus back to the dock", () => {
|
|
2467
|
+
function errorDismissButton(): HTMLElement {
|
|
2468
|
+
return screen.getByTestId("replay-screenshot-error-dismiss");
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
function previewDismissButton(): HTMLElement {
|
|
2472
|
+
return screen.getByTestId("replay-screenshot-preview-dismiss");
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
DOCK_ACTIONS.forEach((action: "copy" | "download"): void => {
|
|
2476
|
+
it(`gives focus to ${action} when its error card is dismissed`, async () => {
|
|
2477
|
+
const mocks: ActionMocks = makeMocks();
|
|
2478
|
+
|
|
2479
|
+
failNextCapture(mocks);
|
|
2480
|
+
renderActions(mocks);
|
|
2481
|
+
|
|
2482
|
+
await clickAndSettle(dockButton(action));
|
|
2483
|
+
|
|
2484
|
+
focusElement(errorDismissButton());
|
|
2485
|
+
expect(errorDismissButton()).toHaveFocus();
|
|
2486
|
+
|
|
2487
|
+
fireEvent.click(errorDismissButton());
|
|
2488
|
+
|
|
2489
|
+
expect(queryErrorCard()).not.toBeInTheDocument();
|
|
2490
|
+
expect(dockButton(action)).toHaveFocus();
|
|
2491
|
+
expect(document.body).not.toHaveFocus();
|
|
2492
|
+
});
|
|
2493
|
+
|
|
2494
|
+
it(`gives focus to ${action} when Try again runs its capture again`, async () => {
|
|
2495
|
+
const mocks: ActionMocks = makeMocks();
|
|
2496
|
+
const retry: Deferred<ReplayScreenshot> =
|
|
2497
|
+
makeDeferred<ReplayScreenshot>();
|
|
2498
|
+
|
|
2499
|
+
failNextCapture(mocks);
|
|
2500
|
+
mocks.onCapture.mockImplementationOnce(
|
|
2501
|
+
(): Promise<ReplayScreenshot> => {
|
|
2502
|
+
return retry.promise;
|
|
2503
|
+
},
|
|
2504
|
+
);
|
|
2505
|
+
renderActions(mocks);
|
|
2506
|
+
|
|
2507
|
+
await clickAndSettle(dockButton(action));
|
|
2508
|
+
|
|
2509
|
+
expect(recoverButton()).toHaveTextContent("Try again");
|
|
2510
|
+
focusElement(recoverButton());
|
|
2511
|
+
fireEvent.click(recoverButton());
|
|
2512
|
+
|
|
2513
|
+
expect(queryErrorCard()).not.toBeInTheDocument();
|
|
2514
|
+
expect(dockButton(action)).toHaveAttribute("data-state", "busy");
|
|
2515
|
+
expect(dockButton(action)).toHaveFocus();
|
|
2516
|
+
|
|
2517
|
+
await settle((): void => {
|
|
2518
|
+
retry.resolve(makeScreenshot());
|
|
2519
|
+
});
|
|
2520
|
+
|
|
2521
|
+
expect(previewCard()).toHaveAttribute("data-action", action);
|
|
2522
|
+
expect(dockButton(action)).toHaveFocus();
|
|
2523
|
+
});
|
|
2524
|
+
|
|
2525
|
+
it(`gives focus to ${action} when its confirmation is dismissed`, async () => {
|
|
2526
|
+
renderActions(makeMocks());
|
|
2527
|
+
|
|
2528
|
+
await clickAndSettle(dockButton(action));
|
|
2529
|
+
|
|
2530
|
+
focusElement(previewDismissButton());
|
|
2531
|
+
expect(previewDismissButton()).toHaveFocus();
|
|
2532
|
+
|
|
2533
|
+
fireEvent.click(previewDismissButton());
|
|
2534
|
+
|
|
2535
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
2536
|
+
expect(dockButton(action)).toHaveFocus();
|
|
2537
|
+
expect(document.body).not.toHaveFocus();
|
|
2538
|
+
});
|
|
2539
|
+
});
|
|
2540
|
+
|
|
2541
|
+
it("gives focus to Download when Download instead saves the PNG that was drawn", async () => {
|
|
2542
|
+
const shot: ReplayScreenshot = makeScreenshot();
|
|
2543
|
+
const mocks: ActionMocks = makeMocks(shot);
|
|
2544
|
+
|
|
2545
|
+
refuseClipboard(mocks);
|
|
2546
|
+
renderActions(mocks);
|
|
2547
|
+
|
|
2548
|
+
await clickAndSettle(copyButton());
|
|
2549
|
+
|
|
2550
|
+
expect(recoverButton()).toHaveTextContent("Download instead");
|
|
2551
|
+
focusElement(recoverButton());
|
|
2552
|
+
fireEvent.click(recoverButton());
|
|
2553
|
+
|
|
2554
|
+
expect(mocks.onCapture).toHaveBeenCalledTimes(1);
|
|
2555
|
+
expect(mocks.download).toHaveBeenCalledTimes(1);
|
|
2556
|
+
expect(mocks.download.mock.calls[0]?.[0]).toBe(shot);
|
|
2557
|
+
expect(queryErrorCard()).not.toBeInTheDocument();
|
|
2558
|
+
expect(downloadButton()).toHaveFocus();
|
|
2559
|
+
expect(copyButton()).not.toHaveFocus();
|
|
2560
|
+
});
|
|
2561
|
+
|
|
2562
|
+
it("gives focus to Download when Download instead has to draw the frame", async () => {
|
|
2563
|
+
const mocks: ActionMocks = makeMocks();
|
|
2564
|
+
const capture: Deferred<ReplayScreenshot> = holdCapture(mocks);
|
|
2565
|
+
|
|
2566
|
+
renderActions(mocks, { clipboardSupport: "unsupported" });
|
|
2567
|
+
|
|
2568
|
+
fireEvent.click(copyButton());
|
|
2569
|
+
focusElement(recoverButton());
|
|
2570
|
+
fireEvent.click(recoverButton());
|
|
2571
|
+
|
|
2572
|
+
expect(queryErrorCard()).not.toBeInTheDocument();
|
|
2573
|
+
expect(downloadButton()).toHaveAttribute("data-state", "busy");
|
|
2574
|
+
expect(downloadButton()).toHaveFocus();
|
|
2575
|
+
|
|
2576
|
+
await settle((): void => {
|
|
2577
|
+
capture.resolve(makeScreenshot());
|
|
2578
|
+
});
|
|
2579
|
+
|
|
2580
|
+
expect(mocks.download).toHaveBeenCalledTimes(1);
|
|
2581
|
+
expect(downloadButton()).toHaveFocus();
|
|
2582
|
+
});
|
|
2583
|
+
|
|
2584
|
+
it("gives focus to Download when saving from Download instead fails", async () => {
|
|
2585
|
+
const mocks: ActionMocks = makeMocks();
|
|
2586
|
+
|
|
2587
|
+
refuseClipboard(mocks);
|
|
2588
|
+
mocks.download.mockImplementationOnce((): void => {
|
|
2589
|
+
throw new Error("Downloads are blocked");
|
|
2590
|
+
});
|
|
2591
|
+
renderActions(mocks);
|
|
2592
|
+
|
|
2593
|
+
await clickAndSettle(copyButton());
|
|
2594
|
+
|
|
2595
|
+
focusElement(recoverButton());
|
|
2596
|
+
fireEvent.click(recoverButton());
|
|
2597
|
+
|
|
2598
|
+
/* A new card, the download's own, and focus on its button. */
|
|
2599
|
+
expect(errorCard()).toHaveAttribute("data-recovery", "retry");
|
|
2600
|
+
expect(downloadButton()).toHaveFocus();
|
|
2601
|
+
});
|
|
2602
|
+
|
|
2603
|
+
it("gives focus to the pressed button when a new capture takes the error card away", async () => {
|
|
2604
|
+
const mocks: ActionMocks = makeMocks();
|
|
2605
|
+
const capture: Deferred<ReplayScreenshot> = holdCapture(mocks);
|
|
2606
|
+
|
|
2607
|
+
renderActions(mocks, { clipboardSupport: "unsupported" });
|
|
2608
|
+
|
|
2609
|
+
fireEvent.click(copyButton());
|
|
2610
|
+
focusElement(errorDismissButton());
|
|
2611
|
+
|
|
2612
|
+
/* A pointer press does not move the focus by itself. */
|
|
2613
|
+
fireEvent.click(downloadButton());
|
|
2614
|
+
|
|
2615
|
+
expect(queryErrorCard()).not.toBeInTheDocument();
|
|
2616
|
+
expect(downloadButton()).toHaveFocus();
|
|
2617
|
+
|
|
2618
|
+
await settle((): void => {
|
|
2619
|
+
capture.resolve(makeScreenshot());
|
|
2620
|
+
});
|
|
2621
|
+
|
|
2622
|
+
expect(previewCard()).toHaveAttribute("data-action", "download");
|
|
2623
|
+
expect(downloadButton()).toHaveFocus();
|
|
2624
|
+
});
|
|
2625
|
+
|
|
2626
|
+
it("gives focus to the pressed button when a new capture replaces the confirmation", async () => {
|
|
2627
|
+
const mocks: ActionMocks = makeMocks();
|
|
2628
|
+
|
|
2629
|
+
renderActions(mocks);
|
|
2630
|
+
|
|
2631
|
+
await clickAndSettle(copyButton());
|
|
2632
|
+
|
|
2633
|
+
const next: Deferred<ReplayScreenshot> = holdCapture(mocks);
|
|
2634
|
+
|
|
2635
|
+
focusElement(previewDismissButton());
|
|
2636
|
+
fireEvent.click(downloadButton());
|
|
2637
|
+
|
|
2638
|
+
expect(downloadButton()).toHaveFocus();
|
|
2639
|
+
|
|
2640
|
+
await settle((): void => {
|
|
2641
|
+
next.resolve(makeScreenshot());
|
|
2642
|
+
});
|
|
2643
|
+
|
|
2644
|
+
expect(previewCard()).toHaveAttribute("data-action", "download");
|
|
2645
|
+
expect(downloadButton()).toHaveFocus();
|
|
2646
|
+
});
|
|
2647
|
+
|
|
2648
|
+
it("gives focus to Copy image when a copy the browser cannot make replaces the confirmation", async () => {
|
|
2649
|
+
const mocks: ActionMocks = makeMocks();
|
|
2650
|
+
const view: ReturnType<typeof render> = renderActions(mocks);
|
|
2651
|
+
|
|
2652
|
+
await clickAndSettle(downloadButton());
|
|
2653
|
+
rerenderActions(view, mocks, { clipboardSupport: "unsupported" });
|
|
2654
|
+
|
|
2655
|
+
focusElement(previewDismissButton());
|
|
2656
|
+
fireEvent.click(copyButton());
|
|
2657
|
+
|
|
2658
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
2659
|
+
expect(errorCard()).toHaveAttribute("data-recovery", "download");
|
|
2660
|
+
expect(copyButton()).toHaveFocus();
|
|
2661
|
+
});
|
|
2662
|
+
|
|
2663
|
+
/*
|
|
2664
|
+
* A seek while paused (ArrowLeft/ArrowRight still seek with a button
|
|
2665
|
+
* focused) changes the frame, and the failure's card goes with it.
|
|
2666
|
+
*/
|
|
2667
|
+
it("gives focus to the failed button when a new frame takes the error card away", async () => {
|
|
2668
|
+
const mocks: ActionMocks = makeMocks();
|
|
2669
|
+
|
|
2670
|
+
refuseClipboard(mocks);
|
|
2671
|
+
|
|
2672
|
+
const view: ReturnType<typeof render> = renderActions(mocks, {
|
|
2673
|
+
frameKey: "1:41200",
|
|
2674
|
+
});
|
|
2675
|
+
|
|
2676
|
+
await clickAndSettle(copyButton());
|
|
2677
|
+
|
|
2678
|
+
focusElement(recoverButton());
|
|
2679
|
+
expect(recoverButton()).toHaveFocus();
|
|
2680
|
+
|
|
2681
|
+
rerenderActions(view, mocks, { frameKey: "1:42200" });
|
|
2682
|
+
|
|
2683
|
+
expect(queryErrorCard()).not.toBeInTheDocument();
|
|
2684
|
+
expect(copyButton()).toHaveFocus();
|
|
2685
|
+
expect(document.body).not.toHaveFocus();
|
|
2686
|
+
});
|
|
2687
|
+
|
|
2688
|
+
it("never pulls focus into the dock from elsewhere on the page", async () => {
|
|
2689
|
+
const outside: HTMLButtonElement = document.createElement("button");
|
|
2690
|
+
|
|
2691
|
+
outside.textContent = "Play";
|
|
2692
|
+
document.body.appendChild(outside);
|
|
2693
|
+
|
|
2694
|
+
try {
|
|
2695
|
+
const mocks: ActionMocks = makeMocks();
|
|
2696
|
+
|
|
2697
|
+
renderActions(mocks);
|
|
2698
|
+
focusElement(outside);
|
|
2699
|
+
|
|
2700
|
+
await clickAndSettle(copyButton());
|
|
2701
|
+
expect(outside).toHaveFocus();
|
|
2702
|
+
|
|
2703
|
+
fireEvent.click(previewDismissButton());
|
|
2704
|
+
expect(outside).toHaveFocus();
|
|
2705
|
+
|
|
2706
|
+
failNextCapture(mocks);
|
|
2707
|
+
await clickAndSettle(downloadButton());
|
|
2708
|
+
expect(outside).toHaveFocus();
|
|
2709
|
+
|
|
2710
|
+
fireEvent.click(errorDismissButton());
|
|
2711
|
+
expect(outside).toHaveFocus();
|
|
2712
|
+
|
|
2713
|
+
await clickAndSettle(copyButton());
|
|
2714
|
+
advance(REPLAY_SCREENSHOT_PREVIEW_MS);
|
|
2715
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
2716
|
+
expect(outside).toHaveFocus();
|
|
2717
|
+
} finally {
|
|
2718
|
+
outside.remove();
|
|
2719
|
+
}
|
|
2720
|
+
});
|
|
2721
|
+
});
|
|
2722
|
+
|
|
2723
|
+
describe("the confirmation's own timer", () => {
|
|
2724
|
+
function previewDismissButton(): HTMLElement {
|
|
2725
|
+
return screen.getByTestId("replay-screenshot-preview-dismiss");
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
it("waits while the pointer is on it, then gives it the full interval once it leaves", async () => {
|
|
2729
|
+
renderActions(makeMocks());
|
|
2730
|
+
|
|
2731
|
+
await clickAndSettle(copyButton());
|
|
2732
|
+
|
|
2733
|
+
advance(REPLAY_SCREENSHOT_PREVIEW_MS - 1000);
|
|
2734
|
+
fireEvent.mouseEnter(previewCard());
|
|
2735
|
+
|
|
2736
|
+
advance(REPLAY_SCREENSHOT_PREVIEW_MS * 3);
|
|
2737
|
+
expect(previewCard()).toBeInTheDocument();
|
|
2738
|
+
expect(revokeObjectURL).not.toHaveBeenCalled();
|
|
2739
|
+
|
|
2740
|
+
fireEvent.mouseLeave(previewCard());
|
|
2741
|
+
|
|
2742
|
+
advance(REPLAY_SCREENSHOT_PREVIEW_MS - 1);
|
|
2743
|
+
expect(previewCard()).toBeInTheDocument();
|
|
2744
|
+
|
|
2745
|
+
advance(1);
|
|
2746
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
2747
|
+
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
|
|
2748
|
+
expect(revokeObjectURL).toHaveBeenCalledWith("blob:replay/1");
|
|
2749
|
+
});
|
|
2750
|
+
|
|
2751
|
+
it("waits while the keyboard is in it, then gives it the full interval once focus has left", async () => {
|
|
2752
|
+
renderActions(makeMocks());
|
|
2753
|
+
|
|
2754
|
+
await clickAndSettle(copyButton());
|
|
2755
|
+
|
|
2756
|
+
advance(1000);
|
|
2757
|
+
focusElement(previewDismissButton());
|
|
2758
|
+
|
|
2759
|
+
advance(REPLAY_SCREENSHOT_PREVIEW_MS * 3);
|
|
2760
|
+
expect(previewCard()).toBeInTheDocument();
|
|
2761
|
+
expect(previewDismissButton()).toHaveFocus();
|
|
2762
|
+
|
|
2763
|
+
/* Shift+Tab back to the dock. */
|
|
2764
|
+
focusElement(copyButton());
|
|
2765
|
+
|
|
2766
|
+
advance(REPLAY_SCREENSHOT_PREVIEW_MS - 1);
|
|
2767
|
+
expect(previewCard()).toBeInTheDocument();
|
|
2768
|
+
|
|
2769
|
+
advance(1);
|
|
2770
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
2771
|
+
expect(copyButton()).toHaveFocus();
|
|
2772
|
+
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
|
|
2773
|
+
});
|
|
2774
|
+
|
|
2775
|
+
it("stays when its interval runs out with the keyboard still in it", async () => {
|
|
2776
|
+
renderActions(makeMocks());
|
|
2777
|
+
|
|
2778
|
+
await clickAndSettle(downloadButton());
|
|
2779
|
+
|
|
2780
|
+
/* The pointer passes over it while the keyboard is in it. */
|
|
2781
|
+
fireEvent.mouseEnter(previewCard());
|
|
2782
|
+
focusElement(previewDismissButton());
|
|
2783
|
+
fireEvent.mouseLeave(previewCard());
|
|
2784
|
+
|
|
2785
|
+
advance(REPLAY_SCREENSHOT_PREVIEW_MS * 2);
|
|
2786
|
+
expect(previewCard()).toBeInTheDocument();
|
|
2787
|
+
expect(previewDismissButton()).toHaveFocus();
|
|
2788
|
+
|
|
2789
|
+
focusElement(downloadButton());
|
|
2790
|
+
|
|
2791
|
+
advance(REPLAY_SCREENSHOT_PREVIEW_MS);
|
|
2792
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
2793
|
+
expect(downloadButton()).toHaveFocus();
|
|
2794
|
+
});
|
|
2795
|
+
|
|
2796
|
+
it("does not count focus moving within it as leaving", async () => {
|
|
2797
|
+
renderActions(makeMocks());
|
|
2798
|
+
|
|
2799
|
+
await clickAndSettle(copyButton());
|
|
2800
|
+
|
|
2801
|
+
focusElement(previewDismissButton());
|
|
2802
|
+
fireEvent.blur(previewDismissButton(), { relatedTarget: previewCard() });
|
|
2803
|
+
|
|
2804
|
+
advance(REPLAY_SCREENSHOT_PREVIEW_MS * 2);
|
|
2805
|
+
expect(previewCard()).toBeInTheDocument();
|
|
2806
|
+
});
|
|
2807
|
+
|
|
2808
|
+
it("waits for a focus that leaves to nowhere, then goes", async () => {
|
|
2809
|
+
renderActions(makeMocks());
|
|
2810
|
+
|
|
2811
|
+
await clickAndSettle(copyButton());
|
|
2812
|
+
|
|
2813
|
+
focusElement(previewDismissButton());
|
|
2814
|
+
act((): void => {
|
|
2815
|
+
previewDismissButton().blur();
|
|
2816
|
+
});
|
|
2817
|
+
|
|
2818
|
+
expect(document.body).toHaveFocus();
|
|
2819
|
+
|
|
2820
|
+
advance(REPLAY_SCREENSHOT_PREVIEW_MS - 1);
|
|
2821
|
+
expect(previewCard()).toBeInTheDocument();
|
|
2822
|
+
|
|
2823
|
+
advance(1);
|
|
2824
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
2825
|
+
});
|
|
2826
|
+
|
|
2827
|
+
it("stays while the pointer rests on it after the keyboard has left it", async () => {
|
|
2828
|
+
renderActions(makeMocks());
|
|
2829
|
+
|
|
2830
|
+
await clickAndSettle(copyButton());
|
|
2831
|
+
|
|
2832
|
+
fireEvent.mouseEnter(previewCard());
|
|
2833
|
+
focusElement(previewDismissButton());
|
|
2834
|
+
focusElement(copyButton());
|
|
2835
|
+
|
|
2836
|
+
advance(REPLAY_SCREENSHOT_PREVIEW_MS * 2);
|
|
2837
|
+
expect(previewCard()).toBeInTheDocument();
|
|
2838
|
+
|
|
2839
|
+
fireEvent.mouseLeave(previewCard());
|
|
2840
|
+
|
|
2841
|
+
advance(REPLAY_SCREENSHOT_PREVIEW_MS);
|
|
2842
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
2843
|
+
});
|
|
2844
|
+
});
|
|
2845
|
+
|
|
2846
|
+
describe("on a short stage", () => {
|
|
2847
|
+
it("confirms a copy with a pill beside the dock instead of the thumbnail card", async () => {
|
|
2848
|
+
renderActions(makeMocks());
|
|
2849
|
+
setStageHeight(224);
|
|
2850
|
+
|
|
2851
|
+
await clickAndSettle(copyButton());
|
|
2852
|
+
|
|
2853
|
+
const pill: HTMLElement = previewCard();
|
|
2854
|
+
|
|
2855
|
+
expect(screen.getAllByTestId("replay-screenshot-preview")).toHaveLength(
|
|
2856
|
+
1,
|
|
2857
|
+
);
|
|
2858
|
+
expect(pill).toHaveAttribute("data-compact", "true");
|
|
2859
|
+
expect(pill).toHaveAttribute("data-action", "copy");
|
|
2860
|
+
expect(pill).toHaveTextContent("Copied");
|
|
2861
|
+
expect(pill).not.toHaveTextContent("Copied to clipboard");
|
|
2862
|
+
expect(pill).toHaveAttribute(
|
|
2863
|
+
"title",
|
|
2864
|
+
"Copied to clipboard: 1440 × 900 PNG",
|
|
2865
|
+
);
|
|
2866
|
+
expect(
|
|
2867
|
+
screen.queryByTestId("replay-screenshot-thumbnail"),
|
|
2868
|
+
).not.toBeInTheDocument();
|
|
2869
|
+
expect(
|
|
2870
|
+
screen.queryByTestId("replay-screenshot-preview-detail"),
|
|
2871
|
+
).not.toBeInTheDocument();
|
|
2872
|
+
|
|
2873
|
+
/* In the dock's own row, just before the buttons - not above them. */
|
|
2874
|
+
const group: HTMLElement = screen.getByTestId(
|
|
2875
|
+
"replay-screenshot-actions",
|
|
2876
|
+
);
|
|
2877
|
+
|
|
2878
|
+
expect(pill.parentElement).toBe(group.parentElement);
|
|
2879
|
+
expect(pill.nextElementSibling).toBe(group);
|
|
2880
|
+
expect(pill.parentElement).not.toBe(
|
|
2881
|
+
screen.getByTestId("replay-screenshot"),
|
|
2882
|
+
);
|
|
2883
|
+
expect(within(pill).getByRole("button", { name: "Dismiss" })).toBe(
|
|
2884
|
+
screen.getByTestId("replay-screenshot-preview-dismiss"),
|
|
2885
|
+
);
|
|
2886
|
+
|
|
2887
|
+
/* Everything else about a confirmation is unchanged. */
|
|
2888
|
+
expect(copyButton()).toHaveAttribute("data-state", "done");
|
|
2889
|
+
expect(statusText()).toBe("Screenshot copied to the clipboard.");
|
|
2890
|
+
expect(screen.getByTestId("replay-screenshot-flash")).toBeInTheDocument();
|
|
2891
|
+
});
|
|
2892
|
+
|
|
2893
|
+
it("says Saved for a download, with the file name in its title", async () => {
|
|
2894
|
+
renderActions(makeMocks());
|
|
2895
|
+
setStageHeight(224);
|
|
2896
|
+
|
|
2897
|
+
await clickAndSettle(downloadButton());
|
|
2898
|
+
|
|
2899
|
+
const pill: HTMLElement = previewCard();
|
|
2900
|
+
|
|
2901
|
+
expect(pill).toHaveAttribute("data-compact", "true");
|
|
2902
|
+
expect(pill).toHaveAttribute("data-action", "download");
|
|
2903
|
+
expect(pill).toHaveTextContent("Saved");
|
|
2904
|
+
expect(pill).not.toHaveTextContent(FILE_NAME);
|
|
2905
|
+
expect(pill).toHaveAttribute(
|
|
2906
|
+
"title",
|
|
2907
|
+
`Screenshot downloaded: ${FILE_NAME}`,
|
|
2908
|
+
);
|
|
2909
|
+
expect(statusText()).toBe(`Screenshot downloaded as ${FILE_NAME}.`);
|
|
2910
|
+
});
|
|
2911
|
+
|
|
2912
|
+
it("leaves by itself after the preview interval and never leaks an object URL", async () => {
|
|
2913
|
+
renderActions(makeMocks());
|
|
2914
|
+
setStageHeight(224);
|
|
2915
|
+
|
|
2916
|
+
await clickAndSettle(copyButton());
|
|
2917
|
+
|
|
2918
|
+
advance(REPLAY_SCREENSHOT_PREVIEW_MS - 1);
|
|
2919
|
+
expect(previewCard()).toHaveAttribute("data-compact", "true");
|
|
2920
|
+
|
|
2921
|
+
advance(1);
|
|
2922
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
2923
|
+
expect(revokeObjectURL.mock.calls.length).toBe(
|
|
2924
|
+
createObjectURL.mock.calls.length,
|
|
2925
|
+
);
|
|
2926
|
+
});
|
|
2927
|
+
|
|
2928
|
+
it("holds the pill under the pointer the way it holds the card", async () => {
|
|
2929
|
+
renderActions(makeMocks());
|
|
2930
|
+
setStageHeight(224);
|
|
2931
|
+
|
|
2932
|
+
await clickAndSettle(copyButton());
|
|
2933
|
+
|
|
2934
|
+
fireEvent.mouseEnter(previewCard());
|
|
2935
|
+
advance(REPLAY_SCREENSHOT_PREVIEW_MS * 2);
|
|
2936
|
+
expect(previewCard()).toHaveAttribute("data-compact", "true");
|
|
2937
|
+
|
|
2938
|
+
fireEvent.mouseLeave(previewCard());
|
|
2939
|
+
advance(REPLAY_SCREENSHOT_PREVIEW_MS);
|
|
2940
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
2941
|
+
});
|
|
2942
|
+
|
|
2943
|
+
it("hands focus back to the dock from the pill's Dismiss", async () => {
|
|
2944
|
+
renderActions(makeMocks());
|
|
2945
|
+
setStageHeight(224);
|
|
2946
|
+
|
|
2947
|
+
await clickAndSettle(downloadButton());
|
|
2948
|
+
|
|
2949
|
+
const dismiss: HTMLElement = screen.getByTestId(
|
|
2950
|
+
"replay-screenshot-preview-dismiss",
|
|
2951
|
+
);
|
|
2952
|
+
|
|
2953
|
+
focusElement(dismiss);
|
|
2954
|
+
advance(REPLAY_SCREENSHOT_PREVIEW_MS * 2);
|
|
2955
|
+
expect(previewCard()).toHaveAttribute("data-compact", "true");
|
|
2956
|
+
|
|
2957
|
+
fireEvent.click(dismiss);
|
|
2958
|
+
|
|
2959
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
2960
|
+
expect(downloadButton()).toHaveFocus();
|
|
2961
|
+
});
|
|
2962
|
+
|
|
2963
|
+
const stages: Array<{ height: number; isCompact: boolean; label: string }> =
|
|
2964
|
+
[
|
|
2965
|
+
{ height: 1, isCompact: true, label: "a 1px" },
|
|
2966
|
+
{
|
|
2967
|
+
height: REPLAY_SCREENSHOT_COMPACT_STAGE_PX - 1,
|
|
2968
|
+
isCompact: true,
|
|
2969
|
+
label: "a just-too-short",
|
|
2970
|
+
},
|
|
2971
|
+
{
|
|
2972
|
+
height: REPLAY_SCREENSHOT_COMPACT_STAGE_PX,
|
|
2973
|
+
isCompact: false,
|
|
2974
|
+
label: "a threshold-high",
|
|
2975
|
+
},
|
|
2976
|
+
{ height: 720, isCompact: false, label: "a desktop" },
|
|
2977
|
+
/* 0 is "not measured" (no layout yet), not "no room". */
|
|
2978
|
+
{ height: 0, isCompact: false, label: "an unmeasured" },
|
|
2979
|
+
];
|
|
2980
|
+
|
|
2981
|
+
stages.forEach(
|
|
2982
|
+
(stage: { height: number; isCompact: boolean; label: string }): void => {
|
|
2983
|
+
it(`draws the ${stage.isCompact ? "pill" : "thumbnail card"} on ${stage.label} stage (${stage.height}px)`, async () => {
|
|
2984
|
+
renderActions(makeMocks());
|
|
2985
|
+
setStageHeight(stage.height);
|
|
2986
|
+
|
|
2987
|
+
await clickAndSettle(copyButton());
|
|
2988
|
+
|
|
2989
|
+
const preview: HTMLElement = previewCard();
|
|
2990
|
+
|
|
2991
|
+
if (stage.isCompact) {
|
|
2992
|
+
expect(preview).toHaveAttribute("data-compact", "true");
|
|
2993
|
+
expect(
|
|
2994
|
+
screen.queryByTestId("replay-screenshot-thumbnail"),
|
|
2995
|
+
).not.toBeInTheDocument();
|
|
2996
|
+
expect(preview.nextElementSibling).toBe(
|
|
2997
|
+
screen.getByTestId("replay-screenshot-actions"),
|
|
2998
|
+
);
|
|
2999
|
+
} else {
|
|
3000
|
+
expect(preview).not.toHaveAttribute("data-compact");
|
|
3001
|
+
expect(preview).toHaveTextContent("Copied to clipboard");
|
|
3002
|
+
expect(
|
|
3003
|
+
screen.getByTestId("replay-screenshot-thumbnail"),
|
|
3004
|
+
).toHaveAttribute("src", "blob:replay/1");
|
|
3005
|
+
expect(
|
|
3006
|
+
screen.getByTestId("replay-screenshot-preview-detail"),
|
|
3007
|
+
).toHaveTextContent("1440 × 900 PNG");
|
|
3008
|
+
/* Stacked above the dock, in the column. */
|
|
3009
|
+
expect(preview.parentElement).toBe(
|
|
3010
|
+
screen.getByTestId("replay-screenshot"),
|
|
3011
|
+
);
|
|
3012
|
+
}
|
|
3013
|
+
});
|
|
3014
|
+
},
|
|
3015
|
+
);
|
|
3016
|
+
|
|
3017
|
+
it("measures the stage again for every capture", async () => {
|
|
3018
|
+
renderActions(makeMocks());
|
|
3019
|
+
setStageHeight(224);
|
|
3020
|
+
|
|
3021
|
+
await clickAndSettle(copyButton());
|
|
3022
|
+
expect(previewCard()).toHaveAttribute("data-compact", "true");
|
|
3023
|
+
|
|
3024
|
+
/* The phone turned to landscape, or the window grew. */
|
|
3025
|
+
setStageHeight(640);
|
|
3026
|
+
|
|
3027
|
+
await clickAndSettle(downloadButton());
|
|
3028
|
+
expect(previewCard()).not.toHaveAttribute("data-compact");
|
|
3029
|
+
expect(
|
|
3030
|
+
screen.getByTestId("replay-screenshot-thumbnail"),
|
|
3031
|
+
).toBeInTheDocument();
|
|
3032
|
+
|
|
3033
|
+
setStageHeight(200);
|
|
3034
|
+
|
|
3035
|
+
await clickAndSettle(copyButton());
|
|
3036
|
+
expect(previewCard()).toHaveAttribute("data-compact", "true");
|
|
3037
|
+
expect(screen.getAllByTestId("replay-screenshot-preview")).toHaveLength(
|
|
3038
|
+
1,
|
|
3039
|
+
);
|
|
3040
|
+
});
|
|
3041
|
+
|
|
3042
|
+
it("shows a failure as the full error card, even on a short stage", async () => {
|
|
3043
|
+
const mocks: ActionMocks = makeMocks();
|
|
3044
|
+
|
|
3045
|
+
renderActions(mocks, { clipboardSupport: "unsupported" });
|
|
3046
|
+
setStageHeight(224);
|
|
3047
|
+
|
|
3048
|
+
fireEvent.click(copyButton());
|
|
3049
|
+
|
|
3050
|
+
expect(errorCard()).toHaveAttribute("data-recovery", "download");
|
|
3051
|
+
expect(recoverButton()).toHaveTextContent("Download instead");
|
|
3052
|
+
expect(queryPreviewCard()).not.toBeInTheDocument();
|
|
3053
|
+
});
|
|
3054
|
+
});
|
|
3055
|
+
|
|
3056
|
+
describe("the PNG's size on the card", () => {
|
|
3057
|
+
it("names the PNG's own pixel size, not the recorded viewport", async () => {
|
|
3058
|
+
renderActions(
|
|
3059
|
+
makeMocks(
|
|
3060
|
+
makeScreenshot({
|
|
3061
|
+
width: 1200,
|
|
3062
|
+
height: 760,
|
|
3063
|
+
pixelWidth: 2400,
|
|
3064
|
+
pixelHeight: 1520,
|
|
3065
|
+
}),
|
|
3066
|
+
),
|
|
3067
|
+
);
|
|
3068
|
+
|
|
3069
|
+
await clickAndSettle(copyButton());
|
|
3070
|
+
|
|
3071
|
+
const detail: HTMLElement = screen.getByTestId(
|
|
3072
|
+
"replay-screenshot-preview-detail",
|
|
3073
|
+
);
|
|
3074
|
+
|
|
3075
|
+
expect(detail).toHaveTextContent("2400 × 1520 PNG");
|
|
3076
|
+
expect(detail).not.toHaveTextContent("1200 × 760");
|
|
3077
|
+
/* The thumbnail keeps the recorded viewport's shape. */
|
|
3078
|
+
expect(
|
|
3079
|
+
(screen.getByTestId("replay-screenshot-thumbnail") as HTMLImageElement)
|
|
3080
|
+
.style.aspectRatio,
|
|
3081
|
+
).toBe("1200 / 760");
|
|
3082
|
+
});
|
|
3083
|
+
|
|
3084
|
+
it("falls back to the recorded viewport when the screenshot has no pixel size", async () => {
|
|
3085
|
+
renderActions(makeMocks(makeScreenshot({ width: 1200, height: 760 })));
|
|
3086
|
+
|
|
3087
|
+
await clickAndSettle(copyButton());
|
|
3088
|
+
|
|
3089
|
+
expect(
|
|
3090
|
+
screen.getByTestId("replay-screenshot-preview-detail"),
|
|
3091
|
+
).toHaveTextContent("1200 × 760 PNG");
|
|
3092
|
+
});
|
|
3093
|
+
|
|
3094
|
+
it("puts the pixel size in the pill's title on a short stage", async () => {
|
|
3095
|
+
renderActions(
|
|
3096
|
+
makeMocks(
|
|
3097
|
+
makeScreenshot({
|
|
3098
|
+
width: 1200,
|
|
3099
|
+
height: 760,
|
|
3100
|
+
pixelWidth: 2400,
|
|
3101
|
+
pixelHeight: 1520,
|
|
3102
|
+
}),
|
|
3103
|
+
),
|
|
3104
|
+
);
|
|
3105
|
+
setStageHeight(224);
|
|
3106
|
+
|
|
3107
|
+
await clickAndSettle(copyButton());
|
|
3108
|
+
|
|
3109
|
+
expect(previewCard()).toHaveAttribute(
|
|
3110
|
+
"title",
|
|
3111
|
+
"Copied to clipboard: 2400 × 1520 PNG",
|
|
3112
|
+
);
|
|
3113
|
+
});
|
|
3114
|
+
|
|
3115
|
+
it("still names the file, not the size, for a download", async () => {
|
|
3116
|
+
renderActions(
|
|
3117
|
+
makeMocks(
|
|
3118
|
+
makeScreenshot({
|
|
3119
|
+
width: 1200,
|
|
3120
|
+
height: 760,
|
|
3121
|
+
pixelWidth: 2400,
|
|
3122
|
+
pixelHeight: 1520,
|
|
3123
|
+
}),
|
|
3124
|
+
),
|
|
3125
|
+
);
|
|
3126
|
+
|
|
3127
|
+
await clickAndSettle(downloadButton());
|
|
3128
|
+
|
|
3129
|
+
const detail: HTMLElement = screen.getByTestId(
|
|
3130
|
+
"replay-screenshot-preview-detail",
|
|
3131
|
+
);
|
|
3132
|
+
|
|
3133
|
+
expect(detail).toHaveTextContent(FILE_NAME);
|
|
3134
|
+
expect(detail).not.toHaveTextContent("2400");
|
|
3135
|
+
});
|
|
3136
|
+
});
|
|
3137
|
+
|
|
3138
|
+
describe("attempts that outlive their frame", () => {
|
|
3139
|
+
const FRAME_ONE: string = "1:41200";
|
|
3140
|
+
const FRAME_TWO: string = "1:52000";
|
|
3141
|
+
|
|
3142
|
+
function frameShots(): {
|
|
3143
|
+
stale: ReplayScreenshot;
|
|
3144
|
+
fresh: ReplayScreenshot;
|
|
3145
|
+
} {
|
|
3146
|
+
return {
|
|
3147
|
+
stale: makeScreenshot({
|
|
3148
|
+
fileName: "session-replay-3f9a2c1b-0m41.2s.png",
|
|
3149
|
+
}),
|
|
3150
|
+
fresh: makeScreenshot({
|
|
3151
|
+
fileName: "session-replay-3f9a2c1b-0m52.0s.png",
|
|
3152
|
+
}),
|
|
3153
|
+
};
|
|
3154
|
+
}
|
|
3155
|
+
|
|
3156
|
+
it("does not keep a download that lands after a seek for a later Download instead", async () => {
|
|
3157
|
+
const shots: { stale: ReplayScreenshot; fresh: ReplayScreenshot } =
|
|
3158
|
+
frameShots();
|
|
3159
|
+
const mocks: ActionMocks = makeMocks(shots.fresh);
|
|
3160
|
+
const first: Deferred<ReplayScreenshot> =
|
|
3161
|
+
makeDeferred<ReplayScreenshot>();
|
|
3162
|
+
|
|
3163
|
+
mocks.onCapture.mockImplementationOnce((): Promise<ReplayScreenshot> => {
|
|
3164
|
+
return first.promise;
|
|
3165
|
+
});
|
|
3166
|
+
|
|
3167
|
+
const view: ReturnType<typeof render> = renderActions(mocks, {
|
|
3168
|
+
frameKey: FRAME_ONE,
|
|
3169
|
+
});
|
|
3170
|
+
|
|
3171
|
+
fireEvent.click(downloadButton());
|
|
3172
|
+
rerenderActions(view, mocks, { frameKey: FRAME_TWO });
|
|
3173
|
+
|
|
3174
|
+
await settle((): void => {
|
|
3175
|
+
first.resolve(shots.stale);
|
|
3176
|
+
});
|
|
3177
|
+
|
|
3178
|
+
/* The download itself still saves what was asked for. */
|
|
3179
|
+
expect(mocks.download).toHaveBeenCalledTimes(1);
|
|
3180
|
+
expect(mocks.download.mock.calls[0]?.[0]).toBe(shots.stale);
|
|
3181
|
+
|
|
3182
|
+
rerenderActions(view, mocks, {
|
|
3183
|
+
frameKey: FRAME_TWO,
|
|
3184
|
+
clipboardSupport: "unsupported",
|
|
3185
|
+
});
|
|
3186
|
+
fireEvent.click(copyButton());
|
|
3187
|
+
|
|
3188
|
+
expect(errorCard()).toHaveAttribute("data-recovery", "download");
|
|
3189
|
+
|
|
3190
|
+
await clickAndSettle(recoverButton());
|
|
3191
|
+
|
|
3192
|
+
/* Drawn afresh: the stale PNG is not what the stage shows now. */
|
|
3193
|
+
expect(mocks.onCapture).toHaveBeenCalledTimes(2);
|
|
3194
|
+
expect(mocks.download).toHaveBeenCalledTimes(2);
|
|
3195
|
+
expect(mocks.download.mock.calls[1]?.[0]).toBe(shots.fresh);
|
|
3196
|
+
expect(previewCard()).toHaveTextContent(
|
|
3197
|
+
"session-replay-3f9a2c1b-0m52.0s.png",
|
|
3198
|
+
);
|
|
3199
|
+
});
|
|
3200
|
+
|
|
3201
|
+
it("keeps a download that lands on the same frame for a later Download instead", async () => {
|
|
3202
|
+
const shots: { stale: ReplayScreenshot; fresh: ReplayScreenshot } =
|
|
3203
|
+
frameShots();
|
|
3204
|
+
const mocks: ActionMocks = makeMocks(shots.stale);
|
|
3205
|
+
const view: ReturnType<typeof render> = renderActions(mocks, {
|
|
3206
|
+
frameKey: FRAME_ONE,
|
|
3207
|
+
});
|
|
3208
|
+
|
|
3209
|
+
await clickAndSettle(downloadButton());
|
|
3210
|
+
|
|
3211
|
+
rerenderActions(view, mocks, {
|
|
3212
|
+
frameKey: FRAME_ONE,
|
|
3213
|
+
clipboardSupport: "unsupported",
|
|
3214
|
+
});
|
|
3215
|
+
fireEvent.click(copyButton());
|
|
3216
|
+
fireEvent.click(recoverButton());
|
|
3217
|
+
|
|
3218
|
+
/* Saved again inside the click - nothing to draw. */
|
|
3219
|
+
expect(mocks.onCapture).toHaveBeenCalledTimes(1);
|
|
3220
|
+
expect(mocks.download).toHaveBeenCalledTimes(2);
|
|
3221
|
+
expect(mocks.download.mock.calls[1]?.[0]).toBe(shots.stale);
|
|
3222
|
+
});
|
|
3223
|
+
|
|
3224
|
+
it("does not keep a copy's PNG that lands after a seek, when the clipboard then refuses it", async () => {
|
|
3225
|
+
const shots: { stale: ReplayScreenshot; fresh: ReplayScreenshot } =
|
|
3226
|
+
frameShots();
|
|
3227
|
+
const mocks: ActionMocks = makeMocks(shots.fresh);
|
|
3228
|
+
const first: Deferred<ReplayScreenshot> =
|
|
3229
|
+
makeDeferred<ReplayScreenshot>();
|
|
3230
|
+
|
|
3231
|
+
mocks.onCapture.mockImplementationOnce((): Promise<ReplayScreenshot> => {
|
|
3232
|
+
return first.promise;
|
|
3233
|
+
});
|
|
3234
|
+
refuseClipboard(mocks);
|
|
3235
|
+
|
|
3236
|
+
const view: ReturnType<typeof render> = renderActions(mocks, {
|
|
3237
|
+
frameKey: FRAME_ONE,
|
|
3238
|
+
});
|
|
3239
|
+
|
|
3240
|
+
fireEvent.click(copyButton());
|
|
3241
|
+
rerenderActions(view, mocks, { frameKey: FRAME_TWO });
|
|
3242
|
+
|
|
3243
|
+
await settle((): void => {
|
|
3244
|
+
first.resolve(shots.stale);
|
|
3245
|
+
});
|
|
3246
|
+
|
|
3247
|
+
expect(errorCard()).toHaveTextContent("Clipboard access was blocked");
|
|
3248
|
+
expect(errorCard()).toHaveAttribute("data-recovery", "download");
|
|
3249
|
+
|
|
3250
|
+
await clickAndSettle(recoverButton());
|
|
3251
|
+
|
|
3252
|
+
expect(mocks.onCapture).toHaveBeenCalledTimes(2);
|
|
3253
|
+
expect(mocks.download).toHaveBeenCalledTimes(1);
|
|
3254
|
+
expect(mocks.download).not.toHaveBeenCalledWith(shots.stale);
|
|
3255
|
+
expect(mocks.download.mock.calls[0]?.[0]).toBe(shots.fresh);
|
|
3256
|
+
});
|
|
3257
|
+
|
|
3258
|
+
it("does nothing when a copy the browser cannot make is pressed while a capture is running", async () => {
|
|
3259
|
+
const mocks: ActionMocks = makeMocks();
|
|
3260
|
+
const capture: Deferred<ReplayScreenshot> = holdCapture(mocks);
|
|
3261
|
+
|
|
3262
|
+
renderActions(mocks, { clipboardSupport: "unsupported" });
|
|
3263
|
+
|
|
3264
|
+
fireEvent.click(downloadButton());
|
|
3265
|
+
focusElement(copyButton());
|
|
3266
|
+
fireEvent.click(copyButton());
|
|
3267
|
+
|
|
3268
|
+
/* No card over a capture that is still running, and no second one. */
|
|
3269
|
+
expect(queryErrorCard()).not.toBeInTheDocument();
|
|
3270
|
+
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
3271
|
+
expect(mocks.onCapture).toHaveBeenCalledTimes(1);
|
|
3272
|
+
expect(mocks.copyToClipboard).not.toHaveBeenCalled();
|
|
3273
|
+
expect(downloadButton()).toHaveAttribute("data-state", "busy");
|
|
3274
|
+
expect(copyButton()).toHaveAttribute("data-state", "idle");
|
|
3275
|
+
expect(copyButton()).toHaveFocus();
|
|
3276
|
+
expect(statusText()).toBe("");
|
|
3277
|
+
|
|
3278
|
+
await settle((): void => {
|
|
3279
|
+
capture.resolve(makeScreenshot());
|
|
3280
|
+
});
|
|
3281
|
+
|
|
3282
|
+
expect(queryErrorCard()).not.toBeInTheDocument();
|
|
3283
|
+
expect(previewCard()).toHaveAttribute("data-action", "download");
|
|
3284
|
+
expect(mocks.download).toHaveBeenCalledTimes(1);
|
|
3285
|
+
expect(copyButton()).toHaveFocus();
|
|
3286
|
+
});
|
|
3287
|
+
});
|
|
3288
|
+
});
|