@rettangoli/vt 1.0.0-rc1 → 1.0.0-rc13
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/README.md +78 -21
- package/package.json +1 -1
- package/src/capture/playwright-runner.js +13 -11
- package/src/capture/spec-loader.js +41 -21
- package/src/cli/generate-options.js +15 -1
- package/src/cli/generate.js +26 -1
- package/src/cli/index.js +3 -1
- package/src/cli/report.js +1 -1
- package/src/cli/screenshot.js +8 -0
- package/src/cli/service-runtime.js +116 -0
- package/src/cli/templates/default.html +5 -3
- package/src/cli/templates/index.html +8 -6
- package/src/cli/templates/report.html +4 -2
- package/src/common.js +4 -1
- package/src/createSteps.js +808 -39
- package/src/section-page-key.js +14 -0
- package/src/selector-filter.js +7 -4
- package/src/step-commands.js +37 -0
- package/src/validation.js +361 -25
- package/src/viewport.js +99 -0
package/README.md
CHANGED
|
@@ -5,12 +5,18 @@ Visual regression testing for Rettangoli specs using Playwright screenshots.
|
|
|
5
5
|
## Commands
|
|
6
6
|
|
|
7
7
|
- `rtgl vt generate`
|
|
8
|
+
- `rtgl vt screenshot`
|
|
8
9
|
- `rtgl vt report`
|
|
9
10
|
- `rtgl vt accept`
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
Behavior split:
|
|
13
|
+
|
|
14
|
+
- `generate` builds candidate HTML only (no Playwright capture)
|
|
15
|
+
- `screenshot` runs generate flow and captures candidate screenshots
|
|
16
|
+
- `report` compares existing artifacts only (does not run generate/screenshot)
|
|
17
|
+
|
|
18
|
+
## Public Screenshot Options
|
|
12
19
|
|
|
13
|
-
- `--skip-screenshots`
|
|
14
20
|
- `--headed`
|
|
15
21
|
- `--concurrency <number>`
|
|
16
22
|
- `--timeout <ms>`
|
|
@@ -30,10 +36,10 @@ Visual regression testing for Rettangoli specs using Playwright screenshots.
|
|
|
30
36
|
|
|
31
37
|
## Scoped Runs
|
|
32
38
|
|
|
33
|
-
Use selectors to run only part of VT in both `
|
|
39
|
+
Use selectors to run only part of VT in both `screenshot` and `report`:
|
|
34
40
|
|
|
35
41
|
- `folder`: matches specs by folder prefix under `vt/specs` (example: `components/forms`)
|
|
36
|
-
- `group`: matches section page key from `vt.sections` (`title`
|
|
42
|
+
- `group`: matches derived section page key from `vt.sections` titles (`kebab-case(title)`)
|
|
37
43
|
- `item`: matches a single spec path relative to `vt/specs` (with or without extension)
|
|
38
44
|
|
|
39
45
|
Selector rules:
|
|
@@ -45,21 +51,21 @@ Examples:
|
|
|
45
51
|
|
|
46
52
|
```bash
|
|
47
53
|
# Only specs under a folder
|
|
48
|
-
rtgl vt
|
|
54
|
+
rtgl vt screenshot --folder components/forms
|
|
49
55
|
|
|
50
56
|
# Only one section/group key from vt.sections
|
|
51
|
-
rtgl vt
|
|
57
|
+
rtgl vt screenshot --group components-basic
|
|
52
58
|
|
|
53
59
|
# Only one spec item (extension optional)
|
|
54
|
-
rtgl vt
|
|
55
|
-
rtgl vt
|
|
60
|
+
rtgl vt screenshot --item components/forms/login
|
|
61
|
+
rtgl vt screenshot --item components/forms/login.html
|
|
56
62
|
|
|
57
63
|
# Combine selectors (union)
|
|
58
|
-
rtgl vt
|
|
64
|
+
rtgl vt screenshot --group components-basic --item pages/home
|
|
59
65
|
|
|
60
66
|
# Same selectors for report
|
|
61
67
|
rtgl vt report --folder components/forms
|
|
62
|
-
rtgl vt report --group
|
|
68
|
+
rtgl vt report --group components-basic
|
|
63
69
|
rtgl vt report --item components/forms/login
|
|
64
70
|
```
|
|
65
71
|
|
|
@@ -73,20 +79,31 @@ Everything else in capture is internal and intentionally not user-configurable.
|
|
|
73
79
|
vt:
|
|
74
80
|
path: ./vt
|
|
75
81
|
port: 3001
|
|
76
|
-
|
|
82
|
+
url: http://127.0.0.1:4173
|
|
83
|
+
service:
|
|
84
|
+
start: bun run preview
|
|
77
85
|
concurrency: 4
|
|
78
86
|
timeout: 30000
|
|
79
87
|
waitEvent: vt:ready
|
|
88
|
+
viewport:
|
|
89
|
+
id: desktop
|
|
90
|
+
width: 1280
|
|
91
|
+
height: 720
|
|
80
92
|
sections:
|
|
81
|
-
- title:
|
|
93
|
+
- title: Components Basic
|
|
82
94
|
files: components
|
|
83
95
|
```
|
|
84
96
|
|
|
85
97
|
Notes:
|
|
86
98
|
|
|
87
99
|
- `vt.sections` is required.
|
|
88
|
-
-
|
|
100
|
+
- `vt.service` is optional. When set, VT starts the command before capture, waits for `vt.url`, then stops it after capture.
|
|
101
|
+
- when `vt.service` is omitted and `vt.url` is set, VT expects that URL to already be running.
|
|
102
|
+
- Section page keys are derived as `kebab-case(title)` for flat sections and group `items[].title`.
|
|
103
|
+
- Derived section page keys must be unique case-insensitively.
|
|
104
|
+
- `vt.viewport` supports object or array; each viewport requires `id`, `width`, `height`.
|
|
89
105
|
- `vt.capture` is internal and must be omitted.
|
|
106
|
+
- Viewport contract details: `docs/viewport-contract.md`.
|
|
90
107
|
|
|
91
108
|
## Spec Frontmatter
|
|
92
109
|
|
|
@@ -99,23 +116,65 @@ Supported frontmatter keys per spec file:
|
|
|
99
116
|
- `waitEvent`
|
|
100
117
|
- `waitSelector`
|
|
101
118
|
- `waitStrategy` (`networkidle` | `load` | `event` | `selector`)
|
|
119
|
+
- `viewport` (object or array of viewport objects)
|
|
102
120
|
- `skipScreenshot`
|
|
121
|
+
- `skipInitialScreenshot`
|
|
103
122
|
- `specs`
|
|
104
123
|
- `steps`
|
|
105
124
|
|
|
125
|
+
Step action reference:
|
|
126
|
+
|
|
127
|
+
- `docs/step-actions.md`
|
|
128
|
+
- canonical format is structured action objects (`- action: ...`); legacy one-line string steps are not supported.
|
|
129
|
+
- `assert` supports `js` deep-equal checks for object/array values.
|
|
130
|
+
|
|
106
131
|
Screenshot naming:
|
|
107
132
|
|
|
108
|
-
-
|
|
133
|
+
- By default, VT takes an immediate first screenshot before running `steps`.
|
|
134
|
+
- Set `skipInitialScreenshot: true` in frontmatter to skip that immediate first screenshot.
|
|
135
|
+
- First captured screenshot is `-01`.
|
|
109
136
|
- Then `-02`, `-03`, up to `-99`.
|
|
137
|
+
- When viewport id is configured, filenames include `--<viewportId>` before ordinal (for example `pages/home--mobile-01.webp`).
|
|
138
|
+
|
|
139
|
+
## Docker
|
|
140
|
+
|
|
141
|
+
A pre-built Docker image with `rtgl` and Playwright browsers is available:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
docker pull han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc13
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Run commands against a local project:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
docker run --rm -v "$(pwd):/workspace" han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc13 rtgl vt screenshot
|
|
151
|
+
docker run --rm -v "$(pwd):/workspace" han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc13 rtgl vt report
|
|
152
|
+
docker run --rm -v "$(pwd):/workspace" han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc13 rtgl vt accept
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Note:
|
|
156
|
+
|
|
157
|
+
- Image default working directory is `/workspace`.
|
|
158
|
+
- Use `-w /workspace/<subdir>` only when running commands from a subfolder within the mounted project.
|
|
159
|
+
|
|
160
|
+
Supports `linux/amd64` and `linux/arm64`.
|
|
110
161
|
|
|
111
162
|
## Development
|
|
112
163
|
|
|
113
|
-
Run tests:
|
|
164
|
+
Run unit tests:
|
|
114
165
|
|
|
115
166
|
```bash
|
|
116
167
|
bun test
|
|
117
168
|
```
|
|
118
169
|
|
|
170
|
+
Default unit run behavior:
|
|
171
|
+
|
|
172
|
+
- `bun test` skips the real-browser smoke tests in `spec/e2e-smoke.spec.js` unless `VT_E2E=1`.
|
|
173
|
+
- Skipped smoke tests are:
|
|
174
|
+
- `runs generate, accept, and report with real screenshots`
|
|
175
|
+
- `supports waitEvent readiness with real browser screenshots`
|
|
176
|
+
- `supports managed service lifecycle with vt.service.start and vt.url`
|
|
177
|
+
|
|
119
178
|
Run real-browser smoke:
|
|
120
179
|
|
|
121
180
|
```bash
|
|
@@ -125,15 +184,13 @@ VT_E2E=1 bun test spec/e2e-smoke.spec.js
|
|
|
125
184
|
Run Docker E2E tests (requires Docker daemon running):
|
|
126
185
|
|
|
127
186
|
```bash
|
|
128
|
-
# Full pipeline: build image →
|
|
129
|
-
bun run test:
|
|
187
|
+
# Full pipeline: build test image → run all E2E scenarios
|
|
188
|
+
bun run test:e2e:full
|
|
130
189
|
|
|
131
|
-
#
|
|
132
|
-
bun run test:
|
|
190
|
+
# Scenarios only (skip image build, assumes image already exists)
|
|
191
|
+
bun run test:e2e
|
|
133
192
|
```
|
|
134
193
|
|
|
135
|
-
The Docker E2E suite builds a local `rtgl-local-test:latest` image, then runs generate/report/accept in temp directories inside containers. Tests validate WebP screenshot headers, report.json schema, metrics.json schema, HTML content, directory structure, accept file copies, multi-spec fixtures, multi-screenshot ordinals, and pixelmatch diff detection.
|
|
136
|
-
|
|
137
194
|
Optional benchmark fixture:
|
|
138
195
|
|
|
139
196
|
```bash
|
package/package.json
CHANGED
|
@@ -3,11 +3,7 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import sharp from "sharp";
|
|
4
4
|
import { createSteps } from "../createSteps.js";
|
|
5
5
|
import { formatScreenshotOrdinal } from "./screenshot-naming.js";
|
|
6
|
-
|
|
7
|
-
const DEFAULT_VIEWPORT = Object.freeze({
|
|
8
|
-
width: 1280,
|
|
9
|
-
height: 720,
|
|
10
|
-
});
|
|
6
|
+
import { DEFAULT_VIEWPORT } from "../viewport.js";
|
|
11
7
|
|
|
12
8
|
function nowMs() {
|
|
13
9
|
return performance.now();
|
|
@@ -128,8 +124,11 @@ export class PlaywrightRunner {
|
|
|
128
124
|
return context;
|
|
129
125
|
}
|
|
130
126
|
|
|
131
|
-
async configurePage(page) {
|
|
132
|
-
await page.setViewportSize(
|
|
127
|
+
async configurePage(page, viewport = DEFAULT_VIEWPORT) {
|
|
128
|
+
await page.setViewportSize({
|
|
129
|
+
width: viewport.width,
|
|
130
|
+
height: viewport.height,
|
|
131
|
+
});
|
|
133
132
|
await page.setDefaultNavigationTimeout(this.navigationTimeout);
|
|
134
133
|
await page.setDefaultTimeout(this.readyTimeout);
|
|
135
134
|
await page.emulateMedia({
|
|
@@ -348,6 +347,7 @@ export class PlaywrightRunner {
|
|
|
348
347
|
|
|
349
348
|
try {
|
|
350
349
|
resetMs = await resetSession();
|
|
350
|
+
await this.configurePage(page, task.viewport ?? DEFAULT_VIEWPORT);
|
|
351
351
|
const readyState = await this.navigateToReadyState(page, task, registeredReadyEvents);
|
|
352
352
|
strategy = readyState.strategy;
|
|
353
353
|
navigationMs = readyState.navigationMs;
|
|
@@ -359,10 +359,12 @@ export class PlaywrightRunner {
|
|
|
359
359
|
}
|
|
360
360
|
settleMs = nowMs() - settleStart;
|
|
361
361
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
362
|
+
if (!task.frontMatter?.skipInitialScreenshot) {
|
|
363
|
+
const firstScreenshotStart = nowMs();
|
|
364
|
+
const firstScreenshotPath = await wrappedScreenshot(page, task.baseName);
|
|
365
|
+
initialScreenshotMs = nowMs() - firstScreenshotStart;
|
|
366
|
+
console.log(`Screenshot saved: ${firstScreenshotPath}`);
|
|
367
|
+
}
|
|
366
368
|
|
|
367
369
|
const stepsStart = nowMs();
|
|
368
370
|
const stepsExecutor = createSteps(page, {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { appendViewportToBaseName, resolveViewports } from "../viewport.js";
|
|
2
|
+
|
|
1
3
|
const removeExtension = (filePath) => filePath.replace(/\.[^/.]+$/, "");
|
|
2
4
|
|
|
3
5
|
const toHtmlPath = (filePath) => {
|
|
@@ -58,14 +60,20 @@ export function createCaptureTasks(generatedFiles, options) {
|
|
|
58
60
|
waitEvent,
|
|
59
61
|
waitSelector,
|
|
60
62
|
waitStrategy,
|
|
63
|
+
viewport,
|
|
61
64
|
} = options;
|
|
62
65
|
|
|
63
|
-
|
|
66
|
+
const tasks = [];
|
|
67
|
+
let taskIndex = 0;
|
|
68
|
+
|
|
69
|
+
generatedFiles.forEach((file, fileIndex) => {
|
|
64
70
|
const frontMatter = file.frontMatter || {};
|
|
65
71
|
const normalizedPath = normalizePathForUrl(file.path);
|
|
66
72
|
const constructedUrl = toHtmlPath(`${serverUrl}/candidate/${normalizedPath}`);
|
|
67
|
-
const
|
|
68
|
-
const
|
|
73
|
+
const resolvedConfigUrl = configUrl ? toAbsoluteUrl(configUrl, serverUrl) : null;
|
|
74
|
+
const urlResolutionBase = resolvedConfigUrl || serverUrl;
|
|
75
|
+
const rawUrl = frontMatter.url ?? resolvedConfigUrl ?? constructedUrl;
|
|
76
|
+
const url = toAbsoluteUrl(rawUrl, urlResolutionBase);
|
|
69
77
|
const resolvedWaitEvent = frontMatter.waitEvent ?? waitEvent;
|
|
70
78
|
const resolvedWaitSelector = frontMatter.waitSelector ?? waitSelector;
|
|
71
79
|
|
|
@@ -75,25 +83,37 @@ export function createCaptureTasks(generatedFiles, options) {
|
|
|
75
83
|
waitStrategy,
|
|
76
84
|
});
|
|
77
85
|
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
const resolvedViewports = resolveViewports(frontMatter.viewport, viewport);
|
|
87
|
+
resolvedViewports.forEach((resolvedViewport) => {
|
|
88
|
+
const viewportId = resolvedViewport.id;
|
|
89
|
+
const task = {
|
|
90
|
+
id: `${fileIndex}:${file.path}:${viewportId ?? "default"}`,
|
|
91
|
+
index: taskIndex,
|
|
92
|
+
path: file.path,
|
|
93
|
+
url,
|
|
94
|
+
baseName: appendViewportToBaseName(removeExtension(file.path), viewportId),
|
|
95
|
+
frontMatter,
|
|
96
|
+
steps: frontMatter.steps || [],
|
|
97
|
+
waitStrategy: resolvedWaitStrategy,
|
|
98
|
+
estimatedCost: estimateTaskCost(frontMatter.steps || [], resolvedWaitStrategy),
|
|
99
|
+
viewport: {
|
|
100
|
+
id: viewportId,
|
|
101
|
+
width: resolvedViewport.width,
|
|
102
|
+
height: resolvedViewport.height,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
89
105
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
106
|
+
if (resolvedWaitEvent !== undefined && resolvedWaitEvent !== null) {
|
|
107
|
+
task.waitEvent = resolvedWaitEvent;
|
|
108
|
+
}
|
|
109
|
+
if (resolvedWaitSelector !== undefined && resolvedWaitSelector !== null) {
|
|
110
|
+
task.waitSelector = resolvedWaitSelector;
|
|
111
|
+
}
|
|
96
112
|
|
|
97
|
-
|
|
113
|
+
tasks.push(task);
|
|
114
|
+
taskIndex += 1;
|
|
115
|
+
});
|
|
98
116
|
});
|
|
117
|
+
|
|
118
|
+
return tasks;
|
|
99
119
|
}
|
|
@@ -5,6 +5,7 @@ import { normalizeSelectors } from "../selector-filter.js";
|
|
|
5
5
|
export function resolveGenerateOptions(options = {}, configData = {}) {
|
|
6
6
|
const {
|
|
7
7
|
skipScreenshots: cliSkipScreenshots,
|
|
8
|
+
captureScreenshots,
|
|
8
9
|
vtPath: cliVtPath,
|
|
9
10
|
port: cliPort,
|
|
10
11
|
concurrency: cliConcurrency,
|
|
@@ -25,13 +26,26 @@ export function resolveGenerateOptions(options = {}, configData = {}) {
|
|
|
25
26
|
item: cliItem,
|
|
26
27
|
});
|
|
27
28
|
|
|
29
|
+
let resolvedSkipScreenshots;
|
|
30
|
+
if (captureScreenshots === true) {
|
|
31
|
+
resolvedSkipScreenshots = false;
|
|
32
|
+
} else if (captureScreenshots === false) {
|
|
33
|
+
resolvedSkipScreenshots = true;
|
|
34
|
+
} else if (cliSkipScreenshots === true) {
|
|
35
|
+
resolvedSkipScreenshots = true;
|
|
36
|
+
} else {
|
|
37
|
+
resolvedSkipScreenshots = configData.skipScreenshots ?? false;
|
|
38
|
+
}
|
|
39
|
+
|
|
28
40
|
const resolvedOptions = {
|
|
29
41
|
vtPath: cliVtPath ?? configData.path ?? "./vt",
|
|
30
|
-
skipScreenshots:
|
|
42
|
+
skipScreenshots: resolvedSkipScreenshots,
|
|
31
43
|
port: cliPort ?? configData.port ?? 3001,
|
|
32
44
|
waitEvent,
|
|
33
45
|
headless: cliHeadless ?? true,
|
|
34
46
|
configUrl: cliUrl ?? configData.url,
|
|
47
|
+
...(configData.viewport !== undefined ? { viewport: configData.viewport } : {}),
|
|
48
|
+
...(configData.service?.start ? { serviceStart: configData.service.start } : {}),
|
|
35
49
|
selectors,
|
|
36
50
|
|
|
37
51
|
// Internal capture defaults (not user-configurable).
|
package/src/cli/generate.js
CHANGED
|
@@ -14,6 +14,11 @@ import {
|
|
|
14
14
|
filterGeneratedFilesBySelectors,
|
|
15
15
|
hasSelectors,
|
|
16
16
|
} from "../selector-filter.js";
|
|
17
|
+
import {
|
|
18
|
+
startManagedService,
|
|
19
|
+
stopManagedService,
|
|
20
|
+
waitForServiceReady,
|
|
21
|
+
} from "./service-runtime.js";
|
|
17
22
|
|
|
18
23
|
const libraryTemplatesPath = new URL("./templates", import.meta.url).pathname;
|
|
19
24
|
const libraryStaticPath = new URL("./static", import.meta.url).pathname;
|
|
@@ -39,6 +44,7 @@ export function buildCaptureOptions({
|
|
|
39
44
|
isolationMode,
|
|
40
45
|
metricsPath,
|
|
41
46
|
headless,
|
|
47
|
+
viewport,
|
|
42
48
|
} = resolvedOptions;
|
|
43
49
|
|
|
44
50
|
return {
|
|
@@ -59,6 +65,7 @@ export function buildCaptureOptions({
|
|
|
59
65
|
isolationMode,
|
|
60
66
|
metricsPath,
|
|
61
67
|
headless,
|
|
68
|
+
viewport,
|
|
62
69
|
};
|
|
63
70
|
}
|
|
64
71
|
|
|
@@ -88,6 +95,7 @@ async function main(options = {}) {
|
|
|
88
95
|
skipScreenshots,
|
|
89
96
|
port,
|
|
90
97
|
configUrl,
|
|
98
|
+
serviceStart,
|
|
91
99
|
} = resolvedOptions;
|
|
92
100
|
|
|
93
101
|
const specsPath = join(vtPath, "specs");
|
|
@@ -162,8 +170,22 @@ async function main(options = {}) {
|
|
|
162
170
|
return;
|
|
163
171
|
}
|
|
164
172
|
|
|
165
|
-
|
|
173
|
+
let server = null;
|
|
174
|
+
let managedService = null;
|
|
175
|
+
|
|
166
176
|
try {
|
|
177
|
+
if (serviceStart) {
|
|
178
|
+
if (!configUrl) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
"vt.service.start requires vt.url (or --url) so VT can wait for readiness and capture against that URL.",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
managedService = startManagedService({ command: serviceStart });
|
|
184
|
+
await waitForServiceReady({ url: configUrl, handle: managedService });
|
|
185
|
+
} else if (!configUrl) {
|
|
186
|
+
server = await startWebServer(siteOutputPath, vtPath, port);
|
|
187
|
+
}
|
|
188
|
+
|
|
167
189
|
await takeScreenshots(
|
|
168
190
|
buildCaptureOptions({
|
|
169
191
|
filesToScreenshot,
|
|
@@ -177,6 +199,9 @@ async function main(options = {}) {
|
|
|
177
199
|
server.close();
|
|
178
200
|
console.log("Server stopped");
|
|
179
201
|
}
|
|
202
|
+
if (managedService) {
|
|
203
|
+
await stopManagedService(managedService);
|
|
204
|
+
}
|
|
180
205
|
}
|
|
181
206
|
}
|
|
182
207
|
}
|
package/src/cli/index.js
CHANGED
package/src/cli/report.js
CHANGED
|
@@ -159,7 +159,7 @@ async function main(options = {}) {
|
|
|
159
159
|
|
|
160
160
|
try {
|
|
161
161
|
if (!fs.existsSync(candidateDir)) {
|
|
162
|
-
throw new Error(`Candidate screenshots directory not found: "${candidateDir}". Run "rtgl vt
|
|
162
|
+
throw new Error(`Candidate screenshots directory not found: "${candidateDir}". Run "rtgl vt screenshot" first.`);
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
const candidateFiles = getAllFiles(candidateDir).filter((file) => file.endsWith(".webp"));
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
const READY_TIMEOUT_MS = 120000;
|
|
4
|
+
const READY_INTERVAL_MS = 500;
|
|
5
|
+
const STOP_TIMEOUT_MS = 10000;
|
|
6
|
+
|
|
7
|
+
function sleep(ms) {
|
|
8
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function formatExitMessage(result) {
|
|
12
|
+
if (!result) return "unknown exit state";
|
|
13
|
+
if (result.code !== null && result.code !== undefined) {
|
|
14
|
+
return `code ${result.code}`;
|
|
15
|
+
}
|
|
16
|
+
if (result.signal) {
|
|
17
|
+
return `signal ${result.signal}`;
|
|
18
|
+
}
|
|
19
|
+
return "unknown exit state";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sendSignal(handle, signal) {
|
|
23
|
+
const { child, useProcessGroup } = handle;
|
|
24
|
+
const pid = child.pid;
|
|
25
|
+
if (!pid) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
if (useProcessGroup) {
|
|
31
|
+
process.kill(-pid, signal);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
child.kill(signal);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error?.code !== "ESRCH") {
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function startManagedService({ command }) {
|
|
43
|
+
const useProcessGroup = process.platform !== "win32";
|
|
44
|
+
const child = spawn(command, {
|
|
45
|
+
cwd: process.cwd(),
|
|
46
|
+
stdio: "inherit",
|
|
47
|
+
shell: true,
|
|
48
|
+
env: process.env,
|
|
49
|
+
detached: useProcessGroup,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const exitPromise = new Promise((resolve, reject) => {
|
|
53
|
+
child.once("error", reject);
|
|
54
|
+
child.once("exit", (code, signal) => {
|
|
55
|
+
resolve({ code, signal });
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
console.log(`Starting managed service: ${command}`);
|
|
60
|
+
return { child, exitPromise, useProcessGroup };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function waitForServiceReady({ url, handle }) {
|
|
64
|
+
const startMs = Date.now();
|
|
65
|
+
|
|
66
|
+
while (Date.now() - startMs < READY_TIMEOUT_MS) {
|
|
67
|
+
if (handle.child.exitCode !== null) {
|
|
68
|
+
const result = await handle.exitPromise.catch(() => null);
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Managed service exited before becoming ready (${formatExitMessage(result)}).`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const response = await fetch(url);
|
|
76
|
+
console.log(`Managed service ready: ${url} (status ${response.status})`);
|
|
77
|
+
return;
|
|
78
|
+
} catch {
|
|
79
|
+
// Keep polling until timeout.
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await sleep(READY_INTERVAL_MS);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Timed out waiting for managed service at ${url} after ${READY_TIMEOUT_MS}ms.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function stopManagedService(handle) {
|
|
91
|
+
if (!handle) return;
|
|
92
|
+
|
|
93
|
+
const { child, exitPromise } = handle;
|
|
94
|
+
if (child.exitCode !== null) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
console.log("Stopping managed service (SIGTERM)...");
|
|
99
|
+
sendSignal(handle, "SIGTERM");
|
|
100
|
+
|
|
101
|
+
const exitedGracefully = await Promise.race([
|
|
102
|
+
exitPromise.then(() => true).catch(() => true),
|
|
103
|
+
sleep(STOP_TIMEOUT_MS).then(() => false),
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
if (exitedGracefully) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (child.exitCode === null) {
|
|
111
|
+
console.warn("Managed service did not exit after SIGTERM, sending SIGKILL...");
|
|
112
|
+
sendSignal(handle, "SIGKILL");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
await exitPromise.catch(() => null);
|
|
116
|
+
}
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
-
<link rel="stylesheet" href="/
|
|
6
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc6/dist/themes/base.css">
|
|
7
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc6/dist/themes/theme-rtgl-slate.css">
|
|
7
8
|
<script>
|
|
8
9
|
window.rtglIcons = {
|
|
9
10
|
text: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M4 12H20M4 8H20M4 16H12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
|
|
@@ -38,7 +39,8 @@
|
|
|
38
39
|
`,
|
|
39
40
|
}
|
|
40
41
|
</script>
|
|
41
|
-
<script src="https://cdn.jsdelivr.net/npm/
|
|
42
|
+
<script src="https://cdn.jsdelivr.net/npm/construct-style-sheets-polyfill@3.1.0/dist/adoptedStyleSheets.min.js"></script>
|
|
43
|
+
<script src="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc6/dist/rettangoli-iife-ui.min.js"></script>
|
|
42
44
|
<script src="/public/main.js"></script>
|
|
43
45
|
</head>
|
|
44
46
|
<body class="dark">
|
|
@@ -46,4 +48,4 @@
|
|
|
46
48
|
{{ content }}
|
|
47
49
|
</div>
|
|
48
50
|
</body>
|
|
49
|
-
</html>
|
|
51
|
+
</html>
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
<head>
|
|
5
5
|
<meta charset="UTF-8">
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
-
<link rel="stylesheet" href="/
|
|
7
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc6/dist/themes/base.css">
|
|
8
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc6/dist/themes/theme-rtgl-slate.css">
|
|
8
9
|
<script>
|
|
9
10
|
window.addEventListener('DOMContentLoaded', () => {
|
|
10
11
|
if (location.hash) {
|
|
@@ -15,7 +16,8 @@
|
|
|
15
16
|
}
|
|
16
17
|
});
|
|
17
18
|
</script>
|
|
18
|
-
<script src="https://cdn.jsdelivr.net/npm/
|
|
19
|
+
<script src="https://cdn.jsdelivr.net/npm/construct-style-sheets-polyfill@3.1.0/dist/adoptedStyleSheets.min.js"></script>
|
|
20
|
+
<script src="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc6/dist/rettangoli-iife-ui.min.js"></script>
|
|
19
21
|
|
|
20
22
|
<style>
|
|
21
23
|
pre {
|
|
@@ -45,14 +47,14 @@
|
|
|
45
47
|
</head>
|
|
46
48
|
|
|
47
49
|
<body class="dark">
|
|
48
|
-
<rtgl-view d="h" w="
|
|
50
|
+
<rtgl-view d="h" w="f" h="100vh">
|
|
49
51
|
|
|
50
52
|
<rtgl-view h="f" sm-hidden>
|
|
51
53
|
<rtgl-sidebar items="{{ sidebarItems }}">
|
|
52
54
|
</rtgl-sidebar>
|
|
53
55
|
</rtgl-view>
|
|
54
56
|
|
|
55
|
-
<rtgl-view id="content" h="100vh" w="1fg" p="lg" g="lg" style="flex-wrap: nowrap;" sv ah="c">
|
|
57
|
+
<rtgl-view id="content" h="100vh" w="1fg" p="lg" g="lg" style="flex-wrap: nowrap; min-width: 0;" sv ah="c">
|
|
56
58
|
<rtgl-view w="f" g="xl">
|
|
57
59
|
<rtgl-text s="h2">{{ currentSection.title }} </rtgl-text>
|
|
58
60
|
{% for file in files %}
|
|
@@ -100,8 +102,8 @@
|
|
|
100
102
|
<rtgl-view h="33vh"></rtgl-view>
|
|
101
103
|
</rtgl-view>
|
|
102
104
|
</rtgl-view>
|
|
103
|
-
<rtgl-view lg-hidden>
|
|
104
|
-
<rtgl-page-outline id="page-outline" target-id="content"></rtgl-page-outline>
|
|
105
|
+
<rtgl-view lg-hidden style="flex: 0 0 auto;">
|
|
106
|
+
<rtgl-page-outline id="page-outline" target-id="content" scroll-container-id="content"></rtgl-page-outline>
|
|
105
107
|
</rtgl-view>
|
|
106
108
|
</rtgl-view>
|
|
107
109
|
</body>
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
<head>
|
|
5
5
|
<meta charset="UTF-8">
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
-
<link rel="stylesheet" href="/
|
|
8
|
-
<
|
|
7
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc6/dist/themes/base.css">
|
|
8
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc6/dist/themes/theme-rtgl-slate.css">
|
|
9
|
+
<script src="https://cdn.jsdelivr.net/npm/construct-style-sheets-polyfill@3.1.0/dist/adoptedStyleSheets.min.js"></script>
|
|
10
|
+
<script src="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc6/dist/rettangoli-iife-ui.min.js"></script>
|
|
9
11
|
<style>
|
|
10
12
|
code {
|
|
11
13
|
white-space: pre-wrap;
|