@editframe/elements 0.42.5 → 0.42.8

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.
Files changed (86) hide show
  1. package/LICENSE-FULL.md +148 -0
  2. package/dist/elements/EFCaptions.d.ts +4 -4
  3. package/dist/elements/EFImage.d.ts +4 -4
  4. package/dist/elements/EFMedia.d.ts +2 -2
  5. package/dist/elements/EFTemporal.js +3 -1
  6. package/dist/elements/EFTemporal.js.map +1 -1
  7. package/dist/elements/EFText.d.ts +4 -4
  8. package/dist/elements/EFText.js +1 -0
  9. package/dist/elements/EFText.js.map +1 -1
  10. package/dist/elements/EFTextSegment.d.ts +4 -4
  11. package/dist/elements/EFTimegroup.js +12 -1
  12. package/dist/elements/EFTimegroup.js.map +1 -1
  13. package/dist/elements/EFWaveform.d.ts +4 -4
  14. package/dist/gui/EFActiveRootTemporal.d.ts +4 -4
  15. package/dist/gui/EFConfiguration.d.ts +4 -4
  16. package/dist/gui/EFDial.d.ts +4 -4
  17. package/dist/gui/EFFilmstrip.d.ts +4 -4
  18. package/dist/gui/EFOverlayItem.d.ts +4 -4
  19. package/dist/gui/EFOverlayLayer.d.ts +4 -4
  20. package/dist/gui/EFPause.d.ts +4 -4
  21. package/dist/gui/EFPlay.d.ts +4 -4
  22. package/dist/gui/EFPreview.d.ts +4 -4
  23. package/dist/gui/EFScrubber.d.ts +4 -4
  24. package/dist/gui/EFTimeDisplay.d.ts +4 -4
  25. package/dist/gui/EFTimelineRuler.d.ts +4 -4
  26. package/dist/gui/EFToggleLoop.d.ts +4 -4
  27. package/dist/gui/EFTogglePlay.d.ts +4 -4
  28. package/dist/gui/EFWorkbench.d.ts +4 -4
  29. package/dist/gui/hierarchy/EFHierarchy.d.ts +4 -4
  30. package/dist/gui/hierarchy/EFHierarchyItem.d.ts +2 -2
  31. package/dist/gui/timeline/EFTimeline.d.ts +2 -2
  32. package/dist/gui/timeline/EFTimeline.js +18 -1
  33. package/dist/gui/timeline/EFTimeline.js.map +1 -1
  34. package/dist/gui/timeline/TrimHandles.d.ts +4 -4
  35. package/dist/gui/timeline/tracks/EFThumbnailStrip.d.ts +4 -4
  36. package/dist/gui/tree/EFTree.d.ts +4 -4
  37. package/dist/gui/tree/EFTreeItem.d.ts +4 -4
  38. package/dist/preview/renderTimegroupToCanvas.js +9 -0
  39. package/dist/preview/renderTimegroupToCanvas.js.map +1 -1
  40. package/dist/preview/renderTimegroupToVideo.js +1 -1
  41. package/dist/preview/renderTimegroupToVideo.js.map +1 -1
  42. package/dist/preview/rendering/serializeTimelineDirect.js +2 -1
  43. package/dist/preview/rendering/serializeTimelineDirect.js.map +1 -1
  44. package/package.json +8 -3
  45. package/scripts/build-css.js +0 -44
  46. package/test/__cache__/GET__api_v1_transcode_audio_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__32da3954ba60c96ad732020c65a08ebc/data.bin +0 -0
  47. package/test/__cache__/GET__api_v1_transcode_audio_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__32da3954ba60c96ad732020c65a08ebc/metadata.json +0 -16
  48. package/test/__cache__/GET__api_v1_transcode_audio_2_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__b0b2b07efcf607de8ee0f650328c32f7/data.bin +0 -0
  49. package/test/__cache__/GET__api_v1_transcode_audio_2_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__b0b2b07efcf607de8ee0f650328c32f7/metadata.json +0 -16
  50. package/test/__cache__/GET__api_v1_transcode_audio_3_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a75c2252b542e0c152c780e9a8d7b154/data.bin +0 -0
  51. package/test/__cache__/GET__api_v1_transcode_audio_3_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a75c2252b542e0c152c780e9a8d7b154/metadata.json +0 -16
  52. package/test/__cache__/GET__api_v1_transcode_audio_4_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a64ff1cfb1b52cae14df4b5dfa1e222b/data.bin +0 -0
  53. package/test/__cache__/GET__api_v1_transcode_audio_4_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a64ff1cfb1b52cae14df4b5dfa1e222b/metadata.json +0 -16
  54. package/test/__cache__/GET__api_v1_transcode_audio_5_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__91e8a522f950809b9f09f4173113b4b0/data.bin +0 -0
  55. package/test/__cache__/GET__api_v1_transcode_audio_5_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__91e8a522f950809b9f09f4173113b4b0/metadata.json +0 -16
  56. package/test/__cache__/GET__api_v1_transcode_audio_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__e66d2c831d951e74ad0aeaa6489795d0/data.bin +0 -0
  57. package/test/__cache__/GET__api_v1_transcode_audio_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__e66d2c831d951e74ad0aeaa6489795d0/metadata.json +0 -16
  58. package/test/__cache__/GET__api_v1_transcode_high_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__26197f6f7c46cacb0a71134131c3f775/data.bin +0 -0
  59. package/test/__cache__/GET__api_v1_transcode_high_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__26197f6f7c46cacb0a71134131c3f775/metadata.json +0 -16
  60. package/test/__cache__/GET__api_v1_transcode_high_2_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__4cb6774cd3650ccf59c8f8dc6678c0b9/data.bin +0 -0
  61. package/test/__cache__/GET__api_v1_transcode_high_2_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__4cb6774cd3650ccf59c8f8dc6678c0b9/metadata.json +0 -16
  62. package/test/__cache__/GET__api_v1_transcode_high_3_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0b3b2b1c8933f7fcf8a9ecaa88d58b41/data.bin +0 -0
  63. package/test/__cache__/GET__api_v1_transcode_high_3_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0b3b2b1c8933f7fcf8a9ecaa88d58b41/metadata.json +0 -16
  64. package/test/__cache__/GET__api_v1_transcode_high_4_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a6fb05a22b18d850f7f2950bbcdbdeed/data.bin +0 -0
  65. package/test/__cache__/GET__api_v1_transcode_high_4_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a6fb05a22b18d850f7f2950bbcdbdeed/metadata.json +0 -16
  66. package/test/__cache__/GET__api_v1_transcode_high_5_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a50058c7c3602e90879fe3428ed891f4/data.bin +0 -0
  67. package/test/__cache__/GET__api_v1_transcode_high_5_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a50058c7c3602e90879fe3428ed891f4/metadata.json +0 -16
  68. package/test/__cache__/GET__api_v1_transcode_high_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0798c479b44aaeef850609a430f6e613/data.bin +0 -0
  69. package/test/__cache__/GET__api_v1_transcode_high_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0798c479b44aaeef850609a430f6e613/metadata.json +0 -16
  70. package/test/__cache__/GET__api_v1_transcode_manifest_json_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__3be92a0437de726b431ed5af2369158a/data.bin +0 -1
  71. package/test/__cache__/GET__api_v1_transcode_manifest_json_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__3be92a0437de726b431ed5af2369158a/metadata.json +0 -17
  72. package/test/__cache__/GET__api_v1_transcode_scrub_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__6ff5127ebeda578a679474347fbd6137/data.bin +0 -0
  73. package/test/__cache__/GET__api_v1_transcode_scrub_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__6ff5127ebeda578a679474347fbd6137/metadata.json +0 -16
  74. package/test/__cache__/GET__api_v1_transcode_scrub_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__f6d4793fc9ff854ee9a738917fb64a53/data.bin +0 -0
  75. package/test/__cache__/GET__api_v1_transcode_scrub_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__f6d4793fc9ff854ee9a738917fb64a53/metadata.json +0 -16
  76. package/test/cache-integration-verification.browsertest.ts +0 -84
  77. package/test/constants.ts +0 -8
  78. package/test/createJitTestClips.ts +0 -425
  79. package/test/profilingPlugin.ts +0 -221
  80. package/test/recordReplayProxyPlugin.js +0 -428
  81. package/test/setup.ts +0 -71
  82. package/test/useAssetMSW.ts +0 -53
  83. package/test/useMSW.ts +0 -40
  84. package/test/useTranscodeMSW.ts +0 -191
  85. package/test/visualRegressionUtils.ts +0 -300
  86. package/tsdown.config.ts +0 -65
