gsd-pi 2.22.0 → 2.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -1
- package/dist/cli.js +74 -7
- package/dist/headless.d.ts +25 -0
- package/dist/headless.js +454 -0
- package/dist/help-text.js +47 -0
- package/dist/mcp-server.d.ts +20 -3
- package/dist/mcp-server.js +21 -1
- package/dist/models-resolver.d.ts +32 -0
- package/dist/models-resolver.js +50 -0
- package/dist/resource-loader.js +64 -9
- package/dist/resources/extensions/bg-shell/output-formatter.ts +36 -16
- package/dist/resources/extensions/bg-shell/process-manager.ts +6 -4
- package/dist/resources/extensions/bg-shell/types.ts +33 -1
- package/dist/resources/extensions/browser-tools/capture.ts +18 -16
- package/dist/resources/extensions/browser-tools/index.ts +20 -0
- package/dist/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs +25 -0
- package/dist/resources/extensions/browser-tools/tools/action-cache.ts +216 -0
- package/dist/resources/extensions/browser-tools/tools/codegen.ts +274 -0
- package/dist/resources/extensions/browser-tools/tools/device.ts +183 -0
- package/dist/resources/extensions/browser-tools/tools/extract.ts +229 -0
- package/dist/resources/extensions/browser-tools/tools/injection-detect.ts +221 -0
- package/dist/resources/extensions/browser-tools/tools/network-mock.ts +244 -0
- package/dist/resources/extensions/browser-tools/tools/pdf.ts +92 -0
- package/dist/resources/extensions/browser-tools/tools/state-persistence.ts +202 -0
- package/dist/resources/extensions/browser-tools/tools/visual-diff.ts +209 -0
- package/dist/resources/extensions/browser-tools/tools/zoom.ts +104 -0
- package/dist/resources/extensions/gsd/auto-dashboard.ts +2 -0
- package/dist/resources/extensions/gsd/auto-dispatch.ts +51 -2
- package/dist/resources/extensions/gsd/auto-prompts.ts +73 -0
- package/dist/resources/extensions/gsd/auto-recovery.ts +51 -2
- package/dist/resources/extensions/gsd/auto-worktree.ts +15 -3
- package/dist/resources/extensions/gsd/auto.ts +560 -52
- package/dist/resources/extensions/gsd/captures.ts +49 -0
- package/dist/resources/extensions/gsd/commands.ts +194 -11
- package/dist/resources/extensions/gsd/complexity.ts +1 -0
- package/dist/resources/extensions/gsd/dashboard-overlay.ts +54 -2
- package/dist/resources/extensions/gsd/diff-context.ts +73 -80
- package/dist/resources/extensions/gsd/doctor.ts +76 -12
- package/dist/resources/extensions/gsd/exit-command.ts +2 -2
- package/dist/resources/extensions/gsd/forensics.ts +95 -52
- package/dist/resources/extensions/gsd/gitignore.ts +1 -0
- package/dist/resources/extensions/gsd/guided-flow.ts +85 -5
- package/dist/resources/extensions/gsd/index.ts +34 -1
- package/dist/resources/extensions/gsd/mcp-server.ts +33 -12
- package/dist/resources/extensions/gsd/parallel-eligibility.ts +233 -0
- package/dist/resources/extensions/gsd/parallel-merge.ts +156 -0
- package/dist/resources/extensions/gsd/parallel-orchestrator.ts +496 -0
- package/dist/resources/extensions/gsd/post-unit-hooks.ts +2 -1
- package/dist/resources/extensions/gsd/preferences.ts +65 -1
- package/dist/resources/extensions/gsd/prompts/discuss-headless.md +86 -0
- package/dist/resources/extensions/gsd/prompts/execute-task.md +5 -0
- package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +104 -1
- package/dist/resources/extensions/gsd/prompts/plan-milestone.md +1 -0
- package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
- package/dist/resources/extensions/gsd/prompts/system.md +2 -1
- package/dist/resources/extensions/gsd/prompts/validate-milestone.md +70 -0
- package/dist/resources/extensions/gsd/provider-error-pause.ts +29 -2
- package/dist/resources/extensions/gsd/roadmap-slices.ts +41 -1
- package/dist/resources/extensions/gsd/session-forensics.ts +36 -2
- package/dist/resources/extensions/gsd/session-status-io.ts +197 -0
- package/dist/resources/extensions/gsd/state.ts +72 -30
- package/dist/resources/extensions/gsd/templates/milestone-validation.md +62 -0
- package/dist/resources/extensions/gsd/tests/agent-end-provider-error.test.ts +81 -0
- package/dist/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +20 -3
- package/dist/resources/extensions/gsd/tests/auto-lock-creation.test.ts +186 -0
- package/dist/resources/extensions/gsd/tests/auto-preflight.test.ts +1 -0
- package/dist/resources/extensions/gsd/tests/auto-recovery.test.ts +264 -0
- package/dist/resources/extensions/gsd/tests/auto-skip-loop.test.ts +123 -0
- package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +34 -0
- package/dist/resources/extensions/gsd/tests/complete-milestone.test.ts +8 -1
- package/dist/resources/extensions/gsd/tests/derive-state-db.test.ts +9 -15
- package/dist/resources/extensions/gsd/tests/derive-state-deps.test.ts +9 -0
- package/dist/resources/extensions/gsd/tests/derive-state-draft.test.ts +8 -0
- package/dist/resources/extensions/gsd/tests/derive-state.test.ts +14 -0
- package/dist/resources/extensions/gsd/tests/doctor.test.ts +58 -0
- package/dist/resources/extensions/gsd/tests/in-flight-tool-tracking.test.ts +17 -6
- package/dist/resources/extensions/gsd/tests/integration/headless-command.ts +534 -0
- package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +8 -0
- package/dist/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +5 -5
- package/dist/resources/extensions/gsd/tests/parallel-orchestration.test.ts +656 -0
- package/dist/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +354 -0
- package/dist/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +1 -0
- package/dist/resources/extensions/gsd/tests/roadmap-slices.test.ts +43 -1
- package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +120 -0
- package/dist/resources/extensions/gsd/tests/triage-resolution.test.ts +203 -2
- package/dist/resources/extensions/gsd/tests/validate-milestone.test.ts +316 -0
- package/dist/resources/extensions/gsd/tests/visualizer-overlay.test.ts +8 -3
- package/dist/resources/extensions/gsd/tests/worker-registry.test.ts +148 -0
- package/dist/resources/extensions/gsd/triage-resolution.ts +83 -0
- package/dist/resources/extensions/gsd/types.ts +15 -1
- package/dist/resources/extensions/gsd/visualizer-overlay.ts +8 -1
- package/dist/resources/extensions/gsd/workspace-index.ts +34 -6
- package/dist/resources/extensions/subagent/index.ts +5 -0
- package/dist/resources/extensions/subagent/worker-registry.ts +99 -0
- package/dist/update-check.d.ts +9 -0
- package/dist/update-check.js +97 -0
- package/package.json +6 -1
- package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/anthropic.js +16 -7
- package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
- package/packages/pi-ai/dist/providers/azure-openai-responses.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/azure-openai-responses.js +12 -4
- package/packages/pi-ai/dist/providers/azure-openai-responses.js.map +1 -1
- package/packages/pi-ai/dist/providers/google-vertex.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/google-vertex.js +21 -9
- package/packages/pi-ai/dist/providers/google-vertex.js.map +1 -1
- package/packages/pi-ai/dist/providers/openai-completions.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/openai-completions.js +12 -4
- package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
- package/packages/pi-ai/dist/providers/openai-responses.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/openai-responses.js +12 -4
- package/packages/pi-ai/dist/providers/openai-responses.js.map +1 -1
- package/packages/pi-ai/src/providers/anthropic.ts +21 -8
- package/packages/pi-ai/src/providers/azure-openai-responses.ts +16 -4
- package/packages/pi-ai/src/providers/google-vertex.ts +32 -17
- package/packages/pi-ai/src/providers/openai-completions.ts +16 -4
- package/packages/pi-ai/src/providers/openai-responses.ts +16 -4
- package/packages/pi-coding-agent/dist/core/agent-session.js +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/settings-manager.js +1 -1
- package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/tools/bash-background.test.d.ts +10 -0
- package/packages/pi-coding-agent/dist/core/tools/bash-background.test.d.ts.map +1 -0
- package/packages/pi-coding-agent/dist/core/tools/bash-background.test.js +79 -0
- package/packages/pi-coding-agent/dist/core/tools/bash-background.test.js.map +1 -0
- package/packages/pi-coding-agent/dist/core/tools/bash.d.ts +18 -0
- package/packages/pi-coding-agent/dist/core/tools/bash.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/tools/bash.js +77 -1
- package/packages/pi-coding-agent/dist/core/tools/bash.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/tools/index.d.ts +1 -1
- package/packages/pi-coding-agent/dist/core/tools/index.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/tools/index.js +1 -1
- package/packages/pi-coding-agent/dist/core/tools/index.js.map +1 -1
- package/packages/pi-coding-agent/dist/index.d.ts +1 -1
- package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/index.js +1 -1
- package/packages/pi-coding-agent/dist/index.js.map +1 -1
- package/packages/pi-coding-agent/src/core/agent-session.ts +1 -1
- package/packages/pi-coding-agent/src/core/settings-manager.ts +2 -2
- package/packages/pi-coding-agent/src/core/tools/bash-background.test.ts +91 -0
- package/packages/pi-coding-agent/src/core/tools/bash.ts +83 -1
- package/packages/pi-coding-agent/src/core/tools/index.ts +1 -0
- package/packages/pi-coding-agent/src/index.ts +1 -0
- package/scripts/postinstall.js +7 -109
- package/src/resources/extensions/bg-shell/output-formatter.ts +36 -16
- package/src/resources/extensions/bg-shell/process-manager.ts +6 -4
- package/src/resources/extensions/bg-shell/types.ts +33 -1
- package/src/resources/extensions/browser-tools/capture.ts +18 -16
- package/src/resources/extensions/browser-tools/index.ts +20 -0
- package/src/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs +25 -0
- package/src/resources/extensions/browser-tools/tools/action-cache.ts +216 -0
- package/src/resources/extensions/browser-tools/tools/codegen.ts +274 -0
- package/src/resources/extensions/browser-tools/tools/device.ts +183 -0
- package/src/resources/extensions/browser-tools/tools/extract.ts +229 -0
- package/src/resources/extensions/browser-tools/tools/injection-detect.ts +221 -0
- package/src/resources/extensions/browser-tools/tools/network-mock.ts +244 -0
- package/src/resources/extensions/browser-tools/tools/pdf.ts +92 -0
- package/src/resources/extensions/browser-tools/tools/state-persistence.ts +202 -0
- package/src/resources/extensions/browser-tools/tools/visual-diff.ts +209 -0
- package/src/resources/extensions/browser-tools/tools/zoom.ts +104 -0
- package/src/resources/extensions/gsd/auto-dashboard.ts +2 -0
- package/src/resources/extensions/gsd/auto-dispatch.ts +51 -2
- package/src/resources/extensions/gsd/auto-prompts.ts +73 -0
- package/src/resources/extensions/gsd/auto-recovery.ts +51 -2
- package/src/resources/extensions/gsd/auto-worktree.ts +15 -3
- package/src/resources/extensions/gsd/auto.ts +560 -52
- package/src/resources/extensions/gsd/captures.ts +49 -0
- package/src/resources/extensions/gsd/commands.ts +194 -11
- package/src/resources/extensions/gsd/complexity.ts +1 -0
- package/src/resources/extensions/gsd/dashboard-overlay.ts +54 -2
- package/src/resources/extensions/gsd/diff-context.ts +73 -80
- package/src/resources/extensions/gsd/doctor.ts +76 -12
- package/src/resources/extensions/gsd/exit-command.ts +2 -2
- package/src/resources/extensions/gsd/forensics.ts +95 -52
- package/src/resources/extensions/gsd/gitignore.ts +1 -0
- package/src/resources/extensions/gsd/guided-flow.ts +85 -5
- package/src/resources/extensions/gsd/index.ts +34 -1
- package/src/resources/extensions/gsd/mcp-server.ts +33 -12
- package/src/resources/extensions/gsd/parallel-eligibility.ts +233 -0
- package/src/resources/extensions/gsd/parallel-merge.ts +156 -0
- package/src/resources/extensions/gsd/parallel-orchestrator.ts +496 -0
- package/src/resources/extensions/gsd/post-unit-hooks.ts +2 -1
- package/src/resources/extensions/gsd/preferences.ts +65 -1
- package/src/resources/extensions/gsd/prompts/discuss-headless.md +86 -0
- package/src/resources/extensions/gsd/prompts/execute-task.md +5 -0
- package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +104 -1
- package/src/resources/extensions/gsd/prompts/plan-milestone.md +1 -0
- package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
- package/src/resources/extensions/gsd/prompts/system.md +2 -1
- package/src/resources/extensions/gsd/prompts/validate-milestone.md +70 -0
- package/src/resources/extensions/gsd/provider-error-pause.ts +29 -2
- package/src/resources/extensions/gsd/roadmap-slices.ts +41 -1
- package/src/resources/extensions/gsd/session-forensics.ts +36 -2
- package/src/resources/extensions/gsd/session-status-io.ts +197 -0
- package/src/resources/extensions/gsd/state.ts +72 -30
- package/src/resources/extensions/gsd/templates/milestone-validation.md +62 -0
- package/src/resources/extensions/gsd/tests/agent-end-provider-error.test.ts +81 -0
- package/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +20 -3
- package/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts +186 -0
- package/src/resources/extensions/gsd/tests/auto-preflight.test.ts +1 -0
- package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +264 -0
- package/src/resources/extensions/gsd/tests/auto-skip-loop.test.ts +123 -0
- package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +34 -0
- package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +8 -1
- package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +9 -15
- package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +9 -0
- package/src/resources/extensions/gsd/tests/derive-state-draft.test.ts +8 -0
- package/src/resources/extensions/gsd/tests/derive-state.test.ts +14 -0
- package/src/resources/extensions/gsd/tests/doctor.test.ts +58 -0
- package/src/resources/extensions/gsd/tests/in-flight-tool-tracking.test.ts +17 -6
- package/src/resources/extensions/gsd/tests/integration/headless-command.ts +534 -0
- package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +8 -0
- package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +5 -5
- package/src/resources/extensions/gsd/tests/parallel-orchestration.test.ts +656 -0
- package/src/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +354 -0
- package/src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +1 -0
- package/src/resources/extensions/gsd/tests/roadmap-slices.test.ts +43 -1
- package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +120 -0
- package/src/resources/extensions/gsd/tests/triage-resolution.test.ts +203 -2
- package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +316 -0
- package/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts +8 -3
- package/src/resources/extensions/gsd/tests/worker-registry.test.ts +148 -0
- package/src/resources/extensions/gsd/triage-resolution.ts +83 -0
- package/src/resources/extensions/gsd/types.ts +15 -1
- package/src/resources/extensions/gsd/visualizer-overlay.ts +8 -1
- package/src/resources/extensions/gsd/workspace-index.ts +34 -6
- package/src/resources/extensions/subagent/index.ts +5 -0
- package/src/resources/extensions/subagent/worker-registry.ts +99 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@gsd/pi-coding-agent";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import type { ToolDeps } from "../state.js";
|
|
4
|
+
import { getActionTimeline } from "../state.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Test code generation — transform recorded browser session into a Playwright test script.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export function registerCodegenTools(pi: ExtensionAPI, deps: ToolDeps): void {
|
|
11
|
+
pi.registerTool({
|
|
12
|
+
name: "browser_generate_test",
|
|
13
|
+
label: "Browser Generate Test",
|
|
14
|
+
description:
|
|
15
|
+
"Generate a runnable Playwright test script from the recorded action timeline. " +
|
|
16
|
+
"Transforms navigation, click, type, and assertion actions into standard Playwright test syntax. " +
|
|
17
|
+
"Uses stable selectors (role-based preferred). Writes the test file to a configurable path.",
|
|
18
|
+
parameters: Type.Object({
|
|
19
|
+
name: Type.Optional(
|
|
20
|
+
Type.String({ description: "Test name (used for describe/test block and filename). Default: 'recorded-session'." }),
|
|
21
|
+
),
|
|
22
|
+
outputPath: Type.Optional(
|
|
23
|
+
Type.String({
|
|
24
|
+
description:
|
|
25
|
+
"Output file path for the generated test. Default: writes to session artifacts directory. " +
|
|
26
|
+
"Use a path ending in .spec.ts for standard Playwright test convention.",
|
|
27
|
+
}),
|
|
28
|
+
),
|
|
29
|
+
includeAssertions: Type.Optional(
|
|
30
|
+
Type.Boolean({ description: "Include assertion steps from the timeline (default: true)." }),
|
|
31
|
+
),
|
|
32
|
+
}),
|
|
33
|
+
|
|
34
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
35
|
+
try {
|
|
36
|
+
await deps.ensureBrowser();
|
|
37
|
+
const timeline = getActionTimeline();
|
|
38
|
+
|
|
39
|
+
if (timeline.entries.length === 0) {
|
|
40
|
+
return {
|
|
41
|
+
content: [{ type: "text", text: "No actions recorded in the current session. Interact with pages first, then generate a test." }],
|
|
42
|
+
details: { error: "no_actions" },
|
|
43
|
+
isError: true,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const testName = params.name ?? "recorded-session";
|
|
48
|
+
const includeAssertions = params.includeAssertions ?? true;
|
|
49
|
+
|
|
50
|
+
// Transform timeline entries into Playwright test code
|
|
51
|
+
const testLines: string[] = [];
|
|
52
|
+
const imports = new Set<string>();
|
|
53
|
+
imports.add("test");
|
|
54
|
+
imports.add("expect");
|
|
55
|
+
|
|
56
|
+
testLines.push(`test.describe('${escapeString(testName)}', () => {`);
|
|
57
|
+
testLines.push(` test('recorded session', async ({ page }) => {`);
|
|
58
|
+
|
|
59
|
+
let lastUrl = "";
|
|
60
|
+
let actionCount = 0;
|
|
61
|
+
|
|
62
|
+
for (const entry of timeline.entries) {
|
|
63
|
+
if (entry.status === "error" && entry.tool !== "browser_assert") continue;
|
|
64
|
+
|
|
65
|
+
const params = parseParamsSummary(entry.paramsSummary);
|
|
66
|
+
|
|
67
|
+
switch (entry.tool) {
|
|
68
|
+
case "browser_navigate": {
|
|
69
|
+
const url = params.url;
|
|
70
|
+
if (url && url !== lastUrl) {
|
|
71
|
+
testLines.push(` await page.goto(${quote(url)});`);
|
|
72
|
+
lastUrl = url;
|
|
73
|
+
actionCount++;
|
|
74
|
+
}
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
case "browser_click": {
|
|
79
|
+
const selector = params.selector;
|
|
80
|
+
if (selector) {
|
|
81
|
+
testLines.push(` await page.locator(${quote(selector)}).click();`);
|
|
82
|
+
actionCount++;
|
|
83
|
+
}
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
case "browser_click_ref": {
|
|
88
|
+
// Refs are session-specific — add comment
|
|
89
|
+
testLines.push(` // browser_click_ref: ${entry.paramsSummary} — replace with stable selector`);
|
|
90
|
+
actionCount++;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
case "browser_type": {
|
|
95
|
+
const selector = params.selector;
|
|
96
|
+
const text = params.text;
|
|
97
|
+
if (selector && text) {
|
|
98
|
+
testLines.push(` await page.locator(${quote(selector)}).fill(${quote(text)});`);
|
|
99
|
+
actionCount++;
|
|
100
|
+
}
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
case "browser_fill_ref": {
|
|
105
|
+
testLines.push(` // browser_fill_ref: ${entry.paramsSummary} — replace with stable selector`);
|
|
106
|
+
actionCount++;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
case "browser_key_press": {
|
|
111
|
+
const key = params.key;
|
|
112
|
+
if (key) {
|
|
113
|
+
testLines.push(` await page.keyboard.press(${quote(key)});`);
|
|
114
|
+
actionCount++;
|
|
115
|
+
}
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
case "browser_select_option": {
|
|
120
|
+
const selector = params.selector;
|
|
121
|
+
const option = params.option;
|
|
122
|
+
if (selector && option) {
|
|
123
|
+
testLines.push(` await page.locator(${quote(selector)}).selectOption(${quote(option)});`);
|
|
124
|
+
actionCount++;
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
case "browser_set_checked": {
|
|
130
|
+
const selector = params.selector;
|
|
131
|
+
const checked = params.checked;
|
|
132
|
+
if (selector) {
|
|
133
|
+
testLines.push(` await page.locator(${quote(selector)}).setChecked(${checked === "true"});`);
|
|
134
|
+
actionCount++;
|
|
135
|
+
}
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
case "browser_hover": {
|
|
140
|
+
const selector = params.selector;
|
|
141
|
+
if (selector) {
|
|
142
|
+
testLines.push(` await page.locator(${quote(selector)}).hover();`);
|
|
143
|
+
actionCount++;
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
case "browser_wait_for": {
|
|
149
|
+
const condition = params.condition;
|
|
150
|
+
const value = params.value;
|
|
151
|
+
if (condition === "selector_visible" && value) {
|
|
152
|
+
testLines.push(` await expect(page.locator(${quote(value)})).toBeVisible();`);
|
|
153
|
+
actionCount++;
|
|
154
|
+
} else if (condition === "text_visible" && value) {
|
|
155
|
+
testLines.push(` await expect(page.locator('body')).toContainText(${quote(value)});`);
|
|
156
|
+
actionCount++;
|
|
157
|
+
} else if (condition === "url_contains" && value) {
|
|
158
|
+
testLines.push(` await page.waitForURL(${quote(`**/*${value}*`)});`);
|
|
159
|
+
actionCount++;
|
|
160
|
+
} else if (condition === "network_idle") {
|
|
161
|
+
testLines.push(` await page.waitForLoadState('networkidle');`);
|
|
162
|
+
actionCount++;
|
|
163
|
+
} else if (condition === "delay" && value) {
|
|
164
|
+
testLines.push(` await page.waitForTimeout(${value});`);
|
|
165
|
+
actionCount++;
|
|
166
|
+
}
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
case "browser_assert": {
|
|
171
|
+
if (!includeAssertions) break;
|
|
172
|
+
// The assertion details are in verificationSummary
|
|
173
|
+
if (entry.verificationSummary) {
|
|
174
|
+
testLines.push(` // Assertion: ${entry.verificationSummary}`);
|
|
175
|
+
}
|
|
176
|
+
actionCount++;
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
case "browser_scroll": {
|
|
181
|
+
const direction = params.direction;
|
|
182
|
+
const amount = params.amount ?? "300";
|
|
183
|
+
const delta = direction === "up" ? `-${amount}` : amount;
|
|
184
|
+
testLines.push(` await page.mouse.wheel(0, ${delta});`);
|
|
185
|
+
actionCount++;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
case "browser_set_viewport": {
|
|
190
|
+
const width = params.width;
|
|
191
|
+
const height = params.height;
|
|
192
|
+
if (width && height) {
|
|
193
|
+
testLines.push(` await page.setViewportSize({ width: ${width}, height: ${height} });`);
|
|
194
|
+
actionCount++;
|
|
195
|
+
}
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
default:
|
|
200
|
+
// Skip tools that don't map to Playwright test actions
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
testLines.push(` });`);
|
|
206
|
+
testLines.push(`});`);
|
|
207
|
+
|
|
208
|
+
const importLine = `import { ${[...imports].join(", ")} } from '@playwright/test';`;
|
|
209
|
+
const fullTest = `${importLine}\n\n${testLines.join("\n")}\n`;
|
|
210
|
+
|
|
211
|
+
// Write to file
|
|
212
|
+
let outputPath: string;
|
|
213
|
+
if (params.outputPath) {
|
|
214
|
+
outputPath = params.outputPath;
|
|
215
|
+
} else {
|
|
216
|
+
const safeName = deps.sanitizeArtifactName(testName, "recorded-session");
|
|
217
|
+
outputPath = deps.buildSessionArtifactPath(`${safeName}.spec.ts`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
await deps.ensureSessionArtifactDir();
|
|
221
|
+
const { path: writtenPath, bytes } = await deps.writeArtifactFile(outputPath, fullTest);
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
content: [{
|
|
225
|
+
type: "text",
|
|
226
|
+
text: `Test generated: ${writtenPath}\nActions: ${actionCount}\nTimeline entries processed: ${timeline.entries.length}\n\n${fullTest}`,
|
|
227
|
+
}],
|
|
228
|
+
details: {
|
|
229
|
+
path: writtenPath,
|
|
230
|
+
bytes,
|
|
231
|
+
actionCount,
|
|
232
|
+
timelineEntries: timeline.entries.length,
|
|
233
|
+
testCode: fullTest,
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
} catch (err: any) {
|
|
237
|
+
return {
|
|
238
|
+
content: [{ type: "text", text: `Test generation failed: ${err.message}` }],
|
|
239
|
+
details: { error: err.message },
|
|
240
|
+
isError: true,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function escapeString(s: string): string {
|
|
248
|
+
return s.replace(/'/g, "\\'").replace(/\\/g, "\\\\");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function quote(s: string): string {
|
|
252
|
+
// Use single quotes for simple strings, backtick for those with quotes
|
|
253
|
+
if (!s.includes("'")) return `'${s}'`;
|
|
254
|
+
if (!s.includes("`")) return `\`${s}\``;
|
|
255
|
+
return `'${s.replace(/'/g, "\\'")}'`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Parse the paramsSummary string back into key-value pairs.
|
|
260
|
+
* Format: key="value", key=value, key=[N], key={...}
|
|
261
|
+
*/
|
|
262
|
+
function parseParamsSummary(summary: string): Record<string, string> {
|
|
263
|
+
const result: Record<string, string> = {};
|
|
264
|
+
if (!summary) return result;
|
|
265
|
+
|
|
266
|
+
const regex = /(\w+)=(?:"([^"]*(?:\\"[^"]*)*)"|([^,\s]+))/g;
|
|
267
|
+
let match;
|
|
268
|
+
while ((match = regex.exec(summary)) !== null) {
|
|
269
|
+
const key = match[1];
|
|
270
|
+
const value = match[2] ?? match[3];
|
|
271
|
+
result[key] = value;
|
|
272
|
+
}
|
|
273
|
+
return result;
|
|
274
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@gsd/pi-coding-agent";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import type { ToolDeps } from "../state.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Device emulation tool — full device simulation using Playwright's built-in device descriptors.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export function registerDeviceTools(pi: ExtensionAPI, deps: ToolDeps): void {
|
|
10
|
+
pi.registerTool({
|
|
11
|
+
name: "browser_emulate_device",
|
|
12
|
+
label: "Browser Emulate Device",
|
|
13
|
+
description:
|
|
14
|
+
"Simulate a specific device by setting viewport, user agent, device scale factor, touch, and mobile flag. " +
|
|
15
|
+
"Uses Playwright's built-in device descriptors (~143 devices). Accepts fuzzy matching on device name. " +
|
|
16
|
+
"Note: Full emulation (user agent, isMobile) requires a context restart — the current page state will be lost. " +
|
|
17
|
+
"The tool recreates the context with the device profile applied.",
|
|
18
|
+
parameters: Type.Object({
|
|
19
|
+
device: Type.String({
|
|
20
|
+
description:
|
|
21
|
+
"Device name (e.g., 'iPhone 15', 'Pixel 7', 'iPad Pro 11'). " +
|
|
22
|
+
"Case-insensitive fuzzy matching. Use 'list' to see all available devices.",
|
|
23
|
+
}),
|
|
24
|
+
}),
|
|
25
|
+
|
|
26
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
27
|
+
try {
|
|
28
|
+
const { chromium, devices } = await import("playwright");
|
|
29
|
+
const allDeviceNames = Object.keys(devices);
|
|
30
|
+
|
|
31
|
+
// Handle 'list' request
|
|
32
|
+
if (params.device.toLowerCase() === "list") {
|
|
33
|
+
// Group by base device name (remove landscape variants for cleaner display)
|
|
34
|
+
const baseNames = allDeviceNames.filter((n) => !n.endsWith(" landscape"));
|
|
35
|
+
return {
|
|
36
|
+
content: [{
|
|
37
|
+
type: "text",
|
|
38
|
+
text: `Available devices (${allDeviceNames.length} total, ${baseNames.length} base):\n${baseNames.join("\n")}`,
|
|
39
|
+
}],
|
|
40
|
+
details: { devices: baseNames, total: allDeviceNames.length },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Fuzzy match device name
|
|
45
|
+
const needle = params.device.toLowerCase();
|
|
46
|
+
let exactMatch = allDeviceNames.find((n) => n.toLowerCase() === needle);
|
|
47
|
+
if (!exactMatch) {
|
|
48
|
+
// Try contains match
|
|
49
|
+
const containsMatches = allDeviceNames.filter((n) => n.toLowerCase().includes(needle));
|
|
50
|
+
if (containsMatches.length === 1) {
|
|
51
|
+
exactMatch = containsMatches[0];
|
|
52
|
+
} else if (containsMatches.length > 1) {
|
|
53
|
+
// Pick the shortest match (most specific)
|
|
54
|
+
containsMatches.sort((a, b) => a.length - b.length);
|
|
55
|
+
exactMatch = containsMatches[0];
|
|
56
|
+
const suggestions = containsMatches.slice(0, 5).join(", ");
|
|
57
|
+
// Continue with best match but mention alternatives
|
|
58
|
+
} else {
|
|
59
|
+
// No match at all — suggest closest
|
|
60
|
+
const suggestions = allDeviceNames
|
|
61
|
+
.map((n) => ({ name: n, score: fuzzyScore(needle, n.toLowerCase()) }))
|
|
62
|
+
.sort((a, b) => b.score - a.score)
|
|
63
|
+
.slice(0, 5)
|
|
64
|
+
.map((s) => s.name);
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
content: [{
|
|
68
|
+
type: "text",
|
|
69
|
+
text: `No device matching "${params.device}". Did you mean:\n${suggestions.map((s) => ` - ${s}`).join("\n")}`,
|
|
70
|
+
}],
|
|
71
|
+
details: { error: "no_match", suggestions },
|
|
72
|
+
isError: true,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const deviceDescriptor = devices[exactMatch!];
|
|
78
|
+
if (!deviceDescriptor) {
|
|
79
|
+
return {
|
|
80
|
+
content: [{ type: "text", text: `Device descriptor not found for "${exactMatch}"` }],
|
|
81
|
+
details: { error: "descriptor_not_found" },
|
|
82
|
+
isError: true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Context restart required for full emulation.
|
|
87
|
+
// Save current URL to navigate back after restart.
|
|
88
|
+
const { page: currentPage, context: currentCtx } = await deps.ensureBrowser();
|
|
89
|
+
const currentUrl = currentPage.url();
|
|
90
|
+
|
|
91
|
+
// Close existing browser and relaunch with device profile
|
|
92
|
+
await deps.closeBrowser();
|
|
93
|
+
|
|
94
|
+
// Re-launch — ensureBrowser doesn't accept device params, so we do it manually.
|
|
95
|
+
// This is a one-off context creation with device emulation.
|
|
96
|
+
const needsHeadless = process.platform === "linux" && !process.env.DISPLAY;
|
|
97
|
+
const launchOptions: Record<string, unknown> = {
|
|
98
|
+
headless: needsHeadless || process.env.FORCE_HEADLESS === "true",
|
|
99
|
+
};
|
|
100
|
+
const customPath = process.env.BROWSER_PATH;
|
|
101
|
+
if (customPath) launchOptions.executablePath = customPath;
|
|
102
|
+
|
|
103
|
+
const browser = await chromium.launch(launchOptions);
|
|
104
|
+
const context = await browser.newContext({
|
|
105
|
+
...deviceDescriptor,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Inject evaluate helpers
|
|
109
|
+
const { EVALUATE_HELPERS_SOURCE } = await import("../evaluate-helpers.js");
|
|
110
|
+
await context.addInitScript(EVALUATE_HELPERS_SOURCE);
|
|
111
|
+
|
|
112
|
+
// Wire up state
|
|
113
|
+
const {
|
|
114
|
+
setBrowser, setContext, pageRegistry, setSessionStartedAt,
|
|
115
|
+
setSessionArtifactDir, resetAllState,
|
|
116
|
+
} = await import("../state.js");
|
|
117
|
+
const { registryAddPage, registrySetActive } = await import("../core.js");
|
|
118
|
+
|
|
119
|
+
// Reset state for new session
|
|
120
|
+
resetAllState();
|
|
121
|
+
setBrowser(browser);
|
|
122
|
+
setContext(context);
|
|
123
|
+
setSessionStartedAt(Date.now());
|
|
124
|
+
|
|
125
|
+
const page = await context.newPage();
|
|
126
|
+
const entry = registryAddPage(pageRegistry, {
|
|
127
|
+
page,
|
|
128
|
+
title: "",
|
|
129
|
+
url: "about:blank",
|
|
130
|
+
opener: null,
|
|
131
|
+
});
|
|
132
|
+
registrySetActive(pageRegistry, entry.id);
|
|
133
|
+
deps.attachPageListeners(page, entry.id);
|
|
134
|
+
|
|
135
|
+
// Navigate back to previous URL if it wasn't about:blank
|
|
136
|
+
if (currentUrl && currentUrl !== "about:blank") {
|
|
137
|
+
await page.goto(currentUrl, { waitUntil: "domcontentloaded", timeout: 15000 }).catch(() => {});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const viewport = deviceDescriptor.viewport;
|
|
141
|
+
const vpText = viewport ? `${viewport.width}x${viewport.height}` : "unknown";
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
content: [{
|
|
145
|
+
type: "text",
|
|
146
|
+
text: `Device emulation active: ${exactMatch}\nViewport: ${vpText}\nUser Agent: ${deviceDescriptor.userAgent?.slice(0, 80) ?? "default"}...\nMobile: ${deviceDescriptor.isMobile ?? false}\nTouch: ${deviceDescriptor.hasTouch ?? false}\nScale Factor: ${deviceDescriptor.deviceScaleFactor ?? 1}\n\nContext was restarted for full emulation. Page state was reset.`,
|
|
147
|
+
}],
|
|
148
|
+
details: {
|
|
149
|
+
device: exactMatch,
|
|
150
|
+
viewport: vpText,
|
|
151
|
+
isMobile: deviceDescriptor.isMobile ?? false,
|
|
152
|
+
hasTouch: deviceDescriptor.hasTouch ?? false,
|
|
153
|
+
deviceScaleFactor: deviceDescriptor.deviceScaleFactor ?? 1,
|
|
154
|
+
userAgent: deviceDescriptor.userAgent,
|
|
155
|
+
restoredUrl: currentUrl,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
} catch (err: any) {
|
|
159
|
+
return {
|
|
160
|
+
content: [{ type: "text", text: `Device emulation failed: ${err.message}` }],
|
|
161
|
+
details: { error: err.message },
|
|
162
|
+
isError: true,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Simple fuzzy scoring — counts matching characters in order.
|
|
171
|
+
*/
|
|
172
|
+
function fuzzyScore(needle: string, haystack: string): number {
|
|
173
|
+
let score = 0;
|
|
174
|
+
let hi = 0;
|
|
175
|
+
for (let ni = 0; ni < needle.length && hi < haystack.length; ni++) {
|
|
176
|
+
const idx = haystack.indexOf(needle[ni], hi);
|
|
177
|
+
if (idx >= 0) {
|
|
178
|
+
score++;
|
|
179
|
+
hi = idx + 1;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return score / Math.max(needle.length, 1);
|
|
183
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@gsd/pi-coding-agent";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import type { ToolDeps } from "../state.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Structured data extraction with JSON Schema validation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export function registerExtractTools(pi: ExtensionAPI, deps: ToolDeps): void {
|
|
10
|
+
pi.registerTool({
|
|
11
|
+
name: "browser_extract",
|
|
12
|
+
label: "Browser Extract",
|
|
13
|
+
description:
|
|
14
|
+
"Extract structured data from the current page using CSS selectors and validate against a JSON Schema. " +
|
|
15
|
+
"Provide a schema describing the shape of data you want. The tool extracts data by evaluating " +
|
|
16
|
+
"CSS selectors in the page context, then validates the result against your schema. " +
|
|
17
|
+
"Supports extracting single objects or arrays of items. Waits for network idle before extraction.",
|
|
18
|
+
parameters: Type.Object({
|
|
19
|
+
schema: Type.Record(Type.String(), Type.Unknown(), {
|
|
20
|
+
description:
|
|
21
|
+
"JSON Schema describing the data shape to extract. Properties should include " +
|
|
22
|
+
"'_selector' (CSS selector) and '_attribute' (attribute to read, default: 'textContent') hints. " +
|
|
23
|
+
"Example: { type: 'object', properties: { title: { _selector: 'h1', _attribute: 'textContent' }, price: { _selector: '.price', _attribute: 'textContent' } } }",
|
|
24
|
+
}),
|
|
25
|
+
selector: Type.Optional(
|
|
26
|
+
Type.String({ description: "CSS selector to scope extraction to a specific container element." }),
|
|
27
|
+
),
|
|
28
|
+
multiple: Type.Optional(
|
|
29
|
+
Type.Boolean({
|
|
30
|
+
description:
|
|
31
|
+
"If true, extract an array of items. The 'selector' parameter becomes the item container selector, " +
|
|
32
|
+
"and schema properties are extracted relative to each matched container.",
|
|
33
|
+
}),
|
|
34
|
+
),
|
|
35
|
+
}),
|
|
36
|
+
|
|
37
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
38
|
+
try {
|
|
39
|
+
const { page: p } = await deps.ensureBrowser();
|
|
40
|
+
|
|
41
|
+
// Wait for network idle before extraction
|
|
42
|
+
await p.waitForLoadState("networkidle", { timeout: 10000 }).catch(() => {});
|
|
43
|
+
|
|
44
|
+
const schema = params.schema as any;
|
|
45
|
+
const scopeSelector = params.selector;
|
|
46
|
+
const multiple = params.multiple ?? false;
|
|
47
|
+
|
|
48
|
+
// Build extraction plan from schema
|
|
49
|
+
const extractionPlan = buildExtractionPlan(schema);
|
|
50
|
+
|
|
51
|
+
// Execute extraction in page context
|
|
52
|
+
const rawData = await p.evaluate(
|
|
53
|
+
({ plan, scope, multi }: { plan: ExtractionField[]; scope: string | undefined; multi: boolean }) => {
|
|
54
|
+
function extractFromContainer(container: Element, fields: typeof plan): Record<string, unknown> {
|
|
55
|
+
const result: Record<string, unknown> = {};
|
|
56
|
+
for (const field of fields) {
|
|
57
|
+
const el = container.querySelector(field.selector);
|
|
58
|
+
if (!el) {
|
|
59
|
+
result[field.name] = null;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
let value: unknown;
|
|
63
|
+
switch (field.attribute) {
|
|
64
|
+
case "textContent":
|
|
65
|
+
value = (el.textContent ?? "").trim();
|
|
66
|
+
break;
|
|
67
|
+
case "innerText":
|
|
68
|
+
value = ((el as HTMLElement).innerText ?? "").trim();
|
|
69
|
+
break;
|
|
70
|
+
case "innerHTML":
|
|
71
|
+
value = el.innerHTML;
|
|
72
|
+
break;
|
|
73
|
+
case "href":
|
|
74
|
+
value = (el as HTMLAnchorElement).href ?? el.getAttribute("href");
|
|
75
|
+
break;
|
|
76
|
+
case "src":
|
|
77
|
+
value = (el as HTMLImageElement).src ?? el.getAttribute("src");
|
|
78
|
+
break;
|
|
79
|
+
case "value":
|
|
80
|
+
value = (el as HTMLInputElement).value;
|
|
81
|
+
break;
|
|
82
|
+
default:
|
|
83
|
+
value = el.getAttribute(field.attribute) ?? (el.textContent ?? "").trim();
|
|
84
|
+
}
|
|
85
|
+
// Type coercion
|
|
86
|
+
if (field.type === "number" && typeof value === "string") {
|
|
87
|
+
const num = parseFloat(value.replace(/[^0-9.-]/g, ""));
|
|
88
|
+
value = isNaN(num) ? value : num;
|
|
89
|
+
} else if (field.type === "boolean" && typeof value === "string") {
|
|
90
|
+
value = value.toLowerCase() === "true" || value === "1";
|
|
91
|
+
}
|
|
92
|
+
result[field.name] = value;
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const root = scope ? document.querySelector(scope) : document.body;
|
|
98
|
+
if (!root) return { data: null, error: `Scope selector "${scope}" not found` };
|
|
99
|
+
|
|
100
|
+
if (multi) {
|
|
101
|
+
// For multiple items, scope is the item selector
|
|
102
|
+
const containers = scope
|
|
103
|
+
? document.querySelectorAll(scope)
|
|
104
|
+
: [document.body];
|
|
105
|
+
const items = Array.from(containers).map((container) =>
|
|
106
|
+
extractFromContainer(container, plan),
|
|
107
|
+
);
|
|
108
|
+
return { data: items, error: null };
|
|
109
|
+
} else {
|
|
110
|
+
return { data: extractFromContainer(root, plan), error: null };
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
{ plan: extractionPlan, scope: scopeSelector, multi: multiple },
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
if (rawData.error) {
|
|
117
|
+
return {
|
|
118
|
+
content: [{ type: "text", text: `Extraction failed: ${rawData.error}` }],
|
|
119
|
+
details: { error: rawData.error },
|
|
120
|
+
isError: true,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Validate against schema using ajv
|
|
125
|
+
const validationErrors = await validateData(rawData.data, schema, multiple);
|
|
126
|
+
|
|
127
|
+
const resultText = JSON.stringify(rawData.data, null, 2);
|
|
128
|
+
const truncated = resultText.length > 4000 ? resultText.slice(0, 4000) + "\n...(truncated)" : resultText;
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
content: [{
|
|
132
|
+
type: "text",
|
|
133
|
+
text: validationErrors.length > 0
|
|
134
|
+
? `Extracted data (with ${validationErrors.length} validation warning(s)):\n${truncated}\n\nValidation warnings:\n${validationErrors.join("\n")}`
|
|
135
|
+
: `Extracted data:\n${truncated}`,
|
|
136
|
+
}],
|
|
137
|
+
details: {
|
|
138
|
+
data: rawData.data,
|
|
139
|
+
validationErrors: validationErrors.length > 0 ? validationErrors : undefined,
|
|
140
|
+
fieldCount: extractionPlan.length,
|
|
141
|
+
itemCount: multiple ? (rawData.data as any[])?.length ?? 0 : 1,
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
} catch (err: any) {
|
|
145
|
+
return {
|
|
146
|
+
content: [{ type: "text", text: `Extraction failed: ${err.message}` }],
|
|
147
|
+
details: { error: err.message },
|
|
148
|
+
isError: true,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface ExtractionField {
|
|
156
|
+
name: string;
|
|
157
|
+
selector: string;
|
|
158
|
+
attribute: string;
|
|
159
|
+
type: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function buildExtractionPlan(schema: any): ExtractionField[] {
|
|
163
|
+
const fields: ExtractionField[] = [];
|
|
164
|
+
|
|
165
|
+
if (!schema || typeof schema !== "object") return fields;
|
|
166
|
+
|
|
167
|
+
const properties = schema.properties ?? schema;
|
|
168
|
+
|
|
169
|
+
for (const [name, propSchema] of Object.entries(properties)) {
|
|
170
|
+
const prop = propSchema as any;
|
|
171
|
+
if (!prop || typeof prop !== "object") continue;
|
|
172
|
+
|
|
173
|
+
// Skip meta fields
|
|
174
|
+
if (name === "type" || name === "required" || name === "properties" || name === "$schema") continue;
|
|
175
|
+
|
|
176
|
+
const selector = prop._selector ?? prop.selector ?? `[data-field="${name}"], .${name}, #${name}`;
|
|
177
|
+
const attribute = prop._attribute ?? prop.attribute ?? "textContent";
|
|
178
|
+
const type = prop.type ?? "string";
|
|
179
|
+
|
|
180
|
+
fields.push({ name, selector, attribute, type });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return fields;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function validateData(data: unknown, schema: any, isArray: boolean): Promise<string[]> {
|
|
187
|
+
const errors: string[] = [];
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const ajvModule = await import("ajv");
|
|
191
|
+
const Ajv = ajvModule.default ?? ajvModule;
|
|
192
|
+
const ajv = new (Ajv as any)({ allErrors: true, strict: false });
|
|
193
|
+
|
|
194
|
+
// Clean schema — remove our custom _selector/_attribute hints before validation
|
|
195
|
+
const cleanSchema = cleanSchemaForValidation(schema);
|
|
196
|
+
|
|
197
|
+
// Wrap in array schema if multiple
|
|
198
|
+
const validationSchema = isArray
|
|
199
|
+
? { type: "array", items: cleanSchema }
|
|
200
|
+
: cleanSchema;
|
|
201
|
+
|
|
202
|
+
const validate = ajv.compile(validationSchema);
|
|
203
|
+
const valid = validate(data);
|
|
204
|
+
|
|
205
|
+
if (!valid && validate.errors) {
|
|
206
|
+
for (const err of validate.errors) {
|
|
207
|
+
errors.push(`${err.instancePath || "/"}: ${err.message}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} catch (err: any) {
|
|
211
|
+
errors.push(`Schema validation setup failed: ${err.message}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return errors;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function cleanSchemaForValidation(schema: any): any {
|
|
218
|
+
if (!schema || typeof schema !== "object") return schema;
|
|
219
|
+
if (Array.isArray(schema)) return schema.map(cleanSchemaForValidation);
|
|
220
|
+
|
|
221
|
+
const cleaned: any = {};
|
|
222
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
223
|
+
if (key.startsWith("_")) continue; // Remove our custom hints
|
|
224
|
+
if (key === "selector" && typeof value === "string") continue; // Also remove plain 'selector'
|
|
225
|
+
if (key === "attribute" && typeof value === "string") continue; // Also remove plain 'attribute'
|
|
226
|
+
cleaned[key] = cleanSchemaForValidation(value);
|
|
227
|
+
}
|
|
228
|
+
return cleaned;
|
|
229
|
+
}
|