@oneuptime/common 14.0.2 → 14.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Models/DatabaseModels/AlertFeed.ts +2 -0
- package/Models/DatabaseModels/IncidentAlert.ts +540 -0
- package/Models/DatabaseModels/IncidentFeed.ts +2 -0
- package/Models/DatabaseModels/Index.ts +2 -0
- package/Models/DatabaseModels/Project.ts +60 -0
- package/Models/DatabaseModels/RumApplication.ts +52 -1
- 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/Index.ts +4 -0
- package/Server/Services/IncidentAlertService.ts +1811 -0
- package/Server/Services/IncidentService.ts +210 -10
- package/Server/Services/IncidentStateTimelineService.ts +24 -0
- package/Server/Services/Index.ts +2 -0
- package/Server/Utils/SessionReplay/SessionReplayGateCache.ts +24 -2
- 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/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/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/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/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/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/ResourceOverviewTileTooltips.test.tsx +1066 -0
- package/Tests/App/Dashboard/RumOverviewPage.test.tsx +1195 -0
- package/Tests/App/Dashboard/ServerlessMetricTooltips.test.tsx +474 -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/TelemetryResourceTabTooltips.test.tsx +437 -0
- 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/Models/DatabaseModels/IncidentAlertModel.test.ts +554 -0
- package/Tests/Models/DatabaseModels/SessionReplayModels.test.ts +117 -0
- package/Tests/Server/Infrastructure/Postgres/AddIncidentAlertMigration.test.ts +341 -0
- package/Tests/Server/Infrastructure/Postgres/AddSessionReplaySameOriginTracePropagationMigration.test.ts +247 -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/IncidentAlertStateCascade.test.ts +970 -0
- package/Tests/Server/Services/IncidentCreateFromAlerts.test.ts +1284 -0
- package/Tests/Server/Utils/SessionReplay/SessionReplayGateCachePolicy.test.ts +103 -0
- package/Tests/Types/Rum/WebVitals.test.ts +173 -0
- package/Tests/UI/Components/FieldLabelInfoCardTooltip.test.tsx +413 -0
- package/Tests/UI/Components/InfoTooltip.test.tsx +242 -0
- 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/TableHeaderTooltip.test.tsx +662 -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/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/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/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
- package/Tests/UI/Utils/PermissionGate.test.ts +76 -0
- package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +696 -0
- package/Tests/Utils/Rum/SessionTraceState.test.ts +239 -0
- package/Types/Incident/IncidentAlertLink.ts +38 -0
- package/Types/Permission.ts +48 -0
- package/Types/Rum/SessionReplay.ts +25 -6
- package/Types/Rum/WebVitals.ts +52 -0
- package/UI/Components/Detail/FieldLabel.tsx +8 -0
- package/UI/Components/InfoCard/InfoCard.tsx +47 -1
- package/UI/Components/ModelTable/BaseModelTable.tsx +95 -17
- package/UI/Components/ModelTable/Column.ts +5 -0
- package/UI/Components/Table/TableHeader.tsx +47 -2
- package/UI/Components/Table/Types/Column.ts +7 -0
- package/UI/Components/Tooltip/InfoTooltip.tsx +78 -0
- package/UI/Utils/PermissionGate.ts +11 -2
- package/Utils/Incident/IncidentFromAlerts.ts +516 -0
- package/Utils/Rum/SessionTraceState.ts +212 -0
- package/build/dist/Models/DatabaseModels/AlertFeed.js +2 -0
- package/build/dist/Models/DatabaseModels/AlertFeed.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 +2 -0
- package/build/dist/Models/DatabaseModels/Index.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/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/Index.js +4 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.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/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 +2 -0
- package/build/dist/Server/Services/Index.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/Types/Incident/IncidentAlertLink.js +34 -0
- package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -0
- package/build/dist/Types/Permission.js +42 -0
- package/build/dist/Types/Permission.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/UI/Components/Detail/FieldLabel.js +2 -0
- package/build/dist/UI/Components/Detail/FieldLabel.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/ModelTable/BaseModelTable.js +66 -14
- package/build/dist/UI/Components/ModelTable/BaseModelTable.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/Tooltip/InfoTooltip.js +28 -0
- package/build/dist/UI/Components/Tooltip/InfoTooltip.js.map +1 -0
- package/build/dist/UI/Utils/PermissionGate.js +4 -2
- package/build/dist/UI/Utils/PermissionGate.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/Rum/SessionTraceState.js +154 -0
- package/build/dist/Utils/Rum/SessionTraceState.js.map +1 -0
- package/package.json +1 -1
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import "@testing-library/jest-dom";
|
|
2
|
-
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
|
3
3
|
/*
|
|
4
4
|
* The Dashboard has its own copy of react; Common's jest moduleNameMapper
|
|
5
5
|
* pins react and react-dom to this project's single copy for every
|
|
6
6
|
* importer (see the note at the top of ReplayStage.test.tsx).
|
|
7
7
|
*/
|
|
8
8
|
import * as React from "react";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
afterEach,
|
|
11
|
+
beforeEach,
|
|
12
|
+
describe,
|
|
13
|
+
expect,
|
|
14
|
+
it,
|
|
15
|
+
jest,
|
|
16
|
+
} from "@jest/globals";
|
|
10
17
|
import getJestMockFunction, { MockFunction } from "../../MockType";
|
|
11
18
|
import ReplayStageOverlays, {
|
|
12
19
|
REPLAY_GAP_TOAST_MS,
|
|
@@ -27,6 +34,8 @@ import {
|
|
|
27
34
|
} from "../../../../App/FeatureSet/Dashboard/src/Components/SessionReplay/Engine/ReplayEngineTypes";
|
|
28
35
|
import { ReplaySignal } from "../../../../App/FeatureSet/Dashboard/src/Components/SessionReplay/Rail/ReplaySignalTypes";
|
|
29
36
|
import { SessionReplayManifestChunk } from "../../../../App/FeatureSet/Dashboard/src/Components/SessionReplay/ReplayManifest";
|
|
37
|
+
import { ReplayScreenshot } from "../../../../App/FeatureSet/Dashboard/src/Components/SessionReplay/ReplayScreenshot";
|
|
38
|
+
import { ReplayFrameCaptureError } from "../../../../App/FeatureSet/Dashboard/src/Components/SessionReplay/ReplayFrameCapture";
|
|
30
39
|
|
|
31
40
|
/*
|
|
32
41
|
* Everything drawn over and around the picture. Pinned: the URL bar picks
|
|
@@ -1429,3 +1438,975 @@ describe("ReplayStageOverlays", () => {
|
|
|
1429
1438
|
});
|
|
1430
1439
|
});
|
|
1431
1440
|
});
|
|
1441
|
+
|
|
1442
|
+
/*
|
|
1443
|
+
* The paused frame's screenshot dock ("Copy image" / "Download"), as the
|
|
1444
|
+
* overlays host it. Pinned: it is offered only while the phase is paused,
|
|
1445
|
+
* the shell has handed over a capture callback and has not said the replay
|
|
1446
|
+
* document is undrawn (canCaptureFrame false), and never in text selection
|
|
1447
|
+
* or the no-footage mode; it sits in the stage box after the paused play
|
|
1448
|
+
* button, so it paints above it, clear of the scrollbars each fit draws;
|
|
1449
|
+
* a failure card belongs to one frame (generation + rounded playhead) and
|
|
1450
|
+
* goes when a seek while paused changes the frame; and the overlays hand
|
|
1451
|
+
* the dock no test seams, so its buttons reach the browser's clipboard
|
|
1452
|
+
* and anchor download exactly as the product does.
|
|
1453
|
+
*
|
|
1454
|
+
* The capture itself is stubbed: onCaptureFrame resolves (or rejects) the
|
|
1455
|
+
* way ReplayScreenshot's captureReplayerScreenshot does. jsdom has no
|
|
1456
|
+
* object URLs, no image clipboard and no navigation, so those are stubbed
|
|
1457
|
+
* per test and put back afterwards.
|
|
1458
|
+
*/
|
|
1459
|
+
|
|
1460
|
+
const SCREENSHOT_FILE_NAME: string = "session-replay-3f9a2c1b-0m41.2s.png";
|
|
1461
|
+
|
|
1462
|
+
interface FakeClipboardItem {
|
|
1463
|
+
items: Record<string, Promise<Blob>>;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
interface SavedDownload {
|
|
1467
|
+
href: string;
|
|
1468
|
+
fileName: string;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
interface Deferred<T> {
|
|
1472
|
+
promise: Promise<T>;
|
|
1473
|
+
resolve: (value: T) => void;
|
|
1474
|
+
reject: (error: unknown) => void;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function makeScreenshot(): ReplayScreenshot {
|
|
1478
|
+
return {
|
|
1479
|
+
blob: new Blob(["png-bytes"], { type: "image/png" }),
|
|
1480
|
+
fileName: SCREENSHOT_FILE_NAME,
|
|
1481
|
+
width: 1440,
|
|
1482
|
+
height: 900,
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
function makeDeferred<T>(): Deferred<T> {
|
|
1487
|
+
const deferred: Partial<Deferred<T>> = {};
|
|
1488
|
+
|
|
1489
|
+
deferred.promise = new Promise<T>(
|
|
1490
|
+
(resolve: (value: T) => void, reject: (error: unknown) => void): void => {
|
|
1491
|
+
deferred.resolve = resolve;
|
|
1492
|
+
deferred.reject = reject;
|
|
1493
|
+
},
|
|
1494
|
+
);
|
|
1495
|
+
|
|
1496
|
+
return deferred as Deferred<T>;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
function makeCapture(
|
|
1500
|
+
result?: ReplayScreenshot | Promise<ReplayScreenshot>,
|
|
1501
|
+
): MockFunction {
|
|
1502
|
+
const capture: MockFunction = getJestMockFunction();
|
|
1503
|
+
|
|
1504
|
+
capture.mockImplementation((): Promise<ReplayScreenshot> => {
|
|
1505
|
+
return Promise.resolve(result ?? makeScreenshot());
|
|
1506
|
+
});
|
|
1507
|
+
|
|
1508
|
+
return capture;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
function makeFailingCapture(error: unknown): MockFunction {
|
|
1512
|
+
const capture: MockFunction = getJestMockFunction();
|
|
1513
|
+
|
|
1514
|
+
capture.mockImplementation((): Promise<ReplayScreenshot> => {
|
|
1515
|
+
return Promise.reject(error);
|
|
1516
|
+
});
|
|
1517
|
+
|
|
1518
|
+
return capture;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
function rerenderOverlays(
|
|
1522
|
+
rerender: ReturnType<typeof render>["rerender"],
|
|
1523
|
+
overrides?: Partial<ReplayStageOverlaysProps>,
|
|
1524
|
+
): void {
|
|
1525
|
+
rerender(
|
|
1526
|
+
<ReplayStageOverlays {...makeProps(overrides)}>
|
|
1527
|
+
<div data-testid="fake-stage">stage</div>
|
|
1528
|
+
</ReplayStageOverlays>,
|
|
1529
|
+
);
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/* Lets the capture, the clipboard and the dock's handlers settle. */
|
|
1533
|
+
async function settle(): Promise<void> {
|
|
1534
|
+
await act(async (): Promise<void> => {
|
|
1535
|
+
for (let index: number = 0; index < 10; index++) {
|
|
1536
|
+
await Promise.resolve();
|
|
1537
|
+
}
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
function queryDock(): HTMLElement | null {
|
|
1542
|
+
return screen.queryByTestId("replay-screenshot-actions");
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
describe("ReplayStageOverlays screenshot dock", () => {
|
|
1546
|
+
let restorers: Array<() => void> = [];
|
|
1547
|
+
let createdUrls: Array<string> = [];
|
|
1548
|
+
let createdBlobs: Array<Blob> = [];
|
|
1549
|
+
let revokedUrls: Array<string> = [];
|
|
1550
|
+
let downloads: Array<SavedDownload> = [];
|
|
1551
|
+
|
|
1552
|
+
function stubProperty(
|
|
1553
|
+
target: Window | Navigator | typeof URL,
|
|
1554
|
+
key: string,
|
|
1555
|
+
value: unknown,
|
|
1556
|
+
): void {
|
|
1557
|
+
const original: PropertyDescriptor | undefined =
|
|
1558
|
+
Object.getOwnPropertyDescriptor(target, key);
|
|
1559
|
+
|
|
1560
|
+
restorers.push((): void => {
|
|
1561
|
+
if (original) {
|
|
1562
|
+
Object.defineProperty(target, key, original);
|
|
1563
|
+
} else {
|
|
1564
|
+
Reflect.deleteProperty(target, key);
|
|
1565
|
+
}
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
Object.defineProperty(target, key, {
|
|
1569
|
+
value: value,
|
|
1570
|
+
configurable: true,
|
|
1571
|
+
writable: true,
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/*
|
|
1576
|
+
* The anchor download's click reaches the document; cancelling it there
|
|
1577
|
+
* records the download and keeps jsdom from attempting a navigation.
|
|
1578
|
+
*/
|
|
1579
|
+
const recordDownload: (event: Event) => void = (event: Event): void => {
|
|
1580
|
+
const target: EventTarget | null = event.target;
|
|
1581
|
+
|
|
1582
|
+
if (
|
|
1583
|
+
target instanceof HTMLAnchorElement &&
|
|
1584
|
+
target.hasAttribute("download")
|
|
1585
|
+
) {
|
|
1586
|
+
event.preventDefault();
|
|
1587
|
+
downloads.push({
|
|
1588
|
+
href: target.getAttribute("href") ?? "",
|
|
1589
|
+
fileName: target.download,
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1592
|
+
};
|
|
1593
|
+
|
|
1594
|
+
/* An async Clipboard API with ClipboardItem, on a secure page. */
|
|
1595
|
+
function stubImageClipboard(write: MockFunction): Array<FakeClipboardItem> {
|
|
1596
|
+
const items: Array<FakeClipboardItem> = [];
|
|
1597
|
+
|
|
1598
|
+
class ClipboardItemStub implements FakeClipboardItem {
|
|
1599
|
+
public readonly items: Record<string, Promise<Blob>>;
|
|
1600
|
+
|
|
1601
|
+
public constructor(itemData: Record<string, Promise<Blob>>) {
|
|
1602
|
+
this.items = itemData;
|
|
1603
|
+
items.push(this);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
stubProperty(window, "isSecureContext", true);
|
|
1608
|
+
stubProperty(window, "ClipboardItem", ClipboardItemStub);
|
|
1609
|
+
stubProperty(navigator, "clipboard", { write: write });
|
|
1610
|
+
|
|
1611
|
+
return items;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
beforeEach(() => {
|
|
1615
|
+
restorers = [];
|
|
1616
|
+
createdUrls = [];
|
|
1617
|
+
createdBlobs = [];
|
|
1618
|
+
revokedUrls = [];
|
|
1619
|
+
downloads = [];
|
|
1620
|
+
|
|
1621
|
+
stubProperty(window.URL, "createObjectURL", (blob: Blob): string => {
|
|
1622
|
+
const url: string = `blob:replay/${createdUrls.length + 1}`;
|
|
1623
|
+
|
|
1624
|
+
createdUrls.push(url);
|
|
1625
|
+
createdBlobs.push(blob);
|
|
1626
|
+
|
|
1627
|
+
return url;
|
|
1628
|
+
});
|
|
1629
|
+
stubProperty(window.URL, "revokeObjectURL", (url: string): void => {
|
|
1630
|
+
revokedUrls.push(url);
|
|
1631
|
+
});
|
|
1632
|
+
|
|
1633
|
+
document.addEventListener("click", recordDownload);
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
afterEach(() => {
|
|
1637
|
+
document.removeEventListener("click", recordDownload);
|
|
1638
|
+
|
|
1639
|
+
for (const restore of restorers.reverse()) {
|
|
1640
|
+
restore();
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
restorers = [];
|
|
1644
|
+
});
|
|
1645
|
+
|
|
1646
|
+
describe("when it is offered", () => {
|
|
1647
|
+
const pausedStates: Array<{
|
|
1648
|
+
label: string;
|
|
1649
|
+
snapshot: Partial<ReplayEngineSnapshot>;
|
|
1650
|
+
}> = [
|
|
1651
|
+
{ label: "with footage in hand", snapshot: { buffer: "ok" } },
|
|
1652
|
+
{ label: "at a stall", snapshot: { buffer: "stalled" } },
|
|
1653
|
+
{ label: "before a gap jump", snapshot: { buffer: "gap-pending" } },
|
|
1654
|
+
];
|
|
1655
|
+
|
|
1656
|
+
for (const state of pausedStates) {
|
|
1657
|
+
it(`offers Copy image and Download while paused ${state.label}`, () => {
|
|
1658
|
+
renderOverlays({
|
|
1659
|
+
snapshot: makeSnapshot({ ...state.snapshot, intent: "paused" }),
|
|
1660
|
+
onCaptureFrame: makeCapture(),
|
|
1661
|
+
});
|
|
1662
|
+
|
|
1663
|
+
expect(screen.getByTestId("replay-overlay")).toHaveAttribute(
|
|
1664
|
+
"data-replay-overlay",
|
|
1665
|
+
"paused",
|
|
1666
|
+
);
|
|
1667
|
+
expect(queryDock()).toBeInTheDocument();
|
|
1668
|
+
expect(screen.getByTestId("replay-screenshot-copy")).toBeEnabled();
|
|
1669
|
+
expect(screen.getByTestId("replay-screenshot-download")).toBeEnabled();
|
|
1670
|
+
});
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
it("treats an unset canCaptureFrame as capturable, and true as well", () => {
|
|
1674
|
+
const { rerender } = renderOverlays({ onCaptureFrame: makeCapture() });
|
|
1675
|
+
|
|
1676
|
+
expect(queryDock()).toBeInTheDocument();
|
|
1677
|
+
|
|
1678
|
+
rerenderOverlays(rerender, {
|
|
1679
|
+
onCaptureFrame: makeCapture(),
|
|
1680
|
+
canCaptureFrame: true,
|
|
1681
|
+
});
|
|
1682
|
+
|
|
1683
|
+
expect(queryDock()).toBeInTheDocument();
|
|
1684
|
+
});
|
|
1685
|
+
|
|
1686
|
+
const hiddenStates: Array<{
|
|
1687
|
+
phase: string;
|
|
1688
|
+
label: string;
|
|
1689
|
+
overrides: Partial<ReplayStageOverlaysProps>;
|
|
1690
|
+
}> = [
|
|
1691
|
+
{
|
|
1692
|
+
phase: "playing",
|
|
1693
|
+
label: "playing",
|
|
1694
|
+
overrides: {
|
|
1695
|
+
snapshot: makeSnapshot({ buffer: "ok", intent: "playing" }),
|
|
1696
|
+
},
|
|
1697
|
+
},
|
|
1698
|
+
{
|
|
1699
|
+
phase: "playing",
|
|
1700
|
+
label: "playing up to a gap jump",
|
|
1701
|
+
overrides: {
|
|
1702
|
+
snapshot: makeSnapshot({ buffer: "gap-pending", intent: "playing" }),
|
|
1703
|
+
},
|
|
1704
|
+
},
|
|
1705
|
+
{
|
|
1706
|
+
phase: "buffering",
|
|
1707
|
+
label: "buffering",
|
|
1708
|
+
overrides: {
|
|
1709
|
+
snapshot: makeSnapshot({ buffer: "building", intent: "playing" }),
|
|
1710
|
+
},
|
|
1711
|
+
},
|
|
1712
|
+
{
|
|
1713
|
+
phase: "buffering",
|
|
1714
|
+
label: "stalled mid-playback",
|
|
1715
|
+
overrides: {
|
|
1716
|
+
snapshot: makeSnapshot({ buffer: "stalled", intent: "playing" }),
|
|
1717
|
+
},
|
|
1718
|
+
},
|
|
1719
|
+
{
|
|
1720
|
+
phase: "seeking",
|
|
1721
|
+
label: "seeking",
|
|
1722
|
+
overrides: {
|
|
1723
|
+
snapshot: makeSnapshot({
|
|
1724
|
+
buffer: "building",
|
|
1725
|
+
intent: "paused",
|
|
1726
|
+
pendingSeekMs: 72000,
|
|
1727
|
+
}),
|
|
1728
|
+
},
|
|
1729
|
+
},
|
|
1730
|
+
{
|
|
1731
|
+
phase: "loading",
|
|
1732
|
+
label: "loading",
|
|
1733
|
+
overrides: { snapshot: makeSnapshot({ buffer: "empty" }) },
|
|
1734
|
+
},
|
|
1735
|
+
{
|
|
1736
|
+
phase: "ended",
|
|
1737
|
+
label: "ended",
|
|
1738
|
+
overrides: {
|
|
1739
|
+
snapshot: makeSnapshot({
|
|
1740
|
+
buffer: "ended",
|
|
1741
|
+
currentTimeMs: DURATION_MS,
|
|
1742
|
+
}),
|
|
1743
|
+
},
|
|
1744
|
+
},
|
|
1745
|
+
{
|
|
1746
|
+
phase: "ended",
|
|
1747
|
+
label: "caught up with a live recording",
|
|
1748
|
+
overrides: {
|
|
1749
|
+
snapshot: makeSnapshot({
|
|
1750
|
+
buffer: "ended",
|
|
1751
|
+
currentTimeMs: DURATION_MS,
|
|
1752
|
+
}),
|
|
1753
|
+
isLive: true,
|
|
1754
|
+
},
|
|
1755
|
+
},
|
|
1756
|
+
{
|
|
1757
|
+
phase: "error",
|
|
1758
|
+
label: "stopped on an error",
|
|
1759
|
+
overrides: {
|
|
1760
|
+
snapshot: makeSnapshot({
|
|
1761
|
+
buffer: "halted",
|
|
1762
|
+
error: { message: "Footage did not arrive.", retryable: true },
|
|
1763
|
+
}),
|
|
1764
|
+
},
|
|
1765
|
+
},
|
|
1766
|
+
];
|
|
1767
|
+
|
|
1768
|
+
for (const state of hiddenStates) {
|
|
1769
|
+
it(`is not offered while ${state.label}`, () => {
|
|
1770
|
+
const capture: MockFunction = makeCapture();
|
|
1771
|
+
|
|
1772
|
+
renderOverlays({ ...state.overrides, onCaptureFrame: capture });
|
|
1773
|
+
|
|
1774
|
+
expect(screen.getByTestId("replay-overlay")).toHaveAttribute(
|
|
1775
|
+
"data-replay-overlay",
|
|
1776
|
+
state.phase,
|
|
1777
|
+
);
|
|
1778
|
+
expect(queryDock()).not.toBeInTheDocument();
|
|
1779
|
+
expect(
|
|
1780
|
+
screen.queryByTestId("replay-screenshot"),
|
|
1781
|
+
).not.toBeInTheDocument();
|
|
1782
|
+
expect(capture).not.toHaveBeenCalled();
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
it("is not offered while the shell says the replay document is not drawn", () => {
|
|
1787
|
+
renderOverlays({
|
|
1788
|
+
onCaptureFrame: makeCapture(),
|
|
1789
|
+
canCaptureFrame: false,
|
|
1790
|
+
});
|
|
1791
|
+
|
|
1792
|
+
expect(queryDock()).not.toBeInTheDocument();
|
|
1793
|
+
/* Only the dock waits for the document; Play is still offered. */
|
|
1794
|
+
expect(screen.getByTestId("replay-overlay-paused")).toBeInTheDocument();
|
|
1795
|
+
});
|
|
1796
|
+
|
|
1797
|
+
it("is not offered without a capture callback", () => {
|
|
1798
|
+
renderOverlays({ canCaptureFrame: true });
|
|
1799
|
+
|
|
1800
|
+
expect(screen.getByTestId("replay-overlay")).toHaveAttribute(
|
|
1801
|
+
"data-replay-overlay",
|
|
1802
|
+
"paused",
|
|
1803
|
+
);
|
|
1804
|
+
expect(queryDock()).not.toBeInTheDocument();
|
|
1805
|
+
});
|
|
1806
|
+
|
|
1807
|
+
it("steps aside while text is being selected and comes back after", () => {
|
|
1808
|
+
const capture: MockFunction = makeCapture();
|
|
1809
|
+
const { rerender } = renderOverlays({
|
|
1810
|
+
onCaptureFrame: capture,
|
|
1811
|
+
isTextSelectionEnabled: true,
|
|
1812
|
+
});
|
|
1813
|
+
|
|
1814
|
+
expect(screen.getByTestId("replay-select-text")).toHaveAttribute(
|
|
1815
|
+
"aria-pressed",
|
|
1816
|
+
"true",
|
|
1817
|
+
);
|
|
1818
|
+
expect(queryDock()).not.toBeInTheDocument();
|
|
1819
|
+
|
|
1820
|
+
rerenderOverlays(rerender, {
|
|
1821
|
+
onCaptureFrame: capture,
|
|
1822
|
+
isTextSelectionEnabled: false,
|
|
1823
|
+
});
|
|
1824
|
+
|
|
1825
|
+
expect(queryDock()).toBeInTheDocument();
|
|
1826
|
+
});
|
|
1827
|
+
|
|
1828
|
+
it("stays when text selection is asked for but cannot take effect", () => {
|
|
1829
|
+
renderOverlays({
|
|
1830
|
+
onCaptureFrame: makeCapture(),
|
|
1831
|
+
canSelectText: false,
|
|
1832
|
+
isTextSelectionEnabled: true,
|
|
1833
|
+
});
|
|
1834
|
+
|
|
1835
|
+
expect(screen.getByTestId("replay-select-text")).toHaveAttribute(
|
|
1836
|
+
"aria-pressed",
|
|
1837
|
+
"false",
|
|
1838
|
+
);
|
|
1839
|
+
expect(queryDock()).toBeInTheDocument();
|
|
1840
|
+
});
|
|
1841
|
+
|
|
1842
|
+
it("is never offered in the no-footage mode, even paused with a capture callback", () => {
|
|
1843
|
+
render(
|
|
1844
|
+
<ReplayStageOverlays
|
|
1845
|
+
{...makeProps({
|
|
1846
|
+
absence: { kind: "none-stored" },
|
|
1847
|
+
onCaptureFrame: makeCapture(),
|
|
1848
|
+
canCaptureFrame: true,
|
|
1849
|
+
})}
|
|
1850
|
+
>
|
|
1851
|
+
<div data-testid="fake-stage">stage</div>
|
|
1852
|
+
</ReplayStageOverlays>,
|
|
1853
|
+
);
|
|
1854
|
+
|
|
1855
|
+
expect(screen.getByTestId("replay-overlay")).toHaveAttribute(
|
|
1856
|
+
"data-replay-overlay",
|
|
1857
|
+
"absent",
|
|
1858
|
+
);
|
|
1859
|
+
expect(queryDock()).not.toBeInTheDocument();
|
|
1860
|
+
});
|
|
1861
|
+
|
|
1862
|
+
it("names the group and both actions for assistive technology", () => {
|
|
1863
|
+
renderOverlays({ onCaptureFrame: makeCapture() });
|
|
1864
|
+
|
|
1865
|
+
const group: HTMLElement = screen.getByRole("group", {
|
|
1866
|
+
name: "Screenshot of this frame",
|
|
1867
|
+
});
|
|
1868
|
+
|
|
1869
|
+
expect(group).toBe(queryDock());
|
|
1870
|
+
expect(
|
|
1871
|
+
within(group).getByRole("button", { name: "Copy image" }),
|
|
1872
|
+
).toBeInTheDocument();
|
|
1873
|
+
expect(
|
|
1874
|
+
within(group).getByRole("button", { name: "Download" }),
|
|
1875
|
+
).toBeInTheDocument();
|
|
1876
|
+
expect(screen.getByTestId("replay-screenshot-status")).toHaveAttribute(
|
|
1877
|
+
"aria-live",
|
|
1878
|
+
"polite",
|
|
1879
|
+
);
|
|
1880
|
+
});
|
|
1881
|
+
});
|
|
1882
|
+
|
|
1883
|
+
describe("where it is drawn", () => {
|
|
1884
|
+
it("sits in the stage box, after the stage and the paused play button", () => {
|
|
1885
|
+
renderOverlays({ onCaptureFrame: makeCapture() });
|
|
1886
|
+
|
|
1887
|
+
const container: HTMLElement = screen.getByTestId(
|
|
1888
|
+
"replay-stage-container",
|
|
1889
|
+
);
|
|
1890
|
+
const dock: HTMLElement = screen.getByTestId("replay-screenshot");
|
|
1891
|
+
const centre: HTMLElement = screen.getByTestId("replay-overlay-centre");
|
|
1892
|
+
const stage: HTMLElement = screen.getByTestId("fake-stage");
|
|
1893
|
+
|
|
1894
|
+
/* Positioned against the stage box, not inside another overlay. */
|
|
1895
|
+
expect(dock.parentElement).toBe(container);
|
|
1896
|
+
expect(dock.className).toContain("absolute");
|
|
1897
|
+
expect(container.className).toContain("relative");
|
|
1898
|
+
expect(dock).toContainElement(queryDock());
|
|
1899
|
+
|
|
1900
|
+
expect(centre).toContainElement(
|
|
1901
|
+
screen.getByTestId("replay-overlay-paused"),
|
|
1902
|
+
);
|
|
1903
|
+
expect(centre.contains(dock)).toBe(false);
|
|
1904
|
+
expect(
|
|
1905
|
+
centre.compareDocumentPosition(dock) & Node.DOCUMENT_POSITION_FOLLOWING,
|
|
1906
|
+
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
|
1907
|
+
expect(
|
|
1908
|
+
stage.compareDocumentPosition(dock) & Node.DOCUMENT_POSITION_FOLLOWING,
|
|
1909
|
+
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
|
1910
|
+
});
|
|
1911
|
+
|
|
1912
|
+
it("keeps the paused play button working beside it", () => {
|
|
1913
|
+
const onPlayPause: MockFunction = getJestMockFunction();
|
|
1914
|
+
const capture: MockFunction = makeCapture();
|
|
1915
|
+
|
|
1916
|
+
renderOverlays({ onCaptureFrame: capture, onPlayPause: onPlayPause });
|
|
1917
|
+
|
|
1918
|
+
fireEvent.click(screen.getByTestId("replay-overlay-paused"));
|
|
1919
|
+
|
|
1920
|
+
expect(onPlayPause).toHaveBeenCalledTimes(1);
|
|
1921
|
+
expect(capture).not.toHaveBeenCalled();
|
|
1922
|
+
});
|
|
1923
|
+
|
|
1924
|
+
it("lives in the stage box in theater sizing too", () => {
|
|
1925
|
+
renderOverlays({ onCaptureFrame: makeCapture(), sizing: "fill" });
|
|
1926
|
+
|
|
1927
|
+
expect(screen.getByTestId("replay-screenshot").parentElement).toBe(
|
|
1928
|
+
screen.getByTestId("replay-stage-container"),
|
|
1929
|
+
);
|
|
1930
|
+
});
|
|
1931
|
+
|
|
1932
|
+
it("clears the scrollbars each fit draws", () => {
|
|
1933
|
+
const capture: MockFunction = makeCapture();
|
|
1934
|
+
const { rerender } = renderOverlays({
|
|
1935
|
+
onCaptureFrame: capture,
|
|
1936
|
+
fit: "contain",
|
|
1937
|
+
});
|
|
1938
|
+
|
|
1939
|
+
const positionOf: () => string = (): string => {
|
|
1940
|
+
return screen.getByTestId("replay-screenshot").className;
|
|
1941
|
+
};
|
|
1942
|
+
|
|
1943
|
+
expect(positionOf()).toContain("bottom-3");
|
|
1944
|
+
expect(positionOf()).toContain("right-3");
|
|
1945
|
+
expect(positionOf()).not.toContain("right-6");
|
|
1946
|
+
|
|
1947
|
+
/* Width scrolls vertically: clear the vertical scrollbar. */
|
|
1948
|
+
rerenderOverlays(rerender, { onCaptureFrame: capture, fit: "width" });
|
|
1949
|
+
|
|
1950
|
+
expect(positionOf()).toContain("right-6");
|
|
1951
|
+
expect(positionOf()).toContain("bottom-3");
|
|
1952
|
+
expect(positionOf()).not.toContain("bottom-6");
|
|
1953
|
+
|
|
1954
|
+
/* 1:1 scrolls both ways: clear both scrollbars. */
|
|
1955
|
+
rerenderOverlays(rerender, { onCaptureFrame: capture, fit: "actual" });
|
|
1956
|
+
|
|
1957
|
+
expect(positionOf()).toContain("right-6");
|
|
1958
|
+
expect(positionOf()).toContain("bottom-6");
|
|
1959
|
+
});
|
|
1960
|
+
});
|
|
1961
|
+
|
|
1962
|
+
describe("capturing", () => {
|
|
1963
|
+
it("captures on Download and saves the PNG under the screenshot's name", async () => {
|
|
1964
|
+
const screenshot: ReplayScreenshot = makeScreenshot();
|
|
1965
|
+
const capture: MockFunction = makeCapture(screenshot);
|
|
1966
|
+
|
|
1967
|
+
renderOverlays({ onCaptureFrame: capture });
|
|
1968
|
+
|
|
1969
|
+
fireEvent.click(screen.getByRole("button", { name: "Download" }));
|
|
1970
|
+
await settle();
|
|
1971
|
+
|
|
1972
|
+
expect(capture).toHaveBeenCalledTimes(1);
|
|
1973
|
+
expect(capture).toHaveBeenCalledWith();
|
|
1974
|
+
|
|
1975
|
+
/* The real anchor download, from an object URL of the PNG. */
|
|
1976
|
+
expect(downloads).toEqual([
|
|
1977
|
+
{ href: "blob:replay/1", fileName: SCREENSHOT_FILE_NAME },
|
|
1978
|
+
]);
|
|
1979
|
+
expect(createdBlobs[0]).toBe(screenshot.blob);
|
|
1980
|
+
expect(document.querySelector("a[download]")).toBeNull();
|
|
1981
|
+
/* Revoked later, never in the click's own task (Safari cancels it). */
|
|
1982
|
+
expect(revokedUrls).not.toContain("blob:replay/1");
|
|
1983
|
+
|
|
1984
|
+
const preview: HTMLElement = screen.getByTestId(
|
|
1985
|
+
"replay-screenshot-preview",
|
|
1986
|
+
);
|
|
1987
|
+
|
|
1988
|
+
expect(preview).toHaveAttribute("data-action", "download");
|
|
1989
|
+
expect(preview).toHaveTextContent("Screenshot downloaded");
|
|
1990
|
+
expect(
|
|
1991
|
+
screen.getByTestId("replay-screenshot-preview-detail"),
|
|
1992
|
+
).toHaveTextContent(SCREENSHOT_FILE_NAME);
|
|
1993
|
+
expect(screen.getByTestId("replay-screenshot-thumbnail")).toHaveAttribute(
|
|
1994
|
+
"src",
|
|
1995
|
+
"blob:replay/2",
|
|
1996
|
+
);
|
|
1997
|
+
expect(createdBlobs[1]).toBe(screenshot.blob);
|
|
1998
|
+
expect(screen.getByTestId("replay-screenshot-status")).toHaveTextContent(
|
|
1999
|
+
`Screenshot downloaded as ${SCREENSHOT_FILE_NAME}.`,
|
|
2000
|
+
);
|
|
2001
|
+
expect(screen.getByTestId("replay-screenshot-download")).toHaveAttribute(
|
|
2002
|
+
"data-state",
|
|
2003
|
+
"done",
|
|
2004
|
+
);
|
|
2005
|
+
});
|
|
2006
|
+
|
|
2007
|
+
it("flashes the stage box, not the page, when the frame lands", async () => {
|
|
2008
|
+
renderOverlays({ onCaptureFrame: makeCapture() });
|
|
2009
|
+
|
|
2010
|
+
expect(
|
|
2011
|
+
screen.queryByTestId("replay-screenshot-flash"),
|
|
2012
|
+
).not.toBeInTheDocument();
|
|
2013
|
+
|
|
2014
|
+
fireEvent.click(screen.getByRole("button", { name: "Download" }));
|
|
2015
|
+
await settle();
|
|
2016
|
+
|
|
2017
|
+
const flash: HTMLElement = screen.getByTestId("replay-screenshot-flash");
|
|
2018
|
+
|
|
2019
|
+
expect(flash.parentElement).toBe(
|
|
2020
|
+
screen.getByTestId("replay-stage-container"),
|
|
2021
|
+
);
|
|
2022
|
+
expect(flash).toHaveAttribute("aria-hidden", "true");
|
|
2023
|
+
expect(flash.className).toContain("pointer-events-none");
|
|
2024
|
+
});
|
|
2025
|
+
|
|
2026
|
+
it("captures once however often it is pressed while a capture is running", async () => {
|
|
2027
|
+
const deferred: Deferred<ReplayScreenshot> = makeDeferred();
|
|
2028
|
+
const capture: MockFunction = makeCapture(deferred.promise);
|
|
2029
|
+
|
|
2030
|
+
renderOverlays({ onCaptureFrame: capture });
|
|
2031
|
+
|
|
2032
|
+
const download: HTMLElement = screen.getByTestId(
|
|
2033
|
+
"replay-screenshot-download",
|
|
2034
|
+
);
|
|
2035
|
+
|
|
2036
|
+
fireEvent.click(download);
|
|
2037
|
+
fireEvent.click(download);
|
|
2038
|
+
fireEvent.click(screen.getByTestId("replay-screenshot-copy"));
|
|
2039
|
+
|
|
2040
|
+
expect(capture).toHaveBeenCalledTimes(1);
|
|
2041
|
+
expect(download).toHaveAttribute("data-state", "busy");
|
|
2042
|
+
expect(download).toHaveAttribute("aria-busy", "true");
|
|
2043
|
+
expect(download).toHaveAttribute("aria-disabled", "true");
|
|
2044
|
+
expect(screen.getByTestId("replay-screenshot-copy")).toHaveAttribute(
|
|
2045
|
+
"aria-disabled",
|
|
2046
|
+
"true",
|
|
2047
|
+
);
|
|
2048
|
+
|
|
2049
|
+
await act(async (): Promise<void> => {
|
|
2050
|
+
deferred.resolve(makeScreenshot());
|
|
2051
|
+
});
|
|
2052
|
+
await settle();
|
|
2053
|
+
|
|
2054
|
+
expect(downloads).toHaveLength(1);
|
|
2055
|
+
expect(download).not.toHaveAttribute("aria-disabled");
|
|
2056
|
+
});
|
|
2057
|
+
|
|
2058
|
+
it("says an image clipboard is missing and offers the download instead", async () => {
|
|
2059
|
+
const capture: MockFunction = makeCapture();
|
|
2060
|
+
|
|
2061
|
+
renderOverlays({ onCaptureFrame: capture });
|
|
2062
|
+
|
|
2063
|
+
const copy: HTMLElement = screen.getByTestId("replay-screenshot-copy");
|
|
2064
|
+
|
|
2065
|
+
/* jsdom has no async Clipboard API: the title says so up front. */
|
|
2066
|
+
expect(copy).toHaveAttribute(
|
|
2067
|
+
"title",
|
|
2068
|
+
"This browser can't copy images - use Download",
|
|
2069
|
+
);
|
|
2070
|
+
|
|
2071
|
+
fireEvent.click(copy);
|
|
2072
|
+
|
|
2073
|
+
const error: HTMLElement = screen.getByTestId("replay-screenshot-error");
|
|
2074
|
+
|
|
2075
|
+
expect(error).toHaveAttribute("role", "alert");
|
|
2076
|
+
expect(error).toHaveAttribute("data-recovery", "download");
|
|
2077
|
+
expect(error).toHaveTextContent("This browser can't copy images");
|
|
2078
|
+
/* Nothing was captured for a copy that could never happen. */
|
|
2079
|
+
expect(capture).not.toHaveBeenCalled();
|
|
2080
|
+
|
|
2081
|
+
fireEvent.click(screen.getByTestId("replay-screenshot-recover"));
|
|
2082
|
+
await settle();
|
|
2083
|
+
|
|
2084
|
+
expect(capture).toHaveBeenCalledTimes(1);
|
|
2085
|
+
expect(downloads).toEqual([
|
|
2086
|
+
{ href: "blob:replay/1", fileName: SCREENSHOT_FILE_NAME },
|
|
2087
|
+
]);
|
|
2088
|
+
expect(
|
|
2089
|
+
screen.queryByTestId("replay-screenshot-error"),
|
|
2090
|
+
).not.toBeInTheDocument();
|
|
2091
|
+
});
|
|
2092
|
+
|
|
2093
|
+
it("copies through the async clipboard, handing it the pending image inside the click", async () => {
|
|
2094
|
+
const write: MockFunction = getJestMockFunction();
|
|
2095
|
+
|
|
2096
|
+
write.mockResolvedValue(undefined);
|
|
2097
|
+
|
|
2098
|
+
const clipboardItems: Array<FakeClipboardItem> =
|
|
2099
|
+
stubImageClipboard(write);
|
|
2100
|
+
const deferred: Deferred<ReplayScreenshot> = makeDeferred();
|
|
2101
|
+
const capture: MockFunction = makeCapture(deferred.promise);
|
|
2102
|
+
const screenshot: ReplayScreenshot = makeScreenshot();
|
|
2103
|
+
|
|
2104
|
+
renderOverlays({ onCaptureFrame: capture });
|
|
2105
|
+
|
|
2106
|
+
const copy: HTMLElement = screen.getByTestId("replay-screenshot-copy");
|
|
2107
|
+
|
|
2108
|
+
expect(copy).toHaveAttribute(
|
|
2109
|
+
"title",
|
|
2110
|
+
"Copy this frame to the clipboard as a PNG",
|
|
2111
|
+
);
|
|
2112
|
+
|
|
2113
|
+
fireEvent.click(copy);
|
|
2114
|
+
|
|
2115
|
+
/* Before the capture has finished: Safari's user-activation rule. */
|
|
2116
|
+
expect(capture).toHaveBeenCalledTimes(1);
|
|
2117
|
+
expect(clipboardItems).toHaveLength(1);
|
|
2118
|
+
expect(write).toHaveBeenCalledTimes(1);
|
|
2119
|
+
expect(write).toHaveBeenCalledWith([clipboardItems[0]]);
|
|
2120
|
+
expect(Object.keys(clipboardItems[0]?.items ?? {})).toEqual([
|
|
2121
|
+
"image/png",
|
|
2122
|
+
]);
|
|
2123
|
+
expect(copy).toHaveAttribute("data-state", "busy");
|
|
2124
|
+
|
|
2125
|
+
await act(async (): Promise<void> => {
|
|
2126
|
+
deferred.resolve(screenshot);
|
|
2127
|
+
});
|
|
2128
|
+
await settle();
|
|
2129
|
+
|
|
2130
|
+
await expect(clipboardItems[0]?.items["image/png"]).resolves.toBe(
|
|
2131
|
+
screenshot.blob,
|
|
2132
|
+
);
|
|
2133
|
+
expect(downloads).toEqual([]);
|
|
2134
|
+
expect(screen.getByTestId("replay-screenshot-preview")).toHaveAttribute(
|
|
2135
|
+
"data-action",
|
|
2136
|
+
"copy",
|
|
2137
|
+
);
|
|
2138
|
+
expect(screen.getByTestId("replay-screenshot-preview")).toHaveTextContent(
|
|
2139
|
+
"Copied to clipboard",
|
|
2140
|
+
);
|
|
2141
|
+
expect(
|
|
2142
|
+
screen.getByTestId("replay-screenshot-preview-detail"),
|
|
2143
|
+
).toHaveTextContent("1440 × 900 PNG");
|
|
2144
|
+
expect(screen.getByTestId("replay-screenshot-status")).toHaveTextContent(
|
|
2145
|
+
"Screenshot copied to the clipboard.",
|
|
2146
|
+
);
|
|
2147
|
+
});
|
|
2148
|
+
|
|
2149
|
+
it("saves the PNG already drawn when the clipboard refuses it", async () => {
|
|
2150
|
+
const write: MockFunction = getJestMockFunction();
|
|
2151
|
+
|
|
2152
|
+
write.mockImplementation((): Promise<void> => {
|
|
2153
|
+
return Promise.reject(new DOMException("Denied", "NotAllowedError"));
|
|
2154
|
+
});
|
|
2155
|
+
stubImageClipboard(write);
|
|
2156
|
+
|
|
2157
|
+
const capture: MockFunction = makeCapture();
|
|
2158
|
+
const { rerender } = renderOverlays({ onCaptureFrame: capture });
|
|
2159
|
+
|
|
2160
|
+
fireEvent.click(screen.getByTestId("replay-screenshot-copy"));
|
|
2161
|
+
await settle();
|
|
2162
|
+
|
|
2163
|
+
expect(screen.getByTestId("replay-screenshot-error")).toHaveTextContent(
|
|
2164
|
+
"Clipboard access was blocked",
|
|
2165
|
+
);
|
|
2166
|
+
expect(screen.getByTestId("replay-screenshot-error")).toHaveAttribute(
|
|
2167
|
+
"data-recovery",
|
|
2168
|
+
"download",
|
|
2169
|
+
);
|
|
2170
|
+
|
|
2171
|
+
/* An unrelated re-render (the scale) is still the same frame. */
|
|
2172
|
+
rerenderOverlays(rerender, { onCaptureFrame: capture, scale: 0.5 });
|
|
2173
|
+
|
|
2174
|
+
fireEvent.click(screen.getByTestId("replay-screenshot-recover"));
|
|
2175
|
+
await settle();
|
|
2176
|
+
|
|
2177
|
+
expect(capture).toHaveBeenCalledTimes(1);
|
|
2178
|
+
expect(downloads).toEqual([
|
|
2179
|
+
{ href: "blob:replay/1", fileName: SCREENSHOT_FILE_NAME },
|
|
2180
|
+
]);
|
|
2181
|
+
expect(screen.getByTestId("replay-screenshot-preview")).toHaveAttribute(
|
|
2182
|
+
"data-action",
|
|
2183
|
+
"download",
|
|
2184
|
+
);
|
|
2185
|
+
});
|
|
2186
|
+
|
|
2187
|
+
it("explains a frame that could not be drawn and captures again on Try again", async () => {
|
|
2188
|
+
const capture: MockFunction = makeFailingCapture(
|
|
2189
|
+
new ReplayFrameCaptureError("render-failed", "No 2D canvas."),
|
|
2190
|
+
);
|
|
2191
|
+
|
|
2192
|
+
renderOverlays({ onCaptureFrame: capture });
|
|
2193
|
+
|
|
2194
|
+
fireEvent.click(screen.getByTestId("replay-screenshot-download"));
|
|
2195
|
+
await settle();
|
|
2196
|
+
|
|
2197
|
+
const error: HTMLElement = screen.getByTestId("replay-screenshot-error");
|
|
2198
|
+
|
|
2199
|
+
expect(error).toHaveAttribute("data-recovery", "retry");
|
|
2200
|
+
expect(error).toHaveTextContent("Couldn't capture this frame");
|
|
2201
|
+
expect(screen.getByTestId("replay-screenshot-recover")).toHaveTextContent(
|
|
2202
|
+
"Try again",
|
|
2203
|
+
);
|
|
2204
|
+
expect(downloads).toEqual([]);
|
|
2205
|
+
expect(
|
|
2206
|
+
screen.queryByTestId("replay-screenshot-preview"),
|
|
2207
|
+
).not.toBeInTheDocument();
|
|
2208
|
+
|
|
2209
|
+
fireEvent.click(screen.getByTestId("replay-screenshot-recover"));
|
|
2210
|
+
await settle();
|
|
2211
|
+
|
|
2212
|
+
expect(capture).toHaveBeenCalledTimes(2);
|
|
2213
|
+
});
|
|
2214
|
+
|
|
2215
|
+
it("tells a frame that is not ready yet apart from one that failed", async () => {
|
|
2216
|
+
renderOverlays({
|
|
2217
|
+
onCaptureFrame: makeFailingCapture(
|
|
2218
|
+
new ReplayFrameCaptureError("no-document", "Not drawn yet."),
|
|
2219
|
+
),
|
|
2220
|
+
});
|
|
2221
|
+
|
|
2222
|
+
fireEvent.click(screen.getByTestId("replay-screenshot-download"));
|
|
2223
|
+
await settle();
|
|
2224
|
+
|
|
2225
|
+
expect(screen.getByTestId("replay-screenshot-error")).toHaveTextContent(
|
|
2226
|
+
"The frame isn't ready yet",
|
|
2227
|
+
);
|
|
2228
|
+
});
|
|
2229
|
+
|
|
2230
|
+
it("turns a capture callback that throws into the same failure card", async () => {
|
|
2231
|
+
const capture: MockFunction = getJestMockFunction();
|
|
2232
|
+
|
|
2233
|
+
capture.mockImplementation((): never => {
|
|
2234
|
+
throw new Error("Synchronous failure");
|
|
2235
|
+
});
|
|
2236
|
+
|
|
2237
|
+
renderOverlays({ onCaptureFrame: capture });
|
|
2238
|
+
|
|
2239
|
+
fireEvent.click(screen.getByTestId("replay-screenshot-download"));
|
|
2240
|
+
await settle();
|
|
2241
|
+
|
|
2242
|
+
expect(screen.getByTestId("replay-screenshot-error")).toHaveAttribute(
|
|
2243
|
+
"data-recovery",
|
|
2244
|
+
"retry",
|
|
2245
|
+
);
|
|
2246
|
+
expect(screen.getByTestId("replay-screenshot-download")).toBeEnabled();
|
|
2247
|
+
});
|
|
2248
|
+
});
|
|
2249
|
+
|
|
2250
|
+
describe("across frames", () => {
|
|
2251
|
+
async function failOnce(): Promise<ReturnType<typeof render>> {
|
|
2252
|
+
const result: ReturnType<typeof render> = renderOverlays({
|
|
2253
|
+
onCaptureFrame: makeFailingCapture(
|
|
2254
|
+
new ReplayFrameCaptureError("render-failed", "Boom"),
|
|
2255
|
+
),
|
|
2256
|
+
});
|
|
2257
|
+
|
|
2258
|
+
fireEvent.click(screen.getByTestId("replay-screenshot-download"));
|
|
2259
|
+
await settle();
|
|
2260
|
+
|
|
2261
|
+
expect(screen.getByTestId("replay-screenshot-error")).toBeInTheDocument();
|
|
2262
|
+
|
|
2263
|
+
return result;
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
it("clears a failure card when a seek while paused changes the frame", async () => {
|
|
2267
|
+
const { rerender } = await failOnce();
|
|
2268
|
+
|
|
2269
|
+
rerenderOverlays(rerender, {
|
|
2270
|
+
snapshot: makeSnapshot({ currentTimeMs: 52000 }),
|
|
2271
|
+
onCaptureFrame: makeCapture(),
|
|
2272
|
+
});
|
|
2273
|
+
|
|
2274
|
+
expect(
|
|
2275
|
+
screen.queryByTestId("replay-screenshot-error"),
|
|
2276
|
+
).not.toBeInTheDocument();
|
|
2277
|
+
expect(queryDock()).toBeInTheDocument();
|
|
2278
|
+
expect(screen.getByTestId("replay-screenshot-download")).toHaveAttribute(
|
|
2279
|
+
"data-state",
|
|
2280
|
+
"idle",
|
|
2281
|
+
);
|
|
2282
|
+
});
|
|
2283
|
+
|
|
2284
|
+
it("keeps the card while the frame stays the same", async () => {
|
|
2285
|
+
const { rerender } = await failOnce();
|
|
2286
|
+
|
|
2287
|
+
/* Same playhead, and one within the same rounded millisecond. */
|
|
2288
|
+
rerenderOverlays(rerender, {
|
|
2289
|
+
snapshot: makeSnapshot({ currentTimeMs: 41200 }),
|
|
2290
|
+
onCaptureFrame: makeCapture(),
|
|
2291
|
+
});
|
|
2292
|
+
|
|
2293
|
+
expect(screen.getByTestId("replay-screenshot-error")).toBeInTheDocument();
|
|
2294
|
+
|
|
2295
|
+
rerenderOverlays(rerender, {
|
|
2296
|
+
snapshot: makeSnapshot({ currentTimeMs: 41200.3 }),
|
|
2297
|
+
onCaptureFrame: makeCapture(),
|
|
2298
|
+
});
|
|
2299
|
+
|
|
2300
|
+
expect(screen.getByTestId("replay-screenshot-error")).toBeInTheDocument();
|
|
2301
|
+
});
|
|
2302
|
+
|
|
2303
|
+
it("clears the card when the replay is rebuilt at the same playhead", async () => {
|
|
2304
|
+
const { rerender } = await failOnce();
|
|
2305
|
+
|
|
2306
|
+
rerenderOverlays(rerender, {
|
|
2307
|
+
snapshot: makeSnapshot({ generation: 2 }),
|
|
2308
|
+
onCaptureFrame: makeCapture(),
|
|
2309
|
+
});
|
|
2310
|
+
|
|
2311
|
+
expect(
|
|
2312
|
+
screen.queryByTestId("replay-screenshot-error"),
|
|
2313
|
+
).not.toBeInTheDocument();
|
|
2314
|
+
});
|
|
2315
|
+
|
|
2316
|
+
it("leaves the stage the moment playback resumes", async () => {
|
|
2317
|
+
const { rerender } = await failOnce();
|
|
2318
|
+
|
|
2319
|
+
rerenderOverlays(rerender, {
|
|
2320
|
+
snapshot: makeSnapshot({ buffer: "ok", intent: "playing" }),
|
|
2321
|
+
onCaptureFrame: makeCapture(),
|
|
2322
|
+
});
|
|
2323
|
+
|
|
2324
|
+
expect(screen.getByTestId("replay-overlay")).toHaveAttribute(
|
|
2325
|
+
"data-replay-overlay",
|
|
2326
|
+
"playing",
|
|
2327
|
+
);
|
|
2328
|
+
expect(queryDock()).not.toBeInTheDocument();
|
|
2329
|
+
expect(
|
|
2330
|
+
screen.queryByTestId("replay-screenshot-error"),
|
|
2331
|
+
).not.toBeInTheDocument();
|
|
2332
|
+
});
|
|
2333
|
+
|
|
2334
|
+
it("comes back fresh on the next pause, without the last frame's card", async () => {
|
|
2335
|
+
const { rerender } = await failOnce();
|
|
2336
|
+
|
|
2337
|
+
rerenderOverlays(rerender, {
|
|
2338
|
+
snapshot: makeSnapshot({ buffer: "ok", intent: "playing" }),
|
|
2339
|
+
onCaptureFrame: makeCapture(),
|
|
2340
|
+
});
|
|
2341
|
+
rerenderOverlays(rerender, {
|
|
2342
|
+
snapshot: makeSnapshot({ buffer: "ok", intent: "paused" }),
|
|
2343
|
+
onCaptureFrame: makeCapture(),
|
|
2344
|
+
});
|
|
2345
|
+
|
|
2346
|
+
expect(queryDock()).toBeInTheDocument();
|
|
2347
|
+
expect(
|
|
2348
|
+
screen.queryByTestId("replay-screenshot-error"),
|
|
2349
|
+
).not.toBeInTheDocument();
|
|
2350
|
+
});
|
|
2351
|
+
|
|
2352
|
+
it("drops a success card when text selection takes the stage", async () => {
|
|
2353
|
+
const capture: MockFunction = makeCapture();
|
|
2354
|
+
const { rerender } = renderOverlays({ onCaptureFrame: capture });
|
|
2355
|
+
|
|
2356
|
+
fireEvent.click(screen.getByTestId("replay-screenshot-download"));
|
|
2357
|
+
await settle();
|
|
2358
|
+
|
|
2359
|
+
expect(
|
|
2360
|
+
screen.getByTestId("replay-screenshot-preview"),
|
|
2361
|
+
).toBeInTheDocument();
|
|
2362
|
+
|
|
2363
|
+
rerenderOverlays(rerender, {
|
|
2364
|
+
onCaptureFrame: capture,
|
|
2365
|
+
isTextSelectionEnabled: true,
|
|
2366
|
+
});
|
|
2367
|
+
|
|
2368
|
+
expect(
|
|
2369
|
+
screen.queryByTestId("replay-screenshot-preview"),
|
|
2370
|
+
).not.toBeInTheDocument();
|
|
2371
|
+
expect(
|
|
2372
|
+
screen.queryByTestId("replay-screenshot-flash"),
|
|
2373
|
+
).not.toBeInTheDocument();
|
|
2374
|
+
/* The thumbnail's object URL goes with it. */
|
|
2375
|
+
expect(revokedUrls).toContain("blob:replay/2");
|
|
2376
|
+
});
|
|
2377
|
+
|
|
2378
|
+
/*
|
|
2379
|
+
* The frame is cloned when Download is pressed, so a PNG still being
|
|
2380
|
+
* encoded when the viewer presses Play is the frame they asked for.
|
|
2381
|
+
*/
|
|
2382
|
+
it("still saves a download asked for just before Play, without drawing a card", async () => {
|
|
2383
|
+
const deferred: Deferred<ReplayScreenshot> = makeDeferred();
|
|
2384
|
+
const capture: MockFunction = makeCapture(deferred.promise);
|
|
2385
|
+
const { rerender } = renderOverlays({ onCaptureFrame: capture });
|
|
2386
|
+
|
|
2387
|
+
fireEvent.click(screen.getByTestId("replay-screenshot-download"));
|
|
2388
|
+
|
|
2389
|
+
rerenderOverlays(rerender, {
|
|
2390
|
+
snapshot: makeSnapshot({ buffer: "ok", intent: "playing" }),
|
|
2391
|
+
onCaptureFrame: capture,
|
|
2392
|
+
});
|
|
2393
|
+
|
|
2394
|
+
await act(async (): Promise<void> => {
|
|
2395
|
+
deferred.resolve(makeScreenshot());
|
|
2396
|
+
});
|
|
2397
|
+
await settle();
|
|
2398
|
+
|
|
2399
|
+
expect(downloads).toEqual([
|
|
2400
|
+
{ href: "blob:replay/1", fileName: SCREENSHOT_FILE_NAME },
|
|
2401
|
+
]);
|
|
2402
|
+
expect(
|
|
2403
|
+
screen.queryByTestId("replay-screenshot-preview"),
|
|
2404
|
+
).not.toBeInTheDocument();
|
|
2405
|
+
expect(
|
|
2406
|
+
screen.queryByTestId("replay-screenshot-flash"),
|
|
2407
|
+
).not.toBeInTheDocument();
|
|
2408
|
+
/* No thumbnail URL was made for a card nobody will see. */
|
|
2409
|
+
expect(createdUrls).toEqual(["blob:replay/1"]);
|
|
2410
|
+
});
|
|
2411
|
+
});
|
|
2412
|
+
});
|