@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.
- package/changelog.md +41 -0
- package/dist_shell/bundle.js +405 -368
- package/dist_shell/bundle.js.map +1 -1
- package/dist_shell/bundle.js.third-party-notices.json +52 -0
- package/dist_shell/bundle.js.third-party-notices.txt +107 -1
- package/dist_shell/index.html +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.bundlestatus.d.ts +36 -0
- package/dist_ts/classes.bundlestatus.js +110 -0
- package/dist_ts/classes.devapi.d.ts +3 -0
- package/dist_ts/classes.devapi.js +8 -1
- package/dist_ts/classes.devserver.d.ts +65 -14
- package/dist_ts/classes.devserver.js +157 -52
- package/dist_ts/classes.hostpolicy.d.ts +63 -0
- package/dist_ts/classes.hostpolicy.js +171 -0
- package/dist_ts/cli.js +28 -3
- package/dist_ts/cli.screenshot.d.ts +6 -0
- package/dist_ts/cli.screenshot.js +89 -0
- package/dist_ts/index.d.ts +0 -2
- package/dist_ts/index.js +1 -3
- 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 +59 -0
- package/dist_ts_shell/bundlestatus.d.ts +15 -0
- package/dist_ts_shell/bundlestatus.js +64 -0
- package/dist_ts_shell/elements/wcc-contextmenu.d.ts +3 -0
- package/dist_ts_shell/elements/wcc-contextmenu.js +18 -3
- package/dist_ts_shell/elements/wcc-preview-frame.d.ts +6 -0
- package/dist_ts_shell/elements/wcc-preview-frame.js +50 -2
- 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/elements/wcc-shell.d.ts +15 -0
- package/dist_ts_shell/elements/wcc-shell.js +45 -3
- package/dist_ts_shell/plugins.d.ts +2 -1
- package/dist_ts_shell/plugins.js +3 -2
- 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 +6 -2
- package/readme.md +108 -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.bundlestatus.ts +123 -0
- package/ts/classes.devapi.ts +9 -0
- package/ts/classes.devserver.ts +172 -50
- package/ts/classes.hostpolicy.ts +201 -0
- package/ts/cli.screenshot.ts +95 -0
- package/ts/cli.ts +28 -2
- package/ts/index.ts +0 -2
- package/ts/plugins.ts +9 -2
- package/ts_interfaces/bridge.ts +95 -0
- package/ts_interfaces/capture.ts +144 -0
- package/ts_interfaces/catalog.ts +37 -0
- package/ts_interfaces/index.ts +5 -0
- package/ts_interfaces/plugins.ts +3 -0
- package/ts_interfaces/requests.ts +104 -0
- package/ts_interfaces/standard.ts +103 -0
- package/ts_shared/index.ts +1 -0
- package/ts_shared/plugins.ts +3 -0
- package/ts_shared/previewroute.ts +58 -0
- package/ts_shell/bundlestatus.ts +68 -0
- package/ts_shell/elements/wcc-contextmenu.ts +306 -0
- package/ts_shell/elements/wcc-preview-frame.ts +167 -0
- package/ts_shell/elements/wcc-properties.ts +998 -0
- package/ts_shell/elements/wcc-record-button.ts +108 -0
- package/ts_shell/elements/wcc-recording-panel.ts +1017 -0
- package/ts_shell/elements/wcc-shell.ts +485 -0
- package/ts_shell/elements/wcc-sidebar.ts +1419 -0
- package/ts_shell/index.html +21 -0
- package/ts_shell/index.ts +4 -0
- package/ts_shell/plugins.ts +12 -0
- package/ts_shell/previewconnection.ts +103 -0
- package/ts_shell/services/framesampler.service.ts +112 -0
- package/ts_shell/services/recorder.service.ts +451 -0
- package/ts_shell/types/dom-mediacapture-stub/index.d.ts +12 -0
- package/ts_shell/types/dom-mediacapture-stub/package.json +6 -0
- package/ts_shell/types/dom-webcodecs-stub/index.d.ts +2 -0
- package/ts_shell/types/dom-webcodecs-stub/package.json +6 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
|
@@ -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
|
+
};
|