Initialize action topology engine
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
artifacts/
|
||||
.DS_Store
|
||||
*.log
|
||||
|
||||
32
AGENTS.md
Normal file
32
AGENTS.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# AGENTS.md
|
||||
|
||||
This project is a focused deterministic website action topology engine.
|
||||
|
||||
## Scope
|
||||
|
||||
Build and maintain only the topology engine:
|
||||
|
||||
- Capture page states.
|
||||
- Extract actionable elements.
|
||||
- Generate and verify locator candidates.
|
||||
- Record action edges and observable effects.
|
||||
- Emit graph artifacts that other projects can consume.
|
||||
|
||||
Do not turn this repository into a general browser agent platform. LLM planning, natural-language task execution, orchestration, and product UI belong in separate projects.
|
||||
|
||||
## Commands
|
||||
|
||||
- `npm install` — install dependencies.
|
||||
- `npm run typecheck` — TypeScript type check.
|
||||
- `npm run build` — compile to `dist/`.
|
||||
- `npm run scan -- --url ./examples/simple.html --out artifacts/simple-scan.json` — static scan.
|
||||
- `npm run example` — low-risk exploration of the local example page.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Prefer Playwright user-facing locator strategies first: role, label, placeholder, text, test id.
|
||||
- Keep CSS locators as fallback candidates, not the primary source of truth.
|
||||
- Bind elements to states. Do not treat a DOM node as globally meaningful outside its page state.
|
||||
- Preserve risk gating. High-risk terms such as delete, submit, pay, confirm, and authorize must not be clicked by the default explorer.
|
||||
- Keep source discussions under `docs/source/` when they drive design decisions.
|
||||
|
||||
63
README.md
Normal file
63
README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Action Topology Engine
|
||||
|
||||
Small deterministic tool for building a website action topology graph.
|
||||
|
||||
The project intentionally does not include an LLM, a generic browser agent, or a large workflow platform. Its job is to observe a site, describe states and actionable elements, generate verifiable locator candidates, and record action edges with observable effects.
|
||||
|
||||
## Current Scope
|
||||
|
||||
- Open a URL or local HTML file with Playwright.
|
||||
- Capture one page state with DOM and visible-text fingerprints.
|
||||
- Extract actionable elements: buttons, links, inputs, selects, textareas, roles, tab stops, and click handlers.
|
||||
- Generate locator candidates with scores.
|
||||
- Optionally explore low-risk click actions from the entry state.
|
||||
- Record state nodes, elements, locators, action edges, and basic effects in JSON.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No LLM decision making.
|
||||
- No natural-language task planner.
|
||||
- No high-risk automatic actions.
|
||||
- No attempt to fully recover frontend business functions in the first version.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
Static scan:
|
||||
|
||||
```bash
|
||||
npm run scan -- --url ./examples/simple.html --out artifacts/simple-scan.json
|
||||
```
|
||||
|
||||
Entry-state low-risk exploration:
|
||||
|
||||
```bash
|
||||
npm run example
|
||||
```
|
||||
|
||||
Build:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Output Model
|
||||
|
||||
The output JSON follows the schema described in [schemas/topology-graph.schema.md](schemas/topology-graph.schema.md).
|
||||
|
||||
Key objects:
|
||||
|
||||
- `StateNode`: URL, route, title, state hashes, modal stack, and entry metadata.
|
||||
- `ActionableElement`: element semantics, visibility, bounding box, and locator candidates.
|
||||
- `LocatorCandidate`: Playwright-friendly locator descriptors plus score and verification status.
|
||||
- `ActionEdge`: action from one state to another through an element, with effects and risk.
|
||||
|
||||
## Source Notes
|
||||
|
||||
The originating ChatGPT share conversation is preserved in [docs/source/chatgpt-share-6a30e583.md](docs/source/chatgpt-share-6a30e583.md).
|
||||
|
||||
39
docs/design/no-llm-topology-engine.md
Normal file
39
docs/design/no-llm-topology-engine.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# No-LLM Topology Engine Design
|
||||
|
||||
## Purpose
|
||||
|
||||
Build a small deterministic engine that maps a website into a verifiable action topology. Other projects can consume the graph for planning, orchestration, or LLM-assisted behavior. This project stays narrow: it observes, indexes, and verifies browser-operable structure.
|
||||
|
||||
## Core Unit
|
||||
|
||||
```text
|
||||
State A -- action(element, params) --> State B / Effect
|
||||
```
|
||||
|
||||
The engine does not index DOM nodes in isolation. It indexes actionable elements inside a state, the locators that can find them, the action that can be applied, and the observable effect.
|
||||
|
||||
## First Implementation Slice
|
||||
|
||||
1. Capture a page state from an entry URL.
|
||||
2. Extract visible actionable elements.
|
||||
3. Generate locator candidates and verify uniqueness.
|
||||
4. Explore a bounded set of low-risk click actions from the entry state.
|
||||
5. Record resulting states and effects.
|
||||
6. Emit a graph JSON artifact.
|
||||
|
||||
## Explicit Non-Goals
|
||||
|
||||
- No LLM.
|
||||
- No natural-language task planner.
|
||||
- No uncontrolled high-risk actions.
|
||||
- No deep frontend function reconstruction in the first version.
|
||||
- No complete arbitrary-site crawler.
|
||||
|
||||
## Risk Policy
|
||||
|
||||
The first explorer only clicks low-risk candidates. Terms such as delete, submit, pay, confirm, authorize, and their Chinese equivalents are classified as high risk and skipped. Form fields are treated as medium risk and are captured but not automatically filled.
|
||||
|
||||
## Storage
|
||||
|
||||
The first version emits JSON. SQLite or Postgres can be added later after the graph shape stabilizes.
|
||||
|
||||
1869
docs/source/chatgpt-share-6a30e583.md
Normal file
1869
docs/source/chatgpt-share-6a30e583.md
Normal file
File diff suppressed because it is too large
Load Diff
50
examples/simple.html
Normal file
50
examples/simple.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Action Topology Example</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
margin: 32px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
a {
|
||||
display: inline-flex;
|
||||
margin: 8px 8px 8px 0;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
#settings {
|
||||
display: none;
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
#settings.open {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Action Topology Example</h1>
|
||||
<button data-testid="open-settings" onclick="document.getElementById('settings').classList.toggle('open')">
|
||||
Open settings
|
||||
</button>
|
||||
<a href="#reports" data-testid="reports-link">Reports</a>
|
||||
<label>
|
||||
Search
|
||||
<input name="q" placeholder="Search customers" />
|
||||
</label>
|
||||
<section id="settings" role="dialog" aria-label="Settings dialog">
|
||||
<button data-testid="save-settings" onclick="document.getElementById('status').textContent='Saved settings'">
|
||||
Save
|
||||
</button>
|
||||
<button data-testid="delete-account">Delete account</button>
|
||||
</section>
|
||||
<p id="status">Idle</p>
|
||||
</body>
|
||||
</html>
|
||||
616
package-lock.json
generated
Normal file
616
package-lock.json
generated
Normal file
@@ -0,0 +1,616 @@
|
||||
{
|
||||
"name": "action-topology-engine",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "action-topology-engine",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"playwright": "^1.61.0"
|
||||
},
|
||||
"bin": {
|
||||
"action-topology": "dist/cli.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.30",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz",
|
||||
"integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
|
||||
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
|
||||
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
||||
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
26
package.json
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "action-topology-engine",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Deterministic website action topology engine built on Playwright.",
|
||||
"bin": {
|
||||
"action-topology": "./dist/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"scan": "tsx src/cli.ts scan",
|
||||
"explore": "tsx src/cli.ts explore",
|
||||
"example": "tsx src/cli.ts explore --url ./examples/simple.html --out artifacts/simple-graph.json --max-actions 8"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright": "^1.61.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.30",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
51
schemas/topology-graph.schema.md
Normal file
51
schemas/topology-graph.schema.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Topology Graph Schema
|
||||
|
||||
This document describes the JSON shape emitted by the engine. It is a practical schema note, not yet a formal JSON Schema file.
|
||||
|
||||
## `TopologyGraph`
|
||||
|
||||
- `schemaVersion`: currently `0.1`.
|
||||
- `metadata.entryUrl`: entry URL after Playwright navigation.
|
||||
- `metadata.mode`: `scan` or `explore`.
|
||||
- `states`: page states.
|
||||
- `elements`: actionable elements bound to a state.
|
||||
- `edges`: observed actions from one state to another.
|
||||
|
||||
## `StateNode`
|
||||
|
||||
- `stateId`: stable id derived from URL, DOM hash, visible text hash, and actionable signature hash.
|
||||
- `url`, `route`, `title`: browser-visible state identity.
|
||||
- `domHash`: hash of full `document.documentElement.outerHTML`.
|
||||
- `visibleTextHash`: hash of normalized visible body text.
|
||||
- `actionableSignatureHash`: hash of actionable element signatures.
|
||||
- `modalStack`: visible dialogs or modal-like surfaces.
|
||||
- `openedByEdgeId`: edge that opened this state, if known.
|
||||
|
||||
## `ActionableElement`
|
||||
|
||||
- `elementId`: stable id within the captured state.
|
||||
- `stateId`: owning state.
|
||||
- `tag`, `role`, `accessibleName`, `text`, `label`, `placeholder`: semantic identity.
|
||||
- `attributes`: selected stable attributes such as `data-testid`, `id`, `name`, `type`, `href`, and ARIA fields.
|
||||
- `bbox`: page-space bounding box.
|
||||
- `interactability`: visibility, enabled/editable status, and whether the element receives events.
|
||||
- `locators`: scored locator candidates.
|
||||
|
||||
## `LocatorCandidate`
|
||||
|
||||
- `descriptor`: structured Playwright locator descriptor.
|
||||
- `expression`: human-readable locator expression.
|
||||
- `score`: ranking score.
|
||||
- `verified`: whether it resolved to exactly one element during capture.
|
||||
- `matchCount`: observed locator match count.
|
||||
|
||||
## `ActionEdge`
|
||||
|
||||
- `fromStateId`, `toStateId`: source and resulting states.
|
||||
- `elementId`: action target.
|
||||
- `actionType`: currently focused on `click`, with type support for future `fill`, `select`, and navigation actions.
|
||||
- `preconditions`: reserved for stored open paths.
|
||||
- `effects`: URL, visible text, network, download, dialog, storage, or DOM effects.
|
||||
- `riskLevel`: `low`, `medium`, or `high`.
|
||||
- `confidence`: confidence in observed edge replayability.
|
||||
|
||||
66
src/cli.ts
Normal file
66
src/cli.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env node
|
||||
import { exploreUrl, scanUrl, type EngineOptions } from "./engine.js";
|
||||
|
||||
interface ParsedArgs extends EngineOptions {
|
||||
command: "scan" | "explore";
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const graph = args.command === "scan" ? await scanUrl(args) : await exploreUrl(args);
|
||||
if (args.out) {
|
||||
console.error(`Wrote ${graph.states.length} state(s), ${graph.elements.length} element(s), ${graph.edges.length} edge(s) to ${args.out}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): ParsedArgs {
|
||||
const command = argv.shift();
|
||||
if (command !== "scan" && command !== "explore") usage();
|
||||
|
||||
const options: Partial<ParsedArgs> = { command };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
switch (arg) {
|
||||
case "--url":
|
||||
options.url = requireValue(argv, ++index, "--url");
|
||||
break;
|
||||
case "--out":
|
||||
options.out = requireValue(argv, ++index, "--out");
|
||||
break;
|
||||
case "--storage-state":
|
||||
options.storageState = requireValue(argv, ++index, "--storage-state");
|
||||
break;
|
||||
case "--max-actions":
|
||||
options.maxActions = Number(requireValue(argv, ++index, "--max-actions"));
|
||||
break;
|
||||
case "--headed":
|
||||
options.headed = true;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.url) usage();
|
||||
return options as ParsedArgs;
|
||||
}
|
||||
|
||||
function requireValue(argv: string[], index: number, flag: string): string {
|
||||
const value = argv[index];
|
||||
if (!value) throw new Error(`Missing value for ${flag}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function usage(): never {
|
||||
console.error(`Usage:
|
||||
action-topology scan --url <url-or-file> [--out graph.json] [--headed] [--storage-state state.json]
|
||||
action-topology explore --url <url-or-file> [--out graph.json] [--max-actions 10] [--headed] [--storage-state state.json]
|
||||
`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
189
src/engine.ts
Normal file
189
src/engine.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { chromium, type Browser, type BrowserContext, type Page, type Response } from "playwright";
|
||||
import { shortHash } from "./hash.js";
|
||||
import { resolveLocator } from "./locator.js";
|
||||
import { isLowRiskClickable, classifyClickRisk } from "./risk.js";
|
||||
import { snapshotPage } from "./snapshot.js";
|
||||
import type { ActionEdge, EffectRecord, TopologyGraph } from "./types.js";
|
||||
|
||||
export interface EngineOptions {
|
||||
url: string;
|
||||
out?: string;
|
||||
headed?: boolean;
|
||||
storageState?: string;
|
||||
maxActions?: number;
|
||||
}
|
||||
|
||||
export async function scanUrl(options: EngineOptions): Promise<TopologyGraph> {
|
||||
const browser = await chromium.launch({ headless: !options.headed });
|
||||
try {
|
||||
const context = await newContext(browser, options);
|
||||
const page = await context.newPage();
|
||||
await page.goto(toNavigableUrl(options.url), { waitUntil: "domcontentloaded" });
|
||||
await page.waitForLoadState("networkidle").catch(() => undefined);
|
||||
const snapshot = await snapshotPage(page);
|
||||
const graph: TopologyGraph = {
|
||||
schemaVersion: "0.1",
|
||||
metadata: {
|
||||
entryUrl: page.url(),
|
||||
generatedAt: new Date().toISOString(),
|
||||
engineVersion: "0.1.0",
|
||||
mode: "scan",
|
||||
},
|
||||
states: [snapshot.state],
|
||||
elements: snapshot.elements,
|
||||
edges: [],
|
||||
};
|
||||
await maybeWriteGraph(graph, options.out);
|
||||
await context.close();
|
||||
return graph;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function exploreUrl(options: EngineOptions): Promise<TopologyGraph> {
|
||||
const browser = await chromium.launch({ headless: !options.headed });
|
||||
try {
|
||||
const context = await newContext(browser, options);
|
||||
const entryUrl = toNavigableUrl(options.url);
|
||||
const page = await context.newPage();
|
||||
await page.goto(entryUrl, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForLoadState("networkidle").catch(() => undefined);
|
||||
|
||||
const entrySnapshot = await snapshotPage(page);
|
||||
const graph: TopologyGraph = {
|
||||
schemaVersion: "0.1",
|
||||
metadata: {
|
||||
entryUrl: page.url(),
|
||||
generatedAt: new Date().toISOString(),
|
||||
engineVersion: "0.1.0",
|
||||
mode: "explore",
|
||||
},
|
||||
states: [entrySnapshot.state],
|
||||
elements: [...entrySnapshot.elements],
|
||||
edges: [],
|
||||
};
|
||||
|
||||
const candidates = entrySnapshot.elements.filter(isLowRiskClickable).slice(0, options.maxActions ?? 10);
|
||||
await page.close();
|
||||
|
||||
for (const element of candidates) {
|
||||
const actionPage = await context.newPage();
|
||||
const networkEvents: EffectRecord[] = [];
|
||||
actionPage.on("response", (response) => {
|
||||
const record = responseToEffect(response);
|
||||
if (record) networkEvents.push(record);
|
||||
});
|
||||
|
||||
await actionPage.goto(entryUrl, { waitUntil: "domcontentloaded" });
|
||||
await actionPage.waitForLoadState("networkidle").catch(() => undefined);
|
||||
const beforeUrl = actionPage.url();
|
||||
const beforeSnapshot = await snapshotPage(actionPage);
|
||||
const locator = element.locators.find((candidate) => candidate.verified) ?? element.locators[0];
|
||||
|
||||
let toStateId: string | null = null;
|
||||
const effects: EffectRecord[] = [];
|
||||
if (locator) {
|
||||
try {
|
||||
await resolveLocator(actionPage, locator.descriptor).first().click({ timeout: 2000 });
|
||||
await actionPage.waitForLoadState("networkidle", { timeout: 2000 }).catch(() => undefined);
|
||||
const afterSnapshot = await snapshotPage(actionPage);
|
||||
graph.states = upsertById(graph.states, afterSnapshot.state, "stateId");
|
||||
graph.elements.push(...afterSnapshot.elements);
|
||||
toStateId = afterSnapshot.state.stateId;
|
||||
effects.push(...diffEffects(beforeUrl, beforeSnapshot.visibleText, actionPage.url(), afterSnapshot.visibleText));
|
||||
} catch (error) {
|
||||
effects.push({
|
||||
type: "dom",
|
||||
description: `Action failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const edge: ActionEdge = {
|
||||
edgeId: `edge_${shortHash([entrySnapshot.state.stateId, element.elementId, "click"])}`,
|
||||
fromStateId: entrySnapshot.state.stateId,
|
||||
toStateId,
|
||||
elementId: element.elementId,
|
||||
actionType: "click",
|
||||
actionParams: {},
|
||||
preconditions: [],
|
||||
effects: [...effects, ...networkEvents],
|
||||
parameterSpecs: [],
|
||||
riskLevel: classifyClickRisk(element),
|
||||
confidence: locator?.verified ? 0.82 : 0.55,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
graph.edges = upsertById(graph.edges, edge, "edgeId");
|
||||
await actionPage.close();
|
||||
}
|
||||
|
||||
await maybeWriteGraph(graph, options.out);
|
||||
await context.close();
|
||||
return graph;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function newContext(browser: Browser, options: EngineOptions): Promise<BrowserContext> {
|
||||
return browser.newContext({
|
||||
storageState: options.storageState,
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
});
|
||||
}
|
||||
|
||||
async function maybeWriteGraph(graph: TopologyGraph, out?: string): Promise<void> {
|
||||
if (!out) {
|
||||
console.log(JSON.stringify(graph, null, 2));
|
||||
return;
|
||||
}
|
||||
await mkdir(path.dirname(out), { recursive: true });
|
||||
await writeFile(out, `${JSON.stringify(graph, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function toNavigableUrl(value: string): string {
|
||||
if (/^https?:\/\//.test(value) || value.startsWith("file://")) return value;
|
||||
return pathToFileURL(path.resolve(value)).toString();
|
||||
}
|
||||
|
||||
function diffEffects(beforeUrl: string, beforeText: string, afterUrl: string, afterText: string): EffectRecord[] {
|
||||
const effects: EffectRecord[] = [];
|
||||
if (beforeUrl !== afterUrl) {
|
||||
effects.push({ type: "url", before: beforeUrl, after: afterUrl, description: "URL changed after action" });
|
||||
}
|
||||
if (shortHash(beforeText) !== shortHash(afterText)) {
|
||||
effects.push({
|
||||
type: "visibleText",
|
||||
before: shortHash(beforeText),
|
||||
after: shortHash(afterText),
|
||||
description: "Visible text changed after action",
|
||||
});
|
||||
}
|
||||
return effects;
|
||||
}
|
||||
|
||||
function responseToEffect(response: Response): EffectRecord | null {
|
||||
const request = response.request();
|
||||
const resourceType = request.resourceType();
|
||||
if (!["fetch", "xhr", "document"].includes(resourceType)) return null;
|
||||
return {
|
||||
type: "network",
|
||||
method: request.method(),
|
||||
url: response.url(),
|
||||
status: response.status(),
|
||||
description: `${request.method()} ${response.url()} -> ${response.status()}`,
|
||||
};
|
||||
}
|
||||
|
||||
function upsertById<T extends Record<K, string>, K extends keyof T>(items: T[], item: T, key: K): T[] {
|
||||
const index = items.findIndex((existing) => existing[key] === item[key]);
|
||||
if (index === -1) return [...items, item];
|
||||
const next = [...items];
|
||||
next[index] = item;
|
||||
return next;
|
||||
}
|
||||
|
||||
27
src/hash.ts
Normal file
27
src/hash.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export function stableHash(value: unknown): string {
|
||||
const normalized = typeof value === "string" ? value : stableStringify(value);
|
||||
return createHash("sha256").update(normalized).digest("hex");
|
||||
}
|
||||
|
||||
export function shortHash(value: unknown, length = 12): string {
|
||||
return stableHash(value).slice(0, length);
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
return JSON.stringify(sortValue(value));
|
||||
}
|
||||
|
||||
function sortValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(sortValue);
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, item]) => [key, sortValue(item)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
170
src/locator.ts
Normal file
170
src/locator.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import type { Locator, Page } from "playwright";
|
||||
import { shortHash } from "./hash.js";
|
||||
import type { LocatorCandidate, LocatorDescriptor, RawActionableElement } from "./types.js";
|
||||
|
||||
export function generateLocators(elementId: string, raw: RawActionableElement): LocatorCandidate[] {
|
||||
const candidates: Array<Omit<LocatorCandidate, "locatorId" | "elementId" | "verified" | "failureCount">> = [];
|
||||
const name = clean(raw.accessibleName);
|
||||
const label = clean(raw.label);
|
||||
const placeholder = clean(raw.placeholder);
|
||||
const text = clean(raw.text);
|
||||
const testId = raw.attributes["data-testid"] || raw.attributes["data-test-id"] || raw.attributes["data-test"];
|
||||
const id = raw.attributes.id;
|
||||
const nameAttr = raw.attributes.name;
|
||||
|
||||
if (raw.role && name) {
|
||||
candidates.push({
|
||||
framework: "playwright",
|
||||
strategy: "role",
|
||||
descriptor: { kind: "role", role: raw.role, name },
|
||||
expression: `page.getByRole(${JSON.stringify(raw.role)}, { name: ${JSON.stringify(name)} })`,
|
||||
score: 0.95,
|
||||
reason: "role plus accessible name",
|
||||
});
|
||||
}
|
||||
|
||||
if (testId) {
|
||||
candidates.push({
|
||||
framework: "playwright",
|
||||
strategy: "testId",
|
||||
descriptor: { kind: "testId", testId },
|
||||
expression: `page.getByTestId(${JSON.stringify(testId)})`,
|
||||
score: 0.92,
|
||||
reason: "explicit test id",
|
||||
});
|
||||
}
|
||||
|
||||
if (label) {
|
||||
candidates.push({
|
||||
framework: "playwright",
|
||||
strategy: "label",
|
||||
descriptor: { kind: "label", label },
|
||||
expression: `page.getByLabel(${JSON.stringify(label)})`,
|
||||
score: 0.9,
|
||||
reason: "associated label",
|
||||
});
|
||||
}
|
||||
|
||||
if (placeholder) {
|
||||
candidates.push({
|
||||
framework: "playwright",
|
||||
strategy: "placeholder",
|
||||
descriptor: { kind: "placeholder", placeholder },
|
||||
expression: `page.getByPlaceholder(${JSON.stringify(placeholder)})`,
|
||||
score: 0.86,
|
||||
reason: "placeholder text",
|
||||
});
|
||||
}
|
||||
|
||||
if (text && text.length <= 80 && ["a", "button"].includes(raw.tag)) {
|
||||
candidates.push({
|
||||
framework: "playwright",
|
||||
strategy: "text",
|
||||
descriptor: { kind: "text", text, exact: true },
|
||||
expression: `page.getByText(${JSON.stringify(text)}, { exact: true })`,
|
||||
score: 0.75,
|
||||
reason: "visible text fallback",
|
||||
});
|
||||
}
|
||||
|
||||
if (id && /^[A-Za-z][A-Za-z0-9_-]*$/.test(id)) {
|
||||
candidates.push({
|
||||
framework: "css",
|
||||
strategy: "css",
|
||||
descriptor: { kind: "css", selector: `#${cssEscape(id)}` },
|
||||
expression: `page.locator(${JSON.stringify(`#${cssEscape(id)}`)})`,
|
||||
score: 0.72,
|
||||
reason: "id selector fallback",
|
||||
});
|
||||
}
|
||||
|
||||
if (nameAttr) {
|
||||
candidates.push({
|
||||
framework: "css",
|
||||
strategy: "css",
|
||||
descriptor: { kind: "css", selector: `${raw.tag}[name="${cssString(nameAttr)}"]` },
|
||||
expression: `page.locator(${JSON.stringify(`${raw.tag}[name="${cssString(nameAttr)}"]`)})`,
|
||||
score: 0.68,
|
||||
reason: "name attribute fallback",
|
||||
});
|
||||
}
|
||||
|
||||
return dedupeCandidates(candidates).map((candidate) => ({
|
||||
...candidate,
|
||||
locatorId: `loc_${shortHash([elementId, candidate.descriptor])}`,
|
||||
elementId,
|
||||
verified: false,
|
||||
failureCount: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function verifyLocators(page: Page, locators: LocatorCandidate[]): Promise<LocatorCandidate[]> {
|
||||
const verifiedAt = new Date().toISOString();
|
||||
const verified: LocatorCandidate[] = [];
|
||||
|
||||
for (const locator of locators) {
|
||||
try {
|
||||
const matchCount = await resolveLocator(page, locator.descriptor).count();
|
||||
verified.push({
|
||||
...locator,
|
||||
verified: matchCount === 1,
|
||||
matchCount,
|
||||
lastVerifiedAt: verifiedAt,
|
||||
score: matchCount === 1 ? locator.score : Math.max(0.1, locator.score - 0.35),
|
||||
});
|
||||
} catch {
|
||||
verified.push({
|
||||
...locator,
|
||||
verified: false,
|
||||
matchCount: 0,
|
||||
lastVerifiedAt: verifiedAt,
|
||||
score: Math.max(0.1, locator.score - 0.45),
|
||||
failureCount: locator.failureCount + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return verified.sort((a, b) => b.score - a.score);
|
||||
}
|
||||
|
||||
export function resolveLocator(page: Page, descriptor: LocatorDescriptor): Locator {
|
||||
switch (descriptor.kind) {
|
||||
case "role":
|
||||
return page.getByRole(descriptor.role as never, descriptor.name ? { name: descriptor.name } : undefined);
|
||||
case "label":
|
||||
return page.getByLabel(descriptor.label);
|
||||
case "placeholder":
|
||||
return page.getByPlaceholder(descriptor.placeholder);
|
||||
case "text":
|
||||
return page.getByText(descriptor.text, { exact: descriptor.exact });
|
||||
case "testId":
|
||||
return page.getByTestId(descriptor.testId);
|
||||
case "css":
|
||||
return page.locator(descriptor.selector);
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeCandidates<T extends { expression: string }>(candidates: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: T[] = [];
|
||||
for (const candidate of candidates) {
|
||||
if (seen.has(candidate.expression)) continue;
|
||||
seen.add(candidate.expression);
|
||||
deduped.push(candidate);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function clean(value: string | null | undefined): string | undefined {
|
||||
const cleaned = value?.replace(/\s+/g, " ").trim();
|
||||
return cleaned || undefined;
|
||||
}
|
||||
|
||||
function cssEscape(value: string): string {
|
||||
return value.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
|
||||
}
|
||||
|
||||
function cssString(value: string): string {
|
||||
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
51
src/risk.ts
Normal file
51
src/risk.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { ActionableElement } from "./types.js";
|
||||
|
||||
const HIGH_RISK_TERMS = [
|
||||
"delete",
|
||||
"remove",
|
||||
"destroy",
|
||||
"drop",
|
||||
"logout",
|
||||
"sign out",
|
||||
"pay",
|
||||
"purchase",
|
||||
"checkout",
|
||||
"submit",
|
||||
"confirm",
|
||||
"authorize",
|
||||
"删除",
|
||||
"移除",
|
||||
"注销",
|
||||
"退出",
|
||||
"支付",
|
||||
"购买",
|
||||
"提交",
|
||||
"确认",
|
||||
"授权",
|
||||
];
|
||||
|
||||
export function classifyClickRisk(element: ActionableElement): "low" | "medium" | "high" {
|
||||
const joined = [
|
||||
element.accessibleName,
|
||||
element.text,
|
||||
element.label,
|
||||
element.placeholder,
|
||||
element.attributes.href,
|
||||
element.attributes.type,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
if (HIGH_RISK_TERMS.some((term) => joined.includes(term.toLowerCase()))) return "high";
|
||||
if (element.tag === "input" || element.tag === "textarea" || element.tag === "select") return "medium";
|
||||
return "low";
|
||||
}
|
||||
|
||||
export function isLowRiskClickable(element: ActionableElement): boolean {
|
||||
if (!element.interactability.visible || !element.interactability.enabled || !element.interactability.receivesEvents) return false;
|
||||
if (classifyClickRisk(element) !== "low") return false;
|
||||
const role = element.role;
|
||||
return element.tag === "button" || element.tag === "a" || role === "button" || role === "link" || role === "tab" || role === "menuitem";
|
||||
}
|
||||
|
||||
251
src/snapshot.ts
Normal file
251
src/snapshot.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import type { Page } from "playwright";
|
||||
import { shortHash, stableHash } from "./hash.js";
|
||||
import { generateLocators, verifyLocators } from "./locator.js";
|
||||
import type { ActionableElement, RawActionableElement, SnapshotResult, StateNode } from "./types.js";
|
||||
|
||||
export async function snapshotPage(page: Page, openedByEdgeId: string | null = null): Promise<SnapshotResult> {
|
||||
const raw = await page.evaluate(collectPageSnapshot);
|
||||
const createdAt = new Date().toISOString();
|
||||
const stateHash = stableHash({
|
||||
url: raw.url,
|
||||
title: raw.title,
|
||||
domHash: stableHash(raw.dom),
|
||||
visibleTextHash: stableHash(raw.visibleText),
|
||||
actionableSignatureHash: stableHash(raw.elements.map(actionableSignature)),
|
||||
});
|
||||
|
||||
const state: StateNode = {
|
||||
stateId: `state_${shortHash(stateHash)}`,
|
||||
url: raw.url,
|
||||
route: raw.route,
|
||||
title: raw.title,
|
||||
frameContext: [],
|
||||
stateHash,
|
||||
domHash: stableHash(raw.dom),
|
||||
visibleTextHash: stableHash(raw.visibleText),
|
||||
actionableSignatureHash: stableHash(raw.elements.map(actionableSignature)),
|
||||
modalStack: raw.modalStack,
|
||||
openedByEdgeId,
|
||||
createdAt,
|
||||
};
|
||||
|
||||
const elements: ActionableElement[] = [];
|
||||
for (const rawElement of raw.elements) {
|
||||
const elementId = `el_${shortHash([state.stateId, rawElement.uid, actionableSignature(rawElement)])}`;
|
||||
const locators = await verifyLocators(page, generateLocators(elementId, rawElement));
|
||||
elements.push({
|
||||
elementId,
|
||||
stateId: state.stateId,
|
||||
tag: rawElement.tag,
|
||||
role: rawElement.role,
|
||||
accessibleName: rawElement.accessibleName,
|
||||
text: rawElement.text,
|
||||
label: rawElement.label,
|
||||
placeholder: rawElement.placeholder,
|
||||
attributes: rawElement.attributes,
|
||||
bbox: rawElement.bbox,
|
||||
framePath: [],
|
||||
shadowPath: [],
|
||||
interactability: rawElement.visibility,
|
||||
locators,
|
||||
});
|
||||
}
|
||||
|
||||
return { state, elements, visibleText: raw.visibleText };
|
||||
}
|
||||
|
||||
interface BrowserSnapshot {
|
||||
url: string;
|
||||
route: string;
|
||||
title: string;
|
||||
dom: string;
|
||||
visibleText: string;
|
||||
modalStack: string[];
|
||||
elements: RawActionableElement[];
|
||||
}
|
||||
|
||||
function actionableSignature(element: RawActionableElement): unknown {
|
||||
return {
|
||||
tag: element.tag,
|
||||
role: element.role,
|
||||
accessibleName: element.accessibleName,
|
||||
text: element.text,
|
||||
label: element.label,
|
||||
placeholder: element.placeholder,
|
||||
attributes: element.attributes,
|
||||
};
|
||||
}
|
||||
|
||||
function collectPageSnapshot(): BrowserSnapshot {
|
||||
const actionableSelector = [
|
||||
"a[href]",
|
||||
"button",
|
||||
"input",
|
||||
"select",
|
||||
"textarea",
|
||||
"[role]",
|
||||
"[onclick]",
|
||||
"[aria-haspopup]",
|
||||
"[aria-expanded]",
|
||||
"[contenteditable='true']",
|
||||
"[tabindex]:not([tabindex='-1'])",
|
||||
].join(",");
|
||||
|
||||
const elements = Array.from(document.querySelectorAll<HTMLElement>(actionableSelector))
|
||||
.filter((element) => isCandidate(element))
|
||||
.slice(0, 500)
|
||||
.map((element, index) => toRawElement(element, index));
|
||||
|
||||
return {
|
||||
url: location.href,
|
||||
route: `${location.pathname}${location.search}`,
|
||||
title: document.title,
|
||||
dom: document.documentElement.outerHTML,
|
||||
visibleText: normalizeText(document.body?.innerText || ""),
|
||||
modalStack: Array.from(document.querySelectorAll<HTMLElement>("[role='dialog'], dialog[open], [aria-modal='true']"))
|
||||
.filter((element) => isVisible(element))
|
||||
.map((element) => getAccessibleName(element) || element.id || element.tagName.toLowerCase()),
|
||||
elements,
|
||||
};
|
||||
|
||||
function isCandidate(element: HTMLElement): boolean {
|
||||
const tag = element.tagName.toLowerCase();
|
||||
if (tag === "input" && (element as HTMLInputElement).type === "hidden") return false;
|
||||
if (!isVisible(element)) return false;
|
||||
const box = element.getBoundingClientRect();
|
||||
return box.width > 0 && box.height > 0;
|
||||
}
|
||||
|
||||
function toRawElement(element: HTMLElement, index: number): RawActionableElement {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const tag = element.tagName.toLowerCase();
|
||||
const attributes = collectAttributes(element);
|
||||
const text = normalizeText(element.innerText || element.textContent || "");
|
||||
const role = attributes.role || inferRole(element);
|
||||
const label = getLabel(element);
|
||||
const placeholder = "placeholder" in element ? normalizeNullable(String((element as HTMLInputElement).placeholder || "")) : null;
|
||||
const accessibleName = normalizeNullable(getAccessibleName(element) || label || placeholder || text);
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
const topAtCenter = document.elementFromPoint(centerX, centerY);
|
||||
|
||||
return {
|
||||
uid: `${tag}_${index}_${attributes.id || attributes.name || accessibleName || text || ""}`,
|
||||
tag,
|
||||
role,
|
||||
accessibleName,
|
||||
text: normalizeNullable(text),
|
||||
label,
|
||||
placeholder,
|
||||
attributes,
|
||||
bbox: {
|
||||
x: Math.round(rect.x),
|
||||
y: Math.round(rect.y),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
},
|
||||
visibility: {
|
||||
visible: isVisible(element),
|
||||
enabled: !("disabled" in element && Boolean((element as HTMLButtonElement).disabled)),
|
||||
editable: isEditable(element),
|
||||
receivesEvents: topAtCenter === element || Boolean(topAtCenter && element.contains(topAtCenter)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function collectAttributes(element: HTMLElement): Record<string, string> {
|
||||
const keep = new Set([
|
||||
"id",
|
||||
"name",
|
||||
"type",
|
||||
"href",
|
||||
"value",
|
||||
"role",
|
||||
"aria-label",
|
||||
"aria-labelledby",
|
||||
"aria-expanded",
|
||||
"aria-haspopup",
|
||||
"aria-controls",
|
||||
"aria-disabled",
|
||||
"data-testid",
|
||||
"data-test-id",
|
||||
"data-test",
|
||||
]);
|
||||
const attrs: Record<string, string> = {};
|
||||
for (const attr of Array.from(element.attributes)) {
|
||||
if (keep.has(attr.name)) attrs[attr.name] = attr.value;
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function inferRole(element: HTMLElement): string | null {
|
||||
const tag = element.tagName.toLowerCase();
|
||||
if (tag === "button") return "button";
|
||||
if (tag === "a" && element.hasAttribute("href")) return "link";
|
||||
if (tag === "select") return "combobox";
|
||||
if (tag === "textarea") return "textbox";
|
||||
if (tag === "input") {
|
||||
const type = ((element as HTMLInputElement).type || "text").toLowerCase();
|
||||
if (["button", "submit", "reset"].includes(type)) return "button";
|
||||
if (type === "checkbox") return "checkbox";
|
||||
if (type === "radio") return "radio";
|
||||
return "textbox";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getAccessibleName(element: HTMLElement): string | null {
|
||||
const ariaLabel = element.getAttribute("aria-label");
|
||||
if (ariaLabel) return normalizeNullable(ariaLabel);
|
||||
const labelledBy = element.getAttribute("aria-labelledby");
|
||||
if (labelledBy) {
|
||||
const text = labelledBy
|
||||
.split(/\s+/)
|
||||
.map((id) => document.getElementById(id)?.innerText || document.getElementById(id)?.textContent || "")
|
||||
.join(" ");
|
||||
if (text.trim()) return normalizeNullable(text);
|
||||
}
|
||||
const title = element.getAttribute("title");
|
||||
if (title) return normalizeNullable(title);
|
||||
const alt = element.getAttribute("alt");
|
||||
if (alt) return normalizeNullable(alt);
|
||||
return normalizeNullable(element.innerText || element.textContent || "");
|
||||
}
|
||||
|
||||
function getLabel(element: HTMLElement): string | null {
|
||||
const id = element.getAttribute("id");
|
||||
if (id) {
|
||||
const explicit = document.querySelector<HTMLLabelElement>(`label[for="${CSS.escape(id)}"]`);
|
||||
if (explicit) return normalizeNullable(explicit.innerText || explicit.textContent || "");
|
||||
}
|
||||
const implicit = element.closest("label");
|
||||
if (implicit) return normalizeNullable(implicit.innerText || implicit.textContent || "");
|
||||
return null;
|
||||
}
|
||||
|
||||
function isVisible(element: HTMLElement): boolean {
|
||||
const style = getComputedStyle(element);
|
||||
if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity) === 0) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
|
||||
function isEditable(element: HTMLElement): boolean {
|
||||
if (element.isContentEditable) return true;
|
||||
const tag = element.tagName.toLowerCase();
|
||||
if (tag === "textarea") return true;
|
||||
if (tag !== "input") return false;
|
||||
const type = ((element as HTMLInputElement).type || "text").toLowerCase();
|
||||
return !["button", "submit", "reset", "checkbox", "radio", "hidden"].includes(type);
|
||||
}
|
||||
|
||||
function normalizeText(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function normalizeNullable(value: string): string | null {
|
||||
const normalized = normalizeText(value);
|
||||
return normalized || null;
|
||||
}
|
||||
}
|
||||
|
||||
139
src/types.ts
Normal file
139
src/types.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
export type LocatorDescriptor =
|
||||
| { kind: "role"; role: string; name?: string }
|
||||
| { kind: "label"; label: string }
|
||||
| { kind: "placeholder"; placeholder: string }
|
||||
| { kind: "text"; text: string; exact: boolean }
|
||||
| { kind: "testId"; testId: string }
|
||||
| { kind: "css"; selector: string };
|
||||
|
||||
export type LocatorFramework = "playwright" | "css";
|
||||
|
||||
export interface LocatorCandidate {
|
||||
locatorId: string;
|
||||
elementId: string;
|
||||
framework: LocatorFramework;
|
||||
strategy: LocatorDescriptor["kind"];
|
||||
descriptor: LocatorDescriptor;
|
||||
expression: string;
|
||||
score: number;
|
||||
reason: string;
|
||||
verified: boolean;
|
||||
lastVerifiedAt?: string;
|
||||
matchCount?: number;
|
||||
failureCount: number;
|
||||
}
|
||||
|
||||
export interface VisibilityInfo {
|
||||
visible: boolean;
|
||||
enabled: boolean;
|
||||
editable: boolean;
|
||||
receivesEvents: boolean;
|
||||
}
|
||||
|
||||
export interface BoundingBox {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ActionableElement {
|
||||
elementId: string;
|
||||
stateId: string;
|
||||
tag: string;
|
||||
role: string | null;
|
||||
accessibleName: string | null;
|
||||
text: string | null;
|
||||
label: string | null;
|
||||
placeholder: string | null;
|
||||
attributes: Record<string, string>;
|
||||
bbox: BoundingBox | null;
|
||||
framePath: string[];
|
||||
shadowPath: string[];
|
||||
interactability: VisibilityInfo;
|
||||
locators: LocatorCandidate[];
|
||||
}
|
||||
|
||||
export interface StateNode {
|
||||
stateId: string;
|
||||
url: string;
|
||||
route: string;
|
||||
title: string;
|
||||
frameContext: string[];
|
||||
stateHash: string;
|
||||
domHash: string;
|
||||
visibleTextHash: string;
|
||||
actionableSignatureHash: string;
|
||||
modalStack: string[];
|
||||
openedByEdgeId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type ActionType = "click" | "fill" | "select" | "check" | "uncheck" | "navigate";
|
||||
|
||||
export interface EffectRecord {
|
||||
type: "url" | "dom" | "visibleText" | "network" | "download" | "dialog" | "storage";
|
||||
before?: string;
|
||||
after?: string;
|
||||
method?: string;
|
||||
url?: string;
|
||||
status?: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ParameterSpec {
|
||||
name: string;
|
||||
type: "string" | "number" | "boolean" | "enum" | "date" | "unknown";
|
||||
required: boolean;
|
||||
enumValues?: string[];
|
||||
pattern?: string;
|
||||
examples?: string[];
|
||||
}
|
||||
|
||||
export interface ActionEdge {
|
||||
edgeId: string;
|
||||
fromStateId: string;
|
||||
toStateId: string | null;
|
||||
elementId: string;
|
||||
actionType: ActionType;
|
||||
actionParams: Record<string, unknown>;
|
||||
preconditions: string[];
|
||||
effects: EffectRecord[];
|
||||
parameterSpecs: ParameterSpec[];
|
||||
riskLevel: "low" | "medium" | "high";
|
||||
confidence: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TopologyGraph {
|
||||
schemaVersion: "0.1";
|
||||
metadata: {
|
||||
entryUrl: string;
|
||||
generatedAt: string;
|
||||
engineVersion: string;
|
||||
mode: "scan" | "explore";
|
||||
};
|
||||
states: StateNode[];
|
||||
elements: ActionableElement[];
|
||||
edges: ActionEdge[];
|
||||
}
|
||||
|
||||
export interface RawActionableElement {
|
||||
uid: string;
|
||||
tag: string;
|
||||
role: string | null;
|
||||
accessibleName: string | null;
|
||||
text: string | null;
|
||||
label: string | null;
|
||||
placeholder: string | null;
|
||||
attributes: Record<string, string>;
|
||||
bbox: BoundingBox | null;
|
||||
visibility: VisibilityInfo;
|
||||
}
|
||||
|
||||
export interface SnapshotResult {
|
||||
state: StateNode;
|
||||
elements: ActionableElement[];
|
||||
visibleText: string;
|
||||
}
|
||||
|
||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user