@design.estate/wcctools 6.1.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/changelog.md +41 -0
  2. package/dist_shell/bundle.js +405 -368
  3. package/dist_shell/bundle.js.map +1 -1
  4. package/dist_shell/bundle.js.third-party-notices.json +52 -0
  5. package/dist_shell/bundle.js.third-party-notices.txt +107 -1
  6. package/dist_shell/index.html +1 -1
  7. package/dist_ts/00_commitinfo_data.js +1 -1
  8. package/dist_ts/capture/classes.capturebrowser.d.ts +65 -0
  9. package/dist_ts/capture/classes.capturebrowser.js +251 -0
  10. package/dist_ts/capture/classes.captureservice.d.ts +39 -0
  11. package/dist_ts/capture/classes.captureservice.js +119 -0
  12. package/dist_ts/capture/classes.screencast.d.ts +47 -0
  13. package/dist_ts/capture/classes.screencast.js +127 -0
  14. package/dist_ts/capture/errors.d.ts +14 -0
  15. package/dist_ts/capture/errors.js +23 -0
  16. package/dist_ts/capture/images.d.ts +3 -0
  17. package/dist_ts/capture/images.js +58 -0
  18. package/dist_ts/capture/index.d.ts +4 -0
  19. package/dist_ts/capture/index.js +5 -0
  20. package/dist_ts/capture/interaction.d.ts +11 -0
  21. package/dist_ts/capture/interaction.js +138 -0
  22. package/dist_ts/capture/navigationguard.d.ts +44 -0
  23. package/dist_ts/capture/navigationguard.js +129 -0
  24. package/dist_ts/capture/previewpage.d.ts +29 -0
  25. package/dist_ts/capture/previewpage.js +96 -0
  26. package/dist_ts/capture/request.d.ts +69 -0
  27. package/dist_ts/capture/request.js +239 -0
  28. package/dist_ts/classes.bundlestatus.d.ts +36 -0
  29. package/dist_ts/classes.bundlestatus.js +110 -0
  30. package/dist_ts/classes.devapi.d.ts +3 -0
  31. package/dist_ts/classes.devapi.js +8 -1
  32. package/dist_ts/classes.devserver.d.ts +65 -14
  33. package/dist_ts/classes.devserver.js +157 -52
  34. package/dist_ts/classes.hostpolicy.d.ts +63 -0
  35. package/dist_ts/classes.hostpolicy.js +171 -0
  36. package/dist_ts/cli.js +28 -3
  37. package/dist_ts/cli.screenshot.d.ts +6 -0
  38. package/dist_ts/cli.screenshot.js +89 -0
  39. package/dist_ts/index.d.ts +0 -2
  40. package/dist_ts/index.js +1 -3
  41. package/dist_ts/plugins.d.ts +6 -2
  42. package/dist_ts/plugins.js +8 -3
  43. package/dist_ts_interfaces/capture.d.ts +151 -0
  44. package/dist_ts_interfaces/capture.js +2 -0
  45. package/dist_ts_interfaces/index.d.ts +1 -0
  46. package/dist_ts_interfaces/index.js +2 -1
  47. package/dist_ts_interfaces/requests.d.ts +59 -0
  48. package/dist_ts_shell/bundlestatus.d.ts +15 -0
  49. package/dist_ts_shell/bundlestatus.js +64 -0
  50. package/dist_ts_shell/elements/wcc-contextmenu.d.ts +3 -0
  51. package/dist_ts_shell/elements/wcc-contextmenu.js +18 -3
  52. package/dist_ts_shell/elements/wcc-preview-frame.d.ts +6 -0
  53. package/dist_ts_shell/elements/wcc-preview-frame.js +50 -2
  54. package/dist_ts_shell/elements/wcc-recording-panel.d.ts +9 -0
  55. package/dist_ts_shell/elements/wcc-recording-panel.js +12 -1
  56. package/dist_ts_shell/elements/wcc-shell.d.ts +15 -0
  57. package/dist_ts_shell/elements/wcc-shell.js +45 -3
  58. package/dist_ts_shell/plugins.d.ts +2 -1
  59. package/dist_ts_shell/plugins.js +3 -2
  60. package/dist_ts_shell/services/framesampler.service.d.ts +23 -0
  61. package/dist_ts_shell/services/framesampler.service.js +101 -0
  62. package/dist_ts_web/00_commitinfo_data.js +1 -1
  63. package/package.json +6 -2
  64. package/readme.md +108 -6
  65. package/ts/00_commitinfo_data.ts +1 -1
  66. package/ts/capture/classes.capturebrowser.ts +290 -0
  67. package/ts/capture/classes.captureservice.ts +152 -0
  68. package/ts/capture/classes.screencast.ts +152 -0
  69. package/ts/capture/errors.ts +25 -0
  70. package/ts/capture/images.ts +61 -0
  71. package/ts/capture/index.ts +4 -0
  72. package/ts/capture/interaction.ts +146 -0
  73. package/ts/capture/navigationguard.ts +147 -0
  74. package/ts/capture/previewpage.ts +130 -0
  75. package/ts/capture/request.ts +304 -0
  76. package/ts/classes.bundlestatus.ts +123 -0
  77. package/ts/classes.devapi.ts +9 -0
  78. package/ts/classes.devserver.ts +172 -50
  79. package/ts/classes.hostpolicy.ts +201 -0
  80. package/ts/cli.screenshot.ts +95 -0
  81. package/ts/cli.ts +28 -2
  82. package/ts/index.ts +0 -2
  83. package/ts/plugins.ts +9 -2
  84. package/ts_interfaces/bridge.ts +95 -0
  85. package/ts_interfaces/capture.ts +144 -0
  86. package/ts_interfaces/catalog.ts +37 -0
  87. package/ts_interfaces/index.ts +5 -0
  88. package/ts_interfaces/plugins.ts +3 -0
  89. package/ts_interfaces/requests.ts +104 -0
  90. package/ts_interfaces/standard.ts +103 -0
  91. package/ts_shared/index.ts +1 -0
  92. package/ts_shared/plugins.ts +3 -0
  93. package/ts_shared/previewroute.ts +58 -0
  94. package/ts_shell/bundlestatus.ts +68 -0
  95. package/ts_shell/elements/wcc-contextmenu.ts +306 -0
  96. package/ts_shell/elements/wcc-preview-frame.ts +167 -0
  97. package/ts_shell/elements/wcc-properties.ts +998 -0
  98. package/ts_shell/elements/wcc-record-button.ts +108 -0
  99. package/ts_shell/elements/wcc-recording-panel.ts +1017 -0
  100. package/ts_shell/elements/wcc-shell.ts +485 -0
  101. package/ts_shell/elements/wcc-sidebar.ts +1419 -0
  102. package/ts_shell/index.html +21 -0
  103. package/ts_shell/index.ts +4 -0
  104. package/ts_shell/plugins.ts +12 -0
  105. package/ts_shell/previewconnection.ts +103 -0
  106. package/ts_shell/services/framesampler.service.ts +112 -0
  107. package/ts_shell/services/recorder.service.ts +451 -0
  108. package/ts_shell/types/dom-mediacapture-stub/index.d.ts +12 -0
  109. package/ts_shell/types/dom-mediacapture-stub/package.json +6 -0
  110. package/ts_shell/types/dom-webcodecs-stub/index.d.ts +2 -0
  111. package/ts_shell/types/dom-webcodecs-stub/package.json +6 -0
  112. package/ts_web/00_commitinfo_data.ts +1 -1
