@design.estate/wcctools 6.2.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.
- package/changelog.md +21 -0
- package/dist_shell/bundle.js +4 -4
- package/dist_shell/bundle.js.map +1 -1
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/capture/classes.capturebrowser.d.ts +65 -0
- package/dist_ts/capture/classes.capturebrowser.js +251 -0
- package/dist_ts/capture/classes.captureservice.d.ts +39 -0
- package/dist_ts/capture/classes.captureservice.js +119 -0
- package/dist_ts/capture/classes.screencast.d.ts +47 -0
- package/dist_ts/capture/classes.screencast.js +127 -0
- package/dist_ts/capture/errors.d.ts +14 -0
- package/dist_ts/capture/errors.js +23 -0
- package/dist_ts/capture/images.d.ts +3 -0
- package/dist_ts/capture/images.js +58 -0
- package/dist_ts/capture/index.d.ts +4 -0
- package/dist_ts/capture/index.js +5 -0
- package/dist_ts/capture/interaction.d.ts +11 -0
- package/dist_ts/capture/interaction.js +138 -0
- package/dist_ts/capture/navigationguard.d.ts +44 -0
- package/dist_ts/capture/navigationguard.js +129 -0
- package/dist_ts/capture/previewpage.d.ts +29 -0
- package/dist_ts/capture/previewpage.js +96 -0
- package/dist_ts/capture/request.d.ts +69 -0
- package/dist_ts/capture/request.js +239 -0
- package/dist_ts/classes.devserver.d.ts +25 -9
- package/dist_ts/classes.devserver.js +45 -14
- package/dist_ts/classes.hostpolicy.d.ts +63 -0
- package/dist_ts/classes.hostpolicy.js +171 -0
- package/dist_ts/cli.js +25 -3
- package/dist_ts/cli.screenshot.d.ts +6 -0
- package/dist_ts/cli.screenshot.js +89 -0
- package/dist_ts/plugins.d.ts +6 -2
- package/dist_ts/plugins.js +8 -3
- package/dist_ts_interfaces/capture.d.ts +151 -0
- package/dist_ts_interfaces/capture.js +2 -0
- package/dist_ts_interfaces/index.d.ts +1 -0
- package/dist_ts_interfaces/index.js +2 -1
- package/dist_ts_interfaces/requests.d.ts +24 -0
- package/dist_ts_shell/elements/wcc-recording-panel.d.ts +9 -0
- package/dist_ts_shell/elements/wcc-recording-panel.js +12 -1
- package/dist_ts_shell/services/framesampler.service.d.ts +23 -0
- package/dist_ts_shell/services/framesampler.service.js +101 -0
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/package.json +2 -1
- package/readme.md +104 -6
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/capture/classes.capturebrowser.ts +290 -0
- package/ts/capture/classes.captureservice.ts +152 -0
- package/ts/capture/classes.screencast.ts +152 -0
- package/ts/capture/errors.ts +25 -0
- package/ts/capture/images.ts +61 -0
- package/ts/capture/index.ts +4 -0
- package/ts/capture/interaction.ts +146 -0
- package/ts/capture/navigationguard.ts +147 -0
- package/ts/capture/previewpage.ts +130 -0
- package/ts/capture/request.ts +304 -0
- package/ts/classes.devserver.ts +55 -13
- package/ts/classes.hostpolicy.ts +201 -0
- package/ts/cli.screenshot.ts +95 -0
- package/ts/cli.ts +25 -2
- package/ts/plugins.ts +9 -2
- package/ts_interfaces/capture.ts +144 -0
- package/ts_interfaces/index.ts +1 -0
- package/ts_interfaces/requests.ts +39 -0
- package/ts_shell/elements/wcc-recording-panel.ts +12 -0
- package/ts_shell/services/framesampler.service.ts +112 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
|
@@ -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,146 @@
|
|
|
1
|
+
import * as plugins from '../plugins.js';
|
|
2
|
+
import { getErrorMessage } from './errors.js';
|
|
3
|
+
import { WccScreencastRecorder, selectFrames } from './classes.screencast.js';
|
|
4
|
+
import { formatBlockedNavigation } from './navigationguard.js';
|
|
5
|
+
import type { IWccRenderedPreview } from './previewpage.js';
|
|
6
|
+
import { captureLimits, type IWccResolvedInteractionRequest } from './request.js';
|
|
7
|
+
|
|
8
|
+
type TPage = plugins.smartbrowser.puppeteer.Page;
|
|
9
|
+
type TWccInteractionStep = plugins.interfaces.TWccInteractionStep;
|
|
10
|
+
|
|
11
|
+
/** A selector matched in the document and in every open shadow root below it. */
|
|
12
|
+
const piercing = (selectorArg: string) => `pierce/${selectorArg}`;
|
|
13
|
+
|
|
14
|
+
/** Resolves after `msArg`, or rejects with the signal's reason once it aborts; never leaves its timer behind. */
|
|
15
|
+
const observe = (msArg: number, abortSignalArg: AbortSignal): Promise<void> => {
|
|
16
|
+
return new Promise<void>((resolve, reject) => {
|
|
17
|
+
if (abortSignalArg.aborted) {
|
|
18
|
+
reject(abortSignalArg.reason);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const onAbort = () => {
|
|
22
|
+
clearTimeout(timer);
|
|
23
|
+
reject(abortSignalArg.reason);
|
|
24
|
+
};
|
|
25
|
+
const timer = setTimeout(() => {
|
|
26
|
+
abortSignalArg.removeEventListener('abort', onAbort);
|
|
27
|
+
resolve();
|
|
28
|
+
}, msArg);
|
|
29
|
+
abortSignalArg.addEventListener('abort', onAbort, { once: true });
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** A step as its error messages name it: its action and its selector or key. */
|
|
34
|
+
const describeStep = (stepArg: TWccInteractionStep): string => {
|
|
35
|
+
switch (stepArg.action) {
|
|
36
|
+
case 'press':
|
|
37
|
+
return stepArg.selector === undefined ? `press "${stepArg.key}"` : `press "${stepArg.key}" on "${stepArg.selector}"`;
|
|
38
|
+
case 'wait':
|
|
39
|
+
return `wait ${stepArg.ms} ms`;
|
|
40
|
+
default:
|
|
41
|
+
return `${stepArg.action} "${stepArg.selector}"`;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const runStep = async (pageArg: TPage, stepArg: TWccInteractionStep, abortSignalArg: AbortSignal): Promise<void> => {
|
|
46
|
+
const timeout = captureLimits.stepTimeoutMs;
|
|
47
|
+
switch (stepArg.action) {
|
|
48
|
+
case 'click':
|
|
49
|
+
await pageArg.locator(piercing(stepArg.selector)).setTimeout(timeout).click({ signal: abortSignalArg });
|
|
50
|
+
return;
|
|
51
|
+
case 'hover':
|
|
52
|
+
await pageArg.locator(piercing(stepArg.selector)).setTimeout(timeout).hover({ signal: abortSignalArg });
|
|
53
|
+
return;
|
|
54
|
+
case 'fill':
|
|
55
|
+
await pageArg.locator(piercing(stepArg.selector)).setTimeout(timeout).fill(stepArg.text, { signal: abortSignalArg });
|
|
56
|
+
return;
|
|
57
|
+
case 'press': {
|
|
58
|
+
if (stepArg.selector !== undefined) {
|
|
59
|
+
const handle = await pageArg.locator(piercing(stepArg.selector)).setTimeout(timeout).waitHandle({ signal: abortSignalArg });
|
|
60
|
+
try {
|
|
61
|
+
await handle.focus();
|
|
62
|
+
} finally {
|
|
63
|
+
await handle.dispose();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Puppeteer refuses keys it does not know with an error naming the key
|
|
67
|
+
await pageArg.keyboard.press(stepArg.key as plugins.smartbrowser.puppeteer.KeyInput);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
case 'waitFor': {
|
|
71
|
+
const handle = await pageArg.waitForSelector(piercing(stepArg.selector), {
|
|
72
|
+
timeout,
|
|
73
|
+
signal: abortSignalArg,
|
|
74
|
+
...(stepArg.state === 'hidden' ? { hidden: true } : { visible: true }),
|
|
75
|
+
});
|
|
76
|
+
await handle?.dispose();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
case 'wait':
|
|
80
|
+
await observe(stepArg.ms, abortSignalArg);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Runs the steps on a rendered preview while recording its screencast, observes for a while after
|
|
87
|
+
* the last one and answers the steps' results and a bounded selection of the frames. A failed step
|
|
88
|
+
* ends the script; an abort ends it with the signal's reason. The page stays on the preview: a step
|
|
89
|
+
* that starts a navigation fails with the blocked navigation and ends the script; a navigation
|
|
90
|
+
* blocked during the observation fails the last step.
|
|
91
|
+
*/
|
|
92
|
+
export const runInteraction = async (
|
|
93
|
+
previewArg: IWccRenderedPreview,
|
|
94
|
+
requestArg: IWccResolvedInteractionRequest,
|
|
95
|
+
abortSignalArg: AbortSignal,
|
|
96
|
+
): Promise<Omit<plugins.interfaces.IWccInteractionResult, 'selection' | 'finalImage'>> => {
|
|
97
|
+
const { page, navigationGuard } = previewArg;
|
|
98
|
+
const recorder = new WccScreencastRecorder(page, {
|
|
99
|
+
quality: requestArg.frameQuality,
|
|
100
|
+
maxWidth: requestArg.view.width * requestArg.view.scale,
|
|
101
|
+
maxHeight: requestArg.view.height * requestArg.view.scale,
|
|
102
|
+
});
|
|
103
|
+
const steps: plugins.interfaces.IWccInteractionStepResult[] = [];
|
|
104
|
+
let frames: plugins.interfaces.IWccTimedFrame[];
|
|
105
|
+
await recorder.start();
|
|
106
|
+
try {
|
|
107
|
+
const startedAt = recorder.getStartedAt();
|
|
108
|
+
for (const [index, step] of requestArg.steps.entries()) {
|
|
109
|
+
const startMs = Date.now() - startedAt;
|
|
110
|
+
let error: string | null = null;
|
|
111
|
+
try {
|
|
112
|
+
await runStep(page, step, abortSignalArg);
|
|
113
|
+
} catch (stepError) {
|
|
114
|
+
abortSignalArg.throwIfAborted();
|
|
115
|
+
error = getErrorMessage(stepError);
|
|
116
|
+
}
|
|
117
|
+
const blockedUrl = await navigationGuard.takeBlockedNavigation();
|
|
118
|
+
if (blockedUrl !== null) {
|
|
119
|
+
error = formatBlockedNavigation(blockedUrl);
|
|
120
|
+
}
|
|
121
|
+
const endMs = Date.now() - startedAt;
|
|
122
|
+
if (error === null) {
|
|
123
|
+
steps.push({ index, ok: true, startMs, endMs });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
steps.push({ index, ok: false, error: `${describeStep(step)}: ${error}`, startMs, endMs });
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
await observe(requestArg.observeMs, abortSignalArg);
|
|
130
|
+
const lastStep = steps.at(-1);
|
|
131
|
+
if (lastStep === undefined) {
|
|
132
|
+
await navigationGuard.assertStayed('being observed');
|
|
133
|
+
} else if (lastStep.ok) {
|
|
134
|
+
const blockedUrl = await navigationGuard.takeBlockedNavigation();
|
|
135
|
+
if (blockedUrl !== null) {
|
|
136
|
+
// Started after the last step ended: by that step, or by the demo on its own
|
|
137
|
+
lastStep.ok = false;
|
|
138
|
+
lastStep.error = `${describeStep(requestArg.steps[lastStep.index])}: ${formatBlockedNavigation(blockedUrl)}`;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} finally {
|
|
142
|
+
frames = await recorder.stop();
|
|
143
|
+
}
|
|
144
|
+
const completed = steps.length === requestArg.steps.length && steps.every((stepArg) => stepArg.ok);
|
|
145
|
+
return { frames: selectFrames(frames, requestArg.maxFrames), steps, completed };
|
|
146
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import * as plugins from '../plugins.js';
|
|
2
|
+
import { WccCaptureError, getErrorMessage } from './errors.js';
|
|
3
|
+
|
|
4
|
+
type TPage = plugins.smartbrowser.puppeteer.Page;
|
|
5
|
+
type THTTPRequest = plugins.smartbrowser.puppeteer.HTTPRequest;
|
|
6
|
+
|
|
7
|
+
/** The message of a navigation the capture blocked. */
|
|
8
|
+
export const formatBlockedNavigation = (urlArg: string): string => {
|
|
9
|
+
return `navigation to ${urlArg} blocked: a capture stays on the rendered preview`;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** The preview window as the navigation guard's page script extends it. */
|
|
13
|
+
interface IWccNavigationGuardWindow {
|
|
14
|
+
/** The URLs of the navigations the page script cancelled and nobody has read yet. */
|
|
15
|
+
wccCancelledNavigations?: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Runs in the main frame before the preview's scripts, so its `navigate` listener is the first:
|
|
20
|
+
* cancels each navigation that would replace the document (a link, a form submission, a script
|
|
21
|
+
* assigning `location`, a reload, also to the same URL, to `about:blank` or a `blob:` URL) as it
|
|
22
|
+
* starts, and records its URL. Same-document navigations (`history.replaceState`, fragments) and
|
|
23
|
+
* downloads pass. Link clicks and scripts start their navigation while the step that caused it
|
|
24
|
+
* runs; form submissions in a task right after.
|
|
25
|
+
*/
|
|
26
|
+
const cancelLeavingNavigations = () => {
|
|
27
|
+
if (window !== window.top) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const cancelled: string[] = [];
|
|
31
|
+
(window as unknown as IWccNavigationGuardWindow).wccCancelledNavigations = cancelled;
|
|
32
|
+
navigation.addEventListener('navigate', (eventArg) => {
|
|
33
|
+
if (eventArg.destination.sameDocument || eventArg.downloadRequest !== null || !eventArg.cancelable) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
eventArg.preventDefault();
|
|
37
|
+
cancelled.push(eventArg.destination.url);
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Keeps a capture page on the preview. Its main frame loads the preview document once; after that,
|
|
43
|
+
* the page script above cancels every navigation that would replace the document before it starts.
|
|
44
|
+
* Request interception backs it up: a main-frame navigation request the page could not cancel is
|
|
45
|
+
* aborted before it leaves. The preview's history holds the preview alone, so there is no earlier
|
|
46
|
+
* document to go back to. Subframes and all other requests are left alone.
|
|
47
|
+
*
|
|
48
|
+
* `takeBlockedNavigation()` answers the navigation blocked since its last call, so each step of an
|
|
49
|
+
* interaction learns about the navigation it started.
|
|
50
|
+
*/
|
|
51
|
+
export class WccNavigationGuard {
|
|
52
|
+
/** Navigation requests aborted by the backstop since the last `takeBlockedNavigation()`. */
|
|
53
|
+
private abortedUrls: string[] = [];
|
|
54
|
+
private interceptionFailure: unknown = null;
|
|
55
|
+
private previewUrl: string | null;
|
|
56
|
+
|
|
57
|
+
private constructor(
|
|
58
|
+
private readonly page: TPage,
|
|
59
|
+
previewUrlArg: string,
|
|
60
|
+
) {
|
|
61
|
+
this.previewUrl = new URL(previewUrlArg).href;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Installs the page script and intercepts the page's requests; the page's first main-frame
|
|
66
|
+
* navigation may load `previewUrlArg` only. Install before loading the preview.
|
|
67
|
+
*/
|
|
68
|
+
public static async install(pageArg: TPage, previewUrlArg: string): Promise<WccNavigationGuard> {
|
|
69
|
+
const guard = new WccNavigationGuard(pageArg, previewUrlArg);
|
|
70
|
+
pageArg.on('request', (requestArg) => guard.decide(requestArg));
|
|
71
|
+
await pageArg.setRequestInterception(true);
|
|
72
|
+
await pageArg.evaluateOnNewDocument(cancelLeavingNavigations);
|
|
73
|
+
return guard;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Drops the history entries before the loaded preview (the blank page it opened as), so going back leaves nothing. */
|
|
77
|
+
public async forgetEarlierHistory(): Promise<void> {
|
|
78
|
+
const session = await this.page.createCDPSession();
|
|
79
|
+
try {
|
|
80
|
+
await session.send('Page.resetNavigationHistory');
|
|
81
|
+
} finally {
|
|
82
|
+
await session.detach();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Lets the page run the tasks queued so far (an animation frame, then a task), then answers the
|
|
88
|
+
* URL of the first navigation blocked since the last call, by the page script or the backstop,
|
|
89
|
+
* or null when none was.
|
|
90
|
+
*/
|
|
91
|
+
public async takeBlockedNavigation(): Promise<string | null> {
|
|
92
|
+
const cancelledUrl = await this.page.evaluate(() => {
|
|
93
|
+
return new Promise<string | null>((resolve) => {
|
|
94
|
+
requestAnimationFrame(() => {
|
|
95
|
+
setTimeout(() => {
|
|
96
|
+
const cancelled = (window as unknown as IWccNavigationGuardWindow).wccCancelledNavigations ?? [];
|
|
97
|
+
resolve(cancelled[0] ?? null);
|
|
98
|
+
cancelled.length = 0;
|
|
99
|
+
}, 0);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
if (this.interceptionFailure !== null) {
|
|
104
|
+
throw new WccCaptureError(`wcctools: the capture failed to handle a request of the preview: ${getErrorMessage(this.interceptionFailure)}`);
|
|
105
|
+
}
|
|
106
|
+
const abortedUrl = this.abortedUrls[0] ?? null;
|
|
107
|
+
this.abortedUrls = [];
|
|
108
|
+
return cancelledUrl ?? abortedUrl;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Throws when the page tried to leave the preview while `whileArg` (e.g. "rendering"). */
|
|
112
|
+
public async assertStayed(whileArg: string): Promise<void> {
|
|
113
|
+
const blockedUrl = await this.takeBlockedNavigation();
|
|
114
|
+
if (blockedUrl !== null) {
|
|
115
|
+
throw new WccCaptureError(`wcctools: the demo tried to navigate away while ${whileArg}; ${formatBlockedNavigation(blockedUrl)}.`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private decide(requestArg: THTTPRequest) {
|
|
120
|
+
if (requestArg.isInterceptResolutionHandled()) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (!requestArg.isNavigationRequest() || requestArg.frame() !== this.page.mainFrame()) {
|
|
124
|
+
this.settle(requestArg.continue());
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (this.previewUrl !== null && requestArg.url() === this.previewUrl) {
|
|
128
|
+
// The preview document's own load, once
|
|
129
|
+
this.previewUrl = null;
|
|
130
|
+
this.settle(requestArg.continue());
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
this.abortedUrls.push(requestArg.url());
|
|
134
|
+
// `aborted` drops the navigation without committing an error page, so the preview stays
|
|
135
|
+
this.settle(requestArg.abort('aborted'));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Keeps the first failure of an interception decision. Puppeteer itself tolerates a request
|
|
140
|
+
* that ended or a page that closed meanwhile; what reaches here fails the capture.
|
|
141
|
+
*/
|
|
142
|
+
private settle(decisionArg: Promise<void>) {
|
|
143
|
+
decisionArg.catch((errorArg: unknown) => {
|
|
144
|
+
this.interceptionFailure ??= errorArg;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import * as plugins from '../plugins.js';
|
|
2
|
+
import { WccCaptureError, WccCaptureRequestError, getErrorMessage } from './errors.js';
|
|
3
|
+
import { toWccImage } from './images.js';
|
|
4
|
+
import { WccNavigationGuard } from './navigationguard.js';
|
|
5
|
+
import { captureLimits, resolveSelection, type IWccResolvedImageOptions, type IWccResolvedView } from './request.js';
|
|
6
|
+
|
|
7
|
+
type TBrowserContext = plugins.smartbrowser.puppeteer.BrowserContext;
|
|
8
|
+
type TPage = plugins.smartbrowser.puppeteer.Page;
|
|
9
|
+
|
|
10
|
+
/** How long the preview document may take to load and render its demo. */
|
|
11
|
+
const previewStartTimeoutMs = 20_000;
|
|
12
|
+
|
|
13
|
+
/** A preview document in the capture browser that rendered one demo. */
|
|
14
|
+
export interface IWccRenderedPreview {
|
|
15
|
+
page: TPage;
|
|
16
|
+
/** Keeps the page on the preview and knows the navigation it blocked. */
|
|
17
|
+
navigationGuard: WccNavigationGuard;
|
|
18
|
+
selection: plugins.interfaces.IWccSelection;
|
|
19
|
+
/** What the demo rendered: an element demo or a page. */
|
|
20
|
+
type: 'element' | 'page';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const formatPageErrors = (errorsArg: string[]): string => {
|
|
24
|
+
return errorsArg.length > 0 ? ` The preview reported: ${errorsArg.slice(0, 5).join(' | ')}` : '';
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Opens the preview route of the dev server at `originArg` in the context, waits for the preview
|
|
29
|
+
* runtime to install its bridge (it does once it rendered the route), resolves the subject against
|
|
30
|
+
* the catalog manifest and renders it. Waits only on the preview's own signals: the bridge, the
|
|
31
|
+
* render's completed updates and the document's fonts. The page stays on the preview: every
|
|
32
|
+
* later navigation of its main frame is blocked, and one blocked while rendering fails the capture.
|
|
33
|
+
*/
|
|
34
|
+
export const renderPreview = async (
|
|
35
|
+
contextArg: TBrowserContext,
|
|
36
|
+
originArg: string,
|
|
37
|
+
viewArg: IWccResolvedView,
|
|
38
|
+
subjectArg: plugins.interfaces.IWccCaptureSubject,
|
|
39
|
+
): Promise<IWccRenderedPreview> => {
|
|
40
|
+
const page = await contextArg.newPage();
|
|
41
|
+
const pageErrors: string[] = [];
|
|
42
|
+
page.on('pageerror', (errorArg) => {
|
|
43
|
+
pageErrors.push(getErrorMessage(errorArg));
|
|
44
|
+
});
|
|
45
|
+
page.on('console', (messageArg) => {
|
|
46
|
+
if (messageArg.type() === 'error') {
|
|
47
|
+
pageErrors.push(messageArg.text());
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
await page.setViewport({ width: viewArg.width, height: viewArg.height, deviceScaleFactor: viewArg.scale });
|
|
51
|
+
await page.emulateMediaFeatures([
|
|
52
|
+
{ name: 'prefers-color-scheme', value: viewArg.theme === 'bright' ? 'light' : 'dark' },
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const previewUrl = `${originArg}${plugins.shared.previewRoutePath}${plugins.shared.formatPreviewQuery({ selection: null, theme: viewArg.theme })}`;
|
|
56
|
+
const navigationGuard = await WccNavigationGuard.install(page, previewUrl);
|
|
57
|
+
const response = await page.goto(previewUrl, { waitUntil: 'load', timeout: previewStartTimeoutMs });
|
|
58
|
+
if (!response?.ok()) {
|
|
59
|
+
throw new WccCaptureError(`wcctools: the preview document answered ${response?.status() ?? 'nothing'}.${formatPageErrors(pageErrors)}`);
|
|
60
|
+
}
|
|
61
|
+
await navigationGuard.forgetEarlierHistory();
|
|
62
|
+
try {
|
|
63
|
+
await page.waitForFunction(() => window.wccPreview !== undefined, { polling: 'raf', timeout: previewStartTimeoutMs });
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw new WccCaptureError(
|
|
66
|
+
`wcctools: the preview did not start within ${previewStartTimeoutMs / 1000} s (${getErrorMessage(error)}).${formatPageErrors(pageErrors)}`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const manifest = await page.evaluate(() => window.wccPreview!.getManifest());
|
|
71
|
+
const selection = resolveSelection(manifest, subjectArg);
|
|
72
|
+
const outcome = await page.evaluate((selectionArg) => window.wccPreview!.render(selectionArg), selection);
|
|
73
|
+
if (outcome.kind === 'empty') {
|
|
74
|
+
throw new WccCaptureError(
|
|
75
|
+
`wcctools: ${selection.sectionName}/${selection.itemName} demo ${selection.demoIndex} did not render: ${outcome.emptyState.title}. ${outcome.emptyState.detail}${formatPageErrors(pageErrors)}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
await page.evaluate(async () => {
|
|
79
|
+
await document.fonts.ready;
|
|
80
|
+
});
|
|
81
|
+
await navigationGuard.assertStayed('rendering');
|
|
82
|
+
return { page, navigationGuard, selection, type: outcome.type };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Screenshots a rendered preview as framed. */
|
|
86
|
+
export const screenshotPreview = async (
|
|
87
|
+
previewArg: IWccRenderedPreview,
|
|
88
|
+
viewArg: IWccResolvedView,
|
|
89
|
+
optionsArg: IWccResolvedImageOptions,
|
|
90
|
+
): Promise<{ framing: plugins.interfaces.TWccCaptureFraming; image: plugins.interfaces.IWccImage; truncated: boolean }> => {
|
|
91
|
+
const { page } = previewArg;
|
|
92
|
+
const framing = optionsArg.framing ?? (previewArg.type === 'page' ? 'fullpage' : 'element');
|
|
93
|
+
const encoding = { type: optionsArg.format, ...(optionsArg.quality === undefined ? {} : { quality: optionsArg.quality }) } as const;
|
|
94
|
+
|
|
95
|
+
if (framing === 'element') {
|
|
96
|
+
if (previewArg.type === 'page') {
|
|
97
|
+
throw new WccCaptureRequestError('wcctools: a page has no element to frame; use the viewport or fullpage framing.');
|
|
98
|
+
}
|
|
99
|
+
const handle = await page.evaluateHandle(() => window.wccPreview!.findRenderedInstance());
|
|
100
|
+
try {
|
|
101
|
+
const element = handle.asElement();
|
|
102
|
+
if (!element) {
|
|
103
|
+
throw new WccCaptureError(
|
|
104
|
+
`wcctools: the demo of ${previewArg.selection.itemName} renders no instance of its element; use the viewport framing.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
const bytes = await element.screenshot(encoding);
|
|
108
|
+
return { framing, image: toWccImage(bytes, optionsArg.format), truncated: false };
|
|
109
|
+
} finally {
|
|
110
|
+
await handle.dispose();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (framing === 'viewport') {
|
|
115
|
+
const bytes = await page.screenshot(encoding);
|
|
116
|
+
return { framing, image: toWccImage(bytes, optionsArg.format), truncated: false };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const documentHeight = await page.evaluate(() => document.documentElement.scrollHeight);
|
|
120
|
+
if (documentHeight > captureLimits.maxFullPageHeight) {
|
|
121
|
+
const bytes = await page.screenshot({
|
|
122
|
+
...encoding,
|
|
123
|
+
clip: { x: 0, y: 0, width: viewArg.width, height: captureLimits.maxFullPageHeight },
|
|
124
|
+
captureBeyondViewport: true,
|
|
125
|
+
});
|
|
126
|
+
return { framing, image: toWccImage(bytes, optionsArg.format), truncated: true };
|
|
127
|
+
}
|
|
128
|
+
const bytes = await page.screenshot({ ...encoding, fullPage: true });
|
|
129
|
+
return { framing, image: toWccImage(bytes, optionsArg.format), truncated: false };
|
|
130
|
+
};
|