@democraft/playwright 0.1.0-beta.0
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/LICENSE +21 -0
- package/README.md +5 -0
- package/dist/index.d.ts +271 -0
- package/dist/index.js +1548 -0
- package/package.json +42 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1548 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
var DEFAULT_SETTLE_STRATEGY = {
|
|
3
|
+
idleWindowMs: 350,
|
|
4
|
+
timeoutMs: 4e3,
|
|
5
|
+
signal: "both"
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
// src/environment-fingerprint.ts
|
|
9
|
+
import { createHash } from "crypto";
|
|
10
|
+
import { readFile } from "fs/promises";
|
|
11
|
+
var CAPTURE_ENVIRONMENT_HASH_PREFIX = "capture-env-v1:sha256:";
|
|
12
|
+
async function resolveCaptureEnvironment(options = {}, runtime = {
|
|
13
|
+
node: process.versions.node,
|
|
14
|
+
platform: process.platform,
|
|
15
|
+
arch: process.arch,
|
|
16
|
+
engine: "chromium"
|
|
17
|
+
}) {
|
|
18
|
+
const configured = options.environment ?? {};
|
|
19
|
+
const settle = resolveSettleStrategy(configured.settle);
|
|
20
|
+
const viewport = configured.viewport ?? { width: 1920, height: 1080 };
|
|
21
|
+
const environment = {
|
|
22
|
+
headless: options.headless ?? true,
|
|
23
|
+
viewport: { width: viewport.width, height: viewport.height },
|
|
24
|
+
deviceScaleFactor: configured.deviceScaleFactor ?? 2,
|
|
25
|
+
locale: configured.locale ?? "en-US",
|
|
26
|
+
timezone: configured.timezone ?? "UTC",
|
|
27
|
+
settle: settle ?? false,
|
|
28
|
+
timeoutMs: options.timeoutMs ?? 8e3
|
|
29
|
+
};
|
|
30
|
+
const storageStateHash = configured.storageState ? sha256(await readFile(configured.storageState)) : null;
|
|
31
|
+
const fingerprint = JSON.stringify({
|
|
32
|
+
...environment,
|
|
33
|
+
storageStateHash,
|
|
34
|
+
runtime: {
|
|
35
|
+
node: runtime.node,
|
|
36
|
+
platform: runtime.platform,
|
|
37
|
+
arch: runtime.arch,
|
|
38
|
+
engine: runtime.engine
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
environment,
|
|
43
|
+
captureEnvironmentHash: `${CAPTURE_ENVIRONMENT_HASH_PREFIX}${sha256(fingerprint)}`
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function resolveSettleStrategy(settle) {
|
|
47
|
+
if (settle === false) return void 0;
|
|
48
|
+
if (settle === void 0) return { ...DEFAULT_SETTLE_STRATEGY };
|
|
49
|
+
return { ...DEFAULT_SETTLE_STRATEGY, ...settle };
|
|
50
|
+
}
|
|
51
|
+
function sha256(value) {
|
|
52
|
+
return createHash("sha256").update(value).digest("hex");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// src/runner.ts
|
|
56
|
+
import { access as access2, mkdir as mkdir2 } from "fs/promises";
|
|
57
|
+
import { isAbsolute, relative, resolve } from "path";
|
|
58
|
+
import {
|
|
59
|
+
parseDemoIR,
|
|
60
|
+
parseRecordedDemoManifest,
|
|
61
|
+
schemaVersion
|
|
62
|
+
} from "@democraft/schema";
|
|
63
|
+
|
|
64
|
+
// src/bindings.ts
|
|
65
|
+
import { chromium } from "playwright";
|
|
66
|
+
var defaultBindings = {
|
|
67
|
+
chromium
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// src/execute.ts
|
|
71
|
+
import { join } from "path";
|
|
72
|
+
|
|
73
|
+
// src/screenshot-path.ts
|
|
74
|
+
import { createHash as createHash2 } from "crypto";
|
|
75
|
+
import path from "path";
|
|
76
|
+
function canonicalScreenshotFilename(sceneId, stepId) {
|
|
77
|
+
const digest = createHash2("sha256").update(sceneId).update("\0").update(stepId).digest("hex").slice(0, 12);
|
|
78
|
+
return `${safeSegment(sceneId)}-${safeSegment(stepId)}-${digest}.png`;
|
|
79
|
+
}
|
|
80
|
+
function screenshotRelativePath(sceneId, stepId) {
|
|
81
|
+
return `screenshots/${canonicalScreenshotFilename(sceneId, stepId)}`;
|
|
82
|
+
}
|
|
83
|
+
function resolveRecordedScreenshotPath(captureDirectory, step) {
|
|
84
|
+
if (step.screenshotPath) {
|
|
85
|
+
return resolveContained(captureDirectory, step.screenshotPath);
|
|
86
|
+
}
|
|
87
|
+
const legacy = `screenshots/${step.sceneId}-${step.stepId}.png`;
|
|
88
|
+
return resolveContained(captureDirectory, legacy);
|
|
89
|
+
}
|
|
90
|
+
function resolveContained(root, relativePath) {
|
|
91
|
+
if (path.isAbsolute(relativePath)) return void 0;
|
|
92
|
+
const rootAbsolute = path.resolve(root);
|
|
93
|
+
const candidate = path.resolve(rootAbsolute, relativePath);
|
|
94
|
+
const relative2 = path.relative(rootAbsolute, candidate);
|
|
95
|
+
if (!relative2 || relative2.startsWith("..") || path.isAbsolute(relative2)) {
|
|
96
|
+
return void 0;
|
|
97
|
+
}
|
|
98
|
+
return candidate;
|
|
99
|
+
}
|
|
100
|
+
function safeSegment(value) {
|
|
101
|
+
return value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "step";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/execute.ts
|
|
105
|
+
import {
|
|
106
|
+
diagnosticDocsUrl as diagnosticDocsUrl2
|
|
107
|
+
} from "@democraft/schema";
|
|
108
|
+
|
|
109
|
+
// src/locator.ts
|
|
110
|
+
async function resolveTarget(ir, page, targetId, timeoutMs = 5e3) {
|
|
111
|
+
const startedAtMs = Date.now();
|
|
112
|
+
const target = ir.targets[targetId];
|
|
113
|
+
const attemptedLocators = [];
|
|
114
|
+
if (!target) {
|
|
115
|
+
return {
|
|
116
|
+
snapshot: {
|
|
117
|
+
targetId,
|
|
118
|
+
attemptedLocators,
|
|
119
|
+
visible: false,
|
|
120
|
+
resolutionDurationMs: Date.now() - startedAtMs
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
for (const locatorDefinition of target.locators) {
|
|
125
|
+
const locator = createLocator(page, locatorDefinition);
|
|
126
|
+
try {
|
|
127
|
+
let visible = await locator.isVisible({ timeout: timeoutMs });
|
|
128
|
+
if (!visible && locator.waitFor) {
|
|
129
|
+
try {
|
|
130
|
+
await locator.waitFor({ state: "visible", timeout: timeoutMs });
|
|
131
|
+
visible = true;
|
|
132
|
+
} catch {
|
|
133
|
+
visible = false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const boundingBox = await locator.boundingBox({ timeout: timeoutMs }) ?? void 0;
|
|
137
|
+
attemptedLocators.push({ locator: locatorDefinition, success: visible });
|
|
138
|
+
if (visible) {
|
|
139
|
+
return {
|
|
140
|
+
locator,
|
|
141
|
+
snapshot: {
|
|
142
|
+
targetId,
|
|
143
|
+
attemptedLocators,
|
|
144
|
+
successfulLocator: locatorDefinition,
|
|
145
|
+
boundingBox,
|
|
146
|
+
visible,
|
|
147
|
+
resolutionDurationMs: Date.now() - startedAtMs
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
} catch (error) {
|
|
152
|
+
attemptedLocators.push({
|
|
153
|
+
locator: locatorDefinition,
|
|
154
|
+
success: false,
|
|
155
|
+
error: error instanceof Error ? error.message : "Locator failed."
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
snapshot: {
|
|
161
|
+
targetId,
|
|
162
|
+
attemptedLocators,
|
|
163
|
+
visible: false,
|
|
164
|
+
resolutionDurationMs: Date.now() - startedAtMs
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function createLocator(page, locator) {
|
|
169
|
+
switch (locator.kind) {
|
|
170
|
+
case "role":
|
|
171
|
+
return page.getByRole(locator.role, { name: locator.name });
|
|
172
|
+
case "label":
|
|
173
|
+
return page.getByLabel(locator.text);
|
|
174
|
+
case "testId":
|
|
175
|
+
return page.getByTestId(locator.id);
|
|
176
|
+
case "text":
|
|
177
|
+
return page.getByText(locator.text);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function resolveUrl(ir, path3) {
|
|
181
|
+
if (/^https?:\/\//.test(path3)) return path3;
|
|
182
|
+
return new URL(path3, ir.source.baseUrl).toString();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// src/diagnostics.ts
|
|
186
|
+
import {
|
|
187
|
+
diagnosticDocsUrl
|
|
188
|
+
} from "@democraft/schema";
|
|
189
|
+
function targetDiagnostic(demoId, sceneId, stepId, targetId, message, details) {
|
|
190
|
+
return {
|
|
191
|
+
code: "DC201",
|
|
192
|
+
severity: "error",
|
|
193
|
+
message,
|
|
194
|
+
path: `scenes.${sceneId}.steps.${stepId}.target`,
|
|
195
|
+
suggestion: message.includes("could not be resolved") ? `Review the locators declared for target "${targetId}".` : `Inspect target "${targetId}" and the failed assertion.`,
|
|
196
|
+
docsUrl: diagnosticDocsUrl("DC201"),
|
|
197
|
+
demoId,
|
|
198
|
+
sceneId,
|
|
199
|
+
stepId,
|
|
200
|
+
targetId,
|
|
201
|
+
details
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function unresolvedTargetDiagnostic(demoId, sceneId, stepId, targetId, attemptedLocators) {
|
|
205
|
+
return targetDiagnostic(
|
|
206
|
+
demoId,
|
|
207
|
+
sceneId,
|
|
208
|
+
stepId,
|
|
209
|
+
targetId,
|
|
210
|
+
`Target "${targetId}" could not be resolved.`,
|
|
211
|
+
{ attemptedLocators }
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// src/settle.ts
|
|
216
|
+
async function waitForSettled(page, strategy) {
|
|
217
|
+
const deadline = Date.now() + strategy.timeoutMs;
|
|
218
|
+
const needDom = strategy.signal === "dom" || strategy.signal === "both";
|
|
219
|
+
const needVisual = strategy.signal === "visual" || strategy.signal === "both";
|
|
220
|
+
const needNetwork = strategy.signal === "network" || strategy.signal === "both";
|
|
221
|
+
const monitors = await installMonitors(page, {
|
|
222
|
+
dom: needDom,
|
|
223
|
+
network: needNetwork
|
|
224
|
+
});
|
|
225
|
+
const quietSinceBySignal = {};
|
|
226
|
+
const initSignal = (name) => {
|
|
227
|
+
quietSinceBySignal[name] = Date.now();
|
|
228
|
+
};
|
|
229
|
+
if (needDom && monitors.dom) initSignal("dom");
|
|
230
|
+
if (needVisual) initSignal("visual");
|
|
231
|
+
if (needNetwork && monitors.network) initSignal("network");
|
|
232
|
+
let lastVisualSignature;
|
|
233
|
+
while (Date.now() < deadline) {
|
|
234
|
+
const now = Date.now();
|
|
235
|
+
if (monitors.dom) {
|
|
236
|
+
const mutations = await withFallback(readDomMutations(page), 0);
|
|
237
|
+
if (mutations > 0) quietSinceBySignal["dom"] = Date.now();
|
|
238
|
+
}
|
|
239
|
+
if (needVisual) {
|
|
240
|
+
const signature = await withFallback(visualSignature(page), void 0);
|
|
241
|
+
if (signature !== void 0 && signature !== lastVisualSignature) {
|
|
242
|
+
lastVisualSignature = signature;
|
|
243
|
+
quietSinceBySignal["visual"] = Date.now();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (monitors.network) {
|
|
247
|
+
const active = await withFallback(readNetworkActivity(page), 0);
|
|
248
|
+
if (active > 0) quietSinceBySignal["network"] = Date.now();
|
|
249
|
+
}
|
|
250
|
+
const signals = [];
|
|
251
|
+
if (needDom && monitors.dom) {
|
|
252
|
+
signals.push(now - quietSinceBySignal["dom"] >= strategy.idleWindowMs);
|
|
253
|
+
}
|
|
254
|
+
if (needVisual) {
|
|
255
|
+
signals.push(now - quietSinceBySignal["visual"] >= strategy.idleWindowMs);
|
|
256
|
+
}
|
|
257
|
+
if (needNetwork && monitors.network) {
|
|
258
|
+
signals.push(
|
|
259
|
+
now - quietSinceBySignal["network"] >= strategy.idleWindowMs
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
if (signals.length > 0 && signals.every(Boolean)) return;
|
|
263
|
+
await sleep(Math.max(50, strategy.idleWindowMs / 2));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
async function withFallback(promise, fallback, perCallMs = 2e3) {
|
|
267
|
+
let timer;
|
|
268
|
+
try {
|
|
269
|
+
return await Promise.race([
|
|
270
|
+
promise,
|
|
271
|
+
new Promise((resolve2) => {
|
|
272
|
+
timer = setTimeout(() => resolve2(fallback), perCallMs);
|
|
273
|
+
})
|
|
274
|
+
]);
|
|
275
|
+
} catch {
|
|
276
|
+
return fallback;
|
|
277
|
+
} finally {
|
|
278
|
+
if (timer) clearTimeout(timer);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async function installMonitors(page, need) {
|
|
282
|
+
if (!page.evaluate) return { dom: false, network: false };
|
|
283
|
+
try {
|
|
284
|
+
await withFallback(
|
|
285
|
+
page.evaluate(() => {
|
|
286
|
+
const w = window;
|
|
287
|
+
w.__democraftMutations = 0;
|
|
288
|
+
w.__democraftObserver?.disconnect();
|
|
289
|
+
const observer = new MutationObserver(() => {
|
|
290
|
+
w.__democraftMutations = (w.__democraftMutations ?? 0) + 1;
|
|
291
|
+
});
|
|
292
|
+
observer.observe(document.documentElement, {
|
|
293
|
+
childList: true,
|
|
294
|
+
subtree: true,
|
|
295
|
+
attributes: true,
|
|
296
|
+
characterData: true
|
|
297
|
+
});
|
|
298
|
+
w.__democraftObserver = observer;
|
|
299
|
+
if (!w.__democraftInstrumented) {
|
|
300
|
+
w.__democraftFetchActive = 0;
|
|
301
|
+
const originalFetch = window.fetch;
|
|
302
|
+
window.fetch = function democraftFetch(...args) {
|
|
303
|
+
w.__democraftFetchActive = (w.__democraftFetchActive ?? 0) + 1;
|
|
304
|
+
return originalFetch.apply(this, args).finally(() => {
|
|
305
|
+
w.__democraftFetchActive = Math.max(
|
|
306
|
+
0,
|
|
307
|
+
(w.__democraftFetchActive ?? 0) - 1
|
|
308
|
+
);
|
|
309
|
+
});
|
|
310
|
+
};
|
|
311
|
+
const originalOpen = XMLHttpRequest.prototype.open;
|
|
312
|
+
const originalSend = XMLHttpRequest.prototype.send;
|
|
313
|
+
XMLHttpRequest.prototype.open = function democraftXhrOpen(...args) {
|
|
314
|
+
this.addEventListener("loadend", () => {
|
|
315
|
+
w.__democraftFetchActive = Math.max(
|
|
316
|
+
0,
|
|
317
|
+
(w.__democraftFetchActive ?? 0) - 1
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
return originalOpen.apply(this, args);
|
|
321
|
+
};
|
|
322
|
+
XMLHttpRequest.prototype.send = function democraftXhrSend(...args) {
|
|
323
|
+
w.__democraftFetchActive = (w.__democraftFetchActive ?? 0) + 1;
|
|
324
|
+
return originalSend.apply(this, args);
|
|
325
|
+
};
|
|
326
|
+
w.__democraftInstrumented = true;
|
|
327
|
+
} else {
|
|
328
|
+
w.__democraftFetchActive = 0;
|
|
329
|
+
}
|
|
330
|
+
}),
|
|
331
|
+
void 0
|
|
332
|
+
);
|
|
333
|
+
return { dom: need.dom, network: need.network };
|
|
334
|
+
} catch {
|
|
335
|
+
return { dom: false, network: false };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
async function readDomMutations(page) {
|
|
339
|
+
return page.evaluate(() => {
|
|
340
|
+
const w = window;
|
|
341
|
+
const count = w.__democraftMutations ?? 0;
|
|
342
|
+
w.__democraftMutations = 0;
|
|
343
|
+
return count;
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
async function readNetworkActivity(page) {
|
|
347
|
+
return page.evaluate(() => {
|
|
348
|
+
const w = window;
|
|
349
|
+
return w.__democraftFetchActive ?? 0;
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
async function visualSignature(page) {
|
|
353
|
+
if (!page.screenshot) return void 0;
|
|
354
|
+
try {
|
|
355
|
+
const png = await page.screenshot({ type: "png" });
|
|
356
|
+
return hashBuffer(png);
|
|
357
|
+
} catch {
|
|
358
|
+
return void 0;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function hashBuffer(buf) {
|
|
362
|
+
let hash = 2166136261;
|
|
363
|
+
for (let i = 0; i < buf.length; i += 1) {
|
|
364
|
+
hash ^= buf[i];
|
|
365
|
+
hash = Math.imul(hash, 16777619);
|
|
366
|
+
}
|
|
367
|
+
return (hash >>> 0).toString(16);
|
|
368
|
+
}
|
|
369
|
+
function sleep(ms) {
|
|
370
|
+
return new Promise((resolve2) => {
|
|
371
|
+
setTimeout(resolve2, ms);
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// src/execute.ts
|
|
376
|
+
async function executeStep(args) {
|
|
377
|
+
const startedAtMs = Date.now();
|
|
378
|
+
let targetSnapshot;
|
|
379
|
+
try {
|
|
380
|
+
switch (args.step.kind) {
|
|
381
|
+
case "browser.goto":
|
|
382
|
+
await args.page.goto(resolveUrl(args.ir, args.step.path));
|
|
383
|
+
break;
|
|
384
|
+
case "browser.click": {
|
|
385
|
+
const resolved = await resolveTarget(
|
|
386
|
+
args.ir,
|
|
387
|
+
args.page,
|
|
388
|
+
args.step.target,
|
|
389
|
+
args.timeoutMs
|
|
390
|
+
);
|
|
391
|
+
targetSnapshot = resolved.snapshot;
|
|
392
|
+
if (resolved.locator) {
|
|
393
|
+
const urlBefore = args.page.url();
|
|
394
|
+
await resolved.locator.click();
|
|
395
|
+
await waitForClientNavigation(args.page, urlBefore, args.timeoutMs);
|
|
396
|
+
} else {
|
|
397
|
+
args.diagnostics.push(
|
|
398
|
+
unresolvedTargetDiagnostic(
|
|
399
|
+
args.ir.id,
|
|
400
|
+
args.sceneId,
|
|
401
|
+
args.step.id,
|
|
402
|
+
args.step.target,
|
|
403
|
+
resolved.snapshot.attemptedLocators
|
|
404
|
+
)
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
case "browser.fill": {
|
|
410
|
+
const resolved = await resolveTarget(
|
|
411
|
+
args.ir,
|
|
412
|
+
args.page,
|
|
413
|
+
args.step.target,
|
|
414
|
+
args.timeoutMs
|
|
415
|
+
);
|
|
416
|
+
targetSnapshot = resolved.snapshot;
|
|
417
|
+
if (resolved.locator) {
|
|
418
|
+
await resolved.locator.fill(args.step.value);
|
|
419
|
+
} else {
|
|
420
|
+
args.diagnostics.push(
|
|
421
|
+
unresolvedTargetDiagnostic(
|
|
422
|
+
args.ir.id,
|
|
423
|
+
args.sceneId,
|
|
424
|
+
args.step.id,
|
|
425
|
+
args.step.target,
|
|
426
|
+
resolved.snapshot.attemptedLocators
|
|
427
|
+
)
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
case "browser.select": {
|
|
433
|
+
const resolved = await resolveTarget(
|
|
434
|
+
args.ir,
|
|
435
|
+
args.page,
|
|
436
|
+
args.step.target,
|
|
437
|
+
args.timeoutMs
|
|
438
|
+
);
|
|
439
|
+
targetSnapshot = resolved.snapshot;
|
|
440
|
+
if (resolved.locator) {
|
|
441
|
+
await resolved.locator.selectOption(args.step.value);
|
|
442
|
+
} else {
|
|
443
|
+
args.diagnostics.push(
|
|
444
|
+
unresolvedTargetDiagnostic(
|
|
445
|
+
args.ir.id,
|
|
446
|
+
args.sceneId,
|
|
447
|
+
args.step.id,
|
|
448
|
+
args.step.target,
|
|
449
|
+
resolved.snapshot.attemptedLocators
|
|
450
|
+
)
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
case "assert.visible": {
|
|
456
|
+
const resolved = await resolveTarget(
|
|
457
|
+
args.ir,
|
|
458
|
+
args.page,
|
|
459
|
+
args.step.target,
|
|
460
|
+
args.timeoutMs
|
|
461
|
+
);
|
|
462
|
+
targetSnapshot = resolved.snapshot;
|
|
463
|
+
if (!targetSnapshot.visible) {
|
|
464
|
+
args.diagnostics.push(
|
|
465
|
+
targetDiagnostic(
|
|
466
|
+
args.ir.id,
|
|
467
|
+
args.sceneId,
|
|
468
|
+
args.step.id,
|
|
469
|
+
args.step.target,
|
|
470
|
+
"Target is not visible."
|
|
471
|
+
)
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
case "assert.text": {
|
|
477
|
+
const resolved = await resolveTarget(
|
|
478
|
+
args.ir,
|
|
479
|
+
args.page,
|
|
480
|
+
args.step.target,
|
|
481
|
+
args.timeoutMs
|
|
482
|
+
);
|
|
483
|
+
targetSnapshot = resolved.snapshot;
|
|
484
|
+
const text = await resolved.locator?.textContent();
|
|
485
|
+
if (!text?.includes(args.step.text)) {
|
|
486
|
+
args.diagnostics.push(
|
|
487
|
+
targetDiagnostic(
|
|
488
|
+
args.ir.id,
|
|
489
|
+
args.sceneId,
|
|
490
|
+
args.step.id,
|
|
491
|
+
args.step.target,
|
|
492
|
+
`Target text does not include "${args.step.text}".`
|
|
493
|
+
)
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
case "assert.url":
|
|
499
|
+
if (!args.page.url().includes(args.step.path)) {
|
|
500
|
+
args.diagnostics.push({
|
|
501
|
+
code: "DC201",
|
|
502
|
+
severity: "error",
|
|
503
|
+
message: `Current URL "${args.page.url()}" does not include "${args.step.path}".`,
|
|
504
|
+
path: `scenes.${args.sceneId}.steps.${args.step.id}.path`,
|
|
505
|
+
suggestion: `Navigate to a URL containing "${args.step.path}" or update the assertion.`,
|
|
506
|
+
docsUrl: diagnosticDocsUrl2("DC201"),
|
|
507
|
+
demoId: args.ir.id,
|
|
508
|
+
sceneId: args.sceneId,
|
|
509
|
+
stepId: args.step.id
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
break;
|
|
513
|
+
case "camera.establish":
|
|
514
|
+
case "camera.focus":
|
|
515
|
+
case "overlay.callout":
|
|
516
|
+
if ("target" in args.step && args.step.target) {
|
|
517
|
+
targetSnapshot = (await resolveTarget(
|
|
518
|
+
args.ir,
|
|
519
|
+
args.page,
|
|
520
|
+
args.step.target,
|
|
521
|
+
args.timeoutMs
|
|
522
|
+
)).snapshot;
|
|
523
|
+
}
|
|
524
|
+
break;
|
|
525
|
+
case "overlay.caption":
|
|
526
|
+
case "overlay.visual":
|
|
527
|
+
case "timeline.hold":
|
|
528
|
+
case "timeline.transition":
|
|
529
|
+
case "cue":
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
} catch (error) {
|
|
533
|
+
args.diagnostics.push({
|
|
534
|
+
code: "DC201",
|
|
535
|
+
severity: "error",
|
|
536
|
+
message: error instanceof Error ? error.message : "Step execution failed.",
|
|
537
|
+
path: `scenes.${args.sceneId}.steps.${args.step.id}`,
|
|
538
|
+
suggestion: "Inspect the failed browser step and its target contract.",
|
|
539
|
+
docsUrl: diagnosticDocsUrl2("DC201"),
|
|
540
|
+
demoId: args.ir.id,
|
|
541
|
+
sceneId: args.sceneId,
|
|
542
|
+
stepId: args.step.id
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
if (args.settleStrategy && stepIsActionDriven(args.step)) {
|
|
546
|
+
await waitForSettled(args.page, args.settleStrategy);
|
|
547
|
+
}
|
|
548
|
+
if (!args.settleStrategy || !stepIsActionDriven(args.step)) {
|
|
549
|
+
await args.page.waitForTimeout?.(captureStepHoldMs(args.step));
|
|
550
|
+
}
|
|
551
|
+
let screenshotPath;
|
|
552
|
+
const screenshotFilename = canonicalScreenshotFilename(
|
|
553
|
+
args.sceneId,
|
|
554
|
+
args.step.id
|
|
555
|
+
);
|
|
556
|
+
try {
|
|
557
|
+
await args.page.screenshot?.({
|
|
558
|
+
path: join(args.screenshotsPath, screenshotFilename),
|
|
559
|
+
// Capture the viewport, not the full document. A full-page screenshot
|
|
560
|
+
// grows with page content (a tall dashboard yields a 1440×3244 PNG while
|
|
561
|
+
// a short page yields 1440×900), so consecutive steps would be captured
|
|
562
|
+
// at different aspect ratios and the renderer's fixed-size stage would
|
|
563
|
+
// re-scale every cut — visible as flicker/flash between steps. The
|
|
564
|
+
// viewport is constant (matches `environment.viewport`), so every
|
|
565
|
+
// screenshot lands at identical dimensions and maps 1:1 onto the stage.
|
|
566
|
+
// Element bounding boxes from Playwright are viewport-relative, so camera
|
|
567
|
+
// focus targets line up with what the screenshot actually shows.
|
|
568
|
+
fullPage: false
|
|
569
|
+
});
|
|
570
|
+
if (args.page.screenshot) {
|
|
571
|
+
screenshotPath = screenshotRelativePath(args.sceneId, args.step.id);
|
|
572
|
+
}
|
|
573
|
+
} catch (error) {
|
|
574
|
+
args.diagnostics.push({
|
|
575
|
+
code: "DC201",
|
|
576
|
+
severity: "warning",
|
|
577
|
+
message: error instanceof Error ? `Screenshot failed: ${error.message}` : "Screenshot failed.",
|
|
578
|
+
path: `scenes.${args.sceneId}.steps.${args.step.id}`,
|
|
579
|
+
suggestion: "Check screenshot permissions and the browser page state.",
|
|
580
|
+
docsUrl: diagnosticDocsUrl2("DC201"),
|
|
581
|
+
demoId: args.ir.id,
|
|
582
|
+
sceneId: args.sceneId,
|
|
583
|
+
stepId: args.step.id
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
return {
|
|
587
|
+
stepId: args.step.id,
|
|
588
|
+
sceneId: args.sceneId,
|
|
589
|
+
kind: args.step.kind,
|
|
590
|
+
startedAtMs,
|
|
591
|
+
endedAtMs: Date.now(),
|
|
592
|
+
targetSnapshot,
|
|
593
|
+
url: args.page.url(),
|
|
594
|
+
screenshotPath
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
function stepIsActionDriven(step) {
|
|
598
|
+
switch (step.kind) {
|
|
599
|
+
case "browser.goto":
|
|
600
|
+
case "browser.click":
|
|
601
|
+
case "browser.fill":
|
|
602
|
+
case "browser.select":
|
|
603
|
+
case "assert.visible":
|
|
604
|
+
case "assert.text":
|
|
605
|
+
case "assert.url":
|
|
606
|
+
case "camera.establish":
|
|
607
|
+
case "camera.focus":
|
|
608
|
+
case "overlay.callout":
|
|
609
|
+
return true;
|
|
610
|
+
case "timeline.hold":
|
|
611
|
+
case "timeline.transition":
|
|
612
|
+
case "overlay.caption":
|
|
613
|
+
case "overlay.visual":
|
|
614
|
+
case "cue":
|
|
615
|
+
return false;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
function captureStepHoldMs(step) {
|
|
619
|
+
switch (step.kind) {
|
|
620
|
+
case "timeline.hold":
|
|
621
|
+
return step.durationMs;
|
|
622
|
+
case "timeline.transition":
|
|
623
|
+
return step.durationMs ?? 500;
|
|
624
|
+
case "overlay.caption":
|
|
625
|
+
return Math.max(1200, step.text.length * 45);
|
|
626
|
+
case "overlay.callout":
|
|
627
|
+
return Math.max(
|
|
628
|
+
1800,
|
|
629
|
+
`${step.title} ${step.description ?? ""}`.trim().length * 45
|
|
630
|
+
);
|
|
631
|
+
case "overlay.visual":
|
|
632
|
+
return step.durationMs ?? 1800;
|
|
633
|
+
case "camera.establish":
|
|
634
|
+
return 700;
|
|
635
|
+
case "camera.focus":
|
|
636
|
+
return 1100;
|
|
637
|
+
case "browser.click":
|
|
638
|
+
return 650;
|
|
639
|
+
case "browser.fill":
|
|
640
|
+
case "browser.select":
|
|
641
|
+
return 700;
|
|
642
|
+
case "browser.goto":
|
|
643
|
+
return 700;
|
|
644
|
+
case "assert.visible":
|
|
645
|
+
case "assert.text":
|
|
646
|
+
case "assert.url":
|
|
647
|
+
return 300;
|
|
648
|
+
case "cue":
|
|
649
|
+
return 1;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
var CLIENT_NAV_TIMEOUT_MS = 6e3;
|
|
653
|
+
var CLIENT_NAV_POLL_MS = 150;
|
|
654
|
+
async function waitForClientNavigation(page, urlBefore, timeoutMs = CLIENT_NAV_TIMEOUT_MS) {
|
|
655
|
+
const deadline = Date.now() + timeoutMs;
|
|
656
|
+
while (Date.now() < deadline) {
|
|
657
|
+
if (page.url() !== urlBefore) {
|
|
658
|
+
await withTimeout(
|
|
659
|
+
page.waitForLoadState?.("domcontentloaded"),
|
|
660
|
+
CLIENT_NAV_TIMEOUT_MS
|
|
661
|
+
);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
await sleep2(CLIENT_NAV_POLL_MS);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
function sleep2(ms) {
|
|
668
|
+
return new Promise((resolve2) => {
|
|
669
|
+
setTimeout(resolve2, ms);
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
async function withTimeout(promise, ms) {
|
|
673
|
+
if (!promise) return;
|
|
674
|
+
let timer;
|
|
675
|
+
try {
|
|
676
|
+
await Promise.race([
|
|
677
|
+
promise,
|
|
678
|
+
new Promise((resolve2) => {
|
|
679
|
+
timer = setTimeout(resolve2, ms);
|
|
680
|
+
})
|
|
681
|
+
]);
|
|
682
|
+
} finally {
|
|
683
|
+
if (timer) clearTimeout(timer);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// src/capture-artifacts.ts
|
|
688
|
+
import { createHash as createHash3, randomBytes } from "crypto";
|
|
689
|
+
import {
|
|
690
|
+
access,
|
|
691
|
+
lstat,
|
|
692
|
+
mkdir,
|
|
693
|
+
readdir,
|
|
694
|
+
readFile as readFile2,
|
|
695
|
+
realpath,
|
|
696
|
+
rename,
|
|
697
|
+
rm,
|
|
698
|
+
stat,
|
|
699
|
+
writeFile
|
|
700
|
+
} from "fs/promises";
|
|
701
|
+
import { homedir } from "os";
|
|
702
|
+
import path2 from "path";
|
|
703
|
+
import {
|
|
704
|
+
parseCaptureArtifactMetadata,
|
|
705
|
+
parseCaptureArtifactMetadataJson,
|
|
706
|
+
parseLatestCapturePointer,
|
|
707
|
+
parseLatestCapturePointerJson,
|
|
708
|
+
parseRecordedDemoManifestJson
|
|
709
|
+
} from "@democraft/schema";
|
|
710
|
+
var MAX_CREATE_ATTEMPTS = 16;
|
|
711
|
+
var LATEST_LOCK_ATTEMPTS = 200;
|
|
712
|
+
var LOCK_LEASE_MS = 3e4;
|
|
713
|
+
var LOCK_RETRY_MS = 10;
|
|
714
|
+
async function createCaptureArtifact(options, dependencies = {}) {
|
|
715
|
+
const now = dependencies.now ?? (() => /* @__PURE__ */ new Date());
|
|
716
|
+
const randomId = dependencies.randomId ?? (() => randomBytes(6).toString("hex"));
|
|
717
|
+
const slug = captureSlug(options.demoId);
|
|
718
|
+
const namespace = captureNamespace(options.demoId);
|
|
719
|
+
const createdAt = now().toISOString();
|
|
720
|
+
const timestamp = createdAt.replace(/[:.]/g, "-");
|
|
721
|
+
buildInitialMetadata(options, `${slug}-${timestamp}-validation`, createdAt);
|
|
722
|
+
let directory;
|
|
723
|
+
let captureRunId = "";
|
|
724
|
+
let demoDirectory;
|
|
725
|
+
let metadata;
|
|
726
|
+
let releaseLock;
|
|
727
|
+
if (options.outputDirectory) {
|
|
728
|
+
directory = options.outputDirectory;
|
|
729
|
+
captureRunId = `${slug}-${timestamp}-${randomId()}`;
|
|
730
|
+
metadata = buildInitialMetadata(options, captureRunId, createdAt);
|
|
731
|
+
await mkdir(directory, { recursive: true });
|
|
732
|
+
releaseLock = await acquireCaptureLeaseLock(
|
|
733
|
+
path2.join(directory, ".capture.lock"),
|
|
734
|
+
options.lockOptions
|
|
735
|
+
);
|
|
736
|
+
try {
|
|
737
|
+
await rm(path2.join(directory, "manifest.json"), { force: true });
|
|
738
|
+
} catch (error) {
|
|
739
|
+
await releaseLock();
|
|
740
|
+
throw error;
|
|
741
|
+
}
|
|
742
|
+
} else {
|
|
743
|
+
demoDirectory = path2.join(options.rootDirectory, namespace);
|
|
744
|
+
await mkdir(demoDirectory, { recursive: true });
|
|
745
|
+
for (let attempt = 0; attempt < MAX_CREATE_ATTEMPTS; attempt += 1) {
|
|
746
|
+
const suffix = `${timestamp}-${randomId()}`;
|
|
747
|
+
const candidate = path2.join(demoDirectory, suffix);
|
|
748
|
+
const candidateRunId = `${slug}-${suffix}`;
|
|
749
|
+
const candidateMetadata = buildInitialMetadata(
|
|
750
|
+
options,
|
|
751
|
+
candidateRunId,
|
|
752
|
+
createdAt
|
|
753
|
+
);
|
|
754
|
+
try {
|
|
755
|
+
await mkdir(candidate, { recursive: false });
|
|
756
|
+
directory = candidate;
|
|
757
|
+
captureRunId = candidateRunId;
|
|
758
|
+
metadata = candidateMetadata;
|
|
759
|
+
break;
|
|
760
|
+
} catch (error) {
|
|
761
|
+
if (!isNodeError(error, "EEXIST")) throw error;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
if (!directory) {
|
|
766
|
+
throw new Error(
|
|
767
|
+
`Could not allocate a unique capture directory after ${MAX_CREATE_ATTEMPTS} attempts.`
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
if (!metadata) throw new Error("Capture metadata was not initialized.");
|
|
771
|
+
const artifact = {
|
|
772
|
+
captureRunId,
|
|
773
|
+
demoId: options.demoId,
|
|
774
|
+
directory,
|
|
775
|
+
metadataPath: path2.join(directory, "metadata.json"),
|
|
776
|
+
manifestPath: path2.join(directory, "manifest.json"),
|
|
777
|
+
screenshotsPath: path2.join(directory, "screenshots"),
|
|
778
|
+
tracePath: path2.join(directory, "trace.zip"),
|
|
779
|
+
metadata,
|
|
780
|
+
managed: options.outputDirectory === void 0,
|
|
781
|
+
demoDirectory,
|
|
782
|
+
releaseLock
|
|
783
|
+
};
|
|
784
|
+
try {
|
|
785
|
+
await writeCaptureMetadata(artifact, metadata);
|
|
786
|
+
} catch (error) {
|
|
787
|
+
await releaseLock?.();
|
|
788
|
+
if (artifact.managed) {
|
|
789
|
+
await rm(artifact.directory, { recursive: true, force: true });
|
|
790
|
+
}
|
|
791
|
+
throw error;
|
|
792
|
+
}
|
|
793
|
+
return artifact;
|
|
794
|
+
}
|
|
795
|
+
async function startCaptureArtifact(artifact, now = /* @__PURE__ */ new Date()) {
|
|
796
|
+
assertMutable(artifact);
|
|
797
|
+
const timestamp = now.toISOString();
|
|
798
|
+
await writeCaptureMetadata(artifact, {
|
|
799
|
+
...artifact.metadata,
|
|
800
|
+
status: "running",
|
|
801
|
+
startedAt: timestamp,
|
|
802
|
+
updatedAt: timestamp
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
async function completeCaptureArtifact(artifact, options = {}) {
|
|
806
|
+
assertMutable(artifact);
|
|
807
|
+
await access(artifact.manifestPath);
|
|
808
|
+
const timestamp = (options.now ?? /* @__PURE__ */ new Date()).toISOString();
|
|
809
|
+
const relativeRecording = options.recordingPath ? relativeArtifactPath(artifact.directory, options.recordingPath) : void 0;
|
|
810
|
+
await writeCaptureMetadata(artifact, {
|
|
811
|
+
...artifact.metadata,
|
|
812
|
+
status: "completed",
|
|
813
|
+
updatedAt: timestamp,
|
|
814
|
+
finishedAt: timestamp,
|
|
815
|
+
paths: {
|
|
816
|
+
...artifact.metadata.paths,
|
|
817
|
+
trace: options.traceAvailable ? "trace.zip" : void 0,
|
|
818
|
+
recording: relativeRecording
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
if (artifact.managed && artifact.demoDirectory) {
|
|
822
|
+
await updateLatestCapturePointer(artifact, timestamp).catch(
|
|
823
|
+
() => void 0
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
async function failCaptureArtifact(artifact, error, now = /* @__PURE__ */ new Date()) {
|
|
828
|
+
assertMutable(artifact);
|
|
829
|
+
const timestamp = now.toISOString();
|
|
830
|
+
await writeCaptureMetadata(artifact, {
|
|
831
|
+
...artifact.metadata,
|
|
832
|
+
status: "failed",
|
|
833
|
+
startedAt: artifact.metadata.startedAt ?? timestamp,
|
|
834
|
+
updatedAt: timestamp,
|
|
835
|
+
finishedAt: timestamp,
|
|
836
|
+
error: { message: redactCaptureErrorMessage(error, artifact.directory) }
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
async function cancelCaptureArtifact(artifact, now = /* @__PURE__ */ new Date()) {
|
|
840
|
+
assertMutable(artifact);
|
|
841
|
+
const timestamp = now.toISOString();
|
|
842
|
+
await writeCaptureMetadata(artifact, {
|
|
843
|
+
...artifact.metadata,
|
|
844
|
+
status: "cancelled",
|
|
845
|
+
startedAt: artifact.metadata.startedAt ?? timestamp,
|
|
846
|
+
updatedAt: timestamp,
|
|
847
|
+
finishedAt: timestamp
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
async function writeCaptureManifestAtomic(artifact, json) {
|
|
851
|
+
await writeFileAtomic(artifact.manifestPath, json);
|
|
852
|
+
}
|
|
853
|
+
async function resolveLatestCompletedCapture(rootDirectory, demoId) {
|
|
854
|
+
let canonicalRoot;
|
|
855
|
+
try {
|
|
856
|
+
canonicalRoot = await realpath(rootDirectory);
|
|
857
|
+
} catch {
|
|
858
|
+
return void 0;
|
|
859
|
+
}
|
|
860
|
+
const directories = [
|
|
861
|
+
path2.join(rootDirectory, captureNamespace(demoId)),
|
|
862
|
+
path2.join(rootDirectory, captureSlug(demoId))
|
|
863
|
+
];
|
|
864
|
+
const scanned = (await Promise.all(
|
|
865
|
+
directories.map(
|
|
866
|
+
(dir) => scanCompletedCaptures(canonicalRoot, dir, demoId)
|
|
867
|
+
)
|
|
868
|
+
)).flat().sort(compareCompletedCaptures)[0];
|
|
869
|
+
if (scanned) {
|
|
870
|
+
await repairPointer(scanned).catch(() => void 0);
|
|
871
|
+
return {
|
|
872
|
+
captureDir: scanned.directory,
|
|
873
|
+
manifestPath: path2.join(scanned.directory, "manifest.json"),
|
|
874
|
+
captureRunId: scanned.metadata.captureRunId,
|
|
875
|
+
legacy: false
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
if (path2.basename(demoId) !== demoId || demoId === "." || demoId === "..") {
|
|
879
|
+
return void 0;
|
|
880
|
+
}
|
|
881
|
+
const legacyDir = path2.join(rootDirectory, demoId);
|
|
882
|
+
const safeLegacyDir = await canonicalContained(canonicalRoot, legacyDir);
|
|
883
|
+
if (safeLegacyDir && await isReusableCaptureDirectory(safeLegacyDir, demoId)) {
|
|
884
|
+
return {
|
|
885
|
+
captureDir: legacyDir,
|
|
886
|
+
manifestPath: path2.join(legacyDir, "manifest.json"),
|
|
887
|
+
legacy: true
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
return void 0;
|
|
891
|
+
}
|
|
892
|
+
async function isReusableCaptureDirectory(directory, demoId) {
|
|
893
|
+
try {
|
|
894
|
+
const canonicalDirectory = await realpath(directory);
|
|
895
|
+
const manifestPath = await canonicalContained(
|
|
896
|
+
canonicalDirectory,
|
|
897
|
+
path2.join(directory, "manifest.json")
|
|
898
|
+
);
|
|
899
|
+
if (!manifestPath) return false;
|
|
900
|
+
const manifest = parseRecordedDemoManifestJson(
|
|
901
|
+
await readFile2(manifestPath, "utf8")
|
|
902
|
+
);
|
|
903
|
+
if (manifest.demoId !== demoId) return false;
|
|
904
|
+
try {
|
|
905
|
+
const metadataCandidate = path2.join(directory, "metadata.json");
|
|
906
|
+
const metadataPath = await canonicalContained(
|
|
907
|
+
canonicalDirectory,
|
|
908
|
+
metadataCandidate
|
|
909
|
+
);
|
|
910
|
+
if (!metadataPath) {
|
|
911
|
+
try {
|
|
912
|
+
await lstat(metadataCandidate);
|
|
913
|
+
return false;
|
|
914
|
+
} catch (error) {
|
|
915
|
+
return isNodeError(error, "ENOENT");
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
const metadata = parseCaptureArtifactMetadataJson(
|
|
919
|
+
await readFile2(metadataPath, "utf8")
|
|
920
|
+
);
|
|
921
|
+
return metadata.status === "completed" && metadata.demoId === demoId && metadata.captureRunId === manifest.captureRunId && metadata.captureEnvironmentHash === manifest.captureEnvironmentHash;
|
|
922
|
+
} catch (error) {
|
|
923
|
+
return isNodeError(error, "ENOENT");
|
|
924
|
+
}
|
|
925
|
+
} catch {
|
|
926
|
+
return false;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
function captureSlug(demoId) {
|
|
930
|
+
const slug = demoId.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
|
|
931
|
+
return slug || "demo";
|
|
932
|
+
}
|
|
933
|
+
function captureNamespace(demoId) {
|
|
934
|
+
const digest = createHash3("sha256").update(demoId).digest("hex").slice(0, 12);
|
|
935
|
+
return `${captureSlug(demoId)}-${digest}`;
|
|
936
|
+
}
|
|
937
|
+
function isCaptureAbort(error, signal) {
|
|
938
|
+
return Boolean(signal?.aborted) || error instanceof CaptureAbortError;
|
|
939
|
+
}
|
|
940
|
+
var CaptureAbortError = class extends Error {
|
|
941
|
+
constructor() {
|
|
942
|
+
super("Capture was cancelled.");
|
|
943
|
+
this.name = "AbortError";
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
async function writeCaptureMetadata(artifact, metadata) {
|
|
947
|
+
const validated = parseCaptureArtifactMetadata(metadata);
|
|
948
|
+
await writeFileAtomic(
|
|
949
|
+
artifact.metadataPath,
|
|
950
|
+
`${JSON.stringify(validated, null, 2)}
|
|
951
|
+
`
|
|
952
|
+
);
|
|
953
|
+
artifact.metadata = validated;
|
|
954
|
+
}
|
|
955
|
+
async function updateLatestCapturePointer(artifact, completedAt, force = false) {
|
|
956
|
+
const demoDirectory = artifact.demoDirectory;
|
|
957
|
+
const lockPath = path2.join(demoDirectory, ".latest.lock");
|
|
958
|
+
const releaseLock = await acquireCaptureLeaseLock(lockPath);
|
|
959
|
+
try {
|
|
960
|
+
const pointerPath = path2.join(demoDirectory, "latest.json");
|
|
961
|
+
let current;
|
|
962
|
+
try {
|
|
963
|
+
current = parseLatestCapturePointerJson(
|
|
964
|
+
await readFile2(pointerPath, "utf8")
|
|
965
|
+
);
|
|
966
|
+
} catch {
|
|
967
|
+
current = void 0;
|
|
968
|
+
}
|
|
969
|
+
const next = parseLatestCapturePointer({
|
|
970
|
+
schemaVersion: 1,
|
|
971
|
+
demoId: artifact.demoId,
|
|
972
|
+
captureRunId: artifact.captureRunId,
|
|
973
|
+
captureDirectory: path2.basename(artifact.directory),
|
|
974
|
+
completedAt
|
|
975
|
+
});
|
|
976
|
+
if (force || !current || comparePointers(next, current) > 0) {
|
|
977
|
+
await writeFileAtomic(pointerPath, `${JSON.stringify(next, null, 2)}
|
|
978
|
+
`);
|
|
979
|
+
}
|
|
980
|
+
} finally {
|
|
981
|
+
await releaseLock();
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
function compareCompletedCaptures(left, right) {
|
|
985
|
+
const byFinishedAt = (right.finishedAt ?? "").localeCompare(
|
|
986
|
+
left.finishedAt ?? ""
|
|
987
|
+
);
|
|
988
|
+
return byFinishedAt || right.metadata.captureRunId.localeCompare(left.metadata.captureRunId);
|
|
989
|
+
}
|
|
990
|
+
async function scanCompletedCaptures(rootDirectory, demoDirectory, demoId) {
|
|
991
|
+
const publicDemoDirectory = demoDirectory;
|
|
992
|
+
const safeDemoDirectory = await canonicalContained(
|
|
993
|
+
rootDirectory,
|
|
994
|
+
demoDirectory
|
|
995
|
+
);
|
|
996
|
+
if (!safeDemoDirectory) return [];
|
|
997
|
+
demoDirectory = safeDemoDirectory;
|
|
998
|
+
let entries;
|
|
999
|
+
try {
|
|
1000
|
+
entries = await readdir(demoDirectory, { withFileTypes: true });
|
|
1001
|
+
} catch {
|
|
1002
|
+
return [];
|
|
1003
|
+
}
|
|
1004
|
+
const candidates = await Promise.all(
|
|
1005
|
+
entries.filter((entry) => entry.isDirectory()).map(async (entry) => {
|
|
1006
|
+
const directory = path2.join(demoDirectory, entry.name);
|
|
1007
|
+
const publicDirectory = path2.join(publicDemoDirectory, entry.name);
|
|
1008
|
+
try {
|
|
1009
|
+
const metadataPath = await canonicalContained(
|
|
1010
|
+
directory,
|
|
1011
|
+
path2.join(directory, "metadata.json")
|
|
1012
|
+
);
|
|
1013
|
+
if (!metadataPath) return void 0;
|
|
1014
|
+
const metadata = parseCaptureArtifactMetadataJson(
|
|
1015
|
+
await readFile2(metadataPath, "utf8")
|
|
1016
|
+
);
|
|
1017
|
+
if (metadata.status !== "completed" || metadata.demoId !== demoId || !await isReusableCaptureDirectory(directory, demoId)) {
|
|
1018
|
+
return void 0;
|
|
1019
|
+
}
|
|
1020
|
+
return {
|
|
1021
|
+
directory: publicDirectory,
|
|
1022
|
+
demoDirectory: publicDemoDirectory,
|
|
1023
|
+
metadata,
|
|
1024
|
+
finishedAt: metadata.finishedAt
|
|
1025
|
+
};
|
|
1026
|
+
} catch {
|
|
1027
|
+
return void 0;
|
|
1028
|
+
}
|
|
1029
|
+
})
|
|
1030
|
+
);
|
|
1031
|
+
return candidates.filter((item) => Boolean(item));
|
|
1032
|
+
}
|
|
1033
|
+
async function canonicalContained(root, candidate) {
|
|
1034
|
+
try {
|
|
1035
|
+
const canonical = await realpath(candidate);
|
|
1036
|
+
const relative2 = path2.relative(root, canonical);
|
|
1037
|
+
return relative2 !== ".." && !relative2.startsWith(`..${path2.sep}`) && !path2.isAbsolute(relative2) ? canonical : void 0;
|
|
1038
|
+
} catch {
|
|
1039
|
+
return void 0;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
async function repairPointer(capture) {
|
|
1043
|
+
const artifact = {
|
|
1044
|
+
captureRunId: capture.metadata.captureRunId,
|
|
1045
|
+
demoId: capture.metadata.demoId,
|
|
1046
|
+
directory: capture.directory,
|
|
1047
|
+
demoDirectory: capture.demoDirectory
|
|
1048
|
+
};
|
|
1049
|
+
await updateLatestCapturePointer(
|
|
1050
|
+
artifact,
|
|
1051
|
+
capture.finishedAt ?? capture.metadata.updatedAt,
|
|
1052
|
+
true
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
function comparePointers(left, right) {
|
|
1056
|
+
const byTime = left.completedAt.localeCompare(right.completedAt);
|
|
1057
|
+
return byTime || left.captureRunId.localeCompare(right.captureRunId);
|
|
1058
|
+
}
|
|
1059
|
+
function buildInitialMetadata(options, captureRunId, createdAt) {
|
|
1060
|
+
return parseCaptureArtifactMetadata({
|
|
1061
|
+
schemaVersion: 1,
|
|
1062
|
+
captureRunId,
|
|
1063
|
+
demoId: options.demoId,
|
|
1064
|
+
definitionHash: options.definitionHash,
|
|
1065
|
+
captureHash: options.captureHash,
|
|
1066
|
+
captureEnvironmentHash: options.captureEnvironmentHash,
|
|
1067
|
+
status: "created",
|
|
1068
|
+
createdAt,
|
|
1069
|
+
updatedAt: createdAt,
|
|
1070
|
+
paths: { manifest: "manifest.json", screenshots: "screenshots" },
|
|
1071
|
+
environment: options.environment
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
async function acquireCaptureLeaseLock(lockPath, options = {}) {
|
|
1075
|
+
const token = randomBytes(16).toString("hex");
|
|
1076
|
+
const leaseMs = options.leaseMs ?? LOCK_LEASE_MS;
|
|
1077
|
+
const retryMs = options.retryMs ?? LOCK_RETRY_MS;
|
|
1078
|
+
const maxAttempts = options.maxAttempts ?? LATEST_LOCK_ATTEMPTS;
|
|
1079
|
+
const malformedStaleMs = options.malformedStaleMs ?? leaseMs;
|
|
1080
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
1081
|
+
const acquired = await withOperationGuard(
|
|
1082
|
+
lockPath,
|
|
1083
|
+
malformedStaleMs,
|
|
1084
|
+
async () => {
|
|
1085
|
+
await cleanupLegacyRecoveryMarker(lockPath, malformedStaleMs);
|
|
1086
|
+
const exists = await fileExists(lockPath);
|
|
1087
|
+
const current = exists ? await readLock(lockPath) : void 0;
|
|
1088
|
+
if (!exists) {
|
|
1089
|
+
const now2 = Date.now();
|
|
1090
|
+
await writeFile(
|
|
1091
|
+
lockPath,
|
|
1092
|
+
lockContents(token, process.pid, now2, leaseMs),
|
|
1093
|
+
{ flag: "wx" }
|
|
1094
|
+
);
|
|
1095
|
+
return true;
|
|
1096
|
+
}
|
|
1097
|
+
const malformedOld = current ? false : await existsOldMalformed(lockPath, malformedStaleMs);
|
|
1098
|
+
if (!malformedOld && (!current || isProcessAlive(current.pid))) {
|
|
1099
|
+
return false;
|
|
1100
|
+
}
|
|
1101
|
+
await options.insideRecoveryGuard?.();
|
|
1102
|
+
const confirmed = await readLock(lockPath);
|
|
1103
|
+
const confirmedMalformedOld = confirmed ? false : await existsOldMalformed(lockPath, malformedStaleMs);
|
|
1104
|
+
if (!confirmedMalformedOld && (!confirmed || isProcessAlive(confirmed.pid))) {
|
|
1105
|
+
return false;
|
|
1106
|
+
}
|
|
1107
|
+
await rm(lockPath, { force: true });
|
|
1108
|
+
const now = Date.now();
|
|
1109
|
+
await writeFile(
|
|
1110
|
+
lockPath,
|
|
1111
|
+
lockContents(token, process.pid, now, leaseMs),
|
|
1112
|
+
{ flag: "wx" }
|
|
1113
|
+
);
|
|
1114
|
+
return true;
|
|
1115
|
+
}
|
|
1116
|
+
);
|
|
1117
|
+
if (acquired) {
|
|
1118
|
+
let released = false;
|
|
1119
|
+
const refresh = async () => {
|
|
1120
|
+
if (released) return false;
|
|
1121
|
+
return awaitOperationGuard(
|
|
1122
|
+
lockPath,
|
|
1123
|
+
malformedStaleMs,
|
|
1124
|
+
retryMs,
|
|
1125
|
+
maxAttempts,
|
|
1126
|
+
async () => {
|
|
1127
|
+
const current = await readLock(lockPath);
|
|
1128
|
+
return current?.token === token && current.pid === process.pid;
|
|
1129
|
+
}
|
|
1130
|
+
);
|
|
1131
|
+
};
|
|
1132
|
+
let releasePromise;
|
|
1133
|
+
const release = Object.assign(
|
|
1134
|
+
async () => {
|
|
1135
|
+
if (!releasePromise) {
|
|
1136
|
+
released = true;
|
|
1137
|
+
releasePromise = awaitOperationGuard(
|
|
1138
|
+
lockPath,
|
|
1139
|
+
malformedStaleMs,
|
|
1140
|
+
retryMs,
|
|
1141
|
+
maxAttempts,
|
|
1142
|
+
async () => {
|
|
1143
|
+
await options.insideReleaseGuard?.();
|
|
1144
|
+
const current = await readLock(lockPath);
|
|
1145
|
+
if (current?.token === token) {
|
|
1146
|
+
await rm(lockPath, { force: true });
|
|
1147
|
+
}
|
|
1148
|
+
return true;
|
|
1149
|
+
}
|
|
1150
|
+
).then(() => void 0);
|
|
1151
|
+
}
|
|
1152
|
+
await releasePromise;
|
|
1153
|
+
},
|
|
1154
|
+
{ refresh }
|
|
1155
|
+
);
|
|
1156
|
+
return release;
|
|
1157
|
+
}
|
|
1158
|
+
if (attempt + 1 < maxAttempts) await delay(retryMs);
|
|
1159
|
+
}
|
|
1160
|
+
throw new Error(
|
|
1161
|
+
`Timed out acquiring capture lock ${path2.basename(lockPath)}.`
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
async function awaitOperationGuard(lockPath, malformedStaleMs, retryMs, maxAttempts, operation) {
|
|
1165
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
1166
|
+
const result = await withOperationGuard(
|
|
1167
|
+
lockPath,
|
|
1168
|
+
malformedStaleMs,
|
|
1169
|
+
operation
|
|
1170
|
+
);
|
|
1171
|
+
if (result !== void 0) return result;
|
|
1172
|
+
if (attempt + 1 < maxAttempts) await delay(retryMs);
|
|
1173
|
+
}
|
|
1174
|
+
throw new Error(
|
|
1175
|
+
`Timed out entering capture lock operation guard ${path2.basename(lockPath)}.`
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
function lockContents(token, pid, createdAt, leaseMs) {
|
|
1179
|
+
return `${JSON.stringify({ token, pid, createdAt, expiresAt: Date.now() + leaseMs })}
|
|
1180
|
+
`;
|
|
1181
|
+
}
|
|
1182
|
+
async function readLock(lockPath) {
|
|
1183
|
+
try {
|
|
1184
|
+
const value = JSON.parse(await readFile2(lockPath, "utf8"));
|
|
1185
|
+
if (typeof value.token !== "string" || !Number.isInteger(value.pid) || !Number.isFinite(value.createdAt) || !Number.isFinite(value.expiresAt)) {
|
|
1186
|
+
return void 0;
|
|
1187
|
+
}
|
|
1188
|
+
return value;
|
|
1189
|
+
} catch {
|
|
1190
|
+
return void 0;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
async function withOperationGuard(lockPath, malformedStaleMs, operation) {
|
|
1194
|
+
const guardPath = `${lockPath}.operation`;
|
|
1195
|
+
const recoveryPath = `${guardPath}.recovery`;
|
|
1196
|
+
const token = randomBytes(16).toString("hex");
|
|
1197
|
+
await cleanupOrphanGuardRecovery(recoveryPath, malformedStaleMs);
|
|
1198
|
+
if (await fileExists(recoveryPath)) return void 0;
|
|
1199
|
+
try {
|
|
1200
|
+
const now = Date.now();
|
|
1201
|
+
await writeFile(
|
|
1202
|
+
guardPath,
|
|
1203
|
+
lockContents(token, process.pid, now, Math.max(malformedStaleMs, 100)),
|
|
1204
|
+
{ flag: "wx" }
|
|
1205
|
+
);
|
|
1206
|
+
} catch (error) {
|
|
1207
|
+
if (!isNodeError(error, "EEXIST")) throw error;
|
|
1208
|
+
const guard = await readLock(guardPath);
|
|
1209
|
+
const malformedOld = guard ? false : await existsOldMalformed(guardPath, malformedStaleMs);
|
|
1210
|
+
if (!malformedOld && (!guard || isProcessAlive(guard.pid)))
|
|
1211
|
+
return void 0;
|
|
1212
|
+
if (!await claimGuardRecovery(recoveryPath, malformedStaleMs)) {
|
|
1213
|
+
return void 0;
|
|
1214
|
+
}
|
|
1215
|
+
let recovered = false;
|
|
1216
|
+
try {
|
|
1217
|
+
const confirmed = await readLock(guardPath);
|
|
1218
|
+
const confirmedMalformedOld = confirmed ? false : await existsOldMalformed(guardPath, malformedStaleMs);
|
|
1219
|
+
if (confirmedMalformedOld || confirmed && !isProcessAlive(confirmed.pid)) {
|
|
1220
|
+
await rm(guardPath, { force: true });
|
|
1221
|
+
const now = Date.now();
|
|
1222
|
+
await writeFile(
|
|
1223
|
+
guardPath,
|
|
1224
|
+
lockContents(
|
|
1225
|
+
token,
|
|
1226
|
+
process.pid,
|
|
1227
|
+
now,
|
|
1228
|
+
Math.max(malformedStaleMs, 100)
|
|
1229
|
+
),
|
|
1230
|
+
{ flag: "wx" }
|
|
1231
|
+
);
|
|
1232
|
+
recovered = true;
|
|
1233
|
+
}
|
|
1234
|
+
} finally {
|
|
1235
|
+
await rm(recoveryPath, { force: true });
|
|
1236
|
+
}
|
|
1237
|
+
if (!recovered) return void 0;
|
|
1238
|
+
}
|
|
1239
|
+
try {
|
|
1240
|
+
return await operation();
|
|
1241
|
+
} finally {
|
|
1242
|
+
const guard = await readLock(guardPath);
|
|
1243
|
+
if (guard?.token === token) await rm(guardPath, { force: true });
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
async function claimGuardRecovery(recoveryPath, malformedStaleMs) {
|
|
1247
|
+
await cleanupOrphanGuardRecovery(recoveryPath, malformedStaleMs);
|
|
1248
|
+
const now = Date.now();
|
|
1249
|
+
try {
|
|
1250
|
+
await writeFile(
|
|
1251
|
+
recoveryPath,
|
|
1252
|
+
lockContents(
|
|
1253
|
+
randomBytes(16).toString("hex"),
|
|
1254
|
+
process.pid,
|
|
1255
|
+
now,
|
|
1256
|
+
Math.max(malformedStaleMs, 100)
|
|
1257
|
+
),
|
|
1258
|
+
{ flag: "wx" }
|
|
1259
|
+
);
|
|
1260
|
+
return true;
|
|
1261
|
+
} catch {
|
|
1262
|
+
return false;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
async function cleanupOrphanGuardRecovery(recoveryPath, malformedStaleMs) {
|
|
1266
|
+
const marker = await readLock(recoveryPath);
|
|
1267
|
+
const malformedOld = marker ? false : await existsOldMalformed(recoveryPath, malformedStaleMs);
|
|
1268
|
+
if (malformedOld || marker && !isProcessAlive(marker.pid)) {
|
|
1269
|
+
await rm(recoveryPath, { force: true });
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
async function cleanupLegacyRecoveryMarker(lockPath, malformedStaleMs) {
|
|
1273
|
+
await cleanupOrphanGuardRecovery(`${lockPath}.recovery`, malformedStaleMs);
|
|
1274
|
+
}
|
|
1275
|
+
async function fileExists(file) {
|
|
1276
|
+
return access(file).then(() => true).catch(() => false);
|
|
1277
|
+
}
|
|
1278
|
+
function isProcessAlive(pid) {
|
|
1279
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
1280
|
+
try {
|
|
1281
|
+
process.kill(pid, 0);
|
|
1282
|
+
return true;
|
|
1283
|
+
} catch (error) {
|
|
1284
|
+
return isNodeError(error, "EPERM");
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
async function existsOldMalformed(file, ageMs) {
|
|
1288
|
+
return stat(file).then((value) => Date.now() - value.mtimeMs > ageMs).catch(() => false);
|
|
1289
|
+
}
|
|
1290
|
+
async function writeFileAtomic(file, contents) {
|
|
1291
|
+
const temporary = `${file}.${randomBytes(6).toString("hex")}.tmp`;
|
|
1292
|
+
try {
|
|
1293
|
+
await writeFile(temporary, contents, { flag: "wx" });
|
|
1294
|
+
await rename(temporary, file);
|
|
1295
|
+
} catch (error) {
|
|
1296
|
+
await rm(temporary, { force: true }).catch(() => void 0);
|
|
1297
|
+
throw error;
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
function relativeArtifactPath(directory, target) {
|
|
1301
|
+
const relative2 = path2.relative(directory, path2.resolve(target));
|
|
1302
|
+
if (relative2 && !relative2.startsWith("..") && !path2.isAbsolute(relative2)) {
|
|
1303
|
+
return relative2;
|
|
1304
|
+
}
|
|
1305
|
+
return path2.basename(target);
|
|
1306
|
+
}
|
|
1307
|
+
function redactCaptureErrorMessage(error, directory) {
|
|
1308
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
1309
|
+
const paths = [
|
|
1310
|
+
[directory ? path2.resolve(directory) : "", "[capture]"],
|
|
1311
|
+
[process.cwd(), "[workspace]"],
|
|
1312
|
+
[homedir(), "[home]"]
|
|
1313
|
+
];
|
|
1314
|
+
const pathRedacted = paths.reduce(
|
|
1315
|
+
(message, [sensitive, replacement]) => sensitive ? message.split(sensitive).join(replacement) : message,
|
|
1316
|
+
raw
|
|
1317
|
+
);
|
|
1318
|
+
return pathRedacted.replace(
|
|
1319
|
+
/\b(Proxy-Authorization|Authorization|Set-Cookie|Cookie)\s*:\s*[^\r\n]*/gi,
|
|
1320
|
+
"$1: [redacted]"
|
|
1321
|
+
).replace(/:\/\/[^\s/@:]+(?::[^\s/@]*)?@/g, "://[redacted]@").replace(
|
|
1322
|
+
/([?&](?:access_?token|token|code|api_?key|key|secret|password|passwd|auth|authorization|signature|sig)=)[^&#\s]*/gi,
|
|
1323
|
+
"$1[redacted]"
|
|
1324
|
+
).replace(
|
|
1325
|
+
/\b((?:access_?token|token|api_?key|secret|password|passwd|authorization|signature)\s*[=:]\s*)[^\s,;&#/?]+/gi,
|
|
1326
|
+
"$1[redacted]"
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1329
|
+
function assertMutable(artifact) {
|
|
1330
|
+
if (["completed", "failed", "cancelled"].includes(artifact.metadata.status)) {
|
|
1331
|
+
throw new Error(
|
|
1332
|
+
`Capture ${artifact.captureRunId} is already ${artifact.metadata.status}.`
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
function isNodeError(error, code) {
|
|
1337
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
1338
|
+
}
|
|
1339
|
+
function delay(durationMs) {
|
|
1340
|
+
return new Promise((resolve2) => setTimeout(resolve2, durationMs));
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// src/runner.ts
|
|
1344
|
+
async function runDemo(ir, options = {}) {
|
|
1345
|
+
return runDemoWithBindings(ir, defaultBindings, options);
|
|
1346
|
+
}
|
|
1347
|
+
async function runDemoWithBindings(ir, bindings, options = {}) {
|
|
1348
|
+
ir = parseDemoIR(ir);
|
|
1349
|
+
const configuredEnvironment = options.environment ?? {};
|
|
1350
|
+
const resolvedEnvironment = await resolveCaptureEnvironment(options);
|
|
1351
|
+
const environment = resolvedEnvironment.environment;
|
|
1352
|
+
const viewport = environment.viewport;
|
|
1353
|
+
const deviceScaleFactor = environment.deviceScaleFactor;
|
|
1354
|
+
const timeoutMs = environment.timeoutMs;
|
|
1355
|
+
assertPositiveFinite("viewport.width", viewport.width);
|
|
1356
|
+
assertPositiveFinite("viewport.height", viewport.height);
|
|
1357
|
+
assertPositiveFinite("deviceScaleFactor", deviceScaleFactor);
|
|
1358
|
+
assertPositiveFinite("timeoutMs", timeoutMs);
|
|
1359
|
+
throwIfAborted(options.signal);
|
|
1360
|
+
const settleStrategy = resolveSettleStrategy(environment.settle);
|
|
1361
|
+
const artifact = await createCaptureArtifact({
|
|
1362
|
+
rootDirectory: options.captureRootDir ?? resolve(".democraft", "runs"),
|
|
1363
|
+
outputDirectory: options.outputDir,
|
|
1364
|
+
demoId: ir.id,
|
|
1365
|
+
definitionHash: ir.definitionHash,
|
|
1366
|
+
captureHash: ir.captureHash,
|
|
1367
|
+
captureEnvironmentHash: resolvedEnvironment.captureEnvironmentHash,
|
|
1368
|
+
environment
|
|
1369
|
+
});
|
|
1370
|
+
const diagnostics = [];
|
|
1371
|
+
const steps = [];
|
|
1372
|
+
let browser;
|
|
1373
|
+
let context;
|
|
1374
|
+
let page;
|
|
1375
|
+
let traceStarted = false;
|
|
1376
|
+
let recordingPath;
|
|
1377
|
+
let traceAvailable = false;
|
|
1378
|
+
try {
|
|
1379
|
+
await startCaptureArtifact(artifact);
|
|
1380
|
+
await options.onArtifactCreated?.({
|
|
1381
|
+
captureRunId: artifact.captureRunId,
|
|
1382
|
+
outputDir: artifact.directory,
|
|
1383
|
+
manifestPath: artifact.manifestPath,
|
|
1384
|
+
metadataPath: artifact.metadataPath
|
|
1385
|
+
});
|
|
1386
|
+
throwIfAborted(options.signal);
|
|
1387
|
+
await mkdir2(artifact.screenshotsPath, { recursive: true });
|
|
1388
|
+
throwIfAborted(options.signal);
|
|
1389
|
+
browser = await bindings.chromium.launch({
|
|
1390
|
+
headless: environment.headless
|
|
1391
|
+
});
|
|
1392
|
+
throwIfAborted(options.signal);
|
|
1393
|
+
context = await browser.newContext({
|
|
1394
|
+
viewport,
|
|
1395
|
+
deviceScaleFactor,
|
|
1396
|
+
locale: environment.locale,
|
|
1397
|
+
timezoneId: environment.timezone,
|
|
1398
|
+
storageState: configuredEnvironment.storageState,
|
|
1399
|
+
recordVideo: { dir: artifact.directory, size: viewport }
|
|
1400
|
+
});
|
|
1401
|
+
let executionFailed = false;
|
|
1402
|
+
let executionError;
|
|
1403
|
+
let cleanupFailed = false;
|
|
1404
|
+
let cleanupError;
|
|
1405
|
+
try {
|
|
1406
|
+
throwIfAborted(options.signal);
|
|
1407
|
+
await context.tracing?.start({
|
|
1408
|
+
screenshots: true,
|
|
1409
|
+
snapshots: true,
|
|
1410
|
+
sources: true
|
|
1411
|
+
});
|
|
1412
|
+
traceStarted = Boolean(context.tracing);
|
|
1413
|
+
throwIfAborted(options.signal);
|
|
1414
|
+
page = await context.newPage();
|
|
1415
|
+
for (const scene of ir.scenes) {
|
|
1416
|
+
for (const step of scene.steps) {
|
|
1417
|
+
throwIfAborted(options.signal);
|
|
1418
|
+
const recorded = await executeStep({
|
|
1419
|
+
ir,
|
|
1420
|
+
page,
|
|
1421
|
+
sceneId: scene.id,
|
|
1422
|
+
step,
|
|
1423
|
+
// Default 8s: SPAs (Next App Router) can take several seconds to
|
|
1424
|
+
// swap a route on a cold code-split chunk, and this timeout gates
|
|
1425
|
+
// both target resolution and click-navigation. Override for faster
|
|
1426
|
+
// apps or mock runtimes.
|
|
1427
|
+
timeoutMs,
|
|
1428
|
+
screenshotsPath: artifact.screenshotsPath,
|
|
1429
|
+
diagnostics,
|
|
1430
|
+
settleStrategy
|
|
1431
|
+
});
|
|
1432
|
+
steps.push(recorded);
|
|
1433
|
+
throwIfAborted(options.signal);
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
} catch (error) {
|
|
1437
|
+
executionFailed = true;
|
|
1438
|
+
executionError = error;
|
|
1439
|
+
} finally {
|
|
1440
|
+
if (traceStarted) {
|
|
1441
|
+
try {
|
|
1442
|
+
await context.tracing?.stop({ path: artifact.tracePath });
|
|
1443
|
+
} catch (error) {
|
|
1444
|
+
cleanupFailed = true;
|
|
1445
|
+
cleanupError = error;
|
|
1446
|
+
}
|
|
1447
|
+
if (!cleanupFailed) {
|
|
1448
|
+
traceAvailable = await access2(artifact.tracePath).then(() => true).catch(() => false);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
try {
|
|
1452
|
+
await context.close();
|
|
1453
|
+
} catch (error) {
|
|
1454
|
+
if (!cleanupFailed) cleanupError = error;
|
|
1455
|
+
cleanupFailed = true;
|
|
1456
|
+
}
|
|
1457
|
+
try {
|
|
1458
|
+
recordingPath = await page?.video?.()?.path().catch(() => void 0);
|
|
1459
|
+
} catch {
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
if (executionFailed) throw executionError;
|
|
1463
|
+
if (cleanupFailed) throw cleanupError;
|
|
1464
|
+
await browser.close();
|
|
1465
|
+
browser = void 0;
|
|
1466
|
+
throwIfAborted(options.signal);
|
|
1467
|
+
const manifest = parseRecordedDemoManifest({
|
|
1468
|
+
schemaVersion,
|
|
1469
|
+
demoId: ir.id,
|
|
1470
|
+
captureRunId: artifact.captureRunId,
|
|
1471
|
+
definitionHash: ir.definitionHash,
|
|
1472
|
+
captureHash: ir.captureHash,
|
|
1473
|
+
captureEnvironmentHash: resolvedEnvironment.captureEnvironmentHash,
|
|
1474
|
+
capture: {
|
|
1475
|
+
width: viewport.width,
|
|
1476
|
+
height: viewport.height,
|
|
1477
|
+
deviceScaleFactor
|
|
1478
|
+
},
|
|
1479
|
+
recording: recordingPath ? {
|
|
1480
|
+
path: publicArtifactPath(recordingPath),
|
|
1481
|
+
width: viewport.width,
|
|
1482
|
+
height: viewport.height
|
|
1483
|
+
} : void 0,
|
|
1484
|
+
tracePath: traceAvailable ? publicArtifactPath(artifact.tracePath) : void 0,
|
|
1485
|
+
screenshotsPath: publicArtifactPath(artifact.screenshotsPath),
|
|
1486
|
+
steps,
|
|
1487
|
+
diagnostics
|
|
1488
|
+
});
|
|
1489
|
+
await writeCaptureManifestAtomic(
|
|
1490
|
+
artifact,
|
|
1491
|
+
`${JSON.stringify(manifest, null, 2)}
|
|
1492
|
+
`
|
|
1493
|
+
);
|
|
1494
|
+
await completeCaptureArtifact(artifact, { recordingPath, traceAvailable });
|
|
1495
|
+
return manifest;
|
|
1496
|
+
} catch (error) {
|
|
1497
|
+
try {
|
|
1498
|
+
if (isCaptureAbort(error, options.signal)) {
|
|
1499
|
+
await cancelCaptureArtifact(artifact);
|
|
1500
|
+
} else {
|
|
1501
|
+
await failCaptureArtifact(artifact, error);
|
|
1502
|
+
}
|
|
1503
|
+
} catch {
|
|
1504
|
+
}
|
|
1505
|
+
throw error;
|
|
1506
|
+
} finally {
|
|
1507
|
+
await browser?.close().catch(() => void 0);
|
|
1508
|
+
await artifact.releaseLock?.();
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
function assertPositiveFinite(name, value) {
|
|
1512
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
1513
|
+
throw new RangeError(`${name} must be a finite number greater than 0.`);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
function throwIfAborted(signal) {
|
|
1517
|
+
if (!signal?.aborted) return;
|
|
1518
|
+
throw new CaptureAbortError();
|
|
1519
|
+
}
|
|
1520
|
+
function publicArtifactPath(target) {
|
|
1521
|
+
const absolute = resolve(target);
|
|
1522
|
+
const fromCwd = relative(process.cwd(), absolute);
|
|
1523
|
+
return fromCwd && !fromCwd.startsWith("..") && !isAbsolute(fromCwd) ? fromCwd : absolute;
|
|
1524
|
+
}
|
|
1525
|
+
export {
|
|
1526
|
+
CAPTURE_ENVIRONMENT_HASH_PREFIX,
|
|
1527
|
+
CaptureAbortError,
|
|
1528
|
+
DEFAULT_SETTLE_STRATEGY,
|
|
1529
|
+
acquireCaptureLeaseLock,
|
|
1530
|
+
cancelCaptureArtifact,
|
|
1531
|
+
canonicalScreenshotFilename,
|
|
1532
|
+
captureNamespace,
|
|
1533
|
+
captureSlug,
|
|
1534
|
+
completeCaptureArtifact,
|
|
1535
|
+
createCaptureArtifact,
|
|
1536
|
+
failCaptureArtifact,
|
|
1537
|
+
isReusableCaptureDirectory,
|
|
1538
|
+
redactCaptureErrorMessage,
|
|
1539
|
+
resolveCaptureEnvironment,
|
|
1540
|
+
resolveLatestCompletedCapture,
|
|
1541
|
+
resolveRecordedScreenshotPath,
|
|
1542
|
+
resolveTarget,
|
|
1543
|
+
runDemo,
|
|
1544
|
+
runDemoWithBindings,
|
|
1545
|
+
screenshotRelativePath,
|
|
1546
|
+
startCaptureArtifact,
|
|
1547
|
+
writeCaptureManifestAtomic
|
|
1548
|
+
};
|