@@ -0,0 +1,290 @@
1
+ import * as plugins from '../plugins.js';
2
+ import { WccCaptureError, getErrorMessage } from './errors.js';
3
+
4
+ type TBrowser = plugins.smartbrowser.puppeteer.Browser;
5
+ type TBrowserContext = plugins.smartbrowser.puppeteer.BrowserContext;
6
+
7
+ export interface IWccCaptureBrowserOptions {
8
+ /** Captures that run at once; default 2. */
9
+ maxConcurrent?: number;
10
+ /** Captures that wait for a slot; more are refused as busy. Default 8. */
11
+ maxQueued?: number;
12
+ /** The browser closes after this long without captures; default 60 s. */
13
+ idleCloseMs?: number;
14
+ /** How long closing the browser may take before its process is killed; default 5 s. */
15
+ closeTimeoutMs?: number;
16
+ }
17
+
18
+ interface IQueuedCapture {
19
+ grant: () => void;
20
+ refuse: (errorArg: Error) => void;
21
+ }
22
+
23
+ export const captureBrowserClosedMessage = 'wcctools: the dev server is not serving captures.';
24
+
25
+ /**
26
+ * The headless browser of a dev server's captures: launched on the first capture, shared by the
27
+ * captures that follow and closed after a while without one, or when the server stops. Each
28
+ * capture runs in its own browser context, closed when it ends; a bounded number run at once and a
29
+ * bounded number wait for a slot. The browser leaves process signals to the application: the dev
30
+ * server's shutdown closes it.
31
+ */
32
+ export class WccCaptureBrowser {
33
+ private readonly maxConcurrent: number;
34
+ private readonly maxQueued: number;
35
+ private readonly idleCloseMs: number;
36
+ private readonly closeTimeoutMs: number;
37
+ private isOpen = false;
38
+ private launching: Promise<TBrowser> | null = null;
39
+ private readonly closingBrowsers = new Set<Promise<void>>();
40
+ private readonly contexts = new Set<TBrowserContext>();
41
+ private readonly running = new Set<Promise<unknown>>();
42
+ private readonly queue: IQueuedCapture[] = [];
43
+ private activeCount = 0;
44
+ private idleTimer: ReturnType<typeof setTimeout> | null = null;
45
+ private launchCount = 0;
46
+
47
+ constructor(optionsArg: IWccCaptureBrowserOptions = {}) {
48
+ this.maxConcurrent = optionsArg.maxConcurrent ?? 2;
49
+ this.maxQueued = optionsArg.maxQueued ?? 8;
50
+ this.idleCloseMs = optionsArg.idleCloseMs ?? 60_000;
51
+ this.closeTimeoutMs = optionsArg.closeTimeoutMs ?? 5_000;
52
+ }
53
+
54
+ /** How often a browser was launched; one browser serves every capture while it runs. */
55
+ public getLaunchCount(): number {
56
+ return this.launchCount;
57
+ }
58
+
59
+ /** The process id of the running browser, or null while none runs. */
60
+ public async getProcessId(): Promise<number | null> {
61
+ const launching = this.launching;
62
+ if (!launching) {
63
+ return null;
64
+ }
65
+ const browser = await launching.catch(() => null);
66
+ return browser?.process()?.pid ?? null;
67
+ }
68
+
69
+ /** Takes captures from now on. */
70
+ public open() {
71
+ this.isOpen = true;
72
+ }
73
+
74
+ /**
75
+ * Refuses new captures and the waiting ones, ends the running ones by closing their contexts,
76
+ * closes the browser and resolves once all of it has settled.
77
+ */
78
+ public async close(): Promise<void> {
79
+ this.isOpen = false;
80
+ this.clearIdleTimer();
81
+ const refusal = new WccCaptureError(captureBrowserClosedMessage);
82
+ for (const queued of this.queue.splice(0)) {
83
+ queued.refuse(refusal);
84
+ }
85
+ await Promise.allSettled([...this.contexts].map((contextArg) => contextArg.close()));
86
+ await Promise.allSettled([...this.running]);
87
+ this.closeBrowser();
88
+ await Promise.allSettled([...this.closingBrowsers]);
89
+ }
90
+
91
+ /**
92
+ * Runs a capture in a browser context of its own, once a slot is free. The context closes when
93
+ * the capture ends, and at once when `abortSignalArg` aborts, which ends the capture with the
94
+ * signal's reason.
95
+ */
96
+ public async withContext<T>(
97
+ abortSignalArg: AbortSignal,
98
+ captureArg: (contextArg: TBrowserContext) => Promise<T>,
99
+ ): Promise<T> {
100
+ await this.acquireSlot(abortSignalArg);
101
+ const run = this.runInContext(abortSignalArg, captureArg);
102
+ this.running.add(run);
103
+ try {
104
+ return await run;
105
+ } finally {
106
+ this.running.delete(run);
107
+ this.releaseSlot();
108
+ }
109
+ }
110
+
111
+ private async runInContext<T>(
112
+ abortSignalArg: AbortSignal,
113
+ captureArg: (contextArg: TBrowserContext) => Promise<T>,
114
+ ): Promise<T> {
115
+ let context: TBrowserContext | null = null;
116
+ const closeContext = () => {
117
+ void context?.close().catch(() => {
118
+ // The context is gone already: its browser closed or disconnected
119
+ });
120
+ };
121
+ abortSignalArg.addEventListener('abort', closeContext, { once: true });
122
+ try {
123
+ const browser = await this.getBrowser();
124
+ abortSignalArg.throwIfAborted();
125
+ context = await browser.createBrowserContext();
126
+ this.contexts.add(context);
127
+ abortSignalArg.throwIfAborted();
128
+ if (!this.isOpen) {
129
+ // close() ran while the browser launched; it does not wait for this capture's context
130
+ throw new WccCaptureError(captureBrowserClosedMessage);
131
+ }
132
+ return await captureArg(context);
133
+ } catch (error) {
134
+ if (abortSignalArg.aborted) {
135
+ throw abortSignalArg.reason;
136
+ }
137
+ if (!this.isOpen) {
138
+ throw new WccCaptureError(captureBrowserClosedMessage);
139
+ }
140
+ throw error;
141
+ } finally {
142
+ abortSignalArg.removeEventListener('abort', closeContext);
143
+ if (context) {
144
+ this.contexts.delete(context);
145
+ if (context.browser().connected) {
146
+ await context.close().catch(() => {
147
+ // Closed meanwhile by an abort, or its browser disconnected
148
+ });
149
+ }
150
+ }
151
+ }
152
+ }
153
+
154
+ private acquireSlot(abortSignalArg: AbortSignal): Promise<void> {
155
+ if (!this.isOpen) {
156
+ return Promise.reject(new WccCaptureError(captureBrowserClosedMessage));
157
+ }
158
+ abortSignalArg.throwIfAborted();
159
+ this.clearIdleTimer();
160
+ if (this.activeCount < this.maxConcurrent) {
161
+ this.activeCount++;
162
+ return Promise.resolve();
163
+ }
164
+ if (this.queue.length >= this.maxQueued) {
165
+ return Promise.reject(new WccCaptureError(
166
+ `wcctools: the capture browser is busy (${this.maxConcurrent} captures running, ${this.queue.length} waiting); try again later.`,
167
+ ));
168
+ }
169
+ return new Promise<void>((resolve, reject) => {
170
+ const queued: IQueuedCapture = {
171
+ grant: () => {
172
+ abortSignalArg.removeEventListener('abort', onAbort);
173
+ resolve();
174
+ },
175
+ refuse: (errorArg) => {
176
+ abortSignalArg.removeEventListener('abort', onAbort);
177
+ reject(errorArg);
178
+ },
179
+ };
180
+ const onAbort = () => {
181
+ const index = this.queue.indexOf(queued);
182
+ if (index !== -1) {
183
+ this.queue.splice(index, 1);
184
+ }
185
+ queued.refuse(abortSignalArg.reason);
186
+ };
187
+ abortSignalArg.addEventListener('abort', onAbort, { once: true });
188
+ this.queue.push(queued);
189
+ });
190
+ }
191
+
192
+ private releaseSlot() {
193
+ const next = this.queue.shift();
194
+ if (next) {
195
+ // The slot passes to the next capture
196
+ next.grant();
197
+ return;
198
+ }
199
+ this.activeCount--;
200
+ if (this.activeCount === 0 && this.isOpen && this.launching) {
201
+ this.idleTimer = setTimeout(() => {
202
+ this.idleTimer = null;
203
+ if (this.activeCount === 0) {
204
+ this.closeBrowser();
205
+ }
206
+ }, this.idleCloseMs);
207
+ this.idleTimer.unref();
208
+ }
209
+ }
210
+
211
+ private clearIdleTimer() {
212
+ if (this.idleTimer) {
213
+ clearTimeout(this.idleTimer);
214
+ this.idleTimer = null;
215
+ }
216
+ }
217
+
218
+ /** The running browser, launched when none runs; refused once close() ran, so it never outlives it. */
219
+ private getBrowser(): Promise<TBrowser> {
220
+ if (!this.isOpen) {
221
+ return Promise.reject(new WccCaptureError(captureBrowserClosedMessage));
222
+ }
223
+ this.launching ??= this.launch();
224
+ return this.launching;
225
+ }
226
+
227
+ private async launch(): Promise<TBrowser> {
228
+ this.launchCount++;
229
+ let browser: TBrowser;
230
+ try {
231
+ browser = await plugins.smartbrowser.getEnvAwareBrowserInstance({
232
+ launchOptions: {
233
+ // The application owns process signals: the dev server's shutdown closes the browser
234
+ handleSIGINT: false,
235
+ handleSIGTERM: false,
236
+ handleSIGHUP: false,
237
+ },
238
+ });
239
+ } catch (error) {
240
+ this.launching = null;
241
+ throw new WccCaptureError(
242
+ `wcctools: the capture browser failed to start: ${getErrorMessage(error)}. Captures need Chrome or Chromium: install google-chrome or chromium on the PATH.`,
243
+ );
244
+ }
245
+ const launching = this.launching;
246
+ browser.once('disconnected', () => {
247
+ // A crashed or closed browser is replaced by the next capture
248
+ if (this.launching === launching) {
249
+ this.launching = null;
250
+ }
251
+ });
252
+ return browser;
253
+ }
254
+
255
+ /** Closes the current browser, if one runs or launches; the next capture launches another. */
256
+ private closeBrowser() {
257
+ this.clearIdleTimer();
258
+ const launching = this.launching;
259
+ if (!launching) {
260
+ return;
261
+ }
262
+ this.launching = null;
263
+ const closing = launching
264
+ .then((browserArg) => this.closeWithin(browserArg), () => undefined)
265
+ .finally(() => {
266
+ this.closingBrowsers.delete(closing);
267
+ });
268
+ this.closingBrowsers.add(closing);
269
+ }
270
+
271
+ /** Closes a browser, and kills its process when closing takes longer than allowed. */
272
+ private async closeWithin(browserArg: TBrowser): Promise<void> {
273
+ if (!browserArg.connected) {
274
+ return;
275
+ }
276
+ let deadline: ReturnType<typeof setTimeout> | null = null;
277
+ const timedOut = new Promise<'timeout'>((resolve) => {
278
+ deadline = setTimeout(() => resolve('timeout'), this.closeTimeoutMs);
279
+ });
280
+ const closed = browserArg.close().then(() => 'closed' as const, () => 'failed' as const);
281
+ try {
282
+ const result = await Promise.race([closed, timedOut]);
283
+ if (result !== 'closed') {
284
+ browserArg.process()?.kill('SIGKILL');
285
+ }
286
+ } finally {
287
+ clearTimeout(deadline);
288
+ }
289
+ }
290
+ }
@@ -0,0 +1,152 @@
1
+ import * as plugins from '../plugins.js';
2
+ import type { WccBundleStatusBoard } from '../classes.bundlestatus.js';
3
+ import { WccCaptureBrowser, captureBrowserClosedMessage, type IWccCaptureBrowserOptions } from './classes.capturebrowser.js';
4
+ import { WccCaptureError } from './errors.js';
5
+ import { runInteraction } from './interaction.js';
6
+ import { renderPreview, screenshotPreview } from './previewpage.js';
7
+ import { captureLimits, resolveInteractionRequest, resolveScreenshotRequest } from './request.js';
8
+
9
+ /** The longest a screenshot may take, from its request to its image. */
10
+ export const screenshotDeadlineMs = 30_000;
11
+
12
+ /** The longest an interaction may take: its steps, its observation and the set-up around them. */
13
+ export const interactionDeadlineMs = 30_000
14
+ + captureLimits.maxSteps * captureLimits.stepTimeoutMs
15
+ + captureLimits.maxObserveMs;
16
+
17
+ /**
18
+ * The captures of a dev server: screenshots and observed interactions of single demos, taken in a
19
+ * headless browser on the server's preview route. A capture waits while a catalog bundle builds,
20
+ * and is refused when one failed, so it never shows a stale or broken bundle. Every capture has a
21
+ * deadline and ends when its client goes away. The service registers `captureScreenshot` and
22
+ * `captureInteraction` on the dev server's typed API.
23
+ */
24
+ export class WccCaptureService {
25
+ private readonly browser: WccCaptureBrowser;
26
+ private origin: string | null = null;
27
+
28
+ constructor(
29
+ private readonly bundleStatus: WccBundleStatusBoard,
30
+ browserOptionsArg: IWccCaptureBrowserOptions = {},
31
+ ) {
32
+ this.browser = new WccCaptureBrowser(browserOptionsArg);
33
+ }
34
+
35
+ /** Registers the capture requests on a typed router. */
36
+ public addTypedHandlers(typedrouterArg: plugins.typedrequest.TypedRouter) {
37
+ typedrouterArg.addTypedHandler(
38
+ new plugins.typedrequest.TypedHandler<plugins.interfaces.IReq_CaptureScreenshot>('captureScreenshot', async (requestArg, typedToolsArg) => {
39
+ return this.captureScreenshot(requestArg, typedToolsArg?.abortSignal);
40
+ }),
41
+ );
42
+ typedrouterArg.addTypedHandler(
43
+ new plugins.typedrequest.TypedHandler<plugins.interfaces.IReq_CaptureInteraction>('captureInteraction', async (requestArg, typedToolsArg) => {
44
+ return this.captureInteraction(requestArg, typedToolsArg?.abortSignal);
45
+ }),
46
+ );
47
+ }
48
+
49
+ /** How often the capture browser was launched. */
50
+ public getBrowserLaunchCount(): number {
51
+ return this.browser.getLaunchCount();
52
+ }
53
+
54
+ /** The process id of the capture browser while it runs. */
55
+ public getBrowserProcessId(): Promise<number | null> {
56
+ return this.browser.getProcessId();
57
+ }
58
+
59
+ /** Takes captures of the dev server serving at `originArg` (scheme, host and port). */
60
+ public open(originArg: string) {
61
+ this.origin = originArg;
62
+ this.browser.open();
63
+ }
64
+
65
+ /** Ends every capture and closes the browser. */
66
+ public async close(): Promise<void> {
67
+ this.origin = null;
68
+ await this.browser.close();
69
+ }
70
+
71
+ public async captureScreenshot(
72
+ requestArg: plugins.interfaces.IWccScreenshotRequest,
73
+ abortSignalArg?: AbortSignal,
74
+ ): Promise<plugins.interfaces.IWccScreenshotResult> {
75
+ const request = resolveScreenshotRequest(requestArg);
76
+ return this.run(screenshotDeadlineMs, abortSignalArg, async (contextArg, originArg) => {
77
+ const preview = await renderPreview(contextArg, originArg, request.view, request.subject);
78
+ const shot = await screenshotPreview(preview, request.view, request.image);
79
+ await preview.navigationGuard.assertStayed('being captured');
80
+ return { selection: preview.selection, ...shot };
81
+ });
82
+ }
83
+
84
+ public async captureInteraction(
85
+ requestArg: plugins.interfaces.IWccInteractionRequest,
86
+ abortSignalArg?: AbortSignal,
87
+ ): Promise<plugins.interfaces.IWccInteractionResult> {
88
+ const request = resolveInteractionRequest(requestArg);
89
+ return this.run(interactionDeadlineMs, abortSignalArg, async (contextArg, originArg, signalArg) => {
90
+ const preview = await renderPreview(contextArg, originArg, request.view, request.subject);
91
+ const observed = await runInteraction(preview, request, signalArg);
92
+ const result: plugins.interfaces.IWccInteractionResult = { selection: preview.selection, ...observed };
93
+ if (request.finalScreenshot) {
94
+ result.finalImage = (await screenshotPreview(preview, request.view, request.finalScreenshot)).image;
95
+ }
96
+ return result;
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Runs a capture within its deadline: waits for settled bundles, then runs it in a browser
102
+ * context of its own. The client's abort and the deadline both end it.
103
+ */
104
+ private async run<T>(
105
+ deadlineMsArg: number,
106
+ clientSignalArg: AbortSignal | undefined,
107
+ captureArg: (
108
+ contextArg: plugins.smartbrowser.puppeteer.BrowserContext,
109
+ originArg: string,
110
+ signalArg: AbortSignal,
111
+ ) => Promise<T>,
112
+ ): Promise<T> {
113
+ const origin = this.origin;
114
+ if (!origin) {
115
+ throw new WccCaptureError(captureBrowserClosedMessage);
116
+ }
117
+ const controller = new AbortController();
118
+ const deadline = setTimeout(() => {
119
+ controller.abort(new WccCaptureError(`wcctools: the capture did not finish within ${deadlineMsArg / 1000} s.`));
120
+ }, deadlineMsArg);
121
+ const onClientAbort = () => controller.abort(new WccCaptureError('wcctools: the client went away; the capture ended.'));
122
+ clientSignalArg?.addEventListener('abort', onClientAbort, { once: true });
123
+ if (clientSignalArg?.aborted) {
124
+ onClientAbort();
125
+ }
126
+ try {
127
+ await this.waitForSettledBundles(controller.signal);
128
+ return await this.browser.withContext(controller.signal, (contextArg) => captureArg(contextArg, origin, controller.signal));
129
+ } finally {
130
+ clearTimeout(deadline);
131
+ clientSignalArg?.removeEventListener('abort', onClientAbort);
132
+ }
133
+ }
134
+
135
+ /** Resolves once no bundle builds; refused while a bundle's latest run failed. */
136
+ private async waitForSettledBundles(abortSignalArg: AbortSignal): Promise<void> {
137
+ for (;;) {
138
+ abortSignalArg.throwIfAborted();
139
+ const snapshot = this.bundleStatus.getSnapshot();
140
+ const failed = snapshot.bundles.filter((bundleArg) => bundleArg.state === 'failed');
141
+ if (failed.length > 0) {
142
+ throw new WccCaptureError(
143
+ `wcctools: the catalog bundle failed, so there is nothing current to capture: ${failed.map((bundleArg) => `${bundleArg.name}: ${bundleArg.errorMessage ?? 'no message'}`).join('; ')}`,
144
+ );
145
+ }
146
+ if (!snapshot.bundles.some((bundleArg) => bundleArg.state === 'started')) {
147
+ return;
148
+ }
149
+ await this.bundleStatus.waitForChange(snapshot.revision, abortSignalArg);
150
+ }
151
+ }
152
+ }
@@ -0,0 +1,152 @@
1
+ import * as plugins from '../plugins.js';
2
+ import { toWccImage } from './images.js';
3
+
4
+ type TPage = plugins.smartbrowser.puppeteer.Page;
5
+ type TCDPSession = plugins.smartbrowser.puppeteer.CDPSession;
6
+
7
+ interface IScreencastFrame {
8
+ /** Milliseconds since the recorder started. */
9
+ timestampMs: number;
10
+ dataBase64: string;
11
+ }
12
+
13
+ export interface IWccScreencastOptions {
14
+ /** JPEG quality from 1 to 100. */
15
+ quality: number;
16
+ /** Frames are scaled down to fit these device pixels. */
17
+ maxWidth: number;
18
+ maxHeight: number;
19
+ /** At most this many frames per second are kept; default 10. */
20
+ maxFps?: number;
21
+ /** The most frames kept over a whole recording; older ones are thinned out. Default 200. */
22
+ maxKeptFrames?: number;
23
+ }
24
+
25
+ /**
26
+ * Records a page's screencast (Chrome DevTools Protocol `Page.startScreencast`): JPEG frames the
27
+ * browser sends whenever the page paints, kept at a bounded rate and count. The page's last
28
+ * painted frame is always kept.
29
+ */
30
+ export class WccScreencastRecorder {
31
+ private session: TCDPSession | null = null;
32
+ private startedAt = 0;
33
+ private readonly kept: IScreencastFrame[] = [];
34
+ /** The newest frame that arrived too soon after the last kept one. */
35
+ private latest: IScreencastFrame | null = null;
36
+ private stopped = false;
37
+ private ackFailure: unknown = null;
38
+ private readonly minFrameGapMs: number;
39
+ private readonly maxKeptFrames: number;
40
+
41
+ constructor(
42
+ private readonly page: TPage,
43
+ private readonly options: IWccScreencastOptions,
44
+ ) {
45
+ this.minFrameGapMs = 1000 / (options.maxFps ?? 10);
46
+ this.maxKeptFrames = options.maxKeptFrames ?? 200;
47
+ }
48
+
49
+ /** The time the recording started, as `Date.now()` read it. */
50
+ public getStartedAt(): number {
51
+ return this.startedAt;
52
+ }
53
+
54
+ public async start(): Promise<void> {
55
+ const session = await this.page.createCDPSession();
56
+ this.session = session;
57
+ session.on('Page.screencastFrame', this.onFrame);
58
+ this.startedAt = Date.now();
59
+ await session.send('Page.startScreencast', {
60
+ format: 'jpeg',
61
+ quality: this.options.quality,
62
+ maxWidth: this.options.maxWidth,
63
+ maxHeight: this.options.maxHeight,
64
+ everyNthFrame: 1,
65
+ });
66
+ }
67
+
68
+ /** Stops the screencast, detaches from the page and answers the kept frames in time order. */
69
+ public async stop(): Promise<plugins.interfaces.IWccTimedFrame[]> {
70
+ const session = this.session;
71
+ this.session = null;
72
+ if (session && !this.stopped) {
73
+ this.stopped = true;
74
+ session.off('Page.screencastFrame', this.onFrame);
75
+ if (!session.detached) {
76
+ try {
77
+ await session.send('Page.stopScreencast');
78
+ } finally {
79
+ await session.detach();
80
+ }
81
+ }
82
+ }
83
+ if (this.ackFailure) {
84
+ throw this.ackFailure;
85
+ }
86
+ if (this.latest && this.latest.timestampMs > (this.kept.at(-1)?.timestampMs ?? -1)) {
87
+ this.kept.push(this.latest);
88
+ this.latest = null;
89
+ }
90
+ return this.kept.map((frameArg) => ({
91
+ ...toWccImage(Buffer.from(frameArg.dataBase64, 'base64'), 'jpeg'),
92
+ timestampMs: frameArg.timestampMs,
93
+ }));
94
+ }
95
+
96
+ private readonly onFrame = (eventArg: { data: string; sessionId: number; metadata: { timestamp?: number } }) => {
97
+ const session = this.session;
98
+ if (!session) {
99
+ return;
100
+ }
101
+ // Chrome sends the next frame only once this one is acknowledged
102
+ session.send('Page.screencastFrameAck', { sessionId: eventArg.sessionId }).catch((error: unknown) => {
103
+ if (!this.stopped) {
104
+ this.ackFailure ??= error;
105
+ }
106
+ });
107
+ const paintedAt = eventArg.metadata.timestamp === undefined ? Date.now() : eventArg.metadata.timestamp * 1000;
108
+ const frame: IScreencastFrame = {
109
+ timestampMs: Math.max(0, Math.round(paintedAt - this.startedAt)),
110
+ dataBase64: eventArg.data,
111
+ };
112
+ const lastKept = this.kept.at(-1);
113
+ if (lastKept && frame.timestampMs - lastKept.timestampMs < this.minFrameGapMs) {
114
+ this.latest = frame;
115
+ return;
116
+ }
117
+ this.latest = null;
118
+ this.kept.push(frame);
119
+ if (this.kept.length > this.maxKeptFrames) {
120
+ // Thin out evenly: drop every other frame, keeping the first
121
+ for (let index = this.kept.length - 1; index > 0; index -= 2) {
122
+ this.kept.splice(index, 1);
123
+ }
124
+ }
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Picks at most `maxFramesArg` frames spread evenly over the time the frames cover: for each of
130
+ * that many points in time, the frame on screen at that point. The first and the last frame are
131
+ * always among them.
132
+ */
133
+ export const selectFrames = <T extends { timestampMs: number }>(framesArg: T[], maxFramesArg: number): T[] => {
134
+ if (framesArg.length <= maxFramesArg) {
135
+ return [...framesArg];
136
+ }
137
+ if (maxFramesArg === 1) {
138
+ return [framesArg[framesArg.length - 1]];
139
+ }
140
+ const first = framesArg[0].timestampMs;
141
+ const span = framesArg[framesArg.length - 1].timestampMs - first;
142
+ const picked = new Set<number>();
143
+ let frameIndex = 0;
144
+ for (let point = 0; point < maxFramesArg; point++) {
145
+ const time = point === maxFramesArg - 1 ? Number.POSITIVE_INFINITY : first + (span * point) / (maxFramesArg - 1);
146
+ while (frameIndex + 1 < framesArg.length && framesArg[frameIndex + 1].timestampMs <= time) {
147
+ frameIndex++;
148
+ }
149
+ picked.add(frameIndex);
150
+ }
151
+ return [...picked].sort((a, b) => a - b).map((indexArg) => framesArg[indexArg]);
152
+ };
@@ -0,0 +1,25 @@
1
+ import * as plugins from '../plugins.js';
2
+
3
+ /**
4
+ * A capture that failed: the typed API answers it as a typed error with this message, the CLI
5
+ * prints it and exits with 1.
6
+ */
7
+ export class WccCaptureError extends plugins.typedrequest.TypedResponseError {
8
+ constructor(messageArg: string) {
9
+ super(messageArg);
10
+ this.name = 'WccCaptureError';
11
+ }
12
+ }
13
+
14
+ /** A capture request that is invalid as given; the CLI exits with 2. */
15
+ export class WccCaptureRequestError extends WccCaptureError {
16
+ constructor(messageArg: string) {
17
+ super(messageArg);
18
+ this.name = 'WccCaptureRequestError';
19
+ }
20
+ }
21
+
22
+ /** The message of any thrown value. */
23
+ export const getErrorMessage = (errorArg: unknown): string => {
24
+ return errorArg instanceof Error ? errorArg.message : String(errorArg);
25
+ };
@@ -0,0 +1,61 @@
1
+ import * as plugins from '../plugins.js';
2
+ import { WccCaptureError } from './errors.js';
3
+
4
+ const pngSignature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
5
+
6
+ /** The pixel size of a PNG, from its IHDR chunk. */
7
+ const readPngSize = (bytesArg: Uint8Array): { width: number; height: number } | null => {
8
+ if (bytesArg.length < 24 || pngSignature.some((byteArg, indexArg) => bytesArg[indexArg] !== byteArg)) {
9
+ return null;
10
+ }
11
+ const view = new DataView(bytesArg.buffer, bytesArg.byteOffset, bytesArg.byteLength);
12
+ return { width: view.getUint32(16), height: view.getUint32(20) };
13
+ };
14
+
15
+ /** The pixel size of a JPEG, from its first start-of-frame segment. */
16
+ const readJpegSize = (bytesArg: Uint8Array): { width: number; height: number } | null => {
17
+ if (bytesArg.length < 4 || bytesArg[0] !== 0xff || bytesArg[1] !== 0xd8) {
18
+ return null;
19
+ }
20
+ const view = new DataView(bytesArg.buffer, bytesArg.byteOffset, bytesArg.byteLength);
21
+ let offset = 2;
22
+ while (offset + 4 <= bytesArg.length) {
23
+ if (bytesArg[offset] !== 0xff) {
24
+ return null;
25
+ }
26
+ const marker = bytesArg[offset + 1];
27
+ // Markers without a length: fill bytes, TEM and RSTn
28
+ if (marker === 0xff) {
29
+ offset += 1;
30
+ continue;
31
+ }
32
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
33
+ offset += 2;
34
+ continue;
35
+ }
36
+ const length = view.getUint16(offset + 2);
37
+ const isStartOfFrame = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
38
+ if (isStartOfFrame) {
39
+ if (offset + 9 > bytesArg.length) {
40
+ return null;
41
+ }
42
+ return { width: view.getUint16(offset + 7), height: view.getUint16(offset + 5) };
43
+ }
44
+ offset += 2 + length;
45
+ }
46
+ return null;
47
+ };
48
+
49
+ /** An encoded image with its pixel size read from its own header. */
50
+ export const toWccImage = (bytesArg: Uint8Array, formatArg: 'png' | 'jpeg'): plugins.interfaces.IWccImage => {
51
+ const size = formatArg === 'png' ? readPngSize(bytesArg) : readJpegSize(bytesArg);
52
+ if (!size) {
53
+ throw new WccCaptureError(`wcctools: the browser returned no valid ${formatArg.toUpperCase()} image.`);
54
+ }
55
+ return {
56
+ mimeType: formatArg === 'png' ? 'image/png' : 'image/jpeg',
57
+ width: size.width,
58
+ height: size.height,
59
+ dataBase64: Buffer.from(bytesArg.buffer, bytesArg.byteOffset, bytesArg.byteLength).toString('base64'),
60
+ };
61
+ };
@@ -0,0 +1,4 @@
1
+ export * from './classes.captureservice.js';
2
+ export * from './classes.capturebrowser.js';
3
+ export * from './errors.js';
4
+ export { captureLimits, captureViewportWidths, resolveScreenshotRequest, resolveInteractionRequest } from './request.js';