@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/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
+ };