package/test/setup.ts DELETED
@@ -1,71 +0,0 @@
1
- /**
2
- * Global test setup for all browser tests
3
- * This runs before every test to ensure clean state
4
- */
5
-
6
- import { beforeEach, afterAll } from "vitest";
7
- import {
8
- globalRequestDeduplicator,
9
- mediaCache,
10
- } from "../src/elements/EFMedia/CachedFetcher.js";
11
- import { globalURLTokenDeduplicator } from "../src/transcoding/cache/URLTokenDeduplicator.js";
12
- import { TEST_SERVER_PORT } from "./constants.js";
13
-
14
- // Type declarations for test environment
15
- declare global {
16
- interface Window {
17
- __CI_MODE__?: boolean;
18
- __PROFILER_STOP_REQUESTED__?: boolean;
19
- }
20
- }
21
-
22
- /**
23
- * Get the correct API host for the current environment.
24
- * In local dev with Traefik, returns the Traefik URL (e.g., http://main.localhost:4322).
25
- * In CI or direct access, returns the current location (e.g., http://localhost:63315).
26
- */
27
- export function getApiHost(): string {
28
- const host = window.location.host;
29
- const protocol = window.location.protocol;
30
-
31
- // Check if CI mode was injected by server
32
- const isCI = (window as any).__CI_MODE__ === true;
33
-
34
- if (isCI) {
35
- // CI mode: always use localhost directly
36
- return `${protocol}//${host}`;
37
- }
38
-
39
- if (host === `localhost:${TEST_SERVER_PORT}`) {
40
- // Check if we have a Traefik referrer (local dev)
41
- const traefikReferrer = document.referrer.match(/\/\/([^:]+):4322/)?.[1];
42
- if (traefikReferrer) {
43
- // Local dev: use Traefik URL
44
- return `${protocol}//${traefikReferrer}:4322`;
45
- }
46
- // No Traefik referrer but not explicitly CI: use localhost directly
47
- return `${protocol}//${host}`;
48
- }
49
- // Already on Traefik URL or other configuration
50
- return `${protocol}//${host}`;
51
- }
52
-
53
- // Clear global caches before each test to ensure isolation
54
- beforeEach(() => {
55
- globalRequestDeduplicator.clear();
56
- mediaCache.clear();
57
- globalURLTokenDeduplicator.clear();
58
- });
59
-
60
- // Signal profiler to stop before tests finish
61
- afterAll(() => {
62
- if (typeof window !== "undefined") {
63
- // Always set the flag, creating it if it doesn't exist
64
- // This handles both profiled and non-profiled test runs
65
- (window as any).__PROFILER_STOP_REQUESTED__ = true;
66
-
67
- // Give profiler time to detect the signal and retrieve profile data
68
- // Poll interval is 50ms, so wait longer to ensure detection + retrieval
69
- return new Promise((resolve) => setTimeout(resolve, 500));
70
- }
71
- });
@@ -1,53 +0,0 @@
1
- import { HttpResponse, http } from "msw";
2
-
3
- export const assetMSWHandlers = [
4
- http.get("/api/v1/files/:id/index", async () => {
5
- const mockIndex = {
6
- 0: {
7
- duration: 10000,
8
- timescale: 1000,
9
- fragments: [
10
- {
11
- offset: 0,
12
- size: 1024,
13
- timestamp: 0,
14
- duration: 10000,
15
- },
16
- ],
17
- },
18
- };
19
-
20
- return HttpResponse.json(mockIndex, {
21
- headers: {
22
- "Content-Type": "application/json",
23
- },
24
- });
25
- }),
26
-
27
- http.get("/api/v1/files/:id/tracks/:trackId", async ({ request }) => {
28
- const rangeHeader = request.headers.get("range");
29
-
30
- if (rangeHeader) {
31
- const mockData = new ArrayBuffer(1024);
32
- return new HttpResponse(mockData, {
33
- status: 206,
34
- headers: {
35
- "Content-Type": "video/mp4",
36
- "Accept-Ranges": "bytes",
37
- "Content-Range": rangeHeader,
38
- "Content-Length": "1024",
39
- },
40
- });
41
- }
42
-
43
- const mockData = new ArrayBuffer(1024);
44
- return new HttpResponse(mockData, {
45
- status: 200,
46
- headers: {
47
- "Content-Type": "video/mp4",
48
- "Accept-Ranges": "bytes",
49
- "Content-Length": "1024",
50
- },
51
- });
52
- }),
53
- ];
package/test/useMSW.ts DELETED
@@ -1,40 +0,0 @@
1
- /**
2
- * MSW integration for Vitest Browser Mode
3
- * Based on: https://mswjs.io/docs/recipes/vitest-browser-mode/
4
- */
5
-
6
- import { setupWorker } from "msw/browser";
7
- import { test as testBase } from "vitest";
8
- import { transcodeMSWHandlers } from "./useTranscodeMSW.js";
9
-
10
- // Create the worker instance that will be shared across tests
11
- const worker = setupWorker();
12
-
13
- // Start the worker once when this module is loaded
14
- let workerStarted = false;
15
-
16
- /**
17
- * Extended test with MSW worker integration for Vitest Browser Mode
18
- * This follows the official MSW recommendation for Vitest Browser Mode
19
- */
20
- export const test = testBase.extend<{
21
- worker: typeof worker;
22
- }>({
23
- worker: [
24
- async ({ expect: _expect }, use) => {
25
- // Only start the worker once
26
- if (!workerStarted) {
27
- await worker.start({
28
- onUnhandledRequest: "bypass", // Allow unhandled requests to pass through
29
- });
30
- // Set up default handlers for transcode API endpoints
31
- worker.use(...transcodeMSWHandlers);
32
- workerStarted = true;
33
- }
34
-
35
- // Use the worker in the test
36
- await use(worker);
37
- },
38
- { scope: "test" },
39
- ],
40
- });
@@ -1,191 +0,0 @@
1
- /**
2
- * Transcode API MSW handlers for testing
3
- * Provides handlers for transcode API endpoints including URL signing
4
- */
5
-
6
- import { HttpResponse, http } from "msw";
7
-
8
- /**
9
- * MSW handlers for transcode API endpoints
10
- * These handlers mock the API responses needed for tests
11
- */
12
- export const transcodeMSWHandlers = [
13
- // URL signing endpoint handler
14
- // This mocks the /@ef-sign-url endpoint used by the Vite plugin
15
- http.post("/@ef-sign-url", async () => {
16
- // Return a mock JWT token
17
- // The token format is: header.payload.signature
18
- // We create a simple mock token that will pass basic validation
19
- const mockToken =
20
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJodHRwOi8vd2ViOjMwMDAvaGVhZC1tb292LTQ4MHAubXA0IiwiZXhwIjo5OTk5OTk5OTk5fQ.mock-signature";
21
-
22
- return HttpResponse.json(
23
- { token: mockToken },
24
- {
25
- status: 200,
26
- headers: {
27
- "Content-Type": "application/json",
28
- },
29
- },
30
- );
31
- }),
32
-
33
- // URL token endpoint handler (for proxied requests from vite plugin)
34
- // The vite plugin proxies /@ef-sign-url to /api/v1/url-token
35
- http.post("/api/v1/url-token", async () => {
36
- // Return the same mock JWT token as /@ef-sign-url
37
- const mockToken =
38
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJodHRwOi8vd2ViOjMwMDAvaGVhZC1tb292LTQ4MHAubXA0IiwiZXhwIjo5OTk5OTk5OTk5fQ.mock-signature";
39
-
40
- return HttpResponse.json(
41
- { token: mockToken },
42
- {
43
- status: 200,
44
- headers: {
45
- "Content-Type": "application/json",
46
- },
47
- },
48
- );
49
- }),
50
-
51
- // Transcode manifest endpoint handler
52
- // This mocks the manifest.json endpoint used by JitMediaEngine
53
- http.get("/api/v1/transcode/manifest.json", async ({ request }) => {
54
- const url = new URL(request.url);
55
- const sourceUrl = url.searchParams.get("url");
56
-
57
- if (!sourceUrl) {
58
- return HttpResponse.json(
59
- { error: "url parameter is required" },
60
- { status: 400 },
61
- );
62
- }
63
-
64
- // Return a mock manifest response
65
- const manifest = {
66
- version: "1.0",
67
- type: "cmaf",
68
- duration: 10,
69
- durationMs: 10000,
70
- segmentDuration: 2000,
71
- baseUrl: `${url.origin}/api/v1/transcode`,
72
- sourceUrl: sourceUrl,
73
- audioRenditions: [
74
- {
75
- id: "audio",
76
- src: sourceUrl,
77
- segmentDurationMs: 2000,
78
- },
79
- ],
80
- videoRenditions: [
81
- {
82
- id: "high",
83
- src: sourceUrl,
84
- segmentDurationMs: 2000,
85
- },
86
- ],
87
- endpoints: {
88
- initSegment: `${url.origin}/api/v1/transcode/{rendition}/init.m4s?url=${encodeURIComponent(sourceUrl)}`,
89
- mediaSegment: `${url.origin}/api/v1/transcode/{rendition}/{segmentId}.m4s?url=${encodeURIComponent(sourceUrl)}`,
90
- },
91
- jitInfo: {
92
- parallelTranscodingSupported: true,
93
- expectedTranscodeLatency: 1000,
94
- segmentCount: 5,
95
- },
96
- };
97
-
98
- return HttpResponse.json(manifest, {
99
- status: 200,
100
- headers: {
101
- "Content-Type": "application/json",
102
- },
103
- });
104
- }),
105
-
106
- // Transcode init segment endpoint handler
107
- http.get("/api/v1/transcode/:rendition/init.m4s", async () => {
108
- // Return a minimal valid MP4 init segment
109
- // This is a very basic ftyp + moov box structure
110
- const initSegment = new Uint8Array([
111
- // ftyp box
112
- 0x00,
113
- 0x00,
114
- 0x00,
115
- 0x20, // box size
116
- 0x66,
117
- 0x74,
118
- 0x79,
119
- 0x70, // 'ftyp'
120
- 0x69,
121
- 0x73,
122
- 0x6f,
123
- 0x6d, // major brand 'isom'
124
- 0x00,
125
- 0x00,
126
- 0x02,
127
- 0x00, // minor version
128
- 0x69,
129
- 0x73,
130
- 0x6f,
131
- 0x6d, // compatible brand 'isom'
132
- 0x69,
133
- 0x73,
134
- 0x6f,
135
- 0x32, // compatible brand 'iso2'
136
- 0x6d,
137
- 0x70,
138
- 0x34,
139
- 0x31, // compatible brand 'mp41'
140
- // moov box (minimal)
141
- 0x00,
142
- 0x00,
143
- 0x00,
144
- 0x08, // box size
145
- 0x6d,
146
- 0x6f,
147
- 0x6f,
148
- 0x76, // 'moov'
149
- ]);
150
-
151
- return HttpResponse.arrayBuffer(initSegment.buffer, {
152
- status: 200,
153
- headers: {
154
- "Content-Type": "video/mp4",
155
- },
156
- });
157
- }),
158
-
159
- // Transcode media segment endpoint handler
160
- http.get("/api/v1/transcode/:rendition/:segmentId.m4s", async () => {
161
- // Return a minimal valid MP4 media segment
162
- // This is a very basic moof + mdat box structure
163
- const mediaSegment = new Uint8Array([
164
- // moof box (minimal)
165
- 0x00,
166
- 0x00,
167
- 0x00,
168
- 0x08, // box size
169
- 0x6d,
170
- 0x6f,
171
- 0x6f,
172
- 0x66, // 'moof'
173
- // mdat box (minimal)
174
- 0x00,
175
- 0x00,
176
- 0x00,
177
- 0x08, // box size
178
- 0x6d,
179
- 0x64,
180
- 0x61,
181
- 0x74, // 'mdat'
182
- ]);
183
-
184
- return HttpResponse.arrayBuffer(mediaSegment.buffer, {
185
- status: 200,
186
- headers: {
187
- "Content-Type": "video/mp4",
188
- },
189
- });
190
- }),
191
- ];
@@ -1,300 +0,0 @@
1
- /**
2
- * Visual regression utilities for browser tests.
3
- *
4
- * These utilities enable capturing canvas/element snapshots in browser tests
5
- * and comparing them against baseline images using odiff for pixel-perfect
6
- * visual regression testing.
7
- */
8
-
9
- export interface SnapshotComparisonResult {
10
- match: boolean;
11
- diffCount?: number;
12
- diffPercentage?: number;
13
- baselineCreated?: boolean;
14
- error?: string;
15
- }
16
-
17
- /**
18
- * Capture a canvas or image as a data URL.
19
- * Uses JPEG format with configurable quality for smaller file sizes.
20
- */
21
- export function captureCanvasAsDataUrl(
22
- source: CanvasImageSource | HTMLCanvasElement,
23
- format: "image/png" | "image/jpeg" = "image/jpeg",
24
- quality: number = 0.85,
25
- ): string {
26
- // If it's already a canvas, use toDataURL directly
27
- if (source instanceof HTMLCanvasElement) {
28
- return source.toDataURL(format, quality);
29
- }
30
-
31
- // Otherwise, draw to temp canvas first
32
- const canvas = document.createElement("canvas");
33
- canvas.width = (source as any).width as number;
34
- canvas.height = (source as any).height as number;
35
- const ctx = canvas.getContext("2d");
36
- if (!ctx) {
37
- throw new Error("Failed to get canvas 2d context");
38
- }
39
- ctx.drawImage(source, 0, 0);
40
- return canvas.toDataURL(format, quality);
41
- }
42
-
43
- /**
44
- * Capture an element to canvas, then return as data URL.
45
- * Uses the same technique as renderToImage but returns raw data.
46
- */
47
- export async function captureElementAsDataUrl(
48
- element: HTMLElement,
49
- width: number,
50
- height: number,
51
- ): Promise<string> {
52
- const canvas = document.createElement("canvas");
53
- canvas.width = width;
54
- canvas.height = height;
55
-
56
- const ctx = canvas.getContext("2d");
57
- if (!ctx) {
58
- throw new Error("Failed to get canvas 2d context");
59
- }
60
-
61
- // Use html2canvas-style rendering through SVG foreignObject
62
- const clone = element.cloneNode(true) as HTMLElement;
63
-
64
- // Create wrapper with XHTML namespace
65
- const wrapper = document.createElement("div");
66
- wrapper.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
67
- wrapper.setAttribute(
68
- "style",
69
- `width:${width}px;height:${height}px;overflow:hidden;position:relative;`,
70
- );
71
- wrapper.appendChild(clone);
72
-
73
- // Serialize to XHTML
74
- const xmlSerializer = new XMLSerializer();
75
- const serialized = xmlSerializer.serializeToString(wrapper);
76
-
77
- // Wrap in SVG foreignObject
78
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
79
- <foreignObject width="100%" height="100%">
80
- ${serialized}
81
- </foreignObject>
82
- </svg>`;
83
-
84
- // Convert to data URL
85
- const base64 = btoa(unescape(encodeURIComponent(svg)));
86
- const svgDataUri = `data:image/svg+xml;base64,${base64}`;
87
-
88
- // Draw to canvas
89
- const img = await new Promise<HTMLImageElement>((resolve, reject) => {
90
- const image = new Image();
91
- image.onload = () => resolve(image);
92
- image.onerror = reject;
93
- image.src = svgDataUri;
94
- });
95
-
96
- ctx.drawImage(img, 0, 0);
97
- return canvas.toDataURL("image/png");
98
- }
99
-
100
- /**
101
- * Write a snapshot image to the file system via the test server.
102
- * This sends the PNG data to the server which writes it to disk.
103
- */
104
- export async function writeSnapshot(
105
- testName: string,
106
- snapshotName: string,
107
- dataUrl: string,
108
- isBaseline: boolean = false,
109
- ): Promise<void> {
110
- const response = await fetch("/@ef-write-snapshot", {
111
- method: "POST",
112
- headers: {
113
- "Content-Type": "application/json",
114
- },
115
- body: JSON.stringify({
116
- testName,
117
- snapshotName,
118
- dataUrl,
119
- isBaseline,
120
- }),
121
- });
122
-
123
- if (!response.ok) {
124
- const error = await response.text();
125
- throw new Error(`Failed to write snapshot: ${error}`);
126
- }
127
- }
128
-
129
- /**
130
- * Compare a snapshot against its baseline using odiff.
131
- * Returns comparison results including diff percentage.
132
- */
133
- export async function compareSnapshot(
134
- testName: string,
135
- snapshotName: string,
136
- dataUrl: string,
137
- options: {
138
- threshold?: number;
139
- antialiasing?: boolean;
140
- acceptableDiffPercentage?: number;
141
- } = {},
142
- ): Promise<SnapshotComparisonResult> {
143
- const {
144
- threshold = 0.1,
145
- antialiasing = true,
146
- acceptableDiffPercentage = 1.0,
147
- } = options;
148
-
149
- const response = await fetch("/@ef-compare-snapshot", {
150
- method: "POST",
151
- headers: {
152
- "Content-Type": "application/json",
153
- },
154
- body: JSON.stringify({
155
- testName,
156
- snapshotName,
157
- dataUrl,
158
- threshold,
159
- antialiasing,
160
- acceptableDiffPercentage,
161
- }),
162
- });
163
-
164
- if (!response.ok) {
165
- const error = await response.text();
166
- throw new Error(`Failed to compare snapshot: ${error}`);
167
- }
168
-
169
- return response.json();
170
- }
171
-
172
- /**
173
- * High-level function to capture and compare a canvas snapshot.
174
- * Creates baseline if it doesn't exist, otherwise compares.
175
- */
176
- export async function assertCanvasSnapshot(
177
- canvas: HTMLCanvasElement,
178
- testName: string,
179
- snapshotName: string,
180
- options: {
181
- threshold?: number;
182
- acceptableDiffPercentage?: number;
183
- } = {},
184
- ): Promise<SnapshotComparisonResult> {
185
- // Use PNG format for consistent snapshot comparison (odiff works best with PNG)
186
- const dataUrl = captureCanvasAsDataUrl(canvas, "image/png");
187
- return compareSnapshot(testName, snapshotName, dataUrl, options);
188
- }
189
-
190
- /**
191
- * Assert that a snapshot matches its baseline.
192
- * Throws an assertion error if the diff exceeds the acceptable threshold.
193
- */
194
- export async function expectCanvasToMatchSnapshot(
195
- source: CanvasImageSource | HTMLCanvasElement,
196
- testName: string,
197
- snapshotName: string,
198
- options: {
199
- threshold?: number;
200
- acceptableDiffPercentage?: number;
201
- } = {},
202
- ): Promise<void> {
203
- const result = await assertCanvasSnapshot(
204
- source as unknown as HTMLCanvasElement,
205
- testName,
206
- snapshotName,
207
- options,
208
- );
209
-
210
- if (result.baselineCreated) {
211
- console.log(`✅ Created baseline: ${testName}/${snapshotName}`);
212
- return;
213
- }
214
-
215
- if (!result.match) {
216
- const diffInfo =
217
- result.diffPercentage !== undefined
218
- ? `${result.diffPercentage.toFixed(2)}% different`
219
- : result.error || "comparison failed";
220
- throw new Error(
221
- `Visual regression detected for ${testName}/${snapshotName}: ${diffInfo}`,
222
- );
223
- }
224
- }
225
-
226
- /**
227
- * Compare two canvases or images directly against each other.
228
- * Returns comparison results including diff percentage.
229
- */
230
- export async function compareTwoCanvases(
231
- source1: CanvasImageSource | HTMLCanvasElement,
232
- source2: CanvasImageSource | HTMLCanvasElement,
233
- testName: string,
234
- comparisonName: string,
235
- options: {
236
- threshold?: number;
237
- acceptableDiffPercentage?: number;
238
- } = {},
239
- ): Promise<SnapshotComparisonResult> {
240
- const { threshold = 0.1, acceptableDiffPercentage = 1.0 } = options;
241
-
242
- // Use PNG format for consistent comparison (odiff works best with PNG)
243
- const dataUrl1 = captureCanvasAsDataUrl(source1, "image/png");
244
- const dataUrl2 = captureCanvasAsDataUrl(source2, "image/png");
245
-
246
- const response = await fetch("/@ef-compare-two-images", {
247
- method: "POST",
248
- headers: {
249
- "Content-Type": "application/json",
250
- },
251
- body: JSON.stringify({
252
- testName,
253
- comparisonName,
254
- dataUrl1,
255
- dataUrl2,
256
- threshold,
257
- acceptableDiffPercentage,
258
- }),
259
- });
260
-
261
- if (!response.ok) {
262
- const error = await response.text();
263
- throw new Error(`Failed to compare canvases: ${error}`);
264
- }
265
-
266
- return response.json();
267
- }
268
-
269
- /**
270
- * Assert that two canvases match within acceptable threshold.
271
- * Throws an assertion error if the diff exceeds the acceptable threshold.
272
- */
273
- export async function expectCanvasesToMatch(
274
- canvas1: HTMLCanvasElement,
275
- canvas2: HTMLCanvasElement,
276
- testName: string,
277
- comparisonName: string,
278
- options: {
279
- threshold?: number;
280
- acceptableDiffPercentage?: number;
281
- } = {},
282
- ): Promise<void> {
283
- const result = await compareTwoCanvases(
284
- canvas1,
285
- canvas2,
286
- testName,
287
- comparisonName,
288
- options,
289
- );
290
-
291
- if (!result.match) {
292
- const diffInfo =
293
- result.diffPercentage !== undefined
294
- ? `${result.diffPercentage.toFixed(2)}% different`
295
- : result.error || "comparison failed";
296
- throw new Error(
297
- `Canvas comparison failed for ${testName}/${comparisonName}: ${diffInfo}`,
298
- );
299
- }
300
- }