@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,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,16 @@
|
|
|
1
|
+
/** Converts a validated locator-relative fraction into page coordinates. */
|
|
2
|
+
export function resolveTutorialFractionPoint(box, point) {
|
|
3
|
+
if (![box.x, box.y, box.width, box.height].every(Number.isFinite)) {
|
|
4
|
+
throw new Error("Tutorial pointer bounding box values must all be finite.");
|
|
5
|
+
}
|
|
6
|
+
if (box.width <= 0 || box.height <= 0) {
|
|
7
|
+
throw new Error("Tutorial pointer bounding box must have positive width and height.");
|
|
8
|
+
}
|
|
9
|
+
if (!Number.isFinite(point.x) || !Number.isFinite(point.y) || point.x < 0 || point.x > 1 || point.y < 0 || point.y > 1) {
|
|
10
|
+
throw new Error("Tutorial pointer fractions must be finite numbers within inclusive bounds [0, 1].");
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
x: box.x + box.width * point.x,
|
|
14
|
+
y: box.y + box.height * point.y
|
|
15
|
+
};
|
|
16
|
+
}
|