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.
Files changed (228) hide show
  1. package/README.md +25 -1
  2. package/dist/cli.js +74 -7
  3. package/dist/headless.d.ts +25 -0
  4. package/dist/headless.js +454 -0
  5. package/dist/help-text.js +47 -0
  6. package/dist/mcp-server.d.ts +20 -3
  7. package/dist/mcp-server.js +21 -1
  8. package/dist/models-resolver.d.ts +32 -0
  9. package/dist/models-resolver.js +50 -0
  10. package/dist/resource-loader.js +64 -9
  11. package/dist/resources/extensions/bg-shell/output-formatter.ts +36 -16
  12. package/dist/resources/extensions/bg-shell/process-manager.ts +6 -4
  13. package/dist/resources/extensions/bg-shell/types.ts +33 -1
  14. package/dist/resources/extensions/browser-tools/capture.ts +18 -16
  15. package/dist/resources/extensions/browser-tools/index.ts +20 -0
  16. package/dist/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs +25 -0
  17. package/dist/resources/extensions/browser-tools/tools/action-cache.ts +216 -0
  18. package/dist/resources/extensions/browser-tools/tools/codegen.ts +274 -0
  19. package/dist/resources/extensions/browser-tools/tools/device.ts +183 -0
  20. package/dist/resources/extensions/browser-tools/tools/extract.ts +229 -0
  21. package/dist/resources/extensions/browser-tools/tools/injection-detect.ts +221 -0
  22. package/dist/resources/extensions/browser-tools/tools/network-mock.ts +244 -0
  23. package/dist/resources/extensions/browser-tools/tools/pdf.ts +92 -0
  24. package/dist/resources/extensions/browser-tools/tools/state-persistence.ts +202 -0
  25. package/dist/resources/extensions/browser-tools/tools/visual-diff.ts +209 -0
  26. package/dist/resources/extensions/browser-tools/tools/zoom.ts +104 -0
  27. package/dist/resources/extensions/gsd/auto-dashboard.ts +2 -0
  28. package/dist/resources/extensions/gsd/auto-dispatch.ts +51 -2
  29. package/dist/resources/extensions/gsd/auto-prompts.ts +73 -0
  30. package/dist/resources/extensions/gsd/auto-recovery.ts +51 -2
  31. package/dist/resources/extensions/gsd/auto-worktree.ts +15 -3
  32. package/dist/resources/extensions/gsd/auto.ts +560 -52
  33. package/dist/resources/extensions/gsd/captures.ts +49 -0
  34. package/dist/resources/extensions/gsd/commands.ts +194 -11
  35. package/dist/resources/extensions/gsd/complexity.ts +1 -0
  36. package/dist/resources/extensions/gsd/dashboard-overlay.ts +54 -2
  37. package/dist/resources/extensions/gsd/diff-context.ts +73 -80
  38. package/dist/resources/extensions/gsd/doctor.ts +76 -12
  39. package/dist/resources/extensions/gsd/exit-command.ts +2 -2
  40. package/dist/resources/extensions/gsd/forensics.ts +95 -52
  41. package/dist/resources/extensions/gsd/gitignore.ts +1 -0
  42. package/dist/resources/extensions/gsd/guided-flow.ts +85 -5
  43. package/dist/resources/extensions/gsd/index.ts +34 -1
  44. package/dist/resources/extensions/gsd/mcp-server.ts +33 -12
  45. package/dist/resources/extensions/gsd/parallel-eligibility.ts +233 -0
  46. package/dist/resources/extensions/gsd/parallel-merge.ts +156 -0
  47. package/dist/resources/extensions/gsd/parallel-orchestrator.ts +496 -0
  48. package/dist/resources/extensions/gsd/post-unit-hooks.ts +2 -1
  49. package/dist/resources/extensions/gsd/preferences.ts +65 -1
  50. package/dist/resources/extensions/gsd/prompts/discuss-headless.md +86 -0
  51. package/dist/resources/extensions/gsd/prompts/execute-task.md +5 -0
  52. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +104 -1
  53. package/dist/resources/extensions/gsd/prompts/plan-milestone.md +1 -0
  54. package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
  55. package/dist/resources/extensions/gsd/prompts/system.md +2 -1
  56. package/dist/resources/extensions/gsd/prompts/validate-milestone.md +70 -0
  57. package/dist/resources/extensions/gsd/provider-error-pause.ts +29 -2
  58. package/dist/resources/extensions/gsd/roadmap-slices.ts +41 -1
  59. package/dist/resources/extensions/gsd/session-forensics.ts +36 -2
  60. package/dist/resources/extensions/gsd/session-status-io.ts +197 -0
  61. package/dist/resources/extensions/gsd/state.ts +72 -30
  62. package/dist/resources/extensions/gsd/templates/milestone-validation.md +62 -0
  63. package/dist/resources/extensions/gsd/tests/agent-end-provider-error.test.ts +81 -0
  64. package/dist/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +20 -3
  65. package/dist/resources/extensions/gsd/tests/auto-lock-creation.test.ts +186 -0
  66. package/dist/resources/extensions/gsd/tests/auto-preflight.test.ts +1 -0
  67. package/dist/resources/extensions/gsd/tests/auto-recovery.test.ts +264 -0
  68. package/dist/resources/extensions/gsd/tests/auto-skip-loop.test.ts +123 -0
  69. package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +34 -0
  70. package/dist/resources/extensions/gsd/tests/complete-milestone.test.ts +8 -1
  71. package/dist/resources/extensions/gsd/tests/derive-state-db.test.ts +9 -15
  72. package/dist/resources/extensions/gsd/tests/derive-state-deps.test.ts +9 -0
  73. package/dist/resources/extensions/gsd/tests/derive-state-draft.test.ts +8 -0
  74. package/dist/resources/extensions/gsd/tests/derive-state.test.ts +14 -0
  75. package/dist/resources/extensions/gsd/tests/doctor.test.ts +58 -0
  76. package/dist/resources/extensions/gsd/tests/in-flight-tool-tracking.test.ts +17 -6
  77. package/dist/resources/extensions/gsd/tests/integration/headless-command.ts +534 -0
  78. package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +8 -0
  79. package/dist/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +5 -5
  80. package/dist/resources/extensions/gsd/tests/parallel-orchestration.test.ts +656 -0
  81. package/dist/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +354 -0
  82. package/dist/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +1 -0
  83. package/dist/resources/extensions/gsd/tests/roadmap-slices.test.ts +43 -1
  84. package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +120 -0
  85. package/dist/resources/extensions/gsd/tests/triage-resolution.test.ts +203 -2
  86. package/dist/resources/extensions/gsd/tests/validate-milestone.test.ts +316 -0
  87. package/dist/resources/extensions/gsd/tests/visualizer-overlay.test.ts +8 -3
  88. package/dist/resources/extensions/gsd/tests/worker-registry.test.ts +148 -0
  89. package/dist/resources/extensions/gsd/triage-resolution.ts +83 -0
  90. package/dist/resources/extensions/gsd/types.ts +15 -1
  91. package/dist/resources/extensions/gsd/visualizer-overlay.ts +8 -1
  92. package/dist/resources/extensions/gsd/workspace-index.ts +34 -6
  93. package/dist/resources/extensions/subagent/index.ts +5 -0
  94. package/dist/resources/extensions/subagent/worker-registry.ts +99 -0
  95. package/dist/update-check.d.ts +9 -0
  96. package/dist/update-check.js +97 -0
  97. package/package.json +6 -1
  98. package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
  99. package/packages/pi-ai/dist/providers/anthropic.js +16 -7
  100. package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
  101. package/packages/pi-ai/dist/providers/azure-openai-responses.d.ts.map +1 -1
  102. package/packages/pi-ai/dist/providers/azure-openai-responses.js +12 -4
  103. package/packages/pi-ai/dist/providers/azure-openai-responses.js.map +1 -1
  104. package/packages/pi-ai/dist/providers/google-vertex.d.ts.map +1 -1
  105. package/packages/pi-ai/dist/providers/google-vertex.js +21 -9
  106. package/packages/pi-ai/dist/providers/google-vertex.js.map +1 -1
  107. package/packages/pi-ai/dist/providers/openai-completions.d.ts.map +1 -1
  108. package/packages/pi-ai/dist/providers/openai-completions.js +12 -4
  109. package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
  110. package/packages/pi-ai/dist/providers/openai-responses.d.ts.map +1 -1
  111. package/packages/pi-ai/dist/providers/openai-responses.js +12 -4
  112. package/packages/pi-ai/dist/providers/openai-responses.js.map +1 -1
  113. package/packages/pi-ai/src/providers/anthropic.ts +21 -8
  114. package/packages/pi-ai/src/providers/azure-openai-responses.ts +16 -4
  115. package/packages/pi-ai/src/providers/google-vertex.ts +32 -17
  116. package/packages/pi-ai/src/providers/openai-completions.ts +16 -4
  117. package/packages/pi-ai/src/providers/openai-responses.ts +16 -4
  118. package/packages/pi-coding-agent/dist/core/agent-session.js +1 -1
  119. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  120. package/packages/pi-coding-agent/dist/core/settings-manager.js +1 -1
  121. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  122. package/packages/pi-coding-agent/dist/core/tools/bash-background.test.d.ts +10 -0
  123. package/packages/pi-coding-agent/dist/core/tools/bash-background.test.d.ts.map +1 -0
  124. package/packages/pi-coding-agent/dist/core/tools/bash-background.test.js +79 -0
  125. package/packages/pi-coding-agent/dist/core/tools/bash-background.test.js.map +1 -0
  126. package/packages/pi-coding-agent/dist/core/tools/bash.d.ts +18 -0
  127. package/packages/pi-coding-agent/dist/core/tools/bash.d.ts.map +1 -1
  128. package/packages/pi-coding-agent/dist/core/tools/bash.js +77 -1
  129. package/packages/pi-coding-agent/dist/core/tools/bash.js.map +1 -1
  130. package/packages/pi-coding-agent/dist/core/tools/index.d.ts +1 -1
  131. package/packages/pi-coding-agent/dist/core/tools/index.d.ts.map +1 -1
  132. package/packages/pi-coding-agent/dist/core/tools/index.js +1 -1
  133. package/packages/pi-coding-agent/dist/core/tools/index.js.map +1 -1
  134. package/packages/pi-coding-agent/dist/index.d.ts +1 -1
  135. package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
  136. package/packages/pi-coding-agent/dist/index.js +1 -1
  137. package/packages/pi-coding-agent/dist/index.js.map +1 -1
  138. package/packages/pi-coding-agent/src/core/agent-session.ts +1 -1
  139. package/packages/pi-coding-agent/src/core/settings-manager.ts +2 -2
  140. package/packages/pi-coding-agent/src/core/tools/bash-background.test.ts +91 -0
  141. package/packages/pi-coding-agent/src/core/tools/bash.ts +83 -1
  142. package/packages/pi-coding-agent/src/core/tools/index.ts +1 -0
  143. package/packages/pi-coding-agent/src/index.ts +1 -0
  144. package/scripts/postinstall.js +7 -109
  145. package/src/resources/extensions/bg-shell/output-formatter.ts +36 -16
  146. package/src/resources/extensions/bg-shell/process-manager.ts +6 -4
  147. package/src/resources/extensions/bg-shell/types.ts +33 -1
  148. package/src/resources/extensions/browser-tools/capture.ts +18 -16
  149. package/src/resources/extensions/browser-tools/index.ts +20 -0
  150. package/src/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs +25 -0
  151. package/src/resources/extensions/browser-tools/tools/action-cache.ts +216 -0
  152. package/src/resources/extensions/browser-tools/tools/codegen.ts +274 -0
  153. package/src/resources/extensions/browser-tools/tools/device.ts +183 -0
  154. package/src/resources/extensions/browser-tools/tools/extract.ts +229 -0
  155. package/src/resources/extensions/browser-tools/tools/injection-detect.ts +221 -0
  156. package/src/resources/extensions/browser-tools/tools/network-mock.ts +244 -0
  157. package/src/resources/extensions/browser-tools/tools/pdf.ts +92 -0
  158. package/src/resources/extensions/browser-tools/tools/state-persistence.ts +202 -0
  159. package/src/resources/extensions/browser-tools/tools/visual-diff.ts +209 -0
  160. package/src/resources/extensions/browser-tools/tools/zoom.ts +104 -0
  161. package/src/resources/extensions/gsd/auto-dashboard.ts +2 -0
  162. package/src/resources/extensions/gsd/auto-dispatch.ts +51 -2
  163. package/src/resources/extensions/gsd/auto-prompts.ts +73 -0
  164. package/src/resources/extensions/gsd/auto-recovery.ts +51 -2
  165. package/src/resources/extensions/gsd/auto-worktree.ts +15 -3
  166. package/src/resources/extensions/gsd/auto.ts +560 -52
  167. package/src/resources/extensions/gsd/captures.ts +49 -0
  168. package/src/resources/extensions/gsd/commands.ts +194 -11
  169. package/src/resources/extensions/gsd/complexity.ts +1 -0
  170. package/src/resources/extensions/gsd/dashboard-overlay.ts +54 -2
  171. package/src/resources/extensions/gsd/diff-context.ts +73 -80
  172. package/src/resources/extensions/gsd/doctor.ts +76 -12
  173. package/src/resources/extensions/gsd/exit-command.ts +2 -2
  174. package/src/resources/extensions/gsd/forensics.ts +95 -52
  175. package/src/resources/extensions/gsd/gitignore.ts +1 -0
  176. package/src/resources/extensions/gsd/guided-flow.ts +85 -5
  177. package/src/resources/extensions/gsd/index.ts +34 -1
  178. package/src/resources/extensions/gsd/mcp-server.ts +33 -12
  179. package/src/resources/extensions/gsd/parallel-eligibility.ts +233 -0
  180. package/src/resources/extensions/gsd/parallel-merge.ts +156 -0
  181. package/src/resources/extensions/gsd/parallel-orchestrator.ts +496 -0
  182. package/src/resources/extensions/gsd/post-unit-hooks.ts +2 -1
  183. package/src/resources/extensions/gsd/preferences.ts +65 -1
  184. package/src/resources/extensions/gsd/prompts/discuss-headless.md +86 -0
  185. package/src/resources/extensions/gsd/prompts/execute-task.md +5 -0
  186. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +104 -1
  187. package/src/resources/extensions/gsd/prompts/plan-milestone.md +1 -0
  188. package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
  189. package/src/resources/extensions/gsd/prompts/system.md +2 -1
  190. package/src/resources/extensions/gsd/prompts/validate-milestone.md +70 -0
  191. package/src/resources/extensions/gsd/provider-error-pause.ts +29 -2
  192. package/src/resources/extensions/gsd/roadmap-slices.ts +41 -1
  193. package/src/resources/extensions/gsd/session-forensics.ts +36 -2
  194. package/src/resources/extensions/gsd/session-status-io.ts +197 -0
  195. package/src/resources/extensions/gsd/state.ts +72 -30
  196. package/src/resources/extensions/gsd/templates/milestone-validation.md +62 -0
  197. package/src/resources/extensions/gsd/tests/agent-end-provider-error.test.ts +81 -0
  198. package/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +20 -3
  199. package/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts +186 -0
  200. package/src/resources/extensions/gsd/tests/auto-preflight.test.ts +1 -0
  201. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +264 -0
  202. package/src/resources/extensions/gsd/tests/auto-skip-loop.test.ts +123 -0
  203. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +34 -0
  204. package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +8 -1
  205. package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +9 -15
  206. package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +9 -0
  207. package/src/resources/extensions/gsd/tests/derive-state-draft.test.ts +8 -0
  208. package/src/resources/extensions/gsd/tests/derive-state.test.ts +14 -0
  209. package/src/resources/extensions/gsd/tests/doctor.test.ts +58 -0
  210. package/src/resources/extensions/gsd/tests/in-flight-tool-tracking.test.ts +17 -6
  211. package/src/resources/extensions/gsd/tests/integration/headless-command.ts +534 -0
  212. package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +8 -0
  213. package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +5 -5
  214. package/src/resources/extensions/gsd/tests/parallel-orchestration.test.ts +656 -0
  215. package/src/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +354 -0
  216. package/src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +1 -0
  217. package/src/resources/extensions/gsd/tests/roadmap-slices.test.ts +43 -1
  218. package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +120 -0
  219. package/src/resources/extensions/gsd/tests/triage-resolution.test.ts +203 -2
  220. package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +316 -0
  221. package/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts +8 -3
  222. package/src/resources/extensions/gsd/tests/worker-registry.test.ts +148 -0
  223. package/src/resources/extensions/gsd/triage-resolution.ts +83 -0
  224. package/src/resources/extensions/gsd/types.ts +15 -1
  225. package/src/resources/extensions/gsd/visualizer-overlay.ts +8 -1
  226. package/src/resources/extensions/gsd/workspace-index.ts +34 -6
  227. package/src/resources/extensions/subagent/index.ts +5 -0
  228. 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
+ }