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