@frontera-sdk/cli 1.52.39 → 1.52.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/package.json +6 -4
- package/src/adopt.ts +6 -2
- package/src/api/apps-api.ts +86 -4
- package/src/api/blueprint-authoring-api.ts +35 -11
- package/src/api/dataset-api.ts +29 -0
- package/src/api/governed-action-api.ts +90 -2
- package/src/api/platform-api.ts +87 -2
- package/src/api/workflow-api.ts +33 -2
- package/src/app-contract.ts +156 -0
- package/src/app-lock.ts +125 -0
- package/src/args.ts +14 -3
- package/src/auth-verify.ts +10 -0
- package/src/automation-template.ts +12 -1
- package/src/blueprint/action-rules.ts +516 -0
- package/src/blueprint/action-semantic-fit.ts +205 -0
- package/src/blueprint/compile.ts +51 -2
- package/src/blueprint/diff.ts +34 -2
- package/src/blueprint/metric-query.ts +215 -0
- package/src/blueprint/metric-rules.ts +122 -0
- package/src/blueprint/object-sets.ts +116 -0
- package/src/blueprint/ontology-edit-plan.ts +48 -4
- package/src/blueprint/projection.ts +24 -1
- package/src/blueprint/scaffold.ts +84 -42
- package/src/commands/action/available.ts +1 -1
- package/src/commands/action/criteria-refusal.ts +84 -0
- package/src/commands/action/decide.ts +5 -2
- package/src/commands/action/deploy.ts +13 -4
- package/src/commands/action/list.ts +134 -19
- package/src/commands/action/requests.ts +34 -6
- package/src/commands/action/submit.ts +28 -13
- package/src/commands/agent/index-commands.ts +69 -7
- package/src/commands/agent/resolve.ts +2 -0
- package/src/commands/app/checks.ts +44 -0
- package/src/commands/app/deploy.ts +243 -60
- package/src/commands/app/deployments.ts +97 -0
- package/src/commands/app/dev.ts +103 -11
- package/src/commands/app/init.ts +36 -3
- package/src/commands/app/promote.ts +45 -19
- package/src/commands/app/pull.ts +3 -1
- package/src/commands/app/rollback.ts +46 -0
- package/src/commands/app/sdk.ts +54 -8
- package/src/commands/app/unpin.ts +27 -0
- package/src/commands/app/versions.ts +1 -1
- package/src/commands/automation/bindings.ts +4 -2
- package/src/commands/automation/build-entry.ts +4 -1
- package/src/commands/automation/index-commands.ts +3 -1
- package/src/commands/automation/pull.ts +6 -1
- package/src/commands/automation/run.ts +34 -9
- package/src/commands/blueprint/bind.ts +2 -1
- package/src/commands/blueprint/declarative.ts +331 -31
- package/src/commands/blueprint/get.ts +6 -2
- package/src/commands/blueprint/grants.ts +64 -10
- package/src/commands/blueprint/metric.ts +108 -0
- package/src/commands/blueprint/object-sets.ts +71 -0
- package/src/commands/blueprint/query.ts +155 -31
- package/src/commands/dataset/index-commands.ts +156 -7
- package/src/commands/knowledge/index-commands.ts +122 -20
- package/src/commands/local/check.ts +657 -44
- package/src/commands/local/data.ts +33 -12
- package/src/commands/local/dev.ts +875 -88
- package/src/commands/local/docs-mcp.ts +10 -18
- package/src/commands/local/index-commands.ts +300 -63
- package/src/commands/local/init.ts +18 -5
- package/src/commands/local/key.ts +0 -3
- package/src/commands/local/providers.ts +0 -7
- package/src/commands/local/remove.ts +116 -0
- package/src/commands/local/schema.ts +3 -4
- package/src/commands/local/sdk-refresh.ts +59 -0
- package/src/commands/local/snapshot.ts +100 -0
- package/src/commands/local/sync-request.ts +320 -13
- package/src/commands/local/sync.ts +102 -5
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +52 -8
- package/src/commands/types.ts +29 -0
- package/src/commands/workflow/archive.ts +46 -0
- package/src/commands/workflow/get.ts +2 -1
- package/src/commands/workflow/index-commands.ts +5 -0
- package/src/commands/workflow/push.ts +49 -2
- package/src/commands/workflow/run.ts +65 -7
- package/src/commands/workflow/runs.ts +9 -6
- package/src/commands/workflow/webhook-test.ts +166 -0
- package/src/config.ts +43 -6
- package/src/dev-broker.ts +52 -0
- package/src/exit.ts +103 -4
- package/src/flag-help.ts +77 -17
- package/src/forge/touches.ts +15 -12
- package/src/function-outside-imports.ts +89 -0
- package/src/harness.ts +42 -24
- package/src/heal.ts +112 -1
- package/src/local/fix-words.ts +12 -0
- package/src/local/function-bundle-recovery.ts +775 -0
- package/src/local/local-actions.ts +103 -10
- package/src/local/local-agent-action-grants.ts +210 -0
- package/src/local/local-agent-checks.ts +232 -0
- package/src/local/local-agent-grants.ts +29 -7
- package/src/local/local-agent-lean-plugins.ts +68 -0
- package/src/local/local-agent-missing-tools.ts +68 -0
- package/src/local/local-agent-name.ts +27 -0
- package/src/local/local-app-code.ts +191 -0
- package/src/local/local-app-dev-files.ts +186 -0
- package/src/local/local-app-dev-session.ts +88 -0
- package/src/local/local-app-lock-install.ts +114 -0
- package/src/local/local-app-pulled.ts +167 -0
- package/src/local/local-app-restore.ts +111 -0
- package/src/local/local-app-runtime.ts +68 -0
- package/src/local/local-app-sdk-paths.ts +149 -0
- package/src/local/local-app-sdk.ts +509 -0
- package/src/local/local-applied.ts +211 -21
- package/src/local/local-apply-history.ts +84 -0
- package/src/local/local-apply-summary.ts +86 -0
- package/src/local/local-apply-watchdog.ts +95 -0
- package/src/local/local-apps.ts +180 -7
- package/src/local/local-blueprint-errors.ts +198 -0
- package/src/local/local-blueprint-outline.ts +31 -12
- package/src/local/local-blueprint-schemas.ts +582 -0
- package/src/local/local-build-inputs.ts +48 -0
- package/src/local/local-build-summary.ts +495 -28
- package/src/local/local-case-evidence.ts +120 -0
- package/src/local/local-case-not-run.ts +153 -0
- package/src/local/local-chat-apps.ts +93 -16
- package/src/local/local-check-inherited.ts +83 -0
- package/src/local/local-cities.ts +89 -0
- package/src/local/local-component-health.ts +119 -0
- package/src/local/local-control.ts +34 -7
- package/src/local/local-db-snapshot.ts +348 -0
- package/src/local/local-dev-references.ts +346 -35
- package/src/local/local-dev-sync.ts +136 -7
- package/src/local/local-docker.ts +35 -1
- package/src/local/local-docs-search.ts +1 -1
- package/src/local/local-docs.ts +281 -2
- package/src/local/local-empty-workflows.ts +92 -0
- package/src/local/local-env.ts +16 -1
- package/src/local/local-file-checks.ts +104 -0
- package/src/local/local-function-ids.ts +141 -0
- package/src/local/local-function-recovery.ts +141 -0
- package/src/local/local-function-state.ts +87 -0
- package/src/local/local-keys.ts +7 -5
- package/src/local/local-knowledge-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- package/src/local/local-media-placeholders.ts +182 -0
- package/src/local/local-media-sets.ts +90 -10
- package/src/local/local-model-parameters.ts +89 -0
- package/src/local/local-object-sets.ts +18 -6
- package/src/local/local-operating.ts +35 -1
- package/src/local/local-org-settings.ts +205 -7
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +141 -16
- package/src/local/local-ports.ts +99 -7
- package/src/local/local-project-notices.ts +234 -0
- package/src/local/local-project-secrets.ts +133 -0
- package/src/local/local-project.ts +12 -0
- package/src/local/local-provider-refusal.ts +66 -0
- package/src/local/local-request-evidence.ts +112 -0
- package/src/local/local-runtime-health.ts +123 -0
- package/src/local/local-runtime-history.ts +50 -0
- package/src/local/local-runtime-target.ts +42 -0
- package/src/local/local-sample-data.ts +141 -0
- package/src/local/local-sample-remake.ts +138 -0
- package/src/local/local-scaffold.ts +336 -53
- package/src/local/local-schema-check.ts +148 -0
- package/src/local/local-schemas.ts +742 -25
- package/src/local/local-secret-carry.ts +107 -0
- package/src/local/local-secrets.ts +38 -8
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +1 -1
- package/src/local/local-state.ts +43 -2
- package/src/local/local-studio-server.ts +170 -7
- package/src/local/local-supervisor.ts +44 -6
- package/src/local/local-sync-names.ts +2 -0
- package/src/local/local-sync-plan.ts +11 -0
- package/src/local/local-sync-push.ts +10 -7
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +343 -32
- package/src/local/local-sync-snapshot.ts +15 -10
- package/src/local/local-synthetic-data.ts +2116 -99
- package/src/local/local-test-baseline.ts +113 -0
- package/src/local/local-test-cases.ts +76 -1
- package/src/local/local-test-runner.ts +582 -91
- package/src/local/local-test-runs.ts +13 -5
- package/src/local/local-vendored-sdk.ts +183 -0
- package/src/local/local-workflow-state.ts +16 -3
- package/src/local/local-workflow-tests.ts +482 -0
- package/src/local/local-workspace-grants.ts +81 -0
- package/src/main.ts +50 -9
- package/src/mcp/a11y-scan.ts +55 -0
- package/src/mcp/browser/ax-snapshot.ts +181 -0
- package/src/mcp/browser/credentials.ts +78 -0
- package/src/mcp/browser/frames.ts +336 -0
- package/src/mcp/browser/headless-session.ts +413 -0
- package/src/mcp/browser/headless-tab.ts +575 -0
- package/src/mcp/browser/keys.ts +70 -0
- package/src/mcp/browser/page-scripts.ts +129 -0
- package/src/mcp/browser/wait.ts +73 -0
- package/src/mcp/forge-link.ts +126 -0
- package/src/mcp/headless.ts +199 -0
- package/src/mcp/mcp-apps.ts +435 -0
- package/src/mcp/mcp-chat.ts +226 -0
- package/src/mcp/mcp-cli.ts +53 -0
- package/src/mcp/mcp-protocol.ts +122 -0
- package/src/mcp/mcp-runtime.ts +113 -0
- package/src/mcp/mcp-server.ts +108 -0
- package/src/mcp/mcp-traces.ts +409 -0
- package/src/mcp/page-observation.ts +193 -0
- package/src/mcp/tools/agent-tools.ts +700 -0
- package/src/mcp/tools/app-tools.ts +241 -0
- package/src/mcp/tools/browser-tools.ts +310 -0
- package/src/mcp/tools/docs-tools.ts +32 -0
- package/src/mcp/tools/project-tools.ts +205 -0
- package/src/mcp/tools/test-tools.ts +339 -0
- package/src/mcp/tools/workflow-tools.ts +611 -0
- package/src/output.ts +1 -1
- package/src/pack.ts +8 -3
- package/src/packaging.ts +1 -1
- package/src/project-bootstrap.ts +107 -4
- package/src/project-context.ts +18 -1
- package/src/project.ts +27 -2
- package/src/provenance.ts +25 -0
- package/src/render-evidence.ts +65 -7
- package/src/template.ts +14 -8
- package/src/templates/next-app-files.ts +35 -6
- package/src/templates/next-skills.ts +61 -27
- package/src/vendor/kit-assets.json +6 -6
- package/src/vendor/sdk-sources.json +24 -21
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
import type { CDPSession, Dialog, Frame, Page } from 'playwright-core'
|
|
2
|
+
|
|
3
|
+
import { formatSnapshot, RefBook, type Snapshot } from './ax-snapshot'
|
|
4
|
+
import { credentialsIn, redactWith, STUDIO_SESSION } from './credentials'
|
|
5
|
+
import { conditionHolds, FRAMES_AGAIN_MS, PageFrames, type Cdp, type PageFrame } from './frames'
|
|
6
|
+
import type { KeyPress } from './keys'
|
|
7
|
+
import { CARET_TO_END, evaluateOnElement, evaluateWrapper, FOCUS, PAGE_TEXT, SELECT_CONTENTS, SELECT_OPTIONS, selectorVisible, textPresent, VISIBLE } from './page-scripts'
|
|
8
|
+
import type { WaitCondition } from './wait'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One page of the headless browser: a Playwright page driven through its
|
|
12
|
+
* DevTools Protocol session the way Forge drives a tab of its Browser, so a
|
|
13
|
+
* snapshot, its refs and every action behave the same in both. Refs are DOM
|
|
14
|
+
* nodes (`backendNodeId`): a ref keeps naming its element until the page
|
|
15
|
+
* goes to another document. Frames (an App embedded in Studio) are read into
|
|
16
|
+
* the snapshot and acted in through `PageFrames`; a frame Chrome renders in a
|
|
17
|
+
* process of its own is reached through a DevTools session of its own.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const CONSOLE_KEEP = 500
|
|
21
|
+
const NETWORK_KEEP = 500
|
|
22
|
+
const CDP_TIMEOUT_MS = 20_000
|
|
23
|
+
|
|
24
|
+
export type DeviceWidth = 'desktop' | 'tablet' | 'phone'
|
|
25
|
+
export const DEVICE_SIZES: Record<DeviceWidth, { label: string; width: number; height: number }> = {
|
|
26
|
+
desktop: { label: 'Desktop · 1440', width: 1440, height: 900 },
|
|
27
|
+
tablet: { label: 'Tablet · 768', width: 768, height: 1024 },
|
|
28
|
+
phone: { label: 'Phone · 375', width: 375, height: 812 },
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ConsoleEntry {
|
|
32
|
+
level: 'error' | 'warning' | 'info' | 'log' | 'debug'
|
|
33
|
+
text: string
|
|
34
|
+
source: string | null
|
|
35
|
+
at: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface NetworkEntry {
|
|
39
|
+
id: string
|
|
40
|
+
method: string
|
|
41
|
+
url: string
|
|
42
|
+
status: number | null
|
|
43
|
+
type: string
|
|
44
|
+
ms: number | null
|
|
45
|
+
failed: string | null
|
|
46
|
+
at: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A request that failed: refused, aborted, or answered 400 or above. */
|
|
50
|
+
export const requestFailed = (n: Pick<NetworkEntry, 'failed' | 'status'>): boolean => !!n.failed || (n.status ?? 0) >= 400
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `browser_network`'s answer: the requests, failed ones first (only them with
|
|
54
|
+
* `onlyFailed`), and how many were captured when none is shown, so "none
|
|
55
|
+
* failed" never reads as "nothing was captured".
|
|
56
|
+
*/
|
|
57
|
+
export function networkText(entries: readonly NetworkEntry[], onlyFailed: boolean, limit = 200): string {
|
|
58
|
+
const failed = entries.filter(requestFailed)
|
|
59
|
+
const shown = (onlyFailed ? failed : [...failed, ...entries.filter((n) => !requestFailed(n))]).slice(0, limit)
|
|
60
|
+
if (!entries.length) return 'No requests captured since the tab opened.'
|
|
61
|
+
if (!shown.length) return `${entries.length} request${entries.length === 1 ? '' : 's'} captured since the tab opened; none failed.`
|
|
62
|
+
const lines = shown.map((n) => `${n.method} ${n.url.slice(0, 200)} → ${n.failed ?? n.status ?? 'pending'}${n.ms !== null ? ` (${n.ms} ms)` : ''}`)
|
|
63
|
+
// With onlyFailed, the rest are said as a count: what was captured besides the failures.
|
|
64
|
+
const rest = onlyFailed ? entries.length - failed.length : 0
|
|
65
|
+
return [...lines, ...(rest ? [`${rest} more request${rest === 1 ? '' : 's'} captured, none failed.`] : [])].join('\n')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface PageDialog {
|
|
69
|
+
type: 'alert' | 'confirm' | 'prompt' | 'beforeunload'
|
|
70
|
+
message: string
|
|
71
|
+
defaultPrompt: string
|
|
72
|
+
at: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
76
|
+
|
|
77
|
+
/** A loopback address, or a page with no address: what belongs to the project on this machine. */
|
|
78
|
+
export function isProjectAddress(url: string): boolean {
|
|
79
|
+
try {
|
|
80
|
+
const { hostname, protocol } = new URL(url)
|
|
81
|
+
if (protocol === 'about:' || protocol === 'data:') return true
|
|
82
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname.endsWith('.localhost')
|
|
83
|
+
} catch {
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The site an address belongs to, for an answer: `github.com`. */
|
|
89
|
+
export function siteOf(url: string): string {
|
|
90
|
+
try {
|
|
91
|
+
return new URL(url).host
|
|
92
|
+
} catch {
|
|
93
|
+
return url
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let nextId = 0
|
|
98
|
+
|
|
99
|
+
export class HeadlessTab {
|
|
100
|
+
readonly id = `h${++nextId}`
|
|
101
|
+
readonly createdAt = Date.now()
|
|
102
|
+
device: DeviceWidth = 'desktop'
|
|
103
|
+
viewport: { width: number; height: number | null } | null = null
|
|
104
|
+
/** When the agent last acted in it. */
|
|
105
|
+
agentAt = Date.now()
|
|
106
|
+
snapshot: Snapshot | null = null
|
|
107
|
+
snapshotAt = 0
|
|
108
|
+
navigatedAt = 0
|
|
109
|
+
readonly refs = new RefBook()
|
|
110
|
+
dialog: PageDialog | null = null
|
|
111
|
+
console: ConsoleEntry[] = []
|
|
112
|
+
network = new Map<string, NetworkEntry>()
|
|
113
|
+
private pendingDialog: Dialog | null = null
|
|
114
|
+
private dialogWaiters = new Set<() => void>()
|
|
115
|
+
/** Each secret this tab has seen, with what it is shown as instead. */
|
|
116
|
+
private secrets = new Map<string, string>()
|
|
117
|
+
private inflight = new Set<string>()
|
|
118
|
+
private requestIds = new WeakMap<object, string>()
|
|
119
|
+
private requestCounter = 0
|
|
120
|
+
private closed = false
|
|
121
|
+
readonly frames: PageFrames
|
|
122
|
+
/** Sessions of the frames Chrome renders in a process of their own, by frame id. */
|
|
123
|
+
private frameSessions = new Map<string, { session: CDPSession; cdp: Cdp }>()
|
|
124
|
+
private frameIds = new WeakMap<Frame, string | null>()
|
|
125
|
+
|
|
126
|
+
private constructor(
|
|
127
|
+
readonly page: Page,
|
|
128
|
+
private readonly session: CDPSession,
|
|
129
|
+
/** The project folder the tab was opened for, where its screenshots go. */
|
|
130
|
+
readonly root: string,
|
|
131
|
+
) {
|
|
132
|
+
this.frames = new PageFrames({ root: (method, params) => this.cdp(method, params), outOfProcess: (frameId) => this.outOfProcess(frameId) }, 'frontera')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** One DevTools command on a session, bounded. */
|
|
136
|
+
private bounded<T>(session: CDPSession, method: string, params: Record<string, unknown>): Promise<T> {
|
|
137
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
138
|
+
return (Promise.race([
|
|
139
|
+
session.send(method as never, params as never),
|
|
140
|
+
new Promise((_, reject) => {
|
|
141
|
+
timer = setTimeout(() => reject(new Error(`the page did not answer ${method}`)), CDP_TIMEOUT_MS)
|
|
142
|
+
}),
|
|
143
|
+
]) as Promise<T>).finally(() => clearTimeout(timer))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A frame Chrome renders in a process of its own has a session of its own;
|
|
148
|
+
* Playwright opens one for such a frame, and refuses for a frame that
|
|
149
|
+
* renders in its parent's process.
|
|
150
|
+
*/
|
|
151
|
+
private async outOfProcess(frameId: string): Promise<Cdp | null> {
|
|
152
|
+
const known = this.frameSessions.get(frameId)
|
|
153
|
+
if (known) return known.cdp
|
|
154
|
+
for (const frame of this.page.frames()) {
|
|
155
|
+
if (frame === this.page.mainFrame() || this.frameIds.has(frame)) continue
|
|
156
|
+
try {
|
|
157
|
+
const session = await this.page.context().newCDPSession(frame)
|
|
158
|
+
const tree = await this.bounded<{ frameTree: { frame: { id: string } } }>(session, 'Page.getFrameTree', {})
|
|
159
|
+
const id = tree.frameTree.frame.id
|
|
160
|
+
this.frameIds.set(frame, id)
|
|
161
|
+
const cdp: Cdp = (method, params) => (this.alive ? this.bounded(session, method, params ?? {}) : Promise.reject(new Error('the page is closed')))
|
|
162
|
+
this.frameSessions.set(id, { session, cdp })
|
|
163
|
+
await Promise.all([cdp('DOM.enable'), cdp('Page.enable'), cdp('Runtime.enable')]).catch(() => {})
|
|
164
|
+
} catch {
|
|
165
|
+
this.frameIds.set(frame, null)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return this.frameSessions.get(frameId)?.cdp ?? null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A frame that navigated may have moved to another process: look its session up again. */
|
|
172
|
+
private forgetFrame(frame: Frame): void {
|
|
173
|
+
const id = this.frameIds.get(frame)
|
|
174
|
+
this.frameIds.delete(frame)
|
|
175
|
+
if (!id) return
|
|
176
|
+
const held = this.frameSessions.get(id)
|
|
177
|
+
this.frameSessions.delete(id)
|
|
178
|
+
this.frames.frameNavigated(id)
|
|
179
|
+
void held?.session.detach().catch(() => {})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
static async open(page: Page, root: string): Promise<HeadlessTab> {
|
|
183
|
+
const session = await page.context().newCDPSession(page)
|
|
184
|
+
const tab = new HeadlessTab(page, session, root)
|
|
185
|
+
tab.attach()
|
|
186
|
+
await Promise.all([session.send('DOM.enable'), session.send('Page.enable'), session.send('Runtime.enable')]).catch(() => {})
|
|
187
|
+
return tab
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
get alive(): boolean {
|
|
191
|
+
return !this.closed && !this.page.isClosed()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** One DevTools command, bounded: a page that is gone must not hold the agent. */
|
|
195
|
+
async cdp<T = Record<string, unknown>>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
|
196
|
+
if (!this.alive) throw new Error('the page is closed')
|
|
197
|
+
return this.bounded<T>(this.session, method, params)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private attach(): void {
|
|
201
|
+
const { page } = this
|
|
202
|
+
// Another document: its nodes, and so its refs, are new. Same-document navigations keep them.
|
|
203
|
+
this.session.on('Page.frameNavigated', (p: { frame: { id: string; parentId?: string } }) => {
|
|
204
|
+
if (p.frame.parentId) {
|
|
205
|
+
this.frames.frameNavigated(p.frame.id)
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
this.navigatedAt = Date.now()
|
|
209
|
+
this.refs.reset()
|
|
210
|
+
this.frames.reset()
|
|
211
|
+
})
|
|
212
|
+
page.on('framenavigated', (f) => {
|
|
213
|
+
// A frame's address can carry a credential (an App's launch token).
|
|
214
|
+
this.noteSecretsIn(f.url())
|
|
215
|
+
if (f !== page.mainFrame()) this.forgetFrame(f)
|
|
216
|
+
})
|
|
217
|
+
page.on('framedetached', (f) => this.forgetFrame(f))
|
|
218
|
+
page.on('console', (m) => {
|
|
219
|
+
const type = m.type()
|
|
220
|
+
const level: ConsoleEntry['level'] = type === 'error' || type === 'assert' ? 'error' : type === 'warning' ? 'warning' : type === 'info' ? 'info' : type === 'debug' ? 'debug' : 'log'
|
|
221
|
+
const loc = m.location()
|
|
222
|
+
this.pushConsole({ level, text: m.text().slice(0, 2_000), source: loc?.url ? `${loc.url.split('/').pop()}:${(loc.lineNumber ?? 0) + 1}` : null, at: Date.now() })
|
|
223
|
+
})
|
|
224
|
+
page.on('pageerror', (e) => this.pushConsole({ level: 'error', text: `Uncaught ${String(e?.stack ?? e?.message ?? e).slice(0, 2_000)}`, source: null, at: Date.now() }))
|
|
225
|
+
page.on('request', (r) => {
|
|
226
|
+
if (r.url().startsWith('data:')) return
|
|
227
|
+
// Before the request is listed: its address can carry a credential (an App frame's launch token).
|
|
228
|
+
this.noteSecretsIn(r.url())
|
|
229
|
+
const id = `r${++this.requestCounter}`
|
|
230
|
+
this.requestIds.set(r, id)
|
|
231
|
+
this.inflight.add(id)
|
|
232
|
+
this.putNetwork({ id, method: r.method(), url: r.url(), status: null, type: r.resourceType(), ms: null, failed: null, at: Date.now() })
|
|
233
|
+
})
|
|
234
|
+
page.on('response', (res) => {
|
|
235
|
+
const id = this.requestIds.get(res.request())
|
|
236
|
+
const entry = id ? this.network.get(id) : undefined
|
|
237
|
+
if (entry) this.putNetwork({ ...entry, status: res.status() })
|
|
238
|
+
})
|
|
239
|
+
const finish = (r: import('playwright-core').Request, failed: string | null) => {
|
|
240
|
+
const id = this.requestIds.get(r)
|
|
241
|
+
if (!id) return
|
|
242
|
+
this.inflight.delete(id)
|
|
243
|
+
const entry = this.network.get(id)
|
|
244
|
+
if (entry) this.putNetwork({ ...entry, ms: Date.now() - entry.at, failed })
|
|
245
|
+
}
|
|
246
|
+
page.on('requestfinished', (r) => finish(r, null))
|
|
247
|
+
page.on('requestfailed', (r) => finish(r, r.failure()?.errorText ?? 'failed'))
|
|
248
|
+
// With a listener, Playwright leaves the dialog open until it is answered, as a person would see it.
|
|
249
|
+
page.on('dialog', (d) => {
|
|
250
|
+
const type = d.type()
|
|
251
|
+
this.pendingDialog = d
|
|
252
|
+
this.dialog = {
|
|
253
|
+
type: type === 'confirm' || type === 'prompt' || type === 'beforeunload' ? type : 'alert',
|
|
254
|
+
message: this.redact(d.message()).slice(0, 2_000),
|
|
255
|
+
defaultPrompt: d.defaultValue(),
|
|
256
|
+
at: Date.now(),
|
|
257
|
+
}
|
|
258
|
+
for (const wake of this.dialogWaiters) wake()
|
|
259
|
+
})
|
|
260
|
+
page.on('close', () => {
|
|
261
|
+
this.closed = true
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Never show a secret this tab was given or saw: `text` with each one blanked out. */
|
|
266
|
+
redact(text: string): string {
|
|
267
|
+
return redactWith(text, this.secrets)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** A credential on a local address (a signed Studio link, an App's launch token) is a secret from here on. */
|
|
271
|
+
noteSecretsIn(url: string): void {
|
|
272
|
+
for (const c of credentialsIn(url)) this.addSecret(c.secret, c.label)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
addSecret(secret: string | null | undefined, label = STUDIO_SESSION): void {
|
|
276
|
+
if (secret && secret.length >= 8 && !this.secrets.has(secret)) this.secrets.set(secret, label)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private pushConsole(entry: ConsoleEntry): void {
|
|
280
|
+
if (this.secrets.size) entry = { ...entry, text: this.redact(entry.text), source: entry.source ? this.redact(entry.source) : null }
|
|
281
|
+
this.console.push(entry)
|
|
282
|
+
if (this.console.length > CONSOLE_KEEP) this.console.splice(0, this.console.length - CONSOLE_KEEP)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private putNetwork(entry: NetworkEntry): void {
|
|
286
|
+
if (this.secrets.size) entry = { ...entry, url: this.redact(entry.url) }
|
|
287
|
+
this.network.set(entry.id, entry)
|
|
288
|
+
if (this.network.size > NETWORK_KEEP) this.network.delete(this.network.keys().next().value!)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
get url(): string {
|
|
292
|
+
return this.alive ? this.redact(this.page.url()) : ''
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async title(): Promise<string> {
|
|
296
|
+
return this.alive ? this.redact(await this.page.title().catch(() => '')) : ''
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Load an address and wait for its document, bounded; why it failed, or null. */
|
|
300
|
+
async load(url: string, timeoutMs = 30_000): Promise<string | null> {
|
|
301
|
+
this.noteSecretsIn(url)
|
|
302
|
+
try {
|
|
303
|
+
await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: timeoutMs })
|
|
304
|
+
return null
|
|
305
|
+
} catch (err) {
|
|
306
|
+
const message = (err as Error).message
|
|
307
|
+
// A redirect, or a page that aborted its own load, still shows something.
|
|
308
|
+
if (/ERR_ABORTED/.test(message)) return null
|
|
309
|
+
// A dialog during load holds it; the page is where it is.
|
|
310
|
+
if (this.dialog) return null
|
|
311
|
+
return this.redact(message.split('\n')[0] ?? message).slice(0, 300)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Back, forward or reload, waiting for the page it went to; why it failed, or null. */
|
|
316
|
+
async go(action: 'back' | 'forward' | 'reload', timeoutMs = 30_000): Promise<string | null> {
|
|
317
|
+
try {
|
|
318
|
+
if (action === 'reload') {
|
|
319
|
+
await this.page.reload({ waitUntil: 'domcontentloaded', timeout: timeoutMs })
|
|
320
|
+
return null
|
|
321
|
+
}
|
|
322
|
+
const h = await this.cdp<{ currentIndex: number; entries: unknown[] }>('Page.getNavigationHistory').catch(() => null)
|
|
323
|
+
if (h && action === 'back' && h.currentIndex <= 0) return 'There is no page to go back to.'
|
|
324
|
+
if (h && action === 'forward' && h.currentIndex >= h.entries.length - 1) return 'There is no page to go forward to.'
|
|
325
|
+
// Null for a step inside the same document, which still moved the page.
|
|
326
|
+
if (action === 'back') await this.page.goBack({ waitUntil: 'domcontentloaded', timeout: timeoutMs })
|
|
327
|
+
else await this.page.goForward({ waitUntil: 'domcontentloaded', timeout: timeoutMs })
|
|
328
|
+
return null
|
|
329
|
+
} catch (err) {
|
|
330
|
+
const message = (err as Error).message
|
|
331
|
+
return /ERR_ABORTED/.test(message) ? null : this.redact(message.split('\n')[0] ?? message).slice(0, 300)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Wait until no request runs for a moment, bounded: a dev server's
|
|
337
|
+
* websocket never closes, so this never fails. True when the page went
|
|
338
|
+
* quiet, false when the time ran out first.
|
|
339
|
+
*/
|
|
340
|
+
async settle(maxMs = 8_000, quietMs = 500): Promise<boolean> {
|
|
341
|
+
const deadline = Date.now() + maxMs
|
|
342
|
+
let quietSince = this.inflight.size === 0 ? Date.now() : 0
|
|
343
|
+
while (Date.now() < deadline && this.alive) {
|
|
344
|
+
await sleep(100)
|
|
345
|
+
if (this.dialog) return false
|
|
346
|
+
if (this.inflight.size) quietSince = 0
|
|
347
|
+
else if (!quietSince) quietSince = Date.now()
|
|
348
|
+
else if (Date.now() - quietSince >= quietMs) return true
|
|
349
|
+
}
|
|
350
|
+
return false
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Evaluate the browser's own expression in the main document's isolated world. */
|
|
354
|
+
evaluate<T>(expression: string): Promise<T | null> {
|
|
355
|
+
return this.frames.evaluate<T>(this.frames.main, expression)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Call the browser's function on an element (its handle), in its frame's isolated world. */
|
|
359
|
+
callOn<T>(handle: number, fn: string, args: unknown[] = []): Promise<T | null> {
|
|
360
|
+
return this.frames.callOn<T>(handle, fn, args)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async takeSnapshot(): Promise<Snapshot> {
|
|
364
|
+
const { nodes, frames } = await this.frames.snapshotTree()
|
|
365
|
+
const snap = formatSnapshot(nodes, { url: this.url, title: await this.title(), refFor: this.refs.refFor, frames })
|
|
366
|
+
this.snapshot = this.secrets.size ? { ...snap, text: this.redact(snap.text) } : snap
|
|
367
|
+
this.snapshotAt = Date.now()
|
|
368
|
+
return this.snapshot
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
nodeForRef(ref: string): number | undefined {
|
|
372
|
+
return this.refs.node(ref)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** The address of the frame an element is in, null for one on the page itself. */
|
|
376
|
+
frameAddressOf(handle: number): string | null {
|
|
377
|
+
return this.frames.addressOf(handle)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async isShown(backendNodeId: number): Promise<boolean> {
|
|
381
|
+
try {
|
|
382
|
+
return (await this.callOn<boolean>(backendNodeId, VISIBLE)) === true
|
|
383
|
+
} catch {
|
|
384
|
+
return false
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private holds(c: WaitCondition, frames: PageFrame[]): Promise<boolean | string> {
|
|
389
|
+
return conditionHolds(this.frames, frames, c, (handle) => this.isShown(handle))
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Wait until the condition holds, bounded: network idle first, then the rest every 200 ms. */
|
|
393
|
+
async waitFor(c: WaitCondition, timeoutMs: number): Promise<{ met: boolean; waitedMs: number; error?: string }> {
|
|
394
|
+
const start = Date.now()
|
|
395
|
+
const deadline = start + timeoutMs
|
|
396
|
+
if (c.networkIdle) {
|
|
397
|
+
const quiet = await this.settle(timeoutMs, 500)
|
|
398
|
+
if (!quiet) return { met: false, waitedMs: Date.now() - start }
|
|
399
|
+
}
|
|
400
|
+
const rest = c.text !== undefined || c.textGone !== undefined || c.selector !== undefined || c.ref !== undefined
|
|
401
|
+
if (!rest) return { met: true, waitedMs: Date.now() - start }
|
|
402
|
+
// Text and selectors are looked for in every frame (an App embedded in Studio), found again now and then.
|
|
403
|
+
const scan = c.text !== undefined || c.textGone !== undefined || c.selector !== undefined
|
|
404
|
+
let frames: PageFrame[] = scan ? await this.frames.all() : [this.frames.main]
|
|
405
|
+
let found = Date.now()
|
|
406
|
+
for (;;) {
|
|
407
|
+
if (this.dialog) return { met: false, waitedMs: Date.now() - start }
|
|
408
|
+
if (scan && Date.now() - found > FRAMES_AGAIN_MS) {
|
|
409
|
+
frames = await this.frames.all()
|
|
410
|
+
found = Date.now()
|
|
411
|
+
}
|
|
412
|
+
const h = await this.holds(c, frames)
|
|
413
|
+
if (typeof h === 'string') return { met: false, waitedMs: Date.now() - start, error: h }
|
|
414
|
+
if (h) return { met: true, waitedMs: Date.now() - start }
|
|
415
|
+
if (Date.now() >= deadline || !this.alive) return { met: false, waitedMs: Date.now() - start }
|
|
416
|
+
await sleep(200)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Run an input the page may answer with a dialog: it returns when the input is done, or as soon as a dialog opens. */
|
|
421
|
+
private async unlessDialog(action: () => Promise<void>): Promise<void> {
|
|
422
|
+
if (this.dialog) return
|
|
423
|
+
let wake: () => void = () => {}
|
|
424
|
+
const opened = new Promise<'dialog'>((r) => {
|
|
425
|
+
wake = () => r('dialog')
|
|
426
|
+
})
|
|
427
|
+
this.dialogWaiters.add(wake)
|
|
428
|
+
const run = action().then(
|
|
429
|
+
() => null,
|
|
430
|
+
(err: Error) => err,
|
|
431
|
+
)
|
|
432
|
+
try {
|
|
433
|
+
const first = await Promise.race([run, opened])
|
|
434
|
+
if (first instanceof Error && !this.dialog) throw first
|
|
435
|
+
} finally {
|
|
436
|
+
this.dialogWaiters.delete(wake)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async answerDialog(accept: boolean, promptText?: string): Promise<void> {
|
|
441
|
+
const d = this.pendingDialog
|
|
442
|
+
if (!d) throw new Error('no dialog is open')
|
|
443
|
+
if (accept) await d.accept(promptText)
|
|
444
|
+
else await d.dismiss()
|
|
445
|
+
this.pendingDialog = null
|
|
446
|
+
this.dialog = null
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** The middle of an element on screen, scrolled into view (with the frames it is in). */
|
|
450
|
+
center(handle: number): Promise<{ x: number; y: number }> {
|
|
451
|
+
return this.frames.center(handle)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async click(backendNodeId: number, opts: { double?: boolean; button?: 'left' | 'right' | 'middle' } = {}): Promise<void> {
|
|
455
|
+
const at = await this.center(backendNodeId)
|
|
456
|
+
const button = opts.button ?? 'left'
|
|
457
|
+
await this.unlessDialog(async () => {
|
|
458
|
+
await this.cdp('Input.dispatchMouseEvent', { type: 'mouseMoved', x: at.x, y: at.y })
|
|
459
|
+
for (let n = 1; n <= (opts.double ? 2 : 1); n++) {
|
|
460
|
+
await this.cdp('Input.dispatchMouseEvent', { type: 'mousePressed', x: at.x, y: at.y, button, clickCount: n })
|
|
461
|
+
await this.cdp('Input.dispatchMouseEvent', { type: 'mouseReleased', x: at.x, y: at.y, button, clickCount: n })
|
|
462
|
+
}
|
|
463
|
+
})
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async hover(backendNodeId: number): Promise<void> {
|
|
467
|
+
const at = await this.center(backendNodeId)
|
|
468
|
+
await this.cdp('Input.dispatchMouseEvent', { type: 'mouseMoved', x: at.x, y: at.y })
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async pressKey(press: KeyPress, backendNodeId?: number): Promise<void> {
|
|
472
|
+
if (backendNodeId !== undefined) {
|
|
473
|
+
await this.center(backendNodeId).catch(() => null)
|
|
474
|
+
await this.frames.focus(backendNodeId, FOCUS)
|
|
475
|
+
}
|
|
476
|
+
await this.unlessDialog(async () => {
|
|
477
|
+
const base = { key: press.key, code: press.code, windowsVirtualKeyCode: press.keyCode, nativeVirtualKeyCode: press.keyCode, modifiers: press.modifiers }
|
|
478
|
+
await this.cdp('Input.dispatchKeyEvent', { type: press.text ? 'keyDown' : 'rawKeyDown', ...base, ...(press.text ? { text: press.text, unmodifiedText: press.text } : {}) })
|
|
479
|
+
await this.cdp('Input.dispatchKeyEvent', { type: 'keyUp', ...base })
|
|
480
|
+
})
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async selectOptions(backendNodeId: number, values: string[]): Promise<{ values: string[] } | { error: string }> {
|
|
484
|
+
await this.center(backendNodeId).catch(() => null)
|
|
485
|
+
return (await this.callOn<{ values: string[] } | { error: string }>(backendNodeId, SELECT_OPTIONS, [values])) ?? { error: 'the page did not answer' }
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* The agent's expression, in the page's own world (where the App's state
|
|
490
|
+
* lives), bounded in time. With an element (its handle), in the world of
|
|
491
|
+
* the frame it is in, a function called with the element.
|
|
492
|
+
*/
|
|
493
|
+
async evaluateInPage(expression: string, cap: number, timeoutMs = 10_000, handle?: number): Promise<{ type: string; json: string; length: number } | { error: string }> {
|
|
494
|
+
type Evaluated = { result: { value?: { type: string; json: string; length: number } }; exceptionDetails?: { text?: string; exception?: { description?: string } } }
|
|
495
|
+
const box: { res: Evaluated | null } = { res: null }
|
|
496
|
+
await this.unlessDialog(async () => {
|
|
497
|
+
const run = async (): Promise<Evaluated> => {
|
|
498
|
+
if (handle === undefined) return this.cdp<Evaluated>('Runtime.evaluate', { expression: evaluateWrapper(expression, cap), returnByValue: true, awaitPromise: true, userGesture: false, timeout: timeoutMs })
|
|
499
|
+
const { frame, node } = this.frames.locate(handle)
|
|
500
|
+
const resolved = await frame.cdp<{ object: { objectId?: string } }>('DOM.resolveNode', { backendNodeId: node })
|
|
501
|
+
if (!resolved.object.objectId) throw new Error('the element is gone: take a new browser_snapshot')
|
|
502
|
+
return frame.cdp<Evaluated>('Runtime.callFunctionOn', { objectId: resolved.object.objectId, functionDeclaration: evaluateOnElement(expression, cap), returnByValue: true, awaitPromise: true, userGesture: false })
|
|
503
|
+
}
|
|
504
|
+
box.res = await Promise.race([run().catch((err: Error) => ({ result: {}, exceptionDetails: { text: err.message } }) as Evaluated), sleep(timeoutMs + 1_000).then(() => null)])
|
|
505
|
+
})
|
|
506
|
+
if (this.dialog) return { error: 'it opened a dialog' }
|
|
507
|
+
const res = box.res
|
|
508
|
+
if (!res) return { error: `it did not finish within ${timeoutMs / 1000} seconds` }
|
|
509
|
+
if (res.exceptionDetails) return { error: (res.exceptionDetails.exception?.description ?? res.exceptionDetails.text ?? 'it threw').slice(0, 2_000) }
|
|
510
|
+
return res.result.value ?? { error: 'it answered nothing the page could send back' }
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async isPassword(handle: number): Promise<boolean> {
|
|
514
|
+
const { frame, node: backendNodeId } = this.frames.locate(handle)
|
|
515
|
+
const { node } = await frame.cdp<{ node: { attributes?: string[] } }>('DOM.describeNode', { backendNodeId })
|
|
516
|
+
const attrs = node.attributes ?? []
|
|
517
|
+
for (let i = 0; i < attrs.length; i += 2) if (attrs[i] === 'type' && attrs[i + 1]?.toLowerCase() === 'password') return true
|
|
518
|
+
return attrs.some((a, i) => i % 2 === 0 && a === 'autocomplete' && /password/i.test(attrs[i + 1] ?? ''))
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async type(backendNodeId: number, text: string, opts: { clear: boolean; submit: boolean }): Promise<void> {
|
|
522
|
+
await this.center(backendNodeId).catch(() => null)
|
|
523
|
+
await this.unlessDialog(async () => {
|
|
524
|
+
await this.frames.focus(backendNodeId, opts.clear ? SELECT_CONTENTS : CARET_TO_END)
|
|
525
|
+
await this.cdp('Input.insertText', { text })
|
|
526
|
+
if (opts.submit) {
|
|
527
|
+
await this.cdp('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, text: '\r' })
|
|
528
|
+
await this.cdp('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 })
|
|
529
|
+
}
|
|
530
|
+
})
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async setSize(size: { device: DeviceWidth } | { width: number; height: number | null }): Promise<void> {
|
|
534
|
+
if ('device' in size) {
|
|
535
|
+
this.device = size.device
|
|
536
|
+
this.viewport = null
|
|
537
|
+
const d = DEVICE_SIZES[size.device]
|
|
538
|
+
await this.page.setViewportSize({ width: d.width, height: d.height })
|
|
539
|
+
} else {
|
|
540
|
+
this.device = 'desktop'
|
|
541
|
+
this.viewport = size
|
|
542
|
+
await this.page.setViewportSize({ width: size.width, height: size.height ?? this.page.viewportSize()?.height ?? DEVICE_SIZES.desktop.height })
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async viewportWidth(): Promise<number> {
|
|
547
|
+
const m = await this.cdp<{ cssLayoutViewport?: { clientWidth: number } }>('Page.getLayoutMetrics').catch(() => null)
|
|
548
|
+
return m?.cssLayoutViewport?.clientWidth ?? this.page.viewportSize()?.width ?? 0
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** A PNG, base64; a dialog holds the page, so the capture goes through DevTools rather than wait for it. */
|
|
552
|
+
async screenshot(opts: { fullPage?: boolean } = {}): Promise<string> {
|
|
553
|
+
if (opts.fullPage) {
|
|
554
|
+
const metrics = await this.cdp<{ cssContentSize: { width: number; height: number } }>('Page.getLayoutMetrics')
|
|
555
|
+
const { width, height } = metrics.cssContentSize
|
|
556
|
+
const { data } = await this.cdp<{ data: string }>('Page.captureScreenshot', { format: 'png', clip: { x: 0, y: 0, width: Math.ceil(width), height: Math.min(Math.ceil(height), 16_000), scale: 1 }, captureBeyondViewport: true })
|
|
557
|
+
return data
|
|
558
|
+
}
|
|
559
|
+
const { data } = await this.cdp<{ data: string }>('Page.captureScreenshot', { format: 'png' })
|
|
560
|
+
return data
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
pageText(): Promise<string | null> {
|
|
564
|
+
return this.evaluate<string>(PAGE_TEXT)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async close(): Promise<void> {
|
|
568
|
+
this.closed = true
|
|
569
|
+
for (const { session } of this.frameSessions.values()) void session.detach().catch(() => {})
|
|
570
|
+
this.frameSessions.clear()
|
|
571
|
+
// A page waiting on a dialog closes only once it is answered.
|
|
572
|
+
await this.pendingDialog?.dismiss().catch(() => {})
|
|
573
|
+
await this.page.close().catch(() => {})
|
|
574
|
+
}
|
|
575
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A key the agent presses (`browser_press_key`), as the DevTools Protocol's
|
|
3
|
+
* `Input.dispatchKeyEvent` takes it. Names follow Playwright's: `Enter`,
|
|
4
|
+
* `ArrowDown`, `a`, and combinations such as `Shift+Tab` or `Meta+a`.
|
|
5
|
+
* Shared by Forge's Browser and the headless one `frontera mcp` runs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface KeyPress {
|
|
9
|
+
key: string
|
|
10
|
+
code: string
|
|
11
|
+
keyCode: number
|
|
12
|
+
/** What the key types; none for a key that only moves or edits, or with Control, Alt or Meta held. */
|
|
13
|
+
text?: string
|
|
14
|
+
/** Alt 1, Control 2, Meta 4, Shift 8. */
|
|
15
|
+
modifiers: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const NAMED: Record<string, { code: string; keyCode: number; text?: string }> = {
|
|
19
|
+
Enter: { code: 'Enter', keyCode: 13, text: '\r' },
|
|
20
|
+
Tab: { code: 'Tab', keyCode: 9 },
|
|
21
|
+
Escape: { code: 'Escape', keyCode: 27 },
|
|
22
|
+
Backspace: { code: 'Backspace', keyCode: 8 },
|
|
23
|
+
Delete: { code: 'Delete', keyCode: 46 },
|
|
24
|
+
Space: { code: 'Space', keyCode: 32, text: ' ' },
|
|
25
|
+
ArrowUp: { code: 'ArrowUp', keyCode: 38 },
|
|
26
|
+
ArrowDown: { code: 'ArrowDown', keyCode: 40 },
|
|
27
|
+
ArrowLeft: { code: 'ArrowLeft', keyCode: 37 },
|
|
28
|
+
ArrowRight: { code: 'ArrowRight', keyCode: 39 },
|
|
29
|
+
Home: { code: 'Home', keyCode: 36 },
|
|
30
|
+
End: { code: 'End', keyCode: 35 },
|
|
31
|
+
PageUp: { code: 'PageUp', keyCode: 33 },
|
|
32
|
+
PageDown: { code: 'PageDown', keyCode: 34 },
|
|
33
|
+
Insert: { code: 'Insert', keyCode: 45 },
|
|
34
|
+
...Object.fromEntries(Array.from({ length: 12 }, (_, i) => [`F${i + 1}`, { code: `F${i + 1}`, keyCode: 112 + i }])),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ALIASES: Record<string, string> = { Esc: 'Escape', Return: 'Enter', Up: 'ArrowUp', Down: 'ArrowDown', Left: 'ArrowLeft', Right: 'ArrowRight', Del: 'Delete', ' ': 'Space' }
|
|
38
|
+
|
|
39
|
+
const MODIFIERS: Record<string, number> = { Alt: 1, Option: 1, Control: 2, Ctrl: 2, Meta: 4, Command: 4, Cmd: 4, Shift: 8 }
|
|
40
|
+
|
|
41
|
+
const PUNCTUATION: Record<string, { code: string; keyCode: number }> = {
|
|
42
|
+
'-': { code: 'Minus', keyCode: 189 }, '=': { code: 'Equal', keyCode: 187 }, '[': { code: 'BracketLeft', keyCode: 219 }, ']': { code: 'BracketRight', keyCode: 221 },
|
|
43
|
+
'\\': { code: 'Backslash', keyCode: 220 }, ';': { code: 'Semicolon', keyCode: 186 }, "'": { code: 'Quote', keyCode: 222 }, ',': { code: 'Comma', keyCode: 188 },
|
|
44
|
+
'.': { code: 'Period', keyCode: 190 }, '/': { code: 'Slash', keyCode: 191 }, '`': { code: 'Backquote', keyCode: 192 },
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The key event for a name, or why it is not one. */
|
|
48
|
+
export function parseKey(name: string): KeyPress | { error: string } {
|
|
49
|
+
const raw = name.trim()
|
|
50
|
+
if (!raw) return { error: 'key is required, e.g. Enter, Escape, ArrowDown, a or Shift+Tab.' }
|
|
51
|
+
// `+` alone, or as the last part (`Shift++`), is the plus key.
|
|
52
|
+
const parts = raw === '+' ? ['+'] : raw.endsWith('++') ? [...raw.slice(0, -2).split('+'), '+'] : raw.split('+')
|
|
53
|
+
const last = parts.pop()!
|
|
54
|
+
let modifiers = 0
|
|
55
|
+
for (const p of parts) {
|
|
56
|
+
const m = MODIFIERS[p.trim()]
|
|
57
|
+
if (m === undefined) return { error: `"${p}" is not a modifier: use Shift, Control, Alt or Meta.` }
|
|
58
|
+
modifiers |= m
|
|
59
|
+
}
|
|
60
|
+
const keyName = ALIASES[last] ?? last
|
|
61
|
+
const named = NAMED[keyName]
|
|
62
|
+
const typing = (modifiers & ~8) === 0
|
|
63
|
+
if (named) return { key: keyName === 'Space' ? ' ' : keyName, code: named.code, keyCode: named.keyCode, ...(named.text && typing ? { text: named.text } : {}), modifiers }
|
|
64
|
+
if ([...keyName].length !== 1) return { error: `Unknown key "${last}": use a name such as Enter, Tab, Escape, Backspace, ArrowDown, Home, PageDown or F5, or a single character.` }
|
|
65
|
+
const shift = (modifiers & 8) !== 0
|
|
66
|
+
const char = /^[a-z]$/i.test(keyName) ? (shift ? keyName.toUpperCase() : keyName) : keyName
|
|
67
|
+
const code = /^[a-z]$/i.test(keyName) ? `Key${keyName.toUpperCase()}` : /^\d$/.test(keyName) ? `Digit${keyName}` : (PUNCTUATION[keyName]?.code ?? '')
|
|
68
|
+
const keyCode = /^[a-z0-9]$/i.test(keyName) ? keyName.toUpperCase().charCodeAt(0) : (PUNCTUATION[keyName]?.keyCode ?? 0)
|
|
69
|
+
return { key: char, code, keyCode, ...(typing ? { text: char } : {}), modifiers }
|
|
70
|
+
}
|