@dailephd/my-dev-kit-lab 0.4.6 → 0.4.8

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +25 -2
  2. package/README.md +8 -1
  3. package/dist/src/browser/index.js +2 -0
  4. package/dist/src/browser/playwrightRuntime.js +74 -0
  5. package/dist/src/browser/types.js +10 -0
  6. package/dist/src/cli/help.js +76 -0
  7. package/dist/src/cli/runLabCli.js +29 -1
  8. package/dist/src/commands/runTutorialRunCommand.js +170 -0
  9. package/dist/src/commands/runTutorialValidateCommand.js +138 -0
  10. package/dist/src/core/processTree.js +52 -0
  11. package/dist/src/core/resolveCommand.js +34 -0
  12. package/dist/src/core/runMeasuredCommand.js +39 -31
  13. package/dist/src/runtime/index.js +1 -0
  14. package/dist/src/runtime/managedProcess.js +380 -0
  15. package/dist/src/screenshot/captureReportScreenshot.js +30 -27
  16. package/dist/src/tutorial/index.js +17 -0
  17. package/dist/src/tutorial/loadTutorialContracts.js +60 -0
  18. package/dist/src/tutorial/markdownTutorialWriter.js +53 -0
  19. package/dist/src/tutorial/runTutorial.js +465 -0
  20. package/dist/src/tutorial/scenarioValidation.js +427 -0
  21. package/dist/src/tutorial/subtitleWriter.js +88 -0
  22. package/dist/src/tutorial/targetContractValidation.js +212 -0
  23. package/dist/src/tutorial/tutorialActions.js +175 -0
  24. package/dist/src/tutorial/tutorialArtifacts.js +176 -0
  25. package/dist/src/tutorial/tutorialAssertions.js +249 -0
  26. package/dist/src/tutorial/tutorialCursor.js +149 -0
  27. package/dist/src/tutorial/tutorialLocators.js +37 -0
  28. package/dist/src/tutorial/tutorialManifest.js +74 -0
  29. package/dist/src/tutorial/tutorialOverlay.js +180 -0
  30. package/dist/src/tutorial/tutorialPaths.js +86 -0
  31. package/dist/src/tutorial/tutorialPointerGeometry.js +16 -0
  32. package/dist/src/tutorial/tutorialSession.js +424 -0
  33. package/dist/src/tutorial/types.js +51 -0
  34. package/dist/src/tutorial/validationPrimitives.js +141 -0
  35. package/examples/tutorial-browser/index.html +109 -0
  36. package/examples/tutorial-browser/prepare.mjs +14 -0
  37. package/examples/tutorial-browser/scenario.json +19 -0
  38. package/examples/tutorial-browser/server.mjs +14 -0
  39. package/package.json +8 -4
