@kata-sh/cli 0.1.0 → 0.1.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/LICENSE +21 -0
- package/README.md +156 -0
- package/dist/app-paths.d.ts +4 -0
- package/dist/app-paths.js +6 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +56 -0
- package/dist/loader.d.ts +2 -0
- package/dist/loader.js +95 -0
- package/dist/resource-loader.d.ts +18 -0
- package/dist/resource-loader.js +50 -0
- package/dist/wizard.d.ts +15 -0
- package/dist/wizard.js +159 -0
- package/package.json +50 -21
- package/pkg/dist/modes/interactive/theme/dark.json +85 -0
- package/pkg/dist/modes/interactive/theme/light.json +84 -0
- package/pkg/dist/modes/interactive/theme/theme-schema.json +335 -0
- package/pkg/dist/modes/interactive/theme/theme.d.ts +78 -0
- package/pkg/dist/modes/interactive/theme/theme.d.ts.map +1 -0
- package/pkg/dist/modes/interactive/theme/theme.js +949 -0
- package/pkg/dist/modes/interactive/theme/theme.js.map +1 -0
- package/pkg/package.json +8 -0
- package/scripts/postinstall.js +45 -0
- package/src/resources/AGENTS.md +108 -0
- package/src/resources/KATA-WORKFLOW.md +661 -0
- package/src/resources/agents/researcher.md +29 -0
- package/src/resources/agents/scout.md +56 -0
- package/src/resources/agents/worker.md +31 -0
- package/src/resources/extensions/ask-user-questions.ts +200 -0
- package/src/resources/extensions/bg-shell/index.ts +2758 -0
- package/src/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md +1277 -0
- package/src/resources/extensions/browser-tools/core.js +1057 -0
- package/src/resources/extensions/browser-tools/index.ts +4916 -0
- package/src/resources/extensions/browser-tools/package.json +20 -0
- package/src/resources/extensions/context7/index.ts +428 -0
- package/src/resources/extensions/context7/package.json +11 -0
- package/src/resources/extensions/get-secrets-from-user.ts +352 -0
- package/src/resources/extensions/github/formatters.ts +207 -0
- package/src/resources/extensions/github/gh-api.ts +537 -0
- package/src/resources/extensions/github/index.ts +778 -0
- package/src/resources/extensions/kata/activity-log.ts +88 -0
- package/src/resources/extensions/kata/auto.ts +2786 -0
- package/src/resources/extensions/kata/commands.ts +355 -0
- package/src/resources/extensions/kata/crash-recovery.ts +85 -0
- package/src/resources/extensions/kata/dashboard-overlay.ts +516 -0
- package/src/resources/extensions/kata/docs/preferences-reference.md +103 -0
- package/src/resources/extensions/kata/doctor.ts +683 -0
- package/src/resources/extensions/kata/files.ts +730 -0
- package/src/resources/extensions/kata/gitignore.ts +165 -0
- package/src/resources/extensions/kata/guided-flow.ts +976 -0
- package/src/resources/extensions/kata/index.ts +556 -0
- package/src/resources/extensions/kata/metrics.ts +397 -0
- package/src/resources/extensions/kata/observability-validator.ts +408 -0
- package/src/resources/extensions/kata/package.json +11 -0
- package/src/resources/extensions/kata/paths.ts +346 -0
- package/src/resources/extensions/kata/preferences.ts +695 -0
- package/src/resources/extensions/kata/prompt-loader.ts +50 -0
- package/src/resources/extensions/kata/prompts/complete-milestone.md +25 -0
- package/src/resources/extensions/kata/prompts/complete-slice.md +27 -0
- package/src/resources/extensions/kata/prompts/discuss.md +151 -0
- package/src/resources/extensions/kata/prompts/doctor-heal.md +29 -0
- package/src/resources/extensions/kata/prompts/execute-task.md +64 -0
- package/src/resources/extensions/kata/prompts/guided-complete-slice.md +1 -0
- package/src/resources/extensions/kata/prompts/guided-discuss-milestone.md +3 -0
- package/src/resources/extensions/kata/prompts/guided-discuss-slice.md +59 -0
- package/src/resources/extensions/kata/prompts/guided-execute-task.md +1 -0
- package/src/resources/extensions/kata/prompts/guided-plan-milestone.md +23 -0
- package/src/resources/extensions/kata/prompts/guided-plan-slice.md +1 -0
- package/src/resources/extensions/kata/prompts/guided-research-slice.md +11 -0
- package/src/resources/extensions/kata/prompts/guided-resume-task.md +1 -0
- package/src/resources/extensions/kata/prompts/plan-milestone.md +47 -0
- package/src/resources/extensions/kata/prompts/plan-slice.md +63 -0
- package/src/resources/extensions/kata/prompts/queue.md +85 -0
- package/src/resources/extensions/kata/prompts/reassess-roadmap.md +48 -0
- package/src/resources/extensions/kata/prompts/replan-slice.md +39 -0
- package/src/resources/extensions/kata/prompts/research-milestone.md +37 -0
- package/src/resources/extensions/kata/prompts/research-slice.md +28 -0
- package/src/resources/extensions/kata/prompts/run-uat.md +109 -0
- package/src/resources/extensions/kata/prompts/system.md +341 -0
- package/src/resources/extensions/kata/session-forensics.ts +550 -0
- package/src/resources/extensions/kata/skill-discovery.ts +137 -0
- package/src/resources/extensions/kata/state.ts +509 -0
- package/src/resources/extensions/kata/templates/context.md +76 -0
- package/src/resources/extensions/kata/templates/decisions.md +8 -0
- package/src/resources/extensions/kata/templates/milestone-summary.md +73 -0
- package/src/resources/extensions/kata/templates/plan.md +133 -0
- package/src/resources/extensions/kata/templates/preferences.md +15 -0
- package/src/resources/extensions/kata/templates/project.md +31 -0
- package/src/resources/extensions/kata/templates/reassessment.md +28 -0
- package/src/resources/extensions/kata/templates/requirements.md +81 -0
- package/src/resources/extensions/kata/templates/research.md +46 -0
- package/src/resources/extensions/kata/templates/roadmap.md +118 -0
- package/src/resources/extensions/kata/templates/slice-context.md +58 -0
- package/src/resources/extensions/kata/templates/slice-summary.md +99 -0
- package/src/resources/extensions/kata/templates/state.md +19 -0
- package/src/resources/extensions/kata/templates/task-plan.md +52 -0
- package/src/resources/extensions/kata/templates/task-summary.md +57 -0
- package/src/resources/extensions/kata/templates/uat.md +54 -0
- package/src/resources/extensions/kata/tests/activity-log-prune.test.ts +327 -0
- package/src/resources/extensions/kata/tests/auto-preflight.test.ts +97 -0
- package/src/resources/extensions/kata/tests/auto-supervisor.test.mjs +53 -0
- package/src/resources/extensions/kata/tests/complete-milestone.test.ts +317 -0
- package/src/resources/extensions/kata/tests/cost-projection.test.ts +160 -0
- package/src/resources/extensions/kata/tests/derive-state-deps.test.ts +477 -0
- package/src/resources/extensions/kata/tests/derive-state.test.ts +1013 -0
- package/src/resources/extensions/kata/tests/doctor.test.ts +718 -0
- package/src/resources/extensions/kata/tests/idle-recovery.test.ts +490 -0
- package/src/resources/extensions/kata/tests/metrics-io.test.ts +254 -0
- package/src/resources/extensions/kata/tests/metrics.test.ts +217 -0
- package/src/resources/extensions/kata/tests/must-have-parser.test.ts +309 -0
- package/src/resources/extensions/kata/tests/parsers.test.ts +1257 -0
- package/src/resources/extensions/kata/tests/plan-milestone.test.ts +185 -0
- package/src/resources/extensions/kata/tests/plan-quality-validator.test.ts +386 -0
- package/src/resources/extensions/kata/tests/reassess-prompt.test.ts +208 -0
- package/src/resources/extensions/kata/tests/replan-slice.test.ts +686 -0
- package/src/resources/extensions/kata/tests/requirements.test.ts +151 -0
- package/src/resources/extensions/kata/tests/resolve-ts-hooks.mjs +17 -0
- package/src/resources/extensions/kata/tests/resolve-ts.mjs +11 -0
- package/src/resources/extensions/kata/tests/run-uat.test.ts +383 -0
- package/src/resources/extensions/kata/tests/unit-runtime.test.ts +388 -0
- package/src/resources/extensions/kata/tests/workspace-index.test.ts +118 -0
- package/src/resources/extensions/kata/tests/worktree.test.ts +222 -0
- package/src/resources/extensions/kata/types.ts +159 -0
- package/src/resources/extensions/kata/unit-runtime.ts +163 -0
- package/src/resources/extensions/kata/workspace-index.ts +203 -0
- package/src/resources/extensions/kata/worktree.ts +182 -0
- package/src/resources/extensions/mac-tools/index.ts +852 -0
- package/src/resources/extensions/mac-tools/swift-cli/Package.swift +22 -0
- package/src/resources/extensions/mac-tools/swift-cli/Sources/main.swift +1318 -0
- package/src/resources/extensions/search-the-web/cache.ts +78 -0
- package/src/resources/extensions/search-the-web/format.ts +258 -0
- package/src/resources/extensions/search-the-web/http.ts +238 -0
- package/src/resources/extensions/search-the-web/index.ts +68 -0
- package/src/resources/extensions/search-the-web/tool-fetch-page.ts +519 -0
- package/src/resources/extensions/search-the-web/tool-llm-context.ts +404 -0
- package/src/resources/extensions/search-the-web/tool-search.ts +503 -0
- package/src/resources/extensions/search-the-web/url-utils.ts +91 -0
- package/src/resources/extensions/shared/confirm-ui.ts +126 -0
- package/src/resources/extensions/shared/interview-ui.ts +822 -0
- package/src/resources/extensions/shared/next-action-ui.ts +235 -0
- package/src/resources/extensions/shared/progress-widget.ts +282 -0
- package/src/resources/extensions/shared/thinking-widget.ts +107 -0
- package/src/resources/extensions/shared/ui.ts +400 -0
- package/src/resources/extensions/shared/wizard-ui.ts +551 -0
- package/src/resources/extensions/slash-commands/audit.ts +92 -0
- package/src/resources/extensions/slash-commands/create-extension.ts +375 -0
- package/src/resources/extensions/slash-commands/create-slash-command.ts +280 -0
- package/src/resources/extensions/slash-commands/index.ts +12 -0
- package/src/resources/extensions/slash-commands/kata-run.ts +34 -0
- package/src/resources/extensions/subagent/agents.ts +126 -0
- package/src/resources/extensions/subagent/index.ts +1293 -0
- package/src/resources/skills/debug-like-expert/SKILL.md +231 -0
- package/src/resources/skills/debug-like-expert/references/debugging-mindset.md +253 -0
- package/src/resources/skills/debug-like-expert/references/hypothesis-testing.md +373 -0
- package/src/resources/skills/debug-like-expert/references/investigation-techniques.md +337 -0
- package/src/resources/skills/debug-like-expert/references/verification-patterns.md +425 -0
- package/src/resources/skills/debug-like-expert/references/when-to-research.md +361 -0
- package/src/resources/skills/frontend-design/SKILL.md +45 -0
- package/src/resources/skills/swiftui/SKILL.md +208 -0
- package/src/resources/skills/swiftui/references/animations.md +921 -0
- package/src/resources/skills/swiftui/references/architecture.md +1561 -0
- package/src/resources/skills/swiftui/references/layout-system.md +1186 -0
- package/src/resources/skills/swiftui/references/navigation.md +1492 -0
- package/src/resources/skills/swiftui/references/networking-async.md +214 -0
- package/src/resources/skills/swiftui/references/performance.md +1706 -0
- package/src/resources/skills/swiftui/references/platform-integration.md +204 -0
- package/src/resources/skills/swiftui/references/state-management.md +1443 -0
- package/src/resources/skills/swiftui/references/swiftdata.md +297 -0
- package/src/resources/skills/swiftui/references/testing-debugging.md +247 -0
- package/src/resources/skills/swiftui/references/uikit-appkit-interop.md +218 -0
- package/src/resources/skills/swiftui/workflows/add-feature.md +191 -0
- package/src/resources/skills/swiftui/workflows/build-new-app.md +311 -0
- package/src/resources/skills/swiftui/workflows/debug-swiftui.md +192 -0
- package/src/resources/skills/swiftui/workflows/optimize-performance.md +197 -0
- package/src/resources/skills/swiftui/workflows/ship-app.md +203 -0
- package/src/resources/skills/swiftui/workflows/write-tests.md +235 -0
- package/dist/commands/task.d.ts +0 -9
- package/dist/commands/task.d.ts.map +0 -1
- package/dist/commands/task.js +0 -129
- package/dist/commands/task.js.map +0 -1
- package/dist/commands/task.test.d.ts +0 -2
- package/dist/commands/task.test.d.ts.map +0 -1
- package/dist/commands/task.test.js +0 -169
- package/dist/commands/task.test.js.map +0 -1
- package/dist/e2e/task-e2e.test.d.ts +0 -2
- package/dist/e2e/task-e2e.test.d.ts.map +0 -1
- package/dist/e2e/task-e2e.test.js +0 -173
- package/dist/e2e/task-e2e.test.js.map +0 -1
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -93
- package/dist/index.js.map +0 -1
- package/dist/slug.d.ts +0 -2
- package/dist/slug.d.ts.map +0 -1
- package/dist/slug.js +0 -12
- package/dist/slug.js.map +0 -1
- package/dist/slug.test.d.ts +0 -2
- package/dist/slug.test.d.ts.map +0 -1
- package/dist/slug.test.js +0 -32
- package/dist/slug.test.js.map +0 -1
|
@@ -0,0 +1,1057 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-neutral helper logic for browser-tools.
|
|
3
|
+
*
|
|
4
|
+
* Kept free of pi-specific imports so it can be exercised with node:test.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export function createActionTimeline(limit = 60) {
|
|
8
|
+
return {
|
|
9
|
+
limit,
|
|
10
|
+
nextId: 1,
|
|
11
|
+
entries: [],
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function beginAction(timeline, partial) {
|
|
16
|
+
const entry = {
|
|
17
|
+
id: timeline.nextId++,
|
|
18
|
+
tool: partial.tool,
|
|
19
|
+
paramsSummary: partial.paramsSummary ?? "",
|
|
20
|
+
startedAt: partial.startedAt ?? Date.now(),
|
|
21
|
+
finishedAt: null,
|
|
22
|
+
status: "running",
|
|
23
|
+
beforeUrl: partial.beforeUrl ?? "",
|
|
24
|
+
afterUrl: partial.afterUrl ?? "",
|
|
25
|
+
verificationSummary: partial.verificationSummary,
|
|
26
|
+
warningSummary: partial.warningSummary,
|
|
27
|
+
diffSummary: partial.diffSummary,
|
|
28
|
+
changed: partial.changed,
|
|
29
|
+
error: partial.error,
|
|
30
|
+
};
|
|
31
|
+
timeline.entries.push(entry);
|
|
32
|
+
if (timeline.entries.length > timeline.limit) {
|
|
33
|
+
timeline.entries.splice(0, timeline.entries.length - timeline.limit);
|
|
34
|
+
}
|
|
35
|
+
return entry;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function finishAction(timeline, actionId, updates = {}) {
|
|
39
|
+
const entry = timeline.entries.find((item) => item.id === actionId);
|
|
40
|
+
if (!entry) return null;
|
|
41
|
+
Object.assign(entry, updates, {
|
|
42
|
+
finishedAt: updates.finishedAt ?? Date.now(),
|
|
43
|
+
status: updates.status ?? entry.status ?? "success",
|
|
44
|
+
afterUrl: updates.afterUrl ?? entry.afterUrl ?? "",
|
|
45
|
+
verificationSummary: updates.verificationSummary ?? entry.verificationSummary,
|
|
46
|
+
warningSummary: updates.warningSummary ?? entry.warningSummary,
|
|
47
|
+
diffSummary: updates.diffSummary ?? entry.diffSummary,
|
|
48
|
+
changed: updates.changed ?? entry.changed,
|
|
49
|
+
error: updates.error ?? entry.error,
|
|
50
|
+
});
|
|
51
|
+
return entry;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function findAction(timeline, actionId) {
|
|
55
|
+
return timeline.entries.find((item) => item.id === actionId) ?? null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function toActionParamsSummary(params) {
|
|
59
|
+
if (!params || typeof params !== "object") return "";
|
|
60
|
+
const entries = [];
|
|
61
|
+
for (const [key, value] of Object.entries(params)) {
|
|
62
|
+
if (value === undefined || value === null) continue;
|
|
63
|
+
if (typeof value === "string") {
|
|
64
|
+
entries.push(`${key}=${JSON.stringify(value.length > 60 ? `${value.slice(0, 57)}...` : value)}`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (Array.isArray(value)) {
|
|
68
|
+
entries.push(`${key}=[${value.length}]`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (typeof value === "object") {
|
|
72
|
+
entries.push(`${key}={...}`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
entries.push(`${key}=${String(value)}`);
|
|
76
|
+
}
|
|
77
|
+
return entries.slice(0, 6).join(", ");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function diffCompactStates(before, after) {
|
|
81
|
+
const changes = [];
|
|
82
|
+
if (!before || !after) {
|
|
83
|
+
return {
|
|
84
|
+
changed: false,
|
|
85
|
+
changes: [],
|
|
86
|
+
summary: "Diff unavailable",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (before.url !== after.url) {
|
|
91
|
+
changes.push({ type: "url", before: before.url, after: after.url });
|
|
92
|
+
}
|
|
93
|
+
if (before.title !== after.title) {
|
|
94
|
+
changes.push({ type: "title", before: before.title, after: after.title });
|
|
95
|
+
}
|
|
96
|
+
if (before.focus !== after.focus) {
|
|
97
|
+
changes.push({ type: "focus", before: before.focus, after: after.focus });
|
|
98
|
+
}
|
|
99
|
+
if ((before.dialog?.count ?? 0) !== (after.dialog?.count ?? 0)) {
|
|
100
|
+
changes.push({
|
|
101
|
+
type: "dialog_count",
|
|
102
|
+
before: before.dialog?.count ?? 0,
|
|
103
|
+
after: after.dialog?.count ?? 0,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
if ((before.dialog?.title ?? "") !== (after.dialog?.title ?? "")) {
|
|
107
|
+
changes.push({
|
|
108
|
+
type: "dialog_title",
|
|
109
|
+
before: before.dialog?.title ?? "",
|
|
110
|
+
after: after.dialog?.title ?? "",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const key of ["landmarks", "buttons", "links", "inputs"]) {
|
|
115
|
+
const beforeValue = before.counts?.[key] ?? 0;
|
|
116
|
+
const afterValue = after.counts?.[key] ?? 0;
|
|
117
|
+
if (beforeValue !== afterValue) {
|
|
118
|
+
changes.push({ type: `count:${key}`, before: beforeValue, after: afterValue });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const beforeHeadings = JSON.stringify(before.headings ?? []);
|
|
123
|
+
const afterHeadings = JSON.stringify(after.headings ?? []);
|
|
124
|
+
if (beforeHeadings !== afterHeadings) {
|
|
125
|
+
changes.push({
|
|
126
|
+
type: "headings",
|
|
127
|
+
before: before.headings ?? [],
|
|
128
|
+
after: after.headings ?? [],
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const beforeBody = before.bodyText ?? "";
|
|
133
|
+
const afterBody = after.bodyText ?? "";
|
|
134
|
+
if (beforeBody !== afterBody) {
|
|
135
|
+
changes.push({
|
|
136
|
+
type: "body_text",
|
|
137
|
+
before: beforeBody.slice(0, 120),
|
|
138
|
+
after: afterBody.slice(0, 120),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const changed = changes.length > 0;
|
|
143
|
+
const summary = changed
|
|
144
|
+
? changes
|
|
145
|
+
.slice(0, 4)
|
|
146
|
+
.map((change) => {
|
|
147
|
+
if (change.type === "url") return `URL changed to ${change.after}`;
|
|
148
|
+
if (change.type === "title") return `title changed to ${change.after}`;
|
|
149
|
+
if (change.type === "focus") return `focus changed`;
|
|
150
|
+
if (change.type === "dialog_count") return `dialog count ${change.before}→${change.after}`;
|
|
151
|
+
if (change.type.startsWith("count:")) return `${change.type.slice(6)} ${change.before}→${change.after}`;
|
|
152
|
+
if (change.type === "headings") return "headings changed";
|
|
153
|
+
if (change.type === "body_text") return "visible text changed";
|
|
154
|
+
return `${change.type} changed`;
|
|
155
|
+
})
|
|
156
|
+
.join("; ")
|
|
157
|
+
: "No meaningful browser-state change detected";
|
|
158
|
+
|
|
159
|
+
return { changed, changes, summary };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function normalizeString(value) {
|
|
163
|
+
return String(value ?? "").trim();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function includesNeedle(haystack, needle) {
|
|
167
|
+
return normalizeString(haystack).toLowerCase().includes(normalizeString(needle).toLowerCase());
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Threshold parsing for count-based assertions
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Parse a threshold expression like ">=3", "==0", "<5", or bare "3" (defaults to ">=").
|
|
176
|
+
* @param {string} value
|
|
177
|
+
* @returns {{ op: string, n: number } | null} — null if malformed
|
|
178
|
+
*/
|
|
179
|
+
export function parseThreshold(value) {
|
|
180
|
+
if (value == null) return null;
|
|
181
|
+
const str = String(value).trim();
|
|
182
|
+
if (str === "") return null;
|
|
183
|
+
const match = str.match(/^(>=|<=|==|>|<)?\s*(\d+)$/);
|
|
184
|
+
if (!match) return null;
|
|
185
|
+
const op = match[1] || ">=";
|
|
186
|
+
const n = parseInt(match[2], 10);
|
|
187
|
+
return { op, n };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Evaluate whether a count meets a parsed threshold.
|
|
192
|
+
* @param {number} count
|
|
193
|
+
* @param {{ op: string, n: number }} threshold
|
|
194
|
+
* @returns {boolean}
|
|
195
|
+
*/
|
|
196
|
+
export function meetsThreshold(count, threshold) {
|
|
197
|
+
switch (threshold.op) {
|
|
198
|
+
case ">=": return count >= threshold.n;
|
|
199
|
+
case "<=": return count <= threshold.n;
|
|
200
|
+
case "==": return count === threshold.n;
|
|
201
|
+
case ">": return count > threshold.n;
|
|
202
|
+
case "<": return count < threshold.n;
|
|
203
|
+
default: return false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Filter entries that occurred at or after a given action's start time.
|
|
209
|
+
* If sinceActionId is missing or the action isn't found, returns all entries.
|
|
210
|
+
* @param {Array<{ timestamp?: number }>} entries
|
|
211
|
+
* @param {number | undefined} sinceActionId
|
|
212
|
+
* @param {{ entries: Array<{ id: number, startedAt: number }> }} timeline
|
|
213
|
+
* @returns {Array}
|
|
214
|
+
*/
|
|
215
|
+
export function getEntriesSince(entries, sinceActionId, timeline) {
|
|
216
|
+
if (!entries || !Array.isArray(entries)) return [];
|
|
217
|
+
if (sinceActionId == null || !timeline) return entries;
|
|
218
|
+
const action = findAction(timeline, sinceActionId);
|
|
219
|
+
if (!action) return entries;
|
|
220
|
+
const since = action.startedAt;
|
|
221
|
+
return entries.filter((e) => (e.timestamp ?? 0) >= since);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function evaluateAssertionChecks({ checks, state }) {
|
|
225
|
+
const results = [];
|
|
226
|
+
const selectorStates = state.selectorStates ?? {};
|
|
227
|
+
const consoleEntries = state.consoleEntries ?? [];
|
|
228
|
+
const networkEntries = state.networkEntries ?? [];
|
|
229
|
+
const allConsoleEntries = state.allConsoleEntries ?? state.consoleEntries ?? [];
|
|
230
|
+
const allNetworkEntries = state.allNetworkEntries ?? state.networkEntries ?? [];
|
|
231
|
+
const actionTimeline = state.actionTimeline ?? null;
|
|
232
|
+
|
|
233
|
+
for (const check of checks) {
|
|
234
|
+
const selectorState = check.selector ? selectorStates[check.selector] ?? null : null;
|
|
235
|
+
let passed = false;
|
|
236
|
+
let actual;
|
|
237
|
+
let expected;
|
|
238
|
+
|
|
239
|
+
switch (check.kind) {
|
|
240
|
+
case "url_contains":
|
|
241
|
+
actual = state.url ?? "";
|
|
242
|
+
expected = check.value ?? "";
|
|
243
|
+
passed = includesNeedle(actual, expected);
|
|
244
|
+
break;
|
|
245
|
+
case "title_contains":
|
|
246
|
+
actual = state.title ?? "";
|
|
247
|
+
expected = check.value ?? "";
|
|
248
|
+
passed = includesNeedle(actual, expected);
|
|
249
|
+
break;
|
|
250
|
+
case "text_visible":
|
|
251
|
+
actual = state.bodyText ?? "";
|
|
252
|
+
expected = check.text ?? "";
|
|
253
|
+
passed = includesNeedle(actual, expected);
|
|
254
|
+
break;
|
|
255
|
+
case "text_not_visible":
|
|
256
|
+
actual = state.bodyText ?? "";
|
|
257
|
+
expected = check.text ?? "";
|
|
258
|
+
passed = !includesNeedle(actual, expected);
|
|
259
|
+
break;
|
|
260
|
+
case "selector_visible":
|
|
261
|
+
actual = selectorState?.visible ?? false;
|
|
262
|
+
expected = true;
|
|
263
|
+
passed = actual === true;
|
|
264
|
+
break;
|
|
265
|
+
case "selector_hidden":
|
|
266
|
+
actual = selectorState?.visible ?? false;
|
|
267
|
+
expected = false;
|
|
268
|
+
passed = actual === false;
|
|
269
|
+
break;
|
|
270
|
+
case "value_equals":
|
|
271
|
+
actual = selectorState?.value ?? "";
|
|
272
|
+
expected = check.value ?? "";
|
|
273
|
+
passed = actual === expected;
|
|
274
|
+
break;
|
|
275
|
+
case "value_contains":
|
|
276
|
+
actual = selectorState?.value ?? "";
|
|
277
|
+
expected = check.value ?? "";
|
|
278
|
+
passed = includesNeedle(actual, expected);
|
|
279
|
+
break;
|
|
280
|
+
case "focused_matches":
|
|
281
|
+
actual = state.focus ?? "";
|
|
282
|
+
expected = check.value ?? "";
|
|
283
|
+
passed = includesNeedle(actual, expected);
|
|
284
|
+
break;
|
|
285
|
+
case "checked_equals":
|
|
286
|
+
actual = selectorState?.checked ?? null;
|
|
287
|
+
expected = !!check.checked;
|
|
288
|
+
passed = actual === expected;
|
|
289
|
+
break;
|
|
290
|
+
case "no_console_errors":
|
|
291
|
+
actual = consoleEntries.filter((entry) => entry.type === "error" || entry.type === "pageerror").length;
|
|
292
|
+
expected = 0;
|
|
293
|
+
passed = actual === 0;
|
|
294
|
+
break;
|
|
295
|
+
case "no_failed_requests":
|
|
296
|
+
actual = networkEntries.filter((entry) => entry.failed || (typeof entry.status === "number" && entry.status >= 400)).length;
|
|
297
|
+
expected = 0;
|
|
298
|
+
passed = actual === 0;
|
|
299
|
+
break;
|
|
300
|
+
|
|
301
|
+
// --- S02: New structured network/console assertion kinds ---
|
|
302
|
+
|
|
303
|
+
case "request_url_seen": {
|
|
304
|
+
const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline);
|
|
305
|
+
const matches = filtered.filter((e) => includesNeedle(e.url ?? "", check.text ?? ""));
|
|
306
|
+
actual = matches.length > 0;
|
|
307
|
+
expected = true;
|
|
308
|
+
passed = actual === true;
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
case "response_status": {
|
|
313
|
+
const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline);
|
|
314
|
+
const statusNum = parseInt(check.value, 10);
|
|
315
|
+
const matches = filtered.filter(
|
|
316
|
+
(e) => includesNeedle(e.url ?? "", check.text ?? "") && typeof e.status === "number" && e.status === statusNum
|
|
317
|
+
);
|
|
318
|
+
actual = matches.length > 0 ? `found (status=${matches[0].status})` : `not found`;
|
|
319
|
+
expected = `status=${check.value ?? ""}`;
|
|
320
|
+
passed = matches.length > 0;
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
case "console_message_matches": {
|
|
325
|
+
const filtered = getEntriesSince(allConsoleEntries, check.sinceActionId, actionTimeline);
|
|
326
|
+
const matches = filtered.filter((e) => includesNeedle(e.text ?? "", check.text ?? ""));
|
|
327
|
+
actual = matches.length > 0;
|
|
328
|
+
expected = true;
|
|
329
|
+
passed = actual === true;
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
case "network_count": {
|
|
334
|
+
const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline);
|
|
335
|
+
const matches = filtered.filter((e) => includesNeedle(e.url ?? "", check.text ?? ""));
|
|
336
|
+
const threshold = parseThreshold(check.value);
|
|
337
|
+
if (!threshold) {
|
|
338
|
+
actual = `invalid threshold: ${check.value}`;
|
|
339
|
+
expected = check.value ?? "";
|
|
340
|
+
passed = false;
|
|
341
|
+
} else {
|
|
342
|
+
actual = `count=${matches.length}`;
|
|
343
|
+
expected = `${threshold.op}${threshold.n}`;
|
|
344
|
+
passed = meetsThreshold(matches.length, threshold);
|
|
345
|
+
}
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
case "console_count": {
|
|
350
|
+
const filtered = getEntriesSince(allConsoleEntries, check.sinceActionId, actionTimeline);
|
|
351
|
+
const matches = filtered.filter((e) => includesNeedle(e.text ?? "", check.text ?? ""));
|
|
352
|
+
const threshold = parseThreshold(check.value);
|
|
353
|
+
if (!threshold) {
|
|
354
|
+
actual = `invalid threshold: ${check.value}`;
|
|
355
|
+
expected = check.value ?? "";
|
|
356
|
+
passed = false;
|
|
357
|
+
} else {
|
|
358
|
+
actual = `count=${matches.length}`;
|
|
359
|
+
expected = `${threshold.op}${threshold.n}`;
|
|
360
|
+
passed = meetsThreshold(matches.length, threshold);
|
|
361
|
+
}
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
case "no_console_errors_since": {
|
|
366
|
+
const filtered = getEntriesSince(allConsoleEntries, check.sinceActionId, actionTimeline);
|
|
367
|
+
const errors = filtered.filter((e) => e.type === "error" || e.type === "pageerror");
|
|
368
|
+
actual = errors.length;
|
|
369
|
+
expected = 0;
|
|
370
|
+
passed = errors.length === 0;
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
case "no_failed_requests_since": {
|
|
375
|
+
const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline);
|
|
376
|
+
const failures = filtered.filter((e) => e.failed || (typeof e.status === "number" && e.status >= 400));
|
|
377
|
+
actual = failures.length;
|
|
378
|
+
expected = 0;
|
|
379
|
+
passed = failures.length === 0;
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
default:
|
|
384
|
+
actual = "unsupported";
|
|
385
|
+
expected = check.kind;
|
|
386
|
+
passed = false;
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
results.push({
|
|
391
|
+
name: check.kind,
|
|
392
|
+
passed,
|
|
393
|
+
actual,
|
|
394
|
+
expected,
|
|
395
|
+
selector: check.selector,
|
|
396
|
+
text: check.text,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const failed = results.filter((result) => !result.passed);
|
|
401
|
+
const verified = failed.length === 0;
|
|
402
|
+
return {
|
|
403
|
+
verified,
|
|
404
|
+
checks: results,
|
|
405
|
+
summary: verified
|
|
406
|
+
? `PASS (${results.length}/${results.length} checks)`
|
|
407
|
+
: `FAIL (${failed.length}/${results.length} checks failed)`,
|
|
408
|
+
agentHint: verified
|
|
409
|
+
? "All assertion checks passed"
|
|
410
|
+
: failed[0]
|
|
411
|
+
? `Investigate ${failed[0].name} (expected ${JSON.stringify(failed[0].expected)}, got ${JSON.stringify(failed[0].actual)})`
|
|
412
|
+
: "Assertion failed",
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
// Wait-condition validation
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* All recognized wait conditions with their parameter requirements.
|
|
422
|
+
* Each entry: { needsValue: bool, valueLabel: string, needsThreshold?: bool }
|
|
423
|
+
*/
|
|
424
|
+
const WAIT_CONDITIONS = {
|
|
425
|
+
// Existing 5 conditions
|
|
426
|
+
selector_visible: { needsValue: true, valueLabel: "CSS selector" },
|
|
427
|
+
selector_hidden: { needsValue: true, valueLabel: "CSS selector" },
|
|
428
|
+
url_contains: { needsValue: true, valueLabel: "URL substring" },
|
|
429
|
+
network_idle: { needsValue: false, valueLabel: "" },
|
|
430
|
+
delay: { needsValue: true, valueLabel: "milliseconds as a string (e.g. '1000')" },
|
|
431
|
+
|
|
432
|
+
// New 6 conditions (S03)
|
|
433
|
+
text_visible: { needsValue: true, valueLabel: "text to search for" },
|
|
434
|
+
text_hidden: { needsValue: true, valueLabel: "text to search for" },
|
|
435
|
+
request_completed: { needsValue: true, valueLabel: "URL substring to match" },
|
|
436
|
+
console_message: { needsValue: true, valueLabel: "message substring to match" },
|
|
437
|
+
element_count: { needsValue: true, valueLabel: "CSS selector", needsThreshold: true },
|
|
438
|
+
region_stable: { needsValue: true, valueLabel: "CSS selector" },
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Validate parameters for a browser_wait_for condition.
|
|
443
|
+
* @param {{ condition: string, value?: string, threshold?: string }} params
|
|
444
|
+
* @returns {null | { error: string }} — null if valid, structured error otherwise
|
|
445
|
+
*/
|
|
446
|
+
export function validateWaitParams(params) {
|
|
447
|
+
const { condition, value, threshold } = params ?? {};
|
|
448
|
+
|
|
449
|
+
if (!condition) {
|
|
450
|
+
return { error: "condition is required" };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const spec = WAIT_CONDITIONS[condition];
|
|
454
|
+
if (!spec) {
|
|
455
|
+
const known = Object.keys(WAIT_CONDITIONS).join(", ");
|
|
456
|
+
return { error: `unknown condition "${condition}". Known conditions: ${known}` };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (spec.needsValue && (!value || String(value).trim() === "")) {
|
|
460
|
+
return { error: `${condition} requires a value (${spec.valueLabel})` };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (spec.needsThreshold && threshold != null && String(threshold).trim() !== "") {
|
|
464
|
+
const parsed = parseThreshold(threshold);
|
|
465
|
+
if (!parsed) {
|
|
466
|
+
return { error: `${condition} threshold is malformed: "${threshold}". Expected format: >=N, <=N, ==N, >N, <N, or bare N` };
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ---------------------------------------------------------------------------
|
|
474
|
+
// Region-stable script generator
|
|
475
|
+
// ---------------------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Generate a JS expression string for page.waitForFunction() that detects
|
|
479
|
+
* DOM stability by comparing snapshot hashes across polling intervals.
|
|
480
|
+
*
|
|
481
|
+
* The script stores a snapshot on a namespaced window key. When the snapshot
|
|
482
|
+
* matches the previous value, the region is considered stable.
|
|
483
|
+
*
|
|
484
|
+
* @param {string} selector — CSS selector for the target element
|
|
485
|
+
* @returns {string} — self-contained JS function body suitable for waitForFunction
|
|
486
|
+
*/
|
|
487
|
+
export function createRegionStableScript(selector) {
|
|
488
|
+
// Create a stable key from the selector (simple hash to avoid special chars)
|
|
489
|
+
const safeKey = Array.from(selector).reduce((h, c) => ((h << 5) - h + c.charCodeAt(0)) | 0, 0) >>> 0;
|
|
490
|
+
const windowKey = `__pw_region_stable_${safeKey}`;
|
|
491
|
+
|
|
492
|
+
return `(() => {
|
|
493
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
494
|
+
if (!el) return false;
|
|
495
|
+
const snapshot = el.innerHTML.length + '|' + el.childElementCount + '|' + el.innerText.length;
|
|
496
|
+
const prev = window[${JSON.stringify(windowKey)}];
|
|
497
|
+
window[${JSON.stringify(windowKey)}] = snapshot;
|
|
498
|
+
if (prev === undefined) return false;
|
|
499
|
+
return snapshot === prev;
|
|
500
|
+
})()`;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ---------------------------------------------------------------------------
|
|
504
|
+
// Page Registry — pure-logic operations for multi-page/tab management
|
|
505
|
+
// ---------------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Create a fresh page registry.
|
|
509
|
+
* @returns {{ pages: Array, activePageId: number | null, nextId: number }}
|
|
510
|
+
*/
|
|
511
|
+
export function createPageRegistry() {
|
|
512
|
+
return { pages: [], activePageId: null, nextId: 1 };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* @typedef {{ id: number, page: any, title: string, url: string, opener: number | null }} PageEntry
|
|
517
|
+
*/
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Add a page to the registry. Assigns an auto-incrementing ID.
|
|
521
|
+
* @param {ReturnType<typeof createPageRegistry>} registry
|
|
522
|
+
* @param {{ page: any, title?: string, url?: string, opener?: number | null }} info
|
|
523
|
+
* @returns {PageEntry}
|
|
524
|
+
*/
|
|
525
|
+
export function registryAddPage(registry, { page, title = "", url = "", opener = null }) {
|
|
526
|
+
const entry = { id: registry.nextId++, page, title, url, opener };
|
|
527
|
+
registry.pages.push(entry);
|
|
528
|
+
return entry;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Remove a page from the registry by ID.
|
|
533
|
+
* If the removed page was active, falls back to the opener (if still present)
|
|
534
|
+
* or the last remaining page.
|
|
535
|
+
* Orphans any pages whose opener was the removed page (sets their opener to null).
|
|
536
|
+
* @param {ReturnType<typeof createPageRegistry>} registry
|
|
537
|
+
* @param {number} pageId
|
|
538
|
+
* @returns {{ removed: PageEntry, newActiveId: number | null }}
|
|
539
|
+
*/
|
|
540
|
+
export function registryRemovePage(registry, pageId) {
|
|
541
|
+
const idx = registry.pages.findIndex((p) => p.id === pageId);
|
|
542
|
+
if (idx === -1) {
|
|
543
|
+
const available = registry.pages.map((p) => p.id);
|
|
544
|
+
throw new Error(
|
|
545
|
+
`registryRemovePage: page ${pageId} not found. ` +
|
|
546
|
+
`Available page IDs: [${available.join(", ")}]. ` +
|
|
547
|
+
`Registry size: ${registry.pages.length}.`
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
const [removed] = registry.pages.splice(idx, 1);
|
|
551
|
+
|
|
552
|
+
// Orphan any pages whose opener was the removed page
|
|
553
|
+
for (const entry of registry.pages) {
|
|
554
|
+
if (entry.opener === pageId) {
|
|
555
|
+
entry.opener = null;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
let newActiveId = registry.activePageId;
|
|
560
|
+
if (registry.activePageId === pageId) {
|
|
561
|
+
if (registry.pages.length === 0) {
|
|
562
|
+
newActiveId = null;
|
|
563
|
+
} else if (removed.opener !== null && registry.pages.some((p) => p.id === removed.opener)) {
|
|
564
|
+
newActiveId = removed.opener;
|
|
565
|
+
} else {
|
|
566
|
+
newActiveId = registry.pages[registry.pages.length - 1].id;
|
|
567
|
+
}
|
|
568
|
+
registry.activePageId = newActiveId;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return { removed, newActiveId };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Set the active page by ID. Throws if the page is not in the registry.
|
|
576
|
+
* @param {ReturnType<typeof createPageRegistry>} registry
|
|
577
|
+
* @param {number} pageId
|
|
578
|
+
*/
|
|
579
|
+
export function registrySetActive(registry, pageId) {
|
|
580
|
+
const entry = registry.pages.find((p) => p.id === pageId);
|
|
581
|
+
if (!entry) {
|
|
582
|
+
const available = registry.pages.map((p) => p.id);
|
|
583
|
+
throw new Error(
|
|
584
|
+
`registrySetActive: page ${pageId} not found. ` +
|
|
585
|
+
`Available page IDs: [${available.join(", ")}]. ` +
|
|
586
|
+
`Registry size: ${registry.pages.length}.`
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
registry.activePageId = pageId;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Get the active page entry. Throws if no active page or active page not found.
|
|
594
|
+
* @param {ReturnType<typeof createPageRegistry>} registry
|
|
595
|
+
* @returns {PageEntry}
|
|
596
|
+
*/
|
|
597
|
+
export function registryGetActive(registry) {
|
|
598
|
+
if (registry.activePageId === null) {
|
|
599
|
+
throw new Error(
|
|
600
|
+
`registryGetActive: no active page. ` +
|
|
601
|
+
`Registry contains ${registry.pages.length} page(s). ` +
|
|
602
|
+
`Page IDs: [${registry.pages.map((p) => p.id).join(", ")}].`
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
const entry = registry.pages.find((p) => p.id === registry.activePageId);
|
|
606
|
+
if (!entry) {
|
|
607
|
+
throw new Error(
|
|
608
|
+
`registryGetActive: activePageId ${registry.activePageId} not found in registry. ` +
|
|
609
|
+
`Available page IDs: [${registry.pages.map((p) => p.id).join(", ")}]. ` +
|
|
610
|
+
`Registry size: ${registry.pages.length}. This indicates stale state.`
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
return entry;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Get a page entry by ID, or null if not found.
|
|
618
|
+
* @param {ReturnType<typeof createPageRegistry>} registry
|
|
619
|
+
* @param {number} pageId
|
|
620
|
+
* @returns {PageEntry | null}
|
|
621
|
+
*/
|
|
622
|
+
export function registryGetPage(registry, pageId) {
|
|
623
|
+
return registry.pages.find((p) => p.id === pageId) ?? null;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* List all pages (without the raw `page` reference).
|
|
628
|
+
* @param {ReturnType<typeof createPageRegistry>} registry
|
|
629
|
+
* @returns {Array<{ id: number, title: string, url: string, opener: number | null, isActive: boolean }>}
|
|
630
|
+
*/
|
|
631
|
+
export function registryListPages(registry) {
|
|
632
|
+
return registry.pages.map((entry) => ({
|
|
633
|
+
id: entry.id,
|
|
634
|
+
title: entry.title,
|
|
635
|
+
url: entry.url,
|
|
636
|
+
opener: entry.opener,
|
|
637
|
+
isActive: entry.id === registry.activePageId,
|
|
638
|
+
}));
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ---------------------------------------------------------------------------
|
|
642
|
+
// FIFO Bounded Log Pusher
|
|
643
|
+
// ---------------------------------------------------------------------------
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Create a push function that enforces FIFO eviction at push-time.
|
|
647
|
+
* @param {number} maxSize — maximum number of entries to retain
|
|
648
|
+
* @returns {(array: Array, entry: any) => void}
|
|
649
|
+
*/
|
|
650
|
+
export function createBoundedLogPusher(maxSize) {
|
|
651
|
+
return function push(array, entry) {
|
|
652
|
+
array.push(entry);
|
|
653
|
+
if (array.length > maxSize) {
|
|
654
|
+
array.splice(0, array.length - maxSize);
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
export async function runBatchSteps({ steps, executeStep, stopOnFailure = true }) {
|
|
660
|
+
const results = [];
|
|
661
|
+
for (let i = 0; i < steps.length; i += 1) {
|
|
662
|
+
const step = steps[i];
|
|
663
|
+
const result = await executeStep(step, i);
|
|
664
|
+
results.push(result);
|
|
665
|
+
if (result.ok === false && stopOnFailure) {
|
|
666
|
+
return {
|
|
667
|
+
ok: false,
|
|
668
|
+
stopReason: "step_failed",
|
|
669
|
+
failedStepIndex: i,
|
|
670
|
+
stepResults: results,
|
|
671
|
+
summary: `Stopped at step ${i + 1} (${step.action})`,
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return {
|
|
676
|
+
ok: true,
|
|
677
|
+
stopReason: null,
|
|
678
|
+
failedStepIndex: null,
|
|
679
|
+
stepResults: results,
|
|
680
|
+
summary: `Completed ${results.length} step(s)`,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// ---------------------------------------------------------------------------
|
|
685
|
+
// Snapshot Modes — semantic element filtering for browser_snapshot_refs
|
|
686
|
+
// ---------------------------------------------------------------------------
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Pre-defined snapshot modes that filter elements by semantic category.
|
|
690
|
+
* Each mode config defines which elements should be captured.
|
|
691
|
+
*
|
|
692
|
+
* Shape: { tags: string[], roles: string[], selectors: string[],
|
|
693
|
+
* ariaAttributes: string[], useInteractiveFilter: boolean,
|
|
694
|
+
* visibleOnly?: boolean, containerExpand?: boolean }
|
|
695
|
+
*/
|
|
696
|
+
export const SNAPSHOT_MODES = {
|
|
697
|
+
interactive: {
|
|
698
|
+
tags: [],
|
|
699
|
+
roles: [],
|
|
700
|
+
selectors: [],
|
|
701
|
+
ariaAttributes: [],
|
|
702
|
+
useInteractiveFilter: true,
|
|
703
|
+
},
|
|
704
|
+
form: {
|
|
705
|
+
tags: ["input", "select", "textarea", "button", "fieldset", "label", "output", "datalist"],
|
|
706
|
+
roles: ["textbox", "searchbox", "combobox", "checkbox", "radio", "switch", "slider", "spinbutton", "listbox", "option"],
|
|
707
|
+
selectors: ["[contenteditable]"],
|
|
708
|
+
ariaAttributes: [],
|
|
709
|
+
useInteractiveFilter: false,
|
|
710
|
+
},
|
|
711
|
+
dialog: {
|
|
712
|
+
tags: ["dialog"],
|
|
713
|
+
roles: ["dialog", "alertdialog"],
|
|
714
|
+
selectors: ['[role="dialog"]', '[role="alertdialog"]'],
|
|
715
|
+
ariaAttributes: [],
|
|
716
|
+
useInteractiveFilter: false,
|
|
717
|
+
containerExpand: true,
|
|
718
|
+
},
|
|
719
|
+
navigation: {
|
|
720
|
+
tags: ["a", "nav"],
|
|
721
|
+
roles: ["link", "navigation", "menubar", "menu", "menuitem"],
|
|
722
|
+
selectors: [],
|
|
723
|
+
ariaAttributes: [],
|
|
724
|
+
useInteractiveFilter: false,
|
|
725
|
+
},
|
|
726
|
+
errors: {
|
|
727
|
+
tags: [],
|
|
728
|
+
roles: ["alert", "status"],
|
|
729
|
+
selectors: ['[aria-invalid="true"]', '[role="alert"]', '[role="status"]'],
|
|
730
|
+
ariaAttributes: ["aria-invalid", "aria-errormessage"],
|
|
731
|
+
useInteractiveFilter: false,
|
|
732
|
+
containerExpand: true,
|
|
733
|
+
},
|
|
734
|
+
headings: {
|
|
735
|
+
tags: ["h1", "h2", "h3", "h4", "h5", "h6"],
|
|
736
|
+
roles: ["heading"],
|
|
737
|
+
selectors: [],
|
|
738
|
+
ariaAttributes: [],
|
|
739
|
+
useInteractiveFilter: false,
|
|
740
|
+
},
|
|
741
|
+
visible_only: {
|
|
742
|
+
tags: [],
|
|
743
|
+
roles: [],
|
|
744
|
+
selectors: [],
|
|
745
|
+
ariaAttributes: [],
|
|
746
|
+
useInteractiveFilter: false,
|
|
747
|
+
visibleOnly: true,
|
|
748
|
+
},
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Get the snapshot mode config by name.
|
|
753
|
+
* @param {string} mode — mode name (e.g. "form", "dialog", "interactive")
|
|
754
|
+
* @returns {{ tags: string[], roles: string[], selectors: string[], ariaAttributes: string[], useInteractiveFilter: boolean, visibleOnly?: boolean, containerExpand?: boolean } | null}
|
|
755
|
+
*/
|
|
756
|
+
export function getSnapshotModeConfig(mode) {
|
|
757
|
+
return SNAPSHOT_MODES[mode] ?? null;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// ---------------------------------------------------------------------------
|
|
761
|
+
// Fingerprint functions — structural identity for ref resolution
|
|
762
|
+
// ---------------------------------------------------------------------------
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Compute a content hash from visible text using djb2.
|
|
766
|
+
* Caller is expected to pre-truncate to ~200 chars and normalize whitespace.
|
|
767
|
+
* @param {string} text — visible text content
|
|
768
|
+
* @returns {string} — hex string hash, or "0" for empty input
|
|
769
|
+
*/
|
|
770
|
+
export function computeContentHash(text) {
|
|
771
|
+
if (!text) return "0";
|
|
772
|
+
let h = 5381;
|
|
773
|
+
for (let i = 0; i < text.length; i++) {
|
|
774
|
+
h = ((h << 5) - h + text.charCodeAt(i)) | 0;
|
|
775
|
+
}
|
|
776
|
+
return (h >>> 0).toString(16);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Compute a structural signature from tag, role, and immediate child tag names.
|
|
781
|
+
* Uses djb2 hash on the concatenated string `tag|role|child1,child2,...`.
|
|
782
|
+
* @param {string} tag — element tag name (lowercase)
|
|
783
|
+
* @param {string} role — ARIA role or empty string
|
|
784
|
+
* @param {string[]} childTags — array of immediate child tag names (lowercase)
|
|
785
|
+
* @returns {string} — hex string hash
|
|
786
|
+
*/
|
|
787
|
+
export function computeStructuralSignature(tag, role, childTags) {
|
|
788
|
+
const input = `${tag}|${role}|${childTags.join(",")}`;
|
|
789
|
+
let h = 5381;
|
|
790
|
+
for (let i = 0; i < input.length; i++) {
|
|
791
|
+
h = ((h << 5) - h + input.charCodeAt(i)) | 0;
|
|
792
|
+
}
|
|
793
|
+
return (h >>> 0).toString(16);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Match two fingerprint objects by contentHash and structuralSignature.
|
|
798
|
+
* Returns true only when both fields are present on both objects and both match.
|
|
799
|
+
* @param {{ contentHash?: string, structuralSignature?: string }} stored
|
|
800
|
+
* @param {{ contentHash?: string, structuralSignature?: string }} candidate
|
|
801
|
+
* @returns {boolean}
|
|
802
|
+
*/
|
|
803
|
+
export function matchFingerprint(stored, candidate) {
|
|
804
|
+
if (!stored || !candidate) return false;
|
|
805
|
+
if (!stored.contentHash || !stored.structuralSignature) return false;
|
|
806
|
+
if (!candidate.contentHash || !candidate.structuralSignature) return false;
|
|
807
|
+
return stored.contentHash === candidate.contentHash &&
|
|
808
|
+
stored.structuralSignature === candidate.structuralSignature;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function formatDurationMs(entry) {
|
|
812
|
+
const startedAt = typeof entry?.startedAt === "number" ? entry.startedAt : null;
|
|
813
|
+
const finishedAt = typeof entry?.finishedAt === "number" ? entry.finishedAt : null;
|
|
814
|
+
if (startedAt == null || finishedAt == null || finishedAt < startedAt) return null;
|
|
815
|
+
return finishedAt - startedAt;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function summarizeActionStatus(status) {
|
|
819
|
+
if (status === "error") return "error";
|
|
820
|
+
if (status === "running") return "running";
|
|
821
|
+
return "success";
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function looksBoundedWarning(value) {
|
|
825
|
+
return /bounded .*history/i.test(String(value ?? ""));
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function uniqueStrings(values) {
|
|
829
|
+
return [...new Set(values.filter(Boolean))];
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
export function formatTimelineEntries(entries = [], options = {}) {
|
|
833
|
+
const retained = options.retained ?? entries.length;
|
|
834
|
+
const totalRecorded = options.totalRecorded ?? retained;
|
|
835
|
+
const bounded = totalRecorded > retained;
|
|
836
|
+
|
|
837
|
+
if (!entries.length) {
|
|
838
|
+
return {
|
|
839
|
+
entries: [],
|
|
840
|
+
retained,
|
|
841
|
+
totalRecorded,
|
|
842
|
+
bounded,
|
|
843
|
+
summary: "No browser actions recorded.",
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const formattedEntries = entries.map((entry) => {
|
|
848
|
+
const status = summarizeActionStatus(entry.status);
|
|
849
|
+
const durationMs = formatDurationMs(entry);
|
|
850
|
+
const parts = [
|
|
851
|
+
`#${entry.id ?? "?"}`,
|
|
852
|
+
entry.tool ?? "unknown_tool",
|
|
853
|
+
status,
|
|
854
|
+
];
|
|
855
|
+
|
|
856
|
+
if (durationMs != null) parts.push(`${durationMs}ms`);
|
|
857
|
+
if (entry.paramsSummary) parts.push(entry.paramsSummary);
|
|
858
|
+
if (entry.error) parts.push(entry.error);
|
|
859
|
+
if (entry.verificationSummary) parts.push(entry.verificationSummary);
|
|
860
|
+
if (entry.diffSummary) parts.push(entry.diffSummary);
|
|
861
|
+
if (entry.warningSummary) parts.push(entry.warningSummary);
|
|
862
|
+
|
|
863
|
+
return {
|
|
864
|
+
id: entry.id ?? null,
|
|
865
|
+
tool: entry.tool ?? "",
|
|
866
|
+
status,
|
|
867
|
+
durationMs,
|
|
868
|
+
beforeUrl: entry.beforeUrl ?? "",
|
|
869
|
+
afterUrl: entry.afterUrl ?? "",
|
|
870
|
+
line: parts.join(" | "),
|
|
871
|
+
};
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
const summary = bounded
|
|
875
|
+
? `Timeline: showing ${retained} of ${totalRecorded} recorded browser actions; older actions were discarded due to bounded history.`
|
|
876
|
+
: `Timeline: ${retained} browser action${retained === 1 ? "" : "s"} recorded.`;
|
|
877
|
+
|
|
878
|
+
return {
|
|
879
|
+
entries: formattedEntries,
|
|
880
|
+
retained,
|
|
881
|
+
totalRecorded,
|
|
882
|
+
bounded,
|
|
883
|
+
summary,
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
export function buildFailureHypothesis(session = {}) {
|
|
888
|
+
const timelineEntries = session.actionTimeline?.entries ?? [];
|
|
889
|
+
const consoleEntries = session.consoleEntries ?? [];
|
|
890
|
+
const networkEntries = session.networkEntries ?? [];
|
|
891
|
+
const dialogEntries = session.dialogEntries ?? [];
|
|
892
|
+
const signals = [];
|
|
893
|
+
|
|
894
|
+
for (const entry of timelineEntries) {
|
|
895
|
+
if (entry?.status !== "error") continue;
|
|
896
|
+
if (entry.tool === "browser_wait_for") {
|
|
897
|
+
signals.push({
|
|
898
|
+
category: "wait",
|
|
899
|
+
source: `action#${entry.id ?? "?"}`,
|
|
900
|
+
detail: entry.error || entry.warningSummary || "Wait condition failed",
|
|
901
|
+
});
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
if (entry.tool === "browser_assert") {
|
|
905
|
+
signals.push({
|
|
906
|
+
category: "assert",
|
|
907
|
+
source: `action#${entry.id ?? "?"}`,
|
|
908
|
+
detail: entry.error || entry.verificationSummary || "Assertion failed",
|
|
909
|
+
});
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
signals.push({
|
|
913
|
+
category: "action",
|
|
914
|
+
source: `action#${entry.id ?? "?"}`,
|
|
915
|
+
detail: entry.error || `${entry.tool ?? "browser action"} failed`,
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
for (const entry of consoleEntries) {
|
|
920
|
+
if (entry?.type !== "error" && entry?.type !== "pageerror") continue;
|
|
921
|
+
signals.push({
|
|
922
|
+
category: "console",
|
|
923
|
+
source: entry.type,
|
|
924
|
+
detail: entry.text || "Console error recorded",
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
for (const entry of networkEntries) {
|
|
929
|
+
const failed = entry?.failed || (typeof entry?.status === "number" && entry.status >= 400);
|
|
930
|
+
if (!failed) continue;
|
|
931
|
+
signals.push({
|
|
932
|
+
category: "network",
|
|
933
|
+
source: entry.url || "network request",
|
|
934
|
+
detail: `${entry.url || "request"} failed${typeof entry?.status === "number" ? ` with ${entry.status}` : ""}`,
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
for (const entry of dialogEntries) {
|
|
939
|
+
signals.push({
|
|
940
|
+
category: "dialog",
|
|
941
|
+
source: entry?.type || "dialog",
|
|
942
|
+
detail: entry?.message || "Dialog appeared during failure investigation",
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const categories = uniqueStrings(signals.map((signal) => signal.category));
|
|
947
|
+
const hasFailures = categories.length > 0;
|
|
948
|
+
const summary = hasFailures
|
|
949
|
+
? `Recent failure signals detected across ${categories.join(", ")}.`
|
|
950
|
+
: "No recent failure signals detected.";
|
|
951
|
+
|
|
952
|
+
return {
|
|
953
|
+
hasFailures,
|
|
954
|
+
categories,
|
|
955
|
+
summary,
|
|
956
|
+
signals,
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
export function summarizeBrowserSession(session = {}) {
|
|
961
|
+
const actionTimeline = session.actionTimeline ?? { limit: 0, entries: [] };
|
|
962
|
+
const actionEntries = actionTimeline.entries ?? [];
|
|
963
|
+
const retainedActionCount = session.retainedActionCount ?? actionEntries.length;
|
|
964
|
+
const totalActionCount = session.totalActionCount ?? retainedActionCount;
|
|
965
|
+
const pages = session.pages ?? [];
|
|
966
|
+
const consoleEntries = session.consoleEntries ?? [];
|
|
967
|
+
const networkEntries = session.networkEntries ?? [];
|
|
968
|
+
const dialogEntries = session.dialogEntries ?? [];
|
|
969
|
+
|
|
970
|
+
const actionStatusCounts = actionEntries.reduce(
|
|
971
|
+
(acc, entry) => {
|
|
972
|
+
const status = summarizeActionStatus(entry.status);
|
|
973
|
+
acc[status] = (acc[status] ?? 0) + 1;
|
|
974
|
+
return acc;
|
|
975
|
+
},
|
|
976
|
+
{ success: 0, error: 0, running: 0 },
|
|
977
|
+
);
|
|
978
|
+
|
|
979
|
+
const waitEntries = actionEntries.filter((entry) => entry.tool === "browser_wait_for");
|
|
980
|
+
const assertEntries = actionEntries.filter((entry) => entry.tool === "browser_assert");
|
|
981
|
+
const consoleErrors = consoleEntries.filter((entry) => entry.type === "error" || entry.type === "pageerror");
|
|
982
|
+
const failedRequests = networkEntries.filter((entry) => entry.failed || (typeof entry.status === "number" && entry.status >= 400));
|
|
983
|
+
const activePage = pages.find((page) => page.isActive) ?? pages[0] ?? null;
|
|
984
|
+
|
|
985
|
+
const caveats = [];
|
|
986
|
+
if (totalActionCount > retainedActionCount) {
|
|
987
|
+
caveats.push(`Showing ${retainedActionCount} of ${totalActionCount} recorded actions; older actions were discarded due to bounded history.`);
|
|
988
|
+
}
|
|
989
|
+
if (
|
|
990
|
+
actionEntries.some((entry) => looksBoundedWarning(entry.warningSummary) || looksBoundedWarning(entry.error)) ||
|
|
991
|
+
consoleEntries.some((entry) => looksBoundedWarning(entry.text) || looksBoundedWarning(entry.message)) ||
|
|
992
|
+
consoleEntries.length > 0
|
|
993
|
+
) {
|
|
994
|
+
caveats.push("bounded console history may hide older console events.");
|
|
995
|
+
}
|
|
996
|
+
if (failedRequests.length > 0 || networkEntries.length > 0) {
|
|
997
|
+
caveats.push("bounded network history may hide older requests.");
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const failureHypothesis = buildFailureHypothesis(session);
|
|
1001
|
+
|
|
1002
|
+
if (!actionEntries.length && pages.length === 0 && consoleEntries.length === 0 && networkEntries.length === 0 && dialogEntries.length === 0) {
|
|
1003
|
+
return {
|
|
1004
|
+
counts: {
|
|
1005
|
+
pages: 0,
|
|
1006
|
+
actions: { total: 0, retained: 0, success: 0, error: 0, running: 0 },
|
|
1007
|
+
waits: { total: 0, success: 0, error: 0, running: 0 },
|
|
1008
|
+
assertions: { total: 0, passed: 0, failed: 0, running: 0 },
|
|
1009
|
+
consoleErrors: 0,
|
|
1010
|
+
failedRequests: 0,
|
|
1011
|
+
dialogs: 0,
|
|
1012
|
+
},
|
|
1013
|
+
activePage: null,
|
|
1014
|
+
caveats: [],
|
|
1015
|
+
failureHypothesis,
|
|
1016
|
+
summary: "No browser session activity recorded.",
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
return {
|
|
1021
|
+
counts: {
|
|
1022
|
+
pages: pages.length,
|
|
1023
|
+
actions: {
|
|
1024
|
+
total: totalActionCount,
|
|
1025
|
+
retained: retainedActionCount,
|
|
1026
|
+
success: actionStatusCounts.success,
|
|
1027
|
+
error: actionStatusCounts.error,
|
|
1028
|
+
running: actionStatusCounts.running,
|
|
1029
|
+
},
|
|
1030
|
+
waits: {
|
|
1031
|
+
total: waitEntries.length,
|
|
1032
|
+
success: waitEntries.filter((entry) => summarizeActionStatus(entry.status) === "success").length,
|
|
1033
|
+
error: waitEntries.filter((entry) => summarizeActionStatus(entry.status) === "error").length,
|
|
1034
|
+
running: waitEntries.filter((entry) => summarizeActionStatus(entry.status) === "running").length,
|
|
1035
|
+
},
|
|
1036
|
+
assertions: {
|
|
1037
|
+
total: assertEntries.length,
|
|
1038
|
+
passed: assertEntries.filter((entry) => summarizeActionStatus(entry.status) === "success").length,
|
|
1039
|
+
failed: assertEntries.filter((entry) => summarizeActionStatus(entry.status) === "error").length,
|
|
1040
|
+
running: assertEntries.filter((entry) => summarizeActionStatus(entry.status) === "running").length,
|
|
1041
|
+
},
|
|
1042
|
+
consoleErrors: consoleErrors.length,
|
|
1043
|
+
failedRequests: failedRequests.length,
|
|
1044
|
+
dialogs: dialogEntries.length,
|
|
1045
|
+
},
|
|
1046
|
+
activePage: activePage
|
|
1047
|
+
? {
|
|
1048
|
+
id: activePage.id ?? null,
|
|
1049
|
+
title: activePage.title ?? "",
|
|
1050
|
+
url: activePage.url ?? "",
|
|
1051
|
+
}
|
|
1052
|
+
: null,
|
|
1053
|
+
caveats,
|
|
1054
|
+
failureHypothesis,
|
|
1055
|
+
summary: `Session: ${pages.length} page${pages.length === 1 ? "" : "s"}, ${totalActionCount} actions, ${waitEntries.length} wait${waitEntries.length === 1 ? "" : "s"}, ${assertEntries.length} assert${assertEntries.length === 1 ? "" : "s"}.${caveats.length ? ` ${caveats.join(" ")}` : ""}`,
|
|
1056
|
+
};
|
|
1057
|
+
}
|