@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,249 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { resolveTargetFilePath } from "./tutorialPaths.js";
3
+ import { describeTutorialLocator, resolveTutorialLocator } from "./tutorialLocators.js";
4
+ import { DEFAULT_TUTORIAL_ASSERTION_TIMEOUT_MS } from "./types.js";
5
+ /**
6
+ * RFC 6901 JSON Pointer resolution.
7
+ *
8
+ * Deliberately only JSON Pointer: no JSONPath, no dot-notation expression
9
+ * language, no evaluation. A pointer can address data but can never compute.
10
+ */
11
+ export function resolveJsonPointer(document, pointer) {
12
+ if (typeof pointer !== "string") {
13
+ return { ok: false, error: "JSON Pointer must be a string." };
14
+ }
15
+ if (pointer === "") {
16
+ return { ok: true, value: document };
17
+ }
18
+ if (!pointer.startsWith("/")) {
19
+ return { ok: false, error: `JSON Pointer must be empty or begin with "/"; received ${JSON.stringify(pointer)}.` };
20
+ }
21
+ let current = document;
22
+ const rawTokens = pointer.slice(1).split("/");
23
+ for (const rawToken of rawTokens) {
24
+ const badEscape = /~(?![01])/.exec(rawToken);
25
+ if (badEscape) {
26
+ return {
27
+ ok: false,
28
+ error: `Malformed JSON Pointer escape in segment ${JSON.stringify(rawToken)}; "~" must be followed by "0" or "1".`
29
+ };
30
+ }
31
+ // Order matters: ~1 becomes "/" first, then ~0 becomes "~", so an encoded
32
+ // "~1" inside a name is not re-interpreted as a separator.
33
+ const token = rawToken.replace(/~1/g, "/").replace(/~0/g, "~");
34
+ if (Array.isArray(current)) {
35
+ if (!/^(0|[1-9][0-9]*)$/.test(token)) {
36
+ return { ok: false, error: `JSON Pointer segment ${JSON.stringify(token)} is not a valid array index.` };
37
+ }
38
+ const index = Number(token);
39
+ if (index >= current.length) {
40
+ return { ok: false, error: `JSON Pointer segment ${JSON.stringify(token)} is out of range.` };
41
+ }
42
+ current = current[index];
43
+ continue;
44
+ }
45
+ if (typeof current === "object" && current !== null) {
46
+ if (!Object.prototype.hasOwnProperty.call(current, token)) {
47
+ return { ok: false, error: `JSON Pointer segment ${JSON.stringify(token)} was not found.` };
48
+ }
49
+ current = current[token];
50
+ continue;
51
+ }
52
+ return {
53
+ ok: false,
54
+ error: `JSON Pointer segment ${JSON.stringify(token)} cannot be resolved against a non-object value.`
55
+ };
56
+ }
57
+ return { ok: true, value: current };
58
+ }
59
+ /**
60
+ * Resolves an HTTP assertion path against the application origin and refuses
61
+ * anything that leaves it. Mirrors the navigation rule so a scenario cannot use
62
+ * assertions as a general-purpose network client.
63
+ */
64
+ export function resolveAssertionRequestUrl(applicationUrl, suppliedPath) {
65
+ if (typeof suppliedPath !== "string" || !suppliedPath.startsWith("/") || suppliedPath.startsWith("//")) {
66
+ throw new Error(`HTTP assertion path must be a root-relative path beginning with "/" and must not be protocol-relative; received ${JSON.stringify(suppliedPath)}.`);
67
+ }
68
+ const base = new URL(applicationUrl);
69
+ const resolved = new URL(suppliedPath, base);
70
+ if (resolved.protocol !== base.protocol || resolved.hostname !== base.hostname || resolved.port !== base.port) {
71
+ throw new Error(`HTTP assertion path ${JSON.stringify(suppliedPath)} resolves to ${resolved.origin}, which leaves the application origin ${base.origin}.`);
72
+ }
73
+ return resolved.href;
74
+ }
75
+ export async function executeTutorialAssertion(page, assertion, context) {
76
+ const startedAtMs = Date.now();
77
+ const startedAt = new Date(startedAtMs).toISOString();
78
+ let failure;
79
+ try {
80
+ failure = await evaluateAssertion(page, assertion, context);
81
+ }
82
+ catch (error) {
83
+ // An ordinary assertion problem must never escape as an unhandled
84
+ // exception; it is recorded as a failed assertion result instead.
85
+ failure = error instanceof Error ? error.message : String(error);
86
+ }
87
+ const endedAtMs = Date.now();
88
+ return {
89
+ type: assertion.type,
90
+ status: failure === undefined ? "passed" : "failed",
91
+ startedAt,
92
+ endedAt: new Date(endedAtMs).toISOString(),
93
+ durationMs: endedAtMs - startedAtMs,
94
+ ...(failure === undefined ? {} : { error: failure })
95
+ };
96
+ }
97
+ /** Returns undefined when the assertion holds, otherwise a failure message. */
98
+ async function evaluateAssertion(page, assertion, context) {
99
+ switch (assertion.type) {
100
+ case "element-visible": {
101
+ const locator = resolveTutorialLocator(page, assertion.locator);
102
+ try {
103
+ await locator.waitFor({ state: "visible", timeout: assertionTimeout(assertion.timeoutMs) });
104
+ return undefined;
105
+ }
106
+ catch (error) {
107
+ return `Element ${describeTutorialLocator(assertion.locator)} was not visible within ${assertionTimeout(assertion.timeoutMs)}ms: ${messageOf(error)}`;
108
+ }
109
+ }
110
+ case "text-equals":
111
+ case "text-contains": {
112
+ const locator = resolveTutorialLocator(page, assertion.locator);
113
+ const actual = await locator.textContent({ timeout: assertionTimeout(assertion.timeoutMs) });
114
+ if (actual === null) {
115
+ return `Element ${describeTutorialLocator(assertion.locator)} has no text content.`;
116
+ }
117
+ if (assertion.type === "text-equals") {
118
+ return actual === assertion.expected
119
+ ? undefined
120
+ : `Element ${describeTutorialLocator(assertion.locator)} text was ${JSON.stringify(actual)}, expected exactly ${JSON.stringify(assertion.expected)}.`;
121
+ }
122
+ return actual.includes(assertion.expected)
123
+ ? undefined
124
+ : `Element ${describeTutorialLocator(assertion.locator)} text was ${JSON.stringify(actual)}, expected it to contain ${JSON.stringify(assertion.expected)}.`;
125
+ }
126
+ case "url-path-equals": {
127
+ const actual = comparableUrlPath(page.url(), assertion.expected);
128
+ return actual === assertion.expected
129
+ ? undefined
130
+ : `Page path was ${JSON.stringify(actual)}, expected ${JSON.stringify(assertion.expected)}.`;
131
+ }
132
+ case "attribute-equals": {
133
+ const locator = resolveTutorialLocator(page, assertion.locator);
134
+ const actual = await locator.getAttribute(assertion.name, {
135
+ timeout: assertionTimeout(assertion.timeoutMs)
136
+ });
137
+ if (actual === null) {
138
+ return `Element ${describeTutorialLocator(assertion.locator)} has no ${JSON.stringify(assertion.name)} attribute.`;
139
+ }
140
+ return actual === assertion.expected
141
+ ? undefined
142
+ : `Element ${describeTutorialLocator(assertion.locator)} attribute ${JSON.stringify(assertion.name)} was ${JSON.stringify(actual)}, expected ${JSON.stringify(assertion.expected)}.`;
143
+ }
144
+ case "http-json-equals": {
145
+ const url = resolveAssertionRequestUrl(context.applicationUrl, assertion.path);
146
+ const timeoutMs = assertionTimeout(assertion.timeoutMs);
147
+ let response;
148
+ try {
149
+ response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(timeoutMs) });
150
+ }
151
+ catch (error) {
152
+ return `HTTP assertion request to ${assertion.path} failed: ${messageOf(error)}`;
153
+ }
154
+ if (response.status < 200 || response.status > 299) {
155
+ await response.body?.cancel().catch(() => undefined);
156
+ return `HTTP assertion request to ${assertion.path} returned status ${response.status}; expected a 2xx response.`;
157
+ }
158
+ let body;
159
+ try {
160
+ body = await response.text();
161
+ }
162
+ catch (error) {
163
+ return `HTTP assertion response body for ${assertion.path} could not be read: ${messageOf(error)}`;
164
+ }
165
+ let document;
166
+ try {
167
+ document = JSON.parse(body);
168
+ }
169
+ catch (error) {
170
+ return `HTTP assertion response body for ${assertion.path} is not valid JSON: ${messageOf(error)}`;
171
+ }
172
+ return compareAtPointer(document, assertion.pointer, assertion.expected, `${assertion.path}`);
173
+ }
174
+ case "json-file-equals": {
175
+ const filePath = resolveTargetFilePath(context.targetRoot, assertion.path);
176
+ let raw;
177
+ try {
178
+ raw = await readFile(filePath, "utf8");
179
+ }
180
+ catch (error) {
181
+ return `JSON file assertion could not read ${assertion.path}: ${messageOf(error)}`;
182
+ }
183
+ let document;
184
+ try {
185
+ document = JSON.parse(raw);
186
+ }
187
+ catch (error) {
188
+ return `JSON file ${assertion.path} is not valid JSON: ${messageOf(error)}`;
189
+ }
190
+ return compareAtPointer(document, assertion.pointer, assertion.expected, assertion.path);
191
+ }
192
+ case "file-exists": {
193
+ const filePath = resolveTargetFilePath(context.targetRoot, assertion.path);
194
+ try {
195
+ const stats = await stat(filePath);
196
+ if (!stats.isFile()) {
197
+ return `Expected ${assertion.path} to be a regular file beneath the tutorial target root, but it is a directory.`;
198
+ }
199
+ return undefined;
200
+ }
201
+ catch (error) {
202
+ return `Expected file ${assertion.path} to exist beneath the tutorial target root: ${messageOf(error)}`;
203
+ }
204
+ }
205
+ }
206
+ }
207
+ function compareAtPointer(document, pointer, expected, sourceLabel) {
208
+ const resolved = resolveJsonPointer(document, pointer);
209
+ if (!resolved.ok) {
210
+ return `JSON Pointer ${JSON.stringify(pointer)} did not resolve in ${sourceLabel}: ${resolved.error}`;
211
+ }
212
+ const actual = resolved.value;
213
+ if (actual !== null &&
214
+ typeof actual !== "string" &&
215
+ typeof actual !== "number" &&
216
+ typeof actual !== "boolean") {
217
+ return `JSON Pointer ${JSON.stringify(pointer)} in ${sourceLabel} resolved to a non-scalar value; expected ${JSON.stringify(expected)}.`;
218
+ }
219
+ return actual === expected
220
+ ? undefined
221
+ : `JSON Pointer ${JSON.stringify(pointer)} in ${sourceLabel} was ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}.`;
222
+ }
223
+ /**
224
+ * Compares only the path portion of the current URL, extended with query and
225
+ * hash when (and only when) the expectation asks for them. The host is never
226
+ * compared because the origin is already constrained by navigation policy.
227
+ */
228
+ export function comparableUrlPath(currentUrl, expected) {
229
+ let parsed;
230
+ try {
231
+ parsed = new URL(currentUrl);
232
+ }
233
+ catch {
234
+ return currentUrl;
235
+ }
236
+ if (expected.includes("#")) {
237
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
238
+ }
239
+ if (expected.includes("?")) {
240
+ return `${parsed.pathname}${parsed.search}`;
241
+ }
242
+ return parsed.pathname;
243
+ }
244
+ function assertionTimeout(timeoutMs) {
245
+ return timeoutMs ?? DEFAULT_TUTORIAL_ASSERTION_TIMEOUT_MS;
246
+ }
247
+ function messageOf(error) {
248
+ return error instanceof Error ? error.message : String(error);
249
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Synthetic tutorial cursor and click feedback.
3
+ *
4
+ * Everything this module injects is presentation only. Playwright still performs
5
+ * every real interaction, locator resolution and assertion; the cursor exists so
6
+ * a viewer of the recording can see where the tutorial is pointing. All injected
7
+ * nodes use `pointer-events: none` so they can never intercept application input,
8
+ * and they live in one tutorial-owned container so cleanup is a single removal.
9
+ */
10
+ /** Stable, collision-resistant identities owned by the tutorial runtime. */
11
+ export const TUTORIAL_VISUAL_ROOT_ID = "__my_dev_kit_lab_tutorial_visuals__";
12
+ export const TUTORIAL_CURSOR_ID = "__my_dev_kit_lab_tutorial_cursor__";
13
+ export const TUTORIAL_CLICK_FEEDBACK_ID = "__my_dev_kit_lab_tutorial_click_feedback__";
14
+ /** Planner-owned, machine-independent so recordings are reproducible. */
15
+ export const CURSOR_MOVE_DURATION_MS = 250;
16
+ export const CLICK_FEEDBACK_DURATION_MS = 300;
17
+ const CURSOR_SIZE_PX = 24;
18
+ const CLICK_FEEDBACK_SIZE_PX = 44;
19
+ /** Center of a bounding box; the deterministic cursor target for every action. */
20
+ export function centerOfBox(box) {
21
+ return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
22
+ }
23
+ export function installCursorInPage(arg) {
24
+ const doc = globalThis.document;
25
+ if (!doc || !doc.body) {
26
+ return false;
27
+ }
28
+ let root = doc.getElementById(arg.rootId);
29
+ if (!root) {
30
+ root = doc.createElement("div");
31
+ root.id = arg.rootId;
32
+ root.setAttribute("aria-hidden", "true");
33
+ root.style.setProperty("position", "fixed");
34
+ root.style.setProperty("left", "0");
35
+ root.style.setProperty("top", "0");
36
+ root.style.setProperty("width", "0");
37
+ root.style.setProperty("height", "0");
38
+ root.style.setProperty("pointer-events", "none");
39
+ root.style.setProperty("z-index", "2147483646");
40
+ doc.body.appendChild(root);
41
+ }
42
+ if (doc.getElementById(arg.cursorId)) {
43
+ // Installing twice must not produce two cursors.
44
+ return false;
45
+ }
46
+ const cursor = doc.createElement("div");
47
+ cursor.id = arg.cursorId;
48
+ cursor.setAttribute("aria-hidden", "true");
49
+ cursor.style.setProperty("position", "fixed");
50
+ cursor.style.setProperty("left", "0");
51
+ cursor.style.setProperty("top", "0");
52
+ cursor.style.setProperty("width", `${arg.sizePx}px`);
53
+ cursor.style.setProperty("height", `${arg.sizePx}px`);
54
+ cursor.style.setProperty("margin-left", `${-arg.sizePx / 2}px`);
55
+ cursor.style.setProperty("margin-top", `${-arg.sizePx / 2}px`);
56
+ cursor.style.setProperty("border-radius", "50%");
57
+ cursor.style.setProperty("background", "rgba(20, 20, 20, 0.35)");
58
+ cursor.style.setProperty("border", "2px solid rgba(255, 255, 255, 0.9)");
59
+ cursor.style.setProperty("box-shadow", "0 0 0 1px rgba(0, 0, 0, 0.45)");
60
+ cursor.style.setProperty("pointer-events", "none");
61
+ cursor.style.setProperty("z-index", "2147483647");
62
+ cursor.style.setProperty("opacity", "0");
63
+ cursor.style.setProperty("transform", "translate(0px, 0px)");
64
+ cursor.style.setProperty("transition", `transform ${arg.moveDurationMs}ms linear, opacity 120ms linear`);
65
+ root.appendChild(cursor);
66
+ return true;
67
+ }
68
+ export function moveCursorInPage(arg) {
69
+ const doc = globalThis.document;
70
+ const cursor = doc?.getElementById(arg.cursorId);
71
+ if (!cursor) {
72
+ return false;
73
+ }
74
+ cursor.style.setProperty("opacity", "1");
75
+ cursor.style.setProperty("transform", `translate(${arg.x}px, ${arg.y}px)`);
76
+ return true;
77
+ }
78
+ export function showClickFeedbackInPage(arg) {
79
+ const doc = globalThis.document;
80
+ const root = doc?.getElementById(arg.rootId);
81
+ if (!root) {
82
+ return false;
83
+ }
84
+ // A previous ripple is replaced rather than accumulated.
85
+ const stale = doc.getElementById(arg.feedbackId);
86
+ if (stale) {
87
+ stale.remove();
88
+ }
89
+ const ripple = doc.createElement("div");
90
+ ripple.id = arg.feedbackId;
91
+ ripple.setAttribute("aria-hidden", "true");
92
+ ripple.style.setProperty("position", "fixed");
93
+ ripple.style.setProperty("left", `${arg.x - arg.sizePx / 2}px`);
94
+ ripple.style.setProperty("top", `${arg.y - arg.sizePx / 2}px`);
95
+ ripple.style.setProperty("width", `${arg.sizePx}px`);
96
+ ripple.style.setProperty("height", `${arg.sizePx}px`);
97
+ ripple.style.setProperty("border-radius", "50%");
98
+ ripple.style.setProperty("border", "3px solid rgba(255, 92, 0, 0.95)");
99
+ ripple.style.setProperty("background", "rgba(255, 92, 0, 0.18)");
100
+ ripple.style.setProperty("pointer-events", "none");
101
+ ripple.style.setProperty("z-index", "2147483647");
102
+ root.appendChild(ripple);
103
+ const timers = globalThis;
104
+ if (typeof timers.setTimeout === "function") {
105
+ timers.setTimeout(() => {
106
+ const current = doc.getElementById(arg.feedbackId);
107
+ if (current) {
108
+ current.remove();
109
+ }
110
+ }, arg.durationMs);
111
+ }
112
+ return true;
113
+ }
114
+ export function removeTutorialVisualsInPage(arg) {
115
+ const doc = globalThis.document;
116
+ const root = doc?.getElementById(arg.rootId);
117
+ if (!root) {
118
+ return false;
119
+ }
120
+ root.remove();
121
+ return true;
122
+ }
123
+ // ---------------------------------------------------------------------------
124
+ // Host-side helpers
125
+ // ---------------------------------------------------------------------------
126
+ export async function installTutorialCursor(page) {
127
+ return page.evaluate(installCursorInPage, {
128
+ rootId: TUTORIAL_VISUAL_ROOT_ID,
129
+ cursorId: TUTORIAL_CURSOR_ID,
130
+ moveDurationMs: CURSOR_MOVE_DURATION_MS,
131
+ sizePx: CURSOR_SIZE_PX
132
+ });
133
+ }
134
+ export async function moveTutorialCursor(page, point) {
135
+ return page.evaluate(moveCursorInPage, { cursorId: TUTORIAL_CURSOR_ID, x: point.x, y: point.y });
136
+ }
137
+ export async function showTutorialClickFeedback(page, point) {
138
+ return page.evaluate(showClickFeedbackInPage, {
139
+ rootId: TUTORIAL_VISUAL_ROOT_ID,
140
+ feedbackId: TUTORIAL_CLICK_FEEDBACK_ID,
141
+ x: point.x,
142
+ y: point.y,
143
+ durationMs: CLICK_FEEDBACK_DURATION_MS,
144
+ sizePx: CLICK_FEEDBACK_SIZE_PX
145
+ });
146
+ }
147
+ export async function removeTutorialVisuals(page) {
148
+ return page.evaluate(removeTutorialVisualsInPage, { rootId: TUTORIAL_VISUAL_ROOT_ID });
149
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The single place a declarative locator becomes a Playwright locator.
3
+ *
4
+ * Actions, assertions, and (in a later batch) overlay targeting all call this,
5
+ * so locator semantics cannot drift between them and a new locator kind has
6
+ * exactly one implementation site.
7
+ */
8
+ export function resolveTutorialLocator(page, locator) {
9
+ switch (locator.kind) {
10
+ case "role":
11
+ return page.getByRole(locator.role, {
12
+ ...(locator.name !== undefined ? { name: locator.name } : {}),
13
+ ...(locator.exact !== undefined ? { exact: locator.exact } : {})
14
+ });
15
+ case "text":
16
+ return page.getByText(locator.text, locator.exact !== undefined ? { exact: locator.exact } : undefined);
17
+ case "css":
18
+ return page.locator(locator.selector);
19
+ case "test-id":
20
+ return page.getByTestId(locator.testId);
21
+ }
22
+ }
23
+ /** Short human-readable form used in action and assertion failure messages. */
24
+ export function describeTutorialLocator(locator) {
25
+ switch (locator.kind) {
26
+ case "role":
27
+ return locator.name === undefined
28
+ ? `role=${locator.role}`
29
+ : `role=${locator.role} name=${JSON.stringify(locator.name)}`;
30
+ case "text":
31
+ return `text=${JSON.stringify(locator.text)}`;
32
+ case "css":
33
+ return `css=${locator.selector}`;
34
+ case "test-id":
35
+ return `test-id=${locator.testId}`;
36
+ }
37
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Canonical tutorial run manifest.
3
+ *
4
+ * Deliberately its own schema rather than an overloaded GalleryManifest: a
5
+ * tutorial run records step/action/assertion outcomes, a timeline, and artifact
6
+ * statuses, none of which a gallery index models. It is built from the
7
+ * structured runtime results that are already in hand -- output directories are
8
+ * scanned only to confirm the size of files this run itself wrote.
9
+ */
10
+ export const TUTORIAL_MANIFEST_SCHEMA_VERSION = "1.0.0";
11
+ export function buildTutorialManifest(options) {
12
+ const stepById = new Map(options.scenario.steps.map((step) => [step.id, step]));
13
+ const screenshotRecordById = new Map(options.artifacts
14
+ .filter((record) => record.kind === "screenshot" && record.id !== undefined)
15
+ .map((record) => [record.id, record]));
16
+ // Step order is scenario order; assertion order is execution order. Neither is
17
+ // re-sorted, so a diff between two runs stays readable.
18
+ const steps = options.steps.map((stepResult) => {
19
+ const step = stepById.get(stepResult.id);
20
+ const requestedScreenshotId = step?.screenshot?.id;
21
+ const record = requestedScreenshotId ? screenshotRecordById.get(requestedScreenshotId) : undefined;
22
+ return {
23
+ id: stepResult.id,
24
+ status: stepResult.status,
25
+ narration: step?.narration ?? "",
26
+ ...(stepResult.timelineStartMs !== undefined ? { timelineStartMs: stepResult.timelineStartMs } : {}),
27
+ ...(stepResult.timelineEndMs !== undefined ? { timelineEndMs: stepResult.timelineEndMs } : {}),
28
+ ...(stepResult.action ? { action: stepResult.action } : {}),
29
+ assertions: stepResult.assertions,
30
+ ...(stepResult.screenshotRequested || record
31
+ ? {
32
+ screenshot: {
33
+ requested: stepResult.screenshotRequested,
34
+ ...(requestedScreenshotId !== undefined ? { id: requestedScreenshotId } : {}),
35
+ ...(record?.status !== undefined ? { status: record.status } : {}),
36
+ ...(record?.path !== undefined ? { path: record.path } : {}),
37
+ ...(record?.error !== undefined ? { error: record.error } : {})
38
+ }
39
+ }
40
+ : {}),
41
+ highlightRequested: stepResult.highlightRequested,
42
+ calloutRequested: stepResult.calloutRequested,
43
+ ...(stepResult.error !== undefined ? { error: stepResult.error } : {})
44
+ };
45
+ });
46
+ return {
47
+ schemaVersion: TUTORIAL_MANIFEST_SCHEMA_VERSION,
48
+ scenario: {
49
+ schemaVersion: options.scenario.schemaVersion,
50
+ id: options.scenario.id,
51
+ title: options.scenario.title,
52
+ ...(options.scenario.description !== undefined ? { description: options.scenario.description } : {}),
53
+ targetId: options.scenario.targetId
54
+ },
55
+ target: {
56
+ schemaVersion: options.targetContract.schemaVersion,
57
+ id: options.targetContract.id,
58
+ applicationUrl: options.targetContract.applicationUrl
59
+ },
60
+ run: { ...options.run },
61
+ environment: options.environment ?? {
62
+ platform: process.platform,
63
+ nodeVersion: process.version
64
+ },
65
+ steps,
66
+ artifacts: [...options.artifacts],
67
+ warnings: [...options.warnings],
68
+ cleanupErrors: [...options.cleanupErrors]
69
+ };
70
+ }
71
+ /** Pretty JSON with a single trailing newline. */
72
+ export function renderTutorialManifest(manifest) {
73
+ return `${JSON.stringify(manifest, null, 2)}\n`;
74
+ }