@@ -0,0 +1,427 @@
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_POINTER_COORDINATE_SPACES, 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 "pointer-click":
270
+ rejectUnknownKeys(errors, location, value, ["type", "locator", "position", "coordinateSpace", "timeoutMs"]);
271
+ validateLocator(errors, `${location}.locator`, value.locator);
272
+ validateFractionPoint(errors, `${location}.position`, value.position);
273
+ validatePointerCoordinateSpace(errors, `${location}.coordinateSpace`, value.coordinateSpace);
274
+ validateOptionalTimeout(errors, `${location}.timeoutMs`, value.timeoutMs);
275
+ break;
276
+ case "pointer-drag": {
277
+ rejectUnknownKeys(errors, location, value, ["type", "locator", "from", "to", "coordinateSpace", "timeoutMs"]);
278
+ validateLocator(errors, `${location}.locator`, value.locator);
279
+ const fromValid = validateFractionPoint(errors, `${location}.from`, value.from);
280
+ const toValid = validateFractionPoint(errors, `${location}.to`, value.to);
281
+ validatePointerCoordinateSpace(errors, `${location}.coordinateSpace`, value.coordinateSpace);
282
+ validateOptionalTimeout(errors, `${location}.timeoutMs`, value.timeoutMs);
283
+ if (fromValid && toValid) {
284
+ const from = value.from;
285
+ const to = value.to;
286
+ if (from.x === to.x && from.y === to.y) {
287
+ errors.add(`${location}.to`, "pointer-drag endpoints must be distinct; zero-length drags are not allowed.");
288
+ }
289
+ }
290
+ break;
291
+ }
292
+ case "wait-for":
293
+ rejectUnknownKeys(errors, location, value, ["type", "locator", "state", "timeoutMs"]);
294
+ validateLocator(errors, `${location}.locator`, value.locator);
295
+ if (value.state !== undefined && !TUTORIAL_WAIT_FOR_STATES.includes(value.state)) {
296
+ errors.add(`${location}.state`, `expected one of ${TUTORIAL_WAIT_FOR_STATES.join(", ")}, received ${describeValue(value.state)}.`);
297
+ }
298
+ validateOptionalTimeout(errors, `${location}.timeoutMs`, value.timeoutMs);
299
+ break;
300
+ }
301
+ }
302
+ function validateFractionPoint(errors, location, value) {
303
+ if (!isPlainObject(value)) {
304
+ errors.add(location, `expected a fraction point object, received ${describeValue(value)}.`);
305
+ return false;
306
+ }
307
+ const startLength = errors.length;
308
+ rejectUnknownKeys(errors, location, value, ["x", "y"]);
309
+ validateFractionCoordinate(errors, `${location}.x`, value.x);
310
+ validateFractionCoordinate(errors, `${location}.y`, value.y);
311
+ return errors.length === startLength;
312
+ }
313
+ function validateFractionCoordinate(errors, location, value) {
314
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
315
+ errors.add(location, `expected a finite number within inclusive bounds [0, 1], received ${describeValue(value)}.`);
316
+ }
317
+ }
318
+ function validatePointerCoordinateSpace(errors, location, value) {
319
+ if (!TUTORIAL_POINTER_COORDINATE_SPACES.includes(value)) {
320
+ errors.add(location, `expected "fraction", received ${describeValue(value)}.`);
321
+ }
322
+ }
323
+ /**
324
+ * A scenario may only navigate inside the target application's own origin, so a
325
+ * goto path is a root-relative path, never a URL. Rejecting the URL forms here
326
+ * (rather than only at resolution time) means an unsafe scenario fails
327
+ * `tutorial validate` without any process ever being started.
328
+ */
329
+ export function validateGotoPath(errors, location, value) {
330
+ if (typeof value !== "string" || value.length === 0) {
331
+ errors.add(location, `expected a root-relative path beginning with "/", received ${describeValue(value)}.`);
332
+ return;
333
+ }
334
+ if (value.startsWith("//")) {
335
+ errors.add(location, `protocol-relative paths are not allowed; received ${JSON.stringify(value)}. Use a root-relative path such as "/dashboard".`);
336
+ return;
337
+ }
338
+ if (!value.startsWith("/")) {
339
+ errors.add(location, `expected a root-relative path beginning with "/", received ${JSON.stringify(value)}. Absolute URLs and scheme-qualified values are not allowed.`);
340
+ }
341
+ }
342
+ export function validateAssertion(errors, location, value) {
343
+ if (!isPlainObject(value)) {
344
+ errors.add(location, `expected an assertion object, received ${describeValue(value)}.`);
345
+ return;
346
+ }
347
+ const type = value.type;
348
+ if (typeof type !== "string" || !TUTORIAL_ASSERTION_TYPES.includes(type)) {
349
+ errors.add(`${location}.type`, `unsupported assertion ${describeValue(type)}; expected one of ${TUTORIAL_ASSERTION_TYPES.join(", ")}.`);
350
+ return;
351
+ }
352
+ switch (type) {
353
+ case "element-visible":
354
+ rejectUnknownKeys(errors, location, value, ["type", "locator", "timeoutMs"]);
355
+ validateLocator(errors, `${location}.locator`, value.locator);
356
+ validateOptionalTimeout(errors, `${location}.timeoutMs`, value.timeoutMs);
357
+ break;
358
+ case "text-equals":
359
+ case "text-contains":
360
+ rejectUnknownKeys(errors, location, value, ["type", "locator", "expected", "timeoutMs"]);
361
+ validateLocator(errors, `${location}.locator`, value.locator);
362
+ if (typeof value.expected !== "string") {
363
+ errors.add(`${location}.expected`, `expected a string, received ${describeValue(value.expected)}.`);
364
+ }
365
+ validateOptionalTimeout(errors, `${location}.timeoutMs`, value.timeoutMs);
366
+ break;
367
+ case "url-path-equals":
368
+ rejectUnknownKeys(errors, location, value, ["type", "expected"]);
369
+ if (typeof value.expected !== "string" || !value.expected.startsWith("/")) {
370
+ errors.add(`${location}.expected`, `expected a root-relative path beginning with "/", received ${describeValue(value.expected)}.`);
371
+ }
372
+ break;
373
+ case "attribute-equals":
374
+ rejectUnknownKeys(errors, location, value, ["type", "locator", "name", "expected", "timeoutMs"]);
375
+ validateLocator(errors, `${location}.locator`, value.locator);
376
+ if (!isNonEmptyString(value.name)) {
377
+ errors.add(`${location}.name`, `expected a non-empty attribute name, received ${describeValue(value.name)}.`);
378
+ }
379
+ if (typeof value.expected !== "string") {
380
+ errors.add(`${location}.expected`, `expected a string, received ${describeValue(value.expected)}.`);
381
+ }
382
+ validateOptionalTimeout(errors, `${location}.timeoutMs`, value.timeoutMs);
383
+ break;
384
+ case "http-json-equals":
385
+ rejectUnknownKeys(errors, location, value, ["type", "path", "pointer", "expected", "timeoutMs"]);
386
+ validateGotoPath(errors, `${location}.path`, value.path);
387
+ validateJsonPointerDeclaration(errors, `${location}.pointer`, value.pointer);
388
+ if (!isJsonScalar(value.expected)) {
389
+ errors.add(`${location}.expected`, `expected a JSON scalar (string, finite number, boolean or null), received ${describeValue(value.expected)}.`);
390
+ }
391
+ validateOptionalTimeout(errors, `${location}.timeoutMs`, value.timeoutMs);
392
+ break;
393
+ case "json-file-equals":
394
+ rejectUnknownKeys(errors, location, value, ["type", "path", "pointer", "expected"]);
395
+ validateTargetRelativePath(errors, `${location}.path`, value.path);
396
+ validateJsonPointerDeclaration(errors, `${location}.pointer`, value.pointer);
397
+ if (!isJsonScalar(value.expected)) {
398
+ errors.add(`${location}.expected`, `expected a JSON scalar (string, finite number, boolean or null), received ${describeValue(value.expected)}.`);
399
+ }
400
+ break;
401
+ case "file-exists":
402
+ rejectUnknownKeys(errors, location, value, ["type", "path"]);
403
+ validateTargetRelativePath(errors, `${location}.path`, value.path);
404
+ break;
405
+ }
406
+ }
407
+ function validateTargetRelativePath(errors, location, value) {
408
+ if (typeof value !== "string" || value.trim().length === 0) {
409
+ errors.add(location, `expected a non-empty path relative to the tutorial target root, received ${describeValue(value)}.`);
410
+ }
411
+ }
412
+ function validateJsonPointerDeclaration(errors, location, value) {
413
+ if (typeof value !== "string") {
414
+ errors.add(location, `expected a JSON Pointer string, received ${describeValue(value)}.`);
415
+ return;
416
+ }
417
+ if (value.length > 0 && !value.startsWith("/")) {
418
+ errors.add(location, `expected a JSON Pointer that is empty or begins with "/", received ${JSON.stringify(value)}.`);
419
+ return;
420
+ }
421
+ // Reject a malformed escape early so a bad pointer fails validation rather
422
+ // than silently never matching at assertion time.
423
+ const badEscape = /~(?![01])/.exec(value);
424
+ if (badEscape) {
425
+ errors.add(location, `malformed JSON Pointer escape at index ${badEscape.index}; "~" must be followed by "0" or "1" in ${JSON.stringify(value)}.`);
426
+ }
427
+ }
@@ -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
+ }