humanish 0.0.1 → 0.15.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.
Files changed (196) hide show
  1. package/AGENTS.md +77 -0
  2. package/CONTRIBUTING.md +39 -0
  3. package/LICENSE +21 -0
  4. package/README.md +435 -8
  5. package/SECURITY.md +34 -0
  6. package/dist/actor-contract.d.ts +107 -0
  7. package/dist/actor-contract.js +286 -0
  8. package/dist/actor-contract.js.map +1 -0
  9. package/dist/actor-registry.d.ts +73 -0
  10. package/dist/actor-registry.js +93 -0
  11. package/dist/actor-registry.js.map +1 -0
  12. package/dist/adapter-extension.d.ts +54 -0
  13. package/dist/adapter-extension.js +214 -0
  14. package/dist/adapter-extension.js.map +1 -0
  15. package/dist/argv.d.ts +1 -0
  16. package/dist/argv.js +8 -0
  17. package/dist/argv.js.map +1 -0
  18. package/dist/artifact-reference.d.ts +22 -0
  19. package/dist/artifact-reference.js +47 -0
  20. package/dist/artifact-reference.js.map +1 -0
  21. package/dist/browser-evidence-hygiene.d.ts +7 -0
  22. package/dist/browser-evidence-hygiene.js +40 -0
  23. package/dist/browser-evidence-hygiene.js.map +1 -0
  24. package/dist/claude-agent-sdk.d.ts +94 -0
  25. package/dist/claude-agent-sdk.js +356 -0
  26. package/dist/claude-agent-sdk.js.map +1 -0
  27. package/dist/cli.d.ts +2 -0
  28. package/dist/cli.js +5 -0
  29. package/dist/cli.js.map +1 -0
  30. package/dist/codex-app-server-ui.d.ts +39 -0
  31. package/dist/codex-app-server-ui.js +327 -0
  32. package/dist/codex-app-server-ui.js.map +1 -0
  33. package/dist/codex-app-server.d.ts +141 -0
  34. package/dist/codex-app-server.js +747 -0
  35. package/dist/codex-app-server.js.map +1 -0
  36. package/dist/command-failure.d.ts +29 -0
  37. package/dist/command-failure.js +54 -0
  38. package/dist/command-failure.js.map +1 -0
  39. package/dist/computer-use-actor.d.ts +52 -0
  40. package/dist/computer-use-actor.js +48 -0
  41. package/dist/computer-use-actor.js.map +1 -0
  42. package/dist/computer-use.d.ts +232 -0
  43. package/dist/computer-use.js +615 -0
  44. package/dist/computer-use.js.map +1 -0
  45. package/dist/concurrency.d.ts +1 -0
  46. package/dist/concurrency.js +27 -0
  47. package/dist/concurrency.js.map +1 -0
  48. package/dist/concurrent-shared-world-lab.d.ts +115 -0
  49. package/dist/concurrent-shared-world-lab.js +1092 -0
  50. package/dist/concurrent-shared-world-lab.js.map +1 -0
  51. package/dist/core/git-state.d.ts +31 -0
  52. package/dist/core/git-state.js +142 -0
  53. package/dist/core/git-state.js.map +1 -0
  54. package/dist/core/index.d.ts +4 -0
  55. package/dist/core/index.js +3 -0
  56. package/dist/core/index.js.map +1 -0
  57. package/dist/core/run-primitives.d.ts +66 -0
  58. package/dist/core/run-primitives.js +120 -0
  59. package/dist/core/run-primitives.js.map +1 -0
  60. package/dist/cua-actor-lab.d.ts +620 -0
  61. package/dist/cua-actor-lab.js +2834 -0
  62. package/dist/cua-actor-lab.js.map +1 -0
  63. package/dist/device-presets.d.ts +67 -0
  64. package/dist/device-presets.js +50 -0
  65. package/dist/device-presets.js.map +1 -0
  66. package/dist/e2b-desktop-executor.d.ts +101 -0
  67. package/dist/e2b-desktop-executor.js +309 -0
  68. package/dist/e2b-desktop-executor.js.map +1 -0
  69. package/dist/e2b-desktop-launch.d.ts +144 -0
  70. package/dist/e2b-desktop-launch.js +59 -0
  71. package/dist/e2b-desktop-launch.js.map +1 -0
  72. package/dist/e2b-detached.d.ts +53 -0
  73. package/dist/e2b-detached.js +130 -0
  74. package/dist/e2b-detached.js.map +1 -0
  75. package/dist/e2b-terminal-lab.d.ts +345 -0
  76. package/dist/e2b-terminal-lab.js +1467 -0
  77. package/dist/e2b-terminal-lab.js.map +1 -0
  78. package/dist/env-file.d.ts +14 -0
  79. package/dist/env-file.js +108 -0
  80. package/dist/env-file.js.map +1 -0
  81. package/dist/feedback.d.ts +51 -0
  82. package/dist/feedback.js +313 -0
  83. package/dist/feedback.js.map +1 -0
  84. package/dist/image-evidence.d.ts +2 -0
  85. package/dist/image-evidence.js +33 -0
  86. package/dist/image-evidence.js.map +1 -0
  87. package/dist/index.d.ts +63 -0
  88. package/dist/index.js +33 -0
  89. package/dist/index.js.map +1 -0
  90. package/dist/init-templates.d.ts +12 -0
  91. package/dist/init-templates.js +369 -0
  92. package/dist/init-templates.js.map +1 -0
  93. package/dist/init.d.ts +26 -0
  94. package/dist/init.js +343 -0
  95. package/dist/init.js.map +1 -0
  96. package/dist/lab-app-runner.d.ts +78 -0
  97. package/dist/lab-app-runner.js +403 -0
  98. package/dist/lab-app-runner.js.map +1 -0
  99. package/dist/lab-config.d.ts +578 -0
  100. package/dist/lab-config.js +1751 -0
  101. package/dist/lab-config.js.map +1 -0
  102. package/dist/lab-engine.d.ts +77 -0
  103. package/dist/lab-engine.js +216 -0
  104. package/dist/lab-engine.js.map +1 -0
  105. package/dist/lab-preflight.d.ts +67 -0
  106. package/dist/lab-preflight.js +385 -0
  107. package/dist/lab-preflight.js.map +1 -0
  108. package/dist/labs.d.ts +53 -0
  109. package/dist/labs.js +176 -0
  110. package/dist/labs.js.map +1 -0
  111. package/dist/observer-assets.d.ts +2 -0
  112. package/dist/observer-assets.js +2630 -0
  113. package/dist/observer-assets.js.map +1 -0
  114. package/dist/observer-data.d.ts +63 -0
  115. package/dist/observer-data.js +145 -0
  116. package/dist/observer-data.js.map +1 -0
  117. package/dist/observer-static.d.ts +39 -0
  118. package/dist/observer-static.js +192 -0
  119. package/dist/observer-static.js.map +1 -0
  120. package/dist/observer.d.ts +46 -0
  121. package/dist/observer.js +353 -0
  122. package/dist/observer.js.map +1 -0
  123. package/dist/openai-responses-cu.d.ts +113 -0
  124. package/dist/openai-responses-cu.js +531 -0
  125. package/dist/openai-responses-cu.js.map +1 -0
  126. package/dist/oss-lab.d.ts +52 -0
  127. package/dist/oss-lab.js +299 -0
  128. package/dist/oss-lab.js.map +1 -0
  129. package/dist/oss-meta-lab-scoring.d.ts +15 -0
  130. package/dist/oss-meta-lab-scoring.js +145 -0
  131. package/dist/oss-meta-lab-scoring.js.map +1 -0
  132. package/dist/oss-meta-lab.d.ts +279 -0
  133. package/dist/oss-meta-lab.js +4889 -0
  134. package/dist/oss-meta-lab.js.map +1 -0
  135. package/dist/oss-remote-telemetry.d.ts +77 -0
  136. package/dist/oss-remote-telemetry.js +403 -0
  137. package/dist/oss-remote-telemetry.js.map +1 -0
  138. package/dist/persona.d.ts +35 -0
  139. package/dist/persona.js +133 -0
  140. package/dist/persona.js.map +1 -0
  141. package/dist/pi-agent-core.d.ts +77 -0
  142. package/dist/pi-agent-core.js +203 -0
  143. package/dist/pi-agent-core.js.map +1 -0
  144. package/dist/program.d.ts +54 -0
  145. package/dist/program.js +2347 -0
  146. package/dist/program.js.map +1 -0
  147. package/dist/redaction.d.ts +90 -0
  148. package/dist/redaction.js +292 -0
  149. package/dist/redaction.js.map +1 -0
  150. package/dist/run.d.ts +784 -0
  151. package/dist/run.js +4555 -0
  152. package/dist/run.js.map +1 -0
  153. package/dist/scripted-browser-actor.d.ts +294 -0
  154. package/dist/scripted-browser-actor.js +1286 -0
  155. package/dist/scripted-browser-actor.js.map +1 -0
  156. package/dist/scripted-browser-lab.d.ts +107 -0
  157. package/dist/scripted-browser-lab.js +738 -0
  158. package/dist/scripted-browser-lab.js.map +1 -0
  159. package/dist/shared-world-lab.d.ts +208 -0
  160. package/dist/shared-world-lab.js +1048 -0
  161. package/dist/shared-world-lab.js.map +1 -0
  162. package/dist/source-archive.d.ts +82 -0
  163. package/dist/source-archive.js +365 -0
  164. package/dist/source-archive.js.map +1 -0
  165. package/dist/stop-conditions.d.ts +26 -0
  166. package/dist/stop-conditions.js +66 -0
  167. package/dist/stop-conditions.js.map +1 -0
  168. package/dist/terminal-agent-actor.d.ts +36 -0
  169. package/dist/terminal-agent-actor.js +23 -0
  170. package/dist/terminal-agent-actor.js.map +1 -0
  171. package/docs/architecture/actor-contract.md +431 -0
  172. package/docs/architecture/github-feedback-loop.md +189 -0
  173. package/docs/architecture/local-codex-tui-actor.md +213 -0
  174. package/docs/architecture/observer.md +118 -0
  175. package/docs/architecture/oss-lab-poc.md +242 -0
  176. package/docs/architecture/project-layout.md +166 -0
  177. package/docs/architecture/state-driven-executor.md +203 -0
  178. package/docs/architecture/terminal-product-lane.md +148 -0
  179. package/docs/contracts/adapter-fixtures.md +80 -0
  180. package/docs/contracts/core.md +71 -0
  181. package/docs/contracts/feedback.md +145 -0
  182. package/docs/contracts/policy.md +305 -0
  183. package/docs/contracts/run-bundle.md +358 -0
  184. package/docs/contracts/schemas.md +984 -0
  185. package/docs/goals/current.md +409 -0
  186. package/docs/principles/invariants-and-defaults.md +135 -0
  187. package/docs/principles/self-driving-harness.md +129 -0
  188. package/docs/product/open-source-install-experience.md +229 -0
  189. package/docs/ramp/README.md +200 -0
  190. package/docs/release/open-source-readiness.md +208 -0
  191. package/docs/release/public-readiness-standard.md +205 -0
  192. package/docs/roadmap/world-class-open-source-v0.md +311 -0
  193. package/package.json +102 -8
  194. package/skills/humanish/SKILL.md +244 -0
  195. package/skills/humanish/agents/openai.yaml +7 -0
  196. package/index.js +0 -1
