@dailephd/my-dev-kit-lab 0.4.6 → 0.4.7
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 +11 -2
- package/README.md +7 -1
- package/dist/src/browser/index.js +2 -0
- package/dist/src/browser/playwrightRuntime.js +74 -0
- package/dist/src/browser/types.js +10 -0
- package/dist/src/cli/help.js +76 -0
- package/dist/src/cli/runLabCli.js +29 -1
- package/dist/src/commands/runTutorialRunCommand.js +170 -0
- package/dist/src/commands/runTutorialValidateCommand.js +138 -0
- package/dist/src/core/processTree.js +52 -0
- package/dist/src/core/resolveCommand.js +34 -0
- package/dist/src/core/runMeasuredCommand.js +39 -31
- package/dist/src/runtime/index.js +1 -0
- package/dist/src/runtime/managedProcess.js +380 -0
- package/dist/src/screenshot/captureReportScreenshot.js +30 -27
- package/dist/src/tutorial/index.js +16 -0
- package/dist/src/tutorial/loadTutorialContracts.js +60 -0
- package/dist/src/tutorial/markdownTutorialWriter.js +53 -0
- package/dist/src/tutorial/runTutorial.js +465 -0
- package/dist/src/tutorial/scenarioValidation.js +383 -0
- package/dist/src/tutorial/subtitleWriter.js +88 -0
- package/dist/src/tutorial/targetContractValidation.js +212 -0
- package/dist/src/tutorial/tutorialActions.js +110 -0
- package/dist/src/tutorial/tutorialArtifacts.js +176 -0
- package/dist/src/tutorial/tutorialAssertions.js +249 -0
- package/dist/src/tutorial/tutorialCursor.js +149 -0
- package/dist/src/tutorial/tutorialLocators.js +37 -0
- package/dist/src/tutorial/tutorialManifest.js +74 -0
- package/dist/src/tutorial/tutorialOverlay.js +180 -0
- package/dist/src/tutorial/tutorialPaths.js +86 -0
- package/dist/src/tutorial/tutorialSession.js +398 -0
- package/dist/src/tutorial/types.js +48 -0
- package/dist/src/tutorial/validationPrimitives.js +141 -0
- package/examples/tutorial-browser/index.html +47 -0
- package/examples/tutorial-browser/prepare.mjs +14 -0
- package/examples/tutorial-browser/scenario.json +17 -0
- package/examples/tutorial-browser/server.mjs +14 -0
- package/package.json +8 -4
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { MAX_STEP_PAUSE_MS, MAX_TUTORIAL_STEPS, MAX_VIEWPORT_HEIGHT, MAX_VIEWPORT_WIDTH, MIN_VIEWPORT_HEIGHT, MIN_VIEWPORT_WIDTH, TUTORIAL_ACTION_TYPES, TUTORIAL_ASSERTION_TYPES, TUTORIAL_CALLOUT_PLACEMENTS, TUTORIAL_GOTO_WAIT_UNTIL, TUTORIAL_LOCATOR_KINDS, TUTORIAL_SCHEMA_VERSION, TUTORIAL_WAIT_FOR_STATES } from "./types.js";
|
|
2
|
+
import { createErrorCollector, describeValue, isJsonScalar, isNonEmptyString, isNonNegativeInteger, isPlainObject, rejectUnknownKeys, validateOptionalTimeout, validateSchemaVersion, validateStableId } from "./validationPrimitives.js";
|
|
3
|
+
const SCENARIO_KEYS = ["schemaVersion", "id", "title", "description", "targetId", "browser", "steps"];
|
|
4
|
+
const STEP_KEYS = [
|
|
5
|
+
"id",
|
|
6
|
+
"narration",
|
|
7
|
+
"pauseBeforeMs",
|
|
8
|
+
"pauseAfterMs",
|
|
9
|
+
"action",
|
|
10
|
+
"highlight",
|
|
11
|
+
"callout",
|
|
12
|
+
"screenshot",
|
|
13
|
+
"assertions"
|
|
14
|
+
];
|
|
15
|
+
/**
|
|
16
|
+
* Validates a parsed JSON value as a TutorialScenarioV1.
|
|
17
|
+
*
|
|
18
|
+
* Strict by construction: unknown action, assertion and locator kinds fail, and
|
|
19
|
+
* unknown object fields fail rather than being ignored. That closure is what
|
|
20
|
+
* keeps a scenario from smuggling in an execution escape hatch (an `evaluate`
|
|
21
|
+
* action, a `script` field, an absolute `goto` URL) that a permissive validator
|
|
22
|
+
* would quietly drop on the floor.
|
|
23
|
+
*
|
|
24
|
+
* `source` is used to prefix each message, normally the scenario file path.
|
|
25
|
+
*/
|
|
26
|
+
export function validateTutorialScenario(value, source = "Scenario") {
|
|
27
|
+
const errors = createErrorCollector(source);
|
|
28
|
+
if (!isPlainObject(value)) {
|
|
29
|
+
errors.add("<root>", `expected a JSON object, received ${describeValue(value)}.`);
|
|
30
|
+
return { ok: false, errors: errors.errors };
|
|
31
|
+
}
|
|
32
|
+
rejectUnknownKeys(errors, "<root>", value, SCENARIO_KEYS);
|
|
33
|
+
validateSchemaVersion(errors, value.schemaVersion, TUTORIAL_SCHEMA_VERSION);
|
|
34
|
+
validateStableId(errors, "id", value.id);
|
|
35
|
+
validateStableId(errors, "targetId", value.targetId);
|
|
36
|
+
if (!isNonEmptyString(value.title)) {
|
|
37
|
+
errors.add("title", `expected a non-empty title string, received ${describeValue(value.title)}.`);
|
|
38
|
+
}
|
|
39
|
+
if (value.description !== undefined && typeof value.description !== "string") {
|
|
40
|
+
errors.add("description", `expected a string when supplied, received ${describeValue(value.description)}.`);
|
|
41
|
+
}
|
|
42
|
+
validateBrowser(errors, value.browser);
|
|
43
|
+
validateSteps(errors, value.steps);
|
|
44
|
+
if (errors.length > 0) {
|
|
45
|
+
return { ok: false, errors: errors.errors };
|
|
46
|
+
}
|
|
47
|
+
return { ok: true, value: value };
|
|
48
|
+
}
|
|
49
|
+
function validateBrowser(errors, value) {
|
|
50
|
+
if (!isPlainObject(value)) {
|
|
51
|
+
errors.add("browser", `expected an object with a viewport, received ${describeValue(value)}.`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
rejectUnknownKeys(errors, "browser", value, ["viewport"]);
|
|
55
|
+
const viewport = value.viewport;
|
|
56
|
+
if (!isPlainObject(viewport)) {
|
|
57
|
+
errors.add("browser.viewport", `expected an object with width and height, received ${describeValue(viewport)}.`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
rejectUnknownKeys(errors, "browser.viewport", viewport, ["width", "height"]);
|
|
61
|
+
validateBounded(errors, "browser.viewport.width", viewport.width, MIN_VIEWPORT_WIDTH, MAX_VIEWPORT_WIDTH);
|
|
62
|
+
validateBounded(errors, "browser.viewport.height", viewport.height, MIN_VIEWPORT_HEIGHT, MAX_VIEWPORT_HEIGHT);
|
|
63
|
+
}
|
|
64
|
+
function validateBounded(errors, location, value, min, max) {
|
|
65
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
66
|
+
errors.add(location, `expected a finite integer, received ${describeValue(value)}.`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (value < min || value > max) {
|
|
70
|
+
errors.add(location, `expected an integer between ${min} and ${max}, received ${value}.`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function validateSteps(errors, value) {
|
|
74
|
+
if (!Array.isArray(value)) {
|
|
75
|
+
errors.add("steps", `expected an array of steps, received ${describeValue(value)}.`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (value.length === 0) {
|
|
79
|
+
errors.add("steps", "a scenario must declare at least one step.");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (value.length > MAX_TUTORIAL_STEPS) {
|
|
83
|
+
errors.add("steps", `a scenario may declare at most ${MAX_TUTORIAL_STEPS} steps, received ${value.length}.`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const stepIds = new Set();
|
|
87
|
+
const screenshotIds = new Set();
|
|
88
|
+
value.forEach((step, index) => {
|
|
89
|
+
validateStep(errors, `steps[${index}]`, step, stepIds, screenshotIds);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
function validateStep(errors, location, value, stepIds, screenshotIds) {
|
|
93
|
+
if (!isPlainObject(value)) {
|
|
94
|
+
errors.add(location, `expected a step object, received ${describeValue(value)}.`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
rejectUnknownKeys(errors, location, value, STEP_KEYS);
|
|
98
|
+
if (validateStableId(errors, `${location}.id`, value.id)) {
|
|
99
|
+
if (stepIds.has(value.id)) {
|
|
100
|
+
errors.add(`${location}.id`, `duplicate step id ${JSON.stringify(value.id)}.`);
|
|
101
|
+
}
|
|
102
|
+
stepIds.add(value.id);
|
|
103
|
+
}
|
|
104
|
+
if (!isNonEmptyString(value.narration)) {
|
|
105
|
+
errors.add(`${location}.narration`, `expected non-empty narration text after trimming, received ${describeValue(value.narration)}.`);
|
|
106
|
+
}
|
|
107
|
+
validatePause(errors, `${location}.pauseBeforeMs`, value.pauseBeforeMs);
|
|
108
|
+
validatePause(errors, `${location}.pauseAfterMs`, value.pauseAfterMs);
|
|
109
|
+
if (value.action !== undefined) {
|
|
110
|
+
validateAction(errors, `${location}.action`, value.action);
|
|
111
|
+
}
|
|
112
|
+
if (value.highlight !== undefined) {
|
|
113
|
+
validateLocator(errors, `${location}.highlight`, value.highlight);
|
|
114
|
+
}
|
|
115
|
+
if (value.callout !== undefined) {
|
|
116
|
+
validateCallout(errors, `${location}.callout`, value.callout);
|
|
117
|
+
}
|
|
118
|
+
if (value.screenshot !== undefined) {
|
|
119
|
+
validateScreenshot(errors, `${location}.screenshot`, value.screenshot, screenshotIds);
|
|
120
|
+
}
|
|
121
|
+
if (value.assertions !== undefined) {
|
|
122
|
+
if (!Array.isArray(value.assertions)) {
|
|
123
|
+
errors.add(`${location}.assertions`, `expected an array of assertions, received ${describeValue(value.assertions)}.`);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
value.assertions.forEach((assertion, index) => {
|
|
127
|
+
validateAssertion(errors, `${location}.assertions[${index}]`, assertion);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function validatePause(errors, location, value) {
|
|
133
|
+
if (value === undefined) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (!isNonNegativeInteger(value)) {
|
|
137
|
+
errors.add(location, `expected a finite non-negative integer number of milliseconds, received ${describeValue(value)}.`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (value > MAX_STEP_PAUSE_MS) {
|
|
141
|
+
errors.add(location, `expected at most ${MAX_STEP_PAUSE_MS}ms, received ${value}.`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function validateCallout(errors, location, value) {
|
|
145
|
+
if (!isPlainObject(value)) {
|
|
146
|
+
errors.add(location, `expected a callout object, received ${describeValue(value)}.`);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
rejectUnknownKeys(errors, location, value, ["text", "locator", "placement"]);
|
|
150
|
+
if (!isNonEmptyString(value.text)) {
|
|
151
|
+
errors.add(`${location}.text`, `expected non-empty callout text, received ${describeValue(value.text)}.`);
|
|
152
|
+
}
|
|
153
|
+
if (value.locator !== undefined) {
|
|
154
|
+
validateLocator(errors, `${location}.locator`, value.locator);
|
|
155
|
+
}
|
|
156
|
+
if (value.placement !== undefined && !TUTORIAL_CALLOUT_PLACEMENTS.includes(value.placement)) {
|
|
157
|
+
errors.add(`${location}.placement`, `expected one of ${TUTORIAL_CALLOUT_PLACEMENTS.join(", ")}, received ${describeValue(value.placement)}.`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function validateScreenshot(errors, location, value, screenshotIds) {
|
|
161
|
+
if (!isPlainObject(value)) {
|
|
162
|
+
errors.add(location, `expected a screenshot object, received ${describeValue(value)}.`);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
rejectUnknownKeys(errors, location, value, ["id", "fullPage"]);
|
|
166
|
+
if (validateStableId(errors, `${location}.id`, value.id)) {
|
|
167
|
+
if (screenshotIds.has(value.id)) {
|
|
168
|
+
errors.add(`${location}.id`, `duplicate screenshot id ${JSON.stringify(value.id)}.`);
|
|
169
|
+
}
|
|
170
|
+
screenshotIds.add(value.id);
|
|
171
|
+
}
|
|
172
|
+
if (value.fullPage !== undefined && typeof value.fullPage !== "boolean") {
|
|
173
|
+
errors.add(`${location}.fullPage`, `expected a boolean, received ${describeValue(value.fullPage)}.`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
export function validateLocator(errors, location, value) {
|
|
177
|
+
if (!isPlainObject(value)) {
|
|
178
|
+
errors.add(location, `expected a locator object, received ${describeValue(value)}.`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const kind = value.kind;
|
|
182
|
+
if (typeof kind !== "string" || !TUTORIAL_LOCATOR_KINDS.includes(kind)) {
|
|
183
|
+
errors.add(`${location}.kind`, `unsupported locator kind ${describeValue(kind)}; expected one of ${TUTORIAL_LOCATOR_KINDS.join(", ")}.`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
switch (kind) {
|
|
187
|
+
case "role":
|
|
188
|
+
rejectUnknownKeys(errors, location, value, ["kind", "role", "name", "exact"]);
|
|
189
|
+
if (!isNonEmptyString(value.role)) {
|
|
190
|
+
errors.add(`${location}.role`, `expected a non-empty role, received ${describeValue(value.role)}.`);
|
|
191
|
+
}
|
|
192
|
+
if (value.name !== undefined && !isNonEmptyString(value.name)) {
|
|
193
|
+
errors.add(`${location}.name`, `expected a non-empty accessible name when supplied, received ${describeValue(value.name)}.`);
|
|
194
|
+
}
|
|
195
|
+
validateOptionalBoolean(errors, `${location}.exact`, value.exact);
|
|
196
|
+
break;
|
|
197
|
+
case "text":
|
|
198
|
+
rejectUnknownKeys(errors, location, value, ["kind", "text", "exact"]);
|
|
199
|
+
if (!isNonEmptyString(value.text)) {
|
|
200
|
+
errors.add(`${location}.text`, `expected non-empty text, received ${describeValue(value.text)}.`);
|
|
201
|
+
}
|
|
202
|
+
validateOptionalBoolean(errors, `${location}.exact`, value.exact);
|
|
203
|
+
break;
|
|
204
|
+
case "css":
|
|
205
|
+
rejectUnknownKeys(errors, location, value, ["kind", "selector"]);
|
|
206
|
+
if (!isNonEmptyString(value.selector)) {
|
|
207
|
+
errors.add(`${location}.selector`, `expected a non-empty CSS selector, received ${describeValue(value.selector)}.`);
|
|
208
|
+
}
|
|
209
|
+
break;
|
|
210
|
+
case "test-id":
|
|
211
|
+
rejectUnknownKeys(errors, location, value, ["kind", "testId"]);
|
|
212
|
+
if (!isNonEmptyString(value.testId)) {
|
|
213
|
+
errors.add(`${location}.testId`, `expected a non-empty testId, received ${describeValue(value.testId)}.`);
|
|
214
|
+
}
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function validateOptionalBoolean(errors, location, value) {
|
|
219
|
+
if (value !== undefined && typeof value !== "boolean") {
|
|
220
|
+
errors.add(location, `expected a boolean, received ${describeValue(value)}.`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
export function validateAction(errors, location, value) {
|
|
224
|
+
if (!isPlainObject(value)) {
|
|
225
|
+
errors.add(location, `expected an action object, received ${describeValue(value)}.`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const type = value.type;
|
|
229
|
+
if (typeof type !== "string" || !TUTORIAL_ACTION_TYPES.includes(type)) {
|
|
230
|
+
errors.add(`${location}.type`, `unsupported action ${describeValue(type)}; expected one of ${TUTORIAL_ACTION_TYPES.join(", ")}.`);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
switch (type) {
|
|
234
|
+
case "goto":
|
|
235
|
+
rejectUnknownKeys(errors, location, value, ["type", "path", "waitUntil"]);
|
|
236
|
+
validateGotoPath(errors, `${location}.path`, value.path);
|
|
237
|
+
if (value.waitUntil !== undefined && !TUTORIAL_GOTO_WAIT_UNTIL.includes(value.waitUntil)) {
|
|
238
|
+
errors.add(`${location}.waitUntil`, `expected one of ${TUTORIAL_GOTO_WAIT_UNTIL.join(", ")}, received ${describeValue(value.waitUntil)}.`);
|
|
239
|
+
}
|
|
240
|
+
break;
|
|
241
|
+
case "click":
|
|
242
|
+
case "hover":
|
|
243
|
+
rejectUnknownKeys(errors, location, value, ["type", "locator", "timeoutMs"]);
|
|
244
|
+
validateLocator(errors, `${location}.locator`, value.locator);
|
|
245
|
+
validateOptionalTimeout(errors, `${location}.timeoutMs`, value.timeoutMs);
|
|
246
|
+
break;
|
|
247
|
+
case "fill":
|
|
248
|
+
rejectUnknownKeys(errors, location, value, ["type", "locator", "value", "timeoutMs"]);
|
|
249
|
+
validateLocator(errors, `${location}.locator`, value.locator);
|
|
250
|
+
if (typeof value.value !== "string") {
|
|
251
|
+
errors.add(`${location}.value`, `expected a string value to fill, received ${describeValue(value.value)}.`);
|
|
252
|
+
}
|
|
253
|
+
validateOptionalTimeout(errors, `${location}.timeoutMs`, value.timeoutMs);
|
|
254
|
+
break;
|
|
255
|
+
case "press":
|
|
256
|
+
rejectUnknownKeys(errors, location, value, ["type", "locator", "key", "timeoutMs"]);
|
|
257
|
+
validateLocator(errors, `${location}.locator`, value.locator);
|
|
258
|
+
if (!isNonEmptyString(value.key)) {
|
|
259
|
+
errors.add(`${location}.key`, `expected a non-empty key name, received ${describeValue(value.key)}.`);
|
|
260
|
+
}
|
|
261
|
+
validateOptionalTimeout(errors, `${location}.timeoutMs`, value.timeoutMs);
|
|
262
|
+
break;
|
|
263
|
+
case "drag":
|
|
264
|
+
rejectUnknownKeys(errors, location, value, ["type", "source", "target", "timeoutMs"]);
|
|
265
|
+
validateLocator(errors, `${location}.source`, value.source);
|
|
266
|
+
validateLocator(errors, `${location}.target`, value.target);
|
|
267
|
+
validateOptionalTimeout(errors, `${location}.timeoutMs`, value.timeoutMs);
|
|
268
|
+
break;
|
|
269
|
+
case "wait-for":
|
|
270
|
+
rejectUnknownKeys(errors, location, value, ["type", "locator", "state", "timeoutMs"]);
|
|
271
|
+
validateLocator(errors, `${location}.locator`, value.locator);
|
|
272
|
+
if (value.state !== undefined && !TUTORIAL_WAIT_FOR_STATES.includes(value.state)) {
|
|
273
|
+
errors.add(`${location}.state`, `expected one of ${TUTORIAL_WAIT_FOR_STATES.join(", ")}, received ${describeValue(value.state)}.`);
|
|
274
|
+
}
|
|
275
|
+
validateOptionalTimeout(errors, `${location}.timeoutMs`, value.timeoutMs);
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* A scenario may only navigate inside the target application's own origin, so a
|
|
281
|
+
* goto path is a root-relative path, never a URL. Rejecting the URL forms here
|
|
282
|
+
* (rather than only at resolution time) means an unsafe scenario fails
|
|
283
|
+
* `tutorial validate` without any process ever being started.
|
|
284
|
+
*/
|
|
285
|
+
export function validateGotoPath(errors, location, value) {
|
|
286
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
287
|
+
errors.add(location, `expected a root-relative path beginning with "/", received ${describeValue(value)}.`);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (value.startsWith("//")) {
|
|
291
|
+
errors.add(location, `protocol-relative paths are not allowed; received ${JSON.stringify(value)}. Use a root-relative path such as "/dashboard".`);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (!value.startsWith("/")) {
|
|
295
|
+
errors.add(location, `expected a root-relative path beginning with "/", received ${JSON.stringify(value)}. Absolute URLs and scheme-qualified values are not allowed.`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
export function validateAssertion(errors, location, value) {
|
|
299
|
+
if (!isPlainObject(value)) {
|
|
300
|
+
errors.add(location, `expected an assertion object, received ${describeValue(value)}.`);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const type = value.type;
|
|
304
|
+
if (typeof type !== "string" || !TUTORIAL_ASSERTION_TYPES.includes(type)) {
|
|
305
|
+
errors.add(`${location}.type`, `unsupported assertion ${describeValue(type)}; expected one of ${TUTORIAL_ASSERTION_TYPES.join(", ")}.`);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
switch (type) {
|
|
309
|
+
case "element-visible":
|
|
310
|
+
rejectUnknownKeys(errors, location, value, ["type", "locator", "timeoutMs"]);
|
|
311
|
+
validateLocator(errors, `${location}.locator`, value.locator);
|
|
312
|
+
validateOptionalTimeout(errors, `${location}.timeoutMs`, value.timeoutMs);
|
|
313
|
+
break;
|
|
314
|
+
case "text-equals":
|
|
315
|
+
case "text-contains":
|
|
316
|
+
rejectUnknownKeys(errors, location, value, ["type", "locator", "expected", "timeoutMs"]);
|
|
317
|
+
validateLocator(errors, `${location}.locator`, value.locator);
|
|
318
|
+
if (typeof value.expected !== "string") {
|
|
319
|
+
errors.add(`${location}.expected`, `expected a string, received ${describeValue(value.expected)}.`);
|
|
320
|
+
}
|
|
321
|
+
validateOptionalTimeout(errors, `${location}.timeoutMs`, value.timeoutMs);
|
|
322
|
+
break;
|
|
323
|
+
case "url-path-equals":
|
|
324
|
+
rejectUnknownKeys(errors, location, value, ["type", "expected"]);
|
|
325
|
+
if (typeof value.expected !== "string" || !value.expected.startsWith("/")) {
|
|
326
|
+
errors.add(`${location}.expected`, `expected a root-relative path beginning with "/", received ${describeValue(value.expected)}.`);
|
|
327
|
+
}
|
|
328
|
+
break;
|
|
329
|
+
case "attribute-equals":
|
|
330
|
+
rejectUnknownKeys(errors, location, value, ["type", "locator", "name", "expected", "timeoutMs"]);
|
|
331
|
+
validateLocator(errors, `${location}.locator`, value.locator);
|
|
332
|
+
if (!isNonEmptyString(value.name)) {
|
|
333
|
+
errors.add(`${location}.name`, `expected a non-empty attribute name, received ${describeValue(value.name)}.`);
|
|
334
|
+
}
|
|
335
|
+
if (typeof value.expected !== "string") {
|
|
336
|
+
errors.add(`${location}.expected`, `expected a string, received ${describeValue(value.expected)}.`);
|
|
337
|
+
}
|
|
338
|
+
validateOptionalTimeout(errors, `${location}.timeoutMs`, value.timeoutMs);
|
|
339
|
+
break;
|
|
340
|
+
case "http-json-equals":
|
|
341
|
+
rejectUnknownKeys(errors, location, value, ["type", "path", "pointer", "expected", "timeoutMs"]);
|
|
342
|
+
validateGotoPath(errors, `${location}.path`, value.path);
|
|
343
|
+
validateJsonPointerDeclaration(errors, `${location}.pointer`, value.pointer);
|
|
344
|
+
if (!isJsonScalar(value.expected)) {
|
|
345
|
+
errors.add(`${location}.expected`, `expected a JSON scalar (string, finite number, boolean or null), received ${describeValue(value.expected)}.`);
|
|
346
|
+
}
|
|
347
|
+
validateOptionalTimeout(errors, `${location}.timeoutMs`, value.timeoutMs);
|
|
348
|
+
break;
|
|
349
|
+
case "json-file-equals":
|
|
350
|
+
rejectUnknownKeys(errors, location, value, ["type", "path", "pointer", "expected"]);
|
|
351
|
+
validateTargetRelativePath(errors, `${location}.path`, value.path);
|
|
352
|
+
validateJsonPointerDeclaration(errors, `${location}.pointer`, value.pointer);
|
|
353
|
+
if (!isJsonScalar(value.expected)) {
|
|
354
|
+
errors.add(`${location}.expected`, `expected a JSON scalar (string, finite number, boolean or null), received ${describeValue(value.expected)}.`);
|
|
355
|
+
}
|
|
356
|
+
break;
|
|
357
|
+
case "file-exists":
|
|
358
|
+
rejectUnknownKeys(errors, location, value, ["type", "path"]);
|
|
359
|
+
validateTargetRelativePath(errors, `${location}.path`, value.path);
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
function validateTargetRelativePath(errors, location, value) {
|
|
364
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
365
|
+
errors.add(location, `expected a non-empty path relative to the tutorial target root, received ${describeValue(value)}.`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
function validateJsonPointerDeclaration(errors, location, value) {
|
|
369
|
+
if (typeof value !== "string") {
|
|
370
|
+
errors.add(location, `expected a JSON Pointer string, received ${describeValue(value)}.`);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (value.length > 0 && !value.startsWith("/")) {
|
|
374
|
+
errors.add(location, `expected a JSON Pointer that is empty or begins with "/", received ${JSON.stringify(value)}.`);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
// Reject a malformed escape early so a bad pointer fails validation rather
|
|
378
|
+
// than silently never matching at assertion time.
|
|
379
|
+
const badEscape = /~(?![01])/.exec(value);
|
|
380
|
+
if (badEscape) {
|
|
381
|
+
errors.add(location, `malformed JSON Pointer escape at index ${badEscape.index}; "~" must be followed by "0" or "1" in ${JSON.stringify(value)}.`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subtitle generation.
|
|
3
|
+
*
|
|
4
|
+
* Cues are built from the structured step results plus the scenario's own
|
|
5
|
+
* narration. This module never reads tutorial.md or tutorial-manifest.json to
|
|
6
|
+
* reconstruct text or timing -- there is exactly one narration source
|
|
7
|
+
* (`TutorialStepV1.narration`) and exactly one timing source (the recorded
|
|
8
|
+
* step timeline), and both arrive as data.
|
|
9
|
+
*/
|
|
10
|
+
/** A cue shorter than this is unreadable, so short steps are padded for display. */
|
|
11
|
+
export const MIN_SUBTITLE_DURATION_MS = 500;
|
|
12
|
+
/**
|
|
13
|
+
* One cue per executed step that has narration and a recorded timeline.
|
|
14
|
+
*
|
|
15
|
+
* Not-run steps produce no cue: they never appeared in the recording. A cue is
|
|
16
|
+
* extended to MIN_SUBTITLE_DURATION_MS when the step was faster than that, but
|
|
17
|
+
* never past the next cue's start, so cues stay ordered and non-overlapping.
|
|
18
|
+
* The padding affects subtitle output only; step timing evidence is untouched.
|
|
19
|
+
*/
|
|
20
|
+
export function buildSubtitleCues(options) {
|
|
21
|
+
const narrationByStepId = new Map(options.scenario.steps.map((step) => [step.id, step.narration]));
|
|
22
|
+
const raw = [];
|
|
23
|
+
for (const step of options.steps) {
|
|
24
|
+
if (step.status === "not-run") {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (step.timelineStartMs === undefined || step.timelineEndMs === undefined) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const text = (narrationByStepId.get(step.id) ?? "").trim();
|
|
31
|
+
if (text.length === 0) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
raw.push({
|
|
35
|
+
stepId: step.id,
|
|
36
|
+
startMs: Math.max(0, step.timelineStartMs),
|
|
37
|
+
endMs: Math.max(step.timelineStartMs, step.timelineEndMs),
|
|
38
|
+
text
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return raw.map((cue, position) => {
|
|
42
|
+
let endMs = cue.endMs;
|
|
43
|
+
if (endMs - cue.startMs < MIN_SUBTITLE_DURATION_MS) {
|
|
44
|
+
endMs = cue.startMs + MIN_SUBTITLE_DURATION_MS;
|
|
45
|
+
}
|
|
46
|
+
const next = raw[position + 1];
|
|
47
|
+
if (next && endMs > next.startMs) {
|
|
48
|
+
// Cap at the next cue rather than overlap; never invert the cue.
|
|
49
|
+
endMs = Math.max(cue.startMs, next.startMs);
|
|
50
|
+
}
|
|
51
|
+
return { index: position + 1, stepId: cue.stepId, startMs: cue.startMs, endMs, text: cue.text };
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/** `HH:MM:SS,mmm` (SRT) or `HH:MM:SS.mmm` (WebVTT). */
|
|
55
|
+
export function formatSubtitleTimestamp(totalMs, millisecondSeparator) {
|
|
56
|
+
const safeMs = Math.max(0, Math.round(totalMs));
|
|
57
|
+
const hours = Math.floor(safeMs / 3_600_000);
|
|
58
|
+
const minutes = Math.floor((safeMs % 3_600_000) / 60_000);
|
|
59
|
+
const seconds = Math.floor((safeMs % 60_000) / 1000);
|
|
60
|
+
const milliseconds = safeMs % 1000;
|
|
61
|
+
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}${millisecondSeparator}${pad(milliseconds, 3)}`;
|
|
62
|
+
}
|
|
63
|
+
function pad(value, width) {
|
|
64
|
+
return String(value).padStart(width, "0");
|
|
65
|
+
}
|
|
66
|
+
/** Deterministic LF line endings regardless of host platform. */
|
|
67
|
+
export function renderSrt(cues) {
|
|
68
|
+
if (cues.length === 0) {
|
|
69
|
+
return "";
|
|
70
|
+
}
|
|
71
|
+
const blocks = cues.map((cue) => [
|
|
72
|
+
String(cue.index),
|
|
73
|
+
`${formatSubtitleTimestamp(cue.startMs, ",")} --> ${formatSubtitleTimestamp(cue.endMs, ",")}`,
|
|
74
|
+
cue.text
|
|
75
|
+
].join("\n"));
|
|
76
|
+
return `${blocks.join("\n\n")}\n`;
|
|
77
|
+
}
|
|
78
|
+
export function renderVtt(cues) {
|
|
79
|
+
const header = "WEBVTT\n";
|
|
80
|
+
if (cues.length === 0) {
|
|
81
|
+
return header;
|
|
82
|
+
}
|
|
83
|
+
const blocks = cues.map((cue) => [
|
|
84
|
+
`${formatSubtitleTimestamp(cue.startMs, ".")} --> ${formatSubtitleTimestamp(cue.endMs, ".")}`,
|
|
85
|
+
cue.text
|
|
86
|
+
].join("\n"));
|
|
87
|
+
return `${header}\n${blocks.join("\n\n")}\n`;
|
|
88
|
+
}
|