@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.
- package/CHANGELOG.md +11 -2
- package/README.md +7 -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 +16 -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 +383 -0
- package/dist/src/tutorial/subtitleWriter.js +88 -0
- package/dist/src/tutorial/targetContractValidation.js +212 -0
- package/dist/src/tutorial/tutorialActions.js +110 -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/tutorialSession.js +398 -0
- package/dist/src/tutorial/types.js +48 -0
- package/dist/src/tutorial/validationPrimitives.js +141 -0
- package/examples/tutorial-browser/index.html +47 -0
- package/examples/tutorial-browser/prepare.mjs +14 -0
- package/examples/tutorial-browser/scenario.json +17 -0
- package/examples/tutorial-browser/server.mjs +14 -0
- 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
|
+
}
|