pi-agent-browser-native 0.8.2 → 0.8.3
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/CHANGELOG.md +23 -0
- package/README.md +16 -7
- package/dist/extensions/agent-browser/index.js +430 -157
- package/dist/extensions/agent-browser/lib/browser-journal.js +418 -0
- package/dist/extensions/agent-browser/lib/browser-session-conversion.js +381 -0
- package/dist/extensions/agent-browser/lib/browser-transcript.js +119 -19
- package/dist/extensions/agent-browser/lib/input-modes/job.js +49 -2
- package/dist/extensions/agent-browser/lib/input-modes/params.js +32 -35
- package/dist/extensions/agent-browser/lib/managed-session-policy-lock.js +25 -5
- package/dist/extensions/agent-browser/lib/managed-session-restore.js +20 -5
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/managed-session-daemon-policy.js +97 -78
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare.js +23 -2
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/process-output.js +52 -17
- package/dist/extensions/agent-browser/lib/orchestration/electron-host/index.js +1 -1
- package/dist/extensions/agent-browser/lib/orchestration/script-mode.js +13 -8
- package/dist/extensions/agent-browser/lib/playbook.js +9 -2
- package/dist/extensions/agent-browser/lib/prompt-policy.js +0 -14
- package/dist/extensions/agent-browser/lib/results/presentation/large-output.js +2 -1
- package/dist/extensions/agent-browser/lib/session-page-state.js +95 -157
- package/dist/extensions/agent-browser/lib/temp.js +14 -6
- package/dist/extensions/agent-browser/lib/tool-surface.js +12 -23
- package/dist/extensions/agent-browser/lib/web-search.js +0 -8
- package/docs/ARCHITECTURE.md +13 -11
- package/docs/COMMAND_REFERENCE.md +9 -7
- package/docs/RELEASE.md +3 -3
- package/docs/REQUIREMENTS.md +1 -1
- package/docs/SESSION_STORAGE.md +60 -0
- package/docs/SUPPORT_MATRIX.md +8 -5
- package/docs/TOOL_CONTRACT.md +17 -14
- package/package.json +9 -5
- package/scripts/build.mjs +4 -3
- package/scripts/convert-browser-session.mjs +22 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { link, lstat, mkdir, open, unlink } from "node:fs/promises";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import { Readable } from "node:stream";
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
7
|
+
import parser from "stream-json/parser.js";
|
|
8
|
+
import ignore from "stream-json/filters/ignore.js";
|
|
9
|
+
import stringer from "stream-json/stringer.js";
|
|
10
|
+
import disassembler from "stream-json/disassembler.js";
|
|
11
|
+
import { extractUpstreamCommandTokens } from "./argv-descriptor.js";
|
|
12
|
+
import { getAgentBrowserSessionIdentityKey, isAgentBrowserSessionIdentityKeyInNamespace } from "./argv-grammar.js";
|
|
13
|
+
import { batchHasSuccessfulCloseAll, getSuccessfulBatchCloseLifecycle } from "./batch-lifecycle.js";
|
|
14
|
+
import { BROWSER_RESULT_TOOLS, BROWSER_STATE_FIELDS, BROWSER_TRANSITION_ENTRY, applyArtifactChanges, artifactChanges, browserStateEffects, getBrowserRecord, snapshotDefinition } from "./browser-transcript.js";
|
|
15
|
+
import { projectJson, readRange, scanJournalMetadata } from "./browser-journal.js";
|
|
16
|
+
import { isCloseAllCommand, isCloseCommand, isReadOnlyDiagnosticSessionTargetCommand } from "./command-taxonomy.js";
|
|
17
|
+
import { isRecord } from "./parsing.js";
|
|
18
|
+
import { isSessionArtifactManifest } from "./results/artifact-manifest.js";
|
|
19
|
+
import { buildNoActivePageRefSnapshotInvalidation, extractSessionTabTargetFromBatchResults, isNoActivePageSnapshotFailure, normalizeSessionTabTarget } from "./session-page-state.js";
|
|
20
|
+
const emptyProjection = () => ({ pages: new Map(), nextIndex: 0 });
|
|
21
|
+
function legacyTarget(value) {
|
|
22
|
+
return normalizeSessionTabTarget({
|
|
23
|
+
url: typeof value.url === "string" ? value.url : undefined,
|
|
24
|
+
title: typeof value.title === "string" ? value.title : undefined,
|
|
25
|
+
targetId: typeof value.targetId === "string" ? value.targetId : undefined,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function legacyMessage(entry) {
|
|
29
|
+
if (entry.type === "custom" && entry.customType === BROWSER_TRANSITION_ENTRY && isRecord(entry.data) && isRecord(entry.data.details))
|
|
30
|
+
return entry.data;
|
|
31
|
+
const message = entry.type === "message" && isRecord(entry.message) ? entry.message : undefined;
|
|
32
|
+
return message && typeof message.toolName === "string" && BROWSER_RESULT_TOOLS.has(message.toolName) && isRecord(message.details)
|
|
33
|
+
&& message.details.browserEventVersion !== 1 ? message : undefined;
|
|
34
|
+
}
|
|
35
|
+
function legacySnapshot(value, id) {
|
|
36
|
+
if (!isRecord(value) || !Array.isArray(value.refIds))
|
|
37
|
+
return undefined;
|
|
38
|
+
const refIds = value.refIds.filter((ref) => typeof ref === "string" && /^e\d+$/.test(ref));
|
|
39
|
+
const sourceRefs = isRecord(value.refs) ? value.refs : {};
|
|
40
|
+
const snapshot = {
|
|
41
|
+
snapshotId: typeof value.snapshotId === "string" ? value.snapshotId : id, refIds,
|
|
42
|
+
generation: typeof value.generation === "string" ? value.generation : undefined,
|
|
43
|
+
refs: Object.fromEntries(refIds.flatMap(refId => {
|
|
44
|
+
const ref = sourceRefs[refId];
|
|
45
|
+
if (!isRecord(ref) || typeof ref.name !== "string" || typeof ref.role !== "string")
|
|
46
|
+
return [];
|
|
47
|
+
return [[refId, { name: ref.name, role: ref.role, ...(typeof ref.isEditable === "boolean" ? { isEditable: ref.isEditable } : {}), ...(typeof ref.isContentEditable === "boolean" ? { isContentEditable: ref.isContentEditable } : {}) }]];
|
|
48
|
+
})),
|
|
49
|
+
target: isRecord(value.target) ? legacyTarget(value.target) : undefined,
|
|
50
|
+
};
|
|
51
|
+
return snapshotDefinition(snapshot);
|
|
52
|
+
}
|
|
53
|
+
/** Offline only: normalize old receipts along their actual parent ancestry, never physical-order equality. */
|
|
54
|
+
export function convertLegacyBrowserEntry(entry, parent = emptyProjection(), ownerSessionId) {
|
|
55
|
+
const current = { pages: new Map(parent.pages), manifest: parent.manifest, nextIndex: parent.nextIndex };
|
|
56
|
+
const nativeId = typeof entry.id === "string" ? entry.id : randomUUID();
|
|
57
|
+
const canonical = getBrowserRecord(entry);
|
|
58
|
+
if (canonical) {
|
|
59
|
+
current.manifest = applyArtifactChanges(parent.manifest, canonical.event.artifacts);
|
|
60
|
+
for (const page of canonical.event.pages ?? []) {
|
|
61
|
+
const prior = current.pages.get(page.key) ?? {};
|
|
62
|
+
if (page.clear) {
|
|
63
|
+
current.pages.delete(page.key);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const definition = canonical.snapshot;
|
|
67
|
+
const digest = definition?.refs ? createHash("sha256").update(JSON.stringify({ refs: definition.refs, target: definition.target, generation: definition.generation })).digest("hex") : prior.snapshot?.digest ?? "";
|
|
68
|
+
current.pages.set(page.key, { target: page.target, unknown: page.unknown, reopenPending: page.reopenPending,
|
|
69
|
+
snapshot: page.refs.kind === "replace" || page.refs.kind === "reuse" ? { id: page.refs.snapshotId, digest, usable: true, candidate: false }
|
|
70
|
+
: prior.snapshot ? { ...prior.snapshot, usable: false, candidate: canonical.event.phase === "begin" } : undefined,
|
|
71
|
+
pending: canonical.event.phase === "begin" ? { operationId: canonical.event.operationId, toolCallId: canonical.event.toolCallId, index: canonical.event.commandIndex } : undefined });
|
|
72
|
+
}
|
|
73
|
+
return { projection: current };
|
|
74
|
+
}
|
|
75
|
+
if (entry.type === "custom" && entry.customType === "agent-browser-script-session" && isRecord(entry.data)) {
|
|
76
|
+
return { record: { event: { version: 1, phase: "state", operationId: `legacy-${nativeId}`, toolCallId: `legacy-${nativeId}`, commandIndex: 0, isError: entry.data.cleanup !== "closed", state: { scriptLease: { ...entry.data, ownerSessionId: entry.data.ownerSessionId ?? ownerSessionId } } } }, projection: current };
|
|
77
|
+
}
|
|
78
|
+
const message = legacyMessage(entry);
|
|
79
|
+
if (!message || !isRecord(message.details))
|
|
80
|
+
return { projection: parent };
|
|
81
|
+
const details = message.details;
|
|
82
|
+
const sessionName = typeof details.sessionName === "string" ? details.sessionName : undefined;
|
|
83
|
+
const namespace = typeof details.namespace === "string" ? details.namespace : undefined;
|
|
84
|
+
const key = sessionName ? getAgentBrowserSessionIdentityKey(sessionName, namespace) : undefined;
|
|
85
|
+
const args = Array.isArray(details.args) ? details.args.filter((arg) => typeof arg === "string") : [];
|
|
86
|
+
const tokens = extractUpstreamCommandTokens(args);
|
|
87
|
+
const command = typeof details.command === "string" ? details.command : tokens[0];
|
|
88
|
+
const subcommand = typeof details.subcommand === "string" ? details.subcommand : tokens[1];
|
|
89
|
+
const isError = message.isError === true;
|
|
90
|
+
const toolCallId = typeof message.toolCallId === "string" ? message.toolCallId : `legacy-${nativeId}`;
|
|
91
|
+
const prior = key ? current.pages.get(key) ?? {} : {};
|
|
92
|
+
const begin = entry.type === "custom" && details.sessionTabTargetUnknown === true && details.command === undefined && details.exitCode === undefined && details.agentBrowserStarted === undefined;
|
|
93
|
+
const pending = prior.pending?.toolCallId === toolCallId ? prior.pending : undefined;
|
|
94
|
+
const phase = begin ? "begin" : pending ? "finish" : "state";
|
|
95
|
+
const commandIndex = begin ? current.nextIndex++ : pending?.index ?? 0;
|
|
96
|
+
const operationId = pending?.operationId ?? `legacy-${nativeId}`;
|
|
97
|
+
const pages = [];
|
|
98
|
+
const closeLifecycle = getSuccessfulBatchCloseLifecycle(details.batchSteps);
|
|
99
|
+
const closeAll = details.closeAllApplied === true || (!isError && isCloseAllCommand(tokens)) || batchHasSuccessfulCloseAll(details.batchSteps);
|
|
100
|
+
const lifecycleReset = !isError && isCloseCommand(command) || closeLifecycle !== undefined;
|
|
101
|
+
if (closeAll) {
|
|
102
|
+
for (const pageKey of current.pages.keys())
|
|
103
|
+
if (isAgentBrowserSessionIdentityKeyInNamespace(pageKey, namespace)) {
|
|
104
|
+
current.pages.delete(pageKey);
|
|
105
|
+
pages.push({ key: pageKey, clear: true, refs: { kind: "invalidate" } });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
else if (key && lifecycleReset) {
|
|
109
|
+
current.pages.delete(key);
|
|
110
|
+
pages.push({ key, clear: true, refs: { kind: "invalidate" } });
|
|
111
|
+
}
|
|
112
|
+
let definition;
|
|
113
|
+
const hasPageEffects = begin || ["sessionTabTargetUnknown", "sessionTabTarget", "sessionTabReopenPending", "refSnapshot", "refSnapshotInvalidation"].some(field => details[field] !== undefined)
|
|
114
|
+
|| command === "batch" && Array.isArray(details.data);
|
|
115
|
+
if (key && hasPageEffects && !((closeAll || lifecycleReset) && (isCloseCommand(command) || closeLifecycle?.endsClosed === true))) {
|
|
116
|
+
const page = { ...(current.pages.get(key) ?? {}) };
|
|
117
|
+
if (begin) {
|
|
118
|
+
page.target = undefined;
|
|
119
|
+
page.unknown = true;
|
|
120
|
+
page.snapshot = prior.snapshot ? { ...prior.snapshot, usable: false, candidate: true } : undefined;
|
|
121
|
+
page.pending = { toolCallId, operationId, index: commandIndex };
|
|
122
|
+
pages.push({ key, refs: { kind: "unknown" }, unknown: true });
|
|
123
|
+
}
|
|
124
|
+
else if (details.sessionTabTargetUnknown === true) {
|
|
125
|
+
page.target = undefined;
|
|
126
|
+
page.unknown = true;
|
|
127
|
+
page.pending = undefined;
|
|
128
|
+
page.snapshot = page.snapshot ? { ...page.snapshot, usable: false, candidate: false } : undefined;
|
|
129
|
+
pages.push({ key, refs: { kind: "unknown", invalidation: isRecord(details.refSnapshotInvalidation) ? details.refSnapshotInvalidation : undefined }, unknown: true });
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
if (!isRecord(details.compiledNetworkSourceLookup) && !isReadOnlyDiagnosticSessionTargetCommand(command, subcommand) && isRecord(details.sessionTabTarget))
|
|
133
|
+
page.target = legacyTarget(details.sessionTabTarget) ?? page.target;
|
|
134
|
+
else if (command === "batch" && !isRecord(details.compiledNetworkSourceLookup))
|
|
135
|
+
page.target = extractSessionTabTargetFromBatchResults(details.data) ?? page.target;
|
|
136
|
+
if (page.target)
|
|
137
|
+
page.unknown = undefined;
|
|
138
|
+
if (typeof details.sessionTabReopenPending === "boolean")
|
|
139
|
+
page.reopenPending = details.sessionTabReopenPending;
|
|
140
|
+
const invalidation = isRecord(details.refSnapshotInvalidation) && ["no-active-page", "page-transition"].includes(String(details.refSnapshotInvalidation.reason))
|
|
141
|
+
? details.refSnapshotInvalidation
|
|
142
|
+
: isNoActivePageSnapshotFailure(command, typeof details.error === "string" ? details.error : typeof details.summary === "string" ? details.summary : undefined) ? buildNoActivePageRefSnapshotInvalidation() : undefined;
|
|
143
|
+
const candidate = invalidation ? undefined : legacySnapshot(details.refSnapshot, `snapshot-${nativeId}`);
|
|
144
|
+
let refs;
|
|
145
|
+
if (candidate) {
|
|
146
|
+
const digest = createHash("sha256").update(JSON.stringify({ refs: candidate.refs, target: candidate.target, generation: candidate.generation })).digest("hex");
|
|
147
|
+
if (page.snapshot?.digest === digest && (page.snapshot.usable || page.snapshot.candidate)
|
|
148
|
+
&& (!isRecord(details.refSnapshot) || typeof details.refSnapshot.snapshotId !== "string" || details.refSnapshot.snapshotId === page.snapshot.id))
|
|
149
|
+
refs = { kind: "reuse", snapshotId: page.snapshot.id };
|
|
150
|
+
else {
|
|
151
|
+
definition = candidate;
|
|
152
|
+
refs = { kind: "replace", snapshotId: candidate.id };
|
|
153
|
+
}
|
|
154
|
+
page.snapshot = { id: refs.snapshotId, digest, usable: true, candidate: false };
|
|
155
|
+
}
|
|
156
|
+
else if (invalidation) {
|
|
157
|
+
refs = { kind: "invalidate", invalidation: { reason: invalidation.reason, summary: typeof invalidation.summary === "string" ? invalidation.summary : "The prior refs were invalidated; take a fresh snapshot." } };
|
|
158
|
+
page.snapshot = page.snapshot ? { ...page.snapshot, usable: false, candidate: false } : undefined;
|
|
159
|
+
}
|
|
160
|
+
else
|
|
161
|
+
refs = page.snapshot?.usable ? { kind: "reuse", snapshotId: page.snapshot.id } : { kind: "invalidate" };
|
|
162
|
+
page.pending = undefined;
|
|
163
|
+
pages.push({ key, target: page.target, unknown: page.unknown, reopenPending: page.reopenPending, refs });
|
|
164
|
+
}
|
|
165
|
+
current.pages.set(key, page);
|
|
166
|
+
}
|
|
167
|
+
if (isSessionArtifactManifest(details.artifactManifest) && (!parent.manifest || details.artifactManifest.updatedAtMs >= parent.manifest.updatedAtMs))
|
|
168
|
+
current.manifest = details.artifactManifest;
|
|
169
|
+
const artifacts = artifactChanges(parent.manifest, current.manifest);
|
|
170
|
+
if (!artifacts)
|
|
171
|
+
current.manifest = parent.manifest;
|
|
172
|
+
const state = browserStateEffects(details);
|
|
173
|
+
if (isRecord(state.electron)) {
|
|
174
|
+
const stamp = (value) => isRecord(value) ? { ...value, ownerSessionId: value.ownerSessionId ?? ownerSessionId } : value;
|
|
175
|
+
const cleanup = isRecord(state.electron.cleanup) ? state.electron.cleanup : undefined;
|
|
176
|
+
state.electron = { ...state.electron, launch: stamp(state.electron.launch), ...(cleanup ? { cleanup: {
|
|
177
|
+
...cleanup, records: Array.isArray(cleanup.records) ? cleanup.records.map(stamp) : cleanup.records,
|
|
178
|
+
results: Array.isArray(cleanup.results) ? cleanup.results.map(row => isRecord(row) ? { ...row, record: stamp(row.record) } : row) : cleanup.results,
|
|
179
|
+
} } : {}) };
|
|
180
|
+
}
|
|
181
|
+
return { record: { event: { version: 1, phase, operationId, toolCallId, commandIndex, isError, state, pages, artifacts }, ...(definition ? { snapshot: definition } : {}) }, projection: current };
|
|
182
|
+
}
|
|
183
|
+
/** Explicit archive/in-memory conversion. Runtime restoration never invokes the legacy decoder. */
|
|
184
|
+
export function convertBrowserEntries(entries, ownerSessionId) {
|
|
185
|
+
const states = new Map();
|
|
186
|
+
let linear = emptyProjection();
|
|
187
|
+
return entries.map((value, index) => {
|
|
188
|
+
if (!isRecord(value))
|
|
189
|
+
return value;
|
|
190
|
+
const entry = { ...value, id: typeof value.id === "string" ? value.id : `archive-${index}`, parentId: value.parentId ?? null };
|
|
191
|
+
const parent = typeof value.parentId === "string" ? states.get(value.parentId) ?? emptyProjection() : Object.hasOwn(value, "parentId") ? emptyProjection() : linear;
|
|
192
|
+
const converted = convertLegacyBrowserEntry(entry, parent, ownerSessionId);
|
|
193
|
+
states.set(entry.id, converted.projection);
|
|
194
|
+
linear = converted.projection;
|
|
195
|
+
return converted.record ? { ...entry, ...(entry.type === "custom" ? { customType: BROWSER_TRANSITION_ENTRY } : {}), data: converted.record } : entry;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
const LEGACY_DETAILS_FIELDS = [...BROWSER_STATE_FIELDS, "browserEventVersion", "electron", "compiledNetworkSourceLookup", "sessionTabTarget", "sessionTabTargetUnknown", "sessionTabReopenPending", "refSnapshot", "refSnapshotInvalidation", "artifactManifest", "error", "summary"];
|
|
199
|
+
const LEGACY_FIELDS = [
|
|
200
|
+
["data", "event"], ["data", "snapshot"], ["data", "cleanup"], ["data", "sessionName"], ["data", "closeCommandArgs"], ["data", "launchAttempted"],
|
|
201
|
+
["data", "toolCallId"], ["data", "isError"], ["message", "toolName"], ["message", "toolCallId"], ["message", "isError"],
|
|
202
|
+
...["data", "message"].flatMap(root => LEGACY_DETAILS_FIELDS.map(field => [root, "details", field])),
|
|
203
|
+
...["data", "message"].flatMap(root => [
|
|
204
|
+
...["command", "success", "lifecycle"].map(field => [root, "details", "batchSteps", "*", field]),
|
|
205
|
+
...[["data", "lifecycle"], ["result", "lifecycle"]].map(field => [root, "details", "batchSteps", "*", ...field]),
|
|
206
|
+
]),
|
|
207
|
+
...["data", "message"].flatMap(root => [
|
|
208
|
+
...[["command"], ["success"], ["error"]].map(field => [root, "details", "data", "*", ...field]),
|
|
209
|
+
...["url", "title", "origin", "targetId"].map(field => [root, "details", "data", "*", "result", field]),
|
|
210
|
+
]),
|
|
211
|
+
];
|
|
212
|
+
async function sourceChecksum(file, size) {
|
|
213
|
+
const hash = createHash("sha256");
|
|
214
|
+
for await (const chunk of readRange(file, { offset: 0, length: size }))
|
|
215
|
+
hash.update(chunk);
|
|
216
|
+
return hash.digest("hex");
|
|
217
|
+
}
|
|
218
|
+
async function writeBytes(file, chunks) {
|
|
219
|
+
for await (const chunk of chunks) {
|
|
220
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
221
|
+
let offset = 0;
|
|
222
|
+
while (offset < buffer.length) {
|
|
223
|
+
const { bytesWritten } = await file.write(buffer, offset, buffer.length - offset);
|
|
224
|
+
if (!bytesWritten)
|
|
225
|
+
throw new Error("Could not write converted journal.");
|
|
226
|
+
offset += bytesWritten;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async function rewriteMessage(file, range, destination, record) {
|
|
231
|
+
async function* decode() {
|
|
232
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
233
|
+
for await (const bytes of readRange(file, range))
|
|
234
|
+
yield decoder.decode(bytes, { stream: true });
|
|
235
|
+
const tail = decoder.decode();
|
|
236
|
+
if (tail)
|
|
237
|
+
yield tail;
|
|
238
|
+
}
|
|
239
|
+
const tokenizer = parser.asStream({ packValues: false, packKeys: true, streamKeys: false });
|
|
240
|
+
const filter = ignore.asStream({ filter: /^(?:data|message\.details\.(?:refSnapshot|artifactManifest|browserEventVersion))$/ });
|
|
241
|
+
async function* inject(tokens) {
|
|
242
|
+
let depth = 0;
|
|
243
|
+
for await (const token of tokens) {
|
|
244
|
+
if (token.name === "startObject" || token.name === "startArray")
|
|
245
|
+
depth++;
|
|
246
|
+
if (token.name === "endObject" || token.name === "endArray") {
|
|
247
|
+
depth--;
|
|
248
|
+
if (depth === 0) {
|
|
249
|
+
yield { name: "keyValue", value: "data" };
|
|
250
|
+
yield* disassembler({ packValues: false, packKeys: true, streamKeys: false })(record);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
yield token;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const output = stringer.asStream({ useKeyValues: true });
|
|
257
|
+
const processing = pipeline(Readable.from(decode()), tokenizer, filter, inject, output);
|
|
258
|
+
try {
|
|
259
|
+
await writeBytes(destination, output);
|
|
260
|
+
await processing;
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
tokenizer.destroy();
|
|
264
|
+
filter.destroy();
|
|
265
|
+
output.destroy();
|
|
266
|
+
}
|
|
267
|
+
await destination.write("\n");
|
|
268
|
+
}
|
|
269
|
+
/** Separate-copy publication only. The operator must quiesce the exact writer first. */
|
|
270
|
+
export async function convertBrowserSession(options) {
|
|
271
|
+
if (!options.confirmedStopped)
|
|
272
|
+
throw new Error("Stop/quiesce the exact session writer first, then pass --confirm-stopped. Conversion never stops a session.");
|
|
273
|
+
const source = resolve(options.source);
|
|
274
|
+
const destination = resolve(options.destination);
|
|
275
|
+
if (source === destination)
|
|
276
|
+
throw new Error("Conversion requires a separate destination; originals are never rewritten.");
|
|
277
|
+
const sourcePathStat = await lstat(source);
|
|
278
|
+
if (!sourcePathStat.isFile() || sourcePathStat.isSymbolicLink())
|
|
279
|
+
throw new Error("Conversion source must be a regular file, not a symlink.");
|
|
280
|
+
const directory = dirname(destination);
|
|
281
|
+
await mkdir(directory, { mode: 0o700, recursive: true });
|
|
282
|
+
const directoryStat = await lstat(directory);
|
|
283
|
+
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink() || process.platform !== "win32" && (directoryStat.uid !== process.getuid?.() || (directoryStat.mode & 0o077) !== 0))
|
|
284
|
+
throw new Error("Conversion destination must be a private current-user directory (0700 on POSIX), without symlinks.");
|
|
285
|
+
try {
|
|
286
|
+
await lstat(destination);
|
|
287
|
+
throw new Error("Conversion destination is occupied.");
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
if (error.code !== "ENOENT")
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
const file = await open(source, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
294
|
+
const stagePath = `${destination}.${randomUUID()}.tmp`;
|
|
295
|
+
let stage;
|
|
296
|
+
let published = false;
|
|
297
|
+
try {
|
|
298
|
+
const initial = await file.stat();
|
|
299
|
+
if (initial.dev !== sourcePathStat.dev || initial.ino !== sourcePathStat.ino)
|
|
300
|
+
throw new Error("Conversion source changed before capture.");
|
|
301
|
+
const checksum = await sourceChecksum(file, initial.size);
|
|
302
|
+
const metadata = await scanJournalMetadata(file, initial.size, true);
|
|
303
|
+
const header = metadata[0];
|
|
304
|
+
if (header?.value.type !== "session" || typeof header.value.id !== "string")
|
|
305
|
+
throw new Error("Conversion source has no native session header.");
|
|
306
|
+
const ids = new Set();
|
|
307
|
+
const states = new Map();
|
|
308
|
+
stage = await open(stagePath, "wx+", 0o600);
|
|
309
|
+
let convertedRecords = 0;
|
|
310
|
+
let snapshotDefinitions = 0;
|
|
311
|
+
for (const entry of metadata) {
|
|
312
|
+
let record;
|
|
313
|
+
if (entry.value.type !== "session") {
|
|
314
|
+
if (typeof entry.value.id !== "string" || ids.has(entry.value.id))
|
|
315
|
+
throw new Error("Conversion source contains a missing or duplicate native entry ID.");
|
|
316
|
+
if (entry.value.parentId !== null && (typeof entry.value.parentId !== "string" || !ids.has(entry.value.parentId)))
|
|
317
|
+
throw new Error("Conversion source contains a missing or forward parent.");
|
|
318
|
+
ids.add(entry.value.id);
|
|
319
|
+
const parent = typeof entry.value.parentId === "string" ? states.get(entry.value.parentId) ?? emptyProjection() : emptyProjection();
|
|
320
|
+
const relevant = entry.value.type === "custom" && [BROWSER_TRANSITION_ENTRY, "agent-browser-script-session"].includes(String(entry.value.customType))
|
|
321
|
+
|| isRecord(entry.value.message) && BROWSER_RESULT_TOOLS.has(String(entry.value.message.toolName));
|
|
322
|
+
if (relevant) {
|
|
323
|
+
const details = await projectJson(readRange(file, entry), LEGACY_FIELDS, Infinity);
|
|
324
|
+
const conversion = convertLegacyBrowserEntry({ ...entry.value, ...details }, parent, header.value.id);
|
|
325
|
+
record = conversion.record;
|
|
326
|
+
states.set(entry.value.id, conversion.projection);
|
|
327
|
+
}
|
|
328
|
+
else
|
|
329
|
+
states.set(entry.value.id, parent);
|
|
330
|
+
}
|
|
331
|
+
if (record) {
|
|
332
|
+
const data = { ...record, archive: { sourceSha256: checksum, offset: entry.offset, length: entry.length } };
|
|
333
|
+
if (entry.value.type === "message")
|
|
334
|
+
await rewriteMessage(file, entry, stage, data);
|
|
335
|
+
else
|
|
336
|
+
await stage.write(`${JSON.stringify({ ...entry.value, customType: BROWSER_TRANSITION_ENTRY, data })}\n`);
|
|
337
|
+
convertedRecords++;
|
|
338
|
+
if (record.snapshot)
|
|
339
|
+
snapshotDefinitions++;
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
await writeBytes(stage, readRange(file, entry));
|
|
343
|
+
if (entry.offset + entry.length === initial.size) {
|
|
344
|
+
const lastByte = Buffer.alloc(1);
|
|
345
|
+
await file.read(lastByte, 0, 1, initial.size - 1);
|
|
346
|
+
if (lastByte[0] !== 10)
|
|
347
|
+
await stage.write("\n");
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
await stage.sync();
|
|
352
|
+
const output = await stage.stat();
|
|
353
|
+
const destinationSha256 = await sourceChecksum(stage, output.size);
|
|
354
|
+
const final = await file.stat();
|
|
355
|
+
const finalPath = await lstat(source);
|
|
356
|
+
if (final.dev !== initial.dev || final.ino !== initial.ino || final.size !== initial.size || final.mtimeMs !== initial.mtimeMs
|
|
357
|
+
|| finalPath.dev !== initial.dev || finalPath.ino !== initial.ino || await sourceChecksum(file, initial.size) !== checksum)
|
|
358
|
+
throw new Error("Conversion source changed; no destination was published.");
|
|
359
|
+
await stage.close();
|
|
360
|
+
stage = undefined;
|
|
361
|
+
await link(stagePath, destination); // Exclusive publication; never overwrite an existing destination.
|
|
362
|
+
published = true;
|
|
363
|
+
return { version: 1, source, destination, sessionId: header.value.id, sourceSha256: checksum, destinationSha256, entryCount: ids.size,
|
|
364
|
+
convertedRecords, snapshotDefinitions, sourceBytes: initial.size, destinationBytes: output.size };
|
|
365
|
+
}
|
|
366
|
+
finally {
|
|
367
|
+
await stage?.close();
|
|
368
|
+
await file.close();
|
|
369
|
+
await unlink(stagePath).catch(error => { if (error.code !== "ENOENT")
|
|
370
|
+
throw error; });
|
|
371
|
+
if (published && process.platform !== "win32") {
|
|
372
|
+
const parent = await open(directory, constants.O_RDONLY);
|
|
373
|
+
try {
|
|
374
|
+
await parent.sync();
|
|
375
|
+
}
|
|
376
|
+
finally {
|
|
377
|
+
await parent.close();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
@@ -1,33 +1,133 @@
|
|
|
1
1
|
import { isRecord } from "./parsing.js";
|
|
2
|
+
import { redactSensitiveValue } from "./runtime.js";
|
|
3
|
+
import { buildPageTransitionRefSnapshotInvalidation, normalizeSessionTabTarget, targetsMatch } from "./session-page-state.js";
|
|
4
|
+
import { getSessionArtifactManifestEntryKey, isPendingRecordingArtifact, isSessionArtifactManifest } from "./results/artifact-manifest.js";
|
|
5
|
+
import { extractAgentBrowserLifecycle } from "./results/presentation/common.js";
|
|
2
6
|
export const BROWSER_TRANSITION_ENTRY = "agent-browser-transition";
|
|
7
|
+
export const BROWSER_EVENT_VERSION = 1;
|
|
3
8
|
export const BROWSER_RESULT_TOOLS = new Set([
|
|
4
9
|
"agent_browser", "agent_browser_code", "agent_browser_action", "agent_browser_qa", "agent_browser_electron",
|
|
5
10
|
"agent_browser_source", "agent_browser_network_source",
|
|
6
11
|
]);
|
|
7
|
-
|
|
8
|
-
export
|
|
9
|
-
if (!isRecord(entry))
|
|
10
|
-
return undefined;
|
|
11
|
-
if (entry.type === "custom" && entry.customType === BROWSER_TRANSITION_ENTRY && isRecord(entry.data)) {
|
|
12
|
-
return isRecord(entry.data.details) && typeof entry.data.isError === "boolean" ? entry.data : undefined;
|
|
13
|
-
}
|
|
14
|
-
const message = entry.type === "message" && isRecord(entry.message) ? entry.message : undefined;
|
|
15
|
-
return message && typeof message.toolName === "string" && BROWSER_RESULT_TOOLS.has(message.toolName) ? message : undefined;
|
|
16
|
-
}
|
|
17
|
-
// Page data and rendered output are not state. Keep only fields consumed by the
|
|
18
|
-
// existing replay reducers; recording reservations retain their own native journal.
|
|
19
|
-
const TRANSITION_FIELDS = [
|
|
12
|
+
// These are small native lifecycle/ownership receipts, not rendered page data.
|
|
13
|
+
export const BROWSER_STATE_FIELDS = [
|
|
20
14
|
"args", "command", "subcommand", "sessionName", "namespace", "sessionMode", "usedImplicitSession",
|
|
15
|
+
"managedSessionSocketDir",
|
|
16
|
+
"managedSessionDaemon", "ownerSessionId",
|
|
21
17
|
"agentBrowserStarted", "resultCategory", "exitCode", "closeAllApplied", "attachedBrowserSession",
|
|
22
|
-
"sessionTabTarget", "sessionTabTargetUnknown", "sessionTabReopenPending", "refSnapshot", "refSnapshotInvalidation",
|
|
23
18
|
"readConfirmation", "compatibilityWorkaround", "managedSessionHeadedAutosaveDisabled", "managedSessionHeadedAutosaveInterval",
|
|
24
|
-
"managedSessionOutcome", "managedSessionCwd", "managedSessionRestoreDisabled",
|
|
19
|
+
"managedSessionOutcome", "managedSessionCwd", "managedSessionRestoreDisabled",
|
|
20
|
+
"nativeSucceeded",
|
|
25
21
|
];
|
|
26
|
-
export function
|
|
27
|
-
const state = Object.fromEntries(
|
|
22
|
+
export function browserStateEffects(details) {
|
|
23
|
+
const state = Object.fromEntries(BROWSER_STATE_FIELDS.filter(key => details[key] !== undefined).map(key => [key, details[key]]));
|
|
28
24
|
if (Array.isArray(details.batchSteps))
|
|
29
25
|
state.batchSteps = details.batchSteps.filter(isRecord).map(step => ({
|
|
30
|
-
command: step.command, success: step.success, lifecycle: step
|
|
26
|
+
command: step.command, success: step.success, lifecycle: extractAgentBrowserLifecycle(step),
|
|
31
27
|
}));
|
|
32
|
-
|
|
28
|
+
if (isRecord(details.electron) && (details.electron.launch !== undefined || details.electron.cleanup !== undefined)) {
|
|
29
|
+
state.electron = { launch: details.electron.launch, cleanup: details.electron.cleanup };
|
|
30
|
+
}
|
|
31
|
+
return state;
|
|
32
|
+
}
|
|
33
|
+
export function getBrowserRecord(entry) {
|
|
34
|
+
if (!isRecord(entry) || !isRecord(entry.data))
|
|
35
|
+
return undefined;
|
|
36
|
+
if (entry.type !== "message" && !(entry.type === "custom" && entry.customType === BROWSER_TRANSITION_ENTRY))
|
|
37
|
+
return undefined;
|
|
38
|
+
const event = entry.data.event;
|
|
39
|
+
if (!isRecord(event) || event.version !== BROWSER_EVENT_VERSION)
|
|
40
|
+
return undefined;
|
|
41
|
+
if (!["begin", "finish", "state"].includes(String(event.phase)) || typeof event.operationId !== "string"
|
|
42
|
+
|| typeof event.toolCallId !== "string" || !Number.isSafeInteger(event.commandIndex) || event.commandIndex < 0 || typeof event.isError !== "boolean" || !isRecord(event.state)) {
|
|
43
|
+
throw new Error("Invalid browser event; inspect or convert the retained session before browser work.");
|
|
44
|
+
}
|
|
45
|
+
if (event.pages !== undefined && (!Array.isArray(event.pages) || !event.pages.every(page => isRecord(page)
|
|
46
|
+
&& typeof page.key === "string" && isRecord(page.refs) && ["reuse", "replace", "invalidate", "unknown"].includes(String(page.refs.kind))
|
|
47
|
+
&& (!["reuse", "replace"].includes(String(page.refs.kind)) || typeof page.refs.snapshotId === "string" && page.refs.snapshotId.length > 0)
|
|
48
|
+
&& (page.target === undefined || isRecord(page.target) && normalizeSessionTabTarget(page.target) !== undefined)
|
|
49
|
+
&& (page.unknown === undefined || page.unknown === true) && (page.clear === undefined || page.clear === true)
|
|
50
|
+
&& (page.reopenPending === undefined || typeof page.reopenPending === "boolean")
|
|
51
|
+
&& (page.pinningReason === undefined || ["restore", "drift"].includes(String(page.pinningReason)))
|
|
52
|
+
&& (page.refs.invalidation === undefined || isRecord(page.refs.invalidation) && ["page-transition", "no-active-page"].includes(String(page.refs.invalidation.reason)) && typeof page.refs.invalidation.summary === "string")))) {
|
|
53
|
+
throw new Error("Invalid browser page disposition; refs cannot be restored.");
|
|
54
|
+
}
|
|
55
|
+
if (event.artifacts !== undefined) {
|
|
56
|
+
const changes = event.artifacts;
|
|
57
|
+
if (!isRecord(changes) || !Array.isArray(changes.removals) || !changes.removals.every(key => typeof key === "string")
|
|
58
|
+
|| !isSessionArtifactManifest({ version: 1, entries: changes.upserts, maxEntries: changes.maxEntries, updatedAtMs: changes.updatedAtMs, liveCount: 0, evictedCount: 0 })) {
|
|
59
|
+
throw new Error("Invalid browser artifact changes; inspect the retained journal.");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return entry.data;
|
|
63
|
+
}
|
|
64
|
+
/** Only canonical receipts participate in live replay. Legacy shapes belong to the offline converter. */
|
|
65
|
+
export function getBrowserResultMessage(entry) {
|
|
66
|
+
const record = getBrowserRecord(entry);
|
|
67
|
+
return record ? { details: record.event.state, isError: typeof record.event.state.nativeSucceeded === "boolean" ? !record.event.state.nativeSucceeded : record.event.isError } : undefined;
|
|
68
|
+
}
|
|
69
|
+
export function snapshotDefinition(snapshot) {
|
|
70
|
+
if (!snapshot.snapshotId)
|
|
71
|
+
throw new Error("A snapshot capture must have an identity before publication.");
|
|
72
|
+
return {
|
|
73
|
+
id: snapshot.snapshotId,
|
|
74
|
+
refs: Object.fromEntries(snapshot.refIds.map(id => [id, snapshot.refs?.[id] ?? {}])),
|
|
75
|
+
target: snapshot.target,
|
|
76
|
+
generation: snapshot.generation,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export function snapshotFromDefinition(definition) {
|
|
80
|
+
if (!definition || typeof definition.id !== "string" || definition.id.length === 0 || !isRecord(definition.refs)
|
|
81
|
+
|| definition.target !== undefined && normalizeSessionTabTarget(definition.target) === undefined
|
|
82
|
+
|| definition.generation !== undefined && typeof definition.generation !== "string"
|
|
83
|
+
|| !Object.entries(definition.refs).every(([id, ref]) => /^e\d+$/.test(id) && isRecord(ref)
|
|
84
|
+
&& (ref.role === undefined || typeof ref.role === "string") && (ref.name === undefined || typeof ref.name === "string")
|
|
85
|
+
&& (ref.isEditable === undefined || typeof ref.isEditable === "boolean") && (ref.isContentEditable === undefined || typeof ref.isContentEditable === "boolean"))) {
|
|
86
|
+
throw new Error("Missing or invalid browser snapshot definition; take a new complete snapshot before using refs.");
|
|
87
|
+
}
|
|
88
|
+
const refs = Object.fromEntries(Object.entries(definition.refs).flatMap(([id, ref]) => typeof ref.name === "string" && typeof ref.role === "string" ? [[id, ref]] : []));
|
|
89
|
+
return {
|
|
90
|
+
snapshotId: definition.id,
|
|
91
|
+
refIds: Object.keys(definition.refs),
|
|
92
|
+
...(Object.keys(refs).length ? { refs } : {}),
|
|
93
|
+
target: definition.target,
|
|
94
|
+
generation: definition.generation,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
export function pageChange(key, page, previousSnapshotId) {
|
|
98
|
+
return {
|
|
99
|
+
key, target: page.tabTarget, unknown: page.tabTargetUnknown, reopenPending: page.tabReopenPending, pinningReason: page.pinningReason,
|
|
100
|
+
refs: page.tabTargetUnknown ? { kind: "unknown", invalidation: page.refSnapshotInvalidation }
|
|
101
|
+
: page.refSnapshot && !targetsMatch(page.refSnapshot.target, page.tabTarget) ? { kind: "invalidate", invalidation: buildPageTransitionRefSnapshotInvalidation(`The saved refs came from a snapshot for ${page.refSnapshot.target?.url}; the current session target is ${page.tabTarget?.url}. Take a fresh snapshot before using page-scoped refs.`) }
|
|
102
|
+
: page.refSnapshot?.snapshotId ? { kind: page.refSnapshot.snapshotId === previousSnapshotId ? "reuse" : "replace", snapshotId: page.refSnapshot.snapshotId }
|
|
103
|
+
: { kind: "invalidate", invalidation: page.refSnapshotInvalidation },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
export function artifactChanges(previous, next) {
|
|
107
|
+
if (previous === next)
|
|
108
|
+
return undefined;
|
|
109
|
+
const before = new Map((previous?.entries ?? []).map(row => [getSessionArtifactManifestEntryKey(row), row]));
|
|
110
|
+
const after = new Map((next?.entries ?? []).map(row => [getSessionArtifactManifestEntryKey(row), row]));
|
|
111
|
+
const upserts = [...after].filter(([key, row]) => JSON.stringify(before.get(key)) !== JSON.stringify(row)).map(([, row]) => redactSensitiveValue(row));
|
|
112
|
+
const removals = [...before.keys()].filter(key => !after.has(key));
|
|
113
|
+
return upserts.length || removals.length ? { upserts, removals, maxEntries: next?.maxEntries ?? previous?.maxEntries ?? 100, updatedAtMs: next?.updatedAtMs ?? Date.now() } : undefined;
|
|
114
|
+
}
|
|
115
|
+
export function applyArtifactChanges(previous, changes) {
|
|
116
|
+
if (!changes)
|
|
117
|
+
return previous;
|
|
118
|
+
const rows = new Map((previous?.entries ?? []).map(row => [getSessionArtifactManifestEntryKey(row), row]));
|
|
119
|
+
for (const key of changes.removals)
|
|
120
|
+
rows.delete(key);
|
|
121
|
+
for (const row of changes.upserts)
|
|
122
|
+
rows.set(getSessionArtifactManifestEntryKey(row), row);
|
|
123
|
+
const entries = [...rows.values()].sort((a, b) => (b.evictedAtMs ?? b.createdAtMs) - (a.evictedAtMs ?? a.createdAtMs)
|
|
124
|
+
|| Number(isPendingRecordingArtifact(b)) - Number(isPendingRecordingArtifact(a)) || a.path.localeCompare(b.path)).slice(0, changes.maxEntries);
|
|
125
|
+
const manifest = { version: 1, entries, maxEntries: changes.maxEntries, updatedAtMs: changes.updatedAtMs,
|
|
126
|
+
liveCount: entries.filter(row => row.retentionState === "live").length, evictedCount: entries.filter(row => row.retentionState === "evicted").length };
|
|
127
|
+
if (!isSessionArtifactManifest(manifest))
|
|
128
|
+
throw new Error("Invalid browser artifact changes; inspect the retained journal.");
|
|
129
|
+
return entries.length ? manifest : undefined;
|
|
130
|
+
}
|
|
131
|
+
export function appendBrowserTransition(pi, record) {
|
|
132
|
+
pi.appendEntry(BROWSER_TRANSITION_ENTRY, record);
|
|
33
133
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isRecord } from "../parsing.js";
|
|
2
2
|
import { summarizeNetworkFailures } from "../results/network.js";
|
|
3
|
+
import { truncateText } from "../results/text.js";
|
|
3
4
|
import { getBatchResultItems, getCommandNameFromBatchItem } from "./shared.js";
|
|
4
5
|
import { AGENT_BROWSER_QA_LOAD_STATES, } from "./types.js";
|
|
5
6
|
function describeQaChecksRun(checks) {
|
|
@@ -61,15 +62,19 @@ export function buildQaCompactPassText(options) {
|
|
|
61
62
|
return lines.join("\n");
|
|
62
63
|
}
|
|
63
64
|
export function buildQaCompactFailureText(options) {
|
|
64
|
-
const lines = [options.
|
|
65
|
+
const lines = [options.causalError
|
|
66
|
+
? truncateText(options.causalError.replace(/\s+/g, " ").trim(), 700)
|
|
67
|
+
: options.qaPreset.summary];
|
|
65
68
|
const pageParts = [options.page?.title, options.page?.url].filter((part) => typeof part === "string" && part.length > 0);
|
|
66
69
|
if (pageParts.length > 0)
|
|
67
70
|
lines.push(`Page: ${pageParts.join(" — ")}`);
|
|
68
71
|
if (options.qaPreset.failedChecks.length > 0)
|
|
69
72
|
lines.push("Failed checks:", ...options.qaPreset.failedChecks.map((failure) => `- ${failure}`));
|
|
73
|
+
if (options.qaPreset.notRunChecks.length > 0)
|
|
74
|
+
lines.push("Not run:", ...options.qaPreset.notRunChecks.map((check) => `- ${check}`));
|
|
70
75
|
if (options.qaPreset.warnings.length > 0)
|
|
71
76
|
lines.push("Warnings:", ...options.qaPreset.warnings.map((warning) => `- ${warning}`));
|
|
72
|
-
lines.push(`
|
|
77
|
+
lines.push(`Execution: ${options.executedStepCount ?? "unknown"}/${options.plannedStepCount} batch steps`);
|
|
73
78
|
lines.push("Full diagnostic matrix: see details.qaPreset and details.batchSteps.");
|
|
74
79
|
return lines.join("\n");
|
|
75
80
|
}
|
|
@@ -182,11 +187,45 @@ export function analyzeQaPresetTimeout(compiled) {
|
|
|
182
187
|
const failedChecks = compiled.checks.expectedText.map((text) => `expected text was not verified before timeout: ${formatQaExpectedTextPreview(text)}`);
|
|
183
188
|
return {
|
|
184
189
|
failedChecks,
|
|
190
|
+
notRunChecks: [],
|
|
185
191
|
passed: false,
|
|
186
192
|
summary: `QA preset failed: ${failedChecks.join("; ")}.`,
|
|
187
193
|
warnings: ["The wrapper timed out before expected-text evidence could be verified; inspect timeoutPartialProgress and retry with a narrower readiness condition if the page was still loading."],
|
|
188
194
|
};
|
|
189
195
|
}
|
|
196
|
+
function describeNotRunQaChecks(compiled, executedStepCount) {
|
|
197
|
+
const checks = [];
|
|
198
|
+
let expectedTextIndex = 0;
|
|
199
|
+
for (const [index, step] of compiled.steps.entries()) {
|
|
200
|
+
let check;
|
|
201
|
+
if (step.action === "assertText") {
|
|
202
|
+
const expected = compiled.checks.expectedText[expectedTextIndex++];
|
|
203
|
+
if (expected)
|
|
204
|
+
check = `expected text: ${formatQaExpectedTextPreview(expected)}`;
|
|
205
|
+
}
|
|
206
|
+
else if (step.args[0] === "wait" && step.args[1] === "--load") {
|
|
207
|
+
check = `load state: ${step.args[2]}`;
|
|
208
|
+
}
|
|
209
|
+
else if (compiled.checks.expectedSelector && step.args[0] === "wait" && step.args[1] === compiled.checks.expectedSelector) {
|
|
210
|
+
check = `expected selector: ${formatQaExpectedTextPreview(compiled.checks.expectedSelector)}`;
|
|
211
|
+
}
|
|
212
|
+
else if (step.args[0] === "network" && !step.args.includes("--clear")) {
|
|
213
|
+
check = "network diagnostics";
|
|
214
|
+
}
|
|
215
|
+
else if (step.args[0] === "console" && !step.args.includes("--clear")) {
|
|
216
|
+
check = "console diagnostics";
|
|
217
|
+
}
|
|
218
|
+
else if (step.args[0] === "errors" && !step.args.includes("--clear") && step.generatedFrom !== "qa.errorBaselineAfterClear") {
|
|
219
|
+
check = "page error diagnostics";
|
|
220
|
+
}
|
|
221
|
+
else if (step.action === "screenshot") {
|
|
222
|
+
check = `screenshot: ${formatQaExpectedTextPreview(step.args[1] ?? "")}`;
|
|
223
|
+
}
|
|
224
|
+
if (index >= executedStepCount && check)
|
|
225
|
+
checks.push(check);
|
|
226
|
+
}
|
|
227
|
+
return checks;
|
|
228
|
+
}
|
|
190
229
|
export function analyzeQaPresetResults(data, compiled) {
|
|
191
230
|
const items = getBatchResultItems(data);
|
|
192
231
|
if (items.length === 0)
|
|
@@ -238,6 +277,8 @@ export function analyzeQaPresetResults(data, compiled) {
|
|
|
238
277
|
const expected = compiled.checks.expectedText[expectedTextIndex++];
|
|
239
278
|
if (!expected)
|
|
240
279
|
return;
|
|
280
|
+
if (!items[index])
|
|
281
|
+
return;
|
|
241
282
|
const visibleTextPassed = qaVisibleTextWaitPassed(items[index], step);
|
|
242
283
|
if (visibleTextPassed === true)
|
|
243
284
|
return;
|
|
@@ -246,10 +287,16 @@ export function analyzeQaPresetResults(data, compiled) {
|
|
|
246
287
|
failedChecks.push(`expected text not found: ${formatQaExpectedTextPreview(expected)}`);
|
|
247
288
|
});
|
|
248
289
|
}
|
|
290
|
+
const stoppedEarly = compiled && items.length < compiled.steps.length;
|
|
291
|
+
const failedFast = stoppedEarly && items.at(-1)?.success === false;
|
|
292
|
+
if (stoppedEarly && !failedFast)
|
|
293
|
+
failedChecks.push("QA execution could not be verified (incomplete batch results)");
|
|
249
294
|
const uniqueFailures = [...new Set(failedChecks)];
|
|
250
295
|
const uniqueWarnings = [...new Set(warnings)];
|
|
296
|
+
const notRunChecks = compiled && failedFast ? describeNotRunQaChecks(compiled, items.length) : [];
|
|
251
297
|
return {
|
|
252
298
|
failedChecks: uniqueFailures,
|
|
299
|
+
notRunChecks,
|
|
253
300
|
passed: uniqueFailures.length === 0,
|
|
254
301
|
summary: uniqueFailures.length === 0
|
|
255
302
|
? uniqueWarnings.length === 0 ? "QA preset passed." : `QA preset passed with warnings: ${uniqueWarnings.join("; ")}.`
|