-
Notifications
You must be signed in to change notification settings - Fork 165
Expand file tree
/
Copy pathbrowser.ts
More file actions
190 lines (183 loc) · 7.33 KB
/
Copy pathbrowser.ts
File metadata and controls
190 lines (183 loc) · 7.33 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
// Browser surface: Playwright over the target's real web UI, dark mode, with
// the standard debugging artifacts — a Playwright trace (time-travel DOM,
// network, console), the session video (transcoded to mp4 so it plays
// everywhere), per-step screenshots, and a failure screenshot. The scenario
// drives `page` directly; assertions are vitest's job.
import { execFile } from "node:child_process";
import { copyFileSync, mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { promisify } from "node:util";
import { Effect } from "effect";
import { chromium, type Page } from "playwright";
import { markFocus, markNavigation, markRecordingStart } from "../timeline";
import { appendTraces, type TraceEntry } from "../trace-harvest";
import type { Identity, Target } from "../target";
export interface BrowserSession {
readonly page: Page;
/** Perform one user-visible step; names the trace group + saves a screenshot. */
readonly step: (label: string, action: (page: Page) => Promise<void>) => Promise<void>;
}
export interface BrowserSurface {
readonly session: (
identity: Identity,
drive: (session: BrowserSession) => Promise<void>,
) => Effect.Effect<void>;
}
const slug = (text: string): string =>
text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
// acquireUseRelease so a vitest timeout (fiber interruption) still closes the
// browser and flushes video + trace — a bare promise would leak Chromium.
export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface => ({
session: (identity, drive) =>
Effect.acquireUseRelease(
Effect.promise(async () => {
const videoTmp = join(dir, ".video-tmp");
mkdirSync(videoTmp, { recursive: true });
// On the desk (E2E_DESK), the browser is a real headed window on the
// virtual display — the desk's single screen recording films it next
// to the chat terminal, exactly like a developer tabbing over.
const browser = await chromium.launch(
process.env.E2E_DESK === "1"
? {
headless: false,
args: ["--window-position=300,40", "--window-size=1100,830"],
}
: {},
);
const context = await browser.newContext({
colorScheme: "dark",
viewport: { width: 1280, height: 800 },
recordVideo: { dir: videoTmp, size: { width: 1280, height: 800 } },
baseURL: target.baseUrl,
});
await context.tracing.start({
screenshots: true,
snapshots: true,
sources: true,
});
if (identity.cookies?.length) {
await context.addCookies(
identity.cookies.map((cookie) => ({
...cookie,
url: target.baseUrl,
})),
);
}
const page = await context.newPage();
// The session video's clock starts with the page; anchor it for the
// run's focus timeline (scripts/film.ts cuts on these).
markRecordingStart(dir, "browser");
// Main-frame navigations feed the viewer's synthetic URL bar — the
// recording itself is chromeless, so this is the only place the
// address the developer "typed" survives.
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) markNavigation(dir, frame.url());
});
// Harvest distributed-trace ids: every app API request carries a W3C
// traceparent (Effect's HttpClient), and each id names one
// click→server→DB trace in whatever OTLP store the run exported to
// (motel locally). Appended to traces.json (shared with the MCP
// surface's terminal-side entries) so the runs viewer can link a
// recording to its traces. Duration comes from the finished/failed
// event so the viewer can answer "why did that take so long"
// without leaving the run page.
const traceIds: Array<TraceEntry & { ms?: number; status?: number }> = [];
const inflight = new Map<unknown, (typeof traceIds)[number]>();
page.on("request", (request) => {
const traceparent = request.headers()["traceparent"];
const match = traceparent ? /^[0-9a-f]{2}-([0-9a-f]{32})-/.exec(traceparent) : null;
if (match?.[1]) {
const entry: (typeof traceIds)[number] = {
id: match[1],
at: Date.now(),
url: request.url(),
source: "browser",
};
traceIds.push(entry);
inflight.set(request, entry);
}
});
page.on("requestfinished", async (request) => {
const entry = inflight.get(request);
if (!entry) return;
inflight.delete(request);
entry.ms = Date.now() - entry.at;
entry.status = (await request.response().catch(() => null))?.status();
});
page.on("requestfailed", (request) => {
const entry = inflight.get(request);
if (!entry) return;
inflight.delete(request);
entry.ms = Date.now() - entry.at;
});
return {
browser,
context,
page,
videoTmp,
shots: { count: 0 },
traceIds,
};
}),
({ page, context, shots }) =>
Effect.promise(async () => {
const step = async (label: string, action: (page: Page) => Promise<void>) => {
// Acting on the page IS focusing the browser window.
markFocus(dir, "browser");
await context.tracing.group(label);
try {
await action(page);
} finally {
await context.tracing.groupEnd();
}
await page.screenshot({
path: join(dir, `${String(shots.count++).padStart(2, "0")}-${slug(label)}.png`),
});
};
try {
await drive({ page, step });
} catch (error) {
// Freeze the scene: the artifact dir shows the screen at failure.
await page.screenshot({ path: join(dir, "failure.png") }).catch(() => {});
throw error;
}
}),
({ browser, context, page, videoTmp, traceIds }) =>
Effect.promise(async () => {
appendTraces(dir, traceIds);
await context.tracing.stop({ path: join(dir, "trace.zip") }).catch(() => {});
const video = page.video();
await context.close(); // flushes the recording
await browser.close();
const recordedPath = await video?.path().catch(() => undefined);
if (recordedPath) {
try {
// mp4 plays everywhere (Safari/iOS don't do webm).
await promisify(execFile)("ffmpeg", [
"-y",
"-i",
recordedPath,
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"26",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
join(dir, "session.mp4"),
]);
} catch {
copyFileSync(recordedPath, join(dir, "session.webm"));
}
}
rmSync(videoTmp, { recursive: true, force: true });
}),
),
});