@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.
- package/CHANGELOG.md +25 -2
- package/README.md +8 -1
- package/dist/src/browser/index.js +2 -0
- package/dist/src/browser/playwrightRuntime.js +74 -0
- package/dist/src/browser/types.js +10 -0
- package/dist/src/cli/help.js +76 -0
- package/dist/src/cli/runLabCli.js +29 -1
- package/dist/src/commands/runTutorialRunCommand.js +170 -0
- package/dist/src/commands/runTutorialValidateCommand.js +138 -0
- package/dist/src/core/processTree.js +52 -0
- package/dist/src/core/resolveCommand.js +34 -0
- package/dist/src/core/runMeasuredCommand.js +39 -31
- package/dist/src/runtime/index.js +1 -0
- package/dist/src/runtime/managedProcess.js +380 -0
- package/dist/src/screenshot/captureReportScreenshot.js +30 -27
- package/dist/src/tutorial/index.js +17 -0
- package/dist/src/tutorial/loadTutorialContracts.js +60 -0
- package/dist/src/tutorial/markdownTutorialWriter.js +53 -0
- package/dist/src/tutorial/runTutorial.js +465 -0
- package/dist/src/tutorial/scenarioValidation.js +427 -0
- package/dist/src/tutorial/subtitleWriter.js +88 -0
- package/dist/src/tutorial/targetContractValidation.js +212 -0
- package/dist/src/tutorial/tutorialActions.js +175 -0
- package/dist/src/tutorial/tutorialArtifacts.js +176 -0
- package/dist/src/tutorial/tutorialAssertions.js +249 -0
- package/dist/src/tutorial/tutorialCursor.js +149 -0
- package/dist/src/tutorial/tutorialLocators.js +37 -0
- package/dist/src/tutorial/tutorialManifest.js +74 -0
- package/dist/src/tutorial/tutorialOverlay.js +180 -0
- package/dist/src/tutorial/tutorialPaths.js +86 -0
- package/dist/src/tutorial/tutorialPointerGeometry.js +16 -0
- package/dist/src/tutorial/tutorialSession.js +424 -0
- package/dist/src/tutorial/types.js +51 -0
- package/dist/src/tutorial/validationPrimitives.js +141 -0
- package/examples/tutorial-browser/index.html +109 -0
- package/examples/tutorial-browser/prepare.mjs +14 -0
- package/examples/tutorial-browser/scenario.json +19 -0
- package/examples/tutorial-browser/server.mjs +14 -0
- package/package.json +8 -4
|
@@ -0,0 +1,424 @@
|
|
|
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 { resolveTutorialFractionPoint } from "./tutorialPointerGeometry.js";
|
|
6
|
+
import { buildScreenshotPath, toRunRelativePosixPath, verifyArtifactFile } from "./tutorialArtifacts.js";
|
|
7
|
+
import { CLICK_FEEDBACK_DURATION_MS, CURSOR_MOVE_DURATION_MS, centerOfBox, installTutorialCursor, moveTutorialCursor, removeTutorialVisuals, showTutorialClickFeedback } from "./tutorialCursor.js";
|
|
8
|
+
import { applyTutorialCallout, applyTutorialHighlight, clearTutorialStepOverlays, resolveCalloutPlacement } from "./tutorialOverlay.js";
|
|
9
|
+
export async function openTutorialSession(browser, options) {
|
|
10
|
+
if (typeof browser.newContext !== "function") {
|
|
11
|
+
return {
|
|
12
|
+
ok: false,
|
|
13
|
+
error: "Tutorial browser runtime does not support browser contexts, which a persistent tutorial session requires."
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
const viewport = { width: options.viewport.width, height: options.viewport.height };
|
|
17
|
+
let context;
|
|
18
|
+
try {
|
|
19
|
+
context = await browser.newContext({
|
|
20
|
+
viewport,
|
|
21
|
+
// Recording size matches the scenario viewport: no invented dimensions.
|
|
22
|
+
...(options.recordVideoDir !== undefined
|
|
23
|
+
? { recordVideo: { dir: options.recordVideoDir, size: { ...viewport } } }
|
|
24
|
+
: {})
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
return { ok: false, error: `Tutorial browser context could not be created: ${messageOf(error)}` };
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const page = await context.newPage();
|
|
32
|
+
let video = null;
|
|
33
|
+
if (typeof page.video === "function") {
|
|
34
|
+
try {
|
|
35
|
+
video = page.video();
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
// A runtime without video must not prevent the tutorial from running;
|
|
39
|
+
// the missing recording is reported later as an artifact outcome.
|
|
40
|
+
video = null;
|
|
41
|
+
void error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { ok: true, session: { context, page, video } };
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
await closeQuietly(() => context.close());
|
|
48
|
+
return { ok: false, error: `Tutorial page could not be created: ${messageOf(error)}` };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Closes the page and context. Playwright only finalizes a recording when the
|
|
53
|
+
* page closes, so the caller finalizes video after this returns.
|
|
54
|
+
*
|
|
55
|
+
* Returns cleanup errors rather than throwing so the caller can preserve a
|
|
56
|
+
* primary failure and still record what went wrong during teardown.
|
|
57
|
+
*/
|
|
58
|
+
export async function closeTutorialSession(session) {
|
|
59
|
+
const errors = [];
|
|
60
|
+
if (typeof session.page.close === "function") {
|
|
61
|
+
const pageError = await closeQuietly(() => session.page.close());
|
|
62
|
+
if (pageError) {
|
|
63
|
+
errors.push(`Tutorial page close failed: ${pageError}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const contextError = await closeQuietly(() => session.context.close());
|
|
67
|
+
if (contextError) {
|
|
68
|
+
errors.push(`Tutorial browser context close failed: ${contextError}`);
|
|
69
|
+
}
|
|
70
|
+
return errors;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Runs every step in declared order against one page.
|
|
74
|
+
*
|
|
75
|
+
* Failure semantics, in the planner's order: an action failure skips that step's
|
|
76
|
+
* assertions; assertion failures do not short-circuit each other within the same
|
|
77
|
+
* step (all are collected so a reader sees the full picture); either kind of
|
|
78
|
+
* failure stops later steps, which are reported as "not-run" rather than omitted
|
|
79
|
+
* or reported as passed.
|
|
80
|
+
*
|
|
81
|
+
* Per-step ordering with visuals enabled:
|
|
82
|
+
* clear previous overlays -> pauseBefore -> cursor move -> real action ->
|
|
83
|
+
* click feedback -> assertions -> highlight + callout -> pauseAfter ->
|
|
84
|
+
* screenshot
|
|
85
|
+
* so a screenshot always shows the final intended state of its step.
|
|
86
|
+
*/
|
|
87
|
+
export async function executeTutorialSteps(options) {
|
|
88
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
89
|
+
const now = options.now ?? Date.now;
|
|
90
|
+
const timelineOrigin = now();
|
|
91
|
+
const steps = [];
|
|
92
|
+
const screenshots = [];
|
|
93
|
+
const visualWarnings = [];
|
|
94
|
+
let failedStepId;
|
|
95
|
+
let failureMessage;
|
|
96
|
+
/**
|
|
97
|
+
* Installing is idempotent, and a navigation replaces the whole document, so
|
|
98
|
+
* the tutorial-owned visual root has to be re-established rather than assumed
|
|
99
|
+
* to survive. Calling this at the start of every step and again after a
|
|
100
|
+
* successful goto keeps the cursor present without tracking navigations.
|
|
101
|
+
*/
|
|
102
|
+
const ensureVisuals = async (stepId) => {
|
|
103
|
+
if (!options.visuals) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
await runVisual(visualWarnings, stepId, "install cursor", () => installTutorialCursor(options.page));
|
|
107
|
+
};
|
|
108
|
+
await ensureVisuals("<session>");
|
|
109
|
+
for (const step of options.scenario.steps) {
|
|
110
|
+
if (failedStepId !== undefined) {
|
|
111
|
+
steps.push(notRunStepResult(step));
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const startedAtMs = Date.now();
|
|
115
|
+
const timelineStartMs = Math.max(0, now() - timelineOrigin);
|
|
116
|
+
const result = {
|
|
117
|
+
id: step.id,
|
|
118
|
+
status: "passed",
|
|
119
|
+
startedAt: new Date(startedAtMs).toISOString(),
|
|
120
|
+
timelineStartMs,
|
|
121
|
+
assertions: [],
|
|
122
|
+
screenshotRequested: step.screenshot !== undefined,
|
|
123
|
+
highlightRequested: step.highlight !== undefined,
|
|
124
|
+
calloutRequested: step.callout !== undefined
|
|
125
|
+
};
|
|
126
|
+
if (options.visuals) {
|
|
127
|
+
// Each step starts from a clean visual slate, on a guaranteed-present root.
|
|
128
|
+
await ensureVisuals(step.id);
|
|
129
|
+
await runVisual(visualWarnings, step.id, "clear overlays", () => clearTutorialStepOverlays(options.page));
|
|
130
|
+
}
|
|
131
|
+
if (step.pauseBeforeMs !== undefined && step.pauseBeforeMs > 0) {
|
|
132
|
+
await sleep(step.pauseBeforeMs);
|
|
133
|
+
}
|
|
134
|
+
let actionPhaseCompleted = true;
|
|
135
|
+
if (step.action) {
|
|
136
|
+
const cursorTarget = options.visuals
|
|
137
|
+
? await moveCursorForAction(options, step.action, visualWarnings, step.id, sleep)
|
|
138
|
+
: undefined;
|
|
139
|
+
const actionResult = await executeTutorialAction(options.page, step.action, {
|
|
140
|
+
applicationUrl: options.applicationUrl
|
|
141
|
+
});
|
|
142
|
+
result.action = actionResult;
|
|
143
|
+
if (actionResult.status === "failed") {
|
|
144
|
+
actionPhaseCompleted = false;
|
|
145
|
+
result.status = "failed";
|
|
146
|
+
result.error = `Step ${JSON.stringify(step.id)} failed because the ${step.action.type} action failed: ${actionResult.error ?? "unknown error"}`;
|
|
147
|
+
}
|
|
148
|
+
else if (options.visuals) {
|
|
149
|
+
if (step.action.type === "goto") {
|
|
150
|
+
// The new document has none of the tutorial's DOM.
|
|
151
|
+
await ensureVisuals(step.id);
|
|
152
|
+
}
|
|
153
|
+
await applyPostActionVisuals(options, step, cursorTarget, visualWarnings, sleep);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (actionPhaseCompleted) {
|
|
157
|
+
const assertionResults = await runStepAssertions(step, options);
|
|
158
|
+
result.assertions = assertionResults;
|
|
159
|
+
const failedAssertions = assertionResults.filter((assertion) => assertion.status === "failed");
|
|
160
|
+
if (failedAssertions.length > 0) {
|
|
161
|
+
result.status = "failed";
|
|
162
|
+
result.error = `Step ${JSON.stringify(step.id)} failed because ${failedAssertions.length} of ${assertionResults.length} assertions failed: ${failedAssertions
|
|
163
|
+
.map((assertion) => `${assertion.type}: ${assertion.error ?? "unknown error"}`)
|
|
164
|
+
.join("; ")}`;
|
|
165
|
+
}
|
|
166
|
+
if (options.visuals) {
|
|
167
|
+
await applyStepOverlays(options, step, visualWarnings);
|
|
168
|
+
}
|
|
169
|
+
if (step.pauseAfterMs !== undefined && step.pauseAfterMs > 0) {
|
|
170
|
+
await sleep(step.pauseAfterMs);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (step.screenshot) {
|
|
174
|
+
screenshots.push(await captureStepScreenshot(options, step, actionPhaseCompleted));
|
|
175
|
+
}
|
|
176
|
+
const endedAtMs = Date.now();
|
|
177
|
+
result.endedAt = new Date(endedAtMs).toISOString();
|
|
178
|
+
result.durationMs = endedAtMs - startedAtMs;
|
|
179
|
+
result.timelineEndMs = Math.max(timelineStartMs, now() - timelineOrigin);
|
|
180
|
+
steps.push(result);
|
|
181
|
+
if (result.status === "failed") {
|
|
182
|
+
failedStepId = step.id;
|
|
183
|
+
failureMessage = result.error;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (options.visuals) {
|
|
187
|
+
await runVisual(visualWarnings, "<cleanup>", "remove tutorial visuals", () => removeTutorialVisuals(options.page));
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
steps,
|
|
191
|
+
screenshots,
|
|
192
|
+
visualWarnings,
|
|
193
|
+
...(failedStepId !== undefined ? { failedStepId } : {}),
|
|
194
|
+
...(failureMessage !== undefined ? { error: failureMessage } : {})
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Moves the synthetic cursor onto the action's target before the real action.
|
|
199
|
+
*
|
|
200
|
+
* `goto` and `wait-for` have no pointer target, so the cursor is left alone. A
|
|
201
|
+
* missing bounding box is recorded as a visual warning and the real action still
|
|
202
|
+
* runs -- presentation never gates correctness.
|
|
203
|
+
*/
|
|
204
|
+
async function moveCursorForAction(options, action, warnings, stepId, sleep) {
|
|
205
|
+
const locator = cursorLocatorForAction(action);
|
|
206
|
+
if (!locator) {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
const box = await readBoundingBox(options.page, locator, warnings, stepId);
|
|
210
|
+
if (!box) {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
let point;
|
|
214
|
+
let pointerDragEnd;
|
|
215
|
+
try {
|
|
216
|
+
if (action.type === "pointer-click") {
|
|
217
|
+
point = resolveTutorialFractionPoint(box, action.position);
|
|
218
|
+
}
|
|
219
|
+
else if (action.type === "pointer-drag") {
|
|
220
|
+
point = resolveTutorialFractionPoint(box, action.from);
|
|
221
|
+
pointerDragEnd = resolveTutorialFractionPoint(box, action.to);
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
point = centerOfBox(box);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
warnings.push(`Step ${JSON.stringify(stepId)}: pointer visual for ${describeTutorialLocator(locator)} could not be planned: ${messageOf(error)}`);
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
await runVisual(warnings, stepId, "move cursor", () => moveTutorialCursor(options.page, point));
|
|
232
|
+
await sleep(CURSOR_MOVE_DURATION_MS);
|
|
233
|
+
return { point, ...(pointerDragEnd ? { pointerDragEnd } : {}) };
|
|
234
|
+
}
|
|
235
|
+
/** The locator whose center the cursor points at before each action type. */
|
|
236
|
+
export function cursorLocatorForAction(action) {
|
|
237
|
+
switch (action.type) {
|
|
238
|
+
case "click":
|
|
239
|
+
case "fill":
|
|
240
|
+
case "press":
|
|
241
|
+
case "hover":
|
|
242
|
+
case "pointer-click":
|
|
243
|
+
case "pointer-drag":
|
|
244
|
+
return action.locator;
|
|
245
|
+
case "drag":
|
|
246
|
+
return action.source;
|
|
247
|
+
// goto has no element target; wait-for deliberately does not move the cursor.
|
|
248
|
+
case "goto":
|
|
249
|
+
case "wait-for":
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async function applyPostActionVisuals(options, step, cursorTarget, warnings, sleep) {
|
|
254
|
+
const action = step.action;
|
|
255
|
+
if (!action) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if ((action.type === "click" || action.type === "pointer-click") && cursorTarget) {
|
|
259
|
+
await runVisual(warnings, step.id, "click feedback", () => showTutorialClickFeedback(options.page, cursorTarget.point));
|
|
260
|
+
// Held only long enough for the ripple to be visible in the recording.
|
|
261
|
+
await sleep(CLICK_FEEDBACK_DURATION_MS);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (action.type === "drag") {
|
|
265
|
+
// The cursor follows to the drop target after the real drag completed.
|
|
266
|
+
const box = await readBoundingBox(options.page, action.target, warnings, step.id);
|
|
267
|
+
if (box) {
|
|
268
|
+
await runVisual(warnings, step.id, "move cursor to drag target", () => moveTutorialCursor(options.page, centerOfBox(box)));
|
|
269
|
+
await sleep(CURSOR_MOVE_DURATION_MS);
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (action.type === "pointer-drag" && cursorTarget?.pointerDragEnd) {
|
|
274
|
+
await runVisual(warnings, step.id, "move cursor to pointer-drag endpoint", () => moveTutorialCursor(options.page, cursorTarget.pointerDragEnd));
|
|
275
|
+
await sleep(CURSOR_MOVE_DURATION_MS);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
async function applyStepOverlays(options, step, warnings) {
|
|
279
|
+
if (step.highlight) {
|
|
280
|
+
const box = await readBoundingBox(options.page, step.highlight, warnings, step.id);
|
|
281
|
+
if (box) {
|
|
282
|
+
await runVisual(warnings, step.id, "apply highlight", () => applyTutorialHighlight(options.page, box));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (step.callout) {
|
|
286
|
+
const box = step.callout.locator
|
|
287
|
+
? await readBoundingBox(options.page, step.callout.locator, warnings, step.id)
|
|
288
|
+
: undefined;
|
|
289
|
+
const viewport = options.scenario.browser.viewport;
|
|
290
|
+
const placement = resolveCalloutPlacement(step.callout.placement, box, viewport);
|
|
291
|
+
await runVisual(warnings, step.id, "apply callout", () => applyTutorialCallout(options.page, {
|
|
292
|
+
text: step.callout.text,
|
|
293
|
+
placement,
|
|
294
|
+
box: box ?? null,
|
|
295
|
+
viewport
|
|
296
|
+
}));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Captures a requested screenshot from the already-open tutorial page.
|
|
301
|
+
*
|
|
302
|
+
* Never launches a browser and never re-navigates: the point of the capture is
|
|
303
|
+
* the live state this session just produced.
|
|
304
|
+
*/
|
|
305
|
+
async function captureStepScreenshot(options, step, actionPhaseCompleted) {
|
|
306
|
+
const screenshot = step.screenshot;
|
|
307
|
+
if (!options.paths) {
|
|
308
|
+
return {
|
|
309
|
+
kind: "screenshot",
|
|
310
|
+
id: screenshot.id,
|
|
311
|
+
status: "failed",
|
|
312
|
+
error: `Screenshot ${JSON.stringify(screenshot.id)} was requested but the tutorial run has no screenshot output directory.`
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
const absolutePath = buildScreenshotPath(options.paths, screenshot.id);
|
|
316
|
+
const relativePath = toRunRelativePosixPath(options.paths.runRoot, absolutePath);
|
|
317
|
+
if (!actionPhaseCompleted) {
|
|
318
|
+
// The step never reached a usable state; fabricating an image would misrepresent it.
|
|
319
|
+
return {
|
|
320
|
+
kind: "screenshot",
|
|
321
|
+
id: screenshot.id,
|
|
322
|
+
status: "skipped",
|
|
323
|
+
path: relativePath,
|
|
324
|
+
error: `Screenshot ${JSON.stringify(screenshot.id)} was not captured because step ${JSON.stringify(step.id)} failed before reaching a usable state.`
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
if (existsSync(absolutePath)) {
|
|
328
|
+
// Canonical screenshot identity is stable; silently overwriting or adding a
|
|
329
|
+
// suffix would both hide a real problem.
|
|
330
|
+
return {
|
|
331
|
+
kind: "screenshot",
|
|
332
|
+
id: screenshot.id,
|
|
333
|
+
status: "failed",
|
|
334
|
+
path: relativePath,
|
|
335
|
+
error: `Screenshot ${JSON.stringify(screenshot.id)} already exists at ${relativePath}; refusing to overwrite a canonical tutorial artifact.`
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
await options.page.screenshot({ path: absolutePath, fullPage: screenshot.fullPage ?? false });
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
return {
|
|
343
|
+
kind: "screenshot",
|
|
344
|
+
id: screenshot.id,
|
|
345
|
+
status: "failed",
|
|
346
|
+
path: relativePath,
|
|
347
|
+
error: `Screenshot ${JSON.stringify(screenshot.id)} could not be captured: ${messageOf(error)}`
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
const check = await verifyArtifactFile(absolutePath);
|
|
351
|
+
if (!check.ok) {
|
|
352
|
+
return { kind: "screenshot", id: screenshot.id, status: "failed", path: relativePath, error: check.error };
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
kind: "screenshot",
|
|
356
|
+
id: screenshot.id,
|
|
357
|
+
status: "written",
|
|
358
|
+
path: relativePath,
|
|
359
|
+
sizeBytes: check.sizeBytes
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
async function readBoundingBox(page, locator, warnings, stepId) {
|
|
363
|
+
try {
|
|
364
|
+
const box = await resolveTutorialLocator(page, locator).boundingBox();
|
|
365
|
+
if (!box) {
|
|
366
|
+
warnings.push(`Step ${JSON.stringify(stepId)}: ${describeTutorialLocator(locator)} has no bounding box, so its tutorial visual was skipped.`);
|
|
367
|
+
return undefined;
|
|
368
|
+
}
|
|
369
|
+
return box;
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
warnings.push(`Step ${JSON.stringify(stepId)}: bounding box for ${describeTutorialLocator(locator)} could not be read: ${messageOf(error)}`);
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Runs one presentation operation. A failure here is recorded as a visual
|
|
378
|
+
* warning, never swallowed and never promoted into an action/assertion outcome.
|
|
379
|
+
*/
|
|
380
|
+
async function runVisual(warnings, stepId, description, operation) {
|
|
381
|
+
try {
|
|
382
|
+
await operation();
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
warnings.push(`Step ${JSON.stringify(stepId)}: tutorial visual "${description}" failed: ${messageOf(error)}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
async function runStepAssertions(step, options) {
|
|
389
|
+
const results = [];
|
|
390
|
+
for (const assertion of step.assertions ?? []) {
|
|
391
|
+
results.push(await executeTutorialAssertion(options.page, assertion, {
|
|
392
|
+
applicationUrl: options.applicationUrl,
|
|
393
|
+
targetRoot: options.targetRoot
|
|
394
|
+
}));
|
|
395
|
+
}
|
|
396
|
+
return results;
|
|
397
|
+
}
|
|
398
|
+
function notRunStepResult(step) {
|
|
399
|
+
return {
|
|
400
|
+
id: step.id,
|
|
401
|
+
status: "not-run",
|
|
402
|
+
assertions: [],
|
|
403
|
+
screenshotRequested: step.screenshot !== undefined,
|
|
404
|
+
highlightRequested: step.highlight !== undefined,
|
|
405
|
+
calloutRequested: step.callout !== undefined
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
function defaultSleep(ms) {
|
|
409
|
+
return new Promise((resolve) => {
|
|
410
|
+
setTimeout(resolve, ms);
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
async function closeQuietly(close) {
|
|
414
|
+
try {
|
|
415
|
+
await close();
|
|
416
|
+
return undefined;
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
return messageOf(error);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
function messageOf(error) {
|
|
423
|
+
return error instanceof Error ? error.message : String(error);
|
|
424
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v0.4.7 tutorial domain contracts.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is declarative and serializable by design. A scenario can
|
|
5
|
+
* describe what to do, but it can never carry JavaScript, a shell command, or
|
|
6
|
+
* an arbitrary URL -- those escape hatches are deliberately absent from the
|
|
7
|
+
* union types below and are rejected by validation, not merely undocumented.
|
|
8
|
+
*/
|
|
9
|
+
export const TUTORIAL_SCHEMA_VERSION = "1.0.0";
|
|
10
|
+
export const TARGET_CONTRACT_SCHEMA_VERSION = "1.0.0";
|
|
11
|
+
/** Result schema version for TutorialRunResultV1. */
|
|
12
|
+
export const TUTORIAL_RUN_RESULT_SCHEMA_VERSION = "1.0.0";
|
|
13
|
+
export const MAX_TUTORIAL_STEPS = 500;
|
|
14
|
+
export const MAX_STEP_PAUSE_MS = 60_000;
|
|
15
|
+
export const MIN_VIEWPORT_WIDTH = 320;
|
|
16
|
+
export const MAX_VIEWPORT_WIDTH = 3840;
|
|
17
|
+
export const MIN_VIEWPORT_HEIGHT = 240;
|
|
18
|
+
export const MAX_VIEWPORT_HEIGHT = 2160;
|
|
19
|
+
export const DEFAULT_TUTORIAL_ACTION_TIMEOUT_MS = 5_000;
|
|
20
|
+
export const DEFAULT_TUTORIAL_ASSERTION_TIMEOUT_MS = 5_000;
|
|
21
|
+
/** Stable, path-safe, lowercase identifier shape shared by every tutorial id. */
|
|
22
|
+
export const TUTORIAL_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
|
|
23
|
+
/** The only placeholder a trusted target contract may use. */
|
|
24
|
+
export const TARGET_ROOT_PLACEHOLDER = "{{targetRoot}}";
|
|
25
|
+
export const TUTORIAL_LOCATOR_KINDS = ["role", "text", "css", "test-id"];
|
|
26
|
+
export const TUTORIAL_POINTER_COORDINATE_SPACES = ["fraction"];
|
|
27
|
+
export const TUTORIAL_ACTION_TYPES = [
|
|
28
|
+
"goto",
|
|
29
|
+
"click",
|
|
30
|
+
"fill",
|
|
31
|
+
"press",
|
|
32
|
+
"hover",
|
|
33
|
+
"drag",
|
|
34
|
+
"wait-for",
|
|
35
|
+
"pointer-click",
|
|
36
|
+
"pointer-drag"
|
|
37
|
+
];
|
|
38
|
+
export const TUTORIAL_GOTO_WAIT_UNTIL = ["load", "domcontentloaded", "networkidle"];
|
|
39
|
+
export const TUTORIAL_WAIT_FOR_STATES = ["visible", "hidden", "attached", "detached"];
|
|
40
|
+
export const TUTORIAL_ASSERTION_TYPES = [
|
|
41
|
+
"element-visible",
|
|
42
|
+
"text-equals",
|
|
43
|
+
"text-contains",
|
|
44
|
+
"url-path-equals",
|
|
45
|
+
"attribute-equals",
|
|
46
|
+
"http-json-equals",
|
|
47
|
+
"json-file-equals",
|
|
48
|
+
"file-exists"
|
|
49
|
+
];
|
|
50
|
+
export const TUTORIAL_CALLOUT_PLACEMENTS = ["auto", "top", "right", "bottom", "left"];
|
|
51
|
+
export const TUTORIAL_TARGET_PROCESS_CWDS = ["contract-root", "target-root"];
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { TUTORIAL_ID_PATTERN } from "./types.js";
|
|
2
|
+
export function createErrorCollector(source) {
|
|
3
|
+
const errors = [];
|
|
4
|
+
return {
|
|
5
|
+
add(location, message) {
|
|
6
|
+
errors.push(`${source} validation failed at ${location}: ${message}`);
|
|
7
|
+
},
|
|
8
|
+
get errors() {
|
|
9
|
+
return errors;
|
|
10
|
+
},
|
|
11
|
+
get length() {
|
|
12
|
+
return errors.length;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function isPlainObject(value) {
|
|
17
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
export function isNonEmptyString(value) {
|
|
20
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
21
|
+
}
|
|
22
|
+
export function isStableId(value) {
|
|
23
|
+
return typeof value === "string" && TUTORIAL_ID_PATTERN.test(value);
|
|
24
|
+
}
|
|
25
|
+
/** Finite positive integer. Rejects NaN, Infinity, zero, negatives and floats. */
|
|
26
|
+
export function isPositiveInteger(value) {
|
|
27
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
28
|
+
}
|
|
29
|
+
/** Finite non-negative integer. Rejects NaN, Infinity, negatives and floats. */
|
|
30
|
+
export function isNonNegativeInteger(value) {
|
|
31
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
32
|
+
}
|
|
33
|
+
export function isJsonScalar(value) {
|
|
34
|
+
return (value === null ||
|
|
35
|
+
typeof value === "string" ||
|
|
36
|
+
typeof value === "boolean" ||
|
|
37
|
+
(typeof value === "number" && Number.isFinite(value)));
|
|
38
|
+
}
|
|
39
|
+
export function describeValue(value) {
|
|
40
|
+
if (value === undefined)
|
|
41
|
+
return "undefined";
|
|
42
|
+
if (value === null)
|
|
43
|
+
return "null";
|
|
44
|
+
if (typeof value === "string")
|
|
45
|
+
return JSON.stringify(value);
|
|
46
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
47
|
+
return String(value);
|
|
48
|
+
if (Array.isArray(value))
|
|
49
|
+
return "an array";
|
|
50
|
+
if (typeof value === "object")
|
|
51
|
+
return "an object";
|
|
52
|
+
return typeof value;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Enforces the exact supported schema version. Prompt 2 supports 1.0.0 only and
|
|
56
|
+
* never reinterprets or auto-upgrades another version, including another minor.
|
|
57
|
+
*/
|
|
58
|
+
export function validateSchemaVersion(errors, value, supported) {
|
|
59
|
+
if (value === undefined) {
|
|
60
|
+
errors.add("schemaVersion", `missing required schemaVersion; expected "${supported}".`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (typeof value !== "string") {
|
|
64
|
+
errors.add("schemaVersion", `expected the string "${supported}", received ${describeValue(value)}.`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (value !== supported) {
|
|
68
|
+
errors.add("schemaVersion", `unsupported schemaVersion ${JSON.stringify(value)}; this build supports "${supported}" only.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export function validateStableId(errors, location, value) {
|
|
72
|
+
if (!isNonEmptyString(value)) {
|
|
73
|
+
errors.add(location, `expected a non-empty id string, received ${describeValue(value)}.`);
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
if (!isStableId(value)) {
|
|
77
|
+
errors.add(location, `id ${JSON.stringify(value)} must match ${String(TUTORIAL_ID_PATTERN)} (lowercase letters, digits, dot, underscore, hyphen; must start with a letter or digit).`);
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
export function validateStringRecord(errors, location, value) {
|
|
83
|
+
if (value === undefined) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
if (!isPlainObject(value)) {
|
|
87
|
+
errors.add(location, `expected an object of string values, received ${describeValue(value)}.`);
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
let valid = true;
|
|
91
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
92
|
+
if (key.length === 0) {
|
|
93
|
+
errors.add(location, "environment variable names must not be empty.");
|
|
94
|
+
valid = false;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (typeof entry !== "string") {
|
|
98
|
+
errors.add(`${location}.${key}`, `expected a string value, received ${describeValue(entry)}.`);
|
|
99
|
+
valid = false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return valid ? value : undefined;
|
|
103
|
+
}
|
|
104
|
+
export function validateStringArray(errors, location, value) {
|
|
105
|
+
if (value === undefined) {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
if (!Array.isArray(value)) {
|
|
109
|
+
errors.add(location, `expected an array of strings, received ${describeValue(value)}.`);
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
let valid = true;
|
|
113
|
+
value.forEach((entry, index) => {
|
|
114
|
+
if (typeof entry !== "string") {
|
|
115
|
+
errors.add(`${location}[${index}]`, `expected a string, received ${describeValue(entry)}.`);
|
|
116
|
+
valid = false;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
return valid ? value : undefined;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Optional timeout fields must be finite positive integers when supplied. Zero,
|
|
123
|
+
* negative values, NaN and Infinity are all rejected rather than silently
|
|
124
|
+
* coerced to a default.
|
|
125
|
+
*/
|
|
126
|
+
export function validateOptionalTimeout(errors, location, value) {
|
|
127
|
+
if (value === undefined) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!isPositiveInteger(value)) {
|
|
131
|
+
errors.add(location, `expected a finite positive integer number of milliseconds, received ${describeValue(value)}.`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
export function rejectUnknownKeys(errors, location, value, allowed) {
|
|
135
|
+
const allowedSet = new Set(allowed);
|
|
136
|
+
for (const key of Object.keys(value)) {
|
|
137
|
+
if (!allowedSet.has(key)) {
|
|
138
|
+
errors.add(`${location}.${key}`, `unknown field ${JSON.stringify(key)} is not part of this contract.`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|