@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +25 -2
  2. package/README.md +8 -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 +17 -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 +427 -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 +175 -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/tutorialPointerGeometry.js +16 -0
  32. package/dist/src/tutorial/tutorialSession.js +424 -0
  33. package/dist/src/tutorial/types.js +51 -0
  34. package/dist/src/tutorial/validationPrimitives.js +141 -0
  35. package/examples/tutorial-browser/index.html +109 -0
  36. package/examples/tutorial-browser/prepare.mjs +14 -0
  37. package/examples/tutorial-browser/scenario.json +19 -0
  38. package/examples/tutorial-browser/server.mjs +14 -0
  39. package/package.json +8 -4
@@ -0,0 +1,212 @@
1
+ import { TARGET_CONTRACT_SCHEMA_VERSION, TARGET_ROOT_PLACEHOLDER, TUTORIAL_TARGET_PROCESS_CWDS } from "./types.js";
2
+ import { createErrorCollector, describeValue, isNonEmptyString, isPlainObject, isPositiveInteger, rejectUnknownKeys, validateSchemaVersion, validateStableId, validateStringArray, validateStringRecord } from "./validationPrimitives.js";
3
+ const CONTRACT_KEYS = ["schemaVersion", "id", "prepare", "processes", "applicationUrl"];
4
+ const PREPARE_KEYS = ["executable", "args", "env"];
5
+ const PROCESS_KEYS = ["id", "executable", "args", "cwd", "env", "readiness"];
6
+ const READINESS_KEYS = [
7
+ "kind",
8
+ "url",
9
+ "timeoutMs",
10
+ "intervalMs",
11
+ "requestTimeoutMs",
12
+ "acceptedStatusMin",
13
+ "acceptedStatusMax"
14
+ ];
15
+ /**
16
+ * Fields that would turn a declarative target contract into a shell. They are
17
+ * already excluded by the closed key lists, but they are named explicitly so the
18
+ * failure says why rather than only "unknown field".
19
+ */
20
+ const FORBIDDEN_EXECUTION_FIELDS = ["shell", "commandString", "script", "javascript", "eval", "command"];
21
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
22
+ /**
23
+ * Validates a parsed JSON value as a TutorialTargetContractV1.
24
+ *
25
+ * The target contract is the trusted half of the tutorial system: it is the only
26
+ * place that may name an executable. That trust is bounded here -- commands are
27
+ * executable-plus-arguments only, the single permitted placeholder is
28
+ * {{targetRoot}}, and the application origin must be loopback.
29
+ */
30
+ export function validateTutorialTargetContract(value, source = "Target contract") {
31
+ const errors = createErrorCollector(source);
32
+ if (!isPlainObject(value)) {
33
+ errors.add("<root>", `expected a JSON object, received ${describeValue(value)}.`);
34
+ return { ok: false, errors: errors.errors };
35
+ }
36
+ rejectForbiddenExecutionFields(errors, "<root>", value);
37
+ rejectUnknownKeys(errors, "<root>", value, CONTRACT_KEYS);
38
+ validateSchemaVersion(errors, value.schemaVersion, TARGET_CONTRACT_SCHEMA_VERSION);
39
+ validateStableId(errors, "id", value.id);
40
+ validatePrepare(errors, value.prepare);
41
+ validateProcesses(errors, value.processes);
42
+ validateApplicationUrl(errors, value.applicationUrl);
43
+ if (errors.length > 0) {
44
+ return { ok: false, errors: errors.errors };
45
+ }
46
+ return { ok: true, value: value };
47
+ }
48
+ function rejectForbiddenExecutionFields(errors, location, value) {
49
+ for (const field of FORBIDDEN_EXECUTION_FIELDS) {
50
+ if (field in value) {
51
+ errors.add(`${location}.${field}`, `field ${JSON.stringify(field)} is not supported; target commands are declared as an executable plus an argument array and are never interpreted by a shell.`);
52
+ }
53
+ }
54
+ }
55
+ function validatePrepare(errors, value) {
56
+ if (value === undefined) {
57
+ errors.add("prepare", "a target contract must declare a prepare command.");
58
+ return;
59
+ }
60
+ if (!isPlainObject(value)) {
61
+ errors.add("prepare", `expected a prepare command object, received ${describeValue(value)}.`);
62
+ return;
63
+ }
64
+ rejectForbiddenExecutionFields(errors, "prepare", value);
65
+ rejectUnknownKeys(errors, "prepare", value, PREPARE_KEYS);
66
+ if (!isNonEmptyString(value.executable)) {
67
+ errors.add("prepare.executable", `expected a non-empty executable name or path, received ${describeValue(value.executable)}.`);
68
+ }
69
+ const args = validateStringArray(errors, "prepare.args", value.args);
70
+ validatePlaceholders(errors, "prepare.args", args);
71
+ const env = validateStringRecord(errors, "prepare.env", value.env);
72
+ validateEnvPlaceholders(errors, "prepare.env", env);
73
+ }
74
+ function validateProcesses(errors, value) {
75
+ if (!Array.isArray(value)) {
76
+ errors.add("processes", `expected an array of managed processes, received ${describeValue(value)}.`);
77
+ return;
78
+ }
79
+ if (value.length === 0) {
80
+ errors.add("processes", "a target contract must declare at least one managed process.");
81
+ return;
82
+ }
83
+ const processIds = new Set();
84
+ value.forEach((entry, index) => {
85
+ validateProcess(errors, `processes[${index}]`, entry, processIds);
86
+ });
87
+ }
88
+ function validateProcess(errors, location, value, processIds) {
89
+ if (!isPlainObject(value)) {
90
+ errors.add(location, `expected a managed-process object, received ${describeValue(value)}.`);
91
+ return;
92
+ }
93
+ rejectForbiddenExecutionFields(errors, location, value);
94
+ rejectUnknownKeys(errors, location, value, PROCESS_KEYS);
95
+ if (validateStableId(errors, `${location}.id`, value.id)) {
96
+ if (processIds.has(value.id)) {
97
+ errors.add(`${location}.id`, `duplicate process id ${JSON.stringify(value.id)}.`);
98
+ }
99
+ processIds.add(value.id);
100
+ }
101
+ if (!isNonEmptyString(value.executable)) {
102
+ errors.add(`${location}.executable`, `expected a non-empty executable name or path, received ${describeValue(value.executable)}.`);
103
+ }
104
+ const args = validateStringArray(errors, `${location}.args`, value.args);
105
+ validatePlaceholders(errors, `${location}.args`, args);
106
+ const env = validateStringRecord(errors, `${location}.env`, value.env);
107
+ validateEnvPlaceholders(errors, `${location}.env`, env);
108
+ if (value.cwd !== undefined && !TUTORIAL_TARGET_PROCESS_CWDS.includes(value.cwd)) {
109
+ errors.add(`${location}.cwd`, `expected one of ${TUTORIAL_TARGET_PROCESS_CWDS.join(", ")}, received ${describeValue(value.cwd)}.`);
110
+ }
111
+ validateReadiness(errors, `${location}.readiness`, value.readiness);
112
+ }
113
+ function validateReadiness(errors, location, value) {
114
+ if (!isPlainObject(value)) {
115
+ errors.add(location, `expected a readiness probe object, received ${describeValue(value)}.`);
116
+ return;
117
+ }
118
+ rejectUnknownKeys(errors, location, value, READINESS_KEYS);
119
+ if (value.kind !== "http") {
120
+ errors.add(`${location}.kind`, `expected "http", received ${describeValue(value.kind)}.`);
121
+ }
122
+ validateLoopbackUrl(errors, `${location}.url`, value.url);
123
+ if (!isPositiveInteger(value.timeoutMs)) {
124
+ errors.add(`${location}.timeoutMs`, `expected a finite positive integer number of milliseconds, received ${describeValue(value.timeoutMs)}.`);
125
+ }
126
+ for (const optional of ["intervalMs", "requestTimeoutMs"]) {
127
+ if (value[optional] !== undefined && !isPositiveInteger(value[optional])) {
128
+ errors.add(`${location}.${optional}`, `expected a finite positive integer number of milliseconds, received ${describeValue(value[optional])}.`);
129
+ }
130
+ }
131
+ for (const optional of ["acceptedStatusMin", "acceptedStatusMax"]) {
132
+ if (value[optional] !== undefined && !isPositiveInteger(value[optional])) {
133
+ errors.add(`${location}.${optional}`, `expected a finite positive integer HTTP status code, received ${describeValue(value[optional])}.`);
134
+ }
135
+ }
136
+ const min = value.acceptedStatusMin;
137
+ const max = value.acceptedStatusMax;
138
+ if (isPositiveInteger(min) && isPositiveInteger(max) && min > max) {
139
+ errors.add(location, `accepted status range is inverted: ${min}..${max}.`);
140
+ }
141
+ }
142
+ function validateApplicationUrl(errors, value) {
143
+ validateLoopbackUrl(errors, "applicationUrl", value);
144
+ }
145
+ /**
146
+ * A tutorial may only drive a local application. Restricting the declared origin
147
+ * to loopback here is what makes the later same-origin navigation and HTTP
148
+ * assertion checks meaningful: they can only ever resolve to this machine.
149
+ */
150
+ function validateLoopbackUrl(errors, location, value) {
151
+ if (!isNonEmptyString(value)) {
152
+ errors.add(location, `expected a non-empty URL string, received ${describeValue(value)}.`);
153
+ return;
154
+ }
155
+ let parsed;
156
+ try {
157
+ parsed = new URL(value);
158
+ }
159
+ catch {
160
+ errors.add(location, `expected a valid absolute URL, received ${JSON.stringify(value)}.`);
161
+ return;
162
+ }
163
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
164
+ errors.add(location, `expected an http: or https: URL, received protocol ${parsed.protocol}.`);
165
+ return;
166
+ }
167
+ const hostname = parsed.hostname.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
168
+ if (!LOOPBACK_HOSTS.has(hostname)) {
169
+ errors.add(location, `must use a loopback hostname (localhost, 127.0.0.1, ::1); received ${parsed.hostname}.`);
170
+ }
171
+ }
172
+ function validatePlaceholders(errors, location, values) {
173
+ if (!values) {
174
+ return;
175
+ }
176
+ values.forEach((entry, index) => {
177
+ validatePlaceholderText(errors, `${location}[${index}]`, entry);
178
+ });
179
+ }
180
+ function validateEnvPlaceholders(errors, location, env) {
181
+ if (!env) {
182
+ return;
183
+ }
184
+ for (const [key, entry] of Object.entries(env)) {
185
+ validatePlaceholderText(errors, `${location}.${key}`, entry);
186
+ }
187
+ }
188
+ /**
189
+ * Only {{targetRoot}} is substituted. Every other `{{...}}` occurrence is a
190
+ * validation error rather than a literal, so a contract can never rely on a
191
+ * generic template evaluator that this repository deliberately does not have.
192
+ */
193
+ function validatePlaceholderText(errors, location, value) {
194
+ const placeholderPattern = /\{\{([^}]*)\}\}/g;
195
+ let match;
196
+ while ((match = placeholderPattern.exec(value)) !== null) {
197
+ const whole = match[0];
198
+ if (whole !== TARGET_ROOT_PLACEHOLDER) {
199
+ errors.add(location, `unsupported placeholder ${JSON.stringify(whole)}; ${TARGET_ROOT_PLACEHOLDER} is the only placeholder this contract supports.`);
200
+ }
201
+ }
202
+ }
203
+ /**
204
+ * Cross-contract identity check. Kept next to target-contract validation because
205
+ * it is the last gate before any target work happens.
206
+ */
207
+ export function tutorialTargetIdsMatch(scenarioTargetId, targetContractId) {
208
+ return scenarioTargetId === targetContractId;
209
+ }
210
+ export function describeTargetIdMismatch(scenarioTargetId, targetContractId) {
211
+ return `Tutorial scenario targetId ${JSON.stringify(scenarioTargetId)} does not match target contract id ${JSON.stringify(targetContractId)}.`;
212
+ }
@@ -0,0 +1,175 @@
1
+ import { describeTutorialLocator, resolveTutorialLocator } from "./tutorialLocators.js";
2
+ import { resolveTutorialFractionPoint } from "./tutorialPointerGeometry.js";
3
+ import { DEFAULT_TUTORIAL_ACTION_TIMEOUT_MS } from "./types.js";
4
+ export const POINTER_DRAG_MOVE_STEPS = 8;
5
+ /**
6
+ * Resolves a scenario `goto` path against the target application's URL and
7
+ * proves the result never leaves that origin.
8
+ *
9
+ * Validation already rejects absolute and protocol-relative paths, so this is
10
+ * the second, authoritative gate: whatever the string is, the resolved URL must
11
+ * match the application's protocol, hostname and port exactly or navigation is
12
+ * refused.
13
+ */
14
+ export function resolveTutorialNavigationUrl(applicationUrl, suppliedPath) {
15
+ if (typeof suppliedPath !== "string" || !suppliedPath.startsWith("/") || suppliedPath.startsWith("//")) {
16
+ throw new Error(`Tutorial goto path must be a root-relative path beginning with "/" and must not be protocol-relative; received ${JSON.stringify(suppliedPath)}.`);
17
+ }
18
+ const base = new URL(applicationUrl);
19
+ const resolved = new URL(suppliedPath, base);
20
+ if (resolved.protocol !== base.protocol || resolved.hostname !== base.hostname || resolved.port !== base.port) {
21
+ throw new Error(`Tutorial goto path ${JSON.stringify(suppliedPath)} resolves to ${resolved.origin}, which leaves the application origin ${base.origin}.`);
22
+ }
23
+ return resolved.href;
24
+ }
25
+ /**
26
+ * Executes one declarative action and always returns a structured result.
27
+ *
28
+ * There is intentionally no retry loop here: Playwright's own per-call timeout
29
+ * is the single waiting mechanism, so a step's duration stays predictable and a
30
+ * flaky scenario cannot be masked by silent repetition.
31
+ */
32
+ export async function executeTutorialAction(page, action, context) {
33
+ const startedAtMs = Date.now();
34
+ const startedAt = new Date(startedAtMs).toISOString();
35
+ try {
36
+ await performAction(page, action, context);
37
+ return finish(action.type, "passed", startedAt, startedAtMs);
38
+ }
39
+ catch (error) {
40
+ return {
41
+ ...finish(action.type, "failed", startedAt, startedAtMs),
42
+ error: describeActionError(action, error)
43
+ };
44
+ }
45
+ }
46
+ async function performAction(page, action, context) {
47
+ switch (action.type) {
48
+ case "goto": {
49
+ const url = resolveTutorialNavigationUrl(context.applicationUrl, action.path);
50
+ await page.goto(url, action.waitUntil !== undefined ? { waitUntil: action.waitUntil } : undefined);
51
+ return;
52
+ }
53
+ case "click":
54
+ await resolveTutorialLocator(page, action.locator).click({ timeout: timeoutFor(action.timeoutMs) });
55
+ return;
56
+ case "fill":
57
+ await resolveTutorialLocator(page, action.locator).fill(action.value, {
58
+ timeout: timeoutFor(action.timeoutMs)
59
+ });
60
+ return;
61
+ case "press":
62
+ await resolveTutorialLocator(page, action.locator).press(action.key, {
63
+ timeout: timeoutFor(action.timeoutMs)
64
+ });
65
+ return;
66
+ case "hover":
67
+ await resolveTutorialLocator(page, action.locator).hover({ timeout: timeoutFor(action.timeoutMs) });
68
+ return;
69
+ case "drag": {
70
+ // Both ends go through the canonical resolver so a drag cannot develop its
71
+ // own locator semantics.
72
+ const source = resolveTutorialLocator(page, action.source);
73
+ const target = resolveTutorialLocator(page, action.target);
74
+ await source.dragTo(target, { timeout: timeoutFor(action.timeoutMs) });
75
+ return;
76
+ }
77
+ case "pointer-click": {
78
+ const locator = resolveTutorialLocator(page, action.locator);
79
+ await locator.waitFor({ state: "visible", timeout: timeoutFor(action.timeoutMs) });
80
+ const box = await locator.boundingBox();
81
+ if (!box) {
82
+ throw new Error("locator has no bounding box");
83
+ }
84
+ const point = resolveTutorialFractionPoint(box, action.position);
85
+ await page.mouse.move(point.x, point.y);
86
+ await runWithMouseDown(page.mouse, async () => { });
87
+ return;
88
+ }
89
+ case "pointer-drag": {
90
+ const locator = resolveTutorialLocator(page, action.locator);
91
+ await locator.waitFor({ state: "visible", timeout: timeoutFor(action.timeoutMs) });
92
+ const box = await locator.boundingBox();
93
+ if (!box) {
94
+ throw new Error("locator has no bounding box");
95
+ }
96
+ const start = resolveTutorialFractionPoint(box, action.from);
97
+ const end = resolveTutorialFractionPoint(box, action.to);
98
+ await page.mouse.move(start.x, start.y);
99
+ await runWithMouseDown(page.mouse, () => page.mouse.move(end.x, end.y, { steps: POINTER_DRAG_MOVE_STEPS }));
100
+ return;
101
+ }
102
+ case "wait-for":
103
+ await resolveTutorialLocator(page, action.locator).waitFor({
104
+ ...(action.state !== undefined ? { state: action.state } : {}),
105
+ timeout: timeoutFor(action.timeoutMs)
106
+ });
107
+ return;
108
+ }
109
+ }
110
+ async function runWithMouseDown(mouse, operation) {
111
+ await mouse.down();
112
+ let primaryError;
113
+ let primaryFailed = false;
114
+ try {
115
+ await operation();
116
+ }
117
+ catch (error) {
118
+ primaryFailed = true;
119
+ primaryError = error;
120
+ }
121
+ try {
122
+ await mouse.up();
123
+ }
124
+ catch (cleanupError) {
125
+ if (primaryFailed) {
126
+ const primaryMessage = messageOf(primaryError);
127
+ const cleanupMessage = messageOf(cleanupError);
128
+ throw new Error(`${primaryMessage} (mouse.up cleanup also failed: ${cleanupMessage})`, {
129
+ cause: primaryError
130
+ });
131
+ }
132
+ throw cleanupError;
133
+ }
134
+ if (primaryFailed) {
135
+ throw primaryError;
136
+ }
137
+ }
138
+ export function timeoutFor(timeoutMs) {
139
+ return timeoutMs ?? DEFAULT_TUTORIAL_ACTION_TIMEOUT_MS;
140
+ }
141
+ function finish(type, status, startedAt, startedAtMs) {
142
+ const endedAtMs = Date.now();
143
+ return {
144
+ type,
145
+ status,
146
+ startedAt,
147
+ endedAt: new Date(endedAtMs).toISOString(),
148
+ durationMs: endedAtMs - startedAtMs
149
+ };
150
+ }
151
+ function describeActionError(action, error) {
152
+ const message = error instanceof Error ? error.message : String(error);
153
+ switch (action.type) {
154
+ case "goto":
155
+ return `goto ${JSON.stringify(action.path)} failed: ${message}`;
156
+ case "drag":
157
+ return `drag from ${describeTutorialLocator(action.source)} to ${describeTutorialLocator(action.target)} failed: ${message}`;
158
+ case "pointer-click":
159
+ return `pointer-click on ${describeTutorialLocator(action.locator)} at fraction ${describeFractionPoint(action.position)} failed: ${message}`;
160
+ case "pointer-drag":
161
+ return `pointer-drag on ${describeTutorialLocator(action.locator)} from fraction ${describeFractionPoint(action.from)} to ${describeFractionPoint(action.to)} failed: ${message}`;
162
+ case "click":
163
+ case "fill":
164
+ case "press":
165
+ case "hover":
166
+ case "wait-for":
167
+ return `${action.type} on ${describeTutorialLocator(action.locator)} failed: ${message}`;
168
+ }
169
+ }
170
+ function describeFractionPoint(point) {
171
+ return `(${String(point.x)}, ${String(point.y)})`;
172
+ }
173
+ function messageOf(error) {
174
+ return error instanceof Error ? error.message : String(error);
175
+ }
@@ -0,0 +1,176 @@
1
+ import { readdir, rm, stat, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { resolveWithinRoot } from "../core/pathSafety.js";
4
+ /**
5
+ * Canonical tutorial artifact paths, writing, finalization and verification.
6
+ *
7
+ * Every artifact this module writes is contained inside the run's own
8
+ * TutorialRunPaths roots. Nothing here accepts an output path from scenario
9
+ * JSON: screenshot identity comes from the validated stable-id contract and
10
+ * everything else has a fixed canonical name.
11
+ */
12
+ export const TUTORIAL_VIDEO_FILENAME = "tutorial.webm";
13
+ export const TUTORIAL_SRT_FILENAME = "tutorial.srt";
14
+ export const TUTORIAL_VTT_FILENAME = "tutorial.vtt";
15
+ export const TUTORIAL_MARKDOWN_FILENAME = "tutorial.md";
16
+ export const TUTORIAL_MANIFEST_FILENAME = "tutorial-manifest.json";
17
+ /** Subdirectory of temporaryRoot that Playwright records raw video into. */
18
+ export const TUTORIAL_VIDEO_TEMP_DIR_NAME = "video";
19
+ export function buildTutorialArtifactPaths(paths) {
20
+ return {
21
+ video: path.join(paths.artifactsRoot, TUTORIAL_VIDEO_FILENAME),
22
+ srt: path.join(paths.artifactsRoot, TUTORIAL_SRT_FILENAME),
23
+ vtt: path.join(paths.artifactsRoot, TUTORIAL_VTT_FILENAME),
24
+ markdown: path.join(paths.artifactsRoot, TUTORIAL_MARKDOWN_FILENAME),
25
+ manifest: path.join(paths.artifactsRoot, TUTORIAL_MANIFEST_FILENAME),
26
+ videoTempDir: path.join(paths.temporaryRoot, TUTORIAL_VIDEO_TEMP_DIR_NAME)
27
+ };
28
+ }
29
+ /**
30
+ * Absolute path for a validated screenshot id, contained under screenshotsRoot.
31
+ *
32
+ * The id has already passed the stable-id pattern, so it cannot contain a
33
+ * separator or traversal; the containment check is belt-and-braces and would
34
+ * catch any future loosening of that contract.
35
+ */
36
+ export function buildScreenshotPath(paths, screenshotId) {
37
+ return resolveWithinRoot(paths.screenshotsRoot, `${screenshotId}.png`);
38
+ }
39
+ /**
40
+ * Run-root-relative POSIX path used in every stored artifact record, so a
41
+ * manifest never carries `C:\Users\...` or `/home/runner/...`.
42
+ */
43
+ export function toRunRelativePosixPath(runRoot, absolutePath) {
44
+ return path.relative(path.resolve(runRoot), path.resolve(absolutePath)).split(path.sep).join("/");
45
+ }
46
+ /** Confirms a canonical artifact is a regular file and, when required, non-empty. */
47
+ export async function verifyArtifactFile(filePath, options = {}) {
48
+ let stats;
49
+ try {
50
+ stats = await stat(filePath);
51
+ }
52
+ catch (error) {
53
+ return { ok: false, error: `Artifact file is missing: ${filePath} (${messageOf(error)})` };
54
+ }
55
+ if (!stats.isFile()) {
56
+ return { ok: false, error: `Artifact path is not a regular file: ${filePath}` };
57
+ }
58
+ if ((options.requireNonEmpty ?? true) && stats.size === 0) {
59
+ return { ok: false, error: `Artifact file is empty: ${filePath}` };
60
+ }
61
+ return { ok: true, sizeBytes: stats.size };
62
+ }
63
+ /**
64
+ * Writes one text artifact and verifies it landed, returning a record either way.
65
+ * A write failure is data, not an exception, so a run can continue to the next
66
+ * artifact and report every failure together.
67
+ */
68
+ export async function writeTextArtifact(options) {
69
+ const relativePath = toRunRelativePosixPath(options.runRoot, options.filePath);
70
+ try {
71
+ await writeFile(options.filePath, options.contents, "utf8");
72
+ }
73
+ catch (error) {
74
+ return {
75
+ kind: options.kind,
76
+ status: "failed",
77
+ path: relativePath,
78
+ error: `Could not write ${relativePath}: ${messageOf(error)}`
79
+ };
80
+ }
81
+ const check = await verifyArtifactFile(options.filePath);
82
+ if (!check.ok) {
83
+ return { kind: options.kind, status: "failed", path: relativePath, error: check.error };
84
+ }
85
+ return { kind: options.kind, status: "written", path: relativePath, sizeBytes: check.sizeBytes };
86
+ }
87
+ /**
88
+ * Saves Playwright's recording to the canonical tutorial.webm.
89
+ *
90
+ * The page must already be closed: Playwright only finalizes a recording on page
91
+ * close, so `saveAs()` before that would either hang or produce a truncated
92
+ * file. On success the raw temporary recording is removed; on failure it is
93
+ * deliberately retained (and reported) because it is the only diagnostic
94
+ * material left.
95
+ */
96
+ export async function finalizeTutorialVideo(options) {
97
+ const warnings = [];
98
+ const relativePath = toRunRelativePosixPath(options.runRoot, options.videoPath);
99
+ if (!options.video) {
100
+ return {
101
+ record: {
102
+ kind: "video",
103
+ status: "skipped",
104
+ error: "The tutorial page exposed no recorded video, so no canonical WebM was produced."
105
+ },
106
+ warnings
107
+ };
108
+ }
109
+ let rawPath;
110
+ try {
111
+ rawPath = await options.video.path();
112
+ }
113
+ catch (error) {
114
+ // Not fatal on its own: saveAs may still work.
115
+ warnings.push(`Raw tutorial video path could not be resolved: ${messageOf(error)}`);
116
+ }
117
+ try {
118
+ await options.video.saveAs(options.videoPath);
119
+ }
120
+ catch (error) {
121
+ if (rawPath) {
122
+ warnings.push(`Raw tutorial video retained for diagnosis at ${rawPath}.`);
123
+ }
124
+ return {
125
+ record: {
126
+ kind: "video",
127
+ status: "failed",
128
+ path: relativePath,
129
+ error: `Tutorial video could not be finalized to ${relativePath}: ${messageOf(error)}`
130
+ },
131
+ warnings
132
+ };
133
+ }
134
+ const check = await verifyArtifactFile(options.videoPath);
135
+ if (!check.ok) {
136
+ if (rawPath) {
137
+ warnings.push(`Raw tutorial video retained for diagnosis at ${rawPath}.`);
138
+ }
139
+ return {
140
+ record: { kind: "video", status: "failed", path: relativePath, error: check.error },
141
+ warnings
142
+ };
143
+ }
144
+ const cleanupWarning = await removeTemporaryVideoFiles(options.videoTempDir);
145
+ if (cleanupWarning) {
146
+ warnings.push(cleanupWarning);
147
+ }
148
+ return {
149
+ record: { kind: "video", status: "written", path: relativePath, sizeBytes: check.sizeBytes },
150
+ warnings
151
+ };
152
+ }
153
+ /**
154
+ * Removes raw recordings once the canonical WebM exists, so runs do not
155
+ * accumulate abandoned temporary video directories. Only the dedicated video
156
+ * temp directory is touched, never the canonical artifact.
157
+ */
158
+ export async function removeTemporaryVideoFiles(videoTempDir) {
159
+ try {
160
+ const entries = await readdir(videoTempDir);
161
+ await Promise.all(entries
162
+ .filter((entry) => entry.toLowerCase().endsWith(".webm"))
163
+ .map((entry) => rm(path.join(videoTempDir, entry), { force: true })));
164
+ return undefined;
165
+ }
166
+ catch (error) {
167
+ const code = error.code;
168
+ if (code === "ENOENT") {
169
+ return undefined;
170
+ }
171
+ return `Temporary tutorial video files under ${videoTempDir} could not be removed: ${messageOf(error)}`;
172
+ }
173
+ }
174
+ function messageOf(error) {
175
+ return error instanceof Error ? error.message : String(error);
176
+ }