feat: add topology graph v0.2.2 contract
This commit is contained in:
13
README.md
13
README.md
@@ -11,11 +11,13 @@ The project intentionally does not include an LLM, a generic browser agent, or a
|
|||||||
- Capture one page state with DOM and visible-text fingerprints.
|
- Capture one page state with DOM and visible-text fingerprints.
|
||||||
- Extract actionable elements: buttons, links, inputs, selects, textareas, roles, tab stops, and click handlers.
|
- Extract actionable elements: buttons, links, inputs, selects, textareas, roles, tab stops, and click handlers.
|
||||||
- Generate locator candidates with scores, context scopes, identity checks, and verification evidence.
|
- Generate locator candidates with scores, context scopes, identity checks, and verification evidence.
|
||||||
|
- Compile scoped locator descriptors for row/dialog/form/list-item contexts when they can make duplicated targets executable.
|
||||||
- Optionally run low-risk one-hop click exploration from a clean entry state.
|
- Optionally run low-risk one-hop click exploration from a clean entry state.
|
||||||
- Replay actions from the entry state and record observed state transitions with snapshot-diff effects.
|
- Replay actions from the entry state and record observed state transitions with snapshot-diff effects.
|
||||||
|
- Record navigation failures as failed observations when a graph can still be emitted.
|
||||||
- Record state-local skipped default-exploration actions when risk policy blocks them.
|
- Record state-local skipped default-exploration actions when risk policy blocks them.
|
||||||
- Record failed observations instead of turning failed locators or failed actions into successful edges.
|
- Record failed observations instead of turning failed locators or failed actions into successful edges.
|
||||||
- Emit state nodes, elements, locators, action edges, skipped actions, failed observations, and metadata in JSON.
|
- Emit state nodes, elements, locators, action edges, skipped actions, failed observations, metadata, and summary counts in JSON.
|
||||||
|
|
||||||
## Non-Goals
|
## Non-Goals
|
||||||
|
|
||||||
@@ -52,6 +54,12 @@ Entry-state low-risk exploration:
|
|||||||
npm run example
|
npm run example
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Timeout tuning for slow sites:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run explore -- --url https://www.gov.cn/ --out artifacts/govcn.json --max-actions 2 --navigation-timeout-ms 60000 --action-timeout-ms 3000
|
||||||
|
```
|
||||||
|
|
||||||
Build:
|
Build:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -60,7 +68,7 @@ npm run build
|
|||||||
|
|
||||||
## Output Model
|
## Output Model
|
||||||
|
|
||||||
The output JSON follows the schema described in [schemas/topology-graph.schema.md](schemas/topology-graph.schema.md).
|
The output JSON follows the schema described in [schemas/topology-graph.schema.md](schemas/topology-graph.schema.md). A machine-checkable JSON Schema is available at [schemas/topology-graph.schema.json](schemas/topology-graph.schema.json).
|
||||||
|
|
||||||
Key objects:
|
Key objects:
|
||||||
|
|
||||||
@@ -72,6 +80,7 @@ Key objects:
|
|||||||
- `EffectRecord`: snapshot-diff record for `url`, `dom`, `visibleText`, or `actionableInventory`, with `before` and `after` values.
|
- `EffectRecord`: snapshot-diff record for `url`, `dom`, `visibleText`, or `actionableInventory`, with `before` and `after` values.
|
||||||
- `SkippedAction`: risk-policy default exploration refusal with risk category, reason, state id, element id, and evidence.
|
- `SkippedAction`: risk-policy default exploration refusal with risk category, reason, state id, element id, and evidence.
|
||||||
- `FailedObservation`: locator, actionability, action, or effect failure with machine-readable reason and diagnostic evidence.
|
- `FailedObservation`: locator, actionability, action, or effect failure with machine-readable reason and diagnostic evidence.
|
||||||
|
- `summary`: count totals and grouped counts for edges, skipped actions, and failed observations.
|
||||||
|
|
||||||
## Source Notes
|
## Source Notes
|
||||||
|
|
||||||
|
|||||||
17
docs/source/2026-06-17-markitdown-reference-notes.md
Normal file
17
docs/source/2026-06-17-markitdown-reference-notes.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# MarkItDown Reference Notes
|
||||||
|
|
||||||
|
Source reviewed: Microsoft `markitdown`, official repository and README.
|
||||||
|
|
||||||
|
## Useful Patterns
|
||||||
|
|
||||||
|
- Keep conversion and extraction units narrow. MarkItDown separates `accepts()` from `convert()`, which maps well to this engine's separation between candidate selection, locator verification, replay, and failure records.
|
||||||
|
- Prefer a registry-style extraction pipeline when multiple strategies may apply. MarkItDown registers specific converters before generic HTML/plain-text fallbacks; a future topology engine can use the same pattern for snapshot extraction strategies without becoming a generic crawler.
|
||||||
|
- Preserve machine-friendly output over high-fidelity rendering. MarkItDown targets Markdown for LLM/text pipelines, not perfect visual reproduction. This matches this project's graph-artifact boundary.
|
||||||
|
- Normalize output centrally. MarkItDown trims trailing line whitespace and collapses excessive blank lines after converter execution; this project now uses graph summaries and JSON Schema validation as artifact-level normalization checks.
|
||||||
|
- Keep security boundaries explicit. MarkItDown warns that URI/file conversion performs I/O with current process privileges and recommends narrow conversion APIs. This project should keep navigation scopes, timeouts, and risk policy explicit rather than adding broad fetch/convert behavior.
|
||||||
|
|
||||||
|
## Non-Adopted Items
|
||||||
|
|
||||||
|
- Do not add MarkItDown as a runtime dependency for v0.2.2.
|
||||||
|
- Do not emit Markdown as the primary artifact. Markdown can be future evidence or summaries, but the project contract remains `TopologyGraph`.
|
||||||
|
- Do not add LLM/OCR/cloud conversion plugins to this repository.
|
||||||
59
package-lock.json
generated
59
package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.15.30",
|
"@types/node": "^22.15.30",
|
||||||
|
"ajv": "^8.20.0",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.20.3",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
@@ -471,6 +472,23 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ajv": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"fast-uri": "^3.0.1",
|
||||||
|
"json-schema-traverse": "^1.0.0",
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||||
@@ -513,6 +531,30 @@
|
|||||||
"@esbuild/win32-x64": "0.28.1"
|
"@esbuild/win32-x64": "0.28.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-deep-equal": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/fast-uri": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
@@ -527,6 +569,13 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/json-schema-traverse": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.61.0",
|
"version": "1.61.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
|
||||||
@@ -557,6 +606,16 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.22.4",
|
"version": "4.22.4",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.15.30",
|
"@types/node": "^22.15.30",
|
||||||
|
"ajv": "^8.20.0",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.20.3",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
|
|||||||
552
schemas/topology-graph.schema.json
Normal file
552
schemas/topology-graph.schema.json
Normal file
@@ -0,0 +1,552 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"$id": "https://action-topology-engine.local/schemas/topology-graph.schema.json",
|
||||||
|
"title": "TopologyGraph",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"schemaVersion",
|
||||||
|
"metadata",
|
||||||
|
"states",
|
||||||
|
"elements",
|
||||||
|
"edges",
|
||||||
|
"skippedActions",
|
||||||
|
"failedObservations",
|
||||||
|
"summary"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"schemaVersion": { "const": "0.2" },
|
||||||
|
"metadata": { "$ref": "#/$defs/metadata" },
|
||||||
|
"states": { "type": "array", "items": { "$ref": "#/$defs/stateNode" } },
|
||||||
|
"elements": { "type": "array", "items": { "$ref": "#/$defs/actionableElement" } },
|
||||||
|
"edges": { "type": "array", "items": { "$ref": "#/$defs/actionEdge" } },
|
||||||
|
"skippedActions": { "type": "array", "items": { "$ref": "#/$defs/skippedAction" } },
|
||||||
|
"failedObservations": { "type": "array", "items": { "$ref": "#/$defs/failedObservation" } },
|
||||||
|
"summary": { "$ref": "#/$defs/graphSummary" }
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"metadata": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"artifactVersion",
|
||||||
|
"inputUrl",
|
||||||
|
"entryUrl",
|
||||||
|
"generatedAt",
|
||||||
|
"engineVersion",
|
||||||
|
"mode",
|
||||||
|
"crawlSessionId",
|
||||||
|
"inputSeed",
|
||||||
|
"inputFingerprint",
|
||||||
|
"runtimeContext",
|
||||||
|
"site",
|
||||||
|
"riskPolicyVersion",
|
||||||
|
"normalizationRuleVersion",
|
||||||
|
"locatorScoringVersion"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"artifactVersion": { "const": "topology-graph-v0.2" },
|
||||||
|
"inputUrl": { "type": "string" },
|
||||||
|
"entryUrl": { "type": "string" },
|
||||||
|
"generatedAt": { "type": "string" },
|
||||||
|
"engineVersion": { "const": "0.2.2" },
|
||||||
|
"mode": { "enum": ["scan", "explore"] },
|
||||||
|
"crawlSessionId": { "type": "string", "pattern": "^crawl_" },
|
||||||
|
"inputSeed": { "type": "string" },
|
||||||
|
"inputFingerprint": { "type": "string", "pattern": "^input_" },
|
||||||
|
"runtimeContext": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["browserName", "viewport", "locale", "timezoneId", "authContext"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"browserName": { "const": "chromium" },
|
||||||
|
"viewport": { "$ref": "#/$defs/viewport" },
|
||||||
|
"locale": { "type": "string" },
|
||||||
|
"timezoneId": { "type": ["string", "null"] },
|
||||||
|
"authContext": { "enum": ["anonymous", "storage_state"] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"site": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["baseUrl", "entryUrl"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"baseUrl": { "type": "string" },
|
||||||
|
"entryUrl": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"riskPolicyVersion": { "const": "risk-policy-v0.2" },
|
||||||
|
"normalizationRuleVersion": { "const": "normalization-v0.2" },
|
||||||
|
"locatorScoringVersion": { "const": "locator-scoring-v0.2" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"stateNode": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"stateId",
|
||||||
|
"url",
|
||||||
|
"route",
|
||||||
|
"title",
|
||||||
|
"frameContext",
|
||||||
|
"stateHash",
|
||||||
|
"domHash",
|
||||||
|
"visibleTextHash",
|
||||||
|
"actionableSignatureHash",
|
||||||
|
"modalStack",
|
||||||
|
"openedByEdgeId",
|
||||||
|
"createdAt",
|
||||||
|
"lifecycle",
|
||||||
|
"urlCanonicalKey",
|
||||||
|
"language",
|
||||||
|
"viewport",
|
||||||
|
"evidence"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"stateId": { "type": "string", "pattern": "^state_" },
|
||||||
|
"url": { "type": "string" },
|
||||||
|
"route": { "type": "string" },
|
||||||
|
"title": { "type": "string" },
|
||||||
|
"frameContext": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"stateHash": { "type": "string" },
|
||||||
|
"domHash": { "type": "string" },
|
||||||
|
"visibleTextHash": { "type": "string" },
|
||||||
|
"actionableSignatureHash": { "type": "string" },
|
||||||
|
"modalStack": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"openedByEdgeId": { "type": ["string", "null"] },
|
||||||
|
"createdAt": { "type": "string" },
|
||||||
|
"lifecycle": { "enum": ["observed", "fingerprinted", "verified", "stale", "deprecated", "conflicted"] },
|
||||||
|
"urlCanonicalKey": { "type": "string" },
|
||||||
|
"language": { "type": ["string", "null"] },
|
||||||
|
"viewport": { "$ref": "#/$defs/viewport" },
|
||||||
|
"evidence": { "$ref": "#/$defs/evidenceList" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actionableElement": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"elementId",
|
||||||
|
"stateId",
|
||||||
|
"tag",
|
||||||
|
"role",
|
||||||
|
"accessibleName",
|
||||||
|
"text",
|
||||||
|
"label",
|
||||||
|
"placeholder",
|
||||||
|
"attributes",
|
||||||
|
"bbox",
|
||||||
|
"framePath",
|
||||||
|
"shadowPath",
|
||||||
|
"interactability",
|
||||||
|
"locators",
|
||||||
|
"context",
|
||||||
|
"parameterSurface",
|
||||||
|
"evidence"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"elementId": { "type": "string", "pattern": "^el_" },
|
||||||
|
"stateId": { "type": "string", "pattern": "^state_" },
|
||||||
|
"tag": { "type": "string" },
|
||||||
|
"role": { "type": ["string", "null"] },
|
||||||
|
"accessibleName": { "type": ["string", "null"] },
|
||||||
|
"text": { "type": ["string", "null"] },
|
||||||
|
"label": { "type": ["string", "null"] },
|
||||||
|
"placeholder": { "type": ["string", "null"] },
|
||||||
|
"attributes": { "type": "object", "additionalProperties": { "type": "string" } },
|
||||||
|
"bbox": { "anyOf": [{ "$ref": "#/$defs/boundingBox" }, { "type": "null" }] },
|
||||||
|
"framePath": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"shadowPath": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"interactability": { "$ref": "#/$defs/interactability" },
|
||||||
|
"locators": { "type": "array", "items": { "$ref": "#/$defs/locatorCandidate" } },
|
||||||
|
"context": { "$ref": "#/$defs/elementContext" },
|
||||||
|
"parameterSurface": { "anyOf": [{ "$ref": "#/$defs/parameterSurface" }, { "type": "null" }] },
|
||||||
|
"evidence": { "$ref": "#/$defs/evidenceList" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"locatorCandidate": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"locatorId",
|
||||||
|
"elementId",
|
||||||
|
"framework",
|
||||||
|
"strategy",
|
||||||
|
"descriptor",
|
||||||
|
"expression",
|
||||||
|
"score",
|
||||||
|
"reason",
|
||||||
|
"verified",
|
||||||
|
"scopes",
|
||||||
|
"identity",
|
||||||
|
"evidence",
|
||||||
|
"failureCount"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"locatorId": { "type": "string", "pattern": "^loc_" },
|
||||||
|
"elementId": { "type": "string", "pattern": "^el_" },
|
||||||
|
"framework": { "enum": ["playwright", "css"] },
|
||||||
|
"strategy": { "enum": ["role", "label", "placeholder", "text", "testId", "css", "scoped"] },
|
||||||
|
"descriptor": { "$ref": "#/$defs/locatorDescriptor" },
|
||||||
|
"expression": { "type": "string" },
|
||||||
|
"score": { "type": "number" },
|
||||||
|
"reason": { "type": "string" },
|
||||||
|
"verified": { "type": "boolean" },
|
||||||
|
"lastVerifiedAt": { "type": "string" },
|
||||||
|
"matchCount": { "type": "integer", "minimum": 0 },
|
||||||
|
"scopes": { "type": "array", "items": { "$ref": "#/$defs/locatorScope" } },
|
||||||
|
"identity": { "anyOf": [{ "$ref": "#/$defs/locatorIdentity" }, { "type": "null" }] },
|
||||||
|
"evidence": { "$ref": "#/$defs/evidenceList" },
|
||||||
|
"failureCount": { "type": "integer", "minimum": 0 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"locatorDescriptor": {
|
||||||
|
"oneOf": [
|
||||||
|
{ "$ref": "#/$defs/baseLocatorDescriptor" },
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["kind", "scope", "target"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"kind": { "const": "scoped" },
|
||||||
|
"scope": { "$ref": "#/$defs/locatorScope" },
|
||||||
|
"target": { "$ref": "#/$defs/baseLocatorDescriptor" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"baseLocatorDescriptor": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["kind", "role"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"kind": { "const": "role" },
|
||||||
|
"role": { "type": "string" },
|
||||||
|
"name": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["kind", "label"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"kind": { "const": "label" },
|
||||||
|
"label": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["kind", "placeholder"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"kind": { "const": "placeholder" },
|
||||||
|
"placeholder": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["kind", "text", "exact"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"kind": { "const": "text" },
|
||||||
|
"text": { "type": "string" },
|
||||||
|
"exact": { "type": "boolean" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["kind", "testId"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"kind": { "const": "testId" },
|
||||||
|
"testId": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["kind", "selector"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"kind": { "const": "css" },
|
||||||
|
"selector": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"actionEdge": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"edgeId",
|
||||||
|
"fromStateId",
|
||||||
|
"toStateId",
|
||||||
|
"elementId",
|
||||||
|
"actionType",
|
||||||
|
"actionParams",
|
||||||
|
"preconditions",
|
||||||
|
"effects",
|
||||||
|
"parameterSpecs",
|
||||||
|
"riskLevel",
|
||||||
|
"riskCategory",
|
||||||
|
"confidence",
|
||||||
|
"createdAt"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"edgeId": { "type": "string", "pattern": "^edge_" },
|
||||||
|
"fromStateId": { "type": "string", "pattern": "^state_" },
|
||||||
|
"toStateId": { "type": ["string", "null"] },
|
||||||
|
"elementId": { "type": "string", "pattern": "^el_" },
|
||||||
|
"actionType": { "$ref": "#/$defs/actionType" },
|
||||||
|
"actionParams": { "type": "object" },
|
||||||
|
"preconditions": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"effects": { "type": "array", "items": { "$ref": "#/$defs/effectRecord" } },
|
||||||
|
"parameterSpecs": { "type": "array", "items": { "$ref": "#/$defs/parameterSpec" } },
|
||||||
|
"riskLevel": { "$ref": "#/$defs/riskLevel" },
|
||||||
|
"riskCategory": { "$ref": "#/$defs/riskCategory" },
|
||||||
|
"confidence": { "type": "number" },
|
||||||
|
"createdAt": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"effectRecord": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["type", "description"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"type": { "enum": ["url", "dom", "visibleText", "actionableInventory"] },
|
||||||
|
"before": { "type": "string" },
|
||||||
|
"after": { "type": "string" },
|
||||||
|
"description": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skippedAction": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["skippedActionId", "stateId", "elementId", "actionType", "riskLevel", "riskCategory", "reason", "evidence", "createdAt"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"skippedActionId": { "type": "string", "pattern": "^skip_" },
|
||||||
|
"stateId": { "type": "string", "pattern": "^state_" },
|
||||||
|
"elementId": { "type": "string", "pattern": "^el_" },
|
||||||
|
"actionType": { "$ref": "#/$defs/actionType" },
|
||||||
|
"riskLevel": { "$ref": "#/$defs/riskLevel" },
|
||||||
|
"riskCategory": { "$ref": "#/$defs/riskCategory" },
|
||||||
|
"reason": { "type": "string" },
|
||||||
|
"evidence": { "$ref": "#/$defs/evidenceList" },
|
||||||
|
"createdAt": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"failedObservation": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["failedObservationId", "stateId", "elementId", "actionType", "reason", "message", "evidence", "createdAt"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"failedObservationId": { "type": "string", "pattern": "^fail_" },
|
||||||
|
"stateId": { "type": ["string", "null"] },
|
||||||
|
"elementId": { "type": ["string", "null"] },
|
||||||
|
"actionType": { "anyOf": [{ "$ref": "#/$defs/actionType" }, { "type": "null" }] },
|
||||||
|
"reason": { "$ref": "#/$defs/failedObservationReason" },
|
||||||
|
"message": { "type": "string" },
|
||||||
|
"evidence": { "$ref": "#/$defs/evidenceList" },
|
||||||
|
"createdAt": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"graphSummary": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"states",
|
||||||
|
"elements",
|
||||||
|
"edges",
|
||||||
|
"skippedActions",
|
||||||
|
"failedObservations",
|
||||||
|
"failedObservationsByReason",
|
||||||
|
"skippedActionsByRiskCategory",
|
||||||
|
"edgesByRiskCategory"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"states": { "type": "integer", "minimum": 0 },
|
||||||
|
"elements": { "type": "integer", "minimum": 0 },
|
||||||
|
"edges": { "type": "integer", "minimum": 0 },
|
||||||
|
"skippedActions": { "type": "integer", "minimum": 0 },
|
||||||
|
"failedObservations": { "type": "integer", "minimum": 0 },
|
||||||
|
"failedObservationsByReason": { "$ref": "#/$defs/countMap" },
|
||||||
|
"skippedActionsByRiskCategory": { "$ref": "#/$defs/countMap" },
|
||||||
|
"edgesByRiskCategory": { "$ref": "#/$defs/countMap" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameterSpec": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["name", "type", "required"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"type": { "enum": ["string", "number", "boolean", "enum", "date", "unknown"] },
|
||||||
|
"required": { "type": "boolean" },
|
||||||
|
"enumValues": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"pattern": { "type": "string" },
|
||||||
|
"examples": { "type": "array", "items": { "type": "string" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameterSurface": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["name", "inputType", "required", "readonly", "disabled", "pattern", "min", "max", "maxLength", "options"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": ["string", "null"] },
|
||||||
|
"inputType": { "type": ["string", "null"] },
|
||||||
|
"required": { "type": "boolean" },
|
||||||
|
"readonly": { "type": "boolean" },
|
||||||
|
"disabled": { "type": "boolean" },
|
||||||
|
"pattern": { "type": ["string", "null"] },
|
||||||
|
"min": { "type": ["string", "null"] },
|
||||||
|
"max": { "type": ["string", "null"] },
|
||||||
|
"maxLength": { "type": ["integer", "null"] },
|
||||||
|
"options": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["label", "value", "disabled", "selected"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"label": { "type": "string" },
|
||||||
|
"value": { "type": "string" },
|
||||||
|
"disabled": { "type": "boolean" },
|
||||||
|
"selected": { "type": "boolean" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"elementContext": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["dialog", "form", "section", "row", "landmark", "listitem"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"dialog": { "anyOf": [{ "$ref": "#/$defs/contextAnchor" }, { "type": "null" }] },
|
||||||
|
"form": { "anyOf": [{ "$ref": "#/$defs/contextAnchor" }, { "type": "null" }] },
|
||||||
|
"section": { "anyOf": [{ "$ref": "#/$defs/contextAnchor" }, { "type": "null" }] },
|
||||||
|
"row": { "anyOf": [{ "$ref": "#/$defs/contextAnchor" }, { "type": "null" }] },
|
||||||
|
"landmark": { "anyOf": [{ "$ref": "#/$defs/contextAnchor" }, { "type": "null" }] },
|
||||||
|
"listitem": { "anyOf": [{ "$ref": "#/$defs/contextAnchor" }, { "type": "null" }] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contextAnchor": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["kind", "name", "text"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"kind": { "enum": ["dialog", "form", "section", "row", "landmark", "listitem"] },
|
||||||
|
"name": { "type": ["string", "null"] },
|
||||||
|
"text": { "type": ["string", "null"] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"locatorScope": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["kind", "name", "text"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"kind": { "enum": ["dialog", "form", "section", "row", "landmark", "listitem"] },
|
||||||
|
"name": { "type": ["string", "null"] },
|
||||||
|
"text": { "type": ["string", "null"] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"locatorIdentity": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["sameTag", "sameRole", "sameAccessibleName", "sameText", "sameVisibility"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"sameTag": { "type": "boolean" },
|
||||||
|
"sameRole": { "type": "boolean" },
|
||||||
|
"sameAccessibleName": { "type": "boolean" },
|
||||||
|
"sameText": { "type": "boolean" },
|
||||||
|
"sameVisibility": { "type": "boolean" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"interactability": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["visible", "enabled", "editable", "receivesEvents"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"visible": { "type": "boolean" },
|
||||||
|
"enabled": { "type": "boolean" },
|
||||||
|
"editable": { "type": "boolean" },
|
||||||
|
"receivesEvents": { "type": "boolean" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"boundingBox": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["x", "y", "width", "height"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"x": { "type": "number" },
|
||||||
|
"y": { "type": "number" },
|
||||||
|
"width": { "type": "number" },
|
||||||
|
"height": { "type": "number" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"viewport": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["width", "height"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"width": { "type": "integer" },
|
||||||
|
"height": { "type": "integer" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"evidenceList": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/evidenceRecord" }
|
||||||
|
},
|
||||||
|
"evidenceRecord": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["level", "source", "description"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"level": { "enum": ["observed", "derived", "verified", "heuristic", "external"] },
|
||||||
|
"source": { "type": "string" },
|
||||||
|
"description": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actionType": { "enum": ["click", "fill", "select", "check", "uncheck", "navigate"] },
|
||||||
|
"riskLevel": { "enum": ["low", "medium", "high"] },
|
||||||
|
"riskCategory": {
|
||||||
|
"enum": [
|
||||||
|
"read_only",
|
||||||
|
"navigation",
|
||||||
|
"ui_reveal",
|
||||||
|
"input_edit",
|
||||||
|
"form_submit",
|
||||||
|
"data_mutation",
|
||||||
|
"destructive",
|
||||||
|
"auth_sensitive",
|
||||||
|
"payment_sensitive",
|
||||||
|
"unknown"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"failedObservationReason": {
|
||||||
|
"enum": [
|
||||||
|
"locator_not_found",
|
||||||
|
"locator_not_unique",
|
||||||
|
"element_hidden",
|
||||||
|
"element_disabled",
|
||||||
|
"element_obscured",
|
||||||
|
"state_precondition_missing",
|
||||||
|
"frame_inaccessible",
|
||||||
|
"shadow_root_closed",
|
||||||
|
"navigation_timeout",
|
||||||
|
"network_error",
|
||||||
|
"permission_denied",
|
||||||
|
"auth_required",
|
||||||
|
"captcha_or_challenge",
|
||||||
|
"risk_policy_blocked",
|
||||||
|
"effect_not_observed",
|
||||||
|
"unexpected_state"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"countMap": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": { "type": "integer", "minimum": 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Topology Graph Schema
|
# 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.
|
This document describes the JSON shape emitted by the engine. A formal JSON Schema is also provided in `schemas/topology-graph.schema.json`.
|
||||||
|
|
||||||
## `TopologyGraph`
|
## `TopologyGraph`
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ This document describes the JSON shape emitted by the engine. It is a practical
|
|||||||
- `edges`: observed actions from one state to another.
|
- `edges`: observed actions from one state to another.
|
||||||
- `skippedActions`: state-local default-explorer actions that risk policy refused to execute.
|
- `skippedActions`: state-local default-explorer actions that risk policy refused to execute.
|
||||||
- `failedObservations`: failed locator, actionability, action, or effect observations recorded as evidence.
|
- `failedObservations`: failed locator, actionability, action, or effect observations recorded as evidence.
|
||||||
|
- `summary`: machine-readable artifact counts and grouped counts.
|
||||||
|
|
||||||
## Evidence Levels
|
## Evidence Levels
|
||||||
|
|
||||||
@@ -64,11 +65,12 @@ This document describes the JSON shape emitted by the engine. It is a practical
|
|||||||
- `locatorId`: locator candidate id.
|
- `locatorId`: locator candidate id.
|
||||||
- `elementId`: parent element id. This must equal the containing `ActionableElement.elementId`.
|
- `elementId`: parent element id. This must equal the containing `ActionableElement.elementId`.
|
||||||
- `framework`: `playwright` or `css`.
|
- `framework`: `playwright` or `css`.
|
||||||
- `strategy`: locator strategy such as role, label, placeholder, text, test id, or CSS.
|
- `strategy`: locator strategy such as role, label, placeholder, text, test id, CSS, or scoped.
|
||||||
- `descriptor`: structured locator descriptor.
|
- `descriptor`: structured locator descriptor.
|
||||||
- `expression`: human-readable locator expression.
|
- `expression`: human-readable locator expression.
|
||||||
- `score`, `reason`, `verified`, `matchCount`, `failureCount`, `lastVerifiedAt`: ranking and verification metadata.
|
- `score`, `reason`, `verified`, `matchCount`, `failureCount`, `lastVerifiedAt`: ranking and verification metadata.
|
||||||
- `scopes`: context-scope evidence such as row, dialog, form, or section anchors. In v0.2.1 scopes are ranking and disambiguation evidence only; they are not executable scoping unless descriptor support is added.
|
- `scopes`: context-scope evidence such as row, dialog, form, or section anchors.
|
||||||
|
- `descriptor.kind: "scoped"`: executable scoped locator descriptor. In v0.2.2 this contains a scope descriptor plus a base target descriptor, with row, dialog, form, and list-item scopes used for execution when available.
|
||||||
- `identity`: semantic identity checks for tag, role, accessible name, text, and visibility.
|
- `identity`: semantic identity checks for tag, role, accessible name, text, and visibility.
|
||||||
- `evidence`: generation and verification facts.
|
- `evidence`: generation and verification facts.
|
||||||
|
|
||||||
@@ -82,6 +84,7 @@ This document describes the JSON shape emitted by the engine. It is a practical
|
|||||||
- `confidence`: confidence in observed edge replayability.
|
- `confidence`: confidence in observed edge replayability.
|
||||||
- `createdAt`: ISO timestamp for edge capture.
|
- `createdAt`: ISO timestamp for edge capture.
|
||||||
- In v0.2.1 default exploration replays each edge from a clean entry-state context.
|
- In v0.2.1 default exploration replays each edge from a clean entry-state context.
|
||||||
|
- Navigation timeout during entry or replay can be recorded as `FailedObservation` instead of aborting artifact generation.
|
||||||
|
|
||||||
## `EffectRecord`
|
## `EffectRecord`
|
||||||
|
|
||||||
@@ -103,8 +106,16 @@ This document describes the JSON shape emitted by the engine. It is a practical
|
|||||||
## `FailedObservation`
|
## `FailedObservation`
|
||||||
|
|
||||||
- `failedObservationId`, `stateId`, optional `elementId`, optional `actionType`: failed observation identity.
|
- `failedObservationId`, `stateId`, optional `elementId`, optional `actionType`: failed observation identity.
|
||||||
|
- `stateId` may be `null` for graph-level failures such as entry navigation timeout before any state exists.
|
||||||
- `reason`: machine-readable failure code.
|
- `reason`: machine-readable failure code.
|
||||||
- `message`: diagnostic message.
|
- `message`: diagnostic message.
|
||||||
- `evidence`: observed facts explaining the failure.
|
- `evidence`: observed facts explaining the failure.
|
||||||
- `createdAt`: ISO timestamp for the failure record.
|
- `createdAt`: ISO timestamp for the failure record.
|
||||||
- `FailedObservation` covers hidden, disabled, obscured, locator, action, and effect failures. Risk-policy default-explorer refusals belong in `SkippedAction`.
|
- `FailedObservation` covers hidden, disabled, obscured, locator, action, and effect failures. Risk-policy default-explorer refusals belong in `SkippedAction`.
|
||||||
|
|
||||||
|
## `summary`
|
||||||
|
|
||||||
|
- `states`, `elements`, `edges`, `skippedActions`, `failedObservations`: total counts for the artifact.
|
||||||
|
- `failedObservationsByReason`: count map keyed by `FailedObservation.reason`.
|
||||||
|
- `skippedActionsByRiskCategory`: count map keyed by skipped action risk category.
|
||||||
|
- `edgesByRiskCategory`: count map keyed by executed edge risk category.
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ function parseArgs(argv: string[]): ParsedArgs {
|
|||||||
case "--max-actions":
|
case "--max-actions":
|
||||||
options.maxActions = Number(requireValue(argv, ++index, "--max-actions"));
|
options.maxActions = Number(requireValue(argv, ++index, "--max-actions"));
|
||||||
break;
|
break;
|
||||||
|
case "--navigation-timeout-ms":
|
||||||
|
options.navigationTimeoutMs = Number(requireValue(argv, ++index, "--navigation-timeout-ms"));
|
||||||
|
break;
|
||||||
|
case "--action-timeout-ms":
|
||||||
|
options.actionTimeoutMs = Number(requireValue(argv, ++index, "--action-timeout-ms"));
|
||||||
|
break;
|
||||||
case "--headed":
|
case "--headed":
|
||||||
options.headed = true;
|
options.headed = true;
|
||||||
break;
|
break;
|
||||||
@@ -54,7 +60,7 @@ function requireValue(argv: string[], index: number, flag: string): string {
|
|||||||
function usage(): never {
|
function usage(): never {
|
||||||
console.error(`Usage:
|
console.error(`Usage:
|
||||||
action-topology scan --url <url-or-file> [--out graph.json] [--headed] [--storage-state state.json]
|
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]
|
action-topology explore --url <url-or-file> [--out graph.json] [--max-actions 10] [--navigation-timeout-ms 30000] [--action-timeout-ms 2000] [--headed] [--storage-state state.json]
|
||||||
`);
|
`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -63,4 +69,3 @@ main().catch((error) => {
|
|||||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
134
src/engine.ts
134
src/engine.ts
@@ -8,6 +8,7 @@ import { resolveLocator } from "./locator.js";
|
|||||||
import { buildEmptyGraph, defaultViewport } from "./metadata.js";
|
import { buildEmptyGraph, defaultViewport } from "./metadata.js";
|
||||||
import { defaultExploreDecision } from "./risk.js";
|
import { defaultExploreDecision } from "./risk.js";
|
||||||
import { snapshotPage } from "./snapshot.js";
|
import { snapshotPage } from "./snapshot.js";
|
||||||
|
import { refreshGraphSummary } from "./summary.js";
|
||||||
import type { ActionableElement, ActionEdge, FailedObservation, LocatorCandidate, SnapshotResult, TopologyGraph } from "./types.js";
|
import type { ActionableElement, ActionEdge, FailedObservation, LocatorCandidate, SnapshotResult, TopologyGraph } from "./types.js";
|
||||||
|
|
||||||
export interface EngineOptions {
|
export interface EngineOptions {
|
||||||
@@ -16,6 +17,8 @@ export interface EngineOptions {
|
|||||||
headed?: boolean;
|
headed?: boolean;
|
||||||
storageState?: string;
|
storageState?: string;
|
||||||
maxActions?: number;
|
maxActions?: number;
|
||||||
|
navigationTimeoutMs?: number;
|
||||||
|
actionTimeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExploreCandidate {
|
interface ExploreCandidate {
|
||||||
@@ -26,22 +29,40 @@ interface ExploreCandidate {
|
|||||||
export async function scanUrl(options: EngineOptions): Promise<TopologyGraph> {
|
export async function scanUrl(options: EngineOptions): Promise<TopologyGraph> {
|
||||||
const browser = await chromium.launch({ headless: !options.headed });
|
const browser = await chromium.launch({ headless: !options.headed });
|
||||||
try {
|
try {
|
||||||
|
const requestedEntryUrl = toNavigableUrl(options.url);
|
||||||
const context = await newContext(browser, options);
|
const context = await newContext(browser, options);
|
||||||
const page = await context.newPage();
|
try {
|
||||||
await page.goto(toNavigableUrl(options.url), { waitUntil: "domcontentloaded" });
|
const page = await createReplayPage(context, requestedEntryUrl, options);
|
||||||
await page.waitForLoadState("networkidle").catch(() => undefined);
|
const snapshot = await snapshotPage(page);
|
||||||
const snapshot = await snapshotPage(page);
|
const graph = buildEmptyGraph({
|
||||||
const graph = buildEmptyGraph({
|
inputUrl: options.url,
|
||||||
inputUrl: options.url,
|
entryUrl: page.url(),
|
||||||
entryUrl: page.url(),
|
mode: "scan",
|
||||||
mode: "scan",
|
storageState: options.storageState,
|
||||||
storageState: options.storageState,
|
});
|
||||||
});
|
graph.states.push(snapshot.state);
|
||||||
graph.states.push(snapshot.state);
|
graph.elements.push(...snapshot.elements);
|
||||||
graph.elements.push(...snapshot.elements);
|
await maybeWriteGraph(graph, options.out);
|
||||||
await maybeWriteGraph(graph, options.out);
|
return graph;
|
||||||
await context.close();
|
} catch (error) {
|
||||||
return graph;
|
const graph = buildEmptyGraph({
|
||||||
|
inputUrl: options.url,
|
||||||
|
entryUrl: requestedEntryUrl,
|
||||||
|
mode: "scan",
|
||||||
|
storageState: options.storageState,
|
||||||
|
});
|
||||||
|
recordNavigationFailure(graph, {
|
||||||
|
stateId: null,
|
||||||
|
elementId: null,
|
||||||
|
actionType: "navigate",
|
||||||
|
url: requestedEntryUrl,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
await maybeWriteGraph(graph, options.out);
|
||||||
|
return graph;
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
}
|
}
|
||||||
@@ -55,9 +76,25 @@ export async function exploreUrl(options: EngineOptions): Promise<TopologyGraph>
|
|||||||
let entrySnapshot: SnapshotResult;
|
let entrySnapshot: SnapshotResult;
|
||||||
let entryUrl: string;
|
let entryUrl: string;
|
||||||
try {
|
try {
|
||||||
const page = await createReplayPage(entryContext, requestedEntryUrl);
|
const page = await createReplayPage(entryContext, requestedEntryUrl, options);
|
||||||
entrySnapshot = await snapshotPage(page);
|
entrySnapshot = await snapshotPage(page);
|
||||||
entryUrl = page.url();
|
entryUrl = page.url();
|
||||||
|
} catch (error) {
|
||||||
|
const graph = buildEmptyGraph({
|
||||||
|
inputUrl: options.url,
|
||||||
|
entryUrl: requestedEntryUrl,
|
||||||
|
mode: "explore",
|
||||||
|
storageState: options.storageState,
|
||||||
|
});
|
||||||
|
recordNavigationFailure(graph, {
|
||||||
|
stateId: null,
|
||||||
|
elementId: null,
|
||||||
|
actionType: "navigate",
|
||||||
|
url: requestedEntryUrl,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
await maybeWriteGraph(graph, options.out);
|
||||||
|
return graph;
|
||||||
} finally {
|
} finally {
|
||||||
await entryContext.close();
|
await entryContext.close();
|
||||||
}
|
}
|
||||||
@@ -96,7 +133,19 @@ export async function exploreUrl(options: EngineOptions): Promise<TopologyGraph>
|
|||||||
const decision = defaultExploreDecision(element, "click");
|
const decision = defaultExploreDecision(element, "click");
|
||||||
const actionContext = await newContext(browser, options);
|
const actionContext = await newContext(browser, options);
|
||||||
try {
|
try {
|
||||||
const actionPage = await createReplayPage(actionContext, entryUrl);
|
let actionPage: Page;
|
||||||
|
try {
|
||||||
|
actionPage = await createReplayPage(actionContext, entryUrl, options);
|
||||||
|
} catch (error) {
|
||||||
|
recordNavigationFailure(graph, {
|
||||||
|
stateId: entrySnapshot.state.stateId,
|
||||||
|
elementId: element.elementId,
|
||||||
|
actionType: "click",
|
||||||
|
url: entryUrl,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const beforeUrl = actionPage.url();
|
const beforeUrl = actionPage.url();
|
||||||
const beforeSnapshot = await snapshotPage(actionPage);
|
const beforeSnapshot = await snapshotPage(actionPage);
|
||||||
const edgeId = `edge_${shortHash([beforeSnapshot.state.stateId, element.elementId, "click"])}`;
|
const edgeId = `edge_${shortHash([beforeSnapshot.state.stateId, element.elementId, "click"])}`;
|
||||||
@@ -108,14 +157,14 @@ export async function exploreUrl(options: EngineOptions): Promise<TopologyGraph>
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await target.click({ timeout: 2000, trial: true });
|
await target.click({ timeout: actionTimeout(options), trial: true });
|
||||||
await target.click({ timeout: 2000 });
|
await target.click({ timeout: actionTimeout(options) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
recordClickFailure(graph, beforeSnapshot, element, error);
|
recordClickFailure(graph, beforeSnapshot, element, error);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await actionPage.waitForLoadState("networkidle", { timeout: 2000 }).catch(() => undefined);
|
await actionPage.waitForLoadState("networkidle", { timeout: actionTimeout(options) }).catch(() => undefined);
|
||||||
const afterSnapshot = await snapshotPage(actionPage, edgeId);
|
const afterSnapshot = await snapshotPage(actionPage, edgeId);
|
||||||
const discoveredState = addStateIfNew(graph, afterSnapshot.state);
|
const discoveredState = addStateIfNew(graph, afterSnapshot.state);
|
||||||
graph.elements = upsertManyById(graph.elements, afterSnapshot.elements, "elementId");
|
graph.elements = upsertManyById(graph.elements, afterSnapshot.elements, "elementId");
|
||||||
@@ -162,10 +211,10 @@ async function newContext(browser: Browser, options: EngineOptions): Promise<Bro
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createReplayPage(context: BrowserContext, entryUrl: string): Promise<Page> {
|
async function createReplayPage(context: BrowserContext, entryUrl: string, options: EngineOptions): Promise<Page> {
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
await page.goto(entryUrl, { waitUntil: "domcontentloaded" });
|
await page.goto(entryUrl, { waitUntil: "domcontentloaded", timeout: navigationTimeout(options) });
|
||||||
await page.waitForLoadState("networkidle").catch(() => undefined);
|
await page.waitForLoadState("networkidle", { timeout: actionTimeout(options) }).catch(() => undefined);
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +292,7 @@ function recordFailedObservation(graph: TopologyGraph, observation: FailedObserv
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function maybeWriteGraph(graph: TopologyGraph, out?: string): Promise<void> {
|
async function maybeWriteGraph(graph: TopologyGraph, out?: string): Promise<void> {
|
||||||
|
refreshGraphSummary(graph);
|
||||||
if (!out) {
|
if (!out) {
|
||||||
console.log(JSON.stringify(graph, null, 2));
|
console.log(JSON.stringify(graph, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -251,6 +301,44 @@ async function maybeWriteGraph(graph: TopologyGraph, out?: string): Promise<void
|
|||||||
await writeFile(out, `${JSON.stringify(graph, null, 2)}\n`, "utf8");
|
await writeFile(out, `${JSON.stringify(graph, null, 2)}\n`, "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function navigationTimeout(options: EngineOptions): number {
|
||||||
|
return options.navigationTimeoutMs ?? 30_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionTimeout(options: EngineOptions): number {
|
||||||
|
return options.actionTimeoutMs ?? 2_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordNavigationFailure(
|
||||||
|
graph: TopologyGraph,
|
||||||
|
args: {
|
||||||
|
stateId: string | null;
|
||||||
|
elementId: string | null;
|
||||||
|
actionType: FailedObservation["actionType"];
|
||||||
|
url: string;
|
||||||
|
error: unknown;
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
const message = args.error instanceof Error ? args.error.message : String(args.error);
|
||||||
|
const reason: FailedObservation["reason"] = /timeout/i.test(message) ? "navigation_timeout" : "network_error";
|
||||||
|
recordFailedObservation(graph, {
|
||||||
|
failedObservationId: `fail_${shortHash([args.stateId ?? "graph", args.elementId ?? "none", args.actionType, reason, args.url])}`,
|
||||||
|
stateId: args.stateId,
|
||||||
|
elementId: args.elementId,
|
||||||
|
actionType: args.actionType,
|
||||||
|
reason,
|
||||||
|
message: `Navigation failed for ${args.url}: ${message}`,
|
||||||
|
evidence: [
|
||||||
|
{
|
||||||
|
level: "observed",
|
||||||
|
source: "engine.navigation",
|
||||||
|
description: "Navigation failure was recorded without converting it into an observed edge",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function toNavigableUrl(value: string): string {
|
function toNavigableUrl(value: string): string {
|
||||||
if (/^https?:\/\//.test(value) || value.startsWith("file://")) return value;
|
if (/^https?:\/\//.test(value) || value.startsWith("file://")) return value;
|
||||||
return pathToFileURL(path.resolve(value)).toString();
|
return pathToFileURL(path.resolve(value)).toString();
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Locator, Page } from "playwright";
|
import type { Locator, Page } from "playwright";
|
||||||
import { shortHash } from "./hash.js";
|
import { shortHash } from "./hash.js";
|
||||||
import type { LocatorCandidate, LocatorDescriptor, LocatorScopeDescriptor, RawActionableElement } from "./types.js";
|
import type { BaseLocatorDescriptor, LocatorCandidate, LocatorDescriptor, LocatorScopeDescriptor, RawActionableElement } from "./types.js";
|
||||||
|
|
||||||
|
type LocatorRoot = Page | Locator;
|
||||||
|
|
||||||
export function generateLocators(elementId: string, raw: RawActionableElement): LocatorCandidate[] {
|
export function generateLocators(elementId: string, raw: RawActionableElement): LocatorCandidate[] {
|
||||||
const candidates: Array<Omit<LocatorCandidate, "locatorId" | "elementId" | "verified" | "failureCount" | "identity">> = [];
|
const candidates: Array<Omit<LocatorCandidate, "locatorId" | "elementId" | "verified" | "failureCount" | "identity">> = [];
|
||||||
@@ -20,6 +22,23 @@ export function generateLocators(elementId: string, raw: RawActionableElement):
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (raw.role && name) {
|
||||||
|
const scope = executableScope(scopes, name);
|
||||||
|
if (scope) {
|
||||||
|
const target: BaseLocatorDescriptor = { kind: "role", role: raw.role, name };
|
||||||
|
candidates.push({
|
||||||
|
framework: "playwright",
|
||||||
|
strategy: "scoped",
|
||||||
|
descriptor: { kind: "scoped", scope, target },
|
||||||
|
expression: `${scopeExpression(scope)}.getByRole(${JSON.stringify(raw.role)}, { name: ${JSON.stringify(name)} })`,
|
||||||
|
score: 0.97,
|
||||||
|
reason: "context-scoped role plus accessible name",
|
||||||
|
scopes,
|
||||||
|
evidence,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (raw.role && name) {
|
if (raw.role && name) {
|
||||||
candidates.push({
|
candidates.push({
|
||||||
framework: "playwright",
|
framework: "playwright",
|
||||||
@@ -258,19 +277,44 @@ export async function verifyLocators(page: Page, raw: RawActionableElement, loca
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveLocator(page: Page, descriptor: LocatorDescriptor): Locator {
|
export function resolveLocator(page: Page, descriptor: LocatorDescriptor): Locator {
|
||||||
|
if (descriptor.kind === "scoped") {
|
||||||
|
return resolveBaseLocator(resolveScopeLocator(page, descriptor.scope), descriptor.target);
|
||||||
|
}
|
||||||
|
return resolveBaseLocator(page, descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBaseLocator(root: LocatorRoot, descriptor: BaseLocatorDescriptor): Locator {
|
||||||
switch (descriptor.kind) {
|
switch (descriptor.kind) {
|
||||||
case "role":
|
case "role":
|
||||||
return page.getByRole(descriptor.role as never, descriptor.name ? { name: descriptor.name } : undefined);
|
return root.getByRole(descriptor.role as never, descriptor.name ? { name: descriptor.name } : undefined);
|
||||||
case "label":
|
case "label":
|
||||||
return page.getByLabel(descriptor.label);
|
return root.getByLabel(descriptor.label);
|
||||||
case "placeholder":
|
case "placeholder":
|
||||||
return page.getByPlaceholder(descriptor.placeholder);
|
return root.getByPlaceholder(descriptor.placeholder);
|
||||||
case "text":
|
case "text":
|
||||||
return page.getByText(descriptor.text, { exact: descriptor.exact });
|
return root.getByText(descriptor.text, { exact: descriptor.exact });
|
||||||
case "testId":
|
case "testId":
|
||||||
return page.getByTestId(descriptor.testId);
|
return root.getByTestId(descriptor.testId);
|
||||||
case "css":
|
case "css":
|
||||||
return page.locator(descriptor.selector);
|
return root.locator(descriptor.selector);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveScopeLocator(page: Page, scope: LocatorScopeDescriptor): Locator {
|
||||||
|
const text = scope.name ?? scope.text ?? "";
|
||||||
|
switch (scope.kind) {
|
||||||
|
case "row":
|
||||||
|
return page.getByRole("row").filter({ hasText: text });
|
||||||
|
case "dialog":
|
||||||
|
return scope.name ? page.getByRole("dialog", { name: scope.name }) : page.getByRole("dialog").filter({ hasText: text });
|
||||||
|
case "form":
|
||||||
|
return page.locator("form").filter({ hasText: text });
|
||||||
|
case "section":
|
||||||
|
return page.locator("section, article").filter({ hasText: text });
|
||||||
|
case "landmark":
|
||||||
|
return page.locator("nav, aside, header, footer, main, [role='navigation'], [role='main'], [role='complementary']").filter({ hasText: text });
|
||||||
|
case "listitem":
|
||||||
|
return page.getByRole("listitem").filter({ hasText: text });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,6 +336,42 @@ function contextScopes(raw: RawActionableElement): LocatorScopeDescriptor[] {
|
|||||||
.map((context) => ({ kind: context.kind, name: clean(context.name) ?? null, text: clean(context.text) ?? null }));
|
.map((context) => ({ kind: context.kind, name: clean(context.name) ?? null, text: clean(context.text) ?? null }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function executableScope(scopes: LocatorScopeDescriptor[], targetName: string): LocatorScopeDescriptor | null {
|
||||||
|
const scope = scopes.find((item) => ["row", "dialog", "form", "listitem"].includes(item.kind) && Boolean(item.name || item.text));
|
||||||
|
if (!scope) return null;
|
||||||
|
return {
|
||||||
|
...scope,
|
||||||
|
text: shortenScopeText(scope.text, targetName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortenScopeText(scopeText: string | null, targetName: string): string | null {
|
||||||
|
if (!scopeText) return null;
|
||||||
|
const escapedTarget = targetName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const shortened = scopeText.replace(new RegExp(`\\s*${escapedTarget}\\s*$`), "").trim();
|
||||||
|
return clean(shortened) ?? scopeText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeExpression(scope: LocatorScopeDescriptor): string {
|
||||||
|
const text = scope.name ?? scope.text ?? "";
|
||||||
|
switch (scope.kind) {
|
||||||
|
case "row":
|
||||||
|
return `page.getByRole("row").filter({ hasText: ${JSON.stringify(text)} })`;
|
||||||
|
case "dialog":
|
||||||
|
return scope.name
|
||||||
|
? `page.getByRole("dialog", { name: ${JSON.stringify(scope.name)} })`
|
||||||
|
: `page.getByRole("dialog").filter({ hasText: ${JSON.stringify(text)} })`;
|
||||||
|
case "form":
|
||||||
|
return `page.locator("form").filter({ hasText: ${JSON.stringify(text)} })`;
|
||||||
|
case "listitem":
|
||||||
|
return `page.getByRole("listitem").filter({ hasText: ${JSON.stringify(text)} })`;
|
||||||
|
case "section":
|
||||||
|
return `page.locator("section, article").filter({ hasText: ${JSON.stringify(text)} })`;
|
||||||
|
case "landmark":
|
||||||
|
return `page.locator("nav, aside, header, footer, main").filter({ hasText: ${JSON.stringify(text)} })`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clean(value: string | null | undefined): string | undefined {
|
function clean(value: string | null | undefined): string | undefined {
|
||||||
const cleaned = value?.replace(/\s+/g, " ").trim();
|
const cleaned = value?.replace(/\s+/g, " ").trim();
|
||||||
return cleaned || undefined;
|
return cleaned || undefined;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { ENGINE_VERSION, SCHEMA_VERSION, type GraphMode, type RuntimeContext, type TopologyGraph } from "./types.js";
|
import { ENGINE_VERSION, SCHEMA_VERSION, type GraphMode, type RuntimeContext, type TopologyGraph } from "./types.js";
|
||||||
import { shortHash } from "./hash.js";
|
import { shortHash } from "./hash.js";
|
||||||
|
import { summarizeGraph } from "./summary.js";
|
||||||
|
|
||||||
const DEFAULT_VIEWPORT = { width: 1440, height: 1000 } as const;
|
const DEFAULT_VIEWPORT = { width: 1440, height: 1000 } as const;
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ export function buildEmptyGraph(args: {
|
|||||||
"normalization-v0.2",
|
"normalization-v0.2",
|
||||||
"locator-scoring-v0.2",
|
"locator-scoring-v0.2",
|
||||||
])}`;
|
])}`;
|
||||||
return {
|
const graph: TopologyGraph = {
|
||||||
schemaVersion: SCHEMA_VERSION,
|
schemaVersion: SCHEMA_VERSION,
|
||||||
metadata: {
|
metadata: {
|
||||||
artifactVersion: "topology-graph-v0.2",
|
artifactVersion: "topology-graph-v0.2",
|
||||||
@@ -59,7 +60,19 @@ export function buildEmptyGraph(args: {
|
|||||||
edges: [],
|
edges: [],
|
||||||
skippedActions: [],
|
skippedActions: [],
|
||||||
failedObservations: [],
|
failedObservations: [],
|
||||||
|
summary: {
|
||||||
|
states: 0,
|
||||||
|
elements: 0,
|
||||||
|
edges: 0,
|
||||||
|
skippedActions: 0,
|
||||||
|
failedObservations: 0,
|
||||||
|
failedObservationsByReason: {},
|
||||||
|
skippedActionsByRiskCategory: {},
|
||||||
|
edgesByRiskCategory: {},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
graph.summary = summarizeGraph(graph);
|
||||||
|
return graph;
|
||||||
}
|
}
|
||||||
|
|
||||||
function baseUrl(value: string): string {
|
function baseUrl(value: string): string {
|
||||||
|
|||||||
28
src/summary.ts
Normal file
28
src/summary.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { GraphSummary, TopologyGraph } from "./types.js";
|
||||||
|
|
||||||
|
export function summarizeGraph(graph: TopologyGraph): GraphSummary {
|
||||||
|
return {
|
||||||
|
states: graph.states.length,
|
||||||
|
elements: graph.elements.length,
|
||||||
|
edges: graph.edges.length,
|
||||||
|
skippedActions: graph.skippedActions.length,
|
||||||
|
failedObservations: graph.failedObservations.length,
|
||||||
|
failedObservationsByReason: countBy(graph.failedObservations, (failure) => failure.reason),
|
||||||
|
skippedActionsByRiskCategory: countBy(graph.skippedActions, (skip) => skip.riskCategory),
|
||||||
|
edgesByRiskCategory: countBy(graph.edges, (edge) => edge.riskCategory),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshGraphSummary(graph: TopologyGraph): TopologyGraph {
|
||||||
|
graph.summary = summarizeGraph(graph);
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countBy<T>(items: T[], keyFor: (item: T) => string): Record<string, number> {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const item of items) {
|
||||||
|
const key = keyFor(item);
|
||||||
|
counts[key] = (counts[key] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
22
src/types.ts
22
src/types.ts
@@ -1,5 +1,5 @@
|
|||||||
export const SCHEMA_VERSION = "0.2" as const;
|
export const SCHEMA_VERSION = "0.2" as const;
|
||||||
export const ENGINE_VERSION = "0.2.0" as const;
|
export const ENGINE_VERSION = "0.2.2" as const;
|
||||||
|
|
||||||
export type GraphMode = "scan" | "explore";
|
export type GraphMode = "scan" | "explore";
|
||||||
export type EvidenceLevel = "observed" | "derived" | "verified" | "heuristic" | "external";
|
export type EvidenceLevel = "observed" | "derived" | "verified" | "heuristic" | "external";
|
||||||
@@ -73,7 +73,7 @@ export interface RuntimeContext {
|
|||||||
authContext: "anonymous" | "storage_state";
|
authContext: "anonymous" | "storage_state";
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LocatorDescriptor =
|
export type BaseLocatorDescriptor =
|
||||||
| { kind: "role"; role: string; name?: string }
|
| { kind: "role"; role: string; name?: string }
|
||||||
| { kind: "label"; label: string }
|
| { kind: "label"; label: string }
|
||||||
| { kind: "placeholder"; placeholder: string }
|
| { kind: "placeholder"; placeholder: string }
|
||||||
@@ -81,6 +81,10 @@ export type LocatorDescriptor =
|
|||||||
| { kind: "testId"; testId: string }
|
| { kind: "testId"; testId: string }
|
||||||
| { kind: "css"; selector: string };
|
| { kind: "css"; selector: string };
|
||||||
|
|
||||||
|
export type LocatorDescriptor =
|
||||||
|
| BaseLocatorDescriptor
|
||||||
|
| { kind: "scoped"; scope: LocatorScopeDescriptor; target: BaseLocatorDescriptor };
|
||||||
|
|
||||||
export type LocatorFramework = "playwright" | "css";
|
export type LocatorFramework = "playwright" | "css";
|
||||||
|
|
||||||
export interface LocatorScopeDescriptor {
|
export interface LocatorScopeDescriptor {
|
||||||
@@ -229,6 +233,18 @@ export interface TopologyGraph {
|
|||||||
edges: ActionEdge[];
|
edges: ActionEdge[];
|
||||||
skippedActions: SkippedAction[];
|
skippedActions: SkippedAction[];
|
||||||
failedObservations: FailedObservation[];
|
failedObservations: FailedObservation[];
|
||||||
|
summary: GraphSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphSummary {
|
||||||
|
states: number;
|
||||||
|
elements: number;
|
||||||
|
edges: number;
|
||||||
|
skippedActions: number;
|
||||||
|
failedObservations: number;
|
||||||
|
failedObservationsByReason: Partial<Record<FailedObservation["reason"], number>>;
|
||||||
|
skippedActionsByRiskCategory: Partial<Record<RiskCategory, number>>;
|
||||||
|
edgesByRiskCategory: Partial<Record<RiskCategory, number>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkippedAction {
|
export interface SkippedAction {
|
||||||
@@ -245,7 +261,7 @@ export interface SkippedAction {
|
|||||||
|
|
||||||
export interface FailedObservation {
|
export interface FailedObservation {
|
||||||
failedObservationId: string;
|
failedObservationId: string;
|
||||||
stateId: string;
|
stateId: string | null;
|
||||||
elementId: string | null;
|
elementId: string | null;
|
||||||
actionType: ActionType | null;
|
actionType: ActionType | null;
|
||||||
reason:
|
reason:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { mkdtemp, rm } from "node:fs/promises";
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { createServer, type Server } from "node:http";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
@@ -21,6 +22,20 @@ async function withTempOutput<T>(name: string, callback: (out: string) => Promis
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function withServer<T>(handler: Parameters<typeof createServer>[0], callback: (url: string) => Promise<T>): Promise<T> {
|
||||||
|
const server = createServer(handler);
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const address = server.address();
|
||||||
|
assert.ok(address && typeof address === "object");
|
||||||
|
try {
|
||||||
|
return await callback(`http://127.0.0.1:${address.port}/`);
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
(server as Server).close((error) => (error ? reject(error) : resolve()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function hasEdgeForAccessibleName(
|
function hasEdgeForAccessibleName(
|
||||||
graph: {
|
graph: {
|
||||||
edges: Array<{ elementId: string }>;
|
edges: Array<{ elementId: string }>;
|
||||||
@@ -73,7 +88,7 @@ test("duplicated row actions are verified with semantic locator evidence", async
|
|||||||
test("graph metadata records generation context and policy versions", async () => {
|
test("graph metadata records generation context and policy versions", async () => {
|
||||||
const graph = await withTempOutput("metadata", (out) => scanUrl({ url: fixturePath, out }));
|
const graph = await withTempOutput("metadata", (out) => scanUrl({ url: fixturePath, out }));
|
||||||
assert.equal(graph.schemaVersion, "0.2");
|
assert.equal(graph.schemaVersion, "0.2");
|
||||||
assert.equal(graph.metadata.engineVersion, "0.2.0");
|
assert.equal(graph.metadata.engineVersion, "0.2.2");
|
||||||
assert.equal(graph.metadata.runtimeContext.browserName, "chromium");
|
assert.equal(graph.metadata.runtimeContext.browserName, "chromium");
|
||||||
assert.equal(graph.metadata.runtimeContext.viewport.width, 1440);
|
assert.equal(graph.metadata.runtimeContext.viewport.width, 1440);
|
||||||
assert.equal(graph.metadata.runtimeContext.viewport.height, 1000);
|
assert.equal(graph.metadata.runtimeContext.viewport.height, 1000);
|
||||||
@@ -297,6 +312,7 @@ test("explore artifact preserves referential integrity and snapshot-diff effect
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const failure of graph.failedObservations) {
|
for (const failure of graph.failedObservations) {
|
||||||
|
if (failure.stateId === null) continue;
|
||||||
assert.equal(stateIds.has(failure.stateId), true);
|
assert.equal(stateIds.has(failure.stateId), true);
|
||||||
if (failure.elementId === null) continue;
|
if (failure.elementId === null) continue;
|
||||||
const element = graph.elements.find((item) => item.elementId === failure.elementId);
|
const element = graph.elements.find((item) => item.elementId === failure.elementId);
|
||||||
@@ -385,6 +401,35 @@ test("explore records locator failures instead of edges or fake effects for unsa
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("explore records replay navigation timeout as a failed observation", async () => {
|
||||||
|
let requestCount = 0;
|
||||||
|
await withServer(
|
||||||
|
(_request, response) => {
|
||||||
|
requestCount += 1;
|
||||||
|
if (requestCount === 1) {
|
||||||
|
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||||||
|
response.end("<!doctype html><button>Open panel</button>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Leave replay navigation hanging so the engine must record a timeout.
|
||||||
|
},
|
||||||
|
async (url) => {
|
||||||
|
const graph = await withTempOutput("explore-navigation-timeout", (out) =>
|
||||||
|
exploreUrl({ url, maxActions: 1, navigationTimeoutMs: 50, out }),
|
||||||
|
);
|
||||||
|
const button = graph.elements.find((element) => element.accessibleName === "Open panel");
|
||||||
|
assert.ok(button);
|
||||||
|
assert.equal(graph.edges.length, 0);
|
||||||
|
assert.equal(
|
||||||
|
graph.failedObservations.some(
|
||||||
|
(failure) => failure.elementId === button.elementId && failure.reason === "navigation_timeout",
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("explore records state-local skipped high-risk actions with evidence", async () => {
|
test("explore records state-local skipped high-risk actions with evidence", async () => {
|
||||||
const graph = await withTempOutput("explore-skips", (out) => exploreUrl({ url: fixturePath, maxActions: 12, out }));
|
const graph = await withTempOutput("explore-skips", (out) => exploreUrl({ url: fixturePath, maxActions: 12, out }));
|
||||||
assert.equal(graph.skippedActions.some((skip) => skip.reason.includes("form_submit")), true);
|
assert.equal(graph.skippedActions.some((skip) => skip.reason.includes("form_submit")), true);
|
||||||
|
|||||||
@@ -91,6 +91,45 @@ test("locator candidates carry context-scope evidence for duplicated actions", (
|
|||||||
assert.equal(candidates[0].scopes.some((scope) => scope.kind === "row"), true);
|
assert.equal(candidates[0].scopes.some((scope) => scope.kind === "row"), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("scoped row locator resolves duplicated row actions to one target", async () => {
|
||||||
|
await withPage(
|
||||||
|
`
|
||||||
|
<table>
|
||||||
|
<tr><td>Acme Co</td><td><button>Edit</button></td></tr>
|
||||||
|
<tr><td>Beta LLC</td><td><button>Edit</button></td></tr>
|
||||||
|
</table>
|
||||||
|
`,
|
||||||
|
async (page) => {
|
||||||
|
const rawElement = raw({
|
||||||
|
uid: "button_1_Edit",
|
||||||
|
candidateIndex: 1,
|
||||||
|
tag: "button",
|
||||||
|
role: "button",
|
||||||
|
accessibleName: "Edit",
|
||||||
|
identityText: "Edit",
|
||||||
|
text: "Edit",
|
||||||
|
attributes: {},
|
||||||
|
context: {
|
||||||
|
dialog: null,
|
||||||
|
form: null,
|
||||||
|
section: null,
|
||||||
|
row: { kind: "row", name: null, text: "Acme Co Edit" },
|
||||||
|
landmark: null,
|
||||||
|
listitem: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const scoped = generateLocators("el_scoped_edit", rawElement).find((candidate) => candidate.strategy === "scoped");
|
||||||
|
assert.ok(scoped);
|
||||||
|
|
||||||
|
const [verified] = await verifyLocators(page, rawElement, [scoped]);
|
||||||
|
|
||||||
|
assert.equal(verified.verified, true);
|
||||||
|
assert.equal(verified.matchCount, 1);
|
||||||
|
assert.equal(verified.identity?.sameAccessibleName, true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("placeholder-only input locator verifies identity from raw placeholder evidence", async () => {
|
test("placeholder-only input locator verifies identity from raw placeholder evidence", async () => {
|
||||||
await withPage(`<input placeholder="Search customers" />`, async (page) => {
|
await withPage(`<input placeholder="Search customers" />`, async (page) => {
|
||||||
const rawElement = raw({
|
const rawElement = raw({
|
||||||
|
|||||||
@@ -22,6 +22,21 @@ test("crawl session id is unique for each generated graph", () => {
|
|||||||
assert.notEqual(first.metadata.crawlSessionId, second.metadata.crawlSessionId);
|
assert.notEqual(first.metadata.crawlSessionId, second.metadata.crawlSessionId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("empty graph starts with a machine-readable summary", () => {
|
||||||
|
const graph = buildEmptyGraph(graphArgs);
|
||||||
|
|
||||||
|
assert.deepEqual(graph.summary, {
|
||||||
|
states: 0,
|
||||||
|
elements: 0,
|
||||||
|
edges: 0,
|
||||||
|
skippedActions: 0,
|
||||||
|
failedObservations: 0,
|
||||||
|
failedObservationsByReason: {},
|
||||||
|
skippedActionsByRiskCategory: {},
|
||||||
|
edgesByRiskCategory: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function withFixedDate<T>(callback: () => T): T {
|
function withFixedDate<T>(callback: () => T): T {
|
||||||
const RealDate = Date;
|
const RealDate = Date;
|
||||||
const fixedTime = new RealDate("2026-06-16T00:00:00.000Z").getTime();
|
const fixedTime = new RealDate("2026-06-16T00:00:00.000Z").getTime();
|
||||||
|
|||||||
21
tests/schema.test.ts
Normal file
21
tests/schema.test.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import Ajv from "ajv";
|
||||||
|
import { scanUrl } from "../src/engine.js";
|
||||||
|
|
||||||
|
test("topology graph JSON schema validates emitted scan artifacts", async () => {
|
||||||
|
const schema = JSON.parse(await readFile("schemas/topology-graph.schema.json", "utf8"));
|
||||||
|
const ajv = new Ajv({ allErrors: true });
|
||||||
|
const validate = ajv.compile(schema);
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "action-topology-schema-test-"));
|
||||||
|
try {
|
||||||
|
const graph = await scanUrl({ url: "examples/simple.html", out: path.join(dir, "graph.json") });
|
||||||
|
|
||||||
|
assert.equal(validate(graph), true, JSON.stringify(validate.errors, null, 2));
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user