@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
package/readme.md
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
`@design.estate/wcctools` provides a comprehensive development environment for web components, featuring:
|
|
8
8
|
|
|
9
|
-
- 🖥️ **`wcctools dev`** — One command bundles your catalogue with live reload and serves it in the wcctools shell
|
|
9
|
+
- 🖥️ **`wcctools dev`** — One command bundles your catalogue with live reload and serves it in the wcctools shell, on this machine only unless you expose it
|
|
10
|
+
- 📸 **Headless Captures** — `wcctools screenshot` and the dev server's typed API screenshot any demo at any viewport and theme, and observe scripted interactions frame by frame
|
|
10
11
|
- 🎨 **Interactive Component Catalogue** — Live preview with customizable sidebar sections
|
|
11
12
|
- 🔧 **Real-time Property Editing** — Modify component props on the fly with auto-detected editors
|
|
12
13
|
- 🌓 **Theme Switching** — Test light/dark modes instantly
|
|
@@ -30,6 +31,14 @@ pnpm add -D @design.estate/wcctools
|
|
|
30
31
|
npm install @design.estate/wcctools --save-dev
|
|
31
32
|
```
|
|
32
33
|
|
|
34
|
+
Captures run in a headless Chrome or Chromium found on the `PATH` (`google-chrome`, `chromium` or `chromium-browser`), driven by Puppeteer. pnpm asks whether Puppeteer's install script may run; without it, Puppeteer downloads no browser of its own and the one on the `PATH` is used:
|
|
35
|
+
|
|
36
|
+
```yaml
|
|
37
|
+
# pnpm-workspace.yaml
|
|
38
|
+
allowBuilds:
|
|
39
|
+
puppeteer: false
|
|
40
|
+
```
|
|
41
|
+
|
|
33
42
|
### Migrating from `@design.estate/dees-wcctools`
|
|
34
43
|
|
|
35
44
|
`@design.estate/dees-wcctools` is now published as `@design.estate/wcctools`. Versions continue from 5.0.0 and the API is the same: swap the dependency and the import specifier.
|
|
@@ -56,6 +65,17 @@ A catalogue still on a 4.x or older release also follows the 5.0.0 changes: [`se
|
|
|
56
65
|
|
|
57
66
|
Opened outside `wcctools dev` (for example from plain `tswatch`), the catalogue bundle shows a notice that names the command instead of the catalogue UI. `RecorderService`, `WccRecordButton` and `WccRecordingPanel` are no longer exported: recording lives in the shell.
|
|
58
67
|
|
|
68
|
+
### Migrating to 7.0.0
|
|
69
|
+
|
|
70
|
+
7.0.0 locks the dev server down, because its typed API now drives a headless browser:
|
|
71
|
+
|
|
72
|
+
1. `wcctools dev` listens on `127.0.0.1` only. To open the catalogue from another machine, start it with `--host 0.0.0.0` (every interface; the machine's own addresses are then allowed) or `--host <address>`, and allow every further name you browse it under with `--allowed-host <name>`. Exposed this way, every machine that can reach the port can use the shell and the capture API: the `Host` and `Origin` checks below keep web pages in your browser from using the server, they do not authenticate other machines.
|
|
73
|
+
2. Requests for a `Host` the server does not serve are refused with `403` and a message naming the `--allowed-host` to add.
|
|
74
|
+
3. Typed requests and other state-changing requests, and WebSocket connections, are admitted only from the server's own origin; a request without an `Origin` header only from this machine.
|
|
75
|
+
4. The preview no longer sends CORS headers: other origins cannot read the catalogue bundle.
|
|
76
|
+
|
|
77
|
+
Nothing changes for a catalogue opened on `http://localhost:<port>/wcctools/`.
|
|
78
|
+
|
|
59
79
|
## Quick Start
|
|
60
80
|
|
|
61
81
|
### 1. Create Your Component
|
|
@@ -163,6 +183,7 @@ setupWccTools({
|
|
|
163
183
|
```bash
|
|
164
184
|
pnpm exec wcctools dev # port from the tswatch configuration, else 3002
|
|
165
185
|
pnpm exec wcctools dev --port 0 # any free port
|
|
186
|
+
pnpm exec wcctools dev --host 0.0.0.0 --allowed-host devbox.lan # reachable from the network as devbox.lan
|
|
166
187
|
```
|
|
167
188
|
|
|
168
189
|
It prints the shell's address, for example `wcctools dev: http://localhost:3002/wcctools/`. Saving a source file rebuilds the bundle and reloads only the preview frame; the shell keeps its state. When a bundle fails, the shell shows `bundle failed` with the bundler's message over the preview until the next run of that bundle finishes. `Ctrl+C` (or `SIGTERM`) stops the server and the watchers.
|
|
@@ -490,7 +511,7 @@ The preview document alone renders one demo from its own route, for tools that c
|
|
|
490
511
|
|
|
491
512
|
## API Reference
|
|
492
513
|
|
|
493
|
-
### `wcctools dev [--port <port>]`
|
|
514
|
+
### `wcctools dev [--port <port>] [--host <address>] [--allowed-host <name>…]`
|
|
494
515
|
|
|
495
516
|
Bundles and watches the catalogue as the project's `@git.zone/tswatch` configuration describes (or tswatch's `element` preset) and serves on one port:
|
|
496
517
|
|
|
@@ -498,15 +519,83 @@ Bundles and watches the catalogue as the project's `@git.zone/tswatch` configura
|
|
|
498
519
|
|------|--------|
|
|
499
520
|
| `/` | Redirects to `/wcctools/` |
|
|
500
521
|
| `/wcctools/`, `/wcctools-route/...` | The shell |
|
|
501
|
-
| `/wcctools/typedrequest` | The
|
|
522
|
+
| `/wcctools/typedrequest` | The typed API: the server info (`getDevServerInfo`), the bundle status (`getBundleStatus`) and [captures](#captures-on-the-typed-api) (`captureScreenshot`, `captureInteraction`) |
|
|
502
523
|
| every other path | The catalogue's serve directory (default `dist_watch/`), with live reload for the preview document |
|
|
503
524
|
|
|
504
|
-
`--port` overrides the configured port; `0` picks a free port.
|
|
525
|
+
`--port` overrides the configured port; `0` picks a free port. `--host` sets the interface address to listen on (default `127.0.0.1`); `--allowed-host` (repeatable) adds a hostname or address the server answers.
|
|
526
|
+
|
|
527
|
+
The shell follows the bundle status: per bundle the state of its latest run (`started`, `finished` or `failed`), the duration of the latest completed run and, while its latest completed run failed, the bundler's error message. A failed bundle is shown over the preview until a run of it finishes, which also reloads the preview.
|
|
505
528
|
|
|
506
|
-
|
|
529
|
+
#### Security model
|
|
530
|
+
|
|
531
|
+
The dev server serves your source and drives a headless browser, so by default it answers only this machine (with `--host`, every machine that can reach the port; the checks below guard against web pages, not against other machines):
|
|
532
|
+
|
|
533
|
+
- It listens on `127.0.0.1` unless `--host` says otherwise.
|
|
534
|
+
- It answers only the hosts it serves: `localhost`, `127.0.0.1`, `::1`, the `--host` address (with `--host 0.0.0.0` or `::`, every address of the machine's interfaces) and each `--allowed-host`. Any other `Host` is refused with `403`, which defeats DNS rebinding.
|
|
535
|
+
- Typed requests and every other request but `GET`, `HEAD` and `OPTIONS`, and WebSocket connections, must come from the server's own origin (`http://<allowed host>:<port>`). A request without an `Origin` header is admitted only from a loopback connection, as local tools send it. So other web pages you visit cannot drive the typed API, and since neither the shell nor the preview sends CORS headers, they cannot read the catalogue either. The same rule refuses the shell behind a TLS-terminating reverse proxy (see [Known Limitations](#known-limitations)).
|
|
507
536
|
|
|
508
537
|
`wcctools dev` owns the process signals: on `SIGINT` or `SIGTERM` it stops the server and tswatch's bundling and watching once, within tswatch's shutdown deadline (the longest `stopGracePeriod` of the configured watcher commands plus a margin), and exits.
|
|
509
538
|
|
|
539
|
+
### `wcctools screenshot <item | section/item> --out <file>`
|
|
540
|
+
|
|
541
|
+
Bundles the catalogue of the working directory, serves it on a free loopback port, screenshots one demo in a headless browser, writes the image and stops.
|
|
542
|
+
|
|
543
|
+
```bash
|
|
544
|
+
wcctools screenshot my-button --out button.png # desktop width, dark theme
|
|
545
|
+
wcctools screenshot Elements/MyButton --demo 1 --viewport phone --theme bright --out button-phone.png
|
|
546
|
+
wcctools screenshot Pages/Home --viewport tablet --out home.jpg # a page, in full
|
|
547
|
+
wcctools screenshot my-card --viewport 720 --framing viewport --height 600 --scale 2 --out card@2x.png
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
| Option | Meaning |
|
|
551
|
+
|--------|---------|
|
|
552
|
+
| `<item>` | An entry's name or its element's tag; `section/item` when the name is in more than one section |
|
|
553
|
+
| `--out` | The file to write; `.png`, `.jpg` or `.jpeg` sets the format |
|
|
554
|
+
| `--demo` | The demo, counted from 0 as in the shell URL (default 0) |
|
|
555
|
+
| `--viewport` | `phone` (400), `phablet` (600), `tablet` (1024), `desktop` (1600, the default) or a width from 200 to 3840 pixels; the widths are the dees-domtools breakpoints |
|
|
556
|
+
| `--height` | Height of the preview window, which also sizes `100vh` (default 800) |
|
|
557
|
+
| `--theme` | `dark` (default) or `bright` |
|
|
558
|
+
| `--framing` | `element`: the rendered element, the default for element demos; `viewport`: the visible window; `fullpage`: the whole document, the default for pages (cut at 10000 pixels) |
|
|
559
|
+
| `--scale` | Device pixel ratio, 1 (default) or 2 |
|
|
560
|
+
| `--quality` | JPEG quality from 1 to 100 (default 80) |
|
|
561
|
+
|
|
562
|
+
The capture waits for the preview's own signals, never for a fixed time: the preview bridge, the render's completed element updates and the document's fonts. It fails (exit code 1) when the bundle failed or the demo renders nothing; an entry that does not exist, or invalid options, exit with 2 and list what exists.
|
|
563
|
+
|
|
564
|
+
### Captures on the typed API
|
|
565
|
+
|
|
566
|
+
While `wcctools dev` runs, its typed API takes the same captures, in one shared headless browser that starts with the first capture, closes after a minute without one and always closes with the server. Two captures run at once and eight wait; more are refused as busy. Captures wait while a bundle builds and are refused while one failed. Every capture ends when its client goes away, and at its deadline, which counts from its request and includes waiting for a slot or a bundle: 30 seconds for a screenshot, 135 seconds for an interaction (20 steps of 5 seconds, 5 seconds of observation and 30 seconds of set-up). `TypedRequest.fire()` gives up after 60 seconds unless given a `timeoutMs`, so fire interactions with a `timeoutMs` above their deadline, as below.
|
|
567
|
+
|
|
568
|
+
```typescript
|
|
569
|
+
import { TypedRequest } from '@api.global/typedrequest';
|
|
570
|
+
import type { IReq_CaptureScreenshot, IReq_CaptureInteraction } from '@design.estate/wcctools/interfaces';
|
|
571
|
+
|
|
572
|
+
const endpoint = 'http://localhost:3002/wcctools/typedrequest';
|
|
573
|
+
|
|
574
|
+
const shot = await new TypedRequest<IReq_CaptureScreenshot>(endpoint, 'captureScreenshot').fire({
|
|
575
|
+
subject: { itemName: 'my-button' },
|
|
576
|
+
viewport: 'phone',
|
|
577
|
+
theme: 'dark',
|
|
578
|
+
});
|
|
579
|
+
// shot.image: { mimeType: 'image/png', width, height, dataBase64 }
|
|
580
|
+
|
|
581
|
+
const observed = await new TypedRequest<IReq_CaptureInteraction>(endpoint, 'captureInteraction').fire({
|
|
582
|
+
subject: { sectionName: 'Elements', itemName: 'MyInput' },
|
|
583
|
+
viewport: 'phone',
|
|
584
|
+
steps: [
|
|
585
|
+
{ action: 'click', selector: 'input' },
|
|
586
|
+
{ action: 'fill', selector: 'input', text: 'hello' },
|
|
587
|
+
{ action: 'press', key: 'Enter' },
|
|
588
|
+
{ action: 'waitFor', selector: '.error-message', state: 'hidden' },
|
|
589
|
+
],
|
|
590
|
+
observeMs: 500,
|
|
591
|
+
maxFrames: 12,
|
|
592
|
+
finalScreenshot: { framing: 'element' },
|
|
593
|
+
}, { timeoutMs: 140_000 }); // the interaction deadline is 135 s; fire() alone gives up after 60 s
|
|
594
|
+
// observed.frames: viewport JPEGs with timestampMs; observed.steps: per step ok/error and its time
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
An interaction renders the demo, records the page's screencast while it runs the steps, keeps watching for `observeMs` after the last one (at most 5000) and returns at most `maxFrames` frames (at most 40) spread evenly over that time, the first and the last always among them. Steps are `click`, `hover`, `fill`, `press` (a key such as `Enter` or `Tab`, on a selector or the focused element), `waitFor` (`visible` or `hidden`) and `wait` (at most 3000 ms); there are at most 20. A selector is a CSS selector matched in the preview document and in every open shadow root below it, so `input` finds the input inside a component; the first match in document order is used once it is visible and enabled. Each step may take 5 seconds; a failing step ends the script and is reported with its error (`completed: false`), the frames up to then included. A capture stays on the preview route: a navigation that would replace the preview document (a link, a form submission, a script assigning `location`, a reload, also to the same URL, to `about:blank` or to a `blob:` URL) is cancelled as it starts, and the step that started it fails with `navigation to <url> blocked` and ends the script, so the final screenshot still shows the preview. A navigation started after the last step fails that step. The preview's history begins with the preview, so going back leaves nothing. A screenshot, or an interaction without steps, fails when its demo navigates away. Same-document navigations, such as fragment links and `history.pushState`, pass.
|
|
598
|
+
|
|
510
599
|
Every `wcctools` command exits with code 2 on a usage error (an unknown command or option, a missing or invalid option value) and with 1 on any other failure.
|
|
511
600
|
|
|
512
601
|
### `wcctools check` and `wcctools fix`
|
|
@@ -577,7 +666,7 @@ The wrapper provides full DOM API access:
|
|
|
577
666
|
|
|
578
667
|
### `@design.estate/wcctools/interfaces`
|
|
579
668
|
|
|
580
|
-
The contracts between the shell, the preview document and the dev server, for tools that drive a preview: the catalogue manifest (`IWccCatalogManifest`), the preview bridge the preview document installs as `window.wccPreview` (`IWccPreviewBridge`: render a demo, switch the theme, read and edit the rendered element's properties) and the dev server's typed requests, and the component standard's configuration and reports (`IStandardConfig`, `IStandardReport`, `IStandardFixResult`).
|
|
669
|
+
The contracts between the shell, the preview document and the dev server, for tools that drive a preview: the catalogue manifest (`IWccCatalogManifest`), the preview bridge the preview document installs as `window.wccPreview` (`IWccPreviewBridge`: render a demo, switch the theme, read and edit the rendered element's properties) and the dev server's typed requests including the capture requests and their results (`IReq_CaptureScreenshot`, `IReq_CaptureInteraction`, `IWccImage`, `TWccInteractionStep`, …), and the component standard's configuration and reports (`IStandardConfig`, `IStandardReport`, `IStandardFixResult`).
|
|
581
670
|
|
|
582
671
|
```typescript
|
|
583
672
|
import type { IWccPreviewBridge } from '@design.estate/wcctools/interfaces';
|
|
@@ -587,6 +676,15 @@ await bridge.render({ sectionName: 'Elements', itemName: 'MyButton', demoIndex:
|
|
|
587
676
|
const properties = await bridge.getProperties();
|
|
588
677
|
```
|
|
589
678
|
|
|
679
|
+
## Known Limitations
|
|
680
|
+
|
|
681
|
+
- Captures need Chrome or Chromium on the `PATH`. In CI (`CI` set) or as root, the browser starts without its sandbox and prints a warning banner saying so.
|
|
682
|
+
- Each start of the capture browser prints its launch arguments and executable on standard output (from `@push.rocks/smartbrowser`, which has no quiet option yet). The lines appear in the `wcctools dev` log on the first capture and in the output of `wcctools screenshot`, which therefore has no machine-readable output mode and writes images only to files.
|
|
683
|
+
- A selector is matched within each document or shadow root on its own: `my-input input` does not reach into `my-input`'s shadow root, `input` does.
|
|
684
|
+
- Captures wait for element updates and fonts, not for images or other resources a demo loads.
|
|
685
|
+
- Captures cancel navigations through the Navigation API's `navigate` event before the demo's own listeners see it, so a demo that routes by calling `intercept()` on cross-document navigations does not route in a capture (its `intercept()` call throws). A `javascript:` URL navigates without that event; one that evaluates to a string replaces the preview document. A navigation Chrome does not let the page cancel (some history traversals; the preview's history holds only the preview, so there is none to traverse) is aborted at the network instead, may be reported on the step after the one that started it, and is not stopped when it loads nothing from the network.
|
|
686
|
+
- The shell does not work through a TLS-terminating reverse proxy, even for a name given with `--allowed-host`: its typed requests and WebSocket connections then carry an `https:` origin (or the proxy's port), and the dev server admits only `http://<allowed host>:<port>` with its own port, so it refuses them; plain page loads still pass. Reach the dev server over plain HTTP on its own port, for example through an SSH tunnel.
|
|
687
|
+
|
|
590
688
|
## Project Structure
|
|
591
689
|
|
|
592
690
|
```
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export const commitinfo = {
|
|
5
5
|
name: '@design.estate/wcctools',
|
|
6
|
-
version: '
|
|
6
|
+
version: '7.0.0',
|
|
7
7
|
description: 'A set of web component tools for creating element catalogues, enabling the structured development and documentation of custom elements and pages.'
|
|
8
8
|
}
|
|
@@ -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
|
+
}
|