@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +11 -2
  2. package/README.md +7 -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 +16 -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 +383 -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 +110 -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/tutorialSession.js +398 -0
  32. package/dist/src/tutorial/types.js +48 -0
  33. package/dist/src/tutorial/validationPrimitives.js +141 -0
  34. package/examples/tutorial-browser/index.html +47 -0
  35. package/examples/tutorial-browser/prepare.mjs +14 -0
  36. package/examples/tutorial-browser/scenario.json +17 -0
  37. package/examples/tutorial-browser/server.mjs +14 -0
  38. package/package.json +8 -4
@@ -0,0 +1,465 @@
1
+ import { stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { launchChromium } from "../browser/index.js";
4
+ import { runMeasuredCommand } from "../core/runMeasuredCommand.js";
5
+ import { startManagedProcess } from "../runtime/managedProcess.js";
6
+ import { loadTutorialScenario, loadTutorialTargetContract } from "./loadTutorialContracts.js";
7
+ import { describeTargetIdMismatch, tutorialTargetIdsMatch } from "./targetContractValidation.js";
8
+ import { buildTutorialRunPaths, createTutorialRunDirectories, generateTutorialRunId } from "./tutorialPaths.js";
9
+ import { closeTutorialSession, executeTutorialSteps, openTutorialSession } from "./tutorialSession.js";
10
+ import { buildTutorialArtifactPaths, finalizeTutorialVideo, writeTextArtifact } from "./tutorialArtifacts.js";
11
+ import { buildSubtitleCues, renderSrt, renderVtt } from "./subtitleWriter.js";
12
+ import { renderTutorialMarkdown } from "./markdownTutorialWriter.js";
13
+ import { buildTutorialManifest, renderTutorialManifest } from "./tutorialManifest.js";
14
+ import { TARGET_ROOT_PLACEHOLDER, TUTORIAL_RUN_RESULT_SCHEMA_VERSION } from "./types.js";
15
+ export async function runTutorial(options) {
16
+ const startedAtMs = Date.now();
17
+ const startedAt = new Date(startedAtMs).toISOString();
18
+ const warnings = [];
19
+ const cleanupErrors = [];
20
+ const finish = (status, extra = {}) => {
21
+ const endedAtMs = Date.now();
22
+ return {
23
+ schemaVersion: TUTORIAL_RUN_RESULT_SCHEMA_VERSION,
24
+ status,
25
+ startedAt,
26
+ endedAt: new Date(endedAtMs).toISOString(),
27
+ durationMs: endedAtMs - startedAtMs,
28
+ steps: [],
29
+ artifacts: { screenshots: [] },
30
+ warnings,
31
+ cleanupErrors,
32
+ ...extra
33
+ };
34
+ };
35
+ // ---- 1. Contracts -------------------------------------------------------
36
+ const loadedScenario = await loadTutorialScenario(options.context.invocationCwd, options.scenarioPath);
37
+ if (!loadedScenario.ok) {
38
+ return finish("scenario-invalid", { error: loadedScenario.errors.join("\n") });
39
+ }
40
+ const { scenario } = loadedScenario.value;
41
+ const loadedTarget = await loadTutorialTargetContract(options.context.invocationCwd, options.targetContractPath);
42
+ if (!loadedTarget.ok) {
43
+ return finish("target-invalid", {
44
+ scenarioId: scenario.id,
45
+ error: loadedTarget.errors.join("\n")
46
+ });
47
+ }
48
+ const { targetContract, contractRoot } = loadedTarget.value;
49
+ // ---- 2. Identity --------------------------------------------------------
50
+ // Checked before any directory is created, any command runs, or any process
51
+ // starts, so a mismatched pair costs nothing but a comparison.
52
+ if (!tutorialTargetIdsMatch(scenario.targetId, targetContract.id)) {
53
+ return finish("target-mismatch", {
54
+ scenarioId: scenario.id,
55
+ targetId: targetContract.id,
56
+ error: describeTargetIdMismatch(scenario.targetId, targetContract.id)
57
+ });
58
+ }
59
+ // ---- 3. Run directory ---------------------------------------------------
60
+ const runId = (options.generateRunId ?? generateTutorialRunId)();
61
+ const paths = buildTutorialRunPaths({
62
+ workspaceRoot: options.context.workspaceRoot,
63
+ invocationCwd: options.context.invocationCwd,
64
+ scenarioId: scenario.id,
65
+ runId,
66
+ outDir: options.outDir
67
+ });
68
+ const identity = { scenarioId: scenario.id, targetId: targetContract.id, runId, paths };
69
+ try {
70
+ await createTutorialRunDirectories(paths);
71
+ }
72
+ catch (error) {
73
+ // No run layout, so no artifacts and no manifest are possible or expected.
74
+ return finish("prepare-failed", {
75
+ ...identity,
76
+ error: `Tutorial run directory could not be created at ${paths.runRoot}: ${messageOf(error)}`
77
+ });
78
+ }
79
+ /**
80
+ * Ends a run that failed before step execution produced anything recordable.
81
+ *
82
+ * The run layout exists, so a manifest is still written: it reports the real
83
+ * failure and marks every tutorial artifact as skipped rather than pretending
84
+ * one was produced. The failure status is never replaced by an artifact
85
+ * status -- an early failure is not a video problem.
86
+ */
87
+ const finishBeforeArtifacts = async (status, error, reason) => {
88
+ const skipped = (kind) => ({
89
+ kind,
90
+ status: "skipped",
91
+ error: reason
92
+ });
93
+ const artifacts = {
94
+ video: skipped("video"),
95
+ srt: skipped("srt"),
96
+ vtt: skipped("vtt"),
97
+ markdown: skipped("markdown"),
98
+ screenshots: []
99
+ };
100
+ const endedAtMs = Date.now();
101
+ const manifest = buildTutorialManifest({
102
+ scenario,
103
+ targetContract,
104
+ run: {
105
+ id: runId,
106
+ status,
107
+ startedAt,
108
+ endedAt: new Date(endedAtMs).toISOString(),
109
+ durationMs: endedAtMs - startedAtMs
110
+ },
111
+ steps: [],
112
+ artifacts: [artifacts.video, artifacts.srt, artifacts.vtt, artifacts.markdown],
113
+ warnings,
114
+ cleanupErrors
115
+ });
116
+ artifacts.manifest = await writeTextArtifact({
117
+ kind: "manifest",
118
+ runRoot: paths.runRoot,
119
+ filePath: buildTutorialArtifactPaths(paths).manifest,
120
+ contents: renderTutorialManifest(manifest)
121
+ });
122
+ if (artifacts.manifest.status === "failed") {
123
+ warnings.push(artifacts.manifest.error ?? "Tutorial manifest could not be written.");
124
+ }
125
+ return finish(status, { ...identity, artifacts, error });
126
+ };
127
+ // ---- 4. Prepare ---------------------------------------------------------
128
+ const prepareResult = await runPrepare(targetContract, contractRoot, paths);
129
+ if (prepareResult) {
130
+ return finishBeforeArtifacts("prepare-failed", prepareResult, "Tutorial preparation failed before any recording started.");
131
+ }
132
+ // ---- 5. Processes + readiness ------------------------------------------
133
+ const started = [];
134
+ const startup = await startTargetProcesses(targetContract, contractRoot, paths, started);
135
+ if (startup) {
136
+ cleanupErrors.push(...(await stopProcessesInReverse(started)));
137
+ return finishBeforeArtifacts(startup.status, startup.error, "Target processes did not become ready, so no recording started.");
138
+ }
139
+ // ---- 6. Browser ---------------------------------------------------------
140
+ const launch = await (options.launchBrowser ?? launchChromium)({ headless: true });
141
+ if (launch.status !== "launched") {
142
+ cleanupErrors.push(...(await stopProcessesInReverse(started)));
143
+ const status = launch.status === "unavailable" ? "browser-unavailable" : "browser-failed";
144
+ const error = launch.status === "unavailable"
145
+ ? `Tutorial browser runtime is unavailable (${launch.reason}): ${launch.error}`
146
+ : `Tutorial browser failed to launch: ${launch.error}`;
147
+ return finishBeforeArtifacts(status, error, "Tutorial recording never started because the browser was not available.");
148
+ }
149
+ const browser = launch.browser;
150
+ // ---- 7. Session + steps -------------------------------------------------
151
+ const artifactPaths = buildTutorialArtifactPaths(paths);
152
+ let steps = [];
153
+ let screenshots = [];
154
+ let primaryStatus = "passed";
155
+ let primaryError;
156
+ let videoRecordingStarted = false;
157
+ let videoRecord;
158
+ const opened = await openTutorialSession(browser, {
159
+ viewport: scenario.browser.viewport,
160
+ recordVideoDir: artifactPaths.videoTempDir
161
+ });
162
+ if (!opened.ok) {
163
+ primaryStatus = "browser-failed";
164
+ primaryError = opened.error;
165
+ }
166
+ else {
167
+ videoRecordingStarted = true;
168
+ const execution = await executeTutorialSteps({
169
+ page: opened.session.page,
170
+ scenario,
171
+ applicationUrl: targetContract.applicationUrl,
172
+ targetRoot: paths.targetRoot,
173
+ visuals: true,
174
+ paths,
175
+ ...(options.sleep ? { sleep: options.sleep } : {})
176
+ });
177
+ steps = execution.steps;
178
+ screenshots = execution.screenshots;
179
+ warnings.push(...execution.visualWarnings);
180
+ if (execution.failedStepId !== undefined) {
181
+ primaryStatus = "step-failed";
182
+ primaryError = execution.error;
183
+ }
184
+ // Playwright finalizes a recording only on page close, so the session is
185
+ // closed before the video is saved. A failed tutorial still gets its video.
186
+ cleanupErrors.push(...(await closeTutorialSession(opened.session)));
187
+ const finalized = await finalizeTutorialVideo({
188
+ video: opened.session.video,
189
+ runRoot: paths.runRoot,
190
+ videoPath: artifactPaths.video,
191
+ videoTempDir: artifactPaths.videoTempDir
192
+ });
193
+ videoRecord = finalized.record;
194
+ warnings.push(...finalized.warnings);
195
+ }
196
+ // ---- 8. Browser cleanup -------------------------------------------------
197
+ // Browser resources are released before the processes they were talking to.
198
+ const browserCloseError = await closeQuietly(() => browser.close());
199
+ if (browserCloseError) {
200
+ cleanupErrors.push(`Tutorial browser close failed: ${browserCloseError}`);
201
+ }
202
+ // ---- 9. Artifacts -------------------------------------------------------
203
+ if (!videoRecord) {
204
+ videoRecord = {
205
+ kind: "video",
206
+ status: "skipped",
207
+ error: "Tutorial recording never started, so no canonical WebM was expected."
208
+ };
209
+ }
210
+ const cues = buildSubtitleCues({ scenario, steps });
211
+ const srtRecord = await writeTextArtifact({
212
+ kind: "srt",
213
+ runRoot: paths.runRoot,
214
+ filePath: artifactPaths.srt,
215
+ contents: renderSrt(cues)
216
+ });
217
+ const vttRecord = await writeTextArtifact({
218
+ kind: "vtt",
219
+ runRoot: paths.runRoot,
220
+ filePath: artifactPaths.vtt,
221
+ contents: renderVtt(cues)
222
+ });
223
+ const markdownRecord = await writeTextArtifact({
224
+ kind: "markdown",
225
+ runRoot: paths.runRoot,
226
+ filePath: artifactPaths.markdown,
227
+ contents: renderTutorialMarkdown({ scenario, steps, screenshots })
228
+ });
229
+ // ---- 10. Process cleanup ------------------------------------------------
230
+ cleanupErrors.push(...(await stopProcessesInReverse(started)));
231
+ // ---- 11. Final status ---------------------------------------------------
232
+ // Precedence for an otherwise-passing run, most specific first: a missing
233
+ // required video, then any other failed required artifact, then cleanup.
234
+ // A real execution failure is never replaced by an artifact problem.
235
+ const artifactFailures = [videoRecord, srtRecord, vttRecord, markdownRecord, ...screenshots].filter((record) => record.status === "failed");
236
+ const videoFailed = videoRecord.status === "failed" || (videoRecordingStarted && videoRecord.status === "skipped");
237
+ const nonVideoArtifactFailed = artifactFailures.some((record) => record.kind !== "video");
238
+ let finalStatus = primaryStatus;
239
+ let finalError = primaryError;
240
+ if (primaryStatus === "passed") {
241
+ if (videoFailed) {
242
+ finalStatus = "video-finalization-failed";
243
+ finalError = videoRecord.error ?? "Canonical tutorial video could not be finalized.";
244
+ }
245
+ else if (nonVideoArtifactFailed) {
246
+ finalStatus = "artifact-failed";
247
+ finalError = artifactFailures
248
+ .filter((record) => record.kind !== "video")
249
+ .map((record) => record.error ?? `${record.kind} artifact failed`)
250
+ .join("; ");
251
+ }
252
+ }
253
+ else {
254
+ // Artifact problems are recorded but must not mask the real failure.
255
+ for (const record of artifactFailures) {
256
+ warnings.push(record.error ?? `${record.kind} artifact failed`);
257
+ }
258
+ }
259
+ // ---- 12. Manifest -------------------------------------------------------
260
+ const allArtifacts = [
261
+ videoRecord,
262
+ srtRecord,
263
+ vttRecord,
264
+ markdownRecord,
265
+ ...screenshots,
266
+ ...collectProcessLogRecords(targetContract, paths)
267
+ ];
268
+ const manifestEndedAtMs = Date.now();
269
+ const manifest = buildTutorialManifest({
270
+ scenario,
271
+ targetContract,
272
+ run: {
273
+ id: runId,
274
+ status: finalStatus,
275
+ startedAt,
276
+ endedAt: new Date(manifestEndedAtMs).toISOString(),
277
+ durationMs: manifestEndedAtMs - startedAtMs
278
+ },
279
+ steps,
280
+ artifacts: allArtifacts,
281
+ warnings,
282
+ cleanupErrors
283
+ });
284
+ const manifestRecord = await writeTextArtifact({
285
+ kind: "manifest",
286
+ runRoot: paths.runRoot,
287
+ filePath: artifactPaths.manifest,
288
+ contents: renderTutorialManifest(manifest)
289
+ });
290
+ if (manifestRecord.status === "failed") {
291
+ if (finalStatus === "passed") {
292
+ finalStatus = "artifact-failed";
293
+ finalError = manifestRecord.error;
294
+ }
295
+ else {
296
+ warnings.push(manifestRecord.error ?? "Tutorial manifest could not be written.");
297
+ }
298
+ }
299
+ const artifacts = {
300
+ video: videoRecord,
301
+ srt: srtRecord,
302
+ vtt: vttRecord,
303
+ markdown: markdownRecord,
304
+ manifest: manifestRecord,
305
+ screenshots
306
+ };
307
+ // A cleanup failure only downgrades an otherwise-clean run, because "passed"
308
+ // would misreport a leaked resource.
309
+ if (finalStatus === "passed" && cleanupErrors.length > 0) {
310
+ return finish("cleanup-failed", {
311
+ ...identity,
312
+ steps,
313
+ artifacts,
314
+ error: `Tutorial run completed but cleanup failed: ${cleanupErrors.join("; ")}`
315
+ });
316
+ }
317
+ return finish(finalStatus, {
318
+ ...identity,
319
+ steps,
320
+ artifacts,
321
+ ...(finalError !== undefined ? { error: finalError } : {})
322
+ });
323
+ }
324
+ /**
325
+ * Managed-process log paths are known from the contract and the run layout, so
326
+ * they are recorded from structured data rather than by scanning the directory.
327
+ */
328
+ function collectProcessLogRecords(targetContract, paths) {
329
+ const records = [];
330
+ for (const declared of targetContract.processes) {
331
+ records.push({
332
+ kind: "stdout-log",
333
+ id: declared.id,
334
+ status: "written",
335
+ path: `logs/processes/${declared.id}.stdout.txt`
336
+ });
337
+ records.push({
338
+ kind: "stderr-log",
339
+ id: declared.id,
340
+ status: "written",
341
+ path: `logs/processes/${declared.id}.stderr.txt`
342
+ });
343
+ }
344
+ return records;
345
+ }
346
+ /** Returns an error message when prepare failed, otherwise undefined. */
347
+ async function runPrepare(targetContract, contractRoot, paths) {
348
+ const prepareLogDir = path.join(paths.logsRoot, "prepare");
349
+ const measured = await runMeasuredCommand({
350
+ commandId: "prepare",
351
+ executable: targetContract.prepare.executable,
352
+ args: expandPlaceholders(targetContract.prepare.args ?? [], paths.targetRoot),
353
+ cwd: contractRoot,
354
+ outDir: prepareLogDir,
355
+ env: expandEnvPlaceholders(targetContract.prepare.env, paths.targetRoot),
356
+ timeoutMs: undefined
357
+ });
358
+ if (!measured.ok || measured.exitCode !== 0) {
359
+ const detail = measured.error ?? `exit code ${String(measured.exitCode)}`;
360
+ return `Tutorial prepare command exited with ${detail}. See ${measured.stdoutPath} and ${measured.stderrPath}.`;
361
+ }
362
+ // The prepare command is the only thing allowed to populate targetRoot, so its
363
+ // success is not enough -- the directory it promised must actually be there.
364
+ try {
365
+ const stats = await stat(paths.targetRoot);
366
+ if (!stats.isDirectory()) {
367
+ return `Tutorial prepare command succeeded but ${paths.targetRoot} is not a directory.`;
368
+ }
369
+ }
370
+ catch {
371
+ return `Tutorial prepare command succeeded but did not create the target directory ${paths.targetRoot}.`;
372
+ }
373
+ return undefined;
374
+ }
375
+ async function startTargetProcesses(targetContract, contractRoot, paths, started) {
376
+ for (const declared of targetContract.processes) {
377
+ let handle;
378
+ try {
379
+ handle = await startManagedProcess({
380
+ id: declared.id,
381
+ executable: declared.executable,
382
+ args: expandPlaceholders(declared.args ?? [], paths.targetRoot),
383
+ cwd: resolveProcessCwd(declared, contractRoot, paths),
384
+ outDir: path.join(paths.logsRoot, "processes"),
385
+ env: expandEnvPlaceholders(declared.env, paths.targetRoot)
386
+ });
387
+ }
388
+ catch (error) {
389
+ return {
390
+ status: "process-start-failed",
391
+ error: `Managed process ${JSON.stringify(declared.id)} failed to start: ${messageOf(error)}`
392
+ };
393
+ }
394
+ started.push(handle);
395
+ const readiness = await handle.waitForReadiness(declared.readiness);
396
+ if (readiness.status !== "ready") {
397
+ return {
398
+ status: "readiness-failed",
399
+ error: describeReadinessFailure(declared, readiness.status, readiness.error)
400
+ };
401
+ }
402
+ }
403
+ return undefined;
404
+ }
405
+ function describeReadinessFailure(declared, status, error) {
406
+ const id = JSON.stringify(declared.id);
407
+ switch (status) {
408
+ case "timeout":
409
+ return `Managed process ${id} did not become ready before the readiness timeout (${declared.readiness.timeoutMs}ms) at ${declared.readiness.url}.`;
410
+ case "process-exited":
411
+ return `Managed process ${id} exited before it became ready at ${declared.readiness.url}.`;
412
+ default:
413
+ return `Managed process ${id} readiness probe failed at ${declared.readiness.url}: ${error ?? "unknown readiness error"}`;
414
+ }
415
+ }
416
+ function resolveProcessCwd(declared, contractRoot, paths) {
417
+ return declared.cwd === "target-root" ? paths.targetRoot : contractRoot;
418
+ }
419
+ /**
420
+ * Substitutes the single supported placeholder. Validation has already rejected
421
+ * every other `{{...}}` form, so this is a literal replacement and never a
422
+ * template evaluation.
423
+ */
424
+ export function expandPlaceholders(values, targetRoot) {
425
+ return values.map((value) => value.split(TARGET_ROOT_PLACEHOLDER).join(targetRoot));
426
+ }
427
+ export function expandEnvPlaceholders(env, targetRoot) {
428
+ if (!env) {
429
+ return undefined;
430
+ }
431
+ const expanded = {};
432
+ for (const [key, value] of Object.entries(env)) {
433
+ expanded[key] = value.split(TARGET_ROOT_PLACEHOLDER).join(targetRoot);
434
+ }
435
+ return expanded;
436
+ }
437
+ /**
438
+ * Stops managed processes in reverse startup order so a dependent process is
439
+ * always shut down before whatever it depends on.
440
+ */
441
+ async function stopProcessesInReverse(started) {
442
+ const errors = [];
443
+ for (const handle of [...started].reverse()) {
444
+ try {
445
+ await handle.stop();
446
+ }
447
+ catch (error) {
448
+ errors.push(`Managed process ${JSON.stringify(handle.id)} failed to stop: ${messageOf(error)}`);
449
+ }
450
+ }
451
+ started.length = 0;
452
+ return errors;
453
+ }
454
+ async function closeQuietly(close) {
455
+ try {
456
+ await close();
457
+ return undefined;
458
+ }
459
+ catch (error) {
460
+ return messageOf(error);
461
+ }
462
+ }
463
+ function messageOf(error) {
464
+ return error instanceof Error ? error.message : String(error);
465
+ }