@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,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,110 @@
|
|
|
1
|
+
import { describeTutorialLocator, resolveTutorialLocator } from "./tutorialLocators.js";
|
|
2
|
+
import { DEFAULT_TUTORIAL_ACTION_TIMEOUT_MS } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Resolves a scenario `goto` path against the target application's URL and
|
|
5
|
+
* proves the result never leaves that origin.
|
|
6
|
+
*
|
|
7
|
+
* Validation already rejects absolute and protocol-relative paths, so this is
|
|
8
|
+
* the second, authoritative gate: whatever the string is, the resolved URL must
|
|
9
|
+
* match the application's protocol, hostname and port exactly or navigation is
|
|
10
|
+
* refused.
|
|
11
|
+
*/
|
|
12
|
+
export function resolveTutorialNavigationUrl(applicationUrl, suppliedPath) {
|
|
13
|
+
if (typeof suppliedPath !== "string" || !suppliedPath.startsWith("/") || suppliedPath.startsWith("//")) {
|
|
14
|
+
throw new Error(`Tutorial goto path must be a root-relative path beginning with "/" and must not be protocol-relative; received ${JSON.stringify(suppliedPath)}.`);
|
|
15
|
+
}
|
|
16
|
+
const base = new URL(applicationUrl);
|
|
17
|
+
const resolved = new URL(suppliedPath, base);
|
|
18
|
+
if (resolved.protocol !== base.protocol || resolved.hostname !== base.hostname || resolved.port !== base.port) {
|
|
19
|
+
throw new Error(`Tutorial goto path ${JSON.stringify(suppliedPath)} resolves to ${resolved.origin}, which leaves the application origin ${base.origin}.`);
|
|
20
|
+
}
|
|
21
|
+
return resolved.href;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Executes one declarative action and always returns a structured result.
|
|
25
|
+
*
|
|
26
|
+
* There is intentionally no retry loop here: Playwright's own per-call timeout
|
|
27
|
+
* is the single waiting mechanism, so a step's duration stays predictable and a
|
|
28
|
+
* flaky scenario cannot be masked by silent repetition.
|
|
29
|
+
*/
|
|
30
|
+
export async function executeTutorialAction(page, action, context) {
|
|
31
|
+
const startedAtMs = Date.now();
|
|
32
|
+
const startedAt = new Date(startedAtMs).toISOString();
|
|
33
|
+
try {
|
|
34
|
+
await performAction(page, action, context);
|
|
35
|
+
return finish(action.type, "passed", startedAt, startedAtMs);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
return {
|
|
39
|
+
...finish(action.type, "failed", startedAt, startedAtMs),
|
|
40
|
+
error: describeActionError(action, error)
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function performAction(page, action, context) {
|
|
45
|
+
switch (action.type) {
|
|
46
|
+
case "goto": {
|
|
47
|
+
const url = resolveTutorialNavigationUrl(context.applicationUrl, action.path);
|
|
48
|
+
await page.goto(url, action.waitUntil !== undefined ? { waitUntil: action.waitUntil } : undefined);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
case "click":
|
|
52
|
+
await resolveTutorialLocator(page, action.locator).click({ timeout: timeoutFor(action.timeoutMs) });
|
|
53
|
+
return;
|
|
54
|
+
case "fill":
|
|
55
|
+
await resolveTutorialLocator(page, action.locator).fill(action.value, {
|
|
56
|
+
timeout: timeoutFor(action.timeoutMs)
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
59
|
+
case "press":
|
|
60
|
+
await resolveTutorialLocator(page, action.locator).press(action.key, {
|
|
61
|
+
timeout: timeoutFor(action.timeoutMs)
|
|
62
|
+
});
|
|
63
|
+
return;
|
|
64
|
+
case "hover":
|
|
65
|
+
await resolveTutorialLocator(page, action.locator).hover({ timeout: timeoutFor(action.timeoutMs) });
|
|
66
|
+
return;
|
|
67
|
+
case "drag": {
|
|
68
|
+
// Both ends go through the canonical resolver so a drag cannot develop its
|
|
69
|
+
// own locator semantics.
|
|
70
|
+
const source = resolveTutorialLocator(page, action.source);
|
|
71
|
+
const target = resolveTutorialLocator(page, action.target);
|
|
72
|
+
await source.dragTo(target, { timeout: timeoutFor(action.timeoutMs) });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
case "wait-for":
|
|
76
|
+
await resolveTutorialLocator(page, action.locator).waitFor({
|
|
77
|
+
...(action.state !== undefined ? { state: action.state } : {}),
|
|
78
|
+
timeout: timeoutFor(action.timeoutMs)
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export function timeoutFor(timeoutMs) {
|
|
84
|
+
return timeoutMs ?? DEFAULT_TUTORIAL_ACTION_TIMEOUT_MS;
|
|
85
|
+
}
|
|
86
|
+
function finish(type, status, startedAt, startedAtMs) {
|
|
87
|
+
const endedAtMs = Date.now();
|
|
88
|
+
return {
|
|
89
|
+
type,
|
|
90
|
+
status,
|
|
91
|
+
startedAt,
|
|
92
|
+
endedAt: new Date(endedAtMs).toISOString(),
|
|
93
|
+
durationMs: endedAtMs - startedAtMs
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function describeActionError(action, error) {
|
|
97
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
98
|
+
switch (action.type) {
|
|
99
|
+
case "goto":
|
|
100
|
+
return `goto ${JSON.stringify(action.path)} failed: ${message}`;
|
|
101
|
+
case "drag":
|
|
102
|
+
return `drag from ${describeTutorialLocator(action.source)} to ${describeTutorialLocator(action.target)} failed: ${message}`;
|
|
103
|
+
case "click":
|
|
104
|
+
case "fill":
|
|
105
|
+
case "press":
|
|
106
|
+
case "hover":
|
|
107
|
+
case "wait-for":
|
|
108
|
+
return `${action.type} on ${describeTutorialLocator(action.locator)} failed: ${message}`;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -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
|
+
}
|