@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,180 @@
1
+ import { TUTORIAL_VISUAL_ROOT_ID } from "./tutorialCursor.js";
2
+ /**
3
+ * Deterministic highlight and callout overlays.
4
+ *
5
+ * Both are drawn as tutorial-owned fixed-position siblings derived from the
6
+ * target's bounding box. Nothing is inserted into the target element and no
7
+ * class list is touched, so the application's own DOM and state are unchanged
8
+ * and an assertion can never accidentally observe an overlay as application
9
+ * content. Both use `pointer-events: none`.
10
+ */
11
+ export const TUTORIAL_HIGHLIGHT_ID = "__my_dev_kit_lab_tutorial_highlight__";
12
+ export const TUTORIAL_CALLOUT_ID = "__my_dev_kit_lab_tutorial_callout__";
13
+ /** Gap between the target box and the callout, and callout sizing bounds. */
14
+ const CALLOUT_GAP_PX = 12;
15
+ const CALLOUT_MAX_WIDTH_PX = 320;
16
+ const HIGHLIGHT_PADDING_PX = 4;
17
+ /**
18
+ * Deterministic "auto" placement, in this exact branch order:
19
+ *
20
+ * 1. target mainly left of viewport center -> "right"
21
+ * 2. target mainly right of viewport center -> "left"
22
+ * 3. target near horizontal center, in the upper half -> "bottom"
23
+ * 4. otherwise -> "top"
24
+ *
25
+ * "Mainly left/right" means the target's own center sits outside the middle
26
+ * third of the viewport. No search, no scoring, no randomness.
27
+ */
28
+ export function resolveCalloutPlacement(placement, box, viewport) {
29
+ if (placement !== undefined && placement !== "auto") {
30
+ return placement;
31
+ }
32
+ if (!box) {
33
+ // Nothing to be relative to; a viewport callout sits at the bottom.
34
+ return "bottom";
35
+ }
36
+ const targetCenterX = box.x + box.width / 2;
37
+ const targetCenterY = box.y + box.height / 2;
38
+ const leftThird = viewport.width / 3;
39
+ const rightThird = (viewport.width * 2) / 3;
40
+ if (targetCenterX < leftThird) {
41
+ return "right";
42
+ }
43
+ if (targetCenterX > rightThird) {
44
+ return "left";
45
+ }
46
+ if (targetCenterY < viewport.height / 2) {
47
+ return "bottom";
48
+ }
49
+ return "top";
50
+ }
51
+ export function applyHighlightInPage(arg) {
52
+ const doc = globalThis.document;
53
+ const root = doc?.getElementById(arg.rootId);
54
+ if (!doc || !root) {
55
+ return false;
56
+ }
57
+ const stale = doc.getElementById(arg.highlightId);
58
+ if (stale) {
59
+ stale.remove();
60
+ }
61
+ const highlight = doc.createElement("div");
62
+ highlight.id = arg.highlightId;
63
+ highlight.setAttribute("aria-hidden", "true");
64
+ highlight.style.setProperty("position", "fixed");
65
+ highlight.style.setProperty("left", `${arg.box.x - arg.paddingPx}px`);
66
+ highlight.style.setProperty("top", `${arg.box.y - arg.paddingPx}px`);
67
+ highlight.style.setProperty("width", `${arg.box.width + arg.paddingPx * 2}px`);
68
+ highlight.style.setProperty("height", `${arg.box.height + arg.paddingPx * 2}px`);
69
+ // An outline plus a translucent wash keeps the target's own content readable.
70
+ highlight.style.setProperty("border", "3px solid rgba(255, 176, 0, 0.95)");
71
+ highlight.style.setProperty("border-radius", "6px");
72
+ highlight.style.setProperty("background", "rgba(255, 176, 0, 0.12)");
73
+ highlight.style.setProperty("box-shadow", "0 0 0 9999px rgba(0, 0, 0, 0.08)");
74
+ highlight.style.setProperty("pointer-events", "none");
75
+ highlight.style.setProperty("z-index", "2147483645");
76
+ root.appendChild(highlight);
77
+ return true;
78
+ }
79
+ export function applyCalloutInPage(arg) {
80
+ const doc = globalThis.document;
81
+ const root = doc?.getElementById(arg.rootId);
82
+ if (!doc || !root) {
83
+ return false;
84
+ }
85
+ const stale = doc.getElementById(arg.calloutId);
86
+ if (stale) {
87
+ stale.remove();
88
+ }
89
+ const callout = doc.createElement("div");
90
+ callout.id = arg.calloutId;
91
+ callout.setAttribute("aria-hidden", "true");
92
+ callout.textContent = arg.text;
93
+ callout.style.setProperty("position", "fixed");
94
+ callout.style.setProperty("max-width", `${arg.maxWidthPx}px`);
95
+ callout.style.setProperty("padding", "10px 14px");
96
+ callout.style.setProperty("border-radius", "8px");
97
+ callout.style.setProperty("background", "rgba(17, 17, 17, 0.92)");
98
+ callout.style.setProperty("color", "#ffffff");
99
+ callout.style.setProperty("font", "16px/1.4 system-ui, sans-serif");
100
+ callout.style.setProperty("box-shadow", "0 6px 20px rgba(0, 0, 0, 0.35)");
101
+ callout.style.setProperty("pointer-events", "none");
102
+ callout.style.setProperty("z-index", "2147483647");
103
+ if (!arg.box) {
104
+ // No locator: a deterministic viewport position, bottom-center.
105
+ callout.style.setProperty("left", "50%");
106
+ callout.style.setProperty("bottom", `${arg.gapPx * 2}px`);
107
+ callout.style.setProperty("transform", "translateX(-50%)");
108
+ root.appendChild(callout);
109
+ return true;
110
+ }
111
+ const box = arg.box;
112
+ const centerX = box.x + box.width / 2;
113
+ const centerY = box.y + box.height / 2;
114
+ if (arg.placement === "top") {
115
+ callout.style.setProperty("left", `${centerX}px`);
116
+ callout.style.setProperty("top", `${box.y - arg.gapPx}px`);
117
+ callout.style.setProperty("transform", "translate(-50%, -100%)");
118
+ }
119
+ else if (arg.placement === "bottom") {
120
+ callout.style.setProperty("left", `${centerX}px`);
121
+ callout.style.setProperty("top", `${box.y + box.height + arg.gapPx}px`);
122
+ callout.style.setProperty("transform", "translateX(-50%)");
123
+ }
124
+ else if (arg.placement === "left") {
125
+ callout.style.setProperty("left", `${box.x - arg.gapPx}px`);
126
+ callout.style.setProperty("top", `${centerY}px`);
127
+ callout.style.setProperty("transform", "translate(-100%, -50%)");
128
+ }
129
+ else {
130
+ callout.style.setProperty("left", `${box.x + box.width + arg.gapPx}px`);
131
+ callout.style.setProperty("top", `${centerY}px`);
132
+ callout.style.setProperty("transform", "translateY(-50%)");
133
+ }
134
+ root.appendChild(callout);
135
+ return true;
136
+ }
137
+ export function clearStepOverlaysInPage(arg) {
138
+ const doc = globalThis.document;
139
+ if (!doc) {
140
+ return 0;
141
+ }
142
+ let removed = 0;
143
+ for (const id of [arg.highlightId, arg.calloutId]) {
144
+ const node = doc.getElementById(id);
145
+ if (node) {
146
+ node.remove();
147
+ removed += 1;
148
+ }
149
+ }
150
+ return removed;
151
+ }
152
+ // ---------------------------------------------------------------------------
153
+ // Host-side helpers
154
+ // ---------------------------------------------------------------------------
155
+ export async function applyTutorialHighlight(page, box) {
156
+ return page.evaluate(applyHighlightInPage, {
157
+ rootId: TUTORIAL_VISUAL_ROOT_ID,
158
+ highlightId: TUTORIAL_HIGHLIGHT_ID,
159
+ box,
160
+ paddingPx: HIGHLIGHT_PADDING_PX
161
+ });
162
+ }
163
+ export async function applyTutorialCallout(page, options) {
164
+ return page.evaluate(applyCalloutInPage, {
165
+ rootId: TUTORIAL_VISUAL_ROOT_ID,
166
+ calloutId: TUTORIAL_CALLOUT_ID,
167
+ text: options.text,
168
+ placement: options.placement,
169
+ box: options.box,
170
+ viewport: options.viewport,
171
+ gapPx: CALLOUT_GAP_PX,
172
+ maxWidthPx: CALLOUT_MAX_WIDTH_PX
173
+ });
174
+ }
175
+ export async function clearTutorialStepOverlays(page) {
176
+ return page.evaluate(clearStepOverlaysInPage, {
177
+ highlightId: TUTORIAL_HIGHLIGHT_ID,
178
+ calloutId: TUTORIAL_CALLOUT_ID
179
+ });
180
+ }
@@ -0,0 +1,86 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { resolveWithinRoot } from "../core/pathSafety.js";
5
+ /**
6
+ * Output ownership for a tutorial run.
7
+ *
8
+ * A run owns exactly one directory tree. The inspected/source repository that
9
+ * supplied the target contract is never an output location: `targetRoot` is a
10
+ * run-owned disposable working copy that only the trusted prepare command
11
+ * populates.
12
+ */
13
+ export const TUTORIAL_WORKSPACE_DIR_NAME = "tutorials";
14
+ /**
15
+ * Generates a collision-resistant run id that is also a safe single path
16
+ * segment (matches the tutorial id pattern).
17
+ */
18
+ export function generateTutorialRunId(now = new Date()) {
19
+ const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "z").toLowerCase();
20
+ return `${stamp}-${randomUUID().slice(0, 8)}`;
21
+ }
22
+ export function buildTutorialRunPaths(options) {
23
+ const runRoot = resolveRunRoot(options);
24
+ return {
25
+ runRoot,
26
+ targetRoot: path.join(runRoot, "target"),
27
+ artifactsRoot: path.join(runRoot, "artifacts"),
28
+ screenshotsRoot: path.join(runRoot, "screenshots"),
29
+ logsRoot: path.join(runRoot, "logs"),
30
+ temporaryRoot: path.join(runRoot, "temporary")
31
+ };
32
+ }
33
+ function resolveRunRoot(options) {
34
+ if (options.outDir !== undefined && options.outDir.trim().length > 0) {
35
+ // An explicit --out is honored exactly as given. It is deliberately NOT
36
+ // relocated beneath workspaceRoot: the caller chose the location.
37
+ return path.isAbsolute(options.outDir)
38
+ ? path.resolve(options.outDir)
39
+ : path.resolve(options.invocationCwd, options.outDir);
40
+ }
41
+ return path.join(path.resolve(options.workspaceRoot), TUTORIAL_WORKSPACE_DIR_NAME, options.scenarioId, options.runId);
42
+ }
43
+ /**
44
+ * Creates the full run layout up front so every later stage writes into a
45
+ * directory that already exists, and so the layout is stable even for a run that
46
+ * fails early.
47
+ */
48
+ export async function createTutorialRunDirectories(paths) {
49
+ await mkdir(paths.runRoot, { recursive: true });
50
+ for (const directory of [
51
+ paths.artifactsRoot,
52
+ paths.screenshotsRoot,
53
+ paths.logsRoot,
54
+ paths.temporaryRoot
55
+ ]) {
56
+ await mkdir(directory, { recursive: true });
57
+ }
58
+ }
59
+ /**
60
+ * Resolves a scenario-declared file path beneath the run-owned target root.
61
+ *
62
+ * Uses path-semantics containment (`resolveWithinRoot`), not string-prefix
63
+ * matching, so a sibling directory such as `<root>-other` is correctly treated
64
+ * as outside. Absolute paths are rejected outright even when they happen to
65
+ * point inside the root, because a scenario has no business naming absolute
66
+ * locations on the host.
67
+ */
68
+ export function resolveTargetFilePath(targetRoot, suppliedPath) {
69
+ if (typeof suppliedPath !== "string" || suppliedPath.trim().length === 0) {
70
+ throw new Error("File assertion path must be a non-empty path relative to targetRoot.");
71
+ }
72
+ if (path.isAbsolute(suppliedPath) || path.win32.isAbsolute(suppliedPath) || path.posix.isAbsolute(suppliedPath)) {
73
+ throw new Error(`File assertion path must be relative to targetRoot; received absolute path: ${suppliedPath}`);
74
+ }
75
+ // Catches a Windows drive-qualified relative form such as "C:file" that is
76
+ // not absolute by either isAbsolute check but still escapes the root.
77
+ if (/^[A-Za-z]:/.test(suppliedPath)) {
78
+ throw new Error(`File assertion path must not be drive-qualified: ${suppliedPath}`);
79
+ }
80
+ try {
81
+ return resolveWithinRoot(targetRoot, suppliedPath);
82
+ }
83
+ catch {
84
+ throw new Error(`File assertion path escapes targetRoot: ${suppliedPath}`);
85
+ }
86
+ }
@@ -0,0 +1,398 @@
1
+ import { existsSync } from "node:fs";
2
+ import { executeTutorialAction } from "./tutorialActions.js";
3
+ import { executeTutorialAssertion } from "./tutorialAssertions.js";
4
+ import { resolveTutorialLocator, describeTutorialLocator } from "./tutorialLocators.js";
5
+ import { buildScreenshotPath, toRunRelativePosixPath, verifyArtifactFile } from "./tutorialArtifacts.js";
6
+ import { CLICK_FEEDBACK_DURATION_MS, CURSOR_MOVE_DURATION_MS, centerOfBox, installTutorialCursor, moveTutorialCursor, removeTutorialVisuals, showTutorialClickFeedback } from "./tutorialCursor.js";
7
+ import { applyTutorialCallout, applyTutorialHighlight, clearTutorialStepOverlays, resolveCalloutPlacement } from "./tutorialOverlay.js";
8
+ export async function openTutorialSession(browser, options) {
9
+ if (typeof browser.newContext !== "function") {
10
+ return {
11
+ ok: false,
12
+ error: "Tutorial browser runtime does not support browser contexts, which a persistent tutorial session requires."
13
+ };
14
+ }
15
+ const viewport = { width: options.viewport.width, height: options.viewport.height };
16
+ let context;
17
+ try {
18
+ context = await browser.newContext({
19
+ viewport,
20
+ // Recording size matches the scenario viewport: no invented dimensions.
21
+ ...(options.recordVideoDir !== undefined
22
+ ? { recordVideo: { dir: options.recordVideoDir, size: { ...viewport } } }
23
+ : {})
24
+ });
25
+ }
26
+ catch (error) {
27
+ return { ok: false, error: `Tutorial browser context could not be created: ${messageOf(error)}` };
28
+ }
29
+ try {
30
+ const page = await context.newPage();
31
+ let video = null;
32
+ if (typeof page.video === "function") {
33
+ try {
34
+ video = page.video();
35
+ }
36
+ catch (error) {
37
+ // A runtime without video must not prevent the tutorial from running;
38
+ // the missing recording is reported later as an artifact outcome.
39
+ video = null;
40
+ void error;
41
+ }
42
+ }
43
+ return { ok: true, session: { context, page, video } };
44
+ }
45
+ catch (error) {
46
+ await closeQuietly(() => context.close());
47
+ return { ok: false, error: `Tutorial page could not be created: ${messageOf(error)}` };
48
+ }
49
+ }
50
+ /**
51
+ * Closes the page and context. Playwright only finalizes a recording when the
52
+ * page closes, so the caller finalizes video after this returns.
53
+ *
54
+ * Returns cleanup errors rather than throwing so the caller can preserve a
55
+ * primary failure and still record what went wrong during teardown.
56
+ */
57
+ export async function closeTutorialSession(session) {
58
+ const errors = [];
59
+ if (typeof session.page.close === "function") {
60
+ const pageError = await closeQuietly(() => session.page.close());
61
+ if (pageError) {
62
+ errors.push(`Tutorial page close failed: ${pageError}`);
63
+ }
64
+ }
65
+ const contextError = await closeQuietly(() => session.context.close());
66
+ if (contextError) {
67
+ errors.push(`Tutorial browser context close failed: ${contextError}`);
68
+ }
69
+ return errors;
70
+ }
71
+ /**
72
+ * Runs every step in declared order against one page.
73
+ *
74
+ * Failure semantics, in the planner's order: an action failure skips that step's
75
+ * assertions; assertion failures do not short-circuit each other within the same
76
+ * step (all are collected so a reader sees the full picture); either kind of
77
+ * failure stops later steps, which are reported as "not-run" rather than omitted
78
+ * or reported as passed.
79
+ *
80
+ * Per-step ordering with visuals enabled:
81
+ * clear previous overlays -> pauseBefore -> cursor move -> real action ->
82
+ * click feedback -> assertions -> highlight + callout -> pauseAfter ->
83
+ * screenshot
84
+ * so a screenshot always shows the final intended state of its step.
85
+ */
86
+ export async function executeTutorialSteps(options) {
87
+ const sleep = options.sleep ?? defaultSleep;
88
+ const now = options.now ?? Date.now;
89
+ const timelineOrigin = now();
90
+ const steps = [];
91
+ const screenshots = [];
92
+ const visualWarnings = [];
93
+ let failedStepId;
94
+ let failureMessage;
95
+ /**
96
+ * Installing is idempotent, and a navigation replaces the whole document, so
97
+ * the tutorial-owned visual root has to be re-established rather than assumed
98
+ * to survive. Calling this at the start of every step and again after a
99
+ * successful goto keeps the cursor present without tracking navigations.
100
+ */
101
+ const ensureVisuals = async (stepId) => {
102
+ if (!options.visuals) {
103
+ return;
104
+ }
105
+ await runVisual(visualWarnings, stepId, "install cursor", () => installTutorialCursor(options.page));
106
+ };
107
+ await ensureVisuals("<session>");
108
+ for (const step of options.scenario.steps) {
109
+ if (failedStepId !== undefined) {
110
+ steps.push(notRunStepResult(step));
111
+ continue;
112
+ }
113
+ const startedAtMs = Date.now();
114
+ const timelineStartMs = Math.max(0, now() - timelineOrigin);
115
+ const result = {
116
+ id: step.id,
117
+ status: "passed",
118
+ startedAt: new Date(startedAtMs).toISOString(),
119
+ timelineStartMs,
120
+ assertions: [],
121
+ screenshotRequested: step.screenshot !== undefined,
122
+ highlightRequested: step.highlight !== undefined,
123
+ calloutRequested: step.callout !== undefined
124
+ };
125
+ if (options.visuals) {
126
+ // Each step starts from a clean visual slate, on a guaranteed-present root.
127
+ await ensureVisuals(step.id);
128
+ await runVisual(visualWarnings, step.id, "clear overlays", () => clearTutorialStepOverlays(options.page));
129
+ }
130
+ if (step.pauseBeforeMs !== undefined && step.pauseBeforeMs > 0) {
131
+ await sleep(step.pauseBeforeMs);
132
+ }
133
+ let actionPhaseCompleted = true;
134
+ if (step.action) {
135
+ const cursorTarget = options.visuals
136
+ ? await moveCursorForAction(options, step.action, visualWarnings, step.id, sleep)
137
+ : undefined;
138
+ const actionResult = await executeTutorialAction(options.page, step.action, {
139
+ applicationUrl: options.applicationUrl
140
+ });
141
+ result.action = actionResult;
142
+ if (actionResult.status === "failed") {
143
+ actionPhaseCompleted = false;
144
+ result.status = "failed";
145
+ result.error = `Step ${JSON.stringify(step.id)} failed because the ${step.action.type} action failed: ${actionResult.error ?? "unknown error"}`;
146
+ }
147
+ else if (options.visuals) {
148
+ if (step.action.type === "goto") {
149
+ // The new document has none of the tutorial's DOM.
150
+ await ensureVisuals(step.id);
151
+ }
152
+ await applyPostActionVisuals(options, step, cursorTarget, visualWarnings, sleep);
153
+ }
154
+ }
155
+ if (actionPhaseCompleted) {
156
+ const assertionResults = await runStepAssertions(step, options);
157
+ result.assertions = assertionResults;
158
+ const failedAssertions = assertionResults.filter((assertion) => assertion.status === "failed");
159
+ if (failedAssertions.length > 0) {
160
+ result.status = "failed";
161
+ result.error = `Step ${JSON.stringify(step.id)} failed because ${failedAssertions.length} of ${assertionResults.length} assertions failed: ${failedAssertions
162
+ .map((assertion) => `${assertion.type}: ${assertion.error ?? "unknown error"}`)
163
+ .join("; ")}`;
164
+ }
165
+ if (options.visuals) {
166
+ await applyStepOverlays(options, step, visualWarnings);
167
+ }
168
+ if (step.pauseAfterMs !== undefined && step.pauseAfterMs > 0) {
169
+ await sleep(step.pauseAfterMs);
170
+ }
171
+ }
172
+ if (step.screenshot) {
173
+ screenshots.push(await captureStepScreenshot(options, step, actionPhaseCompleted));
174
+ }
175
+ const endedAtMs = Date.now();
176
+ result.endedAt = new Date(endedAtMs).toISOString();
177
+ result.durationMs = endedAtMs - startedAtMs;
178
+ result.timelineEndMs = Math.max(timelineStartMs, now() - timelineOrigin);
179
+ steps.push(result);
180
+ if (result.status === "failed") {
181
+ failedStepId = step.id;
182
+ failureMessage = result.error;
183
+ }
184
+ }
185
+ if (options.visuals) {
186
+ await runVisual(visualWarnings, "<cleanup>", "remove tutorial visuals", () => removeTutorialVisuals(options.page));
187
+ }
188
+ return {
189
+ steps,
190
+ screenshots,
191
+ visualWarnings,
192
+ ...(failedStepId !== undefined ? { failedStepId } : {}),
193
+ ...(failureMessage !== undefined ? { error: failureMessage } : {})
194
+ };
195
+ }
196
+ /**
197
+ * Moves the synthetic cursor onto the action's target before the real action.
198
+ *
199
+ * `goto` and `wait-for` have no pointer target, so the cursor is left alone. A
200
+ * missing bounding box is recorded as a visual warning and the real action still
201
+ * runs -- presentation never gates correctness.
202
+ */
203
+ async function moveCursorForAction(options, action, warnings, stepId, sleep) {
204
+ const locator = cursorLocatorForAction(action);
205
+ if (!locator) {
206
+ return undefined;
207
+ }
208
+ const box = await readBoundingBox(options.page, locator, warnings, stepId);
209
+ if (!box) {
210
+ return undefined;
211
+ }
212
+ await runVisual(warnings, stepId, "move cursor", () => moveTutorialCursor(options.page, centerOfBox(box)));
213
+ await sleep(CURSOR_MOVE_DURATION_MS);
214
+ return box;
215
+ }
216
+ /** The locator whose center the cursor points at before each action type. */
217
+ export function cursorLocatorForAction(action) {
218
+ switch (action.type) {
219
+ case "click":
220
+ case "fill":
221
+ case "press":
222
+ case "hover":
223
+ return action.locator;
224
+ case "drag":
225
+ return action.source;
226
+ // goto has no element target; wait-for deliberately does not move the cursor.
227
+ case "goto":
228
+ case "wait-for":
229
+ return undefined;
230
+ }
231
+ }
232
+ async function applyPostActionVisuals(options, step, cursorTarget, warnings, sleep) {
233
+ const action = step.action;
234
+ if (!action) {
235
+ return;
236
+ }
237
+ if (action.type === "click" && cursorTarget) {
238
+ await runVisual(warnings, step.id, "click feedback", () => showTutorialClickFeedback(options.page, centerOfBox(cursorTarget)));
239
+ // Held only long enough for the ripple to be visible in the recording.
240
+ await sleep(CLICK_FEEDBACK_DURATION_MS);
241
+ return;
242
+ }
243
+ if (action.type === "drag") {
244
+ // The cursor follows to the drop target after the real drag completed.
245
+ const box = await readBoundingBox(options.page, action.target, warnings, step.id);
246
+ if (box) {
247
+ await runVisual(warnings, step.id, "move cursor to drag target", () => moveTutorialCursor(options.page, centerOfBox(box)));
248
+ await sleep(CURSOR_MOVE_DURATION_MS);
249
+ }
250
+ }
251
+ }
252
+ async function applyStepOverlays(options, step, warnings) {
253
+ if (step.highlight) {
254
+ const box = await readBoundingBox(options.page, step.highlight, warnings, step.id);
255
+ if (box) {
256
+ await runVisual(warnings, step.id, "apply highlight", () => applyTutorialHighlight(options.page, box));
257
+ }
258
+ }
259
+ if (step.callout) {
260
+ const box = step.callout.locator
261
+ ? await readBoundingBox(options.page, step.callout.locator, warnings, step.id)
262
+ : undefined;
263
+ const viewport = options.scenario.browser.viewport;
264
+ const placement = resolveCalloutPlacement(step.callout.placement, box, viewport);
265
+ await runVisual(warnings, step.id, "apply callout", () => applyTutorialCallout(options.page, {
266
+ text: step.callout.text,
267
+ placement,
268
+ box: box ?? null,
269
+ viewport
270
+ }));
271
+ }
272
+ }
273
+ /**
274
+ * Captures a requested screenshot from the already-open tutorial page.
275
+ *
276
+ * Never launches a browser and never re-navigates: the point of the capture is
277
+ * the live state this session just produced.
278
+ */
279
+ async function captureStepScreenshot(options, step, actionPhaseCompleted) {
280
+ const screenshot = step.screenshot;
281
+ if (!options.paths) {
282
+ return {
283
+ kind: "screenshot",
284
+ id: screenshot.id,
285
+ status: "failed",
286
+ error: `Screenshot ${JSON.stringify(screenshot.id)} was requested but the tutorial run has no screenshot output directory.`
287
+ };
288
+ }
289
+ const absolutePath = buildScreenshotPath(options.paths, screenshot.id);
290
+ const relativePath = toRunRelativePosixPath(options.paths.runRoot, absolutePath);
291
+ if (!actionPhaseCompleted) {
292
+ // The step never reached a usable state; fabricating an image would misrepresent it.
293
+ return {
294
+ kind: "screenshot",
295
+ id: screenshot.id,
296
+ status: "skipped",
297
+ path: relativePath,
298
+ error: `Screenshot ${JSON.stringify(screenshot.id)} was not captured because step ${JSON.stringify(step.id)} failed before reaching a usable state.`
299
+ };
300
+ }
301
+ if (existsSync(absolutePath)) {
302
+ // Canonical screenshot identity is stable; silently overwriting or adding a
303
+ // suffix would both hide a real problem.
304
+ return {
305
+ kind: "screenshot",
306
+ id: screenshot.id,
307
+ status: "failed",
308
+ path: relativePath,
309
+ error: `Screenshot ${JSON.stringify(screenshot.id)} already exists at ${relativePath}; refusing to overwrite a canonical tutorial artifact.`
310
+ };
311
+ }
312
+ try {
313
+ await options.page.screenshot({ path: absolutePath, fullPage: screenshot.fullPage ?? false });
314
+ }
315
+ catch (error) {
316
+ return {
317
+ kind: "screenshot",
318
+ id: screenshot.id,
319
+ status: "failed",
320
+ path: relativePath,
321
+ error: `Screenshot ${JSON.stringify(screenshot.id)} could not be captured: ${messageOf(error)}`
322
+ };
323
+ }
324
+ const check = await verifyArtifactFile(absolutePath);
325
+ if (!check.ok) {
326
+ return { kind: "screenshot", id: screenshot.id, status: "failed", path: relativePath, error: check.error };
327
+ }
328
+ return {
329
+ kind: "screenshot",
330
+ id: screenshot.id,
331
+ status: "written",
332
+ path: relativePath,
333
+ sizeBytes: check.sizeBytes
334
+ };
335
+ }
336
+ async function readBoundingBox(page, locator, warnings, stepId) {
337
+ try {
338
+ const box = await resolveTutorialLocator(page, locator).boundingBox();
339
+ if (!box) {
340
+ warnings.push(`Step ${JSON.stringify(stepId)}: ${describeTutorialLocator(locator)} has no bounding box, so its tutorial visual was skipped.`);
341
+ return undefined;
342
+ }
343
+ return box;
344
+ }
345
+ catch (error) {
346
+ warnings.push(`Step ${JSON.stringify(stepId)}: bounding box for ${describeTutorialLocator(locator)} could not be read: ${messageOf(error)}`);
347
+ return undefined;
348
+ }
349
+ }
350
+ /**
351
+ * Runs one presentation operation. A failure here is recorded as a visual
352
+ * warning, never swallowed and never promoted into an action/assertion outcome.
353
+ */
354
+ async function runVisual(warnings, stepId, description, operation) {
355
+ try {
356
+ await operation();
357
+ }
358
+ catch (error) {
359
+ warnings.push(`Step ${JSON.stringify(stepId)}: tutorial visual "${description}" failed: ${messageOf(error)}`);
360
+ }
361
+ }
362
+ async function runStepAssertions(step, options) {
363
+ const results = [];
364
+ for (const assertion of step.assertions ?? []) {
365
+ results.push(await executeTutorialAssertion(options.page, assertion, {
366
+ applicationUrl: options.applicationUrl,
367
+ targetRoot: options.targetRoot
368
+ }));
369
+ }
370
+ return results;
371
+ }
372
+ function notRunStepResult(step) {
373
+ return {
374
+ id: step.id,
375
+ status: "not-run",
376
+ assertions: [],
377
+ screenshotRequested: step.screenshot !== undefined,
378
+ highlightRequested: step.highlight !== undefined,
379
+ calloutRequested: step.callout !== undefined
380
+ };
381
+ }
382
+ function defaultSleep(ms) {
383
+ return new Promise((resolve) => {
384
+ setTimeout(resolve, ms);
385
+ });
386
+ }
387
+ async function closeQuietly(close) {
388
+ try {
389
+ await close();
390
+ return undefined;
391
+ }
392
+ catch (error) {
393
+ return messageOf(error);
394
+ }
395
+ }
396
+ function messageOf(error) {
397
+ return error instanceof Error ? error.message : String(error);
398
+ }