@@ -0,0 +1,1286 @@
1
+ // The deterministic scripted browser driver ("browser-persona") plus its actor-registry
2
+ // session wrapper. The driver code was MOVED here from src/run.ts unchanged (the journey
3
+ // parser, the surface capture engines, and their small private helpers) because the actor
4
+ // registry can never import run.ts: run.ts imports getActor from actor-registry.js, so a
5
+ // registry entry whose runSession lived in run.ts would be a module cycle through a
6
+ // const-initialized registry. This module is a LEAF — it imports only actor-contract.js and
7
+ // redaction.js, so both run.ts (the `run --app-url` path) and actor-registry.ts (the
8
+ // "scripted-browser" actor) can depend on it.
9
+ //
10
+ // One deliberate structural change from the move (the only one): the step executor's `page`
11
+ // is typed as the narrow structural ScriptedPageLike instead of playwright's Page (precedent:
12
+ // browserPersonaPageState already took { evaluate, url }; E2BDesktopLike is the repo's
13
+ // established seam pattern). playwright's real Page satisfies it; tests inject fakes and run
14
+ // the REAL step executor at $0 with zero browser dependence. playwright-core stays the lazy
15
+ // production default behind launchPlaywrightChromium.
16
+ //
17
+ // Spend posture: nothing in this module can spend provider money — no provider client is
18
+ // importable from this code path. tokenUsage on every projected trace records zeros as an
19
+ // affirmative $0 declaration that is TRUE by mechanism.
20
+ import { execFile, spawn } from "node:child_process";
21
+ import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+ import { promisify } from "node:util";
25
+ import { ACTOR_TRACE_SCHEMA, SCRIPTED_BROWSER_CAPABILITIES } from "./actor-contract.js";
26
+ import { CHROMIUM_EVIDENCE_HYGIENE_FLAGS } from "./browser-evidence-hygiene.js";
27
+ import { digestText, redactText, redactToSecretLabel } from "./redaction.js";
28
+ const execFileAsync = promisify(execFile);
29
+ const LOOPBACK_EVIDENCE_URL_POLICY = { kind: "loopback" };
30
+ /** Production default: lazy playwright-core import + chromium.launch, exactly as the driver
31
+ * always did. Kept in ONE place so the optional peer is touched by exactly one code path. */
32
+ export async function launchPlaywrightChromium(args) {
33
+ const { chromium } = await import("playwright-core");
34
+ const browser = await chromium.launch({
35
+ executablePath: args.browserCommand,
36
+ headless: true,
37
+ args: [
38
+ ...CHROMIUM_EVIDENCE_HYGIENE_FLAGS,
39
+ "--disable-gpu",
40
+ "--disable-dev-shm-usage"
41
+ ],
42
+ timeout: args.timeoutMs
43
+ });
44
+ return browser;
45
+ }
46
+ export const browserSurfaces = [
47
+ {
48
+ id: "desktop",
49
+ label: "Desktop browser surface",
50
+ viewport: {
51
+ width: 1440,
52
+ height: 960,
53
+ deviceScaleFactor: 1,
54
+ isMobile: false
55
+ }
56
+ },
57
+ {
58
+ id: "mobile",
59
+ label: "Mobile browser surface",
60
+ viewport: {
61
+ width: 390,
62
+ height: 844,
63
+ deviceScaleFactor: 2,
64
+ isMobile: true
65
+ }
66
+ }
67
+ ];
68
+ // ---------------------------------------------------------------------------
69
+ // Surface capture engines (moved from run.ts). The HUMANISH_BROWSER_PERSONA_DRIVER=fixture env
70
+ // switch stays a `run --app-url` affordance only; the lab route never consults it (its test
71
+ // seam is the launchBrowser DI hook — an env-switched semi-real driver inside a lab would blur
72
+ // what the evidence claims).
73
+ // ---------------------------------------------------------------------------
74
+ export async function captureBrowserSurface(args) {
75
+ if (process.env.HUMANISH_BROWSER_PERSONA_DRIVER === "fixture") {
76
+ return captureBrowserSurfaceFixture(args);
77
+ }
78
+ return captureBrowserSurfaceWithPlaywright(args);
79
+ }
80
+ export async function captureBrowserSurfaceFixture(args) {
81
+ const started = Date.now();
82
+ const tracePath = path.join("traces", `${args.surface.id}.json`);
83
+ const absoluteTracePath = path.join(args.absoluteArtifactRoot, tracePath);
84
+ const httpProbe = await probeAppUrl(args.appUrl, Math.min(args.timeoutMs, 15_000));
85
+ const capturedAt = new Date().toISOString();
86
+ const profileDir = await mkdtemp(path.join(os.tmpdir(), "humanish-browser-profile-"));
87
+ const steps = [];
88
+ let currentUrl = args.appUrl;
89
+ try {
90
+ for (const [index, step] of args.browserJourney.steps.entries()) {
91
+ if (step.action === "goto") {
92
+ currentUrl = resolveBrowserStepUrl(args.appUrl, step.path ?? args.browserJourney.startPath);
93
+ }
94
+ const stepStarted = Date.now();
95
+ const screenshotPath = screenshotPathForBrowserStep(args.surface, step);
96
+ const absoluteScreenshotPath = path.join(args.absoluteArtifactRoot, screenshotPath);
97
+ await captureScreenshotWithBrowser({
98
+ args: browserScreenshotArgs({
99
+ appUrl: currentUrl,
100
+ profileDir,
101
+ screenshotPath: absoluteScreenshotPath,
102
+ surface: args.surface
103
+ }),
104
+ browserCommand: args.browserCommand,
105
+ screenshotPath: absoluteScreenshotPath,
106
+ timeoutMs: args.timeoutMs
107
+ });
108
+ const assertions = fixtureAssertionsForBrowserStep(step, httpProbe.ok);
109
+ steps.push({
110
+ action: step.action,
111
+ ...(assertions.length === 0 ? {} : { assertions }),
112
+ completedAt: new Date().toISOString(),
113
+ durationMs: Date.now() - stepStarted,
114
+ id: step.id,
115
+ label: step.label,
116
+ reason: httpProbe.ok
117
+ ? `Fixture driver captured ${step.action} step ${index + 1}/${args.browserJourney.steps.length}.`
118
+ : httpProbe.reason,
119
+ screenshotPath,
120
+ status: httpProbe.ok && assertions.every((assertion) => assertion.status === "passed") ? "passed" : "blocked",
121
+ url: sanitizeLoopbackUrl(currentUrl)
122
+ });
123
+ }
124
+ }
125
+ catch (error) {
126
+ const reason = `Browser screenshot command failed for ${args.surface.id}: ${compactBrowserError(error)}`;
127
+ const blockedSteps = buildBlockedBrowserPersonaSteps({
128
+ browserJourney: args.browserJourney,
129
+ currentUrl,
130
+ reason,
131
+ surface: args.surface,
132
+ timestamp: capturedAt
133
+ });
134
+ const blockedScreenshotPath = surfaceScreenshotPath(blockedSteps);
135
+ await writeJson(absoluteTracePath, buildBrowserTrace({
136
+ appUrl: args.appUrl,
137
+ browserCommand: path.basename(args.browserCommand),
138
+ browserJourney: args.browserJourney,
139
+ capturedAt,
140
+ durationMs: Date.now() - started,
141
+ ...(httpProbe.status === undefined ? {} : { httpStatus: httpProbe.status }),
142
+ ok: false,
143
+ reason,
144
+ ...(blockedScreenshotPath === undefined ? {} : { screenshotPath: blockedScreenshotPath }),
145
+ steps: blockedSteps,
146
+ surface: args.surface
147
+ }));
148
+ return {
149
+ capturedAt,
150
+ durationMs: Date.now() - started,
151
+ ...(httpProbe.status === undefined ? {} : { httpStatus: httpProbe.status }),
152
+ ok: false,
153
+ reason,
154
+ ...(blockedScreenshotPath === undefined ? {} : { screenshotPath: blockedScreenshotPath }),
155
+ steps: blockedSteps,
156
+ surface: args.surface,
157
+ tracePath
158
+ };
159
+ }
160
+ finally {
161
+ await rm(profileDir, { force: true, recursive: true }).catch(() => undefined);
162
+ }
163
+ // Every step in the success path attempts a screenshot, so each carries a
164
+ // screenshotPath. A step claiming success whose screenshot is missing or empty
165
+ // must still drag the capture out of `ok` — the strict verifier then catches it.
166
+ const screenshotStats = await Promise.all(steps.map((step) => (step.screenshotPath ? stat(path.join(args.absoluteArtifactRoot, step.screenshotPath)) : Promise.resolve(null)).catch(() => null)));
167
+ const screenshotsOk = screenshotStats.every((stats) => stats?.isFile() && stats.size > 0);
168
+ const ok = Boolean(screenshotsOk && httpProbe.ok && steps.every((step) => step.status === "passed"));
169
+ const reason = ok
170
+ ? `${args.surface.label} completed ${steps.length}/${steps.length} browser persona steps from ${args.appUrl}${httpProbe.status === undefined ? "" : ` with HTTP ${httpProbe.status}`}.`
171
+ : screenshotsOk
172
+ ? `${args.surface.label} persona screenshots exist, but app HTTP readiness was not proven: ${httpProbe.reason}.`
173
+ : `${args.surface.label} persona screenshot artifacts were missing or empty.`;
174
+ const completedAt = new Date().toISOString();
175
+ const durationMs = Date.now() - started;
176
+ const fixtureScreenshotPath = surfaceScreenshotPath(steps);
177
+ await writeJson(absoluteTracePath, buildBrowserTrace({
178
+ appUrl: args.appUrl,
179
+ browserCommand: path.basename(args.browserCommand),
180
+ browserJourney: args.browserJourney,
181
+ capturedAt: completedAt,
182
+ durationMs,
183
+ ...(httpProbe.status === undefined ? {} : { httpStatus: httpProbe.status }),
184
+ ok,
185
+ reason,
186
+ ...(fixtureScreenshotPath === undefined ? {} : { screenshotPath: fixtureScreenshotPath }),
187
+ steps,
188
+ surface: args.surface
189
+ }));
190
+ return {
191
+ capturedAt: completedAt,
192
+ durationMs,
193
+ ...(httpProbe.status === undefined ? {} : { httpStatus: httpProbe.status }),
194
+ ok,
195
+ reason,
196
+ ...(fixtureScreenshotPath === undefined ? {} : { screenshotPath: fixtureScreenshotPath }),
197
+ steps,
198
+ surface: args.surface,
199
+ tracePath
200
+ };
201
+ }
202
+ export async function captureBrowserSurfaceWithPlaywright(args) {
203
+ const started = Date.now();
204
+ const tracePath = path.join("traces", `${args.surface.id}.json`);
205
+ const absoluteTracePath = path.join(args.absoluteArtifactRoot, tracePath);
206
+ const httpProbe = await probeAppUrl(args.appUrl, Math.min(args.timeoutMs, 15_000));
207
+ let browser = null;
208
+ let page = null;
209
+ const steps = [];
210
+ try {
211
+ browser = await launchPlaywrightChromium({
212
+ browserCommand: args.browserCommand,
213
+ timeoutMs: args.timeoutMs
214
+ });
215
+ const context = await browser.newContext({
216
+ deviceScaleFactor: args.surface.viewport.deviceScaleFactor,
217
+ isMobile: args.surface.viewport.isMobile,
218
+ viewport: {
219
+ width: args.surface.viewport.width,
220
+ height: args.surface.viewport.height
221
+ }
222
+ });
223
+ page = await context.newPage();
224
+ for (const step of args.browserJourney.steps) {
225
+ steps.push(await executeBrowserPersonaStep({
226
+ absoluteArtifactRoot: args.absoluteArtifactRoot,
227
+ appUrl: args.appUrl,
228
+ browserJourney: args.browserJourney,
229
+ page,
230
+ step,
231
+ surface: args.surface,
232
+ timeoutMs: args.timeoutMs
233
+ }));
234
+ }
235
+ }
236
+ catch (error) {
237
+ const now = new Date().toISOString();
238
+ const reason = compactBrowserError(error);
239
+ if (steps.length === 0) {
240
+ steps.push(...buildBlockedBrowserPersonaSteps({
241
+ browserJourney: args.browserJourney,
242
+ currentUrl: args.appUrl,
243
+ reason,
244
+ surface: args.surface,
245
+ timestamp: now
246
+ }));
247
+ }
248
+ else if (steps.length < args.browserJourney.steps.length) {
249
+ const nextStep = args.browserJourney.steps[steps.length];
250
+ if (nextStep) {
251
+ const { screenshotPath, written: blockedShotWritten } = await captureBlockedStepScreenshot(page, args.absoluteArtifactRoot, args.surface, nextStep);
252
+ steps.push({
253
+ action: nextStep.action,
254
+ completedAt: now,
255
+ durationMs: Date.now() - started,
256
+ id: nextStep.id,
257
+ label: nextStep.label,
258
+ reason,
259
+ ...(blockedShotWritten ? { screenshotPath } : {}),
260
+ status: "blocked",
261
+ url: page ? sanitizeLoopbackUrl(page.url()) : args.appUrl
262
+ });
263
+ }
264
+ }
265
+ }
266
+ finally {
267
+ await browser?.close().catch(() => undefined);
268
+ }
269
+ const completedAt = new Date().toISOString();
270
+ const durationMs = Date.now() - started;
271
+ const ok = httpProbe.ok && steps.length === args.browserJourney.steps.length && steps.every((step) => step.status === "passed");
272
+ const reason = ok
273
+ ? `${args.surface.label} completed ${steps.length}/${steps.length} browser persona steps from ${args.appUrl}${httpProbe.status === undefined ? "" : ` with HTTP ${httpProbe.status}`}.`
274
+ : `${args.surface.label} browser persona journey blocked: ${steps.find((step) => step.status !== "passed")?.reason ?? httpProbe.reason}`;
275
+ const playwrightScreenshotPath = surfaceScreenshotPath(steps);
276
+ await writeJson(absoluteTracePath, buildBrowserTrace({
277
+ appUrl: args.appUrl,
278
+ browserCommand: path.basename(args.browserCommand),
279
+ browserJourney: args.browserJourney,
280
+ capturedAt: completedAt,
281
+ durationMs,
282
+ ...(httpProbe.status === undefined ? {} : { httpStatus: httpProbe.status }),
283
+ ok,
284
+ reason,
285
+ ...(playwrightScreenshotPath === undefined ? {} : { screenshotPath: playwrightScreenshotPath }),
286
+ steps,
287
+ surface: args.surface
288
+ }));
289
+ return {
290
+ capturedAt: completedAt,
291
+ durationMs,
292
+ ...(httpProbe.status === undefined ? {} : { httpStatus: httpProbe.status }),
293
+ ok,
294
+ reason,
295
+ ...(playwrightScreenshotPath === undefined ? {} : { screenshotPath: playwrightScreenshotPath }),
296
+ steps,
297
+ surface: args.surface,
298
+ tracePath
299
+ };
300
+ }
301
+ export async function executeBrowserPersonaStep(args) {
302
+ const started = Date.now();
303
+ const urlPolicy = args.urlPolicy ?? LOOPBACK_EVIDENCE_URL_POLICY;
304
+ const beforeState = await browserPersonaPageState(args.page, urlPolicy);
305
+ const stepTimeoutMs = Math.min(args.timeoutMs, 8_000);
306
+ if (args.step.action === "goto") {
307
+ await args.page.goto(resolveBrowserStepUrlForPolicy(args.appUrl, args.step.path ?? args.browserJourney.startPath, urlPolicy), {
308
+ waitUntil: "domcontentloaded",
309
+ timeout: args.timeoutMs
310
+ });
311
+ }
312
+ else if (args.step.action === "fill") {
313
+ if (!args.step.selector) {
314
+ throw new Error(`${args.step.id} fill step is missing selector`);
315
+ }
316
+ await args.page.locator(args.step.selector).first().fill(args.step.value ?? "synthetic.user@example.test", {
317
+ timeout: stepTimeoutMs
318
+ });
319
+ }
320
+ else if (args.step.action === "click") {
321
+ let target = args.step.selector
322
+ ? args.page.locator(args.step.selector).first()
323
+ : args.page.locator("button, input[type='submit'], input[type='button'], [role='button']").first();
324
+ if (!args.step.selector) {
325
+ const textInput = args.page.locator("input:not([type='hidden']):not([type='submit']):not([type='button']), textarea").first();
326
+ if (await textInput.count() > 0) {
327
+ await textInput.fill(args.step.value ?? "synthetic.user@example.test", { timeout: stepTimeoutMs });
328
+ }
329
+ }
330
+ if (await target.count() === 0) {
331
+ throw new Error(`${args.step.id} click step found no target`);
332
+ }
333
+ await target.click({ timeout: stepTimeoutMs });
334
+ await args.page.waitForTimeout(300);
335
+ }
336
+ else if (args.step.action === "assertText" || args.step.action === "waitForText") {
337
+ const expectedText = args.step.expectation?.text ?? args.step.value;
338
+ if (!expectedText) {
339
+ throw new Error(`${args.step.id} text assertion step is missing expected text`);
340
+ }
341
+ await waitForPageText(args.page, expectedText, stepTimeoutMs);
342
+ }
343
+ else if (args.step.action === "waitForSelector") {
344
+ if (!args.step.selector) {
345
+ throw new Error(`${args.step.id} selector wait step is missing selector`);
346
+ }
347
+ await args.page.locator(args.step.selector).first().waitFor({ state: "visible", timeout: stepTimeoutMs });
348
+ }
349
+ else {
350
+ const exhaustive = args.step.action;
351
+ throw new Error(`Unsupported browser persona action: ${exhaustive}`);
352
+ }
353
+ const afterState = await browserPersonaPageState(args.page, urlPolicy);
354
+ const screenshotPath = screenshotPathForBrowserStep(args.surface, args.step);
355
+ await args.page.screenshot({
356
+ path: path.join(args.absoluteArtifactRoot, screenshotPath),
357
+ fullPage: true
358
+ });
359
+ const assertions = await evaluateBrowserStepExpectations({
360
+ afterState,
361
+ beforeState,
362
+ page: args.page,
363
+ step: args.step,
364
+ timeoutMs: stepTimeoutMs
365
+ });
366
+ const blockedAssertion = assertions.find((assertion) => assertion.status !== "passed");
367
+ return {
368
+ action: args.step.action,
369
+ ...(assertions.length === 0 ? {} : { assertions }),
370
+ completedAt: new Date().toISOString(),
371
+ durationMs: Date.now() - started,
372
+ id: args.step.id,
373
+ label: args.step.label,
374
+ reason: blockedAssertion?.reason ?? `${args.step.action} completed for ${args.step.label}.`,
375
+ screenshotPath,
376
+ status: blockedAssertion ? "blocked" : "passed",
377
+ url: sanitizeBrowserEvidenceUrl(args.page.url(), urlPolicy)
378
+ };
379
+ }
380
+ export async function evaluateBrowserStepExpectations(args) {
381
+ const assertions = [];
382
+ const expectation = args.step.expectation;
383
+ if (!expectation) {
384
+ return assertions;
385
+ }
386
+ if (expectation.stateChanged === true) {
387
+ const changed = args.beforeState.url !== args.afterState.url || args.beforeState.bodyDigest !== args.afterState.bodyDigest;
388
+ assertions.push({
389
+ id: "state-changed",
390
+ reason: changed ? "Visible page state changed." : "Visible page state did not change.",
391
+ status: changed ? "passed" : "blocked"
392
+ });
393
+ }
394
+ if (expectation.text) {
395
+ assertions.push(await pageTextAssertion(args.page, expectation.text, args.timeoutMs));
396
+ }
397
+ if (expectation.selectorVisible) {
398
+ const visible = await args.page.locator(expectation.selectorVisible).first().isVisible({ timeout: args.timeoutMs }).catch(() => false);
399
+ assertions.push({
400
+ id: "selector-visible",
401
+ reason: visible ? "Expected selector was visible." : "Expected selector was not visible.",
402
+ status: visible ? "passed" : "blocked"
403
+ });
404
+ }
405
+ if (expectation.urlIncludes) {
406
+ const includes = args.afterState.url.includes(expectation.urlIncludes);
407
+ assertions.push({
408
+ id: "url-includes",
409
+ reason: includes ? "URL included expected public-safe substring." : "URL did not include expected public-safe substring.",
410
+ status: includes ? "passed" : "blocked"
411
+ });
412
+ }
413
+ return assertions;
414
+ }
415
+ async function pageTextAssertion(page, expectedText, timeoutMs) {
416
+ const passed = await waitForPageText(page, expectedText, timeoutMs).then(() => true).catch(() => false);
417
+ return {
418
+ id: "text-present",
419
+ reason: passed ? "Expected text was present." : "Expected text was not present.",
420
+ status: passed ? "passed" : "blocked"
421
+ };
422
+ }
423
+ async function waitForPageText(page, expectedText, timeoutMs) {
424
+ await page.waitForFunction("(needle) => typeof needle === 'string' && Boolean(document.body?.innerText.includes(needle))", expectedText, { timeout: timeoutMs });
425
+ }
426
+ function browserScreenshotArgs(args) {
427
+ return [
428
+ "--headless=new",
429
+ ...CHROMIUM_EVIDENCE_HYGIENE_FLAGS,
430
+ "--disable-gpu",
431
+ "--disable-dev-shm-usage",
432
+ "--hide-scrollbars",
433
+ `--user-data-dir=${args.profileDir}`,
434
+ `--window-size=${args.surface.viewport.width},${args.surface.viewport.height}`,
435
+ `--force-device-scale-factor=${args.surface.viewport.deviceScaleFactor}`,
436
+ `--screenshot=${args.screenshotPath}`,
437
+ args.appUrl
438
+ ];
439
+ }
440
+ export function buildBlockedBrowserPersonaSteps(args) {
441
+ // The journey never ran, so no screenshot was written for these steps. The
442
+ // failure IS the evidence: keep the blocked status + reason, but omit the
443
+ // screenshot reference so the bundle never claims an artifact that does not
444
+ // exist (otherwise verify's missingLocalEvidenceArtifacts fails closed on
445
+ // evidence that was never meant to exist). See src/artifact-reference.ts.
446
+ return args.browserJourney.steps.map((step) => ({
447
+ action: step.action,
448
+ completedAt: args.timestamp,
449
+ durationMs: 0,
450
+ id: step.id,
451
+ label: step.label,
452
+ reason: args.reason,
453
+ status: "blocked",
454
+ url: sanitizeBrowserEvidenceUrl(args.currentUrl, args.urlPolicy)
455
+ }));
456
+ }
457
+ function fixtureAssertionsForBrowserStep(step, httpOk) {
458
+ const assertions = [];
459
+ const expectation = step.expectation;
460
+ if (!expectation) {
461
+ return assertions;
462
+ }
463
+ const ids = [];
464
+ if (expectation.stateChanged === true)
465
+ ids.push("state-changed");
466
+ if (expectation.text)
467
+ ids.push("text-present");
468
+ if (expectation.selectorVisible)
469
+ ids.push("selector-visible");
470
+ if (expectation.urlIncludes)
471
+ ids.push("url-includes");
472
+ return ids.map((id) => ({
473
+ id,
474
+ reason: httpOk
475
+ ? "Fixture driver recorded the expected assertion shape."
476
+ : "Fixture driver could not prove the assertion because app HTTP readiness failed.",
477
+ status: httpOk ? "passed" : "blocked"
478
+ }));
479
+ }
480
+ export function screenshotPathForBrowserStep(surface, step) {
481
+ return path.join("screenshots", `${surface.id}-${step?.id ?? "step"}.png`);
482
+ }
483
+ /**
484
+ * Best-effort screenshot of a blocked step. The step is blocked, so its failure is
485
+ * the evidence; the shot is a bonus. Returns the relative path plus whether the
486
+ * write actually produced a non-empty file, so the caller only references the path
487
+ * when the file truly exists (never claim a screenshot that is not there --
488
+ * src/artifact-reference.ts).
489
+ */
490
+ async function captureBlockedStepScreenshot(page, artifactRoot, surface, step) {
491
+ const screenshotPath = screenshotPathForBrowserStep(surface, step);
492
+ await page?.screenshot({ path: path.join(artifactRoot, screenshotPath), fullPage: true }).catch(() => undefined);
493
+ const stats = await stat(path.join(artifactRoot, screenshotPath)).catch(() => null);
494
+ return { screenshotPath, written: Boolean(stats?.isFile() && stats.size > 0) };
495
+ }
496
+ /**
497
+ * Surface-level screenshot path = the last step that actually wrote one. Returns
498
+ * undefined when no step wrote a screenshot (a fully blocked capture whose evidence
499
+ * is the failure itself), so the producer never synthesizes a path to a file it did
500
+ * not write. See src/artifact-reference.ts.
501
+ */
502
+ export function surfaceScreenshotPath(steps) {
503
+ for (let index = steps.length - 1; index >= 0; index -= 1) {
504
+ const candidate = steps[index]?.screenshotPath?.trim();
505
+ if (candidate) {
506
+ return candidate;
507
+ }
508
+ }
509
+ return undefined;
510
+ }
511
+ export function resolveBrowserStepUrl(appUrl, value) {
512
+ return resolveBrowserStepUrlForPolicy(appUrl, value, LOOPBACK_EVIDENCE_URL_POLICY);
513
+ }
514
+ export function resolveBrowserStepUrlForPolicy(appUrl, value, urlPolicy = LOOPBACK_EVIDENCE_URL_POLICY) {
515
+ const url = new URL(value?.trim() || "", appUrl);
516
+ if (urlPolicy.kind === "provisioned-subject") {
517
+ const subjectOrigin = new URL(appUrl).origin;
518
+ if (url.origin !== subjectOrigin) {
519
+ throw new Error("browser step URL must resolve within the provisioned subject origin");
520
+ }
521
+ return url.toString();
522
+ }
523
+ const normalized = normalizeLocalAppUrl(url.toString());
524
+ if (!normalized) {
525
+ throw new Error("browser step URL must resolve to a loopback HTTP URL");
526
+ }
527
+ return normalized;
528
+ }
529
+ async function browserPersonaPageState(page, urlPolicy = LOOPBACK_EVIDENCE_URL_POLICY) {
530
+ const bodyText = await page.evaluate("document.body ? document.body.innerText : ''");
531
+ return {
532
+ bodyDigest: digestText(bodyText.slice(0, 4_000)),
533
+ url: sanitizeBrowserEvidenceUrl(page.url(), urlPolicy)
534
+ };
535
+ }
536
+ export function buildBrowserTrace(args) {
537
+ return {
538
+ schema: "humanish.browser-persona-trace.v1",
539
+ capturedAt: args.capturedAt,
540
+ appUrl: args.appUrl,
541
+ browserCommand: args.browserCommand,
542
+ durationMs: args.durationMs,
543
+ ...(args.httpStatus === undefined ? {} : { httpStatus: args.httpStatus }),
544
+ ok: args.ok,
545
+ reason: args.reason,
546
+ scenario: {
547
+ id: args.browserJourney.scenarioId,
548
+ title: args.browserJourney.scenarioTitle,
549
+ source: args.browserJourney.source,
550
+ sourceDigest: args.browserJourney.sourceDigest,
551
+ stepCount: args.browserJourney.steps.length
552
+ },
553
+ ...(args.screenshotPath === undefined ? {} : { screenshotPath: args.screenshotPath }),
554
+ steps: args.steps,
555
+ surface: args.surface,
556
+ redaction: "passed"
557
+ };
558
+ }
559
+ export function sanitizeLoopbackUrl(value) {
560
+ try {
561
+ const parsed = new URL(value);
562
+ if (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "::1") {
563
+ parsed.username = "";
564
+ parsed.password = "";
565
+ parsed.search = parsed.search ? "?[redacted-query]" : "";
566
+ parsed.hash = parsed.hash ? "#[redacted-hash]" : "";
567
+ return parsed.toString();
568
+ }
569
+ }
570
+ catch { }
571
+ return "[redacted-url]";
572
+ }
573
+ export function sanitizeBrowserEvidenceUrl(value, urlPolicy = LOOPBACK_EVIDENCE_URL_POLICY) {
574
+ if (urlPolicy.kind === "loopback") {
575
+ return sanitizeLoopbackUrl(value);
576
+ }
577
+ const evidenceOrigin = urlPolicy.evidenceOrigin.trim() || "[provisioned-subject]";
578
+ try {
579
+ const parsed = new URL(value);
580
+ const pathname = parsed.pathname || "/";
581
+ const search = parsed.search ? "?[redacted-query]" : "";
582
+ const hash = parsed.hash ? "#[redacted-hash]" : "";
583
+ return `${evidenceOrigin}${pathname}${search}${hash}`;
584
+ }
585
+ catch {
586
+ return evidenceOrigin;
587
+ }
588
+ }
589
+ export async function captureScreenshotWithBrowser(args) {
590
+ const child = spawn(args.browserCommand, args.args, {
591
+ detached: true,
592
+ stdio: "ignore"
593
+ });
594
+ let exitCode = null;
595
+ let signal = null;
596
+ child.once("exit", (code, childSignal) => {
597
+ exitCode = code;
598
+ signal = childSignal;
599
+ });
600
+ child.unref();
601
+ const deadline = Date.now() + args.timeoutMs;
602
+ while (Date.now() <= deadline) {
603
+ const stats = await stat(args.screenshotPath).catch(() => null);
604
+ if (stats?.isFile() && stats.size > 0) {
605
+ terminateProcessGroup(child.pid);
606
+ return;
607
+ }
608
+ if (exitCode !== null || signal !== null) {
609
+ break;
610
+ }
611
+ await wait(250);
612
+ }
613
+ terminateProcessGroup(child.pid, true);
614
+ const stats = await stat(args.screenshotPath).catch(() => null);
615
+ if (stats?.isFile() && stats.size > 0) {
616
+ return;
617
+ }
618
+ throw new Error(exitCode !== null || signal !== null
619
+ ? `browser exited before screenshot was written (exit=${exitCode ?? "null"} signal=${signal ?? "null"})`
620
+ : `timed out after ${args.timeoutMs}ms waiting for screenshot`);
621
+ }
622
+ function terminateProcessGroup(pid, force = false) {
623
+ if (!pid) {
624
+ return;
625
+ }
626
+ try {
627
+ process.kill(-pid, force ? "SIGKILL" : "SIGTERM");
628
+ return;
629
+ }
630
+ catch { }
631
+ try {
632
+ process.kill(pid, force ? "SIGKILL" : "SIGTERM");
633
+ }
634
+ catch { }
635
+ }
636
+ async function wait(ms) {
637
+ await new Promise((resolve) => setTimeout(resolve, ms));
638
+ }
639
+ export async function probeAppUrl(appUrl, timeoutMs) {
640
+ const controller = new AbortController();
641
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
642
+ try {
643
+ const response = await fetch(appUrl, {
644
+ signal: controller.signal
645
+ });
646
+ return {
647
+ ok: response.status < 500,
648
+ reason: `HTTP ${response.status}`,
649
+ status: response.status
650
+ };
651
+ }
652
+ catch (error) {
653
+ return {
654
+ ok: false,
655
+ reason: compactBrowserError(error)
656
+ };
657
+ }
658
+ finally {
659
+ clearTimeout(timer);
660
+ }
661
+ }
662
+ export async function resolveBrowserCommand() {
663
+ const candidates = [
664
+ await resolveBrowserCandidate(process.env.HUMANISH_BROWSER_COMMAND),
665
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
666
+ await resolveExecutableFromPath("google-chrome"),
667
+ await resolveExecutableFromPath("chromium"),
668
+ await resolveExecutableFromPath("chromium-browser")
669
+ ].filter((candidate) => Boolean(candidate?.trim()));
670
+ for (const candidate of candidates) {
671
+ try {
672
+ await execFileAsync(candidate, ["--version"], {
673
+ timeout: 5_000,
674
+ maxBuffer: 256 * 1024
675
+ });
676
+ return candidate;
677
+ }
678
+ catch { }
679
+ }
680
+ return null;
681
+ }
682
+ async function resolveBrowserCandidate(value) {
683
+ const candidate = value?.trim();
684
+ if (!candidate) {
685
+ return null;
686
+ }
687
+ return path.isAbsolute(candidate) ? candidate : resolveExecutableFromPath(candidate);
688
+ }
689
+ async function resolveExecutableFromPath(command) {
690
+ try {
691
+ const { stdout } = await execFileAsync("sh", ["-lc", `command -v ${shellQuote(command)}`], {
692
+ timeout: 5_000,
693
+ maxBuffer: 256 * 1024
694
+ });
695
+ const resolved = stdout.trim().split(/\r?\n/)[0]?.trim();
696
+ return resolved ? resolved : null;
697
+ }
698
+ catch {
699
+ return null;
700
+ }
701
+ }
702
+ export function normalizeLocalAppUrl(value) {
703
+ try {
704
+ const url = new URL(value);
705
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
706
+ return null;
707
+ }
708
+ if (!["127.0.0.1", "localhost", "::1"].includes(url.hostname)) {
709
+ return null;
710
+ }
711
+ url.username = "";
712
+ url.password = "";
713
+ url.search = "";
714
+ url.hash = "";
715
+ return url.toString();
716
+ }
717
+ catch {
718
+ return null;
719
+ }
720
+ }
721
+ function compactBrowserError(error) {
722
+ if (error instanceof Error) {
723
+ return redactSensitiveText(error.message).replace(/\s+/g, " ").slice(0, 240);
724
+ }
725
+ return redactSensitiveText(String(error)).replace(/\s+/g, " ").slice(0, 240);
726
+ }
727
+ // ---------------------------------------------------------------------------
728
+ // Journey parser (moved from run.ts).
729
+ // ---------------------------------------------------------------------------
730
+ export function parseBrowserPersonaJourneyFromScenario(args) {
731
+ if (!isRecord(args.raw)) {
732
+ return {};
733
+ }
734
+ const scenarioId = publicSafeToken(stringValue(args.raw.id), path.basename(args.relativePath, path.extname(args.relativePath)));
735
+ const scenarioTitle = stringValue(args.raw.title) ?? scenarioId;
736
+ const goal = stringValue(args.raw.goal) ?? "Drive a public-safe browser persona through the local app.";
737
+ const browser = isRecord(args.raw.browser) ? args.raw.browser : undefined;
738
+ const declaredBrowser = args.raw.mode === "browser" || browser !== undefined || hasInlineBrowserSteps(args.raw.steps);
739
+ if (!declaredBrowser) {
740
+ return {};
741
+ }
742
+ let startPath = "/";
743
+ let rawSteps = [];
744
+ if (browser !== undefined) {
745
+ const parsedStartPath = stringValue(browser.startPath) ?? stringValue(browser.start_path);
746
+ if (parsedStartPath !== undefined) {
747
+ startPath = parsedStartPath;
748
+ }
749
+ if (Array.isArray(browser.steps)) {
750
+ rawSteps = browser.steps;
751
+ }
752
+ else if ("steps" in browser) {
753
+ return { failure: `${args.relativePath} browser.steps must be a non-empty array.` };
754
+ }
755
+ }
756
+ if (rawSteps.length === 0 && Array.isArray(args.raw.steps)) {
757
+ rawSteps = args.raw.steps;
758
+ }
759
+ const steps = [];
760
+ for (const [index, rawStep] of rawSteps.entries()) {
761
+ const parsed = parseBrowserPersonaStep(rawStep, index);
762
+ if (parsed.failure) {
763
+ return { failure: `${args.relativePath}: ${parsed.failure}` };
764
+ }
765
+ if (parsed.step) {
766
+ steps.push(parsed.step);
767
+ }
768
+ }
769
+ if (browser !== undefined && steps.length === 0) {
770
+ return { failure: `${args.relativePath} declared browser steps, but no executable steps were found.` };
771
+ }
772
+ if (steps.length === 0) {
773
+ return {};
774
+ }
775
+ return {
776
+ journey: {
777
+ goal,
778
+ scenarioId,
779
+ scenarioTitle,
780
+ source: args.relativePath,
781
+ sourceDigest: args.sourceDigest,
782
+ startPath,
783
+ steps
784
+ }
785
+ };
786
+ }
787
+ export function parseBrowserPersonaStep(rawStep, index) {
788
+ if (!isRecord(rawStep)) {
789
+ return { failure: `browser step ${index + 1} must be an object.` };
790
+ }
791
+ const inlineBrowser = isRecord(rawStep.browser) ? rawStep.browser : undefined;
792
+ const source = inlineBrowser ?? rawStep;
793
+ const hasExecutableFields = inlineBrowser !== undefined || "action" in rawStep || "selector" in rawStep || "path" in rawStep || "expect" in rawStep;
794
+ if (!hasExecutableFields) {
795
+ return {};
796
+ }
797
+ const action = browserPersonaActionValue(source.action);
798
+ if (!action) {
799
+ return { failure: `browser step ${index + 1} has unsupported or missing action.` };
800
+ }
801
+ const label = stringValue(rawStep.name)
802
+ ?? stringValue(rawStep.label)
803
+ ?? stringValue(source.label)
804
+ ?? `Step ${index + 1}`;
805
+ const id = publicSafeToken(stringValue(rawStep.id) ?? stringValue(source.id), `step-${String(index + 1).padStart(2, "0")}-${label}`);
806
+ const selector = stringValue(source.selector);
807
+ const pathValue = stringValue(source.path);
808
+ const value = stringValue(source.value);
809
+ const expectation = browserStepExpectationValue(source.expect ?? source.expectation);
810
+ if (action === "fill" && !selector) {
811
+ return { failure: `${id} fill action requires selector.` };
812
+ }
813
+ if (action === "waitForSelector" && !selector) {
814
+ return { failure: `${id} waitForSelector action requires selector.` };
815
+ }
816
+ if ((action === "assertText" || action === "waitForText") && !expectation?.text && !value) {
817
+ return { failure: `${id} ${action} action requires expect.text or value.` };
818
+ }
819
+ return {
820
+ step: {
821
+ action,
822
+ ...(expectation ? { expectation } : {}),
823
+ id,
824
+ label,
825
+ ...(pathValue === undefined ? {} : { path: pathValue }),
826
+ ...(selector === undefined ? {} : { selector }),
827
+ ...(value === undefined ? {} : { value })
828
+ }
829
+ };
830
+ }
831
+ function hasInlineBrowserSteps(value) {
832
+ return Array.isArray(value) && value.some((entry) => isRecord(entry) && isRecord(entry.browser));
833
+ }
834
+ function browserPersonaActionValue(value) {
835
+ if (typeof value !== "string") {
836
+ return null;
837
+ }
838
+ const normalized = value.trim().toLowerCase().replace(/[-_\s]+/g, "");
839
+ if (normalized === "goto" || normalized === "open" || normalized === "navigate")
840
+ return "goto";
841
+ if (normalized === "click" || normalized === "press")
842
+ return "click";
843
+ if (normalized === "fill" || normalized === "type")
844
+ return "fill";
845
+ if (normalized === "asserttext" || normalized === "expecttext")
846
+ return "assertText";
847
+ if (normalized === "waitfortext")
848
+ return "waitForText";
849
+ if (normalized === "waitforselector")
850
+ return "waitForSelector";
851
+ return null;
852
+ }
853
+ function browserStepExpectationValue(value) {
854
+ if (typeof value === "string" && value.trim()) {
855
+ return { text: value.trim() };
856
+ }
857
+ if (!isRecord(value)) {
858
+ return undefined;
859
+ }
860
+ const text = stringValue(value.text);
861
+ const selectorVisible = stringValue(value.selectorVisible) ?? stringValue(value.selector_visible);
862
+ const urlIncludes = stringValue(value.urlIncludes) ?? stringValue(value.url_includes);
863
+ const stateChanged = booleanValue(value.stateChanged) ?? booleanValue(value.state_changed);
864
+ const expectation = {
865
+ ...(selectorVisible === undefined ? {} : { selectorVisible }),
866
+ ...(stateChanged === undefined ? {} : { stateChanged }),
867
+ ...(text === undefined ? {} : { text }),
868
+ ...(urlIncludes === undefined ? {} : { urlIncludes })
869
+ };
870
+ return Object.keys(expectation).length === 0 ? undefined : expectation;
871
+ }
872
+ export function builtinBrowserPersonaJourney() {
873
+ return {
874
+ goal: "Drive a synthetic persona through a two-step browser journey against a running local app URL.",
875
+ scenarioId: "browser-persona-two-step",
876
+ scenarioTitle: "Browser Persona Two-Step Journey",
877
+ source: "builtin:browser-persona-two-step",
878
+ sourceDigest: digestText("browser-persona-two-step"),
879
+ startPath: "",
880
+ steps: [
881
+ {
882
+ action: "goto",
883
+ id: "step-01-load",
884
+ label: "Load app"
885
+ },
886
+ {
887
+ action: "click",
888
+ expectation: {
889
+ stateChanged: true
890
+ },
891
+ id: "step-02-interact",
892
+ label: "Complete primary action",
893
+ value: "synthetic.user@example.test"
894
+ }
895
+ ]
896
+ };
897
+ }
898
+ // ---------------------------------------------------------------------------
899
+ // The registry-facing actor session. NEW code (not moved): it reuses the moved
900
+ // primitives — the REAL step executor, expectation evaluator, blocked-step
901
+ // builder, native trace writer — against an injected or playwright-launched
902
+ // browser, and projects the result into humanish.actor-trace.v1.
903
+ // ---------------------------------------------------------------------------
904
+ export const SCRIPTED_BROWSER_PROVIDER = "browser-persona";
905
+ /** Thrown between/around steps when the journey exceeds its wall-clock budget. */
906
+ class ScriptedJourneyTimeoutError extends Error {
907
+ constructor(timeoutMs) {
908
+ super(`journey exceeded its ${timeoutMs}ms wall-clock budget`);
909
+ this.name = "ScriptedJourneyTimeoutError";
910
+ }
911
+ }
912
+ /**
913
+ * Run the scripted journey for ONE surface and return native capture + ActorTrace projection.
914
+ *
915
+ * Completion semantics (the contract the projection tests pin):
916
+ * - every step executed, every assertion passed, HTTP probe ok -> passed / goal_satisfied
917
+ * (the scenario's expect blocks ARE the success predicate; a pass claims "the app still
918
+ * affords this exact journey", nothing about user behavior);
919
+ * - a step's expectation evaluated false, a step target missing/unactionable, or an
920
+ * unreachable subject -> failed / step_failed (the harness executed faithfully; the SUBJECT
921
+ * did not satisfy the script — distinct from actor_error/harness_error);
922
+ * - journey exceeded timeoutMs -> timed_out / timed_out;
923
+ * - browser launch/import crash -> failed / harness_error;
924
+ * - gave_up / blocked_approval are UNREACHABLE from this actor (no persona patience, no
925
+ * approvals exist on a deterministic replay) — asserted in tests.
926
+ */
927
+ export async function runScriptedBrowserSession(options) {
928
+ const now = options.now ?? (() => Date.now());
929
+ const startedAtMs = now();
930
+ const startedAt = new Date(startedAtMs).toISOString();
931
+ const launch = options.launchBrowser ?? launchPlaywrightChromium;
932
+ const browserCommand = options.browserCommand ?? (options.launchBrowser ? "injected-browser" : "");
933
+ const evidenceAppUrl = options.evidenceAppUrl ?? options.appUrl;
934
+ const urlPolicy = options.urlPolicy ?? LOOPBACK_EVIDENCE_URL_POLICY;
935
+ await mkdir(path.join(options.artifactRoot, "screenshots"), { recursive: true });
936
+ await mkdir(path.join(options.artifactRoot, "traces"), { recursive: true });
937
+ const finish = async (args) => {
938
+ const completedAtMs = now();
939
+ const trace = await projectScriptedActorTrace({
940
+ artifactRoot: options.artifactRoot,
941
+ capture: args.capture,
942
+ completedAt: new Date(completedAtMs).toISOString(),
943
+ completionReason: args.completionReason,
944
+ durationMs: Math.max(0, completedAtMs - startedAtMs),
945
+ executedSteps: args.executedSteps,
946
+ journey: options.journey,
947
+ persona: options.persona,
948
+ reason: args.reason,
949
+ startedAt,
950
+ status: args.status
951
+ });
952
+ return {
953
+ status: args.status,
954
+ completionReason: args.completionReason,
955
+ reason: args.reason,
956
+ capture: args.capture,
957
+ trace
958
+ };
959
+ };
960
+ // Launch FIRST: a browser that cannot start is a harness failure, never subject evidence.
961
+ let browser;
962
+ try {
963
+ browser = await launch({ browserCommand, timeoutMs: options.timeoutMs });
964
+ }
965
+ catch (error) {
966
+ const reason = `Scripted browser launch failed: ${compactBrowserError(error)}`;
967
+ const capture = await persistScriptedFailureCapture({
968
+ appUrl: options.appUrl,
969
+ artifactRoot: options.artifactRoot,
970
+ browserCommand,
971
+ evidenceAppUrl,
972
+ journey: options.journey,
973
+ reason,
974
+ surface: options.surface,
975
+ urlPolicy
976
+ });
977
+ return finish({ capture, executedSteps: 0, status: "failed", completionReason: "harness_error", reason });
978
+ }
979
+ const journeyRun = await runScriptedJourney({
980
+ appUrl: options.appUrl,
981
+ artifactRoot: options.artifactRoot,
982
+ browser,
983
+ browserCommand,
984
+ evidenceAppUrl,
985
+ journey: options.journey,
986
+ surface: options.surface,
987
+ timeoutMs: options.timeoutMs,
988
+ urlPolicy
989
+ });
990
+ if (journeyRun.timedOut) {
991
+ return finish({
992
+ capture: journeyRun.capture,
993
+ executedSteps: journeyRun.executedSteps,
994
+ status: "timed_out",
995
+ completionReason: "timed_out",
996
+ reason: `${options.surface.label} ${new ScriptedJourneyTimeoutError(options.timeoutMs).message}.`
997
+ });
998
+ }
999
+ if (journeyRun.capture.ok) {
1000
+ return finish({
1001
+ capture: journeyRun.capture,
1002
+ executedSteps: journeyRun.executedSteps,
1003
+ status: "passed",
1004
+ completionReason: "goal_satisfied",
1005
+ reason: journeyRun.capture.reason
1006
+ });
1007
+ }
1008
+ const firstFailing = journeyRun.capture.steps.find((step) => step.status !== "passed");
1009
+ return finish({
1010
+ capture: journeyRun.capture,
1011
+ executedSteps: journeyRun.executedSteps,
1012
+ status: "failed",
1013
+ completionReason: "step_failed",
1014
+ reason: firstFailing ? `${firstFailing.id}: ${firstFailing.reason}` : journeyRun.capture.reason
1015
+ });
1016
+ }
1017
+ /** Drive the journey on an already-launched browser, mirroring the driver's capture
1018
+ * semantics (partial blocked steps on error, trace written exactly once, browser closed). */
1019
+ async function runScriptedJourney(args) {
1020
+ const started = Date.now();
1021
+ const deadline = started + args.timeoutMs;
1022
+ const tracePath = path.join("traces", `${args.surface.id}.json`);
1023
+ const absoluteTracePath = path.join(args.artifactRoot, tracePath);
1024
+ const httpProbe = await probeAppUrl(args.appUrl, Math.min(args.timeoutMs, 15_000));
1025
+ let page = null;
1026
+ const steps = [];
1027
+ let executedSteps = 0;
1028
+ let timedOut = false;
1029
+ try {
1030
+ const context = await args.browser.newContext({
1031
+ deviceScaleFactor: args.surface.viewport.deviceScaleFactor,
1032
+ isMobile: args.surface.viewport.isMobile,
1033
+ viewport: {
1034
+ width: args.surface.viewport.width,
1035
+ height: args.surface.viewport.height
1036
+ }
1037
+ });
1038
+ page = await context.newPage();
1039
+ for (const step of args.journey.steps) {
1040
+ executedSteps += 1;
1041
+ steps.push(await withJourneyDeadline(executeBrowserPersonaStep({
1042
+ absoluteArtifactRoot: args.artifactRoot,
1043
+ appUrl: args.appUrl,
1044
+ browserJourney: args.journey,
1045
+ page,
1046
+ step,
1047
+ surface: args.surface,
1048
+ timeoutMs: args.timeoutMs,
1049
+ urlPolicy: args.urlPolicy
1050
+ }), deadline, args.timeoutMs));
1051
+ }
1052
+ }
1053
+ catch (error) {
1054
+ timedOut = error instanceof ScriptedJourneyTimeoutError;
1055
+ const now = new Date().toISOString();
1056
+ const reason = compactBrowserError(error);
1057
+ if (steps.length === 0) {
1058
+ steps.push(...buildBlockedBrowserPersonaSteps({
1059
+ browserJourney: args.journey,
1060
+ currentUrl: args.appUrl,
1061
+ reason,
1062
+ surface: args.surface,
1063
+ timestamp: now,
1064
+ urlPolicy: args.urlPolicy
1065
+ }));
1066
+ }
1067
+ else if (steps.length < args.journey.steps.length) {
1068
+ const nextStep = args.journey.steps[steps.length];
1069
+ if (nextStep) {
1070
+ const { screenshotPath, written: blockedShotWritten } = await captureBlockedStepScreenshot(page, args.artifactRoot, args.surface, nextStep);
1071
+ steps.push({
1072
+ action: nextStep.action,
1073
+ completedAt: now,
1074
+ durationMs: Date.now() - started,
1075
+ id: nextStep.id,
1076
+ label: nextStep.label,
1077
+ reason,
1078
+ ...(blockedShotWritten ? { screenshotPath } : {}),
1079
+ status: "blocked",
1080
+ url: page ? sanitizeBrowserEvidenceUrl(page.url(), args.urlPolicy) : args.evidenceAppUrl
1081
+ });
1082
+ }
1083
+ }
1084
+ }
1085
+ finally {
1086
+ await args.browser.close().catch(() => undefined);
1087
+ }
1088
+ const completedAt = new Date().toISOString();
1089
+ const durationMs = Date.now() - started;
1090
+ const ok = !timedOut && httpProbe.ok && steps.length === args.journey.steps.length && steps.every((step) => step.status === "passed");
1091
+ const reason = ok
1092
+ ? `${args.surface.label} completed ${steps.length}/${steps.length} scripted browser steps from ${args.evidenceAppUrl}${httpProbe.status === undefined ? "" : ` with HTTP ${httpProbe.status}`}.`
1093
+ : `${args.surface.label} scripted browser journey blocked: ${steps.find((step) => step.status !== "passed")?.reason ?? httpProbe.reason}`;
1094
+ const scriptedScreenshotPath = surfaceScreenshotPath(steps);
1095
+ await writeJson(absoluteTracePath, buildBrowserTrace({
1096
+ appUrl: args.evidenceAppUrl,
1097
+ browserCommand: path.basename(args.browserCommand || "injected-browser"),
1098
+ browserJourney: args.journey,
1099
+ capturedAt: completedAt,
1100
+ durationMs,
1101
+ ...(httpProbe.status === undefined ? {} : { httpStatus: httpProbe.status }),
1102
+ ok,
1103
+ reason,
1104
+ ...(scriptedScreenshotPath === undefined ? {} : { screenshotPath: scriptedScreenshotPath }),
1105
+ steps,
1106
+ surface: args.surface
1107
+ }));
1108
+ return {
1109
+ capture: {
1110
+ capturedAt: completedAt,
1111
+ durationMs,
1112
+ ...(httpProbe.status === undefined ? {} : { httpStatus: httpProbe.status }),
1113
+ ok,
1114
+ reason,
1115
+ ...(scriptedScreenshotPath === undefined ? {} : { screenshotPath: scriptedScreenshotPath }),
1116
+ steps,
1117
+ surface: args.surface,
1118
+ tracePath
1119
+ },
1120
+ executedSteps,
1121
+ timedOut
1122
+ };
1123
+ }
1124
+ /** Race one step against the journey's remaining wall-clock budget. A hanging step rejects
1125
+ * with the timeout error; the journey's catch path then records honest blocked steps. */
1126
+ async function withJourneyDeadline(promise, deadline, timeoutMs) {
1127
+ const remaining = deadline - Date.now();
1128
+ if (remaining <= 0) {
1129
+ // Swallow the eventual settlement so an abandoned step can never surface an unhandled rejection.
1130
+ promise.catch(() => undefined);
1131
+ throw new ScriptedJourneyTimeoutError(timeoutMs);
1132
+ }
1133
+ let timer;
1134
+ try {
1135
+ return await Promise.race([
1136
+ promise,
1137
+ new Promise((_, reject) => {
1138
+ timer = setTimeout(() => {
1139
+ promise.catch(() => undefined);
1140
+ reject(new ScriptedJourneyTimeoutError(timeoutMs));
1141
+ }, remaining);
1142
+ })
1143
+ ]);
1144
+ }
1145
+ finally {
1146
+ clearTimeout(timer);
1147
+ }
1148
+ }
1149
+ /** Persist the all-steps-blocked capture + native trace for failures that happen before any
1150
+ * journey actuation (browser launch crash). Mirrors the driver's failure shape. */
1151
+ async function persistScriptedFailureCapture(args) {
1152
+ const capturedAt = new Date().toISOString();
1153
+ const tracePath = path.join("traces", `${args.surface.id}.json`);
1154
+ const blockedSteps = buildBlockedBrowserPersonaSteps({
1155
+ browserJourney: args.journey,
1156
+ currentUrl: args.appUrl,
1157
+ reason: args.reason,
1158
+ surface: args.surface,
1159
+ timestamp: capturedAt,
1160
+ urlPolicy: args.urlPolicy
1161
+ });
1162
+ // Pre-actuation failure: no screenshots were written, so the surface omits the
1163
+ // screenshot reference and the failure itself stands as the evidence.
1164
+ const screenshotPath = surfaceScreenshotPath(blockedSteps);
1165
+ await writeJson(path.join(args.artifactRoot, tracePath), buildBrowserTrace({
1166
+ appUrl: args.evidenceAppUrl,
1167
+ browserCommand: path.basename(args.browserCommand || "injected-browser"),
1168
+ browserJourney: args.journey,
1169
+ capturedAt,
1170
+ durationMs: 0,
1171
+ ok: false,
1172
+ reason: args.reason,
1173
+ ...(screenshotPath === undefined ? {} : { screenshotPath }),
1174
+ steps: blockedSteps,
1175
+ surface: args.surface
1176
+ }));
1177
+ return {
1178
+ capturedAt,
1179
+ durationMs: 0,
1180
+ ok: false,
1181
+ reason: args.reason,
1182
+ ...(screenshotPath === undefined ? {} : { screenshotPath }),
1183
+ steps: blockedSteps,
1184
+ surface: args.surface,
1185
+ tracePath
1186
+ };
1187
+ }
1188
+ /** Project one surface capture into humanish.actor-trace.v1. screenshotRefs are attached only
1189
+ * for frames that actually exist on disk (honest counts; blocked-not-executed steps name a
1190
+ * path that was never written). */
1191
+ async function projectScriptedActorTrace(args) {
1192
+ const writtenScreenshots = new Set();
1193
+ for (const step of args.capture.steps) {
1194
+ if (!step.screenshotPath) {
1195
+ continue;
1196
+ }
1197
+ const stats = await stat(path.join(args.artifactRoot, step.screenshotPath)).catch(() => null);
1198
+ if (stats?.isFile() && stats.size > 0) {
1199
+ writtenScreenshots.add(step.screenshotPath);
1200
+ }
1201
+ }
1202
+ let assertionCount = 0;
1203
+ const items = args.capture.steps.map((step) => {
1204
+ const assertions = step.assertions ?? [];
1205
+ assertionCount += assertions.length;
1206
+ const assertionLines = assertions.map((assertion) => `${assertion.id}: ${assertion.status} — ${assertion.reason}`);
1207
+ return {
1208
+ id: step.id,
1209
+ kind: "ui_action",
1210
+ lifecycle: "completed",
1211
+ status: step.status,
1212
+ title: redactText(`${step.action}: ${step.label}`).slice(0, 120),
1213
+ ...(step.screenshotPath && writtenScreenshots.has(step.screenshotPath)
1214
+ ? { screenshotRef: { path: step.screenshotPath, redaction: "none" } }
1215
+ : {}),
1216
+ text: redactText([step.reason, ...assertionLines].join("\n"))
1217
+ };
1218
+ });
1219
+ return {
1220
+ schema: ACTOR_TRACE_SCHEMA,
1221
+ provider: SCRIPTED_BROWSER_PROVIDER,
1222
+ protocol: "scripted-steps",
1223
+ lane: "scripted-browser",
1224
+ persona: args.persona,
1225
+ redaction: {
1226
+ status: "passed",
1227
+ screenshots: writtenScreenshots.size > 0 ? "raw" : "n/a",
1228
+ notes: "Deterministic scripted steps. Step URLs sanitized to loopback origin+path (query/hash redacted); fill values are committed-scenario constants passed through redactText; screenshots are full-fidelity raw in gitignored .humanish."
1229
+ },
1230
+ startedAt: args.startedAt,
1231
+ completedAt: args.completedAt,
1232
+ durationMs: args.durationMs,
1233
+ status: args.status,
1234
+ completionReason: args.completionReason,
1235
+ reason: redactText(args.reason),
1236
+ // No session/model ids exist on a deterministic replay — absence declared by omission.
1237
+ ids: {},
1238
+ counts: {
1239
+ steps: args.journey.steps.length,
1240
+ // `actions` mirrors the engagement-check contract (verifyRun counts it as engagement).
1241
+ actions: args.executedSteps,
1242
+ assertions: assertionCount,
1243
+ blocked: args.capture.steps.filter((step) => step.status !== "passed").length,
1244
+ screenshots: writtenScreenshots.size
1245
+ },
1246
+ items,
1247
+ // Affirmative $0 declaration, TRUE by mechanism: no provider client is importable from
1248
+ // this code path, so zeros are a recorded fact, not an estimate.
1249
+ tokenUsage: { input: 0, output: 0, total: 0, costUsd: 0 },
1250
+ capabilities: SCRIPTED_BROWSER_CAPABILITIES
1251
+ };
1252
+ }
1253
+ // ---------------------------------------------------------------------------
1254
+ // Small private helpers (moved with the driver; run.ts keeps its own copies for
1255
+ // the code that stayed behind — this module must remain a leaf).
1256
+ // ---------------------------------------------------------------------------
1257
+ async function writeJson(filePath, value) {
1258
+ await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
1259
+ }
1260
+ function shellQuote(value) {
1261
+ if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) {
1262
+ return value;
1263
+ }
1264
+ return `'${value.replace(/'/g, "'\\''")}'`;
1265
+ }
1266
+ function redactSensitiveText(text) {
1267
+ return redactToSecretLabel(text);
1268
+ }
1269
+ function isRecord(value) {
1270
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1271
+ }
1272
+ function stringValue(value) {
1273
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
1274
+ }
1275
+ function booleanValue(value) {
1276
+ return typeof value === "boolean" ? value : undefined;
1277
+ }
1278
+ function publicSafeToken(value, fallback) {
1279
+ const candidate = (value ?? fallback)
1280
+ .toLowerCase()
1281
+ .replace(/[^a-z0-9_.-]+/g, "-")
1282
+ .replace(/^-+|-+$/g, "")
1283
+ .slice(0, 80);
1284
+ return candidate || fallback;
1285
+ }
1286
+ //# sourceMappingURL=scripted-browser-actor.js.map