retestkit 1.4.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/.claude/commands/openspec/apply.md +23 -0
- package/.claude/commands/openspec/archive.md +27 -0
- package/.claude/commands/openspec/proposal.md +28 -0
- package/.gemini/commands/openspec/apply.toml +21 -0
- package/.gemini/commands/openspec/archive.toml +25 -0
- package/.gemini/commands/openspec/proposal.toml +26 -0
- package/.github/prompts/openspec-apply.prompt.md +22 -0
- package/.github/prompts/openspec-archive.prompt.md +26 -0
- package/.github/prompts/openspec-proposal.prompt.md +27 -0
- package/.github/workflows/release.yml +33 -0
- package/.kilocode/workflows/openspec-apply.md +17 -0
- package/.kilocode/workflows/openspec-archive.md +21 -0
- package/.kilocode/workflows/openspec-proposal.md +22 -0
- package/.mcp.json +23 -0
- package/.opencode/command/openspec-apply.md +25 -0
- package/.opencode/command/openspec-archive.md +28 -0
- package/.opencode/command/openspec-proposal.md +30 -0
- package/.roo/commands/openspec-apply.md +20 -0
- package/.roo/commands/openspec-archive.md +24 -0
- package/.roo/commands/openspec-proposal.md +25 -0
- package/.vscode/mcp.json +23 -0
- package/AGENTS.md +18 -0
- package/CLAUDE.md +18 -0
- package/LICENSE +65 -0
- package/README.md +303 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +27 -0
- package/dist/config.js.map +1 -0
- package/dist/elicitation/index.d.ts +17 -0
- package/dist/elicitation/index.d.ts.map +1 -0
- package/dist/elicitation/index.js +118 -0
- package/dist/elicitation/index.js.map +1 -0
- package/dist/elicitation/types.d.ts +35 -0
- package/dist/elicitation/types.d.ts.map +1 -0
- package/dist/elicitation/types.js +39 -0
- package/dist/elicitation/types.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +76 -0
- package/dist/index.js.map +1 -0
- package/dist/lifecycle/index.d.ts +31 -0
- package/dist/lifecycle/index.d.ts.map +1 -0
- package/dist/lifecycle/index.js +61 -0
- package/dist/lifecycle/index.js.map +1 -0
- package/dist/logger.d.ts +21 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +182 -0
- package/dist/logger.js.map +1 -0
- package/dist/playwright-client/index.d.ts +29 -0
- package/dist/playwright-client/index.d.ts.map +1 -0
- package/dist/playwright-client/index.js +288 -0
- package/dist/playwright-client/index.js.map +1 -0
- package/dist/playwright-client/types.d.ts +44 -0
- package/dist/playwright-client/types.d.ts.map +1 -0
- package/dist/playwright-client/types.js +49 -0
- package/dist/playwright-client/types.js.map +1 -0
- package/dist/progress/index.d.ts +39 -0
- package/dist/progress/index.d.ts.map +1 -0
- package/dist/progress/index.js +106 -0
- package/dist/progress/index.js.map +1 -0
- package/dist/progress/types.d.ts +24 -0
- package/dist/progress/types.d.ts.map +1 -0
- package/dist/progress/types.js +2 -0
- package/dist/progress/types.js.map +1 -0
- package/dist/prompts/index.d.ts +19 -0
- package/dist/prompts/index.d.ts.map +1 -0
- package/dist/prompts/index.js +207 -0
- package/dist/prompts/index.js.map +1 -0
- package/dist/prompts/loader.d.ts +20 -0
- package/dist/prompts/loader.d.ts.map +1 -0
- package/dist/prompts/loader.js +47 -0
- package/dist/prompts/loader.js.map +1 -0
- package/dist/resources/index.d.ts +27 -0
- package/dist/resources/index.d.ts.map +1 -0
- package/dist/resources/index.js +186 -0
- package/dist/resources/index.js.map +1 -0
- package/dist/resources/subscriptions.d.ts +10 -0
- package/dist/resources/subscriptions.d.ts.map +1 -0
- package/dist/resources/subscriptions.js +23 -0
- package/dist/resources/subscriptions.js.map +1 -0
- package/dist/sampling/index.d.ts +11 -0
- package/dist/sampling/index.d.ts.map +1 -0
- package/dist/sampling/index.js +201 -0
- package/dist/sampling/index.js.map +1 -0
- package/dist/sampling/prompts.d.ts +56 -0
- package/dist/sampling/prompts.d.ts.map +1 -0
- package/dist/sampling/prompts.js +124 -0
- package/dist/sampling/prompts.js.map +1 -0
- package/dist/sampling/types.d.ts +57 -0
- package/dist/sampling/types.d.ts.map +1 -0
- package/dist/sampling/types.js +2 -0
- package/dist/sampling/types.js.map +1 -0
- package/dist/schemas/config.d.ts +40 -0
- package/dist/schemas/config.d.ts.map +1 -0
- package/dist/schemas/config.js +30 -0
- package/dist/schemas/config.js.map +1 -0
- package/dist/security/index.d.ts +38 -0
- package/dist/security/index.d.ts.map +1 -0
- package/dist/security/index.js +281 -0
- package/dist/security/index.js.map +1 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +142 -0
- package/dist/server.js.map +1 -0
- package/dist/test-utils/index.d.ts +6 -0
- package/dist/test-utils/index.d.ts.map +1 -0
- package/dist/test-utils/index.js +6 -0
- package/dist/test-utils/index.js.map +1 -0
- package/dist/test-utils/mock-context.d.ts +64 -0
- package/dist/test-utils/mock-context.d.ts.map +1 -0
- package/dist/test-utils/mock-context.js +347 -0
- package/dist/test-utils/mock-context.js.map +1 -0
- package/dist/test-utils/mock-playwright-client.d.ts +62 -0
- package/dist/test-utils/mock-playwright-client.d.ts.map +1 -0
- package/dist/test-utils/mock-playwright-client.js +315 -0
- package/dist/test-utils/mock-playwright-client.js.map +1 -0
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +8 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/webtest/crawl.d.ts +46 -0
- package/dist/tools/webtest/crawl.d.ts.map +1 -0
- package/dist/tools/webtest/crawl.js +678 -0
- package/dist/tools/webtest/crawl.js.map +1 -0
- package/dist/tools/webtest/discover-features.d.ts +30 -0
- package/dist/tools/webtest/discover-features.d.ts.map +1 -0
- package/dist/tools/webtest/discover-features.js +343 -0
- package/dist/tools/webtest/discover-features.js.map +1 -0
- package/dist/tools/webtest/discover-flows.d.ts +29 -0
- package/dist/tools/webtest/discover-flows.d.ts.map +1 -0
- package/dist/tools/webtest/discover-flows.js +341 -0
- package/dist/tools/webtest/discover-flows.js.map +1 -0
- package/dist/tools/webtest/generate-tests.d.ts +54 -0
- package/dist/tools/webtest/generate-tests.d.ts.map +1 -0
- package/dist/tools/webtest/generate-tests.js +364 -0
- package/dist/tools/webtest/generate-tests.js.map +1 -0
- package/dist/tools/webtest/index.d.ts +8 -0
- package/dist/tools/webtest/index.d.ts.map +1 -0
- package/dist/tools/webtest/index.js +8 -0
- package/dist/tools/webtest/index.js.map +1 -0
- package/dist/tools/webtest/run-test-case.d.ts +28 -0
- package/dist/tools/webtest/run-test-case.d.ts.map +1 -0
- package/dist/tools/webtest/run-test-case.js +420 -0
- package/dist/tools/webtest/run-test-case.js.map +1 -0
- package/dist/tools/webtest/schemas.d.ts +175 -0
- package/dist/tools/webtest/schemas.d.ts.map +1 -0
- package/dist/tools/webtest/schemas.js +156 -0
- package/dist/tools/webtest/schemas.js.map +1 -0
- package/dist/tools/webtest/start-analysis.d.ts +16 -0
- package/dist/tools/webtest/start-analysis.d.ts.map +1 -0
- package/dist/tools/webtest/start-analysis.js +137 -0
- package/dist/tools/webtest/start-analysis.js.map +1 -0
- package/dist/transports/http.d.ts +8 -0
- package/dist/transports/http.d.ts.map +1 -0
- package/dist/transports/http.js +9 -0
- package/dist/transports/http.js.map +1 -0
- package/dist/transports/index.d.ts +14 -0
- package/dist/transports/index.d.ts.map +1 -0
- package/dist/transports/index.js +20 -0
- package/dist/transports/index.js.map +1 -0
- package/dist/transports/stdio.d.ts +4 -0
- package/dist/transports/stdio.d.ts.map +1 -0
- package/dist/transports/stdio.js +6 -0
- package/dist/transports/stdio.js.map +1 -0
- package/dist/types/capabilities.d.ts +18 -0
- package/dist/types/capabilities.d.ts.map +1 -0
- package/dist/types/capabilities.js +35 -0
- package/dist/types/capabilities.js.map +1 -0
- package/dist/types/context.d.ts +20 -0
- package/dist/types/context.d.ts.map +1 -0
- package/dist/types/context.js +2 -0
- package/dist/types/context.js.map +1 -0
- package/dist/types/tool.d.ts +10 -0
- package/dist/types/tool.d.ts.map +1 -0
- package/dist/types/tool.js +2 -0
- package/dist/types/tool.js.map +1 -0
- package/dist/workspace/index.d.ts +99 -0
- package/dist/workspace/index.d.ts.map +1 -0
- package/dist/workspace/index.js +648 -0
- package/dist/workspace/index.js.map +1 -0
- package/dist/workspace/markdown.d.ts +50 -0
- package/dist/workspace/markdown.d.ts.map +1 -0
- package/dist/workspace/markdown.js +210 -0
- package/dist/workspace/markdown.js.map +1 -0
- package/dist/workspace/types.d.ts +173 -0
- package/dist/workspace/types.d.ts.map +1 -0
- package/dist/workspace/types.js +2 -0
- package/dist/workspace/types.js.map +1 -0
- package/openspec/AGENTS.md +456 -0
- package/openspec/changes/archive/2025-12-18-add-hybrid-artifact-paths/proposal.md +33 -0
- package/openspec/changes/archive/2025-12-18-add-hybrid-artifact-paths/specs/webtest-resources/spec.md +27 -0
- package/openspec/changes/archive/2025-12-18-add-hybrid-artifact-paths/specs/webtest-tools/spec.md +304 -0
- package/openspec/changes/archive/2025-12-18-add-hybrid-artifact-paths/tasks.md +43 -0
- package/openspec/changes/archive/2025-12-18-add-mcp-server-foundation/design.md +209 -0
- package/openspec/changes/archive/2025-12-18-add-mcp-server-foundation/proposal.md +41 -0
- package/openspec/changes/archive/2025-12-18-add-mcp-server-foundation/specs/mcp-server-core/spec.md +183 -0
- package/openspec/changes/archive/2025-12-18-add-mcp-server-foundation/tasks.md +112 -0
- package/openspec/changes/archive/2025-12-18-add-webtest-orchestrator/design.md +333 -0
- package/openspec/changes/archive/2025-12-18-add-webtest-orchestrator/proposal.md +66 -0
- package/openspec/changes/archive/2025-12-18-add-webtest-orchestrator/specs/mcp-server-core/spec.md +129 -0
- package/openspec/changes/archive/2025-12-18-add-webtest-orchestrator/specs/webtest-lifecycle/spec.md +138 -0
- package/openspec/changes/archive/2025-12-18-add-webtest-orchestrator/specs/webtest-logging/spec.md +211 -0
- package/openspec/changes/archive/2025-12-18-add-webtest-orchestrator/specs/webtest-prompts/spec.md +157 -0
- package/openspec/changes/archive/2025-12-18-add-webtest-orchestrator/specs/webtest-resources/spec.md +213 -0
- package/openspec/changes/archive/2025-12-18-add-webtest-orchestrator/specs/webtest-sampling/spec.md +257 -0
- package/openspec/changes/archive/2025-12-18-add-webtest-orchestrator/specs/webtest-tools/spec.md +501 -0
- package/openspec/changes/archive/2025-12-18-add-webtest-orchestrator/tasks.md +264 -0
- package/openspec/changes/archive/2025-12-18-allow-analysis-of-incomplete-crawls/proposal.md +24 -0
- package/openspec/changes/archive/2025-12-18-allow-analysis-of-incomplete-crawls/specs/webtest-tools/spec.md +80 -0
- package/openspec/changes/archive/2025-12-18-allow-analysis-of-incomplete-crawls/tasks.md +8 -0
- package/openspec/changes/archive/2025-12-18-fix-crawl-loop-stability/design.md +90 -0
- package/openspec/changes/archive/2025-12-18-fix-crawl-loop-stability/proposal.md +28 -0
- package/openspec/changes/archive/2025-12-18-fix-crawl-loop-stability/specs/webtest-sampling/spec.md +90 -0
- package/openspec/changes/archive/2025-12-18-fix-crawl-loop-stability/tasks.md +33 -0
- package/openspec/changes/archive/2025-12-18-use-markdown-artifacts/design.md +558 -0
- package/openspec/changes/archive/2025-12-18-use-markdown-artifacts/proposal.md +119 -0
- package/openspec/changes/archive/2025-12-18-use-markdown-artifacts/specs/webtest-resources/spec.md +109 -0
- package/openspec/changes/archive/2025-12-18-use-markdown-artifacts/specs/webtest-tools/spec.md +121 -0
- package/openspec/changes/archive/2025-12-18-use-markdown-artifacts/tasks.md +133 -0
- package/openspec/changes/extract-prompts-to-markdown/design.md +86 -0
- package/openspec/changes/extract-prompts-to-markdown/proposal.md +50 -0
- package/openspec/changes/extract-prompts-to-markdown/specs/webtest-prompts/spec.md +74 -0
- package/openspec/changes/extract-prompts-to-markdown/tasks.md +40 -0
- package/openspec/changes/refactor-webtest-naming/design.md +95 -0
- package/openspec/changes/refactor-webtest-naming/proposal.md +66 -0
- package/openspec/changes/refactor-webtest-naming/specs/webtest-prompts/spec.md +79 -0
- package/openspec/changes/refactor-webtest-naming/specs/webtest-resources/spec.md +80 -0
- package/openspec/changes/refactor-webtest-naming/specs/webtest-sampling/spec.md +122 -0
- package/openspec/changes/refactor-webtest-naming/specs/webtest-tools/spec.md +113 -0
- package/openspec/changes/refactor-webtest-naming/tasks.md +119 -0
- package/openspec/changes/rename-package-to-retest/proposal.md +52 -0
- package/openspec/changes/rename-package-to-retest/specs/mcp-server-core/spec.md +53 -0
- package/openspec/changes/rename-package-to-retest/specs/retest-lifecycle/spec.md +68 -0
- package/openspec/changes/rename-package-to-retest/specs/retest-logging/spec.md +35 -0
- package/openspec/changes/rename-package-to-retest/specs/retest-prompts/spec.md +159 -0
- package/openspec/changes/rename-package-to-retest/specs/retest-resources/spec.md +251 -0
- package/openspec/changes/rename-package-to-retest/specs/retest-sampling/spec.md +99 -0
- package/openspec/changes/rename-package-to-retest/specs/retest-tools/spec.md +295 -0
- package/openspec/changes/rename-package-to-retest/tasks.md +71 -0
- package/openspec/project.md +31 -0
- package/openspec/specs/mcp-server-core/spec.md +178 -0
- package/openspec/specs/webtest-lifecycle/spec.md +136 -0
- package/openspec/specs/webtest-logging/spec.md +209 -0
- package/openspec/specs/webtest-prompts/spec.md +155 -0
- package/openspec/specs/webtest-resources/spec.md +248 -0
- package/openspec/specs/webtest-sampling/spec.md +344 -0
- package/openspec/specs/webtest-tools/spec.md +282 -0
- package/package.json +54 -0
- package/release.config.js +9 -0
- package/src/config.test.ts +96 -0
- package/src/config.ts +32 -0
- package/src/elicitation/index.test.ts +399 -0
- package/src/elicitation/index.ts +171 -0
- package/src/elicitation/types.ts +68 -0
- package/src/index.ts +83 -0
- package/src/lifecycle/index.test.ts +260 -0
- package/src/lifecycle/index.ts +101 -0
- package/src/logger.redaction.test.ts +322 -0
- package/src/logger.test.ts +123 -0
- package/src/logger.ts +229 -0
- package/src/playwright-client/index.ts +392 -0
- package/src/playwright-client/types.ts +99 -0
- package/src/progress/index.test.ts +327 -0
- package/src/progress/index.ts +170 -0
- package/src/progress/types.ts +25 -0
- package/src/prompts/index.test.ts +451 -0
- package/src/prompts/index.ts +246 -0
- package/src/prompts/loader.test.ts +100 -0
- package/src/prompts/loader.ts +59 -0
- package/src/prompts/templates/mcp/webtest-crawl.md +7 -0
- package/src/prompts/templates/mcp/webtest-discover-flows.md +11 -0
- package/src/prompts/templates/mcp/webtest-discover.md +12 -0
- package/src/prompts/templates/mcp/webtest-full-workflow.md +12 -0
- package/src/prompts/templates/mcp/webtest-generate-tests.md +11 -0
- package/src/prompts/templates/mcp/webtest-run-test.md +11 -0
- package/src/prompts/templates/mcp/webtest-start.md +8 -0
- package/src/prompts/templates/sampling/crawl-action.md +35 -0
- package/src/prompts/templates/sampling/feature-discovery.md +27 -0
- package/src/prompts/templates/sampling/flow-discovery.md +29 -0
- package/src/prompts/templates/sampling/page-content-wrapper.md +5 -0
- package/src/prompts/templates/sampling/system-prefix.md +12 -0
- package/src/prompts/templates/sampling/test-evaluation.md +17 -0
- package/src/prompts/templates/sampling/test-generation.md +31 -0
- package/src/resources/index.ts +250 -0
- package/src/resources/subscriptions.ts +37 -0
- package/src/sampling/index.test.ts +414 -0
- package/src/sampling/index.ts +286 -0
- package/src/sampling/prompts.ts +194 -0
- package/src/sampling/types.ts +60 -0
- package/src/schemas/config.ts +39 -0
- package/src/security/index.test.ts +441 -0
- package/src/security/index.ts +361 -0
- package/src/security/security-scenarios.test.ts +468 -0
- package/src/server.ts +211 -0
- package/src/test-utils/index.ts +6 -0
- package/src/test-utils/mock-context.ts +426 -0
- package/src/test-utils/mock-playwright-client.ts +422 -0
- package/src/tools/index.ts +11 -0
- package/src/tools/webtest/crawl.test.ts +834 -0
- package/src/tools/webtest/crawl.ts +901 -0
- package/src/tools/webtest/discover-features.ts +412 -0
- package/src/tools/webtest/discover-flows.ts +408 -0
- package/src/tools/webtest/generate-tests.test.ts +532 -0
- package/src/tools/webtest/generate-tests.ts +425 -0
- package/src/tools/webtest/index.ts +7 -0
- package/src/tools/webtest/integration.test.ts +536 -0
- package/src/tools/webtest/run-test-case.test.ts +659 -0
- package/src/tools/webtest/run-test-case.ts +508 -0
- package/src/tools/webtest/schemas.ts +201 -0
- package/src/tools/webtest/start-analysis.test.ts +151 -0
- package/src/tools/webtest/start-analysis.ts +158 -0
- package/src/transports/http.ts +19 -0
- package/src/transports/index.ts +30 -0
- package/src/transports/stdio.ts +7 -0
- package/src/types/capabilities.test.ts +193 -0
- package/src/types/capabilities.ts +50 -0
- package/src/types/context.ts +21 -0
- package/src/types/tool.ts +11 -0
- package/src/workspace/index.ts +945 -0
- package/src/workspace/markdown.ts +272 -0
- package/src/workspace/types.ts +186 -0
- package/tests/integration/server.test.ts +89 -0
- package/tests/integration/tools.test.ts +99 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +9 -0
- package/vitest.integration.config.ts +10 -0
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { McpTool, ToolResult } from "../../types/tool.js";
|
|
4
|
+
import type { ServerContext } from "../../types/context.js";
|
|
5
|
+
import type { WorkspaceManager, CrawlCheckpoint, CrawlIndex } from "../../workspace/index.js";
|
|
6
|
+
import type { PlaywrightClient } from "../../playwright-client/index.js";
|
|
7
|
+
import type { SamplingClient } from "../../sampling/index.js";
|
|
8
|
+
import type { ElicitationClient, ElicitationType } from "../../elicitation/index.js";
|
|
9
|
+
import type { CancellationRegistry, ProgressEmitter } from "../../progress/index.js";
|
|
10
|
+
import type { SecurityValidator } from "../../security/index.js";
|
|
11
|
+
import type { ResourceManager } from "../../resources/index.js";
|
|
12
|
+
import { CancellationError, calculateBudgetStatus } from "../../progress/index.js";
|
|
13
|
+
import { buildCrawlActionPrompt } from "../../sampling/prompts.js";
|
|
14
|
+
import { CrawlActionSchema, AnalysisIdSchema, type CrawlAction } from "./schemas.js";
|
|
15
|
+
import { createDomSignature } from "../../security/index.js";
|
|
16
|
+
|
|
17
|
+
export const crawlInputSchema = z.object({
|
|
18
|
+
analysisId: AnalysisIdSchema,
|
|
19
|
+
goal: z.string().min(1, "Goal is required").describe(
|
|
20
|
+
"What you want to discover or test (e.g., 'Find all navigation paths', 'Explore the checkout flow')"
|
|
21
|
+
),
|
|
22
|
+
strategy: z
|
|
23
|
+
.enum(["breadth_first", "depth_first", "goal_directed"])
|
|
24
|
+
.default("goal_directed")
|
|
25
|
+
.describe("Crawling strategy"),
|
|
26
|
+
limits: z
|
|
27
|
+
.object({
|
|
28
|
+
maxSteps: z.number().int().min(1).max(1000).optional(),
|
|
29
|
+
maxMinutes: z.number().int().min(1).max(180).optional(),
|
|
30
|
+
maxPages: z.number().int().min(1).max(100).optional(),
|
|
31
|
+
})
|
|
32
|
+
.optional()
|
|
33
|
+
.describe("Override workspace limits for this crawl"),
|
|
34
|
+
artifacts: z
|
|
35
|
+
.object({
|
|
36
|
+
captureScreenshots: z.boolean().default(true),
|
|
37
|
+
captureSnapshots: z.boolean().default(true),
|
|
38
|
+
captureDom: z.boolean().default(true),
|
|
39
|
+
})
|
|
40
|
+
.optional()
|
|
41
|
+
.describe("What artifacts to capture"),
|
|
42
|
+
resume: z
|
|
43
|
+
.boolean()
|
|
44
|
+
.default(false)
|
|
45
|
+
.describe("Resume from the last checkpoint if available"),
|
|
46
|
+
manualNextActions: z
|
|
47
|
+
.array(
|
|
48
|
+
z.object({
|
|
49
|
+
tool: z.string(),
|
|
50
|
+
args: z.record(z.string(), z.any()),
|
|
51
|
+
})
|
|
52
|
+
)
|
|
53
|
+
.optional()
|
|
54
|
+
.describe("Manual actions to execute (for fallback mode when sampling unavailable)"),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export type CrawlInput = z.infer<typeof crawlInputSchema>;
|
|
58
|
+
|
|
59
|
+
interface CrawlState {
|
|
60
|
+
step: number;
|
|
61
|
+
currentUrl: string;
|
|
62
|
+
startUrl: string;
|
|
63
|
+
visitedUrls: Set<string>;
|
|
64
|
+
actionHistory: string[];
|
|
65
|
+
goalProgress: string;
|
|
66
|
+
loopDetection: {
|
|
67
|
+
domSignatures: Map<string, number>;
|
|
68
|
+
urlVisits: Map<string, number>;
|
|
69
|
+
recentActions: string[];
|
|
70
|
+
};
|
|
71
|
+
/** Track if navigation to start URL was blocked (for prompt feedback) */
|
|
72
|
+
navigationBlocked?: {
|
|
73
|
+
url: string;
|
|
74
|
+
reason: string;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createCrawlTool(
|
|
79
|
+
getContext: () => ServerContext & {
|
|
80
|
+
workspaceManager: WorkspaceManager;
|
|
81
|
+
playwrightClient: PlaywrightClient;
|
|
82
|
+
samplingClient: SamplingClient;
|
|
83
|
+
elicitationClient: ElicitationClient;
|
|
84
|
+
cancellationRegistry: CancellationRegistry;
|
|
85
|
+
progressEmitter: ProgressEmitter;
|
|
86
|
+
securityValidator: SecurityValidator;
|
|
87
|
+
resourceManager: ResourceManager;
|
|
88
|
+
}
|
|
89
|
+
): McpTool<CrawlInput> {
|
|
90
|
+
return {
|
|
91
|
+
name: "webtest_crawl_app",
|
|
92
|
+
description: `Perform goal-directed exploration of a web application.
|
|
93
|
+
|
|
94
|
+
This tool crawls the target application using AI-powered decision making:
|
|
95
|
+
- Navigates pages based on the specified goal
|
|
96
|
+
- Captures screenshots, snapshots, and DOM at each step
|
|
97
|
+
- Detects and handles obstacles (cookie banners, modals)
|
|
98
|
+
- Supports checkpointing for resume after interruption
|
|
99
|
+
- Prevents infinite loops through multiple detection mechanisms
|
|
100
|
+
|
|
101
|
+
Progress is reported throughout, and the operation can be cancelled.
|
|
102
|
+
If sampling is unavailable, returns a prompt for manual execution.`,
|
|
103
|
+
|
|
104
|
+
inputSchema: crawlInputSchema,
|
|
105
|
+
|
|
106
|
+
async handler(input: CrawlInput): Promise<ToolResult> {
|
|
107
|
+
const ctx = getContext();
|
|
108
|
+
const {
|
|
109
|
+
logger,
|
|
110
|
+
config,
|
|
111
|
+
workspaceManager,
|
|
112
|
+
playwrightClient,
|
|
113
|
+
samplingClient,
|
|
114
|
+
elicitationClient,
|
|
115
|
+
cancellationRegistry,
|
|
116
|
+
progressEmitter,
|
|
117
|
+
securityValidator,
|
|
118
|
+
resourceManager,
|
|
119
|
+
} = ctx;
|
|
120
|
+
|
|
121
|
+
const requestId = `crawl-${input.analysisId}-${Date.now()}`;
|
|
122
|
+
const crawlLogger = logger.withCorrelation({
|
|
123
|
+
analysisId: input.analysisId,
|
|
124
|
+
requestId,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
crawlLogger.info("Starting crawl", { goal: input.goal, strategy: input.strategy });
|
|
128
|
+
|
|
129
|
+
// Register for cancellation
|
|
130
|
+
cancellationRegistry.register(requestId);
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
// Validate workspace exists
|
|
134
|
+
if (!(await workspaceManager.workspaceExists(input.analysisId))) {
|
|
135
|
+
return {
|
|
136
|
+
content: [
|
|
137
|
+
{
|
|
138
|
+
type: "text",
|
|
139
|
+
text: `Error: Analysis workspace "${input.analysisId}" not found. Run webtest_init first.`,
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
isError: true,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const workspace = await workspaceManager.readWorkspaceIndex(input.analysisId);
|
|
147
|
+
const allowedDomains = [workspace.domain];
|
|
148
|
+
|
|
149
|
+
// Determine limits
|
|
150
|
+
const limits = {
|
|
151
|
+
maxSteps: input.limits?.maxSteps ?? workspace.limits.maxSteps,
|
|
152
|
+
maxMinutes: input.limits?.maxMinutes ?? workspace.limits.maxMinutes,
|
|
153
|
+
maxPages: input.limits?.maxPages ?? workspace.limits.maxPages,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Create crawl entry
|
|
157
|
+
const { crawlId, crawlPath } = await workspaceManager.createCrawl(
|
|
158
|
+
input.analysisId,
|
|
159
|
+
{ goal: input.goal, strategy: input.strategy, limits }
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const iterationLogger = crawlLogger.withCorrelation({ crawlId });
|
|
163
|
+
iterationLogger.info("Crawl created", { crawlId, limits });
|
|
164
|
+
|
|
165
|
+
// Notify about new resource
|
|
166
|
+
await resourceManager.notifyListChanged();
|
|
167
|
+
|
|
168
|
+
// Initialize state
|
|
169
|
+
let state: CrawlState;
|
|
170
|
+
const startTime = Date.now();
|
|
171
|
+
|
|
172
|
+
// Check for resume
|
|
173
|
+
if (input.resume) {
|
|
174
|
+
const checkpoint = await workspaceManager.loadCheckpoint(
|
|
175
|
+
input.analysisId,
|
|
176
|
+
crawlId
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
if (checkpoint && checkpoint.canResume) {
|
|
180
|
+
iterationLogger.info("Resuming from checkpoint", {
|
|
181
|
+
step: checkpoint.step,
|
|
182
|
+
hasLoopDetection: !!checkpoint.loopDetection,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Restore loop detection state if available, otherwise start fresh
|
|
186
|
+
const loopDetection = checkpoint.loopDetection
|
|
187
|
+
? {
|
|
188
|
+
domSignatures: new Map(checkpoint.loopDetection.domSignatures),
|
|
189
|
+
urlVisits: new Map(checkpoint.loopDetection.urlVisits),
|
|
190
|
+
recentActions: [...checkpoint.loopDetection.recentActions],
|
|
191
|
+
}
|
|
192
|
+
: {
|
|
193
|
+
domSignatures: new Map<string, number>(),
|
|
194
|
+
urlVisits: new Map<string, number>(),
|
|
195
|
+
recentActions: [] as string[],
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
if (!checkpoint.loopDetection) {
|
|
199
|
+
iterationLogger.warn("Resuming without loop detection history - context may be limited");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
state = {
|
|
203
|
+
step: checkpoint.step,
|
|
204
|
+
currentUrl: checkpoint.currentUrl,
|
|
205
|
+
startUrl: checkpoint.startUrl || workspace.url,
|
|
206
|
+
visitedUrls: new Set(checkpoint.visitedUrls),
|
|
207
|
+
actionHistory: [],
|
|
208
|
+
goalProgress: checkpoint.goalProgress,
|
|
209
|
+
loopDetection,
|
|
210
|
+
};
|
|
211
|
+
} else {
|
|
212
|
+
state = createInitialState(workspace.url);
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
state = createInitialState(workspace.url);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Ensure Playwright is connected
|
|
219
|
+
if (!playwrightClient.isConnected()) {
|
|
220
|
+
await playwrightClient.connect();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Check current browser URL before navigating
|
|
224
|
+
// This prevents unnecessary navigation that would disrupt an existing browser session
|
|
225
|
+
let shouldNavigate = true;
|
|
226
|
+
try {
|
|
227
|
+
const currentSnapshot = await playwrightClient.snapshot();
|
|
228
|
+
const currentBrowserUrl = currentSnapshot.url;
|
|
229
|
+
|
|
230
|
+
// Skip navigation if browser is already on ANY page within the allowed domain
|
|
231
|
+
// This preserves the current browser state (e.g., logged in, items in cart)
|
|
232
|
+
if (currentBrowserUrl && currentBrowserUrl !== "about:blank") {
|
|
233
|
+
const currentDomain = new URL(currentBrowserUrl).hostname;
|
|
234
|
+
const targetDomain = new URL(state.startUrl).hostname;
|
|
235
|
+
|
|
236
|
+
if (currentDomain === targetDomain) {
|
|
237
|
+
// Browser is already on the target domain - don't navigate at all
|
|
238
|
+
// This preserves login state, cart contents, etc.
|
|
239
|
+
iterationLogger.info("Browser already on target domain, skipping navigation", {
|
|
240
|
+
currentBrowserUrl,
|
|
241
|
+
startUrl: state.startUrl,
|
|
242
|
+
});
|
|
243
|
+
shouldNavigate = false;
|
|
244
|
+
state.currentUrl = currentBrowserUrl;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
} catch (e) {
|
|
248
|
+
// If we can't get current URL, proceed with navigation
|
|
249
|
+
iterationLogger.debug("Could not check current browser URL, will navigate", {
|
|
250
|
+
error: e instanceof Error ? e.message : "Unknown error",
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (shouldNavigate) {
|
|
255
|
+
// Navigate to starting URL
|
|
256
|
+
await playwrightClient.navigate(state.currentUrl);
|
|
257
|
+
iterationLogger.info("Navigated to starting URL", { url: state.currentUrl });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Main crawl loop
|
|
261
|
+
let goalComplete = false;
|
|
262
|
+
let blocked = false;
|
|
263
|
+
let blockedReason: string | undefined;
|
|
264
|
+
|
|
265
|
+
while (!goalComplete && !blocked) {
|
|
266
|
+
// Check cancellation
|
|
267
|
+
cancellationRegistry.checkCancelled(requestId);
|
|
268
|
+
|
|
269
|
+
// Check budget
|
|
270
|
+
const budget = calculateBudgetStatus({
|
|
271
|
+
stepsUsed: state.step,
|
|
272
|
+
maxSteps: limits.maxSteps,
|
|
273
|
+
startTime,
|
|
274
|
+
maxMinutes: limits.maxMinutes,
|
|
275
|
+
pagesVisited: state.visitedUrls.size,
|
|
276
|
+
maxPages: limits.maxPages,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
if (budget.isExhausted) {
|
|
280
|
+
iterationLogger.info("Budget exhausted", { reason: budget.exhaustedReason });
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Emit progress
|
|
285
|
+
progressEmitter.emit({
|
|
286
|
+
progressToken: requestId,
|
|
287
|
+
progress: state.step,
|
|
288
|
+
total: limits.maxSteps,
|
|
289
|
+
message: `Step ${state.step}: ${state.goalProgress || "Exploring..."}`,
|
|
290
|
+
budgetStatus: budget,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Capture current state
|
|
294
|
+
const snapshot = await playwrightClient.snapshot();
|
|
295
|
+
const screenshot = await playwrightClient.screenshot();
|
|
296
|
+
|
|
297
|
+
state.currentUrl = snapshot.url;
|
|
298
|
+
|
|
299
|
+
// Track URL visits for loop detection
|
|
300
|
+
const urlVisits = state.loopDetection.urlVisits.get(state.currentUrl) || 0;
|
|
301
|
+
state.loopDetection.urlVisits.set(state.currentUrl, urlVisits + 1);
|
|
302
|
+
|
|
303
|
+
// Check for URL cycle
|
|
304
|
+
if (urlVisits >= 3) {
|
|
305
|
+
iterationLogger.warn("URL cycle detected", {
|
|
306
|
+
url: state.currentUrl,
|
|
307
|
+
visits: urlVisits + 1,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Check DOM signature for same-state loop (include URL path for semantic differentiation)
|
|
312
|
+
let urlPath: string | undefined;
|
|
313
|
+
try {
|
|
314
|
+
urlPath = new URL(state.currentUrl).pathname;
|
|
315
|
+
} catch {
|
|
316
|
+
// If URL parsing fails, try to extract path from the string or skip it
|
|
317
|
+
const pathMatch = state.currentUrl.match(/^(?:https?:\/\/[^\/]+)?(\/.*)$/);
|
|
318
|
+
urlPath = pathMatch?.[1];
|
|
319
|
+
}
|
|
320
|
+
const domSignature = createDomSignature(snapshot.content, urlPath);
|
|
321
|
+
const sigRepeats = state.loopDetection.domSignatures.get(domSignature) || 0;
|
|
322
|
+
state.loopDetection.domSignatures.set(domSignature, sigRepeats + 1);
|
|
323
|
+
|
|
324
|
+
if (sigRepeats >= 3) {
|
|
325
|
+
iterationLogger.warn("Same-state loop detected", {
|
|
326
|
+
signature: domSignature,
|
|
327
|
+
repeats: sigRepeats + 1,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Save page artifacts
|
|
332
|
+
const artifacts = input.artifacts ?? {
|
|
333
|
+
captureScreenshots: true,
|
|
334
|
+
captureSnapshots: true,
|
|
335
|
+
captureDom: true,
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
if (
|
|
339
|
+
artifacts.captureScreenshots ||
|
|
340
|
+
artifacts.captureSnapshots ||
|
|
341
|
+
artifacts.captureDom
|
|
342
|
+
) {
|
|
343
|
+
await workspaceManager.savePage(input.analysisId, crawlId, {
|
|
344
|
+
url: state.currentUrl,
|
|
345
|
+
title: snapshot.title,
|
|
346
|
+
snapshot: JSON.stringify(snapshot),
|
|
347
|
+
screenshot,
|
|
348
|
+
dom: snapshot.content,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
state.visitedUrls.add(state.currentUrl);
|
|
352
|
+
await resourceManager.notifyListChanged();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Check for injection attempts in page content
|
|
356
|
+
const injectionCheck = securityValidator.detectInjectionAttempt(
|
|
357
|
+
snapshot.content
|
|
358
|
+
);
|
|
359
|
+
if (injectionCheck.detected) {
|
|
360
|
+
iterationLogger.warn("Injection attempt detected in page content", {
|
|
361
|
+
type: injectionCheck.type,
|
|
362
|
+
evidence: injectionCheck.evidence,
|
|
363
|
+
});
|
|
364
|
+
// Don't act on it, but include warning in sampling prompt
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Handle manual mode (no sampling)
|
|
368
|
+
if (input.manualNextActions && input.manualNextActions.length > 0) {
|
|
369
|
+
const action = input.manualNextActions.shift()!;
|
|
370
|
+
|
|
371
|
+
// Validate action
|
|
372
|
+
const actionValidation = securityValidator.validateAction(
|
|
373
|
+
action,
|
|
374
|
+
allowedDomains
|
|
375
|
+
);
|
|
376
|
+
if (!actionValidation.valid) {
|
|
377
|
+
return {
|
|
378
|
+
content: [
|
|
379
|
+
{
|
|
380
|
+
type: "text",
|
|
381
|
+
text: `Security error: ${actionValidation.reason}`,
|
|
382
|
+
},
|
|
383
|
+
],
|
|
384
|
+
isError: true,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
await executeAction(playwrightClient, action, iterationLogger);
|
|
389
|
+
state.step++;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Calculate budget percentage for flow progress
|
|
394
|
+
const budgetUsedPercent = Math.round((state.step / limits.maxSteps) * 100);
|
|
395
|
+
|
|
396
|
+
// Build common prompt parameters
|
|
397
|
+
const promptParams = {
|
|
398
|
+
goal: input.goal,
|
|
399
|
+
currentUrl: state.currentUrl,
|
|
400
|
+
pageSnapshot: snapshot.content.slice(0, 10000),
|
|
401
|
+
actionHistory: state.actionHistory,
|
|
402
|
+
allowedDomains,
|
|
403
|
+
startUrl: state.startUrl,
|
|
404
|
+
flowProgress: {
|
|
405
|
+
currentStep: state.step,
|
|
406
|
+
totalSteps: limits.maxSteps,
|
|
407
|
+
budgetUsedPercent,
|
|
408
|
+
previousGoalProgress: state.goalProgress,
|
|
409
|
+
},
|
|
410
|
+
loopState: {
|
|
411
|
+
domSignatureRepeats: sigRepeats,
|
|
412
|
+
urlVisitCount: Object.fromEntries(state.loopDetection.urlVisits),
|
|
413
|
+
lastActions: state.loopDetection.recentActions,
|
|
414
|
+
},
|
|
415
|
+
navigationBlocked: state.navigationBlocked,
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
// Clear navigation blocked after including in prompt
|
|
419
|
+
state.navigationBlocked = undefined;
|
|
420
|
+
|
|
421
|
+
// Request next action via sampling
|
|
422
|
+
if (!samplingClient.hasSampling()) {
|
|
423
|
+
// Return prompt for manual execution
|
|
424
|
+
const prompt = buildCrawlActionPrompt(promptParams);
|
|
425
|
+
|
|
426
|
+
const crawlIndex = await workspaceManager.readCrawlIndex(
|
|
427
|
+
input.analysisId,
|
|
428
|
+
crawlId
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
content: [
|
|
433
|
+
{
|
|
434
|
+
type: "text",
|
|
435
|
+
text: JSON.stringify(
|
|
436
|
+
{
|
|
437
|
+
needsManualInput: true,
|
|
438
|
+
crawlId,
|
|
439
|
+
step: state.step,
|
|
440
|
+
currentUrl: state.currentUrl,
|
|
441
|
+
prompt,
|
|
442
|
+
expectedResponseSchema: CrawlActionSchema._def,
|
|
443
|
+
instructions:
|
|
444
|
+
"Execute this prompt with your LLM and call webtest_crawl_app again with manualNextActions",
|
|
445
|
+
partialResults: {
|
|
446
|
+
pagesVisited: crawlIndex.pages.length,
|
|
447
|
+
stepsCompleted: state.step,
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
null,
|
|
451
|
+
2
|
|
452
|
+
),
|
|
453
|
+
},
|
|
454
|
+
],
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Get action from sampling
|
|
459
|
+
const samplingResult = await samplingClient.createMessage({
|
|
460
|
+
systemPrompt: `You are analyzing a web application to achieve the following goal: ${input.goal}`,
|
|
461
|
+
userPrompt: buildCrawlActionPrompt(promptParams),
|
|
462
|
+
schema: CrawlActionSchema,
|
|
463
|
+
maxTokens: 2048,
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
if (!samplingResult.success || !samplingResult.data) {
|
|
467
|
+
iterationLogger.error("Sampling failed", {
|
|
468
|
+
error: samplingResult.error,
|
|
469
|
+
});
|
|
470
|
+
blocked = true;
|
|
471
|
+
blockedReason = `Sampling error: ${samplingResult.error}`;
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const actionResponse = samplingResult.data;
|
|
476
|
+
state.goalProgress = actionResponse.goalProgress;
|
|
477
|
+
|
|
478
|
+
// Check if goal is complete
|
|
479
|
+
if (actionResponse.goalComplete) {
|
|
480
|
+
goalComplete = true;
|
|
481
|
+
iterationLogger.info("Goal completed", {
|
|
482
|
+
reasoning: actionResponse.reasoning,
|
|
483
|
+
});
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Check if blocked
|
|
488
|
+
if (actionResponse.blocked) {
|
|
489
|
+
blocked = true;
|
|
490
|
+
blockedReason = actionResponse.blockedReason;
|
|
491
|
+
iterationLogger.info("Crawl blocked", { reason: blockedReason });
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Handle elicitation if needed
|
|
496
|
+
if (actionResponse.elicitationNeeded) {
|
|
497
|
+
const elicitResult = await handleElicitation(
|
|
498
|
+
elicitationClient,
|
|
499
|
+
actionResponse.elicitationNeeded,
|
|
500
|
+
iterationLogger
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
if (elicitResult.cancelled) {
|
|
504
|
+
blocked = true;
|
|
505
|
+
blockedReason = "User cancelled at elicitation prompt";
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Use elicitation result to modify action if needed
|
|
510
|
+
if (
|
|
511
|
+
elicitResult.selectedValue === "stop" ||
|
|
512
|
+
elicitResult.selectedValue === "reject"
|
|
513
|
+
) {
|
|
514
|
+
blocked = true;
|
|
515
|
+
blockedReason = `User chose: ${elicitResult.selectedValue}`;
|
|
516
|
+
break;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Execute actions
|
|
521
|
+
for (const action of actionResponse.actions) {
|
|
522
|
+
// Check cancellation before each action
|
|
523
|
+
cancellationRegistry.checkCancelled(requestId);
|
|
524
|
+
|
|
525
|
+
// Navigation guard: block navigation to start URL after initial steps
|
|
526
|
+
if (action.tool === "navigate" && state.step >= 3) {
|
|
527
|
+
const targetUrl = action.args.url as string;
|
|
528
|
+
// Check if navigating back to start URL
|
|
529
|
+
if (isStartUrlNavigation(targetUrl, state.startUrl)) {
|
|
530
|
+
iterationLogger.warn("Navigation to start URL blocked", {
|
|
531
|
+
targetUrl,
|
|
532
|
+
startUrl: state.startUrl,
|
|
533
|
+
step: state.step,
|
|
534
|
+
});
|
|
535
|
+
// Set navigation blocked for next prompt feedback
|
|
536
|
+
state.navigationBlocked = {
|
|
537
|
+
url: targetUrl,
|
|
538
|
+
reason: "Cannot navigate back to start URL mid-flow. Try interacting with elements on the current page instead.",
|
|
539
|
+
};
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Validate action
|
|
545
|
+
const actionValidation = securityValidator.validateAction(
|
|
546
|
+
action,
|
|
547
|
+
allowedDomains
|
|
548
|
+
);
|
|
549
|
+
if (!actionValidation.valid) {
|
|
550
|
+
iterationLogger.warn("Action blocked by security validator", {
|
|
551
|
+
action,
|
|
552
|
+
reason: actionValidation.reason,
|
|
553
|
+
});
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Check for exfiltration
|
|
558
|
+
const exfilCheck = securityValidator.detectExfiltrationAttempt(
|
|
559
|
+
action,
|
|
560
|
+
snapshot.content
|
|
561
|
+
);
|
|
562
|
+
if (exfilCheck.detected) {
|
|
563
|
+
iterationLogger.warn("Exfiltration attempt blocked", {
|
|
564
|
+
type: exfilCheck.type,
|
|
565
|
+
evidence: exfilCheck.evidence,
|
|
566
|
+
});
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Execute action
|
|
571
|
+
const actionKey = `${action.tool}:${JSON.stringify(action.args)}`;
|
|
572
|
+
|
|
573
|
+
// Check for action repeat
|
|
574
|
+
const recentActions = state.loopDetection.recentActions;
|
|
575
|
+
if (
|
|
576
|
+
recentActions.length >= 3 &&
|
|
577
|
+
recentActions.slice(-3).every((a) => a === actionKey)
|
|
578
|
+
) {
|
|
579
|
+
iterationLogger.warn("Action repeat blocked", { action: actionKey });
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
await executeAction(playwrightClient, action, iterationLogger);
|
|
584
|
+
|
|
585
|
+
// Record action
|
|
586
|
+
await workspaceManager.recordAction(input.analysisId, crawlId, {
|
|
587
|
+
step: state.step,
|
|
588
|
+
timestamp: new Date().toISOString(),
|
|
589
|
+
tool: action.tool,
|
|
590
|
+
args: action.args,
|
|
591
|
+
result: "success",
|
|
592
|
+
reasoning: actionResponse.reasoning,
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
state.actionHistory.push(
|
|
596
|
+
`Step ${state.step}: ${action.tool}(${JSON.stringify(action.args)}) - ${actionResponse.reasoning}`
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
recentActions.push(actionKey);
|
|
600
|
+
if (recentActions.length > 10) {
|
|
601
|
+
recentActions.shift();
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
state.step++;
|
|
606
|
+
|
|
607
|
+
// Checkpoint every N steps
|
|
608
|
+
if (state.step % config.checkpointInterval === 0) {
|
|
609
|
+
const checkpoint: CrawlCheckpoint = {
|
|
610
|
+
step: state.step,
|
|
611
|
+
timestamp: new Date().toISOString(),
|
|
612
|
+
visitedUrls: Array.from(state.visitedUrls),
|
|
613
|
+
currentUrl: state.currentUrl,
|
|
614
|
+
goalProgress: state.goalProgress,
|
|
615
|
+
canResume: true,
|
|
616
|
+
startUrl: state.startUrl,
|
|
617
|
+
// Preserve loop detection state for context continuity
|
|
618
|
+
loopDetection: {
|
|
619
|
+
domSignatures: Array.from(state.loopDetection.domSignatures.entries()),
|
|
620
|
+
urlVisits: Array.from(state.loopDetection.urlVisits.entries()),
|
|
621
|
+
recentActions: [...state.loopDetection.recentActions],
|
|
622
|
+
},
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
await workspaceManager.saveCheckpoint(
|
|
626
|
+
input.analysisId,
|
|
627
|
+
crawlId,
|
|
628
|
+
checkpoint
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
iterationLogger.info("Checkpoint saved", { step: state.step });
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Finalize crawl
|
|
636
|
+
const finalStatus = goalComplete
|
|
637
|
+
? "completed"
|
|
638
|
+
: blocked
|
|
639
|
+
? "failed"
|
|
640
|
+
: "completed";
|
|
641
|
+
|
|
642
|
+
await workspaceManager.updateCrawlIndex(input.analysisId, crawlId, {
|
|
643
|
+
status: finalStatus,
|
|
644
|
+
completedAt: new Date().toISOString(),
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
// Update workspace crawl reference
|
|
648
|
+
const updatedWorkspace = await workspaceManager.readWorkspaceIndex(
|
|
649
|
+
input.analysisId
|
|
650
|
+
);
|
|
651
|
+
const crawlRef = updatedWorkspace.crawls.find((c) => c.crawlId === crawlId);
|
|
652
|
+
if (crawlRef) {
|
|
653
|
+
crawlRef.status = finalStatus;
|
|
654
|
+
crawlRef.completedAt = new Date().toISOString();
|
|
655
|
+
crawlRef.pagesVisited = state.visitedUrls.size;
|
|
656
|
+
crawlRef.stepsExecuted = state.step;
|
|
657
|
+
await workspaceManager.updateWorkspaceIndex(input.analysisId, {
|
|
658
|
+
crawls: updatedWorkspace.crawls,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
await resourceManager.notifyListChanged();
|
|
663
|
+
|
|
664
|
+
const crawlIndex = await workspaceManager.readCrawlIndex(
|
|
665
|
+
input.analysisId,
|
|
666
|
+
crawlId
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
const result = {
|
|
670
|
+
crawlId,
|
|
671
|
+
status: finalStatus,
|
|
672
|
+
goalComplete,
|
|
673
|
+
blocked,
|
|
674
|
+
blockedReason,
|
|
675
|
+
pagesVisited: crawlIndex.pages.length,
|
|
676
|
+
stepsExecuted: state.step,
|
|
677
|
+
crawlIndexFilePath: join(crawlPath, "index.md"),
|
|
678
|
+
crawlIndexUri: `webtest://${input.analysisId}/crawls/${crawlId}/index.md`,
|
|
679
|
+
summaryUri: `webtest://${input.analysisId}/crawls/${crawlId}/summary.md`,
|
|
680
|
+
nextSteps: goalComplete
|
|
681
|
+
? [
|
|
682
|
+
`Use webtest_analyze_app with analysisId="${input.analysisId}" to analyze the application`,
|
|
683
|
+
]
|
|
684
|
+
: blocked
|
|
685
|
+
? [`Resolve the blocker: ${blockedReason}`]
|
|
686
|
+
: [
|
|
687
|
+
`Crawl completed with partial results`,
|
|
688
|
+
`Use webtest_analyze_app to analyze what was discovered`,
|
|
689
|
+
],
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
iterationLogger.info("Crawl completed", {
|
|
693
|
+
status: finalStatus,
|
|
694
|
+
pagesVisited: result.pagesVisited,
|
|
695
|
+
stepsExecuted: result.stepsExecuted,
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
return {
|
|
699
|
+
content: [
|
|
700
|
+
{
|
|
701
|
+
type: "text",
|
|
702
|
+
text: JSON.stringify(result, null, 2),
|
|
703
|
+
},
|
|
704
|
+
],
|
|
705
|
+
};
|
|
706
|
+
} catch (error) {
|
|
707
|
+
if (error instanceof CancellationError) {
|
|
708
|
+
crawlLogger.info("Crawl cancelled", { requestId: error.requestId });
|
|
709
|
+
|
|
710
|
+
return {
|
|
711
|
+
content: [
|
|
712
|
+
{
|
|
713
|
+
type: "text",
|
|
714
|
+
text: JSON.stringify(
|
|
715
|
+
{
|
|
716
|
+
status: "cancelled",
|
|
717
|
+
message: "Crawl was cancelled by user",
|
|
718
|
+
partialResultsAvailable: true,
|
|
719
|
+
},
|
|
720
|
+
null,
|
|
721
|
+
2
|
|
722
|
+
),
|
|
723
|
+
},
|
|
724
|
+
],
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
729
|
+
crawlLogger.error("Crawl failed", { error: message });
|
|
730
|
+
|
|
731
|
+
return {
|
|
732
|
+
content: [
|
|
733
|
+
{
|
|
734
|
+
type: "text",
|
|
735
|
+
text: `Error during crawl: ${message}`,
|
|
736
|
+
},
|
|
737
|
+
],
|
|
738
|
+
isError: true,
|
|
739
|
+
};
|
|
740
|
+
} finally {
|
|
741
|
+
cancellationRegistry.unregister(requestId);
|
|
742
|
+
}
|
|
743
|
+
},
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function createInitialState(startUrl: string): CrawlState {
|
|
748
|
+
return {
|
|
749
|
+
step: 0,
|
|
750
|
+
currentUrl: startUrl,
|
|
751
|
+
startUrl,
|
|
752
|
+
visitedUrls: new Set(),
|
|
753
|
+
actionHistory: [],
|
|
754
|
+
goalProgress: "Starting exploration",
|
|
755
|
+
loopDetection: {
|
|
756
|
+
domSignatures: new Map(),
|
|
757
|
+
urlVisits: new Map(),
|
|
758
|
+
recentActions: [],
|
|
759
|
+
},
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Check if a target URL is the start URL (for navigation guard).
|
|
765
|
+
* Compares normalized URLs to handle variations like trailing slashes and default paths.
|
|
766
|
+
*/
|
|
767
|
+
function isStartUrlNavigation(targetUrl: string, startUrl: string): boolean {
|
|
768
|
+
try {
|
|
769
|
+
const target = new URL(targetUrl, startUrl);
|
|
770
|
+
const start = new URL(startUrl);
|
|
771
|
+
|
|
772
|
+
// Normalize paths (remove trailing slash, handle "/" as empty)
|
|
773
|
+
const normalizePathname = (p: string) => {
|
|
774
|
+
const normalized = p.replace(/\/$/, "") || "/";
|
|
775
|
+
return normalized;
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
// Compare origin and pathname
|
|
779
|
+
const targetPath = normalizePathname(target.pathname);
|
|
780
|
+
const startPath = normalizePathname(start.pathname);
|
|
781
|
+
|
|
782
|
+
return target.origin === start.origin && targetPath === startPath;
|
|
783
|
+
} catch {
|
|
784
|
+
// If URL parsing fails, do simple string comparison
|
|
785
|
+
return targetUrl === startUrl || targetUrl === "/" || targetUrl === "";
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
async function executeAction(
|
|
790
|
+
playwright: PlaywrightClient,
|
|
791
|
+
action: { tool: string; args: Record<string, unknown> },
|
|
792
|
+
logger: { debug: (msg: string, data?: Record<string, unknown>) => void }
|
|
793
|
+
): Promise<void> {
|
|
794
|
+
logger.debug("Executing action", { tool: action.tool, args: action.args });
|
|
795
|
+
|
|
796
|
+
switch (action.tool) {
|
|
797
|
+
case "navigate":
|
|
798
|
+
await playwright.navigate(action.args.url as string);
|
|
799
|
+
break;
|
|
800
|
+
case "click":
|
|
801
|
+
await playwright.click(
|
|
802
|
+
action.args.element as string,
|
|
803
|
+
action.args.ref as string
|
|
804
|
+
);
|
|
805
|
+
break;
|
|
806
|
+
case "type":
|
|
807
|
+
await playwright.type(
|
|
808
|
+
action.args.element as string,
|
|
809
|
+
action.args.ref as string,
|
|
810
|
+
action.args.text as string,
|
|
811
|
+
{
|
|
812
|
+
submit: action.args.submit as boolean | undefined,
|
|
813
|
+
slowly: action.args.slowly as boolean | undefined,
|
|
814
|
+
}
|
|
815
|
+
);
|
|
816
|
+
break;
|
|
817
|
+
case "fill":
|
|
818
|
+
await playwright.fill(
|
|
819
|
+
action.args.element as string,
|
|
820
|
+
action.args.ref as string,
|
|
821
|
+
action.args.value as string
|
|
822
|
+
);
|
|
823
|
+
break;
|
|
824
|
+
case "hover":
|
|
825
|
+
await playwright.hover(
|
|
826
|
+
action.args.element as string,
|
|
827
|
+
action.args.ref as string
|
|
828
|
+
);
|
|
829
|
+
break;
|
|
830
|
+
case "select":
|
|
831
|
+
await playwright.select(
|
|
832
|
+
action.args.element as string,
|
|
833
|
+
action.args.ref as string,
|
|
834
|
+
action.args.values as string[]
|
|
835
|
+
);
|
|
836
|
+
break;
|
|
837
|
+
case "press":
|
|
838
|
+
await playwright.press(action.args.key as string);
|
|
839
|
+
break;
|
|
840
|
+
case "scroll":
|
|
841
|
+
await playwright.scroll(
|
|
842
|
+
action.args.x as number,
|
|
843
|
+
action.args.y as number
|
|
844
|
+
);
|
|
845
|
+
break;
|
|
846
|
+
case "wait":
|
|
847
|
+
await playwright.wait(action.args.ms as number);
|
|
848
|
+
break;
|
|
849
|
+
default:
|
|
850
|
+
logger.debug("Unknown action tool", { tool: action.tool });
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
async function handleElicitation(
|
|
855
|
+
elicitationClient: ElicitationClient,
|
|
856
|
+
elicitation: NonNullable<CrawlAction["elicitationNeeded"]>,
|
|
857
|
+
logger: { info: (msg: string, data?: Record<string, unknown>) => void }
|
|
858
|
+
): Promise<{ selectedValue?: string; cancelled?: boolean }> {
|
|
859
|
+
logger.info("Handling elicitation", {
|
|
860
|
+
type: elicitation.type,
|
|
861
|
+
context: elicitation.context,
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
let request;
|
|
865
|
+
switch (elicitation.type) {
|
|
866
|
+
case "cookie_consent":
|
|
867
|
+
request = elicitationClient.createCookieConsentRequest(elicitation.context);
|
|
868
|
+
break;
|
|
869
|
+
case "modal_blocking":
|
|
870
|
+
request = elicitationClient.createModalBlockingRequest(elicitation.context);
|
|
871
|
+
break;
|
|
872
|
+
case "ambiguous_navigation":
|
|
873
|
+
request = elicitationClient.createAmbiguousNavigationRequest(
|
|
874
|
+
(elicitation.options || []).map(opt => ({
|
|
875
|
+
url: opt.url || "",
|
|
876
|
+
label: opt.label,
|
|
877
|
+
}))
|
|
878
|
+
);
|
|
879
|
+
break;
|
|
880
|
+
case "auth_required":
|
|
881
|
+
request = elicitationClient.createAuthRequiredRequest();
|
|
882
|
+
break;
|
|
883
|
+
default:
|
|
884
|
+
return {};
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const result = await elicitationClient.elicit(request);
|
|
888
|
+
|
|
889
|
+
if (!result.success) {
|
|
890
|
+
// Fallback: include questions in next sampling prompt
|
|
891
|
+
logger.info("Elicitation fallback", {
|
|
892
|
+
fallbackQuestions: result.fallbackQuestions,
|
|
893
|
+
});
|
|
894
|
+
return {};
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
return {
|
|
898
|
+
selectedValue: result.selectedValue,
|
|
899
|
+
cancelled: result.cancelled,
|
|
900
|
+
};
|
|
901
|
+
}
|